= ({
+ contact,
+ groups,
+ x,
+ y,
+ visible,
+ onClose,
+ onComplete,
+ onUpdateRemark,
+}) => {
+ const [remarkForm] = Form.useForm<{ remark: string }>();
+ const [moveForm] = Form.useForm<{ targetGroupId: number }>();
+ const [remarkModalVisible, setRemarkModalVisible] = useState(false);
+ const [moveModalVisible, setMoveModalVisible] = useState(false);
+ const [loading, setLoading] = useState(false);
+
+ // 初始化备注表单
+ useEffect(() => {
+ if (remarkModalVisible) {
+ remarkForm.setFieldsValue({
+ remark: contact.conRemark || "",
+ });
+ }
+ }, [remarkModalVisible, contact.conRemark, remarkForm]);
+
+ // 初始化移动分组表单
+ useEffect(() => {
+ if (moveModalVisible) {
+ // 找到联系人当前所在的分组
+ const currentGroup = groups.find(g => {
+ // 这里需要根据实际业务逻辑找到联系人所在的分组
+ // 暂时使用第一个匹配的分组
+ return true;
+ });
+ moveForm.setFieldsValue({
+ targetGroupId: currentGroup?.id || 0,
+ });
+ }
+ }, [moveModalVisible, groups, moveForm]);
+
+ // 处理修改备注
+ const handleEditRemark = () => {
+ setRemarkModalVisible(true);
+ onClose();
+ };
+
+ // 处理移动分组
+ const handleMoveToGroup = () => {
+ setMoveModalVisible(true);
+ onClose();
+ };
+
+ // 提交修改备注
+ const handleRemarkSubmit = async () => {
+ try {
+ const values = await remarkForm.validateFields();
+ setLoading(true);
+
+ if (onUpdateRemark) {
+ await onUpdateRemark(contact.id, values.remark);
+ message.success("修改备注成功");
+ } else {
+ message.warning("修改备注功能未实现");
+ }
+
+ setRemarkModalVisible(false);
+ remarkForm.resetFields();
+ onComplete?.();
+ } catch (error: any) {
+ if (error?.errorFields) {
+ // 表单验证错误,不显示错误消息
+ return;
+ }
+ console.error("修改备注失败:", error);
+ message.error(error?.message || "修改备注失败");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // 提交移动分组
+ const handleMoveSubmit = async () => {
+ try {
+ const values = await moveForm.validateFields();
+ setLoading(true);
+
+ await moveGroup({
+ type: contact.type === "group" ? "group" : "friend",
+ groupId: values.targetGroupId,
+ id: contact.id,
+ });
+
+ message.success("移动分组成功");
+ setMoveModalVisible(false);
+ moveForm.resetFields();
+ onComplete?.();
+ } catch (error: any) {
+ if (error?.errorFields) {
+ // 表单验证错误,不显示错误消息
+ return;
+ }
+ console.error("移动分组失败:", error);
+ message.error(error?.message || "移动分组失败");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // 菜单项
+ const menuItems = [
+ {
+ key: "remark",
+ label: "修改备注",
+ icon: ,
+ onClick: handleEditRemark,
+ },
+ {
+ key: "move",
+ label: "移动分组",
+ icon: ,
+ onClick: handleMoveToGroup,
+ },
+ ];
+
+ // 过滤分组选项(只显示相同类型的分组)
+ const filteredGroups = groups.filter(
+ g => g.groupType === (contact.type === "group" ? 2 : 1),
+ );
+
+ if (!visible) return null;
+
+ return (
+ <>
+ e.preventDefault()}
+ />
+
+
+ {/* 修改备注Modal */}
+
{
+ setRemarkModalVisible(false);
+ remarkForm.resetFields();
+ }}
+ confirmLoading={loading}
+ okText="确定"
+ cancelText="取消"
+ >
+
+
+
+
+
+
+ {/* 移动分组Modal */}
+
{
+ setMoveModalVisible(false);
+ moveForm.resetFields();
+ }}
+ confirmLoading={loading}
+ okText="确定"
+ cancelText="取消"
+ >
+
+
+
+
+
+ >
+ );
+};
diff --git a/Touchkebao/src/components/ErrorBoundary/index.tsx b/Touchkebao/src/components/ErrorBoundary/index.tsx
new file mode 100644
index 00000000..1ce6be38
--- /dev/null
+++ b/Touchkebao/src/components/ErrorBoundary/index.tsx
@@ -0,0 +1,173 @@
+/**
+ * 错误边界组件
+ * 用于捕获React组件树中的JavaScript错误,记录错误信息,并显示降级UI
+ */
+
+import React, { Component, ErrorInfo, ReactNode } from "react";
+import { Button, Result } from "antd";
+import { captureError } from "@/utils/sentry";
+import { performanceMonitor } from "@/utils/performance";
+
+interface Props {
+ children: ReactNode;
+ fallback?: ReactNode;
+ onError?: (error: Error, errorInfo: ErrorInfo) => void;
+}
+
+interface State {
+ hasError: boolean;
+ error: Error | null;
+ errorInfo: ErrorInfo | null;
+}
+
+/**
+ * 错误边界组件
+ * 使用类组件实现,因为React错误边界必须是类组件
+ */
+export class ErrorBoundary extends Component
{
+ constructor(props: Props) {
+ super(props);
+ this.state = {
+ hasError: false,
+ error: null,
+ errorInfo: null,
+ };
+ }
+
+ static getDerivedStateFromError(error: Error): State {
+ // 更新state,使下一次渲染能够显示降级UI
+ return {
+ hasError: true,
+ error,
+ errorInfo: null,
+ };
+ }
+
+ componentDidCatch(error: Error, errorInfo: ErrorInfo) {
+ // 记录错误信息
+ console.error("ErrorBoundary捕获到错误:", error, errorInfo);
+
+ // 性能监控:记录错误(使用measure方法)
+ performanceMonitor.measure(
+ "ErrorBoundary.error",
+ () => {
+ // 错误已记录,这里只是用于性能监控
+ },
+ {
+ error: error.message,
+ componentStack: errorInfo.componentStack,
+ errorBoundary: true,
+ },
+ );
+
+ // 更新state
+ this.setState({
+ error,
+ errorInfo,
+ });
+
+ // 调用自定义错误处理函数
+ if (this.props.onError) {
+ try {
+ this.props.onError(error, errorInfo);
+ } catch (onErrorError) {
+ console.error("onError回调函数执行失败:", onErrorError);
+ }
+ }
+
+ // 发送错误到Sentry(如果已配置)
+ try {
+ captureError(error, {
+ tags: {
+ errorBoundary: "true",
+ component: errorInfo.componentStack?.split("\n")[0] || "unknown",
+ },
+ extra: {
+ componentStack: errorInfo.componentStack,
+ errorInfo: errorInfo.toString(),
+ },
+ });
+ } catch (sentryError) {
+ console.warn("发送错误到Sentry失败:", sentryError);
+ }
+ }
+
+ handleReset = () => {
+ this.setState({
+ hasError: false,
+ error: null,
+ errorInfo: null,
+ });
+ };
+
+ render() {
+ if (this.state.hasError) {
+ // 如果提供了自定义fallback,使用它
+ if (this.props.fallback) {
+ return this.props.fallback;
+ }
+
+ // 默认错误UI
+ return (
+
+ 抱歉,应用遇到了一个错误。请尝试刷新页面或联系技术支持。
+ {import.meta.env.DEV && this.state.error && (
+
+
+ 错误详情(开发环境)
+
+
+ {this.state.error.toString()}
+ {this.state.errorInfo?.componentStack}
+
+
+ )}
+
+ }
+ extra={[
+ ,
+ ,
+ ]}
+ />
+ );
+ }
+
+ return this.props.children;
+ }
+}
+
+/**
+ * 带错误边界的HOC
+ */
+export function withErrorBoundary(
+ Component: React.ComponentType
,
+ fallback?: ReactNode,
+) {
+ return function WithErrorBoundaryComponent(props: P) {
+ return (
+
+
+
+ );
+ };
+}
diff --git a/Touchkebao/src/components/GroupContextMenu/index.module.scss b/Touchkebao/src/components/GroupContextMenu/index.module.scss
new file mode 100644
index 00000000..82be8b1d
--- /dev/null
+++ b/Touchkebao/src/components/GroupContextMenu/index.module.scss
@@ -0,0 +1,18 @@
+.contextMenuOverlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 999;
+ background: transparent;
+}
+
+.contextMenu {
+ min-width: 180px;
+ padding: 4px 0;
+ background: #ffffff;
+ border-radius: 6px;
+ box-shadow: 0 10px 30px rgba(15, 23, 42, 0.25);
+ border: 1px solid rgba(148, 163, 184, 0.5);
+}
diff --git a/Touchkebao/src/components/GroupContextMenu/index.tsx b/Touchkebao/src/components/GroupContextMenu/index.tsx
new file mode 100644
index 00000000..81b0e3ba
--- /dev/null
+++ b/Touchkebao/src/components/GroupContextMenu/index.tsx
@@ -0,0 +1,121 @@
+/**
+ * 分组右键菜单组件
+ * 仅负责右键菜单展示与事件派发,具体弹窗由上层组件实现
+ */
+
+import React from "react";
+import { Menu } from "antd";
+import { PlusOutlined, EditOutlined, DeleteOutlined } from "@ant-design/icons";
+import { ContactGroup } from "@/store/module/weChat/contacts.data";
+import styles from "./index.module.scss";
+
+/**
+ * 分组右键菜单Props
+ */
+export interface GroupContextMenuProps {
+ /** 当前分组(编辑/删除时使用) */
+ group?: ContactGroup;
+ /** 分组类型(新增时使用) */
+ groupType?: 1 | 2;
+ /** 菜单位置 */
+ x: number;
+ y: number;
+ /** 是否显示 */
+ visible: boolean;
+ /** 关闭菜单 */
+ onClose: () => void;
+ /** 操作完成回调 */
+ onComplete?: () => void;
+ /** 新增分组点击(由上层打开新增分组弹窗) */
+ onAddClick?: (groupType: 1 | 2) => void;
+ /** 编辑分组点击(由上层打开编辑分组弹窗) */
+ onEditClick?: (group: ContactGroup) => void;
+ /** 删除分组点击(由上层打开删除确认弹窗) */
+ onDeleteClick?: (group: ContactGroup) => void;
+}
+
+/**
+ * 分组右键菜单组件
+ */
+export const GroupContextMenu: React.FC = ({
+ group,
+ groupType = 1,
+ x,
+ y,
+ visible,
+ onClose,
+ onComplete,
+ onAddClick,
+ onEditClick,
+ onDeleteClick,
+}) => {
+ // 处理新增分组
+ const handleAdd = () => {
+ onClose();
+ onAddClick?.(groupType);
+ };
+
+ // 处理编辑分组
+ const handleEdit = () => {
+ if (!group) return;
+ onClose();
+ onEditClick?.(group);
+ };
+
+ // 处理删除分组
+ const handleDelete = () => {
+ if (!group) return;
+ onClose();
+ onDeleteClick?.(group);
+ };
+
+ // 菜单项
+ const menuItems = [
+ {
+ key: "add",
+ label: "新增分组",
+ icon: ,
+ onClick: handleAdd,
+ },
+ ...(group
+ ? [
+ {
+ key: "edit",
+ label: "编辑分组",
+ icon: ,
+ onClick: handleEdit,
+ },
+ {
+ key: "delete",
+ label: "删除分组",
+ icon: ,
+ danger: true,
+ onClick: handleDelete,
+ },
+ ]
+ : []),
+ ];
+
+ if (!visible) return null;
+
+ return (
+ <>
+ e.preventDefault()}
+ />
+
+ >
+ );
+};
diff --git a/Touchkebao/src/components/VirtualContactList/index.module.scss b/Touchkebao/src/components/VirtualContactList/index.module.scss
new file mode 100644
index 00000000..1a82d187
--- /dev/null
+++ b/Touchkebao/src/components/VirtualContactList/index.module.scss
@@ -0,0 +1,93 @@
+.virtualListContainer {
+ width: 100%;
+ position: relative;
+}
+
+.virtualList {
+ width: 100%;
+ height: 100%;
+}
+
+.groupHeader {
+ width: 100%;
+ padding: 0;
+ box-sizing: border-box;
+ cursor: pointer;
+ transition: background-color 0.2s;
+ border-bottom: 1px solid #f0f0f0;
+
+ &:hover {
+ background-color: rgba(0, 0, 0, 0.02);
+ }
+}
+
+.contactItem {
+ width: 100%;
+ padding: 0;
+ box-sizing: border-box;
+ cursor: pointer;
+ transition: background-color 0.2s;
+ border-bottom: 1px solid #f0f0f0;
+
+ &:hover {
+ background-color: rgba(0, 0, 0, 0.02);
+ }
+
+ &.selected {
+ background-color: rgba(24, 144, 255, 0.1);
+ }
+}
+
+.empty {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #999;
+}
+
+.emptyText {
+ font-size: 14px;
+}
+
+.loadingItem {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 20px;
+ box-sizing: border-box;
+ gap: 8px;
+}
+
+.loadingText {
+ font-size: 14px;
+ color: #999;
+}
+
+.loadMoreItem {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 10px;
+ box-sizing: border-box;
+ border-top: 1px solid #f0f0f0;
+}
+
+.loadMoreButton {
+ width: 100%;
+ color: #1890ff;
+ font-size: 14px;
+
+ &:hover {
+ color: #40a9ff;
+ }
+
+ &:active {
+ color: #096dd9;
+ }
+}
diff --git a/Touchkebao/src/components/VirtualContactList/index.tsx b/Touchkebao/src/components/VirtualContactList/index.tsx
new file mode 100644
index 00000000..57cea861
--- /dev/null
+++ b/Touchkebao/src/components/VirtualContactList/index.tsx
@@ -0,0 +1,412 @@
+/**
+ * 虚拟滚动联系人列表组件
+ * 使用react-window实现,支持分组展开/折叠和动态高度
+ *
+ * 性能优化:
+ * - 支持分组虚拟滚动
+ * - 动态高度处理(分组头部+联系人列表)
+ * - 只渲染可见区域的项
+ * - 支持分组内分页加载
+ */
+
+import React, { useMemo, useCallback, useRef, useState, useEffect } from "react";
+import { VariableSizeList, ListChildComponentProps } from "react-window";
+import { Spin, Button } from "antd";
+import { Contact } from "@/utils/db";
+import { ContactGroup, GroupContactData } from "@/store/module/weChat/contacts.data";
+import styles from "./index.module.scss";
+
+/**
+ * 分组头部高度(固定)
+ */
+const GROUP_HEADER_HEIGHT = 40;
+
+/**
+ * 联系人项高度(固定)
+ */
+const CONTACT_ITEM_HEIGHT = 60;
+
+/**
+ * Loading项高度(固定)
+ */
+const LOADING_ITEM_HEIGHT = 60;
+
+/**
+ * 加载更多按钮高度(固定)
+ */
+const LOAD_MORE_ITEM_HEIGHT = 50;
+
+/**
+ * 可见区域缓冲项数
+ */
+const OVERSCAN_COUNT = 2;
+
+/**
+ * 虚拟滚动项类型
+ */
+type VirtualItem =
+ | { type: "group"; data: ContactGroup; index: number }
+ | { type: "loading"; groupIndex: number; groupKey: string }
+ | { type: "contact"; data: Contact; groupIndex: number; contactIndex: number }
+ | { type: "loadMore"; groupIndex: number; groupId: number; groupType: 1 | 2; groupKey: string };
+
+/**
+ * 虚拟滚动联系人列表Props
+ */
+export interface VirtualContactListProps {
+ /** 分组列表 */
+ groups: ContactGroup[];
+ /** 展开的分组Key集合 */
+ expandedGroups: Set
;
+ /** 分组数据Map */
+ groupData: Map;
+ /** 生成分组Key的函数 */
+ getGroupKey: (groupId: number, groupType: 1 | 2, accountId: number) => string;
+ /** 当前选中的账号ID */
+ selectedAccountId: number;
+ /** 容器高度 */
+ containerHeight?: number;
+ /** 当前选中的联系人ID */
+ selectedContactId?: number;
+ /** 渲染分组头部 */
+ renderGroupHeader: (group: ContactGroup, isExpanded: boolean) => React.ReactNode;
+ /** 渲染联系人项 */
+ renderContact: (contact: Contact, groupIndex: number, contactIndex: number) => React.ReactNode;
+ /** 点击分组头部(展开/折叠) */
+ onGroupToggle?: (groupId: number, groupType: 1 | 2) => void;
+ /** 点击联系人项 */
+ onContactClick?: (contact: Contact) => void;
+ /** 右键菜单(分组) */
+ onGroupContextMenu?: (e: React.MouseEvent, group: ContactGroup) => void;
+ /** 右键菜单(联系人) */
+ onContactContextMenu?: (e: React.MouseEvent, contact: Contact) => void;
+ /** 滚动事件 */
+ onScroll?: (scrollTop: number) => void;
+ /** 分组内滚动到底部时触发(用于加载更多) */
+ onGroupLoadMore?: (groupId: number, groupType: 1 | 2) => void;
+ /** 滚动到底部的阈值 */
+ loadMoreThreshold?: number;
+ /** 自定义类名 */
+ className?: string;
+}
+
+/**
+ * 虚拟滚动联系人列表组件
+ */
+export const VirtualContactList: React.FC = ({
+ groups,
+ expandedGroups,
+ groupData,
+ getGroupKey,
+ selectedAccountId,
+ containerHeight,
+ selectedContactId,
+ renderGroupHeader,
+ renderContact,
+ onGroupToggle,
+ onContactClick,
+ onGroupContextMenu,
+ onContactContextMenu,
+ onScroll,
+ onGroupLoadMore,
+ loadMoreThreshold = 100,
+ className,
+}) => {
+ const listRef = useRef(null);
+ const itemHeightsRef = useRef
),
},
-
- {
- key: "settings",
- icon: ,
- label: "全局配置",
- onClick: () => {
- navigate("/pc/commonConfig");
- },
- },
+ // {
+ // key: "settings",
+ // icon: ,
+ // label: "全局配置",
+ // onClick: () => {
+ // navigate("/pc/commonConfig");
+ // },
+ // },
{
key: "clearCache",
icon: ,
@@ -170,6 +170,9 @@ const NavCommon: React.FC = ({ title = "触客宝" }) => {
const handleContentManagementClick = () => {
navigate("/pc/powerCenter/content-management");
};
+ const handleAiClick = () => {
+ navigate("/pc/commonConfig");
+ };
return (
<>
@@ -179,7 +182,7 @@ const NavCommon: React.FC = ({ title = "触客宝" }) => {
type="primary"
onClick={handleMenuClick}
>
-
+ } onClick={handleAiClick}>
}
onClick={handleContentManagementClick}
diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/com.module.scss b/Touchkebao/src/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/com.module.scss
index 5d0fb1f1..5d128d1d 100644
--- a/Touchkebao/src/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/com.module.scss
+++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/com.module.scss
@@ -1,7 +1,23 @@
.messageList {
height: 100%;
- overflow-y: auto;
+ overflow: hidden;
position: relative;
+ display: flex;
+ flex-direction: column;
+
+ .virtualList {
+ flex: 1;
+ overflow: hidden;
+ }
+
+ .emptyList {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #999;
+ font-size: 14px;
+ }
.messageItem {
padding: 12px 16px;
diff --git a/Touchkebao/src/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/index.tsx b/Touchkebao/src/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/index.tsx
index b8dce346..fa0275c2 100644
--- a/Touchkebao/src/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/index.tsx
+++ b/Touchkebao/src/pages/pc/ckbox/weChat/components/SidebarMenu/MessageList/index.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useState, useRef } from "react";
+import React, { useEffect, useState, useRef, useCallback } from "react";
import { List, Avatar, Badge, Modal, Input, message } from "antd";
import {
UserOutlined,
@@ -28,6 +28,7 @@ import { ContactManager } from "@/utils/dbAction/contact";
import { formatWechatTime } from "@/utils/common";
import { messageFilter } from "@/utils/filter";
import { ChatSession } from "@/utils/db";
+import { VirtualSessionList } from "@/components/VirtualSessionList";
interface MessageListProps {}
interface SessionItemProps {
@@ -40,7 +41,7 @@ interface SessionItemProps {
const SessionItem: React.FC = React.memo(
({ session, isActive, onClick, onContextMenu }) => {
return (
- = React.memo(