修改了会议获取接口
parent
cdfcceaf47
commit
5cee370e87
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 ? <EditKnowledgeBase user={user} /> : <Navigate to="/" />
|
||||
} />
|
||||
<Route path="/downloads" element={<ClientDownloadPage />} />
|
||||
<Route path="/meetings/:meetingId/preview" element={<MeetingPreview />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</Router>
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="expand-search-readonly">
|
||||
{showIcon && <Search size={18} />}
|
||||
<h3>{collapsedText}</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`expand-search-box ${isExpanded ? 'expanded' : ''}`}
|
||||
onClick={() => !isExpanded && setIsExpanded(true)}
|
||||
>
|
||||
{showIcon && <Search size={18} className="search-icon" />}
|
||||
{isExpanded ? (
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onPressEnter={handleSearch}
|
||||
onKeyPress={handleKeyPress}
|
||||
onBlur={() => {
|
||||
if (!inputValue) setIsExpanded(false);
|
||||
}}
|
||||
allowClear={{
|
||||
clearIcon: <span onClick={handleClear}>×</span>
|
||||
}}
|
||||
onClear={handleClear}
|
||||
autoFocus
|
||||
className="search-input-antd"
|
||||
/>
|
||||
) : (
|
||||
<span className="search-placeholder">{collapsedText}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExpandSearchBox;
|
||||
|
|
@ -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 = `
|
||||
<div style="margin-bottom: 40px;">
|
||||
<h1 style="color: #2563eb; font-size: 28px; margin-bottom: 30px; border-bottom: 2px solid #e5e7eb; padding-bottom: 15px; text-align: center;">
|
||||
${meeting.title || '会议总结'}
|
||||
</h1>
|
||||
|
||||
<div style="background: #f9fafb; padding: 25px; margin-bottom: 35px; border-radius: 8px; border: 1px solid #e5e7eb;">
|
||||
<h2 style="color: #374151; font-size: 20px; margin: 0 0 20px; border-bottom: 1px solid #d1d5db; padding-bottom: 10px;">
|
||||
📋 会议信息
|
||||
</h2>
|
||||
<p style="margin: 12px 0; font-size: 16px;"><strong>会议时间:</strong>${meetingTime}</p>
|
||||
<p style="margin: 12px 0; font-size: 16px;"><strong>创建人:</strong>${meeting.creator_username}</p>
|
||||
<p style="margin: 12px 0; font-size: 16px;"><strong>参会人数:</strong>${meeting.attendees.length}人</p>
|
||||
<p style="margin: 12px 0; font-size: 16px;"><strong>参会人员:</strong>${attendeesList}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 style="color: #374151; font-size: 20px; margin: 0 0 20px; border-bottom: 1px solid #d1d5db; padding-bottom: 10px;">
|
||||
📝 会议摘要
|
||||
</h2>
|
||||
<div id="summary-content" style="font-size: 15px; line-height: 1.8;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #e5e7eb; text-align: center; color: #6b7280; font-size: 14px;">
|
||||
导出时间:${new Date().toLocaleString('zh-CN')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<style>
|
||||
#summary-content h1 { color: #1e293b; font-size: 22px; margin: 25px 0 15px; font-weight: 600; }
|
||||
#summary-content h2 { color: #374151; font-size: 20px; margin: 20px 0 12px; font-weight: 600; }
|
||||
#summary-content h3 { color: #475569; font-size: 18px; margin: 18px 0 10px; font-weight: 600; }
|
||||
#summary-content p { margin: 12px 0; color: #475569; }
|
||||
#summary-content ul, #summary-content ol { margin: 12px 0; padding-left: 25px; }
|
||||
#summary-content li { margin: 8px 0; color: #475569; }
|
||||
#summary-content strong { color: #1e293b; font-weight: 600; }
|
||||
#summary-content blockquote {
|
||||
border-left: 4px solid #3b82f6;
|
||||
background: #f8fafc;
|
||||
margin: 15px 0;
|
||||
padding: 15px 20px;
|
||||
font-style: italic;
|
||||
}
|
||||
#summary-content code {
|
||||
background: #f1f5f9;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
#summary-content pre {
|
||||
background: #f8fafc;
|
||||
padding: 20px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 15px 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
#summary-content table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 15px 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
#summary-content th, #summary-content td {
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
#summary-content th {
|
||||
background: #f8fafc;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
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 (
|
||||
<div className="meeting-summary" ref={exportRef}>
|
||||
<div className="summary-header">
|
||||
<h2><FileText size={20} /> 摘要内容</h2>
|
||||
{meeting?.summary && (
|
||||
<button
|
||||
className="export-pdf-btn-main"
|
||||
onClick={exportToImage}
|
||||
title="导出图片"
|
||||
>
|
||||
<Image size={16} />
|
||||
<span>导出图片</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="summary-content">
|
||||
{meeting?.summary ? (
|
||||
<div className="markdown-content">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, rehypeSanitize]}
|
||||
>
|
||||
{meeting.summary}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="no-summary">
|
||||
<div className="no-summary-content">
|
||||
<FileText size={48} />
|
||||
<h3>暂无会议总结</h3>
|
||||
<p>该会议尚未生成总结内容</p>
|
||||
{isCreator && (
|
||||
<button
|
||||
className="generate-summary-cta"
|
||||
onClick={onOpenSummaryModal}
|
||||
>
|
||||
<Brain size={16} />
|
||||
<span>生成AI总结</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MeetingSummary;
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 }) => {
|
|||
</div>
|
||||
))}
|
||||
|
||||
{/* 加载更多/加载完毕 UI */}
|
||||
<div className="timeline-footer">
|
||||
{hasMore ? (
|
||||
<button
|
||||
className="load-more-btn"
|
||||
onClick={onLoadMore}
|
||||
disabled={loadingMore}
|
||||
>
|
||||
<span>{loadingMore ? '加载中...' : '加载更多'}</span>
|
||||
</button>
|
||||
) : (
|
||||
sortedDates.length > 0 && (
|
||||
<div className="all-loaded">
|
||||
<span>已加载全部会议</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 删除会议确认对话框 */}
|
||||
<ConfirmDialog
|
||||
isOpen={!!deleteConfirmInfo}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,134 @@
|
|||
/* QR Code Modal Styles */
|
||||
.qr-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.qr-modal {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
|
||||
max-width: 450px;
|
||||
width: 90%;
|
||||
overflow: hidden;
|
||||
animation: modalFadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes modalFadeIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px) scale(0.95);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.qr-modal .modal-header {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: linear-gradient(135deg, #f8fafb 0%, #f1f5f9 100%);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.qr-modal .modal-header h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
color: #1e293b;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.qr-modal .close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.qr-modal .close-btn:hover {
|
||||
background: #e2e8f0;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.qr-modal-content {
|
||||
padding: 32px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.qr-code-container {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
border: 2px solid #e2e8f0;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.qr-code-container svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.qr-info {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.qr-description {
|
||||
margin: 0 0 8px 0;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.qr-meeting-title {
|
||||
margin: 0;
|
||||
color: #1e293b;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
padding: 12px 16px;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.qr-modal {
|
||||
max-width: 95%;
|
||||
}
|
||||
|
||||
.qr-modal-content {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.qr-code-container {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import React from 'react';
|
||||
import { QrCode, X } from 'lucide-react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import './QRCodeModal.css';
|
||||
|
||||
/**
|
||||
* QRCodeModal - 二维码分享模态框组件
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.isOpen - 是否显示模态框
|
||||
* @param {Function} props.onClose - 关闭模态框的回调函数
|
||||
* @param {string} props.url - 二维码指向的URL
|
||||
* @param {string} props.title - 显示的标题文本
|
||||
* @param {string} props.description - 描述文本(可选)
|
||||
* @param {number} props.size - 二维码尺寸(可选,默认256)
|
||||
*/
|
||||
const QRCodeModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
url,
|
||||
title,
|
||||
description = '扫描二维码访问',
|
||||
size = 256
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="qr-modal-overlay" onClick={onClose}>
|
||||
<div className="qr-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>
|
||||
<QrCode size={20} /> 分享会议
|
||||
</h3>
|
||||
<button
|
||||
className="close-btn"
|
||||
onClick={onClose}
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="qr-modal-content">
|
||||
<div className="qr-code-container">
|
||||
<QRCodeSVG
|
||||
value={url}
|
||||
size={size}
|
||||
level="H"
|
||||
includeMargin={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="qr-info">
|
||||
<p className="qr-description">{description}</p>
|
||||
<p className="qr-meeting-title">{title}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QRCodeModal;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="tag-cloud-empty">
|
||||
<Tag size={32} />
|
||||
<p>暂无标签数据</p>
|
||||
<small>创建会议时添加标签,这里将显示标签云</small>
|
||||
<div className="tag-cloud-container">
|
||||
{showHeader && (
|
||||
<div className="tag-cloud-header">
|
||||
<Tag size={18} />
|
||||
<h3>标签云</h3>
|
||||
</div>
|
||||
)}
|
||||
<div className="tag-cloud-empty">
|
||||
<Tag size={32} />
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const maxCount = Math.max(...tagStats.map(tag => tag.count));
|
||||
if (allTags.length === 0) {
|
||||
return (
|
||||
<div className="tag-cloud-container">
|
||||
{showHeader && (
|
||||
<div className="tag-cloud-header">
|
||||
<Tag size={18} />
|
||||
<h3>标签云</h3>
|
||||
</div>
|
||||
)}
|
||||
<div className="tag-cloud-empty">
|
||||
<Tag size={32} />
|
||||
<p>暂无标签数据</p>
|
||||
<small>创建会议时添加标签,这里将显示标签云</small>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tag-cloud-container">
|
||||
<div className="tag-cloud-header">
|
||||
<TrendingUp size={20} />
|
||||
<h3>标签云</h3>
|
||||
<small>点击标签筛选相关会议</small>
|
||||
</div>
|
||||
{showHeader && (
|
||||
<div className="tag-cloud-header">
|
||||
<Tag size={18} />
|
||||
<h3>标签云</h3>
|
||||
<small>点击标签筛选会议</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="tag-cloud">
|
||||
{tagStats.map((tag, index) => {
|
||||
const size = getTagSize(tag.count, maxCount);
|
||||
{allTags.map((tag) => {
|
||||
const isSelected = selectedTags.includes(tag.name);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
className={`tag-cloud-item ${size} ${isSelected ? 'selected' : ''}`}
|
||||
key={tag.id}
|
||||
className={`tag-chip ${isSelected ? 'selected' : ''}`}
|
||||
onClick={() => handleTagClick(tag.name)}
|
||||
title={`${tag.name} (${tag.count} 个会议)`}
|
||||
title={tag.name}
|
||||
>
|
||||
<span className="tag-name">{tag.name}</span>
|
||||
<span className="tag-count">{tag.count}</span>
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <PageLoading message="加载中..." />;
|
||||
}
|
||||
|
||||
|
|
@ -240,17 +334,18 @@ const Dashboard = ({ user, onLogout }) => {
|
|||
<div className="dashboard">
|
||||
<div className="error-container">
|
||||
<p>{error}</p>
|
||||
<button onClick={fetchUserData} className="retry-btn">重试</button>
|
||||
<button onClick={() => { fetchUserData(); fetchMeetings(1, false); fetchMeetingsStats(); }} className="retry-btn">重试</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="dashboard">
|
||||
|
|
@ -290,7 +385,7 @@ const Dashboard = ({ user, onLogout }) => {
|
|||
<div className="left-column">
|
||||
<div className="user-card">
|
||||
<div className="user-avatar">
|
||||
<User size={24} />
|
||||
<User size={28} />
|
||||
</div>
|
||||
<div className="user-details">
|
||||
<div className="user-name-row">
|
||||
|
|
@ -328,14 +423,14 @@ const Dashboard = ({ user, onLogout }) => {
|
|||
{/* 知识库入口卡片 */}
|
||||
<Link to="/knowledge-base" className="kb-entry-card">
|
||||
<div className="kb-entry-icon">
|
||||
<Library size={28} />
|
||||
<Library size={22} />
|
||||
</div>
|
||||
<div className="kb-entry-content">
|
||||
<h2>知识库</h2>
|
||||
<p>贯穿内容,生成知识库</p>
|
||||
</div>
|
||||
<div className="kb-entry-arrow">
|
||||
<ChevronDown size={20} style={{ transform: 'rotate(-90deg)' }} />
|
||||
<ChevronDown size={18} style={{ transform: 'rotate(-90deg)' }} />
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
|
@ -353,7 +448,7 @@ const Dashboard = ({ user, onLogout }) => {
|
|||
</div>
|
||||
<div className="stat-text">
|
||||
<span className="stat-label">我创建的会议</span>
|
||||
<span className="stat-value">{createdMeetings.length}</span>
|
||||
<span className="stat-value">{createdMeetings}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -366,7 +461,7 @@ const Dashboard = ({ user, onLogout }) => {
|
|||
</div>
|
||||
<div className="stat-text">
|
||||
<span className="stat-label">我参加的会议</span>
|
||||
<span className="stat-value">{attendedMeetings.length}</span>
|
||||
<span className="stat-value">{attendedMeetings}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -379,19 +474,32 @@ const Dashboard = ({ user, onLogout }) => {
|
|||
</div>
|
||||
<div className="stat-text">
|
||||
<span className="stat-label">全部会议</span>
|
||||
<span className="stat-value">{meetings.length}</span>
|
||||
<span className="stat-value">{allMeetings}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 标签云卡片 */}
|
||||
<div className="tag-cloud-wrapper">
|
||||
<TagCloud
|
||||
meetings={meetings}
|
||||
onTagClick={handleTagClick}
|
||||
selectedTags={selectedTags}
|
||||
/>
|
||||
{/* 搜索和标签过滤卡片 */}
|
||||
<div className="filter-card-wrapper">
|
||||
<div className="filter-card-search">
|
||||
<ExpandSearchBox
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
placeholder="搜索会议名称或发起人..."
|
||||
collapsedText="会议搜索"
|
||||
realTimeSearch={true}
|
||||
showIcon={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="filter-card-tags">
|
||||
<TagCloud
|
||||
onTagClick={handleTagClick}
|
||||
selectedTags={selectedTags}
|
||||
showHeader={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -403,7 +511,6 @@ const Dashboard = ({ user, onLogout }) => {
|
|||
<Clock size={24} />
|
||||
会议时间轴
|
||||
</h2>
|
||||
<p>按时间顺序展示您参与的所有会议</p>
|
||||
</div>
|
||||
<Link to="/meetings/create">
|
||||
<span className="create-meeting-btn">
|
||||
|
|
@ -413,10 +520,13 @@ const Dashboard = ({ user, onLogout }) => {
|
|||
</Link>
|
||||
</div>
|
||||
|
||||
<MeetingTimeline
|
||||
meetingsByDate={groupedMeetings}
|
||||
currentUser={user}
|
||||
<MeetingTimeline
|
||||
meetingsByDate={groupedMeetings}
|
||||
currentUser={user}
|
||||
onDeleteMeeting={handleDeleteMeeting}
|
||||
hasMore={pagination.has_more}
|
||||
onLoadMore={handleLoadMore}
|
||||
loadingMore={loadingMore}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
|||
<div className="modal-overlay">
|
||||
<div className="modal-content create-kb-modal">
|
||||
<div className="modal-header">
|
||||
<h2>新增知识库</h2>
|
||||
<div className="modal-header-left">
|
||||
<h2>新增知识库</h2>
|
||||
{/* 步骤指示器集成到标题栏 */}
|
||||
<div className="header-step-indicator">
|
||||
<span className={`step-tag ${createStep === 1 ? 'active' : 'completed'}`}>
|
||||
{createStep > 1 ? '✓' : '1'} 选择会议
|
||||
</span>
|
||||
<span className="step-arrow">→</span>
|
||||
<span className={`step-tag ${createStep === 2 ? 'active' : ''}`}>
|
||||
2 自定义提示词
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => {
|
||||
setShowCreateForm(false);
|
||||
setCreateStep(1);
|
||||
|
|
@ -795,26 +831,11 @@ const KnowledgeBasePage = ({ user }) => {
|
|||
}} className="close-btn">×</button>
|
||||
</div>
|
||||
|
||||
{/* 步骤指示器 */}
|
||||
<div className="step-indicator">
|
||||
<div className={`step-item ${createStep === 1 ? 'active' : ''} ${createStep > 1 ? 'completed' : ''}`}>
|
||||
<div className="step-number">1</div>
|
||||
<div className="step-label">选择会议</div>
|
||||
</div>
|
||||
<div className="step-line"></div>
|
||||
<div className={`step-item ${createStep === 2 ? 'active' : ''}`}>
|
||||
<div className="step-number">2</div>
|
||||
<div className="step-label">自定义提示词</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
{/* 步骤 1: 选择会议 */}
|
||||
{createStep === 1 && (
|
||||
<div className="form-step">
|
||||
<div className="form-group">
|
||||
<label>选择会议数据源</label>
|
||||
|
||||
{/* 紧凑的搜索和过滤区 */}
|
||||
<div className="search-filter-area">
|
||||
<input
|
||||
|
|
@ -855,12 +876,16 @@ const KnowledgeBasePage = ({ user }) => {
|
|||
</div>
|
||||
|
||||
<div className="meeting-list">
|
||||
{filteredMeetings.length === 0 ? (
|
||||
{loadingMeetings ? (
|
||||
<div className="loading-state">
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
) : meetings.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>未找到匹配的会议</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredMeetings.map(meeting => (
|
||||
meetings.map(meeting => (
|
||||
<div
|
||||
key={meeting.meeting_id}
|
||||
className={`meeting-item ${selectedMeetings.includes(meeting.meeting_id) ? 'selected' : ''}`}
|
||||
|
|
@ -877,20 +902,45 @@ const KnowledgeBasePage = ({ user }) => {
|
|||
/>
|
||||
<div className="meeting-item-content">
|
||||
<div className="meeting-item-title">{meeting.title}</div>
|
||||
{meeting.creator_username && (
|
||||
<div className="meeting-item-creator">创建人: {meeting.creator_username}</div>
|
||||
)}
|
||||
<div className="meeting-item-meta">
|
||||
{meeting.creator_username && (
|
||||
<span className="meeting-item-creator">创建人: {meeting.creator_username}</span>
|
||||
)}
|
||||
{meeting.created_at && (
|
||||
<span className="meeting-item-date">创建时间: {formatMeetingDate(meeting.created_at)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分页按钮 */}
|
||||
{!loadingMeetings && meetings.length > 0 && (
|
||||
<div className="pagination-controls">
|
||||
<button
|
||||
className="pagination-btn"
|
||||
onClick={() => handlePageChange(meetingsPagination.page - 1)}
|
||||
disabled={meetingsPagination.page === 1}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
上一页
|
||||
</button>
|
||||
<span className="pagination-info">
|
||||
第 {meetingsPagination.page} 页 · 共 {meetingsPagination.total} 条
|
||||
</span>
|
||||
<button
|
||||
className="pagination-btn"
|
||||
onClick={() => handlePageChange(meetingsPagination.page + 1)}
|
||||
disabled={!meetingsPagination.has_more}
|
||||
>
|
||||
下一页
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedMeetings.length > 0 && (
|
||||
<div className="selected-meetings-info">
|
||||
已选择 {selectedMeetings.length} 个会议
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 }) => {
|
|||
</Link>
|
||||
{isCreator && (
|
||||
<div className="meeting-actions">
|
||||
<Link to={`/meetings/edit/${meeting_id}`} className="action-btn edit-btn">
|
||||
<Link to={`/meetings/edit/${meeting_id}`} className="action-btn icon-only edit-btn" title="编辑会议">
|
||||
<Edit size={16} />
|
||||
<span>编辑会议</span>
|
||||
</Link>
|
||||
<button
|
||||
className="action-btn delete-btn"
|
||||
className="action-btn icon-only delete-btn"
|
||||
onClick={() => setDeleteConfirmInfo({ id: meeting_id, title: meeting.title })}
|
||||
title="删除会议"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
<span>删除会议</span>
|
||||
</button>
|
||||
<button
|
||||
className="action-btn icon-only qr-btn"
|
||||
onClick={() => setShowQRModal(true)}
|
||||
title="分享会议二维码"
|
||||
>
|
||||
<QrCode size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1562,6 +1570,15 @@ const MeetingDetails = ({ user }) => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* QR Code Sharing Modal */}
|
||||
<QRCodeModal
|
||||
isOpen={showQRModal}
|
||||
onClose={() => setShowQRModal(false)}
|
||||
url={`${window.location.origin}/meetings/${meeting_id}/preview`}
|
||||
title={meeting.title}
|
||||
description="扫描二维码查看会议总结"
|
||||
/>
|
||||
|
||||
{/* Toast notifications */}
|
||||
{toasts.map(toast => (
|
||||
<Toast
|
||||
|
|
|
|||
|
|
@ -0,0 +1,447 @@
|
|||
.preview-container {
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.preview-loading,
|
||||
.preview-error {
|
||||
text-align: center;
|
||||
padding: 60px 40px;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||
max-width: 500px;
|
||||
margin: 40px auto;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #2563eb;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.preview-loading p {
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 72px;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1;
|
||||
animation: errorPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes errorPulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-error h2 {
|
||||
color: #dc2626;
|
||||
margin-bottom: 16px;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.preview-error p {
|
||||
color: #64748b;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.error-retry-btn {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 32px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.error-retry-btn:hover {
|
||||
background: linear-gradient(135deg, #1d4ed8 0%, #1e40af 100%);
|
||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.error-retry-btn:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.3);
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
margin: 20px auto;
|
||||
font-family: "PingFang SC", "Microsoft YaHei", "Hiragino Sans GB", sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
color: #2563eb;
|
||||
font-size: 28px;
|
||||
margin-bottom: 30px;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
padding-bottom: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.meeting-info-section {
|
||||
background: #f9fafb;
|
||||
padding: 25px;
|
||||
margin-bottom: 35px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
color: #374151;
|
||||
font-size: 20px;
|
||||
margin: 0 0 20px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
margin: 12px 0;
|
||||
font-size: 16px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.info-item strong {
|
||||
color: #1e293b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.summary-section {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.summary-content {
|
||||
font-size: 15px;
|
||||
line-height: 1.8;
|
||||
border: 0px;
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Markdown 样式 */
|
||||
.summary-content h1 {
|
||||
color: #1e293b;
|
||||
font-size: 22px;
|
||||
margin: 25px 0 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.summary-content h2 {
|
||||
color: #374151;
|
||||
font-size: 20px;
|
||||
margin: 20px 0 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.summary-content h3 {
|
||||
color: #475569;
|
||||
font-size: 18px;
|
||||
margin: 18px 0 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.summary-content p {
|
||||
margin: 12px 0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.summary-content ul,
|
||||
.summary-content ol {
|
||||
margin: 12px 0;
|
||||
padding-left: 25px;
|
||||
}
|
||||
|
||||
.summary-content li {
|
||||
margin: 8px 0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.summary-content strong {
|
||||
color: #1e293b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.summary-content blockquote {
|
||||
border-left: 4px solid #3b82f6;
|
||||
background: #f8fafc;
|
||||
margin: 15px 0;
|
||||
padding: 15px 20px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.summary-content code {
|
||||
background: #f1f5f9;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-family: "Courier New", "Consolas", "Monaco", monospace;
|
||||
color: #e11d48;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.summary-content pre {
|
||||
background: #1e293b;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
margin: 20px 0;
|
||||
border: 1px solid #334155;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.summary-content pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: #e2e8f0;
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.summary-content img {
|
||||
max-width: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 20px auto;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.summary-content table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 15px 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.summary-content th,
|
||||
.summary-content td {
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.summary-content th {
|
||||
background: #f8fafc;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.summary-content tbody tr:nth-child(even) {
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.summary-content tbody tr:hover {
|
||||
background: #f1f5f9;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.summary-content a {
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.summary-content a:hover {
|
||||
color: #1d4ed8;
|
||||
border-bottom-color: #1d4ed8;
|
||||
}
|
||||
|
||||
.summary-content hr {
|
||||
border: none;
|
||||
border-top: 2px solid #e5e7eb;
|
||||
margin: 30px 0;
|
||||
background: linear-gradient(to right, transparent, #e5e7eb, transparent);
|
||||
}
|
||||
|
||||
.preview-footer {
|
||||
margin-top: 40px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 移动端适配 */
|
||||
@media (max-width: 768px) {
|
||||
.preview-container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
padding: 20px 15px;
|
||||
margin: 10px auto;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
font-size: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.summary-content {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.summary-content h1 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.summary-content h2 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.summary-content h3 {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.summary-content pre {
|
||||
padding: 15px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.summary-content table {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.summary-content th,
|
||||
.summary-content td {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.summary-content img {
|
||||
margin: 15px auto;
|
||||
max-width: 100%;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.summary-content pre {
|
||||
border-radius: 6px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 平板适配 */
|
||||
@media (min-width: 769px) and (max-width: 1024px) {
|
||||
.preview-content {
|
||||
max-width: 700px;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 打印样式优化 */
|
||||
@media print {
|
||||
.preview-container {
|
||||
background: white;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
max-width: 100%;
|
||||
box-shadow: none;
|
||||
padding: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
color: #000;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
page-break-after: avoid;
|
||||
}
|
||||
|
||||
.meeting-info-section,
|
||||
.summary-section {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.summary-content pre {
|
||||
page-break-inside: avoid;
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.summary-content pre code {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.summary-content table {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.summary-content img {
|
||||
page-break-inside: avoid;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.preview-footer {
|
||||
page-break-before: avoid;
|
||||
}
|
||||
|
||||
/* 打印时显示链接地址 */
|
||||
.summary-content a::after {
|
||||
content: " (" attr(href) ")";
|
||||
font-size: 0.85em;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
import './MeetingPreview.css';
|
||||
|
||||
const MeetingPreview = () => {
|
||||
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 (
|
||||
<div className="preview-container">
|
||||
<div className="preview-loading">
|
||||
<div className="loading-spinner"></div>
|
||||
<p>正在加载会议总结...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="preview-container">
|
||||
<div className="preview-error">
|
||||
<div className="error-icon">⚠️</div>
|
||||
<h2>加载失败</h2>
|
||||
<p>{error}</p>
|
||||
<button
|
||||
className="error-retry-btn"
|
||||
onClick={fetchMeetingPreviewData}
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!meetingData) {
|
||||
return (
|
||||
<div className="preview-container">
|
||||
<div className="preview-error">
|
||||
<h2>未找到会议数据</h2>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const attendeesList = meetingData.attendees
|
||||
.map(attendee => attendee.caption)
|
||||
.join('、');
|
||||
|
||||
return (
|
||||
<div className="preview-container">
|
||||
<div className="preview-content">
|
||||
<h1 className="preview-title">
|
||||
{meetingData.title || '会议总结'}
|
||||
</h1>
|
||||
|
||||
<div className="meeting-info-section">
|
||||
<h2 className="section-title">📋 会议信息</h2>
|
||||
<div className="info-item">
|
||||
<strong>会议时间:</strong>
|
||||
{formatDateTime(meetingData.meeting_time)}
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<strong>创建人:</strong>
|
||||
{meetingData.creator_username}
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<strong>参会人数:</strong>
|
||||
{meetingData.attendees_count}人
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<strong>参会人员:</strong>
|
||||
{attendeesList}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="summary-section">
|
||||
<h2 className="section-title">📝 会议摘要</h2>
|
||||
<div className="summary-content">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, rehypeSanitize]}
|
||||
>
|
||||
{meetingData.summary}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="preview-footer">
|
||||
导出时间:{new Date().toLocaleString('zh-CN')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MeetingPreview;
|
||||
|
|
@ -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 = () => {
|
|||
|
||||
<div className="config-form-group">
|
||||
<label>
|
||||
系统提示词
|
||||
<span className="label-hint">(system_prompt)</span>
|
||||
声纹提示词模板
|
||||
<span className="label-hint">(template_text)</span>
|
||||
</label>
|
||||
<textarea
|
||||
className="config-input config-textarea"
|
||||
value={configs.system_prompt}
|
||||
onChange={(e) => handleInputChange('system_prompt', e.target.value)}
|
||||
placeholder="请输入系统提示词"
|
||||
value={configs.template_text}
|
||||
onChange={(e) => handleInputChange('template_text', e.target.value)}
|
||||
placeholder="请输入声纹采集提示词模板"
|
||||
rows={6}
|
||||
/>
|
||||
<div className="config-input-hint">
|
||||
定义AI助手的行为和回答风格,这将影响所有AI生成的内容
|
||||
定义声纹采集时用户需要朗读的文本内容,用于生成用户的声纹特征
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -224,20 +231,20 @@ const SystemConfiguration = () => {
|
|||
|
||||
<div className="config-form-group">
|
||||
<label>
|
||||
最大图片大小
|
||||
<span className="label-hint">(MAX_IMAGE_SIZE)</span>
|
||||
会议列表分页大小
|
||||
<span className="label-hint">(TIMELINE_PAGESIZE)</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="config-input"
|
||||
value={bytesToMB(configs.MAX_IMAGE_SIZE)}
|
||||
onChange={(e) => handleInputChange('MAX_IMAGE_SIZE', e.target.value)}
|
||||
placeholder="请输入图片大小限制(MB)"
|
||||
min="1"
|
||||
value={configs.TIMELINE_PAGESIZE}
|
||||
onChange={(e) => handleInputChange('TIMELINE_PAGESIZE', e.target.value)}
|
||||
placeholder="请输入分页大小"
|
||||
min="5"
|
||||
max="100"
|
||||
/>
|
||||
<div className="config-input-hint">
|
||||
用户上传图片的大小限制,单位为MB,建议设置为5-20MB
|
||||
会议时间轴每页显示的会议数量,建议设置为10-50
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,223 @@
|
|||
# 会议缓存服务使用指南
|
||||
|
||||
## 概述
|
||||
`meetingCacheService` 是一个独立的缓存服务,用于缓存会议列表的查询结果,减少不必要的网络请求,提升用户体验。
|
||||
|
||||
## 导入
|
||||
|
||||
```javascript
|
||||
import meetingCacheService from '../services/meetingCacheService';
|
||||
```
|
||||
|
||||
## 核心API
|
||||
|
||||
### 1. 生成过滤器键
|
||||
```javascript
|
||||
const filterKey = meetingCacheService.generateFilterKey(
|
||||
filterType, // 'all' | 'created' | 'attended'
|
||||
searchQuery, // 搜索关键词字符串
|
||||
selectedTags // 标签数组 ['tag1', 'tag2']
|
||||
);
|
||||
```
|
||||
|
||||
### 2. 获取缓存数据
|
||||
|
||||
#### 获取单页缓存
|
||||
```javascript
|
||||
const cachedPage = meetingCacheService.getPage(filterKey, pageNumber);
|
||||
// 返回: { meetings: [...], pagination: {...} } 或 null
|
||||
```
|
||||
|
||||
#### 获取所有页缓存
|
||||
```javascript
|
||||
const allCached = meetingCacheService.getAllPages(filterKey);
|
||||
// 返回: { pages: { 1: {...}, 2: {...} }, timestamp: number } 或 null
|
||||
```
|
||||
|
||||
### 3. 设置缓存
|
||||
```javascript
|
||||
meetingCacheService.setPage(
|
||||
filterKey,
|
||||
pageNumber,
|
||||
meetingsArray,
|
||||
paginationObject
|
||||
);
|
||||
```
|
||||
|
||||
### 4. 清除缓存
|
||||
|
||||
#### 清除所有缓存
|
||||
```javascript
|
||||
meetingCacheService.clearAll();
|
||||
```
|
||||
|
||||
#### 清除特定过滤器
|
||||
```javascript
|
||||
meetingCacheService.clearFilter(filterKey);
|
||||
```
|
||||
|
||||
#### 清除匹配模式的缓存
|
||||
```javascript
|
||||
// 清除所有包含 'created' 的过滤器缓存
|
||||
meetingCacheService.clearPattern('created');
|
||||
```
|
||||
|
||||
### 5. 检查缓存状态
|
||||
|
||||
#### 检查过滤器是否存在
|
||||
```javascript
|
||||
const hasFilter = meetingCacheService.hasFilter(filterKey);
|
||||
// 返回: boolean
|
||||
```
|
||||
|
||||
#### 检查特定页是否存在
|
||||
```javascript
|
||||
const hasPage = meetingCacheService.hasPage(filterKey, pageNumber);
|
||||
// 返回: boolean
|
||||
```
|
||||
|
||||
### 6. 获取统计信息
|
||||
```javascript
|
||||
const stats = meetingCacheService.getStats();
|
||||
// 返回: {
|
||||
// filterCount: number, // 缓存的过滤器数量
|
||||
// totalPages: number, // 总页数
|
||||
// totalMeetings: number, // 总会议数
|
||||
// cacheSize: number // 估算大小(KB)
|
||||
// }
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 完整的数据获取流程
|
||||
|
||||
```javascript
|
||||
const fetchMeetings = async (page = 1, isLoadMore = false) => {
|
||||
try {
|
||||
// 1. 生成过滤器键
|
||||
const filterKey = meetingCacheService.generateFilterKey(
|
||||
filterType,
|
||||
searchQuery,
|
||||
selectedTags
|
||||
);
|
||||
|
||||
// 2. 检查缓存
|
||||
if (!isLoadMore) {
|
||||
// 新查询,检查第一页缓存
|
||||
const allCached = meetingCacheService.getAllPages(filterKey);
|
||||
if (allCached && allCached.pages[1]) {
|
||||
console.log('Using cached data');
|
||||
setMeetings(allCached.pages[1].meetings);
|
||||
setPagination(allCached.pages[1].pagination);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 加载更多,检查该页缓存
|
||||
const cachedPage = meetingCacheService.getPage(filterKey, page);
|
||||
if (cachedPage) {
|
||||
console.log('Using cached page:', page);
|
||||
setMeetings(prev => [...prev, ...cachedPage.meetings]);
|
||||
setPagination(cachedPage.pagination);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 没有缓存,从服务器获取
|
||||
setLoading(true);
|
||||
const response = await apiClient.get('/api/meetings', { params });
|
||||
|
||||
// 4. 缓存数据
|
||||
meetingCacheService.setPage(
|
||||
filterKey,
|
||||
page,
|
||||
response.data.meetings,
|
||||
response.data.pagination
|
||||
);
|
||||
|
||||
// 5. 更新UI
|
||||
if (isLoadMore) {
|
||||
setMeetings(prev => [...prev, ...response.data.meetings]);
|
||||
} else {
|
||||
setMeetings(response.data.meetings);
|
||||
}
|
||||
setPagination(response.data.pagination);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 数据变更时清除缓存
|
||||
|
||||
```javascript
|
||||
const handleDeleteMeeting = async (meetingId) => {
|
||||
await apiClient.delete(`/api/meetings/${meetingId}`);
|
||||
|
||||
// 清除所有缓存
|
||||
meetingCacheService.clearAll();
|
||||
|
||||
// 重新加载数据
|
||||
await fetchMeetings(1, false);
|
||||
};
|
||||
```
|
||||
|
||||
## 缓存策略
|
||||
|
||||
### 缓存时间
|
||||
- **TTL**: 5分钟
|
||||
- 超过5分钟的缓存会自动失效并被清除
|
||||
|
||||
### 缓存结构
|
||||
```javascript
|
||||
{
|
||||
"filterKey1": {
|
||||
pages: {
|
||||
1: { meetings: [...], pagination: {...} },
|
||||
2: { meetings: [...], pagination: {...} }
|
||||
},
|
||||
timestamp: 1234567890
|
||||
},
|
||||
"filterKey2": {
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 过滤器键格式
|
||||
格式: `filterType|searchQuery|tags`
|
||||
示例:
|
||||
- `all||` - 全部会议,无搜索,无标签
|
||||
- `created||` - 我创建的会议
|
||||
- `all|meeting|tag1,tag2` - 全部会议,搜索"meeting",标签"tag1,tag2"
|
||||
|
||||
## 调试工具
|
||||
|
||||
在开发环境下,控制台会自动注册调试命令:
|
||||
|
||||
```javascript
|
||||
// 查看缓存统计
|
||||
window.meetingCache.stats()
|
||||
|
||||
// 查看详细信息
|
||||
window.meetingCache.info()
|
||||
|
||||
// 清除所有缓存
|
||||
window.meetingCache.clear()
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **及时清除缓存**: 数据变更(增删改)后立即清除相关缓存
|
||||
2. **合理使用**: 只缓存频繁访问的数据
|
||||
3. **监控缓存大小**: 定期检查缓存占用情况
|
||||
4. **错误处理**: 缓存失败时应该降级到正常的网络请求
|
||||
|
||||
## 性能优势
|
||||
|
||||
- ✅ **减少网络请求**: 相同查询直接从内存读取
|
||||
- ✅ **即时响应**: 缓存命中时响应 < 10ms
|
||||
- ✅ **降低服务器负载**: 减少重复查询
|
||||
- ✅ **提升用户体验**: 无刷新切换过滤条件
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
/**
|
||||
* 会议数据缓存服务
|
||||
* 用于缓存会议列表的查询结果,减少不必要的网络请求,提升用户体验
|
||||
*/
|
||||
|
||||
class MeetingCacheService {
|
||||
constructor() {
|
||||
// 缓存结构: filterKey -> { pages: { pageNum: {meetings, pagination} }, timestamp }
|
||||
this.cache = {};
|
||||
// 默认缓存时间: 5分钟
|
||||
this.defaultTTL = 5 * 60 * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成过滤器键(不包含页码)
|
||||
* @param {string} filterType - 过滤类型: 'all', 'created', 'attended'
|
||||
* @param {string} searchQuery - 搜索关键词
|
||||
* @param {Array<string>} selectedTags - 选中的标签列表
|
||||
* @returns {string} 过滤器键
|
||||
*/
|
||||
generateFilterKey(filterType, searchQuery = '', selectedTags = []) {
|
||||
const tagsStr = selectedTags.length > 0 ? selectedTags.sort().join(',') : '';
|
||||
return `${filterType}|${searchQuery}|${tagsStr}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查缓存是否过期
|
||||
* @param {number} timestamp - 缓存时间戳
|
||||
* @returns {boolean} 是否过期
|
||||
*/
|
||||
isExpired(timestamp) {
|
||||
return Date.now() - timestamp > this.defaultTTL;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定页的缓存数据
|
||||
* @param {string} filterKey - 过滤器键
|
||||
* @param {number} page - 页码
|
||||
* @returns {Object|null} 缓存的数据或null
|
||||
*/
|
||||
getPage(filterKey, page) {
|
||||
const filterCache = this.cache[filterKey];
|
||||
|
||||
if (!filterCache) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if (this.isExpired(filterCache.timestamp)) {
|
||||
delete this.cache[filterKey];
|
||||
return null;
|
||||
}
|
||||
|
||||
return filterCache.pages[page] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取过滤器下所有已缓存的页面
|
||||
* @param {string} filterKey - 过滤器键
|
||||
* @returns {Object|null} 所有缓存的页面数据或null
|
||||
*/
|
||||
getAllPages(filterKey) {
|
||||
const filterCache = this.cache[filterKey];
|
||||
|
||||
if (!filterCache) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if (this.isExpired(filterCache.timestamp)) {
|
||||
delete this.cache[filterKey];
|
||||
return null;
|
||||
}
|
||||
|
||||
return filterCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置指定页的缓存
|
||||
* @param {string} filterKey - 过滤器键
|
||||
* @param {number} page - 页码
|
||||
* @param {Array} meetings - 会议列表数据
|
||||
* @param {Object} pagination - 分页信息
|
||||
*/
|
||||
setPage(filterKey, page, meetings, pagination) {
|
||||
if (!this.cache[filterKey]) {
|
||||
this.cache[filterKey] = {
|
||||
pages: {},
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
this.cache[filterKey].pages[page] = {
|
||||
meetings,
|
||||
pagination
|
||||
};
|
||||
|
||||
// 更新时间戳
|
||||
this.cache[filterKey].timestamp = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定过滤器的缓存
|
||||
* @param {string} filterKey - 过滤器键
|
||||
*/
|
||||
clearFilter(filterKey) {
|
||||
delete this.cache[filterKey];
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除匹配模式的缓存
|
||||
* @param {string} pattern - 匹配模式(可选)
|
||||
*/
|
||||
clearPattern(pattern) {
|
||||
if (!pattern) {
|
||||
// 清除所有缓存
|
||||
this.cache = {};
|
||||
return;
|
||||
}
|
||||
|
||||
// 清除匹配的缓存
|
||||
Object.keys(this.cache).forEach(key => {
|
||||
if (key.includes(pattern)) {
|
||||
delete this.cache[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有缓存
|
||||
*/
|
||||
clearAll() {
|
||||
this.cache = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存统计信息(用于调试)
|
||||
* @returns {Object} 缓存统计信息
|
||||
*/
|
||||
getStats() {
|
||||
const filterCount = Object.keys(this.cache).length;
|
||||
let totalPages = 0;
|
||||
let totalMeetings = 0;
|
||||
|
||||
Object.values(this.cache).forEach(filterCache => {
|
||||
const pages = Object.keys(filterCache.pages).length;
|
||||
totalPages += pages;
|
||||
|
||||
Object.values(filterCache.pages).forEach(page => {
|
||||
totalMeetings += page.meetings.length;
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
filterCount, // 缓存的过滤器数量
|
||||
totalPages, // 缓存的总页数
|
||||
totalMeetings, // 缓存的总会议数
|
||||
cacheSize: this._estimateSize() // 估算缓存大小(KB)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 估算缓存占用的内存大小
|
||||
* @private
|
||||
* @returns {number} 估算大小(KB)
|
||||
*/
|
||||
_estimateSize() {
|
||||
const jsonStr = JSON.stringify(this.cache);
|
||||
return Math.round(jsonStr.length / 1024);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查缓存中是否存在指定的过滤器
|
||||
* @param {string} filterKey - 过滤器键
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasFilter(filterKey) {
|
||||
const filterCache = this.cache[filterKey];
|
||||
if (!filterCache) return false;
|
||||
return !this.isExpired(filterCache.timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查缓存中是否存在指定的页面
|
||||
* @param {string} filterKey - 过滤器键
|
||||
* @param {number} page - 页码
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasPage(filterKey, page) {
|
||||
return this.getPage(filterKey, page) !== null;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例实例
|
||||
const meetingCacheService = new MeetingCacheService();
|
||||
|
||||
export default meetingCacheService;
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* 简单的内存缓存管理器
|
||||
* 用于缓存API响应数据,减少不必要的网络请求
|
||||
*/
|
||||
|
||||
class CacheManager {
|
||||
constructor() {
|
||||
this.cache = new Map();
|
||||
this.timestamps = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成缓存键
|
||||
* @param {string} key - 基础键名
|
||||
* @param {object} params - 参数对象
|
||||
* @returns {string} 缓存键
|
||||
*/
|
||||
generateKey(key, params = {}) {
|
||||
const sortedParams = Object.keys(params)
|
||||
.sort()
|
||||
.map(k => `${k}=${params[k]}`)
|
||||
.join('&');
|
||||
return sortedParams ? `${key}?${sortedParams}` : key;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置缓存
|
||||
* @param {string} key - 缓存键
|
||||
* @param {any} data - 要缓存的数据
|
||||
* @param {number} ttl - 过期时间(毫秒),默认5分钟
|
||||
*/
|
||||
set(key, data, ttl = 5 * 60 * 1000) {
|
||||
this.cache.set(key, data);
|
||||
this.timestamps.set(key, Date.now() + ttl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存
|
||||
* @param {string} key - 缓存键
|
||||
* @returns {any|null} 缓存的数据,如果不存在或已过期则返回null
|
||||
*/
|
||||
get(key) {
|
||||
const timestamp = this.timestamps.get(key);
|
||||
|
||||
// 检查是否过期
|
||||
if (!timestamp || Date.now() > timestamp) {
|
||||
this.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.cache.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除缓存
|
||||
* @param {string} key - 缓存键
|
||||
*/
|
||||
delete(key) {
|
||||
this.cache.delete(key);
|
||||
this.timestamps.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有缓存
|
||||
*/
|
||||
clear() {
|
||||
this.cache.clear();
|
||||
this.timestamps.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除匹配模式的缓存
|
||||
* @param {string} pattern - 匹配模式(支持前缀匹配)
|
||||
*/
|
||||
clearPattern(pattern) {
|
||||
const keys = Array.from(this.cache.keys());
|
||||
keys.forEach(key => {
|
||||
if (key.startsWith(pattern)) {
|
||||
this.delete(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查缓存是否存在且有效
|
||||
* @param {string} key - 缓存键
|
||||
* @returns {boolean}
|
||||
*/
|
||||
has(key) {
|
||||
return this.get(key) !== null;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例实例
|
||||
const cacheManager = new CacheManager();
|
||||
|
||||
// 定义缓存键常量
|
||||
export const CACHE_KEYS = {
|
||||
MEETINGS: 'meetings',
|
||||
MEETINGS_STATS: 'meetings_stats',
|
||||
TAGS: 'tags',
|
||||
USER: 'user',
|
||||
};
|
||||
|
||||
// 定义缓存过期时间(毫秒)
|
||||
export const CACHE_TTL = {
|
||||
SHORT: 2 * 60 * 1000, // 2分钟 - 用于会议列表
|
||||
MEDIUM: 5 * 60 * 1000, // 5分钟 - 用于统计数据
|
||||
LONG: 10 * 60 * 1000, // 10分钟 - 用于标签列表
|
||||
VERY_LONG: 30 * 60 * 1000, // 30分钟 - 用于用户信息
|
||||
};
|
||||
|
||||
export default cacheManager;
|
||||
|
|
@ -2928,6 +2928,11 @@ punycode@^2.1.0:
|
|||
resolved "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz"
|
||||
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
||||
|
||||
qrcode.react@^4.2.0:
|
||||
version "4.2.0"
|
||||
resolved "https://registry.npmmirror.com/qrcode.react/-/qrcode.react-4.2.0.tgz"
|
||||
integrity sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==
|
||||
|
||||
raf@^3.4.1:
|
||||
version "3.4.1"
|
||||
resolved "https://registry.npmmirror.com/raf/-/raf-3.4.1.tgz"
|
||||
|
|
|
|||
Loading…
Reference in New Issue