import { useState, useEffect, useRef, useMemo, memo, FC } from 'react' import ReactMarkdown from 'react-markdown' import { Button } from '@/components/ui/button.tsx' import { Copy, Download, ArrowRight, Play, ExternalLink } from 'lucide-react' import { toast } from 'react-hot-toast' import Error from '@/components/Lottie/error.tsx' import Loading from '@/components/Lottie/Loading.tsx' import Idle from '@/components/Lottie/Idle.tsx' import StepBar from '@/pages/HomePage/components/StepBar.tsx' import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' import { atomDark as codeStyle } from 'react-syntax-highlighter/dist/esm/styles/prism' import Zoom from 'react-medium-image-zoom' import 'react-medium-image-zoom/dist/styles.css' import gfm from 'remark-gfm' import remarkMath from 'remark-math' import rehypeKatex from 'rehype-katex' import 'katex/dist/katex.min.css' import 'github-markdown-css/github-markdown-light.css' import { ScrollArea } from '@/components/ui/scroll-area.tsx' import { useTaskStore } from '@/store/taskStore' import { noteStyles } from '@/constant/note.ts' import { MarkdownHeader } from '@/pages/HomePage/components/MarkdownHeader.tsx' import TranscriptViewer from '@/pages/HomePage/components/transcriptViewer.tsx' import MarkmapEditor from '@/pages/HomePage/components/MarkmapComponent.tsx' import ChatPanel from '@/pages/HomePage/components/ChatPanel.tsx' import VideoBanner from '@/pages/HomePage/components/VideoBanner.tsx' interface VersionNote { ver_id: string content: string style: string model_name: string created_at?: string } interface MarkdownViewerProps { content: string | VersionNote[] status: 'idle' | 'loading' | 'success' | 'failed' } const steps = [ { label: '解析链接', key: 'PARSING' }, { label: '下载音频', key: 'DOWNLOADING' }, { label: '转写文字', key: 'TRANSCRIBING' }, { label: '总结内容', key: 'SUMMARIZING' }, { label: '保存完成', key: 'SUCCESS' }, ] const remarkPlugins = [gfm, remarkMath] const rehypePlugins = [rehypeKatex] /** * 构建 ReactMarkdown components 对象,baseURL 用于修正图片路径。 * 使用函数 + useMemo 避免每次渲染都创建新的函数实例。 */ function createMarkdownComponents(baseURL: string) { return { h1: ({ children, ...props }: any) => (

{children}

), h2: ({ children, ...props }: any) => (

{children}

), h3: ({ children, ...props }: any) => (

{children}

), h4: ({ children, ...props }: any) => (

{children}

), p: ({ children, ...props }: any) => (

{children}

), a: ({ href, children, ...props }: any) => { const isOriginLink = typeof children[0] === 'string' && (children[0] as string).startsWith('原片 @') if (isOriginLink) { const timeMatch = (children[0] as string).match(/原片 @ (\d{2}:\d{2})/) const timeText = timeMatch ? timeMatch[1] : '原片' return ( 原片({timeText}) ) } return ( {children} {href?.startsWith('http') && ( )} ) }, img: ({ node, ...props }: any) => { let src = props.src if (src.startsWith('/')) { src = baseURL + src } props.src = src return (
) }, strong: ({ children, ...props }: any) => ( {children} ), li: ({ children, ...props }: any) => { const rawText = String(children) const isFakeHeading = /^(\*\*.+\*\*)$/.test(rawText.trim()) if (isFakeHeading) { return (
{children}
) } return (
  • {children}
  • ) }, ul: ({ children, ...props }: any) => ( ), ol: ({ children, ...props }: any) => (
      {children}
    ), blockquote: ({ children, ...props }: any) => (
    {children}
    ), code: ({ inline, className, children, ...props }: any) => { const match = /language-(\w+)/.exec(className || '') const codeContent = String(children).replace(/\n$/, '') if (!inline && match) { return (
    {match[1].toUpperCase()}
    {codeContent}
    ) } return ( {children} ) }, table: ({ children, ...props }: any) => (
    {children}
    ), th: ({ children, ...props }: any) => ( {children} ), td: ({ children, ...props }: any) => ( {children} ), hr: ({ ...props }: any) => (
    ), } } const MarkdownViewer: FC = memo(({ status }) => { const [copied, setCopied] = useState(false) const [currentVerId, setCurrentVerId] = useState('') const [selectedContent, setSelectedContent] = useState('') const [modelName, setModelName] = useState('') const [style, setStyle] = useState('') const [createTime, setCreateTime] = useState('') // 确保baseURL没有尾部斜杠 const baseURL = (String(import.meta.env.VITE_API_BASE_URL || '').replace('/api','') || '').replace(/\/$/, '') const getCurrentTask = useTaskStore.getState().getCurrentTask const currentTask = useTaskStore(state => state.getCurrentTask()) const taskStatus = currentTask?.status || 'PENDING' const retryTask = useTaskStore.getState().retryTask const isMultiVersion = Array.isArray(currentTask?.markdown) const [showTranscribe, setShowTranscribe] = useState(false) const [showChat, setShowChat] = useState(false) const [viewMode, setViewMode] = useState<'map' | 'preview'>('preview') const svgRef = useRef(null) // 缓存 ReactMarkdown components,仅在 baseURL 变化时重建 const markdownComponents = useMemo(() => createMarkdownComponents(baseURL), [baseURL]) // 多版本内容处理 useEffect(() => { if (!currentTask) return if (!isMultiVersion) { setCurrentVerId('') // 清空旧版本 ID setModelName(currentTask.formData.model_name) setStyle(currentTask.formData.style) setCreateTime(currentTask.createdAt) setSelectedContent(currentTask?.markdown) } else { const latestVersion = [...currentTask.markdown].sort( (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime() )[0] if (latestVersion) { setCurrentVerId(latestVersion.ver_id) } } }, [currentTask?.id, taskStatus]) useEffect(() => { if (!currentTask || !isMultiVersion) return const currentVer = currentTask.markdown.find(v => v.ver_id === currentVerId) if (currentVer) { setModelName(currentVer.model_name) setStyle(currentVer.style) setCreateTime(currentVer.created_at || '') setSelectedContent(currentVer.content) } }, [currentVerId, currentTask?.id]) const handleCopy = async () => { try { await navigator.clipboard.writeText(selectedContent) setCopied(true) toast.success('已复制到剪贴板') setTimeout(() => setCopied(false), 2000) } catch (e) { toast.error('复制失败') } } const alertButton = { id: 'alert', title: '测试警告', content: '⚠️', onClick: () => alert('你点击了自定义按钮!'), } const exportButton = { id: 'export', title: '导出思维导图', content: '⤓', onClick: () => { const svgEl = svgRef.current if (!svgEl) return // 同上面的序列化逻辑 const serializer = new XMLSerializer() const source = serializer.serializeToString(svgEl) const blob = new Blob(['', source], { type: 'image/svg+xml;charset=utf-8', }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = 'mindmap.svg' a.click() URL.revokeObjectURL(url) }, } const handleDownload = () => { const task = getCurrentTask() const name = task?.audioMeta.title || 'note' const blob = new Blob([selectedContent], { type: 'text/markdown;charset=utf-8' }) const link = document.createElement('a') link.href = URL.createObjectURL(blob) link.download = `${name}.md` document.body.appendChild(link) link.click() document.body.removeChild(link) } if (status === 'loading') { return (

    正在生成笔记,请稍候…

    这可能需要几秒钟时间,取决于视频长度

    ) } if (status === 'idle') { return (

    输入视频链接并点击"生成笔记"

    支持哔哩哔哩、YouTube 、抖音等视频平台

    ) } if (status === 'failed' && !isMultiVersion) { return (

    笔记生成失败

    请检查后台或稍后再试

    ) } return (
    {viewMode === 'map' ? (
    {}} height="100%" // 根据需求可以设定百分比或固定高度 title={currentTask?.audioMeta?.title || '思维导图'} />
    ) : (
    {selectedContent && selectedContent !== 'loading' && selectedContent !== 'empty' ? ( <> {showChat === 'full' && currentTask ? (
    ) : ( <>
    {selectedContent.replace(/^>\s*来源链接:[^\n]*\n*/m, '')}
    {showTranscribe && (
    )} {/* 侧边问答模式:markdown + ChatPanel 各占一半 */} {showChat === 'half' && currentTask && (
    )} )} ) : (

    输入视频链接并点击"生成笔记"按钮

    支持哔哩哔哩、YouTube等视频网站

    )}
    )}
    ) }) MarkdownViewer.displayName = 'MarkdownViewer' export default MarkdownViewer