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

存一下进度
This commit is contained in:
笔记本里的永平
2025-07-24 20:43:02 +08:00
parent 9a33801f12
commit 3251a1985e
6 changed files with 794 additions and 98 deletions

View File

@@ -1,4 +1,5 @@
# 基础环境变量示例
VITE_API_BASE_URL=http://www.yishi.com
# VITE_API_BASE_URL=http://www.yishi.com
VITE_API_BASE_URL=https://ckbapi.quwanzhi.com
VITE_APP_TITLE=Nkebao Base

View File

@@ -0,0 +1,207 @@
.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;
}
.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;
}
// 步骤条美化
.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;
}

View File

@@ -1,3 +1,347 @@
export default function TrafficDistributionForm() {
return <div>TrafficDistributionForm</div>;
}
import React, { useState } from "react";
import {
Form,
Input,
Button,
Radio,
Slider,
TimePicker,
message,
Checkbox,
} from "antd";
import { LeftOutlined } from "@ant-design/icons";
import { useNavigate } 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";
const accountList = [
{ label: "客服A", value: "a" },
{ label: "客服B", value: "b" },
{ label: "客服C", value: "c" },
];
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 [current, setCurrent] = useState(0);
const [selectedAccounts, setSelectedAccounts] = useState<string[]>([]);
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 [accountSearch, setAccountSearch] = useState("");
const filteredAccounts = accountList.filter((acc) =>
acc.label.includes(accountSearch)
);
const [targetUserCount, setTargetUserCount] = useState(100);
const [targetTypes, setTargetTypes] = useState<string[]>([]);
const [targetScenarios, setTargetScenarios] = useState<string[]>([]);
const [selectedPools, setSelectedPools] = useState<string[]>([]);
const [poolSearch, setPoolSearch] = useState("");
const handleFinish = async (values: any) => {
setLoading(true);
try {
// TODO: 提交接口
message.success("新建流量分发成功");
navigate(-1);
} catch (e) {
message.error("新建失败");
} finally {
setLoading(false);
}
};
// 步骤切换
const next = () => setCurrent((cur) => cur + 1);
const prev = () => setCurrent((cur) => cur - 1);
// 过滤流量池
const filteredPools = poolList.filter((pool) =>
pool.name.includes(poolSearch)
);
return (
<Layout
header={
<>
<NavCommon title="新建流量分发" />
<div className={style.formStepsWrap}>
<StepIndicator currentStep={current + 1} steps={stepList} />
</div>
</>
}
>
<div className={style.formPage}>
<div className={style.formBody}>
{current === 0 && (
<Form
form={form}
layout="vertical"
onFinish={() => next()}
initialValues={{ distributeType: 1, maxPerDay: 50, timeType: 1 }}
>
<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="选择账号"
required
className={style.accountSelectItem}
>
<Input
placeholder="请选择账号"
value={accountSearch}
onChange={(e) => setAccountSearch(e.target.value)}
suffix={<span className={style.accountSearchIcon} />}
/>
<div className={style.accountListWrap}>
{filteredAccounts.map((acc) => (
<label key={acc.value} className={style.accountItem}>
<input
type="checkbox"
checked={selectedAccounts.includes(acc.value)}
onChange={(e) => {
setSelectedAccounts((val) =>
e.target.checked
? [...val, acc.value]
: val.filter((v) => v !== acc.value)
);
}}
/>
<span>{acc.label}</span>
</label>
))}
</div>
<div className={style.accountSelectedCount}>
<span>{selectedAccounts.length}</span>
</div>
</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.Item>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
>
</Button>
</Form.Item>
</Form>
)}
{current === 1 && (
<div>
<div className={style.sectionTitle}></div>
<div className={style.formBlock}>
<div className={style.formLabel}></div>
<Slider
min={1}
max={1000}
value={targetUserCount}
onChange={setTargetUserCount}
className={style.slider}
/>
<div className={style.sliderValue}>{targetUserCount} </div>
</div>
<div className={style.formBlock}>
<div className={style.formLabel}></div>
<Checkbox.Group
options={["高价值客户", "新用户", "潜在客户", "流失预警"]}
value={targetTypes}
onChange={setTargetTypes}
className={style.checkboxGroup}
/>
</div>
<div className={style.formBlock}>
<div className={style.formLabel}></div>
<Checkbox.Group
options={scenarioList.map((s) => ({
label: s.label,
value: s.value,
}))}
value={targetScenarios}
onChange={setTargetScenarios}
className={style.checkboxGroup}
/>
</div>
<div className={style.formStepBtns}>
<Button onClick={prev} style={{ marginRight: 12 }}>
</Button>
<Button type="primary" onClick={next}>
</Button>
</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 className={style.formStepBtns}>
<Button onClick={prev} style={{ marginRight: 12 }}>
</Button>
<Button
type="primary"
onClick={() => message.success("提交成功")}
>
</Button>
</div>
</div>
)}
</div>
</div>
</Layout>
);
};
export default TrafficDistributionForm;

View File

@@ -8,3 +8,21 @@ export function fetchDistributionRuleList(params: {
}): 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");
}

View File

@@ -99,11 +99,15 @@
font-size: 13px;
color: #888;
margin-top: 6px;
align-items: center;
}
.ruleFooterIcon {
margin-right: 4px;
vertical-align: middle;
font-size: 15px;
position: relative;
top: -2px;
}
.empty {

View File

@@ -1,23 +1,37 @@
import React, { useEffect, useState } from "react";
import Layout from "@/components/Layout/Layout";
import { Input, Button } from "antd";
import NavCommon from "@/components/NavCommon";
import { fetchDistributionRuleList } from "./api";
import type { DistributionRule } from "./data";
import { Tag, Pagination, Spin, message, Switch } from "antd";
import {
EllipsisOutlined,
UserOutlined,
ClusterOutlined,
AppstoreOutlined,
BarChartOutlined,
TeamOutlined,
Input,
Button,
Tag,
Pagination,
Spin,
message,
Switch,
Dropdown,
Menu,
} from "antd";
import NavCommon from "@/components/NavCommon";
import {
fetchDistributionRuleList,
updateDistributionRule,
toggleDistributionRuleStatus,
deleteDistributionRule,
} from "./api";
import type { DistributionRule } from "./data";
import {
MoreOutlined,
PlusOutlined,
ReloadOutlined,
EditOutlined,
PauseOutlined,
DeleteOutlined,
ClockCircleOutlined,
SearchOutlined,
ReloadOutlined,
CalendarOutlined,
} from "@ant-design/icons";
import style from "./index.module.scss";
import { useNavigate } from "react-router-dom";
const PAGE_SIZE = 10;
@@ -35,6 +49,9 @@ const TrafficDistributionList: React.FC = () => {
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [searchQuery, setSearchQuery] = useState("");
// 优化用menuLoadingId标记当前操作的item
const [menuLoadingId, setMenuLoadingId] = useState<number | null>(null);
const navigate = useNavigate();
useEffect(() => {
fetchList(page, searchQuery);
@@ -73,96 +90,204 @@ const TrafficDistributionList: React.FC = () => {
fetchList(page, searchQuery);
};
const renderCard = (item: DistributionRule) => (
<div key={item.id} className={style.ruleCard}>
<div className={style.ruleHeader}>
<span className={style.ruleName}>{item.name}</span>
<div className={style.ruleStatus}>
<Tag
color={statusMap[item.status]?.color || "default"}
style={{ fontWeight: 500, fontSize: 13 }}
>
{statusMap[item.status]?.text || "未知"}
</Tag>
<Switch
className={style.ruleSwitch}
checked={item.status === 1}
size="small"
disabled
/>
<EllipsisOutlined className={style.ruleMenu} />
</div>
</div>
<div className={style.ruleMeta}>
<div className={style.ruleMetaItem}>
<UserOutlined style={{ color: "#3b82f6", fontSize: 18 }} />
<div style={{ fontSize: 18, fontWeight: 600 }}>
{item.config?.account?.length || 0}
// 优化菜单点击事件menuLoadingId标记当前item
const handleMenuClick = async (key: string, item: DistributionRule) => {
setMenuLoadingId(item.id);
try {
if (key === "edit") {
navigate(`/workspace/traffic-distribution/edit/${item.id}`);
} else if (key === "pause") {
await toggleDistributionRuleStatus(item.id, item.status === 1 ? 0 : 1);
message.success(item.status === 1 ? "已暂停" : "已启用");
handleRefresh();
} else if (key === "delete") {
await deleteDistributionRule(item.id);
message.success("删除成功");
handleRefresh();
}
} catch (e) {
message.error("操作失败");
} finally {
setMenuLoadingId(null);
}
};
// 新增Switch点击切换计划状态
const handleSwitchChange = async (
checked: boolean,
item: DistributionRule
) => {
setMenuLoadingId(item.id);
try {
await toggleDistributionRuleStatus(item.id, checked ? 1 : 0);
message.success(checked ? "已启用" : "已暂停");
// 本地只更新当前item的status不刷新全列表
setList((prevList) =>
prevList.map((rule) =>
rule.id === item.id ? { ...rule, status: checked ? 1 : 0 } : rule
)
);
} catch (e) {
message.error("操作失败");
} finally {
setMenuLoadingId(null);
}
};
const renderCard = (item: DistributionRule) => {
const menu = (
<Menu onClick={({ key }) => handleMenuClick(key, item)}>
<Menu.Item
key="edit"
icon={<EditOutlined />}
disabled={menuLoadingId === item.id}
>
</Menu.Item>
<Menu.Item
key="pause"
icon={<PauseOutlined />}
disabled={menuLoadingId === item.id}
>
{item.status === 1 ? "暂停计划" : "启用计划"}
</Menu.Item>
<Menu.Item
key="delete"
icon={<DeleteOutlined />}
disabled={menuLoadingId === item.id}
danger
>
</Menu.Item>
</Menu>
);
return (
<div key={item.id} className={style.ruleCard}>
<div className={style.ruleHeader}>
<span className={style.ruleName}>{item.name}</span>
<div className={style.ruleStatus}>
<Tag
color={statusMap[item.status]?.color || "default"}
style={{ fontWeight: 500, fontSize: 13 }}
>
{statusMap[item.status]?.text || "未知"}
</Tag>
<Switch
className={style.ruleSwitch}
checked={item.status === 1}
size="small"
loading={menuLoadingId === item.id}
disabled={menuLoadingId === item.id}
onChange={(checked) => handleSwitchChange(checked, item)}
/>
{/* Dropdown 只允许传递单一元素给 menu 属性 */}
<Dropdown
menu={{
items: [
{
key: "edit",
icon: <EditOutlined />,
label: "编辑计划",
disabled: menuLoadingId === item.id,
},
{
key: "pause",
icon: <PauseOutlined />,
label: item.status === 1 ? "暂停计划" : "启用计划",
disabled: menuLoadingId === item.id,
},
{
key: "delete",
icon: <DeleteOutlined />,
label: "删除计划",
disabled: menuLoadingId === item.id,
danger: true,
},
],
onClick: ({ key }) => handleMenuClick(key, item),
}}
trigger={["click"]}
placement="bottomRight"
disabled={menuLoadingId === item.id}
>
<MoreOutlined
className={style.ruleMenu}
style={{ cursor: "pointer" }}
/>
</Dropdown>
</div>
<div style={{ fontSize: 13, color: "#888" }}></div>
</div>
<div className={style.ruleMetaItem}>
<AppstoreOutlined style={{ color: "#22c55e", fontSize: 18 }} />
<div style={{ fontSize: 18, fontWeight: 600 }}>
{item.config?.devices?.length || 0}
<div className={style.ruleMeta}>
<div className={style.ruleMetaItem}>
<div style={{ fontSize: 18, fontWeight: 600 }}>
{item.config?.account?.length || 0}
</div>
<div style={{ fontSize: 13, color: "#888" }}></div>
</div>
<div style={{ fontSize: 13, color: "#888" }}></div>
</div>
<div className={style.ruleMetaItem}>
<ClusterOutlined style={{ color: "#f59e42", fontSize: 18 }} />
<div style={{ fontSize: 18, fontWeight: 600 }}>
{item.config?.pools?.length || 0}
<div className={style.ruleMetaItem}>
<div style={{ fontSize: 18, fontWeight: 600 }}>
{item.config?.devices?.length || 0}
</div>
<div style={{ fontSize: 13, color: "#888" }}></div>
</div>
<div className={style.ruleMetaItem}>
<div style={{ fontSize: 18, fontWeight: 600 }}>
{item.config?.pools?.length || 0}
</div>
<div style={{ fontSize: 13, color: "#888" }}></div>
</div>
<div style={{ fontSize: 13, color: "#888" }}></div>
</div>
</div>
<div className={style.ruleDivider} />
<div className={style.ruleStats}>
<div className={style.ruleStatsItem}>
<BarChartOutlined style={{ color: "#3b82f6", fontSize: 16 }} />
<span style={{ marginLeft: 4 }}>
{item.config?.total?.dailyAverage || 0}
<div className={style.ruleDivider} />
<div className={style.ruleStats}>
<div className={style.ruleStatsItem}>
<span style={{ fontSize: 16, fontWeight: 600 }}>
{item.config?.total?.dailyAverage || 0}
</span>
<div style={{ fontSize: 13, color: "#888", marginTop: 2 }}>
</div>
</div>
<div className={style.ruleStatsItem}>
<span style={{ fontSize: 16, fontWeight: 600 }}>
{item.config?.total?.totalUsers || 0}
</span>
<div style={{ fontSize: 13, color: "#888", marginTop: 2 }}>
</div>
</div>
</div>
<div className={style.ruleFooter}>
<span>
<ClockCircleOutlined className={style.ruleFooterIcon} />
{item.config?.lastUpdated || "-"}
</span>
<div style={{ fontSize: 13, color: "#888", marginTop: 2 }}>
</div>
</div>
<div className={style.ruleStatsItem}>
<TeamOutlined style={{ color: "#f97316", fontSize: 16 }} />
<span style={{ marginLeft: 4 }}>
{item.config?.total?.totalUsers || 0}
<span>
<CalendarOutlined className={style.ruleFooterIcon} />
{item.createTime || "-"}
</span>
<div style={{ fontSize: 13, color: "#888", marginTop: 2 }}>
</div>
</div>
</div>
<div className={style.ruleFooter}>
<span>
<ClockCircleOutlined className={style.ruleFooterIcon} />
{item.config?.lastUpdated || "-"}
</span>
<span>
<CalendarOutlined className={style.ruleFooterIcon} />
{item.createTime || "-"}
</span>
</div>
</div>
);
);
};
return (
<Layout
header={
// <PopupHeader
// title="流量分发"
// searchQuery={searchQuery}
// setSearchQuery={handleSearch}
// loading={loading}
// onRefresh={handleRefresh}
// />
<>
<NavCommon title="流量分发" />
<NavCommon
title="流量分发"
right={
<Button
type="primary"
onClick={() => {
navigate("/workspace/traffic-distribution/new");
}}
>
<PlusOutlined />
</Button>
}
/>
{/* 搜索栏 */}
<div className="search-bar">
<div className="search-input-wrapper">
@@ -176,13 +301,10 @@ const TrafficDistributionList: React.FC = () => {
/>
</div>
<Button
size="small"
onClick={handleRefresh}
loading={loading}
className="refresh-btn"
>
<ReloadOutlined />
</Button>
icon={<ReloadOutlined />}
size="large"
></Button>
</div>
</>
}