22 Commits

Author SHA1 Message Date
caozehui
0090a922c6 资源管理页面微调 2026-05-28 14:37:40 +08:00
caozehui
0b26de20b9 资源管理 2026-05-28 13:26:35 +08:00
caozehui
1202f64bfc 检测计划统计功能 2026-05-28 08:44:15 +08:00
caozehui
ce1738daf0 微调 2026-05-27 11:20:12 +08:00
caozehui
a41d824ca3 归档 2026-05-26 15:45:08 +08:00
caozehui
ac5a8450e8 微调 2026-05-26 14:23:59 +08:00
caozehui
01e817a5d6 微调 2026-05-26 13:45:23 +08:00
caozehui
01bf07fc42 检测计划统计功能 2026-05-26 09:22:38 +08:00
caozehui
633e914c9a Revert "下拉多选报告模版"
This reverts commit 37e69e7bda.
2026-05-25 18:38:46 +08:00
caozehui
37e69e7bda 下拉多选报告模版 2026-05-25 14:25:57 +08:00
caozehui
19fb90432a 归档 2026-05-25 09:51:42 +08:00
caozehui
4a3c81a792 归档 2026-05-25 09:50:16 +08:00
caozehui
12d3073241 统一sourceId 2026-05-13 09:47:10 +08:00
caozehui
72838462ad 微调 2026-04-22 10:02:21 +08:00
caozehui
327addf625 微调 2026-04-22 09:58:03 +08:00
caozehui
7fd3b6fdff 归档 2026-04-13 16:31:45 +08:00
4bfab6518e 微调 2026-04-08 20:33:01 +08:00
caozehui
4655259153 归档 2026-04-07 13:28:04 +08:00
caozehui
cdb23726f8 归档 2026-04-07 13:22:57 +08:00
caozehui
68a1c9d28d 归档 2026-04-07 11:25:33 +08:00
caozehui
30e815c027 归档 2026-04-07 11:14:34 +08:00
caozehui
ce10f91b5b 归档 2026-04-07 11:07:18 +08:00
93 changed files with 1070 additions and 51 deletions

View File

@@ -33,9 +33,9 @@ mybatis-plus:
#驼峰命名
map-underscore-to-camel-case: true
#配置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:
db-config:
#指定主键生成策略
@@ -79,6 +79,8 @@ report:
dateFormat: yyyy年MM月dd日
data:
homeDir: {{APP_DATA_PATH}}\data
resource:
videoDir: {{APP_DATA_PATH}}\resources\videos
qr:
cloud: http://pqmcc.com:18082/api/file
dev:

View File

@@ -1 +1 @@
74476
95428

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.

View File

@@ -7,3 +7,10 @@
.\binlog.000029
.\binlog.000030
.\binlog.000031
.\binlog.000032
.\binlog.000033
.\binlog.000034
.\binlog.000035
.\binlog.000036
.\binlog.000037
.\binlog.000038

View File

@@ -19,7 +19,7 @@ VITE_API_URL=/api
# 开发环境跨域代理,支持配置多个
VITE_PROXY=[["/api","http://127.0.0.1:18092/"]]
VITE_PROXY=[["/api","http://127.0.0.1:18093/"]]
#VITE_PROXY=[["/api","http://192.168.1.124:18092/"]]
#VITE_PROXY=[["/api","http://192.168.2.125:18092/"]]
# VITE_PROXY=[["/api","http://192.168.1.138:8080/"]]张文

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "frontend",
"version": "0.0.1",
"version": "2.0.1",
"private": true,
"type": "module",
"scripts": {

View File

@@ -69,5 +69,30 @@ export namespace Plan {
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[];
}
}
}

View File

@@ -94,6 +94,10 @@ export const staticsAnalyse = (params: { id: string[] }) => {
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分页查询被检设
export const getDevListByPlanId = (params: any) => {
return http.post(`/adPlan/listDevByPlanId`, params)
@@ -159,4 +163,4 @@ export const importAndMergePlanCheckData = (params: Plan.ResPlan) => {
return http.upload(`/adPlan/importAndMergePlanCheckData`, params, {
timeout: 60000 * 20
})
}
}

View File

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

View File

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

View File

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

View File

@@ -29,7 +29,7 @@
@node-click="handleNodeClick"
>
<template #default="{ node, data }">
<span class="custom-tree-node" style="display: flex; align-items: center;">
<span class="custom-tree-node">
<!-- 父节点图标 -->
<Platform
v-if="!data.pid"
@@ -39,50 +39,52 @@
}"
/>
<!-- 节点名称 -->
<span>{{ node.label }}</span>
<!-- 子节点右侧图标 + tooltip -->
<el-tooltip
v-if="
node.label != '未检' &&
node.label != '检测中' &&
node.label != '检测完成' &&
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);
"
<span class="node-label">{{ node.label }}</span>
<span class="node-actions">
<PieChart
v-if="isCompletedPlanNode(node.data)"
class="node-action-icon"
@click.stop="openStatistics(node.data)"
style="margin-right: 8px"
/>
</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>
</template>
</el-tree>
</div>
</div>
<SourceOpen ref="openSourceView" :width="width" :height="height + 175"></SourceOpen>
<PlanStatisticsPopup ref="planStatisticsPopupRef" />
</template>
<script lang="ts" setup>
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 { useRouter } from 'vue-router'
import { useCheckStore } from '@/stores/modules/check'
import { ElTooltip } from 'element-plus'
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 { useModeStore } from '@/stores/modules/mode' // 引入模式 store
import { useDictStore } from '@/stores/modules/dict'
const openSourceView = ref()
const planStatisticsPopupRef = ref<InstanceType<typeof PlanStatisticsPopup> | null>(null)
const router = useRouter()
const checkStore = useCheckStore()
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[] {
const map = new Map()
const tree: any[] = []
@@ -293,6 +303,40 @@ defineExpose({ getTreeData, clickTableToTree })
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 {
// font-size: 16px;
// display:block;

View File

@@ -261,7 +261,7 @@ const dictStore = useDictStore()
const activeName = ref('')
const childActiveName = ref('')
const childActiveIndex = ref(0)
const firstName = 'first'
const firstName = ref('first')
const viewRowRef = ref()
const communicationList = ref<[]>([])
const testProjectPopupRef = ref()

View File

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

View File

@@ -260,7 +260,7 @@ const viewDialog = ref(false)
const dictStore = useDictStore()
const activeName = ref('')
const childActiveName = ref('')
const firstName = 'first'
const firstName = ref('first')
const viewRowRef = ref()
const communicationList = ref([])
const testProjectPopupRef = ref()

View File

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

View File

@@ -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>

View File

@@ -99,6 +99,16 @@
被检设备
</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
type="primary"
v-auth.plan="'analysis'"
@@ -136,6 +146,7 @@
<ImportExcel ref="planImportExcel" />
<ImportZip ref="planImportZip" @result="importResult" />
<PlanStatisticsPopup ref="planStatisticsPopupRef" />
<ChildrenPlan
:refresh-table="refreshTable"
@@ -163,6 +174,7 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import type { Plan } from '@/api/plan/interface'
import PlanPopup from '@/views/plan/planList/components/planPopup.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 { useDictStore } from '@/stores/modules/dict'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -187,6 +199,7 @@ const proTable = ref<ProTableInstance>()
const errorStandardPopup = ref()
const testSourcePopup = ref()
const planPopup = ref()
const planStatisticsPopupRef = ref<InstanceType<typeof PlanStatisticsPopup> | null>(null)
const modeStore = useModeStore()
const tableData = ref<any[]>([])
@@ -530,7 +543,7 @@ const columns = reactive<ColumnProps<Plan.ReqPlan>[]>([
isShow: modeStore.currentMode == '比对式'
},
{ prop: 'operation', label: '操作', fixed: 'right', minWidth: 250 }
{ prop: 'operation', label: '操作', fixed: 'right', minWidth: 320 }
])
function isVisible(row: Plan.ReqPlan) {
@@ -654,6 +667,10 @@ const statisticalAnalysis = async (row: Partial<Plan.ReqPlan> = {}) => {
useDownload(staticsAnalyse, '分析结果', [row.id], false, '.xlsx')
}
const openStatistics = (row: Partial<Plan.ReqPlan> = {}) => {
planStatisticsPopupRef.value?.open(row)
}
const importSubClick = () => {
const params = {
title: '导入检测计划',
@@ -671,4 +688,4 @@ const importResult = async (success: boolean | undefined) => {
}
</script>
<style scoped></style>
<style scoped></style>

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,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>