From eb68553bafa5a2c5fe5613eba14cd2bb148483c2 Mon Sep 17 00:00:00 2001 From: cniucheng Date: Thu, 26 Feb 2026 11:07:10 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- prisma/dev.db | Bin 196608 -> 196608 bytes src/app/api/settings/init/route.ts | 7 - src/app/api/settings/status/route.ts | 15 ++ src/app/login/page.tsx | 200 +++++++++++++++++++++------ src/app/page.tsx | 59 ++++---- src/components/editor.tsx | 17 +-- src/components/import-context.tsx | 195 +++++++++++++++----------- src/lib/prisma.ts | 2 +- src/proxy.ts | 2 +- 9 files changed, 329 insertions(+), 168 deletions(-) create mode 100644 src/app/api/settings/status/route.ts diff --git a/prisma/dev.db b/prisma/dev.db index 1cbb1bb2f47c5f2e42bf2b6b40d7a4c03e8292ac..8aa2a7d7a84b245787f12d05aec806fff5401f51 100644 GIT binary patch delta 18155 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 delta 637 zcmXw#Uq}=|7{F&|_w25n-97cRE{#qz@}TMV_V#a>UWHLTlw9~!f_uBOQHtF`Zv|dN zKBVHwK?@ZmKJ=K$ft#Qq<%>|@TlJ6+L1Z8(gb_i|T+s}_fsgN-AK&+tN~lyq(nskV6r9D;0s+M-J zs^|{R6Ia7_UNsEW(DSNnHAr(HXSkYaYOabsM>er46BpaMVPo0I%a%uE-SOOdT9}j- z!z8w8V3Rm1)v6lKD-OXH@zR=>aXr&?qMd9%qhvh)@Ca{a?ctpUf|7?yq5p3m{nBXs8g37`Q`E27y9&>CVDt~={jKi+ diff --git a/src/app/api/settings/init/route.ts b/src/app/api/settings/init/route.ts index bb5db50..298970f 100644 --- a/src/app/api/settings/init/route.ts +++ b/src/app/api/settings/init/route.ts @@ -22,13 +22,6 @@ export async function POST(req: Request) { } const initToken = process.env.INIT_SETUP_TOKEN; - const isProduction = process.env.NODE_ENV === "production"; - if (isProduction && !initToken) { - return NextResponse.json( - { error: "Server misconfigured: INIT_SETUP_TOKEN is required in production." }, - { status: 500 } - ); - } if (initToken) { const providedToken = req.headers.get("x-init-token"); diff --git a/src/app/api/settings/status/route.ts b/src/app/api/settings/status/route.ts new file mode 100644 index 0000000..c40ee64 --- /dev/null +++ b/src/app/api/settings/status/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export async function GET() { + try { + const settings = await prisma.globalSettings.findUnique({ + where: { id: "default" }, + select: { id: true }, + }); + return NextResponse.json({ initialized: Boolean(settings) }); + } catch { + return NextResponse.json({ initialized: false, error: "Failed to check settings status" }, { status: 500 }); + } +} + diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index eee5d14..1e0a030 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,19 +1,42 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { Lock, Sparkles } from "lucide-react"; export default function LoginPage() { const [password, setPassword] = useState(""); + const [initPassword, setInitPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); const [error, setError] = useState(""); + const [isInitialized, setIsInitialized] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); const [rememberMe, setRememberMe] = useState(false); const [duration, setDuration] = useState("1"); const router = useRouter(); + useEffect(() => { + const loadStatus = async () => { + try { + const res = await fetch("/api/settings/status", { cache: "no-store" }); + if (!res.ok) { + setIsInitialized(true); + return; + } + const data = await res.json(); + setIsInitialized(Boolean(data.initialized)); + } catch { + setIsInitialized(true); + } + }; + loadStatus(); + }, []); + const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); + if (isSubmitting) return; setError(""); + setIsSubmitting(true); try { const res = await fetch("/api/auth/login", { @@ -34,9 +57,72 @@ export default function LoginPage() { } } catch { setError("发生错误,请重试"); + } finally { + setIsSubmitting(false); } }; + const handleInitialize = async (e: React.FormEvent) => { + e.preventDefault(); + if (isSubmitting) return; + setError(""); + + if (initPassword.length < 6) { + setError("密码至少 6 位"); + return; + } + + if (initPassword !== confirmPassword) { + setError("两次输入的密码不一致"); + return; + } + + setIsSubmitting(true); + try { + const initRes = await fetch("/api/settings/init", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password: initPassword }), + }); + const initData = await initRes.json().catch(() => ({})); + if (!initRes.ok) { + setError(initData.error || "初始化失败"); + return; + } + + const loginRes = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + password: initPassword, + rememberMe: false, + durationDays: 1, + }), + }); + const loginData = await loginRes.json().catch(() => ({})); + if (!loginRes.ok) { + setError(loginData.error || "初始化成功,但自动登录失败,请手动登录"); + setIsInitialized(true); + setPassword(initPassword); + return; + } + + router.push("/"); + } catch { + setError("初始化失败,请重试"); + } finally { + setIsSubmitting(false); + } + }; + + if (isInitialized === null) { + return ( +
+
加载中...
+
+ ); + } + return (
@@ -44,61 +130,85 @@ export default function LoginPage() {
-

欢迎回来

-

请输入访问密码以继续。

+

{isInitialized ? "欢迎回来" : "首次初始化"}

+

{isInitialized ? "请输入访问密码以继续。" : "请先设置访问密码,完成后将自动登录。"}

-
+
-
- - setPassword(e.target.value)} - className="ui-input pl-10 pr-4 py-3" - required - /> -
+ {isInitialized ? ( +
+ + setPassword(e.target.value)} + className="ui-input pl-10 pr-4 py-3" + required + /> +
+ ) : ( + <> +
+ + setInitPassword(e.target.value)} + className="ui-input pl-10 pr-4 py-3" + required + /> +
+
+ + setConfirmPassword(e.target.value)} + className="ui-input pl-10 pr-4 py-3" + required + /> +
+ + )} {error &&

{error}

}
-
- + {isInitialized && ( +
+ - {rememberMe && ( - - )} -
+ {rememberMe && ( + + )} +
+ )} -
-
- NoteAI - 你的私人第二大脑 -
+
NoteAI - 你的私人第二大脑
); diff --git a/src/app/page.tsx b/src/app/page.tsx index d50f74e..8741101 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -293,53 +293,63 @@ export default function Home() { editable={!activePage.isLocked} /> -
-
-

关联页面

-

在正文中使用 `[[页面名]]` 自动建立链接关系,支持 `Ctrl/Cmd + 点击` 跳转。

-
+
+
+
+
+

关联页面

+ {outgoingLinks.length} +
+

正文写 `[[页面名]]` 可自动关联。

+
{outgoingLinks.length > 0 ? ( outgoingLinks.map((item) => ( )) ) : ( - 暂无关联页面 + 暂无 )}
-
+
-
-

反向链接

-

这些页面提到了当前页面。

-
+
+
+

反向链接

+ {backlinks.length} +
+

被哪些页面引用。

+
{backlinks.length > 0 ? ( backlinks.map((item) => ( )) ) : ( - 暂无反向链接 + 暂无 )}
-
+
-
-

未解析链接

-

这些 `[[页面名]]` 还没有对应页面,可一键创建。

-
+
+
+

未解析链接

+ {unresolvedLinks.length} +
+

可一键创建缺失页面。

+
{unresolvedLinks.length > 0 ? ( unresolvedLinks.map((title) => ( )) ) : ( - 全部链接已解析 + 全部已解析 )}
+
-
+
-

自动保存最近修改快照(当前浏览器本地)。

+

自动保存最近修改快照(当前浏览器本地)。

{isHistoryOpen && ( -
+
{localHistory.length > 0 ? ( <>
diff --git a/src/components/editor.tsx b/src/components/editor.tsx index 6f7afee..111b0c7 100644 --- a/src/components/editor.tsx +++ b/src/components/editor.tsx @@ -27,8 +27,6 @@ import { SlashCommand, getSuggestionItems, renderSuggestionItems } from "./edito import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; import { lowlight } from "lowlight"; import { CodeBlockComponent } from "./editor/code-block"; -import Link from "@tiptap/extension-link"; -import Underline from "@tiptap/extension-underline"; import Subscript from "@tiptap/extension-subscript"; import Superscript from "@tiptap/extension-superscript"; import Highlight from "@tiptap/extension-highlight"; @@ -45,7 +43,6 @@ 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 Gapcursor from "@tiptap/extension-gapcursor"; import { Markdown } from "tiptap-markdown"; import { Fragment, type Node as ProseMirrorNode, type Schema } from "@tiptap/pm/model"; import { TextSelection } from "@tiptap/pm/state"; @@ -624,11 +621,16 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, const editor = useEditor({ extensions: [ - Gapcursor, StarterKit.configure({ heading: { levels: [1, 2, 3], }, + link: { + openOnClick: false, + HTMLAttributes: { + class: "cursor-pointer text-blue-600 dark:text-blue-400 hover:underline hover:text-blue-800 dark:hover:text-blue-300 transition-colors", + }, + }, codeBlock: false, bulletList: { keepMarks: true, @@ -650,13 +652,6 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, return ReactNodeViewRenderer(CodeBlockComponent); }, }).configure({ lowlight, defaultLanguage: "plaintext" }), - Link.configure({ - openOnClick: false, - HTMLAttributes: { - class: "cursor-pointer text-blue-600 dark:text-blue-400 hover:underline hover:text-blue-800 dark:hover:text-blue-300 transition-colors", - }, - }), - Underline, Subscript, Superscript, Highlight.configure({ diff --git a/src/components/import-context.tsx b/src/components/import-context.tsx index 3cfb5b4..9438898 100644 --- a/src/components/import-context.tsx +++ b/src/components/import-context.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client"; import React, { createContext, useContext, useRef, useState, useCallback } from "react"; import { useEditorStore } from "@/lib/store"; @@ -10,6 +10,17 @@ interface ImportContextType { isImporting: boolean; } +class ImportApiError extends Error { + status: number; + detail: string; + + constructor(message: string, status: number, detail: string) { + super(message); + this.status = status; + this.detail = detail; + } +} + const ImportContext = createContext(undefined); export function ImportProvider({ children }: { children: React.ReactNode }) { @@ -18,10 +29,43 @@ export function ImportProvider({ children }: { children: React.ReactNode }) { const targetParentIdRef = useRef(null); const { fetchPages } = useEditorStore(); + const postPage = useCallback( + async ( + payload: { + title: string; + content?: string; + parentId?: string | null; + type: "file" | "folder"; + }, + contextLabel: string + ) => { + const res = await fetch("/api/pages", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + credentials: "same-origin", + }); + + if (!res.ok) { + let detail = ""; + try { + const data = await res.json(); + detail = typeof data?.error === "string" ? data.error : JSON.stringify(data); + } catch { + detail = await res.text(); + } + throw new ImportApiError(`${contextLabel}失败(${res.status})${detail ? `: ${detail}` : ""}`, res.status, detail); + } + + return res.json(); + }, + [] + ); + const triggerImport = useCallback((parentId: string | null = null) => { targetParentIdRef.current = parentId; if (fileInputRef.current) { - fileInputRef.current.value = ''; // Reset + fileInputRef.current.value = ""; fileInputRef.current.click(); } }, []); @@ -32,14 +76,15 @@ export function ImportProvider({ children }: { children: React.ReactNode }) { setIsImporting(true); try { - if (file.name.toLowerCase().endsWith('.zip')) { + if (file.name.toLowerCase().endsWith(".zip")) { await handleZipImport(file); } else { await handleSingleFileImport(file); } } catch (error) { console.error("Import failed", error); - alert("导入失败,请检查文件"); + const message = error instanceof Error ? error.message : "导入失败,请检查文件"; + alert(message); } finally { setIsImporting(false); fetchPages(); @@ -48,131 +93,123 @@ export function ImportProvider({ children }: { children: React.ReactNode }) { const handleSingleFileImport = async (file: File) => { const text = await file.text(); - const title = file.name.replace(/\.md$/i, ''); + const title = file.name.replace(/\.md$/i, ""); const html = await marked.parse(text); - await fetch('/api/pages', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + await postPage( + { title, content: html, parentId: targetParentIdRef.current, - type: 'file' - }), - }); + type: "file", + }, + "创建导入文件" + ); }; const handleZipImport = async (file: File) => { const zip = await JSZip.loadAsync(file); - // Create root folder based on zip name - // Remove .md.zip or .zip - const rootName = file.name.replace(/(\.md)?\.zip$/i, ''); + const rootName = file.name.replace(/(\.md)?\.zip$/i, ""); + let rootFolder: { id: string }; - const rootRes = await fetch('/api/pages', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - title: rootName, - parentId: targetParentIdRef.current, - type: 'folder' - }), - }); + try { + rootFolder = await postPage( + { + title: rootName, + parentId: targetParentIdRef.current, + type: "folder", + }, + "创建导入根目录" + ); + } catch (error) { + const invalidParent = + error instanceof ImportApiError && error.status === 400 && /Parent folder not found/i.test(error.detail); + if (!invalidParent || !targetParentIdRef.current) { + throw error; + } + // Parent folder may have been deleted; fallback to root level and continue import. + rootFolder = await postPage( + { + title: rootName, + parentId: null, + type: "folder", + }, + "创建导入根目录" + ); + } - if (!rootRes.ok) throw new Error("Failed to create root folder"); - const rootFolder = await rootRes.json(); const rootId = rootFolder.id; - // Collect all file entries and infer folder structure - const fileEntries: { path: string, file: JSZip.JSZipObject }[] = []; + const fileEntries: { path: string; file: JSZip.JSZipObject }[] = []; const folderPaths = new Set(); zip.forEach((relativePath, zipEntry) => { - if (relativePath.startsWith('__MACOSX') || relativePath.includes('/.') || relativePath.startsWith('.')) return; // Skip hidden/mac files + if (relativePath.startsWith("__MACOSX") || relativePath.includes("/.") || relativePath.startsWith(".")) return; if (zipEntry.dir) { - // Remove trailing slash - const cleanPath = relativePath.endsWith('/') ? relativePath.slice(0, -1) : relativePath; + const cleanPath = relativePath.endsWith("/") ? relativePath.slice(0, -1) : relativePath; if (cleanPath) folderPaths.add(cleanPath); } else { fileEntries.push({ path: relativePath, file: zipEntry }); - // Also infer parent folders for files - const parts = relativePath.split('/'); - let currentPath = ''; - for (let i = 0; i < parts.length - 1; i++) { + const parts = relativePath.split("/"); + let currentPath = ""; + for (let i = 0; i < parts.length - 1; i += 1) { currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]; folderPaths.add(currentPath); } } }); - // Map path (e.g. "folder/sub") to database ID. Empty key '' maps to rootId. const pathMap = new Map(); - pathMap.set('', rootId); + pathMap.set("", rootId); - // Sort folders by depth (length of path splits) to create parents before children - const sortedFolders = Array.from(folderPaths).sort((a, b) => { - return a.split('/').length - b.split('/').length; - }); + const sortedFolders = Array.from(folderPaths).sort((a, b) => a.split("/").length - b.split("/").length); - // Create folders sequentially for (const folderPath of sortedFolders) { - const parts = folderPath.split('/'); + const parts = folderPath.split("/"); const name = parts[parts.length - 1]; - const parentPath = parts.slice(0, -1).join('/'); - const parentId = pathMap.get(parentPath); // Should exist because we sorted by depth + const parentPath = parts.slice(0, -1).join("/"); + const parentId = pathMap.get(parentPath); if (!parentId) { console.warn(`Parent not found for ${folderPath}, skipping`); continue; } - // Check if we need to create it (users might have zip with folder/ and folder/file, avoiding dups) - // But we didn't check if it existed on server. We assume new import. - // Actually, we are just building map here. - - const res = await fetch('/api/pages', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + const folder = await postPage( + { title: name, - parentId: parentId, - type: 'folder' - }), - }); - - if (res.ok) { - const folder = await res.json(); - pathMap.set(folderPath, folder.id); - } + parentId, + type: "folder", + }, + `创建目录 ${folderPath}` + ); + pathMap.set(folderPath, folder.id); } - // Create files parallel-ish or sequential? Sequential is safer for order but parallel faster. - // Let's do batch sequential to avoid overwhelming server if huge - for (const { path, file } of fileEntries) { - const parts = path.split('/'); + for (const { path, file: zipFile } of fileEntries) { + const parts = path.split("/"); const fileName = parts.pop() || ""; - const parentPath = parts.join('/'); + const parentPath = parts.join("/"); const parentId = pathMap.get(parentPath); if (!parentId) continue; - if (!fileName.endsWith('.md') && !fileName.endsWith('.txt')) continue; // Verify extension again + if (!fileName.endsWith(".md") && !fileName.endsWith(".txt")) continue; - const contentMsg = await file.async('string'); - const title = fileName.replace(/\.md$/i, '').replace(/\.txt$/i, ''); - const html = await marked.parse(contentMsg); + const markdown = await zipFile.async("string"); + const title = fileName.replace(/\.md$/i, "").replace(/\.txt$/i, ""); + const html = await marked.parse(markdown); - await fetch('/api/pages', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + await postPage( + { title, content: html, - parentId: parentId, - type: 'file' - }), - }); + parentId, + type: "file", + }, + `创建文件 ${path}` + ); } }; diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index 680e676..955afd6 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -5,7 +5,7 @@ const globalForPrisma = global as unknown as { prisma: PrismaClient }; export const prisma = globalForPrisma.prisma || new PrismaClient({ - log: ['query'], + log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'], }); if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma; diff --git a/src/proxy.ts b/src/proxy.ts index 8fc70a3..58bbbf3 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -3,7 +3,7 @@ import type { NextRequest } from 'next/server'; import { getSessionCookieName, verifySessionToken } from '@/lib/session'; const PUBLIC_PATHS = new Set(['/login']); -const PUBLIC_API_PATHS = new Set(['/api/auth/login', '/api/settings/init']); +const PUBLIC_API_PATHS = new Set(['/api/auth/login', '/api/settings/init', '/api/settings/status']); export default async function proxy(request: NextRequest) { const authCookie = request.cookies.get(getSessionCookieName());