性能优化

This commit is contained in:
2026-03-04 14:04:55 +08:00
parent 4bbfbede01
commit e69f79f9db
10 changed files with 270 additions and 109 deletions
BIN
View File
Binary file not shown.
@@ -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");
+3
View File
@@ -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 {
+17 -2
View File
@@ -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) {
}
+5
View File
@@ -367,6 +367,11 @@ ul[data-type="taskList"],
padding: 0.65rem 0.75rem;
margin: 0.95rem 0;
overflow-x: auto;
color: #e6edf3;
}
.ProseMirror pre code {
color: inherit;
}
.hljs {
+62 -7
View File
@@ -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<HTMLDivElement>) => {
if (isMobileSidebarOpen) return;
const touch = e.touches[0];
@@ -411,8 +445,27 @@ export default function Home() {
/>
<section className="ui-enter mt-6 space-y-3 md:mt-7">
<div className="grid gap-2 md:grid-cols-3">
<div className="ui-card p-2.5 md:p-3">
<button
onClick={() => setIsLinksOpen((v) => !v)}
className="flex w-full items-center justify-between rounded-md px-1 py-1 text-left transition-colors hover:bg-muted/45"
aria-expanded={isLinksOpen}
aria-label="切换链接分析面板"
>
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-foreground"></h3>
{isLinksOpen && (
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
{outgoingLinks.length + backlinks.length + unresolvedLinks.length}
</span>
)}
</div>
<ChevronDown size={15} className={cn("text-muted-foreground transition-transform", isLinksOpen && "rotate-180")} />
</button>
<p className="mt-1 text-[11px] text-muted-foreground"></p>
{isLinksOpen && (
<div className="mt-2 grid gap-2 md:grid-cols-3">
<div className="rounded-md border border-border/60 bg-muted/10 p-2.5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground"></h3>
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">{outgoingLinks.length}</span>
@@ -436,7 +489,7 @@ export default function Home() {
</div>
</div>
<div className="ui-card p-2.5 md:p-3">
<div className="rounded-md border border-border/60 bg-muted/10 p-2.5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground"></h3>
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">{backlinks.length}</span>
@@ -460,7 +513,7 @@ export default function Home() {
</div>
</div>
<div className="ui-card p-2.5 md:p-3">
<div className="rounded-md border border-border/60 bg-muted/10 p-2.5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground"></h3>
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">{unresolvedLinks.length}</span>
@@ -486,6 +539,8 @@ export default function Home() {
</div>
</div>
</div>
)}
</div>
<div className="ui-card p-2.5 md:p-3">
<button
+3 -3
View File
@@ -55,7 +55,7 @@ export function CodeBlockComponent({
return (
<NodeViewWrapper className="group code-block relative my-4 overflow-hidden rounded-lg border border-border/40 shadow-sm" style={{ backgroundColor: codeBg }}>
<div
className="flex select-none items-center justify-between border-b border-border/50 px-3 py-2 text-xs text-zinc-400"
className="flex select-none items-center justify-between border-b border-border/50 px-3 py-2 text-xs text-zinc-300"
style={{ backgroundColor: "hsl(var(--code-bg) / 0.9)" }}
>
<div className="flex items-center gap-2">
@@ -86,7 +86,7 @@ export function CodeBlockComponent({
</div>
<div className="relative grid grid-cols-[auto_1fr] font-mono text-sm leading-6" style={{ backgroundColor: codeBg }}>
<div className="select-none border-r border-white/5 px-2 py-4 text-right text-zinc-500" style={{ minWidth: "2.5rem" }} contentEditable={false}>
<div className="select-none border-r border-white/5 px-2 py-4 text-right text-zinc-400" style={{ minWidth: "2.5rem" }} contentEditable={false}>
{lineNumbers.map((line) => (
<div key={line} className="px-1">
{line}
@@ -94,7 +94,7 @@ export function CodeBlockComponent({
))}
</div>
<pre className="scrollbar-thin scrollbar-track-transparent scrollbar-thumb-white/10 !my-0 !border-0 !p-0 overflow-x-auto text-zinc-300" style={{ backgroundColor: codeBg }}>
<pre className="scrollbar-thin scrollbar-track-transparent scrollbar-thumb-white/10 !my-0 !border-0 !p-0 overflow-x-auto text-zinc-100" style={{ backgroundColor: codeBg }}>
<NodeViewContent
as={"code" as unknown as "div"}
className="block min-w-full !whitespace-pre !bg-transparent !p-4 !font-mono !text-sm !leading-6 outline-none"
+8 -13
View File
@@ -143,7 +143,13 @@ export function SidebarContent({
onCloseMobile?: () => 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({
}}
>
<Upload size={14} />
{isImporting ? "导入中..." : "导入到当前层级"}
</DropdownMenu.Item>
<DropdownMenu.Item
disabled={isImporting}
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50"
onClick={() => {
triggerImport(null);
handleItemClick();
}}
>
<Upload size={14} />
{isImporting ? "导入中..." : "导入到根目录"}
{isImporting ? "导入中..." : "导入文档"}
</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-muted" />
<DropdownMenu.Item
+20 -3
View File
@@ -30,6 +30,7 @@ interface TreeViewProps {
toggleExpand: (id: string) => void;
level?: number;
folderFileCount: Record<string, number>;
hasChildrenSet?: Set<string>;
}
function buildFolderFileCount(pages: Page[]): Record<string, number> {
@@ -65,6 +66,14 @@ function buildFolderFileCount(pages: Page[]): Record<string, number> {
return counts;
}
function buildHasChildrenSet(pages: Page[]): Set<string> {
const result = new Set<string>();
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<string, boolean>;
toggleExpand: (id: string) => void;
folderFileCount: Record<string, number>;
hasChildrenSet: Set<string>;
}) {
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}
/>
)}
</div>
);
}
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<string>();
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}
/>
))}
</div>
+74 -8
View File
@@ -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<string, PageSyncMeta>;
fetchPages: () => Promise<void>;
fetchPages: (lightweight?: boolean) => Promise<void>;
fetchPageContent: (id: string) => Promise<void>;
setActivePageId: (id: string | null) => void;
addPage: (
parentId?: string | null,
@@ -61,6 +63,8 @@ const CONTENT_RETRY_MAX_MS = 30000;
const pendingContentTimers = new Map<string, ReturnType<typeof setTimeout>>();
const pendingContentPayloads = new Map<string, PendingContentPayload>();
const snapshotCheckAtByPage = new Map<string, number>();
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<EditorState>()(
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<EditorState>()(
}
},
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<EditorState>()(
});
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<EditorState>()(
const isContentOnlyUpdate = data.content !== undefined && Object.keys(data).every((key) => key === "content");
if (current && (data.content !== undefined || data.title !== undefined)) {
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) {