feat: 添加会议转录编辑组件和优化关键词处理

- 在 `MeetingDetail` 页面中添加 `ActiveTranscriptRow` 组件,支持会议转录的编辑和保存
- 优化关键词处理逻辑,移除重复代码并
dev_na
chenhao 2026-03-27 14:33:32 +08:00
parent ffc19fa572
commit 60754bbd26
1 changed files with 167 additions and 210 deletions

View File

@ -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
<Text strong style={{ fontSize: 18, color: isError ? '#ff4d4f' : '#5d67ff', display: 'block', marginBottom: 8 }}>
{progress?.message || '正在准备计算资源...'}
</Text>
<Text type="secondary"></Text>
<Text type="secondary"></Text>
</div>
<Divider style={{ margin: '32px 0' }} />
<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 { id } = useParams<{ id: string }>();
const navigate = useNavigate();
@ -434,7 +546,6 @@ const MeetingDetail: React.FC = () => {
const [selectedKeywords, setSelectedKeywords] = useState<string[]>([]);
const [addingHotwords, setAddingHotwords] = useState(false);
const [editingTranscriptId, setEditingTranscriptId] = useState<number | null>(null);
const [transcriptDraft, setTranscriptDraft] = useState('');
const [savingTranscriptId, setSavingTranscriptId] = useState<number | null>(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<HTMLDivElement>) => {
const handleTranscriptDraftKeyDown = useCallback((item: MeetingTranscriptVO, value: string, event: React.KeyboardEvent<HTMLTextAreaElement>) => {
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 = () => {
)}
</div>
<div className="summary-actions">
<span className={`status-pill ${hasAnalysis ? 'success' : 'warning'}`}>{hasAnalysis ? '已生成' : '待生成'}</span>
<span className={`status-pill ${hasAnalysis ? 'success' : 'warning'}`}>{hasAnalysis ? '已生成' : '待生成'}</span>
</div>
</div>
@ -1113,8 +1135,8 @@ const MeetingDetail: React.FC = () => {
analysis.speakerSummaries.map((item, index) => (
<div className="speaker-summary-card" key={`${item.speaker}-${index}`}>
<div className="speaker-summary-side">
<div className="speaker-avatar">{(item.speaker || '发').slice(0, 1)}</div>
<div className="speaker-summary-name">{item.speaker || `发言人 ${index + 1}`}</div>
<div className="speaker-avatar">{(item.speaker || '发').slice(0, 1)}</div>
<div className="speaker-summary-name">{item.speaker || `发言人${index + 1}`}</div>
</div>
<div className="speaker-summary-body">
<div className="speaker-summary-meta"></div>
@ -1172,7 +1194,7 @@ const MeetingDetail: React.FC = () => {
<div className="section-divider-note">
<div className="section-divider-line" />
<div className="section-divider-text">
AI
AI
</div>
<div className="section-divider-line" />
</div>
@ -1182,85 +1204,19 @@ const MeetingDetail: React.FC = () => {
<List
dataSource={transcripts}
renderItem={(item) => (
<List.Item className="transcript-row" onClick={() => seekTo(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={meeting.id}
speakerId={item.speakerId}
initialName={item.speakerName}
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>
<ActiveTranscriptRow
item={item}
meetingId={meeting.id}
isOwner={isOwner}
isEditing={editingTranscriptId === item.id}
isSaving={savingTranscriptId === item.id}
speakerLabelMap={speakerLabelMap}
onSeek={seekTo}
onStartEdit={handleStartEditTranscript}
onDraftBlur={handleTranscriptDraftBlur}
onDraftKeyDown={handleTranscriptDraftKeyDown}
onSpeakerUpdated={handleTranscriptSpeakerUpdated}
/>
)}
locale={{ emptyText: meeting.status < 3 ? '识别任务进行中...' : '暂无数据' }}
/>
@ -2026,7 +1982,7 @@ const MeetingDetail: React.FC = () => {
</Select>
</Form.Item>
<Divider />
<Text type="secondary"></Text>
<Text type="secondary"></Text>
</Form>
</Drawer>
)}
@ -2035,3 +1991,4 @@ const MeetingDetail: React.FC = () => {
};
export default MeetingDetail;