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

功能、和样式修复
This commit is contained in:
笔记本里的永平
2025-07-22 14:37:57 +08:00
parent 34df010769
commit f33bdf42e2
14 changed files with 205 additions and 74 deletions

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

@@ -6,5 +6,5 @@ export function getDeviceList(params: {
limit: number; limit: number;
keyword?: string; keyword?: string;
}) { }) {
return request("/v1/device/list", params, "GET"); 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 {

View File

@@ -1,8 +1,9 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } 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 { Input, Button } from "antd";
import { getDeviceList } from "./api"; import { getDeviceList } from "./api";
import style from "./module.scss"; import style from "./index.module.scss";
// 设备选择项接口 // 设备选择项接口
interface DeviceSelectionItem { interface DeviceSelectionItem {
@@ -56,7 +57,6 @@ export default function DeviceSelection({
} }
} catch (error) { } catch (error) {
console.error("获取设备列表失败:", error); console.error("获取设备列表失败:", error);
Toast.show({ content: "获取设备列表失败", position: "top" });
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -106,13 +106,13 @@ export default function DeviceSelection({
<> <>
{/* 输入框 */} {/* 输入框 */}
<div className={`${style.inputWrapper} ${className}`}> <div className={`${style.inputWrapper} ${className}`}>
<SearchOutlined className={style.inputIcon} />
<Input <Input
placeholder={placeholder} placeholder={placeholder}
className={style.input}
readOnly
onClick={openPopup}
value={getDisplayText()} value={getDisplayText()}
onClick={openPopup}
prefix={<SearchOutlined />}
allowClear
size="large"
/> />
</div> </div>
@@ -121,7 +121,7 @@ export default function DeviceSelection({
visible={popupVisible} visible={popupVisible}
onMaskClick={() => setPopupVisible(false)} onMaskClick={() => setPopupVisible(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}>
@@ -129,12 +129,13 @@ export default function DeviceSelection({
</div> </div>
<div className={style.popupSearchRow}> <div className={style.popupSearchRow}>
<div className={style.popupSearchInputWrap}> <div className={style.popupSearchInputWrap}>
<SearchOutlined className={style.inputIcon} />
<Input <Input
placeholder="搜索设备IMEI/备注/微信号" placeholder="搜索设备IMEI/备注/微信号"
value={searchQuery} value={searchQuery}
onChange={(val) => setSearchQuery(val)} onChange={(e) => setSearchQuery(e.target.value)}
className={style.popupSearchInput} prefix={<SearchOutlined />}
allowClear
size="large"
/> />
</div> </div>
<select <select
@@ -146,6 +147,19 @@ export default function DeviceSelection({
<option value="online">线</option> <option value="online">线</option>
<option value="offline">线</option> <option value="offline">线</option>
</select> </select>
<Button
type="primary"
size="large"
onClick={() => fetchDevices(searchQuery)}
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 ? (

View File

@@ -6,5 +6,5 @@ export function getDeviceList(params: {
limit: number; limit: number;
keyword?: string; keyword?: string;
}) { }) {
return request("/v1/device/list", params, "GET"); 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;

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 { Input, Button } from "antd";
import { getDeviceList } from "./api"; import { getDeviceList } from "./api";
import style from "./module.scss"; import style from "./index.module.scss";
interface Device { interface Device {
id: string; id: string;
@@ -106,7 +107,7 @@ export function DeviceSelectionDialog({
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}>
@@ -114,12 +115,13 @@ export function DeviceSelectionDialog({
</div> </div>
<div className={style.popupSearchRow}> <div className={style.popupSearchRow}>
<div className={style.popupSearchInputWrap}> <div className={style.popupSearchInputWrap}>
<SearchOutlined className={style.inputIcon} />
<Input <Input
placeholder="搜索设备IMEI/备注" placeholder="搜索设备IMEI/备注/微信号"
value={searchQuery} value={searchQuery}
onChange={(val) => setSearchQuery(val)} onChange={(e) => setSearchQuery(e.target.value)}
className={style.popupSearchInput} prefix={<SearchOutlined />}
allowClear
size="large"
/> />
</div> </div>
<select <select
@@ -132,8 +134,8 @@ export function DeviceSelectionDialog({
<option value="offline">线</option> <option value="offline">线</option>
</select> </select>
<Button <Button
fill="outline" type="primary"
size="mini" size="large"
onClick={() => fetchDevices(searchQuery)} onClick={() => fetchDevices(searchQuery)}
disabled={loading} disabled={loading}
className={style.refreshBtn} className={style.refreshBtn}

View File

@@ -4,7 +4,7 @@ import request from "@/api/request";
export function getFriendList(params: { export function getFriendList(params: {
page: number; page: number;
limit: number; limit: number;
deviceIds?: string; deviceIds?: string; // 逗号分隔
keyword?: string; keyword?: string;
}) { }) {
return request("/v1/friend", params, "GET"); 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

@@ -7,7 +7,7 @@ import {
} 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 { getFriendList } from "./api"; 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 = "") => {
@@ -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

@@ -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

@@ -7,7 +7,7 @@ import {
} 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 { getGroupList } from "./api"; 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 = "") => {
@@ -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;