feat: 添加关键词高亮和跳转功能

- 在 `MeetingDetail.tsx` 中添加 `linkifySummary` 和 `MarkdownSummary` 组件,支持关键词高亮和虚拟链接
- 更新 `ActiveTranscriptRow` 组件以支持关键词高亮和自动滚动到匹配项
- 增加 `handleKeywordClick` 回调函数,处理关键词点击事件并跳转到相应位置
- 优化样式,添加高亮文本的动画效果和样式调整
dev_na
chenhao 2026-05-06 10:09:07 +08:00
parent 6445d429f8
commit aed87e8ad3
2 changed files with 203 additions and 38 deletions

View File

@ -23,7 +23,7 @@ export default function ThemeSelector() {
];
return (
<>
<div className="theme-selector-container">
<Space align="center" style={{ cursor: 'pointer', padding: '0 8px' }} onClick={() => setOpen(true)}>
<FormatPainterOutlined style={{ fontSize: '18px', color: 'var(--app-text-main)' }} title={t("layout.theme", "Theme")} />
</Space>
@ -75,7 +75,7 @@ export default function ThemeSelector() {
</div>
</Space>
</Drawer>
</>
</div>
);
}

View File

@ -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 (
<ReactMarkdown
components={{
a: ({ href, children, ...props }) => {
// 检查是否是关键词链接(支持 URL 编码后的格式)
const isKeywordLink = href?.startsWith('#/keyword/') || (href && decodeURIComponent(href).startsWith('#/keyword/'));
if (isKeywordLink) {
const decodedHref = decodeURIComponent(href!);
const keyword = decodedHref.replace('#/keyword/', '');
return (
<span
className="summary-keyword-link"
role="link"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onKeywordClick(keyword);
}}
>
{children}
</span>
);
}
return <a href={href} target="_blank" rel="noopener noreferrer" {...props}>{children}</a>;
}
}}
>
{processedContent}
</ReactMarkdown>
);
};
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() ? (
<span key={i} className="highlight-text">{part}</span>
) : 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<ActiveTranscriptRowProps>(({
@ -543,6 +625,7 @@ const ActiveTranscriptRow = React.memo<ActiveTranscriptRowProps>(({
onSpeakerUpdated,
isActive,
audioPlaying,
highlightKeyword = '',
}) => {
const [draftValue, setDraftValue] = useState(item.content);
const rowRef = useRef<HTMLDivElement>(null);
@ -554,10 +637,12 @@ const ActiveTranscriptRow = React.memo<ActiveTranscriptRowProps>(({
}, [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<ActiveTranscriptRowProps>(({
className={`transcript-bubble ${isOwner ? 'editable' : ''}`}
onDoubleClick={isOwner ? (event) => onStartEdit(item, event) : undefined}
>
{item.content}
{renderContentWithHighlight(item.content, highlightKeyword)}
</div>
)}
</div>
@ -637,8 +722,10 @@ const ActiveTranscriptRow = React.memo<ActiveTranscriptRowProps>(({
&& 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<number | null>(null);
const [savingTranscriptId, setSavingTranscriptId] = useState<number | null>(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<AiModelVO[]>([]);
const [prompts, setPrompts] = useState<PromptTemplateVO[]>([]);
const [, setUserList] = useState<SysUser[]>([]);
@ -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<number | null>(null);
const audioRef = useRef<HTMLAudioElement>(null);
const [audioCurrentTime, setAudioCurrentTime] = useState(0);
const [audioDuration, setAudioDuration] = useState(0);
const [audioPlaying, setAudioPlaying] = useState(false);
const [audioPlaybackRate, setAudioPlaybackRate] = useState(1);
const emptyTranscriptNoticeShownRef = useRef<number | null>(null);
const audioPlaybackErrorShownRef = useRef<string | null>(null);
const audioRef = useRef<HTMLAudioElement>(null);
const summaryPdfRef = useRef<HTMLDivElement>(null);
const transcriptItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
const leftColumnRef = useRef<HTMLDivElement>(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 ? (
<div className="summary-markdown-panel">
<div className="markdown-body summary-markdown">
<ReactMarkdown>{meeting.summaryContent}</ReactMarkdown>
<MarkdownSummary
content={meeting.summaryContent}
keywords={analysis.keywords}
onKeywordClick={handleKeywordClick}
/>
</div>
</div>
) : false ? (
<>
<div className="brief-section">
<div className="brief-section-title"></div>
{analysis.overview ? (
<>
<div className={!expandSummary && analysis.overview.length > 220 ? 'summary-copy summary-fade' : 'summary-copy'}>
{analysis.overview}
</div>
{analysis.overview.length > 220 && (
<button type="button" className="summary-link" onClick={() => setExpandSummary((value) => !value)}>
{expandSummary ? '收起' : '展开全部'}
</button>
)}
</>
) : (
<Text type="secondary"></Text>
)}
</div>
<div className="brief-section-title"></div>
{analysis.overview ? (
<div className="summary-copy-wrap">
<div className={!expandSummary && analysis.overview.length > 220 ? 'summary-copy summary-fade' : 'summary-copy'}>
{analysis.overview}
</div>
{analysis.overview.length > 220 && (
<button type="button" className="summary-link" onClick={() => setExpandSummary((value) => !value)}>
{expandSummary ? '收起' : '展开全部'}
</button>
)}
</div>
) : (
<Text type="secondary"></Text>
)}
</div>
<div className="brief-section">
<div className="brief-section-title"></div>
@ -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 (
<div
key={tag}
className={`tag selectable-tag ${isSelected ? 'selected' : ''}`}
onClick={() => 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)' } : {}}
>
<span>#{tag}</span>
{isOwner && isSelected && <CheckCircleFilled style={{ fontSize: 12 }} />}
@ -1868,11 +1984,13 @@ const MeetingDetail: React.FC = () => {
onSpeakerUpdated={handleTranscriptSpeakerUpdated}
isActive={isActive}
audioPlaying={audioPlaying}
highlightKeyword={highlightKeyword}
/>
);
}}
locale={{ emptyText: meeting.status < 3 ? '识别任务进行中...' : '暂无数据' }}
/>
</div>
</Card>
</div>
@ -1914,6 +2032,49 @@ const MeetingDetail: React.FC = () => {
)}
<style>{`
@keyframes highlight-pulse {
0% { box-shadow: 0 0 0 rgba(95, 81, 255, 0); transform: scale(1); }
50% { box-shadow: 0 0 15px rgba(95, 81, 255, 0.3); transform: scale(1.02); }
100% { box-shadow: 0 0 0 rgba(95, 81, 255, 0); transform: scale(1); }
}
.highlight-text {
background: linear-gradient(120deg, rgba(95, 81, 255, 0.15) 0%, rgba(108, 140, 255, 0.1) 100%);
border-bottom: 2px solid #5f51ff;
border-radius: 3px;
padding: 1px 4px;
color: #4335eb;
font-weight: 600;
transition: all 0.3s ease;
display: inline-block;
line-height: 1.2;
margin: 0 1px;
cursor: pointer;
animation: highlight-pulse 2s infinite ease-in-out;
}
.highlight-text:hover {
background: rgba(95, 81, 255, 0.25);
transform: translateY(-1px);
}
/* 当转录行处于活动状态(紫色背景)时,调整高亮样式以保持可读性 */
.ant-list-item.transcript-row.active .highlight-text {
background: rgba(255, 255, 255, 0.2);
border-bottom-color: #fff;
color: #fff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
animation: none; /* 活动行内不需要脉冲,避免视觉混乱 */
}
.summary-keyword-link {
color: #5f51ff;
cursor: pointer;
font-weight: 500;
padding: 0 2px;
border-radius: 2px;
transition: all 0.2s;
}
.summary-keyword-link:hover {
background: rgba(95, 81, 255, 0.1);
text-decoration: underline;
}
.meeting-detail-page {
padding: 24px;
height: calc(100vh - 64px);
@ -3029,7 +3190,11 @@ const MeetingDetail: React.FC = () => {
/>
) : (
<div className="markdown-body summary-markdown">
<ReactMarkdown>{meeting.summaryContent}</ReactMarkdown>
<MarkdownSummary
content={meeting.summaryContent}
keywords={analysis.keywords}
onKeywordClick={handleKeywordClick}
/>
</div>
)
) : (