feat(流量池选择): 新增流量池选择组件及相关API和类型定义

添加流量池选择组件,包含以下功能:
- 流量池列表获取API
- 流量池项和组件属性类型定义
- 选择弹窗组件实现
- 样式文件和测试页面集成
- 支持搜索、分页和多选功能
This commit is contained in:
超级老白兔
2025-08-20 16:08:49 +08:00
parent 1d93368eb0
commit 3d944770f1
6 changed files with 656 additions and 1 deletions

View File

@@ -0,0 +1,15 @@
import request from "@/api/request";
// 获取流量池列表
export function getPoolList(params: {
page?: string;
pageSize?: string;
keyword?: string;
addStatus?: string;
deviceId?: string;
packageId?: string;
userValue?: string;
[property: string]: any;
}) {
return request("/v1/traffic/pool", params, "GET");
}

View File

@@ -0,0 +1,50 @@
// 流量池接口类型
export interface PoolItem {
id: number;
identifier: string;
mobile: string;
wechatId: string;
fromd: string;
status: number;
createTime: string;
companyId: number;
sourceId: string;
type: number;
nickname: string;
avatar: string;
gender: number;
phone: string;
alias: string;
packages: any[];
tags: any[];
}
export interface GroupSelectionItem {
id: string;
avatar: string;
name: string;
wechatId?: string;
mobile?: string;
nickname?: string;
createTime?: string;
[key: string]: any;
}
// 组件属性接口
export interface GroupSelectionProps {
selectedOptions: GroupSelectionItem[];
onSelect: (groups: GroupSelectionItem[]) => void;
onSelectDetail?: (groups: PoolItem[]) => void;
placeholder?: string;
className?: string;
visible?: boolean;
onVisibleChange?: (visible: boolean) => void;
selectedListMaxHeight?: number;
showInput?: boolean;
showSelectedList?: boolean;
readonly?: boolean;
onConfirm?: (
selectedIds: string[],
selectedItems: GroupSelectionItem[],
) => void;
}

View File

@@ -0,0 +1,206 @@
.inputWrapper {
position: relative;
}
.inputIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #bdbdbd;
font-size: 20px;
}
.input {
padding-left: 38px !important;
height: 48px;
border-radius: 16px !important;
border: 1px solid #e5e6eb !important;
font-size: 16px;
background: #f8f9fa;
}
.selectedListRow {
padding: 8px;
border-bottom: 1px solid #f0f0f0;
font-size: 14px;
}
.selectedListRowContent {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.selectedListRowContentText {
flex: 1;
}
.popupContainer {
display: flex;
flex-direction: column;
height: 100vh;
background: #fff;
}
.popupHeader {
padding: 24px;
}
.popupTitle {
text-align: center;
font-size: 20px;
font-weight: 600;
margin-bottom: 24px;
}
.searchWrapper {
position: relative;
margin-bottom: 16px;
}
.searchInput {
padding-left: 40px !important;
padding-top: 8px !important;
padding-bottom: 8px !important;
border-radius: 24px !important;
border: 1px solid #e5e6eb !important;
font-size: 15px;
background: #f8f9fa;
}
.searchIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #bdbdbd;
font-size: 16px;
}
.clearBtn {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
height: 24px;
width: 24px;
border-radius: 50%;
min-width: 24px;
}
.groupList {
flex: 1;
overflow-y: auto;
}
.groupListInner {
border-top: 1px solid #f0f0f0;
}
.groupItem {
display: flex;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid #f0f0f0;
transition: background 0.2s;
&:hover {
background: #f5f6fa;
}
}
.groupInfo {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.groupAvatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 14px;
font-weight: 500;
overflow: hidden;
}
.avatarImg {
width: 100%;
height: 100%;
object-fit: cover;
}
.groupDetail {
flex: 1;
}
.groupName {
font-weight: 500;
font-size: 16px;
color: #222;
margin-bottom: 2px;
}
.groupId {
font-size: 13px;
color: #888;
margin-bottom: 2px;
}
.groupOwner {
font-size: 13px;
color: #bdbdbd;
}
.loadingBox {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.loadingText {
color: #888;
font-size: 15px;
}
.emptyBox {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.emptyText {
color: #888;
font-size: 15px;
}
.paginationRow {
border-top: 1px solid #f0f0f0;
padding: 16px;
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
}
.totalCount {
font-size: 14px;
color: #888;
}
.paginationControls {
display: flex;
align-items: center;
gap: 8px;
}
.pageBtn {
padding: 0 8px;
height: 32px;
min-width: 32px;
}
.pageInfo {
font-size: 14px;
color: #222;
}
.popupFooter {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-top: 1px solid #f0f0f0;
background: #fff;
}
.selectedCount {
font-size: 14px;
color: #888;
}
.footerBtnGroup {
display: flex;
gap: 12px;
}

View File

@@ -0,0 +1,126 @@
import React, { useState } from "react";
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
import { Button, Input } from "antd";
import { Avatar } from "antd-mobile";
import style from "./index.module.scss";
import SelectionPopup from "./selectionPopup";
import { GroupSelectionProps } from "./data";
export default function PoolSelection({
selectedOptions,
onSelect,
onSelectDetail,
placeholder = "选择流量池",
className = "",
visible,
onVisibleChange,
selectedListMaxHeight = 300,
showInput = true,
showSelectedList = true,
readonly = false,
onConfirm,
}: GroupSelectionProps) {
const [popupVisible, setPopupVisible] = useState(false);
// 删除已选流量池项
const handleRemoveItem = (id: string) => {
if (readonly) return;
onSelect(selectedOptions.filter(item => item.id !== id));
};
// 受控弹窗逻辑
const realVisible = visible !== undefined ? visible : popupVisible;
const setRealVisible = (v: boolean) => {
if (onVisibleChange) onVisibleChange(v);
if (visible === undefined) setPopupVisible(v);
};
// 打开弹窗
const openPopup = () => {
if (readonly) return;
setRealVisible(true);
};
// 获取显示文本
const getDisplayText = () => {
if (selectedOptions.length === 0) return "";
return `已选择 ${selectedOptions.length} 个流量池项`;
};
return (
<>
{/* 输入框 */}
{showInput && (
<div className={`${style.inputWrapper} ${className}`}>
<Input
placeholder={placeholder}
value={getDisplayText()}
onClick={openPopup}
prefix={<SearchOutlined />}
allowClear={!readonly}
size="large"
readOnly={readonly}
disabled={readonly}
style={
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
}
/>
</div>
)}
{/* 已选流量池列表窗口 */}
{showSelectedList && selectedOptions.length > 0 && (
<div
className={style.selectedListWindow}
style={{
maxHeight: selectedListMaxHeight,
overflowY: "auto",
marginTop: 8,
border: "1px solid #e5e6eb",
borderRadius: 8,
background: "#fff",
}}
>
{selectedOptions.map(item => (
<div key={item.id} className={style.selectedListRow}>
<div className={style.selectedListRowContent}>
<Avatar src={item.avatar} />
<div className={style.selectedListRowContentText}>
<div>{item.nickname || item.name}</div>
<div>{item.wechatId || item.mobile}</div>
</div>
{!readonly && (
<Button
type="text"
icon={<DeleteOutlined />}
size="small"
style={{
marginLeft: 4,
color: "#ff4d4f",
border: "none",
background: "none",
minWidth: 24,
height: 24,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={() => handleRemoveItem(item.id)}
/>
)}
</div>
</div>
))}
</div>
)}
{/* 弹窗 */}
<SelectionPopup
visible={realVisible}
onVisibleChange={setRealVisible}
selectedOptions={selectedOptions}
onSelect={onSelect}
onSelectDetail={onSelectDetail}
readonly={readonly}
onConfirm={onConfirm}
/>
</>
);
}

View File

@@ -0,0 +1,227 @@
import React, { useState, useEffect } from "react";
import { Popup, Checkbox } from "antd-mobile";
import { getPoolList } from "./api";
import style from "./index.module.scss";
import Layout from "@/components/Layout/Layout";
import PopupHeader from "@/components/PopuLayout/header";
import PopupFooter from "@/components/PopuLayout/footer";
import { GroupSelectionItem, PoolItem } from "./data";
// 弹窗属性接口
interface SelectionPopupProps {
visible: boolean;
onVisibleChange: (visible: boolean) => void;
selectedOptions: GroupSelectionItem[];
onSelect: (items: GroupSelectionItem[]) => void;
onSelectDetail?: (items: PoolItem[]) => void;
readonly?: boolean;
onConfirm?: (
selectedIds: string[],
selectedItems: GroupSelectionItem[],
) => void;
}
export default function SelectionPopup({
visible,
onVisibleChange,
selectedOptions,
onSelect,
onSelectDetail,
readonly = false,
onConfirm,
}: SelectionPopupProps) {
const [poolItems, setPoolItems] = useState<PoolItem[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const [loading, setLoading] = useState(false);
// 获取流量池列表API
const fetchPoolItems = async (page: number, keyword: string = "") => {
setLoading(true);
try {
const params: any = {
page: String(page),
pageSize: "20",
};
if (keyword.trim()) {
params.keyword = keyword.trim();
}
const response = await getPoolList(params);
if (response && response.list) {
setPoolItems(response.list);
setTotalItems(response.total || 0);
setTotalPages(Math.ceil((response.total || 0) / 20));
}
} catch (error) {
console.error("获取流量池列表失败:", error);
} finally {
setLoading(false);
}
};
// 处理流量池项选择
const handleItemToggle = (item: PoolItem) => {
if (readonly) return;
// 将PoolItem转换为GroupSelectionItem格式
const selectionItem: GroupSelectionItem = {
id: String(item.id),
name: item.nickname || item.wechatId,
avatar: item.avatar,
wechatId: item.wechatId,
mobile: item.mobile,
nickname: item.nickname,
createTime: item.createTime,
// 保留原始数据
originalData: item,
};
const newSelectedItems = selectedOptions.some(g => g.id === String(item.id))
? selectedOptions.filter(g => g.id !== String(item.id))
: selectedOptions.concat(selectionItem);
onSelect(newSelectedItems);
// 如果有 onSelectDetail 回调,传递完整的流量池对象
if (onSelectDetail) {
const selectedItemObjs = poolItems.filter(poolItem =>
newSelectedItems.some(g => g.id === String(poolItem.id)),
);
onSelectDetail(selectedItemObjs);
}
};
// 确认选择
const handleConfirm = () => {
if (onConfirm) {
onConfirm(
selectedOptions.map(item => item.id),
selectedOptions,
);
}
onVisibleChange(false);
};
// 弹窗打开时初始化数据(只执行一次)
useEffect(() => {
if (visible) {
setCurrentPage(1);
setSearchQuery("");
fetchPoolItems(1, "");
}
}, [visible]);
// 搜索防抖(只在弹窗打开且搜索词变化时执行)
useEffect(() => {
if (!visible || searchQuery === "") return;
const timer = setTimeout(() => {
setCurrentPage(1);
fetchPoolItems(1, searchQuery);
}, 500);
return () => clearTimeout(timer);
}, [searchQuery, visible]);
// 页码变化时请求数据只在弹窗打开且页码不是1时执行
useEffect(() => {
if (!visible || currentPage === 1) return;
fetchPoolItems(currentPage, searchQuery);
}, [currentPage, visible, searchQuery]);
return (
<Popup
visible={visible}
onMaskClick={() => onVisibleChange(false)}
position="bottom"
bodyStyle={{ height: "100vh" }}
>
<Layout
header={
<PopupHeader
title="选择流量池"
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
searchPlaceholder="搜索流量池"
loading={loading}
onRefresh={() => fetchPoolItems(currentPage, searchQuery)}
/>
}
footer={
<PopupFooter
total={totalItems}
currentPage={currentPage}
totalPages={totalPages}
loading={loading}
selectedCount={selectedOptions.length}
onPageChange={setCurrentPage}
onCancel={() => onVisibleChange(false)}
onConfirm={handleConfirm}
/>
}
>
<div className={style.groupList}>
{loading ? (
<div className={style.loadingBox}>
<div className={style.loadingText}>...</div>
</div>
) : poolItems.length > 0 ? (
<div className={style.groupListInner}>
{poolItems.map(item => (
<div key={item.id} className={style.groupItem}>
<Checkbox
checked={selectedOptions.some(
g => g.id === String(item.id),
)}
onChange={() => !readonly && handleItemToggle(item)}
disabled={readonly}
style={{ marginRight: 12 }}
/>
<div className={style.groupInfo}>
<div className={style.groupAvatar}>
{item.avatar ? (
<img
src={item.avatar}
alt={item.nickname || item.wechatId}
className={style.avatarImg}
/>
) : (
(item.nickname || item.wechatId || "").charAt(0)
)}
</div>
<div className={style.groupDetail}>
<div className={style.groupName}>
{item.nickname || item.wechatId}
</div>
<div className={style.groupId}>
ID: {item.wechatId}
</div>
{item.mobile && (
<div className={style.groupOwner}>
: {item.mobile}
</div>
)}
</div>
</div>
</div>
))}
</div>
) : (
<div className={style.emptyBox}>
<div className={style.emptyText}>
{searchQuery
? `没有找到包含"${searchQuery}"的流量池项`
: "没有找到流量池项"}
</div>
</div>
)}
</div>
</Layout>
</Popup>
);
}

View File

@@ -7,14 +7,16 @@ import FriendSelection from "@/components/FriendSelection";
import GroupSelection from "@/components/GroupSelection";
import ContentSelection from "@/components/ContentSelection";
import AccountSelection from "@/components/AccountSelection";
import PoolSelection from "@/components/PoolSelection";
import { isDevelopment } from "@/utils/env";
import { GroupSelectionItem } from "@/components/GroupSelection/data";
import { ContentItem } from "@/components/ContentSelection/data";
import { FriendSelectionItem } from "@/components/FriendSelection/data";
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
import { AccountItem } from "@/components/AccountSelection/data";
import { GroupSelectionItem as PoolSelectionItem } from "@/components/PoolSelection/data";
const ComponentTest: React.FC = () => {
const [activeTab, setActiveTab] = useState("devices");
const [activeTab, setActiveTab] = useState("pools");
// 设备选择状态
const [selectedDevices, setSelectedDevices] = useState<DeviceSelectionItem[]>(
@@ -34,6 +36,9 @@ const ComponentTest: React.FC = () => {
const [selectedFriendsOptions, setSelectedFriendsOptions] = useState<
FriendSelectionItem[]
>([]);
// 流量池选择状态
const [selectedPools, setSelectedPools] = useState<PoolSelectionItem[]>([]);
return (
<Layout header={<NavCommon title="组件调试" />}>
<div style={{ padding: 16 }}>
@@ -155,6 +160,32 @@ const ComponentTest: React.FC = () => {
</div>
</div>
</Tabs.Tab>
<Tabs.Tab title="流量池选择" key="pools">
<div style={{ padding: "16px 0" }}>
<h3 style={{ marginBottom: 16 }}>PoolSelection </h3>
<PoolSelection
selectedOptions={selectedPools}
onSelect={setSelectedPools}
placeholder="请选择流量池"
showSelectedList={true}
selectedListMaxHeight={300}
/>
<div
style={{
marginTop: 16,
padding: 12,
background: "#f5f5f5",
borderRadius: 8,
}}
>
<strong>:</strong> {selectedPools.length}
<br />
<strong>ID:</strong>{" "}
{selectedPools.map(p => p.id).join(", ") || "无"}
</div>
</div>
</Tabs.Tab>
</Tabs>
</div>
</Layout>