Преглед на файлове

Bugfix: add log out & oss safe

Logistics System Developer преди 1 месец
родител
ревизия
b9b168edd2
променени са 6 файла, в които са добавени 545 реда и са изтрити 270 реда
  1. 11 36
      package-lock.json
  2. 1 0
      package.json
  3. 75 12
      src/utils/apiConfig.js
  4. 273 53
      src/utils/sse.js
  5. 154 129
      src/views/NotFound.vue
  6. 31 40
      src/views/mobile/m-Index.vue

+ 11 - 36
package-lock.json

@@ -14,6 +14,7 @@
         "@wangeditor/editor-for-vue": "^5.1.12",
         "amfe-flexible": "^2.2.1",
         "axios": "^1.11.0",
+        "crypto-js": "^4.2.0",
         "docx": "^9.5.1",
         "docx-pdf": "^0.0.1",
         "dompurify": "^3.3.0",
@@ -144,7 +145,6 @@
       "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "@ampproject/remapping": "^2.2.0",
         "@babel/code-frame": "^7.27.1",
@@ -1590,7 +1590,6 @@
       "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
       "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "@types/lodash": "*"
       }
@@ -1659,7 +1658,6 @@
       "resolved": "https://registry.npmmirror.com/@uppy/core/-/core-2.3.4.tgz",
       "integrity": "sha512-iWAqppC8FD8mMVqewavCz+TNaet6HPXitmGXpGGREGrakZ4FeuWytVdrelydzTdXx6vVKkOmI2FLztGg73sENQ==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "@transloadit/prettier-bytes": "0.0.7",
         "@uppy/store-default": "^2.1.1",
@@ -1709,7 +1707,6 @@
       "resolved": "https://registry.npmmirror.com/@uppy/xhr-upload/-/xhr-upload-2.1.3.tgz",
       "integrity": "sha512-YWOQ6myBVPs+mhNjfdWsQyMRWUlrDLMoaG7nvf/G6Y3GKZf8AyjFDjvvJ49XWQ+DaZOftGkHmF1uh/DBeGivJQ==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "@uppy/companion-client": "^2.2.2",
         "@uppy/utils": "^4.1.2",
@@ -2143,7 +2140,6 @@
       "resolved": "https://registry.npmmirror.com/@wangeditor/basic-modules/-/basic-modules-1.1.7.tgz",
       "integrity": "sha512-cY9CPkLJaqF05STqfpZKWG4LpxTMeGSIIF1fHvfm/mz+JXatCagjdkbxdikOuKYlxDdeqvOeBmsUBItufDLXZg==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "is-url": "^1.2.4"
       },
@@ -2176,7 +2172,6 @@
       "resolved": "https://registry.npmmirror.com/@wangeditor/core/-/core-1.1.19.tgz",
       "integrity": "sha512-KevkB47+7GhVszyYF2pKGKtCSj/YzmClsD03C3zTt+9SR2XWT5T0e3yQqg8baZpcMvkjs1D8Dv4fk8ok/UaS2Q==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "@types/event-emitter": "^0.3.3",
         "event-emitter": "^0.3.5",
@@ -2271,7 +2266,6 @@
         }
       ],
       "license": "MIT",
-      "peer": true,
       "bin": {
         "nanoid": "bin/nanoid.cjs"
       },
@@ -2688,7 +2682,6 @@
         }
       ],
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "caniuse-lite": "^1.0.30001733",
         "electron-to-chromium": "^1.5.199",
@@ -3453,7 +3446,6 @@
       "resolved": "https://registry.npmmirror.com/dom7/-/dom7-3.0.0.tgz",
       "integrity": "sha512-oNlcUdHsC4zb7Msx7JN3K0Nro1dzJ48knvBOnDPKJ2GV9wl1i5vydJZUSyOfrkKFDZEud/jBsTk92S/VGSAe/g==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "ssr-window": "^3.0.0-alpha.1"
       }
@@ -4687,8 +4679,7 @@
       "version": "0.2.0",
       "resolved": "https://registry.npmmirror.com/is-hotkey/-/is-hotkey-0.2.0.tgz",
       "integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==",
-      "license": "MIT",
-      "peer": true
+      "license": "MIT"
     },
     "node_modules/is-inside-container": {
       "version": "1.0.0",
@@ -4958,7 +4949,6 @@
         "https://github.com/sponsors/katex"
       ],
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "commander": "^8.3.0"
       },
@@ -5062,15 +5052,13 @@
       "version": "4.17.21",
       "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
       "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
-      "license": "MIT",
-      "peer": true
+      "license": "MIT"
     },
     "node_modules/lodash-es": {
       "version": "4.17.21",
       "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz",
       "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
-      "license": "MIT",
-      "peer": true
+      "license": "MIT"
     },
     "node_modules/lodash-unified": {
       "version": "1.0.3",
@@ -5146,22 +5134,19 @@
       "version": "4.3.0",
       "resolved": "https://registry.npmmirror.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
       "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
-      "license": "MIT",
-      "peer": true
+      "license": "MIT"
     },
     "node_modules/lodash.clonedeep": {
       "version": "4.5.0",
       "resolved": "https://registry.npmmirror.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
       "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
-      "license": "MIT",
-      "peer": true
+      "license": "MIT"
     },
     "node_modules/lodash.debounce": {
       "version": "4.0.8",
       "resolved": "https://registry.npmmirror.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
       "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
-      "license": "MIT",
-      "peer": true
+      "license": "MIT"
     },
     "node_modules/lodash.defaults": {
       "version": "4.2.0",
@@ -5194,8 +5179,7 @@
       "version": "4.5.0",
       "resolved": "https://registry.npmmirror.com/lodash.foreach/-/lodash.foreach-4.5.0.tgz",
       "integrity": "sha512-aEXTF4d+m05rVOAUG3z4vZZ4xVexLKZGF0lIxuHZ1Hplpk/3B6Z1+/ICICYRLm7c41Z2xiejbkCkJoTlypoXhQ==",
-      "license": "MIT",
-      "peer": true
+      "license": "MIT"
     },
     "node_modules/lodash.isarguments": {
       "version": "3.1.0",
@@ -5214,8 +5198,7 @@
       "resolved": "https://registry.npmmirror.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
       "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
       "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
-      "license": "MIT",
-      "peer": true
+      "license": "MIT"
     },
     "node_modules/lodash.isplainobject": {
       "version": "4.0.6",
@@ -5290,15 +5273,13 @@
       "version": "4.1.1",
       "resolved": "https://registry.npmmirror.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
       "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==",
-      "license": "MIT",
-      "peer": true
+      "license": "MIT"
     },
     "node_modules/lodash.toarray": {
       "version": "4.4.0",
       "resolved": "https://registry.npmmirror.com/lodash.toarray/-/lodash.toarray-4.4.0.tgz",
       "integrity": "sha512-QyffEA3i5dma5q2490+SgCvDN0pXLmRGSyAANuVi0HQ01Pkfr9fuoKQW8wm1wGBnJITs/mS7wQvS6VshUEBFCw==",
-      "license": "MIT",
-      "peer": true
+      "license": "MIT"
     },
     "node_modules/lodash.toplainobject": {
       "version": "3.0.0",
@@ -5423,7 +5404,6 @@
       "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.0.tgz",
       "integrity": "sha512-CTPAcRBq57cn3R8n3hwc2REddc28hjR7RzDXQ+lXLmMJYqn20BaI2cGw6QjgZGIgVfp2Wdfw4aMzgNteQ6qJgQ==",
       "license": "MIT",
-      "peer": true,
       "bin": {
         "marked": "bin/marked.js"
       },
@@ -6704,7 +6684,6 @@
         }
       ],
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "nanoid": "^3.3.11",
         "picocolors": "^1.1.1",
@@ -7596,7 +7575,6 @@
       "resolved": "https://registry.npmmirror.com/slate/-/slate-0.72.8.tgz",
       "integrity": "sha512-/nJwTswQgnRurpK+bGJFH1oM7naD5qDmHd89JyiKNT2oOKD8marW0QSBtuFnwEbL5aGCS8AmrhXQgNOsn4osAw==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "immer": "^9.0.6",
         "is-plain-object": "^5.0.0",
@@ -7638,7 +7616,6 @@
       "resolved": "https://registry.npmmirror.com/snabbdom/-/snabbdom-3.6.3.tgz",
       "integrity": "sha512-W2lHLLw2qR2Vv0DcMmcxXqcfdBaIcoN+y/86SmHv8fn4DazEQSH6KN3TjZcWvwujW56OHiiirsbHWZb4vx/0fg==",
       "license": "MIT",
-      "peer": true,
       "engines": {
         "node": ">=12.17.0"
       }
@@ -8281,7 +8258,6 @@
       "integrity": "sha512-OOUi5zjkDxYrKhTV3V7iKsoS37VUM7v40+HuwEmcrsf11Cdx9y3DIr2Px6liIcZFwt3XSRpQvFpL3WVy7ApkGw==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "esbuild": "^0.25.0",
         "fdir": "^6.5.0",
@@ -8487,7 +8463,6 @@
       "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.18.tgz",
       "integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "@vue/compiler-dom": "3.5.18",
         "@vue/compiler-sfc": "3.5.18",

+ 1 - 0
package.json

@@ -18,6 +18,7 @@
     "@wangeditor/editor-for-vue": "^5.1.12",
     "amfe-flexible": "^2.2.1",
     "axios": "^1.11.0",
+    "crypto-js": "^4.2.0",
     "docx": "^9.5.1",
     "docx-pdf": "^0.0.1",
     "dompurify": "^3.3.0",

+ 75 - 12
src/utils/apiConfig.js

@@ -3,6 +3,8 @@
  * 根据环境自动处理 API 路径前缀
  */
 
+import CryptoJS from 'crypto-js'
+
 /**
  * 获取 API 路径前缀
  * 开发环境:/api/v1
@@ -40,34 +42,83 @@ export function getReportApiPrefix() {
   return getApiPrefix()
 }
 
+/**
+ * 解密文件URL
+ * @param {string} encryptedUrl - 加密的Base64 URL字符串
+ * @returns {string} 解密后的原始URL
+ */
+export function decryptFileUrl(encryptedUrl) {
+  if (!encryptedUrl) return ''
+
+  try {
+    // 配置密钥和IV (必须与后端一致)
+    const KEY = 'shudao-aes-key-32bytes-changeeee'  // 32字节
+    const IV = 'shudao-aes-iv16!'                    // 16字节
+
+    // 1. Base64 URL-Safe解码 (替换URL安全字符)
+    const base64 = encryptedUrl
+      .replace(/-/g, '+')   // 将 - 替换为 +
+      .replace(/_/g, '/')   // 将 _ 替换为 /
+
+    // 2. 转换为CryptoJS格式
+    const key = CryptoJS.enc.Utf8.parse(KEY)
+    const iv = CryptoJS.enc.Utf8.parse(IV)
+    const encrypted = CryptoJS.enc.Base64.parse(base64)
+
+    // 3. AES-CBC解密
+    const decrypted = CryptoJS.AES.decrypt(
+      { ciphertext: encrypted },
+      key,
+      {
+        iv: iv,
+        mode: CryptoJS.mode.CBC,
+        padding: CryptoJS.pad.Pkcs7
+      }
+    )
+
+    // 4. 转换为UTF-8字符串
+    const originalUrl = decrypted.toString(CryptoJS.enc.Utf8)
+
+    if (!originalUrl) {
+      throw new Error('解密失败:结果为空')
+    }
+
+    return originalUrl
+
+  } catch (error) {
+    console.error('[URL解密] 失败:', error)
+    throw new Error(`URL解密失败: ${error.message}`)
+  }
+}
+
 /**
  * 转换文档预览URL
  * 根据运行环境自动转换文档预览地址
- * 
+ *
  * 使用场景:
  * - PDF/文档预览
  * - 图片预览
  * - 其他需要通过OSS代理访问的资源
- * 
+ *
  * 环境说明:
  * 1. 开发环境(import.meta.env.DEV = true):
  *    - 直接返回原始URL
  *    - 示例:http://172.16.17.52:8060/gdsc-ai-aqzs/documents/xxx.pdf
- * 
+ *
  * 2. 生产环境(import.meta.env.PROD = true):
  *    - 通过OSS解析服务代理访问
  *    - 转换格式:https://aqai.shudaodsj.com:22000/apiv1/oss/parse/?url={原始URL编码}
  *    - 示例输入:http://172.16.17.52:8060/gdsc-ai-aqzs/documents/xxx.pdf
  *    - 示例输出:https://aqai.shudaodsj.com:22000/apiv1/oss/parse/?url=http%3A%2F%2F172.16.17.52%3A8060%2Fgdsc-ai-aqzs%2Fdocuments%2Fxxx.pdf
- * 
- * @param {string} originalUrl - 原始文档URL
+ *
+ * @param {string} originalUrl - 原始文档URL(可能是加密的)
  * @returns {string} 转换后的预览URL
- * 
+ *
  * @example
  * // 开发环境
  * buildDocumentPreviewUrl('http://172.16.17.52:8060/gdsc-ai-aqzs/documents/xxx.pdf')
  * // => 'http://172.16.17.52:8060/gdsc-ai-aqzs/documents/xxx.pdf'
- * 
+ *
  * @example
  * // 生产环境
  * buildDocumentPreviewUrl('http://172.16.17.52:8060/gdsc-ai-aqzs/documents/xxx.pdf')
@@ -75,15 +126,27 @@ export function getReportApiPrefix() {
  */
 export function buildDocumentPreviewUrl(originalUrl) {
   if (!originalUrl) return ''
-  
-  // 开发环境:直接返回原始URL,可以直接访问内网地址
+
+  // 尝试解密URL(如果是加密的)
+  let decryptedUrl = originalUrl
+  try {
+    // 判断是否为加密URL(Base64 URL-Safe格式通常包含 - 或 _ 字符)
+    if (originalUrl.includes('-') || originalUrl.includes('_')) {
+      decryptedUrl = decryptFileUrl(originalUrl)
+    }
+  } catch (error) {
+    console.warn('[URL解密] 跳过解密,使用原始URL:', error.message)
+    decryptedUrl = originalUrl
+  }
+
+  // 开发环境:直接返回解密后的URL,可以直接访问内网地址
   if (import.meta.env.DEV) {
-    return originalUrl
+    return decryptedUrl
   }
-  
+
   // 生产环境:通过 OSS 解析服务代理访问
   // 这样可以解决生产环境无法直接访问内网地址的问题
   const ossParseUrl = 'https://aqai.shudaodsj.com:22000/apiv1/oss/parse/'
-  return `${ossParseUrl}?url=${encodeURIComponent(originalUrl)}`
+  return `${ossParseUrl}?url=${encodeURIComponent(decryptedUrl)}`
 }
 

+ 273 - 53
src/utils/sse.js

@@ -1,83 +1,303 @@
 /**
- * SSE工具函数
+ * SSE工具函数(支持自动重连)
  */
 
 import { getToken, getTokenType } from './auth.js'
 
 /**
- * 创建SSE连接
- * @param {string} url - SSE URL
- * @param {Object} handlers - 事件处理器
- * @param {Function} handlers.onMessage - 接收消息时的回调
- * @param {Function} handlers.onError - 错误回调
- * @param {Function} handlers.onComplete - 完成回调
- * @returns {EventSource} EventSource实例
+ * SSE连接管理器类(支持自动重连)
  */
-export const createSSEConnection = (url, handlers = {}) => {
-  // EventSource 不支持自定义请求头,需要通过 URL 参数传递 Token
-  const token = getToken()
-  const tokenType = getTokenType()
-  
-  if (token && tokenType) {
-    const urlObj = new URL(url, window.location.origin)
-    // 将 Token 作为 URL 参数传递(后端需要支持从 URL 参数读取 Token)
-    urlObj.searchParams.set('token', token)
-    url = urlObj.toString()
-    
-    console.log('🔐 SSE 连接已添加认证 Token(通过 URL 参数)')
-  } else {
-    console.warn('⚠️ SSE 连接未找到 Token,可能会导致认证失败')
+class SSEConnectionManager {
+  constructor(url, handlers = {}, options = {}) {
+    this.originalUrl = url
+    this.handlers = handlers
+    this.options = {
+      maxRetries: options.maxRetries || 5, // 最大重试次数
+      retryDelay: options.retryDelay || 1000, // 初始重试延迟(毫秒)
+      maxRetryDelay: options.maxRetryDelay || 30000, // 最大重试延迟(毫秒)
+      enableAutoReconnect: options.enableAutoReconnect !== false, // 是否启用自动重连,默认true
+      ...options
+    }
+
+    this.eventSource = null
+    this.retryCount = 0
+    this.retryTimer = null
+    this.isManualClose = false // 是否手动关闭
+    this.isCompleted = false // 是否已完成(收到completed消息)
+    this.lastMessageTime = Date.now() // 最后一次收到消息的时间
+    this.heartbeatTimer = null // 心跳检测定时器
+
+    this.connect()
+  }
+
+  /**
+   * 建立SSE连接
+   */
+  connect() {
+    // 如果手动关闭或已完成,不再重连
+    if (this.isManualClose || this.isCompleted) {
+      console.log('🚫 SSE连接已手动关闭或已完成,不再重连')
+      return
+    }
+
+    // 添加Token到URL
+    let url = this.originalUrl
+    const token = getToken()
+    const tokenType = getTokenType()
+
+    if (token && tokenType) {
+      const urlObj = new URL(url, window.location.origin)
+      urlObj.searchParams.set('token', token)
+      url = urlObj.toString()
+      console.log('🔐 SSE 连接已添加认证 Token(通过 URL 参数)')
+    } else {
+      console.warn('⚠️ SSE 连接未找到 Token,可能会导致认证失败')
+    }
+
+    try {
+      this.eventSource = new EventSource(url)
+      console.log(`🔌 SSE连接已建立 (重试次数: ${this.retryCount}/${this.options.maxRetries})`)
+
+      // 绑定事件处理器
+      this.eventSource.onmessage = this.handleMessage.bind(this)
+      this.eventSource.onerror = this.handleError.bind(this)
+      this.eventSource.onopen = this.handleOpen.bind(this)
+
+      // 启动心跳检测(可选)
+      this.startHeartbeat()
+
+    } catch (error) {
+      console.error('❌ 创建SSE连接失败:', error)
+      this.scheduleReconnect()
+    }
+  }
+
+  /**
+   * 处理连接打开事件
+   */
+  handleOpen() {
+    console.log('✅ SSE连接已打开')
+    this.retryCount = 0 // 重置重试计数
+    this.lastMessageTime = Date.now()
+
+    // 调用用户的onOpen回调(如果有)
+    if (this.handlers.onOpen) {
+      this.handlers.onOpen()
+    }
   }
-  
-  const eventSource = new EventSource(url)
-  
-  eventSource.onmessage = (event) => {
+
+  /**
+   * 处理消息事件
+   */
+  handleMessage(event) {
+    this.lastMessageTime = Date.now()
+
     try {
       const data = JSON.parse(event.data)
-      
+
       // 根据消息类型分发
       if (data.type === 'completed') {
-        handlers.onComplete && handlers.onComplete(data)
-        eventSource.close()
+        console.log('✅ SSE流程已完成')
+        this.isCompleted = true
+        this.handlers.onComplete && this.handlers.onComplete(data)
+        this.close()
       } else if (data.type === 'interrupted') {
-        handlers.onInterrupted && handlers.onInterrupted(data)
-        eventSource.close()
+        console.log('⚠️ SSE流程被中断')
+        this.isCompleted = true
+        this.handlers.onInterrupted && this.handlers.onInterrupted(data)
+        this.close()
       } else if (data.type === 'error') {
-        handlers.onError && handlers.onError(new Error(data.message))
-        eventSource.close()
+        console.error('❌ SSE返回错误:', data.message)
+        this.handlers.onError && this.handlers.onError(new Error(data.message))
+        this.close()
       } else {
-        handlers.onMessage && handlers.onMessage(data)
+        // 普通消息
+        this.handlers.onMessage && this.handlers.onMessage(data)
       }
     } catch (error) {
-      console.error('解析SSE消息失败:', error)
-      handlers.onError && handlers.onError(error)
+      console.error('解析SSE消息失败:', error)
+      this.handlers.onError && this.handlers.onError(error)
     }
   }
-  
-  eventSource.onerror = (error) => {
-    console.error('SSE连接错误:', error)
-    console.error('EventSource readyState:', eventSource.readyState)
-    console.error('EventSource url:', eventSource.url)
-    
-    // 创建更详细的错误信息
+
+  /**
+   * 处理错误事件
+   */
+  handleError(error) {
+    console.error('❌ SSE连接错误:', error)
+
+    if (this.eventSource) {
+      console.error('EventSource readyState:', this.eventSource.readyState)
+      console.error('EventSource url:', this.eventSource.url)
+    }
+
+    // 如果是手动关闭或已完成,不处理错误
+    if (this.isManualClose || this.isCompleted) {
+      return
+    }
+
+    // 创建详细的错误信息
     const detailedError = new Error(
-      `SSE连接失败 (状态: ${eventSource.readyState === 0 ? '连接中' : eventSource.readyState === 1 ? '已连接' : '已关闭'})`
+      `SSE连接失败 (状态: ${this.eventSource?.readyState === 0 ? '连接中' : this.eventSource?.readyState === 1 ? '已连接' : '已关闭'})`
     )
-    
-    handlers.onError && handlers.onError(detailedError)
-    eventSource.close()
+
+    // 调用错误回调
+    this.handlers.onError && this.handlers.onError(detailedError)
+
+    // 关闭当前连接
+    if (this.eventSource) {
+      this.eventSource.close()
+    }
+
+    // 尝试重连
+    this.scheduleReconnect()
   }
-  
-  return eventSource
+
+  /**
+   * 安排重连
+   */
+  scheduleReconnect() {
+    // 如果禁用自动重连,直接返回
+    if (!this.options.enableAutoReconnect) {
+      console.log('🚫 自动重连已禁用')
+      return
+    }
+
+    // 如果手动关闭或已完成,不重连
+    if (this.isManualClose || this.isCompleted) {
+      console.log('🚫 连接已手动关闭或已完成,不再重连')
+      return
+    }
+
+    // 检查是否超过最大重试次数
+    if (this.retryCount >= this.options.maxRetries) {
+      console.error(`❌ 已达到最大重试次数 (${this.options.maxRetries}),停止重连`)
+      this.handlers.onMaxRetriesReached && this.handlers.onMaxRetriesReached()
+      return
+    }
+
+    // 计算重试延迟(指数退避)
+    const delay = Math.min(
+      this.options.retryDelay * Math.pow(2, this.retryCount),
+      this.options.maxRetryDelay
+    )
+
+    this.retryCount++
+    console.log(`🔄 将在 ${delay}ms 后进行第 ${this.retryCount} 次重连...`)
+
+    // 调用重连前回调
+    if (this.handlers.onReconnecting) {
+      this.handlers.onReconnecting(this.retryCount, delay)
+    }
+
+    // 清除之前的重试定时器
+    if (this.retryTimer) {
+      clearTimeout(this.retryTimer)
+    }
+
+    // 设置重连定时器
+    this.retryTimer = setTimeout(() => {
+      this.connect()
+    }, delay)
+  }
+
+  /**
+   * 启动心跳检测(可选)
+   */
+  startHeartbeat() {
+    // 清除之前的心跳定时器
+    if (this.heartbeatTimer) {
+      clearInterval(this.heartbeatTimer)
+    }
+
+    // 如果配置了心跳间隔,启动心跳检测
+    if (this.options.heartbeatInterval) {
+      this.heartbeatTimer = setInterval(() => {
+        const timeSinceLastMessage = Date.now() - this.lastMessageTime
+
+        // 如果超过心跳间隔的2倍没有收到消息,认为连接可能断开
+        if (timeSinceLastMessage > this.options.heartbeatInterval * 2) {
+          console.warn('⚠️ 心跳检测:长时间未收到消息,可能连接已断开')
+          // 可以选择主动关闭并重连
+          if (this.eventSource && this.eventSource.readyState === EventSource.OPEN) {
+            this.eventSource.close()
+            this.scheduleReconnect()
+          }
+        }
+      }, this.options.heartbeatInterval)
+    }
+  }
+
+  /**
+   * 手动关闭连接
+   */
+  close() {
+    console.log('🔌 手动关闭SSE连接')
+    this.isManualClose = true
+
+    // 清除重试定时器
+    if (this.retryTimer) {
+      clearTimeout(this.retryTimer)
+      this.retryTimer = null
+    }
+
+    // 清除心跳定时器
+    if (this.heartbeatTimer) {
+      clearInterval(this.heartbeatTimer)
+      this.heartbeatTimer = null
+    }
+
+    // 关闭EventSource
+    if (this.eventSource && this.eventSource.readyState !== EventSource.CLOSED) {
+      this.eventSource.close()
+    }
+  }
+
+  /**
+   * 获取当前连接状态
+   */
+  getState() {
+    return {
+      readyState: this.eventSource?.readyState,
+      retryCount: this.retryCount,
+      isManualClose: this.isManualClose,
+      isCompleted: this.isCompleted
+    }
+  }
+}
+
+/**
+ * 创建SSE连接(带自动重连功能)
+ * @param {string} url - SSE URL
+ * @param {Object} handlers - 事件处理器
+ * @param {Function} handlers.onMessage - 接收消息时的回调
+ * @param {Function} handlers.onError - 错误回调
+ * @param {Function} handlers.onComplete - 完成回调
+ * @param {Function} handlers.onInterrupted - 中断回调
+ * @param {Function} handlers.onOpen - 连接打开回调
+ * @param {Function} handlers.onReconnecting - 重连中回调
+ * @param {Function} handlers.onMaxRetriesReached - 达到最大重试次数回调
+ * @param {Object} options - 配置选项
+ * @param {number} options.maxRetries - 最大重试次数,默认5
+ * @param {number} options.retryDelay - 初始重试延迟(毫秒),默认1000
+ * @param {number} options.maxRetryDelay - 最大重试延迟(毫秒),默认30000
+ * @param {boolean} options.enableAutoReconnect - 是否启用自动重连,默认true
+ * @param {number} options.heartbeatInterval - 心跳检测间隔(毫秒),可选
+ * @returns {SSEConnectionManager} SSE连接管理器实例
+ */
+export const createSSEConnection = (url, handlers = {}, options = {}) => {
+  return new SSEConnectionManager(url, handlers, options)
 }
 
 /**
  * 关闭SSE连接
- * @param {EventSource} eventSource - EventSource实例
+ * @param {SSEConnectionManager|EventSource} connection - SSE连接管理器或EventSource实例
  */
-export const closeSSEConnection = (eventSource) => {
-  if (eventSource && eventSource.readyState !== EventSource.CLOSED) {
-    eventSource.close()
+export const closeSSEConnection = (connection) => {
+  if (connection instanceof SSEConnectionManager) {
+    connection.close()
+  } else if (connection && connection.readyState !== EventSource.CLOSED) {
+    connection.close()
   }
 }
 

+ 154 - 129
src/views/NotFound.vue

@@ -1,55 +1,62 @@
 <template>
   <div class="not-found-container">
-    <div class="not-found-content">
-      <div class="error-icon">
-        <svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
-          <circle cx="100" cy="100" r="80" fill="none" stroke="#ff6b6b" stroke-width="4"/>
-          <line x1="70" y1="70" x2="130" y2="130" stroke="#ff6b6b" stroke-width="4" stroke-linecap="round"/>
-          <line x1="130" y1="70" x2="70" y2="130" stroke="#ff6b6b" stroke-width="4" stroke-linecap="round"/>
-        </svg>
-      </div>
-      
-      <h1 class="error-code">404</h1>
-      <h2 class="error-title">{{ errorTitle }}</h2>
-      <p class="error-message">{{ errorMessage }}</p>
-      
-      <div class="error-details" v-if="showDetails">
-        <p class="detail-text">{{ detailMessage }}</p>
+    <!-- 错误图标 -->
+    <div class="error-icon">
+      <svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
+        <circle cx="100" cy="100" r="80" fill="none" stroke="#ff6b6b" stroke-width="4"/>
+        <line x1="70" y1="70" x2="130" y2="130" stroke="#ff6b6b" stroke-width="4" stroke-linecap="round"/>
+        <line x1="130" y1="70" x2="70" y2="130" stroke="#ff6b6b" stroke-width="4" stroke-linecap="round"/>
+      </svg>
+    </div>
+
+    <!-- 404 错误码 -->
+    <h1 class="error-code">404</h1>
+
+    <!-- 错误标题 -->
+    <h2 class="error-title">{{ errorTitle }}</h2>
+
+    <!-- 错误消息 -->
+    <p class="error-message">{{ errorMessage }}</p>
+
+    <!-- 详细信息 -->
+    <div class="error-details" v-if="showDetails">
+      <p class="detail-text">{{ detailMessage }}</p>
+    </div>
+
+    <!-- 调试信息区域 -->
+    <div class="debug-info" v-if="debugInfo.length > 0">
+      <div class="debug-header" @click="toggleDebug">
+        <span>🐛 调试信息 (点击{{ showDebug ? '收起' : '展开' }})</span>
       </div>
-      
-      <!-- 调试信息区域 -->
-      <div class="debug-info" v-if="debugInfo.length > 0">
-        <div class="debug-header" @click="toggleDebug">
-          <span>🐛 调试信息 (点击{{ showDebug ? '收起' : '展开' }})</span>
-        </div>
-        <div class="debug-content" v-if="showDebug">
-          <div class="debug-item" v-for="(log, index) in debugInfo" :key="index" :class="log.type">
-            <span class="debug-time">{{ log.time }}</span>
-            <span class="debug-message">{{ log.message }}</span>
-          </div>
+      <div class="debug-content" v-if="showDebug">
+        <div class="debug-item" v-for="(log, index) in debugInfo" :key="index" :class="log.type">
+          <span class="debug-time">{{ log.time }}</span>
+          <span class="debug-message">{{ log.message }}</span>
         </div>
       </div>
-      
-      <div class="action-buttons">
-        <button class="btn-primary" @click="retry">
-          <span class="icon">🔄</span>
-          重新尝试
-        </button>
-        <button class="btn-secondary" @click="contactSupport">
-          <span class="icon">📞</span>
-          联系支持
-        </button>
-      </div>
-      
-      <div class="help-info">
-        <p>如果问题持续存在,请尝试以下操作:</p>
-        <ul>
-          <li>确认您有访问权限</li>
-          <li>检查网络连接</li>
-          <li>清除浏览器缓存后重试</li>
-          <li>联系系统管理员获取帮助</li>
-        </ul>
-      </div>
+    </div>
+
+    <!-- 操作按钮 -->
+    <div class="action-buttons">
+      <button class="btn-primary" @click="retry">
+        <span class="icon">🔄</span>
+        重新尝试
+      </button>
+      <button class="btn-secondary" @click="contactSupport">
+        <span class="icon">📞</span>
+        联系支持
+      </button>
+    </div>
+
+    <!-- 帮助信息 -->
+    <div class="help-info">
+      <p>如果问题持续存在,请尝试以下操作:</p>
+      <ul>
+        <li>确认您有访问权限</li>
+        <li>检查网络连接</li>
+        <li>清除浏览器缓存后重试</li>
+        <li>联系系统管理员获取帮助</li>
+      </ul>
     </div>
   </div>
 </template>
@@ -81,7 +88,7 @@ export default {
     } else if (reason === 'ticket_not_found') {
       this.errorTitle = '缺少访问凭证'
       this.errorMessage = '未检测到有效的访问票据'
-      this.detailMessage = '请从统一认证门户进入系统,不要直接访问此地址。'
+      this.detailMessage = '请从4A统一认证门户进入系统,不要直接访问此地址。'
     } else if (reason === 'token_expired') {
       this.errorTitle = '登录已过期'
       this.errorMessage = '您的登录状态已失效'
@@ -228,150 +235,152 @@ export default {
 </script>
 
 <style scoped>
+/* 容器样式 - 纯白色背景 */
 .not-found-container {
   min-height: 100vh;
   display: flex;
+  flex-direction: column;
   align-items: center;
   justify-content: center;
-  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-  padding: 20px;
+  background: #FFFFFF;
+  padding: 40px 20px;
+  text-align: center;
 }
 
-.not-found-content {
-  max-width: 600px;
-  width: 100%;
-  background: white;
-  border-radius: 20px;
-  padding: 60px 40px;
-  text-align: center;
-  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
-  animation: slideUp 0.5s ease-out;
+/* 错误图标 */
+.error-icon {
+  width: 100px;
+  height: 100px;
+  margin: 0 auto 30px;
+  animation: fadeIn 0.6s ease-out;
 }
 
-@keyframes slideUp {
+@keyframes fadeIn {
   from {
     opacity: 0;
-    transform: translateY(30px);
+    transform: scale(0.8);
   }
   to {
     opacity: 1;
-    transform: translateY(0);
+    transform: scale(1);
   }
 }
 
-.error-icon {
-  width: 120px;
-  height: 120px;
-  margin: 0 auto 30px;
-  animation: shake 0.5s ease-in-out;
-}
-
-@keyframes shake {
-  0%, 100% { transform: rotate(0deg); }
-  25% { transform: rotate(-5deg); }
-  75% { transform: rotate(5deg); }
-}
-
 .error-icon svg {
   width: 100%;
   height: 100%;
-  filter: drop-shadow(0 4px 8px rgba(255, 107, 107, 0.3));
+  filter: drop-shadow(0 2px 8px rgba(255, 107, 107, 0.2));
 }
 
+/* 404 错误码 */
 .error-code {
-  font-size: 80px;
+  font-size: 96px;
   font-weight: 700;
   color: #ff6b6b;
   margin: 0 0 20px;
   line-height: 1;
-  text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+  letter-spacing: -2px;
 }
 
+/* 错误标题 */
 .error-title {
-  font-size: 28px;
+  font-size: 24px;
   font-weight: 600;
   color: #2c3e50;
-  margin: 0 0 15px;
+  margin: 0 0 12px;
 }
 
+/* 错误消息 */
 .error-message {
-  font-size: 18px;
-  color: #7f8c8d;
+  font-size: 16px;
+  color: #95a5a6;
   margin: 0 0 30px;
   line-height: 1.6;
+  max-width: 500px;
 }
 
+/* 详细信息 - 浅黄色胶囊样式 */
 .error-details {
-  background: #fff3cd;
-  border: 1px solid #ffc107;
-  border-radius: 10px;
-  padding: 20px;
-  margin: 0 0 30px;
+  background: #fff9e6;
+  border-radius: 25px;
+  padding: 16px 24px;
+  margin: 0 auto 30px;
+  max-width: 600px;
+  display: inline-block;
 }
 
 .detail-text {
   margin: 0;
-  color: #856404;
-  font-size: 15px;
+  color: #d4a017;
+  font-size: 14px;
   line-height: 1.6;
 }
 
+/* 操作按钮 - 胶囊样式 */
 .action-buttons {
   display: flex;
-  gap: 15px;
+  gap: 12px;
   justify-content: center;
   margin-bottom: 40px;
+  flex-wrap: wrap;
 }
 
 .action-buttons button {
-  padding: 14px 32px;
+  padding: 12px 28px;
   border: none;
-  border-radius: 10px;
-  font-size: 16px;
-  font-weight: 600;
+  border-radius: 25px;
+  font-size: 15px;
+  font-weight: 500;
   cursor: pointer;
   transition: all 0.3s ease;
   display: flex;
   align-items: center;
   gap: 8px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
 }
 
+/* 主按钮 - 浅蓝色 */
 .btn-primary {
-  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  background: #5dade2;
   color: white;
-  box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
 }
 
 .btn-primary:hover {
+  background: #3498db;
   transform: translateY(-2px);
-  box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
+  box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3);
 }
 
+/* 次要按钮 - 白色边框 */
 .btn-secondary {
   background: white;
-  color: #667eea;
-  border: 2px solid #667eea;
+  color: #5dade2;
+  border: 2px solid #5dade2;
 }
 
 .btn-secondary:hover {
-  background: #667eea;
+  background: #5dade2;
   color: white;
   transform: translateY(-2px);
+  box-shadow: 0 4px 12px rgba(52, 152, 219, 0.2);
 }
 
 .icon {
-  font-size: 18px;
+  font-size: 16px;
 }
 
+/* 帮助信息 */
 .help-info {
   background: #f8f9fa;
-  border-radius: 10px;
-  padding: 25px;
+  border-radius: 16px;
+  padding: 24px 28px;
   text-align: left;
+  max-width: 500px;
+  margin: 0 auto;
 }
 
 .help-info p {
-  margin: 0 0 15px;
+  margin: 0 0 16px;
   font-weight: 600;
   color: #2c3e50;
   font-size: 15px;
@@ -379,25 +388,26 @@ export default {
 
 .help-info ul {
   margin: 0;
-  padding-left: 20px;
+  padding-left: 0;
   list-style: none;
 }
 
 .help-info li {
-  margin-bottom: 10px;
-  color: #5a6c7d;
+  margin-bottom: 12px;
+  color: #7f8c8d;
   font-size: 14px;
   line-height: 1.6;
   position: relative;
-  padding-left: 20px;
+  padding-left: 24px;
 }
 
 .help-info li:before {
   content: "✓";
   position: absolute;
   left: 0;
-  color: #667eea;
+  color: #5dade2;
   font-weight: bold;
+  font-size: 16px;
 }
 
 .help-info li:last-child {
@@ -407,38 +417,41 @@ export default {
 /* 调试信息样式 */
 .debug-info {
   background: #f8f9fa;
-  border: 1px solid #dee2e6;
-  border-radius: 10px;
-  margin: 20px 0;
+  border: 1px solid #e9ecef;
+  border-radius: 16px;
+  margin: 20px auto;
   overflow: hidden;
+  max-width: 600px;
 }
 
 .debug-header {
-  background: #343a40;
+  background: #2c3e50;
   color: white;
-  padding: 15px 20px;
+  padding: 14px 20px;
   cursor: pointer;
   user-select: none;
   display: flex;
   justify-content: space-between;
   align-items: center;
   transition: background 0.3s;
+  font-size: 14px;
+  font-weight: 500;
 }
 
 .debug-header:hover {
-  background: #495057;
+  background: #34495e;
 }
 
 .debug-content {
   max-height: 400px;
   overflow-y: auto;
-  padding: 15px;
+  padding: 16px;
 }
 
 .debug-item {
-  padding: 10px;
+  padding: 10px 12px;
   margin-bottom: 8px;
-  border-radius: 5px;
+  border-radius: 8px;
   font-family: 'Courier New', monospace;
   font-size: 12px;
   display: flex;
@@ -448,7 +461,7 @@ export default {
 
 .debug-item.info {
   background: #e3f2fd;
-  border-left: 3px solid #2196f3;
+  border-left: 3px solid #5dade2;
 }
 
 .debug-item.success {
@@ -482,30 +495,42 @@ export default {
 
 /* 移动端适配 */
 @media (max-width: 768px) {
-  .not-found-content {
-    padding: 40px 20px;
+  .not-found-container {
+    padding: 30px 16px;
   }
-  
+
+  .error-icon {
+    width: 80px;
+    height: 80px;
+    margin-bottom: 24px;
+  }
+
   .error-code {
-    font-size: 60px;
+    font-size: 72px;
   }
-  
+
   .error-title {
-    font-size: 22px;
+    font-size: 20px;
   }
-  
+
   .error-message {
-    font-size: 16px;
+    font-size: 15px;
   }
-  
+
   .action-buttons {
     flex-direction: column;
+    width: 100%;
+    max-width: 300px;
   }
-  
+
   .action-buttons button {
     width: 100%;
     justify-content: center;
   }
+
+  .help-info {
+    padding: 20px;
+  }
 }
 </style>
 

+ 31 - 40
src/views/mobile/m-Index.vue

@@ -360,66 +360,57 @@ const goToPolicyDocument = () => {
   router.push('/mobile/policy-document')
 }
 
-// 返回APP(关闭页面
+// 返回APP(登出逻辑
 const handleLogout = () => {
   console.log('='.repeat(60))
-  console.log('📱 用户点击"返回APP"按钮')
+  console.log('📱 用户点击"返回APP"按钮 - 执行登出逻辑')
   console.log('🌐 当前 URL:', window.location.href)
   console.log('🔍 检查 window.nativeClosePage:', typeof window.nativeClosePage)
-  
-  // 策略1: 优先使用原生方法关闭页面
-  if (window.nativeClosePage && typeof window.nativeClosePage === 'function') {
-    try {
-      console.log('✅ nativeClosePage 方法存在,准备调用...')
+
+  try {
+    // 步骤1: 清除本地存储的用户信息和 token
+    console.log('🧹 开始清除本地存储数据...')
+    localStorage.removeItem('token')
+    localStorage.removeItem('userInfo')
+    localStorage.removeItem('username')
+    sessionStorage.clear()
+    console.log('✅ 本地存储数据已清除')
+
+    // 步骤2: 调用原生方法关闭页面
+    // 根据《移动客户端与H5对接规范》,优先使用 nativeClosePage() 方法
+    if (window.nativeClosePage && typeof window.nativeClosePage === 'function') {
+      console.log('✅ 检测到 nativeClosePage 方法,准备调用原生接口...')
       window.nativeClosePage()
       console.log('✅ 已成功调用 nativeClosePage()')
-      
-      // 设置标记,防止页面刷新时触发 404
-      sessionStorage.setItem('is_closing', 'true')
-      
-      // 如果原生方法没有立即关闭,延迟尝试 window.close()
-      setTimeout(() => {
-        console.log('🔄 延迟尝试使用 window.close() 关闭页面')
-        try {
-          window.close()
-          console.log('✅ 已调用 window.close()')
-        } catch (e) {
-          console.warn('⚠️ window.close() 调用失败:', e.message)
-        }
-      }, 100)
-      
       return
-    } catch (error) {
-      console.error('❌ 调用 nativeClosePage() 失败:', error)
-      console.error('❌ 错误详情:', error.message)
-      // 继续尝试 window.close()
     }
-  }
-  
-  // 策略2: 使用 window.close() 关闭 WebView
-  // 在 APP 内嵌 H5 中,关闭 WebView 就会返回到 APP
-  try {
-    console.log('🔄 尝试使用 window.close() 关闭页面')
+
+    // 降级方案1: 尝试使用 finishPage() 方法(部分 APP 可能使用此命名)
+    if (window.finishPage && typeof window.finishPage === 'function') {
+      console.log('✅ 检测到 finishPage 方法,准备调用原生接口...')
+      window.finishPage()
+      console.log('✅ 已成功调用 finishPage()')
+      return
+    }
+
+    // 降级方案2: 使用 window.close() 关闭 WebView
+    console.log('⚠️ 未检测到原生方法,尝试使用 window.close()')
     window.close()
     console.log('✅ 已调用 window.close()')
-    
-    // 设置标记,防止页面刷新时触发 404
-    sessionStorage.setItem('is_closing', 'true')
-    
+
     // 如果 window.close() 没有立即关闭(比如在普通浏览器中),显示提示
     setTimeout(() => {
       console.warn('⚠️ window.close() 可能未生效,显示提示信息')
       ElMessage.info('如果页面未关闭,请使用 APP 的返回按钮')
     }, 500)
-    
+
   } catch (error) {
-    console.error('❌ window.close() 调用失败:', error)
+    console.error('❌ 登出过程发生错误:', error)
     console.error('❌ 错误详情:', error.message)
     console.warn('⚠️ 当前环境:', navigator.userAgent)
-    // 如果都失败了,提示用户
     ElMessage.warning('无法自动关闭页面,请使用 APP 的返回按钮')
   }
-  
+
   console.log('='.repeat(60))
 }