使用openai进行了代码review
This commit is contained in:
@@ -1,20 +1,52 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireApiAuth } from "@/lib/api-auth";
|
||||
|
||||
export const runtime = "edge"; // Optional: Use edge runtime for lower latency
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const authError = await requireApiAuth();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { messages, config } = await req.json();
|
||||
const { apiKey, baseURL, model } = config || {};
|
||||
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: "Missing API Key" }, { status: 401 });
|
||||
if (!apiKey || typeof apiKey !== "string") {
|
||||
return NextResponse.json({ error: "Missing API Key" }, { status: 400 });
|
||||
}
|
||||
if (!baseURL || typeof baseURL !== "string") {
|
||||
return NextResponse.json({ error: "Missing API base URL" }, { status: 400 });
|
||||
}
|
||||
if (!Array.isArray(messages)) {
|
||||
return NextResponse.json({ error: "Invalid messages payload" }, { status: 400 });
|
||||
}
|
||||
if (messages.length === 0 || messages.length > 100) {
|
||||
return NextResponse.json({ error: "Messages count out of range" }, { status: 400 });
|
||||
}
|
||||
const isValidMessage = messages.every(
|
||||
(msg) =>
|
||||
msg &&
|
||||
typeof msg === "object" &&
|
||||
typeof msg.role === "string" &&
|
||||
typeof msg.content === "string" &&
|
||||
msg.content.length <= 20000
|
||||
);
|
||||
if (!isValidMessage) {
|
||||
return NextResponse.json({ error: "Invalid message format" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Clean up baseURL: ensure no trailing slash, add /chat/completions if missing?
|
||||
// Actually, usually users provide standard base URL "https://api.openai.com/v1"
|
||||
// We should append /chat/completions.
|
||||
const url = `${baseURL.replace(/\/$/, "")}/chat/completions`;
|
||||
let normalizedBaseUrl: string;
|
||||
try {
|
||||
const parsed = new URL(baseURL);
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||||
return NextResponse.json({ error: "Unsupported API base URL protocol" }, { status: 400 });
|
||||
}
|
||||
normalizedBaseUrl = parsed.origin + parsed.pathname.replace(/\/$/, "");
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid API base URL" }, { status: 400 });
|
||||
}
|
||||
|
||||
const url = `${normalizedBaseUrl}/chat/completions`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
@@ -23,7 +55,7 @@ export async function POST(req: NextRequest) {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: model || "gpt-3.5-turbo",
|
||||
model: typeof model === "string" && model.trim() ? model.trim() : "gpt-3.5-turbo",
|
||||
messages,
|
||||
stream: true, // Force streaming
|
||||
}),
|
||||
|
||||
@@ -5,7 +5,10 @@ import { createSessionToken, getSessionCookieName, getSessionTtlSeconds } from "
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { password, rememberMe, durationDays } = await req.json();
|
||||
const { password, rememberMe, durationDays } = await req.json().catch(() => ({}));
|
||||
if (typeof password !== "string" || password.length === 0) {
|
||||
return NextResponse.json({ error: "Invalid password" }, { status: 400 });
|
||||
}
|
||||
|
||||
const settings = await prisma.globalSettings.findUnique({
|
||||
where: { id: "default" },
|
||||
@@ -18,7 +21,9 @@ export async function POST(req: Request) {
|
||||
const isValid = await verifyPassword(password, settings.password);
|
||||
|
||||
if (isValid) {
|
||||
const days = rememberMe ? Number(durationDays) || 1 : 1;
|
||||
const rawDays = rememberMe ? Number(durationDays) : 1;
|
||||
const boundedDays = Number.isFinite(rawDays) ? Math.floor(rawDays) : 1;
|
||||
const days = rememberMe ? Math.max(1, Math.min(30, boundedDays)) : 1;
|
||||
const token = await createSessionToken(days);
|
||||
const response = NextResponse.json({ success: true });
|
||||
response.cookies.set(getSessionCookieName(), token, {
|
||||
|
||||
+168
-13
@@ -1,18 +1,87 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { requireApiAuth } from '@/lib/api-auth';
|
||||
|
||||
type PageRef = { id: string; parentId: string | null };
|
||||
|
||||
const MAX_TITLE_LENGTH = 200;
|
||||
const MAX_TAGS = 20;
|
||||
const MAX_TAG_LENGTH = 50;
|
||||
|
||||
function buildChildrenMap(pages: PageRef[]): Map<string | null, string[]> {
|
||||
const childrenMap = new Map<string | null, string[]>();
|
||||
for (const page of pages) {
|
||||
const siblings = childrenMap.get(page.parentId) || [];
|
||||
siblings.push(page.id);
|
||||
childrenMap.set(page.parentId, siblings);
|
||||
}
|
||||
return childrenMap;
|
||||
}
|
||||
|
||||
function isCycleMove(pages: PageRef[], nodeId: string, targetParentId: string): boolean {
|
||||
const parentMap = new Map<string, string | null>(pages.map((p) => [p.id, p.parentId]));
|
||||
let cursor: string | null = targetParentId;
|
||||
|
||||
while (cursor) {
|
||||
if (cursor === nodeId) return true;
|
||||
cursor = parentMap.get(cursor) ?? null;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function collectDeleteOrder(rootId: string, pages: PageRef[]): string[] {
|
||||
const childrenMap = buildChildrenMap(pages);
|
||||
const order: string[] = [];
|
||||
|
||||
const walk = (id: string) => {
|
||||
const children = childrenMap.get(id) || [];
|
||||
for (const childId of children) walk(childId);
|
||||
order.push(id);
|
||||
};
|
||||
|
||||
walk(rootId);
|
||||
return order;
|
||||
}
|
||||
|
||||
function safeParseTags(tags: string | null): string[] {
|
||||
if (!tags) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(tags);
|
||||
return Array.isArray(parsed) ? parsed.filter((t): t is string => typeof t === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTags(input: unknown): string[] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
const unique = new Set<string>();
|
||||
for (const raw of input) {
|
||||
if (typeof raw !== 'string') continue;
|
||||
const normalized = raw.trim();
|
||||
if (!normalized || normalized.length > MAX_TAG_LENGTH) continue;
|
||||
unique.add(normalized);
|
||||
if (unique.size >= MAX_TAGS) break;
|
||||
}
|
||||
return Array.from(unique);
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const authError = await requireApiAuth();
|
||||
if (authError) return authError;
|
||||
|
||||
const id = (await params).id;
|
||||
try {
|
||||
const page = await prisma.page.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!page) return NextResponse.json({ error: 'Page not found' }, { status: 404 });
|
||||
return NextResponse.json({ ...page, tags: JSON.parse(page.tags || "[]") });
|
||||
return NextResponse.json({ ...page, tags: safeParseTags(page.tags) });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Error fetching page' }, { status: 500 });
|
||||
}
|
||||
@@ -22,23 +91,96 @@ export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const authError = await requireApiAuth();
|
||||
if (authError) return authError;
|
||||
|
||||
const id = (await params).id;
|
||||
try {
|
||||
const body = await request.json();
|
||||
// Separate update logic for flexibility (e.g. only updating title)
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body || typeof body !== 'object') {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const current = await prisma.page.findUnique({ where: { id }, select: { id: true } });
|
||||
if (!current) {
|
||||
return NextResponse.json({ error: 'Page not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const updateData: Prisma.PageUncheckedUpdateInput = {};
|
||||
if (body.title !== undefined) updateData.title = body.title;
|
||||
if (body.content !== undefined) updateData.content = body.content;
|
||||
if (body.parentId !== undefined) updateData.parentId = body.parentId;
|
||||
if (body.tags !== undefined) updateData.tags = JSON.stringify(body.tags);
|
||||
if (body.icon !== undefined) updateData.icon = body.icon;
|
||||
if (body.isLocked !== undefined) updateData.isLocked = body.isLocked;
|
||||
|
||||
if (body.title !== undefined) {
|
||||
if (typeof body.title !== 'string') {
|
||||
return NextResponse.json({ error: 'Invalid title' }, { status: 400 });
|
||||
}
|
||||
const trimmed = body.title.trim();
|
||||
if (!trimmed || trimmed.length > MAX_TITLE_LENGTH) {
|
||||
return NextResponse.json({ error: 'Invalid title' }, { status: 400 });
|
||||
}
|
||||
updateData.title = trimmed;
|
||||
}
|
||||
|
||||
if (body.content !== undefined) {
|
||||
if (typeof body.content !== 'string') {
|
||||
return NextResponse.json({ error: 'Invalid content' }, { status: 400 });
|
||||
}
|
||||
updateData.content = body.content;
|
||||
}
|
||||
|
||||
if (body.icon !== undefined) {
|
||||
updateData.icon = typeof body.icon === 'string' ? body.icon : null;
|
||||
}
|
||||
|
||||
if (body.isLocked !== undefined) {
|
||||
if (typeof body.isLocked !== 'boolean') {
|
||||
return NextResponse.json({ error: 'Invalid isLocked' }, { status: 400 });
|
||||
}
|
||||
updateData.isLocked = body.isLocked;
|
||||
}
|
||||
|
||||
if (body.tags !== undefined) {
|
||||
updateData.tags = JSON.stringify(normalizeTags(body.tags));
|
||||
}
|
||||
|
||||
if (body.parentId !== undefined) {
|
||||
if (body.parentId === null) {
|
||||
updateData.parentId = null;
|
||||
} else if (typeof body.parentId === 'string') {
|
||||
const targetParentId = body.parentId;
|
||||
if (targetParentId === id) {
|
||||
return NextResponse.json({ error: 'Invalid parentId' }, { status: 400 });
|
||||
}
|
||||
|
||||
const targetParent = await prisma.page.findUnique({
|
||||
where: { id: targetParentId },
|
||||
select: { id: true, type: true },
|
||||
});
|
||||
if (!targetParent || targetParent.type !== 'folder') {
|
||||
return NextResponse.json({ error: 'Parent folder not found' }, { status: 400 });
|
||||
}
|
||||
|
||||
const refs = await prisma.page.findMany({ select: { id: true, parentId: true } });
|
||||
if (isCycleMove(refs, id, targetParentId)) {
|
||||
return NextResponse.json({ error: 'Cannot move page into its own subtree' }, { status: 400 });
|
||||
}
|
||||
|
||||
updateData.parentId = targetParentId;
|
||||
} else {
|
||||
return NextResponse.json({ error: 'Invalid parentId' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (body.order !== undefined) {
|
||||
if (!Number.isInteger(body.order)) {
|
||||
return NextResponse.json({ error: 'Invalid order' }, { status: 400 });
|
||||
}
|
||||
updateData.order = body.order;
|
||||
}
|
||||
|
||||
const page = await prisma.page.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
return NextResponse.json({ ...page, tags: JSON.parse(page.tags || "[]") });
|
||||
return NextResponse.json({ ...page, tags: safeParseTags(page.tags) });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Error updating page' }, { status: 500 });
|
||||
}
|
||||
@@ -48,11 +190,24 @@ export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const authError = await requireApiAuth();
|
||||
if (authError) return authError;
|
||||
|
||||
const id = (await params).id;
|
||||
try {
|
||||
await prisma.page.delete({
|
||||
where: { id },
|
||||
const refs = await prisma.page.findMany({ select: { id: true, parentId: true } });
|
||||
const exists = refs.some((p) => p.id === id);
|
||||
if (!exists) {
|
||||
return NextResponse.json({ error: 'Page not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const deleteOrder = collectDeleteOrder(id, refs);
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const pageId of deleteOrder) {
|
||||
await tx.page.delete({ where: { id: pageId } });
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Error deleting page' }, { status: 500 });
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireApiAuth } from '@/lib/api-auth';
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
const authError = await requireApiAuth();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { updates } = body;
|
||||
@@ -9,6 +13,24 @@ export async function PUT(request: Request) {
|
||||
if (!Array.isArray(updates)) {
|
||||
return NextResponse.json({ error: 'Invalid updates' }, { status: 400 });
|
||||
}
|
||||
if (updates.length === 0) {
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
const isValid = updates.every(
|
||||
(update) =>
|
||||
update &&
|
||||
typeof update === 'object' &&
|
||||
typeof update.id === 'string' &&
|
||||
update.id.length > 0 &&
|
||||
Number.isInteger(update.order)
|
||||
);
|
||||
if (!isValid) {
|
||||
return NextResponse.json({ error: 'Invalid updates payload' }, { status: 400 });
|
||||
}
|
||||
const ids = updates.map((update: { id: string }) => update.id);
|
||||
if (new Set(ids).size !== ids.length) {
|
||||
return NextResponse.json({ error: 'Duplicate page ids in updates' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Transaction for batch update
|
||||
await prisma.$transaction(
|
||||
|
||||
+75
-15
@@ -1,14 +1,45 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireApiAuth } from '@/lib/api-auth';
|
||||
|
||||
const MAX_TITLE_LENGTH = 200;
|
||||
const MAX_TAGS = 20;
|
||||
const MAX_TAG_LENGTH = 50;
|
||||
|
||||
function safeParseTags(tags: string | null): string[] {
|
||||
if (!tags) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(tags);
|
||||
return Array.isArray(parsed) ? parsed.filter((t): t is string => typeof t === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTags(input: unknown): string[] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
const unique = new Set<string>();
|
||||
for (const raw of input) {
|
||||
if (typeof raw !== 'string') continue;
|
||||
const normalized = raw.trim();
|
||||
if (!normalized || normalized.length > MAX_TAG_LENGTH) continue;
|
||||
unique.add(normalized);
|
||||
if (unique.size >= MAX_TAGS) break;
|
||||
}
|
||||
return Array.from(unique);
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const authError = await requireApiAuth();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const pages = await prisma.page.findMany({
|
||||
orderBy: [{ order: 'asc' }, { createdAt: 'desc' }],
|
||||
});
|
||||
const parsedPages = pages.map(p => ({
|
||||
const parsedPages = pages.map((p) => ({
|
||||
...p,
|
||||
tags: JSON.parse(p.tags || "[]")
|
||||
tags: safeParseTags(p.tags),
|
||||
}));
|
||||
return NextResponse.json(parsedPages);
|
||||
} catch {
|
||||
@@ -17,30 +48,59 @@ export async function GET() {
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireApiAuth();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { title, content, parentId, type } = body;
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body || typeof body !== 'object') {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const rawTitle = typeof body.title === 'string' ? body.title.trim() : '';
|
||||
const title = rawTitle || '无标题';
|
||||
if (title.length > MAX_TITLE_LENGTH) {
|
||||
return NextResponse.json({ error: 'Title too long' }, { status: 400 });
|
||||
}
|
||||
|
||||
const type = body.type === 'folder' ? 'folder' : 'file';
|
||||
const content = typeof body.content === 'string' ? body.content : '';
|
||||
const parentId = typeof body.parentId === 'string' ? body.parentId : null;
|
||||
const tags = normalizeTags(body.tags);
|
||||
const icon = typeof body.icon === 'string' ? body.icon : null;
|
||||
const isLocked = typeof body.isLocked === 'boolean' ? body.isLocked : false;
|
||||
const requestedOrder = Number.isInteger(body.order) ? body.order : undefined;
|
||||
|
||||
if (parentId) {
|
||||
const parent = await prisma.page.findUnique({
|
||||
where: { id: parentId },
|
||||
select: { id: true, type: true },
|
||||
});
|
||||
if (!parent || parent.type !== 'folder') {
|
||||
return NextResponse.json({ error: 'Parent folder not found' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const page = await prisma.page.create({
|
||||
data: {
|
||||
title: title || '无标题',
|
||||
content: content || '',
|
||||
tags: JSON.stringify(body.tags || []),
|
||||
parentId: parentId || null,
|
||||
type: type || 'file',
|
||||
title,
|
||||
content,
|
||||
tags: JSON.stringify(tags),
|
||||
parentId,
|
||||
type,
|
||||
order: await (async () => {
|
||||
if (body.order !== undefined) return body.order;
|
||||
if (requestedOrder !== undefined) return requestedOrder;
|
||||
const lastPage = await prisma.page.findFirst({
|
||||
where: { parentId: parentId || null },
|
||||
where: { parentId },
|
||||
orderBy: { order: 'desc' },
|
||||
});
|
||||
return (lastPage?.order ?? -1) + 1;
|
||||
})(),
|
||||
icon: body.icon || null,
|
||||
isLocked: body.isLocked || false,
|
||||
icon,
|
||||
isLocked,
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ ...page, tags: JSON.parse(page.tags || "[]") });
|
||||
return NextResponse.json({ ...page, tags: safeParseTags(page.tags) });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Error creating page' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -1,16 +1,43 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { hashPassword } from "@/lib/auth";
|
||||
|
||||
export async function POST() {
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const count = await prisma.globalSettings.count();
|
||||
if (count > 0) {
|
||||
return NextResponse.json({ message: "Settings already initialized" }, { status: 200 });
|
||||
}
|
||||
|
||||
// Default password: "admin"
|
||||
const hashedPassword = await hashPassword("admin");
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const passwordFromBody = typeof body?.password === "string" ? body.password : "";
|
||||
const passwordFromEnv = process.env.INIT_DEFAULT_PASSWORD || "";
|
||||
const initPassword = passwordFromBody || passwordFromEnv;
|
||||
|
||||
if (!initPassword || initPassword.length < 6) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing init password. Provide body.password or INIT_DEFAULT_PASSWORD (min 6 chars)." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
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");
|
||||
if (providedToken !== initToken) {
|
||||
return NextResponse.json({ error: "Invalid init token" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(initPassword);
|
||||
await prisma.globalSettings.create({
|
||||
data: {
|
||||
id: "default",
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { hashPassword, verifyPassword } from "@/lib/auth";
|
||||
import { requireApiAuth } from "@/lib/api-auth";
|
||||
|
||||
export async function PUT(req: Request) {
|
||||
const authError = await requireApiAuth();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { currentPassword, newPassword } = await req.json();
|
||||
const { currentPassword, newPassword } = await req.json().catch(() => ({}));
|
||||
if (typeof currentPassword !== "string" || currentPassword.length === 0) {
|
||||
return NextResponse.json({ error: "Invalid current password" }, { status: 400 });
|
||||
}
|
||||
if (typeof newPassword !== "string" || newPassword.length < 6) {
|
||||
return NextResponse.json({ error: "New password must be at least 6 characters" }, { status: 400 });
|
||||
}
|
||||
if (newPassword === currentPassword) {
|
||||
return NextResponse.json({ error: "New password must be different" }, { status: 400 });
|
||||
}
|
||||
|
||||
const settings = await prisma.globalSettings.findUnique({
|
||||
where: { id: "default" },
|
||||
|
||||
@@ -1,194 +1,200 @@
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import JSZip from "jszip";
|
||||
import { marked } from "marked";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireApiAuth } from "@/lib/api-auth";
|
||||
|
||||
// Use a global prisma instance to avoid "too many connections" in dev
|
||||
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
|
||||
const prisma = globalForPrisma.prisma || new PrismaClient();
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||
type ZipEntry = {
|
||||
path: string;
|
||||
isDir: boolean;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
// Disable body parser strictly (Next.js App Router handles FormData naturally)
|
||||
// export const config = {
|
||||
// api: {
|
||||
// bodyParser: false,
|
||||
// },
|
||||
// };
|
||||
// No need for config in App Router route handlers.
|
||||
type ParsedMarkdown = {
|
||||
title: string;
|
||||
order: number;
|
||||
tags: string[];
|
||||
htmlContent: string;
|
||||
};
|
||||
|
||||
const MAX_RESTORE_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
|
||||
|
||||
function normalizeZipPath(input: string): string {
|
||||
const noBackslashes = input.replace(/\\/g, "/");
|
||||
const noTrailingSlash = noBackslashes.endsWith("/") ? noBackslashes.slice(0, -1) : noBackslashes;
|
||||
return noTrailingSlash.trim();
|
||||
}
|
||||
|
||||
function getDepth(path: string): number {
|
||||
return path.split("/").length;
|
||||
}
|
||||
|
||||
function getParentPath(path: string): string | null {
|
||||
const parts = path.split("/");
|
||||
if (parts.length <= 1) return null;
|
||||
return parts.slice(0, -1).join("/");
|
||||
}
|
||||
|
||||
function getNameFromPath(path: string): string {
|
||||
return path.split("/").pop() || "Untitled";
|
||||
}
|
||||
|
||||
async function parseMarkdownWithFrontmatter(filePath: string, content: string): Promise<ParsedMarkdown> {
|
||||
const defaultTitle = getNameFromPath(filePath).replace(/\.md$/i, "") || "Untitled";
|
||||
let title = defaultTitle;
|
||||
let tags: string[] = [];
|
||||
let order = 0;
|
||||
let markdownBody = content;
|
||||
|
||||
const fmMatch = markdownBody.match(/^---\n([\s\S]*?)\n---\n/);
|
||||
if (fmMatch) {
|
||||
const fmString = fmMatch[1];
|
||||
markdownBody = markdownBody.slice(fmMatch[0].length);
|
||||
|
||||
const titleMatch = fmString.match(/title:\s*"(.*)"/);
|
||||
if (titleMatch && titleMatch[1]) title = titleMatch[1];
|
||||
|
||||
const tagsMatch = fmString.match(/tags:\s*\[(.*)\]/);
|
||||
if (tagsMatch && tagsMatch[1]) {
|
||||
tags = tagsMatch[1]
|
||||
.split(",")
|
||||
.map((item) => item.trim().replace(/^"|"$/g, ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
const orderMatch = fmString.match(/order:\s*(\d+)/);
|
||||
if (orderMatch && orderMatch[1]) {
|
||||
order = parseInt(orderMatch[1], 10);
|
||||
}
|
||||
}
|
||||
|
||||
const htmlContent = await marked(markdownBody, { gfm: true, breaks: true });
|
||||
return { title, order, tags, htmlContent };
|
||||
}
|
||||
|
||||
function collectFolderPaths(entries: ZipEntry[]): string[] {
|
||||
const folderSet = new Set<string>();
|
||||
|
||||
for (const entry of entries) {
|
||||
const normalized = normalizeZipPath(entry.path);
|
||||
if (!normalized) continue;
|
||||
|
||||
if (entry.isDir) {
|
||||
folderSet.add(normalized);
|
||||
}
|
||||
|
||||
let current = getParentPath(normalized);
|
||||
while (current) {
|
||||
folderSet.add(current);
|
||||
current = getParentPath(current);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(folderSet).sort((a, b) => getDepth(a) - getDepth(b));
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const authError = await requireApiAuth();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("file") as File;
|
||||
const file = formData.get("file");
|
||||
|
||||
if (!file) {
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
|
||||
}
|
||||
if (file.size <= 0) {
|
||||
return NextResponse.json({ error: "Empty file" }, { status: 400 });
|
||||
}
|
||||
if (file.size > MAX_RESTORE_FILE_SIZE) {
|
||||
return NextResponse.json({ error: "File too large" }, { status: 413 });
|
||||
}
|
||||
|
||||
const buffer = await file.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
const entries: ZipEntry[] = [];
|
||||
const loadTasks: Promise<void>[] = [];
|
||||
|
||||
// Map to store directory paths to their new DB IDs
|
||||
// Data format: "folder/subfolder" -> UUID
|
||||
const pathIdMap = new Map<string, string>();
|
||||
zip.forEach((rawPath, zipEntry) => {
|
||||
if (rawPath.startsWith("__MACOSX") || rawPath.includes(".DS_Store")) return;
|
||||
|
||||
// Prepare data for proper insertion order (Folders first, then files?)
|
||||
// Actually, we need to process by path depth to ensure parents exist.
|
||||
const entries: Array<{ path: string; isDir: boolean; content?: string }> = [];
|
||||
loadTasks.push((async () => {
|
||||
const path = normalizeZipPath(rawPath);
|
||||
if (!path) return;
|
||||
|
||||
// 1. Read all entries
|
||||
const filePromises: Promise<void>[] = [];
|
||||
|
||||
zip.forEach((relativePath, zipEntry) => {
|
||||
if (relativePath.startsWith("__MACOSX") || relativePath.includes(".DS_Store")) {
|
||||
return; // Skip system files
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
if (zipEntry.dir) {
|
||||
// Remove trailing slash for consistency
|
||||
const cleanPath = relativePath.endsWith('/') ? relativePath.slice(0, -1) : relativePath;
|
||||
if (cleanPath) {
|
||||
entries.push({ path: cleanPath, isDir: true });
|
||||
}
|
||||
} else {
|
||||
if (relativePath.endsWith(".md")) {
|
||||
const content = await zipEntry.async("string");
|
||||
entries.push({ path: relativePath, isDir: false, content });
|
||||
}
|
||||
entries.push({ path, isDir: true });
|
||||
return;
|
||||
}
|
||||
})();
|
||||
filePromises.push(promise);
|
||||
if (!path.endsWith(".md")) return;
|
||||
|
||||
const content = await zipEntry.async("string");
|
||||
entries.push({ path, isDir: false, content });
|
||||
})());
|
||||
});
|
||||
|
||||
await Promise.all(filePromises);
|
||||
|
||||
// 2. Clear existing Pages (Transaction usually)
|
||||
// Since sqlite doesn't support nested transactions well in Prisma sometimes,
|
||||
// we'll just do it sequentially but quickly.
|
||||
// Ideally: await prisma.$transaction([prisma.page.deleteMany(), ...])
|
||||
// But logic is complex (recursive id generation), so we delete first.
|
||||
// WARNING: This is destructive.
|
||||
await prisma.page.deleteMany();
|
||||
|
||||
// 3. Sort entries by path depth (number of slashes)
|
||||
entries.sort((a, b) => {
|
||||
const depthA = a.path.split('/').length;
|
||||
const depthB = b.path.split('/').length;
|
||||
return depthA - depthB;
|
||||
});
|
||||
|
||||
// Helper to get or create parent folder
|
||||
const ensureParent = async (entryPath: string): Promise<string | null> => {
|
||||
const parts = entryPath.split('/');
|
||||
if (parts.length <= 1) return null; // Root level
|
||||
|
||||
const parentPath = parts.slice(0, -1).join('/');
|
||||
|
||||
// If parent already processed
|
||||
if (pathIdMap.has(parentPath)) {
|
||||
return pathIdMap.get(parentPath)!;
|
||||
}
|
||||
|
||||
// If parent folder was not explicitly in Zip (implicit folder), create it
|
||||
// Recursively ensure its parent exists
|
||||
const grandParentId = await ensureParent(parentPath);
|
||||
const folderName = parts[parts.length - 2];
|
||||
|
||||
const newFolder = await prisma.page.create({
|
||||
data: {
|
||||
title: folderName,
|
||||
type: 'folder',
|
||||
parentId: grandParentId
|
||||
}
|
||||
});
|
||||
|
||||
pathIdMap.set(parentPath, newFolder.id);
|
||||
return newFolder.id;
|
||||
};
|
||||
|
||||
// 4. Process entries
|
||||
for (const entry of entries) {
|
||||
// Determine parent
|
||||
// If it's a file "A/B.md", parent path is "A".
|
||||
// If it's a folder "A/B", parent path is "A".
|
||||
// Since we sorted by depth, "A" should be processed before "A/B".
|
||||
|
||||
// However, implicit folders might be skipped in sorting if they aren't in `entries`.
|
||||
// So `ensureParent` handles implicit creation.
|
||||
|
||||
const parentId = await ensureParent(entry.path);
|
||||
|
||||
if (entry.isDir) {
|
||||
// Check if already created by ensureParent
|
||||
if (!pathIdMap.has(entry.path)) {
|
||||
const name = entry.path.split('/').pop() || "Untitled Folder";
|
||||
const folder = await prisma.page.create({
|
||||
data: {
|
||||
title: name,
|
||||
type: 'folder',
|
||||
parentId: parentId
|
||||
}
|
||||
});
|
||||
pathIdMap.set(entry.path, folder.id);
|
||||
}
|
||||
} else {
|
||||
// It is a File (.md)
|
||||
const filename = entry.path.split('/').pop()?.replace('.md', '') || "Untitled";
|
||||
|
||||
// Parse Frontmatter
|
||||
let title = filename;
|
||||
let tags: string[] = [];
|
||||
let order = 0;
|
||||
let markdownBody = entry.content || "";
|
||||
|
||||
// Regex for frontmatter
|
||||
const fmMatch = markdownBody.match(/^---\n([\s\S]*?)\n---\n/);
|
||||
if (fmMatch) {
|
||||
const fmString = fmMatch[1];
|
||||
markdownBody = markdownBody.slice(fmMatch[0].length);
|
||||
|
||||
// Simple parsing
|
||||
// title: "Foo"
|
||||
// tags: ["a", "b"]
|
||||
// order: 1
|
||||
|
||||
const titleMatch = fmString.match(/title:\s*"(.*)"/);
|
||||
if (titleMatch) title = titleMatch[1];
|
||||
|
||||
const tagsMatch = fmString.match(/tags:\s*\[(.*)\]/);
|
||||
if (tagsMatch) {
|
||||
// "a", "b" -> split
|
||||
tags = tagsMatch[1].split(',').map(s => s.trim().replace(/^"|"$/g, '')).filter(Boolean);
|
||||
}
|
||||
|
||||
const orderMatch = fmString.match(/order:\s*(\d+)/);
|
||||
if (orderMatch) order = parseInt(orderMatch[1]);
|
||||
}
|
||||
|
||||
// Convert Markdown to HTML for storage (Editor uses HTML)
|
||||
// Ensure GFM is enabled (default true in new versions, but explicit is good)
|
||||
// breaks: true converts \n to <br> (GitHub style)
|
||||
const htmlContent = await marked(markdownBody, { gfm: true, breaks: true });
|
||||
|
||||
await prisma.page.create({
|
||||
data: {
|
||||
title: title,
|
||||
type: 'file',
|
||||
content: htmlContent,
|
||||
tags: JSON.stringify(tags),
|
||||
order: order,
|
||||
parentId: parentId
|
||||
}
|
||||
});
|
||||
}
|
||||
await Promise.all(loadTasks);
|
||||
if (entries.length === 0) {
|
||||
return NextResponse.json({ error: "No valid markdown entries found in zip" }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, count: entries.length });
|
||||
const folderPaths = collectFolderPaths(entries);
|
||||
const fileEntries = entries
|
||||
.filter((entry): entry is ZipEntry & { content: string } => !entry.isDir && typeof entry.content === "string")
|
||||
.sort((a, b) => getDepth(a.path) - getDepth(b.path));
|
||||
|
||||
} catch (e) {
|
||||
console.error("Restore failed:", e);
|
||||
return NextResponse.json({ error: "Restore failed: " + String(e) }, { status: 500 });
|
||||
// Parse markdown before DB transaction to keep lock time low.
|
||||
const parsedFiles = await Promise.all(
|
||||
fileEntries.map(async (entry) => {
|
||||
const parsed = await parseMarkdownWithFrontmatter(entry.path, entry.content);
|
||||
return { entry, parsed };
|
||||
})
|
||||
);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.page.deleteMany();
|
||||
|
||||
const folderIdMap = new Map<string, string>();
|
||||
for (const folderPath of folderPaths) {
|
||||
const parentPath = getParentPath(folderPath);
|
||||
const parentId = parentPath ? folderIdMap.get(parentPath) || null : null;
|
||||
|
||||
const folder = await tx.page.create({
|
||||
data: {
|
||||
title: getNameFromPath(folderPath),
|
||||
type: "folder",
|
||||
parentId,
|
||||
},
|
||||
});
|
||||
folderIdMap.set(folderPath, folder.id);
|
||||
}
|
||||
|
||||
for (const { entry, parsed } of parsedFiles) {
|
||||
const parentPath = getParentPath(entry.path);
|
||||
const parentId = parentPath ? folderIdMap.get(parentPath) || null : null;
|
||||
|
||||
await tx.page.create({
|
||||
data: {
|
||||
title: parsed.title,
|
||||
type: "file",
|
||||
content: parsed.htmlContent,
|
||||
tags: JSON.stringify(parsed.tags),
|
||||
order: parsed.order,
|
||||
parentId,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
count: entries.length,
|
||||
files: parsedFiles.length,
|
||||
folders: folderPaths.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Restore failed:", error);
|
||||
return NextResponse.json({ error: "Restore failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
+41
-45
@@ -5,62 +5,58 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 240 10% 93%;
|
||||
/* Main content: 93% gray */
|
||||
--foreground: 240 5% 20%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 5% 15%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 5% 15%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--background: 38 20% 95%;
|
||||
/* Softer warm gray background */
|
||||
--foreground: 225 11% 22%;
|
||||
--card: 40 17% 98%;
|
||||
--card-foreground: 225 11% 22%;
|
||||
--popover: 40 17% 98%;
|
||||
--popover-foreground: 225 11% 22%;
|
||||
--primary: 223 21% 20%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 10% 90%;
|
||||
/* Sidebar: 90% gray */
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 96%;
|
||||
--muted-foreground: 240 3.8% 46%;
|
||||
--accent: 240 4.8% 96%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--secondary: 36 18% 92%;
|
||||
--secondary-foreground: 223 18% 24%;
|
||||
--muted: 36 16% 93%;
|
||||
--muted-foreground: 223 9% 42%;
|
||||
--accent: 34 20% 91%;
|
||||
--accent-foreground: 223 18% 24%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 5.9% 10%;
|
||||
--border: 35 15% 85%;
|
||||
--input: 35 15% 85%;
|
||||
--ring: 223 21% 30%;
|
||||
--radius: 0.75rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Notion-like Dark Mode (Inverted Hierarchy) - Deepened */
|
||||
--background: 0 0% 9%;
|
||||
/* Main Content: Deep Dark (#171717) */
|
||||
--foreground: 0 0% 92%;
|
||||
--card: 0 0% 9%;
|
||||
/* Match background */
|
||||
--card-foreground: 0 0% 92%;
|
||||
/* Softer dark mode: less pure black, gentler contrast */
|
||||
--background: 220 10% 13%;
|
||||
--foreground: 210 16% 90%;
|
||||
|
||||
--popover: 0 0% 9%;
|
||||
--popover-foreground: 0 0% 92%;
|
||||
--card: 220 10% 15%;
|
||||
--card-foreground: 210 16% 90%;
|
||||
|
||||
--primary: 0 0% 92%;
|
||||
--primary-foreground: 0 0% 10%;
|
||||
--popover: 220 10% 16%;
|
||||
--popover-foreground: 210 16% 90%;
|
||||
|
||||
--secondary: 0 0% 13%;
|
||||
/* Sidebar: Lighter than main (#212121), but deeper than before */
|
||||
--secondary-foreground: 0 0% 92%;
|
||||
--primary: 210 16% 90%;
|
||||
--primary-foreground: 220 12% 14%;
|
||||
|
||||
--muted: 0 0% 13%;
|
||||
--muted-foreground: 0 0% 65%;
|
||||
--secondary: 220 10% 18%;
|
||||
--secondary-foreground: 210 16% 90%;
|
||||
|
||||
--accent: 0 0% 13%;
|
||||
--accent-foreground: 0 0% 92%;
|
||||
--muted: 220 10% 20%;
|
||||
--muted-foreground: 210 10% 72%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 92%;
|
||||
--accent: 220 10% 22%;
|
||||
--accent-foreground: 210 16% 92%;
|
||||
|
||||
--border: 0 0% 18%;
|
||||
/* Subtle borders */
|
||||
--input: 0 0% 18%;
|
||||
--ring: 0 0% 80%;
|
||||
--destructive: 0 62.8% 35%;
|
||||
--destructive-foreground: 0 0% 96%;
|
||||
|
||||
--border: 220 8% 28%;
|
||||
--input: 220 8% 28%;
|
||||
--ring: 210 16% 78%;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -209,12 +205,12 @@ ul[data-type="taskList"],
|
||||
|
||||
/* Override Highlight.js background for a softer look */
|
||||
.ProseMirror pre {
|
||||
background: #252529 !important;
|
||||
/* Softer dark gray (hsl(240 5% 15%)), approx matching foreground */
|
||||
background: #2b313a !important;
|
||||
/* Slightly lifted code block background for comfortable reading */
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.hljs {
|
||||
background: transparent !important;
|
||||
/* Let pre handle the background */
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { ConfirmProvider } from "@/components/confirm-provider";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
@@ -24,7 +25,7 @@ export default function RootLayout({
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
<ConfirmProvider>{children}</ConfirmProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+11
-11
@@ -30,10 +30,10 @@ export default function LoginPage() {
|
||||
router.push("/");
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setError(data.error || "Login failed");
|
||||
setError(data.error || "登录失败");
|
||||
}
|
||||
} catch {
|
||||
setError("Something went wrong. Please try again.");
|
||||
setError("发生错误,请重试");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -44,8 +44,8 @@ export default function LoginPage() {
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-primary rounded-2xl text-primary-foreground mb-4">
|
||||
<Sparkles size={32} />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Welcome back</h1>
|
||||
<p className="text-muted-foreground">Enter your access password to continue.</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight">欢迎回来</h1>
|
||||
<p className="text-muted-foreground">请输入访问密码以继续。</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
@@ -54,7 +54,7 @@ export default function LoginPage() {
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={18} />
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Access password"
|
||||
placeholder="访问密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-3 bg-muted/50 border rounded-xl focus:ring-2 focus:ring-primary outline-none transition-all"
|
||||
@@ -72,7 +72,7 @@ export default function LoginPage() {
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-input bg-background/50 text-primary focus:ring-primary/50"
|
||||
/>
|
||||
Remember me
|
||||
记住我
|
||||
</label>
|
||||
|
||||
{rememberMe && (
|
||||
@@ -81,9 +81,9 @@ export default function LoginPage() {
|
||||
onChange={(e) => setDuration(e.target.value)}
|
||||
className="bg-transparent border-none outline-none text-muted-foreground hover:text-foreground cursor-pointer text-xs"
|
||||
>
|
||||
<option value="1">1 day</option>
|
||||
<option value="7">7 days</option>
|
||||
<option value="30">30 days</option>
|
||||
<option value="1">1天</option>
|
||||
<option value="7">7天</option>
|
||||
<option value="30">30天</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
@@ -92,12 +92,12 @@ export default function LoginPage() {
|
||||
type="submit"
|
||||
className="w-full py-3 bg-primary text-primary-foreground font-semibold rounded-xl hover:opacity-90 active:scale-[0.98] transition-all shadow-lg"
|
||||
>
|
||||
Sign in
|
||||
登录
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="text-center text-xs text-muted-foreground">
|
||||
NoteAI - Your private second brain
|
||||
NoteAI - 你的私人第二大脑
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+246
-384
@@ -1,35 +1,41 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useSettingsStore, fontOptions, timezoneOptions } from "@/lib/settings-store";
|
||||
import { Lock, Type, Save, Sparkles } from "lucide-react";
|
||||
import { Lock, Save, Sparkles, Type, Upload, Download } from "lucide-react";
|
||||
import { ResizableSidebar } from "@/components/sidebar";
|
||||
import { PromptManagement } from "@/components/settings/prompt-management";
|
||||
import { ImportProvider } from "@/components/import-context";
|
||||
import { useSettingsStore, fontOptions, timezoneOptions } from "@/lib/settings-store";
|
||||
import { useConfirm } from "@/components/confirm-provider";
|
||||
|
||||
export default function SettingsPage() {
|
||||
// Appearance
|
||||
const confirm = useConfirm();
|
||||
const {
|
||||
fontSize, setFontSize,
|
||||
fontFamily, setFontFamily,
|
||||
lineHeight, setLineHeight,
|
||||
tableLineHeight, setTableLineHeight,
|
||||
timezone, setTimezone,
|
||||
aiConfig, setAIConfig,
|
||||
fontSize,
|
||||
setFontSize,
|
||||
fontFamily,
|
||||
setFontFamily,
|
||||
lineHeight,
|
||||
setLineHeight,
|
||||
tableLineHeight,
|
||||
setTableLineHeight,
|
||||
timezone,
|
||||
setTimezone,
|
||||
aiConfig,
|
||||
setAIConfig,
|
||||
} = useSettingsStore();
|
||||
|
||||
// Security
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [msg, setMsg] = useState<{ type: 'success' | 'error', text: string } | null>(null);
|
||||
const [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
|
||||
const handlePasswordChange = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setMsg(null);
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setMsg({ type: 'error', text: "两次输入的新密码不一致" });
|
||||
setMsg({ type: "error", text: "两次输入的新密码不一致" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -41,412 +47,268 @@ export default function SettingsPage() {
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMsg({ type: 'success', text: "密码修改成功" });
|
||||
setMsg({ type: "success", text: "密码修改成功" });
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMsg({ type: 'error', text: data.error || "修改失败" });
|
||||
setMsg({ type: "error", text: data.error || "修改失败" });
|
||||
}
|
||||
} catch {
|
||||
setMsg({ type: 'error', text: "系统错误,请重试" });
|
||||
setMsg({ type: "error", text: "系统错误,请重试" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ImportProvider>
|
||||
<div className="flex h-screen w-full bg-background overflow-hidden relative">
|
||||
<div className="flex h-screen w-full bg-background overflow-hidden">
|
||||
<ResizableSidebar />
|
||||
<main className="flex-1 h-full flex overflow-hidden bg-background/50">
|
||||
{/* Settings Navigation Sidebar (Desktop Only) */}
|
||||
<aside className="w-56 lg:w-64 border-r bg-background/30 hidden md:flex flex-col p-6 overflow-y-auto">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight">设置</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">管理偏好与安全</p>
|
||||
</div>
|
||||
<nav className="space-y-1">
|
||||
<button
|
||||
onClick={() => document.getElementById('appearance')?.scrollIntoView({ behavior: 'smooth' })}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground rounded-md transition-colors text-left"
|
||||
>
|
||||
<Type size={16} />
|
||||
<span>外观设置</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => document.getElementById('ai-config')?.scrollIntoView({ behavior: 'smooth' })}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground rounded-md transition-colors text-left"
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
<span>AI 模型</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => document.getElementById('prompts')?.scrollIntoView({ behavior: 'smooth' })}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground rounded-md transition-colors text-left"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2" /><line x1="9" x2="15" y1="9" y2="15" /><line x1="15" x2="9" y1="9" y2="15" /></svg>
|
||||
<span>快捷指令</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => document.getElementById('security')?.scrollIntoView({ behavior: 'smooth' })}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground rounded-md transition-colors text-left"
|
||||
>
|
||||
<Lock size={16} />
|
||||
<span>安全设置</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => document.getElementById('backup')?.scrollIntoView({ behavior: 'smooth' })}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground rounded-md transition-colors text-left"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" x2="12" y1="3" y2="15" /></svg>
|
||||
<span>备份与恢复</span>
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
<main className="flex-1 h-full overflow-y-auto p-4 md:p-10">
|
||||
<div className="mx-auto max-w-3xl space-y-8 pb-16">
|
||||
<header className="space-y-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">设置</h1>
|
||||
<p className="text-muted-foreground">管理外观、AI、安全与备份。</p>
|
||||
</header>
|
||||
|
||||
{/* Settings Content Area */}
|
||||
<div className="flex-1 h-full overflow-y-auto p-4 md:p-12 scroll-smooth">
|
||||
<div className="max-w-3xl mx-auto space-y-10 animate-in fade-in slide-in-from-bottom-4 duration-500 pb-20">
|
||||
{/* Mobile Header (Hidden on Desktop) */}
|
||||
<div className="md:hidden">
|
||||
<h1 className="text-3xl font-bold tracking-tight">设置</h1>
|
||||
<p className="text-muted-foreground mt-2">管理您的编辑器偏好和账户安全</p>
|
||||
<section className="rounded-xl border bg-card p-6 space-y-5">
|
||||
<div className="flex items-center gap-2 border-b pb-2">
|
||||
<Type size={18} className="text-primary" />
|
||||
<h2 className="text-xl font-semibold">外观设置</h2>
|
||||
</div>
|
||||
|
||||
{/* Appearance Section */}
|
||||
<section id="appearance" className="bg-card border shadow-sm rounded-xl p-6 space-y-6">
|
||||
<div className="flex items-center gap-2 pb-2 border-b">
|
||||
<Type size={20} className="text-primary" />
|
||||
<h2 className="text-xl font-semibold">外观设置</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium">编辑器字体</label>
|
||||
<select
|
||||
className="w-full p-2.5 bg-muted/50 border rounded-lg outline-none focus:ring-2 focus:ring-primary/50 transition-all"
|
||||
value={fontFamily}
|
||||
onChange={(e) => setFontFamily(e.target.value)}
|
||||
>
|
||||
{fontOptions.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium">字体大小 ({fontSize}px)</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min="12"
|
||||
max="32"
|
||||
step="1"
|
||||
value={fontSize}
|
||||
onChange={(e) => setFontSize(parseInt(e.target.value))}
|
||||
className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
<span className="w-12 text-center font-mono bg-muted p-1 rounded text-sm">{fontSize}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium">正文行间距 ({lineHeight})</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min="1.0"
|
||||
max="3.0"
|
||||
step="0.1"
|
||||
value={lineHeight}
|
||||
onChange={(e) => setLineHeight(parseFloat(e.target.value))}
|
||||
className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
<span className="w-12 text-center font-mono bg-muted p-1 rounded text-sm">{lineHeight}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium">表格行间距 ({tableLineHeight})</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min="1.0"
|
||||
max="3.0"
|
||||
step="0.1"
|
||||
value={tableLineHeight}
|
||||
onChange={(e) => setTableLineHeight(parseFloat(e.target.value))}
|
||||
className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
<span className="w-12 text-center font-mono bg-muted p-1 rounded text-sm">{tableLineHeight}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timezone takes full width or 2 cols */}
|
||||
<div className="md:col-span-2 space-y-3">
|
||||
<label className="text-sm font-medium">Timezone (时区)</label>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<select
|
||||
className="w-full p-2.5 bg-muted/50 border rounded-lg outline-none focus:ring-2 focus:ring-primary/50 transition-all"
|
||||
value={timezone}
|
||||
onChange={(e) => setTimezone(e.target.value)}
|
||||
>
|
||||
{timezoneOptions.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{/* Future placeholder for more timezone settings or info */}
|
||||
<div className="hidden md:block"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-muted/30 border rounded-xl">
|
||||
<p className="text-sm text-muted-foreground mb-2">预览:</p>
|
||||
<div
|
||||
style={{ fontFamily, fontSize: `${fontSize}px` }}
|
||||
className="leading-relaxed transition-all"
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="space-y-2 text-sm">
|
||||
<span className="font-medium">字体</span>
|
||||
<select
|
||||
value={fontFamily}
|
||||
onChange={(e) => setFontFamily(e.target.value)}
|
||||
className="w-full rounded-lg border bg-muted/40 px-3 py-2"
|
||||
>
|
||||
NoteAI 是一款专注于写作体验的智能笔记应用。它可以帮助您捕捉灵感,整理思绪。
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{fontOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* AI Configuration Section */}
|
||||
<section id="ai-config" className="bg-card border shadow-sm rounded-xl p-6 space-y-6">
|
||||
<div className="flex items-center gap-2 pb-2 border-b">
|
||||
<Sparkles size={20} className="text-primary" />
|
||||
<h2 className="text-xl font-semibold">AI 模型配置</h2>
|
||||
</div>
|
||||
<label className="space-y-2 text-sm">
|
||||
<span className="font-medium">字号: {fontSize}px</span>
|
||||
<input
|
||||
type="range"
|
||||
min="12"
|
||||
max="32"
|
||||
step="1"
|
||||
value={fontSize}
|
||||
onChange={(e) => setFontSize(parseInt(e.target.value, 10))}
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">API Base URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={aiConfig.baseURL}
|
||||
onChange={(e) => setAIConfig({ baseURL: e.target.value })}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
className="w-full p-2.5 bg-muted/50 border rounded-lg outline-none focus:ring-2 focus:ring-primary/50 text-sm font-mono"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">兼容 OpenAI 接口标准的地址</p>
|
||||
</div>
|
||||
<label className="space-y-2 text-sm">
|
||||
<span className="font-medium">正文行距: {lineHeight}</span>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="3"
|
||||
step="0.1"
|
||||
value={lineHeight}
|
||||
onChange={(e) => setLineHeight(parseFloat(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={aiConfig.apiKey}
|
||||
onChange={(e) => setAIConfig({ apiKey: e.target.value })}
|
||||
placeholder="sk-..."
|
||||
className="w-full p-2.5 bg-muted/50 border rounded-lg outline-none focus:ring-2 focus:ring-primary/50 text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Model Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={aiConfig.model}
|
||||
onChange={(e) => setAIConfig({ model: e.target.value })}
|
||||
placeholder="gpt-3.5-turbo"
|
||||
className="w-full p-2.5 bg-muted/50 border rounded-lg outline-none focus:ring-2 focus:ring-primary/50 text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<button
|
||||
onClick={async () => {
|
||||
const btn = document.activeElement as HTMLButtonElement;
|
||||
const originalText = btn.innerText;
|
||||
btn.innerText = "Testing...";
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const res = await fetch("/api/ai/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
config: aiConfig, // Use current config from store
|
||||
messages: [{ role: "user", content: "Hi" }]
|
||||
})
|
||||
});
|
||||
if (res.ok) {
|
||||
const text = await res.text();
|
||||
// Simple check if we got a stream or text
|
||||
if (text.length > 0) {
|
||||
alert("连接成功!API 返回正常。");
|
||||
} else {
|
||||
alert("连接成功,但没有返回内容。");
|
||||
}
|
||||
} else {
|
||||
alert(`连接失败: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
} catch (e) {
|
||||
alert("连接出错: " + String(e));
|
||||
} finally {
|
||||
btn.innerText = originalText;
|
||||
btn.disabled = false;
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 bg-secondary text-secondary-foreground hover:bg-secondary/80 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
测试连接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Prompt Management Section */}
|
||||
<div id="prompts" className="bg-card border shadow-sm rounded-xl p-6">
|
||||
<PromptManagement />
|
||||
<label className="space-y-2 text-sm">
|
||||
<span className="font-medium">表格行距: {tableLineHeight}</span>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="3"
|
||||
step="0.1"
|
||||
value={tableLineHeight}
|
||||
onChange={(e) => setTableLineHeight(parseFloat(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Security Section */}
|
||||
<section id="security" className="bg-card border shadow-sm rounded-xl p-6 space-y-6">
|
||||
<div className="flex items-center gap-2 pb-2 border-b">
|
||||
<Lock size={20} className="text-primary" />
|
||||
<h2 className="text-xl font-semibold">安全设置</h2>
|
||||
</div>
|
||||
<label className="space-y-2 text-sm block">
|
||||
<span className="font-medium">时区</span>
|
||||
<select
|
||||
value={timezone}
|
||||
onChange={(e) => setTimezone(e.target.value)}
|
||||
className="w-full rounded-lg border bg-muted/40 px-3 py-2"
|
||||
>
|
||||
{timezoneOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<form onSubmit={handlePasswordChange} className="space-y-4 max-w-md">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">当前密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
className="w-full p-2.5 bg-muted/50 border rounded-lg outline-none focus:ring-2 focus:ring-primary/50"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">新密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="w-full p-2.5 bg-muted/50 border rounded-lg outline-none focus:ring-2 focus:ring-primary/50"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">确认新密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full p-2.5 bg-muted/50 border rounded-lg outline-none focus:ring-2 focus:ring-primary/50"
|
||||
required
|
||||
/>
|
||||
<section className="rounded-xl border bg-card p-6 space-y-5">
|
||||
<div className="flex items-center gap-2 border-b pb-2">
|
||||
<Sparkles size={18} className="text-primary" />
|
||||
<h2 className="text-xl font-semibold">AI 配置</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="space-y-1 text-sm block">
|
||||
<span className="font-medium">API Base URL</span>
|
||||
<input
|
||||
type="text"
|
||||
value={aiConfig.baseURL}
|
||||
onChange={(e) => setAIConfig({ baseURL: e.target.value })}
|
||||
className="w-full rounded-lg border bg-muted/40 px-3 py-2"
|
||||
placeholder="https://api.openai.com/v1"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-sm block">
|
||||
<span className="font-medium">API Key</span>
|
||||
<input
|
||||
type="password"
|
||||
value={aiConfig.apiKey}
|
||||
onChange={(e) => setAIConfig({ apiKey: e.target.value })}
|
||||
className="w-full rounded-lg border bg-muted/40 px-3 py-2"
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-sm block">
|
||||
<span className="font-medium">Model</span>
|
||||
<input
|
||||
type="text"
|
||||
value={aiConfig.model}
|
||||
onChange={(e) => setAIConfig({ model: e.target.value })}
|
||||
className="w-full rounded-lg border bg-muted/40 px-3 py-2"
|
||||
placeholder="gpt-4o-mini"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border bg-card p-6">
|
||||
<PromptManagement />
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border bg-card p-6 space-y-5">
|
||||
<div className="flex items-center gap-2 border-b pb-2">
|
||||
<Lock size={18} className="text-primary" />
|
||||
<h2 className="text-xl font-semibold">安全设置</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handlePasswordChange} className="space-y-3 max-w-md">
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
className="w-full rounded-lg border bg-muted/40 px-3 py-2"
|
||||
placeholder="当前密码"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="w-full rounded-lg border bg-muted/40 px-3 py-2"
|
||||
placeholder="新密码"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full rounded-lg border bg-muted/40 px-3 py-2"
|
||||
placeholder="确认新密码"
|
||||
required
|
||||
/>
|
||||
|
||||
{msg && (
|
||||
<div className={msg.type === "success" ? "rounded-md bg-green-500/10 p-3 text-sm text-green-600" : "rounded-md bg-red-500/10 p-3 text-sm text-red-600"}>
|
||||
{msg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{msg && (
|
||||
<div className={`p-3 rounded-lg text-sm ${msg.type === 'success' ? 'bg-green-500/10 text-green-600' : 'bg-red-500/10 text-red-600'}`}>
|
||||
{msg.text}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90"
|
||||
>
|
||||
<Save size={14} />
|
||||
保存密码
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<Save size={16} />
|
||||
保存密码
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
<section className="rounded-xl border bg-card p-6 space-y-4">
|
||||
<h2 className="text-xl font-semibold">备份与恢复</h2>
|
||||
<p className="text-sm text-muted-foreground">恢复会覆盖当前全部文档,请谨慎操作。</p>
|
||||
|
||||
{/* Data Management Section */}
|
||||
<section id="backup" className="bg-card border shadow-sm rounded-xl p-6 space-y-6">
|
||||
<div className="flex items-center gap-2 pb-2 border-b">
|
||||
<span className="text-xl font-semibold">📦 数据备份与恢复</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<button
|
||||
onClick={async () => {
|
||||
const { pages } = await import("@/lib/store").then((m) => m.useEditorStore.getState());
|
||||
const { exportAllPagesAsZip } = await import("@/lib/export");
|
||||
await exportAllPagesAsZip(pages);
|
||||
}}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg bg-secondary px-4 py-2 text-sm font-medium text-secondary-foreground hover:bg-secondary/80"
|
||||
>
|
||||
<Download size={14} />
|
||||
下载备份 (Zip)
|
||||
</button>
|
||||
|
||||
<div className="p-4 border rounded-lg bg-orange-50/50 dark:bg-orange-900/10 border-orange-200 dark:border-orange-800/30">
|
||||
<h3 className="font-medium text-orange-800 dark:text-orange-300 mb-2 flex items-center gap-2">
|
||||
⚠️ 注意事项
|
||||
</h3>
|
||||
<ul className="list-disc list-inside text-sm text-orange-700 dark:text-orange-400/80 space-y-1">
|
||||
<li>备份功能将导出所有文档为 Markdown 格式的压缩包 (Zip)。</li>
|
||||
<li>恢复功能将<b>清空当前所有文档</b>,并用备份文件覆盖,请谨慎操作。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<label className="inline-flex cursor-pointer items-center justify-center gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2 text-sm font-medium text-destructive hover:bg-destructive/20">
|
||||
<Upload size={14} />
|
||||
上传备份并恢复
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const ok = await confirm({
|
||||
title: "确认恢复备份",
|
||||
description: "该操作将覆盖当前所有文档,且无法撤销。",
|
||||
confirmText: "继续恢复",
|
||||
cancelText: "取消",
|
||||
tone: "danger",
|
||||
});
|
||||
if (!ok) {
|
||||
e.target.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<button
|
||||
onClick={async () => {
|
||||
const { pages } = await import('@/lib/store').then(m => m.useEditorStore.getState());
|
||||
const { exportAllPagesAsZip } = await import('@/lib/export');
|
||||
try {
|
||||
await exportAllPagesAsZip(pages);
|
||||
} catch (e) {
|
||||
alert("备份失败: " + String(e));
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const res = await fetch("/api/settings/restore", { method: "POST", body: formData });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || "恢复失败");
|
||||
}
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
alert(String(error));
|
||||
} finally {
|
||||
e.target.value = "";
|
||||
}
|
||||
}}
|
||||
className="flex items-center justify-center gap-2 px-4 py-2.5 bg-secondary text-secondary-foreground hover:bg-secondary/80 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" x2="12" y1="15" y2="3" /></svg>
|
||||
下载备份 (Zip)
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip"
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!confirm("⚠️ 警告:此操作将永久删除当前所有文档并恢复备份!\n\n确定要继续吗?")) {
|
||||
e.target.value = ''; // Reset
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const btn = e.target.parentElement?.querySelector('button');
|
||||
if (btn) btn.innerText = "恢复中...";
|
||||
|
||||
const res = await fetch("/api/settings/restore", {
|
||||
method: "POST",
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
alert("恢复成功!页面将刷新。");
|
||||
window.location.reload();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert("恢复失败: " + (err.error || res.statusText));
|
||||
}
|
||||
} catch (error) {
|
||||
alert("系统错误: " + String(error));
|
||||
} finally {
|
||||
if (e.target) e.target.value = '';
|
||||
const btn = e.target.parentElement?.querySelector('button');
|
||||
if (btn) btn.innerText = "上传备份并恢复";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button className="w-full sm:w-auto flex items-center justify-center gap-2 px-4 py-2.5 bg-destructive/10 text-destructive hover:bg-destructive/20 rounded-lg font-medium transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" x2="12" y1="3" y2="15" /></svg>
|
||||
上传备份并恢复
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Version Info */}
|
||||
<div className="py-8 text-center text-xs text-muted-foreground/60">
|
||||
<p>NoteAI v{process.env.NEXT_PUBLIC_APP_VERSION || '0.1.0'}</p>
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="pt-6 text-center text-xs text-muted-foreground/70">
|
||||
NoteAI v{process.env.NEXT_PUBLIC_APP_VERSION || "0.1.0"}
|
||||
</div>
|
||||
</div>
|
||||
</main >
|
||||
</div >
|
||||
</main>
|
||||
</div>
|
||||
</ImportProvider>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user