imetting/frontend/src/components/TranscriptTimeline.jsx

220 lines
7.6 KiB
React
Raw Normal View History

2026-04-07 07:51:51 +00:00
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;