This commit is contained in:
乘风
2025-12-10 11:58:47 +08:00
parent 4374ad5036
commit 81859dfacb
21 changed files with 5091 additions and 1065 deletions

View File

@@ -10,6 +10,8 @@
"license": "MIT",
"dependencies": {
"@ant-design/icons": "^5.6.1",
"@sentry/react": "^10.29.0",
"@tanstack/react-query": "^5.90.12",
"antd": "^5.13.1",
"antd-mobile": "^5.39.1",
"antd-mobile-icons": "^0.3.0",
@@ -2025,6 +2027,124 @@
"win32"
]
},
"node_modules/@sentry-internal/browser-utils": {
"version": "10.29.0",
"resolved": "https://registry.npmmirror.com/@sentry-internal/browser-utils/-/browser-utils-10.29.0.tgz",
"integrity": "sha512-M3kycMY6f3KY9a8jDYac+yG0E3ZgWVWSxlOEC5MhYyX+g7mqxkwrb3LFQyuxSm/m+CCgMTCaPOOaB2twXP6EQg==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.29.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/feedback": {
"version": "10.29.0",
"resolved": "https://registry.npmmirror.com/@sentry-internal/feedback/-/feedback-10.29.0.tgz",
"integrity": "sha512-Y7IRsNeS99cEONu1mZWZc3HvbjNnu59Hgymm0swFFKbdgbCgdT6l85kn2oLsuq4Ew8Dw/pL/Sgpwsl9UgYFpUg==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.29.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay": {
"version": "10.29.0",
"resolved": "https://registry.npmmirror.com/@sentry-internal/replay/-/replay-10.29.0.tgz",
"integrity": "sha512-45NVw9PwB9TQ8z+xJ6G6Za+wmQ1RTA35heBSzR6U4bknj8LmA04k2iwnobvxCBEQXeLfcJEO1vFgagMoqMZMBw==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "10.29.0",
"@sentry/core": "10.29.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay-canvas": {
"version": "10.29.0",
"resolved": "https://registry.npmmirror.com/@sentry-internal/replay-canvas/-/replay-canvas-10.29.0.tgz",
"integrity": "sha512-typY4JrpAQQGPuSyd/BD8+nNCbvTV2UVvKzr+iKgI0m1qc4Dz8tHZ4Nfais2Z8eYn/pL1kqVQN5ERTmJoYFdIw==",
"license": "MIT",
"dependencies": {
"@sentry-internal/replay": "10.29.0",
"@sentry/core": "10.29.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/browser": {
"version": "10.29.0",
"resolved": "https://registry.npmmirror.com/@sentry/browser/-/browser-10.29.0.tgz",
"integrity": "sha512-XdbyIR6F4qoR9Z1JCWTgunVcTJjS9p2Th+v4wYs4ME+ZdLC4tuKKmRgYg3YdSIWCn1CBfIgdI6wqETSf7H6Njw==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "10.29.0",
"@sentry-internal/feedback": "10.29.0",
"@sentry-internal/replay": "10.29.0",
"@sentry-internal/replay-canvas": "10.29.0",
"@sentry/core": "10.29.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/core": {
"version": "10.29.0",
"resolved": "https://registry.npmmirror.com/@sentry/core/-/core-10.29.0.tgz",
"integrity": "sha512-olQ2DU9dA/Bwsz3PtA9KNXRMqBWRQSkPw+MxwWEoU1K1qtiM9L0j6lbEFb5iSY3d7WYD5MB+1d5COugjSBrHtw==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/react": {
"version": "10.29.0",
"resolved": "https://registry.npmmirror.com/@sentry/react/-/react-10.29.0.tgz",
"integrity": "sha512-YGaEUXubzil7qssD1koh1fyt0aS8tHB61/6+oNShJ6xZPg03AB42bNMr2/y8fIFx36kb3MiCA5sFoH/ubF0LnQ==",
"license": "MIT",
"dependencies": {
"@sentry/browser": "10.29.0",
"@sentry/core": "10.29.0",
"hoist-non-react-statics": "^3.3.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.14.0 || 17.x || 18.x || 19.x"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.90.12",
"resolved": "https://registry.npmmirror.com/@tanstack/query-core/-/query-core-5.90.12.tgz",
"integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.90.12",
"resolved": "https://registry.npmmirror.com/@tanstack/react-query/-/react-query-5.90.12.tgz",
"integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.90.12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -4397,6 +4517,21 @@
"node": ">= 0.4"
}
},
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmmirror.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"license": "BSD-3-Clause",
"dependencies": {
"react-is": "^16.7.0"
}
},
"node_modules/hoist-non-react-statics/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz",

View File

@@ -5,6 +5,8 @@
"private": true,
"dependencies": {
"@ant-design/icons": "^5.6.1",
"@sentry/react": "^10.29.0",
"@tanstack/react-query": "^5.90.12",
"antd": "^5.13.1",
"antd-mobile": "^5.39.1",
"antd-mobile-icons": "^0.3.0",

1300
Touchkebao/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,22 @@
import React from "react";
import * as Sentry from "@sentry/react";
import AppRouter from "@/router";
import UpdateNotification from "@/components/UpdateNotification";
const ErrorFallback = () => (
<div style={{ padding: "20px", textAlign: "center" }}>
<h2></h2>
<p>...</p>
<button onClick={() => window.location.reload()}></button>
</div>
);
function App() {
return (
<>
<Sentry.ErrorBoundary fallback={ErrorFallback}>
<AppRouter />
<UpdateNotification position="top" autoReload={false} showToast={true} />
</>
</Sentry.ErrorBoundary>
);
}

View File

@@ -0,0 +1,95 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { getChatMessages, getChatroomMessages } from "@/pages/pc/ckbox/api";
import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import { captureError, addPerformanceBreadcrumb } from "@/utils/sentry";
const DEFAULT_MESSAGE_PAGE_SIZE = 20;
/**
* 消息列表 Hook
* 使用 TanStack Query 管理消息数据,自动缓存和分页
* 使用 Sentry 监控请求性能和错误
*/
export const useChatMessages = (contact: ContractData | weChatGroup | null) => {
return useInfiniteQuery({
queryKey: ["chatMessages", contact?.id, contact?.wechatAccountId],
initialPageParam: 1, // TanStack Query v5 必需
queryFn: async ({ pageParam }) => {
if (!contact) {
throw new Error("联系人信息缺失");
}
const startTime = performance.now();
const params: any = {
wechatAccountId: contact.wechatAccountId,
page: pageParam,
limit: DEFAULT_MESSAGE_PAGE_SIZE,
};
const isGroup = "chatroomId" in contact && Boolean(contact.chatroomId);
if (isGroup) {
params.wechatChatroomId = contact.id;
} else {
params.wechatFriendId = contact.id;
}
try {
const response = isGroup
? await getChatroomMessages(params)
: await getChatMessages(params);
const duration = performance.now() - startTime;
// ✅ 使用 Sentry 记录请求性能
addPerformanceBreadcrumb("获取消息列表", {
duration,
contactId: contact.id,
page: pageParam,
messageCount: (response as any)?.list?.length || 0,
isGroup,
});
// 如果请求时间超过 1 秒,记录警告
if (duration > 1000) {
addPerformanceBreadcrumb("慢请求警告", {
duration,
contactId: contact.id,
threshold: 1000,
});
}
return response;
} catch (error) {
// ✅ 使用 Sentry 捕获错误
captureError(error as Error, {
tags: {
action: "getChatMessages",
isGroup: String(isGroup),
},
extra: {
contactId: contact.id,
page: pageParam,
params,
},
});
throw error;
}
},
getNextPageParam: (lastPage, allPages) => {
// 判断是否还有更多数据
const lastPageData = lastPage as any;
const hasMore =
lastPageData?.hasNext ||
lastPageData?.hasNextPage ||
(lastPageData?.list?.length || 0) >= DEFAULT_MESSAGE_PAGE_SIZE;
return hasMore ? allPages.length + 1 : undefined;
},
enabled: !!contact, // 只有联系人存在时才请求
staleTime: 5 * 60 * 1000, // 5 分钟缓存
gcTime: 10 * 60 * 1000, // 10 分钟缓存v5 使用 gcTime
// ✅ 使用 Sentry 监控查询状态变化
retry: 1,
refetchOnWindowFocus: true,
});
};

View File

@@ -0,0 +1,31 @@
import { useMemo } from "react";
import { ChatRecord } from "@/pages/pc/ckbox/data";
import { formatWechatTime } from "@/utils/common";
export interface MessageGroup {
time: string;
messages: ChatRecord[];
}
/**
* 消息分组 Hook
* 使用 useMemo 缓存分组结果,减少重复计算
*/
export const useMessageGrouping = (
messages: ChatRecord[] | null | undefined,
): MessageGroup[] => {
return useMemo(() => {
const safeMessages = Array.isArray(messages)
? messages
: Array.isArray((messages as any)?.list)
? ((messages as any).list as ChatRecord[])
: [];
return safeMessages
.filter(msg => msg !== null && msg !== undefined) // 过滤掉null和undefined的消息
.map(msg => ({
time: formatWechatTime(String(msg?.wechatTime)),
messages: [msg],
}));
}, [messages]);
};

View File

@@ -0,0 +1,447 @@
import React, { useCallback } from "react";
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import { getEmojiPath } from "@/components/EmojiSeclection/wechatEmoji";
import AudioMessage from "@/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/AudioMessage/AudioMessage";
import SmallProgramMessage from "@/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/SmallProgramMessage";
import VideoMessage from "@/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/VideoMessage";
import LocationMessage from "@/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/LocationMessage";
import SystemRecommendRemarkMessage from "@/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/SystemRecommendRemarkMessage/index";
import RedPacketMessage from "@/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/RedPacketMessage";
import TransferMessage from "@/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/components/TransferMessage";
import styles from "@/pages/pc/ckbox/weChat/components/ChatWindow/components/MessageRecord/com.module.scss";
const IMAGE_EXT_REGEX = /\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i;
const FILE_EXT_REGEX = /\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|zip|rar|7z)$/i;
const openInNewTab = (url: string) => window.open(url, "_blank");
const handleImageError = (
event: React.SyntheticEvent<HTMLImageElement>,
fallbackText: string,
) => {
const target = event.target as HTMLImageElement;
const parent = target.parentElement;
if (parent) {
parent.innerHTML = `<div class="${styles.messageText}">${fallbackText}</div>`;
}
};
interface ImageContentOptions {
src: string;
alt: string;
fallbackText: string;
style?: React.CSSProperties;
wrapperClassName?: string;
withBubble?: boolean;
onClick?: () => void;
}
const renderImageContent = ({
src,
alt,
fallbackText,
style = {
maxWidth: "200px",
maxHeight: "200px",
borderRadius: "8px",
},
wrapperClassName = styles.imageMessage,
withBubble = false,
onClick,
}: ImageContentOptions) => {
const imageNode = (
<div className={wrapperClassName}>
<img
src={src}
alt={alt}
style={style}
onClick={onClick ?? (() => openInNewTab(src))}
onError={event => handleImageError(event, fallbackText)}
/>
</div>
);
if (withBubble) {
return <div className={styles.messageBubble}>{imageNode}</div>;
}
return imageNode;
};
const renderEmojiContent = (src: string) =>
renderImageContent({
src,
alt: "表情包",
fallbackText: "[表情包加载失败]",
style: {
maxWidth: "120px",
maxHeight: "120px",
},
wrapperClassName: styles.emojiMessage,
});
const renderFileContent = (url: string) => {
const fileName = url.split("/").pop()?.split("?")[0] || "文件";
const displayName =
fileName.length > 20 ? `${fileName.substring(0, 20)}...` : fileName;
return (
<div className={styles.fileMessage}>
<div className={styles.fileCard}>
<div className={styles.fileIcon}>📄</div>
<div className={styles.fileInfo}>
<div className={styles.fileName}>{displayName}</div>
<div className={styles.fileAction} onClick={() => openInNewTab(url)}>
</div>
</div>
</div>
</div>
);
};
const isHttpUrl = (value: string) => /^https?:\/\//i.test(value);
const isHttpImageUrl = (value: string) =>
isHttpUrl(value) && IMAGE_EXT_REGEX.test(value);
const isFileUrl = (value: string) =>
isHttpUrl(value) && FILE_EXT_REGEX.test(value);
const isLegacyEmojiContent = (content: string) =>
IMAGE_EXT_REGEX.test(content) ||
content.includes("emoji") ||
content.includes("sticker");
const tryParseContentJson = (content: string): Record<string, any> | null => {
try {
return JSON.parse(content);
} catch (error) {
return null;
}
};
/**
* 消息解析 Hook
* 提取消息解析逻辑,使用 useCallback 优化性能
*/
export const useMessageParser = (contract: ContractData | weChatGroup) => {
// 判断是否为表情包URL的工具函数
const isEmojiUrl = useCallback((content: string): boolean => {
return (
content.includes("ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com") ||
/\.(gif|webp|png|jpg|jpeg)$/i.test(content) ||
content.includes("emoji") ||
content.includes("sticker") ||
content.includes("expression")
);
}, []);
// 解析表情包文字格式[表情名称]并替换为img标签
const parseEmojiText = useCallback((text: string): React.ReactNode[] => {
const emojiRegex = /\[([^\]]+)\]/g;
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let match;
while ((match = emojiRegex.exec(text)) !== null) {
// 添加表情前的文字
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
// 获取表情名称并查找对应路径
const emojiName = match[1];
const emojiPath = getEmojiPath(emojiName as any);
if (emojiPath) {
// 如果找到表情添加img标签
parts.push(
<img
key={`emoji-${match.index}`}
src={emojiPath}
alt={emojiName}
className={styles.emojiImage}
style={{
width: "20px",
height: "20px",
margin: "0 2px",
display: "inline",
lineHeight: "20px",
float: "left",
}}
/>,
);
} else {
// 如果没找到表情,保持原文字
parts.push(match[0]);
}
lastIndex = emojiRegex.lastIndex;
}
// 添加剩余的文字
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts;
}, []);
// 渲染未知内容
const renderUnknownContent = useCallback(
(
rawContent: string,
trimmedContent: string,
msg?: ChatRecord,
contractParam?: ContractData | weChatGroup,
) => {
if (isLegacyEmojiContent(trimmedContent)) {
return renderEmojiContent(rawContent);
}
const jsonData = tryParseContentJson(trimmedContent);
if (jsonData && typeof jsonData === "object") {
// 判断是否为红包消息
if (
jsonData.nativeurl &&
typeof jsonData.nativeurl === "string" &&
jsonData.nativeurl.includes(
"wxpay://c2cbizmessagehandler/hongbao/receivehongbao",
)
) {
return (
<RedPacketMessage
content={rawContent}
msg={msg}
contract={contractParam}
/>
);
}
// 判断是否为转账消息
if (
jsonData.title === "微信转账" ||
(jsonData.transferid && jsonData.feedesc)
) {
return (
<TransferMessage
content={rawContent}
msg={msg}
contract={contractParam}
/>
);
}
if (jsonData.type === "file" && msg && contractParam) {
return (
<SmallProgramMessage
content={rawContent}
msg={msg}
contract={contractParam}
/>
);
}
if (jsonData.type === "link" && jsonData.title && jsonData.url) {
const { title, desc, thumbPath, url } = jsonData;
return (
<div
className={`${styles.miniProgramMessage} ${styles.miniProgramType1}`}
>
<div
className={`${styles.miniProgramCard} ${styles.linkCard}`}
onClick={() => openInNewTab(url)}
>
{thumbPath && (
<img
src={thumbPath}
alt="链接缩略图"
className={styles.miniProgramThumb}
onError={event => {
const target = event.target as HTMLImageElement;
target.style.display = "none";
}}
/>
)}
<div className={styles.miniProgramInfo}>
<div className={styles.miniProgramTitle}>{title}</div>
{desc && <div className={styles.linkDescription}>{desc}</div>}
</div>
</div>
<div className={styles.miniProgramApp}></div>
</div>
);
}
if (
jsonData.previewImage &&
(jsonData.tencentUrl || jsonData.videoUrl)
) {
const previewImageUrl = String(jsonData.previewImage).replace(
/[`"']/g,
"",
);
return (
<div className={styles.videoMessage}>
<div className={styles.videoContainer}>
<img
src={previewImageUrl}
alt="视频预览"
className={styles.videoPreview}
onClick={() => {
const videoUrl = jsonData.videoUrl || jsonData.tencentUrl;
if (videoUrl) {
openInNewTab(videoUrl);
}
}}
onError={event => {
const target = event.target as HTMLImageElement;
const parent = target.parentElement?.parentElement;
if (parent) {
parent.innerHTML = `<div class="${styles.messageText}">[视频预览加载失败]</div>`;
}
}}
/>
<div className={styles.playButton}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="white">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
</div>
);
}
}
if (isHttpImageUrl(trimmedContent)) {
return renderImageContent({
src: rawContent,
alt: "图片消息",
fallbackText: "[图片加载失败]",
});
}
if (isFileUrl(trimmedContent)) {
return renderFileContent(trimmedContent);
}
return (
<div className={styles.messageText}>{parseEmojiText(rawContent)}</div>
);
},
[parseEmojiText],
);
// 解析消息内容根据msgType判断消息类型并返回对应的渲染内容
const parseMessageContent = useCallback(
(
content: string | null | undefined,
msg: ChatRecord,
msgType?: number,
): React.ReactNode => {
// 处理null或undefined的内容
if (content === null || content === undefined) {
return <div className={styles.messageText}></div>;
}
// 统一的错误消息渲染函数
const renderErrorMessage = (fallbackText: string) => (
<div className={styles.messageText}>{fallbackText}</div>
);
const isStringValue = typeof content === "string";
const rawContent = isStringValue ? content : "";
const trimmedContent = rawContent.trim();
switch (msgType) {
case 1: // 文本消息
return (
<div className={styles.messageBubble}>
<div className={styles.messageText}>
{parseEmojiText(rawContent)}
</div>
</div>
);
case 3: // 图片消息
if (!isStringValue || !trimmedContent) {
return renderErrorMessage("[图片消息 - 无效链接]");
}
return renderImageContent({
src: rawContent,
alt: "图片消息",
fallbackText: "[图片加载失败]",
withBubble: true,
});
case 34: // 语音消息
if (!isStringValue || !trimmedContent) {
return renderErrorMessage("[语音消息 - 无效内容]");
}
return <AudioMessage audioUrl={rawContent} msgId={String(msg.id)} />;
case 43: // 视频消息
return (
<VideoMessage
content={isStringValue ? rawContent : ""}
msg={msg}
contract={contract}
/>
);
case 47: // 动图表情包gif、其他表情包
if (!isStringValue || !trimmedContent) {
return renderErrorMessage("[表情包 - 无效链接]");
}
if (isEmojiUrl(trimmedContent)) {
return renderEmojiContent(rawContent);
}
return renderErrorMessage("[表情包]");
case 48: // 定位消息
return <LocationMessage content={isStringValue ? rawContent : ""} />;
case 49: // 小程序/文章/其他:图文、文件
return (
<SmallProgramMessage
content={isStringValue ? rawContent : ""}
msg={msg}
contract={contract}
/>
);
case 10002: // 系统推荐备注消息
return (
<SystemRecommendRemarkMessage
content={isStringValue ? rawContent : ""}
/>
);
default: {
if (!isStringValue || !trimmedContent) {
return renderErrorMessage(
`[未知消息类型${msgType ? ` - ${msgType}` : ""}]`,
);
}
return renderUnknownContent(
rawContent,
trimmedContent,
msg,
contract,
);
}
}
},
[contract, parseEmojiText, isEmojiUrl, renderUnknownContent],
);
return {
parseMessageContent,
parseEmojiText,
isEmojiUrl,
};
};

View File

@@ -0,0 +1,129 @@
import { useWeChatStore } from "@/store/module/weChat/weChat";
import { useShallow } from "zustand/react/shallow";
import { useMemo } from "react";
import type { WeChatState } from "@/store/module/weChat/weChat.data";
/**
* 合并多个 selector减少重渲染
* 使用 useShallow 进行 shallow 比较,只有对象属性变化时才触发更新
* 使用 useMemo 稳定 selector 函数引用
*/
export const useWeChatSelectors = () => {
const selector = useMemo(
() => (state: WeChatState) => ({
// 消息相关
currentMessages: state.currentMessages,
currentMessagesHasMore: state.currentMessagesHasMore,
messagesLoading: state.messagesLoading,
isLoadingData: state.isLoadingData,
// 联系人相关
currentContract: state.currentContract,
// UI 状态
showCheckbox: state.showCheckbox,
EnterModule: state.EnterModule,
showChatRecordModel: state.showChatRecordModel,
// AI 相关
isLoadingAiChat: state.isLoadingAiChat,
quoteMessageContent: state.quoteMessageContent,
aiQuoteMessageContent: state.aiQuoteMessageContent,
// 选中记录
selectedChatRecords: state.selectedChatRecords,
}),
[],
);
return useWeChatStore(useShallow(selector));
};
/**
* 消息相关的 selector
* 使用 useShallow 进行 shallow 比较,避免 getSnapshot 警告
* 使用 useMemo 稳定 selector 函数引用
*/
export const useMessageSelectors = (): Pick<
WeChatState,
| "currentMessages"
| "currentMessagesHasMore"
| "messagesLoading"
| "isLoadingData"
> => {
const selector = useMemo(
() => (state: WeChatState) => ({
currentMessages: state.currentMessages,
currentMessagesHasMore: state.currentMessagesHasMore,
messagesLoading: state.messagesLoading,
isLoadingData: state.isLoadingData,
}),
[],
);
return useWeChatStore(useShallow(selector));
};
/**
* UI 状态相关的 selector
* 使用 useShallow 进行 shallow 比较,避免 getSnapshot 警告
* 使用 useMemo 稳定 selector 函数引用
*/
export const useUIStateSelectors = (): Pick<
WeChatState,
"showCheckbox" | "EnterModule" | "showChatRecordModel"
> => {
const selector = useMemo(
() => (state: WeChatState) => ({
showCheckbox: state.showCheckbox,
EnterModule: state.EnterModule,
showChatRecordModel: state.showChatRecordModel,
}),
[],
);
return useWeChatStore(useShallow(selector));
};
/**
* AI 相关的 selector
* 使用 useShallow 进行 shallow 比较,避免 getSnapshot 警告
* 使用 useMemo 稳定 selector 函数引用
*/
export const useAISelectors = (): Pick<
WeChatState,
"isLoadingAiChat" | "quoteMessageContent" | "aiQuoteMessageContent"
> => {
const selector = useMemo(
() => (state: WeChatState) => ({
isLoadingAiChat: state.isLoadingAiChat,
quoteMessageContent: state.quoteMessageContent,
aiQuoteMessageContent: state.aiQuoteMessageContent,
}),
[],
);
return useWeChatStore(useShallow(selector));
};
/**
* 操作方法 selector
* 使用 useShallow 进行 shallow 比较,避免 getSnapshot 警告
* 虽然方法引用稳定,但返回的对象需要 shallow 比较
*/
export const useWeChatActions = () => {
const selector = useMemo(
() => (state: WeChatState) => ({
addMessage: state.addMessage,
updateMessage: state.updateMessage,
recallMessage: state.recallMessage,
loadChatMessages: state.loadChatMessages,
updateShowCheckbox: state.updateShowCheckbox,
updateEnterModule: state.updateEnterModule,
updateQuoteMessageContent: state.updateQuoteMessageContent,
updateIsLoadingAiChat: state.updateIsLoadingAiChat,
updateSelectedChatRecords: state.updateSelectedChatRecords,
updateShowChatRecordModel: state.updateShowChatRecordModel,
setCurrentContact: state.setCurrentContact,
updateAiQuoteMessageContent: state.updateAiQuoteMessageContent,
}),
[],
);
return useWeChatStore(useShallow(selector));
};

View File

@@ -8,6 +8,11 @@ import "dayjs/locale/zh-cn";
import App from "./App";
import "./styles/global.scss";
import { initializeDatabaseFromPersistedUser } from "./utils/db";
import { initSentry } from "./utils/sentry";
import { QueryProvider } from "./providers/QueryProvider";
// 最先初始化 Sentry必须在其他代码之前
initSentry();
// 设置dayjs为中文
dayjs.locale("zh-cn");
@@ -22,7 +27,9 @@ async function bootstrap() {
const root = createRoot(document.getElementById("root")!);
root.render(
<ConfigProvider locale={zhCN}>
<App />
<QueryProvider>
<App />
</QueryProvider>
</ConfigProvider>,
);
}

View File

@@ -19,10 +19,14 @@ import AudioRecorder from "@/components/Upload/AudioRecorder";
import ToContract from "./components/toContract";
import styles from "./MessageEnter.module.scss";
import {
useWeChatStore,
clearAiRequestQueue,
manualTriggerAi,
} from "@/store/module/weChat/weChat";
import {
useUIStateSelectors,
useAISelectors,
} from "@/hooks/weChat/useWeChatSelectors";
import { useWeChatActions } from "@/hooks/weChat/useWeChatSelectors";
import { useContactStore } from "@/store/module/weChat/contacts";
import SelectMap from "./components/selectMap";
const { Footer } = Layout;
@@ -183,37 +187,31 @@ const InputToolbar = React.memo(
},
);
InputToolbar.displayName = "InputToolbar";
const MemoSelectMap: React.FC<React.ComponentProps<typeof SelectMap>> =
React.memo(props => <SelectMap {...props} />);
MemoSelectMap.displayName = "MemoSelectMap";
const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
const [inputValue, setInputValue] = useState("");
const EnterModule = useWeChatStore(state => state.EnterModule);
const updateShowCheckbox = useWeChatStore(state => state.updateShowCheckbox);
const updateEnterModule = useWeChatStore(state => state.updateEnterModule);
const setTransmitModal = useContactStore(state => state.setTransmitModal);
const addMessage = useWeChatStore(state => state.addMessage);
const showChatRecordModel = useWeChatStore(
state => state.showChatRecordModel,
);
const updateShowChatRecordModel = useWeChatStore(
state => state.updateShowChatRecordModel,
);
const quoteMessageContent = useWeChatStore(
state => state.quoteMessageContent,
);
const isLoadingAiChat = useWeChatStore(state => state.isLoadingAiChat);
const updateIsLoadingAiChat = useWeChatStore(
state => state.updateIsLoadingAiChat,
);
const updateQuoteMessageContent = useWeChatStore(
state => state.updateQuoteMessageContent,
);
// 获取接待类型0=人工接待, 1=AI辅助, 2=AI接管
const aiQuoteMessageContent = useWeChatStore(
state => state.aiQuoteMessageContent,
);
// ✅ 使用优化的 selector合并多个 selector减少重渲染
const { EnterModule, showChatRecordModel } = useUIStateSelectors();
const { isLoadingAiChat, quoteMessageContent, aiQuoteMessageContent } =
useAISelectors();
const {
updateShowCheckbox,
updateEnterModule,
addMessage,
updateShowChatRecordModel,
updateIsLoadingAiChat,
updateQuoteMessageContent,
} = useWeChatActions();
const setTransmitModal = useContactStore(state => state.setTransmitModal);
// 判断接待类型
const isAiAssist = aiQuoteMessageContent === 1; // AI辅助

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo } from "react";
import React, { useState, useEffect, useMemo, useCallback } from "react";
import {
Modal,
Input,
@@ -50,7 +50,7 @@ const TransmitModal: React.FC = () => {
);
// 加载联系人数据
const loadContacts = async () => {
const loadContacts = useCallback(async () => {
setLoading(true);
try {
// 从统一联系人表加载所有联系人
@@ -63,9 +63,9 @@ const TransmitModal: React.FC = () => {
} finally {
setLoading(false);
}
};
}, [currentUserId]);
// 重置状态
// 重置状态 - 只在 openTransmitModal 变为 true 时执行
useEffect(() => {
if (openTransmitModal) {
setSearchValue("");
@@ -73,6 +73,8 @@ const TransmitModal: React.FC = () => {
setPage(1);
loadContacts();
}
// 注意loadContacts 已经在 useCallback 中稳定,但为了安全,我们只在 openTransmitModal 变化时执行
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openTransmitModal]);
// 过滤联系人 - 支持名称和拼音搜索

View File

@@ -21,6 +21,15 @@ import { formatWechatTime } from "@/utils/common";
import { getEmojiPath } from "@/components/EmojiSeclection/wechatEmoji";
import { parseSystemMessage } from "@/utils/filter";
import styles from "./com.module.scss";
import {
useMessageSelectors,
useUIStateSelectors,
} from "@/hooks/weChat/useWeChatSelectors";
import { useWeChatActions } from "@/hooks/weChat/useWeChatSelectors";
import { useMessageParser } from "@/hooks/weChat/useMessageParser";
import { useMessageGrouping } from "@/hooks/weChat/useMessageGrouping";
import { Profiler } from "@sentry/react";
import { addPerformanceBreadcrumb } from "@/utils/sentry";
import { useWeChatStore } from "@/store/module/weChat/weChat";
import { useContactStore } from "@/store/module/weChat/contacts";
import { useCustomerStore } from "@weChatStore/customer";
@@ -324,243 +333,42 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
// 选中的聊天记录状态
const [selectedRecords, setSelectedRecords] = useState<ChatRecord[]>([]);
const currentMessages = useWeChatStore(state => state.currentMessages);
const currentMessagesHasMore = useWeChatStore(
state => state.currentMessagesHasMore,
);
// ✅ 使用优化的 selector合并多个 selector减少重渲染
const {
currentMessages,
currentMessagesHasMore,
messagesLoading,
isLoadingData,
} = useMessageSelectors();
const { showCheckbox } = useUIStateSelectors();
const {
loadChatMessages,
updateShowCheckbox,
updateEnterModule,
updateSelectedChatRecords,
updateQuoteMessageContent,
} = useWeChatActions();
const loadChatMessages = useWeChatStore(state => state.loadChatMessages);
const messagesLoading = useWeChatStore(state => state.messagesLoading);
const isLoadingData = useWeChatStore(state => state.isLoadingData);
const showCheckbox = useWeChatStore(state => state.showCheckbox);
const prevMessagesRef = useRef(currentMessages);
const isLoadingMoreRef = useRef(false);
const scrollPositionRef = useRef<number>(0);
const updateShowCheckbox = useWeChatStore(state => state.updateShowCheckbox);
const updateEnterModule = useWeChatStore(state => state.updateEnterModule);
const currentCustomer = useCustomerStore(state =>
state.customerList.find(kf => kf.id === contract.wechatAccountId),
);
const updateSelectedChatRecords = useWeChatStore(
state => state.updateSelectedChatRecords,
);
const setTransmitModal = useContactStore(state => state.setTransmitModal);
const [groupRender, setGroupRender] = useState<GroupRenderItem[]>([]);
const currentContract = useWeChatStore(state => state.currentContract);
const updateQuoteMessageContent = useWeChatStore(
state => state.updateQuoteMessageContent,
);
// 判断是否为表情包URL的工具函数
const isEmojiUrl = (content: string): boolean => {
return (
content.includes("ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com") ||
/\.(gif|webp|png|jpg|jpeg)$/i.test(content) ||
content.includes("emoji") ||
content.includes("sticker") ||
content.includes("expression")
);
};
// ✅ 使用 useMessageParser Hook提取消息解析逻辑
const { parseMessageContent, parseEmojiText, isEmojiUrl } =
useMessageParser(contract);
// 解析表情包文字格式[表情名称]并替换为img标签
const parseEmojiText = useCallback((text: string): React.ReactNode[] => {
const emojiRegex = /\[([^\]]+)\]/g;
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let match;
while ((match = emojiRegex.exec(text)) !== null) {
// 添加表情前的文字
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
// 获取表情名称并查找对应路径
const emojiName = match[1];
const emojiPath = getEmojiPath(emojiName as any);
if (emojiPath) {
// 如果找到表情添加img标签
parts.push(
<img
key={`emoji-${match.index}`}
src={emojiPath}
alt={emojiName}
className={styles.emojiImage}
style={{
width: "20px",
height: "20px",
margin: "0 2px",
display: "inline",
lineHeight: "20px",
float: "left",
}}
/>,
);
} else {
// 如果没找到表情,保持原文字
parts.push(match[0]);
}
lastIndex = emojiRegex.lastIndex;
}
// 添加剩余的文字
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts;
}, []);
const renderUnknownContent = useCallback(
(
rawContent: string,
trimmedContent: string,
msg?: ChatRecord,
contractParam?: ContractData | weChatGroup,
) => {
if (isLegacyEmojiContent(trimmedContent)) {
return renderEmojiContent(rawContent);
}
const jsonData = tryParseContentJson(trimmedContent);
if (jsonData && typeof jsonData === "object") {
// 判断是否为红包消息
if (
jsonData.nativeurl &&
typeof jsonData.nativeurl === "string" &&
jsonData.nativeurl.includes(
"wxpay://c2cbizmessagehandler/hongbao/receivehongbao",
)
) {
return (
<RedPacketMessage
content={rawContent}
msg={msg}
contract={contractParam}
/>
);
}
// 判断是否为转账消息
if (
jsonData.title === "微信转账" ||
(jsonData.transferid && jsonData.feedesc)
) {
return (
<TransferMessage
content={rawContent}
msg={msg}
contract={contractParam}
/>
);
}
if (jsonData.type === "file" && msg && contractParam) {
return (
<SmallProgramMessage
content={rawContent}
msg={msg}
contract={contractParam}
/>
);
}
if (jsonData.type === "link" && jsonData.title && jsonData.url) {
const { title, desc, thumbPath, url } = jsonData;
return (
<div
className={`${styles.miniProgramMessage} ${styles.miniProgramType1}`}
>
<div
className={`${styles.miniProgramCard} ${styles.linkCard}`}
onClick={() => openInNewTab(url)}
>
{thumbPath && (
<img
src={thumbPath}
alt="链接缩略图"
className={styles.miniProgramThumb}
onError={event => {
const target = event.target as HTMLImageElement;
target.style.display = "none";
}}
/>
)}
<div className={styles.miniProgramInfo}>
<div className={styles.miniProgramTitle}>{title}</div>
{desc && <div className={styles.linkDescription}>{desc}</div>}
</div>
</div>
<div className={styles.miniProgramApp}></div>
</div>
);
}
if (
jsonData.previewImage &&
(jsonData.tencentUrl || jsonData.videoUrl)
) {
const previewImageUrl = String(jsonData.previewImage).replace(
/[`"']/g,
"",
);
return (
<div className={styles.videoMessage}>
<div className={styles.videoContainer}>
<img
src={previewImageUrl}
alt="视频预览"
className={styles.videoPreview}
onClick={() => {
const videoUrl = jsonData.videoUrl || jsonData.tencentUrl;
if (videoUrl) {
openInNewTab(videoUrl);
}
}}
onError={event => {
const target = event.target as HTMLImageElement;
const parent = target.parentElement?.parentElement;
if (parent) {
parent.innerHTML = `<div class="${styles.messageText}">[视频预览加载失败]</div>`;
}
}}
/>
<div className={styles.playButton}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="white">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
</div>
);
}
}
if (isHttpImageUrl(trimmedContent)) {
return renderImageContent({
src: rawContent,
alt: "图片消息",
fallbackText: "[图片加载失败]",
});
}
if (isFileUrl(trimmedContent)) {
return renderFileContent(trimmedContent);
}
return (
<div className={styles.messageText}>{parseEmojiText(rawContent)}</div>
);
},
[parseEmojiText],
);
// ✅ 使用 useMessageGrouping Hook消息分组使用 useMemo 缓存)
const groupedMessages = useMessageGrouping(currentMessages);
useEffect(() => {
const fetchGroupMembers = async () => {
@@ -607,10 +415,20 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
[groupMemberMap],
);
// 定义 scrollToBottom 函数,在 useEffect 之前
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, []);
useEffect(() => {
const prevMessages = prevMessagesRef.current;
const prevLength = prevMessages.length;
// 如果消息数组引用相同,跳过处理(避免不必要的滚动)
if (prevMessages === currentMessages) {
return;
}
const hasVideoStateChange = currentMessages.some((msg, index) => {
// 首先检查消息对象本身是否为null或undefined
if (!msg || !msg.content) return false;
@@ -651,14 +469,20 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
if (isLoadingMoreRef.current && currentMessages.length > prevLength) {
// 不滚动,等待加载完成后在另一个 useEffect 中恢复滚动位置
} else if (currentMessages.length > prevLength && !hasVideoStateChange) {
scrollToBottom();
// 使用 setTimeout 延迟滚动,避免在渲染过程中触发状态更新
setTimeout(() => {
scrollToBottom();
}, 0);
} else if (isLoadingData && !hasVideoStateChange) {
scrollToBottom();
// 使用 setTimeout 延迟滚动,避免在渲染过程中触发状态更新
setTimeout(() => {
scrollToBottom();
}, 0);
}
// 更新上一次的消息状态
// 更新上一次的消息状态(使用浅拷贝避免引用问题)
prevMessagesRef.current = currentMessages;
}, [currentMessages, isLoadingData]);
}, [currentMessages, isLoadingData, scrollToBottom]);
// 监听加载状态,当加载完成时恢复滚动位置
useEffect(() => {
@@ -676,108 +500,6 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
}
}, [messagesLoading]);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
// 解析消息内容根据msgType判断消息类型并返回对应的渲染内容
const parseMessageContent = (
content: string | null | undefined,
msg: ChatRecord,
msgType?: number,
) => {
// 处理null或undefined的内容
if (content === null || content === undefined) {
return <div className={styles.messageText}></div>;
}
// 统一的错误消息渲染函数
const renderErrorMessage = (fallbackText: string) => (
<div className={styles.messageText}>{fallbackText}</div>
);
const isStringValue = typeof content === "string";
const rawContent = isStringValue ? content : "";
const trimmedContent = rawContent.trim();
switch (msgType) {
case 1: // 文本消息
return (
<div className={styles.messageBubble}>
<div className={styles.messageText}>
{parseEmojiText(rawContent)}
</div>
</div>
);
case 3: // 图片消息
if (!isStringValue || !trimmedContent) {
return renderErrorMessage("[图片消息 - 无效链接]");
}
return renderImageContent({
src: rawContent,
alt: "图片消息",
fallbackText: "[图片加载失败]",
withBubble: true,
});
case 34: // 语音消息
if (!isStringValue || !trimmedContent) {
return renderErrorMessage("[语音消息 - 无效内容]");
}
return <AudioMessage audioUrl={rawContent} msgId={String(msg.id)} />;
case 43: // 视频消息
return (
<VideoMessage
content={isStringValue ? rawContent : ""}
msg={msg}
contract={contract}
/>
);
case 47: // 动图表情包gif、其他表情包
if (!isStringValue || !trimmedContent) {
return renderErrorMessage("[表情包 - 无效链接]");
}
if (isEmojiUrl(trimmedContent)) {
return renderEmojiContent(rawContent);
}
return renderErrorMessage("[表情包]");
case 48: // 定位消息
return <LocationMessage content={isStringValue ? rawContent : ""} />;
case 49: // 小程序/文章/其他:图文、文件
return (
<SmallProgramMessage
content={isStringValue ? rawContent : ""}
msg={msg}
contract={contract}
/>
);
case 10002: // 系统推荐备注消息
return (
<SystemRecommendRemarkMessage
content={isStringValue ? rawContent : ""}
/>
);
default: {
if (!isStringValue || !trimmedContent) {
return renderErrorMessage(
`[未知消息类型${msgType ? ` - ${msgType}` : ""}]`,
);
}
return renderUnknownContent(rawContent, trimmedContent, msg, contract);
}
}
};
// 获取群成员头像
// 清理微信ID前缀
const clearWechatidInContent = useCallback((sender: any, content: string) => {
@@ -834,27 +556,6 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
return selectedRecords.some(record => record.id === msg.id);
};
// 用于分组消息并添加时间戳的辅助函数
const groupMessagesByTime = (messages: ChatRecord[] | null | undefined) => {
const safeMessages = Array.isArray(messages)
? messages
: Array.isArray((messages as any)?.list)
? ((messages as any).list as ChatRecord[])
: [];
return safeMessages
.filter(msg => msg !== null && msg !== undefined) // 过滤掉null和undefined的消息
.map(msg => ({
time: formatWechatTime(String(msg?.wechatTime)),
messages: [msg],
}));
};
const groupedMessages = useMemo(
() => groupMessagesByTime(currentMessages),
[currentMessages],
);
const isGroupChat = !!contract.chatroomId;
const loadMoreMessages = () => {
if (messagesLoading || !currentMessagesHasMore) {
@@ -944,95 +645,116 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
};
return (
<div ref={messagesContainerRef} className={styles.messagesContainer}>
<div
className={styles.loadMore}
onClick={loadMoreMessages}
style={{
cursor:
currentMessagesHasMore && !messagesLoading ? "pointer" : "default",
opacity: currentMessagesHasMore ? 1 : 0.6,
}}
>
{currentMessagesHasMore ? "点击加载更早的信息" : "已经没有更早的消息了"}
{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>
);
})}
<Profiler
name="MessageRecord"
onRender={(id, phase, actualDuration) => {
// ✅ 使用 Sentry 监控组件渲染性能
if (actualDuration > 100) {
addPerformanceBreadcrumb("MessageRecord 慢渲染", {
duration: actualDuration,
phase,
messageCount: currentMessages.length,
contractId: contract.id,
});
}
}}
>
<div ref={messagesContainerRef} className={styles.messagesContainer}>
<div
className={styles.loadMore}
onClick={loadMoreMessages}
style={{
cursor:
currentMessagesHasMore && !messagesLoading
? "pointer"
: "default",
opacity: currentMessagesHasMore ? 1 : 0.6,
}}
>
{currentMessagesHasMore
? "点击加载更早的信息"
: "已经没有更早的消息了"}
{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>
);
})}
{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;
{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;
}
} 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}
x={contextMenu.x}
y={contextMenu.y}
messageData={contextMenu.messageData}
isOwn={nowIsOwn}
onClose={handleCloseContextMenu}
onCommad={handCommad}
/>
{/* 转发模态框 */}
<TransmitModal />
</div>
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}
x={contextMenu.x}
y={contextMenu.y}
messageData={contextMenu.messageData}
isOwn={nowIsOwn}
onClose={handleCloseContextMenu}
onCommad={handCommad}
/>
{/* 转发模态框 */}
<TransmitModal />
</div>
</Profiler>
);
};

View File

@@ -24,6 +24,7 @@ import {
import { comfirm } from "@/utils/common";
import { useWeChatStore } from "@/store/module/weChat/weChat";
import { useShallow } from "zustand/react/shallow";
// 单个朋友圈项目组件
export const FriendCard: React.FC<FriendCardProps> = ({
monent,
@@ -37,7 +38,13 @@ export const FriendCard: React.FC<FriendCardProps> = ({
const time = formatTime(monent.createTime);
const likesCount = monent?.likeList?.length || 0;
const commentsCount = monent?.commentList?.length || 0;
const { updateLikeMoment, updateComment } = useWeChatStore();
// ✅ 使用 useShallow 避免 getSnapshot 警告
const { updateLikeMoment, updateComment } = useWeChatStore(
useShallow(state => ({
updateLikeMoment: state.updateLikeMoment,
updateComment: state.updateComment,
})),
);
// 评论相关状态
const [showCommentInput, setShowCommentInput] = useState(false);

View File

@@ -8,6 +8,7 @@ import styles from "./index.module.scss";
import { fetchFriendsCircleData } from "./api";
import { useCkChatStore } from "@/store/module/ckchat/ckchat";
import { useWeChatStore } from "@/store/module/weChat/weChat";
import { useShallow } from "zustand/react/shallow";
interface FriendsCircleProps {
wechatFriendId?: number;
@@ -17,7 +18,13 @@ const FriendsCircle: React.FC<FriendsCircleProps> = ({ wechatFriendId }) => {
const currentKf = useCkChatStore(state =>
state.kfUserList.find(kf => kf.id === state.kfSelected),
);
const { clearMomentCommon, updateMomentCommonLoading } = useWeChatStore();
// ✅ 使用 useShallow 避免 getSnapshot 警告
const { clearMomentCommon, updateMomentCommonLoading } = useWeChatStore(
useShallow(state => ({
clearMomentCommon: state.clearMomentCommon,
updateMomentCommonLoading: state.updateMomentCommonLoading,
})),
);
const MomentCommon = useWeChatStore(state => state.MomentCommon);
const MomentCommonLoading = useWeChatStore(
state => state.MomentCommonLoading,

View File

@@ -53,8 +53,8 @@ const Person: React.FC<PersonProps> = ({ contract }) => {
// 会话列表 & 当前用户,用于在备注更新后同步会话列表显示
const setSessions = useMessageStore(state => state.setSessions);
const { user } = useUserStore();
const currentUserId = user?.id || 0;
// ✅ 使用 selector 避免 getSnapshot 警告
const currentUserId = useUserStore(state => state.user?.id) || 0;
// 判断是否为群聊
const isGroup = "chatroomId" in contract;
@@ -227,7 +227,8 @@ const Person: React.FC<PersonProps> = ({ contract }) => {
// 不再需要从useContactStore获取getContactsByCustomer
const { sendCommand } = useWebSocketStore();
// ✅ 使用 selector 避免 getSnapshot 警告
const sendCommand = useWebSocketStore(state => state.sendCommand);
// 权限控制:检查当前客服是否有群管理权限
const hasGroupManagePermission = () => {

View File

@@ -26,7 +26,8 @@ import FollowupReminderModal from "./components/FollowupReminderModal";
import TodoListModal from "./components/TodoListModal";
import ChatRecordSearch from "./components/ChatRecordSearch";
import { setFriendInjectConfig } from "@/pages/pc/ckbox/weChat/api";
import { useWeChatStore } from "@/store/module/weChat/weChat";
import { useAISelectors, useUIStateSelectors } from "@/hooks/weChat/useWeChatSelectors";
import { useWeChatActions } from "@/hooks/weChat/useWeChatSelectors";
import { MessageManager } from "@/utils/dbAction/message";
import { ContactManager } from "@/utils/dbAction/contact";
import { useUserStore } from "@storeModule/user";
@@ -43,18 +44,13 @@ const typeOptions = [
];
const ChatWindow: React.FC<ChatWindowProps> = ({ contract }) => {
const updateAiQuoteMessageContent = useWeChatStore(
state => state.updateAiQuoteMessageContent,
);
const aiQuoteMessageContent = useWeChatStore(
state => state.aiQuoteMessageContent,
);
const showChatRecordModel = useWeChatStore(
state => state.showChatRecordModel,
);
const setCurrentContact = useWeChatStore(state => state.setCurrentContact);
const { user } = useUserStore();
const currentUserId = user?.id || 0;
// ✅ 使用优化的 selector合并多个 selector减少重渲染
const { aiQuoteMessageContent } = useAISelectors();
const { showChatRecordModel } = useUIStateSelectors();
const { updateAiQuoteMessageContent, setCurrentContact } = useWeChatActions();
// ✅ 使用 selector 避免 getSnapshot 警告
const currentUserId = useUserStore(state => state.user?.id) || 0;
const [showProfile, setShowProfile] = useState(true);
const [followupModalVisible, setFollowupModalVisible] = useState(false);

View File

@@ -0,0 +1,27 @@
import React from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 分钟内数据视为新鲜
gcTime: 10 * 60 * 1000, // 10 分钟缓存v5 使用 gcTime 替代 cacheTime
retry: 1, // 失败重试 1 次
refetchOnWindowFocus: true, // 窗口聚焦时自动刷新
refetchOnReconnect: true, // 网络重连时自动刷新
},
mutations: {
retry: 1, // 失败重试 1 次
},
},
});
interface QueryProviderProps {
children: React.ReactNode;
}
export const QueryProvider: React.FC<QueryProviderProps> = ({ children }) => {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};

View File

@@ -0,0 +1,87 @@
import * as Sentry from "@sentry/react";
/**
* 初始化 Sentry
* 用于错误监控和性能追踪
*/
export const initSentry = () => {
if (!import.meta.env.VITE_SENTRY_DSN) {
console.warn("Sentry DSN 未配置,跳过初始化");
return;
}
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
environment: import.meta.env.MODE,
integrations: [
new Sentry.BrowserTracing({
tracingOrigins: [
"localhost",
import.meta.env.VITE_API_BASE_URL || "/api",
],
}),
new Sentry.Replay({
maskAllText: false,
blockAllMedia: false,
}),
],
tracesSampleRate: import.meta.env.MODE === "development" ? 1.0 : 0.1,
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
ignoreErrors: [
"NetworkError",
"Failed to fetch",
"chrome-extension://",
"moz-extension://",
],
});
};
/**
* 手动捕获错误
*/
export const captureError = (
error: Error,
context?: {
tags?: Record<string, string>;
extra?: Record<string, any>;
},
) => {
Sentry.captureException(error, {
tags: context?.tags,
extra: context?.extra,
});
};
/**
* 手动捕获消息
*/
export const captureMessage = (
message: string,
level: "info" | "warning" | "error" = "info",
context?: {
tags?: Record<string, string>;
extra?: Record<string, any>;
},
) => {
Sentry.captureMessage(message, {
level,
tags: context?.tags,
extra: context?.extra,
});
};
/**
* 添加性能面包屑
*/
export const addPerformanceBreadcrumb = (
message: string,
data?: Record<string, any>,
) => {
Sentry.addBreadcrumb({
category: "performance",
message,
level: "info",
data,
});
};

View File

@@ -0,0 +1,193 @@
# 性能优化快速参考指南
> 快速查看关键优化点和代码示例
## 🚀 快速开始
### 1. 安装依赖(如果需要)
```bash
# 检查 zustand 版本(需要 >= 5.0
npm list zustand
# 确认 react-window 已安装
npm list react-window
```
### 2. 创建目录结构
```bash
mkdir -p src/hooks/weChat
mkdir -p src/components/MessageRenderer
mkdir -p src/components/AiLoadingIndicator
```
---
## 📝 核心优化代码模板
### 1. Zustand Selector 优化模板
```typescript
// ❌ 错误写法(会导致多次重渲染)
const currentMessages = useWeChatStore(state => state.currentMessages);
const messagesLoading = useWeChatStore(state => state.messagesLoading);
// ✅ 正确写法(合并 selector使用 shallow
import { shallow } from "zustand/shallow";
const { currentMessages, messagesLoading } = useWeChatStore(
state => ({
currentMessages: state.currentMessages,
messagesLoading: state.messagesLoading,
}),
shallow, // 关键:使用 shallow 比较
);
```
### 2. React.memo 优化模板
```typescript
// ✅ 使用 React.memo 包装组件
const MyComponent: React.FC<Props> = React.memo(
({ prop1, prop2 }) => {
// 组件逻辑
},
(prev, next) => {
// 自定义比较函数
return prev.prop1.id === next.prop1.id;
},
);
MyComponent.displayName = "MyComponent";
```
### 3. useCallback 优化模板
```typescript
// ✅ 使用 useCallback 缓存函数
const handleClick = useCallback(
(id: number) => {
// 处理逻辑
},
[dependency1, dependency2], // 依赖项
);
// ✅ 使用 useRef 存储不变的引用
const contractRef = useRef(contract);
contractRef.current = contract; // 更新引用
const handleSend = useCallback(() => {
const currentContract = contractRef.current; // 使用引用
// 处理逻辑
}, []); // 不需要 contract 作为依赖
```
### 4. useMemo 优化模板
```typescript
// ✅ 使用 useMemo 缓存计算结果
const expensiveValue = useMemo(
() => {
// 复杂计算
return computeExpensiveValue(data);
},
[data], // 依赖项
);
```
### 5. 虚拟滚动模板
```typescript
import { FixedSizeList } from "react-window";
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={80}
width="100%"
itemData={items}
>
{({ index, style, data }) => (
<div style={style}>
<ItemComponent item={data[index]} />
</div>
)}
</FixedSizeList>
```
---
## 🔍 常见问题排查
### 问题 1组件仍然频繁重渲染
**检查**:
1. ✅ 是否使用了 `shallow` 比较?
2.`useCallback` 依赖项是否正确?
3.`React.memo` 比较函数是否正确?
### 问题 2虚拟滚动不工作
**检查**:
1. ✅ 容器高度是否设置?
2.`itemSize` 是否正确?
3.`itemCount` 是否正确?
### 问题 3性能没有提升
**检查**:
1. ✅ 是否使用了 React DevTools Profiler 测量?
2. ✅ 是否在开发模式下测试?(开发模式性能较差)
3. ✅ 是否有其他性能瓶颈?
---
## 📊 性能指标参考
### 优化前(基准)
- 100 条消息滚动:卡顿明显
- 组件重渲染:每次状态变化都重渲染
- 内存占用1000 条消息 > 200MB
### 优化后(目标)
- 100 条消息滚动流畅FPS > 30
- 组件重渲染:减少 60-80%
- 内存占用1000 条消息 < 100MB
---
## 🛠️ 调试工具
### React DevTools Profiler
1. 安装 React DevTools 浏览器扩展
2. 打开 Profiler 标签
3. 点击录制按钮
4. 执行操作
5. 停止录制查看性能数据
### Chrome Performance
1. 打开 Chrome DevTools
2. 切换到 Performance 标签
3. 点击录制按钮
4. 执行操作
5. 停止录制分析性能数据
---
## 📚 相关文档
- [详细改造计划](./性能优化详细改造计划.md)
- [优化清单](./性能优化改造清单.md)
- [React 代码规范](../提示词/React代码编写规范.md)
---
**最后更新**: 2025-01-XX

File diff suppressed because it is too large Load Diff