Files
cunkebao_v3/nkebao/src/pages/workspace/traffic-distribution/NewDistribution.tsx
笔记本里的永平 53ea1e8395 feat: 本次提交更新内容如下
构建完成
2025-07-10 16:19:16 +08:00

752 lines
25 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, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Steps, StepItem } from 'tdesign-mobile-react';
import {
Users,
Search,
Database,
X,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Checkbox } from '@/components/ui/checkbox';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import Layout from '@/components/Layout';
import PageHeader from '@/components/PageHeader';
import DeviceSelection from '@/components/DeviceSelection';
import { useToast } from '@/components/ui/toast';
import { fetchAccountList, Account } from '@/api/trafficDistribution';
import '@/components/Layout.css';
interface BasicInfoData {
name: string;
distributionMethod: 'equal' | 'priority' | 'ratio';
dailyLimit: number;
timeRestriction: 'allDay' | 'custom';
startTime: string;
endTime: string;
selectedAccounts: string[];
}
interface TargetSettingsData {
selectedDevices: string[];
}
interface TrafficPoolData {
selectedPools: string[];
}
interface FormData {
basicInfo: Partial<BasicInfoData>;
targetSettings: Partial<TargetSettingsData>;
trafficPool: Partial<TrafficPoolData>;
}
interface TrafficPool {
id: string;
name: string;
count: number;
description: string;
}
// 账号选择对话框组件
const AccountSelectionDialog = ({
open,
onClose,
selectedAccounts,
onConfirm
}: {
open: boolean;
onClose: () => void;
selectedAccounts: string[];
onConfirm: (accounts: string[]) => void;
}) => {
const [tempSelectedAccounts, setTempSelectedAccounts] = useState<string[]>(selectedAccounts);
const [accounts, setAccounts] = useState<Account[]>([]);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const { toast } = useToast();
// 获取账号列表
const fetchAccounts = useCallback(async (pageNum: number = 1, reset: boolean = true) => {
setLoading(true);
try {
const response = await fetchAccountList({
page: pageNum,
limit: 10
});
if (response.code === 200 && response.data) {
const accountList = response.data.list || [];
const total = response.data.total || 0;
if (reset) {
setAccounts(accountList);
} else {
setAccounts(prev => [...prev, ...accountList]);
}
// 计算是否还有更多数据
const currentTotal = reset ? accountList.length : accounts.length + accountList.length;
setHasMore(currentTotal < total);
} else {
toast({
title: "获取账号列表失败",
description: response.msg || "请稍后重试",
variant: "destructive"
});
// 如果API失败使用模拟数据作为降级处理
const mockData = [
{ id: "1", userName: "user_001", realName: "张三", nickname: "游戏", memo: "游戏账号" },
{ id: "2", userName: "user_002", realName: "李四", nickname: "商务4", memo: "商务账号" },
{ id: "3", userName: "user_003", realName: "王五", nickname: "魔兽客服", memo: "客服账号" },
{ id: "4", userName: "user_004", realName: "赵六", nickname: "魔兽世界Kf", memo: "游戏客服" },
{ id: "5", userName: "user_005", realName: "孙七", nickname: "小羊网络", memo: "网络账号" },
];
if (reset) {
setAccounts(mockData);
}
setHasMore(false);
}
} catch (error) {
console.error('获取账号列表失败:', error);
toast({
title: "网络错误",
description: "请检查网络连接后重试",
variant: "destructive"
});
// 网络错误时使用模拟数据
const mockData = [
{ id: "1", userName: "user_001", realName: "张三", nickname: "游戏", memo: "游戏账号" },
{ id: "2", userName: "user_002", realName: "李四", nickname: "商务4", memo: "商务账号" },
{ id: "3", userName: "user_003", realName: "王五", nickname: "魔兽客服", memo: "客服账号" },
{ id: "4", userName: "user_004", realName: "赵六", nickname: "魔兽世界Kf", memo: "游戏客服" },
{ id: "5", userName: "user_005", realName: "孙七", nickname: "小羊网络", memo: "网络账号" },
];
if (reset) {
setAccounts(mockData);
}
setHasMore(false);
} finally {
setLoading(false);
}
}, [accounts.length, toast]);
useEffect(() => {
if (open) {
setTempSelectedAccounts(selectedAccounts);
fetchAccounts(1, true);
setPage(1);
}
}, [open, selectedAccounts, fetchAccounts]);
const toggleAccount = (id: string) => {
setTempSelectedAccounts(prev =>
prev.includes(id) ? prev.filter(accountId => accountId !== id) : [...prev, id]
);
};
const handleConfirm = () => {
onConfirm(tempSelectedAccounts);
onClose();
};
const loadMore = () => {
if (!loading && hasMore) {
const nextPage = page + 1;
setPage(nextPage);
fetchAccounts(nextPage, false);
}
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-sm w-[90vw] max-h-[80vh] p-0">
<DialogHeader className=" pb-4">
<div className="flex items-center justify-between">
<DialogTitle></DialogTitle>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="h-6 w-6 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
</DialogHeader>
<div className=" flex-1 overflow-hidden">
<div className="space-y-3 max-h-96 overflow-y-auto">
{accounts.map(account => (
<div
key={account.id}
className={`cursor-pointer border rounded-lg p-4 ${
tempSelectedAccounts.includes(account.id)
? "border-blue-500 bg-blue-50"
: "border-gray-200"
}`}
onClick={() => toggleAccount(account.id)}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="w-12 h-12 rounded-lg bg-blue-100 flex items-center justify-center">
<span className="text-base font-medium text-blue-600">
{(account.nickname || account.realName || account.userName).charAt(0)}
</span>
</div>
<div className="flex-1">
<p className="font-medium text-base">{account.nickname || account.realName || account.userName}</p>
<p className="text-sm text-gray-500">: {account.userName}</p>
</div>
</div>
<div className="ml-2">
<Checkbox
checked={tempSelectedAccounts.includes(account.id)}
onCheckedChange={() => toggleAccount(account.id)}
/>
</div>
</div>
</div>
))}
{loading && (
<div className="text-center py-4">
<div className="flex items-center justify-center space-x-2">
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
<span className="text-sm text-gray-500">...</span>
</div>
</div>
)}
{!loading && hasMore && (
<div className="text-center py-4">
<Button
variant="outline"
size="sm"
onClick={loadMore}
>
</Button>
</div>
)}
{!loading && accounts.length === 0 && (
<div className="text-center py-8 text-gray-500">
<Users className="h-12 w-12 mx-auto mb-2 opacity-30" />
<p></p>
</div>
)}
</div>
</div>
<div className=" pt-4 border-t">
<Button
onClick={handleConfirm}
className="w-full h-12 text-base"
disabled={tempSelectedAccounts.length === 0}
>
</Button>
</div>
</DialogContent>
</Dialog>
);
};
export default function NewDistribution() {
const navigate = useNavigate();
const { toast } = useToast();
const [currentStep, setCurrentStep] = useState(0);
const [formData, setFormData] = useState<FormData>({
basicInfo: {},
targetSettings: {},
trafficPool: {},
});
const steps = [
{ title: "基本信息", content: "step1" },
{ title: "目标设置", content: "step2" },
{ title: "流量池选择", content: "step3" },
];
// 生成默认计划名称
const generateDefaultName = () => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hour = String(now.getHours()).padStart(2, '0');
const minute = String(now.getMinutes()).padStart(2, '0');
return `流量分发 ${year}${month}${day} ${hour}${minute}`;
};
const handleBasicInfoNext = (data: BasicInfoData) => {
setFormData(prev => ({ ...prev, basicInfo: data }));
setCurrentStep(1);
};
const handleTargetSettingsNext = (data: TargetSettingsData) => {
setFormData(prev => ({ ...prev, targetSettings: data }));
setCurrentStep(2);
};
const handleTargetSettingsBack = () => {
setCurrentStep(0);
};
const handleTrafficPoolBack = () => {
setCurrentStep(1);
};
const handleSubmit = async (data: TrafficPoolData) => {
const finalData = {
...formData,
trafficPool: data,
};
try {
console.log('提交的数据:', finalData);
toast({
title: "创建成功",
description: "流量分发规则已成功创建"
});
navigate('/workspace/traffic-distribution');
} catch (error) {
console.error('提交失败:', error);
toast({
title: "创建失败",
description: "请稍后重试",
variant: "destructive"
});
}
};
// 基本信息步骤组件
const BasicInfoStep = ({ onNext, initialData = {} }: { onNext: (data: BasicInfoData) => void; initialData?: Partial<BasicInfoData> }) => {
const [formData, setFormData] = useState<BasicInfoData>({
name: initialData.name || generateDefaultName(),
distributionMethod: initialData.distributionMethod || "equal",
dailyLimit: initialData.dailyLimit || 50,
timeRestriction: initialData.timeRestriction || "custom",
startTime: initialData.startTime || "09:00",
endTime: initialData.endTime || "18:00",
selectedAccounts: initialData.selectedAccounts || [],
});
const [accountDialogOpen, setAccountDialogOpen] = useState(false);
const handleChange = (field: keyof BasicInfoData, value: string | number | string[]) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleAccountConfirm = (selectedAccounts: string[]) => {
handleChange("selectedAccounts", selectedAccounts);
};
const getSelectedAccountsText = () => {
if (formData.selectedAccounts.length === 0) {
return "请选择账号";
}
return `已选择 ${formData.selectedAccounts.length} 个账号`;
};
const handleSubmit = () => {
if (!formData.name.trim()) {
toast({
title: "请填写计划名称",
variant: "destructive"
});
return;
}
if (formData.selectedAccounts.length === 0) {
toast({
title: "请选择至少一个账号",
variant: "destructive"
});
return;
}
onNext(formData);
};
return (
<div className="bg-white rounded-lg p-6">
<h2 className="text-xl font-bold mb-6"></h2>
<div className="space-y-6">
<div className="space-y-2">
<Label htmlFor="name" className="flex items-center">
<span className="text-red-500 ml-1">*</span>
</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => handleChange("name", e.target.value)}
placeholder="请输入计划名称"
className="h-12"
/>
</div>
<div className="space-y-4">
<Label className="flex items-center">
<span className="text-red-500 ml-1">*</span>
</Label>
<div
className="relative cursor-pointer"
onClick={() => setAccountDialogOpen(true)}
>
<Input
value={getSelectedAccountsText()}
placeholder="请选择账号"
className="h-12 cursor-pointer"
readOnly
/>
<Search className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={18} />
</div>
<div className="flex items-center text-sm text-gray-500">
<Users className="h-4 w-4 mr-1" />
<span className="text-blue-600 font-medium ml-1">{formData.selectedAccounts.length} </span>
</div>
</div>
<div className="space-y-2">
<Label></Label>
<RadioGroup
value={formData.distributionMethod}
onValueChange={(value) => handleChange("distributionMethod", value as 'equal' | 'priority' | 'ratio')}
className="space-y-2"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="equal" id="equal" />
<Label htmlFor="equal" className="cursor-pointer">
<span className="text-gray-500 text-sm">()</span>
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="priority" id="priority" />
<Label htmlFor="priority" className="cursor-pointer">
<span className="text-gray-500 text-sm">()</span>
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="ratio" id="ratio" />
<Label htmlFor="ratio" className="cursor-pointer">
<span className="text-gray-500 text-sm">()</span>
</Label>
</div>
</RadioGroup>
</div>
<div className="space-y-4">
<Label></Label>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span></span>
<span className="font-medium">{formData.dailyLimit} /</span>
</div>
<input
type="range"
value={formData.dailyLimit}
min={1}
max={200}
step={1}
onChange={(e) => handleChange("dailyLimit", parseInt(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
style={{
background: `linear-gradient(to right, #3b82f6 0%, #3b82f6 ${(formData.dailyLimit / 200) * 100}%, #e5e7eb ${(formData.dailyLimit / 200) * 100}%, #e5e7eb 100%)`
}}
/>
<p className="text-sm text-gray-500"></p>
</div>
<div className="space-y-4 pt-4">
<Label></Label>
<RadioGroup
value={formData.timeRestriction}
onValueChange={(value) => handleChange("timeRestriction", value as 'allDay' | 'custom')}
className="space-y-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="allDay" id="allDay" />
<Label htmlFor="allDay" className="cursor-pointer">
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="custom" id="custom" />
<Label htmlFor="custom" className="cursor-pointer">
</Label>
</div>
</RadioGroup>
{formData.timeRestriction === "custom" && (
<div className="grid grid-cols-2 gap-4 pt-2">
<div>
<Label htmlFor="startTime" className="mb-2 block">
</Label>
<Input
id="startTime"
type="time"
value={formData.startTime}
onChange={(e) => handleChange("startTime", e.target.value)}
className="h-12"
/>
</div>
<div>
<Label htmlFor="endTime" className="mb-2 block">
</Label>
<Input
id="endTime"
type="time"
value={formData.endTime}
onChange={(e) => handleChange("endTime", e.target.value)}
className="h-12"
/>
</div>
</div>
)}
</div>
</div>
</div>
<div className="mt-8 flex justify-end">
<Button onClick={handleSubmit} className="px-8">
</Button>
</div>
<AccountSelectionDialog
open={accountDialogOpen}
onClose={() => setAccountDialogOpen(false)}
selectedAccounts={formData.selectedAccounts}
onConfirm={handleAccountConfirm}
/>
</div>
);
};
// 目标设置步骤组件
const TargetSettingsStep = ({ onNext, onBack, initialData = {} }: { onNext: (data: TargetSettingsData) => void; onBack: () => void; initialData?: Partial<TargetSettingsData> }) => {
const [selectedDevices, setSelectedDevices] = useState<string[]>(initialData.selectedDevices || []);
const handleSubmit = () => {
if (selectedDevices.length === 0) {
toast({
title: "请选择至少一个设备",
variant: "destructive"
});
return;
}
onNext({
selectedDevices,
});
};
return (
<div className="bg-white rounded-lg p-6">
<h2 className="text-xl font-bold mb-6"></h2>
<div className="mb-6">
<DeviceSelection
selectedDevices={selectedDevices}
onSelect={setSelectedDevices}
placeholder="选择执行设备"
/>
</div>
<div className="mt-8 flex justify-between">
<Button variant="outline" onClick={onBack}>
</Button>
<Button onClick={handleSubmit} disabled={selectedDevices.length === 0}>
</Button>
</div>
</div>
);
};
// 流量池选择步骤组件
const TrafficPoolStep = ({ onSubmit, onBack, initialData = {} }: { onSubmit: (data: TrafficPoolData) => void; onBack: () => void; initialData?: Partial<TrafficPoolData> }) => {
const [selectedPools, setSelectedPools] = useState<string[]>(initialData.selectedPools || []);
const [searchTerm, setSearchTerm] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
// 模拟流量池数据
const trafficPools: TrafficPool[] = [
{ id: "1", name: "新客流量池", count: 1250, description: "新获取的客户流量" },
{ id: "2", name: "高意向流量池", count: 850, description: "有购买意向的客户" },
{ id: "3", name: "复购流量池", count: 620, description: "已购买过产品的客户" },
{ id: "4", name: "活跃流量池", count: 1580, description: "近期活跃的客户" },
{ id: "5", name: "沉睡流量池", count: 2300, description: "长期未活跃的客户" },
];
const filteredPools = trafficPools.filter(
pool =>
pool.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
pool.description.toLowerCase().includes(searchTerm.toLowerCase())
);
const togglePool = (id: string) => {
setSelectedPools(prev =>
prev.includes(id) ? prev.filter(poolId => poolId !== id) : [...prev, id]
);
};
const handleSubmit = async () => {
if (selectedPools.length === 0) {
toast({
title: "请选择至少一个流量池",
variant: "destructive"
});
return;
}
setIsSubmitting(true);
try {
await new Promise(resolve => setTimeout(resolve, 1000));
onSubmit({ selectedPools });
} catch (error) {
console.error("提交失败:", error);
toast({
title: "创建失败",
description: "请稍后重试",
variant: "destructive"
});
} finally {
setIsSubmitting(false);
}
};
return (
<div className="bg-white rounded-lg p-6">
<h2 className="text-xl font-bold mb-6"></h2>
<div className="mb-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={18} />
<Input
placeholder="搜索流量池"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 h-12"
/>
</div>
</div>
<div className="space-y-3 mt-4">
{filteredPools.map(pool => (
<div
key={pool.id}
className={`cursor-pointer border rounded-lg ${selectedPools.includes(pool.id) ? "border-blue-500 bg-blue-50" : "border-gray-200"}`}
onClick={() => togglePool(pool.id)}
>
<div className="p-4 flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center">
<Database className="h-5 w-5 text-blue-600" />
</div>
<div>
<p className="font-medium">{pool.name}</p>
<p className="text-sm text-gray-500">{pool.description}</p>
</div>
</div>
<div className="flex items-center space-x-3">
<span className="text-sm text-gray-500">{pool.count} </span>
<Checkbox
checked={selectedPools.includes(pool.id)}
onCheckedChange={() => togglePool(pool.id)}
/>
</div>
</div>
</div>
))}
</div>
<div className="mt-8 flex justify-between">
<Button variant="outline" onClick={onBack}>
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting ? "提交中..." : "完成"}
</Button>
</div>
</div>
);
};
const headerRightContent = (
<Button
variant="ghost"
size="sm"
className="text-gray-500"
onClick={() => navigate('/workspace/traffic-distribution')}
>
</Button>
);
return (
<Layout
header={
<PageHeader
title="新建流量分发"
defaultBackPath="/workspace/traffic-distribution"
rightContent={headerRightContent}
/>
}
>
<div className="bg-gray-50 min-h-screen pb-16">
<div className="p-4">
<div className="mb-6">
<Steps current={currentStep}>
{steps.map((step, index) => (
<StepItem key={index} title={step.title} />
))}
</Steps>
</div>
{currentStep === 0 && (
<BasicInfoStep
onNext={handleBasicInfoNext}
initialData={formData.basicInfo}
/>
)}
{currentStep === 1 && (
<TargetSettingsStep
onNext={handleTargetSettingsNext}
onBack={handleTargetSettingsBack}
initialData={formData.targetSettings}
/>
)}
{currentStep === 2 && (
<TrafficPoolStep
onSubmit={handleSubmit}
onBack={handleTrafficPoolBack}
initialData={formData.trafficPool}
/>
)}
</div>
</div>
</Layout>
);
}