Ver código fonte

1、前端页面

liweimin 2 semanas atrás
pai
commit
abe2d1ec88

+ 9 - 0
ruoyi-ui/src/api/tsb/ws.js

@@ -0,0 +1,9 @@
+import request from '@/utils/request'
+
+/** 查询当前用户设备绑定(WebSocket 前置校验) */
+export function getTsbWsBind() {
+  return request({
+    url: '/tsb/ws/bind',
+    method: 'get'
+  })
+}

+ 7 - 0
ruoyi-ui/src/permission.js

@@ -1,5 +1,6 @@
 import router from './router'
 import store from './store'
+import tsbWebSocket from '@/utils/tsbWebSocket'
 import { Message } from 'element-ui'
 import NProgress from 'nprogress'
 import 'nprogress/nprogress.css'
@@ -11,6 +12,10 @@ NProgress.configure({ showSpinner: false })
 
 const whiteList = ['/login', '/register']
 
+function connectTsbWebSocket() {
+  tsbWebSocket.connect()
+}
+
 const isWhiteList = (path) => {
   return whiteList.some(pattern => isPathMatch(pattern, path))
 }
@@ -38,6 +43,7 @@ router.beforeEach((to, from, next) => {
         // 判断当前用户是否已拉取完user_info信息
         store.dispatch('GetInfo').then(() => {
           isRelogin.show = false
+          connectTsbWebSocket()
           store.dispatch('GenerateRoutes').then(accessRoutes => {
             // 根据roles权限生成可访问的路由表
             router.addRoutes(accessRoutes) // 动态添加可访问路由表
@@ -50,6 +56,7 @@ router.beforeEach((to, from, next) => {
             })
           })
       } else {
+        connectTsbWebSocket()
         next()
       }
     }

+ 3 - 1
ruoyi-ui/src/store/index.js

@@ -7,6 +7,7 @@ import user from './modules/user'
 import tagsView from './modules/tagsView'
 import permission from './modules/permission'
 import settings from './modules/settings'
+import tsb from './modules/tsb'
 import getters from './getters'
 
 Vue.use(Vuex)
@@ -19,7 +20,8 @@ const store = new Vuex.Store({
     user,
     tagsView,
     permission,
-    settings
+    settings,
+    tsb
   },
   getters
 })

+ 32 - 0
ruoyi-ui/src/store/modules/tsb.js

@@ -0,0 +1,32 @@
+const tsb = {
+  namespaced: true,
+  state: {
+    /** 各 cmdType 最新推送数据 */
+    pageData: {},
+    /** WebSocket 跳转时标记,页面初始化后清除 */
+    initFromWs: {},
+    /** 数据版本号,供页面 watch 实时更新 */
+    pageDataVersion: 0
+  },
+  mutations: {
+    SET_WS_PAGE_DATA(state, { cmdType, data, navigate }) {
+      state.pageData = { ...state.pageData, [cmdType]: data }
+      state.pageDataVersion += 1
+      if (navigate) {
+        state.initFromWs = { ...state.initFromWs, [cmdType]: true }
+      }
+    },
+    CLEAR_WS_INIT(state, cmdType) {
+      const initFromWs = { ...state.initFromWs }
+      delete initFromWs[cmdType]
+      state.initFromWs = initFromWs
+    },
+    RESET_TSB(state) {
+      state.pageData = {}
+      state.initFromWs = {}
+      state.pageDataVersion = 0
+    }
+  }
+}
+
+export default tsb

+ 4 - 0
ruoyi-ui/src/store/modules/user.js

@@ -4,6 +4,7 @@ import cache from '@/plugins/cache'
 import { MessageBox, } from 'element-ui'
 import { login, logout, getInfo } from '@/api/login'
 import { getToken, setToken, removeToken } from '@/utils/auth'
+import tsbWebSocket from '@/utils/tsbWebSocket'
 import { isHttp, isEmpty } from "@/utils/validate"
 import defAva from '@/assets/images/profile.jpg'
 
@@ -54,6 +55,7 @@ const user = {
           setToken(res.token)
           commit('SET_TOKEN', res.token)
           store.dispatch('lock/unlockScreen')
+          tsbWebSocket.connectAfterLogin()
           resolve()
         }).catch(error => {
           reject(error)
@@ -103,6 +105,8 @@ const user = {
     // 退出系统
     LogOut({ commit, state }) {
       return new Promise((resolve, reject) => {
+        tsbWebSocket.disconnect(true)
+        commit('tsb/RESET_TSB', null, { root: true })
         logout(state.token).then(() => {
           commit('SET_TOKEN', '')
           commit('SET_ROLES', [])

+ 13 - 0
ruoyi-ui/src/utils/tsbCmdRoute.js

@@ -0,0 +1,13 @@
+/**
+ * cmdType 与前端路由映射(设备推送页面跳转)
+ */
+export const TSB_CMD_ROUTE_MAP = {
+  'common:tax': '/app-common/tax'
+}
+
+/**
+ * 根据 cmdType 获取路由路径
+ */
+export function getRouteByCmdType(cmdType) {
+  return TSB_CMD_ROUTE_MAP[cmdType] || null
+}

+ 290 - 0
ruoyi-ui/src/utils/tsbWebSocket.js

@@ -0,0 +1,290 @@
+import { getToken } from '@/utils/auth'
+import { Message } from 'element-ui'
+import { initTsbWsRouter } from '@/utils/tsbWsRouter'
+import {
+  initTabCoordinator,
+  isLeaderTab,
+  broadcastWsMessage,
+  broadcastWsStatus,
+  requestSend,
+  requestLeaderReconnect,
+  notifyLogout,
+  destroyTabCoordinator,
+  resetLeadershipForLogin,
+  reclaimLeadershipIfStale
+} from '@/utils/tsbWebSocketTab'
+
+let socket = null
+let reconnectTimer = null
+let manualClose = false
+let messageHandler = null
+/** 连接代次,用于忽略旧 socket 的 close/error 回调 */
+let connectId = 0
+/** 自动重连次数 */
+let reconnectAttempts = 0
+let reconnectExhaustedNotified = false
+/** 非 Leader 标签页感知的连接状态 */
+let followerConnectedState = false
+let tabCoordinatorInited = false
+let wsRouterInited = false
+const RECONNECT_DELAY = 3000
+const MAX_RECONNECT_ATTEMPTS = 3
+
+function ensureWsRouter() {
+  if (wsRouterInited) {
+    return
+  }
+  wsRouterInited = true
+  initTsbWsRouter((handler) => onMessage(handler))
+}
+
+function ensureTabCoordinator() {
+  if (tabCoordinatorInited) {
+    return
+  }
+  tabCoordinatorInited = true
+  initTabCoordinator({
+    onBecomeLeader: (forceReconnect) => {
+      if (forceReconnect) {
+        leaderDisconnectSilently()
+        resetReconnectAttempts()
+        leaderConnect(false)
+        return
+      }
+      leaderConnect(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)
+    }
+  })
+}
+
+function resetReconnectAttempts() {
+  reconnectAttempts = 0
+  reconnectExhaustedNotified = false
+}
+
+function buildWsUrl(token) {
+  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)}`
+}
+
+function clearReconnectTimer() {
+  if (reconnectTimer) {
+    clearTimeout(reconnectTimer)
+    reconnectTimer = null
+  }
+}
+
+function scheduleReconnect() {
+  if (!isLeaderTab() || manualClose || !getToken()) {
+    return
+  }
+  if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
+    if (!reconnectExhaustedNotified) {
+      reconnectExhaustedNotified = true
+      console.warn(`TSB WebSocket 自动重连已达上限(${MAX_RECONNECT_ATTEMPTS} 次),请手动重连`)
+      Message.warning(`WebSocket 自动重连已达上限(${MAX_RECONNECT_ATTEMPTS} 次),请手动重连`)
+    }
+    broadcastWsStatus(false)
+    return
+  }
+  reconnectAttempts++
+  clearReconnectTimer()
+  reconnectTimer = setTimeout(() => leaderConnect(false), RECONNECT_DELAY)
+}
+
+function bindSocketEvents(ws, id) {
+  ws.onopen = () => {
+    if (id !== connectId) {
+      return
+    }
+    clearReconnectTimer()
+    broadcastWsStatus(true)
+  }
+  ws.onmessage = (evt) => {
+    if (id !== connectId || !messageHandler) {
+      return
+    }
+    try {
+      const message = JSON.parse(evt.data)
+      messageHandler(message)
+      broadcastWsMessage(message)
+    } catch (e) {
+      console.warn('TSB WebSocket 消息解析失败', e)
+    }
+  }
+  ws.onclose = () => {
+    if (id !== connectId) {
+      return
+    }
+    socket = null
+    broadcastWsStatus(false)
+    if (!manualClose && isLeaderTab() && getToken()) {
+      scheduleReconnect()
+    }
+  }
+  ws.onerror = () => {
+    if (id !== connectId) {
+      return
+    }
+    ws.close()
+  }
+}
+
+function leaderConnect(resetAttempts = true) {
+  ensureTabCoordinator()
+  if (!isLeaderTab()) {
+    return
+  }
+  const token = getToken()
+  if (!token || manualClose) {
+    return
+  }
+  if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
+    return
+  }
+  clearReconnectTimer()
+  if (resetAttempts !== false) {
+    resetReconnectAttempts()
+  }
+  manualClose = false
+  const id = ++connectId
+  socket = new WebSocket(buildWsUrl(token))
+  bindSocketEvents(socket, id)
+}
+
+function leaderDisconnectSilently() {
+  manualClose = true
+  clearReconnectTimer()
+  connectId++
+  if (socket) {
+    socket.close()
+    socket = null
+  }
+  manualClose = false
+  broadcastWsStatus(false)
+}
+
+function leaderSend(cmdType, data) {
+  if (!socket || socket.readyState !== WebSocket.OPEN) {
+    return false
+  }
+  socket.send(JSON.stringify({
+    cmdType,
+    data: data || {}
+  }))
+  return true
+}
+
+function connect(resetAttempts = true, options = {}) {
+  ensureWsRouter()
+  ensureTabCoordinator()
+  if (options.forceLeader) {
+    resetLeadershipForLogin()
+  } else {
+    reclaimLeadershipIfStale()
+  }
+  if (!isLeaderTab()) {
+    return
+  }
+  leaderConnect(resetAttempts)
+}
+
+/** 登录成功后立即尝试建立 WebSocket(新登录抢占 leader,避免多标签页残留锁) */
+function connectAfterLogin() {
+  connect(true, { forceLeader: true })
+}
+
+function disconnect(logout) {
+  ensureTabCoordinator()
+  manualClose = true
+  clearReconnectTimer()
+  resetReconnectAttempts()
+  connectId++
+  if (socket) {
+    socket.close()
+    socket = null
+  }
+  followerConnectedState = false
+  broadcastWsStatus(false)
+  if (logout) {
+    notifyLogout()
+    destroyTabCoordinator()
+    tabCoordinatorInited = false
+    wsRouterInited = false
+  } else if (!logout) {
+    manualClose = false
+  }
+}
+
+function reconnect() {
+  ensureTabCoordinator()
+  clearReconnectTimer()
+  resetReconnectAttempts()
+  requestLeaderReconnect()
+}
+
+function isReconnectExhausted() {
+  return reconnectAttempts >= MAX_RECONNECT_ATTEMPTS
+}
+
+function notifyConnectSuccess() {
+  resetReconnectAttempts()
+  broadcastWsStatus(true)
+}
+
+function notifyConnectFailed() {
+  // 鉴权失败等场景由 onclose 触发 scheduleReconnect
+}
+
+function send(cmdType, data) {
+  ensureTabCoordinator()
+  if (isLeaderTab()) {
+    return leaderSend(cmdType, data)
+  }
+  requestSend({ cmdType, data })
+  return true
+}
+
+function sendPageSync(cmdType, data) {
+  return send(cmdType, data)
+}
+
+function onMessage(handler) {
+  messageHandler = handler
+}
+
+function isConnected() {
+  if (isLeaderTab()) {
+    return socket && socket.readyState === WebSocket.OPEN
+  }
+  return followerConnectedState
+}
+
+function getReconnectAttempts() {
+  return reconnectAttempts
+}
+
+export default {
+  connect,
+  connectAfterLogin,
+  disconnect,
+  reconnect,
+  send,
+  sendPageSync,
+  onMessage,
+  isConnected,
+  isReconnectExhausted,
+  getReconnectAttempts,
+  notifyConnectSuccess,
+  notifyConnectFailed
+}

+ 234 - 0
ruoyi-ui/src/utils/tsbWebSocketTab.js

@@ -0,0 +1,234 @@
+/**
+ * 多标签页 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
+
+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)
+  }
+}
+
+function postChannel(type, payload) {
+  if (!channel) {
+    return
+  }
+  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) {
+    return
+  }
+  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()
+  }
+}
+
+export function initTabCoordinator(options = {}) {
+  Object.assign(handlers, options)
+
+  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)
+}
+
+export function isLeaderTab() {
+  return isLeader
+}
+
+export function isFollowerConnected() {
+  return followerConnected
+}
+
+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 notifyLogout() {
+  notifyTabLogout()
+}
+
+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)
+}

+ 71 - 0
ruoyi-ui/src/utils/tsbWsRouter.js

@@ -0,0 +1,71 @@
+import store from '@/store'
+import router from '@/router'
+import { Message } from 'element-ui'
+import { getRouteByCmdType } from '@/utils/tsbCmdRoute'
+import tsbWebSocket from '@/utils/tsbWebSocket'
+
+/**
+ * 处理 WebSocket 推送:按 cmdType 跳转页面并缓存 data 供页面初始化
+ */
+export function handleTsbWsMessage(msg) {
+  if (!msg || !msg.cmdType) {
+    return
+  }
+
+  if (msg.cmdType === 'connect') {
+    if (Number(msg.code) === 1) {
+      tsbWebSocket.notifyConnectSuccess()
+    } else {
+      tsbWebSocket.notifyConnectFailed()
+      if (msg.message) {
+        Message.error(msg.message || 'WebSocket 连接失败')
+      }
+    }
+    return
+  }
+
+  const routePath = getRouteByCmdType(msg.cmdType)
+  if (!routePath) {
+    return
+  }
+
+  if (Number(msg.code) !== 1) {
+    Message.error(msg.message || '操作失败')
+    return
+  }
+
+  if (!msg.data) {
+    return
+  }
+
+  const data = typeof msg.data === 'string'
+    ? (() => {
+      try {
+        return JSON.parse(msg.data)
+      } catch (e) {
+        return null
+      }
+    })()
+    : msg.data
+  if (!data) {
+    return
+  }
+
+  const onTargetPage = router.currentRoute.path === routePath
+  store.commit('tsb/SET_WS_PAGE_DATA', {
+    cmdType: msg.cmdType,
+    data,
+    navigate: !onTargetPage
+  })
+
+  if (!onTargetPage) {
+    router.push(routePath).catch(() => {})
+  }
+}
+
+/**
+ * 注册全局 WebSocket 消息路由(登录后调用一次)
+ */
+export function initTsbWsRouter(onMessage) {
+  onMessage(handleTsbWsMessage)
+}

+ 389 - 0
ruoyi-ui/src/views/app-common/tax/index.vue

@@ -0,0 +1,389 @@
+<template>
+  <div class="app-container tax-page">
+    <el-alert
+      v-if="!bindInfo.deviceSn"
+      title="当前用户未绑定调试宝设备,无法同步页面数据"
+      type="warning"
+      show-icon
+      :closable="false"
+      class="mb16"
+    />
+    <el-row v-else type="flex" justify="space-between" align="middle" class="mb16">
+      <el-col :span="16">
+        <el-tag :type="wsConnected ? 'success' : 'info'" size="small">
+          {{ wsConnected ? 'WebSocket 已连接' : 'WebSocket 未连接' }}
+        </el-tag>
+        <span class="bind-text">设备 SN:{{ bindInfo.deviceSn }}</span>
+      </el-col>
+      <el-col :span="8" style="text-align: right">
+        <el-button size="mini" @click="loadBind">刷新绑定</el-button>
+        <el-button size="mini" type="primary" @click="reconnectWs">重连</el-button>
+      </el-col>
+    </el-row>
+
+    <!-- 顶部参数行 -->
+    <el-card shadow="never" class="mb16">
+      <el-row :gutter="12" class="param-row">
+        <el-col :xs="12" :sm="6" :md="4">
+          <div class="field-label">接口号</div>
+          <el-select v-model="form.interfaceNo" size="small" @change="onInterfaceNoChange">
+            <el-option label="A" value="A" />
+            <el-option label="B" value="B" />
+          </el-select>
+        </el-col>
+        <el-col :xs="12" :sm="6" :md="4">
+          <div class="field-label">枪号</div>
+          <el-input-number v-model="form.gunNo" :min="1" :max="9" size="small" controls-position="right" @change="onGunNoChange" />
+        </el-col>
+        <el-col :xs="12" :sm="6" :md="4">
+          <div class="field-label">新国标</div>
+          <el-switch v-model="newStandardOn" active-text="启用" inactive-text="禁用" @change="onNewStandardChange" />
+        </el-col>
+        <el-col :xs="12" :sm="6" :md="4">
+          <div class="field-label">新国标报税口</div>
+          <el-input-number v-model="form.newNationalStandardTaxNo" :min="1" :max="9" size="small" controls-position="right" @change="onNewNationalStandardTaxNoChange" />
+        </el-col>
+      </el-row>
+    </el-card>
+
+    <el-row :gutter="16">
+      <!-- 左侧:税控序列号 -->
+      <el-col :xs="24" :md="10" :lg="8">
+        <el-card shadow="hover" class="panel-card">
+          <div slot="header" class="panel-title">税控序列号查询</div>
+            <el-button type="primary" size="small" class="mb16" @click="queryTaxSerial">查询税控序列号</el-button>
+          <div class="result-item"><span class="label">税控序列号:</span>{{ form.taxNo || '-' }}</div>
+          <div class="result-item"><span class="label">厂家:</span>{{ form.manufacturer || '-' }}</div>
+          <div class="result-item"><span class="label">枪个数:</span>{{ form.gunNumber != null ? form.gunNumber : '-' }}</div>
+          <div class="result-item"><span class="label">执行结果:</span>
+            <el-tag size="mini" :type="resultTag(form.queryTaxResult)">{{ form.queryTaxResult || '-' }}</el-tag>
+          </div>
+        </el-card>
+      </el-col>
+
+      <!-- 右侧:累计数据 -->
+      <el-col :xs="24" :md="14" :lg="16">
+        <el-card shadow="hover" class="panel-card">
+          <div slot="header" class="panel-title">累计数据查询</div>
+          <el-row :gutter="8" class="mb16 date-row">
+            <el-col :span="8">
+              <el-input-number v-model="queryYear" :min="2000" :max="2099" size="small" controls-position="right" @change="onQueryDateChange" />
+              <span class="unit">年</span>
+            </el-col>
+            <el-col :span="8">
+              <el-input-number v-model="queryMonth" :min="1" :max="12" size="small" controls-position="right" @change="onQueryDateChange" />
+              <span class="unit">月</span>
+            </el-col>
+            <el-col :span="8">
+              <el-input-number v-model="queryDay" :min="1" :max="31" size="small" controls-position="right" @change="onQueryDateChange" />
+              <span class="unit">日</span>
+            </el-col>
+          </el-row>
+          <el-button-group class="mb16 btn-group-wrap">
+            <el-button type="primary" size="small" @click="queryAccumulated(BUTTON_TYPE.QUERY_CURRENT)">查当次及总累</el-button>
+            <el-button type="primary" size="small" @click="queryAccumulated(BUTTON_TYPE.QUERY_DAY)">查日累</el-button>
+            <el-button type="primary" size="small" @click="queryAccumulated(BUTTON_TYPE.QUERY_MONTH)">查月累</el-button>
+          </el-button-group>
+          <el-row :gutter="12">
+            <el-col :xs="12" :sm="8"><div class="result-item"><span class="label">金额:</span>{{ formatNum(form.amount) }}</div></el-col>
+            <el-col :xs="12" :sm="8"><div class="result-item"><span class="label">油量:</span>{{ formatNum(form.volume) }}</div></el-col>
+            <el-col :xs="12" :sm="8"><div class="result-item"><span class="label">单价:</span>{{ formatNum(form.unitPrice) }}</div></el-col>
+            <el-col :xs="12" :sm="8"><div class="result-item"><span class="label">是否密文:</span>{{ encryptionText }}</div></el-col>
+            <el-col :xs="12" :sm="8"><div class="result-item"><span class="label">总金额:</span>{{ formatNum(form.totalAmount) }}</div></el-col>
+            <el-col :xs="12" :sm="8"><div class="result-item"><span class="label">总油量:</span>{{ formatNum(form.totalVolume) }}</div></el-col>
+            <el-col :span="24"><div class="result-item"><span class="label">执行结果:</span>
+              <el-tag size="mini" :type="resultTag(form.queryAccumulatedResult)">{{ form.queryAccumulatedResult || '-' }}</el-tag>
+            </div></el-col>
+          </el-row>
+        </el-card>
+      </el-col>
+    </el-row>
+  </div>
+</template>
+
+<script>
+import { getTsbWsBind } from '@/api/tsb/ws'
+import tsbWebSocket from '@/utils/tsbWebSocket'
+import { getRouteByCmdType } from '@/utils/tsbCmdRoute'
+
+const PAGE_CMD = 'common:tax'
+const PAGE_ROUTE = getRouteByCmdType(PAGE_CMD)
+
+/** 按钮操作类型 */
+const BUTTON_TYPE = {
+  QUERY: 'common:tax:query',
+  QUERY_CURRENT: 'common:tax:queryCurrent',
+  QUERY_DAY: 'common:tax:queryDay',
+  QUERY_MONTH: 'common:tax:queryMonth'
+}
+
+/** 报税口页面展示字段(与接口文档 data 映射一致) */
+const TAX_DISPLAY_FIELDS = [
+  'interfaceNo',
+  'gunNo',
+  'newNationalStandard',
+  'newNationalStandardTaxNo',
+  'queryDate',
+  'taxNo',
+  'manufacturer',
+  'gunNumber',
+  'queryTaxResult',
+  'amount',
+  'volume',
+  'unitPrice',
+  'encryption',
+  'totalAmount',
+  'totalVolume',
+  'queryAccumulatedResult'
+]
+
+function createDefaultForm() {
+  const now = new Date()
+  const m = String(now.getMonth() + 1).padStart(2, '0')
+  const d = String(now.getDate()).padStart(2, '0')
+  return {
+    interfaceNo: 'A',
+    gunNo: 1,
+    newNationalStandard: 0,
+    newNationalStandardTaxNo: 1,
+    queryDate: `${now.getFullYear()}-${m}-${d}`,
+    taxNo: '',
+    manufacturer: '',
+    gunNumber: null,
+    queryTaxResult: '',
+    amount: null,
+    volume: null,
+    unitPrice: null,
+    encryption: null,
+    totalAmount: null,
+    totalVolume: null,
+    queryAccumulatedResult: ''
+  }
+}
+
+function createDefaultDateParts() {
+  const now = new Date()
+  return {
+    queryYear: now.getFullYear(),
+    queryMonth: now.getMonth() + 1,
+    queryDay: now.getDate()
+  }
+}
+
+export default {
+  name: 'TsbTaxPage',
+  data() {
+    return {
+      BUTTON_TYPE,
+      bindInfo: {},
+      wsConnected: false,
+      newStandardOn: false,
+      ...createDefaultDateParts(),
+      form: createDefaultForm(),
+      wsTimer: null
+    }
+  },
+  computed: {
+    encryptionText() {
+      if (this.form.encryption === 1) return '是'
+      if (this.form.encryption === 0) return '否'
+      return '-'
+    }
+  },
+  watch: {
+    '$store.state.tsb.pageDataVersion'() {
+      this.tryApplyWsPageData(false)
+    }
+  },
+  created() {
+    this.loadBind()
+    tsbWebSocket.connect()
+    this._enteredByCreated = true
+    this.initPageData()
+    this.wsTimer = setInterval(() => {
+      this.wsConnected = tsbWebSocket.isConnected()
+    }, 1000)
+  },
+  activated() {
+    if (this.tryApplyWsPageData(true)) {
+      return
+    }
+    if (this._enteredByCreated) {
+      this._enteredByCreated = false
+      return
+    }
+    this.applyManualDefaults()
+  },
+  beforeDestroy() {
+    if (this.wsTimer) clearInterval(this.wsTimer)
+  },
+  methods: {
+    loadBind() {
+      getTsbWsBind().then(res => {
+        this.bindInfo = res.data || {}
+      }).catch(() => {
+        this.bindInfo = {}
+      })
+    },
+    reconnectWs() {
+      tsbWebSocket.reconnect()
+    },
+    /** WebSocket 推送进入:用 data 初始化;手动菜单进入:恢复默认值并下发 */
+    initPageData() {
+      if (this.tryApplyWsPageData(true)) {
+        return
+      }
+      this.applyManualDefaults()
+    },
+    /** 手动进入:重置为默认值并同步到设备 */
+    applyManualDefaults() {
+      this.resetFormDefaults()
+      this.syncDefaultParamsToDevice()
+    },
+    /** 从 store 应用 WebSocket 推送数据 */
+    tryApplyWsPageData(clearInitFlag) {
+      const data = this.$store.state.tsb.pageData[PAGE_CMD]
+      const initFromWs = this.$store.state.tsb.initFromWs[PAGE_CMD]
+      if (!data) {
+        return false
+      }
+      if (initFromWs) {
+        this.applyTaxData(data)
+        if (clearInitFlag) {
+          this.$store.commit('tsb/CLEAR_WS_INIT', PAGE_CMD)
+        }
+        return true
+      }
+      if (this.$route.path === PAGE_ROUTE) {
+        this.applyTaxData(data)
+        return true
+      }
+      return false
+    },
+    resetFormDefaults() {
+      const dateParts = createDefaultDateParts()
+      this.queryYear = dateParts.queryYear
+      this.queryMonth = dateParts.queryMonth
+      this.queryDay = dateParts.queryDay
+      this.form = createDefaultForm()
+      this.newStandardOn = false
+    },
+    applyTaxData(data) {
+      if (!data) {
+        return
+      }
+      const defaults = createDefaultForm()
+      TAX_DISPLAY_FIELDS.forEach((key) => {
+        if (Object.prototype.hasOwnProperty.call(data, key)) {
+          this.form[key] = data[key] ?? defaults[key]
+        }
+      })
+      if (Object.prototype.hasOwnProperty.call(data, 'newNationalStandard')) {
+        this.newStandardOn = data.newNationalStandard === 1
+      }
+      if (Object.prototype.hasOwnProperty.call(data, 'queryDate')) {
+        this.parseQueryDate(data.queryDate)
+      }
+    },
+    parseQueryDate(dateStr) {
+      if (dateStr == null || dateStr === '') {
+        return
+      }
+      const parts = dateStr.split('-')
+      if (parts.length === 3) {
+        this.queryYear = Number(parts[0])
+        this.queryMonth = Number(parts[1])
+        this.queryDay = Number(parts[2])
+      }
+    },
+    updateQueryDate() {
+      const m = String(this.queryMonth).padStart(2, '0')
+      const d = String(this.queryDay).padStart(2, '0')
+      this.form.queryDate = `${this.queryYear}-${m}-${d}`
+    },
+    /** 手动进入时下发默认参数区字段 */
+    syncDefaultParamsToDevice() {
+      this.updateQueryDate()
+      const payload = {
+        interfaceNo: this.form.interfaceNo,
+        gunNo: this.form.gunNo,
+        newNationalStandard: this.form.newNationalStandard,
+        newNationalStandardTaxNo: this.form.newNationalStandardTaxNo,
+        queryDate: this.form.queryDate
+      }
+      const send = () => tsbWebSocket.sendPageSync(PAGE_CMD, payload)
+      if (send()) {
+        return
+      }
+      let retries = 0
+      const timer = setInterval(() => {
+        if (send() || ++retries >= 10) {
+          clearInterval(timer)
+        }
+      }, 500)
+    },
+    /** 字段变更:仅下发变更字段 */
+    syncField(payload) {
+      tsbWebSocket.sendPageSync(PAGE_CMD, payload)
+    },
+    onInterfaceNoChange(val) {
+      this.syncField({ interfaceNo: val })
+    },
+    onGunNoChange(val) {
+      this.syncField({ gunNo: val })
+    },
+    onNewStandardChange(val) {
+      this.form.newNationalStandard = val ? 1 : 0
+      this.syncField({ newNationalStandard: this.form.newNationalStandard })
+    },
+    onNewNationalStandardTaxNoChange(val) {
+      this.syncField({ newNationalStandardTaxNo: val })
+    },
+    onQueryDateChange() {
+      this.updateQueryDate()
+      this.syncField({ queryDate: this.form.queryDate })
+    },
+    /** 按钮点击:下发参数区字段 + buttonType */
+    buildButtonPayload(buttonType) {
+      this.updateQueryDate()
+      return {
+        interfaceNo: this.form.interfaceNo,
+        gunNo: this.form.gunNo,
+        newNationalStandard: this.form.newNationalStandard,
+        newNationalStandardTaxNo: this.form.newNationalStandardTaxNo,
+        queryDate: this.form.queryDate,
+        buttonType
+      }
+    },
+    sendButtonAction(buttonType) {
+      tsbWebSocket.sendPageSync(PAGE_CMD, this.buildButtonPayload(buttonType))
+    },
+    queryTaxSerial() {
+      this.sendButtonAction(BUTTON_TYPE.QUERY)
+    },
+    queryAccumulated(buttonType) {
+      this.sendButtonAction(buttonType)
+    },
+    formatNum(v) {
+      return v == null || v === '' ? '-' : v
+    },
+    resultTag(text) {
+      if (!text) return 'info'
+      return text.indexOf('成功') >= 0 ? 'success' : 'danger'
+    }
+  }
+}
+</script>
+
+<style scoped>
+.tax-page .mb16 { margin-bottom: 16px; }
+.field-label { font-size: 13px; color: #606266; margin-bottom: 6px; }
+.bind-text { margin-left: 12px; color: #909399; font-size: 13px; }
+.panel-card { min-height: 280px; }
+.panel-title { font-weight: 600; }
+.result-item { margin-bottom: 10px; font-size: 14px; line-height: 1.6; }
+.result-item .label { color: #909399; }
+.date-row .unit { margin-left: 4px; font-size: 13px; color: #606266; }
+.btn-group-wrap { display: flex; flex-wrap: wrap; gap: 8px; }
+.param-row >>> .el-select, .param-row >>> .el-input-number { width: 100%; }
+</style>

+ 25 - 7
ruoyi-ui/vue.config.js

@@ -13,6 +13,30 @@ const baseUrl = 'http://localhost:8080' // 后端接口
 
 const port = process.env.port || process.env.npm_config_port || 80 // 端口
 
+/** 开发环境 API / WebSocket 代理,捕获后端不可达时的 socket 错误,避免 dev-server 崩溃 */
+function createDevProxy(apiPrefix) {
+  return {
+    target: baseUrl,
+    changeOrigin: true,
+    ws: true,
+    pathRewrite: {
+      ['^' + apiPrefix]: ''
+    },
+    onError(err, req, res) {
+      console.warn('[dev-proxy]', err.code || err.message, req.url)
+      if (res && !res.headersSent) {
+        res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' })
+        res.end('后端服务不可用,请确认 Java 服务已启动(' + baseUrl + ')')
+      }
+    },
+    onProxyReqWs(proxyReq, req, socket) {
+      socket.on('error', (err) => {
+        console.warn('[dev-proxy][ws]', err.code || err.message, req.url)
+      })
+    }
+  }
+}
+
 // vue.config.js 配置说明
 //官方vue.config.js 参考文档 https://cli.vuejs.org/zh/config/#css-loaderoptions
 // 这里只列一部分,具体配置参考文档
@@ -35,13 +59,7 @@ module.exports = {
     open: true,
     proxy: {
       // detail: https://cli.vuejs.org/config/#devserver-proxy
-      [process.env.VUE_APP_BASE_API]: {
-        target: baseUrl,
-        changeOrigin: true,
-        pathRewrite: {
-          ['^' + process.env.VUE_APP_BASE_API]: ''
-        }
-      },
+      [process.env.VUE_APP_BASE_API]: createDevProxy(process.env.VUE_APP_BASE_API),
       // springdoc proxy
       '^/v3/api-docs/(.*)': {
         target: baseUrl,