| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681 |
- <template>
- <view class="notice-board">
- <view class="board-header">
- <view class="card_logo" @click="linkSet">
- <!-- <view class="card_logo"> -->
- <image class="logo_image" src="/static/logo.png" mode=""></image>
- <view class="logo_title">观山湖区疾控中心</view>
- </view>
- <view class="card_yellow">
- <view class="card_board">
- <text class="board-title">接种留观等待</text>
- </view>
- </view>
- <view class="card_time">
- <view class="time_title">{{hhmmss}}</view>
- <view class="current-time">{{ currentTime }} {{whatDay}}</view>
- </view>
- </view>
- <!-- 表格容器 -->
- <view class="table-container">
- <!-- 主要表头 -->
- <view class="table-header">
- <view class="table-row header-row">
- <view class="cell name title_color">姓名</view>
- <view class="cell time title_color">留观时间</view>
- <view class="cell time title_color">离开时间</view>
- <view class="cell status title_color">状态</view>
- </view>
- </view>
- <!-- 特殊状态数据展示区域(固定) -->
- <view class="special-status-container" v-if="specialStatusData.length > 0">
- <view class="table-row" v-for="(item, index) in specialStatusData" :key="item.id"
- :class="`item-${getClass(item.status)}`">
- <view class="table-cell name" :class="`title-${getClass(item.status)}`">{{ item.patientName }}
- </view>
- <view class="table-cell time">{{ formatTime(item.createTime) }}</view>
- <view class="table-cell time">{{ formatTime(item.outTime) }}</view>
- <view class="table-cell status" style="padding: 0rpx;">
- <view class="status-tag" :class="`status-${getClass(item.status)}`">
- {{ getStatusText(item) }}
- </view>
- </view>
- </view>
- <!-- 分隔线 -->
- <view class="divider"></view>
- </view>
- <!-- 普通数据内容区域 -->
- <view class="table-body" @touchstart="handleTouchStart" @touchmove="handleTouchMove"
- @touchend="handleTouchEnd">
- <view class="table-row" v-for="(item, index) in displayNormalData" :key="item.id"
- :class="`item-${getClass(item.status)}`">
- <view class="table-cell name" :class="`title-${getClass(item.status)}`">{{ item.patientName }}
- </view>
- <view class="table-cell time">{{ formatTime(item.createTime) }}</view>
- <view class="table-cell time">{{ formatTime(item.outTime) }}</view>
- <view class="table-cell status" style="padding: 0rpx;">
- <view class="status-tag" :class="`status-${getClass(item.status)}`">
- {{ getStatusText(item) }}
- </view>
- </view>
- </view>
- <!-- 空数据提示 -->
- <view v-if="displayNormalData.length === 0 && specialStatusData.length === 0" class="empty-tip">
- 暂无留观人员信息
- </view>
- </view>
- </view>
- <view class="card_foot">
- <view class="card_tips_box">
- <image class="tips_imageil" src="/static/horn.png" mode=""></image>
- <view class="title_tips">温馨提示:</view>
- </view>
- <view class="title_tips_foot">请注意留观30分钟后无不良反应后再离开,谢谢。</view>
- </view>
- <!-- 连接状态与IP编辑栏 -->
- <view class="box_link_set" v-if="linkShow">
- <view class="box_popup" :style="{bottom: keyboardHeight + 'px'}">
- <view class="head_popup_title">
- <view class="title_head_popup">连接设置</view>
- <view class="close_title" @click="getClose">×</view>
- </view>
- <view class="status-bar-bottom" :class="`status-${connectionStatus}`">
- <!-- IP 编辑区域 -->
- <view class="ip-input-group">
- <text class="ip-label">IP:</text>
- <input type="text" :value="serverIp" @input="onIpInput" placeholder="192.168.0.41"
- class="ip-input" @keyboardheightchange="keyboardheightchange" />
- <text class="colon">:</text>
- <input type="number" :value="serverPort" @input="onPortInput" placeholder="8811"
- class="port-input" @keyboardheightchange="keyboardheightchange" />
- <button class="btn-reconnect" size="mini" @click="handleReconnect">
- 更新并重连
- </button>
- </view>
- <view class="status-text-box">
- <text class="dot" :class="`dot-${connectionStatus}`">●</text>
- <text class="status-text">{{ connectionText }}</text>
- </view>
- </view>
- </view>
- </view>
- </view>
- </template>
- <script>
- export default {
- data() {
- return {
- currentTime: '', // 当前时间
- hhmmss: '', // 当前时间
- whatDay: '', //星期几
- timeTimer: null, // 时间更新定时器
- // WebSocket 实例
- ws: null,
- reconnectTimer: null,
- heartbeatTimer: null, // 心跳定时器
- heartbeatTimeout: null, // 心跳响应超时计时器
- heartbeatInterval: 30000, // 30秒发一次心跳
- heartbeatTimeoutTime: 10000, // 10秒内未响应视为超时
- isPongReceived: true, // 标记是否收到 pong
- // 连接状态
- connectionStatus: 'connecting', // connecting, connected, disconnected
- statusText: {
- observing: '留观中',
- completed: '留观完成,可离开',
- warning: '提前离开',
- hasleft: '已离开',
- },
- // 可编辑的服务器地址
- serverIp: '192.168.0.41',
- serverPort: '8811',
- // 所有数据留观数据列表
- allData: [],
- // 当前页码
- currentPage: 1,
- // 每页显示条数(自适应计算)
- pageSize: 10,
- // 是否自动滚动
- autoScroll: true,
- // 自动滚动定时器
- autoScrollTimer: null,
- // 滚动间隔时间(毫秒)
- scrollInterval: 6000,
- // 窗口高度
- windowHeight: 0,
- // 触摸相关
- touchStartX: 0,
- touchStartTime: 0,
- isTouching: false,
- touchStartY: 0,
- // 垂直滑动相关
- lastSwipeTime: 0,
- linkShow: false,
- ipArray: [],
- keyboardHeight: 0,
- now: new Date(),
- timer: null,
- heartbeatRate: 0,
- isSend: true,
- reconnectionNum: 0,
- TTSSpeech: null,
- speechQueue: [], // 语音队列
- isSpeaking: false, // 是否正在播放语音
- }
- },
- computed: {
- connectionText() {
- const {
- connectionStatus,
- serverIp,
- serverPort
- } = this;
- return {
- connecting: `正在连接 ${serverIp}:${serverPort}...`,
- connected: `已连接到 ${serverIp}:${serverPort}`,
- disconnected: `连接已断开`
- } [connectionStatus];
- },
- // 特殊状态数据(状态1和2)
- specialStatusData() {
- return this.allData.filter(item => item.status === 3 || item.status === 4 || item.IsOut == 1)
- },
- // 普通状态数据(状态0)
- normalData() {
- return this.allData.filter(item => item.status === 0 || item.status === 1)
- },
- // 实际显示的条数(减去缓冲行)
- actualPageSize() {
- return Math.max(1, this.pageSize - 1)
- },
- // 总页数(基于普通数据计算)
- totalPages() {
- if (this.actualPageSize <= 0 || this.normalData.length === 0) return 1
- return Math.ceil(this.normalData.length / this.actualPageSize)
- },
- // 显示的普通数据(包含缓冲行)
- displayNormalData() {
- const start = (this.currentPage - 1) * this.actualPageSize
- const end = Math.min(start + this.pageSize, this.normalData.length)
- return this.normalData.slice(start, end).map(item => {
- const expectedTime = new Date(item.outTime)
- const timeDiff = expectedTime - this.now // 毫秒差
- if (timeDiff > 0) {
- // 未到离开时间
- const days = Math.floor(timeDiff / (24 * 3600 * 1000))
- const hours = Math.floor((timeDiff % (24 * 3600 * 1000)) / (3600 * 1000))
- const minutes = Math.floor((timeDiff % (3600 * 1000)) / (60 * 1000))
- const seconds = Math.floor((timeDiff % (60 * 1000)) / 1000)
- var fzArr = ''
- if (minutes) {
- fzArr = `剩余${minutes}分钟`
- } else {
- fzArr = `剩余${seconds}秒`
- }
- return {
- ...item,
- remainingTime: fzArr,
- }
- } else {
- return {
- ...item,
- remainingTime: '留观完成,可离开',
- }
- }
- })
- }
- },
- onLoad() {
- //#ifdef APP
- // 引入插件
- this.TTSSpeech = uni.requireNativePlugin('MT-TTS-Speech');
- // 初始化TTS引擎
- this.TTSSpeech.init((status) => { // 👈 同样移除 : number
- if (status === 0) {
- console.log('TTS引擎初始化成功');
- } else {
- console.error('TTS引擎初始化失败,状态码:', status)
- }
- }, 'com.iflytek.speechcloud')
- this.TTSSpeech.setPitch(50)
- this.TTSSpeech.setSpeed(50)
- //#endif
- },
- mounted() {
- this.reconnectionNum = 0
- // 每秒更新当前时间
- this.timer = setInterval(() => {
- this.now = new Date()
- }, 1000)
- this.getIpAddress()
- this.initPage()
- const lastIp = uni.getStorageSync('serverIp');
- const lastPort = uni.getStorageSync('serverPort');
- if (lastIp && lastPort) {
- this.serverIp = lastIp
- this.serverPort = lastPort
- }
- // 在H5平台添加鼠标滚轮支持
- //#ifdef H5
- this.startCheck()
- this.addMouseWheelSupport()
- //#endif
- this.updateCurrentTime(); // 立即更新一次时间
- this.timeTimer = setInterval(() => {
- this.updateCurrentTime();
- }, 1000); // 每秒更新一次时间
- },
- beforeDestroy() {
- // 组件销毁时清理定时器
- if (this.timer) {
- this.timer = null
- clearInterval(this.timer)
- }
- this.stopAutoScroll()
- uni.offWindowResize(this.handleWindowResize)
- //#ifdef H5
- this.removeMouseWheelSupport()
- //#endif
- if (this.ws) {
- this.ws.close();
- }
- if (this.reconnectTimer) {
- clearTimeout(this.reconnectTimer);
- }
- this.stopHeartbeat();
- // 清理时间更新定时器
- if (this.timeTimer) {
- clearInterval(this.timeTimer);
- }
- },
- methods: {
- // 将文本添加到队列
- addToSpeechQueue(textValue) {
- if (textValue && typeof textValue === 'string') {
- this.speechQueue.push(textValue);
- // 如果没有正在播放,则开始播放
- if (!this.isSpeaking) {
- this.processSpeechQueue();
- }
- } else {
- console.warn('Invalid text value for speech:', textValue);
- }
- },
- // 处理语音队列
- async processSpeechQueue() {
- if (this.speechQueue.length === 0) {
- this.isSpeaking = false;
- return;
- }
- this.isSpeaking = true;
- const textToSpeak = this.speechQueue.shift(); // 取出队列第一个元素
- try {
- // 等待当前语音播放完成 (通过 onDone 回调)
- await this.speak(textToSpeak);
- } catch (error) {
- console.error('TTS speak error for text:', textToSpeak, error);
- // 即使当前项播放失败,也继续处理队列中的下一个
- } finally {
- this.processSpeechQueue();
- }
- },
- // 执行实际的语音播放 (返回 Promise)
- speak(textValue) {
- return new Promise((resolve, reject) => {
- try{
- setTimeout(() => reject(new Error('Operation timed out')), 8000);
- if (!this.TTSSpeech) {
- //#ifdef APP
- // 引入插件
- this.TTSSpeech = uni.requireNativePlugin('MT-TTS-Speech');
- // 初始化TTS引擎
- this.TTSSpeech.init((status) => { // 👈 同样移除 : number
- if (status === 0) {
- console.log('TTS引擎初始化成功');
- } else {
- console.error('TTS引擎初始化失败,状态码:', status)
- }
- }, 'com.iflytek.speechcloud')
- this.TTSSpeech.setPitch(50)
- this.TTSSpeech.setSpeed(50)
- //#endif
-
- reject(new Error('TTS engine not initialized.'));
- return;
- }
- // 监听 TTS 播放结束事件
- this.TTSSpeech.onDone(() => {
- resolve(); // 播放完成,Promise 成功
- });
- // 尝试启动语音播放
- const speakOptions = {
- text: textValue,
- };
- const res = this.TTSSpeech.speak(speakOptions);
- if (res != 0){
- this.TTSSpeech.destroy()
- this.TTSSpeech = null
- }
- } catch (e) {
- resolve();
- }
-
- });
- },
- keyboardheightchange() {
- uni.onKeyboardHeightChange(res => {
- this.keyboardHeight = res.height
- })
- },
- findFirstWebSocketIp(ipList, index = 0, onSuccess, onAllFailed) {
- // 终止条件:全部失败
- if (index >= ipList.length) {
- if (typeof onAllFailed === 'function') {
- onAllFailed();
- }
- return;
- }
- // console.log(ipList, 344)
- const ip = ipList[index].trim();
- const port = this.serverPort; // 根据你的服务修改端口
- const url = `ws://${ip}:${port}/`; // 可改为 /ws, /device 等路径
- // console.log(`🔍 正在尝试连接 WebSocket: ${url}`);
- // 先确保没有遗留连接
- if (this.ws) {
- this.ws.close();
- this.ws = null;
- }
- // 创建 SocketTask
- this.ws = uni.connectSocket({
- url: url,
- success: (res) => {
- console.log('connectSocket success', res);
- },
- fail: (err) => {
- console.error('connectSocket 失败', err);
- this.connectionStatus = 'disconnected';
- if (this.serverIp == ip) {
- this.heartbeatRate = 0
- this.reconnectionNum++
- //#ifdef H5
- this.isSend = true
- this.findFirstWebSocketIp([this.serverIp], index, onSuccess, onAllFailed);
- //#endif
- }
- //#ifdef APP
- this.reconnectionNum++
- this.isSend = true
- this.heartbeatRate = 0
- this.findFirstWebSocketIp(ipList, index + 1, onSuccess, onAllFailed);
- //#endif
- }
- });
- // --- WebSocket 事件监听 ---
- this.ws.onOpen((res) => {
- this.serverIp = ip
- uni.setStorageSync('serverIp', this.serverIp);
- uni.setStorageSync('serverPort', this.serverPort);
- // console.log('WebSocket 连接成功', res);
- this.connectionStatus = 'connected';
- this.ws.send({
- data: 'link',
- });
- onSuccess(ip);
- // ✅ 连接成功后启动心跳
- this.startHeartbeat();
- uni.hideLoading();
- });
- this.ws.onMessage((res) => {
- // ✅ 处理心跳响应 pong
- if (res.data === 'PONG' || res.data === '{"type":"PONG"}') {
- uni.hideToast();
- this.isSend = true
- this.reconnectionNum = 0
- this.heartbeatRate = 0
- this.isPongReceived = true;
- // console.log('收到 pong,心跳正常');
- // ✅ 清除等待 pong 的超时计时器
- if (this.heartbeatTimeout) {
- clearTimeout(this.heartbeatTimeout);
- this.heartbeatTimeout = null;
- }
- return;
- }
- try {
- const data = JSON.parse(res.data);
- this.handleMessage(data);
- } catch (e) {
- console.warn('非 JSON 消息,已忽略', res.data);
- }
- });
- this.ws.onClose((res) => {
- console.log('WebSocket 连接关闭', res);
- this.connectionStatus = 'disconnected';
- this.stopHeartbeat(); // 停止心跳
- var reconnectionTime = setTimeout(() => {
- clearTimeout(reconnectionTime)
- if (this.serverIp == ip) {
- //#ifdef H5
- this.isSend = true
- this.heartbeatRate = 0
- this.reconnectionNum++
- this.findFirstWebSocketIp([this.serverIp], index, onSuccess, onAllFailed);
- //#endif
- }
- //#ifdef APP
- this.isSend = true
- this.heartbeatRate = 0
- this.reconnectionNum++
- this.findFirstWebSocketIp(ipList, index + 1, onSuccess, onAllFailed);
- //#endif
- }, 2000)
- });
- this.ws.onError((err) => {
- console.error('WebSocket 错误', err);
- this.connectionStatus = 'disconnected';
- //#ifdef APP
- this.isSend = true
- this.heartbeatRate = 0
- this.reconnectionNum++
- this.findFirstWebSocketIp(ipList, index + 1, onSuccess, onAllFailed);
- //#endif
- });
- },
- // 连接设置
- linkSet() {
- this.linkShow = true
- },
- // 关闭弹窗
- getClose() {
- this.linkShow = false
- },
- // 获取ip地址
- getIpAddress() {
- //获取插件示例
- //#ifdef APP
- var deviceFinder = uni.requireNativePlugin("Alikes-NetTools-DeviceFinder")
- deviceFinder.scan({}, (res) => {
- this.ipArray = res
- this.ipArray.unshift(this.serverIp)
- this.startCheck()
- })
- //#endif
- },
- // 启动检查
- startCheck() {
- var ipArray = []
- //#ifdef H5
- ipArray = [this.serverIp]
- //#endif
- //#ifdef APP
- ipArray = this.ipArray
- //#endif
- this.findFirstWebSocketIp(
- ipArray,
- 0,
- (ip) => {
- // ✅ 找到可用设备
- this.onDeviceFound(ip);
- this.linkShow = false
- },
- () => {
- // ❌ 全部失败
- this.onNoDevice();
- }
- );
- },
- // 找到设备后的逻辑
- onDeviceFound(ip) {
- uni.showToast({
- title: '连接成功',
- icon: 'success'
- });
- // console.log(`🚀 可用设备: ${ip},开始后续操作...`);
- // 例如:跳转控制页面、订阅消息等
- },
- // 无设备可用
- onNoDevice() {
- uni.showToast({
- title: '无设备响应',
- icon: 'none',
- duration: 3000
- });
- this.getIpAddress()
- // console.log('🚨 所有设备 WebSocket 均无法连接');
- },
- getClass(status) {
- var title = '';
- if (status == 0) {
- title = 'observing'
- } else if (status == 1) {
- title = 'completed'
- } else if (status == 3) {
- title = 'warning'
- } else if (status == 4) {
- title = 'hasleft'
- }
- return title
- },
- // 获取状态文本
- getStatusText(item) {
- if (item.status == 0) {
- return item.remainingTime
- // 每秒更新一次
- } else if (item.status == 1) {
- return this.statusText.completed
- } else if (item.status == 3) {
- return this.statusText.warning
- } else if (item.status == 4) {
- return this.statusText.hasleft
- }
- },
- // 初始化页面
- initPage() {
- this.calculatePageSize()
- this.handleWindowResize = this.debounce(this.calculatePageSize, 300)
- uni.onWindowResize(this.handleWindowResize)
- },
- // 计算自适应的页面大小
- calculatePageSize() {
- try {
- // 获取系统信息
- const systemInfo = uni.getSystemInfoSync()
- this.windowHeight = systemInfo.windowHeight
- // 计算可用高度
- const headerHeight = uni.upx2px(130) // 头部高度
- const paginationHeight = uni.upx2px(100) // 分页区域高度
- const tableHeaderHeight = uni.upx2px(120) // 表格头部高度
- const specialDataHeight = this.specialStatusData.length > 0 ?
- uni.upx2px(80 * this.specialStatusData.length) : 0 // 特殊数据区域高度 + 分隔线
- const padding = uni.upx2px(0) // 上下padding
- const availableHeight = this.windowHeight - headerHeight - paginationHeight -
- tableHeaderHeight - specialDataHeight - padding
- // 计算行高(px)- 固定60rpx
- const rowHeightPx = uni.upx2px(140)
- // 计算可显示的行数
- const calculatedPageSize = Math.floor(availableHeight / rowHeightPx)
- // 设置页面大小(最少显示4行,最多显示30行)
- this.pageSize = Math.max(4, Math.min(30, calculatedPageSize))
- // 验证当前页
- this.validateCurrentPage()
- } catch (error) {
- console.error('计算页面大小失败:', error)
- this.pageSize = 10 // 默认值
- }
- },
- // 防抖函数
- debounce(func, wait) {
- let timeout
- return function executedFunction(...args) {
- const later = () => {
- clearTimeout(timeout)
- func(...args)
- }
- clearTimeout(timeout)
- timeout = setTimeout(later, wait)
- }
- },
- // 验证当前页是否有效
- validateCurrentPage() {
- if (this.currentPage > this.totalPages && this.totalPages > 0) {
- this.currentPage = this.totalPages
- }
- if (this.currentPage < 1) {
- this.currentPage = 1
- }
- },
- // 构建 WebSocket URL
- getWebSocketUrl() {
- return `ws://${this.serverIp}:${this.serverPort}`;
- },
- onIpInput(value) {
- this.serverIp = value.detail.value
- },
- // 输入
- onPortInput(value) {
- this.serverPort = value.detail.value
- },
- // 处理重连
- handleReconnect() {
- uni.showLoading({
- title: '正在重连...'
- });
- this.linkShow = false
- this.connectionStatus = 'connecting';
- // 关闭旧连接
- if (this.ws) {
- this.ws.close({
- success: () => {
- // console.log('旧连接已关闭');
- //#ifdef H5
- this.startCheck()
- //#endif
- //#ifdef APP
- this.getIpAddress()
- //#endif
- },
- fail: () => {
- //#ifdef H5
- this.startCheck()
- //#endif
- //#ifdef APP
- this.getIpAddress()
- //#endif
- }
- });
- } else {
- //#ifdef H5
- this.startCheck()
- //#endif
- //#ifdef APP
- this.getIpAddress()
- //#endif
- }
- setTimeout(() => {
- uni.hideLoading();
- }, 2000);
- },
- // 处理收到的消息
- handleMessage(data) {
- // 1. 全量数据:数组(不是对象,或没有 action 字段)
- if (data.action == 'link') {
- this.updateListWithArray(data.data);
- return;
- }
- // 2. 增量操作:对象
- if (typeof data === 'object' && data !== null) {
- const action = data.action;
- if (!action) {
- console.warn('消息缺少 action 字段', data);
- return;
- }
- switch (action) {
- case 'add':
- case 'create':
- this.batchAdd(data.data);
- break;
- case 'update':
- this.batchUpdate(data.data);
- break;
- case 'remove':
- case 'delete':
- this.batchRemove(data.data);
- break;
- case 'msg':
- // 使用队列播放语音
- this.addToSpeechQueue(data.msg)
- break;
- default:
- console.warn('未知操作类型:', action);
- }
- } else {
- console.warn('收到未知格式消息:', data);
- }
- },
- // 批量新增
- batchAdd(data) {
- if (!data) return;
- const items = Array.isArray(data) ? data : [data]; // 兼容单条
- const validItems = items.filter(item => item && item.id !== undefined);
- if (validItems.length === 0) {
- console.warn('没有有效数据用于新增', data);
- return;
- }
- // 避免重复添加
- const updated = [...this.allData];
- validItems.forEach(item => {
- const index = updated.findIndex(i => i.id == item.id);
- if (index > -1) {
- // 已存在 → 更新
- updated[index] = {
- ...updated[index],
- ...item
- };
- } else {
- updated.unshift(item);
- }
- });
- this.allData = updated;
- },
- // 批量修改
- batchUpdate(data) {
- if (!data) return;
- const items = Array.isArray(data) ? data : [data]; // 支持单条或数组
- const updated = [...this.allData];
- items.forEach(item => {
- if (!item || item.id === undefined) return;
- const index = updated.findIndex(i => i.id == item.id);
- if (index > -1) {
- updated[index] = {
- ...updated[index],
- ...item
- };
- }
- // else { this.allData.push(item); } // 可选:当作新增
- });
- this.allData = updated;
- },
- // 批量删除
- batchRemove(data) {
- let idsToRemove = [];
- if (Array.isArray(data.id)) {
- // remove: { id: [1,2,3] }
- idsToRemove = data.id;
- } else if (data.id !== undefined) {
- // remove: { id: 1 }
- idsToRemove = [data.id];
- } else if (Array.isArray(data.data)) {
- // remove: { data: [ {id:1}, {id:2} ] }
- idsToRemove = data.data.map(item => item.id).filter(id => id !== undefined);
- } else {
- idsToRemove = [data.id];
- console.warn('无法解析删除指令', data);
- return;
- }
- if (idsToRemove.length === 0) return;
- const updated = this.allData.filter(item => !idsToRemove.includes(item.id));
- this.allData = updated;
- },
- updateListWithArray(newList) {
- this.allData = []
- if (!Array.isArray(newList)) return;
- const map = new Map();
- newList.forEach(item => {
- if (item.id !== undefined) {
- map.set(item.id, item);
- }
- });
- const updated = [...this.allData];
- // 更新或新增
- for (const [id, item] of map.entries()) {
- const index = updated.findIndex(i => i.id == id);
- if (index > -1) {
- updated[index] = item;
- } else {
- updated.push(item);
- }
- }
- const final = updated.filter(item => map.has(item.id));
- this.allData = final;
- },
- // 断线重连(指数退避)
- reconnect() {
- if (this.reconnectTimer) return; // 防止重复定时器
- this.reconnectTimer = setTimeout(() => {
- // console.log('正在尝试重新连接...');
- this.connectWebSocket();
- this.reconnectTimer = null;
- }, 3000);
- },
- // 启动心跳
- startHeartbeat() {
- // 清除旧心跳
- var that = this
- that.stopHeartbeat();
- // 发送 ping 的定时器
- that.heartbeatTimer = setInterval(() => {
- if (that.ws && that.connectionStatus === 'connected') {
- // 检查上一次的 pong 是否已收到,未收到则判定为断线
- if (!that.isSend && !that.isPongReceived) {
- that.heartbeatRate++
- console.warn(that.heartbeatRate, '上一次心跳未收到 pong,可能已断线新');
- if (that.heartbeatRate >= 3) {
- that.ws.close();
- return;
- }
- }
- // 发送心跳
- that.isPongReceived = false; // 等待 pong
- // 设置超时检测:10秒内没收到 pong 就断开
- that.heartbeatTimeout = setTimeout(() => {
- if (that.isSend && !that.isPongReceived) {
- console.warn('⚠️ 心跳超时:未在规定时间内收到 pong,即将重连');
- uni.showToast({
- title: '连接异常,正在重连...',
- icon: 'none',
- duration: 2000
- });
- that.ws.close(); // 触发 onClose → 重连
- }
- }, that.heartbeatTimeoutTime); // 使用配置的超时时间(如 10000ms)
- if (that.isSend) {
- that.ws.send({
- data: 'PING',
- success: () => {
- // console.log('ping 已发送');
- that.isSend = false
- },
- fail: (err) => {
- console.error('ping 发送失败', err);
- that.ws.close();
- }
- });
- }
- }
- }, that.heartbeatInterval);
- // 可选:启动响应超时检测(更严格)
- // 通常靠"发 ping 后未收到 pong"即可判断
- },
- // 停止心跳(断开连接时调用)
- stopHeartbeat() {
- if (this.heartbeatTimer) {
- clearInterval(this.heartbeatTimer);
- this.heartbeatTimer = null;
- }
- if (this.heartbeatTimeout) {
- clearTimeout(this.heartbeatTimeout);
- this.heartbeatTimeout = null;
- }
- },
- // 格式化时间
- formatTime(dateTimeStr) {
- // 创建 Date 对象
- const date = new Date(dateTimeStr);
- // 提取小时和分钟,并格式化为两位数
- const hours = String(date.getHours()).padStart(2, '0');
- const minutes = String(date.getMinutes()).padStart(2, '0');
- const time = `${hours}:${minutes}`;
- return time
- },
- // 上一页
- prevPage() {
- if (this.currentPage > 1) {
- this.currentPage--
- this.resetAutoScroll()
- }
- },
- // 下一页
- nextPage() {
- if (this.currentPage < this.totalPages) {
- this.currentPage++
- this.resetAutoScroll()
- }
- },
- // 触摸开始
- handleTouchStart(e) {
- this.touchStartX = e.touches[0].clientX
- this.touchStartY = e.touches[0].clientY
- this.touchStartTime = Date.now()
- this.isTouching = true
- // 停止自动滚动
- if (this.autoScroll) {
- this.stopAutoScroll()
- }
- },
- // 触摸移动
- handleTouchMove(e) {
- if (!this.isTouching) return
- },
- // 触摸结束
- handleTouchEnd(e) {
- if (!this.isTouching) return
- const touchEndX = e.changedTouches[0].clientX
- const touchEndY = e.changedTouches[0].clientY
- const touchEndTime = Date.now()
- const deltaX = touchEndX - this.touchStartX
- const deltaY = touchEndY - this.touchStartY
- const deltaTime = touchEndTime - this.touchStartTime
- // 防抖:限制滑动切换频率
- const now = Date.now()
- if (now - this.lastSwipeTime < 300) {
- this.isTouching = false
- return
- }
- // 判断滑动方向
- const absDeltaX = Math.abs(deltaX)
- const absDeltaY = Math.abs(deltaY)
- // 水平滑动优先
- if (absDeltaX > 50 && absDeltaX > absDeltaY && deltaTime < 500) {
- this.lastSwipeTime = now
- if (deltaX > 0) {
- // 向右滑动 - 上一页
- this.prevPage()
- } else {
- // 向左滑动 - 下一页
- this.nextPage()
- }
- }
- // 垂直滑动
- else if (absDeltaY > 50 && absDeltaY > absDeltaX && deltaTime < 500) {
- this.lastSwipeTime = now
- if (deltaY > 0) {
- // 向下滑动 - 上一页
- this.prevPage()
- } else {
- // 向上滑动 - 下一页
- this.nextPage()
- }
- }
- this.isTouching = false
- // 如果开启了自动滚动,重新启动
- if (this.autoScroll) {
- this.resetAutoScroll()
- }
- },
- // 添加鼠标滚轮支持(仅H5平台)
- //#ifdef H5
- addMouseWheelSupport() {
- const tableBody = document.querySelector('.table-body')
- if (tableBody) {
- this.wheelHandler = this.debounce((e) => {
- e.preventDefault()
- const delta = e.wheelDelta || -e.detail
- if (delta < 0) {
- // 向下滚动 - 下一页
- this.nextPage()
- } else {
- // 向上滚动 - 上一页
- this.prevPage()
- }
- }, 150)
- tableBody.addEventListener('mousewheel', this.wheelHandler, {
- passive: false
- })
- tableBody.addEventListener('DOMMouseScroll', this.wheelHandler, {
- passive: false
- })
- }
- },
- // 移除鼠标滚轮支持
- removeMouseWheelSupport() {
- const tableBody = document.querySelector('.table-body')
- if (tableBody && this.wheelHandler) {
- tableBody.removeEventListener('mousewheel', this.wheelHandler)
- tableBody.removeEventListener('DOMMouseScroll', this.wheelHandler)
- }
- },
- //#endif
- // 切换自动滚动
- toggleAutoScroll(e) {
- this.autoScroll = e.detail.value
- if (this.autoScroll) {
- this.startAutoScroll()
- } else {
- this.stopAutoScroll()
- }
- },
- // 开始自动滚动
- startAutoScroll() {
- this.stopAutoScroll()
- if (this.autoScroll && this.normalData.length > 0 && this.totalPages > 1) {
- this.autoScrollTimer = setInterval(() => {
- if (this.currentPage < this.totalPages) {
- this.currentPage++
- } else {
- this.currentPage = 1 // 回到第一页
- }
- }, this.scrollInterval)
- }
- },
- // 停止自动滚动
- stopAutoScroll() {
- if (this.autoScrollTimer) {
- clearInterval(this.autoScrollTimer)
- this.autoScrollTimer = null
- }
- },
- // 重置自动滚动(手动操作后)
- resetAutoScroll() {
- if (this.autoScroll) {
- this.stopAutoScroll()
- setTimeout(() => {
- this.startAutoScroll()
- }, this.scrollInterval)
- }
- },
- // 当前时间
- updateCurrentTime() {
- const now = new Date();
- const year = now.getFullYear();
- const month = String(now.getMonth() + 1).padStart(2, '0');
- const day = String(now.getDate()).padStart(2, '0');
- const hours = String(now.getHours()).padStart(2, '0');
- const minutes = String(now.getMinutes()).padStart(2, '0');
- const seconds = String(now.getSeconds()).padStart(2, '0');
- this.currentTime = `${year}-${month}-${day}`;
- this.hhmmss = `${hours}:${minutes}:${seconds}`;
- const weekdays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
- const weekday = weekdays[now.getDay()];
- this.whatDay = weekday
- },
- },
- watch: {
- // 监听数据变化
- allData: {
- handler() {
- this.$nextTick(() => {
- this.validateCurrentPage()
- if (this.autoScroll) {
- this.startAutoScroll()
- }
- })
- },
- immediate: true
- },
- // 监听页面大小变化
- pageSize() {
- this.validateCurrentPage()
- },
- // 监听特殊状态数据变化
- specialStatusData() {
- // 特殊状态数据变化时重新计算页面大小
- this.$nextTick(() => {
- this.calculatePageSize()
- })
- },
- // 重连次数
- reconnectionNum: {
- handler(newVal, oldVal) {
- if (newVal >= 50) {
- //#ifdef APP
- plus.runtime.restart();
- setTimeout(() => {
- plus.navigator.closeSplashscreen();
- }, 3000);
- this.reconnectionNum = 0
- //#endif
- }
- // console.log('对象属性变化', newVal, oldVal);
- },
- deep: true,
- immediate: true
- }
- }
- }
- </script>
- <style scoped>
- .notice-board {
- width: 100%;
- height: 100vh;
- display: flex;
- flex-direction: column;
- background-color: #0d54ec;
- /* padding: 20rpx; */
- box-sizing: border-box;
- }
- .card_logo {
- position: absolute;
- left: 30rpx;
- top: 0;
- bottom: 0;
- display: flex;
- align-items: center;
- cursor: pointer;
- }
- .card_logo:focus {
- border: none !important;
- }
- .board-header {
- /* padding-top: 10rpx; */
- text-align: center;
- /* margin-bottom: 30rpx; */
- position: relative;
- display: flex;
- align-items: center;
- justify-content: center;
- height: 130rpx;
- flex-shrink: 0;
- background-color: #0d54ec;
- box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.2);
- }
- .card_yellow {
- display: flex;
- align-items: center;
- justify-content: center;
- width: fit-content;
- width: -webkit-fit-content;
- width: -moz-fit-content;
- padding: 0rpx 40rpx;
- height: 130rpx;
- background-color: #e5cb8d;
- border-bottom-right-radius: 300rpx;
- border-top-left-radius: 300rpx;
- }
- .card_board {
- display: flex;
- align-items: center;
- justify-content: center;
- width: fit-content;
- width: -webkit-fit-content;
- width: -moz-fit-content;
- padding: 0rpx 140rpx;
- height: 130rpx;
- background-color: #d5f3ff;
- border-bottom-right-radius: 120rpx;
- border-top-left-radius: 120rpx;
- }
- .board-title {
- font-size: 60rpx;
- font-weight: bold;
- color: #0e6699;
- }
- .card_time {
- position: absolute;
- right: 30rpx;
- top: 0;
- bottom: 0;
- display: flex;
- flex-direction: column;
- align-items: flex-end;
- justify-content: center;
- }
- .time_title {
- font-size: 60rpx;
- color: #fff;
- font-weight: bold;
- line-height: 60rpx;
- }
- .current-time {
- display: flex;
- align-items: center;
- display: flex;
- font-size: 40rpx;
- color: #fff;
- font-weight: bold;
- line-height: 45rpx;
- }
- .table-container {
- flex: 1;
- margin: 20rpx 20rpx 0rpx 20rpx;
- border-radius: 24rpx;
- overflow: hidden;
- box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.08);
- background-color: #ffffff;
- display: flex;
- flex-direction: column;
- }
- .table-header {
- background-color: #71a0ee;
- color: white;
- font-weight: bold;
- }
- .table-row {
- display: flex;
- border-bottom: 2rpx solid #dedede;
- }
- .header-row {
- /* background-color: #71a0ee; */
- color: white;
- font-weight: bold;
- }
- .table-cell {
- padding: 24rpx 16rpx;
- /* font-size: 50rpx; */
- font-size: 66rpx;
- font-weight: bold;
- text-align: center;
- color: #333;
- display: flex;
- justify-content: center;
- align-items: center;
- }
- .cell {
- padding: 24rpx 16rpx;
- /* font-size: 50rpx; */
- font-size: 60rpx;
- text-align: center;
- color: #333;
- }
- .name {
- width: 25%;
- white-space: nowrap;
- text-overflow: ellipsis;
- overflow: hidden;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .time {
- width: 25%;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .status {
- width: 25%;
- }
- .title_color {
- color: #fff;
- }
- /* 特殊状态数据容器 */
- .special-status-container {
- background-color: #fff8e6;
- }
- .special-row {
- background-color: #fff8e6 !important;
- min-height: 60rpx;
- line-height: 60rpx;
- }
- /* 分隔线 */
- .divider {
- height: 1rpx;
- background-color: #ddd;
- }
- /* 普通数据内容区域 */
- .table-body {
- flex: 1;
- overflow-y: auto;
- /* 添加触摸反馈 */
- -webkit-overflow-scrolling: touch;
- touch-action: pan-y;
- }
- .title-observing {
- color: #007bff;
- }
- .title-completed {
- color: #28a745;
- }
- .title-warning {
- color: #ff0000;
- }
- .title-hasleft {
- color: #fa8c16;
- }
- /* 状态行样式 */
- .status-observing {
- background-color: #9dd6ff;
- color: #007bff;
- border: 6rpx solid #007bff;
- }
- .status-completed {
- background-color: #e8f5e8;
- color: #28a745;
- border: 6rpx solid #28a745;
- }
- .status-warning {
- background-color: #ffebee;
- color: #ff0000;
- border: 6rpx solid #ff0000;
- }
- .status-hasleft {
- background-color: #fff2e8;
- color: #fa8c16;
- border: 6rpx solid #fa8c16;
- }
- .item-observing {
- background-color: #9dd6ff;
- }
- .item-completed {
- background-color: #e8f5e8;
- }
- .item-warning {
- background-color: #ffebee;
- }
- .item-hasleft {
- background-color: #fff2e8;
- }
- /* 状态标签样式 - 控制高度不超过40rpx */
- .status-tag {
- flex: none;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: 50rpx;
- font-size: 60rpx;
- font-weight: bold;
- width: 80%;
- }
- .empty-tip {
- text-align: center;
- padding: 80rpx;
- color: #999;
- font-size: 60rpx;
- font-style: italic;
- }
- /* 底部 */
- .card_foot {
- display: flex;
- align-items: center;
- height: 120rpx;
- box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.08);
- }
- .card_tips_box {
- display: flex;
- align-items: center;
- padding-left: 15rpx;
- }
- .title_tips {
- color: #fff;
- font-size: 40rpx;
- }
- .title_tips_foot {
- color: #fff;
- flex: 1;
- font-size: 40rpx;
- border-radius: 20rpx;
- margin-right: 20rpx;
- background-color: #71a0ee;
- padding: 20rpx;
- }
- .box_link_set {
- position: fixed;
- display: flex;
- align-items: center;
- justify-content: center;
- width: 100%;
- height: 100%;
- background-color: rgba(0, 0, 0, 0.3);
- }
- .box_popup {
- position: relative;
- background-color: #ffffff;
- border-radius: 8rpx;
- }
- .head_popup_title {
- position: relative;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .title_head_popup {
- font-size: 40rpx;
- font-weight: 500;
- padding: 30rpx 30rpx 0rpx 30rpx;
- }
- .close_title {
- position: absolute;
- right: 30rpx;
- font-size: 40rpx;
- cursor: pointer;
- }
- /* // 连接状态栏 */
- .status-bar-bottom {
- display: flex;
- flex-direction: column;
- margin: 30rpx;
- padding: 24rpx 30rpx;
- background-color: #ffffff;
- box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.05);
- border-radius: 24rpx;
- display: flex;
- gap: 16rpx;
- }
- .status-text-box {
- display: flex;
- align-items: center;
- font-size: 28rpx;
- }
- .dot {
- color: #d32f2f;
- font-size: 24rpx;
- }
- .dot-connecting {
- color: #f9ae3d;
- }
- .dot-connected {
- color: #28a745;
- }
- .dot-disconnected {
- color: #d32f2f;
- }
- .status-text {
- color: #555;
- }
- .status-text {
- margin-left: 16rpx;
- }
- .title_color {
- color: #fff;
- }
- /* // IP 输入区域 */
- .ip-input-group {
- display: flex;
- align-items: center;
- margin-left: 16rpx;
- flex-wrap: wrap;
- gap: 12rpx;
- }
- .ip-label {
- font-size: 28rpx;
- color: #555;
- }
- .ip-input,
- .port-input {
- border: 2rpx solid #ddd;
- border-radius: 12rpx;
- padding: 12rpx 20rpx;
- font-size: 28rpx;
- width: 200rpx;
- }
- .port-input {
- width: 160rpx;
- }
- .colon {
- font-size: 32rpx;
- color: #666;
- margin: 0 8rpx;
- }
- .btn-reconnect {
- background-color: #007aff;
- color: white;
- font-size: 24rpx;
- padding: 0 16rpx;
- border-radius: 12rpx;
- }
- .logo_image {
- width: 80rpx;
- height: 80rpx;
- }
- .logo_title {
- font-size: 50rpx;
- color: #fff;
- font-weight: bold;
- margin-left: 15rpx;
- }
- .tips_imageil {
- width: 70rpx;
- height: 70rpx;
- }
- /* 响应式优化 */
- @media screen and (max-height: 600px) {
- .card_logo {
- left: 10rpx;
- }
- .logo_title {
- font-size: 18rpx;
- margin-left: 5rpx;
- }
- .logo_image {
- width: 35rpx;
- height: 35rpx;
- }
- .table-row {
- min-height: 35rpx;
- line-height: 35rpx;
- padding: 4rpx 0rpx;
- }
- .table-cell {
- font-size: 22rpx;
- padding: 30rpx 10rpx;
- }
- .status-tag {
- padding: 4rpx 10rpx;
- font-size: 18rpx;
- height: 22rpx;
- min-width: 80rpx;
- }
- .control-btn {
- height: 50rpx;
- line-height: 50rpx;
- font-size: 22rpx;
- }
- .board-header {
- height: 50rpx;
- }
- .card_yellow {
- height: 50rpx;
- padding: 0rpx 20rpx;
- }
- .card_board {
- height: 50rpx;
- padding: 0rpx 30rpx;
- }
- .board-title {
- font-size: 20rpx;
- }
- .card_time {
- right: 10rpx;
- }
- .time_title {
- font-size: 20rpx;
- line-height: 20rpx;
- }
- .current-time {
- font-size: 15rpx;
- line-height: 15rpx;
- }
- .table-container {
- margin: 10rpx 10rpx 0rpx 10rpx;
- border-radius: 8rpx;
- }
- .table-cell {
- padding: 2rpx 5rpx;
- font-size: 18rpx;
- }
- .cell {
- padding: 2rpx 5rpx;
- font-size: 18rpx;
- }
- .card_foot {
- height: 50rpx;
- }
- .title_tips {
- font-size: 15rpx;
- }
- .title_tips_foot {
- font-size: 15rpx;
- padding: 2rpx 2rpx 2rpx 5rpx;
- border-radius: 8rpx;
- margin-right: 10rpx;
- }
- .tips_imageil {
- width: 30rpx;
- height: 30rpx;
- }
- .card_tips_box {
- padding-left: 10rpx;
- }
- .empty-tip {
- padding: 40rpx;
- font-size: 20rpx;
- }
- }
- /* 滚动条样式 */
- .table-body::-webkit-scrollbar {
- width: 4rpx;
- }
- .table-body::-webkit-scrollbar-track {
- background: #f1f1f1;
- border-radius: 6rpx;
- }
- .table-body::-webkit-scrollbar-thumb {
- background: #c1c1c1;
- border-radius: 6rpx;
- }
- .table-body::-webkit-scrollbar-thumb:hover {
- background: #a8a8a8;
- }
- </style>
|