src/views/equipmentManagement/ledger/index.vue
@@ -1,84 +1,160 @@
<template>
  <div class="app-container">
    <el-form :model="filters" :inline="true">
      <el-form-item label="设备名称">
        <el-input
          v-model="filters.deviceName"
          style="width: 200px"
          placeholder="请输入设备名称"
          clearable
          @change="getTableData"
        />
      </el-form-item>
      <el-form-item label="规格型号">
        <el-input
            v-model="filters.deviceModel"
            style="width: 200px"
            placeholder="请输入规格型号"
            clearable
            @change="getTableData"
        />
      </el-form-item>
      <el-form-item label="供应商">
        <el-input
            v-model="filters.supplierName"
            style="width: 200px"
            placeholder="请输入供应商"
            clearable
            @change="getTableData"
        />
      </el-form-item>
      <el-form-item label="录入日期:">
        <el-date-picker v-model="filters.entryDate" value-format="YYYY-MM-DD" format="YYYY-MM-DD" type="daterange"
                        placeholder="请选择" clearable @change="changeDaterange" />
      </el-form-item>
      <el-form-item>
        <el-button type="primary" @click="getTableData">搜索</el-button>
        <el-button @click="resetFilters">重置</el-button>
      </el-form-item>
    </el-form>
    <div class="table_list">
      <div class="actions">
        <div></div>
        <div>
          <el-button type="primary" @click="add" icon="Plus"> 新增 </el-button>
          <el-button type="info" @click="handleImport" icon="Upload">导入</el-button>
          <el-button @click="handleOut" icon="download">导出</el-button>
          <el-button
            type="danger"
            icon="Delete"
            :disabled="multipleList.length <= 0"
            @click="deleteRow(multipleList.map((item) => item.id))"
          >
            批量删除
    <div class="area-view">
      <!-- 左侧区域树 -->
      <div class="area-left">
        <div class="area-left-header">
          <el-button type="primary" @click="openAreaDialog('add')" icon="Plus">
            新增区域
          </el-button>
        </div>
        <div
          class="all-device-node"
          :class="{ active: currentAreaId === null }"
          @click="handleAllDeviceClick"
        >
          全部设备
        </div>
        <el-tree
          ref="areaTreeRef"
          v-loading="areaTreeLoading"
          :data="areaTree"
          node-key="id"
          :props="{ label: 'label', children: 'children' }"
          :expand-on-click-node="false"
          default-expand-all
          draggable
          highlight-current
          :current-node-key="currentAreaId"
          @node-click="handleAreaNodeClick"
          @node-drop="handleAreaNodeDrop"
        >
          <template #default="{ node, data }">
            <div class="area-tree-node">
              <span class="area-node-label">{{ data.label }}</span>
              <span class="area-node-count">{{ data.deviceCount ?? 0 }}</span>
              <span class="area-node-actions">
                <el-button
                  link
                  type="primary"
                  size="small"
                  @click.stop="openAreaDialog('addChild', data)"
                >
                  子级
                </el-button>
                <el-button
                  link
                  type="primary"
                  size="small"
                  @click.stop="openAreaDialog('edit', data)"
                >
                  编辑
                </el-button>
                <el-button
                  link
                  type="danger"
                  size="small"
                  @click.stop="handleAreaDelete(node, data)"
                >
                  删除
                </el-button>
              </span>
            </div>
          </template>
        </el-tree>
      </div>
      <PIMTable
        rowKey="id"
        isSelection
        :column="columns"
        :tableData="dataList"
        :page="{
          current: pagination.currentPage,
          size: pagination.pageSize,
          total: pagination.total,
        }"
        @selection-change="handleSelectionChange"
        @pagination="changePage"
      >
      </PIMTable>
      <!-- 右侧设备列表 -->
      <div class="area-right">
        <el-form :model="filters" :inline="true">
          <el-form-item label="设备名称">
            <el-input
              v-model="filters.deviceName"
              style="width: 200px"
              placeholder="请输入设备名称"
              clearable
              @change="getTableData"
            />
          </el-form-item>
          <el-form-item label="规格型号">
            <el-input
              v-model="filters.deviceModel"
              style="width: 200px"
              placeholder="请输入规格型号"
              clearable
              @change="getTableData"
            />
          </el-form-item>
          <el-form-item label="供应商">
            <el-input
              v-model="filters.supplierName"
              style="width: 200px"
              placeholder="请输入供应商"
              clearable
              @change="getTableData"
            />
          </el-form-item>
          <el-form-item label="录入日期:">
            <el-date-picker
              v-model="filters.entryDate"
              value-format="YYYY-MM-DD"
              format="YYYY-MM-DD"
              type="daterange"
              placeholder="请选择"
              clearable
              @change="changeDaterange"
            />
          </el-form-item>
          <el-form-item>
            <el-button type="primary" @click="getTableData">搜索</el-button>
            <el-button @click="handleReset">重置</el-button>
          </el-form-item>
        </el-form>
        <div class="table_list">
          <div class="actions">
            <div></div>
            <div>
              <el-button type="primary" @click="add" icon="Plus"> 新增 </el-button>
              <el-button type="info" @click="handleImport" icon="Upload">导入</el-button>
              <el-button @click="handleOut" icon="download">导出</el-button>
              <el-button
                type="danger"
                icon="Delete"
                :disabled="multipleList.length <= 0"
                @click="deleteRow(multipleList.map((item) => item.id))"
              >
                批量删除
              </el-button>
            </div>
          </div>
          <PIMTable
            rowKey="id"
            isSelection
            :column="columns"
            :tableData="dataList"
            :page="{
              current: pagination.currentPage,
              size: pagination.pageSize,
              total: pagination.total,
            }"
            @selection-change="handleSelectionChange"
            @pagination="changePage"
          >
          </PIMTable>
        </div>
      </div>
    </div>
    <Modal ref="modalRef" @success="getTableData"></Modal>
    <el-dialog v-model="qrDialogVisible" title="二维码" width="300px" draggable>
      <div style="text-align:center;">
        <img :src="qrCodeUrl" alt="二维码" style="width:200px;height:200px;" />
        <div style="margin:10px 0;">
      <div style="text-align: center">
        <img :src="qrCodeUrl" alt="二维码" style="width: 200px; height: 200px" />
        <div style="margin: 10px 0">
          <el-button type="primary" @click="downloadQRCode">下载二维码图片</el-button>
        </div>
      </div>
    </el-dialog>
    <!-- 导入对话框 -->
    <el-dialog :title="upload.title" v-model="upload.open" width="400px" append-to-body>
      <el-upload
@@ -98,7 +174,14 @@
        <template #tip>
          <div class="el-upload__tip text-center">
            <span>仅允许导入xls、xlsx格式文件。</span>
            <el-link type="primary" :underline="false" style="font-size: 12px; vertical-align: baseline; margin-left: 5px;" @click="importTemplate">下载模板</el-link>
            <el-link
              type="primary"
              :underline="false"
              style="font-size: 12px; vertical-align: baseline; margin-left: 5px"
              @click="importTemplate"
            >
              下载模板
            </el-link>
          </div>
        </template>
      </el-upload>
@@ -118,6 +201,7 @@
        <el-descriptions-item label="设备品牌">{{ detailData.deviceBrand }}</el-descriptions-item>
        <el-descriptions-item label="设备类型">{{ detailData.type }}</el-descriptions-item>
        <el-descriptions-item label="供应商">{{ detailData.supplierName }}</el-descriptions-item>
        <el-descriptions-item label="所属区域">{{ detailData.areaName }}</el-descriptions-item>
        <el-descriptions-item label="存放位置">{{ detailData.storageLocation }}</el-descriptions-item>
        <el-descriptions-item label="单位">{{ detailData.unit }}</el-descriptions-item>
        <el-descriptions-item label="数量">{{ detailData.number }}</el-descriptions-item>
@@ -128,17 +212,22 @@
        <el-descriptions-item label="税率(%)">{{ detailData.taxRate }}</el-descriptions-item>
        <el-descriptions-item label="不含税总价">{{ detailData.unTaxIncludingPriceTotal }}</el-descriptions-item>
        <el-descriptions-item label="录入日期">{{ detailData.createTime }}</el-descriptions-item>
        <el-descriptions-item label="预计运行时间">{{ detailData.planRuntimeTime ? dayjs(detailData.planRuntimeTime).format('YYYY-MM-DD') : '' }}</el-descriptions-item>
        <el-descriptions-item label="预计运行时间">{{
          detailData.planRuntimeTime ? dayjs(detailData.planRuntimeTime).format('YYYY-MM-DD') : ''
        }}</el-descriptions-item>
        <el-descriptions-item label="设备图片" :span="2">
          <div v-if="detailData.storageBlobVOs && detailData.storageBlobVOs.length > 0" style="display: flex; gap: 10px; flex-wrap: wrap;">
            <el-image
              v-for="(file, index) in detailData.storageBlobVOs"
              :key="index"
              :src="file.previewURL || file.url"
              :preview-src-list="detailData.storageBlobVOs.map(u => u.previewURL || u.url)"
          <div
            v-if="detailData.storageBlobVOs && detailData.storageBlobVOs.length > 0"
            style="display: flex; gap: 10px; flex-wrap: wrap"
          >
            <el-image
              v-for="(file, index) in detailData.storageBlobVOs"
              :key="index"
              :src="file.previewURL || file.url"
              :preview-src-list="detailData.storageBlobVOs.map((u) => u.previewURL || u.url)"
              :initial-index="index"
              style="width: 100px; height: 100px"
              fit="cover"
              style="width: 100px; height: 100px"
              fit="cover"
            />
          </div>
          <span v-else>无图片</span>
@@ -148,14 +237,57 @@
        <el-button @click="detailDialogVisible = false">关闭</el-button>
      </template>
    </el-dialog>
    <!-- 区域新增/编辑弹窗 -->
    <el-dialog
      v-model="areaDialogVisible"
      :title="areaDialogTitle"
      width="420px"
      draggable
      @closed="areaFormRef && areaFormRef.resetFields()"
    >
      <el-form :model="areaForm" :rules="areaRules" ref="areaFormRef" label-width="90px">
        <el-form-item label="上级区域" prop="parentId">
          <el-tree-select
            v-model="areaForm.parentId"
            :data="areaTree"
            node-key="id"
            :props="{ label: 'label', children: 'children' }"
            check-strictly
            clearable
            placeholder="不选则为根区域"
            style="width: 100%"
          />
        </el-form-item>
        <el-form-item label="区域名称" prop="name">
          <el-input v-model="areaForm.name" placeholder="请输入区域/车间/位置名称" clearable />
        </el-form-item>
        <el-form-item label="排序" prop="sort">
          <el-input-number v-model="areaForm.sort" :min="0" style="width: 100%" placeholder="可选" />
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button type="primary" @click="submitAreaForm">确定</el-button>
        <el-button @click="areaDialogVisible = false">取消</el-button>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
import { usePaginationApi } from "@/hooks/usePaginationApi";
// import { Search } from "@element-plus/icons-vue";
import { getLedgerPage, delLedger, getLedgerById } from "@/api/equipmentManagement/ledger";
import { onMounted, getCurrentInstance, ref, reactive } from "vue";
import {
  getLedgerPage,
  delLedger,
  getLedgerById,
} from "@/api/equipmentManagement/ledger";
import {
  getDeviceAreaTree,
  addDeviceArea,
  editDeviceArea,
  delDeviceArea,
} from "@/api/equipmentManagement/area";
import { onMounted, getCurrentInstance, ref, reactive, computed } from "vue";
import Modal from "./Modal.vue";
import { ElMessageBox, ElMessage } from "element-plus";
import { UploadFilled } from "@element-plus/icons-vue";
@@ -178,20 +310,38 @@
const detailDialogVisible = ref(false);
const detailData = ref({});
// 区域相关
const areaTree = ref([]);
const areaTreeLoading = ref(false);
const areaTreeRef = ref();
const currentAreaId = ref(null);
const areaDialogVisible = ref(false);
const areaDialogType = ref("add");
const areaFormRef = ref();
const areaForm = reactive({
  id: undefined,
  name: "",
  parentId: null,
  sort: 0,
});
const areaRules = {
  name: [{ required: true, message: "请输入区域名称", trigger: "blur" }],
};
const areaDialogTitle = computed(() => {
  if (areaDialogType.value === "edit") return "编辑区域";
  if (areaDialogType.value === "addChild") return "新增子区域";
  return "新增区域";
});
// 导入相关
const uploadRef = ref(null)
const uploadRef = ref(null);
const upload = reactive({
  // 是否显示弹出层
  open: false,
  // 弹出层标题
  title: "",
  // 是否禁用上传
  isUploading: false,
  // 设置上传的请求头部
  headers: { Authorization: "Bearer " + getToken() },
  // 上传的地址
  url: import.meta.env.VITE_APP_BASE_API + "/device/ledger/import"
})
  url: import.meta.env.VITE_APP_BASE_API + "/device/ledger/import",
});
const {
  filters,
@@ -209,6 +359,7 @@
    supplierName: undefined,
    entryDateStart: undefined,
    entryDateEnd: undefined,
    areaId: undefined,
  },
  [
    {
@@ -226,6 +377,10 @@
    {
      label: "设备类型",
      prop: "type",
    },
    {
      label: "所属区域",
      prop: "areaName",
    },
    {
      label: "供应商",
@@ -247,43 +402,167 @@
      label: "录入日期",
      prop: "createTime",
      formatData: (v) => {
        if (!v) return '';
        // 如果包含时分秒,只取日期部分
        if (v.includes(' ')) {
          return v.split(' ')[0];
        if (!v) return "";
        if (v.includes(" ")) {
          return v.split(" ")[0];
        }
        return v;
      },
    },
      {
         dataType: "action",
         label: "操作",
         align: "center",
         fixed: 'right',
         width: 180,
         operation: [
    {
      dataType: "action",
      label: "操作",
      align: "center",
      fixed: "right",
      width: 180,
      operation: [
        {
          name: "详情",
          clickFun: (row) => {
            handleDetail(row);
          },
        },
            {
               name: "编辑",
               clickFun: (row) => {
                  edit(row.id)
               },
            },
            {
               name: "二维码",
               clickFun: (row) => {
                  showQRCode(row)
               },
            },
         ],
      },
        {
          name: "编辑",
          clickFun: (row) => {
            edit(row.id);
          },
        },
        {
          name: "二维码",
          clickFun: (row) => {
            showQRCode(row);
          },
        },
      ],
    },
  ]
);
// 加载区域树
const getAreaTree = () => {
  areaTreeLoading.value = true;
  getDeviceAreaTree()
    .then((res) => {
      areaTree.value = res.data || [];
    })
    .finally(() => {
      areaTreeLoading.value = false;
    });
};
// 重置搜索与区域筛选
const handleReset = () => {
  currentAreaId.value = null;
  resetFilters();
};
// 点击"全部设备"
const handleAllDeviceClick = () => {
  currentAreaId.value = null;
  filters.areaId = undefined;
  getTableData();
};
// 点击区域节点
const handleAreaNodeClick = (data) => {
  currentAreaId.value = data.id;
  filters.areaId = data.id;
  getTableData();
};
// 拖拽调整父子关系
const handleAreaNodeDrop = (draggingNode, dropNode, dropType) => {
  const node = draggingNode.data;
  let parentId = null;
  if (dropNode && dropType === "inner") {
    parentId = dropNode.data.id;
  } else if (dropNode) {
    parentId = dropNode.data.parentId ?? null;
  } else {
    parentId = null;
  }
  if (parentId === node.id) {
    proxy.$modal.msgWarning("不能将区域挂载到自身");
    getAreaTree();
    return;
  }
  editDeviceArea({
    id: node.id,
    name: node.label,
    parentId,
    sort: node.sort,
  })
    .then(() => {
      proxy.$modal.msgSuccess("调整成功");
      getAreaTree();
    })
    .catch(() => {
      getAreaTree();
    });
};
// 打开区域弹窗
const openAreaDialog = (type, data) => {
  areaDialogType.value = type;
  areaForm.id = undefined;
  areaForm.name = "";
  areaForm.parentId = null;
  areaForm.sort = 0;
  if (type === "addChild" && data) {
    areaForm.parentId = data.id;
  } else if (type === "edit" && data) {
    areaForm.id = data.id;
    areaForm.name = data.label;
    areaForm.parentId = data.parentId ?? null;
    areaForm.sort = data.sort ?? 0;
  }
  areaDialogVisible.value = true;
};
// 提交区域表单
const submitAreaForm = () => {
  if (!areaFormRef.value) return;
  areaFormRef.value.validate((valid) => {
    if (!valid) return;
    const payload = {
      name: areaForm.name,
      parentId: areaForm.parentId ?? null,
      sort: areaForm.sort ?? 0,
    };
    const api =
      areaDialogType.value === "edit"
        ? editDeviceArea({ ...payload, id: areaForm.id })
        : addDeviceArea(payload);
    api
      .then(() => {
        proxy.$modal.msgSuccess("操作成功");
        areaDialogVisible.value = false;
        getAreaTree();
      })
      .catch(() => {});
  });
};
// 删除区域
const handleAreaDelete = (node, data) => {
  ElMessageBox.confirm("确认删除该区域吗?", "提示", {
    confirmButtonText: "确定",
    cancelButtonText: "取消",
    type: "warning",
  })
    .then(() => delDeviceArea(data.id))
    .then(() => {
      proxy.$modal.msgSuccess("删除成功");
      if (currentAreaId.value === data.id) {
        currentAreaId.value = null;
        filters.areaId = undefined;
      }
      getAreaTree();
      getTableData();
    })
    .catch(() => {});
};
// 多选后做什么
const handleSelectionChange = (selectionList) => {
@@ -305,7 +584,7 @@
};
const changePage = ({ page, limit }) => {
  pagination.currentPage = page;
   pagination.pageSize = limit;
  pagination.pageSize = limit;
  onCurrentChange(page);
};
const deleteRow = (id) => {
@@ -351,36 +630,33 @@
};
const showQRCode = async (row) => {
  // 直接使用URL,不要用JSON.stringify包装
  const qrContent = proxy.javaApi + '/device-info?deviceId=' + row.id;
  const qrContent = proxy.javaApi + "/device-info?deviceId=" + row.id;
  const qrDataUrl = await QRCode.toDataURL(qrContent, { width: 200, margin: 2 });
  // 创建canvas合成带名称的二维码图片
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');
  const canvas = document.createElement("canvas");
  const ctx = canvas.getContext("2d");
  const qrSize = 200;
  const textHeight = 30;
  const padding = 10;
  canvas.width = qrSize + padding * 2;
  canvas.height = qrSize + textHeight + padding * 2;
  // 填充白色背景
  ctx.fillStyle = '#ffffff';
  ctx.fillStyle = "#ffffff";
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  // 绘制二维码
  const qrImg = new Image();
  qrImg.src = qrDataUrl;
  await new Promise((resolve) => { qrImg.onload = resolve; });
  await new Promise((resolve) => {
    qrImg.onload = resolve;
  });
  ctx.drawImage(qrImg, padding, padding, qrSize, qrSize);
  // 绘制设备名称
  ctx.fillStyle = '#333333';
  ctx.font = 'bold 14px Arial';
  ctx.textAlign = 'center';
  ctx.fillText(row.deviceName || '', canvas.width / 2, qrSize + padding + 20);
  qrCodeUrl.value = canvas.toDataURL('image/png');
  ctx.fillStyle = "#333333";
  ctx.font = "bold 14px Arial";
  ctx.textAlign = "center";
  ctx.fillText(row.deviceName || "", canvas.width / 2, qrSize + padding + 20);
  qrCodeUrl.value = canvas.toDataURL("image/png");
  qrRowData.value = row;
  qrDialogVisible.value = true;
};
@@ -392,42 +668,100 @@
  a.click();
};
// 导入按钮操作
const handleImport = () => {
  upload.title = "设备台账导入"
  upload.open = true
}
  upload.title = "设备台账导入";
  upload.open = true;
};
// 下载模板操作
const importTemplate = () => {
  proxy.download("/device/ledger/downloadTemplate", {}, `设备台账导入模板_${new Date().getTime()}.xlsx`)
}
  proxy.download(
    "/device/ledger/downloadTemplate",
    {},
    `设备台账导入模板_${new Date().getTime()}.xlsx`
  );
};
// 文件上传中处理
const handleFileUploadProgress = (event, file, fileList) => {
  upload.isUploading = true
}
  upload.isUploading = true;
};
// 文件上传成功处理
const handleFileSuccess = (response, file, fileList) => {
  upload.open = false
  upload.isUploading = false
  proxy.$refs["uploadRef"].handleRemove(file)
  proxy.$alert("<div style='overflow: auto;overflow-x: hidden;max-height: 70vh;padding: 10px 20px 0;'>" + response.msg + "</div>", "导入结果", { dangerouslyUseHTMLString: true })
  getTableData()
}
  upload.open = false;
  upload.isUploading = false;
  proxy.$refs["uploadRef"].handleRemove(file);
  proxy.$alert(
    "<div style='overflow: auto;overflow-x: hidden;max-height: 70vh;padding: 10px 20px 0;'>" +
      response.msg +
      "</div>",
    "导入结果",
    { dangerouslyUseHTMLString: true }
  );
  getTableData();
};
// 提交上传文件
const submitFileForm = () => {
  proxy.$refs["uploadRef"].submit()
}
  proxy.$refs["uploadRef"].submit();
};
onMounted(() => {
  getAreaTree();
  getTableData();
});
</script>
<style lang="scss" scoped>
.area-view {
  display: flex;
}
.area-left {
  width: 320px;
  flex-shrink: 0;
  padding: 16px;
  background: #ffffff;
  margin-right: 16px;
}
.area-left-header {
  margin-bottom: 10px;
}
.all-device-node {
  padding: 8px 12px;
  cursor: pointer;
  font-size: 14px;
  border-radius: 4px;
  margin-bottom: 8px;
}
.all-device-node:hover {
  background: #f5f7fa;
}
.all-device-node.active {
  background: #ecf5ff;
  color: #409eff;
}
.area-tree-node {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: space-between;
  font-size: 14px;
  padding-right: 4px;
}
.area-node-count {
  color: #909399;
  font-size: 12px;
  margin-left: 6px;
}
.area-node-actions {
  display: none;
}
.area-tree-node:hover .area-node-actions {
  display: inline-flex;
}
.area-right {
  flex: 1;
  min-width: 0;
  padding: 16px;
  background: #ffffff;
}
.table_list {
  margin-top: unset;
}