buhuazhen
2 天以前 02167990874e1ac989a4baf08c8ac6e50d276cf2
feat: 新增巡检任务管理功能

- 在 pages.json 中注册巡检任务管理页面路由
- 在首页添加巡检任务管理入口并处理导航逻辑
- 新增巡检任务管理主页面,包含定时任务管理和记录查看两个标签页
- 新增表单弹窗组件用于创建和编辑巡检任务,支持设备选择、巡检人选择、任务频率配置等功能
- 新增二维码弹窗组件用于生成和打印设备二维码
- 新增附件查看组件,支持图片和视频预览
- 实现巡检任务列表展示、搜索、加载更多、下拉刷新等功能
- 集成相关 API 接口,包括任务列表获取、新增/编辑任务、删除任务等
已添加5个文件
已修改2个文件
1720 ■■■■■ 文件已修改
src/pages.json 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/index.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/inspectionManagement/components/formDia.vue 549 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/inspectionManagement/components/qrCodeDia.vue 132 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/inspectionManagement/components/viewFiles.vue 296 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/inspectionManagement/components/viewQrCodeFiles.vue 169 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/inspectionManagement/index.vue 545 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages.json
@@ -613,6 +613,15 @@
      }
    },
    {
      "path": "pages/inspectionManagement/index",
      "style": {
        "navigationBarTitleText": "巡检任务管理",
        "navigationStyle": "custom",
        "enablePullDownRefresh": true,
        "backgroundColor": "#f8f8f8"
      }
    },
    {
      "path": "pages/equipmentManagement/faultAnalysis/index",
      "style": {
        "navigationBarTitleText": "故障分析追溯",
@@ -1148,4 +1157,4 @@
    "navigationBarTitleText": "RuoYi",
    "navigationBarBackgroundColor": "#FFFFFF"
  }
}
}
src/pages/index.vue
@@ -435,6 +435,10 @@
      icon: "/static/images/icon/xunjianshangchuan@2x.png",
      label: "巡检管理",
    },
    {
      icon: "/static/images/icon/xunjianshangchuan@2x.png",
      label: "巡检任务管理",
    }
  ]);
  // å¤„理常用功能点击
@@ -657,6 +661,11 @@
      case "巡检管理":
        uni.navigateTo({
          url: "/pages/inspectionUpload/index",
        });
        break;
      case "巡检任务管理":
        uni.navigateTo({
          url: "/pages/inspectionManagement/index",
        });
        break;
      case "分析追溯":
@@ -1033,10 +1042,17 @@
      { icon: "/static/images/icon/shbeibaoxiu@2x.png", label: "设备报修" },
      { icon: "/static/images/icon/shbeibaoyang@2x.png", label: "设备保养" },
      { icon: "/static/images/icon/xunjianshangchuan@2x.png", label: "巡检管理" },
      { icon: "/static/images/icon/xunjianshangchuan@2x.png", label: "巡检任务管理" },
    ];
    const filteredEquipment = originalEquipment.filter(item => {
      return allowedMenuTitles.has(item.label);
    });
    if (filteredEquipment.some(i => i.label === "巡检管理")) {
      const task = originalEquipment.find(i => i.label === "巡检任务管理");
      if (task && !filteredEquipment.some(i => i.label === "巡检任务管理")) {
        filteredEquipment.push(task);
      }
    }
    equipmentItems.splice(0, equipmentItems.length, ...filteredEquipment);
  };
@@ -1796,4 +1812,4 @@
      box-shadow: 0 0.375rem 1.25rem rgba(0, 0, 0, 0.4);
    }
  }
</style>
</style>
src/pages/inspectionManagement/components/formDia.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,549 @@
<template>
  <u-popup :show="dialogVisitable"
           mode="center"
           :round="12"
           :zIndex="900"
           @close="cancel">
    <view class="popup-content">
      <view class="popup-title">{{ operationType === "add" ? "新增巡检任务" : "编辑巡检任务" }}</view>
      <view class="form-body">
        <view class="form-item">
          <text class="label">设备名称</text>
          <picker mode="selector"
                  :range="deviceOptions"
                  range-key="deviceName"
                  :value="deviceIndex"
                  @change="onDeviceChange">
            <view class="picker-value">{{ form.taskName || "请选择设备" }}</view>
          </picker>
        </view>
        <view class="form-item">
          <text class="label">巡检人</text>
          <view class="picker-value inspector-picker"
                @click="openInspectorPopup">
            <text>{{ inspectorText || "请选择巡检人" }}</text>
            <u-icon name="arrow-right"
                    size="14"
                    color="#999" />
          </view>
          <view class="inspector-tags"
                v-if="form.inspector?.length">
            <view v-for="userId in form.inspector"
                  :key="userId"
                  class="inspector-tag"
                  @click="toggleInspector(userId)">
              <text>{{ getUserName(userId) }}</text>
              <u-icon name="close"
                      size="11"
                      color="#1677ff" />
            </view>
          </view>
        </view>
        <view class="form-item">
          <text class="label">备注</text>
          <u-textarea v-model="form.remarks"
                      placeholder="请输入备注"
                      :height="80"
                      count />
        </view>
        <view class="form-item">
          <text class="label">任务频率</text>
          <picker mode="selector"
                  :range="frequencyOptions"
                  range-key="label"
                  :value="frequencyIndex"
                  @change="onFrequencyChange">
            <view class="picker-value">{{ currentFrequencyLabel || "请选择任务频率" }}</view>
          </picker>
        </view>
        <view class="form-item"
              v-if="form.frequencyType === 'DAILY'">
          <text class="label">时间</text>
          <picker mode="time"
                  :value="form.frequencyDetail || '08:00'"
                  @change="onDailyTimeChange">
            <view class="picker-value">{{ form.frequencyDetail || "请选择时间" }}</view>
          </picker>
        </view>
        <view class="form-item"
              v-if="form.frequencyType === 'WEEKLY'">
          <text class="label">每周日期</text>
          <picker mode="selector"
                  :range="weekOptions"
                  range-key="label"
                  :value="weekIndex"
                  @change="onWeekChange">
            <view class="picker-value">{{ currentWeekLabel || "请选择星期" }}</view>
          </picker>
        </view>
        <view class="form-item"
              v-if="form.frequencyType === 'WEEKLY'">
          <text class="label">每周时间</text>
          <picker mode="time"
                  :value="form.time || '08:00'"
                  @change="onWeekTimeChange">
            <view class="picker-value">{{ form.time || "请选择时间" }}</view>
          </picker>
        </view>
        <view class="form-item"
              v-if="form.frequencyType === 'MONTHLY'">
          <text class="label">每月日期与时间</text>
          <picker mode="date"
                  fields="day"
                  :value="monthlyDate"
                  @change="onMonthlyDateChange">
            <view class="picker-value">{{ monthlyDate || "请选择日期" }}</view>
          </picker>
          <picker mode="time"
                  :value="monthlyTime"
                  @change="onMonthlyTimeChange">
            <view class="picker-value">{{ monthlyTime || "请选择时间" }}</view>
          </picker>
        </view>
      </view>
      <view class="popup-footer">
        <u-button @click="cancel">取消</u-button>
        <u-button type="primary"
                  @click="submitForm">保存</u-button>
      </view>
    </view>
  </u-popup>
  <u-popup :show="showInspectorPopup"
           mode="bottom"
           :round="16"
           :zIndex="1100"
           @close="closeInspectorPopup">
    <view class="inspector-popup">
      <view class="inspector-header">
        <text class="inspector-title">选择巡检人</text>
      </view>
      <scroll-view scroll-y
                   class="inspector-list">
        <view v-for="item in userList"
              :key="item.userId"
              class="inspector-row"
              @click="toggleInspector(item.userId)">
          <text class="inspector-name">{{ item.nickName }}</text>
          <u-icon v-if="form.inspector.includes(item.userId)"
                  name="checkmark-circle-fill"
                  color="#1677ff"
                  size="20" />
          <u-icon v-else
                  name=""
                  color="#d5d8de"
                  size="20" />
        </view>
      </scroll-view>
      <view class="inspector-footer">
        <u-button @click="closeInspectorPopup">取消</u-button>
        <u-button type="primary"
                  @click="confirmInspector">确定</u-button>
      </view>
    </view>
  </u-popup>
</template>
<script setup>
  import { computed, reactive, ref } from "vue";
  import useUserStore from "@/store/modules/user";
  import { addOrEditTimingTask } from "@/api/inspectionManagement/index.js";
  import { userListNoPageByTenantId } from "@/api/system/user.js";
  import { getDeviceLedger } from "@/api/equipmentManagement/ledger";
  const emit = defineEmits(["closeDia"]);
  const userStore = useUserStore();
  const dialogVisitable = ref(false);
  const operationType = ref("add");
  const deviceOptions = ref([]);
  const userList = ref([]);
  const showInspectorPopup = ref(false);
  const defaultForm = () => ({
    id: undefined,
    taskId: undefined,
    taskName: "",
    inspector: [],
    inspectorIds: "",
    remarks: "",
    frequencyType: "",
    frequencyDetail: "",
    week: "",
    time: "",
    registrantId: undefined,
  });
  const form = reactive(defaultForm());
  const frequencyOptions = [
    { label: "每日", value: "DAILY" },
    { label: "每周", value: "WEEKLY" },
    { label: "每月", value: "MONTHLY" },
  ];
  const weekOptions = [
    { label: "周一", value: "MON" },
    { label: "周二", value: "TUE" },
    { label: "周三", value: "WED" },
    { label: "周四", value: "THU" },
    { label: "周五", value: "FRI" },
    { label: "周六", value: "SAT" },
    { label: "周日", value: "SUN" },
  ];
  const monthlyDate = ref("");
  const monthlyTime = ref("");
  const deviceIndex = computed(() => {
    const index = deviceOptions.value.findIndex(item => item.id === form.taskId);
    return index >= 0 ? index : 0;
  });
  const frequencyIndex = computed(() => {
    const index = frequencyOptions.findIndex(item => item.value === form.frequencyType);
    return index >= 0 ? index : 0;
  });
  const weekIndex = computed(() => {
    const index = weekOptions.findIndex(item => item.value === form.week);
    return index >= 0 ? index : 0;
  });
  const currentFrequencyLabel = computed(() => {
    return frequencyOptions.find(item => item.value === form.frequencyType)?.label || "";
  });
  const currentWeekLabel = computed(() => {
    return weekOptions.find(item => item.value === form.week)?.label || "";
  });
  const inspectorText = computed(() => {
    if (!form.inspector?.length) return "";
    const nameMap = new Map(userList.value.map(item => [item.userId, item.nickName]));
    return form.inspector.map(id => nameMap.get(id)).filter(Boolean).join("、");
  });
  const resetForm = () => {
    Object.assign(form, defaultForm());
    monthlyDate.value = "";
    monthlyTime.value = "";
    showInspectorPopup.value = false;
  };
  const loadBaseData = async () => {
    const [userRes, deviceRes] = await Promise.all([
      userListNoPageByTenantId(),
      getDeviceLedger(),
    ]);
    userList.value = userRes?.data || [];
    deviceOptions.value = deviceRes?.data || [];
  };
  const parseWeeklyDetail = detail => {
    if (!detail || typeof detail !== "string" || !detail.includes(",")) return;
    const [week, time] = detail.split(",");
    form.week = week || "";
    form.time = time || "";
  };
  const parseMonthlyDetail = detail => {
    if (!detail || typeof detail !== "string" || !detail.includes(",")) return;
    const [day, time] = detail.split(",");
    if (day) monthlyDate.value = `${new Date().getFullYear()}-${new Date().getMonth() + 1 < 10 ? `0${new Date().getMonth() + 1}` : new Date().getMonth() + 1}-${day.padStart(2, "0")}`;
    if (time) monthlyTime.value = time;
  };
  const setDeviceModel = id => {
    const matched = deviceOptions.value.find(item => item.id === id);
    if (matched) {
      form.taskId = matched.id;
      form.taskName = matched.deviceName;
    }
  };
  const openDialog = async (type, row) => {
    operationType.value = type;
    dialogVisitable.value = true;
    resetForm();
    try {
      await loadBaseData();
      if (type === "edit" && row) {
        Object.assign(form, {
          ...defaultForm(),
          ...row,
          inspector: String(row.inspectorIds || "")
            .split(",")
            .map(item => Number(item))
            .filter(Boolean),
        });
        if (form.frequencyType === "WEEKLY") parseWeeklyDetail(form.frequencyDetail);
        if (form.frequencyType === "MONTHLY") parseMonthlyDetail(form.frequencyDetail);
      }
    } catch (error) {
      uni.showToast({
        title: "初始化失败",
        icon: "none",
      });
    }
  };
  const onDeviceChange = e => {
    const index = Number(e?.detail?.value || 0);
    const selected = deviceOptions.value[index];
    if (!selected) return;
    setDeviceModel(selected.id);
  };
  const getUserName = userId => {
    const user = userList.value.find(item => item.userId === userId);
    return user?.nickName || "";
  };
  const openInspectorPopup = () => {
    showInspectorPopup.value = true;
  };
  const closeInspectorPopup = () => {
    showInspectorPopup.value = false;
  };
  const toggleInspector = userId => {
    if (form.inspector.includes(userId)) {
      form.inspector = form.inspector.filter(id => id !== userId);
    } else {
      form.inspector = [...form.inspector, userId];
    }
  };
  const confirmInspector = () => {
    showInspectorPopup.value = false;
  };
  const onFrequencyChange = e => {
    const index = Number(e?.detail?.value || 0);
    const selected = frequencyOptions[index];
    form.frequencyType = selected?.value || "";
    form.frequencyDetail = "";
    form.week = "";
    form.time = "";
    monthlyDate.value = "";
    monthlyTime.value = "";
  };
  const onDailyTimeChange = e => {
    form.frequencyDetail = e?.detail?.value || "";
  };
  const onWeekChange = e => {
    const index = Number(e?.detail?.value || 0);
    const selected = weekOptions[index];
    form.week = selected?.value || "";
  };
  const onWeekTimeChange = e => {
    form.time = e?.detail?.value || "";
  };
  const onMonthlyDateChange = e => {
    monthlyDate.value = e?.detail?.value || "";
  };
  const onMonthlyTimeChange = e => {
    monthlyTime.value = e?.detail?.value || "";
  };
  const validateForm = () => {
    if (!form.taskId) {
      uni.showToast({ title: "请选择设备", icon: "none" });
      return false;
    }
    if (!form.inspector.length) {
      uni.showToast({ title: "请选择巡检人", icon: "none" });
      return false;
    }
    if (!form.frequencyType) {
      uni.showToast({ title: "请选择任务频率", icon: "none" });
      return false;
    }
    if (form.frequencyType === "DAILY" && !form.frequencyDetail) {
      uni.showToast({ title: "请选择时间", icon: "none" });
      return false;
    }
    if (form.frequencyType === "WEEKLY" && (!form.week || !form.time)) {
      uni.showToast({ title: "请选择每周日期和时间", icon: "none" });
      return false;
    }
    if (form.frequencyType === "MONTHLY" && (!monthlyDate.value || !monthlyTime.value)) {
      uni.showToast({ title: "请选择每月日期和时间", icon: "none" });
      return false;
    }
    return true;
  };
  const buildFrequencyDetail = () => {
    if (form.frequencyType === "WEEKLY") return `${form.week},${form.time}`;
    if (form.frequencyType === "MONTHLY") {
      const day = monthlyDate.value.split("-")[2] || "";
      return `${day},${monthlyTime.value}`;
    }
    return form.frequencyDetail;
  };
  const submitForm = async () => {
    if (!validateForm()) return;
    try {
      const userInfo = await userStore.getInfo();
      const payload = {
        ...form,
        inspectorIds: form.inspector.join(","),
        frequencyDetail: buildFrequencyDetail(),
        registrantId: userInfo?.user?.userId,
      };
      delete payload.inspector;
      delete payload.week;
      delete payload.time;
      await addOrEditTimingTask(payload);
      uni.showToast({
        title: "提交成功",
        icon: "success",
      });
      cancel();
    } catch (error) {
      uni.showToast({
        title: "提交失败,请重试",
        icon: "none",
      });
    }
  };
  const cancel = () => {
    dialogVisitable.value = false;
    resetForm();
    emit("closeDia");
  };
  defineExpose({ openDialog });
</script>
<style scoped lang="scss">
  :deep(.uni-picker-container) {
    z-index: 1200 !important;
  }
  .popup-content {
    width: 88vw;
    max-height: 80vh;
    background: #fff;
    border-radius: 20rpx;
    overflow: hidden;
  }
  .popup-title {
    text-align: center;
    font-size: 32rpx;
    color: #1f1f1f;
    font-weight: 600;
    padding: 24rpx 20rpx;
    border-bottom: 1rpx solid #f0f0f0;
  }
  .form-body {
    padding: 24rpx;
    max-height: 56vh;
    overflow-y: auto;
  }
  .form-item {
    margin-bottom: 20rpx;
  }
  .label {
    display: block;
    margin-bottom: 10rpx;
    font-size: 24rpx;
    color: #666;
  }
  .picker-value {
    min-height: 72rpx;
    background: #f7f8fa;
    border-radius: 12rpx;
    padding: 0 20rpx;
    display: flex;
    align-items: center;
    color: #333;
    font-size: 26rpx;
  }
  .inspector-picker {
    justify-content: space-between;
  }
  .inspector-tags {
    display: flex;
    flex-wrap: wrap;
    margin-top: 10rpx;
    gap: 10rpx;
  }
  .inspector-tag {
    display: flex;
    align-items: center;
    gap: 8rpx;
    background: #edf3ff;
    color: #1677ff;
    font-size: 22rpx;
    border-radius: 999rpx;
    padding: 6rpx 14rpx;
  }
  .popup-footer {
    display: flex;
    gap: 16rpx;
    padding: 20rpx 24rpx 24rpx;
    border-top: 1rpx solid #f0f0f0;
  }
  .inspector-popup {
    background: #fff;
    border-radius: 24rpx 24rpx 0 0;
    overflow: hidden;
    max-height: 70vh;
    padding-bottom: env(safe-area-inset-bottom);
  }
  .inspector-header {
    padding: 24rpx;
    text-align: center;
    border-bottom: 1rpx solid #f0f0f0;
  }
  .inspector-title {
    color: #1f1f1f;
    font-size: 30rpx;
    font-weight: 600;
  }
  .inspector-list {
    max-height: 46vh;
    padding: 0 24rpx;
  }
  .inspector-row {
    min-height: 82rpx;
    display: flex;
    justify-content: space-between;
    align-items: center;
    border-bottom: 1rpx solid #f5f5f5;
  }
  .inspector-name {
    font-size: 26rpx;
    color: #333;
  }
  .inspector-footer {
    display: flex;
    gap: 16rpx;
    padding: 20rpx 24rpx 24rpx;
  }
</style>
src/pages/inspectionManagement/components/qrCodeDia.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,132 @@
<template>
  <div>
    <el-dialog :title="operationType === 'add' ? '新增二维码' : '编辑二维码'"
               v-model="dialogVisitable" width="500px" @close="cancel">
      <el-form :model="form" :rules="rules" ref="formRef" label-width="120px">
        <el-row>
          <el-col :span="24">
            <el-form-item label="设备名称" prop="deviceName">
              <el-input v-model="form.deviceName" placeholder="请输入设备名称" maxlength="30" />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row>
          <el-col :span="24">
            <el-form-item label="所在位置描述" prop="location">
              <el-input v-model="form.location" placeholder="请输入所在位置描述" maxlength="30"/>
            </el-form-item>
          </el-col>
        </el-row>
      </el-form>
      <div>
        <el-button type="primary" @click="submitForm">生成并打印二维码</el-button>
      </div>
      <div v-if="isShowQrCode" class="print-section" ref="qrCodeContainer" id="qrCodeContainer">
        <vue-qrcode :value="qrCodeValue" :width="qrCodeSize"></vue-qrcode>
      </div>
    </el-dialog>
  </div>
</template>
<script setup>
import useUserStore from "@/store/modules/user.js";
import {reactive, ref} from "vue";
import printJS from 'print-js';
import {addOrEditQrCode} from "@/api/inspectionUpload/index.js";
const { proxy } = getCurrentInstance()
const emit = defineEmits()
const userStore = useUserStore()
const dialogVisitable = ref(false);
const isShowQrCode = ref(false);
const operationType = ref('add');
const qrCodeValue = ref('');
const qrCodeSize = ref(100);
const data = reactive({
  form: {
    deviceName: '',
    location: '',
    qrCodeId: '',
    id: ''
  },
  rules: {
    deviceName: [{ required: true, message: '请输入设备名称', trigger: 'blur' }],
    location: [{ required: true, message: '请输入地点', trigger: 'blur' }]
  }
})
const { form, rules } = toRefs(data)
// æ‰“开弹框
const openDialog = async (type, row) => {
  dialogVisitable.value = true
  qrCodeValue.value = ''
  isShowQrCode.value = false;
  if (type === 'edit') {
    form.value.id = row.id
    form.value.qrCodeId = row.id
    form.value.deviceName = row.deviceName
    form.value.location = row.location
    // å°†è¡¨å•数据转为 JSON å­—符串作为二维码内容
    qrCodeValue.value = JSON.stringify(form.value);
    isShowQrCode.value = true;
  }
}
// æäº¤åˆå¹¶è¡¨å•
const submitForm = () => {
  proxy.$refs["formRef"].validate(valid => {
    if (valid) {
      addOrEditQrCode(form.value).then((res) => {
        form.value.qrCodeId = res.data
      })
      // å°†è¡¨å•数据转为 JSON å­—符串作为二维码内容
      qrCodeValue.value = JSON.stringify(form.value);
      isShowQrCode.value = true;
      showQrCode()
    }
  })
}
const showQrCode = () => {
  // å»¶è¿Ÿæ‰§è¡Œæ‰“印,避免 DOM æ›´æ–°å‰å°±è°ƒç”¨æ‰“印
  setTimeout(() => {
    printJS({
      printable: 'qrCodeContainer',//页面
      type: "html",//文档类型
      maxWidth: 360,
      style: `@page {
                margin:0;
                size: 400px 75px collapse;
                margin-top:3px;
                &:first-of-type{
                  margin-top:0 !important;
                }
              }
              html{
                zoom:100%;
              }
              @media print{
                width: 400px;
                height: 75px;
                margin:0;
              }`,
      targetStyles: ["*"], // ä½¿ç”¨dom的所有样式,很重要
      font_size: '0.20cm',
    });
  }, 300);
}
// å…³é—­åˆå¹¶è¡¨å•
const cancel = () => {
  proxy.resetForm("formRef")
  dialogVisitable.value = false
  emit('closeDia')
}
defineExpose({ openDialog })
</script>
<style scoped>
.print-section {
  text-align: center;
  margin-top: 30px;
}
</style>
src/pages/inspectionManagement/components/viewFiles.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,296 @@
<template>
  <u-popup :show="dialogVisitable"
           mode="bottom"
           :round="16"
           @close="cancel">
    <view class="popup-content">
      <view class="popup-header">
        <text class="popup-title">查看附件</text>
        <view class="close-icon"
              @click="cancel">
          <u-icon name="close"
                  size="14"
                  color="#666" />
        </view>
      </view>
      <view class="popup-body">
        <view class="tabs">
          <view v-for="tab in tabs"
                :key="tab.key"
                class="tab-item"
                :class="{ active: currentType === tab.key }"
                @click="currentType = tab.key">
            {{ tab.label }} ({{ getCurrentList(tab.key).length }})
          </view>
        </view>
        <view class="file-list"
              v-if="getCurrentList(currentType).length">
          <view v-for="(file, index) in getCurrentList(currentType)"
                :key="index"
                class="file-item"
                @click="previewFile(file)">
            <image v-if="isImageFile(file)"
                   :src="file.url"
                   class="thumb"
                   mode="aspectFill" />
            <view v-else
                  class="video-thumb">
              <u-icon name="video"
                      size="28"
                      color="#1677ff" />
              <text class="video-text">视频</text>
            </view>
            <text class="name">{{ file.name || "附件" }}</text>
          </view>
        </view>
        <view v-else
              class="empty">
          <text>暂无附件</text>
        </view>
      </view>
    </view>
  </u-popup>
  <u-popup :show="showVideoPopup"
           mode="center"
           :round="10"
           @close="closeVideoPopup">
    <view class="video-container">
      <video :src="videoUrl"
             controls
             autoplay
             class="video-player" />
    </view>
  </u-popup>
</template>
<script setup>
  import { ref } from "vue";
  import config from "@/config";
  const dialogVisitable = ref(false);
  const currentType = ref("before");
  const showVideoPopup = ref(false);
  const videoUrl = ref("");
  const filesMap = ref({
    before: [],
    after: [],
    issue: [],
  });
  const tabs = [
    { key: "before", label: "生产前" },
    { key: "after", label: "生产中" },
    { key: "issue", label: "生产后" },
  ];
  const normalizeUrl = raw => {
    if (!raw) return "";
    const url = String(raw).trim();
    if (!url) return "";
    if (/^https?:\/\//i.test(url)) return url;
    if (url.startsWith("/")) return `${config.fileUrl}${url}`;
    if (/^[a-zA-Z]:\\/.test(url)) {
      const normalized = url.replace(/\\/g, "/");
      const idx = normalized.indexOf("/prod/");
      if (idx >= 0) {
        const relative = normalized.slice(idx + "/prod/".length);
        return `${config.fileUrl}/${relative}`;
      }
      return normalized;
    }
    return `${config.fileUrl}/${url.replace(/^\//, "")}`;
  };
  const isImageFile = file => {
    if (file?.contentType?.startsWith("image/")) return true;
    const name = String(file?.name || file?.bucketFilename || "").toLowerCase();
    return /\.(jpg|jpeg|png|gif|bmp|webp)$/.test(name);
  };
  const normalizeFile = (file, type) => ({
    ...file,
    type,
    url: normalizeUrl(file?.url || file?.downloadUrl),
    name: file?.originalFilename || file?.bucketFilename || file?.name,
  });
  const getCurrentList = type => filesMap.value[type] || [];
  const previewFile = file => {
    if (isImageFile(file)) {
      const urls = getCurrentList(currentType.value)
        .filter(item => isImageFile(item))
        .map(item => item.url);
      uni.previewImage({
        urls,
        current: file.url,
      });
      return;
    }
    videoUrl.value = file.url;
    showVideoPopup.value = true;
  };
  const closeVideoPopup = () => {
    showVideoPopup.value = false;
    videoUrl.value = "";
  };
  const openDialog = row => {
    const allList = Array.isArray(row?.commonFileList) ? row.commonFileList : [];
    const beforeList = Array.isArray(row?.commonFileListBefore)
      ? row.commonFileListBefore
      : allList.filter(item => item?.type === 10);
    const afterList = Array.isArray(row?.commonFileListAfter)
      ? row.commonFileListAfter
      : allList.filter(item => item?.type === 11);
    const issueList = Array.isArray(row?.commonFileListIssue)
      ? row.commonFileListIssue
      : allList.filter(item => item?.type === 12);
    filesMap.value = {
      before: beforeList.map(item => normalizeFile(item, "before")).filter(item => item.url),
      after: afterList.map(item => normalizeFile(item, "after")).filter(item => item.url),
      issue: issueList.map(item => normalizeFile(item, "issue")).filter(item => item.url),
    };
    currentType.value = "before";
    dialogVisitable.value = true;
  };
  const cancel = () => {
    dialogVisitable.value = false;
    closeVideoPopup();
    filesMap.value = {
      before: [],
      after: [],
      issue: [],
    };
  };
  defineExpose({ openDialog });
</script>
<style scoped lang="scss">
  .popup-content {
    width: 100vw;
    max-height: 82vh;
    background: #fff;
    border-radius: 24rpx 24rpx 0 0;
    overflow: hidden;
    padding-bottom: env(safe-area-inset-bottom);
  }
  .popup-header {
    padding: 24rpx 20rpx;
    border-bottom: 1rpx solid #f0f0f0;
    text-align: center;
    position: relative;
  }
  .popup-title {
    font-size: 32rpx;
    color: #1f1f1f;
    font-weight: 600;
  }
  .popup-body {
    padding: 20rpx 20rpx 26rpx;
    max-height: 68vh;
    overflow-y: auto;
  }
  .tabs {
    display: flex;
    background: #f4f5f8;
    border-radius: 12rpx;
    padding: 6rpx;
    margin-bottom: 20rpx;
  }
  .tab-item {
    flex: 1;
    text-align: center;
    padding: 12rpx 0;
    color: #666;
    font-size: 24rpx;
    border-radius: 10rpx;
  }
  .tab-item.active {
    background: #1677ff;
    color: #fff;
    font-weight: 600;
  }
  .file-list {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    gap: 16rpx;
  }
  .file-item {
    background: #fafafa;
    border-radius: 12rpx;
    padding: 10rpx;
  }
  .thumb {
    width: 100%;
    height: 180rpx;
    border-radius: 8rpx;
  }
  .video-thumb {
    width: 100%;
    height: 180rpx;
    border-radius: 8rpx;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    background: #edf3ff;
  }
  .video-text {
    font-size: 22rpx;
    color: #1677ff;
    margin-top: 6rpx;
  }
  .name {
    margin-top: 8rpx;
    font-size: 22rpx;
    color: #333;
    display: block;
    word-break: break-all;
  }
  .empty {
    text-align: center;
    color: #999;
    padding: 40rpx 0;
  }
  .video-container {
    width: 94vw;
    background: #000;
  }
  .video-player {
    width: 94vw;
    height: 55vw;
  }
  .close-icon {
    position: absolute;
    right: 24rpx;
    top: 50%;
    transform: translateY(-50%);
    width: 44rpx;
    height: 44rpx;
    border-radius: 50%;
    background: #f5f5f5;
    display: flex;
    align-items: center;
    justify-content: center;
  }
</style>
src/pages/inspectionManagement/components/viewQrCodeFiles.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,169 @@
<template>
  <div>
    <el-dialog title="查看附件"
               v-model="dialogVisitable" width="800px" @close="cancel">
      <div class="upload-container">
        <div class="form-container">
          <div class="title">巡检附件</div>
          <!-- å›¾ç‰‡åˆ—表 -->
          <div style="display: flex; flex-wrap: wrap;">
            <img v-for="(item, index) in beforeProductionImgs" :key="index"
                 @click="showMedia(beforeProductionImgs, index, 'image')"
                 :src="item" style="max-width: 100px; height: 100px; margin: 5px;" alt="">
          </div>
          <!-- è§†é¢‘列表 -->
          <div style="display: flex; flex-wrap: wrap;">
            <div
                v-for="(videoUrl, index) in beforeProductionVideos"
                :key="index"
                @click="showMedia(beforeProductionVideos, index, 'video')"
                style="position: relative; margin: 10px; cursor: pointer;"
            >
              <div style="width: 160px; height: 90px; background-color: #333; display: flex; align-items: center; justify-content: center;">
                <img src="@/assets/images/video.png" alt="播放" style="width: 30px; height: 30px; opacity: 0.8;" />
              </div>
              <div style="text-align: center; font-size: 12px; color: #666;">点击播放</div>
            </div>
          </div>
        </div>
      </div>
    </el-dialog>
    <!-- ç»Ÿä¸€åª’体查看器 -->
    <div v-if="isMediaViewerVisible" class="media-viewer-overlay" @click.self="closeMediaViewer">
      <div class="media-viewer-content" @click.stop>
        <!-- å›¾ç‰‡ -->
        <vue-easy-lightbox
            v-if="mediaType === 'image'"
            :visible="isMediaViewerVisible"
            :imgs="mediaList"
            :index="currentMediaIndex"
            @hide="closeMediaViewer"
        ></vue-easy-lightbox>
        <!-- è§†é¢‘ -->
        <div v-else-if="mediaType === 'video'" style="position: relative;">
          <Video
              :src="mediaList[currentMediaIndex]"
              autoplay
              controls
              style="max-width: 90vw; max-height: 80vh;"
          />
        </div>
      </div>
    </div>
  </div>
</template>
<script setup>
// æŽ§åˆ¶å¼¹çª—显示
import VueEasyLightbox from "vue-easy-lightbox";
const dialogVisitable = ref(false);
// å›¾ç‰‡æ•°ç»„
const beforeProductionImgs = ref([]);
// è§†é¢‘数组
const beforeProductionVideos = ref([]);
// åª’体查看器状态
const isMediaViewerVisible = ref(false);
const currentMediaIndex = ref(0);
const mediaList = ref([]); // å­˜å‚¨å½“前要查看的媒体列表(含图片和视频对象)
const mediaType = ref('image'); // image | video
// æ‰“开弹窗并加载数据
const openDialog = async (row) => {
  const { images: beforeImgs, videos: beforeVids } = processItems(row.storageBlobDTO);
  beforeProductionImgs.value = beforeImgs;
  beforeProductionVideos.value = beforeVids;
  dialogVisitable.value = true;
};
// æ˜¾ç¤ºåª’体(图片 or è§†é¢‘)
function showMedia(mediaArray, index, type) {
  mediaList.value = mediaArray;
  currentMediaIndex.value = index;
  mediaType.value = type;
  isMediaViewerVisible.value = true;
}
// å…³é—­åª’体查看器
function closeMediaViewer() {
  isMediaViewerVisible.value = false;
  mediaList.value = [];
  mediaType.value = 'image';
}
// è¡¨å•关闭方法
const cancel = () => {
  dialogVisitable.value = false;
};
// å¤„理每一类数据:分离图片和视频
function processItems(items) {
  const images = [];
  const videos = [];
  items.forEach(item => {
    if (item.contentType?.startsWith('image/')) {
      images.push(item.url);
    } else if (item.contentType?.startsWith('video/')) {
      videos.push(item.url);
    }
  });
  return { images, videos };
}
defineExpose({ openDialog });
</script>
<style scoped lang="scss">
.upload-container {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 20px;
  border: 1px solid #dcdfe6;
  box-sizing: border-box;
  .form-container {
    flex: 1;
    width: 100%;
    margin-bottom: 20px;
  }
}
.title {
  font-size: 14px;
  color: #165dff;
  line-height: 20px;
  font-weight: 600;
  padding-left: 10px;
  position: relative;
  margin: 6px 0;
  &::before {
    content: "";
    position: absolute;
    left: 0;
    top: 3px;
    width: 4px;
    height: 14px;
    background-color: #165dff;
  }
}
.media-viewer-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(0, 0, 0, 0.8);
  z-index: 9999;
  display: flex;
  align-items: center;
  justify-content: center;
}
.media-viewer-content {
  position: relative;
  max-width: 90vw;
  max-height: 90vh;
  overflow: hidden;
}
</style>
src/pages/inspectionManagement/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,545 @@
<template>
  <view class="inspection-management-page">
    <PageHeader title="巡检任务管理"
                @back="goBack" />
    <view class="toolbar">
      <view class="tab-wrap">
        <view v-for="tab in tabs"
              :key="tab.name"
              class="tab-item"
              :class="{ active: activeTab === tab.name }"
              @click="switchTab(tab.name)">
          {{ tab.label }}
        </view>
      </view>
      <view class="search-section">
        <view class="search-bar">
          <view class="search-input">
            <up-input class="search-text"
                      placeholder="请输入巡检任务名称"
                      v-model="queryParams.taskName"
                      clearable />
          </view>
          <view class="search-button"
                @click="handleQuery">
            <up-icon name="search"
                     size="24"
                     color="#999"></up-icon>
          </view>
        </view>
      </view>
      <view class="meta-bar">
        <text class="meta-text">共 {{ total }} æ¡</text>
      </view>
    </view>
    <view class="list-section">
      <uni-swipe-action>
        <uni-swipe-action-item v-for="item in tableData"
                               :key="item.id"
                               :right-options="activeTab === 'taskManage' ? swipeOptions : []"
                               :disabled="activeTab !== 'taskManage'"
                               @click="onSwipeActionClick($event, item)">
          <view class="ledger-item">
            <view class="item-header">
              <view class="item-left">
                <view class="document-icon">
                  <up-icon name="file-text"
                           size="14"
                           color="#ffffff"></up-icon>
                </view>
                <text class="item-id">{{ item.taskName || "--" }}</text>
              </view>
              <view class="item-right">
                <u-tag :type="getFrequencyTagType(item.frequencyType)"
                       :text="formatFrequency(item.frequencyType) || '未知频次'" />
              </view>
            </view>
            <up-divider></up-divider>
            <view class="item-details">
              <view class="detail-row">
                <text class="detail-label">任务编号</text>
                <text class="detail-value">{{ item.id || "--" }}</text>
              </view>
              <view class="detail-row">
                <text class="detail-label">执行巡检人</text>
                <view class="tag-wrap"
                      v-if="item.inspector?.length">
                  <uni-tag v-for="(person, index) in item.inspector"
                           :key="index"
                           :text="person"
                           type="primary"
                           size="small"
                           inverted />
                </view>
                <text class="detail-value"
                      v-else>--</text>
              </view>
              <view class="detail-row">
                <text class="detail-label">开始日期与时间</text>
                <text class="detail-value highlight">{{ formatFrequencyDetail(item.frequencyDetail) }}</text>
              </view>
              <view class="detail-row">
                <text class="detail-label">登记人</text>
                <text class="detail-value">{{ item.registrant || "--" }}</text>
              </view>
              <view class="detail-row">
                <text class="detail-label">登记日期</text>
                <text class="detail-value">{{ item.createTime || "--" }}</text>
              </view>
              <view class="detail-row">
                <text class="detail-label">备注</text>
                <text class="detail-value">{{ item.remarks || "无" }}</text>
              </view>
              <up-divider></up-divider>
              <view class="card-actions">
                <u-button v-if="activeTab === 'taskManage'"
                          type="primary"
                          size="small"
                          class="action-btn"
                          @click.stop="handleAdd(item)">编辑</u-button>
                <u-button v-else
                          type="success"
                          size="small"
                          class="action-btn"
                          @click.stop="viewFile(item)">查看附件</u-button>
              </view>
            </view>
          </view>
        </uni-swipe-action-item>
      </uni-swipe-action>
      <uni-load-more :status="loadMoreStatus"></uni-load-more>
      <view v-if="!loading && tableData.length === 0"
            class="no-data">
        <text>暂无数据</text>
      </view>
    </view>
    <view v-if="activeTab === 'taskManage'"
          class="fab-button"
          @click="handleAdd()">
      <up-icon name="plus"
               size="24"
               color="#ffffff"></up-icon>
    </view>
    <form-dia ref="formDia"
              @closeDia="handleQuery" />
    <view-files ref="viewFiles" />
  </view>
</template>
<script setup>
  import { ref, reactive, computed, nextTick } from "vue";
  import { onShow, onReachBottom, onPullDownRefresh } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import FormDia from "@/pages/inspectionManagement/components/formDia.vue";
  import ViewFiles from "@/pages/inspectionManagement/components/viewFiles.vue";
  import {
    delTimingTask,
    inspectionTaskList,
    timingTaskList,
  } from "@/api/inspectionManagement/index.js";
  const formDia = ref();
  const viewFiles = ref();
  const activeTab = ref("taskManage");
  const tabs = [
    { name: "taskManage", label: "定时任务管理" },
    { name: "task", label: "定时任务记录" },
  ];
  const queryParams = reactive({
    taskName: "",
  });
  const pageParams = reactive({
    current: 1,
    size: 10,
  });
  const total = ref(0);
  const loading = ref(false);
  const tableData = ref([]);
  const swipeOptions = [
    {
      text: "删除",
      style: {
        backgroundColor: "#ee0a24",
      },
    },
  ];
  const noMore = computed(() => tableData.value.length >= total.value);
  const loadMoreStatus = computed(() => {
    if (loading.value) return "loading";
    if (noMore.value) return "noMore";
    return "more";
  });
  const goBack = () => {
    uni.navigateBack();
  };
  const formatFrequency = value => {
    if (value === "DAILY") return "每日";
    if (value === "WEEKLY") return "每周";
    if (value === "MONTHLY") return "每月";
    if (value === "QUARTERLY") return "季度";
    return "";
  };
  const getFrequencyTagType = value => {
    if (value === "DAILY") return "success";
    if (value === "WEEKLY") return "primary";
    if (value === "MONTHLY") return "warning";
    return "info";
  };
  const formatFrequencyDetail = value => {
    if (!value || typeof value !== "string") return "--";
    return value.replace(
      /MON|TUE|WED|THU|FRI|SAT|SUN/g,
      item =>
        ({
          MON: "周一",
          TUE: "周二",
          WED: "周三",
          THU: "周四",
          FRI: "周五",
          SAT: "周六",
          SUN: "周日",
        })[item] || item
    );
  };
  const normalizeInspector = val => {
    if (!val) return [];
    if (Array.isArray(val)) return val.filter(Boolean);
    if (typeof val === "string") {
      return val
        .split(",")
        .map(item => item.trim())
        .filter(Boolean);
    }
    return [String(val)];
  };
  const switchTab = tabName => {
    if (activeTab.value === tabName) return;
    activeTab.value = tabName;
    handleQuery();
  };
  const getList = async () => {
    if (loading.value) return;
    loading.value = true;
    try {
      const params = {
        ...queryParams,
        current: pageParams.current,
        size: pageParams.size,
      };
      const request = activeTab.value === "task" ? inspectionTaskList : timingTaskList;
      const res = await request(params);
      const records = res?.data?.records || [];
      const normalized = records.map(item => ({
        ...item,
        inspector: normalizeInspector(item.inspector),
      }));
      if (pageParams.current === 1) {
        tableData.value = normalized;
      } else {
        tableData.value = [...tableData.value, ...normalized];
      }
      total.value = Number(res?.data?.total || 0);
    } catch (error) {
      if (pageParams.current === 1) {
        tableData.value = [];
      }
      uni.showToast({
        title: "获取数据失败",
        icon: "none",
      });
    } finally {
      loading.value = false;
    }
  };
  const handleQuery = () => {
    pageParams.current = 1;
    total.value = 0;
    getList();
  };
  const loadMore = () => {
    if (loading.value || noMore.value) return;
    pageParams.current += 1;
    getList();
  };
  const handleAdd = row => {
    nextTick(() => {
      formDia.value?.openDialog(row ? "edit" : "add", row);
    });
  };
  const viewFile = row => {
    nextTick(() => {
      viewFiles.value?.openDialog(row);
    });
  };
  const deleteOne = async row => {
    if (!row?.id) return;
    const canDelete = await new Promise(resolve => {
      uni.showModal({
        title: "提示",
        content: "是否确认删除该巡检任务?",
        success: modalRes => resolve(Boolean(modalRes.confirm)),
        fail: () => resolve(false),
      });
    });
    if (!canDelete) return;
    try {
      await delTimingTask([row.id]);
      uni.showToast({
        title: "删除成功",
        icon: "success",
      });
      handleQuery();
    } catch (error) {
      uni.showToast({
        title: "删除失败",
        icon: "none",
      });
    }
  };
  const onSwipeActionClick = (event, row) => {
    if (activeTab.value !== "taskManage") return;
    if (event?.position !== "right") return;
    deleteOne(row);
  };
  onShow(() => {
    handleQuery();
  });
  onReachBottom(() => {
    loadMore();
  });
  onPullDownRefresh(() => {
    handleQuery();
    uni.stopPullDownRefresh();
  });
</script>
<style scoped>
  .inspection-management-page {
    min-height: 100vh;
    background: #f6f7fb;
  }
  .toolbar {
    padding: 20rpx 24rpx;
    background: #fff;
    border-bottom: 1rpx solid #f0f0f0;
    position: sticky;
    top: 0;
    z-index: 10;
  }
  .tab-wrap {
    display: flex;
    background: #f4f5f8;
    border-radius: 16rpx;
    padding: 6rpx;
  }
  .tab-item {
    flex: 1;
    text-align: center;
    padding: 14rpx 0;
    font-size: 26rpx;
    color: #666;
    border-radius: 12rpx;
  }
  .tab-item.active {
    background: #1677ff;
    color: #fff;
    font-weight: 600;
  }
  .search-section {
    margin-top: 20rpx;
  }
  .search-bar {
    display: flex;
    align-items: center;
    background: #f7f8fa;
    border-radius: 14rpx;
    padding: 8rpx 12rpx 8rpx 16rpx;
    border: 1rpx solid #eef1f5;
  }
  .search-input {
    flex: 1;
    min-width: 0;
  }
  .search-text {
    background: transparent !important;
  }
  :deep(.search-text .u-input__content),
  :deep(.search-text .up-input__content) {
    background: transparent !important;
    padding: 0 !important;
  }
  .search-button {
    width: 64rpx;
    height: 64rpx;
    border-radius: 12rpx;
    background: #ffffff;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .meta-bar {
    margin-top: 16rpx;
    display: flex;
    justify-content: space-between;
    align-items: center;
    background: #f7f9fc;
    border-radius: 12rpx;
    padding: 10rpx 16rpx;
  }
  .meta-text {
    color: #5c6b8a;
    font-size: 22rpx;
  }
  .list-section {
    padding: 20rpx 24rpx;
    padding-bottom: calc(132rpx + env(safe-area-inset-bottom));
  }
  .ledger-item {
    background: #ffffff;
    border-radius: 20rpx;
    margin-bottom: 16rpx;
    overflow: hidden;
    box-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.05);
    padding: 0 20rpx;
  }
  .item-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 22rpx 0;
  }
  .item-left {
    display: flex;
    align-items: center;
    gap: 10rpx;
    flex: 1;
    min-width: 0;
  }
  .item-right {
    display: flex;
    align-items: center;
  }
  .document-icon {
    width: 38rpx;
    height: 38rpx;
    border-radius: 8rpx;
    background: #2979ff;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .item-id {
    font-size: 28rpx;
    color: #1f1f1f;
    font-weight: 600;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .item-details {
    padding: 18rpx 0 20rpx;
  }
  .detail-row {
    display: flex;
    align-items: flex-end;
    justify-content: space-between;
    margin-bottom: 12rpx;
  }
  .detail-label {
    min-width: 160rpx;
    color: #777;
    font-size: 24rpx;
  }
  .detail-value {
    flex: 1;
    color: #333;
    font-size: 24rpx;
    text-align: right;
    word-break: break-all;
    margin-left: 12rpx;
  }
  .detail-value.highlight {
    color: #2979ff;
    font-weight: 500;
  }
  .tag-wrap {
    flex: 1;
    display: flex;
    gap: 10rpx;
    flex-wrap: wrap;
    justify-content: flex-end;
  }
  .card-actions {
    padding-top: 8rpx;
    display: flex;
    justify-content: flex-end;
  }
  .action-btn {
    min-width: 140rpx;
  }
  .fab-button {
    position: fixed;
    bottom: calc(30px + env(safe-area-inset-bottom));
    right: 30px;
    width: 56px;
    height: 56px;
    background: #2979ff;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    box-shadow: 0 4px 16px rgba(41, 121, 255, 0.3);
    z-index: 1000;
  }
  .no-data {
    padding: 40rpx 0;
    text-align: center;
    color: #999;
  }
</style>