diff --git a/src/components/MeetingTimeline.css b/src/components/MeetingTimeline.css index 3dff265..c5990ab 100644 --- a/src/components/MeetingTimeline.css +++ b/src/components/MeetingTimeline.css @@ -278,12 +278,33 @@ margin-bottom: 1rem; } +/* Meeting Title and Tags Layout */ +.title-and-tags { + flex: 1; +} + .meeting-title { margin: 0 0 0.75rem 0; color: #1e293b; font-size: 1.25rem; font-weight: 600; line-height: 1.4; + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.meeting-title .inline-tags { + margin: 0; +} + +.meeting-title .inline-tags .tag-item { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + color: white; + font-size: 10px; + padding: 2px 8px; + border-radius: 12px; } .meeting-meta { diff --git a/src/components/MeetingTimeline.jsx b/src/components/MeetingTimeline.jsx index e159eb8..b3f9bb8 100644 --- a/src/components/MeetingTimeline.jsx +++ b/src/components/MeetingTimeline.jsx @@ -5,6 +5,7 @@ import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; import rehypeSanitize from 'rehype-sanitize'; +import TagDisplay from './TagDisplay'; import './MeetingTimeline.css'; const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting }) => { @@ -116,7 +117,20 @@ const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting }) => {
-

{meeting.title}

+
+

+ {meeting.title} + {meeting.tags && meeting.tags.length > 0 && ( + tag.name)} + size="small" + maxDisplay={3} + showIcon={true} + className="inline-tags" + /> + )} +

+
{isCreator && (
+ ); + })} +
+ +
+ 已选择: + {selectedTags.length > 0 ? ( + selectedTags.map((tag, index) => ( + handleTagClick(tag)} + > + {tag} + × + + )) + ) : ( + 暂未选择任何标签 + )} +
+
+ ); +}; + +export default TagCloud; \ No newline at end of file diff --git a/src/components/TagDisplay.css b/src/components/TagDisplay.css new file mode 100644 index 0000000..d290368 --- /dev/null +++ b/src/components/TagDisplay.css @@ -0,0 +1,136 @@ +.tag-display { + display: inline-flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.tag-display.small { + gap: 4px; +} + +.tag-display.large { + gap: 8px; +} + +.tag-display-icon { + color: #6b7280; + flex-shrink: 0; +} + +.tag-list { + display: inline-flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; +} + +.tag-item { + display: inline-block; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + font-size: 11px; + font-weight: 500; + border-radius: 12px; + padding: 2px 8px; + white-space: nowrap; + max-width: 100px; + overflow: hidden; + text-overflow: ellipsis; + transition: all 0.2s ease; +} + +.tag-display.small .tag-item { + font-size: 10px; + padding: 1px 6px; + border-radius: 10px; + max-width: 80px; +} + +.tag-display.large .tag-item { + font-size: 12px; + padding: 4px 10px; + border-radius: 14px; + max-width: 120px; +} + +.tag-item.clickable { + cursor: pointer; +} + +.tag-item.clickable:hover { + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + background: linear-gradient(135deg, #5a67d8 0%, #6b46c1 100%); +} + +.tag-more { + display: inline-block; + background: #e5e7eb; + color: #6b7280; + font-size: 11px; + font-weight: 500; + border-radius: 12px; + padding: 2px 8px; + white-space: nowrap; +} + +.tag-display.small .tag-more { + font-size: 10px; + padding: 1px 6px; + border-radius: 10px; +} + +.tag-display.large .tag-more { + font-size: 12px; + padding: 4px 10px; + border-radius: 14px; +} + +/* 适用于卡片内的标签显示 */ +.meeting-card .tag-display { + margin-top: 8px; +} + +.meeting-card .tag-display .tag-item { + background: rgba(79, 70, 229, 0.1); + color: #4f46e5; + border: 1px solid rgba(79, 70, 229, 0.2); +} + +.meeting-card .tag-display .tag-item:hover { + background: rgba(79, 70, 229, 0.15); + border-color: rgba(79, 70, 229, 0.3); +} + +/* 适用于详情页标题的标签显示 */ +.meeting-header .tag-display { + margin-left: 12px; +} + +.meeting-header .tag-display .tag-item { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); +} + +/* 响应式设计 */ +@media (max-width: 768px) { + .tag-display { + gap: 3px; + } + + .tag-list { + gap: 3px; + } + + .tag-item { + font-size: 10px; + padding: 1px 6px; + max-width: 70px; + } + + .tag-display.large .tag-item { + font-size: 11px; + padding: 2px 8px; + max-width: 90px; + } +} \ No newline at end of file diff --git a/src/components/TagDisplay.jsx b/src/components/TagDisplay.jsx new file mode 100644 index 0000000..d7586bc --- /dev/null +++ b/src/components/TagDisplay.jsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { Tag } from 'lucide-react'; +import './TagDisplay.css'; + +const TagDisplay = ({ + tags = [], + size = 'medium', + maxDisplay = null, + showIcon = false, + clickable = false, + onTagClick = null, + className = '' +}) => { + // 确保tags是数组格式 + const tagArray = Array.isArray(tags) ? tags : + typeof tags === 'string' ? tags.split(',').map(t => t.trim()).filter(t => t) : []; + + if (tagArray.length === 0) { + return null; + } + + // 如果设置了最大显示数量 + const displayTags = maxDisplay && tagArray.length > maxDisplay ? + tagArray.slice(0, maxDisplay) : tagArray; + const hasMore = maxDisplay && tagArray.length > maxDisplay; + const moreCount = hasMore ? tagArray.length - maxDisplay : 0; + + const handleTagClick = (tag, event) => { + if (clickable && onTagClick) { + event.preventDefault(); + event.stopPropagation(); + onTagClick(tag); + } + }; + + return ( +
+ {showIcon && ( + + )} + +
+ {displayTags.map((tag, index) => ( + handleTagClick(tag, e)} + title={tag} + > + {tag} + + ))} + + {hasMore && ( + + +{moreCount} + + )} +
+
+ ); +}; + +export default TagDisplay; \ No newline at end of file diff --git a/src/components/TagEditor.css b/src/components/TagEditor.css new file mode 100644 index 0000000..bbff5b4 --- /dev/null +++ b/src/components/TagEditor.css @@ -0,0 +1,164 @@ +.tag-editor-container { + position: relative; + width: 100%; +} + +.tag-editor { + border: 2px solid #e2e8f0; + border-radius: 8px; + background: #ffffff; + min-height: 44px; + transition: all 0.2s ease; + position: relative; +} + +.tag-editor.disabled { + background-color: #f8fafc; + border-color: #e2e8f0; + cursor: not-allowed; +} + +.tag-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 8px; + align-items: center; + min-height: 28px; +} + +.tag-chip { + display: inline-flex; + align-items: center; + gap: 4px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 4px 8px; + border-radius: 20px; + font-size: 12px; + font-weight: 500; + white-space: nowrap; + transition: all 0.2s ease; +} + +.tag-chip:hover { + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +.tag-text { + line-height: 1; +} + +.tag-remove { + background: none; + border: none; + color: white; + cursor: pointer; + padding: 2px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.2s ease; + margin-left: 2px; +} + +.tag-remove:hover { + background-color: rgba(255, 255, 255, 0.2); +} + +.tag-input { + border: none; + outline: none; + background: transparent; + font-size: 14px; + padding: 4px; + flex: 1; + min-width: 120px; + color: #374151; + border-radius: 4px; + transition: all 0.2s ease; +} + +.tag-input:focus { + background: #f8fafc; + box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.1); +} + +.tag-input::placeholder { + color: #9ca3af; +} + +.tag-suggestions { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: white; + border: 1px solid #e2e8f0; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + z-index: 1000; + max-height: 200px; + overflow-y: auto; + margin-top: 4px; +} + +.tag-suggestion { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + cursor: pointer; + transition: background-color 0.2s ease; + border-bottom: 1px solid #f1f5f9; +} + +.tag-suggestion:last-child { + border-bottom: none; +} + +.tag-suggestion:hover { + background-color: #f8fafc; +} + +.tag-suggestion span { + font-size: 14px; + color: #374151; +} + +.tag-editor-hint { + margin-top: 6px; +} + +.tag-editor-hint small { + color: #6b7280; + font-size: 12px; +} + +/* 响应式设计 */ +@media (max-width: 768px) { + .tag-list { + gap: 4px; + padding: 6px; + } + + .tag-chip { + font-size: 11px; + padding: 3px 6px; + } + + .tag-input { + font-size: 13px; + min-width: 100px; + } + + .tag-suggestions { + max-height: 150px; + } + + .tag-suggestion { + padding: 6px 10px; + } +} \ No newline at end of file diff --git a/src/components/TagEditor.jsx b/src/components/TagEditor.jsx new file mode 100644 index 0000000..fe91b4f --- /dev/null +++ b/src/components/TagEditor.jsx @@ -0,0 +1,181 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Tag, X, Plus } from 'lucide-react'; +import apiClient from '../utils/apiClient'; +import { buildApiUrl } from '../config/api'; +import './TagEditor.css'; + +const TagEditor = ({ + value = '', + onChange, + placeholder = '输入标签,按回车或逗号分隔', + disabled = false +}) => { + const [inputValue, setInputValue] = useState(''); + const [tags, setTags] = useState([]); + const [availableTags, setAvailableTags] = useState([]); + const [showSuggestions, setShowSuggestions] = useState(false); + const [filteredSuggestions, setFilteredSuggestions] = useState([]); + const inputRef = useRef(null); + const containerRef = useRef(null); + + // 初始化标签 + useEffect(() => { + if (value) { + const initialTags = value.split(',').map(tag => tag.trim()).filter(tag => tag); + setTags(initialTags); + } else { + setTags([]); + } + }, [value]); + + // 获取已有标签库 + useEffect(() => { + fetchAvailableTags(); + }, []); + + // 处理点击外部关闭建议 + useEffect(() => { + const handleClickOutside = (event) => { + if (containerRef.current && !containerRef.current.contains(event.target)) { + setShowSuggestions(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + const fetchAvailableTags = async () => { + try { + const response = await apiClient.get(buildApiUrl('/api/tags/')); + setAvailableTags(response.data.map(tag => tag.name)); + } catch (err) { + console.error('Error fetching tags:', err); + } + }; + + const updateParent = (newTags) => { + const tagString = newTags.join(', '); + onChange(tagString); + }; + + const addTag = (tagName) => { + const trimmedTag = tagName.trim(); + if (trimmedTag && !tags.includes(trimmedTag)) { + const newTags = [...tags, trimmedTag]; + setTags(newTags); + updateParent(newTags); + } + }; + + const removeTag = (indexToRemove) => { + const newTags = tags.filter((_, index) => index !== indexToRemove); + setTags(newTags); + updateParent(newTags); + }; + + const handleInputChange = (e) => { + const value = e.target.value; + setInputValue(value); + + // 过滤建议 + if (value.trim()) { + const filtered = availableTags.filter(tag => + tag.toLowerCase().includes(value.toLowerCase()) && + !tags.includes(tag) + ); + setFilteredSuggestions(filtered); + setShowSuggestions(true); + } else { + setShowSuggestions(false); + } + }; + + const handleInputKeyDown = (e) => { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + if (inputValue.trim()) { + addTag(inputValue); + setInputValue(''); + setShowSuggestions(false); + } + } else if (e.key === 'Backspace' && !inputValue && tags.length > 0) { + // 删除最后一个标签 + removeTag(tags.length - 1); + } + }; + + const handleSuggestionClick = (suggestion) => { + addTag(suggestion); + setInputValue(''); + setShowSuggestions(false); + inputRef.current?.focus(); + }; + + const handleInputFocus = () => { + if (inputValue.trim()) { + setShowSuggestions(true); + } + }; + + return ( +
+
+
+ {tags.map((tag, index) => ( +
+ {tag} + {!disabled && ( + + )} +
+ ))} + {!disabled && ( + + )} +
+ + {showSuggestions && filteredSuggestions.length > 0 && ( +
+ {filteredSuggestions.slice(0, 10).map((suggestion, index) => ( +
handleSuggestionClick(suggestion)} + > + + {suggestion} +
+ ))} +
+ )} +
+ +
+ + 输入标签名称,按回车或逗号添加。点击建议的标签快速选择。 + +
+
+ ); +}; + +export default TagEditor; \ No newline at end of file diff --git a/src/pages/CreateMeeting.css b/src/pages/CreateMeeting.css index b53450a..fbd387d 100644 --- a/src/pages/CreateMeeting.css +++ b/src/pages/CreateMeeting.css @@ -38,24 +38,24 @@ .create-card-header { padding: 2rem; - background: linear-gradient(135deg, #f5f7fa, #eef2f7); + background: linear-gradient(135deg, #3b82f6 0%, #1e40af 100%); border-bottom: 1px solid #e2e8f0; text-align: center; + color: white; } .create-card-header h1 { font-size: 2rem; font-weight: 700; - color: #1e293b; + color: white; margin: 0 0 0.5rem 0; } .create-card-header p { - color: #64748b; - margin: 0; font-size: 1rem; + color: rgba(255, 255, 255, 0.9); + margin: 0; } - .create-form { padding: 2rem; } @@ -302,19 +302,19 @@ display: inline-flex; align-items: center; padding: 0.75rem 2rem; - background: linear-gradient(135deg, #667eea, #764ba2); + background: linear-gradient(135deg, #3b82f6, #1e40af); color: white; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; transition: all 0.3s ease; - box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3); + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3); } .btn-submit:hover:not(:disabled) { transform: translateY(-2px); - box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4); + box-shadow: 0 4px 16px rgba(59, 130, 246, 0.4); } .btn-submit:disabled { diff --git a/src/pages/CreateMeeting.jsx b/src/pages/CreateMeeting.jsx index d1dbf03..bf9941d 100644 --- a/src/pages/CreateMeeting.jsx +++ b/src/pages/CreateMeeting.jsx @@ -1,9 +1,10 @@ import React, { useState, useEffect } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import apiClient from '../utils/apiClient'; -import { ArrowLeft, Upload, Users, Calendar, FileText, X, User, Plus } from 'lucide-react'; +import { ArrowLeft, Upload, Users, Calendar, FileText, X, User, Plus, Tag } from 'lucide-react'; import { buildApiUrl, API_ENDPOINTS } from '../config/api'; import DateTimePicker from '../components/DateTimePicker'; +import TagEditor from '../components/TagEditor'; import './CreateMeeting.css'; const CreateMeeting = ({ user }) => { @@ -11,7 +12,8 @@ const CreateMeeting = ({ user }) => { const [formData, setFormData] = useState({ title: '', meeting_time: '', - attendees: [] + attendees: [], + tags: '' }); const [availableUsers, setAvailableUsers] = useState([]); const [userSearch, setUserSearch] = useState(''); @@ -98,7 +100,8 @@ const CreateMeeting = ({ user }) => { user_id: user.user_id, title: formData.title, meeting_time: formData.meeting_time || null, - attendee_ids: formData.attendees.map(a => a.user_id) + attendee_ids: formData.attendees.map(a => a.user_id), + tags: formData.tags }; const response = await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.CREATE), meetingData); @@ -182,6 +185,18 @@ const CreateMeeting = ({ user }) => { />
+
+ + setFormData(prev => ({ ...prev, tags: value }))} + placeholder="输入标签,按回车或逗号分隔" + /> +
+