feat(环保门禁): 智能巡检回传详情(AI分析/无人机轨迹结构化展示)、异常报警15秒轮询+弹窗提醒、车辆手机采集加拍照上传与移动端适配
已修改4个文件
已添加1个文件
427 ■■■■■ 文件已修改
src/views/environmentAccess/components/PhotoCapture.vue 147 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/environmentAccess/intelligentInspectionManagement/index.vue 152 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/environmentAccess/remoteMonitor/index.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/environmentAccess/vehicleInfoAudit/index.vue 85 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/environmentAccess/vehicleInfoCollect/index.vue 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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>
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="预留:AI视频分析结果" /></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>
    <!-- å·¡æ£€å›žä¼ è¯¦æƒ…:AI è§†é¢‘分析 + æ— äººæœº/机器人回传数据 -->
    <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);
};
/* ---------- å·¡æ£€å›žä¼ è¯¦æƒ…(AI è§†é¢‘分析 + æ— äººæœº/机器人回传) ---------- */
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>
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(() => {
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>
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>