duqing2026's picture
同步 hf
9ed89c8
"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">搜索结果: &quot;{searchTerm}&quot;</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>
);
}