index.vue 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857
  1. <template>
  2. <div class="page-container">
  3. <!-- 搜索区域 -->
  4. <el-card class="search-card">
  5. <el-form :model="query" inline>
  6. <el-form-item label="设备编号">
  7. <el-input v-model="query.deviceCode" placeholder="请输入设备编号" clearable style="width:160px" />
  8. </el-form-item>
  9. <el-form-item label="设备名称">
  10. <el-input v-model="query.deviceName" placeholder="请输入设备名称" clearable style="width:160px" />
  11. </el-form-item>
  12. <el-form-item label="关联用户/成员">
  13. <el-input v-model="query.boundUserName" placeholder="姓名模糊搜索" clearable style="width:160px" />
  14. </el-form-item>
  15. <el-form-item label="设备类型">
  16. <el-select v-model="query.deviceType" placeholder="全部" clearable style="width:120px">
  17. <el-option label="家庭设备" :value="1" />
  18. <el-option label="公共设备" :value="2" />
  19. </el-select>
  20. </el-form-item>
  21. <el-form-item label="在线状态">
  22. <el-select v-model="query.onlineStatus" placeholder="全部" clearable style="width:110px">
  23. <el-option label="在线" :value="1" />
  24. <el-option label="离线" :value="0" />
  25. </el-select>
  26. </el-form-item>
  27. <el-form-item label="固件版本">
  28. <el-select v-model="query.firmwareVersion" placeholder="全部" clearable style="width:130px">
  29. <el-option label="v1.2.0" value="v1.2.0" />
  30. <el-option label="v1.1.0" value="v1.1.0" />
  31. <el-option label="v1.0.0" value="v1.0.0" />
  32. </el-select>
  33. </el-form-item>
  34. <el-form-item label="省">
  35. <el-select-v2
  36. v-model="query.provinceCode"
  37. :options="provinceList"
  38. placeholder="全部"
  39. clearable
  40. filterable
  41. style="width:160px"
  42. @change="onQueryProvinceChange"
  43. />
  44. </el-form-item>
  45. <el-form-item label="市">
  46. <el-select-v2
  47. v-model="query.cityCode"
  48. :options="queryCityList"
  49. placeholder="全部"
  50. clearable
  51. filterable
  52. style="width:200px"
  53. @change="onQueryCityChange"
  54. />
  55. </el-form-item>
  56. <el-form-item label="区/县">
  57. <el-select-v2
  58. v-model="query.districtCode"
  59. :options="queryDistrictList"
  60. placeholder="全部"
  61. clearable
  62. filterable
  63. style="width:240px"
  64. />
  65. </el-form-item>
  66. <el-form-item>
  67. <el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
  68. <el-button :icon="Refresh" @click="handleReset">重置</el-button>
  69. </el-form-item>
  70. </el-form>
  71. </el-card>
  72. <!-- 操作按钮区 -->
  73. <el-card class="action-card">
  74. <div class="action-bar">
  75. <div class="action-left">
  76. <ExcelImport :on-import="handleImport" button-text="导入" @success="onImportSuccess" />
  77. <ExcelExport
  78. :headers="exportHeaders"
  79. :data="tableData"
  80. file-name="设备列表"
  81. />
  82. <el-button type="primary" :icon="Plus" @click="handleAdd">新增</el-button>
  83. <el-button
  84. type="danger"
  85. :icon="Delete"
  86. :disabled="selectedIds.length === 0"
  87. @click="handleBatchDelete"
  88. >批量删除</el-button>
  89. </div>
  90. </div>
  91. </el-card>
  92. <!-- 表格区域 -->
  93. <el-card class="table-card">
  94. <el-table
  95. :data="tableData"
  96. v-loading="loading"
  97. border
  98. stripe
  99. @selection-change="handleSelectionChange"
  100. >
  101. <el-table-column type="selection" width="50" align="center" />
  102. <el-table-column type="index" label="序号" width="60" align="center" />
  103. <el-table-column prop="deviceCode" label="设备编号" min-width="110" />
  104. <el-table-column prop="deviceName" label="设备名称" min-width="120" />
  105. <el-table-column prop="deviceModel" label="设备型号" width="90">
  106. <template #default="{ row }">{{ row.deviceModel || '-' }}</template>
  107. </el-table-column>
  108. <el-table-column label="设备类型" width="95" align="center">
  109. <template #default="{ row }">
  110. <el-tag :type="row.deviceType === 2 ? 'warning' : 'success'" size="small">
  111. {{ formatDeviceType(row.deviceType) }}
  112. </el-tag>
  113. </template>
  114. </el-table-column>
  115. <el-table-column label="关联人数" width="95" align="center">
  116. <template #default="{ row }">
  117. <el-tag type="info" size="small">{{ row.boundUserCount || 0 }} 人</el-tag>
  118. </template>
  119. </el-table-column>
  120. <el-table-column prop="boundUserNames" label="关联用户/成员" min-width="130">
  121. <template #default="{ row }">{{ formatBoundUserNames(row) }}</template>
  122. </el-table-column>
  123. <el-table-column label="在线状态" width="90" align="center">
  124. <template #default="{ row }">
  125. <el-tag :type="row.onlineStatus === 1 ? 'success' : 'info'" size="small">
  126. {{ row.onlineStatus === 1 ? '在线' : '离线' }}
  127. </el-tag>
  128. </template>
  129. </el-table-column>
  130. <el-table-column prop="firmwareVersion" label="固件版本" width="95">
  131. <template #default="{ row }">{{ row.firmwareVersion || '-' }}</template>
  132. </el-table-column>
  133. <el-table-column label="所在地区" min-width="160">
  134. <template #default="{ row }">{{ formatRegion(row) }}</template>
  135. </el-table-column>
  136. <el-table-column prop="lastOnlineTime" label="最后在线时间" width="170">
  137. <template #default="{ row }">{{ formatDateTime(row.lastOnlineTime) }}</template>
  138. </el-table-column>
  139. <el-table-column label="操作" width="170" align="center" fixed="right">
  140. <template #default="{ row }">
  141. <el-button size="small" type="primary" link @click="handleView(row)">查看</el-button>
  142. <el-button size="small" type="warning" link @click="handleEdit(row)">编辑</el-button>
  143. <el-button size="small" type="success" link @click="handleManageUsers(row)">
  144. {{ manageUsersText(row) }}
  145. </el-button>
  146. <el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button>
  147. </template>
  148. </el-table-column>
  149. </el-table>
  150. <!-- 分页 -->
  151. <el-pagination
  152. v-model:current-page="pageNum"
  153. v-model:page-size="pageSize"
  154. :page-sizes="[10, 20, 50]"
  155. :total="total"
  156. :hide-on-single-page="false"
  157. layout="total, sizes, prev, pager, next, jumper"
  158. background
  159. class="pagination"
  160. @size-change="loadData"
  161. @current-change="loadData"
  162. />
  163. </el-card>
  164. <!-- 新增/编辑弹窗 -->
  165. <el-dialog
  166. v-model="formDialogVisible"
  167. :title="formMode === 'add' ? '新增设备' : (formMode === 'view' ? '查看设备' : '编辑设备')"
  168. width="580px"
  169. destroy-on-close
  170. :close-on-click-modal="false"
  171. >
  172. <el-form
  173. :model="deviceForm"
  174. :rules="formMode !== 'view' ? deviceRules : {}"
  175. ref="deviceFormRef"
  176. label-width="100px"
  177. >
  178. <el-form-item label="设备编号" prop="deviceCode">
  179. <el-input
  180. v-model="deviceForm.deviceCode"
  181. placeholder="请输入设备编号(如:AJY001)"
  182. maxlength="50"
  183. :disabled="formMode !== 'add'"
  184. />
  185. </el-form-item>
  186. <el-form-item label="设备名称" prop="deviceName">
  187. <el-input
  188. v-model="deviceForm.deviceName"
  189. placeholder="请输入设备名称"
  190. maxlength="100"
  191. :disabled="formMode === 'view'"
  192. />
  193. </el-form-item>
  194. <el-form-item label="设备型号" prop="deviceModel">
  195. <el-select
  196. v-model="deviceForm.deviceModel"
  197. placeholder="请选择设备型号"
  198. style="width:100%"
  199. :disabled="formMode === 'view'"
  200. >
  201. <el-option label="AJY-2026" value="AJY-2026" />
  202. <el-option label="AJY-2025" value="AJY-2025" />
  203. </el-select>
  204. </el-form-item>
  205. <el-form-item label="设备类型" prop="deviceType">
  206. <el-select
  207. v-model="deviceForm.deviceType"
  208. placeholder="请选择设备类型"
  209. style="width:100%"
  210. :disabled="isDeviceTypeDisabled"
  211. >
  212. <el-option label="家庭设备" :value="1" />
  213. <el-option label="公共设备" :value="2" />
  214. </el-select>
  215. <div v-if="deviceTypeDisabledTip" class="form-tip">{{ deviceTypeDisabledTip }}</div>
  216. </el-form-item>
  217. <el-form-item label="设备序列号" prop="serialNo">
  218. <el-input
  219. v-model="deviceForm.serialNo"
  220. placeholder="请输入设备序列号"
  221. maxlength="50"
  222. :disabled="formMode === 'view'"
  223. />
  224. </el-form-item>
  225. <el-form-item label="固件版本" prop="firmwareVersion">
  226. <el-input
  227. v-model="deviceForm.firmwareVersion"
  228. placeholder="请输入固件版本(如:v1.2.0)"
  229. maxlength="20"
  230. :disabled="formMode === 'view'"
  231. />
  232. </el-form-item>
  233. <el-form-item :label="formUserLabel">
  234. <el-select
  235. v-model="deviceForm.userIds"
  236. multiple
  237. filterable
  238. remote
  239. :remote-method="searchUserOptions"
  240. :loading="userOptionsLoading"
  241. :placeholder="formUserPlaceholder"
  242. style="width:100%"
  243. :disabled="formMode === 'view'"
  244. >
  245. <el-option
  246. v-for="u in userOptions"
  247. :key="u.id"
  248. :label="`${u.name}(${u.phone || '-'})`"
  249. :value="u.id"
  250. />
  251. </el-select>
  252. </el-form-item>
  253. <el-form-item label="所在地区">
  254. <el-cascader
  255. v-model="deviceForm.regionCodes"
  256. :options="regionOptions"
  257. :props="regionCascaderProps"
  258. placeholder="请选择省/市/区"
  259. clearable
  260. filterable
  261. style="width:100%"
  262. :disabled="formMode === 'view'"
  263. />
  264. </el-form-item>
  265. <el-form-item label="详细地址">
  266. <el-input
  267. v-model="deviceForm.address"
  268. placeholder="请输入详细地址(选填)"
  269. maxlength="200"
  270. :disabled="formMode === 'view'"
  271. />
  272. </el-form-item>
  273. <el-form-item label="备注">
  274. <el-input
  275. v-model="deviceForm.remark"
  276. type="textarea"
  277. :rows="3"
  278. placeholder="请输入备注(选填)"
  279. maxlength="200"
  280. show-word-limit
  281. :disabled="formMode === 'view'"
  282. />
  283. </el-form-item>
  284. </el-form>
  285. <template #footer>
  286. <el-button @click="formDialogVisible = false">{{ formMode === 'view' ? '关闭' : '取消' }}</el-button>
  287. <el-button
  288. v-if="formMode !== 'view'"
  289. type="primary"
  290. :loading="submitLoading"
  291. @click="submitForm"
  292. >保存</el-button>
  293. </template>
  294. </el-dialog>
  295. </div>
  296. </template>
  297. <script setup>
  298. import { ref, reactive, onMounted, computed } from 'vue'
  299. import { useRouter } from 'vue-router'
  300. import { ElMessage, ElMessageBox } from 'element-plus'
  301. import { Search, Refresh, Plus, Delete } from '@element-plus/icons-vue'
  302. import { regionData, codeToText } from 'element-china-area-data'
  303. import ExcelImport from '@/components/ExcelImport.vue'
  304. import ExcelExport from '@/components/ExcelExport.vue'
  305. import { formatDateTime } from '@/utils/date'
  306. import { parsePageData } from '@/utils/pagination'
  307. import {
  308. getDevicePageList,
  309. addDevice,
  310. updateDevice,
  311. deleteDevice,
  312. batchDeleteDevice,
  313. batchImportDevice,
  314. getUserProfileOptions,
  315. getDeviceUsers
  316. } from '@/api/device'
  317. const router = useRouter()
  318. // ======================== 列表数据 ========================
  319. const tableData = ref([])
  320. const total = ref(0)
  321. const loading = ref(false)
  322. const selectedIds = ref([])
  323. const pageNum = ref(1)
  324. const pageSize = ref(10)
  325. const BOUND_USER_DISPLAY_LIMIT = 3
  326. /** 搜索条件 */
  327. const query = reactive({
  328. deviceCode: '',
  329. deviceName: '',
  330. boundUserName: '',
  331. deviceType: null,
  332. onlineStatus: null,
  333. firmwareVersion: '',
  334. provinceCode: '',
  335. cityCode: '',
  336. districtCode: ''
  337. })
  338. // ======================== 省市区数据 ========================
  339. const regionOptions = regionData
  340. const regionCascaderProps = { value: 'value', label: 'label', children: 'children' }
  341. // 查询区域:省列表
  342. const provinceList = computed(() => regionData.map(p => ({ value: p.value, label: p.label })))
  343. // 预计算"全国所有市"与"全国所有区"(模块级常量,仅计算一次)
  344. const ALL_CITIES = []
  345. const ALL_DISTRICTS = []
  346. regionData.forEach(p => {
  347. ;(p.children || []).forEach(c => {
  348. ALL_CITIES.push({ value: c.value, label: `${p.label} / ${c.label}` })
  349. ;(c.children || []).forEach(d => {
  350. ALL_DISTRICTS.push({ value: d.value, label: `${p.label} / ${c.label} / ${d.label}` })
  351. })
  352. })
  353. })
  354. /** 市列表:选了省则返回该省市;未选省则返回全国所有市 */
  355. const queryCityList = computed(() => {
  356. if (query.provinceCode) {
  357. const prov = regionData.find(p => p.value === query.provinceCode)
  358. return (prov?.children || []).map(c => ({ value: c.value, label: c.label }))
  359. }
  360. return ALL_CITIES
  361. })
  362. /** 区/县列表:选了市则返回该市下区;只选省返回该省下区;都未选返回全国区 */
  363. const queryDistrictList = computed(() => {
  364. if (query.cityCode) {
  365. for (const p of regionData) {
  366. const city = (p.children || []).find(c => c.value === query.cityCode)
  367. if (city) {
  368. return (city.children || []).map(d => ({ value: d.value, label: d.label }))
  369. }
  370. }
  371. return []
  372. }
  373. if (query.provinceCode) {
  374. const prov = regionData.find(p => p.value === query.provinceCode)
  375. const all = []
  376. ;(prov?.children || []).forEach(c => {
  377. (c.children || []).forEach(d => {
  378. all.push({ value: d.value, label: `${c.label} / ${d.label}` })
  379. })
  380. })
  381. return all
  382. }
  383. return ALL_DISTRICTS
  384. })
  385. /** 选了省:如果已选的市/区不属于该省则清空 */
  386. function onQueryProvinceChange(val) {
  387. if (!val) return
  388. const prov = regionData.find(p => p.value === val)
  389. const cities = prov?.children || []
  390. if (query.cityCode && !cities.find(c => c.value === query.cityCode)) {
  391. query.cityCode = ''
  392. query.districtCode = ''
  393. } else if (query.districtCode) {
  394. const city = cities.find(c => c.value === query.cityCode)
  395. if (city && !(city.children || []).find(d => d.value === query.districtCode)) {
  396. query.districtCode = ''
  397. }
  398. }
  399. }
  400. /** 选了市:如果已选的区不属于该市则清空 */
  401. function onQueryCityChange(val) {
  402. if (!val) return
  403. for (const p of regionData) {
  404. const city = (p.children || []).find(c => c.value === val)
  405. if (city) {
  406. if (query.districtCode && !(city.children || []).find(d => d.value === query.districtCode)) {
  407. query.districtCode = ''
  408. }
  409. return
  410. }
  411. }
  412. }
  413. /** 根据行政区划编码还原为 "省/市/区" 文本 */
  414. function formatRegion(row) {
  415. const parts = [row.provinceCode, row.cityCode, row.districtCode]
  416. .filter(c => !!c)
  417. .map(c => codeToText[c] || '')
  418. .filter(t => !!t)
  419. return parts.length > 0 ? parts.join(' / ') : '-'
  420. }
  421. /**
  422. * 设备列表只展示最新绑定的前 3 个用户,更多用户用省略号提示。
  423. * boundUserNames 由后端按绑定时间倒序拼接。
  424. */
  425. function formatBoundUserNames(row) {
  426. const names = String(row.boundUserNames || '')
  427. .split('、')
  428. .map(name => name.trim())
  429. .filter(Boolean)
  430. if (names.length === 0) {
  431. return '-'
  432. }
  433. const total = Number(row.boundUserCount) || names.length
  434. const displayText = names.slice(0, BOUND_USER_DISPLAY_LIMIT).join('、')
  435. return total > BOUND_USER_DISPLAY_LIMIT || names.length > BOUND_USER_DISPLAY_LIMIT
  436. ? `${displayText}...`
  437. : displayText
  438. }
  439. function formatDeviceType(deviceType) {
  440. return Number(deviceType) === 2 ? '公共设备' : '家庭设备'
  441. }
  442. function manageUsersText(row) {
  443. return Number(row.deviceType) === 2 ? '绑定用户' : '成员管理'
  444. }
  445. /** 导出表头配置 */
  446. const exportHeaders = [
  447. { label: '设备编号', prop: 'deviceCode' },
  448. { label: '设备名称', prop: 'deviceName' },
  449. { label: '设备型号', prop: 'deviceModel' },
  450. { label: '设备类型', prop: 'deviceTypeLabel' },
  451. { label: '所在地区', prop: 'regionText' },
  452. { label: '关联人数', prop: 'boundUserCount' },
  453. { label: '关联用户/成员', prop: 'boundUserNames' },
  454. { label: '在线状态', prop: 'onlineStatusLabel' },
  455. { label: '固件版本', prop: 'firmwareVersion' },
  456. { label: '最后在线时间', prop: 'lastOnlineTime' }
  457. ]
  458. /**
  459. * 加载设备列表数据
  460. */
  461. async function loadData() {
  462. loading.value = true
  463. try {
  464. const res = await getDevicePageList({
  465. ...query,
  466. pageNum: pageNum.value,
  467. pageSize: pageSize.value
  468. })
  469. const { records, total: pageTotal } = parsePageData(res.data)
  470. // 为导出添加状态标签字段和地区文本
  471. tableData.value = records.map(r => ({
  472. ...r,
  473. deviceTypeLabel: formatDeviceType(r.deviceType),
  474. onlineStatusLabel: r.onlineStatus === 1 ? '在线' : '离线',
  475. regionText: formatRegion(r)
  476. }))
  477. total.value = pageTotal
  478. } finally {
  479. loading.value = false
  480. }
  481. }
  482. /**
  483. * 点击查询
  484. */
  485. function handleSearch() {
  486. pageNum.value = 1
  487. loadData()
  488. }
  489. /**
  490. * 重置查询条件
  491. */
  492. function handleReset() {
  493. Object.assign(query, {
  494. deviceCode: '',
  495. deviceName: '',
  496. boundUserName: '',
  497. deviceType: null,
  498. onlineStatus: null,
  499. firmwareVersion: '',
  500. provinceCode: '',
  501. cityCode: '',
  502. districtCode: ''
  503. })
  504. pageNum.value = 1
  505. loadData()
  506. }
  507. /**
  508. * 多选变化
  509. */
  510. function handleSelectionChange(selection) {
  511. selectedIds.value = selection.map(r => r.id)
  512. }
  513. // ======================== 新增/编辑表单 ========================
  514. const formDialogVisible = ref(false)
  515. const submitLoading = ref(false)
  516. const deviceFormRef = ref(null)
  517. const formMode = ref('add') // 'add' | 'edit' | 'view'
  518. const currentBoundUserCount = ref(0)
  519. const deviceForm = reactive({
  520. id: null,
  521. deviceCode: '',
  522. deviceName: '',
  523. deviceModel: '',
  524. deviceType: 1,
  525. serialNo: '',
  526. firmwareVersion: '',
  527. userIds: [],
  528. regionCodes: [],
  529. address: '',
  530. remark: ''
  531. })
  532. const formIsFamilyDevice = computed(() => Number(deviceForm.deviceType || 1) !== 2)
  533. const formUserLabel = computed(() => formIsFamilyDevice.value ? '家庭成员' : '绑定用户')
  534. const formUserPlaceholder = computed(() => formIsFamilyDevice.value ? '可搜索并选择家庭成员(选填)' : '可搜索并选择绑定用户(选填)')
  535. const isDeviceTypeDisabled = computed(() => formMode.value === 'view' || (formMode.value === 'edit' && currentBoundUserCount.value > 0))
  536. const deviceTypeDisabledTip = computed(() => {
  537. if (formMode.value === 'edit' && currentBoundUserCount.value > 0) {
  538. return '已有成员或绑定用户的设备不允许直接修改设备类型'
  539. }
  540. return ''
  541. })
  542. const deviceRules = {
  543. deviceCode: [
  544. { required: true, message: '请输入设备编号', trigger: 'blur' },
  545. { max: 50, message: '设备编号不超过50个字符', trigger: 'blur' }
  546. ],
  547. deviceName: [
  548. { required: true, message: '请输入设备名称', trigger: 'blur' },
  549. { max: 100, message: '设备名称不超过100个字符', trigger: 'blur' }
  550. ],
  551. deviceModel: [{ required: true, message: '请选择设备型号', trigger: 'change' }],
  552. deviceType: [{ required: true, message: '请选择设备类型', trigger: 'change' }],
  553. serialNo: [
  554. { required: true, message: '请输入设备序列号', trigger: 'blur' },
  555. { max: 50, message: '序列号不超过50个字符', trigger: 'blur' }
  556. ],
  557. firmwareVersion: [{ required: true, message: '请输入固件版本', trigger: 'blur' }]
  558. }
  559. // 用户选项数据(供关联用户/成员下拉使用)
  560. const userOptions = ref([])
  561. const userOptionsLoading = ref(false)
  562. /**
  563. * 搜索用户选项
  564. * @param {string} keyword 搜索关键字
  565. */
  566. async function searchUserOptions(keyword) {
  567. userOptionsLoading.value = true
  568. try {
  569. const res = await getUserProfileOptions(keyword)
  570. userOptions.value = res.data || []
  571. } finally {
  572. userOptionsLoading.value = false
  573. }
  574. }
  575. /**
  576. * 打开新增对话框
  577. */
  578. async function handleAdd() {
  579. formMode.value = 'add'
  580. currentBoundUserCount.value = 0
  581. Object.assign(deviceForm, {
  582. id: null,
  583. deviceCode: '',
  584. deviceName: '',
  585. deviceModel: '',
  586. deviceType: 1,
  587. serialNo: '',
  588. firmwareVersion: '',
  589. userIds: [],
  590. regionCodes: [],
  591. address: '',
  592. remark: ''
  593. })
  594. // 预加载用户选项
  595. await searchUserOptions('')
  596. formDialogVisible.value = true
  597. }
  598. /**
  599. * 打开查看对话框
  600. * @param {Object} row 设备行数据
  601. */
  602. async function handleView(row) {
  603. formMode.value = 'view'
  604. currentBoundUserCount.value = Number(row.boundUserCount) || 0
  605. // 获取当前关联用户/成员列表,填充 userOptions 和 userIds 以实现回显
  606. const res = await getDeviceUsers(row.id)
  607. const boundUsers = res.data || []
  608. userOptions.value = boundUsers.map(u => ({ id: u.userId, name: u.userName, phone: u.phone }))
  609. Object.assign(deviceForm, {
  610. ...row,
  611. deviceType: row.deviceType || 1,
  612. userIds: boundUsers.map(u => u.userId),
  613. regionCodes: [row.provinceCode, row.cityCode, row.districtCode].filter(Boolean),
  614. address: row.address || ''
  615. })
  616. formDialogVisible.value = true
  617. }
  618. /**
  619. * 打开编辑对话框
  620. * @param {Object} row 设备行数据
  621. */
  622. async function handleEdit(row) {
  623. formMode.value = 'edit'
  624. currentBoundUserCount.value = Number(row.boundUserCount) || 0
  625. // 加载全量用户选项(供重新选择)
  626. await searchUserOptions('')
  627. // 获取当前关联用户/成员列表,回显已选用户
  628. const res = await getDeviceUsers(row.id)
  629. const boundUsers = res.data || []
  630. // 将已关联用户/成员合并进 userOptions,防止 remote 模式下找不到对应选项
  631. boundUsers.forEach(u => {
  632. if (!userOptions.value.find(o => o.id === u.userId)) {
  633. userOptions.value.push({ id: u.userId, name: u.userName, phone: u.phone })
  634. }
  635. })
  636. Object.assign(deviceForm, {
  637. id: row.id,
  638. deviceCode: row.deviceCode,
  639. deviceName: row.deviceName,
  640. deviceModel: row.deviceModel || '',
  641. deviceType: row.deviceType || 1,
  642. serialNo: row.serialNo || '',
  643. firmwareVersion: row.firmwareVersion || '',
  644. remark: row.remark || '',
  645. userIds: boundUsers.map(u => u.userId),
  646. regionCodes: [row.provinceCode, row.cityCode, row.districtCode].filter(Boolean),
  647. address: row.address || ''
  648. })
  649. formDialogVisible.value = true
  650. }
  651. /**
  652. * 提交表单(新增或编辑)
  653. */
  654. async function submitForm() {
  655. await deviceFormRef.value.validate()
  656. submitLoading.value = true
  657. try {
  658. const codes = deviceForm.regionCodes || []
  659. const payload = {
  660. id: deviceForm.id,
  661. deviceCode: deviceForm.deviceCode.replace(/\s+/g, ''),
  662. deviceName: deviceForm.deviceName,
  663. deviceModel: deviceForm.deviceModel,
  664. deviceType: deviceForm.deviceType || 1,
  665. serialNo: deviceForm.serialNo,
  666. firmwareVersion: deviceForm.firmwareVersion,
  667. provinceCode: codes[0] || '',
  668. cityCode: codes[1] || '',
  669. districtCode: codes[2] || '',
  670. address: deviceForm.address || undefined,
  671. remark: deviceForm.remark || undefined,
  672. userIds: deviceForm.userIds || []
  673. }
  674. if (formMode.value === 'add') {
  675. await addDevice(payload)
  676. ElMessage.success('新增成功')
  677. } else {
  678. await updateDevice(payload)
  679. ElMessage.success('修改成功')
  680. }
  681. formDialogVisible.value = false
  682. loadData()
  683. } finally {
  684. submitLoading.value = false
  685. }
  686. }
  687. // ======================== 删除 ========================
  688. /**
  689. * 删除单条设备
  690. * @param {Object} row 设备行数据
  691. */
  692. async function handleDelete(row) {
  693. await ElMessageBox.confirm(
  694. `确认删除设备「${row.deviceName}」吗?删除后将同时移除或解绑该设备的所有关联用户。`,
  695. '删除确认',
  696. { type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' }
  697. )
  698. await deleteDevice(row.id)
  699. ElMessage.success('删除成功')
  700. loadData()
  701. }
  702. /**
  703. * 批量删除设备(需二次确认)
  704. */
  705. async function handleBatchDelete() {
  706. await ElMessageBox.confirm(
  707. `确认批量删除选中的 ${selectedIds.value.length} 台设备吗?删除后将同时移除或解绑这些设备的所有关联用户。`,
  708. '批量删除确认',
  709. { type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' }
  710. )
  711. await batchDeleteDevice(selectedIds.value)
  712. ElMessage.success('批量删除成功')
  713. selectedIds.value = []
  714. loadData()
  715. }
  716. // ======================== 导入导出 ========================
  717. /**
  718. * 处理 Excel 导入数据
  719. * @param {Object[]} data 解析后的数据列表
  720. */
  721. async function handleImport(data) {
  722. const list = data.map(row => ({
  723. deviceCode: (row['设备编号'] || row.deviceCode || '').replace(/\s+/g, ''),
  724. deviceName: row['设备名称'] || row.deviceName,
  725. deviceModel: row['设备型号'] || row.deviceModel,
  726. deviceType: parseDeviceType(row['设备类型'] || row.deviceType),
  727. serialNo: row['设备序列号'] || row.serialNo,
  728. firmwareVersion: row['固件版本'] || row.firmwareVersion,
  729. remark: row['备注'] || row.remark
  730. }))
  731. await batchImportDevice(list)
  732. }
  733. function parseDeviceType(value) {
  734. if (value === 2 || value === '2' || value === '公共设备') {
  735. return 2
  736. }
  737. return 1
  738. }
  739. /**
  740. * 导入成功回调
  741. */
  742. function onImportSuccess() {
  743. ElMessage.success('导入成功')
  744. loadData()
  745. }
  746. // ======================== 用户管理跳转 ========================
  747. /**
  748. * 跳转到设备用户管理页面
  749. * @param {Object} row 设备行数据
  750. */
  751. function handleManageUsers(row) {
  752. router.push({
  753. path: '/device/users',
  754. query: {
  755. deviceId: row.id,
  756. deviceCode: row.deviceCode,
  757. deviceName: row.deviceName,
  758. deviceType: row.deviceType || 1
  759. }
  760. })
  761. }
  762. onMounted(loadData)
  763. </script>
  764. <style lang="scss" scoped>
  765. .page-container {
  766. display: flex;
  767. flex-direction: column;
  768. gap: 16px;
  769. }
  770. .search-card {
  771. :deep(.el-card__body) {
  772. padding: 18px 20px 4px;
  773. }
  774. }
  775. .form-tip {
  776. margin-top: 4px;
  777. color: #909399;
  778. font-size: 12px;
  779. line-height: 1.4;
  780. }
  781. .action-card {
  782. :deep(.el-card__body) {
  783. padding: 14px 20px;
  784. }
  785. .action-bar {
  786. display: flex;
  787. align-items: center;
  788. justify-content: space-between;
  789. .action-left {
  790. display: flex;
  791. align-items: center;
  792. gap: 8px;
  793. }
  794. }
  795. }
  796. .table-card {
  797. flex: 1;
  798. }
  799. .pagination {
  800. margin-top: 16px;
  801. justify-content: flex-end;
  802. }
  803. </style>