13 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
80 changed files with 1062 additions and 48 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 @@
53820
95428

Binary file not shown.

Binary file not shown.

View File

@@ -12,3 +12,5 @@
.\binlog.000034
.\binlog.000035
.\binlog.000036
.\binlog.000037
.\binlog.000038

View File

@@ -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.1.138:8080/"]]张文
# 开启激活验证
VITE_ACTIVATE_OPEN=true
VITE_ACTIVATE_OPEN=false

View File

@@ -25,4 +25,4 @@ VITE_PWA=true
#VITE_API_URL="/api" # 打包时用
VITE_API_URL="http://127.0.0.1:18093/"
# 开启激活验证
VITE_ACTIVATE_OPEN=true
VITE_ACTIVATE_OPEN=false

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

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

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>