增强应用组件中的ErrorFallback UI,并更新SidebarMenu以实现新的联系人存储集成。重构微信组件,支持虚拟滚动,提升性能。在微信好友组件中实现新的群组管理功能。

This commit is contained in:
乘风
2025-12-16 16:24:10 +08:00
parent 3720987997
commit 631e8d00e2
34 changed files with 8406 additions and 408 deletions

View File

@@ -4,10 +4,103 @@ 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
style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
background:
"radial-gradient(circle at top, #e0f2fe 0, #f9fafb 45%, #f1f5f9 100%)",
color: "#0f172a",
fontFamily:
"-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif",
}}
>
<div
style={{
background: "rgba(255,255,255,0.9)",
boxShadow: "0 20px 45px rgba(15,23,42,0.18)",
borderRadius: 16,
padding: "32px 40px",
maxWidth: 480,
width: "90%",
textAlign: "center",
border: "1px solid rgba(148,163,184,0.35)",
backdropFilter: "blur(8px)",
}}
>
<div
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 56,
height: 56,
borderRadius: "999px",
background: "rgba(248, 113, 113, 0.06)",
marginBottom: 16,
}}
>
<span
style={{
fontSize: 30,
lineHeight: 1,
color: "#f97373",
}}
>
!
</span>
</div>
<h2
style={{
fontSize: 20,
marginBottom: 8,
fontWeight: 600,
}}
>
</h2>
<p
style={{
fontSize: 14,
color: "#64748b",
marginBottom: 24,
}}
>
</p>
<button
onClick={() => window.location.reload()}
style={{
padding: "8px 20px",
borderRadius: 999,
border: "none",
cursor: "pointer",
background:
"linear-gradient(135deg, #0ea5e9 0%, #2563eb 50%, #4f46e5 100%)",
color: "#ffffff",
fontSize: 14,
fontWeight: 500,
boxShadow: "0 12px 25px rgba(37,99,235,0.35)",
transition: "transform 0.1s ease, box-shadow 0.1s ease",
}}
onMouseOver={e => {
(e.currentTarget as HTMLButtonElement).style.transform =
"translateY(-1px)";
(e.currentTarget as HTMLButtonElement).style.boxShadow =
"0 16px 30px rgba(37,99,235,0.4)";
}}
onMouseOut={e => {
(e.currentTarget as HTMLButtonElement).style.transform =
"translateY(0)";
(e.currentTarget as HTMLButtonElement).style.boxShadow =
"0 12px 25px rgba(37,99,235,0.35)";
}}
>
</button>
</div>
</div>
);

View File

@@ -0,0 +1,15 @@
.contextMenuOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 999;
background: transparent;
}
.contextMenu {
min-width: 150px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
border-radius: 4px;
}

View File

@@ -0,0 +1,255 @@
/**
* 联系人右键菜单组件
* 支持修改备注、移动分组操作
*/
import React, { useState, useEffect } from "react";
import { Menu, Modal, Input, Form, Select, message } from "antd";
import { EditOutlined, SwapOutlined } from "@ant-design/icons";
import { Contact } from "@/utils/db";
import { ContactGroup } from "@/store/module/weChat/contacts.data";
import { moveGroup } from "@/api/module/group";
import styles from "./index.module.scss";
/**
* 联系人右键菜单Props
*/
export interface ContactContextMenuProps {
/** 当前联系人 */
contact: Contact;
/** 所有分组列表 */
groups: ContactGroup[];
/** 菜单位置 */
x: number;
y: number;
/** 是否显示 */
visible: boolean;
/** 关闭菜单 */
onClose: () => void;
/** 操作完成回调 */
onComplete?: () => void;
/** 修改备注回调 */
onUpdateRemark?: (contactId: number, remark: string) => Promise<void>;
}
/**
* 联系人编辑表单数据
*/
interface ContactFormData {
remark: string;
targetGroupId: number;
}
/**
* 联系人右键菜单组件
*/
export const ContactContextMenu: React.FC<ContactContextMenuProps> = ({
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: <EditOutlined />,
onClick: handleEditRemark,
},
{
key: "move",
label: "移动分组",
icon: <SwapOutlined />,
onClick: handleMoveToGroup,
},
];
// 过滤分组选项(只显示相同类型的分组)
const filteredGroups = groups.filter(
g => g.groupType === (contact.type === "group" ? 2 : 1),
);
if (!visible) return null;
return (
<>
<div
className={styles.contextMenuOverlay}
onClick={onClose}
onContextMenu={e => e.preventDefault()}
/>
<Menu
className={styles.contextMenu}
style={{
position: "fixed",
left: x,
top: y,
zIndex: 1000,
}}
items={menuItems}
onClick={onClose}
/>
{/* 修改备注Modal */}
<Modal
title="修改备注"
open={remarkModalVisible}
onOk={handleRemarkSubmit}
onCancel={() => {
setRemarkModalVisible(false);
remarkForm.resetFields();
}}
confirmLoading={loading}
okText="确定"
cancelText="取消"
>
<Form form={remarkForm} layout="vertical">
<Form.Item
name="remark"
label="备注名称"
rules={[{ max: 20, message: "备注名称不能超过20个字符" }]}
>
<Input
placeholder="请输入备注名称"
maxLength={20}
showCount
/>
</Form.Item>
</Form>
</Modal>
{/* 移动分组Modal */}
<Modal
title="移动分组"
open={moveModalVisible}
onOk={handleMoveSubmit}
onCancel={() => {
setMoveModalVisible(false);
moveForm.resetFields();
}}
confirmLoading={loading}
okText="确定"
cancelText="取消"
>
<Form form={moveForm} layout="vertical">
<Form.Item
name="targetGroupId"
label="目标分组"
rules={[{ required: true, message: "请选择目标分组" }]}
>
<Select placeholder="请选择目标分组">
{filteredGroups.map(group => (
<Select.Option key={group.id} value={group.id}>
{group.groupName}
</Select.Option>
))}
</Select>
</Form.Item>
</Form>
</Modal>
</>
);
};

View File

@@ -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<Props, State> {
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 (
<Result
status="error"
title="出现了一些问题"
subTitle={
<div>
<p></p>
{import.meta.env.DEV && this.state.error && (
<details style={{ marginTop: 16, textAlign: "left" }}>
<summary style={{ cursor: "pointer", marginBottom: 8 }}>
</summary>
<pre
style={{
background: "#f5f5f5",
padding: 12,
borderRadius: 4,
overflow: "auto",
fontSize: 12,
}}
>
{this.state.error.toString()}
{this.state.errorInfo?.componentStack}
</pre>
</details>
)}
</div>
}
extra={[
<Button
type="primary"
key="reload"
onClick={() => window.location.reload()}
>
</Button>,
<Button key="reset" onClick={this.handleReset}>
</Button>,
]}
/>
);
}
return this.props.children;
}
}
/**
* 带错误边界的HOC
*/
export function withErrorBoundary<P extends object>(
Component: React.ComponentType<P>,
fallback?: ReactNode,
) {
return function WithErrorBoundaryComponent(props: P) {
return (
<ErrorBoundary fallback={fallback}>
<Component {...props} />
</ErrorBoundary>
);
};
}

View File

@@ -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);
}

View File

@@ -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<GroupContextMenuProps> = ({
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: <PlusOutlined />,
onClick: handleAdd,
},
...(group
? [
{
key: "edit",
label: "编辑分组",
icon: <EditOutlined />,
onClick: handleEdit,
},
{
key: "delete",
label: "删除分组",
icon: <DeleteOutlined />,
danger: true,
onClick: handleDelete,
},
]
: []),
];
if (!visible) return null;
return (
<>
<div
className={styles.contextMenuOverlay}
onClick={onClose}
onContextMenu={e => e.preventDefault()}
/>
<Menu
className={styles.contextMenu}
style={{
position: "fixed",
left: x,
top: y,
zIndex: 1000,
}}
items={menuItems}
onClick={onClose}
/>
</>
);
};

View File

@@ -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;
}
}

View File

@@ -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<string>;
/** 分组数据Map */
groupData: Map<string, GroupContactData>;
/** 生成分组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<VirtualContactListProps> = ({
groups,
expandedGroups,
groupData,
getGroupKey,
selectedAccountId,
containerHeight,
selectedContactId,
renderGroupHeader,
renderContact,
onGroupToggle,
onContactClick,
onGroupContextMenu,
onContactContextMenu,
onScroll,
onGroupLoadMore,
loadMoreThreshold = 100,
className,
}) => {
const listRef = useRef<VariableSizeList>(null);
const itemHeightsRef = useRef<Map<number, number>>(new Map());
const scrollOffsetRef = useRef<number>(0);
// 构建虚拟滚动项列表
const virtualItems = useMemo(() => {
const items: VirtualItem[] = [];
groups.forEach((group, groupIndex) => {
// 添加分组头部
items.push({
type: "group",
data: group,
index: groupIndex,
});
// 如果分组展开添加联系人项或loading项
const groupKey = getGroupKey(group.id, group.groupType, selectedAccountId);
if (expandedGroups.has(groupKey)) {
const groupDataItem = groupData.get(groupKey);
if (groupDataItem) {
// 如果正在加载显示loading项
if (groupDataItem.loading && (!groupDataItem.loaded || groupDataItem.contacts.length === 0)) {
items.push({
type: "loading",
groupIndex,
groupKey,
});
} else if (groupDataItem.loaded && groupDataItem.contacts.length > 0) {
// 如果已加载,显示联系人项
groupDataItem.contacts.forEach((contact, contactIndex) => {
items.push({
type: "contact",
data: contact,
groupIndex,
contactIndex,
});
});
// 如果有更多数据,显示"加载更多"按钮加载中时按钮会显示loading状态
if (groupDataItem.hasMore) {
items.push({
type: "loadMore",
groupIndex,
groupId: group.id,
groupType: group.groupType,
groupKey,
});
}
}
} else {
// 如果分组数据不存在显示loading项
items.push({
type: "loading",
groupIndex,
groupKey,
});
}
}
});
return items;
}, [groups, expandedGroups, groupData, getGroupKey, selectedAccountId]);
// 计算每项的高度
const getItemSize = useCallback(
(index: number): number => {
const item = virtualItems[index];
if (!item) return CONTACT_ITEM_HEIGHT;
if (item.type === "group") {
return GROUP_HEADER_HEIGHT;
} else if (item.type === "loading") {
return LOADING_ITEM_HEIGHT;
} else if (item.type === "loadMore") {
return LOAD_MORE_ITEM_HEIGHT;
} else {
// 联系人项,使用固定高度或缓存的高度
const cachedHeight = itemHeightsRef.current.get(index);
return cachedHeight || CONTACT_ITEM_HEIGHT;
}
},
[virtualItems],
);
// 计算总高度
const totalHeight = useMemo(() => {
let height = 0;
for (let i = 0; i < virtualItems.length; i++) {
height += getItemSize(i);
}
return height;
}, [virtualItems, getItemSize]);
// 如果没有指定容器高度,使用总高度(不限制高度)
// 确保至少有一个最小高度,避免渲染错误
const actualContainerHeight = containerHeight ?? (totalHeight || 1);
// 滚动事件处理react-window的onScroll回调参数格式
const handleScroll = useCallback(
(props: {
scrollDirection: "forward" | "backward";
scrollOffset: number;
scrollUpdateWasRequested: boolean;
}) => {
const scrollTop = props.scrollOffset;
// 保存滚动位置
scrollOffsetRef.current = scrollTop;
onScroll?.(scrollTop);
// 检查是否需要加载更多
if (onGroupLoadMore && listRef.current) {
const currentTotalHeight = totalHeight;
const distanceToBottom = currentTotalHeight - scrollTop - containerHeight;
if (distanceToBottom < loadMoreThreshold) {
// 找到最后一个可见的分组,触发加载更多
// 简化处理:找到最后一个展开的分组
for (let i = groups.length - 1; i >= 0; i--) {
const group = groups[i];
const groupKey = getGroupKey(group.id, group.groupType, selectedAccountId);
if (expandedGroups.has(groupKey)) {
onGroupLoadMore(group.id, group.groupType);
break;
}
}
}
}
},
[
onScroll,
onGroupLoadMore,
loadMoreThreshold,
totalHeight,
containerHeight,
groups,
expandedGroups,
getGroupKey,
selectedAccountId,
],
);
// 渲染单个项
const Row = useCallback(
({ index, style }: ListChildComponentProps) => {
const item = virtualItems[index];
if (!item) return null;
if (item.type === "group") {
const group = item.data;
const groupKey = getGroupKey(group.id, group.groupType, selectedAccountId);
const isExpanded = expandedGroups.has(groupKey);
return (
<div
style={style}
className={styles.groupHeader}
onClick={() => onGroupToggle?.(group.id, group.groupType)}
onContextMenu={e => onGroupContextMenu?.(e, group)}
>
{renderGroupHeader(group, isExpanded)}
</div>
);
} else if (item.type === "loading") {
return (
<div style={style} className={styles.loadingItem}>
<Spin size="small" />
<span className={styles.loadingText}>...</span>
</div>
);
} else if (item.type === "loadMore") {
const groupDataItem = groupData.get(item.groupKey);
const isLoading = groupDataItem?.loading || false;
return (
<div style={style} className={styles.loadMoreItem}>
<Button
type="link"
size="small"
loading={isLoading}
onClick={() => {
if (!isLoading && onGroupLoadMore) {
onGroupLoadMore(item.groupId, item.groupType);
}
}}
className={styles.loadMoreButton}
>
{isLoading ? "加载中..." : "加载更多"}
</Button>
</div>
);
} else {
const contact = item.data;
const isSelected = selectedContactId === contact.id;
return (
<div
style={style}
className={`${styles.contactItem} ${isSelected ? styles.selected : ""}`}
onClick={() => onContactClick?.(contact)}
onContextMenu={e => onContactContextMenu?.(e, contact)}
>
{renderContact(contact, item.groupIndex, item.contactIndex)}
</div>
);
}
},
[
virtualItems,
getGroupKey,
selectedAccountId,
expandedGroups,
selectedContactId,
renderGroupHeader,
renderContact,
onGroupToggle,
onContactClick,
onGroupContextMenu,
onContactContextMenu,
groupData,
onGroupLoadMore,
],
);
// 保存前一个 virtualItems 的长度,用于检测是否只是添加了新项
const prevItemsLengthRef = useRef<number>(0);
const prevGroupDataRef = useRef<Map<string, GroupContactData>>(new Map());
// 重置缓存的高度(当数据变化时)
useEffect(() => {
const currentItemsLength = virtualItems.length;
const prevItemsLength = prevItemsLengthRef.current;
// 如果只是添加了新项(加载更多),只重置新增项之后的高度缓存
// 如果项数减少或大幅变化,重置所有缓存
if (currentItemsLength > prevItemsLength) {
// 只重置新增项之后的高度缓存,保持滚动位置
if (listRef.current && prevItemsLength > 0) {
listRef.current.resetAfterIndex(prevItemsLength, false);
}
} else if (currentItemsLength !== prevItemsLength) {
// 项数减少或变化较大,重置所有缓存
itemHeightsRef.current.clear();
if (listRef.current) {
listRef.current.resetAfterIndex(0);
}
}
prevItemsLengthRef.current = currentItemsLength;
prevGroupDataRef.current = new Map(groupData);
}, [virtualItems.length, groupData]);
// 当数据更新后,恢复滚动位置(仅在添加新项时恢复)
const prevItemsLengthForScrollRef = useRef<number>(0);
useEffect(() => {
const currentItemsLength = virtualItems.length;
const prevItemsLength = prevItemsLengthForScrollRef.current;
// 只在添加新项时恢复滚动位置(加载更多场景)
if (listRef.current && scrollOffsetRef.current > 0 && currentItemsLength > prevItemsLength) {
// 使用 requestAnimationFrame 确保在渲染后恢复滚动位置
requestAnimationFrame(() => {
if (listRef.current && scrollOffsetRef.current > 0) {
// 使用 scrollToItem 或 scrollToOffset 恢复位置
// 注意:这里不触发 onScroll 回调,避免循环
listRef.current.scrollTo(scrollOffsetRef.current);
}
});
}
prevItemsLengthForScrollRef.current = currentItemsLength;
}, [virtualItems.length]);
// 如果没有数据,显示空状态
if (groups.length === 0) {
return (
<div className={`${styles.empty} ${className || ""}`}>
<div className={styles.emptyText}></div>
</div>
);
}
return (
<div className={`${styles.virtualListContainer} ${className || ""}`}>
<VariableSizeList
ref={listRef}
height={actualContainerHeight}
itemCount={virtualItems.length}
itemSize={getItemSize}
width="100%"
overscanCount={OVERSCAN_COUNT}
onScroll={handleScroll}
className={styles.virtualList}
>
{Row}
</VariableSizeList>
</div>
);
};

View File

@@ -0,0 +1,40 @@
.virtualListContainer {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
.virtualList {
width: 100%;
height: 100%;
}
.virtualItem {
width: 100%;
padding: 0;
box-sizing: border-box;
cursor: pointer;
transition: background-color 0.2s;
&: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;
}

View File

@@ -0,0 +1,191 @@
/**
* 虚拟滚动会话列表组件
* 使用react-window实现只渲染可见区域的会话项
*
* 性能优化:
* - 固定高度虚拟滚动ITEM_HEIGHT = 72px
* - 只渲染可见区域的10-20条数据
* - 支持滚动加载更多
*/
import React, { useMemo, useCallback, useRef, useEffect } from "react";
import { FixedSizeList, ListChildComponentProps } from "react-window";
import { ChatSession } from "@/utils/db";
import styles from "./index.module.scss";
/**
* 会话项高度(固定)
*/
const ITEM_HEIGHT = 72;
/**
* 可见区域缓冲项数上下各多渲染2项
*/
const OVERSCAN_COUNT = 2;
/**
* 虚拟滚动会话列表Props
*/
export interface VirtualSessionListProps {
/** 会话列表数据 */
sessions: ChatSession[];
/** 容器高度 */
containerHeight?: number;
/** 当前选中的会话ID */
selectedSessionId?: number;
/** 渲染会话项 */
renderItem: (session: ChatSession, index: number) => React.ReactNode;
/** 点击会话项 */
onItemClick?: (session: ChatSession) => void;
/** 右键菜单 */
onItemContextMenu?: (e: React.MouseEvent, session: ChatSession) => void;
/** 滚动事件 */
onScroll?: (scrollTop: number) => void;
/** 滚动到底部时触发(用于加载更多) */
onLoadMore?: () => void;
/** 滚动到底部的阈值(距离底部多少像素时触发加载更多) */
loadMoreThreshold?: number;
/** 自定义类名 */
className?: string;
}
/**
* 虚拟滚动会话列表组件
*/
export const VirtualSessionList: React.FC<VirtualSessionListProps> = ({
sessions,
containerHeight = 600,
selectedSessionId,
renderItem,
onItemClick,
onItemContextMenu,
onScroll,
onLoadMore,
loadMoreThreshold = 100,
className,
}) => {
const listRef = useRef<FixedSizeList>(null);
const scrollTopRef = useRef(0);
// 计算可见项数
const visibleCount = useMemo(() => {
return Math.ceil(containerHeight / ITEM_HEIGHT) + OVERSCAN_COUNT * 2;
}, [containerHeight]);
// 滚动事件处理react-window的onScroll回调参数格式
const handleScroll = useCallback(
(props: {
scrollDirection: "forward" | "backward";
scrollOffset: number;
scrollUpdateWasRequested: boolean;
}) => {
const scrollTop = props.scrollOffset;
scrollTopRef.current = scrollTop;
// 触发滚动事件
onScroll?.(scrollTop);
// 检查是否滚动到底部
if (onLoadMore && listRef.current) {
const totalHeight = sessions.length * ITEM_HEIGHT;
const distanceToBottom = totalHeight - scrollTop - containerHeight;
if (distanceToBottom < loadMoreThreshold) {
onLoadMore();
}
}
},
[onScroll, onLoadMore, loadMoreThreshold, sessions.length, containerHeight],
);
// 会话项组件使用React.memo优化
const SessionRow = React.memo<ListChildComponentProps>(
({ index, style }: ListChildComponentProps) => {
const session = sessions[index];
if (!session) return null;
const isSelected = selectedSessionId === session.id;
return (
<div
style={style}
className={`${styles.virtualItem} ${isSelected ? styles.selected : ""}`}
onClick={() => onItemClick?.(session)}
onContextMenu={e => onItemContextMenu?.(e, session)}
>
{renderItem(session, index)}
</div>
);
},
(prevProps, nextProps) => {
// 自定义比较函数,只在会话数据或选中状态变化时重渲染
const prevSession = sessions[prevProps.index];
const nextSession = sessions[nextProps.index];
const prevSelected = selectedSessionId === prevSession?.id;
const nextSelected = selectedSessionId === nextSession?.id;
return (
prevProps.index === nextProps.index &&
prevSelected === nextSelected &&
prevSession?.id === nextSession?.id &&
prevSession?.lastUpdateTime === nextSession?.lastUpdateTime
);
},
);
// 渲染单个会话项
const Row = useCallback(
(props: ListChildComponentProps) => <SessionRow {...props} />,
[SessionRow],
);
// 滚动到指定会话
const scrollToSession = useCallback(
(sessionId: number) => {
const index = sessions.findIndex(s => s.id === sessionId);
if (index !== -1 && listRef.current) {
listRef.current.scrollToItem(index, "smart");
}
},
[sessions],
);
// 暴露滚动方法通过ref
useEffect(() => {
if (listRef.current && selectedSessionId) {
scrollToSession(selectedSessionId);
}
}, [selectedSessionId, scrollToSession]);
// 如果没有数据,显示空状态
if (sessions.length === 0) {
return (
<div className={`${styles.empty} ${className || ""}`}>
<div className={styles.emptyText}></div>
</div>
);
}
return (
<div className={`${styles.virtualListContainer} ${className || ""}`}>
<FixedSizeList
ref={listRef}
height={containerHeight}
itemCount={sessions.length}
itemSize={ITEM_HEIGHT}
width="100%"
overscanCount={OVERSCAN_COUNT}
onScroll={handleScroll}
className={styles.virtualList}
>
{Row}
</FixedSizeList>
</div>
);
};
// 导出滚动方法类型
export type VirtualSessionListRef = {
scrollToSession: (sessionId: number) => void;
scrollToIndex: (index: number) => void;
};

View File

@@ -10,10 +10,10 @@ const CommonConfig: React.FC = () => {
const tabs = [
{ key: "reception", label: "接待设置" },
{ key: "notification", label: "通知设置" },
{ key: "system", label: "系统设置" },
{ key: "security", label: "安全设置" },
{ key: "advanced", label: "高级设置" },
// { key: "notification", label: "通知设置" },
// { key: "system", label: "系统设置" },
// { key: "security", label: "安全设置" },
// { key: "advanced", label: "高级设置" },
];
const handleTabClick = (tabKey: string) => {

View File

@@ -8,6 +8,7 @@ import {
SettingOutlined,
SendOutlined,
ClearOutlined,
RobotOutlined,
} from "@ant-design/icons";
import { useUserStore } from "@/store/module/user";
import { useNavigate, useLocation } from "react-router-dom";
@@ -144,15 +145,14 @@ const NavCommon: React.FC<NavCommonProps> = ({ title = "触客宝" }) => {
</div>
),
},
{
key: "settings",
icon: <SettingOutlined style={{ fontSize: 16 }} />,
label: "全局配置",
onClick: () => {
navigate("/pc/commonConfig");
},
},
// {
// key: "settings",
// icon: <SettingOutlined style={{ fontSize: 16 }} />,
// label: "全局配置",
// onClick: () => {
// navigate("/pc/commonConfig");
// },
// },
{
key: "clearCache",
icon: <ClearOutlined style={{ fontSize: 16 }} />,
@@ -170,6 +170,9 @@ const NavCommon: React.FC<NavCommonProps> = ({ title = "触客宝" }) => {
const handleContentManagementClick = () => {
navigate("/pc/powerCenter/content-management");
};
const handleAiClick = () => {
navigate("/pc/commonConfig");
};
return (
<>
<Header className={styles.header}>
@@ -179,7 +182,7 @@ const NavCommon: React.FC<NavCommonProps> = ({ title = "触客宝" }) => {
type="primary"
onClick={handleMenuClick}
></Button>
<Button icon={<RobotOutlined />} onClick={handleAiClick}></Button>
<Button
icon={<SendOutlined />}
onClick={handleContentManagementClick}

View File

@@ -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;

View File

@@ -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<SessionItemProps> = React.memo(
({ session, isActive, onClick, onContextMenu }) => {
return (
<List.Item
<div
className={`${styles.messageItem} ${isActive ? styles.active : ""} ${
(session.config as any)?.top ? styles.pinned : ""
}`}
@@ -71,7 +72,7 @@ const SessionItem: React.FC<SessionItemProps> = React.memo(
</div>
</div>
</div>
</List.Item>
</div>
);
},
(prev, next) =>
@@ -92,12 +93,24 @@ const MessageList: React.FC<MessageListProps> = () => {
const {
hasLoadedOnce,
setHasLoadedOnce,
sessions,
sessions: storeSessions, // 新架构的sessions已过滤
setSessions: setSessionState,
// 新架构的SessionStore方法
switchAccount,
setSearchKeyword,
setAllSessions,
buildIndexes,
selectedAccountId,
} = useMessageStore();
// 使用新架构的sessions作为主要数据源保留filteredSessions作为fallback
const [filteredSessions, setFilteredSessions] = useState<ChatSession[]>([]);
const [syncing, setSyncing] = useState(false); // 同步状态
const hasEnrichedRef = useRef(false); // 是否已做过未知联系人补充
const virtualListRef = useRef<HTMLDivElement>(null); // 虚拟列表容器引用
// 决定使用哪个数据源优先使用新架构的sessions否则使用本地filteredSessions
const displaySessions =
storeSessions.length > 0 ? storeSessions : filteredSessions;
// 右键菜单相关状态
const [contextMenu, setContextMenu] = useState<{
@@ -267,7 +280,7 @@ const MessageList: React.FC<MessageListProps> = () => {
const session = editRemarkModal.session;
const isGroup = "chatroomId" in session;
const sessionData = sessions.find(s => s.id === session.id);
const sessionData = displaySessions.find(s => s.id === session.id);
if (!sessionData) return;
const oldRemark = session.conRemark;
@@ -358,15 +371,17 @@ const MessageList: React.FC<MessageListProps> = () => {
// ==================== 数据加载 & 未知联系人补充 ====================
// 同步完成后,检查是否存在未知联系人或缺失头像/昵称的会话,并异步补充详情
// 同步完成后,检查是否存在"未知联系人"或缺失头像/昵称的会话,并异步补充详情
const enrichUnknownContacts = async () => {
if (!currentUserId) return;
if (hasEnrichedRef.current) return; // 避免重复执行
// 只在会话有数据时执行
if (!sessions || sessions.length === 0) return;
// 只在会话有数据时执行使用displaySessions
const sessionsToCheck =
displaySessions.length > 0 ? displaySessions : filteredSessions;
if (!sessionsToCheck || sessionsToCheck.length === 0) return;
const needEnrich = sessions.filter(s => {
const needEnrich = sessionsToCheck.filter(s => {
const noName = !s.conRemark && !s.nickname && !s.wechatId;
const isUnknownNickname = s.nickname === "未知联系人";
const noAvatar = !s.avatar;
@@ -616,6 +631,12 @@ const MessageList: React.FC<MessageListProps> = () => {
// 有缓存数据立即显示
if (cachedSessions.length > 0) {
setSessionState(cachedSessions);
// 同步到新架构的SessionStore构建索引
if (cachedSessions.length > 100) {
setAllSessions(cachedSessions);
} else {
buildIndexes(cachedSessions);
}
}
const needsFullSync = cachedSessions.length === 0 || !hasLoadedOnce;
@@ -668,10 +689,83 @@ const MessageList: React.FC<MessageListProps> = () => {
return unsubscribe;
}, [currentUserId, setSessionState]);
// 根据客服和搜索关键词筛选会话
// 同步账号切换到新架构的SessionStore
useEffect(() => {
const accountId = currentCustomer?.id || 0;
if (accountId !== selectedAccountId) {
switchAccount(accountId);
}
}, [currentCustomer, selectedAccountId, switchAccount]);
// 同步搜索关键词到新架构的SessionStore
useEffect(() => {
if (searchKeyword !== undefined) {
setSearchKeyword(searchKeyword);
}
}, [searchKeyword, setSearchKeyword]);
// 数据加载时构建索引(新架构)
useEffect(() => {
// 使用storeSessions或displaySessions来构建索引
const sessionsToIndex =
storeSessions.length > 0 ? storeSessions : displaySessions;
if (sessionsToIndex.length > 0) {
// 首次加载或数据更新时,构建索引
if (sessionsToIndex.length > 100) {
// 大数据量时使用新架构的索引
buildIndexes(sessionsToIndex);
}
}
}, [storeSessions, displaySessions, buildIndexes]);
// 根据客服和搜索关键词筛选会话保留原有逻辑作为fallback
useEffect(() => {
const filterSessions = async () => {
let filtered = [...sessions];
// 如果新架构的sessions有数据优先使用已通过switchAccount过滤
if (storeSessions.length > 0) {
// 新架构已处理过滤但需要补充wechatId如果需要
const keyword = searchKeyword?.trim().toLowerCase() || "";
if (keyword) {
const sessionsNeedingWechatId = storeSessions.filter(
v => !v.wechatId && v.type === "friend",
);
if (sessionsNeedingWechatId.length > 0) {
const contactPromises = sessionsNeedingWechatId.map(session =>
ContactManager.getContactByIdAndType(
currentUserId,
session.id,
session.type,
),
);
const contacts = await Promise.all(contactPromises);
// 注意这里不能直接修改storeSessions需要更新到store
const updatedSessions = [...storeSessions];
contacts.forEach((contact, index) => {
if (contact && contact.wechatId) {
const session = sessionsNeedingWechatId[index];
const sessionIndex = updatedSessions.findIndex(
s => s.id === session.id && s.type === session.type,
);
if (sessionIndex !== -1) {
updatedSessions[sessionIndex] = {
...updatedSessions[sessionIndex],
wechatId: contact.wechatId,
};
}
}
});
// 更新到store如果需要
// setSessionState(updatedSessions);
}
}
// 新架构已处理不需要设置filteredSessions
return;
}
// Fallback: 原有过滤逻辑(当新架构未启用时)
let filtered = [...(filteredSessions.length > 0 ? filteredSessions : [])];
// 根据当前选中的客服筛选
if (currentCustomer && currentCustomer.id !== 0) {
@@ -680,7 +774,7 @@ const MessageList: React.FC<MessageListProps> = () => {
);
}
const keyword = searchKeyword.trim().toLowerCase();
const keyword = searchKeyword?.trim().toLowerCase() || "";
// 根据搜索关键词进行模糊匹配(支持搜索昵称、备注名、微信号)
if (keyword) {
@@ -742,7 +836,13 @@ const MessageList: React.FC<MessageListProps> = () => {
window.clearTimeout(timer);
}
};
}, [sessions, currentCustomer, searchKeyword, currentUserId]);
}, [
storeSessions,
currentCustomer,
searchKeyword,
currentUserId,
filteredSessions,
]);
// 渲染完毕后自动点击第一个聊天记录
useEffect(() => {
@@ -752,14 +852,14 @@ const MessageList: React.FC<MessageListProps> = () => {
// 3. 还没有自动点击过
// 4. 不在搜索状态(避免搜索时自动切换)
if (
filteredSessions.length > 0 &&
displaySessions.length > 0 &&
!currentContract &&
!autoClickRef.current &&
!searchKeyword.trim()
!searchKeyword?.trim()
) {
// 延迟一点时间确保DOM已渲染
const timer = setTimeout(() => {
const firstSession = filteredSessions[0];
const firstSession = displaySessions[0];
if (firstSession) {
autoClickRef.current = true;
onContactClick(firstSession);
@@ -769,7 +869,7 @@ const MessageList: React.FC<MessageListProps> = () => {
return () => clearTimeout(timer);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filteredSessions, currentContract, searchKeyword]);
}, [displaySessions, currentContract, searchKeyword]);
// ==================== WebSocket消息处理 ====================
@@ -1040,27 +1140,55 @@ const MessageList: React.FC<MessageListProps> = () => {
</div>
);
// 计算虚拟列表容器高度
const [containerHeight, setContainerHeight] = useState(600);
useEffect(() => {
const updateHeight = () => {
if (virtualListRef.current) {
const rect = virtualListRef.current.getBoundingClientRect();
setContainerHeight(rect.height || 600);
}
};
updateHeight();
window.addEventListener("resize", updateHeight);
return () => window.removeEventListener("resize", updateHeight);
}, []);
// 渲染会话项(用于虚拟滚动)
const renderSessionItem = useCallback(
(session: ChatSession, index: number) => {
return (
<SessionItem
key={session.id}
session={session}
isActive={!!currentContract && currentContract.id === session.id}
onClick={onContactClick}
onContextMenu={handleContextMenu}
/>
);
},
[currentContract, onContactClick, handleContextMenu],
);
return (
<div className={styles.messageList}>
<div className={styles.messageList} ref={virtualListRef}>
{/* 同步状态提示栏 */}
{renderSyncStatusBar()}
<List
dataSource={filteredSessions as any[]}
renderItem={session => (
<SessionItem
key={session.id}
session={session}
isActive={!!currentContract && currentContract.id === session.id}
onClick={onContactClick}
onContextMenu={handleContextMenu}
/>
)}
locale={{
emptyText:
filteredSessions.length === 0 && !syncing ? "暂无会话" : null,
}}
/>
{/* 虚拟滚动列表 */}
{displaySessions.length > 0 ? (
<VirtualSessionList
sessions={displaySessions}
containerHeight={containerHeight - 50} // 减去同步状态栏高度
selectedSessionId={currentContract?.id}
renderItem={renderSessionItem}
onItemClick={onContactClick}
onItemContextMenu={handleContextMenu}
className={styles.virtualList}
/>
) : (
<div className={styles.emptyList}>{!syncing ? "暂无会话" : null}</div>
)}
{/* 右键菜单 */}
{contextMenu.visible && contextMenu.session && (

View File

@@ -0,0 +1,269 @@
/**
* MessageList组件 - 虚拟滚动版本(示例)
* 展示如何集成VirtualSessionList组件
*
* 注意:这是一个示例文件,用于展示集成方式
* 实际改造时应该直接修改原有的index.tsx文件
*/
import React, { useEffect, useState, useRef, useMemo, useCallback } from "react";
import { Modal, Input, message } from "antd";
import {
PushpinOutlined,
DeleteOutlined,
EditOutlined,
LoadingOutlined,
CheckCircleOutlined,
} from "@ant-design/icons";
import styles from "./com.module.scss";
import { VirtualSessionList } from "@/components/VirtualSessionList";
import { ChatSession } from "@/utils/db";
import { useMessageStore } from "@weChatStore/message";
import { useCustomerStore } from "@weChatStore/customer";
import { useWeChatStore } from "@weChatStore/weChat";
import { useUserStore } from "@storeModule/user";
import { useContactStore } from "@weChatStore/contacts";
import { formatWechatTime } from "@/utils/common";
import { messageFilter } from "@/utils/filter";
import { UserOutlined, TeamOutlined } from "@ant-design/icons";
import { Avatar, Badge } from "antd";
/**
* 会话项组件(用于虚拟滚动)
*/
const SessionItem: React.FC<{
session: ChatSession;
isActive: boolean;
}> = React.memo(({ session, isActive }) => {
return (
<div
className={`${styles.messageItem} ${isActive ? styles.active : ""} ${
(session.config as any)?.top ? styles.pinned : ""
}`}
>
<div className={styles.messageInfo}>
<Badge count={session.config.unreadCount || 0} size="small">
<Avatar
size={48}
src={session.avatar}
icon={
session?.type === "group" ? <TeamOutlined /> : <UserOutlined />
}
/>
</Badge>
<div className={styles.messageDetails}>
<div className={styles.messageHeader}>
<div className={styles.messageName}>
{session.conRemark || session.nickname || session.wechatId}
</div>
<div className={styles.messageTime}>
{formatWechatTime(session?.lastUpdateTime)}
</div>
</div>
<div className={styles.messageContent}>
{messageFilter(session.content)}
</div>
</div>
</div>
</div>
);
});
SessionItem.displayName = "SessionItem";
/**
* MessageList组件 - 虚拟滚动版本
*/
const MessageListVirtual: React.FC = () => {
const searchKeyword = useContactStore(state => state.searchKeyword);
const { setCurrentContact, currentContract } = useWeChatStore();
const { currentCustomer } = useCustomerStore();
const { user } = useUserStore();
const currentUserId = user?.id || 0;
// 使用新的SessionStore
const {
sessions,
selectedAccountId,
switchAccount,
setSearchKeyword,
setAllSessions,
} = useMessageStore();
// 当前显示的会话列表从新架构的SessionStore获取
const displayedSessions = useMemo(() => {
// 如果currentCustomer存在使用其ID否则使用selectedAccountId
const accountId = currentCustomer?.id || selectedAccountId || 0;
// 使用新架构的switchAccount方法快速过滤
if (accountId !== selectedAccountId) {
// 账号切换调用switchAccount
switchAccount(accountId);
}
return sessions; // sessions已经是过滤后的数据
}, [sessions, currentCustomer, selectedAccountId, switchAccount]);
// 右键菜单相关状态
const [contextMenu, setContextMenu] = useState<{
visible: boolean;
x: number;
y: number;
session: ChatSession | null;
}>({
visible: false,
x: 0,
y: 0,
session: null,
});
// 修改备注相关状态
const [editRemarkModal, setEditRemarkModal] = useState<{
visible: boolean;
session: ChatSession | null;
remark: string;
}>({
visible: false,
session: null,
remark: "",
});
const contextMenuRef = useRef<HTMLDivElement>(null);
// 点击会话
const handleItemClick = useCallback(
(session: ChatSession) => {
setCurrentContact(session as any);
// 标记为已读等逻辑...
},
[setCurrentContact],
);
// 右键菜单
const handleContextMenu = useCallback(
(e: React.MouseEvent, session: ChatSession) => {
e.preventDefault();
setContextMenu({
visible: true,
x: e.clientX,
y: e.clientY,
session,
});
},
[],
);
// 隐藏右键菜单
const hideContextMenu = useCallback(() => {
setContextMenu({
visible: false,
x: 0,
y: 0,
session: null,
});
}, []);
// 渲染会话项(用于虚拟滚动)
const renderSessionItem = useCallback(
(session: ChatSession, index: number) => {
const isActive =
!!currentContract && currentContract.id === session.id;
return <SessionItem session={session} isActive={isActive} />;
},
[currentContract],
);
// 点击外部隐藏菜单
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
contextMenuRef.current &&
!contextMenuRef.current.contains(event.target as Node)
) {
hideContextMenu();
}
};
if (contextMenu.visible) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [contextMenu.visible, hideContextMenu]);
// 同步搜索关键词到SessionStore
useEffect(() => {
if (searchKeyword) {
setSearchKeyword(searchKeyword);
}
}, [searchKeyword, setSearchKeyword]);
return (
<div className={styles.messageList}>
{/* 使用虚拟滚动列表 */}
<VirtualSessionList
sessions={displayedSessions}
containerHeight={600} // 根据实际容器高度调整
selectedSessionId={currentContract?.id}
renderItem={renderSessionItem}
onItemClick={handleItemClick}
onItemContextMenu={handleContextMenu}
className={styles.virtualList}
/>
{/* 右键菜单 */}
{contextMenu.visible && contextMenu.session && (
<div
ref={contextMenuRef}
className={styles.contextMenu}
style={{
position: "fixed",
left: contextMenu.x,
top: contextMenu.y,
zIndex: 1000,
}}
>
<div className={styles.menuItem}></div>
<div className={styles.menuItem}></div>
<div className={styles.menuItem}></div>
</div>
)}
{/* 修改备注Modal */}
<Modal
title="修改备注"
open={editRemarkModal.visible}
onOk={() => {
// 保存备注逻辑...
setEditRemarkModal({
visible: false,
session: null,
remark: "",
});
}}
onCancel={() =>
setEditRemarkModal({
visible: false,
session: null,
remark: "",
})
}
>
<Input
value={editRemarkModal.remark}
onChange={e =>
setEditRemarkModal(prev => ({
...prev,
remark: e.target.value,
}))
}
placeholder="请输入备注"
/>
</Modal>
</div>
);
};
export default MessageListVirtual;

View File

@@ -1,10 +1,15 @@
.contractListSimple {
display: flex;
flex-direction: column;
height: 100%;
min-height: 100%;
background-color: #fff;
color: #333;
.virtualList {
flex: 1;
min-height: 0;
}
.header {
padding: 10px 15px;
font-weight: bold;
@@ -34,7 +39,7 @@
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 10px;
}
.contactCount {

View File

@@ -1,12 +1,12 @@
import React, { useState, useCallback, useEffect } from "react";
import { List, Avatar, Skeleton, Collapse } from "antd";
import type { CollapseProps } from "antd";
import React, { useState, useCallback, useEffect, useRef } from "react";
import { List, Avatar, Skeleton, Modal, Form, Input, message } from "antd";
import dayjs from "dayjs";
import styles from "./com.module.scss";
import { Contact, ChatSession } from "@/utils/db";
import { ContactManager, MessageManager } from "@/utils/dbAction";
import { ContactGroupByLabel } from "@/pages/pc/ckbox/data";
import { useContactStore } from "@weChatStore/contacts";
import { useContactStoreNew } from "@/store/module/weChat/contacts.new";
import { useCustomerStore } from "@weChatStore/customer";
import { useUserStore } from "@storeModule/user";
import {
@@ -15,6 +15,10 @@ import {
getGroupStatistics,
getContactsByGroup,
} from "./extend";
import { VirtualContactList } from "@/components/VirtualContactList";
import { ContactGroup } from "@/store/module/weChat/contacts.data";
import { GroupContextMenu } from "@/components/GroupContextMenu";
import { ContactContextMenu } from "@/components/ContactContextMenu";
interface WechatFriendsProps {
selectedContactId?: Contact;
@@ -22,15 +26,16 @@ interface WechatFriendsProps {
const ContactListSimple: React.FC<WechatFriendsProps> = ({
selectedContactId,
}) => {
// 基础状态
// 基础状态(保留用于向后兼容和搜索模式)
const [contactGroups, setContactGroups] = useState<ContactGroupByLabel[]>([]);
const [labels, setLabels] = useState<ContactGroupByLabel[]>([]);
const [loading, setLoading] = useState(false);
const [initializing, setInitializing] = useState(true); // 初始化状态
const [activeKey, setActiveKey] = useState<string[]>([]);
// 分页相关状态(合并为对象,减少状态数量)
const [groupData, setGroupData] = useState<{
// 注意以下状态已由新架构的ContactStore管理保留仅用于向后兼容
// activeKey, groupData 等已不再使用,但保留以避免破坏现有功能
const [activeKey] = useState<string[]>([]); // 已废弃由expandedGroups替代
const [groupData] = useState<{
contacts: { [groupKey: string]: Contact[] };
pages: { [groupKey: string]: number };
loading: { [groupKey: string]: boolean };
@@ -42,13 +47,286 @@ const ContactListSimple: React.FC<WechatFriendsProps> = ({
hasMore: {},
});
// 使用新的 contacts store
const { searchResults, isSearchMode, setCurrentContact } = useContactStore();
// 使用新的 contacts store(保留原有,用于向后兼容)
const { setCurrentContact: setOldCurrentContact } = useContactStore();
// 使用新架构的ContactStore包括搜索功能
const {
groups: newGroups,
expandedGroups,
groupData: newGroupData,
selectedAccountId,
toggleGroup,
loadGroupContacts,
loadMoreGroupContacts,
setGroups,
switchAccount,
updateContactRemark,
// 搜索相关
searchResults,
isSearchMode,
searchLoading,
} = useContactStoreNew();
// 统一使用新架构的setCurrentContact如果新架构有的话否则使用旧的
const setCurrentContact = setOldCurrentContact;
// 获取用户和客服信息
const currentUser = useUserStore(state => state.user);
const currentCustomer = useCustomerStore(state => state.currentCustomer);
// 虚拟列表容器引用
const virtualListRef = useRef<HTMLDivElement>(null);
// 右键菜单状态
const [groupContextMenu, setGroupContextMenu] = useState<{
visible: boolean;
x: number;
y: number;
group?: ContactGroup;
groupType?: 1 | 2;
}>({
visible: false,
x: 0,
y: 0,
});
const [contactContextMenu, setContactContextMenu] = useState<{
visible: boolean;
x: number;
y: number;
contact?: Contact;
}>({
visible: false,
x: 0,
y: 0,
});
// 分组弹窗相关状态
const [groupForm] = Form.useForm<{
groupName: string;
groupMemo: string;
sort: number;
}>();
const [addGroupVisible, setAddGroupVisible] = useState(false);
const [editGroupVisible, setEditGroupVisible] = useState(false);
const [deleteGroupVisible, setDeleteGroupVisible] = useState(false);
const [groupModalLoading, setGroupModalLoading] = useState(false);
const [editingGroup, setEditingGroup] = useState<ContactGroup | undefined>();
const [currentGroupTypeForAdd, setCurrentGroupTypeForAdd] = useState<1 | 2>(1);
// 生成分组Key的函数
const getGroupKey = useCallback(
(groupId: number, groupType: 1 | 2, accountId: number) => {
return `${groupId}_${groupType}_${accountId}`;
},
[],
);
// 处理分组操作完成
const handleGroupOperationComplete = useCallback(async () => {
// 重新加载分组列表
try {
const labelList = await getCountLables();
setLabels(labelList);
const contactGroups: ContactGroup[] = labelList.map(
(label: ContactGroupByLabel) => ({
id: label.id,
groupName: label.groupName,
groupType: label.groupType as 1 | 2,
count: label.count,
sort: label.sort,
groupMemo: label.groupMemo,
}),
);
setGroups(contactGroups);
} catch (error) {
console.error("重新加载分组列表失败:", error);
}
}, [setGroups]);
// 处理分组右键菜单
const handleGroupContextMenu = useCallback(
(e: React.MouseEvent, group: ContactGroup) => {
e.preventDefault();
e.stopPropagation();
setGroupContextMenu({
visible: true,
x: e.clientX,
y: e.clientY,
group,
groupType: group.groupType,
});
},
[],
);
// 打开新增分组弹窗
const handleOpenAddGroupModal = useCallback((groupType: 1 | 2) => {
setCurrentGroupTypeForAdd(groupType);
groupForm.resetFields();
groupForm.setFieldsValue({
groupName: "",
groupMemo: "",
sort: 0,
});
setAddGroupVisible(true);
}, [groupForm]);
// 打开编辑分组弹窗
const handleOpenEditGroupModal = useCallback((group: ContactGroup) => {
setEditingGroup(group);
groupForm.resetFields();
groupForm.setFieldsValue({
groupName: group.groupName,
groupMemo: group.groupMemo || "",
sort: group.sort || 0,
});
setEditGroupVisible(true);
}, [groupForm]);
// 打开删除分组确认弹窗
const handleOpenDeleteGroupModal = useCallback((group: ContactGroup) => {
setEditingGroup(group);
setDeleteGroupVisible(true);
}, []);
// 提交新增分组
const handleSubmitAddGroup = useCallback(async () => {
try {
const values = await groupForm.validateFields();
setGroupModalLoading(true);
await useContactStoreNew.getState().addGroup({
groupName: values.groupName,
groupMemo: values.groupMemo || "",
groupType: currentGroupTypeForAdd,
sort: values.sort || 0,
});
message.success("新增分组成功");
setAddGroupVisible(false);
groupForm.resetFields();
await handleGroupOperationComplete();
} catch (error: any) {
if (error?.errorFields) {
return;
}
console.error("新增分组失败:", error);
message.error(error?.message || "新增分组失败");
} finally {
setGroupModalLoading(false);
}
}, [groupForm, currentGroupTypeForAdd, handleGroupOperationComplete]);
// 提交编辑分组
const handleSubmitEditGroup = useCallback(async () => {
if (!editingGroup) return;
try {
const values = await groupForm.validateFields();
setGroupModalLoading(true);
await useContactStoreNew.getState().updateGroup({
...editingGroup,
groupName: values.groupName,
groupMemo: values.groupMemo || "",
sort: values.sort || 0,
});
message.success("编辑分组成功");
setEditGroupVisible(false);
groupForm.resetFields();
await handleGroupOperationComplete();
} catch (error: any) {
if (error?.errorFields) {
return;
}
console.error("编辑分组失败:", error);
message.error(error?.message || "编辑分组失败");
} finally {
setGroupModalLoading(false);
}
}, [groupForm, editingGroup, handleGroupOperationComplete]);
// 确认删除分组
const handleConfirmDeleteGroup = useCallback(async () => {
if (!editingGroup) return;
try {
setGroupModalLoading(true);
await useContactStoreNew.getState().deleteGroup(
editingGroup.id,
editingGroup.groupType,
);
message.success("删除分组成功");
setDeleteGroupVisible(false);
await handleGroupOperationComplete();
} catch (error: any) {
console.error("删除分组失败:", error);
message.error(error?.message || "删除分组失败");
} finally {
setGroupModalLoading(false);
}
}, [editingGroup, handleGroupOperationComplete]);
// 处理联系人右键菜单
const handleContactContextMenu = useCallback(
(e: React.MouseEvent, contact: Contact) => {
e.preventDefault();
e.stopPropagation();
setContactContextMenu({
visible: true,
x: e.clientX,
y: e.clientY,
contact,
});
},
[],
);
// 处理联系人操作完成
const handleContactOperationComplete = useCallback(() => {
// 操作完成后,可能需要刷新相关分组的数据
// 这里可以根据需要实现
}, []);
// 处理修改备注
const handleUpdateRemark = useCallback(
async (contactId: number, remark: string) => {
// 找到联系人所在的分组
let foundGroup: ContactGroup | undefined;
let foundGroupKey: string | undefined;
for (const [groupKey, groupDataItem] of newGroupData.entries()) {
const contact = groupDataItem.contacts.find(c => c.id === contactId);
if (contact) {
foundGroupKey = groupKey;
// 从groupKey解析groupId和groupType
const [groupId, groupType] = groupKey.split("_");
// 使用newGroups查找分组优先使用新架构的数据
foundGroup = newGroups.find(
g => g.id === Number(groupId) && g.groupType === Number(groupType),
);
break;
}
}
if (foundGroup && foundGroupKey) {
const [groupId, groupType] = foundGroupKey.split("_");
await updateContactRemark(
contactId,
Number(groupId),
Number(groupType) as 1 | 2,
remark,
);
} else {
message.error("未找到联系人所在的分组");
}
},
[updateContactRemark, newGroupData, newGroups],
);
// 从服务器同步数据(静默同步,不显示提示)
const syncWithServer = useCallback(
async (userId: number) => {
@@ -84,6 +362,14 @@ const ContactListSimple: React.FC<WechatFriendsProps> = ({
loadLabels();
}, []);
// 同步账号切换到新架构的ContactStore
useEffect(() => {
const accountId = currentCustomer?.id || 0;
if (accountId !== selectedAccountId) {
switchAccount(accountId);
}
}, [currentCustomer, selectedAccountId, switchAccount]);
// 初始化数据加载:先读取本地数据库,再静默同步
useEffect(() => {
const loadData = async () => {
@@ -156,124 +442,21 @@ const ContactListSimple: React.FC<WechatFriendsProps> = ({
loadGroupStats();
}, [currentUser?.id, labels, currentCustomer?.id]);
// 当分组展开时,加载该分组的第一页数据
const handleGroupExpand = useCallback(
async (groupKey: string) => {
const groupIndex = parseInt(groupKey);
const label = contactGroups[groupIndex];
// 注意以下函数已由新架构的ContactStore方法替代toggleGroup, loadGroupContacts, loadMoreGroupContacts
// 保留这些函数仅用于向后兼容,实际已不再使用
// 当分组展开时,加载该分组的第一页数据(已废弃)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const handleGroupExpand = useCallback(async (groupKey: string) => {
// 此函数已废弃请使用新架构的toggleGroup方法
console.warn("handleGroupExpand已废弃请使用toggleGroup");
}, []);
if (!label || !currentUser?.id) return;
// 如果已经加载过数据,不重复加载
if (groupData.contacts[groupKey]?.length > 0) return;
// 设置加载状态
setGroupData(prev => ({
...prev,
loading: { ...prev.loading, [groupKey]: true },
}));
try {
// 根据当前标签的 groupType 计算正确的 realGroupIds
const realGroupIds = labels
.filter(
item =>
item.id !== 0 &&
Number(item.groupType) === Number(label.groupType),
)
.map(item => item.id);
const contacts = await getContactsByGroup(
currentUser.id,
label,
realGroupIds,
currentCustomer?.id,
1,
20,
);
// 更新分组数据
setGroupData(prev => ({
contacts: { ...prev.contacts, [groupKey]: contacts },
pages: { ...prev.pages, [groupKey]: 1 },
loading: { ...prev.loading, [groupKey]: false },
hasMore: { ...prev.hasMore, [groupKey]: contacts.length === 20 },
}));
} catch (error) {
console.error("加载分组数据失败:", error);
setGroupData(prev => ({
...prev,
loading: { ...prev.loading, [groupKey]: false },
}));
}
},
[
contactGroups,
labels,
currentUser?.id,
currentCustomer?.id,
groupData.contacts,
],
);
// 加载更多联系人
const handleLoadMore = useCallback(
async (groupKey: string) => {
if (groupData.loading[groupKey] || !groupData.hasMore[groupKey]) return;
const groupIndex = parseInt(groupKey);
const label = contactGroups[groupIndex];
if (!label || !currentUser?.id) return;
// 设置加载状态
setGroupData(prev => ({
...prev,
loading: { ...prev.loading, [groupKey]: true },
}));
try {
const currentPage = groupData.pages[groupKey] || 1;
const nextPage = currentPage + 1;
// 根据当前标签的 groupType 计算正确的 realGroupIds
const realGroupIds = labels
.filter(
item =>
item.id !== 0 &&
Number(item.groupType) === Number(label.groupType),
)
.map(item => item.id);
const newContacts = await getContactsByGroup(
currentUser.id,
label,
realGroupIds,
currentCustomer?.id,
nextPage,
20,
);
// 更新分组数据
setGroupData(prev => ({
contacts: {
...prev.contacts,
[groupKey]: [...(prev.contacts[groupKey] || []), ...newContacts],
},
pages: { ...prev.pages, [groupKey]: nextPage },
loading: { ...prev.loading, [groupKey]: false },
hasMore: { ...prev.hasMore, [groupKey]: newContacts.length === 20 },
}));
} catch (error) {
console.error("加载更多联系人失败:", error);
setGroupData(prev => ({
...prev,
loading: { ...prev.loading, [groupKey]: false },
}));
}
},
[contactGroups, labels, currentUser?.id, currentCustomer?.id, groupData],
);
// 加载更多联系人(已废弃)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const handleLoadMore = useCallback(async (groupKey: string) => {
// 此函数已废弃请使用新架构的loadMoreGroupContacts方法
console.warn("handleLoadMore已废弃请使用loadMoreGroupContacts");
}, []);
// 联系人点击处理
const onContactClick = async (contact: Contact) => {
@@ -334,33 +517,49 @@ const ContactListSimple: React.FC<WechatFriendsProps> = ({
console.error("处理联系人点击失败:", error);
}
};
// 渲染联系人项
const renderContactItem = (contact: Contact) => {
// 判断是否为群组
const isGroup = contact.type === "group";
const avatar = contact.avatar;
const name = contact.conRemark || contact.nickname;
// 渲染联系人项(用于虚拟滚动)
const renderContactItem = useCallback(
(contact: Contact, groupIndex: number, contactIndex: number) => {
// 判断是否为群组
const isGroup = contact.type === "group";
const avatar = contact.avatar;
const name = contact.conRemark || contact.nickname;
return (
<List.Item
key={contact.id}
onClick={() => onContactClick(contact)}
className={`${styles.contractItem} ${contact.id === selectedContactId?.id ? styles.selected : ""}`}
>
<div className={styles.avatarContainer}>
<Avatar
src={avatar}
icon={!avatar && <span>{contact.nickname?.charAt(0) || ""}</span>}
className={styles.avatar}
/>
return (
<div
key={contact.id}
onClick={() => onContactClick(contact)}
className={`${styles.contractItem} ${contact.id === selectedContactId?.id ? styles.selected : ""}`}
>
<div className={styles.avatarContainer}>
<Avatar
src={avatar}
icon={!avatar && <span>{contact.nickname?.charAt(0) || ""}</span>}
className={styles.avatar}
/>
</div>
<div className={styles.contractInfo}>
<div className={styles.name}>{name}</div>
{isGroup && <div className={styles.groupInfo}></div>}
</div>
</div>
<div className={styles.contractInfo}>
<div className={styles.name}>{name}</div>
{isGroup && <div className={styles.groupInfo}></div>}
);
},
[selectedContactId, onContactClick],
);
// 渲染分组头部(用于虚拟滚动)
const renderGroupHeader = useCallback(
(group: ContactGroup, isExpanded: boolean) => {
return (
<div className={styles.groupHeader}>
<span>{group.groupName}</span>
<span className={styles.contactCount}>{group.count || 0}</span>
</div>
</List.Item>
);
};
);
},
[],
);
// 渲染骨架屏
const renderSkeleton = () => (
@@ -378,121 +577,207 @@ const ContactListSimple: React.FC<WechatFriendsProps> = ({
</div>
);
// 监听分组展开/折叠事件
const handleCollapseChange = (keys: string | string[]) => {
const newKeys = Array.isArray(keys) ? keys : [keys];
const expandedKeys = newKeys.filter(key => !activeKey.includes(key));
// 不限制容器高度,让列表根据内容自动扩展
// const [containerHeight, setContainerHeight] = useState(600);
// useEffect(() => {
// const updateHeight = () => {
// if (virtualListRef.current) {
// const rect = virtualListRef.current.getBoundingClientRect();
// setContainerHeight(rect.height || 600);
// }
// };
// updateHeight();
// window.addEventListener("resize", updateHeight);
// return () => window.removeEventListener("resize", updateHeight);
// }, []);
const containerHeight = undefined; // 不限制高度,使用内容总高度
// 加载新展开的分组数据
expandedKeys.forEach(key => {
handleGroupExpand(key);
});
// 处理分组展开/折叠(使用新架构的方法)
const handleGroupToggle = useCallback(
async (groupId: number, groupType: 1 | 2) => {
await toggleGroup(groupId, groupType);
},
[toggleGroup],
);
setActiveKey(newKeys);
};
// 处理分组内加载更多(使用新架构的方法)
const handleGroupLoadMore = useCallback(
async (groupId: number, groupType: 1 | 2) => {
await loadMoreGroupContacts(groupId, groupType);
},
[loadMoreGroupContacts],
);
// 渲染加载更多文字
const renderLoadMoreButton = (groupKey: string) => {
if (!groupData.hasMore[groupKey]) {
return <div className={styles.noMoreText}></div>;
}
return (
<div
className={styles.loadMoreText}
onClick={() => !groupData.loading[groupKey] && handleLoadMore(groupKey)}
>
{groupData.loading[groupKey] ? "加载中..." : "加载更多"}
</div>
);
};
// 构建 Collapse 的 items
const getCollapseItems = (): CollapseProps["items"] => {
if (!contactGroups || contactGroups.length === 0) return [];
return contactGroups.map((group, index) => {
const groupKey = index.toString();
const isActive = activeKey.includes(groupKey);
return {
key: groupKey,
label: (
<div className={styles.groupHeader}>
<span>{group.groupName}</span>
<span className={styles.contactCount}>{group.count || 0}</span>
</div>
),
className: styles.groupPanel,
children: isActive ? (
<>
{groupData.loading[groupKey] && !groupData.contacts[groupKey] ? (
// 首次加载显示骨架屏
<div className={styles.groupSkeleton}>
{Array(3)
.fill(null)
.map((_, i) => (
<div key={`skeleton-${i}`} className={styles.skeletonItem}>
<Skeleton.Avatar active size="large" shape="circle" />
<div className={styles.skeletonInfo}>
<Skeleton.Input
active
size="small"
style={{ width: "60%" }}
/>
</div>
</div>
))}
</div>
) : (
<>
<List
className={styles.list}
dataSource={groupData.contacts[groupKey] || []}
renderItem={renderContactItem}
/>
{(groupData.contacts[groupKey]?.length || 0) > 0 &&
renderLoadMoreButton(groupKey)}
</>
)}
</>
) : null,
};
});
};
// 决定使用哪个数据源优先使用新架构的groups否则使用原有的contactGroups
const displayGroups =
newGroups.length > 0
? newGroups
: contactGroups.map((g: ContactGroupByLabel) => ({
id: g.id,
groupName: g.groupName,
groupType: g.groupType as 1 | 2,
count: g.count,
sort: g.sort,
groupMemo: g.groupMemo,
}));
return (
<div className={styles.contractListSimple}>
<div className={styles.contractListSimple} ref={virtualListRef}>
{loading || initializing ? (
// 加载状态:显示骨架屏(初始化或首次无本地数据时显示)
renderSkeleton()
) : isSearchMode ? (
// 搜索模式:直接显示搜索结果列表
// 搜索模式:直接显示搜索结果列表保留原有List组件
<>
<div className={styles.header}></div>
<List
className={styles.list}
dataSource={searchResults}
renderItem={renderContactItem}
renderItem={(contact: Contact) => renderContactItem(contact, 0, 0)}
/>
{searchResults.length === 0 && (
<div className={styles.noResults}></div>
)}
</>
) : (
// 正常模式:显示分组列表
// 正常模式:使用虚拟滚动显示分组列表
<>
<Collapse
className={styles.groupCollapse}
activeKey={activeKey}
onChange={handleCollapseChange}
items={getCollapseItems()}
/>
{contactGroups.length === 0 && (
{displayGroups.length > 0 ? (
<VirtualContactList
groups={displayGroups}
expandedGroups={expandedGroups}
groupData={newGroupData}
getGroupKey={getGroupKey}
selectedAccountId={selectedAccountId}
containerHeight={containerHeight}
selectedContactId={selectedContactId?.id}
renderGroupHeader={renderGroupHeader}
renderContact={renderContactItem}
onGroupToggle={handleGroupToggle}
onContactClick={onContactClick}
onGroupContextMenu={handleGroupContextMenu}
onContactContextMenu={handleContactContextMenu}
onGroupLoadMore={handleGroupLoadMore}
className={styles.virtualList}
/>
) : (
<div className={styles.noResults}></div>
)}
</>
)}
{/* 分组右键菜单 */}
<GroupContextMenu
group={groupContextMenu.group}
groupType={groupContextMenu.groupType || 1}
x={groupContextMenu.x}
y={groupContextMenu.y}
visible={groupContextMenu.visible}
onClose={() => setGroupContextMenu({ visible: false, x: 0, y: 0 })}
onComplete={handleGroupOperationComplete}
onAddClick={handleOpenAddGroupModal}
onEditClick={handleOpenEditGroupModal}
onDeleteClick={handleOpenDeleteGroupModal}
/>
{/* 联系人右键菜单 */}
{contactContextMenu.contact && (
<ContactContextMenu
contact={contactContextMenu.contact}
groups={displayGroups}
x={contactContextMenu.x}
y={contactContextMenu.y}
visible={contactContextMenu.visible}
onClose={() => setContactContextMenu({ visible: false, x: 0, y: 0 })}
onComplete={handleContactOperationComplete}
onUpdateRemark={handleUpdateRemark}
/>
)}
{/* 新增分组弹窗 */}
<Modal
title="新增分组"
open={addGroupVisible}
onOk={handleSubmitAddGroup}
onCancel={() => {
setAddGroupVisible(false);
groupForm.resetFields();
}}
confirmLoading={groupModalLoading}
okText="确定"
cancelText="取消"
>
<Form form={groupForm} layout="vertical">
<Form.Item
name="groupName"
label="分组名称"
rules={[{ required: true, message: "请输入分组名称" }]}
>
<Input placeholder="请输入分组名称" maxLength={20} />
</Form.Item>
<Form.Item name="groupMemo" label="分组备注">
<Input.TextArea
placeholder="请输入分组备注(可选)"
rows={3}
maxLength={100}
/>
</Form.Item>
<Form.Item name="sort" label="排序" initialValue={0}>
<Input type="number" placeholder="排序值(数字越小越靠前)" />
</Form.Item>
</Form>
</Modal>
{/* 编辑分组弹窗 */}
<Modal
title="编辑分组"
open={editGroupVisible}
onOk={handleSubmitEditGroup}
onCancel={() => {
setEditGroupVisible(false);
groupForm.resetFields();
}}
confirmLoading={groupModalLoading}
okText="确定"
cancelText="取消"
>
<Form form={groupForm} layout="vertical">
<Form.Item
name="groupName"
label="分组名称"
rules={[{ required: true, message: "请输入分组名称" }]}
>
<Input placeholder="请输入分组名称" maxLength={20} />
</Form.Item>
<Form.Item name="groupMemo" label="分组备注">
<Input.TextArea
placeholder="请输入分组备注(可选)"
rows={3}
maxLength={100}
/>
</Form.Item>
<Form.Item name="sort" label="排序">
<Input type="number" placeholder="排序值(数字越小越靠前)" />
</Form.Item>
</Form>
</Modal>
{/* 删除分组确认弹窗 */}
<Modal
title="确认删除"
open={deleteGroupVisible}
onOk={handleConfirmDeleteGroup}
onCancel={() => setDeleteGroupVisible(false)}
confirmLoading={groupModalLoading}
okText="确定"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<p> "{editingGroup?.groupName}" </p>
<p style={{ color: "#999", fontSize: "12px" }}>
</p>
</Modal>
</div>
);
};

View File

@@ -13,6 +13,7 @@ import AddFriends from "./AddFriends";
import PopChatRoom from "./PopChatRoom";
import styles from "./SidebarMenu.module.scss";
import { useContactStore } from "@/store/module/weChat/contacts";
import { useContactStoreNew } from "@/store/module/weChat/contacts.new";
import { useCustomerStore } from "@/store/module/weChat/customer";
import { useWeChatStore } from "@/store/module/weChat/weChat";
import { useUserStore } from "@/store/module/user";
@@ -23,11 +24,19 @@ interface SidebarMenuProps {
const SidebarMenu: React.FC<SidebarMenuProps> = ({ loading = false }) => {
const {
searchKeyword,
setSearchKeyword,
searchKeyword: oldSearchKeyword,
setSearchKeyword: setOldSearchKeyword,
clearSearchKeyword,
currentContact,
} = useContactStore();
// 使用新架构的ContactStore进行搜索
const {
searchKeyword,
searchContacts,
clearSearch,
} = useContactStoreNew();
const currentCustomer = useCustomerStore(state => state.currentCustomer);
const { setCurrentContact } = useWeChatStore();
const { user } = useUserStore();
@@ -70,14 +79,50 @@ const SidebarMenu: React.FC<SidebarMenuProps> = ({ loading = false }) => {
handleContactSelection();
}, [currentContact, currentUserId, setCurrentContact]);
// 搜索防抖处理
const searchDebounceRef = useRef<ReturnType<typeof setTimeout>>();
const handleSearch = (value: string) => {
setSearchKeyword(value);
// 同时更新旧架构(向后兼容)
setOldSearchKeyword(value);
// 清除之前的防抖定时器
if (searchDebounceRef.current) {
clearTimeout(searchDebounceRef.current);
}
// 如果关键词为空,立即清除搜索
if (!value.trim()) {
clearSearch();
return;
}
// 防抖300ms后执行搜索
searchDebounceRef.current = setTimeout(() => {
searchContacts(value);
}, 300);
};
const handleClearSearch = () => {
// 清除防抖定时器
if (searchDebounceRef.current) {
clearTimeout(searchDebounceRef.current);
}
// 清除旧架构的搜索
clearSearchKeyword();
// 清除新架构的搜索
clearSearch();
};
// 组件卸载时清除防抖定时器
useEffect(() => {
return () => {
if (searchDebounceRef.current) {
clearTimeout(searchDebounceRef.current);
}
};
}, []);
// 下拉菜单项
const menuItems: MenuProps["items"] = [
{
@@ -151,7 +196,7 @@ const SidebarMenu: React.FC<SidebarMenuProps> = ({ loading = false }) => {
<Input
placeholder="搜索客户..."
prefix={<SearchOutlined />}
value={searchKeyword}
value={searchKeyword || oldSearchKeyword}
onChange={e => handleSearch(e.target.value)}
onClear={handleClearSearch}
allowClear

View File

@@ -0,0 +1,245 @@
/**
* 微信账号管理Store
* 职责:管理微信账号列表、当前选中的账号、账号状态
*
* 根据新架构设计将账号管理从CkChatStore中独立出来
*/
import { createPersistStore } from "@/store/createPersistStore";
import { KfUserListData } from "@/pages/pc/ckbox/data";
/**
* 账号状态信息
*/
export interface AccountStatus {
isOnline: boolean; // 是否在线
lastSyncTime: number; // 最后同步时间(时间戳)
lastActiveTime?: number; // 最后活跃时间(时间戳)
}
/**
* 微信账号Store状态接口
*/
export interface WeChatAccountState {
// ==================== 账号列表 ====================
/** 微信账号列表 */
accountList: KfUserListData[];
// ==================== 当前选中的账号 ====================
/** 当前选中的账号ID0表示"全部" */
selectedAccountId: number;
// ==================== 账号状态 ====================
/** 账号状态映射表accountId -> AccountStatus */
accountStatusMap: Map<number, AccountStatus>;
// ==================== 操作方法 ====================
/** 设置账号列表 */
setAccountList: (accounts: KfUserListData[]) => void;
/** 设置当前选中的账号 */
setSelectedAccount: (accountId: number) => void;
/** 更新账号状态 */
updateAccountStatus: (
accountId: number,
status: Partial<AccountStatus>,
) => void;
/** 获取账号状态 */
getAccountStatus: (accountId: number) => AccountStatus | undefined;
/** 获取当前选中的账号信息 */
getSelectedAccount: () => KfUserListData | undefined;
/** 根据ID获取账号信息 */
getAccountById: (accountId: number) => KfUserListData | undefined;
/** 清空账号列表 */
clearAccountList: () => void;
/** 添加账号 */
addAccount: (account: KfUserListData) => void;
/** 更新账号信息 */
updateAccount: (accountId: number, account: Partial<KfUserListData>) => void;
/** 删除账号 */
removeAccount: (accountId: number) => void;
}
/**
* 创建微信账号Store
*/
export const useWeChatAccountStore = createPersistStore<WeChatAccountState>(
(set, get) => ({
// ==================== 初始状态 ====================
accountList: [],
selectedAccountId: 0, // 0表示"全部"
accountStatusMap: new Map<number, AccountStatus>(),
// ==================== 操作方法 ====================
/**
* 设置账号列表
*/
setAccountList: (accounts: KfUserListData[]) => {
set({ accountList: accounts });
// 初始化账号状态(如果不存在)
const statusMap = new Map(get().accountStatusMap);
accounts.forEach(account => {
if (!statusMap.has(account.id)) {
statusMap.set(account.id, {
isOnline: account.isOnline || false,
lastSyncTime: Date.now(),
lastActiveTime: account.lastUpdateTime
? new Date(account.lastUpdateTime).getTime()
: undefined,
});
}
});
set({ accountStatusMap: statusMap });
},
/**
* 设置当前选中的账号
* @param accountId 账号ID0表示"全部"
*/
setSelectedAccount: (accountId: number) => {
set({ selectedAccountId: accountId });
},
/**
* 更新账号状态
*/
updateAccountStatus: (
accountId: number,
status: Partial<AccountStatus>,
) => {
const statusMap = new Map(get().accountStatusMap);
const currentStatus = statusMap.get(accountId) || {
isOnline: false,
lastSyncTime: Date.now(),
};
statusMap.set(accountId, {
...currentStatus,
...status,
});
set({ accountStatusMap: statusMap });
},
/**
* 获取账号状态
*/
getAccountStatus: (accountId: number) => {
return get().accountStatusMap.get(accountId);
},
/**
* 获取当前选中的账号信息
*/
getSelectedAccount: () => {
const { selectedAccountId, accountList } = get();
if (selectedAccountId === 0) {
return undefined; // "全部"时返回undefined
}
return accountList.find(account => account.id === selectedAccountId);
},
/**
* 根据ID获取账号信息
*/
getAccountById: (accountId: number) => {
return get().accountList.find(account => account.id === accountId);
},
/**
* 清空账号列表
*/
clearAccountList: () => {
set({
accountList: [],
selectedAccountId: 0,
accountStatusMap: new Map(),
});
},
/**
* 添加账号
*/
addAccount: (account: KfUserListData) => {
const accountList = [...get().accountList, account];
set({ accountList });
// 初始化账号状态
const statusMap = new Map(get().accountStatusMap);
statusMap.set(account.id, {
isOnline: account.isOnline || false,
lastSyncTime: Date.now(),
lastActiveTime: account.lastUpdateTime
? new Date(account.lastUpdateTime).getTime()
: undefined,
});
set({ accountStatusMap: statusMap });
},
/**
* 更新账号信息
*/
updateAccount: (accountId: number, account: Partial<KfUserListData>) => {
const accountList = get().accountList.map(acc =>
acc.id === accountId ? { ...acc, ...account } : acc,
);
set({ accountList });
// 如果更新了在线状态,同步更新状态映射
if (account.isOnline !== undefined) {
get().updateAccountStatus(accountId, {
isOnline: account.isOnline,
});
}
},
/**
* 删除账号
*/
removeAccount: (accountId: number) => {
const accountList = get().accountList.filter(acc => acc.id !== accountId);
set({ accountList });
// 清理账号状态
const statusMap = new Map(get().accountStatusMap);
statusMap.delete(accountId);
set({ accountStatusMap: statusMap });
// 如果删除的是当前选中的账号,切换到"全部"
if (get().selectedAccountId === accountId) {
set({ selectedAccountId: 0 });
}
},
}),
{
name: "wechat-account-store",
partialize: state => {
// Map类型需要转换为数组才能持久化
const accountStatusMapArray = Array.from(state.accountStatusMap.entries());
return {
accountList: state.accountList,
selectedAccountId: state.selectedAccountId,
accountStatusMap: accountStatusMapArray,
};
},
// 恢复时将数组转换回Map
onRehydrateStorage: () => (state: any, error: any) => {
if (error) {
console.error("WeChatAccountStore rehydration error:", error);
return;
}
if (state && Array.isArray(state.accountStatusMap)) {
state.accountStatusMap = new Map(state.accountStatusMap);
}
},
},
);

View File

@@ -1,70 +1,162 @@
//联系人标签分组
export interface ContactGroupByLabel {
id: number;
accountId?: number;
groupName: string;
tenantId?: number;
count: number;
[key: string]: any;
}
//群聊数据接口
export interface weChatGroup {
id?: number;
wechatAccountId: number;
tenantId: number;
accountId: number;
chatroomId: string;
chatroomOwner: string;
conRemark: string;
nickname: string;
chatroomAvatar: string;
groupId: number;
config?: {
top?: false;
chat?: boolean;
unreadCount?: number;
};
labels?: string[];
notice: string;
selfDisplyName: string;
wechatChatroomId: number;
serverId?: number;
[key: string]: any;
/**
* 联系人Store数据结构定义
* 根据新架构设计,支持分组懒加载
*/
import { Contact } from "@/utils/db";
/**
* 联系人分组
*/
export interface ContactGroup {
id: number; // 分组IDgroupId
groupName: string; // 分组名称
groupType: 1 | 2; // 1=好友列表2=群列表
count?: number; // 分组内联系人数量(统计信息)
sort?: number; // 排序
groupMemo?: string; // 分组备注
}
// 联系人数据接口
export interface ContractData {
id?: number;
serverId?: number;
wechatAccountId: number;
wechatId: string;
alias: string;
conRemark: string;
nickname: string;
quanPin: string;
avatar?: string;
gender: number;
region: string;
addFrom: number;
phone: string;
labels: string[];
signature: string;
accountId: number;
extendFields?: Record<string, any> | null;
city?: string;
lastUpdateTime: string;
isPassed: boolean;
tenantId: number;
groupId: number;
thirdParty: null;
additionalPicture: string;
desc: string;
config?: {
chat?: boolean;
unreadCount: number;
};
lastMessageTime: number;
duplicate: boolean;
[key: string]: any;
/**
* 分组联系人数据
*/
export interface GroupContactData {
contacts: Contact[]; // 已加载的联系人列表
page: number; // 当前页码
pageSize: number; // 每页数量
hasMore: boolean; // 是否还有更多数据
loading: boolean; // 是否正在加载
loaded: boolean; // 是否已加载过(用于判断是否需要重新加载)
lastLoadTime?: number; // 最后加载时间(时间戳)
}
/**
* 虚拟滚动状态
*/
export interface VirtualScrollState {
startIndex: number; // 可见区域起始索引
endIndex: number; // 可见区域结束索引
itemHeight: number; // 每项高度
containerHeight: number; // 容器高度
totalHeight: number; // 总高度
}
/**
* 新架构ContactStore状态接口
* 支持分组懒加载、API搜索、分组编辑等功能
*/
export interface ContactStoreState {
// ==================== 分组列表(一次性加载)====================
groups: ContactGroup[]; // 所有分组信息
// ==================== 当前选中的账号ID0=全部)====================
selectedAccountId: number;
// ==================== 展开的分组 ====================
expandedGroups: Set<string>; // groupKey集合格式`${groupId}_${groupType}_${accountId}`
// ==================== 分组数据(按分组懒加载)====================
groupData: Map<string, GroupContactData>; // groupKey → GroupContactData
// ==================== 搜索相关 ====================
searchKeyword: string;
isSearchMode: boolean;
searchResults: Contact[]; // 搜索结果调用API获取不依赖分组数据
searchLoading: boolean;
// ==================== 虚拟滚动状态(每个分组独立)====================
virtualScrollStates: Map<string, VirtualScrollState>;
// ==================== 操作方法 ====================
// 分组管理
setGroups: (groups: ContactGroup[]) => Promise<void>; // 设置分组列表(带缓存)
loadGroups: (accountId?: number) => Promise<ContactGroup[]>; // 加载分组列表(带缓存)
loadGroupsFromAPI: (accountId: number) => Promise<ContactGroup[]>; // 从API加载分组列表
toggleGroup: (groupId: number, groupType: 1 | 2) => Promise<void>; // 切换分组展开/折叠
// 分组编辑操作
addGroup: (
group: Omit<ContactGroup, "id" | "count">,
) => Promise<void>; // 新增分组
updateGroup: (group: ContactGroup) => Promise<void>; // 更新分组
deleteGroup: (groupId: number, groupType: 1 | 2) => Promise<void>; // 删除分组
// 分组数据加载
loadGroupContacts: (
groupId: number,
groupType: 1 | 2,
page?: number,
limit?: number,
) => Promise<void>; // 加载分组联系人(懒加载,带缓存)
loadGroupContactsFromAPI: (
groupId: number,
groupType: 1 | 2,
page?: number,
limit?: number,
) => Promise<void>; // 从API加载分组联系人
loadMoreGroupContacts: (
groupId: number,
groupType: 1 | 2,
) => Promise<void>; // 加载更多
// 搜索
searchContacts: (keyword: string) => Promise<void>; // 搜索调用API同时请求好友和群列表
clearSearch: () => void; // 清除搜索
// 切换账号
switchAccount: (accountId: number) => Promise<void>; // 切换账号(重新加载展开的分组)
// 联系人操作
addContact: (contact: Contact) => void; // 新增联系人(更新对应分组)
updateContact: (contact: Contact) => void; // 更新联系人(更新对应分组)
updateContactRemark: (
contactId: number,
groupId: number,
groupType: 1 | 2,
remark: string,
) => Promise<void>; // 修改联系人备注(右键菜单)
deleteContact: (
contactId: number,
groupId: number,
groupType: 1 | 2,
) => void; // 删除联系人
moveContactToGroup: (
contactId: number,
fromGroupId: number,
toGroupId: number,
groupType: 1 | 2,
) => Promise<void>; // 移动联系人到其他分组(右键菜单)
// 虚拟滚动
setVisibleRange: (
groupKey: string,
start: number,
end: number,
) => void; // 设置可见范围
// ==================== 保留原有接口(向后兼容)====================
// 这些接口保留以兼容现有代码,但建议逐步迁移到新接口
contactList: Contact[];
contactGroups: any[];
currentContact: Contact | null;
loading: boolean;
refreshing: boolean;
searchResults_old: Contact[]; // 重命名避免冲突
isSearchMode_old: boolean; // 重命名避免冲突
visibleContacts: { [key: string]: Contact[] };
loadingStates: { [key: string]: boolean };
hasMore: { [key: string]: boolean };
currentPage: { [key: string]: number };
selectedTransmitContacts: Contact[];
openTransmitModal: boolean;
// 原有方法(保留兼容)
setContactList: (contacts: Contact[]) => void;
setContactGroups: (groups: any[]) => void;
setCurrentContact: (contact: Contact | null) => void;
clearCurrentContact: () => void;
setSearchKeyword_old: (keyword: string) => void; // 重命名避免冲突
clearSearchKeyword: () => void;
setLoading: (loading: boolean) => void;
setRefreshing: (refreshing: boolean) => void;
}

View File

@@ -0,0 +1,986 @@
/**
* 联系人Store - 新架构实现
* 支持分组懒加载、API搜索、分组编辑等功能
*
* 注意这是一个新文件用于逐步迁移。最终会替换原有的contacts.ts
*/
import { createPersistStore } from "@/store/createPersistStore";
import { Contact } from "@/utils/db";
import { useUserStore } from "@/store/module/user";
import { useWeChatAccountStore } from "./account";
import {
ContactGroup,
ContactStoreState,
GroupContactData,
VirtualScrollState,
} from "./contacts.data";
import {
getContactList,
getGroupList,
getLabelsListByGroup,
} from "@/pages/pc/ckbox/weChat/api";
import { addGroup, updateGroup, deleteGroup, moveGroup } from "@/api/module/group";
import { updateFriendInfo } from "@/pages/pc/ckbox/weChat/components/ChatWindow/components/ProfileCard/components/ProfileModules/api";
import {
convertFriendsToContacts,
convertGroupsToContacts,
} from "@/pages/pc/ckbox/weChat/components/SidebarMenu/WechatFriends/extend";
import {
groupListCache,
groupContactsCache,
groupStatsCache,
} from "@/utils/cache";
import { ContactManager } from "@/utils/dbAction/contact";
import { performanceMonitor } from "@/utils/performance";
/**
* 生成分组Key
*/
const getGroupKey = (
groupId: number,
groupType: 1 | 2,
accountId: number,
): string => {
return `${groupId}_${groupType}_${accountId}`;
};
/**
* 联系人Store - 新架构实现
*/
export const useContactStoreNew = createPersistStore<ContactStoreState>(
(set, get) => ({
// ==================== 初始状态 ====================
groups: [],
selectedAccountId: 0, // 0表示"全部"
expandedGroups: new Set<string>(),
groupData: new Map<string, GroupContactData>(),
searchKeyword: "",
isSearchMode: false,
searchResults: [],
searchLoading: false,
virtualScrollStates: new Map<string, VirtualScrollState>(),
// ==================== 保留原有接口(向后兼容)====================
contactList: [],
contactGroups: [],
currentContact: null,
loading: false,
refreshing: false,
searchResults_old: [],
isSearchMode_old: false,
visibleContacts: {},
loadingStates: {},
hasMore: {},
currentPage: {},
selectedTransmitContacts: [],
openTransmitModal: false,
// ==================== 分组管理 ====================
/**
* 设置分组列表
*/
setGroups: async (groups: ContactGroup[]) => {
// 按sort排序
const sortedGroups = [...groups].sort(
(a, b) => (a.sort || 0) - (b.sort || 0),
);
set({ groups: sortedGroups });
// 缓存分组列表
const accountId = get().selectedAccountId;
const cacheKey = `groups_${accountId}`;
await groupListCache.set(cacheKey, sortedGroups);
},
/**
* 加载分组列表(带缓存)
*/
loadGroups: async (accountId?: number) => {
const targetAccountId = accountId ?? get().selectedAccountId;
const cacheKey = `groups_${targetAccountId}`;
// 1. 先尝试从缓存读取
const cached = await groupListCache.get<ContactGroup[]>(cacheKey);
if (cached && cached.length > 0) {
// 立即显示缓存数据
set({ groups: cached });
// 后台更新不阻塞UI
get().loadGroupsFromAPI(targetAccountId).catch(console.error);
return cached;
}
// 2. 无缓存调用API
return await get().loadGroupsFromAPI(targetAccountId);
},
/**
* 从API加载分组列表
*/
loadGroupsFromAPI: async (accountId: number) => {
try {
const userId = useUserStore.getState().user?.id;
if (!userId) {
throw new Error("用户未登录");
}
const params: any = {
page: 1,
limit: 1000, // 分组列表通常不会太多
};
if (accountId !== 0) {
params.wechatAccountId = accountId;
}
// 并行请求好友分组和群分组
const [friendsResult, groupsResult] = await Promise.all([
getLabelsListByGroup(1, params, { debounceGap: 0 }),
getLabelsListByGroup(2, params, { debounceGap: 0 }),
]);
const friendGroups: ContactGroup[] =
friendsResult?.list?.map((item: any) => ({
id: item.id,
groupName: item.groupName,
groupType: 1,
count: item.count || 0,
sort: item.sort || 0,
groupMemo: item.groupMemo || "",
})) || [];
const groupGroups: ContactGroup[] =
groupsResult?.list?.map((item: any) => ({
id: item.id,
groupName: item.groupName,
groupType: 2,
count: item.count || 0,
sort: item.sort || 0,
groupMemo: item.groupMemo || "",
})) || [];
const allGroups = [...friendGroups, ...groupGroups];
await get().setGroups(allGroups);
return allGroups;
} catch (error) {
console.error("加载分组列表失败:", error);
throw error;
}
},
/**
* 切换分组展开/折叠
*/
toggleGroup: async (groupId: number, groupType: 1 | 2) => {
return performanceMonitor.measureAsync(
`ContactStore.toggleGroup(${groupId}, ${groupType})`,
async () => {
const state = get();
const accountId = state.selectedAccountId;
const groupKey = getGroupKey(groupId, groupType, accountId);
const expandedGroups = new Set(state.expandedGroups);
if (expandedGroups.has(groupKey)) {
// 折叠
expandedGroups.delete(groupKey);
set({ expandedGroups });
} else {
// 展开 - 懒加载
expandedGroups.add(groupKey);
set({ expandedGroups });
// 加载分组联系人
await get().loadGroupContacts(groupId, groupType, 1, 50);
}
},
{ groupId, groupType },
);
},
// ==================== 分组数据加载 ====================
/**
* 加载分组联系人(懒加载,带缓存)
*/
loadGroupContacts: async (
groupId: number,
groupType: 1 | 2,
page: number = 1,
limit: number = 50,
) => {
// 在 measureAsync 调用之前获取 accountId用于 metadata
const accountIdForMetadata = get().selectedAccountId;
return performanceMonitor.measureAsync(
`ContactStore.loadGroupContacts(${groupId}, ${groupType}, page=${page})`,
async () => {
const state = get();
// 在异步函数内部重新获取 accountId确保使用执行时的值
const accountId = state.selectedAccountId;
const groupKey = getGroupKey(groupId, groupType, accountId);
const currentData = state.groupData.get(groupKey);
// 如果已经加载过且不是第一页,直接返回
if (currentData?.loaded && page > 1 && !currentData.hasMore) {
return;
}
// 第一页时,先尝试从缓存读取
if (page === 1) {
const cacheKey = `groupContacts_${groupKey}`;
const cached = await groupContactsCache.get<GroupContactData>(cacheKey);
if (cached && cached.contacts && cached.contacts.length > 0) {
// 立即显示缓存数据
const groupData = new Map(state.groupData);
groupData.set(groupKey, {
...cached,
loading: false,
});
set({ groupData });
// 后台更新不阻塞UI
get()
.loadGroupContactsFromAPI(groupId, groupType, page, limit)
.catch(console.error);
return;
} else {
// 没有缓存先设置loading状态
const groupData = new Map(state.groupData);
groupData.set(groupKey, {
contacts: [],
page: 1,
pageSize: limit,
hasMore: true,
loading: true,
loaded: false,
lastLoadTime: Date.now(),
});
set({ groupData });
}
} else {
// 非第一页先设置loading状态
const groupData = new Map(state.groupData);
const existingData = state.groupData.get(groupKey);
groupData.set(groupKey, {
contacts: existingData?.contacts || [],
page: existingData?.page || 1,
pageSize: limit,
hasMore: existingData?.hasMore ?? true,
loading: true,
loaded: existingData?.loaded || false,
lastLoadTime: Date.now(),
});
set({ groupData });
}
// 调用API加载数据
await get().loadGroupContactsFromAPI(groupId, groupType, page, limit);
},
{ groupId, groupType, page, accountId: accountIdForMetadata },
);
},
/**
* 从API加载分组联系人
*/
loadGroupContactsFromAPI: async (
groupId: number,
groupType: 1 | 2,
page: number = 1,
limit: number = 50,
) => {
const state = get();
const accountId = state.selectedAccountId;
const groupKey = getGroupKey(groupId, groupType, accountId);
const currentData = state.groupData.get(groupKey);
// 注意loadGroupContacts 已经设置了 loading 状态,所以这里直接执行 API 调用
// 但如果 currentData 不存在(直接调用此方法),需要设置初始状态
if (!currentData) {
const groupData = new Map(state.groupData);
const loadingData: GroupContactData = {
contacts: [],
page,
pageSize: limit,
hasMore: true,
loading: true,
loaded: false,
lastLoadTime: Date.now(),
};
groupData.set(groupKey, loadingData);
set({ groupData });
}
try {
const userId = useUserStore.getState().user?.id;
if (!userId) {
throw new Error("用户未登录");
}
let contacts: Contact[] = [];
const params: any = {
page,
limit,
};
// 根据groupType调用不同的API
if (groupType === 1) {
// 好友列表API
if (groupId !== 0) {
params.groupId = groupId; // 分组ID
}
if (accountId !== 0) {
params.wechatAccountId = accountId;
}
const result = await getContactList(params, { debounceGap: 0 });
const friendList = result?.list || [];
contacts = convertFriendsToContacts(friendList, userId);
} else if (groupType === 2) {
// 群列表API
if (groupId !== 0) {
params.groupId = groupId; // 分组ID
}
if (accountId !== 0) {
params.wechatAccountId = accountId;
}
const result = await getGroupList(params, { debounceGap: 0 });
const groupList = result?.list || [];
contacts = convertGroupsToContacts(groupList, userId);
}
// 更新数据(从最新的 state 获取当前数据,确保使用最新的 contacts
const latestState = get();
const latestGroupData = latestState.groupData.get(groupKey);
const existingContacts = latestGroupData?.contacts || [];
const updatedData: GroupContactData = {
contacts:
page === 1
? contacts
: [...existingContacts, ...contacts], // 使用最新的 contacts而不是 currentData
page,
pageSize: limit,
hasMore: contacts.length === limit,
loading: false,
loaded: true,
lastLoadTime: Date.now(),
};
const updatedGroupData = new Map(latestState.groupData);
updatedGroupData.set(groupKey, updatedData);
set({ groupData: updatedGroupData });
// 缓存第一页数据
if (page === 1) {
const cacheKey = `groupContacts_${groupKey}`;
await groupContactsCache.set(cacheKey, updatedData);
}
} catch (error) {
console.error("加载分组联系人失败:", error);
// 恢复加载状态
const errorGroupData = new Map(groupData);
errorGroupData.set(groupKey, {
...currentData,
loading: false,
});
set({ groupData: errorGroupData });
throw error;
}
},
/**
* 加载更多分组联系人
*/
loadMoreGroupContacts: async (groupId: number, groupType: 1 | 2) => {
const state = get();
const accountId = state.selectedAccountId;
const groupKey = getGroupKey(groupId, groupType, accountId);
const currentData = state.groupData.get(groupKey);
if (!currentData || !currentData.hasMore || currentData.loading) {
return; // 没有更多数据或正在加载
}
// 加载下一页
const nextPage = currentData.page + 1;
await get().loadGroupContacts(
groupId,
groupType,
nextPage,
currentData.pageSize,
);
},
// ==================== 搜索 ====================
/**
* 搜索联系人API搜索并行请求
*/
searchContacts: async (keyword: string) => {
if (!keyword.trim()) {
set({
isSearchMode: false,
searchResults: [],
searchKeyword: "",
});
return;
}
set({
searchKeyword: keyword,
isSearchMode: true,
searchLoading: true,
});
return performanceMonitor.measureAsync(
`ContactStore.searchContacts("${keyword}")`,
async () => {
try {
const userId = useUserStore.getState().user?.id;
if (!userId) {
throw new Error("用户未登录");
}
const accountId = get().selectedAccountId;
const params: any = {
keyword,
page: 1,
limit: 100, // 搜索时可能需要加载更多结果
};
if (accountId !== 0) {
params.wechatAccountId = accountId;
}
// 并行请求好友列表和群列表
const [friendsResult, groupsResult] = await Promise.all([
getContactList(params, { debounceGap: 0 }),
getGroupList(params, { debounceGap: 0 }),
]);
// 合并结果
const friends = friendsResult?.list || [];
const groups = groupsResult?.list || [];
const friendContacts = convertFriendsToContacts(friends, userId);
const groupContacts = convertGroupsToContacts(groups, userId);
const allResults = [...friendContacts, ...groupContacts];
set({
searchResults: allResults,
searchLoading: false,
});
} catch (error) {
console.error("搜索联系人失败:", error);
set({
searchResults: [],
searchLoading: false,
});
throw error;
}
},
{ keyword, accountId: get().selectedAccountId },
);
},
/**
* 清除搜索
*/
clearSearch: () => {
set({
searchKeyword: "",
isSearchMode: false,
searchResults: [],
searchLoading: false,
});
},
// ==================== 切换账号 ====================
/**
* 切换账号(重新加载展开的分组)
*/
switchAccount: async (accountId: number) => {
return performanceMonitor.measureAsync(
`ContactStore.switchAccount(${accountId})`,
async () => {
const state = get();
set({ selectedAccountId: accountId });
// 重新加载展开的分组
const expandedGroups = Array.from(state.expandedGroups);
const groupData = new Map<string, GroupContactData>();
// 清理旧账号的数据
for (const groupKey of expandedGroups) {
// 检查是否是当前账号的分组
const parts = groupKey.split("_");
if (parts.length === 3) {
const oldAccountId = parseInt(parts[2], 10);
if (oldAccountId === accountId) {
// 保留当前账号的数据
const oldData = state.groupData.get(groupKey);
if (oldData) {
groupData.set(groupKey, oldData);
}
}
}
}
set({ groupData });
// 重新加载展开的分组
for (const groupKey of expandedGroups) {
const parts = groupKey.split("_");
if (parts.length === 3) {
const groupId = parseInt(parts[0], 10);
const groupType = parseInt(parts[1], 10) as 1 | 2;
const oldAccountId = parseInt(parts[2], 10);
if (oldAccountId !== accountId) {
// 不同账号,需要重新加载
const newGroupKey = getGroupKey(groupId, groupType, accountId);
const expandedGroupsNew = new Set(state.expandedGroups);
expandedGroupsNew.delete(groupKey);
expandedGroupsNew.add(newGroupKey);
set({ expandedGroups: expandedGroupsNew });
await get().loadGroupContacts(groupId, groupType, 1, 50);
}
}
}
},
{ accountId, expandedGroupsCount: state.expandedGroups.size },
);
},
// ==================== 分组编辑操作 ====================
/**
* 新增分组
*/
addGroup: async (group: Omit<ContactGroup, "id" | "count">) => {
try {
const result: any = await addGroup({
groupName: group.groupName,
groupMemo: group.groupMemo || "",
groupType: group.groupType,
sort: group.sort || 0,
});
const newGroup: ContactGroup = {
id: result?.id || result?.data?.id || 0,
groupName: result?.groupName || result?.data?.groupName || group.groupName,
groupType: (result?.groupType || result?.data?.groupType || group.groupType) as 1 | 2,
count: 0, // 新分组初始数量为0
sort: result?.sort || result?.data?.sort || group.sort || 0,
groupMemo: result?.groupMemo || result?.data?.groupMemo || group.groupMemo,
};
const groups = [...get().groups, newGroup];
get().setGroups(groups);
} catch (error) {
console.error("新增分组失败:", error);
throw error;
}
},
/**
* 更新分组
*/
updateGroup: async (group: ContactGroup) => {
try {
await updateGroup({
id: group.id,
groupName: group.groupName,
groupMemo: group.groupMemo || "",
groupType: group.groupType,
sort: group.sort || 0,
});
const groups = get().groups.map(g =>
g.id === group.id ? group : g,
);
get().setGroups(groups);
} catch (error) {
console.error("更新分组失败:", error);
throw error;
}
},
/**
* 删除分组
*/
deleteGroup: async (groupId: number, groupType: 1 | 2) => {
try {
await deleteGroup(groupId);
// 从分组列表删除
const groups = get().groups.filter(
g => !(g.id === groupId && g.groupType === groupType),
);
get().setGroups(groups);
// 清理该分组的所有缓存数据
const groupData = new Map(get().groupData);
const expandedGroups = new Set(get().expandedGroups);
// 清理所有账号的该分组数据
groupData.forEach((value, key) => {
if (key.startsWith(`${groupId}_${groupType}_`)) {
groupData.delete(key);
}
});
// 清理展开状态
expandedGroups.forEach(key => {
if (key.startsWith(`${groupId}_${groupType}_`)) {
expandedGroups.delete(key);
}
});
set({ groupData, expandedGroups });
} catch (error) {
console.error("删除分组失败:", error);
throw error;
}
},
// ==================== 联系人操作 ====================
/**
* 新增联系人(更新对应分组)
*/
addContact: (contact: Contact) => {
const state = get();
const accountId = state.selectedAccountId;
const groupKey = getGroupKey(
contact.groupId || 0,
contact.type === "friend" ? 1 : 2,
accountId,
);
const groupData = new Map(state.groupData);
const groupDataItem = groupData.get(groupKey);
if (groupDataItem && groupDataItem.loaded) {
// 如果分组已加载,添加到列表
groupData.set(groupKey, {
...groupDataItem,
contacts: [...groupDataItem.contacts, contact],
});
set({ groupData });
}
},
/**
* 更新联系人(更新对应分组)
*/
updateContact: (contact: Contact) => {
const state = get();
const accountId = state.selectedAccountId;
const groupKey = getGroupKey(
contact.groupId || 0,
contact.type === "friend" ? 1 : 2,
accountId,
);
const groupData = new Map(state.groupData);
const groupDataItem = groupData.get(groupKey);
if (groupDataItem && groupDataItem.loaded) {
const contacts = groupDataItem.contacts.map(c =>
c.id === contact.id ? contact : c,
);
groupData.set(groupKey, {
...groupDataItem,
contacts,
});
set({ groupData });
}
// 如果当前在搜索模式,更新搜索结果
if (state.isSearchMode) {
const searchResults = state.searchResults.map(c =>
c.id === contact.id ? contact : c,
);
set({ searchResults });
}
},
/**
* 修改联系人备注(右键菜单)
*/
updateContactRemark: async (
contactId: number,
groupId: number,
groupType: 1 | 2,
remark: string,
) => {
try {
// 调用API更新备注
await updateFriendInfo({
id: contactId,
conRemark: remark,
phone: "",
company: "",
name: "",
position: "",
email: "",
address: "",
qq: "",
remark: "",
});
// 更新内存中的数据
const state = get();
const accountId = state.selectedAccountId;
const groupKey = getGroupKey(groupId, groupType, accountId);
const groupData = new Map(state.groupData);
const groupDataItem = groupData.get(groupKey);
if (groupDataItem && groupDataItem.loaded) {
const contacts = groupDataItem.contacts.map(c =>
c.id === contactId ? { ...c, conRemark: remark } : c,
);
groupData.set(groupKey, {
...groupDataItem,
contacts,
});
set({ groupData });
// 更新缓存
const cacheKey = `groupContacts_${groupKey}`;
const cachedData = await groupContactsCache.get<GroupContactData>(
cacheKey,
);
if (cachedData && cachedData.contacts) {
const updatedContacts = cachedData.contacts.map(c =>
c.id === contactId ? { ...c, conRemark: remark } : c,
);
await groupContactsCache.set(cacheKey, {
...cachedData,
contacts: updatedContacts,
});
}
}
// 如果当前在搜索模式,更新搜索结果
if (state.isSearchMode) {
const searchResults = state.searchResults.map(c =>
c.id === contactId ? { ...c, conRemark: remark } : c,
);
set({ searchResults });
}
// 更新数据库
const userId = useUserStore.getState().user?.id;
if (userId) {
const contact = await ContactManager.getContactByIdAndType(
userId,
contactId,
groupType === 1 ? "friend" : "group",
);
if (contact) {
await ContactManager.updateContact({
...contact,
conRemark: remark,
});
}
}
} catch (error) {
console.error("更新联系人备注失败:", error);
throw error;
}
},
/**
* 删除联系人
*/
deleteContact: (contactId: number, groupId: number, groupType: 1 | 2) => {
const state = get();
const accountId = state.selectedAccountId;
const groupKey = getGroupKey(groupId, groupType, accountId);
const groupData = new Map(state.groupData);
const groupDataItem = groupData.get(groupKey);
if (groupDataItem && groupDataItem.loaded) {
const contacts = groupDataItem.contacts.filter(c => c.id !== contactId);
groupData.set(groupKey, {
...groupDataItem,
contacts,
});
set({ groupData });
}
// 如果当前在搜索模式,从搜索结果中删除
if (state.isSearchMode) {
const searchResults = state.searchResults.filter(
c => c.id !== contactId,
);
set({ searchResults });
}
},
/**
* 移动联系人到其他分组(右键菜单)
*/
moveContactToGroup: async (
contactId: number,
fromGroupId: number,
toGroupId: number,
groupType: 1 | 2,
) => {
try {
// 调用API移动分组
await moveGroup({
type: groupType === 1 ? "friend" : "group",
groupId: toGroupId,
id: contactId,
});
const state = get();
const accountId = state.selectedAccountId;
// 从原分组移除
const fromGroupKey = getGroupKey(fromGroupId, groupType, accountId);
const groupData = new Map(state.groupData);
const fromGroupData = groupData.get(fromGroupKey);
if (fromGroupData && fromGroupData.loaded) {
const contacts = fromGroupData.contacts.filter(
c => c.id !== contactId,
);
groupData.set(fromGroupKey, {
...fromGroupData,
contacts,
});
}
// 添加到新分组(如果已加载)
const toGroupKey = getGroupKey(toGroupId, groupType, accountId);
const toGroupData = groupData.get(toGroupKey);
if (toGroupData && toGroupData.loaded) {
// 重新加载该联系人数据因为groupId已变化
const userId = useUserStore.getState().user?.id;
if (userId) {
const updatedContact = await ContactManager.getContactByIdAndType(
userId,
contactId,
groupType === 1 ? "friend" : "group",
);
if (updatedContact) {
const updatedContacts = [...toGroupData.contacts, updatedContact];
groupData.set(toGroupKey, {
...toGroupData,
contacts: updatedContacts,
});
// 更新缓存
const cacheKey = `groupContacts_${toGroupKey}`;
const cachedData = await groupContactsCache.get<GroupContactData>(
cacheKey,
);
if (cachedData) {
await groupContactsCache.set(cacheKey, {
...cachedData,
contacts: updatedContacts,
});
}
}
}
}
set({ groupData });
} catch (error) {
console.error("移动联系人失败:", error);
throw error;
}
},
// ==================== 虚拟滚动 ====================
/**
* 设置可见范围
*/
setVisibleRange: (groupKey: string, start: number, end: number) => {
const virtualScrollStates = new Map(get().virtualScrollStates);
virtualScrollStates.set(groupKey, {
startIndex: start,
endIndex: end,
itemHeight: 60, // 默认高度
containerHeight: 600, // 默认容器高度
totalHeight: 0, // 需要根据数据计算
});
set({ virtualScrollStates });
},
// ==================== 保留原有方法(向后兼容)====================
setContactList: (contacts: Contact[]) => {
set({ contactList: contacts });
},
setContactGroups: (groups: any[]) => {
set({ contactGroups: groups });
},
setCurrentContact: (contact: Contact | null) => {
set({ currentContact: contact });
},
clearCurrentContact: () => {
set({ currentContact: null });
},
setSearchKeyword_old: (keyword: string) => {
set({ searchKeyword: keyword });
},
clearSearchKeyword: () => {
get().clearSearch();
},
setLoading: (loading: boolean) => {
set({ loading });
},
setRefreshing: (refreshing: boolean) => {
set({ refreshing });
},
}),
{
name: "contacts-store-new",
partialize: state => {
// Map和Set类型需要转换为数组才能持久化
const expandedGroupsArray = Array.from(state.expandedGroups);
const groupDataArray = Array.from(state.groupData.entries());
const virtualScrollStatesArray = Array.from(
state.virtualScrollStates.entries(),
);
return {
groups: state.groups,
selectedAccountId: state.selectedAccountId,
expandedGroups: expandedGroupsArray,
groupData: groupDataArray,
searchKeyword: state.searchKeyword,
isSearchMode: state.isSearchMode,
virtualScrollStates: virtualScrollStatesArray,
// 保留原有字段
contactList: state.contactList,
contactGroups: state.contactGroups,
currentContact: state.currentContact,
};
},
// 恢复时将数组转换回Map和Set
onRehydrateStorage: () => (state: any, error: any) => {
if (error) {
console.error("ContactStore rehydration error:", error);
return;
}
if (state) {
if (Array.isArray(state.expandedGroups)) {
state.expandedGroups = new Set(state.expandedGroups);
}
if (Array.isArray(state.groupData)) {
state.groupData = new Map(state.groupData);
}
if (Array.isArray(state.virtualScrollStates)) {
state.virtualScrollStates = new Map(state.virtualScrollStates);
}
}
},
},
);

View File

@@ -64,7 +64,7 @@ export interface MessageState {
updateCurrentMessage: (message: Message) => void;
// ==================== 新的会话数据接口 ====================
// 当前会话列表
// 当前会话列表(过滤后的,用于显示)
sessions: ChatSession[];
// 设置或更新会话列表(支持回调写法)
setSessions: (updater: SessionsUpdater) => void;
@@ -74,4 +74,37 @@ export interface MessageState {
removeSessionById: (sessionId: number, type: ChatSession["type"]) => void;
// 清空所有会话(登出/切账号使用)
clearSessions: () => void;
// ==================== 新架构索引和缓存阶段1.2 ====================
// 全部会话数据(一次性加载全部)
allSessions: ChatSession[];
// 会话索引accountId -> sessions[]O(1)快速查找)
sessionIndex: Map<number, ChatSession[]>;
// 过滤结果缓存accountId -> filteredSessions[](避免重复计算)
filteredSessionsCache: Map<number, ChatSession[]>;
// 缓存有效性标记accountId -> boolean
cacheValid: Map<number, boolean>;
// 当前选中的账号ID0表示"全部"
selectedAccountId: number;
// 搜索关键词
searchKeyword: string;
// 排序方式
sortBy: "time" | "unread" | "name";
// 设置全部会话数据并构建索引(带缓存)
setAllSessions: (sessions: ChatSession[]) => Promise<void>;
// 从缓存加载会话列表
loadSessionsFromCache: (accountId: number) => Promise<ChatSession[] | null>;
// 构建索引(数据加载时调用)
buildIndexes: (sessions: ChatSession[]) => void;
// 切换账号(使用索引快速过滤)
switchAccount: (accountId: number) => ChatSession[];
// 新增会话(增量更新索引)
addSession: (session: ChatSession) => void;
// 设置搜索关键词
setSearchKeyword: (keyword: string) => void;
// 设置排序方式
setSortBy: (sortBy: "time" | "unread" | "name") => void;
// 失效缓存(数据更新时调用)
invalidateCache: (accountId?: number) => void;
}

View File

@@ -2,6 +2,8 @@ import { create } from "zustand";
import { persist } from "zustand/middleware";
import { ChatSession } from "@/utils/db";
import { Message, MessageState, SessionsUpdater } from "./message.data";
import { sessionListCache } from "@/utils/cache";
import { performanceMonitor } from "@/utils/performance";
const computeSortKey = (session: ChatSession) => {
const isTop = session.config?.top ? 1 : 0;
@@ -115,17 +117,304 @@ export const useMessageStore = create<MessageState>()(
sessions: [],
lastRefreshTime: new Date().toISOString(),
}),
// ==================== 新架构索引和缓存阶段1.2 ====================
allSessions: [],
sessionIndex: new Map<number, ChatSession[]>(),
filteredSessionsCache: new Map<number, ChatSession[]>(),
cacheValid: new Map<number, boolean>(),
selectedAccountId: 0, // 0表示"全部"
searchKeyword: "",
sortBy: "time" as "time" | "unread" | "name",
/**
* 构建索引(数据加载时调用)
* 时间复杂度O(n),只执行一次
*/
buildIndexes: (sessions: ChatSession[]) => {
const sessionIndex = new Map<number, ChatSession[]>();
sessions.forEach(session => {
const accountId = session.wechatAccountId;
if (!sessionIndex.has(accountId)) {
sessionIndex.set(accountId, []);
}
sessionIndex.get(accountId)!.push(session);
});
set({
allSessions: sessions,
sessionIndex,
// 失效所有缓存
cacheValid: new Map(),
});
},
/**
* 设置全部会话数据并构建索引(带缓存)
*/
setAllSessions: async (sessions: ChatSession[]) => {
const normalized = normalizeSessions(sessions);
get().buildIndexes(normalized);
// 缓存会话列表
const accountId = get().selectedAccountId;
const cacheKey = `sessions_${accountId}`;
await sessionListCache.set(cacheKey, normalized);
},
/**
* 从缓存加载会话列表
*/
loadSessionsFromCache: async (accountId: number) => {
const cacheKey = `sessions_${accountId}`;
const cached = await sessionListCache.get<ChatSession[]>(cacheKey);
if (cached && cached.length > 0) {
// 立即显示缓存数据
get().buildIndexes(cached);
get().switchAccount(accountId);
return cached;
}
return null;
},
/**
* 切换账号(使用索引快速过滤)
* 时间复杂度O(1) 获取 + O(n) 过滤n是当前账号的数据量不是全部数据量
*/
switchAccount: (accountId: number) => {
const currentState = get(); // 在外部获取state用于metadata
return performanceMonitor.measure(
`SessionStore.switchAccount(${accountId})`,
() => {
const state = get();
// 1. 检查缓存O(1) - 最快,< 1ms
if (
state.filteredSessionsCache.has(accountId) &&
state.cacheValid.get(accountId)
) {
const cached = state.filteredSessionsCache.get(accountId)!;
set({
sessions: cached,
selectedAccountId: accountId,
});
return cached;
}
// 2. 使用索引快速获取O(1) - 次快,< 1ms
let filteredSessions: ChatSession[];
if (accountId === 0) {
// "全部":返回所有会话
filteredSessions = state.allSessions;
} else {
// 特定账号从索引获取O(1),不遍历全部数据)
filteredSessions = state.sessionIndex.get(accountId) || [];
}
// 3. 应用搜索和排序(如果需要)
if (state.searchKeyword) {
filteredSessions = filteredSessions.filter(s =>
(s.conRemark || s.nickname || (s as any).wechatId || "")
.toLowerCase()
.includes(state.searchKeyword.toLowerCase()),
);
}
if (state.sortBy !== "time") {
// 排序逻辑(可以根据需要扩展)
filteredSessions = normalizeSessions(filteredSessions);
} else {
// 默认按时间排序
filteredSessions = normalizeSessions(filteredSessions);
}
// 4. 缓存结果(避免下次切换时重复计算)
const newCache = new Map(state.filteredSessionsCache);
newCache.set(accountId, filteredSessions);
const newCacheValid = new Map(state.cacheValid);
newCacheValid.set(accountId, true);
set({
sessions: filteredSessions,
selectedAccountId: accountId,
filteredSessionsCache: newCache,
cacheValid: newCacheValid,
});
return filteredSessions;
},
{ accountId, sessionCount: currentState.allSessions.length },
);
},
/**
* 新增会话(增量更新索引)
*/
addSession: (session: ChatSession) => {
try {
const state = get();
// 边界检查确保session有效
if (!session || !session.id) {
console.warn("addSession: 无效的会话数据", session);
return;
}
// 检查是否已存在(避免重复添加)
const existingIndex = state.allSessions.findIndex(
s => s.id === session.id && s.type === session.type,
);
if (existingIndex >= 0) {
// 已存在,更新而不是添加
const allSessions = [...state.allSessions];
allSessions[existingIndex] = session;
// 更新索引
const accountId = session.wechatAccountId || 0;
const sessionIndex = new Map(state.sessionIndex);
const accountSessions = sessionIndex.get(accountId) || [];
const indexInAccount = accountSessions.findIndex(
s => s.id === session.id && s.type === session.type,
);
if (indexInAccount >= 0) {
accountSessions[indexInAccount] = session;
} else {
accountSessions.push(session);
}
sessionIndex.set(accountId, accountSessions);
// 失效缓存
const cacheValid = new Map(state.cacheValid);
cacheValid.set(accountId, false);
cacheValid.set(0, false);
set({
allSessions,
sessionIndex,
cacheValid,
});
// 如果当前显示的是该账号,重新过滤
if (state.selectedAccountId === accountId || state.selectedAccountId === 0) {
get().switchAccount(state.selectedAccountId);
}
return;
}
// 1. 添加到全部数据
const allSessions = [...state.allSessions, session];
// 2. 更新索引O(1)
const accountId = session.wechatAccountId || 0;
const sessionIndex = new Map(state.sessionIndex);
if (!sessionIndex.has(accountId)) {
sessionIndex.set(accountId, []);
}
sessionIndex.get(accountId)!.push(session);
// 3. 失效缓存(如果当前显示的是该账号)
const cacheValid = new Map(state.cacheValid);
if (state.selectedAccountId === accountId || state.selectedAccountId === 0) {
cacheValid.set(accountId, false);
cacheValid.set(0, false); // "全部"的缓存也失效
}
set({
allSessions,
sessionIndex,
cacheValid,
});
// 4. 如果当前显示的是该账号,重新过滤
if (state.selectedAccountId === accountId || state.selectedAccountId === 0) {
get().switchAccount(state.selectedAccountId);
}
} catch (error) {
console.error("addSession失败:", error, session);
}
},
/**
* 设置搜索关键词
*/
setSearchKeyword: (keyword: string) => {
set({ searchKeyword: keyword });
// 失效缓存,重新过滤
get().invalidateCache();
get().switchAccount(get().selectedAccountId);
},
/**
* 设置排序方式
*/
setSortBy: (sortBy: "time" | "unread" | "name") => {
set({ sortBy });
// 失效缓存,重新过滤
get().invalidateCache();
get().switchAccount(get().selectedAccountId);
},
/**
* 失效缓存(数据更新时调用)
*/
invalidateCache: (accountId?: number) => {
const cacheValid = new Map(get().cacheValid);
if (accountId !== undefined) {
cacheValid.set(accountId, false);
} else {
// 失效所有缓存
cacheValid.forEach((_, key) => {
cacheValid.set(key, false);
});
}
set({ cacheValid });
},
}),
{
name: "message-storage",
partialize: state => ({
// 只持久化必要的状态,不持久化数据
lastRefreshTime: state.lastRefreshTime,
hasLoadedOnce: state.hasLoadedOnce,
// 保留原有持久化字段(向后兼容)
messageList: [],
currentMessage: null,
}),
partialize: state => {
// Map类型需要转换为数组才能持久化
const sessionIndexArray = Array.from(state.sessionIndex.entries());
const filteredSessionsCacheArray = Array.from(
state.filteredSessionsCache.entries(),
);
const cacheValidArray = Array.from(state.cacheValid.entries());
return {
// 只持久化必要的状态,不持久化数据
lastRefreshTime: state.lastRefreshTime,
hasLoadedOnce: state.hasLoadedOnce,
// 保留原有持久化字段(向后兼容)
messageList: [],
currentMessage: null,
// 新架构字段Map转换为数组
selectedAccountId: state.selectedAccountId,
searchKeyword: state.searchKeyword,
sortBy: state.sortBy,
sessionIndex: sessionIndexArray,
filteredSessionsCache: filteredSessionsCacheArray,
cacheValid: cacheValidArray,
};
},
// 恢复时将数组转换回Map
onRehydrateStorage: () => (state: any, error: any) => {
if (error) {
console.error("MessageStore rehydration error:", error);
return;
}
if (state) {
if (Array.isArray(state.sessionIndex)) {
state.sessionIndex = new Map(state.sessionIndex);
}
if (Array.isArray(state.filteredSessionsCache)) {
state.filteredSessionsCache = new Map(state.filteredSessionsCache);
}
if (Array.isArray(state.cacheValid)) {
state.cacheValid = new Map(state.cacheValid);
}
}
},
},
),
);

View File

@@ -7,6 +7,14 @@ import { db } from "@/utils/db";
import { Modal } from "antd";
import { useCustomerStore, updateCustomerList } from "../weChat/customer";
import { dataProcessing, asyncMessageStatus } from "@/api/ai";
import { useContactStoreNew } from "../weChat/contacts.new";
import { useMessageStore } from "../weChat/message";
import { Contact, ChatSession } from "@/utils/db";
import { MessageManager } from "@/utils/dbAction/message";
import { ContactManager } from "@/utils/dbAction/contact";
import { groupContactsCache, sessionListCache } from "@/utils/cache";
import { GroupContactData } from "../weChat/contacts.data";
import { performanceMonitor } from "@/utils/performance";
// 消息处理器类型定义
type MessageHandler = (message: WebSocketMessage) => void;
@@ -93,35 +101,195 @@ const messageHandlers: Record<string, MessageHandler> = {
// 在这里添加具体的处理逻辑
},
//收到消息
CmdNewMessage: (message: Messages) => {
// 处理消息本身
const { receivedMsg } = getWeChatStoreMethods();
receivedMsg(message.friendMessage || message.chatroomMessage);
//异步传新消息给数据库
goAsyncServiceData(message);
// 触发会话列表更新事件
const msgData = message.friendMessage || message.chatroomMessage;
if (msgData) {
const sessionId = message.friendMessage
? message.friendMessage.wechatFriendId
: message.chatroomMessage?.wechatChatroomId;
const type = message.friendMessage ? "friend" : "group";
// 发送自定义事件通知MessageList组件
window.dispatchEvent(
new CustomEvent("chatMessageReceived", {
detail: {
message: msgData,
sessionId,
type,
},
}),
);
CmdNewMessage: async (message: Messages) => {
// 边界检查:确保消息数据有效
if (!message || (!message.friendMessage && !message.chatroomMessage)) {
console.warn("CmdNewMessage: 无效的消息数据", message);
return;
}
return performanceMonitor.measureAsync(
"WebSocket.CmdNewMessage",
async () => {
try {
// 处理消息本身
const { receivedMsg } = getWeChatStoreMethods();
const msgData = message.friendMessage || message.chatroomMessage;
if (msgData) {
receivedMsg(msgData);
}
//异步传新消息给数据库(不阻塞主流程)
try {
goAsyncServiceData(message);
} catch (error) {
console.error("异步同步消息到数据库失败:", error);
}
// 触发会话列表更新事件
if (msgData) {
const sessionId = message.friendMessage
? message.friendMessage.wechatFriendId
: message.chatroomMessage?.wechatChatroomId;
const type = message.friendMessage ? "friend" : "group";
const wechatAccountId =
message.friendMessage?.wechatAccountId ||
message.chatroomMessage?.wechatAccountId ||
0;
// 边界检查确保sessionId有效
if (!sessionId) {
console.warn("CmdNewMessage: 缺少sessionId", message);
return;
}
// 更新新架构的SessionStore增量更新索引和缓存
try {
const userId =
useCustomerStore.getState().currentCustomer?.userId || 0;
if (userId > 0) {
// 从数据库获取更新后的会话信息(带超时保护)
const updatedSession = await Promise.race([
MessageManager.getSessionByContactId(userId, sessionId, type),
new Promise<null>(resolve =>
setTimeout(() => resolve(null), 5000),
), // 5秒超时
]);
if (updatedSession) {
const messageStore = useMessageStore.getState();
// 增量更新索引
messageStore.addSession(updatedSession);
// 失效缓存,下次切换账号时会重新计算
messageStore.invalidateCache(wechatAccountId);
messageStore.invalidateCache(0); // 也失效"全部"的缓存
// 更新会话列表缓存(不阻塞主流程)
const cacheKey = `sessions_${wechatAccountId}`;
sessionListCache
.get<ChatSession[]>(cacheKey)
.then(cachedSessions => {
if (cachedSessions) {
// 更新缓存中的会话
const index = cachedSessions.findIndex(
s =>
s.id === updatedSession.id &&
s.type === updatedSession.type,
);
if (index >= 0) {
cachedSessions[index] = updatedSession;
} else {
cachedSessions.push(updatedSession);
}
return sessionListCache.set(cacheKey, cachedSessions);
}
})
.catch(error => {
console.error("更新会话缓存失败:", error);
});
}
}
} catch (error) {
console.error("更新SessionStore失败:", error);
// 即使更新失败,也发送事件通知(降级处理)
}
// 发送自定义事件通知MessageList组件
try {
window.dispatchEvent(
new CustomEvent("chatMessageReceived", {
detail: {
message: msgData,
sessionId,
type,
},
}),
);
} catch (error) {
console.error("发送消息事件失败:", error);
}
}
} catch (error) {
console.error("CmdNewMessage处理失败:", error, message);
throw error; // 重新抛出以便性能监控记录错误
}
},
{
messageId: message.friendMessage?.id || message.chatroomMessage?.id,
type: message.friendMessage ? "friend" : "group",
},
);
},
CmdFriendInfoChanged: () => {
// console.log("好友信息变更", message);
// 在这里添加具体的处理逻辑
CmdFriendInfoChanged: async (message: WebSocketMessage) => {
// 好友信息变更更新ContactStore和缓存
// 边界检查:确保消息数据有效
if (!message || !message.friendId) {
console.warn("CmdFriendInfoChanged: 无效的消息数据", message);
return;
}
return performanceMonitor.measureAsync(
"WebSocket.CmdFriendInfoChanged",
async () => {
try {
const contactStore = useContactStoreNew.getState();
const userId =
useCustomerStore.getState().currentCustomer?.userId || 0;
if (!userId) {
console.warn("CmdFriendInfoChanged: 用户未登录");
return;
}
// 从数据库获取更新后的联系人信息(带超时保护)
const updatedContact = await Promise.race([
ContactManager.getContactByIdAndType(
userId,
message.friendId,
"friend",
),
new Promise<null>(resolve => setTimeout(() => resolve(null), 5000)), // 5秒超时
]);
if (updatedContact) {
// 更新ContactStore中的联系人会自动更新分组数据和搜索结果
contactStore.updateContact(updatedContact);
// 更新缓存(如果联系人所在的分组已缓存,不阻塞主流程)
const accountId = contactStore.selectedAccountId;
const groupId = updatedContact.groupId || 0;
const groupType = updatedContact.type === "friend" ? 1 : 2;
const groupKey = `groupContacts_${groupId}_${groupType}_${accountId}`;
groupContactsCache
.get<GroupContactData>(groupKey)
.then(cachedData => {
if (cachedData && cachedData.contacts) {
const updatedContacts = cachedData.contacts.map(c =>
c.id === updatedContact.id ? updatedContact : c,
);
return groupContactsCache.set(groupKey, {
...cachedData,
contacts: updatedContacts,
});
}
})
.catch(error => {
console.error("更新联系人缓存失败:", error);
});
} else {
console.warn(
"CmdFriendInfoChanged: 未找到联系人",
message.friendId,
);
}
} catch (error) {
console.error("更新好友信息失败:", error);
throw error; // 重新抛出以便性能监控记录错误
}
},
{ friendId: message.friendId },
);
},
// 登录响应
@@ -254,8 +422,14 @@ export const getRegisteredMessageTypes = (): string[] => {
return Object.keys(messageHandlers);
};
// 消息管理核心函数
// 消息管理核心函数(带性能监控和错误处理)
export const msgManageCore = (message: WebSocketMessage) => {
// 边界检查:确保消息有效
if (!message) {
console.warn("msgManageCore: 无效的消息", message);
return;
}
const cmdType = message.cmdType;
if (!cmdType) {
console.warn("消息缺少cmdType字段", message);
@@ -265,9 +439,30 @@ export const msgManageCore = (message: WebSocketMessage) => {
// 获取对应的处理器,如果没有则使用默认处理器
const handler = messageHandlers[cmdType] || defaultHandler;
try {
handler(message);
} catch (error) {
console.error(`处理消息类型 ${cmdType} 时发生错误:`, error);
}
// 使用性能监控工具(统一监控)
performanceMonitor.measure(
`WebSocket.msgManageCore.${cmdType}`,
() => {
try {
// 执行处理器
const result = handler(message) as any;
// 如果是Promise添加错误处理
if (
result &&
typeof result === "object" &&
typeof result.then === "function"
) {
result.catch((error: any) => {
console.error(`处理消息类型 ${cmdType} 时发生异步错误:`, error);
});
}
} catch (error) {
console.error(`处理消息类型 ${cmdType} 时发生错误:`, error);
// 不抛出错误,避免影响其他消息处理
throw error; // 抛出以便性能监控记录错误
}
},
{ cmdType },
);
};

450
Touchkebao/src/utils/cache/index.ts vendored Normal file
View File

@@ -0,0 +1,450 @@
/**
* 缓存工具类 - 支持TTL和IndexedDB
*
* 功能:
* 1. 支持TTL生存时间机制
* 2. 支持IndexedDB存储大容量数据
* 3. 支持localStorage存储小容量数据
* 4. 自动清理过期缓存
* 5. 支持缓存失效机制
*
* 使用场景:
* - 分组列表缓存TTL: 30分钟
* - 分组联系人缓存TTL: 1小时
* - 分组统计缓存TTL: 30分钟
* - 会话列表缓存TTL: 1小时
*/
import Dexie, { Table } from "dexie";
/**
* 缓存项接口
*/
export interface CacheItem<T> {
key: string; // 缓存键
data: T; // 缓存数据
lastUpdate: number; // 最后更新时间(时间戳)
ttl: number; // 生存时间(毫秒)
version?: number; // 版本号(用于数据迁移)
}
/**
* 缓存配置
*/
export interface CacheConfig {
ttl?: number; // 默认TTL毫秒
storage?: "indexeddb" | "localStorage"; // 存储类型
version?: number; // 版本号
}
/**
* IndexedDB缓存表结构
*/
interface CacheTable {
key: string; // 主键
data: any; // 缓存数据
lastUpdate: number; // 最后更新时间
ttl: number; // 生存时间
version?: number; // 版本号
}
/**
* 缓存数据库类
*/
class CacheDatabase extends Dexie {
cache!: Table<CacheTable>;
constructor() {
super("CacheDatabase");
this.version(1).stores({
cache: "key, lastUpdate, ttl",
});
}
}
/**
* 缓存工具类
*/
class CacheManager {
private db: CacheDatabase;
private defaultTTL: number;
private defaultStorage: "indexeddb" | "localStorage";
constructor(config: CacheConfig = {}) {
this.db = new CacheDatabase();
this.defaultTTL = config.ttl || 30 * 60 * 1000; // 默认30分钟
this.defaultStorage = config.storage || "indexeddb";
}
/**
* 检查缓存是否有效
*/
private isCacheValid<T>(item: CacheItem<T>): boolean {
if (!item || !item.lastUpdate || !item.ttl) {
return false;
}
const now = Date.now();
return now - item.lastUpdate < item.ttl;
}
/**
* 从IndexedDB获取缓存
*/
private async getFromIndexedDB<T>(key: string): Promise<CacheItem<T> | null> {
try {
const record = await this.db.cache.get(key);
if (!record) {
return null;
}
const item: CacheItem<T> = {
key: record.key,
data: record.data,
lastUpdate: record.lastUpdate,
ttl: record.ttl,
version: record.version,
};
// 检查是否过期
if (!this.isCacheValid(item)) {
// 删除过期缓存
await this.db.cache.delete(key);
return null;
}
return item;
} catch (error) {
console.error(`Failed to get cache from IndexedDB for key ${key}:`, error);
return null;
}
}
/**
* 从localStorage获取缓存
*/
private getFromLocalStorage<T>(key: string): CacheItem<T> | null {
try {
const item = localStorage.getItem(`cache_${key}`);
if (!item) {
return null;
}
const cacheItem: CacheItem<T> = JSON.parse(item);
// 检查是否过期
if (!this.isCacheValid(cacheItem)) {
// 删除过期缓存
localStorage.removeItem(`cache_${key}`);
return null;
}
return cacheItem;
} catch (error) {
console.error(`Failed to get cache from localStorage for key ${key}:`, error);
return null;
}
}
/**
* 保存到IndexedDB
*/
private async saveToIndexedDB<T>(
key: string,
data: T,
ttl: number,
version?: number,
): Promise<void> {
try {
await this.db.cache.put({
key,
data,
lastUpdate: Date.now(),
ttl,
version,
});
} catch (error) {
console.error(`Failed to save cache to IndexedDB for key ${key}:`, error);
throw error;
}
}
/**
* 保存到localStorage
*/
private saveToLocalStorage<T>(
key: string,
data: T,
ttl: number,
version?: number,
): void {
try {
const cacheItem: CacheItem<T> = {
key,
data,
lastUpdate: Date.now(),
ttl,
version,
};
localStorage.setItem(`cache_${key}`, JSON.stringify(cacheItem));
} catch (error) {
console.error(`Failed to save cache to localStorage for key ${key}:`, error);
// localStorage可能已满尝试清理过期缓存
this.cleanExpiredCache("localStorage");
throw error;
}
}
/**
* 获取缓存
*/
async get<T>(key: string): Promise<T | null> {
if (this.defaultStorage === "indexeddb") {
const item = await this.getFromIndexedDB<T>(key);
return item ? item.data : null;
} else {
const item = this.getFromLocalStorage<T>(key);
return item ? item.data : null;
}
}
/**
* 设置缓存
*/
async set<T>(
key: string,
data: T,
ttl?: number,
version?: number,
): Promise<void> {
const cacheTTL = ttl || this.defaultTTL;
if (this.defaultStorage === "indexeddb") {
await this.saveToIndexedDB(key, data, cacheTTL, version);
} else {
this.saveToLocalStorage(key, data, cacheTTL, version);
}
}
/**
* 删除缓存
*/
async delete(key: string): Promise<void> {
try {
if (this.defaultStorage === "indexeddb") {
await this.db.cache.delete(key);
} else {
localStorage.removeItem(`cache_${key}`);
}
} catch (error) {
console.error(`Failed to delete cache for key ${key}:`, error);
}
}
/**
* 检查缓存是否存在且有效
*/
async has(key: string): Promise<boolean> {
const item =
this.defaultStorage === "indexeddb"
? await this.getFromIndexedDB(key)
: this.getFromLocalStorage(key);
return item !== null && this.isCacheValid(item);
}
/**
* 清理过期缓存
*/
async cleanExpiredCache(storage?: "indexeddb" | "localStorage"): Promise<void> {
const targetStorage = storage || this.defaultStorage;
if (targetStorage === "indexeddb") {
try {
const now = Date.now();
const expiredKeys: string[] = [];
await this.db.cache.each(record => {
if (now - record.lastUpdate >= record.ttl) {
expiredKeys.push(record.key);
}
});
await Promise.all(expiredKeys.map(key => this.db.cache.delete(key)));
if (expiredKeys.length > 0) {
console.log(`Cleaned ${expiredKeys.length} expired cache items from IndexedDB`);
}
} catch (error) {
console.error("Failed to clean expired cache from IndexedDB:", error);
}
} else {
try {
const now = Date.now();
const keysToDelete: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith("cache_")) {
try {
const item: CacheItem<any> = JSON.parse(localStorage.getItem(key)!);
if (now - item.lastUpdate >= item.ttl) {
keysToDelete.push(key);
}
} catch {
// 无效的缓存项,也删除
keysToDelete.push(key);
}
}
}
keysToDelete.forEach(key => localStorage.removeItem(key));
if (keysToDelete.length > 0) {
console.log(`Cleaned ${keysToDelete.length} expired cache items from localStorage`);
}
} catch (error) {
console.error("Failed to clean expired cache from localStorage:", error);
}
}
}
/**
* 清空所有缓存
*/
async clear(storage?: "indexeddb" | "localStorage"): Promise<void> {
const targetStorage = storage || this.defaultStorage;
if (targetStorage === "indexeddb") {
try {
await this.db.cache.clear();
} catch (error) {
console.error("Failed to clear IndexedDB cache:", error);
}
} else {
try {
const keysToDelete: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith("cache_")) {
keysToDelete.push(key);
}
}
keysToDelete.forEach(key => localStorage.removeItem(key));
} catch (error) {
console.error("Failed to clear localStorage cache:", error);
}
}
}
/**
* 获取缓存统计信息
*/
async getStats(storage?: "indexeddb" | "localStorage"): Promise<{
total: number;
valid: number;
expired: number;
totalSize: number; // 字节
}> {
const targetStorage = storage || this.defaultStorage;
const now = Date.now();
if (targetStorage === "indexeddb") {
try {
let total = 0;
let valid = 0;
let expired = 0;
let totalSize = 0;
await this.db.cache.each(record => {
total++;
const size = JSON.stringify(record).length;
totalSize += size;
if (now - record.lastUpdate < record.ttl) {
valid++;
} else {
expired++;
}
});
return { total, valid, expired, totalSize };
} catch (error) {
console.error("Failed to get cache stats from IndexedDB:", error);
return { total: 0, valid: 0, expired: 0, totalSize: 0 };
}
} else {
try {
let total = 0;
let valid = 0;
let expired = 0;
let totalSize = 0;
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith("cache_")) {
total++;
const item = localStorage.getItem(key);
if (item) {
totalSize += item.length;
try {
const cacheItem: CacheItem<any> = JSON.parse(item);
if (now - cacheItem.lastUpdate < cacheItem.ttl) {
valid++;
} else {
expired++;
}
} catch {
expired++;
}
}
}
}
return { total, valid, expired, totalSize };
} catch (error) {
console.error("Failed to get cache stats from localStorage:", error);
return { total: 0, valid: 0, expired: 0, totalSize: 0 };
}
}
}
}
// 创建默认缓存管理器实例
export const cacheManager = new CacheManager({
ttl: 30 * 60 * 1000, // 默认30分钟
storage: "indexeddb",
});
// 创建分组列表缓存管理器TTL: 30分钟
export const groupListCache = new CacheManager({
ttl: 30 * 60 * 1000, // 30分钟
storage: "indexeddb",
});
// 创建分组联系人缓存管理器TTL: 1小时
export const groupContactsCache = new CacheManager({
ttl: 60 * 60 * 1000, // 1小时
storage: "indexeddb",
});
// 创建分组统计缓存管理器TTL: 30分钟
export const groupStatsCache = new CacheManager({
ttl: 30 * 60 * 1000, // 30分钟
storage: "indexeddb",
});
// 创建会话列表缓存管理器TTL: 1小时
export const sessionListCache = new CacheManager({
ttl: 60 * 60 * 1000, // 1小时
storage: "indexeddb",
});
// 定期清理过期缓存(每小时执行一次)
if (typeof window !== "undefined") {
setInterval(() => {
cacheManager.cleanExpiredCache().catch(console.error);
groupListCache.cleanExpiredCache().catch(console.error);
groupContactsCache.cleanExpiredCache().catch(console.error);
groupStatsCache.cleanExpiredCache().catch(console.error);
sessionListCache.cleanExpiredCache().catch(console.error);
}, 60 * 60 * 1000); // 每小时
}

View File

@@ -0,0 +1,314 @@
/**
* 数据索引工具类
* 用于快速构建和查询按账号ID分组的数据索引
*
* 性能优化:
* - 使用Map索引O(1)时间复杂度获取数据
* - 支持增量更新,避免全量重建
* - 支持批量构建,一次性处理大量数据
*/
import { ChatSession } from "@/utils/db";
import { Contact } from "@/utils/db";
/**
* 数据索引管理器
* 用于管理会话和联系人的索引结构
*/
export class DataIndexManager {
// 会话索引accountId → ChatSession[]
private sessionIndex: Map<number, ChatSession[]>;
// 联系人索引accountId → Contact[]
private contactIndex: Map<number, Contact[]>;
constructor() {
this.sessionIndex = new Map();
this.contactIndex = new Map();
}
/**
* 构建索引(数据加载时调用)
* 时间复杂度O(n)n为数据总量只执行一次
*
* @param sessions 会话列表
* @param contacts 联系人列表
*/
buildIndexes(sessions: ChatSession[], contacts: Contact[]): void {
// 清空现有索引
this.sessionIndex.clear();
this.contactIndex.clear();
// 构建会话索引
sessions.forEach(session => {
const accountId = session.wechatAccountId;
if (!this.sessionIndex.has(accountId)) {
this.sessionIndex.set(accountId, []);
}
this.sessionIndex.get(accountId)!.push(session);
});
// 构建联系人索引
contacts.forEach(contact => {
const accountId = contact.wechatAccountId;
if (!this.contactIndex.has(accountId)) {
this.contactIndex.set(accountId, []);
}
this.contactIndex.get(accountId)!.push(contact);
});
}
/**
* 获取指定账号的会话列表
* 时间复杂度O(1)
*
* @param accountId 账号ID0表示"全部"
* @returns 会话列表
*/
getSessionsByAccount(accountId: number): ChatSession[] {
if (accountId === 0) {
// "全部":需要合并所有账号的数据
const allSessions: ChatSession[] = [];
this.sessionIndex.forEach(sessions => {
allSessions.push(...sessions);
});
return allSessions;
}
return this.sessionIndex.get(accountId) || [];
}
/**
* 获取指定账号的联系人列表
* 时间复杂度O(1)
*
* @param accountId 账号ID0表示"全部"
* @returns 联系人列表
*/
getContactsByAccount(accountId: number): Contact[] {
if (accountId === 0) {
// "全部":需要合并所有账号的数据
const allContacts: Contact[] = [];
this.contactIndex.forEach(contacts => {
allContacts.push(...contacts);
});
return allContacts;
}
return this.contactIndex.get(accountId) || [];
}
/**
* 增量更新:添加会话到索引
* 时间复杂度O(1)
*
* @param session 会话数据
*/
addSession(session: ChatSession): void {
const accountId = session.wechatAccountId;
if (!this.sessionIndex.has(accountId)) {
this.sessionIndex.set(accountId, []);
}
this.sessionIndex.get(accountId)!.push(session);
}
/**
* 增量更新:添加联系人到索引
* 时间复杂度O(1)
*
* @param contact 联系人数据
*/
addContact(contact: Contact): void {
const accountId = contact.wechatAccountId;
if (!this.contactIndex.has(accountId)) {
this.contactIndex.set(accountId, []);
}
this.contactIndex.get(accountId)!.push(contact);
}
/**
* 更新会话(如果已存在)
* 时间复杂度O(n)n为当前账号的会话数量
*
* @param session 会话数据
*/
updateSession(session: ChatSession): void {
const accountId = session.wechatAccountId;
const sessions = this.sessionIndex.get(accountId);
if (sessions) {
const index = sessions.findIndex(
s => s.id === session.id && s.type === session.type,
);
if (index !== -1) {
sessions[index] = session;
} else {
// 不存在则添加
this.addSession(session);
}
} else {
// 账号不存在,创建新索引
this.addSession(session);
}
}
/**
* 更新联系人(如果已存在)
* 时间复杂度O(n)n为当前账号的联系人数量
*
* @param contact 联系人数据
*/
updateContact(contact: Contact): void {
const accountId = contact.wechatAccountId;
const contacts = this.contactIndex.get(accountId);
if (contacts) {
const index = contacts.findIndex(c => c.id === contact.id);
if (index !== -1) {
contacts[index] = contact;
} else {
// 不存在则添加
this.addContact(contact);
}
} else {
// 账号不存在,创建新索引
this.addContact(contact);
}
}
/**
* 删除会话
* 时间复杂度O(n)n为当前账号的会话数量
*
* @param sessionId 会话ID
* @param type 会话类型
* @param accountId 账号ID
*/
removeSession(
sessionId: number,
type: ChatSession["type"],
accountId: number,
): void {
const sessions = this.sessionIndex.get(accountId);
if (sessions) {
const index = sessions.findIndex(
s => s.id === sessionId && s.type === type,
);
if (index !== -1) {
sessions.splice(index, 1);
}
}
}
/**
* 删除联系人
* 时间复杂度O(n)n为当前账号的联系人数量
*
* @param contactId 联系人ID
* @param accountId 账号ID
*/
removeContact(contactId: number, accountId: number): void {
const contacts = this.contactIndex.get(accountId);
if (contacts) {
const index = contacts.findIndex(c => c.id === contactId);
if (index !== -1) {
contacts.splice(index, 1);
}
}
}
/**
* 清空所有索引
*/
clear(): void {
this.sessionIndex.clear();
this.contactIndex.clear();
}
/**
* 获取索引统计信息(用于调试和监控)
*/
getStats(): {
sessionCount: number;
contactCount: number;
accountCount: number;
sessionsByAccount: Map<number, number>;
contactsByAccount: Map<number, number>;
} {
let sessionCount = 0;
let contactCount = 0;
const sessionsByAccount = new Map<number, number>();
const contactsByAccount = new Map<number, number>();
this.sessionIndex.forEach((sessions, accountId) => {
sessionCount += sessions.length;
sessionsByAccount.set(accountId, sessions.length);
});
this.contactIndex.forEach((contacts, accountId) => {
contactCount += contacts.length;
contactsByAccount.set(accountId, contacts.length);
});
const accountCount = new Set([
...this.sessionIndex.keys(),
...this.contactIndex.keys(),
]).size;
return {
sessionCount,
contactCount,
accountCount,
sessionsByAccount,
contactsByAccount,
};
}
/**
* 获取所有账号ID
*/
getAllAccountIds(): number[] {
const accountIds = new Set<number>();
this.sessionIndex.forEach((_, accountId) => {
accountIds.add(accountId);
});
this.contactIndex.forEach((_, accountId) => {
accountIds.add(accountId);
});
return Array.from(accountIds);
}
/**
* 检查索引是否为空
*/
isEmpty(): boolean {
return this.sessionIndex.size === 0 && this.contactIndex.size === 0;
}
}
/**
* 创建数据索引管理器实例
*/
export function createDataIndexManager(): DataIndexManager {
return new DataIndexManager();
}
/**
* 全局单例(可选,如果需要全局共享索引)
*/
let globalIndexManager: DataIndexManager | null = null;
/**
* 获取全局数据索引管理器
*/
export function getGlobalDataIndexManager(): DataIndexManager {
if (!globalIndexManager) {
globalIndexManager = new DataIndexManager();
}
return globalIndexManager;
}
/**
* 重置全局数据索引管理器
*/
export function resetGlobalDataIndexManager(): void {
globalIndexManager = null;
}

View File

@@ -0,0 +1,356 @@
/**
* 错误处理工具
* 统一处理应用中的错误,包括错误记录、错误上报、错误恢复等
*/
import { captureError, captureMessage } from "./sentry";
import { performanceMonitor } from "./performance";
/**
* 错误类型
*/
export enum ErrorType {
NETWORK = "network",
API = "api",
VALIDATION = "validation",
PERMISSION = "permission",
UNKNOWN = "unknown",
}
/**
* 错误严重程度
*/
export enum ErrorSeverity {
LOW = "low",
MEDIUM = "medium",
HIGH = "high",
CRITICAL = "critical",
}
/**
* 错误上下文信息
*/
export interface ErrorContext {
type?: ErrorType;
severity?: ErrorSeverity;
component?: string;
action?: string;
userId?: number;
metadata?: Record<string, any>;
}
/**
* 错误处理结果
*/
export interface ErrorHandleResult {
handled: boolean;
recoverable: boolean;
message?: string;
retry?: () => Promise<void>;
}
/**
* 错误处理器类
*/
class ErrorHandler {
private errorCounts: Map<string, number> = new Map();
private readonly MAX_ERROR_COUNT = 10; // 同一错误最多记录10次
private readonly ERROR_WINDOW = 60000; // 1分钟内的错误计数窗口
/**
* 处理错误
*/
handleError(
error: Error | string,
context?: ErrorContext,
): ErrorHandleResult {
const errorMessage = typeof error === "string" ? error : error.message;
const errorKey = `${errorMessage}_${context?.component || "unknown"}`;
// 检查错误频率(防止错误风暴)
const count = this.errorCounts.get(errorKey) || 0;
if (count >= this.MAX_ERROR_COUNT) {
console.warn(`错误频率过高,已忽略: ${errorKey}`);
return {
handled: false,
recoverable: false,
message: "错误频率过高,已自动忽略",
};
}
// 更新错误计数
this.errorCounts.set(errorKey, count + 1);
setTimeout(() => {
const currentCount = this.errorCounts.get(errorKey) || 0;
this.errorCounts.set(errorKey, Math.max(0, currentCount - 1));
}, this.ERROR_WINDOW);
// 记录错误
const errorObj =
typeof error === "string" ? new Error(error) : error;
console.error("错误处理:", errorObj, context);
// 性能监控记录错误使用measure方法
performanceMonitor.measure(
`ErrorHandler.${context?.type || ErrorType.UNKNOWN}`,
() => {
// 错误已记录,这里只是用于性能监控
},
{
error: errorMessage,
severity: context?.severity || ErrorSeverity.MEDIUM,
component: context?.component,
action: context?.action,
...context?.metadata,
},
);
// 根据错误类型处理
const result = this.handleErrorByType(errorObj, context);
// 发送到Sentry根据严重程度
if (
context?.severity === ErrorSeverity.HIGH ||
context?.severity === ErrorSeverity.CRITICAL
) {
try {
captureError(errorObj, {
tags: {
type: context?.type || ErrorType.UNKNOWN,
severity: context?.severity || ErrorSeverity.MEDIUM,
component: context?.component || "unknown",
},
extra: {
action: context?.action,
userId: context?.userId,
...context?.metadata,
},
});
} catch (sentryError) {
console.warn("发送错误到Sentry失败:", sentryError);
}
}
return result;
}
/**
* 根据错误类型处理
*/
private handleErrorByType(
error: Error,
context?: ErrorContext,
): ErrorHandleResult {
const type = context?.type || this.detectErrorType(error);
switch (type) {
case ErrorType.NETWORK:
return {
handled: true,
recoverable: true,
message: "网络错误,请检查网络连接",
retry: async () => {
// 可以在这里实现重试逻辑
await new Promise(resolve => setTimeout(resolve, 1000));
},
};
case ErrorType.API:
return {
handled: true,
recoverable: true,
message: "API请求失败请稍后重试",
retry: async () => {
await new Promise(resolve => setTimeout(resolve, 1000));
},
};
case ErrorType.VALIDATION:
return {
handled: true,
recoverable: false,
message: "数据验证失败,请检查输入",
};
case ErrorType.PERMISSION:
return {
handled: true,
recoverable: false,
message: "权限不足,无法执行此操作",
};
default:
return {
handled: true,
recoverable: false,
message: "发生未知错误,请刷新页面重试",
};
}
}
/**
* 检测错误类型
*/
private detectErrorType(error: Error): ErrorType {
const message = error.message.toLowerCase();
if (
message.includes("network") ||
message.includes("fetch") ||
message.includes("timeout")
) {
return ErrorType.NETWORK;
}
if (
message.includes("api") ||
message.includes("http") ||
message.includes("status")
) {
return ErrorType.API;
}
if (
message.includes("validation") ||
message.includes("invalid") ||
message.includes("required")
) {
return ErrorType.VALIDATION;
}
if (
message.includes("permission") ||
message.includes("unauthorized") ||
message.includes("forbidden")
) {
return ErrorType.PERMISSION;
}
return ErrorType.UNKNOWN;
}
/**
* 处理Promise错误
*/
async handlePromiseError<T>(
promise: Promise<T>,
context?: ErrorContext,
): Promise<T | null> {
try {
return await promise;
} catch (error) {
this.handleError(
error instanceof Error ? error : new Error(String(error)),
context,
);
return null;
}
}
/**
* 创建错误处理包装函数
*/
wrapAsync<T extends (...args: any[]) => Promise<any>>(
fn: T,
context?: ErrorContext,
): T {
return (async (...args: any[]) => {
try {
return await fn(...args);
} catch (error) {
this.handleError(
error instanceof Error ? error : new Error(String(error)),
{
...context,
action: context?.action || fn.name,
},
);
throw error;
}
}) as T;
}
/**
* 清空错误计数
*/
clearErrorCounts(): void {
this.errorCounts.clear();
}
/**
* 获取错误统计
*/
getErrorStats(): {
totalErrors: number;
errorTypes: Record<string, number>;
} {
const errorTypes: Record<string, number> = {};
let totalErrors = 0;
this.errorCounts.forEach((count, key) => {
totalErrors += count;
const type = key.split("_")[0];
errorTypes[type] = (errorTypes[type] || 0) + count;
});
return {
totalErrors,
errorTypes,
};
}
}
// 创建全局错误处理器实例
export const errorHandler = new ErrorHandler();
/**
* 错误处理Hook用于React组件
*/
export function useErrorHandler() {
return {
handleError: (error: Error | string, context?: ErrorContext) =>
errorHandler.handleError(error, context),
handlePromiseError: <T>(
promise: Promise<T>,
context?: ErrorContext,
) => errorHandler.handlePromiseError(promise, context),
wrapAsync: <T extends (...args: any[]) => Promise<any>>(
fn: T,
context?: ErrorContext,
) => errorHandler.wrapAsync(fn, context),
};
}
/**
* 错误处理装饰器(用于类方法)
*/
export function handleErrors(context?: ErrorContext) {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor,
) {
const originalMethod = descriptor.value;
if (typeof originalMethod === "function") {
descriptor.value = async function (...args: any[]) {
try {
return await originalMethod.apply(this, args);
} catch (error) {
errorHandler.handleError(
error instanceof Error ? error : new Error(String(error)),
{
...context,
component: target.constructor.name,
action: propertyKey,
},
);
throw error;
}
};
}
return descriptor;
};
}

View File

@@ -0,0 +1,204 @@
/**
* 性能监控工具
* 用于测量和记录性能指标
*/
/**
* 性能测量结果
*/
export interface PerformanceResult {
name: string;
duration: number; // 毫秒
timestamp: number;
metadata?: Record<string, any>;
}
/**
* 性能监控类
*/
class PerformanceMonitor {
private results: PerformanceResult[] = [];
private maxResults = 1000; // 最多保存1000条记录
/**
* 测量函数执行时间
*/
measure<T>(
name: string,
fn: () => T,
metadata?: Record<string, any>,
): T {
const start = performance.now();
try {
const result = fn();
const end = performance.now();
this.record(name, end - start, metadata);
return result;
} catch (error) {
const end = performance.now();
this.record(name, end - start, { ...metadata, error: String(error) });
throw error;
}
}
/**
* 异步测量函数执行时间
*/
async measureAsync<T>(
name: string,
fn: () => Promise<T>,
metadata?: Record<string, any>,
): Promise<T> {
const start = performance.now();
try {
const result = await fn();
const end = performance.now();
this.record(name, end - start, metadata);
return result;
} catch (error) {
const end = performance.now();
this.record(name, end - start, { ...metadata, error: String(error) });
throw error;
}
}
/**
* 记录性能结果
*/
private record(
name: string,
duration: number,
metadata?: Record<string, any>,
): void {
const result: PerformanceResult = {
name,
duration,
timestamp: Date.now(),
metadata,
};
this.results.push(result);
// 限制结果数量
if (this.results.length > this.maxResults) {
this.results.shift();
}
// 开发环境下输出到控制台
if (import.meta.env.DEV) {
const color = duration > 100 ? "🔴" : duration > 50 ? "🟡" : "🟢";
console.log(
`${color} [Performance] ${name}: ${duration.toFixed(2)}ms`,
metadata || "",
);
}
}
/**
* 获取性能统计
*/
getStats(name?: string): {
count: number;
total: number;
average: number;
min: number;
max: number;
results: PerformanceResult[];
} {
const filtered = name
? this.results.filter(r => r.name === name)
: this.results;
if (filtered.length === 0) {
return {
count: 0,
total: 0,
average: 0,
min: 0,
max: 0,
results: [],
};
}
const durations = filtered.map(r => r.duration);
const total = durations.reduce((sum, d) => sum + d, 0);
const average = total / filtered.length;
const min = Math.min(...durations);
const max = Math.max(...durations);
return {
count: filtered.length,
total,
average,
min,
max,
results: filtered,
};
}
/**
* 获取所有结果
*/
getAllResults(): PerformanceResult[] {
return [...this.results];
}
/**
* 清空结果
*/
clear(): void {
this.results = [];
}
/**
* 导出结果(用于分析)
*/
export(): string {
return JSON.stringify(this.results, null, 2);
}
}
// 创建全局性能监控实例
export const performanceMonitor = new PerformanceMonitor();
/**
* 性能测量装饰器(用于类方法)
*/
export function measurePerformance(name?: string) {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor,
) {
const originalMethod = descriptor.value;
const methodName = name || `${target.constructor.name}.${propertyKey}`;
if (typeof originalMethod === "function") {
descriptor.value = function (...args: any[]) {
return performanceMonitor.measure(methodName, () =>
originalMethod.apply(this, args),
);
};
}
return descriptor;
};
}
/**
* 性能测量Hook用于React组件
* 注意需要在React组件中使用需要导入React
*/
export function usePerformanceMeasure(name: string) {
// 注意这个Hook需要在React组件中使用
// 由于可能造成循环依赖建议在组件中直接使用performanceMonitor.measure
const startRef = { current: performance.now() };
// 返回清理函数
return () => {
if (startRef.current !== null) {
const duration = performance.now() - startRef.current;
performanceMonitor.record(name, duration);
}
};
}

View File

@@ -0,0 +1,240 @@
/**
* 性能测试工具
* 用于测试新架构的各项性能指标
*/
import { performanceMonitor } from "../performance";
import { useMessageStore } from "@/store/module/weChat/message";
import { useContactStoreNew } from "@/store/module/weChat/contacts.new";
/**
* 性能测试结果
*/
export interface PerformanceTestResult {
name: string;
duration: number;
passed: boolean;
target: number;
metadata?: Record<string, any>;
}
/**
* 性能测试套件
*/
export class PerformanceTestSuite {
private results: PerformanceTestResult[] = [];
/**
* 测试会话列表切换账号性能
*/
async testSwitchAccount(accountId: number): Promise<PerformanceTestResult> {
const result = await performanceMonitor.measureAsync(
"切换账号",
async () => {
const messageStore = useMessageStore.getState();
await messageStore.switchAccount(accountId);
},
{ accountId },
);
const passed = result.duration < 100;
const testResult: PerformanceTestResult = {
name: "切换账号",
duration: result.duration,
passed,
target: 100,
metadata: { accountId },
};
this.results.push(testResult);
return testResult;
}
/**
* 测试联系人分组展开性能
*/
async testExpandGroup(
groupId: number,
groupType: 1 | 2,
): Promise<PerformanceTestResult> {
const result = await performanceMonitor.measureAsync(
"展开分组",
async () => {
const contactStore = useContactStoreNew.getState();
const accountId = contactStore.selectedAccountId;
const groupKey = `${groupId}_${groupType}_${accountId}`;
// 如果分组未展开,先展开
if (!contactStore.expandedGroups.has(groupKey)) {
contactStore.toggleGroup(groupId, groupType);
}
// 加载分组联系人
await contactStore.loadGroupContacts(groupId, groupType);
},
{ groupId, groupType },
);
const passed = result.duration < 200;
const testResult: PerformanceTestResult = {
name: "展开分组",
duration: result.duration,
passed,
target: 200,
metadata: { groupId, groupType },
};
this.results.push(testResult);
return testResult;
}
/**
* 测试搜索性能
*/
async testSearch(keyword: string): Promise<PerformanceTestResult> {
const result = await performanceMonitor.measureAsync(
"搜索",
async () => {
const contactStore = useContactStoreNew.getState();
await contactStore.searchContacts(keyword);
},
{ keyword },
);
const passed = result.duration < 250;
const testResult: PerformanceTestResult = {
name: "搜索",
duration: result.duration,
passed,
target: 250,
metadata: { keyword },
};
this.results.push(testResult);
return testResult;
}
/**
* 测试缓存读取性能
*/
async testCacheRead(cacheKey: string): Promise<PerformanceTestResult> {
const result = await performanceMonitor.measureAsync(
"缓存读取",
async () => {
// 这里需要根据实际的缓存实现来测试
// 示例从IndexedDB读取
const db = (window as any).indexedDB;
if (db) {
// 模拟缓存读取
await new Promise(resolve => setTimeout(resolve, 10));
}
},
{ cacheKey },
);
const passed = result.duration < 50;
const testResult: PerformanceTestResult = {
name: "缓存读取",
duration: result.duration,
passed,
target: 50,
metadata: { cacheKey },
};
this.results.push(testResult);
return testResult;
}
/**
* 获取所有测试结果
*/
getAllResults(): PerformanceTestResult[] {
return [...this.results];
}
/**
* 获取测试统计
*/
getStats(): {
total: number;
passed: number;
failed: number;
passRate: number;
averageDuration: number;
} {
const total = this.results.length;
const passed = this.results.filter(r => r.passed).length;
const failed = total - passed;
const passRate = total > 0 ? (passed / total) * 100 : 0;
const averageDuration =
total > 0
? this.results.reduce((sum, r) => sum + r.duration, 0) / total
: 0;
return {
total,
passed,
failed,
passRate,
averageDuration,
};
}
/**
* 清空结果
*/
clear(): void {
this.results = [];
}
/**
* 导出测试报告
*/
exportReport(): string {
const stats = this.getStats();
const report = {
timestamp: new Date().toISOString(),
stats,
results: this.results,
};
return JSON.stringify(report, null, 2);
}
}
// 创建全局测试套件实例
export const performanceTestSuite = new PerformanceTestSuite();
/**
* 在浏览器控制台运行性能测试
* 使用方法:在控制台输入 window.runPerformanceTests()
*/
if (typeof window !== "undefined") {
(window as any).runPerformanceTests = async () => {
console.log("开始性能测试...");
const suite = performanceTestSuite;
// 测试切换账号
console.log("测试切换账号...");
await suite.testSwitchAccount(0); // 切换到"全部"
// 测试展开分组
console.log("测试展开分组...");
const contactStore = useContactStoreNew.getState();
if (contactStore.groups.length > 0) {
const firstGroup = contactStore.groups[0];
await suite.testExpandGroup(firstGroup.id, firstGroup.groupType);
}
// 测试搜索
console.log("测试搜索...");
await suite.testSearch("测试");
// 输出结果
const stats = suite.getStats();
console.log("性能测试完成!");
console.log("统计结果:", stats);
console.log("详细结果:", suite.getAllResults());
console.log("测试报告:", suite.exportReport());
};
}

View File

@@ -0,0 +1,476 @@
# 存客宝新架构测试用例
## 测试用例说明
本文档包含新架构的详细测试用例,用于功能测试和回归测试。
---
## 1. 会话列表功能测试
### 1.1 切换账号测试
**测试用例ID**: TC-SESSION-001
**测试目标**: 验证切换账号功能正常工作
**前置条件**:
- 已登录系统
- 有多个微信账号数据
- 会话列表已加载
**测试步骤**:
1. 打开会话列表
2. 切换到"全部"账号accountId=0
3. 切换到账号1
4. 切换到账号2
5. 切换回"全部"
**预期结果**:
- 每次切换耗时 < 100ms
- 会话列表正确显示对应账号的数据
- 切换回之前账号时使用缓存更快
- 无错误提示
**性能指标**:
- 切换账号响应时间: < 100ms
- 缓存命中时响应时间: < 50ms
---
### 1.2 搜索功能测试
**测试用例ID**: TC-SESSION-002
**测试目标**: 验证搜索功能正常工作
**前置条件**:
- 已登录系统
- 会话列表已加载
- 有多个会话数据
**测试步骤**:
1. 在搜索框输入关键词"测试"
2. 观察搜索延迟应约300ms防抖
3. 验证搜索结果正确性
4. 清空搜索框
5. 验证恢复原列表
**预期结果**:
- 搜索有300ms防抖延迟
- 搜索结果正确匹配关键词
- 清空后恢复原列表
- 搜索响应时间 < 250ms
**性能指标**:
- 搜索响应时间: < 250ms
- 防抖延迟: 300ms
---
### 1.3 排序功能测试
**测试用例ID**: TC-SESSION-003
**测试目标**: 验证排序功能正常工作
**前置条件**:
- 已登录系统
- 会话列表已加载
- 有多个会话数据
**测试步骤**:
1. 验证默认排序按时间
2. 切换为按未读数排序
3. 切换为按名称排序
4. 验证置顶会话始终在最前
**预期结果**:
- 排序正确
- 置顶会话始终在最前
- 排序响应时间 < 50ms
---
## 2. 联系人列表功能测试
### 2.1 分组懒加载测试
**测试用例ID**: TC-CONTACT-001
**测试目标**: 验证分组懒加载功能正常工作
**前置条件**:
- 已登录系统
- 联系人列表已加载
- 有多个分组
**测试步骤**:
1. 打开联系人列表
2. 展开分组1
3. 观察首次展开性能
4. 展开分组2
5. 折叠分组1
6. 再次展开分组1应使用缓存
**预期结果**:
- 分组列表快速加载
- 首次展开 < 200ms
- 再次展开使用缓存更快
- 分组数据正确显示
**性能指标**:
- 首次展开响应时间: < 200ms
- 缓存命中时响应时间: < 50ms
---
### 2.2 分页加载测试
**测试用例ID**: TC-CONTACT-002
**测试目标**: 验证分页加载功能正常工作
**前置条件**:
- 已登录系统
- 分组已展开
- 分组内有超过50个联系人
**测试步骤**:
1. 展开一个包含大量联系人的分组
2. 滚动到分组底部
3. 观察是否触发加载更多
4. 验证新数据正确加载
5. 继续滚动到底部
6. 验证没有更多数据时不再加载
**预期结果**:
- 滚动到底部时自动加载更多
- 新数据正确追加
- 没有更多数据时不再加载
- 加载过程不阻塞UI
---
### 2.3 搜索功能测试
**测试用例ID**: TC-CONTACT-003
**测试目标**: 验证搜索功能正常工作
**前置条件**:
- 已登录系统
- 联系人列表已加载
**测试步骤**:
1. 在搜索框输入关键词
2. 观察搜索延迟应约300ms防抖
3. 验证搜索结果包含好友和群
4. 验证搜索结果正确性
5. 清空搜索框
**预期结果**:
- 搜索有300ms防抖延迟
- 并行请求好友和群列表
- 搜索结果正确匹配
- 搜索响应时间 < 250ms
**性能指标**:
- 搜索响应时间: < 250ms
- 并行请求完成时间: < 500ms
---
## 3. 右键菜单功能测试
### 3.1 分组右键菜单测试
**测试用例ID**: TC-MENU-001
**测试目标**: 验证分组右键菜单功能正常工作
**前置条件**:
- 已登录系统
- 联系人列表已加载
**测试步骤**:
1. 右键点击分组
2. 点击"新增分组"
3. 输入分组名称并保存
4. 验证分组列表更新
5. 右键点击分组点击"编辑分组"
6. 修改分组名称并保存
7. 验证分组列表更新
8. 右键点击分组点击"删除分组"
9. 确认删除
10. 验证分组列表更新
**预期结果**:
- 新增分组成功
- 编辑分组成功
- 删除分组成功
- 分组列表立即更新
- 缓存同步更新
---
### 3.2 联系人右键菜单测试
**测试用例ID**: TC-MENU-002
**测试目标**: 验证联系人右键菜单功能正常工作
**前置条件**:
- 已登录系统
- 联系人列表已加载
- 有联系人数据
**测试步骤**:
1. 右键点击联系人
2. 点击"修改备注"
3. 输入新备注并保存
4. 验证联系人数据更新
5. 右键点击联系人点击"移动分组"
6. 选择目标分组并确认
7. 验证联系人移动到新分组
**预期结果**:
- 修改备注成功
- 移动分组成功
- 联系人数据立即更新
- 分组数据同步更新
- 缓存同步更新
---
## 4. 缓存功能测试
### 4.1 初始化加载测试
**测试用例ID**: TC-CACHE-001
**测试目标**: 验证缓存初始化加载功能正常工作
**前置条件**:
- 已登录系统
- 之前已加载过数据有缓存
**测试步骤**:
1. 清除浏览器缓存
2. 首次加载会话列表
3. 观察加载速度无缓存
4. 再次加载会话列表
5. 观察加载速度有缓存
**预期结果**:
- 有缓存时 < 50ms不显示Loading
- 无缓存时正常API调用
- 后台静默更新缓存
- 数据正确显示
**性能指标**:
- 缓存读取时间: < 50ms
- 无缓存时API调用时间: 正常取决于网络
---
### 4.2 缓存失效测试
**测试用例ID**: TC-CACHE-002
**测试目标**: 验证缓存失效机制正常工作
**前置条件**:
- 已登录系统
- 有缓存数据
**测试步骤**:
1. 加载会话列表使用缓存
2. 接收新消息
3. 验证会话列表更新
4. 验证缓存同步更新
5. 切换账号
6. 验证使用更新后的缓存
**预期结果**:
- 新消息到达时缓存失效
- 会话列表立即更新
- 缓存同步更新
- 切换账号时使用最新缓存
---
## 5. WebSocket实时更新测试
### 5.1 新消息更新测试
**测试用例ID**: TC-WS-001
**测试目标**: 验证WebSocket新消息更新功能正常工作
**前置条件**:
- 已登录系统
- WebSocket连接正常
- 会话列表已打开
**测试步骤**:
1. 打开会话列表
2. 接收新消息
3. 观察会话列表更新
4. 验证会话索引增量更新
5. 验证会话缓存更新
6. 验证更新性能
**预期结果**:
- 会话列表立即更新
- 索引增量更新
- 缓存同步更新
- 更新速度 < 10ms
**性能指标**:
- 更新响应时间: < 10ms
- 索引更新: O(1)时间复杂度
---
### 5.2 联系人信息更新测试
**测试用例ID**: TC-WS-002
**测试目标**: 验证WebSocket联系人信息更新功能正常工作
**前置条件**:
- 已登录系统
- WebSocket连接正常
- 联系人列表已打开
**测试步骤**:
1. 打开联系人列表
2. 接收联系人信息变更
3. 观察分组数据更新
4. 验证搜索结果更新
5. 验证缓存更新
**预期结果**:
- 分组数据立即更新
- 搜索结果同步更新
- 缓存同步更新
- 更新速度 < 10ms
**性能指标**:
- 更新响应时间: < 10ms
---
## 6. 边界情况测试
### 6.1 大数据量测试
**测试用例ID**: TC-EDGE-001
**测试目标**: 验证大数据量场景下功能正常工作
**前置条件**:
- 已登录系统
- 有10000+条会话数据
- 有50000+条联系人数据
**测试步骤**:
1. 加载10000条会话数据
2. 测试切换账号性能
3. 测试搜索性能
4. 测试虚拟滚动性能
5. 监控内存占用
**预期结果**:
- 切换账号 < 100ms
- 搜索响应时间 < 250ms
- 虚拟滚动流畅60fps
- 内存占用 < 100MB
**性能指标**:
- 切换账号: < 100ms
- 搜索响应时间: < 250ms
- 虚拟滚动帧率: 60fps
- 内存占用: < 100MB
---
### 6.2 网络异常测试
**测试用例ID**: TC-EDGE-002
**测试目标**: 验证网络异常场景下的降级处理
**前置条件**:
- 已登录系统
- 有缓存数据
**测试步骤**:
1. 断开网络连接
2. 尝试加载会话列表
3. 验证使用缓存数据
4. 尝试搜索
5. 验证错误提示
6. 恢复网络连接
7. 验证自动同步
**预期结果**:
- 使用缓存数据降级
- 错误提示友好
- 网络恢复后自动同步
- 不出现白屏或崩溃
---
### 6.3 缓存失效测试
**测试用例ID**: TC-EDGE-003
**测试目标**: 验证缓存失效场景下的处理
**前置条件**:
- 已登录系统
- 有缓存数据
**测试步骤**:
1. 等待缓存过期30分钟/1小时
2. 尝试加载数据
3. 验证重新调用API
4. 验证新缓存生成
5. 验证数据正确性
**预期结果**:
- TTL机制正常工作
- 过期缓存自动清理
- 重新调用API获取最新数据
- 新缓存正确生成
---
## 7. 性能测试
### 7.1 会话列表性能测试
**测试用例ID**: TC-PERF-001
**测试目标**: 验证会话列表性能指标
**测试方法**: 使用 `window.runPerformanceTests()` 运行性能测试
**性能指标**:
- 切换账号: < 100ms
- 搜索响应时间: < 250ms
- 虚拟滚动帧率: 60fps
- 内存占用: < 100MB
---
### 7.2 联系人列表性能测试
**测试用例ID**: TC-PERF-002
**测试目标**: 验证联系人列表性能指标
**测试方法**: 使用 `window.runPerformanceTests()` 运行性能测试
**性能指标**:
- 首次展开分组: < 200ms
- 搜索响应时间: < 250ms
- 虚拟滚动帧率: 60fps
- 内存占用: < 100MB
---
## 测试报告模板
**测试日期**: YYYY-MM-DD
**测试人员**: XXX
**测试环境**: Chrome 最新版 / Windows 10
### 测试结果汇总
| 测试用例ID | 测试用例名称 | 测试结果 | 备注 |
|-----------|------------|---------|------|
| TC-SESSION-001 | 切换账号测试 | 通过 | 性能达标 |
| TC-SESSION-002 | 搜索功能测试 | 通过 | 性能达标 |
| ... | ... | ... | ... |
### 性能测试结果
- 会话列表切换账号: XXms目标< 100ms
- 联系人分组展开: XXms目标< 200ms
- 虚拟滚动帧率: XXfps目标:≥ 60fps
- 内存占用: XXMB目标< 100MB
### 问题记录
1. 问题描述
2. 复现步骤
3. 解决方案

View File

@@ -0,0 +1,257 @@
/**
* 测试辅助工具函数
* 用于测试和调试新架构功能
*/
import { useMessageStore } from "@/store/module/weChat/message";
import { useContactStoreNew } from "@/store/module/weChat/contacts.new";
import { performanceMonitor } from "../performance";
/**
* 测试数据生成器
*/
export class TestDataGenerator {
/**
* 生成模拟会话数据
*/
static generateSessions(count: number, accountId: number = 0) {
const sessions = [];
for (let i = 0; i < count; i++) {
sessions.push({
id: i + 1,
type: i % 2 === 0 ? "friend" : "group",
wechatAccountId: accountId || (i % 3) + 1,
wechatFriendId: i % 2 === 0 ? i + 1 : undefined,
wechatChatroomId: i % 2 === 1 ? i + 1 : undefined,
nickname: `测试用户${i + 1}`,
conRemark: i % 3 === 0 ? `备注${i + 1}` : undefined,
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${i}`,
content: `这是第${i + 1}条消息`,
lastUpdateTime: new Date(Date.now() - i * 60000).toISOString(),
config: {
top: i % 10 === 0 ? 1 : 0,
unreadCount: i % 5 === 0 ? Math.floor(Math.random() * 10) : 0,
},
});
}
return sessions;
}
/**
* 生成模拟联系人数据
*/
static generateContacts(count: number, groupId: number = 0) {
const contacts = [];
for (let i = 0; i < count; i++) {
contacts.push({
id: i + 1,
type: i % 2 === 0 ? "friend" : "group",
groupId: groupId || (i % 5) + 1,
nickname: `联系人${i + 1}`,
conRemark: i % 3 === 0 ? `备注${i + 1}` : undefined,
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${i}`,
wechatId: `wxid_${i + 1}`,
});
}
return contacts;
}
}
/**
* 性能测试辅助函数
*/
export class PerformanceTestHelpers {
/**
* 批量测试切换账号性能
*/
static async batchTestSwitchAccount(
accountIds: number[],
iterations: number = 10,
) {
const results: Array<{ accountId: number; durations: number[] }> = [];
for (const accountId of accountIds) {
const durations: number[] = [];
for (let i = 0; i < iterations; i++) {
const duration = await performanceMonitor.measureAsync(
`批量测试切换账号${accountId}`,
async () => {
const messageStore = useMessageStore.getState();
await messageStore.switchAccount(accountId);
},
);
durations.push(duration.duration);
}
results.push({ accountId, durations });
}
return results;
}
/**
* 测试虚拟滚动性能
*/
static testVirtualScrollPerformance(
itemCount: number,
containerHeight: number = 600,
itemHeight: number = 72,
) {
const visibleCount = Math.ceil(containerHeight / itemHeight);
const renderCount = visibleCount + 4; // 加上缓冲项
return {
totalItems: itemCount,
visibleItems: visibleCount,
renderItems: renderCount,
renderRatio: (renderCount / itemCount) * 100,
memoryEstimate: itemCount * 0.5, // 估算内存占用KB
};
}
/**
* 生成性能报告
*/
static generatePerformanceReport() {
const stats = performanceMonitor.getStats();
const report = {
timestamp: new Date().toISOString(),
totalMeasurements: stats.count,
averageDuration: stats.average,
minDuration: stats.min,
maxDuration: stats.max,
totalDuration: stats.total,
results: stats.results,
};
return report;
}
}
/**
* 数据验证辅助函数
*/
export class DataValidator {
/**
* 验证会话数据完整性
*/
static validateSession(session: any): boolean {
if (!session) return false;
if (!session.id) return false;
if (!session.type) return false;
if (!["friend", "group"].includes(session.type)) return false;
return true;
}
/**
* 验证联系人数据完整性
*/
static validateContact(contact: any): boolean {
if (!contact) return false;
if (!contact.id) return false;
if (!contact.type) return false;
if (!["friend", "group"].includes(contact.type)) return false;
return true;
}
/**
* 验证索引一致性
*/
static validateIndexConsistency() {
const messageStore = useMessageStore.getState();
const allSessions = messageStore.allSessions;
const sessionIndex = messageStore.sessionIndex;
// 验证索引中的会话总数是否等于allSessions的长度
let indexCount = 0;
sessionIndex.forEach(sessions => {
indexCount += sessions.length;
});
const isValid = indexCount === allSessions.length;
return {
isValid,
allSessionsCount: allSessions.length,
indexCount,
difference: Math.abs(indexCount - allSessions.length),
};
}
}
/**
* 调试辅助函数
*/
export class DebugHelpers {
/**
* 打印Store状态
*/
static printStoreState() {
const messageStore = useMessageStore.getState();
const contactStore = useContactStoreNew.getState();
console.group("📊 Store状态");
console.log("MessageStore:", {
sessionsCount: messageStore.sessions.length,
allSessionsCount: messageStore.allSessions.length,
indexSize: messageStore.sessionIndex.size,
selectedAccountId: messageStore.selectedAccountId,
searchKeyword: messageStore.searchKeyword,
});
console.log("ContactStore:", {
groupsCount: contactStore.groups.length,
expandedGroupsCount: contactStore.expandedGroups.size,
groupDataSize: contactStore.groupData.size,
selectedAccountId: contactStore.selectedAccountId,
isSearchMode: contactStore.isSearchMode,
searchResultsCount: contactStore.searchResults.length,
});
console.groupEnd();
}
/**
* 打印性能统计
*/
static printPerformanceStats() {
const stats = performanceMonitor.getStats();
console.group("⚡ 性能统计");
console.log("总测量次数:", stats.count);
console.log("平均耗时:", `${stats.average.toFixed(2)}ms`);
console.log("最小耗时:", `${stats.min.toFixed(2)}ms`);
console.log("最大耗时:", `${stats.max.toFixed(2)}ms`);
console.log("总耗时:", `${stats.total.toFixed(2)}ms`);
console.groupEnd();
}
/**
* 导出性能数据
*/
static exportPerformanceData() {
const data = performanceMonitor.export();
const blob = new Blob([data], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `performance-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
}
}
// 在浏览器控制台暴露调试工具
if (typeof window !== "undefined") {
(window as any).__CKB_TEST_HELPERS__ = {
TestDataGenerator,
PerformanceTestHelpers,
DataValidator,
DebugHelpers,
printStoreState: DebugHelpers.printStoreState,
printPerformanceStats: DebugHelpers.printPerformanceStats,
exportPerformanceData: DebugHelpers.exportPerformanceData,
};
console.log(
"%c🧪 测试工具已加载",
"color: #1890ff; font-weight: bold; font-size: 14px;",
);
console.log("使用 window.__CKB_TEST_HELPERS__ 访问测试工具");
}