全局配置前端功能提交

This commit is contained in:
wong
2026-01-15 14:22:23 +08:00
parent 443ef6ad2d
commit cec64b889b
37 changed files with 2854 additions and 857 deletions

View File

@@ -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 && (

View File

@@ -8,6 +8,7 @@ export interface Task {
total_customers?: number;
today_customers?: number;
lastUpdated?: string;
planType?: number; // 0-全局计划, 1-独立计划
stats?: {
devices?: number;
acquired?: number;

View File

@@ -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;

View File

@@ -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}

View File

@@ -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, // 默认独立计划
};

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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;
}
}

View File

@@ -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>
);
};

View File

@@ -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;
}

View File

@@ -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>
);

View File

@@ -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 {

View File

@@ -56,7 +56,7 @@ export function fetchImportRecords(
keyword?: string,
): Promise<PaginatedResponse<ContactImportRecord>> {
return request(
"/v1/workbench/import-records",
"/v1/workbench/import-contact",
{
workbenchId,
page,

View File

@@ -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;

View File

@@ -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>

View File

@@ -62,7 +62,7 @@ export function fetchImportRecords(
keyword?: string,
): Promise<PaginatedResponse<ContactImportRecord>> {
return request(
"/v1/workbench/import-records",
"/v1/workbench/import-contact",
{
workbenchId,
page,

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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 使用第一个设备(如果需要)

View File

@@ -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保留用于兼容

View File

@@ -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

View File

@@ -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>
);
},

View File

@@ -20,6 +20,8 @@ export interface ContentLibrary {
}
export interface FormData {
// 计划类型0-全局计划1-独立计划
planType?: number;
name: string;
startTime: string; // 允许推送的开始时间
endTime: string; // 允许推送的结束时间

View File

@@ -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}

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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}
>

View File

@@ -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);

View File

@@ -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>
);

View File

@@ -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;

View File

@@ -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>

View File

@@ -46,6 +46,8 @@ export interface TrafficDistributionFormData {
id?: string;
type: number;
name: string;
// 计划类型0-全局计划1-独立计划
planType?: number;
source: string;
sourceIcon: string;
description: string;

View File

@@ -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;

View File

@@ -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>

View File

@@ -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;

View File

@@ -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