Implement contact filtering functionality in CreatePushTask component. Add modal for filtering contacts by tags, cities, and nickname/remark. Update styles for filter modal and enhance state management for filter values.

This commit is contained in:
超级老白兔
2025-11-11 11:56:38 +08:00
parent 2f804c7d40
commit bb72038e2b
3 changed files with 437 additions and 9 deletions

View File

@@ -10,6 +10,8 @@ import {
Pagination,
Spin,
message,
Modal,
Select,
} from "antd";
import {
CloseOutlined,
@@ -23,6 +25,37 @@ import { getContactList, getGroupList } from "@/pages/pc/ckbox/weChat/api";
import styles from "../../index.module.scss";
import { ContactItem, PushType } from "../../types";
interface ContactFilterValues {
includeTags: string[];
excludeTags: string[];
includeCities: string[];
excludeCities: string[];
nicknameRemark: string;
groupIds: string[];
}
const createDefaultFilterValues = (): ContactFilterValues => ({
includeTags: [],
excludeTags: [],
includeCities: [],
excludeCities: [],
nicknameRemark: "",
groupIds: [],
});
const cloneFilterValues = (
values: ContactFilterValues,
): ContactFilterValues => ({
includeTags: [...values.includeTags],
excludeTags: [...values.excludeTags],
includeCities: [...values.includeCities],
excludeCities: [...values.excludeCities],
nicknameRemark: values.nicknameRemark,
groupIds: [...values.groupIds],
});
const DISABLED_TAG_LABELS = new Set(["请选择标签"]);
interface StepSelectContactsProps {
pushType: PushType;
selectedAccounts: any[];
@@ -41,6 +74,12 @@ const StepSelectContacts: React.FC<StepSelectContactsProps> = ({
const [page, setPage] = useState(1);
const [searchValue, setSearchValue] = useState("");
const [total, setTotal] = useState(0);
const [filterModalVisible, setFilterModalVisible] = useState(false);
const [filterValues, setFilterValues] = useState<ContactFilterValues>(
createDefaultFilterValues,
);
const [draftFilterValues, setDraftFilterValues] =
useState<ContactFilterValues>(createDefaultFilterValues);
const pageSize = 20;
@@ -140,12 +179,159 @@ const StepSelectContacts: React.FC<StepSelectContactsProps> = ({
}
};
const filteredContacts = useMemo(() => {
if (searchValue.trim()) {
return contactsData;
const tagOptions = useMemo(() => {
const tagSet = new Set<string>();
contactsData.forEach(contact => {
(contact.labels || []).forEach(tag => {
const normalizedTag = (tag || "").trim();
if (normalizedTag && !DISABLED_TAG_LABELS.has(normalizedTag)) {
tagSet.add(normalizedTag);
}
});
});
return Array.from(tagSet).map(tag => ({ label: tag, value: tag }));
}, [contactsData]);
const cityOptions = useMemo(() => {
const citySet = new Set<string>();
contactsData.forEach(contact => {
const city = (contact.city || contact.region || "").trim();
if (city) {
citySet.add(city);
}
});
return Array.from(citySet).map(city => ({ label: city, value: city }));
}, [contactsData]);
const groupOptions = useMemo(() => {
const groupMap = new Map<string, string>();
contactsData.forEach(contact => {
const key =
contact.groupName ||
contact.groupLabel ||
(contact.groupId !== undefined ? contact.groupId.toString() : "");
if (key) {
const display =
contact.groupName || contact.groupLabel || `分组 ${key}`;
groupMap.set(key, display);
}
});
return Array.from(groupMap.entries()).map(([value, label]) => ({
value,
label,
}));
}, [contactsData]);
const hasActiveFilter = useMemo(() => {
const {
includeTags,
excludeTags,
includeCities,
excludeCities,
nicknameRemark,
groupIds,
} = filterValues;
if (
includeTags.length ||
excludeTags.length ||
includeCities.length ||
excludeCities.length ||
groupIds.length ||
nicknameRemark.trim()
) {
return true;
}
return contactsData;
}, [contactsData, searchValue]);
return false;
}, [filterValues]);
const filteredContacts = useMemo(() => {
const keyword = searchValue.trim().toLowerCase();
const nicknameKeyword = filterValues.nicknameRemark.trim().toLowerCase();
return contactsData.filter(contact => {
const labels = contact.labels || [];
const city = (contact.city || contact.region || "").toLowerCase();
const groupValue =
contact.groupName ||
contact.groupLabel ||
(contact.groupId !== undefined ? contact.groupId.toString() : "");
if (keyword) {
const combined = `${contact.nickname || ""} ${
contact.conRemark || ""
}`.toLowerCase();
if (!combined.includes(keyword)) {
return false;
}
}
if (filterValues.includeTags.length > 0) {
const hasAllIncludes = filterValues.includeTags.every(tag =>
labels.includes(tag),
);
if (!hasAllIncludes) {
return false;
}
}
if (filterValues.excludeTags.length > 0) {
const hasExcluded = filterValues.excludeTags.some(tag =>
labels.includes(tag),
);
if (hasExcluded) {
return false;
}
}
if (filterValues.includeCities.length > 0) {
const matchCity = filterValues.includeCities.some(value =>
city.includes(value.toLowerCase()),
);
if (!matchCity) {
return false;
}
}
if (filterValues.excludeCities.length > 0) {
const matchExcludedCity = filterValues.excludeCities.some(value =>
city.includes(value.toLowerCase()),
);
if (matchExcludedCity) {
return false;
}
}
if (nicknameKeyword) {
const combined = `${contact.nickname || ""} ${
contact.conRemark || ""
}`.toLowerCase();
if (!combined.includes(nicknameKeyword)) {
return false;
}
}
if (filterValues.groupIds.length > 0) {
if (!groupValue) {
return false;
}
if (
!filterValues.groupIds.some(value => value === groupValue.toString())
) {
return false;
}
}
return true;
});
}, [contactsData, filterValues, searchValue]);
const displayTotal = useMemo(() => {
if (hasActiveFilter) {
return filteredContacts.length;
}
return total;
}, [filteredContacts, hasActiveFilter, total]);
const handleContactToggle = (contact: ContactItem) => {
const isSelected = selectedContacts.some(c => c.id === contact.id);
@@ -176,6 +362,39 @@ const StepSelectContacts: React.FC<StepSelectContactsProps> = ({
onChange([...selectedContacts, ...toAdd]);
};
const openFilterModal = () => {
setDraftFilterValues(cloneFilterValues(filterValues));
setFilterModalVisible(true);
};
const closeFilterModal = () => {
setFilterModalVisible(false);
};
const handleFilterConfirm = () => {
setFilterValues(cloneFilterValues(draftFilterValues));
setPage(1);
setFilterModalVisible(false);
};
const handleFilterReset = () => {
const nextValues = createDefaultFilterValues();
setDraftFilterValues(nextValues);
setFilterValues(nextValues);
setPage(1);
setFilterModalVisible(false);
};
const updateDraftFilter = <K extends keyof ContactFilterValues>(
key: K,
value: ContactFilterValues[K],
) => {
setDraftFilterValues(prev => ({
...prev,
[key]: value,
}));
};
const handlePageChange = (p: number) => {
setPage(p);
};
@@ -195,14 +414,22 @@ const StepSelectContacts: React.FC<StepSelectContactsProps> = ({
onChange={e => handleSearchChange(e.target.value)}
allowClear
/>
<Button onClick={handleSelectAllContacts}></Button>
</div>
<div className={styles.contentBody}>
<div className={styles.contactList}>
<div className={styles.listHeader}>
<span>
{stepTitle}({total})
{stepTitle}({displayTotal})
</span>
<div style={{ display: "flex", gap: 10 }}>
<Button onClick={handleSelectAllContacts}></Button>
<Button
type={hasActiveFilter ? "primary" : "default"}
onClick={openFilterModal}
>
</Button>
</div>
</div>
<div className={styles.listContent}>
{loadingContacts ? (
@@ -260,13 +487,13 @@ const StepSelectContacts: React.FC<StepSelectContactsProps> = ({
/>
)}
</div>
{total > 0 && (
{displayTotal > 0 && (
<div className={styles.paginationContainer}>
<Pagination
size="small"
current={page}
pageSize={pageSize}
total={total}
total={displayTotal}
onChange={handlePageChange}
showSizeChanger={false}
/>
@@ -332,6 +559,118 @@ const StepSelectContacts: React.FC<StepSelectContactsProps> = ({
</div>
</div>
</div>
<Modal
title={`筛选${stepTitle}`}
open={filterModalVisible}
onCancel={closeFilterModal}
width={720}
className={styles.filterModal}
footer={[
<Button key="reset" onClick={handleFilterReset}>
</Button>,
<Button key="cancel" onClick={closeFilterModal}>
</Button>,
<Button key="ok" type="primary" onClick={handleFilterConfirm}>
</Button>,
]}
>
<div className={styles.filterRow}>
<div className={styles.filterLabel}></div>
<div className={styles.filterControls}>
<div className={styles.filterControl}>
<Button type="primary"></Button>
<Select
allowClear
mode="multiple"
placeholder="请选择"
options={tagOptions}
value={draftFilterValues.includeTags}
onChange={(value: string[]) =>
updateDraftFilter("includeTags", value)
}
/>
</div>
<div className={styles.filterControl}>
<Button className={styles.excludeButton}></Button>
<Select
allowClear
mode="multiple"
placeholder="请选择"
options={tagOptions}
value={draftFilterValues.excludeTags}
onChange={(value: string[]) =>
updateDraftFilter("excludeTags", value)
}
/>
</div>
</div>
</div>
<div className={styles.filterRow}>
<div className={styles.filterLabel}></div>
<div className={styles.filterControls}>
<div className={styles.filterControl}>
<Button type="primary"></Button>
<Select
allowClear
mode="multiple"
placeholder="请选择"
options={cityOptions}
value={draftFilterValues.includeCities}
onChange={(value: string[]) =>
updateDraftFilter("includeCities", value)
}
/>
</div>
<div className={styles.filterControl}>
<Button className={styles.excludeButton}></Button>
<Select
allowClear
mode="multiple"
placeholder="请选择"
options={cityOptions}
value={draftFilterValues.excludeCities}
onChange={(value: string[]) =>
updateDraftFilter("excludeCities", value)
}
/>
</div>
</div>
</div>
<div className={styles.filterRow}>
<div className={styles.filterLabel}>/</div>
<div className={styles.filterSingleControl}>
<Input
placeholder="请输入内容"
value={draftFilterValues.nicknameRemark}
onChange={e =>
updateDraftFilter("nicknameRemark", e.target.value)
}
/>
</div>
</div>
<div className={styles.filterRow}>
<div className={styles.filterLabel}></div>
<div className={styles.filterSingleControl}>
<Select
allowClear
mode="multiple"
placeholder="请选择"
options={groupOptions}
value={draftFilterValues.groupIds}
onChange={(value: string[]) =>
updateDraftFilter("groupIds", value)
}
/>
</div>
</div>
</Modal>
</div>
);
};

View File

@@ -576,6 +576,89 @@
}
}
.filterModal {
:global(.ant-modal-body) {
padding-bottom: 12px;
}
.filterRow {
display: flex;
align-items: flex-start;
gap: 16px;
margin-bottom: 16px;
}
.filterLabel {
width: 64px;
text-align: right;
line-height: 32px;
font-weight: 500;
color: #1f1f1f;
}
.filterControls {
flex: 1;
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.filterControl {
display: flex;
align-items: center;
gap: 12px;
:global(.ant-select) {
min-width: 220px;
}
}
.filterSingleControl {
flex: 1;
:global(.ant-input),
:global(.ant-select) {
width: 100%;
}
}
.extendFields {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 12px;
}
.extendFieldItem {
display: flex;
align-items: center;
gap: 12px;
}
.extendFieldLabel {
width: 80px;
text-align: right;
color: #595959;
}
.extendFieldItem :global(.ant-input) {
flex: 1;
}
.excludeButton {
background-color: #faad14;
border-color: #faad14;
color: #fff;
&:hover,
&:focus {
background-color: #d48806;
border-color: #d48806;
color: #fff;
}
}
}
.footer {
display: flex;
justify-content: space-between;

View File

@@ -12,4 +12,10 @@ export interface ContactItem {
gender?: number;
region?: string;
type?: "friend" | "group";
labels?: string[];
groupId?: number | string;
groupName?: string;
groupLabel?: string;
city?: string;
extendFields?: Record<string, any>;
}