702 lines
26 KiB
TypeScript
702 lines
26 KiB
TypeScript
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||
import {
|
||
Send,
|
||
AlertCircle,
|
||
Sparkles,
|
||
Tag,
|
||
CheckCircle,
|
||
XCircle,
|
||
MapPin
|
||
} from 'lucide-react';
|
||
import { queryRagGrounding } from '../services/ragGroundingService';
|
||
import { RagGroundingQueryOptions, GroundingMetadata } from '../types/ragGrounding';
|
||
import { EnhancedChatMessageV2 } from './EnhancedChatMessageV2';
|
||
|
||
/**
|
||
* 聊天消息接口
|
||
*/
|
||
interface ChatMessage {
|
||
id: string;
|
||
type: 'user' | 'assistant';
|
||
content: string;
|
||
timestamp: Date;
|
||
status?: 'sending' | 'sent' | 'error';
|
||
metadata?: {
|
||
responseTime?: number;
|
||
modelUsed?: string;
|
||
sources?: Array<{
|
||
title: string;
|
||
uri?: string;
|
||
content?: any;
|
||
}>;
|
||
grounding_metadata?: GroundingMetadata;
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 聊天界面组件属性
|
||
*/
|
||
interface ChatInterfaceProps {
|
||
/** 会话ID,用于上下文保持 */
|
||
sessionId?: string;
|
||
/** 最大保留消息数量 */
|
||
maxMessages?: number;
|
||
/** 是否显示来源信息 */
|
||
showSources?: boolean;
|
||
/** 是否启用图片卡片 */
|
||
enableImageCards?: boolean;
|
||
/** 自定义样式类名 */
|
||
className?: string;
|
||
/** 占位符文本 */
|
||
placeholder?: string;
|
||
}
|
||
|
||
/**
|
||
* 聊天界面组件
|
||
* 遵循 Tauri 开发规范和 ag-ui 设计标准
|
||
* 支持上下文保留和最新3条记录限制
|
||
*/
|
||
export const ChatInterface: React.FC<ChatInterfaceProps> = ({
|
||
sessionId = 'default-session',
|
||
maxMessages = 3,
|
||
showSources = true,
|
||
enableImageCards = true,
|
||
className = '',
|
||
placeholder = '描述您的穿搭需求,比如场合、风格、身材特点等...'
|
||
}) => {
|
||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||
const [input, setInput] = useState('');
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||
const [allTags, setAllTags] = useState<string[]>([]);
|
||
const [isTagsExpanded, setIsTagsExpanded] = useState(false);
|
||
const [showTagBubble, setShowTagBubble] = useState(false);
|
||
const [bubbleTags, _setBubbleTags] = useState<{categories: string[], environment: string[]}>({categories: [], environment: []});
|
||
const [bubblePosition, _setBubblePosition] = useState<{x: number, y: number}>({x: 0, y: 0});
|
||
|
||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||
const chatContainerRef = useRef<HTMLDivElement>(null);
|
||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||
|
||
// 自动滚动到底部
|
||
const scrollToBottom = useCallback(() => {
|
||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||
}, []);
|
||
|
||
// 当消息更新时自动滚动
|
||
useEffect(() => {
|
||
scrollToBottom();
|
||
}, [messages, scrollToBottom]);
|
||
|
||
// 解析图片内容数据
|
||
const parseImageContent = useCallback((content: any) => {
|
||
if (!content || typeof content !== 'object') return null;
|
||
|
||
try {
|
||
return {
|
||
categories: content.categories || [],
|
||
description: content.description || '',
|
||
environment_tags: content.environment_tags || [],
|
||
environment_color: content.environment_color_pattern?.rgb_hex || '#f0f0f0',
|
||
models: content.models || [],
|
||
releaseDate: content.releaseDate || '',
|
||
runtime: content.runtime || ''
|
||
};
|
||
} catch (error) {
|
||
console.error('解析图片内容失败:', error);
|
||
return null;
|
||
}
|
||
}, []);
|
||
|
||
|
||
|
||
// 生成消息ID
|
||
const generateMessageId = useCallback(() => {
|
||
return `msg_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
||
}, []);
|
||
|
||
// 限制消息数量,保留最新的消息
|
||
const limitMessages = useCallback((newMessages: ChatMessage[]) => {
|
||
if (newMessages.length <= maxMessages * 2) {
|
||
return newMessages;
|
||
}
|
||
|
||
// 保留最新的 maxMessages 对话(用户+助手消息对)
|
||
const pairs: ChatMessage[][] = [];
|
||
let currentPair: ChatMessage[] = [];
|
||
|
||
for (let i = newMessages.length - 1; i >= 0; i--) {
|
||
const message = newMessages[i];
|
||
if (message.type === 'assistant') {
|
||
currentPair.unshift(message);
|
||
if (currentPair.length === 2) {
|
||
pairs.unshift([...currentPair]);
|
||
currentPair = [];
|
||
}
|
||
} else if (message.type === 'user') {
|
||
currentPair.unshift(message);
|
||
}
|
||
}
|
||
|
||
// 如果有未完成的对话,也要保留
|
||
if (currentPair.length > 0) {
|
||
pairs.unshift(currentPair);
|
||
}
|
||
|
||
// 只保留最新的 maxMessages 对话
|
||
const limitedPairs = pairs.slice(0, maxMessages);
|
||
return limitedPairs.flat();
|
||
}, [maxMessages]);
|
||
|
||
// 发送消息
|
||
const handleSendMessage = useCallback(async () => {
|
||
if (!input.trim() || isLoading) return;
|
||
|
||
const userMessage: ChatMessage = {
|
||
id: generateMessageId(),
|
||
type: 'user',
|
||
content: input.trim(),
|
||
timestamp: new Date(),
|
||
status: 'sent'
|
||
};
|
||
|
||
const assistantMessage: ChatMessage = {
|
||
id: generateMessageId(),
|
||
type: 'assistant',
|
||
content: '',
|
||
timestamp: new Date(),
|
||
status: 'sending'
|
||
};
|
||
|
||
// 添加用户消息和占位助手消息
|
||
setMessages(prev => limitMessages([...prev, userMessage, assistantMessage]));
|
||
setInput('');
|
||
setIsLoading(true);
|
||
setError(null);
|
||
|
||
try {
|
||
const options: RagGroundingQueryOptions = {
|
||
sessionId,
|
||
includeMetadata: showSources,
|
||
timeout: 30000,
|
||
includeHistory: true,
|
||
maxHistoryMessages: 7
|
||
};
|
||
|
||
const result = await queryRagGrounding(userMessage.content, options);
|
||
|
||
if (result.success && result.data) {
|
||
// 更新助手消息
|
||
setMessages(prev => prev.map(msg =>
|
||
msg.id === assistantMessage.id
|
||
? {
|
||
...msg,
|
||
content: result.data!.answer,
|
||
status: 'sent',
|
||
metadata: {
|
||
responseTime: result.data!.response_time_ms,
|
||
modelUsed: result.data!.model_used,
|
||
sources: result.data!.grounding_metadata?.sources,
|
||
grounding_metadata: result.data!.grounding_metadata
|
||
}
|
||
}
|
||
: msg
|
||
));
|
||
} else {
|
||
throw new Error(result.error || '查询失败');
|
||
}
|
||
} catch (err) {
|
||
const errorMessage = err instanceof Error ? err.message : '未知错误';
|
||
setError(errorMessage);
|
||
|
||
// 更新助手消息为错误状态
|
||
setMessages(prev => prev.map(msg =>
|
||
msg.id === assistantMessage.id
|
||
? {
|
||
...msg,
|
||
content: `抱歉,查询时出现错误:${errorMessage}`,
|
||
status: 'error'
|
||
}
|
||
: msg
|
||
));
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}, [input, isLoading, sessionId, showSources, generateMessageId, limitMessages]);
|
||
|
||
// 处理键盘事件
|
||
const handleKeyPress = useCallback((e: React.KeyboardEvent) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
handleSendMessage();
|
||
}
|
||
}, [handleSendMessage]);
|
||
|
||
|
||
// 重试最后一条消息
|
||
const handleRetry = useCallback(() => {
|
||
if (messages.length >= 2) {
|
||
const lastUserMessage = messages[messages.length - 2];
|
||
if (lastUserMessage.type === 'user') {
|
||
setInput(lastUserMessage.content);
|
||
setMessages(prev => prev.slice(0, -2));
|
||
if (inputRef.current) {
|
||
inputRef.current.focus();
|
||
}
|
||
}
|
||
}
|
||
}, [messages]);
|
||
|
||
|
||
|
||
// 提取所有图片的标签
|
||
const extractAllTags = useCallback(() => {
|
||
const tagSet = new Set<string>();
|
||
|
||
messages.forEach(message => {
|
||
if (message.type === 'assistant' && message.metadata?.sources) {
|
||
message.metadata.sources.forEach(source => {
|
||
const imageData = parseImageContent(source.content);
|
||
if (imageData) {
|
||
// 添加分类标签
|
||
imageData.categories.forEach((tag: string) => tagSet.add(tag));
|
||
// 添加环境标签
|
||
imageData.environment_tags.forEach((tag: string) => tagSet.add(tag));
|
||
// 添加模特相关标签
|
||
imageData.models.forEach((model: any) => {
|
||
if (model.products) {
|
||
model.products.forEach((product: any) => {
|
||
if (product.category) {
|
||
product.category.forEach((cat: string) => tagSet.add(cat));
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
return Array.from(tagSet).sort();
|
||
}, [messages, parseImageContent]);
|
||
|
||
// 当消息更新时,更新标签列表
|
||
useEffect(() => {
|
||
const extractedTags = extractAllTags();
|
||
// 合并提取的标签和已选择的标签,确保所有选中的标签都在列表中
|
||
const combinedTags = [...new Set([...extractedTags, ...selectedTags])];
|
||
setAllTags(combinedTags);
|
||
}, [extractAllTags, selectedTags]);
|
||
|
||
// 切换标签选择状态
|
||
const toggleTag = useCallback((tag: string) => {
|
||
setSelectedTags(prev => {
|
||
const newTags = prev.includes(tag)
|
||
? prev.filter(t => t !== tag)
|
||
: [...prev, tag];
|
||
return newTags;
|
||
});
|
||
}, []);
|
||
|
||
// 切换标签展开状态
|
||
const toggleTagsExpanded = useCallback(() => {
|
||
setIsTagsExpanded(prev => !prev);
|
||
}, []);
|
||
|
||
// 关闭标签气泡
|
||
const closeTagBubble = useCallback(() => {
|
||
setShowTagBubble(false);
|
||
}, []);
|
||
|
||
// 清空选中的标签
|
||
const clearSelectedTags = useCallback(() => {
|
||
setSelectedTags([]);
|
||
}, []);
|
||
|
||
// 根据选中的标签生成搜索文本
|
||
const generateSearchFromTags = useCallback(() => {
|
||
if (selectedTags.length === 0) return '';
|
||
|
||
const tagGroups = {
|
||
style: [] as string[],
|
||
color: [] as string[],
|
||
occasion: [] as string[],
|
||
other: [] as string[]
|
||
};
|
||
|
||
selectedTags.forEach(tag => {
|
||
if (tag.includes('色') || tag.includes('黑') || tag.includes('白') || tag.includes('红') || tag.includes('蓝') || tag.includes('粉') || tag.includes('绿') || tag.includes('黄') || tag.includes('紫') || tag.includes('橙')) {
|
||
tagGroups.color.push(tag);
|
||
} else if (tag.includes('职场') || tag.includes('约会') || tag.includes('度假') || tag.includes('休闲') || tag.includes('正式') || tag.includes('运动')) {
|
||
tagGroups.occasion.push(tag);
|
||
} else if (tag.includes('甜美') || tag.includes('优雅') || tag.includes('性感') || tag.includes('简约') || tag.includes('复古') || tag.includes('时尚')) {
|
||
tagGroups.style.push(tag);
|
||
} else {
|
||
tagGroups.other.push(tag);
|
||
}
|
||
});
|
||
|
||
let searchText = '我想要';
|
||
if (tagGroups.style.length > 0) {
|
||
searchText += `${tagGroups.style.join('、')}风格的`;
|
||
}
|
||
if (tagGroups.color.length > 0) {
|
||
searchText += `${tagGroups.color.join('、')}`;
|
||
}
|
||
if (tagGroups.occasion.length > 0) {
|
||
searchText += `适合${tagGroups.occasion.join('、')}的`;
|
||
}
|
||
if (tagGroups.other.length > 0) {
|
||
searchText += `${tagGroups.other.join('、')}`;
|
||
}
|
||
searchText += '穿搭推荐';
|
||
|
||
return searchText;
|
||
}, [selectedTags]);
|
||
|
||
return (
|
||
<div className={`flex flex-col h-full enhanced-chat-interface rounded-xl shadow-lg border border-gray-200 overflow-y-auto ${className}`}>
|
||
{/* 聊天消息区域 */}
|
||
<div
|
||
ref={chatContainerRef}
|
||
className="flex-1 p-4 space-y-6 bg-transparent"
|
||
>
|
||
{messages.length === 0 ? (
|
||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||
<div className="w-20 h-20 bg-gradient-to-br from-pink-100 to-purple-100 rounded-full flex items-center justify-center mb-6">
|
||
<Sparkles className="w-10 h-10 text-pink-500" />
|
||
</div>
|
||
<p className="text-gray-600 max-w-lg mb-8 text-lg leading-relaxed">
|
||
我是您的专属时尚顾问,基于丰富的女装穿搭知识库,为您提供个性化的搭配建议和时尚指导。
|
||
</p>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-2xl">
|
||
{[
|
||
'夏日清新穿搭推荐',
|
||
'职场女性如何搭配?',
|
||
'约会穿什么显气质?',
|
||
'小个子女生穿搭技巧',
|
||
'秋冬外套怎么选?',
|
||
'显瘦穿搭有什么秘诀?',
|
||
'色彩搭配的基本原则',
|
||
'配饰如何提升整体造型?'
|
||
].map((suggestion, index) => (
|
||
<button
|
||
key={index}
|
||
onClick={() => setInput(suggestion)}
|
||
className="px-3 py-2 text-sm text-pink-600 bg-pink-50 hover:bg-pink-100 rounded-lg transition-all duration-200 hover:shadow-md hover:scale-105"
|
||
>
|
||
{suggestion}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{messages.map((message) => (
|
||
<EnhancedChatMessageV2
|
||
key={message.id}
|
||
message={message}
|
||
showSources={showSources}
|
||
enableMaterialCards={enableImageCards}
|
||
enableReferences={true}
|
||
/>
|
||
))}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* 错误提示 */}
|
||
{error && (
|
||
<div className="mx-4 mb-2 p-3 bg-red-50 border border-red-200 rounded-lg flex items-center gap-2">
|
||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0" />
|
||
<span className="text-red-700 text-sm">{error}</span>
|
||
<button
|
||
onClick={handleRetry}
|
||
className="ml-auto text-red-600 hover:text-red-800 text-sm font-medium"
|
||
>
|
||
重试
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* 固定底部输入区域 */}
|
||
<div className="flex-shrink-0 bg-white border-t border-gray-200 rounded-b-xl">
|
||
{/* 标签选择区域 */}
|
||
{allTags.length > 0 && (
|
||
<div className="border-b border-gray-100 bg-gradient-to-r from-pink-50 to-purple-50">
|
||
{/* 标签头部 - 始终显示 */}
|
||
<div className="px-4 py-3">
|
||
<div className="flex items-center justify-between">
|
||
{/* 左侧:标签标题和已选标签 */}
|
||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||
<Tag className="w-4 h-4 text-pink-500 flex-shrink-0" />
|
||
<span className="text-sm font-medium text-gray-700 flex-shrink-0">
|
||
穿搭标签 ({selectedTags.length} 已选)
|
||
</span>
|
||
|
||
{/* 已选标签预览 */}
|
||
{selectedTags.length > 0 && (
|
||
<div className="flex items-center gap-1 ml-2 overflow-hidden">
|
||
{selectedTags.slice(0, 3).map((tag, index) => (
|
||
<span
|
||
key={index}
|
||
className="inline-block px-2 py-1 bg-pink-500 text-white text-xs rounded-full flex-shrink-0"
|
||
>
|
||
{tag}
|
||
</span>
|
||
))}
|
||
{selectedTags.length > 3 && (
|
||
<span className="text-xs text-gray-500 flex-shrink-0">+{selectedTags.length - 3}</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 右侧:操作按钮和展开按钮 */}
|
||
<div className="flex items-center gap-2 flex-shrink-0">
|
||
{/* 操作按钮 */}
|
||
{selectedTags.length > 0 && (
|
||
<>
|
||
<button
|
||
onClick={() => setInput(generateSearchFromTags())}
|
||
className="text-xs text-pink-600 hover:text-pink-800 bg-white hover:bg-pink-50 px-3 py-1 rounded-full transition-all duration-200 shadow-sm hover:shadow-md border border-pink-200"
|
||
>
|
||
生成搜索
|
||
</button>
|
||
<button
|
||
onClick={clearSelectedTags}
|
||
className="text-xs text-gray-500 hover:text-gray-700 bg-white hover:bg-gray-100 px-3 py-1 rounded-full transition-all duration-200 shadow-sm border border-gray-200"
|
||
>
|
||
清空
|
||
</button>
|
||
</>
|
||
)}
|
||
|
||
{/* 展开/折叠按钮 */}
|
||
<button
|
||
onClick={toggleTagsExpanded}
|
||
className="flex items-center gap-1 hover:bg-white hover:bg-opacity-50 rounded-lg px-2 py-1 transition-all duration-200"
|
||
>
|
||
{!isTagsExpanded && (
|
||
<div className="bg-pink-500 text-white px-2 py-1 rounded-full text-xs font-medium shadow-md">
|
||
展开
|
||
</div>
|
||
)}
|
||
<svg
|
||
className={`w-4 h-4 text-pink-500 transition-transform duration-200 ${isTagsExpanded ? '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>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 标签内容 - 可折叠 */}
|
||
{isTagsExpanded && (
|
||
<div className="px-4 pb-4">
|
||
{/* 标签列表 */}
|
||
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto">
|
||
{allTags.map((tag, index) => {
|
||
const isSelected = selectedTags.includes(tag);
|
||
return (
|
||
<button
|
||
key={index}
|
||
onClick={() => toggleTag(tag)}
|
||
className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium transition-all duration-200 ${
|
||
isSelected
|
||
? 'bg-pink-500 text-white shadow-md transform scale-105'
|
||
: 'bg-white text-gray-700 hover:bg-pink-100 hover:text-pink-700 hover:shadow-sm border border-gray-200'
|
||
}`}
|
||
>
|
||
<span>{tag}</span>
|
||
{isSelected && (
|
||
<CheckCircle className="w-3 h-3" />
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 输入区域 */}
|
||
<div className="p-4">
|
||
<div className="flex gap-3 items-start">
|
||
<div className="flex-1 relative">
|
||
<textarea
|
||
ref={inputRef}
|
||
value={input}
|
||
onChange={(e) => setInput(e.target.value)}
|
||
onKeyDown={handleKeyPress}
|
||
placeholder={placeholder}
|
||
disabled={isLoading}
|
||
className="w-full px-4 py-3 border border-gray-300 rounded-xl resize-none focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||
rows={1}
|
||
style={{
|
||
minHeight: '48px',
|
||
maxHeight: '120px',
|
||
height: 'auto'
|
||
}}
|
||
onInput={(e) => {
|
||
const target = e.target as HTMLTextAreaElement;
|
||
target.style.height = 'auto';
|
||
target.style.height = `${Math.min(target.scrollHeight, 120)}px`;
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<button
|
||
onClick={handleSendMessage}
|
||
disabled={!input.trim() || isLoading}
|
||
className="px-4 bg-pink-500 text-white rounded-xl hover:bg-pink-600 focus:outline-none focus:ring-2 focus:ring-pink-500 focus:ring-offset-2 disabled:bg-gray-300 disabled:cursor-not-allowed transition-all duration-200 flex items-center justify-center flex-shrink-0"
|
||
style={{ height: '48px', minHeight: '48px' }}
|
||
>
|
||
{isLoading ? (
|
||
<div className="animate-spin w-5 h-5 border-2 border-white border-t-transparent rounded-full"></div>
|
||
) : (
|
||
<Send className="w-5 h-5" />
|
||
)}
|
||
</button>
|
||
</div>
|
||
|
||
<div className="mt-2 text-center text-xs text-gray-500">
|
||
<span>按 Enter 发送,Shift + Enter 换行</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 标签详情气泡卡片 */}
|
||
{showTagBubble && (
|
||
<>
|
||
{/* 背景遮罩 */}
|
||
<div
|
||
className="fixed inset-0 z-40"
|
||
onClick={closeTagBubble}
|
||
/>
|
||
|
||
{/* 气泡卡片 */}
|
||
<div
|
||
className="fixed z-50 bg-white rounded-xl shadow-2xl border border-gray-200 max-w-sm w-80 transform -translate-x-1/2 -translate-y-full"
|
||
style={{
|
||
left: bubblePosition.x,
|
||
top: bubblePosition.y,
|
||
}}
|
||
>
|
||
{/* 气泡箭头 */}
|
||
<div className="absolute top-full left-1/2 transform -translate-x-1/2">
|
||
<div className="w-0 h-0 border-l-8 border-r-8 border-t-8 border-l-transparent border-r-transparent border-t-white"></div>
|
||
<div className="absolute -top-1 left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-8 border-r-8 border-t-8 border-l-transparent border-r-transparent border-t-gray-200"></div>
|
||
</div>
|
||
|
||
{/* 卡片头部 */}
|
||
<div className="flex items-center justify-between p-3 border-b border-gray-100 bg-gradient-to-r from-pink-50 to-purple-50 rounded-t-xl">
|
||
<div className="flex items-center gap-2">
|
||
<Tag className="w-4 h-4 text-pink-500" />
|
||
<h3 className="text-sm font-semibold text-gray-800">标签详情</h3>
|
||
</div>
|
||
<button
|
||
onClick={closeTagBubble}
|
||
className="p-1 hover:bg-gray-100 rounded-full transition-colors duration-200"
|
||
>
|
||
<XCircle className="w-4 h-4 text-gray-500" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* 卡片内容 */}
|
||
<div className="p-3 max-h-64 overflow-y-auto">
|
||
{/* 分类标签 */}
|
||
{bubbleTags.categories.length > 0 && (
|
||
<div className="mb-3">
|
||
<h4 className="text-xs font-medium text-gray-700 mb-2 flex items-center gap-1">
|
||
<Tag className="w-3 h-3 text-blue-500" />
|
||
分类标签 ({bubbleTags.categories.length})
|
||
</h4>
|
||
<div className="flex flex-wrap gap-1">
|
||
{bubbleTags.categories.map((category, index) => {
|
||
const isSelected = selectedTags.includes(category);
|
||
return (
|
||
<button
|
||
key={index}
|
||
onClick={() => toggleTag(category)}
|
||
className={`inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full transition-all duration-200 border ${
|
||
isSelected
|
||
? 'bg-pink-500 text-white shadow-md border-pink-600'
|
||
: 'bg-blue-50 text-blue-700 hover:bg-pink-100 hover:text-pink-700 border-blue-200 hover:border-pink-300'
|
||
}`}
|
||
>
|
||
<Tag className="w-2 h-2" />
|
||
{category}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 环境标签 */}
|
||
{bubbleTags.environment.length > 0 && (
|
||
<div>
|
||
<h4 className="text-xs font-medium text-gray-700 mb-2 flex items-center gap-1">
|
||
<MapPin className="w-3 h-3 text-green-500" />
|
||
环境标签 ({bubbleTags.environment.length})
|
||
</h4>
|
||
<div className="flex flex-wrap gap-1">
|
||
{bubbleTags.environment.map((tag, index) => {
|
||
const isSelected = selectedTags.includes(tag);
|
||
return (
|
||
<button
|
||
key={index}
|
||
onClick={() => toggleTag(tag)}
|
||
className={`inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full transition-all duration-200 border ${
|
||
isSelected
|
||
? 'bg-pink-500 text-white shadow-md border-pink-600'
|
||
: 'bg-green-50 text-green-700 hover:bg-pink-100 hover:text-pink-700 border-green-200 hover:border-pink-300'
|
||
}`}
|
||
>
|
||
<MapPin className="w-2 h-2" />
|
||
{tag}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 空状态 */}
|
||
{bubbleTags.categories.length === 0 && bubbleTags.environment.length === 0 && (
|
||
<div className="text-center py-4 text-gray-500">
|
||
<Tag className="w-8 h-8 mx-auto mb-1 text-gray-300" />
|
||
<p className="text-xs">暂无标签信息</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 卡片底部 */}
|
||
<div className="px-3 py-2 border-t border-gray-100 bg-gray-50 rounded-b-xl">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs text-gray-600">
|
||
已选 {selectedTags.length} 个
|
||
</span>
|
||
<button
|
||
onClick={closeTagBubble}
|
||
className="px-3 py-1 bg-pink-500 text-white rounded-md hover:bg-pink-600 transition-colors duration-200 text-xs"
|
||
>
|
||
确定
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|