feat: 本次提交更新内容如下
更新
This commit is contained in:
@@ -7,12 +7,12 @@ import {
|
||||
Toast,
|
||||
SpinLoading,
|
||||
Dialog,
|
||||
Input,
|
||||
Popup,
|
||||
Card,
|
||||
Tag,
|
||||
Space,
|
||||
} from "antd-mobile";
|
||||
import { Input } from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
UserOutlined,
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
ReloadOutlined,
|
||||
QrcodeOutlined,
|
||||
EditOutlined,
|
||||
MoreOutlined,
|
||||
ClockCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
@@ -36,40 +38,7 @@ import {
|
||||
getWxMinAppCode,
|
||||
} from "./api";
|
||||
import style from "./index.module.scss";
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
interface ScenarioData {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
description: string;
|
||||
totalPlans: number;
|
||||
totalCustomers: number;
|
||||
todayCustomers: number;
|
||||
growth: string;
|
||||
}
|
||||
|
||||
interface ApiSettings {
|
||||
apiKey: string;
|
||||
webhookUrl: string;
|
||||
taskId: string;
|
||||
}
|
||||
import { Task, ApiSettings } from "./data";
|
||||
|
||||
const ScenarioList: React.FC = () => {
|
||||
const { scenarioId, scenarioName } = useParams<{
|
||||
@@ -78,10 +47,9 @@ const ScenarioList: React.FC = () => {
|
||||
}>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [scenario, setScenario] = useState<ScenarioData | null>(null);
|
||||
const [pageTitle, setPageTitle] = useState<string>("");
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [showApiDialog, setShowApiDialog] = useState(false);
|
||||
const [currentApiSettings, setCurrentApiSettings] = useState<ApiSettings>({
|
||||
apiKey: "",
|
||||
@@ -93,6 +61,7 @@ const ScenarioList: React.FC = () => {
|
||||
const [showQrDialog, setShowQrDialog] = useState(false);
|
||||
const [qrLoading, setQrLoading] = useState(false);
|
||||
const [qrImg, setQrImg] = useState("");
|
||||
const [showActionMenu, setShowActionMenu] = useState<string | null>(null);
|
||||
|
||||
// 获取渠道中文名称
|
||||
const getChannelName = (channel: string) => {
|
||||
@@ -111,27 +80,19 @@ const ScenarioList: React.FC = () => {
|
||||
return channelMap[channel] || `${channel}获客`;
|
||||
};
|
||||
|
||||
// 获取场景描述
|
||||
const getScenarioDescription = (channel: string) => {
|
||||
const descriptions: Record<string, string> = {
|
||||
douyin: "通过抖音平台进行精准获客,利用短视频内容吸引目标用户",
|
||||
xiaohongshu: "利用小红书平台进行内容营销获客,通过优质内容建立品牌形象",
|
||||
gongzhonghao: "通过微信公众号进行获客,建立私域流量池",
|
||||
haibao: "通过海报分享进行获客,快速传播品牌信息",
|
||||
phone: "通过电话营销进行获客,直接与客户沟通",
|
||||
weixinqun: "通过微信群进行获客,利用社交裂变效应",
|
||||
payment: "通过付款码进行获客,便捷的支付方式",
|
||||
api: "通过API接口进行获客,支持第三方系统集成",
|
||||
};
|
||||
return descriptions[channel] || "通过该平台进行获客";
|
||||
};
|
||||
// 获取场景名称
|
||||
const getScenarioName = useCallback(() => {
|
||||
const urlName = searchParams.get("name");
|
||||
if (urlName) {
|
||||
return urlName;
|
||||
}
|
||||
return getChannelName(scenarioId || "");
|
||||
}, [searchParams, scenarioId]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchScenarioData = async () => {
|
||||
if (!scenarioId) return;
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
// 获取计划列表
|
||||
@@ -142,98 +103,37 @@ const ScenarioList: React.FC = () => {
|
||||
});
|
||||
|
||||
// 设置计划列表
|
||||
if (response && response.data && response.data.list) {
|
||||
setTasks(response.data.list);
|
||||
} else {
|
||||
setTasks([]);
|
||||
}
|
||||
setTasks(response.list);
|
||||
|
||||
// 构建场景数据
|
||||
const scenarioData: ScenarioData = {
|
||||
id: scenarioId,
|
||||
name: scenarioName || "",
|
||||
image: "",
|
||||
description: getScenarioDescription(scenarioId),
|
||||
totalPlans: response?.data?.list?.length || 0,
|
||||
totalCustomers: 0,
|
||||
todayCustomers: 0,
|
||||
growth: "",
|
||||
};
|
||||
|
||||
setScenario(scenarioData);
|
||||
// 设置页面标题
|
||||
setPageTitle(getScenarioName());
|
||||
} catch (error) {
|
||||
console.error("获取场景数据失败:", error);
|
||||
// 即使API失败也要创建基本的场景数据
|
||||
const scenarioData: ScenarioData = {
|
||||
id: scenarioId,
|
||||
name: getScenarioName(),
|
||||
image: "",
|
||||
description: getScenarioDescription(scenarioId),
|
||||
totalPlans: 0,
|
||||
totalCustomers: 0,
|
||||
todayCustomers: 0,
|
||||
growth: "",
|
||||
};
|
||||
setScenario(scenarioData);
|
||||
setTasks([]);
|
||||
setPageTitle(getScenarioName());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchScenarioData();
|
||||
}, [scenarioId]);
|
||||
}, [scenarioId, getScenarioName]);
|
||||
|
||||
// 获取场景名称
|
||||
const getScenarioName = useCallback(() => {
|
||||
const urlName = searchParams.get("name");
|
||||
if (urlName) {
|
||||
return urlName;
|
||||
}
|
||||
return getChannelName(scenarioId || "");
|
||||
}, [searchParams, scenarioId]);
|
||||
|
||||
// 更新场景数据中的名称
|
||||
// 更新页面标题
|
||||
useEffect(() => {
|
||||
setScenario((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
name: (() => {
|
||||
const urlName = searchParams.get("name");
|
||||
if (urlName) return urlName;
|
||||
return getChannelName(scenarioId || "");
|
||||
})(),
|
||||
}
|
||||
: null
|
||||
);
|
||||
}, [searchParams, scenarioId]);
|
||||
setPageTitle(getScenarioName());
|
||||
}, [getScenarioName]);
|
||||
|
||||
const handleCopyPlan = async (taskId: string) => {
|
||||
const taskToCopy = tasks.find((task) => task.id === taskId);
|
||||
if (!taskToCopy) return;
|
||||
|
||||
try {
|
||||
const response = await copyPlan(taskId);
|
||||
if (response && response.code === 200) {
|
||||
Toast.show({
|
||||
content: `已成功复制"${taskToCopy.name}"`,
|
||||
position: "top",
|
||||
});
|
||||
// 刷新列表
|
||||
handleRefresh();
|
||||
} else {
|
||||
Toast.show({
|
||||
content: response?.msg || "复制失败",
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
content: "复制失败,请重试",
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
await copyPlan(taskId);
|
||||
Toast.show({
|
||||
content: `已成功复制"${taskToCopy.name}"`,
|
||||
position: "top",
|
||||
});
|
||||
// 刷新列表
|
||||
handleRefresh();
|
||||
};
|
||||
|
||||
const handleDeletePlan = async (taskId: string) => {
|
||||
@@ -343,7 +243,7 @@ const ScenarioList: React.FC = () => {
|
||||
const getStatusText = (status: number) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return "运行中";
|
||||
return "进行中";
|
||||
case 0:
|
||||
return "已暂停";
|
||||
case -1:
|
||||
@@ -357,13 +257,11 @@ const ScenarioList: React.FC = () => {
|
||||
setLoadingTasks(true);
|
||||
try {
|
||||
const response = await getPlanList({
|
||||
scenarioId: scenarioId!,
|
||||
sceneId: scenarioId!,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
});
|
||||
if (response && response.data && response.data.list) {
|
||||
setTasks(response.data.list);
|
||||
}
|
||||
setTasks(response.list);
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
content: "刷新失败",
|
||||
@@ -378,6 +276,77 @@ const ScenarioList: React.FC = () => {
|
||||
task.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
// 计算通过率
|
||||
const calculatePassRate = (acquired: number, added: number) => {
|
||||
if (added === 0) return "0.00%";
|
||||
return `${((acquired / added) * 100).toFixed(2)}%`;
|
||||
};
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timeString: string) => {
|
||||
if (!timeString) return "";
|
||||
const date = new Date(timeString);
|
||||
return date
|
||||
.toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
.replace(/\//g, "-");
|
||||
};
|
||||
|
||||
// 生成操作菜单
|
||||
const getActionMenu = (task: Task) => [
|
||||
{
|
||||
key: "edit",
|
||||
text: "编辑",
|
||||
icon: <EditOutlined />,
|
||||
onClick: () => {
|
||||
setShowActionMenu(null);
|
||||
navigate(`/scenarios/edit/${task.id}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "settings",
|
||||
text: "API设置",
|
||||
icon: <SettingOutlined />,
|
||||
onClick: () => {
|
||||
setShowActionMenu(null);
|
||||
handleOpenApiSettings(task.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "copy",
|
||||
text: "复制",
|
||||
icon: <CopyOutlined />,
|
||||
onClick: () => {
|
||||
setShowActionMenu(null);
|
||||
handleCopyPlan(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,
|
||||
},
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout
|
||||
@@ -402,7 +371,7 @@ const ScenarioList: React.FC = () => {
|
||||
<NavBar
|
||||
back={null}
|
||||
style={{ background: "#fff" }}
|
||||
left={<div className={style["nav-title"]}>{scenario?.name}</div>}
|
||||
left={<div className={style["nav-title"]}>{pageTitle}</div>}
|
||||
right={
|
||||
<Button
|
||||
size="small"
|
||||
@@ -421,12 +390,13 @@ const ScenarioList: React.FC = () => {
|
||||
{/* 搜索栏 */}
|
||||
<div className={style["search-bar"]}>
|
||||
<div className={style["search-input-wrapper"]}>
|
||||
<SearchOutlined className={style["search-icon"]} />
|
||||
<Input
|
||||
placeholder="搜索计划名称"
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
clearable
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
size="middle"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
@@ -457,63 +427,59 @@ const ScenarioList: React.FC = () => {
|
||||
) : (
|
||||
filteredTasks.map((task) => (
|
||||
<Card key={task.id} className={style["plan-item"]}>
|
||||
{/* 头部:标题和状态 */}
|
||||
{/* 头部:标题、状态和操作菜单 */}
|
||||
<div className={style["plan-header"]}>
|
||||
<div className={style["plan-name"]}>{task.name}</div>
|
||||
<Tag color={getStatusColor(task.status)}>
|
||||
{getStatusText(task.status)}
|
||||
</Tag>
|
||||
<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["plan-meta"]}>
|
||||
<div className={style["meta-item"]}>
|
||||
<CalendarOutlined />
|
||||
<span>最后更新: {task.updated_at || task.created_at}</span>
|
||||
{/* 统计数据网格 */}
|
||||
<div className={style["stats-grid"]}>
|
||||
<div className={style["stat-item"]}>
|
||||
<div className={style["stat-label"]}>设备数</div>
|
||||
<div className={style["stat-value"]}>
|
||||
{task.stats?.devices || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["meta-item"]}>
|
||||
<UserOutlined />
|
||||
<span>
|
||||
设备: {task.stats?.devices || 0} | 获客:{" "}
|
||||
{task.stats?.acquired || 0} | 添加:{" "}
|
||||
<div className={style["stat-item"]}>
|
||||
<div className={style["stat-label"]}>已获客</div>
|
||||
<div className={style["stat-value"]}>
|
||||
{task.stats?.acquired || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["stat-item"]}>
|
||||
<div className={style["stat-label"]}>已添加</div>
|
||||
<div className={style["stat-value"]}>
|
||||
{task.stats?.added || 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>
|
||||
上次执行: {formatTime(task.updated_at || task.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部:操作按钮 */}
|
||||
<div className={style["plan-actions"]}>
|
||||
<Space>
|
||||
<Button
|
||||
size="mini"
|
||||
onClick={() => navigate(`/scenarios/edit/${task.id}`)}
|
||||
>
|
||||
<EditOutlined />
|
||||
</Button>
|
||||
<Button
|
||||
size="mini"
|
||||
onClick={() => handleOpenApiSettings(task.id)}
|
||||
>
|
||||
<SettingOutlined />
|
||||
</Button>
|
||||
<Button size="mini" onClick={() => handleCopyPlan(task.id)}>
|
||||
<CopyOutlined />
|
||||
</Button>
|
||||
<Button
|
||||
size="mini"
|
||||
color="danger"
|
||||
onClick={() => handleDeletePlan(task.id)}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
<Button
|
||||
size="mini"
|
||||
onClick={() => handleShowQrCode(task.id)}
|
||||
>
|
||||
<QrcodeOutlined />
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
@@ -537,7 +503,7 @@ const ScenarioList: React.FC = () => {
|
||||
<div className={style["api-item"]}>
|
||||
<label>API Key:</label>
|
||||
<div className={style["input-with-button"]}>
|
||||
<Input value={currentApiSettings.apiKey} readOnly />
|
||||
<Input value={currentApiSettings.apiKey} disabled />
|
||||
<Button
|
||||
size="mini"
|
||||
onClick={() => handleCopyApiUrl(currentApiSettings.apiKey)}
|
||||
@@ -549,7 +515,7 @@ const ScenarioList: React.FC = () => {
|
||||
<div className={style["api-item"]}>
|
||||
<label>Webhook URL:</label>
|
||||
<div className={style["input-with-button"]}>
|
||||
<Input value={currentApiSettings.webhookUrl} readOnly />
|
||||
<Input value={currentApiSettings.webhookUrl} disabled />
|
||||
<Button
|
||||
size="mini"
|
||||
onClick={() =>
|
||||
@@ -564,6 +530,38 @@ const ScenarioList: React.FC = () => {
|
||||
</div>
|
||||
</Popup>
|
||||
|
||||
{/* 操作菜单弹窗 */}
|
||||
<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}
|
||||
|
||||
Reference in New Issue
Block a user