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

View File

@@ -6,5 +6,5 @@ export function getDeviceList(params: {
limit: number;
keyword?: string;
}) {
return request("/v1/device/list", params, "GET");
return request("/v1/devices", params, "GET");
}

View File

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

View File

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

View File

@@ -6,5 +6,5 @@ export function getDeviceList(params: {
limit: number;
keyword?: string;
}) {
return request("/v1/device/list", params, "GET");
return request("/v1/devices", params, "GET");
}

View File

@@ -1,7 +1,7 @@
.popupContainer {
display: flex;
flex-direction: column;
height: 100%;
height: 100vh;
background: #fff;
}
.popupHeader {
@@ -49,11 +49,6 @@
padding: 0 12px;
background: #fff;
}
.refreshBtn {
min-width: 40px;
height: 40px;
border-radius: 8px;
}
.loadingIcon {
animation: spin 1s linear infinite;
font-size: 16px;

View File

@@ -1,8 +1,9 @@
import React, { useState, useEffect, useCallback } from "react";
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
import { Input, Button, Checkbox, Popup, Toast } from "antd-mobile";
import { Checkbox, Popup, Toast } from "antd-mobile";
import { Input, Button } from "antd";
import { getDeviceList } from "./api";
import style from "./module.scss";
import style from "./index.module.scss";
interface Device {
id: string;
@@ -106,7 +107,7 @@ export function DeviceSelectionDialog({
visible={open}
onMaskClick={() => onOpenChange(false)}
position="bottom"
bodyStyle={{ height: "80vh" }}
bodyStyle={{ height: "100vh" }}
>
<div className={style.popupContainer}>
<div className={style.popupHeader}>
@@ -114,12 +115,13 @@ export function DeviceSelectionDialog({
</div>
<div className={style.popupSearchRow}>
<div className={style.popupSearchInputWrap}>
<SearchOutlined className={style.inputIcon} />
<Input
placeholder="搜索设备IMEI/备注"
placeholder="搜索设备IMEI/备注/微信号"
value={searchQuery}
onChange={(val) => setSearchQuery(val)}
className={style.popupSearchInput}
onChange={(e) => setSearchQuery(e.target.value)}
prefix={<SearchOutlined />}
allowClear
size="large"
/>
</div>
<select
@@ -132,8 +134,8 @@ export function DeviceSelectionDialog({
<option value="offline">线</option>
</select>
<Button
fill="outline"
size="mini"
type="primary"
size="large"
onClick={() => fetchDevices(searchQuery)}
disabled={loading}
className={style.refreshBtn}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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