瀏覽代碼

界面更新

flower_bs 1 月之前
父節點
當前提交
d9bdf4c4f4

+ 4 - 0
erp/apps.py

@@ -4,3 +4,7 @@ from django.apps import AppConfig
 class ErpConfig(AppConfig):
     default_auto_field = 'django.db.models.BigAutoField'
     name = 'erp'
+    
+    def ready(self):
+        """应用启动时导入信号处理器"""
+        import erp.signals  # noqa

+ 55 - 0
erp/signals.py

@@ -0,0 +1,55 @@
+"""
+ERP 模块信号处理
+在任务创建/更新时清除缓存,实现实时推送
+"""
+from django.db.models.signals import post_save, post_delete
+from django.dispatch import receiver
+from django.core.cache import cache
+from .models import InboundBill, OutboundBill
+import logging
+
+logger = logging.getLogger(__name__)
+
+# 缓存键名(与 sse_views.py 中的保持一致)
+CACHE_KEY_TASK_COUNTS = 'erp_task_counts'
+
+
+def invalidate_task_counts_cache():
+    """清除任务数缓存"""
+    cache.delete(CACHE_KEY_TASK_COUNTS)
+    logger.debug("已清除 ERP 任务数缓存")
+
+
+@receiver(post_save, sender=InboundBill)
+def inbound_bill_saved(sender, instance, **kwargs):
+    """入库单保存时清除缓存"""
+    # 只要 bound_status 或 is_delete 字段变化,都可能影响任务数,清除缓存
+    # 任务数统计条件:bound_status=0 且 is_delete=False
+    # 所以任何保存操作都可能影响计数,直接清除缓存
+    invalidate_task_counts_cache()
+    logger.debug(f"入库单 {instance.number} 保存(bound_status={instance.bound_status}, is_delete={instance.is_delete}),已清除任务数缓存")
+
+
+@receiver(post_save, sender=OutboundBill)
+def outbound_bill_saved(sender, instance, **kwargs):
+    """出库单保存时清除缓存"""
+    # 只要 bound_status 或 is_delete 字段变化,都可能影响任务数,清除缓存
+    # 任务数统计条件:bound_status=0 且 is_delete=False
+    # 所以任何保存操作都可能影响计数,直接清除缓存
+    invalidate_task_counts_cache()
+    logger.debug(f"出库单 {instance.number} 保存(bound_status={instance.bound_status}, is_delete={instance.is_delete}),已清除任务数缓存")
+
+
+@receiver(post_delete, sender=InboundBill)
+def inbound_bill_deleted(sender, instance, **kwargs):
+    """入库单删除时清除缓存"""
+    invalidate_task_counts_cache()
+    logger.debug(f"入库单 {instance.number} 删除,已清除任务数缓存")
+
+
+@receiver(post_delete, sender=OutboundBill)
+def outbound_bill_deleted(sender, instance, **kwargs):
+    """出库单删除时清除缓存"""
+    invalidate_task_counts_cache()
+    logger.debug(f"出库单 {instance.number} 删除,已清除任务数缓存")
+

+ 217 - 0
erp/sse_views.py

@@ -0,0 +1,217 @@
+"""
+Server-Sent Events (SSE) 视图
+用于实时推送 ERP 任务数变化
+"""
+from django.http import StreamingHttpResponse
+from django.views.decorators.http import require_http_methods
+from django.views.decorators.csrf import csrf_exempt
+from django.utils.decorators import method_decorator
+from django.core.cache import cache
+from .models import InboundBill, OutboundBill
+import json
+import time
+import threading
+from django.db.models import Q
+import logging
+
+logger = logging.getLogger(__name__)
+
+# 全局连接管理器
+connections = {}
+connection_lock = threading.Lock()
+
+# 最大连接数限制(防止资源耗尽)
+MAX_CONNECTIONS = 100
+
+# 连接超时时间(秒)- 超过此时间未活跃的连接将被清理
+CONNECTION_TIMEOUT = 300  # 5分钟
+
+# 缓存键名
+CACHE_KEY_TASK_COUNTS = 'erp_task_counts'
+CACHE_TIMEOUT = 300  # 缓存5分钟,由信号处理自动清除
+
+
+def cleanup_stale_connections():
+    """
+    清理超时的连接(定期调用)
+    可以放在后台任务中定期执行
+    """
+    current_time = time.time()
+    cleaned = []
+    
+    with connection_lock:
+        to_remove = []
+        for client_id, info in connections.items():
+            if current_time - info['last_active'] > CONNECTION_TIMEOUT:
+                to_remove.append(client_id)
+                cleaned.append(client_id)
+        
+        for client_id in to_remove:
+            connections.pop(client_id, None)
+    
+    if cleaned:
+        logger.info(f"清理了 {len(cleaned)} 个超时连接")
+    
+    return len(cleaned)
+
+
+def get_erp_task_counts():
+    """
+    获取 ERP 任务数(带缓存优化)
+    使用缓存避免每个连接都查询数据库
+    """
+    # 尝试从缓存获取
+    cached_data = cache.get(CACHE_KEY_TASK_COUNTS)
+    if cached_data is not None:
+        return cached_data
+    
+    # 缓存未命中,查询数据库
+    inbound_count = InboundBill.objects.filter(
+        bound_status=0,
+        is_delete=False
+    ).count()
+    
+    outbound_count = OutboundBill.objects.filter(
+        bound_status=0,
+        is_delete=False
+    ).count()
+    
+    result = {
+        'inbound_tasks': inbound_count,
+        'outbound_tasks': outbound_count
+    }
+    
+    # 存入缓存(所有连接共享)
+    cache.set(CACHE_KEY_TASK_COUNTS, result, CACHE_TIMEOUT)
+    
+    return result
+
+
+@csrf_exempt
+@require_http_methods(["GET"])
+def erp_task_stream(request):
+    """
+    SSE 流式响应,推送 ERP 任务数变化
+    客户端通过 EventSource 连接此端点
+    
+    注意:EventSource 会自动发送 cookies,所以认证应该可以正常工作
+    重要:在生成器外部获取用户信息,避免在流处理期间访问会话
+    """
+    # 在生成器外部获取用户信息,避免在流处理期间访问会话导致会话解码错误
+    try:
+        if hasattr(request, 'user') and request.user.is_authenticated:
+            user_id = getattr(request.user, 'id', None)
+        else:
+            user_id = 'anonymous'
+    except Exception as e:
+        # 如果访问用户信息时出错(可能因为会话损坏),使用匿名用户
+        logger.warning(f"获取用户信息失败,使用匿名用户: {str(e)}")
+        user_id = 'anonymous'
+    
+    client_id = f"{user_id}_{int(time.time())}"
+    logger.info(f"SSE 连接建立: {client_id}")
+    
+    def event_stream():
+        """
+        流式响应生成器
+        注意:在生成器内部不要访问 request.session 或 request.user
+        这些信息已在生成器外部获取,避免会话解码错误
+        """
+        # 注册连接(检查连接数限制)
+        with connection_lock:
+            if len(connections) >= MAX_CONNECTIONS:
+                logger.warning(f"SSE 连接数已达上限 ({MAX_CONNECTIONS}),拒绝新连接: {client_id}")
+                yield f"event: error\ndata: {json.dumps({'error': '连接数已达上限,请稍后重试'})}\n\n"
+                return
+            connections[client_id] = {
+                'created_at': time.time(),
+                'last_active': time.time()
+            }
+        
+        try:
+            # 发送初始数据
+            initial_data = get_erp_task_counts()
+            yield f"data: {json.dumps(initial_data)}\n\n"
+            
+            # 上次的任务数
+            last_inbound_count = initial_data['inbound_tasks']
+            last_outbound_count = initial_data['outbound_tasks']
+            
+            # 检查间隔(秒)- 事件驱动:任务创建时会清除缓存,这里只需定期检查缓存
+            # 由于缓存会被信号自动清除,检查间隔可以更长
+            check_interval = 30
+            
+            # 心跳间隔(秒)
+            heartbeat_interval = 30
+            last_heartbeat = time.time()
+            
+            while True:
+                # 检查连接是否仍然活跃
+                if client_id not in connections:
+                    logger.info(f"SSE 连接已移除: {client_id}")
+                    break
+                
+                current_time = time.time()
+                
+                try:
+                    # 更新连接活跃时间
+                    with connection_lock:
+                        if client_id in connections:
+                            connections[client_id]['last_active'] = current_time
+                    
+                    # 获取当前任务数(使用共享缓存,减少数据库查询)
+                    current_counts = get_erp_task_counts()
+                    current_inbound = current_counts['inbound_tasks']
+                    current_outbound = current_counts['outbound_tasks']
+                    
+                    # 如果任务数发生变化,发送更新
+                    if (current_inbound != last_inbound_count or 
+                        current_outbound != last_outbound_count):
+                        
+                        logger.debug(f"任务数更新: 入库={current_inbound}, 出库={current_outbound}")
+                        yield f"data: {json.dumps(current_counts)}\n\n"
+                        last_inbound_count = current_inbound
+                        last_outbound_count = current_outbound
+                    
+                    # 发送心跳(保持连接活跃)
+                    # 使用事件格式而不是注释,这样前端可以看到心跳
+                    if current_time - last_heartbeat >= heartbeat_interval:
+                        heartbeat_data = {
+                            'type': 'heartbeat',
+                            'timestamp': int(current_time),
+                            'message': '连接正常'
+                        }
+                        yield f"event: heartbeat\ndata: {json.dumps(heartbeat_data)}\n\n"
+                        logger.debug(f"发送心跳: {client_id}")
+                        last_heartbeat = current_time
+                    
+                    time.sleep(check_interval)
+                    
+                except Exception as e:
+                    logger.error(f"SSE 流处理错误: {str(e)}")
+                    # 发生错误时发送错误信息
+                    yield f"event: error\ndata: {json.dumps({'error': str(e)})}\n\n"
+                    time.sleep(check_interval)
+                
+        except GeneratorExit:
+            # 客户端断开连接
+            logger.info(f"SSE 客户端断开: {client_id}")
+        except Exception as e:
+            logger.error(f"SSE 流异常: {str(e)}")
+        finally:
+            # 清理连接
+            with connection_lock:
+                connections.pop(client_id, None)
+            logger.info(f"SSE 连接清理完成: {client_id}")
+    
+    response = StreamingHttpResponse(
+        event_stream(),
+        content_type='text/event-stream'
+    )
+    response['Cache-Control'] = 'no-cache'
+    response['X-Accel-Buffering'] = 'no'  # 禁用 nginx 缓冲
+    # 注意:不要设置 Connection 头,它是 hop-by-hop 头,由服务器自动处理
+    # HTTP/1.1 默认就是 keep-alive
+    
+    return response
+

+ 3 - 0
erp/urls.py

@@ -1,6 +1,7 @@
 # urls.py
 from django.urls import path,re_path
 from . import views
+from . import sse_views
 
 urlpatterns = [
     path('createInboundApply', views.InboundApplyCreate.as_view()),
@@ -54,5 +55,7 @@ urlpatterns = [
 
     }), name="OutMaterials_1"),
 
+    # SSE 实时推送 ERP 任务数
+    path('tasks/stream/', sse_views.erp_task_stream, name="erp_task_stream"),
 
 ]

文件差異過大導致無法顯示
+ 1 - 1
templates/dist/spa/index.html


文件差異過大導致無法顯示
+ 0 - 1
templates/dist/spa/js/36.0ef8719a.js


二進制
templates/dist/spa/js/36.0ef8719a.js.gz


文件差異過大導致無法顯示
+ 1 - 0
templates/dist/spa/js/36.e608b5a5.js


二進制
templates/dist/spa/js/36.e608b5a5.js.gz


二進制
templates/dist/spa/js/70.b5a143df.js.gz


文件差異過大導致無法顯示
+ 1 - 1
templates/dist/spa/js/70.b5a143df.js


二進制
templates/dist/spa/js/70.f5f7974b.js.gz


文件差異過大導致無法顯示
+ 1 - 1
templates/dist/spa/js/93.d9b9c124.js


文件差異過大導致無法顯示
+ 1 - 0
templates/dist/spa/js/94.5869752f.js


文件差異過大導致無法顯示
+ 0 - 1
templates/dist/spa/js/94.adaf0972.js


二進制
templates/dist/spa/js/app.86d9ea81.js.gz


文件差異過大導致無法顯示
+ 1 - 1
templates/dist/spa/js/app.86d9ea81.js


二進制
templates/dist/spa/js/app.fd71b802.js.gz


+ 106 - 4
templates/src/layouts/MainLayout.vue

@@ -732,6 +732,7 @@ export default {
       pendingTasks: 0,
       pollInterval: null,
       timer: null,
+      eventSource: null, // SSE 连接对象
       selectedRole: '',
       permissions: [] // 存储权限数据
     }
@@ -833,6 +834,8 @@ export default {
                 _this.loadRolePermissions()
                 localStorage.removeItem('menulink')
                 _this.link = ''
+                // 登录成功后建立 SSE 连接
+                _this.initEventSource()
                 _this.$router.push({ name: 'web_index' })
                 window.setTimeout(() => {
                   location.reload()
@@ -857,6 +860,8 @@ export default {
     },
     Logout () {
       var _this = this
+      // 登出时关闭 SSE 连接
+      _this.closeEventSource()
       _this.authin = '0'
       _this.login_name = ''
       LocalStorage.remove('auth')
@@ -1006,12 +1011,104 @@ export default {
       }
     },
     handleTimer () {
+      // 保留此方法作为备用,但不再使用轮询
       getauth('/wms/inboundBills/?bound_status=0').then((res) => {
         this.ERPTasks = res.count
       })
       getauth('/wms/outboundBills/?bound_status=0').then((res) => {
         this.ERPOutTasks = res.count
       })
+    },
+    // 初始化 SSE 连接
+    initEventSource () {
+      var _this = this
+
+      // 如果已经存在连接,先关闭
+      if (_this.eventSource) {
+        _this.eventSource.close()
+      }
+
+      // 只有在已登录的情况下才建立连接
+      if (!LocalStorage.has('auth') || LocalStorage.getItem('auth') !== '1') {
+        return
+      }
+
+      // 构建 SSE URL(确保路径正确)
+      let baseUrl = baseurl
+      // 如果 baseurl 以 /api 结尾,移除它
+      if (baseUrl.endsWith('/api')) {
+        baseUrl = baseUrl.slice(0, -4)
+      } else if (baseUrl.endsWith('/api/')) {
+        baseUrl = baseUrl.slice(0, -5)
+      }
+      // 确保 baseUrl 不以 / 结尾
+      baseUrl = baseUrl.replace(/\/+$/, '')
+      const sseUrl = `${baseUrl}/wms/tasks/stream/`
+
+      // 创建 EventSource 连接
+      try {
+        _this.eventSource = new EventSource(sseUrl)
+
+        // 监听消息
+        _this.eventSource.onmessage = function (event) {
+          try {
+            const data = JSON.parse(event.data)
+            _this.ERPTasks = data.inbound_tasks || 0
+            _this.ERPOutTasks = data.outbound_tasks || 0
+          } catch (e) {
+            console.error('解析 SSE 数据失败:', e)
+          }
+        }
+
+        // 监听心跳事件
+        _this.eventSource.addEventListener('heartbeat', function (event) {
+          try {
+            const data = JSON.parse(event.data)
+            console.log('SSE 心跳:', data.message, new Date(data.timestamp * 1000).toLocaleTimeString())
+          } catch (e) {
+            console.log('SSE 心跳收到')
+          }
+        })
+
+        // 处理错误
+        _this.eventSource.onerror = function (error) {
+          console.error('SSE 连接错误:', error)
+
+          // 连接断开后,延迟重连(避免频繁重连)
+          setTimeout(() => {
+            if (_this.eventSource && _this.eventSource.readyState === EventSource.CLOSED) {
+              _this.initEventSource()
+            }
+          }, 5000)
+        }
+
+        // 连接成功
+        _this.eventSource.onopen = function () {
+          console.log('SSE 连接已建立')
+        }
+      } catch (e) {
+        console.error('创建 SSE 连接失败:', e)
+        // 如果 SSE 不支持,回退到轮询
+        _this.fallbackToPolling()
+      }
+    },
+    // 回退到轮询模式
+    fallbackToPolling () {
+      var _this = this
+      _this.timer = setInterval(() => {
+        _this.handleTimer()
+      }, 30000) // 30秒轮询一次作为备用
+    },
+    // 关闭 SSE 连接
+    closeEventSource () {
+      if (this.eventSource) {
+        this.eventSource.close()
+        this.eventSource = null
+      }
+      if (this.timer) {
+        clearInterval(this.timer)
+        this.timer = null
+      }
     }
   },
   created () {
@@ -1043,21 +1140,26 @@ export default {
   mounted () {
     var _this = this
     _this.warehouseOptionsGet()
-    _this.handleTimer()
     _this.link = localStorage.getItem('menulink')
     Bus.$on('needLogin', (val) => {
       _this.isLoggedIn()
     })
     _this.loadRolePermissions()
 
-    _this.timer = setInterval(() => {
-      _this.handleTimer()
-    }, 1000000)
+    // 初始化时先获取一次数据
+    _this.handleTimer()
+
+    // 使用 SSE 替代轮询
+    if (_this.authin === '1') {
+      _this.initEventSource()
+    }
   },
   // 修改时间 :10000
   updated () {},
   beforeDestroy () {
     Bus.$off('needLogin')
+    // 组件销毁时关闭 SSE 连接
+    this.closeEventSource()
   },
   destroyed () {},
   watch: {