From 3f615df738f2e1150fab099c00cfec4e4c43196b Mon Sep 17 00:00:00 2001
From: tuqin <tuqin@tuqin.cn>
Date: 星期一, 26 十月 2026 09:24:45 +0800
Subject: [PATCH] feat(环保门禁): 智能巡检回传详情(AI分析/无人机轨迹结构化展示)、异常报警15秒轮询+弹窗提醒、车辆手机采集加拍照上传与移动端适配
---
src/views/environmentAccess/remoteMonitor/index.vue | 3
src/views/environmentAccess/vehicleInfoAudit/index.vue | 85 ++++++++++
src/views/environmentAccess/vehicleInfoCollect/index.vue | 40 +++++
src/views/environmentAccess/intelligentInspectionManagement/index.vue | 152 ++++++++++++++++++
src/views/environmentAccess/components/PhotoCapture.vue | 147 ++++++++++++++++++
5 files changed, 421 insertions(+), 6 deletions(-)
diff --git a/src/views/environmentAccess/components/PhotoCapture.vue b/src/views/environmentAccess/components/PhotoCapture.vue
new file mode 100644
index 0000000..e42548f
--- /dev/null
+++ b/src/views/environmentAccess/components/PhotoCapture.vue
@@ -0,0 +1,147 @@
+<template>
+ <div class="photo-capture">
+ <div v-if="modelValue" class="photo-box">
+ <el-image :src="modelValue" :preview-src-list="[modelValue]" fit="cover" class="photo-img" />
+ <div class="photo-remove" @click.stop="remove">
+ <el-icon><Close /></el-icon>
+ </div>
+ </div>
+ <div v-else class="photo-box photo-empty" @click="pick">
+ <el-icon class="photo-icon"><Camera /></el-icon>
+ <span class="photo-text">{{ placeholder }}</span>
+ </div>
+ <input
+ ref="inputRef"
+ type="file"
+ accept="image/*"
+ capture="environment"
+ class="photo-input"
+ @change="onChange"
+ />
+ </div>
+</template>
+
+<script setup>
+import { ref } from "vue";
+import axios from "axios";
+import { ElMessage } from "element-plus";
+import { Camera, Close } from "@element-plus/icons-vue";
+import { getToken } from "@/utils/auth";
+
+defineOptions({ name: "PhotoCapture" });
+
+const props = defineProps({
+ modelValue: { type: String, default: "" },
+ placeholder: { type: String, default: "鎷嶇収 / 涓婁紶" },
+});
+
+const emit = defineEmits(["update:modelValue"]);
+
+const inputRef = ref();
+const loading = ref(false);
+const MAX_SIZE = 10 * 1024 * 1024;
+
+const pick = () => {
+ if (loading.value) return;
+ inputRef.value && inputRef.value.click();
+};
+
+const remove = () => {
+ emit("update:modelValue", "");
+};
+
+const onChange = async (e) => {
+ const file = e.target.files && e.target.files[0];
+ e.target.value = "";
+ if (!file) return;
+ if (!/^image\//.test(file.type)) {
+ ElMessage.warning("璇烽�夋嫨鍥剧墖鏂囦欢");
+ return;
+ }
+ if (file.size > MAX_SIZE) {
+ ElMessage.warning("鍥剧墖涓嶈兘瓒呰繃 10MB锛岃鍘嬬缉鍚庨噸璇�");
+ return;
+ }
+ loading.value = true;
+ try {
+ const fd = new FormData();
+ fd.append("file", file);
+ const res = await axios.post(import.meta.env.VITE_APP_BASE_API + "/common/upload", fd, {
+ headers: {
+ Authorization: "Bearer " + getToken(),
+ "Content-Type": "multipart/form-data",
+ },
+ });
+ const body = res && res.data;
+ if (body && body.code === 200) {
+ emit("update:modelValue", body.url || body.fileName || "");
+ ElMessage.success("涓婁紶鎴愬姛");
+ } else {
+ ElMessage.error((body && body.msg) || "涓婁紶澶辫触");
+ }
+ } catch (err) {
+ ElMessage.error("涓婁紶澶辫触锛岃妫�鏌ョ綉缁滃悗閲嶈瘯");
+ } finally {
+ loading.value = false;
+ }
+};
+</script>
+
+<style scoped lang="scss">
+.photo-capture {
+ width: 100%;
+}
+.photo-box {
+ position: relative;
+ width: 100%;
+ height: 140px;
+ border-radius: 6px;
+ overflow: hidden;
+ border: 1px solid #dcdfe6;
+ background: #fafafa;
+ box-sizing: border-box;
+}
+.photo-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ border-style: dashed;
+ cursor: pointer;
+ color: #909399;
+
+ &:active {
+ background: #f0f2f5;
+ }
+}
+.photo-icon {
+ font-size: 30px;
+ margin-bottom: 6px;
+}
+.photo-text {
+ font-size: 13px;
+}
+.photo-img {
+ width: 100%;
+ height: 140px;
+ display: block;
+}
+.photo-remove {
+ position: absolute;
+ top: 4px;
+ right: 4px;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ background: rgba(0, 0, 0, 0.55);
+ color: #fff;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ font-size: 13px;
+}
+.photo-input {
+ display: none;
+}
+</style>
diff --git a/src/views/environmentAccess/intelligentInspectionManagement/index.vue b/src/views/environmentAccess/intelligentInspectionManagement/index.vue
index ada1f9a..214f7b0 100644
--- a/src/views/environmentAccess/intelligentInspectionManagement/index.vue
+++ b/src/views/environmentAccess/intelligentInspectionManagement/index.vue
@@ -61,8 +61,8 @@
<el-col :span="24"><el-form-item label="宸℃缁撴灉/璁板綍锛�"><el-input v-model="form.inspectionResult" type="textarea" :rows="2" placeholder="璇疯緭鍏ュ贰妫�缁撴灉" /></el-form-item></el-col>
</el-row>
<el-row :gutter="30">
- <el-col :span="12"><el-form-item label="AI瑙嗛鍒嗘瀽缁撴灉锛�"><el-input v-model="form.aiAnalysis" type="textarea" :rows="2" placeholder="棰勭暀锛欰I瑙嗛鍒嗘瀽缁撴灉" /></el-form-item></el-col>
- <el-col :span="12"><el-form-item label="鏃犱汉鏈�/鏈哄櫒浜烘暟鎹細"><el-input v-model="form.uavData" type="textarea" :rows="2" placeholder="棰勭暀锛氭棤浜烘満/鏈哄櫒浜哄洖浼犳暟鎹�" /></el-form-item></el-col>
+ <el-col :span="12"><el-form-item label="AI瑙嗛鍒嗘瀽缁撴灉锛�"><el-input v-model="form.aiAnalysis" type="textarea" :rows="2" placeholder="璁惧鍥炰紶鍚庤嚜鍔ㄥ啓鍏ワ紱涔熷彲鎵嬪伐褰曞叆" /></el-form-item></el-col>
+ <el-col :span="12"><el-form-item label="鏃犱汉鏈�/鏈哄櫒浜烘暟鎹細"><el-input v-model="form.uavData" type="textarea" :rows="2" placeholder="璁惧鍥炰紶鍚庤嚜鍔ㄥ啓鍏ワ紱涔熷彲鎵嬪伐褰曞叆" /></el-form-item></el-col>
</el-row>
<el-row :gutter="30">
<el-col :span="24"><el-form-item label="宸℃鎶ュ憡锛�"><el-input v-model="form.reportPath" type="textarea" :rows="2" placeholder="鑷姩鐢熸垚/濉啓宸℃鎶ュ憡" /></el-form-item></el-col>
@@ -85,12 +85,78 @@
<el-button @click="reportVisible = false">鍏抽棴</el-button>
</template>
</el-dialog>
+
+ <!-- 宸℃鍥炰紶璇︽儏锛欰I 瑙嗛鍒嗘瀽 + 鏃犱汉鏈�/鏈哄櫒浜哄洖浼犳暟鎹� -->
+ <el-dialog v-model="traceVisible" :title="traceTitle" width="75%" top="6vh">
+ <div v-if="!traceHasData" class="trace-empty">
+ 璇ュ贰妫�浠诲姟鏆傛棤璁惧鍥炰紶鏁版嵁銆傛棤浜烘満 / 鏈哄櫒浜� / AI 鎽勫儚澶撮�氳繃鍥炰紶鎺ュ彛涓婃姤鍚庯紝杩欓噷浼氳嚜鍔ㄥ睍绀鸿瘑鍒粨鏋溿�佺幇鍦虹収鐗囦笌杞ㄨ抗銆�
+ </div>
+ <template v-else>
+ <div class="trace-block">
+ <div class="trace-title">鏁版嵁鏉ユ簮</div>
+ <el-descriptions :column="4" border size="small">
+ <el-descriptions-item label="鏉ユ簮绫诲瀷">{{ deviceTypeText(trace.device.deviceType) }}</el-descriptions-item>
+ <el-descriptions-item label="璁惧缂栧彿">{{ trace.device.deviceCode || "-" }}</el-descriptions-item>
+ <el-descriptions-item label="宸℃鍖哄煙">{{ trace.device.area || "-" }}</el-descriptions-item>
+ <el-descriptions-item label="璁惧鐢甸噺">{{ trace.device.battery != null ? trace.device.battery + "%" : "-" }}</el-descriptions-item>
+ <el-descriptions-item label="閲囬泦鏃堕棿">{{ trace.device.collectedAt || "-" }}</el-descriptions-item>
+ <el-descriptions-item label="绯荤粺鎺ユ敹鏃堕棿">{{ trace.device.receivedAt || "-" }}</el-descriptions-item>
+ <el-descriptions-item label="閲囬泦鍧愭爣">{{ coordText }}</el-descriptions-item>
+ <el-descriptions-item label="浣滀笟楂樺害">{{ trace.device.altitude != null ? trace.device.altitude + " m" : "-" }}</el-descriptions-item>
+ </el-descriptions>
+ </div>
+
+ <div class="trace-block">
+ <div class="trace-title">AI 瑙嗛鍒嗘瀽缁撴灉</div>
+ <div class="trace-summary">{{ trace.ai.summary || "鏈鍥炰紶鏈檮甯� AI 缁撹" }}</div>
+ <el-table v-if="aiObjects.length" :data="aiObjects" border size="small">
+ <el-table-column type="index" label="搴忓彿" width="60" align="center" />
+ <el-table-column prop="object" label="璇嗗埆瀵硅薄" min-width="140" show-overflow-tooltip />
+ <el-table-column prop="conclusion" label="璇嗗埆缁撹" min-width="160" show-overflow-tooltip />
+ <el-table-column label="缃俊搴�" width="110" align="center">
+ <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>
+ <div v-else class="trace-sub-empty">鏆傛棤缁撴瀯鍖栬瘑鍒潯鐩�</div>
+ </div>
+
+ <div v-if="tracePhotos.length" class="trace-block">
+ <div class="trace-title">鐜板満閲囬泦鐓х墖</div>
+ <div class="trace-photos">
+ <el-image
+ v-for="(p, i) in tracePhotos"
+ :key="i"
+ :src="p"
+ :preview-src-list="tracePhotos"
+ :initial-index="i"
+ fit="cover"
+ class="trace-photo"
+ />
+ </div>
+ </div>
+
+ <div v-if="traceTrack.length" class="trace-block">
+ <div class="trace-title">璁惧宸℃杞ㄨ抗</div>
+ <el-table :data="traceTrack" border size="small" max-height="200">
+ <el-table-column type="index" label="搴忓彿" width="60" align="center" />
+ <el-table-column prop="t" label="鏃堕棿" width="140" />
+ <el-table-column prop="lng" label="缁忓害" width="140" />
+ <el-table-column prop="lat" label="绾害" width="140" />
+ <el-table-column prop="alt" label="楂樺害(m)" width="110" />
+ </el-table>
+ </div>
+ </template>
+ <template #footer>
+ <el-button @click="traceVisible = false">鍏抽棴</el-button>
+ </template>
+ </el-dialog>
</div>
</template>
<script setup>
import { Search } from "@element-plus/icons-vue";
-import { ref, reactive, toRefs, onMounted, getCurrentInstance } from "vue";
+import { ref, reactive, toRefs, onMounted, computed, getCurrentInstance } from "vue";
import { ElMessageBox } from "element-plus";
import { listInspection, addInspection, updateInspection, deleteInspection, generateInspectionReport } from "@/api/environmentAccess/inspection.js";
@@ -112,8 +178,9 @@
{ label: "鐘舵��", prop: "status", width: 90, dataType: "tag", formatData: (p) => statusText(p), formatType: (p) => statusType(p) },
{ label: "宸℃缁撴灉", prop: "inspectionResult", showOverflowTooltip: true },
{
- dataType: "action", label: "鎿嶄綔", align: "center", fixed: "right", width: 100,
+ dataType: "action", label: "鎿嶄綔", align: "center", fixed: "right", width: 200,
operation: [
+ { name: "鍥炰紶璇︽儏", type: "text", clickFun: (row) => openTrace(row) },
{ name: "缂栬緫", type: "text", clickFun: (row) => openForm("edit", row) },
{ name: "鐢熸垚鎶ュ憡", type: "text", clickFun: (row) => openReport(row) },
],
@@ -149,6 +216,47 @@
a.download = reportTitle.value + ".doc";
a.click();
URL.revokeObjectURL(a.href);
+};
+
+/* ---------- 宸℃鍥炰紶璇︽儏锛圓I 瑙嗛鍒嗘瀽 + 鏃犱汉鏈�/鏈哄櫒浜哄洖浼狅級 ---------- */
+const traceVisible = ref(false);
+const traceTitle = ref("宸℃鍥炰紶璇︽儏");
+const trace = ref({ device: {}, ai: {}, photos: [], track: [] });
+const deviceTypeText = (t) => ({ UAV: "鏃犱汉鏈�", ROBOT: "宸℃鏈哄櫒浜�", CAMERA: "AI 鎽勫儚澶�" }[t] || t || "-");
+const safeJson = (txt) => {
+ if (!txt) return null;
+ try {
+ const v = JSON.parse(txt);
+ return v && typeof v === "object" ? v : null;
+ } catch (e) {
+ return null;
+ }
+};
+const traceHasData = computed(() => {
+ const d = trace.value.device || {};
+ const ai = trace.value.ai || {};
+ return !!(d.deviceCode || d.deviceType || ai.summary || (ai.objects && ai.objects.length));
+});
+const aiObjects = computed(() => (trace.value.ai && trace.value.ai.objects) || []);
+const tracePhotos = computed(() => (trace.value.device && trace.value.device.photos) || []);
+const traceTrack = computed(() => (trace.value.device && trace.value.device.track) || []);
+const coordText = computed(() => {
+ const d = trace.value.device || {};
+ if (d.longitude == null || d.latitude == null) return "-";
+ return d.longitude + ", " + d.latitude;
+});
+const openTrace = (row) => {
+ const uav = safeJson(row.uavData);
+ const aiParsed = safeJson(row.aiAnalysis);
+ const ai = aiParsed || { summary: row.aiAnalysis || "", objects: [] };
+ trace.value = {
+ device: uav || {},
+ ai: ai,
+ photos: (uav && uav.photos) || [],
+ track: (uav && uav.track) || [],
+ };
+ traceTitle.value = (row.inspectionName || "鏅鸿兘宸℃") + " - 鍥炰紶璇︽儏";
+ traceVisible.value = true;
};
const handleQuery = () => { page.current = 1; getList(); };
@@ -192,4 +300,40 @@
<style scoped lang="scss">
.ml10 { margin-left: 10px; }
.table_list { margin-top: unset; }
+.trace-block { margin-bottom: 18px; }
+.trace-title {
+ font-size: 14px;
+ font-weight: 600;
+ color: #303133;
+ border-left: 3px solid #409eff;
+ padding-left: 8px;
+ margin-bottom: 10px;
+}
+.trace-summary {
+ font-size: 13px;
+ color: #606266;
+ background: #f5f7fa;
+ border-radius: 4px;
+ padding: 8px 12px;
+ margin-bottom: 10px;
+ line-height: 1.6;
+}
+.trace-sub-empty,
+.trace-empty {
+ color: #909399;
+ font-size: 13px;
+ line-height: 1.8;
+}
+.trace-empty {
+ padding: 24px 8px;
+ text-align: center;
+}
+.trace-photos { display: flex; flex-wrap: wrap; gap: 10px; }
+.trace-photo {
+ width: 132px;
+ height: 96px;
+ border-radius: 4px;
+ border: 1px solid #ebeef5;
+ cursor: pointer;
+}
</style>
diff --git a/src/views/environmentAccess/remoteMonitor/index.vue b/src/views/environmentAccess/remoteMonitor/index.vue
index 6459893..fd4c808 100644
--- a/src/views/environmentAccess/remoteMonitor/index.vue
+++ b/src/views/environmentAccess/remoteMonitor/index.vue
@@ -162,7 +162,8 @@
const startAlarmPolling = () => {
loadAlarms(false);
- alarmTimer = setInterval(() => loadAlarms(true), 60000);
+ // 15 绉掕疆璇竴娆★紝鏂版姤璀︿細绔嬪嵆寮瑰嚭绔欏唴鎻愰啋
+ alarmTimer = setInterval(() => loadAlarms(true), 15000);
};
onMounted(() => {
diff --git a/src/views/environmentAccess/vehicleInfoAudit/index.vue b/src/views/environmentAccess/vehicleInfoAudit/index.vue
index 337f6bd..cf2c139 100644
--- a/src/views/environmentAccess/vehicleInfoAudit/index.vue
+++ b/src/views/environmentAccess/vehicleInfoAudit/index.vue
@@ -127,6 +127,44 @@
</el-form-item>
</el-col>
</el-row>
+ <el-row :gutter="30">
+ <el-col :span="8">
+ <el-form-item label="杞︾墝鐓х墖">
+ <el-image
+ v-if="form.platePhoto"
+ :src="form.platePhoto"
+ :preview-src-list="[form.platePhoto]"
+ fit="cover"
+ class="audit-photo"
+ />
+ <span v-else class="audit-photo-empty">鏈笂浼�</span>
+ </el-form-item>
+ </el-col>
+ <el-col :span="8">
+ <el-form-item label="琛岄┒璇佺収鐗�">
+ <el-image
+ v-if="form.licensePhoto"
+ :src="form.licensePhoto"
+ :preview-src-list="[form.licensePhoto]"
+ fit="cover"
+ class="audit-photo"
+ />
+ <span v-else class="audit-photo-empty">鏈笂浼�</span>
+ </el-form-item>
+ </el-col>
+ <el-col :span="8">
+ <el-form-item label="杞﹁締鍏ㄦ櫙鐓х墖">
+ <el-image
+ v-if="form.scenePhoto"
+ :src="form.scenePhoto"
+ :preview-src-list="[form.scenePhoto]"
+ fit="cover"
+ class="audit-photo"
+ />
+ <span v-else class="audit-photo-empty">鏈笂浼�</span>
+ </el-form-item>
+ </el-col>
+ </el-row>
</el-form>
<template #footer>
<div class="dialog-footer">
@@ -144,6 +182,20 @@
@close="closeAuditDialog"
>
<el-form :model="auditForm" label-width="100px">
+ <el-form-item label="閲囬泦鐓х墖">
+ <div class="audit-photos">
+ <el-image
+ v-for="(p, i) in auditPhotos"
+ :key="i"
+ :src="p"
+ :preview-src-list="auditPhotos"
+ :initial-index="i"
+ fit="cover"
+ class="audit-thumb"
+ />
+ <span v-if="!auditPhotos.length" class="audit-photo-empty">璇ヨ褰曟湭涓婁紶鐓х墖</span>
+ </div>
+ </el-form-item>
<el-form-item label="瀹℃牳缁撴灉" prop="status">
<el-radio-group v-model="auditForm.status">
<el-radio label="approved">閫氳繃</el-radio>
@@ -170,7 +222,7 @@
</template>
<script setup>
-import { ref, reactive } from "vue";
+import { ref, reactive, computed } from "vue";
import { Search } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus";
import PIMTable from "@/components/PIMTable/PIMTable.vue";
@@ -340,6 +392,9 @@
registeDate: "",
natureOfUse: "",
fuelType: "",
+ platePhoto: "",
+ licensePhoto: "",
+ scenePhoto: "",
reviewStatus: "pending",
};
}
@@ -355,6 +410,10 @@
const auditForm = reactive({
status: "approved",
comment: "",
+});
+const auditPhotos = computed(() => {
+ const r = currentAuditRow.value || {};
+ return [r.platePhoto, r.licensePhoto, r.scenePhoto].filter(Boolean);
});
// 鏌ヨ
@@ -496,5 +555,29 @@
.table_list {
margin-top: 20px;
}
+.audit-photo {
+ width: 140px;
+ height: 100px;
+ border-radius: 4px;
+ border: 1px solid #ebeef5;
+ cursor: pointer;
+}
+.audit-photo-empty {
+ font-size: 13px;
+ color: #909399;
+}
+.audit-photos {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: center;
+}
+.audit-thumb {
+ width: 96px;
+ height: 72px;
+ border-radius: 4px;
+ border: 1px solid #ebeef5;
+ cursor: pointer;
+}
</style>
diff --git a/src/views/environmentAccess/vehicleInfoCollect/index.vue b/src/views/environmentAccess/vehicleInfoCollect/index.vue
index c37475c..f41af69 100644
--- a/src/views/environmentAccess/vehicleInfoCollect/index.vue
+++ b/src/views/environmentAccess/vehicleInfoCollect/index.vue
@@ -71,6 +71,18 @@
</el-select>
</el-form-item>
+ <el-form-item label="杞︾墝鐓х墖" prop="platePhoto">
+ <PhotoCapture v-model="form.platePhoto" placeholder="鎷嶆憚杞︾墝" />
+ </el-form-item>
+
+ <el-form-item label="琛岄┒璇佺収鐗�" prop="licensePhoto">
+ <PhotoCapture v-model="form.licensePhoto" placeholder="鎷嶆憚琛岄┒璇�" />
+ </el-form-item>
+
+ <el-form-item label="杞﹁締鍏ㄦ櫙鐓х墖" prop="scenePhoto">
+ <PhotoCapture v-model="form.scenePhoto" placeholder="鎷嶆憚杞﹁締鍏ㄦ櫙" />
+ </el-form-item>
+
<div class="collect-actions">
<el-button type="primary" size="large" :loading="submitting" style="width: 100%" @click="submit">
鎻愪氦閲囬泦
@@ -85,6 +97,7 @@
import { ref, reactive } from "vue";
import { ElMessage } from "element-plus";
import { addVehicleInfo } from "@/api/environmentAccess/vehicleInfo.js";
+import PhotoCapture from "../components/PhotoCapture.vue";
defineOptions({ name: "杞﹁締淇℃伅閲囬泦" });
@@ -110,6 +123,9 @@
registeDate: "",
natureOfUse: "",
fuelType: "",
+ platePhoto: "",
+ licensePhoto: "",
+ scenePhoto: "",
});
const form = reactive(emptyForm());
@@ -186,4 +202,28 @@
.collect-actions {
margin-top: 22px;
}
+
+/* 鎵嬫満绔�傞厤 */
+@media (max-width: 768px) {
+ .collect-page {
+ padding: 12px 12px calc(48px + env(safe-area-inset-bottom));
+ min-height: auto;
+ }
+
+ .collect-header h2 {
+ font-size: 18px;
+ }
+
+ .collect-form :deep(.el-form-item) {
+ margin-bottom: 16px;
+ }
+
+ .collect-actions {
+ position: sticky;
+ bottom: 0;
+ padding: 10px 0 calc(10px + env(safe-area-inset-bottom));
+ background: #fff;
+ margin-top: 12px;
+ }
+}
</style>
--
Gitblit v1.9.3