重构MessageRecord组件以支持虚拟化,从而提高大型消息列表的性能。引入VirtualizedMessageList以实现高效渲染,并使用React.memo优化渲染逻辑以防止不必要的重新渲染。

This commit is contained in:
乘风
2025-12-10 17:09:08 +08:00
parent 7752dd136f
commit c7e934f23d
13 changed files with 638 additions and 173 deletions

View File

@@ -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);
},
);

View File

@@ -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);
},
);

View File

@@ -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<PopupHeaderProps> = ({
{showSearch && (
<div className={style.popupSearchRow}>
<div className={style.popupSearchInputWrap}>
<Input.Search
placeholder={searchPlaceholder}
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onSearch={() => onSearch && onSearch(searchQuery)}
prefix={<SearchOutlined />}
size="large"
/>
<Space.Compact style={{ width: "100%" }}>
<Input
placeholder={searchPlaceholder}
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onPressEnter={() => onSearch && onSearch(searchQuery)}
prefix={<SearchOutlined />}
size="large"
/>
<Button
type="primary"
size="large"
icon={<SearchOutlined />}
onClick={() => onSearch && onSearch(searchQuery)}
/>
</Space.Compact>
</div>
{showRefresh && onRefresh && (

View File

@@ -219,4 +219,3 @@ export const VirtualMessageList: React.FC<VirtualMessageListProps> = ({
};
export default VirtualMessageList;

View File

@@ -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<SelectMapProps> = ({
<div className={styles.selectMapContainer}>
{/* 搜索区域 */}
<div className={styles.searchArea}>
<Input
placeholder="搜索地址"
value={searchValue}
onChange={e => setSearchValue(e.target.value)}
onPressEnter={handleSearch}
prefix={<SearchOutlined />}
suffix={
<Button
type="link"
size="small"
onClick={handleSearch}
loading={isSearching}
>
</Button>
}
className={styles.searchInput}
/>
{/* ✅ 使用 Space.Compact 替代 Input 的 suffixaddonAfter 已废弃) */}
<Space.Compact style={{ width: "100%" }}>
<Input
placeholder="搜索地址"
value={searchValue}
onChange={e => setSearchValue(e.target.value)}
onPressEnter={handleSearch}
prefix={<SearchOutlined />}
className={styles.searchInput}
/>
<Button
type="primary"
onClick={handleSearch}
loading={isSearching}
>
</Button>
</Space.Compact>
{/* 搜索结果列表 */}
{searchResults.length > 0 && (

View File

@@ -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<React.ComponentProps<typeof SelectMap>> =
MemoSelectMap.displayName = "MemoSelectMap";
const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
const MessageEnterComponent: React.FC<MessageEnterProps> = ({ 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<MessageEnterProps> = ({ 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<MessageEnterProps> = ({ contract }) => {
contract.id,
contract.wechatAccountId,
contract?.chatroomId,
inputValue,
// ✅ 移除 inputValue 依赖,使用 ref 代替
isLoadingAiChat,
updateIsLoadingAiChat,
updateQuoteMessageContent,
@@ -602,4 +609,15 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ 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;

View File

@@ -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<HTMLDivElement>;
messagesEndRef: React.RefObject<HTMLDivElement>;
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<ListChildComponentProps<ItemData>> = ({
index,
style,
data,
}) => {
const { groups, props } = data;
const group = groups[index];
return (
<div style={style}>
{/* 时间分隔符 */}
{group.messages
.filter(v => [10000, -10001].includes(v.msgType))
.map(msg => {
const parsedText = parseSystemMessage(msg.content);
return (
<div key={`divider-${msg.id}`} className={styles.messageTime}>
{parsedText}
</div>
);
})}
{/* 其他系统消息 */}
{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 (
<div key={`divider-${msg.id}`} className={styles.messageTime}>
{displayContent}
</div>
);
})}
{/* 时间标签 */}
<div className={styles.messageTime}>{group.time}</div>
{/* 消息项 */}
{group.messages
.filter(v => ![10000, 570425393, 90000, -10001].includes(v.msgType))
.map(msg => {
if (!msg) return null;
const isOwn = !!msg.isSend;
return (
<MessageItem
key={msg.id}
msg={msg}
contract={props.contract}
isGroup={props.isGroupChat}
showCheckbox={props.showCheckbox}
isSelected={props.isMessageSelected(msg)}
currentCustomerAvatar={props.currentCustomerAvatar || ""}
renderGroupUser={props.renderGroupUser}
clearWechatidInContent={props.clearWechatidInContent}
parseMessageContent={props.parseMessageContent}
onCheckboxChange={props.onCheckboxChange}
onContextMenu={e => props.onContextMenu(e, msg, isOwn)}
/>
);
})}
</div>
);
};
/**
* 虚拟滚动消息列表
* 使用 react-window 实现虚拟滚动,提升长列表性能
*/
export const VirtualizedMessageList: React.FC<VirtualizedMessageListProps> = ({
groupedMessages,
containerRef,
messagesEndRef,
onScroll,
...props
}) => {
const listRef = useRef<VariableSizeList>(null);
const [listHeight, setListHeight] = React.useState(600);
const heightCacheRef = useRef<Map<number, number>>(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<ItemData>(
() => ({
groups: groupedMessages,
props,
}),
[groupedMessages, props],
);
if (groupedMessages.length === 0) {
return null;
}
return (
<>
<VariableSizeList
ref={listRef}
height={listHeight}
itemCount={groupedMessages.length}
itemSize={getItemSize}
width="100%"
itemData={itemData}
onScroll={handleScroll}
overscanCount={5} // 预渲染 5 个项目,提升滚动流畅度
>
{VirtualizedMessageItem}
</VariableSizeList>
{/* 用于滚动到底部的锚点 */}
<div ref={messagesEndRef} />
</>
);
};

View File

@@ -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<MessageItemProps> = React.memo(
MessageItem.displayName = "MessageItem";
const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
// 导出 MessageItem 供虚拟滚动组件使用
export { MessageItem };
const MessageRecordComponent: React.FC<MessageRecordProps> = ({ contract }) => {
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
// 右键菜单状态
@@ -370,6 +374,9 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ 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<MessageRecordProps> = ({ contract }) => {
: "已经没有更早的消息了"}
{messagesLoading ? <LoadingOutlined /> : ""}
</div>
{groupedMessages.map((group, groupIndex) => (
<React.Fragment key={`group-${groupIndex}`}>
{group.messages
.filter(v => [10000, -10001].includes(v.msgType))
.map(msg => {
// 解析系统消息提取纯文本移除img标签和_wc_custom_link_标签
const parsedText = parseSystemMessage(msg.content);
return (
<div key={`divider-${msg.id}`} className={styles.messageTime}>
{parsedText}
</div>
);
})}
{shouldUseVirtualization ? (
// ✅ 使用虚拟滚动(消息数量 > 50
<VirtualizedMessageList
groupedMessages={groupedMessages}
contract={contract}
isGroupChat={isGroupChat}
showCheckbox={showCheckbox}
currentCustomerAvatar={currentCustomer?.avatar || ""}
renderGroupUser={renderGroupUser}
clearWechatidInContent={clearWechatidInContent}
parseMessageContent={parseMessageContent}
isMessageSelected={isMessageSelected}
onCheckboxChange={handleCheckboxChange}
onContextMenu={handleContextMenu}
containerRef={messagesContainerRef}
messagesEndRef={messagesEndRef}
onScroll={scrollTop => {
// 处理滚动位置,用于加载更多时保持位置
scrollPositionRef.current = scrollTop;
}}
/>
) : (
// 原有的渲染逻辑(小列表不使用虚拟滚动)
<>
{groupedMessages.map((group, groupIndex) => (
<React.Fragment key={`group-${groupIndex}`}>
{group.messages
.filter(v => [10000, -10001].includes(v.msgType))
.map(msg => {
// 解析系统消息提取纯文本移除img标签和_wc_custom_link_标签
const parsedText = parseSystemMessage(msg.content);
return (
<div key={`divider-${msg.id}`} className={styles.messageTime}>
{parsedText}
</div>
);
})}
{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 (
<div key={`divider-${msg.id}`} className={styles.messageTime}>
{displayContent}
</div>
);
})}
<div className={styles.messageTime}>{group.time}</div>
{group.messages
.filter(
v => ![10000, 570425393, 90000, -10001].includes(v.msgType),
)
.map(msg => {
if (!msg) return null;
return (
<MessageItem
key={msg.id}
msg={msg}
contract={contract}
isGroup={isGroupChat}
showCheckbox={showCheckbox}
isSelected={isMessageSelected(msg)}
currentCustomerAvatar={currentCustomer?.avatar || ""}
renderGroupUser={renderGroupUser}
clearWechatidInContent={clearWechatidInContent}
parseMessageContent={parseMessageContent}
onCheckboxChange={handleCheckboxChange}
onContextMenu={handleContextMenu}
/>
);
})}
</React.Fragment>
))}
<div ref={messagesEndRef} />
{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 (
<div key={`divider-${msg.id}`} className={styles.messageTime}>
{displayContent}
</div>
);
})}
<div className={styles.messageTime}>{group.time}</div>
{group.messages
.filter(
v => ![10000, 570425393, 90000, -10001].includes(v.msgType),
)
.map(msg => {
if (!msg) return null;
return (
<MessageItem
key={msg.id}
msg={msg}
contract={contract}
isGroup={isGroupChat}
showCheckbox={showCheckbox}
isSelected={isMessageSelected(msg)}
currentCustomerAvatar={currentCustomer?.avatar || ""}
renderGroupUser={renderGroupUser}
clearWechatidInContent={clearWechatidInContent}
parseMessageContent={parseMessageContent}
onCheckboxChange={handleCheckboxChange}
onContextMenu={handleContextMenu}
/>
);
})}
</React.Fragment>
))}
<div ref={messagesEndRef} />
</>
)}
{/* 右键菜单组件 */}
<ClickMenu
visible={contextMenu.visible}
@@ -758,4 +791,15 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ 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;

View File

@@ -20,6 +20,7 @@ import {
FileTextOutlined,
PictureOutlined,
PlayCircleOutlined,
SearchOutlined,
} from "@ant-design/icons";
import {
QuickWordsItem,
@@ -510,13 +511,16 @@ const QuickWords: React.FC<QuickWordsProps> = ({ onInsert }) => {
]}
/>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<Input.Search
placeholder="输入关键字过滤"
allowClear
value={keyword}
onChange={e => setKeyword(e.target.value)}
style={{ flex: 1 }}
/>
<Space.Compact style={{ flex: 1 }}>
<Input
placeholder="输入关键字过滤"
allowClear
value={keyword}
onChange={e => setKeyword(e.target.value)}
prefix={<SearchOutlined />}
onPressEnter={() => {}}
/>
</Space.Compact>
<Dropdown
menu={{
items: [

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import React, { useState, useEffect, useCallback, useMemo, lazy, Suspense } from "react";
import {
Layout,
Button,
@@ -7,6 +7,7 @@ import {
Tooltip,
Dropdown,
message,
Spin,
} from "antd";
import {
UserOutlined,
@@ -19,12 +20,22 @@ import {
import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import styles from "./ChatWindow.module.scss";
import ProfileCard from "./components/ProfileCard";
// ✅ 核心组件保持同步加载(需要立即渲染)
import MessageEnter from "./components/MessageEnter";
import MessageRecord from "./components/MessageRecord";
import FollowupReminderModal from "./components/FollowupReminderModal";
import TodoListModal from "./components/TodoListModal";
import ChatRecordSearch from "./components/ChatRecordSearch";
// ✅ 非关键组件使用懒加载(减少初始包大小)
const ProfileCard = lazy(() => import("./components/ProfileCard"));
const FollowupReminderModal = lazy(() => import("./components/FollowupReminderModal"));
const TodoListModal = lazy(() => import("./components/TodoListModal"));
// 加载中的占位组件
const LoadingFallback = () => (
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", padding: "20px" }}>
<Spin size="small" />
</div>
);
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<ChatWindowProps> = ({ contract }) => {
const ChatWindowComponent: React.FC<ChatWindowProps> = ({ contract }) => {
// ✅ 使用优化的 selector合并多个 selector减少重渲染
const { aiQuoteMessageContent } = useAISelectors();
const { showChatRecordModel } = useUIStateSelectors();
@@ -237,26 +248,49 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ contract }) => {
<MessageEnter contract={contract} />
</Layout>
{/* 右侧个人资料卡片 */}
{showProfile && <ProfileCard contract={contract} />}
{/* 右侧个人资料卡片 - 懒加载 */}
{showProfile && (
<Suspense fallback={<div style={{ padding: "20px", textAlign: "center" }}>...</div>}>
<ProfileCard contract={contract} />
</Suspense>
)}
{/* 跟进提醒模态框 */}
<FollowupReminderModal
visible={followupModalVisible}
onClose={handleFollowupModalClose}
recipientName={contract.nickname || contract.name}
friendId={contract.id?.toString()}
/>
{/* 跟进提醒模态框 - 懒加载 */}
{followupModalVisible && (
<Suspense fallback={null}>
<FollowupReminderModal
visible={followupModalVisible}
onClose={handleFollowupModalClose}
recipientName={contract.nickname || contract.name}
friendId={contract.id?.toString()}
/>
</Suspense>
)}
{/* 待办事项模态框 */}
<TodoListModal
visible={todoModalVisible}
onClose={handleTodoModalClose}
clientName={contract.nickname || contract.name}
friendId={contract.id?.toString()}
/>
{/* 待办事项模态框 - 懒加载 */}
{todoModalVisible && (
<Suspense fallback={null}>
<TodoListModal
visible={todoModalVisible}
onClose={handleTodoModalClose}
clientName={contract.nickname || contract.name}
friendId={contract.id?.toString()}
/>
</Suspense>
)}
</Layout>
);
};
// ✅ 使用 React.memo 优化 ChatWindow 组件,避免不必要的重渲染
const ChatWindow = React.memo(
ChatWindowComponent,
(prev, next) => {
// 只有当联系人 ID 变化时才重新渲染
return prev.contract.id === next.contract.id;
},
);
ChatWindow.displayName = "ChatWindow";
export default ChatWindow;

View File

@@ -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<WeChatState>()(
},
// ==================== 消息接收处理 ====================
/** 接收新消息处理 */
/** 接收新消息处理(优化:批量更新减少重渲染) */
receivedMsg: async message => {
const currentContract = useWeChatStore.getState().currentContract;
// 判断是群聊还是私聊
@@ -839,11 +844,29 @@ export const useWeChatStore = create<WeChatState>()(
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);
// 只有文字消息才触发AImsgType === 1且必须是对方发送的消息isSend !== true
if (

View File

@@ -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,
}),

View File

@@ -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();
}
}