瀏覽代碼

1、首页内容清除,替换为查询已登录、未登录的调试宝设备(管理员有多个调试宝设备,运维人员只有自己的调试宝设备)
2、点击调试宝设备卡片,进行对应设备的操作(调试宝相关的菜单,在侧边栏隐藏,在卡片中存在)

liweimin 1 周之前
父節點
當前提交
c39b2723f7

+ 1 - 1
ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/TsbWebSocketController.java

@@ -27,7 +27,7 @@ public class TsbWebSocketController extends BaseController
     /**
      * WebSocket 连接预检(设备在线、操作权限、设备未被占用)
      */
-    @GetMapping("/bind")
+    @GetMapping("/bindInfo")
     public AjaxResult bindInfo(@RequestParam Long deviceSn)
     {
         Long userId = SecurityUtils.getUserId();

+ 5 - 0
ruoyi-device/src/main/java/com/ruoyi/device/websocket/TsbWebSocketConstants.java

@@ -39,6 +39,11 @@ public final class TsbWebSocketConstants
     public static final String DEVICE_SN_KEY = "deviceSn";
 
     /**
+     * 绑定信息属性名称
+     */
+    public static final String USER_ID_KEY = "userId";
+
+    /**
      * code 1:成功,0:失败
      */
     public static final int SUCCESS_CODE = 1;

+ 7 - 2
ruoyi-device/src/main/java/com/ruoyi/device/websocket/TsbWebSocketService.java

@@ -82,9 +82,13 @@ public class TsbWebSocketService
         {
             return "设备未登录在线,无法建立连接";
         }
-        if (TsbWebSocketUsers.isDeviceConnected(deviceSn))
+        if (TsbWebSocketUsers.isDeviceConnectedByOtherUser(deviceSn, userId))
         {
-            return "该设备已被占用连接,请稍后再试";
+            return "该设备已被其他用户占用连接,请稍后再试";
+        }
+        if (TsbWebSocketUsers.isDeviceConnectedByCurrentUser(deviceSn, userId))
+        {
+            return "该设备已在其他页签中打开,请切换到已打开的设备操作页";
         }
         return null;
     }
@@ -146,6 +150,7 @@ public class TsbWebSocketService
             session.getUserProperties().put(TsbWebSocketConstants.DEVICE_SN_KEY, bind.getDeviceSn());
             session.getUserProperties().put(TsbWebSocketConstants.USER_DEVICE_BIND_KEY, bind);
             session.getUserProperties().put(TsbWebSocketConstants.USER_PERMISSIONS_KEY, loginUser.getPermissions());
+            session.getUserProperties().put(TsbWebSocketConstants.USER_ID_KEY, userId);
             // 创建信息map
             if (!TsbWebSocketUsers.tryPut(bind.getDeviceSn(), session))
             {

+ 55 - 2
ruoyi-device/src/main/java/com/ruoyi/device/websocket/TsbWebSocketUsers.java

@@ -48,9 +48,9 @@ public class TsbWebSocketUsers
     }
 
     /**
-     * 设备是否已有有效 WebSocket 连接
+     * 设备是否已有有效 WebSocket 连接(其他用户)
      */
-    public static boolean isDeviceConnected(Long deviceSn)
+    public static boolean isDeviceConnectedByOtherUser(Long deviceSn, Long userId)
     {
         if (deviceSn == null)
         {
@@ -66,10 +66,63 @@ public class TsbWebSocketUsers
             SESSION_USERS.remove(deviceSn, session);
             return false;
         }
+        Long userIdFromSession = getUserIdFromSession(session);
+        if (userIdFromSession == null || userIdFromSession.equals(userId))
+        {
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * 设备是否已有有效 WebSocket 连接(当前用户)
+     */
+    public static boolean isDeviceConnectedByCurrentUser(Long deviceSn, Long userId)
+    {
+        if (deviceSn == null)
+        {
+            return false;
+        }
+        Session session = SESSION_USERS.get(deviceSn);
+        if (session == null)
+        {
+            return false;
+        }
+        if (!session.isOpen())
+        {
+            SESSION_USERS.remove(deviceSn, session);
+            return false;
+        }
+        Long userIdFromSession = getUserIdFromSession(session);
+        if (userIdFromSession == null || !userIdFromSession.equals(userId))
+        {
+            return false;
+        }
         return true;
     }
 
     /**
+     * 获取用户Id
+     */
+    private static Long getUserIdFromSession(Session session)
+    {
+        if (session == null)
+        {
+            return null;
+        }
+        Object userIdObj = session.getUserProperties().get(TsbWebSocketConstants.USER_ID_KEY);
+        if (userIdObj instanceof Long)
+        {
+            return (Long) userIdObj;
+        }
+        if (userIdObj instanceof Number)
+        {
+            return ((Number) userIdObj).longValue();
+        }
+        return null;
+    }
+
+    /**
      * 注册会话;同一 deviceSn 仅保留最新连接
      *
      * @param key 唯一键 设备sn码

+ 3 - 3
ruoyi-ui/src/api/tsb/ws.js

@@ -1,13 +1,13 @@
 import request from '@/utils/request'
 
 /**
- * WebSocket 连接预检(deviceSn 必填)
+ * WebSocket 连接预检(deviceSn 必填,对应 TsbWebSocketController#bindInfo
  * @param {number|string} deviceSn 设备 SN
- * @param {boolean} silent 为 true 时不自动弹出全局错误提示(用于重连预检
+ * @param {boolean} silent 为 true 时不自动弹出全局错误提示(由调用方统一提示
  */
 export function getTsbWsBind(deviceSn, silent = false) {
   return request({
-    url: '/tsb/ws/bind',
+    url: '/tsb/ws/bindInfo',
     method: 'get',
     params: { deviceSn },
     headers: { silentError: silent }

+ 17 - 1
ruoyi-ui/src/permission.js

@@ -1,6 +1,7 @@
 import router from './router'
 import store from './store'
 import tsbWebSocket from '@/utils/tsbWebSocket'
+import { hasTsbDeviceSession } from '@/utils/tsbDeviceSession'
 import { Message } from 'element-ui'
 import NProgress from 'nprogress'
 import 'nprogress/nprogress.css'
@@ -16,6 +17,16 @@ const isWhiteList = (path) => {
   return whiteList.some(pattern => isPathMatch(pattern, path))
 }
 
+function blockWorkspaceWithoutSession(to, next) {
+  if (to.path === '/tsb/workspace' && !hasTsbDeviceSession()) {
+    store.commit('tsb/SET_SESSION_CHECKED', false)
+    next({ path: '/index', replace: true })
+    NProgress.done()
+    return true
+  }
+  return false
+}
+
 router.beforeEach((to, from, next) => {
   NProgress.start()
   if (getToken()) {
@@ -43,7 +54,9 @@ router.beforeEach((to, from, next) => {
           store.dispatch('GenerateRoutes').then(accessRoutes => {
             // 根据roles权限生成可访问的路由表
             router.addRoutes(accessRoutes) // 动态添加可访问路由表
-            next({ ...to, replace: true }) // hack方法 确保addRoutes已完成
+            if (!blockWorkspaceWithoutSession(to, next)) {
+              next({ ...to, replace: true }) // hack方法 确保addRoutes已完成
+            }
           })
         }).catch(err => {
             store.dispatch('LogOut').then(() => {
@@ -52,6 +65,9 @@ router.beforeEach((to, from, next) => {
             })
           })
       } else {
+        if (blockWorkspaceWithoutSession(to, next)) {
+          return
+        }
         tsbWebSocket.connectAfterLogin()
         next()
       }

+ 18 - 0
ruoyi-ui/src/router/index.js

@@ -93,6 +93,24 @@ export const constantRoutes = [
         meta: { title: '个人中心', icon: 'user' }
       }
     ]
+  },
+  {
+    path: '/tsb/workspace',
+    component: Layout,
+    hidden: true,
+    children: [
+      {
+        path: '',
+        name: 'TsbWorkspace',
+        component: () => import('@/views/tsb/device-shell/index'),
+        meta: { title: '设备操作', activeMenu: '/index', noCache: true }
+      }
+    ]
+  },
+  {
+    path: '/tsb/device/:deviceSn/:panel?',
+    redirect: '/tsb/workspace',
+    hidden: true
   }
 ]
 

+ 35 - 25
ruoyi-ui/src/store/getters.js

@@ -1,25 +1,35 @@
-const getters = {
-  sidebar: state => state.app.sidebar,
-  size: state => state.app.size,
-  device: state => state.app.device,
-  dict: state => state.dict.dict,
-  isLock: state => state.lock.isLock,
-  lockPath: state => state.lock.lockPath,
-  visitedViews: state => state.tagsView.visitedViews,
-  cachedViews: state => state.tagsView.cachedViews,
-  token: state => state.user.token,
-  avatar: state => state.user.avatar,
-  id: state => state.user.id,
-  name: state => state.user.name,
-  nickName: state => state.user.nickName,
-  introduction: state => state.user.introduction,
-  roles: state => state.user.roles,
-  permissions: state => state.user.permissions,
-  permission_routes: state => state.permission.routes,
-  topbarRouters: state => state.permission.topbarRouters,
-  defaultRoutes: state => state.permission.defaultRoutes,
-  sidebarRouters: state => state.permission.sidebarRouters,
-  tsbCurrentDevice: state => state.tsb.currentDevice,
-  tsbCurrentDeviceSn: state => state.tsb.currentDevice && state.tsb.currentDevice.deviceSn
-}
-export default getters
+const getters = {
+  sidebar: state => state.app.sidebar,
+  size: state => state.app.size,
+  device: state => state.app.device,
+  dict: state => state.dict.dict,
+  isLock: state => state.lock.isLock,
+  lockPath: state => state.lock.lockPath,
+  visitedViews: state => state.tagsView.visitedViews,
+  cachedViews: state => state.tagsView.cachedViews,
+  token: state => state.user.token,
+  avatar: state => state.user.avatar,
+  id: state => state.user.id,
+  name: state => state.user.name,
+  nickName: state => state.user.nickName,
+  introduction: state => state.user.introduction,
+  roles: state => state.user.roles,
+  permissions: state => state.user.permissions,
+  permission_routes: state => state.permission.routes,
+  topbarRouters: state => state.permission.topbarRouters,
+  defaultRoutes: state => state.permission.defaultRoutes,
+  sidebarRouters: state => state.permission.sidebarRouters,
+  tsbCurrentDevice: state => state.tsb.currentDevice,
+  tsbCurrentDeviceSn: state => state.tsb.currentDevice && state.tsb.currentDevice.deviceSn,
+  tsbOpenedDevices: state => state.tsb.openedDevices,
+  tsbIsNewTabWithoutDevice: state => state.tsb.isNewTabWithoutDevice,
+  tsbSessionReady: state => state.tsb.sessionReady,
+  tsbCurrentPanel: state => {
+    const sn = state.tsb.currentDevice && state.tsb.currentDevice.deviceSn
+    if (sn == null) {
+      return 'home'
+    }
+    return state.tsb.devicePanelMap[sn] || 'home'
+  }
+}
+export default getters

+ 86 - 6
ruoyi-ui/src/store/modules/tsb.js

@@ -1,12 +1,22 @@
 const tsb = {
   namespaced: true,
   state: {
-    /** 当前选中的调试宝设备(首页卡片点击后写入) */
+    /** 当前操作的调试宝设备 */
     currentDevice: null,
+    /** 本页签已打开过的设备卡片 */
+    openedDevices: [],
+    /** 是否已完成页签会话检查 */
+    sessionReady: false,
+    /** 新页签且未在本页签选择过设备 */
+    isNewTabWithoutDevice: false,
     /** 各 cmdType 最新推送数据 */
     pageData: {},
     /** WebSocket 跳转时标记,页面初始化后清除 */
     initFromWs: {},
+    /** 各设备当前子页面:home / tax 等 */
+    devicePanelMap: {},
+    /** 各设备页面表单缓存:key = deviceSn::panel */
+    devicePageFormCache: {},
     /** 数据版本号,供页面 watch 实时更新 */
     pageDataVersion: 0
   },
@@ -14,22 +24,92 @@ const tsb = {
     SET_CURRENT_DEVICE(state, device) {
       state.currentDevice = device || null
     },
-    SET_WS_PAGE_DATA(state, { cmdType, data, navigate }) {
-      state.pageData = { ...state.pageData, [cmdType]: data }
+    ADD_OPENED_DEVICE(state, device) {
+      if (!device || device.deviceSn == null) {
+        return
+      }
+      if (!state.openedDevices.some(d => d.deviceSn === device.deviceSn)) {
+        state.openedDevices = [...state.openedDevices, device]
+      }
+    },
+    REMOVE_OPENED_DEVICE(state, deviceSn) {
+      if (deviceSn == null) {
+        return
+      }
+      state.openedDevices = state.openedDevices.filter(d => d.deviceSn !== deviceSn)
+      if (state.currentDevice && state.currentDevice.deviceSn === deviceSn) {
+        state.currentDevice = state.openedDevices.length
+          ? state.openedDevices[state.openedDevices.length - 1]
+          : null
+      }
+    },
+    SET_DEVICE_PANEL(state, { deviceSn, panel }) {
+      if (deviceSn == null || !panel) {
+        return
+      }
+      state.devicePanelMap = { ...state.devicePanelMap, [deviceSn]: panel }
+    },
+    SET_DEVICE_PANEL_MAP(state, map) {
+      state.devicePanelMap = map || {}
+    },
+    RESTORE_SESSION(state, session) {
+      if (!session) {
+        return
+      }
+      state.currentDevice = session.currentDevice || null
+      state.openedDevices = session.openedDevices || []
+      state.devicePanelMap = session.devicePanelMap || {}
+      state.sessionReady = true
+      state.isNewTabWithoutDevice = false
+    },
+    SET_SESSION_CHECKED(state, hasSession) {
+      state.sessionReady = true
+      state.isNewTabWithoutDevice = !hasSession
+    },
+    SET_DEVICE_PAGE_FORM(state, { key, data }) {
+      if (!key) {
+        return
+      }
+      state.devicePageFormCache = { ...state.devicePageFormCache, [key]: data }
+    },
+    CLEAR_DEVICE_PAGE_FORM(state, keyPrefix) {
+      if (!keyPrefix) {
+        return
+      }
+      const prefix = `${keyPrefix}::`
+      const next = { ...state.devicePageFormCache }
+      Object.keys(next).forEach((k) => {
+        if (k.startsWith(prefix)) {
+          delete next[k]
+        }
+      })
+      state.devicePageFormCache = next
+    },
+    SET_WS_PAGE_DATA(state, { cmdType, data, navigate, deviceSn }) {
+      const key = deviceSn != null ? `${deviceSn}::${cmdType}` : cmdType
+      state.pageData = { ...state.pageData, [key]: data }
       state.pageDataVersion += 1
       if (navigate) {
-        state.initFromWs = { ...state.initFromWs, [cmdType]: true }
+        state.initFromWs = { ...state.initFromWs, [key]: true }
       }
     },
-    CLEAR_WS_INIT(state, cmdType) {
+    CLEAR_WS_INIT(state, payload) {
+      const cmdType = payload && payload.cmdType ? payload.cmdType : payload
+      const deviceSn = payload && payload.deviceSn != null ? payload.deviceSn : null
+      const key = deviceSn != null ? `${deviceSn}::${cmdType}` : cmdType
       const initFromWs = { ...state.initFromWs }
-      delete initFromWs[cmdType]
+      delete initFromWs[key]
       state.initFromWs = initFromWs
     },
     RESET_TSB(state) {
       state.currentDevice = null
+      state.openedDevices = []
+      state.sessionReady = false
+      state.isNewTabWithoutDevice = false
       state.pageData = {}
       state.initFromWs = {}
+      state.devicePanelMap = {}
+      state.devicePageFormCache = {}
       state.pageDataVersion = 0
     }
   }

+ 35 - 2
ruoyi-ui/src/utils/tsbCmdRoute.js

@@ -5,9 +5,42 @@ export const TSB_CMD_ROUTE_MAP = {
   'common:tax': '/app-common/tax'
 }
 
+/** 设备工作区固定路由(切换设备不改地址) */
+export const TSB_WORKSPACE_ROUTE = '/tsb/workspace'
+
 /**
  * 根据 cmdType 获取路由路径
+ * @param {string} cmdType
+ * @param {number|string} [deviceSn] 有设备上下文时进入工作区
+ */
+export function getRouteByCmdType(cmdType, deviceSn) {
+  const base = TSB_CMD_ROUTE_MAP[cmdType]
+  if (!base) {
+    return null
+  }
+  if (deviceSn == null || deviceSn === '') {
+    return base
+  }
+  if (cmdType === 'common:tax') {
+    return TSB_WORKSPACE_ROUTE
+  }
+  return base
+}
+
+/**
+ * 是否处于某 cmdType 对应页面
+ * @param {string} path 当前路由
+ * @param {string} cmdType
+ * @param {number|string} [deviceSn]
+ * @param {string} [currentPanel] 工作区子页面
  */
-export function getRouteByCmdType(cmdType) {
-  return TSB_CMD_ROUTE_MAP[cmdType] || null
+export function isOnCmdRoute(path, cmdType, deviceSn, currentPanel) {
+  const legacy = TSB_CMD_ROUTE_MAP[cmdType]
+  if (path === legacy) {
+    return true
+  }
+  if (cmdType === 'common:tax' && path === TSB_WORKSPACE_ROUTE) {
+    return currentPanel === 'tax'
+  }
+  return false
 }

+ 37 - 0
ruoyi-ui/src/utils/tsbDeviceSession.js

@@ -0,0 +1,37 @@
+/** 页签内设备会话(sessionStorage,同页签刷新保留,新页签无数据) */
+const SESSION_KEY = 'tsb_device_session'
+
+export function loadTsbDeviceSession() {
+  try {
+    const raw = sessionStorage.getItem(SESSION_KEY)
+    return raw ? JSON.parse(raw) : null
+  } catch (e) {
+    return null
+  }
+}
+
+export function saveTsbDeviceSession(payload) {
+  sessionStorage.setItem(SESSION_KEY, JSON.stringify(payload))
+}
+
+export function clearTsbDeviceSession() {
+  sessionStorage.removeItem(SESSION_KEY)
+}
+
+export function hasTsbDeviceSession() {
+  const session = loadTsbDeviceSession()
+  return !!(session && session.userInitiatedConnect && session.currentDevice && session.currentDevice.deviceSn != null)
+}
+
+export function buildDeviceSession(currentDevice, openedDevices, devicePanelMap) {
+  const list = Array.isArray(openedDevices) ? openedDevices.slice() : []
+  if (currentDevice && !list.some(d => d.deviceSn === currentDevice.deviceSn)) {
+    list.push(currentDevice)
+  }
+  return {
+    currentDevice,
+    openedDevices: list,
+    devicePanelMap: devicePanelMap || {},
+    userInitiatedConnect: true
+  }
+}

+ 172 - 0
ruoyi-ui/src/utils/tsbDeviceTabRegistry.js

@@ -0,0 +1,172 @@
+import store from '@/store'
+
+/** 页签内固定 ID(sessionStorage 同页签刷新保持不变) */
+const TAB_ID_KEY = 'tsb_tab_id'
+const REGISTRY_PREFIX = 'tsb_opened_devices_'
+const HEARTBEAT_INTERVAL = 5000
+/** 超过该时间未心跳则视为页签已关闭,允许其他页签接管 */
+const ENTRY_TTL = 12000
+
+let heartbeatTimer = null
+let lifecycleInited = false
+
+function getTabId() {
+  try {
+    let id = sessionStorage.getItem(TAB_ID_KEY)
+    if (!id) {
+      id = `${Date.now()}-${Math.random()}`
+      sessionStorage.setItem(TAB_ID_KEY, id)
+    }
+    return id
+  } catch (e) {
+    return `${Date.now()}-${Math.random()}`
+  }
+}
+
+function getUserId() {
+  const id = store.getters.id
+  return id != null && id !== '' ? String(id) : null
+}
+
+function registryKey(userId) {
+  return `${REGISTRY_PREFIX}${userId}`
+}
+
+function loadRegistry(userId) {
+  try {
+    const raw = localStorage.getItem(registryKey(userId))
+    return raw ? JSON.parse(raw) : {}
+  } catch (e) {
+    return {}
+  }
+}
+
+function saveRegistry(userId, registry) {
+  localStorage.setItem(registryKey(userId), JSON.stringify(registry))
+}
+
+function cleanExpired(registry) {
+  const now = Date.now()
+  const next = { ...registry }
+  Object.keys(next).forEach((sn) => {
+    if (!next[sn] || now - next[sn].ts > ENTRY_TTL) {
+      delete next[sn]
+    }
+  })
+  return next
+}
+
+function touchRegistryEntries(deviceSns) {
+  const userId = getUserId()
+  if (!userId) {
+    return
+  }
+  const tabId = getTabId()
+  const registry = loadRegistry(userId)
+  let changed = false
+  deviceSns.forEach((sn) => {
+    const key = String(sn)
+    if (registry[key] && registry[key].tabId === tabId) {
+      registry[key].ts = Date.now()
+      changed = true
+    }
+  })
+  if (changed) {
+    saveRegistry(userId, registry)
+  }
+}
+
+export function registerDevice(deviceSn) {
+  const userId = getUserId()
+  if (!userId || deviceSn == null || deviceSn === '') {
+    return
+  }
+  const registry = cleanExpired(loadRegistry(userId))
+  registry[String(deviceSn)] = {
+    tabId: getTabId(),
+    ts: Date.now()
+  }
+  saveRegistry(userId, registry)
+}
+
+export function unregisterDevice(deviceSn) {
+  const userId = getUserId()
+  if (!userId || deviceSn == null || deviceSn === '') {
+    return
+  }
+  const registry = loadRegistry(userId)
+  const key = String(deviceSn)
+  const entry = registry[key]
+  if (entry && entry.tabId === getTabId()) {
+    delete registry[key]
+    saveRegistry(userId, registry)
+  }
+}
+
+export function unregisterAllForCurrentTab() {
+  const userId = getUserId()
+  if (!userId) {
+    return
+  }
+  const tabId = getTabId()
+  const registry = loadRegistry(userId)
+  let changed = false
+  Object.keys(registry).forEach((sn) => {
+    if (registry[sn] && registry[sn].tabId === tabId) {
+      delete registry[sn]
+      changed = true
+    }
+  })
+  if (changed) {
+    saveRegistry(userId, registry)
+  }
+}
+
+export function syncOpenedDevices(devices) {
+  const list = Array.isArray(devices) ? devices : []
+  list.forEach((device) => {
+    if (device && device.deviceSn != null) {
+      registerDevice(device.deviceSn)
+    }
+  })
+  startHeartbeat(list.map(d => d.deviceSn))
+}
+
+export function startHeartbeat(deviceSns) {
+  stopHeartbeat()
+  const sns = (deviceSns || []).filter(sn => sn != null && sn !== '').map(sn => String(sn))
+  if (!sns.length) {
+    return
+  }
+  touchRegistryEntries(sns)
+  heartbeatTimer = setInterval(() => touchRegistryEntries(sns), HEARTBEAT_INTERVAL)
+}
+
+export function stopHeartbeat() {
+  if (heartbeatTimer) {
+    clearInterval(heartbeatTimer)
+    heartbeatTimer = null
+  }
+}
+
+export function refreshRegistryHeartbeat(deviceSns) {
+  const sns = (deviceSns || []).filter(sn => sn != null && sn !== '')
+  if (!sns.length) {
+    stopHeartbeat()
+    return
+  }
+  startHeartbeat(sns)
+}
+
+/**
+ * 刷新页面仅停止心跳,不立刻删除注册(由 TTL 兜底;刷新后同页签会重新 sync)
+ */
+export function initTabRegistryLifecycle() {
+  if (lifecycleInited) {
+    return
+  }
+  lifecycleInited = true
+  const onUnload = () => stopHeartbeat()
+  window.addEventListener('pagehide', onUnload)
+  window.addEventListener('beforeunload', onUnload)
+}

+ 443 - 181
ruoyi-ui/src/utils/tsbWebSocket.js

@@ -1,37 +1,105 @@
-import { getToken } from '@/utils/auth'
+import store from '@/store'
 import { Message } from 'element-ui'
+import { getToken } from '@/utils/auth'
 import { getTsbWsBind } from '@/api/tsb/ws'
 import { initTsbWsRouter } from '@/utils/tsbWsRouter'
 import {
+  buildDeviceSession,
+  clearTsbDeviceSession,
+  hasTsbDeviceSession,
+  loadTsbDeviceSession,
+  saveTsbDeviceSession
+} from '@/utils/tsbDeviceSession'
+import {
   initTabCoordinator,
-  isLeaderTab,
   broadcastWsMessage,
   broadcastWsStatus,
-  requestSend,
-  requestLeaderReconnect,
   notifyLogout,
-  destroyTabCoordinator,
-  reclaimLeadershipIfStale
+  destroyTabCoordinator
 } from '@/utils/tsbWebSocketTab'
+import {
+  initTabRegistryLifecycle,
+  registerDevice,
+  refreshRegistryHeartbeat,
+  stopHeartbeat,
+  syncOpenedDevices,
+  unregisterAllForCurrentTab,
+  unregisterDevice
+} from '@/utils/tsbDeviceTabRegistry'
+import { ensureWorkspaceTag } from '@/utils/tsbWorkspaceNav'
+
+/** deviceSn -> 连接池条目 */
+const devicePool = new Map()
 
-let socket = null
-let reconnectTimer = null
-let manualClose = false
+let manualCloseAll = false
 let messageHandler = null
-let connectId = 0
-let reconnectAttempts = 0
-let reconnectExhaustedNotified = false
-let followerConnectedState = false
 let tabCoordinatorInited = false
 let wsRouterInited = false
-/** 当前选中的设备 SN(仅用户点击卡片后赋值) */
+let sessionInitialized = false
 let currentDeviceSn = null
-/** 是否由用户主动发起连接(用于控制提示与重连) */
 let userInitiatedConnect = false
 
 const RECONNECT_DELAY = 3000
 const MAX_RECONNECT_ATTEMPTS = 3
 
+/** 同一设备连接中的 Promise,避免重复预检 */
+const pendingConnects = new Map()
+
+function createPoolEntry() {
+  return {
+    socket: null,
+    connectId: 0,
+    reconnectTimer: null,
+    reconnectAttempts: 0,
+    reconnectExhaustedNotified: false,
+    manualClose: false
+  }
+}
+
+function poolKey(deviceSn) {
+  return String(deviceSn)
+}
+
+function getPoolEntry(deviceSn) {
+  const key = poolKey(deviceSn)
+  if (!devicePool.has(key)) {
+    devicePool.set(key, createPoolEntry())
+  }
+  return devicePool.get(key)
+}
+
+function isEntryConnected(entry) {
+  return !!(entry && entry.socket && entry.socket.readyState === WebSocket.OPEN)
+}
+
+function clearEntryReconnectTimer(entry) {
+  if (entry.reconnectTimer) {
+    clearTimeout(entry.reconnectTimer)
+    entry.reconnectTimer = null
+  }
+}
+
+function resetEntryReconnectAttempts(entry) {
+  entry.reconnectAttempts = 0
+  entry.reconnectExhaustedNotified = false
+}
+
+function getCurrentEntry() {
+  return currentDeviceSn != null ? getPoolEntry(currentDeviceSn) : null
+}
+
+function broadcastWsStatusForDevice(deviceSn) {
+  if (!isValidDeviceSn(deviceSn)) {
+    return
+  }
+  const entry = getPoolEntry(deviceSn)
+  broadcastWsStatus(isEntryConnected(entry), deviceSn)
+}
+
+function broadcastWsStatusForCurrent() {
+  broadcastWsStatusForDevice(currentDeviceSn)
+}
+
 function ensureWsRouter() {
   if (wsRouterInited) {
     return
@@ -46,49 +114,17 @@ function ensureTabCoordinator() {
   }
   tabCoordinatorInited = true
   initTabCoordinator({
-    onBecomeLeader: (forceReconnect) => {
-      if (!userInitiatedConnect || !currentDeviceSn) {
-        return
-      }
-      if (forceReconnect) {
-        leaderDisconnectSilently()
-        resetReconnectAttempts()
-        runPrecheckAndConnect(false)
-        return
-      }
-      runPrecheckAndConnect(true)
-    },
-    onBecomeFollower: () => leaderDisconnectSilently(),
-    onBroadcastMessage: (message) => messageHandler?.(message),
-    onFollowerSend: (payload) => leaderSend(payload.cmdType, payload.data),
-    onLeaderStatus: (connected) => {
-      followerConnectedState = connected
-    },
-    onStatusRequest: () => {
-      const connected = socket && socket.readyState === WebSocket.OPEN
-      broadcastWsStatus(connected)
-    }
+    onTabLogout: () => disconnectAllDevices(false),
+    onBroadcastMessage: (message) => dispatchBroadcastMessage(message)
   })
 }
 
-function resetReconnectAttempts() {
-  reconnectAttempts = 0
-  reconnectExhaustedNotified = false
-}
-
 function buildWsUrl(token, deviceSn) {
   const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
   const base = process.env.VUE_APP_BASE_API || ''
   return `${protocol}//${window.location.host}${base}/websocket/tsb?token=${encodeURIComponent(token)}&deviceSn=${encodeURIComponent(deviceSn)}`
 }
 
-function clearReconnectTimer() {
-  if (reconnectTimer) {
-    clearTimeout(reconnectTimer)
-    reconnectTimer = null
-  }
-}
-
 function extractErrorMessage(err) {
   if (!err) {
     return '预检失败'
@@ -99,232 +135,434 @@ function extractErrorMessage(err) {
   return err.msg || err.message || '预检失败'
 }
 
-function runPrecheck(deviceSn, silent = true) {
-  return getTsbWsBind(deviceSn, silent).then(res => res.data)
+function isValidDeviceSn(deviceSn) {
+  return deviceSn != null && deviceSn !== ''
 }
 
-function runPrecheckAndConnect(resetAttempts = true) {
-  if (!isLeaderTab() || !currentDeviceSn || manualClose) {
-    return Promise.resolve(false)
-  }
-  const token = getToken()
-  if (!token) {
-    return Promise.resolve(false)
+function isDeviceStillOpened(deviceSn) {
+  return (store.getters.tsbOpenedDevices || []).some(
+    d => String(d.deviceSn) === String(deviceSn)
+  )
+}
+
+/** 无本会话:新浏览器页签从首页点卡片,须本页预检建连,禁止借用 Leader 已有连接 */
+function isFreshTabConnect() {
+  return !hasTsbDeviceSession()
+}
+
+function notifyPrecheckFailure(deviceSn, err, silent) {
+  if (silent || poolKey(deviceSn) !== poolKey(currentDeviceSn)) {
+    return
   }
-  return runPrecheck(currentDeviceSn).then(() => {
-    leaderConnect(resetAttempts)
-    return true
-  })
+  Message.error(extractErrorMessage(err))
 }
 
-function scheduleReconnect() {
-  if (!userInitiatedConnect || !currentDeviceSn || !isLeaderTab() || manualClose || !getToken()) {
+function dispatchBroadcastMessage(message) {
+  if (!messageHandler || !message) {
     return
   }
-  if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
-    if (!reconnectExhaustedNotified) {
-      reconnectExhaustedNotified = true
-      Message.warning(`WebSocket 自动重连已达上限(${MAX_RECONNECT_ATTEMPTS} 次),请手动重连`)
-    }
-    broadcastWsStatus(false)
+  const msgSn = message._tsbDeviceSn
+  const tabSn = store.getters.tsbCurrentDeviceSn || currentDeviceSn
+  if (msgSn != null && tabSn != null && poolKey(msgSn) !== poolKey(tabSn)) {
     return
   }
-  reconnectAttempts++
-  clearReconnectTimer()
-  reconnectTimer = setTimeout(() => {
-    runPrecheck(currentDeviceSn).then(() => {
-      leaderConnect(false)
-    }).catch(() => {
-      scheduleReconnect()
-    })
-  }, RECONNECT_DELAY)
+  const payload = { ...message }
+  delete payload._tsbDeviceSn
+  messageHandler(payload)
+}
+
+function persistDeviceSession(device) {
+  if (!device || device.deviceSn == null) {
+    return
+  }
+  const prev = loadTsbDeviceSession()
+  const openedDevices = (prev && prev.openedDevices) || store.getters.tsbOpenedDevices || []
+  const devicePanelMap = store.state.tsb.devicePanelMap || (prev && prev.devicePanelMap) || {}
+  const session = buildDeviceSession(device, openedDevices, devicePanelMap)
+  saveTsbDeviceSession(session)
+  store.commit('tsb/SET_CURRENT_DEVICE', device)
+  store.commit('tsb/ADD_OPENED_DEVICE', device)
+  store.commit('tsb/SET_SESSION_CHECKED', true)
+  registerDevice(device.deviceSn)
+  refreshRegistryHeartbeat((store.getters.tsbOpenedDevices || []).map(d => d.deviceSn))
+}
+
+function restoreDeviceSession() {
+  const session = loadTsbDeviceSession()
+  if (!hasTsbDeviceSession()) {
+    store.commit('tsb/SET_SESSION_CHECKED', false)
+    return false
+  }
+  store.commit('tsb/RESTORE_SESSION', session)
+  currentDeviceSn = session.currentDevice.deviceSn
+  userInitiatedConnect = true
+  return true
+}
+
+function runPrecheck(deviceSn, silent = true) {
+  if (!isValidDeviceSn(deviceSn)) {
+    return Promise.reject('请指定设备')
+  }
+  return getTsbWsBind(deviceSn, silent).then(res => res.data || {})
 }
 
-function bindSocketEvents(ws, id) {
+function bindSocketEvents(ws, entry, deviceSn) {
+  const id = entry.connectId
   ws.onopen = () => {
-    if (id !== connectId) {
+    if (id !== entry.connectId) {
       return
     }
-    clearReconnectTimer()
-    broadcastWsStatus(true)
+    clearEntryReconnectTimer(entry)
+    broadcastWsStatusForDevice(deviceSn)
   }
   ws.onmessage = (evt) => {
-    if (id !== connectId || !messageHandler) {
+    if (id !== entry.connectId || !messageHandler) {
       return
     }
     try {
       const message = JSON.parse(evt.data)
-      messageHandler(message)
-      broadcastWsMessage(message)
+      broadcastWsMessage({ ...message, _tsbDeviceSn: deviceSn })
+      if (poolKey(deviceSn) === poolKey(currentDeviceSn)) {
+        messageHandler(message)
+      }
     } catch (e) {
       console.warn('TSB WebSocket 消息解析失败', e)
     }
   }
   ws.onclose = () => {
-    if (id !== connectId) {
+    if (id !== entry.connectId) {
       return
     }
-    socket = null
-    broadcastWsStatus(false)
-    if (!manualClose && userInitiatedConnect && isLeaderTab() && getToken() && currentDeviceSn) {
-      scheduleReconnect()
+    entry.socket = null
+    broadcastWsStatusForDevice(deviceSn)
+    if (!entry.manualClose && !manualCloseAll && userInitiatedConnect && getToken()
+      && isDeviceStillOpened(deviceSn)) {
+      scheduleReconnectForDevice(deviceSn)
     }
   }
   ws.onerror = () => {
-    if (id !== connectId) {
+    if (id !== entry.connectId) {
       return
     }
     ws.close()
   }
 }
 
-function leaderConnect(resetAttempts = true) {
+function leaderConnectDevice(deviceSn, resetAttempts = true) {
   ensureTabCoordinator()
-  if (!isLeaderTab() || !currentDeviceSn) {
+  if (!userInitiatedConnect) {
     return
   }
   const token = getToken()
-  if (!token || manualClose) {
+  if (!token || manualCloseAll) {
     return
   }
-  if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
+  const entry = getPoolEntry(deviceSn)
+  if (isEntryConnected(entry) || (entry.socket && entry.socket.readyState === WebSocket.CONNECTING)) {
     return
   }
-  clearReconnectTimer()
+  clearEntryReconnectTimer(entry)
   if (resetAttempts !== false) {
-    resetReconnectAttempts()
+    resetEntryReconnectAttempts(entry)
   }
-  manualClose = false
-  const id = ++connectId
-  socket = new WebSocket(buildWsUrl(token, currentDeviceSn))
-  bindSocketEvents(socket, id)
+  entry.manualClose = false
+  entry.connectId++
+  entry.socket = new WebSocket(buildWsUrl(token, deviceSn))
+  bindSocketEvents(entry.socket, entry, deviceSn)
 }
 
-function leaderDisconnectSilently() {
-  manualClose = true
-  clearReconnectTimer()
-  connectId++
-  if (socket) {
-    socket.close()
-    socket = null
+function applyPrecheckResultForDevice(deviceSn, bind, resetAttempts = true) {
+  const entry = getPoolEntry(deviceSn)
+  if (isEntryConnected(entry)) {
+    clearEntryReconnectTimer(entry)
+    if (resetAttempts !== false) {
+      resetEntryReconnectAttempts(entry)
+    }
+    broadcastWsStatusForDevice(deviceSn)
+    return bind
+  }
+  leaderConnectDevice(deviceSn, resetAttempts)
+  return bind
+}
+
+function scheduleReconnectForDevice(deviceSn) {
+  if (!isValidDeviceSn(deviceSn) || !isDeviceStillOpened(deviceSn)
+    || !userInitiatedConnect || manualCloseAll || !getToken()) {
+    return
+  }
+  const entry = getPoolEntry(deviceSn)
+  if (entry.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
+    if (!entry.reconnectExhaustedNotified) {
+      entry.reconnectExhaustedNotified = true
+    }
+    broadcastWsStatusForDevice(deviceSn)
+    return
   }
-  manualClose = false
-  broadcastWsStatus(false)
+  entry.reconnectAttempts++
+  clearEntryReconnectTimer(entry)
+  entry.reconnectTimer = setTimeout(() => {
+    if (!isDeviceStillOpened(deviceSn)) {
+      return
+    }
+    runPrecheck(deviceSn, true).then((bind) => {
+      applyPrecheckResultForDevice(deviceSn, bind, false)
+    }).catch((err) => {
+      clearEntryReconnectTimer(entry)
+      entry.reconnectAttempts = MAX_RECONNECT_ATTEMPTS
+      notifyPrecheckFailure(deviceSn, err, false)
+      broadcastWsStatusForDevice(deviceSn)
+    })
+  }, RECONNECT_DELAY)
+}
+
+function ensureDeviceConnection(deviceSn, silent = true) {
+  if (!isValidDeviceSn(deviceSn) || !userInitiatedConnect || manualCloseAll) {
+    return Promise.resolve(false)
+  }
+  if (!isDeviceStillOpened(deviceSn)) {
+    return Promise.resolve(false)
+  }
+  const entry = getPoolEntry(deviceSn)
+  if (isEntryConnected(entry)) {
+    broadcastWsStatusForDevice(deviceSn)
+    return Promise.resolve(true)
+  }
+  if (!getToken()) {
+    return Promise.resolve(false)
+  }
+  return runPrecheck(deviceSn, true).then((bind) => {
+    applyPrecheckResultForDevice(deviceSn, bind, silent !== false)
+    return true
+  }).catch((err) => {
+    notifyPrecheckFailure(deviceSn, err, silent)
+    return false
+  })
+}
+
+function closeDeviceConnection(deviceSn, removeFromPool = true) {
+  const key = poolKey(deviceSn)
+  const entry = devicePool.get(key)
+  if (!entry) {
+    return
+  }
+  entry.manualClose = true
+  clearEntryReconnectTimer(entry)
+  entry.connectId++
+  if (entry.socket) {
+    entry.socket.close()
+    entry.socket = null
+  }
+  entry.manualClose = false
+  if (removeFromPool) {
+    devicePool.delete(key)
+  }
+  broadcastWsStatusForDevice(deviceSn)
+}
+
+function forceReconnectDevice(deviceSn) {
+  if (!isValidDeviceSn(deviceSn)) {
+    return Promise.reject('请先在首页选择设备')
+  }
+  const inPool = devicePool.has(poolKey(deviceSn))
+  if (!isDeviceStillOpened(deviceSn) && !inPool) {
+    return Promise.reject('设备已关闭,请重新选择')
+  }
+  closeDeviceConnection(deviceSn, false)
+  const entry = getPoolEntry(deviceSn)
+  resetEntryReconnectAttempts(entry)
+  return runPrecheck(deviceSn, true).then((bind) => {
+    applyPrecheckResultForDevice(deviceSn, bind, true)
+    if (poolKey(deviceSn) === poolKey(currentDeviceSn)) {
+      broadcastWsStatusForCurrent()
+    }
+    return bind
+  }).catch((err) => Promise.reject(extractErrorMessage(err)))
+}
+
+function disconnectAllDevices(logout) {
+  manualCloseAll = true
+  devicePool.forEach((entry, sn) => {
+    entry.manualClose = true
+    clearEntryReconnectTimer(entry)
+    entry.connectId++
+    if (entry.socket) {
+      entry.socket.close()
+      entry.socket = null
+    }
+    entry.manualClose = false
+  })
+  const closedSns = [...devicePool.keys()]
+  devicePool.clear()
+  closedSns.forEach((sn) => broadcastWsStatus(false, sn))
+  if (logout) {
+    currentDeviceSn = null
+    userInitiatedConnect = false
+    sessionInitialized = false
+    clearTsbDeviceSession()
+    stopHeartbeat()
+    unregisterAllForCurrentTab()
+    notifyLogout()
+    destroyTabCoordinator()
+    tabCoordinatorInited = false
+    wsRouterInited = false
+  }
+  manualCloseAll = false
 }
 
 function leaderSend(cmdType, data) {
-  if (!socket || socket.readyState !== WebSocket.OPEN) {
+  const entry = getCurrentEntry()
+  if (!isEntryConnected(entry)) {
     return false
   }
-  socket.send(JSON.stringify({
+  entry.socket.send(JSON.stringify({
     cmdType,
     data: data || {}
   }))
   return true
 }
 
-/** 初始化协调器,不自动连接 */
 function connect() {
   ensureWsRouter()
-  ensureTabCoordinator()
-  reclaimLeadershipIfStale()
+  if (userInitiatedConnect && hasTsbDeviceSession()) {
+    ensureTabCoordinator()
+  }
 }
 
-/** 登录后不自动连接 */
 function connectAfterLogin() {
+  if (sessionInitialized) {
+    return
+  }
+  sessionInitialized = true
   ensureWsRouter()
+  if (!restoreDeviceSession()) {
+    return
+  }
+  initTabRegistryLifecycle()
+  syncOpenedDevices(store.getters.tsbOpenedDevices || [])
   ensureTabCoordinator()
+  if (!getToken()) {
+    return
+  }
+  const opened = store.getters.tsbOpenedDevices || []
+  opened.forEach((device) => {
+    if (device && isValidDeviceSn(device.deviceSn)) {
+      ensureDeviceConnection(device.deviceSn, true)
+    }
+  })
+  ensureWorkspaceTag()
 }
 
-/**
- * 用户点击设备卡片后:预检通过再建立 WebSocket
- */
-function connectWithDevice(deviceSn) {
-  if (deviceSn == null || deviceSn === '') {
+function switchToDevice(device) {
+  if (!device || !isValidDeviceSn(device.deviceSn)) {
     return Promise.reject('请指定设备')
   }
+  const opened = store.getters.tsbOpenedDevices || []
+  if (!opened.some(d => String(d.deviceSn) === String(device.deviceSn))) {
+    return connectWithDevice(device)
+  }
   ensureWsRouter()
   ensureTabCoordinator()
-  reclaimLeadershipIfStale()
-  if (currentDeviceSn != null && currentDeviceSn !== deviceSn) {
-    leaderDisconnectSilently()
-  }
-  currentDeviceSn = deviceSn
+  currentDeviceSn = device.deviceSn
   userInitiatedConnect = true
-  if (!isLeaderTab()) {
-    return Promise.resolve()
+  store.commit('tsb/SET_CURRENT_DEVICE', device)
+  const panelMap = store.state.tsb.devicePanelMap || {}
+  saveTsbDeviceSession(buildDeviceSession(device, opened, panelMap))
+  broadcastWsStatusForCurrent()
+  const entry = getPoolEntry(device.deviceSn)
+  if (isEntryConnected(entry)) {
+    return Promise.resolve(device)
   }
-  return runPrecheck(deviceSn, false).then((bind) => {
-    leaderConnect(true)
-    return bind
-  }).catch((err) => {
-    return Promise.reject(extractErrorMessage(err))
+  return ensureDeviceConnection(device.deviceSn, true).then(() => device)
+}
+
+function ensureCurrentDeviceConnection() {
+  if (!userInitiatedConnect || !isValidDeviceSn(currentDeviceSn)) {
+    return Promise.resolve(false)
+  }
+  return ensureDeviceConnection(currentDeviceSn, true)
+}
+
+function connectWithDevice(deviceOrSn) {
+  const device = typeof deviceOrSn === 'object' ? deviceOrSn : { deviceSn: deviceOrSn }
+  if (!isValidDeviceSn(device.deviceSn)) {
+    return Promise.reject('请指定设备')
+  }
+  const key = poolKey(device.deviceSn)
+  if (pendingConnects.has(key)) {
+    return pendingConnects.get(key)
+  }
+  const task = doConnectWithDevice(device).finally(() => {
+    pendingConnects.delete(key)
   })
+  pendingConnects.set(key, task)
+  return task
 }
 
-function disconnect(logout) {
+function doConnectWithDevice(device) {
+  initTabRegistryLifecycle()
+
+  ensureWsRouter()
   ensureTabCoordinator()
-  manualClose = true
-  clearReconnectTimer()
-  resetReconnectAttempts()
-  connectId++
-  if (socket) {
-    socket.close()
-    socket = null
-  }
-  followerConnectedState = false
-  broadcastWsStatus(false)
-  if (logout) {
-    currentDeviceSn = null
-    userInitiatedConnect = false
-    notifyLogout()
-    destroyTabCoordinator()
-    tabCoordinatorInited = false
-    wsRouterInited = false
-  } else {
-    manualClose = false
+
+  const entry = getPoolEntry(device.deviceSn)
+  if (isEntryConnected(entry) && !isFreshTabConnect()) {
+    persistDeviceSession(device)
+    currentDeviceSn = device.deviceSn
+    userInitiatedConnect = true
+    broadcastWsStatusForCurrent()
+    return Promise.resolve(device)
   }
+
+  // 静默预检,错误由调用方统一提示一次(避免与 axios 拦截器重复弹窗)
+  return runPrecheck(device.deviceSn, true).then((bind) => {
+    persistDeviceSession(device)
+    currentDeviceSn = device.deviceSn
+    userInitiatedConnect = true
+    applyPrecheckResultForDevice(device.deviceSn, bind, true)
+    broadcastWsStatusForCurrent()
+    return bind
+  }).catch((err) => Promise.reject(extractErrorMessage(err)))
+}
+
+function disconnect(logout) {
+  ensureTabCoordinator()
+  disconnectAllDevices(logout)
 }
 
 function reconnect() {
-  if (!currentDeviceSn) {
+  if (!userInitiatedConnect || !isValidDeviceSn(currentDeviceSn)) {
     return Promise.reject('请先在首页选择设备')
   }
-  ensureTabCoordinator()
-  clearReconnectTimer()
-  resetReconnectAttempts()
-  userInitiatedConnect = true
-  if (isLeaderTab()) {
-    return runPrecheck(currentDeviceSn, false).then((bind) => {
-      leaderDisconnectSilently()
-      leaderConnect(true)
-      return bind
-    }).catch((err) => Promise.reject(extractErrorMessage(err)))
+  if (!isDeviceStillOpened(currentDeviceSn)) {
+    return Promise.reject('设备已关闭,请重新选择')
   }
-  requestLeaderReconnect()
-  return Promise.resolve()
+  ensureTabCoordinator()
+  return forceReconnectDevice(currentDeviceSn)
 }
 
 function isReconnectExhausted() {
-  return reconnectAttempts >= MAX_RECONNECT_ATTEMPTS
+  const entry = getCurrentEntry()
+  return entry ? entry.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS : false
 }
 
 function notifyConnectSuccess() {
-  resetReconnectAttempts()
-  broadcastWsStatus(true)
+  const entry = getCurrentEntry()
+  if (entry) {
+    resetEntryReconnectAttempts(entry)
+  }
+  broadcastWsStatusForCurrent()
 }
 
 function notifyConnectFailed() {
-  // 鉴权失败等场景由 onclose 触发 scheduleReconnect
+  // 鉴权失败等场景由 onclose 触发 scheduleReconnectForDevice
 }
 
 function send(cmdType, data) {
-  ensureTabCoordinator()
-  if (isLeaderTab()) {
-    return leaderSend(cmdType, data)
+  if (!hasSelectedDevice()) {
+    return false
   }
-  requestSend({ cmdType, data })
-  return true
+  ensureTabCoordinator()
+  return leaderSend(cmdType, data)
 }
 
 function sendPageSync(cmdType, data) {
@@ -336,25 +574,46 @@ function onMessage(handler) {
 }
 
 function isConnected() {
-  if (isLeaderTab()) {
-    return socket && socket.readyState === WebSocket.OPEN
-  }
-  return followerConnectedState
+  return isEntryConnected(getCurrentEntry())
 }
 
 function getReconnectAttempts() {
-  return reconnectAttempts
+  const entry = getCurrentEntry()
+  return entry ? entry.reconnectAttempts : 0
 }
 
 function getCurrentDeviceSn() {
   return currentDeviceSn
 }
 
+function hasSelectedDevice() {
+  return userInitiatedConnect && currentDeviceSn != null
+}
+
+function releaseWorkspaceSession() {
+  const sn = currentDeviceSn
+  currentDeviceSn = null
+  userInitiatedConnect = false
+  stopHeartbeat()
+  unregisterAllForCurrentTab()
+  if (isValidDeviceSn(sn)) {
+    broadcastWsStatus(false, sn)
+  }
+}
+
+function onDeviceTabClosed(deviceSn) {
+  unregisterDevice(deviceSn)
+  refreshRegistryHeartbeat((store.getters.tsbOpenedDevices || []).map(d => d.deviceSn))
+}
+
 export default {
   connect,
   connectAfterLogin,
   connectWithDevice,
+  switchToDevice,
+  ensureCurrentDeviceConnection,
   disconnect,
+  disconnectDevice: closeDeviceConnection,
   reconnect,
   send,
   sendPageSync,
@@ -363,6 +622,9 @@ export default {
   isReconnectExhausted,
   getReconnectAttempts,
   getCurrentDeviceSn,
+  hasSelectedDevice,
+  releaseWorkspaceSession,
+  onDeviceTabClosed,
   notifyConnectSuccess,
   notifyConnectFailed
 }

+ 16 - 175
ruoyi-ui/src/utils/tsbWebSocketTab.js

@@ -1,47 +1,15 @@
 /**
- * 多标签页 WebSocket 协调:同一用户仅一个标签页持有连接
+ * 多标签页 WebSocket:每页签独立持连,频道仅用于登出同步与消息透传
  */
 const TAB_ID = `${Date.now()}-${Math.random()}`
 const CHANNEL_NAME = 'tsb-ws-tab'
-const LEADER_STORAGE_KEY = 'tsb-ws-leader'
-const LEADER_TTL = 5000
-const HEARTBEAT_INTERVAL = 2000
 
 let channel = null
-let heartbeatTimer = null
-let isLeader = false
-let followerConnected = false
+let coordinatorInited = false
 
 const handlers = {
-  onBecomeLeader: null,
-  onBecomeFollower: null,
-  onBroadcastMessage: null,
-  onFollowerSend: null,
-  onLeaderStatus: null,
-  onStatusRequest: null
-}
-
-function readLeader() {
-  try {
-    const raw = localStorage.getItem(LEADER_STORAGE_KEY)
-    return raw ? JSON.parse(raw) : null
-  } catch (e) {
-    return null
-  }
-}
-
-function writeLeader() {
-  localStorage.setItem(LEADER_STORAGE_KEY, JSON.stringify({
-    id: TAB_ID,
-    ts: Date.now()
-  }))
-}
-
-function clearLeaderIfMine() {
-  const leader = readLeader()
-  if (leader && leader.id === TAB_ID) {
-    localStorage.removeItem(LEADER_STORAGE_KEY)
-  }
+  onTabLogout: null,
+  onBroadcastMessage: null
 }
 
 function postChannel(type, payload) {
@@ -51,64 +19,6 @@ function postChannel(type, payload) {
   channel.postMessage({ type, payload, from: TAB_ID })
 }
 
-function becomeLeader() {
-  if (isLeader) {
-    return
-  }
-  isLeader = true
-  followerConnected = false
-  handlers.onBecomeLeader?.()
-}
-
-function becomeFollower() {
-  const wasLeader = isLeader
-  isLeader = false
-  if (wasLeader) {
-    handlers.onBecomeFollower?.()
-  }
-  postChannel('ws-status-request', {})
-}
-
-function isLeaderFresh(leader) {
-  return leader && (Date.now() - leader.ts < LEADER_TTL)
-}
-
-function tryClaimLeadership() {
-  const leader = readLeader()
-  if (!isLeaderFresh(leader) || leader.id === TAB_ID) {
-    writeLeader()
-    becomeLeader()
-    return true
-  }
-  if (leader.id !== TAB_ID) {
-    becomeFollower()
-    return false
-  }
-  return isLeader
-}
-
-function maintainLeadership() {
-  if (isLeader) {
-    writeLeader()
-    return
-  }
-  tryClaimLeadership()
-}
-
-function onStorageChange(e) {
-  if (e.key !== LEADER_STORAGE_KEY) {
-    return
-  }
-  const leader = readLeader()
-  if (isLeader && leader && leader.id !== TAB_ID) {
-    becomeFollower()
-    return
-  }
-  if (!isLeader && !isLeaderFresh(leader)) {
-    tryClaimLeadership()
-  }
-}
-
 function onChannelMessage(evt) {
   const { type, payload, from } = evt.data || {}
   if (from === TAB_ID) {
@@ -116,119 +26,50 @@ function onChannelMessage(evt) {
   }
   if (type === 'ws-message') {
     handlers.onBroadcastMessage?.(payload)
-  } else if (type === 'ws-send' && isLeader) {
-    handlers.onFollowerSend?.(payload)
-  } else if (type === 'ws-status') {
-    if (!isLeader) {
-      followerConnected = !!payload?.connected
-      handlers.onLeaderStatus?.(followerConnected)
-    }
-  } else if (type === 'ws-status-request' && isLeader) {
-    handlers.onStatusRequest?.()
   } else if (type === 'ws-logout') {
-    isLeader = false
-    followerConnected = false
-    handlers.onBecomeFollower?.()
-  } else if (type === 'ws-reconnect' && isLeader) {
-    handlers.onBecomeLeader?.(true)
-  }
-}
-
-function releaseLeadership() {
-  clearLeaderIfMine()
-}
-
-function notifyTabLogout() {
-  clearLeaderIfMine()
-  postChannel('ws-logout', {})
-}
-
-export function resetLeadershipForLogin() {
-  localStorage.removeItem(LEADER_STORAGE_KEY)
-  if (channel) {
-    tryClaimLeadership()
-  }
-}
-
-/** Leader 心跳过期时重新抢占(避免关闭标签页后残留 leader 导致无法建连) */
-export function reclaimLeadershipIfStale() {
-  const leader = readLeader()
-  if (!isLeaderFresh(leader)) {
-    localStorage.removeItem(LEADER_STORAGE_KEY)
-    tryClaimLeadership()
+    handlers.onTabLogout?.()
   }
 }
 
 export function initTabCoordinator(options = {}) {
   Object.assign(handlers, options)
+  if (coordinatorInited) {
+    return
+  }
+  coordinatorInited = true
 
   if (typeof BroadcastChannel === 'undefined') {
-    becomeLeader()
     return
   }
 
   channel = new BroadcastChannel(CHANNEL_NAME)
   channel.onmessage = onChannelMessage
-  window.addEventListener('storage', onStorageChange)
-  window.addEventListener('beforeunload', releaseLeadership)
-  window.addEventListener('pagehide', releaseLeadership)
-
-  tryClaimLeadership()
-  if (!isLeader) {
-    postChannel('ws-status-request', {})
-  }
-  if (heartbeatTimer) {
-    clearInterval(heartbeatTimer)
-  }
-  heartbeatTimer = setInterval(maintainLeadership, HEARTBEAT_INTERVAL)
 }
 
+/** 每页签独立持连,本页始终视为可持有本地 WebSocket */
 export function isLeaderTab() {
-  return isLeader
+  return true
 }
 
 export function isFollowerConnected() {
-  return followerConnected
+  return false
 }
 
 export function broadcastWsMessage(message) {
   postChannel('ws-message', message)
 }
 
-export function broadcastWsStatus(connected) {
-  if (!isLeader) {
-    return
-  }
-  postChannel('ws-status', { connected })
-}
-
-export function requestSend(payload) {
-  postChannel('ws-send', payload)
-}
-
-export function requestLeaderReconnect() {
-  tryClaimLeadership()
-  postChannel('ws-reconnect', {})
-  if (isLeader) {
-    handlers.onBecomeLeader?.(true)
-  }
-}
+/** 连接状态仅本页签维护,不再跨页签广播 */
+export function broadcastWsStatus() {}
 
 export function notifyLogout() {
-  notifyTabLogout()
+  postChannel('ws-logout', {})
 }
 
 export function destroyTabCoordinator() {
-  if (heartbeatTimer) {
-    clearInterval(heartbeatTimer)
-    heartbeatTimer = null
-  }
-  releaseLeadership()
   if (channel) {
     channel.close()
     channel = null
   }
-  window.removeEventListener('storage', onStorageChange)
-  window.removeEventListener('beforeunload', releaseLeadership)
-  window.removeEventListener('pagehide', releaseLeadership)
+  coordinatorInited = false
 }

+ 57 - 0
ruoyi-ui/src/utils/tsbWorkspaceNav.js

@@ -0,0 +1,57 @@
+import router from '@/router'
+import store from '@/store'
+import { TSB_WORKSPACE_ROUTE } from '@/utils/tsbCmdRoute'
+
+export const TSB_WORKSPACE_NAME = 'TsbWorkspace'
+
+export function isWorkspaceRoute(route) {
+  const r = route || router.currentRoute
+  return r.name === TSB_WORKSPACE_NAME || r.path === TSB_WORKSPACE_ROUTE
+}
+
+export function findWorkspaceVisitedView() {
+  return store.state.tagsView.visitedViews.find(
+    v => v.name === TSB_WORKSPACE_NAME || v.path === TSB_WORKSPACE_ROUTE
+  )
+}
+
+/** 移除重复的「设备操作」页签,只保留最早一条 */
+export function dedupeWorkspaceTags() {
+  const views = store.state.tagsView.visitedViews
+  const matched = views.filter(
+    v => v.name === TSB_WORKSPACE_NAME || v.path === TSB_WORKSPACE_ROUTE
+  )
+  if (matched.length <= 1) {
+    return
+  }
+  matched.slice(1).forEach((view) => {
+    store.dispatch('tagsView/delView', view)
+  })
+}
+
+/** 刷新等工作区页已打开时,确保 tags 中存在一条「设备操作」 */
+export function ensureWorkspaceTag() {
+  if (!isWorkspaceRoute()) {
+    return
+  }
+  dedupeWorkspaceTags()
+  if (!findWorkspaceVisitedView()) {
+    store.dispatch('tagsView/addView', router.currentRoute)
+  }
+}
+
+/**
+ * 进入设备工作区:已存在「设备操作」页签则激活,不新建
+ */
+export function navigateToWorkspace() {
+  dedupeWorkspaceTags()
+  const existing = findWorkspaceVisitedView()
+  if (existing) {
+    const target = existing.fullPath || TSB_WORKSPACE_ROUTE
+    if (router.currentRoute.fullPath === target) {
+      return Promise.resolve()
+    }
+    return router.push(target)
+  }
+  return router.push({ name: TSB_WORKSPACE_NAME })
+}

+ 17 - 5
ruoyi-ui/src/utils/tsbWsRouter.js

@@ -1,7 +1,8 @@
 import store from '@/store'
 import router from '@/router'
 import { Message } from 'element-ui'
-import { getRouteByCmdType } from '@/utils/tsbCmdRoute'
+import { getRouteByCmdType, isOnCmdRoute, TSB_WORKSPACE_ROUTE } from '@/utils/tsbCmdRoute'
+import { navigateToWorkspace } from '@/utils/tsbWorkspaceNav'
 import tsbWebSocket from '@/utils/tsbWebSocket'
 
 /**
@@ -24,7 +25,8 @@ export function handleTsbWsMessage(msg) {
     return
   }
 
-  const routePath = getRouteByCmdType(msg.cmdType)
+  const deviceSn = store.getters.tsbCurrentDeviceSn || tsbWebSocket.getCurrentDeviceSn()
+  const routePath = getRouteByCmdType(msg.cmdType, deviceSn)
   if (!routePath) {
     return
   }
@@ -51,15 +53,25 @@ export function handleTsbWsMessage(msg) {
     return
   }
 
-  const onTargetPage = router.currentRoute.path === routePath
+  if (msg.cmdType === 'common:tax' && deviceSn != null && routePath === TSB_WORKSPACE_ROUTE) {
+    store.commit('tsb/SET_DEVICE_PANEL', { deviceSn, panel: 'tax' })
+  }
+
+  const currentPanel = store.getters.tsbCurrentPanel
+  const onTargetPage = isOnCmdRoute(router.currentRoute.path, msg.cmdType, deviceSn, currentPanel)
   store.commit('tsb/SET_WS_PAGE_DATA', {
     cmdType: msg.cmdType,
     data,
-    navigate: !onTargetPage
+    navigate: !onTargetPage,
+    deviceSn
   })
 
   if (!onTargetPage) {
-    router.push(routePath).catch(() => {})
+    if (routePath === TSB_WORKSPACE_ROUTE) {
+      navigateToWorkspace().catch(() => {})
+    } else {
+      router.push(routePath).catch(() => {})
+    }
   }
 }
 

+ 126 - 23
ruoyi-ui/src/views/app-common/tax/index.vue

@@ -1,14 +1,23 @@
 <template>
   <div class="app-container tax-page">
     <el-alert
-      v-if="!currentDeviceSn"
+      v-if="showNewTabHint"
+      title="当前页签未选择设备,请前往首页选择已登录设备后再操作"
+      type="warning"
+      show-icon
+      :closable="false"
+      class="mb16"
+    />
+    <el-alert
+      v-else-if="!currentDeviceSn"
       title="请先在首页选择已登录设备并建立连接"
       type="warning"
       show-icon
       :closable="false"
       class="mb16"
     />
-    <el-row v-else type="flex" justify="space-between" align="middle" class="mb16">
+    <template v-if="canOperateDevice">
+    <el-row type="flex" justify="space-between" align="middle" class="mb16">
       <el-col :span="16">
         <el-tag :type="wsConnected ? 'success' : 'info'" size="small">
           {{ wsConnected ? 'WebSocket 已连接' : 'WebSocket 未连接' }}
@@ -98,16 +107,16 @@
         </el-card>
       </el-col>
     </el-row>
+    </template>
   </div>
 </template>
 
 <script>
 import { getTsbWsBind } from '@/api/tsb/ws'
 import tsbWebSocket from '@/utils/tsbWebSocket'
-import { getRouteByCmdType } from '@/utils/tsbCmdRoute'
+import { isOnCmdRoute } from '@/utils/tsbCmdRoute'
 
 const PAGE_CMD = 'common:tax'
-const PAGE_ROUTE = getRouteByCmdType(PAGE_CMD)
 
 /** 按钮操作类型 */
 const BUTTON_TYPE = {
@@ -172,6 +181,13 @@ function createDefaultDateParts() {
 
 export default {
   name: 'TsbTaxPage',
+  props: {
+    /** 工作区传入:固定绑定到该设备 SN,切换页签后不随 store 变化 */
+    boundDeviceSn: {
+      type: [Number, String],
+      default: null
+    }
+  },
   data() {
     return {
       BUTTON_TYPE,
@@ -184,9 +200,24 @@ export default {
     }
   },
   computed: {
-    currentDeviceSn() {
+    ownerDeviceSn() {
+      if (this.boundDeviceSn != null && this.boundDeviceSn !== '') {
+        return this.boundDeviceSn
+      }
       return this.$store.getters.tsbCurrentDeviceSn || tsbWebSocket.getCurrentDeviceSn()
     },
+    currentDeviceSn() {
+      return this.ownerDeviceSn
+    },
+    showNewTabHint() {
+      return this.$store.getters.tsbSessionReady && this.$store.getters.tsbIsNewTabWithoutDevice && !this.currentDeviceSn
+    },
+    canOperateDevice() {
+      const activeSn = this.$store.getters.tsbCurrentDeviceSn
+      return !!this.ownerDeviceSn
+        && String(activeSn) === String(this.ownerDeviceSn)
+        && !this.showNewTabHint
+    },
     encryptionText() {
       if (this.form.encryption === 1) return '是'
       if (this.form.encryption === 0) return '否'
@@ -195,41 +226,93 @@ export default {
   },
   watch: {
     '$store.state.tsb.pageDataVersion'() {
-      this.tryApplyWsPageData(false)
+      if (this.canOperateDevice) {
+        this.tryApplyWsPageData(false)
+      }
     }
   },
   created() {
-    this.loadBind()
-    this._enteredByCreated = true
-    this.initPageData()
     this.wsTimer = setInterval(() => {
       this.wsConnected = tsbWebSocket.isConnected()
     }, 1000)
+    if (this.restorePageCache()) {
+      return
+    }
+    this.initPageData()
   },
   activated() {
-    if (this.tryApplyWsPageData(true)) {
+    if (this.restorePageCache()) {
       return
     }
-    if (this._enteredByCreated) {
-      this._enteredByCreated = false
+    if (this.tryApplyWsPageData(true)) {
       return
     }
-    this.applyManualDefaults()
+  },
+  deactivated() {
+    this.savePageCache()
   },
   beforeDestroy() {
+    this.savePageCache()
     if (this.wsTimer) clearInterval(this.wsTimer)
   },
   methods: {
+    pageCacheKey(deviceSn) {
+      const sn = deviceSn != null ? deviceSn : this.ownerDeviceSn
+      return sn != null ? `${sn}::tax` : null
+    },
+    pageDataKey(deviceSn) {
+      const sn = deviceSn != null ? deviceSn : this.ownerDeviceSn
+      return sn != null ? `${sn}::${PAGE_CMD}` : PAGE_CMD
+    },
+    savePageCache(deviceSn) {
+      const key = this.pageCacheKey(deviceSn)
+      if (!key) {
+        return
+      }
+      this.$store.commit('tsb/SET_DEVICE_PAGE_FORM', {
+        key,
+        data: {
+          form: { ...this.form },
+          queryYear: this.queryYear,
+          queryMonth: this.queryMonth,
+          queryDay: this.queryDay,
+          newStandardOn: this.newStandardOn,
+          initialized: true
+        }
+      })
+    },
+    restorePageCache(deviceSn) {
+      const key = this.pageCacheKey(deviceSn)
+      if (!key) {
+        return false
+      }
+      const cached = this.$store.state.tsb.devicePageFormCache[key]
+      if (!cached || !cached.initialized) {
+        return false
+      }
+      this.form = { ...cached.form }
+      this.queryYear = cached.queryYear
+      this.queryMonth = cached.queryMonth
+      this.queryDay = cached.queryDay
+      this.newStandardOn = cached.newStandardOn
+      return true
+    },
     loadBind() {
       const deviceSn = this.currentDeviceSn
       if (!deviceSn) {
         this.bindInfo = {}
         return
       }
-      getTsbWsBind(deviceSn).then(res => {
-        this.bindInfo = res.data || { deviceSn }
-      }).catch(() => {
-        this.bindInfo = { deviceSn }
+      const currentDevice = this.$store.getters.tsbCurrentDevice
+      this.bindInfo = currentDevice || { deviceSn }
+      getTsbWsBind(deviceSn, true).then(res => {
+        this.bindInfo = { ...(currentDevice || {}), ...(res.data || {}), deviceSn }
+      }).catch((err) => {
+        this.bindInfo = currentDevice || { deviceSn }
+        const msg = typeof err === 'string' ? err : (err && (err.message || err.msg))
+        if (msg) {
+          this.$message.error(msg)
+        }
       })
     },
     reconnectWs() {
@@ -247,29 +330,37 @@ export default {
       if (this.tryApplyWsPageData(true)) {
         return
       }
-      this.applyManualDefaults()
+      this.resetFormDefaults()
+      if (this.canOperateDevice) {
+        this.syncDefaultParamsToDevice()
+      }
     },
     /** 手动进入:重置为默认值并同步到设备 */
     applyManualDefaults() {
       this.resetFormDefaults()
-      this.syncDefaultParamsToDevice()
+      if (this.canOperateDevice) {
+        this.syncDefaultParamsToDevice()
+      }
     },
     /** 从 store 应用 WebSocket 推送数据 */
     tryApplyWsPageData(clearInitFlag) {
-      const data = this.$store.state.tsb.pageData[PAGE_CMD]
-      const initFromWs = this.$store.state.tsb.initFromWs[PAGE_CMD]
+      const dataKey = this.pageDataKey()
+      const data = this.$store.state.tsb.pageData[dataKey]
+      const initFromWs = this.$store.state.tsb.initFromWs[dataKey]
       if (!data) {
         return false
       }
       if (initFromWs) {
         this.applyTaxData(data)
         if (clearInitFlag) {
-          this.$store.commit('tsb/CLEAR_WS_INIT', PAGE_CMD)
+          this.$store.commit('tsb/CLEAR_WS_INIT', { cmdType: PAGE_CMD, deviceSn: this.ownerDeviceSn })
         }
+        this.savePageCache()
         return true
       }
-      if (this.$route.path === PAGE_ROUTE) {
+      if (isOnCmdRoute(this.$route.path, PAGE_CMD, this.ownerDeviceSn, this.$store.getters.tsbCurrentPanel)) {
         this.applyTaxData(data)
+        this.savePageCache()
         return true
       }
       return false
@@ -298,6 +389,7 @@ export default {
       if (Object.prototype.hasOwnProperty.call(data, 'queryDate')) {
         this.parseQueryDate(data.queryDate)
       }
+      this.savePageCache()
     },
     parseQueryDate(dateStr) {
       if (dateStr == null || dateStr === '') {
@@ -338,24 +430,32 @@ export default {
     },
     /** 字段变更:仅下发变更字段 */
     syncField(payload) {
+      if (!this.canOperateDevice) {
+        return
+      }
       tsbWebSocket.sendPageSync(PAGE_CMD, payload)
     },
     onInterfaceNoChange(val) {
       this.syncField({ interfaceNo: val })
+      this.savePageCache()
     },
     onGunNoChange(val) {
       this.syncField({ gunNo: val })
+      this.savePageCache()
     },
     onNewStandardChange(val) {
       this.form.newNationalStandard = val ? 1 : 0
       this.syncField({ newNationalStandard: this.form.newNationalStandard })
+      this.savePageCache()
     },
     onNewNationalStandardTaxNoChange(val) {
       this.syncField({ newNationalStandardTaxNo: val })
+      this.savePageCache()
     },
     onQueryDateChange() {
       this.updateQueryDate()
       this.syncField({ queryDate: this.form.queryDate })
+      this.savePageCache()
     },
     /** 按钮点击:下发参数区字段 + buttonType */
     buildButtonPayload(buttonType) {
@@ -370,6 +470,9 @@ export default {
       }
     },
     sendButtonAction(buttonType) {
+      if (!this.canOperateDevice) {
+        return
+      }
       tsbWebSocket.sendPageSync(PAGE_CMD, this.buildButtonPayload(buttonType))
     },
     queryTaxSerial() {

+ 154 - 148
ruoyi-ui/src/views/index.vue

@@ -1,148 +1,154 @@
-<template>
-  <div class="app-container tsb-home">
-    <div class="tsb-home-header">
-      <h2 class="tsb-home-title">调试宝设备</h2>
-    </div>
-
-    <div v-loading="loading">
-      <div v-if="onlineDevices.length" class="section-block">
-        <div class="section-title">
-          <span>已登录设备</span>
-          <el-tag size="mini" type="success">{{ onlineDevices.length }}</el-tag>
-        </div>
-        <el-row :gutter="16">
-          <el-col v-for="item in onlineDevices" :key="'on-' + item.deviceId" :xs="24" :sm="12" :md="8" :lg="6">
-            <device-card :device="item" :active="isActive(item)" @operate="handleOperate" />
-          </el-col>
-        </el-row>
-      </div>
-      <el-empty v-else-if="!loading && !offlineDevices.length" description="暂无调试宝设备" />
-
-      <el-collapse v-if="offlineDevices.length" v-model="offlineCollapse" class="offline-collapse">
-        <el-collapse-item name="offline">
-          <template slot="title">
-            <span class="section-title inline">
-              <span>未登录设备</span>
-              <el-tag size="mini" type="info">{{ offlineDevices.length }}</el-tag>
-              <span class="collapse-hint">点击标题可展开/收起</span>
-            </span>
-          </template>
-          <el-row :gutter="16">
-            <el-col v-for="item in offlineDevices" :key="'off-' + item.deviceId" :xs="24" :sm="12" :md="8" :lg="6">
-              <device-card :device="item" offline />
-            </el-col>
-          </el-row>
-        </el-collapse-item>
-      </el-collapse>
-    </div>
-  </div>
-</template>
-
-<script>
-import { listTsbDeviceHome } from '@/api/tsb/device'
-import { getRouteByCmdType } from '@/utils/tsbCmdRoute'
-import tsbWebSocket from '@/utils/tsbWebSocket'
-import DeviceCard from './dashboard/DeviceCard'
-
-const DEFAULT_PAGE_CMD = 'common:tax'
-
-export default {
-  name: 'Index',
-  components: { DeviceCard },
-  data() {
-    return {
-      loading: false,
-      deviceList: [],
-      offlineCollapse: [],
-      refreshTimer: null
-    }
-  },
-  computed: {
-    onlineDevices() {
-      return this.deviceList.filter(d => d.lineStatus === 1)
-    },
-    offlineDevices() {
-      return this.deviceList.filter(d => d.lineStatus !== 1)
-    },
-    currentDeviceSn() {
-      return this.$store.getters.tsbCurrentDeviceSn
-    }
-  },
-  created() {
-    this.loadDevices()
-    this.refreshTimer = setInterval(() => this.loadDevices(true), 30000)
-  },
-  beforeDestroy() {
-    if (this.refreshTimer) {
-      clearInterval(this.refreshTimer)
-    }
-  },
-  methods: {
-    loadDevices(silent) {
-      if (!silent) {
-        this.loading = true
-      }
-      listTsbDeviceHome().then(res => {
-        this.deviceList = res.data || []
-      }).finally(() => {
-        this.loading = false
-      })
-    },
-    isActive(device) {
-      return this.currentDeviceSn != null && device.deviceSn === this.currentDeviceSn
-    },
-    handleOperate(device) {
-      this.$store.commit('tsb/SET_CURRENT_DEVICE', device)
-      tsbWebSocket.connectWithDevice(device.deviceSn).then(() => {
-        const routePath = getRouteByCmdType(DEFAULT_PAGE_CMD)
-        if (routePath) {
-          this.$router.push(routePath).catch(() => {})
-        } else {
-          this.$message.info('请在左侧菜单进入调试宝功能页')
-        }
-      }).catch(() => {})
-    }
-  }
-}
-</script>
-
-<style scoped lang="scss">
-.tsb-home-header {
-  margin-bottom: 16px;
-}
-.tsb-home-title {
-  margin: 0;
-  font-size: 20px;
-  font-weight: 600;
-}
-.section-block {
-  margin-bottom: 20px;
-}
-.section-title {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  margin-bottom: 12px;
-  font-size: 15px;
-  font-weight: 500;
-  &.inline {
-    margin-bottom: 0;
-  }
-}
-.collapse-hint {
-  margin-left: 4px;
-  font-size: 12px;
-  font-weight: normal;
-  color: #909399;
-}
-.offline-collapse {
-  border: none;
-  ::v-deep .el-collapse-item__header {
-    border-bottom: none;
-    font-size: 15px;
-  }
-  ::v-deep .el-collapse-item__wrap {
-    border-bottom: none;
-  }
-}
-</style>
+<template>
+  <div class="app-container tsb-home">
+    <div class="tsb-home-header">
+      <h2 class="tsb-home-title">调试宝设备</h2>
+    </div>
+
+    <div v-loading="loading">
+      <div v-if="onlineDevices.length" class="section-block">
+        <div class="section-title">
+          <span>已登录设备</span>
+          <el-tag size="mini" type="success">{{ onlineDevices.length }}</el-tag>
+        </div>
+        <el-row :gutter="16">
+          <el-col v-for="item in onlineDevices" :key="'on-' + item.deviceId" :xs="24" :sm="12" :md="8" :lg="6">
+            <device-card :device="item" :active="isActive(item)" @operate="handleOperate" />
+          </el-col>
+        </el-row>
+      </div>
+
+      <el-collapse v-if="offlineDevices.length" v-model="offlineCollapse" class="offline-collapse">
+        <el-collapse-item name="offline">
+          <template slot="title">
+            <span class="section-title inline">
+              <span>未登录设备</span>
+              <el-tag size="mini" type="info">{{ offlineDevices.length }}</el-tag>
+              <span class="collapse-hint">点击标题可展开/收起</span>
+            </span>
+          </template>
+          <el-row :gutter="16">
+            <el-col v-for="item in offlineDevices" :key="'off-' + item.deviceId" :xs="24" :sm="12" :md="8" :lg="6">
+              <device-card :device="item" offline />
+            </el-col>
+          </el-row>
+        </el-collapse-item>
+      </el-collapse>
+
+      <el-empty v-if="!loading && !deviceList.length" description="暂无调试宝设备" />
+    </div>
+  </div>
+</template>
+
+<script>
+import { listTsbDeviceHome } from '@/api/tsb/device'
+import tsbWebSocket from '@/utils/tsbWebSocket'
+import { navigateToWorkspace } from '@/utils/tsbWorkspaceNav'
+import DeviceCard from './dashboard/DeviceCard'
+
+export default {
+  name: 'Index',
+  components: { DeviceCard },
+  data() {
+    return {
+      loading: false,
+      deviceList: [],
+      offlineCollapse: [],
+      refreshTimer: null,
+      connectingDeviceSn: null
+    }
+  },
+  computed: {
+    onlineDevices() {
+      return this.deviceList.filter(d => Number(d.lineStatus) === 1)
+    },
+    offlineDevices() {
+      return this.deviceList.filter(d => Number(d.lineStatus) !== 1)
+    },
+    openedDevices() {
+      return this.$store.getters.tsbOpenedDevices || []
+    }
+  },
+  created() {
+    this.loadDevices()
+    this.refreshTimer = setInterval(() => this.loadDevices(true), 30000)
+  },
+  beforeDestroy() {
+    if (this.refreshTimer) {
+      clearInterval(this.refreshTimer)
+    }
+  },
+  methods: {
+    loadDevices(silent) {
+      if (!silent) {
+        this.loading = true
+      }
+      listTsbDeviceHome().then(res => {
+        const data = res.data
+        this.deviceList = Array.isArray(data) ? data : []
+      }).catch(() => {
+        this.deviceList = []
+      }).finally(() => {
+        this.loading = false
+      })
+    },
+    isActive(device) {
+      return this.openedDevices.some(d => d.deviceSn === device.deviceSn)
+    },
+    handleOperate(device) {
+      if (this.connectingDeviceSn === device.deviceSn) {
+        return
+      }
+      this.connectingDeviceSn = device.deviceSn
+      tsbWebSocket.connectWithDevice(device).then(() => {
+        navigateToWorkspace().catch(() => {})
+      }).catch((err) => {
+        const msg = typeof err === 'string' ? err : (err && (err.message || err.msg)) || '设备连接失败'
+        this.$message.error(msg)
+      }).finally(() => {
+        this.connectingDeviceSn = null
+      })
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.tsb-home-header {
+  margin-bottom: 16px;
+}
+.tsb-home-title {
+  margin: 0;
+  font-size: 20px;
+  font-weight: 600;
+}
+.section-block {
+  margin-bottom: 20px;
+}
+.section-title {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 12px;
+  font-size: 15px;
+  font-weight: 500;
+  &.inline {
+    margin-bottom: 0;
+  }
+}
+.collapse-hint {
+  margin-left: 4px;
+  font-size: 12px;
+  font-weight: normal;
+  color: #909399;
+}
+.offline-collapse {
+  border: none;
+  ::v-deep .el-collapse-item__header {
+    border-bottom: none;
+    font-size: 15px;
+  }
+  ::v-deep .el-collapse-item__wrap {
+    border-bottom: none;
+  }
+}
+</style>

+ 40 - 0
ruoyi-ui/src/views/tsb/device-shell/deviceMenus.js

@@ -0,0 +1,40 @@
+/**
+ * 设备功能菜单(对齐设备端 tsb_home_page.lua 分类)
+ */
+export const DEVICE_TAB_NAMES = [
+  '常用功能',
+  '目标设备监测',
+  '目标设备管理',
+  '本机设备管理'
+]
+
+export const DEVICE_DEMOS = [
+  [
+    { name: '报税口', route: 'tax', icon: 'money', color: '#007AFF', enabled: true },
+    { name: '液位仪', route: 'opw', icon: 'slider', color: '#4CAF50', enabled: false },
+    { name: '提枪信号', route: 'raise', icon: 'radio', color: '#F44336', enabled: false },
+    { name: '编码器', route: 'coder', icon: 'code', color: '#FF9800', enabled: false },
+    { name: '简易示波器', route: 'oscilloscope', icon: 'chart', color: '#FFAB91', enabled: false },
+    { name: '帮助', route: 'help', icon: 'question', color: '#CE93D8', enabled: false }
+  ],
+  [
+    { name: '日志监测', route: 'devlog', icon: 'log', color: '#82B1FF', enabled: false },
+    { name: '485监测', route: '485log', icon: 'monitor', color: '#A5D6A7', enabled: false },
+    { name: '232监测', route: '232log', icon: 'monitor', color: '#FFCCBC', enabled: false },
+    { name: '射频局域网监测', route: 'lora', icon: 'link', color: '#FFE082', enabled: false }
+  ],
+  [
+    { name: '固件包下载', route: 'firm', icon: 'download', color: '#2196F3', enabled: false },
+    { name: '升级', route: 'upgrade', icon: 'upload', color: '#66BB6A', enabled: false },
+    { name: '刷机', route: 'reflash', icon: 'tool', color: '#F4511E', enabled: false },
+    { name: 'MQTT配置', route: 'mqtt', icon: 'message', color: '#FFB74D', enabled: false },
+    { name: '信道切换', route: 'channel', icon: 'switch', color: '#7E57C2', enabled: false }
+  ],
+  [
+    { name: '本机信息', route: 'devinfo', icon: 'server', color: '#B3E5FC', enabled: false },
+    { name: '本机升级', route: 'update', icon: 'upload', color: '#A5D6A7', enabled: false },
+    { name: '本机日志', route: 'log', icon: 'documentation', color: '#FFCCBC', enabled: false },
+    { name: '亮度管理', route: 'light', icon: 'eye-open', color: '#F2E2C5', enabled: false },
+    { name: '网络设置', route: 'wlan', icon: 'international', color: '#CE93D8', enabled: false }
+  ]
+]

+ 162 - 0
ruoyi-ui/src/views/tsb/device-shell/home.vue

@@ -0,0 +1,162 @@
+<template>
+  <div class="device-home app-container">
+    <div class="device-home-header">
+      <h3 class="title">设备功能</h3>
+      <div class="device-meta">
+        <el-tag size="small" type="success">已连接设备</el-tag>
+        <span>SN {{ deviceSn }}</span>
+        <span v-if="deviceInfo.imei" class="imei">IMEI {{ deviceInfo.imei }}</span>
+      </div>
+    </div>
+
+    <el-tabs v-model="activeTab" class="device-menu-tabs">
+      <el-tab-pane
+        v-for="(tabName, tabIndex) in tabNames"
+        :key="tabName"
+        :label="tabName"
+        :name="String(tabIndex)"
+      >
+        <el-row :gutter="16" class="demo-grid">
+          <el-col
+            v-for="demo in demosByTab[tabIndex]"
+            :key="demo.name"
+            :xs="12"
+            :sm="8"
+            :md="6"
+            :lg="4"
+          >
+            <div
+              class="demo-card"
+              :class="{ disabled: !demo.enabled }"
+              :style="{ '--card-color': demo.color }"
+              @click="openDemo(demo)"
+            >
+              <div class="demo-icon">
+                <svg-icon :icon-class="demo.icon" />
+              </div>
+              <div class="demo-name">{{ demo.name }}</div>
+              <div v-if="!demo.enabled" class="demo-badge">敬请期待</div>
+            </div>
+          </el-col>
+          <el-col v-if="!demosByTab[tabIndex].length" :span="24">
+            <el-empty description="暂无功能" :image-size="80" />
+          </el-col>
+        </el-row>
+      </el-tab-pane>
+    </el-tabs>
+  </div>
+</template>
+
+<script>
+import { DEVICE_TAB_NAMES, DEVICE_DEMOS } from './deviceMenus'
+import { buildDeviceSession, saveTsbDeviceSession } from '@/utils/tsbDeviceSession'
+
+export default {
+  name: 'TsbDeviceHome',
+  data() {
+    return {
+      activeTab: '0',
+      tabNames: DEVICE_TAB_NAMES,
+      demosByTab: DEVICE_DEMOS
+    }
+  },
+  computed: {
+    deviceSn() {
+      return this.$store.getters.tsbCurrentDeviceSn
+    },
+    deviceInfo() {
+      const list = this.$store.getters.tsbOpenedDevices || []
+      return list.find(d => String(d.deviceSn) === String(this.deviceSn)) || {}
+    }
+  },
+  methods: {
+    openDemo(demo) {
+      if (!demo.enabled || !demo.route) {
+        this.$message.info('功能开发中,敬请期待')
+        return
+      }
+      const sn = this.deviceSn
+      if (sn == null) {
+        return
+      }
+      this.$store.commit('tsb/SET_DEVICE_PANEL', { deviceSn: sn, panel: demo.route })
+      saveTsbDeviceSession(buildDeviceSession(
+        this.$store.getters.tsbCurrentDevice,
+        this.$store.getters.tsbOpenedDevices,
+        this.$store.state.tsb.devicePanelMap
+      ))
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.device-home-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 12px;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+.title {
+  margin: 0;
+  font-size: 18px;
+  font-weight: 600;
+}
+.device-meta {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  color: #606266;
+  font-size: 13px;
+}
+.imei {
+  color: #909399;
+}
+.device-menu-tabs {
+  ::v-deep .el-tabs__header {
+    margin-bottom: 16px;
+  }
+}
+.demo-grid {
+  min-height: 200px;
+}
+.demo-card {
+  position: relative;
+  margin-bottom: 16px;
+  padding: 20px 12px 16px;
+  border-radius: 8px;
+  background: linear-gradient(135deg, var(--card-color), rgba(255, 255, 255, 0.15));
+  color: #fff;
+  text-align: center;
+  cursor: pointer;
+  transition: transform 0.2s, box-shadow 0.2s;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+  &:hover:not(.disabled) {
+    transform: translateY(-2px);
+    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
+  }
+  &.disabled {
+    cursor: not-allowed;
+    opacity: 0.55;
+  }
+}
+.demo-icon {
+  font-size: 28px;
+  margin-bottom: 8px;
+}
+.demo-name {
+  font-size: 14px;
+  font-weight: 500;
+}
+.demo-badge {
+  position: absolute;
+  top: 8px;
+  right: 8px;
+  font-size: 11px;
+  padding: 2px 6px;
+  border-radius: 10px;
+  background: rgba(0, 0, 0, 0.25);
+}
+</style>

+ 225 - 0
ruoyi-ui/src/views/tsb/device-shell/index.vue

@@ -0,0 +1,225 @@
+<template>
+  <div class="tsb-device-shell">
+    <div v-if="!currentDeviceSn" class="workspace-empty app-container">
+      <el-empty description="请从首页选择已登录设备">
+        <el-button type="primary" size="small" @click="goHomeIndex">前往首页</el-button>
+      </el-empty>
+    </div>
+
+    <template v-else>
+      <div class="device-tabs-bar">
+        <div
+          v-for="item in openedDevices"
+          :key="item.deviceSn"
+          class="device-tab"
+          :class="{ active: isActiveTab(item) }"
+          @click="switchDevice(item)"
+        >
+          <span class="tab-label">SN {{ item.deviceSn }}</span>
+          <i
+            class="el-icon-close tab-close"
+            @click.stop="closeDevice(item)"
+          />
+        </div>
+        <el-button type="text" size="mini" class="add-device-btn" @click="goHomeIndex">
+          <i class="el-icon-plus" /> 选择设备
+        </el-button>
+      </div>
+
+      <div v-if="!isHomePage" class="sub-toolbar">
+        <el-button type="text" icon="el-icon-back" @click="goDeviceHome">返回功能菜单</el-button>
+        <span class="sub-title">{{ subPageTitle }}</span>
+        <span class="sub-sn">设备 SN:{{ currentDeviceSn }}</span>
+      </div>
+
+      <keep-alive>
+        <component :is="panelComponent" :key="panelCacheKey" :bound-device-sn="currentDeviceSn" />
+      </keep-alive>
+    </template>
+  </div>
+</template>
+
+<script>
+import tsbWebSocket from '@/utils/tsbWebSocket'
+import { ensureWorkspaceTag } from '@/utils/tsbWorkspaceNav'
+import { buildDeviceSession, clearTsbDeviceSession, hasTsbDeviceSession, saveTsbDeviceSession } from '@/utils/tsbDeviceSession'
+import DeviceHome from './home'
+import TaxPage from '@/views/app-common/tax/index'
+
+const PANEL_COMPONENTS = {
+  home: DeviceHome,
+  tax: TaxPage
+}
+
+const PANEL_TITLES = {
+  home: '设备功能',
+  tax: '报税口'
+}
+
+export default {
+  name: 'TsbDeviceShell',
+  components: { DeviceHome, TaxPage },
+  computed: {
+    openedDevices() {
+      return this.$store.getters.tsbOpenedDevices || []
+    },
+    currentDeviceSn() {
+      return this.$store.getters.tsbCurrentDeviceSn
+    },
+    currentPanel() {
+      return this.$store.getters.tsbCurrentPanel
+    },
+    isHomePage() {
+      return this.currentPanel === 'home'
+    },
+    subPageTitle() {
+      return PANEL_TITLES[this.currentPanel] || ''
+    },
+    panelComponent() {
+      return PANEL_COMPONENTS[this.currentPanel] || DeviceHome
+    },
+    panelCacheKey() {
+      return `${this.currentDeviceSn}-${this.currentPanel}`
+    }
+  },
+  created() {
+    if (!hasTsbDeviceSession() || !this.openedDevices.length) {
+      this.$store.commit('tsb/SET_SESSION_CHECKED', false)
+      this.$tab.closeOpenPage({ path: '/index' })
+      return
+    }
+    this.ensureWsConnected()
+    ensureWorkspaceTag()
+  },
+  activated() {
+    if (!hasTsbDeviceSession() || !this.openedDevices.length) {
+      return
+    }
+    this.ensureWsConnected()
+    ensureWorkspaceTag()
+  },
+  methods: {
+    ensureWsConnected() {
+      const device = this.$store.getters.tsbCurrentDevice
+      if (!device || device.deviceSn == null) {
+        return
+      }
+      if (!tsbWebSocket.isConnected()) {
+        tsbWebSocket.ensureCurrentDeviceConnection()
+      }
+    },
+    isActiveTab(device) {
+      return String(device.deviceSn) === String(this.currentDeviceSn)
+    },
+    switchDevice(device) {
+      if (this.isActiveTab(device)) {
+        return
+      }
+      tsbWebSocket.switchToDevice(device).catch(() => {})
+    },
+    closeDevice(device) {
+      const closingCurrent = this.isActiveTab(device)
+      const panelMap = { ...this.$store.state.tsb.devicePanelMap }
+      delete panelMap[device.deviceSn]
+      this.$store.commit('tsb/SET_DEVICE_PANEL_MAP', panelMap)
+      this.$store.commit('tsb/REMOVE_OPENED_DEVICE', device.deviceSn)
+      this.$store.commit('tsb/CLEAR_DEVICE_PAGE_FORM', String(device.deviceSn))
+      tsbWebSocket.disconnectDevice(device.deviceSn)
+      tsbWebSocket.onDeviceTabClosed(device.deviceSn)
+      const remaining = this.$store.getters.tsbOpenedDevices
+      if (!remaining.length) {
+        clearTsbDeviceSession()
+        tsbWebSocket.releaseWorkspaceSession()
+        this.$store.commit('tsb/SET_SESSION_CHECKED', false)
+        this.$tab.closeOpenPage({ path: '/index' })
+        return
+      }
+      const current = this.$store.getters.tsbCurrentDevice
+      saveTsbDeviceSession(buildDeviceSession(current, remaining, panelMap))
+      if (closingCurrent) {
+        const next = remaining[remaining.length - 1]
+        tsbWebSocket.switchToDevice(next).catch(() => {})
+      }
+    },
+    goDeviceHome() {
+      const sn = this.currentDeviceSn
+      if (sn != null) {
+        this.$store.commit('tsb/SET_DEVICE_PANEL', { deviceSn: sn, panel: 'home' })
+        this.persistPanelMap()
+      }
+    },
+    goHomeIndex() {
+      this.$router.push('/index')
+    },
+    persistPanelMap() {
+      saveTsbDeviceSession(buildDeviceSession(
+        this.$store.getters.tsbCurrentDevice,
+        this.$store.getters.tsbOpenedDevices,
+        this.$store.state.tsb.devicePanelMap
+      ))
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.tsb-device-shell {
+  min-height: 100%;
+}
+.workspace-empty {
+  padding-top: 48px;
+}
+.device-tabs-bar {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 8px;
+  padding: 8px 12px;
+  background: #f5f7fa;
+  border-bottom: 1px solid #e4e7ed;
+}
+.device-tab {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  padding: 6px 12px;
+  border: 1px solid #dcdfe6;
+  border-radius: 4px;
+  background: #fff;
+  cursor: pointer;
+  font-size: 13px;
+  transition: all 0.2s;
+  &.active {
+    color: #409eff;
+    border-color: #409eff;
+    background: #ecf5ff;
+  }
+}
+.tab-close {
+  font-size: 12px;
+  color: #909399;
+  &:hover {
+    color: #f56c6c;
+  }
+}
+.add-device-btn {
+  margin-left: 4px;
+}
+.sub-toolbar {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 8px 16px;
+  border-bottom: 1px solid #ebeef5;
+  background: #fff;
+}
+.sub-title {
+  font-weight: 600;
+  font-size: 14px;
+}
+.sub-sn {
+  margin-left: auto;
+  color: #909399;
+  font-size: 13px;
+}
+</style>