24 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
75 changed files with 2001 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:
#指定主键生成策略 #指定主键生成策略
@@ -56,29 +56,31 @@ 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 @@
95428 116212

Binary file not shown.

Binary file not shown.

View File

@@ -14,3 +14,5 @@
.\binlog.000036 .\binlog.000036
.\binlog.000037 .\binlog.000037
.\binlog.000038 .\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=false 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=false 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 必填 id: string
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)

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,6 +494,8 @@ 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) {
@@ -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连接
}) })

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,7 +39,14 @@
}" }"
/> />
<!-- 节点名称 --> <!-- 节点名称 -->
<span>{{ node.label }}</span> <span class="node-label">{{ node.label }}</span>
<span class="node-actions">
<PieChart
v-if="!isCompareMode && isCompletedPlanNode(node.data)"
class="node-action-icon"
@click.stop="openStatistics(node.data)"
style="margin-right: 8px"
/>
<!-- 子节点右侧图标 + tooltip --> <!-- 子节点右侧图标 + tooltip -->
<el-tooltip <el-tooltip
v-if=" v-if="
@@ -52,43 +59,39 @@
:manual="true" :manual="true"
content="子计划信息" content="子计划信息"
> >
<List <List class="node-action-icon" @click.stop="childDetail(node.data)" />
@click.stop="childDetail(node.data)"
style="
width: 16px;
height: 16px;
margin-left: 8px;
cursor: pointer;
color: var(--el-color-primary);
"
/>
</el-tooltip> </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,10 +464,12 @@ const getPieData = async (id: string) => {
planName.value = '所选计划:' planName.value = '所选计划:'
} }
if (pieRef1.value && pieRef2.value && pieRef3.value) {
pieRef1.value.init() pieRef1.value.init()
pieRef2.value.init() pieRef2.value.init()
pieRef3.value.init() pieRef3.value.init()
} }
}
/** /**
* 初始化树组件数据 * 初始化树组件数据
* @param data - 计划数据 * @param data - 计划数据

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>

View File

@@ -228,7 +228,7 @@ const unit = [
}, },
{ {
label: '功率', label: '功率',
unit: 'W' unit: props.valueCode == 'Absolute' ? 'W' : '%Un*In'
}, },
{ {
label: '电压偏差', label: '电压偏差',

View File

@@ -10,7 +10,7 @@
class="form-three" class="form-three"
> >
<el-form-item label="设备类型" prop="devType"> <el-form-item label="设备类型" prop="devType">
<el-select v-model="formContent.devType" placeholder="请选择源型"> <el-select v-model="formContent.devType" placeholder="请选择源设备类型">
<el-option <el-option
v-for="item in dictStore.getDictData(dictTypeCode)" v-for="item in dictStore.getDictData(dictTypeCode)"
:key="item.id" :key="item.id"
@@ -29,6 +29,24 @@
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="最大电压" prop="maxVoltage">
<el-input-number
v-model="formContent.maxVoltage"
:min="0"
:precision="2"
:step="0.1"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="最大电流" prop="maxCurrent">
<el-input-number
v-model="formContent.maxCurrent"
:min="0"
:precision="2"
:step="0.1"
style="width: 100%"
/>
</el-form-item>
</el-form> </el-form>
</div> </div>
@@ -49,12 +67,12 @@
<script lang="ts" setup name="ErrorSystemDialog"> <script lang="ts" setup name="ErrorSystemDialog">
import { ElMessage, type FormItemRule } from 'element-plus' import { ElMessage, type FormItemRule } from 'element-plus'
import { computed, Ref, ref } from 'vue' import { computed, ref, type Ref } from 'vue'
import { dialogBig } from '@/utils/elementBind' import { dialogBig } from '@/utils/elementBind'
import { addTestSource, getTestSourceById, updateTestSource } from '@/api/device/testSource/index' import { addTestSource, getTestSourceById, updateTestSource } from '@/api/device/testSource/index'
import { useDictStore } from '@/stores/modules/dict'
import { type TestSource } from '@/api/device/interface/testSource' import { type TestSource } from '@/api/device/interface/testSource'
// 定义弹出组件元信息 import { useDictStore } from '@/stores/modules/dict'
const dialogFormRef = ref() const dialogFormRef = ref()
const dictStore = useDictStore() const dictStore = useDictStore()
const mode = ref() const mode = ref()
@@ -71,13 +89,15 @@ function useMetaInfo() {
parameter: '', parameter: '',
type: '', type: '',
devType: '', devType: '',
maxVoltage: undefined,
maxCurrent: undefined,
state: 1 state: 1
}) })
return { dialogVisible, titleType, formContent } return { dialogVisible, titleType, formContent }
} }
const { dialogVisible, titleType, formContent } = useMetaInfo() const { dialogVisible, titleType, formContent } = useMetaInfo()
// 清空formContent
const resetFormContent = () => { const resetFormContent = () => {
formContent.value = { formContent.value = {
id: '', id: '',
@@ -85,11 +105,13 @@ const resetFormContent = () => {
parameter: '', parameter: '',
type: '', type: '',
devType: '', devType: '',
maxVoltage: undefined,
maxCurrent: undefined,
state: 1 state: 1
} }
} }
let dialogTitle = computed(() => { const dialogTitle = computed(() => {
switch (titleType.value) { switch (titleType.value) {
case 'add': case 'add':
tableIsDisable.value = false tableIsDisable.value = false
@@ -101,45 +123,52 @@ let dialogTitle = computed(() => {
tableIsDisable.value = true tableIsDisable.value = true
return '查看检测源' return '查看检测源'
default: default:
return '' // 默认情况,可选 return ''
} }
}) })
let dictTypeCode = computed(() => { const dictTypeCode = computed(() => {
return 'S_Dev_Type_' + dictStore.getDictData('Pattern').find(item => item.id === modeId.value)?.code return 'S_Dev_Type_' + dictStore.getDictData('Pattern').find(item => item.id === modeId.value)?.code
}) })
// 定义规则
const validateNonNegative = (label: string) => {
return (_rule: FormItemRule, value: number | undefined, callback: (error?: Error) => void) => {
if (value != null && value < 0) {
callback(new Error(`${label} cannot be negative`))
return
}
callback()
}
}
const rules: Ref<Record<string, Array<FormItemRule>>> = ref({ const rules: Ref<Record<string, Array<FormItemRule>>> = ref({
name: [{ required: true, message: '检测源名称必填', trigger: 'blur' }], name: [{ required: true, message: '检测源名称必填', trigger: 'blur' }],
devType: [{ required: true, message: '请选择一项设备类型', trigger: 'change' }], devType: [{ required: true, message: '请选择一项设备类型', trigger: 'change' }],
type: [{ required: true, message: '请选择一项检测源类型', trigger: 'change ' }] type: [{ required: true, message: '请选择一项检测源类型', trigger: 'change' }],
maxVoltage: [{ validator: validateNonNegative('最大电压'), trigger: 'change' }],
maxCurrent: [{ validator: validateNonNegative('最大电流'), trigger: 'change' }]
}) })
// 关闭弹窗
const close = () => { const close = () => {
dialogVisible.value = false dialogVisible.value = false
// 清空dialogForm中的值
resetFormContent() resetFormContent()
// 重置表单
dialogFormRef.value?.resetFields() dialogFormRef.value?.resetFields()
parameterTable.value?.clearData() parameterTable.value?.clearData()
} }
// 保存数据
const save = () => { const save = () => {
try { try {
dialogFormRef.value?.validate(async (valid: boolean) => { dialogFormRef.value?.validate(async (valid: boolean) => {
if (valid) { if (valid) {
if (formContent.value.id) { if (formContent.value.id) {
await updateTestSource(formContent.value) await updateTestSource(formContent.value)
ElMessage.success({ message: `${dialogTitle.value}成功` }) ElMessage.success({ message: `${dialogTitle.value}成功` })
} else { } else {
await addTestSource(formContent.value) await addTestSource(formContent.value)
ElMessage.success({ message: `${dialogTitle.value}成功` }) ElMessage.success({ message: `${dialogTitle.value}成功` })
} }
close() close()
// 刷新表格 await props.refreshTable?.()
await props.refreshTable!()
} }
}) })
} catch (err) { } catch (err) {
@@ -147,7 +176,6 @@ const save = () => {
} }
} }
// 打开弹窗,可能是新增,也可能是编辑
const open = async (sign: string, data: TestSource.ResTestSource, currentMode: string) => { const open = async (sign: string, data: TestSource.ResTestSource, currentMode: string) => {
titleType.value = sign titleType.value = sign
dialogVisible.value = true dialogVisible.value = true
@@ -155,13 +183,25 @@ const open = async (sign: string, data: TestSource.ResTestSource, currentMode: s
modeId.value = dictStore.getDictData('Pattern').find(item => item.name === currentMode)?.id modeId.value = dictStore.getDictData('Pattern').find(item => item.name === currentMode)?.id
if (data.id) { if (data.id) {
const result = await getTestSourceById(data) const result = await getTestSourceById(data)
if (result && result.data) { const sourceData = (result?.data ?? {}) as Partial<TestSource.ResTestSource>
formContent.value = result.data as TestSource.ResTestSource formContent.value = {
id: sourceData.id ?? data.id,
pattern: sourceData.pattern ?? modeId.value,
parameter: sourceData.parameter ?? '',
type: sourceData.type ?? '',
devType: sourceData.devType ?? '',
maxVoltage: sourceData.maxVoltage ?? undefined,
maxCurrent: sourceData.maxCurrent ?? undefined,
state: sourceData.state ?? 1,
name: sourceData.name,
createBy: sourceData.createBy,
createTime: sourceData.createTime,
updateBy: sourceData.updateBy,
updateTime: sourceData.updateTime
} }
} else { } else {
resetFormContent() resetFormContent()
} }
// 重置表单
dialogFormRef.value?.resetFields() dialogFormRef.value?.resetFields()
} }
@@ -169,7 +209,6 @@ const changeParameter = (parameterArr: any) => {
formContent.value.parameter = JSON.stringify(parameterArr) formContent.value.parameter = JSON.stringify(parameterArr)
} }
// 对外映射
defineExpose({ open }) defineExpose({ open })
const props = defineProps<{ const props = defineProps<{
refreshTable: (() => Promise<void>) | undefined refreshTable: (() => Promise<void>) | undefined

View File

@@ -1,83 +1,89 @@
<template> <template>
<div class='table-box'> <div class="table-box">
<ProTable <ProTable ref="proTable" :columns="columns" :request-api="getTableList">
ref='proTable' <template #tableHeader="scope">
:columns='columns' <el-button v-auth.testSource="'add'" type="primary" :icon="CirclePlus" @click="openDialog('add')">
:request-api="getTableList" 新增
</el-button>
<el-button
v-auth.testSource="'delete'"
type="danger"
:icon="Delete"
plain
:disabled="!scope.isSelected"
@click="batchDelete(scope.selectedListIds)"
> >
<!-- :data='testSourceData' 如果要显示静态数据就切换该配置-->
<!-- 表格 header 按钮 -->
<template #tableHeader='scope'>
<el-button v-auth.testSource="'add'" type='primary' :icon='CirclePlus' @click="openDialog('add')">新增</el-button>
<el-button v-auth.testSource="'delete'" type='danger' :icon='Delete'
plain :disabled='!scope.isSelected' @click='batchDelete(scope.selectedListIds)'>
删除 删除
</el-button> </el-button>
</template> </template>
<!-- 表格操作 --> <template #operation="scope">
<template #operation='scope'> <el-button v-auth.testSource="'view'" type="primary" link :icon="View" @click="openDialog('view', scope.row)">
<el-button v-auth.testSource="'view'" type='primary' link :icon='View' @click="openDialog('view', scope.row)">查看</el-button> 查看
<el-button v-auth.testSource="'edit'" type='primary' link :icon='EditPen' @click="openDialog('edit', scope.row)">编辑</el-button> </el-button>
<el-button v-auth.testSource="'delete'" type='primary' link :icon='Delete' @click='handleDelete(scope.row)'>删除</el-button> <el-button v-auth.testSource="'edit'" type="primary" link :icon="EditPen" @click="openDialog('edit', scope.row)">
编辑
</el-button>
<el-button
v-auth.testSource="'delete'"
type="primary"
link
:icon="Delete"
@click="handleDelete(scope.row)"
>
删除
</el-button>
</template> </template>
</ProTable> </ProTable>
</div> </div>
<TestSourcePopup :refresh-table='proTable?.getTableList' ref='testSourcePopup' /> <TestSourcePopup :refresh-table="proTable?.getTableList" ref="testSourcePopup" />
</template> </template>
<script setup lang='tsx' name='useRole'> <script setup lang="tsx" name="useRole">
import { type TestSource } from '@/api/device/interface/testSource' import { type TestSource } from '@/api/device/interface/testSource'
import { useHandleData } from '@/hooks/useHandleData' import { useHandleData } from '@/hooks/useHandleData'
import { useDownload } from '@/hooks/useDownload'
import { useAuthButtons } from '@/hooks/useAuthButtons'
import ProTable from '@/components/ProTable/index.vue' import ProTable from '@/components/ProTable/index.vue'
import ImportExcel from '@/components/ImportExcel/index.vue'
import type { ProTableInstance, ColumnProps } from '@/components/ProTable/interface' import type { ProTableInstance, ColumnProps } from '@/components/ProTable/interface'
import { CirclePlus, Delete, EditPen, Share, Download, Upload, View, Refresh } from '@element-plus/icons-vue' import { CirclePlus, Delete, EditPen, View } from '@element-plus/icons-vue'
import { useDictStore } from '@/stores/modules/dict' import { useDictStore } from '@/stores/modules/dict'
import TestSourcePopup from './components/testSourcePopup.vue' import TestSourcePopup from './components/testSourcePopup.vue'
import { import { getTestSourceList, deleteTestSource } from '@/api/device/testSource/index'
getTestSourceList,deleteTestSource,
} from '@/api/device/testSource/index'
import { reactive, ref } from 'vue' import { reactive, ref } from 'vue'
import { useModeStore } from '@/stores/modules/mode'; // 引入模式 store import { useModeStore } from '@/stores/modules/mode'
defineOptions({ defineOptions({
name: 'testSource' name: 'testSource'
}) })
const testSourcePopup = ref() const testSourcePopup = ref()
const dictStore = useDictStore() const dictStore = useDictStore()
const modeStore = useModeStore(); const modeStore = useModeStore()
// ProTable 实例
const proTable = ref<ProTableInstance>() const proTable = ref<ProTableInstance>()
const getTableList = (params: any) => { const getTableList = (params: any) => {
const newParams = JSON.parse(JSON.stringify(params))
let newParams = JSON.parse(JSON.stringify(params)) const patternId = dictStore.getDictData('Pattern').find(item => item.name === modeStore.currentMode)?.id
const patternId = dictStore.getDictData('Pattern').find(item=>item.name=== modeStore.currentMode)?.id//获取数据字典中对应的id
newParams.pattern = patternId newParams.pattern = patternId
return getTestSourceList(newParams) return getTestSourceList(newParams)
} }
// 表格配置项
const columns = reactive<ColumnProps<TestSource.ResTestSource>[]>([ const columns = reactive<ColumnProps<TestSource.ResTestSource>[]>([
{ type: 'selection', fixed: 'left', width: 70 , { type: 'selection', fixed: 'left', width: 70 },
},
{ type: 'index', fixed: 'left', width: 70, label: '序号' }, { type: 'index', fixed: 'left', width: 70, label: '序号' },
{ {
prop: 'name', prop: 'name',
label: '名称', label: '名称',
search: { el: 'input' }, search: { el: 'input' },
minWidth: 300, minWidth: 300
}, },
{ {
prop: 'devType', prop: 'devType',
label: '设备类型', label: '设备类型',
enum: dictStore.getDictData('S_Dev_Type_'+dictStore.getDictData('Pattern').find(item=>item.name=== modeStore.currentMode)?.code), enum: dictStore.getDictData(
'S_Dev_Type_' + dictStore.getDictData('Pattern').find(item => item.name === modeStore.currentMode)?.code
),
fieldNames: { label: 'name', value: 'id' }, fieldNames: { label: 'name', value: 'id' },
search: { el: 'select' }, search: { el: 'select' },
minWidth: 250, minWidth: 250
}, },
{ {
prop: 'type', prop: 'type',
@@ -85,31 +91,33 @@
enum: dictStore.getDictData('Pq_Source_Type'), enum: dictStore.getDictData('Pq_Source_Type'),
fieldNames: { label: 'name', value: 'id' }, fieldNames: { label: 'name', value: 'id' },
search: { el: 'select' }, search: { el: 'select' },
minWidth: 150, minWidth: 150
}, },
{ prop: 'operation', label: '操作', fixed: 'right', width: 250 }, {
prop: 'maxVoltage',
label: '最大电压(V)',
minWidth: 140
},
{
prop: 'maxCurrent',
label: '最大电流(A)',
minWidth: 140
},
{ prop: 'operation', label: '操作', fixed: 'right', width: 250 }
]) ])
// 打开 drawer(新增、编辑)
const openDialog = (titleType: string, row: Partial<TestSource.ResTestSource> = {}) => { const openDialog = (titleType: string, row: Partial<TestSource.ResTestSource> = {}) => {
testSourcePopup.value?.open(titleType, row, modeStore.currentMode) testSourcePopup.value?.open(titleType, row, modeStore.currentMode)
} }
// 批量删除设备
const batchDelete = async (id: string[]) => { const batchDelete = async (id: string[]) => {
await useHandleData(deleteTestSource, id, '删除所选检测源') await useHandleData(deleteTestSource, id, '删除所选检测源')
proTable.value?.clearSelection() proTable.value?.clearSelection()
proTable.value?.getTableList() proTable.value?.getTableList()
} }
// 删除设备
const handleDelete = async (params: TestSource.ResTestSource) => { const handleDelete = async (params: TestSource.ResTestSource) => {
await useHandleData(deleteTestSource, [params.id], `删除【${params.name}】检测源`) await useHandleData(deleteTestSource, [params.id], `删除【${params.name}】检测源`)
proTable.value?.getTableList() proTable.value?.getTableList()
} }
</script> </script>

View File

@@ -933,7 +933,7 @@ const open = async (sign: string, data: Plan.ReqPlan, currentMode: string, plan:
const datasourceDicts = dictStore.getDictData('Datasource') const datasourceDicts = dictStore.getDictData('Datasource')
formContent.datasourceIds = datasourceDicts formContent.datasourceIds = datasourceDicts
.filter(item => ['real', 'wave_data'].includes(item.code)) .filter(item => ['real'].includes(item.code))
.map(item => item.code) .map(item => item.code)
realTimeSetting.value = true realTimeSetting.value = true

View File

@@ -0,0 +1,505 @@
<template>
<el-dialog
v-model="dialogVisible"
:title="`检测计划统计 - ${planName || '/'}`"
width="min(1280px, 92vw)"
class="plan-statistics-dialog"
destroy-on-close
draggable
@closed="handleClosed"
>
<div v-loading="loading" class="plan-statistics">
<el-empty v-if="loadFailed" description="统计数据加载失败" />
<template v-else>
<el-form class="filter-bar" :model="filters" inline label-width="72px">
<el-form-item label="设备厂家">
<el-select
v-model="filters.manufacturer"
filterable
placeholder="全部"
class="filter-select"
@change="reloadStatistics('manufacturer')"
>
<el-option label="全部" value="" />
<el-option
v-for="item in manufacturerOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="设备类型">
<el-select
v-model="filters.devType"
filterable
placeholder="全部"
class="filter-select"
@change="reloadStatistics('devType')"
>
<el-option label="全部" value="" />
<el-option
v-for="item in devTypeOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-form>
<div class="summary-grid">
<div v-for="item in summaryItems" :key="item.label" class="summary-item" :class="item.type">
<span class="summary-label">{{ item.label }}</span>
<strong class="summary-value">{{ item.value }}</strong>
</div>
</div>
<div v-if="isEmpty" class="empty-area">
<el-empty description="暂无统计数据" />
</div>
<template v-else>
<div class="chart-grid">
<div class="chart-panel">
<div class="panel-title">合格率</div>
<div ref="rateChartRef" class="chart"></div>
</div>
<div class="chart-panel">
<div class="panel-title">检测大项不合格分布</div>
<div ref="itemChartRef" class="chart"></div>
</div>
</div>
</template>
</template>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'
import * as echarts from 'echarts'
import { ElMessage } from 'element-plus'
import { getPlanStatistics } from '@/api/plan/plan'
import type { Plan } from '@/api/plan/interface'
interface SelectOption {
id: string
name: string
}
type FilterField = 'manufacturer' | 'devType'
const emptyStatistics = (): Plan.PlanStatistics => ({
planId: '',
planName: '',
totalCheckCount: 0,
checkedDeviceCount: 0,
uncheckedDeviceCount: 0,
firstQualifiedDeviceCount: 0,
secondQualifiedDeviceCount: 0,
thirdOrMoreQualifiedDeviceCount: 0,
qualifiedDeviceCount: 0,
unqualifiedDeviceCount: 0,
unqualifiedItemCount: 0,
firstPassRate: 0,
secondPassRate: 0,
thirdOrMorePassRate: 0,
unqualifiedRate: 0,
itemDistributions: [],
manufacturerOptions: [],
devTypeOptions: []
})
const dialogVisible = ref(false)
const loading = ref(false)
const loadFailed = ref(false)
const planName = ref('')
const currentPlanId = ref('')
const rateChartRef = ref<HTMLDivElement>()
const itemChartRef = ref<HTMLDivElement>()
const statisticsData = reactive<Plan.PlanStatistics>(emptyStatistics())
const filters = reactive({
manufacturer: '',
devType: ''
})
const manufacturerOptions = ref<SelectOption[]>([])
const devTypeOptions = ref<SelectOption[]>([])
let rateChart: echarts.ECharts | null = null
let itemChart: echarts.ECharts | null = null
const isEmpty = computed(() => {
return (
!loading.value &&
statisticsData.totalCheckCount === 0 &&
statisticsData.checkedDeviceCount === 0 &&
statisticsData.itemDistributions.length === 0
)
})
const summaryItems = computed(() => [
{ label: '未检设备', value: statisticsData.uncheckedDeviceCount },
{ label: '已检设备', value: statisticsData.checkedDeviceCount },
{ label: '合格设备', value: statisticsData.qualifiedDeviceCount, type: 'is-qualified' },
{ label: '不合格设备', value: statisticsData.unqualifiedDeviceCount, type: 'is-unqualified' }
])
const resetData = () => {
Object.assign(statisticsData, emptyStatistics())
}
const formatRate = (value: number | string | null | undefined) => {
const numberValue = Number(value)
if (!Number.isFinite(numberValue)) return '0%'
return `${numberValue.toFixed(2)}%`
}
const normalizeRate = (value: number | string | null | undefined) => {
const numberValue = Number(value)
return Number.isFinite(numberValue) ? numberValue : 0
}
const open = async (row: Partial<Plan.ReqPlan>) => {
if (!row.id) {
ElMessage.error('计划信息缺失,无法统计')
return
}
resetData()
disposeCharts()
loadFailed.value = false
currentPlanId.value = row.id
filters.manufacturer = ''
filters.devType = ''
planName.value = row.name || ''
dialogVisible.value = true
await loadStatistics()
}
const reloadStatistics = async (changedField?: FilterField) => {
if (!dialogVisible.value || !currentPlanId.value) return
await loadStatistics(changedField)
}
const loadStatistics = async (changedField?: FilterField) => {
loading.value = true
try {
const { data } = await getPlanStatistics({
planId: currentPlanId.value,
manufacturer: filters.manufacturer || undefined,
devType: filters.devType || undefined
})
const nextManufacturerOptions = data?.manufacturerOptions || []
const nextDevTypeOptions = data?.devTypeOptions || []
Object.assign(statisticsData, {
...emptyStatistics(),
...data,
itemDistributions: data?.itemDistributions || [],
manufacturerOptions: nextManufacturerOptions,
devTypeOptions: nextDevTypeOptions
})
manufacturerOptions.value = nextManufacturerOptions
devTypeOptions.value = nextDevTypeOptions
if (clearInvalidFilters(changedField)) {
await loadStatistics()
return
}
await nextTick()
renderCharts()
} catch (error) {
loadFailed.value = true
ElMessage.error('统计数据加载失败')
} finally {
loading.value = false
}
}
const clearInvalidFilters = (changedField?: FilterField) => {
let cleared = false
if (
changedField !== 'manufacturer' &&
filters.manufacturer &&
!manufacturerOptions.value.some(item => item.id === filters.manufacturer)
) {
filters.manufacturer = ''
cleared = true
}
if (changedField !== 'devType' && filters.devType && !devTypeOptions.value.some(item => item.id === filters.devType)) {
filters.devType = ''
cleared = true
}
return cleared
}
const renderCharts = () => {
if (!dialogVisible.value || loadFailed.value || isEmpty.value) return
renderRateChart()
renderItemChart()
resizeCharts()
}
const renderRateChart = () => {
if (!rateChartRef.value) return
rateChart?.dispose()
rateChart = echarts.init(rateChartRef.value)
const rateData = [
{
name: '一次合格率',
value: normalizeRate(statisticsData.firstPassRate),
count: statisticsData.firstQualifiedDeviceCount
},
{
name: '二次合格率',
value: normalizeRate(statisticsData.secondPassRate),
count: statisticsData.secondQualifiedDeviceCount
},
{
name: '三次及以上合格率',
value: normalizeRate(statisticsData.thirdOrMorePassRate),
count: statisticsData.thirdOrMoreQualifiedDeviceCount
},
{
name: '不合格率',
value: normalizeRate(statisticsData.unqualifiedRate),
count: statisticsData.unqualifiedDeviceCount
}
]
rateChart.setOption({
tooltip: {
trigger: 'item',
formatter: (params: any) => {
return `${params.name}<br/>${formatRate(params.value)}<br/>设备数:${params.data?.count || 0}`
}
},
legend: { bottom: 0, left: 'center' },
color: ['#67c23a', '#409eff', '#e6a23c', '#f56c6c'],
series: [
{
name: '合格率',
type: 'pie',
radius: ['42%', '68%'],
center: ['50%', '43%'],
avoidLabelOverlap: true,
data: rateData,
label: {
formatter: ({ name, value }: any) => `${name}\n${formatRate(value)}`
}
}
]
})
}
const renderItemChart = () => {
if (!itemChartRef.value) return
itemChart?.dispose()
itemChart = echarts.init(itemChartRef.value)
const topItems = [...statisticsData.itemDistributions]
.sort((a, b) => (b.unqualifiedCount || 0) - (a.unqualifiedCount || 0))
.slice(0, 8)
itemChart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: 48, right: 20, top: 36, bottom: 48 },
xAxis: {
type: 'category',
data: topItems.map(item => item.itemName || '/'),
axisLabel: { interval: 0, rotate: 28 }
},
yAxis: { type: 'value', minInterval: 1 },
series: [
{
name: '不合格次数',
type: 'bar',
barWidth: 30,
data: topItems.map(item => item.unqualifiedCount || 0),
itemStyle: { color: '#f56c6c' }
}
]
})
}
const disposeCharts = () => {
rateChart?.dispose()
itemChart?.dispose()
rateChart = null
itemChart = null
}
const resizeCharts = () => {
rateChart?.resize()
itemChart?.resize()
}
const handleClosed = () => {
disposeCharts()
resetData()
loadFailed.value = false
currentPlanId.value = ''
}
onMounted(() => {
window.addEventListener('resize', resizeCharts)
})
onUnmounted(() => {
window.removeEventListener('resize', resizeCharts)
disposeCharts()
})
defineExpose({ open })
</script>
<style scoped>
:deep(.plan-statistics-dialog) {
max-width: 92vw;
}
:deep(.plan-statistics-dialog .el-dialog__body) {
padding: 14px;
}
.plan-statistics {
min-height: 0;
}
.filter-bar {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 12px;
}
.filter-select {
width: 220px;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.summary-item {
min-height: 64px;
padding: 10px 12px;
border: 1px solid var(--el-border-color-light);
border-radius: 6px;
background: var(--el-fill-color-lighter);
box-sizing: border-box;
}
.summary-label {
display: block;
color: var(--el-text-color-secondary);
font-size: 13px;
line-height: 18px;
}
.summary-value {
display: block;
margin-top: 6px;
color: var(--el-text-color-primary);
font-size: 20px;
line-height: 28px;
}
.summary-item.is-qualified {
border-color: var(--el-color-success-light-5);
background: var(--el-color-success-light-9);
}
.summary-item.is-qualified .summary-value {
color: var(--el-color-success);
}
.summary-item.is-unqualified {
border-color: var(--el-color-danger-light-5);
background: var(--el-color-danger-light-9);
}
.summary-item.is-unqualified .summary-value {
color: var(--el-color-danger);
}
.chart-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
}
.chart-panel {
border: 1px solid var(--el-border-color-light);
border-radius: 6px;
padding: 10px;
}
.panel-title {
color: var(--el-text-color-primary);
font-size: 14px;
font-weight: 600;
line-height: 20px;
}
.chart {
width: 100%;
height: 250px;
}
.empty-area {
display: flex;
align-items: center;
justify-content: center;
min-height: 320px;
}
@media (max-width: 1200px) {
.summary-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.chart-grid {
grid-template-columns: 1fr;
}
.chart {
height: 260px;
}
}
@media (max-width: 640px) {
:deep(.plan-statistics-dialog) {
width: 96vw;
max-width: 96vw;
}
:deep(.plan-statistics-dialog .el-dialog__body) {
max-height: calc(92vh - 110px);
overflow-y: auto;
padding: 12px;
}
.summary-grid {
gap: 8px;
}
.filter-select {
width: 100%;
}
.summary-item {
padding: 10px;
}
.summary-value {
font-size: 18px;
line-height: 24px;
}
}
</style>

View File

@@ -99,6 +99,16 @@
被检设备 被检设备
</el-button> </el-button>
<!-- <el-button type='primary' link :icon='List' @click='showDeviceOpen(scope.row)'>设备绑定</el-button> --> <!-- <el-button type='primary' link :icon='List' @click='showDeviceOpen(scope.row)'>设备绑定</el-button> -->
<el-button
type="primary"
v-auth.plan="'analysis'"
link
icon="PieChart"
v-if="(scope.row.testState == '1' || scope.row.testState == '2') && modeStore.currentMode != '比对式'"
@click="openStatistics(scope.row)"
>
统计
</el-button>
<el-button <el-button
type="primary" type="primary"
v-auth.plan="'analysis'" v-auth.plan="'analysis'"
@@ -136,6 +146,7 @@
<ImportExcel ref="planImportExcel" /> <ImportExcel ref="planImportExcel" />
<ImportZip ref="planImportZip" @result="importResult" /> <ImportZip ref="planImportZip" @result="importResult" />
<PlanStatisticsPopup ref="planStatisticsPopupRef" />
<ChildrenPlan <ChildrenPlan
:refresh-table="refreshTable" :refresh-table="refreshTable"
@@ -163,6 +174,7 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import type { Plan } from '@/api/plan/interface' import type { Plan } from '@/api/plan/interface'
import PlanPopup from '@/views/plan/planList/components/planPopup.vue' // 导入子组件 import PlanPopup from '@/views/plan/planList/components/planPopup.vue' // 导入子组件
import ChildrenPlan from '@/views/plan/planList/components/childrenPlan.vue' import ChildrenPlan from '@/views/plan/planList/components/childrenPlan.vue'
import PlanStatisticsPopup from '@/views/plan/planList/components/planStatisticsPopup.vue'
import { useViewSize } from '@/hooks/useViewSize' import { useViewSize } from '@/hooks/useViewSize'
import { useDictStore } from '@/stores/modules/dict' import { useDictStore } from '@/stores/modules/dict'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
@@ -187,6 +199,7 @@ const proTable = ref<ProTableInstance>()
const errorStandardPopup = ref() const errorStandardPopup = ref()
const testSourcePopup = ref() const testSourcePopup = ref()
const planPopup = ref() const planPopup = ref()
const planStatisticsPopupRef = ref<InstanceType<typeof PlanStatisticsPopup> | null>(null)
const modeStore = useModeStore() const modeStore = useModeStore()
const tableData = ref<any[]>([]) const tableData = ref<any[]>([])
@@ -530,7 +543,7 @@ const columns = reactive<ColumnProps<Plan.ReqPlan>[]>([
isShow: modeStore.currentMode == '比对式' isShow: modeStore.currentMode == '比对式'
}, },
{ prop: 'operation', label: '操作', fixed: 'right', minWidth: 250 } { prop: 'operation', label: '操作', fixed: 'right', minWidth: 320 }
]) ])
function isVisible(row: Plan.ReqPlan) { function isVisible(row: Plan.ReqPlan) {
@@ -654,6 +667,10 @@ const statisticalAnalysis = async (row: Partial<Plan.ReqPlan> = {}) => {
useDownload(staticsAnalyse, '分析结果', [row.id], false, '.xlsx') useDownload(staticsAnalyse, '分析结果', [row.id], false, '.xlsx')
} }
const openStatistics = (row: Partial<Plan.ReqPlan> = {}) => {
planStatisticsPopupRef.value?.open(row)
}
const importSubClick = () => { const importSubClick = () => {
const params = { const params = {
title: '导入检测计划', title: '导入检测计划',

View File

@@ -0,0 +1,182 @@
<template>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="520px"
:destroy-on-close="true"
:close-on-click-modal="!submitting"
draggable
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="资源名称" prop="name">
<el-input v-model="form.name" maxlength="250" placeholder="请输入资源名称" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" maxlength="250" placeholder="请输入备注" type="textarea" :rows="3" />
</el-form-item>
<el-form-item v-if="mode === 'add'" label="文件" prop="file">
<el-upload
ref="uploadRef"
action="#"
:auto-upload="false"
:limit="1"
accept=".mp4,video/mp4"
:file-list="fileList"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
:on-exceed="handleExceed"
>
<el-button type="primary" :icon="Upload">选择文件</el-button>
<template #tip>
<div class="el-upload__tip">仅支持 MP4 文件最大 250MB</div>
</template>
</el-upload>
</el-form-item>
</el-form>
<template #footer>
<el-button :disabled="submitting" @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submit">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import {
ElMessage,
genFileId,
type FormInstance,
type FormRules,
type UploadFile,
type UploadInstance,
type UploadProps,
type UploadRawFile
} from 'element-plus'
import { Upload } from '@element-plus/icons-vue'
import { addResourceManage, updateResourceManage } from '@/api/resourceManage'
import type { ResourceManage } from '@/api/resourceManage/interface'
const MAX_FILE_SIZE = 250 * 1024 * 1024
const props = defineProps<{
refreshTable?: () => void
}>()
const dialogVisible = ref(false)
const submitting = ref(false)
const mode = ref<'add' | 'edit'>('add')
const formRef = ref<FormInstance>()
const uploadRef = ref<UploadInstance>()
const fileList = ref<UploadFile[]>([])
const form = reactive<{
id: string
name: string
remark: string
file: File | null
}>({
id: '',
name: '',
remark: '',
file: null
})
const dialogTitle = computed(() => (mode.value === 'edit' ? '编辑资源' : '新增资源'))
const validateFile = (_rule: unknown, value: File | null, callback: (error?: Error) => void) => {
if (!value) {
callback(new Error('请选择 MP4 文件'))
return
}
callback()
}
const rules = reactive<FormRules>({
name: [{ required: true, message: '请输入资源名称', trigger: 'blur' }],
remark: [{ required: true, message: '请输入备注', trigger: 'blur' }],
file: [{ validator: validateFile, trigger: 'change' }]
})
const open = (type: 'add' | 'edit' = 'add', row?: ResourceManage.ResResourceManage) => {
mode.value = type
form.id = row?.id ?? ''
form.name = row?.name ?? ''
form.remark = row?.remark ?? ''
form.file = null
fileList.value = []
dialogVisible.value = true
formRef.value?.clearValidate()
}
const isValidMp4 = (file: File) => {
return file.name.toLowerCase().endsWith('.mp4') && (!file.type || file.type === 'video/mp4')
}
const handleFileChange: UploadProps['onChange'] = uploadFile => {
const raw = uploadFile.raw
if (!raw) return
if (!isValidMp4(raw)) {
ElMessage.error('仅支持上传 MP4 文件')
fileList.value = []
form.file = null
return
}
if (raw.size > MAX_FILE_SIZE) {
ElMessage.error('文件大小不能超过 250MB')
fileList.value = []
form.file = null
return
}
fileList.value = [uploadFile]
form.file = raw
formRef.value?.validateField('file')
}
const handleFileRemove = () => {
form.file = null
fileList.value = []
formRef.value?.validateField('file')
}
const handleExceed: UploadProps['onExceed'] = files => {
uploadRef.value?.clearFiles()
const file = files[0] as UploadRawFile
file.uid = genFileId()
uploadRef.value?.handleStart(file)
}
const submit = async () => {
if (!formRef.value) return
await formRef.value.validate()
const name = form.name.trim()
const remark = form.remark.trim()
submitting.value = true
try {
if (mode.value === 'edit') {
await updateResourceManage({
id: form.id,
name,
remark
})
} else {
if (!form.file) return
const formData = new FormData()
formData.append('name', name)
formData.append('remark', remark)
formData.append('file', form.file)
await addResourceManage(formData)
}
ElMessage.success(mode.value === 'edit' ? '编辑成功' : '新增成功')
dialogVisible.value = false
props.refreshTable?.()
} finally {
submitting.value = false
}
}
defineExpose({
open
})
</script>

View File

@@ -0,0 +1,42 @@
<template>
<el-dialog v-model="dialogVisible" title="播放视频" width="820px" :destroy-on-close="true" @closed="clearVideo">
<video ref="videoRef" class="resource-player" :src="videoUrl" controls autoplay />
</el-dialog>
</template>
<script setup lang="ts">
import { nextTick, ref } from 'vue'
const dialogVisible = ref(false)
const videoUrl = ref('')
const videoRef = ref<HTMLVideoElement>()
const open = async (url: string) => {
videoUrl.value = url
dialogVisible.value = true
await nextTick()
videoRef.value?.load()
}
const clearVideo = () => {
if (videoRef.value) {
videoRef.value.pause()
videoRef.value.removeAttribute('src')
videoRef.value.load()
}
videoUrl.value = ''
}
defineExpose({
open
})
</script>
<style scoped lang="scss">
.resource-player {
display: block;
width: 100%;
max-height: 68vh;
background: #000;
}
</style>

View File

@@ -0,0 +1,166 @@
<template>
<div class="table-box">
<ProTable ref="proTable" :columns="columns" :request-api="getTableList">
<template #tableHeader>
<el-button v-auth.resourceManage="'add'" type="primary" :icon="CirclePlus" @click="openAddDialog">
新增
</el-button>
</template>
<template #operation="scope">
<el-button
v-auth.resourceManage="'play'"
type="primary"
link
:icon="VideoPlay"
@click="handlePlay(scope.row)"
>
播放
</el-button>
<el-button
v-auth.resourceManage="'edit'"
type="primary"
link
:icon="EditPen"
@click="openEditDialog(scope.row)"
>
编辑
</el-button>
</template>
</ProTable>
</div>
<ResourceManagePopup ref="resourceManagePopup" :refresh-table="proTable?.getTableList" />
<ResourcePlayerDialog ref="resourcePlayerDialog" />
</template>
<script setup lang="tsx" name="resourceManage">
import { onActivated, reactive, ref } from 'vue'
import { CirclePlus, EditPen, VideoPlay } from '@element-plus/icons-vue'
import ProTable from '@/components/ProTable/index.vue'
import type { ColumnProps, ProTableInstance } from '@/components/ProTable/interface'
import type { ResourceManage } from '@/api/resourceManage/interface'
import { getResourceManageList, getResourceManagePlayUrl } from '@/api/resourceManage'
import {
consumeResourceManageAutoplayFirst,
hasPendingResourceManageAutoplayFirst
} from '@/utils/resourceManageAutoplay'
import ResourceManagePopup from './components/resourceManagePopup.vue'
import ResourcePlayerDialog from './components/resourcePlayerDialog.vue'
defineOptions({
name: 'resourceManage'
})
const proTable = ref<ProTableInstance>()
const resourceManagePopup = ref()
const resourcePlayerDialog = ref()
const tryAutoPlayFirstRecord = async (firstRecord?: ResourceManage.ResResourceManage) => {
if (!consumeResourceManageAutoplayFirst()) return
if (!firstRecord) return
await handlePlay(firstRecord)
}
const getTableList = async (params: ResourceManage.ReqResourceManageParams) => {
const response = await getResourceManageList(params)
const firstRecord = response.data.records?.[0]
await tryAutoPlayFirstRecord(firstRecord)
return response
}
const formatFileSize = (size?: number) => {
if (!size && size !== 0) return ''
if (size < 1024) return `${size} B`
const kb = size / 1024
if (kb < 1024) return `${kb.toFixed(2)} KB`
return `${(kb / 1024).toFixed(2)} MB`
}
const formatDateTime = (value?: string | null) => {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
const normalizeStreamUrl = (url: string) => {
if (/^https?:\/\//i.test(url)) return url
const baseUrl = import.meta.env.VITE_API_URL as string
const normalizedBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl
const normalizedUrl = url.startsWith('/') ? url : `/${url}`
return `${normalizedBase}${normalizedUrl}`
}
const columns = reactive<ColumnProps<ResourceManage.ResResourceManage>[]>([
{ type: 'index', fixed: 'left', width: 70, label: '序号' },
{
prop: 'name',
label: '资源名称',
minWidth: 160,
search: { el: 'input' }
},
{
prop: 'fileName',
label: '文件名',
minWidth: 220,
search: { el: 'input' }
},
{
prop: 'fileSize',
label: '文件大小',
width: 120,
render: scope => formatFileSize(scope.row.fileSize)
},
{
prop: 'relativePath',
label: '路径',
width: 200,
showOverflowTooltip: true
},
{
prop: 'remark',
label: '备注',
minWidth: 180,
showOverflowTooltip: true
},
{
prop: 'createTime',
label: '上传时间',
width: 180,
render: scope => formatDateTime(scope.row.createTime)
},
{ prop: 'operation', label: '操作', fixed: 'right', width: 180 }
])
const openAddDialog = () => {
resourceManagePopup.value?.open('add')
}
const openEditDialog = (row: ResourceManage.ResResourceManage) => {
resourceManagePopup.value?.open('edit', row)
}
const handlePlay = async (row: ResourceManage.ResResourceManage) => {
const { data } = await getResourceManagePlayUrl(row.id)
resourcePlayerDialog.value?.open(normalizeStreamUrl(data.url))
}
onActivated(async () => {
if (!hasPendingResourceManageAutoplayFirst()) return
const currentFirstRecord = proTable.value?.tableData?.[0] as ResourceManage.ResResourceManage | undefined
if (currentFirstRecord) {
await tryAutoPlayFirstRecord(currentFirstRecord)
return
}
await proTable.value?.getTableList?.()
})
</script>