feat: 缓存数据
This commit is contained in:
@@ -28,7 +28,7 @@ export const BackButton: React.FC<BackButtonProps> = ({
|
||||
onBack,
|
||||
text = '返回',
|
||||
className = '',
|
||||
iconSize = 20,
|
||||
iconSize = 6,
|
||||
showIcon = true,
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user