diff --git a/prisma/dev.db b/prisma/dev.db index f9a4dae..670b66f 100644 Binary files a/prisma/dev.db and b/prisma/dev.db differ diff --git a/prisma/migrations/20260304000000_add_page_indexes/migration.sql b/prisma/migrations/20260304000000_add_page_indexes/migration.sql new file mode 100644 index 0000000..8b62b81 --- /dev/null +++ b/prisma/migrations/20260304000000_add_page_indexes/migration.sql @@ -0,0 +1,5 @@ +-- CreateIndex +CREATE INDEX IF NOT EXISTS "idx_page_parent_order" ON "Page"("parentId", "order"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "idx_page_created_at" ON "Page"("createdAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e9135d3..1510a0e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -22,6 +22,9 @@ model Page { updatedAt DateTime @updatedAt order Int @default(0) isLocked Boolean @default(false) + + @@index([parentId, order], map: "idx_page_parent_order") + @@index([createdAt], map: "idx_page_created_at") } model GlobalSettings { diff --git a/src/app/api/pages/route.ts b/src/app/api/pages/route.ts index 7649713..620d9f5 100644 --- a/src/app/api/pages/route.ts +++ b/src/app/api/pages/route.ts @@ -18,13 +18,28 @@ export async function GET(request: NextRequest) { // 默认行为:返回所有页面(向后兼容) const usePagination = limit > 0 && limit <= 1000 && page > 0; + const baseSelect = { + id: true, + title: true, + content: !lightweight, + tags: true, + icon: true, + type: true, + parentId: true, + createdAt: true, + updatedAt: true, + order: true, + isLocked: true, + } as const; + if (!usePagination) { const pages = await prisma.page.findMany({ + select: baseSelect, orderBy: [{ order: 'asc' }, { createdAt: 'desc' }], }); const parsedPages = pages.map((p) => ({ ...p, - content: lightweight ? '' : p.content, + content: lightweight ? '' : (p.content || ''), tags: safeParseTags(p.tags), })); return NextResponse.json(parsedPages); @@ -34,6 +49,7 @@ export async function GET(request: NextRequest) { const skip = (page - 1) * limit; const [pages, total] = await Promise.all([ prisma.page.findMany({ + select: baseSelect, skip, take: limit, orderBy: [{ order: 'asc' }, { createdAt: 'desc' }], @@ -133,4 +149,3 @@ export async function POST(request: Request) { } - diff --git a/src/app/globals.css b/src/app/globals.css index 706e59d..3e089dd 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -360,13 +360,18 @@ ul[data-type="taskList"], The component handles its own layout. */ /* Override Highlight.js background for a softer look */ -.ProseMirror pre { + .ProseMirror pre { background: hsl(var(--code-bg)) !important; border: 1px solid hsl(var(--border) / 0.5); border-radius: 0.5rem; padding: 0.65rem 0.75rem; margin: 0.95rem 0; overflow-x: auto; + color: #e6edf3; +} + +.ProseMirror pre code { + color: inherit; } .hljs { diff --git a/src/app/page.tsx b/src/app/page.tsx index 553c93e..e40ed88 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -25,7 +25,16 @@ const ICONS = [ ]; export default function Home() { - const { activePageId, pages, pageSync, updatePage, setActivePageId, addPage, retryPageSync, retryPendingSyncs, flushPendingSyncs } = useEditorStore(); + const activePageId = useEditorStore((s) => s.activePageId); + const pages = useEditorStore((s) => s.pages); + const pageSync = useEditorStore((s) => s.pageSync); + const updatePage = useEditorStore((s) => s.updatePage); + const setActivePageId = useEditorStore((s) => s.setActivePageId); + const addPage = useEditorStore((s) => s.addPage); + const retryPageSync = useEditorStore((s) => s.retryPageSync); + const retryPendingSyncs = useEditorStore((s) => s.retryPendingSyncs); + const flushPendingSyncs = useEditorStore((s) => s.flushPendingSyncs); + const fetchPageContent = useEditorStore((s) => s.fetchPageContent); const { openSearchWithTag } = useSearchStore(); const { timezone } = useSettingsStore(); @@ -36,6 +45,7 @@ export default function Home() { const [isAddingTag, setIsAddingTag] = useState(false); const [tagInput, setTagInput] = useState(""); const [isIconPickerOpen, setIsIconPickerOpen] = useState(false); + const [isLinksOpen, setIsLinksOpen] = useState(false); const [isHistoryOpen, setIsHistoryOpen] = useState(false); const [selectedHistoryIndex, setSelectedHistoryIndex] = useState(0); const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(() => !activePageId); @@ -57,19 +67,22 @@ export default function Home() { }, [activePage, pages]); const outgoingLinks = useMemo(() => { + if (!isLinksOpen) return []; if (!activePage || activePage.type !== "file") return []; return getOutgoingLinks(activePage, pages); - }, [activePage, pages]); + }, [activePage, pages, isLinksOpen]); const backlinks = useMemo(() => { + if (!isLinksOpen) return []; if (!activePage || activePage.type !== "file") return []; return getBacklinks(activePage, pages); - }, [activePage, pages]); + }, [activePage, pages, isLinksOpen]); const unresolvedLinks = useMemo(() => { + if (!isLinksOpen) return []; if (!activePage || activePage.type !== "file") return []; return getUnresolvedLinkTitles(activePage, pages); - }, [activePage, pages]); + }, [activePage, pages, isLinksOpen]); const localHistory = useMemo(() => { if (!activePage || activePage.type !== "file") return []; @@ -125,6 +138,27 @@ export default function Home() { }; }, [flushPendingSyncs]); + useEffect(() => { + if (!activePageId) return; + const current = pages.find((p) => p.id === activePageId); + if (!current || current.type === "folder") return; + if (current.contentLoaded) return; + fetchPageContent(activePageId); + }, [activePageId, pages, fetchPageContent]); + + useEffect(() => { + if (!isLinksOpen) return; + const batchIds = pages + .filter((p) => p.type === "file" && !p.contentLoaded) + .slice(0, 8) + .map((p) => p.id); + if (batchIds.length === 0) return; + + Promise.all(batchIds.map((id) => fetchPageContent(id))).catch((e) => { + console.error("Failed to batch load pages for link analysis", e); + }); + }, [isLinksOpen, pages, fetchPageContent]); + const handleEdgeSwipeStart = (e: React.TouchEvent) => { if (isMobileSidebarOpen) return; const touch = e.touches[0]; @@ -411,80 +445,101 @@ export default function Home() { />
-
-
-
-

关联页面

- {outgoingLinks.length} +
+ - )) - ) : ( - 暂无 - )} -
-
+ + +

展开后计算关联页面、反向链接和未解析链接。

+ {isLinksOpen && ( +
+
+
+

关联页面

+ {outgoingLinks.length} +
+

正文写 `[[页面名]]` 可自动关联。

+
+ {outgoingLinks.length > 0 ? ( + outgoingLinks.map((item) => ( + + )) + ) : ( + 暂无 + )} +
+
-
-
-

反向链接

- {backlinks.length} -
-

被哪些页面引用。

-
- {backlinks.length > 0 ? ( - backlinks.map((item) => ( - - )) - ) : ( - 暂无 - )} -
-
+
+
+

反向链接

+ {backlinks.length} +
+

被哪些页面引用。

+
+ {backlinks.length > 0 ? ( + backlinks.map((item) => ( + + )) + ) : ( + 暂无 + )} +
+
-
-
-

未解析链接

- {unresolvedLinks.length} +
+
+

未解析链接

+ {unresolvedLinks.length} +
+

可一键创建缺失页面。

+
+ {unresolvedLinks.length > 0 ? ( + unresolvedLinks.map((title) => ( + + )) + ) : ( + 全部已解析 + )} +
-

可一键创建缺失页面。

-
- {unresolvedLinks.length > 0 ? ( - unresolvedLinks.map((title) => ( - - )) - ) : ( - 全部已解析 - )} -
-
+
+ )}
diff --git a/src/components/editor/code-block.tsx b/src/components/editor/code-block.tsx index bf1840b..feb1bf3 100644 --- a/src/components/editor/code-block.tsx +++ b/src/components/editor/code-block.tsx @@ -55,7 +55,7 @@ export function CodeBlockComponent({ return (
@@ -86,7 +86,7 @@ export function CodeBlockComponent({
-
+
{lineNumbers.map((line) => (
{line} @@ -94,7 +94,7 @@ export function CodeBlockComponent({ ))}
-
+                
                      void;
     onToggleSidebarCollapse?: () => void;
 }) {
-    const { pages, activePageId, fetchPages, addPage, isLoading, movePage, reorderPages } = useEditorStore();
+    const pages = useEditorStore((s) => s.pages);
+    const activePageId = useEditorStore((s) => s.activePageId);
+    const fetchPages = useEditorStore((s) => s.fetchPages);
+    const addPage = useEditorStore((s) => s.addPage);
+    const isLoading = useEditorStore((s) => s.isLoading);
+    const movePage = useEditorStore((s) => s.movePage);
+    const reorderPages = useEditorStore((s) => s.reorderPages);
     const { setOpen, openSearchWithTag } = useSearchStore();
     const { triggerImport, isImporting } = useImport();
     const { theme, setTheme } = useTheme();
@@ -359,18 +365,7 @@ export function SidebarContent({
                                         }}
                                     >
                                         
-                                        {isImporting ? "导入中..." : "导入到当前层级"}
-                                    
-                                     {
-                                            triggerImport(null);
-                                            handleItemClick();
-                                        }}
-                                    >
-                                        
-                                        {isImporting ? "导入中..." : "导入到根目录"}
+                                        {isImporting ? "导入中..." : "导入文档"}
                                     
                                     
                                      void;
     level?: number;
     folderFileCount: Record;
+    hasChildrenSet?: Set;
 }
 
 function buildFolderFileCount(pages: Page[]): Record {
@@ -65,6 +66,14 @@ function buildFolderFileCount(pages: Page[]): Record {
     return counts;
 }
 
+function buildHasChildrenSet(pages: Page[]): Set {
+    const result = new Set();
+    for (const page of pages) {
+        if (page.parentId) result.add(page.parentId);
+    }
+    return result;
+}
+
 function TreeNode({
     node,
     pages,
@@ -72,6 +81,7 @@ function TreeNode({
     expanded,
     toggleExpand,
     folderFileCount,
+    hasChildrenSet,
 }: {
     node: Page;
     pages: Page[];
@@ -79,15 +89,19 @@ function TreeNode({
     expanded: Record;
     toggleExpand: (id: string) => void;
     folderFileCount: Record;
+    hasChildrenSet: Set;
 }) {
-    const { activePageId, setActivePageId, addPage, deletePage } = useEditorStore();
+    const activePageId = useEditorStore((s) => s.activePageId);
+    const setActivePageId = useEditorStore((s) => s.setActivePageId);
+    const addPage = useEditorStore((s) => s.addPage);
+    const deletePage = useEditorStore((s) => s.deletePage);
     const router = useRouter();
     const pathname = usePathname();
     const { triggerImport } = useImport();
     const confirm = useConfirm();
 
     const isFolder = node.type === "folder";
-    const hasChildren = pages.some((p) => p.parentId === node.id);
+    const hasChildren = hasChildrenSet.has(node.id);
     const isExpanded = expanded[node.id];
     const isActive = activePageId === node.id;
 
@@ -259,14 +273,16 @@ function TreeNode({
                     expanded={expanded}
                     toggleExpand={toggleExpand}
                     folderFileCount={folderFileCount}
+                    hasChildrenSet={hasChildrenSet}
                 />
             )}
         
); } -export function TreeView({ pages, parentId, expanded, toggleExpand, level = 0, folderFileCount }: TreeViewProps) { +export function TreeView({ pages, parentId, expanded, toggleExpand, level = 0, folderFileCount, hasChildrenSet }: TreeViewProps) { const computedFolderFileCount = level === 0 ? buildFolderFileCount(pages) : folderFileCount; + const computedHasChildrenSet = level === 0 ? buildHasChildrenSet(pages) : hasChildrenSet || new Set(); const nodes = pages .filter((p) => p.parentId === parentId) .sort((a, b) => (a.order || 0) - (b.order || 0)); @@ -285,6 +301,7 @@ export function TreeView({ pages, parentId, expanded, toggleExpand, level = 0, f expanded={expanded} toggleExpand={toggleExpand} folderFileCount={computedFolderFileCount} + hasChildrenSet={computedHasChildrenSet} /> ))}
diff --git a/src/lib/store.ts b/src/lib/store.ts index dbaf634..aa6efd0 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -15,6 +15,7 @@ export interface Page { tags?: string[]; order?: number; isLocked?: boolean; + contentLoaded?: boolean; } export type PageSyncStatus = "saved" | "saving" | "pending" | "error"; @@ -31,7 +32,8 @@ interface EditorState { isLoading: boolean; pageSync: Record; - fetchPages: () => Promise; + fetchPages: (lightweight?: boolean) => Promise; + fetchPageContent: (id: string) => Promise; setActivePageId: (id: string | null) => void; addPage: ( parentId?: string | null, @@ -61,6 +63,8 @@ const CONTENT_RETRY_MAX_MS = 30000; const pendingContentTimers = new Map>(); const pendingContentPayloads = new Map(); +const snapshotCheckAtByPage = new Map(); +const SNAPSHOT_CHECK_INTERVAL_MS = 5000; function setPageSyncMeta(set: StoreSet, id: string, meta: PageSyncMeta): void { set((state) => ({ @@ -153,13 +157,30 @@ export const useEditorStore = create()( isLoading: false, pageSync: {}, - fetchPages: async () => { + fetchPages: async (lightweight = true) => { set({ isLoading: true }); try { - const res = await fetch("/api/pages"); + const query = lightweight ? "?lightweight=true" : ""; + const res = await fetch(`/api/pages${query}`); if (res.ok) { const data = await res.json(); - set({ pages: data }); + const prevById = new Map(get().pages.map((p) => [p.id, p])); + const merged = (Array.isArray(data) ? data : []).map((page: Page) => { + const prev = prevById.get(page.id); + const hasLoadedContent = typeof prev?.content === "string" && (prev.content.length > 0 || prev.contentLoaded); + if (lightweight && hasLoadedContent) { + return { + ...page, + content: prev?.content || "", + contentLoaded: true, + }; + } + return { + ...page, + contentLoaded: lightweight ? false : true, + }; + }); + set({ pages: merged }); } } catch (e) { console.error("Failed to fetch pages", e); @@ -168,6 +189,32 @@ export const useEditorStore = create()( } }, + fetchPageContent: async (id) => { + try { + const existing = get().pages.find((p) => p.id === id); + if (!existing || existing.type === "folder") return; + if (existing.contentLoaded) return; + + const res = await fetch(`/api/pages/${id}`); + if (!res.ok) return; + const page = (await res.json()) as Page; + set((state) => ({ + pages: state.pages.map((p) => + p.id === id + ? { + ...p, + content: page.content || "", + updatedAt: page.updatedAt || p.updatedAt, + contentLoaded: true, + } + : p + ), + })); + } catch (e) { + console.error("Failed to fetch page content", e); + } + }, + setActivePageId: (id) => set({ activePageId: id }), addPage: async (parentId = null, type = "file", initialData, order) => { @@ -186,12 +233,13 @@ export const useEditorStore = create()( }); if (res.ok) { const newPage = await res.json(); + const hydratedPage = { ...newPage, contentLoaded: true }; set((state) => ({ - pages: [newPage, ...state.pages], - activePageId: newPage.id, + pages: [hydratedPage, ...state.pages], + activePageId: hydratedPage.id, pageSync: { ...state.pageSync, - [newPage.id]: { + [hydratedPage.id]: { status: "saved", message: "已保存", lastSyncedAt: new Date().toISOString(), @@ -210,15 +258,33 @@ export const useEditorStore = create()( const isContentOnlyUpdate = data.content !== undefined && Object.keys(data).every((key) => key === "content"); if (current && (data.content !== undefined || data.title !== undefined)) { - capturePageSnapshot({ - id: current.id, - title: current.title, - content: current.content, - }); + const now = Date.now(); + const titleChanged = data.title !== undefined && data.title !== current.title; + const contentChanged = data.content !== undefined && data.content !== current.content; + const lastCheckedAt = snapshotCheckAtByPage.get(current.id) ?? 0; + const shouldCheckSnapshot = titleChanged || (contentChanged && now - lastCheckedAt >= SNAPSHOT_CHECK_INTERVAL_MS); + + if (shouldCheckSnapshot) { + snapshotCheckAtByPage.set(current.id, now); + capturePageSnapshot({ + id: current.id, + title: current.title, + content: current.content, + }); + } } set((state) => ({ - pages: state.pages.map((p) => (p.id === id ? { ...p, ...data, updatedAt: new Date().toISOString() } : p)), + pages: state.pages.map((p) => + p.id === id + ? { + ...p, + ...data, + updatedAt: new Date().toISOString(), + contentLoaded: data.content !== undefined ? true : p.contentLoaded, + } + : p + ), })); if (isContentOnlyUpdate) {