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

更新
This commit is contained in:
笔记本里的永平
2025-07-21 09:55:37 +08:00
parent 5122a0965a
commit f6f9e45198
7 changed files with 398 additions and 264 deletions

View File

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