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