349 lines
12 KiB
JavaScript
349 lines
12 KiB
JavaScript
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;
|