From 82fd803e6db3a96dfda1cd4a5f5057ae49b8b101 Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Tue, 26 Aug 2025 21:59:15 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0AI=E6=91=98=E8=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.html | 2 +- package.json | 1 + src/components/DateTimePicker.css | 258 ++++++++++++ src/components/DateTimePicker.jsx | 260 ++++++++++++ src/components/MeetingTimeline.jsx | 4 +- src/pages/CreateMeeting.jsx | 15 +- src/pages/EditMeeting.jsx | 18 +- src/pages/MeetingDetails.css | 623 +++++++++++++++++++++++++++-- src/pages/MeetingDetails.jsx | 570 ++++++++++++++++++++++++-- vite.config.js | 2 +- yarn.lock | 397 +++++++++++++++++- 11 files changed, 2050 insertions(+), 100 deletions(-) create mode 100644 src/components/DateTimePicker.css create mode 100644 src/components/DateTimePicker.jsx diff --git a/index.html b/index.html index 0c589ec..6dac6df 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - Vite + React + 会议助手
diff --git a/package.json b/package.json index 930ce5c..23709c8 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dependencies": { "@uiw/react-md-editor": "^4.0.8", "axios": "^1.6.2", + "jspdf": "^3.0.2", "lucide-react": "^0.294.0", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/src/components/DateTimePicker.css b/src/components/DateTimePicker.css new file mode 100644 index 0000000..ded0a98 --- /dev/null +++ b/src/components/DateTimePicker.css @@ -0,0 +1,258 @@ +.datetime-picker { + position: relative; + width: 100%; +} + +.datetime-display { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + border: 1px solid #d1d5db; + border-radius: 8px; + background: white; + cursor: pointer; + transition: all 0.2s ease; + min-height: 48px; +} + +.datetime-display:hover { + border-color: #9ca3af; +} + +.datetime-display:focus-within, +.datetime-display:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +.display-text { + flex: 1; + color: #374151; + font-size: 14px; +} + +.display-text.placeholder { + color: #9ca3af; +} + +.clear-btn { + background: none; + border: none; + color: #9ca3af; + font-size: 18px; + cursor: pointer; + padding: 4px; + border-radius: 4px; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; +} + +.clear-btn:hover { + background: #f3f4f6; + color: #6b7280; +} + +.datetime-picker-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.1); + z-index: 10; + pointer-events: auto; +} + +.datetime-picker-panel { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: white; + border: 1px solid #e5e7eb; + border-radius: 8px; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15); + z-index: 20; + margin-top: 4px; + padding: 20px; + min-width: 320px; + max-height: 500px; + height: auto; + min-height: 400px; + overflow-y: auto; +} + +.picker-section { + margin-bottom: 24px; +} + +.picker-section:last-of-type { + margin-bottom: 16px; +} + +.picker-section h4 { + margin: 0 0 12px 0; + color: #374151; + font-size: 14px; + font-weight: 600; +} + +.quick-date-options, +.quick-time-options { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + margin-bottom: 16px; +} + +.quick-time-options { + grid-template-columns: repeat(4, 1fr); +} + +.quick-option { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 6px; + padding: 8px 12px; + font-size: 13px; + cursor: pointer; + transition: all 0.2s ease; + text-align: center; +} + +.quick-option:hover { + background: #f1f5f9; + border-color: #cbd5e1; +} + +.quick-option.selected { + background: #667eea; + border-color: #667eea; + color: white; +} + +.custom-date-input, +.custom-time-input { + display: flex; + align-items: center; + gap: 8px; +} + +.custom-time-input { + background: #f8fafc; + padding: 8px 12px; + border-radius: 6px; + border: 1px solid #e2e8f0; +} + +.date-input, +.time-input { + border: 1px solid #d1d5db; + border-radius: 6px; + padding: 8px 12px; + font-size: 14px; + background: white; + transition: all 0.2s ease; + flex: 1; +} + +.time-input { + border: none; + background: transparent; + flex: 1; +} + +.date-input:focus, +.time-input:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +.time-input:focus { + box-shadow: none; +} + +.picker-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + padding-top: 16px; + border-top: 1px solid #e5e7eb; +} + +.action-btn { + padding: 8px 16px; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + border: 1px solid transparent; +} + +.action-btn.cancel { + background: #f8fafc; + color: #6b7280; + border-color: #d1d5db; +} + +.action-btn.cancel:hover { + background: #f1f5f9; + border-color: #9ca3af; +} + +.action-btn.confirm { + background: #667eea; + color: white; +} + +.action-btn.confirm:hover { + background: #5a67d8; +} + +/* 响应式设计 */ +@media (max-width: 768px) { + .datetime-picker-panel { + min-width: 280px; + padding: 16px; + } + + .quick-date-options { + grid-template-columns: repeat(2, 1fr); + } + + .quick-time-options { + grid-template-columns: repeat(3, 1fr); + } + + .picker-actions { + flex-direction: column; + gap: 8px; + } + + .action-btn { + width: 100%; + text-align: center; + } +} + +/* 改进输入框在Safari中的显示 */ +.date-input::-webkit-calendar-picker-indicator, +.time-input::-webkit-calendar-picker-indicator { + background: transparent; + color: #6b7280; + cursor: pointer; +} + +.date-input::-webkit-calendar-picker-indicator:hover, +.time-input::-webkit-calendar-picker-indicator:hover { + background: #f3f4f6; + border-radius: 4px; +} \ No newline at end of file diff --git a/src/components/DateTimePicker.jsx b/src/components/DateTimePicker.jsx new file mode 100644 index 0000000..1fc22d6 --- /dev/null +++ b/src/components/DateTimePicker.jsx @@ -0,0 +1,260 @@ +import React, { useState, useEffect } from 'react'; +import { Calendar, Clock } from 'lucide-react'; +import './DateTimePicker.css'; + +const DateTimePicker = ({ value, onChange, placeholder = "选择会议时间" }) => { + const [date, setDate] = useState(''); + const [time, setTime] = useState(''); + const [showQuickSelect, setShowQuickSelect] = useState(false); + const [isInitialized, setIsInitialized] = useState(false); + + // 组件卸载时清理状态 + useEffect(() => { + return () => { + setShowQuickSelect(false); + }; + }, []); + + // 初始化时间值 + useEffect(() => { + if (value && !isInitialized) { + const dateObj = new Date(value); + if (!isNaN(dateObj.getTime())) { + // 转换为本地时间字符串 + const timeZoneOffset = dateObj.getTimezoneOffset() * 60000; + const localDate = new Date(dateObj.getTime() - timeZoneOffset); + const isoString = localDate.toISOString(); + + setDate(isoString.split('T')[0]); + setTime(isoString.split('T')[1].slice(0, 5)); + } + setIsInitialized(true); + } else if (!value && !isInitialized) { + setDate(''); + setTime(''); + setIsInitialized(true); + } + }, [value, isInitialized]); + + // 当日期或时间改变时,更新父组件的值 + useEffect(() => { + // 只在初始化完成后才触发onChange + if (!isInitialized) return; + + if (date && time) { + const dateTimeString = `${date}T${time}`; + onChange?.(dateTimeString); + } else if (!date && !time) { + onChange?.(''); + } + }, [date, time, isInitialized]); // 移除onChange依赖 + + // 快速选择时间的选项 + const timeOptions = [ + { label: '09:00', value: '09:00' }, + { label: '10:00', value: '10:00' }, + { label: '11:00', value: '11:00' }, + { label: '14:00', value: '14:00' }, + { label: '15:00', value: '15:00' }, + { label: '16:00', value: '16:00' }, + { label: '17:00', value: '17:00' }, + ]; + + // 快速选择日期的选项 + const getQuickDateOptions = () => { + const today = new Date(); + const options = []; + + // 今天 + options.push({ + label: '今天', + value: today.toISOString().split('T')[0] + }); + + // 明天 + const tomorrow = new Date(today); + tomorrow.setDate(today.getDate() + 1); + options.push({ + label: '明天', + value: tomorrow.toISOString().split('T')[0] + }); + + // 后天 + const dayAfterTomorrow = new Date(today); + dayAfterTomorrow.setDate(today.getDate() + 2); + options.push({ + label: '后天', + value: dayAfterTomorrow.toISOString().split('T')[0] + }); + + return options; + }; + + const quickDateOptions = getQuickDateOptions(); + + const formatDisplayText = () => { + if (!date && !time) return placeholder; + + if (date && time) { + const dateObj = new Date(`${date}T${time}`); + return dateObj.toLocaleString('zh-CN', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } + + if (date) { + const dateObj = new Date(date); + return dateObj.toLocaleDateString('zh-CN', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + } + + return placeholder; + }; + + const clearDateTime = () => { + setDate(''); + setTime(''); + // 重置初始化状态,允许后续值的设定 + setIsInitialized(false); + onChange?.(''); + }; + + return ( +
+
{ + e.stopPropagation(); + setShowQuickSelect(!showQuickSelect); + }}> + + + {formatDisplayText()} + + {(date || time) && ( + + )} +
+ + {showQuickSelect && ( +
+
+

选择日期

+
+ {quickDateOptions.map((option) => ( + + ))} +
+
+ { + e.preventDefault(); + e.stopPropagation(); + setDate(e.target.value); + }} + onClick={(e) => e.stopPropagation()} + className="date-input" + /> +
+
+ +
+

选择时间

+
+ {timeOptions.map((option) => ( + + ))} +
+
+ + { + e.preventDefault(); + e.stopPropagation(); + setTime(e.target.value); + }} + onClick={(e) => e.stopPropagation()} + className="time-input" + /> +
+
+ +
+ + +
+
+ )} + + {showQuickSelect && ( +
setShowQuickSelect(false)} + /> + )} +
+ ); +}; + +export default DateTimePicker; \ No newline at end of file diff --git a/src/components/MeetingTimeline.jsx b/src/components/MeetingTimeline.jsx index 7edb0e8..e159eb8 100644 --- a/src/components/MeetingTimeline.jsx +++ b/src/components/MeetingTimeline.jsx @@ -34,7 +34,7 @@ const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting }) => { return date.toLocaleDateString('zh-CN', { month: 'long', day: 'numeric' }); }; - const truncateSummary = (summary, maxLines = 3, maxLength = 80) => { + const truncateSummary = (summary, maxLines = 3, maxLength = 100) => { if (!summary) return '暂无摘要'; // Split by lines and check line count @@ -59,7 +59,7 @@ const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting }) => { return summary; }; - const shouldShowMoreButton = (summary, maxLines = 3, maxLength = 80) => { + const shouldShowMoreButton = (summary, maxLines = 3, maxLength = 100) => { if (!summary) return false; const lines = summary.split('\n'); return lines.length > maxLines || summary.length > maxLength; diff --git a/src/pages/CreateMeeting.jsx b/src/pages/CreateMeeting.jsx index 8896898..92db85d 100644 --- a/src/pages/CreateMeeting.jsx +++ b/src/pages/CreateMeeting.jsx @@ -3,6 +3,7 @@ import { Link, useNavigate } from 'react-router-dom'; import axios from 'axios'; import { ArrowLeft, Upload, Users, Calendar, FileText, X, User, Plus } from 'lucide-react'; import { buildApiUrl, API_ENDPOINTS } from '../config/api'; +import DateTimePicker from '../components/DateTimePicker'; import './CreateMeeting.css'; const CreateMeeting = ({ user }) => { @@ -169,20 +170,14 @@ const CreateMeeting = ({ user }) => {
-
diff --git a/src/pages/EditMeeting.jsx b/src/pages/EditMeeting.jsx index 288e070..781c217 100644 --- a/src/pages/EditMeeting.jsx +++ b/src/pages/EditMeeting.jsx @@ -5,6 +5,7 @@ import { ArrowLeft, Users, Calendar, FileText, X, User, Save, Upload, Plus, Imag import MDEditor, * as commands from '@uiw/react-md-editor'; import '@uiw/react-md-editor/markdown-editor.css'; import { buildApiUrl, API_ENDPOINTS, API_BASE_URL } from '../config/api'; +import DateTimePicker from '../components/DateTimePicker'; import './EditMeeting.css'; const EditMeeting = ({ user }) => { @@ -48,8 +49,7 @@ const EditMeeting = ({ user }) => { setMeeting(meetingData); setFormData({ title: meetingData.title, - meeting_time: meetingData.meeting_time ? - new Date(meetingData.meeting_time).toISOString().slice(0, 16) : '', + meeting_time: meetingData.meeting_time || '', summary: meetingData.summary || '', attendees: meetingData.attendees || [] }); @@ -378,20 +378,14 @@ const EditMeeting = ({ user }) => {
-
diff --git a/src/pages/MeetingDetails.css b/src/pages/MeetingDetails.css index 30b9835..140ff22 100644 --- a/src/pages/MeetingDetails.css +++ b/src/pages/MeetingDetails.css @@ -12,6 +12,55 @@ align-items: center; } +.meeting-actions { + display: flex; + gap: 0.5rem; +} + +.action-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + border: none; + border-radius: 8px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + text-decoration: none; +} + +.summary-btn { + background: linear-gradient(135deg, #667eea, #764ba2); + color: white; +} + +.summary-btn:hover { + background: linear-gradient(135deg, #5a67d8, #6b46c1); + transform: translateY(-1px); +} + +.edit-btn { + background: #f59e0b; + color: white; +} + +.edit-btn:hover { + background: #d97706; + transform: translateY(-1px); +} + +.delete-btn { + background: #ef4444; + color: white; +} + +.delete-btn:hover { + background: #dc2626; + transform: translateY(-1px); +} + .details-layout { max-width: 1400px; margin: 0 auto; @@ -385,13 +434,171 @@ cursor: pointer; } +/* Meeting Summary Section */ +.summary-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.summary-header h2 { + margin: 0; + color: #334155; + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1.25rem; +} + +.export-pdf-btn-main { + display: flex; + align-items: center; + gap: 8px; + background: linear-gradient(135deg, #667eea, #764ba2); + color: white; + border: none; + padding: 10px 16px; + border-radius: 8px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all 0.2s ease; + box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3); +} + +.export-pdf-btn-main svg { + color: white; +} + +.export-pdf-btn-main:hover { + background: linear-gradient(135deg, #5a67d8, #6b46c1); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); +} + +.summary-content { + background: #fafbfc; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 0; + min-height: 200px; + overflow: hidden; +} + +.markdown-content { + padding: 2rem; + background: white; + border-radius: 12px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); +} + +.markdown-content h1, +.markdown-content h2, +.markdown-content h3, +.markdown-content h4, +.markdown-content h5, +.markdown-content h6 { + color: #1e293b; + margin-top: 1.5rem; + margin-bottom: 0.75rem; +} + +.markdown-content h1 { font-size: 1.5rem; } +.markdown-content h2 { font-size: 1.375rem; } +.markdown-content h3 { font-size: 1.25rem; } +.markdown-content h4 { font-size: 1.125rem; } + +.markdown-content p { + line-height: 1.6; + color: #475569; + margin-bottom: 1rem; +} + +.markdown-content ul, +.markdown-content ol { + margin: 1rem 0; + padding-left: 1.5rem; +} + +.markdown-content li { + margin-bottom: 0.5rem; + line-height: 1.5; + color: #475569; +} + +.markdown-content strong { + color: #1e293b; + font-weight: 600; +} + +.markdown-content code { + background: #f1f5f9; + padding: 2px 6px; + border-radius: 4px; + font-family: 'Monaco', 'Consolas', monospace; + font-size: 0.875rem; + color: #be185d; +} + +.no-summary { + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; + padding: 2rem; +} + +.no-summary-content { + text-align: center; + color: #64748b; +} + +.no-summary-content svg { + color: #cbd5e1; + margin-bottom: 1rem; +} + +.no-summary-content h3 { + margin: 0 0 0.5rem 0; + color: #475569; + font-size: 1.125rem; +} + +.no-summary-content p { + margin: 0 0 1.5rem 0; + color: #64748b; +} + +.generate-summary-cta { + display: inline-flex; + align-items: center; + gap: 8px; + background: linear-gradient(135deg, #667eea, #764ba2); + color: white; + border: none; + padding: 12px 20px; + border-radius: 8px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all 0.2s ease; + box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3); +} + +.generate-summary-cta:hover { + background: linear-gradient(135deg, #5a67d8, #6b46c1); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); +} + /* Transcript Sidebar */ .transcript-sidebar { background: white; border-radius: 16px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); - height: fit-content; - max-height: calc(100vh - 200px); + height: calc(100vh - 120px); + min-height: 600px; display: flex; flex-direction: column; } @@ -410,13 +617,12 @@ align-items: center; } -.edit-speakers-btn { +.edit-speakers-btn, +.ai-summary-btn { display: flex; align-items: center; gap: 4px; padding: 6px 12px; - background: #007bff; - color: white; border: none; border-radius: 4px; font-size: 12px; @@ -424,10 +630,24 @@ transition: background-color 0.2s; } +.edit-speakers-btn { + background: #007bff; + color: white; +} + .edit-speakers-btn:hover { background: #0056b3; } +.ai-summary-btn { + background: linear-gradient(135deg, #667eea, #764ba2); + color: white; +} + +.ai-summary-btn:hover { + background: linear-gradient(135deg, #5a67d8, #6b46c1); +} + .transcript-header h3 { margin: 0; color: #334155; @@ -437,22 +657,6 @@ font-size: 1.1rem; } -.toggle-transcript { - background: #f1f5f9; - border: none; - border-radius: 6px; - padding: 0.5rem 1rem; - color: #475569; - cursor: pointer; - font-size: 0.9rem; - font-weight: 500; - transition: all 0.3s ease; -} - -.toggle-transcript:hover { - background: #e2e8f0; -} - .transcript-content { padding: 1rem; overflow-y: auto; @@ -464,13 +668,21 @@ padding: 1rem; background: #f8fafc; border-radius: 8px; - border-left: 3px solid #667eea; - cursor: pointer; - transition: background-color 0.3s ease; + border-left: 3px solid transparent; + transition: all 0.3s ease; +} + +.transcript-item.active { + background: #eff6ff; + border-left-color: #667eea; + box-shadow: 0 2px 8px rgba(102, 126, 234, 0.15); + transform: translateX(2px); } .transcript-item:hover { - background-color: #f0f2ff; + background: #f1f5f9; + cursor: pointer; + transition: background-color 0.3s ease; } .transcript-header-item { @@ -495,9 +707,13 @@ } .speaker-name { - font-weight: 600; - color: #334155; - font-size: 0.9rem; + font-weight: 700; + color: #1e293b; + font-size: 0.95rem; + background: #f1f5f9; + padding: 4px 10px; + border-radius: 12px; + border: 1px solid #e2e8f0; } .timestamp { @@ -849,6 +1065,216 @@ border-color: #9ca3af; } +/* AI Summary Modal Styles */ +.summary-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; +} + +.summary-modal { + background: white; + border-radius: 12px; + width: 90%; + max-width: 800px; + max-height: 90vh; + overflow: hidden; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15); +} + +.summary-modal-content { + padding: 0 24px 24px; + max-height: calc(90vh - 120px); + overflow-y: auto; +} + +.summary-input-section { + margin-bottom: 2rem; +} + +.summary-input-section h4 { + margin: 0 0 1rem 0; + color: #1e293b; + font-size: 1.125rem; +} + +.input-description { + color: #64748b; + font-size: 0.875rem; + margin-bottom: 1rem; +} + +.user-prompt-input { + width: 100%; + padding: 12px 16px; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 14px; + font-family: inherit; + margin-bottom: 1rem; + resize: vertical; + transition: all 0.2s ease; +} + +.user-prompt-input:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +.generate-summary-btn { + display: flex; + align-items: center; + gap: 8px; + background: linear-gradient(135deg, #667eea, #764ba2); + color: white; + border: none; + padding: 12px 20px; + border-radius: 8px; + cursor: pointer; + font-size: 14px; + font-weight: 600; + transition: all 0.2s ease; + box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3); +} + +.generate-summary-btn:hover:not(:disabled) { + background: linear-gradient(135deg, #5a67d8, #6b46c1); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); +} + +.generate-summary-btn:disabled { + opacity: 0.7; + cursor: not-allowed; + transform: none; +} + +.loading-spinner.small { + width: 16px; + height: 16px; +} + +.summary-result-section, +.summary-history-section { + border-top: 1px solid #e2e8f0; + padding-top: 2rem; + margin-top: 2rem; +} + +.summary-result-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.summary-result-section h4, +.summary-history-section h4 { + margin: 0; + color: #1e293b; + font-size: 1.125rem; +} + +.export-pdf-btn { + display: flex; + align-items: center; + gap: 6px; + background: #059669; + color: white; + border: none; + padding: 8px 14px; + border-radius: 6px; + cursor: pointer; + font-size: 12px; + font-weight: 500; + transition: all 0.2s ease; + box-shadow: 0 2px 4px rgba(5, 150, 105, 0.3); +} + +.export-pdf-btn svg { + color: white; +} + +.export-pdf-btn:hover { + background: #047857; + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(5, 150, 105, 0.4); +} + +.summary-result-content { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 1.5rem; + max-height: 400px; + overflow-y: auto; +} + +.summary-history-list { + display: flex; + flex-direction: column; + gap: 1rem; + max-height: 300px; + overflow-y: auto; +} + +.summary-history-item { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 1rem; + transition: all 0.2s ease; +} + +.summary-history-item:hover { + background: #f1f5f9; + border-color: #cbd5e1; +} + +.summary-history-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.summary-date { + color: #64748b; + font-size: 0.875rem; +} + +.user-prompt-tag { + background: linear-gradient(135deg, #667eea, #764ba2); + color: white; + padding: 2px 8px; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 500; +} + +.user-prompt-display { + background: white; + border-left: 3px solid #667eea; + padding: 8px 12px; + margin-bottom: 0.5rem; + border-radius: 4px; + font-size: 0.875rem; +} + +.summary-content-preview { + color: #475569; + font-size: 0.875rem; + line-height: 1.5; +} + /* Responsive Design */ @media (max-width: 1200px) { .details-layout { @@ -939,3 +1365,144 @@ justify-content: center; } } + +/* Transcript Edit Modal */ +.transcript-edit-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; +} + +.transcript-edit-modal { + background: white; + border-radius: 12px; + width: 90%; + max-width: 800px; + max-height: 90vh; + overflow: hidden; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2); +} + +.transcript-edit-content { + padding: 20px; + max-height: calc(90vh - 120px); + overflow-y: auto; +} + +.modal-description { + margin-bottom: 20px; + color: #64748b; + font-size: 14px; +} + +.transcript-edit-list { + display: flex; + flex-direction: column; + gap: 16px; +} + +.transcript-edit-item { + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 16px; + background: #f8fafc; +} + +.transcript-edit-item.current { + border-color: #3b82f6; + background: #eff6ff; +} + +.transcript-edit-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} + +.current-indicator { + background: #3b82f6; + color: white; + padding: 2px 8px; + border-radius: 12px; + font-size: 12px; + font-weight: 600; +} + +.transcript-edit-textarea { + width: 100%; + min-height: 80px; + border: 1px solid #d1d5db; + border-radius: 6px; + padding: 12px; + font-family: inherit; + font-size: 14px; + line-height: 1.5; + resize: vertical; +} + +.transcript-edit-textarea:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +/* Audio Divider and Subtitle Display */ +.audio-divider { + margin: 20px 0; + height: 1px; + background: rgba(255, 255, 255, 0.2); + border-radius: 1px; +} + +.subtitle-display { + margin-top: 20px; + min-height: 80px; + padding: 15px; + background: rgba(0, 0, 0, 0.2); + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.subtitle-content { + display: flex; + flex-direction: column; + gap: 8px; +} + +.subtitle-text { + color: white; + font-size: 1rem; + line-height: 1.5; + text-align: left; +} + +.speaker-indicator { + font-weight: 600; + color: #fbbf24; + font-size: 0.9rem; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); + align-self: flex-start; + margin-bottom: 4px; +} + +.subtitle-placeholder { + display: flex; + align-items: center; + justify-content: center; + height: 60px; +} + +.placeholder-text { + color: rgba(255, 255, 255, 0.6); + font-size: 0.9rem; + font-style: italic; + text-align: center; +} diff --git a/src/pages/MeetingDetails.jsx b/src/pages/MeetingDetails.jsx index 7ebd341..4cf1dd4 100644 --- a/src/pages/MeetingDetails.jsx +++ b/src/pages/MeetingDetails.jsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { useParams, Link, useNavigate } from 'react-router-dom'; import axios from 'axios'; -import { ArrowLeft, Clock, Users, FileText, User, Calendar, Play, Pause, Volume2, MessageCircle, Edit, Trash2, Settings, Save, X } from 'lucide-react'; +import { ArrowLeft, Clock, Users, FileText, User, Calendar, Play, Pause, Volume2, MessageCircle, Edit, Trash2, Settings, Save, X, Edit3, Brain, Sparkles, Download } from 'lucide-react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; @@ -27,7 +27,19 @@ const MeetingDetails = ({ user }) => { const [showSpeakerEdit, setShowSpeakerEdit] = useState(false); const [editingSpeakers, setEditingSpeakers] = useState({}); const [speakerList, setSpeakerList] = useState([]); + const [showTranscriptEdit, setShowTranscriptEdit] = useState(false); + const [editingTranscriptIndex, setEditingTranscriptIndex] = useState(-1); + const [editingTranscripts, setEditingTranscripts] = useState({}); + const [currentSubtitle, setCurrentSubtitle] = useState(''); + const [currentSpeaker, setCurrentSpeaker] = useState(''); + const [showSummaryModal, setShowSummaryModal] = useState(false); + const [summaryLoading, setSummaryLoading] = useState(false); + const [summaryResult, setSummaryResult] = useState(null); + const [userPrompt, setUserPrompt] = useState(''); + const [summaryHistory, setSummaryHistory] = useState([]); + const [currentHighlightIndex, setCurrentHighlightIndex] = useState(-1); const audioRef = useRef(null); + const transcriptRefs = useRef([]); useEffect(() => { fetchMeetingDetails(); @@ -133,7 +145,40 @@ const MeetingDetails = ({ user }) => { const handleTimeUpdate = () => { if (audioRef.current) { - setCurrentTime(audioRef.current.currentTime); + const currentTime = audioRef.current.currentTime; + setCurrentTime(currentTime); + + // 更新字幕显示 + updateSubtitle(currentTime); + } + }; + + const updateSubtitle = (currentTime) => { + const currentTimeMs = currentTime * 1000; + const currentSegment = transcript.find(item => + currentTimeMs >= item.start_time_ms && currentTimeMs <= item.end_time_ms + ); + + if (currentSegment) { + setCurrentSubtitle(currentSegment.text_content); + // 确保使用 speaker_tag 来保持一致性 + setCurrentSpeaker(currentSegment.speaker_tag || `发言人 ${currentSegment.speaker_id}`); + + // 找到当前segment在transcript数组中的索引 + const currentIndex = transcript.findIndex(item => item.segment_id === currentSegment.segment_id); + setCurrentHighlightIndex(currentIndex); + + // 滚动到对应的转录条目 + if (currentIndex !== -1 && transcriptRefs.current[currentIndex]) { + transcriptRefs.current[currentIndex].scrollIntoView({ + behavior: 'smooth', + block: 'center' + }); + } + } else { + setCurrentSubtitle(''); + setCurrentSpeaker(''); + setCurrentHighlightIndex(-1); } }; @@ -271,6 +316,247 @@ const MeetingDetails = ({ user }) => { setShowSpeakerEdit(true); }; + const handleTranscriptEdit = (index) => { + setEditingTranscriptIndex(index); + + // 获取前一条、当前条、后一条的数据 + const editItems = []; + if (index > 0) editItems.push({ ...transcript[index - 1], originalIndex: index - 1 }); + editItems.push({ ...transcript[index], originalIndex: index }); + if (index < transcript.length - 1) editItems.push({ ...transcript[index + 1], originalIndex: index + 1 }); + + // 初始化编辑状态 + const initialEditState = {}; + editItems.forEach(item => { + initialEditState[item.originalIndex] = item.text_content; + }); + setEditingTranscripts(initialEditState); + setShowTranscriptEdit(true); + }; + + const handleTranscriptTextChange = (index, newText) => { + setEditingTranscripts(prev => ({ + ...prev, + [index]: newText + })); + }; + + const handleSaveTranscriptEdits = async () => { + try { + const baseUrl = ""; + const updates = Object.entries(editingTranscripts).map(([index, text_content]) => ({ + segment_id: transcript[index].segment_id, + text_content: text_content + })); + + await axios.put(`${baseUrl}/api/meetings/${meeting_id}/transcript/batch`, { + updates: updates + }); + + // 更新本地状态 + setTranscript(prev => prev.map((item, idx) => { + const newText = editingTranscripts[idx]; + return newText !== undefined ? { ...item, text_content: newText } : item; + })); + + setShowTranscriptEdit(false); + setEditingTranscripts({}); + setEditingTranscriptIndex(-1); + + } catch (err) { + console.error('Error updating transcript:', err); + setError('更新转录内容失败,请重试'); + } + }; + + const getEditingItems = () => { + if (editingTranscriptIndex === -1) return []; + + const items = []; + const index = editingTranscriptIndex; + + if (index > 0) items.push({ ...transcript[index - 1], originalIndex: index - 1, position: 'prev' }); + items.push({ ...transcript[index], originalIndex: index, position: 'current' }); + if (index < transcript.length - 1) items.push({ ...transcript[index + 1], originalIndex: index + 1, position: 'next' }); + + return items; + }; + + // AI总结相关函数 + const generateSummary = async () => { + if (summaryLoading) return; + + setSummaryLoading(true); + try { + const baseUrl = ""; + const response = await axios.post(`${baseUrl}/api/meetings/${meeting_id}/generate-summary`, { + user_prompt: userPrompt + }); + + setSummaryResult(response.data); + + // 刷新总结历史 + await fetchSummaryHistory(); + + } catch (err) { + console.error('Error generating summary:', err); + setError('生成AI总结失败,请重试'); + } finally { + setSummaryLoading(false); + } + }; + + const fetchSummaryHistory = async () => { + try { + const baseUrl = ""; + const response = await axios.get(`${baseUrl}/api/meetings/${meeting_id}/summaries`); + setSummaryHistory(response.data.summaries); + } catch (err) { + console.error('Error fetching summary history:', err); + } + }; + + const openSummaryModal = async () => { + setShowSummaryModal(true); + setUserPrompt(''); + setSummaryResult(null); + await fetchSummaryHistory(); + }; + + const exportToPDF = async () => { + try { + // 检查是否有总结内容 + let summaryContent = summaryResult?.content || + meeting?.summary || + (summaryHistory.length > 0 ? summaryHistory[0].content : null); + + if (!summaryContent) { + alert('暂无会议总结内容,请先生成AI总结。'); + return; + } + + // 创建一个临时的React容器用于渲染Markdown + const tempDiv = document.createElement('div'); + tempDiv.style.position = 'fixed'; + tempDiv.style.top = '-9999px'; + tempDiv.style.width = '800px'; + tempDiv.style.padding = '20px'; + tempDiv.style.backgroundColor = 'white'; + + // 导入markdown-to-html转换所需的模块 + const ReactMarkdown = (await import('react-markdown')).default; + const { createRoot } = await import('react-dom/client'); + + document.body.appendChild(tempDiv); + const root = createRoot(tempDiv); + + // 渲染Markdown内容并获取HTML + await new Promise((resolve) => { + root.render( + React.createElement(ReactMarkdown, { + remarkPlugins: [remarkGfm], + rehypePlugins: [rehypeRaw, rehypeSanitize], + children: summaryContent + }) + ); + setTimeout(resolve, 100); // 等待渲染完成 + }); + + const renderedHTML = tempDiv.innerHTML; + + // 创建一个隐藏的HTML容器用于生成PDF + const printContainer = document.createElement('div'); + printContainer.style.position = 'fixed'; + printContainer.style.top = '-9999px'; + printContainer.style.width = '210mm'; + printContainer.style.padding = '20mm'; + printContainer.style.backgroundColor = 'white'; + printContainer.style.fontFamily = 'Arial, sans-serif'; + printContainer.style.fontSize = '14px'; + printContainer.style.lineHeight = '1.6'; + printContainer.style.color = '#333'; + + // 创建PDF内容的HTML,使用渲染后的Markdown内容 + const meetingTime = formatDateTime(meeting.meeting_time); + const attendeesList = meeting.attendees.map(attendee => + typeof attendee === 'string' ? attendee : attendee.caption + ).join('、'); + + printContainer.innerHTML = ` +
+

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

+ +
+

会议信息

+

会议时间:${meetingTime}

+

创建人:${meeting.creator_username}

+

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

+

参会人员:${attendeesList}

+
+ +
+

会议摘要

+
${renderedHTML}
+
+ +
+

导出时间:${new Date().toLocaleString('zh-CN')}

+
+
+ `; + + document.body.appendChild(printContainer); + + // 使用浏览器的打印功能生成PDF + const originalContent = document.body.innerHTML; + const originalTitle = document.title; + + // 临时替换页面内容 + document.body.innerHTML = printContainer.innerHTML; + document.title = `${meeting.title || '会议总结'}_${new Date().toISOString().split('T')[0]}`; + + // 添加打印样式 + const printStyles = document.createElement('style'); + printStyles.innerHTML = ` + @media print { + body { margin: 0; padding: 20px; font-family: 'Microsoft YaHei', Arial, sans-serif; } + h1 { page-break-before: avoid; } + h2 { page-break-before: avoid; } + h3 { margin-top: 1.5rem; margin-bottom: 0.75rem; color: #1e293b; } + h4 { margin-top: 1rem; margin-bottom: 0.5rem; color: #1e293b; } + p { margin-bottom: 0.75rem; color: #475569; line-height: 1.6; } + ul, ol { margin: 0.75rem 0; padding-left: 1.5rem; } + li { margin-bottom: 0.25rem; color: #475569; } + strong { color: #1e293b; font-weight: 600; } + code { background: #f1f5f9; padding: 2px 4px; border-radius: 3px; color: #dc2626; } + .page-break { page-break-before: always; } + } + `; + document.head.appendChild(printStyles); + + // 打开打印对话框 + window.print(); + + // 清理:恢复原始内容 + setTimeout(() => { + document.body.innerHTML = originalContent; + document.title = originalTitle; + document.head.removeChild(printStyles); + document.body.removeChild(printContainer); + document.body.removeChild(tempDiv); + + // 重新初始化React组件(这是一个简化的处理) + window.location.reload(); + }, 1000); + + } catch (error) { + console.error('PDF导出失败:', error); + alert('PDF导出失败,请重试。建议使用浏览器的打印功能并选择"保存为PDF"。'); + } + }; + const isCreator = meeting && user && String(meeting.creator_id) === String(user.user_id); if (loading) { @@ -409,6 +695,27 @@ const MeetingDetails = ({ user }) => { /> + + {/* 分割线 */} +
+ + {/* 动态字幕显示 */} +
+ {currentSubtitle ? ( +
+
+ {currentSpeaker} +
+
+ {currentSubtitle} +
+
+ ) : ( +
+
播放音频时将在此处显示实时字幕
+
+ )} +
) : (
@@ -422,16 +729,47 @@ const MeetingDetails = ({ user }) => {
-

会议摘要

-
-
- +

会议摘要

+ {meeting?.summary && ( +
+ + 导出PDF + + )} +
+
+ {meeting?.summary ? ( +
+ + {meeting.summary} + +
+ ) : ( +
+
+ +

暂无会议总结

+

该会议尚未生成总结内容

+ {isCreator && ( + + )} +
+
+ )}
@@ -443,30 +781,34 @@ const MeetingDetails = ({ user }) => {

对话转录

{isCreator && ( - + <> + + + )} -
- {showTranscript && ( -
- {transcript.map((item) => ( +
+ {transcript.map((item, index) => (
transcriptRefs.current[index] = el} + className={`transcript-item ${currentHighlightIndex === index ? 'active' : ''}`} >
{ > {formatTime(item.start_time_ms / 1000)} + {isCreator && ( + + )}
{
))}
- )}
@@ -587,6 +937,166 @@ const MeetingDetails = ({ user }) => { )} + + {/* Transcript Edit Modal */} + {showTranscriptEdit && ( +
setShowTranscriptEdit(false)}> +
e.stopPropagation()}> +
+

编辑转录内容

+ +
+ +
+

修改选中转录条目及其上下文内容:

+ +
+ {getEditingItems().map((item) => ( +
+
+ {item.speaker_tag} + {formatTime(item.start_time_ms / 1000)} + {item.position === 'current' && ( + 当前编辑 + )} +
+