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

迁移社群推送
This commit is contained in:
笔记本里的永平
2025-07-21 16:43:59 +08:00
parent 15e72309cf
commit 44e4c54df7
12 changed files with 2058 additions and 24 deletions

View File

@@ -0,0 +1,73 @@
import request from "./request";
export interface GroupPushTask {
id: string;
name: string;
status: number; // 1: 运行中, 2: 已暂停
deviceCount: number;
targetGroups: string[];
pushCount: number;
successCount: number;
lastPushTime: string;
createTime: string;
creator: string;
pushInterval: number;
maxPushPerDay: number;
timeRange: { start: string; end: string };
messageType: "text" | "image" | "video" | "link";
messageContent: string;
targetTags: string[];
pushMode: "immediate" | "scheduled";
scheduledTime?: string;
}
interface ApiResponse<T = any> {
code: number;
message: string;
data: T;
}
export async function fetchGroupPushTasks(): Promise<GroupPushTask[]> {
const response = await request("/v1/workbench/list", { type: 3 }, "GET");
if (Array.isArray(response)) return response;
if (response && Array.isArray(response.data)) return response.data;
return [];
}
export async function deleteGroupPushTask(id: string): Promise<ApiResponse> {
return request(`/v1/workspace/group-push/tasks/${id}`, {}, "DELETE");
}
export async function toggleGroupPushTask(
id: string,
status: string
): Promise<ApiResponse> {
return request(
`/v1/workspace/group-push/tasks/${id}/toggle`,
{ status },
"POST"
);
}
export async function copyGroupPushTask(id: string): Promise<ApiResponse> {
return request(`/v1/workspace/group-push/tasks/${id}/copy`, {}, "POST");
}
export async function createGroupPushTask(
taskData: Partial<GroupPushTask>
): Promise<ApiResponse> {
return request("/v1/workspace/group-push/tasks", taskData, "POST");
}
export async function updateGroupPushTask(
id: string,
taskData: Partial<GroupPushTask>
): Promise<ApiResponse> {
return request(`/v1/workspace/group-push/tasks/${id}`, taskData, "PUT");
}
export async function getGroupPushTaskDetail(
id: string
): Promise<GroupPushTask> {
return request(`/v1/workspace/group-push/tasks/${id}`);
}

View File

@@ -0,0 +1,250 @@
import React, { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { Button, Spin, message } from "antd";
import { ArrowLeftOutlined } from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import MeauMobile from "@/components/MeauMobile/MeauMoible";
import StepIndicator from "./form/components/StepIndicator";
import BasicSettings from "./form/components/BasicSettings";
import GroupSelector from "./form/components/GroupSelector";
import ContentSelector from "./form/components/ContentSelector";
import {
getGroupPushTaskDetail,
updateGroupPushTask,
GroupPushTask,
} from "@/api/groupPush";
const steps = [
{ id: 1, title: "步骤 1", subtitle: "基础设置" },
{ id: 2, title: "步骤 2", subtitle: "选择社群" },
{ id: 3, title: "步骤 3", subtitle: "选择内容库" },
{ id: 4, title: "步骤 4", subtitle: "京东联盟" },
];
const EditGroupPush: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [currentStep, setCurrentStep] = useState(1);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState<any>(null);
useEffect(() => {
if (!id) return;
setLoading(true);
getGroupPushTaskDetail(id)
.then((res) => {
const task = res.data || res;
setFormData({
name: task.name,
pushTimeStart: task.timeRange?.start || "06:00",
pushTimeEnd: task.timeRange?.end || "23:59",
dailyPushCount: task.maxPushPerDay,
pushOrder: task.pushOrder || "latest",
isLoopPush: task.isLoopPush || false,
isImmediatePush: task.pushMode === "immediate",
isEnabled: task.isEnabled || false,
groups: (task.targetGroups || []).map(
(name: string, idx: number) => ({
id: String(idx + 1),
name,
avatar: "",
serviceAccount: { id: "", name: "", avatar: "" },
})
),
contentLibraries: (task.contentLibraries || []).map(
(name: string, idx: number) => ({
id: String(idx + 1),
name,
targets: [],
})
),
});
})
.finally(() => setLoading(false));
}, [id]);
const handleBasicSettingsNext = (values: any) => {
setFormData((prev: any) => ({ ...prev, ...values }));
setCurrentStep(2);
};
const handleGroupsChange = (groups: any[]) => {
setFormData((prev: any) => ({ ...prev, groups }));
};
const handleLibrariesChange = (contentLibraries: any[]) => {
setFormData((prev: any) => ({ ...prev, contentLibraries }));
};
const handleSave = async () => {
if (!formData.name.trim()) {
message.error("请输入任务名称");
return;
}
if (!formData.groups || formData.groups.length === 0) {
message.error("请选择至少一个社群");
return;
}
if (!formData.contentLibraries || formData.contentLibraries.length === 0) {
message.error("请选择至少一个内容库");
return;
}
setSaving(true);
try {
const apiData = {
name: formData.name,
timeRange: {
start: formData.pushTimeStart,
end: formData.pushTimeEnd,
},
maxPushPerDay: formData.dailyPushCount,
pushOrder: formData.pushOrder,
isLoopPush: formData.isLoopPush,
isImmediatePush: formData.isImmediatePush,
isEnabled: formData.isEnabled,
targetGroups: formData.groups.map((g: any) => g.name),
contentLibraries: formData.contentLibraries.map((c: any) => c.name),
pushMode: formData.isImmediatePush
? ("immediate" as const)
: ("scheduled" as const),
messageType: "text" as const,
messageContent: "",
targetTags: [],
pushInterval: 60,
};
const response = await updateGroupPushTask(id!, apiData);
if (response.code === 200) {
message.success("保存成功");
navigate("/workspace/group-push");
} else {
message.error("保存失败,请稍后重试");
}
} catch (error) {
message.error("保存失败,请稍后重试");
} finally {
setSaving(false);
}
};
const handleCancel = () => {
navigate("/workspace/group-push");
};
if (loading || !formData) {
return (
<Layout
header={
<div
style={{
background: "#fff",
padding: "0 16px",
fontWeight: 600,
fontSize: 18,
}}
>
<ArrowLeftOutlined
onClick={() => navigate(-1)}
style={{ marginRight: 12, cursor: "pointer" }}
/>
</div>
}
footer={<MeauMobile />}
>
<div style={{ padding: 48, textAlign: "center" }}>
<Spin />
</div>
</Layout>
);
}
return (
<Layout
header={
<div
style={{
background: "#fff",
padding: "0 16px",
fontWeight: 600,
fontSize: 18,
}}
>
<ArrowLeftOutlined
onClick={() => navigate(-1)}
style={{ marginRight: 12, cursor: "pointer" }}
/>
</div>
}
footer={<MeauMobile />}
>
<div style={{ maxWidth: 600, margin: "0 auto", padding: 16 }}>
<StepIndicator currentStep={currentStep} steps={steps} />
<div style={{ marginTop: 32 }}>
{currentStep === 1 && (
<BasicSettings
defaultValues={{
name: formData.name,
pushTimeStart: formData.pushTimeStart,
pushTimeEnd: formData.pushTimeEnd,
dailyPushCount: formData.dailyPushCount,
pushOrder: formData.pushOrder,
isLoopPush: formData.isLoopPush,
isImmediatePush: formData.isImmediatePush,
isEnabled: formData.isEnabled,
}}
onNext={handleBasicSettingsNext}
onSave={handleSave}
onCancel={handleCancel}
loading={saving}
/>
)}
{currentStep === 2 && (
<GroupSelector
selectedGroups={formData.groups}
onGroupsChange={handleGroupsChange}
onPrevious={() => setCurrentStep(1)}
onNext={() => setCurrentStep(3)}
onSave={handleSave}
onCancel={handleCancel}
loading={saving}
/>
)}
{currentStep === 3 && (
<ContentSelector
selectedLibraries={formData.contentLibraries}
onLibrariesChange={handleLibrariesChange}
onPrevious={() => setCurrentStep(2)}
onNext={() => setCurrentStep(4)}
onSave={handleSave}
onCancel={handleCancel}
loading={saving}
/>
)}
{currentStep === 4 && (
<div style={{ padding: 32, textAlign: "center", color: "#888" }}>
<div
style={{
marginTop: 24,
display: "flex",
justifyContent: "center",
gap: 8,
}}
>
<Button onClick={() => setCurrentStep(3)} disabled={saving}>
</Button>
<Button type="primary" onClick={handleSave} loading={saving}>
</Button>
<Button onClick={handleCancel} disabled={saving}>
</Button>
</div>
</div>
)}
</div>
</div>
</Layout>
);
};
export default EditGroupPush;

View File

@@ -0,0 +1,100 @@
.searchBar {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 0 8px 0;
}
.taskList {
display: flex;
flex-direction: column;
gap: 16px;
}
.emptyCard {
text-align: center;
padding: 48px 0;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.taskCard {
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
padding: 20px 16px 12px 16px;
}
.taskHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.taskTitle {
display: flex;
align-items: center;
font-size: 16px;
font-weight: 600;
}
.taskActions {
display: flex;
align-items: center;
gap: 8px;
}
.taskInfoGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px 16px;
font-size: 13px;
color: #666;
margin-bottom: 12px;
}
.progressBlock {
margin-bottom: 12px;
}
.progressLabel {
font-size: 13px;
color: #888;
margin-bottom: 4px;
}
.taskFooter {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: #888;
border-top: 1px dashed #eee;
padding-top: 8px;
margin-top: 8px;
}
.expandedPanel {
margin-top: 16px;
padding-top: 16px;
border-top: 1px dashed #eee;
}
.expandedGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
}
@media (max-width: 600px) {
.taskCard {
padding: 12px 6px 8px 6px;
}
.expandedGrid {
grid-template-columns: 1fr;
}
}

View File

@@ -1,10 +1,393 @@
import React from "react";
import PlaceholderPage from "@/components/PlaceholderPage";
const GroupPush: React.FC = () => {
return (
<PlaceholderPage title="群发推送" showAddButton addButtonText="新建推送" />
);
};
export default GroupPush;
import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import {
PlusOutlined,
SearchOutlined,
ReloadOutlined,
MoreOutlined,
ClockCircleOutlined,
EditOutlined,
DeleteOutlined,
EyeOutlined,
CopyOutlined,
DownOutlined,
UpOutlined,
SettingOutlined,
CalendarOutlined,
TeamOutlined,
MessageOutlined,
SendOutlined,
} from "@ant-design/icons";
import {
Card,
Button,
Input,
Badge,
Switch,
Progress,
Dropdown,
Menu,
} from "antd";
import Layout from "@/components/Layout/Layout";
import MeauMobile from "@/components/MeauMobile/MeauMoible";
import {
fetchGroupPushTasks,
deleteGroupPushTask,
toggleGroupPushTask,
copyGroupPushTask,
GroupPushTask,
} from "@/api/groupPush";
import styles from "./GroupPush.module.scss";
const { Search } = Input;
const GroupPush: React.FC = () => {
const navigate = useNavigate();
const [expandedTaskId, setExpandedTaskId] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState("");
const [tasks, setTasks] = useState<GroupPushTask[]>([]);
const [loading, setLoading] = useState(false);
const fetchTasks = async () => {
setLoading(true);
try {
const list = await fetchGroupPushTasks();
setTasks(list);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchTasks();
}, []);
const toggleExpand = (taskId: string) => {
setExpandedTaskId(expandedTaskId === taskId ? null : taskId);
};
const handleDelete = async (taskId: string) => {
if (!window.confirm("确定要删除该任务吗?")) return;
await deleteGroupPushTask(taskId);
fetchTasks();
};
const handleEdit = (taskId: string) => {
navigate(`/workspace/group-push/${taskId}/edit`);
};
const handleView = (taskId: string) => {
navigate(`/workspace/group-push/${taskId}`);
};
const handleCopy = async (taskId: string) => {
await copyGroupPushTask(taskId);
fetchTasks();
};
const toggleTaskStatus = async (taskId: string) => {
const task = tasks.find((t) => t.id === taskId);
if (!task) return;
const newStatus = task.status === 1 ? 2 : 1;
await toggleGroupPushTask(taskId, String(newStatus));
fetchTasks();
};
const handleCreateNew = () => {
navigate("/workspace/group-push/new");
};
const filteredTasks = tasks.filter((task) =>
task.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const getStatusColor = (status: number) => {
switch (status) {
case 1:
return "green";
case 2:
return "gray";
default:
return "gray";
}
};
const getStatusText = (status: number) => {
switch (status) {
case 1:
return "进行中";
case 2:
return "已暂停";
default:
return "未知";
}
};
const getMessageTypeText = (type: string) => {
switch (type) {
case "text":
return "文字";
case "image":
return "图片";
case "video":
return "视频";
case "link":
return "链接";
default:
return "未知";
}
};
const getSuccessRate = (pushCount: number, successCount: number) => {
if (pushCount === 0) return 0;
return Math.round((successCount / pushCount) * 100);
};
return (
<Layout
header={
<div style={{ background: "#fff", padding: "0 16px" }}>
<div style={{ display: "flex", alignItems: "center", height: 48 }}>
<span style={{ fontWeight: 600, fontSize: 18 }}></span>
<Button
type="primary"
icon={<PlusOutlined />}
style={{ marginLeft: "auto" }}
onClick={handleCreateNew}
>
</Button>
</div>
</div>
}
footer={<MeauMobile />}
>
<div className={styles.bg}>
<div className={styles.searchBar}>
<Search
placeholder="搜索任务名称"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
enterButton={<SearchOutlined />}
style={{ maxWidth: 320 }}
/>
<Button
icon={<ReloadOutlined />}
onClick={fetchTasks}
loading={loading}
style={{ marginLeft: 8 }}
/>
</div>
<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)}
/>
<Dropdown
overlay={
<Menu>
<Menu.Item
key="view"
icon={<EyeOutlined />}
onClick={() => handleView(task.id)}
>
</Menu.Item>
<Menu.Item
key="edit"
icon={<EditOutlined />}
onClick={() => handleEdit(task.id)}
>
</Menu.Item>
<Menu.Item
key="copy"
icon={<CopyOutlined />}
onClick={() => handleCopy(task.id)}
>
</Menu.Item>
<Menu.Item
key="delete"
icon={<DeleteOutlined />}
onClick={() => handleDelete(task.id)}
danger
>
</Menu.Item>
</Menu>
}
trigger={["click"]}
>
<Button icon={<MoreOutlined />} />
</Dropdown>
</div>
</div>
<div className={styles.taskInfoGrid}>
<div>{task.deviceCount} </div>
<div>{task.targetGroups.length} </div>
<div>
{task.successCount}/{task.pushCount}
</div>
<div>{task.creator}</div>
</div>
<div className={styles.progressBlock}>
<div className={styles.progressLabel}></div>
<Progress
percent={getSuccessRate(task.pushCount, task.successCount)}
size="small"
/>
</div>
<div className={styles.taskFooter}>
<div>
<ClockCircleOutlined /> {task.lastPushTime}
</div>
<div>
{task.createTime}
<Button
type="link"
size="small"
icon={
expandedTaskId === task.id ? (
<UpOutlined />
) : (
<DownOutlined />
)
}
onClick={() => toggleExpand(task.id)}
/>
</div>
</div>
{expandedTaskId === task.id && (
<div className={styles.expandedPanel}>
<div className={styles.expandedGrid}>
<div>
<SettingOutlined /> <b></b>
<div>{task.pushInterval} </div>
<div>{task.maxPushPerDay} </div>
<div>
{task.timeRange.start} -{" "}
{task.timeRange.end}
</div>
<div>
{task.pushMode === "immediate"
? "立即推送"
: "定时推送"}
</div>
{task.scheduledTime && (
<div>{task.scheduledTime}</div>
)}
</div>
<div>
<TeamOutlined /> <b></b>
<div
style={{ display: "flex", flexWrap: "wrap", gap: 4 }}
>
{task.targetGroups.map((group) => (
<Badge
key={group}
color="blue"
text={group}
style={{ background: "#f0f5ff", marginRight: 4 }}
/>
))}
</div>
</div>
<div>
<MessageOutlined /> <b></b>
<div>
{getMessageTypeText(task.messageType)}
</div>
<div
style={{
background: "#f5f5f5",
padding: 8,
borderRadius: 4,
marginTop: 4,
}}
>
{task.messageContent}
</div>
</div>
<div>
<CalendarOutlined /> <b></b>
<div>
{task.pushCount} / {task.maxPushPerDay}
</div>
<Progress
percent={Math.round(
(task.pushCount / task.maxPushPerDay) * 100
)}
size="small"
/>
{task.targetTags.length > 0 && (
<div style={{ marginTop: 8 }}>
<div></div>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 4,
}}
>
{task.targetTags.map((tag) => (
<Badge
key={tag}
color="purple"
text={tag}
style={{
background: "#f9f0ff",
marginRight: 4,
}}
/>
))}
</div>
</div>
)}
</div>
</div>
</div>
)}
</Card>
))
)}
</div>
</div>
</Layout>
);
};
export default GroupPush;

View File

@@ -0,0 +1,258 @@
import React, { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { Card, Badge, Button, Progress, Spin } from "antd";
import {
ArrowLeftOutlined,
SettingOutlined,
TeamOutlined,
MessageOutlined,
CalendarOutlined,
} from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import MeauMobile from "@/components/MeauMobile/MeauMoible";
import { getGroupPushTaskDetail, GroupPushTask } from "@/api/groupPush";
import styles from "./GroupPush.module.scss";
const Detail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [task, setTask] = useState<GroupPushTask | null>(null);
useEffect(() => {
if (!id) return;
setLoading(true);
getGroupPushTaskDetail(id)
.then((res) => {
setTask(res.data || res); // 兼容两种返回格式
})
.finally(() => setLoading(false));
}, [id]);
if (loading) {
return (
<Layout
header={
<div
style={{
background: "#fff",
padding: "0 16px",
fontWeight: 600,
fontSize: 18,
}}
>
<ArrowLeftOutlined
onClick={() => navigate(-1)}
style={{ marginRight: 12, cursor: "pointer" }}
/>
</div>
}
footer={<MeauMobile />}
>
<div style={{ padding: 48, textAlign: "center" }}>
<Spin />
</div>
</Layout>
);
}
if (!task) {
return (
<Layout
header={
<div
style={{
background: "#fff",
padding: "0 16px",
fontWeight: 600,
fontSize: 18,
}}
>
<ArrowLeftOutlined
onClick={() => navigate(-1)}
style={{ marginRight: 12, cursor: "pointer" }}
/>
</div>
}
footer={<MeauMobile />}
>
<div style={{ padding: 48, textAlign: "center", color: "#888" }}>
</div>
</Layout>
);
}
const getStatusColor = (status: number) => {
switch (status) {
case 1:
return "green";
case 2:
return "gray";
default:
return "gray";
}
};
const getStatusText = (status: number) => {
switch (status) {
case 1:
return "进行中";
case 2:
return "已暂停";
default:
return "未知";
}
};
const getMessageTypeText = (type: string) => {
switch (type) {
case "text":
return "文字";
case "image":
return "图片";
case "video":
return "视频";
case "link":
return "链接";
default:
return "未知";
}
};
const getSuccessRate = (pushCount: number, successCount: number) => {
if (pushCount === 0) return 0;
return Math.round((successCount / pushCount) * 100);
};
return (
<Layout
header={
<div
style={{
background: "#fff",
padding: "0 16px",
fontWeight: 600,
fontSize: 18,
}}
>
<ArrowLeftOutlined
onClick={() => navigate(-1)}
style={{ marginRight: 12, cursor: "pointer" }}
/>
</div>
}
footer={<MeauMobile />}
>
<div className={styles.bg}>
<Card 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>
<div className={styles.taskInfoGrid}>
<div>{task.deviceCount} </div>
<div>{task.targetGroups.length} </div>
<div>
{task.successCount}/{task.pushCount}
</div>
<div>{task.creator}</div>
</div>
<div className={styles.progressBlock}>
<div className={styles.progressLabel}></div>
<Progress
percent={getSuccessRate(task.pushCount, task.successCount)}
size="small"
/>
</div>
<div className={styles.taskFooter}>
<div>
<CalendarOutlined /> {task.lastPushTime}
</div>
<div>{task.createTime}</div>
</div>
<div className={styles.expandedPanel}>
<div className={styles.expandedGrid}>
<div>
<SettingOutlined /> <b></b>
<div>{task.pushInterval} </div>
<div>{task.maxPushPerDay} </div>
<div>
{task.timeRange.start} - {task.timeRange.end}
</div>
<div>
{task.pushMode === "immediate" ? "立即推送" : "定时推送"}
</div>
{task.scheduledTime && (
<div>{task.scheduledTime}</div>
)}
</div>
<div>
<TeamOutlined /> <b></b>
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
{task.targetGroups.map((group) => (
<Badge
key={group}
color="blue"
text={group}
style={{ background: "#f0f5ff", marginRight: 4 }}
/>
))}
</div>
</div>
<div>
<MessageOutlined /> <b></b>
<div>{getMessageTypeText(task.messageType)}</div>
<div
style={{
background: "#f5f5f5",
padding: 8,
borderRadius: 4,
marginTop: 4,
}}
>
{task.messageContent}
</div>
</div>
<div>
<CalendarOutlined /> <b></b>
<div>
{task.pushCount} / {task.maxPushPerDay}
</div>
<Progress
percent={Math.round(
(task.pushCount / task.maxPushPerDay) * 100
)}
size="small"
/>
{task.targetTags.length > 0 && (
<div style={{ marginTop: 8 }}>
<div></div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
{task.targetTags.map((tag) => (
<Badge
key={tag}
color="purple"
text={tag}
style={{ background: "#f9f0ff", marginRight: 4 }}
/>
))}
</div>
</div>
)}
</div>
</div>
</div>
</Card>
</div>
</Layout>
);
};
export default Detail;

View File

@@ -0,0 +1,237 @@
import React, { useState } from "react";
import { Input, Button, Card, Switch } from "antd";
import { MinusOutlined, PlusOutlined } from "@ant-design/icons";
interface BasicSettingsProps {
defaultValues?: {
name: string;
pushTimeStart: string;
pushTimeEnd: string;
dailyPushCount: number;
pushOrder: "earliest" | "latest";
isLoopPush: boolean;
isImmediatePush: boolean;
isEnabled: boolean;
};
onNext: (values: any) => void;
onSave: (values: any) => void;
onCancel: () => void;
loading?: boolean;
}
const BasicSettings: React.FC<BasicSettingsProps> = ({
defaultValues = {
name: "",
pushTimeStart: "06:00",
pushTimeEnd: "23:59",
dailyPushCount: 20,
pushOrder: "latest",
isLoopPush: false,
isImmediatePush: false,
isEnabled: false,
},
onNext,
onSave,
onCancel,
loading = false,
}) => {
const [values, setValues] = useState(defaultValues);
const handleChange = (field: string, value: any) => {
setValues((prev) => ({ ...prev, [field]: value }));
};
const handleCountChange = (increment: boolean) => {
setValues((prev) => ({
...prev,
dailyPushCount: increment
? prev.dailyPushCount + 1
: Math.max(1, prev.dailyPushCount - 1),
}));
};
return (
<div style={{ marginBottom: 24 }}>
<Card>
<div style={{ padding: 16 }}>
{/* 任务名称 */}
<div style={{ marginBottom: 16 }}>
<span style={{ color: "red", marginRight: 4 }}>*</span>:
<Input
value={values.name}
onChange={(e) => handleChange("name", e.target.value)}
placeholder="请输入任务名称"
style={{ marginTop: 4 }}
/>
</div>
{/* 允许推送的时间段 */}
<div style={{ marginBottom: 16 }}>
<span>:</span>
<div style={{ display: "flex", gap: 8, marginTop: 4 }}>
<Input
type="time"
value={values.pushTimeStart}
onChange={(e) => handleChange("pushTimeStart", e.target.value)}
style={{ width: 120 }}
/>
<span style={{ color: "#888" }}></span>
<Input
type="time"
value={values.pushTimeEnd}
onChange={(e) => handleChange("pushTimeEnd", e.target.value)}
style={{ width: 120 }}
/>
</div>
</div>
{/* 每日推送 */}
<div style={{ marginBottom: 16 }}>
<span>:</span>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginTop: 4,
}}
>
<Button
icon={<MinusOutlined />}
onClick={() => handleCountChange(false)}
disabled={loading}
/>
<Input
type="number"
value={values.dailyPushCount}
onChange={(e) =>
handleChange(
"dailyPushCount",
Number.parseInt(e.target.value) || 1
)
}
style={{ width: 80, textAlign: "center" }}
min={1}
disabled={loading}
/>
<Button
icon={<PlusOutlined />}
onClick={() => handleCountChange(true)}
disabled={loading}
/>
<span style={{ color: "#888" }}></span>
</div>
</div>
{/* 推送顺序 */}
<div style={{ marginBottom: 16 }}>
<span>:</span>
<Button.Group style={{ marginLeft: 8 }}>
<Button
type={values.pushOrder === "earliest" ? "primary" : "default"}
onClick={() => handleChange("pushOrder", "earliest")}
disabled={loading}
>
</Button>
<Button
type={values.pushOrder === "latest" ? "primary" : "default"}
onClick={() => handleChange("pushOrder", "latest")}
disabled={loading}
>
</Button>
</Button.Group>
</div>
{/* 是否循环推送 */}
<div
style={{
marginBottom: 16,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span>
<span style={{ color: "red", marginRight: 4 }}>*</span>
:
</span>
<Switch
checked={values.isLoopPush}
onChange={(checked) => handleChange("isLoopPush", checked)}
disabled={loading}
/>
</div>
{/* 是否立即推送 */}
<div
style={{
marginBottom: 16,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span>
<span style={{ color: "red", marginRight: 4 }}>*</span>
:
</span>
<Switch
checked={values.isImmediatePush}
onChange={(checked) => handleChange("isImmediatePush", checked)}
disabled={loading}
/>
</div>
{values.isImmediatePush && (
<div
style={{
background: "#fffbe6",
border: "1px solid #ffe58f",
borderRadius: 4,
padding: 8,
color: "#ad8b00",
marginBottom: 16,
}}
>
</div>
)}
{/* 是否启用 */}
<div
style={{
marginBottom: 16,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span>
<span style={{ color: "red", marginRight: 4 }}>*</span>:
</span>
<Switch
checked={values.isEnabled}
onChange={(checked) => handleChange("isEnabled", checked)}
disabled={loading}
/>
</div>
</div>
</Card>
<div
style={{
display: "flex",
gap: 8,
justifyContent: "flex-end",
marginTop: 16,
}}
>
<Button onClick={() => onNext(values)} disabled={loading}>
</Button>
<Button onClick={() => onSave(values)} disabled={loading}>
{loading ? "保存中..." : "保存"}
</Button>
<Button onClick={onCancel} disabled={loading}>
</Button>
</div>
</div>
);
};
export default BasicSettings;

View File

@@ -0,0 +1,247 @@
import React, { useState } from "react";
import { Button, Card, Input, Checkbox, Avatar } from "antd";
import { FileTextOutlined, SearchOutlined } from "@ant-design/icons";
interface ContentLibrary {
id: string;
name: string;
targets: Array<{
id: string;
avatar: string;
}>;
}
interface ContentSelectorProps {
selectedLibraries: ContentLibrary[];
onLibrariesChange: (libraries: ContentLibrary[]) => void;
onPrevious: () => void;
onNext: () => void;
onSave: () => void;
onCancel: () => void;
loading?: boolean;
}
const mockLibraries: ContentLibrary[] = [
{
id: "1",
name: "产品推广内容库",
targets: [
{ id: "1", avatar: "https://via.placeholder.com/32" },
{ id: "2", avatar: "https://via.placeholder.com/32" },
{ id: "3", avatar: "https://via.placeholder.com/32" },
],
},
{
id: "2",
name: "活动宣传内容库",
targets: [
{ id: "4", avatar: "https://via.placeholder.com/32" },
{ id: "5", avatar: "https://via.placeholder.com/32" },
],
},
{
id: "3",
name: "客户服务内容库",
targets: [
{ id: "6", avatar: "https://via.placeholder.com/32" },
{ id: "7", avatar: "https://via.placeholder.com/32" },
{ id: "8", avatar: "https://via.placeholder.com/32" },
{ id: "9", avatar: "https://via.placeholder.com/32" },
],
},
{
id: "4",
name: "节日问候内容库",
targets: [
{ id: "10", avatar: "https://via.placeholder.com/32" },
{ id: "11", avatar: "https://via.placeholder.com/32" },
],
},
{
id: "5",
name: "新品发布内容库",
targets: [
{ id: "12", avatar: "https://via.placeholder.com/32" },
{ id: "13", avatar: "https://via.placeholder.com/32" },
{ id: "14", avatar: "https://via.placeholder.com/32" },
],
},
];
const ContentSelector: React.FC<ContentSelectorProps> = ({
selectedLibraries,
onLibrariesChange,
onPrevious,
onNext,
onSave,
onCancel,
loading = false,
}) => {
const [searchTerm, setSearchTerm] = useState("");
const [libraries] = useState<ContentLibrary[]>(mockLibraries);
const filteredLibraries = libraries.filter((library) =>
library.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleLibraryToggle = (library: ContentLibrary, checked: boolean) => {
if (checked) {
onLibrariesChange([...selectedLibraries, library]);
} else {
onLibrariesChange(selectedLibraries.filter((l) => l.id !== library.id));
}
};
const handleSelectAll = () => {
if (selectedLibraries.length === filteredLibraries.length) {
onLibrariesChange([]);
} else {
onLibrariesChange(filteredLibraries);
}
};
const isLibrarySelected = (libraryId: string) => {
return selectedLibraries.some((library) => library.id === libraryId);
};
return (
<div style={{ marginBottom: 24 }}>
<Card>
<div style={{ padding: 16 }}>
<div style={{ marginBottom: 16 }}>
<span>:</span>
<Input
prefix={<SearchOutlined />}
placeholder="搜索内容库名称"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
disabled={loading}
style={{ marginTop: 4 }}
/>
</div>
<div
style={{
marginBottom: 16,
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<Checkbox
checked={
selectedLibraries.length === filteredLibraries.length &&
filteredLibraries.length > 0
}
onChange={handleSelectAll}
disabled={loading}
>
({selectedLibraries.length}/{filteredLibraries.length})
</Checkbox>
</div>
<div style={{ maxHeight: 320, overflowY: "auto" }}>
{filteredLibraries.map((library) => (
<div
key={library.id}
style={{
display: "flex",
alignItems: "center",
padding: 8,
border: "1px solid #f0f0f0",
borderRadius: 6,
marginBottom: 8,
background: isLibrarySelected(library.id)
? "#e6f7ff"
: "#fff",
}}
>
<Checkbox
checked={isLibrarySelected(library.id)}
onChange={(e) =>
handleLibraryToggle(library, e.target.checked)
}
disabled={loading}
style={{ marginRight: 8 }}
/>
<Avatar
icon={<FileTextOutlined />}
size={40}
style={{
marginRight: 8,
background: "#e6f7ff",
color: "#1890ff",
}}
/>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 500 }}>{library.name}</div>
<div style={{ fontSize: 12, color: "#888", marginTop: 2 }}>
{library.targets.length}
</div>
</div>
<div style={{ display: "flex", gap: 2 }}>
{library.targets.slice(0, 3).map((target) => (
<Avatar
key={target.id}
src={target.avatar}
size={24}
style={{ border: "1px solid #fff" }}
/>
))}
{library.targets.length > 3 && (
<div
style={{
width: 24,
height: 24,
borderRadius: 12,
background: "#eee",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 12,
color: "#888",
border: "1px solid #fff",
}}
>
+{library.targets.length - 3}
</div>
)}
</div>
</div>
))}
{filteredLibraries.length === 0 && (
<div style={{ textAlign: "center", color: "#bbb", padding: 32 }}>
<FileTextOutlined style={{ fontSize: 32, marginBottom: 8 }} />
</div>
)}
</div>
</div>
</Card>
<div
style={{
display: "flex",
gap: 8,
justifyContent: "flex-end",
marginTop: 16,
}}
>
<Button onClick={onPrevious} disabled={loading}>
</Button>
<Button
onClick={onNext}
disabled={loading || selectedLibraries.length === 0}
>
</Button>
<Button onClick={onSave} disabled={loading}>
{loading ? "保存中..." : "保存"}
</Button>
<Button onClick={onCancel} disabled={loading}>
</Button>
</div>
</div>
);
};
export default ContentSelector;

View File

@@ -0,0 +1,245 @@
import React, { useState } from "react";
import { Button, Card, Input, Checkbox, Avatar } from "antd";
import { TeamOutlined, SearchOutlined } from "@ant-design/icons";
interface WechatGroup {
id: string;
name: string;
avatar: string;
serviceAccount: {
id: string;
name: string;
avatar: string;
};
}
interface GroupSelectorProps {
selectedGroups: WechatGroup[];
onGroupsChange: (groups: WechatGroup[]) => void;
onPrevious: () => void;
onNext: () => void;
onSave: () => void;
onCancel: () => void;
loading?: boolean;
}
const mockGroups: WechatGroup[] = [
{
id: "1",
name: "VIP客户群",
avatar: "https://via.placeholder.com/40",
serviceAccount: {
id: "1",
name: "客服小美",
avatar: "https://via.placeholder.com/32",
},
},
{
id: "2",
name: "潜在客户群",
avatar: "https://via.placeholder.com/40",
serviceAccount: {
id: "1",
name: "客服小美",
avatar: "https://via.placeholder.com/32",
},
},
{
id: "3",
name: "活动群",
avatar: "https://via.placeholder.com/40",
serviceAccount: {
id: "2",
name: "推广专员",
avatar: "https://via.placeholder.com/32",
},
},
{
id: "4",
name: "推广群",
avatar: "https://via.placeholder.com/40",
serviceAccount: {
id: "2",
name: "推广专员",
avatar: "https://via.placeholder.com/32",
},
},
{
id: "5",
name: "新客户群",
avatar: "https://via.placeholder.com/40",
serviceAccount: {
id: "3",
name: "销售小王",
avatar: "https://via.placeholder.com/32",
},
},
{
id: "6",
name: "体验群",
avatar: "https://via.placeholder.com/40",
serviceAccount: {
id: "3",
name: "销售小王",
avatar: "https://via.placeholder.com/32",
},
},
];
const GroupSelector: React.FC<GroupSelectorProps> = ({
selectedGroups,
onGroupsChange,
onPrevious,
onNext,
onSave,
onCancel,
loading = false,
}) => {
const [searchTerm, setSearchTerm] = useState("");
const [groups] = useState<WechatGroup[]>(mockGroups);
const filteredGroups = groups.filter(
(group) =>
group.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
group.serviceAccount.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleGroupToggle = (group: WechatGroup, checked: boolean) => {
if (checked) {
onGroupsChange([...selectedGroups, group]);
} else {
onGroupsChange(selectedGroups.filter((g) => g.id !== group.id));
}
};
const handleSelectAll = () => {
if (selectedGroups.length === filteredGroups.length) {
onGroupsChange([]);
} else {
onGroupsChange(filteredGroups);
}
};
const isGroupSelected = (groupId: string) => {
return selectedGroups.some((group) => group.id === groupId);
};
return (
<div style={{ marginBottom: 24 }}>
<Card>
<div style={{ padding: 16 }}>
<div style={{ marginBottom: 16 }}>
<span>:</span>
<Input
prefix={<SearchOutlined />}
placeholder="搜索群组名称或客服名称"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
disabled={loading}
style={{ marginTop: 4 }}
/>
</div>
<div
style={{
marginBottom: 16,
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<Checkbox
checked={
selectedGroups.length === filteredGroups.length &&
filteredGroups.length > 0
}
onChange={handleSelectAll}
disabled={loading}
>
({selectedGroups.length}/{filteredGroups.length})
</Checkbox>
</div>
<div style={{ maxHeight: 320, overflowY: "auto" }}>
{filteredGroups.map((group) => (
<div
key={group.id}
style={{
display: "flex",
alignItems: "center",
padding: 8,
border: "1px solid #f0f0f0",
borderRadius: 6,
marginBottom: 8,
background: isGroupSelected(group.id) ? "#e6f7ff" : "#fff",
}}
>
<Checkbox
checked={isGroupSelected(group.id)}
onChange={(e) => handleGroupToggle(group, e.target.checked)}
disabled={loading}
style={{ marginRight: 8 }}
/>
<Avatar
src={group.avatar}
size={40}
icon={<TeamOutlined />}
style={{ marginRight: 8 }}
/>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 500 }}>{group.name}</div>
<div
style={{
fontSize: 12,
color: "#888",
display: "flex",
alignItems: "center",
marginTop: 2,
}}
>
<Avatar
src={group.serviceAccount.avatar}
size={16}
style={{ marginRight: 4 }}
/>
{group.serviceAccount.name}
</div>
</div>
</div>
))}
{filteredGroups.length === 0 && (
<div style={{ textAlign: "center", color: "#bbb", padding: 32 }}>
<TeamOutlined style={{ fontSize: 32, marginBottom: 8 }} />
</div>
)}
</div>
</div>
</Card>
<div
style={{
display: "flex",
gap: 8,
justifyContent: "flex-end",
marginTop: 16,
}}
>
<Button onClick={onPrevious} disabled={loading}>
</Button>
<Button
onClick={onNext}
disabled={loading || selectedGroups.length === 0}
>
</Button>
<Button onClick={onSave} disabled={loading}>
{loading ? "保存中..." : "保存"}
</Button>
<Button onClick={onCancel} disabled={loading}>
</Button>
</div>
</div>
);
};
export default GroupSelector;

View File

@@ -0,0 +1,24 @@
import React from "react";
import { Steps } from "antd";
interface StepIndicatorProps {
currentStep: number;
steps: { id: number; title: string; subtitle: string }[];
}
const StepIndicator: React.FC<StepIndicatorProps> = ({
currentStep,
steps,
}) => {
return (
<div style={{ marginBottom: 24 }}>
<Steps current={currentStep - 1}>
{steps.map((step) => (
<Steps.Step key={step.id} title={step.subtitle} />
))}
</Steps>
</div>
);
};
export default StepIndicator;

View File

@@ -0,0 +1,224 @@
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "antd";
import { createGroupPushTask } from "@/api/groupPush";
import Layout from "@/components/Layout/Layout";
import MeauMobile from "@/components/MeauMobile/MeauMoible";
import StepIndicator from "./components/StepIndicator";
import BasicSettings from "./components/BasicSettings";
import GroupSelector from "./components/GroupSelector";
import ContentSelector from "./components/ContentSelector";
interface WechatGroup {
id: string;
name: string;
avatar: string;
serviceAccount: {
id: string;
name: string;
avatar: string;
};
}
interface ContentLibrary {
id: string;
name: string;
targets: Array<{
id: string;
avatar: string;
}>;
}
interface FormData {
name: string;
pushTimeStart: string;
pushTimeEnd: string;
dailyPushCount: number;
pushOrder: "earliest" | "latest";
isLoopPush: boolean;
isImmediatePush: boolean;
isEnabled: boolean;
groups: WechatGroup[];
contentLibraries: ContentLibrary[];
}
const steps = [
{ id: 1, title: "步骤 1", subtitle: "基础设置" },
{ id: 2, title: "步骤 2", subtitle: "选择社群" },
{ id: 3, title: "步骤 3", subtitle: "选择内容库" },
{ id: 4, title: "步骤 4", subtitle: "京东联盟" },
];
const NewGroupPush: React.FC = () => {
const navigate = useNavigate();
const [currentStep, setCurrentStep] = useState(1);
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState<FormData>({
name: "",
pushTimeStart: "06:00",
pushTimeEnd: "23:59",
dailyPushCount: 20,
pushOrder: "latest",
isLoopPush: false,
isImmediatePush: false,
isEnabled: false,
groups: [],
contentLibraries: [],
});
const handleBasicSettingsNext = (values: Partial<FormData>) => {
setFormData((prev) => ({ ...prev, ...values }));
setCurrentStep(2);
};
const handleGroupsChange = (groups: WechatGroup[]) => {
setFormData((prev) => ({ ...prev, groups }));
};
const handleLibrariesChange = (contentLibraries: ContentLibrary[]) => {
setFormData((prev) => ({ ...prev, contentLibraries }));
};
const handleSave = async () => {
if (!formData.name.trim()) {
window.alert("请输入任务名称");
return;
}
if (formData.groups.length === 0) {
window.alert("请选择至少一个社群");
return;
}
if (formData.contentLibraries.length === 0) {
window.alert("请选择至少一个内容库");
return;
}
setLoading(true);
try {
const apiData = {
name: formData.name,
timeRange: {
start: formData.pushTimeStart,
end: formData.pushTimeEnd,
},
maxPushPerDay: formData.dailyPushCount,
pushOrder: formData.pushOrder,
isLoopPush: formData.isLoopPush,
isImmediatePush: formData.isImmediatePush,
isEnabled: formData.isEnabled,
targetGroups: formData.groups.map((g) => g.name),
contentLibraries: formData.contentLibraries.map((c) => c.name),
pushMode: formData.isImmediatePush
? ("immediate" as const)
: ("scheduled" as const),
messageType: "text" as const,
messageContent: "",
targetTags: [],
pushInterval: 60,
};
const response = await createGroupPushTask(apiData);
if (response.code === 200) {
window.alert("保存成功");
navigate("/workspace/group-push");
} else {
window.alert("保存失败,请稍后重试");
}
} catch (error) {
window.alert("保存失败,请稍后重试");
} finally {
setLoading(false);
}
};
const handleCancel = () => {
navigate("/workspace/group-push");
};
return (
<Layout
header={
<div
style={{
background: "#fff",
padding: "0 16px",
fontWeight: 600,
fontSize: 18,
}}
>
</div>
}
footer={<MeauMobile />}
>
<div style={{ maxWidth: 600, margin: "0 auto", padding: 16 }}>
<StepIndicator currentStep={currentStep} steps={steps} />
<div style={{ marginTop: 32 }}>
{currentStep === 1 && (
<BasicSettings
defaultValues={{
name: formData.name,
pushTimeStart: formData.pushTimeStart,
pushTimeEnd: formData.pushTimeEnd,
dailyPushCount: formData.dailyPushCount,
pushOrder: formData.pushOrder,
isLoopPush: formData.isLoopPush,
isImmediatePush: formData.isImmediatePush,
isEnabled: formData.isEnabled,
}}
onNext={handleBasicSettingsNext}
onSave={handleSave}
onCancel={handleCancel}
loading={loading}
/>
)}
{currentStep === 2 && (
<GroupSelector
selectedGroups={formData.groups}
onGroupsChange={handleGroupsChange}
onPrevious={() => setCurrentStep(1)}
onNext={() => setCurrentStep(3)}
onSave={handleSave}
onCancel={handleCancel}
loading={loading}
/>
)}
{currentStep === 3 && (
<ContentSelector
selectedLibraries={formData.contentLibraries}
onLibrariesChange={handleLibrariesChange}
onPrevious={() => setCurrentStep(2)}
onNext={() => setCurrentStep(4)}
onSave={handleSave}
onCancel={handleCancel}
loading={loading}
/>
)}
{currentStep === 4 && (
<div style={{ padding: 32, textAlign: "center", color: "#888" }}>
<div
style={{
marginTop: 24,
display: "flex",
justifyContent: "center",
gap: 8,
}}
>
<Button onClick={() => setCurrentStep(3)} disabled={loading}>
</Button>
<Button type="primary" onClick={handleSave} loading={loading}>
</Button>
<Button onClick={handleCancel} disabled={loading}>
</Button>
</div>
</div>
)}
</div>
</div>
</Layout>
);
};
export default NewGroupPush;

View File

@@ -1,8 +0,0 @@
import React from "react";
import PlaceholderPage from "@/components/PlaceholderPage";
const NewGroupPush: React.FC = () => {
return <PlaceholderPage title="新建群发推送" />;
};
export default NewGroupPush;

View File

@@ -5,7 +5,8 @@ import AutoLikeDetail from "@/pages/workspace/auto-like/AutoLikeDetail";
import AutoGroup from "@/pages/workspace/auto-group/AutoGroup";
import AutoGroupDetail from "@/pages/workspace/auto-group/Detail";
import GroupPush from "@/pages/workspace/group-push/GroupPush";
import NewGroupPush from "@/pages/workspace/group-push/new";
import FormGroupPush from "@/pages/workspace/group-push/form";
import DetailGroupPush from "@/pages/workspace/group-push/detail";
import MomentsSync from "@/pages/workspace/moments-sync/MomentsSync";
import MomentsSyncDetail from "@/pages/workspace/moments-sync/Detail";
import NewMomentsSync from "@/pages/workspace/moments-sync/new";
@@ -60,18 +61,18 @@ const workspaceRoutes = [
auth: true,
},
{
path: "/workspace/group-push/new",
element: <NewGroupPush />,
path: "/workspace/group-push/:id",
element: <DetailGroupPush />,
auth: true,
},
{
path: "/workspace/group-push/:id",
element: <PlaceholderPage title="群发推送详情" />,
path: "/workspace/group-push/new",
element: <FormGroupPush />,
auth: true,
},
{
path: "/workspace/group-push/:id/edit",
element: <PlaceholderPage title="编辑群发推送" />,
element: <FormGroupPush />,
auth: true,
},
// 朋友圈同步