220 lines
7.6 KiB
JavaScript
220 lines
7.6 KiB
JavaScript
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;
|