diff --git a/Touchkebao/src/api/request.ts b/Touchkebao/src/api/request.ts index 35e7bbe5..0d601e4b 100644 --- a/Touchkebao/src/api/request.ts +++ b/Touchkebao/src/api/request.ts @@ -17,6 +17,11 @@ const NO_DEBOUNCE_URLS = [ "/wechat/message/list", // 消息列表 ]; +// 接口错误白名单:这些接口失败时不显示错误提示 +const ERROR_SILENT_URLS = [ + "/v1/kefu/wechatFriend/list", // 微信好友列表 +]; + const instance: AxiosInstance = axios.create({ baseURL: (import.meta as any).env?.VITE_API_BASE_URL || "/api", timeout: 20000, @@ -49,7 +54,13 @@ instance.interceptors.response.use( return payload.data ?? payload; } - Toast.show({ content: msg || "接口错误", position: "top" }); + // 检查是否在错误白名单中 + const url = res.config?.url || ""; + const isInErrorSilentList = ERROR_SILENT_URLS.some(pattern => + url.includes(pattern), + ); + + // 401 错误始终需要处理 if (code === 401) { localStorage.removeItem("token"); const currentPath = window.location.pathname + window.location.search; @@ -58,11 +69,38 @@ instance.interceptors.response.use( } else { window.location.href = `/login?redirect=${encodeURIComponent(currentPath)}`; } + return Promise.reject(msg || "接口错误"); + } + + // 如果不在白名单中,显示错误提示 + if (!isInErrorSilentList) { + Toast.show({ content: msg || "接口错误", position: "top" }); } return Promise.reject(msg || "接口错误"); }, err => { - Toast.show({ content: err.message || "网络异常", position: "top" }); + // 检查是否在错误白名单中 + const url = err.config?.url || ""; + const isInErrorSilentList = ERROR_SILENT_URLS.some(pattern => + url.includes(pattern), + ); + + // 401 错误始终需要处理 + if (err.response && err.response.status === 401) { + localStorage.removeItem("token"); + const currentPath = window.location.pathname + window.location.search; + if (currentPath === "/login") { + window.location.href = "/login"; + } else { + window.location.href = `/login?redirect=${encodeURIComponent(currentPath)}`; + } + return Promise.reject(err); + } + + // 如果不在白名单中,显示错误提示 + if (!isInErrorSilentList) { + Toast.show({ content: err.message || "网络异常", position: "top" }); + } return Promise.reject(err); }, ); diff --git a/Touchkebao/src/api/request2.ts b/Touchkebao/src/api/request2.ts index 13732be0..6c80edf6 100644 --- a/Touchkebao/src/api/request2.ts +++ b/Touchkebao/src/api/request2.ts @@ -16,6 +16,11 @@ const NO_DEBOUNCE_URLS = [ "/wechat/message/list", ]; +// 接口错误白名单:这些接口失败时不显示错误提示 +const ERROR_SILENT_URLS = [ + "/v1/kefu/wechatFriend/list", // 微信好友列表 +]; + interface RequestConfig extends AxiosRequestConfig { headers?: { Client?: string; @@ -49,6 +54,12 @@ instance.interceptors.response.use( return res.data; }, err => { + // 检查是否在错误白名单中 + const url = err.config?.url || ""; + const isInErrorSilentList = ERROR_SILENT_URLS.some(pattern => + url.includes(pattern), + ); + // 处理401错误,跳转到登录页面 if (err.response && err.response.status === 401) { Toast.show({ content: "登录已过期,请重新登录", position: "top" }); @@ -58,7 +69,10 @@ instance.interceptors.response.use( return Promise.reject(err); } - Toast.show({ content: err.message || "网络异常", position: "top" }); + // 如果不在白名单中,显示错误提示 + if (!isInErrorSilentList) { + Toast.show({ content: err.message || "网络异常", position: "top" }); + } return Promise.reject(err); }, ); diff --git a/Touchkebao/src/components/PopuLayout/header.tsx b/Touchkebao/src/components/PopuLayout/header.tsx index 51cad1b2..160bf43d 100644 --- a/Touchkebao/src/components/PopuLayout/header.tsx +++ b/Touchkebao/src/components/PopuLayout/header.tsx @@ -1,6 +1,6 @@ import React from "react"; import { SearchOutlined, ReloadOutlined } from "@ant-design/icons"; -import { Input, Button } from "antd"; +import { Input, Button, Space } from "antd"; import { Tabs } from "antd-mobile"; import style from "./header.module.scss"; @@ -44,14 +44,22 @@ const PopupHeader: React.FC = ({ {showSearch && (
- setSearchQuery(e.target.value)} - onSearch={() => onSearch && onSearch(searchQuery)} - prefix={} - size="large" - /> + + setSearchQuery(e.target.value)} + onPressEnter={() => onSearch && onSearch(searchQuery)} + prefix={} + size="large" + /> +
{showRefresh && onRefresh && ( diff --git a/Touchkebao/src/components/VirtualMessageList/VirtualMessageList.tsx b/Touchkebao/src/components/VirtualMessageList/VirtualMessageList.tsx index e9fd2c18..e8ef8c0a 100644 --- a/Touchkebao/src/components/VirtualMessageList/VirtualMessageList.tsx +++ b/Touchkebao/src/components/VirtualMessageList/VirtualMessageList.tsx @@ -219,4 +219,3 @@ export const VirtualMessageList: React.FC = ({ }; export default VirtualMessageList; - diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageEnter/components/selectMap.tsx b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageEnter/components/selectMap.tsx index 093b3067..4f29c559 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageEnter/components/selectMap.tsx +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageEnter/components/selectMap.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef } from "react"; -import { Modal, Input, Button, List, message, Spin } from "antd"; +import { Modal, Input, Button, List, message, Spin, Space } from "antd"; import { SearchOutlined, EnvironmentOutlined } from "@ant-design/icons"; import { useWebSocketStore } from "@/store/module/websocket/websocket"; import styles from "./selectMap.module.scss"; @@ -943,24 +943,24 @@ const SelectMap: React.FC = ({
{/* 搜索区域 */}
- setSearchValue(e.target.value)} - onPressEnter={handleSearch} - prefix={} - suffix={ - - } - className={styles.searchInput} - /> + {/* ✅ 使用 Space.Compact 替代 Input 的 suffix(addonAfter 已废弃) */} + + setSearchValue(e.target.value)} + onPressEnter={handleSearch} + prefix={} + className={styles.searchInput} + /> + + {/* 搜索结果列表 */} {searchResults.length > 0 && ( diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageEnter/index.tsx b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageEnter/index.tsx index f323cd72..457811e0 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageEnter/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageEnter/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback } from "react"; +import React, { useEffect, useState, useCallback, useRef } from "react"; import { Layout, Button, message, Tooltip } from "antd"; import { SendOutlined, @@ -194,8 +194,15 @@ const MemoSelectMap: React.FC> = MemoSelectMap.displayName = "MemoSelectMap"; -const MessageEnter: React.FC = ({ contract }) => { +const MessageEnterComponent: React.FC = ({ contract }) => { const [inputValue, setInputValue] = useState(""); + // ✅ 使用 useRef 存储 inputValue,避免 handleSend 依赖变化 + const inputValueRef = useRef(inputValue); + + // 同步 inputValue 到 ref + useEffect(() => { + inputValueRef.current = inputValue; + }, [inputValue]); // ✅ 使用优化的 selector(合并多个 selector,减少重渲染) const { EnterModule, showChatRecordModel } = useUIStateSelectors(); @@ -257,10 +264,10 @@ const MessageEnter: React.FC = ({ contract }) => { } }, []); - // 发送消息(支持传入内容参数,避免闭包问题) + // ✅ 发送消息(使用 useRef 避免依赖 inputValue,减少函数重新创建) const handleSend = useCallback( async (content?: string) => { - const messageContent = content || inputValue; // 优先使用传入的内容 + const messageContent = content || inputValueRef.current; // 优先使用传入的内容,否则使用 ref if (!messageContent || !messageContent.trim()) { console.warn("消息内容为空,取消发送"); @@ -324,7 +331,7 @@ const MessageEnter: React.FC = ({ contract }) => { contract.id, contract.wechatAccountId, contract?.chatroomId, - inputValue, + // ✅ 移除 inputValue 依赖,使用 ref 代替 isLoadingAiChat, updateIsLoadingAiChat, updateQuoteMessageContent, @@ -602,4 +609,15 @@ const MessageEnter: React.FC = ({ contract }) => { ); }; +// ✅ 使用 React.memo 优化 MessageEnter 组件,避免不必要的重渲染 +const MessageEnter = React.memo( + MessageEnterComponent, + (prev, next) => { + // 只有当联系人 ID 变化时才重新渲染 + return prev.contract.id === next.contract.id; + }, +); + +MessageEnter.displayName = "MessageEnter"; + export default MessageEnter; diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/VirtualizedMessageList.tsx b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/VirtualizedMessageList.tsx new file mode 100644 index 00000000..11807c83 --- /dev/null +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/VirtualizedMessageList.tsx @@ -0,0 +1,268 @@ +import React, { useRef, useEffect, useCallback, useMemo } from "react"; +import { VariableSizeList, ListChildComponentProps } from "react-window"; +import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data"; +import { MessageGroup } from "@/hooks/weChat/useMessageGrouping"; +import { MessageItem } from "../index"; +import { parseSystemMessage } from "@/utils/filter"; +import styles from "../com.module.scss"; +import { addPerformanceBreadcrumb } from "@/utils/sentry"; + +interface VirtualizedMessageListProps { + groupedMessages: MessageGroup[]; + contract: ContractData | weChatGroup; + isGroupChat: boolean; + showCheckbox: boolean; + currentCustomerAvatar?: string; + renderGroupUser: (msg: ChatRecord) => { avatar: string; nickname: string }; + clearWechatidInContent: (sender: any, content: string) => string; + parseMessageContent: ( + content: string | null | undefined, + msg: ChatRecord, + msgType?: number, + ) => React.ReactNode; + isMessageSelected: (msg: ChatRecord) => boolean; + onCheckboxChange: (checked: boolean, msg: ChatRecord) => void; + onContextMenu: (e: React.MouseEvent, msg: ChatRecord, isOwn: boolean) => void; + containerRef: React.RefObject; + messagesEndRef: React.RefObject; + onScroll?: (scrollTop: number) => void; +} + +interface ItemData { + groups: MessageGroup[]; + props: Omit< + VirtualizedMessageListProps, + "groupedMessages" | "containerRef" | "messagesEndRef" | "onScroll" + >; +} + +/** + * 估算每个消息组的高度(像素) + * 根据消息数量和类型估算 + */ +const estimateGroupHeight = (group: MessageGroup): number => { + let height = 40; // 时间分隔符高度 + const messageCount = group.messages.filter( + v => ![10000, 570425393, 90000, -10001].includes(v.msgType), + ).length; + + // 基础消息项高度(包含间距) + const baseMessageHeight = 80; + // 系统消息高度 + const systemMessageHeight = 30; + + // 计算系统消息数量 + const systemMessageCount = group.messages.filter(v => + [10000, 570425393, 90000, -10001].includes(v.msgType), + ).length; + + height += systemMessageCount * systemMessageHeight; + height += messageCount * baseMessageHeight; + + return height; +}; + +/** + * 虚拟滚动消息列表项 + */ +const VirtualizedMessageItem: React.FC> = ({ + index, + style, + data, +}) => { + const { groups, props } = data; + const group = groups[index]; + + return ( +
+ {/* 时间分隔符 */} + {group.messages + .filter(v => [10000, -10001].includes(v.msgType)) + .map(msg => { + const parsedText = parseSystemMessage(msg.content); + return ( +
+ {parsedText} +
+ ); + })} + + {/* 其他系统消息 */} + {group.messages + .filter(v => [570425393, 90000].includes(v.msgType)) + .map(msg => { + let displayContent = msg.content; + try { + const parsedContent = JSON.parse(msg.content); + if ( + parsedContent && + typeof parsedContent === "object" && + parsedContent.content + ) { + displayContent = parsedContent.content; + } + } catch (error) { + displayContent = msg.content; + } + return ( +
+ {displayContent} +
+ ); + })} + + {/* 时间标签 */} +
{group.time}
+ + {/* 消息项 */} + {group.messages + .filter(v => ![10000, 570425393, 90000, -10001].includes(v.msgType)) + .map(msg => { + if (!msg) return null; + const isOwn = !!msg.isSend; + return ( + props.onContextMenu(e, msg, isOwn)} + /> + ); + })} +
+ ); +}; + +/** + * 虚拟滚动消息列表 + * 使用 react-window 实现虚拟滚动,提升长列表性能 + */ +export const VirtualizedMessageList: React.FC = ({ + groupedMessages, + containerRef, + messagesEndRef, + onScroll, + ...props +}) => { + const listRef = useRef(null); + const [listHeight, setListHeight] = React.useState(600); + const heightCacheRef = useRef>(new Map()); + + // 监听容器高度变化 + useEffect(() => { + const updateHeight = () => { + if (containerRef.current) { + const height = containerRef.current.clientHeight; + setListHeight(height); + } + }; + + updateHeight(); + const resizeObserver = new ResizeObserver(updateHeight); + if (containerRef.current) { + resizeObserver.observe(containerRef.current); + } + + return () => { + resizeObserver.disconnect(); + }; + }, [containerRef]); + + // 获取每个项目的高度 + const getItemSize = useCallback( + (index: number) => { + // 如果缓存中有,直接返回 + if (heightCacheRef.current.has(index)) { + return heightCacheRef.current.get(index)!; + } + + // 估算高度 + const group = groupedMessages[index]; + const estimatedHeight = estimateGroupHeight(group); + heightCacheRef.current.set(index, estimatedHeight); + return estimatedHeight; + }, + [groupedMessages], + ); + + // 当消息列表变化时,清除高度缓存 + useEffect(() => { + heightCacheRef.current.clear(); + if (listRef.current) { + listRef.current.resetAfterIndex(0); + } + }, [groupedMessages.length]); + + // 当新消息到达时,滚动到底部 + useEffect(() => { + if (listRef.current && groupedMessages.length > 0) { + // 延迟滚动,确保 DOM 已更新 + requestAnimationFrame(() => { + if (listRef.current) { + // 滚动到最后一个项目 + listRef.current.scrollToItem(groupedMessages.length - 1, "end"); + } + }); + } + }, [groupedMessages.length]); + + // 处理滚动事件 + const handleScroll = useCallback( + (event: { scrollOffset: number }) => { + if (onScroll) { + onScroll(event.scrollOffset); + } + }, + [onScroll], + ); + + // 性能监控 + useEffect(() => { + if (groupedMessages.length > 100) { + addPerformanceBreadcrumb("虚拟滚动启用", { + messageCount: groupedMessages.length, + groupCount: groupedMessages.length, + }); + } + }, [groupedMessages.length]); + + // 准备传递给列表项的数据 + const itemData = useMemo( + () => ({ + groups: groupedMessages, + props, + }), + [groupedMessages, props], + ); + + if (groupedMessages.length === 0) { + return null; + } + + return ( + <> + + {VirtualizedMessageItem} + + {/* 用于滚动到底部的锚点 */} +
+ + ); +}; diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/index.tsx b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/index.tsx index dce9dc07..5a96f3fa 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/index.tsx @@ -30,6 +30,7 @@ import { useMessageParser } from "@/hooks/weChat/useMessageParser"; import { useMessageGrouping } from "@/hooks/weChat/useMessageGrouping"; import { Profiler } from "@sentry/react"; import { addPerformanceBreadcrumb } from "@/utils/sentry"; +import { VirtualizedMessageList } from "./components/VirtualizedMessageList"; import { useWeChatStore } from "@/store/module/weChat/weChat"; import { useContactStore } from "@/store/module/weChat/contacts"; import { useCustomerStore } from "@weChatStore/customer"; @@ -319,7 +320,10 @@ const MessageItem: React.FC = React.memo( MessageItem.displayName = "MessageItem"; -const MessageRecord: React.FC = ({ contract }) => { +// 导出 MessageItem 供虚拟滚动组件使用 +export { MessageItem }; + +const MessageRecordComponent: React.FC = ({ contract }) => { const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); // 右键菜单状态 @@ -370,6 +374,9 @@ const MessageRecord: React.FC = ({ contract }) => { // ✅ 使用 useMessageGrouping Hook(消息分组,使用 useMemo 缓存) const groupedMessages = useMessageGrouping(currentMessages); + // ✅ 判断是否使用虚拟滚动(消息数量 > 50 时启用) + const shouldUseVirtualization = groupedMessages.length > 50; + useEffect(() => { const fetchGroupMembers = async () => { if (!contract.chatroomId) { @@ -676,71 +683,97 @@ const MessageRecord: React.FC = ({ contract }) => { : "已经没有更早的消息了"} {messagesLoading ? : ""}
- {groupedMessages.map((group, groupIndex) => ( - - {group.messages - .filter(v => [10000, -10001].includes(v.msgType)) - .map(msg => { - // 解析系统消息,提取纯文本(移除img标签和_wc_custom_link_标签) - const parsedText = parseSystemMessage(msg.content); - return ( -
- {parsedText} -
- ); - })} + {shouldUseVirtualization ? ( + // ✅ 使用虚拟滚动(消息数量 > 50) + { + // 处理滚动位置,用于加载更多时保持位置 + scrollPositionRef.current = scrollTop; + }} + /> + ) : ( + // 原有的渲染逻辑(小列表不使用虚拟滚动) + <> + {groupedMessages.map((group, groupIndex) => ( + + {group.messages + .filter(v => [10000, -10001].includes(v.msgType)) + .map(msg => { + // 解析系统消息,提取纯文本(移除img标签和_wc_custom_link_标签) + const parsedText = parseSystemMessage(msg.content); + return ( +
+ {parsedText} +
+ ); + })} - {group.messages - .filter(v => [570425393, 90000].includes(v.msgType)) - .map(msg => { - // 解析JSON字符串 - let displayContent = msg.content; - try { - const parsedContent = JSON.parse(msg.content); - if ( - parsedContent && - typeof parsedContent === "object" && - parsedContent.content - ) { - displayContent = parsedContent.content; - } - } catch (error) { - // 如果解析失败,使用原始内容 - displayContent = msg.content; - } - return ( -
- {displayContent} -
- ); - })} -
{group.time}
- {group.messages - .filter( - v => ![10000, 570425393, 90000, -10001].includes(v.msgType), - ) - .map(msg => { - if (!msg) return null; - return ( - - ); - })} -
- ))} -
+ {group.messages + .filter(v => [570425393, 90000].includes(v.msgType)) + .map(msg => { + // 解析JSON字符串 + let displayContent = msg.content; + try { + const parsedContent = JSON.parse(msg.content); + if ( + parsedContent && + typeof parsedContent === "object" && + parsedContent.content + ) { + displayContent = parsedContent.content; + } + } catch (error) { + // 如果解析失败,使用原始内容 + displayContent = msg.content; + } + return ( +
+ {displayContent} +
+ ); + })} +
{group.time}
+ {group.messages + .filter( + v => ![10000, 570425393, 90000, -10001].includes(v.msgType), + ) + .map(msg => { + if (!msg) return null; + return ( + + ); + })} + + ))} +
+ + )} {/* 右键菜单组件 */} = ({ contract }) => { ); }; +// ✅ 使用 React.memo 优化 MessageRecord 组件,避免不必要的重渲染 +const MessageRecord = React.memo( + MessageRecordComponent, + (prev, next) => { + // 只有当联系人 ID 变化时才重新渲染 + return prev.contract.id === next.contract.id; + }, +); + +MessageRecord.displayName = "MessageRecord"; + export default MessageRecord; diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/QuickWords/index.tsx b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/QuickWords/index.tsx index 7c812bb8..eac9b77c 100644 --- a/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/QuickWords/index.tsx +++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/QuickWords/index.tsx @@ -20,6 +20,7 @@ import { FileTextOutlined, PictureOutlined, PlayCircleOutlined, + SearchOutlined, } from "@ant-design/icons"; import { QuickWordsItem, @@ -510,13 +511,16 @@ const QuickWords: React.FC = ({ onInsert }) => { ]} />
- setKeyword(e.target.value)} - style={{ flex: 1 }} - /> + + setKeyword(e.target.value)} + prefix={} + onPressEnter={() => {}} + /> + import("./components/ProfileCard")); +const FollowupReminderModal = lazy(() => import("./components/FollowupReminderModal")); +const TodoListModal = lazy(() => import("./components/TodoListModal")); + +// 加载中的占位组件 +const LoadingFallback = () => ( +
+ +
+); import { setFriendInjectConfig } from "@/pages/pc/ckbox/weChat/api"; import { useAISelectors, useUIStateSelectors } from "@/hooks/weChat/useWeChatSelectors"; import { useWeChatActions } from "@/hooks/weChat/useWeChatSelectors"; @@ -43,7 +54,7 @@ const typeOptions = [ { value: 2, label: "AI接管" }, ]; -const ChatWindow: React.FC = ({ contract }) => { +const ChatWindowComponent: React.FC = ({ contract }) => { // ✅ 使用优化的 selector(合并多个 selector,减少重渲染) const { aiQuoteMessageContent } = useAISelectors(); const { showChatRecordModel } = useUIStateSelectors(); @@ -237,26 +248,49 @@ const ChatWindow: React.FC = ({ contract }) => { - {/* 右侧个人资料卡片 */} - {showProfile && } + {/* 右侧个人资料卡片 - 懒加载 */} + {showProfile && ( + 加载中...
}> + + + )} - {/* 跟进提醒模态框 */} - + {/* 跟进提醒模态框 - 懒加载 */} + {followupModalVisible && ( + + + + )} - {/* 待办事项模态框 */} - + {/* 待办事项模态框 - 懒加载 */} + {todoModalVisible && ( + + + + )} ); }; +// ✅ 使用 React.memo 优化 ChatWindow 组件,避免不必要的重渲染 +const ChatWindow = React.memo( + ChatWindowComponent, + (prev, next) => { + // 只有当联系人 ID 变化时才重新渲染 + return prev.contract.id === next.contract.id; + }, +); + +ChatWindow.displayName = "ChatWindow"; + export default ChatWindow; diff --git a/Touchkebao/src/store/module/weChat/weChat.ts b/Touchkebao/src/store/module/weChat/weChat.ts index bfedd6a1..75c3a844 100644 --- a/Touchkebao/src/store/module/weChat/weChat.ts +++ b/Touchkebao/src/store/module/weChat/weChat.ts @@ -31,6 +31,11 @@ const AI_REQUEST_DELAY = 3000; // 3秒延迟 const FILE_MESSAGE_TYPE = "file"; const DEFAULT_MESSAGE_PAGE_SIZE = 20; +// ✅ 消息批量处理优化 +let messageBatchQueue: ChatRecord[] = []; // 消息批量队列 +let messageBatchTimer: NodeJS.Timeout | null = null; +const MESSAGE_BATCH_DELAY = 16; // 16ms,约一帧的时间,用于批量更新 + type FileMessagePayload = { type?: string; title?: string; @@ -830,7 +835,7 @@ export const useWeChatStore = create()( }, // ==================== 消息接收处理 ==================== - /** 接收新消息处理 */ + /** 接收新消息处理(优化:批量更新减少重渲染) */ receivedMsg: async message => { const currentContract = useWeChatStore.getState().currentContract; // 判断是群聊还是私聊 @@ -839,11 +844,29 @@ export const useWeChatStore = create()( const isWechatGroup = message?.wechatChatroomId; try { - // 如果是当前选中的聊天,直接添加到消息列表 + // 如果是当前选中的聊天,使用批量更新机制 if (currentContract && currentContract.id == getMessageId) { - set(state => ({ - currentMessages: [...state.currentMessages, message], - })); + // ✅ 将消息加入批量队列 + messageBatchQueue.push(message); + + // ✅ 清除之前的定时器 + if (messageBatchTimer) { + clearTimeout(messageBatchTimer); + } + + // ✅ 设置批量更新定时器(16ms,约一帧时间) + messageBatchTimer = setTimeout(() => { + const messagesToAdd = [...messageBatchQueue]; + messageBatchQueue = []; + messageBatchTimer = null; + + // 批量添加到消息列表(减少重渲染次数) + if (messagesToAdd.length > 0) { + set(state => ({ + currentMessages: [...state.currentMessages, ...messagesToAdd], + })); + } + }, MESSAGE_BATCH_DELAY); // 只有文字消息才触发AI(msgType === 1),且必须是对方发送的消息(isSend !== true) if ( diff --git a/Touchkebao/src/utils/sentry/index.ts b/Touchkebao/src/utils/sentry/index.ts index a630125a..5bdd3719 100644 --- a/Touchkebao/src/utils/sentry/index.ts +++ b/Touchkebao/src/utils/sentry/index.ts @@ -1,4 +1,6 @@ import * as Sentry from "@sentry/react"; +import { browserTracingIntegration } from "@sentry/react"; +import { replayIntegration } from "@sentry/react"; /** * 初始化 Sentry @@ -14,13 +16,13 @@ export const initSentry = () => { dsn: import.meta.env.VITE_SENTRY_DSN, environment: import.meta.env.MODE, integrations: [ - new Sentry.BrowserTracing({ + browserTracingIntegration({ tracingOrigins: [ "localhost", import.meta.env.VITE_API_BASE_URL || "/api", ], }), - new Sentry.Replay({ + replayIntegration({ maskAllText: false, blockAllMedia: false, }), diff --git a/Touchkebao/src/utils/updateChecker.ts b/Touchkebao/src/utils/updateChecker.ts index c0022f18..035e8c91 100644 --- a/Touchkebao/src/utils/updateChecker.ts +++ b/Touchkebao/src/utils/updateChecker.ts @@ -13,48 +13,37 @@ class UpdateChecker { private checkInterval: number = 1000 * 60 * 5; // 5分钟检查一次 private intervalId: NodeJS.Timeout | null = null; private updateCallbacks: ((info: UpdateInfo) => void)[] = []; - private currentHashes: string[] = []; + private readonly STORAGE_KEY = "__app_manifest_hash__"; constructor() { // 从package.json获取版本号 this.currentVersion = import.meta.env.VITE_APP_VERSION || "1.0.0"; - // 初始化当前哈希值 - this.initCurrentHashes(); } /** - * 初始化当前哈希值 + * 从 localStorage 获取保存的 manifest 哈希值 */ - private initCurrentHashes() { - // 从当前页面的资源中提取哈希值 - const scripts = document.querySelectorAll("script[src]"); - const links = document.querySelectorAll("link[href]"); + private getStoredHashes(): string[] { + try { + const stored = localStorage.getItem(this.STORAGE_KEY); + if (stored) { + return JSON.parse(stored); + } + } catch (error) { + console.warn("读取保存的哈希值失败:", error); + } + return []; + } - const scriptHashes = Array.from(scripts) - .map(script => script.getAttribute("src")) - .filter( - src => src && (src.includes("assets/") || src.includes("/assets/")), - ) - .map(src => { - // 修改正则表达式,匹配包含字母、数字和下划线的哈希值 - const match = src?.match(/[a-zA-Z0-9_-]{8,}/); - return match ? match[0] : ""; - }) - .filter(hash => hash); - - const linkHashes = Array.from(links) - .map(link => link.getAttribute("href")) - .filter( - href => href && (href.includes("assets/") || href.includes("/assets/")), - ) - .map(href => { - // 修改正则表达式,匹配包含字母、数字和下划线的哈希值 - const match = href?.match(/[a-zA-Z0-9_-]{8,}/); - return match ? match[0] : ""; - }) - .filter(hash => hash); - - this.currentHashes = [...new Set([...scriptHashes, ...linkHashes])]; + /** + * 保存 manifest 哈希值到 localStorage + */ + private saveHashes(hashes: string[]): void { + try { + localStorage.setItem(this.STORAGE_KEY, JSON.stringify(hashes)); + } catch (error) { + console.warn("保存哈希值失败:", error); + } } /** @@ -65,8 +54,11 @@ class UpdateChecker { return; } - // 立即检查一次 - this.checkForUpdate(); + // 延迟首次检查,等待页面资源加载完成 + // 延迟 3 秒后再进行首次检查,避免页面刚加载时误判 + setTimeout(() => { + this.checkForUpdate(); + }, 3000); // 设置定时检查 this.intervalId = setInterval(() => { @@ -152,8 +144,27 @@ class UpdateChecker { // 去重新哈希值数组 const uniqueNewHashes = [...new Set(newHashes)]; + // 从 localStorage 获取保存的哈希值 + const storedHashes = this.getStoredHashes(); + + // 如果 localStorage 中没有保存的哈希值,说明是首次加载或清除了缓存 + // 此时保存当前 manifest 的哈希值,不触发更新提示 + if (storedHashes.length === 0) { + this.saveHashes(uniqueNewHashes); + return { hasUpdate: false }; + } + // 比较哈希值 - const hasUpdate = this.compareHashes(this.currentHashes, uniqueNewHashes); + const hasUpdate = this.compareHashes(storedHashes, uniqueNewHashes); + + // 如果有更新,更新保存的哈希值 + if (hasUpdate) { + // 注意:这里不立即更新,等用户刷新后再更新 + // 这样用户刷新后就不会再提示了 + } else { + // 如果没有更新,确保保存的哈希值是最新的(防止 manifest 格式变化) + this.saveHashes(uniqueNewHashes); + } const updateInfo: UpdateInfo = { hasUpdate, @@ -210,6 +221,8 @@ class UpdateChecker { * 强制刷新页面 */ forceReload() { + // 刷新前清除保存的哈希值,这样刷新后不会立即提示更新 + localStorage.removeItem(this.STORAGE_KEY); window.location.reload(); } }