feat: 添加关键词高亮和跳转功能
- 在 `MeetingDetail.tsx` 中添加 `linkifySummary` 和 `MarkdownSummary` 组件,支持关键词高亮和虚拟链接 - 更新 `ActiveTranscriptRow` 组件以支持关键词高亮和自动滚动到匹配项 - 增加 `handleKeywordClick` 回调函数,处理关键词点击事件并跳转到相应位置 - 优化样式,添加高亮文本的动画效果和样式调整dev_na
parent
6445d429f8
commit
aed87e8ad3
|
|
@ -23,7 +23,7 @@ export default function ThemeSelector() {
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="theme-selector-container">
|
||||||
<Space align="center" style={{ cursor: 'pointer', padding: '0 8px' }} onClick={() => setOpen(true)}>
|
<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")} />
|
<FormatPainterOutlined style={{ fontSize: '18px', color: 'var(--app-text-main)' }} title={t("layout.theme", "Theme")} />
|
||||||
</Space>
|
</Space>
|
||||||
|
|
@ -75,7 +75,7 @@ export default function ThemeSelector() {
|
||||||
</div>
|
</div>
|
||||||
</Space>
|
</Space>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -317,6 +317,70 @@ function formatPlayerTime(seconds: number) {
|
||||||
return `${minutes.toString().padStart(2, '0')}:${remainSeconds.toString().padStart(2, '0')}`;
|
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<{
|
const MeetingProgressDisplay: React.FC<{
|
||||||
meetingId: number;
|
meetingId: number;
|
||||||
onComplete: () => void;
|
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 = {
|
type ActiveTranscriptRowProps = {
|
||||||
item: MeetingTranscriptVO;
|
item: MeetingTranscriptVO;
|
||||||
meetingId: number;
|
meetingId: number;
|
||||||
|
|
@ -527,6 +608,7 @@ type ActiveTranscriptRowProps = {
|
||||||
onSpeakerUpdated: () => void;
|
onSpeakerUpdated: () => void;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
audioPlaying: boolean;
|
audioPlaying: boolean;
|
||||||
|
highlightKeyword?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ActiveTranscriptRow = React.memo<ActiveTranscriptRowProps>(({
|
const ActiveTranscriptRow = React.memo<ActiveTranscriptRowProps>(({
|
||||||
|
|
@ -543,6 +625,7 @@ const ActiveTranscriptRow = React.memo<ActiveTranscriptRowProps>(({
|
||||||
onSpeakerUpdated,
|
onSpeakerUpdated,
|
||||||
isActive,
|
isActive,
|
||||||
audioPlaying,
|
audioPlaying,
|
||||||
|
highlightKeyword = '',
|
||||||
}) => {
|
}) => {
|
||||||
const [draftValue, setDraftValue] = useState(item.content);
|
const [draftValue, setDraftValue] = useState(item.content);
|
||||||
const rowRef = useRef<HTMLDivElement>(null);
|
const rowRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -554,10 +637,12 @@ const ActiveTranscriptRow = React.memo<ActiveTranscriptRowProps>(({
|
||||||
}, [isEditing, item.content]);
|
}, [isEditing, item.content]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isActive && audioPlaying && rowRef.current) {
|
if ((isActive && audioPlaying) || (highlightKeyword && item.content.toLowerCase().includes(highlightKeyword.toLowerCase()))) {
|
||||||
rowRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
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) : '';
|
const speakerTagLabel = item.speakerLabel ? (speakerLabelMap.get(item.speakerLabel) || item.speakerLabel) : '';
|
||||||
|
|
||||||
|
|
@ -616,7 +701,7 @@ const ActiveTranscriptRow = React.memo<ActiveTranscriptRowProps>(({
|
||||||
className={`transcript-bubble ${isOwner ? 'editable' : ''}`}
|
className={`transcript-bubble ${isOwner ? 'editable' : ''}`}
|
||||||
onDoubleClick={isOwner ? (event) => onStartEdit(item, event) : undefined}
|
onDoubleClick={isOwner ? (event) => onStartEdit(item, event) : undefined}
|
||||||
>
|
>
|
||||||
{item.content}
|
{renderContentWithHighlight(item.content, highlightKeyword)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -637,8 +722,10 @@ const ActiveTranscriptRow = React.memo<ActiveTranscriptRowProps>(({
|
||||||
&& prevProps.onSpeakerUpdated === nextProps.onSpeakerUpdated
|
&& prevProps.onSpeakerUpdated === nextProps.onSpeakerUpdated
|
||||||
&& prevProps.isActive === nextProps.isActive
|
&& prevProps.isActive === nextProps.isActive
|
||||||
&& prevProps.audioPlaying === nextProps.audioPlaying
|
&& prevProps.audioPlaying === nextProps.audioPlaying
|
||||||
|
&& prevProps.highlightKeyword === nextProps.highlightKeyword
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|
||||||
const MeetingDetail: React.FC = () => {
|
const MeetingDetail: React.FC = () => {
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
|
|
@ -662,10 +749,6 @@ const MeetingDetail: React.FC = () => {
|
||||||
const [addingHotwords, setAddingHotwords] = useState(false);
|
const [addingHotwords, setAddingHotwords] = useState(false);
|
||||||
const [editingTranscriptId, setEditingTranscriptId] = useState<number | null>(null);
|
const [editingTranscriptId, setEditingTranscriptId] = useState<number | null>(null);
|
||||||
const [savingTranscriptId, setSavingTranscriptId] = 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 [llmModels, setLlmModels] = useState<AiModelVO[]>([]);
|
||||||
const [prompts, setPrompts] = useState<PromptTemplateVO[]>([]);
|
const [prompts, setPrompts] = useState<PromptTemplateVO[]>([]);
|
||||||
const [, setUserList] = useState<SysUser[]>([]);
|
const [, setUserList] = useState<SysUser[]>([]);
|
||||||
|
|
@ -674,11 +757,18 @@ const MeetingDetail: React.FC = () => {
|
||||||
const [shareSaving, setShareSaving] = useState(false);
|
const [shareSaving, setShareSaving] = useState(false);
|
||||||
const [sharePasswordEnabled, setSharePasswordEnabled] = useState(false);
|
const [sharePasswordEnabled, setSharePasswordEnabled] = useState(false);
|
||||||
const [sharePasswordDraft, setSharePasswordDraft] = useState('');
|
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 emptyTranscriptNoticeShownRef = useRef<number | null>(null);
|
||||||
const audioPlaybackErrorShownRef = useRef<string | null>(null);
|
const audioPlaybackErrorShownRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
|
||||||
const summaryPdfRef = useRef<HTMLDivElement>(null);
|
const summaryPdfRef = useRef<HTMLDivElement>(null);
|
||||||
const transcriptItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
const transcriptItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
||||||
const leftColumnRef = useRef<HTMLDivElement>(null);
|
const leftColumnRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -967,6 +1057,27 @@ const MeetingDetail: React.FC = () => {
|
||||||
setSummaryVisible(true);
|
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 () => {
|
const handleRetryTranscription = async () => {
|
||||||
setActionLoading(true);
|
setActionLoading(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -1108,12 +1219,6 @@ const MeetingDetail: React.FC = () => {
|
||||||
void fetchData(meeting.id);
|
void fetchData(meeting.id);
|
||||||
}, [fetchData, meeting]);
|
}, [fetchData, meeting]);
|
||||||
|
|
||||||
const seekTo = useCallback((timeMs: number) => {
|
|
||||||
if (!audioRef.current) return;
|
|
||||||
audioRef.current.currentTime = timeMs / 1000;
|
|
||||||
audioRef.current.play();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAudioPlaybackError = useCallback(() => {
|
const handleAudioPlaybackError = useCallback(() => {
|
||||||
const currentAudioUrl = playbackAudioUrl || '';
|
const currentAudioUrl = playbackAudioUrl || '';
|
||||||
if (!currentAudioUrl || audioPlaybackErrorShownRef.current === currentAudioUrl) {
|
if (!currentAudioUrl || audioPlaybackErrorShownRef.current === currentAudioUrl) {
|
||||||
|
|
@ -1631,28 +1736,32 @@ const MeetingDetail: React.FC = () => {
|
||||||
) : meeting.summaryContent ? (
|
) : meeting.summaryContent ? (
|
||||||
<div className="summary-markdown-panel">
|
<div className="summary-markdown-panel">
|
||||||
<div className="markdown-body summary-markdown">
|
<div className="markdown-body summary-markdown">
|
||||||
<ReactMarkdown>{meeting.summaryContent}</ReactMarkdown>
|
<MarkdownSummary
|
||||||
|
content={meeting.summaryContent}
|
||||||
|
keywords={analysis.keywords}
|
||||||
|
onKeywordClick={handleKeywordClick}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : false ? (
|
) : false ? (
|
||||||
<>
|
<>
|
||||||
<div className="brief-section">
|
<div className="brief-section">
|
||||||
<div className="brief-section-title">会议概述</div>
|
<div className="brief-section-title">会议概述</div>
|
||||||
{analysis.overview ? (
|
{analysis.overview ? (
|
||||||
<>
|
<div className="summary-copy-wrap">
|
||||||
<div className={!expandSummary && analysis.overview.length > 220 ? 'summary-copy summary-fade' : 'summary-copy'}>
|
<div className={!expandSummary && analysis.overview.length > 220 ? 'summary-copy summary-fade' : 'summary-copy'}>
|
||||||
{analysis.overview}
|
{analysis.overview}
|
||||||
</div>
|
</div>
|
||||||
{analysis.overview.length > 220 && (
|
{analysis.overview.length > 220 && (
|
||||||
<button type="button" className="summary-link" onClick={() => setExpandSummary((value) => !value)}>
|
<button type="button" className="summary-link" onClick={() => setExpandSummary((value) => !value)}>
|
||||||
{expandSummary ? '收起' : '展开全部'}
|
{expandSummary ? '收起' : '展开全部'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Text type="secondary">暂无概述</Text>
|
<Text type="secondary">暂无概述</Text>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="brief-section">
|
<div className="brief-section">
|
||||||
<div className="brief-section-title">主要讨论点</div>
|
<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).length ? (
|
||||||
(analysis.keywords.length ? visibleKeywords : meetingTags).map((tag) => {
|
(analysis.keywords.length ? visibleKeywords : meetingTags).map((tag) => {
|
||||||
const isSelected = selectedKeywords.includes(tag);
|
const isSelected = selectedKeywords.includes(tag);
|
||||||
|
const isHighlighted = highlightKeyword === tag;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={tag}
|
key={tag}
|
||||||
className={`tag selectable-tag ${isSelected ? 'selected' : ''}`}
|
className={`tag selectable-tag ${isSelected ? 'selected' : ''} ${isHighlighted ? 'highlighted-tag' : ''}`}
|
||||||
onClick={() => isOwner && analysis.keywords.length && handleKeywordToggle(tag, !isSelected)}
|
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>
|
<span>#{tag}</span>
|
||||||
{isOwner && isSelected && <CheckCircleFilled style={{ fontSize: 12 }} />}
|
{isOwner && isSelected && <CheckCircleFilled style={{ fontSize: 12 }} />}
|
||||||
|
|
@ -1868,11 +1984,13 @@ const MeetingDetail: React.FC = () => {
|
||||||
onSpeakerUpdated={handleTranscriptSpeakerUpdated}
|
onSpeakerUpdated={handleTranscriptSpeakerUpdated}
|
||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
audioPlaying={audioPlaying}
|
audioPlaying={audioPlaying}
|
||||||
|
highlightKeyword={highlightKeyword}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
locale={{ emptyText: meeting.status < 3 ? '识别任务进行中...' : '暂无数据' }}
|
locale={{ emptyText: meeting.status < 3 ? '识别任务进行中...' : '暂无数据' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1914,6 +2032,49 @@ const MeetingDetail: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<style>{`
|
<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 {
|
.meeting-detail-page {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
height: calc(100vh - 64px);
|
height: calc(100vh - 64px);
|
||||||
|
|
@ -3029,7 +3190,11 @@ const MeetingDetail: React.FC = () => {
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="markdown-body summary-markdown">
|
<div className="markdown-body summary-markdown">
|
||||||
<ReactMarkdown>{meeting.summaryContent}</ReactMarkdown>
|
<MarkdownSummary
|
||||||
|
content={meeting.summaryContent}
|
||||||
|
keywords={analysis.keywords}
|
||||||
|
onKeywordClick={handleKeywordClick}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue