From 6f280b58164c1f59090f54e8d3de093a0ab87522 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=AC=94=E8=AE=B0=E6=9C=AC=E9=87=8C=E7=9A=84=E6=B0=B8?= =?UTF-8?q?=E5=B9=B3?= Date: Thu, 24 Jul 2025 22:07:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9C=AC=E6=AC=A1=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=86=85=E5=AE=B9=E5=A6=82=E4=B8=8B=20?= =?UTF-8?q?=E5=AD=98=E4=B8=80=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nkebao/src/components/AccountSelection/api.ts | 10 + .../AccountSelection/index.module.scss | 231 ++++++ .../src/components/AccountSelection/index.tsx | 340 +++++++++ nkebao/src/pages/component-test/index.tsx | 20 + .../traffic-distribution/form/index.tsx | 662 +++++++++--------- 5 files changed, 916 insertions(+), 347 deletions(-) create mode 100644 nkebao/src/components/AccountSelection/api.ts create mode 100644 nkebao/src/components/AccountSelection/index.module.scss create mode 100644 nkebao/src/components/AccountSelection/index.tsx diff --git a/nkebao/src/components/AccountSelection/api.ts b/nkebao/src/components/AccountSelection/api.ts new file mode 100644 index 00000000..1aeb5785 --- /dev/null +++ b/nkebao/src/components/AccountSelection/api.ts @@ -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"); +} diff --git a/nkebao/src/components/AccountSelection/index.module.scss b/nkebao/src/components/AccountSelection/index.module.scss new file mode 100644 index 00000000..51eb1af5 --- /dev/null +++ b/nkebao/src/components/AccountSelection/index.module.scss @@ -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; +} diff --git a/nkebao/src/components/AccountSelection/index.tsx b/nkebao/src/components/AccountSelection/index.tsx new file mode 100644 index 00000000..b900164b --- /dev/null +++ b/nkebao/src/components/AccountSelection/index.tsx @@ -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(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 && ( +
+ } + allowClear={!readonly} + size="large" + readOnly={readonly} + disabled={readonly} + style={ + readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {} + } + /> +
+ )} + {/* 已选账号列表窗口 */} + {showSelectedList && selectedAccountObjs.length > 0 && ( +
+ {selectedAccountObjs.map((acc) => ( +
+
+ {acc.userName}({acc.realName})- {acc.departmentName} +
+ {!readonly && ( +
+ ))} +
+ )} + {/* 弹窗 */} + setRealVisible(false)} + position="bottom" + bodyStyle={{ height: "100vh" }} + > + {}} + /> + } + footer={ + setRealVisible(false)} + onConfirm={handleConfirm} + /> + } + > +
+ {loading ? ( +
+
加载中...
+
+ ) : filteredAccounts.length > 0 ? ( +
+ {filteredAccounts.map((acc) => ( + + ))} +
+ ) : ( +
+
+ {searchQuery + ? `没有找到包含"${searchQuery}"的账号` + : "没有找到账号"} +
+
+ )} +
+
+
+ + ); +} diff --git a/nkebao/src/pages/component-test/index.tsx b/nkebao/src/pages/component-test/index.tsx index fa7f273e..acb05ebe 100644 --- a/nkebao/src/pages/component-test/index.tsx +++ b/nkebao/src/pages/component-test/index.tsx @@ -8,6 +8,7 @@ import DeviceSelection from "@/components/DeviceSelection"; import FriendSelection from "@/components/FriendSelection"; import GroupSelection from "@/components/GroupSelection"; import ContentLibrarySelection from "@/components/ContentLibrarySelection"; +import AccountSelection from "@/components/AccountSelection"; const ComponentTest: React.FC = () => { const navigate = useNavigate(); @@ -25,6 +26,8 @@ const ComponentTest: React.FC = () => { // 内容库选择状态 const [selectedLibraries, setSelectedLibraries] = useState([]); + const [selectedAccounts, setSelectedAccounts] = useState([]); + return ( }>
@@ -131,6 +134,23 @@ const ComponentTest: React.FC = () => {
+ + +
+

AccountSelection 组件测试

+ +
+ 已选账号: + {selectedAccounts.length > 0 + ? selectedAccounts.join(", ") + : "无"} +
+
+
diff --git a/nkebao/src/pages/workspace/traffic-distribution/form/index.tsx b/nkebao/src/pages/workspace/traffic-distribution/form/index.tsx index 617db51b..6ef63582 100644 --- a/nkebao/src/pages/workspace/traffic-distribution/form/index.tsx +++ b/nkebao/src/pages/workspace/traffic-distribution/form/index.tsx @@ -1,347 +1,315 @@ -import React, { useState } from "react"; -import { - Form, - Input, - Button, - Radio, - Slider, - TimePicker, - message, - Checkbox, -} from "antd"; -import { LeftOutlined } from "@ant-design/icons"; -import { useNavigate } from "react-router-dom"; -import style from "./index.module.scss"; -import StepIndicator from "@/components/StepIndicator"; -import Layout from "@/components/Layout/Layout"; -import NavCommon from "@/components/NavCommon"; - -const accountList = [ - { label: "客服A", value: "a" }, - { label: "客服B", value: "b" }, - { label: "客服C", value: "c" }, -]; -const scenarioList = [ - { label: "海报获客", value: "poster" }, - { label: "电话获客", value: "phone" }, - { label: "抖音获客", value: "douyin" }, - { label: "小红书获客", value: "xiaohongshu" }, - { label: "微信群获客", value: "weixinqun" }, - { label: "API获客", value: "api" }, - { label: "订单获客", value: "order" }, - { label: "付款码获客", value: "payment" }, -]; -const poolList = [ - { - id: "pool-1", - name: "高价值客户池", - userCount: 156, - tags: ["高价值", "优先添加"], - }, - { id: "pool-2", name: "潜在客户池", userCount: 289, tags: ["潜在客户"] }, - { id: "pool-3", name: "新用户池", userCount: 432, tags: ["新用户"] }, -]; - -const stepList = [ - { id: 1, title: "基本信息", subtitle: "基本信息" }, - { id: 2, title: "目标设置", subtitle: "目标设置" }, - { id: 3, title: "流量池选择", subtitle: "流量池选择" }, -]; - -const TrafficDistributionForm: React.FC = () => { - const [form] = Form.useForm(); - const navigate = useNavigate(); - const [current, setCurrent] = useState(0); - const [selectedAccounts, setSelectedAccounts] = useState([]); - const [distributeType, setDistributeType] = useState(1); - const [maxPerDay, setMaxPerDay] = useState(50); - const [timeType, setTimeType] = useState(1); - const [timeRange, setTimeRange] = useState(null); - const [loading, setLoading] = useState(false); - - // 账号搜索(模拟) - const [accountSearch, setAccountSearch] = useState(""); - const filteredAccounts = accountList.filter((acc) => - acc.label.includes(accountSearch) - ); - - const [targetUserCount, setTargetUserCount] = useState(100); - const [targetTypes, setTargetTypes] = useState([]); - const [targetScenarios, setTargetScenarios] = useState([]); - const [selectedPools, setSelectedPools] = useState([]); - const [poolSearch, setPoolSearch] = useState(""); - - const handleFinish = async (values: any) => { - setLoading(true); - try { - // TODO: 提交接口 - message.success("新建流量分发成功"); - navigate(-1); - } catch (e) { - message.error("新建失败"); - } finally { - setLoading(false); - } - }; - - // 步骤切换 - const next = () => setCurrent((cur) => cur + 1); - const prev = () => setCurrent((cur) => cur - 1); - - // 过滤流量池 - const filteredPools = poolList.filter((pool) => - pool.name.includes(poolSearch) - ); - - return ( - - -
- -
- - } - > -
-
- {current === 0 && ( -
next()} - initialValues={{ distributeType: 1, maxPerDay: 50, timeType: 1 }} - > -
基本信息
- - - - - setAccountSearch(e.target.value)} - suffix={} - /> -
- {filteredAccounts.map((acc) => ( - - ))} -
-
- 已选账号:{selectedAccounts.length} 个 -
-
- - setDistributeType(e.target.value)} - className={style.radioGroup} - > - - 均分配{" "} - - (流量将均分分配给所有客服) - - - - 优先级分配{" "} - - (按客服优先级顺序分配) - - - - 比例分配{" "} - - (按设置比例分配流量) - - - - - -
- 每日最大分配量 - {maxPerDay} 人/天 -
- -
- 限制每天最多分配的流量数量 -
-
- - setTimeType(e.target.value)} - className={style.radioGroup} - > - 全天分配 - 自定义时间段 - - - {timeType === 2 && ( - -
-
- 开始时间 - setTimeRange([v, timeRange?.[1]])} - /> -
-
- 结束时间 - setTimeRange([timeRange?.[0], v])} - /> -
-
-
- )} - - - -
- )} - {current === 1 && ( -
-
目标设置
-
-
目标用户数
- -
{targetUserCount} 人
-
-
-
目标客户类型
- -
-
-
获客场景
- ({ - label: s.label, - value: s.value, - }))} - value={targetScenarios} - onChange={setTargetScenarios} - className={style.checkboxGroup} - /> -
-
- - -
-
- )} - {current === 2 && ( -
-
流量池选择
-
- setPoolSearch(e.target.value)} - style={{ marginBottom: 12 }} - /> -
- {filteredPools.map((pool) => ( - - ))} -
-
- 已选流量池:{selectedPools.length} 个 -
-
-
- - -
-
- )} -
-
-
- ); -}; - -export default TrafficDistributionForm; +import React, { useState } from "react"; +import { + Form, + Input, + Button, + Radio, + Slider, + TimePicker, + message, + Checkbox, +} from "antd"; +import { LeftOutlined } from "@ant-design/icons"; +import { useNavigate } from "react-router-dom"; +import style from "./index.module.scss"; +import StepIndicator from "@/components/StepIndicator"; +import Layout from "@/components/Layout/Layout"; +import NavCommon from "@/components/NavCommon"; +import AccountSelection from "@/components/AccountSelection"; +import { getAccountList } from "@/components/AccountSelection/api"; + +const scenarioList = [ + { label: "海报获客", value: "poster" }, + { label: "电话获客", value: "phone" }, + { label: "抖音获客", value: "douyin" }, + { label: "小红书获客", value: "xiaohongshu" }, + { label: "微信群获客", value: "weixinqun" }, + { label: "API获客", value: "api" }, + { label: "订单获客", value: "order" }, + { label: "付款码获客", value: "payment" }, +]; +const poolList = [ + { + id: "pool-1", + name: "高价值客户池", + userCount: 156, + tags: ["高价值", "优先添加"], + }, + { id: "pool-2", name: "潜在客户池", userCount: 289, tags: ["潜在客户"] }, + { id: "pool-3", name: "新用户池", userCount: 432, tags: ["新用户"] }, +]; + +const stepList = [ + { id: 1, title: "基本信息", subtitle: "基本信息" }, + { id: 2, title: "目标设置", subtitle: "目标设置" }, + { id: 3, title: "流量池选择", subtitle: "流量池选择" }, +]; + +const TrafficDistributionForm: React.FC = () => { + const [form] = Form.useForm(); + const navigate = useNavigate(); + const [current, setCurrent] = useState(0); + const [selectedAccountIds, setSelectedAccountIds] = useState([]); + const [distributeType, setDistributeType] = useState(1); + const [maxPerDay, setMaxPerDay] = useState(50); + const [timeType, setTimeType] = useState(1); + const [timeRange, setTimeRange] = useState(null); + const [loading, setLoading] = useState(false); + + const [targetUserCount, setTargetUserCount] = useState(100); + const [targetTypes, setTargetTypes] = useState([]); + const [targetScenarios, setTargetScenarios] = useState([]); + const [selectedPools, setSelectedPools] = useState([]); + const [poolSearch, setPoolSearch] = useState(""); + + const handleFinish = async (values: any) => { + setLoading(true); + try { + // TODO: 提交接口 + message.success("新建流量分发成功"); + navigate(-1); + } catch (e) { + message.error("新建失败"); + } finally { + setLoading(false); + } + }; + + // 步骤切换 + const next = () => setCurrent((cur) => cur + 1); + const prev = () => setCurrent((cur) => cur - 1); + + // 过滤流量池 + const filteredPools = poolList.filter((pool) => + pool.name.includes(poolSearch) + ); + + return ( + + +
+ +
+ + } + > +
+
+ {current === 0 && ( +
next()} + initialValues={{ distributeType: 1, maxPerDay: 50, timeType: 1 }} + > +
基本信息
+ + + + + + + + setDistributeType(e.target.value)} + className={style.radioGroup} + > + + 均分配{" "} + + (流量将均分分配给所有客服) + + + + 优先级分配{" "} + + (按客服优先级顺序分配) + + + + 比例分配{" "} + + (按设置比例分配流量) + + + + + +
+ 每日最大分配量 + {maxPerDay} 人/天 +
+ +
+ 限制每天最多分配的流量数量 +
+
+ + setTimeType(e.target.value)} + className={style.radioGroup} + > + 全天分配 + 自定义时间段 + + + {timeType === 2 && ( + +
+
+ 开始时间 + setTimeRange([v, timeRange?.[1]])} + /> +
+
+ 结束时间 + setTimeRange([timeRange?.[0], v])} + /> +
+
+
+ )} + + + +
+ )} + {current === 1 && ( +
+
目标设置
+
+
目标用户数
+ +
{targetUserCount} 人
+
+
+
目标客户类型
+ +
+
+
获客场景
+ ({ + label: s.label, + value: s.value, + }))} + value={targetScenarios} + onChange={setTargetScenarios} + className={style.checkboxGroup} + /> +
+
+ + +
+
+ )} + {current === 2 && ( +
+
流量池选择
+
+ setPoolSearch(e.target.value)} + style={{ marginBottom: 12 }} + /> +
+ {filteredPools.map((pool) => ( + + ))} +
+
+ 已选流量池:{selectedPools.length} 个 +
+
+
+ + +
+
+ )} +
+
+
+ ); +}; + +export default TrafficDistributionForm;