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

存一版
This commit is contained in:
笔记本里的永平
2025-07-24 22:07:14 +08:00
parent 3251a1985e
commit 6f280b5816
5 changed files with 916 additions and 347 deletions

View File

@@ -0,0 +1,10 @@
import request from "@/api/request";
// 获取好友列表
export function getAccountList(params: {
page: number;
limit: number;
keyword?: string;
}) {
return request("/v1/workbench/account-list", params, "GET");
}

View File

@@ -0,0 +1,231 @@
.inputWrapper {
position: relative;
}
.inputIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #bdbdbd;
font-size: 20px;
}
.input {
padding-left: 38px !important;
height: 48px;
border-radius: 16px !important;
border: 1px solid #e5e6eb !important;
font-size: 16px;
background: #f8f9fa;
}
.popupContainer {
display: flex;
flex-direction: column;
height: 100vh;
background: #fff;
}
.popupHeader {
padding: 24px;
}
.popupTitle {
text-align: center;
font-size: 20px;
font-weight: 600;
margin-bottom: 24px;
}
.searchWrapper {
position: relative;
margin-bottom: 16px;
}
.searchInput {
padding-left: 40px !important;
padding-top: 8px !important;
padding-bottom: 8px !important;
border-radius: 24px !important;
border: 1px solid #e5e6eb !important;
font-size: 15px;
background: #f8f9fa;
}
.searchIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #bdbdbd;
font-size: 16px;
}
.clearBtn {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
height: 24px;
width: 24px;
border-radius: 50%;
min-width: 24px;
}
.friendList {
flex: 1;
overflow-y: auto;
}
.friendListInner {
border-top: 1px solid #f0f0f0;
}
.friendItem {
display: flex;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #f5f6fa;
}
}
.radioWrapper {
margin-right: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.radioSelected {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid #1890ff;
display: flex;
align-items: center;
justify-content: center;
}
.radioUnselected {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid #e5e6eb;
display: flex;
align-items: center;
justify-content: center;
}
.radioDot {
width: 12px;
height: 12px;
border-radius: 50%;
background: #1890ff;
}
.friendInfo {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.friendAvatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 14px;
font-weight: 500;
overflow: hidden;
}
.avatarImg {
width: 100%;
height: 100%;
object-fit: cover;
}
.friendDetail {
flex: 1;
}
.friendName {
font-weight: 500;
font-size: 16px;
color: #222;
margin-bottom: 2px;
}
.friendId {
font-size: 13px;
color: #888;
margin-bottom: 2px;
}
.friendCustomer {
font-size: 13px;
color: #bdbdbd;
}
.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;
}
.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;
}
.pageInfo {
font-size: 14px;
color: #222;
}
.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;
}
.cancelBtn {
padding: 0 24px;
border-radius: 24px;
border: 1px solid #e5e6eb;
}
.confirmBtn {
padding: 0 24px;
border-radius: 24px;
}

View File

@@ -0,0 +1,340 @@
import React, { useState, useEffect } 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;
}
export default function AccountSelection({
value,
onChange,
accounts: propAccounts = [],
placeholder = "选择账号",
className = "",
visible,
onVisibleChange,
selectedListMaxHeight = 300,
showInput = true,
showSelectedList = true,
readonly = false,
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;
const setRealVisible = (v: boolean) => {
if (onVisibleChange) onVisibleChange(v);
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 newSelected = value.includes(accountId)
? value.filter((id) => id !== accountId)
: [...value, accountId];
onChange(newSelected);
};
// 获取显示文本
const getDisplayText = () => {
if (value.length === 0) return "";
return `已选择 ${value.length} 个账号`;
};
// 获取已选账号详细信息
const selectedAccountObjs = [
...accountsList.filter((acc) => value.includes(acc.id)),
...value
.filter((id) => !accountsList.some((acc) => acc.id === id))
.map((id) => ({
id,
userName: String(id),
realName: "",
departmentName: "",
})),
];
// 删除已选账号
const handleRemoveAccount = (id: number) => {
if (readonly) return;
onChange(value.filter((d) => d !== id));
};
// 确认选择
const handleConfirm = () => {
if (onConfirm) {
onConfirm(value, selectedAccountObjs);
}
setRealVisible(false);
};
return (
<>
{/* 输入框 */}
{showInput && (
<div className={`${style.inputWrapper} ${className}`}>
<Input
placeholder={placeholder}
value={getDisplayText()}
onClick={openPopup}
prefix={<SearchOutlined />}
allowClear={!readonly}
size="large"
readOnly={readonly}
disabled={readonly}
style={
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
}
/>
</div>
)}
{/* 已选账号列表窗口 */}
{showSelectedList && selectedAccountObjs.length > 0 && (
<div
className={style.selectedListWindow}
style={{
maxHeight: selectedListMaxHeight,
overflowY: "auto",
marginTop: 8,
border: "1px solid #e5e6eb",
borderRadius: 8,
background: "#fff",
}}
>
{selectedAccountObjs.map((acc) => (
<div
key={acc.id}
className={style.selectedListRow}
style={{
display: "flex",
alignItems: "center",
padding: "4px 8px",
borderBottom: "1px solid #f0f0f0",
fontSize: 14,
}}
>
<div
style={{
flex: 1,
minWidth: 0,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{acc.userName}{acc.realName}- {acc.departmentName}
</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={() => handleRemoveAccount(acc.id)}
/>
)}
</div>
))}
</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={value.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={
value.includes(acc.id)
? style.radioSelected
: style.radioUnselected
}
>
{value.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>
</>
);
}

View File

@@ -8,6 +8,7 @@ import DeviceSelection from "@/components/DeviceSelection";
import FriendSelection from "@/components/FriendSelection"; import FriendSelection from "@/components/FriendSelection";
import GroupSelection from "@/components/GroupSelection"; import GroupSelection from "@/components/GroupSelection";
import ContentLibrarySelection from "@/components/ContentLibrarySelection"; import ContentLibrarySelection from "@/components/ContentLibrarySelection";
import AccountSelection from "@/components/AccountSelection";
const ComponentTest: React.FC = () => { const ComponentTest: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -25,6 +26,8 @@ const ComponentTest: React.FC = () => {
// 内容库选择状态 // 内容库选择状态
const [selectedLibraries, setSelectedLibraries] = useState<string[]>([]); const [selectedLibraries, setSelectedLibraries] = useState<string[]>([]);
const [selectedAccounts, setSelectedAccounts] = useState<string[]>([]);
return ( return (
<Layout header={<NavCommon title="组件调试" />}> <Layout header={<NavCommon title="组件调试" />}>
<div style={{ padding: 16 }}> <div style={{ padding: 16 }}>
@@ -131,6 +134,23 @@ const ComponentTest: React.FC = () => {
</div> </div>
</div> </div>
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab title="账号选择" key="accounts">
<div style={{ padding: "16px 0" }}>
<h3 style={{ marginBottom: 16 }}>AccountSelection </h3>
<AccountSelection
value={selectedAccounts}
onChange={setSelectedAccounts}
// 可根据实际API和props补充其它参数
/>
<div style={{ marginTop: 16 }}>
<strong></strong>
{selectedAccounts.length > 0
? selectedAccounts.join(", ")
: "无"}
</div>
</div>
</Tabs.Tab>
</Tabs> </Tabs>
</div> </div>
</Layout> </Layout>

View File

@@ -15,12 +15,9 @@ import style from "./index.module.scss";
import StepIndicator from "@/components/StepIndicator"; import StepIndicator from "@/components/StepIndicator";
import Layout from "@/components/Layout/Layout"; import Layout from "@/components/Layout/Layout";
import NavCommon from "@/components/NavCommon"; import NavCommon from "@/components/NavCommon";
import AccountSelection from "@/components/AccountSelection";
import { getAccountList } from "@/components/AccountSelection/api";
const accountList = [
{ label: "客服A", value: "a" },
{ label: "客服B", value: "b" },
{ label: "客服C", value: "c" },
];
const scenarioList = [ const scenarioList = [
{ label: "海报获客", value: "poster" }, { label: "海报获客", value: "poster" },
{ label: "电话获客", value: "phone" }, { label: "电话获客", value: "phone" },
@@ -52,19 +49,13 @@ const TrafficDistributionForm: React.FC = () => {
const [form] = Form.useForm(); const [form] = Form.useForm();
const navigate = useNavigate(); const navigate = useNavigate();
const [current, setCurrent] = useState(0); const [current, setCurrent] = useState(0);
const [selectedAccounts, setSelectedAccounts] = useState<string[]>([]); const [selectedAccountIds, setSelectedAccountIds] = useState<number[]>([]);
const [distributeType, setDistributeType] = useState(1); const [distributeType, setDistributeType] = useState(1);
const [maxPerDay, setMaxPerDay] = useState(50); const [maxPerDay, setMaxPerDay] = useState(50);
const [timeType, setTimeType] = useState(1); const [timeType, setTimeType] = useState(1);
const [timeRange, setTimeRange] = useState<any>(null); const [timeRange, setTimeRange] = useState<any>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// 账号搜索(模拟)
const [accountSearch, setAccountSearch] = useState("");
const filteredAccounts = accountList.filter((acc) =>
acc.label.includes(accountSearch)
);
const [targetUserCount, setTargetUserCount] = useState(100); const [targetUserCount, setTargetUserCount] = useState(100);
const [targetTypes, setTargetTypes] = useState<string[]>([]); const [targetTypes, setTargetTypes] = useState<string[]>([]);
const [targetScenarios, setTargetScenarios] = useState<string[]>([]); const [targetScenarios, setTargetScenarios] = useState<string[]>([]);
@@ -126,33 +117,10 @@ const TrafficDistributionForm: React.FC = () => {
required required
className={style.accountSelectItem} className={style.accountSelectItem}
> >
<Input <AccountSelection
placeholder="请选择账号" value={selectedAccountIds}
value={accountSearch} onChange={setSelectedAccountIds}
onChange={(e) => setAccountSearch(e.target.value)}
suffix={<span className={style.accountSearchIcon} />}
/> />
<div className={style.accountListWrap}>
{filteredAccounts.map((acc) => (
<label key={acc.value} className={style.accountItem}>
<input
type="checkbox"
checked={selectedAccounts.includes(acc.value)}
onChange={(e) => {
setSelectedAccounts((val) =>
e.target.checked
? [...val, acc.value]
: val.filter((v) => v !== acc.value)
);
}}
/>
<span>{acc.label}</span>
</label>
))}
</div>
<div className={style.accountSelectedCount}>
<span>{selectedAccounts.length}</span>
</div>
</Form.Item> </Form.Item>
<Form.Item label="分配方式" name="distributeType" required> <Form.Item label="分配方式" name="distributeType" required>
<Radio.Group <Radio.Group