diff --git a/src/components/ui/IconPicker.tsx b/src/components/ui/IconPicker.tsx new file mode 100644 index 0000000..888ed67 --- /dev/null +++ b/src/components/ui/IconPicker.tsx @@ -0,0 +1,204 @@ +'use client'; + +import { useState, useRef, useEffect, useMemo } from 'react'; +import { Search, ChevronDown } from 'lucide-react'; +import { IconRenderer } from './IconRenderer'; +import { ICON_CATEGORIES, ICON_LABELS, DEFAULT_ICON } from './icons'; +import { cn } from '@/lib/utils'; + +interface IconPickerProps { + value: string; + onChange: (icon: string) => void; + triggerSize?: number; + triggerClassName?: string; +} + +/** + * 图标选择器组件 + */ +export function IconPicker({ + value, + onChange, + triggerSize = 48, + triggerClassName, +}: IconPickerProps) { + const [isOpen, setIsOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [activeCategory, setActiveCategory] = useState('recommended'); + + const containerRef = useRef(null); + const searchInputRef = useRef(null); + + // 点击外部关闭 + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + // 聚焦搜索框 + setTimeout(() => searchInputRef.current?.focus(), 100); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + // 搜索过滤 + const filteredIcons = useMemo(() => { + if (!searchQuery.trim()) { + return null; // 不搜索时返回 null,显示分类视图 + } + + const query = searchQuery.toLowerCase(); + const results: string[] = []; + + // 遍历所有图标,检查名称和标签 + Object.values(ICON_CATEGORIES).forEach((category) => { + category.icons.forEach((iconName) => { + // 检查图标名称 + if (iconName.toLowerCase().includes(query)) { + if (!results.includes(iconName)) { + results.push(iconName); + } + return; + } + + // 检查标签 + const labels = ICON_LABELS[iconName] || []; + if (labels.some((label) => label.toLowerCase().includes(query))) { + if (!results.includes(iconName)) { + results.push(iconName); + } + } + }); + }); + + return results; + }, [searchQuery]); + + // 选择图标 + const handleSelect = (iconName: string) => { + onChange(iconName); + setIsOpen(false); + setSearchQuery(''); + }; + + // 渲染图标按钮 + const renderIconButton = (iconName: string, selected: boolean = false) => ( + + ); + + return ( +
+ {/* 触发按钮 */} + + + {/* 下拉面板 */} + {isOpen && ( +
+ {/* 搜索框 */} +
+
+ + setSearchQuery(e.target.value)} + placeholder="搜索图标..." + className="w-full pl-8 pr-3 py-2 text-sm bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:border-[var(--color-primary)]" + /> +
+
+ + {/* 内容区域 */} +
+ {filteredIcons ? ( + /* 搜索结果 */ +
+ {filteredIcons.length === 0 ? ( +

+ 未找到匹配的图标 +

+ ) : ( + <> +

+ 找到 {filteredIcons.length} 个图标 +

+
+ {filteredIcons.map((iconName) => + renderIconButton(iconName, iconName === value) + )} +
+ + )} +
+ ) : ( + /* 分类视图 */ +
+ {/* 分类标签 */} +
+ {Object.entries(ICON_CATEGORIES).map(([key, category]) => ( + + ))} +
+ + {/* 图标网格 */} +
+
+ {ICON_CATEGORIES[activeCategory as keyof typeof ICON_CATEGORIES]?.icons.map( + (iconName) => renderIconButton(iconName, iconName === value) + )} +
+
+
+ )} +
+
+ )} +
+ ); +} diff --git a/src/components/ui/IconRenderer.tsx b/src/components/ui/IconRenderer.tsx new file mode 100644 index 0000000..2000ac0 --- /dev/null +++ b/src/components/ui/IconRenderer.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { useMemo, ComponentType, SVGProps } from 'react'; +import * as LucideIcons from 'lucide-react'; +import { isEmoji, DEFAULT_ICON } from './icons'; +import { cn } from '@/lib/utils'; + +interface IconRendererProps { + icon: string | null | undefined; + size?: number; + className?: string; + fallback?: string; +} + +// 定义图标组件类型 +type IconComponentType = ComponentType & { size?: number | string }>; + +// 类型安全的图标映射 +const icons = LucideIcons as unknown as Record; + +/** + * 图标渲染组件 + * 支持渲染 lucide-react 图标和 emoji + */ +export function IconRenderer({ + icon, + size = 24, + className, + fallback = DEFAULT_ICON, +}: IconRendererProps) { + const renderedIcon = useMemo(() => { + const iconValue = icon || fallback; + + // 如果是 emoji,直接渲染文本 + if (isEmoji(iconValue)) { + return ( + + {iconValue} + + ); + } + + // 尝试从 lucide-react 获取图标组件 + const IconComponent = icons[iconValue]; + + if (IconComponent) { + return ; + } + + // 如果找不到图标,使用默认图标 + const FallbackIcon = icons[fallback]; + if (FallbackIcon) { + return ; + } + + // 最后的回退:显示问号图标 + return ; + }, [icon, size, className, fallback]); + + return <>{renderedIcon}; +} + +/** + * 获取 Lucide 图标组件 + * 用于需要直接使用图标组件的场景 + */ +export function getLucideIcon(iconName: string): IconComponentType | null { + return icons[iconName] || null; +} diff --git a/src/components/ui/icons.ts b/src/components/ui/icons.ts new file mode 100644 index 0000000..5cd0e01 --- /dev/null +++ b/src/components/ui/icons.ts @@ -0,0 +1,142 @@ +/** + * 图标配置文件 + * 定义可用的 lucide-react 图标分类和推荐图标 + */ + +// 图标分类配置 +export const ICON_CATEGORIES = { + recommended: { + label: '推荐', + icons: [ + 'Code', 'Terminal', 'Bot', 'Brain', 'Lightbulb', + 'Rocket', 'Sparkles', 'Palette', 'FileText', 'Globe', + ], + }, + programming: { + label: '编程开发', + icons: [ + 'Code', 'Code2', 'Terminal', 'Database', 'Server', + 'GitBranch', 'GitCommit', 'GitMerge', 'Bug', 'Cpu', + 'HardDrive', 'Monitor', 'Laptop', 'Smartphone', 'Tablet', + 'Cloud', 'CloudCog', 'Container', 'Boxes', 'Package', + ], + }, + design: { + label: '设计创意', + icons: [ + 'Palette', 'Paintbrush', 'PaintBucket', 'Layers', 'Layout', + 'LayoutGrid', 'Image', 'ImagePlus', 'Camera', 'Video', + 'Film', 'Figma', 'Pen', 'PenTool', 'Brush', + 'Eraser', 'Scissors', 'Crop', 'Frame', 'Shapes', + ], + }, + writing: { + label: '写作文档', + icons: [ + 'FileText', 'File', 'FileCode', 'FileJson', 'FilePlus', + 'BookOpen', 'Book', 'BookMarked', 'Notebook', 'NotebookPen', + 'PenLine', 'Pencil', 'Type', 'Languages', 'Quote', + 'AlignLeft', 'AlignCenter', 'List', 'ListOrdered', 'Text', + ], + }, + tools: { + label: '工具效率', + icons: [ + 'Wrench', 'Settings', 'Cog', 'SlidersHorizontal', 'Search', + 'Filter', 'Calculator', 'Calendar', 'Clock', 'Timer', + 'Zap', 'Bolt', 'Target', 'Crosshair', 'Compass', + 'Ruler', 'Scale', 'Gauge', 'Activity', 'BarChart', + ], + }, + communication: { + label: '沟通协作', + icons: [ + 'MessageSquare', 'MessageCircle', 'MessagesSquare', 'Mail', 'Send', + 'Phone', 'PhoneCall', 'Video', 'Mic', 'Headphones', + 'Users', 'UserPlus', 'UserCheck', 'Contact', 'AtSign', + 'Share', 'Share2', 'Link', 'ExternalLink', 'Globe', + ], + }, + ai: { + label: 'AI 智能', + icons: [ + 'Bot', 'Brain', 'Sparkles', 'Wand', 'Wand2', + 'Stars', 'Star', 'Atom', 'CircuitBoard', 'Scan', + 'ScanLine', 'ScanSearch', 'Eye', 'Fingerprint', 'Shield', + 'Lock', 'Key', 'BadgeCheck', 'Award', 'Trophy', + ], + }, + general: { + label: '通用', + icons: [ + 'Home', 'Heart', 'ThumbsUp', 'Smile', 'Sun', + 'Moon', 'Lightbulb', 'Rocket', 'Plane', 'Car', + 'Building', 'Store', 'ShoppingCart', 'Gift', 'Flag', + 'Bookmark', 'Tag', 'Hash', 'Info', 'HelpCircle', + ], + }, +} as const; + +// 所有可用图标的平铺列表(用于搜索) +export const ALL_ICONS = Object.values(ICON_CATEGORIES).flatMap( + (category) => category.icons +); + +// 去重后的图标列表 +export const UNIQUE_ICONS = [...new Set(ALL_ICONS)]; + +// 图标名称到中文的映射(用于搜索) +export const ICON_LABELS: Record = { + Code: ['代码', '编程', 'code'], + Code2: ['代码', '编程', 'code'], + Terminal: ['终端', '命令行', 'terminal', 'console'], + Database: ['数据库', 'database', 'db'], + Server: ['服务器', 'server'], + GitBranch: ['分支', 'git', 'branch'], + Bug: ['调试', 'bug', '虫子'], + Cpu: ['处理器', 'cpu', '芯片'], + Bot: ['机器人', 'bot', 'ai', '助手'], + Brain: ['大脑', '智能', 'brain', 'ai'], + Sparkles: ['闪光', '魔法', 'sparkles', 'magic'], + Palette: ['调色板', '设计', 'palette', 'design'], + Paintbrush: ['画笔', '绘画', 'brush', 'paint'], + Layers: ['图层', 'layers'], + Image: ['图片', '图像', 'image', 'picture'], + FileText: ['文档', '文件', 'file', 'document'], + BookOpen: ['书籍', '阅读', 'book', 'read'], + PenLine: ['写作', '笔', 'pen', 'write'], + Languages: ['语言', '翻译', 'language', 'translate'], + Wrench: ['工具', '扳手', 'tool', 'wrench'], + Settings: ['设置', 'settings', 'config'], + Search: ['搜索', '查找', 'search', 'find'], + Calculator: ['计算器', 'calculator', '计算'], + Calendar: ['日历', 'calendar', '日期'], + Clock: ['时钟', '时间', 'clock', 'time'], + MessageSquare: ['消息', '聊天', 'message', 'chat'], + Mail: ['邮件', 'mail', 'email'], + Users: ['用户', '团队', 'users', 'team'], + Globe: ['全球', '网络', 'globe', 'web', 'internet'], + Home: ['首页', '主页', 'home'], + Heart: ['心', '喜欢', 'heart', 'like', 'love'], + Lightbulb: ['灯泡', '想法', 'lightbulb', 'idea'], + Rocket: ['火箭', '发射', 'rocket', 'launch'], + Star: ['星星', '收藏', 'star', 'favorite'], + Zap: ['闪电', '快速', 'zap', 'fast', 'lightning'], + Target: ['目标', 'target', 'goal'], + Shield: ['盾牌', '安全', 'shield', 'security'], + Lock: ['锁', '安全', 'lock', 'security'], + Key: ['钥匙', 'key'], +}; + +// 默认图标 +export const DEFAULT_ICON = 'Bot'; + +// 判断是否为 emoji +export const isEmoji = (str: string | null | undefined): boolean => { + if (!str) return false; + // emoji 通常以高位 Unicode 开头 + return /^[\u{1F300}-\u{1FAD6}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u.test(str); +}; + +// 获取图标类型 +export type IconName = typeof UNIQUE_ICONS[number];