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) => (
),
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