feat: 本次提交更新内容如下
定版本转移2025年7月17日
This commit is contained in:
@@ -1,92 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ChevronLeft, ArrowLeft } from 'lucide-react';
|
||||
|
||||
interface BackButtonProps {
|
||||
/** 返回按钮的样式变体 */
|
||||
variant?: 'icon' | 'button' | 'text';
|
||||
/** 自定义返回逻辑,如果不提供则使用navigate(-1) */
|
||||
onBack?: () => void;
|
||||
/** 按钮文本,仅在button和text变体时使用 */
|
||||
text?: string;
|
||||
/** 自定义CSS类名 */
|
||||
className?: string;
|
||||
/** 图标大小 */
|
||||
iconSize?: number;
|
||||
/** 是否显示图标 */
|
||||
showIcon?: boolean;
|
||||
/** 自定义图标 */
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用返回上一页按钮组件
|
||||
* 使用React Router的navigate方法实现返回功能
|
||||
*/
|
||||
export const BackButton: React.FC<BackButtonProps> = ({
|
||||
variant = 'icon',
|
||||
onBack,
|
||||
text = '返回',
|
||||
className = '',
|
||||
iconSize = 6,
|
||||
showIcon = true,
|
||||
icon
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleBack = () => {
|
||||
if (onBack) {
|
||||
onBack();
|
||||
} else {
|
||||
navigate(-1);
|
||||
}
|
||||
};
|
||||
|
||||
const defaultIcon = variant === 'icon' ? (
|
||||
<ChevronLeft className={`h-${iconSize} w-${iconSize}`} />
|
||||
) : (
|
||||
<ArrowLeft className={`h-${iconSize} w-${iconSize}`} />
|
||||
);
|
||||
|
||||
const buttonIcon = icon || (showIcon ? defaultIcon : null);
|
||||
|
||||
switch (variant) {
|
||||
case 'icon':
|
||||
return (
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className={`p-2 hover:bg-gray-100 rounded-lg transition-colors ${className}`}
|
||||
title="返回上一页"
|
||||
>
|
||||
{buttonIcon}
|
||||
</button>
|
||||
);
|
||||
|
||||
case 'button':
|
||||
return (
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className={`flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors ${className}`}
|
||||
>
|
||||
{buttonIcon}
|
||||
{text}
|
||||
</button>
|
||||
);
|
||||
|
||||
case 'text':
|
||||
return (
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className={`flex items-center gap-2 text-blue-600 hover:text-blue-700 transition-colors ${className}`}
|
||||
>
|
||||
{buttonIcon}
|
||||
{text}
|
||||
</button>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default BackButton;
|
||||
@@ -1,66 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Home, Users, LayoutGrid, User } from 'lucide-react';
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
id: "home",
|
||||
name: "首页",
|
||||
href: "/",
|
||||
icon: Home,
|
||||
active: (pathname: string) => pathname === "/",
|
||||
},
|
||||
{
|
||||
id: "scenarios",
|
||||
name: "场景获客",
|
||||
href: "/scenarios",
|
||||
icon: Users,
|
||||
active: (pathname: string) => pathname.startsWith("/scenarios"),
|
||||
},
|
||||
{
|
||||
id: "workspace",
|
||||
name: "工作台",
|
||||
href: "/workspace",
|
||||
icon: LayoutGrid,
|
||||
active: (pathname: string) => pathname.startsWith("/workspace"),
|
||||
},
|
||||
{
|
||||
id: "profile",
|
||||
name: "我的",
|
||||
href: "/profile",
|
||||
icon: User,
|
||||
active: (pathname: string) => pathname.startsWith("/profile"),
|
||||
},
|
||||
];
|
||||
|
||||
interface BottomNavProps {
|
||||
activeTab?: string;
|
||||
}
|
||||
|
||||
export default function BottomNav({ activeTab }: BottomNavProps) {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-gray-200 safe-area-pb">
|
||||
<div className="flex justify-around items-center h-16 max-w-md mx-auto">
|
||||
{navItems.map((item) => {
|
||||
const IconComponent = item.icon;
|
||||
const isActive = activeTab ? activeTab === item.id : item.active(location.pathname);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
to={item.href}
|
||||
className={`flex flex-col items-center justify-center flex-1 h-full transition-colors ${
|
||||
isActive ? "text-blue-500" : "text-gray-500 hover:text-gray-900"
|
||||
}`}
|
||||
>
|
||||
<IconComponent className="w-5 h-5" />
|
||||
<span className="text-xs mt-1">{item.name}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Search, RefreshCw, Loader2 } from "lucide-react";
|
||||
import { fetchContentLibraryList } from "@/api/content";
|
||||
import { ContentLibrary } from "@/api/content";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
|
||||
interface ContentLibrarySelectionDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
selectedLibraries: string[];
|
||||
onSelect: (libraries: string[]) => void;
|
||||
}
|
||||
|
||||
export function ContentLibrarySelectionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
selectedLibraries,
|
||||
onSelect,
|
||||
}: ContentLibrarySelectionDialogProps) {
|
||||
const { toast } = useToast();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [libraries, setLibraries] = useState<ContentLibrary[]>([]);
|
||||
const [tempSelected, setTempSelected] = useState<string[]>([]);
|
||||
|
||||
// 获取内容库列表
|
||||
const fetchLibraries = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetchContentLibraryList(1, 100, searchQuery);
|
||||
if (response.code === 200 && response.data) {
|
||||
setLibraries(response.data.list);
|
||||
} else {
|
||||
toast({
|
||||
title: "获取内容库列表失败",
|
||||
description: response.msg,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取内容库列表失败:", error);
|
||||
toast({
|
||||
title: "获取内容库列表失败",
|
||||
description: "请检查网络连接",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchQuery, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchLibraries();
|
||||
setTempSelected(selectedLibraries);
|
||||
}
|
||||
}, [open, selectedLibraries, fetchLibraries]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchLibraries();
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (tempSelected.length === libraries.length) {
|
||||
setTempSelected([]);
|
||||
} else {
|
||||
setTempSelected(libraries.map((lib) => lib.id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleLibraryToggle = (libraryId: string) => {
|
||||
setTempSelected((prev) =>
|
||||
prev.includes(libraryId)
|
||||
? prev.filter((id) => id !== libraryId)
|
||||
: [...prev, libraryId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleDialogOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
setTempSelected(selectedLibraries);
|
||||
}
|
||||
onOpenChange(open);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
onSelect(tempSelected);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
|
||||
<DialogContent className="flex flex-col bg-white">
|
||||
<DialogHeader>
|
||||
<DialogTitle>选择内容库</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center space-x-2 my-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索内容库"
|
||||
className="pl-9"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<div className="text-sm text-gray-500">
|
||||
已选择 {tempSelected.length} 个内容库
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSelectAll}
|
||||
disabled={loading || libraries.length === 0}
|
||||
>
|
||||
{tempSelected.length === libraries.length ? "取消全选" : "全选"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto -mx-6 px-6 max-h-[400px]">
|
||||
<div className="space-y-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
加载中...
|
||||
</div>
|
||||
) : libraries.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
暂无数据
|
||||
</div>
|
||||
) : (
|
||||
libraries.map((library) => (
|
||||
<label
|
||||
key={library.id}
|
||||
className="flex items-start space-x-3 p-4 rounded-lg hover:bg-gray-50 cursor-pointer border"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={tempSelected.includes(library.id)}
|
||||
onChange={() => handleLibraryToggle(library.id)}
|
||||
className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 focus:ring-2"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{library.name}</span>
|
||||
<Badge variant="outline">
|
||||
{library.sourceType === 1
|
||||
? "文本"
|
||||
: library.sourceType === 2
|
||||
? "图片"
|
||||
: "视频"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
<div>创建人: {library.creatorName || "-"}</div>
|
||||
<div>
|
||||
更新时间:{" "}
|
||||
{new Date(library.updateTime).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mt-4 pt-4 border-t">
|
||||
<div className="text-sm text-gray-500">
|
||||
已选择 {tempSelected.length} 个内容库
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleConfirm}>
|
||||
确定{tempSelected.length > 0 ? ` (${tempSelected.length})` : ""}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { fetchDeviceList } from "@/api/devices";
|
||||
|
||||
// 设备选择项接口
|
||||
interface DeviceSelectionItem {
|
||||
id: string;
|
||||
name: string;
|
||||
imei: string;
|
||||
wechatId: string;
|
||||
status: "online" | "offline";
|
||||
}
|
||||
|
||||
// 组件属性接口
|
||||
interface DeviceSelectionProps {
|
||||
selectedDevices: string[];
|
||||
onSelect: (devices: string[]) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function DeviceSelection({
|
||||
selectedDevices,
|
||||
onSelect,
|
||||
placeholder = "选择设备",
|
||||
className = "",
|
||||
}: DeviceSelectionProps) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [devices, setDevices] = useState<DeviceSelectionItem[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 获取设备列表,支持keyword
|
||||
const fetchDevices = async (keyword: string = "") => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetchDeviceList(1, 100, keyword.trim() || undefined);
|
||||
if (res && res.data && Array.isArray(res.data.list)) {
|
||||
setDevices(
|
||||
res.data.list.map((d) => ({
|
||||
id: d.id?.toString() || "",
|
||||
name: d.memo || d.imei || "",
|
||||
imei: d.imei || "",
|
||||
wechatId: d.wechatId || "",
|
||||
status: d.alive === 1 ? "online" : "offline",
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取设备列表失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 打开弹窗时获取设备列表
|
||||
const openDialog = () => {
|
||||
setSearchQuery("");
|
||||
setDialogOpen(true);
|
||||
fetchDevices("");
|
||||
};
|
||||
|
||||
// 搜索防抖
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) return;
|
||||
const timer = setTimeout(() => {
|
||||
fetchDevices(searchQuery);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, dialogOpen]);
|
||||
|
||||
// 过滤设备(只保留状态过滤)
|
||||
const filteredDevices = devices.filter((device) => {
|
||||
const matchesStatus =
|
||||
statusFilter === "all" ||
|
||||
(statusFilter === "online" && device.status === "online") ||
|
||||
(statusFilter === "offline" && device.status === "offline");
|
||||
return matchesStatus;
|
||||
});
|
||||
|
||||
// 处理设备选择
|
||||
const handleDeviceToggle = (deviceId: string) => {
|
||||
if (selectedDevices.includes(deviceId)) {
|
||||
onSelect(selectedDevices.filter((id) => id !== deviceId));
|
||||
} else {
|
||||
onSelect([...selectedDevices, deviceId]);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取显示文本
|
||||
const getDisplayText = () => {
|
||||
if (selectedDevices.length === 0) return "";
|
||||
return `已选择 ${selectedDevices.length} 个设备`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 输入框 */}
|
||||
<div className={`relative ${className}`}>
|
||||
<Search className="absolute left-3 top-4 h-5 w-5 text-gray-400" />
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
className="pl-10 h-14 rounded-xl border-gray-200 text-base"
|
||||
readOnly
|
||||
onClick={openDialog}
|
||||
value={getDisplayText()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 设备选择弹窗 */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col bg-white">
|
||||
<DialogHeader>
|
||||
<DialogTitle>选择设备</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center space-x-4 my-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索设备IMEI/备注/微信号"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="w-32 h-10 rounded border border-gray-300 px-2 text-base"
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="online">在线</option>
|
||||
<option value="offline">离线</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 -mx-6 px-6 h-80 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredDevices.map((device) => (
|
||||
<label
|
||||
key={device.id}
|
||||
className="flex items-start space-x-3 p-4 rounded-lg hover:bg-gray-50 cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedDevices.includes(device.id)}
|
||||
onCheckedChange={() => handleDeviceToggle(device.id)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{device.name}</span>
|
||||
<div
|
||||
className={`w-16 h-6 flex items-center justify-center text-xs ${
|
||||
device.status === "online"
|
||||
? "bg-green-500 text-white"
|
||||
: "bg-gray-200 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{device.status === "online" ? "在线" : "离线"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
<div>IMEI: {device.imei}</div>
|
||||
<div>微信号: {device.wechatId}</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<div className="flex items-center justify-between pt-4 border-t">
|
||||
<div className="text-sm text-gray-500">
|
||||
已选择 {selectedDevices.length} 个设备
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={() => setDialogOpen(false)}>确定</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Search, RefreshCw, Loader2 } from "lucide-react";
|
||||
import { fetchDeviceList } from "@/api/devices";
|
||||
import { ServerDevice } from "@/types/device";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
|
||||
interface Device {
|
||||
id: string;
|
||||
name: string;
|
||||
imei: string;
|
||||
wxid: string;
|
||||
status: "online" | "offline";
|
||||
usedInPlans: number;
|
||||
nickname: string;
|
||||
}
|
||||
|
||||
interface DeviceSelectionDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
selectedDevices: string[];
|
||||
onSelect: (devices: string[]) => void;
|
||||
}
|
||||
|
||||
export function DeviceSelectionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
selectedDevices,
|
||||
onSelect,
|
||||
}: DeviceSelectionDialogProps) {
|
||||
const { toast } = useToast();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [devices, setDevices] = useState<Device[]>([]);
|
||||
|
||||
// 获取设备列表,支持keyword
|
||||
const fetchDevices = useCallback(
|
||||
async (keyword: string = "") => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetchDeviceList(
|
||||
1,
|
||||
100,
|
||||
keyword.trim() || undefined
|
||||
);
|
||||
if (response.code === 200 && response.data) {
|
||||
// 转换服务端数据格式为组件需要的格式
|
||||
const convertedDevices: Device[] = response.data.list.map(
|
||||
(serverDevice: ServerDevice) => ({
|
||||
id: serverDevice.id.toString(),
|
||||
name: serverDevice.memo || `设备 ${serverDevice.id}`,
|
||||
imei: serverDevice.imei,
|
||||
wxid: serverDevice.wechatId || "",
|
||||
status: serverDevice.alive === 1 ? "online" : "offline",
|
||||
usedInPlans: 0, // 这个字段需要从其他API获取
|
||||
nickname: serverDevice.nickname || "",
|
||||
})
|
||||
);
|
||||
setDevices(convertedDevices);
|
||||
} else {
|
||||
toast({
|
||||
title: "获取设备列表失败",
|
||||
description: response.msg,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取设备列表失败:", error);
|
||||
toast({
|
||||
title: "获取设备列表失败",
|
||||
description: "请检查网络连接",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[toast]
|
||||
);
|
||||
|
||||
// 打开弹窗时获取设备列表
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchDevices("");
|
||||
}
|
||||
}, [open, fetchDevices]);
|
||||
|
||||
// 搜索防抖
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const timer = setTimeout(() => {
|
||||
fetchDevices(searchQuery);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, open, fetchDevices]);
|
||||
|
||||
// 过滤设备(只保留状态过滤)
|
||||
const filteredDevices = devices.filter((device) => {
|
||||
const matchesStatus =
|
||||
statusFilter === "all" ||
|
||||
(statusFilter === "online" && device.status === "online") ||
|
||||
(statusFilter === "offline" && device.status === "offline");
|
||||
return matchesStatus;
|
||||
});
|
||||
|
||||
const handleDeviceSelect = (deviceId: string) => {
|
||||
if (selectedDevices.includes(deviceId)) {
|
||||
onSelect(selectedDevices.filter((id) => id !== deviceId));
|
||||
} else {
|
||||
onSelect([...selectedDevices, deviceId]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex flex-col bg-white">
|
||||
<DialogHeader>
|
||||
<DialogTitle>选择设备</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center space-x-4 my-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索设备IMEI/备注"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="w-32 px-3 py-2 border border-gray-300 rounded-md text-sm"
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="online">在线</option>
|
||||
<option value="offline">离线</option>
|
||||
</select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => fetchDevices(searchQuery)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto -mx-6 px-6 max-h-[400px]">
|
||||
<div className="space-y-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
加载中...
|
||||
</div>
|
||||
) : filteredDevices.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
暂无数据
|
||||
</div>
|
||||
) : (
|
||||
filteredDevices.map((device) => (
|
||||
<label
|
||||
key={device.id}
|
||||
className="flex items-start space-x-3 p-4 rounded-lg hover:bg-gray-50 cursor-pointer border"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDevices.includes(device.id)}
|
||||
onChange={() => handleDeviceSelect(device.id)}
|
||||
className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 focus:ring-2"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{device.name}</span>
|
||||
<Badge
|
||||
variant={
|
||||
device.status === "online" ? "default" : "secondary"
|
||||
}
|
||||
>
|
||||
{device.status === "online" ? "在线" : "离线"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
<div>IMEI: {device.imei}</div>
|
||||
<div>微信号: {device.wxid || "-"}</div>
|
||||
<div>昵称: {device.nickname || "-"}</div>
|
||||
</div>
|
||||
{device.usedInPlans > 0 && (
|
||||
<div className="text-sm text-orange-500 mt-1">
|
||||
已用于 {device.usedInPlans} 个计划
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mt-4 pt-4 border-t">
|
||||
<div className="text-sm text-gray-500">
|
||||
已选择 {selectedDevices.length} 个设备
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={() => onOpenChange(false)}>
|
||||
确定
|
||||
{selectedDevices.length > 0 ? ` (${selectedDevices.length})` : ""}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,375 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { get } from "@/api/request";
|
||||
|
||||
// 微信好友接口类型
|
||||
interface WechatFriend {
|
||||
id: string;
|
||||
nickname: string;
|
||||
wechatId: string;
|
||||
avatar: string;
|
||||
customer: string;
|
||||
}
|
||||
|
||||
// 好友列表API响应类型
|
||||
interface FriendsResponse {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: {
|
||||
list: Array<{
|
||||
id: number;
|
||||
nickname: string;
|
||||
wechatId: string;
|
||||
avatar?: string;
|
||||
customer?: string;
|
||||
}>;
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
};
|
||||
}
|
||||
|
||||
// 获取好友列表API函数 - 添加 keyword 参数
|
||||
const fetchFriendsList = async (params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
deviceIds?: string[];
|
||||
keyword?: string;
|
||||
}): Promise<FriendsResponse> => {
|
||||
if (params.deviceIds && params.deviceIds.length === 0) {
|
||||
return {
|
||||
code: 200,
|
||||
msg: "success",
|
||||
data: {
|
||||
list: [],
|
||||
total: 0,
|
||||
page: params.page,
|
||||
limit: params.limit,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const deviceIdsParam = params?.deviceIds?.join(",") || "";
|
||||
const keywordParam = params?.keyword
|
||||
? `&keyword=${encodeURIComponent(params.keyword)}`
|
||||
: "";
|
||||
|
||||
return get<FriendsResponse>(
|
||||
`/v1/friend?page=${params.page}&limit=${params.limit}&deviceIds=${deviceIdsParam}${keywordParam}`
|
||||
);
|
||||
};
|
||||
|
||||
// 组件属性接口
|
||||
interface FriendSelectionProps {
|
||||
selectedFriends: string[];
|
||||
onSelect: (friends: string[]) => void;
|
||||
onSelectDetail?: (friends: WechatFriend[]) => void; // 新增
|
||||
deviceIds?: string[];
|
||||
enableDeviceFilter?: boolean; // 新增开关,默认true
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function FriendSelection({
|
||||
selectedFriends,
|
||||
onSelect,
|
||||
onSelectDetail,
|
||||
deviceIds = [],
|
||||
enableDeviceFilter = true,
|
||||
placeholder = "选择微信好友",
|
||||
className = "",
|
||||
}: FriendSelectionProps) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [friends, setFriends] = useState<WechatFriend[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalFriends, setTotalFriends] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 打开弹窗并请求第一页好友
|
||||
const openDialog = () => {
|
||||
setCurrentPage(1);
|
||||
setSearchQuery(""); // 重置搜索关键词
|
||||
setDialogOpen(true);
|
||||
fetchFriends(1, "");
|
||||
};
|
||||
|
||||
// 当页码变化时,拉取对应页数据(弹窗已打开时)
|
||||
useEffect(() => {
|
||||
if (dialogOpen && currentPage !== 1) {
|
||||
fetchFriends(currentPage, searchQuery);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentPage]);
|
||||
|
||||
// 搜索防抖
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setCurrentPage(1); // 重置到第一页
|
||||
fetchFriends(1, searchQuery);
|
||||
}, 500); // 500 防抖
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, dialogOpen]);
|
||||
|
||||
// 获取好友列表API - 添加 keyword 参数
|
||||
const fetchFriends = async (page: number, keyword: string = "") => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let res;
|
||||
if (enableDeviceFilter) {
|
||||
if (deviceIds.length === 0) {
|
||||
setFriends([]);
|
||||
setTotalFriends(0);
|
||||
setTotalPages(1);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
res = await fetchFriendsList({
|
||||
page,
|
||||
limit: 20,
|
||||
deviceIds: deviceIds,
|
||||
keyword: keyword.trim() || undefined,
|
||||
});
|
||||
} else {
|
||||
res = await fetchFriendsList({
|
||||
page,
|
||||
limit: 20,
|
||||
keyword: keyword.trim() || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (res && res.code === 200 && res.data) {
|
||||
setFriends(
|
||||
res.data.list.map((friend) => ({
|
||||
id: friend.id?.toString() || "",
|
||||
nickname: friend.nickname || "",
|
||||
wechatId: friend.wechatId || "",
|
||||
avatar: friend.avatar || "",
|
||||
customer: friend.customer || "",
|
||||
}))
|
||||
);
|
||||
setTotalFriends(res.data.total || 0);
|
||||
setTotalPages(Math.ceil((res.data.total || 0) / 20));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取好友列表失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理好友选择
|
||||
const handleFriendToggle = (friendId: string) => {
|
||||
let newIds: string[];
|
||||
if (selectedFriends.includes(friendId)) {
|
||||
newIds = selectedFriends.filter((id) => id !== friendId);
|
||||
} else {
|
||||
newIds = [...selectedFriends, friendId];
|
||||
}
|
||||
onSelect(newIds);
|
||||
if (onSelectDetail) {
|
||||
const selectedObjs = friends.filter((f) => newIds.includes(f.id));
|
||||
onSelectDetail(selectedObjs);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取显示文本
|
||||
const getDisplayText = () => {
|
||||
if (selectedFriends.length === 0) return "";
|
||||
return `已选择 ${selectedFriends.length} 个好友`;
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
// 清空搜索
|
||||
const handleClearSearch = () => {
|
||||
setSearchQuery("");
|
||||
setCurrentPage(1);
|
||||
fetchFriends(1, "");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 输入框 */}
|
||||
<div className={`relative ${className}`}>
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
className="pl-10 h-12 rounded-xl border-gray-200 text-base"
|
||||
readOnly
|
||||
onClick={openDialog}
|
||||
value={getDisplayText()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 微信好友选择弹窗 */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-xl max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden bg-white">
|
||||
<div className="p-6">
|
||||
<DialogTitle className="text-center text-xl font-medium mb-6">
|
||||
选择微信好友
|
||||
</DialogTitle>
|
||||
|
||||
<div className="relative mb-4">
|
||||
<Input
|
||||
placeholder="搜索好友"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 py-2 rounded-full border-gray-200"
|
||||
/>
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 h-6 w-6 rounded-full"
|
||||
onClick={handleClearSearch}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto h-[50vh]">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
) : friends.length > 0 ? (
|
||||
<div className="divide-y">
|
||||
{friends.map((friend) => (
|
||||
<label
|
||||
key={friend.id}
|
||||
className="flex items-center px-6 py-4 hover:bg-gray-50 cursor-pointer"
|
||||
onClick={() => handleFriendToggle(friend.id)}
|
||||
>
|
||||
<div className="mr-3 flex items-center justify-center">
|
||||
<div
|
||||
className={`w-5 h-5 rounded-full border ${
|
||||
selectedFriends.includes(friend.id)
|
||||
? "border-blue-600"
|
||||
: "border-gray-300"
|
||||
} flex items-center justify-center`}
|
||||
>
|
||||
{selectedFriends.includes(friend.id) && (
|
||||
<div className="w-3 h-3 rounded-full bg-blue-600"></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-blue-400 to-purple-500 flex items-center justify-center text-white text-sm font-medium overflow-hidden">
|
||||
{friend.avatar ? (
|
||||
<img
|
||||
src={friend.avatar}
|
||||
alt={friend.nickname}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
friend.nickname.charAt(0)
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{friend.nickname}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
微信ID: {friend.wechatId}
|
||||
</div>
|
||||
{friend.customer && (
|
||||
<div className="text-sm text-gray-400">
|
||||
归属客户: {friend.customer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-gray-500">
|
||||
{deviceIds.length === 0
|
||||
? "请先选择设备"
|
||||
: searchQuery
|
||||
? `没有找到包含"${searchQuery}"的好友`
|
||||
: "没有找到好友"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t p-4 flex items-center justify-between bg-white">
|
||||
<div className="text-sm text-gray-500">
|
||||
总计 {totalFriends} 个好友
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1 || loading}
|
||||
className="px-2 py-0 h-8 min-w-0"
|
||||
>
|
||||
<
|
||||
</Button>
|
||||
<span className="text-sm">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setCurrentPage(Math.min(totalPages, currentPage + 1))
|
||||
}
|
||||
disabled={currentPage === totalPages || loading}
|
||||
className="px-2 py-0 h-8 min-w-0"
|
||||
>
|
||||
>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t p-4 flex items-center justify-between bg-white">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
className="px-6 rounded-full border-gray-300"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
className="px-6 bg-blue-600 hover:bg-blue-700 rounded-full"
|
||||
>
|
||||
确定 ({selectedFriends.length})
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { get } from "@/api/request";
|
||||
|
||||
// 群组接口类型
|
||||
interface WechatGroup {
|
||||
id: string;
|
||||
chatroomId: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
ownerWechatId: string;
|
||||
ownerNickname: string;
|
||||
ownerAvatar: string;
|
||||
}
|
||||
|
||||
interface GroupsResponse {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: {
|
||||
list: Array<{
|
||||
id: number;
|
||||
chatroomId: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
ownerWechatId?: string;
|
||||
ownerNickname?: string;
|
||||
ownerAvatar?: string;
|
||||
}>;
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
};
|
||||
}
|
||||
|
||||
// 修改:支持keyword参数
|
||||
const fetchGroupsList = async (params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
keyword?: string;
|
||||
}): Promise<GroupsResponse> => {
|
||||
const keywordParam = params.keyword
|
||||
? `&keyword=${encodeURIComponent(params.keyword)}`
|
||||
: "";
|
||||
return get<GroupsResponse>(
|
||||
`/v1/chatroom?page=${params.page}&limit=${params.limit}${keywordParam}`
|
||||
);
|
||||
};
|
||||
|
||||
interface GroupSelectionProps {
|
||||
selectedGroups: string[];
|
||||
onSelect: (groups: string[]) => void;
|
||||
onSelectDetail?: (groups: WechatGroup[]) => void; // 新增
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function GroupSelection({
|
||||
selectedGroups,
|
||||
onSelect,
|
||||
onSelectDetail,
|
||||
placeholder = "选择群聊",
|
||||
className = "",
|
||||
}: GroupSelectionProps) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [groups, setGroups] = useState<WechatGroup[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalGroups, setTotalGroups] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 打开弹窗并请求第一页群组
|
||||
const openDialog = () => {
|
||||
setCurrentPage(1);
|
||||
setSearchQuery(""); // 重置搜索关键词
|
||||
setDialogOpen(true);
|
||||
fetchGroups(1, "");
|
||||
};
|
||||
|
||||
// 当页码变化时,拉取对应页数据(弹窗已打开时)
|
||||
useEffect(() => {
|
||||
if (dialogOpen && currentPage !== 1) {
|
||||
fetchGroups(currentPage, searchQuery);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentPage]);
|
||||
|
||||
// 搜索防抖
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) return;
|
||||
const timer = setTimeout(() => {
|
||||
setCurrentPage(1);
|
||||
fetchGroups(1, searchQuery);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, dialogOpen]);
|
||||
|
||||
// 获取群组列表API - 支持keyword
|
||||
const fetchGroups = async (page: number, keyword: string = "") => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetchGroupsList({
|
||||
page,
|
||||
limit: 20,
|
||||
keyword: keyword.trim() || undefined,
|
||||
});
|
||||
if (res && res.code === 200 && res.data) {
|
||||
setGroups(
|
||||
res.data.list.map((group) => ({
|
||||
id: group.id?.toString() || "",
|
||||
chatroomId: group.chatroomId || "",
|
||||
name: group.name || "",
|
||||
avatar: group.avatar || "",
|
||||
ownerWechatId: group.ownerWechatId || "",
|
||||
ownerNickname: group.ownerNickname || "",
|
||||
ownerAvatar: group.ownerAvatar || "",
|
||||
}))
|
||||
);
|
||||
setTotalGroups(res.data.total || 0);
|
||||
setTotalPages(Math.ceil((res.data.total || 0) / 20));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取群组列表失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理群组选择
|
||||
const handleGroupToggle = (groupId: string) => {
|
||||
let newIds: string[];
|
||||
if (selectedGroups.includes(groupId)) {
|
||||
newIds = selectedGroups.filter((id) => id !== groupId);
|
||||
} else {
|
||||
newIds = [...selectedGroups, groupId];
|
||||
}
|
||||
onSelect(newIds);
|
||||
if (onSelectDetail) {
|
||||
const selectedObjs = groups.filter((g) => newIds.includes(g.id));
|
||||
onSelectDetail(selectedObjs);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取显示文本
|
||||
const getDisplayText = () => {
|
||||
if (selectedGroups.length === 0) return "";
|
||||
return `已选择 ${selectedGroups.length} 个群聊`;
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
// 清空搜索
|
||||
const handleClearSearch = () => {
|
||||
setSearchQuery("");
|
||||
setCurrentPage(1);
|
||||
fetchGroups(1, "");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 输入框 */}
|
||||
<div className={`relative ${className}`}>
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
className="pl-10 h-12 rounded-xl border-gray-200 text-base"
|
||||
readOnly
|
||||
onClick={openDialog}
|
||||
value={getDisplayText()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 群组选择弹窗 */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-xl max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden bg-white">
|
||||
<div className="p-6">
|
||||
<DialogTitle className="text-center text-xl font-medium mb-6">
|
||||
选择群聊
|
||||
</DialogTitle>
|
||||
<div className="relative mb-4">
|
||||
<Input
|
||||
placeholder="搜索群聊"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 py-2 rounded-full border-gray-200"
|
||||
/>
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 h-6 w-6 rounded-full"
|
||||
onClick={handleClearSearch}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto h-[50vh]">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
) : groups.length > 0 ? (
|
||||
<div className="divide-y">
|
||||
{groups.map((group) => (
|
||||
<label
|
||||
key={group.id}
|
||||
className="flex items-center px-6 py-4 hover:bg-gray-50 cursor-pointer"
|
||||
onClick={() => handleGroupToggle(group.id)}
|
||||
>
|
||||
<div className="mr-3 flex items-center justify-center">
|
||||
<div
|
||||
className={`w-5 h-5 rounded-full border ${
|
||||
selectedGroups.includes(group.id)
|
||||
? "border-blue-600"
|
||||
: "border-gray-300"
|
||||
} flex items-center justify-center`}
|
||||
>
|
||||
{selectedGroups.includes(group.id) && (
|
||||
<div className="w-3 h-3 rounded-full bg-blue-600"></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-blue-400 to-purple-500 flex items-center justify-center text-white text-sm font-medium overflow-hidden">
|
||||
{group.avatar ? (
|
||||
<img
|
||||
src={group.avatar}
|
||||
alt={group.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
group.name.charAt(0)
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{group.name}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
群ID: {group.chatroomId}
|
||||
</div>
|
||||
{group.ownerNickname && (
|
||||
<div className="text-sm text-gray-400">
|
||||
群主: {group.ownerNickname}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-gray-500">
|
||||
{searchQuery
|
||||
? `没有找到包含"${searchQuery}"的群聊`
|
||||
: "没有找到群聊"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t p-4 flex items-center justify-between bg-white">
|
||||
<div className="text-sm text-gray-500">
|
||||
总计 {totalGroups} 个群聊
|
||||
{searchQuery && ` (搜索: "${searchQuery}")`}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1 || loading}
|
||||
className="px-2 py-0 h-8 min-w-0"
|
||||
>
|
||||
<
|
||||
</Button>
|
||||
<span className="text-sm">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setCurrentPage(Math.min(totalPages, currentPage + 1))
|
||||
}
|
||||
disabled={currentPage === totalPages || loading}
|
||||
className="px-2 py-0 h-8 min-w-0"
|
||||
>
|
||||
>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t p-4 flex items-center justify-between bg-white">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
className="px-6 rounded-full border-gray-300"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
className="px-6 bg-blue-600 hover:bg-blue-700 rounded-full"
|
||||
>
|
||||
确定 ({selectedGroups.length})
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
.container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.container main {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
interface LayoutProps {
|
||||
loading?: boolean;
|
||||
children?: React.ReactNode;
|
||||
header?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Layout: React.FC<LayoutProps> = ({
|
||||
loading,
|
||||
children,
|
||||
header,
|
||||
footer,
|
||||
}) => {
|
||||
return (
|
||||
<div className="container">
|
||||
{header && <header>{header}</header>}
|
||||
<main className="bg-gray-50">{children}</main>
|
||||
{footer && <footer>{footer}</footer>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@@ -1,43 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import BottomNav from './BottomNav';
|
||||
|
||||
// 配置需要底部导航的页面路径(白名单)
|
||||
const BOTTOM_NAV_CONFIG = [
|
||||
'/', // 首页
|
||||
'/scenarios', // 场景获客
|
||||
'/workspace', // 工作台
|
||||
'/profile', // 我的
|
||||
];
|
||||
|
||||
interface LayoutWrapperProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function LayoutWrapper({ children }: LayoutWrapperProps) {
|
||||
const location = useLocation();
|
||||
|
||||
// 检查当前路径是否需要底部导航
|
||||
const shouldShowBottomNav = BOTTOM_NAV_CONFIG.some(path => {
|
||||
// 特殊处理首页路由 '/'
|
||||
if (path === '/') {
|
||||
return location.pathname === '/';
|
||||
}
|
||||
return location.pathname === path;
|
||||
});
|
||||
|
||||
// 如果是登录页面,直接渲染内容(不显示底部导航)
|
||||
if (location.pathname === '/login') {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// 只有在配置列表中的页面才显示底部导航
|
||||
return (
|
||||
<div className="flex flex-col h-screen">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{children}
|
||||
</div>
|
||||
{shouldShowBottomNav && <BottomNav />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import React from 'react';
|
||||
import BackButton from './BackButton';
|
||||
import { useSimpleBack } from '@/hooks/useBackNavigation';
|
||||
|
||||
interface PageHeaderProps {
|
||||
/** 页面标题 */
|
||||
title: string;
|
||||
/** 返回按钮文本 */
|
||||
backText?: string;
|
||||
/** 自定义返回逻辑 */
|
||||
onBack?: () => void;
|
||||
/** 默认返回路径 */
|
||||
defaultBackPath?: string;
|
||||
/** 是否显示返回按钮 */
|
||||
showBack?: boolean;
|
||||
/** 右侧扩展内容 */
|
||||
rightContent?: React.ReactNode;
|
||||
/** 自定义CSS类名 */
|
||||
className?: string;
|
||||
/** 标题样式类名 */
|
||||
titleClassName?: string;
|
||||
/** 返回按钮样式变体 */
|
||||
backButtonVariant?: 'icon' | 'button' | 'text';
|
||||
/** 返回按钮自定义样式类名 */
|
||||
backButtonClassName?: string;
|
||||
/** 是否显示底部边框 */
|
||||
showBorder?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用页面Header组件
|
||||
* 支持返回按钮、标题和右侧扩展插槽
|
||||
*/
|
||||
export const PageHeader: React.FC<PageHeaderProps> = ({
|
||||
title,
|
||||
backText = '返回',
|
||||
onBack,
|
||||
defaultBackPath = '/',
|
||||
showBack = true,
|
||||
rightContent,
|
||||
className = '',
|
||||
titleClassName = '',
|
||||
backButtonVariant = 'icon',
|
||||
backButtonClassName = '',
|
||||
showBorder = true
|
||||
}) => {
|
||||
const { goBack } = useSimpleBack(defaultBackPath);
|
||||
|
||||
const handleBack = onBack || goBack;
|
||||
|
||||
const baseClasses = `bg-white ${showBorder ? 'border-b border-gray-200' : ''}`;
|
||||
const headerClasses = `${baseClasses} ${className}`;
|
||||
// 默认小号按钮样式
|
||||
const defaultBackBtnClass = 'text-sm px-2 py-1 h-8 min-h-0';
|
||||
|
||||
return (
|
||||
<header className={headerClasses}>
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center ">
|
||||
{showBack && (
|
||||
<BackButton
|
||||
variant={backButtonVariant}
|
||||
text={backText}
|
||||
onBack={handleBack}
|
||||
className={`${defaultBackBtnClass} ${backButtonClassName}`.trim()}
|
||||
/>
|
||||
)}
|
||||
<h1 className={`text-lg font-semibold ${titleClassName}`}>
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{rightContent && (
|
||||
<div className="flex items-center gap-2">
|
||||
{rightContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageHeader;
|
||||
@@ -1,71 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
// 不需要登录的公共页面路径
|
||||
const PUBLIC_PATHS = [
|
||||
'/login',
|
||||
'/register',
|
||||
'/forgot-password',
|
||||
'/reset-password',
|
||||
'/404',
|
||||
'/500'
|
||||
];
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// 检查当前路径是否是公共页面
|
||||
const isPublicPath = PUBLIC_PATHS.some(path =>
|
||||
location.pathname.startsWith(path)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// 如果正在加载,不进行任何跳转
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果未登录且不是公共页面,重定向到登录页面
|
||||
if (!isAuthenticated && !isPublicPath) {
|
||||
// 保存当前URL,登录后可以重定向回来
|
||||
const returnUrl = encodeURIComponent(window.location.href);
|
||||
navigate(`/login?returnUrl=${returnUrl}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果已登录且在登录页面,重定向到首页
|
||||
if (isAuthenticated && location.pathname === '/login') {
|
||||
navigate('/', { replace: true });
|
||||
return;
|
||||
}
|
||||
}, [isAuthenticated, isLoading, location.pathname, navigate, isPublicPath]);
|
||||
|
||||
// 如果正在加载,显示加载状态
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 如果未登录且不是公共页面,不渲染内容(等待重定向)
|
||||
if (!isAuthenticated && !isPublicPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 如果已登录且在登录页面,不渲染内容(等待重定向)
|
||||
if (isAuthenticated && location.pathname === '/login') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 其他情况正常渲染
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/utils';
|
||||
|
||||
interface TestComponentProps {
|
||||
title: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TestComponent: React.FC<TestComponentProps> = ({ title, className }) => {
|
||||
return (
|
||||
<div className={cn('p-4 border rounded-lg', className)}>
|
||||
<h3 className="text-lg font-semibold mb-2">{title}</h3>
|
||||
<p className="text-gray-600">
|
||||
这是一个测试组件,用于验证路径别名 @/ 是否正常工作。
|
||||
</p>
|
||||
<div className="mt-2 text-sm text-blue-600">
|
||||
✓ 成功使用 @/utils 导入工具函数
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestComponent;
|
||||
@@ -1,291 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
useThrottledRequestWithLoading,
|
||||
useThrottledRequestWithError,
|
||||
useRequestWithRetry,
|
||||
useCancellableRequest
|
||||
} from '../hooks/useThrottledRequest';
|
||||
|
||||
interface ThrottledButtonProps {
|
||||
onClick: () => Promise<any>;
|
||||
children: React.ReactNode;
|
||||
delay?: number;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
variant?: 'throttle' | 'debounce' | 'retry' | 'cancellable';
|
||||
maxRetries?: number;
|
||||
retryDelay?: number;
|
||||
showLoadingText?: boolean;
|
||||
loadingText?: string;
|
||||
errorText?: string;
|
||||
onSuccess?: (result: any) => void;
|
||||
onError?: (error: any) => void;
|
||||
}
|
||||
|
||||
export const ThrottledButton: React.FC<ThrottledButtonProps> = ({
|
||||
onClick,
|
||||
children,
|
||||
delay = 1000,
|
||||
disabled = false,
|
||||
className = '',
|
||||
variant = 'throttle',
|
||||
maxRetries = 3,
|
||||
retryDelay = 1000,
|
||||
showLoadingText = true,
|
||||
loadingText = '处理中...',
|
||||
errorText,
|
||||
onSuccess,
|
||||
onError
|
||||
}) => {
|
||||
// 处理请求结果
|
||||
const handleRequest = async () => {
|
||||
try {
|
||||
const result = await onClick();
|
||||
onSuccess?.(result);
|
||||
} catch (error) {
|
||||
onError?.(error);
|
||||
}
|
||||
};
|
||||
|
||||
// 根据variant渲染不同的按钮
|
||||
const renderButton = () => {
|
||||
switch (variant) {
|
||||
case 'retry':
|
||||
return <RetryButtonContent
|
||||
onClick={handleRequest}
|
||||
maxRetries={maxRetries}
|
||||
retryDelay={retryDelay}
|
||||
loadingText={loadingText}
|
||||
showLoadingText={showLoadingText}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</RetryButtonContent>;
|
||||
|
||||
case 'cancellable':
|
||||
return <CancellableButtonContent
|
||||
onClick={handleRequest}
|
||||
loadingText={loadingText}
|
||||
showLoadingText={showLoadingText}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</CancellableButtonContent>;
|
||||
|
||||
case 'debounce':
|
||||
return <DebounceButtonContent
|
||||
onClick={handleRequest}
|
||||
delay={delay}
|
||||
loadingText={loadingText}
|
||||
showLoadingText={showLoadingText}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
errorText={errorText}
|
||||
>
|
||||
{children}
|
||||
</DebounceButtonContent>;
|
||||
|
||||
default:
|
||||
return <ThrottleButtonContent
|
||||
onClick={handleRequest}
|
||||
delay={delay}
|
||||
loadingText={loadingText}
|
||||
showLoadingText={showLoadingText}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</ThrottleButtonContent>;
|
||||
}
|
||||
};
|
||||
|
||||
return renderButton();
|
||||
};
|
||||
|
||||
// 节流按钮内容组件
|
||||
const ThrottleButtonContent: React.FC<{
|
||||
onClick: () => Promise<any>;
|
||||
delay: number;
|
||||
loadingText: string;
|
||||
showLoadingText: boolean;
|
||||
disabled: boolean;
|
||||
className: string;
|
||||
children: React.ReactNode;
|
||||
}> = ({ onClick, delay, loadingText, showLoadingText, disabled, className, children }) => {
|
||||
const { throttledRequest, loading } = useThrottledRequestWithLoading(onClick, delay);
|
||||
|
||||
const getButtonText = () => {
|
||||
return loading && showLoadingText ? loadingText : children;
|
||||
};
|
||||
|
||||
const getButtonClassName = () => {
|
||||
const baseClasses = 'px-4 py-2 rounded font-medium transition-colors duration-200 disabled:cursor-not-allowed';
|
||||
const variantClasses = loading
|
||||
? 'bg-gray-400 text-white cursor-not-allowed'
|
||||
: 'bg-blue-500 text-white hover:bg-blue-600 disabled:bg-gray-400';
|
||||
|
||||
return `${baseClasses} ${variantClasses} ${className}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={throttledRequest}
|
||||
disabled={disabled || loading}
|
||||
className={getButtonClassName()}
|
||||
>
|
||||
{getButtonText()}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// 防抖按钮内容组件
|
||||
const DebounceButtonContent: React.FC<{
|
||||
onClick: () => Promise<any>;
|
||||
delay: number;
|
||||
loadingText: string;
|
||||
showLoadingText: boolean;
|
||||
disabled: boolean;
|
||||
className: string;
|
||||
errorText?: string;
|
||||
children: React.ReactNode;
|
||||
}> = ({ onClick, delay, loadingText, showLoadingText, disabled, className, errorText, children }) => {
|
||||
const { throttledRequest, loading, error } = useThrottledRequestWithError(onClick, delay);
|
||||
|
||||
const getButtonText = () => {
|
||||
return loading && showLoadingText ? loadingText : children;
|
||||
};
|
||||
|
||||
const getButtonClassName = () => {
|
||||
const baseClasses = 'px-4 py-2 rounded font-medium transition-colors duration-200 disabled:cursor-not-allowed';
|
||||
let variantClasses = '';
|
||||
if (loading) {
|
||||
variantClasses = 'bg-gray-400 text-white cursor-not-allowed';
|
||||
} else if (error) {
|
||||
variantClasses = 'bg-red-500 text-white hover:bg-red-600';
|
||||
} else {
|
||||
variantClasses = 'bg-blue-500 text-white hover:bg-blue-600 disabled:bg-gray-400';
|
||||
}
|
||||
|
||||
return `${baseClasses} ${variantClasses} ${className}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={throttledRequest}
|
||||
disabled={disabled || loading}
|
||||
className={getButtonClassName()}
|
||||
>
|
||||
{getButtonText()}
|
||||
</button>
|
||||
|
||||
{error && errorText && (
|
||||
<span className="text-red-500 text-sm">{errorText}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 重试按钮内容组件
|
||||
const RetryButtonContent: React.FC<{
|
||||
onClick: () => Promise<any>;
|
||||
maxRetries: number;
|
||||
retryDelay: number;
|
||||
loadingText: string;
|
||||
showLoadingText: boolean;
|
||||
disabled: boolean;
|
||||
className: string;
|
||||
children: React.ReactNode;
|
||||
}> = ({ onClick, maxRetries, retryDelay, loadingText, showLoadingText, disabled, className, children }) => {
|
||||
const { requestWithRetry, loading, retryCount } = useRequestWithRetry(onClick, maxRetries, retryDelay);
|
||||
|
||||
const getButtonText = () => {
|
||||
if (loading) {
|
||||
if (retryCount > 0) {
|
||||
return `${loadingText} (重试 ${retryCount}/${maxRetries})`;
|
||||
}
|
||||
return showLoadingText ? loadingText : children;
|
||||
}
|
||||
return children;
|
||||
};
|
||||
|
||||
const getButtonClassName = () => {
|
||||
const baseClasses = 'px-4 py-2 rounded font-medium transition-colors duration-200 disabled:cursor-not-allowed';
|
||||
const variantClasses = loading
|
||||
? 'bg-gray-400 text-white cursor-not-allowed'
|
||||
: 'bg-blue-500 text-white hover:bg-blue-600 disabled:bg-gray-400';
|
||||
|
||||
return `${baseClasses} ${variantClasses} ${className}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={requestWithRetry}
|
||||
disabled={disabled || loading}
|
||||
className={getButtonClassName()}
|
||||
>
|
||||
{getButtonText()}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// 可取消按钮内容组件
|
||||
const CancellableButtonContent: React.FC<{
|
||||
onClick: () => Promise<any>;
|
||||
loadingText: string;
|
||||
showLoadingText: boolean;
|
||||
disabled: boolean;
|
||||
className: string;
|
||||
children: React.ReactNode;
|
||||
}> = ({ onClick, loadingText, showLoadingText, disabled, className, children }) => {
|
||||
const { cancellableRequest, loading, cancelRequest } = useCancellableRequest(onClick);
|
||||
|
||||
const getButtonText = () => {
|
||||
return loading && showLoadingText ? loadingText : children;
|
||||
};
|
||||
|
||||
const getButtonClassName = () => {
|
||||
const baseClasses = 'px-4 py-2 rounded font-medium transition-colors duration-200 disabled:cursor-not-allowed';
|
||||
const variantClasses = loading
|
||||
? 'bg-gray-400 text-white cursor-not-allowed'
|
||||
: 'bg-blue-500 text-white hover:bg-blue-600 disabled:bg-gray-400';
|
||||
|
||||
return `${baseClasses} ${variantClasses} ${className}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={cancellableRequest}
|
||||
disabled={disabled || loading}
|
||||
className={getButtonClassName()}
|
||||
>
|
||||
{getButtonText()}
|
||||
</button>
|
||||
|
||||
{loading && cancelRequest && (
|
||||
<button
|
||||
onClick={cancelRequest}
|
||||
className="px-3 py-2 rounded bg-red-500 text-white hover:bg-red-600 text-sm"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 导出其他类型的按钮组件
|
||||
export const DebouncedButton: React.FC<Omit<ThrottledButtonProps, 'variant'> & { delay?: number }> = (props) => (
|
||||
<ThrottledButton {...props} variant="debounce" delay={props.delay || 300} />
|
||||
);
|
||||
|
||||
export const RetryButton: React.FC<Omit<ThrottledButtonProps, 'variant'> & { maxRetries?: number; retryDelay?: number }> = (props) => (
|
||||
<ThrottledButton {...props} variant="retry" maxRetries={props.maxRetries || 3} retryDelay={props.retryDelay || 1000} />
|
||||
);
|
||||
|
||||
export const CancellableButton: React.FC<Omit<ThrottledButtonProps, 'variant'>> = (props) => (
|
||||
<ThrottledButton {...props} variant="cancellable" />
|
||||
);
|
||||
@@ -1,297 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Search, Database } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import { fetchDeviceLabels, type TrafficPool } from '@/api/trafficDistribution';
|
||||
|
||||
// 组件属性接口
|
||||
interface TrafficPoolSelectionProps {
|
||||
selectedPools: string[];
|
||||
onSelect: (pools: string[]) => void;
|
||||
deviceIds: string[];
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function TrafficPoolSelection({
|
||||
selectedPools,
|
||||
onSelect,
|
||||
deviceIds,
|
||||
placeholder = "选择流量池",
|
||||
className = ""
|
||||
}: TrafficPoolSelectionProps) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [pools, setPools] = useState<TrafficPool[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalPools, setTotalPools] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
// 获取流量池列表API
|
||||
const fetchPools = useCallback(async (page: number, keyword: string = '') => {
|
||||
if (deviceIds.length === 0) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetchDeviceLabels({
|
||||
deviceIds,
|
||||
page,
|
||||
pageSize: 10,
|
||||
keyword
|
||||
});
|
||||
|
||||
if (res && res.code === 200 && res.data) {
|
||||
setPools(res.data.list || []);
|
||||
setTotalPools(res.data.total || 0);
|
||||
setTotalPages(Math.ceil((res.data.total || 0) / 10));
|
||||
} else {
|
||||
toast({
|
||||
title: "获取流量池列表失败",
|
||||
description: res?.msg || "请稍后重试",
|
||||
variant: "destructive"
|
||||
});
|
||||
|
||||
// 使用模拟数据作为降级处理
|
||||
const mockData: TrafficPool[] = [
|
||||
{ id: "1", name: "新客流量池", count: 1250, description: "新获取的客户流量", deviceIds },
|
||||
{ id: "2", name: "高意向流量池", count: 850, description: "有购买意向的客户", deviceIds },
|
||||
{ id: "3", name: "复购流量池", count: 620, description: "已购买过产品的客户", deviceIds },
|
||||
{ id: "4", name: "活跃流量池", count: 1580, description: "近期活跃的客户", deviceIds },
|
||||
{ id: "5", name: "沉睡流量池", count: 2300, description: "长期未活跃的客户", deviceIds },
|
||||
{ id: "6", name: "VIP客户池", count: 156, description: "VIP等级客户", deviceIds },
|
||||
{ id: "7", name: "潜在客户池", count: 3200, description: "有潜在购买可能的客户", deviceIds },
|
||||
{ id: "8", name: "游戏玩家池", count: 890, description: "游戏类产品感兴趣客户", deviceIds },
|
||||
];
|
||||
|
||||
// 根据关键词过滤模拟数据
|
||||
const filteredData = keyword
|
||||
? mockData.filter(pool =>
|
||||
pool.name.toLowerCase().includes(keyword.toLowerCase()) ||
|
||||
(pool.description && pool.description.toLowerCase().includes(keyword.toLowerCase()))
|
||||
)
|
||||
: mockData;
|
||||
|
||||
// 分页处理模拟数据
|
||||
const startIndex = (page - 1) * 10;
|
||||
const endIndex = startIndex + 10;
|
||||
const paginatedData = filteredData.slice(startIndex, endIndex);
|
||||
|
||||
setPools(paginatedData);
|
||||
setTotalPools(filteredData.length);
|
||||
setTotalPages(Math.ceil(filteredData.length / 10));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取流量池列表失败:', error);
|
||||
toast({
|
||||
title: "网络错误",
|
||||
description: "请检查网络连接后重试",
|
||||
variant: "destructive"
|
||||
});
|
||||
|
||||
// 网络错误时使用模拟数据
|
||||
const mockData: TrafficPool[] = [
|
||||
{ id: "1", name: "新客流量池", count: 1250, description: "新获取的客户流量", deviceIds },
|
||||
{ id: "2", name: "高意向流量池", count: 850, description: "有购买意向的客户", deviceIds },
|
||||
{ id: "3", name: "复购流量池", count: 620, description: "已购买过产品的客户", deviceIds },
|
||||
{ id: "4", name: "活跃流量池", count: 1580, description: "近期活跃的客户", deviceIds },
|
||||
{ id: "5", name: "沉睡流量池", count: 2300, description: "长期未活跃的客户", deviceIds },
|
||||
];
|
||||
|
||||
setPools(mockData);
|
||||
setTotalPools(mockData.length);
|
||||
setTotalPages(1);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [deviceIds, toast]);
|
||||
|
||||
// 当弹窗打开时获取流量池列表
|
||||
useEffect(() => {
|
||||
if (dialogOpen && deviceIds.length > 0) {
|
||||
// 弹窗打开时重置搜索和页码,然后立即请求第一页数据
|
||||
setSearchQuery('');
|
||||
setCurrentPage(1);
|
||||
fetchPools(1, '');
|
||||
}
|
||||
}, [dialogOpen, deviceIds, fetchPools]);
|
||||
|
||||
// 监听页码变化,重新请求数据
|
||||
useEffect(() => {
|
||||
if (dialogOpen && deviceIds.length > 0 && currentPage > 1) {
|
||||
fetchPools(currentPage, searchQuery);
|
||||
}
|
||||
}, [currentPage, dialogOpen, deviceIds.length, fetchPools, searchQuery]);
|
||||
|
||||
// 当设备ID变化时,清空已选择的流量池(如果需要的话)
|
||||
useEffect(() => {
|
||||
if (deviceIds.length === 0) {
|
||||
setPools([]);
|
||||
setTotalPools(0);
|
||||
setTotalPages(1);
|
||||
}
|
||||
}, [deviceIds]);
|
||||
|
||||
// 处理搜索
|
||||
const handleSearch = (keyword: string) => {
|
||||
setSearchQuery(keyword);
|
||||
setCurrentPage(1);
|
||||
// 立即搜索,不管弹窗是否打开(因为这个函数只在弹窗内调用)
|
||||
if (deviceIds.length > 0) {
|
||||
fetchPools(1, keyword);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理流量池选择
|
||||
const handlePoolToggle = (poolId: string) => {
|
||||
if (selectedPools.includes(poolId)) {
|
||||
onSelect(selectedPools.filter(id => id !== poolId));
|
||||
} else {
|
||||
onSelect([...selectedPools, poolId]);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取显示文本
|
||||
const getDisplayText = () => {
|
||||
if (selectedPools.length === 0) return '';
|
||||
return `已选择 ${selectedPools.length} 个流量池`;
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
// 处理输入框点击
|
||||
const handleInputClick = () => {
|
||||
if (deviceIds.length === 0) {
|
||||
toast({
|
||||
title: "请先选择设备",
|
||||
description: "需要先选择设备才能选择流量池",
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 输入框 */}
|
||||
<div className={`relative ${className}`}>
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
<Database className="w-5 h-5" />
|
||||
</span>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
className="pl-10 h-12 rounded-xl border-gray-200 text-base"
|
||||
readOnly
|
||||
onClick={handleInputClick}
|
||||
value={getDisplayText()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 流量池选择弹窗 */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-sm w-[90vw] max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden">
|
||||
<div>
|
||||
<DialogTitle className="text-center text-xl font-medium mb-6">选择流量池</DialogTitle>
|
||||
|
||||
<div className="relative mb-4">
|
||||
<Input
|
||||
placeholder="搜索流量池"
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="pl-10 py-2 rounded-full border-gray-200"
|
||||
/>
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
) : pools.length > 0 ? (
|
||||
<div className="divide-y">
|
||||
{pools.map((pool) => (
|
||||
<label
|
||||
key={pool.id}
|
||||
className="flex items-center px-6 py-4 hover:bg-gray-50 cursor-pointer"
|
||||
onClick={() => handlePoolToggle(pool.id)}
|
||||
>
|
||||
<div className="mr-3 flex items-center justify-center">
|
||||
<div className={`w-5 h-5 rounded-full border ${selectedPools.includes(pool.id) ? 'border-blue-600' : 'border-gray-300'} flex items-center justify-center`}>
|
||||
{selectedPools.includes(pool.id) && (
|
||||
<div className="w-3 h-3 rounded-full bg-blue-600"></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center">
|
||||
<Database className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{pool.name}</div>
|
||||
{pool.description && (
|
||||
<div className="text-sm text-gray-500">{pool.description}</div>
|
||||
)}
|
||||
<div className="text-sm text-gray-400">{pool.count} 人</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-gray-500">
|
||||
{deviceIds.length === 0 ? '请先选择设备' : '没有找到流量池'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<div className="border-t p-4 flex items-center justify-between bg-white">
|
||||
<div className="text-sm text-gray-500">
|
||||
总计 {totalPools} 个流量池
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1 || loading}
|
||||
className="px-2 py-0 h-8 min-w-0"
|
||||
>
|
||||
<
|
||||
</Button>
|
||||
<span className="text-sm">{currentPage} / {totalPages}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage === totalPages || loading}
|
||||
className="px-2 py-0 h-8 min-w-0"
|
||||
>
|
||||
>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t p-4 flex items-center justify-between bg-white">
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)} className="px-6 rounded-full border-gray-300">
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} className="px-6 bg-blue-600 hover:bg-blue-700 rounded-full">
|
||||
确定 ({selectedPools.length})
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
import React from 'react';
|
||||
import { ChevronLeft, Settings, Bell, Search, RefreshCw, Filter, Plus, MoreVertical } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
|
||||
interface HeaderAction {
|
||||
type: 'button' | 'icon' | 'search' | 'custom';
|
||||
icon?: React.ComponentType<any>;
|
||||
label?: string;
|
||||
onClick?: () => void;
|
||||
variant?: 'default' | 'ghost' | 'outline' | 'destructive' | 'secondary';
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon';
|
||||
className?: string;
|
||||
content?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface UnifiedHeaderProps {
|
||||
/** 页面标题 */
|
||||
title: string;
|
||||
/** 是否显示返回按钮 */
|
||||
showBack?: boolean;
|
||||
/** 返回按钮文本 */
|
||||
backText?: string;
|
||||
/** 自定义返回逻辑 */
|
||||
onBack?: () => void;
|
||||
/** 默认返回路径 */
|
||||
defaultBackPath?: string;
|
||||
/** 右侧操作按钮 */
|
||||
actions?: HeaderAction[];
|
||||
/** 自定义右侧内容 */
|
||||
rightContent?: React.ReactNode;
|
||||
/** 是否显示搜索框 */
|
||||
showSearch?: boolean;
|
||||
/** 搜索框占位符 */
|
||||
searchPlaceholder?: string;
|
||||
/** 搜索值 */
|
||||
searchValue?: string;
|
||||
/** 搜索回调 */
|
||||
onSearchChange?: (value: string) => void;
|
||||
/** 是否显示底部边框 */
|
||||
showBorder?: boolean;
|
||||
/** 背景样式 */
|
||||
background?: 'white' | 'transparent' | 'blur';
|
||||
/** 自定义CSS类名 */
|
||||
className?: string;
|
||||
/** 标题样式类名 */
|
||||
titleClassName?: string;
|
||||
/** 标题颜色 */
|
||||
titleColor?: 'default' | 'blue' | 'gray';
|
||||
/** 是否居中标题 */
|
||||
centerTitle?: boolean;
|
||||
/** 头部高度 */
|
||||
height?: 'default' | 'compact' | 'tall';
|
||||
}
|
||||
|
||||
const UnifiedHeader: React.FC<UnifiedHeaderProps> = ({
|
||||
title,
|
||||
showBack = true,
|
||||
backText = '返回',
|
||||
onBack,
|
||||
defaultBackPath = '/',
|
||||
actions = [],
|
||||
rightContent,
|
||||
showSearch = false,
|
||||
searchPlaceholder = '搜索...',
|
||||
searchValue = '',
|
||||
onSearchChange,
|
||||
showBorder = true,
|
||||
background = 'white',
|
||||
className = '',
|
||||
titleClassName = '',
|
||||
titleColor = 'default',
|
||||
centerTitle = false,
|
||||
height = 'default',
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const handleBack = () => {
|
||||
if (onBack) {
|
||||
onBack();
|
||||
} else if (defaultBackPath) {
|
||||
navigate(defaultBackPath);
|
||||
} else {
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 背景样式
|
||||
const backgroundClasses = {
|
||||
white: 'bg-white',
|
||||
transparent: 'bg-transparent',
|
||||
blur: 'bg-white/80 backdrop-blur-sm',
|
||||
};
|
||||
|
||||
// 高度样式
|
||||
const heightClasses = {
|
||||
default: 'h-14',
|
||||
compact: 'h-12',
|
||||
tall: 'h-16',
|
||||
};
|
||||
|
||||
// 标题颜色样式
|
||||
const titleColorClasses = {
|
||||
default: 'text-gray-900',
|
||||
blue: 'text-blue-600',
|
||||
gray: 'text-gray-600',
|
||||
};
|
||||
|
||||
const headerClasses = [
|
||||
backgroundClasses[background],
|
||||
heightClasses[height],
|
||||
showBorder ? 'border-b border-gray-200' : '',
|
||||
'sticky top-0 z-50',
|
||||
className,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
const titleClasses = [
|
||||
'text-lg font-semibold',
|
||||
titleColorClasses[titleColor],
|
||||
centerTitle ? 'text-center' : '',
|
||||
titleClassName,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
// 渲染操作按钮
|
||||
const renderAction = (action: HeaderAction, index: number) => {
|
||||
if (action.type === 'custom' && action.content) {
|
||||
return <div key={index}>{action.content}</div>;
|
||||
}
|
||||
|
||||
if (action.type === 'search') {
|
||||
return (
|
||||
<div key={index} className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue}
|
||||
onChange={(e) => onSearchChange?.(e.target.value)}
|
||||
className="pl-9 w-48"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const IconComponent = action.icon || MoreVertical;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={index}
|
||||
variant={action.variant || 'ghost'}
|
||||
size={action.size || 'icon'}
|
||||
onClick={action.onClick}
|
||||
className={action.className}
|
||||
>
|
||||
<IconComponent className="h-5 w-5" />
|
||||
{action.label && action.size !== 'icon' && (
|
||||
<span className="ml-2">{action.label}</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className={headerClasses}>
|
||||
<div className="flex items-center justify-between px-4 h-full">
|
||||
{/* 左侧:返回按钮和标题 */}
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
{showBack && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleBack}
|
||||
className="h-8 w-8 hover:bg-gray-100"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
{!centerTitle && (
|
||||
<h1 className={titleClasses}>
|
||||
{title}
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 中间:居中标题 */}
|
||||
{centerTitle && (
|
||||
<div className="flex-1 flex justify-center">
|
||||
<h1 className={titleClasses}>
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 右侧:搜索框、操作按钮、自定义内容 */}
|
||||
<div className="flex items-center space-x-2 flex-1 justify-end">
|
||||
{showSearch && !actions.some(a => a.type === 'search') && (
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue}
|
||||
onChange={(e) => onSearchChange?.(e.target.value)}
|
||||
className="pl-9 w-48"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actions.map((action, index) => renderAction(action, index))}
|
||||
|
||||
{rightContent && (
|
||||
<div className="flex items-center space-x-2">
|
||||
{rightContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
// 预设的常用Header配置
|
||||
export const HeaderPresets = {
|
||||
// 基础页面Header(有返回按钮)
|
||||
basic: (title: string, onBack?: () => void): UnifiedHeaderProps => ({
|
||||
title,
|
||||
showBack: true,
|
||||
onBack,
|
||||
titleColor: 'blue',
|
||||
}),
|
||||
|
||||
// 主页Header(无返回按钮)
|
||||
main: (title: string, actions?: HeaderAction[]): UnifiedHeaderProps => ({
|
||||
title,
|
||||
showBack: false,
|
||||
titleColor: 'blue',
|
||||
actions: actions || [
|
||||
{
|
||||
type: 'icon',
|
||||
icon: Bell,
|
||||
onClick: () => console.log('Notifications'),
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
// 搜索页面Header
|
||||
search: (title: string, searchValue: string, onSearchChange: (value: string) => void): UnifiedHeaderProps => ({
|
||||
title,
|
||||
showBack: true,
|
||||
showSearch: true,
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
titleColor: 'blue',
|
||||
}),
|
||||
|
||||
// 列表页面Header(带刷新和添加)
|
||||
list: (title: string, onRefresh?: () => void, onAdd?: () => void): UnifiedHeaderProps => ({
|
||||
title,
|
||||
showBack: true,
|
||||
titleColor: 'blue',
|
||||
actions: [
|
||||
...(onRefresh ? [{
|
||||
type: 'icon' as const,
|
||||
icon: RefreshCw,
|
||||
onClick: onRefresh,
|
||||
}] : []),
|
||||
...(onAdd ? [{
|
||||
type: 'button' as const,
|
||||
icon: Plus,
|
||||
label: '新建',
|
||||
size: 'sm' as const,
|
||||
onClick: onAdd,
|
||||
}] : []),
|
||||
],
|
||||
}),
|
||||
|
||||
// 设置页面Header
|
||||
settings: (title: string): UnifiedHeaderProps => ({
|
||||
title,
|
||||
showBack: true,
|
||||
titleColor: 'blue',
|
||||
actions: [
|
||||
{
|
||||
type: 'icon',
|
||||
icon: Settings,
|
||||
onClick: () => console.log('Settings'),
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
export default UnifiedHeader;
|
||||
@@ -1,54 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Upload } from 'tdesign-mobile-react';
|
||||
import type { UploadFile as TDesignUploadFile } from 'tdesign-mobile-react/es/upload/type';
|
||||
import { uploadImage } from '@/api/upload';
|
||||
|
||||
interface UploadImageProps {
|
||||
value?: string[];
|
||||
onChange?: (urls: string[]) => void;
|
||||
max?: number;
|
||||
accept?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const UploadImage: React.FC<UploadImageProps> = ({ value = [], onChange, ...props }) => {
|
||||
// 处理上传
|
||||
const requestMethod = async (file: TDesignUploadFile) => {
|
||||
try {
|
||||
const url = await uploadImage(file.raw as File);
|
||||
return {
|
||||
status: 'success' as const,
|
||||
response: {
|
||||
url
|
||||
},
|
||||
url,
|
||||
};
|
||||
} catch (e: any) {
|
||||
return {
|
||||
status: 'fail' as const,
|
||||
error: e.message || '上传失败',
|
||||
response: {},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 处理文件变更
|
||||
const handleChange = (newFiles: TDesignUploadFile[]) => {
|
||||
const urls = newFiles.map(f => f.url).filter((url): url is string => Boolean(url));
|
||||
onChange?.(urls);
|
||||
};
|
||||
|
||||
return (
|
||||
<Upload
|
||||
files={value.map(url => ({ url }))}
|
||||
requestMethod={requestMethod}
|
||||
onChange={handleChange}
|
||||
multiple
|
||||
accept={props.accept}
|
||||
max={props.max}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadImage;
|
||||
@@ -1,94 +0,0 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { Button } from 'tdesign-mobile-react';
|
||||
import { X } from 'lucide-react';
|
||||
import { uploadImage } from '@/api/upload';
|
||||
|
||||
interface UploadVideoProps {
|
||||
value?: string;
|
||||
onChange?: (url: string) => void;
|
||||
accept?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const VIDEO_BOX_CLASS =
|
||||
'relative flex items-center justify-center w-full aspect-[16/9] rounded-2xl border-2 border-dashed border-blue-300 bg-gray-50 overflow-hidden';
|
||||
|
||||
const UploadVideo: React.FC<UploadVideoProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
accept = 'video/mp4,video/webm,video/ogg,video/quicktime,video/x-msvideo,video/x-ms-wmv,video/x-flv,video/x-matroska',
|
||||
disabled,
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 选择文件并上传
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const url = await uploadImage(file);
|
||||
onChange?.(url);
|
||||
} catch (err: any) {
|
||||
alert(err?.message || '上传失败');
|
||||
} finally {
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
// 触发文件选择
|
||||
const handleClick = () => {
|
||||
if (!disabled) inputRef.current?.click();
|
||||
};
|
||||
|
||||
// 删除视频
|
||||
const handleDelete = () => {
|
||||
onChange?.('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center w-full">
|
||||
{!value ? (
|
||||
<div className={VIDEO_BOX_CLASS}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-col items-center justify-center w-full h-full bg-transparent border-none outline-none cursor-pointer"
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className="text-3xl mb-2">🎬</span>
|
||||
<span className="text-base text-gray-500 font-medium">点击上传视频</span>
|
||||
<span className="text-xs text-gray-400 mt-1">支持MP4、WebM、MOV等格式</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={VIDEO_BOX_CLASS}>
|
||||
<video
|
||||
src={value}
|
||||
controls
|
||||
className="w-full h-full object-cover rounded-2xl bg-black"
|
||||
style={{ background: '#000' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-2 right-2 z-10 bg-white/80 hover:bg-white rounded-full p-1 shadow"
|
||||
onClick={handleDelete}
|
||||
disabled={disabled}
|
||||
aria-label="删除视频"
|
||||
>
|
||||
<X className="w-5 h-5 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadVideo;
|
||||
@@ -1,11 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
export function AppleIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" height="24" width="24" {...props}>
|
||||
<path d="M14.94 5.19A4.38 4.38 0 0016 2a4.44 4.44 0 00-3 1.52 4.17 4.17 0 00-1 3.09 3.69 3.69 0 002.94-1.42zm2.52 7.44a4.51 4.51 0 012.16-3.81 4.66 4.66 0 00-3.66-2c-1.56-.16-3 .91-3.83.91s-2-.89-3.3-.87a4.92 4.92 0 00-4.14 2.53C2.93 12.45 4.24 17 6 19.47c.8 1.21 1.8 2.58 3.12 2.53s1.75-.82 3.28-.82 2 .82 3.3.79 2.22-1.24 3.06-2.45a11 11 0 001.38-2.85 4.41 4.41 0 01-2.68-4.04z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppleIcon;
|
||||
@@ -1,22 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
export function EyeIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
height="24"
|
||||
width="24"
|
||||
{...props}
|
||||
>
|
||||
<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default EyeIcon;
|
||||
@@ -1,12 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
export function WeChatIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" height="24" width="24" {...props}>
|
||||
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.81-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.595-6.348zM5.959 5.48c.609 0 1.104.498 1.104 1.112 0 .612-.495 1.11-1.104 1.11-.612 0-1.108-.498-1.108-1.11 0-.614.496-1.112 1.108-1.112zm5.315 0c.61 0 1.107.498 1.107 1.112 0 .612-.497 1.11-1.107 1.11-.611 0-1.105-.498-1.105-1.11 0-.614.494-1.112 1.105-1.112z" />
|
||||
<path d="M23.002 15.816c0-3.309-3.136-6-7-6-3.863 0-7 2.691-7 6 0 3.31 3.137 6 7 6 .814 0 1.601-.099 2.338-.285a.7.7 0 0 1 .579.08l1.5.87a.267.267 0 0 0 .135.044c.13 0 .236-.108.236-.241 0-.06-.023-.118-.038-.17l-.309-1.167a.476.476 0 0 1 .172-.534c1.645-1.17 2.387-2.835 2.387-4.597zm-9.498-1.19c-.497 0-.9-.407-.9-.908a.905.905 0 0 1 .9-.91c.498 0 .9.408.9.91 0 .5-.402.908-.9.908zm4.998 0c-.497 0-.9-.407-.9-.908a.905.905 0 0 1 .9-.91c.498 0 .9.408.9.91 0 .5-.402.908-.9.908z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default WeChatIcon;
|
||||
@@ -1,62 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
children ? (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h5>
|
||||
) : null
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -1,77 +0,0 @@
|
||||
import React from 'react';
|
||||
import { UserCircle } from 'lucide-react';
|
||||
|
||||
interface AvatarProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Avatar({ children, className = '' }: AvatarProps) {
|
||||
return (
|
||||
<div className={`relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AvatarImageProps {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AvatarImage({ src, alt, className = '' }: AvatarImageProps) {
|
||||
if (!src) return null;
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || '头像'}
|
||||
className={`aspect-square h-full w-full object-cover ${className}`}
|
||||
onError={(e) => {
|
||||
// 图片加载失败时隐藏图片,显示fallback
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface AvatarFallbackProps {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
variant?: 'default' | 'gradient' | 'solid' | 'outline';
|
||||
showUserIcon?: boolean;
|
||||
}
|
||||
|
||||
export function AvatarFallback({
|
||||
children,
|
||||
className = '',
|
||||
variant = 'default',
|
||||
showUserIcon = true
|
||||
}: AvatarFallbackProps) {
|
||||
const getVariantClasses = () => {
|
||||
switch (variant) {
|
||||
case 'gradient':
|
||||
return 'bg-gradient-to-br from-blue-500 to-purple-600 text-white shadow-lg';
|
||||
case 'solid':
|
||||
return 'bg-blue-500 text-white';
|
||||
case 'outline':
|
||||
return 'bg-white border-2 border-blue-500 text-blue-500';
|
||||
default:
|
||||
return 'bg-gradient-to-br from-blue-100 to-blue-200 text-blue-600';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex h-full w-full items-center justify-center rounded-full ${getVariantClasses()} ${className}`}>
|
||||
{children ? (
|
||||
<span className="text-sm font-medium">{children}</span>
|
||||
) : showUserIcon ? (
|
||||
<UserCircle className="h-1/2 w-1/2" />
|
||||
) : (
|
||||
<span className="text-sm font-medium">用户</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface BadgeProps {
|
||||
children: React.ReactNode;
|
||||
variant?: 'default' | 'secondary' | 'success' | 'destructive' | 'outline';
|
||||
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',
|
||||
outline: 'border border-gray-300 bg-white text-gray-700'
|
||||
};
|
||||
|
||||
const classes = `${baseClasses} ${variantClasses[variant]} ${className}`;
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
className={classes}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={classes}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
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';
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
className = '',
|
||||
onClick,
|
||||
disabled = false,
|
||||
type = 'button',
|
||||
loading = false
|
||||
}: 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 || loading}
|
||||
type={type}
|
||||
>
|
||||
{loading && (
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
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 CardTitleProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CardTitle({ children, className = '' }: CardTitleProps) {
|
||||
return (
|
||||
<h3 className={`text-lg font-semibold text-gray-900 ${className}`}>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CheckboxProps {
|
||||
checked?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
onChange?: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
id?: string;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export function Checkbox({
|
||||
checked = false,
|
||||
onCheckedChange,
|
||||
onChange,
|
||||
disabled = false,
|
||||
className = '',
|
||||
id,
|
||||
onClick
|
||||
}: CheckboxProps) {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newChecked = e.target.checked;
|
||||
onCheckedChange?.(newChecked);
|
||||
onChange?.(newChecked);
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
id={id}
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 focus:ring-2 ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-white fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
@@ -1,109 +0,0 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
|
||||
interface DropdownMenuProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DropdownMenu({ children }: DropdownMenuProps) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
interface DropdownMenuTriggerProps {
|
||||
children: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
export function DropdownMenuTrigger({ children }: DropdownMenuTriggerProps) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
interface DropdownMenuContentProps {
|
||||
children: React.ReactNode;
|
||||
align?: 'start' | 'center' | 'end';
|
||||
}
|
||||
|
||||
export function DropdownMenuContent({ children, align = 'end' }: DropdownMenuContentProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const trigger = triggerRef.current;
|
||||
if (!trigger) return;
|
||||
|
||||
const handleClick = () => setIsOpen(!isOpen);
|
||||
trigger.addEventListener('click', handleClick);
|
||||
|
||||
return () => {
|
||||
trigger.removeEventListener('click', handleClick);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
contentRef.current &&
|
||||
!contentRef.current.contains(event.target as Node) &&
|
||||
triggerRef.current &&
|
||||
!triggerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div ref={triggerRef}>
|
||||
{React.Children.map(children, (child) => {
|
||||
if (React.isValidElement(child)) {
|
||||
return React.cloneElement(child, {
|
||||
...child.props,
|
||||
children: (
|
||||
<>
|
||||
{child.props.children}
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={`absolute z-50 mt-2 min-w-[8rem] overflow-hidden rounded-md border bg-white p-1 shadow-md ${
|
||||
align === 'start' ? 'left-0' : align === 'center' ? 'left-1/2 transform -translate-x-1/2' : 'right-0'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
});
|
||||
}
|
||||
return child;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DropdownMenuItemProps {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function DropdownMenuItem({ children, onClick, disabled = false }: DropdownMenuItemProps) {
|
||||
return (
|
||||
<button
|
||||
className={`relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-gray-100 focus:bg-gray-100 disabled:pointer-events-none disabled:opacity-50 ${
|
||||
disabled ? 'cursor-not-allowed opacity-50' : ''
|
||||
}`}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface InputProps {
|
||||
value?: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
onClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
readOnly?: boolean;
|
||||
readonly?: boolean;
|
||||
id?: string;
|
||||
type?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
name?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
autoComplete?: string;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
export function Input({
|
||||
value,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
onClick,
|
||||
placeholder,
|
||||
className = '',
|
||||
readOnly = false,
|
||||
readonly = false,
|
||||
id,
|
||||
type = 'text',
|
||||
min,
|
||||
max,
|
||||
name,
|
||||
required = false,
|
||||
disabled = false,
|
||||
autoComplete,
|
||||
step
|
||||
}: InputProps) {
|
||||
const isReadOnly = readOnly || readonly;
|
||||
|
||||
return (
|
||||
<input
|
||||
id={id}
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onClick={onClick}
|
||||
placeholder={placeholder}
|
||||
readOnly={isReadOnly}
|
||||
min={min}
|
||||
max={max}
|
||||
name={name}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
autoComplete={autoComplete}
|
||||
step={step}
|
||||
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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ProgressProps {
|
||||
value: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Progress({ value, className = '' }: ProgressProps) {
|
||||
return (
|
||||
<div className={`w-full bg-gray-200 rounded-full h-2 ${className}`}>
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/utils"
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
})
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
@@ -1,48 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/utils"
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -1,184 +0,0 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
|
||||
interface SelectProps {
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface SelectTriggerProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface SelectContentProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface SelectItemProps {
|
||||
value: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface SelectValueProps {
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Select({
|
||||
value,
|
||||
onValueChange,
|
||||
disabled = false,
|
||||
className = '',
|
||||
placeholder,
|
||||
children
|
||||
}: SelectProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedValue, setSelectedValue] = useState(value || '');
|
||||
const [selectedLabel, setSelectedLabel] = useState('');
|
||||
const selectRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedValue(value || '');
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (selectRef.current && !selectRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleSelect = (value: string, label: string) => {
|
||||
setSelectedValue(value);
|
||||
setSelectedLabel(label);
|
||||
onValueChange?.(value);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={selectRef} className={`relative ${className}`}>
|
||||
{React.Children.map(children, (child) => {
|
||||
if (React.isValidElement(child)) {
|
||||
if (child.type === SelectTrigger) {
|
||||
return React.cloneElement(child as any, {
|
||||
onClick: () => !disabled && setIsOpen(!isOpen),
|
||||
disabled,
|
||||
selectedValue: selectedValue,
|
||||
selectedLabel: selectedLabel,
|
||||
placeholder,
|
||||
isOpen
|
||||
});
|
||||
}
|
||||
if (child.type === SelectContent && isOpen) {
|
||||
return React.cloneElement(child as any, {
|
||||
onSelect: handleSelect
|
||||
});
|
||||
}
|
||||
}
|
||||
return child;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SelectTrigger({
|
||||
children,
|
||||
className = '',
|
||||
onClick,
|
||||
disabled,
|
||||
selectedValue,
|
||||
selectedLabel,
|
||||
placeholder,
|
||||
isOpen
|
||||
}: SelectTriggerProps & {
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
selectedValue?: string;
|
||||
selectedLabel?: string;
|
||||
placeholder?: string;
|
||||
isOpen?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`w-full px-3 py-2 text-left border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed ${className}`}
|
||||
>
|
||||
<span className="flex items-center justify-between">
|
||||
<span className={selectedValue ? 'text-gray-900' : 'text-gray-500'}>
|
||||
{selectedLabel || placeholder || '请选择...'}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SelectContent({
|
||||
children,
|
||||
className = '',
|
||||
onSelect
|
||||
}: SelectContentProps & {
|
||||
onSelect?: (value: string, label: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={`absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-auto ${className}`}>
|
||||
{React.Children.map(children, (child) => {
|
||||
if (React.isValidElement(child) && child.type === SelectItem) {
|
||||
return React.cloneElement(child as any, {
|
||||
onSelect
|
||||
});
|
||||
}
|
||||
return child;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SelectItem({
|
||||
value,
|
||||
children,
|
||||
className = '',
|
||||
onSelect
|
||||
}: SelectItemProps & {
|
||||
onSelect?: (value: string, label: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect?.(value, children as string)}
|
||||
className={`w-full px-3 py-2 text-left hover:bg-gray-100 focus:bg-gray-100 focus:outline-none ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SelectValue({
|
||||
placeholder,
|
||||
className = ''
|
||||
}: SelectValueProps) {
|
||||
return (
|
||||
<span className={className}>
|
||||
{placeholder}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
import { cn } from "../../utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
@@ -1,15 +0,0 @@
|
||||
import { cn } from "../../utils";
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -1,34 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface SwitchProps {
|
||||
checked: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export function Switch({ checked, onCheckedChange, disabled = false, className = '', id }: SwitchProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
onClick={() => !disabled && onCheckedChange(!checked)}
|
||||
className={`
|
||||
relative inline-flex h-6 w-11 items-center rounded-full transition-colors 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
|
||||
${checked ? 'bg-blue-600' : 'bg-gray-200'}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={`
|
||||
inline-block h-4 w-4 transform rounded-full bg-white transition-transform
|
||||
${checked ? 'translate-x-6' : 'translate-x-1'}
|
||||
`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/utils"
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
|
||||
</div>
|
||||
),
|
||||
)
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />,
|
||||
)
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
|
||||
),
|
||||
)
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tfoot ref={ref} className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)} {...props} />
|
||||
),
|
||||
)
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn("border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell }
|
||||
@@ -1,52 +0,0 @@
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { cn } from "@/utils";
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
@@ -1,33 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface TextareaProps {
|
||||
value?: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
export function Textarea({
|
||||
value,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
placeholder,
|
||||
className = '',
|
||||
disabled = false,
|
||||
rows = 3
|
||||
}: TextareaProps) {
|
||||
return (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
rows={rows}
|
||||
className={`w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/utils"
|
||||
|
||||
export type { ToastActionElement, ToastProps };
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive: "destructive border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return <ToastPrimitives.Root ref={ref} className={cn(toastVariants({ variant }), className)} {...props} />
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className,
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title ref={ref} className={cn("text-sm font-semibold", className)} {...props} />
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description ref={ref} className={cn("text-sm opacity-90", className)} {...props} />
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
// === 以下为 use-toast.ts 的内容迁移 ===
|
||||
|
||||
const TOAST_LIMIT = 1;
|
||||
const TOAST_REMOVE_DELAY = 1000000;
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string;
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
action?: ToastActionElement;
|
||||
};
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const;
|
||||
|
||||
let count = 0;
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_VALUE;
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes;
|
||||
type Action =
|
||||
| { type: ActionType["ADD_TOAST"]; toast: ToasterToast }
|
||||
| { type: ActionType["UPDATE_TOAST"]; toast: Partial<ToasterToast> }
|
||||
| { type: ActionType["DISMISS_TOAST"]; toastId?: ToasterToast["id"] }
|
||||
| { type: ActionType["REMOVE_TOAST"]; toastId?: ToasterToast["id"] };
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[];
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) return;
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId);
|
||||
dispatch({ type: "REMOVE_TOAST", toastId });
|
||||
}, TOAST_REMOVE_DELAY);
|
||||
toastTimeouts.set(toastId, timeout);
|
||||
};
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return { ...state, toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT) };
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
|
||||
};
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action;
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId);
|
||||
} else {
|
||||
state.toasts.forEach((toast) => addToRemoveQueue(toast.id));
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined ? { ...t, open: false } : t
|
||||
),
|
||||
};
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return { ...state, toasts: [] };
|
||||
}
|
||||
return { ...state, toasts: state.toasts.filter((t) => t.id !== action.toastId) };
|
||||
}
|
||||
};
|
||||
|
||||
const listeners: Array<(state: State) => void> = [];
|
||||
let memoryState: State = { toasts: [] };
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action);
|
||||
listeners.forEach((listener) => listener(memoryState));
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">;
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId();
|
||||
const update = (props: ToasterToast) => dispatch({ type: "UPDATE_TOAST", toast: { ...props, id } });
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open: boolean) => {
|
||||
if (!open) dismiss();
|
||||
},
|
||||
},
|
||||
});
|
||||
return { id, dismiss, update };
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState);
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState);
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState);
|
||||
if (index > -1) listeners.splice(index, 1);
|
||||
};
|
||||
}, []);
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
};
|
||||
}
|
||||
|
||||
export { useToast, toast };
|
||||
@@ -1,76 +0,0 @@
|
||||
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,188 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_VALUE
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
|
||||
}
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t,
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
Reference in New Issue
Block a user