imetting/frontend/src/pages/MeetingPreview.jsx

349 lines
12 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { Layout, Space, Button, App, Empty, Input, Tabs } from 'antd';
import { LockOutlined, EyeOutlined, CopyOutlined, ShareAltOutlined, HomeOutlined, FileTextOutlined, PartitionOutlined, AudioOutlined } from '@ant-design/icons';
import httpService from '../services/httpService';
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
import MarkdownRenderer from '../components/MarkdownRenderer';
import MindMap from '../components/MindMap';
import AudioPlayerBar from '../components/AudioPlayerBar';
import TranscriptTimeline from '../components/TranscriptTimeline';
import tools from '../utils/tools';
import configService from '../utils/configService';
import './MeetingPreview.css';
const { Content } = Layout;
const MeetingPreview = () => {
const { meeting_id } = useParams();
const { message } = App.useApp();
const audioRef = useRef(null);
const transcriptRefs = useRef([]);
const [meeting, setMeeting] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [password, setPassword] = useState('');
const [passwordError, setPasswordError] = useState('');
const [passwordRequired, setPasswordRequired] = useState(false);
const [isAuthorized, setIsAuthorized] = useState(false);
const [branding, setBranding] = useState(() => configService.getCachedBrandingConfig());
const [transcript, setTranscript] = useState([]);
const [audioUrl, setAudioUrl] = useState('');
const [activeSegmentIndex, setActiveSegmentIndex] = useState(-1);
const [playbackRate, setPlaybackRate] = useState(1);
useEffect(() => {
if (branding) {
return;
}
let active = true;
configService.getBrandingConfig().then((nextBranding) => {
if (active) {
setBranding(nextBranding);
}
}).catch((error) => {
console.error('Load branding config failed:', error);
});
return () => {
active = false;
};
}, [branding]);
const fetchTranscriptAndAudio = useCallback(async () => {
const [transcriptRes, audioRes] = await Promise.allSettled([
httpService.get(buildApiUrl(API_ENDPOINTS.MEETINGS.TRANSCRIPT(meeting_id))),
httpService.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('');
}
}, [meeting_id]);
const fetchPreview = useCallback(async (pwd = '') => {
setLoading(true);
try {
const endpoint = API_ENDPOINTS.MEETINGS.PREVIEW_DATA(meeting_id);
const url = buildApiUrl(`${endpoint}${pwd ? `?password=${encodeURIComponent(pwd)}` : ''}`);
const res = await httpService.get(url);
setMeeting(res.data);
setIsAuthorized(true);
setPasswordRequired(false);
setPasswordError('');
setError(null);
await fetchTranscriptAndAudio();
} catch (err) {
const responseCode = String(err?.response?.data?.code || '');
if (responseCode === '401') {
setMeeting(null);
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);
}
}, [fetchTranscriptAndAudio, meeting_id]);
useEffect(() => {
setMeeting(null);
setTranscript([]);
transcriptRefs.current = [];
setAudioUrl('');
setActiveSegmentIndex(-1);
setPlaybackRate(1);
setError(null);
setPassword('');
setPasswordError('');
setPasswordRequired(false);
setIsAuthorized(false);
fetchPreview();
}, [fetchPreview, meeting_id]);
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;
} 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(() => {});
};
useEffect(() => {
if (activeSegmentIndex < 0) {
return;
}
transcriptRefs.current[activeSegmentIndex]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, [activeSegmentIndex]);
const handleVerify = () => {
if (!password) {
message.warning('请输入访问密码');
return;
}
fetchPreview(password);
};
if (!branding) {
return null;
}
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>
<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 />
<span>总结</span>
</Space>
),
children: (
<div className="summary-content">
{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 ? (
<AudioPlayerBar
audioRef={audioRef}
src={audioUrl}
playbackRate={playbackRate}
onPlaybackRateChange={setPlaybackRate}
onTimeUpdate={handleTimeUpdate}
showMoreButton={false}
/>
) : null}
<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>
),
}
]}
/>
</div>
<div className="preview-footer">
{branding.footer_text}
</div>
</div>
</Content>
</Layout>
);
};
export default MeetingPreview;