feat: 本次提交更新内容如下
存一下进度
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user