生成新的总结
+
+ {/* 模板选择器 */}
+ {promptList.length > 0 && (
+
+
+
+ {promptList.map(prompt => (
+
+ ))}
+
+
+ )}
+
- 系统将使用通用提示词分析会议转录,您可以添加额外要求:
+ 您可以添加额外的要求:
)}
diff --git a/src/pages/MeetingPreview.css b/src/pages/MeetingPreview.css
index 8e120b9..ce025e7 100644
--- a/src/pages/MeetingPreview.css
+++ b/src/pages/MeetingPreview.css
@@ -131,10 +131,71 @@
.section-title {
color: #374151;
font-size: 20px;
- margin: 0 0 20px;
+ margin: 0 0 16px;
padding-bottom: 10px;
}
+/* 操作按钮行 */
+.action-buttons {
+ display: flex;
+ gap: 12px;
+ margin-bottom: 20px;
+ flex-wrap: wrap;
+}
+
+.action-btn {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 10px 20px;
+ border: none;
+ border-radius: 8px;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.action-btn svg {
+ flex-shrink: 0;
+}
+
+/* 复制按钮 */
+.copy-btn {
+ background: linear-gradient(135deg, #667eea, #764ba2);
+ color: white;
+ box-shadow: 0 2px 8px rgba(102, 126, 234, 0.25);
+}
+
+.copy-btn:hover {
+ background: linear-gradient(135deg, #5a67d8, #6b46c1);
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(102, 126, 234, 0.35);
+}
+
+.copy-btn:active {
+ transform: translateY(0);
+ box-shadow: 0 2px 6px rgba(102, 126, 234, 0.25);
+}
+
+/* 分享按钮 */
+.share-btn {
+ background: linear-gradient(135deg, #10b981, #059669);
+ color: white;
+ box-shadow: 0 2px 8px rgba(16, 185, 129, 0.25);
+}
+
+.share-btn:hover {
+ background: linear-gradient(135deg, #059669, #047857);
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(16, 185, 129, 0.35);
+}
+
+.share-btn:active {
+ transform: translateY(0);
+ box-shadow: 0 2px 6px rgba(16, 185, 129, 0.25);
+}
+
.info-item {
margin: 12px 0;
font-size: 16px;
@@ -220,147 +281,6 @@
color: #dc2626;
}
-/* 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;
@@ -429,43 +349,6 @@
font-size: 14px;
padding: 15px 0;
}
-
- .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;
- }
}
/* 平板适配 */
@@ -689,6 +572,212 @@
box-shadow: none;
}
+/* ========================================
+ 预览页面音频播放器样式(独立命名空间)
+ ======================================== */
+
+.transcript-wrapper {
+ padding: 20px 0;
+}
+
+.preview-audio-player {
+ background: linear-gradient(135deg, #667eea, #764ba2);
+ border-radius: 12px;
+ padding: 24px;
+ margin-bottom: 24px;
+ box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
+}
+
+.preview-player-controls {
+ display: flex;
+ align-items: center;
+ gap: 32px;
+ flex-wrap: nowrap;
+ min-width: 0;
+}
+
+.preview-play-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 50px;
+ height: 50px;
+ background: rgba(255, 255, 255, 0.2);
+ border: none;
+ border-radius: 50%;
+ color: white;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ flex-shrink: 0;
+ outline: none;
+}
+
+.preview-play-btn:hover {
+ background: rgba(255, 255, 255, 0.3);
+ transform: scale(1.05);
+}
+
+.preview-play-btn:active {
+ transform: scale(0.95);
+}
+
+.preview-progress-wrapper {
+ flex: 1;
+ min-width: 0;
+ overflow: visible;
+}
+
+.preview-time-slider {
+ display: flex;
+ align-items: center;
+}
+
+.preview-slider-track {
+ flex: 1;
+ height: 50px;
+ display: flex;
+ align-items: center;
+ cursor: pointer;
+ position: relative;
+ -webkit-tap-highlight-color: transparent;
+ touch-action: none;
+ user-select: none;
+}
+
+.preview-slider-track::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 50%;
+ transform: translateY(-50%);
+ height: 6px;
+ background: rgba(255, 255, 255, 0.3);
+ border-radius: 3px;
+}
+
+.preview-slider-fill {
+ position: absolute;
+ left: 0;
+ top: 50%;
+ transform: translateY(-50%);
+ height: 6px;
+ background: rgba(255, 255, 255, 0.9);
+ border-radius: 3px;
+ transition: width 0.1s ease;
+ pointer-events: none;
+}
+
+.preview-slider-thumb {
+ position: absolute;
+ right: 0;
+ top: 50%;
+ transform: translate(50%, -50%);
+ cursor: grab;
+ pointer-events: auto;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+}
+
+.preview-slider-thumb:active {
+ cursor: grabbing;
+}
+
+.preview-current-time {
+ background: white;
+ color: #667eea;
+ font-size: 12px;
+ font-weight: 700;
+ padding: 5px 10px;
+ border-radius: 6px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
+ white-space: nowrap;
+ position: relative;
+}
+
+.preview-current-time::after {
+ content: '';
+ position: absolute;
+ bottom: -4px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 0;
+ height: 0;
+ border-left: 5px solid transparent;
+ border-right: 5px solid transparent;
+ border-top: 5px solid white;
+}
+
+.preview-slider-thumb::after {
+ content: '';
+ width: 16px;
+ height: 16px;
+ background: white;
+ border: 3px solid rgba(255, 255, 255, 0.5);
+ border-radius: 50%;
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
+}
+
+.transcript-list {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ max-height: 500px;
+ overflow-y: auto;
+}
+
+.transcript-segment {
+ background: #f8fafc;
+ padding: 16px;
+ border-radius: 8px;
+ border-left: 3px solid transparent;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.transcript-segment:hover {
+ background: #f1f5f9;
+}
+
+.transcript-segment.active {
+ background: #eff6ff;
+ border-left-color: #3b82f6;
+ box-shadow: 0 2px 8px rgba(59, 130, 246, 0.15);
+}
+
+.segment-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 8px;
+}
+
+.speaker-name {
+ font-weight: 600;
+ color: #1e293b;
+ font-size: 14px;
+}
+
+.segment-time {
+ font-size: 12px;
+ color: #64748b;
+}
+
+.segment-text {
+ color: #475569;
+ font-size: 14px;
+ line-height: 1.6;
+}
+
+.empty-transcript {
+ text-align: center;
+ padding: 60px 20px;
+ color: #64748b;
+ font-size: 16px;
+}
+
/* 移动端密码界面优化 */
@media (max-width: 768px) {
.password-modal-content {
@@ -719,4 +808,35 @@
padding: 12px 20px;
font-size: 15px;
}
+
+ /* 预览页面播放器移动端优化 */
+ .preview-audio-player {
+ padding: 16px;
+ }
+
+ .preview-player-controls {
+ gap: 12px;
+ }
+
+ .preview-progress-wrapper {
+ width: 100%;
+ }
+
+ .preview-current-time {
+ font-size: 11px;
+ padding: 4px 8px;
+ }
+
+ .preview-current-time::after {
+ border-left: 4px solid transparent;
+ border-right: 4px solid transparent;
+ border-top: 4px solid white;
+ bottom: -3px;
+ }
+
+ .preview-slider-thumb::after {
+ width: 14px;
+ height: 14px;
+ border-width: 2px;
+ }
}
diff --git a/src/pages/MeetingPreview.jsx b/src/pages/MeetingPreview.jsx
index f7e72fd..288f3ba 100644
--- a/src/pages/MeetingPreview.jsx
+++ b/src/pages/MeetingPreview.jsx
@@ -1,12 +1,9 @@
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 { Tabs } from 'antd';
-import { Lock, Eye, EyeOff, AlertCircle } from 'lucide-react';
+import { Lock, Eye, EyeOff, AlertCircle, Copy, Check, Share2, Play, Pause } from 'lucide-react';
import MindMap from '../components/MindMap';
+import MarkdownRenderer from '../components/MarkdownRenderer';
import './MeetingPreview.css';
const { TabPane } = Tabs;
@@ -17,6 +14,16 @@ const MeetingPreview = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [errorType, setErrorType] = useState(''); // 'not_found', 'no_summary', 'network'
+ const [copied, setCopied] = useState(false);
+ const [shared, setShared] = useState(false);
+
+ // 转录相关状态
+ const [transcript, setTranscript] = useState([]);
+ const [audioUrl, setAudioUrl] = useState(null);
+ const [currentTime, setCurrentTime] = useState(0);
+ const [duration, setDuration] = useState(0);
+ const [isPlaying, setIsPlaying] = useState(false);
+ const audioRef = React.useRef(null);
// 密码验证相关状态
const [isPasswordProtected, setIsPasswordProtected] = useState(false);
@@ -54,6 +61,8 @@ const MeetingPreview = () => {
} else {
setMeetingData(result.data);
setIsPasswordProtected(false);
+ // 获取转录数据和音频
+ fetchTranscriptAndAudio();
}
setError(null);
setErrorType('');
@@ -76,6 +85,40 @@ const MeetingPreview = () => {
}
};
+ const fetchTranscriptAndAudio = async () => {
+ try {
+ // 获取转录数据
+ const transcriptResponse = await fetch(`/api/meetings/${meeting_id}/transcript`);
+ if (transcriptResponse.ok) {
+ const transcriptResult = await transcriptResponse.json();
+ if (transcriptResult.code === '200' && transcriptResult.data) {
+ // 转换后端数据格式为前端需要的格式
+ const formattedSegments = transcriptResult.data.map(seg => ({
+ segment_id: seg.segment_id,
+ speaker_label: seg.speaker_tag || `发言人 ${seg.speaker_id}`,
+ start_time: seg.start_time_ms / 1000.0, // 毫秒转秒
+ end_time: seg.end_time_ms / 1000.0, // 毫秒转秒
+ text: seg.text_content
+ }));
+ setTranscript(formattedSegments);
+ }
+ }
+
+ // 获取音频URL
+ const audioResponse = await fetch(`/api/meetings/${meeting_id}/audio`);
+ if (audioResponse.ok) {
+ const audioResult = await audioResponse.json();
+ if (audioResult.code === '200' && audioResult.data) {
+ // 使用stream端点作为audio URL
+ const audioUrl = `/api/meetings/${meeting_id}/audio/stream`;
+ setAudioUrl(audioUrl);
+ }
+ }
+ } catch (err) {
+ console.error('获取转录或音频失败:', err);
+ }
+ };
+
const handlePasswordVerify = async () => {
if (!passwordInput.trim()) {
setPasswordError('请输入访问密码');
@@ -126,6 +169,99 @@ const MeetingPreview = () => {
}
};
+ // 复制总结文本
+ const handleCopySummary = async () => {
+ try {
+ // 移除Markdown格式,只保留纯文本
+ const plainText = meetingData.summary
+ .replace(/#+\s/g, '') // 移除标题符号
+ .replace(/\*\*/g, '') // 移除粗体
+ .replace(/\*/g, '') // 移除斜体
+ .replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1') // 移除链接保留文字
+ .replace(/`/g, ''); // 移除代码标记
+
+ // 检查 Clipboard API 是否可用
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ await navigator.clipboard.writeText(plainText);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } else {
+ // 降级方案:使用旧方法
+ const textArea = document.createElement('textarea');
+ textArea.value = plainText;
+ textArea.style.position = 'fixed';
+ textArea.style.left = '-999999px';
+ document.body.appendChild(textArea);
+ textArea.select();
+ try {
+ document.execCommand('copy');
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } finally {
+ document.body.removeChild(textArea);
+ }
+ }
+ } catch (err) {
+ console.error('复制失败:', err);
+ }
+ };
+
+ // 分享功能
+ const handleShare = async () => {
+ const shareUrl = window.location.href;
+ const shareTitle = `${meetingData.title} - 会议总结`;
+ const shareText = `查看会议总结:${meetingData.title}`;
+
+ try {
+ // 优先使用 Web Share API(移动端)
+ if (navigator.share && typeof navigator.share === 'function') {
+ await navigator.share({
+ title: shareTitle,
+ text: shareText,
+ url: shareUrl
+ });
+ setShared(true);
+ setTimeout(() => setShared(false), 2000);
+ return;
+ }
+ } catch (err) {
+ // 用户取消分享,不算错误
+ if (err.name === 'AbortError') {
+ return;
+ }
+ console.warn('Web Share API 失败,使用降级方案:', err);
+ }
+
+ // 降级方案:复制链接
+ try {
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ await navigator.clipboard.writeText(shareUrl);
+ setShared(true);
+ setTimeout(() => setShared(false), 2000);
+ } else {
+ // 再次降级:使用 execCommand
+ const textArea = document.createElement('textarea');
+ textArea.value = shareUrl;
+ textArea.style.position = 'fixed';
+ textArea.style.left = '-999999px';
+ document.body.appendChild(textArea);
+ textArea.select();
+ try {
+ const successful = document.execCommand('copy');
+ if (successful) {
+ setShared(true);
+ setTimeout(() => setShared(false), 2000);
+ }
+ } finally {
+ document.body.removeChild(textArea);
+ }
+ }
+ } catch (err) {
+ console.error('复制链接失败:', err);
+ alert('分享链接:' + shareUrl);
+ }
+ };
+
const formatDateTime = (dateTime) => {
if (!dateTime) return '';
const date = new Date(dateTime);
@@ -140,6 +276,128 @@ const MeetingPreview = () => {
});
};
+ // 音频播放器控制函数
+ const togglePlayPause = () => {
+ if (audioRef.current) {
+ if (isPlaying) {
+ audioRef.current.pause();
+ } else {
+ audioRef.current.play();
+ }
+ setIsPlaying(!isPlaying);
+ }
+ };
+
+ const handleTimeUpdate = () => {
+ if (audioRef.current) {
+ setCurrentTime(audioRef.current.currentTime);
+ }
+ };
+
+ const handleLoadedMetadata = () => {
+ if (audioRef.current) {
+ setDuration(audioRef.current.duration);
+ }
+ };
+
+ const seekTo = (time) => {
+ if (audioRef.current) {
+ audioRef.current.currentTime = time;
+ setCurrentTime(time);
+
+ // 滚动到对应的转录段落
+ scrollToTranscriptSegment(time);
+ }
+ };
+
+ // 滚动到对应时间的转录段落
+ const scrollToTranscriptSegment = (time) => {
+ if (transcript.length === 0) return;
+
+ // 找到当前时间对应的转录段落索引
+ const segmentIndex = transcript.findIndex(
+ seg => time >= seg.start_time && time < seg.end_time
+ );
+
+ if (segmentIndex !== -1) {
+ // 找到对应的DOM元素并滚动到可见区域
+ const segmentElements = document.querySelectorAll('.transcript-segment');
+ if (segmentElements[segmentIndex]) {
+ segmentElements[segmentIndex].scrollIntoView({
+ behavior: 'smooth',
+ block: 'center'
+ });
+ }
+ }
+ };
+
+ // 处理进度条点击和触摸
+ const handleProgressInteraction = (clientX, element) => {
+ const rect = element.getBoundingClientRect();
+ const x = clientX - rect.left;
+ const percentage = Math.max(0, Math.min(1, x / rect.width));
+ seekTo(percentage * duration);
+ };
+
+ const handleProgressClick = (e) => {
+ handleProgressInteraction(e.clientX, e.currentTarget);
+ };
+
+ const handleProgressTouch = (e) => {
+ if (e.touches.length > 0) {
+ handleProgressInteraction(e.touches[0].clientX, e.currentTarget);
+ }
+ };
+
+ // 滑块拖动处理
+ const handleThumbMouseDown = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const handleMouseMove = (moveEvent) => {
+ const track = e.currentTarget.closest('.preview-slider-track');
+ if (track) {
+ handleProgressInteraction(moveEvent.clientX, track);
+ }
+ };
+
+ const handleMouseUp = () => {
+ document.removeEventListener('mousemove', handleMouseMove);
+ document.removeEventListener('mouseup', handleMouseUp);
+ };
+
+ document.addEventListener('mousemove', handleMouseMove);
+ document.addEventListener('mouseup', handleMouseUp);
+ };
+
+ const handleThumbTouchStart = (e) => {
+ e.stopPropagation();
+
+ const handleTouchMove = (moveEvent) => {
+ if (moveEvent.touches.length > 0) {
+ const track = e.currentTarget.closest('.preview-slider-track');
+ if (track) {
+ handleProgressInteraction(moveEvent.touches[0].clientX, track);
+ }
+ }
+ };
+
+ const handleTouchEnd = () => {
+ document.removeEventListener('touchmove', handleTouchMove);
+ document.removeEventListener('touchend', handleTouchEnd);
+ };
+
+ document.addEventListener('touchmove', handleTouchMove);
+ document.addEventListener('touchend', handleTouchEnd);
+ };
+
+ const formatTime = (seconds) => {
+ if (!seconds || isNaN(seconds)) return '0:00';
+ const mins = Math.floor(seconds / 60);
+ const secs = Math.floor(seconds % 60);
+ return `${mins}:${secs.toString().padStart(2, '0')}`;
+ };
+
if (loading) {
return (
@@ -236,6 +494,21 @@ const MeetingPreview = () => {
.map(attendee => attendee.caption)
.join('、');
+ // 计算参会人数
+ let attendeesCount = meetingData.attendees_count || 0;
+ let isCalculatedFromTranscript = false;
+
+ // 如果参会人数为0,从转录中计算唯一说话人数
+ if (attendeesCount === 0 && transcript.length > 0) {
+ const uniqueSpeakers = new Set(
+ transcript
+ .map(seg => seg.speaker_label)
+ .filter(label => label && label !== '未知')
+ );
+ attendeesCount = uniqueSpeakers.size;
+ isCalculatedFromTranscript = true;
+ }
+
return (
@@ -254,23 +527,59 @@ const MeetingPreview = () => {
{meetingData.creator_username}
- 人数:
- {meetingData.attendees_count}人
+ {isCalculatedFromTranscript ? '计算人数:' : '人数:'}
+ {attendeesCount}人
📝 {meetingData.prompt_name || '会议'} 总结
+
+ {/* 操作按钮行 */}
+
+
+
+
+
+
-
-
- {meetingData.summary}
-
-
+
@@ -281,6 +590,102 @@ const MeetingPreview = () => {
/>
+
+ {audioUrl || transcript.length > 0 ? (
+
+ {/* 音频播放器 */}
+ {audioUrl && (
+
+
+ )}
+
+ {/* 转录列表 */}
+ {transcript.length > 0 ? (
+
+ {transcript.map((segment, index) => {
+ const isActive = currentTime >= segment.start_time && currentTime < segment.end_time;
+ return (
+
seekTo(segment.start_time)}
+ >
+
+ {segment.speaker_label || '未知'}
+
+ {formatTime(segment.start_time)} - {formatTime(segment.end_time)}
+
+
+
{segment.text}
+
+ );
+ })}
+
+ ) : (
+
暂无转录数据
+ )}
+
+ ) : (
+ 暂无转录和音频数据
+ )}
+