feat: 添加会议转录编辑组件和优化关键词处理
- 在 `MeetingDetail` 页面中添加 `ActiveTranscriptRow` 组件,支持会议转录的编辑和保存 - 优化关键词处理逻辑,移除重复代码并dev_na
parent
ffc19fa572
commit
60754bbd26
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
|
|
@ -100,6 +100,7 @@ const ANALYSIS_EMPTY: MeetingAnalysis = {
|
||||||
todos: [],
|
todos: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const splitLines = (value?: string | null) =>
|
const splitLines = (value?: string | null) =>
|
||||||
(value || '')
|
(value || '')
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
|
|
@ -175,7 +176,7 @@ const parseOverviewSection = (markdown: string) =>
|
||||||
const parseKeywordsSection = (markdown: string, tags: string) => {
|
const parseKeywordsSection = (markdown: string, tags: string) => {
|
||||||
const section = extractSection(markdown, ['关键词', '关键字', '标签']);
|
const section = extractSection(markdown, ['关键词', '关键字', '标签']);
|
||||||
const fromSection = parseBulletList(section)
|
const fromSection = parseBulletList(section)
|
||||||
.flatMap((line) => line.split(/[,、,]/))
|
.flatMap((line) => line.split(/[,、]/))
|
||||||
.map((item) => item.trim())
|
.map((item) => item.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
|
|
@ -300,7 +301,7 @@ const MeetingProgressDisplay: React.FC<{ meetingId: number; onComplete: () => vo
|
||||||
const isError = percent < 0;
|
const isError = percent < 0;
|
||||||
|
|
||||||
const formatEta = (seconds?: number) => {
|
const formatEta = (seconds?: number) => {
|
||||||
if (!seconds || seconds <= 0) return '正在分析中';
|
if (!seconds || seconds <= 0) return '计算中';
|
||||||
if (seconds < 60) return `${seconds} 秒`;
|
if (seconds < 60) return `${seconds} 秒`;
|
||||||
const minutes = Math.floor(seconds / 60);
|
const minutes = Math.floor(seconds / 60);
|
||||||
const remainSeconds = seconds % 60;
|
const remainSeconds = seconds % 60;
|
||||||
|
|
@ -336,7 +337,7 @@ const MeetingProgressDisplay: React.FC<{ meetingId: number; onComplete: () => vo
|
||||||
<Text strong style={{ fontSize: 18, color: isError ? '#ff4d4f' : '#5d67ff', display: 'block', marginBottom: 8 }}>
|
<Text strong style={{ fontSize: 18, color: isError ? '#ff4d4f' : '#5d67ff', display: 'block', marginBottom: 8 }}>
|
||||||
{progress?.message || '正在准备计算资源...'}
|
{progress?.message || '正在准备计算资源...'}
|
||||||
</Text>
|
</Text>
|
||||||
<Text type="secondary">分析过程中请稍候,你可以先处理其他工作。</Text>
|
<Text type="secondary">分析进行中,请稍候,你可以先处理其他工作。</Text>
|
||||||
</div>
|
</div>
|
||||||
<Divider style={{ margin: '32px 0' }} />
|
<Divider style={{ margin: '32px 0' }} />
|
||||||
<Row gutter={24}>
|
<Row gutter={24}>
|
||||||
|
|
@ -413,6 +414,117 @@ const SpeakerEditor: React.FC<{
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ActiveTranscriptRowProps = {
|
||||||
|
item: MeetingTranscriptVO;
|
||||||
|
meetingId: number;
|
||||||
|
isOwner: boolean;
|
||||||
|
isEditing: boolean;
|
||||||
|
isSaving: boolean;
|
||||||
|
speakerLabelMap: Map<string, string>;
|
||||||
|
onSeek: (timeMs: number) => void;
|
||||||
|
onStartEdit: (item: MeetingTranscriptVO, event: React.MouseEvent) => void;
|
||||||
|
onDraftBlur: (item: MeetingTranscriptVO, value: string) => void;
|
||||||
|
onDraftKeyDown: (item: MeetingTranscriptVO, value: string, event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||||
|
onSpeakerUpdated: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ActiveTranscriptRow = React.memo<ActiveTranscriptRowProps>(({
|
||||||
|
item,
|
||||||
|
meetingId,
|
||||||
|
isOwner,
|
||||||
|
isEditing,
|
||||||
|
isSaving,
|
||||||
|
speakerLabelMap,
|
||||||
|
onSeek,
|
||||||
|
onStartEdit,
|
||||||
|
onDraftBlur,
|
||||||
|
onDraftKeyDown,
|
||||||
|
onSpeakerUpdated,
|
||||||
|
}) => {
|
||||||
|
const [draftValue, setDraftValue] = useState(item.content || '');
|
||||||
|
const speakerTagLabel = item.speakerLabel ? (speakerLabelMap.get(item.speakerLabel) || item.speakerLabel) : '';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditing) {
|
||||||
|
setDraftValue(item.content || '');
|
||||||
|
}
|
||||||
|
}, [isEditing, item.content]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List.Item className="transcript-row" onClick={() => onSeek(item.startTime)}>
|
||||||
|
<div className="transcript-time">{formatTime(item.startTime)}</div>
|
||||||
|
<div className="transcript-entry">
|
||||||
|
<div className="transcript-meta">
|
||||||
|
<Avatar icon={<UserOutlined />} className="transcript-avatar" />
|
||||||
|
{isOwner ? (
|
||||||
|
<Popover
|
||||||
|
content={(
|
||||||
|
<SpeakerEditor
|
||||||
|
meetingId={meetingId}
|
||||||
|
speakerId={item.speakerId}
|
||||||
|
initialName={item.speakerName}
|
||||||
|
initialLabel={item.speakerLabel}
|
||||||
|
onSuccess={onSpeakerUpdated}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
title="编辑发言人"
|
||||||
|
trigger="click"
|
||||||
|
>
|
||||||
|
<span className="transcript-speaker editable" onClick={(event) => event.stopPropagation()}>
|
||||||
|
{item.speakerName || item.speakerId || '发言人'} <EditOutlined style={{ fontSize: 12 }} />
|
||||||
|
</span>
|
||||||
|
</Popover>
|
||||||
|
) : (
|
||||||
|
<span className="transcript-speaker">{item.speakerName || item.speakerId || '发言人'}</span>
|
||||||
|
)}
|
||||||
|
<Text type="secondary">{formatTime(item.startTime)}</Text>
|
||||||
|
{speakerTagLabel && <Tag color="blue">{speakerTagLabel}</Tag>}
|
||||||
|
</div>
|
||||||
|
{isEditing ? (
|
||||||
|
<div
|
||||||
|
className={`transcript-bubble transcript-bubble-editing ${isSaving ? 'is-saving' : ''}`}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
autoFocus
|
||||||
|
value={draftValue}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
onChange={(event) => setDraftValue(event.target.value)}
|
||||||
|
onKeyDown={(event) => onDraftKeyDown(item, draftValue, event)}
|
||||||
|
onBlur={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onDraftBlur(item, draftValue);
|
||||||
|
}}
|
||||||
|
autoSize={{ minRows: 1, maxRows: 8 }}
|
||||||
|
className="transcript-bubble-input"
|
||||||
|
bordered={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={`transcript-bubble ${isOwner ? 'editable' : ''}`}
|
||||||
|
onDoubleClick={isOwner ? (event) => onStartEdit(item, event) : undefined}
|
||||||
|
>
|
||||||
|
{item.content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</List.Item>
|
||||||
|
);
|
||||||
|
}, (prevProps, nextProps) => (
|
||||||
|
prevProps.item === nextProps.item
|
||||||
|
&& prevProps.meetingId === nextProps.meetingId
|
||||||
|
&& prevProps.isOwner === nextProps.isOwner
|
||||||
|
&& prevProps.isEditing === nextProps.isEditing
|
||||||
|
&& prevProps.isSaving === nextProps.isSaving
|
||||||
|
&& prevProps.speakerLabelMap === nextProps.speakerLabelMap
|
||||||
|
&& prevProps.onSeek === nextProps.onSeek
|
||||||
|
&& prevProps.onStartEdit === nextProps.onStartEdit
|
||||||
|
&& prevProps.onDraftBlur === nextProps.onDraftBlur
|
||||||
|
&& prevProps.onDraftKeyDown === nextProps.onDraftKeyDown
|
||||||
|
&& prevProps.onSpeakerUpdated === nextProps.onSpeakerUpdated
|
||||||
|
));
|
||||||
|
|
||||||
const MeetingDetail: React.FC = () => {
|
const MeetingDetail: React.FC = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -434,7 +546,6 @@ const MeetingDetail: React.FC = () => {
|
||||||
const [selectedKeywords, setSelectedKeywords] = useState<string[]>([]);
|
const [selectedKeywords, setSelectedKeywords] = useState<string[]>([]);
|
||||||
const [addingHotwords, setAddingHotwords] = useState(false);
|
const [addingHotwords, setAddingHotwords] = useState(false);
|
||||||
const [editingTranscriptId, setEditingTranscriptId] = useState<number | null>(null);
|
const [editingTranscriptId, setEditingTranscriptId] = useState<number | null>(null);
|
||||||
const [transcriptDraft, setTranscriptDraft] = useState('');
|
|
||||||
const [savingTranscriptId, setSavingTranscriptId] = useState<number | null>(null);
|
const [savingTranscriptId, setSavingTranscriptId] = useState<number | null>(null);
|
||||||
const [audioCurrentTime, setAudioCurrentTime] = useState(0);
|
const [audioCurrentTime, setAudioCurrentTime] = useState(0);
|
||||||
const [audioDuration, setAudioDuration] = useState(0);
|
const [audioDuration, setAudioDuration] = useState(0);
|
||||||
|
|
@ -461,6 +572,10 @@ const MeetingDetail: React.FC = () => {
|
||||||
analysis.todos.length
|
analysis.todos.length
|
||||||
);
|
);
|
||||||
const visibleKeywords = expandKeywords ? analysis.keywords : analysis.keywords.slice(0, 9);
|
const visibleKeywords = expandKeywords ? analysis.keywords : analysis.keywords.slice(0, 9);
|
||||||
|
const speakerLabelMap = useMemo(
|
||||||
|
() => new Map(speakerLabels.map((item) => [item.itemValue, item.itemLabel])),
|
||||||
|
[speakerLabels],
|
||||||
|
);
|
||||||
|
|
||||||
const isOwner = useMemo(() => {
|
const isOwner = useMemo(() => {
|
||||||
if (!meeting) return false;
|
if (!meeting) return false;
|
||||||
|
|
@ -516,7 +631,7 @@ const MeetingDetail: React.FC = () => {
|
||||||
};
|
};
|
||||||
}, [meeting?.audioUrl, audioPlaybackRate]);
|
}, [meeting?.audioUrl, audioPlaybackRate]);
|
||||||
|
|
||||||
const fetchData = async (meetingId: number) => {
|
const fetchData = useCallback(async (meetingId: number) => {
|
||||||
try {
|
try {
|
||||||
const [detailRes, transcriptRes] = await Promise.all([getMeetingDetail(meetingId), getTranscripts(meetingId)]);
|
const [detailRes, transcriptRes] = await Promise.all([getMeetingDetail(meetingId), getTranscripts(meetingId)]);
|
||||||
setMeeting(detailRes.data.data);
|
setMeeting(detailRes.data.data);
|
||||||
|
|
@ -526,7 +641,7 @@ const MeetingDetail: React.FC = () => {
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const loadAiConfigs = async () => {
|
const loadAiConfigs = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -625,11 +740,10 @@ const MeetingDetail: React.FC = () => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/*
|
|
||||||
const handleAddSelectedHotwords = async () => {
|
const handleAddSelectedHotwords = async () => {
|
||||||
const keywords = selectedKeywords.map((item) => item.trim()).filter(Boolean);
|
const keywords = selectedKeywords.map((item) => item.trim()).filter(Boolean);
|
||||||
if (!keywords.length) {
|
if (!keywords.length) {
|
||||||
message.warning('请先勾选关键词');
|
message.warning('请先选择关键词');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -644,7 +758,7 @@ const MeetingDetail: React.FC = () => {
|
||||||
const toCreate = keywords.filter((item) => !existingWords.has(item));
|
const toCreate = keywords.filter((item) => !existingWords.has(item));
|
||||||
|
|
||||||
if (!toCreate.length) {
|
if (!toCreate.length) {
|
||||||
message.info('所选关键词已全部存在于热词库');
|
message.info('所选关键词已存在于热词库');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -658,106 +772,7 @@ const MeetingDetail: React.FC = () => {
|
||||||
weight: 2,
|
weight: 2,
|
||||||
status: 1,
|
status: 1,
|
||||||
isPublic: 0,
|
isPublic: 0,
|
||||||
remark: meeting ? `来源于会议《${meeting.title}》` : '来源于会议关键词',
|
remark: meeting ? `来源于会议:${meeting.title}` : '来源于会议关键词',
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const skippedCount = keywords.length - toCreate.length;
|
|
||||||
message.success(skippedCount > 0 ? `已新增 ${toCreate.length} 个热词,跳过 ${skippedCount} 个重复项` : `已新增 ${toCreate.length} 个热词`);
|
|
||||||
setSelectedKeywords([]);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
setAddingHotwords(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
};
|
|
||||||
*/
|
|
||||||
|
|
||||||
/*
|
|
||||||
const handleAddSelectedHotwords = async () => {
|
|
||||||
const keywords = selectedKeywords.map((item) => item.trim()).filter(Boolean);
|
|
||||||
if (!keywords.length) {
|
|
||||||
message.warning('请先勾选关键词');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setAddingHotwords(true);
|
|
||||||
try {
|
|
||||||
const existingRes = await getHotWordPage({ current: 1, size: 500, word: '' });
|
|
||||||
const existingWords = new Set(
|
|
||||||
(existingRes.data?.data?.records || [])
|
|
||||||
.map((item) => item.word?.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
);
|
|
||||||
const toCreate = keywords.filter((item) => !existingWords.has(item));
|
|
||||||
|
|
||||||
if (!toCreate.length) {
|
|
||||||
message.info('所选关键词已全部存在于热词库');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
toCreate.map((word) =>
|
|
||||||
saveHotWord({
|
|
||||||
word,
|
|
||||||
pinyinList: [],
|
|
||||||
matchStrategy: 1,
|
|
||||||
category: '',
|
|
||||||
weight: 2,
|
|
||||||
status: 1,
|
|
||||||
isPublic: 0,
|
|
||||||
remark: meeting ? `来源于会议《${meeting.title}》` : '来源于会议关键词',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const skippedCount = keywords.length - toCreate.length;
|
|
||||||
message.success(skippedCount > 0 ? `已新增 ${toCreate.length} 个热词,跳过 ${skippedCount} 个重复项` : `已新增 ${toCreate.length} 个热词`);
|
|
||||||
setSelectedKeywords([]);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
setAddingHotwords(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
*/
|
|
||||||
|
|
||||||
const handleAddSelectedHotwords = async () => {
|
|
||||||
const keywords = selectedKeywords.map((item) => item.trim()).filter(Boolean);
|
|
||||||
if (!keywords.length) {
|
|
||||||
message.warning('Please select keywords first');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setAddingHotwords(true);
|
|
||||||
try {
|
|
||||||
const existingRes = await getHotWordPage({ current: 1, size: 500, word: '' });
|
|
||||||
const existingWords = new Set(
|
|
||||||
(existingRes.data?.data?.records || [])
|
|
||||||
.map((item) => item.word?.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
);
|
|
||||||
const toCreate = keywords.filter((item) => !existingWords.has(item));
|
|
||||||
|
|
||||||
if (!toCreate.length) {
|
|
||||||
message.info('Selected keywords already exist in hot words');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
toCreate.map((word) =>
|
|
||||||
saveHotWord({
|
|
||||||
word,
|
|
||||||
pinyinList: [],
|
|
||||||
matchStrategy: 1,
|
|
||||||
category: '',
|
|
||||||
weight: 2,
|
|
||||||
status: 1,
|
|
||||||
isPublic: 0,
|
|
||||||
remark: meeting ? `From meeting: ${meeting.title}` : 'From meeting keywords',
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -765,8 +780,8 @@ const MeetingDetail: React.FC = () => {
|
||||||
const skippedCount = keywords.length - toCreate.length;
|
const skippedCount = keywords.length - toCreate.length;
|
||||||
message.success(
|
message.success(
|
||||||
skippedCount > 0
|
skippedCount > 0
|
||||||
? `Added ${toCreate.length} hot words, skipped ${skippedCount} duplicates`
|
? `已新增 ${toCreate.length} 个热词,跳过 ${skippedCount} 个重复项`
|
||||||
: `Added ${toCreate.length} hot words`,
|
: `已新增 ${toCreate.length} 个热词`,
|
||||||
);
|
);
|
||||||
setSelectedKeywords([]);
|
setSelectedKeywords([]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -776,20 +791,18 @@ const MeetingDetail: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStartEditTranscript = (item: MeetingTranscriptVO, event: React.MouseEvent) => {
|
const handleStartEditTranscript = useCallback((item: MeetingTranscriptVO, event: React.MouseEvent) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
setEditingTranscriptId(item.id);
|
setEditingTranscriptId(item.id);
|
||||||
setTranscriptDraft(item.content || '');
|
}, []);
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancelEditTranscript = (event?: React.SyntheticEvent) => {
|
const handleCancelEditTranscript = useCallback((event?: React.SyntheticEvent) => {
|
||||||
event?.stopPropagation();
|
event?.stopPropagation();
|
||||||
setEditingTranscriptId(null);
|
setEditingTranscriptId(null);
|
||||||
setTranscriptDraft('');
|
}, []);
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveTranscript = async (item: MeetingTranscriptVO, nextContent?: string) => {
|
const handleSaveTranscript = useCallback(async (item: MeetingTranscriptVO, nextContent?: string) => {
|
||||||
const content = (nextContent ?? transcriptDraft).trim();
|
const content = (nextContent ?? item.content ?? '').trim();
|
||||||
if (!content) {
|
if (!content) {
|
||||||
message.warning('转录内容不能为空');
|
message.warning('转录内容不能为空');
|
||||||
return;
|
return;
|
||||||
|
|
@ -816,24 +829,33 @@ const MeetingDetail: React.FC = () => {
|
||||||
} finally {
|
} finally {
|
||||||
setSavingTranscriptId(null);
|
setSavingTranscriptId(null);
|
||||||
}
|
}
|
||||||
};
|
}, [fetchData, handleCancelEditTranscript, meeting]);
|
||||||
|
|
||||||
const handleTranscriptDraftKeyDown = (item: MeetingTranscriptVO, event: React.KeyboardEvent<HTMLDivElement>) => {
|
const handleTranscriptDraftKeyDown = useCallback((item: MeetingTranscriptVO, value: string, event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
handleCancelEditTranscript();
|
handleCancelEditTranscript();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
|
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void handleSaveTranscript(item);
|
void handleSaveTranscript(item, value);
|
||||||
}
|
}
|
||||||
};
|
}, [handleCancelEditTranscript, handleSaveTranscript]);
|
||||||
|
|
||||||
const seekTo = (timeMs: number) => {
|
const handleTranscriptDraftBlur = useCallback((item: MeetingTranscriptVO, value: string) => {
|
||||||
|
void handleSaveTranscript(item, value);
|
||||||
|
}, [handleSaveTranscript]);
|
||||||
|
|
||||||
|
const handleTranscriptSpeakerUpdated = useCallback(() => {
|
||||||
|
if (!meeting) return;
|
||||||
|
void fetchData(meeting.id);
|
||||||
|
}, [fetchData, meeting]);
|
||||||
|
|
||||||
|
const seekTo = useCallback((timeMs: number) => {
|
||||||
if (!audioRef.current) return;
|
if (!audioRef.current) return;
|
||||||
audioRef.current.currentTime = timeMs / 1000;
|
audioRef.current.currentTime = timeMs / 1000;
|
||||||
audioRef.current.play();
|
audioRef.current.play();
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const toggleAudioPlayback = () => {
|
const toggleAudioPlayback = () => {
|
||||||
if (!audioRef.current) return;
|
if (!audioRef.current) return;
|
||||||
|
|
@ -1022,7 +1044,7 @@ const MeetingDetail: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="summary-actions">
|
<div className="summary-actions">
|
||||||
<span className={`status-pill ${hasAnalysis ? 'success' : 'warning'}`}>{hasAnalysis ? '已生成' : '待生成'}</span>
|
<span className={`status-pill ${hasAnalysis ? 'success' : 'warning'}`}>{hasAnalysis ? '已生成' : '待生成'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1113,8 +1135,8 @@ const MeetingDetail: React.FC = () => {
|
||||||
analysis.speakerSummaries.map((item, index) => (
|
analysis.speakerSummaries.map((item, index) => (
|
||||||
<div className="speaker-summary-card" key={`${item.speaker}-${index}`}>
|
<div className="speaker-summary-card" key={`${item.speaker}-${index}`}>
|
||||||
<div className="speaker-summary-side">
|
<div className="speaker-summary-side">
|
||||||
<div className="speaker-avatar">{(item.speaker || '发').slice(0, 1)}</div>
|
<div className="speaker-avatar">{(item.speaker || '发').slice(0, 1)}</div>
|
||||||
<div className="speaker-summary-name">{item.speaker || `发言人 ${index + 1}`}</div>
|
<div className="speaker-summary-name">{item.speaker || `发言人${index + 1}`}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="speaker-summary-body">
|
<div className="speaker-summary-body">
|
||||||
<div className="speaker-summary-meta">发言概述</div>
|
<div className="speaker-summary-meta">发言概述</div>
|
||||||
|
|
@ -1172,7 +1194,7 @@ const MeetingDetail: React.FC = () => {
|
||||||
<div className="section-divider-note">
|
<div className="section-divider-note">
|
||||||
<div className="section-divider-line" />
|
<div className="section-divider-line" />
|
||||||
<div className="section-divider-text">
|
<div className="section-divider-text">
|
||||||
智能内容由 AI 模型生成,我们不对内容准确性和完整性做任何保证,亦不代表我们的观点或态度
|
智能内容由 AI 模型生成,我们不对内容准确性和完整性作任何保证,也不代表我们的观点或态度
|
||||||
</div>
|
</div>
|
||||||
<div className="section-divider-line" />
|
<div className="section-divider-line" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1182,85 +1204,19 @@ const MeetingDetail: React.FC = () => {
|
||||||
<List
|
<List
|
||||||
dataSource={transcripts}
|
dataSource={transcripts}
|
||||||
renderItem={(item) => (
|
renderItem={(item) => (
|
||||||
<List.Item className="transcript-row" onClick={() => seekTo(item.startTime)}>
|
<ActiveTranscriptRow
|
||||||
<div className="transcript-time">{formatTime(item.startTime)}</div>
|
item={item}
|
||||||
<div className="transcript-entry">
|
meetingId={meeting.id}
|
||||||
<div className="transcript-meta">
|
isOwner={isOwner}
|
||||||
<Avatar icon={<UserOutlined />} className="transcript-avatar" />
|
isEditing={editingTranscriptId === item.id}
|
||||||
{isOwner ? (
|
isSaving={savingTranscriptId === item.id}
|
||||||
<Popover
|
speakerLabelMap={speakerLabelMap}
|
||||||
content={
|
onSeek={seekTo}
|
||||||
<SpeakerEditor
|
onStartEdit={handleStartEditTranscript}
|
||||||
meetingId={meeting.id}
|
onDraftBlur={handleTranscriptDraftBlur}
|
||||||
speakerId={item.speakerId}
|
onDraftKeyDown={handleTranscriptDraftKeyDown}
|
||||||
initialName={item.speakerName}
|
onSpeakerUpdated={handleTranscriptSpeakerUpdated}
|
||||||
initialLabel={item.speakerLabel}
|
/>
|
||||||
onSuccess={() => fetchData(meeting.id)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
title="编辑发言人"
|
|
||||||
trigger="click"
|
|
||||||
>
|
|
||||||
<span className="transcript-speaker editable" onClick={(event) => event.stopPropagation()}>
|
|
||||||
{item.speakerName || item.speakerId || '发言人'} <EditOutlined style={{ fontSize: 12 }} />
|
|
||||||
</span>
|
|
||||||
</Popover>
|
|
||||||
) : (
|
|
||||||
<span className="transcript-speaker">{item.speakerName || item.speakerId || '发言人'}</span>
|
|
||||||
)}
|
|
||||||
<Text type="secondary">{formatTime(item.startTime)}</Text>
|
|
||||||
{item.speakerLabel && (
|
|
||||||
<Tag color="blue">
|
|
||||||
{speakerLabels.find((label) => label.itemValue === item.speakerLabel)?.itemLabel || item.speakerLabel}
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{editingTranscriptId === item.id ? (
|
|
||||||
<div
|
|
||||||
className="transcript-bubble transcript-bubble-editing"
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Input.TextArea
|
|
||||||
autoFocus
|
|
||||||
value={transcriptDraft}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
onChange={(event) => setTranscriptDraft(event.target.value)}
|
|
||||||
onKeyDown={(event) => handleTranscriptDraftKeyDown(item, event as unknown as React.KeyboardEvent<HTMLDivElement>)}
|
|
||||||
onBlur={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
void handleSaveTranscript(item, event.target.value);
|
|
||||||
}}
|
|
||||||
autoSize={{ minRows: 1, maxRows: 8 }}
|
|
||||||
className="transcript-bubble-input"
|
|
||||||
bordered={false}
|
|
||||||
/>
|
|
||||||
{false && false && <div className="transcript-bubble-actions">
|
|
||||||
<Text type="secondary" className="transcript-bubble-hint">
|
|
||||||
Ctrl+Enter 保存,Esc 取消
|
|
||||||
</Text>
|
|
||||||
<Button size="small" onClick={handleCancelEditTranscript}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
type="primary"
|
|
||||||
loading={savingTranscriptId === item.id}
|
|
||||||
onClick={() => handleSaveTranscript(item)}
|
|
||||||
>
|
|
||||||
保存
|
|
||||||
</Button>
|
|
||||||
</div>}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className={`transcript-bubble ${isOwner ? 'editable' : ''}`}
|
|
||||||
onDoubleClick={isOwner ? (event) => handleStartEditTranscript(item, event) : undefined}
|
|
||||||
>
|
|
||||||
{item.content}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</List.Item>
|
|
||||||
)}
|
)}
|
||||||
locale={{ emptyText: meeting.status < 3 ? '识别任务进行中...' : '暂无数据' }}
|
locale={{ emptyText: meeting.status < 3 ? '识别任务进行中...' : '暂无数据' }}
|
||||||
/>
|
/>
|
||||||
|
|
@ -2026,7 +1982,7 @@ const MeetingDetail: React.FC = () => {
|
||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Text type="secondary">重新总结会基于当前语音转写全文重新生成纪要,原有总结内容将被覆盖。</Text>
|
<Text type="secondary">重新总结会基于当前语音转录全文重新生成纪要,原有总结内容将被覆盖。</Text>
|
||||||
</Form>
|
</Form>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
)}
|
)}
|
||||||
|
|
@ -2035,3 +1991,4 @@ const MeetingDetail: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MeetingDetail;
|
export default MeetingDetail;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue