使用openai进行了代码review

This commit is contained in:
2026-02-24 11:33:08 +08:00
parent 9efdf060f5
commit 0c97f02e51
32 changed files with 1499 additions and 969 deletions
+246 -384
View File
@@ -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>
);
}