imetting/frontend/src/pages/MeetingPreview.jsx

330 lines
12 KiB
React
Raw Normal View History

import React, { useEffect, useRef, useState } from 'react';
2026-03-26 06:55:12 +00:00
import { useParams, Link } from 'react-router-dom';
2026-04-03 16:25:53 +00:00
import { Layout, Space, Button, App, Empty, Input, Tabs } from 'antd';
2026-04-07 07:51:51 +00:00
import { LockOutlined, EyeOutlined, CopyOutlined, ShareAltOutlined, HomeOutlined, FileTextOutlined, PartitionOutlined, AudioOutlined } from '@ant-design/icons';
2026-03-26 06:55:12 +00:00
import apiClient from '../utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
import MarkdownRenderer from '../components/MarkdownRenderer';
import MindMap from '../components/MindMap';
2026-04-07 07:51:51 +00:00
import AudioPlayerBar from '../components/AudioPlayerBar';
import TranscriptTimeline from '../components/TranscriptTimeline';
2026-03-26 06:55:12 +00:00
import tools from '../utils/tools';
import configService, { DEFAULT_BRANDING_CONFIG } from '../utils/configService';
import './MeetingPreview.css';
const { Content } = Layout;
const MeetingPreview = () => {
const { meeting_id } = useParams();
2026-03-26 06:55:12 +00:00
const { message } = App.useApp();
const audioRef = useRef(null);
2026-04-07 07:51:51 +00:00
const transcriptRefs = useRef([]);
2026-03-26 06:55:12 +00:00
const [meeting, setMeeting] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
2026-03-26 06:55:12 +00:00
const [password, setPassword] = useState('');
const [passwordError, setPasswordError] = useState('');
const [passwordRequired, setPasswordRequired] = useState(false);
2026-03-26 06:55:12 +00:00
const [isAuthorized, setIsAuthorized] = useState(false);
const [branding, setBranding] = useState(DEFAULT_BRANDING_CONFIG);
const [transcript, setTranscript] = useState([]);
const [audioUrl, setAudioUrl] = useState('');
const [activeSegmentIndex, setActiveSegmentIndex] = useState(-1);
2026-04-07 07:51:51 +00:00
const [playbackRate, setPlaybackRate] = useState(1);
useEffect(() => {
configService.getBrandingConfig().then(setBranding).catch(() => {});
}, []);
useEffect(() => {
setMeeting(null);
setTranscript([]);
2026-04-07 07:51:51 +00:00
transcriptRefs.current = [];
setAudioUrl('');
setActiveSegmentIndex(-1);
2026-04-07 07:51:51 +00:00
setPlaybackRate(1);
setError(null);
setPassword('');
setPasswordError('');
setPasswordRequired(false);
setIsAuthorized(false);
2026-03-26 06:55:12 +00:00
fetchPreview();
}, [meeting_id]);
const fetchTranscriptAndAudio = async () => {
const [transcriptRes, audioRes] = await Promise.allSettled([
apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.TRANSCRIPT(meeting_id))),
apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.AUDIO(meeting_id))),
]);
if (transcriptRes.status === 'fulfilled') {
setTranscript(Array.isArray(transcriptRes.value.data) ? transcriptRes.value.data : []);
} else {
setTranscript([]);
}
if (audioRes.status === 'fulfilled') {
setAudioUrl(buildApiUrl(`${API_ENDPOINTS.MEETINGS.AUDIO(meeting_id)}/stream`));
} else {
setAudioUrl('');
}
};
const fetchPreview = async (pwd = '') => {
2026-03-26 06:55:12 +00:00
setLoading(true);
try {
const endpoint = API_ENDPOINTS.MEETINGS.PREVIEW_DATA(meeting_id);
const url = buildApiUrl(`${endpoint}${pwd ? `?password=${encodeURIComponent(pwd)}` : ''}`);
2026-03-26 06:55:12 +00:00
const res = await apiClient.get(url);
setMeeting(res.data);
setIsAuthorized(true);
setPasswordRequired(false);
setPasswordError('');
2026-03-26 06:55:12 +00:00
setError(null);
await fetchTranscriptAndAudio();
2026-03-26 06:55:12 +00:00
} catch (err) {
const responseCode = String(err?.response?.data?.code || '');
if (responseCode === '401') {
setMeeting(null);
2026-03-26 06:55:12 +00:00
setIsAuthorized(false);
setPasswordRequired(true);
setPasswordError(err?.response?.data?.message || '');
setError(null);
} else {
setMeeting(null);
setIsAuthorized(false);
setPasswordRequired(false);
setPasswordError('');
setError(err?.response?.data?.message || '无法加载会议预览');
}
} finally {
setLoading(false);
}
};
const handleCopyLink = async () => {
await navigator.clipboard.writeText(window.location.href);
message.success('分享链接已复制');
};
const handleShare = async () => {
if (navigator.share) {
try {
await navigator.share({ title: meeting?.title || branding.preview_title, url: window.location.href });
return;
2026-04-03 16:25:53 +00:00
} catch {
// Fallback to copying the link when native share is cancelled or unavailable.
}
}
await handleCopyLink();
};
const handleTimeUpdate = () => {
if (!audioRef.current || !transcript.length) {
return;
}
const currentMs = audioRef.current.currentTime * 1000;
const index = transcript.findIndex(
(item) => currentMs >= item.start_time_ms && currentMs <= item.end_time_ms,
);
setActiveSegmentIndex(index);
};
const jumpToSegment = (segment) => {
if (!audioRef.current || !segment) {
return;
}
audioRef.current.currentTime = (segment.start_time_ms || 0) / 1000;
audioRef.current.play().catch(() => {});
};
2026-04-07 07:51:51 +00:00
useEffect(() => {
if (activeSegmentIndex < 0) {
return;
}
transcriptRefs.current[activeSegmentIndex]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, [activeSegmentIndex]);
2026-03-26 06:55:12 +00:00
const handleVerify = () => {
if (!password) {
message.warning('请输入访问密码');
return;
}
2026-03-26 06:55:12 +00:00
fetchPreview(password);
};
if (loading && !meeting) {
return (
<div className="preview-container">
<div className="preview-loading">
<div className="loading-spinner" />
<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 type="button" className="error-retry-btn" onClick={() => fetchPreview(password)}>
重试
</button>
</div>
</div>
);
}
if (passwordRequired && !isAuthorized) {
return (
<div className="preview-container">
<div className="password-protection-modal">
<div className="password-modal-content">
<div className="password-icon-large">
<LockOutlined style={{ fontSize: 36 }} />
</div>
<h2>此会议受密码保护</h2>
<p>请输入访问密码以查看会议纪要</p>
<div className="password-input-group">
<Input.Password
className={`password-input${passwordError ? ' error' : ''}`}
placeholder="访问密码"
value={password}
onChange={(e) => {
setPassword(e.target.value);
if (passwordError) {
setPasswordError('');
}
}}
onPressEnter={handleVerify}
/>
</div>
{passwordError ? <div className="password-error-message">{passwordError}</div> : null}
<button type="button" className="password-verify-btn" onClick={handleVerify}>
<EyeOutlined style={{ marginRight: 8 }} />
立即查看
</button>
<div style={{ marginTop: 24 }}>
<Link to="/"><Button type="link" icon={<HomeOutlined />}>返回首页</Button></Link>
</div>
</div>
</div>
</div>
);
}
return (
<Layout style={{ minHeight: '100vh' }}>
<Content className="preview-container">
<div className="preview-content">
<h1 className="preview-title">{meeting.title}</h1>
<div className="meeting-info-section">
<h2 className="section-title">会议信息</h2>
2026-04-03 16:25:53 +00:00
<div className="info-grid">
<div className="info-item"><strong>创建人</strong>{meeting.creator_username || '未知'}</div>
<div className="info-item"><strong>会议时间</strong>{tools.formatDateTime(meeting.meeting_time)}</div>
<div className="info-item"><strong>参会人员</strong>{meeting.attendees?.length ? meeting.attendees.map((item) => item.caption).join('、') : '未设置'}</div>
<div className="info-item"><strong>计算人数</strong>{meeting.attendees_count || meeting.attendees?.length || 0}</div>
</div>
</div>
<div className="action-buttons">
<button type="button" className="action-btn copy-btn" onClick={handleCopyLink}>
<CopyOutlined />
复制链接
</button>
<button type="button" className="action-btn share-btn" onClick={handleShare}>
<ShareAltOutlined />
立即分享
</button>
</div>
<div className="summary-section">
<Tabs
className="preview-tabs"
items={[
{
key: 'summary',
label: (
<Space size={8}>
<FileTextOutlined />
2026-04-03 16:25:53 +00:00
<span>总结</span>
</Space>
),
children: (
<div className="summary-content">
2026-04-03 16:25:53 +00:00
{meeting.summary ? <MarkdownRenderer content={meeting.summary} /> : <Empty description="暂无总结内容" />}
</div>
),
},
{
key: 'mindmap',
label: (
<Space size={8}>
<PartitionOutlined />
<span>思维导图</span>
</Space>
),
children: (
<div className="mindmap-wrapper">
<MindMap content={meeting.summary} title={meeting.title} />
</div>
),
},
{
key: 'transcript',
label: (
<Space size={8}>
<AudioOutlined />
<span>会议转录</span>
</Space>
),
children: (
<div className="transcript-wrapper">
{audioUrl ? (
2026-04-07 07:51:51 +00:00
<AudioPlayerBar
audioRef={audioRef}
src={audioUrl}
playbackRate={playbackRate}
onPlaybackRateChange={setPlaybackRate}
onTimeUpdate={handleTimeUpdate}
showMoreButton={false}
/>
) : null}
2026-04-07 07:51:51 +00:00
<TranscriptTimeline
transcript={transcript}
visibleCount={transcript.length}
currentHighlightIndex={activeSegmentIndex}
onJumpToTime={(timeMs) => jumpToSegment({ start_time_ms: timeMs })}
transcriptRefs={transcriptRefs}
maxHeight="520px"
getSpeakerColor={(speakerId) => {
const palette = ['#1677ff', '#52c41a', '#fa8c16', '#eb2f96', '#722ed1', '#13c2c2', '#2f54eb', '#faad14'];
return palette[(speakerId ?? 0) % palette.length];
}}
emptyDescription="暂无转录内容"
/>
</div>
),
2026-04-03 16:25:53 +00:00
}
]}
/>
</div>
<div className="preview-footer">
{branding.footer_text}
</div>
</div>
2026-03-26 06:55:12 +00:00
</Content>
</Layout>
);
};
export default MeetingPreview;