Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 19ea08d5e0 | |||
|
|
f9809197e8 | ||
|
|
0090a922c6 | ||
|
|
0b26de20b9 | ||
|
|
1202f64bfc | ||
|
|
ce1738daf0 | ||
|
|
a41d824ca3 | ||
|
|
ac5a8450e8 | ||
|
|
01e817a5d6 | ||
|
|
01bf07fc42 | ||
|
|
633e914c9a | ||
|
|
37e69e7bda | ||
|
|
19fb90432a | ||
|
|
4a3c81a792 | ||
|
|
12d3073241 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,3 +9,4 @@ public/electron/
|
|||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
/public/dist/
|
/public/dist/
|
||||||
|
/docs/
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ mybatis-plus:
|
|||||||
#驼峰命名
|
#驼峰命名
|
||||||
map-underscore-to-camel-case: true
|
map-underscore-to-camel-case: true
|
||||||
#配置sql日志输出
|
#配置sql日志输出
|
||||||
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
# log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||||
#关闭日志输出
|
#关闭日志输出
|
||||||
# log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl
|
log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl
|
||||||
global-config:
|
global-config:
|
||||||
db-config:
|
db-config:
|
||||||
#指定主键生成策略
|
#指定主键生成策略
|
||||||
@@ -55,30 +55,32 @@ webSocket:
|
|||||||
|
|
||||||
#源参数下发,暂态数据默认值
|
#源参数下发,暂态数据默认值
|
||||||
Dip:
|
Dip:
|
||||||
#暂态前时间(s)
|
# 暂态前时间(s)
|
||||||
fPreTime: 2f
|
# fPreTime: 2f
|
||||||
#写入时间(s)
|
#写入时间(s)
|
||||||
fRampIn: 0.001f
|
fRampIn: 0.001f
|
||||||
#写出时间(s)
|
#写出时间(s)
|
||||||
fRampOut: 0.001f
|
fRampOut: 0.001f
|
||||||
#暂态后时间(s)
|
# 暂态后时间(s)
|
||||||
fAfterTime: 3f
|
# fAfterTime: 3f
|
||||||
|
|
||||||
|
|
||||||
Flicker:
|
#Flicker:
|
||||||
waveFluType: CPM
|
# waveFluType: CPM
|
||||||
waveType: SQU
|
# waveType: SQU
|
||||||
fDutyCycle: 50f
|
# fDutyCycle: 50f
|
||||||
|
|
||||||
log:
|
#log:
|
||||||
homeDir: {{APP_DATA_PATH}}\logs
|
# homeDir: D:\logs
|
||||||
commonLevel: info
|
# commonLevel: info
|
||||||
report:
|
report:
|
||||||
template: {{APP_DATA_PATH}}\template
|
# template: D:\template
|
||||||
reportDir: {{APP_DATA_PATH}}\report
|
# reportDir: D:\report
|
||||||
dateFormat: yyyy年MM月dd日
|
dateFormat: yyyy年MM月dd日
|
||||||
data:
|
#data:
|
||||||
homeDir: {{APP_DATA_PATH}}\data
|
# homeDir: D:\data
|
||||||
|
#resource:
|
||||||
|
# videoDir: ${data.homeDir}\resources\videos
|
||||||
qr:
|
qr:
|
||||||
cloud: http://pqmcc.com:18082/api/file
|
cloud: http://pqmcc.com:18082/api/file
|
||||||
dev:
|
dev:
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
build/extraResources/mysql/data/#innodb_redo/#ib_redo42
Normal file
BIN
build/extraResources/mysql/data/#innodb_redo/#ib_redo42
Normal file
Binary file not shown.
BIN
build/extraResources/mysql/data/#innodb_redo/#ib_redo43
Normal file
BIN
build/extraResources/mysql/data/#innodb_redo/#ib_redo43
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1 +1 @@
|
|||||||
53820
|
95428
|
||||||
|
|||||||
Binary file not shown.
BIN
build/extraResources/mysql/data/binlog.000037
Normal file
BIN
build/extraResources/mysql/data/binlog.000037
Normal file
Binary file not shown.
BIN
build/extraResources/mysql/data/binlog.000038
Normal file
BIN
build/extraResources/mysql/data/binlog.000038
Normal file
Binary file not shown.
@@ -12,3 +12,5 @@
|
|||||||
.\binlog.000034
|
.\binlog.000034
|
||||||
.\binlog.000035
|
.\binlog.000035
|
||||||
.\binlog.000036
|
.\binlog.000036
|
||||||
|
.\binlog.000037
|
||||||
|
.\binlog.000038
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -24,4 +24,4 @@ VITE_PROXY=[["/api","http://127.0.0.1:18093/"]]
|
|||||||
#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_ACTIVATE_OPEN=true
|
VITE_ACTIVATE_OPEN=false
|
||||||
@@ -25,4 +25,4 @@ 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:18093/"
|
||||||
# 开启激活验证
|
# 开启激活验证
|
||||||
VITE_ACTIVATE_OPEN=true
|
VITE_ACTIVATE_OPEN=false
|
||||||
@@ -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
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
15
frontend/src/api/detection/lock.ts
Normal file
15
frontend/src/api/detection/lock.ts
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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('&')) {
|
||||||
|
|||||||
@@ -69,5 +69,30 @@ export namespace Plan {
|
|||||||
maxTime: number;
|
maxTime: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PlanStatisticsItem {
|
||||||
|
itemId: string;
|
||||||
|
itemName: string;
|
||||||
|
unqualifiedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
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[];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ export const staticsAnalyse = (params: { id: string[] }) => {
|
|||||||
return http.download('/adPlan/analyse', params)
|
return http.download('/adPlan/analyse', params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getPlanStatistics = (params: { planId: string; manufacturer?: string; devType?: string }) => {
|
||||||
|
return http.post<Plan.PlanStatistics>(`/adPlan/statistics`, params)
|
||||||
|
}
|
||||||
|
|
||||||
//根据计划id分页查询被检设
|
//根据计划id分页查询被检设
|
||||||
export const getDevListByPlanId = (params: any) => {
|
export const getDevListByPlanId = (params: any) => {
|
||||||
return http.post(`/adPlan/listDevByPlanId`, params)
|
return http.post(`/adPlan/listDevByPlanId`, params)
|
||||||
@@ -159,4 +163,4 @@ export const importAndMergePlanCheckData = (params: Plan.ResPlan) => {
|
|||||||
return http.upload(`/adPlan/importAndMergePlanCheckData`, params, {
|
return http.upload(`/adPlan/importAndMergePlanCheckData`, params, {
|
||||||
timeout: 60000 * 20
|
timeout: 60000 * 20
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
18
frontend/src/api/resourceManage/index.ts
Normal file
18
frontend/src/api/resourceManage/index.ts
Normal 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}`)
|
||||||
|
}
|
||||||
34
frontend/src/api/resourceManage/interface/index.ts
Normal file
34
frontend/src/api/resourceManage/interface/index.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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= () => {
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
33
frontend/src/stores/modules/detectionLock.ts
Normal file
33
frontend/src/stores/modules/detectionLock.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
72
frontend/src/utils/detectionLockDialog.ts
Normal file
72
frontend/src/utils/detectionLockDialog.ts
Normal 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(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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://192.168.1.124:7777/hello',
|
url: 'ws://127.0.0.1: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 {
|
||||||
|
|||||||
@@ -91,7 +91,7 @@
|
|||||||
type="primary"
|
type="primary"
|
||||||
icon="Clock"
|
icon="Clock"
|
||||||
@click="handleTest('手动检测')"
|
@click="handleTest('手动检测')"
|
||||||
v-if="form.activeTabs === 0 && modeStore.currentMode == '模拟式'"
|
v-if="form.activeTabs === 0 && modeStore.currentMode != '比对式'"
|
||||||
>
|
>
|
||||||
手动检测
|
手动检测
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -483,7 +483,7 @@ const columns = reactive<ColumnProps<Device.ResPqDev>[]>([
|
|||||||
sortable: true,
|
sortable: true,
|
||||||
isShow: checkStateShow,
|
isShow: checkStateShow,
|
||||||
render: scope => {
|
render: scope => {
|
||||||
return scope.row.checkState === 0 ? '未检' : scope.row.checkState === 1 ? '检测中' : '检测完成'
|
return scope.row.checkState === 0 ? '未检' : scope.row.checkState === 1 ? '检测中' : scope.row.checkState === 2 ? '检测完成':'归档'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -494,10 +494,12 @@ const columns = reactive<ColumnProps<Device.ResPqDev>[]>([
|
|||||||
render: scope => {
|
render: scope => {
|
||||||
if (scope.row.checkResult === 0) {
|
if (scope.row.checkResult === 0) {
|
||||||
return <el-tag type="danger">不符合</el-tag>
|
return <el-tag type="danger">不符合</el-tag>
|
||||||
|
} else if (scope.row.checkResult === 0) {
|
||||||
|
return '不符合'
|
||||||
} else if (scope.row.checkResult === 1) {
|
} else if (scope.row.checkResult === 1) {
|
||||||
return '符合'
|
return '符合'
|
||||||
} else if (scope.row.checkResult === 2) {
|
}else if(scope.row.checkResult === 2) {
|
||||||
return '未检'
|
return '未检'
|
||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
@@ -539,7 +541,6 @@ const columns = reactive<ColumnProps<Device.ResPqDev>[]>([
|
|||||||
{ prop: 'operation', label: '操作', fixed: 'right', minWidth :200,isShow: operationShow }
|
{ prop: 'operation', label: '操作', fixed: 'right', minWidth :200,isShow: operationShow }
|
||||||
])
|
])
|
||||||
let testType = 'test' // 检测类型:'test'-检测 'reTest'-复检
|
let testType = 'test' // 检测类型:'test'-检测 'reTest'-复检
|
||||||
let qualifiedCount = 0 //合格数量
|
|
||||||
|
|
||||||
|
|
||||||
//比对单个报告生成
|
//比对单个报告生成
|
||||||
@@ -575,8 +576,6 @@ const handleSelectionChange = (selection: any[]) => {
|
|||||||
} else {
|
} else {
|
||||||
testType = 'reTest'
|
testType = 'reTest'
|
||||||
}
|
}
|
||||||
qualifiedCount=selection.filter(item => item.checkResult == 1).length
|
|
||||||
|
|
||||||
let devices: CheckData.Device[] = selection.map((item: any) => {
|
let devices: CheckData.Device[] = selection.map((item: any) => {
|
||||||
return {
|
return {
|
||||||
deviceId: item.id,
|
deviceId: item.id,
|
||||||
@@ -599,6 +598,19 @@ const handleSelectionChange = (selection: any[]) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isUncheckedDevice = (device: Device.ResPqDev) => Number(device.checkState) === 0 || Number(device.checkResult) === 2
|
||||||
|
|
||||||
|
const hasCheckedSelectedDevice = () => channelsSelection.value.some(device => !isUncheckedDevice(device))
|
||||||
|
|
||||||
|
const hasUncheckedSelectedDevice = () => channelsSelection.value.some(device => isUncheckedDevice(device))
|
||||||
|
|
||||||
|
const hasCheckedUnqualifiedSelectedDevice = () =>
|
||||||
|
channelsSelection.value.some(device => !isUncheckedDevice(device) && Number(device.checkResult) === 0)
|
||||||
|
|
||||||
|
const shouldShowRecheckModeDialog = () => hasCheckedSelectedDevice()
|
||||||
|
|
||||||
|
const canUseUnqualifiedItemRecheck = () => hasCheckedUnqualifiedSelectedDevice() && !hasUncheckedSelectedDevice()
|
||||||
|
|
||||||
//查询
|
//查询
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
proTable.value?.getTableList()
|
proTable.value?.getTableList()
|
||||||
@@ -923,12 +935,12 @@ const handleTest = async (val: string) => {
|
|||||||
dialogTitle.value = val
|
dialogTitle.value = val
|
||||||
if (val === '手动检测') {
|
if (val === '手动检测') {
|
||||||
checkStore.setShowDetailType(2)
|
checkStore.setShowDetailType(2)
|
||||||
if (testType === 'reTest') {
|
if (shouldShowRecheckModeDialog()) {
|
||||||
ElMessageBox.confirm('请选择复检检测方式', '设备复检', {
|
ElMessageBox.confirm('请选择复检检测方式', '设备复检', {
|
||||||
distinguishCancelAndClose: true,
|
distinguishCancelAndClose: true,
|
||||||
confirmButtonText: '不合格项复检',
|
confirmButtonText: '不合格项复检',
|
||||||
cancelButtonText: '全部复检',
|
cancelButtonText: '全部复检',
|
||||||
showConfirmButton:qualifiedCount<=0,
|
showConfirmButton: canUseUnqualifiedItemRecheck(),
|
||||||
type: 'warning'
|
type: 'warning'
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -963,11 +975,12 @@ const handleTest = async (val: string) => {
|
|||||||
checkStore.setCheckType(1)
|
checkStore.setCheckType(1)
|
||||||
checkStore.initSelectTestItems()
|
checkStore.initSelectTestItems()
|
||||||
// 一键检测
|
// 一键检测
|
||||||
if (testType === 'reTest' && modeStore.currentMode != '比对式') {
|
if (shouldShowRecheckModeDialog() && modeStore.currentMode != '比对式') {
|
||||||
ElMessageBox.confirm('请选择复检检测方式', '设备复检', {
|
ElMessageBox.confirm('请选择复检检测方式', '设备复检', {
|
||||||
distinguishCancelAndClose: true,
|
distinguishCancelAndClose: true,
|
||||||
confirmButtonText: '不合格项复检',
|
confirmButtonText: '不合格项复检',
|
||||||
cancelButtonText: '全部复检',
|
cancelButtonText: '全部复检',
|
||||||
|
showConfirmButton: canUseUnqualifiedItemRecheck(),
|
||||||
type: 'warning'
|
type: 'warning'
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -1087,7 +1100,7 @@ const openDrawer = async (title: string, row: any) => {
|
|||||||
|
|
||||||
if (title === '检测数据查询') {
|
if (title === '检测数据查询') {
|
||||||
checkStore.setShowDetailType(0)
|
checkStore.setShowDetailType(0)
|
||||||
if (modeStore.currentMode == '模拟式') {
|
if (modeStore.currentMode == '模拟式'||modeStore.currentMode == '数字式') {
|
||||||
dataCheckPopupRef.value?.open(row.id, '-1', null)
|
dataCheckPopupRef.value?.open(row.id, '-1', null)
|
||||||
} else if (modeStore.currentMode == '比对式') {
|
} else if (modeStore.currentMode == '比对式') {
|
||||||
dataCheckSingleChannelSingleTestPopupRef.value?.open(row, null, row.id, 2)
|
dataCheckSingleChannelSingleTestPopupRef.value?.open(row, null, row.id, 2)
|
||||||
@@ -1095,7 +1108,7 @@ const openDrawer = async (title: string, row: any) => {
|
|||||||
}
|
}
|
||||||
if (title === '误差体系更换') {
|
if (title === '误差体系更换') {
|
||||||
checkStore.setShowDetailType(1)
|
checkStore.setShowDetailType(1)
|
||||||
if (modeStore.currentMode == '模拟式') {
|
if (modeStore.currentMode == '模拟式'||modeStore.currentMode == '数字式') {
|
||||||
dataCheckPopupRef.value?.open(row.id, '-1', null)
|
dataCheckPopupRef.value?.open(row.id, '-1', null)
|
||||||
} else if (modeStore.currentMode == '比对式') {
|
} else if (modeStore.currentMode == '比对式') {
|
||||||
dataCheckSingleChannelSingleTestPopupRef.value?.open(row, null, row.id, 2)
|
dataCheckSingleChannelSingleTestPopupRef.value?.open(row, null, row.id, 2)
|
||||||
|
|||||||
@@ -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,50 +39,52 @@
|
|||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
<!-- 节点名称 -->
|
<!-- 节点名称 -->
|
||||||
<span>{{ node.label }}</span>
|
<span class="node-label">{{ node.label }}</span>
|
||||||
<!-- 子节点右侧图标 + tooltip -->
|
<span class="node-actions">
|
||||||
<el-tooltip
|
<PieChart
|
||||||
v-if="
|
v-if="isCompletedPlanNode(node.data)"
|
||||||
node.label != '未检' &&
|
class="node-action-icon"
|
||||||
node.label != '检测中' &&
|
@click.stop="openStatistics(node.data)"
|
||||||
node.label != '检测完成' &&
|
style="margin-right: 8px"
|
||||||
hasChildrenInPlanTable(node.data)
|
|
||||||
"
|
|
||||||
placement="top"
|
|
||||||
:manual="true"
|
|
||||||
content="子计划信息"
|
|
||||||
>
|
|
||||||
<List
|
|
||||||
@click.stop="childDetail(node.data)"
|
|
||||||
style="
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
margin-left: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--el-color-primary);
|
|
||||||
"
|
|
||||||
/>
|
/>
|
||||||
</el-tooltip>
|
<!-- 子节点右侧图标 + tooltip -->
|
||||||
|
<el-tooltip
|
||||||
|
v-if="
|
||||||
|
node.label != '未检' &&
|
||||||
|
node.label != '检测中' &&
|
||||||
|
node.label != '检测完成' &&
|
||||||
|
hasChildrenInPlanTable(node.data)
|
||||||
|
"
|
||||||
|
placement="top"
|
||||||
|
:manual="true"
|
||||||
|
content="子计划信息"
|
||||||
|
>
|
||||||
|
<List class="node-action-icon" @click.stop="childDetail(node.data)" />
|
||||||
|
</el-tooltip>
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-tree>
|
</el-tree>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<SourceOpen ref="openSourceView" :width="width" :height="height + 175"></SourceOpen>
|
<SourceOpen ref="openSourceView" :width="width" :height="height + 175"></SourceOpen>
|
||||||
|
<PlanStatisticsPopup ref="planStatisticsPopupRef" />
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { type Plan } from '@/api/plan/interface'
|
import { type Plan } from '@/api/plan/interface'
|
||||||
import { List, Menu, Platform } from '@element-plus/icons-vue'
|
import { List, Menu, PieChart, Platform } from '@element-plus/icons-vue'
|
||||||
import { nextTick, onMounted, ref, watch } from 'vue'
|
import { 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('')
|
||||||
@@ -211,6 +213,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 +303,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;
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ const unit = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '功率',
|
label: '功率',
|
||||||
unit: 'W'
|
unit: props.valueCode == 'Absolute' ? 'W' : '%Un*In'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '电压偏差',
|
label: '电压偏差',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,492 @@
|
|||||||
|
<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"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<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 { getPqDev } from '@/api/device/device'
|
||||||
|
import type { Plan } from '@/api/plan/interface'
|
||||||
|
import type { Device } from '@/api/device/interface/device'
|
||||||
|
import { useDictStore } from '@/stores/modules/dict'
|
||||||
|
|
||||||
|
interface SelectOption {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
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: []
|
||||||
|
})
|
||||||
|
|
||||||
|
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 dictStore = useDictStore()
|
||||||
|
const manufacturerOptions = computed<SelectOption[]>(() => dictStore.getDictData('Dev_Manufacturers') as 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 loadFilterOptions()
|
||||||
|
await loadStatistics()
|
||||||
|
}
|
||||||
|
|
||||||
|
const reloadStatistics = async () => {
|
||||||
|
if (!dialogVisible.value || !currentPlanId.value) return
|
||||||
|
await loadStatistics()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadStatistics = async () => {
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await getPlanStatistics({
|
||||||
|
planId: currentPlanId.value,
|
||||||
|
manufacturer: filters.manufacturer || undefined,
|
||||||
|
devType: filters.devType || undefined
|
||||||
|
})
|
||||||
|
Object.assign(statisticsData, {
|
||||||
|
...emptyStatistics(),
|
||||||
|
...data,
|
||||||
|
itemDistributions: data?.itemDistributions || []
|
||||||
|
})
|
||||||
|
await nextTick()
|
||||||
|
renderCharts()
|
||||||
|
} catch (error) {
|
||||||
|
loadFailed.value = true
|
||||||
|
ElMessage.error('统计数据加载失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadFilterOptions = async () => {
|
||||||
|
if (devTypeOptions.value.length) return
|
||||||
|
try {
|
||||||
|
const { data } = await getPqDev()
|
||||||
|
devTypeOptions.value = ((data || []) as Device.ResDev[]).map(item => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
devTypeOptions.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
@@ -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: '导入检测计划',
|
||||||
@@ -671,4 +688,4 @@ const importResult = async (success: boolean | undefined) => {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
139
frontend/src/views/resourceManage/index.vue
Normal file
139
frontend/src/views/resourceManage/index.vue
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
<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 { 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 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 getTableList = async (params: ResourceManage.ReqResourceManageParams) => {
|
||||||
|
return getResourceManageList(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user