소스 검색

台账信息财务信息

yanghao 5 일 전
부모
커밋
ce9308e38e

+ 24 - 0
src/router/modules/remaining.ts

@@ -2294,6 +2294,30 @@ const remainingRouter: AppRouteRecordRaw[] = [
           title: '事故事件详情',
           hidden: true
         }
+      },
+      {
+        path: 'month_report/add',
+        component: () => import('@/views/pms/qhse/monthlyReport/MonthlyReportAdd.vue'),
+        name: 'MonthlyReportAdd',
+        meta: {
+          noCache: true,
+          canto: true,
+          icon: 'ep:plus',
+          title: '新增月报',
+          hidden: true
+        }
+      },
+      {
+        path: 'month_report/edit/:id',
+        component: () => import('@/views/pms/qhse/monthlyReport/MonthlyReportEdit.vue'),
+        name: 'MonthlyReportEdit',
+        meta: {
+          noCache: true,
+          canto: true,
+          icon: 'ep:edit',
+          title: '编辑月报',
+          hidden: true
+        }
       }
     ]
   }

+ 78 - 85
src/views/pms/device/IotDeviceForm.vue

@@ -5,8 +5,7 @@
       :model="formData"
       :rules="formRules"
       style="margin-right: 4em; margin-left: 0.5em"
-      label-width="130px"
-    >
+      label-width="130px">
       <div class="title-div">
         <el-button @click="baseInfoClick" class="title-button">
           <Icon color="black" icon="ep:set-up" :size="18" class="cursor-pointer first-icon" />
@@ -15,8 +14,7 @@
             color="black"
             :icon="baseIsExpanded ? 'fa-solid:angle-double-down' : 'fa-solid:angle-double-right'"
             :size="18"
-            class="cursor-pointer"
-          />
+            class="cursor-pointer" />
         </el-button>
       </div>
       <div class="base-expandable-content" :class="{ 'is-expanded': baseIsExpanded }">
@@ -31,8 +29,7 @@
                 :props="{ expandTrigger: 'hover' }"
                 clearable
                 filterable
-                @change="handleYfClassChange"
-              />
+                @change="handleYfClassChange" />
             </el-form-item>
           </el-col>
           <el-col :span="8">
@@ -40,8 +37,7 @@
               <el-input
                 v-model="formData.yfDeviceCode"
                 :disabled="formData.yfDeviceCode"
-                placeholder="请输入油服设备编码"
-              />
+                placeholder="请输入油服设备编码" />
             </el-form-item>
           </el-col>
           <el-col :span="8">
@@ -49,8 +45,7 @@
               <el-input
                 v-model="formData.deviceCode"
                 :disabled="formType === 'update'"
-                placeholder="请输入设备编码"
-              />
+                placeholder="请输入设备编码" />
             </el-form-item>
           </el-col>
           <el-col :span="8">
@@ -68,8 +63,7 @@
                 check-strictly
                 node-key="id"
                 filterable
-                placeholder="请选择所在部门"
-              />
+                placeholder="请选择所在部门" />
               <!--              <el-tree-select-->
               <!--                v-model="formData.deptId"-->
               <!--                :data="deptList"-->
@@ -91,8 +85,7 @@
                 node-key="id"
                 :placeholder="t('deviceForm.categoryHolder')"
                 @change="assetclasschange"
-                filterable
-              />
+                filterable />
             </el-form-item>
           </el-col>
           <el-col :span="8">
@@ -101,14 +94,12 @@
                 v-model="formData.deviceStatus"
                 :placeholder="t('deviceForm.choose')"
                 :disabled="formType === 'update'"
-                clearable
-              >
+                clearable>
                 <el-option
                   v-for="dict in getStrDictOptions(DICT_TYPE.PMS_DEVICE_STATUS)"
                   :key="dict.label"
                   :label="dict.label"
-                  :value="dict.value"
-                />
+                  :value="dict.value" />
               </el-select>
             </el-form-item>
           </el-col>
@@ -119,8 +110,7 @@
                   v-for="dict in getStrDictOptions(DICT_TYPE.PMS_ASSET_PROPERTY)"
                   :key="dict.id"
                   :label="dict.label"
-                  :value="dict.value"
-                />
+                  :value="dict.value" />
               </el-select>
             </el-form-item>
           </el-col>
@@ -131,8 +121,7 @@
                 v-model="formData.brandName"
                 @clear="brandClear"
                 :placeholder="t('iotDevice.brandHolder')"
-                @click="openForm"
-              />
+                @click="openForm" />
             </el-form-item>
           </el-col>
           <el-col :span="8">
@@ -152,8 +141,7 @@
                 <el-input
                   clearable
                   v-model="formData.model"
-                  :placeholder="t('deviceForm.modelHolder')"
-                />
+                  :placeholder="t('deviceForm.modelHolder')" />
               </el-form-item>
               <el-button type="info" @click="openModelForm">请选择</el-button>
             </div>
@@ -183,8 +171,7 @@
               <el-input
                 v-model="formData.remark"
                 type="textarea"
-                :placeholder="t('deviceForm.remarkHolder')"
-              />
+                :placeholder="t('deviceForm.remarkHolder')" />
             </el-form-item>
           </el-col>
         </el-row>
@@ -197,8 +184,7 @@
             color="black"
             :icon="zzIsExpanded ? 'fa-solid:angle-double-down' : 'fa-solid:angle-double-right'"
             :size="18"
-            class="cursor-pointer"
-          />
+            class="cursor-pointer" />
         </el-button>
       </div>
       <div class="zz-expandable-content" :class="{ 'is-expanded': zzIsExpanded }">
@@ -210,8 +196,7 @@
                 @clear="zzClear"
                 v-model="formData.manufacturerName"
                 :placeholder="t('deviceForm.mfgHolder')"
-                @click="openCustomerZz"
-              />
+                @click="openCustomerZz" />
             </el-form-item>
           </el-col>
           <el-col :span="8">
@@ -221,8 +206,7 @@
                 v-model="formData.manDate"
                 type="date"
                 value-format="x"
-                :placeholder="t('deviceForm.pdHolder')"
-              />
+                :placeholder="t('deviceForm.pdHolder')" />
             </el-form-item>
           </el-col>
           <el-col :span="8">
@@ -232,8 +216,7 @@
                 @clear="supplierClear"
                 v-model="formData.supplierName"
                 :placeholder="t('deviceForm.suppHolder')"
-                @click="openCustomerSupplier"
-              />
+                @click="openCustomerSupplier" />
             </el-form-item>
           </el-col>
           <el-col :span="8">
@@ -243,8 +226,7 @@
                 v-model="formData.expires"
                 type="date"
                 value-format="x"
-                :placeholder="t('deviceForm.warrHolder')"
-              />
+                :placeholder="t('deviceForm.warrHolder')" />
             </el-form-item>
           </el-col>
           <el-col :span="8">
@@ -254,8 +236,7 @@
                 v-model="formData.enableDate"
                 type="date"
                 value-format="x"
-                :placeholder="t('deviceForm.enableHolder')"
-              />
+                :placeholder="t('deviceForm.enableHolder')" />
             </el-form-item>
           </el-col>
           <el-col :span="8">
@@ -263,8 +244,7 @@
               <el-input
                 v-model="formData.nameplate"
                 type="textarea"
-                :placeholder="t('deviceForm.niHolder')"
-              />
+                :placeholder="t('deviceForm.niHolder')" />
             </el-form-item>
           </el-col>
         </el-row>
@@ -277,8 +257,7 @@
             color="black"
             :icon="cwIsExpanded ? 'fa-solid:angle-double-down' : 'fa-solid:angle-double-right'"
             :size="18"
-            class="cursor-pointer"
-          />
+            class="cursor-pointer" />
         </el-button>
       </div>
       <div class="cw-expandable-content" :class="{ 'is-expanded': cwIsExpanded }">
@@ -286,46 +265,45 @@
           <el-col :span="8">
             <el-form-item
               :label="formData.assetProperty === 'zy' ? '采购价格' : '租赁价格'"
-              prop="plPrice"
-            >
+              prop="plPrice">
               <el-input
                 v-model="formData.plPrice"
                 @input="handleInput(formData.plPrice, 'plPrice')"
-                :placeholder="formData.assetProperty === 'zy' ? '请输入采购价格' : '请输入租赁价格'"
-              />
+                :placeholder="
+                  formData.assetProperty === 'zy' ? '请输入采购价格' : '请输入租赁价格'
+                " />
             </el-form-item>
           </el-col>
           <el-col :span="8">
             <el-form-item
               :label="formData.assetProperty === 'zy' ? '采购日期' : '租赁日期'"
-              prop="plDate"
-            >
+              prop="plDate">
               <el-date-picker
                 style="width: 150%"
                 v-model="formData.plDate"
                 type="date"
                 value-format="x"
-                :placeholder="formData.assetProperty === 'zy' ? '请输入采购日期' : '请输入租赁日期'"
-              />
+                :placeholder="
+                  formData.assetProperty === 'zy' ? '请输入采购日期' : '请输入租赁日期'
+                " />
             </el-form-item>
           </el-col>
           <el-col :span="8">
             <el-form-item
               :label="formData.assetProperty === 'zy' ? '折旧年限' : '租赁年限'"
-              prop="plYear"
-            >
+              prop="plYear">
               <el-input
                 v-model="formData.plYear"
                 type="number"
-                :placeholder="formData.assetProperty === 'zy' ? '请输入折旧年限' : '请输入租赁年限'"
-              />
+                :placeholder="
+                  formData.assetProperty === 'zy' ? '请输入折旧年限' : '请输入租赁年限'
+                " />
             </el-form-item>
           </el-col>
           <el-col :span="8">
             <el-form-item
               :label="formData.assetProperty === 'zy' ? '折旧开始日期' : '租赁开始日期'"
-              prop="plStartDate"
-            >
+              prop="plStartDate">
               <el-date-picker
                 style="width: 150%"
                 v-model="formData.plStartDate"
@@ -333,34 +311,31 @@
                 value-format="x"
                 :placeholder="
                   formData.assetProperty === 'zy' ? '请选择折旧开始日期' : '请选择租赁开始日期'
-                "
-              />
+                " />
             </el-form-item>
           </el-col>
           <el-col :span="8">
             <el-form-item
               :label="formData.assetProperty === 'zy' ? '已提折旧月数' : '已租赁月数'"
-              prop="plMonthed"
-            >
+              prop="plMonthed">
               <el-input
                 v-model="formData.plMonthed"
                 type="number"
                 :placeholder="
                   formData.assetProperty === 'zy' ? '请输入已提折旧月数' : '请输入已租赁月数'
-                "
-              />
+                " />
             </el-form-item>
           </el-col>
           <el-col :span="8">
             <el-form-item
               :label="formData.assetProperty === 'zy' ? '已提折旧金额' : '已租赁金额'"
-              prop="plAmounted"
-            >
+              prop="plAmounted">
               <el-input
                 v-model="formData.plAmounted"
                 @input="handleInput(formData.plAmounted, 'plAmounted')"
-                :placeholder="formData.assetProperty === 'zy' ? '请输入已提折旧金额' : '已租赁金额'"
-              />
+                :placeholder="
+                  formData.assetProperty === 'zy' ? '请输入已提折旧金额' : '已租赁金额'
+                " />
             </el-form-item>
           </el-col>
           <el-col :span="8">
@@ -368,8 +343,31 @@
               <el-input
                 v-model="formData.remainAmount"
                 @input="handleInput(formData.remainAmount, 'remainAmount')"
-                placeholder="请输入剩余金额"
-              />
+                placeholder="请输入剩余金额" />
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="8">
+            <el-form-item label="每月折旧金额" prop="monthAmount">
+              <el-input
+                v-model="formData.monthAmount"
+                @input="handleInput(formData.monthAmount, 'monthAmount')"
+                placeholder="请输入每月折旧金额" />
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="8">
+            <el-form-item label="总折旧月份" prop="totalMonth">
+              <el-input
+                v-model="formData.totalMonth"
+                @input="handleInput(formData.totalMonth, 'totalMonth')"
+                placeholder="请输入总折旧月份" />
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="8">
+            <el-form-item label="币种" prop="currency">
+              <el-input v-model="formData.currency" placeholder="请输入币种" />
             </el-form-item>
           </el-col>
         </el-row>
@@ -382,8 +380,7 @@
             color="black"
             :icon="qtIsExpanded ? 'fa-solid:angle-double-down' : 'fa-solid:angle-double-right'"
             :size="18"
-            class="cursor-pointer"
-          />
+            class="cursor-pointer" />
         </el-button>
       </div>
       <div class="qt-expandable-content" :class="{ 'is-expanded': qtIsExpanded }">
@@ -393,29 +390,25 @@
               label-width="180px"
               :label="field.name"
               :prop="field.code"
-              :rules="field.rules"
-            >
+              :rules="field.rules">
               <!-- 文本输入 -->
               <el-input
                 v-if="field.type === 'text'"
                 v-model="formData[field.code]"
                 :placeholder="'请输入' + field.name"
-                :type="field.type || 'text'"
-              />
+                :type="field.type || 'text'" />
 
               <el-select
                 v-else-if="field.type === 'enum'"
                 v-model="formData[field.code]"
                 :placeholder="'请输入' + field.name"
                 clearable
-                filterable
-              >
+                filterable>
                 <el-option
                   v-for="item in field.selectOptions.dataSpecsList"
                   :key="item.name"
                   :label="item.name"
-                  :value="item.name"
-                />
+                  :value="item.name" />
               </el-select>
 
               <!-- 数字输入 -->
@@ -423,14 +416,12 @@
                 v-else-if="field.type === 'int'"
                 type="number"
                 v-model="formData[field.code]"
-                style="width: 150%"
-              />
+                style="width: 150%" />
               <el-input
                 v-else-if="field.type === 'double'"
                 v-model="formData[field.code]"
                 @input="handleInput(formData[field.code], field.code)"
-                style="width: 150%"
-              />
+                style="width: 150%" />
               <!-- 日期选择 -->
               <el-date-picker
                 v-else-if="field.type === 'date'"
@@ -438,8 +429,7 @@
                 :type="field.type || 'date'"
                 :placeholder="'请输入' + field.name"
                 value-format="YYYY-MM-DD"
-                style="width: 150%"
-              />
+                style="width: 150%" />
             </el-form-item>
           </el-col>
         </el-row>
@@ -547,7 +537,10 @@ const formData = ref({
   assetClass: undefined,
   carNo: undefined,
   deviceNo: undefined,
-  address: undefined
+  address: undefined,
+  monthAmount: undefined,
+  totalMonth: undefined,
+  currency: undefined
 })
 const formRules = reactive({
   yfClass: [

+ 129 - 0
src/views/pms/qhse/data/IotTreeForm.vue

@@ -0,0 +1,129 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="原id" prop="originId">
+        <el-input v-model="formData.originId" placeholder="请输入原id" />
+      </el-form-item>
+      <el-form-item label="父分类id" prop="parentId">
+        <el-input v-model="formData.parentId" placeholder="请输入父分类id" />
+      </el-form-item>
+      <el-form-item label="分类名称" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入分类名称" />
+      </el-form-item>
+      <el-form-item label="类型" prop="type">
+        <el-select v-model="formData.type" placeholder="请选择类型">
+          <el-option label="请选择字典生成" value="" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="分类排序" prop="sort">
+        <el-input v-model="formData.sort" placeholder="请输入分类排序" />
+      </el-form-item>
+      <el-form-item label="开启状态" prop="status">
+        <el-radio-group v-model="formData.status">
+          <el-radio value="1">请选择字典生成</el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="备注" prop="remark">
+        <el-input v-model="formData.remark" placeholder="请输入备注" />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script setup lang="ts">
+import { IotTreeApi, IotTreeVO } from '@/api/system/tree'
+
+/** pms树 表单 */
+defineOptions({ name: 'IotTreeForm' })
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const formData = ref({
+  id: undefined,
+  originId: undefined,
+  parentId: undefined,
+  name: undefined,
+  type: undefined,
+  sort: undefined,
+  status: undefined,
+  remark: undefined,
+})
+const formRules = reactive({
+  originId: [{ required: true, message: '原id不能为空', trigger: 'blur' }],
+  parentId: [{ required: true, message: '父分类id不能为空', trigger: 'blur' }],
+  name: [{ required: true, message: '分类名称不能为空', trigger: 'blur' }],
+  type: [{ required: true, message: '类型不能为空', trigger: 'change' }],
+})
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await IotTreeApi.getIotTree(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  await formRef.value.validate()
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as IotTreeVO
+    if (formType.value === 'create') {
+      await IotTreeApi.createIotTree(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await IotTreeApi.updateIotTree(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    originId: undefined,
+    parentId: undefined,
+    name: undefined,
+    type: undefined,
+    sort: undefined,
+    status: undefined,
+    remark: undefined,
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 404 - 0
src/views/pms/qhse/data/PmsTree.vue

@@ -0,0 +1,404 @@
+<template>
+  <div class="head-container">
+    <el-input v-model="deptName" class="mb-15px" clearable placeholder="请输入名称">
+      <template #prefix>
+        <Icon icon="ep:search" />
+      </template>
+    </el-input>
+  </div>
+  <div ref="treeContainer" class="tree-container">
+    <el-tree
+      ref="treeRef"
+      :data="treeList"
+      :expand-on-click-node="false"
+      :filter-node-method="filterNode"
+      :props="defaultProps"
+      :default-expanded-keys="expandedKeys"
+      highlight-current
+      node-key="id"
+      @node-click="handleNodeClick"
+      @node-contextmenu="handleRightClick"
+      style="height: 52em">
+      <template #default="{ node }">
+        <div
+          style="display: flex; justify-content: space-between; align-items: center; width: 100%">
+          <div>
+            <Icon
+              style="vertical-align: middle; fill: currentColor; color: orange"
+              v-if="node.data.type === 'dept'"
+              icon="fa:folder-open" />
+            <Icon
+              style="vertical-align: middle; fill: currentColor; color: dodgerblue"
+              v-if="node.data.type === 'device'"
+              icon="fa:tasks" />
+            <Icon
+              icon="fa:folder-open"
+              v-if="node.data.type === 'file'"
+              style="vertical-align: middle; color: orange; fill: currentColor" />
+            <span style="vertical-align: middle; margin-left: 3px">{{ node.data.name }}</span>
+          </div>
+        </div>
+      </template>
+    </el-tree>
+  </div>
+  <div
+    v-show="deviceVisible"
+    ref="contextMenuRef"
+    class="custom-menu"
+    :style="{ left: menuX + 'px', top: menuY + 'px' }">
+    <ul>
+      <li style="border-bottom: 1px solid #ccc" @click="handleDeviceClick('add')">设备详情</li>
+      <li style="border-bottom: 0px solid #ccc" @click="handleMenuClick('add')">新建目录</li>
+    </ul>
+  </div>
+  <div
+    v-show="menuVisible"
+    ref="contextMenuRef"
+    class="custom-menu"
+    :style="{ left: menuX + 'px', top: menuY + 'px' }">
+    <ul>
+      <li style="border-bottom: 1px solid #ccc" @click="handleMenuClick('add')">新建目录</li>
+      <li style="border-bottom: 1px solid #ccc" @click="handleMenuClick('edit')">编辑</li>
+      <li @click="handleMenuClick('delete')">删除目录</li>
+    </ul>
+  </div>
+
+  <Dialog v-model="dialogVisible" :title="dialogTitle" style="width: 40em">
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="80px">
+      <el-form-item label="目录名称" prop="name" label-width="110px">
+        <el-input v-model="formData.name" placeholder="请输入目录名称" />
+      </el-form-item>
+      <el-form-item label="显示排序" prop="sort" label-width="110px">
+        <el-input-number v-model="formData.sort" :min="0" controls-position="right" />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button type="primary" @click="submitForm">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+
+<script lang="ts" setup>
+import { ElTree } from 'element-plus'
+import { defaultProps, handleTree } from '@/utils/tree'
+import { CommonStatusEnum } from '@/utils/constants'
+import { IotTreeApi } from '@/api/system/tree'
+import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
+import { useI18n } from 'vue-i18n'
+import { useRouter } from 'vue-router'
+
+// 类型定义
+interface Tree {
+  id: number | string
+  name: string
+  type: string
+  children?: Tree[]
+  [key: string]: any
+}
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formRef = ref() // 搜索的表单
+const firstLevelKeys = ref<(number | string)[]>([])
+const expandedKeys = ref<(number | string)[]>([]) // 用于展开节点的路径
+
+// 表单相关
+const formLoading = ref(false)
+const formType = ref<'create' | 'update'>('create')
+const formData = ref({
+  id: undefined,
+  title: '',
+  parentId: undefined,
+  name: undefined,
+  sort: undefined,
+  leaderUserId: undefined,
+  phone: undefined,
+  email: undefined,
+  status: CommonStatusEnum.ENABLE
+})
+const formRules = ref({
+  name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
+  sort: [{ required: true, message: '请输入排序', trigger: 'blur' }]
+})
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    title: '',
+    parentId: undefined,
+    name: undefined,
+    sort: undefined,
+    leaderUserId: undefined,
+    phone: undefined,
+    email: undefined,
+    status: CommonStatusEnum.ENABLE
+  }
+  formRef.value?.resetFields()
+}
+
+defineOptions({ name: 'IotTree' })
+
+// 接收外部参数
+const props = defineProps({
+  deviceId: { type: Number, required: true },
+  currentId: { type: [Number, String], default: null } // 新增:接收需要定位的节点ID
+})
+
+const deptName = ref('')
+const nodeInfo = ref({})
+const treeList = ref<Tree[]>([]) // 树形结构
+const treeRef = ref<InstanceType<typeof ElTree>>()
+const menuVisible = ref(false)
+const deviceVisible = ref(false)
+const menuX = ref(0)
+const menuY = ref(0)
+const parentId = ref()
+
+const { push } = useRouter() // 路由跳转
+
+// 动态高度计算
+const treeContainer = ref(null)
+const setHeight = () => {
+  if (!treeContainer.value) return
+  const windowHeight = window.innerHeight
+  const containerTop = treeContainer.value.offsetTop
+  treeContainer.value.style.height = `${windowHeight * 0.78}px` // 60px 底部预留
+}
+
+// 新增:查找节点路径的递归方法
+const findNodePath = (
+  nodes: Tree[],
+  targetId: number | string,
+  path: (number | string)[] = []
+): (number | string)[] | null => {
+  for (const node of nodes) {
+    path.push(node.id)
+    if (node.id === targetId) {
+      return [...path]
+    }
+    if (node.children && node.children.length) {
+      const result = findNodePath(node.children, targetId, path)
+      if (result) {
+        return result
+      }
+    }
+    path.pop()
+  }
+  return null
+}
+
+// 新增:定位节点并高亮
+const locateNode = (targetId: number | string) => {
+  if (!targetId || !treeList.value.length) return
+
+  const pathIds = findNodePath(treeList.value, targetId)
+  if (pathIds) {
+    // 展开所有父节点
+    expandedKeys.value = pathIds.slice(0, -1)
+
+    // 等待DOM更新后设置当前节点
+    nextTick(() => {
+      if (treeRef.value) {
+        treeRef.value.setCurrentKey(targetId)
+
+        // 滚动到节点位置
+        const node = treeRef.value.getNode(targetId)
+        if (node && node.$el) {
+          node.$el.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
+        }
+      }
+    })
+  }
+}
+
+onMounted(async () => {
+  await getTreeInfo()
+  setHeight()
+  window.addEventListener('resize', setHeight)
+})
+
+onUnmounted(() => {
+  window.removeEventListener('resize', setHeight)
+})
+
+const handleRightClick = (event, node, data) => {
+  nodeInfo.value = node
+  event.preventDefault()
+  menuX.value = event.clientX
+  menuY.value = event.clientY
+  selectedNode = data
+  debugger
+  parentId.value = data.data.id
+  if (nodeInfo.value.type === 'device') {
+    deviceVisible.value = true
+  } else if (nodeInfo.value.type === 'file') {
+    menuVisible.value = true
+  }
+}
+
+const handleDeviceClick = async () => {
+  const id = nodeInfo.value.originId
+  push({ name: 'DeviceDetailInfo', params: { id } })
+  deviceVisible.value = false
+  menuVisible.value = false
+}
+
+const handleMenuClick = async (action) => {
+  switch (action) {
+    case 'add':
+      dialogVisible.value = true
+      dialogTitle.value = '新增目录'
+      formType.value = 'create'
+      resetForm()
+      break
+    case 'edit':
+      resetForm()
+      dialogVisible.value = true
+      dialogTitle.value = '编辑目录'
+      formType.value = 'update'
+      formData.value = { ...nodeInfo.value }
+      debugger
+      break
+    case 'delete':
+      // 删除的二次确认
+      await message.delConfirm()
+      // 假设存在删除接口
+      await IotTreeApi.deleteIotTree(nodeInfo.value.id)
+      message.success(t('common.delSuccess'))
+      // 刷新列表
+      await getTreeInfo()
+      break
+  }
+  deviceVisible.value = false
+  menuVisible.value = false
+}
+
+/** 获得部门树 */
+const getTreeInfo = async () => {
+  const res = await IotTreeApi.getSimpleTreeList()
+  treeList.value = []
+  treeList.value.push(...handleTree(res))
+
+  // 处理展开逻辑:有currentId则展开对应路径,否则展开一级节点
+  if (props.currentId) {
+    locateNode(props.currentId)
+  } else {
+    firstLevelKeys.value = treeList.value.map((node) => node.id)
+    expandedKeys.value = [...firstLevelKeys.value]
+  }
+  emits('success', treeList.value[0]?.id)
+}
+
+/** 基于名字过滤 */
+const filterNode = (name: string, data: Tree) => {
+  if (!name) return true
+  return data.name.includes(name)
+}
+
+/** 处理节点被点击 */
+const handleNodeClick = async (row: { [key: string]: any }) => {
+  deviceVisible.value = false
+  menuVisible.value = false
+  emits('node-click', row)
+}
+
+/** 提交表单 */
+const submitForm = async () => {
+  // 表单验证逻辑
+  formLoading.value = true
+  try {
+    formData.value.deviceId = props.deviceId
+    debugger
+    // formData.value.parentId = parentId.value
+    if (formData.value.parentId === undefined || formData.value.parentId === null) {
+      formData.value.parentId = props.currentId
+    }
+    formData.value.type = 'file'
+    if (formType.value === 'create') {
+      debugger
+      await IotTreeApi.createIotTree(formData.value)
+    } else {
+      await IotTreeApi.updateIotTree(formData.value)
+    }
+    message.success(t(formType.value === 'create' ? 'common.addSuccess' : 'common.updateSuccess'))
+    dialogVisible.value = false
+    await getTreeInfo()
+  } catch (error) {
+    console.error(error)
+  } finally {
+    formLoading.value = false
+  }
+}
+
+const emits = defineEmits(['node-click', 'success'])
+
+/** 监听搜索框变化 */
+watch(deptName, (val) => {
+  treeRef.value!.filter(val)
+})
+
+/** 监听currentId变化,重新定位 */
+watch(
+  () => props.currentId,
+  (newVal) => {
+    if (newVal && treeList.value.length) {
+      locateNode(newVal)
+    }
+  },
+  { immediate: true }
+)
+</script>
+
+<style lang="scss" scoped>
+.custom-menu {
+  position: fixed;
+  background: white;
+  border: 1px solid #ccc;
+  box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.1);
+  z-index: 1000;
+}
+.custom-menu ul {
+  list-style: none;
+  padding: 0;
+  margin: 0;
+}
+.custom-menu li {
+  padding: 2px 10px;
+  cursor: pointer;
+  font-size: 12px;
+  margin: 2px;
+}
+.custom-menu li:hover {
+  background: #77a0ec;
+}
+.tree-container {
+  overflow-y: auto;
+  min-width: 100%;
+  border: none;
+  border-radius: 4px;
+}
+/* 自定义滚动条 */
+.tree-container::-webkit-scrollbar {
+  width: 6px;
+}
+.tree-container::-webkit-scrollbar-thumb {
+  background: #c0c4cc;
+  border-radius: 3px;
+}
+
+/* 自定义高亮样式 */
+::v-deep .el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content {
+  background-color: #eaf5ff !important;
+  color: #1890ff !important;
+  font-weight: bold;
+}
+</style>

+ 609 - 0
src/views/pms/qhse/data/index.vue

@@ -0,0 +1,609 @@
+<template>
+  <ContentWrap>
+    <div style="display: flex; justify-content: space-between; align-items: center">
+      <el-breadcrumb separator=">" class="breadcrumb-container">
+        <el-breadcrumb-item
+          v-for="(item, index) in breadcrumbs"
+          :key="index"
+          @click="handleBreadcrumbClick(index)"
+          :class="{ 'current-crumb': index === breadcrumbs.length - 1 }"
+          class="custom-breadcrumb-item">
+          {{ item.name }}
+        </el-breadcrumb-item>
+      </el-breadcrumb>
+      <el-input
+        v-model="queryParams.allName"
+        :placeholder="'在' + breadcrumbs[breadcrumbs.length - 1].name + '下搜索'"
+        style="width: 250px; height: 30px"
+        @input="searchFolderAndFile" />
+    </div>
+  </ContentWrap>
+  <div class="container-tree" ref="container">
+    <el-row>
+      <div class="left-tree" :style="{ width: leftWidth + 'px' }">
+        <ContentWrapNoBottom>
+          <PmsTree
+            @node-click="handleFileNodeClick"
+            @success="successList"
+            :currentId="queryParams.classId"
+            :deviceId="id" />
+        </ContentWrapNoBottom>
+      </div>
+      <!--    </el-col>-->
+      <div class="divider-tree" @mousedown="startDrag"></div>
+      <div class="right-tree" :style="{ width: rightWidth + 'px' }">
+        <div class="bg-white rounded-lg pt-4 flex items-center">
+          <el-form :model="queryParams" ref="queryFormRef" :inline="true" label-width="68px">
+            <el-form-item :label="t('file.name')" prop="filename">
+              <el-input
+                v-model="queryParams.filename"
+                :placeholder="t('file.nameHolder')"
+                clearable
+                @keyup.enter="handleQuery"
+                class="!w-240px" />
+            </el-form-item>
+            <el-form-item v-show="false" :label="t('file.fileType')" prop="fileType">
+              <el-select
+                v-model="queryParams.fileType"
+                :placeholder="t('file.fileTypeHolder')"
+                clearable
+                class="!w-200px">
+                <el-option
+                  v-for="dict in getStrDictOptions(DICT_TYPE.PMS_FILE_TYPE)"
+                  :key="dict.value"
+                  :label="dict.label"
+                  :value="dict.value" />
+              </el-select>
+            </el-form-item>
+            <el-form-item>
+              <el-button @click="handleQuery"
+                ><Icon icon="ep:search" /> {{ t('file.search') }}</el-button
+              >
+              <el-button @click="resetQuery"
+                ><Icon icon="ep:refresh" />{{ t('file.reset') }}</el-button
+              >
+              <el-button type="primary" :loading="uploadLoading" @click="openForm('create')">
+                <Icon icon="ep:plus" /> {{ t('file.upload') }}
+              </el-button>
+            </el-form-item>
+          </el-form>
+        </div>
+        <ContentWrap style="margin-top: 10px; border: 0">
+          <zm-table
+            :loading="formLoading"
+            :data="list"
+            :stripe="true"
+            :show-overflow-tooltip="true"
+            @row-dblclick="inContent"
+            class="custom-table">
+            <zm-table-column :label="t('file.name')" align="left" prop="filename" min-width="300">
+              <template #default="scope">
+                <div style="display: flex; align-items: center; gap: 5px">
+                  <Icon
+                    v-if="scope.row.fileType === 'content'"
+                    icon="fa:folder-open"
+                    color="orange" />
+                  <Icon
+                    v-else-if="scope.row.fileType === 'device'"
+                    icon="fa:folder-open"
+                    color="blue" />
+                  <Icon
+                    v-else-if="
+                      scope.row.fileType === 'pic' ||
+                      scope.row.fileClassify === 'jpg' ||
+                      scope.row.fileClassify === 'png' ||
+                      scope.row.fileClassify === 'JPG'
+                    "
+                    icon="ep:picture-filled"
+                    color="#2183D1" />
+                  <Icon
+                    v-else-if="
+                      scope.row.fileType === 'file' &&
+                      (scope.row.fileClassify === 'pdf' || scope.row.fileClassify === 'PDF')
+                    "
+                    icon="fa-solid:file-pdf"
+                    color="#E20012" />
+                  <Icon
+                    v-else-if="
+                      scope.row.fileType === 'file' &&
+                      (scope.row.fileClassify === 'doc' || scope.row.fileClassify === 'docx')
+                    "
+                    icon="fa:file-word-o"
+                    color="blue" />
+                  <Icon
+                    v-else-if="
+                      scope.row.fileType === 'file' &&
+                      (scope.row.fileClassify === 'xls' || scope.row.fileClassify === 'xlsx')
+                    "
+                    icon="fa-solid:file-excel"
+                    color="#107C41" />
+                  <Icon
+                    v-else-if="scope.row.fileType === 'file' && scope.row.fileClassify === 'txt'"
+                    icon="fa:file-text-o" />
+                  <Icon
+                    v-else-if="scope.row.fileType === 'file' && scope.row.fileClassify === 'mp4'"
+                    icon="fa:play-circle-o"
+                    color="#009fff" />
+                  <Icon
+                    v-else-if="
+                      scope.row.fileType === 'file' &&
+                      (scope.row.fileClassify === 'ppt' || scope.row.fileClassify === 'pptx')
+                    "
+                    icon="fa-solid:file-powerpoint"
+                    color="#C43E1C" />
+                  <Icon v-else icon="fa-solid:file-alt" />
+                  {{ scope.row.filename }}
+                </div>
+              </template>
+            </zm-table-column>
+            <zm-table-column :label="t('file.fileType')" align="center" prop="fileType">
+              <template #default="scope">
+                <dict-tag :type="DICT_TYPE.PMS_FILE_TYPE" :value="scope.row.fileType" />
+              </template>
+            </zm-table-column>
+            <zm-table-column :label="t('file.fileSize')" align="center" prop="fileSize" />
+
+            <zm-table-column :label="t('file.dept')" align="center" prop="deptName" />
+            <zm-table-column
+              :label="t('file.device')"
+              align="center"
+              prop="deviceCode"
+              min-width="220" />
+            />
+            <zm-table-column :label="t('file.operation')" align="center" width="160">
+              <template #default="scope">
+                <div class="flex items-center justify-center">
+                  <el-button
+                    type="primary"
+                    v-if="scope.row.fileType !== 'content'"
+                    link
+                    @click="handleDownload(scope.row.filePath)"
+                    v-hasPermi="['rq:iot-info:download']">
+                    <Icon icon="ep:download" />{{ t('file.dow') }}
+                  </el-button>
+                  <el-button
+                    type="danger"
+                    v-if="scope.row.fileType !== 'content'"
+                    link
+                    @click="deleteInfo(scope.row.id)"
+                    v-hasPermi="['rq:iot-info:download']">
+                    <Icon icon="ep:delete" />{{ t('file.delete') }}
+                  </el-button>
+                </div>
+              </template>
+            </zm-table-column>
+          </zm-table>
+        </ContentWrap>
+      </div>
+    </el-row>
+  </div>
+  <IotInfoFormTree
+    ref="formRef"
+    @success="getList"
+    :deviceId="deviceId"
+    :nodeId="nodeId"
+    :classId="clickNodeId" />
+</template>
+<script lang="ts" setup>
+import { IotDeviceVO } from '@/api/pms/device'
+import IotInfoFormTree from '@/views/pms/iotinfo/IotInfoFormTree.vue'
+import * as IotInfoApi from '@/api/pms/iotinfo'
+import { ref, onMounted, onUnmounted } from 'vue'
+
+import PmsTree from '@/views/system/tree/PmsTree.vue'
+import { useCache } from '@/hooks/web/useCache'
+import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
+import { IotInfoClassifyApi } from '@/api/pms/info'
+import { IotTreeApi } from '@/api/system/tree'
+defineOptions({ name: 'IotTree' })
+
+const container = ref(null)
+const leftWidth = ref(350) // 初始左侧宽度
+const rightWidth = ref(window.innerWidth * 0.8)
+let isDragging = false
+const uploadLoading = ref(false)
+
+const searchFolderAndFile = async () => {
+  debugger
+  formLoading.value = true
+  const data = await IotInfoApi.IotInfoApi.getAllChildContentFile(queryParams)
+  debugger
+  list.value = data
+  formLoading.value = false
+  queryParams.filename = ''
+}
+const openWeb = (url) => {
+  window.open(
+    'http://1.94.244.160:8012/onlinePreview?url=' + encodeURIComponent(Base64.encode(url))
+  )
+}
+const handleView = (row) => {
+  openForm('detail', row.id)
+}
+const startDrag = (e) => {
+  isDragging = true
+  document.addEventListener('mousemove', onDrag)
+  document.addEventListener('mouseup', stopDrag)
+}
+const topNodeId = ref('')
+const successList = async (id) => {
+  queryParams.classId = id
+  topNodeId.value = id
+  const rootItem = breadcrumbs.value.find((item) => item.type === 'root')
+  if (rootItem) {
+    rootItem.id = id
+  }
+
+  await getList()
+  // queryParams.classId = ''
+}
+
+const onDrag = (e) => {
+  if (!isDragging) return
+
+  const containerRect = container.value.getBoundingClientRect()
+  const newWidth = e.clientX - containerRect.left
+
+  // 设置最小和最大宽度限制
+  if (newWidth > 300 && newWidth < containerRect.width - 100) {
+    leftWidth.value = newWidth
+  }
+}
+
+const stopDrag = () => {
+  isDragging = false
+  document.removeEventListener('mousemove', onDrag)
+  document.removeEventListener('mouseup', stopDrag)
+}
+
+const queryFormRef = ref() // 搜索的表单
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+const loading = ref(true) // 列表的加载中
+const { params } = useRoute() // 查询参数
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const list = ref<IotDeviceVO[]>([]) // 列表的数据
+// const total = ref(0) // 列表的总页数
+const id = ref()
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  filename: null,
+  fileType: null,
+  createTime: [],
+  deviceId: null,
+  classId: null,
+  deptId: undefined,
+  allName: null
+})
+// SPU 表单数据
+const formData = ref({
+  id: undefined,
+  deviceId: undefined,
+  deptId: undefined,
+  filename: undefined,
+  fileType: undefined,
+  filePath: undefined,
+  remark: undefined,
+  classId: undefined
+})
+const handleDownload = async (url) => {
+  try {
+    formLoading.value = true
+    const response = await fetch(url)
+    const blob = await response.blob()
+    const downloadUrl = window.URL.createObjectURL(blob)
+
+    const link = document.createElement('a')
+    link.href = downloadUrl
+    link.download = url.split('/').pop() // 自动获取文件名‌:ml-citation{ref="3" data="citationList"}
+    link.click()
+
+    URL.revokeObjectURL(downloadUrl)
+    formLoading.value = false
+  } catch (error) {
+    console.error('下载失败:', error)
+  }
+}
+const deleteInfo = async (id) => {
+  await message.delConfirm()
+  await IotInfoApi.IotInfoApi.deleteIotInfo(id).then((res) => {
+    if (res) {
+      message.success('文件删除成功')
+      getList()
+    } else {
+      message.error('文件删除失败')
+    }
+  })
+}
+// const handleFileView = (url: string) => {
+//   window.open(
+//     'http://1.94.244.160:8012/onlinePreview?url=' + encodeURIComponent(Base64.encode(url))
+//   )
+// }
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await IotInfoApi.IotInfoApi.deleteIotInfo(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+const formRef = ref()
+const openForm = async (type: string, id?: number) => {
+  // if (classType.value==='dept'){
+  //   message.error(t('common.deptChoose'))
+  //   return;
+  // }
+  uploadLoading.value = true
+  if (!queryParams.classId) {
+    message.error(t('common.leftNode'))
+    return
+  }
+  formRef.value.open(type, id)
+  await new Promise((resolve) => {
+    setTimeout(() => {
+      resolve() // 操作完成后resolve
+    }, 2000) // 模拟2秒耗时
+  })
+  uploadLoading.value = false
+}
+const deviceId = ref('')
+const clickNodeId = ref('')
+const nodeId = ref('')
+const classType = ref('')
+
+const breadcrumbs = ref([
+  { id: null, name: '科瑞石油技术', type: 'root' } // 根节点
+])
+
+// 共享的面包屑更新逻辑
+const updateBreadcrumbs = async (node) => {
+  // 查找当前节点是否已在面包屑中
+  const currentIndex = breadcrumbs.value.findIndex((item) => item.id === node.id)
+
+  if (currentIndex > -1) {
+    // 如果已存在则截断后面的节点
+    breadcrumbs.value = breadcrumbs.value.slice(0, currentIndex + 1)
+  } else {
+    // 新增节点到面包屑
+    breadcrumbs.value.push({ id: node.id, name: node.filename || node.name })
+  }
+
+  // 更新表格数据
+  queryParams.classId = node.id
+  const data = await IotInfoApi.IotInfoApi.getChildContentFile(queryParams)
+  list.value = data
+}
+
+// 表格行点击事件
+const inContent = async (row) => {
+  if (row.fileType != 'content') {
+    window.open(
+      'http://doc.deepoil.cc:8012/onlinePreview?url=' +
+        encodeURIComponent(Base64.encode(row.filePath))
+    )
+    return
+  }
+  queryParams.filename = ''
+  formLoading.value = true
+  // 调用共享方法更新面包屑和表格
+  await updateBreadcrumbs(row)
+  // 可以添加其他表格行点击需要的逻辑
+  queryParams.classId = row.id
+  nodeId.value = row.id
+  const data = await IotInfoApi.IotInfoApi.getChildContentFile(queryParams)
+  formLoading.value = false
+  list.value = data
+}
+
+// 文件树节点点击事件
+const handleFileNodeClick = async (row) => {
+  queryParams.filename = ''
+  const parentItems = await IotTreeApi.getParentIds(row.id)
+  breadcrumbs.value = []
+  parentItems.forEach((item) => {
+    breadcrumbs.value.push({ id: item.id, name: item.name, type: 'root' })
+  })
+
+  queryParams.classId = row.id
+  if (row.type == 'device') {
+    id.value = row.originId
+  }
+
+  classType.value = row.type
+  if (row.type === 'device') {
+    deviceId.value = row.originId
+    const queryParam = {
+      deviceId: row.originId,
+      pageNo: 1,
+      pagesize: 10
+    }
+    const data = await IotInfoClassifyApi.getIotInfoClassifyPage(queryParam)
+    if (data) {
+      const target = data.filter((item) => item.parentId === 0)
+      clickNodeId.value = target[0].id
+    }
+  } else if (row.type === 'file') {
+    clickNodeId.value = row.originId
+  } else if (row.type === 'dept') {
+    // message.error("请选择设备及文件节点")
+    // return
+  }
+  nodeId.value = row.id
+  await getList()
+}
+
+// 面包屑点击事件
+const handleBreadcrumbClick = async (index) => {
+  queryParams.filename = ''
+  formLoading.value = true
+  // 忽略当前节点的点击
+  if (index === breadcrumbs.value.length - 1) return
+
+  // 截断面包屑到点击的节点
+  const targetBreadcrumbs = breadcrumbs.value.slice(0, index + 1)
+  breadcrumbs.value = targetBreadcrumbs
+
+  // 获取对应节点的数据
+  let targetId = targetBreadcrumbs[index].id
+  if (!targetId) {
+    targetId = topNodeId.value
+  }
+  queryParams.classId = targetId
+  clickNodeId.value = targetId
+  nodeId.value = targetId
+  debugger
+  const data = await IotInfoApi.IotInfoApi.getChildContentFile(queryParams)
+  list.value = data
+  formLoading.value = false
+}
+
+// const handleFileNodeClick = async (row) => {
+//   queryParams.classId = row.id
+//   classType.value = row.type
+//   if (row.type==='device') {
+//     deviceId.value = row.originId
+//     const queryParam = {
+//       deviceId: row.originId,
+//       pageNo: 1,
+//       pagesize: 10,
+//     }
+//     const data = await IotInfoClassifyApi.getIotInfoClassifyPage(queryParam)
+//     debugger
+//     if (data){
+//       const target = data.filter((item)=> item.parentId===0)
+//       clickNodeId.value = target[0].id
+//     }
+//   } else if (row.type === 'file'){
+//     clickNodeId.value = row.originId
+//   } else if (row.type==='dept') {
+//     // message.error("请选择设备及文件节点")
+//     // return
+//   }
+//   nodeId.value = row.id
+//   await getList()
+// }
+/** 获得详情 */
+// const getDetail = async () => {
+//   if (id) {
+//     formLoading.value = true
+//     try {
+//       formData.value = (await IotDeviceApi.getIotDevice(id)) as IotDeviceVO
+//     } finally {
+//       formLoading.value = false
+//     }
+//   }
+// }
+/** 查询列表 */
+const getList = async () => {
+  formLoading.value = true
+  try {
+    const data = await IotInfoApi.IotInfoApi.getChildContentFile(queryParams)
+    debugger
+    list.value = data
+  } finally {
+    formLoading.value = false
+  }
+}
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value?.resetFields()
+  handleQuery()
+}
+const { wsCache } = useCache()
+/** 初始化 */
+onMounted(async () => {
+  // await getDetail()
+  // queryParams.deptId = wsCache.get(CACHE_KEY.USER).user.deptId;
+  // await getList()
+  // deviceId.value = params.id as unknown as number
+  id.value = params.id as unknown as number
+})
+</script>
+<style scoped>
+::v-deep .breadcrumb-container {
+  padding: 12px 16px;
+  background-color: #f5f7fa;
+  border-radius: 6px;
+  box-shadow: 0 1px 1px rgba(0, 0, 0, 0.06);
+}
+::v-deep .custom-breadcrumb-item {
+  font-size: 14px;
+  font-weight: bold;
+}
+
+/* 面包屑文本样式 */
+::v-deep .el-breadcrumb__item .el-breadcrumb__inner {
+  color: #101010;
+  text-decoration: none;
+  padding: 2px 4px;
+  border-radius: 2px;
+  transition: all 0.2s ease;
+}
+
+/* 可点击项悬停效果 */
+::v-deep .el-breadcrumb__item:not(:last-child) .el-breadcrumb__inner:hover {
+  color: #409eff;
+  background-color: rgba(64, 158, 255, 0.1);
+  cursor: pointer;
+}
+
+/* 当前项样式 */
+::v-deep .el-breadcrumb__item:last-child .el-breadcrumb__inner {
+  color: #1890ff;
+  font-weight: 500;
+  cursor: default;
+}
+
+/* 分隔符样式优化 */
+::v-deep .el-breadcrumb__separator {
+  margin: 0 8px;
+  color: #0954f6;
+  font-size: 12px;
+}
+
+.custom-table {
+  cursor: pointer;
+  --el-table-row-hover-bg-color: #f5f7fa; /* 优化悬停背景色 */
+}
+
+.container-tree {
+  display: flex;
+  height: 100%;
+  user-select: none; /* 防止拖动时选中文本 */
+}
+
+.left-tree {
+  background: #f0f0f0;
+  height: 100%;
+  overflow: auto;
+}
+
+.right-tree {
+  flex: 1;
+  height: 100%;
+  overflow: auto;
+  margin-left: 15px;
+}
+
+.divider-tree {
+  width: 2px;
+  background: #ccc;
+  cursor: col-resize;
+  position: relative;
+}
+
+.divider-tree:hover {
+  background: #666;
+}
+</style>

+ 7 - 0
src/views/pms/qhse/monthlyReport/MonthlyReportAdd.vue

@@ -0,0 +1,7 @@
+<template>
+  <div>addddd</div>
+</template>
+
+<script setup lang="ts">
+defineOptions({ name: 'MonthlyReportAdd' })
+</script>

+ 0 - 0
src/views/pms/qhse/monthlyReport/MonthlyReportEdit.vue


+ 836 - 0
src/views/pms/qhse/monthlyReport/index.vue

@@ -0,0 +1,836 @@
+<template>
+  <el-row :gutter="20">
+    <DeptTree @node-click="handleDeptNodeClick" v-model:collapsed="isLeftContentCollapsed" />
+    <el-col :span="isLeftContentCollapsed ? 24 : 20" :xs="24">
+      <ContentWrap style="border: 0">
+        <!-- 搜索工作栏 -->
+        <el-form class="-mb-15px" :model="queryParams" ref="queryFormRef" :inline="true">
+          <el-form-item label="风险等级" prop="riskGrade">
+            <el-select
+              v-model="queryParams.riskGrade"
+              placeholder="请选择风险等级"
+              clearable
+              style="width: 200px">
+              <el-option
+                v-for="dict in getStrDictOptions(DICT_TYPE.DANGER_GRADE)"
+                :key="dict.value"
+                :label="dict.label"
+                :value="dict.value" />
+            </el-select>
+          </el-form-item>
+
+          <el-form-item>
+            <el-button @click="handleQuery"
+              ><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button
+            >
+            <el-button @click="resetQuery"
+              ><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button
+            >
+            <el-button type="primary" @click="add" color="#626aef">
+              <Icon icon="ep:plus" class="mr-5px" /> 新增
+            </el-button>
+            <el-button type="success" plain @click="handleExport" :loading="exportLoading">
+              <Icon icon="ep:download" class="mr-5px" /> 导出
+            </el-button>
+          </el-form-item>
+        </el-form>
+      </ContentWrap>
+
+      <ContentWrap style="border: 0">
+        <div v-loading="!staticData.length" class="stats-cards">
+          <div
+            v-for="item in statsDisplayCards"
+            :key="item.key"
+            class="stats-card"
+            :style="getStatsCardStyle(item.accent, item.glow)">
+            <div
+              class="stats-card__decor stats-card__decor--left"
+              :style="{ background: item.glow }"></div>
+            <div
+              class="stats-card__decor stats-card__decor--right"
+              :style="{ background: item.glow }"></div>
+            <div class="stats-card__header">
+              <div class="stats-card__icon-wrap">
+                <div class="stats-card__icon" :style="{ color: item.accent }">
+                  <Icon :icon="item.icon" />
+                </div>
+              </div>
+              <div class="stats-card__title">{{ item.label }}</div>
+            </div>
+            <div class="stats-card__body">
+              <CountTo
+                :duration="2600"
+                :end-val="item.count"
+                :start-val="0"
+                class="stats-card__count"
+                :style="{ color: item.accent }" />
+            </div>
+          </div>
+        </div>
+        <zm-table
+          :data="tableData"
+          border
+          style="width: 100%"
+          :header-cell-style="{ background: '#f5f7fa', color: '#333' }"
+          :cell-style="{ padding: '12px 8px' }"
+          height="52.7vh">
+          <!-- 区域/位置 列(已合并) -->
+          <zm-table-column prop="region" label="区域/位置" align="center" fixed="left" />
+
+          <!-- 其他列保持不变 -->
+          <zm-table-column label="序号" width="70" align="center">
+            <template #default="scope">
+              {{ scope.$index + 1 }}
+            </template>
+          </zm-table-column>
+          <zm-table-column prop="elementDescription" label="危害因素描述" align="center" />
+          <zm-table-column prop="maybeResult" label="可导致的后果" align="center" />
+
+          <!-- 风险评价列保持不变 -->
+          <zm-table-column label="风险评价" align="center">
+            <zm-table-column prop="evalKn" label="可能性 (L)" width="80" align="center">
+              <template #default="{ row }">
+                {{ row.evalKn }}
+              </template>
+            </zm-table-column>
+            <zm-table-column prop="evalYz" label="严重性 (S)" width="80" align="center">
+              <template #default="{ row }">
+                {{ row.evalYz }}
+              </template>
+            </zm-table-column>
+            <zm-table-column prop="evalFxz" label="风险值 (R)" width="80" align="center">
+              <template #default="{ row }">
+                {{ row.evalFxz }}
+              </template>
+            </zm-table-column>
+            <zm-table-column prop="riskGrade" label="风险等级" width="100" align="center">
+              <template #default="scope">
+                <dict-tag :type="DICT_TYPE.DANGER_GRADE" :value="scope.row.riskGrade" />
+              </template>
+            </zm-table-column>
+          </zm-table-column>
+
+          <zm-table-column
+            prop="controlMethod"
+            label="控制措施"
+            show-overflow-tooltip
+            align="center" />
+          <zm-table-column prop="charge" label="责任人" align="center" />
+          <zm-table-column label="操作" width="150" align="center" fixed="right" action>
+            <template #default="{ row }">
+              <div class="flex gap-3 justify-center">
+                <el-link
+                  :underline="false"
+                  size="small"
+                  type="primary"
+                  @click="openForm('edit', row)">
+                  编辑
+                </el-link>
+                <el-link :underline="false" size="small" type="danger" @click="deleteRow(row)">
+                  删除
+                </el-link>
+              </div>
+            </template>
+          </zm-table-column>
+        </zm-table>
+
+        <!-- 分页 -->
+        <div class="mt-2 flex justify-right">
+          <el-pagination
+            v-model:current-page="pagination.pageNo"
+            v-model:page-size="pagination.pageSize"
+            :total="total"
+            layout="total, sizes, prev, pager, next, jumper"
+            @size-change="handleSizeChange"
+            @current-change="handleCurrentChange"
+            background />
+        </div>
+      </ContentWrap>
+    </el-col>
+  </el-row>
+  <!-- 新增/编辑弹窗 -->
+  <!-- 新增/编辑弹窗 -->
+  <el-dialog v-model="dialogVisible" :title="dialogTitle" width="50%" @close="resetForm">
+    <el-form ref="formRef" :model="formData" :rules="rules" label-width="120px">
+      <el-row :gutter="20">
+        <!-- 第一行 -->
+        <el-col :span="12">
+          <el-form-item label="区域/位置" prop="region">
+            <el-input v-model="formData.region" placeholder="请输入区域/位置" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="危害因素描述" prop="elementDescription">
+            <el-input v-model="formData.elementDescription" placeholder="请输入危害因素描述" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+
+      <el-row :gutter="20">
+        <!-- 第二行 -->
+        <el-col :span="12">
+          <el-form-item label="可能导致的后果" prop="maybeResult">
+            <el-input
+              v-model="formData.maybeResult"
+              placeholder="请输入可能导致的后果"
+              type="textarea"
+              :rows="1" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="风险评价可能性" prop="evalKn">
+            <el-input-number
+              v-model="formData.evalKn"
+              controls-position="right"
+              style="width: 100%" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+
+      <el-row :gutter="20">
+        <!-- 第三行 -->
+        <el-col :span="12">
+          <el-form-item label="风险评价严重性" prop="evalYz">
+            <el-input-number
+              v-model="formData.evalYz"
+              controls-position="right"
+              style="width: 100%" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="风险评价风险值" prop="evalFxz">
+            <el-input-number v-model="formData.evalFxz" disabled style="width: 100%" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+
+      <el-row :gutter="20">
+        <!-- 第四行 -->
+        <el-col :span="12">
+          <el-form-item label="风险等级" prop="riskGrade">
+            <el-select
+              v-model="formData.riskGrade"
+              placeholder="请选择风险等级"
+              clearable
+              style="width: 100%">
+              <el-option
+                v-for="dict in getStrDictOptions(DICT_TYPE.DANGER_GRADE)"
+                :key="dict.value"
+                :label="dict.label"
+                :value="dict.value" />
+            </el-select>
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="责任人" prop="charge">
+            <el-input v-model="formData.charge" placeholder="请输入责任人" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+
+      <el-row :gutter="20">
+        <el-col :span="24">
+          <el-form-item label="备注" prop="remark">
+            <el-input
+              v-model="formData.remark"
+              type="textarea"
+              placeholder="请输入备注"
+              :rows="1" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+
+      <!-- 控制措施单独一行(占满) -->
+      <el-row :gutter="20">
+        <el-col :span="24">
+          <el-form-item label="控制措施" prop="controlMethod">
+            <el-input
+              v-model="formData.controlMethod"
+              type="textarea"
+              :rows="4"
+              placeholder="请输入控制措施" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+    </el-form>
+
+    <template #footer>
+      <el-button @click="dialogVisible = false">取消</el-button>
+      <el-button type="primary" @click="submitForm">确定</el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup>
+import { ref, reactive, watch, onMounted, computed } from 'vue'
+import { IotDangerApi } from '@/api/pms/qhse/index'
+import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
+import DeptTree from '@/views/system/user/HazardTree.vue'
+import { useUserStore } from '@/store/modules/user'
+import { useRouter } from 'vue-router'
+
+defineOptions({ name: 'QhseMonthlyReport' })
+
+const userStore = useUserStore()
+const router = useRouter()
+// 查询参数
+const queryParams = reactive({
+  riskGrade: '',
+  deptId: ''
+})
+
+// 表格数据
+const tableData = ref([])
+const isLeftContentCollapsed = ref(false)
+// 弹窗控制
+const dialogVisible = ref(false)
+const dialogTitle = ref('新增')
+const formData = reactive({
+  region: '', // 区域/位置
+  charge: '',
+  elementDescription: '', // 危害因素描述
+  maybeResult: '', // 可能导致的后果
+  evalKn: 1, // 可能性
+  evalYz: 1, // 严重性
+  evalFxz: 1, // 风险值(自动计算)
+  riskGrade: '一般风险', // 风险等级
+  controlMethod: '', // 控制措施
+  remark: '' // 备注
+})
+
+// 表单校验规则
+const rules = {
+  region: [{ required: true, message: '请输入区域/位置', trigger: 'blur' }],
+  charge: [{ required: true, message: '请输入责任人', trigger: 'blur' }],
+  elementDescription: [{ required: true, message: '请输入危害因素描述', trigger: 'blur' }],
+  maybeResult: [{ required: true, message: '请输入可能导致的后果', trigger: 'blur' }],
+  evalKn: [{ required: true, message: '请输入风险评价可能性', trigger: 'change' }],
+  evalYz: [{ required: true, message: '请输入风险评价严重性', trigger: 'change' }],
+  riskGrade: [{ required: true, message: '请选择风险等级', trigger: 'change' }],
+  controlMethod: [{ required: true, message: '请输入控制措施', trigger: 'blur' }]
+}
+
+watch(
+  () => [formData.evalKn, formData.evalYz],
+  ([kn, yz]) => {
+    if (kn && yz) {
+      formData.evalFxz = kn * yz
+    }
+  },
+  { immediate: true }
+)
+// 搜索
+const handleQuery = () => {
+  pagination.pageNo = 1 // 搜索后回到第一页
+  loadTableData()
+  getStatic()
+}
+
+const handleDeptNodeClick = async (row) => {
+  queryParams.deptId = row.id
+  pagination.pageNo = 1
+  loadTableData()
+  getStatic()
+}
+
+const downloadFile = (response) => {
+  // 创建 blob 对象
+  const blob = new Blob([response], {
+    type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=utf-8'
+  })
+
+  // 获取文件名
+  let fileName = '危险源.xlsx'
+  const disposition = response.headers ? response.headers['content-disposition'] : ''
+  if (disposition) {
+    const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/
+    const matches = filenameRegex.exec(disposition)
+    if (matches != null && matches[1]) {
+      fileName = matches[1].replace(/['"]/g, '')
+    }
+  }
+
+  // 创建下载链接
+  const url = window.URL.createObjectURL(blob)
+  const link = document.createElement('a')
+  link.href = url
+  link.setAttribute('download', fileName)
+
+  // 触发下载
+  document.body.appendChild(link)
+  link.click()
+
+  // 清理
+  document.body.removeChild(link)
+  window.URL.revokeObjectURL(url)
+}
+
+const handleExport = async () => {
+  try {
+    exportLoading.value = true
+    // 调用导出接口
+    const response = await IotDangerApi.exportDanger(queryParams)
+
+    // 下载文件
+    downloadFile(response)
+    exportLoading.value = false
+  } catch (error) {
+    ElMessage.error('导出失败,请重试')
+    console.error('导出错误:', error)
+  } finally {
+  }
+}
+
+// 重置查询
+const resetQuery = () => {
+  queryParams.riskGrade = '' // 清空风险等级筛选
+  pagination.pageNo = 1 // 重置为第一页
+  loadTableData()
+  getStatic()
+}
+
+// 删除确认
+const deleteRow = async (row) => {
+  try {
+    await ElMessageBox.confirm(`确认删除 ${row.elementDescription} 吗?`, '提示', {
+      confirmButtonText: '确定',
+      cancelButtonText: '取消',
+      type: 'warning'
+    })
+
+    await IotDangerApi.deleteDanger(row.id)
+    ElMessage.success('删除成功')
+    loadTableData() // 重新加载数据
+  } catch (error) {
+    // 用户取消或删除失败
+    if (error !== 'cancel') {
+      ElMessage.error('删除失败')
+    }
+  }
+}
+
+// 每页数量变化
+const handleSizeChange = (val) => {
+  pagination.pageSize = val
+  pagination.pageNo = 1 // 重置为第一页
+  loadTableData()
+}
+
+// 预先计算合并信息
+const spanArr = ref([])
+const pos = ref(0)
+
+// 计算合并信息
+const getSpanArr = (data) => {
+  spanArr.value = []
+  pos.value = 0
+
+  data.forEach((item, index) => {
+    if (index === 0) {
+      spanArr.value.push(1)
+      pos.value = 0
+    } else {
+      if (data[index].region === data[index - 1].region) {
+        spanArr.value[pos.value] += 1
+        spanArr.value.push(0)
+      } else {
+        spanArr.value.push(1)
+        pos.value = index
+      }
+    }
+  })
+}
+
+// 行合并方法
+const objectSpanMethod = ({ row, column, rowIndex, columnIndex }) => {
+  if (columnIndex === 0) {
+    const _row = spanArr.value[rowIndex]
+    const _col = _row > 0 ? 1 : 0
+    return {
+      rowspan: _row,
+      colspan: _col
+    }
+  }
+  return {
+    rowspan: 1,
+    colspan: 1
+  }
+}
+
+// 当前页变化
+const handleCurrentChange = (val) => {
+  pagination.pageNo = val
+  loadTableData()
+}
+
+const add = () => {
+  router.push({
+    name: 'MonthlyReportAdd'
+  })
+}
+// 打开表单
+const openForm = (type, row = null) => {
+  dialogTitle.value = type === 'create' ? '新增' : '编辑'
+  if (type === 'edit') {
+    Object.assign(formData, row)
+    // 计算风险值 R = L × S
+    formData.riskValue = formData.possibility * formData.severity
+  } else {
+    resetForm()
+  }
+  dialogVisible.value = true
+}
+
+// 重置表单
+const resetForm = () => {
+  Object.keys(formData).forEach((key) => {
+    formData[key] = ''
+  })
+}
+
+// 提交表单
+const formRef = ref(null)
+const submitForm = async () => {
+  await formRef.value.validate()
+  try {
+    const params = {
+      ...formData,
+      evalFxz: formData.evalFxz // 使用已计算的值
+    }
+
+    if (dialogTitle.value === '新增') {
+      await IotDangerApi.createDanger(params)
+      ElMessage.success('新增成功')
+    } else {
+      params.id = formData.id
+      await IotDangerApi.updateDanger(params)
+      ElMessage.success('修改成功')
+    }
+
+    loadTableData()
+    dialogVisible.value = false
+  } catch (error) {
+    ElMessage.error('提交失败')
+  }
+}
+
+// 加载数据
+const exportLoading = ref(false)
+let total = ref(0)
+const pagination = reactive({
+  pageNo: 1,
+  pageSize: 10
+})
+const loadTableData = async () => {
+  try {
+    const params = {
+      pageNo: pagination.pageNo,
+      pageSize: pagination.pageSize,
+      riskGrade: queryParams.riskGrade, // 添加搜索参数
+      deptId: queryParams.deptId
+    }
+    const res = await IotDangerApi.getDangerList(params)
+    tableData.value = res.list || []
+    total.value = res.total || 0
+
+    // // 按 region 排序(支持中文)
+    // tableData.value.sort((a, b) => {
+    //   return a.region.localeCompare(b.region, 'zh-CN')
+    // })
+
+    // 计算合并信息
+    getSpanArr(tableData.value)
+  } catch (error) {
+    console.error('加载失败:', error)
+  }
+}
+
+let staticData = ref([])
+const totalRiskCount = computed(() =>
+  staticData.value.reduce((sum, item) => sum + (Number(item.count) || 0), 0)
+)
+
+const getStatsCardMeta = (classify) => {
+  const value = String(classify || '')
+  if (value.includes('重大') || value.includes('閲嶅ぇ')) {
+    return {
+      accent: '#ff5b61',
+      glow: 'radial-gradient(circle, rgba(255, 91, 97, 0.22) 0%, rgba(255, 91, 97, 0) 72%)',
+      icon: 'ep:warning-filled'
+    }
+  }
+  if (value.includes('较大') || value.includes('杈冨ぇ')) {
+    return {
+      accent: '#ff9827',
+      glow: 'radial-gradient(circle, rgba(255, 152, 39, 0.24) 0%, rgba(255, 152, 39, 0) 72%)',
+      icon: 'ep:opportunity'
+    }
+  }
+  if (value.includes('一般') || value.includes('涓€鑸')) {
+    return {
+      accent: '#3d7cff',
+      glow: 'radial-gradient(circle, rgba(61, 124, 255, 0.2) 0%, rgba(61, 124, 255, 0) 72%)',
+      icon: 'ep:info-filled'
+    }
+  }
+  if (value.includes('低') || value.includes('浣')) {
+    return {
+      accent: '#25b36a',
+      glow: 'radial-gradient(circle, rgba(37, 179, 106, 0.22) 0%, rgba(37, 179, 106, 0) 72%)',
+      icon: 'ep:success-filled'
+    }
+  }
+  return {
+    accent: '#5f7da8',
+    glow: 'radial-gradient(circle, rgba(95, 125, 168, 0.18) 0%, rgba(95, 125, 168, 0) 72%)',
+    icon: 'ep:data-analysis'
+  }
+}
+
+const statsDisplayCards = computed(() =>
+  staticData.value.map((item, index) => {
+    const meta = getStatsCardMeta(item.classify)
+    const count = Number(item.count) || 0
+    const rate = totalRiskCount.value ? ((count / totalRiskCount.value) * 100).toFixed(1) : '0.0'
+    return {
+      key: `${item.classify}-${index}`,
+      label: item.classify,
+      count,
+      note: `占比:${rate}%`,
+      ...meta
+    }
+  })
+)
+
+const getStatsCardStyle = (accent, glow) => ({
+  '--stats-accent': accent,
+  '--stats-glow': glow
+})
+
+const getStatsCardClass = (classify) => {
+  const value = String(classify || '')
+  if (value.includes('重大')) return 'stats-card--major'
+  if (value.includes('较大')) return 'stats-card--high'
+  if (value.includes('一般')) return 'stats-card--medium'
+  if (value.includes('低')) return 'stats-card--low'
+  return 'stats-card--default'
+}
+
+async function getStatic() {
+  if (queryParams.deptId) {
+    const res = await IotDangerApi.getDangerStatistics(queryParams.deptId)
+    staticData.value = res.classify
+  } else {
+    const res = await IotDangerApi.getDangerStatistics(userStore.user.deptId)
+    staticData.value = res.classify
+  }
+}
+
+// 页面挂载后加载数据
+onMounted(() => {
+  loadTableData()
+  getStatic()
+})
+</script>
+
+<style scoped lang="scss">
+::v-deep .el-button {
+  border-radius: 0;
+}
+
+::v-deep .el-select__wrapper {
+  border-radius: 0 !important;
+  height: 26px;
+}
+.hazard-table-container {
+  margin: 20px;
+  margin-top: 10px;
+}
+
+.area-header {
+  font-weight: bold;
+  text-align: center;
+  padding: 12px 0;
+  background-color: #f5f7fa;
+  border-bottom: 1px solid #ddd;
+}
+
+.sub-row {
+  padding: 12px 0;
+  text-align: left;
+}
+
+.risk-evaluation {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  align-items: center;
+}
+
+.risk-item {
+  font-size: 12px;
+  padding: 4px 8px;
+  border-radius: 4px;
+  background-color: #f5f7fa;
+  color: #333;
+}
+
+.risk-level {
+  font-weight: bold;
+  color: #333;
+  padding: 6px 12px;
+  border-radius: 4px;
+}
+
+.stats-cards {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+  gap: 12px;
+  margin-bottom: 16px;
+}
+
+.stats-card {
+  position: relative;
+  overflow: hidden;
+  min-height: 132px;
+  padding: 18px 18px 16px;
+  border-radius: 22px;
+  background: radial-gradient(
+      circle at 18% 22%,
+      rgb(255 255 255 / 92%) 0%,
+      rgb(255 255 255 / 0%) 20%
+    ),
+    radial-gradient(circle at 88% 80%, rgb(255 215 158 / 22%) 0%, rgb(255 215 158 / 0%) 16%),
+    linear-gradient(135deg, rgb(239 245 255 / 96%) 0%, rgb(217 230 248 / 88%) 100%);
+  border: 1px solid rgb(255 255 255 / 62%);
+  box-shadow:
+    inset 0 1px 0 rgb(255 255 255 / 86%),
+    0 14px 30px rgb(116 146 191 / 12%);
+}
+
+.stats-card__decor {
+  position: absolute;
+  border-radius: 999px;
+  pointer-events: none;
+  filter: blur(8px);
+  opacity: 0.95;
+}
+
+.stats-card__decor--left {
+  width: 72px;
+  height: 72px;
+  left: -10px;
+  top: -8px;
+}
+
+.stats-card__decor--right {
+  width: 88px;
+  height: 88px;
+  right: -18px;
+  bottom: -24px;
+}
+
+.stats-card__header {
+  position: relative;
+  z-index: 1;
+  display: flex;
+  align-items: center;
+  gap: 14px;
+}
+
+.stats-card__icon-wrap {
+  width: 48px;
+  height: 48px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 14px;
+  box-shadow:
+    inset 0 1px 0 rgb(255 255 255 / 85%),
+    0 10px 24px rgb(118 144 187 / 10%);
+}
+
+.stats-card__icon {
+  font-size: 24px;
+  /* line-height: 1; */
+}
+
+.stats-card__title {
+  font-size: 16px;
+  font-weight: 700;
+  color: #324b72;
+  letter-spacing: 0;
+}
+
+.stats-card__body {
+  position: relative;
+  z-index: 1;
+  display: flex;
+  align-items: flex-end;
+  gap: 8px;
+  margin-top: 14px;
+  padding-left: 62px;
+}
+
+.stats-card__count {
+  display: block;
+  font-size: 38px !important;
+  font-weight: 800;
+  line-height: 0.92;
+  letter-spacing: 1px;
+  font-style: italic;
+  text-shadow: 0 8px 18px rgb(68 110 183 / 10%);
+}
+
+.stats-card__note {
+  padding-bottom: 4px;
+  font-size: 16px;
+  font-weight: 700;
+  line-height: 1;
+}
+
+@media (max-width: 768px) {
+  .stats-cards {
+    grid-template-columns: 1fr;
+  }
+
+  .stats-card {
+    min-height: 160px;
+    padding: 24px 22px 22px;
+    border-radius: 22px;
+  }
+
+  .stats-card__header {
+    gap: 18px;
+  }
+
+  .stats-card__icon-wrap {
+    width: 58px;
+    height: 58px;
+    border-radius: 16px;
+  }
+
+  .stats-card__icon {
+    font-size: 28px;
+  }
+
+  .stats-card__title {
+    font-size: 17px;
+  }
+
+  .stats-card__body {
+    margin-top: 18px;
+    padding-left: 76px;
+    gap: 10px;
+  }
+
+  .stats-card__count {
+    font-size: 46px !important;
+  }
+
+  .stats-card__note {
+    font-size: 18px;
+    padding-bottom: 6px;
+  }
+}
+</style>