gogoo
This commit is contained in:
135
Touchkebao/package-lock.json
generated
135
Touchkebao/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
1300
Touchkebao/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
95
Touchkebao/src/hooks/weChat/useChatMessages.ts
Normal file
95
Touchkebao/src/hooks/weChat/useChatMessages.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
31
Touchkebao/src/hooks/weChat/useMessageGrouping.ts
Normal file
31
Touchkebao/src/hooks/weChat/useMessageGrouping.ts
Normal 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]);
|
||||
};
|
||||
447
Touchkebao/src/hooks/weChat/useMessageParser.tsx
Normal file
447
Touchkebao/src/hooks/weChat/useMessageParser.tsx
Normal 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,
|
||||
};
|
||||
};
|
||||
129
Touchkebao/src/hooks/weChat/useWeChatSelectors.ts
Normal file
129
Touchkebao/src/hooks/weChat/useWeChatSelectors.ts
Normal 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));
|
||||
};
|
||||
@@ -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>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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辅助
|
||||
|
||||
@@ -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]);
|
||||
|
||||
// 过滤联系人 - 支持名称和拼音搜索
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
27
Touchkebao/src/providers/QueryProvider.tsx
Normal file
27
Touchkebao/src/providers/QueryProvider.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
87
Touchkebao/src/utils/sentry/index.ts
Normal file
87
Touchkebao/src/utils/sentry/index.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
193
Touchkebao/提示词/性能优化快速参考.md
Normal file
193
Touchkebao/提示词/性能优化快速参考.md
Normal 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
|
||||
2985
Touchkebao/提示词/性能优化详细改造计划.md
Normal file
2985
Touchkebao/提示词/性能优化详细改造计划.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user