diff --git a/frontend/src/components/AudioPlayerBar.css b/frontend/src/components/AudioPlayerBar.css
new file mode 100644
index 0000000..89d4218
--- /dev/null
+++ b/frontend/src/components/AudioPlayerBar.css
@@ -0,0 +1,179 @@
+.audio-player-bar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-height: 42px;
+ padding: 4px 12px;
+ border-radius: 999px;
+ border: 1px solid #e3ebf6;
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, #f7faff 100%);
+ box-shadow: 0 8px 18px rgba(40, 72, 120, 0.06);
+}
+
+.audio-player-bar.is-empty {
+ justify-content: center;
+}
+
+.audio-player-bar-audio {
+ display: none;
+}
+
+.audio-player-bar-play {
+ width: 24px;
+ min-width: 24px;
+ height: 24px;
+ padding: 0;
+ border: 0;
+ border-radius: 999px;
+ background: transparent;
+ color: #5f7392;
+ box-shadow: none;
+}
+
+.audio-player-bar-play.ant-btn:hover,
+.audio-player-bar-play.ant-btn:focus {
+ background: rgba(233, 241, 251, 0.7) !important;
+ color: #355171 !important;
+}
+
+.audio-player-bar-play.ant-btn:disabled {
+ background: transparent !important;
+ color: #a8b7ca !important;
+}
+
+.audio-player-bar-time {
+ flex: 0 0 auto;
+ min-width: 102px;
+ font-size: 12px;
+ font-weight: 700;
+ color: #5f7392;
+ letter-spacing: 0.01em;
+ white-space: nowrap;
+}
+
+.audio-player-bar-progress {
+ position: relative;
+ flex: 1;
+ height: 5px;
+ border: 0;
+ border-radius: 999px;
+ background: #edf2fa;
+ cursor: pointer;
+ appearance: none;
+ outline: none;
+}
+
+.audio-player-bar-progress::-webkit-slider-runnable-track {
+ height: 5px;
+ border-radius: 999px;
+ background: linear-gradient(
+ 90deg,
+ #bfd1e8 0%,
+ #bfd1e8 var(--progress, 0%),
+ #edf2fa var(--progress, 0%),
+ #edf2fa 100%
+ );
+}
+
+.audio-player-bar-progress::-webkit-slider-thumb {
+ appearance: none;
+ width: 10px;
+ height: 10px;
+ margin-top: -2.5px;
+ border: 2px solid #ffffff;
+ border-radius: 50%;
+ background: #b9cde7;
+ box-shadow: 0 2px 5px rgba(79, 111, 157, 0.12);
+}
+
+.audio-player-bar-progress::-moz-range-track {
+ height: 5px;
+ border-radius: 999px;
+ background: #edf2fa;
+}
+
+.audio-player-bar-progress::-moz-range-progress {
+ height: 5px;
+ border-radius: 999px;
+ background: #bfd1e8;
+}
+
+.audio-player-bar-progress::-moz-range-thumb {
+ width: 10px;
+ height: 10px;
+ border: 2px solid #ffffff;
+ border-radius: 50%;
+ background: #b9cde7;
+ box-shadow: 0 2px 5px rgba(79, 111, 157, 0.12);
+}
+
+.audio-player-bar-volume {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: #5f7392;
+ font-size: 14px;
+}
+
+.audio-player-bar-divider {
+ width: 1px;
+ height: 16px;
+ background: #e4ebf5;
+}
+
+.audio-player-bar-control.ant-btn {
+ height: 24px;
+ min-height: 24px;
+ padding: 0 6px;
+ border: 0;
+ border-radius: 999px;
+ background: transparent;
+ color: #5f7392;
+ box-shadow: none;
+ font-size: 11px;
+}
+
+.audio-player-bar-control.ant-btn:hover,
+.audio-player-bar-control.ant-btn:focus {
+ background: rgba(240, 245, 252, 0.75) !important;
+ color: #355171 !important;
+}
+
+.audio-player-bar-control.ant-btn:disabled {
+ background: #f8fafc !important;
+ color: #a8b7ca !important;
+}
+
+.audio-player-bar-rate.ant-btn {
+ min-width: 46px;
+ font-weight: 700;
+}
+
+.audio-player-bar-more.ant-btn {
+ width: 24px;
+ min-width: 24px;
+ padding: 0;
+ border-radius: 999px;
+}
+
+.audio-player-bar-empty-text {
+ color: #5f7392;
+ font-size: 12px;
+}
+
+@media (max-width: 768px) {
+ .audio-player-bar {
+ gap: 8px;
+ padding: 4px 9px;
+ }
+
+ .audio-player-bar-time {
+ min-width: 78px;
+ font-size: 12px;
+ }
+
+ .audio-player-bar-volume,
+ .audio-player-bar-divider {
+ display: none;
+ }
+}
diff --git a/frontend/src/components/AudioPlayerBar.jsx b/frontend/src/components/AudioPlayerBar.jsx
new file mode 100644
index 0000000..8938f30
--- /dev/null
+++ b/frontend/src/components/AudioPlayerBar.jsx
@@ -0,0 +1,185 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { Button, Dropdown } from 'antd';
+import {
+ CaretRightFilled,
+ DownOutlined,
+ MoreOutlined,
+ PauseOutlined,
+ SoundOutlined,
+} from '@ant-design/icons';
+import tools from '../utils/tools';
+import './AudioPlayerBar.css';
+
+const DEFAULT_RATE_OPTIONS = [0.75, 1, 1.25, 1.5, 2];
+
+const AudioPlayerBar = ({
+ audioRef,
+ src,
+ playbackRate = 1,
+ onPlaybackRateChange,
+ onTimeUpdate,
+ onLoadedMetadata,
+ moreMenuItems = [],
+ emptyText = '暂无音频',
+ showMoreButton = true,
+ rateOptions = DEFAULT_RATE_OPTIONS,
+}) => {
+ const [isPlaying, setIsPlaying] = useState(false);
+ const [currentTime, setCurrentTime] = useState(0);
+ const [duration, setDuration] = useState(0);
+
+ useEffect(() => {
+ const audio = audioRef?.current;
+ if (!audio) {
+ return undefined;
+ }
+
+ const syncState = () => {
+ setCurrentTime(audio.currentTime || 0);
+ setDuration(audio.duration || 0);
+ setIsPlaying(!audio.paused && !audio.ended);
+ };
+
+ const handleMeta = (event) => {
+ syncState();
+ onLoadedMetadata?.(event);
+ };
+
+ const handleTime = (event) => {
+ syncState();
+ onTimeUpdate?.(event);
+ };
+
+ const handlePlay = () => setIsPlaying(true);
+ const handlePause = () => setIsPlaying(false);
+ const handleEnded = () => {
+ setIsPlaying(false);
+ setCurrentTime(audio.duration || 0);
+ };
+
+ syncState();
+ audio.addEventListener('loadedmetadata', handleMeta);
+ audio.addEventListener('durationchange', syncState);
+ audio.addEventListener('timeupdate', handleTime);
+ audio.addEventListener('play', handlePlay);
+ audio.addEventListener('pause', handlePause);
+ audio.addEventListener('ended', handleEnded);
+
+ return () => {
+ audio.removeEventListener('loadedmetadata', handleMeta);
+ audio.removeEventListener('durationchange', syncState);
+ audio.removeEventListener('timeupdate', handleTime);
+ audio.removeEventListener('play', handlePlay);
+ audio.removeEventListener('pause', handlePause);
+ audio.removeEventListener('ended', handleEnded);
+ };
+ }, [audioRef, onLoadedMetadata, onTimeUpdate, src]);
+
+ useEffect(() => {
+ if (audioRef?.current) {
+ audioRef.current.playbackRate = playbackRate;
+ }
+ }, [audioRef, playbackRate]);
+
+ const rateMenuItems = useMemo(
+ () => rateOptions.map((rate) => ({
+ key: String(rate),
+ label: `${rate.toFixed(rate % 1 === 0 ? 1 : 2)}x`,
+ onClick: () => onPlaybackRateChange?.(rate),
+ })),
+ [onPlaybackRateChange, rateOptions],
+ );
+
+ const progress = duration > 0 ? Math.min((currentTime / duration) * 100, 100) : 0;
+
+ const togglePlay = async () => {
+ const audio = audioRef?.current;
+ if (!audio || !src) {
+ return;
+ }
+
+ if (audio.paused || audio.ended) {
+ try {
+ await audio.play();
+ } catch {
+ setIsPlaying(false);
+ }
+ } else {
+ audio.pause();
+ }
+ };
+
+ const handleSeek = (event) => {
+ const audio = audioRef?.current;
+ if (!audio || !src) {
+ return;
+ }
+
+ const nextTime = Number(event.target.value);
+ audio.currentTime = nextTime;
+ setCurrentTime(nextTime);
+ };
+
+ if (!src) {
+ return (
+
+ {emptyText}
+
+ );
+ }
+
+ return (
+
+
+
+
:
}
+ onClick={togglePlay}
+ />
+
+
+ {tools.formatDuration(currentTime)} / {tools.formatDuration(duration)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {showMoreButton ? (
+ <>
+
+ {moreMenuItems.length > 0 ? (
+
+ } />
+
+ ) : (
+
} />
+ )}
+ >
+ ) : null}
+
+ );
+};
+
+export default AudioPlayerBar;
diff --git a/frontend/src/components/MindMap.jsx b/frontend/src/components/MindMap.jsx
index 14770b6..3d8dc25 100644
--- a/frontend/src/components/MindMap.jsx
+++ b/frontend/src/components/MindMap.jsx
@@ -6,9 +6,16 @@ import { FullscreenOutlined, ZoomInOutlined, ZoomOutOutlined, SyncOutlined } fro
const transformer = new Transformer();
+const hasRenderableSize = (element) => {
+ if (!element) return false;
+ const rect = element.getBoundingClientRect();
+ return Number.isFinite(rect.width) && Number.isFinite(rect.height) && rect.width > 0 && rect.height > 0;
+};
+
const MindMap = ({ content, title }) => {
const svgRef = useRef(null);
const markmapRef = useRef(null);
+ const latestRootRef = useRef(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
@@ -17,16 +24,22 @@ const MindMap = ({ content, title }) => {
setLoading(true);
try {
const { root } = transformer.transform(content);
-
+ latestRootRef.current = root;
+
if (markmapRef.current) {
markmapRef.current.setData(root);
- markmapRef.current.fit();
} else {
markmapRef.current = Markmap.create(svgRef.current, {
- autoFit: true,
+ autoFit: false,
duration: 500,
}, root);
}
+
+ requestAnimationFrame(() => {
+ if (svgRef.current && hasRenderableSize(svgRef.current)) {
+ markmapRef.current?.fit();
+ }
+ });
} catch (error) {
console.error('Markmap error:', error);
} finally {
@@ -34,6 +47,33 @@ const MindMap = ({ content, title }) => {
}
}, [content]);
+ useEffect(() => {
+ const svgElement = svgRef.current;
+ if (!svgElement || typeof ResizeObserver === 'undefined') {
+ return undefined;
+ }
+
+ const observer = new ResizeObserver(() => {
+ if (!hasRenderableSize(svgElement)) {
+ return;
+ }
+
+ if (!markmapRef.current && latestRootRef.current) {
+ markmapRef.current = Markmap.create(svgElement, {
+ autoFit: false,
+ duration: 500,
+ }, latestRootRef.current);
+ }
+
+ requestAnimationFrame(() => {
+ markmapRef.current?.fit();
+ });
+ });
+
+ observer.observe(svgElement);
+ return () => observer.disconnect();
+ }, []);
+
const handleFit = () => markmapRef.current?.fit();
const handleZoomIn = () => markmapRef.current?.rescale(1.2);
const handleZoomOut = () => markmapRef.current?.rescale(0.8);
diff --git a/frontend/src/components/TranscriptTimeline.css b/frontend/src/components/TranscriptTimeline.css
new file mode 100644
index 0000000..6186bcd
--- /dev/null
+++ b/frontend/src/components/TranscriptTimeline.css
@@ -0,0 +1,118 @@
+.transcript-scroll-panel {
+ overflow-y: auto;
+ padding: 12px 12px 12px 4px;
+}
+
+.transcript-scroll-panel-fill {
+ flex: 1;
+ min-height: 0;
+ height: 100%;
+}
+
+.transcript-loading {
+ display: flex;
+ justify-content: center;
+ padding-top: 120px;
+}
+
+.transcript-entry {
+ margin-left: -4px;
+ padding: 10px 12px;
+ border: 1px solid transparent;
+ border-radius: 14px;
+ background: rgba(255, 255, 255, 0.82);
+ cursor: pointer;
+ transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
+}
+
+.transcript-entry:hover {
+ background: #f8fbff;
+ border-color: #d9e8fb;
+}
+
+.transcript-entry.is-active {
+ background: linear-gradient(180deg, #eef6ff 0%, #e2f0ff 100%);
+ border-color: #bfd8fb;
+ box-shadow: 0 10px 22px rgba(29, 78, 216, 0.12);
+}
+
+.transcript-entry-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: var(--speaker-color, #1677ff);
+ box-shadow: none;
+ transition: box-shadow 0.2s ease;
+}
+
+.transcript-entry-dot.is-active {
+ box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.16);
+}
+
+.transcript-entry-time.ant-typography {
+ font-size: 12px;
+ white-space: nowrap;
+}
+
+.transcript-entry-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 4px;
+}
+
+.transcript-entry-speaker {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+
+.transcript-entry-avatar.ant-avatar {
+ flex-shrink: 0;
+}
+
+.transcript-entry-speaker-label.ant-typography {
+ margin: 0 !important;
+ font-size: 13px;
+ font-weight: 700;
+ color: #1677ff;
+}
+
+.transcript-entry-speaker-label.is-editable {
+ cursor: pointer;
+}
+
+.transcript-entry-speaker-edit {
+ font-size: 11px;
+ margin-left: 3px;
+}
+
+.transcript-entry-content {
+ color: #334155;
+ font-size: 14px;
+ line-height: 1.72;
+}
+
+.transcript-entry-content.ant-typography {
+ margin: 0 !important;
+}
+
+.transcript-render-hint {
+ text-align: center;
+ padding: 8px 0 20px;
+}
+
+@media (max-width: 768px) {
+ .transcript-scroll-panel {
+ padding-right: 6px;
+ }
+
+ .transcript-entry {
+ padding: 9px 10px;
+ }
+
+ .transcript-entry-content {
+ font-size: 13px;
+ }
+}
diff --git a/frontend/src/components/TranscriptTimeline.jsx b/frontend/src/components/TranscriptTimeline.jsx
new file mode 100644
index 0000000..77a002b
--- /dev/null
+++ b/frontend/src/components/TranscriptTimeline.jsx
@@ -0,0 +1,219 @@
+import React from 'react';
+import {
+ Avatar,
+ Button,
+ Empty,
+ Input,
+ Space,
+ Spin,
+ Timeline,
+ Typography,
+} from 'antd';
+import {
+ CheckOutlined,
+ CloseOutlined,
+ EditOutlined,
+ UserOutlined,
+} from '@ant-design/icons';
+import tools from '../utils/tools';
+import './TranscriptTimeline.css';
+
+const { Text } = Typography;
+
+const TranscriptTimeline = ({
+ transcript = [],
+ loading = false,
+ visibleCount,
+ currentHighlightIndex = -1,
+ onJumpToTime,
+ onScroll,
+ transcriptRefs,
+ getSpeakerColor,
+ emptyDescription = '暂无对话数据',
+ loadingTip = '正在加载转录内容...',
+ showRenderHint = false,
+ fillHeight = false,
+ maxHeight = null,
+ editable = false,
+ isMeetingOwner = false,
+ editing = {},
+}) => {
+ const {
+ inlineSpeakerEdit = null,
+ inlineSpeakerEditSegmentId = null,
+ inlineSpeakerValue = '',
+ setInlineSpeakerValue,
+ startInlineSpeakerEdit,
+ saveInlineSpeakerEdit,
+ cancelInlineSpeakerEdit,
+ inlineSegmentEditId = null,
+ inlineSegmentValue = '',
+ setInlineSegmentValue,
+ startInlineSegmentEdit,
+ saveInlineSegmentEdit,
+ cancelInlineSegmentEdit,
+ savingInlineEdit = false,
+ } = editing;
+
+ const renderCount = Math.min(visibleCount ?? transcript.length, transcript.length);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (!transcript.length) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
{
+ const isActive = currentHighlightIndex === index;
+ const speakerColor = getSpeakerColor(item.speaker_id);
+ const speakerEditKey = `speaker-${item.speaker_id}-${item.segment_id}`;
+
+ return {
+ label: (
+
+ {tools.formatDuration(item.start_time_ms / 1000)}
+
+ ),
+ dot: (
+
+ ),
+ children: (
+ {
+ if (transcriptRefs?.current) {
+ transcriptRefs.current[index] = el;
+ }
+ }}
+ className={`transcript-entry${isActive ? ' is-active' : ''}`}
+ onClick={() => onJumpToTime?.(item.start_time_ms)}
+ >
+
+
+
}
+ className="transcript-entry-avatar"
+ style={{ backgroundColor: speakerColor }}
+ />
+ {editable && inlineSpeakerEdit === item.speaker_id && inlineSpeakerEditSegmentId === speakerEditKey ? (
+
event.stopPropagation()}>
+ setInlineSpeakerValue?.(event.target.value)}
+ onPressEnter={saveInlineSpeakerEdit}
+ style={{ width: 180 }}
+ />
+ }
+ loading={savingInlineEdit}
+ onClick={saveInlineSpeakerEdit}
+ />
+ }
+ disabled={savingInlineEdit}
+ onClick={cancelInlineSpeakerEdit}
+ />
+
+ ) : (
+
{
+ event.stopPropagation();
+ startInlineSpeakerEdit?.(item.speaker_id, item.speaker_tag, item.segment_id);
+ } : undefined}
+ >
+ {item.speaker_tag || `发言人 ${item.speaker_id}`}
+ {editable && isMeetingOwner ? : null}
+
+ )}
+
+
+
+ {editable && inlineSegmentEditId === item.segment_id ? (
+
event.stopPropagation()}>
+ setInlineSegmentValue?.(event.target.value)}
+ onPressEnter={(event) => {
+ if (event.ctrlKey || event.metaKey) {
+ saveInlineSegmentEdit?.();
+ }
+ }}
+ />
+
+ } loading={savingInlineEdit} onClick={saveInlineSegmentEdit}>
+ 保存
+
+ } disabled={savingInlineEdit} onClick={cancelInlineSegmentEdit}>
+ 取消
+
+
+
+ ) : (
+
{
+ event.stopPropagation();
+ startInlineSegmentEdit?.(item);
+ } : undefined}
+ >
+ {item.text_content}
+
+ )}
+
+ ),
+ };
+ })}
+ />
+ {showRenderHint && renderCount < transcript.length ? (
+
+
+ 已渲染 {renderCount} / {transcript.length} 条转录,继续向下滚动将自动加载更多
+
+
+ ) : null}
+
+ );
+};
+
+export default TranscriptTimeline;
diff --git a/frontend/src/pages/MeetingDetails.jsx b/frontend/src/pages/MeetingDetails.jsx
index ede8d6a..5018933 100644
--- a/frontend/src/pages/MeetingDetails.jsx
+++ b/frontend/src/pages/MeetingDetails.jsx
@@ -3,7 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
import {
Card, Row, Col, Button, Space, Typography, Tag, Avatar,
Tooltip, Progress, Spin, App, Dropdown,
- Divider, List, Timeline, Tabs, Input, Upload, Empty, Drawer, Select, Switch
+ Divider, List, Tabs, Input, Upload, Empty, Drawer, Select, Switch
} from 'antd';
import {
ClockCircleOutlined, UserOutlined, TeamOutlined,
@@ -13,13 +13,15 @@ import {
EyeOutlined, FileTextOutlined, PartitionOutlined,
SaveOutlined, CloseOutlined,
StarFilled, RobotOutlined, DownloadOutlined,
- DownOutlined, CheckOutlined,
+ CheckOutlined,
MoreOutlined, AudioOutlined, CopyOutlined
} from '@ant-design/icons';
import MarkdownRenderer from '../components/MarkdownRenderer';
import MarkdownEditor from '../components/MarkdownEditor';
import MindMap from '../components/MindMap';
import ActionButton from '../components/ActionButton';
+import AudioPlayerBar from '../components/AudioPlayerBar';
+import TranscriptTimeline from '../components/TranscriptTimeline';
import apiClient from '../utils/apiClient';
import tools from '../utils/tools';
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
@@ -731,14 +733,6 @@ const MeetingDetails = ({ user }) => {
{ key: 'upload', icon: , label: isUploading ? '上传中...' : '上传音频', disabled: isUploading, onClick: openAudioUploadPicker },
];
- const playbackRateMenuItems = [
- { key: '0.75', label: '0.75x', onClick: () => changePlaybackRate(0.75) },
- { key: '1', label: '1.0x', onClick: () => changePlaybackRate(1) },
- { key: '1.25', label: '1.25x', onClick: () => changePlaybackRate(1.25) },
- { key: '1.5', label: '1.5x', onClick: () => changePlaybackRate(1.5) },
- { key: '2', label: '2.0x', onClick: () => changePlaybackRate(2) },
- ];
-
/* ══════════════════ 渲染 ══════════════════ */
if (loading) return
;
@@ -842,188 +836,54 @@ const MeetingDetails = ({ user }) => {
{/* 音频播放器 */}
-
-
- {audioUrl ? (
-
-
-
-
-
-
- }
- />
-
-
-
+
{
+ if (audioRef.current) {
+ audioRef.current.playbackRate = playbackRate;
+ }
+ }}
+ moreMenuItems={audioMoreMenuItems}
+ emptyText="暂无音频,可通过右侧更多操作上传音频"
+ />
{/* 转录时间轴 */}
-
- {transcriptLoading ? (
-
-
-
- ) : transcript.length > 0 ? (
- <>
-
{
- const isActive = currentHighlightIndex === index;
- return {
- label: (
-
- {tools.formatDuration(item.start_time_ms / 1000)}
-
- ),
- dot: (
-
- ),
- children: (
- transcriptRefs.current[index] = el}
- style={{
- padding: '6px 10px',
- borderRadius: 8,
- background: isActive ? '#e6f4ff' : 'transparent',
- cursor: 'pointer',
- transition: 'background 0.2s',
- marginLeft: -4,
- }}
- onClick={() => jumpToTime(item.start_time_ms)}
- >
-
-
}
- style={{ backgroundColor: getSpeakerColor(item.speaker_id), flexShrink: 0 }}
- />
- {inlineSpeakerEdit === item.speaker_id && inlineSpeakerEditSegmentId === `speaker-${item.speaker_id}-${item.segment_id}` ? (
-
e.stopPropagation()}>
- setInlineSpeakerValue(e.target.value)}
- onPressEnter={saveInlineSpeakerEdit}
- style={{ width: 180 }}
- />
- }
- loading={savingInlineEdit}
- onClick={saveInlineSpeakerEdit}
- />
- }
- disabled={savingInlineEdit}
- onClick={cancelInlineSpeakerEdit}
- />
-
- ) : (
-
{
- e.stopPropagation();
- startInlineSpeakerEdit(item.speaker_id, item.speaker_tag, item.segment_id);
- } : undefined}
- >
- {item.speaker_tag || `发言人 ${item.speaker_id}`}
- {isMeetingOwner ? : null}
-
- )}
-
- {inlineSegmentEditId === item.segment_id ? (
-
e.stopPropagation()}>
- setInlineSegmentValue(e.target.value)}
- onPressEnter={(e) => {
- if (e.ctrlKey || e.metaKey) {
- saveInlineSegmentEdit();
- }
- }}
- />
-
- } loading={savingInlineEdit} onClick={saveInlineSegmentEdit}>
- 保存
-
- } disabled={savingInlineEdit} onClick={cancelInlineSegmentEdit}>
- 取消
-
-
-
- ) : (
-
{
- e.stopPropagation();
- startInlineSegmentEdit(item);
- } : undefined}
- >
- {item.text_content}
-
- )}
-
- ),
- };
- })}
- />
- {transcriptVisibleCount < transcript.length ? (
-
-
- 已渲染 {transcriptVisibleCount} / {transcript.length} 条转录,继续向下滚动将自动加载更多
-
-
- ) : null}
- >
- ) : (
-
- )}
+
+
diff --git a/frontend/src/pages/MeetingPreview.css b/frontend/src/pages/MeetingPreview.css
index 67a6ebe..0a8a0b1 100644
--- a/frontend/src/pages/MeetingPreview.css
+++ b/frontend/src/pages/MeetingPreview.css
@@ -591,9 +591,14 @@
======================================== */
.transcript-wrapper {
+ display: block;
padding: 20px 0;
}
+.preview-audio-toolbar {
+ padding: 0;
+}
+
.preview-audio-player {
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: 12px;
@@ -823,34 +828,4 @@
font-size: 15px;
}
- /* 预览页面播放器移动端优化 */
- .preview-audio-player {
- padding: 16px;
- }
-
- .preview-player-controls {
- gap: 12px;
- }
-
- .preview-progress-wrapper {
- width: 100%;
- }
-
- .preview-current-time {
- font-size: 11px;
- padding: 4px 8px;
- }
-
- .preview-current-time::after {
- border-left: 4px solid transparent;
- border-right: 4px solid transparent;
- border-top: 4px solid white;
- bottom: -3px;
- }
-
- .preview-slider-thumb::after {
- width: 14px;
- height: 14px;
- border-width: 2px;
- }
}
diff --git a/frontend/src/pages/MeetingPreview.jsx b/frontend/src/pages/MeetingPreview.jsx
index f262b57..3c72e80 100644
--- a/frontend/src/pages/MeetingPreview.jsx
+++ b/frontend/src/pages/MeetingPreview.jsx
@@ -1,11 +1,13 @@
import React, { 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, UserOutlined, FileTextOutlined, PartitionOutlined, AudioOutlined } from '@ant-design/icons';
+import { LockOutlined, EyeOutlined, CopyOutlined, ShareAltOutlined, HomeOutlined, FileTextOutlined, PartitionOutlined, AudioOutlined } from '@ant-design/icons';
import apiClient from '../utils/apiClient';
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, { DEFAULT_BRANDING_CONFIG } from '../utils/configService';
import './MeetingPreview.css';
@@ -16,6 +18,7 @@ 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);
@@ -27,6 +30,7 @@ const MeetingPreview = () => {
const [transcript, setTranscript] = useState([]);
const [audioUrl, setAudioUrl] = useState('');
const [activeSegmentIndex, setActiveSegmentIndex] = useState(-1);
+ const [playbackRate, setPlaybackRate] = useState(1);
useEffect(() => {
configService.getBrandingConfig().then(setBranding).catch(() => {});
@@ -35,8 +39,10 @@ const MeetingPreview = () => {
useEffect(() => {
setMeeting(null);
setTranscript([]);
+ transcriptRefs.current = [];
setAudioUrl('');
setActiveSegmentIndex(-1);
+ setPlaybackRate(1);
setError(null);
setPassword('');
setPasswordError('');
@@ -132,6 +138,14 @@ const MeetingPreview = () => {
audioRef.current.play().catch(() => {});
};
+ useEffect(() => {
+ if (activeSegmentIndex < 0) {
+ return;
+ }
+
+ transcriptRefs.current[activeSegmentIndex]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ }, [activeSegmentIndex]);
+
const handleVerify = () => {
if (!password) {
message.warning('请输入访问密码');
@@ -274,39 +288,28 @@ const MeetingPreview = () => {
children: (
{audioUrl ? (
-
+
) : null}
- {transcript.length ? (
-
- {transcript.map((segment, index) => (
-
jumpToSegment(segment)}
- >
-
-
-
- {segment.speaker_tag || `发言人 ${segment.speaker_id}`}
-
- {tools.formatDuration((segment.start_time_ms || 0) / 1000)}
-
-
{segment.text_content}
-
- ))}
-
- ) : (
-
暂无转录内容
- )}
+
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="暂无转录内容"
+ />
),
}
diff --git a/frontend/src/styles/console-theme.css b/frontend/src/styles/console-theme.css
index 47b72dc..b31cdff 100644
--- a/frontend/src/styles/console-theme.css
+++ b/frontend/src/styles/console-theme.css
@@ -439,110 +439,6 @@ body {
width: calc(100% - 96px) !important;
}
-.meeting-audio-toolbar {
- display: flex;
- align-items: center;
- gap: 12px;
- padding: 0;
-}
-
-.meeting-audio-toolbar-player {
- flex: 1;
- min-width: 0;
-}
-
-.meeting-audio-toolbar-native {
- width: 100%;
- height: 40px;
- border-radius: 999px;
- background: #f5f7fb;
-}
-
-.meeting-audio-toolbar-native::-webkit-media-controls-panel {
- background: #f5f7fb;
-}
-
-.meeting-audio-toolbar-empty {
- height: 40px;
- display: flex;
- align-items: center;
- padding: 0 14px;
- border-radius: 999px;
- background: #f5f7fb;
-}
-
-.meeting-audio-toolbar-actions {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- padding: 0;
-}
-
-.meeting-audio-toolbar-button.ant-btn {
- height: 32px;
- padding: 0 10px;
- border: 1px solid #dbe3ef;
- border-radius: 999px;
- background: #fff;
- color: #526581;
- font-size: 12px;
- font-weight: 600;
- box-shadow: none;
-}
-
-.meeting-audio-toolbar-button.ant-btn:hover,
-.meeting-audio-toolbar-button.ant-btn:focus {
- color: #355171;
- border-color: #c7d4e4;
- background: #f8fafc !important;
- box-shadow: none;
-}
-
-.meeting-audio-toolbar-button.ant-btn:disabled {
- color: #a1b1c4;
- border-color: #e3e8f0;
- background: #f8fafc !important;
- box-shadow: none;
-}
-
-.meeting-audio-toolbar-rate.ant-btn {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- min-width: 68px;
- justify-content: center;
- font-weight: 700;
-}
-
-.meeting-audio-toolbar-button .anticon {
- font-size: 11px;
-}
-
-.meeting-audio-toolbar-rate .anticon {
- font-size: 9px;
-}
-
-.meeting-audio-toolbar-more.ant-btn {
- width: 32px;
- min-width: 32px;
- padding-inline: 0;
- padding: 0;
- border-color: #c9ddfb;
- background: linear-gradient(180deg, #f8fbff 0%, #eef5ff 100%);
- color: #1d4ed8;
-}
-
-.meeting-audio-toolbar-more.ant-btn:hover,
-.meeting-audio-toolbar-more.ant-btn:focus {
- border-color: #a9c9fa;
- background: linear-gradient(180deg, #f2f8ff 0%, #e7f0ff 100%) !important;
- color: #1d4ed8;
-}
-
-.meeting-audio-toolbar-more.ant-btn .anticon {
- font-size: 12px;
-}
-
.console-tab-toolbar {
display: flex;
justify-content: space-between;