= ({
+ contact,
+ groups,
+ x,
+ y,
+ visible,
+ onClose,
+ onComplete,
+ onUpdateRemark,
+ onMoveGroup,
+}) => {
+ 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, 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);
+
+ if (onMoveGroup) {
+ await onMoveGroup(contact, values.targetGroupId);
+ message.success("移动分组成功");
+ } else {
+ message.warning("移动分组功能未实现");
+ }
+ 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),
+ );
+
+ return (
+ <>
+ {visible && (
+ <>
+ 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..f9f194bf
--- /dev/null
+++ b/Touchkebao/src/components/GroupContextMenu/index.module.scss
@@ -0,0 +1,17 @@
+.contextMenuOverlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 999;
+ background: transparent;
+}
+
+.contextMenu {
+ min-width: 120px;
+ padding: 2px 0;
+ background: #ffffff;
+ border-radius: 6px;
+ border: 1px solid rgba(148, 163, 184, 0.4);
+}
diff --git a/Touchkebao/src/components/GroupContextMenu/index.tsx b/Touchkebao/src/components/GroupContextMenu/index.tsx
new file mode 100644
index 00000000..d1e62a40
--- /dev/null
+++ b/Touchkebao/src/components/GroupContextMenu/index.tsx
@@ -0,0 +1,148 @@
+/**
+ * 分组右键菜单组件
+ * 仅负责右键菜单展示与事件派发,具体弹窗由上层组件实现
+ */
+
+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;
+ /** 遮罩层右键事件回调(用于在新位置打开菜单) */
+ onOverlayContextMenu?: (e: React.MouseEvent) => void;
+}
+
+/**
+ * 分组右键菜单组件
+ */
+export const GroupContextMenu: React.FC = ({
+ group,
+ groupType = 1,
+ x,
+ y,
+ visible,
+ onClose,
+ onComplete,
+ onAddClick,
+ onEditClick,
+ onDeleteClick,
+ onOverlayContextMenu,
+}) => {
+ // 默认群分组 / 未分组:id 为 0,且 groupType 为 1 或 2
+ const isDefaultOrUngrouped =
+ !!group &&
+ group.id === 0 &&
+ (group.groupType === 1 || group.groupType === 2);
+
+ // 处理新增分组
+ const handleAdd = () => {
+ onClose();
+ onAddClick?.(groupType);
+ };
+
+ // 处理编辑分组
+ const handleEdit = () => {
+ if (!group || isDefaultOrUngrouped) return;
+ onClose();
+ onEditClick?.(group);
+ };
+
+ // 处理删除分组
+ const handleDelete = () => {
+ if (!group || isDefaultOrUngrouped) return;
+ onClose();
+ onDeleteClick?.(group);
+ };
+
+ // 菜单项
+ const menuItems = [
+ {
+ key: "add",
+ label: "新增分组",
+ icon: ,
+ onClick: handleAdd,
+ },
+ ...(group
+ ? [
+ {
+ key: "edit",
+ label: "编辑分组",
+ icon: ,
+ onClick: handleEdit,
+ disabled: isDefaultOrUngrouped,
+ },
+ {
+ key: "delete",
+ label: "删除分组",
+ icon: ,
+ danger: true,
+ onClick: handleDelete,
+ disabled: isDefaultOrUngrouped,
+ },
+ ]
+ : []),
+ ];
+
+ if (!visible) return null;
+
+ // 处理遮罩层右键事件:关闭菜单并通知父组件处理新位置的右键事件
+ const handleOverlayContextMenu = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ // 关闭当前菜单
+ onClose();
+ // 通知父组件处理新位置的右键事件
+ if (onOverlayContextMenu) {
+ // 使用 setTimeout 确保菜单先关闭,然后再处理新事件
+ setTimeout(() => {
+ onOverlayContextMenu(e);
+ }, 0);
+ }
+ };
+
+ return (
+ <>
+
+
+ >
+ );
+};
diff --git a/Touchkebao/src/components/VirtualContactList/index.module.scss b/Touchkebao/src/components/VirtualContactList/index.module.scss
new file mode 100644
index 00000000..df6f3af7
--- /dev/null
+++ b/Touchkebao/src/components/VirtualContactList/index.module.scss
@@ -0,0 +1,80 @@
+.virtualListContainer {
+ width: 100%;
+ position: relative;
+}
+
+.virtualList {
+ width: 100%;
+ height: 100%;
+ overflow: inherit !important;
+}
+
+.groupHeader {
+ width: 100%;
+ padding: 0;
+ box-sizing: border-box;
+ cursor: pointer;
+ /* 样式由具体业务组件(如 WechatFriends/com.module.scss)控制,这里只负责布局和点击区域 */
+}
+
+.contactItem {
+ width: 100%;
+ padding: 0;
+ box-sizing: border-box;
+ cursor: pointer;
+ /* 避免样式穿透:不在这里设置边框/背景,全部交由内部渲染的联系人项样式控制 */
+}
+
+.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..377e1583
--- /dev/null
+++ b/Touchkebao/src/components/VirtualContactList/index.tsx
@@ -0,0 +1,437 @@
+/**
+ * 虚拟滚动联系人列表组件
+ * 使用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 ROW_HEIGHT = 60;
+
+/**
+ * 可见区域缓冲项数
+ */
+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