feat: 缓存数据
This commit is contained in:
@@ -28,7 +28,7 @@ export const BackButton: React.FC<BackButtonProps> = ({
|
|||||||
onBack,
|
onBack,
|
||||||
text = '返回',
|
text = '返回',
|
||||||
className = '',
|
className = '',
|
||||||
iconSize = 20,
|
iconSize = 6,
|
||||||
showIcon = true,
|
showIcon = true,
|
||||||
icon
|
icon
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
206
nkebao/src/components/ScenarioAcquisitionCard.tsx
Normal file
206
nkebao/src/components/ScenarioAcquisitionCard.tsx
Normal file
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<Card className="p-6 hover:shadow-lg transition-all mb-4 bg-white/80">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<h3 className="font-medium text-lg">{task.name}</h3>
|
||||||
|
<Badge
|
||||||
|
variant={isActive ? "success" : "secondary"}
|
||||||
|
className="cursor-pointer hover:opacity-80"
|
||||||
|
onClick={handleStatusChange}
|
||||||
|
>
|
||||||
|
{isActive ? "进行中" : "已暂停"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="relative z-20" ref={menuRef}>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-gray-100 rounded-full" onClick={toggleMenu}>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{menuOpen && (
|
||||||
|
<div className="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg z-50 py-1 border">
|
||||||
|
<button
|
||||||
|
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
|
onClick={handleEdit}
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4 mr-2" />
|
||||||
|
编辑计划
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
|
onClick={handleCopy}
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4 mr-2" />
|
||||||
|
复制计划
|
||||||
|
</button>
|
||||||
|
{onOpenSettings && (
|
||||||
|
<button
|
||||||
|
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
|
onClick={handleOpenSettings}
|
||||||
|
>
|
||||||
|
<Link className="w-4 h-4 mr-2" />
|
||||||
|
计划接口
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="flex items-center w-full px-4 py-2 text-sm text-red-600 hover:bg-gray-100"
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
删除计划
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 mb-4">
|
||||||
|
<div className="block">
|
||||||
|
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
|
||||||
|
<div className="text-sm text-gray-500 mb-1">设备数</div>
|
||||||
|
<div className="text-2xl font-semibold">{deviceCount}</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="block">
|
||||||
|
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
|
||||||
|
<div className="text-sm text-gray-500 mb-1">已获客</div>
|
||||||
|
<div className="text-2xl font-semibold">{acquiredCount}</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="block">
|
||||||
|
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
|
||||||
|
<div className="text-sm text-gray-500 mb-1">已添加</div>
|
||||||
|
<div className="text-2xl font-semibold">{addedCount}</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="p-2">
|
||||||
|
<div className="text-sm text-gray-500 mb-1">通过率</div>
|
||||||
|
<div className="text-2xl font-semibold">{passRate}%</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm border-t pt-4 text-gray-500">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
<span>上次执行:{task.lastUpdated}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
nkebao/src/components/ui/badge.tsx
Normal file
44
nkebao/src/components/ui/badge.tsx
Normal file
@@ -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 (
|
||||||
|
<button
|
||||||
|
className={classes}
|
||||||
|
onClick={onClick}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={classes}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
nkebao/src/components/ui/button.tsx
Normal file
52
nkebao/src/components/ui/button.tsx
Normal file
@@ -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 (
|
||||||
|
<button
|
||||||
|
className={classes}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
type={type}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
nkebao/src/components/ui/card.tsx
Normal file
53
nkebao/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface CardProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Card({ children, className = '' }: CardProps) {
|
||||||
|
return (
|
||||||
|
<div className={`bg-white rounded-lg border border-gray-200 shadow-sm ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardHeaderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardHeader({ children, className = '' }: CardHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className={`px-6 py-4 border-b border-gray-200 ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardContentProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardContent({ children, className = '' }: CardContentProps) {
|
||||||
|
return (
|
||||||
|
<div className={`px-6 py-4 ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardFooterProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardFooter({ children, className = '' }: CardFooterProps) {
|
||||||
|
return (
|
||||||
|
<div className={`px-6 py-4 border-t border-gray-200 ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
nkebao/src/components/ui/dialog.tsx
Normal file
100
nkebao/src/components/ui/dialog.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black bg-opacity-50"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
/>
|
||||||
|
<div className="relative bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DialogContentProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogContent({ children, className = '' }: DialogContentProps) {
|
||||||
|
return (
|
||||||
|
<div className={`p-6 ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DialogHeaderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogHeader({ children, className = '' }: DialogHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className={`mb-4 ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DialogTitleProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogTitle({ children, className = '' }: DialogTitleProps) {
|
||||||
|
return (
|
||||||
|
<h2 className={`text-lg font-semibold text-gray-900 ${className}`}>
|
||||||
|
{children}
|
||||||
|
</h2>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DialogDescriptionProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogDescription({ children, className = '' }: DialogDescriptionProps) {
|
||||||
|
return (
|
||||||
|
<p className={`text-sm text-gray-600 mt-1 ${className}`}>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DialogFooterProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogFooter({ children, className = '' }: DialogFooterProps) {
|
||||||
|
return (
|
||||||
|
<div className={`flex justify-end space-x-2 mt-6 ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
nkebao/src/components/ui/input.tsx
Normal file
31
nkebao/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface InputProps {
|
||||||
|
value?: string;
|
||||||
|
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Input({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
className = '',
|
||||||
|
readOnly = false,
|
||||||
|
id
|
||||||
|
}: InputProps) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
readOnly={readOnly}
|
||||||
|
className={`flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
nkebao/src/components/ui/label.tsx
Normal file
18
nkebao/src/components/ui/label.tsx
Normal file
@@ -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 (
|
||||||
|
<label
|
||||||
|
htmlFor={htmlFor}
|
||||||
|
className={`text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
nkebao/src/components/ui/tooltip.tsx
Normal file
76
nkebao/src/components/ui/tooltip.tsx
Normal file
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<div ref={triggerRef} className="relative inline-block">
|
||||||
|
{React.Children.map(children, (child) => {
|
||||||
|
if (React.isValidElement(child)) {
|
||||||
|
return React.cloneElement(child, {
|
||||||
|
...child.props,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
{child.props.children}
|
||||||
|
{isVisible && (
|
||||||
|
<div className={`absolute z-50 px-2 py-1 text-xs text-white bg-gray-900 rounded shadow-lg whitespace-nowrap ${className}`} style={{ top: '-30px', left: '50%', transform: 'translateX(-50%)' }}>
|
||||||
|
{children}
|
||||||
|
<div className="absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-900"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { ArrowLeft, Check } from 'lucide-react';
|
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 {
|
interface ScenarioOption {
|
||||||
id: string;
|
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() {
|
export default function NewPlan() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [selectedScenario, setSelectedScenario] = useState<string>('');
|
const searchParams = useSearchParams();
|
||||||
const [planName, setPlanName] = useState('');
|
const { toast } = useToast();
|
||||||
const [description, setDescription] = useState('');
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [loadingScenes, setLoadingScenes] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const [formData, setFormData] = useState<FormData>({
|
||||||
e.preventDefault();
|
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
|
||||||
|
});
|
||||||
|
|
||||||
if (!selectedScenario || !planName.trim()) {
|
// 更新表单数据
|
||||||
alert('请选择场景并填写计划名称');
|
const onChange = (data: Partial<FormData>) => {
|
||||||
return;
|
if ('planName' in data) {
|
||||||
|
setFormData(prev => ({ ...prev, planNameEdited: true, ...data }));
|
||||||
|
} else {
|
||||||
|
setFormData(prev => ({ ...prev, ...data }));
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
setLoading(true);
|
// 处理保存
|
||||||
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
// 这里可以调用实际的API
|
setLoading(true);
|
||||||
// const response = await fetch('/api/plans', {
|
|
||||||
// method: 'POST',
|
|
||||||
// headers: { 'Content-Type': 'application/json' },
|
|
||||||
// body: JSON.stringify({
|
|
||||||
// scenarioId: selectedScenario,
|
|
||||||
// name: planName,
|
|
||||||
// description: description,
|
|
||||||
// }),
|
|
||||||
// });
|
|
||||||
|
|
||||||
// 模拟API调用
|
// 模拟API调用
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
// 跳转到计划详情页
|
toast({
|
||||||
navigate(`/scenarios/${selectedScenario}?plan=${encodeURIComponent(planName)}`);
|
title: "创建成功",
|
||||||
} catch (error) {
|
description: "获客计划已创建",
|
||||||
console.error('创建计划失败:', error);
|
});
|
||||||
alert('创建计划失败,请重试');
|
navigate("/scenarios");
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({
|
||||||
|
title: "创建失败",
|
||||||
|
description: error?.message || "创建计划失败,请重试",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
// 下一步
|
||||||
<div className="flex-1 overflow-y-auto pb-20 bg-gray-50">
|
const handleNext = () => {
|
||||||
<header className="sticky top-0 z-10 bg-white border-b">
|
if (currentStep === steps.length) {
|
||||||
<div className="flex items-center p-4">
|
handleSave();
|
||||||
<button
|
} else {
|
||||||
onClick={() => navigate(-1)}
|
setCurrentStep((prev) => prev + 1);
|
||||||
className="mr-3 p-1 hover:bg-gray-100 rounded"
|
}
|
||||||
>
|
};
|
||||||
<ArrowLeft className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
<h1 className="text-xl font-semibold">新建计划</h1>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="p-4">
|
// 上一步
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
const handlePrev = () => {
|
||||||
{/* 场景选择 */}
|
setCurrentStep((prev) => Math.max(prev - 1, 1));
|
||||||
<div>
|
};
|
||||||
<h2 className="text-lg font-medium mb-4">选择获客场景</h2>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
// 步骤指示器组件
|
||||||
{scenarioOptions.map((scenario) => (
|
const StepIndicator = ({ steps, currentStep }: { steps: any[], currentStep: number }) => (
|
||||||
<div
|
<div className="flex items-center justify-between mb-6">
|
||||||
key={scenario.id}
|
{steps.map((step, index) => (
|
||||||
className={`relative p-4 border-2 rounded-lg cursor-pointer transition-all ${
|
<div key={step.id} className="flex items-center">
|
||||||
selectedScenario === scenario.id
|
<div className={`flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium ${
|
||||||
? 'border-blue-500 bg-blue-50'
|
currentStep > step.id
|
||||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
? 'bg-green-500 text-white'
|
||||||
}`}
|
: currentStep === step.id
|
||||||
onClick={() => setSelectedScenario(scenario.id)}
|
? 'bg-blue-500 text-white'
|
||||||
>
|
: 'bg-gray-200 text-gray-500'
|
||||||
{selectedScenario === scenario.id && (
|
}`}>
|
||||||
<div className="absolute top-2 right-2 w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center">
|
{currentStep > step.id ? <Check className="h-4 w-4" /> : step.id}
|
||||||
<Check className="h-4 w-4 text-white" />
|
</div>
|
||||||
</div>
|
<div className="ml-2">
|
||||||
)}
|
<div className="text-sm font-medium">{step.title}</div>
|
||||||
<div className="flex flex-col items-center text-center">
|
<div className="text-xs text-gray-500">{step.subtitle}</div>
|
||||||
<img
|
</div>
|
||||||
src={scenario.image}
|
{index < steps.length - 1 && (
|
||||||
alt={scenario.name}
|
<div className={`w-12 h-0.5 mx-4 ${
|
||||||
className="w-12 h-12 mb-2 rounded"
|
currentStep > step.id ? 'bg-green-500' : 'bg-gray-200'
|
||||||
/>
|
}`} />
|
||||||
<h3 className="font-medium text-gray-900">{scenario.name}</h3>
|
)}
|
||||||
<p className="text-xs text-gray-500 mt-1">{scenario.description}</p>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 基础设置步骤
|
||||||
|
const BasicSettings = () => {
|
||||||
|
const selectedScenario = scenarioOptions.find(s => s.id === formData.sceneId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium mb-4">选择获客场景</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{scenarioOptions.map((scenario) => (
|
||||||
|
<div
|
||||||
|
key={scenario.id}
|
||||||
|
className={`relative p-4 border-2 rounded-lg cursor-pointer transition-all ${
|
||||||
|
formData.sceneId === scenario.id
|
||||||
|
? 'border-blue-500 bg-blue-50'
|
||||||
|
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
onClick={() => onChange({ sceneId: scenario.id, scenario: scenario.id })}
|
||||||
|
>
|
||||||
|
{formData.sceneId === scenario.id && (
|
||||||
|
<div className="absolute top-2 right-2 w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center">
|
||||||
|
<Check className="h-4 w-4 text-white" />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col items-center text-center">
|
||||||
|
<img
|
||||||
|
src={scenario.image}
|
||||||
|
alt={scenario.name}
|
||||||
|
className="w-12 h-12 mb-2 rounded"
|
||||||
|
/>
|
||||||
|
<h3 className="font-medium text-gray-900">{scenario.name}</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">{scenario.description}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-medium">计划信息</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
计划名称 *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.planName}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 计划信息 */}
|
<div>
|
||||||
<div className="space-y-4">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
<h2 className="text-lg font-medium">计划信息</h2>
|
执行时间
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">开始时间</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={formData.startTime}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">结束时间</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={formData.endTime}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
<button
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={!formData.sceneId || !formData.planName.trim()}
|
||||||
|
className="w-full py-3 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
下一步
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 好友申请设置步骤
|
||||||
|
const FriendRequestSettings = () => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium mb-4">好友申请设置</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
添加间隔(秒)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.addInterval}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">建议设置60-300秒,避免被限制</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
备注类型
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.remarkType}
|
||||||
|
onChange={(e) => onChange({ remarkType: 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"
|
||||||
|
>
|
||||||
|
<option value="default">默认备注</option>
|
||||||
|
<option value="custom">自定义备注</option>
|
||||||
|
<option value="none">不添加备注</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.remarkType === 'custom' && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
计划名称 *
|
自定义备注
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={planName}
|
placeholder="请输入自定义备注内容"
|
||||||
onChange={(e) => setPlanName(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"
|
||||||
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
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="flex gap-4 pt-4">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<button
|
||||||
计划描述
|
onClick={handlePrev}
|
||||||
</label>
|
className="flex-1 py-3 px-4 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 transition-colors"
|
||||||
<textarea
|
>
|
||||||
value={description}
|
上一步
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
</button>
|
||||||
placeholder="请输入计划描述(可选)"
|
<button
|
||||||
rows={3}
|
onClick={handleNext}
|
||||||
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 resize-none"
|
className="flex-1 py-3 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||||
/>
|
>
|
||||||
</div>
|
下一步
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 消息设置步骤
|
||||||
|
const MessageSettings = () => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium mb-4">消息设置</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
打招呼消息
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.greeting}
|
||||||
|
onChange={(e) => onChange({ greeting: e.target.value })}
|
||||||
|
placeholder="请输入打招呼消息内容"
|
||||||
|
rows={4}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">支持变量:{name}(对方昵称)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 提交按钮 */}
|
<div className="flex items-center">
|
||||||
<div className="pt-4">
|
<input
|
||||||
<button
|
type="checkbox"
|
||||||
type="submit"
|
id="enabled"
|
||||||
disabled={loading || !selectedScenario || !planName.trim()}
|
checked={formData.enabled}
|
||||||
className="w-full py-3 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
|
onChange={(e) => onChange({ enabled: e.target.checked })}
|
||||||
>
|
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||||
{loading ? '创建中...' : '创建计划'}
|
/>
|
||||||
|
<label htmlFor="enabled" className="ml-2 block text-sm text-gray-700">
|
||||||
|
立即启用计划
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 pt-4">
|
||||||
|
<button
|
||||||
|
onClick={handlePrev}
|
||||||
|
className="flex-1 py-3 px-4 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
上一步
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex-1 py-3 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? '创建中...' : '创建计划'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 渲染当前步骤内容
|
||||||
|
const renderStepContent = () => {
|
||||||
|
switch (currentStep) {
|
||||||
|
case 1:
|
||||||
|
return <BasicSettings />;
|
||||||
|
case 2:
|
||||||
|
return <FriendRequestSettings />;
|
||||||
|
case 3:
|
||||||
|
return <MessageSettings />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<div className="max-w-[390px] mx-auto bg-white min-h-screen flex flex-col">
|
||||||
|
<PageHeader
|
||||||
|
title="新建获客计划"
|
||||||
|
defaultBackPath="/scenarios"
|
||||||
|
rightContent={
|
||||||
|
<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors">
|
||||||
|
<Settings className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
<div className="px-4 py-6">
|
||||||
|
<StepIndicator steps={steps} currentStep={currentStep} />
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
|
<div className="flex-1 px-4 pb-20">{renderStepContent()}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ export default function Scenarios() {
|
|||||||
<div
|
<div
|
||||||
key={scenario.id}
|
key={scenario.id}
|
||||||
className="bg-white rounded-lg shadow overflow-hidden hover:shadow-md transition-shadow cursor-pointer"
|
className="bg-white rounded-lg shadow overflow-hidden hover:shadow-md transition-shadow cursor-pointer"
|
||||||
onClick={() => navigate(`/scenarios/${scenario.id}`)}
|
onClick={() => navigate(`/scenarios/${scenario.id}/detail`)}
|
||||||
>
|
>
|
||||||
<div className="p-4 flex flex-col items-center">
|
<div className="p-4 flex flex-col items-center">
|
||||||
<img src={scenario.image} alt={scenario.name} className="w-12 h-12 mb-2 rounded" />
|
<img src={scenario.image} alt={scenario.name} className="w-12 h-12 mb-2 rounded" />
|
||||||
|
|||||||
243
nkebao/src/pages/scenarios/douyin/DouyinScenario.tsx
Normal file
243
nkebao/src/pages/scenarios/douyin/DouyinScenario.tsx
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Plus, Copy, Trash2, Play, Pause, TrendingUp, Users, Clock } from 'lucide-react';
|
||||||
|
import PageHeader from '@/components/PageHeader';
|
||||||
|
import { useToast } from '@/components/ui/toast';
|
||||||
|
|
||||||
|
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 }[];
|
||||||
|
dailyData: { date: string; acquired: number; added: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DouyinScenario() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const channel = "douyin";
|
||||||
|
|
||||||
|
const [tasks, setTasks] = useState<Task[]>([
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
name: "抖音直播获客计划",
|
||||||
|
status: "running",
|
||||||
|
stats: {
|
||||||
|
devices: 3,
|
||||||
|
acquired: 45,
|
||||||
|
added: 32,
|
||||||
|
},
|
||||||
|
lastUpdated: "2024-03-18 15:30",
|
||||||
|
executionTime: "2024-03-18 15:30",
|
||||||
|
nextExecutionTime: "预计30分钟后",
|
||||||
|
trend: Array.from({ length: 7 }, (_, i) => ({
|
||||||
|
date: `3月${String(i + 12)}日`,
|
||||||
|
customers: Math.floor(Math.random() * 10) + 5,
|
||||||
|
})),
|
||||||
|
dailyData: [
|
||||||
|
{ date: "3/12", acquired: 12, added: 8 },
|
||||||
|
{ date: "3/13", acquired: 15, added: 10 },
|
||||||
|
{ date: "3/14", acquired: 8, added: 6 },
|
||||||
|
{ date: "3/15", acquired: 10, added: 7 },
|
||||||
|
{ date: "3/16", acquired: 14, added: 11 },
|
||||||
|
{ date: "3/17", acquired: 9, added: 7 },
|
||||||
|
{ date: "3/18", acquired: 11, added: 9 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleCopyPlan = (taskId: string) => {
|
||||||
|
const taskToCopy = tasks.find((task) => task.id === taskId);
|
||||||
|
if (taskToCopy) {
|
||||||
|
const newTask = {
|
||||||
|
...taskToCopy,
|
||||||
|
id: `${Date.now()}`,
|
||||||
|
name: `${taskToCopy.name} (副本)`,
|
||||||
|
status: "paused" as const,
|
||||||
|
};
|
||||||
|
setTasks([...tasks, newTask]);
|
||||||
|
toast({
|
||||||
|
title: "计划已复制",
|
||||||
|
description: `已成功复制"${taskToCopy.name}"`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletePlan = (taskId: string) => {
|
||||||
|
const taskToDelete = tasks.find((t) => t.id === taskId);
|
||||||
|
if (taskToDelete) {
|
||||||
|
setTasks(tasks.filter((t) => t.id !== taskId));
|
||||||
|
toast({
|
||||||
|
title: "计划已删除",
|
||||||
|
description: `已成功删除"${taskToDelete.name}"`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'running':
|
||||||
|
return 'text-green-600 bg-green-50';
|
||||||
|
case 'paused':
|
||||||
|
return 'text-yellow-600 bg-yellow-50';
|
||||||
|
case 'completed':
|
||||||
|
return 'text-gray-600 bg-gray-50';
|
||||||
|
default:
|
||||||
|
return 'text-gray-600 bg-gray-50';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'running':
|
||||||
|
return '进行中';
|
||||||
|
case 'paused':
|
||||||
|
return '已暂停';
|
||||||
|
case 'completed':
|
||||||
|
return '已完成';
|
||||||
|
default:
|
||||||
|
return '未知';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'running':
|
||||||
|
return <Play className="h-4 w-4" />;
|
||||||
|
case 'paused':
|
||||||
|
return <Pause className="h-4 w-4" />;
|
||||||
|
case 'completed':
|
||||||
|
return <Clock className="h-4 w-4" />;
|
||||||
|
default:
|
||||||
|
return <Clock className="h-4 w-4" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 bg-gradient-to-b from-blue-50 to-white min-h-screen">
|
||||||
|
<PageHeader
|
||||||
|
title="抖音获客"
|
||||||
|
defaultBackPath="/scenarios"
|
||||||
|
rightContent={
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/scenarios/new?scenario=douyin')}
|
||||||
|
className="flex items-center px-3 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
新建计划
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="p-4 max-w-7xl mx-auto">
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<div key={task.id} className="bg-white rounded-lg shadow-sm border border-gray-100 mb-4 overflow-hidden">
|
||||||
|
{/* 任务头部 */}
|
||||||
|
<div className="p-4 border-b border-gray-100">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className={`p-2 rounded-lg ${getStatusColor(task.status)}`}>
|
||||||
|
{getStatusIcon(task.status)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-gray-900">{task.name}</h3>
|
||||||
|
<div className="flex items-center space-x-4 text-sm text-gray-500 mt-1">
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(task.status)}`}>
|
||||||
|
{getStatusText(task.status)}
|
||||||
|
</span>
|
||||||
|
<span>最后更新: {task.lastUpdated}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopyPlan(task.id)}
|
||||||
|
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||||
|
title="复制计划"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeletePlan(task.id)}
|
||||||
|
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
title="删除计划"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 统计信息 */}
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="flex items-center justify-center text-gray-500 mb-1">
|
||||||
|
<Users className="h-4 w-4 mr-1" />
|
||||||
|
<span className="text-sm">设备数</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold text-blue-600">{task.stats.devices}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="flex items-center justify-center text-gray-500 mb-1">
|
||||||
|
<TrendingUp className="h-4 w-4 mr-1" />
|
||||||
|
<span className="text-sm">已获客</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold text-green-600">{task.stats.acquired}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="flex items-center justify-center text-gray-500 mb-1">
|
||||||
|
<Users className="h-4 w-4 mr-1" />
|
||||||
|
<span className="text-sm">已添加</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold text-purple-600">{task.stats.added}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 趋势图表 */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-2">7天趋势</h4>
|
||||||
|
<div className="flex items-end space-x-1 h-20">
|
||||||
|
{task.trend.map((item, index) => (
|
||||||
|
<div key={index} className="flex-1 flex flex-col items-center">
|
||||||
|
<div
|
||||||
|
className="w-full bg-blue-200 rounded-t"
|
||||||
|
style={{ height: `${(item.customers / 15) * 100}%` }}
|
||||||
|
></div>
|
||||||
|
<span className="text-xs text-gray-500 mt-1">{item.date}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 执行信息 */}
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-600">执行时间: {task.executionTime}</span>
|
||||||
|
<span className="text-blue-600">{task.nextExecutionTime}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 设备树图表 */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-100 p-4">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-4">设备分布</h3>
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
<Users className="h-12 w-12 mx-auto mb-2 text-gray-300" />
|
||||||
|
<p>设备树图表功能开发中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
433
nkebao/src/pages/scenarios/gongzhonghao/GongzhonghaoScenario.tsx
Normal file
433
nkebao/src/pages/scenarios/gongzhonghao/GongzhonghaoScenario.tsx
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Copy, Link, HelpCircle, Shield, Plus } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useToast } from '@/components/ui/toast';
|
||||||
|
import { ScenarioAcquisitionCard } from '@/components/ScenarioAcquisitionCard';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import PageHeader from '@/components/PageHeader';
|
||||||
|
|
||||||
|
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 DeviceStats {
|
||||||
|
active: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API文档提示组件
|
||||||
|
function ApiDocumentationTooltip() {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<HelpCircle className="h-4 w-4 text-gray-400 cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-xs">
|
||||||
|
<p className="text-xs">
|
||||||
|
计划接口允许您通过API将外部系统的客户数据直接导入到存客宝。支持多种编程语言和第三方平台集成。
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GongzhonghaoScenario() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const channel = "gongzhonghao";
|
||||||
|
const [channelName, setChannelName] = useState<string>("公众号获客");
|
||||||
|
|
||||||
|
// 1. tasks 初始值设为 []
|
||||||
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 模拟API调用获取场景名称
|
||||||
|
setChannelName("公众号获客");
|
||||||
|
}, [channel]);
|
||||||
|
|
||||||
|
// 抽出请求列表的函数
|
||||||
|
const fetchTasks = () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
// 模拟API调用
|
||||||
|
setTimeout(() => {
|
||||||
|
const mockTasks: Task[] = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
name: "公众号私域引流计划",
|
||||||
|
status: "running",
|
||||||
|
stats: {
|
||||||
|
devices: 4,
|
||||||
|
acquired: 67,
|
||||||
|
added: 45,
|
||||||
|
},
|
||||||
|
lastUpdated: "2024-03-18 16:15",
|
||||||
|
executionTime: "2024-03-18 16:15",
|
||||||
|
nextExecutionTime: "预计20分钟后",
|
||||||
|
trend: Array.from({ length: 7 }, (_, i) => ({
|
||||||
|
date: `3月${String(i + 12)}日`,
|
||||||
|
customers: Math.floor(Math.random() * 15) + 8,
|
||||||
|
})),
|
||||||
|
acquiredCount: 67,
|
||||||
|
addedCount: 45,
|
||||||
|
passRate: 67,
|
||||||
|
reqConf: {
|
||||||
|
selectedDevices: ["device1", "device2", "device3", "device4"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
];
|
||||||
|
setTasks(mockTasks);
|
||||||
|
setTotal(1);
|
||||||
|
setLoading(false);
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTasks();
|
||||||
|
}, [channel, page, pageSize]);
|
||||||
|
|
||||||
|
const [deviceStats, setDeviceStats] = useState<DeviceStats>({
|
||||||
|
active: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [showApiDialog, setShowApiDialog] = useState(false);
|
||||||
|
const [currentApiSettings, setCurrentApiSettings] = useState({
|
||||||
|
apiKey: "",
|
||||||
|
webhookUrl: "",
|
||||||
|
taskId: "",
|
||||||
|
fullUrl: ""
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleEditPlan = (taskId: string) => {
|
||||||
|
navigate(`/scenarios/${channel}/edit/${taskId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyPlan = (taskId: string) => {
|
||||||
|
const taskToCopy = tasks.find((task) => task.id === taskId);
|
||||||
|
if (!taskToCopy) return;
|
||||||
|
|
||||||
|
// 模拟API调用
|
||||||
|
setTimeout(() => {
|
||||||
|
const newTask = {
|
||||||
|
...taskToCopy,
|
||||||
|
id: `${Date.now()}`,
|
||||||
|
name: `${taskToCopy.name} (副本)`,
|
||||||
|
status: "paused" as const,
|
||||||
|
};
|
||||||
|
setTasks([...tasks, newTask]);
|
||||||
|
toast({
|
||||||
|
title: "计划已复制",
|
||||||
|
description: `已成功复制"${taskToCopy.name}"`,
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletePlan = (taskId: string) => {
|
||||||
|
const taskToDelete = tasks.find((t) => t.id === taskId);
|
||||||
|
if (!taskToDelete) return;
|
||||||
|
|
||||||
|
// 模拟API调用
|
||||||
|
setTimeout(() => {
|
||||||
|
setTasks(tasks.filter((t) => t.id !== taskId));
|
||||||
|
toast({
|
||||||
|
title: "计划已删除",
|
||||||
|
description: `已成功删除"${taskToDelete.name}"`,
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusChange = (taskId: string, newStatus: "running" | "paused") => {
|
||||||
|
setTasks(tasks.map((task) => (task.id === taskId ? { ...task, status: newStatus } : task)));
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: newStatus === "running" ? "计划已启动" : "计划已暂停",
|
||||||
|
description: `已${newStatus === "running" ? "启动" : "暂停"}获客计划`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenApiSettings = async (taskId: string) => {
|
||||||
|
const task = tasks.find((t) => t.id === taskId);
|
||||||
|
if (!task) return;
|
||||||
|
|
||||||
|
// 模拟API调用获取接口设置
|
||||||
|
const mockApiSettings = {
|
||||||
|
apiKey: `api_key_${taskId}_${Date.now()}`,
|
||||||
|
webhookUrl: `https://api.example.com/webhook/${taskId}`,
|
||||||
|
taskId: taskId,
|
||||||
|
fullUrl: `name=测试用户&phone=13800138000&source=公众号&remark=测试备注`
|
||||||
|
};
|
||||||
|
|
||||||
|
setCurrentApiSettings(mockApiSettings);
|
||||||
|
setShowApiDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyApiUrl = (url: string, withParams = false) => {
|
||||||
|
const textToCopy = withParams ? `${url}?${currentApiSettings.fullUrl}` : url;
|
||||||
|
navigator.clipboard.writeText(textToCopy);
|
||||||
|
toast({
|
||||||
|
title: "已复制",
|
||||||
|
description: "接口地址已复制到剪贴板",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateNewPlan = () => {
|
||||||
|
navigate(`/scenarios/new?type=${channel}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 bg-gradient-to-b from-blue-50 to-white min-h-screen">
|
||||||
|
<PageHeader
|
||||||
|
title={channelName}
|
||||||
|
defaultBackPath="/scenarios"
|
||||||
|
rightContent={
|
||||||
|
<Button onClick={handleCreateNewPlan} size="sm" className="bg-blue-600 hover:bg-blue-700 text-white">
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
新建计划
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="p-4 md:p-6 lg:p-8 max-w-7xl mx-auto">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12 text-gray-400">加载中...</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-center py-12 text-red-500">{error}</div>
|
||||||
|
) : tasks.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<div key={task.id}>
|
||||||
|
<ScenarioAcquisitionCard
|
||||||
|
task={task}
|
||||||
|
channel={channel}
|
||||||
|
onEdit={handleEditPlan}
|
||||||
|
onCopy={handleCopyPlan}
|
||||||
|
onDelete={handleDeletePlan}
|
||||||
|
onStatusChange={handleStatusChange}
|
||||||
|
onOpenSettings={handleOpenApiSettings}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex justify-center mt-6 gap-2">
|
||||||
|
<Button disabled={page === 1} onClick={() => setPage(page - 1)}>上一页</Button>
|
||||||
|
<span className="px-2">第 {page} 页 / 共 {Math.max(1, Math.ceil(total / pageSize))} 页</span>
|
||||||
|
<Button disabled={page * pageSize >= total} onClick={() => setPage(page + 1)}>下一页</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12 bg-white rounded-lg shadow-sm md:col-span-2 lg:col-span-3">
|
||||||
|
<div className="text-gray-400 mb-4">暂无获客计划</div>
|
||||||
|
<Button onClick={handleCreateNewPlan} className="bg-blue-600 hover:bg-blue-700 text-white">
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
新建计划
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API接口设置对话框 */}
|
||||||
|
<Dialog open={showApiDialog} onOpenChange={setShowApiDialog}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DialogTitle>计划接口</DialogTitle>
|
||||||
|
<ApiDocumentationTooltip />
|
||||||
|
</div>
|
||||||
|
<DialogDescription>使用此接口直接导入客资到该获客计划,支持多种编程语言。</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-5 py-4">
|
||||||
|
{/* API密钥部分 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="api-key" className="text-sm font-medium flex items-center gap-1">
|
||||||
|
API密钥
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<HelpCircle className="h-3.5 w-3.5 text-gray-400 cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-xs">
|
||||||
|
<p className="text-xs">API密钥用于身份验证,请妥善保管,不要在客户端代码中暴露它</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</Label>
|
||||||
|
<span className="text-xs text-gray-500">用于接口身份验证</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Input
|
||||||
|
id="api-key"
|
||||||
|
value={currentApiSettings.apiKey}
|
||||||
|
readOnly
|
||||||
|
className="pr-10 font-mono text-sm bg-gray-50"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-0 top-0 h-full"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(currentApiSettings.apiKey);
|
||||||
|
toast({
|
||||||
|
title: "已复制",
|
||||||
|
description: "API密钥已复制到剪贴板",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 接口地址部分 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="webhook-url" className="text-sm font-medium">
|
||||||
|
接口地址
|
||||||
|
</Label>
|
||||||
|
<button
|
||||||
|
className="text-xs text-blue-600 hover:underline flex items-center gap-1"
|
||||||
|
onClick={() => handleCopyApiUrl(currentApiSettings.webhookUrl, true)}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
复制(含示例参数)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Input
|
||||||
|
id="webhook-url"
|
||||||
|
value={currentApiSettings.webhookUrl}
|
||||||
|
readOnly
|
||||||
|
className="pr-10 font-mono text-sm bg-gray-50 text-gray-700"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-0 top-0 h-full"
|
||||||
|
onClick={() => handleCopyApiUrl(currentApiSettings.webhookUrl)}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-50 p-2 rounded-md">
|
||||||
|
<p className="text-xs text-blue-700">
|
||||||
|
<span className="font-medium">必要参数:</span>name(姓名)、phone(电话)
|
||||||
|
<br />
|
||||||
|
<span className="font-medium">可选参数:</span>source(来源)、remark(备注)、tags(标签)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 接口文档部分 */}
|
||||||
|
<div className="space-y-3 pt-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-sm font-medium">接口文档</Label>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 p-4 rounded-lg border border-gray-100">
|
||||||
|
<div className="flex flex-col space-y-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full flex items-center justify-center gap-2 bg-white"
|
||||||
|
onClick={() => {
|
||||||
|
window.open(`/api/docs/scenarios?planId=${currentApiSettings.taskId}`, "_blank");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Link className="h-4 w-4" />
|
||||||
|
查看详细接口文档与集成指南
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
window.open(`/api/docs/scenarios?planId=${currentApiSettings.taskId}#examples`, "_blank");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-blue-600">查看代码示例</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
window.open(`/api/docs/scenarios?planId=${currentApiSettings.taskId}#integration`, "_blank");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-blue-600">查看集成指南</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 快速测试部分 */}
|
||||||
|
<div className="space-y-2 pt-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-sm font-medium">快速测试</Label>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 p-3 rounded-md border border-gray-100">
|
||||||
|
<p className="text-xs text-gray-600 mb-2">使用以下URL可以快速测试接口是否正常工作:</p>
|
||||||
|
<div className="text-xs font-mono bg-white p-2 rounded border border-gray-200 overflow-x-auto">
|
||||||
|
{`${currentApiSettings.webhookUrl}?${currentApiSettings.fullUrl}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex justify-between items-center">
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
<span className="inline-flex items-center">
|
||||||
|
<Shield className="h-3 w-3 mr-1" />
|
||||||
|
接口安全加密传输
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setShowApiDialog(false)}>关闭</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
243
nkebao/src/pages/scenarios/haibao/HaibaoScenario.tsx
Normal file
243
nkebao/src/pages/scenarios/haibao/HaibaoScenario.tsx
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Plus, Copy, Trash2, Play, Pause, TrendingUp, Users, Clock, Image } from 'lucide-react';
|
||||||
|
import PageHeader from '@/components/PageHeader';
|
||||||
|
import { useToast } from '@/components/ui/toast';
|
||||||
|
|
||||||
|
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 }[];
|
||||||
|
dailyData: { date: string; acquired: number; added: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HaibaoScenario() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const channel = "haibao";
|
||||||
|
|
||||||
|
const [tasks, setTasks] = useState<Task[]>([
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
name: "海报分享获客计划",
|
||||||
|
status: "running",
|
||||||
|
stats: {
|
||||||
|
devices: 5,
|
||||||
|
acquired: 89,
|
||||||
|
added: 62,
|
||||||
|
},
|
||||||
|
lastUpdated: "2024-03-18 17:30",
|
||||||
|
executionTime: "2024-03-18 17:30",
|
||||||
|
nextExecutionTime: "预计15分钟后",
|
||||||
|
trend: Array.from({ length: 7 }, (_, i) => ({
|
||||||
|
date: `3月${String(i + 12)}日`,
|
||||||
|
customers: Math.floor(Math.random() * 20) + 10,
|
||||||
|
})),
|
||||||
|
dailyData: [
|
||||||
|
{ date: "3/12", acquired: 25, added: 18 },
|
||||||
|
{ date: "3/13", acquired: 32, added: 22 },
|
||||||
|
{ date: "3/14", acquired: 18, added: 12 },
|
||||||
|
{ date: "3/15", acquired: 28, added: 19 },
|
||||||
|
{ date: "3/16", acquired: 35, added: 24 },
|
||||||
|
{ date: "3/17", acquired: 22, added: 15 },
|
||||||
|
{ date: "3/18", acquired: 30, added: 21 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleCopyPlan = (taskId: string) => {
|
||||||
|
const taskToCopy = tasks.find((task) => task.id === taskId);
|
||||||
|
if (taskToCopy) {
|
||||||
|
const newTask = {
|
||||||
|
...taskToCopy,
|
||||||
|
id: `${Date.now()}`,
|
||||||
|
name: `${taskToCopy.name} (副本)`,
|
||||||
|
status: "paused" as const,
|
||||||
|
};
|
||||||
|
setTasks([...tasks, newTask]);
|
||||||
|
toast({
|
||||||
|
title: "计划已复制",
|
||||||
|
description: `已成功复制"${taskToCopy.name}"`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletePlan = (taskId: string) => {
|
||||||
|
const taskToDelete = tasks.find((t) => t.id === taskId);
|
||||||
|
if (taskToDelete) {
|
||||||
|
setTasks(tasks.filter((t) => t.id !== taskId));
|
||||||
|
toast({
|
||||||
|
title: "计划已删除",
|
||||||
|
description: `已成功删除"${taskToDelete.name}"`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'running':
|
||||||
|
return 'text-green-600 bg-green-50';
|
||||||
|
case 'paused':
|
||||||
|
return 'text-yellow-600 bg-yellow-50';
|
||||||
|
case 'completed':
|
||||||
|
return 'text-gray-600 bg-gray-50';
|
||||||
|
default:
|
||||||
|
return 'text-gray-600 bg-gray-50';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'running':
|
||||||
|
return '进行中';
|
||||||
|
case 'paused':
|
||||||
|
return '已暂停';
|
||||||
|
case 'completed':
|
||||||
|
return '已完成';
|
||||||
|
default:
|
||||||
|
return '未知';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'running':
|
||||||
|
return <Play className="h-4 w-4" />;
|
||||||
|
case 'paused':
|
||||||
|
return <Pause className="h-4 w-4" />;
|
||||||
|
case 'completed':
|
||||||
|
return <Clock className="h-4 w-4" />;
|
||||||
|
default:
|
||||||
|
return <Clock className="h-4 w-4" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 bg-gradient-to-b from-orange-50 to-white min-h-screen">
|
||||||
|
<PageHeader
|
||||||
|
title="海报获客"
|
||||||
|
defaultBackPath="/scenarios"
|
||||||
|
rightContent={
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/scenarios/new?scenario=haibao')}
|
||||||
|
className="flex items-center px-3 py-2 bg-orange-600 text-white rounded-md hover:bg-orange-700 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
新建计划
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="p-4 max-w-7xl mx-auto">
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<div key={task.id} className="bg-white rounded-lg shadow-sm border border-gray-100 mb-4 overflow-hidden">
|
||||||
|
{/* 任务头部 */}
|
||||||
|
<div className="p-4 border-b border-gray-100">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className={`p-2 rounded-lg ${getStatusColor(task.status)}`}>
|
||||||
|
{getStatusIcon(task.status)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-gray-900">{task.name}</h3>
|
||||||
|
<div className="flex items-center space-x-4 text-sm text-gray-500 mt-1">
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(task.status)}`}>
|
||||||
|
{getStatusText(task.status)}
|
||||||
|
</span>
|
||||||
|
<span>最后更新: {task.lastUpdated}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopyPlan(task.id)}
|
||||||
|
className="p-2 text-gray-400 hover:text-orange-600 hover:bg-orange-50 rounded-lg transition-colors"
|
||||||
|
title="复制计划"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeletePlan(task.id)}
|
||||||
|
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
title="删除计划"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 统计信息 */}
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="flex items-center justify-center text-gray-500 mb-1">
|
||||||
|
<Users className="h-4 w-4 mr-1" />
|
||||||
|
<span className="text-sm">设备数</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold text-orange-600">{task.stats.devices}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="flex items-center justify-center text-gray-500 mb-1">
|
||||||
|
<TrendingUp className="h-4 w-4 mr-1" />
|
||||||
|
<span className="text-sm">已获客</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold text-green-600">{task.stats.acquired}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="flex items-center justify-center text-gray-500 mb-1">
|
||||||
|
<Users className="h-4 w-4 mr-1" />
|
||||||
|
<span className="text-sm">已添加</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold text-purple-600">{task.stats.added}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 趋势图表 */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-2">7天趋势</h4>
|
||||||
|
<div className="flex items-end space-x-1 h-20">
|
||||||
|
{task.trend.map((item, index) => (
|
||||||
|
<div key={index} className="flex-1 flex flex-col items-center">
|
||||||
|
<div
|
||||||
|
className="w-full bg-orange-200 rounded-t"
|
||||||
|
style={{ height: `${(item.customers / 30) * 100}%` }}
|
||||||
|
></div>
|
||||||
|
<span className="text-xs text-gray-500 mt-1">{item.date}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 执行信息 */}
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-600">执行时间: {task.executionTime}</span>
|
||||||
|
<span className="text-orange-600">{task.nextExecutionTime}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 海报管理 */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-100 p-4">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-4">海报管理</h3>
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
<Image className="h-12 w-12 mx-auto mb-2 text-gray-300" />
|
||||||
|
<p>海报管理功能开发中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
243
nkebao/src/pages/scenarios/xiaohongshu/XiaohongshuScenario.tsx
Normal file
243
nkebao/src/pages/scenarios/xiaohongshu/XiaohongshuScenario.tsx
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Plus, Copy, Trash2, Play, Pause, TrendingUp, Users, Clock } from 'lucide-react';
|
||||||
|
import PageHeader from '@/components/PageHeader';
|
||||||
|
import { useToast } from '@/components/ui/toast';
|
||||||
|
|
||||||
|
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 }[];
|
||||||
|
dailyData: { date: string; acquired: number; added: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function XiaohongshuScenario() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const channel = "xiaohongshu";
|
||||||
|
|
||||||
|
const [tasks, setTasks] = useState<Task[]>([
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
name: "小红书内容营销计划",
|
||||||
|
status: "running",
|
||||||
|
stats: {
|
||||||
|
devices: 2,
|
||||||
|
acquired: 28,
|
||||||
|
added: 19,
|
||||||
|
},
|
||||||
|
lastUpdated: "2024-03-18 14:20",
|
||||||
|
executionTime: "2024-03-18 14:20",
|
||||||
|
nextExecutionTime: "预计45分钟后",
|
||||||
|
trend: Array.from({ length: 7 }, (_, i) => ({
|
||||||
|
date: `3月${String(i + 12)}日`,
|
||||||
|
customers: Math.floor(Math.random() * 8) + 3,
|
||||||
|
})),
|
||||||
|
dailyData: [
|
||||||
|
{ date: "3/12", acquired: 8, added: 5 },
|
||||||
|
{ date: "3/13", acquired: 12, added: 8 },
|
||||||
|
{ date: "3/14", acquired: 6, added: 4 },
|
||||||
|
{ date: "3/15", acquired: 9, added: 6 },
|
||||||
|
{ date: "3/16", acquired: 11, added: 7 },
|
||||||
|
{ date: "3/17", acquired: 7, added: 5 },
|
||||||
|
{ date: "3/18", acquired: 10, added: 7 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleCopyPlan = (taskId: string) => {
|
||||||
|
const taskToCopy = tasks.find((task) => task.id === taskId);
|
||||||
|
if (taskToCopy) {
|
||||||
|
const newTask = {
|
||||||
|
...taskToCopy,
|
||||||
|
id: `${Date.now()}`,
|
||||||
|
name: `${taskToCopy.name} (副本)`,
|
||||||
|
status: "paused" as const,
|
||||||
|
};
|
||||||
|
setTasks([...tasks, newTask]);
|
||||||
|
toast({
|
||||||
|
title: "计划已复制",
|
||||||
|
description: `已成功复制"${taskToCopy.name}"`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletePlan = (taskId: string) => {
|
||||||
|
const taskToDelete = tasks.find((t) => t.id === taskId);
|
||||||
|
if (taskToDelete) {
|
||||||
|
setTasks(tasks.filter((t) => t.id !== taskId));
|
||||||
|
toast({
|
||||||
|
title: "计划已删除",
|
||||||
|
description: `已成功删除"${taskToDelete.name}"`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'running':
|
||||||
|
return 'text-green-600 bg-green-50';
|
||||||
|
case 'paused':
|
||||||
|
return 'text-yellow-600 bg-yellow-50';
|
||||||
|
case 'completed':
|
||||||
|
return 'text-gray-600 bg-gray-50';
|
||||||
|
default:
|
||||||
|
return 'text-gray-600 bg-gray-50';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'running':
|
||||||
|
return '进行中';
|
||||||
|
case 'paused':
|
||||||
|
return '已暂停';
|
||||||
|
case 'completed':
|
||||||
|
return '已完成';
|
||||||
|
default:
|
||||||
|
return '未知';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'running':
|
||||||
|
return <Play className="h-4 w-4" />;
|
||||||
|
case 'paused':
|
||||||
|
return <Pause className="h-4 w-4" />;
|
||||||
|
case 'completed':
|
||||||
|
return <Clock className="h-4 w-4" />;
|
||||||
|
default:
|
||||||
|
return <Clock className="h-4 w-4" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 bg-gradient-to-b from-pink-50 to-white min-h-screen">
|
||||||
|
<PageHeader
|
||||||
|
title="小红书获客"
|
||||||
|
defaultBackPath="/scenarios"
|
||||||
|
rightContent={
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/scenarios/new?scenario=xiaohongshu')}
|
||||||
|
className="flex items-center px-3 py-2 bg-pink-600 text-white rounded-md hover:bg-pink-700 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
新建计划
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="p-4 max-w-7xl mx-auto">
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<div key={task.id} className="bg-white rounded-lg shadow-sm border border-gray-100 mb-4 overflow-hidden">
|
||||||
|
{/* 任务头部 */}
|
||||||
|
<div className="p-4 border-b border-gray-100">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className={`p-2 rounded-lg ${getStatusColor(task.status)}`}>
|
||||||
|
{getStatusIcon(task.status)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-gray-900">{task.name}</h3>
|
||||||
|
<div className="flex items-center space-x-4 text-sm text-gray-500 mt-1">
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(task.status)}`}>
|
||||||
|
{getStatusText(task.status)}
|
||||||
|
</span>
|
||||||
|
<span>最后更新: {task.lastUpdated}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopyPlan(task.id)}
|
||||||
|
className="p-2 text-gray-400 hover:text-pink-600 hover:bg-pink-50 rounded-lg transition-colors"
|
||||||
|
title="复制计划"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeletePlan(task.id)}
|
||||||
|
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
title="删除计划"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 统计信息 */}
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="flex items-center justify-center text-gray-500 mb-1">
|
||||||
|
<Users className="h-4 w-4 mr-1" />
|
||||||
|
<span className="text-sm">设备数</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold text-pink-600">{task.stats.devices}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="flex items-center justify-center text-gray-500 mb-1">
|
||||||
|
<TrendingUp className="h-4 w-4 mr-1" />
|
||||||
|
<span className="text-sm">已获客</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold text-green-600">{task.stats.acquired}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="flex items-center justify-center text-gray-500 mb-1">
|
||||||
|
<Users className="h-4 w-4 mr-1" />
|
||||||
|
<span className="text-sm">已添加</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold text-purple-600">{task.stats.added}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 趋势图表 */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-2">7天趋势</h4>
|
||||||
|
<div className="flex items-end space-x-1 h-20">
|
||||||
|
{task.trend.map((item, index) => (
|
||||||
|
<div key={index} className="flex-1 flex flex-col items-center">
|
||||||
|
<div
|
||||||
|
className="w-full bg-pink-200 rounded-t"
|
||||||
|
style={{ height: `${(item.customers / 12) * 100}%` }}
|
||||||
|
></div>
|
||||||
|
<span className="text-xs text-gray-500 mt-1">{item.date}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 执行信息 */}
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-600">执行时间: {task.executionTime}</span>
|
||||||
|
<span className="text-pink-600">{task.nextExecutionTime}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 内容分析 */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-100 p-4">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-4">内容分析</h3>
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
<TrendingUp className="h-12 w-12 mx-auto mb-2 text-gray-300" />
|
||||||
|
<p>内容分析功能开发中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user