首次发布git
This commit is contained in:
@@ -0,0 +1,453 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useSettingsStore, defaultPrompts, fontOptions, timezoneOptions } from "@/lib/settings-store";
|
||||
import { Lock, Type, Save, Sparkles } from "lucide-react";
|
||||
import { ResizableSidebar } from "@/components/sidebar";
|
||||
import { PromptManagement } from "@/components/settings/prompt-management";
|
||||
import { ImportProvider } from "@/components/import-context";
|
||||
|
||||
export default function SettingsPage() {
|
||||
// Appearance
|
||||
const {
|
||||
fontSize, setFontSize,
|
||||
fontFamily, setFontFamily,
|
||||
lineHeight, setLineHeight,
|
||||
tableLineHeight, setTableLineHeight,
|
||||
timezone, setTimezone,
|
||||
aiConfig, setAIConfig,
|
||||
prompts, resetPrompts, updatePrompt, deletePrompt, addPrompt
|
||||
} = 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 handlePasswordChange = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setMsg(null);
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setMsg({ type: 'error', text: "两次输入的新密码不一致" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/settings/password", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMsg({ type: 'success', text: "密码修改成功" });
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMsg({ type: 'error', text: data.error || "修改失败" });
|
||||
}
|
||||
} catch (e) {
|
||||
setMsg({ type: 'error', text: "系统错误,请重试" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ImportProvider>
|
||||
<div className="flex h-screen w-full bg-background overflow-hidden relative">
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
</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"
|
||||
>
|
||||
NoteAI 是一款专注于写作体验的智能笔记应用。它可以帮助您捕捉灵感,整理思绪。
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 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>
|
||||
|
||||
<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>
|
||||
|
||||
<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 />
|
||||
</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>
|
||||
|
||||
<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
|
||||
/>
|
||||
</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="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>
|
||||
|
||||
{/* 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="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>
|
||||
|
||||
<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));
|
||||
}
|
||||
}}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main >
|
||||
</div >
|
||||
</ImportProvider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user