修复了导入bug

This commit is contained in:
2026-03-04 10:28:31 +08:00
parent ea8113f072
commit 087323e58a
20 changed files with 355 additions and 173 deletions
-47
View File
@@ -56,7 +56,6 @@
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.9.0",
"turndown": "^7.2.2",
"turndown-plugin-gfm": "^1.0.2",
"zustand": "^5.0.9"
@@ -6509,12 +6508,6 @@
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/markdown-it-task-lists": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/markdown-it-task-lists/-/markdown-it-task-lists-2.1.1.tgz",
"integrity": "sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==",
"license": "ISC"
},
"node_modules/marked": {
"version": "17.0.1",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz",
@@ -8480,46 +8473,6 @@
"@popperjs/core": "^2.9.0"
}
},
"node_modules/tiptap-markdown": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/tiptap-markdown/-/tiptap-markdown-0.9.0.tgz",
"integrity": "sha512-dKLQ9iiuGNgrlGVjrNauF/UBzWu4LYOx5pkD0jNkmQt/GOwfCJsBuzZTsf1jZ204ANHOm572mZ9PYvGh1S7tpQ==",
"license": "MIT",
"workspaces": [
"example"
],
"dependencies": {
"@types/markdown-it": "^13.0.7",
"markdown-it": "^14.1.0",
"markdown-it-task-lists": "^2.1.1",
"prosemirror-markdown": "^1.11.1"
},
"peerDependencies": {
"@tiptap/core": "^3.0.1"
}
},
"node_modules/tiptap-markdown/node_modules/@types/linkify-it": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.5.tgz",
"integrity": "sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==",
"license": "MIT"
},
"node_modules/tiptap-markdown/node_modules/@types/markdown-it": {
"version": "13.0.9",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-13.0.9.tgz",
"integrity": "sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==",
"license": "MIT",
"dependencies": {
"@types/linkify-it": "^3",
"@types/mdurl": "^1"
}
},
"node_modules/tiptap-markdown/node_modules/@types/mdurl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.5.tgz",
"integrity": "sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==",
"license": "MIT"
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "note-ai",
"version": "1.0.0105",
"version": "1.0.0304",
"private": true,
"scripts": {
"dev": "next dev -p 3001",
@@ -61,7 +61,6 @@
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.9.0",
"turndown": "^7.2.2",
"turndown-plugin-gfm": "^1.0.2",
"zustand": "^5.0.9"
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+25 -2
View File
@@ -181,8 +181,13 @@
/* 移动端侧边栏文档树:增大字号和触摸目标 */
.sidebar-tree-item {
font-size: 0.875rem;
min-height: 2.75rem;
font-size: 0.9375rem !important;
min-height: 2.5rem !important;
line-height: 1.2;
}
.sidebar-tree-item .sidebar-title {
line-height: 1.2;
}
}
@@ -380,3 +385,21 @@ ul[data-type="taskList"],
background: linear-gradient(180deg, hsl(var(--accent) / 0.42), hsl(var(--accent) / 0.25));
box-shadow: inset 0 0 0 1px hsl(var(--ring) / 0.28);
}
.sidebar-title {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.2;
}
@media (min-width: 768px) {
.sidebar-title {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
white-space: normal;
max-height: calc(1.25em * 2);
}
}
+56 -1
View File
@@ -7,6 +7,7 @@ import { Hash, X, Plus, ChevronLeft, ChevronRight, ChevronDown, Sparkles, Lock,
import { useSettingsStore } from "@/lib/settings-store";
import { exportPageAsMarkdown } from "@/lib/export";
import { useMemo, useState } from "react";
import { useEffect } from "react";
import { cn, getTagColor } from "@/lib/utils";
import { useSearchStore } from "@/lib/search-store";
import { type Editor as TiptapEditor } from "@tiptap/react";
@@ -25,11 +26,12 @@ const ICONS = [
];
export default function Home() {
const { activePageId, pages, updatePage, setActivePageId, addPage } = useEditorStore();
const { activePageId, pages, pageSync, updatePage, setActivePageId, addPage, retryPageSync, retryPendingSyncs, flushPendingSyncs } = useEditorStore();
const { openSearchWithTag } = useSearchStore();
const { timezone } = useSettingsStore();
const activePage = pages.find((p) => p.id === activePageId);
const activePageSync = activePageId ? pageSync[activePageId] : undefined;
const [isChatOpen, setIsChatOpen] = useState(false);
const [editor, setEditor] = useState<TiptapEditor | null>(null);
const [isAddingTag, setIsAddingTag] = useState(false);
@@ -94,6 +96,33 @@ export default function Home() {
await addPage(null, "file", { title, content: "" });
};
useEffect(() => {
const onOnline = () => {
retryPendingSyncs();
};
window.addEventListener("online", onOnline);
return () => window.removeEventListener("online", onOnline);
}, [retryPendingSyncs]);
useEffect(() => {
const flush = () => flushPendingSyncs();
const onVisibilityChange = () => {
if (document.visibilityState === "hidden") {
flush();
}
};
window.addEventListener("beforeunload", flush);
window.addEventListener("pagehide", flush);
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
window.removeEventListener("beforeunload", flush);
window.removeEventListener("pagehide", flush);
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, [flushPendingSyncs]);
return (
<ImportProvider>
<div className="relative flex h-[100dvh] w-full overflow-hidden bg-background">
@@ -263,6 +292,32 @@ export default function Home() {
{activePage.isLocked ? "已锁定" : "锁定"}
</button>
{activePageSync && (
<div className="flex items-center gap-1.5">
<div
className={cn(
"rounded border px-2 py-0.5 text-[11px] md:text-xs",
activePageSync.status === "saving" && "border-blue-200 bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-300",
activePageSync.status === "pending" && "border-amber-200 bg-amber-50 text-amber-700 dark:bg-amber-900/20 dark:text-amber-300",
activePageSync.status === "error" && "border-red-200 bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-300",
activePageSync.status === "saved" && "border-emerald-200 bg-emerald-50 text-emerald-700 dark:bg-emerald-900/20 dark:text-emerald-300"
)}
title={activePageSync.message}
>
{activePageSync.message || "已保存"}
</div>
{(activePageSync.status === "pending" || activePageSync.status === "error") && (
<button
onClick={() => activePage && retryPageSync(activePage.id)}
className="ui-btn-secondary px-2 py-0.5 text-[11px] md:text-xs"
title="立即尝试同步到本地数据库"
>
</button>
)}
</div>
)}
{activePage.updatedAt && (
<div className="w-full pt-0.5 text-[11px] text-muted-foreground/60 md:ml-1 md:h-4 md:w-auto md:border-l md:pl-2 md:pt-0 md:text-xs md:text-muted-foreground/50">
{new Date(activePage.updatedAt).toLocaleString("zh-CN", {
-7
View File
@@ -43,11 +43,9 @@ import TableHeader from "@tiptap/extension-table-header";
import Image from "@tiptap/extension-image";
import Youtube from "@tiptap/extension-youtube";
import TextAlign from "@tiptap/extension-text-align";
import { Markdown } from "tiptap-markdown";
import { Fragment, type Node as ProseMirrorNode, type Schema } from "@tiptap/pm/model";
import { TextSelection } from "@tiptap/pm/state";
import { buildDropIndicatorText, mergeBlockTexts, moveContiguousSpan } from "@/lib/editor-block-ops";
import { cleanupAccidentalStandaloneInlineCode } from "@/lib/markdown-import";
interface EditorProps {
content: string;
@@ -686,11 +684,6 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport,
TextAlign.configure({
types: ["heading", "paragraph"],
}),
Markdown.configure({
html: true,
transformPastedText: true,
transformCopiedText: true,
}),
],
content,
onUpdate: ({ editor }) => {
+4 -1
View File
@@ -95,7 +95,10 @@ 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 }}>
<NodeViewContent className="block min-w-full !whitespace-pre !bg-transparent !p-4 !font-mono !text-sm !leading-6 outline-none" />
<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"
/>
</pre>
</div>
</NodeViewWrapper>
+2
View File
@@ -38,6 +38,8 @@ export const SlashCommand = Extension.create({
return {
suggestion: {
char: "/",
startOfLine: false,
allowedPrefixes: [" ", "\t", "\n"],
command: ({ editor, range, props }: { editor: Editor; range: Range; props: CommandItem }) => {
props.command({ editor, range });
},
+8 -3
View File
@@ -113,7 +113,7 @@ export function ImportProvider({ children }: { children: React.ReactNode }) {
let importedPageId: string | null = null;
try {
if (file.name.toLowerCase().endsWith(".zip")) {
await handleZipImport(file);
importedPageId = await handleZipImport(file);
} else {
importedPageId = await handleSingleFileImport(file);
}
@@ -164,7 +164,7 @@ export function ImportProvider({ children }: { children: React.ReactNode }) {
return result.id;
};
const handleZipImport = async (file: File) => {
const handleZipImport = async (file: File): Promise<string | null> => {
const zip = await JSZip.loadAsync(file);
const rootName = file.name.replace(/(\.md)?\.zip$/i, "");
@@ -220,6 +220,7 @@ export function ImportProvider({ children }: { children: React.ReactNode }) {
const pathMap = new Map<string, string>();
pathMap.set("", rootId);
let firstImportedPageId: string | null = null;
const sortedFolders = Array.from(folderPaths).sort((a, b) => a.split("/").length - b.split("/").length);
@@ -268,8 +269,9 @@ export function ImportProvider({ children }: { children: React.ReactNode }) {
},
`更新文件 ${path}`
);
if (!firstImportedPageId) firstImportedPageId = existing.id;
} else {
await postPage(
const created = await postPage(
{
title,
importMarkdown: markdown,
@@ -278,8 +280,11 @@ export function ImportProvider({ children }: { children: React.ReactNode }) {
},
`创建文件 ${path}`
);
if (!firstImportedPageId && created?.id) firstImportedPageId = created.id;
}
}
return firstImportedPageId;
};
return (
+17 -6
View File
@@ -113,7 +113,7 @@ export function ResizableSidebar() {
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className={cn(
"absolute -right-3 top-6 z-50 rounded-full border bg-card p-1 text-foreground shadow-sm transition-all opacity-0 group-hover/sidebar:opacity-100 hover:bg-primary hover:text-primary-foreground",
"absolute -right-3 top-6 z-50 rounded-full border bg-card p-1 text-foreground shadow-sm transition-all opacity-100 lg:opacity-0 lg:group-hover/sidebar:opacity-100 hover:bg-primary hover:text-primary-foreground",
isCollapsed && "-right-8 opacity-100"
)}
title={isCollapsed ? "展开侧边栏" : "收起侧边栏"}
@@ -285,24 +285,35 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
</div>
</div>
<div className="flex items-center gap-1">
<button onClick={() => addPage(null, "file")} className="ui-icon-btn h-10 w-10 md:h-8 md:w-8" title="新建文件">
<button
onClick={() => addPage(null, "file")}
className="ui-btn-secondary h-10 px-2 text-xs md:h-8 md:w-8 md:px-0"
title="新建文件"
>
<span className="sr-only"></span>
<FilePlus size={15} />
<span className="md:hidden"></span>
</button>
<button onClick={() => addPage(null, "folder")} className="ui-icon-btn h-10 w-10 md:h-8 md:w-8" title="新建文件夹">
<button
onClick={() => addPage(null, "folder")}
className="ui-btn-secondary h-10 px-2 text-xs md:h-8 md:w-8 md:px-0"
title="新建文件夹"
>
<span className="sr-only"></span>
<FolderPlus size={15} />
<span className="md:hidden"></span>
</button>
<button
onClick={() => {
setOpen(true);
handleItemClick();
}}
className="ui-icon-btn h-10 w-10 md:h-8 md:w-8"
className="ui-btn-secondary h-10 px-2 text-xs md:h-8 md:w-8 md:px-0"
title="搜索文档 (Ctrl+K)"
aria-label="搜索文档"
>
<Search size={15} />
<span className="md:hidden"></span>
</button>
{onCloseMobile && (
<button
@@ -319,7 +330,7 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
<button
onClick={() => setIsFilterOpen((v) => !v)}
className="flex h-10 w-full items-center justify-between rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground md:h-8"
className="flex h-11 w-full items-center justify-between rounded-md px-2 text-xs font-medium text-foreground/80 transition-colors hover:bg-muted/60 hover:text-foreground md:h-9"
>
<span className="flex items-center gap-1.5">
<SlidersHorizontal size={13} />
@@ -370,7 +381,7 @@ export function SidebarContent({ onCloseMobile }: { onCloseMobile?: () => void }
<div className="flex min-h-0 flex-1 flex-col p-2">
<button
onClick={() => setIsWorkspaceOpen(!isWorkspaceOpen)}
className="mb-1 flex h-10 w-full items-center justify-between rounded-lg px-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground md:h-8"
className="mb-1 flex h-11 w-full items-center justify-between rounded-lg px-2 text-sm font-semibold uppercase tracking-wide text-foreground/85 transition-colors hover:bg-muted/60 hover:text-foreground md:h-9 md:text-xs"
>
<span className="flex items-center gap-1.5">
<Layers size={13} className="opacity-80" />
+13 -6
View File
@@ -107,10 +107,10 @@ function TreeNode({
<div key={node.id} style={style} ref={setNodeRef} {...attributes} {...listeners}>
<div
className={cn(
"sidebar-tree-item group relative flex h-10 items-center justify-between rounded-lg border border-transparent px-1.5 text-[13px] transition-colors cursor-pointer select-none md:h-8",
"sidebar-tree-item group relative flex h-10 items-center justify-between rounded-lg border border-transparent px-1.5 text-[14px] transition-colors cursor-pointer select-none md:h-9 md:text-[13px]",
isActive
? "border-border/60 bg-accent/75 text-accent-foreground"
: "text-muted-foreground hover:border-border/45 hover:bg-muted/55 hover:text-foreground"
: "text-foreground/85 hover:border-border/45 hover:bg-muted/55 hover:text-foreground"
)}
onClick={() => {
setActivePageId(node.id);
@@ -127,7 +127,7 @@ function TreeNode({
toggleExpand(node.id);
}}
className={cn(
"flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground/70 transition-colors hover:bg-muted-foreground/15 hover:text-foreground md:h-6 md:w-6",
"flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground/80 transition-colors hover:bg-muted-foreground/15 hover:text-foreground md:h-8 md:w-8",
!hasChildren && !isFolder && "invisible"
)}
>
@@ -147,7 +147,14 @@ function TreeNode({
)}
</span>
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate">{node.title || "无标题"}</span>
<span
className={cn(
"sidebar-title",
level === 0 ? "text-[15px] font-semibold md:text-[14px]" : "text-[14px] md:text-[13px]"
)}
>
{node.title || "无标题"}
</span>
{isFolder && (
<span className="inline-flex min-w-[1.2rem] shrink-0 items-center justify-center rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{folderFileCount[node.id] ?? 0}
@@ -160,7 +167,7 @@ function TreeNode({
<DropdownMenu.Trigger asChild>
<button
onPointerDown={(e) => e.stopPropagation()}
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground/75 transition-opacity hover:bg-muted-foreground/15 hover:text-foreground opacity-100 md:h-6 md:w-6 lg:opacity-0 lg:group-hover:opacity-100"
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground/85 transition-opacity hover:bg-muted-foreground/15 hover:text-foreground opacity-100 md:h-8 md:w-8 lg:opacity-0 lg:group-hover:opacity-100"
>
<MoreHorizontal size={13} />
</button>
@@ -268,7 +275,7 @@ export function TreeView({ pages, parentId, expanded, toggleExpand, level = 0, f
return (
<SortableContext items={nodes.map((n) => n.id)} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-0.5">
<div className="flex flex-col gap-0 md:gap-0.5">
{nodes.map((node) => (
<TreeNode
key={node.id}
+1 -41
View File
@@ -1,48 +1,9 @@
import TurndownService from "turndown";
import JSZip from "jszip";
import { saveAs } from "file-saver";
import { Page } from "./store";
import { useSettingsStore } from "./settings-store";
import { sanitizeFilename } from "./page-utils";
import { gfm } from "turndown-plugin-gfm";
// Initialize Turndown service
const turndownService = new TurndownService({
headingStyle: "atx",
codeBlockStyle: "fenced",
bulletListMarker: "-",
});
// Use GFM plugin for tables
turndownService.use(gfm);
// Configure Turndown to handle some common elements better if needed
turndownService.addRule('strikethrough', {
filter: ['del', 's'],
replacement: function (content: string) {
return '~~' + content + '~~';
}
});
// Task List Support (Simple)
turndownService.addRule('taskListItems', {
filter: function (node: HTMLElement) {
return node.nodeName === 'LI' && node.className.includes('task-list-item');
},
replacement: function (content: string, node: HTMLElement) {
const isChecked = node.getAttribute('data-checked') === 'true';
return (isChecked ? '- [x] ' : '- [ ] ') + content.replace(/^- /, '') + '\n';
}
});
/**
* Converts HTML content to Markdown
*/
export function htmlToMarkdown(html: string): string {
if (!html) return "";
return turndownService.turndown(html);
}
import { htmlToMarkdown } from "./markdown-codec";
// Helper to escape YAML strings
function escapeYamlString(str: string): string {
@@ -179,4 +140,3 @@ function addPagesToZipRecursive(currentFolder: JSZip, parentId: string | null, a
}
});
}
-54
View File
@@ -1,54 +0,0 @@
export function exportToMarkdown(title: string, htmlContent: string) {
// Simple HTML to MD conversion (basic elements)
const md = htmlContent
.replace(/<h1>(.*?)<\/h1>/gi, '# $1\n\n')
.replace(/<h2>(.*?)<\/h2>/gi, '## $1\n\n')
.replace(/<h3>(.*?)<\/h3>/gi, '### $1\n\n')
.replace(/<p>(.*?)<\/p>/gi, '$1\n\n')
.replace(/<strong>(.*?)<\/strong>/gi, '**$1**')
.replace(/<em>(.*?)<\/em>/gi, '*$1*')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/&nbsp;/g, ' ')
.replace(/<.*?>/g, ''); // Strip remaining tags
const blob = new Blob([`# ${title}\n\n${md}`], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title || 'untitled'}.md`;
a.click();
URL.revokeObjectURL(url);
}
export function importFromMarkdown(file: File): Promise<{ title: string; content: string }> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result as string;
const lines = text.split('\n');
let title = "导入文档";
let content = "";
if (lines[0]?.startsWith('# ')) {
title = lines[0].substring(2).trim();
content = lines.slice(1).join('\n');
} else {
content = text;
}
// Simple MD to HTML for TipTap (basic)
const html = content
.split('\n\n')
.map(p => {
if (p.startsWith('## ')) return `<h2>${p.substring(3)}</h2>`;
if (p.startsWith('### ')) return `<h3>${p.substring(4)}</h3>`;
return `<p>${p}</p>`;
})
.join('');
resolve({ title, content: html });
};
reader.onerror = reject;
reader.readAsText(file);
});
}
+32
View File
@@ -0,0 +1,32 @@
import TurndownService from "turndown";
import { gfm } from "turndown-plugin-gfm";
const turndownService = new TurndownService({
headingStyle: "atx",
codeBlockStyle: "fenced",
bulletListMarker: "-",
});
turndownService.use(gfm);
turndownService.addRule("strikethrough", {
filter: ["del", "s"],
replacement(content: string) {
return `~~${content}~~`;
},
});
turndownService.addRule("taskListItems", {
filter(node: HTMLElement) {
return node.nodeName === "LI" && node.className.includes("task-list-item");
},
replacement(content: string, node: HTMLElement) {
const isChecked = node.getAttribute("data-checked") === "true";
return `${isChecked ? "- [x] " : "- [ ] "}${content.replace(/^- /, "")}\n`;
},
});
export function htmlToMarkdown(html: string): string {
if (!html) return "";
return turndownService.turndown(html);
}
+195 -2
View File
@@ -1,4 +1,4 @@
import { create } from "zustand";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { capturePageSnapshot } from "@/lib/page-history";
@@ -17,10 +17,19 @@ export interface Page {
isLocked?: boolean;
}
export type PageSyncStatus = "saved" | "saving" | "pending" | "error";
export interface PageSyncMeta {
status: PageSyncStatus;
message?: string;
lastSyncedAt?: string;
}
interface EditorState {
pages: Page[];
activePageId: string | null;
isLoading: boolean;
pageSync: Record<string, PageSyncMeta>;
fetchPages: () => Promise<void>;
setActivePageId: (id: string | null) => void;
@@ -31,17 +40,118 @@ interface EditorState {
order?: number
) => Promise<void>;
updatePage: (id: string, data: Partial<Page>) => Promise<void>;
retryPageSync: (id: string) => Promise<void>;
retryPendingSyncs: () => Promise<void>;
flushPendingSyncs: () => void;
deletePage: (id: string) => Promise<void>;
movePage: (id: string, parentId: string | null) => Promise<void>;
reorderPages: (data: { id: string; order: number }[]) => Promise<void>;
}
type StoreSet = (partial: Partial<EditorState> | ((state: EditorState) => Partial<EditorState>)) => void;
type PendingContentPayload = {
content: string;
attempts: number;
};
const CONTENT_SYNC_DEBOUNCE_MS = 600;
const CONTENT_RETRY_BASE_MS = 1200;
const CONTENT_RETRY_MAX_MS = 30000;
const pendingContentTimers = new Map<string, ReturnType<typeof setTimeout>>();
const pendingContentPayloads = new Map<string, PendingContentPayload>();
function setPageSyncMeta(set: StoreSet, id: string, meta: PageSyncMeta): void {
set((state) => ({
pageSync: {
...state.pageSync,
[id]: meta,
},
}));
}
function removePageSyncMeta(set: StoreSet, id: string): void {
set((state) => {
if (!state.pageSync[id]) return {};
const next = { ...state.pageSync };
delete next[id];
return { pageSync: next };
});
}
function clearPendingContentSync(id: string): void {
const timer = pendingContentTimers.get(id);
if (timer) {
clearTimeout(timer);
pendingContentTimers.delete(id);
}
pendingContentPayloads.delete(id);
}
function scheduleContentSync(id: string, delayMs: number, set: StoreSet): void {
const existingTimer = pendingContentTimers.get(id);
if (existingTimer) {
clearTimeout(existingTimer);
}
const timer = setTimeout(async () => {
pendingContentTimers.delete(id);
const payload = pendingContentPayloads.get(id);
if (!payload) return;
setPageSyncMeta(set, id, { status: "saving", message: "保存中..." });
try {
const res = await fetch(`/api/pages/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: payload.content }),
});
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(`Update failed: ${res.status}${errorText ? ` - ${errorText}` : ""}`);
}
pendingContentPayloads.delete(id);
setPageSyncMeta(set, id, {
status: "saved",
message: "已保存",
lastSyncedAt: new Date().toISOString(),
});
} catch (e) {
console.error("Failed to sync page content", e);
const latest = pendingContentPayloads.get(id);
if (!latest) return;
latest.attempts += 1;
pendingContentPayloads.set(id, latest);
const isOnline = typeof navigator === "undefined" ? true : navigator.onLine;
setPageSyncMeta(set, id, {
status: "pending",
message: isOnline ? "同步失败,自动重试中" : "离线,待同步",
});
const retryDelay = Math.min(
CONTENT_RETRY_MAX_MS,
CONTENT_RETRY_BASE_MS * 2 ** Math.min(latest.attempts, 5)
);
scheduleContentSync(id, retryDelay, set);
}
}, delayMs);
pendingContentTimers.set(id, timer);
}
export const useEditorStore = create<EditorState>()(
persist(
(set, get) => ({
pages: [],
activePageId: null,
isLoading: false,
pageSync: {},
fetchPages: async () => {
set({ isLoading: true });
@@ -79,6 +189,14 @@ export const useEditorStore = create<EditorState>()(
set((state) => ({
pages: [newPage, ...state.pages],
activePageId: newPage.id,
pageSync: {
...state.pageSync,
[newPage.id]: {
status: "saved",
message: "已保存",
lastSyncedAt: new Date().toISOString(),
},
},
}));
}
} catch (e) {
@@ -89,6 +207,7 @@ export const useEditorStore = create<EditorState>()(
updatePage: async (id, data) => {
const prevPages = get().pages;
const current = prevPages.find((p) => p.id === id);
const isContentOnlyUpdate = data.content !== undefined && Object.keys(data).every((key) => key === "content");
if (current && (data.content !== undefined || data.title !== undefined)) {
capturePageSnapshot({
@@ -102,19 +221,90 @@ export const useEditorStore = create<EditorState>()(
pages: state.pages.map((p) => (p.id === id ? { ...p, ...data, updatedAt: new Date().toISOString() } : p)),
}));
if (isContentOnlyUpdate) {
pendingContentPayloads.set(id, {
content: data.content ?? "",
attempts: pendingContentPayloads.get(id)?.attempts ?? 0,
});
setPageSyncMeta(set, id, { status: "pending", message: "本地已保存,待同步" });
scheduleContentSync(id, CONTENT_SYNC_DEBOUNCE_MS, set);
return;
}
const pendingContent = pendingContentPayloads.get(id);
clearPendingContentSync(id);
const payloadToSend: Partial<Page> =
pendingContent && data.content === undefined
? { ...data, content: pendingContent.content }
: data;
setPageSyncMeta(set, id, { status: "saving", message: "保存中..." });
try {
const res = await fetch(`/api/pages/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
body: JSON.stringify(payloadToSend),
});
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(`Update failed: ${res.status}${errorText ? ` - ${errorText}` : ""}`);
}
setPageSyncMeta(set, id, {
status: "saved",
message: "已保存",
lastSyncedAt: new Date().toISOString(),
});
} catch (e) {
console.error("Failed to update page", e);
set({ pages: prevPages });
setPageSyncMeta(set, id, {
status: "error",
message: "保存失败",
});
}
},
retryPageSync: async (id) => {
const pending = pendingContentPayloads.get(id);
if (!pending) {
const page = get().pages.find((p) => p.id === id);
if (!page) return;
pendingContentPayloads.set(id, { content: page.content || "", attempts: 0 });
} else {
pending.attempts = 0;
pendingContentPayloads.set(id, pending);
}
setPageSyncMeta(set, id, { status: "pending", message: "正在尝试同步..." });
scheduleContentSync(id, 80, set);
},
retryPendingSyncs: async () => {
const ids = Array.from(pendingContentPayloads.keys());
await Promise.all(ids.map((id) => get().retryPageSync(id)));
},
flushPendingSyncs: () => {
const entries = Array.from(pendingContentPayloads.entries());
for (const [id, payload] of entries) {
const timer = pendingContentTimers.get(id);
if (timer) {
clearTimeout(timer);
pendingContentTimers.delete(id);
}
// Best-effort write on page hide/unload.
fetch(`/api/pages/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: payload.content }),
keepalive: true,
}).catch((e) => {
console.error("Failed to flush page sync", e);
});
}
},
@@ -126,6 +316,9 @@ export const useEditorStore = create<EditorState>()(
const prevPages = get().pages;
const prevActive = get().activePageId;
clearPendingContentSync(id);
removePageSyncMeta(set, id);
set((state) => ({
pages: state.pages.filter((p) => p.id !== id),
activePageId: prevActive === id ? null : prevActive,