修改了音频播放器
parent
ac9c2f5fd4
commit
2591996a48
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="audio-player-bar is-empty">
|
||||
<span className="audio-player-bar-empty-text">{emptyText}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="audio-player-bar">
|
||||
<audio ref={audioRef} className="audio-player-bar-audio" src={src} preload="metadata" />
|
||||
|
||||
<Button
|
||||
type="text"
|
||||
className="audio-player-bar-play"
|
||||
icon={isPlaying ? <PauseOutlined /> : <CaretRightFilled />}
|
||||
onClick={togglePlay}
|
||||
/>
|
||||
|
||||
<div className="audio-player-bar-time">
|
||||
{tools.formatDuration(currentTime)} / {tools.formatDuration(duration)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
className="audio-player-bar-progress"
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration || 0}
|
||||
step={0.1}
|
||||
value={Math.min(currentTime, duration || 0)}
|
||||
onChange={handleSeek}
|
||||
style={{ '--progress': `${progress}%` }}
|
||||
/>
|
||||
|
||||
<span className="audio-player-bar-volume">
|
||||
<SoundOutlined />
|
||||
</span>
|
||||
<span className="audio-player-bar-divider" />
|
||||
|
||||
<Dropdown menu={{ items: rateMenuItems, selectable: true, selectedKeys: [String(playbackRate)] }} trigger={['click']}>
|
||||
<Button type="text" className="audio-player-bar-control audio-player-bar-rate">
|
||||
<span>{playbackRate.toFixed(playbackRate % 1 === 0 ? 1 : 2)}x</span>
|
||||
<DownOutlined />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
|
||||
{showMoreButton ? (
|
||||
<>
|
||||
<span className="audio-player-bar-divider" />
|
||||
{moreMenuItems.length > 0 ? (
|
||||
<Dropdown menu={{ items: moreMenuItems }} trigger={['click']}>
|
||||
<Button type="text" className="audio-player-bar-control audio-player-bar-more" icon={<MoreOutlined />} />
|
||||
</Dropdown>
|
||||
) : (
|
||||
<Button type="text" disabled className="audio-player-bar-control audio-player-bar-more" icon={<MoreOutlined />} />
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioPlayerBar;
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div
|
||||
className={`transcript-scroll-panel${fillHeight ? ' transcript-scroll-panel-fill' : ''}`}
|
||||
onScroll={onScroll}
|
||||
style={maxHeight ? { maxHeight } : undefined}
|
||||
>
|
||||
<div className="transcript-loading">
|
||||
<Spin size="large" tip={loadingTip} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!transcript.length) {
|
||||
return (
|
||||
<div
|
||||
className={`transcript-scroll-panel${fillHeight ? ' transcript-scroll-panel-fill' : ''}`}
|
||||
onScroll={onScroll}
|
||||
style={maxHeight ? { maxHeight } : undefined}
|
||||
>
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={emptyDescription} style={{ marginTop: 80 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`transcript-scroll-panel${fillHeight ? ' transcript-scroll-panel-fill' : ''}`}
|
||||
onScroll={onScroll}
|
||||
style={maxHeight ? { maxHeight } : undefined}
|
||||
>
|
||||
<Timeline
|
||||
mode="left"
|
||||
className="transcript-timeline"
|
||||
items={transcript.slice(0, renderCount).map((item, index) => {
|
||||
const isActive = currentHighlightIndex === index;
|
||||
const speakerColor = getSpeakerColor(item.speaker_id);
|
||||
const speakerEditKey = `speaker-${item.speaker_id}-${item.segment_id}`;
|
||||
|
||||
return {
|
||||
label: (
|
||||
<Text type="secondary" className="transcript-entry-time">
|
||||
{tools.formatDuration(item.start_time_ms / 1000)}
|
||||
</Text>
|
||||
),
|
||||
dot: (
|
||||
<span
|
||||
className={`transcript-entry-dot${isActive ? ' is-active' : ''}`}
|
||||
style={{ '--speaker-color': speakerColor }}
|
||||
/>
|
||||
),
|
||||
children: (
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (transcriptRefs?.current) {
|
||||
transcriptRefs.current[index] = el;
|
||||
}
|
||||
}}
|
||||
className={`transcript-entry${isActive ? ' is-active' : ''}`}
|
||||
onClick={() => onJumpToTime?.(item.start_time_ms)}
|
||||
>
|
||||
<div className="transcript-entry-header">
|
||||
<div className="transcript-entry-speaker">
|
||||
<Avatar
|
||||
size={24}
|
||||
icon={<UserOutlined />}
|
||||
className="transcript-entry-avatar"
|
||||
style={{ backgroundColor: speakerColor }}
|
||||
/>
|
||||
{editable && inlineSpeakerEdit === item.speaker_id && inlineSpeakerEditSegmentId === speakerEditKey ? (
|
||||
<Space.Compact onClick={(event) => event.stopPropagation()}>
|
||||
<Input
|
||||
size="small"
|
||||
autoFocus
|
||||
value={inlineSpeakerValue}
|
||||
onChange={(event) => setInlineSpeakerValue?.(event.target.value)}
|
||||
onPressEnter={saveInlineSpeakerEdit}
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<CheckOutlined />}
|
||||
loading={savingInlineEdit}
|
||||
onClick={saveInlineSpeakerEdit}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
disabled={savingInlineEdit}
|
||||
onClick={cancelInlineSpeakerEdit}
|
||||
/>
|
||||
</Space.Compact>
|
||||
) : (
|
||||
<Text
|
||||
className={`transcript-entry-speaker-label${editable && isMeetingOwner ? ' is-editable' : ''}`}
|
||||
onClick={editable && isMeetingOwner ? (event) => {
|
||||
event.stopPropagation();
|
||||
startInlineSpeakerEdit?.(item.speaker_id, item.speaker_tag, item.segment_id);
|
||||
} : undefined}
|
||||
>
|
||||
{item.speaker_tag || `发言人 ${item.speaker_id}`}
|
||||
{editable && isMeetingOwner ? <EditOutlined className="transcript-entry-speaker-edit" /> : null}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editable && inlineSegmentEditId === item.segment_id ? (
|
||||
<div onClick={(event) => event.stopPropagation()}>
|
||||
<Input.TextArea
|
||||
autoFocus
|
||||
autoSize={{ minRows: 2, maxRows: 6 }}
|
||||
value={inlineSegmentValue}
|
||||
onChange={(event) => setInlineSegmentValue?.(event.target.value)}
|
||||
onPressEnter={(event) => {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
saveInlineSegmentEdit?.();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Space style={{ marginTop: 8 }}>
|
||||
<Button size="small" type="primary" icon={<CheckOutlined />} loading={savingInlineEdit} onClick={saveInlineSegmentEdit}>
|
||||
保存
|
||||
</Button>
|
||||
<Button size="small" icon={<CloseOutlined />} disabled={savingInlineEdit} onClick={cancelInlineSegmentEdit}>
|
||||
取消
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
) : (
|
||||
<Text
|
||||
className="transcript-entry-content"
|
||||
onDoubleClick={editable && isMeetingOwner ? (event) => {
|
||||
event.stopPropagation();
|
||||
startInlineSegmentEdit?.(item);
|
||||
} : undefined}
|
||||
>
|
||||
{item.text_content}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
{showRenderHint && renderCount < transcript.length ? (
|
||||
<div className="transcript-render-hint">
|
||||
<Text type="secondary">
|
||||
已渲染 {renderCount} / {transcript.length} 条转录,继续向下滚动将自动加载更多
|
||||
</Text>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TranscriptTimeline;
|
||||
|
|
@ -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: <UploadOutlined />, 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 <div style={{ textAlign: 'center', padding: '100px' }}><Spin size="large" tip="正在加载..." /></div>;
|
||||
|
|
@ -842,188 +836,54 @@ const MeetingDetails = ({ user }) => {
|
|||
|
||||
{/* 音频播放器 */}
|
||||
<div style={{ padding: '8px 20px 0' }}>
|
||||
<div className="meeting-audio-toolbar">
|
||||
<div className="meeting-audio-toolbar-player">
|
||||
{audioUrl ? (
|
||||
<audio
|
||||
className="meeting-audio-toolbar-native"
|
||||
ref={audioRef}
|
||||
src={audioUrl}
|
||||
controls
|
||||
controlsList="nodownload noplaybackrate"
|
||||
onLoadedMetadata={() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.playbackRate = playbackRate;
|
||||
}
|
||||
}}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
style={{ width: '100%', height: 36 }}
|
||||
/>
|
||||
) : (
|
||||
<div className="meeting-audio-toolbar-empty">
|
||||
<Text type="secondary">暂无音频,可通过右侧更多操作上传音频</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="meeting-audio-toolbar-actions">
|
||||
<Dropdown
|
||||
menu={{ items: playbackRateMenuItems, selectable: true, selectedKeys: [String(playbackRate)] }}
|
||||
trigger={['click']}
|
||||
>
|
||||
<Button
|
||||
className="meeting-audio-toolbar-button meeting-audio-toolbar-rate btn-pill-secondary"
|
||||
disabled={!audioUrl}
|
||||
>
|
||||
<span>{playbackRate.toFixed(playbackRate % 1 === 0 ? 1 : 2)}x</span>
|
||||
<DownOutlined />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<Dropdown menu={{ items: audioMoreMenuItems }} trigger={['click']}>
|
||||
<Button
|
||||
className="meeting-audio-toolbar-button meeting-audio-toolbar-more btn-pill-secondary"
|
||||
icon={<MoreOutlined />}
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<AudioPlayerBar
|
||||
audioRef={audioRef}
|
||||
src={audioUrl}
|
||||
playbackRate={playbackRate}
|
||||
onPlaybackRateChange={changePlaybackRate}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.playbackRate = playbackRate;
|
||||
}
|
||||
}}
|
||||
moreMenuItems={audioMoreMenuItems}
|
||||
emptyText="暂无音频,可通过右侧更多操作上传音频"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 转录时间轴 */}
|
||||
<div
|
||||
style={{ flex: 1, overflowY: 'auto', padding: '12px 12px 12px 4px' }}
|
||||
onScroll={handleTranscriptScroll}
|
||||
>
|
||||
{transcriptLoading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', paddingTop: 120 }}>
|
||||
<Spin size="large" tip="正在加载转录内容..." />
|
||||
</div>
|
||||
) : transcript.length > 0 ? (
|
||||
<>
|
||||
<Timeline
|
||||
mode="left"
|
||||
className="transcript-timeline"
|
||||
items={transcript.slice(0, transcriptVisibleCount).map((item, index) => {
|
||||
const isActive = currentHighlightIndex === index;
|
||||
return {
|
||||
label: (
|
||||
<Text type="secondary" style={{ fontSize: 12, whiteSpace: 'nowrap' }}>
|
||||
{tools.formatDuration(item.start_time_ms / 1000)}
|
||||
</Text>
|
||||
),
|
||||
dot: (
|
||||
<div style={{
|
||||
width: 10, height: 10, borderRadius: '50%',
|
||||
background: getSpeakerColor(item.speaker_id),
|
||||
boxShadow: isActive ? `0 0 0 3px ${getSpeakerColor(item.speaker_id)}33` : 'none',
|
||||
}} />
|
||||
),
|
||||
children: (
|
||||
<div
|
||||
ref={el => 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)}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 2 }}>
|
||||
<Avatar
|
||||
size={24}
|
||||
icon={<UserOutlined />}
|
||||
style={{ backgroundColor: getSpeakerColor(item.speaker_id), flexShrink: 0 }}
|
||||
/>
|
||||
{inlineSpeakerEdit === item.speaker_id && inlineSpeakerEditSegmentId === `speaker-${item.speaker_id}-${item.segment_id}` ? (
|
||||
<Space.Compact onClick={(e) => e.stopPropagation()}>
|
||||
<Input
|
||||
size="small"
|
||||
autoFocus
|
||||
value={inlineSpeakerValue}
|
||||
onChange={(e) => setInlineSpeakerValue(e.target.value)}
|
||||
onPressEnter={saveInlineSpeakerEdit}
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<CheckOutlined />}
|
||||
loading={savingInlineEdit}
|
||||
onClick={saveInlineSpeakerEdit}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
disabled={savingInlineEdit}
|
||||
onClick={cancelInlineSpeakerEdit}
|
||||
/>
|
||||
</Space.Compact>
|
||||
) : (
|
||||
<Text
|
||||
strong
|
||||
style={{ color: '#1677ff', cursor: isMeetingOwner ? 'pointer' : 'default', fontSize: 13 }}
|
||||
onClick={isMeetingOwner ? (e) => {
|
||||
e.stopPropagation();
|
||||
startInlineSpeakerEdit(item.speaker_id, item.speaker_tag, item.segment_id);
|
||||
} : undefined}
|
||||
>
|
||||
{item.speaker_tag || `发言人 ${item.speaker_id}`}
|
||||
{isMeetingOwner ? <EditOutlined style={{ fontSize: 11, marginLeft: 3 }} /> : null}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
{inlineSegmentEditId === item.segment_id ? (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Input.TextArea
|
||||
autoFocus
|
||||
autoSize={{ minRows: 2, maxRows: 6 }}
|
||||
value={inlineSegmentValue}
|
||||
onChange={(e) => setInlineSegmentValue(e.target.value)}
|
||||
onPressEnter={(e) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
saveInlineSegmentEdit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Space style={{ marginTop: 8 }}>
|
||||
<Button size="small" type="primary" icon={<CheckOutlined />} loading={savingInlineEdit} onClick={saveInlineSegmentEdit}>
|
||||
保存
|
||||
</Button>
|
||||
<Button size="small" icon={<CloseOutlined />} disabled={savingInlineEdit} onClick={cancelInlineSegmentEdit}>
|
||||
取消
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
) : (
|
||||
<Text
|
||||
style={{ fontSize: 14, lineHeight: 1.7, color: '#333', cursor: isMeetingOwner ? 'text' : 'default' }}
|
||||
onDoubleClick={isMeetingOwner ? (e) => {
|
||||
e.stopPropagation();
|
||||
startInlineSegmentEdit(item);
|
||||
} : undefined}
|
||||
>
|
||||
{item.text_content}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
{transcriptVisibleCount < transcript.length ? (
|
||||
<div style={{ textAlign: 'center', padding: '8px 0 20px' }}>
|
||||
<Text type="secondary">
|
||||
已渲染 {transcriptVisibleCount} / {transcript.length} 条转录,继续向下滚动将自动加载更多
|
||||
</Text>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无对话数据" style={{ marginTop: 80 }} />
|
||||
)}
|
||||
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', minHeight: 0 }}>
|
||||
<TranscriptTimeline
|
||||
transcript={transcript}
|
||||
loading={transcriptLoading}
|
||||
visibleCount={transcriptVisibleCount}
|
||||
currentHighlightIndex={currentHighlightIndex}
|
||||
onJumpToTime={jumpToTime}
|
||||
onScroll={handleTranscriptScroll}
|
||||
transcriptRefs={transcriptRefs}
|
||||
getSpeakerColor={getSpeakerColor}
|
||||
showRenderHint
|
||||
fillHeight
|
||||
editable
|
||||
isMeetingOwner={isMeetingOwner}
|
||||
editing={{
|
||||
inlineSpeakerEdit,
|
||||
inlineSpeakerEditSegmentId,
|
||||
inlineSpeakerValue,
|
||||
setInlineSpeakerValue,
|
||||
startInlineSpeakerEdit,
|
||||
saveInlineSpeakerEdit,
|
||||
cancelInlineSpeakerEdit,
|
||||
inlineSegmentEditId,
|
||||
inlineSegmentValue,
|
||||
setInlineSegmentValue,
|
||||
startInlineSegmentEdit,
|
||||
saveInlineSegmentEdit,
|
||||
cancelInlineSegmentEdit,
|
||||
savingInlineEdit,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: (
|
||||
<div className="transcript-wrapper">
|
||||
{audioUrl ? (
|
||||
<div className="preview-audio-player">
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={audioUrl}
|
||||
controls
|
||||
controlsList="nodownload noplaybackrate"
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<AudioPlayerBar
|
||||
audioRef={audioRef}
|
||||
src={audioUrl}
|
||||
playbackRate={playbackRate}
|
||||
onPlaybackRateChange={setPlaybackRate}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
showMoreButton={false}
|
||||
/>
|
||||
) : null}
|
||||
{transcript.length ? (
|
||||
<div className="transcript-list">
|
||||
{transcript.map((segment, index) => (
|
||||
<div
|
||||
key={segment.segment_id}
|
||||
className={`transcript-segment${activeSegmentIndex === index ? ' active' : ''}`}
|
||||
onClick={() => jumpToSegment(segment)}
|
||||
>
|
||||
<div className="segment-header">
|
||||
<span className="speaker-name">
|
||||
<UserOutlined style={{ marginRight: 6 }} />
|
||||
{segment.speaker_tag || `发言人 ${segment.speaker_id}`}
|
||||
</span>
|
||||
<span className="segment-time">{tools.formatDuration((segment.start_time_ms || 0) / 1000)}</span>
|
||||
</div>
|
||||
<div className="segment-text">{segment.text_content}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-transcript">暂无转录内容</div>
|
||||
)}
|
||||
<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>
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue