修改了会议获取接口

main
mula.liu 2025-11-07 17:11:54 +08:00
parent cdfcceaf47
commit 5cee370e87
28 changed files with 2489 additions and 672 deletions

BIN
.DS_Store vendored

Binary file not shown.

BIN
dist.zip

Binary file not shown.

10
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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>

View File

@ -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%;
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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);
}

View File

@ -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}

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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>
);
})}

View File

@ -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',

View File

@ -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;
}

View File

@ -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>

View File

@ -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;
}

View File

@ -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>
)}

View File

@ -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);
}

View File

@ -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

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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>

View File

@ -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
- ✅ **降低服务器负载**: 减少重复查询
- ✅ **提升用户体验**: 无刷新切换过滤条件

View File

@ -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;

View File

@ -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;

View File

@ -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"