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