重构MessageRecord组件以支持虚拟化,从而提高大型消息列表的性能。引入VirtualizedMessageList以实现高效渲染,并使用React.memo优化渲染逻辑以防止不必要的重新渲染。
This commit is contained in:
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -219,4 +219,3 @@ export const VirtualMessageList: React.FC<VirtualMessageListProps> = ({
|
||||
};
|
||||
|
||||
export default VirtualMessageList;
|
||||
|
||||
|
||||
@@ -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 的 suffix(addonAfter 已废弃) */}
|
||||
<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 && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
// 只有文字消息才触发AI(msgType === 1),且必须是对方发送的消息(isSend !== true)
|
||||
if (
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user