2026-04-01 08:36:52 +00:00
|
|
|
|
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';
|
2026-01-19 11:03:08 +00:00
|
|
|
|
import MarkdownRenderer from '../components/MarkdownRenderer';
|
2026-04-01 08:36:52 +00:00
|
|
|
|
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';
|
2026-04-01 08:36:52 +00:00
|
|
|
|
import configService, { DEFAULT_BRANDING_CONFIG } from '../utils/configService';
|
|
|
|
|
|
import './MeetingPreview.css';
|
2026-01-19 11:03:08 +00:00
|
|
|
|
|
2026-04-01 08:36:52 +00:00
|
|
|
|
const { Content } = Layout;
|
2026-01-19 11:03:08 +00:00
|
|
|
|
|
|
|
|
|
|
const MeetingPreview = () => {
|
|
|
|
|
|
const { meeting_id } = useParams();
|
2026-03-26 06:55:12 +00:00
|
|
|
|
const { message } = App.useApp();
|
2026-04-01 08:36:52 +00:00
|
|
|
|
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);
|
2026-01-19 11:03:08 +00:00
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
const [error, setError] = useState(null);
|
2026-03-26 06:55:12 +00:00
|
|
|
|
const [password, setPassword] = useState('');
|
2026-03-27 07:43:08 +00:00
|
|
|
|
const [passwordError, setPasswordError] = useState('');
|
|
|
|
|
|
const [passwordRequired, setPasswordRequired] = useState(false);
|
2026-03-26 06:55:12 +00:00
|
|
|
|
const [isAuthorized, setIsAuthorized] = useState(false);
|
2026-04-01 08:36:52 +00:00
|
|
|
|
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);
|
2026-04-01 08:36:52 +00:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
configService.getBrandingConfig().then(setBranding).catch(() => {});
|
|
|
|
|
|
}, []);
|
2026-01-19 11:03:08 +00:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-03-27 07:43:08 +00:00
|
|
|
|
setMeeting(null);
|
2026-04-01 08:36:52 +00:00
|
|
|
|
setTranscript([]);
|
2026-04-07 07:51:51 +00:00
|
|
|
|
transcriptRefs.current = [];
|
2026-04-01 08:36:52 +00:00
|
|
|
|
setAudioUrl('');
|
|
|
|
|
|
setActiveSegmentIndex(-1);
|
2026-04-07 07:51:51 +00:00
|
|
|
|
setPlaybackRate(1);
|
2026-03-27 07:43:08 +00:00
|
|
|
|
setError(null);
|
|
|
|
|
|
setPassword('');
|
|
|
|
|
|
setPasswordError('');
|
|
|
|
|
|
setPasswordRequired(false);
|
|
|
|
|
|
setIsAuthorized(false);
|
2026-03-26 06:55:12 +00:00
|
|
|
|
fetchPreview();
|
2026-01-19 11:03:08 +00:00
|
|
|
|
}, [meeting_id]);
|
|
|
|
|
|
|
2026-04-01 08:36:52 +00:00
|
|
|
|
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('');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-27 07:43:08 +00:00
|
|
|
|
const fetchPreview = async (pwd = '') => {
|
2026-03-26 06:55:12 +00:00
|
|
|
|
setLoading(true);
|
2026-01-19 11:03:08 +00:00
|
|
|
|
try {
|
2026-03-27 07:43:08 +00:00
|
|
|
|
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);
|
2026-03-27 07:43:08 +00:00
|
|
|
|
setPasswordRequired(false);
|
|
|
|
|
|
setPasswordError('');
|
2026-03-26 06:55:12 +00:00
|
|
|
|
setError(null);
|
2026-04-01 08:36:52 +00:00
|
|
|
|
await fetchTranscriptAndAudio();
|
2026-03-26 06:55:12 +00:00
|
|
|
|
} catch (err) {
|
2026-03-27 07:43:08 +00:00
|
|
|
|
const responseCode = String(err?.response?.data?.code || '');
|
|
|
|
|
|
if (responseCode === '401') {
|
|
|
|
|
|
setMeeting(null);
|
2026-03-26 06:55:12 +00:00
|
|
|
|
setIsAuthorized(false);
|
2026-03-27 07:43:08 +00:00
|
|
|
|
setPasswordRequired(true);
|
|
|
|
|
|
setPasswordError(err?.response?.data?.message || '');
|
|
|
|
|
|
setError(null);
|
2026-01-19 11:03:08 +00:00
|
|
|
|
} else {
|
2026-03-27 07:43:08 +00:00
|
|
|
|
setMeeting(null);
|
|
|
|
|
|
setIsAuthorized(false);
|
|
|
|
|
|
setPasswordRequired(false);
|
|
|
|
|
|
setPasswordError('');
|
|
|
|
|
|
setError(err?.response?.data?.message || '无法加载会议预览');
|
2026-01-19 11:03:08 +00:00
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-01 08:36:52 +00:00
|
|
|
|
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.
|
|
|
|
|
|
}
|
2026-04-01 08:36:52 +00:00
|
|
|
|
}
|
|
|
|
|
|
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('请输入访问密码');
|
2026-01-19 11:03:08 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-26 06:55:12 +00:00
|
|
|
|
fetchPreview(password);
|
2026-01-19 11:03:08 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-01 08:36:52 +00:00
|
|
|
|
if (loading && !meeting) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="preview-container">
|
|
|
|
|
|
<div className="preview-loading">
|
|
|
|
|
|
<div className="loading-spinner" />
|
|
|
|
|
|
<p>正在加载会议预览...</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-01-19 11:03:08 +00:00
|
|
|
|
|
2026-04-01 08:36:52 +00:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-03-27 07:43:08 +00:00
|
|
|
|
|
|
|
|
|
|
if (passwordRequired && !isAuthorized) {
|
2026-01-19 11:03:08 +00:00
|
|
|
|
return (
|
2026-04-01 08:36:52 +00:00
|
|
|
|
<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>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
</div>
|
2026-04-01 08:36:52 +00:00
|
|
|
|
</div>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-04-01 08:36:52 +00:00
|
|
|
|
<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>
|
2026-04-01 08:36:52 +00:00
|
|
|
|
</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>
|
2026-04-01 08:36:52 +00:00
|
|
|
|
</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} />
|
2026-04-01 08:36:52 +00:00
|
|
|
|
</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}
|
|
|
|
|
|
/>
|
2026-04-01 08:36:52 +00:00
|
|
|
|
) : 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="暂无转录内容"
|
|
|
|
|
|
/>
|
2026-04-01 08:36:52 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
),
|
2026-04-03 16:25:53 +00:00
|
|
|
|
}
|
2026-04-01 08:36:52 +00:00
|
|
|
|
]}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="preview-footer">
|
|
|
|
|
|
{branding.footer_text}
|
2026-01-19 11:03:08 +00:00
|
|
|
|
</div>
|
2026-04-01 08:36:52 +00:00
|
|
|
|
</div>
|
2026-03-26 06:55:12 +00:00
|
|
|
|
</Content>
|
|
|
|
|
|
</Layout>
|
2026-01-19 11:03:08 +00:00
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default MeetingPreview;
|