全局配置前端功能提交
This commit is contained in:
@@ -89,7 +89,7 @@ export default function GroupSelection({
|
||||
<div style={{ fontSize: 12, color: "#666" }}>归属:{group.nickName}</div>
|
||||
)}
|
||||
{!group.nickName && group.chatroomId && (
|
||||
<div>{group.chatroomId}</div>
|
||||
<div>{group.chatroomId}</div>
|
||||
)}
|
||||
</div>
|
||||
{!readonly && (
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface Task {
|
||||
total_customers?: number;
|
||||
today_customers?: number;
|
||||
lastUpdated?: string;
|
||||
planType?: number; // 0-全局计划, 1-独立计划
|
||||
stats?: {
|
||||
devices?: number;
|
||||
acquired?: number;
|
||||
|
||||
@@ -2,6 +2,73 @@
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
color: #2563eb;
|
||||
font-size: 20px;
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
font-size: 13px;
|
||||
color: #1e40af;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
padding-left: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.section-dot {
|
||||
width: 4px;
|
||||
height: 14px;
|
||||
background: #2563eb;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 8px rgba(37, 99, 235, 0.4);
|
||||
}
|
||||
|
||||
.section-title-independent {
|
||||
.section-dot {
|
||||
background: #fb923c;
|
||||
box-shadow: 0 0 8px rgba(251, 146, 60, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.section-dot-independent {
|
||||
background: #fb923c;
|
||||
box-shadow: 0 0 8px rgba(251, 146, 60, 0.4);
|
||||
}
|
||||
|
||||
.plan-list-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -101,17 +101,23 @@ const ScenarioList: React.FC = () => {
|
||||
});
|
||||
|
||||
if (response && response.list) {
|
||||
// 处理 planType 字段
|
||||
const processedList = response.list.map((task: any) => ({
|
||||
...task,
|
||||
planType: task.planType ?? task.config?.planType ?? 1, // 默认独立计划
|
||||
}));
|
||||
|
||||
if (isLoadMore) {
|
||||
// 加载更多时,追加数据
|
||||
setTasks(prev => [...prev, ...response.list]);
|
||||
setTasks(prev => [...prev, ...processedList]);
|
||||
} else {
|
||||
// 首次加载或刷新时,替换数据
|
||||
setTasks(response.list);
|
||||
setTasks(processedList);
|
||||
}
|
||||
|
||||
// 更新分页信息
|
||||
setTotal(response.total || 0);
|
||||
setHasMore(response.list.length === limit);
|
||||
setHasMore(processedList.length === limit);
|
||||
setCurrentPage(page);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -313,6 +319,10 @@ const ScenarioList: React.FC = () => {
|
||||
task.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
// 分隔全局计划和独立计划
|
||||
const globalPlans = filteredTasks.filter(task => task.planType === 0);
|
||||
const independentPlans = filteredTasks.filter(task => task.planType === 1 || !task.planType);
|
||||
|
||||
// 生成操作菜单
|
||||
const getActionMenu = (task: Task) => [
|
||||
{
|
||||
@@ -409,6 +419,16 @@ const ScenarioList: React.FC = () => {
|
||||
loading={loading}
|
||||
>
|
||||
<div className={style["scenario-list-page"]}>
|
||||
{/* 全局计划提示 */}
|
||||
{globalPlans.length > 0 && (
|
||||
<div className={style["info-box"]}>
|
||||
<span className={style["info-icon"]}>ℹ</span>
|
||||
<p className={style["info-text"]}>
|
||||
全局获客计划将应用于所有设备,包含新添加的设备,请确保设置合理的规则。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 计划列表 */}
|
||||
<div className={style["plan-list"]}>
|
||||
{filteredTasks.length === 0 ? (
|
||||
@@ -428,7 +448,119 @@ const ScenarioList: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{filteredTasks.map(task => (
|
||||
{/* 全局获客计划 */}
|
||||
{globalPlans.length > 0 && (
|
||||
<section className={style["section"]}>
|
||||
<h2 className={style["section-title"]}>
|
||||
<div className={style["section-dot"]}></div>
|
||||
全局获客计划
|
||||
</h2>
|
||||
<div className={style["plan-list-group"]}>
|
||||
{globalPlans.map(task => (
|
||||
<Card key={task.id} className={style["plan-item"]}>
|
||||
{/* 头部:标题、状态和操作菜单 */}
|
||||
<div className={style["plan-header"]}>
|
||||
<div className={style["plan-name"]}>{task.name}</div>
|
||||
<div className={style["plan-header-right"]}>
|
||||
<Tag color={getStatusColor(task.status)}>
|
||||
{getStatusText(task.status)}
|
||||
</Tag>
|
||||
<Button
|
||||
size="mini"
|
||||
fill="none"
|
||||
className={style["more-btn"]}
|
||||
onClick={e => {
|
||||
e.stopPropagation(); // 阻止事件冒泡
|
||||
setShowActionMenu(task.id);
|
||||
}}
|
||||
>
|
||||
<MoreOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计数据网格 */}
|
||||
<div className={style["stats-grid"]}>
|
||||
<div
|
||||
className={style["stat-item"]}
|
||||
onClick={e => {
|
||||
e.stopPropagation(); // 阻止事件冒泡,避免触发卡片点击
|
||||
handleShowDeviceList(task);
|
||||
}}
|
||||
>
|
||||
<div className={style["stat-label"]}>设备数</div>
|
||||
<div className={style["stat-value"]}>
|
||||
{deviceCount(task)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={style["stat-item"]}
|
||||
onClick={e => {
|
||||
e.stopPropagation(); // 阻止事件冒泡,避免触发卡片点击
|
||||
handleShowAccountList(task);
|
||||
}}
|
||||
>
|
||||
<div className={style["stat-label"]}>已获客</div>
|
||||
<div className={style["stat-value"]}>
|
||||
{task?.acquiredCount || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={style["stat-item"]}
|
||||
onClick={e => {
|
||||
e.stopPropagation(); // 阻止事件冒泡,避免触发卡片点击
|
||||
handleShowOreadyAdd(task);
|
||||
}}
|
||||
>
|
||||
<div className={style["stat-label"]}>已添加</div>
|
||||
<div className={style["stat-value"]}>
|
||||
{task.passCount || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={style["stat-item"]}
|
||||
onClick={e => {
|
||||
e.stopPropagation(); // 阻止事件冒泡,避免触发卡片点击
|
||||
handleShowPoolList(task);
|
||||
}}
|
||||
>
|
||||
<div className={style["stat-label"]}>通过率</div>
|
||||
<div className={style["stat-value"]}>
|
||||
{task.passRate}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部:上次执行时间 */}
|
||||
<div className={style["plan-footer"]}>
|
||||
<div className={style["last-execution"]}>
|
||||
<ClockCircleOutlined />
|
||||
<span>上次执行: {task.lastUpdated || "--"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<QrcodeOutlined
|
||||
onClick={() => {
|
||||
setShowActionMenu(null);
|
||||
handleShowQrCode(task.id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 独立获客计划 */}
|
||||
{independentPlans.length > 0 && (
|
||||
<section className={style["section"]}>
|
||||
<h2 className={`${style["section-title"]} ${style["section-title-independent"]}`}>
|
||||
<div className={`${style["section-dot"]} ${style["section-dot-independent"]}`}></div>
|
||||
独立获客计划
|
||||
</h2>
|
||||
<div className={style["plan-list-group"]}>
|
||||
{independentPlans.map(task => (
|
||||
<Card key={task.id} className={style["plan-item"]}>
|
||||
{/* 头部:标题、状态和操作菜单 */}
|
||||
<div className={style["plan-header"]}>
|
||||
@@ -520,6 +652,10 @@ const ScenarioList: React.FC = () => {
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 上拉加载更多 */}
|
||||
<InfiniteScroll
|
||||
loadMore={handleLoadMore}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
// 步骤定义 - 五个步骤
|
||||
// 步骤定义 - 三个步骤
|
||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||
import { GroupSelectionItem } from "@/components/GroupSelection/data";
|
||||
export const steps = [
|
||||
{ id: 1, title: "步骤一", subtitle: "基础设置" },
|
||||
{ id: 2, title: "步骤二", subtitle: "好友申请设置" },
|
||||
{ id: 3, title: "步骤三", subtitle: "渠道设置" },
|
||||
{ id: 4, title: "步骤四", subtitle: "拉群设置" },
|
||||
{ id: 5, title: "步骤五", subtitle: "消息设置" },
|
||||
{ id: 3, title: "步骤三", subtitle: "消息设置" },
|
||||
];
|
||||
|
||||
// 类型定义
|
||||
@@ -50,6 +48,8 @@ export interface FormData {
|
||||
distributionCustomerReward?: number;
|
||||
// 添加奖励金额(元,前端使用,提交时转为 addFriendRewardAmount)
|
||||
distributionAddReward?: number;
|
||||
// 计划类型:0-全局计划,1-独立计划(仅管理员可创建)
|
||||
planType?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
export const defFormData: FormData = {
|
||||
@@ -85,4 +85,5 @@ export const defFormData: FormData = {
|
||||
distributionChannelsOptions: [],
|
||||
distributionCustomerReward: undefined,
|
||||
distributionAddReward: undefined,
|
||||
planType: 1, // 默认独立计划
|
||||
};
|
||||
|
||||
@@ -4,8 +4,6 @@ import { message, Button, Space } from "antd";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import BasicSettings from "./steps/BasicSettings";
|
||||
import FriendRequestSettings from "./steps/FriendRequestSettings";
|
||||
import DistributionSettings from "./steps/DistributionSettings";
|
||||
import GroupSettings from "./steps/GroupSettings";
|
||||
import MessageSettings from "./steps/MessageSettings";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import StepIndicator from "@/components/StepIndicator";
|
||||
@@ -116,6 +114,8 @@ export default function NewPlan() {
|
||||
// 拉群设置
|
||||
groupInviteEnabled: detail.groupInviteEnabled ?? false,
|
||||
groupName: detail.groupName ?? "",
|
||||
// 计划类型
|
||||
planType: detail.planType ?? 1,
|
||||
// 优先使用后端返回的 options(完整好友信息),否则退回到 ID 数组或旧字段
|
||||
fixedGroupMembers:
|
||||
detail.groupFixedMembersOptions ??
|
||||
@@ -282,6 +282,7 @@ export default function NewPlan() {
|
||||
onChange={onChange}
|
||||
sceneList={sceneList}
|
||||
sceneLoading={sceneLoading}
|
||||
planId={planId}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
@@ -289,16 +290,6 @@ export default function NewPlan() {
|
||||
<FriendRequestSettings formData={formData} onChange={onChange} />
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<DistributionSettings
|
||||
formData={formData}
|
||||
onChange={onChange}
|
||||
planId={planId}
|
||||
/>
|
||||
);
|
||||
case 4:
|
||||
return <GroupSettings formData={formData} onChange={onChange} />;
|
||||
case 5:
|
||||
return <MessageSettings formData={formData} onChange={onChange} />;
|
||||
default:
|
||||
return null;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Input, Button, Tag, Switch, Spin, message, Modal } from "antd";
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Input, Button, Tag, Switch, Spin, message, Modal, Radio } from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
EyeOutlined,
|
||||
@@ -7,13 +7,25 @@ import {
|
||||
DownloadOutlined,
|
||||
SearchOutlined,
|
||||
DeleteOutlined,
|
||||
QrcodeOutlined,
|
||||
CopyOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Toast, SpinLoading } from "antd-mobile";
|
||||
import { Checkbox, Popup } from "antd-mobile";
|
||||
import { uploadFile } from "@/api/common";
|
||||
import styles from "./base.module.scss";
|
||||
import { posterTemplates } from "./base.data";
|
||||
import GroupSelection from "@/components/GroupSelection";
|
||||
import FileUpload from "@/components/Upload/FileUpload";
|
||||
import FriendSelection from "@/components/FriendSelection";
|
||||
import { GroupSelectionItem } from "@/components/GroupSelection/data";
|
||||
import { FriendSelectionItem } from "@/components/FriendSelection/data";
|
||||
import { useUserStore } from "@/store/module/user";
|
||||
import { fetchChannelList } from "@/pages/mobile/workspace/distribution-management/api";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import PopupHeader from "@/components/PopuLayout/header";
|
||||
import PopupFooter from "@/components/PopuLayout/footer";
|
||||
import request from "@/api/request";
|
||||
|
||||
interface BasicSettingsProps {
|
||||
isEdit: boolean;
|
||||
@@ -21,6 +33,7 @@ interface BasicSettingsProps {
|
||||
onChange: (data: any) => void;
|
||||
sceneList: any[];
|
||||
sceneLoading: boolean;
|
||||
planId?: string; // 计划ID,用于生成渠道二维码
|
||||
}
|
||||
|
||||
interface Material {
|
||||
@@ -45,13 +58,51 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
||||
onChange,
|
||||
sceneList,
|
||||
sceneLoading,
|
||||
planId,
|
||||
}) => {
|
||||
const { user } = useUserStore();
|
||||
const isAdmin = user?.isAdmin === 1; // 判断是否是管理员
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
const [materials] = useState<Material[]>(generatePosterMaterials());
|
||||
const [selectedMaterials, setSelectedMaterials] = useState<Material[]>(
|
||||
formData.posters?.length > 0 ? formData.posters : [],
|
||||
);
|
||||
|
||||
// 分销相关状态
|
||||
const [distributionEnabled, setDistributionEnabled] = useState<boolean>(
|
||||
formData.distributionEnabled ?? false,
|
||||
);
|
||||
const [channelModalVisible, setChannelModalVisible] = useState(false);
|
||||
const [channelLoading, setChannelLoading] = useState(false);
|
||||
const [channelList, setChannelList] = useState<any[]>([]);
|
||||
const [tempSelectedChannelIds, setTempSelectedChannelIds] = useState<
|
||||
Array<string | number>
|
||||
>(formData.distributionChannelIds || []);
|
||||
const [channelSearchQuery, setChannelSearchQuery] = useState("");
|
||||
const [channelCurrentPage, setChannelCurrentPage] = useState(1);
|
||||
const [channelTotal, setChannelTotal] = useState(0);
|
||||
const [customerReward, setCustomerReward] = useState<number | undefined>(
|
||||
formData.distributionCustomerReward
|
||||
);
|
||||
const [addReward, setAddReward] = useState<number | undefined>(
|
||||
formData.distributionAddReward
|
||||
);
|
||||
|
||||
// 二维码相关状态
|
||||
const [qrCodeMap, setQrCodeMap] = useState<Record<string | number, {
|
||||
qrCode: string;
|
||||
url: string;
|
||||
loading: boolean;
|
||||
}>>({});
|
||||
const [showQrDialog, setShowQrDialog] = useState(false);
|
||||
const [currentQrChannel, setCurrentQrChannel] = useState<{
|
||||
id: string | number;
|
||||
name: string;
|
||||
code?: string;
|
||||
} | null>(null);
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
// 自定义标签相关状态
|
||||
const [customTagInput, setCustomTagInput] = useState("");
|
||||
const [customTagsOptions, setCustomTagsOptions] = useState<string[]>(
|
||||
@@ -240,6 +291,302 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
// ==================== 渠道设置相关函数 ====================
|
||||
// 生成H5链接
|
||||
const generateH5Url = (channelId: string | number, channelCode?: string): string => {
|
||||
if (planId) {
|
||||
return `https://h5.ckb.quwanzhi.com/#/pages/form/input2?id=${planId}&channelId=${channelId}`;
|
||||
} else if (channelCode) {
|
||||
return `https://h5.ckb.quwanzhi.com/#/pages/form/input2?channelCode=${channelCode}`;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
// 生成渠道二维码
|
||||
const generateChannelQRCode = async (channelId: string | number, channelCode?: string) => {
|
||||
const h5Url = generateH5Url(channelId, channelCode);
|
||||
|
||||
if (qrCodeMap[channelId]) {
|
||||
if (!qrCodeMap[channelId].url) {
|
||||
setQrCodeMap(prev => ({
|
||||
...prev,
|
||||
[channelId]: { ...prev[channelId], url: h5Url },
|
||||
}));
|
||||
}
|
||||
if (qrCodeMap[channelId].qrCode) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
setQrCodeMap(prev => ({
|
||||
...prev,
|
||||
[channelId]: {
|
||||
qrCode: "",
|
||||
url: h5Url,
|
||||
loading: true,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
setQrCodeMap(prev => ({
|
||||
...prev,
|
||||
[channelId]: { ...prev[channelId], loading: true },
|
||||
}));
|
||||
|
||||
try {
|
||||
const params: any = {};
|
||||
if (planId) {
|
||||
params.taskId = planId;
|
||||
params.channelId = channelId;
|
||||
} else if (channelCode) {
|
||||
params.channelCode = channelCode;
|
||||
}
|
||||
|
||||
const response = await request(
|
||||
`/v1/plan/getWxMinAppCode`,
|
||||
params,
|
||||
"GET"
|
||||
);
|
||||
|
||||
if (response && typeof response === 'string' && response.startsWith('data:image')) {
|
||||
setQrCodeMap(prev => ({
|
||||
...prev,
|
||||
[channelId]: {
|
||||
qrCode: response,
|
||||
url: h5Url,
|
||||
loading: false,
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
throw new Error("二维码生成失败");
|
||||
}
|
||||
} catch (error: any) {
|
||||
Toast.show({
|
||||
content: error.message || "生成二维码失败",
|
||||
position: "top",
|
||||
});
|
||||
setQrCodeMap(prev => ({
|
||||
...prev,
|
||||
[channelId]: {
|
||||
...prev[channelId],
|
||||
qrCode: "",
|
||||
url: h5Url,
|
||||
loading: false,
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 显示二维码弹窗
|
||||
const handleShowQRCode = async (channel: { id: string | number; name: string; code?: string }) => {
|
||||
setCurrentQrChannel(channel);
|
||||
setShowQrDialog(true);
|
||||
|
||||
if (!qrCodeMap[channel.id]?.qrCode && !qrCodeMap[channel.id]?.loading) {
|
||||
await generateChannelQRCode(channel.id, channel.code);
|
||||
}
|
||||
};
|
||||
|
||||
// 同步分销相关的外部表单数据到本地状态
|
||||
useEffect(() => {
|
||||
setDistributionEnabled(formData.distributionEnabled ?? false);
|
||||
setTempSelectedChannelIds(formData.distributionChannelIds || []);
|
||||
setCustomerReward(formData.distributionCustomerReward);
|
||||
setAddReward(formData.distributionAddReward);
|
||||
}, [
|
||||
formData.distributionEnabled,
|
||||
formData.distributionChannelIds,
|
||||
formData.distributionCustomerReward,
|
||||
formData.distributionAddReward,
|
||||
]);
|
||||
|
||||
// 加载分销渠道列表
|
||||
const loadDistributionChannels = useCallback(
|
||||
async (keyword: string = "", page: number = 1) => {
|
||||
setChannelLoading(true);
|
||||
try {
|
||||
const res = await fetchChannelList({
|
||||
page,
|
||||
limit: PAGE_SIZE,
|
||||
keyword: keyword.trim() || undefined,
|
||||
status: "enabled",
|
||||
});
|
||||
setChannelList(res.list || []);
|
||||
setChannelTotal(res.total || 0);
|
||||
} catch (error: any) {
|
||||
// 错误处理
|
||||
} finally {
|
||||
setChannelLoading(false);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleToggleDistribution = (value: boolean) => {
|
||||
setDistributionEnabled(value);
|
||||
if (!value) {
|
||||
setTempSelectedChannelIds([]);
|
||||
setCustomerReward(undefined);
|
||||
setAddReward(undefined);
|
||||
onChange({
|
||||
...formData,
|
||||
distributionEnabled: false,
|
||||
distributionChannelIds: [],
|
||||
distributionChannelsOptions: [],
|
||||
distributionCustomerReward: undefined,
|
||||
distributionAddReward: undefined,
|
||||
});
|
||||
} else {
|
||||
onChange({
|
||||
...formData,
|
||||
distributionEnabled: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 打开弹窗时获取第一页
|
||||
useEffect(() => {
|
||||
if (channelModalVisible) {
|
||||
setChannelSearchQuery("");
|
||||
setChannelCurrentPage(1);
|
||||
setTempSelectedChannelIds(formData.distributionChannelIds || []);
|
||||
loadDistributionChannels("", 1);
|
||||
}
|
||||
}, [channelModalVisible, loadDistributionChannels, formData.distributionChannelIds]);
|
||||
|
||||
// 搜索防抖
|
||||
useEffect(() => {
|
||||
if (!channelModalVisible) return;
|
||||
const timer = setTimeout(() => {
|
||||
setChannelCurrentPage(1);
|
||||
loadDistributionChannels(channelSearchQuery, 1);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [channelSearchQuery, channelModalVisible, loadDistributionChannels]);
|
||||
|
||||
// 翻页时重新请求
|
||||
useEffect(() => {
|
||||
if (!channelModalVisible) return;
|
||||
loadDistributionChannels(channelSearchQuery, channelCurrentPage);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [channelCurrentPage]);
|
||||
|
||||
const handleOpenChannelModal = () => {
|
||||
setChannelModalVisible(true);
|
||||
};
|
||||
|
||||
const handleChannelToggle = (channel: any) => {
|
||||
const id = channel.id;
|
||||
setTempSelectedChannelIds(prev =>
|
||||
prev.includes(id) ? prev.filter(v => v !== id) : [...prev, id],
|
||||
);
|
||||
};
|
||||
|
||||
const filteredChannels = channelList;
|
||||
const channelTotalPages = Math.max(1, Math.ceil(channelTotal / PAGE_SIZE));
|
||||
|
||||
// 全选当前页
|
||||
const handleSelectAllCurrentPage = (checked: boolean) => {
|
||||
if (checked) {
|
||||
const currentPageChannels = filteredChannels.filter(
|
||||
(channel: any) => !tempSelectedChannelIds.includes(channel.id),
|
||||
);
|
||||
setTempSelectedChannelIds(prev => [
|
||||
...prev,
|
||||
...currentPageChannels.map((c: any) => c.id),
|
||||
]);
|
||||
} else {
|
||||
const currentPageChannelIds = filteredChannels.map((c: any) => c.id);
|
||||
setTempSelectedChannelIds(prev =>
|
||||
prev.filter(id => !currentPageChannelIds.includes(id)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 检查当前页是否全选
|
||||
const isCurrentPageAllSelected =
|
||||
filteredChannels.length > 0 &&
|
||||
filteredChannels.every((channel: any) =>
|
||||
tempSelectedChannelIds.includes(channel.id),
|
||||
);
|
||||
|
||||
const handleConfirmChannels = () => {
|
||||
const selectedOptions =
|
||||
channelList
|
||||
.filter(c => tempSelectedChannelIds.includes(c.id))
|
||||
.map(c => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
code: c.code,
|
||||
})) || [];
|
||||
|
||||
onChange({
|
||||
...formData,
|
||||
distributionEnabled: true,
|
||||
distributionChannelIds: tempSelectedChannelIds,
|
||||
distributionChannelsOptions: selectedOptions,
|
||||
});
|
||||
setDistributionEnabled(true);
|
||||
setChannelModalVisible(false);
|
||||
};
|
||||
|
||||
const handleCancelChannels = () => {
|
||||
setChannelModalVisible(false);
|
||||
setTempSelectedChannelIds(formData.distributionChannelIds || []);
|
||||
};
|
||||
|
||||
// 获取显示文本
|
||||
const getChannelDisplayText = () => {
|
||||
const selectedChannels = formData.distributionChannelsOptions || [];
|
||||
if (selectedChannels.length === 0) return "";
|
||||
return `已选择 ${selectedChannels.length} 个渠道`;
|
||||
};
|
||||
|
||||
// 删除已选渠道
|
||||
const handleRemoveChannel = (id: string | number) => {
|
||||
const newChannelIds = (formData.distributionChannelIds || []).filter(
|
||||
(cid: string | number) => cid !== id
|
||||
);
|
||||
const newChannelOptions = (formData.distributionChannelsOptions || []).filter(
|
||||
(item: { id: string | number; name: string }) => item.id !== id
|
||||
);
|
||||
onChange({
|
||||
...formData,
|
||||
distributionChannelIds: newChannelIds,
|
||||
distributionChannelsOptions: newChannelOptions,
|
||||
});
|
||||
};
|
||||
|
||||
// 清除所有已选渠道
|
||||
const handleClearAllChannels = () => {
|
||||
onChange({
|
||||
...formData,
|
||||
distributionChannelIds: [],
|
||||
distributionChannelsOptions: [],
|
||||
});
|
||||
};
|
||||
|
||||
// ==================== 拉群设置相关函数 ====================
|
||||
const handleToggleGroupInvite = (value: boolean) => {
|
||||
onChange({
|
||||
...formData,
|
||||
groupInviteEnabled: value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleGroupNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange({
|
||||
...formData,
|
||||
groupName: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFixedMembersSelect = (friends: FriendSelectionItem[]) => {
|
||||
onChange({
|
||||
...formData,
|
||||
fixedGroupMembers: friends,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className={styles["basic-container"]}>
|
||||
@@ -289,6 +636,22 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 计划类型选择 - 仅管理员可见 */}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className={styles["basic-label"]}>计划类型</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Radio.Group
|
||||
value={formData.planType ?? 1}
|
||||
onChange={e => onChange({ ...formData, planType: e.target.value })}
|
||||
style={{ display: "flex", gap: 24 }}
|
||||
>
|
||||
<Radio value={0}>全局计划</Radio>
|
||||
<Radio value={1}>独立计划</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* 计划名称输入区 */}
|
||||
<div className={styles["basic-label"]}>计划名称</div>
|
||||
<div className={styles["basic-input-block"]}>
|
||||
@@ -579,6 +942,278 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分销设置 */}
|
||||
<div className={styles["basic-distribution"]}>
|
||||
<div className={styles["basic-distribution-header"]}>
|
||||
<div>
|
||||
<div className={styles["basic-distribution-title"]}>分销设置</div>
|
||||
<div className={styles["basic-distribution-desc"]}>
|
||||
开启后,可将当前场景的获客用户同步到指定分销渠道
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={distributionEnabled}
|
||||
onChange={handleToggleDistribution}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{distributionEnabled && (
|
||||
<>
|
||||
{/* 输入框 */}
|
||||
<div className={styles["distribution-input-wrapper"]}>
|
||||
<Input
|
||||
placeholder="选择分销渠道"
|
||||
value={getChannelDisplayText()}
|
||||
onClick={handleOpenChannelModal}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
onClear={handleClearAllChannels}
|
||||
size="large"
|
||||
readOnly
|
||||
style={{ cursor: "pointer" }}
|
||||
/>
|
||||
</div>
|
||||
{/* 已选渠道列表 */}
|
||||
{formData.distributionChannelsOptions &&
|
||||
formData.distributionChannelsOptions.length > 0 ? (
|
||||
<div
|
||||
className={styles["distribution-selected-list"]}
|
||||
style={{
|
||||
maxHeight: 300,
|
||||
overflowY: "auto",
|
||||
marginTop: 8,
|
||||
border: "1px solid #e5e6eb",
|
||||
borderRadius: 8,
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
{formData.distributionChannelsOptions.map(
|
||||
(item: { id: string | number; name: string; code?: string }) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={styles["distribution-selected-item"]}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "8px 12px",
|
||||
borderBottom: "1px solid #f0f0f0",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{/* 渠道图标 */}
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: "6px",
|
||||
background:
|
||||
"linear-gradient(135deg, #1677ff 0%, #0958d9 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "hidden",
|
||||
boxShadow: "0 2px 8px rgba(22, 119, 255, 0.25)",
|
||||
marginRight: "12px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: "#fff",
|
||||
fontWeight: 700,
|
||||
textShadow: "0 1px 3px rgba(0,0,0,0.3)",
|
||||
}}
|
||||
>
|
||||
{(item.name || "渠")[0]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
color: "#1a1a1a",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</div>
|
||||
{item.code && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "#8c8c8c",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
编码: {item.code}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 4, alignItems: "center" }}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<QrcodeOutlined />}
|
||||
size="small"
|
||||
style={{
|
||||
color: "#1890ff",
|
||||
border: "none",
|
||||
background: "none",
|
||||
minWidth: 24,
|
||||
height: 24,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
onClick={() => handleShowQRCode(item)}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
style={{
|
||||
color: "#ff4d4f",
|
||||
border: "none",
|
||||
background: "none",
|
||||
minWidth: 24,
|
||||
height: 24,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
onClick={() => handleRemoveChannel(item.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{/* 奖励金额设置 */}
|
||||
<div className={styles["distribution-rewards"]}>
|
||||
<div className={styles["basic-label"]}>获客奖励金额(元)</div>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="请输入获客奖励金额"
|
||||
value={customerReward}
|
||||
onChange={e => {
|
||||
const value = e.target.value ? Number(e.target.value) : undefined;
|
||||
setCustomerReward(value);
|
||||
onChange({
|
||||
...formData,
|
||||
distributionCustomerReward: value,
|
||||
});
|
||||
}}
|
||||
min={0}
|
||||
step={0.01}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
<div className={styles["basic-label"]}>添加奖励金额(元)</div>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="请输入添加奖励金额"
|
||||
value={addReward}
|
||||
onChange={e => {
|
||||
const value = e.target.value ? Number(e.target.value) : undefined;
|
||||
setAddReward(value);
|
||||
onChange({
|
||||
...formData,
|
||||
distributionAddReward: value,
|
||||
});
|
||||
}}
|
||||
min={0}
|
||||
step={0.01}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 拉群设置 */}
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
border: "1px solid #f0f0f0",
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 500,
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
拉群设置
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#8c8c8c" }}>
|
||||
开启后,可配置群名称和固定群成员,将用户引导到指定微信群
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={!!formData.groupInviteEnabled}
|
||||
onChange={handleToggleGroupInvite}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.groupInviteEnabled && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<Input
|
||||
placeholder="请输入群名称"
|
||||
value={formData.groupName || ""}
|
||||
onChange={handleGroupNameChange}
|
||||
/>
|
||||
{/* 固定成员选择 */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
marginBottom: 4,
|
||||
color: "#595959",
|
||||
}}
|
||||
>
|
||||
固定群成员
|
||||
</div>
|
||||
<FriendSelection
|
||||
selectedOptions={
|
||||
(formData.fixedGroupMembers || []) as FriendSelectionItem[]
|
||||
}
|
||||
onSelect={handleFixedMembersSelect}
|
||||
placeholder="选择固定群成员"
|
||||
showSelectedList={true}
|
||||
deviceIds={formData.deviceGroups || []}
|
||||
enableDeviceFilter={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles["basic-footer-switch"]}>
|
||||
<span>是否启用</span>
|
||||
<Switch
|
||||
@@ -587,6 +1222,255 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 分销渠道选择弹框 */}
|
||||
<Popup
|
||||
visible={channelModalVisible}
|
||||
onMaskClick={handleCancelChannels}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "100vh" }}
|
||||
closeOnMaskClick={false}
|
||||
>
|
||||
<Layout
|
||||
header={
|
||||
<PopupHeader
|
||||
title="选择分销渠道"
|
||||
searchQuery={channelSearchQuery}
|
||||
setSearchQuery={setChannelSearchQuery}
|
||||
searchPlaceholder="搜索渠道名称、编码..."
|
||||
loading={channelLoading}
|
||||
onRefresh={() => loadDistributionChannels(channelSearchQuery, channelCurrentPage)}
|
||||
showTabs={false}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<PopupFooter
|
||||
currentPage={channelCurrentPage}
|
||||
totalPages={channelTotalPages}
|
||||
loading={channelLoading}
|
||||
selectedCount={tempSelectedChannelIds.length}
|
||||
onPageChange={setChannelCurrentPage}
|
||||
onCancel={handleCancelChannels}
|
||||
onConfirm={handleConfirmChannels}
|
||||
isAllSelected={isCurrentPageAllSelected}
|
||||
onSelectAll={handleSelectAllCurrentPage}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className={styles["channelList"]}>
|
||||
{channelLoading && channelList.length === 0 ? (
|
||||
<div className={styles["loadingBox"]}>
|
||||
<div className={styles["loadingText"]}>加载中...</div>
|
||||
</div>
|
||||
) : filteredChannels.length === 0 ? (
|
||||
<div className={styles["loadingBox"]}>
|
||||
<div className={styles["loadingText"]}>
|
||||
暂无分销渠道,请先在「分销管理」中创建渠道
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles["channelListInner"]}>
|
||||
{filteredChannels.map((channel: any) => (
|
||||
<div key={channel.id} className={styles["channelItem"]}>
|
||||
<div className={styles["headerRow"]}>
|
||||
<div className={styles["checkboxContainer"]}>
|
||||
<Checkbox
|
||||
checked={tempSelectedChannelIds.includes(channel.id)}
|
||||
onChange={() => handleChannelToggle(channel)}
|
||||
className={styles["channelCheckbox"]}
|
||||
/>
|
||||
</div>
|
||||
<span className={styles["codeText"]}>
|
||||
编码: {channel.code}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles["mainContent"]}>
|
||||
<div className={styles["channelContent"]}>
|
||||
<div className={styles["channelInfoRow"]}>
|
||||
<span className={styles["channelName"]}>
|
||||
{channel.name}
|
||||
</span>
|
||||
<div
|
||||
className={
|
||||
channel.status === "enabled"
|
||||
? styles["statusEnabled"]
|
||||
: styles["statusDisabled"]
|
||||
}
|
||||
>
|
||||
{channel.status === "enabled" ? "启用" : "禁用"}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles["channelInfoDetail"]}>
|
||||
{channel.phone && (
|
||||
<div className={styles["infoItem"]}>
|
||||
<span className={styles["infoLabel"]}>手机号:</span>
|
||||
<span className={styles["infoValue"]}>
|
||||
{channel.phone}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{channel.wechatId && (
|
||||
<div className={styles["infoItem"]}>
|
||||
<span className={styles["infoLabel"]}>微信号:</span>
|
||||
<span className={styles["infoValue"]}>
|
||||
{channel.wechatId}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
</Popup>
|
||||
|
||||
{/* 二维码弹窗 */}
|
||||
<Popup
|
||||
visible={showQrDialog}
|
||||
onMaskClick={() => setShowQrDialog(false)}
|
||||
position="bottom"
|
||||
>
|
||||
<div style={{
|
||||
background: "#fff",
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
padding: "20px",
|
||||
maxHeight: "80vh",
|
||||
overflowY: "auto",
|
||||
}}>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 20,
|
||||
}}>
|
||||
<h3 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>
|
||||
{currentQrChannel?.name || "渠道"}二维码
|
||||
</h3>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setShowQrDialog(false)}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
}}>
|
||||
{currentQrChannel && (
|
||||
<>
|
||||
{qrCodeMap[currentQrChannel.id]?.loading ? (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "40px 20px",
|
||||
}}>
|
||||
<SpinLoading color="primary" style={{ fontSize: 32 }} />
|
||||
<div style={{ fontSize: 14, color: "#666" }}>生成二维码中...</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 二维码显示区域 */}
|
||||
{qrCodeMap[currentQrChannel.id]?.qrCode ? (
|
||||
<img
|
||||
src={qrCodeMap[currentQrChannel.id].qrCode}
|
||||
alt="渠道二维码"
|
||||
style={{
|
||||
width: 200,
|
||||
height: 200,
|
||||
border: "1px solid #e5e6eb",
|
||||
borderRadius: 8,
|
||||
padding: 8,
|
||||
background: "#fff",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "40px 20px",
|
||||
color: "#999",
|
||||
}}>
|
||||
<QrcodeOutlined style={{ fontSize: 48 }} />
|
||||
<div style={{ fontSize: 14 }}>二维码生成失败</div>
|
||||
<Button
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => currentQrChannel && generateChannelQRCode(currentQrChannel.id, currentQrChannel.code)}
|
||||
>
|
||||
重新生成
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* H5链接展示 */}
|
||||
{qrCodeMap[currentQrChannel.id]?.url && (
|
||||
<div style={{
|
||||
width: "100%",
|
||||
marginTop: 16,
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 14,
|
||||
color: "#666",
|
||||
marginBottom: 8,
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
H5链接
|
||||
</div>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
}}>
|
||||
<Input
|
||||
value={qrCodeMap[currentQrChannel.id].url}
|
||||
readOnly
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const link = qrCodeMap[currentQrChannel.id].url;
|
||||
navigator.clipboard.writeText(link);
|
||||
Toast.show({
|
||||
content: "链接已复制到剪贴板",
|
||||
position: "top",
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<CopyOutlined />
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
// 页面容器
|
||||
.auto-like-page {
|
||||
padding: 0 16px 24px;
|
||||
background: #f8fafc;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
@@ -252,6 +258,50 @@
|
||||
color: #999;
|
||||
}
|
||||
|
||||
// 计划分组(全局 / 独立)
|
||||
.info-box {
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.section-dot {
|
||||
width: 4px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.section-title-independent {
|
||||
.section-dot {
|
||||
background: #fb923c;
|
||||
}
|
||||
}
|
||||
|
||||
.plan-list-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
.task-info {
|
||||
@@ -272,7 +322,7 @@
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
padding: 0 12px;
|
||||
.auto-like-page {
|
||||
padding: 0 12px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ const AutoLike: React.FC = () => {
|
||||
const Res: any = await fetchAutoLikeTasks();
|
||||
// 数据在 data.list 中
|
||||
const taskList = Res?.data?.list || Res?.list || [];
|
||||
const mappedTasks = taskList.map((task: any) => {
|
||||
const mappedTasks: LikeTask[] = taskList.map((task: any) => {
|
||||
const config = task.config || {};
|
||||
const friends = config.friends || [];
|
||||
const devices = config.devices || [];
|
||||
@@ -142,6 +142,7 @@ const AutoLike: React.FC = () => {
|
||||
updateTime: task.updateTime || "",
|
||||
todayLikeCount: config.todayLikeCount || 0,
|
||||
totalLikeCount: config.totalLikeCount || 0,
|
||||
planType: config.planType ?? task.planType ?? 1,
|
||||
// 保留原始数据
|
||||
config: config,
|
||||
devices: devices,
|
||||
@@ -243,73 +244,18 @@ const AutoLike: React.FC = () => {
|
||||
task.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<NavCommon
|
||||
title="自动点赞"
|
||||
backFn={() => navigate("/workspace")}
|
||||
right={
|
||||
<Button size="small" color="primary" onClick={handleCreateNew}>
|
||||
<PlusOutlined /> 新建计划
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
// 按计划类型分组
|
||||
const globalTasks = filteredTasks.filter(t => t.planType === 0);
|
||||
const independentTasks = filteredTasks.filter(t => t.planType !== 0);
|
||||
|
||||
{/* 搜索栏 */}
|
||||
<div className="search-bar">
|
||||
<div className="search-input-wrapper">
|
||||
<Input
|
||||
placeholder="搜索计划名称"
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={fetchTasks}
|
||||
loading={loading}
|
||||
className="refresh-btn"
|
||||
>
|
||||
<ReloadOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={style["auto-like-page"]}>
|
||||
{/* 任务列表 */}
|
||||
<div className={style["task-list"]}>
|
||||
{loading ? (
|
||||
<div className={style["loading"]}>
|
||||
<SpinLoading color="primary" />
|
||||
<div className={style["loading-text"]}>加载中...</div>
|
||||
</div>
|
||||
) : filteredTasks.length === 0 ? (
|
||||
<div className={style["empty-state"]}>
|
||||
<div className={style["empty-icon"]}>
|
||||
<LikeOutlined />
|
||||
</div>
|
||||
<div className={style["empty-text"]}>暂无自动点赞任务</div>
|
||||
<div className={style["empty-subtext"]}>
|
||||
点击右上角按钮创建新任务
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
filteredTasks.map(task => (
|
||||
const renderTaskCard = (task: LikeTask) => (
|
||||
<Card key={task.id} className={style["task-card"]}>
|
||||
<div className={style["task-header"]}>
|
||||
<div className={style["task-title-section"]}>
|
||||
<h3 className={style["task-name"]}>{task.name}</h3>
|
||||
<span
|
||||
className={`${style["task-status"]} ${
|
||||
Number(task.status) === 1
|
||||
? style["active"]
|
||||
: style["inactive"]
|
||||
Number(task.status) === 1 ? style["active"] : style["inactive"]
|
||||
}`}
|
||||
>
|
||||
{Number(task.status) === 1 ? "进行中" : "已暂停"}
|
||||
@@ -320,9 +266,7 @@ const AutoLike: React.FC = () => {
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Number(task.status) === 1}
|
||||
onChange={() =>
|
||||
toggleTaskStatus(task.id, Number(task.status))
|
||||
}
|
||||
onChange={() => toggleTaskStatus(task.id, Number(task.status))}
|
||||
/>
|
||||
<span className={style["slider"]}></span>
|
||||
</label>
|
||||
@@ -339,15 +283,11 @@ const AutoLike: React.FC = () => {
|
||||
<div className={style["info-section"]}>
|
||||
<div className={style["info-item"]}>
|
||||
<span className={style["info-label"]}>执行设备:</span>
|
||||
<span className={style["info-value"]}>
|
||||
{task.deviceCount} 个
|
||||
</span>
|
||||
<span className={style["info-value"]}>{task.deviceCount} 个</span>
|
||||
</div>
|
||||
<div className={style["info-item"]}>
|
||||
<span className={style["info-label"]}>目标人群:</span>
|
||||
<span className={style["info-value"]}>
|
||||
{task.targetGroup}
|
||||
</span>
|
||||
<span className={style["info-value"]}>{task.targetGroup}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["info-section"]}>
|
||||
@@ -387,10 +327,109 @@ const AutoLike: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
);
|
||||
|
||||
let content: React.ReactNode;
|
||||
if (loading) {
|
||||
content = (
|
||||
<div className={style["task-list"]}>
|
||||
<div className={style["loading"]}>
|
||||
<SpinLoading color="primary" />
|
||||
<div className={style["loading-text"]}>加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (filteredTasks.length === 0) {
|
||||
content = (
|
||||
<div className={style["task-list"]}>
|
||||
<div className={style["empty-state"]}>
|
||||
<div className={style["empty-icon"]}>
|
||||
<LikeOutlined />
|
||||
</div>
|
||||
<div className={style["empty-text"]}>暂无自动点赞任务</div>
|
||||
<div className={style["empty-subtext"]}>
|
||||
点击右上角按钮创建新任务
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<>
|
||||
{globalTasks.length > 0 && (
|
||||
<div className={style["info-box"]}>
|
||||
全局计划将应用于所有设备(包括新添加的设备),请谨慎配置点赞频率和数量,避免账号风险。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{globalTasks.length > 0 && (
|
||||
<section className={style["section"]}>
|
||||
<h3 className={style["section-title"]}>
|
||||
<span className={style["section-dot"]} />
|
||||
全局自动点赞计划
|
||||
</h3>
|
||||
<div className={style["plan-list-group"]}>
|
||||
{globalTasks.map(renderTaskCard)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{independentTasks.length > 0 && (
|
||||
<section className={style["section"]}>
|
||||
<h3
|
||||
className={`${style["section-title"]} ${style["section-title-independent"]}`}
|
||||
>
|
||||
<span className={style["section-dot"]} />
|
||||
独立自动点赞计划
|
||||
</h3>
|
||||
<div className={style["plan-list-group"]}>
|
||||
{independentTasks.map(renderTaskCard)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<NavCommon
|
||||
title="自动点赞"
|
||||
backFn={() => navigate("/workspace")}
|
||||
right={
|
||||
<Button size="small" color="primary" onClick={handleCreateNew}>
|
||||
<PlusOutlined /> 新建计划
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 搜索栏 */}
|
||||
<div className="search-bar">
|
||||
<div className="search-input-wrapper">
|
||||
<Input
|
||||
placeholder="搜索计划名称"
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={fetchTasks}
|
||||
loading={loading}
|
||||
className="refresh-btn"
|
||||
>
|
||||
<ReloadOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={style["auto-like-page"]}>{content}</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -69,6 +69,8 @@ export interface LikeTask {
|
||||
todayLikeCount: number;
|
||||
totalLikeCount: number;
|
||||
updateTime: string;
|
||||
// 计划类型:0-全局计划,1-独立计划
|
||||
planType?: number;
|
||||
}
|
||||
|
||||
// 创建任务数据
|
||||
@@ -81,12 +83,15 @@ export interface CreateLikeTaskData {
|
||||
contentTypes: ContentType[];
|
||||
deviceGroups: number[];
|
||||
deviceGroupsOptions: DeviceSelectionItem[];
|
||||
friendsGroups: number[];
|
||||
friendsGroupsOptions: FriendSelectionItem[];
|
||||
// 实际使用的好友字段(兼容旧字段)
|
||||
wechatFriends?: number[];
|
||||
wechatFriendsOptions?: FriendSelectionItem[];
|
||||
friendMaxLikes: number;
|
||||
friendTags?: string;
|
||||
enableFriendTags: boolean;
|
||||
targetTags: string[];
|
||||
// 计划类型:0-全局计划,1-独立计划
|
||||
planType?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { PlusOutlined, MinusOutlined } from "@ant-design/icons";
|
||||
import { Button, Input, Switch, Spin, message } from "antd";
|
||||
import { Button, Input, Switch, Spin, message, Radio } from "antd";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import DeviceSelection from "@/components/DeviceSelection";
|
||||
import FriendSelection from "@/components/FriendSelection";
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
fetchAutoLikeTaskDetail,
|
||||
} from "./api";
|
||||
import { CreateLikeTaskData, ContentType } from "./data";
|
||||
import { useUserStore } from "@/store/module/user";
|
||||
import style from "./new.module.scss";
|
||||
|
||||
const contentTypeLabels: Record<ContentType, string> = {
|
||||
@@ -32,12 +33,15 @@ const NewAutoLike: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isEditMode = !!id;
|
||||
const { user } = useUserStore();
|
||||
const isAdmin = user?.isAdmin === 1;
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(isEditMode);
|
||||
const [autoEnabled, setAutoEnabled] = useState(false);
|
||||
const [selectAllFriends, setSelectAllFriends] = useState(false);
|
||||
const [formData, setFormData] = useState<CreateLikeTaskData>({
|
||||
planType: 1, // 默认独立计划
|
||||
name: "",
|
||||
interval: 5,
|
||||
maxLikes: 200,
|
||||
@@ -67,6 +71,7 @@ const NewAutoLike: React.FC = () => {
|
||||
if (taskDetail) {
|
||||
const config = (taskDetail as any).config || taskDetail;
|
||||
setFormData({
|
||||
planType: config.planType ?? (taskDetail as any).planType ?? 1,
|
||||
name: taskDetail.name || "",
|
||||
interval: config.likeInterval || config.interval || 5,
|
||||
maxLikes: config.maxLikesPerDay || config.maxLikes || 200,
|
||||
@@ -184,7 +189,22 @@ const NewAutoLike: React.FC = () => {
|
||||
|
||||
// 步骤1:基础设置
|
||||
const renderBasicSettings = () => (
|
||||
<div className={style.basicSection}>
|
||||
<div className={style.container}>
|
||||
{/* 计划类型和任务名称 */}
|
||||
<div className={style.card}>
|
||||
{isAdmin && (
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>计划类型</div>
|
||||
<Radio.Group
|
||||
value={formData.planType}
|
||||
onChange={e => handleUpdateFormData({ planType: e.target.value })}
|
||||
className={style.radioGroup}
|
||||
>
|
||||
<Radio value={0}>全局计划</Radio>
|
||||
<Radio value={1}>独立计划</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)}
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>任务名称</div>
|
||||
<Input
|
||||
@@ -194,6 +214,10 @@ const NewAutoLike: React.FC = () => {
|
||||
className={style.input}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 点赞间隔 */}
|
||||
<div className={style.card}>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>点赞间隔</div>
|
||||
<div className={style.counterRow}>
|
||||
@@ -231,6 +255,10 @@ const NewAutoLike: React.FC = () => {
|
||||
</div>
|
||||
<div className={style.counterTip}>设置两次点赞之间的最小时间间隔</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 每日最大点赞数 */}
|
||||
<div className={style.card}>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>每日最大点赞数</div>
|
||||
<div className={style.counterRow}>
|
||||
@@ -268,6 +296,10 @@ const NewAutoLike: React.FC = () => {
|
||||
</div>
|
||||
<div className={style.counterTip}>设置每天最多点赞的次数</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 点赞时间范围 */}
|
||||
<div className={style.card}>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>点赞时间范围</div>
|
||||
<div className={style.timeRow}>
|
||||
@@ -287,6 +319,10 @@ const NewAutoLike: React.FC = () => {
|
||||
</div>
|
||||
<div className={style.counterTip}>设置每天可以点赞的时间段</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 点赞内容类型 */}
|
||||
<div className={style.card}>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>点赞内容类型</div>
|
||||
<div className={style.contentTypes}>
|
||||
@@ -311,6 +347,10 @@ const NewAutoLike: React.FC = () => {
|
||||
</div>
|
||||
<div className={style.counterTip}>选择要点赞的内容类型</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 好友标签和自动开启 */}
|
||||
<div className={style.card}>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.switchRow}>
|
||||
<span className={style.switchLabel}>启用好友标签</span>
|
||||
@@ -323,7 +363,7 @@ const NewAutoLike: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
{formData.enableFriendTags && (
|
||||
<div className={style.formItem}>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Input
|
||||
placeholder="请输入标签"
|
||||
value={formData.friendTags}
|
||||
@@ -346,6 +386,8 @@ const NewAutoLike: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
@@ -361,7 +403,8 @@ const NewAutoLike: React.FC = () => {
|
||||
|
||||
// 步骤2:设备选择
|
||||
const renderDeviceSelection = () => (
|
||||
<div className={style.basicSection}>
|
||||
<div className={style.container}>
|
||||
<div className={style.card}>
|
||||
<div className={style.formItem}>
|
||||
<DeviceSelection
|
||||
selectedOptions={formData.deviceGroupsOptions}
|
||||
@@ -375,11 +418,13 @@ const NewAutoLike: React.FC = () => {
|
||||
showSelectedList={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 16, marginTop: 16 }}>
|
||||
<Button
|
||||
onClick={handlePrev}
|
||||
className={style.prevBtn}
|
||||
size="large"
|
||||
style={{ marginRight: 16 }}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
上一步
|
||||
</Button>
|
||||
@@ -389,15 +434,18 @@ const NewAutoLike: React.FC = () => {
|
||||
className={style.nextBtn}
|
||||
size="large"
|
||||
disabled={formData.deviceGroups.length === 0}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 步骤3:好友设置
|
||||
const renderFriendSettings = () => (
|
||||
<div className={style.basicSection}>
|
||||
<div className={style.container}>
|
||||
<div className={style.card}>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.friendSelectionHeader}>
|
||||
<div className={style.formLabel}>选择好友</div>
|
||||
@@ -433,11 +481,13 @@ const NewAutoLike: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 16, marginTop: 16 }}>
|
||||
<Button
|
||||
onClick={handlePrev}
|
||||
className={style.prevBtn}
|
||||
size="large"
|
||||
style={{ marginRight: 16 }}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
上一步
|
||||
</Button>
|
||||
@@ -451,9 +501,11 @@ const NewAutoLike: React.FC = () => {
|
||||
!selectAllFriends &&
|
||||
(!formData.wechatFriends || formData.wechatFriends.length === 0)
|
||||
}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{isEditMode ? "更新任务" : "创建任务"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,10 +1,39 @@
|
||||
.formBg {
|
||||
background: #f8f6f3;
|
||||
background: #f8fafc;
|
||||
min-height: 100vh;
|
||||
padding: 0 0 80px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 16px;
|
||||
background: #f8fafc;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 100px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
|
||||
@media (max-width: 375px) {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
@media (max-width: 375px) {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.basicSection {
|
||||
background: none;
|
||||
border-radius: 0;
|
||||
@@ -26,10 +55,31 @@
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.radioGroup {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
font-size: 15px;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
}
|
||||
|
||||
.timeRow {
|
||||
|
||||
@@ -56,7 +56,7 @@ export function fetchImportRecords(
|
||||
keyword?: string,
|
||||
): Promise<PaginatedResponse<ContactImportRecord>> {
|
||||
return request(
|
||||
"/v1/workbench/import-records",
|
||||
"/v1/workbench/import-contact",
|
||||
{
|
||||
workbenchId,
|
||||
page,
|
||||
|
||||
@@ -1,11 +1,58 @@
|
||||
.formBg {
|
||||
background: #f8fafc;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
|
||||
.basicSection {
|
||||
background: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
padding: 24px 16px 0 16px;
|
||||
padding: 16px;
|
||||
background: #f8fafc;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 100px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
@media (max-width: 375px) {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
@media (max-width: 375px) {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
// 输入框样式
|
||||
:global(.ant-input) {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.formItem {
|
||||
@@ -20,6 +67,12 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.radioGroup {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.input {
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { PlusOutlined, MinusOutlined } from "@ant-design/icons";
|
||||
import { Button, Input, message, TimePicker, Select, Switch } from "antd";
|
||||
import { Button, Input, message, TimePicker, Select, Switch, Radio, Card } from "antd";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import DeviceSelection from "@/components/DeviceSelection";
|
||||
@@ -16,14 +16,18 @@ import { PoolSelectionItem } from "@/components/PoolSelection/data";
|
||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||
import style from "./index.module.scss";
|
||||
import dayjs from "dayjs";
|
||||
import { useUserStore } from "@/store/module/user";
|
||||
|
||||
const ContactImportForm: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
const isEdit = !!id;
|
||||
const { user } = useUserStore();
|
||||
const isAdmin = user?.isAdmin === 1;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
planType: 1, // 计划类型:0-全局计划,1-独立计划
|
||||
name: "", // 任务名称
|
||||
status: 1, // 是否启用,默认启用
|
||||
type: 6, // 任务类型,固定为6
|
||||
@@ -89,6 +93,7 @@ const ContactImportForm: React.FC = () => {
|
||||
endTime: config.endTime ? dayjs(config.endTime, "HH:mm") : null,
|
||||
deviceGroupsOptions,
|
||||
poolGroupsOptions,
|
||||
planType: config.planType ?? (data as any).planType ?? 1,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -146,6 +151,7 @@ const ContactImportForm: React.FC = () => {
|
||||
remark: formData.remark || null,
|
||||
startTime: formData.startTime?.format("HH:mm") || null,
|
||||
endTime: formData.endTime?.format("HH:mm") || null,
|
||||
planType: (formData as any).planType ?? 1,
|
||||
};
|
||||
|
||||
if (isEdit && id) {
|
||||
@@ -220,158 +226,191 @@ const ContactImportForm: React.FC = () => {
|
||||
>
|
||||
<div className={style.formBg}>
|
||||
<div className={style.basicSection}>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>任务名称</div>
|
||||
<Input
|
||||
placeholder="请输入任务名称"
|
||||
value={formData.name}
|
||||
onChange={e => handleUpdateFormData({ name: e.target.value })}
|
||||
className={style.input}
|
||||
/>
|
||||
<div className={style.counterTip}>为此导入任务设置一个名称</div>
|
||||
</div>
|
||||
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>设备选择</div>
|
||||
<DeviceSelection
|
||||
selectedOptions={formData.deviceGroupsOptions}
|
||||
onSelect={handleDeviceSelect}
|
||||
placeholder="请选择设备"
|
||||
className={style.deviceSelection}
|
||||
/>
|
||||
<div className={style.counterTip}>选择要分配联系人的设备</div>
|
||||
</div>
|
||||
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>流量池选择</div>
|
||||
<PoolSelection
|
||||
selectedOptions={formData.poolGroupsOptions}
|
||||
onSelect={handlePoolSelect}
|
||||
placeholder="请选择流量池"
|
||||
className={style.poolSelection}
|
||||
/>
|
||||
<div className={style.counterTip}>选择要导入的流量池</div>
|
||||
</div>
|
||||
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>分配数量</div>
|
||||
<div className={style.stepperContainer}>
|
||||
<Button
|
||||
icon={<MinusOutlined />}
|
||||
onClick={() =>
|
||||
handleUpdateFormData({ num: Math.max(1, formData.num - 1) })
|
||||
}
|
||||
disabled={formData.num <= 1}
|
||||
className={style.stepperButton}
|
||||
/>
|
||||
<Input
|
||||
value={formData.num}
|
||||
onChange={e => {
|
||||
const value = parseInt(e.target.value) || 1;
|
||||
handleUpdateFormData({
|
||||
num: Math.min(1000, Math.max(1, value)),
|
||||
});
|
||||
}}
|
||||
className={style.stepperInput}
|
||||
/>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() =>
|
||||
handleUpdateFormData({
|
||||
num: Math.min(1000, formData.num + 1),
|
||||
})
|
||||
}
|
||||
disabled={formData.num >= 1000}
|
||||
className={style.stepperButton}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.counterTip}>要分配给设备的联系人数量</div>
|
||||
</div>
|
||||
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>清除现有联系人</div>
|
||||
<Switch
|
||||
checked={formData.clearContact === 1}
|
||||
onChange={checked =>
|
||||
handleUpdateFormData({ clearContact: checked ? 1 : 0 })
|
||||
}
|
||||
/>
|
||||
<div className={style.counterTip}>是否清除设备上现有的联系人</div>
|
||||
</div>
|
||||
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>备注类型</div>
|
||||
<Select
|
||||
placeholder="请选择备注类型"
|
||||
value={formData.remarkType}
|
||||
onChange={value => handleUpdateFormData({ remarkType: value })}
|
||||
className={style.select}
|
||||
>
|
||||
<Select.Option value={0}>不备注</Select.Option>
|
||||
<Select.Option value={1}>年月日</Select.Option>
|
||||
<Select.Option value={2}>月日</Select.Option>
|
||||
<Select.Option value={3}>自定义</Select.Option>
|
||||
</Select>
|
||||
<div className={style.counterTip}>选择联系人备注的格式</div>
|
||||
</div>
|
||||
|
||||
{formData.remarkType === 3 && (
|
||||
{/* 计划类型和任务名称 */}
|
||||
<Card className={style.card}>
|
||||
{isAdmin && (
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>计划类型</div>
|
||||
<Radio.Group
|
||||
value={(formData as any).planType}
|
||||
onChange={e =>
|
||||
handleUpdateFormData({ planType: e.target.value })
|
||||
}
|
||||
className={style.radioGroup}
|
||||
>
|
||||
<Radio value={0}>全局计划</Radio>
|
||||
<Radio value={1}>独立计划</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)}
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>自定义备注</div>
|
||||
<div className={style.formLabel}>任务名称</div>
|
||||
<Input
|
||||
placeholder="请输入备注内容"
|
||||
value={formData.remark}
|
||||
onChange={e => handleUpdateFormData({ remark: e.target.value })}
|
||||
placeholder="请输入任务名称"
|
||||
value={formData.name}
|
||||
onChange={e => handleUpdateFormData({ name: e.target.value })}
|
||||
className={style.input}
|
||||
/>
|
||||
<div className={style.counterTip}>输入自定义的备注内容</div>
|
||||
<div className={style.counterTip}>为此导入任务设置一个名称</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>开始时间</div>
|
||||
<TimePicker
|
||||
value={formData.startTime}
|
||||
onChange={time =>
|
||||
handleUpdateFormData({
|
||||
startTime: time,
|
||||
})
|
||||
}
|
||||
format="HH:mm"
|
||||
placeholder="请选择开始时间"
|
||||
className={style.timePicker}
|
||||
/>
|
||||
<div className={style.counterTip}>设置每天开始导入的时间</div>
|
||||
</div>
|
||||
{/* 设备选择 */}
|
||||
<Card className={style.card}>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>设备选择</div>
|
||||
<DeviceSelection
|
||||
selectedOptions={formData.deviceGroupsOptions}
|
||||
onSelect={handleDeviceSelect}
|
||||
placeholder="请选择设备"
|
||||
className={style.deviceSelection}
|
||||
/>
|
||||
<div className={style.counterTip}>选择要分配联系人的设备</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>结束时间</div>
|
||||
<TimePicker
|
||||
value={formData.endTime}
|
||||
onChange={time =>
|
||||
handleUpdateFormData({
|
||||
endTime: time,
|
||||
})
|
||||
}
|
||||
format="HH:mm"
|
||||
placeholder="请选择结束时间"
|
||||
className={style.timePicker}
|
||||
/>
|
||||
<div className={style.counterTip}>设置每天结束导入的时间</div>
|
||||
</div>
|
||||
{/* 流量池选择 */}
|
||||
<Card className={style.card}>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>流量池选择</div>
|
||||
<PoolSelection
|
||||
selectedOptions={formData.poolGroupsOptions}
|
||||
onSelect={handlePoolSelect}
|
||||
placeholder="请选择流量池"
|
||||
className={style.poolSelection}
|
||||
/>
|
||||
<div className={style.counterTip}>选择要导入的流量池</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div
|
||||
className={style.formItem}
|
||||
style={{ display: "flex", justifyContent: "space-between" }}
|
||||
>
|
||||
<span>是否启用</span>
|
||||
<Switch
|
||||
checked={formData.status === 1}
|
||||
onChange={check =>
|
||||
handleUpdateFormData({ status: check ? 1 : 0 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/* 分配数量 */}
|
||||
<Card className={style.card}>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>分配数量</div>
|
||||
<div className={style.stepperContainer}>
|
||||
<Button
|
||||
icon={<MinusOutlined />}
|
||||
onClick={() =>
|
||||
handleUpdateFormData({ num: Math.max(1, formData.num - 1) })
|
||||
}
|
||||
disabled={formData.num <= 1}
|
||||
className={style.stepperButton}
|
||||
/>
|
||||
<Input
|
||||
value={formData.num}
|
||||
onChange={e => {
|
||||
const value = parseInt(e.target.value) || 1;
|
||||
handleUpdateFormData({
|
||||
num: Math.min(1000, Math.max(1, value)),
|
||||
});
|
||||
}}
|
||||
className={style.stepperInput}
|
||||
/>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() =>
|
||||
handleUpdateFormData({
|
||||
num: Math.min(1000, formData.num + 1),
|
||||
})
|
||||
}
|
||||
disabled={formData.num >= 1000}
|
||||
className={style.stepperButton}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.counterTip}>要分配给设备的联系人数量</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 清除现有联系人和备注类型 */}
|
||||
<Card className={style.card}>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>清除现有联系人</div>
|
||||
<Switch
|
||||
checked={formData.clearContact === 1}
|
||||
onChange={checked =>
|
||||
handleUpdateFormData({ clearContact: checked ? 1 : 0 })
|
||||
}
|
||||
/>
|
||||
<div className={style.counterTip}>是否清除设备上现有的联系人</div>
|
||||
</div>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>备注类型</div>
|
||||
<Select
|
||||
placeholder="请选择备注类型"
|
||||
value={formData.remarkType}
|
||||
onChange={value => handleUpdateFormData({ remarkType: value })}
|
||||
className={style.select}
|
||||
>
|
||||
<Select.Option value={0}>不备注</Select.Option>
|
||||
<Select.Option value={1}>年月日</Select.Option>
|
||||
<Select.Option value={2}>月日</Select.Option>
|
||||
<Select.Option value={3}>自定义</Select.Option>
|
||||
</Select>
|
||||
<div className={style.counterTip}>选择联系人备注的格式</div>
|
||||
</div>
|
||||
{formData.remarkType === 3 && (
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>自定义备注</div>
|
||||
<Input
|
||||
placeholder="请输入备注内容"
|
||||
value={formData.remark}
|
||||
onChange={e => handleUpdateFormData({ remark: e.target.value })}
|
||||
className={style.input}
|
||||
/>
|
||||
<div className={style.counterTip}>输入自定义的备注内容</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 时间设置 */}
|
||||
<Card className={style.card}>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>开始时间</div>
|
||||
<TimePicker
|
||||
value={formData.startTime}
|
||||
onChange={time =>
|
||||
handleUpdateFormData({
|
||||
startTime: time,
|
||||
})
|
||||
}
|
||||
format="HH:mm"
|
||||
placeholder="请选择开始时间"
|
||||
className={style.timePicker}
|
||||
/>
|
||||
<div className={style.counterTip}>设置每天开始导入的时间</div>
|
||||
</div>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>结束时间</div>
|
||||
<TimePicker
|
||||
value={formData.endTime}
|
||||
onChange={time =>
|
||||
handleUpdateFormData({
|
||||
endTime: time,
|
||||
})
|
||||
}
|
||||
format="HH:mm"
|
||||
placeholder="请选择结束时间"
|
||||
className={style.timePicker}
|
||||
/>
|
||||
<div className={style.counterTip}>设置每天结束导入的时间</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 是否启用 */}
|
||||
<Card className={style.card}>
|
||||
<div
|
||||
className={style.formItem}
|
||||
style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}
|
||||
>
|
||||
<span className={style.formLabel}>是否启用</span>
|
||||
<Switch
|
||||
checked={formData.status === 1}
|
||||
onChange={check =>
|
||||
handleUpdateFormData({ status: check ? 1 : 0 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
@@ -62,7 +62,7 @@ export function fetchImportRecords(
|
||||
keyword?: string,
|
||||
): Promise<PaginatedResponse<ContactImportRecord>> {
|
||||
return request(
|
||||
"/v1/workbench/import-records",
|
||||
"/v1/workbench/import-contact",
|
||||
{
|
||||
workbenchId,
|
||||
page,
|
||||
|
||||
@@ -46,6 +46,49 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.infoBox {
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.sectionDot {
|
||||
width: 4px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.sectionTitleIndependent {
|
||||
.sectionDot {
|
||||
background: #fb923c;
|
||||
}
|
||||
}
|
||||
|
||||
.planListGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -115,6 +158,16 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.planTypeTag {
|
||||
margin-left: 8px;
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #bfdbfe;
|
||||
color: #1d4ed8;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.taskStatus {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -97,9 +97,13 @@ const ContactImport: React.FC = () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetchContactImportTasks();
|
||||
const data = response?.list || [];
|
||||
setTasks(data);
|
||||
setFilteredTasks(data);
|
||||
const data: ContactImportTask[] = response?.list || [];
|
||||
const normalized = data.map(task => ({
|
||||
...task,
|
||||
planType: task.config?.planType ?? (task as any).planType ?? 1,
|
||||
}));
|
||||
setTasks(normalized);
|
||||
setFilteredTasks(normalized);
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
content: "获取任务列表失败",
|
||||
@@ -211,6 +215,11 @@ const ContactImport: React.FC = () => {
|
||||
loadTasks();
|
||||
}, []);
|
||||
|
||||
const globalTasks = filteredTasks.filter(task => (task as any).planType === 0);
|
||||
const independentTasks = filteredTasks.filter(
|
||||
task => (task as any).planType !== 0,
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
@@ -252,92 +261,184 @@ const ContactImport: React.FC = () => {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={style.container}>
|
||||
{/* 任务列表 */}
|
||||
<div className={style.taskList}>
|
||||
{loading ? (
|
||||
<div className={style.loading}>
|
||||
<SpinLoading /> 加载中...
|
||||
</div>
|
||||
) : filteredTasks.length === 0 ? (
|
||||
<div className={style.empty}>
|
||||
<ContactsOutlined className={style.emptyIcon} />
|
||||
<div className={style.emptyText}>
|
||||
{searchKeyword ? "未找到相关任务" : "暂无通讯录导入任务"}
|
||||
<div className={style.container}>
|
||||
{/* 任务列表 */}
|
||||
<div className={style.taskList}>
|
||||
{loading ? (
|
||||
<div className={style.loading}>
|
||||
<SpinLoading /> 加载中...
|
||||
</div>
|
||||
{!searchKeyword && (
|
||||
<Button
|
||||
color="primary"
|
||||
size="small"
|
||||
onClick={() => navigate("/workspace/contact-import/form")}
|
||||
>
|
||||
<PlusOutlined /> 创建第一个任务
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
filteredTasks.map(task => (
|
||||
<Card key={task.id} className={style.taskCard}>
|
||||
<div className={style.cardHeader}>
|
||||
<div className={style.taskInfo}>
|
||||
<div className={style.taskName}>{task.name}</div>
|
||||
<div
|
||||
className={style.taskStatus}
|
||||
style={{ color: getStatusColor(task.status) }}
|
||||
>
|
||||
{getStatusText(task.status)}
|
||||
) : filteredTasks.length === 0 ? (
|
||||
<div className={style.empty}>
|
||||
<ContactsOutlined className={style.emptyIcon} />
|
||||
<div className={style.emptyText}>
|
||||
{searchKeyword ? "未找到相关任务" : "暂无通讯录导入任务"}
|
||||
</div>
|
||||
{!searchKeyword && (
|
||||
<Button
|
||||
color="primary"
|
||||
size="small"
|
||||
onClick={() => navigate("/workspace/contact-import/form")}
|
||||
>
|
||||
<PlusOutlined /> 创建第一个任务
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{globalTasks.length > 0 && (
|
||||
<div className={style.infoBox}>
|
||||
全局通讯录导入计划将作用于所有设备,请谨慎配置导入数量与时间。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{globalTasks.length > 0 && (
|
||||
<section className={style.section}>
|
||||
<h3 className={style.sectionTitle}>
|
||||
<span className={style.sectionDot} />
|
||||
全局通讯录导入计划
|
||||
</h3>
|
||||
<div className={style.planListGroup}>
|
||||
{globalTasks.map(task => (
|
||||
<Card key={task.id} className={style.taskCard}>
|
||||
<div className={style.cardHeader}>
|
||||
<div className={style.taskInfo}>
|
||||
<div className={style.taskName}>{task.name}</div>
|
||||
<div
|
||||
className={style.taskStatus}
|
||||
style={{ color: getStatusColor(task.status) }}
|
||||
>
|
||||
{getStatusText(task.status)}
|
||||
</div>
|
||||
</div>
|
||||
<CardMenu
|
||||
onView={() => handleView(task.id)}
|
||||
onEdit={() => handleEdit(task.id)}
|
||||
onCopy={() => handleCopy(task.id)}
|
||||
onDelete={() => handleDelete(task.id)}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.cardContent}>
|
||||
<div className={style.taskDetail}>
|
||||
<span className={style.label}>备注类型:</span>
|
||||
<span className={style.value}>
|
||||
{task.config?.remarkType === 1 ? "自定义备注" : "其他"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style.taskDetail}>
|
||||
<span className={style.label}>设备数量:</span>
|
||||
<span className={style.value}>
|
||||
{task.config?.devices?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style.taskDetail}>
|
||||
<span className={style.label}>导入数量:</span>
|
||||
<span className={style.value}>
|
||||
{task.config?.num || 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style.taskDetail}>
|
||||
<span className={style.label}>创建时间:</span>
|
||||
<span className={style.value}>{task.createTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.cardActions}>
|
||||
<Button
|
||||
size="small"
|
||||
fill="none"
|
||||
onClick={() => handleToggleStatus(task)}
|
||||
>
|
||||
{task.status === 1 ? "暂停" : "启动"}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
fill="none"
|
||||
onClick={() => handleView(task.id)}
|
||||
>
|
||||
查看记录
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<CardMenu
|
||||
onView={() => handleView(task.id)}
|
||||
onEdit={() => handleEdit(task.id)}
|
||||
onCopy={() => handleCopy(task.id)}
|
||||
onDelete={() => handleDelete(task.id)}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.cardContent}>
|
||||
<div className={style.taskDetail}>
|
||||
<span className={style.label}>备注类型:</span>
|
||||
<span className={style.value}>
|
||||
{task.config?.remarkType === 1 ? "自定义备注" : "其他"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style.taskDetail}>
|
||||
<span className={style.label}>设备数量:</span>
|
||||
<span className={style.value}>
|
||||
{task.config?.devices?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style.taskDetail}>
|
||||
<span className={style.label}>导入数量:</span>
|
||||
<span className={style.value}>{task.config?.num || 0}</span>
|
||||
</div>
|
||||
<div className={style.taskDetail}>
|
||||
<span className={style.label}>创建时间:</span>
|
||||
<span className={style.value}>{task.createTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.cardActions}>
|
||||
<Button
|
||||
size="small"
|
||||
fill="none"
|
||||
onClick={() => handleToggleStatus(task)}
|
||||
>
|
||||
{task.status === 1 ? "暂停" : "启动"}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
fill="none"
|
||||
onClick={() => handleView(task.id)}
|
||||
>
|
||||
查看记录
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{independentTasks.length > 0 && (
|
||||
<section className={style.section}>
|
||||
<h3 className={`${style.sectionTitle} ${style.sectionTitleIndependent}`}>
|
||||
<span className={style.sectionDot} />
|
||||
独立通讯录导入计划
|
||||
</h3>
|
||||
<div className={style.planListGroup}>
|
||||
{independentTasks.map(task => (
|
||||
<Card key={task.id} className={style.taskCard}>
|
||||
<div className={style.cardHeader}>
|
||||
<div className={style.taskInfo}>
|
||||
<div className={style.taskName}>{task.name}</div>
|
||||
<div
|
||||
className={style.taskStatus}
|
||||
style={{ color: getStatusColor(task.status) }}
|
||||
>
|
||||
{getStatusText(task.status)}
|
||||
</div>
|
||||
</div>
|
||||
<CardMenu
|
||||
onView={() => handleView(task.id)}
|
||||
onEdit={() => handleEdit(task.id)}
|
||||
onCopy={() => handleCopy(task.id)}
|
||||
onDelete={() => handleDelete(task.id)}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.cardContent}>
|
||||
<div className={style.taskDetail}>
|
||||
<span className={style.label}>备注类型:</span>
|
||||
<span className={style.value}>
|
||||
{task.config?.remarkType === 1 ? "自定义备注" : "其他"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style.taskDetail}>
|
||||
<span className={style.label}>设备数量:</span>
|
||||
<span className={style.value}>
|
||||
{task.config?.devices?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style.taskDetail}>
|
||||
<span className={style.label}>导入数量:</span>
|
||||
<span className={style.value}>
|
||||
{task.config?.num || 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style.taskDetail}>
|
||||
<span className={style.label}>创建时间:</span>
|
||||
<span className={style.value}>{task.createTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.cardActions}>
|
||||
<Button
|
||||
size="small"
|
||||
fill="none"
|
||||
onClick={() => handleToggleStatus(task)}
|
||||
>
|
||||
{task.status === 1 ? "暂停" : "启动"}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
fill="none"
|
||||
onClick={() => handleView(task.id)}
|
||||
>
|
||||
查看记录
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import { FriendSelectionItem } from "@/components/FriendSelection/data";
|
||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||
import { GroupCreateFormData } from "../types";
|
||||
import style from "./BasicSettings.module.scss";
|
||||
import { useUserStore } from "@/store/module/user";
|
||||
|
||||
interface BasicSettingsProps {
|
||||
formData: GroupCreateFormData;
|
||||
@@ -22,6 +23,8 @@ export interface BasicSettingsRef {
|
||||
|
||||
const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
||||
({ formData, onChange }, ref) => {
|
||||
const { user } = useUserStore();
|
||||
const isAdmin = user?.isAdmin === 1;
|
||||
const [executorSelectionVisible, setExecutorSelectionVisible] = useState(false);
|
||||
const [groupAdminSelectionVisible, setGroupAdminSelectionVisible] = useState(false);
|
||||
const [fixedWechatIdsSelectionVisible, setFixedWechatIdsSelectionVisible] = useState(false);
|
||||
@@ -200,6 +203,8 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
||||
<div className={style.container}>
|
||||
{/* 计划类型和计划名称 */}
|
||||
<div className={style.card}>
|
||||
{/* 计划类型:去掉 isPlanType 限制,只要当前用户为管理员即可配置 */}
|
||||
{isAdmin && (
|
||||
<div>
|
||||
<label className={style.label}>计划类型</label>
|
||||
<Radio.Group
|
||||
@@ -211,6 +216,7 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
||||
<Radio value={1}>独立计划</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: "16px" }}>
|
||||
<label className={style.label}>
|
||||
计划名称 <span className={style.labelRequired}>*</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useNavigate, useParams, useLocation } from "react-router-dom";
|
||||
import { Toast } from "antd-mobile";
|
||||
import { Button } from "antd";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
@@ -19,6 +19,7 @@ const steps = [
|
||||
|
||||
const defaultForm: GroupCreateFormData = {
|
||||
planType: 1, // 默认独立计划
|
||||
isPlanType: 0, // 是否支持计划类型配置:默认不支持,依赖接口返回
|
||||
name: "",
|
||||
executorId: undefined,
|
||||
executor: undefined,
|
||||
@@ -43,11 +44,26 @@ const defaultForm: GroupCreateFormData = {
|
||||
const GroupCreateForm: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const location = useLocation();
|
||||
const routeState = (location.state || {}) as { isPlanType?: number };
|
||||
const isEdit = Boolean(id);
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dataLoaded, setDataLoaded] = useState(!isEdit);
|
||||
const [formData, setFormData] = useState<GroupCreateFormData>(defaultForm);
|
||||
const [formData, setFormData] = useState<GroupCreateFormData>({
|
||||
...defaultForm,
|
||||
// 新建时,尝试从路由状态中获取 isPlanType(由列表页传入)
|
||||
isPlanType: routeState.isPlanType === 1 ? 1 : 0,
|
||||
});
|
||||
|
||||
// 调试日志:查看路由传入的 isPlanType 和当前表单中的 planType / isPlanType
|
||||
useEffect(() => {
|
||||
// 仅在开发调试用,后续可以删除
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[GroupCreate] routeState.isPlanType =", routeState.isPlanType);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[GroupCreate] formData.planType =", formData.planType, "formData.isPlanType =", formData.isPlanType);
|
||||
}, [routeState.isPlanType, formData.planType, formData.isPlanType]);
|
||||
|
||||
// 创建子组件的ref
|
||||
const basicSettingsRef = useRef<BasicSettingsRef>(null);
|
||||
@@ -76,6 +92,8 @@ const GroupCreateForm: React.FC = () => {
|
||||
...defaultForm,
|
||||
id: String(res.id),
|
||||
planType: config.planType ?? res.planType ?? 1,
|
||||
// 由接口控制是否展示计划类型配置
|
||||
isPlanType: config.isPlanType ?? res.isPlanType ?? 0,
|
||||
name: res.name ?? "",
|
||||
executorId: config.executorId,
|
||||
executor: config.deviceGroupsOptions?.[0], // executor 使用第一个设备(如果需要)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PoolSelectionItem } from "@/components/PoolSelection/data";
|
||||
export interface GroupCreateFormData {
|
||||
id?: string; // 任务ID
|
||||
planType: number; // 计划类型:0-全局计划,1-独立计划
|
||||
isPlanType?: number; // 是否支持计划类型配置:1-支持,其他/未定义-不支持(接口返回)
|
||||
name: string; // 计划名称
|
||||
executor?: DeviceSelectionItem; // 执行智能体(执行者)- 单个设备(保留用于兼容)
|
||||
executorId?: number; // 执行智能体ID(设备ID)(保留用于兼容)
|
||||
|
||||
@@ -28,13 +28,16 @@ const GroupCreateList: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [plans, setPlans] = useState<GroupCreatePlan[]>([]);
|
||||
const [menuLoadingId, setMenuLoadingId] = useState<string | null>(null);
|
||||
const [isPlanTypeEnabled, setIsPlanTypeEnabled] = useState(false);
|
||||
|
||||
// 获取列表数据
|
||||
const fetchList = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getGroupCreateList({ type: 4 });
|
||||
const list = res?.list || res?.data?.list || res?.data || [];
|
||||
const list = res?.list || res?.data?.list || res?.data?.listData || res?.data || [];
|
||||
const isPlanTypeFlag = res?.isPlanType ?? res?.data?.isPlanType;
|
||||
setIsPlanTypeEnabled(isPlanTypeFlag === 1);
|
||||
const normalized: GroupCreatePlan[] = (list as any[]).map((item: any) => {
|
||||
const stats = item.config?.stats || {};
|
||||
return {
|
||||
@@ -115,12 +118,14 @@ const GroupCreateList: React.FC = () => {
|
||||
|
||||
// 创建新计划
|
||||
const handleCreate = () => {
|
||||
navigate("/workspace/group-create/new");
|
||||
navigate("/workspace/group-create/new", {
|
||||
state: { isPlanType: isPlanTypeEnabled ? 1 : 0 },
|
||||
});
|
||||
};
|
||||
|
||||
// 分隔全局计划和独立计划
|
||||
const globalPlans = plans.filter(p => p.planType === 0);
|
||||
const independentPlans = plans.filter(p => p.planType === 1);
|
||||
// 分隔全局计划和独立计划(仅在 isPlanType 为 1 时启用)
|
||||
const globalPlans = isPlanTypeEnabled ? plans.filter(p => p.planType === 0) : [];
|
||||
const independentPlans = isPlanTypeEnabled ? plans.filter(p => p.planType === 1) : plans;
|
||||
|
||||
return (
|
||||
<Layout
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
|
||||
const { TextArea } = Input;
|
||||
import { fetchSocialMediaList, fetchPromotionSiteList } from "../index.api";
|
||||
import { useUserStore } from "@/store/module/user";
|
||||
|
||||
interface BasicSettingsProps {
|
||||
defaultValues?: {
|
||||
@@ -74,6 +75,8 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { user } = useUserStore();
|
||||
const isAdmin = user?.isAdmin === 1;
|
||||
const [form] = Form.useForm();
|
||||
const [, forceUpdate] = useState({});
|
||||
const [socialMediaList, setSocialMediaList] = useState([]);
|
||||
@@ -161,18 +164,29 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
||||
};
|
||||
return (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Card>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={defaultValues}
|
||||
onValuesChange={(changedValues, allValues) => {
|
||||
// 当pushOrder值变化时,强制更新组件
|
||||
if ("pushOrder" in changedValues) {
|
||||
forceUpdate({});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={defaultValues}
|
||||
onValuesChange={(changedValues, allValues) => {
|
||||
// 当pushOrder值变化时,强制更新组件
|
||||
if ("pushOrder" in changedValues) {
|
||||
forceUpdate({});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* 计划类型和任务名称 */}
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
{/* 计划类型:仅管理员可见 */}
|
||||
{isAdmin && (
|
||||
<Form.Item label="计划类型" name="planType" initialValue={1}>
|
||||
<Radio.Group>
|
||||
<Radio value={0}>全局计划</Radio>
|
||||
<Radio value={1}>独立计划</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{/* 任务名称 */}
|
||||
<Form.Item
|
||||
label="任务名称"
|
||||
@@ -184,25 +198,28 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
||||
>
|
||||
<Input placeholder="请输入任务名称" />
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
{/* 推送目标类型 - 暂时隐藏,但保留默认值 */}
|
||||
<Form.Item
|
||||
name="targetType"
|
||||
hidden
|
||||
initialValue={1}
|
||||
>
|
||||
<Input type="hidden" />
|
||||
</Form.Item>
|
||||
{/* 推送目标类型 - 暂时隐藏,但保留默认值 */}
|
||||
<Form.Item
|
||||
name="targetType"
|
||||
hidden
|
||||
initialValue={1}
|
||||
>
|
||||
<Input type="hidden" />
|
||||
</Form.Item>
|
||||
|
||||
{/* 群推送子类型 - 暂时隐藏,但保留默认值 */}
|
||||
<Form.Item
|
||||
name="groupPushSubType"
|
||||
hidden
|
||||
initialValue={1}
|
||||
>
|
||||
<Input type="hidden" />
|
||||
</Form.Item>
|
||||
{/* 群推送子类型 - 暂时隐藏,但保留默认值 */}
|
||||
<Form.Item
|
||||
name="groupPushSubType"
|
||||
hidden
|
||||
initialValue={1}
|
||||
>
|
||||
<Input type="hidden" />
|
||||
</Form.Item>
|
||||
|
||||
{/* 推送类型和时间段 */}
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
{/* 推送类型 */}
|
||||
<Form.Item
|
||||
label="推送类型"
|
||||
@@ -248,18 +265,21 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
||||
) : null;
|
||||
}}
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
{/* 每日推送 - 群公告时隐藏 */}
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) =>
|
||||
prevValues.targetType !== currentValues.targetType ||
|
||||
prevValues.groupPushSubType !== currentValues.groupPushSubType
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const isGroupAnnouncement = getFieldValue("targetType") === 1 && getFieldValue("groupPushSubType") === 2;
|
||||
return !isGroupAnnouncement ? (
|
||||
{/* 每日推送和推送顺序 - 群公告时隐藏 */}
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) =>
|
||||
prevValues.targetType !== currentValues.targetType ||
|
||||
prevValues.groupPushSubType !== currentValues.groupPushSubType
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const isGroupAnnouncement = getFieldValue("targetType") === 1 && getFieldValue("groupPushSubType") === 2;
|
||||
return !isGroupAnnouncement ? (
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
{/* 每日推送 */}
|
||||
<Form.Item
|
||||
label="每日推送"
|
||||
name="maxPerDay"
|
||||
@@ -280,21 +300,8 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
||||
addonAfter="条内容"
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null;
|
||||
}}
|
||||
</Form.Item>
|
||||
|
||||
{/* 推送顺序 - 群公告时隐藏 */}
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) =>
|
||||
prevValues.targetType !== currentValues.targetType ||
|
||||
prevValues.groupPushSubType !== currentValues.groupPushSubType
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const isGroupAnnouncement = getFieldValue("targetType") === 1 && getFieldValue("groupPushSubType") === 2;
|
||||
return !isGroupAnnouncement ? (
|
||||
{/* 推送顺序 */}
|
||||
<Form.Item
|
||||
label="推送顺序"
|
||||
name="pushOrder"
|
||||
@@ -321,21 +328,24 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
) : null;
|
||||
}}
|
||||
</Form.Item>
|
||||
</Card>
|
||||
) : null;
|
||||
}}
|
||||
</Form.Item>
|
||||
|
||||
{/* 京东联盟 - 仅群推送显示,群公告时隐藏 */}
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) =>
|
||||
prevValues.targetType !== currentValues.targetType ||
|
||||
prevValues.groupPushSubType !== currentValues.groupPushSubType
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const isGroupAnnouncement = getFieldValue("targetType") === 1 && getFieldValue("groupPushSubType") === 2;
|
||||
return getFieldValue("targetType") === 1 && !isGroupAnnouncement ? (
|
||||
{/* 京东联盟和随机模板 - 仅群推送显示,群公告时隐藏 */}
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) =>
|
||||
prevValues.targetType !== currentValues.targetType ||
|
||||
prevValues.groupPushSubType !== currentValues.groupPushSubType
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const isGroupAnnouncement = getFieldValue("targetType") === 1 && getFieldValue("groupPushSubType") === 2;
|
||||
return getFieldValue("targetType") === 1 && !isGroupAnnouncement ? (
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
{/* 京东联盟 */}
|
||||
<Form.Item label="京东联盟" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "flex-end" }}>
|
||||
<Form.Item name="socialMediaId" noStyle>
|
||||
@@ -365,21 +375,8 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form.Item>
|
||||
) : null;
|
||||
}}
|
||||
</Form.Item>
|
||||
|
||||
{/* 是否随机模板 - 群公告时隐藏 */}
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) =>
|
||||
prevValues.targetType !== currentValues.targetType ||
|
||||
prevValues.groupPushSubType !== currentValues.groupPushSubType
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const isGroupAnnouncement = getFieldValue("targetType") === 1 && getFieldValue("groupPushSubType") === 2;
|
||||
return !isGroupAnnouncement ? (
|
||||
{/* 是否随机模板 */}
|
||||
<Form.Item
|
||||
label="是否随机模板"
|
||||
name="isRandomTemplate"
|
||||
@@ -389,10 +386,13 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
) : null;
|
||||
}}
|
||||
</Form.Item>
|
||||
</Card>
|
||||
) : null;
|
||||
}}
|
||||
</Form.Item>
|
||||
|
||||
{/* 推送后标签、循环推送和是否启用 */}
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
{/* 推送后标签 - 仅好友推送显示 */}
|
||||
<Form.Item
|
||||
noStyle
|
||||
@@ -457,151 +457,153 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
{/* 推送间隔设置 - 仅好友推送显示 */}
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) =>
|
||||
prevValues.targetType !== currentValues.targetType
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
return getFieldValue("targetType") === 2 ? (
|
||||
<>
|
||||
<Form.Item label="目标间间隔(秒)">
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<Form.Item
|
||||
name="friendIntervalMin"
|
||||
noStyle
|
||||
rules={[{ required: true, message: "请输入最小间隔" }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
placeholder="最小"
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<span style={{ color: "#888" }}>至</span>
|
||||
<Form.Item
|
||||
name="friendIntervalMax"
|
||||
noStyle
|
||||
rules={[{ required: true, message: "请输入最大间隔" }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
placeholder="最大"
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form.Item>
|
||||
<Form.Item label="消息间间隔(秒)">
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<Form.Item
|
||||
name="messageIntervalMin"
|
||||
noStyle
|
||||
rules={[{ required: true, message: "请输入最小间隔" }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
placeholder="最小"
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<span style={{ color: "#888" }}>至</span>
|
||||
<Form.Item
|
||||
name="messageIntervalMax"
|
||||
noStyle
|
||||
rules={[{ required: true, message: "请输入最大间隔" }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
placeholder="最大"
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null;
|
||||
}}
|
||||
</Form.Item>
|
||||
{/* 推送间隔设置 - 仅好友推送显示 */}
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) =>
|
||||
prevValues.targetType !== currentValues.targetType
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
return getFieldValue("targetType") === 2 ? (
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Form.Item label="目标间间隔(秒)">
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<Form.Item
|
||||
name="friendIntervalMin"
|
||||
noStyle
|
||||
rules={[{ required: true, message: "请输入最小间隔" }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
placeholder="最小"
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<span style={{ color: "#888" }}>至</span>
|
||||
<Form.Item
|
||||
name="friendIntervalMax"
|
||||
noStyle
|
||||
rules={[{ required: true, message: "请输入最大间隔" }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
placeholder="最大"
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form.Item>
|
||||
<Form.Item label="消息间间隔(秒)">
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<Form.Item
|
||||
name="messageIntervalMin"
|
||||
noStyle
|
||||
rules={[{ required: true, message: "请输入最小间隔" }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
placeholder="最小"
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<span style={{ color: "#888" }}>至</span>
|
||||
<Form.Item
|
||||
name="messageIntervalMax"
|
||||
noStyle
|
||||
rules={[{ required: true, message: "请输入最大间隔" }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
placeholder="最大"
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
) : null;
|
||||
}}
|
||||
</Form.Item>
|
||||
|
||||
{/* 群公告设置 - 仅当targetType=1且groupPushSubType=2时显示 */}
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) =>
|
||||
prevValues.targetType !== currentValues.targetType ||
|
||||
prevValues.groupPushSubType !== currentValues.groupPushSubType
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
return getFieldValue("targetType") === 1 &&
|
||||
getFieldValue("groupPushSubType") === 2 ? (
|
||||
<>
|
||||
<Form.Item
|
||||
label="群公告内容"
|
||||
name="announcementContent"
|
||||
rules={[
|
||||
{ required: true, message: "请输入群公告内容" },
|
||||
{ min: 1, max: 500, message: "群公告内容长度在1-500个字符之间" },
|
||||
]}
|
||||
>
|
||||
<TextArea
|
||||
rows={4}
|
||||
placeholder="请输入群公告内容"
|
||||
maxLength={500}
|
||||
showCount
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="是否启用AI改写"
|
||||
name="enableAiRewrite"
|
||||
valuePropName="checked"
|
||||
getValueFromEvent={checked => (checked ? 1 : 0)}
|
||||
getValueProps={value => ({ checked: value === 1 })}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) =>
|
||||
prevValues.enableAiRewrite !== currentValues.enableAiRewrite
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
return getFieldValue("enableAiRewrite") === 1 ? (
|
||||
<Form.Item
|
||||
label="AI改写提示词"
|
||||
name="aiRewritePrompt"
|
||||
rules={[
|
||||
{ required: true, message: "请输入AI改写提示词" },
|
||||
]}
|
||||
>
|
||||
<TextArea
|
||||
rows={3}
|
||||
placeholder="请输入AI改写提示词"
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null;
|
||||
}}
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null;
|
||||
}}
|
||||
</Form.Item>
|
||||
{/* 群公告设置 - 仅当targetType=1且groupPushSubType=2时显示 */}
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) =>
|
||||
prevValues.targetType !== currentValues.targetType ||
|
||||
prevValues.groupPushSubType !== currentValues.groupPushSubType
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
return getFieldValue("targetType") === 1 &&
|
||||
getFieldValue("groupPushSubType") === 2 ? (
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Form.Item
|
||||
label="群公告内容"
|
||||
name="announcementContent"
|
||||
rules={[
|
||||
{ required: true, message: "请输入群公告内容" },
|
||||
{ min: 1, max: 500, message: "群公告内容长度在1-500个字符之间" },
|
||||
]}
|
||||
>
|
||||
<TextArea
|
||||
rows={4}
|
||||
placeholder="请输入群公告内容"
|
||||
maxLength={500}
|
||||
showCount
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="是否启用AI改写"
|
||||
name="enableAiRewrite"
|
||||
valuePropName="checked"
|
||||
getValueFromEvent={checked => (checked ? 1 : 0)}
|
||||
getValueProps={value => ({ checked: value === 1 })}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) =>
|
||||
prevValues.enableAiRewrite !== currentValues.enableAiRewrite
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
return getFieldValue("enableAiRewrite") === 1 ? (
|
||||
<Form.Item
|
||||
label="AI改写提示词"
|
||||
name="aiRewritePrompt"
|
||||
rules={[
|
||||
{ required: true, message: "请输入AI改写提示词" },
|
||||
]}
|
||||
>
|
||||
<TextArea
|
||||
rows={3}
|
||||
placeholder="请输入AI改写提示词"
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null;
|
||||
}}
|
||||
</Form.Item>
|
||||
</Card>
|
||||
) : null;
|
||||
}}
|
||||
</Form.Item>
|
||||
|
||||
{/* 推送类型提示 */}
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) =>
|
||||
prevValues.pushType !== currentValues.pushType
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const pushType = getFieldValue("pushType");
|
||||
if (pushType === 1) {
|
||||
return (
|
||||
{/* 推送类型提示 */}
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) =>
|
||||
prevValues.pushType !== currentValues.pushType
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const pushType = getFieldValue("pushType");
|
||||
if (pushType === 1) {
|
||||
return (
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<div
|
||||
style={{
|
||||
background: "#fffbe6",
|
||||
@@ -609,18 +611,17 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
||||
borderRadius: 4,
|
||||
padding: 8,
|
||||
color: "#ad8b00",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
如果启用立即推送,系统会把内容库里所有的内容按顺序推送到指定的社群
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface ContentLibrary {
|
||||
}
|
||||
|
||||
export interface FormData {
|
||||
// 计划类型:0-全局计划,1-独立计划
|
||||
planType?: number;
|
||||
name: string;
|
||||
startTime: string; // 允许推送的开始时间
|
||||
endTime: string; // 允许推送的结束时间
|
||||
|
||||
@@ -16,6 +16,7 @@ import NavCommon from "@/components/NavCommon";
|
||||
import { GroupSelectionItem } from "@/components/GroupSelection/data";
|
||||
import { ContentItem } from "@/components/ContentSelection/data";
|
||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||
import styles from "./index.module.scss";
|
||||
|
||||
// 根据targetType和groupPushSubType动态生成步骤
|
||||
const getSteps = (targetType: number, groupPushSubType?: number) => {
|
||||
@@ -66,6 +67,7 @@ const NewGroupPush: React.FC = () => {
|
||||
>([]);
|
||||
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
planType: 1, // 默认独立计划
|
||||
name: "",
|
||||
startTime: "09:00", // 允许推送的开始时间
|
||||
dailyPushCount: 0, // 每日已推送次数
|
||||
@@ -126,6 +128,7 @@ const NewGroupPush: React.FC = () => {
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
planType: config.planType ?? data.planType ?? 1,
|
||||
name: data.name || "",
|
||||
status: data.status ?? config.status ?? config.autoStart ?? 0, // status 和 autoStart 合并为 status
|
||||
targetType: config.targetType ?? 1,
|
||||
@@ -273,6 +276,7 @@ const NewGroupPush: React.FC = () => {
|
||||
isRandomTemplate: formData.isRandomTemplate || 0,
|
||||
socialMediaId: basicSettingsValues.socialMediaId || "",
|
||||
promotionSiteId: basicSettingsValues.promotionSiteId || "",
|
||||
planType: formData.planType ?? 1,
|
||||
};
|
||||
|
||||
// 群推送(targetType = 1)
|
||||
@@ -419,11 +423,11 @@ const NewGroupPush: React.FC = () => {
|
||||
header={<NavCommon title={isEditMode ? "编辑任务" : "新建任务"} />}
|
||||
footer={renderFooter()}
|
||||
>
|
||||
<div style={{ padding: 12 }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div className={styles.formContainer}>
|
||||
<div style={{ marginBottom: 12, padding: "0 16px" }}>
|
||||
<StepIndicator currentStep={currentStep} steps={getSteps(formData.targetType, formData.groupPushSubType)} />
|
||||
</div>
|
||||
<div>
|
||||
<div className={styles.formContent}>
|
||||
{currentStep === 1 && (
|
||||
<BasicSettings
|
||||
ref={basicSettingsRef}
|
||||
|
||||
@@ -28,6 +28,49 @@
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.infoBox {
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.sectionDot {
|
||||
width: 4px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.sectionTitleIndependent {
|
||||
.sectionDot {
|
||||
background: #fb923c;
|
||||
}
|
||||
}
|
||||
|
||||
.taskListGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.emptyCard {
|
||||
text-align: center;
|
||||
padding: 48px 0;
|
||||
|
||||
@@ -100,7 +100,12 @@ const GroupPush: React.FC = () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await fetchGroupPushTasks();
|
||||
setTasks(result.list);
|
||||
const list = (result.list || []) as any[];
|
||||
const normalized = list.map(item => ({
|
||||
...item,
|
||||
planType: item.config?.planType ?? item.planType ?? 1,
|
||||
}));
|
||||
setTasks(normalized);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -150,6 +155,9 @@ const GroupPush: React.FC = () => {
|
||||
task.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
const globalTasks = filteredTasks.filter(t => t.planType === 0);
|
||||
const independentTasks = filteredTasks.filter(t => t.planType !== 0);
|
||||
|
||||
const getStatusColor = (status: number) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
@@ -172,6 +180,111 @@ const GroupPush: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const renderTaskCard = (task: any) => (
|
||||
<Card key={task.id} className={styles.taskCard}>
|
||||
<div className={styles.taskHeader}>
|
||||
<div className={styles.taskTitle}>
|
||||
<span>{task.name}</span>
|
||||
<Badge
|
||||
color={getStatusColor(task.status)}
|
||||
text={getStatusText(task.status)}
|
||||
style={{ marginLeft: 8 }}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.taskActions}>
|
||||
<Switch
|
||||
checked={task.status === 1}
|
||||
onChange={() => toggleTaskStatus(task.id)}
|
||||
/>
|
||||
<CardMenu
|
||||
onView={() => handleView(task.id)}
|
||||
onEdit={() => handleEdit(task.id)}
|
||||
onCopy={() => handleCopy(task.id)}
|
||||
onDelete={() => handleDelete(task.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.taskInfoGrid}>
|
||||
<div>
|
||||
<TeamOutlined />
|
||||
推送目标:{task.config?.groups?.length || 0} 个社群
|
||||
</div>
|
||||
<div>
|
||||
<CarryOutOutlined /> 推送内容:
|
||||
{task.config?.content || 0} 个
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.taskFooter}>
|
||||
<div>
|
||||
<ClockCircleOutlined /> 上次推送:
|
||||
{task.config?.lastPushTime || "暂无"}
|
||||
</div>
|
||||
<div>创建时间:{task.createTime}</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
let content: React.ReactNode;
|
||||
if (filteredTasks.length === 0) {
|
||||
content = (
|
||||
<Card className={styles.emptyCard}>
|
||||
<SendOutlined
|
||||
style={{ fontSize: 48, color: "#ccc", marginBottom: 12 }}
|
||||
/>
|
||||
<div style={{ color: "#888", fontSize: 16, marginBottom: 8 }}>
|
||||
暂无推送任务
|
||||
</div>
|
||||
<div style={{ color: "#bbb", fontSize: 13, marginBottom: 16 }}>
|
||||
创建您的第一个群消息推送任务
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleCreateNew}
|
||||
>
|
||||
创建第一个任务
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<>
|
||||
{globalTasks.length > 0 && (
|
||||
<div className={styles.infoBox}>
|
||||
全局群发计划将应用于所有设备(包括新添加的设备),请谨慎设置推送频率和内容。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{globalTasks.length > 0 && (
|
||||
<section className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>
|
||||
<span className={styles.sectionDot} />
|
||||
全局群发计划
|
||||
</h3>
|
||||
<div className={styles.taskListGroup}>
|
||||
{globalTasks.map(renderTaskCard)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{independentTasks.length > 0 && (
|
||||
<section className={styles.section}>
|
||||
<h3
|
||||
className={`${styles.sectionTitle} ${styles.sectionTitleIndependent}`}
|
||||
>
|
||||
<span className={styles.sectionDot} />
|
||||
独立群发计划
|
||||
</h3>
|
||||
<div className={styles.taskListGroup}>
|
||||
{independentTasks.map(renderTaskCard)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
loading={loading}
|
||||
@@ -212,75 +325,8 @@ const GroupPush: React.FC = () => {
|
||||
}
|
||||
>
|
||||
<div className={styles.bg}>
|
||||
<div className={styles.taskList}>
|
||||
{filteredTasks.length === 0 ? (
|
||||
<Card className={styles.emptyCard}>
|
||||
<SendOutlined
|
||||
style={{ fontSize: 48, color: "#ccc", marginBottom: 12 }}
|
||||
/>
|
||||
<div style={{ color: "#888", fontSize: 16, marginBottom: 8 }}>
|
||||
暂无推送任务
|
||||
</div>
|
||||
<div style={{ color: "#bbb", fontSize: 13, marginBottom: 16 }}>
|
||||
创建您的第一个群消息推送任务
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleCreateNew}
|
||||
>
|
||||
创建第一个任务
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
filteredTasks.map(task => (
|
||||
<Card key={task.id} className={styles.taskCard}>
|
||||
<div className={styles.taskHeader}>
|
||||
<div className={styles.taskTitle}>
|
||||
<span>{task.name}</span>
|
||||
<Badge
|
||||
color={getStatusColor(task.status)}
|
||||
text={getStatusText(task.status)}
|
||||
style={{ marginLeft: 8 }}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.taskActions}>
|
||||
<Switch
|
||||
checked={task.status === 1}
|
||||
onChange={() => toggleTaskStatus(task.id)}
|
||||
/>
|
||||
<CardMenu
|
||||
onView={() => handleView(task.id)}
|
||||
onEdit={() => handleEdit(task.id)}
|
||||
onCopy={() => handleCopy(task.id)}
|
||||
onDelete={() => handleDelete(task.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.taskInfoGrid}>
|
||||
<div>
|
||||
<TeamOutlined />
|
||||
推送目标:{task.config?.groups?.length || 0} 个社群
|
||||
</div>
|
||||
<div>
|
||||
<CarryOutOutlined /> 推送内容:
|
||||
{task.config?.content || 0} 个
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.taskFooter}>
|
||||
<div>
|
||||
<ClockCircleOutlined /> 上次推送:
|
||||
{task.config?.lastPushTime || "暂无"}
|
||||
</div>
|
||||
<div>创建时间:{task.createTime}</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.taskList}>{content}</div>
|
||||
</div>
|
||||
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -60,6 +60,7 @@ interface CommonFunction {
|
||||
path: string;
|
||||
bgColor?: string;
|
||||
isNew?: boolean;
|
||||
isPlanType?: number; // 是否支持计划类型配置:1-支持,0-不支持
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
@@ -73,8 +74,10 @@ const Workspace: React.FC = () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await getCommonFunctions();
|
||||
// 兼容不同返回结构:优先使用 data.list,其次 list,最后整体当作数组
|
||||
const list = res?.data?.list || res?.list || res?.data || res || [];
|
||||
// 处理API返回的数据,映射图标和样式
|
||||
const features = (res?.list || res || []).map((item: any) => {
|
||||
const features = (list as any[]).map((item: any) => {
|
||||
const config = featureConfig[item.key];
|
||||
|
||||
// icon是远程图片URL,渲染为img标签
|
||||
@@ -91,7 +94,7 @@ const Workspace: React.FC = () => {
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return {
|
||||
const feature = {
|
||||
id: item.id,
|
||||
key: item.key,
|
||||
name: item.title || item.name || "",
|
||||
@@ -101,7 +104,11 @@ const Workspace: React.FC = () => {
|
||||
path: item.route || item.path || (config?.path) || `/workspace/${item.key?.replace(/_/g, '-')}`,
|
||||
bgColor: item.iconColor || (config?.bgColor) || undefined, // iconColor可以为空
|
||||
isNew: item.isNew || item.is_new || false,
|
||||
isPlanType: item.isPlanType ?? 0, // 保存 isPlanType,用于传递给子页面
|
||||
};
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[Workspace] feature loaded:", feature.key, "isPlanType =", feature.isPlanType);
|
||||
return feature;
|
||||
});
|
||||
setCommonFeatures(features);
|
||||
} catch (e: any) {
|
||||
@@ -152,6 +159,8 @@ const Workspace: React.FC = () => {
|
||||
commonFeatures.map(feature => (
|
||||
<Link
|
||||
to={feature.path}
|
||||
// 将 isPlanType 透传到对应页面,便于调试和控制计划类型
|
||||
state={{ isPlanType: feature.isPlanType ?? 0 }}
|
||||
key={feature.key || feature.id}
|
||||
className={styles.featureLink}
|
||||
>
|
||||
|
||||
@@ -21,6 +21,49 @@
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.infoBox {
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.sectionDot {
|
||||
width: 4px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.sectionTitleIndependent {
|
||||
.sectionDot {
|
||||
background: #fb923c;
|
||||
}
|
||||
}
|
||||
|
||||
.taskListGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.taskCard {
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
|
||||
@@ -28,6 +28,8 @@ interface MomentsSyncTask {
|
||||
createTime: string;
|
||||
creatorName: string;
|
||||
contentLib?: string;
|
||||
// 计划类型:0-全局计划,1-独立计划
|
||||
planType?: number;
|
||||
config?: {
|
||||
devices?: string[];
|
||||
contentGroups: number[];
|
||||
@@ -64,7 +66,12 @@ const MomentsSync: React.FC = () => {
|
||||
{ type: 2, page: 1, limit: 100 },
|
||||
"GET",
|
||||
);
|
||||
setTasks(res.list || []);
|
||||
const list = (res.list || []) as any[];
|
||||
const normalized: MomentsSyncTask[] = list.map(item => ({
|
||||
...item,
|
||||
planType: item.config?.planType ?? item.planType ?? 1,
|
||||
}));
|
||||
setTasks(normalized);
|
||||
} catch (e) {
|
||||
message.error("获取任务失败");
|
||||
} finally {
|
||||
@@ -118,6 +125,9 @@ const MomentsSync: React.FC = () => {
|
||||
task.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
const globalTasks = filteredTasks.filter(t => t.planType === 0);
|
||||
const independentTasks = filteredTasks.filter(t => t.planType !== 0);
|
||||
|
||||
// 菜单
|
||||
const getMenu = (task: MomentsSyncTask) => (
|
||||
<Menu>
|
||||
@@ -153,64 +163,7 @@ const MomentsSync: React.FC = () => {
|
||||
</Menu>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<NavCommon
|
||||
title="朋友圈同步"
|
||||
right={
|
||||
<Button
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => navigate("/workspace/moments-sync/new")}
|
||||
>
|
||||
<PlusOutlined /> 新建任务
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="search-bar">
|
||||
<div className="search-input-wrapper">
|
||||
<Input
|
||||
placeholder="搜索任务名称"
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={fetchTasks}
|
||||
loading={loading}
|
||||
className="refresh-btn"
|
||||
>
|
||||
<ReloadOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
loading={loading}
|
||||
>
|
||||
<div className={style.pageBg}>
|
||||
<div className={style.taskList}>
|
||||
{filteredTasks.length === 0 ? (
|
||||
<div className={style.emptyBox}>
|
||||
<span style={{ fontSize: 40, color: "#ddd" }}>
|
||||
<ClockCircleOutlined />
|
||||
</span>
|
||||
<div className={style.emptyText}>暂无同步任务</div>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={() => navigate("/workspace/moments-sync/new")}
|
||||
>
|
||||
新建第一个任务
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
filteredTasks.map(task => (
|
||||
const renderTaskCard = (task: MomentsSyncTask) => (
|
||||
<div key={task.id} className={style.itemCard}>
|
||||
<div className={style.itemTop}>
|
||||
<div className={style.itemTitle}>
|
||||
@@ -268,23 +221,115 @@ const MomentsSync: React.FC = () => {
|
||||
?.map(c => c.name)
|
||||
.join(",") || "默认内容库"}
|
||||
</div>
|
||||
<div className={style.infoCol}>
|
||||
创建人:{task.creatorName}
|
||||
</div>
|
||||
<div className={style.infoCol}>创建人:{task.creatorName}</div>
|
||||
</div>
|
||||
<div className={style.itemBottom}>
|
||||
<div className={style.bottomLeft}>
|
||||
<ClockCircleOutlined className={style.clockIcon} />
|
||||
上次同步:{task.lastSyncTime || "无"}
|
||||
</div>
|
||||
<div className={style.bottomRight}>
|
||||
创建时间:{task.createTime}
|
||||
<div className={style.bottomRight}>创建时间:{task.createTime}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
let content: React.ReactNode;
|
||||
if (filteredTasks.length === 0) {
|
||||
content = (
|
||||
<div className={style.emptyBox}>
|
||||
<span style={{ fontSize: 40, color: "#ddd" }}>
|
||||
<ClockCircleOutlined />
|
||||
</span>
|
||||
<div className={style.emptyText}>暂无同步任务</div>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={() => navigate("/workspace/moments-sync/new")}
|
||||
>
|
||||
新建第一个任务
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<>
|
||||
{globalTasks.length > 0 && (
|
||||
<div className={style.infoBox}>
|
||||
全局同步计划将应用于所有设备(包括新添加的设备),请合理设置同步频率与数量。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{globalTasks.length > 0 && (
|
||||
<section className={style.section}>
|
||||
<h3 className={style.sectionTitle}>
|
||||
<span className={style.sectionDot} />
|
||||
全局朋友圈同步计划
|
||||
</h3>
|
||||
<div className={style.taskListGroup}>
|
||||
{globalTasks.map(renderTaskCard)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{independentTasks.length > 0 && (
|
||||
<section className={style.section}>
|
||||
<h3
|
||||
className={`${style.sectionTitle} ${style.sectionTitleIndependent}`}
|
||||
>
|
||||
<span className={style.sectionDot} />
|
||||
独立朋友圈同步计划
|
||||
</h3>
|
||||
<div className={style.taskListGroup}>
|
||||
{independentTasks.map(renderTaskCard)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<NavCommon
|
||||
title="朋友圈同步"
|
||||
right={
|
||||
<Button
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => navigate("/workspace/moments-sync/new")}
|
||||
>
|
||||
<PlusOutlined /> 新建任务
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="search-bar">
|
||||
<div className="search-input-wrapper">
|
||||
<Input
|
||||
placeholder="搜索任务名称"
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={fetchTasks}
|
||||
loading={loading}
|
||||
className="refresh-btn"
|
||||
>
|
||||
<ReloadOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
loading={loading}
|
||||
>
|
||||
<div className={style.pageBg}>
|
||||
<div className={style.taskList}>{content}</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,50 @@
|
||||
.formBg {
|
||||
background: #f8fafc;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 16px;
|
||||
background: #f8fafc;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 100px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
|
||||
@media (max-width: 375px) {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
@media (max-width: 375px) {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
.formStepBtnRow {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 32px;
|
||||
padding: 12px;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.formSteps {
|
||||
display: flex;
|
||||
@@ -78,10 +116,31 @@
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.radioGroup {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
font-size: 15px;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
}
|
||||
|
||||
.timeRow {
|
||||
@@ -213,12 +272,6 @@
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.formStepBtnRow {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.prevBtn {
|
||||
height: 44px;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Button, Input, Switch, message, Spin } from "antd";
|
||||
import { Button, Input, Switch, message, Spin, Radio } from "antd";
|
||||
import { MinusOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import DeviceSelection from "@/components/DeviceSelection";
|
||||
import ContentSelection from "@/components/ContentSelection";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import { useUserStore } from "@/store/module/user";
|
||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||
import { ContentItem } from "@/components/ContentSelection/data";
|
||||
|
||||
@@ -38,12 +39,16 @@ const defaultForm = {
|
||||
contentTypes: ["text", "image", "video"],
|
||||
targetTags: [] as string[],
|
||||
filterKeywords: [] as string[],
|
||||
// 计划类型:0-全局计划,1-独立计划
|
||||
planType: 1,
|
||||
};
|
||||
|
||||
const NewMomentsSync: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isEditMode = !!id;
|
||||
const { user } = useUserStore();
|
||||
const isAdmin = user?.isAdmin === 1;
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formData, setFormData] = useState({ ...defaultForm });
|
||||
@@ -76,6 +81,7 @@ const NewMomentsSync: React.FC = () => {
|
||||
contentTypes: res.config?.contentTypes || ["text", "image", "video"],
|
||||
targetTags: res.config?.targetTags || [],
|
||||
filterKeywords: res.config?.filterKeywords || [],
|
||||
planType: res.config?.planType ?? (res as any).planType ?? 1,
|
||||
});
|
||||
setSelectedDevicesOptions(res.config?.deviceGroupsOptions || []);
|
||||
setContentGroupsOptions(res.config?.contentGroupsOptions || []);
|
||||
@@ -147,6 +153,7 @@ const NewMomentsSync: React.FC = () => {
|
||||
filterKeywords: formData.filterKeywords,
|
||||
type: 2,
|
||||
status: formData.enabled ? 1 : 0,
|
||||
planType: (formData as any).planType ?? 1,
|
||||
};
|
||||
if (isEditMode && id) {
|
||||
await updateMomentsSync({ id, ...params });
|
||||
@@ -168,7 +175,22 @@ const NewMomentsSync: React.FC = () => {
|
||||
const renderStep = () => {
|
||||
if (currentStep === 0) {
|
||||
return (
|
||||
<div className={style.formStep}>
|
||||
<div className={style.container}>
|
||||
{/* 计划类型和任务名称 */}
|
||||
<div className={style.card}>
|
||||
{isAdmin && (
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>计划类型</div>
|
||||
<Radio.Group
|
||||
value={(formData as any).planType}
|
||||
onChange={e => updateForm({ planType: e.target.value })}
|
||||
className={style.radioGroup}
|
||||
>
|
||||
<Radio value={0}>全局计划</Radio>
|
||||
<Radio value={1}>独立计划</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)}
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>任务名称</div>
|
||||
<Input
|
||||
@@ -178,8 +200,11 @@ const NewMomentsSync: React.FC = () => {
|
||||
maxLength={30}
|
||||
className={style.input}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 允许发布时间段 */}
|
||||
<div className={style.card}>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>允许发布时间段</div>
|
||||
<div className={style.timeRow}>
|
||||
@@ -196,9 +221,12 @@ const NewMomentsSync: React.FC = () => {
|
||||
onChange={e => updateForm({ endTime: e.target.value })}
|
||||
className={style.inputTime}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 每日同步数量 */}
|
||||
<div className={style.card}>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>每日同步数量</div>
|
||||
<div className={style.counterRow}>
|
||||
@@ -220,9 +248,12 @@ const NewMomentsSync: React.FC = () => {
|
||||
<PlusOutlined />
|
||||
</button>
|
||||
<span className={style.counterUnit}>条朋友圈</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 账号类型和是否启用 */}
|
||||
<div className={style.card}>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>账号类型</div>
|
||||
<div className={style.accountTypeRow}>
|
||||
@@ -240,7 +271,6 @@ const NewMomentsSync: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.formItem}>
|
||||
<div className={style.switchRow}>
|
||||
<span className={style.switchLabel}>是否启用</span>
|
||||
@@ -249,6 +279,7 @@ const NewMomentsSync: React.FC = () => {
|
||||
onChange={checked => updateForm({ enabled: checked })}
|
||||
className={style.switch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -256,7 +287,8 @@ const NewMomentsSync: React.FC = () => {
|
||||
}
|
||||
if (currentStep === 1) {
|
||||
return (
|
||||
<div className={style.formStep}>
|
||||
<div className={style.container}>
|
||||
<div className={style.card}>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>选择设备</div>
|
||||
<DeviceSelection
|
||||
@@ -266,13 +298,15 @@ const NewMomentsSync: React.FC = () => {
|
||||
showSelectedList={true}
|
||||
selectedListMaxHeight={200}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (currentStep === 2) {
|
||||
return (
|
||||
<div className={style.formStep}>
|
||||
<div className={style.container}>
|
||||
<div className={style.card}>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>选择内容库</div>
|
||||
<ContentSelection
|
||||
@@ -287,6 +321,7 @@ const NewMomentsSync: React.FC = () => {
|
||||
已选内容库: {formData.contentGroups.length}个
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -315,10 +350,10 @@ const NewMomentsSync: React.FC = () => {
|
||||
if (currentStep === 1) {
|
||||
return (
|
||||
<div className={style.formStepBtnRow}>
|
||||
<Button onClick={prev} className={style.prevBtn} block>
|
||||
<Button onClick={prev} className={style.prevBtn} style={{ flex: 1 }}>
|
||||
上一步
|
||||
</Button>
|
||||
<Button type="primary" onClick={next} className={style.nextBtn} block>
|
||||
<Button type="primary" onClick={next} className={style.nextBtn} style={{ flex: 1 }}>
|
||||
下一步
|
||||
</Button>
|
||||
</div>
|
||||
@@ -327,7 +362,7 @@ const NewMomentsSync: React.FC = () => {
|
||||
if (currentStep === 2) {
|
||||
return (
|
||||
<div className={style.formStepBtnRow}>
|
||||
<Button onClick={prev} className={style.prevBtn} block>
|
||||
<Button onClick={prev} className={style.prevBtn} style={{ flex: 1 }}>
|
||||
上一步
|
||||
</Button>
|
||||
<Button
|
||||
@@ -335,7 +370,7 @@ const NewMomentsSync: React.FC = () => {
|
||||
onClick={handleSubmit}
|
||||
loading={loading}
|
||||
className={style.completeBtn}
|
||||
block
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
完成
|
||||
</Button>
|
||||
@@ -353,7 +388,7 @@ const NewMomentsSync: React.FC = () => {
|
||||
footer={renderFooter()}
|
||||
>
|
||||
<div className={style.formBg}>
|
||||
<div style={{ marginBottom: "15px" }}>
|
||||
<div style={{ marginBottom: "15px", padding: "0 16px" }}>
|
||||
<StepIndicator currentStep={currentStep + 1} steps={steps} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -46,6 +46,8 @@ export interface TrafficDistributionFormData {
|
||||
id?: string;
|
||||
type: number;
|
||||
name: string;
|
||||
// 计划类型:0-全局计划,1-独立计划
|
||||
planType?: number;
|
||||
source: string;
|
||||
sourceIcon: string;
|
||||
description: string;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
.formPage {
|
||||
background: #f8fafc;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
|
||||
.formHeader {
|
||||
@@ -45,10 +48,54 @@
|
||||
}
|
||||
|
||||
.formBody {
|
||||
padding: 16px;
|
||||
background: #f8fafc;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 100px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
|
||||
@media (max-width: 375px) {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
@media (max-width: 375px) {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
// 输入框样式
|
||||
:global(.ant-input) {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
}
|
||||
}
|
||||
.sectionTitle {
|
||||
font-size: 17px;
|
||||
@@ -136,6 +183,13 @@
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.radioGroupHorizontal {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.radioDesc {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Form, Input, Button, Radio, Slider, TimePicker, message } from "antd";
|
||||
import { Form, Input, Button, Radio, Slider, TimePicker, message, Card } from "antd";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import style from "./index.module.scss";
|
||||
import StepIndicator from "@/components/StepIndicator";
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
createTrafficDistribution,
|
||||
} from "./api";
|
||||
import type { TrafficDistributionFormData } from "./data";
|
||||
import { useUserStore } from "@/store/module/user";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
const stepList = [
|
||||
@@ -30,6 +31,8 @@ const TrafficDistributionForm: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isEdit = !!id;
|
||||
const { user } = useUserStore();
|
||||
const isAdmin = user?.isAdmin === 1;
|
||||
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [selectedDevices, setSelectedDevices] = useState<DeviceSelectionItem[]>(
|
||||
@@ -181,6 +184,7 @@ const TrafficDistributionForm: React.FC = () => {
|
||||
accountGroupsOptions,
|
||||
poolGroups: poolGroupsOptions.map(v => v.id),
|
||||
enabled: true,
|
||||
planType: formValues.planType ?? 1,
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
@@ -273,6 +277,7 @@ const TrafficDistributionForm: React.FC = () => {
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
name: isEdit ? "" : generateDefaultName(),
|
||||
planType: 1,
|
||||
distributeType: 1,
|
||||
maxPerDay: 50,
|
||||
timeType: 1,
|
||||
@@ -281,99 +286,124 @@ const TrafficDistributionForm: React.FC = () => {
|
||||
onFinish={handleSubmit}
|
||||
style={{ display: current === 0 ? "block" : "none" }}
|
||||
>
|
||||
<div className={style.sectionTitle}>基本信息</div>
|
||||
<Form.Item
|
||||
label="计划名称"
|
||||
name="name"
|
||||
rules={[{ required: true, message: "请输入计划名称" }]}
|
||||
>
|
||||
<Input placeholder="流量分发 20250724 1700" maxLength={30} />
|
||||
</Form.Item>
|
||||
<Form.Item label="分配方式" name="distributeType" required>
|
||||
<Radio.Group className={style.radioGroup}>
|
||||
<Radio value={1}>
|
||||
均分配
|
||||
<span className={style.radioDesc}>
|
||||
(流量将均分分配给所有客服)
|
||||
</span>
|
||||
</Radio>
|
||||
<Radio value={2}>
|
||||
优先级分配
|
||||
<span className={style.radioDesc}>
|
||||
(按客服优先级顺序分配)
|
||||
</span>
|
||||
</Radio>
|
||||
<Radio value={3}>
|
||||
比例分配
|
||||
<span className={style.radioDesc}>(按设置比例分配流量)</span>
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<Form.Item label="分配限制" required>
|
||||
<div className={style.sliderLabelWrap}>
|
||||
<span>每日最大分配量</span>
|
||||
<span className={style.sliderValue}>
|
||||
{maxPerDay || 0} 人/天
|
||||
</span>
|
||||
</div>
|
||||
{/* 计划类型和计划名称 */}
|
||||
<Card className={style.card}>
|
||||
{isAdmin && (
|
||||
<Form.Item label="计划类型" name="planType">
|
||||
<Radio.Group className={style.radioGroupHorizontal}>
|
||||
<Radio value={0}>全局计划</Radio>
|
||||
<Radio value={1}>独立计划</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item
|
||||
name="maxPerDay"
|
||||
noStyle
|
||||
rules={[{ required: true, message: "请设置每日最大分配量" }]}
|
||||
label="计划名称"
|
||||
name="name"
|
||||
rules={[{ required: true, message: "请输入计划名称" }]}
|
||||
>
|
||||
<Slider min={1} max={1000} className={style.slider} />
|
||||
<Input placeholder="流量分发 20250724 1700" maxLength={30} />
|
||||
</Form.Item>
|
||||
<div className={style.sliderDesc}>限制每天最多分配的流量数量</div>
|
||||
</Form.Item>
|
||||
<Form.Item label="时间限制" name="timeType" required>
|
||||
<Radio.Group className={style.radioGroup}>
|
||||
<Radio value={1}>全天分配</Radio>
|
||||
<Radio value={2}>自定义时间段</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
{timeType === 2 && (
|
||||
<Form.Item
|
||||
label=""
|
||||
name="timeRange"
|
||||
required
|
||||
dependencies={["timeType"]}
|
||||
rules={[
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (getFieldValue("timeType") === 1) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (value && value.length === 2) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error("请选择开始和结束时间"));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<TimePicker.RangePicker
|
||||
format="HH:mm"
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className={style.sectionTitle}>客服选择</div>
|
||||
<div className={style.formBlock}>
|
||||
<AccountSelection
|
||||
selectedOptions={accountGroupsOptions}
|
||||
onSelect={accounts => {
|
||||
setAccountGroupsOptions(accounts);
|
||||
}}
|
||||
placeholder="请选择客服"
|
||||
showSelectedList={true}
|
||||
selectedListMaxHeight={300}
|
||||
accountGroups={accountGroups}
|
||||
/>
|
||||
</div>
|
||||
{/* 分配方式 */}
|
||||
<Card className={style.card}>
|
||||
<Form.Item label="分配方式" name="distributeType" required>
|
||||
<Radio.Group className={style.radioGroup}>
|
||||
<Radio value={1}>
|
||||
均分配
|
||||
<span className={style.radioDesc}>
|
||||
(流量将均分分配给所有客服)
|
||||
</span>
|
||||
</Radio>
|
||||
<Radio value={2}>
|
||||
优先级分配
|
||||
<span className={style.radioDesc}>
|
||||
(按客服优先级顺序分配)
|
||||
</span>
|
||||
</Radio>
|
||||
<Radio value={3}>
|
||||
比例分配
|
||||
<span className={style.radioDesc}>(按设置比例分配流量)</span>
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
{/* 分配限制 */}
|
||||
<Card className={style.card}>
|
||||
<Form.Item label="分配限制" required>
|
||||
<div className={style.sliderLabelWrap}>
|
||||
<span>每日最大分配量</span>
|
||||
<span className={style.sliderValue}>
|
||||
{maxPerDay || 0} 人/天
|
||||
</span>
|
||||
</div>
|
||||
<Form.Item
|
||||
name="maxPerDay"
|
||||
noStyle
|
||||
rules={[{ required: true, message: "请设置每日最大分配量" }]}
|
||||
>
|
||||
<Slider min={1} max={1000} className={style.slider} />
|
||||
</Form.Item>
|
||||
<div className={style.sliderDesc}>限制每天最多分配的流量数量</div>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
{/* 时间限制 */}
|
||||
<Card className={style.card}>
|
||||
<Form.Item label="时间限制" name="timeType" required>
|
||||
<Radio.Group className={style.radioGroupHorizontal}>
|
||||
<Radio value={1}>全天分配</Radio>
|
||||
<Radio value={2}>自定义时间段</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
{timeType === 2 && (
|
||||
<Form.Item
|
||||
label=""
|
||||
name="timeRange"
|
||||
required
|
||||
dependencies={["timeType"]}
|
||||
rules={[
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (getFieldValue("timeType") === 1) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (value && value.length === 2) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error("请选择开始和结束时间"));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<TimePicker.RangePicker
|
||||
format="HH:mm"
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 客服选择 */}
|
||||
<Card className={style.card}>
|
||||
<div className={style.sectionTitle}>客服选择</div>
|
||||
<div className={style.formBlock}>
|
||||
<AccountSelection
|
||||
selectedOptions={accountGroupsOptions}
|
||||
onSelect={accounts => {
|
||||
setAccountGroupsOptions(accounts);
|
||||
}}
|
||||
placeholder="请选择客服"
|
||||
showSelectedList={true}
|
||||
selectedListMaxHeight={300}
|
||||
accountGroups={accountGroups}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Form>
|
||||
{current === 1 && (
|
||||
<div>
|
||||
<Card className={style.card}>
|
||||
<div className={style.sectionTitle}>目标设置</div>
|
||||
<div className={style.formBlock}>
|
||||
<DeviceSelection
|
||||
@@ -388,16 +418,18 @@ const TrafficDistributionForm: React.FC = () => {
|
||||
deviceGroups={deviceGroups}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
{current === 2 && (
|
||||
<PoolSelection
|
||||
selectedOptions={poolGroupsOptions}
|
||||
onSelect={handlePoolSelect}
|
||||
placeholder="请选择流量池"
|
||||
showSelectedList={true}
|
||||
selectedListMaxHeight={300}
|
||||
/>
|
||||
<Card className={style.card}>
|
||||
<PoolSelection
|
||||
selectedOptions={poolGroupsOptions}
|
||||
onSelect={handlePoolSelect}
|
||||
placeholder="请选择流量池"
|
||||
showSelectedList={true}
|
||||
selectedListMaxHeight={300}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,43 @@
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.infoBox {
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.sectionDot {
|
||||
width: 4px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.sectionTitleIndependent {
|
||||
.sectionDot {
|
||||
background: #fb923c;
|
||||
}
|
||||
}
|
||||
|
||||
.ruleCard {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
|
||||
@@ -75,7 +75,12 @@ const TrafficDistributionList: React.FC = () => {
|
||||
limit: PAGE_SIZE,
|
||||
keyword,
|
||||
});
|
||||
setList(res?.list || []);
|
||||
const rawList: DistributionRule[] = res?.list || [];
|
||||
const normalized = rawList.map(item => ({
|
||||
...item,
|
||||
planType: (item as any).planType ?? item.config?.planType ?? 1,
|
||||
}));
|
||||
setList(normalized);
|
||||
setTotal(Number(res?.total) || 0);
|
||||
} catch (e) {
|
||||
message.error("获取流量分发列表失败");
|
||||
@@ -93,6 +98,9 @@ const TrafficDistributionList: React.FC = () => {
|
||||
fetchList(page, searchQuery);
|
||||
};
|
||||
|
||||
const globalRules = list.filter(item => item.planType === 0);
|
||||
const independentRules = list.filter(item => item.planType !== 0);
|
||||
|
||||
// 优化:菜单点击事件,menuLoadingId标记当前item
|
||||
const handleMenuClick = async (key: string, item: DistributionRule) => {
|
||||
setMenuLoadingId(item.id);
|
||||
@@ -322,6 +330,45 @@ const TrafficDistributionList: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
let content: React.ReactNode;
|
||||
if (loading) {
|
||||
content = <Spin />;
|
||||
} else if (list.length === 0) {
|
||||
content = <div className={style.empty}>暂无数据</div>;
|
||||
} else {
|
||||
content = (
|
||||
<>
|
||||
{globalRules.length > 0 && (
|
||||
<div className={style.infoBox}>
|
||||
全局流量分发计划将作用于所有账号和设备,请谨慎配置每日分配量与时间段。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{globalRules.length > 0 && (
|
||||
<section className={style.section}>
|
||||
<h3 className={style.sectionTitle}>
|
||||
<span className={style.sectionDot} />
|
||||
全局流量分发计划
|
||||
</h3>
|
||||
{globalRules.map(renderCard)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{independentRules.length > 0 && (
|
||||
<section className={style.section}>
|
||||
<h3
|
||||
className={`${style.sectionTitle} ${style.sectionTitleIndependent}`}
|
||||
>
|
||||
<span className={style.sectionDot} />
|
||||
独立流量分发计划
|
||||
</h3>
|
||||
{independentRules.map(renderCard)}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
@@ -373,15 +420,7 @@ const TrafficDistributionList: React.FC = () => {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={style.ruleList}>
|
||||
{loading ? (
|
||||
<Spin />
|
||||
) : list.length > 0 ? (
|
||||
list.map(renderCard)
|
||||
) : (
|
||||
<div className={style.empty}>暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={style.ruleList}>{content}</div>
|
||||
|
||||
{/* 账号列表弹窗 */}
|
||||
<AccountListModal
|
||||
|
||||
Reference in New Issue
Block a user