diff --git a/frontend/src/components/ThemeSelector/ThemeSelector.tsx b/frontend/src/components/ThemeSelector/ThemeSelector.tsx index 85aed9a..da7634b 100644 --- a/frontend/src/components/ThemeSelector/ThemeSelector.tsx +++ b/frontend/src/components/ThemeSelector/ThemeSelector.tsx @@ -23,7 +23,7 @@ export default function ThemeSelector() { ]; return ( - <> +
setOpen(true)}> @@ -75,7 +75,7 @@ export default function ThemeSelector() {
- + ); } diff --git a/frontend/src/pages/business/MeetingDetail.tsx b/frontend/src/pages/business/MeetingDetail.tsx index 58ca265..eaaeac6 100644 --- a/frontend/src/pages/business/MeetingDetail.tsx +++ b/frontend/src/pages/business/MeetingDetail.tsx @@ -317,6 +317,70 @@ function formatPlayerTime(seconds: number) { return `${minutes.toString().padStart(2, '0')}:${remainSeconds.toString().padStart(2, '0')}`; } +/** + * 给 Markdown 文本中的关键词添加虚拟超链接 + */ +const linkifySummary = (content: string, keywords: string[]) => { + if (!content || !keywords.length) return content; + + // 按长度降序排列关键词,防止短词匹配长词的一部分 + const sortedKeywords = [...keywords].sort((a, b) => b.length - a.length); + const keywordPattern = sortedKeywords.map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'); + + // 这种正则替换需要非常小心,不要破坏已有的 Markdown 结构(链接、代码块等) + // 这里的策略是:先按代码块/链接分割,只在纯文本部分进行替换 + const parts = content.split(/(```[\s\S]*?```|`[^`]*`|\[[^\]]*\]\([^)]*\))/g); + + return parts.map(part => { + // 如果是代码块或已有链接,不处理 + if (part.startsWith('```') || part.startsWith('`') || part.startsWith('[')) { + return part; + } + // 在普通文本中查找并替换关键词 + return part.replace(new RegExp(`(${keywordPattern})`, 'g'), '[$1](#/keyword/$1)'); + }).join(''); +}; + +const MarkdownSummary: React.FC<{ + content: string; + keywords: string[]; + onKeywordClick: (keyword: string) => void; +}> = ({ content, keywords, onKeywordClick }) => { + const processedContent = useMemo(() => linkifySummary(content, keywords), [content, keywords]); + + return ( + { + // 检查是否是关键词链接(支持 URL 编码后的格式) + const isKeywordLink = href?.startsWith('#/keyword/') || (href && decodeURIComponent(href).startsWith('#/keyword/')); + + if (isKeywordLink) { + const decodedHref = decodeURIComponent(href!); + const keyword = decodedHref.replace('#/keyword/', ''); + return ( + { + e.preventDefault(); + e.stopPropagation(); + onKeywordClick(keyword); + }} + > + {children} + + ); + } + return {children}; + } + }} + > + {processedContent} + + ); +}; + const MeetingProgressDisplay: React.FC<{ meetingId: number; onComplete: () => void; @@ -513,6 +577,23 @@ const SpeakerEditor: React.FC<{ ); }; +const renderContentWithHighlight = (content: string, keyword: string) => { + if (!keyword || !content.toLowerCase().includes(keyword.toLowerCase())) { + return content; + } + + const parts = content.split(new RegExp(`(${keyword})`, 'gi')); + return ( + <> + {parts.map((part, i) => ( + part.toLowerCase() === keyword.toLowerCase() ? ( + {part} + ) : part + ))} + + ); +}; + type ActiveTranscriptRowProps = { item: MeetingTranscriptVO; meetingId: number; @@ -527,6 +608,7 @@ type ActiveTranscriptRowProps = { onSpeakerUpdated: () => void; isActive: boolean; audioPlaying: boolean; + highlightKeyword?: string; }; const ActiveTranscriptRow = React.memo(({ @@ -543,6 +625,7 @@ const ActiveTranscriptRow = React.memo(({ onSpeakerUpdated, isActive, audioPlaying, + highlightKeyword = '', }) => { const [draftValue, setDraftValue] = useState(item.content); const rowRef = useRef(null); @@ -554,10 +637,12 @@ const ActiveTranscriptRow = React.memo(({ }, [isEditing, item.content]); useEffect(() => { - if (isActive && audioPlaying && rowRef.current) { - rowRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }); + if ((isActive && audioPlaying) || (highlightKeyword && item.content.toLowerCase().includes(highlightKeyword.toLowerCase()))) { + if (rowRef.current) { + rowRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } } - }, [isActive, audioPlaying]); + }, [isActive, audioPlaying, highlightKeyword, item.content]); const speakerTagLabel = item.speakerLabel ? (speakerLabelMap.get(item.speakerLabel) || item.speakerLabel) : ''; @@ -616,7 +701,7 @@ const ActiveTranscriptRow = React.memo(({ className={`transcript-bubble ${isOwner ? 'editable' : ''}`} onDoubleClick={isOwner ? (event) => onStartEdit(item, event) : undefined} > - {item.content} + {renderContentWithHighlight(item.content, highlightKeyword)} )} @@ -637,8 +722,10 @@ const ActiveTranscriptRow = React.memo(({ && prevProps.onSpeakerUpdated === nextProps.onSpeakerUpdated && prevProps.isActive === nextProps.isActive && prevProps.audioPlaying === nextProps.audioPlaying + && prevProps.highlightKeyword === nextProps.highlightKeyword )); + const MeetingDetail: React.FC = () => { const { message } = App.useApp(); const { id } = useParams<{ id: string }>(); @@ -662,10 +749,6 @@ const MeetingDetail: React.FC = () => { const [addingHotwords, setAddingHotwords] = useState(false); const [editingTranscriptId, setEditingTranscriptId] = useState(null); const [savingTranscriptId, setSavingTranscriptId] = useState(null); - const [audioCurrentTime, setAudioCurrentTime] = useState(0); - const [audioDuration, setAudioDuration] = useState(0); - const [audioPlaying, setAudioPlaying] = useState(false); - const [audioPlaybackRate, setAudioPlaybackRate] = useState(1); const [llmModels, setLlmModels] = useState([]); const [prompts, setPrompts] = useState([]); const [, setUserList] = useState([]); @@ -674,11 +757,18 @@ const MeetingDetail: React.FC = () => { const [shareSaving, setShareSaving] = useState(false); const [sharePasswordEnabled, setSharePasswordEnabled] = useState(false); const [sharePasswordDraft, setSharePasswordDraft] = useState(''); + const [highlightKeyword, setHighlightKeyword] = useState(''); + const [highlightTimestamp, setHighlightTimestamp] = useState(null); + + const audioRef = useRef(null); + const [audioCurrentTime, setAudioCurrentTime] = useState(0); + const [audioDuration, setAudioDuration] = useState(0); + const [audioPlaying, setAudioPlaying] = useState(false); + const [audioPlaybackRate, setAudioPlaybackRate] = useState(1); + const emptyTranscriptNoticeShownRef = useRef(null); const audioPlaybackErrorShownRef = useRef(null); - - const audioRef = useRef(null); const summaryPdfRef = useRef(null); const transcriptItemRefs = useRef>({}); const leftColumnRef = useRef(null); @@ -967,6 +1057,27 @@ const MeetingDetail: React.FC = () => { setSummaryVisible(true); }; + const seekTo = useCallback((timeMs: number) => { + if (!audioRef.current) return; + audioRef.current.currentTime = timeMs / 1000; + audioRef.current.play(); + }, []); + + const handleKeywordClick = useCallback((keyword: string) => { + const firstMatch = transcripts.find((item) => + item.content.toLowerCase().includes(keyword.toLowerCase()) + ); + + if (firstMatch) { + setHighlightKeyword(keyword); + setHighlightTimestamp(firstMatch.startTime); + seekTo(firstMatch.startTime); + message.info(`已跳转至关键词 "${keyword}" 所在位置`); + } else { + message.warning(`在转录原文中未找到关键词 "${keyword}"`); + } + }, [transcripts, seekTo, message]); + const handleRetryTranscription = async () => { setActionLoading(true); try { @@ -1108,12 +1219,6 @@ const MeetingDetail: React.FC = () => { void fetchData(meeting.id); }, [fetchData, meeting]); - const seekTo = useCallback((timeMs: number) => { - if (!audioRef.current) return; - audioRef.current.currentTime = timeMs / 1000; - audioRef.current.play(); - }, []); - const handleAudioPlaybackError = useCallback(() => { const currentAudioUrl = playbackAudioUrl || ''; if (!currentAudioUrl || audioPlaybackErrorShownRef.current === currentAudioUrl) { @@ -1631,28 +1736,32 @@ const MeetingDetail: React.FC = () => { ) : meeting.summaryContent ? (
- {meeting.summaryContent} +
) : false ? ( <>
-
会议概述
- {analysis.overview ? ( - <> -
220 ? 'summary-copy summary-fade' : 'summary-copy'}> - {analysis.overview} -
- {analysis.overview.length > 220 && ( - - )} - - ) : ( - 暂无概述 - )} -
+
会议概述
+ {analysis.overview ? ( +
+
220 ? 'summary-copy summary-fade' : 'summary-copy'}> + {analysis.overview} +
+ {analysis.overview.length > 220 && ( + + )} +
+ ) : ( + 暂无概述 + )} +
主要讨论点
@@ -1789,11 +1898,18 @@ const MeetingDetail: React.FC = () => { {(analysis.keywords.length ? visibleKeywords : meetingTags).length ? ( (analysis.keywords.length ? visibleKeywords : meetingTags).map((tag) => { const isSelected = selectedKeywords.includes(tag); + const isHighlighted = highlightKeyword === tag; return (
isOwner && analysis.keywords.length && handleKeywordToggle(tag, !isSelected)} + className={`tag selectable-tag ${isSelected ? 'selected' : ''} ${isHighlighted ? 'highlighted-tag' : ''}`} + onClick={() => { + if (isOwner && analysis.keywords.length) { + handleKeywordToggle(tag, !isSelected); + } + handleKeywordClick(tag); + }} + style={isHighlighted ? { borderColor: '#5f51ff', backgroundColor: 'rgba(95, 81, 255, 0.1)' } : {}} > #{tag} {isOwner && isSelected && } @@ -1868,11 +1984,13 @@ const MeetingDetail: React.FC = () => { onSpeakerUpdated={handleTranscriptSpeakerUpdated} isActive={isActive} audioPlaying={audioPlaying} + highlightKeyword={highlightKeyword} /> ); }} locale={{ emptyText: meeting.status < 3 ? '识别任务进行中...' : '暂无数据' }} /> +
@@ -1914,6 +2032,49 @@ const MeetingDetail: React.FC = () => { )}