增强应用组件中的ErrorFallback UI,并更新SidebarMenu以实现新的联系人存储集成。重构微信组件,支持虚拟滚动,提升性能。在微信好友组件中实现新的群组管理功能。
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
255
Touchkebao/src/components/ContactContextMenu/index.tsx
Normal file
255
Touchkebao/src/components/ContactContextMenu/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
173
Touchkebao/src/components/ErrorBoundary/index.tsx
Normal file
173
Touchkebao/src/components/ErrorBoundary/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
}
|
||||
18
Touchkebao/src/components/GroupContextMenu/index.module.scss
Normal file
18
Touchkebao/src/components/GroupContextMenu/index.module.scss
Normal 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);
|
||||
}
|
||||
121
Touchkebao/src/components/GroupContextMenu/index.tsx
Normal file
121
Touchkebao/src/components/GroupContextMenu/index.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
412
Touchkebao/src/components/VirtualContactList/index.tsx
Normal file
412
Touchkebao/src/components/VirtualContactList/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
191
Touchkebao/src/components/VirtualSessionList/index.tsx
Normal file
191
Touchkebao/src/components/VirtualSessionList/index.tsx
Normal 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;
|
||||
};
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
245
Touchkebao/src/store/module/weChat/account.ts
Normal file
245
Touchkebao/src/store/module/weChat/account.ts
Normal 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[];
|
||||
|
||||
// ==================== 当前选中的账号 ====================
|
||||
/** 当前选中的账号ID,0表示"全部" */
|
||||
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 账号ID,0表示"全部"
|
||||
*/
|
||||
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);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -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; // 分组ID(groupId)
|
||||
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[]; // 所有分组信息
|
||||
|
||||
// ==================== 当前选中的账号ID(0=全部)====================
|
||||
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;
|
||||
}
|
||||
|
||||
986
Touchkebao/src/store/module/weChat/contacts.new.ts
Normal file
986
Touchkebao/src/store/module/weChat/contacts.new.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -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>;
|
||||
// 当前选中的账号ID(0表示"全部")
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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
450
Touchkebao/src/utils/cache/index.ts
vendored
Normal 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); // 每小时
|
||||
}
|
||||
314
Touchkebao/src/utils/dataIndex.ts
Normal file
314
Touchkebao/src/utils/dataIndex.ts
Normal 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 账号ID,0表示"全部"
|
||||
* @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 账号ID,0表示"全部"
|
||||
* @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;
|
||||
}
|
||||
356
Touchkebao/src/utils/errorHandler.ts
Normal file
356
Touchkebao/src/utils/errorHandler.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
204
Touchkebao/src/utils/performance.ts
Normal file
204
Touchkebao/src/utils/performance.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
240
Touchkebao/src/utils/test/performanceTest.ts
Normal file
240
Touchkebao/src/utils/test/performanceTest.ts
Normal 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());
|
||||
};
|
||||
}
|
||||
476
Touchkebao/src/utils/test/testCases.md
Normal file
476
Touchkebao/src/utils/test/testCases.md
Normal 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. 解决方案
|
||||
257
Touchkebao/src/utils/test/testHelpers.ts
Normal file
257
Touchkebao/src/utils/test/testHelpers.ts
Normal 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__ 访问测试工具");
|
||||
}
|
||||
Reference in New Issue
Block a user