feat: 本次提交更新内容如下

弹窗暂时封装完成
This commit is contained in:
笔记本里的永平
2025-07-22 16:27:45 +08:00
parent 40c53c2cfe
commit 3b45abe69b
3 changed files with 567 additions and 249 deletions

View File

@@ -1,4 +1,4 @@
# 基础环境变量示例 # 基础环境变量示例
VITE_API_BASE_URL=https://ckbapi.quwanzhi.com VITE_API_BASE_URL=http://www.yishi.com
VITE_APP_TITLE=Nkebao Base VITE_APP_TITLE=Nkebao Base

View File

@@ -1,248 +1,369 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useCallback } from "react";
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons"; import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
import { Checkbox, Popup, Toast } from "antd-mobile"; import { Checkbox, Popup, Toast } from "antd-mobile";
import { Input, Button } from "antd"; import { Input, Button } from "antd";
import { getDeviceList } from "./api"; import { getDeviceList } from "./api";
import style from "./index.module.scss"; import style from "./index.module.scss";
import { DeleteOutlined } from "@ant-design/icons";
// 设备选择项接口
interface DeviceSelectionItem { // 设备选择项接口
id: string; interface DeviceSelectionItem {
name: string; id: string;
imei: string; name: string;
wechatId: string; imei: string;
status: "online" | "offline"; wechatId: string;
} status: "online" | "offline";
wxid?: string;
// 组件属性接口 nickname?: string;
interface DeviceSelectionProps { usedInPlans?: number;
selectedDevices: string[]; }
onSelect: (devices: string[]) => void;
placeholder?: string; // 组件属性接口
className?: string; interface DeviceSelectionProps {
} selectedDevices: string[];
onSelect: (devices: string[]) => void;
export default function DeviceSelection({ placeholder?: string;
selectedDevices, className?: string;
onSelect, mode?: "input" | "dialog"; // 新增默认input
placeholder = "选择设备", open?: boolean; // 仅mode=dialog时生效
className = "", onOpenChange?: (open: boolean) => void; // 仅mode=dialog时生效
}: DeviceSelectionProps) { selectedListMaxHeight?: number; // 新增已选列表最大高度默认500
const [popupVisible, setPopupVisible] = useState(false); showInput?: boolean; // 新增
const [devices, setDevices] = useState<DeviceSelectionItem[]>([]); showSelectedList?: boolean; // 新增
const [searchQuery, setSearchQuery] = useState(""); }
const [statusFilter, setStatusFilter] = useState("all");
const [loading, setLoading] = useState(false); const PAGE_SIZE = 20;
const [currentPage, setCurrentPage] = useState(1); // 新增
const [total, setTotal] = useState(0); // 新增 const DeviceSelection: React.FC<DeviceSelectionProps> = ({
const pageSize = 20; // 每页条数 selectedDevices,
onSelect,
// 获取设备列表支持keyword和分页 placeholder = "选择设备",
const fetchDevices = async (keyword: string = "", page: number = 1) => { className = "",
setLoading(true); mode = "input",
try { open,
const res = await getDeviceList({ onOpenChange,
page, selectedListMaxHeight = 300, // 默认300
limit: pageSize, showInput = true,
keyword: keyword.trim() || undefined, showSelectedList = true,
}); }) => {
if (res && Array.isArray(res.list)) { // 弹窗控制
setDevices( const [popupVisible, setPopupVisible] = useState(false);
res.list.map((d: any) => ({ const isDialog = mode === "dialog";
id: d.id?.toString() || "", const realVisible = isDialog ? !!open : popupVisible;
name: d.memo || d.imei || "", const setRealVisible = (v: boolean) => {
imei: d.imei || "", if (isDialog && onOpenChange) onOpenChange(v);
wechatId: d.wechatId || "", if (!isDialog) setPopupVisible(v);
status: d.alive === 1 ? "online" : "offline", };
}))
); // 设备数据
setTotal(res.total || 0); const [devices, setDevices] = useState<DeviceSelectionItem[]>([]);
} const [searchQuery, setSearchQuery] = useState("");
} catch (error) { const [statusFilter, setStatusFilter] = useState("all");
console.error("获取设备列表失败:", error); const [loading, setLoading] = useState(false);
Toast.show({ content: "获取设备列表失败", position: "top" }); const [currentPage, setCurrentPage] = useState(1);
} finally { const [total, setTotal] = useState(0);
setLoading(false);
} // 获取设备列表支持keyword和分页
}; const fetchDevices = useCallback(
async (keyword: string = "", page: number = 1) => {
// 打开弹窗时获取第一页 setLoading(true);
const openPopup = () => { try {
setSearchQuery(""); const res = await getDeviceList({
setCurrentPage(1); page,
setPopupVisible(true); limit: PAGE_SIZE,
fetchDevices("", 1); keyword: keyword.trim() || undefined,
}; });
if (res && Array.isArray(res.list)) {
// 搜索防抖 setDevices(
useEffect(() => { res.list.map((d: any) => ({
if (!popupVisible) return; id: d.id?.toString() || "",
const timer = setTimeout(() => { name: d.memo || d.imei || "",
setCurrentPage(1); imei: d.imei || "",
fetchDevices(searchQuery, 1); wechatId: d.wechatId || "",
}, 500); status: d.alive === 1 ? "online" : "offline",
return () => clearTimeout(timer); wxid: d.wechatId || "",
}, [searchQuery, popupVisible]); nickname: d.nickname || "",
usedInPlans: d.usedInPlans || 0,
// 翻页时重新请求 }))
useEffect(() => { );
if (!popupVisible) return; setTotal(res.total || 0);
fetchDevices(searchQuery, currentPage); }
// eslint-disable-next-line react-hooks/exhaustive-deps } catch (error) {
}, [currentPage]); console.error("获取设备列表失败:", error);
} finally {
// 过滤设备(只保留状态过滤) setLoading(false);
const filteredDevices = devices.filter((device) => { }
const matchesStatus = },
statusFilter === "all" || []
(statusFilter === "online" && device.status === "online") || );
(statusFilter === "offline" && device.status === "offline");
return matchesStatus; // 打开弹窗时获取第一页
}); const openPopup = () => {
setSearchQuery("");
const totalPages = Math.max(1, Math.ceil(total / pageSize)); setCurrentPage(1);
setRealVisible(true);
// 处理设备选择 fetchDevices("", 1);
const handleDeviceToggle = (deviceId: string) => { };
if (selectedDevices.includes(deviceId)) {
onSelect(selectedDevices.filter((id) => id !== deviceId)); // 搜索防抖
} else { useEffect(() => {
onSelect([...selectedDevices, deviceId]); if (!realVisible) return;
} const timer = setTimeout(() => {
}; setCurrentPage(1);
fetchDevices(searchQuery, 1);
// 获取显示文本 }, 500);
const getDisplayText = () => { return () => clearTimeout(timer);
if (selectedDevices.length === 0) return ""; }, [searchQuery, realVisible, fetchDevices]);
return `已选择 ${selectedDevices.length} 个设备`;
}; // 翻页时重新请求
useEffect(() => {
return ( if (!realVisible) return;
<> fetchDevices(searchQuery, currentPage);
{/* 输入框 */} // eslint-disable-next-line react-hooks/exhaustive-deps
<div className={`${style.inputWrapper} ${className}`}> }, [currentPage]);
<Input
placeholder={placeholder} // 过滤设备(只保留状态过滤)
value={getDisplayText()} const filteredDevices = devices.filter((device) => {
onClick={openPopup} const matchesStatus =
prefix={<SearchOutlined />} statusFilter === "all" ||
allowClear (statusFilter === "online" && device.status === "online") ||
size="large" (statusFilter === "offline" && device.status === "offline");
/> return matchesStatus;
</div> });
{/* 设备选择弹窗 */} const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
<Popup
visible={popupVisible} // 处理设备选择
onMaskClick={() => setPopupVisible(false)} const handleDeviceToggle = (deviceId: string) => {
position="bottom" if (selectedDevices.includes(deviceId)) {
bodyStyle={{ height: "100vh" }} onSelect(selectedDevices.filter((id) => id !== deviceId));
> } else {
<div className={style.popupContainer}> onSelect([...selectedDevices, deviceId]);
<div className={style.popupHeader}> }
<div className={style.popupTitle}></div> };
</div>
<div className={style.popupSearchRow}> // 获取显示文本
<div className={style.popupSearchInputWrap}> const getDisplayText = () => {
<SearchOutlined className={style.inputIcon} /> if (selectedDevices.length === 0) return "";
<Input return `已选择 ${selectedDevices.length} 个设备`;
placeholder="搜索设备IMEI/备注/微信号" };
value={searchQuery}
onChange={(val) => setSearchQuery(val)} // 获取已选设备详细信息
className={style.popupSearchInput} const selectedDeviceObjs = selectedDevices
/> .map((id) => devices.find((d) => d.id === id))
</div> .filter(Boolean) as DeviceSelectionItem[];
<select
value={statusFilter} // 删除已选设备
onChange={(e) => setStatusFilter(e.target.value)} const handleRemoveDevice = (id: string) => {
className={style.statusSelect} onSelect(selectedDevices.filter((d) => d !== id));
> };
<option value="all"></option>
<option value="online">线</option> // 弹窗内容
<option value="offline">线</option> const popupContent = (
</select> <div className={style.popupContainer}>
</div> <div className={style.popupHeader}>
<div className={style.deviceList}> <div className={style.popupTitle}></div>
{loading ? ( </div>
<div className={style.loadingBox}> <div className={style.popupSearchRow}>
<div className={style.loadingText}>...</div> <div className={style.popupSearchInputWrap}>
</div> <SearchOutlined className={style.inputIcon} />
) : ( <Input
<div className={style.deviceListInner}> placeholder="搜索设备IMEI/备注/微信号"
{filteredDevices.map((device) => ( value={searchQuery}
<label key={device.id} className={style.deviceItem}> onChange={(val) => setSearchQuery(val)}
<Checkbox className={style.popupSearchInput}
checked={selectedDevices.includes(device.id)} />
onChange={() => handleDeviceToggle(device.id)} </div>
className={style.deviceCheckbox} <select
/> value={statusFilter}
<div className={style.deviceInfo}> onChange={(e) => setStatusFilter(e.target.value)}
<div className={style.deviceInfoRow}> className={style.statusSelect}
<span className={style.deviceName}>{device.name}</span> >
<div <option value="all"></option>
className={ <option value="online">线</option>
device.status === "online" <option value="offline">线</option>
? style.statusOnline </select>
: style.statusOffline <Button
} fill="outline"
> size="mini"
{device.status === "online" ? "在线" : "离线"} onClick={() => fetchDevices(searchQuery, currentPage)}
</div> disabled={loading}
</div> className={style.refreshBtn}
<div className={style.deviceInfoDetail}> >
<div>IMEI: {device.imei}</div> {loading ? (
<div>: {device.wechatId}</div> <div className={style.loadingIcon}></div>
</div> ) : (
</div> <ReloadOutlined />
</label> )}
))} </Button>
</div> </div>
)} <div className={style.deviceList}>
</div> {loading ? (
{/* 分页栏 */} <div className={style.loadingBox}>
<div className={style.paginationRow}> <div className={style.loadingText}>...</div>
<div className={style.totalCount}> {total} </div> </div>
<div className={style.paginationControls}> ) : (
<Button <div className={style.deviceListInner}>
fill="none" {filteredDevices.map((device) => (
size="mini" <label key={device.id} className={style.deviceItem}>
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))} <Checkbox
disabled={currentPage === 1 || loading} checked={selectedDevices.includes(device.id)}
className={style.pageBtn} onChange={() => handleDeviceToggle(device.id)}
> className={style.deviceCheckbox}
&lt; />
</Button> <div className={style.deviceInfo}>
<span className={style.pageInfo}> <div className={style.deviceInfoRow}>
{currentPage} / {totalPages} <span className={style.deviceName}>{device.name}</span>
</span> <div
<Button className={
fill="none" device.status === "online"
size="mini" ? style.statusOnline
onClick={() => : style.statusOffline
setCurrentPage(Math.min(totalPages, currentPage + 1)) }
} >
disabled={currentPage === totalPages || loading} {device.status === "online" ? "在线" : "离线"}
className={style.pageBtn} </div>
> </div>
&gt; <div className={style.deviceInfoDetail}>
</Button> <div>IMEI: {device.imei}</div>
</div> <div>: {device.wechatId}</div>
</div> </div>
<div className={style.popupFooter}> </div>
<div className={style.selectedCount}> </label>
{selectedDevices.length} ))}
</div> </div>
<div className={style.footerBtnGroup}> )}
<Button fill="outline" onClick={() => setPopupVisible(false)}> </div>
{/* 分页栏 */}
</Button> <div className={style.paginationRow}>
<Button color="primary" onClick={() => setPopupVisible(false)}> <div className={style.totalCount}> {total} </div>
<div className={style.paginationControls}>
</Button> <Button
</div> fill="none"
</div> size="mini"
</div> onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
</Popup> disabled={currentPage === 1 || loading}
</> className={style.pageBtn}
); >
} &lt;
</Button>
<span className={style.pageInfo}>
{currentPage} / {totalPages}
</span>
<Button
fill="none"
size="mini"
onClick={() =>
setCurrentPage(Math.min(totalPages, currentPage + 1))
}
disabled={currentPage === totalPages || loading}
className={style.pageBtn}
>
&gt;
</Button>
</div>
</div>
<div className={style.popupFooter}>
<div className={style.selectedCount}>
{selectedDevices.length}
</div>
<div className={style.footerBtnGroup}>
<Button fill="outline" onClick={() => setRealVisible(false)}>
</Button>
<Button color="primary" onClick={() => setRealVisible(false)}>
</Button>
</div>
</div>
</div>
);
return (
<>
{/* mode=input 显示输入框mode=dialog不显示 */}
{mode === "input" && showInput && (
<div className={`${style.inputWrapper} ${className}`}>
<Input
placeholder={placeholder}
value={getDisplayText()}
onClick={openPopup}
prefix={<SearchOutlined />}
allowClear
size="large"
/>
</div>
)}
{/* 已选设备列表窗口 */}
{mode === "input" &&
showSelectedList &&
selectedDeviceObjs.length > 0 && (
<div
className={style.selectedListWindow}
style={{
maxHeight: selectedListMaxHeight,
overflowY: "auto",
marginTop: 8,
border: "1px solid #e5e6eb",
borderRadius: 8,
background: "#fff",
}}
>
{selectedDeviceObjs.map((device) => (
<div
key={device.id}
className={style.selectedListRow}
style={{
display: "flex",
alignItems: "center",
padding: "4px 8px",
borderBottom: "1px solid #f0f0f0",
fontSize: 14,
}}
>
<div
style={{
flex: 1,
minWidth: 0,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{device.name}
</div>
<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={() => handleRemoveDevice(device.id)}
/>
</div>
))}
</div>
)}
{/* 弹窗 */}
<Popup
visible={realVisible}
onMaskClick={() => setRealVisible(false)}
position="bottom"
bodyStyle={{ height: "100vh" }}
>
{popupContent}
</Popup>
</>
);
};
export default DeviceSelection;

View File

@@ -0,0 +1,197 @@
import React from "react";
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
import { Input, Button, Checkbox, Popup } from "antd-mobile";
import style from "./index.module.scss";
interface DeviceSelectionItem {
id: string;
name: string;
imei: string;
wechatId: string;
status: "online" | "offline";
wxid?: string;
nickname?: string;
usedInPlans?: number;
}
interface SelectionPopupProps {
visible: boolean;
onClose: () => void;
selectedDevices: string[];
onSelect: (devices: string[]) => void;
devices: DeviceSelectionItem[];
loading: boolean;
searchQuery: string;
setSearchQuery: (v: string) => void;
statusFilter: string;
setStatusFilter: (v: string) => void;
onRefresh: () => void;
filteredDevices: DeviceSelectionItem[];
total: number;
currentPage: number;
totalPages: number;
setCurrentPage: (v: number) => void;
onCancel: () => void;
onConfirm: () => void;
}
const SelectionPopup: React.FC<SelectionPopupProps> = ({
visible,
onClose,
selectedDevices,
onSelect,
devices,
loading,
searchQuery,
setSearchQuery,
statusFilter,
setStatusFilter,
onRefresh,
filteredDevices,
total,
currentPage,
totalPages,
setCurrentPage,
onCancel,
onConfirm,
}) => {
// 处理设备选择
const handleDeviceToggle = (deviceId: string) => {
if (selectedDevices.includes(deviceId)) {
onSelect(selectedDevices.filter((id) => id !== deviceId));
} else {
onSelect([...selectedDevices, deviceId]);
}
};
return (
<Popup
visible={visible}
// 禁止点击遮罩关闭
onMaskClick={() => {}}
position="bottom"
bodyStyle={{ height: "100vh" }}
closeOnMaskClick={false}
>
<div className={style.popupContainer}>
<div className={style.popupHeader}>
<div className={style.popupTitle}></div>
</div>
<div className={style.popupSearchRow}>
<div className={style.popupSearchInputWrap}>
<SearchOutlined className={style.inputIcon} />
<Input
placeholder="搜索设备IMEI/备注/微信号"
value={searchQuery}
onChange={setSearchQuery}
className={style.popupSearchInput}
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className={style.statusSelect}
>
<option value="all"></option>
<option value="online">线</option>
<option value="offline">线</option>
</select>
<Button
fill="outline"
size="mini"
onClick={onRefresh}
disabled={loading}
className={style.refreshBtn}
>
{loading ? (
<div className={style.loadingIcon}></div>
) : (
<ReloadOutlined />
)}
</Button>
</div>
<div className={style.deviceList}>
{loading ? (
<div className={style.loadingBox}>
<div className={style.loadingText}>...</div>
</div>
) : (
<div className={style.deviceListInner}>
{filteredDevices.map((device) => (
<label key={device.id} className={style.deviceItem}>
<Checkbox
checked={selectedDevices.includes(device.id)}
onChange={() => handleDeviceToggle(device.id)}
className={style.deviceCheckbox}
/>
<div className={style.deviceInfo}>
<div className={style.deviceInfoRow}>
<span className={style.deviceName}>{device.name}</span>
<div
className={
device.status === "online"
? style.statusOnline
: style.statusOffline
}
>
{device.status === "online" ? "在线" : "离线"}
</div>
</div>
<div className={style.deviceInfoDetail}>
<div>IMEI: {device.imei}</div>
<div>: {device.wechatId}</div>
</div>
</div>
</label>
))}
</div>
)}
</div>
{/* 分页栏 */}
<div className={style.paginationRow}>
<div className={style.totalCount}> {total} </div>
<div className={style.paginationControls}>
<Button
fill="none"
size="mini"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1 || loading}
className={style.pageBtn}
>
&lt;
</Button>
<span className={style.pageInfo}>
{currentPage} / {totalPages}
</span>
<Button
fill="none"
size="mini"
onClick={() =>
setCurrentPage(Math.min(totalPages, currentPage + 1))
}
disabled={currentPage === totalPages || loading}
className={style.pageBtn}
>
&gt;
</Button>
</div>
</div>
<div className={style.popupFooter}>
<div className={style.selectedCount}>
{selectedDevices.length}
</div>
<div className={style.footerBtnGroup}>
<Button fill="outline" onClick={onCancel}>
</Button>
<Button color="primary" onClick={onConfirm}>
</Button>
</div>
</div>
</div>
</Popup>
);
};
export default SelectionPopup;