feat: 初始化项目基础架构与核心功能

添加项目基础文件结构、路由配置、API接口和核心组件
实现登录认证、权限控制、WebSocket通信等基础功能
引入antd-mobile UI组件库和Vite构建工具
配置TypeScript、ESLint、Prettier等开发环境
添加移动端适配方案和全局样式
完成首页、工作台、个人中心等基础页面框架
This commit is contained in:
2025-09-11 15:00:57 +08:00
parent 648a61d09a
commit 9b3181576f
481 changed files with 74456 additions and 0 deletions

View File

@@ -0,0 +1,153 @@
.twoColumnModal {
.ant-modal-body {
padding: 0;
}
}
.container {
display: flex;
height: 500px;
border: 1px solid #e8e8e8;
}
.leftColumn {
flex: 1;
border-right: 1px solid #e8e8e8;
display: flex;
flex-direction: column;
}
.rightColumn {
width: 300px;
display: flex;
flex-direction: column;
background: #fafafa;
}
.searchWrapper {
padding: 16px;
border-bottom: 1px solid #e8e8e8;
.ant-input {
border-radius: 6px;
}
}
.friendList {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
.friendItem {
display: flex;
align-items: center;
padding: 12px 16px;
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background-color: #f5f5f5;
}
&.selected {
background-color: #e6f7ff;
}
.ant-checkbox {
margin-right: 12px;
}
}
.friendInfo {
margin-left: 12px;
flex: 1;
}
.friendName {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 2px;
}
.friendId {
font-size: 12px;
color: #999;
}
.selectedHeader {
padding: 16px;
border-bottom: 1px solid #e8e8e8;
font-weight: 500;
color: #333;
background: #fff;
}
.selectedList {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
.selectedItem {
display: flex;
align-items: center;
padding: 8px 16px;
background: #fff;
margin: 4px 8px;
border-radius: 6px;
border: 1px solid #e8e8e8;
}
.selectedInfo {
margin-left: 8px;
flex: 1;
}
.selectedName {
font-size: 13px;
color: #333;
}
.removeBtn {
color: #999;
font-size: 16px;
padding: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
&:hover {
color: #ff4d4f;
background: #fff2f0;
}
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 100px;
color: #999;
}
.empty {
display: flex;
align-items: center;
justify-content: center;
height: 100px;
color: #999;
font-size: 14px;
}
.emptySelected {
display: flex;
align-items: center;
justify-content: center;
height: 100px;
color: #999;
font-size: 14px;
}

View File

@@ -0,0 +1,329 @@
import React, { useState, useCallback, useEffect, useMemo, memo } from "react";
import { Modal, Input, Avatar, Button, Checkbox, message } from "antd";
import { SearchOutlined } from "@ant-design/icons";
import { getFriendList } from "../FriendSelection/api";
import type { FriendSelectionItem } from "../FriendSelection/data";
import styles from "./TwoColumnSelection.module.scss";
// 使用 React.memo 优化好友列表项组件
const FriendListItem = memo<{
friend: FriendSelectionItem;
isSelected: boolean;
onSelect: (friend: FriendSelectionItem) => void;
}>(({ friend, isSelected, onSelect }) => {
return (
<div
className={`${styles.friendItem} ${isSelected ? styles.selected : ""}`}
onClick={() => onSelect(friend)}
>
<Checkbox checked={isSelected} />
<Avatar src={friend.avatar} size={40}>
{friend.nickname?.charAt(0)}
</Avatar>
<div className={styles.friendInfo}>
<div className={styles.friendName}>{friend.nickname}</div>
<div className={styles.friendId}>{friend.wechatId}</div>
</div>
</div>
);
});
FriendListItem.displayName = "FriendListItem";
interface TwoColumnSelectionProps {
visible: boolean;
onCancel: () => void;
onConfirm: (
selectedIds: string[],
selectedItems: FriendSelectionItem[],
) => void;
title?: string;
deviceIds?: number[];
enableDeviceFilter?: boolean;
dataSource?: FriendSelectionItem[];
}
const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({
visible,
onCancel,
onConfirm,
title = "选择好友",
deviceIds = [],
enableDeviceFilter = true,
dataSource,
}) => {
const [rawFriends, setRawFriends] = useState<FriendSelectionItem[]>([]);
const [selectedFriends, setSelectedFriends] = useState<FriendSelectionItem[]>(
[],
);
const [searchQuery, setSearchQuery] = useState("");
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
// 使用 useMemo 缓存过滤结果,避免每次渲染都重新计算
const filteredFriends = useMemo(() => {
const sourceData = dataSource || rawFriends;
if (!searchQuery.trim()) {
return sourceData;
}
const query = searchQuery.toLowerCase();
return sourceData.filter(
item =>
item.name?.toLowerCase().includes(query) ||
item.nickname?.toLowerCase().includes(query),
);
}, [dataSource, rawFriends, searchQuery]);
// 分页显示好友列表,避免一次性渲染太多项目
const ITEMS_PER_PAGE = 50;
const [displayPage, setDisplayPage] = useState(1);
const friends = useMemo(() => {
const startIndex = 0;
const endIndex = displayPage * ITEMS_PER_PAGE;
return filteredFriends.slice(startIndex, endIndex);
}, [filteredFriends, displayPage]);
const hasMoreFriends = filteredFriends.length > friends.length;
// 使用 useMemo 缓存选中状态映射,避免每次渲染都重新计算
const selectedFriendsMap = useMemo(() => {
const map = new Map();
selectedFriends.forEach(friend => {
map.set(friend.id, true);
});
return map;
}, [selectedFriends]);
// 获取好友列表
const fetchFriends = useCallback(
async (page: number, keyword: string = "") => {
setLoading(true);
try {
const params: any = {
page,
pageSize: 20,
};
if (keyword) {
params.keyword = keyword;
}
if (enableDeviceFilter && deviceIds.length > 0) {
params.deviceIds = deviceIds;
}
const response = await getFriendList(params);
if (response.success) {
setRawFriends(response.data.list || []);
setTotalPages(Math.ceil((response.data.total || 0) / 20));
} else {
setRawFriends([]);
message.error(response.message || "获取好友列表失败");
}
} catch (error) {
console.error("获取好友列表失败:", error);
message.error("获取好友列表失败");
} finally {
setLoading(false);
}
},
[deviceIds, enableDeviceFilter],
);
// 初始化数据加载
useEffect(() => {
if (visible && !dataSource) {
// 只有在没有外部数据源时才调用 API
fetchFriends(1);
setCurrentPage(1);
}
}, [visible, dataSource, fetchFriends]);
// 重置搜索状态
useEffect(() => {
if (visible) {
setSearchQuery("");
setSelectedFriends([]);
setLoading(false);
}
}, [visible]);
// 防抖搜索处理
const handleSearch = useCallback(() => {
let timeoutId: NodeJS.Timeout;
return (value: string) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
setDisplayPage(1); // 重置分页
if (!dataSource) {
fetchFriends(1, value);
}
}, 300);
};
}, [dataSource, fetchFriends])();
// API搜索处理当没有外部数据源时
const handleApiSearch = useCallback(
async (keyword: string) => {
if (!dataSource) {
await fetchFriends(1, keyword);
}
},
[dataSource, fetchFriends],
);
// 加载更多好友
const handleLoadMore = useCallback(() => {
setDisplayPage(prev => prev + 1);
}, []);
// 防抖搜索
useEffect(() => {
if (!dataSource && searchQuery.trim()) {
const timer = setTimeout(() => {
handleApiSearch(searchQuery);
}, 300);
return () => clearTimeout(timer);
}
}, [searchQuery, dataSource, handleApiSearch]);
// 选择好友 - 使用 useCallback 优化性能
const handleSelectFriend = useCallback((friend: FriendSelectionItem) => {
setSelectedFriends(prev => {
const isSelected = prev.some(f => f.id === friend.id);
if (isSelected) {
return prev.filter(f => f.id !== friend.id);
} else {
return [...prev, friend];
}
});
}, []);
// 移除已选好友 - 使用 useCallback 优化性能
const handleRemoveFriend = useCallback((friendId: number) => {
setSelectedFriends(prev => prev.filter(f => f.id !== friendId));
}, []);
// 确认选择 - 使用 useCallback 优化性能
const handleConfirmSelection = useCallback(() => {
const selectedIds = selectedFriends.map(f => f.id.toString());
onConfirm(selectedIds, selectedFriends);
setSelectedFriends([]);
setSearchQuery("");
}, [selectedFriends, onConfirm]);
// 取消选择 - 使用 useCallback 优化性能
const handleCancel = useCallback(() => {
setSelectedFriends([]);
setSearchQuery("");
onCancel();
}, [onCancel]);
return (
<Modal
title={title}
open={visible}
onCancel={handleCancel}
width={800}
footer={[
<Button key="cancel" onClick={handleCancel}>
</Button>,
<Button key="confirm" type="primary" onClick={handleConfirmSelection}>
</Button>,
]}
className={styles.twoColumnModal}
>
<div className={styles.container}>
{/* 左侧:好友列表 */}
<div className={styles.leftColumn}>
<div className={styles.searchWrapper}>
<Input
placeholder="请输入昵称或微信号"
value={searchQuery}
onChange={e => {
const value = e.target.value;
setSearchQuery(value); // 立即更新显示
handleSearch(value); // 防抖处理搜索
}}
prefix={<SearchOutlined />}
allowClear
/>
</div>
<div className={styles.friendList}>
{loading ? (
<div className={styles.loading}>...</div>
) : friends.length > 0 ? (
// 使用 React.memo 优化列表项渲染
friends.map(friend => {
const isSelected = selectedFriendsMap.has(friend.id);
return (
<FriendListItem
key={friend.id}
friend={friend}
isSelected={isSelected}
onSelect={handleSelectFriend}
/>
);
})
) : (
<div className={styles.empty}>
{searchQuery
? `没有找到包含"${searchQuery}"的好友`
: "暂无好友"}
</div>
)}
{hasMoreFriends && (
<div className={styles.loadMoreWrapper}>
<Button type="link" onClick={handleLoadMore} loading={loading}>
</Button>
</div>
)}
</div>
</div>
{/* 右侧:已选好友 */}
<div className={styles.rightColumn}>
<div className={styles.selectedHeader}>
({selectedFriends.length})
</div>
<div className={styles.selectedList}>
{selectedFriends.length > 0 ? (
selectedFriends.map(friend => (
<div key={friend.id} className={styles.selectedItem}>
<Avatar src={friend.avatar} size={32}>
{friend.nickname?.charAt(0)}
</Avatar>
<div className={styles.selectedInfo}>
<div className={styles.selectedName}>{friend.nickname}</div>
</div>
<Button
type="text"
size="small"
onClick={() => handleRemoveFriend(friend.id)}
className={styles.removeBtn}
>
×
</Button>
</div>
))
) : (
<div className={styles.emptySelected}></div>
)}
</div>
</div>
</div>
</Modal>
);
};
export default TwoColumnSelection;