From 95c90fb1c91ae4a086e8e92e6df124ca7afbf202 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=AC=94=E8=AE=B0=E6=9C=AC=E9=87=8C=E7=9A=84=E6=B0=B8?= =?UTF-8?q?=E5=B9=B3?= Date: Tue, 22 Jul 2025 15:46:16 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=E6=9C=AC=E6=AC=A1=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E6=9B=B4=E6=96=B0=E5=86=85=E5=AE=B9=E5=A6=82=E4=B8=8B?= =?UTF-8?q?=20=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nkebao/src/api/devices.ts | 44 +++ nkebao/src/api/request.ts | 83 +++-- nkebao/src/pages/devices/DeviceDetail.tsx | 363 ++++++++++++++++++- nkebao/src/pages/devices/Devices.tsx | 411 +++++++++++++++++++++- nkebao/src/types/device.ts | 67 ++++ 5 files changed, 923 insertions(+), 45 deletions(-) create mode 100644 nkebao/src/api/devices.ts create mode 100644 nkebao/src/types/device.ts diff --git a/nkebao/src/api/devices.ts b/nkebao/src/api/devices.ts new file mode 100644 index 00000000..b39c7683 --- /dev/null +++ b/nkebao/src/api/devices.ts @@ -0,0 +1,44 @@ +import request from "./request"; + +// 获取设备列表 +export const fetchDeviceList = (params: { + page?: number; + limit?: number; + keyword?: string; +}) => request("/v1/devices", params, "GET"); + +// 获取设备详情 +export const fetchDeviceDetail = (id: string | number) => + request(`/v1/devices/${id}`); + +// 获取设备关联微信账号 +export const fetchDeviceRelatedAccounts = (id: string | number) => + request(`/v1/wechats/related-device/${id}`); + +// 获取设备操作日志 +export const fetchDeviceHandleLogs = ( + id: string | number, + page = 1, + limit = 10 +) => request(`/v1/devices/${id}/handle-logs`, { page, limit }, "GET"); + +// 更新设备任务配置 +export const updateDeviceTaskConfig = (config: { + deviceId: string | number; + autoAddFriend?: boolean; + autoReply?: boolean; + momentsSync?: boolean; + aiChat?: boolean; +}) => request("/v1/devices/task-config", config, "POST"); + +// 删除设备 +export const deleteDevice = (id: number) => + request(`/v1/devices/${id}`, undefined, "DELETE"); + +// 获取设备二维码 +export const fetchDeviceQRCode = (accountId: string) => + request("/v1/api/device/add", { accountId }, "POST"); + +// 通过IMEI添加设备 +export const addDeviceByImei = (imei: string, name: string) => + request("/v1/api/device/add-by-imei", { imei, name }, "POST"); diff --git a/nkebao/src/api/request.ts b/nkebao/src/api/request.ts index 2c5372bf..b24074ad 100644 --- a/nkebao/src/api/request.ts +++ b/nkebao/src/api/request.ts @@ -1,22 +1,35 @@ -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(); +// 死循环请求拦截配置 +const FAIL_LIMIT = 3; +const BLOCK_TIME = 30 * 1000; // 30秒 +const failMap = new Map< + string, + { count: number; lastFail: number; blockedUntil?: 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 +40,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 +61,26 @@ instance.interceptors.response.use( export function request( url: string, data?: any, - method: Method = 'GET', + method: Method = "GET", config?: AxiosRequestConfig, debounceGap?: number ): Promise { - 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; + + // 死循环拦截:如果被block,直接拒绝 + const failInfo = failMap.get(key); + if (failInfo && failInfo.blockedUntil && now < failInfo.blockedUntil) { + Toast.show({ content: "请求失败过多,请稍后再试", position: "top" }); + return Promise.reject("请求失败过多,请稍后再试"); + } + 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,12 +89,33 @@ export function request( method, ...config, }; - if (method.toUpperCase() === 'GET') { + if (method.toUpperCase() === "GET") { axiosConfig.params = data; } else { axiosConfig.data = data; } - return instance(axiosConfig); + return instance(axiosConfig) + .then((res) => { + // 成功则清除失败计数 + failMap.delete(key); + return res; + }) + .catch((err) => { + debounceMap.delete(key); + // 失败计数 + const fail = failMap.get(key) || { count: 0, lastFail: 0 }; + const newCount = now - fail.lastFail < BLOCK_TIME ? fail.count + 1 : 1; + if (newCount >= FAIL_LIMIT) { + failMap.set(key, { + count: newCount, + lastFail: now, + blockedUntil: now + BLOCK_TIME, + }); + } else { + failMap.set(key, { count: newCount, lastFail: now }); + } + throw err; + }); } export default request; diff --git a/nkebao/src/pages/devices/DeviceDetail.tsx b/nkebao/src/pages/devices/DeviceDetail.tsx index 69876d6e..a67f8883 100644 --- a/nkebao/src/pages/devices/DeviceDetail.tsx +++ b/nkebao/src/pages/devices/DeviceDetail.tsx @@ -1,28 +1,369 @@ -import React from "react"; -import { NavBar } from "antd-mobile"; +import React, { useEffect, useState, useCallback, useRef } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { NavBar, Tabs, Switch, Toast, SpinLoading, Button } from "antd-mobile"; +import { SettingOutlined, RedoOutlined } from "@ant-design/icons"; import Layout from "@/components/Layout/Layout"; import MeauMobile from "@/components/MeauMobile/MeauMoible"; +import { + fetchDeviceDetail, + fetchDeviceRelatedAccounts, + fetchDeviceHandleLogs, + updateDeviceTaskConfig, +} from "@/api/devices"; +import type { Device, WechatAccount, HandleLog } from "@/types/device"; const DeviceDetail: React.FC = () => { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [loading, setLoading] = useState(true); + const [device, setDevice] = useState(null); + const [tab, setTab] = useState("info"); + const [accounts, setAccounts] = useState([]); + const [accountsLoading, setAccountsLoading] = useState(false); + const [logs, setLogs] = useState([]); + const [logsLoading, setLogsLoading] = useState(false); + const [featureSaving, setFeatureSaving] = useState<{ [k: string]: boolean }>( + {} + ); + + // 获取设备详情 + const loadDetail = useCallback(async () => { + if (!id) return; + setLoading(true); + try { + const res = await fetchDeviceDetail(id); + setDevice(res); + } catch (e: any) { + Toast.show({ content: e.message || "获取设备详情失败", position: "top" }); + } finally { + setLoading(false); + } + }, [id]); + + // 获取关联账号 + const loadAccounts = useCallback(async () => { + if (!id) return; + setAccountsLoading(true); + try { + const res = await fetchDeviceRelatedAccounts(id); + setAccounts(Array.isArray(res.accounts) ? res.accounts : []); + } catch (e: any) { + Toast.show({ content: e.message || "获取关联账号失败", position: "top" }); + } finally { + setAccountsLoading(false); + } + }, [id]); + + // 获取操作日志 + const loadLogs = useCallback(async () => { + if (!id) return; + setLogsLoading(true); + try { + const res = await fetchDeviceHandleLogs(id, 1, 20); + setLogs(Array.isArray(res.list) ? res.list : []); + } catch (e: any) { + Toast.show({ content: e.message || "获取操作日志失败", position: "top" }); + } finally { + setLogsLoading(false); + } + }, [id]); + + useEffect(() => { + loadDetail(); + // eslint-disable-next-line + }, [id]); + + useEffect(() => { + if (tab === "accounts") loadAccounts(); + if (tab === "logs") loadLogs(); + // eslint-disable-next-line + }, [tab]); + + // 功能开关 + const handleFeatureChange = async ( + feature: keyof Device["features"], + checked: boolean + ) => { + if (!id) return; + setFeatureSaving((prev) => ({ ...prev, [feature]: true })); + try { + await updateDeviceTaskConfig({ deviceId: id, [feature]: checked }); + setDevice((prev) => + prev + ? { + ...prev, + features: { ...prev.features, [feature]: checked }, + } + : prev + ); + Toast.show({ + content: `${getFeatureName(feature)}已${checked ? "开启" : "关闭"}`, + }); + } catch (e: any) { + Toast.show({ content: e.message || "设置失败", position: "top" }); + } finally { + setFeatureSaving((prev) => ({ ...prev, [feature]: false })); + } + }; + + const getFeatureName = (feature: string) => { + const map: Record = { + autoAddFriend: "自动加好友", + autoReply: "自动回复", + momentsSync: "朋友圈同步", + aiChat: "AI会话", + }; + return map[feature] || feature; + }; + return ( navigate(-1)} style={{ background: "#fff" }} - onBack={() => window.history.back()} + right={ + + } > -
+ 设备详情 -
+ } - footer={} + loading={loading} > -
-

设备详情页面

-

此页面正在开发中...

-
+ {!device ? ( +
+ +
正在加载设备信息...
+
+ ) : ( +
+ {/* 基本信息卡片 */} +
+
+ {device.memo || "未命名设备"} +
+
+ IMEI: {device.imei} +
+
+ 微信号: {device.wechatId || "未绑定"} +
+
+ 好友数: {device.totalFriend ?? "-"} +
+
+ {device.status === "online" || device.alive === 1 + ? "在线" + : "离线"} +
+
+ {/* 标签页 */} + + + + + + {/* 功能开关 */} + {tab === "info" && ( +
+ {["autoAddFriend", "autoReply", "momentsSync", "aiChat"].map( + (f) => ( +
+
+
{getFeatureName(f)}
+
+ + handleFeatureChange( + f as keyof Device["features"], + checked + ) + } + /> +
+ ) + )} +
+ )} + {/* 关联账号 */} + {tab === "accounts" && ( +
+ {accountsLoading ? ( +
+ +
+ ) : accounts.length === 0 ? ( +
+ 暂无关联微信账号 +
+ ) : ( +
+ {accounts.map((acc) => ( +
+ {acc.nickname} +
+
{acc.nickname}
+
+ 微信号: {acc.wechatId} +
+
+ 好友数: {acc.totalFriend} +
+
+ 最后活跃: {acc.lastActive} +
+
+ + {acc.wechatAliveText} + +
+ ))} +
+ )} +
+ +
+
+ )} + {/* 操作日志 */} + {tab === "logs" && ( +
+ {logsLoading ? ( +
+ +
+ ) : logs.length === 0 ? ( +
+ 暂无操作日志 +
+ ) : ( +
+ {logs.map((log) => ( +
+
{log.content}
+
+ 操作人: {log.username} · {log.createTime} +
+
+ ))} +
+ )} +
+ +
+
+ )} +
+ )}
); }; diff --git a/nkebao/src/pages/devices/Devices.tsx b/nkebao/src/pages/devices/Devices.tsx index 66895d54..bb7f7f95 100644 --- a/nkebao/src/pages/devices/Devices.tsx +++ b/nkebao/src/pages/devices/Devices.tsx @@ -1,35 +1,418 @@ -import React from "react"; -import { NavBar, Button } from "antd-mobile"; -import { AddOutline } from "antd-mobile-icons"; +import React, { useEffect, useRef, useState, useCallback } from "react"; +import { NavBar, Popup, Tabs, Toast, SpinLoading, Dialog } from "antd-mobile"; +import { Button, Input, Pagination } from "antd"; +import { AddOutline, DeleteOutline } from "antd-mobile-icons"; +import { + ReloadOutlined, + SearchOutlined, + QrcodeOutlined, +} from "@ant-design/icons"; import Layout from "@/components/Layout/Layout"; import MeauMobile from "@/components/MeauMobile/MeauMoible"; +import { + fetchDeviceList, + fetchDeviceQRCode, + addDeviceByImei, + deleteDevice, +} from "@/api/devices"; +import type { Device } from "@/types/device"; const Devices: React.FC = () => { + // 设备列表相关 + const [devices, setDevices] = useState([]); + const [loading, setLoading] = useState(false); + const [search, setSearch] = useState(""); + const [status, setStatus] = useState<"all" | "online" | "offline">("all"); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(true); + const [total, setTotal] = useState(0); + const [selected, setSelected] = useState<(string | number)[]>([]); + const observerRef = useRef(null); + const [usePagination, setUsePagination] = useState(true); // 新增:是否使用分页 + + // 添加设备弹窗 + const [addVisible, setAddVisible] = useState(false); + const [addTab, setAddTab] = useState("scan"); + const [qrLoading, setQrLoading] = useState(false); + const [qrCode, setQrCode] = useState(null); + const [imei, setImei] = useState(""); + const [name, setName] = useState(""); + const [addLoading, setAddLoading] = useState(false); + + // 删除弹窗 + const [delVisible, setDelVisible] = useState(false); + const [delLoading, setDelLoading] = useState(false); + + // 加载设备列表 + const loadDevices = useCallback( + async (reset = false) => { + if (loading) return; + setLoading(true); + try { + const params: any = { page: reset ? 1 : page, limit: 20 }; + if (search) params.keyword = search; + const res = await fetchDeviceList(params); + const list = Array.isArray(res.list) ? res.list : []; + setDevices((prev) => (reset ? list : [...prev, ...list])); + setTotal(res.total || 0); + setHasMore(list.length === 20); + if (reset) setPage(1); + } catch (e) { + Toast.show({ content: "获取设备列表失败", position: "top" }); + setHasMore(false); // 请求失败后不再继续请求 + } finally { + setLoading(false); + } + }, + [loading, search, page] + ); + + // 首次加载和搜索 + useEffect(() => { + loadDevices(true); + // eslint-disable-next-line + }, [search]); + + // 无限滚动 + useEffect(() => { + if (!hasMore || loading) return; + const observer = new window.IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasMore && !loading) { + setPage((p) => p + 1); + } + }, + { threshold: 0.5 } + ); + if (observerRef.current) observer.observe(observerRef.current); + return () => observer.disconnect(); + }, [hasMore, loading]); + + // 分页加载 + useEffect(() => { + if (page === 1) return; + loadDevices(); + // eslint-disable-next-line + }, [page]); + + // 状态筛选 + const filtered = devices.filter((d) => { + if (status === "all") return true; + if (status === "online") return d.status === "online" || d.alive === 1; + if (status === "offline") return d.status === "offline" || d.alive === 0; + return true; + }); + + // 获取二维码 + const handleGetQr = async () => { + setQrLoading(true); + setQrCode(null); + try { + const accountId = localStorage.getItem("s2_accountId") || ""; + if (!accountId) throw new Error("未获取到用户信息"); + const res = await fetchDeviceQRCode(accountId); + setQrCode(res.qrCode); + } catch (e: any) { + Toast.show({ content: e.message || "获取二维码失败", position: "top" }); + } finally { + setQrLoading(false); + } + }; + + // 手动添加设备 + const handleAddDevice = async () => { + if (!imei.trim() || !name.trim()) { + Toast.show({ content: "请填写完整信息", position: "top" }); + return; + } + setAddLoading(true); + try { + await addDeviceByImei(imei, name); + Toast.show({ content: "添加成功", position: "top" }); + setAddVisible(false); + setImei(""); + setName(""); + loadDevices(true); + } catch (e: any) { + Toast.show({ content: e.message || "添加失败", position: "top" }); + } finally { + setAddLoading(false); + } + }; + + // 删除设备 + const handleDelete = async () => { + if (!selected.length) return; + setDelLoading(true); + try { + for (const id of selected) { + await deleteDevice(Number(id)); + } + Toast.show({ content: `删除成功`, position: "top" }); + setDelVisible(false); + setSelected([]); + loadDevices(true); + } catch (e: any) { + Toast.show({ content: e.message || "删除失败", position: "top" }); + } finally { + setDelLoading(false); + } + }; + + // 跳转详情 + const goDetail = (id: string | number) => { + window.location.href = `/devices/${id}`; + }; + + // 分页切换 + const handlePageChange = (p: number) => { + setPage(p); + loadDevices(true); + }; + return ( - 设备管理 - - } right={ - } - /> + > + + 设备管理 + + } - footer={} + footer={} + loading={loading && devices.length === 0} > -
-

设备管理页面

-

此页面正在开发中...

+
+ {/* 搜索栏 */} +
+ setSearch(e.target.value)} + prefix={} + allowClear + style={{ flex: 1 }} + /> + +
+ {/* 筛选和删除 */} +
+ setStatus(k as any)} + style={{ flex: 1 }} + > + + + + + +
+ {/* 设备列表 */} +
+ {filtered.map((device) => ( +
goDetail(device.id!)} + > + { + e.stopPropagation(); + setSelected((prev) => + e.target.checked + ? [...prev, device.id!] + : prev.filter((id) => id !== device.id) + ); + }} + onClick={(e) => e.stopPropagation()} + style={{ marginRight: 12 }} + /> +
+
+ {device.memo || "未命名设备"} +
+
+ IMEI: {device.imei} +
+
+ 微信号: {device.wechatId || "未绑定"} +
+
+ 好友数: {device.totalFriend ?? "-"} +
+
+ + {device.status === "online" || device.alive === 1 + ? "在线" + : "离线"} + +
+ ))} + {/* 分页组件 */} + {usePagination && ( +
+ +
+ )} + {/* 无限滚动提示(仅在不分页时显示) */} + {!usePagination && ( +
+ {loading && } + {!hasMore && devices.length > 0 && "没有更多设备了"} + {!hasMore && devices.length === 0 && "暂无设备"} +
+ )} +
+ {/* 添加设备弹窗 */} + setAddVisible(false)} + bodyStyle={{ + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + minHeight: 320, + }} + > +
+ + + + + {addTab === "scan" && ( +
+ + {qrCode && ( +
+ 二维码 +
+ 请用手机扫码添加设备 +
+
+ )} +
+ )} + {addTab === "manual" && ( +
+ setName(e.target.value)} + allowClear + /> + setImei(e.target.value)} + allowClear + /> + +
+ )} +
+
+ {/* 删除确认弹窗 */} + setDelVisible(false)} + closeOnAction + closeOnMaskClick + actions={[ + { + key: "confirm", + text: "确认删除", + danger: true, + loading: delLoading, + }, + { key: "cancel", text: "取消" }, + ]} + /> ); }; diff --git a/nkebao/src/types/device.ts b/nkebao/src/types/device.ts new file mode 100644 index 00000000..629bde55 --- /dev/null +++ b/nkebao/src/types/device.ts @@ -0,0 +1,67 @@ +export type DeviceStatus = "online" | "offline" | "busy" | "error"; + +export interface Device { + id: number | string; + imei: string; + memo?: string; + wechatId?: string; + totalFriend?: number; + alive?: number; + status?: DeviceStatus; + nickname?: string; + battery?: number; + lastActive?: string; + features?: { + autoAddFriend?: boolean; + autoReply?: boolean; + momentsSync?: boolean; + aiChat?: boolean; + }; +} + +export interface DeviceListResponse { + list: Device[]; + total: number; + page: number; + limit: number; +} + +export interface DeviceDetailResponse { + id: number | string; + imei: string; + memo?: string; + wechatId?: string; + alive?: number; + totalFriend?: number; + nickname?: string; + battery?: number; + lastActive?: string; + features?: { + autoAddFriend?: boolean; + autoReply?: boolean; + momentsSync?: boolean; + aiChat?: boolean; + }; +} + +export interface WechatAccount { + id: string; + avatar: string; + nickname: string; + wechatId: string; + gender: number; + status: number; + statusText: string; + wechatAlive: number; + wechatAliveText: string; + addFriendStatus: number; + totalFriend: number; + lastActive: string; +} + +export interface HandleLog { + id: string | number; + content: string; + username: string; + createTime: string; +} From 8bb26032bde81ed1e097ab477035f1b7380a62d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=AC=94=E8=AE=B0=E6=9C=AC=E9=87=8C=E7=9A=84=E6=B0=B8?= =?UTF-8?q?=E5=B9=B3?= Date: Tue, 22 Jul 2025 15:47:06 +0800 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=E6=9C=AC=E6=AC=A1=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E6=9B=B4=E6=96=B0=E5=86=85=E5=AE=B9=E5=A6=82=E4=B8=8B?= =?UTF-8?q?=20=E7=8E=AF=E5=A2=83=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nkebao/.env.development | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nkebao/.env.development b/nkebao/.env.development index fe189d22..b60a89c6 100644 --- a/nkebao/.env.development +++ b/nkebao/.env.development @@ -1,4 +1,4 @@ # 基础环境变量示例 -VITE_API_BASE_URL=https://ckbapi.quwanzhi.com +VITE_API_BASE_URL=http://yishi.com VITE_APP_TITLE=Nkebao Base From ac9aae920082ff5011aae12828b8318d8f6f76cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=AC=94=E8=AE=B0=E6=9C=AC=E9=87=8C=E7=9A=84=E6=B0=B8?= =?UTF-8?q?=E5=B9=B3?= Date: Tue, 22 Jul 2025 16:02:16 +0800 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=E6=9C=AC=E6=AC=A1=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E6=9B=B4=E6=96=B0=E5=86=85=E5=AE=B9=E5=A6=82=E4=B8=8B?= =?UTF-8?q?=20=E6=A0=B7=E5=BC=8F=E6=9E=84=E5=BB=BA=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nkebao/.env.development | 2 +- nkebao/src/pages/devices/Devices.tsx | 150 +++++++++++++++------------ 2 files changed, 85 insertions(+), 67 deletions(-) diff --git a/nkebao/.env.development b/nkebao/.env.development index b60a89c6..da6a111b 100644 --- a/nkebao/.env.development +++ b/nkebao/.env.development @@ -1,4 +1,4 @@ # 基础环境变量示例 -VITE_API_BASE_URL=http://yishi.com +VITE_API_BASE_URL=http://www.yishi.com VITE_APP_TITLE=Nkebao Base diff --git a/nkebao/src/pages/devices/Devices.tsx b/nkebao/src/pages/devices/Devices.tsx index bb7f7f95..2692cad9 100644 --- a/nkebao/src/pages/devices/Devices.tsx +++ b/nkebao/src/pages/devices/Devices.tsx @@ -1,11 +1,13 @@ import React, { useEffect, useRef, useState, useCallback } from "react"; import { NavBar, Popup, Tabs, Toast, SpinLoading, Dialog } from "antd-mobile"; import { Button, Input, Pagination } from "antd"; +import { useNavigate } from "react-router-dom"; import { AddOutline, DeleteOutline } from "antd-mobile-icons"; import { ReloadOutlined, SearchOutlined, QrcodeOutlined, + ArrowLeftOutlined, } from "@ant-design/icons"; import Layout from "@/components/Layout/Layout"; import MeauMobile from "@/components/MeauMobile/MeauMoible"; @@ -43,6 +45,7 @@ const Devices: React.FC = () => { const [delVisible, setDelVisible] = useState(false); const [delLoading, setDelLoading] = useState(false); + const navigate = useNavigate(); // 加载设备列表 const loadDevices = useCallback( async (reset = false) => { @@ -173,64 +176,90 @@ const Devices: React.FC = () => { return ( setAddVisible(true)} - > - - 添加设备 - - } - > - - 设备管理 - - + <> + + navigate(-1)} + /> +
+ } + style={{ background: "#fff" }} + right={ + + } + > + + 设备管理 + + + +
+ {/* 搜索栏 */} +
+ setSearch(e.target.value)} + prefix={} + allowClear + style={{ flex: 1 }} + /> + +
+ {/* 筛选和删除 */} +
+ setStatus(k as any)} + style={{ flex: 1 }} + > + + + + + +
+
+ + } + footer={ +
+ +
} - footer={} loading={loading && devices.length === 0} >
- {/* 搜索栏 */} -
- setSearch(e.target.value)} - prefix={} - allowClear - style={{ flex: 1 }} - /> - -
- {/* 筛选和删除 */} -
- setStatus(k as any)} - style={{ flex: 1 }} - > - - - - - -
{/* 设备列表 */}
{filtered.map((device) => ( @@ -294,18 +323,7 @@ const Devices: React.FC = () => {
))} - {/* 分页组件 */} - {usePagination && ( -
- -
- )} + {/* 无限滚动提示(仅在不分页时显示) */} {!usePagination && (
Date: Tue, 22 Jul 2025 16:24:30 +0800 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20=E6=9C=AC=E6=AC=A1=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E6=9B=B4=E6=96=B0=E5=86=85=E5=AE=B9=E5=A6=82=E4=B8=8B?= =?UTF-8?q?=20=E5=AD=98=E4=B8=80=E4=B8=8B=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nkebao/src/pages/devices/Devices.tsx | 43 ++++++++++++++-------------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/nkebao/src/pages/devices/Devices.tsx b/nkebao/src/pages/devices/Devices.tsx index 2692cad9..11e11dd6 100644 --- a/nkebao/src/pages/devices/Devices.tsx +++ b/nkebao/src/pages/devices/Devices.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useState, useCallback } from "react"; import { NavBar, Popup, Tabs, Toast, SpinLoading, Dialog } from "antd-mobile"; -import { Button, Input, Pagination } from "antd"; +import { Button, Input, Pagination, Checkbox } from "antd"; import { useNavigate } from "react-router-dom"; import { AddOutline, DeleteOutline } from "antd-mobile-icons"; import { @@ -191,11 +191,11 @@ const Devices: React.FC = () => { right={ } > @@ -223,7 +223,7 @@ const Devices: React.FC = () => {
{/* 筛选和删除 */} -
+
setStatus(k as any)} @@ -233,15 +233,18 @@ const Devices: React.FC = () => { - +
+ +
@@ -279,8 +282,7 @@ const Devices: React.FC = () => { }} onClick={() => goDetail(device.id!)} > - { e.stopPropagation(); @@ -297,13 +299,13 @@ const Devices: React.FC = () => {
{device.memo || "未命名设备"}
-
+
IMEI: {device.imei}
-
+
微信号: {device.wechatId || "未绑定"}
-
+
好友数: {device.totalFriend ?? "-"}
@@ -359,8 +361,7 @@ const Devices: React.FC = () => { {addTab === "scan" && (
- } - style={{ background: "#fff" }} - right={ - - } - > - - 设备管理 - - - -
- {/* 搜索栏 */} -
- setSearch(e.target.value)} - prefix={} - allowClear - style={{ flex: 1 }} - /> - -
- {/* 筛选和删除 */} -
- setStatus(k as any)} - style={{ flex: 1 }} - > - - - - -
- -
-
-
- - } - footer={ -
- -
- } - loading={loading && devices.length === 0} - > -
- {/* 设备列表 */} -
- {filtered.map((device) => ( -
goDetail(device.id!)} - > - { - e.stopPropagation(); - setSelected((prev) => - e.target.checked - ? [...prev, device.id!] - : prev.filter((id) => id !== device.id) - ); - }} - onClick={(e) => e.stopPropagation()} - style={{ marginRight: 12 }} - /> -
-
- {device.memo || "未命名设备"} -
-
- IMEI: {device.imei} -
-
- 微信号: {device.wechatId || "未绑定"} -
-
- 好友数: {device.totalFriend ?? "-"} -
-
- - {device.status === "online" || device.alive === 1 - ? "在线" - : "离线"} - -
- ))} - - {/* 无限滚动提示(仅在不分页时显示) */} - {!usePagination && ( -
- {loading && } - {!hasMore && devices.length > 0 && "没有更多设备了"} - {!hasMore && devices.length === 0 && "暂无设备"} -
- )} -
-
- {/* 添加设备弹窗 */} - setAddVisible(false)} - bodyStyle={{ - borderTopLeftRadius: 16, - borderTopRightRadius: 16, - minHeight: 320, - }} - > -
- - - - - {addTab === "scan" && ( -
- - {qrCode && ( -
- 二维码 -
- 请用手机扫码添加设备 -
-
- )} -
- )} - {addTab === "manual" && ( -
- setName(e.target.value)} - allowClear - /> - setImei(e.target.value)} - allowClear - /> - -
- )} -
-
- {/* 删除确认弹窗 */} - setDelVisible(false)} - closeOnAction - closeOnMaskClick - actions={[ - { - key: "confirm", - text: "确认删除", - danger: true, - // loading: delLoading, // antd-mobile Dialog.Action 不支持 loading 属性,去掉 - }, - { key: "cancel", text: "取消" }, - ]} - /> - - ); -}; - -export default Devices; +import React, { useEffect, useRef, useState, useCallback } from "react"; +import { NavBar, Popup, Tabs, Toast, SpinLoading, Dialog } from "antd-mobile"; +import { Button, Input, Pagination, Checkbox } from "antd"; +import { useNavigate } from "react-router-dom"; +import { AddOutline, DeleteOutline } from "antd-mobile-icons"; +import { + ReloadOutlined, + SearchOutlined, + QrcodeOutlined, + ArrowLeftOutlined, +} from "@ant-design/icons"; +import Layout from "@/components/Layout/Layout"; +import MeauMobile from "@/components/MeauMobile/MeauMoible"; +import { + fetchDeviceList, + fetchDeviceQRCode, + addDeviceByImei, + deleteDevice, +} from "@/api/devices"; +import type { Device } from "@/types/device"; +import { comfirm } from "@/utils/common"; + +const Devices: React.FC = () => { + // 设备列表相关 + const [devices, setDevices] = useState([]); + const [loading, setLoading] = useState(false); + const [search, setSearch] = useState(""); + const [status, setStatus] = useState<"all" | "online" | "offline">("all"); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(true); + const [total, setTotal] = useState(0); + const [selected, setSelected] = useState<(string | number)[]>([]); + const observerRef = useRef(null); + const [usePagination, setUsePagination] = useState(true); // 新增:是否使用分页 + + // 添加设备弹窗 + const [addVisible, setAddVisible] = useState(false); + const [addTab, setAddTab] = useState("scan"); + const [qrLoading, setQrLoading] = useState(false); + const [qrCode, setQrCode] = useState(null); + const [imei, setImei] = useState(""); + const [name, setName] = useState(""); + const [addLoading, setAddLoading] = useState(false); + + // 删除弹窗 + const [delVisible, setDelVisible] = useState(false); + const [delLoading, setDelLoading] = useState(false); + + const navigate = useNavigate(); + // 加载设备列表 + const loadDevices = useCallback( + async (reset = false) => { + if (loading) return; + setLoading(true); + try { + const params: any = { page: reset ? 1 : page, limit: 20 }; + if (search) params.keyword = search; + const res = await fetchDeviceList(params); + const list = Array.isArray(res.list) ? res.list : []; + setDevices((prev) => (reset ? list : [...prev, ...list])); + setTotal(res.total || 0); + setHasMore(list.length === 20); + if (reset) setPage(1); + } catch (e) { + Toast.show({ content: "获取设备列表失败", position: "top" }); + setHasMore(false); // 请求失败后不再继续请求 + } finally { + setLoading(false); + } + }, + [loading, search, page] + ); + + // 首次加载和搜索 + useEffect(() => { + loadDevices(true); + // eslint-disable-next-line + }, [search]); + + // 无限滚动 + useEffect(() => { + if (!hasMore || loading) return; + const observer = new window.IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasMore && !loading) { + setPage((p) => p + 1); + } + }, + { threshold: 0.5 } + ); + if (observerRef.current) observer.observe(observerRef.current); + return () => observer.disconnect(); + }, [hasMore, loading]); + + // 分页加载 + useEffect(() => { + if (page === 1) return; + loadDevices(); + // eslint-disable-next-line + }, [page]); + + // 状态筛选 + const filtered = devices.filter((d) => { + if (status === "all") return true; + if (status === "online") return d.status === "online" || d.alive === 1; + if (status === "offline") return d.status === "offline" || d.alive === 0; + return true; + }); + + // 获取二维码 + const handleGetQr = async () => { + setQrLoading(true); + setQrCode(null); + try { + const accountId = localStorage.getItem("s2_accountId") || ""; + if (!accountId) throw new Error("未获取到用户信息"); + const res = await fetchDeviceQRCode(accountId); + setQrCode(res.qrCode); + } catch (e: any) { + Toast.show({ content: e.message || "获取二维码失败", position: "top" }); + } finally { + setQrLoading(false); + } + }; + + // 手动添加设备 + const handleAddDevice = async () => { + if (!imei.trim() || !name.trim()) { + Toast.show({ content: "请填写完整信息", position: "top" }); + return; + } + setAddLoading(true); + try { + await addDeviceByImei(imei, name); + Toast.show({ content: "添加成功", position: "top" }); + setAddVisible(false); + setImei(""); + setName(""); + loadDevices(true); + } catch (e: any) { + Toast.show({ content: e.message || "添加失败", position: "top" }); + } finally { + setAddLoading(false); + } + }; + + // 删除设备 + const handleDelete = async () => { + setDelLoading(true); + try { + for (const id of selected) { + await deleteDevice(Number(id)); + } + Toast.show({ content: `删除成功`, position: "top" }); + setSelected([]); + loadDevices(true); + } catch (e: any) { + if (e) Toast.show({ content: e.message || "删除失败", position: "top" }); + } finally { + setDelLoading(false); + } + }; + + // 删除按钮点击 + const handleDeleteClick = async () => { + try { + await comfirm( + `将删除${selected.length}个设备,删除后本设备配置的计划任务操作也将失效。确认删除?`, + { title: "确认删除", confirmText: "确认删除", cancelText: "取消" } + ); + handleDelete(); + } catch { + // 用户取消,无需处理 + } + }; + + // 跳转详情 + const goDetail = (id: string | number) => { + window.location.href = `/devices/${id}`; + }; + + // 分页切换 + const handlePageChange = (p: number) => { + setPage(p); + loadDevices(true); + }; + + return ( + + + navigate(-1)} + /> +
+ } + style={{ background: "#fff" }} + right={ + + } + > + + 设备管理 + + + +
+ {/* 搜索栏 */} +
+ setSearch(e.target.value)} + prefix={} + allowClear + style={{ flex: 1 }} + /> + +
+ {/* 筛选和删除 */} +
+ setStatus(k as any)} + style={{ flex: 1 }} + > + + + + +
+ +
+
+
+ + } + footer={ +
+ +
+ } + loading={loading && devices.length === 0} + > +
+ {/* 设备列表 */} +
+ {filtered.map((device) => ( +
goDetail(device.id!)} + > + { + e.stopPropagation(); + setSelected((prev) => + e.target.checked + ? [...prev, device.id!] + : prev.filter((id) => id !== device.id) + ); + }} + onClick={(e) => e.stopPropagation()} + style={{ marginRight: 12 }} + /> +
+
+ {device.memo || "未命名设备"} +
+
+ IMEI: {device.imei} +
+
+ 微信号: {device.wechatId || "未绑定"} +
+
+ 好友数: {device.totalFriend ?? "-"} +
+
+ + {device.status === "online" || device.alive === 1 + ? "在线" + : "离线"} + +
+ ))} + + {/* 无限滚动提示(仅在不分页时显示) */} + {!usePagination && ( +
+ {loading && } + {!hasMore && devices.length > 0 && "没有更多设备了"} + {!hasMore && devices.length === 0 && "暂无设备"} +
+ )} +
+
+ {/* 添加设备弹窗 */} + setAddVisible(false)} + bodyStyle={{ + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + minHeight: 320, + }} + > +
+ + + + + {addTab === "scan" && ( +
+ + {qrCode && ( +
+ 二维码 +
+ 请用手机扫码添加设备 +
+
+ )} +
+ )} + {addTab === "manual" && ( +
+ setName(e.target.value)} + allowClear + /> + setImei(e.target.value)} + allowClear + /> + +
+ )} +
+
+ + ); +}; + +export default Devices; diff --git a/nkebao/src/utils/common.ts b/nkebao/src/utils/common.ts new file mode 100644 index 00000000..60cbf840 --- /dev/null +++ b/nkebao/src/utils/common.ts @@ -0,0 +1,37 @@ +import { Modal } from "antd-mobile"; + +/** + * 通用js调用弹窗,Promise风格 + * @param content 弹窗内容 + * @param config 配置项(title, cancelText, confirmText) + * @returns Promise + */ +export const comfirm = ( + content: string, + config?: { + title?: string; + cancelText?: string; + confirmText?: string; + } +): Promise => { + return new Promise((resolve, reject) => { + Modal.show({ + title: config?.title || "提示", + content, + closeOnAction: true, + actions: [ + { + key: "cancel", + text: config?.cancelText || "取消", + onClick: () => reject(), + }, + { + key: "confirm", + text: config?.confirmText || "确认", + danger: true, + onClick: () => resolve(), + }, + ], + }); + }); +}; From 17e4cd26fe3254a46edfc6a1f90c3d32f2c8250d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=AC=94=E8=AE=B0=E6=9C=AC=E9=87=8C=E7=9A=84=E6=B0=B8?= =?UTF-8?q?=E5=B9=B3?= Date: Tue, 22 Jul 2025 17:58:14 +0800 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20=E6=9C=AC=E6=AC=A1=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E6=9B=B4=E6=96=B0=E5=86=85=E5=AE=B9=E5=A6=82=E4=B8=8B?= =?UTF-8?q?=20=E9=A6=96=E9=A1=B5=E6=A0=B7=E5=BC=8F=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nkebao/src/pages/home/index.module.scss | 5 ----- 1 file changed, 5 deletions(-) diff --git a/nkebao/src/pages/home/index.module.scss b/nkebao/src/pages/home/index.module.scss index 5ddda9a0..ac10533a 100644 --- a/nkebao/src/pages/home/index.module.scss +++ b/nkebao/src/pages/home/index.module.scss @@ -1,11 +1,8 @@ .home-page { padding: 12px; - background: #f8f6f3; - min-height: 100vh; } .content-wrapper { - padding: 12px; display: flex; flex-direction: column; gap: 12px; @@ -58,7 +55,6 @@ display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; - margin-bottom: 16px; } .stat-card { @@ -159,7 +155,6 @@ background: white; border-radius: 12px; padding: 16px; - margin-bottom: 12px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); transition: all 0.3s ease; From 3987bfa551d21cd08a57a1ab0161f33cf63213b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=AC=94=E8=AE=B0=E6=9C=AC=E9=87=8C=E7=9A=84=E6=B0=B8?= =?UTF-8?q?=E5=B9=B3?= Date: Tue, 22 Jul 2025 18:36:07 +0800 Subject: [PATCH 7/8] =?UTF-8?q?feat:=20=E6=9C=AC=E6=AC=A1=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E6=9B=B4=E6=96=B0=E5=86=85=E5=AE=B9=E5=A6=82=E4=B8=8B?= =?UTF-8?q?=20=E6=A0=B7=E5=BC=8F=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/DeviceSelectionDialog/api.ts | 10 - .../DeviceSelectionDialog/index.module.scss | 197 ------------- .../DeviceSelectionDialog/index.tsx | 258 ------------------ .../FriendSelection/index.module.scss | 12 +- .../src/components/FriendSelection/index.tsx | 212 +++++++++----- .../GroupSelection/index.module.scss | 17 +- .../src/components/GroupSelection/index.tsx | 226 ++++++++++----- nkebao/src/components/SelectionTest.tsx | 14 +- 8 files changed, 311 insertions(+), 635 deletions(-) delete mode 100644 nkebao/src/components/DeviceSelectionDialog/api.ts delete mode 100644 nkebao/src/components/DeviceSelectionDialog/index.module.scss delete mode 100644 nkebao/src/components/DeviceSelectionDialog/index.tsx diff --git a/nkebao/src/components/DeviceSelectionDialog/api.ts b/nkebao/src/components/DeviceSelectionDialog/api.ts deleted file mode 100644 index 1f28ce04..00000000 --- a/nkebao/src/components/DeviceSelectionDialog/api.ts +++ /dev/null @@ -1,10 +0,0 @@ -import request from "@/api/request"; - -// 获取设备列表 -export function getDeviceList(params: { - page: number; - limit: number; - keyword?: string; -}) { - return request("/v1/devices", params, "GET"); -} diff --git a/nkebao/src/components/DeviceSelectionDialog/index.module.scss b/nkebao/src/components/DeviceSelectionDialog/index.module.scss deleted file mode 100644 index e0961f10..00000000 --- a/nkebao/src/components/DeviceSelectionDialog/index.module.scss +++ /dev/null @@ -1,197 +0,0 @@ -.popupContainer { - display: flex; - flex-direction: column; - height: 100vh; - background: #fff; -} -.popupHeader { - padding: 16px; - border-bottom: 1px solid #f0f0f0; -} -.popupTitle { - font-size: 18px; - font-weight: 600; - text-align: center; -} -.popupSearchRow { - display: flex; - align-items: center; - gap: 16px; - padding: 16px; -} -.popupSearchInputWrap { - position: relative; - flex: 1; -} -.inputIcon { - position: absolute; - left: 12px; - top: 50%; - transform: translateY(-50%); - color: #bdbdbd; - z-index: 10; - font-size: 16px; -} -.popupSearchInput { - padding-left: 36px !important; - border-radius: 12px !important; - height: 44px; - font-size: 15px; - border: 1px solid #e5e6eb !important; - background: #f8f9fa; -} -.statusSelect { - width: 128px; - height: 40px; - border-radius: 8px; - border: 1px solid #e5e6eb; - font-size: 14px; - padding: 0 12px; - background: #fff; -} -.loadingIcon { - animation: spin 1s linear infinite; - font-size: 16px; -} -@keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} -.deviceList { - flex: 1; - overflow-y: auto; -} -.deviceListInner { - display: flex; - flex-direction: column; - gap: 12px; - padding: 16px; -} -.deviceItem { - display: flex; - align-items: flex-start; - gap: 12px; - padding: 16px; - border-radius: 12px; - border: 1px solid #f0f0f0; - background: #fff; - cursor: pointer; - transition: background 0.2s; - &:hover { - background: #f5f6fa; - } -} -.deviceCheckbox { - margin-top: 4px; -} -.deviceInfo { - flex: 1; -} -.deviceInfoRow { - display: flex; - align-items: center; - justify-content: space-between; -} -.deviceName { - font-weight: 500; - font-size: 16px; - color: #222; -} -.statusOnline { - padding: 4px 8px; - border-radius: 12px; - background: #52c41a; - color: #fff; - font-size: 12px; - display: flex; - align-items: center; - justify-content: center; -} -.statusOffline { - padding: 4px 8px; - border-radius: 12px; - background: #e5e6eb; - color: #888; - font-size: 12px; - display: flex; - align-items: center; - justify-content: center; -} -.deviceInfoDetail { - font-size: 13px; - color: #888; - margin-top: 4px; -} -.usedInPlans { - font-size: 13px; - color: #fa8c16; - margin-top: 4px; -} -.loadingBox { - display: flex; - align-items: center; - justify-content: center; - height: 100%; -} -.loadingText { - color: #888; - font-size: 15px; -} -.emptyBox { - display: flex; - align-items: center; - justify-content: center; - height: 100%; -} -.emptyText { - color: #888; - font-size: 15px; -} -.popupFooter { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px; - border-top: 1px solid #f0f0f0; - background: #fff; -} -.selectedCount { - font-size: 14px; - color: #888; -} -.footerBtnGroup { - display: flex; - gap: 12px; -} -.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; -} diff --git a/nkebao/src/components/DeviceSelectionDialog/index.tsx b/nkebao/src/components/DeviceSelectionDialog/index.tsx deleted file mode 100644 index 588d774a..00000000 --- a/nkebao/src/components/DeviceSelectionDialog/index.tsx +++ /dev/null @@ -1,258 +0,0 @@ -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"; - -interface Device { - id: string; - name: string; - imei: string; - wxid: string; - status: "online" | "offline"; - usedInPlans: number; - nickname: string; -} - -interface DeviceSelectionDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - selectedDevices: string[]; - onSelect: (devices: string[]) => void; -} - -export function DeviceSelectionDialog({ - open, - onOpenChange, - selectedDevices, - onSelect, -}: DeviceSelectionDialogProps) { - const [searchQuery, setSearchQuery] = useState(""); - const [statusFilter, setStatusFilter] = useState("all"); - const [loading, setLoading] = useState(false); - const [devices, setDevices] = useState([]); - const [currentPage, setCurrentPage] = useState(1); // 新增 - const [total, setTotal] = useState(0); // 新增 - const pageSize = 20; // 每页条数 - - // 获取设备列表,支持keyword和分页 - const fetchDevices = useCallback( - async (keyword: string = "", page: number = 1) => { - setLoading(true); - try { - const response = await getDeviceList({ - page, - limit: pageSize, - keyword: keyword.trim() || undefined, - }); - 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); - } - }, - [] - ); - - // 打开弹窗时获取第一页 - useEffect(() => { - if (open) { - setCurrentPage(1); - fetchDevices("", 1); - } - }, [open, fetchDevices]); - - // 搜索防抖 - useEffect(() => { - if (!open) return; - const timer = setTimeout(() => { - 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 = - statusFilter === "all" || - (statusFilter === "online" && device.status === "online") || - (statusFilter === "offline" && device.status === "offline"); - return matchesStatus; - }); - - const handleDeviceSelect = (deviceId: string) => { - if (selectedDevices.includes(deviceId)) { - onSelect(selectedDevices.filter((id) => id !== deviceId)); - } else { - onSelect([...selectedDevices, deviceId]); - } - }; - - const totalPages = Math.max(1, Math.ceil(total / pageSize)); - - return ( - onOpenChange(false)} - position="bottom" - bodyStyle={{ height: "100vh" }} - > -
-
-
选择设备
-
-
-
- - setSearchQuery(val)} - className={style.popupSearchInput} - /> -
- - -
-
- {loading ? ( -
-
加载中...
-
- ) : filteredDevices.length === 0 ? ( -
-
暂无数据
-
- ) : ( -
- {filteredDevices.map((device) => ( - - ))} -
- )} -
- {/* 分页栏 */} -
-
总计 {total} 个设备
-
- - - {currentPage} / {totalPages} - - -
-
-
-
- 已选择 {selectedDevices.length} 个设备 -
-
- - -
-
-
-
- ); -} diff --git a/nkebao/src/components/FriendSelection/index.module.scss b/nkebao/src/components/FriendSelection/index.module.scss index 0c82db96..663353e8 100644 --- a/nkebao/src/components/FriendSelection/index.module.scss +++ b/nkebao/src/components/FriendSelection/index.module.scss @@ -205,13 +205,21 @@ } .popupFooter { - border-top: 1px solid #f0f0f0; - padding: 16px; display: flex; align-items: center; justify-content: space-between; + padding: 16px; + border-top: 1px solid #f0f0f0; background: #fff; } +.selectedCount { + font-size: 14px; + color: #888; +} +.footerBtnGroup { + display: flex; + gap: 12px; +} .cancelBtn { padding: 0 24px; border-radius: 24px; diff --git a/nkebao/src/components/FriendSelection/index.tsx b/nkebao/src/components/FriendSelection/index.tsx index d67aa0ff..13090569 100644 --- a/nkebao/src/components/FriendSelection/index.tsx +++ b/nkebao/src/components/FriendSelection/index.tsx @@ -1,11 +1,7 @@ import React, { useState, useEffect } from "react"; -import { - SearchOutlined, - CloseOutlined, - LeftOutlined, - RightOutlined, -} from "@ant-design/icons"; -import { Input, Button, Popup, Toast } from "antd-mobile"; +import { SearchOutlined, DeleteOutlined } from "@ant-design/icons"; +import { Popup, Toast } from "antd-mobile"; +import { Button, Input } from "antd"; import { getFriendList } from "./api"; import style from "./index.module.scss"; @@ -29,6 +25,10 @@ interface FriendSelectionProps { className?: string; visible?: boolean; // 新增 onVisibleChange?: (visible: boolean) => void; // 新增 + selectedListMaxHeight?: number; + showInput?: boolean; + showSelectedList?: boolean; + readonly?: boolean; } export default function FriendSelection({ @@ -41,6 +41,10 @@ export default function FriendSelection({ className = "", visible, onVisibleChange, + selectedListMaxHeight = 300, + showInput = true, + showSelectedList = true, + readonly = false, }: FriendSelectionProps) { const [popupVisible, setPopupVisible] = useState(false); const [friends, setFriends] = useState([]); @@ -59,6 +63,7 @@ export default function FriendSelection({ // 打开弹窗并请求第一页好友 const openPopup = () => { + if (readonly) return; setCurrentPage(1); setSearchQuery(""); setRealVisible(true); @@ -152,6 +157,17 @@ export default function FriendSelection({ return `已选择 ${selectedFriends.length} 个好友`; }; + // 获取已选好友详细信息 + const selectedFriendObjs = selectedFriends + .map((id) => friends.find((f) => f.id === id)) + .filter(Boolean) as WechatFriend[]; + + // 删除已选好友 + const handleRemoveFriend = (id: string) => { + if (readonly) return; + onSelect(selectedFriends.filter((f) => f !== id)); + }; + const handleConfirm = () => { setPopupVisible(false); }; @@ -166,35 +182,85 @@ export default function FriendSelection({ return ( <> {/* 输入框 */} -
- - - - - - -
- - {/* 微信好友选择弹窗 */} + {showInput && ( +
+ } + allowClear={!readonly} + size="large" + readOnly={readonly} + disabled={readonly} + style={ + readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {} + } + /> +
+ )} + {/* 已选好友列表窗口 */} + {showSelectedList && selectedFriendObjs.length > 0 && ( +
+ {selectedFriendObjs.map((friend) => ( +
+
+ {friend.nickname || friend.wechatId || friend.id} +
+ {!readonly && ( +
+ ))} +
+ )} + {/* 弹窗 */} setRealVisible(false)} position="bottom" bodyStyle={{ height: "100vh" }} @@ -206,23 +272,33 @@ export default function FriendSelection({ setSearchQuery(val)} - className={style.searchInput} + onChange={(e) => setSearchQuery(e.target.value)} + disabled={readonly} + prefix={} + allowClear + size="large" /> - - {searchQuery && ( + {searchQuery && !readonly && ( + style={{ + color: "#ff4d4f", + border: "none", + background: "none", + minWidth: 24, + height: 24, + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + /> )}
-
{loading ? (
@@ -234,7 +310,7 @@ export default function FriendSelection({