Merge branch 'yongpxu-dev' into yongpxu-dev2

This commit is contained in:
2025-07-22 16:55:00 +08:00
18 changed files with 951 additions and 310 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

View File

@@ -1,22 +1,27 @@
import axios, { AxiosInstance, AxiosRequestConfig, Method, AxiosResponse } from 'axios';
import { Toast } from 'antd-mobile';
import axios, {
AxiosInstance,
AxiosRequestConfig,
Method,
AxiosResponse,
} from "axios";
import { Toast } from "antd-mobile";
const DEFAULT_DEBOUNCE_GAP = 1000;
const debounceMap = new Map<string, number>();
const instance: AxiosInstance = axios.create({
baseURL: (import.meta as any).env?.VITE_API_BASE_URL || '/api',
baseURL: (import.meta as any).env?.VITE_API_BASE_URL || "/api",
timeout: 10000,
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
});
instance.interceptors.request.use(config => {
const token = localStorage.getItem('token');
instance.interceptors.request.use((config) => {
const token = localStorage.getItem("token");
if (token) {
config.headers = config.headers || {};
config.headers['Authorization'] = `Bearer ${token}`;
config.headers["Authorization"] = `Bearer ${token}`;
}
return config;
});
@@ -27,20 +32,20 @@ instance.interceptors.response.use(
if (code === 200 || success) {
return res.data.data ?? res.data;
}
Toast.show({ content: msg || '接口错误', position: 'top' });
Toast.show({ content: msg || "接口错误", position: "top" });
if (code === 401) {
localStorage.removeItem('token');
localStorage.removeItem("token");
const currentPath = window.location.pathname + window.location.search;
if (currentPath === '/login') {
window.location.href = '/login';
if (currentPath === "/login") {
window.location.href = "/login";
} else {
window.location.href = `/login?redirect=${encodeURIComponent(currentPath)}`;
}
}
return Promise.reject(msg || '接口错误');
return Promise.reject(msg || "接口错误");
},
err => {
Toast.show({ content: err.message || '网络异常', position: 'top' });
(err) => {
Toast.show({ content: err.message || "网络异常", position: "top" });
return Promise.reject(err);
}
);
@@ -48,17 +53,18 @@ instance.interceptors.response.use(
export function request(
url: string,
data?: any,
method: Method = 'GET',
method: Method = "GET",
config?: AxiosRequestConfig,
debounceGap?: number
): Promise<any> {
const gap = typeof debounceGap === 'number' ? debounceGap : DEFAULT_DEBOUNCE_GAP;
const gap =
typeof debounceGap === "number" ? debounceGap : DEFAULT_DEBOUNCE_GAP;
const key = `${method}_${url}_${JSON.stringify(data)}`;
const now = Date.now();
const last = debounceMap.get(key) || 0;
if (gap > 0 && now - last < gap) {
Toast.show({ content: '请求过于频繁,请稍后再试', position: 'top' });
return Promise.reject('请求过于频繁,请稍后再试');
// Toast.show({ content: '请求过于频繁,请稍后再试', position: 'top' });
return Promise.reject("请求过于频繁,请稍后再试");
}
debounceMap.set(key, now);
@@ -67,7 +73,7 @@ export function request(
method,
...config,
};
if (method.toUpperCase() === 'GET') {
if (method.toUpperCase() === "GET") {
axiosConfig.params = data;
} else {
axiosConfig.data = data;

View File

@@ -0,0 +1,10 @@
import request from "@/api/request";
// 获取设备列表
export function getDeviceList(params: {
page: number;
limit: number;
keyword?: string;
}) {
return request("/v1/devices", params, "GET");
}

View File

@@ -22,7 +22,7 @@
.popupContainer {
display: flex;
flex-direction: column;
height: 100%;
height: 100vh;
background: #fff;
}
.popupHeader {
@@ -154,3 +154,35 @@
display: flex;
gap: 12px;
}
.refreshBtn {
width: 36px;
height: 36px;
}
.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;
border-radius: 16px;
}
.pageInfo {
font-size: 14px;
color: #222;
margin: 0 8px;
}

View File

@@ -1,208 +1,380 @@
import React, { useState, useEffect } from "react";
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
import { Input, Button, Checkbox, Popup, Toast } from "antd-mobile";
import request from "@/api/request";
import style from "./module.scss";
// 设备选择项接口
interface DeviceSelectionItem {
id: string;
name: string;
imei: string;
wechatId: string;
status: "online" | "offline";
}
// 组件属性接口
interface DeviceSelectionProps {
selectedDevices: string[];
onSelect: (devices: string[]) => void;
placeholder?: string;
className?: string;
}
export default function DeviceSelection({
selectedDevices,
onSelect,
placeholder = "选择设备",
className = "",
}: DeviceSelectionProps) {
const [popupVisible, setPopupVisible] = useState(false);
const [devices, setDevices] = useState<DeviceSelectionItem[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [loading, setLoading] = useState(false);
// 获取设备列表支持keyword
const fetchDevices = async (keyword: string = "") => {
setLoading(true);
try {
const res = await request(
"/v1/device/list",
{
page: 1,
limit: 100,
keyword: keyword.trim() || undefined,
},
"GET"
);
if (res && Array.isArray(res.list)) {
setDevices(
res.list.map((d: any) => ({
id: d.id?.toString() || "",
name: d.memo || d.imei || "",
imei: d.imei || "",
wechatId: d.wechatId || "",
status: d.alive === 1 ? "online" : "offline",
}))
);
}
} catch (error) {
console.error("获取设备列表失败:", error);
Toast.show({ content: "获取设备列表失败", position: "top" });
} finally {
setLoading(false);
}
};
// 打开弹窗时获取设备列表
const openPopup = () => {
setSearchQuery("");
setPopupVisible(true);
fetchDevices("");
};
// 搜索防抖
useEffect(() => {
if (!popupVisible) return;
const timer = setTimeout(() => {
fetchDevices(searchQuery);
}, 500);
return () => clearTimeout(timer);
}, [searchQuery, popupVisible]);
// 过滤设备(只保留状态过滤)
const filteredDevices = devices.filter((device) => {
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "online" && device.status === "online") ||
(statusFilter === "offline" && device.status === "offline");
return matchesStatus;
});
// 处理设备选择
const handleDeviceToggle = (deviceId: string) => {
if (selectedDevices.includes(deviceId)) {
onSelect(selectedDevices.filter((id) => id !== deviceId));
} else {
onSelect([...selectedDevices, deviceId]);
}
};
// 获取显示文本
const getDisplayText = () => {
if (selectedDevices.length === 0) return "";
return `已选择 ${selectedDevices.length} 个设备`;
};
return (
<>
{/* 输入框 */}
<div className={`${style.inputWrapper} ${className}`}>
<SearchOutlined className={style.inputIcon} />
<Input
placeholder={placeholder}
className={style.input}
readOnly
onClick={openPopup}
value={getDisplayText()}
/>
</div>
{/* 设备选择弹窗 */}
<Popup
visible={popupVisible}
onMaskClick={() => setPopupVisible(false)}
position="bottom"
bodyStyle={{ height: "80vh" }}
>
<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={(val) => setSearchQuery(val)}
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>
</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.popupFooter}>
<div className={style.selectedCount}>
{selectedDevices.length}
</div>
<div className={style.footerBtnGroup}>
<Button fill="outline" onClick={() => setPopupVisible(false)}>
</Button>
<Button color="primary" onClick={() => setPopupVisible(false)}>
</Button>
</div>
</div>
</div>
</Popup>
</>
);
}
import React, { useState, useEffect, useCallback } from "react";
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
import { Checkbox, Popup, Toast } from "antd-mobile";
import { Input, Button } from "antd";
import { getDeviceList } from "./api";
import style from "./index.module.scss";
import { DeleteOutlined } from "@ant-design/icons";
// 设备选择项接口
interface DeviceSelectionItem {
id: string;
name: string;
imei: string;
wechatId: string;
status: "online" | "offline";
wxid?: string;
nickname?: string;
usedInPlans?: number;
}
// 组件属性接口
interface DeviceSelectionProps {
selectedDevices: string[];
onSelect: (devices: string[]) => void;
placeholder?: string;
className?: string;
mode?: "input" | "dialog"; // 新增默认input
open?: boolean; // 仅mode=dialog时生效
onOpenChange?: (open: boolean) => void; // 仅mode=dialog时生效
selectedListMaxHeight?: number; // 新增已选列表最大高度默认500
showInput?: boolean; // 新增
showSelectedList?: boolean; // 新增
readonly?: boolean; // 新增
}
const PAGE_SIZE = 20;
const DeviceSelection: React.FC<DeviceSelectionProps> = ({
selectedDevices,
onSelect,
placeholder = "选择设备",
className = "",
mode = "input",
open,
onOpenChange,
selectedListMaxHeight = 300, // 默认300
showInput = true,
showSelectedList = true,
readonly = false,
}) => {
// 弹窗控制
const [popupVisible, setPopupVisible] = useState(false);
const isDialog = mode === "dialog";
const realVisible = isDialog ? !!open : popupVisible;
const setRealVisible = (v: boolean) => {
if (isDialog && onOpenChange) onOpenChange(v);
if (!isDialog) setPopupVisible(v);
};
// 设备数据
const [devices, setDevices] = useState<DeviceSelectionItem[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [total, setTotal] = useState(0);
// 获取设备列表支持keyword和分页
const fetchDevices = useCallback(
async (keyword: string = "", page: number = 1) => {
setLoading(true);
try {
const res = await getDeviceList({
page,
limit: PAGE_SIZE,
keyword: keyword.trim() || undefined,
});
if (res && Array.isArray(res.list)) {
setDevices(
res.list.map((d: any) => ({
id: d.id?.toString() || "",
name: d.memo || d.imei || "",
imei: d.imei || "",
wechatId: d.wechatId || "",
status: d.alive === 1 ? "online" : "offline",
wxid: d.wechatId || "",
nickname: d.nickname || "",
usedInPlans: d.usedInPlans || 0,
}))
);
setTotal(res.total || 0);
}
} catch (error) {
console.error("获取设备列表失败:", error);
} finally {
setLoading(false);
}
},
[]
);
// 打开弹窗时获取第一页
const openPopup = () => {
if (readonly) return;
setSearchQuery("");
setCurrentPage(1);
setRealVisible(true);
fetchDevices("", 1);
};
// 搜索防抖
useEffect(() => {
if (!realVisible) return;
const timer = setTimeout(() => {
setCurrentPage(1);
fetchDevices(searchQuery, 1);
}, 500);
return () => clearTimeout(timer);
}, [searchQuery, realVisible, fetchDevices]);
// 翻页时重新请求
useEffect(() => {
if (!realVisible) return;
fetchDevices(searchQuery, currentPage);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPage]);
// 过滤设备(只保留状态过滤)
const filteredDevices = devices.filter((device) => {
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "online" && device.status === "online") ||
(statusFilter === "offline" && device.status === "offline");
return matchesStatus;
});
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
// 处理设备选择
const handleDeviceToggle = (deviceId: string) => {
if (selectedDevices.includes(deviceId)) {
onSelect(selectedDevices.filter((id) => id !== deviceId));
} else {
onSelect([...selectedDevices, deviceId]);
}
};
// 获取显示文本
const getDisplayText = () => {
if (selectedDevices.length === 0) return "";
return `已选择 ${selectedDevices.length} 个设备`;
};
// 获取已选设备详细信息
const selectedDeviceObjs = selectedDevices
.map((id) => devices.find((d) => d.id === id))
.filter(Boolean) as DeviceSelectionItem[];
// 删除已选设备
const handleRemoveDevice = (id: string) => {
if (readonly) return;
onSelect(selectedDevices.filter((d) => d !== id));
};
// 弹窗内容
const popupContent = (
<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={(val) => setSearchQuery(val)}
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={() => fetchDevices(searchQuery, currentPage)}
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={() => 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={!readonly}
size="large"
readOnly={readonly}
disabled={readonly}
style={
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
}
/>
</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>
{!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={() => handleRemoveDevice(device.id)}
/>
)}
</div>
))}
</div>
)}
{/* 弹窗 */}
<Popup
visible={realVisible && !readonly}
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;

View File

@@ -0,0 +1,10 @@
import request from "@/api/request";
// 获取设备列表
export function getDeviceList(params: {
page: number;
limit: number;
keyword?: string;
}) {
return request("/v1/devices", params, "GET");
}

View File

@@ -1,7 +1,7 @@
.popupContainer {
display: flex;
flex-direction: column;
height: 100%;
height: 100vh;
background: #fff;
}
.popupHeader {
@@ -49,11 +49,6 @@
padding: 0 12px;
background: #fff;
}
.refreshBtn {
min-width: 40px;
height: 40px;
border-radius: 8px;
}
.loadingIcon {
animation: spin 1s linear infinite;
font-size: 16px;
@@ -168,3 +163,35 @@
display: flex;
gap: 12px;
}
.refreshBtn {
width: 36px;
height: 36px;
}
.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;
border-radius: 16px;
}
.pageInfo {
font-size: 14px;
color: #222;
margin: 0 8px;
}

View File

@@ -1,8 +1,9 @@
import React, { useState, useEffect, useCallback } from "react";
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
import { Input, Button, Checkbox, Popup, Toast } from "antd-mobile";
import request from "@/api/request";
import style from "./module.scss";
import { Checkbox, Popup, Toast } from "antd-mobile";
import { Input, Button } from "antd";
import { getDeviceList } from "./api";
import style from "./index.module.scss";
interface Device {
id: string;
@@ -31,51 +32,53 @@ export function DeviceSelectionDialog({
const [statusFilter, setStatusFilter] = useState("all");
const [loading, setLoading] = useState(false);
const [devices, setDevices] = useState<Device[]>([]);
const [currentPage, setCurrentPage] = useState(1); // 新增
const [total, setTotal] = useState(0); // 新增
const pageSize = 20; // 每页条数
// 获取设备列表支持keyword
const fetchDevices = useCallback(async (keyword: string = "") => {
setLoading(true);
try {
const response = await request(
"/v1/device/list",
{
page: 1,
limit: 100,
// 获取设备列表支持keyword和分页
const fetchDevices = useCallback(
async (keyword: string = "", page: number = 1) => {
setLoading(true);
try {
const response = await getDeviceList({
page,
limit: pageSize,
keyword: keyword.trim() || undefined,
},
"GET"
);
if (response && Array.isArray(response.list)) {
// 转换服务端数据格式为组件需要的格式
const convertedDevices: Device[] = response.list.map(
(serverDevice: any) => ({
id: serverDevice.id.toString(),
name: serverDevice.memo || `设备 ${serverDevice.id}`,
imei: serverDevice.imei,
wxid: serverDevice.wechatId || "",
status: serverDevice.alive === 1 ? "online" : "offline",
usedInPlans: 0, // 这个字段需要从其他API获取
nickname: serverDevice.nickname || "",
})
);
setDevices(convertedDevices);
});
if (response && Array.isArray(response.list)) {
const convertedDevices: Device[] = response.list.map(
(serverDevice: any) => ({
id: serverDevice.id.toString(),
name: serverDevice.memo || `设备 ${serverDevice.id}`,
imei: serverDevice.imei,
wxid: serverDevice.wechatId || "",
status: serverDevice.alive === 1 ? "online" : "offline",
usedInPlans: 0,
nickname: serverDevice.nickname || "",
})
);
setDevices(convertedDevices);
setTotal(response.total || 0);
}
} catch (error) {
console.error("获取设备列表失败:", error);
Toast.show({
content: "获取设备列表失败,请检查网络连接",
position: "top",
});
} finally {
setLoading(false);
}
} catch (error) {
console.error("获取设备列表失败:", error);
Toast.show({
content: "获取设备列表失败,请检查网络连接",
position: "top",
});
} finally {
setLoading(false);
}
}, []);
},
[]
);
// 打开弹窗时获取设备列表
// 打开弹窗时获取第一页
useEffect(() => {
if (open) {
fetchDevices("");
setCurrentPage(1);
fetchDevices("", 1);
}
}, [open, fetchDevices]);
@@ -83,11 +86,19 @@ export function DeviceSelectionDialog({
useEffect(() => {
if (!open) return;
const timer = setTimeout(() => {
fetchDevices(searchQuery);
setCurrentPage(1);
fetchDevices(searchQuery, 1);
}, 500);
return () => clearTimeout(timer);
}, [searchQuery, open, fetchDevices]);
// 翻页时重新请求
useEffect(() => {
if (!open) return;
fetchDevices(searchQuery, currentPage);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPage]);
// 过滤设备(只保留状态过滤)
const filteredDevices = devices.filter((device) => {
const matchesStatus =
@@ -105,12 +116,14 @@ export function DeviceSelectionDialog({
}
};
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return (
<Popup
visible={open}
onMaskClick={() => onOpenChange(false)}
position="bottom"
bodyStyle={{ height: "80vh" }}
bodyStyle={{ height: "100vh" }}
>
<div className={style.popupContainer}>
<div className={style.popupHeader}>
@@ -138,7 +151,7 @@ export function DeviceSelectionDialog({
<Button
fill="outline"
size="mini"
onClick={() => fetchDevices(searchQuery)}
onClick={() => fetchDevices(searchQuery, currentPage)}
disabled={loading}
className={style.refreshBtn}
>
@@ -182,7 +195,7 @@ export function DeviceSelectionDialog({
</div>
<div className={style.deviceInfoDetail}>
<div>IMEI: {device.imei}</div>
<div>: {device.wxid || "-"}</div>
<div>: {device.wxid || "-"} </div>
<div>: {device.nickname || "-"}</div>
</div>
{device.usedInPlans > 0 && (
@@ -196,6 +209,35 @@ export function DeviceSelectionDialog({
</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}

View File

@@ -0,0 +1,11 @@
import request from "@/api/request";
// 获取好友列表
export function getFriendList(params: {
page: number;
limit: number;
deviceIds?: string; // 逗号分隔
keyword?: string;
}) {
return request("/v1/friend", params, "GET");
}

View File

@@ -21,7 +21,7 @@
.popupContainer {
display: flex;
flex-direction: column;
height: 100%;
height: 100vh;
background: #fff;
}
.popupHeader {

View File

@@ -6,8 +6,8 @@ import {
RightOutlined,
} from "@ant-design/icons";
import { Input, Button, Popup, Toast } from "antd-mobile";
import request from "@/api/request";
import style from "./module.scss";
import { getFriendList } from "./api";
import style from "./index.module.scss";
// 微信好友接口类型
interface WechatFriend {
@@ -27,6 +27,8 @@ interface FriendSelectionProps {
enableDeviceFilter?: boolean;
placeholder?: string;
className?: string;
visible?: boolean; // 新增
onVisibleChange?: (visible: boolean) => void; // 新增
}
export default function FriendSelection({
@@ -37,6 +39,8 @@ export default function FriendSelection({
enableDeviceFilter = true,
placeholder = "选择微信好友",
className = "",
visible,
onVisibleChange,
}: FriendSelectionProps) {
const [popupVisible, setPopupVisible] = useState(false);
const [friends, setFriends] = useState<WechatFriend[]>([]);
@@ -46,24 +50,31 @@ export default function FriendSelection({
const [totalFriends, setTotalFriends] = useState(0);
const [loading, setLoading] = useState(false);
// 受控弹窗逻辑
const realVisible = visible !== undefined ? visible : popupVisible;
const setRealVisible = (v: boolean) => {
if (onVisibleChange) onVisibleChange(v);
if (visible === undefined) setPopupVisible(v);
};
// 打开弹窗并请求第一页好友
const openPopup = () => {
setCurrentPage(1);
setSearchQuery("");
setPopupVisible(true);
setRealVisible(true);
fetchFriends(1, "");
};
// 当页码变化时,拉取对应页数据(弹窗已打开时)
useEffect(() => {
if (popupVisible && currentPage !== 1) {
if (realVisible && currentPage !== 1) {
fetchFriends(currentPage, searchQuery);
}
}, [currentPage, popupVisible, searchQuery]);
}, [currentPage, realVisible, searchQuery]);
// 搜索防抖
useEffect(() => {
if (!popupVisible) return;
if (!realVisible) return;
const timer = setTimeout(() => {
setCurrentPage(1);
@@ -71,7 +82,7 @@ export default function FriendSelection({
}, 500);
return () => clearTimeout(timer);
}, [searchQuery, popupVisible]);
}, [searchQuery, realVisible]);
// 获取好友列表API - 添加 keyword 参数
const fetchFriends = async (page: number, keyword: string = "") => {
@@ -97,7 +108,7 @@ export default function FriendSelection({
params.deviceIds = deviceIds.join(",");
}
const res = await request("/v1/friend", params, "GET");
const res = await getFriendList(params);
if (res && Array.isArray(res.list)) {
setFriends(
@@ -183,10 +194,10 @@ export default function FriendSelection({
{/* 微信好友选择弹窗 */}
<Popup
visible={popupVisible}
onMaskClick={() => setPopupVisible(false)}
visible={realVisible}
onMaskClick={() => setRealVisible(false)}
position="bottom"
bodyStyle={{ height: "80vh" }}
bodyStyle={{ height: "100vh" }}
>
<div className={style.popupContainer}>
<div className={style.popupHeader}>
@@ -312,14 +323,17 @@ export default function FriendSelection({
<div className={style.popupFooter}>
<Button
fill="outline"
onClick={() => setPopupVisible(false)}
onClick={() => setRealVisible(false)}
className={style.cancelBtn}
>
</Button>
<Button
color="primary"
onClick={handleConfirm}
onClick={() => {
setRealVisible(false);
handleConfirm();
}}
className={style.confirmBtn}
>
({selectedFriends.length})

View File

@@ -0,0 +1,10 @@
import request from "@/api/request";
// 获取群组列表
export function getGroupList(params: {
page: number;
limit: number;
keyword?: string;
}) {
return request("/v1/chatroom", params, "GET");
}

View File

@@ -21,7 +21,7 @@
.popupContainer {
display: flex;
flex-direction: column;
height: 100%;
height: 100vh;
background: #fff;
}
.popupHeader {

View File

@@ -6,8 +6,8 @@ import {
RightOutlined,
} from "@ant-design/icons";
import { Input, Button, Popup, Toast } from "antd-mobile";
import request from "@/api/request";
import style from "./module.scss";
import { getGroupList } from "./api";
import style from "./index.module.scss";
// 群组接口类型
interface WechatGroup {
@@ -27,6 +27,8 @@ interface GroupSelectionProps {
onSelectDetail?: (groups: WechatGroup[]) => void;
placeholder?: string;
className?: string;
visible?: boolean; // 新增
onVisibleChange?: (visible: boolean) => void; // 新增
}
export default function GroupSelection({
@@ -35,6 +37,8 @@ export default function GroupSelection({
onSelectDetail,
placeholder = "选择群聊",
className = "",
visible,
onVisibleChange,
}: GroupSelectionProps) {
const [popupVisible, setPopupVisible] = useState(false);
const [groups, setGroups] = useState<WechatGroup[]>([]);
@@ -44,30 +48,37 @@ export default function GroupSelection({
const [totalGroups, setTotalGroups] = useState(0);
const [loading, setLoading] = useState(false);
// 受控弹窗逻辑
const realVisible = visible !== undefined ? visible : popupVisible;
const setRealVisible = (v: boolean) => {
if (onVisibleChange) onVisibleChange(v);
if (visible === undefined) setPopupVisible(v);
};
// 打开弹窗并请求第一页群组
const openPopup = () => {
setCurrentPage(1);
setSearchQuery("");
setPopupVisible(true);
setRealVisible(true);
fetchGroups(1, "");
};
// 当页码变化时,拉取对应页数据(弹窗已打开时)
useEffect(() => {
if (popupVisible && currentPage !== 1) {
if (realVisible && currentPage !== 1) {
fetchGroups(currentPage, searchQuery);
}
}, [currentPage, popupVisible, searchQuery]);
}, [currentPage, realVisible, searchQuery]);
// 搜索防抖
useEffect(() => {
if (!popupVisible) return;
if (!realVisible) return;
const timer = setTimeout(() => {
setCurrentPage(1);
fetchGroups(1, searchQuery);
}, 500);
return () => clearTimeout(timer);
}, [searchQuery, popupVisible]);
}, [searchQuery, realVisible]);
// 获取群组列表API - 支持keyword
const fetchGroups = async (page: number, keyword: string = "") => {
@@ -81,7 +92,7 @@ export default function GroupSelection({
params.keyword = keyword.trim();
}
const res = await request("/v1/chatroom", params, "GET");
const res = await getGroupList(params);
if (res && Array.isArray(res.list)) {
setGroups(
@@ -128,7 +139,7 @@ export default function GroupSelection({
};
const handleConfirm = () => {
setPopupVisible(false);
setRealVisible(false);
};
// 清空搜索
@@ -169,10 +180,10 @@ export default function GroupSelection({
{/* 群组选择弹窗 */}
<Popup
visible={popupVisible}
onMaskClick={() => setPopupVisible(false)}
visible={realVisible}
onMaskClick={() => setRealVisible(false)}
position="bottom"
bodyStyle={{ height: "80vh" }}
bodyStyle={{ height: "100vh" }}
>
<div className={style.popupContainer}>
<div className={style.popupHeader}>
@@ -297,14 +308,17 @@ export default function GroupSelection({
<div className={style.popupFooter}>
<Button
fill="outline"
onClick={() => setPopupVisible(false)}
onClick={() => setRealVisible(false)}
className={style.cancelBtn}
>
</Button>
<Button
color="primary"
onClick={handleConfirm}
onClick={() => {
setRealVisible(false);
handleConfirm();
}}
className={style.confirmBtn}
>
({selectedGroups.length})

View File

@@ -0,0 +1,80 @@
import React, { useState } from "react";
import DeviceSelection from "./DeviceSelection";
import { DeviceSelectionDialog } from "./DeviceSelectionDialog";
import FriendSelection from "./FriendSelection";
import GroupSelection from "./GroupSelection";
import { Button, Space } from "antd-mobile";
export default function SelectionTest() {
// 设备选择
const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
const [deviceDialogOpen, setDeviceDialogOpen] = useState(false);
// 好友选择
const [selectedFriends, setSelectedFriends] = useState<string[]>([]);
const [friendDialogOpen, setFriendDialogOpen] = useState(false);
// 群组选择
const [selectedGroups, setSelectedGroups] = useState<string[]>([]);
const [groupDialogOpen, setGroupDialogOpen] = useState(false);
return (
<div style={{ padding: 24 }}>
<h2></h2>
<Space direction="vertical" block>
<div>
<b>DeviceSelection+</b>
<DeviceSelection
selectedDevices={selectedDevices}
onSelect={setSelectedDevices}
/>
</div>
<div>
<b>DeviceSelectionDialog</b>
<Button color="primary" onClick={() => setDeviceDialogOpen(true)}>
</Button>
<DeviceSelectionDialog
open={deviceDialogOpen}
onOpenChange={setDeviceDialogOpen}
selectedDevices={selectedDevices}
onSelect={setSelectedDevices}
/>
</div>
<div>
<b>FriendSelection</b>
<Button color="primary" onClick={() => setFriendDialogOpen(true)}>
</Button>
<FriendSelection
selectedFriends={selectedFriends}
onSelect={setSelectedFriends}
placeholder="请选择微信好友"
className=""
visible={friendDialogOpen}
onVisibleChange={setFriendDialogOpen}
/>
</div>
<div>
<b>GroupSelection</b>
<Button color="primary" onClick={() => setGroupDialogOpen(true)}>
</Button>
<GroupSelection
selectedGroups={selectedGroups}
onSelect={setSelectedGroups}
placeholder="请选择群聊"
className=""
visible={groupDialogOpen}
onVisibleChange={setGroupDialogOpen}
/>
</div>
</Space>
<div style={{ marginTop: 32 }}>
<div>ID: {selectedDevices.join(", ")}</div>
<div>ID: {selectedFriends.join(", ")}</div>
<div>ID: {selectedGroups.join(", ")}</div>
</div>
</div>
);
}

View File

@@ -3,6 +3,7 @@ import Plans from "@/pages/plans/Plans";
import PlanDetail from "@/pages/plans/PlanDetail";
import Orders from "@/pages/orders/Orders";
import ContactImport from "@/pages/contact-import/ContactImport";
import SelectionTest from "@/components/SelectionTest";
const otherRoutes = [
{
@@ -35,6 +36,11 @@ const otherRoutes = [
element: <ContactImport />,
auth: true,
},
{
path: "/selection-test",
element: <SelectionTest />,
auth: false,
},
];
export default otherRoutes;

View File

@@ -1 +1,11 @@
/// <reference types="vite/client" />
declare module "*.scss" {
const content: { [className: string]: string };
export default content;
}
declare module "*.css" {
const content: { [className: string]: string };
export default content;
}