码桶

发现社区成员的开源项目

奶狗 /

ng-webot

公开
main
ng-webot/web/src/components/DocsModal.tsx
DocsModal.tsx3.4 KB
import { useEffect, useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Loader2 } from 'lucide-react'

function escapeHtml(s: string) {
  return s
    .replace(/&/g, '&')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
}

/** 行内语法:加粗 / 行内代码 / 链接 */
function inline(s: string): string {
  let t = escapeHtml(s)
  t = t.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
  t = t.replace(/`([^`]+?)`/g, '<code class="rounded bg-muted px-1 py-0.5 text-[11px]">$1</code>')
  t = t.replace(/\[(.+?)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer" class="text-sky-600 underline">$1</a>')
  return t
}

export function DocsModal({ open, onOpenChange, file, title }: {
  open: boolean
  onOpenChange: (v: boolean) => void
  file: string
  title: string
}) {
  const [text, setText] = useState('')
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState('')

  useEffect(() => {
    if (!open) return
    let active = true
    setLoading(true); setError(''); setText('')
    fetch(`/docs/${encodeURIComponent(file)}`)
      .then(r => { if (!r.ok) throw new Error('文档加载失败 (' + r.status + ')'); return r.text() })
      .then(t => { if (active) setText(t) })
      .catch(e => { if (active) setError(e.message) })
      .finally(() => { if (active) setLoading(false) })
    return () => { active = false }
  }, [open, file])

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent className="max-h-[85vh]">
        <DialogHeader>
          <DialogTitle>{title}</DialogTitle>
        </DialogHeader>

        {loading && (
          <div className="flex items-center justify-center py-10 text-muted-foreground">
            <Loader2 className="mr-2 h-4 w-4 animate-spin" /> 加载中…
          </div>
        )}
        {error && <p className="py-6 text-center text-sm text-destructive">{error}</p>}

        {!loading && !error && (
          <div className="space-y-3 overflow-y-auto no-scrollbar pr-1 text-sm leading-relaxed text-foreground/90">
            {text.split('\n').map((line, i) => {
              if (line.startsWith('```')) return null
              if (line.startsWith('### ')) return <h3 key={i} className="mt-3 text-base font-semibold">{inline(line.slice(4))}</h3>
              if (line.startsWith('## ')) return <h2 key={i} className="mt-4 border-b pb-1 text-lg font-bold">{inline(line.slice(3))}</h2>
              if (line.startsWith('# ')) return <h1 key={i} className="mt-2 text-xl font-bold">{inline(line.slice(2))}</h1>
              if (line.startsWith('> ')) return <blockquote key={i} className="border-l-2 border-muted-foreground/30 pl-3 text-muted-foreground">{inline(line.slice(2))}</blockquote>
              if (line.startsWith('- ') || line.startsWith('* ')) return <div key={i} className="flex gap-2 pl-2"><span className="text-muted-foreground">•</span><span dangerouslySetInnerHTML={{ __html: inline(line.slice(2)) }} /></div>
              if (line.trim() === '---') return <hr key={i} className="my-3 border-muted" />
              if (line.trim() === '') return <div key={i} className="h-1" />
              return <p key={i} dangerouslySetInnerHTML={{ __html: inline(line) }} />
            })}
          </div>
        )}
      </DialogContent>
    </Dialog>
  )
}