From c2b97f0f2dc79111909e4358da135a752e127ed9 Mon Sep 17 00:00:00 2001 From: gaoziman <2942894660@qq.com> Date: Sun, 28 Dec 2025 17:28:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=BB=84=E4=BB=B6):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=20UI=20=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 TagBadge 标签徽章组件 - 添加 TagManager 标签管理器组件 - 添加 TagFilter 标签筛选组件 - 添加 AutoTagModal AI 自动标签弹窗 - 导出组件入口 index.ts --- src/components/features/Tags/AutoTagModal.tsx | 280 ++++++++++++++++++ src/components/features/Tags/TagBadge.tsx | 81 +++++ src/components/features/Tags/TagFilter.tsx | 94 ++++++ src/components/features/Tags/TagManager.tsx | 217 ++++++++++++++ src/components/features/Tags/index.ts | 4 + 5 files changed, 676 insertions(+) create mode 100644 src/components/features/Tags/AutoTagModal.tsx create mode 100644 src/components/features/Tags/TagBadge.tsx create mode 100644 src/components/features/Tags/TagFilter.tsx create mode 100644 src/components/features/Tags/TagManager.tsx create mode 100644 src/components/features/Tags/index.ts diff --git a/src/components/features/Tags/AutoTagModal.tsx b/src/components/features/Tags/AutoTagModal.tsx new file mode 100644 index 0000000..eb74fdb --- /dev/null +++ b/src/components/features/Tags/AutoTagModal.tsx @@ -0,0 +1,280 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import { X, Sparkles, Check, Loader2 } from 'lucide-react'; +import type { SuggestedTag } from '@/types/tags'; +import { getTagColor } from '@/types/tags'; +import { cn } from '@/lib/utils'; + +interface AutoTagModalProps { + /** 是否显示 */ + isOpen: boolean; + /** 关闭回调 */ + onClose: () => void; + /** AI 推荐的标签 */ + suggestedTags: SuggestedTag[]; + /** 当前已有的标签 */ + currentTags: string[]; + /** 应用标签回调 */ + onApply: (selectedTags: string[]) => void; + /** 是否加载中 */ + loading?: boolean; +} + +export function AutoTagModal({ + isOpen, + onClose, + suggestedTags, + currentTags, + onApply, + loading = false, +}: AutoTagModalProps) { + // 选中的标签(默认选中前3个高置信度的) + const [selectedTags, setSelectedTags] = useState([]); + + // 客户端挂载状态(用于 Portal SSR 安全) + const [mounted, setMounted] = useState(false); + + // 组件挂载后设置 mounted 状态 + useEffect(() => { + setMounted(true); + return () => setMounted(false); + }, []); + + // 初始化选中状态 + useEffect(() => { + if (suggestedTags.length > 0) { + const defaultSelected = suggestedTags + .filter((tag) => !currentTags.includes(tag.name)) + .slice(0, 3) + .map((tag) => tag.name); + setSelectedTags(defaultSelected); + } + }, [suggestedTags, currentTags]); + + // 阻止背景滚动 + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = ''; + }; + } + }, [isOpen]); + + // 关闭处理 + const handleClose = useCallback(() => { + if (!loading) { + onClose(); + } + }, [loading, onClose]); + + // ESC 键关闭 + useEffect(() => { + const handleEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !loading) { + handleClose(); + } + }; + + if (isOpen) { + document.addEventListener('keydown', handleEsc); + return () => document.removeEventListener('keydown', handleEsc); + } + }, [isOpen, loading, handleClose]); + + // 切换标签选中状态 + const toggleTag = (tagName: string) => { + setSelectedTags((prev) => { + if (prev.includes(tagName)) { + return prev.filter((t) => t !== tagName); + } + return [...prev, tagName]; + }); + }; + + // 处理应用 + const handleApply = () => { + onApply(selectedTags); + }; + + // SSR 安全检查:未挂载或未打开时不渲染 + if (!mounted || !isOpen) return null; + + // 使用 Portal 将 Modal 渲染到 body,脱离组件树的层叠上下文 + return createPortal( +
+ {/* 背景遮罩 */} +
+ + {/* Modal 内容 */} +
+ {/* 头部 */} +
+
+ +
+
+

+ AI 智能标签生成 +

+

+ 基于对话内容自动分析生成标签 +

+
+ +
+ + {/* 内容 */} +
+ {loading ? ( + // 加载状态 +
+
+
+ + ✨ + +
+

+ 正在分析对话内容... +

+

+ AI 正在理解上下文并生成相关标签 +

+
+ ) : suggestedTags.length === 0 ? ( + // 无结果 +
+

+ 暂无推荐标签 +

+
+ ) : ( + // 标签列表 +
+

+ 根据对话内容,AI 推荐以下标签: +

+ {suggestedTags.map((tag) => { + const isSelected = selectedTags.includes(tag.name); + const isExisting = currentTags.includes(tag.name); + const color = getTagColor(tag.name); + + return ( +
!isExisting && toggleTag(tag.name)} + className={cn( + 'flex items-center gap-3 px-3 py-2.5 rounded-[4px] border transition-all', + isExisting + ? 'bg-[var(--color-bg-tertiary)] border-[var(--color-border-light)] opacity-50 cursor-not-allowed' + : isSelected + ? 'bg-[var(--color-primary-light)] border-[var(--color-primary)] cursor-pointer' + : 'bg-[var(--color-bg-tertiary)] border-[var(--color-border-light)] cursor-pointer hover:bg-[var(--color-bg-hover)]' + )} + > + {/* 复选框 */} +
+ {(isSelected || isExisting) && ( + + )} +
+ + {/* 标签预览 */} +
+ + {tag.name} + + {isExisting && ( + + (已存在) + + )} +
+ + {/* 置信度 */} +
+
+
+
+ + {tag.confidence}% + +
+
+ ); + })} +
+ )} +
+ + {/* 底部按钮 */} +
+ + +
+
+ + {/* 动画样式 */} + +
, + document.body + ); +} diff --git a/src/components/features/Tags/TagBadge.tsx b/src/components/features/Tags/TagBadge.tsx new file mode 100644 index 0000000..8db652e --- /dev/null +++ b/src/components/features/Tags/TagBadge.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { X } from 'lucide-react'; +import { getTagColor } from '@/types/tags'; +import { cn } from '@/lib/utils'; + +interface TagBadgeProps { + /** 标签名称 */ + name: string; + /** 是否显示删除按钮 */ + removable?: boolean; + /** 删除回调 */ + onRemove?: () => void; + /** 点击回调 */ + onClick?: () => void; + /** 是否选中状态 */ + selected?: boolean; + /** 尺寸 */ + size?: 'sm' | 'md'; + /** 显示数量 */ + count?: number; + /** 自定义类名 */ + className?: string; +} + +export function TagBadge({ + name, + removable = false, + onRemove, + onClick, + selected = false, + size = 'sm', + count, + className, +}: TagBadgeProps) { + const color = getTagColor(name); + + const sizeStyles = { + sm: 'px-2 py-0.5 text-[10px]', + md: 'px-2.5 py-1 text-xs', + }; + + return ( + + {name} + {count !== undefined && ( + + {count} + + )} + {removable && onRemove && ( + + )} + + ); +} diff --git a/src/components/features/Tags/TagFilter.tsx b/src/components/features/Tags/TagFilter.tsx new file mode 100644 index 0000000..2b11622 --- /dev/null +++ b/src/components/features/Tags/TagFilter.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { Tag, Loader2 } from 'lucide-react'; +import { useTags, useTagFilter } from '@/hooks/useTags'; +import { cn } from '@/lib/utils'; + +interface TagFilterProps { + /** 选中标签变化回调 */ + onFilterChange?: (selectedTags: string[]) => void; + /** 自定义类名 */ + className?: string; +} + +export function TagFilter({ onFilterChange, className }: TagFilterProps) { + const { tags, loading } = useTags(); + const { selectedTags, selectTag, clearFilter } = useTagFilter(); + + const handleTagClick = (tagName: string) => { + selectTag(tagName); + // 通知父组件筛选变化 + if (onFilterChange) { + const newSelected = selectedTags.includes(tagName) && selectedTags.length === 1 + ? [] + : [tagName]; + onFilterChange(newSelected); + } + }; + + const handleClear = () => { + clearFilter(); + if (onFilterChange) { + onFilterChange([]); + } + }; + + // 没有标签时不显示 + if (!loading && tags.length === 0) { + return null; + } + + return ( +
+
+ {/* 标题行 */} +
+
+ + + 标签筛选 + +
+ {selectedTags.length > 0 && ( + + )} +
+ + {/* 标签列表 - 紧凑胶囊式 */} + {loading ? ( +
+ +
+ ) : ( +
+ {tags.slice(0, 10).map((tag) => { + const isSelected = selectedTags.includes(tag.name); + return ( + + ); + })} +
+ )} +
+
+ ); +} diff --git a/src/components/features/Tags/TagManager.tsx b/src/components/features/Tags/TagManager.tsx new file mode 100644 index 0000000..d59c499 --- /dev/null +++ b/src/components/features/Tags/TagManager.tsx @@ -0,0 +1,217 @@ +'use client'; + +import { useState, useRef, useEffect, KeyboardEvent } from 'react'; +import { Tag, Sparkles, Loader2, Plus, X, ChevronDown } from 'lucide-react'; +import { useConversationTags } from '@/hooks/useTags'; +import { AutoTagModal } from './AutoTagModal'; +import { cn } from '@/lib/utils'; + +interface TagManagerProps { + /** 对话 ID */ + conversationId: string; + /** 自定义类名 */ + className?: string; +} + +export function TagManager({ conversationId, className }: TagManagerProps) { + const { + tags, + loading, + updating, + generating, + suggestedTags, + addTag, + removeTag, + generateTags, + applySuggestedTags, + clearSuggestedTags, + } = useConversationTags(conversationId); + + const [inputValue, setInputValue] = useState(''); + const [showInput, setShowInput] = useState(false); + const [showModal, setShowModal] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); + const inputRef = useRef(null); + const menuRef = useRef(null); + + // 聚焦输入框 + useEffect(() => { + if (showInput && inputRef.current) { + inputRef.current.focus(); + } + }, [showInput]); + + // 点击外部关闭下拉菜单 + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setMenuOpen(false); + } + }; + if (menuOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [menuOpen]); + + // 处理添加标签 + const handleAddTag = async () => { + const trimmed = inputValue.trim(); + if (!trimmed) return; + + try { + await addTag(trimmed); + setInputValue(''); + setShowInput(false); + } catch { + // 错误已在 hook 中处理 + } + }; + + // 处理键盘事件 + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddTag(); + } else if (e.key === 'Escape') { + setInputValue(''); + setShowInput(false); + } + }; + + // 处理 AI 生成标签 + const handleGenerateTags = async () => { + setMenuOpen(false); + try { + await generateTags(); + setShowModal(true); + } catch { + // 错误已在 hook 中处理 + } + }; + + // 处理应用推荐标签 + const handleApplyTags = async (selectedTags: string[]) => { + try { + await applySuggestedTags(selectedTags); + setShowModal(false); + } catch { + // 错误已在 hook 中处理 + } + }; + + // 处理关闭弹窗 + const handleCloseModal = () => { + setShowModal(false); + clearSuggestedTags(); + }; + + // 打开添加标签输入框 + const handleOpenAddTag = () => { + setMenuOpen(false); + setShowInput(true); + }; + + if (loading) { + return ( +
+ + 加载标签... +
+ ); + } + + return ( +
+ {/* 标签展示区域 - 显示全部标签 */} + {tags.map((tag) => ( +
+ {tag} + +
+ ))} + + {/* 添加标签输入框 */} + {showInput && ( + setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => { + if (!inputValue.trim()) { + setShowInput(false); + } + }} + placeholder="输入标签..." + maxLength={20} + disabled={updating} + className="w-20 px-2 py-1 text-[11px] bg-[var(--color-bg-tertiary)] border border-[var(--color-border)] rounded-[4px] text-[var(--color-text-primary)] placeholder-[var(--color-text-tertiary)] outline-none focus:border-[var(--color-primary)] focus:w-24 transition-all" + /> + )} + + {/* 管理标签下拉按钮 */} +
+ + + {/* 下拉菜单 */} + {menuOpen && ( +
+ + +
+ )} +
+ + {/* 自动生成标签弹窗 */} + +
+ ); +} diff --git a/src/components/features/Tags/index.ts b/src/components/features/Tags/index.ts new file mode 100644 index 0000000..09d1e9a --- /dev/null +++ b/src/components/features/Tags/index.ts @@ -0,0 +1,4 @@ +export { TagBadge } from './TagBadge'; +export { TagFilter } from './TagFilter'; +export { TagManager } from './TagManager'; +export { AutoTagModal } from './AutoTagModal';