diff --git a/.DS_Store b/.DS_Store index 72e91dd..75641e9 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/dist.zip b/dist.zip index 26e9148..02d5f6a 100644 Binary files a/dist.zip and b/dist.zip differ diff --git a/package-lock.json b/package-lock.json index 84b445d..8760045 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "markmap-common": "^0.18.9", "markmap-lib": "^0.18.12", "markmap-view": "^0.18.12", + "qrcode.react": "^4.2.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", @@ -5142,6 +5143,15 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/raf": { "version": "3.4.1", "resolved": "https://registry.npmmirror.com/raf/-/raf-3.4.1.tgz", diff --git a/package.json b/package.json index f7a5f90..fab4a5d 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "markmap-common": "^0.18.9", "markmap-lib": "^0.18.12", "markmap-view": "^0.18.12", + "qrcode.react": "^4.2.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", diff --git a/src/App.jsx b/src/App.jsx index f1a2910..790804f 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -5,6 +5,7 @@ import { buildApiUrl, API_ENDPOINTS } from './config/api'; import HomePage from './pages/HomePage'; import Dashboard from './pages/Dashboard'; import MeetingDetails from './pages/MeetingDetails'; +import MeetingPreview from './pages/MeetingPreview'; import CreateMeeting from './pages/CreateMeeting'; import EditMeeting from './pages/EditMeeting'; import AdminManagement from './pages/AdminManagement'; @@ -95,6 +96,7 @@ function App() { user ? : } /> } /> + } /> diff --git a/src/components/ExpandSearchBox.css b/src/components/ExpandSearchBox.css new file mode 100644 index 0000000..413a809 --- /dev/null +++ b/src/components/ExpandSearchBox.css @@ -0,0 +1,104 @@ +/* 可展开搜索框组件样式 */ + +/* 只读标题样式(当没有搜索功能时) */ +.expand-search-readonly { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.expand-search-readonly h3 { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + color: #1e293b; +} + +.expand-search-readonly svg { + color: #667eea; +} + +/* 可展开的紧凑搜索框 */ +.expand-search-box { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.85rem; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #f8fafc; + cursor: pointer; + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); + width: fit-content; + max-width: 150px; + height: 40px; +} + +.expand-search-box .search-icon { + color: #667eea; + flex-shrink: 0; + transition: color 0.3s ease; + display: flex; + align-items: center; +} + +.expand-search-box:hover:not(.expanded) { + border-color: #cbd5e1; + background: white; +} + +.expand-search-box:hover:not(.expanded) .search-icon { + color: #5a67d8; +} + +.expand-search-box .search-placeholder { + font-size: 0.9rem; + font-weight: 600; + color: #1e293b; + white-space: nowrap; + user-select: none; + line-height: 1.4; +} + +/* 展开状态 */ +.expand-search-box.expanded { + cursor: text; + max-width: 100%; + width: 100%; + border-color: #e2e8f0; + background: white; +} + +.expand-search-box.expanded .search-icon { + color: #667eea; +} + +/* Ant Design Input 样式定制 */ +.expand-search-box .search-input-antd { + flex: 1; + height: 32px; + border-radius: 6px; +} + +.expand-search-box .search-input-antd .ant-input { + font-size: 0.9rem; +} + +/* 移除默认的焦点阴影,使用自定义样式 */ +.expand-search-box .search-input-antd:focus, +.expand-search-box .search-input-antd:focus-within { + border-color: #667eea; + box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1); +} + +/* 响应式设计 */ +@media (max-width: 768px) { + .expand-search-box { + width: 100%; + max-width: 100%; + } + + .expand-search-box.expanded { + width: 100%; + } +} diff --git a/src/components/ExpandSearchBox.jsx b/src/components/ExpandSearchBox.jsx new file mode 100644 index 0000000..8e4e212 --- /dev/null +++ b/src/components/ExpandSearchBox.jsx @@ -0,0 +1,120 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Input } from 'antd'; +import { Search } from 'lucide-react'; +import './ExpandSearchBox.css'; + +const ExpandSearchBox = ({ + searchQuery = '', + onSearchChange = null, + placeholder = '搜索会议名称或发起人...', + collapsedText = '会议搜索', + showIcon = true, + realTimeSearch = false, // 改为默认false,避免频繁刷新 + debounceDelay = 500 // 防抖延迟时间(毫秒) +}) => { + const [isExpanded, setIsExpanded] = useState(false); + const [inputValue, setInputValue] = useState(searchQuery); + const debounceTimerRef = useRef(null); + + // 同步外部 searchQuery 的变化 + useEffect(() => { + setInputValue(searchQuery); + }, [searchQuery]); + + const handleInputChange = (e) => { + const value = e.target.value; + setInputValue(value); + + // 如果启用实时搜索,使用防抖触发回调 + if (realTimeSearch && onSearchChange) { + // 清除之前的定时器 + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + // 设置新的定时器 + debounceTimerRef.current = setTimeout(() => { + onSearchChange(value.trim()); + }, debounceDelay); + } + }; + + const handleSearch = () => { + // 立即清除防抖定时器 + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + if (onSearchChange) { + onSearchChange(inputValue.trim()); + } + }; + + const handleKeyPress = (e) => { + if (e.key === 'Enter') { + handleSearch(); + } + }; + + const handleClear = () => { + // 清除防抖定时器 + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + setInputValue(''); + if (onSearchChange) { + onSearchChange(''); + } + }; + + // 组件卸载时清除定时器 + useEffect(() => { + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, []); + + // 如果没有提供搜索回调函数,显示只读标题 + if (!onSearchChange) { + return ( +
+ {showIcon && } +

{collapsedText}

+
+ ); + } + + return ( +
!isExpanded && setIsExpanded(true)} + > + {showIcon && } + {isExpanded ? ( + { + if (!inputValue) setIsExpanded(false); + }} + allowClear={{ + clearIcon: × + }} + onClear={handleClear} + autoFocus + className="search-input-antd" + /> + ) : ( + {collapsedText} + )} +
+ ); +}; + +export default ExpandSearchBox; diff --git a/src/components/MeetingSummary.jsx b/src/components/MeetingSummary.jsx deleted file mode 100644 index 82b5517..0000000 --- a/src/components/MeetingSummary.jsx +++ /dev/null @@ -1,251 +0,0 @@ -import React, { useRef } from 'react'; -import { FileText, Download, Brain, Image } from 'lucide-react'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; -import rehypeRaw from 'rehype-raw'; -import rehypeSanitize from 'rehype-sanitize'; -import html2canvas from 'html2canvas'; - -const MeetingSummary = ({ - meeting, - summaryResult, - summaryHistory, - isCreator, - onOpenSummaryModal, - formatDateTime, - showToast -}) => { - const exportRef = useRef(null); - - const exportToImage = async () => { - try { - // 检查是否有总结内容 - let summaryContent = summaryResult?.content || - meeting?.summary || - (summaryHistory.length > 0 ? summaryHistory[0].content : null); - - if (!summaryContent) { - showToast?.('暂无会议总结内容,请先生成AI总结。', 'warning'); - return; - } - - if (!exportRef.current) { - showToast?.('内容尚未渲染完成,请稍后重试。', 'warning'); - return; - } - - // 创建完整的导出内容 - const exportContainer = document.createElement('div'); - exportContainer.style.cssText = ` - position: fixed; - top: -10000px; - left: -10000px; - width: 800px; - background: white; - padding: 40px; - font-family: "PingFang SC", "Microsoft YaHei", "Hiragino Sans GB", sans-serif; - line-height: 1.6; - color: #333; - `; - - // 创建会议信息 - const meetingTime = formatDateTime(meeting.meeting_time); - const attendeesList = meeting.attendees.map(attendee => - typeof attendee === 'string' ? attendee : attendee.caption - ).join('、'); - - exportContainer.innerHTML = ` -
-

- ${meeting.title || '会议总结'} -

- -
-

- 📋 会议信息 -

-

会议时间:${meetingTime}

-

创建人:${meeting.creator_username}

-

参会人数:${meeting.attendees.length}人

-

参会人员:${attendeesList}

-
- -
-

- 📝 会议摘要 -

-
-
-
- -
- 导出时间:${new Date().toLocaleString('zh-CN')} -
-
- `; - - document.body.appendChild(exportContainer); - - // 渲染Markdown内容 - const tempDiv = document.createElement('div'); - tempDiv.style.display = 'none'; - document.body.appendChild(tempDiv); - - const ReactMarkdown = (await import('react-markdown')).default; - const { createRoot } = await import('react-dom/client'); - - const root = createRoot(tempDiv); - - await new Promise((resolve) => { - root.render( - React.createElement(ReactMarkdown, { - remarkPlugins: [remarkGfm], - rehypePlugins: [rehypeRaw, rehypeSanitize], - children: summaryContent - }) - ); - setTimeout(resolve, 200); - }); - - // 获取渲染后的HTML并添加样式 - const renderedHTML = tempDiv.innerHTML; - const summaryContentDiv = exportContainer.querySelector('#summary-content'); - summaryContentDiv.innerHTML = renderedHTML; - - // 为渲染后的内容添加样式 - const styles = ` - - `; - - exportContainer.insertAdjacentHTML('afterbegin', styles); - - // 等待图片和样式加载 - await new Promise(resolve => setTimeout(resolve, 500)); - - // 使用html2canvas生成图片 - const canvas = await html2canvas(exportContainer, { - width: 880, - height: exportContainer.scrollHeight + 80, - scale: 2, // 高分辨率 - useCORS: true, - allowTaint: true, - backgroundColor: '#ffffff', - scrollX: 0, - scrollY: 0 - }); - - // 创建下载链接 - const link = document.createElement('a'); - link.download = `${meeting.title || '会议总结'}_总结_${new Date().toISOString().slice(0, 10)}.png`; - link.href = canvas.toDataURL('image/png', 1.0); - - // 触发下载 - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - - // 清理DOM - root.unmount(); - document.body.removeChild(tempDiv); - document.body.removeChild(exportContainer); - - } catch (error) { - console.error('图片导出失败:', error); - showToast?.('图片导出失败,请重试。', 'error'); - } - }; - - return ( -
-
-

摘要内容

- {meeting?.summary && ( - - )} -
-
- {meeting?.summary ? ( -
- - {meeting.summary} - -
- ) : ( -
-
- -

暂无会议总结

-

该会议尚未生成总结内容

- {isCreator && ( - - )} -
-
- )} -
-
- ); -}; - -export default MeetingSummary; \ No newline at end of file diff --git a/src/components/MeetingTimeline.css b/src/components/MeetingTimeline.css index 25e2afc..38fd360 100644 --- a/src/components/MeetingTimeline.css +++ b/src/components/MeetingTimeline.css @@ -664,4 +664,95 @@ font-size: 0.75rem; padding: 0.2rem 0.5rem; } +} + +/* Timeline Footer - 加载更多/加载完毕 */ +.timeline-footer { + margin-top: 2.5rem; + padding: 1.5rem 0 2rem 0; + text-align: center; +} + +.load-more-btn { + position: relative; + padding: 0.7rem 2.5rem; + background: white; + color: #667eea; + border: 2px solid #e2e8f0; + border-radius: 50px; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); + overflow: hidden; +} + +.load-more-btn::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + opacity: 0; + transition: opacity 0.3s ease; + z-index: 0; +} + +.load-more-btn span { + position: relative; + z-index: 1; + display: flex; + align-items: center; + gap: 0.5rem; + justify-content: center; +} + +.load-more-btn:hover:not(:disabled) { + border-color: #667eea; + transform: translateY(-2px); + box-shadow: 0 4px 16px rgba(102, 126, 234, 0.15); +} + +.load-more-btn:hover:not(:disabled)::before { + opacity: 1; +} + +.load-more-btn:hover:not(:disabled) span { + color: white; +} + +.load-more-btn:active:not(:disabled) { + transform: translateY(0); +} + +.load-more-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + border-color: #e2e8f0; +} + +.all-loaded { + padding: 1rem; + color: #94a3b8; + font-size: 0.875rem; + font-weight: 500; + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; +} + +.all-loaded::before, +.all-loaded::after { + content: ''; + width: 60px; + height: 1px; + background: linear-gradient(to right, transparent, #cbd5e1, transparent); +} + +.all-loaded::after { + background: linear-gradient(to left, transparent, #cbd5e1, transparent); } \ No newline at end of file diff --git a/src/components/MeetingTimeline.jsx b/src/components/MeetingTimeline.jsx index a991a3a..92c685b 100644 --- a/src/components/MeetingTimeline.jsx +++ b/src/components/MeetingTimeline.jsx @@ -9,7 +9,7 @@ import TagDisplay from './TagDisplay'; import ConfirmDialog from './ConfirmDialog'; import './MeetingTimeline.css'; -const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting }) => { +const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting, hasMore = false, onLoadMore, loadingMore = false }) => { const [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null); const [showDropdown, setShowDropdown] = useState(null); const navigate = useNavigate(); @@ -236,6 +236,25 @@ const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting }) => { ))} + {/* 加载更多/加载完毕 UI */} +
+ {hasMore ? ( + + ) : ( + sortedDates.length > 0 && ( +
+ 已加载全部会议 +
+ ) + )} +
+ {/* 删除会议确认对话框 */} { + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()}> +
+

+ 分享会议 +

+ +
+ +
+
+ +
+
+

{description}

+

{title}

+
+
+
+
+ ); +}; + +export default QRCodeModal; diff --git a/src/components/TagCloud.css b/src/components/TagCloud.css index e386f4b..30a4e86 100644 --- a/src/components/TagCloud.css +++ b/src/components/TagCloud.css @@ -33,6 +33,18 @@ 75% { transform: translate(-20%, -20%) rotate(270deg); } } +/* 新的紧凑标题栏样式 */ +.tag-cloud-header-compact { + display: flex; + align-items: center; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid #f1f5f9; + position: relative; + z-index: 1; +} + +/* 保留旧样式以兼容 */ .tag-cloud-header { display: flex; align-items: center; @@ -44,7 +56,51 @@ z-index: 1; } -.tag-cloud-header h3 { +/* 新的标题栏样式 - 整合搜索框 */ +.tag-cloud-header-with-search { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid #f1f5f9; + position: relative; + z-index: 1; +} + +.tag-cloud-header-with-search .header-left { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +/* 搜索切换按钮 */ +.search-toggle-btn { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: #667eea; + cursor: pointer; + padding: 0; + transition: all 0.3s ease; + border-radius: 6px; +} + +.search-toggle-btn:hover { + color: #5a67d8; + transform: scale(1.1); +} + +.search-toggle-btn:active { + transform: scale(0.95); +} + +.tag-cloud-header h3, +.tag-cloud-header-with-search h3 { margin: 0; font-size: 1.1rem; font-weight: 600; @@ -69,6 +125,92 @@ } } +/* 标题栏内的搜索框样式 - 可展开/收起 */ +.header-search-box { + position: relative; + flex: 0; + max-width: 0; + opacity: 0; + overflow: hidden; + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 1; +} + +.header-search-box.expanded { + flex: 1; + max-width: 320px; + opacity: 1; +} + +.header-search-box .search-input { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid #e2e8f0; + border-radius: 8px; + font-size: 0.875rem; + color: #1e293b; + background: #f8fafc; + transition: all 0.3s ease; + outline: none; +} + +.header-search-box .search-input::placeholder { + color: #94a3b8; + font-size: 0.8rem; +} + +.header-search-box .search-input:focus { + background: white; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +.header-search-box .search-input:hover:not(:focus) { + border-color: #cbd5e1; +} + +/* 旧的独立搜索框样式 - 保留作为备用 */ +.meeting-search-box { + position: relative; + margin-bottom: 1rem; + z-index: 1; +} + +.meeting-search-box .search-icon { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + color: #94a3b8; + pointer-events: none; +} + +.meeting-search-box .search-input { + width: 100%; + padding: 0.6rem 0.75rem 0.6rem 2.5rem; + border: 1px solid #e2e8f0; + border-radius: 8px; + font-size: 0.875rem; + color: #1e293b; + background: #f8fafc; + transition: all 0.3s ease; + outline: none; +} + +.meeting-search-box .search-input::placeholder { + color: #94a3b8; +} + +.meeting-search-box .search-input:focus { + background: white; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +.meeting-search-box .search-input:hover:not(:focus) { + border-color: #cbd5e1; +} + .tag-cloud { display: flex; flex-wrap: wrap; @@ -81,163 +223,32 @@ z-index: 1; } -.tag-cloud-item { - display: inline-flex; - align-items: center; - gap: 4px; - background: #f8fafc; +/* 统一的标签芯片样式 - 与知识库页面保持一致 */ +.tag-chip { + padding: 0.375rem 0.75rem; border: 1px solid #e2e8f0; - border-radius: 6px; - cursor: pointer; + border-radius: 16px; + background: #f8fafc; + color: #64748b; + font-size: 0.8rem; font-weight: 500; - color: #475569; + cursor: pointer; + transition: all 0.2s ease; white-space: nowrap; - text-decoration: none; font-family: inherit; - position: relative; - transform-origin: center; - opacity: 0; - transform: translateY(20px) scale(0.8); - animation: tagAppear 0.6s ease-out forwards; - transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); } -/* 错开动画时间让标签依次出现 */ -.tag-cloud-item:nth-child(1) { animation-delay: 0.1s; } -.tag-cloud-item:nth-child(2) { animation-delay: 0.15s; } -.tag-cloud-item:nth-child(3) { animation-delay: 0.2s; } -.tag-cloud-item:nth-child(4) { animation-delay: 0.25s; } -.tag-cloud-item:nth-child(5) { animation-delay: 0.3s; } -.tag-cloud-item:nth-child(6) { animation-delay: 0.35s; } -.tag-cloud-item:nth-child(7) { animation-delay: 0.4s; } -.tag-cloud-item:nth-child(8) { animation-delay: 0.45s; } -.tag-cloud-item:nth-child(9) { animation-delay: 0.5s; } -.tag-cloud-item:nth-child(10) { animation-delay: 0.55s; } -.tag-cloud-item:nth-child(n+11) { animation-delay: 0.6s; } - -@keyframes tagAppear { - to { - opacity: 1; - transform: translateY(0) scale(1); - } +.tag-chip:hover { + background: #e2e8f0; + border-color: #cbd5e1; } -/* 悬停时的弹性动画 */ -.tag-cloud-item:hover { +.tag-chip.selected { background: #667eea; color: white; - transform: scale(1.08) translateY(-2px); - box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4); border-color: #667eea; - z-index: 10; - opacity: 1; - animation: none; } -@keyframes bounce { - 0%, 100% { transform: scale(1.08) translateY(-2px); } - 50% { transform: scale(1.12) translateY(-4px); } -} - -/* 选中状态的脉冲动画 */ -.tag-cloud-item.selected { - background: #4f46e5; - color: white; - border-color: #4f46e5; - box-shadow: 0 4px 12px rgba(79, 70, 229, 0.4); - animation: pulse 2s ease-in-out infinite; -} - -@keyframes pulse { - 0%, 100% { - box-shadow: 0 4px 12px rgba(79, 70, 229, 0.4); - transform: scale(1); - } - 50% { - box-shadow: 0 6px 16px rgba(79, 70, 229, 0.6); - transform: scale(1.02); - } -} - -/* 不同大小的标签 - 模拟经典标签云效果 */ -.tag-cloud-item.xs { - font-size: 11px; - padding: 3px 8px; - line-height: 1.2; -} - -.tag-cloud-item.sm { - font-size: 12px; - padding: 4px 10px; - line-height: 1.3; -} - -.tag-cloud-item.md { - font-size: 14px; - padding: 6px 12px; - line-height: 1.4; - font-weight: 600; -} - -.tag-cloud-item.lg { - font-size: 16px; - padding: 8px 16px; - line-height: 1.4; - font-weight: 600; -} - -.tag-cloud-item.xl { - font-size: 18px; - padding: 10px 20px; - line-height: 1.5; - font-weight: 700; -} - -.tag-name { - line-height: 1; -} - -.tag-count { - background: rgba(0, 0, 0, 0.1); - border-radius: 8px; - padding: 1px 5px; - font-size: 9px; - line-height: 1; - font-weight: 600; - margin-left: 2px; - transition: all 0.3s ease; -} - -.tag-cloud-item.xs .tag-count { - font-size: 8px; - padding: 1px 4px; -} - -.tag-cloud-item.sm .tag-count { - font-size: 9px; - padding: 1px 4px; -} - -.tag-cloud-item.md .tag-count { - font-size: 10px; - padding: 2px 5px; -} - -.tag-cloud-item.lg .tag-count { - font-size: 11px; - padding: 2px 6px; -} - -.tag-cloud-item.xl .tag-count { - font-size: 12px; - padding: 3px 7px; -} - -.tag-cloud-item:hover .tag-count, -.tag-cloud-item.selected .tag-count { - background: rgba(255, 255, 255, 0.3); - transform: scale(1.1); -} /* 选中标签的滑入动画 */ .selected-tags { @@ -347,39 +358,30 @@ padding: 1rem; } + .tag-cloud-header-with-search { + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + } + + .tag-cloud-header-compact { + width: 100%; + } + + .header-search-box.expanded { + width: 100%; + max-width: 100%; + } + .tag-cloud { gap: 6px; min-height: 100px; padding: 0.5rem 0; } - .tag-cloud-item { - gap: 3px; - } - - .tag-cloud-item.xs { - font-size: 10px; - padding: 2px 6px; - } - - .tag-cloud-item.sm { - font-size: 11px; - padding: 3px 8px; - } - - .tag-cloud-item.md { - font-size: 12px; - padding: 4px 10px; - } - - .tag-cloud-item.lg { - font-size: 14px; - padding: 6px 12px; - } - - .tag-cloud-item.xl { - font-size: 16px; - padding: 8px 16px; + .tag-chip { + font-size: 0.75rem; + padding: 0.3rem 0.65rem; } .tag-cloud-header small { @@ -390,8 +392,4 @@ .tag-cloud-container::before { animation: none; } - - .tag-cloud-item.selected { - animation: none; - } } \ No newline at end of file diff --git a/src/components/TagCloud.jsx b/src/components/TagCloud.jsx index b76c3b0..f082fc7 100644 --- a/src/components/TagCloud.jsx +++ b/src/components/TagCloud.jsx @@ -1,51 +1,34 @@ import React, { useState, useEffect } from 'react'; -import { Tag, TrendingUp } from 'lucide-react'; +import { Tag } from 'lucide-react'; import apiClient from '../utils/apiClient'; -import { buildApiUrl } from '../config/api'; +import { buildApiUrl, API_ENDPOINTS } from '../config/api'; import './TagCloud.css'; const TagCloud = ({ - meetings = [], onTagClick = null, selectedTags = [], - maxTags = 20 + limitTags = false, // 是否限制标签数量,默认false显示全部 + showHeader = true // 是否显示标题行,默认显示 }) => { - const [tagStats, setTagStats] = useState([]); + const [allTags, setAllTags] = useState([]); + const [loading, setLoading] = useState(true); useEffect(() => { - calculateTagStats(); - }, [meetings]); + fetchAllTags(); + }, []); - const calculateTagStats = () => { - const tagCounts = {}; - - // 统计所有会议的标签 - meetings.forEach(meeting => { - if (meeting.tags && meeting.tags.length > 0) { - meeting.tags.forEach(tag => { - const tagName = typeof tag === 'string' ? tag : tag.name; - tagCounts[tagName] = (tagCounts[tagName] || 0) + 1; - }); - } - }); - - // 转换为数组并排序 - const stats = Object.entries(tagCounts) - .map(([name, count]) => ({ name, count })) - .sort((a, b) => b.count - a.count) - .slice(0, maxTags); - - setTagStats(stats); - }; - - const getTagSize = (count, maxCount) => { - // 根据使用频率计算标签大小 - const ratio = count / maxCount; - if (ratio >= 0.8) return 'xl'; - if (ratio >= 0.6) return 'lg'; - if (ratio >= 0.4) return 'md'; - if (ratio >= 0.2) return 'sm'; - return 'xs'; + const fetchAllTags = async () => { + try { + setLoading(true); + const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.TAGS.LIST)); + const tags = response.data || []; + // 根据limitTags参数决定是否限制数量 + setAllTags(limitTags ? tags.slice(0, 6) : tags); + } catch (err) { + console.error('Error fetching tags:', err); + } finally { + setLoading(false); + } }; const handleTagClick = (tagName) => { @@ -54,40 +37,63 @@ const TagCloud = ({ } }; - if (tagStats.length === 0) { + if (loading) { return ( -
- -

暂无标签数据

- 创建会议时添加标签,这里将显示标签云 +
+ {showHeader && ( +
+ +

标签云

+
+ )} +
+ +

加载中...

+
); } - const maxCount = Math.max(...tagStats.map(tag => tag.count)); + if (allTags.length === 0) { + return ( +
+ {showHeader && ( +
+ +

标签云

+
+ )} +
+ +

暂无标签数据

+ 创建会议时添加标签,这里将显示标签云 +
+
+ ); + } return (
-
- -

标签云

- 点击标签筛选相关会议 -
+ {showHeader && ( +
+ +

标签云

+ 点击标签筛选会议 +
+ )}
- {tagStats.map((tag, index) => { - const size = getTagSize(tag.count, maxCount); + {allTags.map((tag) => { const isSelected = selectedTags.includes(tag.name); return ( ); })} diff --git a/src/config/api.js b/src/config/api.js index df8435e..e1be57f 100644 --- a/src/config/api.js +++ b/src/config/api.js @@ -21,6 +21,7 @@ const API_CONFIG = { }, MEETINGS: { LIST: '/api/meetings', + STATS: '/api/meetings/stats', DETAIL: (meetingId) => `/api/meetings/${meetingId}`, EDIT: (meetingId) => `/api/meetings/${meetingId}/edit`, CREATE: '/api/meetings', diff --git a/src/pages/Dashboard.css b/src/pages/Dashboard.css index 25fc74f..bcb022b 100644 --- a/src/pages/Dashboard.css +++ b/src/pages/Dashboard.css @@ -91,24 +91,53 @@ height: 100%; } +/* 统计卡片 */ +.unified-stats-card { + background: white; + padding: 1.5rem; + border-radius: 12px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + border: 1px solid #e2e8f0; +} + +/* 筛选卡片整合搜索框和标签云 */ +.filter-card-wrapper { + background: white; + border-radius: 12px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + border: 1px solid #e2e8f0; + display: flex; + flex-direction: column; +} + +.filter-card-search { + padding: 1.5rem 1.5rem 1rem 1.5rem; + border-bottom: 1px solid #f1f5f9; +} + +.filter-card-tags { + flex: 1; +} + +.filter-card-tags .tag-cloud-container { + background: transparent; + box-shadow: none; + border: none; + padding: 1rem 1.5rem 1.5rem 1.5rem; +} + .user-card { background: white; - padding: 1rem 1.5rem; + padding: 1.75rem 1.5rem; border-radius: 12px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); display: flex; align-items: center; - gap: 1rem; + gap: 1.25rem; border: 1px solid #e2e8f0; flex: 3; } -.tag-cloud-wrapper { - display: flex; - flex-direction: column; - gap: 1rem; -} - .filter-result-bar { background: white; padding: 1rem 1.5rem; @@ -147,8 +176,8 @@ } .user-avatar { - width: 40px; - height: 40px; + width: 50px; + height: 50px; background: linear-gradient(45deg, #667eea, #764ba2); border-radius: 50%; display: flex; @@ -166,25 +195,25 @@ display: flex; align-items: center; gap: 0.75rem; - margin-bottom: 0.15rem; + margin-bottom: 0.35rem; } .user-details h2 { margin: 0; color: #1e293b; - font-size: 1.1rem; + font-size: 1.25rem; font-weight: 600; } .user-email { color: #64748b; - margin: 0 0 0.15rem 0; - font-size: 0.8rem; + margin: 0 0 0.25rem 0; + font-size: 0.875rem; } .join-date { color: #94a3b8; - font-size: 0.7rem; + font-size: 0.75rem; margin: 0; } @@ -679,11 +708,11 @@ .kb-entry-card { display: flex; align-items: center; - gap: 1rem; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - padding: 1rem 1.5rem; + gap: 0.75rem; + background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%); + padding: 0.75rem 1rem; border-radius: 12px; - box-shadow: 0 2px 8px rgba(102, 126, 234, 0.25); + box-shadow: 0 2px 8px rgba(6, 182, 212, 0.25); transition: all 0.3s ease; text-decoration: none; position: relative; @@ -709,15 +738,15 @@ .kb-entry-card:hover { transform: translateY(-2px); - box-shadow: 0 4px 16px rgba(102, 126, 234, 0.35); + box-shadow: 0 4px 16px rgba(6, 182, 212, 0.35); text-decoration: none; } .kb-entry-icon { - width: 48px; - height: 48px; + width: 36px; + height: 36px; background: rgba(255, 255, 255, 0.2); - border-radius: 12px; + border-radius: 10px; display: flex; align-items: center; justify-content: center; @@ -731,16 +760,16 @@ } .kb-entry-content h2 { - margin: 0 0 0.25rem 0; + margin: 0 0 0.15rem 0; color: white; - font-size: 1.1rem; + font-size: 0.95rem; font-weight: 700; } .kb-entry-content p { margin: 0; color: rgba(255, 255, 255, 0.9); - font-size: 0.85rem; + font-size: 0.75rem; font-weight: 400; } diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index cda0f28..a66aef2 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -5,18 +5,23 @@ import { Link } from 'react-router-dom'; import { buildApiUrl, API_ENDPOINTS } from '../config/api'; import MeetingTimeline from '../components/MeetingTimeline'; import TagCloud from '../components/TagCloud'; +import ExpandSearchBox from '../components/ExpandSearchBox'; import VoiceprintCollectionModal from '../components/VoiceprintCollectionModal'; import ConfirmDialog from '../components/ConfirmDialog'; import PageLoading from '../components/PageLoading'; +import meetingCacheService from '../services/meetingCacheService'; import './Dashboard.css'; const Dashboard = ({ user, onLogout }) => { const [userInfo, setUserInfo] = useState(null); - const [meetings, setMeetings] = useState(null); - const [filteredMeetings, setFilteredMeetings] = useState([]); + const [meetings, setMeetings] = useState([]); + const [meetingsStats, setMeetingsStats] = useState({ all_meetings: 0, created_meetings: 0, attended_meetings: 0 }); + const [pagination, setPagination] = useState({ page: 1, total: 0, has_more: false }); const [selectedTags, setSelectedTags] = useState([]); const [filterType, setFilterType] = useState('all'); // 'all', 'created', 'attended' + const [searchQuery, setSearchQuery] = useState(''); // 搜索关键词 const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(''); const [dropdownOpen, setDropdownOpen] = useState(false); const [showChangePasswordModal, setShowChangePasswordModal] = useState(false); @@ -36,9 +41,35 @@ const Dashboard = ({ user, onLogout }) => { useEffect(() => { fetchUserData(); + fetchMeetingsStats(); fetchVoiceprintData(); + + // 开发环境下,在控制台添加缓存调试命令 + if (process.env.NODE_ENV === 'development') { + window.meetingCache = { + stats: () => meetingCacheService.getStats(), + clear: () => { + meetingCacheService.clearAll(); + console.log('Cache cleared!'); + }, + info: () => { + const stats = meetingCacheService.getStats(); + console.log('Meeting Cache Stats:', stats); + console.log(`- Cached filters: ${stats.filterCount}`); + console.log(`- Total pages: ${stats.totalPages}`); + console.log(`- Total meetings: ${stats.totalMeetings}`); + console.log(`- Cache size: ~${stats.cacheSize} KB`); + } + }; + console.log('💡 Cache debug commands available: window.meetingCache.stats(), window.meetingCache.clear(), window.meetingCache.info()'); + } }, [user.user_id]); + // 当筛选条件变化时,重新加载第一页 + useEffect(() => { + fetchMeetings(1, false); + }, [selectedTags, filterType, searchQuery]); + const fetchVoiceprintData = async () => { try { setVoiceprintLoading(true); @@ -61,30 +92,97 @@ const Dashboard = ({ user, onLogout }) => { // 过滤会议 useEffect(() => { - filterMeetings(); - }, [meetings, selectedTags, filterType]); + fetchMeetings(1, false); + }, [selectedTags, filterType, searchQuery]); - const filterMeetings = () => { - if (!meetings) return; - let filtered = [...meetings]; + const fetchMeetings = async (page = 1, isLoadMore = false) => { + try { + // 生成当前过滤器的键 + const filterKey = meetingCacheService.generateFilterKey(filterType, searchQuery, selectedTags); - // 根据创建/参与类型过滤 - if (filterType === 'created') { - filtered = filtered.filter(meeting => String(meeting.creator_id) === String(user.user_id)); - } else if (filterType === 'attended') { - filtered = filtered.filter(meeting => String(meeting.creator_id) !== String(user.user_id)); + // 如果不是加载更多,先检查是否有该过滤器的缓存 + if (!isLoadMore) { + const allCached = meetingCacheService.getAllPages(filterKey); + if (allCached && allCached.pages[1]) { + console.log('Using cached data for filter:', filterKey); + + // 恢复第一页数据 + const firstPage = allCached.pages[1]; + setMeetings(firstPage.meetings); + setPagination(firstPage.pagination); + return; + } + } else { + // 加载更多时,检查该页是否有缓存 + const cachedPage = meetingCacheService.getPage(filterKey, page); + if (cachedPage) { + console.log('Using cached page:', page, 'for filter:', filterKey); + setMeetings(prev => [...prev, ...cachedPage.meetings]); + setPagination(cachedPage.pagination); + return; + } + } + + // 没有缓存,从服务器获取 + if (isLoadMore) { + setLoadingMore(true); + } else { + setLoading(true); + } + + const params = { + user_id: user.user_id, + page: page, + filter_type: filterType, + search: searchQuery || undefined, + tags: selectedTags.length > 0 ? selectedTags.join(',') : undefined + }; + + const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.LIST), { params }); + + const newMeetings = response.data.meetings; + const newPagination = { + page: response.data.page, + total: response.data.total, + has_more: response.data.has_more + }; + + // 缓存当前页数据 + meetingCacheService.setPage(filterKey, page, newMeetings, newPagination); + + if (isLoadMore) { + // 加载更多:追加数据 + setMeetings(prev => [...prev, ...newMeetings]); + } else { + // 新查询:替换数据 + setMeetings(newMeetings); + } + + setPagination(newPagination); + } catch (err) { + console.error('Error fetching meetings:', err); + setError('获取会议列表失败,请刷新重试'); + } finally { + setLoading(false); + setLoadingMore(false); } + }; - // 根据选中的标签过滤 - if (selectedTags.length > 0) { - filtered = filtered.filter(meeting => { - if (!meeting.tags || meeting.tags.length === 0) return false; - const meetingTags = meeting.tags.map(tag => typeof tag === 'string' ? tag : tag.name); - return selectedTags.some(selectedTag => meetingTags.includes(selectedTag)); + const fetchMeetingsStats = async () => { + try { + const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.STATS), { + params: { user_id: user.user_id } }); + setMeetingsStats(response.data); + } catch (err) { + console.error('Error fetching meetings stats:', err); } + }; - setFilteredMeetings(filtered); + const handleLoadMore = () => { + if (!loadingMore && pagination.has_more) { + fetchMeetings(pagination.page + 1, true); + } }; const handleTagClick = (tagName) => { @@ -104,6 +202,7 @@ const Dashboard = ({ user, onLogout }) => { const clearFilters = () => { setSelectedTags([]); setFilterType('all'); + setSearchQuery(''); }; useEffect(() => { @@ -120,32 +219,27 @@ const Dashboard = ({ user, onLogout }) => { const fetchUserData = async () => { try { - setLoading(true); console.log('Fetching user data for user_id:', user.user_id); - + const userResponse = await apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.DETAIL(user.user_id))); console.log('User response:', userResponse.data); - setUserInfo(userResponse.data); - - const meetingsResponse = await apiClient.get(buildApiUrl(`${API_ENDPOINTS.MEETINGS.LIST}?user_id=${user.user_id}`)); - setMeetings(meetingsResponse.data); + setUserInfo(userResponse.data); } catch (err) { console.error('Error fetching data:', err); setError('获取数据失败,请刷新重试'); - } finally { - setLoading(false); } }; const handleDeleteMeeting = async (meetingId) => { try { await apiClient.delete(buildApiUrl(API_ENDPOINTS.MEETINGS.DELETE(meetingId))); - // Refresh meetings list - const meetingsResponse = await apiClient.get(buildApiUrl(`${API_ENDPOINTS.MEETINGS.LIST}?user_id=${user.user_id}`)); - setMeetings(meetingsResponse.data); + // 清除所有缓存,因为删除会影响统计和列表 + meetingCacheService.clearAll(); + // 刷新会议列表和统计 + await fetchMeetings(1, false); + await fetchMeetingsStats(); } catch (err) { console.error('Error deleting meeting:', err); - // You might want to show an error message to the user here } }; @@ -231,7 +325,7 @@ const Dashboard = ({ user, onLogout }) => { }); }; - if (loading || !meetings) { + if (loading && meetings.length === 0) { return ; } @@ -240,17 +334,18 @@ const Dashboard = ({ user, onLogout }) => {

{error}

- +
); } - const groupedMeetings = groupMeetingsByDate(filteredMeetings); + const groupedMeetings = groupMeetingsByDate(meetings); - // 计算统计数据 - const createdMeetings = meetings.filter(m => String(m.creator_id) === String(user.user_id)); - const attendedMeetings = meetings.filter(m => String(m.creator_id) !== String(user.user_id)); + // 使用统计数据 + const createdMeetings = meetingsStats.created_meetings; + const attendedMeetings = meetingsStats.attended_meetings; + const allMeetings = meetingsStats.all_meetings; return (
@@ -290,7 +385,7 @@ const Dashboard = ({ user, onLogout }) => {
- +
@@ -328,14 +423,14 @@ const Dashboard = ({ user, onLogout }) => { {/* 知识库入口卡片 */}
- +

知识库

贯穿内容,生成知识库

- +
@@ -353,7 +448,7 @@ const Dashboard = ({ user, onLogout }) => {
我创建的会议 - {createdMeetings.length} + {createdMeetings}
@@ -366,7 +461,7 @@ const Dashboard = ({ user, onLogout }) => {
我参加的会议 - {attendedMeetings.length} + {attendedMeetings}
@@ -379,19 +474,32 @@ const Dashboard = ({ user, onLogout }) => {
全部会议 - {meetings.length} + {allMeetings}
- {/* 标签云卡片 */} -
- + {/* 搜索和标签过滤卡片 */} +
+
+ +
+ +
+ +
@@ -403,7 +511,6 @@ const Dashboard = ({ user, onLogout }) => { 会议时间轴 -

按时间顺序展示您参与的所有会议

@@ -413,10 +520,13 @@ const Dashboard = ({ user, onLogout }) => { - diff --git a/src/pages/KnowledgeBasePage.css b/src/pages/KnowledgeBasePage.css index b0b1965..f9c112b 100644 --- a/src/pages/KnowledgeBasePage.css +++ b/src/pages/KnowledgeBasePage.css @@ -541,7 +541,7 @@ /* 新增知识库弹窗 */ .create-kb-modal { max-width: 700px; - max-height: 80vh; + max-height: 85vh; display: flex; flex-direction: column; } @@ -575,13 +575,57 @@ border-bottom: 1px solid #e2e8f0; } -.modal-header h2 { +.modal-header-left { + display: flex; + align-items: center; + gap: 1.5rem; + flex: 1; +} + +.modal-header-left h2 { margin: 0; font-size: 1.25rem; font-weight: 600; color: #1e293b; } +/* 标题栏步骤指示器 */ +.header-step-indicator { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.step-tag { + display: inline-flex; + align-items: center; + padding: 0.375rem 0.875rem; + background: #f1f5f9; + color: #64748b; + border-radius: 6px; + font-size: 0.8rem; + font-weight: 500; + transition: all 0.3s ease; + white-space: nowrap; +} + +.step-tag.active { + background: #667eea; + color: white; + box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3); +} + +.step-tag.completed { + background: #10b981; + color: white; +} + +.step-arrow { + color: #cbd5e1; + font-size: 0.875rem; + font-weight: 600; +} + .close-btn { padding: 0.5rem; border: none; @@ -603,6 +647,9 @@ padding: 1rem; overflow-y: auto; flex: 1; + height: 450px; /* 调整为更小的固定高度 */ + min-height: 450px; + max-height: 450px; } .form-group { @@ -624,7 +671,7 @@ font-family: inherit; font-size: 0.95rem; resize: vertical; - min-height: 100px; + min-height: 270px; /* 调整高度以匹配步骤一的列表高度 */ transition: border-color 0.2s ease; } @@ -731,7 +778,7 @@ } .meeting-list { - max-height: 300px; + max-height: 310px; /* 调整为适应新的窗体高度 */ overflow-y: auto; border: 1px solid #e2e8f0; border-radius: 8px; @@ -778,7 +825,15 @@ font-weight: 500; } -.meeting-item-creator { +.meeting-item-meta { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 0.25rem; +} + +.meeting-item-creator, +.meeting-item-date { font-size: 0.8rem; color: #94a3b8; } @@ -1056,3 +1111,59 @@ background: #f8fafc; border-color: #cbd5e1; } + +/* 加载状态 */ +.loading-state { + text-align: center; + padding: 2rem; + color: #64748b; + font-size: 0.9rem; +} + +/* 分页控件 */ +.pagination-controls { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + background: #f8fafc; + border-top: 1px solid #e2e8f0; + border-radius: 0 0 8px 8px; + margin-top: -0.5rem; +} + +.pagination-btn { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 0.875rem; + border: 1px solid #e2e8f0; + border-radius: 6px; + background: white; + color: #475569; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.pagination-btn:hover:not(:disabled) { + background: #667eea; + color: white; + border-color: #667eea; + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(102, 126, 234, 0.25); +} + +.pagination-btn:disabled { + background: #f1f5f9; + color: #cbd5e1; + cursor: not-allowed; + border-color: #e2e8f0; +} + +.pagination-info { + font-size: 0.8rem; + color: #64748b; + font-weight: 500; +} diff --git a/src/pages/KnowledgeBasePage.jsx b/src/pages/KnowledgeBasePage.jsx index 3663527..221ce1d 100644 --- a/src/pages/KnowledgeBasePage.jsx +++ b/src/pages/KnowledgeBasePage.jsx @@ -12,6 +12,7 @@ import rehypeRaw from 'rehype-raw'; import rehypeSanitize from 'rehype-sanitize'; import html2canvas from 'html2canvas'; import PageLoading from '../components/PageLoading'; +import meetingCacheService from '../services/meetingCacheService'; import './KnowledgeBasePage.css'; const KnowledgeBasePage = ({ user }) => { @@ -33,6 +34,8 @@ const KnowledgeBasePage = ({ user }) => { const [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null); const [toasts, setToasts] = useState([]); const [createStep, setCreateStep] = useState(1); // 1: 选择会议, 2: 输入提示词 + const [meetingsPagination, setMeetingsPagination] = useState({ page: 1, total: 0, has_more: false }); + const [loadingMeetings, setLoadingMeetings] = useState(false); // Toast helper functions const showToast = (message, type = 'info') => { @@ -46,9 +49,16 @@ const KnowledgeBasePage = ({ user }) => { useEffect(() => { fetchAllKbs(); - fetchMeetings(); + fetchAllTagsForFilter(); // 获取标签云数据 }, []); + // 当搜索或标签过滤变化时,重新加载第一页 + useEffect(() => { + if (showCreateForm) { + fetchMeetings(1); + } + }, [searchQuery, selectedTags, showCreateForm]); + useEffect(() => { if (taskId) { const interval = setInterval(() => { @@ -110,36 +120,65 @@ const KnowledgeBasePage = ({ user }) => { }); }; - const fetchMeetings = () => { - apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.LIST, { user_id: user.user_id })) - .then(response => { - const meetingsData = response.data; - setMeetings(meetingsData); + const fetchMeetings = async (page = 1) => { + try { + // 生成当前过滤器的键(不包含filterType,因为知识库这里不需要) + const filterKey = meetingCacheService.generateFilterKey('all', searchQuery, selectedTags); - // 统计标签使用频率 - const tagFrequency = {}; - meetingsData.forEach(meeting => { - if (meeting.tags && Array.isArray(meeting.tags)) { - meeting.tags.forEach(tag => { - const tagName = typeof tag === 'string' ? tag : tag.name; - if (tagName) { - tagFrequency[tagName] = (tagFrequency[tagName] || 0) + 1; - } - }); - } - }); + // 先检查缓存 + const cachedPage = meetingCacheService.getPage(filterKey, page); + if (cachedPage) { + console.log('Using cached page:', page, 'for filter:', filterKey); + setMeetings(cachedPage.meetings); + setMeetingsPagination(cachedPage.pagination); + return; + } - // 按使用频率降序排序,取前5个 - const topTags = Object.entries(tagFrequency) - .sort((a, b) => b[1] - a[1]) - .slice(0, 6) - .map(([tag]) => tag); + // 没有缓存,从服务器获取 + setLoadingMeetings(true); - setAvailableTags(topTags); - }) - .catch(error => { - console.error("Error fetching meetings:", error); - }); + const params = { + user_id: user.user_id, + page: page, + search: searchQuery || undefined, + tags: selectedTags.length > 0 ? selectedTags.join(',') : undefined + }; + + const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.LIST), { params }); + + const newMeetings = response.data.meetings || []; + const newPagination = { + page: response.data.page, + total: response.data.total, + has_more: response.data.has_more + }; + + // 缓存当前页数据 + meetingCacheService.setPage(filterKey, page, newMeetings, newPagination); + + setMeetings(newMeetings); + setMeetingsPagination(newPagination); + } catch (error) { + console.error("Error fetching meetings:", error); + setMeetings([]); + } finally { + setLoadingMeetings(false); + } + }; + + // 获取所有标签用于过滤器 + const fetchAllTagsForFilter = async () => { + try { + const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.TAGS.LIST)); + const allTags = response.data || []; + console.log('所有标签:', allTags.length, allTags); + // 取前6个热门标签 + const topSixTags = allTags.slice(0, 6).map(tag => tag.name); + console.log('取前6个标签:', topSixTags); + setAvailableTags(topSixTags); + } catch (error) { + console.error("Error fetching tags:", error); + } }; const loadKbDetail = async (kbId) => { @@ -183,35 +222,9 @@ const KnowledgeBasePage = ({ user }) => { ); }; - const filteredMeetings = (meetings || []).filter(meeting => { - // 关键字过滤:支持会议名称和创建人 - if (searchQuery) { - const query = searchQuery.toLowerCase(); - const matchTitle = meeting.title?.toLowerCase().includes(query); - const matchCreator = meeting.creator_username?.toLowerCase().includes(query); - if (!matchTitle && !matchCreator) { - return false; - } - } - - // 标签过滤 - if (selectedTags.length > 0) { - if (!meeting.tags || meeting.tags.length === 0) { - return false; - } - const meetingTags = meeting.tags.map(tag => - typeof tag === 'string' ? tag : tag.name - ); - const hasAllTags = selectedTags.every(selectedTag => - meetingTags.includes(selectedTag) - ); - if (!hasAllTags) { - return false; - } - } - - return true; - }); + const handlePageChange = (newPage) => { + fetchMeetings(newPage); + }; const handleTagToggle = (tag) => { setSelectedTags(prev => @@ -273,6 +286,17 @@ const KnowledgeBasePage = ({ user }) => { }); }; + const formatMeetingDate = (dateString) => { + const date = new Date(dateString); + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + }; + const isToday = (dateString) => { const date = new Date(dateString); const today = new Date(); @@ -784,7 +808,19 @@ const KnowledgeBasePage = ({ user }) => {
-

新增知识库

+
+

新增知识库

+ {/* 步骤指示器集成到标题栏 */} +
+ + {createStep > 1 ? '✓' : '1'} 选择会议 + + + + 2 自定义提示词 + +
+
- {/* 步骤指示器 */} -
-
1 ? 'completed' : ''}`}> -
1
-
选择会议
-
-
-
-
2
-
自定义提示词
-
-
-
{/* 步骤 1: 选择会议 */} {createStep === 1 && (
- - {/* 紧凑的搜索和过滤区 */}
{
- {filteredMeetings.length === 0 ? ( + {loadingMeetings ? ( +
+

加载中...

+
+ ) : meetings.length === 0 ? (

未找到匹配的会议

) : ( - filteredMeetings.map(meeting => ( + meetings.map(meeting => (
{ />
{meeting.title}
- {meeting.creator_username && ( -
创建人: {meeting.creator_username}
- )} +
+ {meeting.creator_username && ( + 创建人: {meeting.creator_username} + )} + {meeting.created_at && ( + 创建时间: {formatMeetingDate(meeting.created_at)} + )} +
)) )}
+ + {/* 分页按钮 */} + {!loadingMeetings && meetings.length > 0 && ( +
+ + + 第 {meetingsPagination.page} 页 · 共 {meetingsPagination.total} 条 + + +
+ )}
- {selectedMeetings.length > 0 && ( -
- 已选择 {selectedMeetings.length} 个会议 -
- )}
)} diff --git a/src/pages/MeetingDetails.css b/src/pages/MeetingDetails.css index 997d9e5..afdfdd2 100644 --- a/src/pages/MeetingDetails.css +++ b/src/pages/MeetingDetails.css @@ -1927,4 +1927,61 @@ @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } +} + +/* Icon-only action buttons */ +.action-btn.icon-only { + padding: 0.6rem; + min-width: auto; + gap: 0; + width: 40px; + height: 40px; + justify-content: center; +} + +.action-btn.icon-only span { + display: none; +} + +.action-btn.qr-btn { + background: #10b981; + color: white; + box-shadow: 0 2px 4px rgba(16, 185, 129, 0.2); +} + +.action-btn.qr-btn:hover { + background: #059669; + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(16, 185, 129, 0.3); +} + +/* Enhanced transcript control buttons */ +.edit-speakers-btn, +.ai-summary-btn { + padding: 8px 16px; + font-size: 14px; + font-weight: 600; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); + transition: all 0.2s ease; +} + +.edit-speakers-btn { + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); +} + +.edit-speakers-btn:hover { + background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); + transform: translateY(-1px); + box-shadow: 0 4px 10px rgba(59, 130, 246, 0.3); +} + +.ai-summary-btn { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + box-shadow: 0 2px 6px rgba(102, 126, 234, 0.2); +} + +.ai-summary-btn:hover { + background: linear-gradient(135deg, #5a67d8 0%, #6b46c1 100%); + transform: translateY(-1px); + box-shadow: 0 4px 10px rgba(102, 126, 234, 0.4); } \ No newline at end of file diff --git a/src/pages/MeetingDetails.jsx b/src/pages/MeetingDetails.jsx index 34a20d1..2fd55a3 100644 --- a/src/pages/MeetingDetails.jsx +++ b/src/pages/MeetingDetails.jsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { useParams, Link, useNavigate } from 'react-router-dom'; import apiClient from '../utils/apiClient'; -import { ArrowLeft, Clock, Users, User, Calendar, Play, Pause, Volume2, MessageCircle, Edit, Trash2, Settings, Save, X, Edit3, Brain, Sparkles, Download, ArrowDown, RefreshCw, RefreshCwOff, Image } from 'lucide-react'; +import { ArrowLeft, Clock, Users, User, Calendar, Play, Pause, Volume2, MessageCircle, Edit, Trash2, Settings, Save, X, Edit3, Brain, Sparkles, Download, ArrowDown, RefreshCw, RefreshCwOff, Image, QrCode } from 'lucide-react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; @@ -12,6 +12,7 @@ import TagDisplay from '../components/TagDisplay'; import ConfirmDialog from '../components/ConfirmDialog'; import Toast from '../components/Toast'; import PageLoading from '../components/PageLoading'; +import QRCodeModal from '../components/QRCodeModal'; import { Tabs } from 'antd'; import html2canvas from 'html2canvas'; import './MeetingDetails.css'; @@ -57,6 +58,7 @@ const MeetingDetails = ({ user }) => { const [summaryTaskMessage, setSummaryTaskMessage] = useState(''); const [summaryPollInterval, setSummaryPollInterval] = useState(null); const [toasts, setToasts] = useState([]); + const [showQRModal, setShowQRModal] = useState(false); const audioRef = useRef(null); const transcriptRefs = useRef([]); @@ -980,16 +982,22 @@ const MeetingDetails = ({ user }) => { {isCreator && (
- + - 编辑会议 +
)} @@ -1562,6 +1570,15 @@ const MeetingDetails = ({ user }) => {
)} + {/* QR Code Sharing Modal */} + setShowQRModal(false)} + url={`${window.location.origin}/meetings/${meeting_id}/preview`} + title={meeting.title} + description="扫描二维码查看会议总结" + /> + {/* Toast notifications */} {toasts.map(toast => ( { + const { meetingId } = useParams(); + const [meetingData, setMeetingData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [errorType, setErrorType] = useState(''); // 'not_found', 'no_summary', 'network' + + useEffect(() => { + fetchMeetingPreviewData(); + }, [meetingId]); + + const fetchMeetingPreviewData = async () => { + try { + setLoading(true); + const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000'; + const response = await fetch(`${apiUrl}/api/meetings/${meetingId}/preview-data`); + const result = await response.json(); + + if (result.code === "200") { + setMeetingData(result.data); + setError(null); + setErrorType(''); + } else if (result.code === "404") { + setError(result.message || '会议不存在'); + setErrorType('not_found'); + } else if (result.code === "400") { + setError(result.message || '该会议总结尚未生成'); + setErrorType('no_summary'); + } else { + setError(result.message || '获取会议数据失败'); + setErrorType('network'); + } + } catch (err) { + console.error('获取会议预览数据失败:', err); + setError('网络错误,无法获取会议数据'); + setErrorType('network'); + } finally { + setLoading(false); + } + }; + + const formatDateTime = (dateTime) => { + if (!dateTime) return ''; + const date = new Date(dateTime); + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }); + }; + + if (loading) { + return ( +
+
+
+

正在加载会议总结...

+
+
+ ); + } + + if (error) { + return ( +
+
+
⚠️
+

加载失败

+

{error}

+ +
+
+ ); + } + + if (!meetingData) { + return ( +
+
+

未找到会议数据

+
+
+ ); + } + + const attendeesList = meetingData.attendees + .map(attendee => attendee.caption) + .join('、'); + + return ( +
+
+

+ {meetingData.title || '会议总结'} +

+ +
+

📋 会议信息

+
+ 会议时间: + {formatDateTime(meetingData.meeting_time)} +
+
+ 创建人: + {meetingData.creator_username} +
+
+ 参会人数: + {meetingData.attendees_count}人 +
+
+ 参会人员: + {attendeesList} +
+
+ +
+

📝 会议摘要

+
+ + {meetingData.summary} + +
+
+ +
+ 导出时间:{new Date().toLocaleString('zh-CN')} +
+
+
+ ); +}; + +export default MeetingPreview; diff --git a/src/pages/admin/SystemConfiguration.jsx b/src/pages/admin/SystemConfiguration.jsx index cbaaf92..1c4a1bd 100644 --- a/src/pages/admin/SystemConfiguration.jsx +++ b/src/pages/admin/SystemConfiguration.jsx @@ -7,10 +7,10 @@ import './SystemConfiguration.css'; const SystemConfiguration = () => { const [configs, setConfigs] = useState({ model_name: '', - system_prompt: '', + template_text: '', DEFAULT_RESET_PASSWORD: '', MAX_FILE_SIZE: 0, - MAX_IMAGE_SIZE: 0 + TIMELINE_PAGESIZE: 0 }); const [originalConfigs, setOriginalConfigs] = useState({}); @@ -53,13 +53,20 @@ const SystemConfiguration = () => { }; const handleInputChange = (key, value) => { - if (key === 'MAX_FILE_SIZE' || key === 'MAX_IMAGE_SIZE') { + if (key === 'MAX_FILE_SIZE') { // 对于文件大小,输入的是MB,需要转换为字节 const numValue = parseInt(value) || 0; setConfigs(prev => ({ ...prev, [key]: mbToBytes(numValue) })); + } else if (key === 'TIMELINE_PAGESIZE') { + // 分页大小直接存储数字 + const numValue = parseInt(value) || 0; + setConfigs(prev => ({ + ...prev, + [key]: numValue + })); } else { setConfigs(prev => ({ ...prev, @@ -159,18 +166,18 @@ const SystemConfiguration = () => {