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 VITE_APP_TITLE=Nkebao Base

View File

@@ -1,22 +1,27 @@
import axios, { AxiosInstance, AxiosRequestConfig, Method, AxiosResponse } from 'axios'; import axios, {
import { Toast } from 'antd-mobile'; AxiosInstance,
AxiosRequestConfig,
Method,
AxiosResponse,
} from "axios";
import { Toast } from "antd-mobile";
const DEFAULT_DEBOUNCE_GAP = 1000; const DEFAULT_DEBOUNCE_GAP = 1000;
const debounceMap = new Map<string, number>(); const debounceMap = new Map<string, number>();
const instance: AxiosInstance = axios.create({ 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, timeout: 10000,
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
}); });
instance.interceptors.request.use(config => { instance.interceptors.request.use((config) => {
const token = localStorage.getItem('token'); const token = localStorage.getItem("token");
if (token) { if (token) {
config.headers = config.headers || {}; config.headers = config.headers || {};
config.headers['Authorization'] = `Bearer ${token}`; config.headers["Authorization"] = `Bearer ${token}`;
} }
return config; return config;
}); });
@@ -27,20 +32,20 @@ instance.interceptors.response.use(
if (code === 200 || success) { if (code === 200 || success) {
return res.data.data ?? res.data; return res.data.data ?? res.data;
} }
Toast.show({ content: msg || '接口错误', position: 'top' }); Toast.show({ content: msg || "接口错误", position: "top" });
if (code === 401) { if (code === 401) {
localStorage.removeItem('token'); localStorage.removeItem("token");
const currentPath = window.location.pathname + window.location.search; const currentPath = window.location.pathname + window.location.search;
if (currentPath === '/login') { if (currentPath === "/login") {
window.location.href = '/login'; window.location.href = "/login";
} else { } else {
window.location.href = `/login?redirect=${encodeURIComponent(currentPath)}`; window.location.href = `/login?redirect=${encodeURIComponent(currentPath)}`;
} }
} }
return Promise.reject(msg || '接口错误'); return Promise.reject(msg || "接口错误");
}, },
err => { (err) => {
Toast.show({ content: err.message || '网络异常', position: 'top' }); Toast.show({ content: err.message || "网络异常", position: "top" });
return Promise.reject(err); return Promise.reject(err);
} }
); );
@@ -48,17 +53,18 @@ instance.interceptors.response.use(
export function request( export function request(
url: string, url: string,
data?: any, data?: any,
method: Method = 'GET', method: Method = "GET",
config?: AxiosRequestConfig, config?: AxiosRequestConfig,
debounceGap?: number debounceGap?: number
): Promise<any> { ): 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 key = `${method}_${url}_${JSON.stringify(data)}`;
const now = Date.now(); const now = Date.now();
const last = debounceMap.get(key) || 0; const last = debounceMap.get(key) || 0;
if (gap > 0 && now - last < gap) { if (gap > 0 && now - last < gap) {
Toast.show({ content: '请求过于频繁,请稍后再试', position: 'top' }); // Toast.show({ content: '请求过于频繁,请稍后再试', position: 'top' });
return Promise.reject('请求过于频繁,请稍后再试'); return Promise.reject("请求过于频繁,请稍后再试");
} }
debounceMap.set(key, now); debounceMap.set(key, now);
@@ -67,7 +73,7 @@ export function request(
method, method,
...config, ...config,
}; };
if (method.toUpperCase() === 'GET') { if (method.toUpperCase() === "GET") {
axiosConfig.params = data; axiosConfig.params = data;
} else { } else {
axiosConfig.data = data; 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 { .popupContainer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100vh;
background: #fff; background: #fff;
} }
.popupHeader { .popupHeader {
@@ -154,3 +154,35 @@
display: flex; display: flex;
gap: 12px; 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,10 @@
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 { Input, Button, Checkbox, Popup, Toast } from "antd-mobile"; import { Checkbox, Popup, Toast } from "antd-mobile";
import request from "@/api/request"; import { Input, Button } from "antd";
import style from "./module.scss"; import { getDeviceList } from "./api";
import style from "./index.module.scss";
import { DeleteOutlined } from "@ant-design/icons";
// 设备选择项接口 // 设备选择项接口
interface DeviceSelectionItem { interface DeviceSelectionItem {
@@ -11,6 +13,9 @@ interface DeviceSelectionItem {
imei: string; imei: string;
wechatId: string; wechatId: string;
status: "online" | "offline"; status: "online" | "offline";
wxid?: string;
nickname?: string;
usedInPlans?: number;
} }
// 组件属性接口 // 组件属性接口
@@ -19,34 +24,57 @@ interface DeviceSelectionProps {
onSelect: (devices: string[]) => void; onSelect: (devices: string[]) => void;
placeholder?: string; placeholder?: string;
className?: 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; // 新增
} }
export default function DeviceSelection({ const PAGE_SIZE = 20;
const DeviceSelection: React.FC<DeviceSelectionProps> = ({
selectedDevices, selectedDevices,
onSelect, onSelect,
placeholder = "选择设备", placeholder = "选择设备",
className = "", className = "",
}: DeviceSelectionProps) { mode = "input",
open,
onOpenChange,
selectedListMaxHeight = 300, // 默认300
showInput = true,
showSelectedList = true,
readonly = false,
}) => {
// 弹窗控制
const [popupVisible, setPopupVisible] = useState(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 [devices, setDevices] = useState<DeviceSelectionItem[]>([]);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("all"); const [statusFilter, setStatusFilter] = useState("all");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [total, setTotal] = useState(0);
// 获取设备列表支持keyword // 获取设备列表支持keyword和分页
const fetchDevices = async (keyword: string = "") => { const fetchDevices = useCallback(
async (keyword: string = "", page: number = 1) => {
setLoading(true); setLoading(true);
try { try {
const res = await request( const res = await getDeviceList({
"/v1/device/list", page,
{ limit: PAGE_SIZE,
page: 1,
limit: 100,
keyword: keyword.trim() || undefined, keyword: keyword.trim() || undefined,
}, });
"GET"
);
if (res && Array.isArray(res.list)) { if (res && Array.isArray(res.list)) {
setDevices( setDevices(
res.list.map((d: any) => ({ res.list.map((d: any) => ({
@@ -55,32 +83,47 @@ export default function DeviceSelection({
imei: d.imei || "", imei: d.imei || "",
wechatId: d.wechatId || "", wechatId: d.wechatId || "",
status: d.alive === 1 ? "online" : "offline", status: d.alive === 1 ? "online" : "offline",
wxid: d.wechatId || "",
nickname: d.nickname || "",
usedInPlans: d.usedInPlans || 0,
})) }))
); );
setTotal(res.total || 0);
} }
} catch (error) { } catch (error) {
console.error("获取设备列表失败:", error); console.error("获取设备列表失败:", error);
Toast.show({ content: "获取设备列表失败", position: "top" });
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; },
[]
);
// 打开弹窗时获取设备列表 // 打开弹窗时获取第一页
const openPopup = () => { const openPopup = () => {
if (readonly) return;
setSearchQuery(""); setSearchQuery("");
setPopupVisible(true); setCurrentPage(1);
fetchDevices(""); setRealVisible(true);
fetchDevices("", 1);
}; };
// 搜索防抖 // 搜索防抖
useEffect(() => { useEffect(() => {
if (!popupVisible) return; if (!realVisible) return;
const timer = setTimeout(() => { const timer = setTimeout(() => {
fetchDevices(searchQuery); setCurrentPage(1);
fetchDevices(searchQuery, 1);
}, 500); }, 500);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [searchQuery, popupVisible]); }, [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 filteredDevices = devices.filter((device) => {
@@ -91,6 +134,8 @@ export default function DeviceSelection({
return matchesStatus; return matchesStatus;
}); });
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
// 处理设备选择 // 处理设备选择
const handleDeviceToggle = (deviceId: string) => { const handleDeviceToggle = (deviceId: string) => {
if (selectedDevices.includes(deviceId)) { if (selectedDevices.includes(deviceId)) {
@@ -106,27 +151,19 @@ export default function DeviceSelection({
return `已选择 ${selectedDevices.length} 个设备`; return `已选择 ${selectedDevices.length} 个设备`;
}; };
return ( // 获取已选设备详细信息
<> const selectedDeviceObjs = selectedDevices
{/* 输入框 */} .map((id) => devices.find((d) => d.id === id))
<div className={`${style.inputWrapper} ${className}`}> .filter(Boolean) as DeviceSelectionItem[];
<SearchOutlined className={style.inputIcon} />
<Input
placeholder={placeholder}
className={style.input}
readOnly
onClick={openPopup}
value={getDisplayText()}
/>
</div>
{/* 设备选择弹窗 */} // 删除已选设备
<Popup const handleRemoveDevice = (id: string) => {
visible={popupVisible} if (readonly) return;
onMaskClick={() => setPopupVisible(false)} onSelect(selectedDevices.filter((d) => d !== id));
position="bottom" };
bodyStyle={{ height: "80vh" }}
> // 弹窗内容
const popupContent = (
<div className={style.popupContainer}> <div className={style.popupContainer}>
<div className={style.popupHeader}> <div className={style.popupHeader}>
<div className={style.popupTitle}></div> <div className={style.popupTitle}></div>
@@ -150,6 +187,19 @@ export default function DeviceSelection({
<option value="online">线</option> <option value="online">线</option>
<option value="offline">线</option> <option value="offline">线</option>
</select> </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>
<div className={style.deviceList}> <div className={style.deviceList}>
{loading ? ( {loading ? (
@@ -188,21 +238,143 @@ export default function DeviceSelection({
</div> </div>
)} )}
</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.popupFooter}>
<div className={style.selectedCount}> <div className={style.selectedCount}>
{selectedDevices.length} {selectedDevices.length}
</div> </div>
<div className={style.footerBtnGroup}> <div className={style.footerBtnGroup}>
<Button fill="outline" onClick={() => setPopupVisible(false)}> <Button fill="outline" onClick={() => setRealVisible(false)}>
</Button> </Button>
<Button color="primary" onClick={() => setPopupVisible(false)}> <Button color="primary" onClick={() => setRealVisible(false)}>
</Button> </Button>
</div> </div>
</div> </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> </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 { .popupContainer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100vh;
background: #fff; background: #fff;
} }
.popupHeader { .popupHeader {
@@ -49,11 +49,6 @@
padding: 0 12px; padding: 0 12px;
background: #fff; background: #fff;
} }
.refreshBtn {
min-width: 40px;
height: 40px;
border-radius: 8px;
}
.loadingIcon { .loadingIcon {
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
font-size: 16px; font-size: 16px;
@@ -168,3 +163,35 @@
display: flex; display: flex;
gap: 12px; 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 React, { useState, useEffect, useCallback } from "react";
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons"; import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
import { Input, Button, Checkbox, Popup, Toast } from "antd-mobile"; import { Checkbox, Popup, Toast } from "antd-mobile";
import request from "@/api/request"; import { Input, Button } from "antd";
import style from "./module.scss"; import { getDeviceList } from "./api";
import style from "./index.module.scss";
interface Device { interface Device {
id: string; id: string;
@@ -31,23 +32,21 @@ export function DeviceSelectionDialog({
const [statusFilter, setStatusFilter] = useState("all"); const [statusFilter, setStatusFilter] = useState("all");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [devices, setDevices] = useState<Device[]>([]); const [devices, setDevices] = useState<Device[]>([]);
const [currentPage, setCurrentPage] = useState(1); // 新增
const [total, setTotal] = useState(0); // 新增
const pageSize = 20; // 每页条数
// 获取设备列表支持keyword // 获取设备列表支持keyword和分页
const fetchDevices = useCallback(async (keyword: string = "") => { const fetchDevices = useCallback(
async (keyword: string = "", page: number = 1) => {
setLoading(true); setLoading(true);
try { try {
const response = await request( const response = await getDeviceList({
"/v1/device/list", page,
{ limit: pageSize,
page: 1,
limit: 100,
keyword: keyword.trim() || undefined, keyword: keyword.trim() || undefined,
}, });
"GET"
);
if (response && Array.isArray(response.list)) { if (response && Array.isArray(response.list)) {
// 转换服务端数据格式为组件需要的格式
const convertedDevices: Device[] = response.list.map( const convertedDevices: Device[] = response.list.map(
(serverDevice: any) => ({ (serverDevice: any) => ({
id: serverDevice.id.toString(), id: serverDevice.id.toString(),
@@ -55,11 +54,12 @@ export function DeviceSelectionDialog({
imei: serverDevice.imei, imei: serverDevice.imei,
wxid: serverDevice.wechatId || "", wxid: serverDevice.wechatId || "",
status: serverDevice.alive === 1 ? "online" : "offline", status: serverDevice.alive === 1 ? "online" : "offline",
usedInPlans: 0, // 这个字段需要从其他API获取 usedInPlans: 0,
nickname: serverDevice.nickname || "", nickname: serverDevice.nickname || "",
}) })
); );
setDevices(convertedDevices); setDevices(convertedDevices);
setTotal(response.total || 0);
} }
} catch (error) { } catch (error) {
console.error("获取设备列表失败:", error); console.error("获取设备列表失败:", error);
@@ -70,12 +70,15 @@ export function DeviceSelectionDialog({
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); },
[]
);
// 打开弹窗时获取设备列表 // 打开弹窗时获取第一页
useEffect(() => { useEffect(() => {
if (open) { if (open) {
fetchDevices(""); setCurrentPage(1);
fetchDevices("", 1);
} }
}, [open, fetchDevices]); }, [open, fetchDevices]);
@@ -83,11 +86,19 @@ export function DeviceSelectionDialog({
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const timer = setTimeout(() => { const timer = setTimeout(() => {
fetchDevices(searchQuery); setCurrentPage(1);
fetchDevices(searchQuery, 1);
}, 500); }, 500);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [searchQuery, open, fetchDevices]); }, [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 filteredDevices = devices.filter((device) => {
const matchesStatus = const matchesStatus =
@@ -105,12 +116,14 @@ export function DeviceSelectionDialog({
} }
}; };
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return ( return (
<Popup <Popup
visible={open} visible={open}
onMaskClick={() => onOpenChange(false)} onMaskClick={() => onOpenChange(false)}
position="bottom" position="bottom"
bodyStyle={{ height: "80vh" }} bodyStyle={{ height: "100vh" }}
> >
<div className={style.popupContainer}> <div className={style.popupContainer}>
<div className={style.popupHeader}> <div className={style.popupHeader}>
@@ -138,7 +151,7 @@ export function DeviceSelectionDialog({
<Button <Button
fill="outline" fill="outline"
size="mini" size="mini"
onClick={() => fetchDevices(searchQuery)} onClick={() => fetchDevices(searchQuery, currentPage)}
disabled={loading} disabled={loading}
className={style.refreshBtn} className={style.refreshBtn}
> >
@@ -196,6 +209,35 @@ export function DeviceSelectionDialog({
</div> </div>
)} )}
</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.popupFooter}>
<div className={style.selectedCount}> <div className={style.selectedCount}>
{selectedDevices.length} {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 { .popupContainer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100vh;
background: #fff; background: #fff;
} }
.popupHeader { .popupHeader {

View File

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

View File

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

View File

@@ -1 +1,11 @@
/// <reference types="vite/client" /> /// <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;
}