ConfigDeviceAllot.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628
  1. <template>
  2. <div class="container" >
  3. <el-row :gutter="20" class="equal-height-row">
  4. <!-- 左侧设备列表 -->
  5. <el-col :span="12" class="col-height">
  6. <div class="card left-card">
  7. <h3>{{ t('configPerson.deviceList') }}</h3>
  8. <div class="dept-select">
  9. <el-tree-select
  10. clearable
  11. v-model="formData.deptId"
  12. :data="deptList"
  13. :props="defaultProps"
  14. check-strictly
  15. node-key="id"
  16. filterable
  17. :placeholder="t('configPerson.deviceListHolder')"
  18. @node-click="handleDeptDeviceTreeNodeClick"
  19. />
  20. </div>
  21. <!-- 设备搜索框 -->
  22. <div class="filter-input">
  23. <el-input
  24. v-model="deviceFilterText"
  25. :placeholder="t('devicePerson.filterDevicePlaceholder')"
  26. :disabled="!devicesLoaded"
  27. clearable
  28. prefix-icon="Search"
  29. />
  30. </div>
  31. <el-scrollbar height="500px">
  32. <el-checkbox-group v-model="selectedDevices">
  33. <div
  34. v-for="device in filteredDevices"
  35. :key="device.id"
  36. class="checkbox-item"
  37. >
  38. <el-checkbox :label="device.id">
  39. {{ device.deviceCode }} ({{ device.deviceName }}) - {{ device.deptName }} —— {{ device.devicePersons }}
  40. </el-checkbox>
  41. </div>
  42. </el-checkbox-group>
  43. </el-scrollbar>
  44. </div>
  45. </el-col>
  46. <!-- 右侧部门选择 -->
  47. <el-col :span="12" class="col-height">
  48. <div class="card right-card">
  49. <h3>{{ t('configDevice.deptList') }}</h3>
  50. <ContentWrap class="dept-tree-container" height="400px">
  51. <DeptTree2 ref="deptTreeRef" v-model="selectedDeptId" height="100%" @update:modelValue="handleDeptChange"/>
  52. </ContentWrap>
  53. </div>
  54. </el-col>
  55. </el-row>
  56. <!-- 暂存关联列表 -->
  57. <div class="submit-area">
  58. <div class="card selection-area">
  59. <div class="control-row">
  60. <!-- 左侧人员选择 -->
  61. <div class="control-group">
  62. <label class="control-title">{{ t('devicePerson.rp') }}</label>
  63. <div class="person-selector">
  64. <el-select
  65. v-model="selectedPersons"
  66. multiple
  67. filterable
  68. :placeholder="t('configPerson.selectPersons')"
  69. style="width: 100%"
  70. >
  71. <el-option
  72. v-for="person in simpleUsers"
  73. :key="person.id"
  74. :label="person.nickname"
  75. :value="person.id"
  76. />
  77. </el-select>
  78. </div>
  79. </div>
  80. <div class="control-group">
  81. <label class="control-title">{{ t('configDevice.reasonForAdjustment') }}<span class="required-star">*</span></label>
  82. <div class="reason-input-wrapper">
  83. <el-input
  84. v-model="formData.reason"
  85. :placeholder="t('configDevice.rfaHolder')"
  86. class="reason-input"
  87. type="textarea"
  88. :rows="4"
  89. resize="none"
  90. @input="updateTempRelations"
  91. />
  92. </div>
  93. </div>
  94. </div>
  95. </div>
  96. <div class="temp-list card" v-if="true">
  97. <h3>{{ t('configPerson.adjustmentRecords') }}</h3>
  98. <el-table :data="tempRelations" style="width: 100%">
  99. <el-table-column prop="deviceNames" label="设备" width="200" />
  100. <el-table-column prop="deptName" label="部门" />
  101. <el-table-column prop="deptId" label="部门id" v-if="false"/>
  102. <el-table-column prop="reason" label="调拨原因" />
  103. <el-table-column label="操作" width="120">
  104. <template #default="{ row }">
  105. <el-button
  106. type="danger"
  107. size="small"
  108. @click="removeTempRelation(row.deviceId)"
  109. >
  110. 删除
  111. </el-button>
  112. </template>
  113. </el-table-column>
  114. </el-table>
  115. </div>
  116. <el-button
  117. type="primary"
  118. size="large"
  119. style="min-width: 180px;"
  120. @click="submitRelations"
  121. :disabled="tempRelations.length === 0 || !formData.reason.trim()"
  122. >
  123. {{ t('iotMaintain.save') }}
  124. </el-button>
  125. </div>
  126. </div>
  127. </template>
  128. <script setup lang="ts">
  129. import { ref, computed, onMounted } from 'vue'
  130. import { ElMessage } from 'element-plus'
  131. import {defaultProps, handleTree} from "@/utils/tree";
  132. import * as DeptApi from "@/api/system/dept";
  133. import * as UserApi from "@/api/system/user";
  134. import {IotDeviceApi, IotDeviceVO} from "@/api/pms/device";
  135. import DeptTree2 from "@/views/pms/device/DeptTree2.vue";
  136. import { useRouter, useRoute } from 'vue-router'
  137. import { useTagsViewStore } from "@/store/modules/tagsView";
  138. import {UserVO} from "@/api/system/user";
  139. const router = useRouter()
  140. const route = useRoute() // 获取路由参数
  141. const { t } = useI18n() // 国际化
  142. defineOptions({ name: 'ConfigDeviceAllot' })
  143. const selectedDeptId = ref<number | string>('')
  144. const simpleDevices = ref<IotDeviceVO[]>([])
  145. const deptList = ref<Tree[]>([]) // 树形结构
  146. const selectedDevices = ref<number[]>([]) // 改为数组存储多选
  147. const { delView } = useTagsViewStore() // 视图操作
  148. // 设备加载状态标记 只有加载完设备才能通过文本框筛选
  149. const devicesLoaded = ref(false)
  150. const formData = ref({
  151. id: undefined,
  152. deviceCode: undefined,
  153. deviceName: undefined,
  154. brand: undefined,
  155. model: undefined,
  156. deptId: undefined as string | undefined,
  157. deviceStatus: undefined,
  158. reason: '',
  159. assetProperty: undefined,
  160. picUrl: undefined,
  161. })
  162. const queryParams = reactive({
  163. deptId: formData.value.deptId,
  164. })
  165. const deptTreeRef = ref<InstanceType<typeof DeptTree2>>()
  166. const emit = defineEmits(['success', 'node-click']) // 定义 success 树点击 事件,用于操作成功后的回调
  167. const simpleUsers = ref<UserVO[]>([]) // 人员下拉列表选项
  168. const selectedPersons = ref<number[]>([]) // 存储选中的人员ID
  169. // 响应式数据
  170. const tempRelationsMap = ref(new Map<number, {
  171. deviceId: number
  172. deviceNames: string
  173. deptId: number
  174. deptName: string
  175. reason: string
  176. }>())
  177. // 计算属性转换 Map 为数组
  178. const tempRelations = computed(() =>
  179. Array.from(tempRelationsMap.value.values())
  180. )
  181. const updateTempRelations = () => {
  182. if (!selectedDeptId.value) {
  183. // 未选择部门时清除所有关联
  184. tempRelationsMap.value.clear()
  185. return
  186. }
  187. // 获取部门信息
  188. const treeNode = deptTreeRef.value?.treeRef?.getNode(selectedDeptId.value)
  189. const deptName = treeNode?.data?.name || '未知部门'
  190. // 创建当前应该存在的设备集合
  191. const currentDeviceIds = new Set(selectedDevices.value)
  192. // 清理无效关联(包含:1.当前部门外的关联 2.未选中的设备)
  193. Array.from(tempRelationsMap.value.entries()).forEach(([deviceId, relation]) => {
  194. if (relation.deptId !== selectedDeptId.value || !currentDeviceIds.has(deviceId)) {
  195. tempRelationsMap.value.delete(deviceId)
  196. }
  197. })
  198. // 添加/更新当前选中设备的关联
  199. selectedDevices.value.forEach(deviceId => {
  200. const device = simpleDevices.value.find(d => d.id === deviceId)
  201. if (device) {
  202. tempRelationsMap.value.set(deviceId, {
  203. deviceId,
  204. deviceNames: `${device.deviceCode} (${device.deviceName})`,
  205. deptId: selectedDeptId.value as number,
  206. deptName,
  207. reason: formData.value.reason
  208. })
  209. }
  210. })
  211. }
  212. // 添加设备选择监听
  213. watch([selectedDevices, selectedDeptId], () => {
  214. updateTempRelations();
  215. }, {deep: true, immediate: true, debounce: 100})
  216. // 监听部门变化
  217. watch(selectedDeptId, (newVal) => {
  218. if (newVal) {
  219. loadDeptPersons(newVal as number)
  220. } else {
  221. simpleUsers.value = []
  222. }
  223. selectedPersons.value = [] // 切换部门时清空已选人员
  224. })
  225. // 修改部门变更处理方法
  226. const handleDeptChange = (deptId) => {
  227. if (!deptId) {
  228. selectedDeptId.value = ''
  229. return
  230. }
  231. // 校验部门选择
  232. if (!validateDeptSelection(deptId)) {
  233. // 重置部门选择
  234. selectedDeptId.value = ''
  235. deptTreeRef.value?.treeRef?.setCurrentKey(undefined) // 清除树的选择状态
  236. deptTreeRef.value?.treeRef?.setCurrentNode(undefined)
  237. return
  238. }
  239. selectedDeptId.value = deptId
  240. updateTempRelations()
  241. }
  242. /** 处理 部门-设备 树 被点击 */
  243. const handleDeptDeviceTreeNodeClick = async (row: { [key: string]: any }) => {
  244. emit('node-click', row)
  245. formData.value.deptId = row.id
  246. selectedDevices.value = []
  247. await getDeviceList()
  248. }
  249. /** 获得 部门下的设备 列表 */
  250. const getDeviceList = async () => {
  251. try {
  252. // 查询到数据后才能进行搜索 筛选
  253. devicesLoaded.value = false
  254. const params = { deptId: formData.value.deptId }
  255. const data = await IotDeviceApi.simpleDevices(params)
  256. simpleDevices.value = data || []
  257. devicesLoaded.value = true
  258. } catch (error) {
  259. simpleDevices.value = []
  260. // 即使失败也启用搜索框(避免卡在禁用状态)
  261. devicesLoaded.value = true
  262. console.error('获取设备列表失败:', error)
  263. }
  264. }
  265. // 选择部门后 加载部门人员
  266. const loadDeptPersons = async (deptId: number) => {
  267. if (!deptId) {
  268. simpleUsers.value = []
  269. return
  270. }
  271. try {
  272. // 调用API获取部门人员
  273. const params = { deptId: deptId, pageNo: 1, pageSize: 10 }
  274. const data = await UserApi.simpleUserList(params)
  275. simpleUsers.value = data.map(user => ({
  276. id: user.id,
  277. nickname: user.nickname || user.username
  278. }))
  279. } catch (error) {
  280. console.error('获取部门人员失败:', error)
  281. simpleUsers.value = []
  282. }
  283. }
  284. // 计算选中的设备原部门集合
  285. const originDeptIds = computed(() =>
  286. selectedDevices.value
  287. .map(deviceId =>
  288. simpleDevices.value.find(d => d.id === deviceId)?.deptId
  289. )
  290. .filter(Boolean) as number[]
  291. )
  292. // 部门选择校验方法
  293. const validateDeptSelection = (deptId: number) => {
  294. if (originDeptIds.value.includes(deptId)) {
  295. ElMessage.error('不能选择设备原属部门作为调拨部门')
  296. return false
  297. }
  298. return true
  299. }
  300. // 设备过滤文本
  301. const deviceFilterText = ref('')
  302. // 计算属性:过滤设备列表
  303. const filteredDevices = computed(() => {
  304. const searchText = deviceFilterText.value.toLowerCase().trim()
  305. if (!searchText) return simpleDevices.value
  306. return simpleDevices.value.filter(device => {
  307. return (
  308. (device.deviceCode || '').toLowerCase().includes(searchText) ||
  309. (device.deviceName || '').toLowerCase().includes(searchText)
  310. )
  311. })
  312. })
  313. // 在计算属性区域添加 selectedPersonNames
  314. const selectedPersonNames = computed(() => {
  315. return selectedPersons.value.map(id => {
  316. const user = simpleUsers.value.find(u => u.id === id)
  317. return user?.nickname || ''
  318. }).filter(Boolean) // 过滤掉空值
  319. })
  320. const removeTempRelation = (deviceId: number) => {
  321. tempRelationsMap.value.delete(deviceId)
  322. selectedDevices.value = selectedDevices.value.filter(id => id !== deviceId)
  323. };
  324. const submitRelations = async () => {
  325. try {
  326. // 提交前二次校验
  327. const invalidDept = tempRelations.value.some(r =>
  328. originDeptIds.value.includes(r.deptId)
  329. )
  330. if (invalidDept) {
  331. ElMessage.error('存在调拨部门与设备原属部门相同的情况,请检查')
  332. return
  333. }
  334. // 转换为后端需要的格式
  335. const submitData = tempRelations.value.map(r => ({
  336. deviceId: r.deviceId,
  337. deptId: r.deptId,
  338. reason: r.reason,
  339. personIds: selectedPersons.value || [], // 添加人员ID列表
  340. personNames: [...selectedPersonNames.value]
  341. }))
  342. await IotDeviceApi.saveDeviceAllot(submitData)
  343. // 模拟API调用
  344. console.log('提交数据:', submitData)
  345. ElMessage.success('提交成功')
  346. tempRelations.value = []
  347. delView(unref(router.currentRoute.value))
  348. router.back()
  349. } catch (error) {
  350. ElMessage.error('提交失败,请重试')
  351. }
  352. }
  353. /** 初始化 */
  354. onMounted(async () => {
  355. try {
  356. // 初始化部门树
  357. const deptData = await DeptApi.getSimpleDeptList()
  358. deptList.value = handleTree(deptData) // 转换为树形结构
  359. // 从路由参数获取部门ID
  360. const routeDeptId = route.query.deptId ? Number(route.query.deptId) : null
  361. let targetDeptId: number
  362. if (routeDeptId) {
  363. // 如果路由参数有部门ID,使用路由参数中的部门ID
  364. targetDeptId = routeDeptId
  365. } else if (deptList.value.length > 0) {
  366. // 否则使用第一个节点
  367. targetDeptId = deptList.value[0].id
  368. } else {
  369. console.warn("部门树数据为空,无法设置默认值")
  370. return
  371. }
  372. // 设置默认选中的部门(转换后树的第一个节点)
  373. // if (deptList.value.length > 0) {
  374. // 获取转换后树的第一个节点
  375. // const firstRootNode = deptList.value[0]
  376. // 设置设备部门的默认值
  377. formData.value.deptId = targetDeptId
  378. // 触发设备部门的节点点击事件,加载设备列表
  379. const targetDeptNode = findDeptNode(deptList.value, targetDeptId)
  380. // 触发设备部门的节点点击事件,加载设备列表
  381. if (targetDeptNode) {
  382. await handleDeptDeviceTreeNodeClick(targetDeptNode)
  383. }
  384. // } else {
  385. // console.warn("部门树数据为空,无法设置默认值")
  386. // }
  387. } catch (error) {
  388. console.error("初始化部门树失败:", error)
  389. ElMessage.error("加载部门数据失败")
  390. }
  391. nextTick(() => {
  392. // 强制重新计算布局
  393. window.dispatchEvent(new Event('resize'))
  394. })
  395. })
  396. // 在部门树中查找指定ID的节点
  397. const findDeptNode = (nodes: Tree[], deptId: number): Tree | null => {
  398. for (const node of nodes) {
  399. if (node.id === deptId) {
  400. return node
  401. }
  402. if (node.children && node.children.length > 0) {
  403. const found = findDeptNode(node.children, deptId)
  404. if (found) return found
  405. }
  406. }
  407. return null
  408. }
  409. </script>
  410. <style scoped>
  411. .container {
  412. padding: 20px;
  413. }
  414. .card {
  415. border: 1px solid #ebeef5;
  416. border-radius: 4px;
  417. padding: 20px;
  418. margin-bottom: 20px;
  419. background: white;
  420. display: flex;
  421. flex-direction: column;
  422. height: 100%; /* 根据实际需要调整整体高度 */
  423. box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1);
  424. transition: box-shadow 0.2s;
  425. }
  426. .card:hover {
  427. box-shadow: 0 4px 16px rgba(0,0,0,0.15);
  428. }
  429. .list-item {
  430. padding: 8px 12px;
  431. border-bottom: 1px solid #f0f0f0;
  432. }
  433. .dept-select {
  434. margin-bottom: 20px;
  435. }
  436. .user-list {
  437. margin-bottom: 20px;
  438. border: 1px solid #ebeef5;
  439. border-radius: 4px;
  440. padding: 10px;
  441. }
  442. .action-bar {
  443. text-align: right;
  444. }
  445. .temp-list {
  446. margin-top: 20px;
  447. }
  448. .submit-area {
  449. margin-top: 20px;
  450. text-align: center;
  451. }
  452. h3 {
  453. margin: 0 0 15px 0;
  454. color: #303133;
  455. }
  456. .dept-select {
  457. flex-shrink: 0; /* 防止搜索框被压缩 */
  458. }
  459. .el-scrollbar__wrap {
  460. overflow-x: hidden;
  461. }
  462. .tree-container .el-tree {
  463. height: 100% !important;
  464. }
  465. /* 左侧滚动区域 */
  466. .el-scrollbar,
  467. .tree-container {
  468. scrollbar-width: thin;
  469. scrollbar-color: #c0c4cc transparent;
  470. }
  471. .left-card,
  472. .right-card {
  473. flex: 1;
  474. display: flex;
  475. flex-direction: column;
  476. height: 100%; /* 继承父级高度 */
  477. }
  478. .dept-tree-container {
  479. flex: 1;
  480. min-height: 0; /* 关键:允许内容区域收缩 */
  481. position: relative;
  482. overflow: hidden;
  483. }
  484. /* 调整树形组件内部滚动 */
  485. :deep(.el-tree) {
  486. height: 100%;
  487. overflow: auto;
  488. }
  489. .equal-height-row {
  490. display: flex;
  491. align-items: stretch; /* 关键:等高布局 */
  492. }
  493. .col-height {
  494. display: flex;
  495. flex-direction: column;
  496. min-height: 400px;/* 统一最小高度 */
  497. }
  498. .filter-input {
  499. margin-bottom: 15px;
  500. }
  501. .no-data {
  502. padding: 20px;
  503. text-align: center;
  504. color: #999;
  505. }
  506. .control-row {
  507. display: flex;
  508. width: 100%;
  509. gap: 20px;
  510. }
  511. /* 调整文本域高度 */
  512. .reason-input-wrapper :deep(.el-textarea__inner) {
  513. height: 100% !important;
  514. min-height: 100px;
  515. }
  516. /* 响应式调整 */
  517. @media (max-width: 992px) {
  518. .control-row {
  519. flex-direction: column;
  520. gap: 15px;
  521. }
  522. .control-group {
  523. width: 100%;
  524. }
  525. }
  526. .selection-area {
  527. display: flex;
  528. }
  529. .control-group {
  530. flex: 1;
  531. min-width: 0;
  532. display: flex;
  533. flex-direction: column;
  534. }
  535. .control-title {
  536. display: block;
  537. margin-bottom: 8px;
  538. font-weight: bold;
  539. color: #606266;
  540. }
  541. .person-selector,
  542. .reason-input-wrapper {
  543. flex: 1;
  544. min-height: 100px;
  545. }
  546. .required-star {
  547. color: #ff4d4f;
  548. margin-left: 3px;
  549. vertical-align: middle;
  550. }
  551. </style>