Uni-app 实现全局无操作监听:自动退出弹窗倒计时功能

在App开发中,为了保障用户数据安全或资源合理利用,常常需要实现 "无操作自动退出" 功能------当用户长时间未进行交互时,弹出倒计时提示,超时后自动登出并跳转到登录页。本文基于Uni-app框架,分享一套完整的全局无操作监听方案,包含核心原理、代码实现及工程化配置。

一、功能核心需求

  1. 全局监听:覆盖所有页面的用户交互(点击、滑动、输入等)
  2. 阈值配置:支持自定义"无操作阈值" 和 "弹窗倒计时"时长
  3. 白名单机制:指定页面 (如登录页)不启用无操作检测
  4. 状态联动:与登录状态绑定,未登录是不启动监听
  5. 友好提示:弹窗显示倒计时,支持点击取消退出
  6. 异常处理:应用前后台切换、页面切换时正常工作

二、技术方案设计

整体架构

采用「单例服务 + 全局组件 + 配置化」设计,核心分为 3 个模块:

  1. 监听服务(idleDetector.js):单例模式,负责监听用户操作、计时、状态管理
  2. 配置文件(idleConfig.js):集中管理阈值、白名单等配置
  3. 弹窗组件(AutoLogoutMask.vue):可视化提示,响应用户取消操作
  4. 全局挂载:在 App.vue 和 main.js 中集成,实现全量覆盖
核心原理
  1. 监听用户交互事件(click、touchstart、scroll 等),触发时重置空闲计时器
  2. 空闲时间达到阈值后,启动退出倒计时并显示弹窗
  3. 倒计时期间有用户操作则取消退出,超时则执行登出逻辑
  4. 页面切换、应用前后台切换时,根据当前页面是否在白名单动态启停监听

三、完整代码实现

1.配置文件:统一管理参数(/mixin/idleConfig.js)
html 复制代码
export default {
  // 无操作阈值(秒):用户多久不操作启动倒计时
  inactivityThreshold: 300,
  // 弹窗倒计时(秒):提示后多久自动退出
  countdownSeconds: 30,
  // 是否启用本地存储配置覆盖(可选)
  enableStorageOverride: false,
  // 白名单页面(路由路径):这些页面不启用无操作检测
  whiteListPages: [
    '/pages/index/index' // 示例:登录页不检测
  ]
};
2.核心服务:无操作监听与状态管理(/mixin/idleDetector.js)
html 复制代码
import idleConfig from '@/mixin/idleConfig.js';
import { recordCabLog } from '../common/LogUtil.js';

class GlobalIdleService {
  constructor() {
    // 单例模式:防止重复创建实例
    if (GlobalIdleService.instance) {
      return GlobalIdleService.instance;
    }

    // 从配置读取参数
    this.inactivityThreshold = idleConfig.inactivityThreshold;
    this.countdownSeconds = idleConfig.countdownSeconds;
    this.whiteListPages = idleConfig.whiteListPages || [];
    this.enableStorageOverride = idleConfig.enableStorageOverride;

    // 计时器管理
    this.idleTime = 0; // 当前空闲时间(秒)
    this.idleInterval = null; // 空闲检测计时器
    this.countdownInterval = null; // 退出倒计时计时器

    // 状态管理
    this.isActive = false; // 服务是否激活
    this.isCountingDown = false; // 是否正在倒计时退出
    this.currentCountdown = 0; // 当前倒计时剩余时间

    // 事件回调:与弹窗组件通信
    this.onCountdownStart = null; // 倒计时开始回调
    this.onCountdownUpdate = null; // 倒计时更新回调
    this.onCountdownEnd = null; // 倒计时结束回调
    this.onCountdownCancel = null; // 倒计时取消回调

    // 事件监听缓存:用于销毁时移除监听
    this.eventHandlers = new Map();
    this.lifecycleHandlers = {};

    GlobalIdleService.instance = this;
  }

  // 初始化服务
  init() {
    // 可选:从本地存储加载配置(如需动态修改阈值)
    if (this.enableStorageOverride) {
      this.loadConfigFromStorage();
    }
    // 设置全局事件监听(用户交互+应用生命周期)
    this.setupGlobalListeners();
    console.log('[GlobalIdleService] 初始化完成,无操作阈值:', this.inactivityThreshold, '秒');
  }

  // 从本地存储加载配置(可选功能)
  loadConfigFromStorage() {
    try {
      const macAddressInfo = uni.getStorageSync('macAddressInfo') || {};
      const autoExitValue = parseInt(macAddressInfo.autoexit, 10);
      if (!isNaN(autoExitValue) && autoExitValue > 0) {
        this.inactivityThreshold = autoExitValue;
      }
    } catch (error) {
      console.error('[GlobalIdleService] 加载本地配置失败:', error);
    }
  }

  // 判断当前页面是否在白名单
  isCurrentPageInWhiteList() {
    try {
      const pages = getCurrentPages();
      if (pages.length === 0) return false;
      const currentRoute = `/${pages[pages.length - 1].route}`;
      return this.whiteListPages.includes(currentRoute);
    } catch (error) {
      console.error('[GlobalIdleService] 获取当前页面路由失败:', error);
      return false;
    }
  }

  // 设置全局用户交互事件监听
  setupGlobalListeners() {
    // 监听的用户交互事件列表
    const userEvents = ['click', 'touchstart', 'keydown', 'scroll', 'mousemove', 'tap'];
    
    // 交互事件处理函数:重置空闲计时器
    const handleUserAction = () => {
      if (!this.isCurrentPageInWhiteList()) {
        this.resetIdleTimer();
      }
    };

    // 绑定所有交互事件(兼容Uni-app和H5)
    userEvents.forEach(event => {
      if (typeof uni !== 'undefined' && uni.on) {
        uni.on(event, handleUserAction);
      }
      if (typeof document !== 'undefined') {
        document.addEventListener(event, handleUserAction, { capture: true, passive: true });
      }
      this.eventHandlers.set(event, handleUserAction);
    });

    // 绑定应用/页面生命周期事件
    this.setupPageLifecycleListeners();
  }

  // 设置应用/页面生命周期监听
  setupPageLifecycleListeners() {
    // 应用显示:恢复检测
    this.lifecycleHandlers.appShow = () => {
      if (!this.isCurrentPageInWhiteList() && this.$store?.state?.isLoggedIn) {
        this.resume();
      }
    };
    uni.onAppShow(this.lifecycleHandlers.appShow);

    // 应用隐藏:暂停检测
    this.lifecycleHandlers.appHide = () => {
      this.pause();
    };
    uni.onAppHide(this.lifecycleHandlers.appHide);

    // 页面显示:根据白名单动态启停
    this.lifecycleHandlers.pageShow = () => {
      if (this.isCurrentPageInWhiteList()) {
        this.pause();
      } else if (this.$store?.state?.isLoggedIn) {
        this.resetIdleTimer();
      }
    };
    uni.onPageShow(this.lifecycleHandlers.pageShow);
  }

  // 启动空闲检测
  startIdleDetection() {
    if (this.isCurrentPageInWhiteList() || !this.isActive || this.inactivityThreshold <= 0) return;

    // 清除已有计时器
    if (this.idleInterval) {
      clearInterval(this.idleInterval);
    }

    // 每秒检查一次空闲时间
    this.idleInterval = setInterval(() => {
      if (this.isCurrentPageInWhiteList()) {
        this.idleTime = 0;
        return;
      }

      this.idleTime++;
      // 达到无操作阈值,启动退出倒计时
      if (this.idleTime >= this.inactivityThreshold && !this.isCountingDown) {
        this.startCountdown();
      }
    }, 1000);
  }

  // 启动退出倒计时
  startCountdown() {
    if (this.isCurrentPageInWhiteList()) {
      this.clearCountdown();
      return;
    }

    this.isCountingDown = true;
    this.currentCountdown = this.countdownSeconds;

    // 通知弹窗组件:开始倒计时
    if (this.onCountdownStart) {
      this.onCountdownStart(this.currentCountdown);
    }

    // 倒计时更新(每秒一次)
    this.countdownInterval = setInterval(() => {
      if (this.isCurrentPageInWhiteList()) {
        this.clearCountdown();
        return;
      }

      this.currentCountdown--;
      // 通知弹窗组件:更新倒计时
      if (this.onCountdownUpdate) {
        this.onCountdownUpdate(this.currentCountdown);
      }

      // 倒计时结束,执行退出
      if (this.currentCountdown <= 0) {
        this.finishCountdown();
      }
    }, 1000);
  }

  // 完成倒计时:执行自动登出
  finishCountdown() {
    this.clearCountdown();
    // 通知弹窗组件:倒计时结束
    if (this.onCountdownEnd) {
      this.onCountdownEnd();
    }
    // 执行登出逻辑
    this.handleAutoLogout();
  }

  // 清除倒计时(取消退出)
  clearCountdown() {
    if (this.countdownInterval) {
      clearInterval(this.countdownInterval);
      this.countdownInterval = null;
    }

    this.isCountingDown = false;
    this.currentCountdown = 0;

    // 通知弹窗组件:取消倒计时
    if (this.onCountdownCancel) {
      this.onCountdownCancel();
    }
  }

  // 重置空闲计时器(用户有操作时调用)
  resetIdleTimer() {
    if (this.isCurrentPageInWhiteList() || !this.isActive) return;

    this.idleTime = 0;
    // 若正在倒计时,取消倒计时
    if (this.isCountingDown) {
      this.clearCountdown();
    }
    // 重启空闲检测
    this.restartIdleDetection();
  }

  // 重启空闲检测
  restartIdleDetection() {
    if (this.isCurrentPageInWhiteList()) return;

    if (this.idleInterval) {
      clearInterval(this.idleInterval);
    }

    if (this.isActive && this.inactivityThreshold > 0) {
      this.startIdleDetection();
    }
  }

  // 启动服务(登录后调用)
  start() {
    this.isActive = true;
    this.idleTime = 0;
    if (!this.isCurrentPageInWhiteList()) {
      this.startIdleDetection();
    }
    console.log('[GlobalIdleService] 服务已启动');
  }

  // 暂停服务(未登录/应用隐藏/白名单页面时调用)
  pause() {
    this.isActive = false;

    if (this.idleInterval) {
      clearInterval(this.idleInterval);
      this.idleInterval = null;
    }

    this.clearCountdown();
    console.log('[GlobalIdleService] 服务已暂停');
  }

  // 恢复服务(应用显示/离开白名单页面时调用)
  resume() {
    this.isActive = true;
    this.idleTime = 0;
    if (!this.isCurrentPageInWhiteList()) {
      this.startIdleDetection();
    }
    console.log('[GlobalIdleService] 服务已恢复');
  }

  // 处理自动登出逻辑
  handleAutoLogout() {
    console.log('[GlobalIdleService] 执行自动登出');
    // 记录退出日志(可选)
    recordCabLog({
      logType: 'YCDL',
      logData: '无操作自动退出',
    }).catch(error => {
      console.error('自动退出日志记录失败', error);
    });

    // 销毁服务
    this.destroy();
    // 清除存储的用户信息
    uni.clearStorageSync();
    // 跳转到登录页
    uni.reLaunch({
      url: '/pages/index/index'
    });
  }

  // 销毁服务(应用退出/登出时调用)
  destroy() {
    this.pause();

    // 移除所有事件监听
    this.eventHandlers.forEach((handler, event) => {
      try {
        if (typeof uni !== 'undefined' && uni.off) {
          uni.off(event, handler);
        }
        if (typeof document !== 'undefined') {
          document.removeEventListener(event, handler, true);
        }
      } catch (error) {
        console.warn(`移除${event}事件监听失败:`, error);
      }
    });
    this.eventHandlers.clear();

    // 移除生命周期监听
    try {
      if (uni.offAppShow && this.lifecycleHandlers.appShow) {
        uni.offAppShow(this.lifecycleHandlers.appShow);
      }
      if (uni.offAppHide && this.lifecycleHandlers.appHide) {
        uni.offAppHide(this.lifecycleHandlers.appHide);
      }
      if (uni.offPageShow && this.lifecycleHandlers.pageShow) {
        uni.offPageShow(this.lifecycleHandlers.pageShow);
      }
    } catch (error) {
      console.warn('移除生命周期监听失败:', error);
    }
    this.lifecycleHandlers = {};

    console.log('[GlobalIdleService] 服务已销毁');
  }
}

// 创建单例实例并导出
const globalIdleService = new GlobalIdleService();
export default globalIdleService;
3.弹窗组件:可视化提示与用户监交互(/components/AutoLogoutMask.vue)
html 复制代码
<template>
  <!-- 无操作提示弹窗:v-if控制显示隐藏 -->
  <view class="auto-logout-mask" v-if="showMask" @click="handleMaskClick">
    <!-- 半透明背景遮罩 -->
    <view class="mask-bg"></view>
    <!-- 倒计时弹窗容器 -->
    <view class="countdown-container">
      <view class="countdown-box">
        <text class="title">长时间未操作</text>
        <view class="countdown-info">
          <text>系统将在 </text>
          <text class="countdown-number">{{ countdown }}</text>
          <text> 秒后自动退出</text>
        </view>
        <text class="tip">点击任意位置取消</text>
      </view>
    </view>
  </view>
</template>

<script>
import globalIdleService from '@/mixin/idleDetector.js';
import idleConfig from '@/mixin/idleConfig.js';

export default {
  name: 'AutoLogoutMask',
  data() {
    return {
      showMask: false, // 弹窗显示状态
      countdown: idleConfig.countdownSeconds // 倒计时初始值
    };
  },
  created() {
    // 注册回调函数:与监听服务通信
    this.registerGlobalCallbacks();
  },
  methods: {
    // 注册服务回调:响应倒计时状态变化
    registerGlobalCallbacks() {
      // 倒计时开始:显示弹窗
      globalIdleService.onCountdownStart = (seconds) => {
        this.countdown = seconds;
        this.showMask = true;
        console.log('[AutoLogoutMask] 开始倒计时:', seconds);
      };

      // 倒计时更新:同步显示剩余时间
      globalIdleService.onCountdownUpdate = (seconds) => {
        this.countdown = seconds;
      };

      // 倒计时取消:隐藏弹窗
      globalIdleService.onCountdownCancel = () => {
        this.showMask = false;
        console.log('[AutoLogoutMask] 倒计时已取消');
      };

      // 倒计时结束:隐藏弹窗
      globalIdleService.onCountdownEnd = () => {
        this.showMask = false;
        console.log('[AutoLogoutMask] 倒计时结束');
      };
    },

    // 点击遮罩:取消退出(重置空闲计时器)
    handleMaskClick() {
      globalIdleService.resetIdleTimer();
    }
  },
  beforeDestroy() {
    // 组件销毁:清除回调引用,避免内存泄漏
    console.log('[AutoLogoutMask] 组件销毁,清除回调');
    globalIdleService.onCountdownStart = null;
    globalIdleService.onCountdownUpdate = null;
    globalIdleService.onCountdownCancel = null;
    globalIdleService.onCountdownEnd = null;
  }
};
</script>

<style scoped>
/* 遮罩样式:全屏半透明背景 */
.mask-bg {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(0, 0, 0, 0.7);
  z-index: 9998;
}

/* 弹窗容器:居中显示 */
.countdown-container {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 9999;
  display: flex;
  justify-content: center;
  align-items: center;
}

/* 弹窗盒子样式 */
.countdown-box {
  width: 70%;
  background-color: #fff;
  border-radius: 16px;
  padding: 30rpx;
  text-align: center;
  box-shadow: 0 10rpx 30rpx rgba(0, 0, 0, 0.3);
}

/* 标题样式 */
.title {
  font-size: 36rpx;
  font-weight: bold;
  color: #333;
  display: block;
  margin-bottom: 20rpx;
}

/* 倒计时信息样式 */
.countdown-info {
  font-size: 32rpx;
  color: #666;
  margin: 30rpx 0;
}

/* 倒计时数字样式:红色+脉冲动画 */
.countdown-number {
  color: #ff4d4f;
  font-size: 40rpx;
  font-weight: bold;
  margin: 0 10rpx;
  display: inline-block;
  animation: pulse 1s infinite;
}

/* 提示文本样式 */
.tip {
  font-size: 28rpx;
  color: #999;
  display: block;
  margin-top: 20rpx;
}

/* 数字脉冲动画:增强视觉提醒 */
@keyframes pulse {
  0% { transform: scale(1); }
  50% { transform: scale(1.1); }
  100% { transform: scale(1); }
}
</style>
4.全局集成:挂载组件与启动服务

(1)main.js:全局注册组件

html 复制代码
// main.js
import App from "./App";
// 导入无操作弹窗组件
import AutoLogoutMask from '@/components/AutoLogoutMask.vue';

// #ifndef VUE3
import Vue from "vue";
// 全局注册组件(Vue2)
Vue.component("AutoLogoutMask", AutoLogoutMask);
// #endif

// #ifdef VUE3
import { createSSRApp } from "vue";
function createApp() {
  const app = createSSRApp(App);
  // 全局注册组件(Vue3)
  app.component("AutoLogoutMask", AutoLogoutMask);
  return { app };
}
// #endif

(2)App.vue:集成服务与状态联动

html 复制代码
<template>
  <div id="app">
    <router-view />
    <!-- 全局挂载无操作弹窗组件 -->
    <AutoLogoutMask />
  </div>
</template>

<script>
import globalIdleService from '@/mixin/idleDetector.js';

export default {
  onLaunch: function() {
    console.log('App Launch');

    // 初始化登录状态(从缓存读取)
    const loginSession = uni.getStorageSync('LoginSession');
    this.$store.dispatch('updateLoginStatus', !!loginSession);

    // 初始化无操作监听服务
    globalIdleService.init();

    // 已登录则启动服务,未登录则不启动
    if (loginSession) {
      globalIdleService.start();
    }

    // 监听登录状态变化:动态启停服务
    this.$store.watch(
      state => state.isLoggedIn,
      (isLoggedIn) => {
        if (isLoggedIn) {
          globalIdleService.start();
        } else {
          globalIdleService.pause();
        }
      }
    );
  },

  onShow: function() {
    console.log('App Show');
    // 应用显示时,若已登录则恢复服务
    if (this.$store.state.isLoggedIn) {
      globalIdleService.resume();
    }
  },

  onHide: function() {
    console.log('App Hide');
    // 应用隐藏时,暂停服务
    globalIdleService.pause();
  },

  onExit: function() {
    console.log('App Exit');
    // 应用退出时,销毁服务
    try {
      globalIdleService.destroy();
    } catch (error) {
      console.warn('销毁空闲检测服务失败', error);
    }
  }
};
</script>

四、关键功能说明

1.白名单机制

在dileConfig.js中配置了whiteListPages,支持指定页面跳过无操作检测(如登录页、支付页等不需要自动退出的场景)。isCurrentPageInWhiteList方法中,通过getCurrentPages()获取当前页面路由并匹配白名单。

2.状态联动
  • 登录状态:未登录时不启动监听,登录后自动启动
  • 应用生命周期:应用隐藏时暂停监听,显示时恢复
  • 页面切换:切换到白名单页面时暂停,离开时恢复
3.用户体验优化
  • 弹窗居中显示,半透明遮罩层突出提示
  • 倒计时数字添加脉冲动画,增强视觉提醒
  • 支持点击任意位置取消退出,操作便捷
  • 所有计时器在组件销毁 / 服务暂停时清理,避免内存泄漏
4.异常处理
  • 兼容 Uni-app 多端(App、H5)的事件监听方式
  • 销毁服务时移除所有事件监听,避免残留
  • 本地存储读取失败时降级使用默认配置
  • 页面路由获取失败时默认不启用白名单

五、使用与扩展

1.基础使用
  • 修改idleConfig.js中的inactivityThreshold(无操作阈值)和countdownSeconds(倒计时时长)可自定义时效
  • 在whiteListPages中添加需要跳过检测的页面路由(格式为/pages/xxx/xxx
  • 登录时存储LoginSession到缓存,登出时清除,服务会自动联动状态
2.功能扩展
  • 动态修改阈值:通过globalIdleService.inactivityThreshold = 60动态修改无操作阈值
  • 自定义退出逻辑:修改handleAutoLogout方法,可添加接口请求、数据备份等操作
  • 多语言支持:将弹窗文本提取到语言包,根据当前语言动态显示
  • 自定义弹窗样式:修改AutoLogoutMask.vue的样式,适配项目设计风格

六、注意事项

  1. 事件监听冲突:若项目中已有全局事件监听,需避免重复绑定同一事件
  2. 性能优化:监听的事件列表不宜过多,当前已覆盖核心交互事件,无需额外添加
  3. 白名单路由格式:必须与getCurrentPages()返回的route字段一致(不带参数)
  4. 缓存清理:登出时需确保uni.clearStorageSync()正确执行,避免残留登录状态
  5. 单例模式:GlobalIdleService采用单例设计,不可重复创建实例

总结

本文实现的全局无操作监听方案,基于Uni-app框架特性,通过单例服务 + 全局组件 + 配置化设计,实现了灵活、可开的自动退出功能。核心优势在于:

  • 全局覆盖:监听所有页面的用户交互,无遗漏
  • 配置灵活:支持阈值、白名单等参数自定义
  • 状态联动:与登录状态、应用声明周期深度集成
  • 体验友好:可视化倒计时提示,支持用户取消操作
  • 稳定可靠:完善的异常处理和内存泄露防护

该方案可直接应用于需要安全防护的App项目(如金融、医疗、企业办公等),也可根据实际需求扩展更多功能。

相关推荐
一只月月鸟呀1 小时前
使用node和@abandonware/bleno写一个ble模拟设备,用Uni-app模拟终端连接
uni-app
tianyuanwo1 小时前
SSH连接底层原理与故障深度解析:从协议握手到安全运维
运维·安全·ssh
f***24111 小时前
不常用,总是忘记:nginx 重启指令
运维·windows·nginx
R***z1011 小时前
【Sql Server】sql server 2019设置远程访问,外网服务器需要设置好安全组入方向规则
运维·服务器·安全
梁正雄1 小时前
5、python 模块与包
linux·服务器·python
de之梦-御风1 小时前
【远程控制】开箱即用的 RustDesk 自建服务端完整 Docker Compose 模板
运维·docker·容器
axihaihai1 小时前
腾讯云镜像仓库访问问题
linux·服务器·腾讯云
宇钶宇夕1 小时前
魏德米勒 UR20-FBC-PN-IRT-V2 从站全解析:产品特性、模块详情、接线图与地址配置指南(地址修改部分)
运维·自动化
嘻哈baby1 小时前
从零搭建家庭All-in-One服务器:300元成本实现企业级功能
运维·服务器