feat(会议详情): 改进音频播放和转写交互体验

- 将会议预览页面的基本信息卡片合并到会议概览卡片,优化布局
- 为转写列表添加当前播放高亮和自动滚动功能
- 实现浮动音频播放器,随滚动保持可见
- 调整转写列表的响应式网格布局
dev_na
alanpaine 2026-04-17 11:00:50 +08:00
parent ddd97e0514
commit 9480d0e4bf
3 changed files with 221 additions and 134 deletions

View File

@ -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;

View File

@ -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;

View File

@ -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">