FEAT => 本次更新项目为:重构帳號選擇組件,簡化狀態管理,並更新測試頁面以使用新接口

This commit is contained in:
超级老白兔
2025-08-08 14:59:59 +08:00
parent a06e691420
commit 8749a59957
5 changed files with 289 additions and 247 deletions

View File

@@ -0,0 +1,34 @@
// 账号对象类型
export interface AccountItem {
id: number;
userName: string;
realName: string;
departmentName: string;
avatar?: string;
[key: string]: any;
}
//弹窗的
export interface SelectionPopupProps {
visible: boolean;
onVisibleChange: (visible: boolean) => void;
selectedOptions: AccountItem[];
onSelect: (options: AccountItem[]) => void;
readonly?: boolean;
onConfirm?: (selectedOptions: AccountItem[]) => void;
}
// 组件属性接口
export interface AccountSelectionProps {
selectedOptions: AccountItem[];
onSelect: (options: AccountItem[]) => void;
accounts?: AccountItem[]; // 可选:用于在外层显示已选账号详情
placeholder?: string;
className?: string;
visible?: boolean;
onVisibleChange?: (visible: boolean) => void;
selectedListMaxHeight?: number;
showInput?: boolean;
showSelectedList?: boolean;
readonly?: boolean;
onConfirm?: (selectedOptions: AccountItem[]) => void;
}

View File

@@ -1,42 +1,13 @@
import React, { useState, useEffect } from "react";
import React, { useState } from "react";
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
import { Popup } from "antd-mobile";
import { Button, Input } from "antd";
import { getAccountList } 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";
// 账号对象类型
export interface AccountItem {
id: number;
userName: string;
realName: string;
departmentName: string;
avatar?: string;
[key: string]: any;
}
// 组件属性接口
interface AccountSelectionProps {
value: number[];
onChange: (ids: number[]) => void;
accounts?: AccountItem[];
placeholder?: string;
className?: string;
visible?: boolean;
onVisibleChange?: (visible: boolean) => void;
selectedListMaxHeight?: number;
showInput?: boolean;
showSelectedList?: boolean;
readonly?: boolean;
onConfirm?: (selectedIds: number[], selectedItems: AccountItem[]) => void;
}
import SelectionPopup from "./selectionPopup";
import { AccountItem, AccountSelectionProps } from "./data";
export default function AccountSelection({
value,
onChange,
selectedOptions,
onSelect,
accounts: propAccounts = [],
placeholder = "选择账号",
className = "",
@@ -49,10 +20,6 @@ export default function AccountSelection({
onConfirm,
}: AccountSelectionProps) {
const [popupVisible, setPopupVisible] = useState(false);
const [accountsList, setAccountsList] = useState<AccountItem[]>(propAccounts);
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [loading, setLoading] = useState(false);
// 受控弹窗逻辑
const realVisible = visible !== undefined ? visible : popupVisible;
@@ -61,110 +28,22 @@ export default function AccountSelection({
if (visible === undefined) setPopupVisible(v);
};
// 打开弹窗时先显示弹窗再请求账号数据
// 打开弹窗
const openPopup = () => {
if (readonly) return;
setCurrentPage(1);
setSearchQuery("");
setRealVisible(true);
setTimeout(async () => {
if (typeof getAccountList === "function") {
setLoading(true);
try {
const response = await getAccountList({
page: 1,
limit: 100,
keyword: "",
});
if (response && response.list) {
setAccountsList(response.list);
}
} catch (e) {
} finally {
setLoading(false);
}
}
}, 0);
};
// 搜索时请求账号数据
useEffect(() => {
if (!realVisible) return;
const timer = setTimeout(async () => {
setCurrentPage(1);
if (typeof getAccountList === "function") {
setLoading(true);
try {
const response = await getAccountList({
page: 1,
limit: 100,
keyword: searchQuery,
});
if (response && response.list) {
setAccountsList(response.list);
}
} catch (e) {
} finally {
setLoading(false);
}
}
}, 400);
return () => clearTimeout(timer);
}, [searchQuery, realVisible]);
// 渲染和过滤都依赖内部accountsList
const filteredAccounts = accountsList.filter(
acc =>
acc.userName.includes(searchQuery) ||
acc.realName.includes(searchQuery) ||
acc.departmentName.includes(searchQuery),
);
// 处理账号选择
const handleAccountToggle = (accountId: number) => {
if (readonly) return;
const uniqueValue = [...new Set(value)];
const newSelected = uniqueValue.includes(accountId)
? uniqueValue.filter(id => id !== accountId)
: [...uniqueValue, accountId];
onChange(newSelected);
};
// 获取显示文本
const getDisplayText = () => {
const uniqueValue = [...new Set(value)];
if (uniqueValue.length === 0) return "";
return `已选择 ${uniqueValue.length} 个账号`;
if (selectedOptions.length === 0) return "";
return `已选择 ${selectedOptions.length} 个账号`;
};
// 获取已选账号详细信息 - 去重处理
const uniqueValue = [...new Set(value)];
const selectedAccountObjs = [
...accountsList.filter(acc => uniqueValue.includes(acc.id)),
...uniqueValue
.filter(id => !accountsList.some(acc => acc.id === id))
.map(id => ({
id,
userName: String(id),
realName: "",
departmentName: "",
})),
];
// 删除已选账号
const handleRemoveAccount = (id: number) => {
if (readonly) return;
const uniqueValue = [...new Set(value)];
onChange(uniqueValue.filter(d => d !== id));
};
// 确认选择
const handleConfirm = () => {
if (onConfirm) {
const uniqueValue = [...new Set(value)];
onConfirm(uniqueValue, selectedAccountObjs);
}
setRealVisible(false);
onSelect(selectedOptions.filter(d => d.id !== id));
};
return (
@@ -188,7 +67,7 @@ export default function AccountSelection({
</div>
)}
{/* 已选账号列表窗口 */}
{showSelectedList && selectedAccountObjs.length > 0 && (
{showSelectedList && selectedOptions.length > 0 && (
<div
className={style.selectedListWindow}
style={{
@@ -200,7 +79,7 @@ export default function AccountSelection({
background: "#fff",
}}
>
{selectedAccountObjs.map(acc => (
{selectedOptions.map(acc => (
<div
key={acc.id}
className={style.selectedListRow}
@@ -247,99 +126,14 @@ export default function AccountSelection({
</div>
)}
{/* 弹窗 */}
<Popup
visible={realVisible && !readonly}
onMaskClick={() => setRealVisible(false)}
position="bottom"
bodyStyle={{ height: "100vh" }}
>
<Layout
header={
<PopupHeader
title="选择账号"
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
searchPlaceholder="搜索账号"
loading={loading}
onRefresh={() => {}}
/>
}
footer={
<PopupFooter
total={filteredAccounts.length}
currentPage={currentPage}
totalPages={1}
loading={loading}
selectedCount={uniqueValue.length}
onPageChange={setCurrentPage}
onCancel={() => setRealVisible(false)}
onConfirm={handleConfirm}
/>
}
>
<div className={style.friendList}>
{loading ? (
<div className={style.loadingBox}>
<div className={style.loadingText}>...</div>
</div>
) : filteredAccounts.length > 0 ? (
<div className={style.friendListInner}>
{filteredAccounts.map(acc => (
<label
key={acc.id}
className={style.friendItem}
onClick={() => !readonly && handleAccountToggle(acc.id)}
>
<div className={style.radioWrapper}>
<div
className={
uniqueValue.includes(acc.id)
? style.radioSelected
: style.radioUnselected
}
>
{uniqueValue.includes(acc.id) && (
<div className={style.radioDot}></div>
)}
</div>
</div>
<div className={style.friendInfo}>
<div className={style.friendAvatar}>
{acc.avatar ? (
<img
src={acc.avatar}
alt={acc.userName}
className={style.avatarImg}
/>
) : (
acc.userName.charAt(0)
)}
</div>
<div className={style.friendDetail}>
<div className={style.friendName}>{acc.userName}</div>
<div className={style.friendId}>
: {acc.realName}
</div>
<div className={style.friendId}>
: {acc.departmentName}
</div>
</div>
</div>
</label>
))}
</div>
) : (
<div className={style.emptyBox}>
<div className={style.emptyText}>
{searchQuery
? `没有找到包含"${searchQuery}"的账号`
: "没有找到账号"}
</div>
</div>
)}
</div>
</Layout>
</Popup>
<SelectionPopup
visible={realVisible}
onVisibleChange={setRealVisible}
selectedOptions={selectedOptions}
onSelect={onSelect}
readonly={readonly}
onConfirm={onConfirm}
/>
</>
);
}

View File

@@ -0,0 +1,202 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Popup } from "antd-mobile";
import Layout from "@/components/Layout/Layout";
import PopupHeader from "@/components/PopuLayout/header";
import PopupFooter from "@/components/PopuLayout/footer";
import style from "./index.module.scss";
import { getAccountList } from "./api";
import { AccountItem, SelectionPopupProps } from "./data";
export default function SelectionPopup({
visible,
onVisibleChange,
selectedOptions,
onSelect,
readonly = false,
onConfirm,
}: SelectionPopupProps) {
const [accounts, setAccounts] = useState<AccountItem[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalAccounts, setTotalAccounts] = useState(0);
const [loading, setLoading] = useState(false);
// 累积已加载过的账号,确保确认时能返回更完整的对象
const loadedAccountMapRef = useRef<Map<number, AccountItem>>(new Map());
const pageSize = 20;
const fetchAccounts = async (page: number, keyword: string = "") => {
setLoading(true);
try {
const params: any = { page, limit: pageSize };
if (keyword.trim()) params.keyword = keyword.trim();
const response = await getAccountList(params);
if (response && response.list) {
setAccounts(response.list);
const total: number = response.total || response.list.length || 0;
setTotalAccounts(total);
setTotalPages(Math.max(1, Math.ceil(total / pageSize)));
// 累积到映射表
response.list.forEach((acc: AccountItem) => {
loadedAccountMapRef.current.set(acc.id, acc);
});
} else {
setAccounts([]);
setTotalAccounts(0);
setTotalPages(1);
}
} catch (error) {
console.error("获取账号列表失败:", error);
} finally {
setLoading(false);
}
};
const handleAccountToggle = (account: AccountItem) => {
if (readonly || !onSelect) return;
const isSelected = selectedOptions.some(opt => opt.id === account.id);
const next = isSelected
? selectedOptions.filter(opt => opt.id !== account.id)
: selectedOptions.concat(account);
onSelect(next);
};
const handleConfirm = () => {
if (onConfirm) {
onConfirm(selectedOptions);
}
onVisibleChange(false);
};
// 弹窗打开时初始化数据
useEffect(() => {
if (visible) {
setCurrentPage(1);
setSearchQuery("");
loadedAccountMapRef.current.clear();
fetchAccounts(1, "");
}
}, [visible]);
// 搜索防抖
useEffect(() => {
if (!visible) return;
if (searchQuery === "") return;
const timer = setTimeout(() => {
setCurrentPage(1);
fetchAccounts(1, searchQuery);
}, 500);
return () => clearTimeout(timer);
}, [searchQuery, visible]);
// 页码变化
useEffect(() => {
if (!visible || currentPage === 1) return;
fetchAccounts(currentPage, searchQuery);
}, [currentPage, visible, searchQuery]);
const selectedIdSet = useMemo(
() => new Set(selectedOptions.map(opt => opt.id)),
[selectedOptions],
);
return (
<Popup
visible={visible && !readonly}
onMaskClick={() => onVisibleChange(false)}
position="bottom"
bodyStyle={{ height: "100vh" }}
>
<Layout
header={
<PopupHeader
title="选择账号"
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
searchPlaceholder="搜索账号"
loading={loading}
onRefresh={() => fetchAccounts(currentPage, searchQuery)}
/>
}
footer={
<PopupFooter
total={totalAccounts}
currentPage={currentPage}
totalPages={totalPages}
loading={loading}
selectedCount={selectedOptions.length}
onPageChange={setCurrentPage}
onCancel={() => onVisibleChange(false)}
onConfirm={handleConfirm}
/>
}
>
<div className={style.friendList}>
{loading ? (
<div className={style.loadingBox}>
<div className={style.loadingText}>...</div>
</div>
) : accounts.length > 0 ? (
<div className={style.friendListInner}>
{accounts.map(acc => (
<label
key={acc.id}
className={style.friendItem}
onClick={() => !readonly && handleAccountToggle(acc)}
>
<div className={style.radioWrapper}>
<div
className={
selectedIdSet.has(acc.id)
? style.radioSelected
: style.radioUnselected
}
>
{selectedIdSet.has(acc.id) && (
<div className={style.radioDot}></div>
)}
</div>
</div>
<div className={style.friendInfo}>
<div className={style.friendAvatar}>
{acc.avatar ? (
<img
src={acc.avatar}
alt={acc.userName}
className={style.avatarImg}
/>
) : (
(acc.userName?.charAt(0) ?? "?")
)}
</div>
<div className={style.friendDetail}>
<div className={style.friendName}>{acc.userName}</div>
<div className={style.friendId}>
: {acc.realName}
</div>
<div className={style.friendId}>
: {acc.departmentName}
</div>
</div>
</div>
</label>
))}
</div>
) : (
<div className={style.emptyBox}>
<div className={style.emptyText}>
{searchQuery
? `没有找到包含"${searchQuery}"的账号`
: "没有找到账号"}
</div>
</div>
)}
</div>
</Layout>
</Popup>
);
}

View File

@@ -11,15 +11,13 @@ import { isDevelopment } from "@/utils/env";
import { GroupSelectionItem } from "@/components/GroupSelection/data";
import { ContentItem } from "@/components/ContentSelection/data";
import { FriendSelectionItem } from "@/components/FriendSelection/data";
import { AccountItem } from "@/components/AccountSelection/data";
const ComponentTest: React.FC = () => {
const [activeTab, setActiveTab] = useState("libraries");
const [activeTab, setActiveTab] = useState("accounts");
// 设备选择状态
const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
// 好友选择状态
const [selectedFriends, setSelectedFriends] = useState<string[]>([]);
// 群组选择状态
const [selectedGroups, setSelectedGroups] = useState<GroupSelectionItem[]>(
[],
@@ -28,7 +26,7 @@ const ComponentTest: React.FC = () => {
// 内容库选择状态
const [selectedContent, setSelectedContent] = useState<ContentItem[]>([]);
const [selectedAccounts, setSelectedAccounts] = useState<number[]>([]);
const [selectedAccounts, setSelectedAccounts] = useState<AccountItem[]>([]);
const [selectedFriendsOptions, setSelectedFriendsOptions] = useState<
FriendSelectionItem[]
>([]);
@@ -137,15 +135,24 @@ const ComponentTest: React.FC = () => {
<div style={{ padding: "16px 0" }}>
<h3 style={{ marginBottom: 16 }}>AccountSelection </h3>
<AccountSelection
value={selectedAccounts}
onChange={setSelectedAccounts}
// 可根据实际API和props补充其它参数
selectedOptions={selectedAccounts}
onSelect={setSelectedAccounts}
placeholder="请选择账号"
showSelectedList={true}
selectedListMaxHeight={300}
/>
<div style={{ marginTop: 16 }}>
<strong></strong>
{selectedAccounts.length > 0
? selectedAccounts.join(", ")
: "无"}
<div
style={{
marginTop: 16,
padding: 12,
background: "#f5f5f5",
borderRadius: 8,
}}
>
<strong>:</strong> {selectedAccounts.length}
<br />
<strong>ID:</strong>{" "}
{selectedAccounts.map(a => a.id).join(", ") || "无"}
</div>
</div>
</Tabs.Tab>

View File

@@ -17,6 +17,7 @@ import Layout from "@/components/Layout/Layout";
import NavCommon from "@/components/NavCommon";
import AccountSelection from "@/components/AccountSelection";
import { getAccountList } from "@/components/AccountSelection/api";
import { AccountItem } from "@/components/AccountSelection/data";
import {
getTrafficDistributionDetail,
updateTrafficDistribution,
@@ -62,7 +63,7 @@ const TrafficDistributionForm: React.FC = () => {
const isEdit = !!id;
const [current, setCurrent] = useState(0);
const [selectedAccountIds, setSelectedAccountIds] = useState<number[]>([]);
const [selectedAccounts, setSelectedAccounts] = useState<AccountItem[]>([]);
const [distributeType, setDistributeType] = useState(1);
const [maxPerDay, setMaxPerDay] = useState(50);
const [timeType, setTimeType] = useState(1);
@@ -117,11 +118,15 @@ const TrafficDistributionForm: React.FC = () => {
setMaxPerDay(config.maxPerDay);
setTimeType(config.timeType);
// 去重账号ID
const uniqueAccountIds = [
...new Set(config.account.map(id => Number(id))),
];
setSelectedAccountIds(uniqueAccountIds);
// 设置账号信息 - 这里需要根据实际API返回的数据结构调整
// 暂时使用ID创建简单的AccountItem对象
const accountItems = config.account.map((id: any) => ({
id: Number(id),
userName: `账号${id}`,
realName: "",
departmentName: "",
}));
setSelectedAccounts(accountItems);
// 设置时间范围 - 使用dayjs格式
if (config.timeType === 2 && config.startTime && config.endTime) {
@@ -167,7 +172,7 @@ const TrafficDistributionForm: React.FC = () => {
endTime:
timeType === 2 && timeRange?.[1] ? timeRange[1].format("HH:mm") : "",
devices: detailData?.config.devices || [],
account: selectedAccountIds,
account: selectedAccounts.map(acc => acc.id),
pools: selectedPools,
enabled: true,
};
@@ -236,8 +241,8 @@ const TrafficDistributionForm: React.FC = () => {
className={style.accountSelectItem}
>
<AccountSelection
value={selectedAccountIds}
onChange={setSelectedAccountIds}
selectedOptions={selectedAccounts}
onSelect={setSelectedAccounts}
/>
</Form.Item>
<Form.Item label="分配方式" name="distributeType" required>