refactor(router): 移除未使用的页面文件并添加用户设置路由
移除多个未使用的页面组件、样式和API文件,包括流量分发、自动建群、朋友圈同步等功能模块。同时添加新的用户设置路由配置到移动端路由模块中。这些变更旨在清理代码库并支持新的用户设置功能。
This commit is contained in:
@@ -1,26 +0,0 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
// 获取场景列表
|
||||
export function getScenarios(params: any) {
|
||||
return request("/v1/plan/scenes", params, "GET");
|
||||
}
|
||||
|
||||
// 获取场景详情
|
||||
export function getScenarioDetail(id: string) {
|
||||
return request(`/v1/scenarios/${id}`, {}, "GET");
|
||||
}
|
||||
|
||||
// 创建场景
|
||||
export function createScenario(data: any) {
|
||||
return request("/v1/scenarios", data, "POST");
|
||||
}
|
||||
|
||||
// 更新场景
|
||||
export function updateScenario(id: string, data: any) {
|
||||
return request(`/v1/scenarios/${id}`, data, "PUT");
|
||||
}
|
||||
|
||||
// 删除场景
|
||||
export function deleteScenario(id: string) {
|
||||
return request(`/v1/scenarios/${id}`, {}, "DELETE");
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
// 导航栏样式
|
||||
.nav-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.nav-text {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.new-plan-btn {
|
||||
border-radius: 20px;
|
||||
padding: 4px 12px;
|
||||
height: 32px;
|
||||
font-size: 12px;
|
||||
background: var(--primary-gradient);
|
||||
border: none;
|
||||
box-shadow: 0 2px 8px var(--primary-shadow);
|
||||
|
||||
&:active {
|
||||
transform: translateY(1px);
|
||||
box-shadow: 0 1px 4px var(--primary-shadow);
|
||||
}
|
||||
}
|
||||
|
||||
// 页面容器
|
||||
.scene-page {
|
||||
background: #f5f6fa;
|
||||
min-height: 100vh;
|
||||
padding: 0 0 60px 0;
|
||||
}
|
||||
.error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 50px 30px;
|
||||
}
|
||||
// 错误提示
|
||||
.error-notice {
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 12px;
|
||||
background: #fff2e8;
|
||||
border: 1px solid #ffd591;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 4px rgba(255, 213, 145, 0.2);
|
||||
}
|
||||
|
||||
.error-notice-text {
|
||||
font-size: 12px;
|
||||
color: #d46b08;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// 加载状态
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: 14px;
|
||||
color: #ff4d4f;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.retry-button {
|
||||
min-width: 100px;
|
||||
border-radius: 20px;
|
||||
background: var(--primary-gradient);
|
||||
border: none;
|
||||
box-shadow: 0 2px 8px var(--primary-shadow);
|
||||
}
|
||||
|
||||
// 页面头部
|
||||
.scene-header {
|
||||
margin-bottom: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
font-size: 20px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
// 场景列表
|
||||
.scenarios-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
// 场景卡片
|
||||
.scenario-item {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.scenarios-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.scenario-card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
transition:
|
||||
box-shadow 0.2s,
|
||||
transform 0.2s;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
&:hover {
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||||
transform: translateY(-2px) scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
.card-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 18px 10px 14px 10px;
|
||||
}
|
||||
|
||||
.card-img-wrap {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.card-img-bg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: #f0f2f5;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.card-img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1677ff;
|
||||
text-align: center;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.card-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.card-count {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
.card-growth {
|
||||
font-size: 12px;
|
||||
color: #52c41a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// 空状态
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 36px;
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.empty-action {
|
||||
border-radius: 20px;
|
||||
background: var(--primary-gradient);
|
||||
border: none;
|
||||
box-shadow: 0 2px 8px var(--primary-shadow);
|
||||
padding: 6px 16px;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 480px) {
|
||||
.scenario-card {
|
||||
padding: 14px 16px;
|
||||
min-height: 70px;
|
||||
}
|
||||
|
||||
.scenario-icon {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
}
|
||||
|
||||
.scenario-image {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.scenario-name {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.stat-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.scenario-growth {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.growth-icon {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.scenarios-grid {
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
.card-inner {
|
||||
padding: 12px 4px 10px 4px;
|
||||
}
|
||||
.card-img-bg {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
.card-img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
}
|
||||
.card-desc {
|
||||
font-size: 11px;
|
||||
}
|
||||
.card-count {
|
||||
font-size: 12px;
|
||||
}
|
||||
.card-growth {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button, Toast } from "antd-mobile";
|
||||
import { PlusOutlined, RiseOutlined } from "@ant-design/icons";
|
||||
import MeauMobile from "@/components/MeauMobile/MeauMoible";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import { getScenarios } from "./api";
|
||||
import style from "./index.module.scss";
|
||||
|
||||
interface Scenario {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
description?: string;
|
||||
count: number;
|
||||
growth: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
const Scene: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [scenarios, setScenarios] = useState<Scenario[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchScenarios = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await getScenarios({ page: 1, limit: 20 });
|
||||
const transformedScenarios: Scenario[] = response.map((item: any) => ({
|
||||
id: item.id.toString(),
|
||||
name: item.name,
|
||||
image:
|
||||
item.image ||
|
||||
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-api.png",
|
||||
description: "",
|
||||
count: item.count,
|
||||
growth: item.growth,
|
||||
status: item.status,
|
||||
}));
|
||||
setScenarios(transformedScenarios);
|
||||
} catch (error) {
|
||||
setError("获取场景数据失败,请稍后重试");
|
||||
Toast.show({
|
||||
content: "获取场景数据失败,请稍后重试",
|
||||
position: "top",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchScenarios();
|
||||
}, []);
|
||||
|
||||
const handleScenarioClick = (scenarioId: string, scenarioName: string) => {
|
||||
navigate(
|
||||
`/scenarios/list/${scenarioId}/${encodeURIComponent(scenarioName)}`,
|
||||
);
|
||||
};
|
||||
|
||||
const handleNewPlan = () => {
|
||||
navigate("/scenarios/new");
|
||||
};
|
||||
|
||||
if (error && scenarios.length === 0) {
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<NavCommon
|
||||
left={<></>}
|
||||
title="场景获客"
|
||||
right={
|
||||
<Button size="small" color="primary" onClick={handleNewPlan}>
|
||||
<PlusOutlined /> 新建计划
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
footer={<MeauMobile activeKey="scenarios" />}
|
||||
>
|
||||
<div className={style["error"]}>
|
||||
<div className={style["error-text"]}>{error}</div>
|
||||
<Button color="primary" onClick={() => window.location.reload()}>
|
||||
重新加载
|
||||
</Button>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
loading={loading}
|
||||
header={
|
||||
<NavCommon
|
||||
left={<div className="nav-title">场景获客</div>}
|
||||
title={""}
|
||||
right={
|
||||
<Button
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={handleNewPlan}
|
||||
className="new-plan-btn"
|
||||
>
|
||||
<PlusOutlined /> 新建计划
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
footer={<MeauMobile activeKey="scenarios" />}
|
||||
>
|
||||
<div className={style["scene-page"]}>
|
||||
<div className={style["scenarios-grid"]}>
|
||||
{scenarios.map(scenario => (
|
||||
<div
|
||||
key={scenario.id}
|
||||
className={style["scenario-card"]}
|
||||
onClick={() => handleScenarioClick(scenario.id, scenario.name)}
|
||||
>
|
||||
<div className={style["card-inner"]}>
|
||||
<div className={style["card-img-wrap"]}>
|
||||
<div className={style["card-img-bg"]}>
|
||||
<img
|
||||
src={scenario.image}
|
||||
alt={scenario.name}
|
||||
className={style["card-img"]}
|
||||
onError={e => {
|
||||
e.currentTarget.src =
|
||||
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-api.png";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["card-title"]}>{scenario.name}</div>
|
||||
|
||||
<div className={style["card-stats"]}>
|
||||
<span className={style["card-count"]}>
|
||||
今日: {scenario.count}
|
||||
</span>
|
||||
<span className={style["card-growth"]}>
|
||||
<RiseOutlined
|
||||
style={{ fontSize: 14, color: "#52c41a", marginRight: 2 }}
|
||||
/>
|
||||
{scenario.growth}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Scene;
|
||||
@@ -1,41 +0,0 @@
|
||||
import request from "@/api/request";
|
||||
import { PlanDetail, PlanListResponse, ApiResponse } from "./data";
|
||||
|
||||
// ==================== 计划相关接口 ====================
|
||||
// 获取计划列表
|
||||
export function getPlanList(params: {
|
||||
sceneId: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}): Promise<PlanListResponse> {
|
||||
return request(`/v1/plan/list`, params, "GET");
|
||||
}
|
||||
|
||||
// 获取计划详情
|
||||
export function getPlanDetail(planId: string): Promise<PlanDetail> {
|
||||
return request(`/v1/plan/detail`, { planId }, "GET");
|
||||
}
|
||||
|
||||
// 复制计划
|
||||
export function copyPlan(planId: string): Promise<ApiResponse<any>> {
|
||||
return request(`/v1/plan/copy`, { planId }, "GET");
|
||||
}
|
||||
|
||||
// 删除计划
|
||||
export function deletePlan(planId: string): Promise<ApiResponse<any>> {
|
||||
return request(`/v1/plan/delete`, { planId }, "DELETE");
|
||||
}
|
||||
|
||||
// 获取小程序二维码
|
||||
export function getWxMinAppCode(planId: string): Promise<ApiResponse<string>> {
|
||||
return request(`/v1/plan/getWxMinAppCode`, { taskId: planId }, "GET");
|
||||
}
|
||||
//获客列表
|
||||
export function getUserList(planId: string, type: number) {
|
||||
return request(`/v1/plan/getUserList`, { planId, type }, "GET");
|
||||
}
|
||||
|
||||
//获客列表
|
||||
export function getFriendRequestTaskStats(taskId: string) {
|
||||
return request(`/v1/dashboard/friendRequestTaskStats`, { taskId }, "GET");
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Popup, Avatar, SpinLoading } from "antd-mobile";
|
||||
import { Button, message } from "antd";
|
||||
import { CloseOutlined } from "@ant-design/icons";
|
||||
import style from "./Popups.module.scss";
|
||||
import { getUserList } from "../api";
|
||||
|
||||
interface AccountItem {
|
||||
id: string | number;
|
||||
nickname?: string;
|
||||
wechatId?: string;
|
||||
avatar?: string;
|
||||
status?: string;
|
||||
userinfo: {
|
||||
alias: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
wechatId: string;
|
||||
};
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
interface AccountListModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
ruleId?: number;
|
||||
ruleName?: string;
|
||||
}
|
||||
|
||||
const AccountListModal: React.FC<AccountListModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
ruleId,
|
||||
ruleName,
|
||||
}) => {
|
||||
const [accounts, setAccounts] = useState<AccountItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 获取账号数据
|
||||
const fetchAccounts = async () => {
|
||||
if (!ruleId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const detailRes = await getUserList(ruleId.toString(), 1);
|
||||
const accountData = detailRes?.list || [];
|
||||
setAccounts(accountData);
|
||||
} catch (error) {
|
||||
console.error("获取账号详情失败:", error);
|
||||
message.error("获取账号详情失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 当弹窗打开且有ruleId时,获取数据
|
||||
useEffect(() => {
|
||||
if (visible && ruleId) {
|
||||
fetchAccounts();
|
||||
}
|
||||
}, [visible, ruleId]);
|
||||
|
||||
const title = ruleName ? `${ruleName} - 已添加账号列表` : "已添加账号列表";
|
||||
const getStatusColor = (status?: string | number) => {
|
||||
// 确保status是数字类型
|
||||
const statusNum = Number(status);
|
||||
|
||||
switch (statusNum) {
|
||||
case 0:
|
||||
return "#faad14"; // 待添加 - 黄色警告色
|
||||
case 1:
|
||||
return "#1890ff"; // 添加中 - 蓝色进行中
|
||||
case 2:
|
||||
return "#ff4d4f"; // 添加失败 - 红色错误色
|
||||
case 3:
|
||||
return "#ff4d4f"; // 添加失败 - 红色错误色
|
||||
case 4:
|
||||
return "#52c41a"; // 已添加 - 绿色成功色
|
||||
default:
|
||||
return "#d9d9d9"; // 未知状态 - 灰色
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status?: number) => {
|
||||
switch (status) {
|
||||
case 0:
|
||||
return "待添加";
|
||||
case 1:
|
||||
return "添加中";
|
||||
case 2:
|
||||
return "请求已发送待通过";
|
||||
case 3:
|
||||
return "添加失败";
|
||||
case 4:
|
||||
return "已添加";
|
||||
default:
|
||||
return "未知状态";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popup
|
||||
visible={visible}
|
||||
onMaskClick={onClose}
|
||||
position="bottom"
|
||||
bodyStyle={{
|
||||
height: "70vh",
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
}}
|
||||
>
|
||||
<div className={style.accountModal}>
|
||||
{/* 头部 */}
|
||||
<div className={style.accountModalHeader}>
|
||||
<h3 className={style.accountModalTitle}>{title}</h3>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={onClose}
|
||||
className={style.accountModalClose}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 账号列表 */}
|
||||
<div className={style.accountList}>
|
||||
{loading ? (
|
||||
<div className={style.accountLoading}>
|
||||
<SpinLoading color="primary" />
|
||||
<div className={style.accountLoadingText}>
|
||||
正在加载账号列表...
|
||||
</div>
|
||||
</div>
|
||||
) : accounts.length > 0 ? (
|
||||
accounts.map((account, index) => (
|
||||
<div key={account.id || index} className={style.accountItem}>
|
||||
<div className={style.accountAvatar}>
|
||||
<Avatar
|
||||
src={account.userinfo.avatar}
|
||||
style={{ "--size": "48px" }}
|
||||
fallback={
|
||||
(account.userinfo.nickname ||
|
||||
account.userinfo.alias ||
|
||||
"账号")[0]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.accountInfo}>
|
||||
<div className={style.accountName}>
|
||||
{account.userinfo.nickname ||
|
||||
account.userinfo.alias ||
|
||||
`账号${account.id}`}
|
||||
</div>
|
||||
<div className={style.accountWechatId}>
|
||||
{account.userinfo.wechatId || "未绑定微信号"}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.accountStatus}>
|
||||
<span
|
||||
className={style.statusDot}
|
||||
style={{ backgroundColor: getStatusColor(account.status) }}
|
||||
/>
|
||||
<span className={style.statusText}>
|
||||
{getStatusText(Number(account.status))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className={style.accountEmpty}>
|
||||
<div className={style.accountEmptyText}>暂无账号数据</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部统计 */}
|
||||
<div className={style.accountModalFooter}>
|
||||
<div className={style.accountStats}>
|
||||
<span>共 {accounts.length} 个账号</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountListModal;
|
||||
@@ -1,175 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Popup, Avatar, SpinLoading } from "antd-mobile";
|
||||
import { Button, message } from "antd";
|
||||
import { CloseOutlined } from "@ant-design/icons";
|
||||
import style from "./Popups.module.scss";
|
||||
import { getPlanDetail } from "../api";
|
||||
|
||||
interface DeviceItem {
|
||||
id: string | number;
|
||||
memo?: string;
|
||||
imei?: string;
|
||||
wechatId?: string;
|
||||
status?: "online" | "offline";
|
||||
avatar?: string;
|
||||
totalFriend?: number;
|
||||
}
|
||||
|
||||
interface DeviceListModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
ruleId?: number;
|
||||
ruleName?: string;
|
||||
}
|
||||
|
||||
const DeviceListModal: React.FC<DeviceListModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
ruleId,
|
||||
ruleName,
|
||||
}) => {
|
||||
const [devices, setDevices] = useState<DeviceItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 获取设备数据
|
||||
const fetchDevices = async () => {
|
||||
if (!ruleId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const detailRes = await getPlanDetail(ruleId.toString());
|
||||
const deviceData = detailRes?.deviceGroupsOptions || [];
|
||||
setDevices(deviceData);
|
||||
} catch (error) {
|
||||
console.error("获取设备详情失败:", error);
|
||||
message.error("获取设备详情失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 当弹窗打开且有ruleId时,获取数据
|
||||
useEffect(() => {
|
||||
if (visible && ruleId) {
|
||||
fetchDevices();
|
||||
}
|
||||
}, [visible, ruleId]);
|
||||
|
||||
const title = ruleName ? `${ruleName} - 分发设备列表` : "分发设备列表";
|
||||
const getStatusColor = (status?: string) => {
|
||||
return status === "online" ? "#52c41a" : "#ff4d4f";
|
||||
};
|
||||
|
||||
const getStatusText = (status?: string) => {
|
||||
return status === "online" ? "在线" : "离线";
|
||||
};
|
||||
|
||||
return (
|
||||
<Popup
|
||||
visible={visible}
|
||||
onMaskClick={onClose}
|
||||
position="bottom"
|
||||
bodyStyle={{
|
||||
height: "70vh",
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
}}
|
||||
>
|
||||
<div className={style.deviceModal}>
|
||||
{/* 头部 */}
|
||||
<div className={style.deviceModalHeader}>
|
||||
<h3 className={style.deviceModalTitle}>{title}</h3>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={onClose}
|
||||
className={style.deviceModalClose}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 设备列表 */}
|
||||
<div className={style.deviceList}>
|
||||
{loading ? (
|
||||
<div className={style.deviceLoading}>
|
||||
<SpinLoading color="primary" />
|
||||
<div className={style.deviceLoadingText}>正在加载设备列表...</div>
|
||||
</div>
|
||||
) : devices.length > 0 ? (
|
||||
devices.map((device, index) => (
|
||||
<div key={device.id || index} className={style.deviceItem}>
|
||||
{/* 顶部行:IMEI */}
|
||||
<div className={style.deviceHeaderRow}>
|
||||
<span className={style.deviceImeiText}>
|
||||
IMEI: {device.imei?.toUpperCase() || "-"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 主要内容区域:头像和详细信息 */}
|
||||
<div className={style.deviceMainContent}>
|
||||
{/* 头像 */}
|
||||
<div className={style.deviceAvatar}>
|
||||
{device.avatar ? (
|
||||
<img src={device.avatar} alt="头像" />
|
||||
) : (
|
||||
<span className={style.deviceAvatarText}>
|
||||
{(device.memo || device.wechatId || "设")[0]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 设备信息 */}
|
||||
<div className={style.deviceInfo}>
|
||||
<div className={style.deviceInfoHeader}>
|
||||
<h3 className={style.deviceName}>
|
||||
{device.memo || "未命名设备"}
|
||||
</h3>
|
||||
<span
|
||||
className={`${style.deviceStatusBadge} ${
|
||||
device.status === "online"
|
||||
? style.deviceStatusOnline
|
||||
: style.deviceStatusOffline
|
||||
}`}
|
||||
>
|
||||
{getStatusText(device.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={style.deviceInfoList}>
|
||||
<div className={style.deviceInfoItem}>
|
||||
<span className={style.deviceInfoLabel}>微信号:</span>
|
||||
<span className={style.deviceInfoValue}>
|
||||
{device.wechatId || "未绑定"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style.deviceInfoItem}>
|
||||
<span className={style.deviceInfoLabel}>好友数:</span>
|
||||
<span
|
||||
className={`${style.deviceInfoValue} ${style.deviceFriendCount}`}
|
||||
>
|
||||
{device.totalFriend ?? "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className={style.deviceEmpty}>
|
||||
<div className={style.deviceEmptyText}>暂无设备数据</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部统计 */}
|
||||
<div className={style.deviceModalFooter}>
|
||||
<div className={style.deviceStats}>
|
||||
<span>共 {devices.length} 个设备</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeviceListModal;
|
||||
@@ -1,175 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Popup, Avatar, SpinLoading } from "antd-mobile";
|
||||
import { Button, message } from "antd";
|
||||
import { CloseOutlined } from "@ant-design/icons";
|
||||
import style from "./Popups.module.scss";
|
||||
import { getUserList } from "../api";
|
||||
|
||||
interface AccountItem {
|
||||
id: string | number;
|
||||
nickname?: string;
|
||||
wechatId?: string;
|
||||
avatar?: string;
|
||||
status?: string;
|
||||
userinfo: {
|
||||
alias: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
wechatId: string;
|
||||
};
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
interface AccountListModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
ruleId?: number;
|
||||
ruleName?: string;
|
||||
}
|
||||
|
||||
const AccountListModal: React.FC<AccountListModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
ruleId,
|
||||
ruleName,
|
||||
}) => {
|
||||
const [accounts, setAccounts] = useState<AccountItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 获取账号数据
|
||||
const fetchAccounts = async () => {
|
||||
if (!ruleId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const detailRes = await getUserList(ruleId.toString(), 2);
|
||||
const accountData = detailRes?.list || [];
|
||||
setAccounts(accountData);
|
||||
} catch (error) {
|
||||
console.error("获取账号详情失败:", error);
|
||||
message.error("获取账号详情失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 当弹窗打开且有ruleId时,获取数据
|
||||
useEffect(() => {
|
||||
if (visible && ruleId) {
|
||||
fetchAccounts();
|
||||
}
|
||||
}, [visible, ruleId]);
|
||||
|
||||
const title = ruleName ? `${ruleName} - 已添加账号列表` : "已添加账号列表";
|
||||
const getStatusColor = (status?: string) => {
|
||||
switch (status) {
|
||||
case "normal":
|
||||
return "#52c41a";
|
||||
case "limited":
|
||||
return "#faad14";
|
||||
case "blocked":
|
||||
return "#ff4d4f";
|
||||
default:
|
||||
return "#d9d9d9";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status?: string) => {
|
||||
switch (status) {
|
||||
case "normal":
|
||||
return "正常";
|
||||
case "limited":
|
||||
return "受限";
|
||||
case "blocked":
|
||||
return "封禁";
|
||||
default:
|
||||
return "未知";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popup
|
||||
visible={visible}
|
||||
onMaskClick={onClose}
|
||||
position="bottom"
|
||||
bodyStyle={{
|
||||
height: "70vh",
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
}}
|
||||
>
|
||||
<div className={style.accountModal}>
|
||||
{/* 头部 */}
|
||||
<div className={style.accountModalHeader}>
|
||||
<h3 className={style.accountModalTitle}>{title}</h3>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={onClose}
|
||||
className={style.accountModalClose}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 账号列表 */}
|
||||
<div className={style.accountList}>
|
||||
{loading ? (
|
||||
<div className={style.accountLoading}>
|
||||
<SpinLoading color="primary" />
|
||||
<div className={style.accountLoadingText}>
|
||||
正在加载账号列表...
|
||||
</div>
|
||||
</div>
|
||||
) : accounts.length > 0 ? (
|
||||
accounts.map((account, index) => (
|
||||
<div key={account.id || index} className={style.accountItem}>
|
||||
<div className={style.accountAvatar}>
|
||||
<Avatar
|
||||
src={account.userinfo.avatar}
|
||||
style={{ "--size": "48px" }}
|
||||
fallback={
|
||||
(account.userinfo.nickname ||
|
||||
account.userinfo.alias ||
|
||||
"账号")[0]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.accountInfo}>
|
||||
<div className={style.accountName}>
|
||||
{account.userinfo.nickname ||
|
||||
account.userinfo.alias ||
|
||||
`账号${account.id}`}
|
||||
</div>
|
||||
<div className={style.accountWechatId}>
|
||||
{account.userinfo.wechatId || "未绑定微信号"}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.accountStatus}>
|
||||
<span
|
||||
className={style.statusDot}
|
||||
style={{ backgroundColor: getStatusColor(account.status) }}
|
||||
/>
|
||||
<span className={style.statusText}>
|
||||
{getStatusText(account.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className={style.accountEmpty}>
|
||||
<div className={style.accountEmptyText}>暂无账号数据</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部统计 */}
|
||||
<div className={style.accountModalFooter}>
|
||||
<div className={style.accountStats}>
|
||||
<span>共 {accounts.length} 个账号</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountListModal;
|
||||
@@ -1,161 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Popup, SpinLoading } from "antd-mobile";
|
||||
import { Button, message } from "antd";
|
||||
import { CloseOutlined } from "@ant-design/icons";
|
||||
import style from "./Popups.module.scss";
|
||||
import { getFriendRequestTaskStats } from "../api";
|
||||
import LineChart2 from "@/components/LineChart2";
|
||||
interface StatisticsData {
|
||||
totalAll: number;
|
||||
totalError: number;
|
||||
totalPass: number;
|
||||
totalPassRate: number;
|
||||
totalSuccess: number;
|
||||
totalSuccessRate: number;
|
||||
}
|
||||
|
||||
interface PoolListModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
ruleId?: number;
|
||||
ruleName?: string;
|
||||
}
|
||||
|
||||
const PoolListModal: React.FC<PoolListModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
ruleId,
|
||||
ruleName,
|
||||
}) => {
|
||||
const [statistics, setStatistics] = useState<StatisticsData>({
|
||||
totalAll: 0,
|
||||
totalError: 0,
|
||||
totalPass: 0,
|
||||
totalPassRate: 0,
|
||||
totalSuccess: 0,
|
||||
totalSuccessRate: 0,
|
||||
});
|
||||
|
||||
const [xData, setXData] = useState<any[]>([]);
|
||||
const [yData, setYData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 当弹窗打开且有ruleId时,获取数据
|
||||
useEffect(() => {
|
||||
if (visible && ruleId) {
|
||||
setLoading(true);
|
||||
getFriendRequestTaskStats(ruleId.toString())
|
||||
.then(res => {
|
||||
console.log(res);
|
||||
setXData(res.dateArray);
|
||||
setYData([
|
||||
res.allNumArray,
|
||||
res.errorNumArray,
|
||||
res.passNumArray,
|
||||
res.passRateArray,
|
||||
res.successNumArray,
|
||||
res.successRateArray,
|
||||
]);
|
||||
setStatistics(res.totalStats);
|
||||
setLoading(false);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [visible, ruleId]);
|
||||
|
||||
const title = ruleName ? `${ruleName} - 累计统计数据` : "累计统计数据";
|
||||
return (
|
||||
<Popup
|
||||
visible={visible}
|
||||
onMaskClick={onClose}
|
||||
position="bottom"
|
||||
bodyStyle={{
|
||||
height: "70vh",
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
}}
|
||||
>
|
||||
<div className={style.poolModal}>
|
||||
{/* 头部 */}
|
||||
<div className={style.poolModalHeader}>
|
||||
<h3 className={style.poolModalTitle}>{title}</h3>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={onClose}
|
||||
className={style.poolModalClose}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 统计数据表格 */}
|
||||
<div className={style.statisticsContent}>
|
||||
{loading ? (
|
||||
<div className={style.statisticsLoading}>
|
||||
<SpinLoading color="primary" />
|
||||
<div className={style.statisticsLoadingText}>
|
||||
正在加载统计数据...
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={style.statisticsTable}>
|
||||
<div className={style.statisticsRow}>
|
||||
<div className={style.statisticsLabel}>总计</div>
|
||||
<div className={style.statisticsValue}>
|
||||
{statistics.totalAll}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.statisticsRow}>
|
||||
<div className={style.statisticsLabel}>扫码</div>
|
||||
<div className={style.statisticsValue}>
|
||||
{statistics.totalError}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.statisticsRow}>
|
||||
<div className={style.statisticsLabel}>成功</div>
|
||||
<div className={style.statisticsValue}>
|
||||
{statistics.totalSuccess}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.statisticsRow}>
|
||||
<div className={style.statisticsLabel}>失败</div>
|
||||
<div className={style.statisticsValue}>
|
||||
{statistics.totalError}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.statisticsRow}>
|
||||
<div className={style.statisticsLabel}>通过</div>
|
||||
<div className={style.statisticsValue}>
|
||||
{statistics.totalPass}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.statisticsRow}>
|
||||
<div className={style.statisticsLabel}>成功率</div>
|
||||
<div className={style.statisticsValue}>
|
||||
{statistics.totalSuccessRate}%
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.statisticsRow}>
|
||||
<div className={style.statisticsLabel}>通过率</div>
|
||||
<div className={style.statisticsValue}>
|
||||
{statistics.totalPassRate}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 趋势图占位 */}
|
||||
<div className={style.trendChart}>
|
||||
<div className={style.chartTitle}>趋势图</div>
|
||||
<div className={style.chartPlaceholder}>
|
||||
<LineChart2 title="趋势图" xData={xData} yData={yData} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
};
|
||||
|
||||
export default PoolListModal;
|
||||
@@ -1,744 +0,0 @@
|
||||
.listToolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
font-size: 16px;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.ruleList {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.ruleCard {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
margin-bottom: 20px;
|
||||
padding: 16px;
|
||||
border: 1px solid #ececec;
|
||||
transition:
|
||||
box-shadow 0.2s,
|
||||
border-color 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
.ruleCard:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
border-color: #b3e5fc;
|
||||
}
|
||||
|
||||
.ruleHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.ruleName {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.ruleStatus {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ruleSwitch {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.ruleMenu {
|
||||
margin-left: 8px;
|
||||
cursor: pointer;
|
||||
color: #888;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.ruleMeta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
font-size: 15px;
|
||||
color: #444;
|
||||
font-weight: 500;
|
||||
}
|
||||
.ruleMetaItem {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
.ruleMetaItem:not(:last-child) {
|
||||
border-right: 1px solid #f0f0f0;
|
||||
}
|
||||
.ruleMetaItem:hover {
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.ruleDivider {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
margin: 12px 0 10px 0;
|
||||
}
|
||||
|
||||
.ruleStats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 16px;
|
||||
color: #222;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.ruleStatsItem {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
.ruleStatsItem:not(:last-child) {
|
||||
border-right: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.ruleFooter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-top: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ruleFooterIcon {
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
font-size: 15px;
|
||||
position: relative;
|
||||
top: -2px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #bbb;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
// 账号列表弹窗样式
|
||||
.accountModal {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.accountModalHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.accountModalTitle {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.accountModalClose {
|
||||
border: none;
|
||||
background: none;
|
||||
color: #888;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.accountList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.accountItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
}
|
||||
|
||||
.accountItem:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.accountAvatar {
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.accountInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.accountName {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #222;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.accountWechatId {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.accountStatus {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.statusText {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.accountEmpty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.accountEmptyText {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.accountLoading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.accountLoadingText {
|
||||
font-size: 15px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.accountModalFooter {
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.accountStats {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
// 设备列表弹窗样式
|
||||
.deviceModal {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.deviceModalHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.deviceModalTitle {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.deviceModalClose {
|
||||
border: none;
|
||||
background: none;
|
||||
color: #888;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.deviceList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.deviceItem {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid #ececec;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.deviceItem:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.deviceHeaderRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.deviceImeiText {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.deviceMainContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.deviceAvatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.deviceAvatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.deviceAvatarText {
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.deviceInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.deviceInfoHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.deviceName {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.deviceStatusBadge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.deviceStatusOnline {
|
||||
background: rgba(82, 196, 26, 0.1);
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.deviceStatusOffline {
|
||||
background: rgba(255, 77, 79, 0.1);
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.deviceInfoList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.deviceInfoItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.deviceInfoLabel {
|
||||
color: #888;
|
||||
margin-right: 6px;
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.deviceInfoValue {
|
||||
color: #444;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.deviceFriendCount {
|
||||
color: #1890ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.deviceEmpty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.deviceEmptyText {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.deviceLoading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.deviceLoadingText {
|
||||
font-size: 15px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.deviceModalFooter {
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.deviceStats {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
// 流量池列表弹窗样式
|
||||
.poolModal {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.poolModalHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.poolModalTitle {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.poolModalClose {
|
||||
border: none;
|
||||
background: none;
|
||||
color: #888;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.poolList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.poolItem {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid #ececec;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.poolItem:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.poolMainContent {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.poolIcon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #1890ff 0%, #722ed1 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.poolIconText {
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.poolInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.poolInfoHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.poolName {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.poolUserCount {
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background: rgba(24, 144, 255, 0.1);
|
||||
color: #1890ff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.poolInfoList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.poolInfoItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.poolInfoLabel {
|
||||
color: #888;
|
||||
margin-right: 6px;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.poolInfoValue {
|
||||
color: #444;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.poolTags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.poolTag {
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: #666;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.poolLoading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.poolLoadingText {
|
||||
font-size: 15px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.poolEmpty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.poolEmptyText {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.poolModalFooter {
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.poolStats {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
// 统计数据弹窗样式
|
||||
.statisticsContent {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.statisticsLoading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.statisticsLoadingText {
|
||||
font-size: 15px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.statisticsTable {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.statisticsRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
}
|
||||
|
||||
.statisticsRow:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.statisticsLabel {
|
||||
font-size: 15px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.statisticsValue {
|
||||
font-size: 16px;
|
||||
color: #222;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.trendChart {
|
||||
padding: 20px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.chartTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.chartPlaceholder {
|
||||
height: 200px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px dashed #d9d9d9;
|
||||
}
|
||||
|
||||
.chartNote {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
export interface Task {
|
||||
id: string;
|
||||
name: string;
|
||||
status: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
enabled: boolean;
|
||||
total_customers?: number;
|
||||
today_customers?: number;
|
||||
lastUpdated?: string;
|
||||
stats?: {
|
||||
devices?: number;
|
||||
acquired?: number;
|
||||
added?: number;
|
||||
};
|
||||
reqConf?: {
|
||||
device?: string[];
|
||||
selectedDevices?: string[];
|
||||
};
|
||||
acquiredCount?: number;
|
||||
addedCount?: number;
|
||||
passRate?: number;
|
||||
passCount?: number;
|
||||
}
|
||||
|
||||
export interface ApiSettings {
|
||||
apiKey: string;
|
||||
webhookUrl: string;
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
// API响应相关类型
|
||||
export interface TextUrl {
|
||||
apiKey: string;
|
||||
originalString?: string;
|
||||
sign?: string;
|
||||
fullUrl: string;
|
||||
}
|
||||
|
||||
export interface PlanDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
scenario: number;
|
||||
enabled: boolean;
|
||||
status: number;
|
||||
apiKey: string;
|
||||
textUrl: TextUrl;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
code: number;
|
||||
msg?: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface PlanListResponse {
|
||||
list: Task[];
|
||||
total: number;
|
||||
}
|
||||
@@ -1,444 +0,0 @@
|
||||
.scenario-list-page {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60vh;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
|
||||
.ant-input {
|
||||
border-radius: 8px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.plan-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 14px 0;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
margin-top: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
:global(.ant-pagination) {
|
||||
.ant-pagination-item {
|
||||
border-radius: 6px;
|
||||
border: 1px solid #d9d9d9;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
&.ant-pagination-item-active {
|
||||
background: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
|
||||
a {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-pagination-prev,
|
||||
.ant-pagination-next {
|
||||
border-radius: 6px;
|
||||
border: 1px solid #d9d9d9;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.plan-item {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.plan-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.plan-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.plan-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
padding: 4px;
|
||||
min-width: auto;
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
border: 1px solid #e9ecef;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background: #e6f7ff;
|
||||
border-color: #91d5ff;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.15);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 1px 4px rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
|
||||
&:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.plan-footer {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding-top: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.last-execution {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
|
||||
svg {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.create-first-btn {
|
||||
height: 40px;
|
||||
padding: 0 24px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
// 加载更多按钮样式
|
||||
.load-more-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.load-more-btn {
|
||||
height: 44px;
|
||||
padding: 0 32px;
|
||||
border-radius: 22px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
// 没有更多数据提示样式
|
||||
.no-more-data {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
|
||||
span {
|
||||
position: relative;
|
||||
padding: 0 20px;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 40px;
|
||||
height: 1px;
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: -50px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
right: -50px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-menu-dialog {
|
||||
background: white;
|
||||
border-radius: 16px 16px 0 0;
|
||||
padding: 20px;
|
||||
max-height: 60vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.action-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: #ff4d4f;
|
||||
|
||||
&:hover {
|
||||
background-color: #fff2f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: 16px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.action-text {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.qr-dialog {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.qr-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
gap: 16px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.qr-image {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.qr-error {
|
||||
text-align: center;
|
||||
color: #ff4d4f;
|
||||
font-size: 14px;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.qr-link-section {
|
||||
margin-top: 20px;
|
||||
width: 100%;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.link-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.link-input-wrapper {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.link-input {
|
||||
flex: 1;
|
||||
|
||||
.ant-input {
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
|
||||
&:focus {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
|
||||
.anticon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
@@ -1,671 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Button,
|
||||
Toast,
|
||||
SpinLoading,
|
||||
Dialog,
|
||||
Popup,
|
||||
Card,
|
||||
Tag,
|
||||
} from "antd-mobile";
|
||||
import { Input, Pagination } from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
SettingOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
QrcodeOutlined,
|
||||
EditOutlined,
|
||||
MoreOutlined,
|
||||
ClockCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import {
|
||||
getPlanList,
|
||||
getPlanDetail,
|
||||
copyPlan,
|
||||
deletePlan,
|
||||
getWxMinAppCode,
|
||||
} from "./api";
|
||||
import style from "./index.module.scss";
|
||||
import { Task, ApiSettings, PlanDetail } from "./data";
|
||||
import PlanApi from "./planApi";
|
||||
import { buildApiUrl } from "@/utils/apiUrl";
|
||||
import DeviceListModal from "./components/DeviceListModal";
|
||||
import AccountListModal from "./components/AccountListModal";
|
||||
import OreadyAdd from "./components/OreadyAdd";
|
||||
import PoolListModal from "./components/PoolListModal";
|
||||
|
||||
const ScenarioList: React.FC = () => {
|
||||
const { scenarioId, scenarioName } = useParams<{
|
||||
scenarioId: string;
|
||||
scenarioName: string;
|
||||
}>();
|
||||
const navigate = useNavigate();
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showApiDialog, setShowApiDialog] = useState(false);
|
||||
const [currentApiSettings, setCurrentApiSettings] = useState<ApiSettings>({
|
||||
apiKey: "",
|
||||
webhookUrl: "",
|
||||
taskId: "",
|
||||
});
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [loadingTasks, setLoadingTasks] = useState(false);
|
||||
const [showQrDialog, setShowQrDialog] = useState(false);
|
||||
const [qrLoading, setQrLoading] = useState(false);
|
||||
const [qrImg, setQrImg] = useState<any>("");
|
||||
const [currentTaskId, setCurrentTaskId] = useState<string>("");
|
||||
const [showActionMenu, setShowActionMenu] = useState<string | null>(null);
|
||||
|
||||
// 设备列表弹窗状态
|
||||
const [showDeviceList, setShowDeviceList] = useState(false);
|
||||
const [currentTask, setCurrentTask] = useState<Task | null>(null);
|
||||
|
||||
// 账号列表弹窗状态
|
||||
const [showAccountList, setShowAccountList] = useState(false);
|
||||
|
||||
// 已添加弹窗状态
|
||||
const [showOreadyAdd, setShowOreadyAdd] = useState(false);
|
||||
|
||||
// 通过率弹窗状态
|
||||
const [showPoolList, setShowPoolList] = useState(false);
|
||||
|
||||
// 分页相关状态
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [total, setTotal] = useState(0);
|
||||
const pageSize = 20;
|
||||
|
||||
// 获取计划列表数据
|
||||
const fetchPlanList = async (page: number, isLoadMore: boolean = false) => {
|
||||
if (!scenarioId) return;
|
||||
|
||||
if (isLoadMore) {
|
||||
setLoadingMore(true);
|
||||
} else {
|
||||
setLoadingTasks(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getPlanList({
|
||||
sceneId: scenarioId,
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
});
|
||||
|
||||
if (response && response.list) {
|
||||
if (isLoadMore) {
|
||||
// 加载更多时,追加数据
|
||||
setTasks(prev => [...prev, ...response.list]);
|
||||
} else {
|
||||
// 首次加载或刷新时,替换数据
|
||||
setTasks(response.list);
|
||||
}
|
||||
|
||||
// 更新分页信息
|
||||
setTotal(response.total || 0);
|
||||
setHasMore(response.list.length === pageSize);
|
||||
setCurrentPage(page);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取计划列表失败:", error);
|
||||
if (!isLoadMore) {
|
||||
setTasks([]);
|
||||
}
|
||||
Toast.show({
|
||||
content: "获取数据失败",
|
||||
position: "top",
|
||||
});
|
||||
} finally {
|
||||
if (isLoadMore) {
|
||||
setLoadingMore(false);
|
||||
} else {
|
||||
setLoadingTasks(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchScenarioData = async () => {
|
||||
if (!scenarioId) return;
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await fetchPlanList(1, false);
|
||||
} catch (error) {
|
||||
console.error("获取场景数据失败:", error);
|
||||
setTasks([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchScenarioData();
|
||||
}, [scenarioId]);
|
||||
|
||||
// 分页改变处理
|
||||
const handlePageChange = async (page: number) => {
|
||||
setCurrentPage(page);
|
||||
await fetchPlanList(page, false);
|
||||
};
|
||||
|
||||
const handleCopyPlan = async (taskId: string) => {
|
||||
const taskToCopy = tasks.find(task => task.id === taskId);
|
||||
if (!taskToCopy) return;
|
||||
|
||||
try {
|
||||
await copyPlan(taskId);
|
||||
Toast.show({
|
||||
content: `已成功复制"${taskToCopy.name}"`,
|
||||
position: "top",
|
||||
});
|
||||
// 刷新列表
|
||||
handleRefresh();
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
content: "复制失败,请重试",
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePlan = async (taskId: string) => {
|
||||
const taskToDelete = tasks.find(task => task.id === taskId);
|
||||
if (!taskToDelete) return;
|
||||
|
||||
const result = await Dialog.confirm({
|
||||
content: `确定要删除"${taskToDelete.name}"吗?`,
|
||||
confirmText: "删除",
|
||||
cancelText: "取消",
|
||||
});
|
||||
|
||||
if (result) {
|
||||
try {
|
||||
await deletePlan(taskId);
|
||||
Toast.show({
|
||||
content: "计划已删除",
|
||||
position: "top",
|
||||
});
|
||||
// 刷新列表
|
||||
handleRefresh();
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
content: "删除失败,请重试",
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenApiSettings = async (taskId: string) => {
|
||||
try {
|
||||
const response: PlanDetail = await getPlanDetail(taskId);
|
||||
if (response) {
|
||||
// 处理webhook URL,使用工具函数构建完整地址
|
||||
const webhookUrl = buildApiUrl(
|
||||
response.textUrl?.fullUrl || `webhook/${taskId}`,
|
||||
);
|
||||
|
||||
setCurrentApiSettings({
|
||||
apiKey: response.apiKey || "demo-api-key-123456",
|
||||
webhookUrl: webhookUrl,
|
||||
taskId: taskId,
|
||||
});
|
||||
setShowApiDialog(true);
|
||||
}
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
content: "获取计划接口失败",
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateNewPlan = () => {
|
||||
navigate(`/scenarios/new/${scenarioId}`);
|
||||
};
|
||||
|
||||
const handleShowQrCode = async (taskId: string) => {
|
||||
setQrLoading(true);
|
||||
setShowQrDialog(true);
|
||||
setQrImg("");
|
||||
setCurrentTaskId(taskId); // 设置当前任务ID
|
||||
|
||||
try {
|
||||
const response = await getWxMinAppCode(taskId);
|
||||
setQrImg(response);
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
content: "获取二维码失败",
|
||||
position: "top",
|
||||
});
|
||||
} finally {
|
||||
setQrLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理设备列表弹窗
|
||||
const handleShowDeviceList = (task: Task) => {
|
||||
setCurrentTask(task);
|
||||
setShowDeviceList(true);
|
||||
};
|
||||
|
||||
// 处理账号列表弹窗
|
||||
const handleShowAccountList = (task: Task) => {
|
||||
setCurrentTask(task);
|
||||
setShowAccountList(true);
|
||||
};
|
||||
|
||||
// 处理已添加弹窗
|
||||
const handleShowOreadyAdd = (task: Task) => {
|
||||
setCurrentTask(task);
|
||||
setShowOreadyAdd(true);
|
||||
};
|
||||
|
||||
// 处理通过率弹窗
|
||||
const handleShowPoolList = (task: Task) => {
|
||||
setCurrentTask(task);
|
||||
setShowPoolList(true);
|
||||
};
|
||||
|
||||
const getStatusColor = (status: number) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return "success";
|
||||
case 0:
|
||||
return "default";
|
||||
case -1:
|
||||
return "danger";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: number) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return "进行中";
|
||||
case 0:
|
||||
return "已暂停";
|
||||
case -1:
|
||||
return "已停止";
|
||||
default:
|
||||
return "未知";
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
// 重置分页状态
|
||||
setCurrentPage(1);
|
||||
setHasMore(true);
|
||||
await fetchPlanList(1, false);
|
||||
};
|
||||
|
||||
const filteredTasks = tasks.filter(task =>
|
||||
task.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
// 生成操作菜单
|
||||
const getActionMenu = (task: Task) => [
|
||||
{
|
||||
key: "edit",
|
||||
text: "编辑计划",
|
||||
icon: <EditOutlined />,
|
||||
onClick: () => {
|
||||
setShowActionMenu(null);
|
||||
navigate(`/scenarios/edit/${task.id}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "copy",
|
||||
text: "复制计划",
|
||||
icon: <CopyOutlined />,
|
||||
onClick: () => {
|
||||
setShowActionMenu(null);
|
||||
handleCopyPlan(task.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "settings",
|
||||
text: "计划接口",
|
||||
icon: <SettingOutlined />,
|
||||
onClick: () => {
|
||||
setShowActionMenu(null);
|
||||
handleOpenApiSettings(task.id);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
key: "delete",
|
||||
text: "删除计划",
|
||||
icon: <DeleteOutlined />,
|
||||
onClick: () => {
|
||||
setShowActionMenu(null);
|
||||
handleDeletePlan(task.id);
|
||||
},
|
||||
danger: true,
|
||||
},
|
||||
];
|
||||
|
||||
const deviceCount = (task: Task) => {
|
||||
return Array.isArray(task.reqConf?.device)
|
||||
? task.reqConf!.device.length
|
||||
: Array.isArray(task.reqConf?.selectedDevices)
|
||||
? task.reqConf!.selectedDevices.length
|
||||
: 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<NavCommon
|
||||
backFn={() => navigate("/scenarios")}
|
||||
title={scenarioName || ""}
|
||||
right={
|
||||
<Button
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={handleCreateNewPlan}
|
||||
>
|
||||
<PlusOutlined /> 新建计划
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 搜索栏 */}
|
||||
<div className={style["search-bar"]}>
|
||||
<div className={style["search-input-wrapper"]}>
|
||||
<Input
|
||||
placeholder="搜索计划名称"
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleRefresh}
|
||||
loading={loadingTasks}
|
||||
className="refresh-btn"
|
||||
>
|
||||
<ReloadOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
loading={loading}
|
||||
footer={
|
||||
<div className="pagination-container">
|
||||
<Pagination
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
current={currentPage}
|
||||
onChange={handlePageChange}
|
||||
showSizeChanger={false}
|
||||
showQuickJumper={false}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={style["scenario-list-page"]}>
|
||||
{/* 计划列表 */}
|
||||
<div className={style["plan-list"]}>
|
||||
{filteredTasks.length === 0 ? (
|
||||
<div className={style["empty-state"]}>
|
||||
<div className={style["empty-text"]}>
|
||||
{searchTerm ? "没有找到匹配的计划" : "暂无计划"}
|
||||
</div>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={handleCreateNewPlan}
|
||||
className={style["create-first-btn"]}
|
||||
>
|
||||
<PlusOutlined /> 创建第一个计划
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{filteredTasks.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>
|
||||
|
||||
{/* 计划接口弹窗 */}
|
||||
<PlanApi
|
||||
visible={showApiDialog}
|
||||
onClose={() => setShowApiDialog(false)}
|
||||
apiKey={currentApiSettings.apiKey}
|
||||
webhookUrl={currentApiSettings.webhookUrl}
|
||||
taskId={currentApiSettings.taskId}
|
||||
/>
|
||||
|
||||
{/* 操作菜单弹窗 */}
|
||||
<Popup
|
||||
visible={!!showActionMenu}
|
||||
onMaskClick={() => setShowActionMenu(null)}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "auto", maxHeight: "60vh" }}
|
||||
>
|
||||
<div className={style["action-menu-dialog"]}>
|
||||
<div className={style["dialog-header"]}>
|
||||
<h3>操作菜单</h3>
|
||||
<Button size="small" onClick={() => setShowActionMenu(null)}>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
<div className={style["dialog-content"]}>
|
||||
{showActionMenu &&
|
||||
getActionMenu(tasks.find(t => t.id === showActionMenu)!).map(
|
||||
item => (
|
||||
<div
|
||||
key={item.key}
|
||||
className={`${style["action-menu-item"]} ${item.danger ? style["danger"] : ""}`}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
<span className={style["action-icon"]}>{item.icon}</span>
|
||||
<span className={style["action-text"]}>{item.text}</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
|
||||
{/* 二维码弹窗 */}
|
||||
<Popup
|
||||
visible={showQrDialog}
|
||||
onMaskClick={() => setShowQrDialog(false)}
|
||||
position="bottom"
|
||||
>
|
||||
<div className={style["qr-dialog"]}>
|
||||
<div className={style["dialog-header"]}>
|
||||
<h3>小程序二维码</h3>
|
||||
<Button size="small" onClick={() => setShowQrDialog(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
<div className={style["dialog-content"]}>
|
||||
{qrLoading ? (
|
||||
<div className={style["qr-loading"]}>
|
||||
<SpinLoading color="primary" />
|
||||
<div>生成二维码中...</div>
|
||||
</div>
|
||||
) : qrImg ? (
|
||||
<>
|
||||
<img
|
||||
src={qrImg}
|
||||
alt="小程序二维码"
|
||||
className={style["qr-image"]}
|
||||
/>
|
||||
{/* 链接复制区域 */}
|
||||
<div className={style["qr-link-section"]}>
|
||||
<div className={style["link-label"]}>小程序链接</div>
|
||||
<div className={style["link-input-wrapper"]}>
|
||||
<Input
|
||||
value={`https://h5.ckb.quwanzhi.com/#/pages/form/input?id=${currentTaskId}`}
|
||||
readOnly
|
||||
className={style["link-input"]}
|
||||
placeholder="小程序链接"
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const link = `https://h5.ckb.quwanzhi.com/#/pages/form/input?id=${currentTaskId}`;
|
||||
navigator.clipboard.writeText(link);
|
||||
Toast.show({
|
||||
content: "链接已复制到剪贴板",
|
||||
position: "top",
|
||||
});
|
||||
}}
|
||||
className={style["copy-button"]}
|
||||
>
|
||||
<CopyOutlined />
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={style["qr-error"]}>二维码生成失败</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
|
||||
{/* 设备列表弹窗 */}
|
||||
<DeviceListModal
|
||||
visible={showDeviceList}
|
||||
onClose={() => setShowDeviceList(false)}
|
||||
ruleId={currentTask?.id ? parseInt(currentTask.id) : undefined}
|
||||
ruleName={currentTask?.name}
|
||||
/>
|
||||
|
||||
{/* 账号列表弹窗 */}
|
||||
<AccountListModal
|
||||
visible={showAccountList}
|
||||
onClose={() => setShowAccountList(false)}
|
||||
ruleId={currentTask?.id ? parseInt(currentTask.id) : undefined}
|
||||
ruleName={currentTask?.name}
|
||||
/>
|
||||
|
||||
{/* 已添加弹窗 */}
|
||||
<OreadyAdd
|
||||
visible={showOreadyAdd}
|
||||
onClose={() => setShowOreadyAdd(false)}
|
||||
ruleId={currentTask?.id ? parseInt(currentTask.id) : undefined}
|
||||
ruleName={currentTask?.name}
|
||||
/>
|
||||
|
||||
{/* 通过率弹窗 */}
|
||||
<PoolListModal
|
||||
visible={showPoolList}
|
||||
onClose={() => setShowPoolList(false)}
|
||||
ruleId={currentTask?.id ? parseInt(currentTask.id) : undefined}
|
||||
ruleName={currentTask?.name}
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScenarioList;
|
||||
@@ -1,601 +0,0 @@
|
||||
// 移动端样式
|
||||
.plan-api-dialog {
|
||||
background: white;
|
||||
border-radius: 16px 16px 0 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #fafafa;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
font-size: 24px;
|
||||
color: #1890ff;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
color: #999;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
display: flex;
|
||||
background: white;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
.nav-tab {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
padding: 12px 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
|
||||
svg {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #1890ff;
|
||||
border-bottom: 2px solid #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fafafa;
|
||||
|
||||
.security-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
|
||||
svg {
|
||||
color: #52c41a;
|
||||
}
|
||||
}
|
||||
|
||||
.complete-btn {
|
||||
height: 36px;
|
||||
padding: 0 24px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
// 配置内容样式
|
||||
.config-content {
|
||||
.config-section {
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
|
||||
.section-icon {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.api-input {
|
||||
flex: 1;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.security-tip {
|
||||
padding: 12px;
|
||||
background: #fff7e6;
|
||||
border: 1px solid #ffd591;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: #d46b08;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.params-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.param-section {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e9ecef;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.param-list {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
|
||||
div {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #e9ecef;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 测试内容样式
|
||||
.test-content {
|
||||
.test-section {
|
||||
h3 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.test-input {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.test-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
.test-btn {
|
||||
flex: 1;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 文档内容样式
|
||||
.docs-content {
|
||||
.docs-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.doc-card {
|
||||
text-align: center;
|
||||
padding: 24px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #f0f0f0;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.doc-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: #f0f8ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 16px;
|
||||
font-size: 24px;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 代码内容样式
|
||||
.code-content {
|
||||
.language-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
.lang-tab {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #d9d9d9;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #1890ff;
|
||||
border-color: #1890ff;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.code-block {
|
||||
position: relative;
|
||||
background: #f6f8fa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e1e4e8;
|
||||
overflow: hidden;
|
||||
|
||||
.code {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #24292e;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.copy-code-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PC端样式覆盖
|
||||
.plan-api-modal {
|
||||
.plan-api-dialog {
|
||||
border-radius: 12px;
|
||||
height: auto;
|
||||
max-height: 80vh;
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
.nav-tab {
|
||||
min-width: 100px;
|
||||
padding: 16px 12px;
|
||||
font-size: 15px;
|
||||
|
||||
svg {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.params-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.docs-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.test-buttons {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.plan-api-dialog {
|
||||
.header-content {
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
.nav-tab {
|
||||
font-size: 13px;
|
||||
padding: 10px 6px;
|
||||
|
||||
svg {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.config-content {
|
||||
.params-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.docs-content {
|
||||
.docs-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.test-content {
|
||||
.test-buttons {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.code-content {
|
||||
.language-tabs {
|
||||
.lang-tab {
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.code-block {
|
||||
.code {
|
||||
font-size: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 暗色主题支持
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.plan-api-dialog {
|
||||
background: #1f1f1f;
|
||||
color: #fff;
|
||||
|
||||
.dialog-header {
|
||||
background: #262626;
|
||||
border-bottom-color: #434343;
|
||||
|
||||
.header-content {
|
||||
h3 {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #a6a6a6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
background: #262626;
|
||||
border-bottom-color: #434343;
|
||||
|
||||
.nav-tab {
|
||||
color: #a6a6a6;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #1890ff;
|
||||
border-bottom-color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
background: #262626;
|
||||
border-top-color: #434343;
|
||||
|
||||
.security-note {
|
||||
color: #a6a6a6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.config-content {
|
||||
.section-title {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.security-tip {
|
||||
background: #2a1f00;
|
||||
border-color: #d48806;
|
||||
color: #ffc53d;
|
||||
}
|
||||
|
||||
.param-section {
|
||||
background: #262626;
|
||||
border-color: #434343;
|
||||
|
||||
h4 {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.param-list {
|
||||
color: #a6a6a6;
|
||||
|
||||
code {
|
||||
background: #434343;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.test-content {
|
||||
h3 {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.docs-content {
|
||||
.doc-card {
|
||||
background: #262626;
|
||||
border-color: #434343;
|
||||
|
||||
h4 {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #a6a6a6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.code-content {
|
||||
.code-block {
|
||||
background: #0d1117;
|
||||
border-color: #30363d;
|
||||
|
||||
.code {
|
||||
color: #c9d1d9;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,437 +0,0 @@
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { Popup, Button, Toast, SpinLoading } from "antd-mobile";
|
||||
import { Modal, Input, Tabs, Card, Tag, Space } from "antd";
|
||||
import {
|
||||
CopyOutlined,
|
||||
CodeOutlined,
|
||||
BookOutlined,
|
||||
ThunderboltOutlined,
|
||||
SettingOutlined,
|
||||
LinkOutlined,
|
||||
SafetyOutlined,
|
||||
CheckCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import style from "./planApi.module.scss";
|
||||
import { buildApiUrl } from "@/utils/apiUrl";
|
||||
|
||||
/**
|
||||
* 计划接口配置弹窗组件
|
||||
*
|
||||
* 使用示例:
|
||||
* ```tsx
|
||||
* const [showApiDialog, setShowApiDialog] = useState(false);
|
||||
* const [apiSettings, setApiSettings] = useState({
|
||||
* apiKey: "your-api-key",
|
||||
* webhookUrl: "https://api.example.com/webhook",
|
||||
* taskId: "task-123"
|
||||
* });
|
||||
*
|
||||
* <PlanApi
|
||||
* visible={showApiDialog}
|
||||
* onClose={() => setShowApiDialog(false)}
|
||||
* apiKey={apiSettings.apiKey}
|
||||
* webhookUrl={apiSettings.webhookUrl}
|
||||
* taskId={apiSettings.taskId}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* 特性:
|
||||
* - 移动端使用 Popup,PC端使用 Modal
|
||||
* - 支持四个标签页:接口配置、快速测试、开发文档、代码示例
|
||||
* - 支持多种编程语言的代码示例
|
||||
* - 响应式设计,自适应不同屏幕尺寸
|
||||
* - 支持暗色主题
|
||||
* - 自动拼接API地址前缀
|
||||
*/
|
||||
|
||||
interface PlanApiProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
apiKey: string;
|
||||
webhookUrl: string;
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
interface ApiSettings {
|
||||
apiKey: string;
|
||||
webhookUrl: string;
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
const PlanApi: React.FC<PlanApiProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
apiKey,
|
||||
webhookUrl,
|
||||
taskId,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState("config");
|
||||
const [activeLanguage, setActiveLanguage] = useState("javascript");
|
||||
|
||||
// 处理webhook URL,确保包含完整的API地址
|
||||
const fullWebhookUrl = useMemo(() => {
|
||||
return buildApiUrl(webhookUrl);
|
||||
}, [webhookUrl]);
|
||||
|
||||
// 生成测试URL
|
||||
const testUrl = useMemo(() => {
|
||||
if (!fullWebhookUrl) return "";
|
||||
return `${fullWebhookUrl}?name=测试客户&phone=13800138000&source=API测试`;
|
||||
}, [fullWebhookUrl]);
|
||||
|
||||
// 检测是否为移动端
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
|
||||
const handleCopy = (text: string, type: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
Toast.show({
|
||||
content: `${type}已复制到剪贴板`,
|
||||
position: "top",
|
||||
});
|
||||
};
|
||||
|
||||
const handleTestInBrowser = () => {
|
||||
window.open(testUrl, "_blank");
|
||||
};
|
||||
|
||||
const renderConfigTab = () => (
|
||||
<div className={style["config-content"]}>
|
||||
{/* API密钥配置 */}
|
||||
<div className={style["config-section"]}>
|
||||
<div className={style["section-header"]}>
|
||||
<div className={style["section-title"]}>
|
||||
<CheckCircleOutlined className={style["section-icon"]} />
|
||||
API密钥
|
||||
</div>
|
||||
<Tag color="green">安全认证</Tag>
|
||||
</div>
|
||||
<div className={style["input-group"]}>
|
||||
<Input value={apiKey} disabled className={style["api-input"]} />
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => handleCopy(apiKey, "API密钥")}
|
||||
className={style["copy-btn"]}
|
||||
>
|
||||
<CopyOutlined />
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
<div className={style["security-tip"]}>
|
||||
<strong>安全提示:</strong>
|
||||
请妥善保管API密钥,不要在客户端代码中暴露。建议在服务器端使用该密钥。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 接口地址配置 */}
|
||||
<div className={style["config-section"]}>
|
||||
<div className={style["section-header"]}>
|
||||
<div className={style["section-title"]}>
|
||||
<LinkOutlined className={style["section-icon"]} />
|
||||
接口地址
|
||||
</div>
|
||||
<Tag color="blue">POST请求</Tag>
|
||||
</div>
|
||||
<div className={style["input-group"]}>
|
||||
<Input
|
||||
value={fullWebhookUrl}
|
||||
disabled
|
||||
className={style["api-input"]}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => handleCopy(fullWebhookUrl, "接口地址")}
|
||||
className={style["copy-btn"]}
|
||||
>
|
||||
<CopyOutlined />
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 参数说明 */}
|
||||
<div className={style["params-grid"]}>
|
||||
<div className={style["param-section"]}>
|
||||
<h4>必要参数</h4>
|
||||
<div className={style["param-list"]}>
|
||||
<div>
|
||||
<code>name</code> - 客户姓名
|
||||
</div>
|
||||
<div>
|
||||
<code>phone</code> - 手机号码
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["param-section"]}>
|
||||
<h4>可选参数</h4>
|
||||
<div className={style["param-list"]}>
|
||||
<div>
|
||||
<code>source</code> - 来源标识
|
||||
</div>
|
||||
<div>
|
||||
<code>remark</code> - 备注信息
|
||||
</div>
|
||||
<div>
|
||||
<code>tags</code> - 客户标签
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderQuickTestTab = () => (
|
||||
<div className={style["test-content"]}>
|
||||
<div className={style["test-section"]}>
|
||||
<h3>快速测试URL</h3>
|
||||
<div className={style["input-group"]}>
|
||||
<Input value={testUrl} disabled className={style["test-input"]} />
|
||||
</div>
|
||||
<div className={style["test-buttons"]}>
|
||||
<Button
|
||||
onClick={() => handleCopy(testUrl, "测试URL")}
|
||||
className={style["test-btn"]}
|
||||
>
|
||||
<CopyOutlined />
|
||||
复制测试URL
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleTestInBrowser}
|
||||
className={style["test-btn"]}
|
||||
>
|
||||
在浏览器中测试
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderDocsTab = () => (
|
||||
<div className={style["docs-content"]}>
|
||||
<div className={style["docs-grid"]}>
|
||||
<Card className={style["doc-card"]}>
|
||||
<div className={style["doc-icon"]}>
|
||||
<BookOutlined />
|
||||
</div>
|
||||
<h4>完整API文档</h4>
|
||||
<p>详细的接口说明和参数文档</p>
|
||||
</Card>
|
||||
<Card className={style["doc-card"]}>
|
||||
<div className={style["doc-icon"]}>
|
||||
<LinkOutlined />
|
||||
</div>
|
||||
<h4>集成指南</h4>
|
||||
<p>第三方平台集成教程</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderCodeTab = () => {
|
||||
const codeExamples = {
|
||||
javascript: `fetch('${fullWebhookUrl}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ${apiKey}'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: '张三',
|
||||
phone: '13800138000',
|
||||
source: '官网表单',
|
||||
})
|
||||
})`,
|
||||
python: `import requests
|
||||
|
||||
url = '${fullWebhookUrl}'
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ${apiKey}'
|
||||
}
|
||||
data = {
|
||||
'name': '张三',
|
||||
'phone': '13800138000',
|
||||
'source': '官网表单'
|
||||
}
|
||||
|
||||
response = requests.post(url, json=data, headers=headers)`,
|
||||
php: `<?php
|
||||
$url = '${fullWebhookUrl}';
|
||||
$data = array(
|
||||
'name' => '张三',
|
||||
'phone' => '13800138000',
|
||||
'source' => '官网表单'
|
||||
);
|
||||
|
||||
$options = array(
|
||||
'http' => array(
|
||||
'header' => "Content-type: application/json\\r\\nAuthorization: Bearer ${apiKey}\\r\\n",
|
||||
'method' => 'POST',
|
||||
'content' => json_encode($data)
|
||||
)
|
||||
);
|
||||
|
||||
$context = stream_context_create($options);
|
||||
$result = file_get_contents($url, false, $context);`,
|
||||
java: `import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.net.URI;
|
||||
|
||||
HttpClient client = HttpClient.newHttpClient();
|
||||
String json = "{\\"name\\":\\"张三\\",\\"phone\\":\\"13800138000\\",\\"source\\":\\"官网表单\\"}";
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create("${fullWebhookUrl}"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", "Bearer ${apiKey}")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(json))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style["code-content"]}>
|
||||
<div className={style["language-tabs"]}>
|
||||
{Object.keys(codeExamples).map(lang => (
|
||||
<button
|
||||
key={lang}
|
||||
className={`${style["lang-tab"]} ${
|
||||
activeLanguage === lang ? style["active"] : ""
|
||||
}`}
|
||||
onClick={() => setActiveLanguage(lang)}
|
||||
>
|
||||
{lang.charAt(0).toUpperCase() + lang.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={style["code-block"]}>
|
||||
<pre className={style["code"]}>
|
||||
<code>
|
||||
{codeExamples[activeLanguage as keyof typeof codeExamples]}
|
||||
</code>
|
||||
</pre>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() =>
|
||||
handleCopy(
|
||||
codeExamples[activeLanguage as keyof typeof codeExamples],
|
||||
"代码",
|
||||
)
|
||||
}
|
||||
className={style["copy-code-btn"]}
|
||||
>
|
||||
<CopyOutlined />
|
||||
复制代码
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderContent = () => (
|
||||
<div className={style["plan-api-dialog"]}>
|
||||
{/* 头部 */}
|
||||
<div className={style["dialog-header"]}>
|
||||
<div className={style["header-left"]}>
|
||||
<CodeOutlined className={style["header-icon"]} />
|
||||
<div className={style["header-content"]}>
|
||||
<h3>计划接口配置</h3>
|
||||
<p>
|
||||
通过API接口直接导入客资到该获客计划,支持多种编程语言和第三方平台集成
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="small" onClick={onClose} className={style["close-btn"]}>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 导航标签 */}
|
||||
<div className={style["nav-tabs"]}>
|
||||
<button
|
||||
className={`${style["nav-tab"]} ${activeTab === "config" ? style["active"] : ""}`}
|
||||
onClick={() => setActiveTab("config")}
|
||||
>
|
||||
<SettingOutlined />
|
||||
接口配置
|
||||
</button>
|
||||
<button
|
||||
className={`${style["nav-tab"]} ${activeTab === "test" ? style["active"] : ""}`}
|
||||
onClick={() => setActiveTab("test")}
|
||||
>
|
||||
<ThunderboltOutlined />
|
||||
快速测试
|
||||
</button>
|
||||
<button
|
||||
className={`${style["nav-tab"]} ${activeTab === "docs" ? style["active"] : ""}`}
|
||||
onClick={() => setActiveTab("docs")}
|
||||
>
|
||||
<BookOutlined />
|
||||
开发文档
|
||||
</button>
|
||||
<button
|
||||
className={`${style["nav-tab"]} ${activeTab === "code" ? style["active"] : ""}`}
|
||||
onClick={() => setActiveTab("code")}
|
||||
>
|
||||
<CodeOutlined />
|
||||
代码示例
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className={style["dialog-content"]}>
|
||||
{activeTab === "config" && renderConfigTab()}
|
||||
{activeTab === "test" && renderQuickTestTab()}
|
||||
{activeTab === "docs" && renderDocsTab()}
|
||||
{activeTab === "code" && renderCodeTab()}
|
||||
</div>
|
||||
|
||||
{/* 底部 */}
|
||||
<div className={style["dialog-footer"]}>
|
||||
<div className={style["security-note"]}>
|
||||
<SafetyOutlined />
|
||||
所有数据传输均采用HTTPS加密
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={onClose}
|
||||
className={style["complete-btn"]}
|
||||
>
|
||||
完成配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 移动端使用Popup
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Popup
|
||||
visible={visible}
|
||||
onMaskClick={onClose}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "90vh" }}
|
||||
>
|
||||
{renderContent()}
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
// PC端使用Modal
|
||||
return (
|
||||
<Modal
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={800}
|
||||
centered
|
||||
className={style["plan-api-modal"]}
|
||||
>
|
||||
{renderContent()}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanApi;
|
||||
@@ -1,20 +0,0 @@
|
||||
import request from "@/api/request";
|
||||
// 获取场景类型列表
|
||||
export function getScenarioTypes() {
|
||||
return request("/v1/plan/scenes", undefined, "GET");
|
||||
}
|
||||
|
||||
// 创建计划
|
||||
export function createPlan(data: any) {
|
||||
return request("/v1/plan/create", data, "POST");
|
||||
}
|
||||
|
||||
// 更新计划
|
||||
export function updatePlan(data: any) {
|
||||
return request("/v1/plan/update", data, "PUT");
|
||||
}
|
||||
|
||||
// 获取计划详情
|
||||
export function getPlanDetail(planId: string) {
|
||||
return request(`/v1/plan/detail?planId=${planId}`, undefined, "GET");
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
// 步骤定义 - 只保留三个步骤
|
||||
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: "消息设置" },
|
||||
];
|
||||
|
||||
// 类型定义
|
||||
export interface FormData {
|
||||
name: string;
|
||||
scenario: number;
|
||||
status: number;
|
||||
sceneId: string | number;
|
||||
remarkType: string;
|
||||
greeting: string;
|
||||
addInterval: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
enabled: boolean;
|
||||
remarkFormat: string;
|
||||
addFriendInterval: number;
|
||||
posters: any[]; // 后续可替换为具体Poster类型
|
||||
device: string[];
|
||||
customTags: string[];
|
||||
customTagsOptions: string[];
|
||||
deviceGroups: string[];
|
||||
deviceGroupsOptions: DeviceSelectionItem[];
|
||||
wechatGroups: string[];
|
||||
wechatGroupsOptions: GroupSelectionItem[];
|
||||
messagePlans: any[];
|
||||
[key: string]: any;
|
||||
}
|
||||
export const defFormData: FormData = {
|
||||
name: "",
|
||||
scenario: 1,
|
||||
status: 0,
|
||||
sceneId: "",
|
||||
remarkType: "phone",
|
||||
greeting: "你好,请通过",
|
||||
addInterval: 1,
|
||||
startTime: "09:00",
|
||||
endTime: "18:00",
|
||||
enabled: true,
|
||||
remarkFormat: "",
|
||||
addFriendInterval: 1,
|
||||
posters: [],
|
||||
device: [],
|
||||
customTags: [],
|
||||
customTagsOptions: [],
|
||||
messagePlans: [],
|
||||
deviceGroups: [],
|
||||
deviceGroupsOptions: [],
|
||||
wechatGroups: [],
|
||||
wechatGroupsOptions: [],
|
||||
contentGroups: [],
|
||||
contentGroupsOptions: [],
|
||||
};
|
||||
@@ -1,218 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { message, Button, Space } from "antd";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import BasicSettings from "./steps/BasicSettings";
|
||||
import FriendRequestSettings from "./steps/FriendRequestSettings";
|
||||
import MessageSettings from "./steps/MessageSettings";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import StepIndicator from "@/components/StepIndicator";
|
||||
|
||||
import {
|
||||
getScenarioTypes,
|
||||
createPlan,
|
||||
getPlanDetail,
|
||||
updatePlan,
|
||||
} from "./index.api";
|
||||
import { FormData, defFormData, steps } from "./index.data";
|
||||
|
||||
export default function NewPlan() {
|
||||
const router = useNavigate();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [formData, setFormData] = useState<FormData>(defFormData);
|
||||
|
||||
const [sceneList, setSceneList] = useState<any[]>([]);
|
||||
const [sceneLoading, setSceneLoading] = useState(true);
|
||||
const { scenarioId, planId } = useParams<{
|
||||
scenarioId: string;
|
||||
planId: string;
|
||||
}>();
|
||||
const [isEdit, setIsEdit] = useState(false);
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
setSceneLoading(true);
|
||||
//获取场景类型
|
||||
getScenarioTypes()
|
||||
.then(data => {
|
||||
setSceneList(data || []);
|
||||
})
|
||||
.catch(err => {
|
||||
message.error(err.message || "获取场景类型失败");
|
||||
})
|
||||
.finally(() => setSceneLoading(false));
|
||||
if (planId) {
|
||||
setIsEdit(true);
|
||||
//获取计划详情
|
||||
|
||||
const detail = await getPlanDetail(planId);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
name: detail.name ?? "",
|
||||
scenario: Number(detail.scenario) || 1,
|
||||
scenarioTags: detail.scenarioTags ?? [],
|
||||
customTags: detail.customTags ?? [],
|
||||
customTagsOptions: detail.customTags ?? [],
|
||||
posters: detail.posters ?? [],
|
||||
device: detail.device ?? [],
|
||||
remarkType: detail.remarkType ?? "phone",
|
||||
greeting: detail.greeting ?? "",
|
||||
addInterval: detail.addInterval ?? 1,
|
||||
startTime: detail.startTime ?? "09:00",
|
||||
endTime: detail.endTime ?? "18:00",
|
||||
enabled: detail.enabled ?? true,
|
||||
sceneId: Number(detail.scenario) || 1,
|
||||
remarkFormat: detail.remarkFormat ?? "",
|
||||
addFriendInterval: detail.addFriendInterval ?? 1,
|
||||
tips: detail.tips ?? "",
|
||||
deviceGroups: detail.deviceGroups ?? [],
|
||||
deviceGroupsOptions: detail.deviceGroupsOptions ?? [],
|
||||
wechatGroups: detail.wechatGroups ?? [],
|
||||
wechatGroupsOptions: detail.wechatGroupsOptions ?? [],
|
||||
contentGroups: detail.contentGroups ?? [],
|
||||
contentGroupsOptions: detail.contentGroupsOptions ?? [],
|
||||
status: detail.status ?? 0,
|
||||
messagePlans: detail.messagePlans ?? [],
|
||||
}));
|
||||
} else {
|
||||
if (scenarioId) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
...{ scenario: Number(scenarioId) || 1 },
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 更新表单数据
|
||||
const onChange = (data: any) => {
|
||||
setFormData(prev => ({ ...prev, ...data }));
|
||||
};
|
||||
|
||||
// 处理保存
|
||||
const handleSave2 = async () => {
|
||||
if (isEdit && planId) {
|
||||
// 编辑:拼接后端需要的完整参数
|
||||
const editData = {
|
||||
...formData,
|
||||
...{ sceneId: Number(formData.scenario) },
|
||||
id: Number(planId),
|
||||
planId: Number(planId),
|
||||
};
|
||||
console.log("editData", editData);
|
||||
} else {
|
||||
// 新建
|
||||
formData.sceneId = Number(formData.scenario);
|
||||
console.log("formData", formData);
|
||||
}
|
||||
};
|
||||
// 处理保存
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
if (isEdit && planId) {
|
||||
// 编辑:拼接后端需要的完整参数
|
||||
const editData = {
|
||||
...formData,
|
||||
...{ sceneId: Number(formData.scenario) },
|
||||
id: Number(planId),
|
||||
planId: Number(planId),
|
||||
// 兼容后端需要的字段
|
||||
// 你可以根据实际需要补充其它字段
|
||||
};
|
||||
await updatePlan(editData);
|
||||
} else {
|
||||
// 新建
|
||||
formData.sceneId = Number(formData.scenario);
|
||||
await createPlan(formData);
|
||||
}
|
||||
message.success(isEdit ? "计划已更新" : "获客计划已创建");
|
||||
const sceneItem = sceneList.find(v => formData.scenario === v.id);
|
||||
router(`/scenarios/list/${formData.scenario}/${sceneItem.name}`);
|
||||
} catch (error) {
|
||||
message.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof error === "string"
|
||||
? error
|
||||
: isEdit
|
||||
? "更新计划失败,请重试"
|
||||
: "创建计划失败,请重试",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 下一步
|
||||
const handleNext = () => {
|
||||
if (currentStep === steps.length) {
|
||||
handleSave();
|
||||
} else {
|
||||
setCurrentStep(prev => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
// 上一步
|
||||
const handlePrev = () => {
|
||||
setCurrentStep(prev => Math.max(prev - 1, 1));
|
||||
};
|
||||
|
||||
// 渲染当前步骤内容
|
||||
const renderStepContent = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
<BasicSettings
|
||||
isEdit={isEdit}
|
||||
formData={formData}
|
||||
onChange={onChange}
|
||||
sceneList={sceneList}
|
||||
sceneLoading={sceneLoading}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<FriendRequestSettings formData={formData} onChange={onChange} />
|
||||
);
|
||||
case 3:
|
||||
return <MessageSettings formData={formData} onChange={onChange} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染底部按钮
|
||||
const renderFooterButtons = () => {
|
||||
return (
|
||||
<div style={{ padding: "16px", display: "flex", gap: "12px" }}>
|
||||
{currentStep > 1 && (
|
||||
<Button onClick={handlePrev} size="large" style={{ flex: 1 }}>
|
||||
上一步
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
onClick={handleNext}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{currentStep === steps.length ? "完成" : "下一步"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<NavCommon title={isEdit ? "编辑场景计划" : "新建场景计划"} />
|
||||
<StepIndicator currentStep={currentStep} steps={steps} />
|
||||
</>
|
||||
}
|
||||
footer={renderFooterButtons()}
|
||||
>
|
||||
{renderStepContent()}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,564 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Input, Button, Tag, Switch, Modal, Spin } from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
EyeOutlined,
|
||||
CloseOutlined,
|
||||
DownloadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
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 { GroupSelectionItem } from "@/components/GroupSelection/data";
|
||||
|
||||
interface BasicSettingsProps {
|
||||
isEdit: boolean;
|
||||
formData: any;
|
||||
onChange: (data: any) => void;
|
||||
sceneList: any[];
|
||||
sceneLoading: boolean;
|
||||
}
|
||||
|
||||
interface Material {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const generatePosterMaterials = (): Material[] => {
|
||||
return posterTemplates.map(template => ({
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
type: "poster",
|
||||
url: template.url,
|
||||
}));
|
||||
};
|
||||
|
||||
const BasicSettings: React.FC<BasicSettingsProps> = ({
|
||||
formData,
|
||||
onChange,
|
||||
sceneList,
|
||||
sceneLoading,
|
||||
}) => {
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
const [materials] = useState<Material[]>(generatePosterMaterials());
|
||||
const [selectedMaterials, setSelectedMaterials] = useState<Material[]>(
|
||||
formData.posters?.length > 0 ? formData.posters : [],
|
||||
);
|
||||
|
||||
// 自定义标签相关状态
|
||||
const [customTagInput, setCustomTagInput] = useState("");
|
||||
const [customTagsOptions, setCustomTagsOptions] = useState<string[]>(
|
||||
formData.customTagsOptions || [],
|
||||
);
|
||||
const [tips, setTips] = useState(formData.tips || "");
|
||||
const [selectedScenarioTags, setSelectedScenarioTags] = useState(
|
||||
formData.scenarioTags || [],
|
||||
);
|
||||
const [selectedCustomTags, setSelectedCustomTags] = useState(
|
||||
formData.customTags || [],
|
||||
);
|
||||
// 电话获客相关状态
|
||||
const [phoneSettings, setPhoneSettings] = useState({
|
||||
autoAdd: formData.phoneSettings?.autoAdd ?? true,
|
||||
speechToText: formData.phoneSettings?.speechToText ?? true,
|
||||
questionExtraction: formData.phoneSettings?.questionExtraction ?? true,
|
||||
});
|
||||
|
||||
// 新增:自定义海报相关状态
|
||||
const [customPosters, setCustomPosters] = useState<Material[]>([]);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
|
||||
// 新增:用于文件选择的ref
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 初始化时,如果没有选择场景,默认选择海报获客
|
||||
useEffect(() => {
|
||||
if (!formData.scenario) {
|
||||
onChange({ ...formData, scenario: "haibao" });
|
||||
}
|
||||
}, [formData, onChange]);
|
||||
|
||||
// 监听 formData 变化,同步自定义标签和获客标签状态
|
||||
useEffect(() => {
|
||||
setCustomTagsOptions(formData.customTagsOptions || []);
|
||||
setSelectedCustomTags(formData.customTags || []);
|
||||
}, [formData.customTagsOptions, formData.customTags]);
|
||||
|
||||
// 监听获客标签变化
|
||||
useEffect(() => {
|
||||
setSelectedScenarioTags(formData.scenarioTags || []);
|
||||
}, [formData.scenarioTags]);
|
||||
|
||||
useEffect(() => {
|
||||
setTips(formData.tips || "");
|
||||
}, [formData.tips]);
|
||||
|
||||
// 选中场景
|
||||
const handleScenarioSelect = (sceneId: number) => {
|
||||
onChange({ ...formData, scenario: sceneId });
|
||||
};
|
||||
|
||||
// 选中/取消标签
|
||||
const handleScenarioTagToggle = (tag: string) => {
|
||||
const newTags = selectedScenarioTags.includes(tag)
|
||||
? selectedScenarioTags.filter((t: string) => t !== tag)
|
||||
: [...selectedScenarioTags, tag];
|
||||
setSelectedScenarioTags(newTags);
|
||||
onChange({ ...formData, scenarioTags: newTags });
|
||||
};
|
||||
|
||||
const handleCustomTagToggle = (tag: string) => {
|
||||
const newTags = selectedCustomTags.includes(tag)
|
||||
? selectedCustomTags.filter((t: string) => t !== tag)
|
||||
: [...selectedCustomTags, tag];
|
||||
setSelectedCustomTags(newTags);
|
||||
onChange({ ...formData, customTags: newTags });
|
||||
};
|
||||
// 添加自定义标签
|
||||
const handleAddCustomTag = () => {
|
||||
if (!customTagInput.trim()) return;
|
||||
const newTag = customTagInput.trim();
|
||||
// 已存在则忽略
|
||||
if (customTagsOptions.includes(newTag)) {
|
||||
// 若未选中则顺便选中
|
||||
const maybeSelected = selectedCustomTags.includes(newTag)
|
||||
? selectedCustomTags
|
||||
: [...selectedCustomTags, newTag];
|
||||
setSelectedCustomTags(maybeSelected);
|
||||
onChange({ ...formData, customTags: maybeSelected });
|
||||
setCustomTagInput("");
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedOptions = [...customTagsOptions, newTag];
|
||||
const updatedSelected = [...selectedCustomTags, newTag];
|
||||
setCustomTagsOptions(updatedOptions);
|
||||
setSelectedCustomTags(updatedSelected);
|
||||
setCustomTagInput("");
|
||||
onChange({
|
||||
...formData,
|
||||
customTagsOptions: updatedOptions,
|
||||
customTags: updatedSelected,
|
||||
});
|
||||
};
|
||||
|
||||
// 删除自定义标签
|
||||
const handleRemoveCustomTag = (tagName: string) => {
|
||||
const updatedOptions = customTagsOptions.filter(
|
||||
(tag: string) => tag !== tagName,
|
||||
);
|
||||
setCustomTagsOptions(updatedOptions);
|
||||
|
||||
// 同时从选中的自定义标签中移除
|
||||
const updatedSelectedCustom = selectedCustomTags.filter(
|
||||
(t: string) => t !== tagName,
|
||||
);
|
||||
setSelectedCustomTags(updatedSelectedCustom);
|
||||
|
||||
onChange({
|
||||
...formData,
|
||||
customTagsOptions: updatedOptions,
|
||||
customTags: updatedSelectedCustom,
|
||||
});
|
||||
};
|
||||
|
||||
// 新增:删除自定义海报
|
||||
const handleRemoveCustomPoster = (id: string) => {
|
||||
setCustomPosters(prev => prev.filter(p => p.id !== id));
|
||||
// 如果选中则取消选中
|
||||
if (selectedMaterials.some(m => m.id === id)) {
|
||||
setSelectedMaterials([]);
|
||||
onChange({ ...formData, posters: [] });
|
||||
}
|
||||
};
|
||||
|
||||
// 修改:选中/取消选中海报
|
||||
const handleMaterialSelect = (material: Material) => {
|
||||
const isSelected = selectedMaterials.some(m => m.id === material.id);
|
||||
if (isSelected) {
|
||||
setSelectedMaterials([]);
|
||||
onChange({ ...formData, posters: [] });
|
||||
} else {
|
||||
setSelectedMaterials([material]);
|
||||
onChange({ ...formData, posters: [material] });
|
||||
}
|
||||
};
|
||||
|
||||
// 新增:全屏预览
|
||||
const handlePreviewImage = (url: string) => {
|
||||
setPreviewUrl(url);
|
||||
setIsPreviewOpen(true);
|
||||
};
|
||||
|
||||
// 下载模板
|
||||
const handleDownloadTemplate = () => {
|
||||
const template =
|
||||
"电话号码,微信号,来源,订单金额,下单日期\n13800138000,wxid_123,抖音,99.00,2024-03-03";
|
||||
const blob = new Blob([template], { type: "text/csv" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "订单导入模板.csv";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// 当前选中的场景对象
|
||||
const currentScene = sceneList.find(s => s.id === formData.scenario);
|
||||
//打开订单
|
||||
const openOrder =
|
||||
formData.scenario !== 2 ? { display: "none" } : { display: "block" };
|
||||
|
||||
const openPoster =
|
||||
formData.scenario !== 1 ? { display: "none" } : { display: "block" };
|
||||
|
||||
const handleWechatGroupSelect = (groups: GroupSelectionItem[]) => {
|
||||
onChange({
|
||||
...formData,
|
||||
wechatGroups: groups.map(v => v.id),
|
||||
wechatGroupsOptions: groups,
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div className={styles["basic-container"]}>
|
||||
{/* 场景选择区块 */}
|
||||
<div className={styles["basic-scene-select"]}>
|
||||
{sceneLoading ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: 80,
|
||||
}}
|
||||
>
|
||||
<Spin size="large"></Spin>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles["basic-scene-grid"]}>
|
||||
{sceneList.map(scene => {
|
||||
const selected = formData.scenario === scene.id;
|
||||
return (
|
||||
<button
|
||||
key={scene.id}
|
||||
onClick={() => handleScenarioSelect(scene.id)}
|
||||
className={
|
||||
styles["basic-scene-btn"] +
|
||||
(selected ? " " + styles.selected : "")
|
||||
}
|
||||
>
|
||||
{scene.name.replace("获客", "")}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 计划名称输入区 */}
|
||||
<div className={styles["basic-label"]}>计划名称</div>
|
||||
<div className={styles["basic-input-block"]}>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={e =>
|
||||
onChange({ ...formData, name: String(e.target.value) })
|
||||
}
|
||||
placeholder="请输入计划名称"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles["basic-label"]}>获客标签(可多选)</div>
|
||||
{/* 标签选择区块 */}
|
||||
{formData.scenario && (
|
||||
<div className={styles["basic-tag-list"]}>
|
||||
{(currentScene?.scenarioTags || []).map((tag: string) => (
|
||||
<Tag
|
||||
key={tag}
|
||||
color={selectedScenarioTags.includes(tag) ? "blue" : "default"}
|
||||
onClick={() => handleScenarioTagToggle(tag)}
|
||||
className={styles["basic-tag-item"]}
|
||||
>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
{/* 自定义标签 */}
|
||||
{customTagsOptions.map((tag: string) => (
|
||||
<Tag
|
||||
key={tag}
|
||||
color={selectedCustomTags.includes(tag) ? "blue" : "default"}
|
||||
onClick={() => handleCustomTagToggle(tag)}
|
||||
closable
|
||||
onClose={() => handleRemoveCustomTag(tag)}
|
||||
className={styles["basic-tag-item"]}
|
||||
>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 自定义标签输入区 */}
|
||||
<div className={styles["basic-custom-tag-input"]}>
|
||||
<Input
|
||||
type="text"
|
||||
value={customTagInput}
|
||||
onChange={e => setCustomTagInput(e.target.value)}
|
||||
onPressEnter={handleAddCustomTag}
|
||||
placeholder="添加自定义标签"
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleAddCustomTag}
|
||||
disabled={!customTagInput.trim()}
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
{/* 输入获客成功提示 - 只有海报场景才显示 */}
|
||||
{formData.scenario === 1 && (
|
||||
<>
|
||||
<div className={styles["basic-label"]}>请输入获客成功提示</div>
|
||||
<div className={styles["basic-success-tip"]}>
|
||||
<Input
|
||||
type="text"
|
||||
value={tips}
|
||||
onChange={e => {
|
||||
setTips(e.target.value);
|
||||
onChange({ ...formData, tips: e.target.value });
|
||||
}}
|
||||
placeholder="请输入获客成功提示"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* 选素材 */}
|
||||
<div className={styles["basic-materials"]} style={openPoster}>
|
||||
<div className={styles["basic-label"]}>选择海报</div>
|
||||
<div className={styles["basic-materials-grid"]}>
|
||||
{[...materials, ...customPosters].map(material => {
|
||||
const isSelected = selectedMaterials.some(
|
||||
m => m.id === material.id,
|
||||
);
|
||||
const isCustom = material.id.startsWith("custom-");
|
||||
return (
|
||||
<div
|
||||
key={material.id}
|
||||
className={
|
||||
styles["basic-material-card"] +
|
||||
(isSelected ? " " + styles.selected : "")
|
||||
}
|
||||
onClick={() => handleMaterialSelect(material)}
|
||||
>
|
||||
{/* 预览按钮:自定义海报在左上,内置海报在右上 */}
|
||||
<span
|
||||
className={styles["basic-material-preview"]}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
handlePreviewImage(material.url);
|
||||
}}
|
||||
>
|
||||
<EyeOutlined
|
||||
style={{ color: "#fff", width: 18, height: 18 }}
|
||||
/>
|
||||
</span>
|
||||
{/* 删除自定义海报按钮 */}
|
||||
{isCustom && (
|
||||
<Button
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
width: 28,
|
||||
height: 28,
|
||||
background: "rgba(0,0,0,0.5)",
|
||||
border: "none",
|
||||
borderRadius: "50%",
|
||||
zIndex: 2,
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
lineHeight: 20,
|
||||
color: "#ffffff",
|
||||
}}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
handleRemoveCustomPoster(material.id);
|
||||
}}
|
||||
>
|
||||
<CloseOutlined />
|
||||
</Button>
|
||||
)}
|
||||
<img
|
||||
src={material.url}
|
||||
alt={material.name}
|
||||
className={styles["basic-material-img"]}
|
||||
/>
|
||||
<div className={styles["basic-material-name"]}>
|
||||
{material.name}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* 添加海报卡片 */}
|
||||
<div
|
||||
className={styles["basic-add-material"]}
|
||||
onClick={() => uploadInputRef.current?.click()}
|
||||
>
|
||||
<span style={{ fontSize: 36, color: "#bbb", marginBottom: 8 }}>
|
||||
<PlusOutlined />
|
||||
</span>
|
||||
<span style={{ color: "#888" }}>添加海报</span>
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={async e => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// 直接上传
|
||||
try {
|
||||
const url = await uploadFile(file);
|
||||
const newPoster = {
|
||||
id: `custom-${Date.now()}`,
|
||||
name: "自定义海报",
|
||||
type: "poster",
|
||||
url: url,
|
||||
};
|
||||
setCustomPosters(prev => [...prev, newPoster]);
|
||||
} catch (err) {
|
||||
// 可加toast提示
|
||||
}
|
||||
e.target.value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 全屏图片预览 */}
|
||||
<Modal
|
||||
open={isPreviewOpen}
|
||||
onCancel={() => {
|
||||
setIsPreviewOpen(false);
|
||||
setPreviewUrl(null);
|
||||
}}
|
||||
footer={null}
|
||||
width={800}
|
||||
>
|
||||
{previewUrl && (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Preview"
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
{/* 群选择 - 只有微信群场景才显示 */}
|
||||
{formData.scenario === 7 && (
|
||||
<div className={styles["basic-group-selection"]}>
|
||||
<div className={styles["basic-label"]}>选择群聊</div>
|
||||
<GroupSelection
|
||||
selectedOptions={formData.wechatGroupsOptions || []}
|
||||
onSelect={handleWechatGroupSelect}
|
||||
placeholder="请选择微信群"
|
||||
className={styles["basic-group-selector"]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 订单导入区块 - 使用FileUpload组件 */}
|
||||
<div className={styles["basic-order-upload"]} style={openOrder}>
|
||||
<div className={styles["basic-order-upload-label"]}>订单表格上传</div>
|
||||
<div className={styles["basic-order-upload-actions"]}>
|
||||
<Button
|
||||
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||
onClick={handleDownloadTemplate}
|
||||
>
|
||||
<DownloadOutlined style={{ fontSize: 18 }} /> 下载模板
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles["basic-order-upload-file"]}>
|
||||
<FileUpload
|
||||
value={formData.orderFileUrl || ""}
|
||||
onChange={url => onChange({ ...formData, orderFileUrl: url })}
|
||||
acceptTypes={["excel"]}
|
||||
maxCount={1}
|
||||
maxSize={10}
|
||||
showPreview={false}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles["basic-order-upload-tip"]}>
|
||||
支持 Excel 格式,上传后将文件保存到服务器
|
||||
</div>
|
||||
</div>
|
||||
{/* 电话获客设置区块,仅在选择电话获客场景时显示 */}
|
||||
{formData.scenario === 5 && (
|
||||
<div className={styles["basic-phone-settings"]}>
|
||||
<div style={{ fontWeight: 600, fontSize: 16, marginBottom: 16 }}>
|
||||
电话获客设置
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<span>自动加好友</span>
|
||||
<Switch
|
||||
checked={phoneSettings.autoAdd}
|
||||
onChange={v => setPhoneSettings(s => ({ ...s, autoAdd: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<span>语音转文字</span>
|
||||
<Switch
|
||||
checked={phoneSettings.speechToText}
|
||||
onChange={v =>
|
||||
setPhoneSettings(s => ({ ...s, speechToText: v }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<span>问题提取</span>
|
||||
<Switch
|
||||
checked={phoneSettings.questionExtraction}
|
||||
onChange={v =>
|
||||
setPhoneSettings(s => ({ ...s, questionExtraction: v }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles["basic-footer-switch"]}>
|
||||
<span>是否启用</span>
|
||||
<Switch
|
||||
checked={formData.status === 1}
|
||||
onChange={value => onChange({ ...formData, status: value ? 1 : 0 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BasicSettings;
|
||||
@@ -1,232 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Input, Button, Modal, Alert, Select } from "antd";
|
||||
import { MessageOutlined } from "@ant-design/icons";
|
||||
import DeviceSelection from "@/components/DeviceSelection";
|
||||
import styles from "./friend.module.scss";
|
||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||
|
||||
interface FriendRequestSettingsProps {
|
||||
formData: any;
|
||||
onChange: (data: any) => void;
|
||||
}
|
||||
|
||||
// 招呼语模板
|
||||
const greetingTemplates = [
|
||||
"你好,请通过",
|
||||
"你好,了解XX,请通过",
|
||||
"你好,我是XX产品的客服请通过",
|
||||
"你好,感谢关注我们的产品",
|
||||
"你好,很高兴为您服务",
|
||||
];
|
||||
|
||||
// 备注类型选项
|
||||
const remarkTypes = [
|
||||
{ value: "phone", label: "手机号" },
|
||||
{ value: "nickname", label: "昵称" },
|
||||
{ value: "source", label: "来源" },
|
||||
];
|
||||
|
||||
const FriendRequestSettings: React.FC<FriendRequestSettingsProps> = ({
|
||||
formData,
|
||||
onChange,
|
||||
}) => {
|
||||
const [isTemplateDialogOpen, setIsTemplateDialogOpen] = useState(false);
|
||||
const [hasWarnings, setHasWarnings] = useState(false);
|
||||
|
||||
const [showRemarkTip, setShowRemarkTip] = useState(false);
|
||||
|
||||
// 获取场景标题
|
||||
const getScenarioTitle = () => {
|
||||
switch (formData.scenario) {
|
||||
case "douyin":
|
||||
return "抖音直播";
|
||||
case "xiaohongshu":
|
||||
return "小红书";
|
||||
case "weixinqun":
|
||||
return "微信群";
|
||||
case "gongzhonghao":
|
||||
return "公众号";
|
||||
default:
|
||||
return formData.name || "获客计划";
|
||||
}
|
||||
};
|
||||
|
||||
// 使用useEffect设置默认值
|
||||
useEffect(() => {
|
||||
if (!formData.greeting) {
|
||||
onChange({
|
||||
...formData,
|
||||
greeting: "你好,请通过",
|
||||
remarkType: "phone", // 默认选择手机号
|
||||
remarkFormat: `手机号+${getScenarioTitle()}`, // 默认备注格式
|
||||
addFriendInterval: 1,
|
||||
});
|
||||
}
|
||||
}, [formData, formData.greeting, onChange]);
|
||||
|
||||
// 检查是否有未完成的必填项
|
||||
useEffect(() => {
|
||||
const hasIncompleteFields = !formData.greeting?.trim();
|
||||
setHasWarnings(hasIncompleteFields);
|
||||
}, [formData]);
|
||||
|
||||
const handleTemplateSelect = (template: string) => {
|
||||
onChange({ ...formData, greeting: template });
|
||||
setIsTemplateDialogOpen(false);
|
||||
};
|
||||
const handleDevicesChange = (deviceGroupsOptions: DeviceSelectionItem[]) => {
|
||||
onChange({
|
||||
...formData,
|
||||
deviceGroups: deviceGroupsOptions.map(d => d.id),
|
||||
deviceGroupsOptions: deviceGroupsOptions,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles["friend-container"]}>
|
||||
{/* 选择设备区块 */}
|
||||
<div className={styles["friend-label"]}>选择设备</div>
|
||||
<div className={styles["friend-block"]}>
|
||||
<DeviceSelection
|
||||
selectedOptions={formData.deviceGroupsOptions}
|
||||
onSelect={handleDevicesChange}
|
||||
placeholder="选择设备"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 好友备注区块 */}
|
||||
<div className={styles["friend-label"]}>好友备注</div>
|
||||
<div className={styles["friend-block"]}>
|
||||
<div className={styles["friend-remark-container"]}>
|
||||
<Select
|
||||
value={formData.remarkType || "phone"}
|
||||
onChange={value => onChange({ ...formData, remarkType: value })}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{remarkTypes.map(type => (
|
||||
<Select.Option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<span
|
||||
className={styles["friend-remark-q"]}
|
||||
onMouseEnter={() => setShowRemarkTip(true)}
|
||||
onMouseLeave={() => setShowRemarkTip(false)}
|
||||
>
|
||||
?
|
||||
</span>
|
||||
{showRemarkTip && (
|
||||
<div className={styles["friend-remark-tip"]}>
|
||||
<div>设置添加好友时的备注格式</div>
|
||||
<div style={{ marginTop: 8, color: "#888", fontSize: 12 }}>
|
||||
备注格式预览:
|
||||
</div>
|
||||
<div style={{ marginTop: 4, color: "#1677ff" }}>
|
||||
{formData.remarkType === "phone" &&
|
||||
`138****1234+${getScenarioTitle()}`}
|
||||
{formData.remarkType === "nickname" &&
|
||||
`小红书用户2851+${getScenarioTitle()}`}
|
||||
{formData.remarkType === "source" &&
|
||||
`抖音直播+${getScenarioTitle()}`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 招呼语区块 */}
|
||||
<div className={styles["friend-label"]}>招呼语</div>
|
||||
<div className={styles["friend-block"]}>
|
||||
<Input
|
||||
value={formData.greeting}
|
||||
onChange={e => onChange({ ...formData, greeting: e.target.value })}
|
||||
placeholder="请输入招呼语"
|
||||
suffix={
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() => setIsTemplateDialogOpen(true)}
|
||||
style={{ padding: 0 }}
|
||||
>
|
||||
<MessageOutlined /> 参考模板
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 添加间隔区块 */}
|
||||
<div className={styles["friend-label"]}>添加间隔</div>
|
||||
<div
|
||||
className={styles["friend-interval-row"] + " " + styles["friend-block"]}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.addFriendInterval || 1}
|
||||
onChange={e =>
|
||||
onChange({
|
||||
...formData,
|
||||
addFriendInterval: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
<span>分钟</span>
|
||||
</div>
|
||||
|
||||
{/* 允许加人时间段区块 */}
|
||||
<div className={styles["friend-label"]}>允许加人的时间段</div>
|
||||
<div className={styles["friend-time-row"] + " " + styles["friend-block"]}>
|
||||
<Input
|
||||
type="time"
|
||||
value={formData.addFriendTimeStart || "09:00"}
|
||||
onChange={e =>
|
||||
onChange({ ...formData, addFriendTimeStart: e.target.value })
|
||||
}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
<span>至</span>
|
||||
<Input
|
||||
type="time"
|
||||
value={formData.addFriendTimeEnd || "18:00"}
|
||||
onChange={e =>
|
||||
onChange({ ...formData, addFriendTimeEnd: e.target.value })
|
||||
}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasWarnings && (
|
||||
<Alert
|
||||
message="警告"
|
||||
description="您有未完成的设置项,建议完善后再进入下一步。"
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 招呼语模板弹窗 */}
|
||||
<Modal
|
||||
open={isTemplateDialogOpen}
|
||||
onCancel={() => setIsTemplateDialogOpen(false)}
|
||||
footer={null}
|
||||
>
|
||||
<div>
|
||||
{greetingTemplates.map((template, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
onClick={() => handleTemplateSelect(template)}
|
||||
className={styles["friend-modal-btn"]}
|
||||
>
|
||||
{template}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FriendRequestSettings;
|
||||
@@ -1,336 +0,0 @@
|
||||
import React from "react";
|
||||
import { Input, Button } from "antd";
|
||||
import { CloseOutlined, ClockCircleOutlined } from "@ant-design/icons";
|
||||
import styles from "./messages.module.scss";
|
||||
// 导入Upload组件
|
||||
import ImageUpload from "@/components/Upload/ImageUpload/ImageUpload";
|
||||
import VideoUpload from "@/components/Upload/VideoUpload";
|
||||
import FileUpload from "@/components/Upload/FileUpload";
|
||||
import MainImgUpload from "@/components/Upload/MainImgUpload";
|
||||
// 导入GroupSelection组件
|
||||
import GroupSelection from "@/components/GroupSelection";
|
||||
import { GroupSelectionItem } from "@/components/GroupSelection/data";
|
||||
import { MessageContentItem, messageTypes } from "./base.data";
|
||||
|
||||
interface MessageCardProps {
|
||||
message: MessageContentItem;
|
||||
dayIndex: number;
|
||||
messageIndex: number;
|
||||
planDay: number;
|
||||
onUpdateMessage: (
|
||||
dayIndex: number,
|
||||
messageIndex: number,
|
||||
updates: Partial<MessageContentItem>,
|
||||
) => void;
|
||||
onRemoveMessage: (dayIndex: number, messageIndex: number) => void;
|
||||
onToggleIntervalUnit: (dayIndex: number, messageIndex: number) => void;
|
||||
}
|
||||
|
||||
const MessageCard: React.FC<MessageCardProps> = ({
|
||||
message,
|
||||
dayIndex,
|
||||
messageIndex,
|
||||
planDay,
|
||||
onUpdateMessage,
|
||||
onRemoveMessage,
|
||||
onToggleIntervalUnit,
|
||||
}) => {
|
||||
return (
|
||||
<div className={styles["messages-message-card"]}>
|
||||
<div className={styles["messages-message-header"]}>
|
||||
{/* 时间/间隔设置 */}
|
||||
<div className={styles["messages-message-header-content"]}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
{planDay === 0 ? (
|
||||
<>
|
||||
<span style={{ minWidth: 36 }}>间隔</span>
|
||||
<Input
|
||||
type="number"
|
||||
value={String(message.sendInterval || 5)}
|
||||
onChange={e =>
|
||||
onUpdateMessage(dayIndex, messageIndex, {
|
||||
sendInterval: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
style={{ width: 60 }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => onToggleIntervalUnit(dayIndex, messageIndex)}
|
||||
>
|
||||
<ClockCircleOutlined />
|
||||
{message.intervalUnit === "minutes" ? "分钟" : "秒"}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ minWidth: 60 }}>发送时间</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={23}
|
||||
value={String(message.scheduledTime?.hour || 9)}
|
||||
onChange={e =>
|
||||
onUpdateMessage(dayIndex, messageIndex, {
|
||||
scheduledTime: {
|
||||
...(message.scheduledTime || {
|
||||
hour: 9,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
}),
|
||||
hour: Number(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
style={{ width: 40 }}
|
||||
/>
|
||||
<span>:</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={59}
|
||||
value={String(message.scheduledTime?.minute || 0)}
|
||||
onChange={e =>
|
||||
onUpdateMessage(dayIndex, messageIndex, {
|
||||
scheduledTime: {
|
||||
...(message.scheduledTime || {
|
||||
hour: 9,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
}),
|
||||
minute: Number(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
style={{ width: 40 }}
|
||||
/>
|
||||
<span>:</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={59}
|
||||
value={String(message.scheduledTime?.second || 0)}
|
||||
onChange={e =>
|
||||
onUpdateMessage(dayIndex, messageIndex, {
|
||||
scheduledTime: {
|
||||
...(message.scheduledTime || {
|
||||
hour: 9,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
}),
|
||||
second: Number(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
style={{ width: 40 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className={styles["messages-message-remove-btn"]}
|
||||
onClick={() => onRemoveMessage(dayIndex, messageIndex)}
|
||||
title="删除"
|
||||
>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
{/* 类型切换按钮 */}
|
||||
<div className={styles["messages-message-type-btns"]}>
|
||||
{messageTypes.map(type => (
|
||||
<Button
|
||||
key={type.id}
|
||||
type={message.type === type.id ? "primary" : "default"}
|
||||
onClick={() =>
|
||||
onUpdateMessage(dayIndex, messageIndex, {
|
||||
type: type.id as any,
|
||||
})
|
||||
}
|
||||
className={styles["messages-message-type-btn"]}
|
||||
>
|
||||
<type.icon />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles["messages-message-content"]}>
|
||||
{/* 文本消息 */}
|
||||
{message.type === "text" && (
|
||||
<Input.TextArea
|
||||
value={message.content}
|
||||
onChange={e =>
|
||||
onUpdateMessage(dayIndex, messageIndex, {
|
||||
content: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="请输入消息内容"
|
||||
autoSize={{ minRows: 3, maxRows: 6 }}
|
||||
/>
|
||||
)}
|
||||
{/* 小程序消息 */}
|
||||
{message.type === "miniprogram" && (
|
||||
<>
|
||||
<Input
|
||||
value={message.title}
|
||||
onChange={e =>
|
||||
onUpdateMessage(dayIndex, messageIndex, {
|
||||
title: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="请输入小程序标题"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<Input
|
||||
value={message.description}
|
||||
onChange={e =>
|
||||
onUpdateMessage(dayIndex, messageIndex, {
|
||||
description: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="请输入小程序描述"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<Input
|
||||
value={message.address}
|
||||
onChange={e =>
|
||||
onUpdateMessage(dayIndex, messageIndex, {
|
||||
address: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="请输入小程序路径"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<MainImgUpload
|
||||
value={message.content || ""}
|
||||
onChange={url =>
|
||||
onUpdateMessage(dayIndex, messageIndex, {
|
||||
content: url,
|
||||
})
|
||||
}
|
||||
maxSize={5}
|
||||
showPreview={true}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* 链接消息 */}
|
||||
{message.type === "link" && (
|
||||
<>
|
||||
<Input
|
||||
value={message.title}
|
||||
onChange={e =>
|
||||
onUpdateMessage(dayIndex, messageIndex, {
|
||||
title: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="请输入链接标题"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<Input
|
||||
value={message.description}
|
||||
onChange={e =>
|
||||
onUpdateMessage(dayIndex, messageIndex, {
|
||||
description: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="请输入链接描述"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<Input
|
||||
value={message.linkUrl}
|
||||
onChange={e =>
|
||||
onUpdateMessage(dayIndex, messageIndex, {
|
||||
linkUrl: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="请输入链接地址"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<MainImgUpload
|
||||
value={message.coverImage || ""}
|
||||
onChange={url =>
|
||||
onUpdateMessage(dayIndex, messageIndex, {
|
||||
coverImage: url,
|
||||
})
|
||||
}
|
||||
maxSize={1}
|
||||
showPreview={true}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* 群邀请消息 */}
|
||||
{message.type === "group" && (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<GroupSelection
|
||||
selectedOptions={message.groupOptions || []}
|
||||
onSelect={(groups: GroupSelectionItem[]) => {
|
||||
onUpdateMessage(dayIndex, messageIndex, {
|
||||
groupIds: groups.map(v => v.id),
|
||||
groupOptions: groups,
|
||||
});
|
||||
}}
|
||||
placeholder="选择邀请入的群"
|
||||
showSelectedList={true}
|
||||
selectedListMaxHeight={200}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 图片消息 */}
|
||||
{message.type === "image" && (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<ImageUpload
|
||||
value={message.content ? [message.content] : []}
|
||||
onChange={urls =>
|
||||
onUpdateMessage(dayIndex, messageIndex, {
|
||||
content: urls[0] || "",
|
||||
})
|
||||
}
|
||||
count={1}
|
||||
accept="image/*"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 视频消息 */}
|
||||
{message.type === "video" && (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<VideoUpload
|
||||
value={message.content || ""}
|
||||
onChange={url => {
|
||||
const videoUrl = Array.isArray(url) ? url[0] || "" : url;
|
||||
onUpdateMessage(dayIndex, messageIndex, {
|
||||
content: videoUrl,
|
||||
});
|
||||
}}
|
||||
maxSize={50}
|
||||
maxCount={1}
|
||||
showPreview={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 文件消息 */}
|
||||
{message.type === "file" && (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<FileUpload
|
||||
value={message.content || ""}
|
||||
onChange={url => {
|
||||
const fileUrl = Array.isArray(url) ? url[0] || "" : url;
|
||||
onUpdateMessage(dayIndex, messageIndex, {
|
||||
content: fileUrl,
|
||||
});
|
||||
}}
|
||||
maxSize={10}
|
||||
maxCount={1}
|
||||
showPreview={true}
|
||||
acceptTypes={["excel", "word", "ppt"]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageCard;
|
||||
@@ -1,242 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button, Tabs, Modal, message } from "antd";
|
||||
import { PlusOutlined, CloseOutlined } from "@ant-design/icons";
|
||||
import styles from "./messages.module.scss";
|
||||
import {
|
||||
MessageContentItem,
|
||||
MessageContentGroup,
|
||||
MessageSettingsProps,
|
||||
} from "./base.data";
|
||||
import MessageCard from "./MessageCard";
|
||||
|
||||
const MessageSettings: React.FC<MessageSettingsProps> = ({
|
||||
formData,
|
||||
onChange,
|
||||
}) => {
|
||||
const [isAddDayPlanOpen, setIsAddDayPlanOpen] = useState(false);
|
||||
|
||||
// 获取当前的消息计划,如果没有则使用默认值
|
||||
const getCurrentMessagePlans = (): MessageContentGroup[] => {
|
||||
if (formData.messagePlans && formData.messagePlans.length > 0) {
|
||||
return formData.messagePlans;
|
||||
}
|
||||
return [
|
||||
{
|
||||
day: 0,
|
||||
messages: [
|
||||
{
|
||||
id: "1",
|
||||
type: "text",
|
||||
content: "",
|
||||
sendInterval: 5,
|
||||
intervalUnit: "seconds",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
// 添加新消息
|
||||
const handleAddMessage = (dayIndex: number, type = "text") => {
|
||||
const currentPlans = getCurrentMessagePlans();
|
||||
const updatedPlans = [...currentPlans];
|
||||
const newMessage: MessageContentItem = {
|
||||
id: Date.now().toString(),
|
||||
type: type as MessageContentItem["type"],
|
||||
content: "",
|
||||
};
|
||||
|
||||
if (currentPlans[dayIndex].day === 0) {
|
||||
newMessage.sendInterval = 5;
|
||||
newMessage.intervalUnit = "seconds";
|
||||
} else {
|
||||
newMessage.scheduledTime = {
|
||||
hour: 9,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
};
|
||||
}
|
||||
|
||||
updatedPlans[dayIndex].messages.push(newMessage);
|
||||
onChange({ ...formData, messagePlans: updatedPlans });
|
||||
};
|
||||
|
||||
// 更新消息内容
|
||||
const handleUpdateMessage = (
|
||||
dayIndex: number,
|
||||
messageIndex: number,
|
||||
updates: Partial<MessageContentItem>,
|
||||
) => {
|
||||
const currentPlans = getCurrentMessagePlans();
|
||||
const updatedPlans = [...currentPlans];
|
||||
updatedPlans[dayIndex].messages[messageIndex] = {
|
||||
...updatedPlans[dayIndex].messages[messageIndex],
|
||||
...updates,
|
||||
};
|
||||
onChange({ ...formData, messagePlans: updatedPlans });
|
||||
};
|
||||
|
||||
// 删除消息
|
||||
const handleRemoveMessage = (dayIndex: number, messageIndex: number) => {
|
||||
const currentPlans = getCurrentMessagePlans();
|
||||
const updatedPlans = [...currentPlans];
|
||||
updatedPlans[dayIndex].messages.splice(messageIndex, 1);
|
||||
onChange({ ...formData, messagePlans: updatedPlans });
|
||||
};
|
||||
|
||||
// 切换时间单位
|
||||
const toggleIntervalUnit = (dayIndex: number, messageIndex: number) => {
|
||||
const currentPlans = getCurrentMessagePlans();
|
||||
const message = currentPlans[dayIndex].messages[messageIndex];
|
||||
const newUnit = message.intervalUnit === "minutes" ? "seconds" : "minutes";
|
||||
handleUpdateMessage(dayIndex, messageIndex, { intervalUnit: newUnit });
|
||||
};
|
||||
|
||||
// 添加新的天数计划
|
||||
const handleAddDayPlan = () => {
|
||||
const currentPlans = getCurrentMessagePlans();
|
||||
const newDay = currentPlans.length;
|
||||
const updatedPlans = [
|
||||
...currentPlans,
|
||||
{
|
||||
day: newDay,
|
||||
messages: [
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
type: "text",
|
||||
content: "",
|
||||
scheduledTime: {
|
||||
hour: 9,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
onChange({ ...formData, messagePlans: updatedPlans });
|
||||
setIsAddDayPlanOpen(false);
|
||||
message.success(`已添加第${newDay}天的消息计划`);
|
||||
};
|
||||
|
||||
// 删除天数计划
|
||||
const handleRemoveDayPlan = (dayIndex: number) => {
|
||||
if (dayIndex === 0) {
|
||||
message.warning("不能删除即时消息");
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPlans = getCurrentMessagePlans();
|
||||
Modal.confirm({
|
||||
title: "确认删除",
|
||||
content: `确定要删除第${currentPlans[dayIndex].day}天的消息计划吗?`,
|
||||
onOk: () => {
|
||||
const updatedPlans = currentPlans.filter(
|
||||
(_, index) => index !== dayIndex,
|
||||
);
|
||||
// 重新计算天数
|
||||
const recalculatedPlans = updatedPlans.map((plan, index) => ({
|
||||
...plan,
|
||||
day: index,
|
||||
}));
|
||||
onChange({ ...formData, messagePlans: recalculatedPlans });
|
||||
message.success(`已删除第${currentPlans[dayIndex].day}天的消息计划`);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const items = getCurrentMessagePlans().map(
|
||||
(plan: MessageContentGroup, dayIndex: number) => ({
|
||||
key: plan.day.toString(),
|
||||
label: (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "2px",
|
||||
}}
|
||||
>
|
||||
<span>{plan.day === 0 ? "即时消息" : `第${plan.day}天`}</span>
|
||||
{dayIndex > 0 && (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
handleRemoveDayPlan(dayIndex);
|
||||
}}
|
||||
style={{
|
||||
padding: "0 4px",
|
||||
minWidth: "auto",
|
||||
color: "#ff4d4f",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
title="删除此天计划"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<div className={styles["messages-day-panel"]}>
|
||||
{plan.messages.map((message, messageIndex) => (
|
||||
<MessageCard
|
||||
key={message.id}
|
||||
message={message}
|
||||
dayIndex={dayIndex}
|
||||
messageIndex={messageIndex}
|
||||
planDay={plan.day}
|
||||
onUpdateMessage={handleUpdateMessage}
|
||||
onRemoveMessage={handleRemoveMessage}
|
||||
onToggleIntervalUnit={toggleIntervalUnit}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
onClick={() => handleAddMessage(dayIndex)}
|
||||
className={styles["messages-add-message-btn"]}
|
||||
>
|
||||
<PlusOutlined className="w-4 h-4 mr-2" />
|
||||
添加消息
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles["messages-container"]}>
|
||||
<div className={styles["messages-header"]}>
|
||||
<h2 className={styles["messages-title"]}>消息设置</h2>
|
||||
<Button onClick={() => setIsAddDayPlanOpen(true)}>
|
||||
<PlusOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
<Tabs
|
||||
defaultActiveKey="0"
|
||||
items={items}
|
||||
className={styles["messages-tab"]}
|
||||
/>
|
||||
|
||||
{/* 添加天数计划弹窗 */}
|
||||
<Modal
|
||||
title="添加消息计划"
|
||||
open={isAddDayPlanOpen}
|
||||
onCancel={() => setIsAddDayPlanOpen(false)}
|
||||
onOk={() => {
|
||||
handleAddDayPlan();
|
||||
setIsAddDayPlanOpen(false);
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-gray-500 mb-4">选择要添加的消息计划类型</p>
|
||||
<Button
|
||||
onClick={handleAddDayPlan}
|
||||
className={styles["messages-modal-btn"]}
|
||||
>
|
||||
添加第 {getCurrentMessagePlans().length} 天计划
|
||||
</Button>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageSettings;
|
||||
@@ -1,84 +0,0 @@
|
||||
export const posterTemplates = [
|
||||
{
|
||||
id: "poster-1",
|
||||
name: "点击领取",
|
||||
url: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E9%A2%86%E5%8F%961-tipd1HI7da6qooY5NkhxQnXBnT5LGU.gif",
|
||||
},
|
||||
{
|
||||
id: "poster-2",
|
||||
name: "点击合作",
|
||||
url: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E5%90%88%E4%BD%9C-LPlMdgxtvhqCSr4IM1bZFEFDBF3ztI.gif",
|
||||
},
|
||||
{
|
||||
id: "poster-3",
|
||||
name: "点击咨询",
|
||||
url: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E5%92%A8%E8%AF%A2-FTiyAMAPop2g9LvjLOLDz0VwPg3KVu.gif",
|
||||
},
|
||||
{
|
||||
id: "poster-4",
|
||||
name: "点击签到",
|
||||
url: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E7%AD%BE%E5%88%B0-94TZIkjLldb4P2jTVlI6MkSDg0NbXi.gif",
|
||||
},
|
||||
{
|
||||
id: "poster-5",
|
||||
name: "点击了解",
|
||||
url: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E4%BA%86%E8%A7%A3-6GCl7mQVdO4WIiykJyweSubLsTwj71.gif",
|
||||
},
|
||||
{
|
||||
id: "poster-6",
|
||||
name: "点击报名",
|
||||
url: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E6%8A%A5%E5%90%8D-Mj0nnva0BiASeDAIhNNaRRAbjPgjEj.gif",
|
||||
},
|
||||
];
|
||||
// ========================================
|
||||
import {
|
||||
MessageOutlined,
|
||||
PictureOutlined,
|
||||
VideoCameraOutlined,
|
||||
FileOutlined,
|
||||
AppstoreOutlined,
|
||||
LinkOutlined,
|
||||
TeamOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
export interface MessageContentItem {
|
||||
id: string;
|
||||
type: "text" | "image" | "video" | "file" | "miniprogram" | "link" | "group";
|
||||
content: string;
|
||||
sendInterval?: number;
|
||||
intervalUnit?: "seconds" | "minutes";
|
||||
scheduledTime?: {
|
||||
hour: number;
|
||||
minute: number;
|
||||
second: number;
|
||||
};
|
||||
title?: string;
|
||||
description?: string;
|
||||
address?: string;
|
||||
groupIds?: string[]; // 改为数组以支持GroupSelection组件
|
||||
groupOptions?: any[]; // 添加群选项数组
|
||||
linkUrl?: string;
|
||||
coverImage?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface MessageContentGroup {
|
||||
day: number;
|
||||
messages: MessageContentItem[];
|
||||
}
|
||||
|
||||
export interface MessageSettingsProps {
|
||||
formData: any;
|
||||
onChange: (data: any) => void;
|
||||
}
|
||||
|
||||
// 消息类型配置
|
||||
export const messageTypes = [
|
||||
{ id: "text", icon: MessageOutlined, label: "文本" },
|
||||
{ id: "image", icon: PictureOutlined, label: "图片" },
|
||||
{ id: "video", icon: VideoCameraOutlined, label: "视频" },
|
||||
{ id: "file", icon: FileOutlined, label: "文件" },
|
||||
{ id: "miniprogram", icon: AppstoreOutlined, label: "小程序" },
|
||||
{ id: "link", icon: LinkOutlined, label: "链接" },
|
||||
{ id: "group", icon: TeamOutlined, label: "邀请入群" },
|
||||
];
|
||||
@@ -1,163 +0,0 @@
|
||||
.basic-container {
|
||||
padding: 12px;
|
||||
}
|
||||
.basic-scene-select {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.basic-scene-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
.basic-scene-btn {
|
||||
height: 40px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
background: rgba(#1677ff, 0.1);
|
||||
color: #1677ff;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.basic-scene-btn.selected {
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(22, 119, 255, 0.08);
|
||||
}
|
||||
.basic-label {
|
||||
margin-bottom: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.basic-input-block {
|
||||
border: 1px solid #eee;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.basic-tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
.basic-tag-item {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.basic-custom-tag-input {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.basic-success-tip {
|
||||
display: flex;
|
||||
}
|
||||
.basic-materials {
|
||||
margin: 16px 0;
|
||||
}
|
||||
.basic-materials-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.basic-material-preview {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
padding-left: 2px;
|
||||
right: 8px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 50%;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
cursor: pointer;
|
||||
}
|
||||
.basic-material-card {
|
||||
border: 2px solid #eee;
|
||||
border-radius: 8px;
|
||||
padding: 6px;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
min-height: 192px;
|
||||
transition: border 0.2s;
|
||||
}
|
||||
.basic-material-card.selected {
|
||||
border: 2px solid #1890ff;
|
||||
background: #e6f7ff;
|
||||
}
|
||||
.basic-material-img {
|
||||
width: 100px;
|
||||
height: 180px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0;
|
||||
display: block;
|
||||
}
|
||||
.basic-material-name {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
padding: 4px 0;
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
text-align: center;
|
||||
z-index: 3;
|
||||
}
|
||||
.basic-add-material {
|
||||
border: 2px dashed #bbb;
|
||||
border-radius: 8px;
|
||||
padding: 6px;
|
||||
cursor: pointer;
|
||||
background: #fafbfc;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 190px;
|
||||
}
|
||||
.basic-order-upload {
|
||||
margin: 16px 0;
|
||||
}
|
||||
.basic-order-upload-label {
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.basic-order-upload-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.basic-order-upload-tip {
|
||||
color: #888;
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.basic-phone-settings {
|
||||
margin: 16px 0;
|
||||
background: #f7f8fa;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.basic-wechat-group {
|
||||
margin: 16px 0;
|
||||
}
|
||||
.basic-footer-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 16px 0;
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
.friend-container {
|
||||
padding: 12px;
|
||||
}
|
||||
.friend-label {
|
||||
margin-bottom: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.friend-block {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.friend-remark-tip {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
z-index: 10;
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
width: 240px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: 20px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 6px solid transparent;
|
||||
border-right: 6px solid transparent;
|
||||
border-bottom: 6px solid #fff;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
right: 20px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 6px solid transparent;
|
||||
border-right: 6px solid transparent;
|
||||
border-bottom: 6px solid #e8e8e8;
|
||||
}
|
||||
}
|
||||
.friend-remark-q {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
cursor: pointer;
|
||||
color: #888;
|
||||
background: #fff;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
z-index: 2;
|
||||
border: 1px solid #d9d9d9;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: #1677ff;
|
||||
border-color: #1677ff;
|
||||
background: #f0f8ff;
|
||||
}
|
||||
}
|
||||
.friend-remark-container {
|
||||
position: relative;
|
||||
}
|
||||
.friend-interval-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.friend-time-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.friend-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 32px;
|
||||
}
|
||||
.friend-modal-btn {
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
.messages-container {
|
||||
padding: 16px;
|
||||
}
|
||||
.messages-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.messages-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.messages-tab {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.messages-day-panel {
|
||||
border-radius: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.messages-message-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow:
|
||||
0 4px 16px rgba(22, 119, 255, 0.06),
|
||||
0 1.5px 4px rgba(0, 0, 0, 0.04);
|
||||
padding: 20px 12px 16px 12px;
|
||||
margin-bottom: 20px;
|
||||
border: 1.5px solid #f0f3fa;
|
||||
transition:
|
||||
box-shadow 0.2s,
|
||||
border 0.2s,
|
||||
transform 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
.messages-message-card:hover {
|
||||
box-shadow:
|
||||
0 8px 24px rgba(22, 119, 255, 0.12),
|
||||
0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
border: 1.5px solid #1677ff;
|
||||
transform: translateY(-2px) scale(1.01);
|
||||
}
|
||||
.messages-message-header {
|
||||
}
|
||||
.messages-message-header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.messages-message-type-btns {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.messages-message-type-btn {
|
||||
width: 20px;
|
||||
}
|
||||
.messages-message-content {
|
||||
margin-bottom: 10px;
|
||||
font-size: 15px;
|
||||
color: #222;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.messages-message-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.messages-message-remove-btn {
|
||||
color: #ff4d4f;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
padding: 0 8px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.messages-message-remove-btn:hover {
|
||||
color: #d9363e;
|
||||
}
|
||||
.messages-add-message-btn {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.messages-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 32px;
|
||||
}
|
||||
.messages-modal-btn {
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.messages-group-select-item {
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid #eee;
|
||||
transition:
|
||||
border 0.2s,
|
||||
background 0.2s;
|
||||
}
|
||||
.messages-group-select-item.selected {
|
||||
background: #e6f7ff;
|
||||
border: 1.5px solid #1677ff;
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
// ==================== 场景相关接口 ====================
|
||||
|
||||
// 获取场景列表
|
||||
export function getScenarios(params: any) {
|
||||
return request("/v1/plan/scenes", params, "GET");
|
||||
}
|
||||
|
||||
// 获取场景详情
|
||||
export function getScenarioDetail(id: string) {
|
||||
return request(`/v1/scenarios/${id}`, {}, "GET");
|
||||
}
|
||||
|
||||
// 创建场景
|
||||
export function createScenario(data: any) {
|
||||
return request("/v1/scenarios", data, "POST");
|
||||
}
|
||||
|
||||
// 更新场景
|
||||
export function updateScenario(id: string, data: any) {
|
||||
return request(`/v1/scenarios/${id}`, data, "PUT");
|
||||
}
|
||||
|
||||
// 删除场景
|
||||
export function deleteScenario(id: string) {
|
||||
return request(`/v1/scenarios/${id}`, {}, "DELETE");
|
||||
}
|
||||
|
||||
// ==================== 计划相关接口 ====================
|
||||
|
||||
// 获取计划列表
|
||||
export function getPlanList(
|
||||
scenarioId: string,
|
||||
page: number = 1,
|
||||
limit: number = 20,
|
||||
) {
|
||||
return request(`/api/scenarios/${scenarioId}/plans`, { page, limit }, "GET");
|
||||
}
|
||||
// 复制计划
|
||||
export function copyPlan(planId: string) {
|
||||
return request(`/api/scenarios/plans/${planId}/copy`, undefined, "POST");
|
||||
}
|
||||
|
||||
// 删除计划
|
||||
export function deletePlan(planId: string) {
|
||||
return request(`/api/scenarios/plans/${planId}`, undefined, "DELETE");
|
||||
}
|
||||
|
||||
// 获取小程序二维码
|
||||
export function getWxMinAppCode(planId: string) {
|
||||
return request(`/api/scenarios/plans/${planId}/qrcode`, undefined, "GET");
|
||||
}
|
||||
|
||||
// ==================== 设备相关接口 ====================
|
||||
|
||||
// 获取设备列表
|
||||
export function getDevices() {
|
||||
return request("/api/devices", undefined, "GET");
|
||||
}
|
||||
|
||||
// 获取设备详情
|
||||
export function getDeviceDetail(deviceId: string) {
|
||||
return request(`/api/devices/${deviceId}`, undefined, "GET");
|
||||
}
|
||||
|
||||
// 创建设备
|
||||
export function createDevice(data: any) {
|
||||
return request("/api/devices", data, "POST");
|
||||
}
|
||||
|
||||
// 更新设备
|
||||
export function updateDevice(deviceId: string, data: any) {
|
||||
return request(`/api/devices/${deviceId}`, data, "PUT");
|
||||
}
|
||||
|
||||
// 删除设备
|
||||
export function deleteDevice(deviceId: string) {
|
||||
return request(`/api/devices/${deviceId}`, undefined, "DELETE");
|
||||
}
|
||||
|
||||
// ==================== 微信号相关接口 ====================
|
||||
|
||||
// 获取微信号列表
|
||||
export function getWechatAccounts() {
|
||||
return request("/api/wechat-accounts", undefined, "GET");
|
||||
}
|
||||
|
||||
// 获取微信号详情
|
||||
export function getWechatAccountDetail(accountId: string) {
|
||||
return request(`/api/wechat-accounts/${accountId}`, undefined, "GET");
|
||||
}
|
||||
|
||||
// 创建微信号
|
||||
export function createWechatAccount(data: any) {
|
||||
return request("/api/wechat-accounts", data, "POST");
|
||||
}
|
||||
|
||||
// 更新微信号
|
||||
export function updateWechatAccount(accountId: string, data: any) {
|
||||
return request(`/api/wechat-accounts/${accountId}`, data, "PUT");
|
||||
}
|
||||
|
||||
// 删除微信号
|
||||
export function deleteWechatAccount(accountId: string) {
|
||||
return request(`/api/wechat-accounts/${accountId}`, undefined, "DELETE");
|
||||
}
|
||||
|
||||
// ==================== 海报相关接口 ====================
|
||||
|
||||
// 获取海报列表
|
||||
export function getPosters() {
|
||||
return request("/api/posters", undefined, "GET");
|
||||
}
|
||||
|
||||
// 获取海报详情
|
||||
export function getPosterDetail(posterId: string) {
|
||||
return request(`/api/posters/${posterId}`, undefined, "GET");
|
||||
}
|
||||
|
||||
// 创建海报
|
||||
export function createPoster(data: any) {
|
||||
return request("/api/posters", data, "POST");
|
||||
}
|
||||
|
||||
// 更新海报
|
||||
export function updatePoster(posterId: string, data: any) {
|
||||
return request(`/api/posters/${posterId}`, data, "PUT");
|
||||
}
|
||||
|
||||
// 删除海报
|
||||
export function deletePoster(posterId: string) {
|
||||
return request(`/api/posters/${posterId}`, undefined, "DELETE");
|
||||
}
|
||||
|
||||
// ==================== 内容相关接口 ====================
|
||||
|
||||
// 获取内容列表
|
||||
export function getContents(params: any) {
|
||||
return request("/api/contents", params, "GET");
|
||||
}
|
||||
|
||||
// 获取内容详情
|
||||
export function getContentDetail(contentId: string) {
|
||||
return request(`/api/contents/${contentId}`, undefined, "GET");
|
||||
}
|
||||
|
||||
// 创建内容
|
||||
export function createContent(data: any) {
|
||||
return request("/api/contents", data, "POST");
|
||||
}
|
||||
|
||||
// 更新内容
|
||||
export function updateContent(contentId: string, data: any) {
|
||||
return request(`/api/contents/${contentId}`, data, "PUT");
|
||||
}
|
||||
|
||||
// 删除内容
|
||||
export function deleteContent(contentId: string) {
|
||||
return request(`/api/contents/${contentId}`, undefined, "DELETE");
|
||||
}
|
||||
|
||||
// ==================== 流量池相关接口 ====================
|
||||
|
||||
// 获取流量池列表
|
||||
export function getTrafficPools() {
|
||||
return request("/api/traffic-pools", undefined, "GET");
|
||||
}
|
||||
|
||||
// 获取流量池详情
|
||||
export function getTrafficPoolDetail(poolId: string) {
|
||||
return request(`/api/traffic-pools/${poolId}`, undefined, "GET");
|
||||
}
|
||||
|
||||
// 创建流量池
|
||||
export function createTrafficPool(data: any) {
|
||||
return request("/api/traffic-pools", data, "POST");
|
||||
}
|
||||
|
||||
// 更新流量池
|
||||
export function updateTrafficPool(poolId: string, data: any) {
|
||||
return request(`/api/traffic-pools/${poolId}`, data, "PUT");
|
||||
}
|
||||
|
||||
// 删除流量池
|
||||
export function deleteTrafficPool(poolId: string) {
|
||||
return request(`/api/traffic-pools/${poolId}`, undefined, "DELETE");
|
||||
}
|
||||
|
||||
// ==================== 工作台相关接口 ====================
|
||||
|
||||
// 获取工作台统计数据
|
||||
export function getWorkspaceStats() {
|
||||
return request("/api/workspace/stats", undefined, "GET");
|
||||
}
|
||||
|
||||
// 获取自动点赞任务列表
|
||||
export function getAutoLikeTasks() {
|
||||
return request("/api/workspace/auto-like/tasks", undefined, "GET");
|
||||
}
|
||||
|
||||
// 创建自动点赞任务
|
||||
export function createAutoLikeTask(data: any) {
|
||||
return request("/api/workspace/auto-like/tasks", data, "POST");
|
||||
}
|
||||
|
||||
// 更新自动点赞任务
|
||||
export function updateAutoLikeTask(taskId: string, data: any) {
|
||||
return request(`/api/workspace/auto-like/tasks/${taskId}`, data, "PUT");
|
||||
}
|
||||
|
||||
// 删除自动点赞任务
|
||||
export function deleteAutoLikeTask(taskId: string) {
|
||||
return request(
|
||||
`/api/workspace/auto-like/tasks/${taskId}`,
|
||||
undefined,
|
||||
"DELETE",
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 群发相关接口 ====================
|
||||
|
||||
// 获取群发任务列表
|
||||
export function getGroupPushTasks() {
|
||||
return request("/api/workspace/group-push/tasks", undefined, "GET");
|
||||
}
|
||||
|
||||
// 创建群发任务
|
||||
export function createGroupPushTask(data: any) {
|
||||
return request("/api/workspace/group-push/tasks", data, "POST");
|
||||
}
|
||||
|
||||
// 更新群发任务
|
||||
export function updateGroupPushTask(taskId: string, data: any) {
|
||||
return request(`/api/workspace/group-push/tasks/${taskId}`, data, "PUT");
|
||||
}
|
||||
|
||||
// 删除群发任务
|
||||
export function deleteGroupPushTask(taskId: string) {
|
||||
return request(
|
||||
`/api/workspace/group-push/tasks/${taskId}`,
|
||||
undefined,
|
||||
"DELETE",
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 自动建群相关接口 ====================
|
||||
|
||||
// 获取自动建群任务列表
|
||||
export function getAutoGroupTasks() {
|
||||
return request("/api/workspace/auto-group/tasks", undefined, "GET");
|
||||
}
|
||||
|
||||
// 创建自动建群任务
|
||||
export function createAutoGroupTask(data: any) {
|
||||
return request("/api/workspace/auto-group/tasks", data, "POST");
|
||||
}
|
||||
|
||||
// 更新自动建群任务
|
||||
export function updateAutoGroupTask(taskId: string, data: any) {
|
||||
return request(`/api/workspace/auto-group/tasks/${taskId}`, data, "PUT");
|
||||
}
|
||||
|
||||
// 删除自动建群任务
|
||||
export function deleteAutoGroupTask(taskId: string) {
|
||||
return request(
|
||||
`/api/workspace/auto-group/tasks/${taskId}`,
|
||||
undefined,
|
||||
"DELETE",
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== AI助手相关接口 ====================
|
||||
|
||||
// 获取AI对话历史
|
||||
export function getAIChatHistory() {
|
||||
return request("/api/workspace/ai-assistant/chat-history", undefined, "GET");
|
||||
}
|
||||
|
||||
// 发送AI消息
|
||||
export function sendAIMessage(data: any) {
|
||||
return request("/api/workspace/ai-assistant/send-message", data, "POST");
|
||||
}
|
||||
|
||||
// 获取AI分析报告
|
||||
export function getAIAnalysisReport() {
|
||||
return request(
|
||||
"/api/workspace/ai-assistant/analysis-report",
|
||||
undefined,
|
||||
"GET",
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 订单相关接口 ====================
|
||||
|
||||
// 获取订单列表
|
||||
export function getOrders(params: any) {
|
||||
return request("/api/orders", params, "GET");
|
||||
}
|
||||
|
||||
// 获取订单详情
|
||||
export function getOrderDetail(orderId: string) {
|
||||
return request(`/api/orders/${orderId}`, undefined, "GET");
|
||||
}
|
||||
|
||||
// 创建订单
|
||||
export function createOrder(data: any) {
|
||||
return request("/api/orders", data, "POST");
|
||||
}
|
||||
|
||||
// 更新订单
|
||||
export function updateOrder(orderId: string, data: any) {
|
||||
return request(`/api/orders/${orderId}`, data, "PUT");
|
||||
}
|
||||
|
||||
// 删除订单
|
||||
export function deleteOrder(orderId: string) {
|
||||
return request(`/api/orders/${orderId}`, undefined, "DELETE");
|
||||
}
|
||||
|
||||
// ==================== 用户相关接口 ====================
|
||||
|
||||
// 获取用户信息
|
||||
export function getUserInfo() {
|
||||
return request("/api/user/info", undefined, "GET");
|
||||
}
|
||||
|
||||
// 更新用户信息
|
||||
export function updateUserInfo(data: any) {
|
||||
return request("/api/user/info", data, "PUT");
|
||||
}
|
||||
|
||||
// 修改密码
|
||||
export function changePassword(data: any) {
|
||||
return request("/api/user/change-password", data, "POST");
|
||||
}
|
||||
|
||||
// 上传头像
|
||||
export function uploadAvatar(data: any) {
|
||||
return request("/api/user/upload-avatar", data, "POST");
|
||||
}
|
||||
|
||||
// ==================== 文件上传相关接口 ====================
|
||||
|
||||
// 上传文件
|
||||
export function uploadFile(data: any) {
|
||||
return request("/api/upload/file", data, "POST");
|
||||
}
|
||||
|
||||
// 上传图片
|
||||
export function uploadImage(data: any) {
|
||||
return request("/api/upload/image", data, "POST");
|
||||
}
|
||||
|
||||
// 删除文件
|
||||
export function deleteFile(fileId: string) {
|
||||
return request(`/api/upload/files/${fileId}`, undefined, "DELETE");
|
||||
}
|
||||
|
||||
// ==================== 系统配置相关接口 ====================
|
||||
|
||||
// 获取系统配置
|
||||
export function getSystemConfig() {
|
||||
return request("/api/system/config", undefined, "GET");
|
||||
}
|
||||
|
||||
// 更新系统配置
|
||||
export function updateSystemConfig(data: any) {
|
||||
return request("/api/system/config", data, "PUT");
|
||||
}
|
||||
|
||||
// 获取系统通知
|
||||
export function getSystemNotifications() {
|
||||
return request("/api/system/notifications", undefined, "GET");
|
||||
}
|
||||
|
||||
// 标记通知为已读
|
||||
export function markNotificationAsRead(notificationId: string) {
|
||||
return request(
|
||||
`/api/system/notifications/${notificationId}/read`,
|
||||
undefined,
|
||||
"PUT",
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
.analyzerPage {
|
||||
}
|
||||
|
||||
.tabs {
|
||||
background: #fff;
|
||||
padding: 0 12px;
|
||||
border-radius: 0 0 12px 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.planList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 0 12px 16px 12px;
|
||||
}
|
||||
|
||||
.planCard {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
padding: 16px 14px 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.statusDone {
|
||||
background: #e6f9e6;
|
||||
color: #22c55e;
|
||||
font-size: 12px;
|
||||
border-radius: 8px;
|
||||
padding: 2px 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.statusDoing {
|
||||
background: #e0f2fe;
|
||||
color: #1677ff;
|
||||
font-size: 12px;
|
||||
border-radius: 8px;
|
||||
padding: 2px 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cardInfo {
|
||||
font-size: 13px;
|
||||
color: #444;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.keyword {
|
||||
display: inline-block;
|
||||
background: #f3f4f6;
|
||||
color: #1677ff;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
margin-right: 6px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.cardActions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.actionBtn {
|
||||
border-radius: 6px !important;
|
||||
font-size: 13px !important;
|
||||
padding: 0 12px !important;
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import { Tabs } from "antd-mobile";
|
||||
import { Button } from "antd";
|
||||
import styles from "./index.module.scss";
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
|
||||
const mockPlans = [
|
||||
{
|
||||
id: "1",
|
||||
title: "美妆用户分析",
|
||||
status: "done",
|
||||
device: "设备1",
|
||||
wechat: "wxid_abc123",
|
||||
type: "综合分析",
|
||||
keywords: ["美妆", "护肤", "彩妆"],
|
||||
createTime: "2023/12/15 18:30:00",
|
||||
finishTime: "2023/12/15 19:45:00",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "健身爱好者分析",
|
||||
status: "doing",
|
||||
device: "设备2",
|
||||
wechat: "wxid_fit456",
|
||||
type: "好友信息分析",
|
||||
keywords: ["健身", "运动", "健康"],
|
||||
createTime: "2023/12/16 17:15:00",
|
||||
finishTime: "",
|
||||
},
|
||||
];
|
||||
|
||||
const statusMap = {
|
||||
all: "全部计划",
|
||||
doing: "进行中",
|
||||
done: "已完成",
|
||||
};
|
||||
|
||||
const statusTag = {
|
||||
done: <span className={styles.statusDone}>已完成</span>,
|
||||
doing: <span className={styles.statusDoing}>分析中</span>,
|
||||
};
|
||||
|
||||
const AiAnalyzer: React.FC = () => {
|
||||
const [tab, setTab] = useState<"all" | "doing" | "done">("all");
|
||||
|
||||
const filteredPlans =
|
||||
tab === "all" ? mockPlans : mockPlans.filter(p => p.status === tab);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<NavCommon
|
||||
title="AI数据分析"
|
||||
right={
|
||||
<Button type="primary" size="small" style={{ borderRadius: 6 }}>
|
||||
<PlusOutlined /> 新建计划
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className={styles.analyzerPage}>
|
||||
<Tabs
|
||||
activeKey={tab}
|
||||
onChange={key => setTab(key as any)}
|
||||
className={styles.tabs}
|
||||
>
|
||||
<Tabs.Tab title="全部计划" key="all" />
|
||||
<Tabs.Tab title="进行中" key="doing" />
|
||||
<Tabs.Tab title="已完成" key="done" />
|
||||
</Tabs>
|
||||
<div className={styles.planList}>
|
||||
{filteredPlans.map(plan => (
|
||||
<div className={styles.planCard} key={plan.id}>
|
||||
<div className={styles.cardHeader}>
|
||||
<span className={styles.cardTitle}>{plan.title}</span>
|
||||
{statusTag[plan.status as "done" | "doing"]}
|
||||
</div>
|
||||
<div className={styles.cardInfo}>
|
||||
<div>
|
||||
<span className={styles.label}>设备:</span>
|
||||
{plan.device} | 微信号: {plan.wechat}
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.label}>分析类型:</span>
|
||||
{plan.type}
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.label}>关键词:</span>
|
||||
{plan.keywords.map(k => (
|
||||
<span className={styles.keyword} key={k}>
|
||||
{k}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.label}>创建时间:</span>
|
||||
{plan.createTime}
|
||||
</div>
|
||||
{plan.status === "done" && (
|
||||
<div>
|
||||
<span className={styles.label}>完成时间:</span>
|
||||
{plan.finishTime}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.cardActions}>
|
||||
{plan.status === "done" ? (
|
||||
<>
|
||||
<Button size="small" className={styles.actionBtn}>
|
||||
发送报告
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className={styles.actionBtn}
|
||||
>
|
||||
查看报告
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className={styles.actionBtn}
|
||||
>
|
||||
查看进度
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiAnalyzer;
|
||||
@@ -1,145 +0,0 @@
|
||||
.chatContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.messageList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 12px 80px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.userMessage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.aiMessage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
max-width: 80%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 18px;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
background: #fff;
|
||||
color: #222;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.userMessage .bubble {
|
||||
background: linear-gradient(135deg, #a7e0ff 0%, #5bbcff 100%);
|
||||
color: #222;
|
||||
border-bottom-right-radius: 6px;
|
||||
}
|
||||
|
||||
.aiMessage .bubble {
|
||||
background: #fff;
|
||||
color: #222;
|
||||
border-bottom-left-radius: 6px;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
margin: 4px 8px 0 8px;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.inputBar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
padding: 10px 12px 10px 12px;
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.04);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: #f3f4f6;
|
||||
border-radius: 18px;
|
||||
padding: 10px 14px;
|
||||
font-size: 15px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.sendButton {
|
||||
background: var(
|
||||
--primary-gradient,
|
||||
linear-gradient(135deg, #a7e0ff 0%, #5bbcff 100%)
|
||||
);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 18px;
|
||||
padding: 8px 18px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.sendButton:disabled {
|
||||
background: #e5e7eb;
|
||||
color: #aaa;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.iconBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
margin-right: 6px;
|
||||
font-size: 20px;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 50%;
|
||||
transition:
|
||||
background 0.2s,
|
||||
color 0.2s;
|
||||
}
|
||||
|
||||
.iconBtn:hover,
|
||||
.iconBtn:active {
|
||||
background: #f3f4f6;
|
||||
color: #5bbcff;
|
||||
}
|
||||
|
||||
.image {
|
||||
max-width: 180px;
|
||||
max-height: 180px;
|
||||
border-radius: 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.fileLink {
|
||||
color: #5bbcff;
|
||||
text-decoration: none;
|
||||
font-size: 15px;
|
||||
word-break: break-all;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
color: var(--primary-color);
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
text-shadow: 0 2px 4px rgba(24, 142, 238, 0.2);
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
import React, { useRef, useState, useEffect } from "react";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import {
|
||||
PictureOutlined,
|
||||
PaperClipOutlined,
|
||||
AudioOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import styles from "./AIAssistant.module.scss";
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
content: string;
|
||||
from: "user" | "ai";
|
||||
time: string;
|
||||
type?: "text" | "image" | "file" | "audio";
|
||||
fileName?: string;
|
||||
fileUrl?: string;
|
||||
}
|
||||
|
||||
const initialMessages: Message[] = [
|
||||
{
|
||||
id: "1",
|
||||
content: "你好!我是你的AI助手,有什么可以帮助你的吗?",
|
||||
from: "ai",
|
||||
time: "15:29",
|
||||
type: "text",
|
||||
},
|
||||
];
|
||||
|
||||
const AIAssistant: React.FC = () => {
|
||||
const [messages, setMessages] = useState<Message[]>(initialMessages);
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const [recognizing, setRecognizing] = useState(false);
|
||||
const recognitionRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
// 语音识别初始化
|
||||
useEffect(() => {
|
||||
if (!("webkitSpeechRecognition" in window)) return;
|
||||
const SpeechRecognition = (window as any).webkitSpeechRecognition;
|
||||
recognitionRef.current = new SpeechRecognition();
|
||||
recognitionRef.current.continuous = false;
|
||||
recognitionRef.current.interimResults = false;
|
||||
recognitionRef.current.lang = "zh-CN";
|
||||
recognitionRef.current.onresult = (event: any) => {
|
||||
const transcript = event.results[0][0].transcript;
|
||||
setInput(prev => prev + transcript);
|
||||
setRecognizing(false);
|
||||
};
|
||||
recognitionRef.current.onerror = () => setRecognizing(false);
|
||||
recognitionRef.current.onend = () => setRecognizing(false);
|
||||
}, []);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim()) return;
|
||||
const userMsg: Message = {
|
||||
id: Date.now().toString(),
|
||||
content: input,
|
||||
from: "user",
|
||||
time: new Date().toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
type: "text",
|
||||
};
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
setInput("");
|
||||
setLoading(true);
|
||||
setTimeout(() => {
|
||||
setMessages(prev => [
|
||||
...prev,
|
||||
{
|
||||
id: Date.now().toString() + "-ai",
|
||||
content: "AI正在思考...(此处可接入真实API)",
|
||||
from: "ai",
|
||||
time: new Date().toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
type: "text",
|
||||
},
|
||||
]);
|
||||
setLoading(false);
|
||||
}, 1200);
|
||||
};
|
||||
|
||||
// 图片上传
|
||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const url = URL.createObjectURL(file);
|
||||
setMessages(prev => [
|
||||
...prev,
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
content: url,
|
||||
from: "user",
|
||||
time: new Date().toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
type: "image",
|
||||
fileName: file.name,
|
||||
fileUrl: url,
|
||||
},
|
||||
]);
|
||||
}
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
// 文件上传
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const url = URL.createObjectURL(file);
|
||||
setMessages(prev => [
|
||||
...prev,
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
content: file.name,
|
||||
from: "user",
|
||||
time: new Date().toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
type: "file",
|
||||
fileName: file.name,
|
||||
fileUrl: url,
|
||||
},
|
||||
]);
|
||||
}
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
// 语音输入
|
||||
const handleVoiceInput = () => {
|
||||
if (!recognitionRef.current) return alert("当前浏览器不支持语音输入");
|
||||
if (recognizing) {
|
||||
recognitionRef.current.stop();
|
||||
setRecognizing(false);
|
||||
} else {
|
||||
recognitionRef.current.start();
|
||||
setRecognizing(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout header={<NavCommon title="AI助手" />} loading={false}>
|
||||
<div className={styles.chatContainer}>
|
||||
<div className={styles.messageList}>
|
||||
{messages.map(msg => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={
|
||||
msg.from === "user" ? styles.userMessage : styles.aiMessage
|
||||
}
|
||||
>
|
||||
{msg.type === "text" && (
|
||||
<div className={styles.bubble}>{msg.content}</div>
|
||||
)}
|
||||
{msg.type === "image" && (
|
||||
<div className={styles.bubble}>
|
||||
<img
|
||||
src={msg.fileUrl}
|
||||
alt={msg.fileName}
|
||||
className={styles.image}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{msg.type === "file" && (
|
||||
<div className={styles.bubble}>
|
||||
<a
|
||||
href={msg.fileUrl}
|
||||
download={msg.fileName}
|
||||
className={styles.fileLink}
|
||||
>
|
||||
<PaperClipOutlined style={{ marginRight: 6 }} />
|
||||
{msg.fileName}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{/* 语音消息可后续扩展 */}
|
||||
<div className={styles.time}>{msg.time}</div>
|
||||
</div>
|
||||
))}
|
||||
{loading && (
|
||||
<div className={styles.aiMessage}>
|
||||
<div className={styles.bubble}>AI正在输入...</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<div className={styles.inputBar}>
|
||||
<button
|
||||
className={styles.iconBtn}
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
title="图片"
|
||||
type="button"
|
||||
>
|
||||
<PictureOutlined />
|
||||
</button>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleImageChange}
|
||||
/>
|
||||
<button
|
||||
className={styles.iconBtn}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title="文件"
|
||||
type="button"
|
||||
>
|
||||
<PaperClipOutlined />
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<button
|
||||
className={styles.iconBtn}
|
||||
onClick={handleVoiceInput}
|
||||
title="语音输入"
|
||||
type="button"
|
||||
style={{ color: recognizing ? "#5bbcff" : undefined }}
|
||||
>
|
||||
<AudioOutlined />
|
||||
</button>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="text"
|
||||
placeholder="输入消息..."
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Enter") handleSend();
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
className={styles.sendButton}
|
||||
onClick={handleSend}
|
||||
disabled={loading || !input.trim()}
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIAssistant;
|
||||
@@ -1,6 +0,0 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
// 获取自动建群任务详情
|
||||
export function getAutoGroupDetail(id: string) {
|
||||
return request(`/api/auto-group/detail/${id}`);
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
.autoGroupDetail {
|
||||
padding: 16px 0 80px 0;
|
||||
background: #f7f8fa;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.headerBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 48px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.infoCard {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
|
||||
border: none;
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
}
|
||||
.infoGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
.infoTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1677ff;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.infoItem {
|
||||
font-size: 13px;
|
||||
color: #444;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.progressSection {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.progressCard {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
|
||||
border: none;
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.progressHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.groupList {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.groupCard {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
|
||||
border: none;
|
||||
background: #fff;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.groupHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.memberGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.memberItem {
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
padding: 4px 8px;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.warnText {
|
||||
color: #faad14;
|
||||
font-size: 13px;
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.successText {
|
||||
color: #389e0d;
|
||||
font-size: 13px;
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.successAlert {
|
||||
color: #389e0d;
|
||||
background: #f6ffed;
|
||||
border-radius: 8px;
|
||||
padding: 8px 0;
|
||||
text-align: center;
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.emptyCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 0;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
|
||||
margin-top: 32px;
|
||||
}
|
||||
.emptyTitle {
|
||||
font-size: 16px;
|
||||
color: #888;
|
||||
margin: 12px 0 4px 0;
|
||||
}
|
||||
.emptyDesc {
|
||||
font-size: 13px;
|
||||
color: #bbb;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { Card, Button, Toast, ProgressBar, Tag } from "antd-mobile";
|
||||
import { TeamOutline, LeftOutline } from "antd-mobile-icons";
|
||||
import { AlertOutlined } from "@ant-design/icons";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import NavCommon from "@/components/NavCommon/index";
|
||||
import style from "./index.module.scss";
|
||||
|
||||
interface GroupMember {
|
||||
id: string;
|
||||
nickname: string;
|
||||
wechatId: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
interface Group {
|
||||
id: string;
|
||||
members: GroupMember[];
|
||||
}
|
||||
|
||||
interface GroupTaskDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "preparing" | "creating" | "completed" | "paused";
|
||||
totalGroups: number;
|
||||
currentGroupIndex: number;
|
||||
groups: Group[];
|
||||
createTime: string;
|
||||
lastUpdateTime: string;
|
||||
creator: string;
|
||||
deviceCount: number;
|
||||
targetFriends: number;
|
||||
groupSize: { min: number; max: number };
|
||||
timeRange: { start: string; end: string };
|
||||
targetTags: string[];
|
||||
groupNameTemplate: string;
|
||||
groupDescription: string;
|
||||
}
|
||||
|
||||
const mockTaskDetail: GroupTaskDetail = {
|
||||
id: "1",
|
||||
name: "VIP客户建群",
|
||||
status: "creating",
|
||||
totalGroups: 5,
|
||||
currentGroupIndex: 2,
|
||||
groups: Array.from({ length: 5 }).map((_, index) => ({
|
||||
id: `group-${index}`,
|
||||
members: Array.from({ length: Math.floor(Math.random() * 10) + 30 }).map(
|
||||
(_, mIndex) => ({
|
||||
id: `member-${index}-${mIndex}`,
|
||||
nickname: `用户${mIndex + 1}`,
|
||||
wechatId: `wx_${mIndex}`,
|
||||
tags: [`标签${(mIndex % 3) + 1}`],
|
||||
}),
|
||||
),
|
||||
})),
|
||||
createTime: "2024-11-20 19:04:14",
|
||||
lastUpdateTime: "2025-02-06 13:12:35",
|
||||
creator: "admin",
|
||||
deviceCount: 2,
|
||||
targetFriends: 156,
|
||||
groupSize: { min: 20, max: 50 },
|
||||
timeRange: { start: "09:00", end: "21:00" },
|
||||
targetTags: ["VIP客户", "高价值"],
|
||||
groupNameTemplate: "VIP客户交流群{序号}",
|
||||
groupDescription: "VIP客户专属交流群,提供优质服务",
|
||||
};
|
||||
|
||||
const GroupPreview: React.FC<{
|
||||
groupIndex: number;
|
||||
members: GroupMember[];
|
||||
isCreating: boolean;
|
||||
isCompleted: boolean;
|
||||
onRetry?: () => void;
|
||||
}> = ({ groupIndex, members, isCreating, isCompleted, onRetry }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const targetSize = 38;
|
||||
return (
|
||||
<Card className={style.groupCard}>
|
||||
<div className={style.groupHeader}>
|
||||
<div>
|
||||
群 {groupIndex + 1}
|
||||
<Tag
|
||||
color={isCompleted ? "success" : isCreating ? "warning" : "default"}
|
||||
style={{ marginLeft: 8 }}
|
||||
>
|
||||
{isCompleted ? "已完成" : isCreating ? "创建中" : "等待中"}
|
||||
</Tag>
|
||||
</div>
|
||||
<div style={{ color: "#888", fontSize: 12 }}>
|
||||
<TeamOutline style={{ marginRight: 4 }} />
|
||||
{members.length}/{targetSize}
|
||||
</div>
|
||||
</div>
|
||||
{isCreating && !isCompleted && (
|
||||
<ProgressBar
|
||||
percent={Math.round((members.length / targetSize) * 100)}
|
||||
style={{ margin: "8px 0" }}
|
||||
/>
|
||||
)}
|
||||
{expanded ? (
|
||||
<>
|
||||
<div className={style.memberGrid}>
|
||||
{members.map(member => (
|
||||
<div key={member.id} className={style.memberItem}>
|
||||
<span>{member.nickname}</span>
|
||||
{member.tags.length > 0 && (
|
||||
<Tag color="primary" style={{ marginLeft: 4 }}>
|
||||
{member.tags[0]}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
size="mini"
|
||||
fill="none"
|
||||
block
|
||||
onClick={() => setExpanded(false)}
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
收起
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="mini"
|
||||
fill="none"
|
||||
block
|
||||
onClick={() => setExpanded(true)}
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
查看成员 ({members.length})
|
||||
</Button>
|
||||
)}
|
||||
{!isCompleted && members.length < targetSize && (
|
||||
<div className={style.warnText}>
|
||||
<AlertOutlined style={{ marginRight: 4 }} />
|
||||
群人数不足{targetSize}人
|
||||
{onRetry && (
|
||||
<Button
|
||||
size="mini"
|
||||
fill="none"
|
||||
color="primary"
|
||||
style={{ marginLeft: 8 }}
|
||||
onClick={onRetry}
|
||||
>
|
||||
继续拉人
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isCompleted && <div className={style.successText}>群创建完成</div>}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const GroupCreationProgress: React.FC<{
|
||||
taskDetail: GroupTaskDetail;
|
||||
onComplete: () => void;
|
||||
}> = ({ taskDetail, onComplete }) => {
|
||||
const [groups, setGroups] = useState<Group[]>(taskDetail.groups);
|
||||
const [currentGroupIndex, setCurrentGroupIndex] = useState(
|
||||
taskDetail.currentGroupIndex,
|
||||
);
|
||||
const [status, setStatus] = useState<GroupTaskDetail["status"]>(
|
||||
taskDetail.status,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "creating" && currentGroupIndex < groups.length) {
|
||||
const timer = setTimeout(() => {
|
||||
if (currentGroupIndex === groups.length - 1) {
|
||||
setStatus("completed");
|
||||
onComplete();
|
||||
} else {
|
||||
setCurrentGroupIndex(prev => prev + 1);
|
||||
}
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [status, currentGroupIndex, groups.length, onComplete]);
|
||||
|
||||
const handleRetryGroup = (groupIndex: number) => {
|
||||
setGroups(prev =>
|
||||
prev.map((group, index) => {
|
||||
if (index === groupIndex) {
|
||||
return {
|
||||
...group,
|
||||
members: [
|
||||
...group.members,
|
||||
{
|
||||
id: `retry-member-${Date.now()}`,
|
||||
nickname: `补充用户${group.members.length + 1}`,
|
||||
wechatId: `wx_retry_${Date.now()}`,
|
||||
tags: ["新加入"],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return group;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.progressSection}>
|
||||
<Card className={style.progressCard}>
|
||||
<div className={style.progressHeader}>
|
||||
<div>
|
||||
建群进度
|
||||
<Tag
|
||||
color={
|
||||
status === "completed"
|
||||
? "success"
|
||||
: status === "creating"
|
||||
? "warning"
|
||||
: "default"
|
||||
}
|
||||
style={{ marginLeft: 8 }}
|
||||
>
|
||||
{status === "preparing"
|
||||
? "准备中"
|
||||
: status === "creating"
|
||||
? "创建中"
|
||||
: "已完成"}
|
||||
</Tag>
|
||||
</div>
|
||||
<div style={{ color: "#888", fontSize: 12 }}>
|
||||
{currentGroupIndex + 1}/{groups.length}组
|
||||
</div>
|
||||
</div>
|
||||
<ProgressBar
|
||||
percent={Math.round(((currentGroupIndex + 1) / groups.length) * 100)}
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
</Card>
|
||||
<div className={style.groupList}>
|
||||
{groups.map((group, index) => (
|
||||
<GroupPreview
|
||||
key={group.id}
|
||||
groupIndex={index}
|
||||
members={group.members}
|
||||
isCreating={status === "creating" && index === currentGroupIndex}
|
||||
isCompleted={status === "completed" || index < currentGroupIndex}
|
||||
onRetry={() => handleRetryGroup(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{status === "completed" && (
|
||||
<div className={style.successAlert}>所有群组已创建完成</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AutoGroupDetail: React.FC = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [taskDetail, setTaskDetail] = useState<GroupTaskDetail | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setTimeout(() => {
|
||||
setTaskDetail(mockTaskDetail);
|
||||
setLoading(false);
|
||||
}, 800);
|
||||
}, [id]);
|
||||
|
||||
const handleComplete = () => {
|
||||
Toast.show({ content: "所有群组已创建完成" });
|
||||
};
|
||||
|
||||
if (!taskDetail) {
|
||||
return (
|
||||
<Layout
|
||||
header={<NavCommon title="建群详情" backFn={() => navigate(-1)} />}
|
||||
>
|
||||
<Card className={style.emptyCard}>
|
||||
<AlertOutlined style={{ fontSize: 48, color: "#ccc" }} />
|
||||
<div className={style.emptyTitle}>任务不存在</div>
|
||||
<div className={style.emptyDesc}>请检查任务ID是否正确</div>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={() => navigate("/workspace/auto-group")}
|
||||
>
|
||||
返回列表
|
||||
</Button>
|
||||
</Card>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<NavCommon
|
||||
title={taskDetail.name + " - 建群详情"}
|
||||
backFn={() => navigate(-1)}
|
||||
/>
|
||||
}
|
||||
loading={loading}
|
||||
>
|
||||
<div className={style.autoGroupDetail}>
|
||||
<Card className={style.infoCard}>
|
||||
<div className={style.infoGrid}>
|
||||
<div>
|
||||
<div className={style.infoTitle}>基本信息</div>
|
||||
<div className={style.infoItem}>任务名称:{taskDetail.name}</div>
|
||||
<div className={style.infoItem}>
|
||||
创建时间:{taskDetail.createTime}
|
||||
</div>
|
||||
<div className={style.infoItem}>创建人:{taskDetail.creator}</div>
|
||||
<div className={style.infoItem}>
|
||||
执行设备:{taskDetail.deviceCount} 个
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className={style.infoTitle}>建群配置</div>
|
||||
<div className={style.infoItem}>
|
||||
群组规模:{taskDetail.groupSize.min}-{taskDetail.groupSize.max}{" "}
|
||||
人
|
||||
</div>
|
||||
<div className={style.infoItem}>
|
||||
执行时间:{taskDetail.timeRange.start} -{" "}
|
||||
{taskDetail.timeRange.end}
|
||||
</div>
|
||||
<div className={style.infoItem}>
|
||||
目标标签:{taskDetail.targetTags.join(", ")}
|
||||
</div>
|
||||
<div className={style.infoItem}>
|
||||
群名称模板:{taskDetail.groupNameTemplate}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<GroupCreationProgress
|
||||
taskDetail={taskDetail}
|
||||
onComplete={handleComplete}
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutoGroupDetail;
|
||||
@@ -1,17 +0,0 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
// 创建朋友圈同步任务
|
||||
export const createAutoGroup = (params: any) =>
|
||||
request("/v1/workbench/create", params, "POST");
|
||||
|
||||
// 更新朋友圈同步任务
|
||||
export const updateAutoGroup = (params: any) =>
|
||||
request("/v1/workbench/update", params, "POST");
|
||||
|
||||
// 获取朋友圈同步任务详情
|
||||
export const getAutoGroupDetail = (id: string) =>
|
||||
request("/v1/workbench/detail", { id }, "GET");
|
||||
|
||||
// 获取朋友圈同步任务列表
|
||||
export const getAutoGroupList = (params: any) =>
|
||||
request("/v1/workbench/list", params, "GET");
|
||||
@@ -1,336 +0,0 @@
|
||||
import React, { useImperativeHandle, forwardRef, useEffect } from "react";
|
||||
import { Button, Card, Switch, Form, InputNumber } from "antd";
|
||||
import { Input } from "antd";
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface BasicSettingsProps {
|
||||
initialValues?: {
|
||||
name: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
groupSizeMin: number;
|
||||
groupSizeMax: number;
|
||||
maxGroupsPerDay: number;
|
||||
groupNameTemplate: string;
|
||||
groupDescription: string;
|
||||
status: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BasicSettingsRef {
|
||||
validate: () => Promise<boolean>;
|
||||
getValues: () => any;
|
||||
}
|
||||
|
||||
const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
||||
(
|
||||
{
|
||||
initialValues = {
|
||||
name: "",
|
||||
startTime: "06:00",
|
||||
endTime: "23:59",
|
||||
groupSizeMin: 20,
|
||||
groupSizeMax: 50,
|
||||
maxGroupsPerDay: 10,
|
||||
groupNameTemplate: "",
|
||||
groupDescription: "",
|
||||
status: 1,
|
||||
},
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// 当initialValues变化时,重新设置表单值
|
||||
useEffect(() => {
|
||||
form.setFieldsValue(initialValues);
|
||||
}, [form, initialValues]);
|
||||
|
||||
// 暴露方法给父组件
|
||||
useImperativeHandle(ref, () => ({
|
||||
validate: async () => {
|
||||
try {
|
||||
await form.validateFields();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log("BasicSettings 表单验证失败:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
getValues: () => {
|
||||
return form.getFieldsValue();
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Card>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
key={JSON.stringify(initialValues)}
|
||||
onValuesChange={(changedValues, allValues) => {
|
||||
// 可以在这里处理表单值变化
|
||||
}}
|
||||
>
|
||||
{/* 任务名称 */}
|
||||
<Form.Item
|
||||
label="任务名称"
|
||||
name="name"
|
||||
rules={[
|
||||
{ required: true, message: "请输入任务名称" },
|
||||
{ min: 2, max: 50, message: "任务名称长度在2-50个字符之间" },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入任务名称" />
|
||||
</Form.Item>
|
||||
|
||||
{/* 允许建群的时间段 */}
|
||||
<Form.Item label="允许建群的时间段">
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<Form.Item
|
||||
name="startTime"
|
||||
noStyle
|
||||
rules={[{ required: true, message: "请选择开始时间" }]}
|
||||
>
|
||||
<Input type="time" style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<span style={{ color: "#888" }}>至</span>
|
||||
<Form.Item
|
||||
name="endTime"
|
||||
noStyle
|
||||
rules={[{ required: true, message: "请选择结束时间" }]}
|
||||
>
|
||||
<Input type="time" style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
{/* 每日最大建群数 */}
|
||||
<Form.Item
|
||||
label="每日最大建群数"
|
||||
name="maxGroupsPerDay"
|
||||
rules={[
|
||||
{ required: true, message: "请输入每日最大建群数" },
|
||||
{
|
||||
validator: (_, value) => {
|
||||
const numValue = Number(value);
|
||||
if (value && (numValue < 1 || numValue > 100)) {
|
||||
return Promise.reject(
|
||||
new Error("每日最大建群数在1-100之间"),
|
||||
);
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={100}
|
||||
placeholder="请输入最大建群数"
|
||||
step={1}
|
||||
style={{ width: "100%" }}
|
||||
value={form.getFieldValue("maxGroupsPerDay")}
|
||||
onChange={value => form.setFieldValue("maxGroupsPerDay", value)}
|
||||
addonBefore={
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => {
|
||||
const currentValue =
|
||||
form.getFieldValue("maxGroupsPerDay") || 1;
|
||||
const newValue = Math.max(1, currentValue - 1);
|
||||
form.setFieldValue("maxGroupsPerDay", newValue);
|
||||
}}
|
||||
>
|
||||
-
|
||||
</Button>
|
||||
}
|
||||
addonAfter={
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => {
|
||||
const currentValue =
|
||||
form.getFieldValue("maxGroupsPerDay") || 1;
|
||||
const newValue = Math.min(100, currentValue + 1);
|
||||
form.setFieldValue("maxGroupsPerDay", newValue);
|
||||
}}
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 群组最小人数 */}
|
||||
<Form.Item
|
||||
label="群组最小人数"
|
||||
name="groupSizeMin"
|
||||
rules={[
|
||||
{ required: true, message: "请输入群组最小人数" },
|
||||
{
|
||||
validator: (_, value) => {
|
||||
const numValue = Number(value);
|
||||
if (value && (numValue < 1 || numValue > 500)) {
|
||||
return Promise.reject(
|
||||
new Error("群组最小人数在1-500之间"),
|
||||
);
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={500}
|
||||
placeholder="请输入最小人数"
|
||||
step={1}
|
||||
style={{ width: "100%" }}
|
||||
value={form.getFieldValue("groupSizeMin")}
|
||||
onChange={value => form.setFieldValue("groupSizeMin", value)}
|
||||
addonBefore={
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => {
|
||||
const currentValue =
|
||||
form.getFieldValue("groupSizeMin") || 1;
|
||||
const newValue = Math.max(1, currentValue - 1);
|
||||
form.setFieldValue("groupSizeMin", newValue);
|
||||
}}
|
||||
>
|
||||
-
|
||||
</Button>
|
||||
}
|
||||
addonAfter={
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => {
|
||||
const currentValue =
|
||||
form.getFieldValue("groupSizeMin") || 1;
|
||||
const newValue = Math.min(500, currentValue + 1);
|
||||
form.setFieldValue("groupSizeMin", newValue);
|
||||
}}
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 群组最大人数 */}
|
||||
<Form.Item
|
||||
label="群组最大人数"
|
||||
name="groupSizeMax"
|
||||
rules={[
|
||||
{ required: true, message: "请输入群组最大人数" },
|
||||
{
|
||||
validator: (_, value) => {
|
||||
const numValue = Number(value);
|
||||
if (value && (numValue < 1 || numValue > 500)) {
|
||||
return Promise.reject(
|
||||
new Error("群组最大人数在1-500之间"),
|
||||
);
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={500}
|
||||
placeholder="请输入最大人数"
|
||||
step={1}
|
||||
style={{ width: "100%" }}
|
||||
value={form.getFieldValue("groupSizeMax")}
|
||||
onChange={value => form.setFieldValue("groupSizeMax", value)}
|
||||
addonBefore={
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => {
|
||||
const currentValue =
|
||||
form.getFieldValue("groupSizeMax") || 1;
|
||||
const newValue = Math.max(1, currentValue - 1);
|
||||
form.setFieldValue("groupSizeMax", newValue);
|
||||
}}
|
||||
>
|
||||
-
|
||||
</Button>
|
||||
}
|
||||
addonAfter={
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => {
|
||||
const currentValue =
|
||||
form.getFieldValue("groupSizeMax") || 1;
|
||||
const newValue = Math.min(500, currentValue + 1);
|
||||
form.setFieldValue("groupSizeMax", newValue);
|
||||
}}
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 群名称模板 */}
|
||||
<Form.Item
|
||||
label="群名称模板"
|
||||
name="groupNameTemplate"
|
||||
rules={[
|
||||
{ required: true, message: "请输入群名称模板" },
|
||||
{
|
||||
min: 2,
|
||||
max: 100,
|
||||
message: "群名称模板长度在2-100个字符之间",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入群名称模板" />
|
||||
</Form.Item>
|
||||
|
||||
{/* 群描述 */}
|
||||
<Form.Item
|
||||
label="群描述"
|
||||
name="groupDescription"
|
||||
rules={[{ max: 200, message: "群描述不能超过200个字符" }]}
|
||||
>
|
||||
<TextArea
|
||||
placeholder="请输入群描述"
|
||||
rows={3}
|
||||
maxLength={200}
|
||||
showCount
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 是否启用 */}
|
||||
<Form.Item
|
||||
label="是否启用"
|
||||
name="status"
|
||||
valuePropName="checked"
|
||||
getValueFromEvent={checked => (checked ? 1 : 0)}
|
||||
getValueProps={value => ({ checked: value === 1 })}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<span>状态</span>
|
||||
<Switch />
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
BasicSettings.displayName = "BasicSettings";
|
||||
|
||||
export default BasicSettings;
|
||||
@@ -1,93 +0,0 @@
|
||||
import React, { useImperativeHandle, forwardRef } from "react";
|
||||
import { Form, Card } from "antd";
|
||||
import DeviceSelection from "@/components/DeviceSelection";
|
||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||
|
||||
interface DeviceSelectorProps {
|
||||
selectedDevices: DeviceSelectionItem[];
|
||||
onNext: (data: {
|
||||
deviceGroups: string[];
|
||||
deviceGroupsOptions: DeviceSelectionItem[];
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export interface DeviceSelectorRef {
|
||||
validate: () => Promise<boolean>;
|
||||
getValues: () => any;
|
||||
}
|
||||
|
||||
const DeviceSelector = forwardRef<DeviceSelectorRef, DeviceSelectorProps>(
|
||||
({ selectedDevices, onNext }, ref) => {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// 暴露方法给父组件
|
||||
useImperativeHandle(ref, () => ({
|
||||
validate: async () => {
|
||||
try {
|
||||
await form.validateFields();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log("DeviceSelector 表单验证失败:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
getValues: () => {
|
||||
return form.getFieldsValue();
|
||||
},
|
||||
}));
|
||||
|
||||
// 设备选择
|
||||
const handleDeviceSelect = (deviceGroupsOptions: DeviceSelectionItem[]) => {
|
||||
const deviceGroups = deviceGroupsOptions.map(item => item.id);
|
||||
form.setFieldValue("deviceGroups", deviceGroups);
|
||||
// 通知父组件数据变化
|
||||
onNext({
|
||||
deviceGroups: deviceGroups.map(id => String(id)),
|
||||
deviceGroupsOptions,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
deviceGroups: selectedDevices.map(item => item.id),
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>
|
||||
选择设备组
|
||||
</h2>
|
||||
<p style={{ margin: "8px 0 0 0", color: "#666", fontSize: 14 }}>
|
||||
请选择要用于建群的设备组
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
name="deviceGroups"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
type: "array",
|
||||
min: 1,
|
||||
message: "请选择至少一个设备组",
|
||||
},
|
||||
{ type: "array", max: 20, message: "最多只能选择20个设备组" },
|
||||
]}
|
||||
>
|
||||
<DeviceSelection
|
||||
selectedOptions={selectedDevices}
|
||||
onSelect={handleDeviceSelect}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
DeviceSelector.displayName = "DeviceSelector";
|
||||
|
||||
export default DeviceSelector;
|
||||
@@ -1,98 +0,0 @@
|
||||
import React, { useImperativeHandle, forwardRef } from "react";
|
||||
import { Form, Card } from "antd";
|
||||
import PoolSelection from "@/components/PoolSelection";
|
||||
import {
|
||||
PoolSelectionItem,
|
||||
PoolPackageItem,
|
||||
} from "@/components/PoolSelection/data";
|
||||
|
||||
interface PoolSelectorProps {
|
||||
selectedPools: PoolSelectionItem[];
|
||||
onNext: (data: {
|
||||
poolGroups: string[];
|
||||
poolGroupsOptions: PoolSelectionItem[];
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export interface PoolSelectorRef {
|
||||
validate: () => Promise<boolean>;
|
||||
getValues: () => any;
|
||||
}
|
||||
|
||||
const PoolSelector = forwardRef<PoolSelectorRef, PoolSelectorProps>(
|
||||
({ selectedPools, onNext }, ref) => {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// 暴露方法给父组件
|
||||
useImperativeHandle(ref, () => ({
|
||||
validate: async () => {
|
||||
try {
|
||||
await form.validateFields();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log("PoolSelector 表单验证失败:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
getValues: () => {
|
||||
return form.getFieldsValue();
|
||||
},
|
||||
}));
|
||||
|
||||
// 处理选择变化
|
||||
const handlePoolChange = (poolGroupsOptions: PoolSelectionItem[]) => {
|
||||
const poolGroups = poolGroupsOptions.map(c => c.id.toString());
|
||||
form.setFieldValue("poolGroups", poolGroups);
|
||||
onNext({
|
||||
poolGroups,
|
||||
poolGroupsOptions,
|
||||
});
|
||||
};
|
||||
|
||||
// 处理详细选择数据
|
||||
const handleSelectDetail = (poolPackages: PoolPackageItem[]) => {
|
||||
// 如果需要处理原始流量池包数据,可以在这里添加逻辑
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Card>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
poolGroups: selectedPools.map(c => c.id.toString()),
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>
|
||||
选择流量池包
|
||||
</h2>
|
||||
<p style={{ margin: "8px 0 0 0", color: "#666", fontSize: 14 }}>
|
||||
请选择要用于建群的流量池包
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
name="poolGroups"
|
||||
rules={[
|
||||
{ required: true, message: "请选择至少一个流量池包" },
|
||||
{ type: "array", min: 1, message: "请选择至少一个流量池包" },
|
||||
{ type: "array", max: 20, message: "最多只能选择20个流量池包" },
|
||||
]}
|
||||
>
|
||||
<PoolSelection
|
||||
selectedOptions={selectedPools}
|
||||
onSelect={handlePoolChange}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
PoolSelector.displayName = "PoolSelector";
|
||||
|
||||
export default PoolSelector;
|
||||
@@ -1,25 +0,0 @@
|
||||
.autoGroupForm {
|
||||
padding: 10px;
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
.headerBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 48px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Toast } from "antd-mobile";
|
||||
import { Button } from "antd";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import { createAutoGroup, updateAutoGroup, getAutoGroupDetail } from "./api";
|
||||
import { AutoGroupFormData, StepItem } from "./types";
|
||||
import StepIndicator from "@/components/StepIndicator";
|
||||
import BasicSettings, { BasicSettingsRef } from "./components/BasicSettings";
|
||||
import DeviceSelector, { DeviceSelectorRef } from "./components/DeviceSelector";
|
||||
import PoolSelector, { PoolSelectorRef } from "./components/PoolSelector";
|
||||
import NavCommon from "@/components/NavCommon/index";
|
||||
import dayjs from "dayjs";
|
||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||
import { PoolSelectionItem } from "@/components/PoolSelection/data";
|
||||
|
||||
const steps: StepItem[] = [
|
||||
{ id: 1, title: "步骤 1", subtitle: "基础设置" },
|
||||
{ id: 2, title: "步骤 2", subtitle: "选择设备" },
|
||||
{ id: 3, title: "步骤 3", subtitle: "选择流量池包" },
|
||||
];
|
||||
|
||||
const defaultForm: AutoGroupFormData = {
|
||||
name: "",
|
||||
type: 4,
|
||||
deviceGroups: [], // 设备组
|
||||
deviceGroupsOptions: [], // 设备组选项
|
||||
poolGroups: [], // 内容库
|
||||
poolGroupsOptions: [], // 内容库选项
|
||||
startTime: dayjs().format("HH:mm"), // 开始时间 (HH:mm)
|
||||
endTime: dayjs().add(1, "hour").format("HH:mm"), // 结束时间 (HH:mm)
|
||||
groupSizeMin: 20, // 群组最小人数
|
||||
groupSizeMax: 50, // 群组最大人数
|
||||
maxGroupsPerDay: 10, // 每日最大建群数
|
||||
groupNameTemplate: "", // 群名称模板
|
||||
groupDescription: "", // 群描述
|
||||
status: 1, // 是否启用 (1: 启用, 0: 禁用)
|
||||
};
|
||||
|
||||
const AutoGroupForm: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const isEdit = Boolean(id);
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dataLoaded, setDataLoaded] = useState(!isEdit); // 非编辑模式直接标记为已加载
|
||||
const [formData, setFormData] = useState<AutoGroupFormData>(defaultForm);
|
||||
const [deviceGroupsOptions, setDeviceGroupsOptions] = useState<
|
||||
DeviceSelectionItem[]
|
||||
>([]);
|
||||
const [poolGroupsOptions, setpoolGroupsOptions] = useState<
|
||||
PoolSelectionItem[]
|
||||
>([]);
|
||||
|
||||
// 创建子组件的ref
|
||||
const basicSettingsRef = useRef<BasicSettingsRef>(null);
|
||||
const deviceSelectorRef = useRef<DeviceSelectorRef>(null);
|
||||
const poolSelectorRef = useRef<PoolSelectorRef>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
// 这里应请求详情接口,回填表单,演示用mock
|
||||
getAutoGroupDetail(id).then(res => {
|
||||
const updatedForm = {
|
||||
...defaultForm,
|
||||
name: res.name,
|
||||
deviceGroups: res.config.deviceGroups || [],
|
||||
deviceGroupsOptions: res.config.deviceGroupsOptions || [],
|
||||
poolGroups: res.config.poolGroups || [],
|
||||
poolGroupsOptions: res.config.poolGroupsOptions || [],
|
||||
startTime: res.config.startTime,
|
||||
endTime: res.config.endTime,
|
||||
groupSizeMin: res.config.groupSizeMin,
|
||||
groupSizeMax: res.config.groupSizeMax,
|
||||
maxGroupsPerDay: res.config.maxGroupsPerDay,
|
||||
groupNameTemplate: res.config.groupNameTemplate,
|
||||
groupDescription: res.config.groupDescription,
|
||||
status: res.status,
|
||||
type: res.type,
|
||||
id: res.id,
|
||||
};
|
||||
setFormData(updatedForm);
|
||||
setDeviceGroupsOptions(res.config.deviceGroupsOptions || []);
|
||||
setpoolGroupsOptions(res.config.poolGroupsOptions || []);
|
||||
setDataLoaded(true); // 标记数据已加载
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
const handleBasicSettingsChange = (values: Partial<AutoGroupFormData>) => {
|
||||
setFormData(prev => ({ ...prev, ...values }));
|
||||
};
|
||||
|
||||
// 设备组选择
|
||||
const handleDevicesChange = (data: {
|
||||
deviceGroups: string[];
|
||||
deviceGroupsOptions: DeviceSelectionItem[];
|
||||
}) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
deviceGroups: data.deviceGroups,
|
||||
}));
|
||||
setDeviceGroupsOptions(data.deviceGroupsOptions);
|
||||
};
|
||||
|
||||
// 流量池包选择
|
||||
const handlePoolsChange = (data: {
|
||||
poolGroups: string[];
|
||||
poolGroupsOptions: PoolSelectionItem[];
|
||||
}) => {
|
||||
setFormData(prev => ({ ...prev, poolGroups: data.poolGroups }));
|
||||
setpoolGroupsOptions(data.poolGroupsOptions);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.name.trim()) {
|
||||
Toast.show({ content: "请输入任务名称" });
|
||||
return;
|
||||
}
|
||||
if (formData.deviceGroups.length === 0) {
|
||||
Toast.show({ content: "请选择至少一个设备组" });
|
||||
return;
|
||||
}
|
||||
if (formData.poolGroups.length === 0) {
|
||||
Toast.show({ content: "请选择至少一个流量池包" });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const submitData = {
|
||||
...formData,
|
||||
deviceGroupsOptions: deviceGroupsOptions,
|
||||
poolGroupsOptions: poolGroupsOptions,
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
await updateAutoGroup(submitData);
|
||||
Toast.show({ content: "编辑成功" });
|
||||
} else {
|
||||
await createAutoGroup(submitData);
|
||||
Toast.show({ content: "创建成功" });
|
||||
}
|
||||
navigate("/workspace/auto-group");
|
||||
} catch (e) {
|
||||
Toast.show({ content: "提交失败" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = async () => {
|
||||
if (currentStep < 3) {
|
||||
try {
|
||||
let isValid = false;
|
||||
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
// 调用 BasicSettings 的表单校验
|
||||
isValid = (await basicSettingsRef.current?.validate()) || false;
|
||||
if (isValid) {
|
||||
const values = basicSettingsRef.current?.getValues();
|
||||
if (values) {
|
||||
handleBasicSettingsChange(values);
|
||||
}
|
||||
setCurrentStep(2);
|
||||
}
|
||||
break;
|
||||
|
||||
case 2:
|
||||
// 调用 DeviceSelector 的表单校验
|
||||
isValid = (await deviceSelectorRef.current?.validate()) || false;
|
||||
if (isValid) {
|
||||
setCurrentStep(3);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
setCurrentStep(currentStep + 1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("表单验证失败:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderCurrentStep = () => {
|
||||
// 编辑模式下,等待数据加载完成后再渲染
|
||||
if (isEdit && !dataLoaded) {
|
||||
return (
|
||||
<div style={{ textAlign: "center", padding: "50px" }}>加载中...</div>
|
||||
);
|
||||
}
|
||||
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
<BasicSettings
|
||||
ref={basicSettingsRef}
|
||||
initialValues={{
|
||||
name: formData.name,
|
||||
startTime: formData.startTime,
|
||||
endTime: formData.endTime,
|
||||
groupSizeMin: formData.groupSizeMin,
|
||||
groupSizeMax: formData.groupSizeMax,
|
||||
maxGroupsPerDay: formData.maxGroupsPerDay,
|
||||
groupNameTemplate: formData.groupNameTemplate,
|
||||
groupDescription: formData.groupDescription,
|
||||
status: formData.status,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<DeviceSelector
|
||||
ref={deviceSelectorRef}
|
||||
selectedDevices={deviceGroupsOptions}
|
||||
onNext={handleDevicesChange}
|
||||
/>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<PoolSelector
|
||||
ref={poolSelectorRef}
|
||||
selectedPools={poolGroupsOptions}
|
||||
onNext={handlePoolsChange}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const renderFooter = () => {
|
||||
return (
|
||||
<div className="footer-btn-group">
|
||||
{currentStep > 1 && (
|
||||
<Button size="large" onClick={handlePrevious}>
|
||||
上一步
|
||||
</Button>
|
||||
)}
|
||||
{currentStep === 3 ? (
|
||||
<Button
|
||||
size="large"
|
||||
type="primary"
|
||||
loading={loading}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{isEdit ? "保存修改" : "创建任务"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="large" type="primary" onClick={handleNext}>
|
||||
下一步
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<NavCommon
|
||||
title={isEdit ? "编辑建群任务" : "新建建群任务"}
|
||||
backFn={() => navigate(-1)}
|
||||
/>
|
||||
<StepIndicator currentStep={currentStep} steps={steps} />
|
||||
</>
|
||||
}
|
||||
footer={renderFooter()}
|
||||
>
|
||||
<div style={{ padding: 12 }}>{renderCurrentStep()}</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutoGroupForm;
|
||||
@@ -1,69 +0,0 @@
|
||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||
import { PoolSelectionItem } from "@/components/PoolSelection/data";
|
||||
|
||||
// 自动建群表单数据类型定义
|
||||
export interface AutoGroupFormData {
|
||||
id?: string; // 任务ID
|
||||
type: number; // 任务类型
|
||||
name: string; // 任务名称
|
||||
deviceGroups: string[]; // 设备组
|
||||
deviceGroupsOptions: DeviceSelectionItem[]; // 设备组选项
|
||||
poolGroups: string[]; // 流量池
|
||||
poolGroupsOptions: PoolSelectionItem[]; // 流量池选项
|
||||
startTime: string; // 开始时间 (YYYY-MM-DD HH:mm:ss)
|
||||
endTime: string; // 结束时间 (YYYY-MM-DD HH:mm:ss)
|
||||
groupSizeMin: number; // 群组最小人数
|
||||
groupSizeMax: number; // 群组最大人数
|
||||
maxGroupsPerDay: number; // 每日最大建群数
|
||||
groupNameTemplate: string; // 群名称模板
|
||||
groupDescription: string; // 群描述
|
||||
status: number; // 是否启用 (1: 启用, 0: 禁用)
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// 步骤定义
|
||||
export interface StepItem {
|
||||
id: number;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
// 表单验证规则
|
||||
export const formValidationRules = {
|
||||
name: [
|
||||
{ required: true, message: "请输入任务名称" },
|
||||
{ min: 2, max: 50, message: "任务名称长度应在2-50个字符之间" },
|
||||
],
|
||||
deviceGroups: [
|
||||
{ required: true, message: "请选择设备组" },
|
||||
{ type: "array", min: 1, message: "至少选择一个设备组" },
|
||||
],
|
||||
poolGroups: [
|
||||
{ required: true, message: "请选择内容库" },
|
||||
{ type: "array", min: 1, message: "至少选择一个内容库" },
|
||||
],
|
||||
startTime: [{ required: true, message: "请选择开始时间" }],
|
||||
endTime: [{ required: true, message: "请选择结束时间" }],
|
||||
groupSizeMin: [
|
||||
{ required: true, message: "请输入群组最小人数" },
|
||||
{ type: "number", min: 1, max: 500, message: "群组最小人数应在1-500之间" },
|
||||
],
|
||||
groupSizeMax: [
|
||||
{ required: true, message: "请输入群组最大人数" },
|
||||
{ type: "number", min: 1, max: 500, message: "群组最大人数应在1-500之间" },
|
||||
],
|
||||
maxGroupsPerDay: [
|
||||
{ required: true, message: "请输入每日最大建群数" },
|
||||
{
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 100,
|
||||
message: "每日最大建群数应在1-100之间",
|
||||
},
|
||||
],
|
||||
groupNameTemplate: [
|
||||
{ required: true, message: "请输入群名称模板" },
|
||||
{ min: 2, max: 100, message: "群名称模板长度应在2-100个字符之间" },
|
||||
],
|
||||
groupDescription: [{ max: 200, message: "群描述不能超过200个字符" }],
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
// 获取自动建群任务列表
|
||||
// 获取朋友圈同步任务列表
|
||||
export const getAutoGroupList = (params: any) =>
|
||||
request("/v1/workbench/list", params, "GET");
|
||||
|
||||
// 复制自动建群任务
|
||||
export function copyAutoGroupTask(id: string): Promise<any> {
|
||||
return request("/v1/workbench/copy", { id }, "POST");
|
||||
}
|
||||
|
||||
// 删除自动建群任务
|
||||
export function deleteAutoGroupTask(id: string): Promise<any> {
|
||||
return request("/v1/workbench/delete", { id }, "DELETE");
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
.autoGroupList {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.taskList {
|
||||
}
|
||||
|
||||
.taskCard {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
|
||||
border: none;
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.taskHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.taskTitle {
|
||||
flex: 1;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.statusRunning {
|
||||
background: #e6f7e6;
|
||||
color: #389e0d;
|
||||
border-radius: 8px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.statusPaused {
|
||||
background: #f5f5f5;
|
||||
color: #888;
|
||||
border-radius: 8px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.statusCompleted {
|
||||
background: #e6f4ff;
|
||||
color: #1677ff;
|
||||
border-radius: 8px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.taskInfoGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px 16px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.infoLabel {
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
}
|
||||
.infoValue {
|
||||
color: #222;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.taskFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding-top: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.footerLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.footerRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.expandPanel {
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed #e0e0e0;
|
||||
}
|
||||
.expandGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
.expandTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #1677ff;
|
||||
}
|
||||
.expandInfo {
|
||||
font-size: 13px;
|
||||
color: #444;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.expandTags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.tag {
|
||||
background: #f0f5ff;
|
||||
color: #1677ff;
|
||||
border-radius: 8px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
color: #222;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.menuItem:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.menuItemDanger {
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
color: #e53e3e;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.menuItemDanger:hover {
|
||||
background: #fff1f0;
|
||||
}
|
||||
|
||||
.emptyCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 0;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
|
||||
margin-top: 32px;
|
||||
}
|
||||
.emptyTitle {
|
||||
font-size: 16px;
|
||||
color: #888;
|
||||
margin: 12px 0 4px 0;
|
||||
}
|
||||
.emptyDesc {
|
||||
font-size: 13px;
|
||||
color: #bbb;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button, Card, Popover, Toast } from "antd-mobile";
|
||||
import { Input, Switch, Pagination } from "antd";
|
||||
import {
|
||||
MoreOutline,
|
||||
AddCircleOutline,
|
||||
UserAddOutline,
|
||||
ClockCircleOutline,
|
||||
} from "antd-mobile-icons";
|
||||
|
||||
import {
|
||||
ReloadOutlined,
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
getAutoGroupList,
|
||||
copyAutoGroupTask,
|
||||
deleteAutoGroupTask,
|
||||
} from "./api";
|
||||
import { comfirm } from "@/utils/common";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import style from "./index.module.scss";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
|
||||
interface GroupTask {
|
||||
id: string;
|
||||
name: string;
|
||||
status: number; // 1 开启, 0 关闭
|
||||
deviceCount?: number;
|
||||
targetFriends?: number;
|
||||
createdGroups?: number;
|
||||
lastCreateTime?: string;
|
||||
createTime?: string;
|
||||
creator?: string;
|
||||
createInterval?: number;
|
||||
maxGroupsPerDay?: number;
|
||||
timeRange?: { start: string; end: string };
|
||||
groupSize?: { min: number; max: number };
|
||||
targetTags?: string[];
|
||||
groupNameTemplate?: string;
|
||||
groupDescription?: string;
|
||||
}
|
||||
|
||||
const getStatusColor = (status: number) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return style.statusRunning;
|
||||
case 0:
|
||||
return style.statusPaused;
|
||||
default:
|
||||
return style.statusPaused;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: number) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return "开启";
|
||||
case 0:
|
||||
return "关闭";
|
||||
default:
|
||||
return "关闭";
|
||||
}
|
||||
};
|
||||
|
||||
const AutoGroupList: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [tasks, setTasks] = useState<GroupTask[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const refreshTasks = async (p = page, ps = pageSize) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res: any = await getAutoGroupList({ type: 4, page: p, limit: ps });
|
||||
// 兼容不同返回结构
|
||||
const list = res?.list || res?.records || res?.data || [];
|
||||
const totalCount = res?.total || res?.totalCount || list.length;
|
||||
const normalized: GroupTask[] = (list as any[]).map(item => ({
|
||||
id: String(item.id),
|
||||
name: item.name,
|
||||
status: Number(item.status) === 1 ? 1 : 0,
|
||||
deviceCount: Array.isArray(item.config?.devices)
|
||||
? item.config.devices.length
|
||||
: 0,
|
||||
maxGroupsPerDay: item.config?.maxGroupsPerDay ?? 0,
|
||||
timeRange: {
|
||||
start: item.config?.startTime ?? "-",
|
||||
end: item.config?.endTime ?? "-",
|
||||
},
|
||||
groupSize: {
|
||||
min: item.config?.groupSizeMin ?? 0,
|
||||
max: item.config?.groupSizeMax ?? 0,
|
||||
},
|
||||
creator: item.creatorName ?? "",
|
||||
createTime: item.createTime ?? "",
|
||||
lastCreateTime: item.updateTime ?? "",
|
||||
}));
|
||||
setTasks(normalized);
|
||||
setTotal(totalCount);
|
||||
} catch (e) {
|
||||
Toast.show({ content: "获取列表失败" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refreshTasks(1, pageSize);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (taskId: string) => {
|
||||
const taskToDelete = tasks.find(task => task.id === taskId);
|
||||
if (!taskToDelete) return;
|
||||
|
||||
try {
|
||||
await comfirm("确定要删除吗?", {
|
||||
title: "删除确认",
|
||||
cancelText: "取消",
|
||||
confirmText: "删除",
|
||||
});
|
||||
|
||||
await deleteAutoGroupTask(taskId);
|
||||
setTasks(tasks.filter(task => task.id !== taskId));
|
||||
Toast.show({ content: "删除成功" });
|
||||
} catch (error) {
|
||||
// 用户取消删除或删除失败
|
||||
if (error !== "cancel") {
|
||||
Toast.show({ content: "删除失败" });
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleEdit = (taskId: string) => {
|
||||
navigate(`/workspace/auto-group/${taskId}/edit`);
|
||||
};
|
||||
|
||||
const handleView = (taskId: string) => {
|
||||
navigate(`/workspace/auto-group/${taskId}`);
|
||||
};
|
||||
|
||||
// 复制任务
|
||||
const handleCopy = async (id: string) => {
|
||||
try {
|
||||
await copyAutoGroupTask(id);
|
||||
Toast.show({
|
||||
content: "复制成功",
|
||||
position: "top",
|
||||
});
|
||||
refreshTasks(); // 重新获取列表
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
content: "复制失败",
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTaskStatus = (taskId: string) => {
|
||||
setTasks(prev =>
|
||||
prev.map(task =>
|
||||
task.id === taskId
|
||||
? {
|
||||
...task,
|
||||
status: task.status === 1 ? 0 : 1,
|
||||
}
|
||||
: task,
|
||||
),
|
||||
);
|
||||
Toast.show({ content: "状态已切换" });
|
||||
};
|
||||
|
||||
const handleCreateNew = () => {
|
||||
navigate("/workspace/auto-group/new");
|
||||
};
|
||||
|
||||
const filteredTasks = tasks.filter(task =>
|
||||
task.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
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={() => refreshTasks()}
|
||||
loading={false}
|
||||
className="refresh-btn"
|
||||
>
|
||||
<ReloadOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
<div className="pagination-container">
|
||||
<Pagination
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
onChange={(p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
refreshTasks(p, ps);
|
||||
}}
|
||||
showSizeChanger
|
||||
showTotal={t => `共 ${t} 条`}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
loading={loading}
|
||||
>
|
||||
<div className={style.autoGroupList}>
|
||||
<div className={style.taskList}>
|
||||
{filteredTasks.length === 0 ? (
|
||||
<Card className={style.emptyCard}>
|
||||
<UserAddOutline style={{ fontSize: 48, color: "#ccc" }} />
|
||||
<div className={style.emptyTitle}>暂无建群任务</div>
|
||||
<div className={style.emptyDesc}>创建您的第一个自动建群任务</div>
|
||||
<Button color="primary" onClick={handleCreateNew}>
|
||||
<AddCircleOutline /> 创建第一个任务
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
filteredTasks.map(task => (
|
||||
<Card key={task.id} className={style.taskCard}>
|
||||
<div className={style.taskHeader}>
|
||||
<div className={style.taskTitle}>{task.name}</div>
|
||||
<span className={getStatusColor(task.status)}>
|
||||
{getStatusText(task.status)}
|
||||
</span>
|
||||
<Switch
|
||||
checked={task.status === 1}
|
||||
onChange={() => toggleTaskStatus(task.id)}
|
||||
disabled={false}
|
||||
style={{ marginLeft: 8 }}
|
||||
/>
|
||||
<Popover
|
||||
content={
|
||||
<div>
|
||||
<div
|
||||
className={style.menuItem}
|
||||
onClick={() => handleView(task.id)}
|
||||
>
|
||||
查看
|
||||
</div>
|
||||
<div
|
||||
className={style.menuItem}
|
||||
onClick={() => handleEdit(task.id)}
|
||||
>
|
||||
编辑
|
||||
</div>
|
||||
<div
|
||||
className={style.menuItem}
|
||||
onClick={() => handleCopy(task.id)}
|
||||
>
|
||||
复制
|
||||
</div>
|
||||
<div
|
||||
className={style.menuItemDanger}
|
||||
onClick={() => handleDelete(task.id)}
|
||||
>
|
||||
删除
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
trigger="click"
|
||||
>
|
||||
<MoreOutline style={{ fontSize: 20, marginLeft: 8 }} />
|
||||
</Popover>
|
||||
</div>
|
||||
<div className={style.taskInfoGrid}>
|
||||
<div>
|
||||
<div className={style.infoLabel}>执行设备</div>
|
||||
<div className={style.infoValue}>
|
||||
{task.deviceCount ?? 0} 个
|
||||
</div>
|
||||
</div>
|
||||
{/* 该字段暂无,预留位 */}
|
||||
<div>
|
||||
<div className={style.infoLabel}>时间段</div>
|
||||
<div className={style.infoValue}>
|
||||
{task.timeRange?.start} - {task.timeRange?.end}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className={style.infoLabel}>单日上限</div>
|
||||
<div className={style.infoValue}>
|
||||
{task.maxGroupsPerDay ?? 0} 个
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className={style.infoLabel}>创建人</div>
|
||||
<div className={style.infoValue}>{task.creator ?? ""}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.taskFooter}>
|
||||
<div className={style.footerLeft}>
|
||||
<ClockCircleOutline style={{ marginRight: 4 }} />
|
||||
更新时间:{task.lastCreateTime}
|
||||
</div>
|
||||
<div className={style.footerRight}>
|
||||
创建时间:{task.createTime}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutoGroupList;
|
||||
@@ -1,63 +0,0 @@
|
||||
import request from "@/api/request";
|
||||
import {
|
||||
LikeTask,
|
||||
CreateLikeTaskData,
|
||||
UpdateLikeTaskData,
|
||||
LikeRecord,
|
||||
PaginatedResponse,
|
||||
} from "@/pages/mobile/workspace/auto-like/record/data";
|
||||
|
||||
// 获取自动点赞任务列表
|
||||
export function fetchAutoLikeTasks(
|
||||
params = { type: 1, page: 1, limit: 100 },
|
||||
): Promise<LikeTask[]> {
|
||||
return request("/v1/workbench/list", params, "GET");
|
||||
}
|
||||
|
||||
// 获取单个任务详情
|
||||
export function fetchAutoLikeTaskDetail(id: string): Promise<LikeTask | null> {
|
||||
return request("/v1/workbench/detail", { id }, "GET");
|
||||
}
|
||||
|
||||
// 创建自动点赞任务
|
||||
export function createAutoLikeTask(data: CreateLikeTaskData): Promise<any> {
|
||||
return request("/v1/workbench/create", { ...data, type: 1 }, "POST");
|
||||
}
|
||||
|
||||
// 更新自动点赞任务
|
||||
export function updateAutoLikeTask(data: UpdateLikeTaskData): Promise<any> {
|
||||
return request("/v1/workbench/update", { ...data, type: 1 }, "POST");
|
||||
}
|
||||
|
||||
// 删除自动点赞任务
|
||||
export function deleteAutoLikeTask(id: string): Promise<any> {
|
||||
return request("/v1/workbench/delete", { id }, "DELETE");
|
||||
}
|
||||
|
||||
// 切换任务状态
|
||||
export function toggleAutoLikeTask(data): Promise<any> {
|
||||
return request("/v1/workbench/update-status", { ...data }, "POST");
|
||||
}
|
||||
|
||||
// 复制自动点赞任务
|
||||
export function copyAutoLikeTask(id: string): Promise<any> {
|
||||
return request("/v1/workbench/copy", { id }, "POST");
|
||||
}
|
||||
|
||||
// 获取点赞记录
|
||||
export function fetchLikeRecords(
|
||||
workbenchId: string,
|
||||
page: number = 1,
|
||||
limit: number = 20,
|
||||
keyword?: string,
|
||||
): Promise<PaginatedResponse<LikeRecord>> {
|
||||
const params: any = {
|
||||
workbenchId,
|
||||
page: page.toString(),
|
||||
limit: limit.toString(),
|
||||
};
|
||||
if (keyword) {
|
||||
params.keyword = keyword;
|
||||
}
|
||||
return request("/v1/workbench/records", params, "GET");
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
// 自动点赞任务状态
|
||||
export type LikeTaskStatus = 1 | 2; // 1: 开启, 2: 关闭
|
||||
|
||||
// 内容类型
|
||||
export type ContentType = "text" | "image" | "video" | "link";
|
||||
|
||||
// 设备信息
|
||||
export interface Device {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "online" | "offline";
|
||||
lastActive: string;
|
||||
}
|
||||
|
||||
// 好友信息
|
||||
export interface Friend {
|
||||
id: string;
|
||||
nickname: string;
|
||||
wechatId: string;
|
||||
avatar: string;
|
||||
tags: string[];
|
||||
region: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
// 点赞记录
|
||||
export interface LikeRecord {
|
||||
id: string;
|
||||
workbenchId: string;
|
||||
momentsId: string;
|
||||
snsId: string;
|
||||
wechatAccountId: string;
|
||||
wechatFriendId: string;
|
||||
likeTime: string;
|
||||
content: string;
|
||||
resUrls: string[];
|
||||
momentTime: string;
|
||||
userName: string;
|
||||
operatorName: string;
|
||||
operatorAvatar: string;
|
||||
friendName: string;
|
||||
friendAvatar: string;
|
||||
}
|
||||
|
||||
// 自动点赞任务
|
||||
export interface LikeTask {
|
||||
id: string;
|
||||
name: string;
|
||||
status: LikeTaskStatus;
|
||||
deviceCount: number;
|
||||
targetGroup: string;
|
||||
likeCount: number;
|
||||
lastLikeTime: string;
|
||||
createTime: string;
|
||||
creator: string;
|
||||
likeInterval: number;
|
||||
maxLikesPerDay: number;
|
||||
timeRange: { start: string; end: string };
|
||||
contentTypes: ContentType[];
|
||||
targetTags: string[];
|
||||
devices: string[];
|
||||
friends: string[];
|
||||
friendMaxLikes: number;
|
||||
friendTags: string;
|
||||
enableFriendTags: boolean;
|
||||
todayLikeCount: number;
|
||||
totalLikeCount: number;
|
||||
updateTime: string;
|
||||
}
|
||||
|
||||
// 创建任务数据
|
||||
export interface CreateLikeTaskData {
|
||||
name: string;
|
||||
interval: number;
|
||||
maxLikes: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
contentTypes: ContentType[];
|
||||
devices: string[];
|
||||
friends?: string[];
|
||||
friendMaxLikes: number;
|
||||
friendTags?: string;
|
||||
enableFriendTags: boolean;
|
||||
targetTags: string[];
|
||||
}
|
||||
|
||||
// 更新任务数据
|
||||
export interface UpdateLikeTaskData extends CreateLikeTaskData {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// 任务配置
|
||||
export interface TaskConfig {
|
||||
interval: number;
|
||||
maxLikes: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
contentTypes: ContentType[];
|
||||
devices: string[];
|
||||
friends: string[];
|
||||
friendMaxLikes: number;
|
||||
friendTags: string;
|
||||
enableFriendTags: boolean;
|
||||
}
|
||||
|
||||
// API响应类型
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
// 分页响应类型
|
||||
export interface PaginatedResponse<T> {
|
||||
list: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
.task-list {
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s;
|
||||
&:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.task-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.task-title-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.task-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.task-status {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
&.active {
|
||||
background: #f6ffed;
|
||||
color: #52c41a;
|
||||
border: 1px solid #b7eb8f;
|
||||
}
|
||||
|
||||
&.inactive {
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
border: 1px solid #d9d9d9;
|
||||
}
|
||||
}
|
||||
|
||||
.task-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
|
||||
input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
transition: 0.4s;
|
||||
border-radius: 24px;
|
||||
|
||||
&:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: 0.4s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: #1890ff;
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
}
|
||||
|
||||
.menu-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
color: #666;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-dropdown {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 28px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
z-index: 100;
|
||||
min-width: 120px;
|
||||
padding: 4px;
|
||||
border: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
gap: 8px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: #ff4d4f;
|
||||
|
||||
&:hover {
|
||||
background: #fff2f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.task-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.task-stats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.stats-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stats-icon {
|
||||
font-size: 16px;
|
||||
|
||||
&.blue {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
&.green {
|
||||
color: #52c41a;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stats-value {
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60vh;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
color: #d9d9d9;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-subtext {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
.task-info {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.task-stats {
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.search-section {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
padding: 0 12px;
|
||||
}
|
||||
}
|
||||
@@ -1,380 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button, Toast, SpinLoading, Dialog, Card } from "antd-mobile";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import { Input } from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
EyeOutlined,
|
||||
EditOutlined,
|
||||
MoreOutlined,
|
||||
LikeOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import {
|
||||
fetchAutoLikeTasks,
|
||||
deleteAutoLikeTask,
|
||||
toggleAutoLikeTask,
|
||||
copyAutoLikeTask,
|
||||
} from "./api";
|
||||
import { LikeTask } from "./data";
|
||||
import style from "./index.module.scss";
|
||||
|
||||
// 卡片菜单组件
|
||||
interface CardMenuProps {
|
||||
onView: () => void;
|
||||
onEdit: () => void;
|
||||
onCopy: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
const CardMenu: React.FC<CardMenuProps> = ({
|
||||
onView,
|
||||
onEdit,
|
||||
onCopy,
|
||||
onDelete,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
if (open) document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<button onClick={() => setOpen(v => !v)} className={style["menu-btn"]}>
|
||||
<MoreOutlined />
|
||||
</button>
|
||||
{open && (
|
||||
<div ref={menuRef} className={style["menu-dropdown"]}>
|
||||
<div
|
||||
onClick={() => {
|
||||
onView();
|
||||
setOpen(false);
|
||||
}}
|
||||
className={style["menu-item"]}
|
||||
>
|
||||
<EyeOutlined />
|
||||
查看
|
||||
</div>
|
||||
<div
|
||||
onClick={() => {
|
||||
onEdit();
|
||||
setOpen(false);
|
||||
}}
|
||||
className={style["menu-item"]}
|
||||
>
|
||||
<EditOutlined />
|
||||
编辑
|
||||
</div>
|
||||
<div
|
||||
onClick={() => {
|
||||
onCopy();
|
||||
setOpen(false);
|
||||
}}
|
||||
className={style["menu-item"]}
|
||||
>
|
||||
<CopyOutlined />
|
||||
复制
|
||||
</div>
|
||||
<div
|
||||
onClick={() => {
|
||||
onDelete();
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`${style["menu-item"]} ${style["danger"]}`}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
删除
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AutoLike: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [tasks, setTasks] = useState<LikeTask[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 获取任务列表
|
||||
const fetchTasks = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const Res: any = await fetchAutoLikeTasks();
|
||||
// 直接就是任务数组,无需再解包
|
||||
const mappedTasks = Res?.list?.map((task: any) => ({
|
||||
...task,
|
||||
status: task.status || 2, // 默认为关闭状态
|
||||
deviceCount: task.deviceCount || 0,
|
||||
targetGroup: task.targetGroup || "全部好友",
|
||||
likeInterval: task.likeInterval || 60,
|
||||
maxLikesPerDay: task.maxLikesPerDay || 100,
|
||||
lastLikeTime: task.lastLikeTime || "暂无",
|
||||
createTime: task.createTime || "",
|
||||
updateTime: task.updateTime || "",
|
||||
todayLikeCount: task.todayLikeCount || 0,
|
||||
totalLikeCount: task.totalLikeCount || 0,
|
||||
}));
|
||||
setTasks(mappedTasks);
|
||||
} catch (error) {
|
||||
console.error("获取自动点赞任务失败:", error);
|
||||
Toast.show({
|
||||
content: "获取任务列表失败",
|
||||
position: "top",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
}, []);
|
||||
|
||||
// 删除任务
|
||||
const handleDelete = async (id: string) => {
|
||||
const result = await Dialog.confirm({
|
||||
content: "确定要删除这个任务吗?",
|
||||
confirmText: "删除",
|
||||
cancelText: "取消",
|
||||
});
|
||||
|
||||
if (result) {
|
||||
try {
|
||||
await deleteAutoLikeTask(id);
|
||||
Toast.show({
|
||||
content: "删除成功",
|
||||
position: "top",
|
||||
});
|
||||
fetchTasks(); // 重新获取列表
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
content: "删除失败",
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 编辑任务
|
||||
const handleEdit = (taskId: string) => {
|
||||
navigate(`/workspace/auto-like/edit/${taskId}`);
|
||||
};
|
||||
|
||||
// 查看任务
|
||||
const handleView = (taskId: string) => {
|
||||
navigate(`/workspace/auto-like/record/${taskId}`);
|
||||
};
|
||||
|
||||
// 复制任务
|
||||
const handleCopy = async (id: string) => {
|
||||
try {
|
||||
await copyAutoLikeTask(id);
|
||||
Toast.show({
|
||||
content: "复制成功",
|
||||
position: "top",
|
||||
});
|
||||
fetchTasks(); // 重新获取列表
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
content: "复制失败",
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 切换任务状态
|
||||
const toggleTaskStatus = async (id: string, status: number) => {
|
||||
try {
|
||||
await toggleAutoLikeTask({ id });
|
||||
Toast.show({
|
||||
content: status === 1 ? "已暂停" : "已启动",
|
||||
position: "top",
|
||||
});
|
||||
fetchTasks(); // 重新获取列表
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
content: "操作失败",
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 创建新任务
|
||||
const handleCreateNew = () => {
|
||||
navigate("/workspace/auto-like/new");
|
||||
};
|
||||
|
||||
// 过滤任务
|
||||
const filteredTasks = tasks.filter(task =>
|
||||
task.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
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"]}>
|
||||
{/* 任务列表 */}
|
||||
<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 => (
|
||||
<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 ? "进行中" : "已暂停"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style["task-controls"]}>
|
||||
<label className={style["switch"]}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Number(task.status) === 1}
|
||||
onChange={() =>
|
||||
toggleTaskStatus(task.id, Number(task.status))
|
||||
}
|
||||
/>
|
||||
<span className={style["slider"]}></span>
|
||||
</label>
|
||||
<CardMenu
|
||||
onView={() => handleView(task.id)}
|
||||
onEdit={() => handleEdit(task.id)}
|
||||
onCopy={() => handleCopy(task.id)}
|
||||
onDelete={() => handleDelete(task.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style["task-info"]}>
|
||||
<div className={style["info-section"]}>
|
||||
<div className={style["info-item"]}>
|
||||
<span className={style["info-label"]}>执行设备:</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>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["info-section"]}>
|
||||
<div className={style["info-item"]}>
|
||||
<span className={style["info-label"]}>点赞间隔:</span>
|
||||
<span className={style["info-value"]}>
|
||||
{task.likeInterval} 秒
|
||||
</span>
|
||||
</div>
|
||||
<div className={style["info-item"]}>
|
||||
<span className={style["info-label"]}>每日上限:</span>
|
||||
<span className={style["info-value"]}>
|
||||
{task.maxLikesPerDay} 次
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style["task-stats"]}>
|
||||
<div className={style["stats-item"]}>
|
||||
<LikeOutlined
|
||||
className={`${style["stats-icon"]} ${style["blue"]}`}
|
||||
/>
|
||||
<span className={style["stats-label"]}>今日点赞:</span>
|
||||
<span className={style["stats-value"]}>
|
||||
{task.lastLikeTime}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style["stats-item"]}>
|
||||
<LikeOutlined
|
||||
className={`${style["stats-icon"]} ${style["green"]}`}
|
||||
/>
|
||||
<span className={style["stats-label"]}>总点赞数:</span>
|
||||
<span className={style["stats-value"]}>
|
||||
{task.totalLikeCount || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutoLike;
|
||||
@@ -1,21 +0,0 @@
|
||||
import request from "@/api/request";
|
||||
import {
|
||||
CreateLikeTaskData,
|
||||
UpdateLikeTaskData,
|
||||
LikeTask,
|
||||
} from "@/pages/workspace/auto-like/record/data";
|
||||
|
||||
// 获取自动点赞任务详情
|
||||
export function fetchAutoLikeTaskDetail(id: string): Promise<LikeTask | null> {
|
||||
return request("/v1/workbench/detail", { id }, "GET");
|
||||
}
|
||||
|
||||
// 创建自动点赞任务
|
||||
export function createAutoLikeTask(data: CreateLikeTaskData): Promise<any> {
|
||||
return request("/v1/workbench/create", { ...data, type: 1 }, "POST");
|
||||
}
|
||||
|
||||
// 更新自动点赞任务
|
||||
export function updateAutoLikeTask(data: UpdateLikeTaskData): Promise<any> {
|
||||
return request("/v1/workbench/update", { ...data, type: 1 }, "POST");
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||
import { FriendSelectionItem } from "@/components/FriendSelection/data";
|
||||
|
||||
// 自动点赞任务状态
|
||||
export type LikeTaskStatus = 1 | 2; // 1: 开启, 2: 关闭
|
||||
|
||||
// 内容类型
|
||||
export type ContentType = "text" | "image" | "video" | "link";
|
||||
|
||||
// 设备信息
|
||||
export interface Device {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "online" | "offline";
|
||||
lastActive: string;
|
||||
}
|
||||
|
||||
// 好友信息
|
||||
export interface Friend {
|
||||
id: string;
|
||||
nickname: string;
|
||||
wechatId: string;
|
||||
avatar: string;
|
||||
tags: string[];
|
||||
region: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
// 点赞记录
|
||||
export interface LikeRecord {
|
||||
id: string;
|
||||
workbenchId: string;
|
||||
momentsId: string;
|
||||
snsId: string;
|
||||
wechatAccountId: string;
|
||||
wechatFriendId: string;
|
||||
likeTime: string;
|
||||
content: string;
|
||||
resUrls: string[];
|
||||
momentTime: string;
|
||||
userName: string;
|
||||
operatorName: string;
|
||||
operatorAvatar: string;
|
||||
friendName: string;
|
||||
friendAvatar: string;
|
||||
}
|
||||
|
||||
// 自动点赞任务
|
||||
export interface LikeTask {
|
||||
id: string;
|
||||
name: string;
|
||||
status: LikeTaskStatus;
|
||||
deviceCount: number;
|
||||
targetGroup: string;
|
||||
likeCount: number;
|
||||
lastLikeTime: string;
|
||||
createTime: string;
|
||||
creator: string;
|
||||
likeInterval: number;
|
||||
maxLikesPerDay: number;
|
||||
timeRange: { start: string; end: string };
|
||||
contentTypes: ContentType[];
|
||||
targetTags: string[];
|
||||
devices: string[];
|
||||
friends: string[];
|
||||
friendMaxLikes: number;
|
||||
friendTags: string;
|
||||
enableFriendTags: boolean;
|
||||
todayLikeCount: number;
|
||||
totalLikeCount: number;
|
||||
updateTime: string;
|
||||
}
|
||||
|
||||
// 创建任务数据
|
||||
export interface CreateLikeTaskData {
|
||||
name: string;
|
||||
interval: number;
|
||||
maxLikes: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
contentTypes: ContentType[];
|
||||
deviceGroups: number[];
|
||||
deviceGroupsOptions: DeviceSelectionItem[];
|
||||
friendsGroups: number[];
|
||||
friendsGroupsOptions: FriendSelectionItem[];
|
||||
friendMaxLikes: number;
|
||||
friendTags?: string;
|
||||
enableFriendTags: boolean;
|
||||
targetTags: string[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// 更新任务数据
|
||||
export interface UpdateLikeTaskData extends CreateLikeTaskData {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// 任务配置
|
||||
export interface TaskConfig {
|
||||
interval: number;
|
||||
maxLikes: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
contentTypes: ContentType[];
|
||||
devices: string[];
|
||||
friends: string[];
|
||||
friendMaxLikes: number;
|
||||
friendTags: string;
|
||||
enableFriendTags: boolean;
|
||||
}
|
||||
|
||||
// API响应类型
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
// 分页响应类型
|
||||
export interface PaginatedResponse<T> {
|
||||
list: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
@@ -1,427 +0,0 @@
|
||||
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 Layout from "@/components/Layout/Layout";
|
||||
import DeviceSelection from "@/components/DeviceSelection";
|
||||
import FriendSelection from "@/components/FriendSelection";
|
||||
import StepIndicator from "@/components/StepIndicator";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import {
|
||||
createAutoLikeTask,
|
||||
updateAutoLikeTask,
|
||||
fetchAutoLikeTaskDetail,
|
||||
} from "./api";
|
||||
import { CreateLikeTaskData, ContentType } from "./data";
|
||||
import style from "./new.module.scss";
|
||||
|
||||
const contentTypeLabels: Record<ContentType, string> = {
|
||||
text: "文字",
|
||||
image: "图片",
|
||||
video: "视频",
|
||||
link: "链接",
|
||||
};
|
||||
|
||||
const steps = [
|
||||
{ id: 1, title: "基础设置", subtitle: "设置点赞规则" },
|
||||
{ id: 2, title: "设备选择", subtitle: "选择执行设备" },
|
||||
{ id: 3, title: "人群选择", subtitle: "选择目标人群" },
|
||||
];
|
||||
|
||||
const NewAutoLike: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isEditMode = !!id;
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(isEditMode);
|
||||
const [autoEnabled, setAutoEnabled] = useState(false);
|
||||
const [formData, setFormData] = useState<CreateLikeTaskData>({
|
||||
name: "",
|
||||
interval: 5,
|
||||
maxLikes: 200,
|
||||
startTime: "08:00",
|
||||
endTime: "22:00",
|
||||
contentTypes: ["text", "image", "video"],
|
||||
deviceGroups: [],
|
||||
deviceGroupsOptions: [],
|
||||
friendsGroups: [],
|
||||
friendsGroupsOptions: [],
|
||||
targetTags: [],
|
||||
friendMaxLikes: 10,
|
||||
enableFriendTags: false,
|
||||
friendTags: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchTaskDetail();
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const fetchTaskDetail = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const taskDetail = await fetchAutoLikeTaskDetail(id!);
|
||||
if (taskDetail) {
|
||||
const config = (taskDetail as any).config || taskDetail;
|
||||
setFormData({
|
||||
name: taskDetail.name || "",
|
||||
interval: config.likeInterval || config.interval || 5,
|
||||
maxLikes: config.maxLikesPerDay || config.maxLikes || 200,
|
||||
startTime: config.timeRange?.start || config.startTime || "08:00",
|
||||
endTime: config.timeRange?.end || config.endTime || "22:00",
|
||||
contentTypes: config.contentTypes || ["text", "image", "video"],
|
||||
deviceGroups: config.deviceGroups || [],
|
||||
deviceGroupsOptions: config.deviceGroupsOptions || [],
|
||||
friendsGroups: config.friendsgroups || [],
|
||||
friendsGroupsOptions: config.friendsGroupsOptions || [],
|
||||
targetTags: config.targetTags || [],
|
||||
friendMaxLikes: config.friendMaxLikes || 10,
|
||||
enableFriendTags: config.enableFriendTags || false,
|
||||
friendTags: config.friendTags || "",
|
||||
});
|
||||
setAutoEnabled(
|
||||
(taskDetail as any).status === 1 ||
|
||||
(taskDetail as any).status === "running",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error("获取任务详情失败");
|
||||
navigate("/workspace/auto-like");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateFormData = (data: Partial<CreateLikeTaskData>) => {
|
||||
setFormData(prev => ({ ...prev, ...data }));
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
setCurrentStep(prev => Math.min(prev + 1, 3));
|
||||
// 滚动到顶部
|
||||
const mainElement = document.querySelector("main");
|
||||
if (mainElement) {
|
||||
mainElement.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
setCurrentStep(prev => Math.max(prev - 1, 1));
|
||||
// 滚动到顶部
|
||||
const mainElement = document.querySelector("main");
|
||||
if (mainElement) {
|
||||
mainElement.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = async () => {
|
||||
if (!formData.name.trim()) {
|
||||
message.warning("请输入任务名称");
|
||||
return;
|
||||
}
|
||||
if (!formData.deviceGroups || formData.deviceGroups.length === 0) {
|
||||
message.warning("请选择执行设备");
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (isEditMode) {
|
||||
await updateAutoLikeTask({ ...formData, id });
|
||||
message.success("更新成功");
|
||||
} else {
|
||||
await createAutoLikeTask(formData);
|
||||
message.success("创建成功");
|
||||
}
|
||||
navigate("/workspace/auto-like");
|
||||
} catch (error) {
|
||||
message.error(isEditMode ? "更新失败" : "创建失败");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 步骤器
|
||||
const renderStepIndicator = () => (
|
||||
<StepIndicator steps={steps} currentStep={currentStep} />
|
||||
);
|
||||
|
||||
// 步骤1:基础设置
|
||||
const renderBasicSettings = () => (
|
||||
<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>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>点赞间隔</div>
|
||||
<div className={style.counterRow}>
|
||||
<Button
|
||||
icon={<MinusOutlined />}
|
||||
onClick={() =>
|
||||
handleUpdateFormData({
|
||||
interval: Math.max(1, formData.interval - 1),
|
||||
})
|
||||
}
|
||||
className={style.counterBtn}
|
||||
/>
|
||||
<div className={style.counterInputWrapper}>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={60}
|
||||
value={formData.interval}
|
||||
onChange={e =>
|
||||
handleUpdateFormData({
|
||||
interval: Number.parseInt(e.target.value) || 1,
|
||||
})
|
||||
}
|
||||
className={style.counterInput}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() =>
|
||||
handleUpdateFormData({ interval: formData.interval + 1 })
|
||||
}
|
||||
className={style.counterBtn}
|
||||
/>
|
||||
<span className={style.counterUnit}>秒</span>
|
||||
</div>
|
||||
<div className={style.counterTip}>设置两次点赞之间的最小时间间隔</div>
|
||||
</div>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>每日最大点赞数</div>
|
||||
<div className={style.counterRow}>
|
||||
<Button
|
||||
icon={<MinusOutlined />}
|
||||
onClick={() =>
|
||||
handleUpdateFormData({
|
||||
maxLikes: Math.max(1, formData.maxLikes - 10),
|
||||
})
|
||||
}
|
||||
className={style.counterBtn}
|
||||
/>
|
||||
<div className={style.counterInputWrapper}>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={500}
|
||||
value={formData.maxLikes}
|
||||
onChange={e =>
|
||||
handleUpdateFormData({
|
||||
maxLikes: Number.parseInt(e.target.value) || 1,
|
||||
})
|
||||
}
|
||||
className={style.counterInput}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() =>
|
||||
handleUpdateFormData({ maxLikes: formData.maxLikes + 10 })
|
||||
}
|
||||
className={style.counterBtn}
|
||||
/>
|
||||
<span className={style.counterUnit}>次/天</span>
|
||||
</div>
|
||||
<div className={style.counterTip}>设置每天最多点赞的次数</div>
|
||||
</div>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>点赞时间范围</div>
|
||||
<div className={style.timeRow}>
|
||||
<Input
|
||||
type="time"
|
||||
value={formData.startTime}
|
||||
onChange={e => handleUpdateFormData({ startTime: e.target.value })}
|
||||
className={style.inputTime}
|
||||
/>
|
||||
<span className={style.timeSeparator}>至</span>
|
||||
<Input
|
||||
type="time"
|
||||
value={formData.endTime}
|
||||
onChange={e => handleUpdateFormData({ endTime: e.target.value })}
|
||||
className={style.inputTime}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.counterTip}>设置每天可以点赞的时间段</div>
|
||||
</div>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>点赞内容类型</div>
|
||||
<div className={style.contentTypes}>
|
||||
{(["text", "image", "video"] as ContentType[]).map(type => (
|
||||
<Button
|
||||
key={type}
|
||||
type={
|
||||
formData.contentTypes.includes(type) ? "primary" : "default"
|
||||
}
|
||||
ghost={!formData.contentTypes.includes(type)}
|
||||
className={style.contentTypeBtn}
|
||||
onClick={() => {
|
||||
const newTypes = formData.contentTypes.includes(type)
|
||||
? formData.contentTypes.filter(t => t !== type)
|
||||
: [...formData.contentTypes, type];
|
||||
handleUpdateFormData({ contentTypes: newTypes });
|
||||
}}
|
||||
>
|
||||
{contentTypeLabels[type]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className={style.counterTip}>选择要点赞的内容类型</div>
|
||||
</div>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.switchRow}>
|
||||
<span className={style.switchLabel}>启用好友标签</span>
|
||||
<Switch
|
||||
checked={formData.enableFriendTags}
|
||||
onChange={checked =>
|
||||
handleUpdateFormData({ enableFriendTags: checked })
|
||||
}
|
||||
className={style.switch}
|
||||
/>
|
||||
</div>
|
||||
{formData.enableFriendTags && (
|
||||
<div className={style.formItem}>
|
||||
<Input
|
||||
placeholder="请输入标签"
|
||||
value={formData.friendTags}
|
||||
onChange={e =>
|
||||
handleUpdateFormData({ friendTags: e.target.value })
|
||||
}
|
||||
className={style.input}
|
||||
/>
|
||||
<div className={style.counterTip}>只给有此标签的好友点赞</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.switchRow}>
|
||||
<span className={style.switchLabel}>自动开启</span>
|
||||
<Switch
|
||||
checked={autoEnabled}
|
||||
onChange={setAutoEnabled}
|
||||
className={style.switch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
onClick={handleNext}
|
||||
size="large"
|
||||
className={style.mainBtn}
|
||||
disabled={!formData.name.trim()}
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 步骤2:设备选择
|
||||
const renderDeviceSelection = () => (
|
||||
<div className={style.basicSection}>
|
||||
<div className={style.formItem}>
|
||||
<DeviceSelection
|
||||
selectedOptions={formData.deviceGroupsOptions}
|
||||
onSelect={devices =>
|
||||
handleUpdateFormData({
|
||||
deviceGroups: devices.map(v => v.id),
|
||||
deviceGroupsOptions: devices,
|
||||
})
|
||||
}
|
||||
showInput={true}
|
||||
showSelectedList={true}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handlePrev}
|
||||
className={style.prevBtn}
|
||||
size="large"
|
||||
style={{ marginRight: 16 }}
|
||||
>
|
||||
上一步
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleNext}
|
||||
className={style.nextBtn}
|
||||
size="large"
|
||||
disabled={formData.deviceGroups.length === 0}
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 步骤3:好友设置
|
||||
const renderFriendSettings = () => (
|
||||
<div className={style.basicSection}>
|
||||
<div className={style.formItem}>
|
||||
<FriendSelection
|
||||
selectedOptions={formData.friendsGroupsOptions || []}
|
||||
onSelect={friends =>
|
||||
handleUpdateFormData({
|
||||
friendsGroups: friends.map(f => f.id),
|
||||
friendsGroupsOptions: friends,
|
||||
})
|
||||
}
|
||||
deviceIds={formData.deviceGroups}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handlePrev}
|
||||
className={style.prevBtn}
|
||||
size="large"
|
||||
style={{ marginRight: 16 }}
|
||||
>
|
||||
上一步
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleComplete}
|
||||
className={style.completeBtn}
|
||||
size="large"
|
||||
loading={isSubmitting}
|
||||
disabled={
|
||||
!formData.friendsGroups || formData.friendsGroups.length === 0
|
||||
}
|
||||
>
|
||||
{isEditMode ? "更新任务" : "创建任务"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<NavCommon title={isEditMode ? "编辑自动点赞" : "新建自动点赞"} />
|
||||
{renderStepIndicator()}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={style.formBg}>
|
||||
{isLoading ? (
|
||||
<div className={style.formLoading}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{currentStep === 1 && renderBasicSettings()}
|
||||
{currentStep === 2 && renderDeviceSelection()}
|
||||
{currentStep === 3 && renderFriendSettings()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewAutoLike;
|
||||
@@ -1,232 +0,0 @@
|
||||
.formBg {
|
||||
background: #f8f6f3;
|
||||
min-height: 100vh;
|
||||
padding: 0 0 80px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.basicSection {
|
||||
background: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
padding: 24px 16px 0 16px;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.formItem {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.formLabel {
|
||||
font-size: 15px;
|
||||
color: #222;
|
||||
font-weight: 500;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.input {
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.timeRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.inputTime {
|
||||
width: 100px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.timeTo {
|
||||
margin: 0 8px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.counterRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.counterBtn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
font-size: 16px;
|
||||
color: #188eee;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: border 0.2s;
|
||||
}
|
||||
|
||||
.counterBtn:hover {
|
||||
border: 1px solid #188eee;
|
||||
}
|
||||
|
||||
.counterValue {
|
||||
width: 48px;
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.counterInputWrapper {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.counterInput {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
border-radius: 0;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.counterUnit {
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.timeSeparator {
|
||||
margin: 0 8px;
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.counterTip {
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.contentTypes {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.contentTypeTag {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.contentTypeTag:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.contentTypeTagActive {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
background: #e6f7ff;
|
||||
color: #188eee;
|
||||
border: 1px solid #91d5ff;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.switchRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.switchLabel {
|
||||
font-size: 15px;
|
||||
color: #222;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.switch {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.selectedTip {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.formStepBtnRow {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.prevBtn {
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.nextBtn {
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.completeBtn {
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.formLoading {
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.footerBtnBar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 30;
|
||||
background: #fff;
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.04);
|
||||
padding: 16px 24px 24px 24px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.prevBtn,
|
||||
.nextBtn,
|
||||
.completeBtn {
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
min-width: 120px;
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import request from "@/api/request";
|
||||
import {
|
||||
LikeTask,
|
||||
CreateLikeTaskData,
|
||||
UpdateLikeTaskData,
|
||||
LikeRecord,
|
||||
PaginatedResponse,
|
||||
} from "@/pages/workspace/auto-like/record/data";
|
||||
|
||||
// 获取自动点赞任务列表
|
||||
export function fetchAutoLikeTasks(
|
||||
params = { type: 1, page: 1, limit: 100 },
|
||||
): Promise<LikeTask[]> {
|
||||
return request("/v1/workbench/list", params, "GET");
|
||||
}
|
||||
|
||||
// 获取单个任务详情
|
||||
export function fetchAutoLikeTaskDetail(id: string): Promise<LikeTask | null> {
|
||||
return request("/v1/workbench/detail", { id }, "GET");
|
||||
}
|
||||
|
||||
// 创建自动点赞任务
|
||||
export function createAutoLikeTask(data: CreateLikeTaskData): Promise<any> {
|
||||
return request("/v1/workbench/create", { ...data, type: 1 }, "POST");
|
||||
}
|
||||
|
||||
// 更新自动点赞任务
|
||||
export function updateAutoLikeTask(data: UpdateLikeTaskData): Promise<any> {
|
||||
return request("/v1/workbench/update", { ...data, type: 1 }, "POST");
|
||||
}
|
||||
|
||||
// 删除自动点赞任务
|
||||
export function deleteAutoLikeTask(id: string): Promise<any> {
|
||||
return request("/v1/workbench/delete", { id }, "DELETE");
|
||||
}
|
||||
|
||||
// 切换任务状态
|
||||
export function toggleAutoLikeTask(id: string, status: string): Promise<any> {
|
||||
return request("/v1/workbench/update-status", { id, status }, "POST");
|
||||
}
|
||||
|
||||
// 复制自动点赞任务
|
||||
export function copyAutoLikeTask(id: string): Promise<any> {
|
||||
return request("/v1/workbench/copy", { id }, "POST");
|
||||
}
|
||||
|
||||
// 获取点赞记录
|
||||
export function fetchLikeRecords(
|
||||
workbenchId: string,
|
||||
page: number = 1,
|
||||
limit: number = 20,
|
||||
keyword?: string,
|
||||
): Promise<PaginatedResponse<LikeRecord>> {
|
||||
const params: any = {
|
||||
workbenchId,
|
||||
page: page.toString(),
|
||||
limit: limit.toString(),
|
||||
};
|
||||
if (keyword) {
|
||||
params.keyword = keyword;
|
||||
}
|
||||
return request("/v1/workbench/like-records", params, "GET");
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
// 自动点赞任务状态
|
||||
export type LikeTaskStatus = 1 | 2; // 1: 开启, 2: 关闭
|
||||
|
||||
// 内容类型
|
||||
export type ContentType = "text" | "image" | "video" | "link";
|
||||
|
||||
// 设备信息
|
||||
export interface Device {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "online" | "offline";
|
||||
lastActive: string;
|
||||
}
|
||||
|
||||
// 好友信息
|
||||
export interface Friend {
|
||||
id: string;
|
||||
nickname: string;
|
||||
wechatId: string;
|
||||
avatar: string;
|
||||
tags: string[];
|
||||
region: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
// 点赞记录
|
||||
export interface LikeRecord {
|
||||
id: string;
|
||||
workbenchId: string;
|
||||
momentsId: string;
|
||||
snsId: string;
|
||||
wechatAccountId: string;
|
||||
wechatFriendId: string;
|
||||
likeTime: string;
|
||||
content: string;
|
||||
resUrls: string[];
|
||||
momentTime: string;
|
||||
userName: string;
|
||||
operatorName: string;
|
||||
operatorAvatar: string;
|
||||
friendName: string;
|
||||
friendAvatar: string;
|
||||
}
|
||||
|
||||
// 自动点赞任务
|
||||
export interface LikeTask {
|
||||
id: string;
|
||||
name: string;
|
||||
status: LikeTaskStatus;
|
||||
deviceCount: number;
|
||||
targetGroup: string;
|
||||
likeCount: number;
|
||||
lastLikeTime: string;
|
||||
createTime: string;
|
||||
creator: string;
|
||||
likeInterval: number;
|
||||
maxLikesPerDay: number;
|
||||
timeRange: { start: string; end: string };
|
||||
contentTypes: ContentType[];
|
||||
targetTags: string[];
|
||||
devices: string[];
|
||||
friends: string[];
|
||||
friendMaxLikes: number;
|
||||
friendTags: string;
|
||||
enableFriendTags: boolean;
|
||||
todayLikeCount: number;
|
||||
totalLikeCount: number;
|
||||
updateTime: string;
|
||||
}
|
||||
|
||||
// 创建任务数据
|
||||
export interface CreateLikeTaskData {
|
||||
name: string;
|
||||
interval: number;
|
||||
maxLikes: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
contentTypes: ContentType[];
|
||||
devices: string[];
|
||||
friends?: string[];
|
||||
friendMaxLikes: number;
|
||||
friendTags?: string;
|
||||
enableFriendTags: boolean;
|
||||
targetTags: string[];
|
||||
}
|
||||
|
||||
// 更新任务数据
|
||||
export interface UpdateLikeTaskData extends CreateLikeTaskData {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// 任务配置
|
||||
export interface TaskConfig {
|
||||
interval: number;
|
||||
maxLikes: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
contentTypes: ContentType[];
|
||||
devices: string[];
|
||||
friends: string[];
|
||||
friendMaxLikes: number;
|
||||
friendTags: string;
|
||||
enableFriendTags: boolean;
|
||||
}
|
||||
|
||||
// API响应类型
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
// 分页响应类型
|
||||
export interface PaginatedResponse<T> {
|
||||
list: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Card,
|
||||
Badge,
|
||||
Avatar,
|
||||
Skeleton,
|
||||
message,
|
||||
Spin,
|
||||
Divider,
|
||||
Pagination,
|
||||
} from "antd";
|
||||
import {
|
||||
LikeOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import styles from "./record.module.scss";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import { fetchLikeRecords } from "./api";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString: string) => {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch (error) {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
export default function AutoLikeRecord() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [records, setRecords] = useState<any[]>([]);
|
||||
const [recordsLoading, setRecordsLoading] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const pageSize = 10;
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setRecordsLoading(true);
|
||||
fetchLikeRecords(id, 1, pageSize)
|
||||
.then((response: any) => {
|
||||
setRecords(response.list || []);
|
||||
setTotal(response.total || 0);
|
||||
setCurrentPage(1);
|
||||
})
|
||||
.catch(() => {
|
||||
message.error("获取点赞记录失败,请稍后重试");
|
||||
})
|
||||
.finally(() => setRecordsLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setCurrentPage(1);
|
||||
fetchLikeRecords(id!, 1, pageSize, searchTerm)
|
||||
.then((response: any) => {
|
||||
setRecords(response.list || []);
|
||||
setTotal(response.total || 0);
|
||||
setCurrentPage(1);
|
||||
})
|
||||
.catch(() => {
|
||||
message.error("获取点赞记录失败,请稍后重试");
|
||||
});
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchLikeRecords(id!, currentPage, pageSize, searchTerm)
|
||||
.then((response: any) => {
|
||||
setRecords(response.list || []);
|
||||
setTotal(response.total || 0);
|
||||
})
|
||||
.catch(() => {
|
||||
message.error("获取点赞记录失败,请稍后重试");
|
||||
});
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
fetchLikeRecords(id!, newPage, pageSize, searchTerm)
|
||||
.then((response: any) => {
|
||||
setRecords(response.list || []);
|
||||
setTotal(response.total || 0);
|
||||
setCurrentPage(newPage);
|
||||
})
|
||||
.catch(() => {
|
||||
message.error("获取点赞记录失败,请稍后重试");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<NavCommon title="点赞记录" />
|
||||
<div className={styles.headerSearchBar}>
|
||||
<div className={styles.headerSearchInputWrap}>
|
||||
<Input
|
||||
prefix={<SearchOutlined className={styles.headerSearchIcon} />}
|
||||
placeholder="搜索好友昵称或内容"
|
||||
className={styles.headerSearchInput}
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
onPressEnter={handleSearch}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
icon={<ReloadOutlined spin={recordsLoading} />}
|
||||
onClick={handleRefresh}
|
||||
loading={recordsLoading}
|
||||
type="default"
|
||||
shape="circle"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<div className={styles.footerPagination}>
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
onChange={handlePageChange}
|
||||
showSizeChanger={false}
|
||||
showQuickJumper
|
||||
size="default"
|
||||
className={styles.pagination}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={styles.bgWrap}>
|
||||
<div className={styles.contentWrap}>
|
||||
{recordsLoading ? (
|
||||
<div className={styles.skeletonWrap}>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className={styles.skeletonCard}>
|
||||
<div className={styles.skeletonCardHeader}>
|
||||
<Skeleton.Avatar
|
||||
active
|
||||
size={40}
|
||||
className={styles.skeletonAvatar}
|
||||
/>
|
||||
<div className={styles.skeletonNameWrap}>
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
className={styles.skeletonName}
|
||||
style={{ width: 96 }}
|
||||
/>
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
className={styles.skeletonSub}
|
||||
style={{ width: 64 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Divider className={styles.skeletonSep} />
|
||||
<div className={styles.skeletonContentWrap}>
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
className={styles.skeletonContent1}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
className={styles.skeletonContent2}
|
||||
style={{ width: "75%" }}
|
||||
/>
|
||||
<div className={styles.skeletonImgWrap}>
|
||||
<Skeleton.Image
|
||||
active
|
||||
className={styles.skeletonImg}
|
||||
style={{ width: 80, height: 80 }}
|
||||
/>
|
||||
<Skeleton.Image
|
||||
active
|
||||
className={styles.skeletonImg}
|
||||
style={{ width: 80, height: 80 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : records.length === 0 ? (
|
||||
<div className={styles.emptyWrap}>
|
||||
<LikeOutlined className={styles.emptyIcon} />
|
||||
<p className={styles.emptyText}>暂无点赞记录</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{records.map(record => (
|
||||
<div key={record.id} className={styles.recordCard}>
|
||||
<div className={styles.recordCardHeader}>
|
||||
<div className={styles.recordCardHeaderLeft}>
|
||||
<Avatar
|
||||
src={record.friendAvatar || undefined}
|
||||
icon={<UserOutlined />}
|
||||
size={40}
|
||||
className={styles.avatarImg}
|
||||
/>
|
||||
<div className={styles.friendInfo}>
|
||||
<div
|
||||
className={styles.friendName}
|
||||
title={record.friendName}
|
||||
>
|
||||
{record.friendName}
|
||||
</div>
|
||||
<div className={styles.friendSub}>内容发布者</div>
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
className={styles.timeBadge}
|
||||
count={formatDate(record.momentTime || record.likeTime)}
|
||||
style={{
|
||||
background: "#e8f0fe",
|
||||
color: "#333",
|
||||
fontWeight: 400,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Divider className={styles.cardSep} />
|
||||
<div className={styles.cardContent}>
|
||||
{record.content && (
|
||||
<p className={styles.contentText}>{record.content}</p>
|
||||
)}
|
||||
{Array.isArray(record.resUrls) &&
|
||||
record.resUrls.length > 0 && (
|
||||
<div
|
||||
className={
|
||||
`${styles.imgGrid} ` +
|
||||
(record.resUrls.length === 1
|
||||
? styles.grid1
|
||||
: record.resUrls.length === 2
|
||||
? styles.grid2
|
||||
: record.resUrls.length <= 3
|
||||
? styles.grid3
|
||||
: record.resUrls.length <= 6
|
||||
? styles.grid6
|
||||
: styles.grid9)
|
||||
}
|
||||
>
|
||||
{record.resUrls
|
||||
.slice(0, 9)
|
||||
.map((image: string, idx: number) => (
|
||||
<div key={idx} className={styles.imgItem}>
|
||||
<img
|
||||
src={image}
|
||||
alt={`内容图片 ${idx + 1}`}
|
||||
className={styles.img}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.operatorWrap}>
|
||||
<Avatar
|
||||
src={record.operatorAvatar || undefined}
|
||||
icon={<UserOutlined />}
|
||||
size={32}
|
||||
className={styles.operatorAvatar}
|
||||
/>
|
||||
<div className={styles.operatorInfo}>
|
||||
<span
|
||||
className={styles.operatorName}
|
||||
title={record.operatorName}
|
||||
>
|
||||
{record.operatorName}
|
||||
</span>
|
||||
<span className={styles.operatorAction}>
|
||||
<LikeOutlined
|
||||
style={{ color: "red", marginRight: 4 }}
|
||||
/>
|
||||
已赞
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
// 搜索栏
|
||||
.headerSearchBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
.headerSearchInputWrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
.headerSearchIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 10px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #a3a3a3;
|
||||
}
|
||||
.headerSearchInput {
|
||||
padding-left: 32px !important;
|
||||
}
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// 分页
|
||||
.footerPagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
background: #fff;
|
||||
}
|
||||
.pagination {
|
||||
:global(.ant-pagination-item) {
|
||||
border-radius: 6px;
|
||||
}
|
||||
:global(.ant-pagination-item-active) {
|
||||
background: #1890ff;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
:global(.ant-pagination-prev),
|
||||
:global(.ant-pagination-next) {
|
||||
border-radius: 6px;
|
||||
}
|
||||
:global(.ant-pagination-jump-prev),
|
||||
:global(.ant-pagination-jump-next) {
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
// 背景和内容
|
||||
.bgWrap {
|
||||
background: #f7f7fa;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
.contentWrap {
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
// 骨架屏
|
||||
.skeletonWrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.skeletonCard {
|
||||
padding: 0px;
|
||||
}
|
||||
.skeletonCardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.skeletonAvatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.skeletonNameWrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.skeletonName {
|
||||
width: 96px;
|
||||
height: 16px;
|
||||
}
|
||||
.skeletonSub {
|
||||
width: 64px;
|
||||
height: 12px;
|
||||
}
|
||||
.skeletonSep {
|
||||
margin: 12px 0;
|
||||
}
|
||||
.skeletonContentWrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.skeletonContent1 {
|
||||
width: 100%;
|
||||
height: 16px;
|
||||
}
|
||||
.skeletonContent2 {
|
||||
width: 75%;
|
||||
height: 16px;
|
||||
}
|
||||
.skeletonImgWrap {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.skeletonImg {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
// 空状态
|
||||
.emptyWrap {
|
||||
text-align: center;
|
||||
padding: 48px 0;
|
||||
}
|
||||
.emptyIcon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: #e5e7eb;
|
||||
margin: 0 auto 12px auto;
|
||||
}
|
||||
.emptyText {
|
||||
color: #888;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
// 记录卡片
|
||||
.recordCard {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
padding: 16px;
|
||||
}
|
||||
.recordCardHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.recordCardHeaderLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
max-width: 65%;
|
||||
}
|
||||
.avatarImg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.friendInfo {
|
||||
min-width: 0;
|
||||
}
|
||||
.friendName {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.friendSub {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
}
|
||||
.timeBadge {
|
||||
background: #e8f0fe;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cardSep {
|
||||
margin: 12px 0;
|
||||
}
|
||||
.cardContent {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.contentText {
|
||||
color: #444;
|
||||
margin-bottom: 12px;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.imgGrid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.grid1 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.grid2 {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.grid3 {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
}
|
||||
.grid6 {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
.grid9 {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr 1fr;
|
||||
}
|
||||
.imgItem {
|
||||
position: relative;
|
||||
aspect-ratio: 1/1;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
// 操作人
|
||||
.operatorWrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 16px;
|
||||
padding: 8px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.operatorAvatar {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.operatorInfo {
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
.operatorName {
|
||||
font-weight: 500;
|
||||
max-width: 100%;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.operatorAction {
|
||||
color: #888;
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 2px;
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
.container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
.actionCard {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
|
||||
:global(.adm-card-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.taskHeader {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.taskInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.taskName {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.taskStatus {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
background-color: rgba(82, 196, 26, 0.1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
|
||||
:global(.adm-button) {
|
||||
flex: 1;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
overflow: hidden;
|
||||
|
||||
:global(.adm-tabs-header) {
|
||||
background: #fafafa;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
:global(.adm-tabs-tab) {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
|
||||
&.adm-tabs-tab-active {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.adm-tabs-tab-line) {
|
||||
background: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.tabContent {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.infoCard {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f0f0f0;
|
||||
|
||||
:global(.adm-card-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.infoList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.infoItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #666;
|
||||
margin-right: 12px;
|
||||
min-width: 80px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #333;
|
||||
flex: 1;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.recordList {
|
||||
:global(.adm-list-body) {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.recordItem {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f0f0f0;
|
||||
margin-bottom: 12px;
|
||||
|
||||
:global(.adm-list-item-content) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.recordHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.recordInfo {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.recordDevice {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.recordTime {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.recordContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.recordDetail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
|
||||
.label {
|
||||
min-width: 70px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.recordError {
|
||||
font-size: 13px;
|
||||
color: #ff4d4f;
|
||||
background: #fff2f0;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #ffccc7;
|
||||
}
|
||||
|
||||
.loadingMore {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
// 状态标签样式覆盖
|
||||
:global {
|
||||
.adm-tag {
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
|
||||
&.adm-tag-success {
|
||||
background: #f6ffed;
|
||||
border-color: #b7eb8f;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
&.adm-tag-danger {
|
||||
background: #fff2f0;
|
||||
border-color: #ffccc7;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
&.adm-tag-warning {
|
||||
background: #fff7e6;
|
||||
border-color: #ffd591;
|
||||
color: #fa8c16;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 下拉刷新样式
|
||||
:global(.adm-pull-to-refresh) {
|
||||
.adm-pull-to-refresh-indicator {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 480px) {
|
||||
.taskName {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.taskStatus {
|
||||
font-size: 13px;
|
||||
padding: 3px 10px;
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.infoItem {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.label {
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.recordDevice {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.recordTime {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.recordDetail {
|
||||
font-size: 12px;
|
||||
|
||||
.label {
|
||||
min-width: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
.recordError {
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
// 空状态样式
|
||||
:global(.adm-empty) {
|
||||
padding: 40px 20px;
|
||||
|
||||
.adm-empty-image {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.adm-empty-description {
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
@@ -1,467 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Button,
|
||||
Toast,
|
||||
Card,
|
||||
Tabs,
|
||||
List,
|
||||
Tag,
|
||||
Space,
|
||||
InfiniteScroll,
|
||||
PullToRefresh,
|
||||
Empty,
|
||||
SpinLoading,
|
||||
} from "antd-mobile";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import {
|
||||
fetchContactImportTaskDetail,
|
||||
fetchImportRecords,
|
||||
triggerImport,
|
||||
toggleContactImportTask,
|
||||
} from "../list/api";
|
||||
import { ContactImportRecord } from "../list/data";
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
EditOutlined,
|
||||
ReloadOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import style from "./index.module.scss";
|
||||
|
||||
const ContactImportDetail: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
const [task, setTask] = useState<any>(null);
|
||||
const [records, setRecords] = useState<ContactImportRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [recordsLoading, setRecordsLoading] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [activeTab, setActiveTab] = useState("info");
|
||||
|
||||
// 获取任务详情
|
||||
const loadTaskDetail = async () => {
|
||||
if (!id) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetchContactImportTaskDetail(id);
|
||||
const data = response?.data || response;
|
||||
setTask(data);
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
content: "获取任务详情失败",
|
||||
icon: "fail",
|
||||
});
|
||||
navigate("/workspace/contact-import/list");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取导入记录
|
||||
const loadRecords = async (pageNum: number = 1, reset: boolean = false) => {
|
||||
if (!id) return;
|
||||
|
||||
setRecordsLoading(true);
|
||||
try {
|
||||
const response = await fetchImportRecords(id, pageNum, 20);
|
||||
const data = response?.data || response;
|
||||
if (reset) {
|
||||
setRecords(data.list || []);
|
||||
} else {
|
||||
setRecords(prev => [...prev, ...(data.list || [])]);
|
||||
}
|
||||
setHasMore(data.list.length === 20);
|
||||
setPage(pageNum);
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
content: "获取记录失败",
|
||||
icon: "fail",
|
||||
});
|
||||
} finally {
|
||||
setRecordsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 切换任务状态
|
||||
const handleToggleStatus = async () => {
|
||||
if (!task) return;
|
||||
|
||||
try {
|
||||
await toggleContactImportTask({
|
||||
id: task.id,
|
||||
status: task.status === 1 ? 2 : 1,
|
||||
});
|
||||
Toast.show({
|
||||
content: task.status === 1 ? "任务已暂停" : "任务已启动",
|
||||
icon: "success",
|
||||
});
|
||||
loadTaskDetail();
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
content: "操作失败",
|
||||
icon: "fail",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 手动触发导入
|
||||
const handleTriggerImport = async () => {
|
||||
if (!task) return;
|
||||
|
||||
try {
|
||||
await triggerImport(task.id);
|
||||
Toast.show({
|
||||
content: "导入任务已触发",
|
||||
icon: "success",
|
||||
});
|
||||
setTimeout(() => {
|
||||
loadTaskDetail();
|
||||
loadRecords(1, true);
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
content: "触发失败",
|
||||
icon: "fail",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 刷新数据
|
||||
const handleRefresh = async () => {
|
||||
await loadTaskDetail();
|
||||
if (activeTab === "records") {
|
||||
await loadRecords(1, true);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载更多记录
|
||||
const loadMoreRecords = async () => {
|
||||
await loadRecords(page + 1);
|
||||
};
|
||||
|
||||
// 获取状态文本和颜色
|
||||
const getStatusInfo = (status: number) => {
|
||||
return status === 1
|
||||
? { text: "运行中", color: "#52c41a" }
|
||||
: { text: "已暂停", color: "#faad14" };
|
||||
};
|
||||
|
||||
// 获取记录状态图标
|
||||
const getRecordStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "success":
|
||||
return <CheckCircleOutlined style={{ color: "#52c41a" }} />;
|
||||
case "failed":
|
||||
return <CloseCircleOutlined style={{ color: "#ff4d4f" }} />;
|
||||
case "pending":
|
||||
return <ClockCircleOutlined style={{ color: "#faad14" }} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取记录状态标签
|
||||
const getRecordStatusTag = (status: string) => {
|
||||
switch (status) {
|
||||
case "success":
|
||||
return <Tag color="success">成功</Tag>;
|
||||
case "failed":
|
||||
return <Tag color="danger">失败</Tag>;
|
||||
case "pending":
|
||||
return <Tag color="warning">进行中</Tag>;
|
||||
default:
|
||||
return <Tag color="default">未知</Tag>;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTaskDetail();
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
// if (activeTab === "records" && records.length === 0) {
|
||||
// loadRecords(1, true);
|
||||
// }
|
||||
}, [activeTab]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<NavCommon
|
||||
left={
|
||||
<Button
|
||||
fill="none"
|
||||
size="small"
|
||||
onClick={() => navigate("/workspace/contact-import/list")}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
}
|
||||
title="任务详情"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className={style.loading}>
|
||||
<SpinLoading /> 加载中...
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!task) {
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<NavCommon
|
||||
left={
|
||||
<Button
|
||||
fill="none"
|
||||
size="small"
|
||||
onClick={() => navigate("/workspace/contact-import/list")}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
}
|
||||
title="任务详情"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className={style.empty}>
|
||||
<Empty description="任务不存在" />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const statusInfo = getStatusInfo(task.status);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<NavCommon
|
||||
title="任务详情"
|
||||
right={
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={() =>
|
||||
navigate(`/workspace/contact-import/form/${task.id}`)
|
||||
}
|
||||
>
|
||||
<EditOutlined /> 编辑
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{/* 任务操作栏 */}
|
||||
<Card className={style.actionCard}>
|
||||
<div className={style.taskHeader}>
|
||||
<div className={style.taskInfo}>
|
||||
<div className={style.taskName}>{task.name}</div>
|
||||
<div
|
||||
className={style.taskStatus}
|
||||
style={{ color: statusInfo.color }}
|
||||
>
|
||||
{statusInfo.text}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.actions}>
|
||||
<Button
|
||||
onClick={handleToggleStatus}
|
||||
color={task.status === 1 ? "warning" : "primary"}
|
||||
>
|
||||
{task.status === 1 ? (
|
||||
<>
|
||||
<PauseCircleOutlined /> 暂停
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlayCircleOutlined /> 启动
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleTriggerImport}
|
||||
disabled={task.status !== 1}
|
||||
>
|
||||
<ReloadOutlined /> 立即导入
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
<div className={style.container}>
|
||||
{/* 标签页 */}
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
className={style.tabs}
|
||||
>
|
||||
<Tabs.Tab title="任务信息" key="info">
|
||||
<div className={style.tabContent}>
|
||||
{/* 基本信息 */}
|
||||
<Card className={style.infoCard}>
|
||||
<div className={style.cardTitle}>基本信息</div>
|
||||
<div className={style.infoList}>
|
||||
<div className={style.infoItem}>
|
||||
<span className={style.label}>任务名称:</span>
|
||||
<span className={style.value}>{task.name}</span>
|
||||
</div>
|
||||
<div className={style.infoItem}>
|
||||
<span className={style.label}>设备组数:</span>
|
||||
<span className={style.value}>
|
||||
{task.deviceGroups?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style.infoItem}>
|
||||
<span className={style.label}>导入数量:</span>
|
||||
<span className={style.value}>{task.num}</span>
|
||||
</div>
|
||||
<div className={style.infoItem}>
|
||||
<span className={style.label}>客户端ID:</span>
|
||||
<span className={style.value}>{task.clientId}</span>
|
||||
</div>
|
||||
<div className={style.infoItem}>
|
||||
<span className={style.label}>备注类型:</span>
|
||||
<span className={style.value}>{task.remarkType}</span>
|
||||
</div>
|
||||
<div className={style.infoItem}>
|
||||
<span className={style.label}>备注内容:</span>
|
||||
<span className={style.value}>{task.remarkValue}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 时间配置 */}
|
||||
<Card className={style.infoCard}>
|
||||
<div className={style.cardTitle}>时间配置</div>
|
||||
<div className={style.infoList}>
|
||||
<div className={style.infoItem}>
|
||||
<span className={style.label}>开始时间:</span>
|
||||
<span className={style.value}>{task.startTime}</span>
|
||||
</div>
|
||||
<div className={style.infoItem}>
|
||||
<span className={style.label}>结束时间:</span>
|
||||
<span className={style.value}>{task.endTime}</span>
|
||||
</div>
|
||||
<div className={style.infoItem}>
|
||||
<span className={style.label}>每日最大导入:</span>
|
||||
<span className={style.value}>
|
||||
{task.maxImportsPerDay}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style.infoItem}>
|
||||
<span className={style.label}>导入间隔:</span>
|
||||
<span className={style.value}>
|
||||
{task.importInterval}分钟
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 统计信息 */}
|
||||
<Card className={style.infoCard}>
|
||||
<div className={style.cardTitle}>统计信息</div>
|
||||
<div className={style.infoList}>
|
||||
<div className={style.infoItem}>
|
||||
<span className={style.label}>今日导入:</span>
|
||||
<span className={style.value}>
|
||||
{task.todayImportCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style.infoItem}>
|
||||
<span className={style.label}>总导入数:</span>
|
||||
<span className={style.value}>
|
||||
{task.totalImportCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style.infoItem}>
|
||||
<span className={style.label}>创建时间:</span>
|
||||
<span className={style.value}>{task.createTime}</span>
|
||||
</div>
|
||||
<div className={style.infoItem}>
|
||||
<span className={style.label}>更新时间:</span>
|
||||
<span className={style.value}>{task.updateTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Tabs.Tab>
|
||||
|
||||
<Tabs.Tab title="导入记录" key="records">
|
||||
<div className={style.tabContent}>
|
||||
{records.length === 0 && !recordsLoading ? (
|
||||
<Empty description="暂无导入记录" />
|
||||
) : (
|
||||
<List className={style.recordList}>
|
||||
{records.map(record => (
|
||||
<List.Item key={record.id} className={style.recordItem}>
|
||||
<div className={style.recordHeader}>
|
||||
<div className={style.recordInfo}>
|
||||
<Space>
|
||||
{getRecordStatusIcon(record.importStatus)}
|
||||
<span className={style.recordDevice}>
|
||||
{record.deviceName}
|
||||
</span>
|
||||
{getRecordStatusTag(record.importStatus)}
|
||||
</Space>
|
||||
</div>
|
||||
<div className={style.recordTime}>
|
||||
{record.createTime}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.recordContent}>
|
||||
<div className={style.recordDetail}>
|
||||
<span className={style.label}>导入数量:</span>
|
||||
<span className={style.value}>{record.num}</span>
|
||||
</div>
|
||||
<div className={style.recordDetail}>
|
||||
<span className={style.label}>备注:</span>
|
||||
<span className={style.value}>
|
||||
{record.remarkType}: {record.remarkValue}
|
||||
</span>
|
||||
</div>
|
||||
{record.errorMessage && (
|
||||
<div className={style.recordError}>
|
||||
错误信息: {record.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
<InfiniteScroll
|
||||
loadMore={loadMoreRecords}
|
||||
hasMore={hasMore}
|
||||
threshold={10}
|
||||
>
|
||||
{recordsLoading && (
|
||||
<div className={style.loadingMore}>
|
||||
<SpinLoading /> 加载中...
|
||||
</div>
|
||||
)}
|
||||
</InfiniteScroll>
|
||||
</div>
|
||||
</Tabs.Tab>
|
||||
</Tabs>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactImportDetail;
|
||||
@@ -1,91 +0,0 @@
|
||||
import request from "@/api/request";
|
||||
import {
|
||||
Allocation,
|
||||
CreateContactImportTaskData,
|
||||
UpdateContactImportTaskData,
|
||||
ContactImportRecord,
|
||||
PaginatedResponse,
|
||||
ImportStats,
|
||||
} from "./data";
|
||||
|
||||
// 获取通讯录导入任务列表
|
||||
export function fetchContactImportTasks(
|
||||
params = { type: 6, page: 1, limit: 10 },
|
||||
) {
|
||||
return request("/v1/workbench/list", params, "GET");
|
||||
}
|
||||
|
||||
// 获取单个任务详情
|
||||
export function fetchContactImportTaskDetail(id: number) {
|
||||
return request("/v1/workbench/detail", { id }, "GET");
|
||||
}
|
||||
|
||||
// 创建通讯录导入任务
|
||||
export function createContactImportTask(data: Allocation): Promise<any> {
|
||||
return request("/v1/workbench/create", { ...data, type: 6 }, "POST");
|
||||
}
|
||||
|
||||
// 更新通讯录导入任务
|
||||
export function updateContactImportTask(data: Allocation): Promise<any> {
|
||||
return request("/v1/workbench/update", { ...data, type: 6 }, "POST");
|
||||
}
|
||||
|
||||
// 删除通讯录导入任务
|
||||
export function deleteContactImportTask(id: number): Promise<any> {
|
||||
return request("/v1/workbench/delete", { id }, "DELETE");
|
||||
}
|
||||
|
||||
// 切换任务状态
|
||||
export function toggleContactImportTask(data: {
|
||||
id: number;
|
||||
status: number;
|
||||
}): Promise<any> {
|
||||
return request("/v1/workbench/update-status", { ...data }, "POST");
|
||||
}
|
||||
|
||||
// 复制通讯录导入任务
|
||||
export function copyContactImportTask(id: number): Promise<any> {
|
||||
return request("/v1/workbench/copy", { id }, "POST");
|
||||
}
|
||||
|
||||
// 获取导入记录
|
||||
export function fetchImportRecords(
|
||||
workbenchId: number,
|
||||
page: number = 1,
|
||||
limit: number = 20,
|
||||
keyword?: string,
|
||||
): Promise<PaginatedResponse<ContactImportRecord>> {
|
||||
return request(
|
||||
"/v1/workbench/import-records",
|
||||
{
|
||||
workbenchId,
|
||||
page,
|
||||
limit,
|
||||
keyword,
|
||||
},
|
||||
"GET",
|
||||
);
|
||||
}
|
||||
|
||||
// 获取统计数据
|
||||
export function fetchImportStats(): Promise<ImportStats> {
|
||||
return request("/v1/workbench/import-stats", {}, "GET");
|
||||
}
|
||||
|
||||
// 获取设备组列表
|
||||
export function fetchDeviceGroups(): Promise<any[]> {
|
||||
return request("/v1/device/groups", {}, "GET");
|
||||
}
|
||||
|
||||
// 手动触发导入
|
||||
export function triggerImport(taskId: number): Promise<any> {
|
||||
return request("/v1/workbench/trigger-import", { taskId }, "POST");
|
||||
}
|
||||
|
||||
// 批量操作任务
|
||||
export function batchOperateTasks(data: {
|
||||
taskIds: number[];
|
||||
operation: "start" | "stop" | "delete";
|
||||
}): Promise<any> {
|
||||
return request("/v1/workbench/batch-operate", data, "POST");
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
// 分配接口类型
|
||||
export interface Allocation {
|
||||
/** 主键ID */
|
||||
id?: number;
|
||||
/** 任务名称 */
|
||||
name: string;
|
||||
//是否启用0关闭,1启用
|
||||
status: number;
|
||||
//任务类型,固定为6
|
||||
type: number;
|
||||
/** 工作台ID */
|
||||
workbenchId: number;
|
||||
|
||||
/** 设备id */
|
||||
deviceGroups: number[];
|
||||
/** 流量池 */
|
||||
pools?: JSON | null;
|
||||
|
||||
/** 分配数量 */
|
||||
num?: number | null;
|
||||
|
||||
/** 是否清除现有联系人,默认0 */
|
||||
clearContact?: number;
|
||||
|
||||
/** 备注类型 0不备注 1年月日 2月日 3自定义,默认0 */
|
||||
remarkType: number;
|
||||
|
||||
/** 备注 */
|
||||
remark?: string | null;
|
||||
|
||||
/** 开始时间 */
|
||||
startTime?: string | null;
|
||||
|
||||
/** 结束时间 */
|
||||
endTime?: string | null;
|
||||
[key: string]: any;
|
||||
}
|
||||
// 通讯录导入任务状态
|
||||
export type ContactImportTaskStatus = 1 | 2; // 1: 开启, 2: 关闭
|
||||
|
||||
// 设备组信息
|
||||
export interface DeviceGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
deviceCount: number;
|
||||
status: "online" | "offline";
|
||||
lastActive: string;
|
||||
}
|
||||
|
||||
// 通讯录导入记录
|
||||
export interface ContactImportRecord {
|
||||
id: string;
|
||||
workbenchId: string;
|
||||
wechatAccountId: string;
|
||||
deviceId: string;
|
||||
num: number;
|
||||
clientId: string;
|
||||
remarkType: string;
|
||||
remarkValue: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
createTime: string;
|
||||
operatorName: string;
|
||||
operatorAvatar: string;
|
||||
deviceName: string;
|
||||
importStatus: "success" | "failed" | "pending";
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
// 通讯录导入任务配置
|
||||
export interface ContactImportTaskConfig {
|
||||
id: number;
|
||||
workbenchId: number;
|
||||
devices: number[];
|
||||
pools: number[];
|
||||
num: number;
|
||||
clearContact: number;
|
||||
remarkType: number;
|
||||
remark: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
createTime: string;
|
||||
}
|
||||
|
||||
// 通讯录导入任务
|
||||
export interface ContactImportTask {
|
||||
id: number;
|
||||
companyId: number;
|
||||
name: string;
|
||||
type: number;
|
||||
status: ContactImportTaskStatus;
|
||||
autoStart: number;
|
||||
userId: number;
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
config: ContactImportTaskConfig;
|
||||
creatorName: string;
|
||||
auto_like: any;
|
||||
moments_sync: any;
|
||||
traffic_config: any;
|
||||
group_push: any;
|
||||
group_create: any;
|
||||
// 计算属性,用于向后兼容
|
||||
deviceGroups?: string[];
|
||||
todayImportCount?: number;
|
||||
totalImportCount?: number;
|
||||
maxImportsPerDay?: number;
|
||||
importInterval?: number;
|
||||
}
|
||||
|
||||
// 创建通讯录导入任务数据
|
||||
export interface CreateContactImportTaskData {
|
||||
name: string;
|
||||
type: number;
|
||||
config: {
|
||||
devices: number[];
|
||||
pools: number[];
|
||||
num: number;
|
||||
clearContact: number;
|
||||
remarkType: number;
|
||||
remark: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 更新通讯录导入任务数据
|
||||
export interface UpdateContactImportTaskData
|
||||
extends CreateContactImportTaskData {
|
||||
id: number;
|
||||
}
|
||||
|
||||
// 任务配置
|
||||
export interface TaskConfig {
|
||||
deviceGroups: string[];
|
||||
num: number;
|
||||
clientId: string;
|
||||
remarkType: string;
|
||||
remarkValue: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
maxImportsPerDay: number;
|
||||
importInterval: number;
|
||||
}
|
||||
|
||||
// API响应
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
// 分页响应
|
||||
export interface PaginatedResponse<T> {
|
||||
list: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
// 统计数据
|
||||
export interface ImportStats {
|
||||
totalTasks: number;
|
||||
activeTasks: number;
|
||||
todayImports: number;
|
||||
totalImports: number;
|
||||
successRate: number;
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
.basicSection {
|
||||
background: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
padding: 24px 16px 0 16px;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.formItem {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.formLabel {
|
||||
font-size: 15px;
|
||||
color: #222;
|
||||
font-weight: 500;
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.input {
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.select {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
|
||||
:global(.ant-select-selector) {
|
||||
height: 44px !important;
|
||||
border-radius: 8px !important;
|
||||
font-size: 15px !important;
|
||||
}
|
||||
|
||||
:global(.ant-select-selection-item) {
|
||||
line-height: 42px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.timePicker {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
|
||||
:global(.ant-picker) {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.counterRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.stepperContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.stepperButton {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
font-size: 16px;
|
||||
color: #188eee;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: border 0.2s;
|
||||
|
||||
&:hover {
|
||||
border: 1px solid #188eee;
|
||||
}
|
||||
}
|
||||
|
||||
.stepperInput {
|
||||
width: 80px;
|
||||
height: 40px;
|
||||
border-radius: 0;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.counterTip {
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.buttonGroup {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.submitButton {
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
min-width: 120px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.resetButton {
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.deviceSelection {
|
||||
width: 100%;
|
||||
|
||||
:global {
|
||||
.ant-input {
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e8e8e8;
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
|
||||
&:focus {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
import React, { useState, useEffect } 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 NavCommon from "@/components/NavCommon";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import DeviceSelection from "@/components/DeviceSelection";
|
||||
import {
|
||||
createContactImportTask,
|
||||
updateContactImportTask,
|
||||
fetchContactImportTaskDetail,
|
||||
} from "./api";
|
||||
import { Allocation } from "./data";
|
||||
|
||||
import style from "./index.module.scss";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
const ContactImportForm: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
const isEdit = !!id;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
name: "", // 任务名称
|
||||
status: 1, // 是否启用,默认启用
|
||||
type: 6, // 任务类型,固定为6
|
||||
workbenchId: 1, // 默认工作台ID
|
||||
deviceGroups: [] as number[],
|
||||
pools: [] as any[],
|
||||
num: 50,
|
||||
clearContact: 0,
|
||||
remarkType: 0,
|
||||
remark: "",
|
||||
startTime: dayjs("09:00", "HH:mm"),
|
||||
endTime: dayjs("21:00", "HH:mm"),
|
||||
// 保留原有字段用于UI显示
|
||||
deviceGroupsOptions: [] as any[],
|
||||
});
|
||||
|
||||
// 处理设备选择
|
||||
const handleDeviceSelect = (selectedDevices: any[]) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
deviceGroupsOptions: selectedDevices,
|
||||
deviceGroups: selectedDevices.map(device => device.id), // 提取设备ID存储到deviceGroups数组
|
||||
}));
|
||||
};
|
||||
|
||||
// 获取任务详情(编辑模式)
|
||||
const loadTaskDetail = async () => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await fetchContactImportTaskDetail(Number(id));
|
||||
if (data) {
|
||||
const config = data.config || {};
|
||||
|
||||
// 构造设备选择组件需要的数据格式
|
||||
const deviceGroupsOptions = config.deviceGroupsOptions || [];
|
||||
|
||||
setFormData({
|
||||
name: data.name || "",
|
||||
status: data.status || 1,
|
||||
type: data.type || 6,
|
||||
workbenchId: config.workbenchId || 1,
|
||||
deviceGroups:
|
||||
deviceGroupsOptions.map((device: any) => device.id) || [],
|
||||
pools: config.pools ? JSON.parse(JSON.stringify(config.pools)) : [],
|
||||
num: config.num || 50,
|
||||
clearContact: config.clearContact || 0,
|
||||
remarkType: config.remarkType || 0,
|
||||
remark: config.remark || "",
|
||||
startTime: config.startTime ? dayjs(config.startTime, "HH:mm") : null,
|
||||
endTime: config.endTime ? dayjs(config.endTime, "HH:mm") : null,
|
||||
deviceGroupsOptions,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load task detail:", error);
|
||||
message.error("获取任务详情失败");
|
||||
navigate("/workspace/contact-import/list");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 更新表单数据
|
||||
const handleUpdateFormData = (data: Partial<typeof formData>) => {
|
||||
setFormData(prev => ({ ...prev, ...data }));
|
||||
};
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
// 表单验证
|
||||
if (!formData.name.trim()) {
|
||||
message.error("请输入任务名称");
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.deviceGroups.length === 0) {
|
||||
message.error("请选择设备");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.num || formData.num <= 0) {
|
||||
message.error("请输入有效的分配数量");
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证开始时间不得大于结束时间
|
||||
if (formData.startTime && formData.endTime) {
|
||||
if (formData.startTime.isAfter(formData.endTime)) {
|
||||
message.error("开始时间不得大于结束时间");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const submitData: Partial<Allocation> = {
|
||||
name: formData.name,
|
||||
status: formData.status,
|
||||
type: formData.type,
|
||||
workbenchId: formData.workbenchId,
|
||||
deviceGroups: formData.deviceGroups,
|
||||
pools: JSON.parse(JSON.stringify(formData.pools)),
|
||||
num: formData.num,
|
||||
clearContact: formData.clearContact,
|
||||
remarkType: formData.remarkType,
|
||||
remark: formData.remark || null,
|
||||
startTime: formData.startTime?.format("HH:mm") || null,
|
||||
endTime: formData.endTime?.format("HH:mm") || null,
|
||||
};
|
||||
|
||||
if (isEdit && id) {
|
||||
await updateContactImportTask({
|
||||
...submitData,
|
||||
id: Number(id),
|
||||
} as Allocation);
|
||||
message.success("更新成功");
|
||||
} else {
|
||||
await createContactImportTask(submitData as Allocation);
|
||||
message.success("创建成功");
|
||||
}
|
||||
|
||||
navigate("/workspace/contact-import/list");
|
||||
} catch (error) {
|
||||
message.error(isEdit ? "更新失败" : "创建失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 重置表单
|
||||
const handleReset = () => {
|
||||
setFormData({
|
||||
name: "",
|
||||
status: 1,
|
||||
type: 6,
|
||||
workbenchId: 1,
|
||||
deviceGroups: [],
|
||||
pools: [],
|
||||
num: 50,
|
||||
clearContact: 0,
|
||||
remarkType: 0,
|
||||
remark: "",
|
||||
startTime: dayjs("09:00", "HH:mm"),
|
||||
endTime: dayjs("21:00", "HH:mm"),
|
||||
deviceGroupsOptions: [],
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit) {
|
||||
loadTaskDetail();
|
||||
}
|
||||
}, [id, isEdit]);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={<NavCommon title={isEdit ? "编辑导入任务" : "新建导入任务"} />}
|
||||
footer={
|
||||
<div className={style.buttonGroup}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
loading={loading}
|
||||
onClick={handleSubmit}
|
||||
className={style.submitButton}
|
||||
>
|
||||
{isEdit ? "更新" : "创建"}
|
||||
</Button>
|
||||
<Button
|
||||
size="large"
|
||||
onClick={handleReset}
|
||||
className={style.resetButton}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
loading={loading}
|
||||
>
|
||||
<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>
|
||||
<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 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactImportForm;
|
||||
@@ -1,97 +0,0 @@
|
||||
import request from "@/api/request";
|
||||
import {
|
||||
ContactImportTask,
|
||||
CreateContactImportTaskData,
|
||||
UpdateContactImportTaskData,
|
||||
ContactImportRecord,
|
||||
PaginatedResponse,
|
||||
ImportStats,
|
||||
} from "./data";
|
||||
|
||||
// 获取通讯录导入任务列表
|
||||
export function fetchContactImportTasks(
|
||||
params = { type: 6, page: 1, limit: 10 },
|
||||
) {
|
||||
return request("/v1/workbench/list", params, "GET");
|
||||
}
|
||||
|
||||
// 获取单个任务详情
|
||||
export function fetchContactImportTaskDetail(
|
||||
id: number,
|
||||
): Promise<ContactImportTask | null> {
|
||||
return request("/v1/workbench/detail", { id }, "GET");
|
||||
}
|
||||
|
||||
// 创建通讯录导入任务
|
||||
export function createContactImportTask(
|
||||
data: CreateContactImportTaskData,
|
||||
): Promise<any> {
|
||||
return request("/v1/workbench/create", { ...data, type: 6 }, "POST");
|
||||
}
|
||||
|
||||
// 更新通讯录导入任务
|
||||
export function updateContactImportTask(
|
||||
data: UpdateContactImportTaskData,
|
||||
): Promise<any> {
|
||||
return request("/v1/workbench/update", { ...data, type: 6 }, "POST");
|
||||
}
|
||||
|
||||
// 删除通讯录导入任务
|
||||
export function deleteContactImportTask(id: number): Promise<any> {
|
||||
return request("/v1/workbench/delete", { id }, "DELETE");
|
||||
}
|
||||
|
||||
// 切换任务状态
|
||||
export function toggleContactImportTask(data: {
|
||||
id: number;
|
||||
status: number;
|
||||
}): Promise<any> {
|
||||
return request("/v1/workbench/update-status", { ...data }, "POST");
|
||||
}
|
||||
|
||||
// 复制通讯录导入任务
|
||||
export function copyContactImportTask(id: number): Promise<any> {
|
||||
return request("/v1/workbench/copy", { id }, "POST");
|
||||
}
|
||||
|
||||
// 获取导入记录
|
||||
export function fetchImportRecords(
|
||||
workbenchId: number,
|
||||
page: number = 1,
|
||||
limit: number = 20,
|
||||
keyword?: string,
|
||||
): Promise<PaginatedResponse<ContactImportRecord>> {
|
||||
return request(
|
||||
"/v1/workbench/import-records",
|
||||
{
|
||||
workbenchId,
|
||||
page,
|
||||
limit,
|
||||
keyword,
|
||||
},
|
||||
"GET",
|
||||
);
|
||||
}
|
||||
|
||||
// 获取统计数据
|
||||
export function fetchImportStats(): Promise<ImportStats> {
|
||||
return request("/v1/workbench/import-stats", {}, "GET");
|
||||
}
|
||||
|
||||
// 获取设备组列表
|
||||
export function fetchDeviceGroups(): Promise<any[]> {
|
||||
return request("/v1/device/groups", {}, "GET");
|
||||
}
|
||||
|
||||
// 手动触发导入
|
||||
export function triggerImport(taskId: number): Promise<any> {
|
||||
return request("/v1/workbench/trigger-import", { taskId }, "POST");
|
||||
}
|
||||
|
||||
// 批量操作任务
|
||||
export function batchOperateTasks(data: {
|
||||
taskIds: number[];
|
||||
operation: "start" | "stop" | "delete";
|
||||
}): Promise<any> {
|
||||
return request("/v1/workbench/batch-operate", data, "POST");
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
// 通讯录导入任务状态
|
||||
export type ContactImportTaskStatus = 1 | 2; // 1: 开启, 2: 关闭
|
||||
|
||||
// 设备组信息
|
||||
export interface DeviceGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
deviceCount: number;
|
||||
status: "online" | "offline";
|
||||
lastActive: string;
|
||||
}
|
||||
|
||||
// 通讯录导入记录
|
||||
export interface ContactImportRecord {
|
||||
id: string;
|
||||
workbenchId: string;
|
||||
wechatAccountId: string;
|
||||
deviceId: string;
|
||||
num: number;
|
||||
clientId: string;
|
||||
remarkType: string;
|
||||
remarkValue: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
createTime: string;
|
||||
operatorName: string;
|
||||
operatorAvatar: string;
|
||||
deviceName: string;
|
||||
importStatus: "success" | "failed" | "pending";
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
// 通讯录导入任务配置
|
||||
export interface ContactImportTaskConfig {
|
||||
id: number;
|
||||
workbenchId: number;
|
||||
devices: number[];
|
||||
pools: number[];
|
||||
num: number;
|
||||
clearContact: number;
|
||||
remarkType: number;
|
||||
remark: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
createTime: string;
|
||||
}
|
||||
|
||||
// 通讯录导入任务
|
||||
export interface ContactImportTask {
|
||||
id: number;
|
||||
companyId: number;
|
||||
name: string;
|
||||
type: number;
|
||||
status: ContactImportTaskStatus;
|
||||
autoStart: number;
|
||||
userId: number;
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
config: ContactImportTaskConfig;
|
||||
creatorName: string;
|
||||
auto_like: any;
|
||||
moments_sync: any;
|
||||
traffic_config: any;
|
||||
group_push: any;
|
||||
group_create: any;
|
||||
// 计算属性,用于向后兼容
|
||||
deviceGroups?: string[];
|
||||
todayImportCount?: number;
|
||||
totalImportCount?: number;
|
||||
maxImportsPerDay?: number;
|
||||
importInterval?: number;
|
||||
}
|
||||
|
||||
// 创建通讯录导入任务数据
|
||||
export interface CreateContactImportTaskData {
|
||||
name: string;
|
||||
type: number;
|
||||
config: {
|
||||
devices: number[];
|
||||
pools: number[];
|
||||
num: number;
|
||||
clearContact: number;
|
||||
remarkType: number;
|
||||
remark: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 更新通讯录导入任务数据
|
||||
export interface UpdateContactImportTaskData
|
||||
extends CreateContactImportTaskData {
|
||||
id: number;
|
||||
}
|
||||
|
||||
// 任务配置
|
||||
export interface TaskConfig {
|
||||
deviceGroups: string[];
|
||||
num: number;
|
||||
clientId: string;
|
||||
remarkType: string;
|
||||
remarkValue: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
maxImportsPerDay: number;
|
||||
importInterval: number;
|
||||
}
|
||||
|
||||
// API响应
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
// 分页响应
|
||||
export interface PaginatedResponse<T> {
|
||||
list: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
// 统计数据
|
||||
export interface ImportStats {
|
||||
totalTasks: number;
|
||||
activeTasks: number;
|
||||
todayImports: number;
|
||||
totalImports: number;
|
||||
successRate: number;
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
.container {
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.searchBox {
|
||||
flex: 1;
|
||||
|
||||
:global(.ant-input-affix-wrapper) {
|
||||
border-radius: 6px;
|
||||
border: 1px solid #d9d9d9;
|
||||
|
||||
&:hover {
|
||||
border-color: #40a9ff;
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
:global(.adm-button) {
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.taskList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.emptyIcon {
|
||||
font-size: 48px;
|
||||
color: #d9d9d9;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.emptyText {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.taskCard {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:global(.adm-card-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.taskInfo {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.taskName {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.taskStatus {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
background-color: rgba(82, 196, 26, 0.1);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.cardMenu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.menuButton {
|
||||
padding: 4px 8px;
|
||||
color: #666;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
background-color: #f0f8ff;
|
||||
}
|
||||
}
|
||||
|
||||
.menuDropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
background: #fff;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
min-width: 100px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
color: #ff4d4f;
|
||||
|
||||
&:hover {
|
||||
background-color: #fff2f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.taskDetail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #666;
|
||||
margin-right: 8px;
|
||||
min-width: 70px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #333;
|
||||
flex: 1;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.cardActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
|
||||
:global(.adm-button) {
|
||||
flex: 1;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 480px) {
|
||||
.toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.taskName {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.taskDetail {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.label {
|
||||
min-width: 60px;
|
||||
}
|
||||
}
|
||||
@@ -1,345 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button, Toast, SpinLoading, Dialog, Card } from "antd-mobile";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import { Input } from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
EyeOutlined,
|
||||
EditOutlined,
|
||||
MoreOutlined,
|
||||
ContactsOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import {
|
||||
fetchContactImportTasks,
|
||||
deleteContactImportTask,
|
||||
toggleContactImportTask,
|
||||
copyContactImportTask,
|
||||
} from "./api";
|
||||
import { ContactImportTask } from "./data";
|
||||
import style from "./index.module.scss";
|
||||
|
||||
// 卡片菜单组件
|
||||
interface CardMenuProps {
|
||||
onView: () => void;
|
||||
onEdit: () => void;
|
||||
onCopy: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
const CardMenu: React.FC<CardMenuProps> = ({
|
||||
onView,
|
||||
onEdit,
|
||||
onCopy,
|
||||
onDelete,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={style.cardMenu} ref={menuRef}>
|
||||
<Button
|
||||
size="small"
|
||||
fill="none"
|
||||
onClick={() => setOpen(!open)}
|
||||
className={style.menuButton}
|
||||
>
|
||||
<MoreOutlined />
|
||||
</Button>
|
||||
{open && (
|
||||
<div className={style.menuDropdown}>
|
||||
<div className={style.menuItem} onClick={onView}>
|
||||
<EyeOutlined /> 查看
|
||||
</div>
|
||||
<div className={style.menuItem} onClick={onEdit}>
|
||||
<EditOutlined /> 编辑
|
||||
</div>
|
||||
<div className={style.menuItem} onClick={onCopy}>
|
||||
<CopyOutlined /> 复制
|
||||
</div>
|
||||
<div className={style.menuItem} onClick={onDelete}>
|
||||
<DeleteOutlined /> 删除
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ContactImport: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [tasks, setTasks] = useState<ContactImportTask[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchKeyword, setSearchKeyword] = useState("");
|
||||
const [filteredTasks, setFilteredTasks] = useState<ContactImportTask[]>([]);
|
||||
|
||||
// 获取任务列表
|
||||
const loadTasks = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetchContactImportTasks();
|
||||
const data = response?.list || [];
|
||||
setTasks(data);
|
||||
setFilteredTasks(data);
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
content: "获取任务列表失败",
|
||||
icon: "fail",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 搜索过滤
|
||||
const handleSearch = (keyword: string) => {
|
||||
setSearchKeyword(keyword);
|
||||
if (!keyword.trim()) {
|
||||
setFilteredTasks(tasks);
|
||||
} else {
|
||||
const filtered = tasks.filter(
|
||||
task =>
|
||||
task.name.toLowerCase().includes(keyword.toLowerCase()) ||
|
||||
(task.config?.remark || "")
|
||||
.toLowerCase()
|
||||
.includes(keyword.toLowerCase()) ||
|
||||
task.creatorName.toLowerCase().includes(keyword.toLowerCase()),
|
||||
);
|
||||
setFilteredTasks(filtered);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除任务
|
||||
const handleDelete = async (id: number) => {
|
||||
const result = await Dialog.confirm({
|
||||
content: "确定要删除这个通讯录导入任务吗?",
|
||||
});
|
||||
if (result) {
|
||||
try {
|
||||
await deleteContactImportTask(id);
|
||||
Toast.show({
|
||||
content: "删除成功",
|
||||
icon: "success",
|
||||
});
|
||||
loadTasks();
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
content: "删除失败",
|
||||
icon: "fail",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 切换任务状态
|
||||
const handleToggleStatus = async (task: ContactImportTask) => {
|
||||
try {
|
||||
await toggleContactImportTask({
|
||||
id: task.id,
|
||||
status: task.status === 1 ? 2 : 1,
|
||||
});
|
||||
Toast.show({
|
||||
content: task.status === 1 ? "任务已暂停" : "任务已启动",
|
||||
icon: "success",
|
||||
});
|
||||
loadTasks();
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
content: "操作失败",
|
||||
icon: "fail",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 复制任务
|
||||
const handleCopy = async (id: number) => {
|
||||
try {
|
||||
await copyContactImportTask(id);
|
||||
Toast.show({
|
||||
content: "复制成功",
|
||||
icon: "success",
|
||||
});
|
||||
loadTasks();
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
content: "复制失败",
|
||||
icon: "fail",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 查看详情
|
||||
const handleView = (id: number) => {
|
||||
navigate(`/workspace/contact-import/detail/${id}`);
|
||||
};
|
||||
|
||||
// 编辑任务
|
||||
const handleEdit = (id: number) => {
|
||||
navigate(`/workspace/contact-import/form/${id}`);
|
||||
};
|
||||
|
||||
// 格式化状态文本
|
||||
const getStatusText = (status: number) => {
|
||||
return status === 1 ? "运行中" : "已暂停";
|
||||
};
|
||||
|
||||
// 格式化状态颜色
|
||||
const getStatusColor = (status: number) => {
|
||||
return status === 1 ? "#52c41a" : "#faad14";
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTasks();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<NavCommon
|
||||
backFn={() => navigate("/workspace")}
|
||||
title="通讯录导入"
|
||||
right={
|
||||
<Button
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => navigate("/workspace/contact-import/form")}
|
||||
>
|
||||
<PlusOutlined /> 新建
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{/* 搜索栏 */}
|
||||
<div className="search-bar">
|
||||
<div className="search-input-wrapper">
|
||||
<Input
|
||||
placeholder="搜索计划名称"
|
||||
value={searchKeyword}
|
||||
onChange={e => setSearchKeyword(e.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => handleSearch(searchKeyword)}
|
||||
loading={loading}
|
||||
className="refresh-btn"
|
||||
>
|
||||
<ReloadOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
{!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)}
|
||||
</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>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactImport;
|
||||
@@ -1,74 +0,0 @@
|
||||
import request from "@/api/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;
|
||||
maxPerDay: number;
|
||||
startTime: string; // 允许推送的开始时间
|
||||
endTime: 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}`);
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
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 "./groupPush";
|
||||
import styles from "./index.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>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
}
|
||||
>
|
||||
<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.maxPerDay} 条</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.maxPerDay}
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.round((task.pushCount / task.maxPerDay) * 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;
|
||||
@@ -1,333 +0,0 @@
|
||||
import React, {
|
||||
useImperativeHandle,
|
||||
forwardRef,
|
||||
useState,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import {
|
||||
Input,
|
||||
Button,
|
||||
Card,
|
||||
Switch,
|
||||
Form,
|
||||
InputNumber,
|
||||
Select,
|
||||
Radio,
|
||||
} from "antd";
|
||||
import { fetchSocialMediaList, fetchPromotionSiteList } from "../index.api";
|
||||
|
||||
interface BasicSettingsProps {
|
||||
defaultValues?: {
|
||||
name: string;
|
||||
startTime: string; // 允许推送的开始时间
|
||||
endTime: string; // 允许推送的结束时间
|
||||
maxPerDay: number;
|
||||
pushOrder: number; // 1: 按最早, 2: 按最新
|
||||
isLoop: number; // 0: 否, 1: 是
|
||||
pushType: number; // 0: 定时推送, 1: 立即推送
|
||||
status: number; // 0: 否, 1: 是
|
||||
socialMediaId?: string;
|
||||
promotionSiteId?: string;
|
||||
};
|
||||
onNext: (values: any) => void;
|
||||
onSave: (values: any) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export interface BasicSettingsRef {
|
||||
validate: () => Promise<boolean>;
|
||||
getValues: () => any;
|
||||
}
|
||||
|
||||
const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
||||
(
|
||||
{
|
||||
defaultValues = {
|
||||
name: "",
|
||||
startTime: "06:00", // 允许推送的开始时间
|
||||
endTime: "23:59", // 允许推送的结束时间
|
||||
maxPerDay: 20,
|
||||
pushOrder: 1,
|
||||
isLoop: 0, // 0: 否, 1: 是
|
||||
pushType: 0, // 0: 定时推送, 1: 立即推送
|
||||
status: 0, // 0: 否, 1: 是
|
||||
socialMediaId: undefined,
|
||||
promotionSiteId: undefined,
|
||||
},
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [form] = Form.useForm();
|
||||
const [, forceUpdate] = useState({});
|
||||
const [socialMediaList, setSocialMediaList] = useState([]);
|
||||
const [promotionSiteList, setPromotionSiteList] = useState([]);
|
||||
const [loadingSocialMedia, setLoadingSocialMedia] = useState(false);
|
||||
const [loadingPromotionSite, setLoadingPromotionSite] = useState(false);
|
||||
|
||||
// 确保组件初始化时能正确显示按钮状态
|
||||
useEffect(() => {
|
||||
forceUpdate({});
|
||||
}, []);
|
||||
|
||||
// 组件挂载时获取社交媒体列表
|
||||
useEffect(() => {
|
||||
setLoadingSocialMedia(true);
|
||||
fetchSocialMediaList()
|
||||
.then(res => {
|
||||
setSocialMediaList(res);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoadingSocialMedia(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 监听社交媒体选择变化
|
||||
const handleSocialMediaChange = value => {
|
||||
form.setFieldsValue({ socialMediaId: value });
|
||||
// 清空推广站点选择
|
||||
form.setFieldsValue({ promotionSiteId: undefined });
|
||||
setPromotionSiteList([]);
|
||||
|
||||
if (value) {
|
||||
setLoadingPromotionSite(true);
|
||||
fetchPromotionSiteList(value)
|
||||
.then(res => {
|
||||
setPromotionSiteList(res);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoadingPromotionSite(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 暴露方法给父组件
|
||||
useImperativeHandle(ref, () => ({
|
||||
validate: async () => {
|
||||
try {
|
||||
await form.validateFields();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log("BasicSettings 表单验证失败:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
getValues: () => {
|
||||
return form.getFieldsValue();
|
||||
},
|
||||
}));
|
||||
const handlePushOrderChange = (value: number) => {
|
||||
form.setFieldsValue({ pushOrder: value });
|
||||
forceUpdate({}); // 强制组件重新渲染
|
||||
};
|
||||
return (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Card>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={defaultValues}
|
||||
onValuesChange={(changedValues, allValues) => {
|
||||
// 当pushOrder值变化时,强制更新组件
|
||||
if ("pushOrder" in changedValues) {
|
||||
forceUpdate({});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* 任务名称 */}
|
||||
<Form.Item
|
||||
label="任务名称"
|
||||
name="name"
|
||||
rules={[
|
||||
{ required: true, message: "请输入任务名称" },
|
||||
{ min: 2, max: 50, message: "任务名称长度在2-50个字符之间" },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入任务名称" />
|
||||
</Form.Item>
|
||||
{/* 推送类型 */}
|
||||
<Form.Item
|
||||
label="推送类型"
|
||||
name="pushType"
|
||||
rules={[{ required: true, message: "请选择推送类型" }]}
|
||||
>
|
||||
<Radio.Group>
|
||||
<Radio value={0}>定时推送</Radio>
|
||||
<Radio value={1}>立即推送</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
{/* 允许推送的时间段 - 只在定时推送时显示 */}
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) =>
|
||||
prevValues.pushType !== currentValues.pushType
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
// 只在pushType为0(定时推送)时显示时间段设置
|
||||
return getFieldValue("pushType") === 0 ? (
|
||||
<Form.Item label="允许推送的时间段">
|
||||
<div
|
||||
style={{ display: "flex", gap: 8, alignItems: "center" }}
|
||||
>
|
||||
<Form.Item
|
||||
name="startTime"
|
||||
noStyle
|
||||
rules={[{ required: true, message: "请选择开始时间" }]}
|
||||
>
|
||||
<Input type="time" style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<span style={{ color: "#888" }}>至</span>
|
||||
<Form.Item
|
||||
name="endTime"
|
||||
noStyle
|
||||
rules={[{ required: true, message: "请选择结束时间" }]}
|
||||
>
|
||||
<Input type="time" style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form.Item>
|
||||
) : null;
|
||||
}}
|
||||
</Form.Item>
|
||||
|
||||
{/* 每日推送 */}
|
||||
<Form.Item
|
||||
label="每日推送"
|
||||
name="maxPerDay"
|
||||
rules={[
|
||||
{ required: true, message: "请输入每日推送数量" },
|
||||
{
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 100,
|
||||
message: "每日推送数量在1-100之间",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={100}
|
||||
style={{ width: 120 }}
|
||||
addonAfter="条内容"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 推送顺序 */}
|
||||
<Form.Item
|
||||
label="推送顺序"
|
||||
name="pushOrder"
|
||||
rules={[{ required: true, message: "请选择推送顺序" }]}
|
||||
>
|
||||
<div style={{ display: "flex" }}>
|
||||
<Button
|
||||
type={
|
||||
form.getFieldValue("pushOrder") == 1 ? "primary" : "default"
|
||||
}
|
||||
style={{ borderRadius: "6px 0 0 6px" }}
|
||||
onClick={() => handlePushOrderChange(1)}
|
||||
>
|
||||
按最早
|
||||
</Button>
|
||||
<Button
|
||||
type={
|
||||
form.getFieldValue("pushOrder") == 2 ? "primary" : "default"
|
||||
}
|
||||
style={{ borderRadius: "0 6px 6px 0", marginLeft: -1 }}
|
||||
onClick={() => handlePushOrderChange(2)}
|
||||
>
|
||||
按最新
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
{/* 京东联盟 */}
|
||||
<Form.Item label="京东联盟" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "flex-end" }}>
|
||||
<Form.Item name="socialMediaId" noStyle>
|
||||
<Select
|
||||
placeholder="请选择社交媒体"
|
||||
style={{ width: 200 }}
|
||||
loading={loadingSocialMedia}
|
||||
onChange={handleSocialMediaChange}
|
||||
options={socialMediaList.map(item => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="promotionSiteId" noStyle>
|
||||
<Select
|
||||
placeholder="请选择推广站点"
|
||||
style={{ width: 200 }}
|
||||
loading={loadingPromotionSite}
|
||||
disabled={!form.getFieldValue("socialMediaId")}
|
||||
options={promotionSiteList.map(item => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
{/* 是否循环推送 */}
|
||||
<Form.Item
|
||||
label="是否循环推送"
|
||||
name="isLoop"
|
||||
valuePropName="checked"
|
||||
getValueFromEvent={checked => (checked ? 1 : 0)}
|
||||
getValueProps={value => ({ checked: value === 1 })}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
{/* 是否启用 */}
|
||||
<Form.Item
|
||||
label="是否启用"
|
||||
name="status"
|
||||
valuePropName="checked"
|
||||
getValueFromEvent={checked => (checked ? 1 : 0)}
|
||||
getValueProps={value => ({ checked: value === 1 })}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
{/* 推送类型提示 */}
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) =>
|
||||
prevValues.pushType !== currentValues.pushType
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const pushType = getFieldValue("pushType");
|
||||
if (pushType === 1) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "#fffbe6",
|
||||
border: "1px solid #ffe58f",
|
||||
borderRadius: 4,
|
||||
padding: 8,
|
||||
color: "#ad8b00",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
如果启用立即推送,系统会把内容库里所有的内容按顺序推送到指定的社群
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
BasicSettings.displayName = "BasicSettings";
|
||||
|
||||
export default BasicSettings;
|
||||
@@ -1,96 +0,0 @@
|
||||
import React, { useImperativeHandle, forwardRef } from "react";
|
||||
import { Form, Card } from "antd";
|
||||
import ContentSelection from "@/components/ContentSelection";
|
||||
import { ContentItem } from "@/components/ContentSelection/data";
|
||||
|
||||
interface ContentSelectorProps {
|
||||
selectedOptions: ContentItem[];
|
||||
onPrevious: () => void;
|
||||
onNext: (data: {
|
||||
contentGroups: string[];
|
||||
contentGroupsOptions: ContentItem[];
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export interface ContentSelectorRef {
|
||||
validate: () => Promise<boolean>;
|
||||
getValues: () => any;
|
||||
}
|
||||
|
||||
const ContentSelector = forwardRef<ContentSelectorRef, ContentSelectorProps>(
|
||||
({ selectedOptions, onNext }, ref) => {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// 暴露方法给父组件
|
||||
useImperativeHandle(ref, () => ({
|
||||
validate: async () => {
|
||||
try {
|
||||
await form.validateFields();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log("ContentSelector 表单验证失败:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
getValues: () => {
|
||||
return form.getFieldsValue();
|
||||
},
|
||||
}));
|
||||
|
||||
// 处理选择变化
|
||||
const handleLibrariesChange = (contentGroupsOptions: ContentItem[]) => {
|
||||
const contentGroups = contentGroupsOptions.map(c => c.id.toString());
|
||||
onNext({
|
||||
contentGroups,
|
||||
contentGroupsOptions,
|
||||
});
|
||||
form.setFieldValue("contentGroups", contentGroups);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Card>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
contentGroups: selectedOptions.map(c => Number(c.id)),
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>
|
||||
选择内容库
|
||||
</h2>
|
||||
<p style={{ margin: "8px 0 0 0", color: "#666", fontSize: 14 }}>
|
||||
请选择要推送的内容库
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
name="contentGroups"
|
||||
rules={[
|
||||
{ required: true, message: "请选择至少一个内容库" },
|
||||
{ type: "array", min: 1, message: "请选择至少一个内容库" },
|
||||
{ type: "array", max: 20, message: "最多只能选择20个内容库" },
|
||||
]}
|
||||
>
|
||||
<ContentSelection
|
||||
selectedOptions={selectedOptions}
|
||||
onSelect={handleLibrariesChange}
|
||||
placeholder="选择内容库"
|
||||
showInput={true}
|
||||
showSelectedList={true}
|
||||
readonly={false}
|
||||
selectedListMaxHeight={320}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ContentSelector.displayName = "ContentSelector";
|
||||
|
||||
export default ContentSelector;
|
||||
@@ -1,95 +0,0 @@
|
||||
import React, { useImperativeHandle, forwardRef } from "react";
|
||||
import { Form, Card } from "antd";
|
||||
import GroupSelection from "@/components/GroupSelection";
|
||||
import { GroupSelectionItem } from "@/components/GroupSelection/data";
|
||||
|
||||
interface GroupSelectorProps {
|
||||
selectedGroups: GroupSelectionItem[];
|
||||
onPrevious: () => void;
|
||||
onNext: (data: {
|
||||
wechatGroups: string[];
|
||||
wechatGroupsOptions: GroupSelectionItem[];
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export interface GroupSelectorRef {
|
||||
validate: () => Promise<boolean>;
|
||||
getValues: () => any;
|
||||
}
|
||||
|
||||
const GroupSelector = forwardRef<GroupSelectorRef, GroupSelectorProps>(
|
||||
({ selectedGroups, onNext }, ref) => {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// 暴露方法给父组件
|
||||
useImperativeHandle(ref, () => ({
|
||||
validate: async () => {
|
||||
try {
|
||||
form.setFieldsValue({
|
||||
wechatGroups: selectedGroups.map(item => item.id),
|
||||
});
|
||||
await form.validateFields();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log("GroupSelector 表单验证失败:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
getValues: () => {
|
||||
return form.getFieldsValue();
|
||||
},
|
||||
}));
|
||||
|
||||
// 群组选择
|
||||
const handleGroupSelect = (wechatGroupsOptions: GroupSelectionItem[]) => {
|
||||
const wechatGroups = wechatGroupsOptions.map(item => item.id);
|
||||
form.setFieldValue("wechatGroups", wechatGroups);
|
||||
onNext({ wechatGroups, wechatGroupsOptions });
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{ groups: selectedGroups }}
|
||||
>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>
|
||||
选择推送群组
|
||||
</h2>
|
||||
<p style={{ margin: "8px 0 0 0", color: "#666", fontSize: 14 }}>
|
||||
请选择要推送消息的微信群组
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
name="wechatGroups"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
type: "array",
|
||||
min: 1,
|
||||
message: "请选择至少一个群组",
|
||||
},
|
||||
{ type: "array", max: 50, message: "最多只能选择50个群组" },
|
||||
]}
|
||||
>
|
||||
<GroupSelection
|
||||
selectedOptions={selectedGroups}
|
||||
onSelect={handleGroupSelect}
|
||||
placeholder="选择要推送的群组"
|
||||
readonly={false}
|
||||
showSelectedList={true}
|
||||
selectedListMaxHeight={300}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
GroupSelector.displayName = "GroupSelector";
|
||||
|
||||
export default GroupSelector;
|
||||
@@ -1,22 +0,0 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
export function createGroupPushTask(data) {
|
||||
return request("/v1/workbench/create", { ...data, type: 3 }, "POST");
|
||||
}
|
||||
// 获取自动点赞任务详情
|
||||
export function fetchGroupPushTaskDetail(id: string) {
|
||||
return request("/v1/workbench/detail", { id }, "GET");
|
||||
}
|
||||
|
||||
export function updateGroupPushTask(data) {
|
||||
return request("/v1/workbench/update", { ...data, type: 3 }, "POST");
|
||||
}
|
||||
// 获取京东社交媒体列表
|
||||
export const fetchSocialMediaList = async () => {
|
||||
return request("/v1/workbench/getJdSocialMedia", {}, "GET");
|
||||
};
|
||||
|
||||
// 获取京东推广站点列表
|
||||
export const fetchPromotionSiteList = async (id: number) => {
|
||||
return request("/v1/workbench/getJdPromotionSite", { id }, "GET");
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
import { ContentItem } from "@/components/ContentSelection/data";
|
||||
export interface WechatGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
serviceAccount: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContentLibrary {
|
||||
id: string;
|
||||
name: string;
|
||||
targets: Array<{
|
||||
id: string;
|
||||
avatar: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface FormData {
|
||||
name: string;
|
||||
startTime: string; // 允许推送的开始时间
|
||||
endTime: string; // 允许推送的结束时间
|
||||
dailyPushCount: number;
|
||||
pushOrder: number; // 1: 按最早, 2: 按最新
|
||||
isLoop: number; // 0: 否, 1: 是
|
||||
pushType: number; // 0: 定时推送, 1: 立即推送
|
||||
status: number; // 0: 否, 1: 是
|
||||
contentGroups: string[];
|
||||
wechatGroups: string[];
|
||||
// 京东联盟相关字段
|
||||
socialMediaId?: string;
|
||||
promotionSiteId?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Button } from "antd";
|
||||
import { Toast } from "antd-mobile";
|
||||
import { createGroupPushTask, fetchGroupPushTaskDetail } from "./index.api";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import StepIndicator from "@/components/StepIndicator";
|
||||
import BasicSettings, { BasicSettingsRef } from "./components/BasicSettings";
|
||||
import GroupSelector, { GroupSelectorRef } from "./components/GroupSelector";
|
||||
import ContentSelector, {
|
||||
ContentSelectorRef,
|
||||
} from "./components/ContentSelector";
|
||||
import type { FormData } from "./index.data";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import { GroupSelectionItem } from "@/components/GroupSelection/data";
|
||||
import { ContentItem } from "@/components/ContentSelection/data";
|
||||
const steps = [
|
||||
{ id: 1, title: "步骤 1", subtitle: "基础设置" },
|
||||
{ id: 2, title: "步骤 2", subtitle: "选择社群" },
|
||||
{ id: 3, title: "步骤 3", subtitle: "选择内容库" },
|
||||
];
|
||||
|
||||
const NewGroupPush: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [wechatGroupsOptions, setWechatGroupsOptions] = useState<
|
||||
GroupSelectionItem[]
|
||||
>([]);
|
||||
const [contentGroupsOptions, setContentGroupsOptions] = useState<
|
||||
ContentItem[]
|
||||
>([]);
|
||||
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
name: "",
|
||||
startTime: "06:00", // 允许推送的开始时间
|
||||
dailyPushCount: 0, // 每日已推送次数
|
||||
endTime: "23:59", // 允许推送的结束时间
|
||||
maxPerDay: 20,
|
||||
pushOrder: 2, // 2: 按最新
|
||||
isLoop: 0, // 0: 否, 1: 是
|
||||
pushType: 0, // 0: 定时推送, 1: 立即推送
|
||||
status: 0, // 0: 否, 1: 是
|
||||
wechatGroups: [],
|
||||
contentGroups: [],
|
||||
});
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
|
||||
// 创建子组件的ref
|
||||
const basicSettingsRef = useRef<BasicSettingsRef>(null);
|
||||
const groupSelectorRef = useRef<GroupSelectorRef>(null);
|
||||
const contentSelectorRef = useRef<ContentSelectorRef>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setIsEditMode(true);
|
||||
}, [id]);
|
||||
|
||||
const handleBasicSettingsChange = (values: Partial<FormData>) => {
|
||||
setFormData(prev => ({ ...prev, ...values }));
|
||||
};
|
||||
|
||||
//群组选择
|
||||
const handleGroupsChange = (data: {
|
||||
wechatGroups: string[];
|
||||
wechatGroupsOptions: GroupSelectionItem[];
|
||||
}) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
wechatGroups: data.wechatGroups,
|
||||
}));
|
||||
setWechatGroupsOptions(data.wechatGroupsOptions);
|
||||
};
|
||||
//内容库选择
|
||||
const handleLibrariesChange = (data: {
|
||||
contentGroups: string[];
|
||||
contentGroupsOptions: ContentItem[];
|
||||
}) => {
|
||||
setFormData(prev => ({ ...prev, contentGroups: data.contentGroups }));
|
||||
setContentGroupsOptions(data.contentGroupsOptions);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
// 调用 ContentSelector 的表单校验
|
||||
const isValid = (await contentSelectorRef.current?.validate()) || false;
|
||||
if (!isValid) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
// 获取基础设置中的京东联盟数据
|
||||
const basicSettingsValues = basicSettingsRef.current?.getValues() || {};
|
||||
|
||||
// 构建 API 请求数据
|
||||
const apiData = {
|
||||
name: formData.name,
|
||||
startTime: formData.startTime, // 允许推送的开始时间
|
||||
endTime: formData.endTime, // 允许推送的结束时间
|
||||
maxPerDay: formData.maxPerDay,
|
||||
pushOrder: formData.pushOrder,
|
||||
isLoop: formData.isLoop, // 0: 否, 1: 是
|
||||
pushType: formData.pushType, // 0: 定时推送, 1: 立即推送
|
||||
status: formData.status, // 0: 否, 1: 是
|
||||
wechatGroups: formData.wechatGroups,
|
||||
contentGroups: formData.contentGroups,
|
||||
// 京东联盟数据从基础设置中获取
|
||||
socialMediaId: basicSettingsValues.socialMediaId,
|
||||
promotionSiteId: basicSettingsValues.promotionSiteId,
|
||||
pushMode:
|
||||
formData.pushType === 1
|
||||
? ("immediate" as const)
|
||||
: ("scheduled" as const),
|
||||
messageType: "text" as const,
|
||||
messageContent: "",
|
||||
targetTags: [],
|
||||
pushInterval: 60,
|
||||
};
|
||||
|
||||
// 打印API请求数据,用于调试
|
||||
console.log("发送到API的数据:", apiData);
|
||||
|
||||
// 调用创建或更新 API
|
||||
if (id) {
|
||||
// 更新逻辑将在这里实现
|
||||
Toast.show({ content: "更新成功", position: "top" });
|
||||
navigate("/workspace/group-push");
|
||||
} else {
|
||||
createGroupPushTask(apiData)
|
||||
.then(() => {
|
||||
Toast.show({ content: "创建成功", position: "top" });
|
||||
navigate("/workspace/group-push");
|
||||
})
|
||||
.catch(() => {
|
||||
Toast.show({ content: "创建失败,请稍后重试", position: "top" });
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
Toast.show({ content: "保存失败,请稍后重试", position: "top" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = async () => {
|
||||
if (currentStep < 3) {
|
||||
try {
|
||||
let isValid = false;
|
||||
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
// 调用 BasicSettings 的表单校验
|
||||
isValid = (await basicSettingsRef.current?.validate()) || false;
|
||||
if (isValid) {
|
||||
const values = basicSettingsRef.current?.getValues();
|
||||
if (values) {
|
||||
handleBasicSettingsChange(values);
|
||||
}
|
||||
setCurrentStep(2);
|
||||
}
|
||||
break;
|
||||
|
||||
case 2:
|
||||
// 调用 GroupSelector 的表单校验
|
||||
isValid = (await groupSelectorRef.current?.validate()) || false;
|
||||
if (isValid) {
|
||||
setCurrentStep(3);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
setCurrentStep(currentStep + 1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("表单验证失败:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderFooter = () => {
|
||||
return (
|
||||
<div className="footer-btn-group">
|
||||
{currentStep > 1 && (
|
||||
<Button size="large" onClick={handlePrevious}>
|
||||
上一步
|
||||
</Button>
|
||||
)}
|
||||
{currentStep === 3 ? (
|
||||
<Button size="large" type="primary" onClick={handleSave}>
|
||||
保存
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="large" type="primary" onClick={handleNext}>
|
||||
下一步
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={<NavCommon title={isEditMode ? "编辑任务" : "新建任务"} />}
|
||||
footer={renderFooter()}
|
||||
>
|
||||
<div style={{ padding: 12 }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<StepIndicator currentStep={currentStep} steps={steps} />
|
||||
</div>
|
||||
<div>
|
||||
{currentStep === 1 && (
|
||||
<BasicSettings
|
||||
ref={basicSettingsRef}
|
||||
defaultValues={{
|
||||
name: formData.name,
|
||||
startTime: formData.startTime,
|
||||
endTime: formData.endTime,
|
||||
maxPerDay: formData.maxPerDay,
|
||||
pushOrder: formData.pushOrder,
|
||||
isLoop: formData.isLoop,
|
||||
status: formData.status,
|
||||
pushType: formData.pushType,
|
||||
}}
|
||||
onNext={handleBasicSettingsChange}
|
||||
onSave={handleSave}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<GroupSelector
|
||||
ref={groupSelectorRef}
|
||||
selectedGroups={wechatGroupsOptions}
|
||||
onPrevious={() => setCurrentStep(1)}
|
||||
onNext={handleGroupsChange}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 3 && (
|
||||
<ContentSelector
|
||||
ref={contentSelectorRef}
|
||||
selectedOptions={contentGroupsOptions}
|
||||
onPrevious={() => setCurrentStep(2)}
|
||||
onNext={handleLibrariesChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewGroupPush;
|
||||
@@ -1,44 +0,0 @@
|
||||
import request from "@/api/request";
|
||||
import { GroupPushTask } from "../detail/groupPush";
|
||||
|
||||
interface ApiResponse<T = any> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export async function fetchGroupPushTasks() {
|
||||
return request("/v1/workbench/list", { type: 3 }, "GET");
|
||||
}
|
||||
|
||||
export async function deleteGroupPushTask(id: string): Promise<ApiResponse> {
|
||||
return request("/v1/workbench/delete", { id }, "DELETE");
|
||||
}
|
||||
|
||||
// 切换任务状态
|
||||
export function toggleGroupPushTask(data): Promise<any> {
|
||||
return request("/v1/workbench/update-status", { ...data, type: 3 }, "POST");
|
||||
}
|
||||
|
||||
export async function copyGroupPushTask(id: string): Promise<ApiResponse> {
|
||||
return request("/v1/workbench/copy", { id }, "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}`);
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
.nav-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
.searchBar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
// 只针对当前模块的refresh-btn按钮进行样式设置
|
||||
&.ant-btn {
|
||||
height: 38px !important;
|
||||
width: 40px !important;
|
||||
padding: 0 !important;
|
||||
border-radius: 8px !important;
|
||||
min-width: 40px !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.taskList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 0 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 {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
// CardMenu 样式
|
||||
.menu-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
color: #666;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-dropdown {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 28px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
z-index: 100;
|
||||
min-width: 120px;
|
||||
padding: 4px;
|
||||
border: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
gap: 8px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: #ff4d4f;
|
||||
|
||||
&:hover {
|
||||
background: #fff2f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.taskCard {
|
||||
padding: 12px 6px 8px 6px;
|
||||
}
|
||||
.expandedGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
TeamOutlined,
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
MoreOutlined,
|
||||
ClockCircleOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
CopyOutlined,
|
||||
SendOutlined,
|
||||
CarryOutOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Card, Button, Input, Badge, Switch } from "antd";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import {
|
||||
fetchGroupPushTasks,
|
||||
deleteGroupPushTask,
|
||||
toggleGroupPushTask,
|
||||
copyGroupPushTask,
|
||||
} from "./index.api";
|
||||
import styles from "./index.module.scss";
|
||||
|
||||
// 卡片菜单组件
|
||||
interface CardMenuProps {
|
||||
onView: () => void;
|
||||
onEdit: () => void;
|
||||
onCopy: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
const CardMenu: React.FC<CardMenuProps> = ({ onEdit, onCopy, onDelete }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
if (open) document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<button onClick={() => setOpen(v => !v)} className={styles["menu-btn"]}>
|
||||
<MoreOutlined />
|
||||
</button>
|
||||
{open && (
|
||||
<div ref={menuRef} className={styles["menu-dropdown"]}>
|
||||
<div
|
||||
onClick={() => {
|
||||
onEdit();
|
||||
setOpen(false);
|
||||
}}
|
||||
className={styles["menu-item"]}
|
||||
>
|
||||
<EditOutlined />
|
||||
编辑
|
||||
</div>
|
||||
<div
|
||||
onClick={() => {
|
||||
onCopy();
|
||||
setOpen(false);
|
||||
}}
|
||||
className={styles["menu-item"]}
|
||||
>
|
||||
<CopyOutlined />
|
||||
复制
|
||||
</div>
|
||||
<div
|
||||
onClick={() => {
|
||||
onDelete();
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`${styles["menu-item"]} ${styles["danger"]}`}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
删除
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const GroupPush: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [expandedTaskId, setExpandedTaskId] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [tasks, setTasks] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchTasks = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await fetchGroupPushTasks();
|
||||
setTasks(result.list);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (taskId: string) => {
|
||||
if (!window.confirm("确定要删除该任务吗?")) return;
|
||||
await deleteGroupPushTask(taskId);
|
||||
fetchTasks();
|
||||
};
|
||||
|
||||
const handleEdit = (taskId: string) => {
|
||||
navigate(`/workspace/group-push/edit/${taskId}`);
|
||||
};
|
||||
|
||||
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({ id: taskId, status: 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 "未知";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
loading={loading}
|
||||
header={
|
||||
<>
|
||||
<NavCommon
|
||||
title="群消息推送"
|
||||
backFn={() => navigate("/workspace")}
|
||||
right={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleCreateNew}
|
||||
>
|
||||
创建任务
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className={styles.searchBar}>
|
||||
<Input
|
||||
placeholder="搜索计划名称"
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
size="large"
|
||||
/>
|
||||
<Button
|
||||
onClick={fetchTasks}
|
||||
size="large"
|
||||
className={styles["refresh-btn"]}
|
||||
>
|
||||
<ReloadOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupPush;
|
||||
@@ -1,14 +0,0 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
// 设备统计
|
||||
export function getDeviceStats() {
|
||||
return request("/v1/dashboard/device-stats", {}, "GET");
|
||||
}
|
||||
|
||||
// 微信号统计
|
||||
export function getWechatStats() {
|
||||
return request("/v1/dashboard/wechat-stats", {}, "GET");
|
||||
}
|
||||
|
||||
// 你可以根据需要继续添加其他接口
|
||||
// 例如:场景获客统计、今日数据统计等
|
||||
@@ -1,115 +0,0 @@
|
||||
.workspace {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 16px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.featuresGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.featureLink {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.featureCard {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:global(.adm-card-body) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.featureIcon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.featureHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.featureName {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.newBadge {
|
||||
margin-left: 8px;
|
||||
|
||||
:global(.adm-badge-content) {
|
||||
background-color: var(--primary-color);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.featureDescription {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 375px) {
|
||||
.workspace {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.statsGrid {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.featuresGrid {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.featureCard {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.featureIcon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Card, Badge } from "antd-mobile";
|
||||
import {
|
||||
LikeOutlined,
|
||||
SendOutlined,
|
||||
TeamOutlined,
|
||||
LinkOutlined,
|
||||
ClockCircleOutlined,
|
||||
ContactsOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import MeauMobile from "@/components/MeauMobile/MeauMoible";
|
||||
import styles from "./index.module.scss";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
const Workspace: React.FC = () => {
|
||||
// 常用功能
|
||||
const commonFeatures = [
|
||||
{
|
||||
id: "auto-like",
|
||||
name: "自动点赞",
|
||||
description: "智能自动点赞互动",
|
||||
icon: (
|
||||
<LikeOutlined className={styles.icon} style={{ color: "#ff4d4f" }} />
|
||||
),
|
||||
path: "/workspace/auto-like",
|
||||
bgColor: "#fff2f0",
|
||||
isNew: true,
|
||||
},
|
||||
{
|
||||
id: "moments-sync",
|
||||
name: "朋友圈同步",
|
||||
description: "自动同步朋友圈内容",
|
||||
icon: (
|
||||
<ClockCircleOutlined
|
||||
className={styles.icon}
|
||||
style={{ color: "#722ed1" }}
|
||||
/>
|
||||
),
|
||||
path: "/workspace/moments-sync",
|
||||
bgColor: "#f9f0ff",
|
||||
},
|
||||
{
|
||||
id: "group-push",
|
||||
name: "群消息推送",
|
||||
description: "智能群发助手",
|
||||
icon: (
|
||||
<SendOutlined className={styles.icon} style={{ color: "#fa8c16" }} />
|
||||
),
|
||||
path: "/workspace/group-push",
|
||||
bgColor: "#fff7e6",
|
||||
},
|
||||
{
|
||||
id: "auto-group",
|
||||
name: "自动建群",
|
||||
description: "智能拉好友建群",
|
||||
icon: (
|
||||
<TeamOutlined className={styles.icon} style={{ color: "#52c41a" }} />
|
||||
),
|
||||
path: "/workspace/auto-group",
|
||||
bgColor: "#f6ffed",
|
||||
},
|
||||
{
|
||||
id: "traffic-distribution",
|
||||
name: "流量分发",
|
||||
description: "管理流量分发和分配",
|
||||
icon: (
|
||||
<LinkOutlined className={styles.icon} style={{ color: "#1890ff" }} />
|
||||
),
|
||||
path: "/workspace/traffic-distribution",
|
||||
bgColor: "#e6f7ff",
|
||||
},
|
||||
{
|
||||
id: "contact-import",
|
||||
name: "通讯录导入",
|
||||
description: "批量导入通讯录联系人",
|
||||
icon: (
|
||||
<ContactsOutlined className={styles.icon} style={{ color: "#722ed1" }} />
|
||||
),
|
||||
path: "/workspace/contact-import/list",
|
||||
bgColor: "#f9f0ff",
|
||||
isNew: true,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={<NavCommon left={<></>} title="工作台" />}
|
||||
footer={<MeauMobile activeKey="workspace" />}
|
||||
>
|
||||
<div className={styles.workspace}>
|
||||
{/* 常用功能 */}
|
||||
<div className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>常用功能</h2>
|
||||
<div className={styles.featuresGrid}>
|
||||
{commonFeatures.map(feature => (
|
||||
<Link
|
||||
to={feature.path}
|
||||
key={feature.id}
|
||||
className={styles.featureLink}
|
||||
>
|
||||
<Card className={styles.featureCard}>
|
||||
<div
|
||||
className={styles.featureIcon}
|
||||
style={{ backgroundColor: feature.bgColor }}
|
||||
>
|
||||
{feature.icon}
|
||||
</div>
|
||||
<div className={styles.featureHeader}>
|
||||
<div className={styles.featureName}>{feature.name}</div>
|
||||
{feature.isNew && (
|
||||
<Badge content="New" className={styles.newBadge} />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.featureDescription}>
|
||||
{feature.description}
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI智能助手 */}
|
||||
{/* <div className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>AI 智能助手</h2>
|
||||
<div className={styles.featuresGrid}>
|
||||
{aiFeatures.map(feature => (
|
||||
<Link
|
||||
to={feature.path}
|
||||
key={feature.id}
|
||||
className={styles.featureLink}
|
||||
>
|
||||
<Card className={styles.featureCard}>
|
||||
<div
|
||||
className={styles.featureIcon}
|
||||
style={{ backgroundColor: feature.bgColor }}
|
||||
>
|
||||
{feature.icon}
|
||||
</div>
|
||||
<div className={styles.featureHeader}>
|
||||
<div className={styles.featureName}>{feature.name}</div>
|
||||
{feature.isNew && (
|
||||
<Badge content="New" className={styles.newBadge} />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.featureDescription}>
|
||||
{feature.description}
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Workspace;
|
||||
@@ -1,198 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { Button, Switch, message, Spin } from "antd";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import { EditOutlined } from "@ant-design/icons";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import style from "./index.module.scss";
|
||||
import request from "@/api/request";
|
||||
|
||||
interface MomentsSyncTask {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 1 | 2;
|
||||
deviceCount: number;
|
||||
syncCount: number;
|
||||
lastSyncTime: string;
|
||||
createTime: string;
|
||||
creatorName: string;
|
||||
updateTime?: string;
|
||||
maxSyncPerDay?: number;
|
||||
syncInterval?: number;
|
||||
timeRange?: { start: string; end: string };
|
||||
contentTypes?: string[];
|
||||
targetTags?: string[];
|
||||
todaySyncCount?: number;
|
||||
totalSyncCount?: number;
|
||||
syncMode?: string;
|
||||
config?: {
|
||||
devices?: string[];
|
||||
contentLibraryNames?: string[];
|
||||
syncCount?: number;
|
||||
};
|
||||
}
|
||||
|
||||
const getStatusText = (status: number) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return "进行中";
|
||||
case 2:
|
||||
return "已暂停";
|
||||
default:
|
||||
return "未知";
|
||||
}
|
||||
};
|
||||
|
||||
const MomentsSyncDetail: React.FC = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [task, setTask] = useState<MomentsSyncTask | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchTaskDetail = useCallback(async () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request("/v1/workbench/detail", { id }, "GET");
|
||||
if (res) setTask(res);
|
||||
} catch {
|
||||
message.error("获取任务详情失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) fetchTaskDetail();
|
||||
}, [id, fetchTaskDetail]);
|
||||
|
||||
const handleToggleStatus = async () => {
|
||||
if (!task || !id) return;
|
||||
try {
|
||||
const newStatus = task.status === 1 ? 2 : 1;
|
||||
await request(
|
||||
"/v1/workbench/update-status",
|
||||
{ id, status: newStatus },
|
||||
"POST",
|
||||
);
|
||||
setTask({ ...task, status: newStatus });
|
||||
message.success(newStatus === 1 ? "任务已开启" : "任务已暂停");
|
||||
} catch {
|
||||
message.error("操作失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
if (id) navigate(`/workspace/moments-sync/edit/${id}`);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className={style.detailLoading}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!task) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className={style.detailLoading}>
|
||||
<div>任务不存在</div>
|
||||
<Button onClick={() => navigate("/workspace/moments-sync")}>
|
||||
返回列表
|
||||
</Button>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<NavCommon
|
||||
title="查看朋友圈同步任务"
|
||||
right={
|
||||
<Button
|
||||
icon={<EditOutlined />}
|
||||
onClick={handleEdit}
|
||||
className={style.editBtn}
|
||||
type="primary"
|
||||
>
|
||||
编辑任务
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className={style.detailBg}>
|
||||
<div className={style.detailCard}>
|
||||
<div className={style.detailTop}>
|
||||
<div className={style.detailTitle}>{task.name}</div>
|
||||
<span
|
||||
className={
|
||||
task.status === 1
|
||||
? style.statusPill + " " + style.statusActive
|
||||
: style.statusPill + " " + style.statusPaused
|
||||
}
|
||||
>
|
||||
{getStatusText(task.status)}
|
||||
</span>
|
||||
<Switch
|
||||
checked={task.status === 1}
|
||||
onChange={handleToggleStatus}
|
||||
className={style.switchBtn}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
marginTop: 16,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 8 }}>任务详情</div>
|
||||
<div style={{ fontSize: 14, color: "#222", marginBottom: 4 }}>
|
||||
推送设备:{task.config?.devices?.length || 0} 个
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: "#222", marginBottom: 4 }}>
|
||||
内容库:{task.config?.contentLibraryNames?.join(",") || "-"}
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: "#222", marginBottom: 4 }}>
|
||||
已同步:{task.syncCount || 0} 条
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: "#222" }}>
|
||||
创建人:{task.creatorName}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 8 }}>时间信息</div>
|
||||
<div style={{ fontSize: 14, color: "#222", marginBottom: 4 }}>
|
||||
创建时间:{task.createTime}
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: "#222" }}>
|
||||
上次同步:{task.lastSyncTime || "无"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
borderTop: "1px solid #f0f0f0",
|
||||
margin: "16px 0 0 0",
|
||||
paddingTop: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 500, marginBottom: 8 }}>同步内容预览</div>
|
||||
<div style={{ color: "#888", fontSize: 14 }}>暂无内容预览</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MomentsSyncDetail;
|
||||
@@ -1,356 +0,0 @@
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #188eee;
|
||||
}
|
||||
|
||||
.backBtn {
|
||||
border: none;
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
color: #666;
|
||||
font-size: 18px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.addBtn {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.taskList {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.taskCard {
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
transition: box-shadow 0.2s;
|
||||
&:hover {
|
||||
box-shadow: 0 4px 16px rgba(24, 142, 238, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.taskCardTop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.taskName {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #222;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.switchBtn {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.actionBtn {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.taskCardInfo {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 4px 16px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.emptyBox {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
padding: 40px 0 32px 0;
|
||||
text-align: center;
|
||||
color: #bbb;
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.emptyText {
|
||||
font-size: 16px;
|
||||
color: #888;
|
||||
margin: 16px 0 20px 0;
|
||||
}
|
||||
|
||||
.itemCard {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
margin-bottom: 16px;
|
||||
padding: 16px 16px 12px 16px;
|
||||
transition: box-shadow 0.2s;
|
||||
position: relative;
|
||||
&:hover {
|
||||
box-shadow: 0 4px 16px rgba(24, 142, 238, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.itemTop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.itemTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.itemName {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #222;
|
||||
margin-right: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.statusBadge {
|
||||
margin-left: 2px;
|
||||
.ant-badge-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
.ant-badge-status-success {
|
||||
background: #19c37d;
|
||||
}
|
||||
.ant-badge-status-default {
|
||||
background: #bdbdbd;
|
||||
}
|
||||
.ant-badge-status-text {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
padding: 0 6px;
|
||||
border-radius: 8px;
|
||||
background: #f5f5f5;
|
||||
color: #222;
|
||||
}
|
||||
}
|
||||
|
||||
.itemActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.switchBtn {
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.moreBtn {
|
||||
margin-left: 2px;
|
||||
color: #888;
|
||||
font-size: 18px;
|
||||
background: none;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.itemInfoRow {
|
||||
display: flex;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.infoCol {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.itemBottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
border-top: 1px solid #f3f3f3;
|
||||
margin-top: 8px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
.bottomLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.clockIcon {
|
||||
color: #bdbdbd;
|
||||
font-size: 14px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
.bottomRight {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
// 覆盖Antd Dropdown菜单样式
|
||||
.ant-dropdown-menu {
|
||||
border-radius: 10px !important;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12) !important;
|
||||
min-width: 110px !important;
|
||||
padding: 6px 0 !important;
|
||||
}
|
||||
.ant-dropdown-menu-item {
|
||||
font-size: 14px !important;
|
||||
padding: 7px 16px !important;
|
||||
border-radius: 6px !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.ant-dropdown-menu-item:hover {
|
||||
background: #f5f5f5 !important;
|
||||
}
|
||||
.ant-dropdown-menu-item-danger {
|
||||
color: #e53e3e !important;
|
||||
}
|
||||
|
||||
.detailBg {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detailCard {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
padding: 20px 20px 12px 20px;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.detailTop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.detailTitle {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.statusBadge {
|
||||
margin-left: 2px;
|
||||
.ant-badge-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
.ant-badge-status-success {
|
||||
background: #19c37d;
|
||||
}
|
||||
.ant-badge-status-default {
|
||||
background: #bdbdbd;
|
||||
}
|
||||
.ant-badge-status-text {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
padding: 0 6px;
|
||||
border-radius: 8px;
|
||||
background: #f5f5f5;
|
||||
color: #222;
|
||||
}
|
||||
}
|
||||
|
||||
.switchBtn {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.detailInfoRow {
|
||||
display: flex;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.infoCol {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.detailBottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
border-top: 1px solid #f3f3f3;
|
||||
margin-top: 10px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
.bottomLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.clockIcon {
|
||||
color: #bdbdbd;
|
||||
font-size: 14px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
.bottomRight {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.detailLoading {
|
||||
min-height: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #888;
|
||||
font-size: 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.statusPill {
|
||||
display: inline-block;
|
||||
min-width: 48px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
font-size: 10px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
margin-left: 6px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.statusActive {
|
||||
background: #19c37d;
|
||||
color: #fff;
|
||||
}
|
||||
.statusPaused {
|
||||
background: #e5e7eb;
|
||||
color: #888;
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Switch, Input, message, Dropdown, Menu } from "antd";
|
||||
import { Button } from "antd-mobile";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import {
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
EyeOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
CopyOutlined,
|
||||
MoreOutlined,
|
||||
ClockCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import style from "./index.module.scss";
|
||||
import request from "@/api/request";
|
||||
|
||||
interface MomentsSyncTask {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 1 | 2;
|
||||
deviceCount: number;
|
||||
syncCount: number;
|
||||
lastSyncTime: string;
|
||||
createTime: string;
|
||||
creatorName: string;
|
||||
contentLib?: string;
|
||||
config?: {
|
||||
devices?: string[];
|
||||
contentGroups: number[];
|
||||
contentGroupsOptions?: {
|
||||
id: number;
|
||||
name: string;
|
||||
[key: string]: any;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
const getStatusText = (status: number) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return "进行中";
|
||||
case 2:
|
||||
return "已暂停";
|
||||
default:
|
||||
return "未知";
|
||||
}
|
||||
};
|
||||
|
||||
const MomentsSync: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [tasks, setTasks] = useState<MomentsSyncTask[]>([]);
|
||||
|
||||
const fetchTasks = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request(
|
||||
"/v1/workbench/list",
|
||||
{ type: 2, page: 1, limit: 100 },
|
||||
"GET",
|
||||
);
|
||||
setTasks(res.list || []);
|
||||
} catch (e) {
|
||||
message.error("获取任务失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!window.confirm("确定要删除该任务吗?")) return;
|
||||
try {
|
||||
await request("/v1/workbench/delete", { id }, "DELETE");
|
||||
message.success("删除成功");
|
||||
fetchTasks();
|
||||
} catch {
|
||||
message.error("删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async (id: string) => {
|
||||
try {
|
||||
await request("/v1/workbench/copy", { id }, "POST");
|
||||
message.success("复制成功");
|
||||
fetchTasks();
|
||||
} catch {
|
||||
message.error("复制失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (id: string, status: number) => {
|
||||
const newStatus = status === 1 ? 2 : 1;
|
||||
try {
|
||||
await request(
|
||||
"/v1/workbench/update-status",
|
||||
{ id, status: newStatus },
|
||||
"POST",
|
||||
);
|
||||
setTasks(prev =>
|
||||
prev.map(t => (t.id === id ? { ...t, status: newStatus } : t)),
|
||||
);
|
||||
message.success("操作成功");
|
||||
} catch {
|
||||
message.error("操作失败");
|
||||
}
|
||||
};
|
||||
|
||||
const filteredTasks = tasks.filter(task =>
|
||||
task.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
// 菜单
|
||||
const getMenu = (task: MomentsSyncTask) => (
|
||||
<Menu>
|
||||
<Menu.Item
|
||||
key="view"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => navigate(`/workspace/moments-sync/record/${task.id}`)}
|
||||
>
|
||||
查看
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
key="edit"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => navigate(`/workspace/moments-sync/edit/${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>
|
||||
);
|
||||
|
||||
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 => (
|
||||
<div key={task.id} className={style.itemCard}>
|
||||
<div className={style.itemTop}>
|
||||
<div className={style.itemTitle}>
|
||||
<span className={style.itemName}>{task.name}</span>
|
||||
<span
|
||||
className={
|
||||
task.status === 1
|
||||
? style.statusPill + " " + style.statusActive
|
||||
: style.statusPill + " " + style.statusPaused
|
||||
}
|
||||
>
|
||||
{getStatusText(task.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style.itemActions}>
|
||||
<Switch
|
||||
checked={task.status === 1}
|
||||
onChange={() => handleToggle(task.id, task.status)}
|
||||
className={style.switchBtn}
|
||||
size="small"
|
||||
/>
|
||||
<Dropdown
|
||||
overlay={getMenu(task)}
|
||||
trigger={["click"]}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<button
|
||||
className={style.moreBtn}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
tabIndex={0}
|
||||
aria-label="更多操作"
|
||||
>
|
||||
<MoreOutlined />
|
||||
</button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.itemInfoRow}>
|
||||
<div className={style.infoCol}>
|
||||
推送设备:{task.config?.devices?.length || 0} 个
|
||||
</div>
|
||||
<div className={style.infoCol}>
|
||||
已同步:{task.syncCount || 0} 条
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.itemInfoRow}>
|
||||
<div className={style.infoCol}>
|
||||
内容库:
|
||||
{task.config?.contentGroupsOptions
|
||||
?.map(c => c.name)
|
||||
.join(",") || "默认内容库"}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MomentsSync;
|
||||
@@ -1,13 +0,0 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
// 创建朋友圈同步任务
|
||||
export const createMomentsSync = (params: any) =>
|
||||
request("/v1/workbench/create", params, "POST");
|
||||
|
||||
// 更新朋友圈同步任务
|
||||
export const updateMomentsSync = (params: any) =>
|
||||
request("/v1/workbench/update", params, "POST");
|
||||
|
||||
// 获取朋友圈同步任务详情
|
||||
export const getMomentsSyncDetail = (id: string) =>
|
||||
request("/v1/workbench/detail", { id }, "GET");
|
||||
@@ -1,249 +0,0 @@
|
||||
.formBg {
|
||||
padding: 16px;
|
||||
}
|
||||
.formStepBtnRow {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 32px;
|
||||
padding: 12px;
|
||||
}
|
||||
.formSteps {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 32px;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.formStepIndicator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: #bbb;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.formStepActive {
|
||||
color: #188eee;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.formStepDone {
|
||||
color: #19c37d;
|
||||
}
|
||||
|
||||
.formStepNum {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: #e5e7eb;
|
||||
color: #888;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 15px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.formStepActive .formStepNum {
|
||||
background: #188eee;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.formStepDone .formStepNum {
|
||||
background: #19c37d;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.formStep {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
padding: 32px 24px 24px 24px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
margin: 0 auto 24px auto;
|
||||
}
|
||||
|
||||
.formItem {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.formLabel {
|
||||
font-size: 15px;
|
||||
color: #222;
|
||||
font-weight: 500;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.input {
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.timeRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.inputTime {
|
||||
width: 100px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.timeTo {
|
||||
margin: 0 8px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.counterRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.counterBtn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
font-size: 20px;
|
||||
color: #188eee;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: border 0.2s;
|
||||
}
|
||||
|
||||
.counterBtn:hover {
|
||||
border: 1px solid #188eee;
|
||||
}
|
||||
|
||||
.counterValue {
|
||||
width: 48px;
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.counterUnit {
|
||||
margin-left: 8px;
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.accountTypeRow {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.accountTypeBtn {
|
||||
flex: 1;
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
font-size: 15px;
|
||||
color: #666;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.accountTypeBtn:hover {
|
||||
border: 1px solid #188eee;
|
||||
}
|
||||
|
||||
.accountTypeActive {
|
||||
background: #f0f8ff;
|
||||
border: 1px solid #188eee;
|
||||
color: #188eee;
|
||||
}
|
||||
|
||||
.questionIcon {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.switchRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.switchLabel {
|
||||
font-size: 15px;
|
||||
color: #222;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.switch {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
color: #999;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.selectedTip {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.formStepBtnRow {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.prevBtn {
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.nextBtn {
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.completeBtn {
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.formLoading {
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -1,372 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Button, Input, Switch, message, Spin } from "antd";
|
||||
import { MinusOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import style from "./index.module.scss";
|
||||
|
||||
import StepIndicator from "@/components/StepIndicator";
|
||||
import {
|
||||
createMomentsSync,
|
||||
updateMomentsSync,
|
||||
getMomentsSyncDetail,
|
||||
} from "./api";
|
||||
import DeviceSelection from "@/components/DeviceSelection";
|
||||
import ContentSelection from "@/components/ContentSelection";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||
import { ContentItem } from "@/components/ContentSelection/data";
|
||||
|
||||
const steps = [
|
||||
{ id: 1, title: "基础设置", subtitle: "基础设置" },
|
||||
{ id: 2, title: "设备选择", subtitle: "设备选择" },
|
||||
{ id: 3, title: "内容库选择", subtitle: "内容库选择" },
|
||||
];
|
||||
|
||||
const defaultForm = {
|
||||
taskName: "",
|
||||
startTime: "06:00",
|
||||
endTime: "23:59",
|
||||
syncCount: 5,
|
||||
syncInterval: 30,
|
||||
syncType: 1, // 1=业务号 2=人设号
|
||||
accountType: "business" as "business" | "personal", // 仅UI用
|
||||
enabled: true,
|
||||
deviceGroups: [] as any[],
|
||||
contentGroups: [] as any[], // 存完整内容库对象数组
|
||||
contentTypes: ["text", "image", "video"],
|
||||
targetTags: [] as string[],
|
||||
filterKeywords: [] as string[],
|
||||
};
|
||||
|
||||
const NewMomentsSync: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isEditMode = !!id;
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formData, setFormData] = useState({ ...defaultForm });
|
||||
const [deviceGroupsOptions, setSelectedDevicesOptions] = useState<
|
||||
DeviceSelectionItem[]
|
||||
>([]);
|
||||
const [contentGroupsOptions, setContentGroupsOptions] = useState<
|
||||
ContentItem[]
|
||||
>([]);
|
||||
|
||||
// 获取详情(编辑)
|
||||
const fetchDetail = useCallback(async () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getMomentsSyncDetail(id);
|
||||
if (res) {
|
||||
setFormData({
|
||||
taskName: res.name,
|
||||
startTime: res.timeRange?.start || "06:00",
|
||||
endTime: res.timeRange?.end || "23:59",
|
||||
syncCount: res.config?.syncCount || res.syncCount || 5,
|
||||
syncInterval: res.config?.syncInterval || res.syncInterval || 30,
|
||||
syncType: res.accountType === 1 ? 1 : 2,
|
||||
accountType: res.accountType === 1 ? "business" : "personal",
|
||||
enabled: res.status === 1,
|
||||
deviceGroups: res.config?.deviceGroups || [],
|
||||
// 关键:用id字符串数组回填
|
||||
contentGroups: res.config?.contentGroups || [], // 直接用对象数组
|
||||
contentTypes: res.config?.contentTypes || ["text", "image", "video"],
|
||||
targetTags: res.config?.targetTags || [],
|
||||
filterKeywords: res.config?.filterKeywords || [],
|
||||
});
|
||||
setSelectedDevicesOptions(res.config?.deviceGroupsOptions || []);
|
||||
setContentGroupsOptions(res.config?.contentGroupsOptions || []);
|
||||
}
|
||||
} catch {
|
||||
message.error("获取详情失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditMode) fetchDetail();
|
||||
}, [isEditMode, fetchDetail]);
|
||||
|
||||
// 步骤切换
|
||||
const next = () => setCurrentStep(s => Math.min(s + 1, steps.length - 1));
|
||||
const prev = () => setCurrentStep(s => Math.max(s - 1, 0));
|
||||
|
||||
// 表单数据更新
|
||||
const updateForm = (data: Partial<typeof formData>) => {
|
||||
setFormData(prev => ({ ...prev, ...data }));
|
||||
};
|
||||
|
||||
// UI选择账号类型时同步syncType和accountType
|
||||
const handleAccountTypeChange = (type: "business" | "personal") => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
accountType: type,
|
||||
syncType: type === "business" ? 1 : 2,
|
||||
}));
|
||||
};
|
||||
const handleDevicesChange = (devices: DeviceSelectionItem[]) => {
|
||||
setSelectedDevicesOptions(devices);
|
||||
updateForm({ deviceGroups: devices.map(d => d.id) });
|
||||
};
|
||||
const handleContentChange = (libs: ContentItem[]) => {
|
||||
setContentGroupsOptions(libs);
|
||||
updateForm({ contentGroups: libs });
|
||||
};
|
||||
// 提交
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.taskName.trim()) {
|
||||
message.error("请输入任务名称");
|
||||
return;
|
||||
}
|
||||
if (formData.deviceGroups.length === 0) {
|
||||
message.error("请选择设备");
|
||||
return;
|
||||
}
|
||||
if (formData.contentGroups.length === 0) {
|
||||
message.error("请选择内容库");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = {
|
||||
name: formData.taskName,
|
||||
deviceGroups: formData.deviceGroups,
|
||||
contentGroups: formData.contentGroups.map((lib: any) => lib.id),
|
||||
syncInterval: formData.syncInterval,
|
||||
syncCount: formData.syncCount,
|
||||
syncType: formData.syncType, // 账号类型真实传参
|
||||
accountType: formData.accountType === "business" ? 1 : 2, // 也要传
|
||||
startTime: formData.startTime,
|
||||
endTime: formData.endTime,
|
||||
contentTypes: formData.contentTypes,
|
||||
targetTags: formData.targetTags,
|
||||
filterKeywords: formData.filterKeywords,
|
||||
type: 2,
|
||||
status: formData.enabled ? 1 : 0,
|
||||
};
|
||||
if (isEditMode && id) {
|
||||
await updateMomentsSync({ id, ...params });
|
||||
message.success("更新成功");
|
||||
navigate(`/workspace/moments-sync`);
|
||||
} else {
|
||||
await createMomentsSync(params);
|
||||
message.success("创建成功");
|
||||
navigate("/workspace/moments-sync");
|
||||
}
|
||||
} catch {
|
||||
message.error(isEditMode ? "更新失败" : "创建失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 步骤内容(去除按钮)
|
||||
const renderStep = () => {
|
||||
if (currentStep === 0) {
|
||||
return (
|
||||
<div className={style.formStep}>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>任务名称</div>
|
||||
<Input
|
||||
value={formData.taskName}
|
||||
onChange={e => updateForm({ taskName: e.target.value })}
|
||||
placeholder="请输入任务名称"
|
||||
maxLength={30}
|
||||
className={style.input}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>允许发布时间段</div>
|
||||
<div className={style.timeRow}>
|
||||
<Input
|
||||
type="time"
|
||||
value={formData.startTime}
|
||||
onChange={e => updateForm({ startTime: e.target.value })}
|
||||
className={style.inputTime}
|
||||
/>
|
||||
<span className={style.timeTo}>至</span>
|
||||
<Input
|
||||
type="time"
|
||||
value={formData.endTime}
|
||||
onChange={e => updateForm({ endTime: e.target.value })}
|
||||
className={style.inputTime}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>每日同步数量</div>
|
||||
<div className={style.counterRow}>
|
||||
<button
|
||||
className={style.counterBtn}
|
||||
onClick={() =>
|
||||
updateForm({ syncCount: Math.max(1, formData.syncCount - 1) })
|
||||
}
|
||||
>
|
||||
<MinusOutlined />
|
||||
</button>
|
||||
<span className={style.counterValue}>{formData.syncCount}</span>
|
||||
<button
|
||||
className={style.counterBtn}
|
||||
onClick={() =>
|
||||
updateForm({ syncCount: formData.syncCount + 1 })
|
||||
}
|
||||
>
|
||||
<PlusOutlined />
|
||||
</button>
|
||||
<span className={style.counterUnit}>条朋友圈</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>账号类型</div>
|
||||
<div className={style.accountTypeRow}>
|
||||
<button
|
||||
className={`${style.accountTypeBtn} ${formData.accountType === "business" ? style.accountTypeActive : ""}`}
|
||||
onClick={() => handleAccountTypeChange("business")}
|
||||
>
|
||||
业务号
|
||||
</button>
|
||||
<button
|
||||
className={`${style.accountTypeBtn} ${formData.accountType === "personal" ? style.accountTypeActive : ""}`}
|
||||
onClick={() => handleAccountTypeChange("personal")}
|
||||
>
|
||||
人设号
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.formItem}>
|
||||
<div className={style.switchRow}>
|
||||
<span className={style.switchLabel}>是否启用</span>
|
||||
<Switch
|
||||
checked={formData.enabled}
|
||||
onChange={checked => updateForm({ enabled: checked })}
|
||||
className={style.switch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (currentStep === 1) {
|
||||
return (
|
||||
<div className={style.formStep}>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>选择设备</div>
|
||||
<DeviceSelection
|
||||
selectedOptions={deviceGroupsOptions}
|
||||
onSelect={handleDevicesChange}
|
||||
placeholder="请选择设备"
|
||||
showSelectedList={true}
|
||||
selectedListMaxHeight={200}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (currentStep === 2) {
|
||||
return (
|
||||
<div className={style.formStep}>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>选择内容库</div>
|
||||
<ContentSelection
|
||||
selectedOptions={contentGroupsOptions}
|
||||
onSelect={handleContentChange}
|
||||
placeholder="请选择内容库"
|
||||
showSelectedList={true}
|
||||
selectedListMaxHeight={200}
|
||||
/>
|
||||
{formData.contentGroups.length > 0 && (
|
||||
<div className={style.selectedTip}>
|
||||
已选内容库: {formData.contentGroups.length}个
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 统一底部按钮
|
||||
const renderFooter = () => {
|
||||
if (loading) return null;
|
||||
if (currentStep === 0) {
|
||||
return (
|
||||
<div className={style.formStepBtnRow}>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!formData.taskName.trim()}
|
||||
onClick={next}
|
||||
className={style.nextBtn}
|
||||
block
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (currentStep === 1) {
|
||||
return (
|
||||
<div className={style.formStepBtnRow}>
|
||||
<Button onClick={prev} className={style.prevBtn} block>
|
||||
上一步
|
||||
</Button>
|
||||
<Button type="primary" onClick={next} className={style.nextBtn} block>
|
||||
下一步
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (currentStep === 2) {
|
||||
return (
|
||||
<div className={style.formStepBtnRow}>
|
||||
<Button onClick={prev} className={style.prevBtn} block>
|
||||
上一步
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleSubmit}
|
||||
loading={loading}
|
||||
className={style.completeBtn}
|
||||
block
|
||||
>
|
||||
完成
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<NavCommon title={isEditMode ? "编辑朋友圈同步" : "新建朋友圈同步"} />
|
||||
}
|
||||
footer={renderFooter()}
|
||||
>
|
||||
<div className={style.formBg}>
|
||||
<div style={{ marginBottom: "15px" }}>
|
||||
<StepIndicator currentStep={currentStep + 1} steps={steps} />
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className={style.formLoading}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : (
|
||||
renderStep()
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewMomentsSync;
|
||||
@@ -1,63 +0,0 @@
|
||||
import request from "@/api/request";
|
||||
import {
|
||||
LikeTask,
|
||||
CreateLikeTaskData,
|
||||
UpdateLikeTaskData,
|
||||
LikeRecord,
|
||||
PaginatedResponse,
|
||||
} from "@/pages/workspace/auto-like/record/data";
|
||||
|
||||
// 获取自动点赞任务列表
|
||||
export function fetchAutoLikeTasks(
|
||||
params = { type: 1, page: 1, limit: 100 },
|
||||
): Promise<LikeTask[]> {
|
||||
return request("/v1/workbench/list", params, "GET");
|
||||
}
|
||||
|
||||
// 获取单个任务详情
|
||||
export function fetchAutoLikeTaskDetail(id: string): Promise<LikeTask | null> {
|
||||
return request("/v1/workbench/detail", { id }, "GET");
|
||||
}
|
||||
|
||||
// 创建自动点赞任务
|
||||
export function createAutoLikeTask(data: CreateLikeTaskData): Promise<any> {
|
||||
return request("/v1/workbench/create", { ...data, type: 1 }, "POST");
|
||||
}
|
||||
|
||||
// 更新自动点赞任务
|
||||
export function updateAutoLikeTask(data: UpdateLikeTaskData): Promise<any> {
|
||||
return request("/v1/workbench/update", { ...data, type: 1 }, "POST");
|
||||
}
|
||||
|
||||
// 删除自动点赞任务
|
||||
export function deleteAutoLikeTask(id: string): Promise<any> {
|
||||
return request("/v1/workbench/delete", { id }, "DELETE");
|
||||
}
|
||||
|
||||
// 切换任务状态
|
||||
export function toggleAutoLikeTask(id: string, status: string): Promise<any> {
|
||||
return request("/v1/workbench/update-status", { id, status }, "POST");
|
||||
}
|
||||
|
||||
// 复制自动点赞任务
|
||||
export function copyAutoLikeTask(id: string): Promise<any> {
|
||||
return request("/v1/workbench/copy", { id }, "POST");
|
||||
}
|
||||
|
||||
// 获取点赞记录
|
||||
export function fetchLikeRecords(
|
||||
workbenchId: string,
|
||||
page: number = 1,
|
||||
limit: number = 20,
|
||||
keyword?: string,
|
||||
): Promise<PaginatedResponse<LikeRecord>> {
|
||||
const params: any = {
|
||||
workbenchId,
|
||||
page: page.toString(),
|
||||
limit: limit.toString(),
|
||||
};
|
||||
if (keyword) {
|
||||
params.keyword = keyword;
|
||||
}
|
||||
return request("/v1/workbench/moments-records", params, "GET");
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
// 自动点赞任务状态
|
||||
export type LikeTaskStatus = 1 | 2; // 1: 开启, 2: 关闭
|
||||
|
||||
// 内容类型
|
||||
export type ContentType = "text" | "image" | "video" | "link";
|
||||
|
||||
// 设备信息
|
||||
export interface Device {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "online" | "offline";
|
||||
lastActive: string;
|
||||
}
|
||||
|
||||
// 好友信息
|
||||
export interface Friend {
|
||||
id: string;
|
||||
nickname: string;
|
||||
wechatId: string;
|
||||
avatar: string;
|
||||
tags: string[];
|
||||
region: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
// 点赞记录
|
||||
export interface LikeRecord {
|
||||
id: string;
|
||||
workbenchId: string;
|
||||
momentsId: string;
|
||||
snsId: string;
|
||||
wechatAccountId: string;
|
||||
wechatFriendId: string;
|
||||
likeTime: string;
|
||||
content: string;
|
||||
resUrls: string[];
|
||||
momentTime: string;
|
||||
userName: string;
|
||||
operatorName: string;
|
||||
operatorAvatar: string;
|
||||
friendName: string;
|
||||
friendAvatar: string;
|
||||
}
|
||||
|
||||
// 自动点赞任务
|
||||
export interface LikeTask {
|
||||
id: string;
|
||||
name: string;
|
||||
status: LikeTaskStatus;
|
||||
deviceCount: number;
|
||||
targetGroup: string;
|
||||
likeCount: number;
|
||||
lastLikeTime: string;
|
||||
createTime: string;
|
||||
creator: string;
|
||||
likeInterval: number;
|
||||
maxLikesPerDay: number;
|
||||
timeRange: { start: string; end: string };
|
||||
contentTypes: ContentType[];
|
||||
targetTags: string[];
|
||||
devices: string[];
|
||||
friends: string[];
|
||||
friendMaxLikes: number;
|
||||
friendTags: string;
|
||||
enableFriendTags: boolean;
|
||||
todayLikeCount: number;
|
||||
totalLikeCount: number;
|
||||
updateTime: string;
|
||||
}
|
||||
|
||||
// 创建任务数据
|
||||
export interface CreateLikeTaskData {
|
||||
name: string;
|
||||
interval: number;
|
||||
maxLikes: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
contentTypes: ContentType[];
|
||||
devices: string[];
|
||||
friends?: string[];
|
||||
friendMaxLikes: number;
|
||||
friendTags?: string;
|
||||
enableFriendTags: boolean;
|
||||
targetTags: string[];
|
||||
}
|
||||
|
||||
// 更新任务数据
|
||||
export interface UpdateLikeTaskData extends CreateLikeTaskData {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// 任务配置
|
||||
export interface TaskConfig {
|
||||
interval: number;
|
||||
maxLikes: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
contentTypes: ContentType[];
|
||||
devices: string[];
|
||||
friends: string[];
|
||||
friendMaxLikes: number;
|
||||
friendTags: string;
|
||||
enableFriendTags: boolean;
|
||||
}
|
||||
|
||||
// API响应类型
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
// 分页响应类型
|
||||
export interface PaginatedResponse<T> {
|
||||
list: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Card,
|
||||
Badge,
|
||||
Avatar,
|
||||
Skeleton,
|
||||
message,
|
||||
Spin,
|
||||
Divider,
|
||||
Pagination,
|
||||
} from "antd";
|
||||
import {
|
||||
LikeOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import styles from "./record.module.scss";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import { fetchLikeRecords } from "./api";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (timestamp: number) => {
|
||||
timestamp = timestamp * 1000;
|
||||
try {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch (error) {
|
||||
return timestamp;
|
||||
}
|
||||
};
|
||||
|
||||
export default function AutoLikeRecord() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [records, setRecords] = useState<any[]>([]);
|
||||
const [recordsLoading, setRecordsLoading] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const pageSize = 10;
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setRecordsLoading(true);
|
||||
fetchLikeRecords(id, 1, pageSize)
|
||||
.then((response: any) => {
|
||||
setRecords(response.list || []);
|
||||
setTotal(response.total || 0);
|
||||
setCurrentPage(1);
|
||||
})
|
||||
.catch(() => {
|
||||
message.error("获取发表记录失败,请稍后重试");
|
||||
})
|
||||
.finally(() => setRecordsLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setCurrentPage(1);
|
||||
fetchLikeRecords(id!, 1, pageSize, searchTerm)
|
||||
.then((response: any) => {
|
||||
setRecords(response.list || []);
|
||||
setTotal(response.total || 0);
|
||||
setCurrentPage(1);
|
||||
})
|
||||
.catch(() => {
|
||||
message.error("获取发表记录失败,请稍后重试");
|
||||
});
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchLikeRecords(id!, currentPage, pageSize, searchTerm)
|
||||
.then((response: any) => {
|
||||
setRecords(response.list || []);
|
||||
setTotal(response.total || 0);
|
||||
})
|
||||
.catch(() => {
|
||||
message.error("获取发表记录失败,请稍后重试");
|
||||
});
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
fetchLikeRecords(id!, newPage, pageSize, searchTerm)
|
||||
.then((response: any) => {
|
||||
setRecords(response.list || []);
|
||||
setTotal(response.total || 0);
|
||||
setCurrentPage(newPage);
|
||||
})
|
||||
.catch(() => {
|
||||
message.error("获取发表记录失败,请稍后重试");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<NavCommon title="发表记录" />
|
||||
<div className={styles.headerSearchBar}>
|
||||
<div className={styles.headerSearchInputWrap}>
|
||||
<Input
|
||||
prefix={<SearchOutlined className={styles.headerSearchIcon} />}
|
||||
placeholder="搜索好友昵称或内容"
|
||||
className={styles.headerSearchInput}
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
onPressEnter={handleSearch}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
icon={<ReloadOutlined spin={recordsLoading} />}
|
||||
onClick={handleRefresh}
|
||||
loading={recordsLoading}
|
||||
type="default"
|
||||
shape="circle"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<div className={styles.footerPagination}>
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
onChange={handlePageChange}
|
||||
showSizeChanger={false}
|
||||
showQuickJumper
|
||||
size="default"
|
||||
className={styles.pagination}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={styles.bgWrap}>
|
||||
<div className={styles.contentWrap}>
|
||||
{recordsLoading ? (
|
||||
<div className={styles.skeletonWrap}>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className={styles.skeletonCard}>
|
||||
<div className={styles.skeletonCardHeader}>
|
||||
<Skeleton.Avatar
|
||||
active
|
||||
size={40}
|
||||
className={styles.skeletonAvatar}
|
||||
/>
|
||||
<div className={styles.skeletonNameWrap}>
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
className={styles.skeletonName}
|
||||
style={{ width: 96 }}
|
||||
/>
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
className={styles.skeletonSub}
|
||||
style={{ width: 64 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Divider className={styles.skeletonSep} />
|
||||
<div className={styles.skeletonContentWrap}>
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
className={styles.skeletonContent1}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
className={styles.skeletonContent2}
|
||||
style={{ width: "75%" }}
|
||||
/>
|
||||
<div className={styles.skeletonImgWrap}>
|
||||
<Skeleton.Image
|
||||
active
|
||||
className={styles.skeletonImg}
|
||||
style={{ width: 80, height: 80 }}
|
||||
/>
|
||||
<Skeleton.Image
|
||||
active
|
||||
className={styles.skeletonImg}
|
||||
style={{ width: 80, height: 80 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : records.length === 0 ? (
|
||||
<div className={styles.emptyWrap}>
|
||||
<LikeOutlined className={styles.emptyIcon} />
|
||||
<p className={styles.emptyText}>暂无发表记录</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{records.map(record => (
|
||||
<div key={record.id} className={styles.recordCard}>
|
||||
<div className={styles.recordCardHeader}>
|
||||
<div className={styles.recordCardHeaderLeft}>
|
||||
<Avatar
|
||||
src={record.operatorAvatar || undefined}
|
||||
icon={<UserOutlined />}
|
||||
size={40}
|
||||
className={styles.avatarImg}
|
||||
/>
|
||||
<div className={styles.friendInfo}>
|
||||
<div
|
||||
className={styles.friendName}
|
||||
title={record.friendName}
|
||||
>
|
||||
{record.operatorName}
|
||||
</div>
|
||||
<div className={styles.friendSub}>
|
||||
{formatDate(record.publishTime)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Divider className={styles.cardSep} />
|
||||
<div className={styles.cardContent}>
|
||||
{record.content && (
|
||||
<p className={styles.contentText}>{record.content}</p>
|
||||
)}
|
||||
{Array.isArray(record.resUrls) &&
|
||||
record.resUrls.length > 0 && (
|
||||
<div
|
||||
className={
|
||||
`${styles.imgGrid} ` +
|
||||
(record.resUrls.length === 1
|
||||
? styles.grid1
|
||||
: record.resUrls.length === 2
|
||||
? styles.grid2
|
||||
: record.resUrls.length <= 3
|
||||
? styles.grid3
|
||||
: record.resUrls.length <= 6
|
||||
? styles.grid6
|
||||
: styles.grid9)
|
||||
}
|
||||
>
|
||||
{record.resUrls
|
||||
.slice(0, 9)
|
||||
.map((image: string, idx: number) => (
|
||||
<div key={idx} className={styles.imgItem}>
|
||||
<img
|
||||
src={image}
|
||||
alt={`内容图片 ${idx + 1}`}
|
||||
className={styles.img}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
// 搜索栏
|
||||
.headerSearchBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
.headerSearchInputWrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
.headerSearchIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 10px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #a3a3a3;
|
||||
}
|
||||
.headerSearchInput {
|
||||
padding-left: 32px !important;
|
||||
}
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// 分页
|
||||
.footerPagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
background: #fff;
|
||||
}
|
||||
.pagination {
|
||||
:global(.ant-pagination-item) {
|
||||
border-radius: 6px;
|
||||
}
|
||||
:global(.ant-pagination-item-active) {
|
||||
background: #1890ff;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
:global(.ant-pagination-prev),
|
||||
:global(.ant-pagination-next) {
|
||||
border-radius: 6px;
|
||||
}
|
||||
:global(.ant-pagination-jump-prev),
|
||||
:global(.ant-pagination-jump-next) {
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
// 背景和内容
|
||||
.bgWrap {
|
||||
background: #f7f7fa;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
.contentWrap {
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
// 骨架屏
|
||||
.skeletonWrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.skeletonCard {
|
||||
padding: 0px;
|
||||
}
|
||||
.skeletonCardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.skeletonAvatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.skeletonNameWrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.skeletonName {
|
||||
width: 96px;
|
||||
height: 16px;
|
||||
}
|
||||
.skeletonSub {
|
||||
width: 64px;
|
||||
height: 12px;
|
||||
}
|
||||
.skeletonSep {
|
||||
margin: 12px 0;
|
||||
}
|
||||
.skeletonContentWrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.skeletonContent1 {
|
||||
width: 100%;
|
||||
height: 16px;
|
||||
}
|
||||
.skeletonContent2 {
|
||||
width: 75%;
|
||||
height: 16px;
|
||||
}
|
||||
.skeletonImgWrap {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.skeletonImg {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
// 空状态
|
||||
.emptyWrap {
|
||||
text-align: center;
|
||||
padding: 48px 0;
|
||||
}
|
||||
.emptyIcon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: #e5e7eb;
|
||||
margin: 0 auto 12px auto;
|
||||
}
|
||||
.emptyText {
|
||||
color: #888;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
// 记录卡片
|
||||
.recordCard {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
padding: 16px;
|
||||
}
|
||||
.recordCardHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.recordCardHeaderLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.avatarImg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.friendInfo {
|
||||
min-width: 0;
|
||||
}
|
||||
.friendName {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.friendSub {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
}
|
||||
.timeBadge {
|
||||
background: #e8f0fe;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cardSep {
|
||||
margin: 12px 0;
|
||||
}
|
||||
.cardContent {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.contentText {
|
||||
color: #444;
|
||||
margin-bottom: 12px;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.imgGrid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.grid1 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.grid2 {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.grid3 {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
}
|
||||
.grid6 {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
.grid9 {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr 1fr;
|
||||
}
|
||||
.imgItem {
|
||||
position: relative;
|
||||
aspect-ratio: 1/1;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
// 操作人
|
||||
.operatorWrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 16px;
|
||||
padding: 8px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.operatorAvatar {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.operatorInfo {
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
.operatorName {
|
||||
font-weight: 500;
|
||||
max-width: 100%;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.operatorAction {
|
||||
color: #888;
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 2px;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function TrafficDistributionDetail() {
|
||||
return <div>TrafficDistributionDetail</div>;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import request from "@/api/request";
|
||||
import type { TrafficDistributionFormData } from "./data";
|
||||
|
||||
// 获取流量分发详情
|
||||
export const getTrafficDistributionDetail = (id: string) => {
|
||||
return request("/v1/workbench/detail", { id });
|
||||
};
|
||||
|
||||
// 更新流量分发
|
||||
export const updateTrafficDistribution = (
|
||||
data: TrafficDistributionFormData,
|
||||
) => {
|
||||
return request("/v1/workbench/update", data, "POST");
|
||||
};
|
||||
|
||||
// 创建流量分发
|
||||
export const createTrafficDistribution = (
|
||||
data: TrafficDistributionFormData,
|
||||
) => {
|
||||
return request("/v1/workbench/create", data, "POST");
|
||||
};
|
||||
@@ -1,77 +0,0 @@
|
||||
// 流量分发详情接口
|
||||
export interface TrafficDistributionDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
type: number;
|
||||
status: number;
|
||||
autoStart: number;
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
companyId: number;
|
||||
config: TrafficDistributionConfig;
|
||||
auto_like: any;
|
||||
moments_sync: any;
|
||||
group_push: any;
|
||||
}
|
||||
|
||||
// 流量分发配置接口
|
||||
export interface TrafficDistributionConfig {
|
||||
id: number;
|
||||
workbenchId: number;
|
||||
distributeType: number;
|
||||
maxPerDay: number;
|
||||
timeType: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
accountGroups: any[];
|
||||
accountGroupsOptions: any[];
|
||||
deviceGroups: any[];
|
||||
deviceGroupsOptions: any[];
|
||||
pools: any[];
|
||||
exp: number;
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
lastUpdated: string;
|
||||
total: {
|
||||
dailyAverage: number;
|
||||
totalAccounts: number;
|
||||
deviceCount: number;
|
||||
poolCount: number;
|
||||
totalUsers: number;
|
||||
};
|
||||
}
|
||||
|
||||
// 流量分发表单数据接口
|
||||
export interface TrafficDistributionFormData {
|
||||
id?: string;
|
||||
type: number;
|
||||
name: string;
|
||||
source: string;
|
||||
sourceIcon: string;
|
||||
description: string;
|
||||
distributeType: number;
|
||||
maxPerDay: number;
|
||||
timeType: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
deviceGroups: any[];
|
||||
deviceGroupsOptions: any[];
|
||||
accountGroups: any[];
|
||||
accountGroupsOptions: any[];
|
||||
pools: any[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// 流量池接口
|
||||
export interface TrafficPool {
|
||||
id: string;
|
||||
name: string;
|
||||
userCount: number;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
// 获客场景接口
|
||||
export interface Scenario {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
.formPage {
|
||||
}
|
||||
|
||||
.formHeader {
|
||||
background: #fff;
|
||||
padding: 0 16px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
position: relative;
|
||||
}
|
||||
.formTitle {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
.backBtn {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 10px;
|
||||
font-size: 18px;
|
||||
color: #222;
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
.cancelBtn {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 10px;
|
||||
color: #888;
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.formStepsWrap {
|
||||
background: #fff;
|
||||
padding: 0 0 8px 0;
|
||||
}
|
||||
.formSteps {
|
||||
padding: 0 24px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.formBody {
|
||||
background: #fff;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.sectionTitle {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 18px;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.accountSelectItem {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.deviceSelectItem {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.searchWrapper {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tabWrapper {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tabList {
|
||||
display: flex;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.tabItem {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tabItem:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
background: #fff;
|
||||
color: #1890ff;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.tabContent {
|
||||
min-height: 200px;
|
||||
}
|
||||
.accountListWrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
margin: 10px 0 4px 0;
|
||||
}
|
||||
.accountItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 15px;
|
||||
background: #f7f8fa;
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
border: 1px solid #e5e6eb;
|
||||
transition: border 0.2s;
|
||||
}
|
||||
.accountItem input[type="checkbox"] {
|
||||
margin-right: 6px;
|
||||
}
|
||||
.accountSelectedCount {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.radioGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.radioDesc {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.sliderLabelWrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.sliderValue {
|
||||
font-size: 15px;
|
||||
color: #222;
|
||||
font-weight: 500;
|
||||
}
|
||||
.slider {
|
||||
margin: 0 0 2px 0;
|
||||
}
|
||||
.sliderDesc {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.timeRangeWrap {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.timeRangeWrap > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.formBlock {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.formLabel {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: #222;
|
||||
}
|
||||
.checkboxGroup {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 24px;
|
||||
}
|
||||
.poolListWrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.poolItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #f7f8fa;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #e5e6eb;
|
||||
font-size: 15px;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
transition: border 0.2s;
|
||||
}
|
||||
.poolItem input[type="checkbox"] {
|
||||
margin-right: 6px;
|
||||
}
|
||||
.poolName {
|
||||
font-weight: 500;
|
||||
color: #222;
|
||||
}
|
||||
.poolTags {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.poolCount {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-left: auto;
|
||||
}
|
||||
.poolSelectedCount {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.formStepBtns {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.formFooter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
background: #fff;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.footerBtn {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
// 步骤条美化
|
||||
.formSteps :global(.ant-steps-item-title) {
|
||||
font-size: 15px;
|
||||
}
|
||||
.formSteps :global(.ant-steps-item-process .ant-steps-item-title) {
|
||||
color: #1677ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.formSteps :global(.ant-steps-item-finish .ant-steps-item-title) {
|
||||
color: #222;
|
||||
}
|
||||
.formSteps :global(.ant-steps-item-wait .ant-steps-item-title) {
|
||||
color: #888;
|
||||
}
|
||||
@@ -1,470 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Form,
|
||||
Input,
|
||||
Button,
|
||||
Radio,
|
||||
Slider,
|
||||
TimePicker,
|
||||
message,
|
||||
Checkbox,
|
||||
} from "antd";
|
||||
import { SearchOutlined } from "@ant-design/icons";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import style from "./index.module.scss";
|
||||
import StepIndicator from "@/components/StepIndicator";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import AccountSelection from "@/components/AccountSelection";
|
||||
import { AccountItem } from "@/components/AccountSelection/data";
|
||||
import DeviceSelection from "@/components/DeviceSelection";
|
||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||
import {
|
||||
getTrafficDistributionDetail,
|
||||
updateTrafficDistribution,
|
||||
createTrafficDistribution,
|
||||
} from "./api";
|
||||
import type {
|
||||
TrafficDistributionDetail,
|
||||
TrafficDistributionFormData,
|
||||
} from "./data";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
const scenarioList = [
|
||||
{ label: "海报获客", value: "poster" },
|
||||
{ label: "电话获客", value: "phone" },
|
||||
{ label: "抖音获客", value: "douyin" },
|
||||
{ label: "小红书获客", value: "xiaohongshu" },
|
||||
{ label: "微信群获客", value: "weixinqun" },
|
||||
{ label: "API获客", value: "api" },
|
||||
{ label: "订单获客", value: "order" },
|
||||
{ label: "付款码获客", value: "payment" },
|
||||
];
|
||||
const poolList = [
|
||||
{
|
||||
id: "pool-1",
|
||||
name: "高价值客户池",
|
||||
userCount: 156,
|
||||
tags: ["高价值", "优先添加"],
|
||||
},
|
||||
{ id: "pool-2", name: "潜在客户池", userCount: 289, tags: ["潜在客户"] },
|
||||
{ id: "pool-3", name: "新用户池", userCount: 432, tags: ["新用户"] },
|
||||
];
|
||||
|
||||
const stepList = [
|
||||
{ id: 1, title: "基本信息", subtitle: "基本信息" },
|
||||
{ id: 2, title: "目标设置", subtitle: "目标设置" },
|
||||
{ id: 3, title: "流量池选择", subtitle: "流量池选择" },
|
||||
];
|
||||
|
||||
const TrafficDistributionForm: React.FC = () => {
|
||||
const [form] = Form.useForm();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isEdit = !!id;
|
||||
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [selectedAccounts, setSelectedAccounts] = useState<AccountItem[]>([]);
|
||||
const [selectedDevices, setSelectedDevices] = useState<DeviceSelectionItem[]>(
|
||||
[],
|
||||
);
|
||||
// 设备组和账号组数据
|
||||
const [deviceGroups, setDeviceGroups] = useState<any[]>([]);
|
||||
const [deviceGroupsOptions, setDeviceGroupsOptions] = useState<any[]>([]);
|
||||
const [accountGroups, setAccountGroups] = useState<any[]>([]);
|
||||
const [accountGroupsOptions, setAccountGroupsOptions] = useState<any[]>([]);
|
||||
const [distributeType, setDistributeType] = useState(1);
|
||||
const [maxPerDay, setMaxPerDay] = useState(50);
|
||||
const [timeType, setTimeType] = useState(1);
|
||||
const [timeRange, setTimeRange] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
const [selectedPools, setSelectedPools] = useState<string[]>([]);
|
||||
const [poolSearch, setPoolSearch] = useState("");
|
||||
const [targetSelectionTab, setTargetSelectionTab] = useState<
|
||||
"device" | "account"
|
||||
>("device");
|
||||
|
||||
// 编辑时的详情数据
|
||||
const [detailData, setDetailData] =
|
||||
useState<TrafficDistributionDetail | null>(null);
|
||||
|
||||
// 生成默认名称
|
||||
const generateDefaultName = () => {
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().slice(0, 10).replace(/-/g, "");
|
||||
const timeStr = now.toTimeString().slice(0, 5).replace(/:/g, "");
|
||||
return `流量分发 ${dateStr} ${timeStr}`;
|
||||
};
|
||||
|
||||
// 获取详情数据
|
||||
useEffect(() => {
|
||||
if (isEdit && id) {
|
||||
fetchDetail();
|
||||
}
|
||||
}, [isEdit, id]);
|
||||
|
||||
const fetchDetail = async () => {
|
||||
if (!id) return;
|
||||
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const detail = await getTrafficDistributionDetail(id);
|
||||
setDetailData(detail);
|
||||
|
||||
// 回填表单数据
|
||||
const config = detail.config;
|
||||
form.setFieldsValue({
|
||||
name: detail.name,
|
||||
distributeType: config.distributeType,
|
||||
maxPerDay: config.maxPerDay,
|
||||
timeType: config.timeType,
|
||||
});
|
||||
|
||||
// 设置状态
|
||||
setDistributeType(config.distributeType);
|
||||
setMaxPerDay(config.maxPerDay);
|
||||
setTimeType(config.timeType);
|
||||
|
||||
// 设置账号组数据
|
||||
setAccountGroups(config.accountGroups || []);
|
||||
setAccountGroupsOptions(config.accountGroupsOptions || []);
|
||||
setSelectedAccounts(config.accountGroupsOptions || []);
|
||||
|
||||
// 设置设备组数据
|
||||
setDeviceGroups(config.deviceGroups || []);
|
||||
setDeviceGroupsOptions(config.deviceGroupsOptions || []);
|
||||
setSelectedDevices(config.deviceGroupsOptions || []);
|
||||
|
||||
// 设置时间范围 - 使用dayjs格式
|
||||
if (config.timeType === 2 && config.startTime && config.endTime) {
|
||||
const [startHour, startMinute] = config.startTime
|
||||
.split(":")
|
||||
.map(Number);
|
||||
const [endHour, endMinute] = config.endTime.split(":").map(Number);
|
||||
|
||||
// 使用dayjs创建时间对象
|
||||
const startTime = dayjs().hour(startHour).minute(startMinute).second(0);
|
||||
const endTime = dayjs().hour(endHour).minute(endMinute).second(0);
|
||||
setTimeRange([startTime, endTime]);
|
||||
}
|
||||
|
||||
// 设置流量池
|
||||
setSelectedPools(config.pools.map((pool: any) => pool.id || pool));
|
||||
} catch (error) {
|
||||
console.error("获取详情失败:", error);
|
||||
message.error("获取详情失败");
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinish = async (values?: any) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 如果没有传递values参数,从表单中获取
|
||||
const formValues = values || form.getFieldsValue();
|
||||
|
||||
const formData: TrafficDistributionFormData = {
|
||||
id: id,
|
||||
type: 5, // 流量分发类型
|
||||
name: formValues.name,
|
||||
source: "",
|
||||
sourceIcon: "",
|
||||
description: "",
|
||||
distributeType: distributeType,
|
||||
maxPerDay: maxPerDay,
|
||||
timeType: timeType,
|
||||
startTime:
|
||||
timeType === 2 && timeRange?.[0] ? timeRange[0].format("HH:mm") : "",
|
||||
endTime:
|
||||
timeType === 2 && timeRange?.[1] ? timeRange[1].format("HH:mm") : "",
|
||||
deviceGroups: deviceGroups,
|
||||
deviceGroupsOptions: deviceGroupsOptions,
|
||||
accountGroups: accountGroups,
|
||||
accountGroupsOptions: accountGroupsOptions,
|
||||
pools: selectedPools,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
await updateTrafficDistribution(formData);
|
||||
message.success("更新流量分发成功");
|
||||
} else {
|
||||
await createTrafficDistribution(formData);
|
||||
message.success("新建流量分发成功");
|
||||
}
|
||||
|
||||
navigate(-1);
|
||||
} catch (e) {
|
||||
message.error(isEdit ? "更新失败" : "新建失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 步骤切换
|
||||
const next = () => {
|
||||
if (current === 0) {
|
||||
// 第一步需要验证表单
|
||||
form
|
||||
.validateFields()
|
||||
.then(() => {
|
||||
setCurrent(cur => cur + 1);
|
||||
})
|
||||
.catch(() => {
|
||||
// 验证失败,不进行下一步
|
||||
});
|
||||
} else {
|
||||
setCurrent(cur => cur + 1);
|
||||
}
|
||||
};
|
||||
const prev = () => setCurrent(cur => cur - 1);
|
||||
|
||||
// 过滤流量池
|
||||
const filteredPools = poolList.filter(pool => pool.name.includes(poolSearch));
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<NavCommon title={isEdit ? "编辑流量分发" : "新建流量分发"} />
|
||||
<div className={style.formStepsWrap}>
|
||||
<StepIndicator currentStep={current + 1} steps={stepList} />
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
loading={detailLoading}
|
||||
footer={
|
||||
<div className="footer-btn-group">
|
||||
{current > 0 && (
|
||||
<Button size="large" onClick={prev}>
|
||||
上一步
|
||||
</Button>
|
||||
)}
|
||||
{current < 2 ? (
|
||||
<Button size="large" type="primary" onClick={next}>
|
||||
下一步
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
onClick={handleFinish}
|
||||
loading={loading}
|
||||
>
|
||||
提交
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={style.formPage}>
|
||||
<div className={style.formBody}>
|
||||
{current === 0 && (
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
name: isEdit ? "" : generateDefaultName(),
|
||||
distributeType: 1,
|
||||
maxPerDay: 50,
|
||||
timeType: 1,
|
||||
}}
|
||||
disabled={detailLoading}
|
||||
>
|
||||
<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
|
||||
value={distributeType}
|
||||
onChange={e => setDistributeType(e.target.value)}
|
||||
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} 人/天</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={1}
|
||||
max={100}
|
||||
value={maxPerDay}
|
||||
onChange={setMaxPerDay}
|
||||
className={style.slider}
|
||||
/>
|
||||
<div className={style.sliderDesc}>
|
||||
限制每天最多分配的流量数量
|
||||
</div>
|
||||
</Form.Item>
|
||||
<Form.Item label="时间限制" name="timeType" required>
|
||||
<Radio.Group
|
||||
value={timeType}
|
||||
onChange={e => setTimeType(e.target.value)}
|
||||
className={style.radioGroup}
|
||||
>
|
||||
<Radio value={1}>全天分配</Radio>
|
||||
<Radio value={2}>自定义时间段</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
{timeType === 2 && (
|
||||
<Form.Item label="" required>
|
||||
<div className={style.timeRangeWrap}>
|
||||
<div>
|
||||
<span>开始时间</span>
|
||||
<TimePicker
|
||||
format="HH:mm"
|
||||
style={{ width: 120 }}
|
||||
value={timeRange?.[0]}
|
||||
onChange={v => setTimeRange([v, timeRange?.[1]])}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span>结束时间</span>
|
||||
<TimePicker
|
||||
format="HH:mm"
|
||||
style={{ width: 120 }}
|
||||
value={timeRange?.[1]}
|
||||
onChange={v => setTimeRange([timeRange?.[0], v])}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
)}
|
||||
{current === 1 && (
|
||||
<div>
|
||||
<div className={style.sectionTitle}>目标设置</div>
|
||||
|
||||
{/* Tab 切换 */}
|
||||
<div className={style.tabWrapper}>
|
||||
<div className={style.tabList}>
|
||||
<div
|
||||
className={`${style.tabItem} ${
|
||||
targetSelectionTab === "device" ? style.tabActive : ""
|
||||
}`}
|
||||
onClick={() => setTargetSelectionTab("device")}
|
||||
>
|
||||
设备选择
|
||||
</div>
|
||||
<div
|
||||
className={`${style.tabItem} ${
|
||||
targetSelectionTab === "account" ? style.tabActive : ""
|
||||
}`}
|
||||
onClick={() => setTargetSelectionTab("account")}
|
||||
>
|
||||
客服选择
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab 内容 */}
|
||||
<div className={style.tabContent}>
|
||||
{targetSelectionTab === "device" && (
|
||||
<div className={style.formBlock}>
|
||||
<DeviceSelection
|
||||
selectedOptions={selectedDevices}
|
||||
onSelect={devices => {
|
||||
setSelectedDevices(devices);
|
||||
setDeviceGroupsOptions(devices);
|
||||
}}
|
||||
placeholder="请选择设备"
|
||||
showSelectedList={true}
|
||||
selectedListMaxHeight={300}
|
||||
deviceGroups={deviceGroups}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{targetSelectionTab === "account" && (
|
||||
<div className={style.formBlock}>
|
||||
<AccountSelection
|
||||
selectedOptions={selectedAccounts}
|
||||
onSelect={accounts => {
|
||||
setSelectedAccounts(accounts);
|
||||
setAccountGroupsOptions(accounts);
|
||||
}}
|
||||
placeholder="请选择客服"
|
||||
showSelectedList={true}
|
||||
selectedListMaxHeight={300}
|
||||
accountGroups={accountGroups}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{current === 2 && (
|
||||
<div>
|
||||
<div className={style.sectionTitle}>流量池选择</div>
|
||||
<div className={style.formBlock}>
|
||||
<Input
|
||||
placeholder="搜索流量池"
|
||||
value={poolSearch}
|
||||
onChange={e => setPoolSearch(e.target.value)}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
<div className={style.poolListWrap}>
|
||||
{filteredPools.map(pool => (
|
||||
<label key={pool.id} className={style.poolItem}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedPools.includes(pool.id)}
|
||||
onChange={e => {
|
||||
setSelectedPools(val =>
|
||||
e.target.checked
|
||||
? [...val, pool.id]
|
||||
: val.filter(v => v !== pool.id),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className={style.poolName}>{pool.name}</span>
|
||||
<span className={style.poolTags}>
|
||||
{pool.tags.join("/")}
|
||||
</span>
|
||||
<span className={style.poolCount}>
|
||||
{pool.userCount}人
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className={style.poolSelectedCount}>
|
||||
已选流量池:<span>{selectedPools.length}</span> 个
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrafficDistributionForm;
|
||||
@@ -1,43 +0,0 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
// 获取流量分发规则列表
|
||||
export function fetchDistributionRuleList(params: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
keyword?: string;
|
||||
}): Promise<any> {
|
||||
return request("/v1/workbench/list?type=5", params, "GET");
|
||||
}
|
||||
|
||||
// 编辑计划(更新)
|
||||
export function updateDistributionRule(data: any): Promise<any> {
|
||||
return request("/v1/workbench/update", { ...data, type: 5 }, "POST");
|
||||
}
|
||||
|
||||
// 暂停/启用计划
|
||||
export function toggleDistributionRuleStatus(
|
||||
id: number,
|
||||
status: 0 | 1,
|
||||
): Promise<any> {
|
||||
return request("/v1/workbench/update-status", { id, status }, "POST");
|
||||
}
|
||||
|
||||
// 删除计划
|
||||
export function deleteDistributionRule(id: number): Promise<any> {
|
||||
return request("/v1/workbench/delete", { id }, "POST");
|
||||
}
|
||||
|
||||
// 获取流量分发规则详情
|
||||
export function fetchDistributionRuleDetail(id: number): Promise<any> {
|
||||
return request(`/v1/workbench/detail?id=${id}`, {}, "GET");
|
||||
}
|
||||
|
||||
//流量分发记录
|
||||
export function fetchTransferFriends(params: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
keyword?: string;
|
||||
workbenchId: number;
|
||||
}) {
|
||||
return request("/v1/workbench/transfer-friends", params, "GET");
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Popup, Avatar, SpinLoading } from "antd-mobile";
|
||||
import { Button, message } from "antd";
|
||||
import { CloseOutlined } from "@ant-design/icons";
|
||||
import style from "../index.module.scss";
|
||||
import { fetchDistributionRuleDetail } from "../api";
|
||||
|
||||
interface AccountItem {
|
||||
id: string | number;
|
||||
nickname?: string;
|
||||
wechatId?: string;
|
||||
avatar?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface AccountListModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
ruleId?: number;
|
||||
ruleName?: string;
|
||||
}
|
||||
|
||||
const AccountListModal: React.FC<AccountListModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
ruleId,
|
||||
ruleName,
|
||||
}) => {
|
||||
const [accounts, setAccounts] = useState<AccountItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 获取账号数据
|
||||
const fetchAccounts = async () => {
|
||||
if (!ruleId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const detailRes = await fetchDistributionRuleDetail(ruleId);
|
||||
const accountData = detailRes?.config?.accountGroupsOptions || [];
|
||||
setAccounts(accountData);
|
||||
} catch (error) {
|
||||
console.error("获取账号详情失败:", error);
|
||||
message.error("获取账号详情失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 当弹窗打开且有ruleId时,获取数据
|
||||
useEffect(() => {
|
||||
if (visible && ruleId) {
|
||||
fetchAccounts();
|
||||
}
|
||||
}, [visible, ruleId]);
|
||||
|
||||
const title = ruleName ? `${ruleName} - 分发账号列表` : "分发账号列表";
|
||||
const getStatusColor = (status?: string) => {
|
||||
switch (status) {
|
||||
case "normal":
|
||||
return "#52c41a";
|
||||
case "limited":
|
||||
return "#faad14";
|
||||
case "blocked":
|
||||
return "#ff4d4f";
|
||||
default:
|
||||
return "#d9d9d9";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status?: string) => {
|
||||
switch (status) {
|
||||
case "normal":
|
||||
return "正常";
|
||||
case "limited":
|
||||
return "受限";
|
||||
case "blocked":
|
||||
return "封禁";
|
||||
default:
|
||||
return "未知";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popup
|
||||
visible={visible}
|
||||
onMaskClick={onClose}
|
||||
position="bottom"
|
||||
bodyStyle={{
|
||||
height: "70vh",
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
}}
|
||||
>
|
||||
<div className={style.accountModal}>
|
||||
{/* 头部 */}
|
||||
<div className={style.accountModalHeader}>
|
||||
<h3 className={style.accountModalTitle}>{title}</h3>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={onClose}
|
||||
className={style.accountModalClose}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 账号列表 */}
|
||||
<div className={style.accountList}>
|
||||
{loading ? (
|
||||
<div className={style.accountLoading}>
|
||||
<SpinLoading color="primary" />
|
||||
<div className={style.accountLoadingText}>
|
||||
正在加载账号列表...
|
||||
</div>
|
||||
</div>
|
||||
) : accounts.length > 0 ? (
|
||||
accounts.map((account, index) => (
|
||||
<div key={account.id || index} className={style.accountItem}>
|
||||
<div className={style.accountAvatar}>
|
||||
<Avatar
|
||||
src={account.avatar}
|
||||
style={{ "--size": "48px" }}
|
||||
fallback={
|
||||
(account.nickname || account.wechatId || "账号")[0]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.accountInfo}>
|
||||
<div className={style.accountName}>
|
||||
{account.nickname ||
|
||||
account.wechatId ||
|
||||
`账号${account.id}`}
|
||||
</div>
|
||||
<div className={style.accountWechatId}>
|
||||
{account.wechatId || "未绑定微信号"}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.accountStatus}>
|
||||
<span
|
||||
className={style.statusDot}
|
||||
style={{ backgroundColor: getStatusColor(account.status) }}
|
||||
/>
|
||||
<span className={style.statusText}>
|
||||
{getStatusText(account.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className={style.accountEmpty}>
|
||||
<div className={style.accountEmptyText}>暂无账号数据</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部统计 */}
|
||||
<div className={style.accountModalFooter}>
|
||||
<div className={style.accountStats}>
|
||||
<span>共 {accounts.length} 个账号</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountListModal;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user