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

优化弹窗
This commit is contained in:
笔记本里的永平
2025-07-23 13:48:16 +08:00
parent 55ba56fcf1
commit 4804bab90a
15 changed files with 723 additions and 476 deletions

View File

@@ -0,0 +1,26 @@
// 设备选择项接口
export interface DeviceSelectionItem {
id: string;
name: string;
imei: string;
wechatId: string;
status: "online" | "offline";
wxid?: string;
nickname?: string;
usedInPlans?: number;
}
// 组件属性接口
export interface DeviceSelectionProps {
selectedDevices: string[];
onSelect: (devices: string[]) => void;
placeholder?: string;
className?: string;
mode?: "input" | "dialog"; // 新增默认input
open?: boolean; // 仅mode=dialog时生效
onOpenChange?: (open: boolean) => void; // 仅mode=dialog时生效
selectedListMaxHeight?: number; // 新增已选列表最大高度默认500
showInput?: boolean; // 新增
showSelectedList?: boolean; // 新增
readonly?: boolean; // 新增
}

View File

@@ -19,12 +19,7 @@
background: #f8f9fa;
}
.popupContainer {
display: flex;
flex-direction: column;
height: 100vh;
background: #fff;
}
.popupHeader {
padding: 16px;
border-bottom: 1px solid #f0f0f0;

View File

@@ -1,39 +1,10 @@
import React, { useState, useEffect, useCallback } from "react";
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
import { Checkbox, Popup, Toast } from "antd-mobile";
import React, { useState } from "react";
import { SearchOutlined } from "@ant-design/icons";
import { Input, Button } from "antd";
import { getDeviceList } from "./api";
import style from "./index.module.scss";
import { DeleteOutlined } from "@ant-design/icons";
// 设备选择项接口
interface DeviceSelectionItem {
id: string;
name: string;
imei: string;
wechatId: string;
status: "online" | "offline";
wxid?: string;
nickname?: string;
usedInPlans?: number;
}
// 组件属性接口
interface DeviceSelectionProps {
selectedDevices: string[];
onSelect: (devices: string[]) => void;
placeholder?: string;
className?: string;
mode?: "input" | "dialog"; // 新增默认input
open?: boolean; // 仅mode=dialog时生效
onOpenChange?: (open: boolean) => void; // 仅mode=dialog时生效
selectedListMaxHeight?: number; // 新增已选列表最大高度默认500
showInput?: boolean; // 新增
showSelectedList?: boolean; // 新增
readonly?: boolean; // 新增
}
const PAGE_SIZE = 20;
import { DeviceSelectionProps } from "./data";
import SelectionPopup from "./selectionPopup";
import style from "./index.module.scss";
const DeviceSelection: React.FC<DeviceSelectionProps> = ({
selectedDevices,
@@ -57,92 +28,10 @@ const DeviceSelection: React.FC<DeviceSelectionProps> = ({
if (!isDialog) setPopupVisible(v);
};
// 设备数据
const [devices, setDevices] = useState<DeviceSelectionItem[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [total, setTotal] = useState(0);
// 获取设备列表支持keyword和分页
const fetchDevices = useCallback(
async (keyword: string = "", page: number = 1) => {
setLoading(true);
try {
const res = await getDeviceList({
page,
limit: PAGE_SIZE,
keyword: keyword.trim() || undefined,
});
if (res && Array.isArray(res.list)) {
setDevices(
res.list.map((d: any) => ({
id: d.id?.toString() || "",
name: d.memo || d.imei || "",
imei: d.imei || "",
wechatId: d.wechatId || "",
status: d.alive === 1 ? "online" : "offline",
wxid: d.wechatId || "",
nickname: d.nickname || "",
usedInPlans: d.usedInPlans || 0,
}))
);
setTotal(res.total || 0);
}
} catch (error) {
console.error("获取设备列表失败:", error);
} finally {
setLoading(false);
}
},
[]
);
// 打开弹窗时获取第一页
// 打开弹窗
const openPopup = () => {
if (readonly) return;
setSearchQuery("");
setCurrentPage(1);
setRealVisible(true);
fetchDevices("", 1);
};
// 搜索防抖
useEffect(() => {
if (!realVisible) return;
const timer = setTimeout(() => {
setCurrentPage(1);
fetchDevices(searchQuery, 1);
}, 500);
return () => clearTimeout(timer);
}, [searchQuery, realVisible, fetchDevices]);
// 翻页时重新请求
useEffect(() => {
if (!realVisible) return;
fetchDevices(searchQuery, currentPage);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPage]);
// 过滤设备(只保留状态过滤)
const filteredDevices = devices.filter((device) => {
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "online" && device.status === "online") ||
(statusFilter === "offline" && device.status === "offline");
return matchesStatus;
});
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
// 处理设备选择
const handleDeviceToggle = (deviceId: string) => {
if (selectedDevices.includes(deviceId)) {
onSelect(selectedDevices.filter((id) => id !== deviceId));
} else {
onSelect([...selectedDevices, deviceId]);
}
};
// 获取显示文本
@@ -151,138 +40,12 @@ const DeviceSelection: React.FC<DeviceSelectionProps> = ({
return `已选择 ${selectedDevices.length} 个设备`;
};
// 获取已选设备详细信息
const selectedDeviceObjs = selectedDevices
.map((id) => devices.find((d) => d.id === id))
.filter(Boolean) as DeviceSelectionItem[];
// 删除已选设备
const handleRemoveDevice = (id: string) => {
if (readonly) return;
onSelect(selectedDevices.filter((d) => d !== id));
};
// 弹窗内容
const popupContent = (
<div className={style.popupContainer}>
<div className={style.popupHeader}>
<div className={style.popupTitle}></div>
</div>
<div className={style.popupSearchRow}>
<div className={style.popupSearchInputWrap}>
<SearchOutlined className={style.inputIcon} />
<Input
placeholder="搜索设备IMEI/备注/微信号"
value={searchQuery}
onChange={(val) => setSearchQuery(val)}
className={style.popupSearchInput}
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className={style.statusSelect}
>
<option value="all"></option>
<option value="online">线</option>
<option value="offline">线</option>
</select>
<Button
fill="outline"
size="mini"
onClick={() => fetchDevices(searchQuery, currentPage)}
disabled={loading}
className={style.refreshBtn}
>
{loading ? (
<div className={style.loadingIcon}></div>
) : (
<ReloadOutlined />
)}
</Button>
</div>
<div className={style.deviceList}>
{loading ? (
<div className={style.loadingBox}>
<div className={style.loadingText}>...</div>
</div>
) : (
<div className={style.deviceListInner}>
{filteredDevices.map((device) => (
<label key={device.id} className={style.deviceItem}>
<Checkbox
checked={selectedDevices.includes(device.id)}
onChange={() => handleDeviceToggle(device.id)}
className={style.deviceCheckbox}
/>
<div className={style.deviceInfo}>
<div className={style.deviceInfoRow}>
<span className={style.deviceName}>{device.name}</span>
<div
className={
device.status === "online"
? style.statusOnline
: style.statusOffline
}
>
{device.status === "online" ? "在线" : "离线"}
</div>
</div>
<div className={style.deviceInfoDetail}>
<div>IMEI: {device.imei}</div>
<div>: {device.wechatId}</div>
</div>
</div>
</label>
))}
</div>
)}
</div>
{/* 分页栏 */}
<div className={style.paginationRow}>
<div className={style.totalCount}> {total} </div>
<div className={style.paginationControls}>
<Button
fill="none"
size="mini"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1 || loading}
className={style.pageBtn}
>
&lt;
</Button>
<span className={style.pageInfo}>
{currentPage} / {totalPages}
</span>
<Button
fill="none"
size="mini"
onClick={() =>
setCurrentPage(Math.min(totalPages, currentPage + 1))
}
disabled={currentPage === totalPages || loading}
className={style.pageBtn}
>
&gt;
</Button>
</div>
</div>
<div className={style.popupFooter}>
<div className={style.selectedCount}>
{selectedDevices.length}
</div>
<div className={style.footerBtnGroup}>
<Button fill="outline" onClick={() => setRealVisible(false)}>
</Button>
<Button color="primary" onClick={() => setRealVisible(false)}>
</Button>
</div>
</div>
</div>
);
return (
<>
{/* mode=input 显示输入框mode=dialog不显示 */}
@@ -304,75 +67,71 @@ const DeviceSelection: React.FC<DeviceSelectionProps> = ({
</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) => (
{mode === "input" && showSelectedList && selectedDevices.length > 0 && (
<div
className={style.selectedListWindow}
style={{
maxHeight: selectedListMaxHeight,
overflowY: "auto",
marginTop: 8,
border: "1px solid #e5e6eb",
borderRadius: 8,
background: "#fff",
}}
>
{selectedDevices.map((deviceId) => (
<div
key={deviceId}
className={style.selectedListRow}
style={{
display: "flex",
alignItems: "center",
padding: "4px 8px",
borderBottom: "1px solid #f0f0f0",
fontSize: 14,
}}
>
<div
key={device.id}
className={style.selectedListRow}
style={{
display: "flex",
alignItems: "center",
padding: "4px 8px",
borderBottom: "1px solid #f0f0f0",
fontSize: 14,
flex: 1,
minWidth: 0,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
<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)}
/>
)}
{deviceId}
</div>
))}
</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(deviceId)}
/>
)}
</div>
))}
</div>
)}
{/* 弹窗 */}
<Popup
<SelectionPopup
visible={realVisible && !readonly}
onMaskClick={() => setRealVisible(false)}
position="bottom"
bodyStyle={{ height: "100vh" }}
>
{popupContent}
</Popup>
onClose={() => setRealVisible(false)}
selectedDevices={selectedDevices}
onSelect={onSelect}
/>
</>
);
};

View File

@@ -1,7 +1,10 @@
import React from "react";
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
import { Input, Button, Checkbox, Popup } from "antd-mobile";
import React, { useState, useEffect, useCallback } from "react";
import { Checkbox, Popup } from "antd-mobile";
import { getDeviceList } from "./api";
import style from "./index.module.scss";
import Layout from "@/components/Layout/Layout";
import PopupHeader from "@/components/PopuLayout/header";
import PopupFooter from "@/components/PopuLayout/footer";
interface DeviceSelectionItem {
id: string;
@@ -19,42 +22,95 @@ interface SelectionPopupProps {
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 PAGE_SIZE = 20;
const SelectionPopup: React.FC<SelectionPopupProps> = ({
visible,
onClose,
selectedDevices,
onSelect,
devices,
loading,
searchQuery,
setSearchQuery,
statusFilter,
setStatusFilter,
onRefresh,
filteredDevices,
total,
currentPage,
totalPages,
setCurrentPage,
onCancel,
onConfirm,
}) => {
// 设备数据
const [devices, setDevices] = useState<DeviceSelectionItem[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [total, setTotal] = useState(0);
// 获取设备列表支持keyword和分页
const fetchDevices = useCallback(
async (keyword: string = "", page: number = 1) => {
setLoading(true);
try {
const res = await getDeviceList({
page,
limit: PAGE_SIZE,
keyword: keyword.trim() || undefined,
});
if (res && Array.isArray(res.list)) {
setDevices(
res.list.map((d: any) => ({
id: d.id?.toString() || "",
name: d.memo || d.imei || "",
imei: d.imei || "",
wechatId: d.wechatId || "",
status: d.alive === 1 ? "online" : "offline",
wxid: d.wechatId || "",
nickname: d.nickname || "",
usedInPlans: d.usedInPlans || 0,
}))
);
setTotal(res.total || 0);
}
} catch (error) {
console.error("获取设备列表失败:", error);
} finally {
setLoading(false);
}
},
[]
);
// 打开弹窗时获取第一页
useEffect(() => {
if (visible) {
setSearchQuery("");
setCurrentPage(1);
fetchDevices("", 1);
}
}, [visible, fetchDevices]);
// 搜索防抖
useEffect(() => {
if (!visible) return;
const timer = setTimeout(() => {
setCurrentPage(1);
fetchDevices(searchQuery, 1);
}, 500);
return () => clearTimeout(timer);
}, [searchQuery, visible, fetchDevices]);
// 翻页时重新请求
useEffect(() => {
if (!visible) return;
fetchDevices(searchQuery, currentPage);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPage]);
// 过滤设备(只保留状态过滤)
const filteredDevices = devices.filter((device) => {
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "online" && device.status === "online") ||
(statusFilter === "offline" && device.status === "offline");
return matchesStatus;
});
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
// 处理设备选择
const handleDeviceToggle = (deviceId: string) => {
if (selectedDevices.includes(deviceId)) {
@@ -67,49 +123,45 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
return (
<Popup
visible={visible}
// 禁止点击遮罩关闭
onMaskClick={() => {}}
onMaskClick={onClose}
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>
<Layout
header={
<PopupHeader
title="选择设备"
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
searchPlaceholder="搜索设备IMEI/备注/微信号"
loading={loading}
onRefresh={() => fetchDevices(searchQuery, currentPage)}
showTabs={true}
tabsConfig={{
activeKey: statusFilter,
onChange: setStatusFilter,
tabs: [
{ title: "全部", key: "all" },
{ title: "在线", key: "online" },
{ title: "离线", key: "offline" },
],
}}
/>
}
footer={
<PopupFooter
total={total}
currentPage={currentPage}
totalPages={totalPages}
loading={loading}
selectedCount={selectedDevices.length}
onPageChange={setCurrentPage}
onCancel={onClose}
onConfirm={onClose}
/>
}
>
<div className={style.deviceList}>
{loading ? (
<div className={style.loadingBox}>
@@ -147,49 +199,7 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
</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>
</Layout>
</Popup>
);
};

View File

@@ -2,11 +2,11 @@ import React, { useState, useEffect } from "react";
import {
SearchOutlined,
CloseOutlined,
LeftOutlined,
RightOutlined,
ArrowLeftOutlined,
ArrowRightOutlined,
DeleteOutlined,
} from "@ant-design/icons";
import { Button as AntdButton, Input as AntdInput } from "antd";
import { Button, Input } from "antd";
import { Popup, Toast } from "antd-mobile";
import { getGroupList } from "./api";
import style from "./index.module.scss";
@@ -182,7 +182,7 @@ export default function GroupSelection({
{/* 输入框 */}
{showInput && (
<div className={`${style.inputWrapper} ${className}`}>
<AntdInput
<Input
placeholder={placeholder}
value={getDisplayText()}
onClick={openPopup}
@@ -234,7 +234,7 @@ export default function GroupSelection({
{group.name || group.chatroomId || group.id}
</div>
{!readonly && (
<AntdButton
<Button
type="text"
icon={<DeleteOutlined />}
size="small"
@@ -267,7 +267,7 @@ export default function GroupSelection({
<div className={style.popupHeader}>
<div className={style.popupTitle}></div>
<div className={style.searchWrapper}>
<AntdInput
<Input
placeholder="搜索群聊"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
@@ -278,7 +278,7 @@ export default function GroupSelection({
/>
<SearchOutlined className={style.searchIcon} />
{searchQuery && !readonly && (
<AntdButton
<Button
type="text"
icon={<CloseOutlined />}
size="small"
@@ -365,7 +365,7 @@ export default function GroupSelection({
<div className={style.paginationRow}>
<div className={style.totalCount}> {totalGroups} </div>
<div className={style.paginationControls}>
<AntdButton
<Button
type="text"
size="small"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
@@ -373,12 +373,12 @@ export default function GroupSelection({
className={style.pageBtn}
style={{ borderRadius: 16 }}
>
<LeftOutlined />
</AntdButton>
<ArrowLeftOutlined />
</Button>
<span className={style.pageInfo}>
{currentPage} / {totalPages}
</span>
<AntdButton
<Button
type="text"
size="small"
onClick={() =>
@@ -388,8 +388,8 @@ export default function GroupSelection({
className={style.pageBtn}
style={{ borderRadius: 16 }}
>
<RightOutlined />
</AntdButton>
<ArrowRightOutlined />
</Button>
</div>
</div>
{/* 底部按钮栏 */}
@@ -398,12 +398,12 @@ export default function GroupSelection({
{selectedGroups.length}
</div>
<div className={style.footerBtnGroup}>
<AntdButton type="default" onClick={() => setRealVisible(false)}>
<Button type="default" onClick={() => setRealVisible(false)}>
</AntdButton>
<AntdButton type="primary" onClick={handleConfirm}>
</Button>
<Button type="primary" onClick={handleConfirm}>
</AntdButton>
</Button>
</div>
</div>
</div>

View File

@@ -0,0 +1,41 @@
import React from "react";
import { NavBar } from "antd-mobile";
import { ArrowLeftOutlined } from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
interface NavCommonProps {
title: string;
backFn?: () => void;
right?: React.ReactNode;
}
const NavCommon: React.FC<NavCommonProps> = ({ title, backFn, right }) => {
const navigate = useNavigate();
return (
<NavBar
back={null}
style={{ background: "#fff" }}
left={
<div className="nav-title">
<ArrowLeftOutlined
twoToneColor="#1677ff"
onClick={() => {
if (backFn) {
backFn();
} else {
navigate(-1);
}
}}
/>
</div>
}
right={right}
>
<span style={{ color: "var(--primary-color)", fontWeight: 600 }}>
{title}
</span>
</NavBar>
);
};
export default NavCommon;

View File

@@ -0,0 +1,71 @@
.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;
}
.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;
border: 1px solid #d9d9d9;
color: #333;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
&:hover:not(:disabled) {
border-color: #1677ff;
color: #1677ff;
}
&:disabled {
background: #f5f5f5;
color: #ccc;
cursor: not-allowed;
}
}
.pageInfo {
font-size: 14px;
color: #222;
margin: 0 8px;
min-width: 60px;
text-align: center;
}

View File

@@ -0,0 +1,67 @@
import React from "react";
import { Button } from "antd";
import style from "./footer.module.scss";
import { ArrowLeftOutlined, ArrowRightOutlined } from "@ant-design/icons";
interface PopupFooterProps {
total: number;
currentPage: number;
totalPages: number;
loading: boolean;
selectedCount: number;
onPageChange: (page: number) => void;
onCancel: () => void;
onConfirm: () => void;
}
const PopupFooter: React.FC<PopupFooterProps> = ({
total,
currentPage,
totalPages,
loading,
selectedCount,
onPageChange,
onCancel,
onConfirm,
}) => {
return (
<>
{/* 分页栏 */}
<div className={style.paginationRow}>
<div className={style.totalCount}> {total} </div>
<div className={style.paginationControls}>
<Button
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={currentPage === 1 || loading}
className={style.pageBtn}
>
<ArrowLeftOutlined />
</Button>
<span className={style.pageInfo}>
{currentPage} / {totalPages}
</span>
<Button
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages || loading}
className={style.pageBtn}
>
<ArrowRightOutlined />
</Button>
</div>
</div>
<div className={style.popupFooter}>
<div className={style.selectedCount}> {selectedCount} </div>
<div className={style.footerBtnGroup}>
<Button color="primary" variant="filled" onClick={onCancel}>
</Button>
<Button type="primary" onClick={onConfirm}>
</Button>
</div>
</div>
</>
);
};
export default PopupFooter;

View File

@@ -0,0 +1,52 @@
.popupHeader {
padding: 16px;
border-bottom: 1px solid #f0f0f0;
}
.popupTitle {
font-size: 20px;
font-weight: 600;
text-align: center;
}
.popupSearchRow {
display: flex;
align-items: center;
gap: 5px;
padding: 16px;
}
.popupSearchInputWrap {
position: relative;
flex: 1;
}
.inputIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #bdbdbd;
z-index: 10;
font-size: 18px;
}
.refreshBtn {
width: 36px;
height: 36px;
}
.loadingIcon {
animation: spin 1s linear infinite;
font-size: 16px;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,86 @@
import React from "react";
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
import { Input, Button } from "antd";
import { Tabs } from "antd-mobile";
import style from "./header.module.scss";
interface PopupHeaderProps {
title: string;
searchQuery: string;
setSearchQuery: (value: string) => void;
searchPlaceholder?: string;
loading?: boolean;
onRefresh?: () => void;
showRefresh?: boolean;
showSearch?: boolean;
showTabs?: boolean;
tabsConfig?: {
activeKey: string;
onChange: (key: string) => void;
tabs: Array<{ title: string; key: string }>;
};
}
const PopupHeader: React.FC<PopupHeaderProps> = ({
title,
searchQuery,
setSearchQuery,
searchPlaceholder = "搜索...",
loading = false,
onRefresh,
showRefresh = true,
showSearch = true,
showTabs = false,
tabsConfig,
}) => {
return (
<>
<div className={style.popupHeader}>
<div className={style.popupTitle}>{title}</div>
</div>
{showSearch && (
<div className={style.popupSearchRow}>
<div className={style.popupSearchInputWrap}>
<Input
placeholder={searchPlaceholder}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
prefix={<SearchOutlined />}
size="large"
/>
</div>
{showRefresh && onRefresh && (
<Button
type="text"
onClick={onRefresh}
disabled={loading}
className={style.refreshBtn}
>
{loading ? (
<div className={style.loadingIcon}></div>
) : (
<ReloadOutlined />
)}
</Button>
)}
</div>
)}
{showTabs && tabsConfig && (
<Tabs
activeKey={tabsConfig.activeKey}
onChange={tabsConfig.onChange}
style={{ marginTop: 8 }}
>
{tabsConfig.tabs.map((tab) => (
<Tabs.Tab key={tab.key} title={tab.title} />
))}
</Tabs>
)}
</>
);
};
export default PopupHeader;

View File

@@ -0,0 +1,108 @@
import React, { useState } from "react";
import { NavBar, Tabs } from "antd-mobile";
import { ArrowLeftOutlined } from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
import Layout from "@/components/Layout/Layout";
import NavCommon from "@/components/NavCommon";
import DeviceSelection from "@/components/DeviceSelection";
import FriendSelection from "@/components/FriendSelection";
import GroupSelection from "@/components/GroupSelection";
const ComponentTest: React.FC = () => {
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState("devices");
// 设备选择状态
const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
// 好友选择状态
const [selectedFriends, setSelectedFriends] = useState<string[]>([]);
// 群组选择状态
const [selectedGroups, setSelectedGroups] = useState<string[]>([]);
return (
<Layout header={<NavCommon title="组件调试" />}>
<div style={{ padding: 16 }}>
<Tabs activeKey={activeTab} onChange={setActiveTab}>
<Tabs.Tab title="设备选择" key="devices">
<div style={{ padding: "16px 0" }}>
<h3 style={{ marginBottom: 16 }}>DeviceSelection </h3>
<DeviceSelection
selectedDevices={selectedDevices}
onSelect={setSelectedDevices}
placeholder="请选择设备"
showSelectedList={true}
selectedListMaxHeight={300}
/>
<div
style={{
marginTop: 16,
padding: 12,
background: "#f5f5f5",
borderRadius: 8,
}}
>
<strong>:</strong> {selectedDevices.length}
<br />
<strong>ID:</strong> {selectedDevices.join(", ") || "无"}
</div>
</div>
</Tabs.Tab>
<Tabs.Tab title="好友选择" key="friends">
<div style={{ padding: "16px 0" }}>
<h3 style={{ marginBottom: 16 }}>FriendSelection </h3>
<FriendSelection
selectedFriends={selectedFriends}
onSelect={setSelectedFriends}
placeholder="请选择微信好友"
showSelectedList={true}
selectedListMaxHeight={300}
/>
<div
style={{
marginTop: 16,
padding: 12,
background: "#f5f5f5",
borderRadius: 8,
}}
>
<strong>:</strong> {selectedFriends.length}
<br />
<strong>ID:</strong> {selectedFriends.join(", ") || "无"}
</div>
</div>
</Tabs.Tab>
<Tabs.Tab title="群组选择" key="groups">
<div style={{ padding: "16px 0" }}>
<h3 style={{ marginBottom: 16 }}>GroupSelection </h3>
<GroupSelection
selectedGroups={selectedGroups}
onSelect={setSelectedGroups}
placeholder="请选择微信群组"
showSelectedList={true}
selectedListMaxHeight={300}
/>
<div
style={{
marginTop: 16,
padding: 12,
background: "#f5f5f5",
borderRadius: 8,
}}
>
<strong>:</strong> {selectedGroups.length}
<br />
<strong>ID:</strong> {selectedGroups.join(", ") || "无"}
</div>
</div>
</Tabs.Tab>
</Tabs>
</div>
</Layout>
);
};
export default ComponentTest;

View File

@@ -0,0 +1,32 @@
import request from "@/api/request";
// 创建朋友圈同步任务
export const createMomentsSync = (params: {
name: string;
devices: string[];
contentLibraries: string[];
syncCount: number;
startTime: string;
endTime: string;
accountType: number;
status: number;
type: number;
}) => request("/v1/workbench/create", params, "POST");
// 更新朋友圈同步任务
export const updateMomentsSync = (params: {
id: string;
name: string;
devices: string[];
contentLibraries: string[];
syncCount: number;
startTime: string;
endTime: string;
accountType: number;
status: number;
type: number;
}) => request("/v1/workbench/update", params, "POST");
// 获取朋友圈同步任务详情
export const getMomentsSyncDetail = (id: string) =>
request("/v1/workbench/detail", { id }, "GET");

View File

@@ -84,7 +84,7 @@
}
.inputTime {
width: 90px;
width: 100px;
height: 40px;
border-radius: 8px;
font-size: 15px;

View File

@@ -7,8 +7,19 @@ import { NavBar } from "antd-mobile";
import Layout from "@/components/Layout/Layout";
import style from "./index.module.scss";
import request from "@/api/request";
import StepIndicator from "@/components/StepIndicator";
import {
createMomentsSync,
updateMomentsSync,
getMomentsSyncDetail,
} from "./api";
import DeviceSelection from "@/components/DeviceSelection";
const steps = ["基础设置", "设备选择", "内容库选择"];
const steps = [
{ id: 1, title: "基础设置", subtitle: "基础设置" },
{ id: 2, title: "设备选择", subtitle: "设备选择" },
{ id: 3, title: "内容库选择", subtitle: "内容库选择" },
];
const defaultForm = {
taskName: "",
@@ -34,7 +45,7 @@ const NewMomentsSync: React.FC = () => {
if (!id) return;
setLoading(true);
try {
const res = await request("/v1/workbench/detail", { id }, "GET");
const res = await getMomentsSyncDetail(id);
if (res) {
setFormData({
taskName: res.name,
@@ -95,11 +106,11 @@ const NewMomentsSync: React.FC = () => {
type: 2,
};
if (isEditMode && id) {
await request("/v1/workbench/update", { id, ...params }, "POST");
await updateMomentsSync({ id, ...params });
message.success("更新成功");
navigate(`/workspace/moments-sync/${id}`);
} else {
await request("/v1/workbench/create", params, "POST");
await createMomentsSync(params);
message.success("创建成功");
navigate("/workspace/moments-sync");
}
@@ -214,18 +225,14 @@ const NewMomentsSync: React.FC = () => {
return (
<div className={style.formStep}>
<div className={style.formItem}>
<Input
placeholder="选择设备"
prefix={<span className={style.searchIcon}>Q</span>}
className={style.searchInput}
onClick={() => message.info("这里应弹出设备选择器")}
readOnly
<div className={style.formLabel}></div>
<DeviceSelection
selectedDevices={formData.selectedDevices}
onSelect={(devices) => updateForm({ selectedDevices: devices })}
placeholder="请选择设备"
showSelectedList={true}
selectedListMaxHeight={200}
/>
{formData.selectedDevices.length > 0 && (
<div className={style.selectedTip}>
: {formData.selectedDevices.length}
</div>
)}
</div>
<div className={style.formStepBtnRow}>
<Button onClick={prev} className={style.prevBtn}>
@@ -294,25 +301,7 @@ const NewMomentsSync: React.FC = () => {
}
>
<div className={style.formBg}>
<div className={style.formSteps}>
{steps.map((s, i) => (
<div
key={s}
className={
style.formStepIndicator +
" " +
(i === currentStep
? style.formStepActive
: i < currentStep
? style.formStepDone
: "")
}
>
<span className={style.formStepNum}>{i + 1}</span>
<span>{s}</span>
</div>
))}
</div>
<StepIndicator currentStep={currentStep + 1} steps={steps} />
{loading ? (
<div className={style.formLoading}>
<Spin />

View File

@@ -0,0 +1,11 @@
import ComponentTest from "@/pages/component-test";
const componentTestRoutes = [
{
path: "/component-test",
element: <ComponentTest />,
auth: true,
},
];
export default componentTestRoutes;