feat(图标系统): 添加图标选择器和渲染器组件

- 新增 icons.ts 图标配置文件,定义图标分类和中文标签
- 新增 IconRenderer 组件,支持渲染 lucide 图标和 emoji
- 新增 IconPicker 组件,提供分类浏览和搜索功能
- 支持向后兼容已有的 emoji 图标数据
This commit is contained in:
gaoziman 2025-12-20 20:45:34 +08:00
parent 66a58a2d3d
commit bcb2141915
3 changed files with 418 additions and 0 deletions

View File

@ -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<string>('recommended');
const containerRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(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) => (
<button
key={iconName}
type="button"
onClick={() => handleSelect(iconName)}
title={iconName}
className={cn(
'w-9 h-9 flex items-center justify-center rounded transition-colors',
selected
? 'bg-[var(--color-primary)] text-white'
: 'hover:bg-[var(--color-bg-hover)] text-[var(--color-text-secondary)]'
)}
>
<IconRenderer icon={iconName} size={20} />
</button>
);
return (
<div className="relative" ref={containerRef}>
{/* 触发按钮 */}
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className={cn(
'flex items-center justify-center bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded hover:border-[var(--color-primary)] transition-colors',
triggerClassName
)}
style={{ width: triggerSize, height: triggerSize }}
>
<IconRenderer icon={value || DEFAULT_ICON} size={triggerSize * 0.5} />
</button>
{/* 下拉面板 */}
{isOpen && (
<div className="absolute top-full left-0 mt-2 w-[320px] bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded shadow-lg z-50 overflow-hidden">
{/* 搜索框 */}
<div className="p-3 border-b border-[var(--color-border)]">
<div className="relative">
<Search
size={16}
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--color-text-tertiary)]"
/>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => 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)]"
/>
</div>
</div>
{/* 内容区域 */}
<div className="max-h-[300px] overflow-y-auto">
{filteredIcons ? (
/* 搜索结果 */
<div className="p-3">
{filteredIcons.length === 0 ? (
<p className="text-sm text-[var(--color-text-tertiary)] text-center py-4">
</p>
) : (
<>
<p className="text-xs text-[var(--color-text-tertiary)] mb-2">
{filteredIcons.length}
</p>
<div className="grid grid-cols-8 gap-1">
{filteredIcons.map((iconName) =>
renderIconButton(iconName, iconName === value)
)}
</div>
</>
)}
</div>
) : (
/* 分类视图 */
<div>
{/* 分类标签 */}
<div className="flex flex-wrap gap-1 p-2 border-b border-[var(--color-border)] bg-[var(--color-bg-secondary)]">
{Object.entries(ICON_CATEGORIES).map(([key, category]) => (
<button
key={key}
type="button"
onClick={() => setActiveCategory(key)}
className={cn(
'px-2 py-1 text-xs rounded transition-colors',
activeCategory === key
? 'bg-[var(--color-primary)] text-white'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)]'
)}
>
{category.label}
</button>
))}
</div>
{/* 图标网格 */}
<div className="p-3">
<div className="grid grid-cols-8 gap-1">
{ICON_CATEGORIES[activeCategory as keyof typeof ICON_CATEGORIES]?.icons.map(
(iconName) => renderIconButton(iconName, iconName === value)
)}
</div>
</div>
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@ -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<SVGProps<SVGSVGElement> & { size?: number | string }>;
// 类型安全的图标映射
const icons = LucideIcons as unknown as Record<string, IconComponentType>;
/**
*
* 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 (
<span
className={cn('flex items-center justify-center', className)}
style={{ fontSize: size * 0.9, lineHeight: 1 }}
>
{iconValue}
</span>
);
}
// 尝试从 lucide-react 获取图标组件
const IconComponent = icons[iconValue];
if (IconComponent) {
return <IconComponent size={size} className={className} />;
}
// 如果找不到图标,使用默认图标
const FallbackIcon = icons[fallback];
if (FallbackIcon) {
return <FallbackIcon size={size} className={className} />;
}
// 最后的回退:显示问号图标
return <LucideIcons.HelpCircle size={size} className={className} />;
}, [icon, size, className, fallback]);
return <>{renderedIcon}</>;
}
/**
* Lucide
* 使
*/
export function getLucideIcon(iconName: string): IconComponentType | null {
return icons[iconName] || null;
}

142
src/components/ui/icons.ts Normal file
View File

@ -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<string, string[]> = {
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];