增加了双向链接和全局搜索
This commit is contained in:
@@ -1,2 +0,0 @@
|
||||
⚠ Cross origin request detected from 127.0.0.1 to /_next/* resource. In a future major version of Next.js, you will need to explicitly configure "allowedDevOrigins" in next.config to allow this.
|
||||
Read more: https://nextjs.org/docs/app/api-reference/config/next-config-js/allowedDevOrigins
|
||||
@@ -1,72 +0,0 @@
|
||||
|
||||
> note-ai@1.0.0105 dev
|
||||
> next dev -p 3001
|
||||
|
||||
▲ Next.js 16.1.1 (Turbopack)
|
||||
- Local: http://localhost:3001
|
||||
- Network: http://198.18.0.1:3001
|
||||
- Environments: .env
|
||||
|
||||
✓ Starting...
|
||||
✓ Ready in 1339ms
|
||||
GET / 200 in 2.5s (compile: 1896ms, proxy.ts: 145ms, render: 479ms)
|
||||
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
|
||||
GET /api/pages 200 in 164ms (compile: 104ms, proxy.ts: 9ms, render: 51ms)
|
||||
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
|
||||
GET /api/pages 200 in 32ms (compile: 3ms, proxy.ts: 6ms, render: 22ms)
|
||||
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
|
||||
GET /api/pages 200 in 29ms (compile: 3ms, proxy.ts: 4ms, render: 21ms)
|
||||
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
|
||||
GET /api/pages 200 in 29ms (compile: 3ms, proxy.ts: 5ms, render: 21ms)
|
||||
prisma:query SELECT `main`.`Page`.`id` FROM `main`.`Page` WHERE (`main`.`Page`.`id` = ? AND 1=1) LIMIT ? OFFSET ?
|
||||
prisma:query UPDATE `main`.`Page` SET `content` = ?, `updatedAt` = ? WHERE (`main`.`Page`.`id` = ? AND 1=1) RETURNING `id` AS `id`, `title` AS `title`, `content` AS `content`, `tags` AS `tags`, `icon` AS `icon`, `type` AS `type`, `parentId` AS `parentId`, `createdAt` AS `createdAt`, `updatedAt` AS `updatedAt`, `order` AS `order`, `isLocked` AS `isLocked`
|
||||
PUT /api/pages/d039b54a-ab1c-4341-a14a-70b1b1786118 200 in 898ms (compile: 865ms, proxy.ts: 8ms, render: 25ms)
|
||||
GET /settings 200 in 208ms (compile: 182ms, proxy.ts: 5ms, render: 21ms)
|
||||
prisma:query SELECT 1
|
||||
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
|
||||
GET /api/pages 200 in 42ms (compile: 7ms, proxy.ts: 14ms, render: 21ms)
|
||||
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
|
||||
GET /api/pages 200 in 31ms (compile: 4ms, proxy.ts: 10ms, render: 17ms)
|
||||
✓ Compiled in 180ms
|
||||
GET /settings 200 in 116ms (compile: 64ms, proxy.ts: 5ms, render: 47ms)
|
||||
GET /settings 200 in 38ms (compile: 4ms, proxy.ts: 5ms, render: 29ms)
|
||||
✓ Compiled in 135ms
|
||||
GET /settings 200 in 200ms (compile: 61ms, proxy.ts: 4ms, render: 135ms)
|
||||
prisma:query SELECT 1
|
||||
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
|
||||
GET /api/pages 200 in 38ms (compile: 4ms, proxy.ts: 8ms, render: 25ms)
|
||||
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
|
||||
GET /api/pages 200 in 23ms (compile: 3ms, proxy.ts: 9ms, render: 11ms)
|
||||
✓ Compiled in 100ms
|
||||
prisma:query SELECT 1
|
||||
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
|
||||
GET /api/pages 200 in 20ms (compile: 3ms, proxy.ts: 5ms, render: 12ms)
|
||||
GET /settings 200 in 92ms (compile: 69ms, proxy.ts: 6ms, render: 17ms)
|
||||
GET / 200 in 129ms (compile: 101ms, proxy.ts: 6ms, render: 22ms)
|
||||
prisma:query SELECT 1
|
||||
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
|
||||
GET /api/pages 200 in 36ms (compile: 10ms, proxy.ts: 13ms, render: 14ms)
|
||||
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
|
||||
GET /api/pages 200 in 28ms (compile: 7ms, proxy.ts: 11ms, render: 10ms)
|
||||
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
|
||||
GET /api/pages 200 in 25ms (compile: 5ms, proxy.ts: 9ms, render: 11ms)
|
||||
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
|
||||
GET /api/pages 200 in 18ms (compile: 3ms, proxy.ts: 7ms, render: 8ms)
|
||||
✓ Compiled in 455ms
|
||||
✓ Compiled in 121ms
|
||||
✓ Compiled in 87ms
|
||||
GET /settings 200 in 76ms (compile: 57ms, proxy.ts: 5ms, render: 15ms)
|
||||
prisma:query SELECT 1
|
||||
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
|
||||
GET /api/pages 200 in 40ms (compile: 4ms, proxy.ts: 11ms, render: 26ms)
|
||||
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
|
||||
GET /api/pages 200 in 38ms (compile: 5ms, proxy.ts: 8ms, render: 25ms)
|
||||
GET / 200 in 93ms (compile: 73ms, proxy.ts: 4ms, render: 17ms)
|
||||
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
|
||||
GET /api/pages 200 in 22ms (compile: 3ms, proxy.ts: 7ms, render: 12ms)
|
||||
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
|
||||
GET /api/pages 200 in 20ms (compile: 6ms, proxy.ts: 6ms, render: 8ms)
|
||||
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
|
||||
GET /api/pages 200 in 29ms (compile: 1969µs, proxy.ts: 4ms, render: 22ms)
|
||||
prisma:query SELECT `main`.`Page`.`id`, `main`.`Page`.`title`, `main`.`Page`.`content`, `main`.`Page`.`tags`, `main`.`Page`.`icon`, `main`.`Page`.`type`, `main`.`Page`.`parentId`, `main`.`Page`.`createdAt`, `main`.`Page`.`updatedAt`, `main`.`Page`.`order`, `main`.`Page`.`isLocked` FROM `main`.`Page` WHERE 1=1 ORDER BY `main`.`Page`.`order` ASC, `main`.`Page`.`createdAt` DESC LIMIT ? OFFSET ?
|
||||
GET /api/pages 200 in 28ms (compile: 1741µs, proxy.ts: 4ms, render: 22ms)
|
||||
@@ -29,6 +29,10 @@ npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
*.log
|
||||
*.err.log
|
||||
.dev*.log
|
||||
.dev*.err.log
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
Binary file not shown.
+146
-2
@@ -3,7 +3,7 @@
|
||||
import { SidebarContent, ResizableSidebar } from "@/components/sidebar";
|
||||
import { Editor } from "@/components/editor";
|
||||
import { useEditorStore } from "@/lib/store";
|
||||
import { Hash, X, Plus, ChevronLeft, ChevronRight, Sparkles, Lock, Unlock, FolderOpen } from "lucide-react";
|
||||
import { Hash, X, Plus, ChevronLeft, ChevronRight, Sparkles, Lock, Unlock, FolderOpen, History, RotateCcw } from "lucide-react";
|
||||
import { useSettingsStore } from "@/lib/settings-store";
|
||||
import { exportPageAsMarkdown } from "@/lib/export";
|
||||
import { useMemo, useState } from "react";
|
||||
@@ -12,6 +12,8 @@ import { useSearchStore } from "@/lib/search-store";
|
||||
import { type Editor as TiptapEditor } from "@tiptap/react";
|
||||
import { ImportProvider } from "@/components/import-context";
|
||||
import { AIChatPanel } from "@/components/chat/ai-chat-panel";
|
||||
import { getBacklinks, getOutgoingLinks, getUnresolvedLinkTitles } from "@/lib/wiki-links";
|
||||
import { getPageHistory } from "@/lib/page-history";
|
||||
|
||||
const ICONS = [
|
||||
"📄", "📝", "📒", "📘", "📙", "📕", "📗", "📚",
|
||||
@@ -22,7 +24,7 @@ const ICONS = [
|
||||
];
|
||||
|
||||
export default function Home() {
|
||||
const { activePageId, pages, updatePage } = useEditorStore();
|
||||
const { activePageId, pages, updatePage, setActivePageId, addPage } = useEditorStore();
|
||||
const { openSearchWithTag } = useSearchStore();
|
||||
const { timezone } = useSettingsStore();
|
||||
|
||||
@@ -47,6 +49,26 @@ export default function Home() {
|
||||
return result;
|
||||
}, [activePage, pages]);
|
||||
|
||||
const outgoingLinks = useMemo(() => {
|
||||
if (!activePage || activePage.type !== "file") return [];
|
||||
return getOutgoingLinks(activePage, pages);
|
||||
}, [activePage, pages]);
|
||||
|
||||
const backlinks = useMemo(() => {
|
||||
if (!activePage || activePage.type !== "file") return [];
|
||||
return getBacklinks(activePage, pages);
|
||||
}, [activePage, pages]);
|
||||
|
||||
const unresolvedLinks = useMemo(() => {
|
||||
if (!activePage || activePage.type !== "file") return [];
|
||||
return getUnresolvedLinkTitles(activePage, pages);
|
||||
}, [activePage, pages]);
|
||||
|
||||
const localHistory = useMemo(() => {
|
||||
if (!activePage || activePage.type !== "file") return [];
|
||||
return getPageHistory(activePage.id);
|
||||
}, [activePage]);
|
||||
|
||||
const addTag = () => {
|
||||
if (!activePage) return;
|
||||
const next = tagInput.trim();
|
||||
@@ -57,6 +79,17 @@ export default function Home() {
|
||||
setIsAddingTag(false);
|
||||
};
|
||||
|
||||
const handleOpenWikiLink = async (title: string) => {
|
||||
const target = pages.find((p) => p.type === "file" && (p.title || "").trim() === title);
|
||||
if (target) {
|
||||
setActivePageId(target.id);
|
||||
return;
|
||||
}
|
||||
const shouldCreate = window.confirm(`未找到页面“${title}”。是否立即创建?`);
|
||||
if (!shouldCreate) return;
|
||||
await addPage(null, "file", { title, content: "" });
|
||||
};
|
||||
|
||||
return (
|
||||
<ImportProvider>
|
||||
<div className="relative flex h-[100dvh] w-full overflow-hidden bg-background">
|
||||
@@ -250,8 +283,119 @@ export default function Home() {
|
||||
onEditorReady={setEditor}
|
||||
onToggleAI={() => setIsChatOpen(!isChatOpen)}
|
||||
onExport={() => exportPageAsMarkdown(activePage)}
|
||||
onOpenWikiLink={handleOpenWikiLink}
|
||||
editable={!activePage.isLocked}
|
||||
/>
|
||||
|
||||
<section className="ui-enter mt-8 space-y-4">
|
||||
<div className="ui-card p-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">关联页面</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">在正文中使用 `[[页面名]]` 自动建立链接关系,支持 `Ctrl/Cmd + 点击` 跳转。</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{outgoingLinks.length > 0 ? (
|
||||
outgoingLinks.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setActivePageId(item.id)}
|
||||
className="rounded-md border border-border/70 bg-muted/40 px-2.5 py-1 text-xs text-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
{item.icon ? `${item.icon} ` : ""}
|
||||
{item.title}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">暂无关联页面</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ui-card p-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">反向链接</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">这些页面提到了当前页面。</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{backlinks.length > 0 ? (
|
||||
backlinks.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setActivePageId(item.id)}
|
||||
className="rounded-md border border-border/70 bg-muted/40 px-2.5 py-1 text-xs text-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
{item.icon ? `${item.icon} ` : ""}
|
||||
{item.title}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">暂无反向链接</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ui-card p-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">未解析链接</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">这些 `[[页面名]]` 还没有对应页面,可一键创建。</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{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.5 py-1 text-xs text-foreground transition-colors hover:bg-accent"
|
||||
title={`创建页面:${title}`}
|
||||
>
|
||||
+ {title}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">全部链接已解析</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ui-card p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<History size={14} className="text-muted-foreground" />
|
||||
<h3 className="text-sm font-semibold text-foreground">本地历史版本</h3>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">自动保存最近修改快照(当前浏览器本地)。</p>
|
||||
<div className="mt-3 space-y-2">
|
||||
{localHistory.length > 0 ? (
|
||||
localHistory.slice(0, 8).map((snapshot, index) => (
|
||||
<div key={`${snapshot.timestamp}-${index}`} className="flex items-center justify-between rounded-md border border-border/60 bg-muted/20 px-2.5 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs text-foreground">
|
||||
{snapshot.title || "无标题"}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{new Date(snapshot.timestamp).toLocaleString("zh-CN", {
|
||||
timeZone: timezone || "Asia/Shanghai",
|
||||
hour12: false,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const ok = window.confirm("恢复该历史版本后将覆盖当前内容,是否继续?");
|
||||
if (!ok || !activePage) return;
|
||||
await updatePage(activePage.id, {
|
||||
title: snapshot.title,
|
||||
content: snapshot.content,
|
||||
});
|
||||
}}
|
||||
className="ui-btn-secondary px-2 py-1 text-xs"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
恢复
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">暂无历史记录</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -37,10 +37,31 @@ interface EditorProps {
|
||||
onEditorReady?: (editor: TiptapEditor) => void;
|
||||
onToggleAI?: () => void;
|
||||
onExport?: () => void;
|
||||
onOpenWikiLink?: (title: string) => void;
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, editable = true }: EditorProps) {
|
||||
function resolveWikiLinkAtPos(doc: TiptapEditor["state"]["doc"], pos: number): string | null {
|
||||
const $pos = doc.resolve(pos);
|
||||
const start = $pos.start($pos.depth);
|
||||
const end = $pos.end($pos.depth);
|
||||
const text = doc.textBetween(start, end, "\n");
|
||||
const offset = pos - start;
|
||||
const re = /\[\[([^[\]]+)\]\]/g;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
const from = match.index;
|
||||
const to = from + match[0].length;
|
||||
if (offset >= from && offset <= to) {
|
||||
const title = (match[1] || "").trim();
|
||||
return title || null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, onOpenWikiLink, editable = true }: EditorProps) {
|
||||
const [showAI, setShowAI] = useState(false);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
@@ -130,6 +151,18 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport,
|
||||
style: `font-family: ${fontFamily}; font-size: ${fontSize}px; --editor-line-height: ${lineHeight}; --table-line-height: ${tableLineHeight};`,
|
||||
spellcheck: "false",
|
||||
},
|
||||
handleClick: (view, pos, event) => {
|
||||
if (!onOpenWikiLink) return false;
|
||||
const pointerEvent = event as MouseEvent;
|
||||
if (!pointerEvent.metaKey && !pointerEvent.ctrlKey) return false;
|
||||
|
||||
const title = resolveWikiLinkAtPos(view.state.doc, pos);
|
||||
if (!title) return false;
|
||||
|
||||
pointerEvent.preventDefault();
|
||||
onOpenWikiLink(title);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
editable,
|
||||
immediatelyRender: false,
|
||||
|
||||
@@ -1,14 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchStore } from "@/lib/search-store";
|
||||
import { useEffect } from "react";
|
||||
import { Fragment, useEffect, useMemo, useState } from "react";
|
||||
import { useEditorStore } from "@/lib/store";
|
||||
import { Command } from "cmdk";
|
||||
import { Search, FileText, Folder } from "lucide-react";
|
||||
import { filterPagesByQuery, parseSearchQuery } from "@/lib/search-query";
|
||||
|
||||
const RECENT_PAGE_IDS_KEY = "noteai-recent-page-ids";
|
||||
const MAX_RECENT_PAGES = 30;
|
||||
|
||||
function escapeRegExp(input: string): string {
|
||||
return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function highlightText(text: string, terms: string[]) {
|
||||
if (!text || terms.length === 0) return text;
|
||||
const normalized = Array.from(new Set(terms.filter(Boolean).map((t) => t.toLowerCase())));
|
||||
if (normalized.length === 0) return text;
|
||||
|
||||
const pattern = normalized.map(escapeRegExp).join("|");
|
||||
const regex = new RegExp(`(${pattern})`, "ig");
|
||||
const parts = text.split(regex);
|
||||
|
||||
return parts.map((part, idx) => {
|
||||
const matched = normalized.includes(part.toLowerCase());
|
||||
return matched ? (
|
||||
<mark key={`${part}-${idx}`} className="rounded bg-yellow-200/70 px-0.5 text-foreground dark:bg-yellow-500/30">
|
||||
{part}
|
||||
</mark>
|
||||
) : (
|
||||
<Fragment key={`${part}-${idx}`}>{part}</Fragment>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function SearchCommand() {
|
||||
const { isOpen, setOpen, query, setQuery } = useSearchStore();
|
||||
const { pages, setActivePageId } = useEditorStore();
|
||||
const [recentPageIds, setRecentPageIds] = useState<string[]>(() => {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(RECENT_PAGE_IDS_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === "string") : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const parsedQuery = useMemo(() => parseSearchQuery(query), [query]);
|
||||
const filteredPages = useMemo(() => {
|
||||
const items = filterPagesByQuery(pages, query);
|
||||
const recentRank = new Map(recentPageIds.map((id, idx) => [id, idx]));
|
||||
|
||||
return [...items].sort((a, b) => {
|
||||
const ra = recentRank.has(a.id) ? (recentRank.get(a.id) as number) : Number.MAX_SAFE_INTEGER;
|
||||
const rb = recentRank.has(b.id) ? (recentRank.get(b.id) as number) : Number.MAX_SAFE_INTEGER;
|
||||
if (ra !== rb) return ra - rb;
|
||||
const ta = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
|
||||
const tb = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
|
||||
return tb - ta;
|
||||
});
|
||||
}, [pages, query, recentPageIds]);
|
||||
|
||||
useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
@@ -27,34 +82,57 @@ export function SearchCommand() {
|
||||
command();
|
||||
};
|
||||
|
||||
const rememberVisitedPage = (pageId: string) => {
|
||||
setRecentPageIds((prev) => {
|
||||
const next = [pageId, ...prev.filter((id) => id !== pageId)].slice(0, MAX_RECENT_PAGES);
|
||||
try {
|
||||
localStorage.setItem(RECENT_PAGE_IDS_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
// ignore storage failures
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/70 p-4 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div className="fixed inset-0" onClick={() => setOpen(false)} />
|
||||
<div className="ui-card relative w-full max-w-lg overflow-hidden shadow-2xl animate-in zoom-in-95 duration-200">
|
||||
<Command className="w-full">
|
||||
<Command className="w-full" shouldFilter={false}>
|
||||
<div className="flex items-center border-b border-border/70 px-3">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<Command.Input
|
||||
placeholder="搜索文档或标签..."
|
||||
placeholder="搜索... 支持 tag:项目 type:file updated:7d"
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
className="flex h-12 w-full bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="border-b border-border/50 px-3 py-2 text-[11px] text-muted-foreground">
|
||||
语法:`tag:标签` `type:file|folder` `updated:7d`
|
||||
</div>
|
||||
<Command.List className="max-h-[320px] overflow-y-auto p-2">
|
||||
<Command.Empty className="py-6 text-center text-sm text-muted-foreground">未找到匹配结果</Command.Empty>
|
||||
<Command.Group heading="文档">
|
||||
{pages.map((page) => (
|
||||
{filteredPages.map((page) => (
|
||||
<Command.Item
|
||||
key={page.id}
|
||||
value={`${page.title} ${(page.tags || []).join(" ")}`}
|
||||
onSelect={() => runCommand(() => setActivePageId(page.id))}
|
||||
onSelect={() =>
|
||||
runCommand(() => {
|
||||
setActivePageId(page.id);
|
||||
rememberVisitedPage(page.id);
|
||||
})
|
||||
}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-accent hover:text-accent-foreground aria-selected:bg-accent aria-selected:text-accent-foreground"
|
||||
>
|
||||
{page.type === "folder" ? <Folder size={14} className="text-blue-500" /> : <FileText size={14} />}
|
||||
<span>{page.title || "无标题"}</span>
|
||||
<span>{highlightText(page.title || "无标题", parsedQuery.textTerms)}</span>
|
||||
{page.tags && page.tags.length > 0 && (
|
||||
<span className="ml-auto truncate text-[10px] text-muted-foreground">{page.tags.slice(0, 2).join(" · ")}</span>
|
||||
)}
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.Group>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Page } from "@/lib/store";
|
||||
|
||||
const MAX_SNAPSHOTS_PER_PAGE = 30;
|
||||
const MIN_SNAPSHOT_INTERVAL_MS = 15_000;
|
||||
|
||||
export type PageSnapshot = {
|
||||
title: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
function getKey(pageId: string): string {
|
||||
return `noteai:history:${pageId}`;
|
||||
}
|
||||
|
||||
function canUseStorage(): boolean {
|
||||
return typeof window !== "undefined" && typeof localStorage !== "undefined";
|
||||
}
|
||||
|
||||
export function getPageHistory(pageId: string): PageSnapshot[] {
|
||||
if (!canUseStorage()) return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(getKey(pageId));
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter(
|
||||
(item): item is PageSnapshot =>
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
typeof item.title === "string" &&
|
||||
typeof item.content === "string" &&
|
||||
typeof item.timestamp === "string"
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function capturePageSnapshot(page: Pick<Page, "id" | "title" | "content">): void {
|
||||
if (!canUseStorage()) return;
|
||||
const history = getPageHistory(page.id);
|
||||
const latest = history[0];
|
||||
|
||||
if (latest && latest.title === page.title && latest.content === page.content) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (latest) {
|
||||
const gap = Date.now() - new Date(latest.timestamp).getTime();
|
||||
if (!Number.isNaN(gap) && gap < MIN_SNAPSHOT_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const next: PageSnapshot[] = [
|
||||
{
|
||||
title: page.title || "无标题",
|
||||
content: page.content || "",
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
...history,
|
||||
].slice(0, MAX_SNAPSHOTS_PER_PAGE);
|
||||
|
||||
try {
|
||||
localStorage.setItem(getKey(page.id), JSON.stringify(next));
|
||||
} catch {
|
||||
// Ignore storage errors.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { Page } from "@/lib/store";
|
||||
|
||||
type ParsedQuery = {
|
||||
textTerms: string[];
|
||||
tagTerms: string[];
|
||||
typeTerm: "file" | "folder" | null;
|
||||
updatedDays: number | null;
|
||||
};
|
||||
|
||||
function stripHtml(input: string): string {
|
||||
return (input || "").replace(/<[^>]+>/g, " ");
|
||||
}
|
||||
|
||||
export function parseSearchQuery(rawQuery: string): ParsedQuery {
|
||||
const tokens = rawQuery
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
|
||||
const parsed: ParsedQuery = {
|
||||
textTerms: [],
|
||||
tagTerms: [],
|
||||
typeTerm: null,
|
||||
updatedDays: null,
|
||||
};
|
||||
|
||||
for (const token of tokens) {
|
||||
const lower = token.toLowerCase();
|
||||
if (lower.startsWith("tag:")) {
|
||||
const value = token.slice(4).trim();
|
||||
if (value) parsed.tagTerms.push(value.toLowerCase());
|
||||
continue;
|
||||
}
|
||||
if (lower.startsWith("type:")) {
|
||||
const value = token.slice(5).trim().toLowerCase();
|
||||
if (value === "file" || value === "folder") parsed.typeTerm = value;
|
||||
continue;
|
||||
}
|
||||
if (lower.startsWith("updated:")) {
|
||||
const value = token.slice(8).trim().toLowerCase();
|
||||
const match = /^(\d+)d$/.exec(value);
|
||||
if (match) {
|
||||
parsed.updatedDays = Number.parseInt(match[1], 10);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
parsed.textTerms.push(lower);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function filterPagesByQuery(pages: Page[], rawQuery: string): Page[] {
|
||||
const q = parseSearchQuery(rawQuery);
|
||||
const now = Date.now();
|
||||
|
||||
return pages.filter((page) => {
|
||||
if (q.typeTerm && page.type !== q.typeTerm) return false;
|
||||
|
||||
if (q.tagTerms.length > 0) {
|
||||
const tagSet = new Set((page.tags || []).map((t) => t.toLowerCase()));
|
||||
const allTagsMatched = q.tagTerms.every((t) => tagSet.has(t));
|
||||
if (!allTagsMatched) return false;
|
||||
}
|
||||
|
||||
if (q.updatedDays !== null) {
|
||||
if (!page.updatedAt) return false;
|
||||
const diffMs = now - new Date(page.updatedAt).getTime();
|
||||
const maxMs = q.updatedDays * 24 * 60 * 60 * 1000;
|
||||
if (Number.isNaN(diffMs) || diffMs > maxMs) return false;
|
||||
}
|
||||
|
||||
if (q.textTerms.length > 0) {
|
||||
const haystack = [
|
||||
page.title || "",
|
||||
stripHtml(page.content || ""),
|
||||
...(page.tags || []),
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
const allTermsMatched = q.textTerms.every((term) => haystack.includes(term));
|
||||
if (!allTermsMatched) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,5 +13,5 @@ export const useSearchStore = create<SearchState>((set) => ({
|
||||
query: "",
|
||||
setOpen: (isOpen) => set({ isOpen }),
|
||||
setQuery: (query) => set({ query }),
|
||||
openSearchWithTag: (tag) => set({ isOpen: true, query: tag }),
|
||||
openSearchWithTag: (tag) => set({ isOpen: true, query: `tag:${tag}` }),
|
||||
}));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { capturePageSnapshot } from "@/lib/page-history";
|
||||
|
||||
export interface Page {
|
||||
id: string;
|
||||
@@ -87,6 +88,15 @@ export const useEditorStore = create<EditorState>()(
|
||||
|
||||
updatePage: async (id, data) => {
|
||||
const prevPages = get().pages;
|
||||
const current = prevPages.find((p) => p.id === id);
|
||||
|
||||
if (current && (data.content !== undefined || data.title !== undefined)) {
|
||||
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)),
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Page } from "@/lib/store";
|
||||
|
||||
const WIKI_LINK_RE = /\[\[([^[\]]+)\]\]/g;
|
||||
|
||||
function stripHtml(input: string): string {
|
||||
return input.replace(/<[^>]+>/g, " ");
|
||||
}
|
||||
|
||||
export function extractWikiLinkTitles(content: string): string[] {
|
||||
const text = stripHtml(content || "");
|
||||
const result: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = WIKI_LINK_RE.exec(text)) !== null) {
|
||||
const title = (match[1] || "").trim();
|
||||
if (!title || seen.has(title)) continue;
|
||||
seen.add(title);
|
||||
result.push(title);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getOutgoingLinks(page: Page, pages: Page[]): Page[] {
|
||||
const titles = extractWikiLinkTitles(page.content || "");
|
||||
if (titles.length === 0) return [];
|
||||
const titleSet = new Set(titles);
|
||||
return pages.filter((p) => p.id !== page.id && p.type === "file" && titleSet.has((p.title || "").trim()));
|
||||
}
|
||||
|
||||
export function getUnresolvedLinkTitles(page: Page, pages: Page[]): string[] {
|
||||
const titles = extractWikiLinkTitles(page.content || "");
|
||||
if (titles.length === 0) return [];
|
||||
const existing = new Set(
|
||||
pages
|
||||
.filter((p) => p.type === "file")
|
||||
.map((p) => (p.title || "").trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
return titles.filter((title) => !existing.has(title));
|
||||
}
|
||||
|
||||
export function getBacklinks(target: Page, pages: Page[]): Page[] {
|
||||
const targetTitle = (target.title || "").trim();
|
||||
if (!targetTitle) return [];
|
||||
return pages.filter((p) => {
|
||||
if (p.id === target.id || p.type !== "file") return false;
|
||||
const titles = extractWikiLinkTitles(p.content || "");
|
||||
return titles.includes(targetTitle);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user