feat(会议详情): 改进音频播放和转写交互体验
- 将会议预览页面的基本信息卡片合并到会议概览卡片,优化布局 - 为转写列表添加当前播放高亮和自动滚动功能 - 实现浮动音频播放器,随滚动保持可见 - 调整转写列表的响应式网格布局dev_na
parent
ddd97e0514
commit
9480d0e4bf
|
|
@ -452,6 +452,8 @@ type ActiveTranscriptRowProps = {
|
|||
onDraftBlur: (item: MeetingTranscriptVO, value: string) => void;
|
||||
onDraftKeyDown: (item: MeetingTranscriptVO, value: string, event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
onSpeakerUpdated: () => void;
|
||||
isActive: boolean;
|
||||
audioPlaying: boolean;
|
||||
};
|
||||
|
||||
const ActiveTranscriptRow = React.memo<ActiveTranscriptRowProps>(({
|
||||
|
|
@ -466,19 +468,29 @@ const ActiveTranscriptRow = React.memo<ActiveTranscriptRowProps>(({
|
|||
onDraftBlur,
|
||||
onDraftKeyDown,
|
||||
onSpeakerUpdated,
|
||||
isActive,
|
||||
audioPlaying,
|
||||
}) => {
|
||||
const [draftValue, setDraftValue] = useState(item.content || '');
|
||||
const speakerTagLabel = item.speakerLabel ? (speakerLabelMap.get(item.speakerLabel) || item.speakerLabel) : '';
|
||||
const [draftValue, setDraftValue] = useState(item.content);
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
setDraftValue(item.content || '');
|
||||
setDraftValue(item.content);
|
||||
}
|
||||
}, [isEditing, item.content]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive && audioPlaying && rowRef.current) {
|
||||
rowRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, [isActive, audioPlaying]);
|
||||
|
||||
const speakerTagLabel = item.speakerLabel ? (speakerLabelMap.get(item.speakerLabel) || item.speakerLabel) : '';
|
||||
|
||||
return (
|
||||
<List.Item className="transcript-row" onClick={() => onSeek(item.startTime)}>
|
||||
<div className="transcript-entry">
|
||||
<List.Item className={`transcript-row ${isActive ? 'active' : ''}`} onClick={() => onSeek(item.startTime)}>
|
||||
<div className="transcript-entry" ref={rowRef}>
|
||||
<Avatar icon={<UserOutlined />} className="transcript-avatar" />
|
||||
<div className="transcript-content-wrap">
|
||||
<div className="transcript-meta">
|
||||
|
|
@ -550,6 +562,8 @@ const ActiveTranscriptRow = React.memo<ActiveTranscriptRowProps>(({
|
|||
&& prevProps.onDraftBlur === nextProps.onDraftBlur
|
||||
&& prevProps.onDraftKeyDown === nextProps.onDraftKeyDown
|
||||
&& prevProps.onSpeakerUpdated === nextProps.onSpeakerUpdated
|
||||
&& prevProps.isActive === nextProps.isActive
|
||||
&& prevProps.audioPlaying === nextProps.audioPlaying
|
||||
));
|
||||
|
||||
const MeetingDetail: React.FC = () => {
|
||||
|
|
@ -591,6 +605,11 @@ const MeetingDetail: React.FC = () => {
|
|||
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const summaryPdfRef = useRef<HTMLDivElement>(null);
|
||||
const transcriptItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
||||
const leftColumnRef = useRef<HTMLDivElement>(null);
|
||||
const transcriptSectionRef = useRef<HTMLDivElement>(null);
|
||||
const [showFloatingTranscriptPlayer, setShowFloatingTranscriptPlayer] = useState(false);
|
||||
const [floatingTranscriptPlayerLayout, setFloatingTranscriptPlayerLayout] = useState<{ left: number; width: number } | null>(null);
|
||||
|
||||
const analysis = useMemo(
|
||||
() => buildMeetingAnalysis(meeting?.analysis, meeting?.summaryContent, meeting?.tags || ''),
|
||||
|
|
@ -632,6 +651,60 @@ const MeetingDetail: React.FC = () => {
|
|||
const canRetrySummary = isOwner && transcripts.length > 0 && meeting?.status !== 1 && meeting?.status !== 2;
|
||||
const canRetryTranscription = isOwner && meeting?.status === 4 && transcripts.length === 0 && !!meeting?.audioUrl;
|
||||
|
||||
useEffect(() => {
|
||||
if (!meeting?.audioUrl) {
|
||||
setShowFloatingTranscriptPlayer(false);
|
||||
setFloatingTranscriptPlayerLayout(null);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const updateFloatingPlayerState = () => {
|
||||
const target = transcriptSectionRef.current;
|
||||
if (!target) {
|
||||
setShowFloatingTranscriptPlayer(false);
|
||||
setFloatingTranscriptPlayerLayout(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = target.getBoundingClientRect();
|
||||
const rootRect = leftColumnRef.current?.getBoundingClientRect();
|
||||
|
||||
setFloatingTranscriptPlayerLayout({
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
});
|
||||
|
||||
if (rootRect) {
|
||||
const isVisible = rect.bottom > rootRect.top + 80 && rect.top < rootRect.bottom - 40;
|
||||
setShowFloatingTranscriptPlayer(isVisible);
|
||||
return;
|
||||
}
|
||||
|
||||
const isVisible = rect.bottom > 120 && rect.top < window.innerHeight - 80;
|
||||
setShowFloatingTranscriptPlayer(isVisible);
|
||||
};
|
||||
|
||||
updateFloatingPlayerState();
|
||||
|
||||
const target = transcriptSectionRef.current;
|
||||
const root = leftColumnRef.current;
|
||||
const resizeObserver = target ? new ResizeObserver(() => updateFloatingPlayerState()) : null;
|
||||
if (target && resizeObserver) {
|
||||
resizeObserver.observe(target);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', updateFloatingPlayerState);
|
||||
window.addEventListener('scroll', updateFloatingPlayerState, { passive: true });
|
||||
root?.addEventListener('scroll', updateFloatingPlayerState, { passive: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateFloatingPlayerState);
|
||||
window.removeEventListener('scroll', updateFloatingPlayerState);
|
||||
root?.removeEventListener('scroll', updateFloatingPlayerState);
|
||||
resizeObserver?.disconnect();
|
||||
};
|
||||
}, [meeting?.audioUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
fetchData(Number(id));
|
||||
|
|
@ -668,7 +741,9 @@ const MeetingDetail: React.FC = () => {
|
|||
setAudioCurrentTime(audio.currentTime || 0);
|
||||
audio.playbackRate = audioPlaybackRate;
|
||||
};
|
||||
const handleTimeUpdate = () => setAudioCurrentTime(audio.currentTime || 0);
|
||||
const handleTimeUpdate = () => {
|
||||
setAudioCurrentTime(audio.currentTime || 0);
|
||||
};
|
||||
const handlePlay = () => setAudioPlaying(true);
|
||||
const handlePause = () => setAudioPlaying(false);
|
||||
const handleEnded = () => setAudioPlaying(false);
|
||||
|
|
@ -1284,7 +1359,7 @@ const MeetingDetail: React.FC = () => {
|
|||
) : (
|
||||
<Row gutter={24} style={{ height: '100%' }}>
|
||||
<Col xs={24} lg={14} style={{ height: '100%' }}>
|
||||
<div className="detail-side-column detail-left-column">
|
||||
<div ref={leftColumnRef} className="detail-side-column detail-left-column">
|
||||
<Card className="left-flow-card summary-panel" variant="borderless">
|
||||
<div className="summary-head">
|
||||
<div className="summary-title">
|
||||
|
|
@ -1465,63 +1540,47 @@ const MeetingDetail: React.FC = () => {
|
|||
<div className="section-divider-line" />
|
||||
</div>
|
||||
|
||||
<Card className="left-flow-card" variant="borderless" title={<span><AudioOutlined /> 原文</span>}>
|
||||
{meeting.audioUrl && <audio ref={audioRef} src={meeting.audioUrl} style={{ display: 'none' }} preload="metadata" />}
|
||||
{meeting.audioSaveStatus === 'FAILED' && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={meeting.audioSaveMessage || '实时会议已完成,但音频保存失败,当前无法播放会议录音。转写和总结不受影响。'}
|
||||
/>
|
||||
)}
|
||||
<List
|
||||
dataSource={transcripts}
|
||||
renderItem={(item) => (
|
||||
<ActiveTranscriptRow
|
||||
item={item}
|
||||
meetingId={meeting.id}
|
||||
isOwner={isOwner}
|
||||
isEditing={editingTranscriptId === item.id}
|
||||
isSaving={savingTranscriptId === item.id}
|
||||
speakerLabelMap={speakerLabelMap}
|
||||
onSeek={seekTo}
|
||||
onStartEdit={handleStartEditTranscript}
|
||||
onDraftBlur={handleTranscriptDraftBlur}
|
||||
onDraftKeyDown={handleTranscriptDraftKeyDown}
|
||||
onSpeakerUpdated={handleTranscriptSpeakerUpdated}
|
||||
<div ref={transcriptSectionRef} className="transcript-player-anchor">
|
||||
<Card className="left-flow-card" variant="borderless" title={<span><AudioOutlined /> 原文</span>}>
|
||||
{meeting.audioUrl && <audio ref={audioRef} src={meeting.audioUrl} style={{ display: 'none' }} preload="metadata" />}
|
||||
{meeting.audioSaveStatus === 'FAILED' && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={meeting.audioSaveMessage || '实时会议已完成,但音频保存失败,当前无法播放会议录音。转写和总结不受影响。'}
|
||||
/>
|
||||
)}
|
||||
locale={{ emptyText: meeting.status < 3 ? '识别任务进行中...' : '暂无数据' }}
|
||||
/>
|
||||
<List
|
||||
className="transcript-list"
|
||||
dataSource={transcripts}
|
||||
style={{ paddingBottom: meeting.audioUrl ? 156 : 0 }}
|
||||
renderItem={(item, index) => {
|
||||
const nextStartTime = transcripts[index + 1]?.startTime || Infinity;
|
||||
const isActive = (audioCurrentTime * 1000) >= item.startTime && (audioCurrentTime * 1000) < nextStartTime;
|
||||
|
||||
{meeting.audioUrl && (
|
||||
<div className="transcript-player">
|
||||
<button type="button" className="player-main-btn" onClick={toggleAudioPlayback} aria-label="toggle-audio">
|
||||
{audioPlaying ? <PauseOutlined /> : <CaretRightFilled />}
|
||||
</button>
|
||||
<div className="player-progress-shell">
|
||||
<div className="player-time-row">
|
||||
<span>{formatPlayerTime(audioCurrentTime)}</span>
|
||||
<span>{formatPlayerTime(audioDuration)}</span>
|
||||
</div>
|
||||
<input
|
||||
className="player-range"
|
||||
type="range"
|
||||
min={0}
|
||||
max={audioDuration || 0}
|
||||
step={0.1}
|
||||
value={Math.min(audioCurrentTime, audioDuration || 0)}
|
||||
onChange={handleAudioProgressChange}
|
||||
/>
|
||||
</div>
|
||||
<button type="button" className="player-ghost-btn" onClick={cyclePlaybackRate}>
|
||||
<FastForwardOutlined />
|
||||
{audioPlaybackRate}x
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
return (
|
||||
<ActiveTranscriptRow
|
||||
item={item}
|
||||
meetingId={meeting.id}
|
||||
isOwner={isOwner}
|
||||
isEditing={editingTranscriptId === item.id}
|
||||
isSaving={savingTranscriptId === item.id}
|
||||
speakerLabelMap={speakerLabelMap}
|
||||
onSeek={seekTo}
|
||||
onStartEdit={handleStartEditTranscript}
|
||||
onDraftBlur={handleTranscriptDraftBlur}
|
||||
onDraftKeyDown={handleTranscriptDraftKeyDown}
|
||||
onSpeakerUpdated={handleTranscriptSpeakerUpdated}
|
||||
isActive={isActive}
|
||||
audioPlaying={audioPlaying}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
locale={{ emptyText: meeting.status < 3 ? '识别任务进行中...' : '暂无数据' }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
|
|
@ -1591,6 +1650,36 @@ const MeetingDetail: React.FC = () => {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{meeting?.audioUrl && showFloatingTranscriptPlayer && floatingTranscriptPlayerLayout && (
|
||||
<div
|
||||
className="transcript-player transcript-player--floating"
|
||||
style={{ left: floatingTranscriptPlayerLayout.left, width: floatingTranscriptPlayerLayout.width }}
|
||||
>
|
||||
<button type="button" className="player-main-btn" onClick={toggleAudioPlayback} aria-label="toggle-audio">
|
||||
{audioPlaying ? <PauseOutlined /> : <CaretRightFilled />}
|
||||
</button>
|
||||
<div className="player-progress-shell">
|
||||
<div className="player-time-row">
|
||||
<span>{formatPlayerTime(audioCurrentTime)}</span>
|
||||
<span>{formatPlayerTime(audioDuration)}</span>
|
||||
</div>
|
||||
<input
|
||||
className="player-range"
|
||||
type="range"
|
||||
min={0}
|
||||
max={audioDuration || 0}
|
||||
step={0.1}
|
||||
value={Math.min(audioCurrentTime, audioDuration || 0)}
|
||||
onChange={handleAudioProgressChange}
|
||||
/>
|
||||
</div>
|
||||
<button type="button" className="player-ghost-btn" onClick={cyclePlaybackRate}>
|
||||
<FastForwardOutlined />
|
||||
{audioPlaybackRate}x
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.detail-side-column {
|
||||
height: 100%;
|
||||
|
|
@ -2060,6 +2149,12 @@ const MeetingDetail: React.FC = () => {
|
|||
border-bottom: 0 !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ant-list-item.transcript-row.active .transcript-bubble,
|
||||
.ant-list-item.transcript-row.active .transcript-bubble-editing {
|
||||
border-color: rgba(22, 119, 255, 0.5);
|
||||
background: #f0f5ff;
|
||||
box-shadow: 0 4px 12px rgba(22, 119, 255, 0.08);
|
||||
}
|
||||
.transcript-entry {
|
||||
flex: 1;
|
||||
justify-self: start;
|
||||
|
|
@ -2170,20 +2265,28 @@ const MeetingDetail: React.FC = () => {
|
|||
.transcript-bubble-hint {
|
||||
display: none;
|
||||
}
|
||||
.transcript-player-anchor {
|
||||
position: relative;
|
||||
}
|
||||
.transcript-list {
|
||||
min-width: 0;
|
||||
}
|
||||
.transcript-player {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 18px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
background: var(--app-bg-surface);
|
||||
border: 1px solid var(--app-border-color);
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.08);
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.12);
|
||||
backdrop-filter: blur(24px);
|
||||
}
|
||||
.transcript-player--floating {
|
||||
position: fixed;
|
||||
bottom: 84px;
|
||||
z-index: 100;
|
||||
max-width: calc(100vw - 32px);
|
||||
}
|
||||
.player-main-btn,
|
||||
.player-ghost-btn {
|
||||
|
|
@ -2344,8 +2447,9 @@ const MeetingDetail: React.FC = () => {
|
|||
.detail-left-column {
|
||||
overflow: visible;
|
||||
}
|
||||
.transcript-player {
|
||||
position: static;
|
||||
.transcript-player--floating {
|
||||
bottom: 72px;
|
||||
max-width: calc(100vw - 24px);
|
||||
}
|
||||
.section-divider-text {
|
||||
white-space: normal;
|
||||
|
|
|
|||
|
|
@ -164,11 +164,17 @@
|
|||
|
||||
.meeting-preview-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.meeting-preview-metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.meeting-preview-metric {
|
||||
padding: 14px 14px 12px;
|
||||
border-radius: 18px;
|
||||
|
|
|
|||
|
|
@ -547,66 +547,6 @@ export default function MeetingPreview() {
|
|||
|
||||
const summaryTab = (
|
||||
<div className="meeting-preview-tab-panel">
|
||||
<section className="meeting-preview-card meeting-preview-section">
|
||||
<div className="meeting-preview-section-header">
|
||||
<div>
|
||||
<div className="meeting-preview-section-kicker">
|
||||
<CalendarOutlined />
|
||||
{TEXT.basicInfo}
|
||||
</div>
|
||||
<h2 className="meeting-preview-section-title">{TEXT.meetingOverview}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="meeting-preview-metrics">
|
||||
<div className="meeting-preview-metric">
|
||||
<span className="meeting-preview-metric-label">{TEXT.creator}</span>
|
||||
<span className="meeting-preview-metric-value">{meeting.creatorName || TEXT.notSet}</span>
|
||||
</div>
|
||||
<div className="meeting-preview-metric">
|
||||
<span className="meeting-preview-metric-label">{TEXT.host}</span>
|
||||
<span className="meeting-preview-metric-value">{meeting.hostName || TEXT.notSet}</span>
|
||||
</div>
|
||||
<div className="meeting-preview-metric">
|
||||
<span className="meeting-preview-metric-label">{TEXT.createdAt}</span>
|
||||
<span className="meeting-preview-metric-value">
|
||||
{meeting.createdAt ? dayjs(meeting.createdAt).format("YYYY.MM.DD HH:mm") : TEXT.notSet}
|
||||
</span>
|
||||
</div>
|
||||
<div className="meeting-preview-metric">
|
||||
<span className="meeting-preview-metric-label">{TEXT.audioStatus}</span>
|
||||
<span className="meeting-preview-metric-value">{audioStatusLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{participants.length > 0 ? (
|
||||
<div className="meeting-preview-overview">
|
||||
<div className="meeting-preview-overview-label">{TEXT.participants}</div>
|
||||
<div className="meeting-preview-tags">
|
||||
{participants.map((item) => (
|
||||
<span key={item} className="meeting-preview-tag">
|
||||
<TeamOutlined style={{ marginRight: 8 }} />
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tags.length > 0 ? (
|
||||
<div className="meeting-preview-overview">
|
||||
<div className="meeting-preview-overview-label">{TEXT.tags}</div>
|
||||
<div className="meeting-preview-tags">
|
||||
{tags.map((item) => (
|
||||
<Tag key={item} bordered={false} className="meeting-preview-tag">
|
||||
{item}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="meeting-preview-card meeting-preview-section">
|
||||
<div className="meeting-preview-section-header">
|
||||
<div>
|
||||
|
|
@ -854,18 +794,55 @@ export default function MeetingPreview() {
|
|||
</span>
|
||||
</div>
|
||||
<div className="meeting-preview-metric">
|
||||
<span className="meeting-preview-metric-label">{TEXT.hostCreator}</span>
|
||||
<span className="meeting-preview-metric-value">{meeting.hostName || meeting.creatorName || TEXT.notSet}</span>
|
||||
<span className="meeting-preview-metric-label">{TEXT.createdAt}</span>
|
||||
<span className="meeting-preview-metric-value">
|
||||
{meeting.createdAt ? dayjs(meeting.createdAt).format("YYYY.MM.DD HH:mm") : TEXT.notSet}
|
||||
</span>
|
||||
</div>
|
||||
<div className="meeting-preview-metric">
|
||||
<span className="meeting-preview-metric-label">{TEXT.creator}</span>
|
||||
<span className="meeting-preview-metric-value">{meeting.creatorName || TEXT.notSet}</span>
|
||||
</div>
|
||||
<div className="meeting-preview-metric">
|
||||
<span className="meeting-preview-metric-label">{TEXT.host}</span>
|
||||
<span className="meeting-preview-metric-value">{meeting.hostName || TEXT.notSet}</span>
|
||||
</div>
|
||||
<div className="meeting-preview-metric">
|
||||
<span className="meeting-preview-metric-label">{TEXT.participantsCount}</span>
|
||||
<span className="meeting-preview-metric-value">{participantCountValue || TEXT.notFilled}</span>
|
||||
</div>
|
||||
<div className="meeting-preview-metric">
|
||||
<span className="meeting-preview-metric-label">{TEXT.tagsCount}</span>
|
||||
<span className="meeting-preview-metric-value">{tags.length || TEXT.notSet}</span>
|
||||
<span className="meeting-preview-metric-label">会议时长</span>
|
||||
<span className="meeting-preview-metric-value">{meetingDuration > 0 ? formatDurationRange(0, meetingDuration).split(' - ')[1] : TEXT.noDuration}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{participants.length > 0 ? (
|
||||
<div className="meeting-preview-overview" style={{ marginTop: 20 }}>
|
||||
<div className="meeting-preview-overview-label">{TEXT.participants}</div>
|
||||
<div className="meeting-preview-tags">
|
||||
{participants.map((item) => (
|
||||
<span key={item} className="meeting-preview-tag">
|
||||
<TeamOutlined style={{ marginRight: 8 }} />
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tags.length > 0 ? (
|
||||
<div className="meeting-preview-overview" style={{ marginTop: participants.length > 0 ? 12 : 20 }}>
|
||||
<div className="meeting-preview-overview-label">{TEXT.tags}</div>
|
||||
<div className="meeting-preview-tags">
|
||||
{tags.map((item) => (
|
||||
<Tag key={item} bordered={false} className="meeting-preview-tag">
|
||||
{item}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<div className="meeting-preview-panels">
|
||||
|
|
|
|||
Loading…
Reference in New Issue