性能优化
This commit is contained in:
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");
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user