feat: 添加关键词高亮和跳转功能
- 在 `MeetingDetail.tsx` 中添加 `linkifySummary` 和 `MarkdownSummary` 组件,支持关键词高亮和虚拟链接 - 更新 `ActiveTranscriptRow` 组件以支持关键词高亮和自动滚动到匹配项 - 增加 `handleKeywordClick` 回调函数,处理关键词点击事件并跳转到相应位置 - 优化样式,添加高亮文本的动画效果和样式调整dev_na
parent
6445d429f8
commit
aed87e8ad3
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
) : (
|
||||
|
|
|
|||
Loading…
Reference in New Issue