| | |
| | | <el-button type="danger" plain @click="handleDelete">删除</el-button> |
| | | </div> |
| | | </div> |
| | | <!-- AI 视频分析统计概览 --> |
| | | <div class="ai-stat-row"> |
| | | <div class="ai-stat-card"> |
| | | <div class="ai-stat-label">巡检任务</div> |
| | | <div class="ai-stat-value">{{ aiStat.total }}</div> |
| | | </div> |
| | | <div class="ai-stat-card"> |
| | | <div class="ai-stat-label">已完成 AI 分析</div> |
| | | <div class="ai-stat-value">{{ aiStat.analyzed }}</div> |
| | | </div> |
| | | <div class="ai-stat-card"> |
| | | <div class="ai-stat-label">AI 识别目标</div> |
| | | <div class="ai-stat-value accent">{{ aiStat.objectCount }}<span class="unit">项</span></div> |
| | | </div> |
| | | <div class="ai-stat-card" :class="{ danger: aiStat.abnormalCount > 0 }"> |
| | | <div class="ai-stat-label">发现异常</div> |
| | | <div class="ai-stat-value">{{ aiStat.abnormalCount }}<span class="unit">处</span></div> |
| | | </div> |
| | | <div class="ai-stat-card"> |
| | | <div class="ai-stat-label">平均置信度</div> |
| | | <div class="ai-stat-value">{{ aiStat.avgConfidence }}<span class="unit">%</span></div> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="table_list"> |
| | | <PIMTable rowKey="id" :column="tableColumn" :tableData="tableData" :page="page" :isSelection="true" |
| | | @selection-change="handleSelectionChange" :tableLoading="tableLoading" @pagination="pagination"></PIMTable> |
| | | @selection-change="handleSelectionChange" :tableLoading="tableLoading" @pagination="pagination"> |
| | | <template #aiAnalysis="{ row }"> |
| | | <span v-if="row._ai && row._ai.has" class="ai-cell" title="点击查看回传详情" @click="openTrace(row)"> |
| | | <el-tag v-if="row._ai.abnormal > 0" type="danger" size="small" effect="dark"> |
| | | {{ row._ai.total }} 项 · {{ row._ai.abnormal }} 异常 |
| | | </el-tag> |
| | | <el-tag v-else type="success" size="small"> |
| | | {{ row._ai.total }} 项 |
| | | </el-tag> |
| | | </span> |
| | | <span v-else class="ai-none">— 无回传</span> |
| | | </template> |
| | | </PIMTable> |
| | | </div> |
| | | |
| | | <el-dialog v-model="dialogVisible" :title="operationType === 'add' ? '新增巡检' : '编辑巡检'" width="70%" @close="closeDia"> |
| | |
| | | <template #default="{ row }">{{ row.confidence != null ? (Number(row.confidence) * 100).toFixed(1) + "%" : "-" }}</template> |
| | | </el-table-column> |
| | | <el-table-column prop="frameTime" label="截帧时间" width="170" /> |
| | | <el-table-column label="AI 截帧" width="110" align="center"> |
| | | <template #default="{ row }"> |
| | | <el-image |
| | | v-if="row.frameImage" |
| | | :src="row.frameImage" |
| | | :preview-src-list="[row.frameImage]" |
| | | fit="cover" |
| | | class="trace-frame" |
| | | /> |
| | | <span v-else class="trace-frame-none">待回传</span> |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | <div v-else class="trace-sub-empty">暂无结构化识别条目</div> |
| | | </div> |
| | |
| | | { label: "计划开始", prop: "planStartTime", width: 160 }, |
| | | { label: "巡检人员", prop: "inspector", width: 100 }, |
| | | { label: "状态", prop: "status", width: 90, dataType: "tag", formatData: (p) => statusText(p), formatType: (p) => statusType(p) }, |
| | | { label: "AI 分析", prop: "aiAnalysis", width: 170, align: "center", dataType: "slot", slot: "aiAnalysis" }, |
| | | { label: "巡检结果", prop: "inspectionResult", showOverflowTooltip: true }, |
| | | { |
| | | dataType: "action", label: "操作", align: "center", fixed: "right", width: 200, |
| | |
| | | traceVisible.value = true; |
| | | }; |
| | | |
| | | /* ---------- AI 视频分析:解析与统计 ---------- */ |
| | | // 判定"异常"的关键词(后端未显式给 abnormal 标记时的兜底) |
| | | const ABNORMAL_WORDS = ["未佩戴", "未按", "异常", "超标", "违规", "模糊", "褪色", "难以辨认", "失效", "偏移", "移位", "占用", "遗撒", "波动", "漂移", "中断", "松动", "渗漏", "超时", "超限", "缺失", "破损"]; |
| | | // 否定词:出现这些说明是"正常"的描述,不能算异常(如"运行平稳无异常噪音""未发现渗漏") |
| | | const NEGATIVE_WORDS = ["无异常", "无异响", "未发现", "未出现", "正常", "完好", "符合", "良好", "齐全", "清晰", "稳定", "无卡滞", "达标", "无渗漏"]; |
| | | const EMPTY_AI = { has: false, total: 0, abnormal: 0, avgConf: 0, summary: "" }; |
| | | |
| | | /** |
| | | * 解析 aiAnalysis 字段(后端存的是 JSON 字符串) |
| | | * 兼容三种情况:① 标准 JSON ② 纯文本 ③ 空 |
| | | */ |
| | | const parseAi = (raw) => { |
| | | if (!raw) return { ...EMPTY_AI }; |
| | | let obj = null; |
| | | try { |
| | | const v = JSON.parse(raw); |
| | | obj = v && typeof v === "object" ? v : null; |
| | | } catch (e) { |
| | | obj = null; |
| | | } |
| | | // 不是 JSON → 当纯文本处理,标记已分析但无结构化条目 |
| | | if (!obj) { |
| | | return { has: true, total: 0, abnormal: 0, avgConf: 0, summary: String(raw) }; |
| | | } |
| | | const list = Array.isArray(obj.objects) ? obj.objects : []; |
| | | let abnormal = 0; |
| | | let confSum = 0; |
| | | let confCnt = 0; |
| | | list.forEach((it) => { |
| | | const concl = String(it.conclusion || ""); |
| | | // ① 后端显式标记最准,优先用 |
| | | let isBad; |
| | | if (it.abnormal === true || it.abnormal === 1 || it.abnormal === "1" || it.abnormal === "true") { |
| | | isBad = true; |
| | | } else if (it.abnormal === false || it.abnormal === 0 || it.abnormal === "0" || it.abnormal === "false") { |
| | | isBad = false; |
| | | } else { |
| | | // ② 没有显式标记时才走关键词兜底:命中异常词 且 不含否定词 |
| | | const hasNeg = NEGATIVE_WORDS.some((w) => concl.includes(w)); |
| | | isBad = !hasNeg && ABNORMAL_WORDS.some((w) => concl.includes(w)); |
| | | } |
| | | if (isBad) abnormal++; |
| | | const c = Number(it.confidence); |
| | | if (!Number.isNaN(c) && it.confidence !== null && it.confidence !== "") { |
| | | confSum += c > 1 ? c / 100 : c; // 兼容 0.93 和 93 两种写法 |
| | | confCnt++; |
| | | } |
| | | }); |
| | | return { |
| | | has: true, |
| | | total: list.length, |
| | | abnormal, |
| | | avgConf: confCnt ? Math.round((confSum / confCnt) * 100) : 0, |
| | | summary: obj.summary || "", |
| | | }; |
| | | }; |
| | | |
| | | const aiStat = ref({ total: 0, analyzed: 0, objectCount: 0, abnormalCount: 0, avgConfidence: 0 }); |
| | | const computeAiStat = (rows) => { |
| | | let analyzed = 0; |
| | | let objectCount = 0; |
| | | let abnormalCount = 0; |
| | | let confSum = 0; // 按条目加权 |
| | | let confCnt = 0; |
| | | rows.forEach((r) => { |
| | | const a = r._ai; |
| | | if (!a || !a.has) return; |
| | | analyzed++; |
| | | objectCount += a.total; |
| | | abnormalCount += a.abnormal; |
| | | if (a.total > 0 && a.avgConf > 0) { |
| | | confSum += a.avgConf * a.total; |
| | | confCnt += a.total; |
| | | } |
| | | }); |
| | | aiStat.value = { |
| | | total: rows.length, |
| | | analyzed, |
| | | objectCount, |
| | | abnormalCount, |
| | | avgConfidence: confCnt ? Math.round(confSum / confCnt) : 0, |
| | | }; |
| | | }; |
| | | |
| | | const handleQuery = () => { page.current = 1; getList(); }; |
| | | const pagination = (obj) => { page.current = obj.page; page.size = obj.limit; getList(); }; |
| | | const handleSelectionChange = (s) => { selectedRows.value = s; }; |
| | |
| | | tableLoading.value = true; |
| | | listInspection({ ...searchForm.value, ...page }).then((res) => { |
| | | tableLoading.value = false; |
| | | tableData.value = res.data.records; |
| | | page.total = res.data.total; |
| | | const rows = (res.data && res.data.records) || []; |
| | | // 预解析 AI 分析结果,避免模板里重复 JSON.parse |
| | | rows.forEach((r) => { |
| | | r._ai = parseAi(r.aiAnalysis); |
| | | }); |
| | | tableData.value = rows; |
| | | page.total = (res.data && res.data.total) || 0; |
| | | computeAiStat(rows); |
| | | }).catch(() => { tableLoading.value = false; }); |
| | | }; |
| | | const openForm = (type, row) => { |
| | |
| | | |
| | | <style scoped lang="scss"> |
| | | .ml10 { margin-left: 10px; } |
| | | .table_list { margin-top: unset; } |
| | | .table_list { margin-top: 12px; } |
| | | |
| | | /* ============ AI 视频分析统计卡片 ============ */ |
| | | .ai-stat-row { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | gap: 12px; |
| | | margin: 14px 0 4px; |
| | | } |
| | | .ai-stat-card { |
| | | flex: 1; |
| | | min-width: 130px; |
| | | padding: 12px 16px; |
| | | background: #f7f9fc; |
| | | border: 1px solid #e4e8ee; |
| | | border-radius: 6px; |
| | | box-sizing: border-box; |
| | | |
| | | &.danger { |
| | | background: #fef0f0; |
| | | border-color: #fbc4c4; |
| | | } |
| | | } |
| | | .ai-stat-label { |
| | | font-size: 12px; |
| | | color: #909399; |
| | | margin-bottom: 6px; |
| | | } |
| | | .ai-stat-value { |
| | | font-size: 22px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | line-height: 1.2; |
| | | |
| | | &.accent { |
| | | color: #409eff; |
| | | } |
| | | .ai-stat-card.danger & { |
| | | color: #f56c6c; |
| | | } |
| | | .unit { |
| | | font-size: 13px; |
| | | font-weight: 400; |
| | | margin-left: 2px; |
| | | color: #909399; |
| | | } |
| | | } |
| | | |
| | | /* 列表里的 AI 分析列 */ |
| | | .ai-cell { |
| | | display: inline-block; |
| | | cursor: pointer; |
| | | } |
| | | .ai-none { |
| | | font-size: 13px; |
| | | color: #c0c4cc; |
| | | } |
| | | |
| | | .trace-block { margin-bottom: 18px; } |
| | | .trace-title { |
| | | font-size: 14px; |
| | |
| | | border: 1px solid #ebeef5; |
| | | cursor: pointer; |
| | | } |
| | | .trace-frame { |
| | | width: 80px; |
| | | height: 60px; |
| | | border-radius: 4px; |
| | | border: 1px solid #ebeef5; |
| | | cursor: pointer; |
| | | } |
| | | .trace-frame-none { |
| | | font-size: 12px; |
| | | color: #c0c4cc; |
| | | } |
| | | </style> |