Spaces:
Build error
Build error
| "use client"; | |
| import { useState, useEffect, useMemo, Suspense, ReactNode, useRef } from 'react'; | |
| import { useLanguage } from '@/contexts/LanguageContext'; | |
| import { ArrowLeft, RefreshCw, Search, Database, ExternalLink, ChevronRight, ChevronDown, Home, List, Square, ArrowUp, MoreVertical, BarChart2, Save, Tag, X } from 'lucide-react'; | |
| import ReactMarkdown from 'react-markdown'; | |
| import rehypeRaw from 'rehype-raw'; | |
| import remarkGfm from 'remark-gfm'; | |
| import Link from 'next/link'; | |
| import { useSearchParams, useRouter, usePathname } from 'next/navigation'; | |
| // Helper for slugify | |
| const slugify = (text: string) => { | |
| return text.toString().toLowerCase() | |
| .replace(/\s+/g, '-') | |
| .replace(/[^\w\u4e00-\u9fa5\-]+/g, '') | |
| .replace(/\-\-+/g, '-') | |
| .replace(/^-+/, '') | |
| .replace(/-+$/, ''); | |
| }; | |
| // Helper to extract text from React children | |
| const extractText = (children: ReactNode): string => { | |
| if (typeof children === 'string') return children; | |
| if (typeof children === 'number') return children.toString(); | |
| if (Array.isArray(children)) return children.map(extractText).join(''); | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| if ((children as any)?.props?.children) return extractText((children as any).props.children); | |
| return ''; | |
| }; | |
| interface Heading { | |
| id: string; | |
| text: string; | |
| level: number; | |
| } | |
| const extractHeadings = (markdown: string): Heading[] => { | |
| // Split by newline, handling both \n and \r\n | |
| const lines = markdown.split(/\r?\n/); | |
| const headings: Heading[] = []; | |
| let inCodeBlock = false; | |
| for (const line of lines) { | |
| const trimmedLine = line.trim(); | |
| // Check for code block delimiter | |
| // Matches ``` or ~~~ followed by optional language identifier | |
| if (trimmedLine.startsWith('```') || trimmedLine.startsWith('~~~')) { | |
| inCodeBlock = !inCodeBlock; | |
| continue; | |
| } | |
| if (inCodeBlock) continue; | |
| const match = line.match(/^(#{1,3})\s+(.+)$/); | |
| if (match) { | |
| const level = match[1].length; | |
| // Remove markdown formatting from text for display | |
| const text = match[2] | |
| .replace(/!\[([^\]]*)\]\([^)]+\)/g, '') // images - remove entirely | |
| .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // links | |
| .replace(/(\*\*|__)(.*?)\1/g, '$2') // bold | |
| .replace(/(\*|_)(.*?)\1/g, '$2') // italic | |
| .replace(/`([^`]+)`/g, '$1') // code | |
| .replace(/<[^>]+>/g, '') // HTML tags | |
| .trim(); | |
| if (!text) continue; // Skip empty headings | |
| const id = slugify(text); | |
| headings.push({ id, text, level }); | |
| } | |
| } | |
| return headings; | |
| }; | |
| const Outline = ({ markdown }: { markdown: string }) => { | |
| const { t } = useLanguage(); | |
| const headings = useMemo(() => extractHeadings(markdown), [markdown]); | |
| if (headings.length === 0) return null; | |
| return ( | |
| <div className="text-sm"> | |
| <div className="font-semibold mb-3 text-gray-900 dark:text-gray-100 flex items-center gap-2"> | |
| <List className="w-4 h-4" /> | |
| {t('outline')} | |
| </div> | |
| <div className="flex flex-col gap-1.5 border-l border-gray-200 dark:border-gray-800 pl-3"> | |
| {headings.map((heading, index) => ( | |
| <a | |
| key={`${heading.id}-${index}`} | |
| href={`#${heading.id}`} | |
| className={`block text-gray-500 hover:text-primary-600 transition-colors py-1 break-words whitespace-normal | |
| ${heading.level === 1 ? 'font-medium text-gray-800 dark:text-gray-200' : ''} | |
| ${heading.level === 3 ? 'pl-4' : ''} | |
| `} | |
| onClick={(e) => { | |
| e.preventDefault(); | |
| const element = document.getElementById(heading.id); | |
| if (element) { | |
| element.scrollIntoView({ behavior: 'smooth' }); | |
| } | |
| }} | |
| title={heading.text} | |
| > | |
| {heading.text} | |
| </a> | |
| ))} | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| interface Document { | |
| id: string; | |
| yuque_id: number; | |
| title: string; | |
| slug: string; | |
| url: string; | |
| namespace: string; | |
| content_preview: string; | |
| synced_at: number; | |
| parent_uuid?: string; | |
| uuid?: string; | |
| sort_order?: number; | |
| content_length?: number; | |
| updated_at?: number; | |
| tags?: string[]; | |
| } | |
| interface TreeNode { | |
| doc: Document; | |
| children: TreeNode[]; | |
| } | |
| function buildTree(docs: Document[]): TreeNode[] { | |
| const nodeMap = new Map<string, TreeNode>(); | |
| const roots: TreeNode[] = []; | |
| // Initialize nodes | |
| docs.forEach(doc => { | |
| if (doc.uuid) { | |
| nodeMap.set(doc.uuid, { doc, children: [] }); | |
| } | |
| }); | |
| // Build hierarchy | |
| docs.forEach(doc => { | |
| if (doc.uuid) { | |
| const node = nodeMap.get(doc.uuid)!; | |
| if (doc.parent_uuid && nodeMap.has(doc.parent_uuid)) { | |
| nodeMap.get(doc.parent_uuid)!.children.push(node); | |
| } else { | |
| roots.push(node); | |
| } | |
| } | |
| }); | |
| // Sort function | |
| const sortNodes = (nodes: TreeNode[]) => { | |
| nodes.sort((a, b) => (a.doc.sort_order || 0) - (b.doc.sort_order || 0)); | |
| nodes.forEach(node => { | |
| if (node.children.length > 0) { | |
| sortNodes(node.children); | |
| } | |
| }); | |
| }; | |
| sortNodes(roots); | |
| return roots; | |
| } | |
| const TreeNodeView = ({ | |
| node, | |
| level = 0, | |
| onSelect, | |
| selectedUuid, | |
| variant | |
| }: { | |
| node: TreeNode, | |
| level?: number, | |
| onSelect: (node: TreeNode) => void, | |
| selectedUuid?: string, | |
| variant?: 'sidebar' | 'main' | |
| }) => { | |
| const [isOpen, setIsOpen] = useState(false); | |
| const hasChildren = node.children.length > 0; | |
| const isSelected = node.doc.uuid === selectedUuid; | |
| // Helper to format date | |
| const formatDate = (timestamp?: number) => { | |
| if (!timestamp) return ''; | |
| const date = new Date(timestamp); | |
| return `${date.getFullYear()}-${(date.getMonth()+1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`; | |
| }; | |
| // Use refs to track mounted state and last processed selection | |
| // This prevents auto-expansion when data refreshes but selection hasn't changed | |
| const isMounted = useRef(false); | |
| const lastSelectedUuidRef = useRef<string | undefined>(selectedUuid); | |
| // Auto-expand if a child is selected or if this node is selected | |
| useEffect(() => { | |
| // We want to expand if: | |
| // 1. It's the initial mount (and we contain the selection) | |
| // 2. The selectedUuid has CHANGED (and we contain the new selection) | |
| const shouldCheck = !isMounted.current || selectedUuid !== lastSelectedUuidRef.current; | |
| if (shouldCheck) { | |
| isMounted.current = true; | |
| lastSelectedUuidRef.current = selectedUuid; | |
| if (selectedUuid) { | |
| const containsSelected = (n: TreeNode): boolean => { | |
| if (n.doc.uuid === selectedUuid) return true; | |
| return n.children.some(child => containsSelected(child)); | |
| }; | |
| if (containsSelected(node) && !isOpen) { | |
| // eslint-disable-next-line | |
| setIsOpen(true); | |
| } | |
| } | |
| } | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [selectedUuid, node]); | |
| const handleToggle = (e: React.MouseEvent) => { | |
| e.stopPropagation(); | |
| setIsOpen(!isOpen); | |
| }; | |
| const handleClick = (e: React.MouseEvent) => { | |
| e.stopPropagation(); | |
| // Only select if it's a real document (yuque_id !== 0) | |
| if (node.doc.yuque_id !== 0) { | |
| onSelect(node); | |
| } else { | |
| // If it's a directory node, just toggle expansion | |
| setIsOpen(!isOpen); | |
| } | |
| }; | |
| return ( | |
| <div className="select-none"> | |
| <div | |
| className={`flex items-center gap-2 px-3 py-2 rounded-md cursor-pointer transition-colors text-sm | |
| ${isSelected ? 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300' : 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300'}`} | |
| style={{ paddingLeft: `${level * 1 + 0.75}rem` }} | |
| onClick={handleClick} | |
| > | |
| <div | |
| className={`w-4 h-4 flex items-center justify-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors ${hasChildren ? 'cursor-pointer' : 'invisible'}`} | |
| onClick={handleToggle} | |
| > | |
| {isOpen ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />} | |
| </div> | |
| <div className={`flex items-center gap-2 min-w-0 ${variant === 'main' ? '' : 'flex-1'}`}> | |
| <span className={variant === 'main' ? 'truncate text-sm/relaxed' : 'truncate'} title={node.doc.title}>{node.doc.title}</span> | |
| </div> | |
| {variant === 'main' && node.doc.yuque_id !== 0 && ( | |
| <> | |
| <div className="flex-1 min-w-[1rem] mx-2 border-b border-dotted border-gray-300 dark:border-gray-700 h-[1px] relative top-[2px] opacity-50" /> | |
| <span className="text-xs text-gray-400 shrink-0 font-mono"> | |
| {formatDate(node.doc.updated_at || node.doc.synced_at)} | |
| </span> | |
| </> | |
| )} | |
| </div> | |
| {isOpen && hasChildren && ( | |
| <div> | |
| {node.children.map(child => ( | |
| <TreeNodeView | |
| key={child.doc.uuid} | |
| node={child} | |
| level={level + 1} | |
| onSelect={onSelect} | |
| selectedUuid={selectedUuid} | |
| variant={variant} | |
| /> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| interface SyncStatus { | |
| total: number; | |
| processed: number; | |
| currentBatch: number; | |
| totalBatches: number; | |
| status: 'idle' | 'running' | 'completed' | 'error'; | |
| error?: string; | |
| message?: string; | |
| } | |
| interface KnowledgeBase { | |
| namespace: string; | |
| name: string; | |
| description: string; | |
| synced_at: number; | |
| } | |
| function KnowledgePageContent() { | |
| const { t, language, setLanguage } = useLanguage(); | |
| const searchParams = useSearchParams(); | |
| const router = useRouter(); | |
| const pathname = usePathname(); | |
| const [selectedNode, setSelectedNode] = useState<TreeNode | null>(null); | |
| const [documents, setDocuments] = useState<Document[]>([]); | |
| const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]); | |
| const [currentKbNamespace, setCurrentKbNamespace] = useState<string>(''); | |
| const [isKbDropdownOpen, setIsKbDropdownOpen] = useState(false); | |
| const [isLoading, setIsLoading] = useState(true); | |
| const [isLoadingContent, setIsLoadingContent] = useState(false); | |
| const [searchTerm, setSearchTerm] = useState(''); | |
| const [syncStatus, setSyncStatus] = useState<SyncStatus>({ | |
| total: 0, | |
| processed: 0, | |
| currentBatch: 0, | |
| totalBatches: 0, | |
| status: 'idle' | |
| }); | |
| const [isDemoMode, setIsDemoMode] = useState(false); | |
| // State initialization flag | |
| const [isInitialized, setIsInitialized] = useState(false); | |
| const isManualNav = useRef(false); | |
| const mainContentRef = useRef<HTMLDivElement>(null); | |
| const tagInputRef = useRef<HTMLInputElement>(null); | |
| const [showScrollTop, setShowScrollTop] = useState(false); | |
| const [treeVersion, setTreeVersion] = useState(0); | |
| const [visibleCount, setVisibleCount] = useState(30); | |
| const [sidebarVisibleCount, setSidebarVisibleCount] = useState(30); | |
| const [isHoveringSync, setIsHoveringSync] = useState(false); | |
| const [isStatsMenuOpen, setIsStatsMenuOpen] = useState(false); | |
| const [isBackingUp, setIsBackingUp] = useState(false); | |
| const handleBackup = async () => { | |
| if (isBackingUp) return; | |
| setIsBackingUp(true); | |
| try { | |
| const res = await fetch('/api/backup', { method: 'POST' }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| alert(`导出 JSON 成功!\n文件已保存到「备份-语雀数据-JSON」文件夹:\n${data.files.join('\n')}`); | |
| } else { | |
| alert(`导出失败: ${data.message}`); | |
| } | |
| } catch (error) { | |
| console.error('Export failed:', error); | |
| alert('导出请求失败,请检查控制台。'); | |
| } finally { | |
| setIsBackingUp(false); | |
| } | |
| }; | |
| useEffect(() => { | |
| setVisibleCount(30); | |
| setSidebarVisibleCount(30); | |
| }, [currentKbNamespace]); | |
| const handleCollapseAll = (e: React.MouseEvent) => { | |
| e.stopPropagation(); | |
| setTreeVersion(v => v + 1); | |
| }; | |
| const fetchDocuments = async () => { | |
| try { | |
| const isDemoEnv = process.env.NEXT_PUBLIC_DEMO_MODE === 'true'; | |
| const isDemoParam = searchParams.get('demo') === 'true'; | |
| const isDemo = isDemoEnv || isDemoParam; | |
| const res = await fetch(`/api/documents${isDemo ? '?mode=demo' : ''}`); | |
| const data = await res.json(); | |
| setDocuments(data.documents || []); | |
| setKnowledgeBases(data.knowledgeBases || []); | |
| setSyncStatus(data.status); | |
| if (typeof data.isDemo === 'boolean') { | |
| setIsDemoMode(data.isDemo); | |
| } | |
| // Knowledge base selection logic | |
| // Stabilize current selection during sync or after initialization to prevent flicker | |
| if (!isInitialized) { | |
| if (data.knowledgeBases && data.knowledgeBases.length > 0) { | |
| const kbParam = searchParams.get('kb'); | |
| const kbExists = kbParam && data.knowledgeBases.some((k: KnowledgeBase) => k.namespace === kbParam); | |
| if (kbExists) { | |
| if (!currentKbNamespace) setCurrentKbNamespace(kbParam); | |
| } else if (!currentKbNamespace) { | |
| setCurrentKbNamespace(data.knowledgeBases[0].namespace); | |
| } | |
| } | |
| } else { | |
| // Do not auto-switch KB while syncing even if current is temporarily missing | |
| // Only set default if nothing is selected and we have KBs | |
| if (!currentKbNamespace && data.knowledgeBases && data.knowledgeBases.length > 0) { | |
| setCurrentKbNamespace(data.knowledgeBases[0].namespace); | |
| } | |
| } | |
| } catch (error) { | |
| console.error('Failed to fetch documents:', error); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| const filteredDocuments = useMemo(() => { | |
| if (!currentKbNamespace) return []; | |
| return documents.filter(doc => doc.namespace === currentKbNamespace); | |
| }, [documents, currentKbNamespace]); | |
| const treeRoots = useMemo(() => buildTree(filteredDocuments), [filteredDocuments]); | |
| // Tag filter state | |
| const [selectedTag, setSelectedTag] = useState<string>(''); | |
| const [isTagDropdownOpen, setIsTagDropdownOpen] = useState(false); | |
| const [isInputFocused, setIsInputFocused] = useState(false); | |
| const [tagSearchTerm, setTagSearchTerm] = useState(''); | |
| const allTags = useMemo(() => { | |
| if (!documents || documents.length === 0) return []; | |
| const tagMap = new Map<string, number>(); | |
| let untaggedCount = 0; | |
| // Use filteredDocuments to only show tags relevant to current KB | |
| filteredDocuments.forEach(doc => { | |
| if (doc.tags && Array.isArray(doc.tags) && doc.tags.length > 0) { | |
| doc.tags.forEach(t => { | |
| if (t) tagMap.set(t, (tagMap.get(t) || 0) + 1); | |
| }); | |
| } else { | |
| untaggedCount++; | |
| } | |
| }); | |
| const tagsList = Array.from(tagMap.entries()) | |
| .map(([name, count]) => ({ name, count })); | |
| if (untaggedCount > 0) { | |
| tagsList.push({ name: '无标签', count: untaggedCount }); | |
| } | |
| return tagsList.sort((a, b) => b.count - a.count); | |
| }, [filteredDocuments, documents]); | |
| const mainAreaRoots = useMemo(() => { | |
| if (!selectedTag) return treeRoots; | |
| let taggedDocs; | |
| if (selectedTag === '无标签') { | |
| taggedDocs = filteredDocuments.filter(doc => | |
| !doc.tags || !Array.isArray(doc.tags) || doc.tags.length === 0 | |
| ); | |
| } else { | |
| taggedDocs = filteredDocuments.filter(doc => | |
| doc.tags && Array.isArray(doc.tags) && doc.tags.includes(selectedTag) | |
| ); | |
| } | |
| // Rebuild tree for the filtered view | |
| // Note: If a child matches but parent doesn't, it becomes a root in this new tree | |
| return buildTree(taggedDocs); | |
| }, [treeRoots, filteredDocuments, selectedTag]); | |
| useEffect(() => { | |
| setSelectedTag(''); | |
| setIsTagDropdownOpen(false); | |
| setTagSearchTerm(''); | |
| }, [currentKbNamespace]); | |
| // Restore state from URL on load and when documents are ready | |
| useEffect(() => { | |
| if (documents.length > 0 && !isInitialized) { | |
| const kbParam = searchParams.get('kb'); | |
| const docParam = searchParams.get('doc'); | |
| if (kbParam && knowledgeBases.some(k => k.namespace === kbParam)) { | |
| setCurrentKbNamespace(kbParam); | |
| } | |
| if (docParam) { | |
| const targetDoc = documents.find(d => d.uuid === docParam); | |
| if (targetDoc && targetDoc.namespace) { | |
| // Ensure namespace is string | |
| const ns: string = targetDoc.namespace; | |
| if (ns !== currentKbNamespace && ns !== kbParam) { | |
| setCurrentKbNamespace(ns); | |
| } | |
| } | |
| } | |
| setIsInitialized(true); | |
| } | |
| }, [documents, knowledgeBases, searchParams, isInitialized]); | |
| // Effect to sync URL when state changes | |
| useEffect(() => { | |
| if (!isInitialized) return; | |
| const params = new URLSearchParams(searchParams.toString()); | |
| if (currentKbNamespace) { | |
| params.set('kb', currentKbNamespace); | |
| } else { | |
| params.delete('kb'); | |
| } | |
| if (selectedNode && selectedNode.doc.uuid) { | |
| params.set('doc', selectedNode.doc.uuid); | |
| } else { | |
| params.delete('doc'); | |
| } | |
| const newSearch = params.toString(); | |
| if (newSearch !== searchParams.toString()) { | |
| router.replace(`${pathname}?${newSearch}`, { scroll: false }); | |
| } | |
| }, [currentKbNamespace, selectedNode, isInitialized, pathname, router, searchParams]); | |
| // Effect to select node from URL once tree is built | |
| useEffect(() => { | |
| if (isManualNav.current) { | |
| isManualNav.current = false; | |
| return; | |
| } | |
| const docParam = searchParams.get('doc'); | |
| if (docParam && treeRoots.length > 0 && !selectedNode) { | |
| const findNode = (nodes: TreeNode[]): TreeNode | undefined => { | |
| for (const n of nodes) { | |
| if (n.doc.uuid === docParam) return n; | |
| const found = findNode(n.children); | |
| if (found) return found; | |
| } | |
| return undefined; | |
| }; | |
| const node = findNode(treeRoots); | |
| if (node) setSelectedNode(node); | |
| } | |
| }, [treeRoots, searchParams]); | |
| useEffect(() => { | |
| const fetchContent = async () => { | |
| if (selectedNode && !selectedNode.doc.content_preview && selectedNode.doc.id) { | |
| setIsLoadingContent(true); | |
| try { | |
| const isDemoEnv = process.env.NEXT_PUBLIC_DEMO_MODE === 'true'; | |
| const isDemoParam = searchParams.get('demo') === 'true'; | |
| const isDemo = isDemoEnv || isDemoParam; | |
| const res = await fetch(`/api/documents/${encodeURIComponent(selectedNode.doc.id)}${isDemo ? '?mode=demo' : ''}`); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| if (data.content_preview) { | |
| const updatedDoc = { ...selectedNode.doc, content_preview: data.content_preview }; | |
| setDocuments(prev => prev.map(d => d.id === updatedDoc.id ? updatedDoc : d)); | |
| setSelectedNode(prev => prev ? ({ ...prev, doc: updatedDoc }) : null); | |
| } | |
| } | |
| } catch (e) { | |
| console.error("Failed to load content", e); | |
| } finally { | |
| setIsLoadingContent(false); | |
| } | |
| } | |
| }; | |
| fetchContent(); | |
| }, [selectedNode?.doc.id]); // Fix: removed selectedNode dependency, only depend on ID to avoid loop | |
| useEffect(() => { | |
| fetchDocuments(); | |
| // Poll status if running | |
| const interval = setInterval(() => { | |
| if (syncStatus.status === 'running') { | |
| fetchDocuments(); | |
| } | |
| }, 2000); | |
| return () => clearInterval(interval); | |
| }, [syncStatus.status, searchParams.get('demo')]); | |
| const handleStopSync = async () => { | |
| try { | |
| await fetch('/api/documents', { method: 'DELETE' }); | |
| // Trigger immediate refresh to get updated status | |
| fetchDocuments(); | |
| } catch (error) { | |
| console.error('Failed to stop sync:', error); | |
| } | |
| }; | |
| const handleSync = async () => { | |
| try { | |
| await fetch('/api/documents', { method: 'POST' }); | |
| // Trigger immediate refresh to get 'running' status | |
| fetchDocuments(); | |
| } catch (error) { | |
| console.error('Failed to start sync:', error); | |
| } | |
| }; | |
| const filteredDocs = filteredDocuments.filter(doc => | |
| doc.title.toLowerCase().includes(searchTerm.toLowerCase()) || | |
| doc.slug.toLowerCase().includes(searchTerm.toLowerCase()) | |
| ); | |
| return ( | |
| <div className="min-h-screen bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 flex flex-col h-screen overflow-hidden"> | |
| {/* Top Navigation Bar */} | |
| <div className="border-b border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 shrink-0"> | |
| <div className="flex items-center justify-between px-4 py-3"> | |
| <div className="flex items-center gap-4"> | |
| <Link href="/" className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition text-gray-500"> | |
| <ArrowLeft className="w-5 h-5" /> | |
| </Link> | |
| <h1 className="text-lg font-semibold flex items-center gap-2"> | |
| <Database className="w-5 h-5 text-primary-600" /> | |
| {t('knowledgeManagement')} | |
| </h1> | |
| </div> | |
| <div className="flex items-center gap-4"> | |
| {(isDemoMode || process.env.NEXT_PUBLIC_DEMO_MODE === 'true' || searchParams.get('demo') === 'true') && ( | |
| <div className="px-2 py-1 bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 text-xs font-medium rounded border border-gray-200 dark:border-gray-700"> | |
| {t('demoMode')} | |
| </div> | |
| )} | |
| <button | |
| onClick={handleBackup} | |
| disabled={isBackingUp} | |
| className="flex items-center gap-2 px-3 py-1.5 text-sm rounded-md font-medium transition-all hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 disabled:opacity-50 disabled:cursor-not-allowed" | |
| title="备份语雀数据到本地" | |
| > | |
| {isBackingUp ? ( | |
| <RefreshCw className="w-4 h-4 animate-spin" /> | |
| ) : ( | |
| <Save className="w-4 h-4" /> | |
| )} | |
| <span>导出 JSON</span> | |
| </button> | |
| <button | |
| onClick={syncStatus.status === 'running' ? handleStopSync : handleSync} | |
| onMouseEnter={() => setIsHoveringSync(true)} | |
| onMouseLeave={() => setIsHoveringSync(false)} | |
| className={`flex items-center gap-2 px-3 py-1.5 text-sm rounded-md font-medium transition-all | |
| ${syncStatus.status === 'running' | |
| ? (isHoveringSync ? 'bg-red-50 text-red-600 dark:bg-red-900/20 dark:text-red-400' : 'text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/20') | |
| : 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100' | |
| }`} | |
| > | |
| {syncStatus.status === 'running' ? ( | |
| isHoveringSync ? ( | |
| <> | |
| <Square className="w-4 h-4 fill-current" /> | |
| <span>停止同步</span> | |
| </> | |
| ) : ( | |
| <> | |
| <RefreshCw className="w-4 h-4 animate-spin" /> | |
| <span>同步中</span> | |
| </> | |
| ) | |
| ) : ( | |
| <> | |
| <RefreshCw className="w-4 h-4" /> | |
| <span>{syncStatus.message?.includes('停止') ? '继续同步' : '同步'}</span> | |
| </> | |
| )} | |
| </button> | |
| </div> | |
| </div> | |
| {/* Sync Progress Bar (Slim) and Status Message */} | |
| {(syncStatus.status === 'running' || syncStatus.message?.includes('停止') || syncStatus.status === 'error') && ( | |
| <div className="w-full border-t border-gray-100 dark:border-gray-800"> | |
| <div className={`px-4 py-2 ${syncStatus.status === 'error' ? 'bg-red-50/50 dark:bg-red-900/10' : 'bg-primary-50/50 dark:bg-primary-900/10'}`}> | |
| <div className={`flex items-center justify-center text-xs ${syncStatus.status === 'error' ? 'text-red-700 dark:text-red-300' : 'text-primary-700 dark:text-primary-300'} mb-1.5 gap-2`}> | |
| <span className="relative flex h-2 w-2"> | |
| <span className={`animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 ${syncStatus.status === 'error' ? 'bg-red-400' : 'bg-primary-400'}`}></span> | |
| <span className={`relative inline-flex rounded-full h-2 w-2 ${syncStatus.status === 'error' ? 'bg-red-500' : 'bg-primary-500'}`}></span> | |
| </span> | |
| <span className="font-medium">{syncStatus.message || '正在同步...'}</span> | |
| <span className="mx-1 opacity-50">|</span> | |
| <span className="font-mono font-medium"> | |
| {syncStatus.processed} / {syncStatus.total} ({Math.round((syncStatus.processed / (syncStatus.total || 1)) * 100)}%) | |
| </span> | |
| </div> | |
| <div className="h-2 w-full bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden"> | |
| <div | |
| className={`h-full transition-all duration-500 ease-out rounded-full ${syncStatus.status === 'error' ? 'bg-red-600' : 'bg-primary-600'}`} | |
| style={{ width: `${(syncStatus.processed / (syncStatus.total || 1)) * 100}%` }} | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| {/* Main Content Area */} | |
| <div className="flex flex-1 overflow-hidden"> | |
| {/* Sidebar */} | |
| <div className="w-64 border-r border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-900/50 flex flex-col z-20 shrink-0"> | |
| <div className="px-3 pt-3 pb-1"> | |
| {/* Knowledge Base Switcher */} | |
| <div className="relative mb-2 px-3"> | |
| <div className="flex items-center gap-2 py-2"> | |
| <Database className="w-4 h-4 text-gray-500 shrink-0" /> | |
| <span className="font-bold text-sm text-gray-900 dark:text-gray-100 truncate"> | |
| {knowledgeBases.find(kb => kb.namespace === currentKbNamespace)?.name || t('selectKb')} | |
| </span> | |
| <div | |
| className="p-1 rounded-sm hover:bg-gray-200 dark:hover:bg-gray-700 cursor-pointer transition-colors shrink-0" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| setIsKbDropdownOpen(!isKbDropdownOpen); | |
| }} | |
| > | |
| <ChevronDown className="w-4 h-4 text-gray-500" /> | |
| </div> | |
| <div className="relative ml-auto"> | |
| <div | |
| className="p-1 rounded-sm hover:bg-gray-200 dark:hover:bg-gray-700 cursor-pointer transition-colors" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| setIsStatsMenuOpen(!isStatsMenuOpen); | |
| }} | |
| > | |
| <MoreVertical className="w-4 h-4 text-gray-500" /> | |
| </div> | |
| {isStatsMenuOpen && ( | |
| <> | |
| <div | |
| className="fixed inset-0 z-10" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| setIsStatsMenuOpen(false); | |
| }} | |
| /> | |
| <div className="absolute left-full top-0 ml-2 w-32 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-20 py-1"> | |
| <Link | |
| href="/knowledge/stats" | |
| className="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 w-full" | |
| onClick={() => setIsStatsMenuOpen(false)} | |
| > | |
| <BarChart2 className="w-4 h-4" /> | |
| <span>统计</span> | |
| </Link> | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| {isKbDropdownOpen && ( | |
| <> | |
| <div | |
| className="fixed inset-0 z-10" | |
| onClick={() => setIsKbDropdownOpen(false)} | |
| /> | |
| <div className="absolute top-full left-0 mt-1 w-48 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-20 py-1 max-h-60 overflow-y-auto"> | |
| {knowledgeBases.map(kb => ( | |
| <button | |
| key={kb.namespace} | |
| className={`w-full text-left px-3 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-700/50 flex items-center justify-between | |
| ${currentKbNamespace === kb.namespace ? 'text-primary-600 bg-primary-50 dark:bg-primary-900/20' : 'text-gray-700 dark:text-gray-300'} | |
| `} | |
| onClick={() => { | |
| setCurrentKbNamespace(kb.namespace); | |
| setIsKbDropdownOpen(false); | |
| setSelectedNode(null); | |
| setSearchTerm(''); | |
| }} | |
| > | |
| <span className="truncate">{kb.name}</span> | |
| {currentKbNamespace === kb.namespace && <div className="w-2 h-2 rounded-full bg-primary-600" />} | |
| </button> | |
| ))} | |
| {knowledgeBases.length === 0 && ( | |
| <div className="px-3 py-2 text-xs text-gray-400 text-center"> | |
| 暂无知识库,请点击同步 | |
| </div> | |
| )} | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| <div className="relative mb-3"> | |
| <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-4 h-4" /> | |
| <input | |
| type="text" | |
| placeholder="搜索文档..." | |
| value={searchTerm} | |
| onChange={(e) => setSearchTerm(e.target.value)} | |
| className="w-full pl-9 pr-4 py-1.5 text-sm rounded-md border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 focus:ring-1 focus:ring-primary-500 outline-none" | |
| /> | |
| </div> | |
| <div | |
| className={`flex items-center gap-2 px-3 py-2 rounded-md cursor-pointer transition-colors text-sm | |
| ${!selectedNode && !searchTerm ? 'bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400' : 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300'}`} | |
| onClick={() => { | |
| isManualNav.current = true; | |
| setSelectedNode(null); | |
| setSearchTerm(''); | |
| // Proactively clear URL param to prevent race condition | |
| const params = new URLSearchParams(searchParams.toString()); | |
| params.delete('doc'); | |
| router.replace(`${pathname}?${params.toString()}`, { scroll: false }); | |
| }} | |
| > | |
| <Home className="w-4 h-4" /> | |
| <span className="font-medium">{t('home')}</span> | |
| </div> | |
| </div> | |
| <div className="px-3 mb-1"> | |
| <div className="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-300"> | |
| <div | |
| className="cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 p-1 -ml-1 rounded-sm transition-colors text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100" | |
| onClick={handleCollapseAll} | |
| title="折叠所有" | |
| > | |
| <List className="w-4 h-4" /> | |
| </div> | |
| <span className="font-medium">{t('outline')}</span> | |
| </div> | |
| </div> | |
| <div | |
| className="flex-1 overflow-y-auto px-3 pb-4 scrollbar-thin" | |
| onScroll={(e) => { | |
| const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; | |
| if (scrollHeight - scrollTop - clientHeight < 100) { | |
| if (sidebarVisibleCount < treeRoots.length) { | |
| setSidebarVisibleCount(prev => Math.min(prev + 30, treeRoots.length)); | |
| } | |
| } | |
| }} | |
| > | |
| {isLoading ? ( | |
| <div className="p-4 text-center text-sm text-gray-500">{t('loading')}</div> | |
| ) : ( | |
| <div className="flex flex-col gap-0.5"> | |
| {(currentKbNamespace === 'NOTES' ? treeRoots.slice(0, sidebarVisibleCount) : treeRoots).map(node => ( | |
| <TreeNodeView | |
| key={`${node.doc.uuid}-${treeVersion}`} | |
| node={node} | |
| onSelect={setSelectedNode} | |
| selectedUuid={selectedNode?.doc.uuid} | |
| /> | |
| ))} | |
| {currentKbNamespace === 'NOTES' && sidebarVisibleCount < treeRoots.length && ( | |
| <div className="py-2 text-center text-xs text-gray-400"> | |
| ... | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| {/* Right Panel */} | |
| <div | |
| ref={mainContentRef} | |
| className="flex-1 overflow-y-auto bg-white dark:bg-gray-900 p-8 scroll-smooth" | |
| onScroll={(e) => { | |
| const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; | |
| // Toggle Back to Top button | |
| if (scrollTop > 300) { | |
| if (!showScrollTop) setShowScrollTop(true); | |
| } else { | |
| if (showScrollTop) setShowScrollTop(false); | |
| } | |
| if (scrollHeight - scrollTop - clientHeight < 100) { | |
| const targetRoots = currentKbNamespace === 'NOTES' ? mainAreaRoots : treeRoots; | |
| if (visibleCount < targetRoots.length) { | |
| setVisibleCount(prev => Math.min(prev + 30, targetRoots.length)); | |
| } | |
| } | |
| }} | |
| > | |
| {searchTerm ? ( | |
| <div className="max-w-4xl mx-auto"> | |
| <h2 className="text-xl font-bold mb-6">搜索结果: "{searchTerm}"</h2> | |
| <div className="grid grid-cols-1 gap-2"> | |
| {filteredDocs.map(doc => ( | |
| <div | |
| key={doc.id} | |
| className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg hover:bg-primary-50 dark:hover:bg-primary-900/20 cursor-pointer group transition-colors" | |
| onClick={() => { | |
| const findNode = (nodes: TreeNode[]): TreeNode | undefined => { | |
| for (const n of nodes) { | |
| if (n.doc.id === doc.id) return n; | |
| const found = findNode(n.children); | |
| if (found) return found; | |
| } | |
| return undefined; | |
| }; | |
| const targetNode = findNode(treeRoots); | |
| if (targetNode) { | |
| setSelectedNode(targetNode); | |
| setSearchTerm(''); | |
| } | |
| }} | |
| > | |
| <span className="font-medium text-gray-900 dark:text-gray-100 group-hover:text-primary-600 truncate"> | |
| {doc.title} | |
| </span> | |
| <ChevronRight className="w-4 h-4 text-gray-400 group-hover:text-primary-500 shrink-0 ml-2" /> | |
| </div> | |
| ))} | |
| {filteredDocs.length === 0 && ( | |
| <div className="text-center text-gray-500 py-10">{t('noDocs')}</div> | |
| )} | |
| </div> | |
| </div> | |
| ) : selectedNode ? ( | |
| <div className="max-w-6xl mx-auto flex gap-12 pt-2"> | |
| <div className="flex-1 min-w-0 max-w-3xl"> | |
| {/* Breadcrumb / Header */} | |
| <div className="mb-8 pb-6 border-b border-gray-100 dark:border-gray-800"> | |
| {selectedNode.doc.namespace !== 'NOTES' && ( | |
| <div className="flex items-center gap-3 mb-10"> | |
| <div> | |
| <h1 className="text-4xl font-bold text-gray-900 dark:text-gray-100"> | |
| {selectedNode.doc.title} | |
| </h1> | |
| </div> | |
| </div> | |
| )} | |
| {/* Content */} | |
| {isLoadingContent ? ( | |
| <div className="flex justify-center py-12"> | |
| <RefreshCw className="w-8 h-8 animate-spin text-gray-300" /> | |
| </div> | |
| ) : selectedNode.doc.content_preview ? ( | |
| <> | |
| <div className="prose dark:prose-invert max-w-none prose-hr:my-4 text-gray-900 dark:text-gray-100 mb-8"> | |
| <ReactMarkdown | |
| remarkPlugins={[remarkGfm]} | |
| rehypePlugins={[rehypeRaw]} | |
| components={{ | |
| h1: ({node, ...props}) => <h1 id={slugify(extractText(props.children))} className="scroll-mt-24" {...props} />, | |
| h2: ({node, ...props}) => <h2 id={slugify(extractText(props.children))} className="scroll-mt-24" {...props} />, | |
| h3: ({node, ...props}) => <h3 id={slugify(extractText(props.children))} className="scroll-mt-24" {...props} />, | |
| }} | |
| > | |
| {selectedNode.doc.content_preview} | |
| </ReactMarkdown> | |
| </div> | |
| <div className="flex items-center gap-4 text-sm text-gray-400 mt-8 pt-4 border-t border-gray-100 dark:border-gray-800"> | |
| <span>{new Date(selectedNode.doc.updated_at || selectedNode.doc.synced_at).toLocaleString()}</span> | |
| {selectedNode.doc.url && ( | |
| <a href={selectedNode.doc.url} target="_blank" className="hover:text-primary-600 flex items-center gap-1"> | |
| <ExternalLink className="w-3 h-3" /> 语雀链接 | |
| </a> | |
| )} | |
| {Array.isArray(selectedNode.doc.tags) && selectedNode.doc.tags.length > 0 && ( | |
| <div className="flex items-center gap-2"> | |
| <Tag className="w-3 h-3" /> | |
| {selectedNode.doc.tags.map(tag => ( | |
| <span | |
| key={tag} | |
| className="px-1.5 py-0.5 text-[11px] rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 border border-gray-200 dark:border-gray-700" | |
| > | |
| #{tag} | |
| </span> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| </> | |
| ) : ( | |
| <div className="py-12 text-center text-gray-400 dark:text-gray-500 italic bg-gray-50 dark:bg-gray-800/30 rounded-lg mb-8"> | |
| {selectedNode.doc.id.includes('dir-') ? '这是一个目录节点,请查看下方子文档' : '该文档暂无内容'} | |
| </div> | |
| )} | |
| </div> | |
| {/* Children List */} | |
| {selectedNode.children.length > 0 && ( | |
| <div className="mt-8"> | |
| <h3 className="text-lg font-semibold mb-4 flex items-center gap-2"> | |
| 子文档 ({selectedNode.children.length}) | |
| </h3> | |
| <div className="flex flex-col gap-2"> | |
| {selectedNode.children.map(child => ( | |
| <div | |
| key={child.doc.id} | |
| className="p-3 border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer group transition-colors flex items-center justify-between" | |
| onClick={() => setSelectedNode(child)} | |
| > | |
| <div className="flex items-center gap-3"> | |
| <span className="font-medium text-gray-900 dark:text-gray-100 group-hover:text-primary-600"> | |
| {child.doc.title} | |
| </span> | |
| </div> | |
| <ChevronRight className="w-4 h-4 text-gray-400 group-hover:text-primary-500" /> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| {/* Right Outline - Hidden on mobile, visible on lg/xl */} | |
| <div className="hidden xl:block w-64 shrink-0"> | |
| <div className="sticky top-6"> | |
| {selectedNode.doc.content_preview && ( | |
| <Outline markdown={selectedNode.doc.content_preview} /> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ) : ( | |
| /* Home View */ | |
| <div className="max-w-5xl mx-auto px-4 pt-2"> | |
| <div className="mb-8"> | |
| <div className="flex items-center justify-between"> | |
| <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100"> | |
| {knowledgeBases.find(kb => kb.namespace === currentKbNamespace)?.name || '知识库'} | |
| </h1> | |
| {currentKbNamespace === 'NOTES' && ( | |
| <div className="relative w-64"> | |
| <Tag className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 z-10 pointer-events-none" /> | |
| <input | |
| ref={tagInputRef} | |
| type="text" | |
| className={`w-full pl-9 pr-10 py-1.5 text-sm rounded-md border transition-colors outline-none | |
| ${isTagDropdownOpen | |
| ? 'border-primary-500 ring-1 ring-primary-500 bg-white dark:bg-gray-800' | |
| : 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700'} | |
| ${selectedTag ? 'text-primary-600 dark:text-primary-400 font-medium' : 'text-gray-700 dark:text-gray-300'} | |
| `} | |
| placeholder={allTags.length > 0 ? "输入筛选标签..." : "暂无标签"} | |
| value={tagSearchTerm} | |
| onChange={(e) => { | |
| setTagSearchTerm(e.target.value); | |
| setIsTagDropdownOpen(true); | |
| }} | |
| onFocus={() => { | |
| setIsTagDropdownOpen(true); | |
| setIsInputFocused(true); | |
| }} | |
| onBlur={() => setIsInputFocused(false)} | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| setIsTagDropdownOpen(true); | |
| }} | |
| disabled={allTags.length === 0} | |
| /> | |
| <div | |
| className="absolute right-1 top-1/2 -translate-y-1/2 p-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md text-gray-400 hover:text-gray-600 transition-colors z-10" | |
| onMouseDown={(e) => e.preventDefault()} | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| if (tagSearchTerm) { | |
| setTagSearchTerm(''); | |
| setSelectedTag(''); | |
| setIsTagDropdownOpen(true); | |
| tagInputRef.current?.focus(); | |
| } else { | |
| if (isTagDropdownOpen) { | |
| setIsTagDropdownOpen(false); | |
| } else { | |
| setIsTagDropdownOpen(true); | |
| tagInputRef.current?.focus(); | |
| } | |
| } | |
| }} | |
| > | |
| {tagSearchTerm ? ( | |
| <X className="w-3 h-3" /> | |
| ) : ( | |
| <ChevronDown className={`w-3 h-3 transition-transform ${isTagDropdownOpen ? 'rotate-180' : ''}`} /> | |
| )} | |
| </div> | |
| {isTagDropdownOpen && ( | |
| <> | |
| <div | |
| className="fixed inset-0 z-0" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| setIsTagDropdownOpen(false); | |
| setTagSearchTerm(selectedTag || ''); | |
| }} | |
| /> | |
| <div className="absolute left-0 right-0 top-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-20 py-1 max-h-60 overflow-y-auto"> | |
| {(!tagSearchTerm || '全部标签'.includes(tagSearchTerm)) && ( | |
| <button | |
| className={`w-full text-left px-3 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-700/50 flex items-center justify-between | |
| ${!selectedTag ? 'text-primary-600 bg-primary-50 dark:bg-primary-900/20' : 'text-gray-700 dark:text-gray-300'} | |
| `} | |
| onClick={() => { | |
| setSelectedTag(''); | |
| setTagSearchTerm(''); | |
| setIsTagDropdownOpen(false); | |
| }} | |
| > | |
| <span>全部标签</span> | |
| {!selectedTag && <div className="w-2 h-2 rounded-full bg-primary-600" />} | |
| </button> | |
| )} | |
| {allTags | |
| .filter(tag => tag.name.toLowerCase().includes(tagSearchTerm.toLowerCase())) | |
| .map(tag => ( | |
| <button | |
| key={tag.name} | |
| className={`w-full text-left px-3 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-700/50 flex items-center justify-between gap-2 | |
| ${selectedTag === tag.name ? 'text-primary-600 bg-primary-50 dark:bg-primary-900/20' : 'text-gray-700 dark:text-gray-300'} | |
| `} | |
| onClick={() => { | |
| setSelectedTag(tag.name); | |
| setTagSearchTerm(tag.name); | |
| setIsTagDropdownOpen(false); | |
| }} | |
| > | |
| <span className="truncate flex-1">{tag.name}</span> | |
| <span className="text-xs text-gray-400 shrink-0 tabular-nums">{tag.count}</span> | |
| {selectedTag === tag.name && <div className="w-2 h-2 rounded-full bg-primary-600 shrink-0" />} | |
| </button> | |
| ))} | |
| {allTags.filter(tag => tag.name.toLowerCase().includes(tagSearchTerm.toLowerCase())).length === 0 && !('全部标签'.includes(tagSearchTerm)) && ( | |
| <div className="px-3 py-2 text-xs text-gray-400 text-center"> | |
| 未找到相关标签 | |
| </div> | |
| )} | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| <div className="mt-3 flex items-center gap-4 text-sm text-gray-500"> | |
| <span>{filteredDocuments.filter(d => d.yuque_id !== 0).length} {t('documents')}</span> | |
| <span className="text-gray-300 dark:text-gray-700">|</span> | |
| <span>{(filteredDocuments.filter(d => d.yuque_id !== 0).reduce((acc, d) => acc + (d.content_length || 0), 0) / 10000).toFixed(1)} {language === 'zh' ? '万字' : '0k chars'}</span> | |
| </div> | |
| </div> | |
| <div className="flex flex-col gap-0.5 mt-6"> | |
| {(currentKbNamespace === 'NOTES' ? mainAreaRoots.slice(0, visibleCount) : mainAreaRoots).map(node => ( | |
| <TreeNodeView | |
| key={`${node.doc.uuid}-${treeVersion}`} | |
| node={node} | |
| onSelect={setSelectedNode} | |
| variant="main" | |
| /> | |
| ))} | |
| {currentKbNamespace === 'NOTES' && visibleCount < mainAreaRoots.length && ( | |
| <div className="py-4 text-center text-sm text-gray-400"> | |
| 加载更多... | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| {/* Back to Top Button */} | |
| <button | |
| onClick={() => mainContentRef.current?.scrollTo({ top: 0, behavior: 'smooth' })} | |
| className={`fixed bottom-8 right-8 p-3 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-lg rounded-full text-gray-500 hover:text-primary-600 dark:text-gray-400 dark:hover:text-primary-400 transition-all z-50 duration-300 ${showScrollTop ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10 pointer-events-none'}`} | |
| title="回到顶部" | |
| > | |
| <ArrowUp className="w-5 h-5" /> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| export default function KnowledgePage() { | |
| return ( | |
| <Suspense fallback={<div className="flex items-center justify-center min-h-screen"><RefreshCw className="w-6 h-6 animate-spin text-gray-400" /></div>}> | |
| <KnowledgePageContent /> | |
| </Suspense> | |
| ); | |
| } | |