feat(组件): 添加标签 UI 组件
- 添加 TagBadge 标签徽章组件 - 添加 TagManager 标签管理器组件 - 添加 TagFilter 标签筛选组件 - 添加 AutoTagModal AI 自动标签弹窗 - 导出组件入口 index.ts
This commit is contained in:
parent
728c13eb5e
commit
c2b97f0f2d
280
src/components/features/Tags/AutoTagModal.tsx
Normal file
280
src/components/features/Tags/AutoTagModal.tsx
Normal file
@ -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<string[]>([]);
|
||||||
|
|
||||||
|
// 客户端挂载状态(用于 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(
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
{/* 背景遮罩 */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||||
|
style={{ animation: 'fadeIn 0.2s ease-out' }}
|
||||||
|
onClick={handleClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal 内容 */}
|
||||||
|
<div
|
||||||
|
className="relative w-full max-w-md mx-4 bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-[4px] shadow-lg overflow-hidden"
|
||||||
|
style={{ animation: 'scaleIn 0.2s ease-out' }}
|
||||||
|
>
|
||||||
|
{/* 头部 */}
|
||||||
|
<div className="flex items-center gap-3 px-5 py-4 border-b border-[var(--color-border-light)]">
|
||||||
|
<div className="w-9 h-9 flex items-center justify-center bg-[var(--color-primary)] rounded-[4px]">
|
||||||
|
<Sparkles size={18} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">
|
||||||
|
AI 智能标签生成
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-[var(--color-text-tertiary)]">
|
||||||
|
基于对话内容自动分析生成标签
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-7 h-7 flex items-center justify-center bg-[var(--color-bg-tertiary)] rounded-[4px] text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-primary)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 内容 */}
|
||||||
|
<div className="px-5 py-4">
|
||||||
|
{loading ? (
|
||||||
|
// 加载状态
|
||||||
|
<div className="flex flex-col items-center justify-center py-8">
|
||||||
|
<div className="relative w-14 h-14 mb-4">
|
||||||
|
<div className="absolute inset-0 border-3 border-[var(--color-border)] border-t-[var(--color-primary)] rounded-full animate-spin" />
|
||||||
|
<span className="absolute inset-0 flex items-center justify-center text-xl">
|
||||||
|
✨
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-[var(--color-text-primary)]">
|
||||||
|
正在分析对话内容...
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
|
||||||
|
AI 正在理解上下文并生成相关标签
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : suggestedTags.length === 0 ? (
|
||||||
|
// 无结果
|
||||||
|
<div className="flex flex-col items-center justify-center py-8">
|
||||||
|
<p className="text-sm text-[var(--color-text-secondary)]">
|
||||||
|
暂无推荐标签
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// 标签列表
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs text-[var(--color-text-secondary)] mb-3">
|
||||||
|
根据对话内容,AI 推荐以下标签:
|
||||||
|
</p>
|
||||||
|
{suggestedTags.map((tag) => {
|
||||||
|
const isSelected = selectedTags.includes(tag.name);
|
||||||
|
const isExisting = currentTags.includes(tag.name);
|
||||||
|
const color = getTagColor(tag.name);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tag.name}
|
||||||
|
onClick={() => !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)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* 复选框 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-4.5 h-4.5 flex items-center justify-center rounded-[4px] border-2 transition-all',
|
||||||
|
isExisting
|
||||||
|
? 'bg-[var(--color-bg-hover)] border-[var(--color-border)]'
|
||||||
|
: isSelected
|
||||||
|
? 'bg-[var(--color-primary)] border-[var(--color-primary)]'
|
||||||
|
: 'border-[var(--color-border)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{(isSelected || isExisting) && (
|
||||||
|
<Check size={10} className="text-white" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 标签预览 */}
|
||||||
|
<div className="flex-1 flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="px-2 py-0.5 text-xs font-medium rounded-[4px] border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: color.bg,
|
||||||
|
color: color.text,
|
||||||
|
borderColor: color.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</span>
|
||||||
|
{isExisting && (
|
||||||
|
<span className="text-[10px] text-[var(--color-text-tertiary)]">
|
||||||
|
(已存在)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 置信度 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-12 h-1 bg-[var(--color-bg-hover)] rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-[var(--color-primary)] rounded-full"
|
||||||
|
style={{ width: `${tag.confidence}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-[var(--color-text-tertiary)] font-mono w-8">
|
||||||
|
{tag.confidence}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部按钮 */}
|
||||||
|
<div className="flex justify-end gap-2 px-5 py-4 border-t border-[var(--color-border-light)]">
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-[var(--color-text-secondary)] bg-[var(--color-bg-tertiary)] border border-[var(--color-border)] rounded-[4px] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-primary)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleApply}
|
||||||
|
disabled={loading || selectedTags.length === 0}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-[var(--color-primary)] rounded-[4px] hover:bg-[var(--color-primary-hover)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
应用标签 ({selectedTags.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 动画样式 */}
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes scaleIn {
|
||||||
|
from { opacity: 0; transform: scale(0.95); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/components/features/Tags/TagBadge.tsx
Normal file
81
src/components/features/Tags/TagBadge.tsx
Normal file
@ -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 (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 rounded-[4px] font-medium transition-all border',
|
||||||
|
sizeStyles[size],
|
||||||
|
onClick ? 'cursor-pointer hover:opacity-80' : '',
|
||||||
|
selected ? 'ring-1 ring-[var(--color-primary)]' : '',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: color.bg,
|
||||||
|
color: color.text,
|
||||||
|
borderColor: color.border,
|
||||||
|
}}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<span className="truncate max-w-[80px]">{name}</span>
|
||||||
|
{count !== undefined && (
|
||||||
|
<span
|
||||||
|
className="px-1 py-0 rounded text-[9px] font-medium"
|
||||||
|
style={{ backgroundColor: 'rgba(255, 255, 255, 0.2)' }}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{removable && onRemove && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemove();
|
||||||
|
}}
|
||||||
|
className="w-3.5 h-3.5 flex items-center justify-center rounded-full hover:bg-white/20 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={10} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
src/components/features/Tags/TagFilter.tsx
Normal file
94
src/components/features/Tags/TagFilter.tsx
Normal file
@ -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 (
|
||||||
|
<div className={cn('border-b border-[var(--color-border-light)]', className)}>
|
||||||
|
<div className="px-3 py-2">
|
||||||
|
{/* 标题行 */}
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Tag size={12} className="text-[var(--color-text-muted)]" />
|
||||||
|
<span className="text-[10px] font-medium text-[var(--color-text-muted)] uppercase tracking-wider">
|
||||||
|
标签筛选
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{selectedTags.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={handleClear}
|
||||||
|
className="text-[10px] text-[var(--color-text-tertiary)] hover:text-[var(--color-primary)] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
清除
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 标签列表 - 紧凑胶囊式 */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-2">
|
||||||
|
<Loader2 size={14} className="animate-spin text-[var(--color-text-tertiary)]" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-1.5 flex-wrap">
|
||||||
|
{tags.slice(0, 10).map((tag) => {
|
||||||
|
const isSelected = selectedTags.includes(tag.name);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tag.name}
|
||||||
|
onClick={() => handleTagClick(tag.name)}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[10px] border transition-all cursor-pointer',
|
||||||
|
isSelected
|
||||||
|
? 'bg-[var(--color-primary-light)] border-[rgba(224,107,62,0.3)] text-[var(--color-primary)]'
|
||||||
|
: 'bg-transparent border-[var(--color-border)] text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] hover:border-[rgba(255,255,255,0.12)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate max-w-[60px]">{tag.name}</span>
|
||||||
|
{tag.count !== undefined && (
|
||||||
|
<span className="opacity-60 text-[9px]">{tag.count}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
217
src/components/features/Tags/TagManager.tsx
Normal file
217
src/components/features/Tags/TagManager.tsx
Normal file
@ -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<HTMLInputElement>(null);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className={cn('flex items-center gap-2', className)}>
|
||||||
|
<Loader2 size={14} className="animate-spin text-[var(--color-text-tertiary)]" />
|
||||||
|
<span className="text-xs text-[var(--color-text-tertiary)]">加载标签...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center gap-2', className)}>
|
||||||
|
{/* 标签展示区域 - 显示全部标签 */}
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<div
|
||||||
|
key={tag}
|
||||||
|
className="group inline-flex items-center gap-1 px-2 py-1 bg-[var(--color-bg-tertiary)] rounded-[4px] text-[11px] text-[var(--color-text-secondary)] transition-all hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-primary)]"
|
||||||
|
>
|
||||||
|
<span>{tag}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => removeTag(tag)}
|
||||||
|
className="hidden group-hover:flex items-center justify-center w-3 h-3 rounded-[2px] text-[var(--color-text-tertiary)] hover:text-[var(--color-primary)] hover:bg-[var(--color-primary-light)] transition-all"
|
||||||
|
>
|
||||||
|
<X size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 添加标签输入框 */}
|
||||||
|
{showInput && (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 管理标签下拉按钮 */}
|
||||||
|
<div className="relative" ref={menuRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setMenuOpen(!menuOpen)}
|
||||||
|
disabled={generating}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-[var(--color-bg-tertiary)] border border-[var(--color-border)] rounded-[4px] text-[11px] text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-primary)] hover:border-[rgba(255,255,255,0.12)] transition-all disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{generating ? (
|
||||||
|
<Loader2 size={12} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Tag size={12} />
|
||||||
|
)}
|
||||||
|
<span>{generating ? '生成中...' : '管理标签'}</span>
|
||||||
|
<ChevronDown size={12} className={cn(
|
||||||
|
'transition-transform',
|
||||||
|
menuOpen && 'rotate-180'
|
||||||
|
)} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 下拉菜单 */}
|
||||||
|
{menuOpen && (
|
||||||
|
<div className="absolute right-0 top-full mt-1 bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-[4px] shadow-lg py-1 z-20 min-w-[140px]">
|
||||||
|
<button
|
||||||
|
onClick={handleOpenAddTag}
|
||||||
|
disabled={tags.length >= 10}
|
||||||
|
className="w-full px-3 py-2 text-left text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-primary)] flex items-center gap-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Plus size={12} />
|
||||||
|
添加标签
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleGenerateTags}
|
||||||
|
disabled={generating}
|
||||||
|
className="w-full px-3 py-2 text-left text-xs text-[var(--color-primary)] hover:bg-[var(--color-primary-light)] flex items-center gap-2 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Sparkles size={12} />
|
||||||
|
AI 智能生成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 自动生成标签弹窗 */}
|
||||||
|
<AutoTagModal
|
||||||
|
isOpen={showModal}
|
||||||
|
onClose={handleCloseModal}
|
||||||
|
suggestedTags={suggestedTags}
|
||||||
|
currentTags={tags}
|
||||||
|
onApply={handleApplyTags}
|
||||||
|
loading={generating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
src/components/features/Tags/index.ts
Normal file
4
src/components/features/Tags/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export { TagBadge } from './TagBadge';
|
||||||
|
export { TagFilter } from './TagFilter';
|
||||||
|
export { TagManager } from './TagManager';
|
||||||
|
export { AutoTagModal } from './AutoTagModal';
|
||||||
Loading…
Reference in New Issue
Block a user