| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749 |
- <template>
- <view class="container">
- <view class="header">
- <text class="title">接种留观人员信息表</text>
- <text class="current-time">{{ currentTime }}</text>
- </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="table-body-container">
- <view class="table-body-wrapper" :class="{ 'animate': shouldAnimate }">
- <view v-for="(item, index) in carouselList" :key="index" class="table-row body-row"
- :class="`item-${getClass(item.status)}`">
- <view class="cell name">{{ item.patientName }}</view>
- <view class="cell time">{{ getTime(item.createTime) }}</view>
- <view class="cell time">{{ getTime(item.outTime) }}</view>
- <view class="cell status">
- <text class="status-tag" :class="`status-${getClass(item.status)}`">
- {{ getStatusText(item.status) }}
- </text>
- </view>
- </view>
- </view>
- </view>
- </view>
- <!-- 连接状态与IP编辑栏 -->
- <view class="status-bar-bottom" :class="`status-${connectionStatus}`">
- <view class="status-text-box">
- <text class="dot" :class="`dot-${connectionStatus}`">●</text>
- <text class="status-text">{{ connectionText }}</text>
- </view>
- <!-- 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" />
- <text class="colon">:</text>
- <input type="number" :value="serverPort" @input="onPortInput" placeholder="8811" class="port-input" />
- <button class="btn-reconnect" size="mini" @click="handleReconnect">
- 更新并重连
- </button>
- <button class="btn-reconnect" size="mini" @click="goback">
- 测试
- </button>
- </view>
- </view>
- </view>
- </template>
- <script>
- export default {
- data() {
- return {
- currentTime: '', // 当前时间
- 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
- // 留观数据列表
- list: [],
- statusText: {
- observing: '留观中',
- completed: '留观完成,可离开',
- warning: '提前离开',
- hasleft: '已离开',
- },
- // 可编辑的服务器地址
- serverIp: '192.168.11.132',
- serverPort: '8811',
- };
- },
- computed: {
- connectionText() {
- const {
- connectionStatus,
- serverIp,
- serverPort
- } = this;
- return {
- connecting: `正在连接 ${serverIp}:${serverPort}...`,
- connected: `已连接到 ${serverIp}:${serverPort}`,
- disconnected: `连接已断开`
- } [connectionStatus];
- },
- // 轮播列表数据
- carouselList() {
- if (this.list.length === 0) return [];
- // 如果数据较少,不需要轮播
- if (this.list.length <= 5) return this.list;
- // 复制数据实现无缝轮播
- return [...this.list, ...this.list];
- },
- // 是否需要启动轮播动画
- shouldAnimate() {
- return this.list.length > 5;
- }
- },
- methods: {
- 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 = 'completed'
- }
- return title
- },
- getStatusText(status) {
- var title = '';
- if (status == 0) {
- title = this.statusText.observing
- } else if (status == 1) {
- title = this.statusText.completed
- } else if (status == 3) {
- title = this.statusText.warning
- } else if (status == 4) {
- title = this.statusText.hasleft
- }
- return title
- },
- // 格式时分
- getTime(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
- },
- // 格式化时间范围:09:00 - 09:30
- formatTimeRange(start, end) {
- return start && end ? `${start} - ${end}` : '--';
- },
- // 输入框事件
- onIpInput(e) {
- this.serverIp = e.detail.value;
- },
- onPortInput(e) {
- this.serverPort = e.detail.value;
- },
- // 构建 WebSocket URL
- getWebSocketUrl() {
- return `ws://${this.serverIp}:${this.serverPort}`;
- },
- // 处理重连
- handleReconnect() {
- uni.showLoading({
- title: '正在重连...'
- });
- this.connectionStatus = 'connecting';
- // 关闭旧连接
- if (this.ws) {
- this.ws.close({
- success: () => {
- console.log('旧连接已关闭');
- this.connectWebSocket('link');
- },
- fail: () => {
- this.connectWebSocket('link'); // 即使关闭失败也尝试重连
- }
- });
- } else {
- this.connectWebSocket('link');
- }
- setTimeout(() => {
- uni.hideLoading();
- }, 2000);
- },
- // 建立 WebSocket 连接
- connectWebSocket(type) {
- const url = this.getWebSocketUrl();
- // 创建 SocketTask
- this.ws = uni.connectSocket({
- url: url,
- success: (res) => {
- console.log('connectSocket success', res);
- },
- fail: (err) => {
- console.error('connectSocket 失败', err);
- this.connectionStatus = 'disconnected';
- this.reconnect();
- }
- });
- // --- WebSocket 事件监听 ---
- this.ws.onOpen((res) => {
- // console.log('WebSocket 连接成功', res);
- this.connectionStatus = 'connected';
- this.ws.send({
- data: type,
- });
- // ✅ 连接成功后启动心跳
- this.startHeartbeat();
- uni.hideLoading();
- });
- this.ws.onMessage((res) => {
- // ✅ 处理心跳响应 pong
- if (res.data === 'PONG' || res.data === '{"type":"PONG"}') {
- this.isPongReceived = true;
- // console.log('收到 pong,心跳正常');
- // ✅ 清除等待 pong 的超时计时器
- if (this.heartbeatTimeout) {
- clearTimeout(this.heartbeatTimeout);
- this.heartbeatTimeout = null;
- }
- return;
- }
- try {
- const data = JSON.parse(res.data);
- // console.log('收到消息:', data);
- this.handleMessage(data);
- } catch (e) {
- console.warn('非 JSON 消息,已忽略', res.data);
- }
- });
- this.ws.onClose((res) => {
- console.log('WebSocket 连接关闭', res);
- this.connectionStatus = 'disconnected';
- this.stopHeartbeat(); // 停止心跳
- this.reconnect(); // 断线重连
- });
- this.ws.onError((err) => {
- console.error('WebSocket 错误', err);
- this.connectionStatus = 'disconnected';
- });
- },
- // 处理收到的消息
- 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;
- 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.list];
- 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.list = updated;
- },
- // 批量修改
- batchUpdate(data) {
- if (!data) return;
- const items = Array.isArray(data) ? data : [data]; // 支持单条或数组
- const updated = [...this.list];
- 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.list.push(item); } // 可选:当作新增
- });
- this.list = 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.list.filter(item => !idsToRemove.includes(item.id));
- this.list = updated;
- },
- updateListWithArray(newList) {
- if (!Array.isArray(newList)) return;
- const map = new Map();
- newList.forEach(item => {
- if (item.id !== undefined) {
- map.set(item.id, item);
- }
- });
- const updated = [...this.list];
- // 更新或新增
- 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.list = final;
- },
- // 断线重连(指数退避)
- reconnect() {
- if (this.reconnectTimer) return; // 防止重复定时器
- this.reconnectTimer = setTimeout(() => {
- console.log('正在尝试重新连接...');
- this.connectWebSocket('link');
- this.reconnectTimer = null;
- }, 3000);
- },
- // 启动心跳
- startHeartbeat() {
- console.log(2324)
- // 清除旧心跳
- this.stopHeartbeat();
- // 发送 ping 的定时器
- this.heartbeatTimer = setInterval(() => {
- if (this.ws && this.connectionStatus === 'connected') {
- // 检查上一次的 pong 是否已收到,未收到则判定为断线
- if (!this.isPongReceived) {
- console.warn('上一次心跳未收到 pong,可能已断线');
- this.ws.close();
- return;
- }
- // 发送心跳
- this.isPongReceived = false; // 等待 pong
- // 设置超时检测:10秒内没收到 pong 就断开
- this.heartbeatTimeout = setTimeout(() => {
- if (!this.isPongReceived) {
- console.warn('⚠️ 心跳超时:未在规定时间内收到 pong,即将重连');
- uni.showToast({
- title: '连接异常,正在重连...',
- icon: 'none',
- duration: 2000
- });
- this.ws.close(); // 触发 onClose → 重连
- }
- }, this.heartbeatTimeoutTime); // 使用配置的超时时间(如 10000ms)
- try {
- this.ws.send({
- data: 'PING',
- success: () => {
- // console.log('ping 已发送');
- },
- fail: (err) => {
- console.error('ping 发送失败', err);
- this.ws.close();
- }
- });
- } catch (e) {
- console.error('发送 ping 异常', e);
- this.ws.close();
- }
- }
- }, this.heartbeatInterval);
- // 可选:启动响应超时检测(更严格)
- // 通常靠"发 ping 后未收到 pong"即可判断
- },
- // 停止心跳(断开连接时调用)
- stopHeartbeat() {
- if (this.heartbeatTimer) {
- clearInterval(this.heartbeatTimer);
- this.heartbeatTimer = null;
- }
- if (this.heartbeatTimeout) {
- clearTimeout(this.heartbeatTimeout);
- this.heartbeatTimeout = null;
- }
- },
- 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} ${hours}:${minutes}:${seconds}`;
- },
- goback() {
- uni.navigateTo({
- url: '/pages/index/home'
- });
- }
- },
- mounted() {
- this.connectWebSocket('link');
- this.updateCurrentTime(); // 立即更新一次时间
- this.timeTimer = setInterval(() => {
- this.updateCurrentTime();
- }, 1000); // 每秒更新一次时间
- },
- // 页面卸载时关闭连接
- beforeDestroy() {
- if (this.ws) {
- this.ws.close();
- }
- if (this.reconnectTimer) {
- clearTimeout(this.reconnectTimer);
- }
- this.stopHeartbeat();
- // 清理时间更新定时器
- if (this.timeTimer) {
- clearInterval(this.timeTimer);
- }
- }
- };
- </script>
- <style lang="scss" scoped>
- .container {
- background-color: #f4f6f8;
- display: flex;
- flex-direction: column;
- height: 100vh;
- padding: 20rpx 30rpx 30rpx 30rpx;
- background-color: #f4f6f8;
- font-family: 'Arial', 'Microsoft YaHei', sans-serif;
- box-sizing: border-box;
- overflow: hidden;
- /* 隐藏页面滚动,让轮播在表格内进行 */
- }
- .header {
- padding-top: 10rpx;
- text-align: center;
- margin-bottom: 30rpx;
- position: relative;
- display: flex;
- align-items: center;
- justify-content: center;
- height: 100rpx;
- }
- .title {
- font-size: 60rpx;
- font-weight: bold;
- color: #1e3a8a;
- }
- .current-time {
- display: flex;
- align-items: center;
- position: absolute;
- display: flex;
- height: 100rpx;
- right: 0rpx;
- font-size: 55rpx;
- color: #666;
- background-color: rgba(30, 58, 138, 0.1);
- padding: 0rpx 30rpx;
- border-radius: 12rpx;
- font-weight: bold;
- }
- .table-container {
- flex: 1;
- margin-bottom: 30rpx;
- border-radius: 24rpx;
- overflow: hidden;
- box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.08);
- background-color: #ffffff;
- border: 2rpx solid #e0e0e0;
- display: flex;
- flex-direction: column;
- }
- .table-header {
- background-color: #f0f4f8;
- color: white;
- font-weight: bold;
- }
- .header-row {
- background-color: #1e3a8a;
- color: white;
- font-weight: bold;
- }
- .table-row {
- display: flex;
- border-bottom: 2rpx solid #dedede;
- }
- .body-row {
- padding: 10rpx 0rpx;
- }
- .table-row:last-child {
- border-bottom: none;
- }
- .cell {
- padding: 24rpx 16rpx;
- font-size: 50rpx;
- text-align: center;
- color: #333;
- }
- .name {
- width: 25%;
- }
- .time {
- width: 25%;
- }
- .status {
- width: 25%;
- }
- .status-tag {
- padding: 8rpx 20rpx;
- border-radius: 32rpx;
- font-size: 48rpx;
- font-weight: 500;
- }
- .status-observing {
- background-color: #e3f2fd;
- color: #007bff;
- border: 2rpx solid #9acffa;
- }
- .status-completed {
- background-color: #e8f5e8;
- color: #28a745;
- border: 2rpx solid #8bc34a;
- }
- .status-warning {
- background-color: #ffebee; // 浅红背景
- color: #c62828; // 深红色文字
- border: 2rpx solid #ef9a9a;
- }
- .item-observing {
- background-color: #e3f2fd;
- }
- .item-completed {
- background-color: #e8f5e8;
- }
- .item-warning {
- background-color: #ffebee; // 浅红背景
- }
- .item-hasleft {
- background-color: #e3f2fd;
- }
- .table-header {
- flex: 0 0 auto;
- overflow: hidden;
- }
- /* 保持表格形式的轮播样式 */
- .table-body-container {
- flex: 1;
- overflow: hidden;
- background-color: #fafafa;
- position: relative;
- }
- .table-body-wrapper {
- display: block;
- }
- .table-body-wrapper.animate {
- animation: scrollUp 20s linear infinite;
- }
- @keyframes scrollUp {
- 0% {
- transform: translateY(0);
- }
- 100% {
- transform: translateY(calc(-50% - 1px));
- /* 减去1px确保无缝衔接 */
- }
- }
- .empty {
- text-align: center;
- padding: 60rpx;
- color: #999;
- font-style: italic;
- font-size: 40rpx;
- }
- // 连接状态栏
- .status-bar-bottom {
- 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: 40rpx;
- }
- .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;
- }
- </style>
|