diff --git a/frontend/src/pages/business/MeetingDetail.tsx b/frontend/src/pages/business/MeetingDetail.tsx index 57bbb10..e16d680 100644 --- a/frontend/src/pages/business/MeetingDetail.tsx +++ b/frontend/src/pages/business/MeetingDetail.tsx @@ -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 { Avatar, @@ -100,6 +100,7 @@ const ANALYSIS_EMPTY: MeetingAnalysis = { todos: [], }; + const splitLines = (value?: string | null) => (value || '') .split(/\r?\n/) @@ -175,7 +176,7 @@ const parseOverviewSection = (markdown: string) => const parseKeywordsSection = (markdown: string, tags: string) => { const section = extractSection(markdown, ['关键词', '关键字', '标签']); const fromSection = parseBulletList(section) - .flatMap((line) => line.split(/[,、,]/)) + .flatMap((line) => line.split(/[,、]/)) .map((item) => item.trim()) .filter(Boolean); @@ -300,7 +301,7 @@ const MeetingProgressDisplay: React.FC<{ meetingId: number; onComplete: () => vo const isError = percent < 0; const formatEta = (seconds?: number) => { - if (!seconds || seconds <= 0) return '正在分析中'; + if (!seconds || seconds <= 0) return '计算中'; if (seconds < 60) return `${seconds} 秒`; const minutes = Math.floor(seconds / 60); const remainSeconds = seconds % 60; @@ -336,7 +337,7 @@ const MeetingProgressDisplay: React.FC<{ meetingId: number; onComplete: () => vo {progress?.message || '正在准备计算资源...'} - 分析过程中请稍候,你可以先处理其他工作。 + 分析进行中,请稍候,你可以先处理其他工作。 @@ -413,6 +414,117 @@ const SpeakerEditor: React.FC<{ ); }; +type ActiveTranscriptRowProps = { + item: MeetingTranscriptVO; + meetingId: number; + isOwner: boolean; + isEditing: boolean; + isSaving: boolean; + speakerLabelMap: Map; + 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) => void; + onSpeakerUpdated: () => void; +}; + +const ActiveTranscriptRow = React.memo(({ + 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 ( + onSeek(item.startTime)}> +
{formatTime(item.startTime)}
+
+
+ } className="transcript-avatar" /> + {isOwner ? ( + + )} + title="编辑发言人" + trigger="click" + > + event.stopPropagation()}> + {item.speakerName || item.speakerId || '发言人'} + + + ) : ( + {item.speakerName || item.speakerId || '发言人'} + )} + {formatTime(item.startTime)} + {speakerTagLabel && {speakerTagLabel}} +
+ {isEditing ? ( +
event.stopPropagation()} + > + 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} + /> +
+ ) : ( +
onStartEdit(item, event) : undefined} + > + {item.content} +
+ )} +
+
+ ); +}, (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 { id } = useParams<{ id: string }>(); const navigate = useNavigate(); @@ -434,7 +546,6 @@ const MeetingDetail: React.FC = () => { const [selectedKeywords, setSelectedKeywords] = useState([]); const [addingHotwords, setAddingHotwords] = useState(false); const [editingTranscriptId, setEditingTranscriptId] = useState(null); - const [transcriptDraft, setTranscriptDraft] = useState(''); const [savingTranscriptId, setSavingTranscriptId] = useState(null); const [audioCurrentTime, setAudioCurrentTime] = useState(0); const [audioDuration, setAudioDuration] = useState(0); @@ -461,6 +572,10 @@ const MeetingDetail: React.FC = () => { analysis.todos.length ); 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(() => { if (!meeting) return false; @@ -516,7 +631,7 @@ const MeetingDetail: React.FC = () => { }; }, [meeting?.audioUrl, audioPlaybackRate]); - const fetchData = async (meetingId: number) => { + const fetchData = useCallback(async (meetingId: number) => { try { const [detailRes, transcriptRes] = await Promise.all([getMeetingDetail(meetingId), getTranscripts(meetingId)]); setMeeting(detailRes.data.data); @@ -526,7 +641,7 @@ const MeetingDetail: React.FC = () => { } finally { setLoading(false); } - }; + }, []); const loadAiConfigs = async () => { try { @@ -625,11 +740,10 @@ const MeetingDetail: React.FC = () => { }); }; - /* const handleAddSelectedHotwords = async () => { const keywords = selectedKeywords.map((item) => item.trim()).filter(Boolean); if (!keywords.length) { - message.warning('请先勾选关键词'); + message.warning('请先选择关键词'); return; } @@ -644,7 +758,7 @@ const MeetingDetail: React.FC = () => { const toCreate = keywords.filter((item) => !existingWords.has(item)); if (!toCreate.length) { - message.info('所选关键词已全部存在于热词库'); + message.info('所选关键词已存在于热词库'); return; } @@ -658,106 +772,7 @@ const MeetingDetail: React.FC = () => { 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('请先勾选关键词'); - 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', + remark: meeting ? `来源于会议:${meeting.title}` : '来源于会议关键词', }), ), ); @@ -765,8 +780,8 @@ const MeetingDetail: React.FC = () => { const skippedCount = keywords.length - toCreate.length; message.success( skippedCount > 0 - ? `Added ${toCreate.length} hot words, skipped ${skippedCount} duplicates` - : `Added ${toCreate.length} hot words`, + ? `已新增 ${toCreate.length} 个热词,跳过 ${skippedCount} 个重复项` + : `已新增 ${toCreate.length} 个热词`, ); setSelectedKeywords([]); } 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(); setEditingTranscriptId(item.id); - setTranscriptDraft(item.content || ''); - }; + }, []); - const handleCancelEditTranscript = (event?: React.SyntheticEvent) => { + const handleCancelEditTranscript = useCallback((event?: React.SyntheticEvent) => { event?.stopPropagation(); setEditingTranscriptId(null); - setTranscriptDraft(''); - }; + }, []); - const handleSaveTranscript = async (item: MeetingTranscriptVO, nextContent?: string) => { - const content = (nextContent ?? transcriptDraft).trim(); + const handleSaveTranscript = useCallback(async (item: MeetingTranscriptVO, nextContent?: string) => { + const content = (nextContent ?? item.content ?? '').trim(); if (!content) { message.warning('转录内容不能为空'); return; @@ -816,24 +829,33 @@ const MeetingDetail: React.FC = () => { } finally { setSavingTranscriptId(null); } - }; + }, [fetchData, handleCancelEditTranscript, meeting]); - const handleTranscriptDraftKeyDown = (item: MeetingTranscriptVO, event: React.KeyboardEvent) => { + const handleTranscriptDraftKeyDown = useCallback((item: MeetingTranscriptVO, value: string, event: React.KeyboardEvent) => { if (event.key === 'Escape') { handleCancelEditTranscript(); return; } if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { 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; audioRef.current.currentTime = timeMs / 1000; audioRef.current.play(); - }; + }, []); const toggleAudioPlayback = () => { if (!audioRef.current) return; @@ -1022,7 +1044,7 @@ const MeetingDetail: React.FC = () => { )}
- {hasAnalysis ? '已生成' : '待生成'} + {hasAnalysis ? '已生成' : '待生成'}
@@ -1113,8 +1135,8 @@ const MeetingDetail: React.FC = () => { analysis.speakerSummaries.map((item, index) => (
-
{(item.speaker || '发').slice(0, 1)}
-
{item.speaker || `发言人 ${index + 1}`}
+
{(item.speaker || '发').slice(0, 1)}
+
{item.speaker || `发言人${index + 1}`}
发言概述
@@ -1172,7 +1194,7 @@ const MeetingDetail: React.FC = () => {
- 智能内容由 AI 模型生成,我们不对内容准确性和完整性做任何保证,亦不代表我们的观点或态度 + 智能内容由 AI 模型生成,我们不对内容准确性和完整性作任何保证,也不代表我们的观点或态度
@@ -1182,85 +1204,19 @@ const MeetingDetail: React.FC = () => { ( - seekTo(item.startTime)}> -
{formatTime(item.startTime)}
-
-
- } className="transcript-avatar" /> - {isOwner ? ( - fetchData(meeting.id)} - /> - } - title="编辑发言人" - trigger="click" - > - event.stopPropagation()}> - {item.speakerName || item.speakerId || '发言人'} - - - ) : ( - {item.speakerName || item.speakerId || '发言人'} - )} - {formatTime(item.startTime)} - {item.speakerLabel && ( - - {speakerLabels.find((label) => label.itemValue === item.speakerLabel)?.itemLabel || item.speakerLabel} - - )} -
- {editingTranscriptId === item.id ? ( -
event.stopPropagation()} - > - setTranscriptDraft(event.target.value)} - onKeyDown={(event) => handleTranscriptDraftKeyDown(item, event as unknown as React.KeyboardEvent)} - onBlur={(event) => { - event.stopPropagation(); - void handleSaveTranscript(item, event.target.value); - }} - autoSize={{ minRows: 1, maxRows: 8 }} - className="transcript-bubble-input" - bordered={false} - /> - {false && false &&
- - Ctrl+Enter 保存,Esc 取消 - - - -
} -
- ) : ( -
handleStartEditTranscript(item, event) : undefined} - > - {item.content} -
- )} -
-
+ )} locale={{ emptyText: meeting.status < 3 ? '识别任务进行中...' : '暂无数据' }} /> @@ -2026,7 +1982,7 @@ const MeetingDetail: React.FC = () => { - 重新总结会基于当前语音转写全文重新生成纪要,原有总结内容将被覆盖。 + 重新总结会基于当前语音转录全文重新生成纪要,原有总结内容将被覆盖。 )} @@ -2035,3 +1991,4 @@ const MeetingDetail: React.FC = () => { }; export default MeetingDetail; +