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

定版本转移2025年7月17日
This commit is contained in:
笔记本里的永平
2025-07-17 10:22:38 +08:00
parent 0f860d01e4
commit 92a3d407a7
645 changed files with 30755 additions and 118800 deletions

View File

@@ -1,858 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Steps, StepItem } from 'tdesign-mobile-react';
import {
Users,
Search,
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, createDistributionRule, updateDistributionRule, fetchDistributionRuleDetail } from '@/api/trafficDistribution';
import '@/components/Layout.css';
import TrafficPoolSelection from '@/components/TrafficPoolSelection';
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>;
}
// 账号选择对话框组件
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 { id: ruleId } = useParams<{ id: string }>();
const { toast } = useToast();
const [currentStep, setCurrentStep] = useState(0);
const [isEditMode, setIsEditMode] = useState(false);
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState<FormData>({
basicInfo: {},
targetSettings: {},
trafficPool: {},
});
const steps = [
{ title: "基本信息", content: "step1" },
{ title: "目标设置", content: "step2" },
{ title: "流量池选择", content: "step3" },
];
// 加载规则数据
const loadRuleData = useCallback(async (id: string) => {
console.log('开始加载规则数据ID:', id);
setLoading(true);
try {
console.log('调用API: /v1/workbench/detail?id=' + id);
const response = await fetchDistributionRuleDetail(id);
if (response.code === 200 && response.data) {
const rule = response.data;
const config = rule.config as any || {};
console.log('接收到的详情数据:', rule);
console.log('config数据:', config);
// 转换分配类型1-均分配, 2-优先级分配, 3-比例分配
const getDistributionMethod = (distributeType: number) => {
switch (distributeType) {
case 2: return 'priority';
case 3: return 'ratio';
default: return 'equal';
}
};
// 转换时间限制类型1-全天, 2-自定义时间段
const timeRestriction = config?.timeType === 1 ? 'allDay' : 'custom';
// 转换后端数据为前端表单格式
setFormData({
basicInfo: {
name: rule.name || '',
distributionMethod: getDistributionMethod(config?.distributeType),
dailyLimit: config?.maxPerDay || 50,
timeRestriction: timeRestriction,
startTime: config?.startTime || '09:00',
endTime: config?.endTime || '18:00',
selectedAccounts: config?.account || [],
},
targetSettings: {
selectedDevices: config?.devices || [],
},
trafficPool: {
selectedPools: config?.pools || [],
},
});
console.log('转换后的表单数据:', {
basicInfo: {
name: rule.name || '',
distributionMethod: getDistributionMethod(config?.distributeType),
dailyLimit: config?.maxPerDay || 50,
timeRestriction: timeRestriction,
startTime: config?.startTime || '09:00',
endTime: config?.endTime || '18:00',
selectedAccounts: config?.account || [],
},
targetSettings: {
selectedDevices: config?.devices || [],
},
trafficPool: {
selectedPools: config?.pools || [],
},
});
} else {
toast({
title: "加载失败",
description: response.msg || "无法加载规则数据",
variant: "destructive"
});
navigate('/workspace/traffic-distribution');
}
} catch (error) {
console.error('加载规则数据失败:', error);
toast({
title: "加载失败",
description: "网络错误,请稍后重试",
variant: "destructive"
});
navigate('/workspace/traffic-distribution');
} finally {
setLoading(false);
}
}, [toast, navigate]);
// 检查是否为编辑模式并加载数据
useEffect(() => {
console.log('检查编辑模式 - ruleId:', ruleId);
if (ruleId) {
console.log('进入编辑模式,加载数据...');
setIsEditMode(true);
loadRuleData(ruleId);
}
}, [ruleId, loadRuleData]);
// 生成默认计划名称
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 getDistributeType = (distributionMethod: string | undefined) => {
switch (distributionMethod) {
case 'equal':
return 1; // 均分配
case 'priority':
return 2; // 优先级分配
case 'ratio':
return 3; // 比例分配
default:
return 1; // 默认均分配
}
};
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 {
// 构造API请求参数 - 按照后端要求的格式
const apiParams = {
type: 5, // 流量分发类型
name: finalData.basicInfo.name || '',
distributeType: getDistributeType(finalData.basicInfo.distributionMethod),
maxPerDay: finalData.basicInfo.dailyLimit || 50,
timeType: finalData.basicInfo.timeRestriction === 'allDay' ? 1 : 2,
startTime: finalData.basicInfo.timeRestriction === 'custom'
? (finalData.basicInfo.startTime || "09:00")
: "00:00",
endTime: finalData.basicInfo.timeRestriction === 'custom'
? (finalData.basicInfo.endTime || "18:00")
: "23:59",
devices: finalData.targetSettings?.selectedDevices || [],
account: finalData.basicInfo?.selectedAccounts || [],
pools: finalData.trafficPool?.selectedPools || [],
enabled: true // 默认启用
};
console.log('提交的数据:', apiParams);
let response;
if (isEditMode && ruleId) {
// 编辑模式 - 使用更新接口
response = await updateDistributionRule(ruleId, apiParams);
} else {
// 创建模式 - 使用创建接口
response = await createDistributionRule(apiParams);
}
if (response.code === 200) {
toast({
title: isEditMode ? "更新成功" : "创建成功",
description: isEditMode ? "流量分发规则已成功更新" : "流量分发规则已成功创建"
});
navigate('/workspace/traffic-distribution');
} else {
toast({
title: isEditMode ? "更新失败" : "创建失败",
description: response.msg || "请稍后重试",
variant: "destructive"
});
}
} catch (error) {
console.error('提交失败:', error);
toast({
title: isEditMode ? "更新失败" : "创建失败",
description: "网络错误,请稍后重试",
variant: "destructive"
});
}
};
// 基本信息步骤组件
const BasicInfoStep = ({ onNext, initialData = {} }: { onNext: (data: BasicInfoData) => void; initialData?: Partial<BasicInfoData> }) => {
const [formData, setFormData] = useState<BasicInfoData>({
name: initialData.name || (isEditMode ? '' : 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);
// 当编辑模式下initialData变化时更新表单数据
useEffect(() => {
if (initialData.name) {
setFormData(prev => ({
...prev,
name: initialData.name || '',
distributionMethod: initialData.distributionMethod || prev.distributionMethod,
dailyLimit: initialData.dailyLimit || prev.dailyLimit,
timeRestriction: initialData.timeRestriction || prev.timeRestriction,
startTime: initialData.startTime || prev.startTime,
endTime: initialData.endTime || prev.endTime,
selectedAccounts: initialData.selectedAccounts || prev.selectedAccounts,
}));
}
}, [initialData]);
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 [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async () => {
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);
}
};
// 从formData中获取选中的设备ID
const deviceIds = formData.targetSettings?.selectedDevices || [];
return (
<div className="bg-white rounded-lg p-6">
<h2 className="text-xl font-bold mb-6"></h2>
<div className="mb-6">
<TrafficPoolSelection
selectedPools={selectedPools}
onSelect={setSelectedPools}
deviceIds={deviceIds}
placeholder="选择流量池"
/>
</div>
<div className="mt-8 flex justify-between">
<Button variant="outline" onClick={onBack}>
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting ? (isEditMode ? "更新中..." : "提交中...") : (isEditMode ? "更新完成" : "完成")}
</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={isEditMode ? "编辑流量分发" : "新建流量分发"}
defaultBackPath="/workspace/traffic-distribution"
rightContent={headerRightContent}
/>
}
>
<div className="bg-gray-50 min-h-screen pb-16">
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
<div className="text-gray-500">...</div>
</div>
</div>
) : (
<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>
);
}