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

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

View File

@@ -1,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;

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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"
>
&lt;
</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"
>
&gt;
</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>
</>
);
}

View File

@@ -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"
>
&lt;
</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"
>
&gt;
</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>
</>
);
}

View File

@@ -1,10 +0,0 @@
.container {
display: flex;
height: 100vh;
flex-direction: column;
}
.container main {
flex: 1;
overflow: auto;
}

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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}</>;
}

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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" />
);

View File

@@ -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"
>
&lt;
</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"
>
&gt;
</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>
</>
);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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">MP4WebMMOV等格式</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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 }

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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}`}
/>
);
}

View File

@@ -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,
};

View File

@@ -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>
);
}

View File

@@ -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}`}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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>
);
}

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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>
);
}

View File

@@ -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 }

View File

@@ -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 };

View File

@@ -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}`}
/>
);
}

View File

@@ -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 };

View File

@@ -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>
);
}

View File

@@ -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 }