home.vue 40 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681
  1. <template>
  2. <view class="notice-board">
  3. <view class="board-header">
  4. <view class="card_logo" @click="linkSet">
  5. <!-- <view class="card_logo"> -->
  6. <image class="logo_image" src="/static/logo.png" mode=""></image>
  7. <view class="logo_title">观山湖区疾控中心</view>
  8. </view>
  9. <view class="card_yellow">
  10. <view class="card_board">
  11. <text class="board-title">接种留观等待</text>
  12. </view>
  13. </view>
  14. <view class="card_time">
  15. <view class="time_title">{{hhmmss}}</view>
  16. <view class="current-time">{{ currentTime }} {{whatDay}}</view>
  17. </view>
  18. </view>
  19. <!-- 表格容器 -->
  20. <view class="table-container">
  21. <!-- 主要表头 -->
  22. <view class="table-header">
  23. <view class="table-row header-row">
  24. <view class="cell name title_color">姓名</view>
  25. <view class="cell time title_color">留观时间</view>
  26. <view class="cell time title_color">离开时间</view>
  27. <view class="cell status title_color">状态</view>
  28. </view>
  29. </view>
  30. <!-- 特殊状态数据展示区域(固定) -->
  31. <view class="special-status-container" v-if="specialStatusData.length > 0">
  32. <view class="table-row" v-for="(item, index) in specialStatusData" :key="item.id"
  33. :class="`item-${getClass(item.status)}`">
  34. <view class="table-cell name" :class="`title-${getClass(item.status)}`">{{ item.patientName }}
  35. </view>
  36. <view class="table-cell time">{{ formatTime(item.createTime) }}</view>
  37. <view class="table-cell time">{{ formatTime(item.outTime) }}</view>
  38. <view class="table-cell status" style="padding: 0rpx;">
  39. <view class="status-tag" :class="`status-${getClass(item.status)}`">
  40. {{ getStatusText(item) }}
  41. </view>
  42. </view>
  43. </view>
  44. <!-- 分隔线 -->
  45. <view class="divider"></view>
  46. </view>
  47. <!-- 普通数据内容区域 -->
  48. <view class="table-body" @touchstart="handleTouchStart" @touchmove="handleTouchMove"
  49. @touchend="handleTouchEnd">
  50. <view class="table-row" v-for="(item, index) in displayNormalData" :key="item.id"
  51. :class="`item-${getClass(item.status)}`">
  52. <view class="table-cell name" :class="`title-${getClass(item.status)}`">{{ item.patientName }}
  53. </view>
  54. <view class="table-cell time">{{ formatTime(item.createTime) }}</view>
  55. <view class="table-cell time">{{ formatTime(item.outTime) }}</view>
  56. <view class="table-cell status" style="padding: 0rpx;">
  57. <view class="status-tag" :class="`status-${getClass(item.status)}`">
  58. {{ getStatusText(item) }}
  59. </view>
  60. </view>
  61. </view>
  62. <!-- 空数据提示 -->
  63. <view v-if="displayNormalData.length === 0 && specialStatusData.length === 0" class="empty-tip">
  64. 暂无留观人员信息
  65. </view>
  66. </view>
  67. </view>
  68. <view class="card_foot">
  69. <view class="card_tips_box">
  70. <image class="tips_imageil" src="/static/horn.png" mode=""></image>
  71. <view class="title_tips">温馨提示:</view>
  72. </view>
  73. <view class="title_tips_foot">请注意留观30分钟后无不良反应后再离开,谢谢。</view>
  74. </view>
  75. <!-- 连接状态与IP编辑栏 -->
  76. <view class="box_link_set" v-if="linkShow">
  77. <view class="box_popup" :style="{bottom: keyboardHeight + 'px'}">
  78. <view class="head_popup_title">
  79. <view class="title_head_popup">连接设置</view>
  80. <view class="close_title" @click="getClose">×</view>
  81. </view>
  82. <view class="status-bar-bottom" :class="`status-${connectionStatus}`">
  83. <!-- IP 编辑区域 -->
  84. <view class="ip-input-group">
  85. <text class="ip-label">IP:</text>
  86. <input type="text" :value="serverIp" @input="onIpInput" placeholder="192.168.0.41"
  87. class="ip-input" @keyboardheightchange="keyboardheightchange" />
  88. <text class="colon">:</text>
  89. <input type="number" :value="serverPort" @input="onPortInput" placeholder="8811"
  90. class="port-input" @keyboardheightchange="keyboardheightchange" />
  91. <button class="btn-reconnect" size="mini" @click="handleReconnect">
  92. 更新并重连
  93. </button>
  94. </view>
  95. <view class="status-text-box">
  96. <text class="dot" :class="`dot-${connectionStatus}`">●</text>
  97. <text class="status-text">{{ connectionText }}</text>
  98. </view>
  99. </view>
  100. </view>
  101. </view>
  102. </view>
  103. </template>
  104. <script>
  105. export default {
  106. data() {
  107. return {
  108. currentTime: '', // 当前时间
  109. hhmmss: '', // 当前时间
  110. whatDay: '', //星期几
  111. timeTimer: null, // 时间更新定时器
  112. // WebSocket 实例
  113. ws: null,
  114. reconnectTimer: null,
  115. heartbeatTimer: null, // 心跳定时器
  116. heartbeatTimeout: null, // 心跳响应超时计时器
  117. heartbeatInterval: 30000, // 30秒发一次心跳
  118. heartbeatTimeoutTime: 10000, // 10秒内未响应视为超时
  119. isPongReceived: true, // 标记是否收到 pong
  120. // 连接状态
  121. connectionStatus: 'connecting', // connecting, connected, disconnected
  122. statusText: {
  123. observing: '留观中',
  124. completed: '留观完成,可离开',
  125. warning: '提前离开',
  126. hasleft: '已离开',
  127. },
  128. // 可编辑的服务器地址
  129. serverIp: '192.168.0.41',
  130. serverPort: '8811',
  131. // 所有数据留观数据列表
  132. allData: [],
  133. // 当前页码
  134. currentPage: 1,
  135. // 每页显示条数(自适应计算)
  136. pageSize: 10,
  137. // 是否自动滚动
  138. autoScroll: true,
  139. // 自动滚动定时器
  140. autoScrollTimer: null,
  141. // 滚动间隔时间(毫秒)
  142. scrollInterval: 6000,
  143. // 窗口高度
  144. windowHeight: 0,
  145. // 触摸相关
  146. touchStartX: 0,
  147. touchStartTime: 0,
  148. isTouching: false,
  149. touchStartY: 0,
  150. // 垂直滑动相关
  151. lastSwipeTime: 0,
  152. linkShow: false,
  153. ipArray: [],
  154. keyboardHeight: 0,
  155. now: new Date(),
  156. timer: null,
  157. heartbeatRate: 0,
  158. isSend: true,
  159. reconnectionNum: 0,
  160. TTSSpeech: null,
  161. speechQueue: [], // 语音队列
  162. isSpeaking: false, // 是否正在播放语音
  163. }
  164. },
  165. computed: {
  166. connectionText() {
  167. const {
  168. connectionStatus,
  169. serverIp,
  170. serverPort
  171. } = this;
  172. return {
  173. connecting: `正在连接 ${serverIp}:${serverPort}...`,
  174. connected: `已连接到 ${serverIp}:${serverPort}`,
  175. disconnected: `连接已断开`
  176. } [connectionStatus];
  177. },
  178. // 特殊状态数据(状态1和2)
  179. specialStatusData() {
  180. return this.allData.filter(item => item.status === 3 || item.status === 4 || item.IsOut == 1)
  181. },
  182. // 普通状态数据(状态0)
  183. normalData() {
  184. return this.allData.filter(item => item.status === 0 || item.status === 1)
  185. },
  186. // 实际显示的条数(减去缓冲行)
  187. actualPageSize() {
  188. return Math.max(1, this.pageSize - 1)
  189. },
  190. // 总页数(基于普通数据计算)
  191. totalPages() {
  192. if (this.actualPageSize <= 0 || this.normalData.length === 0) return 1
  193. return Math.ceil(this.normalData.length / this.actualPageSize)
  194. },
  195. // 显示的普通数据(包含缓冲行)
  196. displayNormalData() {
  197. const start = (this.currentPage - 1) * this.actualPageSize
  198. const end = Math.min(start + this.pageSize, this.normalData.length)
  199. return this.normalData.slice(start, end).map(item => {
  200. const expectedTime = new Date(item.outTime)
  201. const timeDiff = expectedTime - this.now // 毫秒差
  202. if (timeDiff > 0) {
  203. // 未到离开时间
  204. const days = Math.floor(timeDiff / (24 * 3600 * 1000))
  205. const hours = Math.floor((timeDiff % (24 * 3600 * 1000)) / (3600 * 1000))
  206. const minutes = Math.floor((timeDiff % (3600 * 1000)) / (60 * 1000))
  207. const seconds = Math.floor((timeDiff % (60 * 1000)) / 1000)
  208. var fzArr = ''
  209. if (minutes) {
  210. fzArr = `剩余${minutes}分钟`
  211. } else {
  212. fzArr = `剩余${seconds}秒`
  213. }
  214. return {
  215. ...item,
  216. remainingTime: fzArr,
  217. }
  218. } else {
  219. return {
  220. ...item,
  221. remainingTime: '留观完成,可离开',
  222. }
  223. }
  224. })
  225. }
  226. },
  227. onLoad() {
  228. //#ifdef APP
  229. // 引入插件
  230. this.TTSSpeech = uni.requireNativePlugin('MT-TTS-Speech');
  231. // 初始化TTS引擎
  232. this.TTSSpeech.init((status) => { // 👈 同样移除 : number
  233. if (status === 0) {
  234. console.log('TTS引擎初始化成功');
  235. } else {
  236. console.error('TTS引擎初始化失败,状态码:', status)
  237. }
  238. }, 'com.iflytek.speechcloud')
  239. this.TTSSpeech.setPitch(50)
  240. this.TTSSpeech.setSpeed(50)
  241. //#endif
  242. },
  243. mounted() {
  244. this.reconnectionNum = 0
  245. // 每秒更新当前时间
  246. this.timer = setInterval(() => {
  247. this.now = new Date()
  248. }, 1000)
  249. this.getIpAddress()
  250. this.initPage()
  251. const lastIp = uni.getStorageSync('serverIp');
  252. const lastPort = uni.getStorageSync('serverPort');
  253. if (lastIp && lastPort) {
  254. this.serverIp = lastIp
  255. this.serverPort = lastPort
  256. }
  257. // 在H5平台添加鼠标滚轮支持
  258. //#ifdef H5
  259. this.startCheck()
  260. this.addMouseWheelSupport()
  261. //#endif
  262. this.updateCurrentTime(); // 立即更新一次时间
  263. this.timeTimer = setInterval(() => {
  264. this.updateCurrentTime();
  265. }, 1000); // 每秒更新一次时间
  266. },
  267. beforeDestroy() {
  268. // 组件销毁时清理定时器
  269. if (this.timer) {
  270. this.timer = null
  271. clearInterval(this.timer)
  272. }
  273. this.stopAutoScroll()
  274. uni.offWindowResize(this.handleWindowResize)
  275. //#ifdef H5
  276. this.removeMouseWheelSupport()
  277. //#endif
  278. if (this.ws) {
  279. this.ws.close();
  280. }
  281. if (this.reconnectTimer) {
  282. clearTimeout(this.reconnectTimer);
  283. }
  284. this.stopHeartbeat();
  285. // 清理时间更新定时器
  286. if (this.timeTimer) {
  287. clearInterval(this.timeTimer);
  288. }
  289. },
  290. methods: {
  291. // 将文本添加到队列
  292. addToSpeechQueue(textValue) {
  293. if (textValue && typeof textValue === 'string') {
  294. this.speechQueue.push(textValue);
  295. // 如果没有正在播放,则开始播放
  296. if (!this.isSpeaking) {
  297. this.processSpeechQueue();
  298. }
  299. } else {
  300. console.warn('Invalid text value for speech:', textValue);
  301. }
  302. },
  303. // 处理语音队列
  304. async processSpeechQueue() {
  305. if (this.speechQueue.length === 0) {
  306. this.isSpeaking = false;
  307. return;
  308. }
  309. this.isSpeaking = true;
  310. const textToSpeak = this.speechQueue.shift(); // 取出队列第一个元素
  311. try {
  312. // 等待当前语音播放完成 (通过 onDone 回调)
  313. await this.speak(textToSpeak);
  314. } catch (error) {
  315. console.error('TTS speak error for text:', textToSpeak, error);
  316. // 即使当前项播放失败,也继续处理队列中的下一个
  317. } finally {
  318. this.processSpeechQueue();
  319. }
  320. },
  321. // 执行实际的语音播放 (返回 Promise)
  322. speak(textValue) {
  323. return new Promise((resolve, reject) => {
  324. try{
  325. setTimeout(() => reject(new Error('Operation timed out')), 8000);
  326. if (!this.TTSSpeech) {
  327. //#ifdef APP
  328. // 引入插件
  329. this.TTSSpeech = uni.requireNativePlugin('MT-TTS-Speech');
  330. // 初始化TTS引擎
  331. this.TTSSpeech.init((status) => { // 👈 同样移除 : number
  332. if (status === 0) {
  333. console.log('TTS引擎初始化成功');
  334. } else {
  335. console.error('TTS引擎初始化失败,状态码:', status)
  336. }
  337. }, 'com.iflytek.speechcloud')
  338. this.TTSSpeech.setPitch(50)
  339. this.TTSSpeech.setSpeed(50)
  340. //#endif
  341. reject(new Error('TTS engine not initialized.'));
  342. return;
  343. }
  344. // 监听 TTS 播放结束事件
  345. this.TTSSpeech.onDone(() => {
  346. resolve(); // 播放完成,Promise 成功
  347. });
  348. // 尝试启动语音播放
  349. const speakOptions = {
  350. text: textValue,
  351. };
  352. const res = this.TTSSpeech.speak(speakOptions);
  353. if (res != 0){
  354. this.TTSSpeech.destroy()
  355. this.TTSSpeech = null
  356. }
  357. } catch (e) {
  358. resolve();
  359. }
  360. });
  361. },
  362. keyboardheightchange() {
  363. uni.onKeyboardHeightChange(res => {
  364. this.keyboardHeight = res.height
  365. })
  366. },
  367. findFirstWebSocketIp(ipList, index = 0, onSuccess, onAllFailed) {
  368. // 终止条件:全部失败
  369. if (index >= ipList.length) {
  370. if (typeof onAllFailed === 'function') {
  371. onAllFailed();
  372. }
  373. return;
  374. }
  375. // console.log(ipList, 344)
  376. const ip = ipList[index].trim();
  377. const port = this.serverPort; // 根据你的服务修改端口
  378. const url = `ws://${ip}:${port}/`; // 可改为 /ws, /device 等路径
  379. // console.log(`🔍 正在尝试连接 WebSocket: ${url}`);
  380. // 先确保没有遗留连接
  381. if (this.ws) {
  382. this.ws.close();
  383. this.ws = null;
  384. }
  385. // 创建 SocketTask
  386. this.ws = uni.connectSocket({
  387. url: url,
  388. success: (res) => {
  389. console.log('connectSocket success', res);
  390. },
  391. fail: (err) => {
  392. console.error('connectSocket 失败', err);
  393. this.connectionStatus = 'disconnected';
  394. if (this.serverIp == ip) {
  395. this.heartbeatRate = 0
  396. this.reconnectionNum++
  397. //#ifdef H5
  398. this.isSend = true
  399. this.findFirstWebSocketIp([this.serverIp], index, onSuccess, onAllFailed);
  400. //#endif
  401. }
  402. //#ifdef APP
  403. this.reconnectionNum++
  404. this.isSend = true
  405. this.heartbeatRate = 0
  406. this.findFirstWebSocketIp(ipList, index + 1, onSuccess, onAllFailed);
  407. //#endif
  408. }
  409. });
  410. // --- WebSocket 事件监听 ---
  411. this.ws.onOpen((res) => {
  412. this.serverIp = ip
  413. uni.setStorageSync('serverIp', this.serverIp);
  414. uni.setStorageSync('serverPort', this.serverPort);
  415. // console.log('WebSocket 连接成功', res);
  416. this.connectionStatus = 'connected';
  417. this.ws.send({
  418. data: 'link',
  419. });
  420. onSuccess(ip);
  421. // ✅ 连接成功后启动心跳
  422. this.startHeartbeat();
  423. uni.hideLoading();
  424. });
  425. this.ws.onMessage((res) => {
  426. // ✅ 处理心跳响应 pong
  427. if (res.data === 'PONG' || res.data === '{"type":"PONG"}') {
  428. uni.hideToast();
  429. this.isSend = true
  430. this.reconnectionNum = 0
  431. this.heartbeatRate = 0
  432. this.isPongReceived = true;
  433. // console.log('收到 pong,心跳正常');
  434. // ✅ 清除等待 pong 的超时计时器
  435. if (this.heartbeatTimeout) {
  436. clearTimeout(this.heartbeatTimeout);
  437. this.heartbeatTimeout = null;
  438. }
  439. return;
  440. }
  441. try {
  442. const data = JSON.parse(res.data);
  443. this.handleMessage(data);
  444. } catch (e) {
  445. console.warn('非 JSON 消息,已忽略', res.data);
  446. }
  447. });
  448. this.ws.onClose((res) => {
  449. console.log('WebSocket 连接关闭', res);
  450. this.connectionStatus = 'disconnected';
  451. this.stopHeartbeat(); // 停止心跳
  452. var reconnectionTime = setTimeout(() => {
  453. clearTimeout(reconnectionTime)
  454. if (this.serverIp == ip) {
  455. //#ifdef H5
  456. this.isSend = true
  457. this.heartbeatRate = 0
  458. this.reconnectionNum++
  459. this.findFirstWebSocketIp([this.serverIp], index, onSuccess, onAllFailed);
  460. //#endif
  461. }
  462. //#ifdef APP
  463. this.isSend = true
  464. this.heartbeatRate = 0
  465. this.reconnectionNum++
  466. this.findFirstWebSocketIp(ipList, index + 1, onSuccess, onAllFailed);
  467. //#endif
  468. }, 2000)
  469. });
  470. this.ws.onError((err) => {
  471. console.error('WebSocket 错误', err);
  472. this.connectionStatus = 'disconnected';
  473. //#ifdef APP
  474. this.isSend = true
  475. this.heartbeatRate = 0
  476. this.reconnectionNum++
  477. this.findFirstWebSocketIp(ipList, index + 1, onSuccess, onAllFailed);
  478. //#endif
  479. });
  480. },
  481. // 连接设置
  482. linkSet() {
  483. this.linkShow = true
  484. },
  485. // 关闭弹窗
  486. getClose() {
  487. this.linkShow = false
  488. },
  489. // 获取ip地址
  490. getIpAddress() {
  491. //获取插件示例
  492. //#ifdef APP
  493. var deviceFinder = uni.requireNativePlugin("Alikes-NetTools-DeviceFinder")
  494. deviceFinder.scan({}, (res) => {
  495. this.ipArray = res
  496. this.ipArray.unshift(this.serverIp)
  497. this.startCheck()
  498. })
  499. //#endif
  500. },
  501. // 启动检查
  502. startCheck() {
  503. var ipArray = []
  504. //#ifdef H5
  505. ipArray = [this.serverIp]
  506. //#endif
  507. //#ifdef APP
  508. ipArray = this.ipArray
  509. //#endif
  510. this.findFirstWebSocketIp(
  511. ipArray,
  512. 0,
  513. (ip) => {
  514. // ✅ 找到可用设备
  515. this.onDeviceFound(ip);
  516. this.linkShow = false
  517. },
  518. () => {
  519. // ❌ 全部失败
  520. this.onNoDevice();
  521. }
  522. );
  523. },
  524. // 找到设备后的逻辑
  525. onDeviceFound(ip) {
  526. uni.showToast({
  527. title: '连接成功',
  528. icon: 'success'
  529. });
  530. // console.log(`🚀 可用设备: ${ip},开始后续操作...`);
  531. // 例如:跳转控制页面、订阅消息等
  532. },
  533. // 无设备可用
  534. onNoDevice() {
  535. uni.showToast({
  536. title: '无设备响应',
  537. icon: 'none',
  538. duration: 3000
  539. });
  540. this.getIpAddress()
  541. // console.log('🚨 所有设备 WebSocket 均无法连接');
  542. },
  543. getClass(status) {
  544. var title = '';
  545. if (status == 0) {
  546. title = 'observing'
  547. } else if (status == 1) {
  548. title = 'completed'
  549. } else if (status == 3) {
  550. title = 'warning'
  551. } else if (status == 4) {
  552. title = 'hasleft'
  553. }
  554. return title
  555. },
  556. // 获取状态文本
  557. getStatusText(item) {
  558. if (item.status == 0) {
  559. return item.remainingTime
  560. // 每秒更新一次
  561. } else if (item.status == 1) {
  562. return this.statusText.completed
  563. } else if (item.status == 3) {
  564. return this.statusText.warning
  565. } else if (item.status == 4) {
  566. return this.statusText.hasleft
  567. }
  568. },
  569. // 初始化页面
  570. initPage() {
  571. this.calculatePageSize()
  572. this.handleWindowResize = this.debounce(this.calculatePageSize, 300)
  573. uni.onWindowResize(this.handleWindowResize)
  574. },
  575. // 计算自适应的页面大小
  576. calculatePageSize() {
  577. try {
  578. // 获取系统信息
  579. const systemInfo = uni.getSystemInfoSync()
  580. this.windowHeight = systemInfo.windowHeight
  581. // 计算可用高度
  582. const headerHeight = uni.upx2px(130) // 头部高度
  583. const paginationHeight = uni.upx2px(100) // 分页区域高度
  584. const tableHeaderHeight = uni.upx2px(120) // 表格头部高度
  585. const specialDataHeight = this.specialStatusData.length > 0 ?
  586. uni.upx2px(80 * this.specialStatusData.length) : 0 // 特殊数据区域高度 + 分隔线
  587. const padding = uni.upx2px(0) // 上下padding
  588. const availableHeight = this.windowHeight - headerHeight - paginationHeight -
  589. tableHeaderHeight - specialDataHeight - padding
  590. // 计算行高(px)- 固定60rpx
  591. const rowHeightPx = uni.upx2px(140)
  592. // 计算可显示的行数
  593. const calculatedPageSize = Math.floor(availableHeight / rowHeightPx)
  594. // 设置页面大小(最少显示4行,最多显示30行)
  595. this.pageSize = Math.max(4, Math.min(30, calculatedPageSize))
  596. // 验证当前页
  597. this.validateCurrentPage()
  598. } catch (error) {
  599. console.error('计算页面大小失败:', error)
  600. this.pageSize = 10 // 默认值
  601. }
  602. },
  603. // 防抖函数
  604. debounce(func, wait) {
  605. let timeout
  606. return function executedFunction(...args) {
  607. const later = () => {
  608. clearTimeout(timeout)
  609. func(...args)
  610. }
  611. clearTimeout(timeout)
  612. timeout = setTimeout(later, wait)
  613. }
  614. },
  615. // 验证当前页是否有效
  616. validateCurrentPage() {
  617. if (this.currentPage > this.totalPages && this.totalPages > 0) {
  618. this.currentPage = this.totalPages
  619. }
  620. if (this.currentPage < 1) {
  621. this.currentPage = 1
  622. }
  623. },
  624. // 构建 WebSocket URL
  625. getWebSocketUrl() {
  626. return `ws://${this.serverIp}:${this.serverPort}`;
  627. },
  628. onIpInput(value) {
  629. this.serverIp = value.detail.value
  630. },
  631. // 输入
  632. onPortInput(value) {
  633. this.serverPort = value.detail.value
  634. },
  635. // 处理重连
  636. handleReconnect() {
  637. uni.showLoading({
  638. title: '正在重连...'
  639. });
  640. this.linkShow = false
  641. this.connectionStatus = 'connecting';
  642. // 关闭旧连接
  643. if (this.ws) {
  644. this.ws.close({
  645. success: () => {
  646. // console.log('旧连接已关闭');
  647. //#ifdef H5
  648. this.startCheck()
  649. //#endif
  650. //#ifdef APP
  651. this.getIpAddress()
  652. //#endif
  653. },
  654. fail: () => {
  655. //#ifdef H5
  656. this.startCheck()
  657. //#endif
  658. //#ifdef APP
  659. this.getIpAddress()
  660. //#endif
  661. }
  662. });
  663. } else {
  664. //#ifdef H5
  665. this.startCheck()
  666. //#endif
  667. //#ifdef APP
  668. this.getIpAddress()
  669. //#endif
  670. }
  671. setTimeout(() => {
  672. uni.hideLoading();
  673. }, 2000);
  674. },
  675. // 处理收到的消息
  676. handleMessage(data) {
  677. // 1. 全量数据:数组(不是对象,或没有 action 字段)
  678. if (data.action == 'link') {
  679. this.updateListWithArray(data.data);
  680. return;
  681. }
  682. // 2. 增量操作:对象
  683. if (typeof data === 'object' && data !== null) {
  684. const action = data.action;
  685. if (!action) {
  686. console.warn('消息缺少 action 字段', data);
  687. return;
  688. }
  689. switch (action) {
  690. case 'add':
  691. case 'create':
  692. this.batchAdd(data.data);
  693. break;
  694. case 'update':
  695. this.batchUpdate(data.data);
  696. break;
  697. case 'remove':
  698. case 'delete':
  699. this.batchRemove(data.data);
  700. break;
  701. case 'msg':
  702. // 使用队列播放语音
  703. this.addToSpeechQueue(data.msg)
  704. break;
  705. default:
  706. console.warn('未知操作类型:', action);
  707. }
  708. } else {
  709. console.warn('收到未知格式消息:', data);
  710. }
  711. },
  712. // 批量新增
  713. batchAdd(data) {
  714. if (!data) return;
  715. const items = Array.isArray(data) ? data : [data]; // 兼容单条
  716. const validItems = items.filter(item => item && item.id !== undefined);
  717. if (validItems.length === 0) {
  718. console.warn('没有有效数据用于新增', data);
  719. return;
  720. }
  721. // 避免重复添加
  722. const updated = [...this.allData];
  723. validItems.forEach(item => {
  724. const index = updated.findIndex(i => i.id == item.id);
  725. if (index > -1) {
  726. // 已存在 → 更新
  727. updated[index] = {
  728. ...updated[index],
  729. ...item
  730. };
  731. } else {
  732. updated.unshift(item);
  733. }
  734. });
  735. this.allData = updated;
  736. },
  737. // 批量修改
  738. batchUpdate(data) {
  739. if (!data) return;
  740. const items = Array.isArray(data) ? data : [data]; // 支持单条或数组
  741. const updated = [...this.allData];
  742. items.forEach(item => {
  743. if (!item || item.id === undefined) return;
  744. const index = updated.findIndex(i => i.id == item.id);
  745. if (index > -1) {
  746. updated[index] = {
  747. ...updated[index],
  748. ...item
  749. };
  750. }
  751. // else { this.allData.push(item); } // 可选:当作新增
  752. });
  753. this.allData = updated;
  754. },
  755. // 批量删除
  756. batchRemove(data) {
  757. let idsToRemove = [];
  758. if (Array.isArray(data.id)) {
  759. // remove: { id: [1,2,3] }
  760. idsToRemove = data.id;
  761. } else if (data.id !== undefined) {
  762. // remove: { id: 1 }
  763. idsToRemove = [data.id];
  764. } else if (Array.isArray(data.data)) {
  765. // remove: { data: [ {id:1}, {id:2} ] }
  766. idsToRemove = data.data.map(item => item.id).filter(id => id !== undefined);
  767. } else {
  768. idsToRemove = [data.id];
  769. console.warn('无法解析删除指令', data);
  770. return;
  771. }
  772. if (idsToRemove.length === 0) return;
  773. const updated = this.allData.filter(item => !idsToRemove.includes(item.id));
  774. this.allData = updated;
  775. },
  776. updateListWithArray(newList) {
  777. this.allData = []
  778. if (!Array.isArray(newList)) return;
  779. const map = new Map();
  780. newList.forEach(item => {
  781. if (item.id !== undefined) {
  782. map.set(item.id, item);
  783. }
  784. });
  785. const updated = [...this.allData];
  786. // 更新或新增
  787. for (const [id, item] of map.entries()) {
  788. const index = updated.findIndex(i => i.id == id);
  789. if (index > -1) {
  790. updated[index] = item;
  791. } else {
  792. updated.push(item);
  793. }
  794. }
  795. const final = updated.filter(item => map.has(item.id));
  796. this.allData = final;
  797. },
  798. // 断线重连(指数退避)
  799. reconnect() {
  800. if (this.reconnectTimer) return; // 防止重复定时器
  801. this.reconnectTimer = setTimeout(() => {
  802. // console.log('正在尝试重新连接...');
  803. this.connectWebSocket();
  804. this.reconnectTimer = null;
  805. }, 3000);
  806. },
  807. // 启动心跳
  808. startHeartbeat() {
  809. // 清除旧心跳
  810. var that = this
  811. that.stopHeartbeat();
  812. // 发送 ping 的定时器
  813. that.heartbeatTimer = setInterval(() => {
  814. if (that.ws && that.connectionStatus === 'connected') {
  815. // 检查上一次的 pong 是否已收到,未收到则判定为断线
  816. if (!that.isSend && !that.isPongReceived) {
  817. that.heartbeatRate++
  818. console.warn(that.heartbeatRate, '上一次心跳未收到 pong,可能已断线新');
  819. if (that.heartbeatRate >= 3) {
  820. that.ws.close();
  821. return;
  822. }
  823. }
  824. // 发送心跳
  825. that.isPongReceived = false; // 等待 pong
  826. // 设置超时检测:10秒内没收到 pong 就断开
  827. that.heartbeatTimeout = setTimeout(() => {
  828. if (that.isSend && !that.isPongReceived) {
  829. console.warn('⚠️ 心跳超时:未在规定时间内收到 pong,即将重连');
  830. uni.showToast({
  831. title: '连接异常,正在重连...',
  832. icon: 'none',
  833. duration: 2000
  834. });
  835. that.ws.close(); // 触发 onClose → 重连
  836. }
  837. }, that.heartbeatTimeoutTime); // 使用配置的超时时间(如 10000ms)
  838. if (that.isSend) {
  839. that.ws.send({
  840. data: 'PING',
  841. success: () => {
  842. // console.log('ping 已发送');
  843. that.isSend = false
  844. },
  845. fail: (err) => {
  846. console.error('ping 发送失败', err);
  847. that.ws.close();
  848. }
  849. });
  850. }
  851. }
  852. }, that.heartbeatInterval);
  853. // 可选:启动响应超时检测(更严格)
  854. // 通常靠"发 ping 后未收到 pong"即可判断
  855. },
  856. // 停止心跳(断开连接时调用)
  857. stopHeartbeat() {
  858. if (this.heartbeatTimer) {
  859. clearInterval(this.heartbeatTimer);
  860. this.heartbeatTimer = null;
  861. }
  862. if (this.heartbeatTimeout) {
  863. clearTimeout(this.heartbeatTimeout);
  864. this.heartbeatTimeout = null;
  865. }
  866. },
  867. // 格式化时间
  868. formatTime(dateTimeStr) {
  869. // 创建 Date 对象
  870. const date = new Date(dateTimeStr);
  871. // 提取小时和分钟,并格式化为两位数
  872. const hours = String(date.getHours()).padStart(2, '0');
  873. const minutes = String(date.getMinutes()).padStart(2, '0');
  874. const time = `${hours}:${minutes}`;
  875. return time
  876. },
  877. // 上一页
  878. prevPage() {
  879. if (this.currentPage > 1) {
  880. this.currentPage--
  881. this.resetAutoScroll()
  882. }
  883. },
  884. // 下一页
  885. nextPage() {
  886. if (this.currentPage < this.totalPages) {
  887. this.currentPage++
  888. this.resetAutoScroll()
  889. }
  890. },
  891. // 触摸开始
  892. handleTouchStart(e) {
  893. this.touchStartX = e.touches[0].clientX
  894. this.touchStartY = e.touches[0].clientY
  895. this.touchStartTime = Date.now()
  896. this.isTouching = true
  897. // 停止自动滚动
  898. if (this.autoScroll) {
  899. this.stopAutoScroll()
  900. }
  901. },
  902. // 触摸移动
  903. handleTouchMove(e) {
  904. if (!this.isTouching) return
  905. },
  906. // 触摸结束
  907. handleTouchEnd(e) {
  908. if (!this.isTouching) return
  909. const touchEndX = e.changedTouches[0].clientX
  910. const touchEndY = e.changedTouches[0].clientY
  911. const touchEndTime = Date.now()
  912. const deltaX = touchEndX - this.touchStartX
  913. const deltaY = touchEndY - this.touchStartY
  914. const deltaTime = touchEndTime - this.touchStartTime
  915. // 防抖:限制滑动切换频率
  916. const now = Date.now()
  917. if (now - this.lastSwipeTime < 300) {
  918. this.isTouching = false
  919. return
  920. }
  921. // 判断滑动方向
  922. const absDeltaX = Math.abs(deltaX)
  923. const absDeltaY = Math.abs(deltaY)
  924. // 水平滑动优先
  925. if (absDeltaX > 50 && absDeltaX > absDeltaY && deltaTime < 500) {
  926. this.lastSwipeTime = now
  927. if (deltaX > 0) {
  928. // 向右滑动 - 上一页
  929. this.prevPage()
  930. } else {
  931. // 向左滑动 - 下一页
  932. this.nextPage()
  933. }
  934. }
  935. // 垂直滑动
  936. else if (absDeltaY > 50 && absDeltaY > absDeltaX && deltaTime < 500) {
  937. this.lastSwipeTime = now
  938. if (deltaY > 0) {
  939. // 向下滑动 - 上一页
  940. this.prevPage()
  941. } else {
  942. // 向上滑动 - 下一页
  943. this.nextPage()
  944. }
  945. }
  946. this.isTouching = false
  947. // 如果开启了自动滚动,重新启动
  948. if (this.autoScroll) {
  949. this.resetAutoScroll()
  950. }
  951. },
  952. // 添加鼠标滚轮支持(仅H5平台)
  953. //#ifdef H5
  954. addMouseWheelSupport() {
  955. const tableBody = document.querySelector('.table-body')
  956. if (tableBody) {
  957. this.wheelHandler = this.debounce((e) => {
  958. e.preventDefault()
  959. const delta = e.wheelDelta || -e.detail
  960. if (delta < 0) {
  961. // 向下滚动 - 下一页
  962. this.nextPage()
  963. } else {
  964. // 向上滚动 - 上一页
  965. this.prevPage()
  966. }
  967. }, 150)
  968. tableBody.addEventListener('mousewheel', this.wheelHandler, {
  969. passive: false
  970. })
  971. tableBody.addEventListener('DOMMouseScroll', this.wheelHandler, {
  972. passive: false
  973. })
  974. }
  975. },
  976. // 移除鼠标滚轮支持
  977. removeMouseWheelSupport() {
  978. const tableBody = document.querySelector('.table-body')
  979. if (tableBody && this.wheelHandler) {
  980. tableBody.removeEventListener('mousewheel', this.wheelHandler)
  981. tableBody.removeEventListener('DOMMouseScroll', this.wheelHandler)
  982. }
  983. },
  984. //#endif
  985. // 切换自动滚动
  986. toggleAutoScroll(e) {
  987. this.autoScroll = e.detail.value
  988. if (this.autoScroll) {
  989. this.startAutoScroll()
  990. } else {
  991. this.stopAutoScroll()
  992. }
  993. },
  994. // 开始自动滚动
  995. startAutoScroll() {
  996. this.stopAutoScroll()
  997. if (this.autoScroll && this.normalData.length > 0 && this.totalPages > 1) {
  998. this.autoScrollTimer = setInterval(() => {
  999. if (this.currentPage < this.totalPages) {
  1000. this.currentPage++
  1001. } else {
  1002. this.currentPage = 1 // 回到第一页
  1003. }
  1004. }, this.scrollInterval)
  1005. }
  1006. },
  1007. // 停止自动滚动
  1008. stopAutoScroll() {
  1009. if (this.autoScrollTimer) {
  1010. clearInterval(this.autoScrollTimer)
  1011. this.autoScrollTimer = null
  1012. }
  1013. },
  1014. // 重置自动滚动(手动操作后)
  1015. resetAutoScroll() {
  1016. if (this.autoScroll) {
  1017. this.stopAutoScroll()
  1018. setTimeout(() => {
  1019. this.startAutoScroll()
  1020. }, this.scrollInterval)
  1021. }
  1022. },
  1023. // 当前时间
  1024. updateCurrentTime() {
  1025. const now = new Date();
  1026. const year = now.getFullYear();
  1027. const month = String(now.getMonth() + 1).padStart(2, '0');
  1028. const day = String(now.getDate()).padStart(2, '0');
  1029. const hours = String(now.getHours()).padStart(2, '0');
  1030. const minutes = String(now.getMinutes()).padStart(2, '0');
  1031. const seconds = String(now.getSeconds()).padStart(2, '0');
  1032. this.currentTime = `${year}-${month}-${day}`;
  1033. this.hhmmss = `${hours}:${minutes}:${seconds}`;
  1034. const weekdays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
  1035. const weekday = weekdays[now.getDay()];
  1036. this.whatDay = weekday
  1037. },
  1038. },
  1039. watch: {
  1040. // 监听数据变化
  1041. allData: {
  1042. handler() {
  1043. this.$nextTick(() => {
  1044. this.validateCurrentPage()
  1045. if (this.autoScroll) {
  1046. this.startAutoScroll()
  1047. }
  1048. })
  1049. },
  1050. immediate: true
  1051. },
  1052. // 监听页面大小变化
  1053. pageSize() {
  1054. this.validateCurrentPage()
  1055. },
  1056. // 监听特殊状态数据变化
  1057. specialStatusData() {
  1058. // 特殊状态数据变化时重新计算页面大小
  1059. this.$nextTick(() => {
  1060. this.calculatePageSize()
  1061. })
  1062. },
  1063. // 重连次数
  1064. reconnectionNum: {
  1065. handler(newVal, oldVal) {
  1066. if (newVal >= 50) {
  1067. //#ifdef APP
  1068. plus.runtime.restart();
  1069. setTimeout(() => {
  1070. plus.navigator.closeSplashscreen();
  1071. }, 3000);
  1072. this.reconnectionNum = 0
  1073. //#endif
  1074. }
  1075. // console.log('对象属性变化', newVal, oldVal);
  1076. },
  1077. deep: true,
  1078. immediate: true
  1079. }
  1080. }
  1081. }
  1082. </script>
  1083. <style scoped>
  1084. .notice-board {
  1085. width: 100%;
  1086. height: 100vh;
  1087. display: flex;
  1088. flex-direction: column;
  1089. background-color: #0d54ec;
  1090. /* padding: 20rpx; */
  1091. box-sizing: border-box;
  1092. }
  1093. .card_logo {
  1094. position: absolute;
  1095. left: 30rpx;
  1096. top: 0;
  1097. bottom: 0;
  1098. display: flex;
  1099. align-items: center;
  1100. cursor: pointer;
  1101. }
  1102. .card_logo:focus {
  1103. border: none !important;
  1104. }
  1105. .board-header {
  1106. /* padding-top: 10rpx; */
  1107. text-align: center;
  1108. /* margin-bottom: 30rpx; */
  1109. position: relative;
  1110. display: flex;
  1111. align-items: center;
  1112. justify-content: center;
  1113. height: 130rpx;
  1114. flex-shrink: 0;
  1115. background-color: #0d54ec;
  1116. box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.2);
  1117. }
  1118. .card_yellow {
  1119. display: flex;
  1120. align-items: center;
  1121. justify-content: center;
  1122. width: fit-content;
  1123. width: -webkit-fit-content;
  1124. width: -moz-fit-content;
  1125. padding: 0rpx 40rpx;
  1126. height: 130rpx;
  1127. background-color: #e5cb8d;
  1128. border-bottom-right-radius: 300rpx;
  1129. border-top-left-radius: 300rpx;
  1130. }
  1131. .card_board {
  1132. display: flex;
  1133. align-items: center;
  1134. justify-content: center;
  1135. width: fit-content;
  1136. width: -webkit-fit-content;
  1137. width: -moz-fit-content;
  1138. padding: 0rpx 140rpx;
  1139. height: 130rpx;
  1140. background-color: #d5f3ff;
  1141. border-bottom-right-radius: 120rpx;
  1142. border-top-left-radius: 120rpx;
  1143. }
  1144. .board-title {
  1145. font-size: 60rpx;
  1146. font-weight: bold;
  1147. color: #0e6699;
  1148. }
  1149. .card_time {
  1150. position: absolute;
  1151. right: 30rpx;
  1152. top: 0;
  1153. bottom: 0;
  1154. display: flex;
  1155. flex-direction: column;
  1156. align-items: flex-end;
  1157. justify-content: center;
  1158. }
  1159. .time_title {
  1160. font-size: 60rpx;
  1161. color: #fff;
  1162. font-weight: bold;
  1163. line-height: 60rpx;
  1164. }
  1165. .current-time {
  1166. display: flex;
  1167. align-items: center;
  1168. display: flex;
  1169. font-size: 40rpx;
  1170. color: #fff;
  1171. font-weight: bold;
  1172. line-height: 45rpx;
  1173. }
  1174. .table-container {
  1175. flex: 1;
  1176. margin: 20rpx 20rpx 0rpx 20rpx;
  1177. border-radius: 24rpx;
  1178. overflow: hidden;
  1179. box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.08);
  1180. background-color: #ffffff;
  1181. display: flex;
  1182. flex-direction: column;
  1183. }
  1184. .table-header {
  1185. background-color: #71a0ee;
  1186. color: white;
  1187. font-weight: bold;
  1188. }
  1189. .table-row {
  1190. display: flex;
  1191. border-bottom: 2rpx solid #dedede;
  1192. }
  1193. .header-row {
  1194. /* background-color: #71a0ee; */
  1195. color: white;
  1196. font-weight: bold;
  1197. }
  1198. .table-cell {
  1199. padding: 24rpx 16rpx;
  1200. /* font-size: 50rpx; */
  1201. font-size: 66rpx;
  1202. font-weight: bold;
  1203. text-align: center;
  1204. color: #333;
  1205. display: flex;
  1206. justify-content: center;
  1207. align-items: center;
  1208. }
  1209. .cell {
  1210. padding: 24rpx 16rpx;
  1211. /* font-size: 50rpx; */
  1212. font-size: 60rpx;
  1213. text-align: center;
  1214. color: #333;
  1215. }
  1216. .name {
  1217. width: 25%;
  1218. white-space: nowrap;
  1219. text-overflow: ellipsis;
  1220. overflow: hidden;
  1221. display: flex;
  1222. align-items: center;
  1223. justify-content: center;
  1224. }
  1225. .time {
  1226. width: 25%;
  1227. display: flex;
  1228. align-items: center;
  1229. justify-content: center;
  1230. }
  1231. .status {
  1232. width: 25%;
  1233. }
  1234. .title_color {
  1235. color: #fff;
  1236. }
  1237. /* 特殊状态数据容器 */
  1238. .special-status-container {
  1239. background-color: #fff8e6;
  1240. }
  1241. .special-row {
  1242. background-color: #fff8e6 !important;
  1243. min-height: 60rpx;
  1244. line-height: 60rpx;
  1245. }
  1246. /* 分隔线 */
  1247. .divider {
  1248. height: 1rpx;
  1249. background-color: #ddd;
  1250. }
  1251. /* 普通数据内容区域 */
  1252. .table-body {
  1253. flex: 1;
  1254. overflow-y: auto;
  1255. /* 添加触摸反馈 */
  1256. -webkit-overflow-scrolling: touch;
  1257. touch-action: pan-y;
  1258. }
  1259. .title-observing {
  1260. color: #007bff;
  1261. }
  1262. .title-completed {
  1263. color: #28a745;
  1264. }
  1265. .title-warning {
  1266. color: #ff0000;
  1267. }
  1268. .title-hasleft {
  1269. color: #fa8c16;
  1270. }
  1271. /* 状态行样式 */
  1272. .status-observing {
  1273. background-color: #9dd6ff;
  1274. color: #007bff;
  1275. border: 6rpx solid #007bff;
  1276. }
  1277. .status-completed {
  1278. background-color: #e8f5e8;
  1279. color: #28a745;
  1280. border: 6rpx solid #28a745;
  1281. }
  1282. .status-warning {
  1283. background-color: #ffebee;
  1284. color: #ff0000;
  1285. border: 6rpx solid #ff0000;
  1286. }
  1287. .status-hasleft {
  1288. background-color: #fff2e8;
  1289. color: #fa8c16;
  1290. border: 6rpx solid #fa8c16;
  1291. }
  1292. .item-observing {
  1293. background-color: #9dd6ff;
  1294. }
  1295. .item-completed {
  1296. background-color: #e8f5e8;
  1297. }
  1298. .item-warning {
  1299. background-color: #ffebee;
  1300. }
  1301. .item-hasleft {
  1302. background-color: #fff2e8;
  1303. }
  1304. /* 状态标签样式 - 控制高度不超过40rpx */
  1305. .status-tag {
  1306. flex: none;
  1307. display: flex;
  1308. align-items: center;
  1309. justify-content: center;
  1310. border-radius: 50rpx;
  1311. font-size: 60rpx;
  1312. font-weight: bold;
  1313. width: 80%;
  1314. }
  1315. .empty-tip {
  1316. text-align: center;
  1317. padding: 80rpx;
  1318. color: #999;
  1319. font-size: 60rpx;
  1320. font-style: italic;
  1321. }
  1322. /* 底部 */
  1323. .card_foot {
  1324. display: flex;
  1325. align-items: center;
  1326. height: 120rpx;
  1327. box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.08);
  1328. }
  1329. .card_tips_box {
  1330. display: flex;
  1331. align-items: center;
  1332. padding-left: 15rpx;
  1333. }
  1334. .title_tips {
  1335. color: #fff;
  1336. font-size: 40rpx;
  1337. }
  1338. .title_tips_foot {
  1339. color: #fff;
  1340. flex: 1;
  1341. font-size: 40rpx;
  1342. border-radius: 20rpx;
  1343. margin-right: 20rpx;
  1344. background-color: #71a0ee;
  1345. padding: 20rpx;
  1346. }
  1347. .box_link_set {
  1348. position: fixed;
  1349. display: flex;
  1350. align-items: center;
  1351. justify-content: center;
  1352. width: 100%;
  1353. height: 100%;
  1354. background-color: rgba(0, 0, 0, 0.3);
  1355. }
  1356. .box_popup {
  1357. position: relative;
  1358. background-color: #ffffff;
  1359. border-radius: 8rpx;
  1360. }
  1361. .head_popup_title {
  1362. position: relative;
  1363. display: flex;
  1364. align-items: center;
  1365. justify-content: center;
  1366. }
  1367. .title_head_popup {
  1368. font-size: 40rpx;
  1369. font-weight: 500;
  1370. padding: 30rpx 30rpx 0rpx 30rpx;
  1371. }
  1372. .close_title {
  1373. position: absolute;
  1374. right: 30rpx;
  1375. font-size: 40rpx;
  1376. cursor: pointer;
  1377. }
  1378. /* // 连接状态栏 */
  1379. .status-bar-bottom {
  1380. display: flex;
  1381. flex-direction: column;
  1382. margin: 30rpx;
  1383. padding: 24rpx 30rpx;
  1384. background-color: #ffffff;
  1385. box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.05);
  1386. border-radius: 24rpx;
  1387. display: flex;
  1388. gap: 16rpx;
  1389. }
  1390. .status-text-box {
  1391. display: flex;
  1392. align-items: center;
  1393. font-size: 28rpx;
  1394. }
  1395. .dot {
  1396. color: #d32f2f;
  1397. font-size: 24rpx;
  1398. }
  1399. .dot-connecting {
  1400. color: #f9ae3d;
  1401. }
  1402. .dot-connected {
  1403. color: #28a745;
  1404. }
  1405. .dot-disconnected {
  1406. color: #d32f2f;
  1407. }
  1408. .status-text {
  1409. color: #555;
  1410. }
  1411. .status-text {
  1412. margin-left: 16rpx;
  1413. }
  1414. .title_color {
  1415. color: #fff;
  1416. }
  1417. /* // IP 输入区域 */
  1418. .ip-input-group {
  1419. display: flex;
  1420. align-items: center;
  1421. margin-left: 16rpx;
  1422. flex-wrap: wrap;
  1423. gap: 12rpx;
  1424. }
  1425. .ip-label {
  1426. font-size: 28rpx;
  1427. color: #555;
  1428. }
  1429. .ip-input,
  1430. .port-input {
  1431. border: 2rpx solid #ddd;
  1432. border-radius: 12rpx;
  1433. padding: 12rpx 20rpx;
  1434. font-size: 28rpx;
  1435. width: 200rpx;
  1436. }
  1437. .port-input {
  1438. width: 160rpx;
  1439. }
  1440. .colon {
  1441. font-size: 32rpx;
  1442. color: #666;
  1443. margin: 0 8rpx;
  1444. }
  1445. .btn-reconnect {
  1446. background-color: #007aff;
  1447. color: white;
  1448. font-size: 24rpx;
  1449. padding: 0 16rpx;
  1450. border-radius: 12rpx;
  1451. }
  1452. .logo_image {
  1453. width: 80rpx;
  1454. height: 80rpx;
  1455. }
  1456. .logo_title {
  1457. font-size: 50rpx;
  1458. color: #fff;
  1459. font-weight: bold;
  1460. margin-left: 15rpx;
  1461. }
  1462. .tips_imageil {
  1463. width: 70rpx;
  1464. height: 70rpx;
  1465. }
  1466. /* 响应式优化 */
  1467. @media screen and (max-height: 600px) {
  1468. .card_logo {
  1469. left: 10rpx;
  1470. }
  1471. .logo_title {
  1472. font-size: 18rpx;
  1473. margin-left: 5rpx;
  1474. }
  1475. .logo_image {
  1476. width: 35rpx;
  1477. height: 35rpx;
  1478. }
  1479. .table-row {
  1480. min-height: 35rpx;
  1481. line-height: 35rpx;
  1482. padding: 4rpx 0rpx;
  1483. }
  1484. .table-cell {
  1485. font-size: 22rpx;
  1486. padding: 30rpx 10rpx;
  1487. }
  1488. .status-tag {
  1489. padding: 4rpx 10rpx;
  1490. font-size: 18rpx;
  1491. height: 22rpx;
  1492. min-width: 80rpx;
  1493. }
  1494. .control-btn {
  1495. height: 50rpx;
  1496. line-height: 50rpx;
  1497. font-size: 22rpx;
  1498. }
  1499. .board-header {
  1500. height: 50rpx;
  1501. }
  1502. .card_yellow {
  1503. height: 50rpx;
  1504. padding: 0rpx 20rpx;
  1505. }
  1506. .card_board {
  1507. height: 50rpx;
  1508. padding: 0rpx 30rpx;
  1509. }
  1510. .board-title {
  1511. font-size: 20rpx;
  1512. }
  1513. .card_time {
  1514. right: 10rpx;
  1515. }
  1516. .time_title {
  1517. font-size: 20rpx;
  1518. line-height: 20rpx;
  1519. }
  1520. .current-time {
  1521. font-size: 15rpx;
  1522. line-height: 15rpx;
  1523. }
  1524. .table-container {
  1525. margin: 10rpx 10rpx 0rpx 10rpx;
  1526. border-radius: 8rpx;
  1527. }
  1528. .table-cell {
  1529. padding: 2rpx 5rpx;
  1530. font-size: 18rpx;
  1531. }
  1532. .cell {
  1533. padding: 2rpx 5rpx;
  1534. font-size: 18rpx;
  1535. }
  1536. .card_foot {
  1537. height: 50rpx;
  1538. }
  1539. .title_tips {
  1540. font-size: 15rpx;
  1541. }
  1542. .title_tips_foot {
  1543. font-size: 15rpx;
  1544. padding: 2rpx 2rpx 2rpx 5rpx;
  1545. border-radius: 8rpx;
  1546. margin-right: 10rpx;
  1547. }
  1548. .tips_imageil {
  1549. width: 30rpx;
  1550. height: 30rpx;
  1551. }
  1552. .card_tips_box {
  1553. padding-left: 10rpx;
  1554. }
  1555. .empty-tip {
  1556. padding: 40rpx;
  1557. font-size: 20rpx;
  1558. }
  1559. }
  1560. /* 滚动条样式 */
  1561. .table-body::-webkit-scrollbar {
  1562. width: 4rpx;
  1563. }
  1564. .table-body::-webkit-scrollbar-track {
  1565. background: #f1f1f1;
  1566. border-radius: 6rpx;
  1567. }
  1568. .table-body::-webkit-scrollbar-thumb {
  1569. background: #c1c1c1;
  1570. border-radius: 6rpx;
  1571. }
  1572. .table-body::-webkit-scrollbar-thumb:hover {
  1573. background: #a8a8a8;
  1574. }
  1575. </style>