index.vue 30 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094
  1. <!-- 物联感知设备管理 -->
  2. <template>
  3. <div class="app-container equipment-page">
  4. <!-- 页头 -->
  5. <div class="page-head">
  6. <div class="page-head-titles">
  7. <div class="page-title">设备管理</div>
  8. </div>
  9. <el-button type="primary" icon="Plus" class="btn-primary" @click="handleAdd">新增设备</el-button>
  10. </div>
  11. <!-- KPI -->
  12. <div class="kpi-row">
  13. <el-card shadow="never" class="kpi">
  14. <div class="kpi-icon total"><el-icon><monitor /></el-icon></div>
  15. <div class="kpi-data">
  16. <div class="kpi-label">设备总数(台)</div>
  17. <div class="kpi-value">{{ statistics.total }}</div>
  18. </div>
  19. </el-card>
  20. <el-card shadow="never" class="kpi">
  21. <div class="kpi-icon online"><el-icon><circle-check /></el-icon></div>
  22. <div class="kpi-data">
  23. <div class="kpi-label">在线设备(台)</div>
  24. <div class="kpi-value">{{ statistics.online }}</div>
  25. </div>
  26. </el-card>
  27. <el-card shadow="never" class="kpi">
  28. <div class="kpi-icon offline"><el-icon><circle-close /></el-icon></div>
  29. <div class="kpi-data">
  30. <div class="kpi-label">离线设备(台)</div>
  31. <div class="kpi-value">{{ statistics.offline }}</div>
  32. </div>
  33. </el-card>
  34. <el-card shadow="never" class="kpi">
  35. <div class="kpi-icon alarm"><el-icon><warning /></el-icon></div>
  36. <div class="kpi-data">
  37. <div class="kpi-label">报警设备(台)</div>
  38. <div class="kpi-value">{{ statistics.alarm }}</div>
  39. </div>
  40. </el-card>
  41. </div>
  42. <!-- 设备台账 -->
  43. <el-card shadow="never" class="ledger">
  44. <div class="ledger-head">
  45. <div class="ledger-title">设备台账</div>
  46. <div class="ledger-sub">一览管网监测设备台账与运行状态</div>
  47. </div>
  48. <el-form ref="queryRef" :model="queryParams" :inline="true" class="filters">
  49. <el-form-item label="设备编码" prop="equipmentCode">
  50. <el-input
  51. v-model="queryParams.equipmentCode"
  52. placeholder="请输入"
  53. clearable
  54. class="input--code"
  55. @keyup.enter="handleQuery"
  56. />
  57. </el-form-item>
  58. <el-form-item label="设备名称" prop="equipmentName">
  59. <el-input
  60. v-model="queryParams.equipmentName"
  61. placeholder="请输入"
  62. clearable
  63. class="input--name"
  64. @keyup.enter="handleQuery"
  65. />
  66. </el-form-item>
  67. <el-form-item label="设备类型" prop="equipmentTypeId">
  68. <el-tree-select
  69. v-model="queryParams.equipmentTypeId"
  70. :data="equipmentTypeTree"
  71. node-key="id"
  72. :props="{ label: 'typeName', children: 'children', disabled: 'disabled' }"
  73. check-strictly
  74. :render-after-expand="false"
  75. clearable
  76. placeholder="请选择"
  77. class="select--type"
  78. />
  79. </el-form-item>
  80. <el-form-item label="在线状态" prop="onlineStatus">
  81. <el-select v-model="queryParams.onlineStatus" clearable placeholder="请选择" class="select--online">
  82. <el-option label="在线" :value="1" />
  83. <el-option label="离线" :value="0" />
  84. </el-select>
  85. </el-form-item>
  86. <el-form-item label="报警" prop="alarmOnly">
  87. <el-checkbox v-model="alarmOnly" @change="handleQuery" />
  88. </el-form-item>
  89. <el-form-item>
  90. <el-button type="primary" class="btn-query" @click="handleQuery">查询</el-button>
  91. <el-button class="btn-reset" @click="resetQuery">重置</el-button>
  92. </el-form-item>
  93. </el-form>
  94. <div v-loading="loading" class="grid">
  95. <el-empty v-if="!equipmentList.length" description="暂无设备数据" />
  96. <div
  97. v-for="row in equipmentList"
  98. :key="row.equipmentId"
  99. class="card"
  100. :class="{ 'is-selected': selectedId === row.equipmentId, 'is-alarm': getRowAlarm(row.equipmentId) === 1 }"
  101. @click="handleView(row)"
  102. >
  103. <div class="card-top">
  104. <div class="card-name">{{ row.equipmentName || "未命名设备" }}</div>
  105. <div class="status-group">
  106. <span class="badge" :class="getRowOnline(row.equipmentId) === 1 ? 'online' : 'offline'">
  107. <span class="badge-dot"></span>
  108. <span class="badge-text">{{ getRowOnline(row.equipmentId) === 1 ? "在线" : "离线" }}</span>
  109. </span>
  110. <span v-if="getRowAlarm(row.equipmentId) === 1" class="badge alarm">
  111. <el-icon class="badge-icon"><warning-filled /></el-icon>
  112. <span class="badge-text">报警</span>
  113. </span>
  114. </div>
  115. </div>
  116. <div class="card-code">{{ row.equipmentCode }}</div>
  117. <div class="card-bottom">
  118. <span class="type-tag">
  119. {{ (row.equipmentTypeId && typeNameMap[row.equipmentTypeId]) || "未分类" }}
  120. </span>
  121. <div class="card-actions">
  122. <el-button link class="link-detail" @click.stop="handleView(row)">详情</el-button>
  123. <el-button link class="link-delete" @click.stop="handleDelete(row)">删除</el-button>
  124. </div>
  125. </div>
  126. </div>
  127. </div>
  128. <pagination
  129. v-show="total > 0"
  130. :total="total"
  131. v-model:page="queryParams.pageNum"
  132. v-model:limit="queryParams.pageSize"
  133. @pagination="getList"
  134. />
  135. </el-card>
  136. <!-- 设备详情弹窗 -->
  137. <el-dialog title="设备详情" v-model="detailOpen" width="800px" append-to-body class="detail-dialog">
  138. <template v-if="selectedDetail.equipmentInfo">
  139. <div class="dlg-summary">
  140. <div class="summary-name">{{ selectedDetail.equipmentInfo.equipmentName || "未命名设备" }}</div>
  141. <div class="summary-code">{{ selectedDetail.equipmentInfo.equipmentCode }}</div>
  142. <span class="summary-tag">
  143. {{ selectedDetail.typeInfo?.typeName || (selectedDetail.equipmentInfo.equipmentTypeId && typeNameMap[selectedDetail.equipmentInfo.equipmentTypeId]) || "未分类" }}
  144. </span>
  145. <div class="status-group">
  146. <span class="badge" :class="selectedDetail.statusInfo?.onlineStatus === 1 ? 'online' : 'offline'">
  147. <span class="badge-dot"></span>
  148. <span class="badge-text">{{ selectedDetail.statusInfo?.onlineStatus === 1 ? "在线" : "离线" }}</span>
  149. </span>
  150. <span v-if="selectedDetail.statusInfo?.alarmStatus === 1" class="badge alarm">
  151. <el-icon class="badge-icon"><warning-filled /></el-icon>
  152. <span class="badge-text">报警</span>
  153. </span>
  154. </div>
  155. </div>
  156. <el-descriptions :column="2" border size="small" class="dlg-grid">
  157. <el-descriptions-item label="设备编码">{{ selectedDetail.equipmentInfo.equipmentCode || "-" }}</el-descriptions-item>
  158. <el-descriptions-item label="设备名称">{{ selectedDetail.equipmentInfo.equipmentName || "-" }}</el-descriptions-item>
  159. <el-descriptions-item label="设备型号">{{ selectedDetail.equipmentInfo.equipmentModel || "-" }}</el-descriptions-item>
  160. <el-descriptions-item label="设备类型">{{ selectedDetail.typeInfo?.typeName || "-" }}</el-descriptions-item>
  161. <el-descriptions-item label="制造商">{{ selectedDetail.equipmentInfo.manufacturer || "-" }}</el-descriptions-item>
  162. <el-descriptions-item label="生产日期">{{ selectedDetail.equipmentInfo.productionDate || "-" }}</el-descriptions-item>
  163. <el-descriptions-item label="规格参数" :span="2">{{ selectedDetail.equipmentInfo.equipmentSpec || "-" }}</el-descriptions-item>
  164. <el-descriptions-item label="资产价值(元)">{{ selectedDetail.equipmentInfo.assetValue ?? "-" }}</el-descriptions-item>
  165. <el-descriptions-item label="使用年限(年)">{{ selectedDetail.equipmentInfo.useLife ?? "-" }}</el-descriptions-item>
  166. <el-descriptions-item label="资产状态">{{ currentStatusMeta(selectedDetail.statusInfo?.currentStatus).text }}</el-descriptions-item>
  167. <el-descriptions-item label="所属片区">{{ selectedDetail.equipmentInfo.district || "-" }}</el-descriptions-item>
  168. <el-descriptions-item label="设备位置" :span="2">{{ selectedDetail.equipmentInfo.equipmentLocation || "-" }}</el-descriptions-item>
  169. <el-descriptions-item label="经度">{{ selectedDetail.equipmentInfo.longitude ?? "-" }}</el-descriptions-item>
  170. <el-descriptions-item label="纬度">{{ selectedDetail.equipmentInfo.latitude ?? "-" }}</el-descriptions-item>
  171. <el-descriptions-item label="安装时间">{{ selectedDetail.equipmentInfo.installTime || "-" }}</el-descriptions-item>
  172. <el-descriptions-item label="维护人员">{{ selectedDetail.equipmentInfo.maintainer || "-" }}</el-descriptions-item>
  173. <el-descriptions-item label="联系电话">{{ selectedDetail.equipmentInfo.maintainerPhone || "-" }}</el-descriptions-item>
  174. <el-descriptions-item label="备注" :span="2">{{ selectedDetail.equipmentInfo.remark || "-" }}</el-descriptions-item>
  175. </el-descriptions>
  176. <div v-if="selectedDetail.equipmentInfo.equipmentImage" class="detail-image">
  177. <div class="field-label">设备图片</div>
  178. <el-image
  179. :src="resolveImageUrl(selectedDetail.equipmentInfo.equipmentImage)"
  180. :preview-src-list="[resolveImageUrl(selectedDetail.equipmentInfo.equipmentImage)]"
  181. preview-teleported
  182. fit="cover"
  183. class="field-media"
  184. />
  185. </div>
  186. </template>
  187. <el-empty v-else description="未选中设备" />
  188. <template #footer>
  189. <div class="dlg-footer">
  190. <el-button class="btn-ghost" @click="detailOpen = false">关 闭</el-button>
  191. <el-button type="primary" icon="Edit" @click="handleUpdate(selectedDetail.equipmentInfo)">编辑设备</el-button>
  192. </div>
  193. </template>
  194. </el-dialog>
  195. <!-- 新增/修改弹窗 -->
  196. <el-dialog :title="title" v-model="open" width="860px" append-to-body>
  197. <el-form ref="equipmentRef" :model="form" :rules="rules" label-width="110px">
  198. <el-row :gutter="16">
  199. <el-col :span="12">
  200. <el-form-item label="设备编码" prop="equipmentCode">
  201. <el-input v-model="form.equipmentCode" placeholder="请输入设备编码" />
  202. </el-form-item>
  203. </el-col>
  204. <el-col :span="12">
  205. <el-form-item label="设备名称" prop="equipmentName">
  206. <el-input v-model="form.equipmentName" placeholder="请输入设备名称" />
  207. </el-form-item>
  208. </el-col>
  209. <el-col :span="12">
  210. <el-form-item label="设备型号" prop="equipmentModel">
  211. <el-input v-model="form.equipmentModel" placeholder="请输入设备型号" />
  212. </el-form-item>
  213. </el-col>
  214. <el-col :span="12">
  215. <el-form-item label="设备类型" prop="equipmentTypeId">
  216. <el-tree-select
  217. v-model="form.equipmentTypeId"
  218. :data="equipmentTypeTree"
  219. node-key="id"
  220. :props="{ label: 'typeName', children: 'children', disabled: 'disabled' }"
  221. default-expand-all
  222. :render-after-expand="false"
  223. placeholder="请选择设备类型"
  224. style="width: 100%"
  225. />
  226. </el-form-item>
  227. </el-col>
  228. <el-col :span="12">
  229. <el-form-item label="制造商" prop="manufacturer">
  230. <el-input v-model="form.manufacturer" placeholder="请输入制造商" />
  231. </el-form-item>
  232. </el-col>
  233. <el-col :span="12">
  234. <el-form-item label="生产日期" prop="productionDate">
  235. <el-date-picker
  236. v-model="form.productionDate"
  237. type="date"
  238. value-format="YYYY-MM-DD"
  239. placeholder="请选择生产日期"
  240. style="width: 100%"
  241. />
  242. </el-form-item>
  243. </el-col>
  244. <el-col :span="12">
  245. <el-form-item label="资产状态" prop="currentStatus">
  246. <el-select v-model="form.currentStatus" placeholder="请选择资产状态" style="width: 100%">
  247. <el-option
  248. v-for="item in equipmentStatusOptions"
  249. :key="item.value"
  250. :label="item.label"
  251. :value="item.value"
  252. />
  253. </el-select>
  254. </el-form-item>
  255. </el-col>
  256. <el-col :span="12">
  257. <el-form-item label="所属片区" prop="district">
  258. <el-select v-model="form.district" clearable placeholder="请选择所属片区" style="width: 100%">
  259. <el-option v-for="item in districtOptions" :key="item" :label="item" :value="item" />
  260. </el-select>
  261. </el-form-item>
  262. </el-col>
  263. <el-col :span="24">
  264. <el-form-item label="规格参数" prop="equipmentSpec">
  265. <el-input v-model="form.equipmentSpec" placeholder="请输入设备规格参数" />
  266. </el-form-item>
  267. </el-col>
  268. <el-col :span="12">
  269. <el-form-item label="资产价值(元)" prop="assetValue">
  270. <el-input-number v-model="form.assetValue" :precision="2" :step="100" style="width: 100%" />
  271. </el-form-item>
  272. </el-col>
  273. <el-col :span="12">
  274. <el-form-item label="使用年限(年)" prop="useLife">
  275. <el-input-number v-model="form.useLife" :precision="0" :step="1" style="width: 100%" />
  276. </el-form-item>
  277. </el-col>
  278. <el-col :span="24">
  279. <el-form-item label="设备位置" prop="equipmentLocation">
  280. <el-input v-model="form.equipmentLocation" placeholder="请输入设备位置" />
  281. </el-form-item>
  282. </el-col>
  283. <el-col :span="12">
  284. <el-form-item label="经度" prop="longitude">
  285. <el-input-number v-model="form.longitude" :precision="6" :step="0.000001" style="width: 100%" />
  286. </el-form-item>
  287. </el-col>
  288. <el-col :span="12">
  289. <el-form-item label="纬度" prop="latitude">
  290. <el-input-number v-model="form.latitude" :precision="6" :step="0.000001" style="width: 100%" />
  291. </el-form-item>
  292. </el-col>
  293. <el-col :span="12">
  294. <el-form-item label="安装时间" prop="installTime">
  295. <el-date-picker
  296. v-model="form.installTime"
  297. type="datetime"
  298. value-format="YYYY-MM-DD HH:mm:ss"
  299. placeholder="请选择安装时间"
  300. style="width: 100%"
  301. />
  302. </el-form-item>
  303. </el-col>
  304. <el-col :span="12">
  305. <el-form-item label="维护人员" prop="maintainer">
  306. <el-input v-model="form.maintainer" placeholder="请输入维护人员" />
  307. </el-form-item>
  308. </el-col>
  309. <el-col :span="12">
  310. <el-form-item label="联系电话" prop="maintainerPhone">
  311. <el-input v-model="form.maintainerPhone" placeholder="请输入联系电话" />
  312. </el-form-item>
  313. </el-col>
  314. <el-col :span="24">
  315. <el-form-item label="备注" prop="remark">
  316. <el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入备注" />
  317. </el-form-item>
  318. </el-col>
  319. <el-col :span="24">
  320. <el-form-item label="设备图片" prop="equipmentImage">
  321. <image-upload v-model="form.equipmentImage" :limit="1" />
  322. </el-form-item>
  323. </el-col>
  324. </el-row>
  325. </el-form>
  326. <template #footer>
  327. <div class="dialog-footer">
  328. <el-button type="primary" @click="submitForm">确 定</el-button>
  329. <el-button @click="cancel">取 消</el-button>
  330. </div>
  331. </template>
  332. </el-dialog>
  333. </div>
  334. </template>
  335. <script setup>
  336. import { computed, getCurrentInstance, onMounted, reactive, ref, toRefs } from "vue";
  337. import { useRoute } from "vue-router";
  338. import {
  339. addEquipment,
  340. delEquipment,
  341. getEquipment,
  342. getEquipmentDetail,
  343. getEquipmentStatusStatistics,
  344. listEquipment,
  345. listEquipmentStatusAll,
  346. listEquipmentTypeAll,
  347. updateEquipment,
  348. updateEquipmentStatus
  349. } from "@/api/pipeNetwork/pdeviceInform";
  350. const { proxy } = getCurrentInstance();
  351. // 数据范围:由系统菜单 query 配置 {"rootTypeId":1} 注入,对应 EquipmentTypeEnum 的 ROOT_ 大类
  352. const route = useRoute();
  353. const rootTypeId = ref(route.query.rootTypeId ? Number(route.query.rootTypeId) : undefined);
  354. const equipmentList = ref([]);
  355. const allTypeList = ref([]);
  356. const equipmentStatusList = ref([]);
  357. const loading = ref(false);
  358. const open = ref(false);
  359. const detailOpen = ref(false);
  360. const alarmOnly = ref(false);
  361. const total = ref(0);
  362. const title = ref("");
  363. const selectedDetail = ref({});
  364. const selectedId = ref("");
  365. const statistics = reactive({
  366. total: 0,
  367. online: 0,
  368. offline: 0,
  369. alarm: 0
  370. });
  371. // 资产状态:0-在用,1-维修,2-报废
  372. const equipmentStatusOptions = [
  373. { label: "在用", value: 0 },
  374. { label: "维修", value: 1 },
  375. { label: "报废", value: 2 }
  376. ];
  377. // 所属片区
  378. const districtOptions = ["城南", "城北", "太常"];
  379. const typeNameMap = computed(() => {
  380. return allTypeList.value.reduce((acc, item) => {
  381. acc[item.id] = item.typeName;
  382. return acc;
  383. }, {});
  384. });
  385. // 设备状态索引:equipmentId -> EquipmentStatus
  386. const statusMap = computed(() => {
  387. return equipmentStatusList.value.reduce((acc, item) => {
  388. acc[item.equipmentId] = item;
  389. return acc;
  390. }, {});
  391. });
  392. function getRowStatus(equipmentId) {
  393. return statusMap.value[equipmentId]?.currentStatus;
  394. }
  395. function getRowOnline(equipmentId) {
  396. return statusMap.value[equipmentId]?.onlineStatus;
  397. }
  398. function getRowAlarm(equipmentId) {
  399. return statusMap.value[equipmentId]?.alarmStatus;
  400. }
  401. // parentId 为 0(或空)表示父类
  402. function isTopLevelType(item) {
  403. const parentId = item?.parentId;
  404. return parentId === 0 || parentId === "0" || parentId === null || parentId === undefined || parentId === "";
  405. }
  406. function buildTypeTree(list) {
  407. const entries = [];
  408. const nodeIndex = {};
  409. list.forEach((item) => {
  410. const node = {
  411. id: item.id,
  412. typeName: item.typeName,
  413. disabled: isTopLevelType(item),
  414. children: []
  415. };
  416. entries.push({ item, node });
  417. if (item.id !== undefined && item.id !== null) {
  418. nodeIndex[String(item.id)] = node;
  419. }
  420. });
  421. const roots = [];
  422. const fallbackNode = { id: "__other__", typeName: "未分类", disabled: true, children: [] };
  423. entries.forEach(({ item, node }) => {
  424. if (isTopLevelType(item)) {
  425. roots.push(node);
  426. return;
  427. }
  428. const parent = nodeIndex[String(item.parentId)];
  429. if (parent && parent !== node) {
  430. parent.children.push(node);
  431. } else {
  432. fallbackNode.children.push(node);
  433. }
  434. });
  435. const prune = (node) => {
  436. if (node.children && node.children.length) {
  437. node.disabled = true;
  438. node.children.forEach(prune);
  439. } else {
  440. delete node.children;
  441. }
  442. return node;
  443. };
  444. const result = roots.map(prune);
  445. if (fallbackNode.children.length) {
  446. result.push(prune(fallbackNode));
  447. }
  448. return result;
  449. }
  450. // 全量类型构建树后按 rootTypeId 取对应大类分支:
  451. // 父节点展示真实大类名称(如“供水设备”),而非笼统的“未分类”
  452. const equipmentTypeTree = computed(() => {
  453. const tree = buildTypeTree(allTypeList.value || []);
  454. if (!rootTypeId.value) {
  455. return tree;
  456. }
  457. const branch = tree.find((node) => String(node.id) === String(rootTypeId.value));
  458. return branch ? [branch] : tree;
  459. });
  460. function currentStatusMeta(value) {
  461. const map = {
  462. 0: { text: "在用", type: "success" },
  463. 1: { text: "维修", type: "warning" },
  464. 2: { text: "报废", type: "danger" }
  465. };
  466. return map[value] || { text: "未知", type: "info" };
  467. }
  468. const baseUrl = import.meta.env.VITE_APP_BASE_API;
  469. function resolveImageUrl(url) {
  470. if (!url) return "";
  471. const value = String(url).trim();
  472. if (/^(https?:)?\/\//i.test(value) || value.startsWith("data:")) return value;
  473. return baseUrl + (value.startsWith("/") ? value : "/" + value);
  474. }
  475. const data = reactive({
  476. queryParams: {
  477. pageNum: 1,
  478. pageSize: 10,
  479. equipmentCode: undefined,
  480. equipmentName: undefined,
  481. equipmentTypeId: undefined,
  482. onlineStatus: undefined
  483. },
  484. form: {},
  485. rules: {
  486. equipmentCode: [{ required: true, message: "设备编码不能为空", trigger: "blur" }],
  487. equipmentName: [{ required: true, message: "设备名称不能为空", trigger: "blur" }],
  488. equipmentTypeId: [{ required: true, message: "设备类型不能为空", trigger: "change" }]
  489. }
  490. });
  491. const { queryParams, form, rules } = toRefs(data);
  492. function createEmptyForm() {
  493. return {
  494. equipmentId: undefined,
  495. equipmentCode: undefined,
  496. equipmentName: undefined,
  497. equipmentModel: undefined,
  498. equipmentSpec: undefined,
  499. manufacturer: undefined,
  500. productionDate: undefined,
  501. equipmentTypeId: undefined,
  502. district: undefined,
  503. currentStatus: 0,
  504. assetValue: undefined,
  505. useLife: undefined,
  506. longitude: undefined,
  507. latitude: undefined,
  508. equipmentLocation: undefined,
  509. installTime: undefined,
  510. maintainer: undefined,
  511. maintainerPhone: undefined,
  512. equipmentImage: undefined,
  513. remark: undefined
  514. };
  515. }
  516. function getList() {
  517. loading.value = true;
  518. return listEquipment({
  519. ...queryParams.value,
  520. rootTypeId: rootTypeId.value,
  521. alarmStatus: alarmOnly.value ? 1 : undefined
  522. })
  523. .then((response) => {
  524. equipmentList.value = response.records || [];
  525. total.value = response.total || 0;
  526. })
  527. .finally(() => {
  528. loading.value = false;
  529. });
  530. }
  531. function loadAssistData() {
  532. return Promise.all([listEquipmentTypeAll(), listEquipmentStatusAll(rootTypeId.value)]).then(([typeRes, statusRes]) => {
  533. allTypeList.value = typeRes.data || [];
  534. equipmentStatusList.value = statusRes.data || [];
  535. });
  536. }
  537. function loadStatistics() {
  538. return getEquipmentStatusStatistics(rootTypeId.value).then((res) => {
  539. const data = res.data || {};
  540. statistics.total = data.total || 0;
  541. statistics.online = data.online || 0;
  542. statistics.offline = data.offline || 0;
  543. statistics.alarm = data.alarm || 0;
  544. });
  545. }
  546. function reset() {
  547. form.value = createEmptyForm();
  548. proxy.resetForm("equipmentRef");
  549. }
  550. function handleQuery() {
  551. queryParams.value.pageNum = 1;
  552. getList();
  553. }
  554. function resetQuery() {
  555. proxy.resetForm("queryRef");
  556. alarmOnly.value = false;
  557. handleQuery();
  558. }
  559. function handleAdd() {
  560. reset();
  561. open.value = true;
  562. title.value = "新增设备";
  563. }
  564. function handleUpdate(row) {
  565. detailOpen.value = false;
  566. reset();
  567. const equipmentId = row?.equipmentId;
  568. if (!equipmentId) {
  569. return;
  570. }
  571. getEquipment(equipmentId).then((response) => {
  572. form.value = { ...createEmptyForm(), ...(response.data || {}) };
  573. form.value.equipmentTypeId =
  574. form.value.equipmentTypeId !== undefined &&
  575. form.value.equipmentTypeId !== null &&
  576. form.value.equipmentTypeId !== ""
  577. ? Number(form.value.equipmentTypeId)
  578. : undefined;
  579. form.value.currentStatus = getRowStatus(equipmentId) ?? 0;
  580. open.value = true;
  581. title.value = "修改设备";
  582. });
  583. }
  584. function handleView(row) {
  585. if (!row?.equipmentId) {
  586. return;
  587. }
  588. selectedId.value = row.equipmentId;
  589. return getEquipmentDetail(row.equipmentId).then((response) => {
  590. selectedDetail.value = response.data || {};
  591. detailOpen.value = true;
  592. });
  593. }
  594. function handleDelete(row) {
  595. if (!row?.equipmentId) {
  596. return;
  597. }
  598. proxy.$modal
  599. .confirm(`即将删除 ${row.equipmentName || "该设备"}(${row.equipmentCode}),确认继续?`)
  600. .then(() => delEquipment(row.equipmentId))
  601. .then(() => {
  602. proxy.$modal.msgSuccess("删除成功");
  603. if (selectedDetail.value?.equipmentInfo?.equipmentId === row.equipmentId) {
  604. selectedDetail.value = {};
  605. selectedId.value = "";
  606. detailOpen.value = false;
  607. }
  608. return Promise.all([getList(), loadAssistData(), loadStatistics()]);
  609. });
  610. }
  611. function cancel() {
  612. open.value = false;
  613. reset();
  614. }
  615. function submitForm() {
  616. proxy.$refs.equipmentRef.validate((valid) => {
  617. if (!valid) {
  618. return;
  619. }
  620. const isEdit = !!form.value.equipmentId;
  621. const equipmentId = form.value.equipmentId;
  622. const currentStatus = form.value.currentStatus;
  623. const request = isEdit ? updateEquipment(form.value) : addEquipment(form.value);
  624. request.then(() => {
  625. proxy.$modal.msgSuccess(isEdit ? "修改成功" : "新增成功");
  626. open.value = false;
  627. const tasks = [getList(), loadAssistData(), loadStatistics()];
  628. if (isEdit && currentStatus !== undefined && currentStatus !== null) {
  629. tasks.push(updateEquipmentStatus(equipmentId, currentStatus));
  630. }
  631. Promise.all(tasks).then(() => {
  632. if (isEdit) {
  633. handleView({ equipmentId });
  634. }
  635. });
  636. });
  637. });
  638. }
  639. onMounted(() => {
  640. reset();
  641. Promise.all([getList(), loadAssistData(), loadStatistics()]);
  642. });
  643. </script>
  644. <style scoped>
  645. .equipment-page {
  646. background: #f9f9f9;
  647. }
  648. /* 页头 */
  649. .page-head {
  650. display: flex;
  651. align-items: center;
  652. justify-content: space-between;
  653. margin-bottom: 20px;
  654. }
  655. .page-title {
  656. font-size: 20px;
  657. font-weight: 600;
  658. color: #0a1628;
  659. line-height: 1.4;
  660. }
  661. .btn-primary :deep(.el-icon) {
  662. font-size: 14px;
  663. }
  664. /* KPI */
  665. .kpi-row {
  666. display: flex;
  667. gap: 16px;
  668. margin-bottom: 20px;
  669. }
  670. .kpi {
  671. flex: 1 1 0;
  672. min-width: 0;
  673. border-radius: 12px;
  674. }
  675. .kpi :deep(.el-card__body) {
  676. display: flex;
  677. align-items: center;
  678. gap: 16px;
  679. padding: 20px;
  680. }
  681. .kpi-icon {
  682. display: flex;
  683. align-items: center;
  684. justify-content: center;
  685. width: 44px;
  686. height: 44px;
  687. flex: 0 0 44px;
  688. border-radius: 10px;
  689. font-size: 22px;
  690. }
  691. .kpi-icon.total {
  692. background: #edf3fc;
  693. color: #014db2;
  694. }
  695. .kpi-icon.online {
  696. background: #e7f8f1;
  697. color: #10b981;
  698. }
  699. .kpi-icon.offline {
  700. background: #fef4e2;
  701. color: #f59e0b;
  702. }
  703. .kpi-icon.alarm {
  704. background: #fdecec;
  705. color: #ef4444;
  706. }
  707. .kpi-label {
  708. font-size: 13px;
  709. font-weight: 400;
  710. color: #6b7280;
  711. }
  712. .kpi-value {
  713. margin-top: 6px;
  714. font-size: 28px;
  715. font-weight: 700;
  716. line-height: 1;
  717. color: #0a1628;
  718. }
  719. /* 台账卡片 */
  720. .ledger {
  721. border-radius: 12px;
  722. }
  723. .ledger :deep(.el-card__body) {
  724. display: flex;
  725. flex-direction: column;
  726. gap: 14px;
  727. padding: 20px;
  728. }
  729. .ledger-head {
  730. display: flex;
  731. align-items: baseline;
  732. gap: 8px;
  733. }
  734. .ledger-title {
  735. font-size: 16px;
  736. font-weight: 600;
  737. color: #0a1628;
  738. }
  739. .ledger-sub {
  740. font-size: 12px;
  741. font-weight: 400;
  742. color: #9ca3af;
  743. }
  744. /* 筛选栏 */
  745. .filters {
  746. display: flex;
  747. align-items: center;
  748. flex-wrap: wrap;
  749. gap: 4px 16px;
  750. }
  751. .filters :deep(.el-form-item) {
  752. margin-right: 0;
  753. margin-bottom: 0;
  754. }
  755. .filters :deep(.el-form-item__label) {
  756. font-size: 12px;
  757. font-weight: 400;
  758. color: #6b7280;
  759. padding-right: 8px;
  760. }
  761. .input--code,
  762. .input--name {
  763. width: 150px;
  764. }
  765. .select--type {
  766. width: 150px;
  767. }
  768. .select--online {
  769. width: 120px;
  770. }
  771. .btn-query {
  772. height: 34px;
  773. padding: 0 16px;
  774. border-radius: 8px;
  775. font-size: 13px;
  776. font-weight: 500;
  777. }
  778. .btn-reset {
  779. height: 34px;
  780. padding: 0 16px;
  781. border-radius: 8px;
  782. font-size: 13px;
  783. font-weight: 400;
  784. }
  785. /* 设备卡片 */
  786. .grid {
  787. display: grid;
  788. grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
  789. gap: 12px;
  790. min-height: 200px;
  791. }
  792. .card {
  793. display: flex;
  794. flex-direction: column;
  795. gap: 10px;
  796. padding: 14px;
  797. border-radius: 10px;
  798. background: #fff;
  799. border: 1px solid #ebebed;
  800. cursor: pointer;
  801. transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
  802. }
  803. .card:hover {
  804. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
  805. }
  806. .card.is-selected {
  807. background: #f5f9ff;
  808. border: 1.5px solid #014db2;
  809. }
  810. .card.is-alarm {
  811. border: 1.5px solid #fca5a5;
  812. }
  813. .card-top {
  814. display: flex;
  815. align-items: center;
  816. justify-content: space-between;
  817. gap: 8px;
  818. }
  819. .card-name {
  820. min-width: 0;
  821. overflow: hidden;
  822. text-overflow: ellipsis;
  823. white-space: nowrap;
  824. font-size: 14px;
  825. font-weight: 500;
  826. color: #0a1628;
  827. }
  828. .status-group {
  829. display: flex;
  830. align-items: center;
  831. gap: 10px;
  832. flex-shrink: 0;
  833. }
  834. .badge {
  835. display: flex;
  836. align-items: center;
  837. gap: 5px;
  838. }
  839. .badge-dot {
  840. width: 6px;
  841. height: 6px;
  842. border-radius: 3px;
  843. flex-shrink: 0;
  844. }
  845. .badge-text {
  846. font-size: 12px;
  847. font-weight: 500;
  848. }
  849. .badge.online .badge-dot {
  850. background: #10b981;
  851. }
  852. .badge.online .badge-text {
  853. color: #10b981;
  854. }
  855. .badge.offline .badge-dot {
  856. background: #f59e0b;
  857. }
  858. .badge.offline .badge-text {
  859. color: #f59e0b;
  860. }
  861. .badge.alarm .badge-text {
  862. color: #ef4444;
  863. }
  864. .badge-icon {
  865. font-size: 13px;
  866. color: #ef4444;
  867. }
  868. .card-code {
  869. font-size: 12px;
  870. font-weight: 400;
  871. color: #9ca3af;
  872. }
  873. .card-bottom {
  874. display: flex;
  875. align-items: center;
  876. justify-content: space-between;
  877. gap: 8px;
  878. }
  879. .type-tag {
  880. display: inline-flex;
  881. align-items: center;
  882. padding: 3px 8px;
  883. border-radius: 4px;
  884. background: #edf3fc;
  885. font-size: 11px;
  886. font-weight: 500;
  887. color: #014db2;
  888. }
  889. .card-actions {
  890. display: flex;
  891. align-items: center;
  892. gap: 12px;
  893. }
  894. .card-actions :deep(.el-button) {
  895. height: auto;
  896. padding: 0;
  897. font-size: 12px;
  898. font-weight: 500;
  899. }
  900. .link-detail {
  901. color: #014db2;
  902. }
  903. .link-delete {
  904. color: #ef4444;
  905. }
  906. /* 详情弹窗 */
  907. .dlg-summary {
  908. display: flex;
  909. align-items: center;
  910. gap: 12px;
  911. flex-wrap: wrap;
  912. padding: 14px;
  913. border-radius: 8px;
  914. background: #f5f9ff;
  915. }
  916. .summary-name {
  917. font-size: 15px;
  918. font-weight: 500;
  919. color: #014db2;
  920. }
  921. .summary-code {
  922. font-size: 12px;
  923. font-weight: 400;
  924. color: #6b7280;
  925. }
  926. .summary-tag {
  927. display: inline-flex;
  928. align-items: center;
  929. padding: 3px 8px;
  930. border-radius: 4px;
  931. background: #fff;
  932. font-size: 11px;
  933. font-weight: 500;
  934. color: #014db2;
  935. }
  936. .dlg-grid {
  937. margin-top: 16px;
  938. }
  939. .detail-image {
  940. margin-top: 16px;
  941. }
  942. .field-label {
  943. margin-bottom: 6px;
  944. font-size: 12px;
  945. font-weight: 400;
  946. color: #6b7280;
  947. }
  948. .field-media {
  949. width: 320px;
  950. max-width: 100%;
  951. height: 180px;
  952. border-radius: 6px;
  953. border: 1px solid #e6e6eb;
  954. }
  955. .dlg-footer,
  956. .dialog-footer {
  957. display: flex;
  958. align-items: center;
  959. justify-content: flex-end;
  960. gap: 10px;
  961. }
  962. @media (max-width: 1200px) {
  963. .kpi-row {
  964. flex-wrap: wrap;
  965. }
  966. .kpi {
  967. flex: 1 1 45%;
  968. }
  969. }
  970. @media (max-width: 768px) {
  971. .kpi {
  972. flex: 1 1 100%;
  973. }
  974. .page-head {
  975. flex-direction: column;
  976. align-items: flex-start;
  977. gap: 12px;
  978. }
  979. }
  980. </style>