WaterDevicePage.vue 55 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404
  1. <template>
  2. <div class="water-page device-page">
  3. <div class="page-heading">
  4. <div class="page-title">
  5. <span class="page-title-icon"><el-icon><component :is="pageIcon"/></el-icon></span>
  6. <div><h2>{{ title }}</h2><span class="muted">{{ subtitle }}</span></div>
  7. </div>
  8. <el-button class="heading-refresh" :icon="Refresh" :loading="loading" @click="loadData">刷新</el-button>
  9. </div>
  10. <div v-if="view === 'abnormal'" class="metric-grid">
  11. <div v-for="item in abnormalStats" :key="item.label" class="metric">
  12. <div class="metric-icon">
  13. <el-icon>
  14. <component :is="item.icon"/>
  15. </el-icon>
  16. </div>
  17. <div class="metric-body">
  18. <span>{{ item.label }}</span>
  19. <div class="metric-value"><strong>{{ item.value }}</strong><small>{{ item.unit }}</small></div>
  20. </div>
  21. </div>
  22. </div>
  23. <div v-if="view === 'ledger'" class="metric-grid">
  24. <div v-for="item in ledgerStatsCards" :key="item.label" class="metric">
  25. <div class="metric-icon">
  26. <el-icon>
  27. <component :is="item.icon"/>
  28. </el-icon>
  29. </div>
  30. <div class="metric-body">
  31. <span>{{ item.label }}</span>
  32. <div class="metric-value"><strong>{{ item.value }}</strong><small>{{ item.unit }}</small></div>
  33. </div>
  34. </div>
  35. </div>
  36. <el-card shadow="never" class="filter-card">
  37. <el-form :inline="true" :model="query">
  38. <el-form-item v-if="view !== 'gis'" label="设备名称">
  39. <el-input v-model="query.equipmentName" clearable/>
  40. </el-form-item>
  41. <el-form-item v-if="view !== 'gis'" label="设备编码">
  42. <el-input v-model="query.equipmentCode" clearable/>
  43. </el-form-item>
  44. <el-form-item v-if="view === 'ledger'" label="报警状态">
  45. <el-select v-model="query.alarmStatus" clearable class="filter-select" style="width:180px">
  46. <el-option label="正常" :value="0"/>
  47. <el-option label="报警" :value="1"/>
  48. </el-select>
  49. </el-form-item>
  50. <el-form-item v-if="view === 'ledger' || view === 'dispatch' || view === 'abnormal'" label="报警等级">
  51. <el-select v-model="query.alertLevel" clearable class="filter-select" style="width:180px">
  52. <el-option v-for="item in levels" :key="item.value" v-bind="item"/>
  53. </el-select>
  54. </el-form-item>
  55. <el-form-item v-if="view === 'dispatch' || view === 'abnormal'" label="异常类型">
  56. <el-select v-model="query.abnormalType" clearable class="filter-select" style="width:160px">
  57. <el-option label="报警" value="alarm"/>
  58. <el-option label="维修" value="repair"/>
  59. <el-option label="离线" value="offline"/>
  60. </el-select>
  61. </el-form-item>
  62. <el-form-item v-if="view === 'dispatch' || view === 'abnormal'" label="区域">
  63. <el-select v-model="query.region" clearable class="filter-select" style="width:140px">
  64. <el-option label="城南" value="城南"/>
  65. <el-option label="城北" value="城北"/>
  66. <el-option label="太常" value="太常"/>
  67. </el-select>
  68. </el-form-item>
  69. <el-form-item>
  70. <el-button type="primary" @click="loadData">查询</el-button>
  71. <el-button @click="reset">重置</el-button>
  72. </el-form-item>
  73. </el-form>
  74. </el-card>
  75. <el-card shadow="never" class="water-panel">
  76. <div class="card-title"><span><el-icon><Monitor/></el-icon> {{ title }}清单</span>
  77. <div class="panel-actions">
  78. <el-button v-if="view === 'ledger'" type="primary" plain size="small" :icon="Plus"
  79. @click="openCreate">新增
  80. </el-button>
  81. <el-button v-if="view === 'ledger'" type="danger" plain size="small" :icon="Delete"
  82. :disabled="!selectedDevices.length" @click="removeSelectedDevices">删除
  83. </el-button>
  84. <el-button v-if="view === 'ledger'" type="info" plain size="small" :icon="Upload"
  85. @click="openImport">导入
  86. </el-button>
  87. <el-button v-if="view !== 'gis'" type="success" plain size="small" :icon="Download"
  88. @click="exportData">导出
  89. </el-button>
  90. </div>
  91. </div>
  92. <el-alert v-if="loadError" :title="loadError" type="error" show-icon :closable="false" class="load-error">
  93. <el-button size="small" type="danger" plain @click="loadData">重试</el-button>
  94. </el-alert>
  95. <el-empty v-if="view === 'gis' && !loading && !loadError && !page.records.length"
  96. description="暂无设备 GIS 点位数据"/>
  97. <el-table :data="page.records" border stripe v-loading="loading" row-key="equipmentId"
  98. @selection-change="handleDeviceSelectionChange">
  99. <el-table-column v-if="view === 'ledger'" type="selection" width="48" align="center"/>
  100. <el-table-column type="index" label="#" width="55"/>
  101. <el-table-column prop="equipmentCode" label="设备编码" min-width="130"/>
  102. <el-table-column prop="equipmentName" label="设备名称" min-width="150"/>
  103. <el-table-column prop="equipmentTypeName" label="设备类型" min-width="120"/>
  104. <el-table-column v-if="view === 'abnormal' || view === 'dispatch'" label="所属区域" width="110">
  105. <template #default="{ row }">{{ row.district || '-' }}</template>
  106. </el-table-column>
  107. <el-table-column prop="equipmentLocation" label="安装位置" min-width="180" show-overflow-tooltip/>
  108. <el-table-column v-if="view === 'ledger' || view === 'abnormal'" label="设备图片" width="110" align="center">
  109. <template #default="{ row }">
  110. <el-image v-if="row.equipmentImageUrl" :src="row.equipmentImageUrl"
  111. :preview-src-list="[row.equipmentImageUrl]" preview-teleported hide-on-click-modal
  112. fit="cover" class="device-image"/>
  113. <span v-else class="image-placeholder">暂无图片</span></template>
  114. </el-table-column>
  115. <el-table-column v-if="view !== 'maintain'" label="设备状态" width="100">
  116. <template #default="{ row }">
  117. <el-tag :type="Number(row.currentStatus) === 3 ? 'danger' : 'success'">{{ row.currentStatusText
  118. }}
  119. </el-tag>
  120. </template>
  121. </el-table-column>
  122. <el-table-column v-if="view !== 'maintain'" label="在线状态" width="100">
  123. <template #default="{ row }">
  124. <el-tag :type="Number(row.onlineStatus) === 1 ? 'success' : 'info'">{{ row.onlineStatusText }}
  125. </el-tag>
  126. </template>
  127. </el-table-column>
  128. <el-table-column v-if="view !== 'maintain'" label="报警状态" width="100">
  129. <template #default="{ row }">
  130. <el-tag :type="Number(row.alarmStatus) === 1 ? 'danger' : 'success'">{{ row.alarmStatusText }}
  131. </el-tag>
  132. </template>
  133. </el-table-column>
  134. <el-table-column v-if="view !== 'maintain'" label="报警等级" width="110">
  135. <template #default="{ row }">
  136. <el-tag :type="alarmLevelType(row.alertLevel)">{{ row.alarmLevelText }}</el-tag>
  137. </template>
  138. </el-table-column>
  139. <el-table-column v-if="view === 'abnormal' || view === 'dispatch'" label="异常原因" min-width="150"
  140. show-overflow-tooltip>
  141. <template #default="{ row }">{{ row.abnormalReasonText || '-' }}</template>
  142. </el-table-column>
  143. <el-table-column label="关联工单" min-width="190" show-overflow-tooltip>
  144. <template #default="{ row }">{{ view === 'maintain' ? (row.orderNo || '-') : row.relatedOrderNo }}
  145. </template>
  146. </el-table-column>
  147. <el-table-column label="工单状态" min-width="110">
  148. <template #default="{ row }">
  149. <el-tag v-if="view !== 'maintain' && row.workOrderStatusText !== '暂无工单'"
  150. :type="workOrderStatusType(row.workOrderStatusCode)">{{ row.workOrderStatusText }}
  151. </el-tag>
  152. <span v-else>{{ view === 'maintain' ? (row.status || '暂无') : row.workOrderStatusText }}</span>
  153. </template>
  154. </el-table-column>
  155. <el-table-column v-if="view === 'abnormal' || view === 'dispatch'" label="接单责任人" min-width="110">
  156. <template #default="{ row }">{{ view === 'maintain' ? (row.maintainPerson || '-') :
  157. (row.relatedWorkOrderPerson || '-') }}
  158. </template>
  159. </el-table-column>
  160. <el-table-column label="操作" width="210" fixed="right">
  161. <template #default="{ row }">
  162. <div class="facility-row-actions">
  163. <el-tooltip content="查看详情" placement="top">
  164. <el-button class="facility-row-action is-detail" aria-label="查看详情" size="small"
  165. type="primary" plain circle :icon="View" @click="selectPoint(row)"/>
  166. </el-tooltip>
  167. <el-tooltip v-if="view === 'ledger'" content="修改信息" placement="top">
  168. <el-button class="facility-row-action is-edit" aria-label="修改信息" size="small"
  169. type="warning" plain circle :icon="Edit" @click="openEdit(row)"/>
  170. </el-tooltip>
  171. <el-tooltip v-if="canDispatch(row)" content="维修派单" placement="top">
  172. <el-button class="facility-row-action is-dispatch" aria-label="维修派单" size="small"
  173. type="warning" plain circle :icon="Position" @click="openOrder(row)"/>
  174. </el-tooltip>
  175. <el-tooltip v-if="view === 'ledger'" content="删除设备" placement="top">
  176. <el-button class="facility-row-action is-delete" aria-label="删除设备" size="small"
  177. type="danger" plain circle :icon="Delete" @click="removeDevice(row)"/>
  178. </el-tooltip>
  179. <el-tooltip v-if="view === 'maintain'" content="删除记录" placement="top">
  180. <el-button class="facility-row-action is-delete" aria-label="删除记录" size="small"
  181. type="danger" plain circle :icon="Delete" @click="removeRecord(row)"/>
  182. </el-tooltip>
  183. </div>
  184. </template>
  185. </el-table-column>
  186. </el-table>
  187. <el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="page.total"
  188. layout="total, sizes, prev, pager, next" :page-sizes="[10, 20, 50]" @change="loadData"/>
  189. </el-card>
  190. <el-drawer v-model="detailVisible" class="detail-drawer device-detail-drawer" title="设备详情" direction="rtl"
  191. size="680px" append-to-body>
  192. <div class="detail-summary"><span class="detail-name">{{ detail.equipmentName || '-' }}</span>
  193. <el-tag :type="Number(detail.currentStatus) === 3 ? 'danger' : 'success'">{{ detail.currentStatusText ||
  194. '-' }}
  195. </el-tag>
  196. <el-tag :type="Number(detail.alarmStatus) === 1 ? 'danger' : 'success'">{{ detail.alarmStatusText || '-'
  197. }}
  198. </el-tag>
  199. <el-tag v-if="detail.workOrderStatusText && detail.workOrderStatusText !== '暂无工单'"
  200. :type="workOrderStatusType(detail.workOrderStatusCode)">{{ detail.workOrderStatusText }}
  201. </el-tag>
  202. </div>
  203. <div v-if="view === 'ledger' || view === 'abnormal'" class="detail-media">
  204. <div class="detail-media-label">设备图片</div>
  205. <el-image v-if="detail.equipmentImageUrl" :src="detail.equipmentImageUrl"
  206. :preview-src-list="[detail.equipmentImageUrl]" preview-teleported hide-on-click-modal
  207. fit="cover"
  208. class="detail-image"/>
  209. <div v-else class="detail-image-placeholder">暂无图片</div>
  210. </div>
  211. <el-descriptions :column="2" border>
  212. <el-descriptions-item v-for="item in detailFields" :key="item.label" :label="item.label">{{ item.value
  213. }}
  214. </el-descriptions-item>
  215. </el-descriptions>
  216. <template #footer>
  217. <el-button @click="detailVisible = false">关闭</el-button>
  218. </template>
  219. </el-drawer>
  220. <el-drawer v-model="editVisible" class="detail-drawer device-edit-drawer"
  221. :title="editMode === 'create' ? '新增设备信息' : '修改设备信息'" direction="rtl" size="680px"
  222. append-to-body>
  223. <el-form ref="editFormRef" :model="editForm" label-width="110px" :rules="editRules">
  224. <el-row :gutter="16">
  225. <el-col :span="12">
  226. <el-form-item label="设备编码" prop="equipmentCode">
  227. <el-input v-model="editForm.equipmentCode" :disabled="editMode === 'edit'" maxlength="50"/>
  228. </el-form-item>
  229. </el-col>
  230. <el-col :span="12">
  231. <el-form-item label="设备类型" prop="equipmentTypeId">
  232. <el-select v-if="editMode === 'create'" v-model="editForm.equipmentTypeId" filterable
  233. class="full-input">
  234. <el-option v-for="item in waterDeviceTypes" :key="item.id" :label="item.typeName"
  235. :value="item.id"/>
  236. </el-select>
  237. <el-input v-else v-model="editForm.equipmentTypeName" disabled/>
  238. </el-form-item>
  239. </el-col>
  240. <el-col :span="12">
  241. <el-form-item label="设备名称" prop="equipmentName">
  242. <el-input v-model="editForm.equipmentName" clearable/>
  243. </el-form-item>
  244. </el-col>
  245. <el-col :span="12">
  246. <el-form-item label="设备型号">
  247. <el-input v-model="editForm.equipmentModel" clearable/>
  248. </el-form-item>
  249. </el-col>
  250. <el-col :span="12">
  251. <el-form-item label="所属片区">
  252. <el-select v-model="editForm.district" clearable class="full-input">
  253. <el-option label="城南" value="城南"/>
  254. <el-option label="城北" value="城北"/>
  255. <el-option label="太常" value="太常"/>
  256. </el-select>
  257. </el-form-item>
  258. </el-col>
  259. <el-col :span="12">
  260. <el-form-item label="制造商">
  261. <el-input v-model="editForm.manufacturer" clearable/>
  262. </el-form-item>
  263. </el-col>
  264. <el-col :span="12">
  265. <el-form-item label="经度">
  266. <el-input-number v-model="editForm.longitude" :precision="8" :controls="false"
  267. class="full-input"/>
  268. </el-form-item>
  269. </el-col>
  270. <el-col :span="12">
  271. <el-form-item label="纬度">
  272. <el-input-number v-model="editForm.latitude" :precision="8" :controls="false"
  273. class="full-input"/>
  274. </el-form-item>
  275. </el-col>
  276. <el-col :span="24">
  277. <el-form-item label="安装位置">
  278. <el-input v-model="editForm.equipmentLocation" clearable/>
  279. </el-form-item>
  280. </el-col>
  281. <el-col :span="12">
  282. <el-form-item label="设备负责人">
  283. <el-input v-model="editForm.maintainer" clearable/>
  284. </el-form-item>
  285. </el-col>
  286. <el-col :span="12">
  287. <el-form-item label="联系方式">
  288. <el-input v-model="editForm.maintainerPhone" clearable/>
  289. </el-form-item>
  290. </el-col>
  291. <el-col :span="24">
  292. <el-form-item label="权属单位">
  293. <el-input v-model="editForm.ownershipUnit" clearable/>
  294. </el-form-item>
  295. </el-col>
  296. <el-col :span="24">
  297. <el-form-item label="设备图片">
  298. <ImageUpload v-model="editForm.equipmentImage" :limit="1" :file-size="5"/>
  299. </el-form-item>
  300. </el-col>
  301. <el-col :span="24">
  302. <el-form-item label="备注">
  303. <el-input v-model="editForm.remark" type="textarea" :rows="3" maxlength="500"
  304. show-word-limit/>
  305. </el-form-item>
  306. </el-col>
  307. </el-row>
  308. </el-form>
  309. <template #footer>
  310. <el-button @click="editVisible = false">取消</el-button>
  311. <el-button type="primary" :loading="editSaving" @click="submitEdit">保存</el-button>
  312. </template>
  313. </el-drawer>
  314. <el-dialog v-model="dispatchVisible" class="dispatch-dialog" title="设备维修派单" width="860px">
  315. <div class="dispatch-layout">
  316. <div class="assignee-picker">
  317. <aside class="assignee-depts">
  318. <el-input v-model="deptFilterText" clearable placeholder="筛选部门" size="small"/>
  319. <el-tree ref="deptTreeRef" :data="deptTreeData" node-key="deptId" highlight-current
  320. :props="{ label: 'deptName', children: 'children' }"
  321. :filter-node-method="filterDeptNode" class="assignee-tree"
  322. @node-click="handleDeptClick"/>
  323. </aside>
  324. <section class="assignee-users">
  325. <div class="assignee-search">
  326. <el-input v-model="assigneeKeyword" clearable placeholder="搜索用户名、昵称或手机号"
  327. @keyup.enter="searchAssigneeUsers"/>
  328. <el-button type="primary" :loading="userLoading" @click="searchAssigneeUsers">查询
  329. </el-button>
  330. </div>
  331. <el-alert v-if="userLoadError" :title="userLoadError" type="error" show-icon :closable="false">
  332. <el-button size="small" type="danger" plain @click="retryAssigneeLoading">重试</el-button>
  333. </el-alert>
  334. <el-table :data="users" border row-key="userId" highlight-current-row
  335. :current-row-key="assigneeDraft?.userId" @row-click="chooseAssignee">
  336. <el-table-column label="选择" width="58" align="center">
  337. <template #default="{ row }">
  338. <el-radio :model-value="assigneeDraft?.userId" :label="row.userId"
  339. @click.stop="chooseAssignee(row)">&nbsp;
  340. </el-radio>
  341. </template>
  342. </el-table-column>
  343. <el-table-column prop="nickName" label="昵称" min-width="100"/>
  344. <el-table-column prop="userName" label="用户名" min-width="110"/>
  345. <el-table-column prop="deptName" label="部门" min-width="120" show-overflow-tooltip/>
  346. <el-table-column prop="phone" label="手机号" min-width="130"/>
  347. <el-table-column prop="statusText" label="账号状态" width="90"/>
  348. </el-table>
  349. <el-empty v-if="!userLoading && !userLoadError && !users.length"
  350. description="暂无符合条件的责任人" :image-size="80"/>
  351. <el-pagination v-model:current-page="assigneePageNum" v-model:page-size="assigneePageSize"
  352. :total="assigneeTotal" layout="total, sizes, prev, pager, next"
  353. :page-sizes="[10, 20, 50]" @current-change="loadAssigneeUsers"
  354. @size-change="handleAssigneeSizeChange"/>
  355. <div class="assignee-picked">已选责任人:<strong v-if="assigneeDraft">{{ assigneeDraft.nickName
  356. || assigneeDraft.userName }}</strong><span v-else>未选择</span>
  357. <el-button size="small" type="primary" plain :disabled="!assigneeDraft"
  358. @click="confirmAssignee">确认选择
  359. </el-button>
  360. </div>
  361. </section>
  362. </div>
  363. <el-form class="dispatch-form" :model="dispatchForm" label-width="100px">
  364. <el-form-item label="设备"><span>{{ dispatchDevice.equipmentName }}({{ dispatchDevice.equipmentCode }})</span>
  365. </el-form-item>
  366. <el-form-item label="工单类型"><span>1-故障维修</span></el-form-item>
  367. <el-form-item label="优先级" required>
  368. <el-select v-model="dispatchForm.orderLevel" placeholder="请选择优先级">
  369. <el-option label="紧急" :value="0"/>
  370. <el-option label="一般" :value="1"/>
  371. <el-option label="低" :value="2"/>
  372. </el-select>
  373. </el-form-item>
  374. <el-form-item label="问题描述" required>
  375. <el-input v-model="dispatchForm.description" type="textarea" :rows="5" maxlength="500"
  376. show-word-limit placeholder="请输入故障或异常描述"/>
  377. </el-form-item>
  378. <el-form-item label="接单责任人" required>
  379. <span v-if="selectedAssignee" class="assignee-summary">{{ selectedAssignee.nickName || selectedAssignee.userName }}({{ selectedAssignee.deptName || '-' }})</span>
  380. <span v-else class="assignee-placeholder">请在左侧选择责任人</span>
  381. </el-form-item>
  382. </el-form>
  383. </div>
  384. <template #footer>
  385. <el-button @click="dispatchVisible = false">取消</el-button>
  386. <el-button type="primary" :loading="dispatching" @click="submitDispatch">确认派单</el-button>
  387. </template>
  388. </el-dialog>
  389. <el-dialog v-model="importVisible" title="供水设备批量导入" width="520px" append-to-body @closed="resetImport">
  390. <el-upload ref="importUploadRef" drag :limit="1" accept=".xlsx,.xls" :auto-upload="false"
  391. :file-list="importFileList" :on-change="handleImportChange" :on-remove="handleImportRemove"
  392. :disabled="importUploading">
  393. <el-icon class="el-icon--upload">
  394. <Upload/>
  395. </el-icon>
  396. <div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
  397. <template #tip>
  398. <div class="el-upload__tip import-tip">仅支持 xls、xlsx;设备类型必须属于供水类型树。</div>
  399. </template>
  400. </el-upload>
  401. <el-checkbox v-model="importUpdateSupport" class="import-update">更新已存在的设备编码</el-checkbox>
  402. <template #footer>
  403. <el-button type="primary" :loading="importUploading" @click="submitImport">开始导入</el-button>
  404. <el-button @click="importVisible = false">取消</el-button>
  405. </template>
  406. </el-dialog>
  407. </div>
  408. </template>
  409. <script setup>
  410. import {computed, onMounted, ref, watch} from 'vue'
  411. import {ElMessage, ElMessageBox} from 'element-plus'
  412. import {
  413. Monitor,
  414. Refresh,
  415. Warning,
  416. Bell,
  417. Tools,
  418. DataLine,
  419. Upload,
  420. Download,
  421. Search,
  422. Plus,
  423. Delete,
  424. View,
  425. Edit,
  426. Position
  427. } from '@element-plus/icons-vue'
  428. import {listUser} from '@/api/system/user'
  429. import {listDept} from '@/api/system/dept'
  430. import {handleTree} from '@/utils/ruoyi'
  431. import {
  432. createAbnormalOrder,
  433. createWaterDevice,
  434. deleteMaintainRecord,
  435. deleteWaterDevices,
  436. exportAbnormalDevices,
  437. exportDeviceLedger,
  438. getAbnormalDeviceStats,
  439. getDeviceLedgerStatistics,
  440. getWaterDeviceById,
  441. importDeviceLedger,
  442. listAbnormalDevices,
  443. listDeviceGisPoints,
  444. listDeviceLedger,
  445. listMaintainRecords,
  446. updateWaterDevice
  447. } from '@/api/pipeNetwork/waterSupply'
  448. import {listEquipmentTypeAll} from '@/api/pipeNetwork/pdeviceInform'
  449. import {normalizePage, readMonitoringExportError} from '@/utils/waterSupplyModel'
  450. import {
  451. buildAssigneeQuery,
  452. buildMaintainOrderPayload,
  453. canDispatchDevice,
  454. normalizeAssigneeUser,
  455. normalizeDeviceGisPoints,
  456. normalizeDeviceRow,
  457. normalizeDispatchOrderResponse,
  458. preserveDevicePageOnError,
  459. selectSingleAssignee
  460. } from '@/utils/waterSupplyUiModel'
  461. import {saveAs} from 'file-saver'
  462. import {orderStatusType as workOrderStatusType} from '@/views/subSystem/pipeNetwork/ops/workOrder/workOrderDict.js'
  463. const props = defineProps({
  464. title: {type: String, required: true},
  465. subtitle: {type: String, default: '设备信息、运行状态与维护过程管理'},
  466. view: {type: String, default: 'ledger'}
  467. })
  468. const {title, subtitle, view} = props
  469. const query = ref({
  470. pageNum: 1,
  471. pageSize: 10,
  472. equipmentName: '',
  473. equipmentCode: '',
  474. alarmStatus: undefined,
  475. alertLevel: undefined,
  476. abnormalType: undefined,
  477. region: undefined
  478. })
  479. const importVisible = ref(false);
  480. const importUploading = ref(false);
  481. const importUpdateSupport = ref(false);
  482. const importFileList = ref([]);
  483. const importUploadRef = ref(null);
  484. const editVisible = ref(false);
  485. const editSaving = ref(false);
  486. const editFormRef = ref(null);
  487. const editForm = ref({})
  488. const editMode = ref('edit');
  489. const selectedDevices = ref([]);
  490. const waterDeviceTypes = ref([])
  491. const editRules = {
  492. equipmentCode: [{required: true, message: '请输入设备编码', trigger: 'blur'}],
  493. equipmentTypeId: [{required: true, message: '请选择设备类型', trigger: 'change'}],
  494. equipmentName: [{required: true, message: '请输入设备名称', trigger: 'blur'}]
  495. }
  496. const page = ref({records: [], total: 0});
  497. const ledgerStats = ref({total: 0, online: 0, offline: 0, onlineRate: 0});
  498. const abnormalStatsData = ref({total: 0, alarm: 0, repair: 0, offline: 0});
  499. const loading = ref(false);
  500. const loadError = ref('');
  501. const detailVisible = ref(false);
  502. const detail = ref({});
  503. const dispatchVisible = ref(false);
  504. const dispatching = ref(false);
  505. const dispatchDevice = ref({});
  506. const dispatchForm = ref({description: '', receiveUserId: undefined});
  507. const users = ref([]);
  508. const userLoading = ref(false);
  509. const userLoadError = ref('');
  510. const deptLoadError = ref('');
  511. const selectedAssignee = ref(null);
  512. const assigneeDraft = ref(null);
  513. const deptTreeRef = ref(null);
  514. const deptTreeData = ref([]);
  515. const deptFilterText = ref('');
  516. const assigneeKeyword = ref('');
  517. const assigneeDeptId = ref(undefined);
  518. const assigneePageNum = ref(1);
  519. const assigneePageSize = ref(10);
  520. const assigneeTotal = ref(0)
  521. const levels = [{label: '一级报警', value: 1}, {label: '二级报警', value: 2}, {
  522. label: '三级报警',
  523. value: 3
  524. }, {label: '四级报警', value: 4}];
  525. const pageIcon = Monitor;
  526. const alarmLevelType = level => ({1: 'danger', 2: 'warning', 3: 'primary', 4: 'success'}[Number(level)] || 'info')
  527. function canDispatch(row) {
  528. if (view !== 'dispatch') return false
  529. return canDispatchDevice(row)
  530. }
  531. const abnormalStats = computed(() => {
  532. return [
  533. {label: '异常设备', value: abnormalStatsData.value.total, unit: '台', icon: Warning},
  534. {label: '报警中', value: abnormalStatsData.value.alarm, unit: '台', icon: Bell},
  535. {label: '维修中', value: abnormalStatsData.value.repair, unit: '台', icon: Tools},
  536. {label: '离线设备', value: abnormalStatsData.value.offline, unit: '台', icon: DataLine}
  537. ]
  538. })
  539. const ledgerStatsCards = computed(() => [
  540. {label: '设备数量', value: ledgerStats.value.total, unit: '台', icon: Monitor},
  541. {label: '在线设备', value: ledgerStats.value.online, unit: '台', icon: DataLine},
  542. {label: '离线设备', value: ledgerStats.value.offline, unit: '台', icon: Warning},
  543. {label: '在线率', value: ledgerStats.value.onlineRate, unit: '%', icon: Tools}
  544. ])
  545. const abnormalViews = ['abnormal', 'dispatch']
  546. const detailFields = computed(() => [
  547. ['设备编码', detail.value.equipmentCode],
  548. ['设备类型', detail.value.equipmentTypeName || detail.value.typeName],
  549. ...(abnormalViews.includes(view) ? [['所属区域', detail.value.district]] : []),
  550. ['安装位置', detail.value.equipmentLocation],
  551. ['设备状态', detail.value.currentStatusText],
  552. ['在线状态', detail.value.onlineStatusText],
  553. ['报警状态', detail.value.alarmStatusText],
  554. ['报警等级', detail.value.alarmLevelText],
  555. ['异常原因', detail.value.abnormalReasonText],
  556. ['关联工单', detail.value.relatedOrderNo || detail.value.orderNo],
  557. ['工单状态', detail.value.workOrderStatusText || detail.value.status],
  558. ['接单责任人', detail.value.relatedWorkOrderPerson || detail.value.maintainPerson],
  559. ['状态更新时间', detail.value.statusUpdateTime]
  560. ].map(([label, value]) => ({
  561. label,
  562. value: value ?? '-'
  563. })))
  564. async function loadData() {
  565. loading.value = true;
  566. loadError.value = '';
  567. try {
  568. if (view === 'gis') {
  569. const response = await listDeviceGisPoints({...query.value});
  570. const records = normalizeDeviceGisPoints(response).map(normalizeDeviceRow);
  571. page.value = {records, total: records.length, current: 1, size: records.length || 10};
  572. return
  573. }
  574. let response;
  575. if (view === 'maintain') response = await listMaintainRecords({...query.value}); else if (view === 'dispatch' || view === 'abnormal') response = await listAbnormalDevices({...query.value, ...(view === 'dispatch' ? {dispatchableOnly: true} : {})}); else response = await listDeviceLedger({...query.value});
  576. const normalized = normalizePage(response);
  577. page.value = {...normalized, records: normalized.records.map(normalizeDeviceRow)}
  578. if (view === 'ledger') await loadLedgerStats()
  579. if (view === 'abnormal') await loadAbnormalStats()
  580. } catch (error) {
  581. const retained = preserveDevicePageOnError(page.value);
  582. page.value = retained.page;
  583. loadError.value = retained.error;
  584. console.warn(`[${title}] 数据加载失败`, error)
  585. } finally {
  586. loading.value = false
  587. }
  588. }
  589. async function loadLedgerStats() {
  590. try {
  591. const response = await getDeviceLedgerStatistics()
  592. const stats = response?.data ?? response ?? {}
  593. ledgerStats.value = {
  594. total: Number(stats.total ?? 0) || 0,
  595. online: Number(stats.online ?? 0) || 0,
  596. offline: Number(stats.offline ?? 0) || 0,
  597. onlineRate: Number(stats.onlineRate ?? 0) || 0
  598. }
  599. } catch (error) {
  600. console.warn('设备台账统计加载失败', error)
  601. }
  602. }
  603. async function loadAbnormalStats() {
  604. try {
  605. const response = await getAbnormalDeviceStats()
  606. const stats = response?.data ?? response ?? {}
  607. abnormalStatsData.value = {
  608. total: Number(stats.total ?? 0) || 0,
  609. alarm: Number(stats.alarm ?? 0) || 0,
  610. repair: Number(stats.repair ?? 0) || 0,
  611. offline: Number(stats.offline ?? 0) || 0
  612. }
  613. } catch (error) {
  614. console.warn('[设备异常管理] 统计加载失败', error)
  615. }
  616. }
  617. function reset() {
  618. query.value = {
  619. pageNum: 1,
  620. pageSize: 10,
  621. equipmentName: '',
  622. equipmentCode: '',
  623. alarmStatus: undefined,
  624. alertLevel: undefined,
  625. abnormalType: undefined,
  626. region: undefined
  627. };
  628. loadData()
  629. }
  630. function selectPoint(row) {
  631. detail.value = normalizeDeviceRow(row);
  632. detailVisible.value = true
  633. }
  634. watch(deptFilterText, value => deptTreeRef.value?.filter(value))
  635. function filterDeptNode(value, data) {
  636. return !value || String(data.deptName || '').includes(value)
  637. }
  638. async function loadDeptTree(force = false) {
  639. if (!force && deptTreeData.value.length) return true;
  640. deptLoadError.value = '';
  641. try {
  642. const response = await listDept();
  643. deptTreeData.value = handleTree(response.data || [], 'deptId');
  644. return true
  645. } catch (error) {
  646. deptLoadError.value = '部门列表加载失败,请点击重试';
  647. userLoadError.value = deptLoadError.value;
  648. return false
  649. }
  650. }
  651. async function loadAssigneeUsers() {
  652. userLoading.value = true;
  653. userLoadError.value = '';
  654. try {
  655. const response = await listUser(buildAssigneeQuery({
  656. deptId: assigneeDeptId.value,
  657. keyword: assigneeKeyword.value,
  658. pageNum: assigneePageNum.value,
  659. pageSize: assigneePageSize.value
  660. }));
  661. const rows = response.rows || response.data?.rows || [];
  662. users.value = rows.map(normalizeAssigneeUser).filter(user => user.eligible);
  663. assigneeTotal.value = Number(response.total ?? response.data?.total ?? users.value.length) || 0
  664. } catch (error) {
  665. userLoadError.value = '责任人列表加载失败,请点击重试'
  666. } finally {
  667. userLoading.value = false
  668. }
  669. }
  670. function searchAssigneeUsers() {
  671. assigneePageNum.value = 1;
  672. loadAssigneeUsers()
  673. }
  674. function handleAssigneeSizeChange() {
  675. assigneePageNum.value = 1;
  676. loadAssigneeUsers()
  677. }
  678. async function retryAssigneeLoading() {
  679. const deptLoaded = await loadDeptTree(true);
  680. if (!deptLoaded) return;
  681. await loadAssigneeUsers()
  682. }
  683. async function openOrder(row) {
  684. dispatchDevice.value = row
  685. dispatchForm.value = {
  686. description: row.abnormalReason || row.abnormalReasonText || row.description || '',
  687. orderLevel: 1,
  688. receiveUserId: undefined
  689. }
  690. selectedAssignee.value = null
  691. assigneeDraft.value = null
  692. assigneeDeptId.value = undefined
  693. assigneeKeyword.value = ''
  694. assigneePageNum.value = 1
  695. dispatchVisible.value = true
  696. await retryAssigneeLoading()
  697. }
  698. function handleDeptClick(node) {
  699. assigneeDeptId.value = node.deptId;
  700. assigneePageNum.value = 1;
  701. loadAssigneeUsers()
  702. }
  703. function chooseAssignee(row) {
  704. assigneeDraft.value = selectSingleAssignee([row])
  705. }
  706. function confirmAssignee() {
  707. selectedAssignee.value = assigneeDraft.value;
  708. dispatchForm.value.receiveUserId = assigneeDraft.value?.userId
  709. }
  710. async function submitDispatch() {
  711. if (!dispatchForm.value.description?.trim()) return ElMessage.warning('请填写问题描述')
  712. if (!dispatchForm.value.receiveUserId) return ElMessage.warning('请选择接单责任人')
  713. dispatching.value = true
  714. try {
  715. const response = await createAbnormalOrder(buildMaintainOrderPayload(dispatchDevice.value, dispatchForm.value))
  716. const summary = normalizeDispatchOrderResponse(response)
  717. if (!summary) throw new Error('派单失败:服务端未返回有效的待接单工单')
  718. ElMessage.success(`已派单,等待接单(${summary.orderNo})`)
  719. dispatchVisible.value = false
  720. await loadData()
  721. } catch (error) {
  722. const responseData = error?.response?.data
  723. const message = responseData?.msg || responseData?.message || error?.message || '派单失败,请检查权限或服务状态'
  724. // A conflict means another order now owns the device; refresh the list so its order number is visible.
  725. if (/已有|冲突|重复|处理中的工单|关联工单/.test(String(message))) {
  726. await loadData()
  727. ElMessage.warning(`该设备已有相关处置工单:${message}`)
  728. } else {
  729. ElMessage.error(message)
  730. }
  731. } finally {
  732. dispatching.value = false
  733. }
  734. }
  735. async function removeRecord(row) {
  736. try {
  737. await deleteMaintainRecord(row.maintainId || row.id);
  738. ElMessage.success('记录已删除');
  739. await loadData()
  740. } catch (error) {
  741. ElMessage.error('删除失败')
  742. }
  743. }
  744. function emptyDeviceForm() {
  745. return {
  746. equipmentId: undefined, equipmentCode: '', equipmentTypeId: undefined, equipmentTypeName: '',
  747. equipmentName: '', equipmentModel: '', district: '', manufacturer: '', longitude: undefined,
  748. latitude: undefined, equipmentLocation: '', maintainer: '', maintainerPhone: '', ownershipUnit: '',
  749. equipmentImage: '', remark: ''
  750. }
  751. }
  752. function isWaterDeviceType(type, typesById) {
  753. if (!type || (type.typeName === '供水' && String(type.parentId) === '0')) return false
  754. let cursor = type
  755. let guard = 0
  756. while (cursor && guard++ <= typesById.size) {
  757. if (cursor.typeName === '供水' && String(cursor.parentId) === '0') return true
  758. cursor = typesById.get(cursor.parentId)
  759. }
  760. return false
  761. }
  762. async function loadWaterTypes() {
  763. if (waterDeviceTypes.value.length) return
  764. try {
  765. const response = await listEquipmentTypeAll()
  766. const rows = response?.data || []
  767. const typesById = new Map(rows.map(item => [item.id, item]))
  768. waterDeviceTypes.value = rows.filter(item => isWaterDeviceType(item, typesById))
  769. .map(({id, typeName}) => ({id, typeName}))
  770. } catch (error) {
  771. ElMessage.warning('供水设备类型加载失败,请重试')
  772. }
  773. }
  774. async function openCreate() {
  775. editMode.value = 'create'
  776. editVisible.value = true
  777. editSaving.value = false
  778. editForm.value = emptyDeviceForm()
  779. await loadWaterTypes()
  780. }
  781. function handleDeviceSelectionChange(rows) {
  782. selectedDevices.value = rows
  783. }
  784. async function confirmDeleteDevices(ids) {
  785. await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 台设备吗?删除后不可恢复。`, '删除设备', {type: 'warning'})
  786. await deleteWaterDevices(ids)
  787. ElMessage.success('删除成功')
  788. selectedDevices.value = []
  789. query.value.pageNum = 1
  790. await loadData()
  791. }
  792. async function removeDevice(row) {
  793. if (!row?.equipmentId) return
  794. await confirmDeleteDevices([row.equipmentId])
  795. }
  796. async function removeSelectedDevices() {
  797. const ids = selectedDevices.value.map(row => row.equipmentId).filter(Boolean)
  798. if (!ids.length) return
  799. await confirmDeleteDevices(ids)
  800. }
  801. async function openEdit(row) {
  802. editMode.value = 'edit'
  803. editVisible.value = true
  804. editSaving.value = false
  805. editForm.value = {...row}
  806. try {
  807. const response = await getWaterDeviceById(row.equipmentId)
  808. const full = response?.data || response
  809. if (full) editForm.value = {...editForm.value, ...full}
  810. } catch (error) {
  811. ElMessage.warning('设备详情加载失败,已使用列表数据填充')
  812. }
  813. }
  814. async function submitEdit() {
  815. try {
  816. await editFormRef.value?.validate()
  817. } catch {
  818. return
  819. }
  820. editSaving.value = true
  821. try {
  822. const creating = editMode.value === 'create'
  823. if (creating) await createWaterDevice(editForm.value)
  824. else await updateWaterDevice(editForm.value)
  825. ElMessage.success(creating ? '新增成功' : '修改成功')
  826. editVisible.value = false
  827. if (creating) query.value.pageNum = 1
  828. await loadData()
  829. } catch (error) {
  830. ElMessage.error(error?.response?.data?.msg || error?.message || (editMode.value === 'create' ? '新增失败,请稍后重试' : '修改失败,请稍后重试'))
  831. } finally {
  832. editSaving.value = false
  833. }
  834. }
  835. function openImport() {
  836. importVisible.value = true
  837. }
  838. function handleImportChange(file, fileList) {
  839. if (!/\.(xlsx|xls)$/iu.test(file?.name || '')) {
  840. ElMessage.error('仅支持 xls、xlsx 文件')
  841. importFileList.value = []
  842. return false
  843. }
  844. importFileList.value = fileList.slice(-1)
  845. }
  846. function handleImportRemove(file, fileList) {
  847. importFileList.value = fileList
  848. }
  849. function resetImport() {
  850. importUploading.value = false
  851. importUpdateSupport.value = false
  852. importFileList.value = []
  853. importUploadRef.value?.clearFiles()
  854. }
  855. async function submitImport() {
  856. if (!importFileList.value.length) return ElMessage.warning('请选择 xls 或 xlsx 文件')
  857. importUploading.value = true
  858. try {
  859. const response = await importDeviceLedger(importFileList.value[0].raw, importUpdateSupport.value)
  860. if (response?.code && response.code !== 200) throw new Error(response.msg || response.message || '导入失败,请检查文件内容')
  861. ElMessage.success(response?.msg || response?.data || '导入成功')
  862. importVisible.value = false
  863. await loadData()
  864. } catch (error) {
  865. ElMessage.error(error?.response?.data?.msg || error?.response?.data?.message || error?.message || '导入失败,请稍后重试')
  866. } finally {
  867. importUploading.value = false
  868. }
  869. }
  870. async function exportData() {
  871. try {
  872. const response = await (view === 'dispatch' || view === 'abnormal' ? exportAbnormalDevices : exportDeviceLedger)({...query.value})
  873. const errorMessage = await readMonitoringExportError(response)
  874. if (errorMessage) throw new Error(errorMessage)
  875. const blob = response instanceof Blob ? response : new Blob([response], {type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'})
  876. saveAs(blob, `${title}_${new Date().toISOString().slice(0, 10)}.xlsx`)
  877. ElMessage.success('导出成功')
  878. } catch (error) {
  879. const backendMessage = await readMonitoringExportError(error?.response?.data)
  880. ElMessage.error(backendMessage || error?.message || '导出失败,请稍后重试')
  881. }
  882. }
  883. onMounted(loadData)
  884. </script>
  885. <style scoped>
  886. .device-page {
  887. padding: 20px;
  888. background: linear-gradient(135deg, #eff6ff 0%, #f8fafc 45%, #eef2f7 100%);
  889. min-height: calc(100vh - 84px)
  890. }
  891. .page-heading {
  892. display: flex;
  893. justify-content: space-between;
  894. align-items: center;
  895. gap: 16px;
  896. margin-bottom: 16px
  897. }
  898. .page-title {
  899. display: flex;
  900. align-items: center;
  901. gap: 12px;
  902. min-width: 0
  903. }
  904. .page-title-icon {
  905. display: grid;
  906. place-items: center;
  907. width: 42px;
  908. height: 42px;
  909. color: #fff;
  910. background: linear-gradient(135deg, #2563eb 0%, #06b6d4 100%);
  911. border-radius: 8px;
  912. box-shadow: 0 8px 16px rgba(37, 99, 235, .2)
  913. }
  914. .page-title h2 {
  915. margin: 0 0 4px;
  916. font-size: 20px;
  917. color: #1e293b
  918. }
  919. .muted {
  920. font-size: 13px;
  921. color: #64748b
  922. }
  923. .heading-refresh {
  924. border-color: #bfdbfe;
  925. color: #2563eb;
  926. background: #fff
  927. }
  928. .filter-card, .water-panel {
  929. border: 1px solid #e2e8f0;
  930. border-radius: 8px;
  931. box-shadow: 0 6px 14px rgba(15, 23, 42, .04)
  932. }
  933. .filter-card {
  934. margin-bottom: 16px;
  935. background: #fff
  936. }
  937. .filter-card :deep(.el-card__header) {
  938. padding: 14px 18px;
  939. border-bottom: 1px solid #e2e8f0;
  940. background: #fbfdff
  941. }
  942. .filter-card :deep(.el-form-item) {
  943. margin-top: 8px;
  944. margin-right: 16px;
  945. margin-bottom: 8px
  946. }
  947. .filter-card :deep(.el-form-item__label) {
  948. color: #475569
  949. }
  950. .metric-grid {
  951. display: grid;
  952. grid-template-columns:repeat(4, minmax(0, 1fr));
  953. gap: 14px;
  954. margin-bottom: 16px
  955. }
  956. .metric {
  957. display: flex;
  958. align-items: center;
  959. gap: 14px;
  960. min-width: 0;
  961. padding: 16px 18px;
  962. background: #fff;
  963. border: 1px solid #e2e8f0;
  964. border-radius: 8px;
  965. box-shadow: 0 6px 14px rgba(15, 23, 42, .04)
  966. }
  967. .metric-icon {
  968. position: static;
  969. flex: 0 0 42px;
  970. display: grid;
  971. place-items: center;
  972. width: 42px;
  973. height: 42px;
  974. color: #2563eb;
  975. background: linear-gradient(135deg, #dbeafe 0%, #eff6ff 100%);
  976. border-radius: 8px
  977. }
  978. .metric-icon .el-icon {
  979. font-size: 22px
  980. }
  981. .metric-body {
  982. min-width: 0;
  983. text-align: left
  984. }
  985. .metric-body span {
  986. display: block;
  987. overflow: hidden;
  988. margin-bottom: 3px;
  989. color: #64748b;
  990. font-size: 13px;
  991. text-overflow: ellipsis;
  992. white-space: nowrap
  993. }
  994. .metric-value {
  995. display: flex;
  996. align-items: baseline;
  997. gap: 5px;
  998. min-width: 0
  999. }
  1000. .metric-value strong {
  1001. font-size: 24px;
  1002. line-height: 1.2;
  1003. color: #1e293b
  1004. }
  1005. .metric-value small {
  1006. color: #94a3b8
  1007. }
  1008. .water-panel {
  1009. overflow: hidden
  1010. }
  1011. .water-panel :deep(.el-card__header) {
  1012. padding: 14px 18px;
  1013. border-bottom: 1px solid #e2e8f0;
  1014. background: #fbfdff
  1015. }
  1016. .card-title {
  1017. display: flex;
  1018. align-items: center;
  1019. gap: 8px;
  1020. color: #1e293b;
  1021. font-weight: 600;
  1022. margin-bottom: 16px
  1023. }
  1024. .card-title .el-icon {
  1025. color: #2563eb;
  1026. font-size: 18px
  1027. }
  1028. .table-toolbar {
  1029. display: flex;
  1030. justify-content: space-between;
  1031. align-items: center
  1032. }
  1033. .panel-actions {
  1034. display: flex;
  1035. align-items: center;
  1036. gap: 8px
  1037. }
  1038. .load-error {
  1039. margin-bottom: 14px
  1040. }
  1041. .device-page :deep(.el-table) {
  1042. --el-table-border-color: #e2e8f0;
  1043. --el-table-header-bg-color: #f8fafc;
  1044. --el-table-header-text-color: #475569;
  1045. --el-table-row-hover-bg-color: #eff6ff
  1046. }
  1047. .device-page :deep(.el-table__row) {
  1048. cursor: pointer
  1049. }
  1050. .device-image {
  1051. width: 66px;
  1052. height: 44px;
  1053. border-radius: 6px;
  1054. overflow: hidden
  1055. }
  1056. .image-placeholder {
  1057. display: inline-grid;
  1058. place-items: center;
  1059. width: 66px;
  1060. height: 44px;
  1061. color: #94a3b8;
  1062. font-size: 12px;
  1063. background: #f1f5f9;
  1064. border-radius: 6px
  1065. }
  1066. .detail-summary {
  1067. display: flex;
  1068. align-items: center;
  1069. gap: 12px;
  1070. margin-bottom: 16px
  1071. }
  1072. .detail-name {
  1073. font-size: 18px;
  1074. font-weight: 700;
  1075. color: #17324d
  1076. }
  1077. .device-detail-drawer :deep(.el-drawer__body), .device-edit-drawer :deep(.el-drawer__body) {
  1078. overflow: auto;
  1079. padding: 20px 24px
  1080. }
  1081. .device-detail-drawer :deep(.el-descriptions__label) {
  1082. white-space: nowrap
  1083. }
  1084. .device-detail-drawer :deep(.el-descriptions__content) {
  1085. overflow-wrap: anywhere;
  1086. word-break: break-word
  1087. }
  1088. .detail-media {
  1089. margin-bottom: 14px;
  1090. padding: 12px;
  1091. background: #f7fbff;
  1092. border: 1px solid rgba(37, 99, 235, .1);
  1093. border-radius: 10px
  1094. }
  1095. .detail-media-label {
  1096. margin-bottom: 8px;
  1097. font-weight: 600;
  1098. color: #17324d
  1099. }
  1100. .detail-image {
  1101. width: 100%;
  1102. height: 180px;
  1103. border-radius: 8px;
  1104. overflow: hidden
  1105. }
  1106. .detail-image-placeholder {
  1107. display: grid;
  1108. place-items: center;
  1109. height: 120px;
  1110. background: #f1f5f9;
  1111. border-radius: 8px;
  1112. color: #909399
  1113. }
  1114. .full-input {
  1115. width: 100%
  1116. }
  1117. .import-update {
  1118. margin-top: 10px
  1119. }
  1120. .import-tip {
  1121. line-height: 1.6
  1122. }
  1123. .el-pagination {
  1124. justify-content: flex-end;
  1125. margin-top: 16px
  1126. }
  1127. .dispatch-layout {
  1128. display: flex;
  1129. gap: 20px;
  1130. min-height: 420px
  1131. }
  1132. .dispatch-form {
  1133. width: 300px;
  1134. min-width: 280px;
  1135. padding-left: 20px;
  1136. border-left: 1px solid #e4e7ed
  1137. }
  1138. .assignee-field {
  1139. display: flex;
  1140. align-items: center;
  1141. gap: 12px;
  1142. width: 100%;
  1143. min-height: 32px
  1144. }
  1145. .assignee-summary {
  1146. color: #17324d
  1147. }
  1148. .assignee-placeholder {
  1149. color: #909399
  1150. }
  1151. .assignee-picker {
  1152. display: flex;
  1153. gap: 16px;
  1154. flex: 1;
  1155. min-width: 0;
  1156. min-height: 390px
  1157. }
  1158. .assignee-depts {
  1159. width: 190px;
  1160. min-width: 190px;
  1161. padding-right: 16px;
  1162. border-right: 1px solid #e4e7ed
  1163. }
  1164. .assignee-tree {
  1165. margin-top: 10px;
  1166. max-height: 340px;
  1167. overflow: auto
  1168. }
  1169. .assignee-users {
  1170. flex: 1;
  1171. min-width: 0
  1172. }
  1173. .assignee-search {
  1174. display: flex;
  1175. gap: 8px;
  1176. margin-bottom: 12px
  1177. }
  1178. .assignee-search .el-input {
  1179. flex: 1
  1180. }
  1181. .assignee-users .el-pagination {
  1182. margin-top: 12px
  1183. }
  1184. .assignee-picked {
  1185. display: flex;
  1186. justify-content: space-between;
  1187. align-items: center;
  1188. gap: 8px;
  1189. margin-top: 14px;
  1190. padding: 10px 12px;
  1191. background: #f5f7fa;
  1192. border: 1px solid #ebeef5;
  1193. color: #606266
  1194. }
  1195. .assignee-picked strong {
  1196. color: #2563eb
  1197. }
  1198. .assignee-users .el-alert {
  1199. margin-bottom: 12px
  1200. }
  1201. @media (max-width: 900px) {
  1202. .metric-grid {
  1203. grid-template-columns:repeat(2, minmax(0, 1fr))
  1204. }
  1205. .filter-card :deep(.el-form-item) {
  1206. margin-bottom: 12px
  1207. }
  1208. }
  1209. @media (max-width: 768px) {
  1210. .device-detail-drawer, .device-edit-drawer {
  1211. width: 100% !important
  1212. }
  1213. .dispatch-dialog {
  1214. width: calc(100% - 24px) !important;
  1215. margin-left: auto;
  1216. margin-right: auto
  1217. }
  1218. .dispatch-layout {
  1219. flex-direction: column
  1220. }
  1221. .assignee-picker {
  1222. flex-direction: column
  1223. }
  1224. .assignee-depts {
  1225. width: auto;
  1226. min-width: 0;
  1227. padding-right: 0;
  1228. padding-bottom: 12px;
  1229. border-right: 0;
  1230. border-bottom: 1px solid #e4e7ed
  1231. }
  1232. .assignee-tree {
  1233. max-height: 180px
  1234. }
  1235. .dispatch-form {
  1236. width: auto;
  1237. min-width: 0;
  1238. padding-left: 0;
  1239. padding-top: 16px;
  1240. border-left: 0;
  1241. border-top: 1px solid #e4e7ed
  1242. }
  1243. }
  1244. </style>
  1245. <style lang="scss">
  1246. @use './waterSupplyPageTheme.scss';
  1247. @media (max-width: 768px) {
  1248. .device-detail-drawer {
  1249. width: 100% !important
  1250. }
  1251. .dispatch-dialog {
  1252. width: calc(100% - 24px) !important;
  1253. margin-left: auto;
  1254. margin-right: auto
  1255. }
  1256. .dispatch-layout {
  1257. flex-direction: column
  1258. }
  1259. .assignee-picker {
  1260. flex-direction: column
  1261. }
  1262. .assignee-depts {
  1263. width: auto;
  1264. min-width: 0;
  1265. padding-right: 0;
  1266. padding-bottom: 12px;
  1267. border-right: 0;
  1268. border-bottom: 1px solid #e4e7ed
  1269. }
  1270. .assignee-tree {
  1271. max-height: 180px
  1272. }
  1273. .dispatch-form {
  1274. width: auto;
  1275. min-width: 0;
  1276. padding-left: 0;
  1277. padding-top: 16px;
  1278. border-left: 0;
  1279. border-top: 1px solid #e4e7ed
  1280. }
  1281. }
  1282. </style>