Files
ckb-SuperAdmin/nkebao/src/pages/scenarios/plan/list/index.tsx
笔记本里的永平 02049ed91d feat: 本次提交更新内容如下
流量池样式完成了
2025-07-24 20:30:40 +08:00

612 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import {
NavBar,
Button,
Toast,
SpinLoading,
Dialog,
Popup,
Card,
Tag,
} from "antd-mobile";
import { Input } from "antd";
import {
PlusOutlined,
CopyOutlined,
DeleteOutlined,
SettingOutlined,
SearchOutlined,
ReloadOutlined,
QrcodeOutlined,
EditOutlined,
MoreOutlined,
ClockCircleOutlined,
DownOutlined,
} 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";
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 [currentPage, setCurrentPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [total, setTotal] = useState(0);
const pageSize = 20;
// 获取渠道中文名称
const getChannelName = (channel: string) => {
const channelMap: Record<string, string> = {
douyin: "抖音直播获客",
kuaishou: "快手直播获客",
xiaohongshu: "小红书种草获客",
weibo: "微博话题获客",
haibao: "海报扫码获客",
phone: "电话号码获客",
gongzhonghao: "公众号引流获客",
weixinqun: "微信群裂变获客",
payment: "付款码获客",
api: "API接口获客",
};
return channelMap[channel] || `${channel}获客`;
};
// 获取计划列表数据
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 handleLoadMore = async () => {
if (loadingMore || !hasMore) return;
await fetchPlanList(currentPage + 1, true);
};
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 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: "qrcode",
text: "二维码",
icon: <QrcodeOutlined />,
onClick: () => {
setShowActionMenu(null);
handleShowQrCode(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}
>
<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={() => setShowActionMenu(task.id)}
>
<MoreOutlined />
</Button>
</div>
</div>
{/* 统计数据网格 */}
<div className={style["stats-grid"]}>
<div className={style["stat-item"]}>
<div className={style["stat-label"]}></div>
<div className={style["stat-value"]}>
{deviceCount(task)}
</div>
</div>
<div className={style["stat-item"]}>
<div className={style["stat-label"]}></div>
<div className={style["stat-value"]}>
{task?.acquiredCount || 0}
</div>
</div>
<div className={style["stat-item"]}>
<div className={style["stat-label"]}></div>
<div className={style["stat-value"]}>
{task.addedCount || 0}
</div>
</div>
<div className={style["stat-item"]}>
<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>
</Card>
))}
{/* 加载更多按钮 */}
{hasMore && (
<div className={style["load-more-container"]}>
<Button
color="primary"
fill="outline"
size="large"
onClick={handleLoadMore}
loading={loadingMore}
className={style["load-more-btn"]}
>
{loadingMore ? (
<>
<SpinLoading color="primary" />
...
</>
) : (
<>
<DownOutlined />
</>
)}
</Button>
</div>
)}
{/* 没有更多数据提示 */}
{!hasMore && filteredTasks.length > 0 && (
<div className={style["no-more-data"]}>
<span></span>
</div>
)}
</>
)}
</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>
</div>
</Layout>
);
};
export default ScenarioList;