index.vue 29 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118
  1. <template>
  2. <view class="container">
  3. <!-- 搜索区域 -->
  4. <view class="search-section">
  5. <view class="search-box" :class="{ 'search-focus': isSearchFocused }">
  6. <view class="search-icon">
  7. <!-- <text class="iconfont icon-search"></text> -->
  8. <image src="@/static/icons/search.png" style="width: 40rpx; height: 40rpx; padding-right: 10rpx;"
  9. mode="widthFix" />
  10. </view>
  11. <input
  12. class="search-input"
  13. type="text"
  14. v-model="searchKey"
  15. placeholder="搜索设备名称 / 编号"
  16. confirm-type="search"
  17. @confirm="onSearch"
  18. @focus="isSearchFocused = true"
  19. @blur="isSearchFocused = false"
  20. />
  21. <view class="clear-icon" v-if="searchKey" @click="clearSearch">
  22. <text class="iconfont icon-close"></text>
  23. </view>
  24. </view>
  25. </view>
  26. <!-- 状态筛选区域 -->
  27. <view class="filter-section">
  28. <view
  29. class="filter-item"
  30. :class="{ active: currentStatus === -1 }"
  31. @click="selectStatus(-1)"
  32. >
  33. 全部
  34. </view>
  35. <view
  36. class="filter-item"
  37. :class="{ active: currentStatus === 1 }"
  38. @click="selectStatus(1)"
  39. >
  40. <view class="filter-dot online-dot"></view>
  41. 在线 ({{ onlineDevices }})
  42. </view>
  43. <view
  44. class="filter-item"
  45. :class="{ active: currentStatus === 0 }"
  46. @click="selectStatus(0)"
  47. >
  48. <view class="filter-dot offline-dot"></view>
  49. 离线 ({{ offlineDevices }})
  50. </view>
  51. <!-- <view
  52. class="filter-item"
  53. :class="{ active: currentStatus === 2 }"
  54. @click="selectStatus(2)"
  55. >
  56. <view class="filter-dot fault-dot"></view>
  57. 故障 ({{ getStatusCount(2) }})
  58. </view> -->
  59. </view>
  60. <!-- 设备列表区域 -->
  61. <scroll-view
  62. scroll-y
  63. class="device-list"
  64. @scrolltolower="onReachBottom"
  65. :scroll-with-animation="true"
  66. :enable-back-to-top="true"
  67. :refresher-enabled="true"
  68. :refresher-threshold="80"
  69. :refresher-triggered="isRefreshing"
  70. @refresherrefresh="handleRefresh"
  71. @refresherrestore="isRefreshing = false"
  72. >
  73. <!-- 无数据提示 -->
  74. <view v-if="deviceList.length === 0 && !loading" class="empty-tips">
  75. <view class="empty-icon">
  76. <text class="iconfont icon-empty"></text>
  77. </view>
  78. <text class="empty-text">{{ searchKey ? '未找到匹配的设备' : '暂无设备' }}</text>
  79. <view v-if="searchKey" class="empty-action" @click="clearSearch">
  80. <text>清除搜索条件</text>
  81. </view>
  82. </view>
  83. <!-- 首次加载中状态 -->
  84. <view v-if="loading && deviceList.length === 0" class="loading-container">
  85. <view class="loading-spinner"></view>
  86. <text class="loading-text">加载中...</text>
  87. </view>
  88. <!-- 设备列表 -->
  89. <view
  90. v-for="(item, index) in deviceList"
  91. :key="index"
  92. class="device-card"
  93. :class="{
  94. 'has-alert': item.hasAlert,
  95. 'is-offline': item.status === 0
  96. }"
  97. hover-class="device-card-hover"
  98. hover-stay-time="70"
  99. @click="navigateToDeviceDetail(item)"
  100. >
  101. <!-- 设备基本信息 -->
  102. <view class="device-info">
  103. <view class="device-icon-wrapper">
  104. <!-- 告警标记 -->
  105. <view v-if="item.hasAlert" class="alarm-badge">
  106. 1
  107. </view>
  108. <view class="device-icon-container" :class="{'offline-icon': item.status === 0}">
  109. <image :src="getDeviceTypeIcon(item.deviceTypeId)" mode="aspectFit" class="device-icon"></image>
  110. </view>
  111. </view>
  112. <view class="device-meta">
  113. <view class="device-name-row">
  114. <text class="device-name" :class="{'offline-text': item.status === 0}">{{ item.deviceName }}</text>
  115. <view
  116. class="status-tag"
  117. :class="{
  118. 'status-online': item.status === 1,
  119. 'status-offline': item.status === 0,
  120. 'status-fault': item.status === 2,
  121. 'status-maintain': item.status === 3
  122. }"
  123. >
  124. <text class="status-dot" :class="{
  125. 'offline-dot': item.status === 0,
  126. 'fault-dot': item.status === 2,
  127. 'maintain-dot': item.status === 3
  128. }"></text>
  129. {{ getStatusText(item.status) }}
  130. </view>
  131. </view>
  132. <view class="device-id">
  133. <text class="id-label">设备编号:</text>
  134. <text class="id-value">{{ item.deviceId }}</text>
  135. </view>
  136. <view class="device-location">
  137. <text class="location-label">安装位置:</text>
  138. <text class="location-value">{{ item.fieldName || '未指定位置' }}</text>
  139. </view>
  140. </view>
  141. </view>
  142. <!-- 底部信息栏 -->
  143. <view class="device-footer">
  144. <text class="update-time">最后活跃: {{ formatDate(item.lastActiveTime) }}</text>
  145. </view>
  146. </view>
  147. <!-- 加载更多提示 -->
  148. <view v-if="deviceList.length > 0" class="load-more">
  149. <view class="load-more-content" v-if="loadMoreStatus === 'loading'">
  150. <view class="loading-icon"></view>
  151. <text>正在加载...</text>
  152. </view>
  153. <view class="load-more-content" v-if="loadMoreStatus === 'noMore'">
  154. <text>没有更多了</text>
  155. </view>
  156. <view class="load-more-content" v-if="loadMoreStatus === 'more'" @click="onReachBottom">
  157. <text>点击加载更多</text>
  158. </view>
  159. </view>
  160. </scroll-view>
  161. </view>
  162. </template>
  163. <script setup>
  164. import { ref, reactive, computed } from 'vue'
  165. import { onLoad} from '@dcloudio/uni-app'
  166. import { fetchDevicesByType } from "@/api/services/device.js";
  167. import { machinesDeviceList } from "@/api/services/agriculturalMachines.js";
  168. import storage from "@/utils/storage.js";
  169. // 响应式数据
  170. const deviceType = ref('') // monitor, sensor, control, irrigation, tractor
  171. const deviceTypeName = ref('')
  172. const deviceList = ref([])
  173. const searchKey = ref('')
  174. const isSearchFocused = ref(false)
  175. const currentStatus = ref(-1) // -1代表全部
  176. const pageNum = ref(1)
  177. const pageSize = ref(10)
  178. const total = ref(0)
  179. const loading = ref(false)
  180. const isRefreshing = ref(false)
  181. const loadMoreStatus = ref('more') // 加载更多状态: more-加载更多 loading-加载中 noMore-没有更多了
  182. const deviceTypeMap = reactive({
  183. 'monitor': { name: '监控设备', icon: '/static/icons/camera.png', class: 'type-monitor' },
  184. 'sensor': { name: '采集设备', icon: '/static/icons/sensor.png', class: 'type-sensor' },
  185. 'control': { name: '控制设备', icon: '/static/icons/control.png', class: 'type-control' },
  186. 'irrigation': { name: '灌溉设备', icon: '/static/icons/water.png', class: 'type-irrigation' },
  187. 'tractor': { name: '农机设备', icon: '/static/icons/tractor.png', class: 'type-tractor' }
  188. })
  189. const currentFieldId = ref(null)
  190. const onlineDevices = ref(0)
  191. const offlineDevices = ref(0)
  192. // 计算属性
  193. // 设备类型对应的样式类
  194. const deviceTypeClass = computed(() => {
  195. return deviceTypeMap[deviceType.value]?.class || ''
  196. })
  197. // uni-app 生命周期 - onLoad
  198. onLoad((options) => {
  199. // 获取传递的设备类型
  200. console.log("options类型:", options);
  201. const { type, typeOnline, typeOffline } = options;
  202. // 处理传递过来的统计数量
  203. offlineDevices.value = typeOffline
  204. onlineDevices.value = typeOnline
  205. if (type && deviceTypeMap[type]) {
  206. deviceType.value = type;
  207. deviceTypeName.value = deviceTypeMap[type].name;
  208. }
  209. // 获取当前地块ID
  210. initFieldInfo();
  211. // 加载设备列表
  212. deviceType.value === 'tractor' ? loadMachinesList() : loadDeviceList();
  213. })
  214. // 方法定义
  215. const loadMachinesList = () => {
  216. machinesDeviceList({
  217. pageNum: pageNum.value,
  218. pageSize: pageSize.value,
  219. }).then(res => {
  220. console.log("res收到发斯蒂芬斯蒂",res);
  221. res.data.code === 200 && res.data.rows && (deviceList.value = res.data.rows);
  222. res.data.code !== 200 && handleApiError(res);
  223. }).catch(error => {
  224. console.error('获取农机设备列表失败', error);
  225. uni.showToast({
  226. title: '获取农机设备列表失败',
  227. icon: 'none'
  228. });
  229. }).finally(() => {
  230. loading.value = false;
  231. });
  232. }
  233. // 初始化地块信息
  234. const initFieldInfo = () => {
  235. const currentPlots = JSON.parse(storage.getPlots() || '{}');
  236. if (currentPlots) {
  237. currentFieldId.value = currentPlots.id;
  238. }
  239. }
  240. // 获取特定状态的设备数量
  241. const getStatusCount = (status) => {
  242. return deviceList.value.filter(device => device.status === status).length;
  243. }
  244. // 加载设备列表
  245. const loadDeviceList = (reset = true) => {
  246. if (loading.value) return;
  247. if (reset) {
  248. pageNum.value = 1;
  249. deviceList.value = [];
  250. }
  251. loading.value = true;
  252. loadMoreStatus.value = 'loading';
  253. // 构建查询参数
  254. const params = {
  255. pageNum: pageNum.value,
  256. pageSize: pageSize.value,
  257. deviceQueryParams: searchKey.value || undefined,
  258. deviceType: deviceType.value || undefined,
  259. fieldId: currentFieldId.value || undefined
  260. };
  261. // 如果状态不是全部,添加状态筛选
  262. if (currentStatus.value !== -1) {
  263. params.status = currentStatus.value;
  264. }
  265. // 调用API获取设备列表
  266. fetchDevicesByType(params)
  267. .then(res => {
  268. console.log("res", res);
  269. if (res.data.code === 200 && res.data.rows) {
  270. const { rows, total: totalCount } = res.data;
  271. // 更新设备列表
  272. if (reset) {
  273. deviceList.value = rows;
  274. } else {
  275. deviceList.value = [...deviceList.value, ...rows];
  276. }
  277. total.value = totalCount;
  278. // 为农技增加模拟数据 后续可删除
  279. if(deviceType.value === 'tractor' ){
  280. const newDevices = generateMockDevices();
  281. console.log("newDevices",newDevices);
  282. deviceList.value = [...deviceList.value, ...newDevices];
  283. total.value = deviceList.value.length
  284. }
  285. // 标记有告警的设备
  286. deviceList.value.forEach(device => {
  287. // 这里可以根据实际情况设置hasAlert属性
  288. device.hasAlert = false; // 示例,实际应该根据后台数据判断
  289. });
  290. // 更新加载更多状态
  291. if (deviceList.value.length >= totalCount) {
  292. loadMoreStatus.value = 'noMore';
  293. } else {
  294. loadMoreStatus.value = 'more';
  295. }
  296. } else {
  297. handleApiError(res);
  298. }
  299. })
  300. .catch(error => {
  301. console.error('获取设备列表失败', error);
  302. uni.showToast({
  303. title: '获取设备列表失败',
  304. icon: 'none'
  305. });
  306. loadMoreStatus.value = 'more';
  307. })
  308. .finally(() => {
  309. loading.value = false;
  310. isRefreshing.value = false;
  311. uni.hideLoading();
  312. });
  313. }
  314. // 处理API错误
  315. const handleApiError = (res) => {
  316. console.error('API错误', res);
  317. uni.showToast({
  318. title: res.msg || '获取数据失败',
  319. icon: 'none'
  320. });
  321. }
  322. // 搜索
  323. const onSearch = () => {
  324. loadDeviceList();
  325. }
  326. // 清除搜索
  327. const clearSearch = () => {
  328. searchKey.value = '';
  329. loadDeviceList();
  330. }
  331. // 选择状态
  332. const selectStatus = (value) => {
  333. currentStatus.value = value;
  334. loadDeviceList();
  335. }
  336. // 处理下拉刷新
  337. const handleRefresh = () => {
  338. isRefreshing.value = true;
  339. loadDeviceList();
  340. }
  341. // 跳转到设备详情页
  342. const navigateToDeviceDetail = (device) => {
  343. console.log("device",device);
  344. // 根据设备类型跳转到不同的详情页
  345. let url = '';
  346. // 先发送事件
  347. uni.$emit('passDeviceData', {
  348. deviceId: device.deviceId,
  349. deviceTypeId: device.deviceTypeId,
  350. fieldName: device.fieldName,
  351. deviceName: device.deviceName
  352. });
  353. if (device.deviceTypeId === '2') {
  354. url = `/pages/device/device-list/detail-camera?id=${device.id}`;
  355. } else if (device.deviceTypeId === '1' || device.deviceTypeId === '4') {
  356. // 采集设备跳转到采集设备详情页,同时传递设备编码,便于判断设备子类型
  357. // url = `/pages/device/device-list/detail-collector?id=${device.deviceId}&deviceTypeId=${device.deviceTypeId}&fieldName=${device.fieldName}&deviceName=${device.deviceName}`;
  358. url = `/pages/device/device-list/detail-collector`;
  359. } else if (device.type === 'tractor') {
  360. // 农机设备跳转到农机设备详情页
  361. url = `/pages/device/device-list/detail-machine?id=${device.id}&deviceId=${device.code}`;
  362. } else {
  363. // 其他类型设备暂时使用通用详情页
  364. url = `/pages/device/device-detail/index?id=${device.id}&type=${device.type}`;
  365. }
  366. // 先跳转
  367. uni.navigateTo({
  368. url: url,
  369. success: () => {
  370. // 跳转成功后再发送事件,延迟一点确保页面onLoad注册完成
  371. setTimeout(() => {
  372. uni.$emit('passDeviceData', {
  373. deviceId: device.deviceId,
  374. deviceTypeId: device.deviceTypeId,
  375. fieldName: device.fieldName,
  376. deviceName: device.deviceName,
  377. status:device.status
  378. });
  379. }, 100); // 100ms 通常足够,必要时可加到 200
  380. },
  381. fail: (e)=>{
  382. console.log(e);
  383. }
  384. });
  385. }
  386. // 获取设备类型图标
  387. const getDeviceTypeIcon = (typeId) => {
  388. // 根据后端设备类型ID获取对应前端类型的图标
  389. const typeMapping = {
  390. '1': 'sensor', // 传感器
  391. '2': 'monitor', // 摄像头
  392. '3': 'control', // 控制器
  393. '4': 'irrigation', // 气象设备/灌溉设备
  394. '5': 'tractor' // 农机设备
  395. };
  396. const frontendType = typeMapping[typeId] || deviceType.value;
  397. return deviceTypeMap[frontendType]?.icon || '/static/icons/device.png';
  398. }
  399. // 获取设备类型名称
  400. const getDeviceTypeName = (typeId) => {
  401. const typeNames = {
  402. '1': '采集设备',
  403. '2': '监控设备',
  404. '3': '控制设备',
  405. '4': '灌溉设备',
  406. '5': '农机设备'
  407. };
  408. return typeNames[typeId] || '未知类型';
  409. }
  410. // 获取状态文本
  411. const getStatusText = (status) => {
  412. const statusMap = {
  413. 0: '离线',
  414. 1: '在线',
  415. 2: '故障',
  416. 3: '维护中'
  417. };
  418. return statusMap[status] || '未知状态';
  419. }
  420. // 格式化日期
  421. const formatDate = (dateStr) => {
  422. if (!dateStr) return '未知';
  423. // 解析为 Date
  424. const parsedStr = dateStr.replace(' ', 'T');
  425. const date = new Date(parsedStr);
  426. if (isNaN(date)) return '无效时间';
  427. const now = new Date();
  428. // 计算差时长(分钟)
  429. const diff = Math.floor((now - date) / 1000 / 60);
  430. if (diff < 1) return '刚刚更新';
  431. if (diff < 5) return '1分钟前更新';
  432. if (diff < 10) return '5分钟前更新';
  433. if (diff < 60) return `${diff}分钟前更新`;
  434. if (diff < 120) return '1小时前更新';
  435. if (diff < 24 * 60) return `${Math.floor(diff / 60)}小时前更新`;
  436. if (diff < 7 * 24 * 60) return `${Math.floor(diff / (60 * 24))}天前更新`;
  437. return parsedStr.split('T')[0] + ' 更新';
  438. }
  439. // 生成模拟设备数据
  440. const generateMockDevices = () => {
  441. const devices = [];
  442. const locations = ['东区A1地块', '西区B2地块', '南区C3地块', '北区D4地块'];
  443. const updateTimes = ['刚刚更新', '1分钟前更新', '5分钟前更新', '10分钟前更新', '1小时前更新'];
  444. // 根据当前页码和限制数量生成对应数量的模拟数据
  445. const startIndex = (pageNum.value - 1) * pageSize.value;
  446. for (let i = 0; i < pageSize.value; i++) {
  447. const index = startIndex + i;
  448. // 如果已经生成了30条数据,则停止
  449. if (index >= 30) break;
  450. // 对于采集设备类型,生成随机的气象或土壤设备
  451. let devType = deviceType.value;
  452. let deviceCode = `DEV${String(index + 1001).padStart(4, '0')}`;
  453. // 如果是采集设备,随机生成气象站或土壤墒情设备
  454. if (deviceType.value === 'sensor') {
  455. // 随机分配采集设备子类型:气象站或土壤墒情
  456. const sensorSubType = Math.random() > 0.5 ? 'weather' : 'soil';
  457. deviceCode = sensorSubType === 'weather' ? `W${deviceCode}` : `S${deviceCode}`;
  458. }
  459. devices.push({
  460. id: `device-${index + 1}`,
  461. deviceName: `${getDeviceTypeName(deviceType.value)}-${index + 1}`,
  462. deviceId: deviceCode,
  463. type: devType,
  464. status: Math.random() > 0.3 ? 'online' : 'offline', // 70% 概率在线
  465. fieldName: locations[Math.floor(Math.random() * locations.length)],
  466. updateTime: updateTimes[Math.floor(Math.random() * updateTimes.length)],
  467. alarmCount: Math.random() > 0.7 ? Math.floor(Math.random() * 3) + 1 : 0 // 30% 概率有告警
  468. });
  469. }
  470. return devices;
  471. }
  472. /* formatDate(dateStr) {
  473. if (!dateStr) return '未知';
  474. const date = new Date(dateStr);
  475. const now = new Date();
  476. // 今天的日期
  477. if (date.toDateString() === now.toDateString()) {
  478. return `今天 ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
  479. }
  480. // 一周内
  481. const days = ['日', '一', '二', '三', '四', '五', '六'];
  482. const dayDiff = Math.floor((now - date) / (24 * 60 * 60 * 1000));
  483. if (dayDiff < 7) {
  484. return `周${days[date.getDay()]} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
  485. }
  486. // 超过一周
  487. return `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`;
  488. }, */
  489. // 页面上拉触底事件
  490. const onReachBottom = () => {
  491. if (loadMoreStatus.value === 'more') {
  492. pageNum.value++;
  493. loadDeviceList(false);
  494. }
  495. }
  496. // uni-app 生命周期 - 下拉刷新
  497. const onPullDownRefresh = () => {
  498. loadDeviceList();
  499. setTimeout(() => {
  500. uni.stopPullDownRefresh();
  501. }, 1000);
  502. }
  503. // uni-app 生命周期 - 页面显示
  504. const onShow = () => {
  505. uni.setNavigationBarTitle({
  506. title: deviceTypeName.value || '设备列表'
  507. });
  508. }
  509. </script>
  510. <style scoped>
  511. /* 图标字体 */
  512. @font-face {
  513. font-family: "iconfont";
  514. src: url('data:font/woff2;charset=utf-8;base64,d09GMgABAAAAAAOMAAsAAAAACFAAAAM+AAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHFQGYACDMgqDGIJmATYCJAMUCwwABCAFhGcHRhvqBsgOJaDgwABgBNFYPPy3H/3OSB4kWdI1Pb2QBw8gSpZIsxBFsTCNTIm//9dv/7FzF1E1S9ysIZWDPNdPJdSYbfLy/+fpTTMwKn5ZZ3OImpRUTZ8FHAcYFrLCBqyAHZD3CP9I09oXUgNcDSEAS8oGR507tO6KhRQTNQGQyeNGD8PCkVATUkFYsMgGcuqQ5YiFWruCPAVYnn9f/KGFBQxrghRq0KjzAXS5TP3SHp3ZmXUyEIfTAYDvDEiQARSQiaJjIjKaZiDJ+hpQYnY1i8tK1ZfWl83/fxYLm8TS//EADBBU6C9ghIBwJXxprxqwhC8d6x9AvAB4BcARgGWgRlWY71Cn5I5XvJ5Ot5sXy91Ud9Oj1nXnfbK9vK09bVsrGq2rrB1sxMcbxZmXntbdI8u6cRGhGy9r92Hj7hCjx1G6Dd9G9N6sbSvwVZiw+/hC0D7WFi5tbcltVyPOdqm41HkQr9T0bKFa2zrLLVeKJufZy7vn7aXtY+3XMvl65fJ7wnHT7ZJWozcvrHC5XNnTGy5/0NvX1xdSu8W9Ml0ePXEqZ3Jm0r6SLSdPbJnc27Mm05YsrLjfI8Ypvb5dL5pAj9m5c3Tzw+UvtK/SzO/Vv+Dt/9Lfq3+Xr30Hf9pz0JsnGgsPDBWHv+v12l5zGsE7Pz9A2d0W9WJ2dUZ/a/PkxQW58o/asvHDfyvJEw39G/5zf2p9OmZFYYGKoioVKPzPpwQA0+d2xv7C/aL/Dx/m7QfkjDLZL8AH4F3p+8PBrGIuqFLmM3uqfI6+YxAFCRZWAQ9LM8Q3JuRm2/bCgWEDCgzzIFgrEYk0AxgciQCDtQxgSRr76yYgfkIiHoB0FxGEEKfAEOQMJCHukYj0CQyjPsMQ0oJgyQD6hI6DL3FILJJLiqDqQs9kbYnLnTRuoA+5j7HsWmUJG2QdvdPrY51xR0WpwQZZIJvsGJ8OWYgVpomO28rIDCGTHHGJpqFe7xSPlUXs04FBiQV1FxSCqhboGVlrEldXpOGP94G4Hwydnaj6DjJBj1dvVtZizE8WlMYD9rHWaVsLsmF6QhZEmMDsIk1wOZWLgSHSSTkEJzGNQvvLc3HLFVXz9euBBWkm+YbLZKGH3uISrbIXRuNaAA==');
  515. }
  516. .iconfont {
  517. font-family: "iconfont" !important;
  518. font-size: 24rpx;
  519. font-style: normal;
  520. -webkit-font-smoothing: antialiased;
  521. -moz-osx-font-smoothing: grayscale;
  522. }
  523. .icon-search:before {
  524. content: "\e6e1";
  525. font-size: 32rpx;
  526. }
  527. .icon-close:before {
  528. content: "\e6a7";
  529. font-size: 28rpx;
  530. }
  531. .icon-right:before {
  532. content: "\e6a3";
  533. font-size: 28rpx;
  534. }
  535. .icon-empty:before {
  536. content: "\e6a9";
  537. font-size: 80rpx;
  538. }
  539. /* 容器样式 */
  540. .container {
  541. display: flex;
  542. flex-direction: column;
  543. min-height: 100vh;
  544. background-color: #F9FCFA;
  545. padding-bottom: 20rpx;
  546. }
  547. /* 搜索区域 */
  548. .search-section {
  549. padding: 20rpx 30rpx;
  550. background-color: #FFFFFF;
  551. margin-bottom: 2rpx;
  552. width: 100%;
  553. box-sizing: border-box;
  554. position: relative;
  555. z-index: 5;
  556. box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.03);
  557. }
  558. .search-box {
  559. display: flex;
  560. align-items: center;
  561. background-color: #F7F7F7;
  562. height: 80rpx;
  563. border-radius: 40rpx;
  564. padding: 0 24rpx;
  565. width: 100%;
  566. box-sizing: border-box;
  567. border: 2rpx solid transparent;
  568. transition: all 0.3s ease;
  569. }
  570. .search-box.search-focus {
  571. border-color: #4CAF50;
  572. background-color: #FFFFFF;
  573. box-shadow: 0 0 10rpx rgba(76, 175, 80, 0.1);
  574. }
  575. .search-icon {
  576. color: #4CAF50;
  577. width: 60rpx;
  578. display: flex;
  579. justify-content: center;
  580. }
  581. .search-input {
  582. flex: 1;
  583. height: 80rpx;
  584. font-size: 28rpx;
  585. color: #333333;
  586. }
  587. .clear-icon {
  588. width: 60rpx;
  589. display: flex;
  590. justify-content: center;
  591. color: #999;
  592. }
  593. /* 状态筛选区域 */
  594. .filter-section {
  595. display: flex;
  596. padding: 24rpx 24rpx;
  597. background-color: #FFFFFF;
  598. margin-bottom: 20rpx;
  599. box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.03);
  600. position: relative;
  601. z-index: 4;
  602. flex-wrap: wrap;
  603. }
  604. .filter-item {
  605. display: flex;
  606. align-items: center;
  607. font-size: 28rpx;
  608. color: #666666;
  609. margin-right: 30rpx;
  610. padding: 12rpx 20rpx;
  611. border-radius: 30rpx;
  612. transition: all 0.2s ease;
  613. position: relative;
  614. margin-bottom: 10rpx;
  615. }
  616. .filter-item.active {
  617. background-color: #F0F9F0;
  618. color: #4CAF50;
  619. font-weight: 500;
  620. }
  621. .filter-dot {
  622. width: 14rpx;
  623. height: 14rpx;
  624. border-radius: 50%;
  625. margin-right: 10rpx;
  626. }
  627. .online-dot {
  628. background-color: #4CAF50;
  629. box-shadow: 0 0 6rpx rgba(76, 175, 80, 0.5);
  630. }
  631. .offline-dot {
  632. background-color: #F56C6C;
  633. box-shadow: 0 0 6rpx rgba(245, 108, 108, 0.5);
  634. }
  635. .fault-dot {
  636. background-color: #FF9800;
  637. box-shadow: 0 0 6rpx rgba(255, 152, 0, 0.5);
  638. }
  639. .maintain-dot {
  640. background-color: #2196F3;
  641. box-shadow: 0 0 6rpx rgba(33, 150, 243, 0.5);
  642. }
  643. /* 设备列表区域 */
  644. .device-list {
  645. flex: 1;
  646. padding: 0 30rpx;
  647. box-sizing: border-box;
  648. width: 100%;
  649. position: relative;
  650. z-index: 3;
  651. height: calc(100vh - 220rpx);
  652. }
  653. /* 空数据提示 */
  654. .empty-tips {
  655. padding: 120rpx 0;
  656. display: flex;
  657. flex-direction: column;
  658. align-items: center;
  659. justify-content: center;
  660. }
  661. .empty-icon {
  662. color: #DDDDDD;
  663. margin-bottom: 20rpx;
  664. }
  665. .empty-text {
  666. font-size: 28rpx;
  667. color: #999999;
  668. margin-bottom: 20rpx;
  669. }
  670. .empty-action {
  671. font-size: 26rpx;
  672. color: #4CAF50;
  673. padding: 12rpx 30rpx;
  674. border-radius: 30rpx;
  675. background-color: rgba(76, 175, 80, 0.1);
  676. }
  677. /* 首次加载中状态 */
  678. .loading-container {
  679. padding: 80rpx 0;
  680. display: flex;
  681. flex-direction: column;
  682. align-items: center;
  683. justify-content: center;
  684. }
  685. .loading-spinner {
  686. width: 60rpx;
  687. height: 60rpx;
  688. border: 4rpx solid #E0E0E0;
  689. border-top: 4rpx solid #4CAF50;
  690. border-radius: 50%;
  691. animation: spin 1s linear infinite;
  692. margin-bottom: 20rpx;
  693. }
  694. .loading-text {
  695. font-size: 28rpx;
  696. color: #999999;
  697. }
  698. /* 设备卡片 */
  699. .device-card {
  700. position: relative;
  701. background-color: #FFFFFF;
  702. border-radius: 24rpx;
  703. padding: 28rpx;
  704. margin-bottom: 24rpx;
  705. box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.05);
  706. transition: all 0.25s ease;
  707. width: 100%;
  708. box-sizing: border-box;
  709. border: 1rpx solid rgba(0, 0, 0, 0.05);
  710. overflow: hidden;
  711. }
  712. .device-card::before {
  713. content: "";
  714. position: absolute;
  715. top: 0;
  716. left: 0;
  717. width: 6rpx;
  718. height: 100%;
  719. background: linear-gradient(to bottom, #66CC6A, #3BB44A);
  720. opacity: 0;
  721. transition: opacity 0.3s ease;
  722. }
  723. .device-card:active::before {
  724. opacity: 1;
  725. }
  726. .device-card-hover {
  727. background-color: #f2fef6;
  728. box-shadow: 0 10rpx 25rpx rgba(59, 180, 74, 0.1);
  729. transform: translateY(-3rpx);
  730. }
  731. .device-card.has-alert {
  732. box-shadow: 0 6rpx 20rpx rgba(245, 108, 108, 0.08);
  733. border: 1rpx solid rgba(245, 108, 108, 0.08);
  734. }
  735. .device-card.has-alert::before {
  736. background: linear-gradient(to bottom, #FF8F8F, #F56C6C);
  737. }
  738. .device-card.is-offline {
  739. opacity: 0.9;
  740. }
  741. .device-card.is-offline.device-card-hover {
  742. background-color: #f5f5f5;
  743. box-shadow: 0 10rpx 25rpx rgba(0, 0, 0, 0.05);
  744. }
  745. /* 告警角标 */
  746. .alarm-badge {
  747. position: absolute;
  748. top: -8rpx;
  749. right: -8rpx;
  750. min-width: 36rpx;
  751. height: 36rpx;
  752. border-radius: 18rpx;
  753. background-color: #F56C6C;
  754. color: #FFFFFF;
  755. font-size: 22rpx;
  756. font-weight: 600;
  757. display: flex;
  758. align-items: center;
  759. justify-content: center;
  760. padding: 0 8rpx;
  761. z-index: 3;
  762. box-shadow: 0 3rpx 8rpx rgba(245, 108, 108, 0.3);
  763. animation: pulse 1.5s infinite;
  764. }
  765. @keyframes pulse {
  766. 0% {
  767. transform: scale(1);
  768. }
  769. 50% {
  770. transform: scale(1.1);
  771. }
  772. 100% {
  773. transform: scale(1);
  774. }
  775. }
  776. /* 设备基本信息 */
  777. .device-info {
  778. display: flex;
  779. margin-bottom: 24rpx;
  780. width: 100%;
  781. }
  782. .device-icon-wrapper {
  783. position: relative;
  784. margin-right: 24rpx;
  785. flex-shrink: 0;
  786. }
  787. .device-icon-container {
  788. width: 96rpx;
  789. height: 96rpx;
  790. border-radius: 50%;
  791. background: linear-gradient(135deg, #66CC6A 0%, #3BB44A 100%);
  792. display: flex;
  793. align-items: center;
  794. justify-content: center;
  795. flex-shrink: 0;
  796. box-shadow: 0 6rpx 16rpx rgba(59, 180, 74, 0.2);
  797. transition: all 0.3s ease;
  798. }
  799. .device-icon-container.offline-icon {
  800. background: linear-gradient(135deg, #AAB2BD 0%, #656D78 100%);
  801. box-shadow: 0 6rpx 16rpx rgba(101, 109, 120, 0.2);
  802. }
  803. .device-icon {
  804. width: 52rpx;
  805. height: 52rpx;
  806. filter: brightness(0) invert(1);
  807. }
  808. .device-meta {
  809. flex: 1;
  810. width: calc(100% - 120rpx);
  811. overflow: hidden;
  812. }
  813. .device-name-row {
  814. display: flex;
  815. justify-content: space-between;
  816. align-items: center;
  817. margin-bottom: 14rpx;
  818. width: 100%;
  819. }
  820. .device-name {
  821. font-size: 34rpx;
  822. font-weight: 600;
  823. color: #333333;
  824. max-width: 65%;
  825. overflow: hidden;
  826. text-overflow: ellipsis;
  827. white-space: nowrap;
  828. transition: color 0.3s ease;
  829. }
  830. .device-name.offline-text {
  831. color: #656D78;
  832. }
  833. .status-tag {
  834. padding: 6rpx 16rpx 6rpx 32rpx;
  835. border-radius: 8rpx;
  836. font-size: 24rpx;
  837. font-weight: 500;
  838. flex-shrink: 0;
  839. border: 1rpx solid;
  840. position: relative;
  841. overflow: hidden;
  842. }
  843. .status-online {
  844. background-color: rgba(76, 175, 80, 0.1);
  845. color: #4CAF50;
  846. border-color: rgba(76, 175, 80, 0.3);
  847. }
  848. .status-offline {
  849. background-color: rgba(245, 108, 108, 0.1);
  850. color: #F56C6C;
  851. border-color: rgba(245, 108, 108, 0.3);
  852. }
  853. .status-fault {
  854. background-color: rgba(255, 152, 0, 0.1);
  855. color: #FF9800;
  856. border-color: rgba(255, 152, 0, 0.3);
  857. }
  858. .status-maintain {
  859. background-color: rgba(33, 150, 243, 0.1);
  860. color: #2196F3;
  861. border-color: rgba(33, 150, 243, 0.3);
  862. }
  863. .status-dot {
  864. position: absolute;
  865. width: 8rpx;
  866. height: 8rpx;
  867. background-color: #4CAF50;
  868. border-radius: 50%;
  869. top: 50%;
  870. left: 16rpx;
  871. transform: translateY(-50%);
  872. box-shadow: 0 0 4rpx rgba(76, 175, 80, 0.8);
  873. animation: blink 1.5s infinite;
  874. display: inline-block;
  875. }
  876. .status-dot.offline-dot {
  877. background-color: #F56C6C;
  878. box-shadow: 0 0 4rpx rgba(245, 108, 108, 0.8);
  879. }
  880. .status-dot.fault-dot {
  881. background-color: #FF9800;
  882. box-shadow: 0 0 4rpx rgba(255, 152, 0, 0.8);
  883. }
  884. .status-dot.maintain-dot {
  885. background-color: #2196F3;
  886. box-shadow: 0 0 4rpx rgba(33, 150, 243, 0.8);
  887. }
  888. @keyframes blink {
  889. 0% {
  890. opacity: 0.4;
  891. }
  892. 50% {
  893. opacity: 1;
  894. }
  895. 100% {
  896. opacity: 0.4;
  897. }
  898. }
  899. .device-id, .device-location {
  900. display: flex;
  901. font-size: 26rpx;
  902. margin-top: 10rpx;
  903. color: #666666;
  904. width: 100%;
  905. overflow: hidden;
  906. }
  907. .id-label, .location-label {
  908. color: #999999;
  909. margin-right: 8rpx;
  910. flex-shrink: 0;
  911. }
  912. .id-value, .location-value {
  913. color: #666666;
  914. overflow: hidden;
  915. text-overflow: ellipsis;
  916. white-space: nowrap;
  917. }
  918. /* 底部信息栏 */
  919. .device-footer {
  920. display: flex;
  921. justify-content: space-between;
  922. align-items: center;
  923. padding-top: 18rpx;
  924. border-top: 1rpx solid #F2F2F2;
  925. }
  926. .update-time {
  927. font-size: 24rpx;
  928. color: #999999;
  929. }
  930. .device-actions {
  931. display: flex;
  932. align-items: center;
  933. color: #CCCCCC;
  934. }
  935. /* 加载更多区域 */
  936. .load-more {
  937. padding: 20rpx 0 40rpx;
  938. }
  939. .load-more-content {
  940. display: flex;
  941. justify-content: center;
  942. align-items: center;
  943. height: 60rpx;
  944. font-size: 24rpx;
  945. color: #999999;
  946. }
  947. .loading-icon {
  948. width: 30rpx;
  949. height: 30rpx;
  950. margin-right: 10rpx;
  951. border: 2rpx solid #E0E0E0;
  952. border-top: 2rpx solid #4CAF50;
  953. border-radius: 50%;
  954. animation: spin 1s linear infinite;
  955. }
  956. @keyframes spin {
  957. 0% { transform: rotate(0deg); }
  958. 100% { transform: rotate(360deg); }
  959. }
  960. /* 悬浮新增按钮 */
  961. .floating-add-btn {
  962. position: fixed;
  963. right: 40rpx;
  964. bottom: 160rpx;
  965. width: 120rpx;
  966. height: 120rpx;
  967. border-radius: 60rpx;
  968. background: linear-gradient(135deg, #3BB44A, #66CC6A);
  969. box-shadow: 0 12rpx 30rpx rgba(59, 180, 74, 0.35);
  970. display: flex;
  971. align-items: center;
  972. justify-content: center;
  973. z-index: 99;
  974. }
  975. .floating-add-btn .plus {
  976. font-size: 60rpx;
  977. color: #ffffff;
  978. line-height: 1;
  979. }
  980. </style>