From ae6821d91748dea2cff1d271977948ff32e7eba4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=AE=B8=E6=B0=B8=E5=B9=B3?= Date: Sun, 6 Jul 2025 12:34:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BC=93=E5=AD=98=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nkebao/src/components/BackButton.tsx | 2 +- .../components/ScenarioAcquisitionCard.tsx | 206 ++++++++ nkebao/src/components/ui/badge.tsx | 44 ++ nkebao/src/components/ui/button.tsx | 52 ++ nkebao/src/components/ui/card.tsx | 53 +++ nkebao/src/components/ui/dialog.tsx | 100 ++++ nkebao/src/components/ui/input.tsx | 31 ++ nkebao/src/components/ui/label.tsx | 18 + nkebao/src/components/ui/tooltip.tsx | 76 +++ nkebao/src/pages/scenarios/NewPlan.tsx | 448 ++++++++++++++---- nkebao/src/pages/scenarios/Scenarios.tsx | 2 +- .../pages/scenarios/douyin/DouyinScenario.tsx | 243 ++++++++++ .../gongzhonghao/GongzhonghaoScenario.tsx | 433 +++++++++++++++++ .../pages/scenarios/haibao/HaibaoScenario.tsx | 243 ++++++++++ .../xiaohongshu/XiaohongshuScenario.tsx | 243 ++++++++++ 15 files changed, 2088 insertions(+), 106 deletions(-) create mode 100644 nkebao/src/components/ScenarioAcquisitionCard.tsx create mode 100644 nkebao/src/components/ui/badge.tsx create mode 100644 nkebao/src/components/ui/button.tsx create mode 100644 nkebao/src/components/ui/card.tsx create mode 100644 nkebao/src/components/ui/dialog.tsx create mode 100644 nkebao/src/components/ui/input.tsx create mode 100644 nkebao/src/components/ui/label.tsx create mode 100644 nkebao/src/components/ui/tooltip.tsx create mode 100644 nkebao/src/pages/scenarios/douyin/DouyinScenario.tsx create mode 100644 nkebao/src/pages/scenarios/gongzhonghao/GongzhonghaoScenario.tsx create mode 100644 nkebao/src/pages/scenarios/haibao/HaibaoScenario.tsx create mode 100644 nkebao/src/pages/scenarios/xiaohongshu/XiaohongshuScenario.tsx diff --git a/nkebao/src/components/BackButton.tsx b/nkebao/src/components/BackButton.tsx index 9acfb8af..808bc01c 100644 --- a/nkebao/src/components/BackButton.tsx +++ b/nkebao/src/components/BackButton.tsx @@ -28,7 +28,7 @@ export const BackButton: React.FC = ({ onBack, text = '返回', className = '', - iconSize = 20, + iconSize = 6, showIcon = true, icon }) => { diff --git a/nkebao/src/components/ScenarioAcquisitionCard.tsx b/nkebao/src/components/ScenarioAcquisitionCard.tsx new file mode 100644 index 00000000..ad8343c8 --- /dev/null +++ b/nkebao/src/components/ScenarioAcquisitionCard.tsx @@ -0,0 +1,206 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { Card } from './ui/card'; +import { Button } from './ui/button'; +import { Badge } from './ui/badge'; +import { MoreHorizontal, Copy, Pencil, Trash2, Clock, Link } from 'lucide-react'; + +interface Task { + id: string; + name: string; + status: "running" | "paused" | "completed"; + stats: { + devices: number; + acquired: number; + added: number; + }; + lastUpdated: string; + executionTime: string; + nextExecutionTime: string; + trend: { date: string; customers: number }[]; + reqConf?: { + device?: string[]; + selectedDevices?: string[]; + }; + acquiredCount?: number; + addedCount?: number; + passRate?: number; +} + +interface ScenarioAcquisitionCardProps { + task: Task; + channel: string; + onEdit: (taskId: string) => void; + onCopy: (taskId: string) => void; + onDelete: (taskId: string) => void; + onOpenSettings?: (taskId: string) => void; + onStatusChange?: (taskId: string, newStatus: "running" | "paused") => void; +} + +export function ScenarioAcquisitionCard({ + task, + channel, + onEdit, + onCopy, + onDelete, + onOpenSettings, + onStatusChange, +}: ScenarioAcquisitionCardProps) { + // 兼容后端真实数据结构 + const deviceCount = Array.isArray(task.reqConf?.device) + ? task.reqConf!.device.length + : Array.isArray(task.reqConf?.selectedDevices) + ? task.reqConf!.selectedDevices.length + : 0; + // 获客数和已添加数可根据 msgConf 或其它字段自定义 + const acquiredCount = task.acquiredCount ?? 0; + const addedCount = task.addedCount ?? 0; + const passRate = task.passRate ?? 0; + const [menuOpen, setMenuOpen] = useState(false); + const menuRef = useRef(null); + + const isActive = task.status === "running"; + + const handleStatusChange = (e: React.MouseEvent) => { + e.stopPropagation(); + if (onStatusChange) { + onStatusChange(task.id, task.status === "running" ? "paused" : "running"); + } + }; + + const handleEdit = (e: React.MouseEvent) => { + e.stopPropagation(); + setMenuOpen(false); + onEdit(task.id); + }; + + const handleCopy = (e: React.MouseEvent) => { + e.stopPropagation(); + setMenuOpen(false); + onCopy(task.id); + }; + + const handleOpenSettings = (e: React.MouseEvent) => { + e.stopPropagation(); + setMenuOpen(false); + if (onOpenSettings) { + onOpenSettings(task.id); + } + }; + + const handleDelete = (e: React.MouseEvent) => { + e.stopPropagation(); + setMenuOpen(false); + onDelete(task.id); + }; + + const toggleMenu = (e?: React.MouseEvent) => { + if (e) e.stopPropagation(); + setMenuOpen(!menuOpen); + }; + + // 点击外部关闭菜单 + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setMenuOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + return ( + +
+
+

{task.name}

+ + {isActive ? "进行中" : "已暂停"} + +
+
+ + + {menuOpen && ( +
+ + + {onOpenSettings && ( + + )} + +
+ )} +
+
+ +
+
+ +
设备数
+
{deviceCount}
+
+
+ +
+ +
已获客
+
{acquiredCount}
+
+
+ +
+ +
已添加
+
{addedCount}
+
+
+ + +
通过率
+
{passRate}%
+
+
+ +
+
+ + 上次执行:{task.lastUpdated} +
+
+
+ ); +} \ No newline at end of file diff --git a/nkebao/src/components/ui/badge.tsx b/nkebao/src/components/ui/badge.tsx new file mode 100644 index 00000000..73aec328 --- /dev/null +++ b/nkebao/src/components/ui/badge.tsx @@ -0,0 +1,44 @@ +import React from 'react'; + +interface BadgeProps { + children: React.ReactNode; + variant?: 'default' | 'secondary' | 'success' | 'destructive'; + className?: string; + onClick?: (e: React.MouseEvent) => void; +} + +export function Badge({ + children, + variant = 'default', + className = '', + onClick +}: BadgeProps) { + const baseClasses = 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium'; + + const variantClasses = { + default: 'bg-blue-100 text-blue-800', + secondary: 'bg-gray-100 text-gray-800', + success: 'bg-green-100 text-green-800', + destructive: 'bg-red-100 text-red-800' + }; + + const classes = `${baseClasses} ${variantClasses[variant]} ${className}`; + + if (onClick) { + return ( + + ); + } + + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/nkebao/src/components/ui/button.tsx b/nkebao/src/components/ui/button.tsx new file mode 100644 index 00000000..4eb276f1 --- /dev/null +++ b/nkebao/src/components/ui/button.tsx @@ -0,0 +1,52 @@ +import React from 'react'; + +interface ButtonProps { + children: React.ReactNode; + variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'; + size?: 'default' | 'sm' | 'lg' | 'icon'; + className?: string; + onClick?: (e?: React.MouseEvent) => void; + disabled?: boolean; + type?: 'button' | 'submit' | 'reset'; +} + +export function Button({ + children, + variant = 'default', + size = 'default', + className = '', + onClick, + disabled = false, + type = 'button' +}: ButtonProps) { + const baseClasses = 'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none'; + + const variantClasses = { + default: 'bg-blue-600 text-white hover:bg-blue-700', + destructive: 'bg-red-600 text-white hover:bg-red-700', + outline: 'border border-gray-300 bg-white text-gray-700 hover:bg-gray-50', + secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200', + ghost: 'hover:bg-gray-100 text-gray-700', + link: 'text-blue-600 underline-offset-4 hover:underline' + }; + + const sizeClasses = { + default: 'h-10 px-4 py-2', + sm: 'h-9 px-3', + lg: 'h-11 px-8', + icon: 'h-10 w-10' + }; + + const classes = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`; + + return ( + + ); +} \ No newline at end of file diff --git a/nkebao/src/components/ui/card.tsx b/nkebao/src/components/ui/card.tsx new file mode 100644 index 00000000..87695c98 --- /dev/null +++ b/nkebao/src/components/ui/card.tsx @@ -0,0 +1,53 @@ +import React from 'react'; + +interface CardProps { + children: React.ReactNode; + className?: string; +} + +export function Card({ children, className = '' }: CardProps) { + return ( +
+ {children} +
+ ); +} + +interface CardHeaderProps { + children: React.ReactNode; + className?: string; +} + +export function CardHeader({ children, className = '' }: CardHeaderProps) { + return ( +
+ {children} +
+ ); +} + +interface CardContentProps { + children: React.ReactNode; + className?: string; +} + +export function CardContent({ children, className = '' }: CardContentProps) { + return ( +
+ {children} +
+ ); +} + +interface CardFooterProps { + children: React.ReactNode; + className?: string; +} + +export function CardFooter({ children, className = '' }: CardFooterProps) { + return ( +
+ {children} +
+ ); +} \ No newline at end of file diff --git a/nkebao/src/components/ui/dialog.tsx b/nkebao/src/components/ui/dialog.tsx new file mode 100644 index 00000000..e73f67d1 --- /dev/null +++ b/nkebao/src/components/ui/dialog.tsx @@ -0,0 +1,100 @@ +import React, { useEffect } from 'react'; + +interface DialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + children: React.ReactNode; +} + +export function Dialog({ open, onOpenChange, children }: DialogProps) { + useEffect(() => { + if (open) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = 'unset'; + } + + return () => { + document.body.style.overflow = 'unset'; + }; + }, [open]); + + if (!open) return null; + + return ( +
+
onOpenChange(false)} + /> +
+ {children} +
+
+ ); +} + +interface DialogContentProps { + children: React.ReactNode; + className?: string; +} + +export function DialogContent({ children, className = '' }: DialogContentProps) { + return ( +
+ {children} +
+ ); +} + +interface DialogHeaderProps { + children: React.ReactNode; + className?: string; +} + +export function DialogHeader({ children, className = '' }: DialogHeaderProps) { + return ( +
+ {children} +
+ ); +} + +interface DialogTitleProps { + children: React.ReactNode; + className?: string; +} + +export function DialogTitle({ children, className = '' }: DialogTitleProps) { + return ( +

+ {children} +

+ ); +} + +interface DialogDescriptionProps { + children: React.ReactNode; + className?: string; +} + +export function DialogDescription({ children, className = '' }: DialogDescriptionProps) { + return ( +

+ {children} +

+ ); +} + +interface DialogFooterProps { + children: React.ReactNode; + className?: string; +} + +export function DialogFooter({ children, className = '' }: DialogFooterProps) { + return ( +
+ {children} +
+ ); +} \ No newline at end of file diff --git a/nkebao/src/components/ui/input.tsx b/nkebao/src/components/ui/input.tsx new file mode 100644 index 00000000..104635ef --- /dev/null +++ b/nkebao/src/components/ui/input.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +interface InputProps { + value?: string; + onChange?: (e: React.ChangeEvent) => void; + placeholder?: string; + className?: string; + readOnly?: boolean; + id?: string; +} + +export function Input({ + value, + onChange, + placeholder, + className = '', + readOnly = false, + id +}: InputProps) { + return ( + + ); +} \ No newline at end of file diff --git a/nkebao/src/components/ui/label.tsx b/nkebao/src/components/ui/label.tsx new file mode 100644 index 00000000..cdefaa0d --- /dev/null +++ b/nkebao/src/components/ui/label.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +interface LabelProps { + children: React.ReactNode; + htmlFor?: string; + className?: string; +} + +export function Label({ children, htmlFor, className = '' }: LabelProps) { + return ( + + ); +} \ No newline at end of file diff --git a/nkebao/src/components/ui/tooltip.tsx b/nkebao/src/components/ui/tooltip.tsx new file mode 100644 index 00000000..fb7807fb --- /dev/null +++ b/nkebao/src/components/ui/tooltip.tsx @@ -0,0 +1,76 @@ +import React, { useState, useRef, useEffect } from 'react'; + +interface TooltipProviderProps { + children: React.ReactNode; +} + +export function TooltipProvider({ children }: TooltipProviderProps) { + return <>{children}; +} + +interface TooltipProps { + children: React.ReactNode; +} + +export function Tooltip({ children }: TooltipProps) { + return <>{children}; +} + +interface TooltipTriggerProps { + children: React.ReactNode; + asChild?: boolean; +} + +export function TooltipTrigger({ children, asChild }: TooltipTriggerProps) { + return <>{children}; +} + +interface TooltipContentProps { + children: React.ReactNode; + className?: string; +} + +export function TooltipContent({ children, className = '' }: TooltipContentProps) { + const [isVisible, setIsVisible] = useState(false); + const triggerRef = useRef(null); + + useEffect(() => { + const trigger = triggerRef.current; + if (!trigger) return; + + const showTooltip = () => setIsVisible(true); + const hideTooltip = () => setIsVisible(false); + + trigger.addEventListener('mouseenter', showTooltip); + trigger.addEventListener('mouseleave', hideTooltip); + + return () => { + trigger.removeEventListener('mouseenter', showTooltip); + trigger.removeEventListener('mouseleave', hideTooltip); + }; + }, []); + + return ( +
+ {React.Children.map(children, (child) => { + if (React.isValidElement(child)) { + return React.cloneElement(child, { + ...child.props, + children: ( + <> + {child.props.children} + {isVisible && ( +
+ {children} +
+
+ )} + + ) + }); + } + return child; + })} +
+ ); +} \ No newline at end of file diff --git a/nkebao/src/pages/scenarios/NewPlan.tsx b/nkebao/src/pages/scenarios/NewPlan.tsx index d305a694..680e7374 100644 --- a/nkebao/src/pages/scenarios/NewPlan.tsx +++ b/nkebao/src/pages/scenarios/NewPlan.tsx @@ -1,6 +1,15 @@ -import React, { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { ArrowLeft, Check } from 'lucide-react'; +import React, { useState, useEffect } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { Check, Settings } from 'lucide-react'; +import PageHeader from '@/components/PageHeader'; +import { useToast } from '@/components/ui/toast'; + +// 步骤定义 +const steps = [ + { id: 1, title: "步骤一", subtitle: "基础设置" }, + { id: 2, title: "步骤二", subtitle: "好友申请设置" }, + { id: 3, title: "步骤三", subtitle: "消息设置" }, +]; interface ScenarioOption { id: string; @@ -41,139 +50,370 @@ const scenarioOptions: ScenarioOption[] = [ }, ]; +interface FormData { + planName: string; + posters: any[]; + device: any[]; + remarkType: string; + greeting: string; + addInterval: number; + startTime: string; + endTime: string; + enabled: boolean; + sceneId: string; + scenario: string; + planNameEdited: boolean; +} + export default function NewPlan() { const navigate = useNavigate(); - const [selectedScenario, setSelectedScenario] = useState(''); - const [planName, setPlanName] = useState(''); - const [description, setDescription] = useState(''); + const searchParams = useSearchParams(); + const { toast } = useToast(); + const [currentStep, setCurrentStep] = useState(1); const [loading, setLoading] = useState(false); + const [loadingScenes, setLoadingScenes] = useState(false); + + const [formData, setFormData] = useState({ + planName: "", + posters: [], + device: [], + remarkType: "default", + greeting: "", + addInterval: 60, + startTime: "09:00", + endTime: "18:00", + enabled: true, + sceneId: searchParams[0].get("scenario") || "", + scenario: searchParams[0].get("scenario") || "", + planNameEdited: false + }); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!selectedScenario || !planName.trim()) { - alert('请选择场景并填写计划名称'); - return; + // 更新表单数据 + const onChange = (data: Partial) => { + if ('planName' in data) { + setFormData(prev => ({ ...prev, planNameEdited: true, ...data })); + } else { + setFormData(prev => ({ ...prev, ...data })); } + }; - setLoading(true); + // 处理保存 + const handleSave = async () => { try { - // 这里可以调用实际的API - // const response = await fetch('/api/plans', { - // method: 'POST', - // headers: { 'Content-Type': 'application/json' }, - // body: JSON.stringify({ - // scenarioId: selectedScenario, - // name: planName, - // description: description, - // }), - // }); - + setLoading(true); // 模拟API调用 await new Promise(resolve => setTimeout(resolve, 1000)); - // 跳转到计划详情页 - navigate(`/scenarios/${selectedScenario}?plan=${encodeURIComponent(planName)}`); - } catch (error) { - console.error('创建计划失败:', error); - alert('创建计划失败,请重试'); + toast({ + title: "创建成功", + description: "获客计划已创建", + }); + navigate("/scenarios"); + } catch (error: any) { + toast({ + title: "创建失败", + description: error?.message || "创建计划失败,请重试", + variant: "destructive", + }); } finally { setLoading(false); } }; - return ( -
-
-
- -

新建计划

-
-
+ // 下一步 + const handleNext = () => { + if (currentStep === steps.length) { + handleSave(); + } else { + setCurrentStep((prev) => prev + 1); + } + }; -
-
- {/* 场景选择 */} -
-

选择获客场景

-
- {scenarioOptions.map((scenario) => ( -
setSelectedScenario(scenario.id)} - > - {selectedScenario === scenario.id && ( -
- -
- )} -
- {scenario.name} -

{scenario.name}

-

{scenario.description}

+ // 上一步 + const handlePrev = () => { + setCurrentStep((prev) => Math.max(prev - 1, 1)); + }; + + // 步骤指示器组件 + const StepIndicator = ({ steps, currentStep }: { steps: any[], currentStep: number }) => ( +
+ {steps.map((step, index) => ( +
+
step.id + ? 'bg-green-500 text-white' + : currentStep === step.id + ? 'bg-blue-500 text-white' + : 'bg-gray-200 text-gray-500' + }`}> + {currentStep > step.id ? : step.id} +
+
+
{step.title}
+
{step.subtitle}
+
+ {index < steps.length - 1 && ( +
step.id ? 'bg-green-500' : 'bg-gray-200' + }`} /> + )} +
+ ))} +
+ ); + + // 基础设置步骤 + const BasicSettings = () => { + const selectedScenario = scenarioOptions.find(s => s.id === formData.sceneId); + + return ( +
+
+

选择获客场景

+
+ {scenarioOptions.map((scenario) => ( +
onChange({ sceneId: scenario.id, scenario: scenario.id })} + > + {formData.sceneId === scenario.id && ( +
+
+ )} +
+ {scenario.name} +

{scenario.name}

+

{scenario.description}

- ))} -
+
+ ))} +
+
+ +
+

计划信息

+ +
+ + onChange({ planName: e.target.value })} + placeholder="请输入计划名称" + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + required + />
- {/* 计划信息 */} -
-

计划信息

- +
+ +
+
+ + onChange({ startTime: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ + onChange({ endTime: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+
+
+ +
+ +
+
+ ); + }; + + // 好友申请设置步骤 + const FriendRequestSettings = () => ( +
+
+

好友申请设置

+ +
+
+ + onChange({ addInterval: parseInt(e.target.value) || 60 })} + min="30" + max="3600" + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +

建议设置60-300秒,避免被限制

+
+ +
+ + +
+ + {formData.remarkType === 'custom' && (
setPlanName(e.target.value)} - placeholder="请输入计划名称" - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" - required + placeholder="请输入自定义备注内容" + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" />
+ )} +
+
-
- -