性能优化

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 updatedAt DateTime @updatedAt
order Int @default(0) order Int @default(0)
isLocked Boolean @default(false) isLocked Boolean @default(false)
@@index([parentId, order], map: "idx_page_parent_order")
@@index([createdAt], map: "idx_page_created_at")
} }
model GlobalSettings { 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 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) { if (!usePagination) {
const pages = await prisma.page.findMany({ const pages = await prisma.page.findMany({
select: baseSelect,
orderBy: [{ order: 'asc' }, { createdAt: 'desc' }], orderBy: [{ order: 'asc' }, { createdAt: 'desc' }],
}); });
const parsedPages = pages.map((p) => ({ const parsedPages = pages.map((p) => ({
...p, ...p,
content: lightweight ? '' : p.content, content: lightweight ? '' : (p.content || ''),
tags: safeParseTags(p.tags), tags: safeParseTags(p.tags),
})); }));
return NextResponse.json(parsedPages); return NextResponse.json(parsedPages);
@@ -34,6 +49,7 @@ export async function GET(request: NextRequest) {
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
const [pages, total] = await Promise.all([ const [pages, total] = await Promise.all([
prisma.page.findMany({ prisma.page.findMany({
select: baseSelect,
skip, skip,
take: limit, take: limit,
orderBy: [{ order: 'asc' }, { createdAt: 'desc' }], orderBy: [{ order: 'asc' }, { createdAt: 'desc' }],
@@ -133,4 +149,3 @@ export async function POST(request: Request) {
} }
+6 -1
View File
@@ -360,13 +360,18 @@ ul[data-type="taskList"],
The component handles its own layout. */ The component handles its own layout. */
/* Override Highlight.js background for a softer look */ /* Override Highlight.js background for a softer look */
.ProseMirror pre { .ProseMirror pre {
background: hsl(var(--code-bg)) !important; background: hsl(var(--code-bg)) !important;
border: 1px solid hsl(var(--border) / 0.5); border: 1px solid hsl(var(--border) / 0.5);
border-radius: 0.5rem; border-radius: 0.5rem;
padding: 0.65rem 0.75rem; padding: 0.65rem 0.75rem;
margin: 0.95rem 0; margin: 0.95rem 0;
overflow-x: auto; overflow-x: auto;
color: #e6edf3;
}
.ProseMirror pre code {
color: inherit;
} }
.hljs { .hljs {
+129 -74
View File
@@ -25,7 +25,16 @@ const ICONS = [
]; ];
export default function Home() { 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 { openSearchWithTag } = useSearchStore();
const { timezone } = useSettingsStore(); const { timezone } = useSettingsStore();
@@ -36,6 +45,7 @@ export default function Home() {
const [isAddingTag, setIsAddingTag] = useState(false); const [isAddingTag, setIsAddingTag] = useState(false);
const [tagInput, setTagInput] = useState(""); const [tagInput, setTagInput] = useState("");
const [isIconPickerOpen, setIsIconPickerOpen] = useState(false); const [isIconPickerOpen, setIsIconPickerOpen] = useState(false);
const [isLinksOpen, setIsLinksOpen] = useState(false);
const [isHistoryOpen, setIsHistoryOpen] = useState(false); const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const [selectedHistoryIndex, setSelectedHistoryIndex] = useState(0); const [selectedHistoryIndex, setSelectedHistoryIndex] = useState(0);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(() => !activePageId); const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(() => !activePageId);
@@ -57,19 +67,22 @@ export default function Home() {
}, [activePage, pages]); }, [activePage, pages]);
const outgoingLinks = useMemo(() => { const outgoingLinks = useMemo(() => {
if (!isLinksOpen) return [];
if (!activePage || activePage.type !== "file") return []; if (!activePage || activePage.type !== "file") return [];
return getOutgoingLinks(activePage, pages); return getOutgoingLinks(activePage, pages);
}, [activePage, pages]); }, [activePage, pages, isLinksOpen]);
const backlinks = useMemo(() => { const backlinks = useMemo(() => {
if (!isLinksOpen) return [];
if (!activePage || activePage.type !== "file") return []; if (!activePage || activePage.type !== "file") return [];
return getBacklinks(activePage, pages); return getBacklinks(activePage, pages);
}, [activePage, pages]); }, [activePage, pages, isLinksOpen]);
const unresolvedLinks = useMemo(() => { const unresolvedLinks = useMemo(() => {
if (!isLinksOpen) return [];
if (!activePage || activePage.type !== "file") return []; if (!activePage || activePage.type !== "file") return [];
return getUnresolvedLinkTitles(activePage, pages); return getUnresolvedLinkTitles(activePage, pages);
}, [activePage, pages]); }, [activePage, pages, isLinksOpen]);
const localHistory = useMemo(() => { const localHistory = useMemo(() => {
if (!activePage || activePage.type !== "file") return []; if (!activePage || activePage.type !== "file") return [];
@@ -125,6 +138,27 @@ export default function Home() {
}; };
}, [flushPendingSyncs]); }, [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>) => { const handleEdgeSwipeStart = (e: React.TouchEvent<HTMLDivElement>) => {
if (isMobileSidebarOpen) return; if (isMobileSidebarOpen) return;
const touch = e.touches[0]; const touch = e.touches[0];
@@ -411,80 +445,101 @@ export default function Home() {
/> />
<section className="ui-enter mt-6 space-y-3 md:mt-7"> <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">
<div className="ui-card p-2.5 md:p-3"> <button
<div className="flex items-center justify-between"> onClick={() => setIsLinksOpen((v) => !v)}
<h3 className="text-sm font-semibold text-foreground"></h3> className="flex w-full items-center justify-between rounded-md px-1 py-1 text-left transition-colors hover:bg-muted/45"
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">{outgoingLinks.length}</span> 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> </div>
<p className="mt-1 text-[11px] text-muted-foreground"> `[[页面名]]` </p> <ChevronDown size={15} className={cn("text-muted-foreground transition-transform", isLinksOpen && "rotate-180")} />
<div className="mt-2 flex flex-wrap gap-1.5"> </button>
{outgoingLinks.length > 0 ? ( <p className="mt-1 text-[11px] text-muted-foreground"></p>
outgoingLinks.map((item) => ( {isLinksOpen && (
<button <div className="mt-2 grid gap-2 md:grid-cols-3">
key={item.id} <div className="rounded-md border border-border/60 bg-muted/10 p-2.5">
onClick={() => setActivePageId(item.id)} <div className="flex items-center justify-between">
className="rounded-md border border-border/70 bg-muted/40 px-2 py-0.5 text-[11px] text-foreground transition-colors hover:bg-accent" <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>
{item.icon ? `${item.icon} ` : ""} </div>
{item.title} <p className="mt-1 text-[11px] text-muted-foreground"> `[[页面名]]` </p>
</button> <div className="mt-2 flex flex-wrap gap-1.5">
)) {outgoingLinks.length > 0 ? (
) : ( outgoingLinks.map((item) => (
<span className="text-[11px] text-muted-foreground"></span> <button
)} key={item.id}
</div> onClick={() => setActivePageId(item.id)}
</div> className="rounded-md border border-border/70 bg-muted/40 px-2 py-0.5 text-[11px] text-foreground transition-colors hover:bg-accent"
>
{item.icon ? `${item.icon} ` : ""}
{item.title}
</button>
))
) : (
<span className="text-[11px] text-muted-foreground"></span>
)}
</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"> <div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground"></h3> <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> <span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">{backlinks.length}</span>
</div> </div>
<p className="mt-1 text-[11px] text-muted-foreground"></p> <p className="mt-1 text-[11px] text-muted-foreground"></p>
<div className="mt-2 flex flex-wrap gap-1.5"> <div className="mt-2 flex flex-wrap gap-1.5">
{backlinks.length > 0 ? ( {backlinks.length > 0 ? (
backlinks.map((item) => ( backlinks.map((item) => (
<button <button
key={item.id} key={item.id}
onClick={() => setActivePageId(item.id)} onClick={() => setActivePageId(item.id)}
className="rounded-md border border-border/70 bg-muted/40 px-2 py-0.5 text-[11px] text-foreground transition-colors hover:bg-accent" className="rounded-md border border-border/70 bg-muted/40 px-2 py-0.5 text-[11px] text-foreground transition-colors hover:bg-accent"
> >
{item.icon ? `${item.icon} ` : ""} {item.icon ? `${item.icon} ` : ""}
{item.title} {item.title}
</button> </button>
)) ))
) : ( ) : (
<span className="text-[11px] text-muted-foreground"></span> <span className="text-[11px] text-muted-foreground"></span>
)} )}
</div> </div>
</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"> <div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground"></h3> <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> <span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">{unresolvedLinks.length}</span>
</div>
<p className="mt-1 text-[11px] text-muted-foreground"></p>
<div className="mt-2 flex flex-wrap gap-1.5">
{unresolvedLinks.length > 0 ? (
unresolvedLinks.map((title) => (
<button
key={title}
onClick={async () => {
await addPage(null, "file", { title, content: "" });
}}
className="rounded-md border border-dashed border-border bg-muted/30 px-2 py-0.5 text-[11px] text-foreground transition-colors hover:bg-accent"
title={`创建页面:${title}`}
>
+ {title}
</button>
))
) : (
<span className="text-[11px] text-muted-foreground"></span>
)}
</div>
</div> </div>
<p className="mt-1 text-[11px] text-muted-foreground"></p> </div>
<div className="mt-2 flex flex-wrap gap-1.5"> )}
{unresolvedLinks.length > 0 ? (
unresolvedLinks.map((title) => (
<button
key={title}
onClick={async () => {
await addPage(null, "file", { title, content: "" });
}}
className="rounded-md border border-dashed border-border bg-muted/30 px-2 py-0.5 text-[11px] text-foreground transition-colors hover:bg-accent"
title={`创建页面:${title}`}
>
+ {title}
</button>
))
) : (
<span className="text-[11px] text-muted-foreground"></span>
)}
</div>
</div>
</div> </div>
<div className="ui-card p-2.5 md:p-3"> <div className="ui-card p-2.5 md:p-3">
+3 -3
View File
@@ -55,7 +55,7 @@ export function CodeBlockComponent({
return ( return (
<NodeViewWrapper className="group code-block relative my-4 overflow-hidden rounded-lg border border-border/40 shadow-sm" style={{ backgroundColor: codeBg }}> <NodeViewWrapper className="group code-block relative my-4 overflow-hidden rounded-lg border border-border/40 shadow-sm" style={{ backgroundColor: codeBg }}>
<div <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)" }} style={{ backgroundColor: "hsl(var(--code-bg) / 0.9)" }}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -86,7 +86,7 @@ export function CodeBlockComponent({
</div> </div>
<div className="relative grid grid-cols-[auto_1fr] font-mono text-sm leading-6" style={{ backgroundColor: codeBg }}> <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) => ( {lineNumbers.map((line) => (
<div key={line} className="px-1"> <div key={line} className="px-1">
{line} {line}
@@ -94,7 +94,7 @@ export function CodeBlockComponent({
))} ))}
</div> </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 <NodeViewContent
as={"code" as unknown as "div"} 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" 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; onCloseMobile?: () => void;
onToggleSidebarCollapse?: () => 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 { setOpen, openSearchWithTag } = useSearchStore();
const { triggerImport, isImporting } = useImport(); const { triggerImport, isImporting } = useImport();
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
@@ -359,18 +365,7 @@ export function SidebarContent({
}} }}
> >
<Upload size={14} /> <Upload size={14} />
{isImporting ? "导入中..." : "导入到当前层级"} {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 ? "导入中..." : "导入到根目录"}
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-muted" /> <DropdownMenu.Separator className="my-1 h-px bg-muted" />
<DropdownMenu.Item <DropdownMenu.Item
+20 -3
View File
@@ -30,6 +30,7 @@ interface TreeViewProps {
toggleExpand: (id: string) => void; toggleExpand: (id: string) => void;
level?: number; level?: number;
folderFileCount: Record<string, number>; folderFileCount: Record<string, number>;
hasChildrenSet?: Set<string>;
} }
function buildFolderFileCount(pages: Page[]): Record<string, number> { function buildFolderFileCount(pages: Page[]): Record<string, number> {
@@ -65,6 +66,14 @@ function buildFolderFileCount(pages: Page[]): Record<string, number> {
return counts; 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({ function TreeNode({
node, node,
pages, pages,
@@ -72,6 +81,7 @@ function TreeNode({
expanded, expanded,
toggleExpand, toggleExpand,
folderFileCount, folderFileCount,
hasChildrenSet,
}: { }: {
node: Page; node: Page;
pages: Page[]; pages: Page[];
@@ -79,15 +89,19 @@ function TreeNode({
expanded: Record<string, boolean>; expanded: Record<string, boolean>;
toggleExpand: (id: string) => void; toggleExpand: (id: string) => void;
folderFileCount: Record<string, number>; 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 router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const { triggerImport } = useImport(); const { triggerImport } = useImport();
const confirm = useConfirm(); const confirm = useConfirm();
const isFolder = node.type === "folder"; 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 isExpanded = expanded[node.id];
const isActive = activePageId === node.id; const isActive = activePageId === node.id;
@@ -259,14 +273,16 @@ function TreeNode({
expanded={expanded} expanded={expanded}
toggleExpand={toggleExpand} toggleExpand={toggleExpand}
folderFileCount={folderFileCount} folderFileCount={folderFileCount}
hasChildrenSet={hasChildrenSet}
/> />
)} )}
</div> </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 computedFolderFileCount = level === 0 ? buildFolderFileCount(pages) : folderFileCount;
const computedHasChildrenSet = level === 0 ? buildHasChildrenSet(pages) : hasChildrenSet || new Set<string>();
const nodes = pages const nodes = pages
.filter((p) => p.parentId === parentId) .filter((p) => p.parentId === parentId)
.sort((a, b) => (a.order || 0) - (b.order || 0)); .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} expanded={expanded}
toggleExpand={toggleExpand} toggleExpand={toggleExpand}
folderFileCount={computedFolderFileCount} folderFileCount={computedFolderFileCount}
hasChildrenSet={computedHasChildrenSet}
/> />
))} ))}
</div> </div>
+79 -13
View File
@@ -15,6 +15,7 @@ export interface Page {
tags?: string[]; tags?: string[];
order?: number; order?: number;
isLocked?: boolean; isLocked?: boolean;
contentLoaded?: boolean;
} }
export type PageSyncStatus = "saved" | "saving" | "pending" | "error"; export type PageSyncStatus = "saved" | "saving" | "pending" | "error";
@@ -31,7 +32,8 @@ interface EditorState {
isLoading: boolean; isLoading: boolean;
pageSync: Record<string, PageSyncMeta>; pageSync: Record<string, PageSyncMeta>;
fetchPages: () => Promise<void>; fetchPages: (lightweight?: boolean) => Promise<void>;
fetchPageContent: (id: string) => Promise<void>;
setActivePageId: (id: string | null) => void; setActivePageId: (id: string | null) => void;
addPage: ( addPage: (
parentId?: string | null, parentId?: string | null,
@@ -61,6 +63,8 @@ const CONTENT_RETRY_MAX_MS = 30000;
const pendingContentTimers = new Map<string, ReturnType<typeof setTimeout>>(); const pendingContentTimers = new Map<string, ReturnType<typeof setTimeout>>();
const pendingContentPayloads = new Map<string, PendingContentPayload>(); 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 { function setPageSyncMeta(set: StoreSet, id: string, meta: PageSyncMeta): void {
set((state) => ({ set((state) => ({
@@ -153,13 +157,30 @@ export const useEditorStore = create<EditorState>()(
isLoading: false, isLoading: false,
pageSync: {}, pageSync: {},
fetchPages: async () => { fetchPages: async (lightweight = true) => {
set({ isLoading: true }); set({ isLoading: true });
try { try {
const res = await fetch("/api/pages"); const query = lightweight ? "?lightweight=true" : "";
const res = await fetch(`/api/pages${query}`);
if (res.ok) { if (res.ok) {
const data = await res.json(); 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) { } catch (e) {
console.error("Failed to fetch pages", 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 }), setActivePageId: (id) => set({ activePageId: id }),
addPage: async (parentId = null, type = "file", initialData, order) => { addPage: async (parentId = null, type = "file", initialData, order) => {
@@ -186,12 +233,13 @@ export const useEditorStore = create<EditorState>()(
}); });
if (res.ok) { if (res.ok) {
const newPage = await res.json(); const newPage = await res.json();
const hydratedPage = { ...newPage, contentLoaded: true };
set((state) => ({ set((state) => ({
pages: [newPage, ...state.pages], pages: [hydratedPage, ...state.pages],
activePageId: newPage.id, activePageId: hydratedPage.id,
pageSync: { pageSync: {
...state.pageSync, ...state.pageSync,
[newPage.id]: { [hydratedPage.id]: {
status: "saved", status: "saved",
message: "已保存", message: "已保存",
lastSyncedAt: new Date().toISOString(), 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"); const isContentOnlyUpdate = data.content !== undefined && Object.keys(data).every((key) => key === "content");
if (current && (data.content !== undefined || data.title !== undefined)) { if (current && (data.content !== undefined || data.title !== undefined)) {
capturePageSnapshot({ const now = Date.now();
id: current.id, const titleChanged = data.title !== undefined && data.title !== current.title;
title: current.title, const contentChanged = data.content !== undefined && data.content !== current.content;
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) => ({ 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) { if (isContentOnlyUpdate) {