sunshibo 2 місяців тому
батько
коміт
da40569dac
3 змінених файлів з 157 додано та 37 видалено
  1. 8 14
      api/dining.js
  2. 16 23
      pages/orderFood/index.vue
  3. 133 0
      utils/sse.js

+ 8 - 14
api/dining.js

@@ -27,20 +27,14 @@ export const PostOrderCartUpdate = (params) =>
   api.put({ url: '/store/order/cart/update', params, formUrlEncoded: true });
 
 /**
- * 创建订单 SSE 接(GET /store/order/sse/{tableId})
- * 返回 uni.request 的 requestTask,需在页面 onUnload 时 abort()
+ * 订单 SSE 接口配置(GET /store/order/sse/{tableId})
+ * 仅提供 URL 与 header,实际连接请使用 utils/sse.js 的 createSSEConnection 封装
  */
-export function createOrderSseConnection(tableId, options = {}) {
+export function getOrderSseConfig(tableId) {
   const userStore = useUserStore();
-  const url = `${BASE_API_URL}/store/order/sse/${encodeURIComponent(tableId)}`;
-  return uni.request({
-    url,
-    method: 'GET',
-    header: {
-      Authorization: userStore.getToken || ''
-    },
-    enableChunked: true,
-    timeout: 0,
-    ...options
-  });
+  return {
+    url: `${BASE_API_URL}/store/order/sse/${encodeURIComponent(tableId)}`,
+    header: { Authorization: userStore.getToken || '' },
+    timeout: 0
+  };
 }

+ 16 - 23
pages/orderFood/index.vue

@@ -51,11 +51,12 @@ import CouponModal from "./components/CouponModal.vue";
 import CartModal from "./components/CartModal.vue";
 import SelectCouponModal from "./components/SelectCouponModal.vue";
 import { go } from "@/utils/utils.js";
-import { DiningOrderFood, GetStoreCategories, GetStoreCuisines, createOrderSseConnection, GetOrderCart, PostOrderCartAdd, PostOrderCartUpdate } from "@/api/dining.js";
+import { DiningOrderFood, GetStoreCategories, GetStoreCuisines, getOrderSseConfig, GetOrderCart, PostOrderCartAdd, PostOrderCartUpdate } from "@/api/dining.js";
+import { createSSEConnection } from "@/utils/sse.js";
 
 const tableId = ref(''); // 桌号,来自上一页 url 参数 tableid
 const currentDiners = ref(uni.getStorageSync('currentDiners') || '');
-let sseRequestTask = null; // 订单 SSE 连接,页面卸载时需 abort
+let sseRequestTask = null; // 订单 SSE 连接(封装后兼容小程序),页面卸载时需 abort()
 const currentCategoryIndex = ref(0);
 const couponModalOpen = ref(false);
 const cartModalOpen = ref(false);
@@ -490,28 +491,20 @@ onLoad(async (options) => {
     await fetchAndMergeCart(tableid).catch((err) => console.error('获取购物车失败:', err));
   }
 
-  // 页面加载时创建订单 SSE 连接(GET /store/order/sse/{tableId}
+  // 页面加载时创建订单 SSE 连接(仅 SSE 使用 utils/sse 封装,兼容微信小程序
   if (tableid) {
     try {
-      sseRequestTask = createOrderSseConnection(tableid);
-      if (sseRequestTask && typeof sseRequestTask.onChunkReceived === 'function') {
-        sseRequestTask.onChunkReceived((res) => {
-          try {
-            const uint8 = new Uint8Array(res.data);
-            const text = String.fromCharCode.apply(null, uint8);
-            console.log('SSE 收到:', text);
-            // 可在此解析 SSE 事件并更新页面
-          } catch (e) {
-            console.error('SSE 数据解析:', e);
-          }
-        });
-      }
-      if (sseRequestTask && typeof sseRequestTask.onHeadersReceived === 'function') {
-        sseRequestTask.onHeadersReceived(async () => {
-          console.log('SSE 连接已建立');
-          await fetchAndMergeCart(tableid).catch(() => {});
-        });
-      }
+      const sseConfig = getOrderSseConfig(tableid);
+      sseRequestTask = createSSEConnection(sseConfig.url, sseConfig);
+      sseRequestTask.onMessage((msg) => {
+        console.log('SSE 收到:', msg);
+        // 可在此根据 msg.data / msg.event 更新页面
+      });
+      sseRequestTask.onOpen(async () => {
+        console.log('SSE 连接已建立');
+        await fetchAndMergeCart(tableid).catch(() => {});
+      });
+      sseRequestTask.onError((err) => console.error('SSE 错误:', err));
     } catch (e) {
       console.error('SSE 连接失败:', e);
     }
@@ -563,8 +556,8 @@ onLoad(async (options) => {
 onUnload(() => {
   if (sseRequestTask && typeof sseRequestTask.abort === 'function') {
     sseRequestTask.abort();
-    sseRequestTask = null;
   }
+  sseRequestTask = null;
 });
 </script>
 

+ 133 - 0
utils/sse.js

@@ -0,0 +1,133 @@
+/**
+ * SSE 封装,使微信小程序等环境可通过 enableChunked + 分块解析使用 SSE
+ * - 小程序:使用 uni.request enableChunked,按块接收后解析 SSE 格式并回调 onMessage
+ * - H5:可优先使用原生 EventSource,不支持时回退到 chunked 方式
+ */
+
+// 将 UTF-8 字节数组解码为字符串(兼容小程序无 TextDecoder、避免中文乱码)
+function utf8Decode(bytes) {
+  if (!bytes || bytes.length === 0) return '';
+  if (typeof TextDecoder !== 'undefined') {
+    return new TextDecoder('utf-8').decode(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes));
+  }
+  const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
+  let s = '';
+  let i = 0;
+  const len = arr.length;
+  while (i < len) {
+    const b = arr[i++];
+    if (b < 0x80) {
+      s += String.fromCharCode(b);
+    } else if (b < 0xe0 && i < len) {
+      s += String.fromCharCode(((b & 0x1f) << 6) | (arr[i++] & 0x3f));
+    } else if (b < 0xf0 && i + 1 < len) {
+      s += String.fromCharCode(((b & 0x0f) << 12) | ((arr[i++] & 0x3f) << 6) | (arr[i++] & 0x3f));
+    } else if (b < 0xf8 && i + 2 < len) {
+      let c = ((b & 0x07) << 18) | ((arr[i++] & 0x3f) << 12) | ((arr[i++] & 0x3f) << 6) | (arr[i++] & 0x3f);
+      c -= 0x10000;
+      s += String.fromCharCode(0xd800 + (c >> 10), 0xdc00 + (c & 0x3ff));
+    } else {
+      s += String.fromCharCode(b);
+    }
+  }
+  return s;
+}
+
+// 将分块数据转为字符串(UTF-8 解码,避免中文乱码)
+function chunkToText(data) {
+  if (data == null) return '';
+  if (typeof data === 'string') return data;
+  if (data instanceof ArrayBuffer) data = new Uint8Array(data);
+  if (data instanceof Uint8Array) return utf8Decode(data);
+  return '';
+}
+
+// 解析 SSE 缓冲区:按 \n\n 分割消息,解析 data:/event:/id: 行,返回 { messages, remaining }
+function parseSSEBuffer(buffer) {
+  const messages = [];
+  let rest = buffer;
+  for (;;) {
+    const idx = rest.indexOf('\n\n');
+    if (idx === -1) break;
+    const block = rest.slice(0, idx);
+    rest = rest.slice(idx + 2);
+    let data = '';
+    let event = '';
+    let id = '';
+    block.split('\n').forEach((line) => {
+      const s = line.trim();
+      if (s.startsWith('data:')) data = (data ? data + '\n' : '') + s.slice(5).trim();
+      else if (s.startsWith('event:')) event = s.slice(6).trim();
+      else if (s.startsWith('id:')) id = s.slice(3).trim();
+    });
+    messages.push({ data, event, id });
+  }
+  return { messages, remaining: rest };
+}
+
+/**
+ * 创建 SSE 连接(兼容微信小程序等无原生 EventSource 的环境)
+ * @param {string} url - 完整请求 URL
+ * @param {object} options - { header, timeout, ... } 会透传给 uni.request
+ * @returns {object} - { abort(), onMessage(cb), onOpen(cb), onError(cb) }
+ */
+export function createSSEConnection(url, options = {}) {
+  let buffer = '';
+  const callbacks = { onMessage: null, onOpen: null, onError: null };
+  const { header = {}, timeout = 0, ...rest } = options;
+
+  const requestTask = uni.request({
+    url,
+    method: 'GET',
+    header: { ...header },
+    enableChunked: true,
+    timeout,
+    ...rest,
+    fail(err) {
+      if (callbacks.onError) callbacks.onError(err);
+      if (options.fail) options.fail(err);
+    }
+  });
+
+  if (requestTask && typeof requestTask.onChunkReceived === 'function') {
+    requestTask.onChunkReceived((res) => {
+      try {
+        buffer += chunkToText(res.data);
+        const { messages, remaining } = parseSSEBuffer(buffer);
+        buffer = remaining;
+        messages.forEach((msg) => {
+          if (callbacks.onMessage) callbacks.onMessage(msg);
+        });
+      } catch (e) {
+        if (callbacks.onError) callbacks.onError(e);
+      }
+    });
+  }
+
+  if (requestTask && typeof requestTask.onHeadersReceived === 'function') {
+    requestTask.onHeadersReceived(() => {
+      if (callbacks.onOpen) callbacks.onOpen();
+    });
+  }
+
+  return {
+    get requestTask() {
+      return requestTask;
+    },
+    onMessage(cb) {
+      callbacks.onMessage = cb;
+      return this;
+    },
+    onOpen(cb) {
+      callbacks.onOpen = cb;
+      return this;
+    },
+    onError(cb) {
+      callbacks.onError = cb;
+      return this;
+    },
+    abort() {
+      if (requestTask && typeof requestTask.abort === 'function') requestTask.abort();
+    }
+  };
+}