代码提交

This commit is contained in:
wong
2025-09-05 17:10:50 +08:00
parent 689e6d18df
commit 1c99eee96b
4 changed files with 698 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,206 @@
import React, { useState, useCallback, useEffect } from 'react';
import { Modal, Input, Avatar, Button, Checkbox, message } from 'antd';
import { SearchOutlined } from '@ant-design/icons';
import { getFriendList } from './api';
import type { FriendSelectionItem } from './data';
import styles from './TwoColumnSelection.module.scss';
interface TwoColumnSelectionProps {
visible: boolean;
onCancel: () => void;
onConfirm: (selectedIds: string[], selectedItems: FriendSelectionItem[]) => void;
title?: string;
deviceIds?: number[];
enableDeviceFilter?: boolean;
}
const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({
visible,
onCancel,
onConfirm,
title = '选择好友',
deviceIds = [],
enableDeviceFilter = true,
}) => {
const [friends, setFriends] = 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);
// 获取好友列表
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) {
setFriends(response.data.list || []);
setTotalPages(Math.ceil((response.data.total || 0) / 20));
} else {
message.error(response.message || '获取好友列表失败');
}
} catch (error) {
console.error('获取好友列表失败:', error);
message.error('获取好友列表失败');
} finally {
setLoading(false);
}
}, [deviceIds, enableDeviceFilter]);
// 初始化和搜索
useEffect(() => {
if (visible) {
fetchFriends(1, searchQuery);
setCurrentPage(1);
}
}, [visible, searchQuery, fetchFriends]);
// 处理搜索
const handleSearch = (value: string) => {
setSearchQuery(value);
};
// 选择好友
const handleSelectFriend = (friend: FriendSelectionItem) => {
const isSelected = selectedFriends.some(f => f.id === friend.id);
if (isSelected) {
setSelectedFriends(selectedFriends.filter(f => f.id !== friend.id));
} else {
setSelectedFriends([...selectedFriends, friend]);
}
};
// 移除已选好友
const handleRemoveFriend = (friendId: number) => {
setSelectedFriends(selectedFriends.filter(f => f.id !== friendId));
};
// 确认选择
const handleConfirmSelection = () => {
const selectedIds = selectedFriends.map(f => f.id.toString());
onConfirm(selectedIds, selectedFriends);
setSelectedFriends([]);
setSearchQuery('');
};
// 取消选择
const handleCancelSelection = () => {
setSelectedFriends([]);
setSearchQuery('');
onCancel();
};
return (
<Modal
title={title}
open={visible}
onCancel={handleCancelSelection}
width={800}
footer={[
<Button key="cancel" onClick={handleCancelSelection}>
</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) => handleSearch(e.target.value)}
prefix={<SearchOutlined />}
allowClear
/>
</div>
<div className={styles.friendList}>
{loading ? (
<div className={styles.loading}>...</div>
) : friends.length > 0 ? (
friends.map(friend => {
const isSelected = selectedFriends.some(f => f.id === friend.id);
return (
<div
key={friend.id}
className={`${styles.friendItem} ${isSelected ? styles.selected : ''}`}
onClick={() => handleSelectFriend(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>
);
})
) : (
<div className={styles.empty}>
{searchQuery ? `没有找到包含"${searchQuery}"的好友` : '暂无好友'}
</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;

View File

@@ -0,0 +1,154 @@
.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;
}
}
.memberList {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
.memberItem {
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;
}
}
.memberInfo {
margin-left: 12px;
flex: 1;
}
.memberName {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 2px;
}
.memberId {
font-size: 12px;
color: #999;
}
.selectedHeader {
padding: 16px;
border-bottom: 1px solid #e8e8e8;
font-weight: 500;
color: #333;
background: #fff;
display: flex;
align-items: center;
justify-content: space-between;
}
.singleTip {
font-size: 12px;
color: #999;
font-weight: normal;
}
.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;
}
}
.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,185 @@
import React, { useState } from 'react';
import { Modal, Input, Avatar, Button, Checkbox } from 'antd';
import { SearchOutlined } from '@ant-design/icons';
import styles from './TwoColumnMemberSelection.module.scss';
interface Member {
id: string;
nickname: string;
avatar: string;
}
interface TwoColumnMemberSelectionProps {
visible: boolean;
members: Member[];
onCancel: () => void;
onConfirm: (selectedIds: string[]) => void;
title?: string;
allowMultiple?: boolean;
}
const TwoColumnMemberSelection: React.FC<TwoColumnMemberSelectionProps> = ({
visible,
members,
onCancel,
onConfirm,
title = '选择成员',
allowMultiple = true,
}) => {
const [selectedMembers, setSelectedMembers] = useState<Member[]>([]);
const [searchQuery, setSearchQuery] = useState('');
// 过滤成员
const filteredMembers = members.filter(member =>
member.nickname.toLowerCase().includes(searchQuery.toLowerCase()) ||
member.id.toLowerCase().includes(searchQuery.toLowerCase())
);
// 处理搜索
const handleSearch = (value: string) => {
setSearchQuery(value);
};
// 选择成员
const handleSelectMember = (member: Member) => {
const isSelected = selectedMembers.some(m => m.id === member.id);
if (allowMultiple) {
if (isSelected) {
setSelectedMembers(selectedMembers.filter(m => m.id !== member.id));
} else {
setSelectedMembers([...selectedMembers, member]);
}
} else {
// 单选模式
if (isSelected) {
setSelectedMembers([]);
} else {
setSelectedMembers([member]);
}
}
};
// 移除已选成员
const handleRemoveMember = (memberId: string) => {
setSelectedMembers(selectedMembers.filter(m => m.id !== memberId));
};
// 确认选择
const handleConfirmSelection = () => {
const selectedIds = selectedMembers.map(m => m.id);
onConfirm(selectedIds);
setSelectedMembers([]);
setSearchQuery('');
};
// 取消选择
const handleCancelSelection = () => {
setSelectedMembers([]);
setSearchQuery('');
onCancel();
};
return (
<Modal
title={title}
open={visible}
onCancel={handleCancelSelection}
width={800}
footer={[
<Button key="cancel" onClick={handleCancelSelection}>
</Button>,
<Button
key="confirm"
type="primary"
onClick={handleConfirmSelection}
disabled={selectedMembers.length === 0}
>
</Button>,
]}
className={styles.twoColumnModal}
>
<div className={styles.container}>
{/* 左侧:成员列表 */}
<div className={styles.leftColumn}>
<div className={styles.searchWrapper}>
<Input
placeholder="请输入昵称或微信号"
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
prefix={<SearchOutlined />}
allowClear
/>
</div>
<div className={styles.memberList}>
{filteredMembers.length > 0 ? (
filteredMembers.map(member => {
const isSelected = selectedMembers.some(m => m.id === member.id);
return (
<div
key={member.id}
className={`${styles.memberItem} ${isSelected ? styles.selected : ''}`}
onClick={() => handleSelectMember(member)}
>
<Checkbox checked={isSelected} />
<Avatar src={member.avatar} size={40}>
{member.nickname?.charAt(0)}
</Avatar>
<div className={styles.memberInfo}>
<div className={styles.memberName}>{member.nickname}</div>
<div className={styles.memberId}>{member.id}</div>
</div>
</div>
);
})
) : (
<div className={styles.empty}>
{searchQuery ? `没有找到包含"${searchQuery}"的成员` : '暂无成员'}
</div>
)}
</div>
</div>
{/* 右侧:已选成员 */}
<div className={styles.rightColumn}>
<div className={styles.selectedHeader}>
({selectedMembers.length})
{!allowMultiple && <span className={styles.singleTip}></span>}
</div>
<div className={styles.selectedList}>
{selectedMembers.length > 0 ? (
selectedMembers.map(member => (
<div key={member.id} className={styles.selectedItem}>
<Avatar src={member.avatar} size={32}>
{member.nickname?.charAt(0)}
</Avatar>
<div className={styles.selectedInfo}>
<div className={styles.selectedName}>{member.nickname}</div>
</div>
<Button
type="text"
size="small"
onClick={() => handleRemoveMember(member.id)}
className={styles.removeBtn}
>
×
</Button>
</div>
))
) : (
<div className={styles.emptySelected}>
</div>
)}
</div>
</div>
</div>
</Modal>
);
};
export default TwoColumnMemberSelection;