对移动端的UI进行了重构
This commit is contained in:
@@ -113,10 +113,10 @@ export async function PUT(
|
||||
return NextResponse.json({ error: 'Invalid title' }, { status: 400 });
|
||||
}
|
||||
const trimmed = body.title.trim();
|
||||
if (!trimmed || trimmed.length > MAX_TITLE_LENGTH) {
|
||||
if (trimmed.length > MAX_TITLE_LENGTH) {
|
||||
return NextResponse.json({ error: 'Invalid title' }, { status: 400 });
|
||||
}
|
||||
updateData.title = trimmed;
|
||||
updateData.title = trimmed || '无标题';
|
||||
}
|
||||
|
||||
if (body.content !== undefined) {
|
||||
|
||||
+85
-30
@@ -1,4 +1,4 @@
|
||||
@import 'highlight.js/styles/atom-one-dark.css';
|
||||
@import 'highlight.js/styles/atom-one-dark.css';
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -26,6 +26,7 @@
|
||||
--input: 35 15% 85%;
|
||||
--ring: 223 21% 30%;
|
||||
--radius: 0.75rem;
|
||||
--code-bg: 220 16% 21%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -57,6 +58,7 @@
|
||||
--border: 220 8% 28%;
|
||||
--input: 220 8% 28%;
|
||||
--ring: 210 16% 78%;
|
||||
--code-bg: 220 16% 20%;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -76,7 +78,8 @@
|
||||
|
||||
@layer components {
|
||||
.ui-card {
|
||||
@apply rounded-2xl border border-border/70 bg-card/95 shadow-sm backdrop-blur-sm;
|
||||
@apply rounded-xl border border-border/65 bg-card shadow-sm backdrop-blur-sm;
|
||||
background-color: hsl(var(--card) / 0.92);
|
||||
}
|
||||
|
||||
.ui-input {
|
||||
@@ -88,23 +91,23 @@
|
||||
}
|
||||
|
||||
.ui-btn {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-all active:scale-[0.98];
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-xl border border-transparent px-4 py-2 text-sm font-semibold transition-all active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-55 disabled:active:scale-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/25;
|
||||
}
|
||||
|
||||
.ui-btn-primary {
|
||||
@apply ui-btn bg-primary text-primary-foreground hover:opacity-90 shadow-sm;
|
||||
@apply ui-btn border-primary/20 bg-primary text-primary-foreground shadow-sm hover:opacity-90;
|
||||
}
|
||||
|
||||
.ui-btn-secondary {
|
||||
@apply ui-btn bg-secondary text-secondary-foreground hover:bg-secondary/80;
|
||||
@apply ui-btn border-border/70 bg-secondary/85 text-secondary-foreground hover:bg-secondary;
|
||||
}
|
||||
|
||||
.ui-btn-danger {
|
||||
@apply ui-btn bg-destructive text-destructive-foreground hover:opacity-90;
|
||||
@apply ui-btn border-destructive/25 bg-destructive text-destructive-foreground hover:opacity-90;
|
||||
}
|
||||
|
||||
.ui-icon-btn {
|
||||
@apply inline-flex h-9 w-9 items-center justify-center rounded-lg border border-transparent text-muted-foreground transition-colors hover:border-border hover:bg-muted/60 hover:text-foreground;
|
||||
@apply inline-flex h-9 w-9 items-center justify-center rounded-lg border border-border/40 bg-background/30 text-muted-foreground transition-colors active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-55 disabled:active:scale-100 hover:border-border hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/25;
|
||||
}
|
||||
|
||||
.ui-kbd {
|
||||
@@ -159,11 +162,72 @@
|
||||
.ProseMirror {
|
||||
outline: none;
|
||||
min-height: 300px;
|
||||
padding-bottom: 50px;
|
||||
padding-bottom: 56px;
|
||||
font-size: 1.05rem;
|
||||
line-height: var(--editor-line-height, 1.5);
|
||||
/* Use CSS variable with fallback */
|
||||
/* Reduced from 1.75 */
|
||||
letter-spacing: 0.003em;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.ProseMirror {
|
||||
font-size: 1rem;
|
||||
line-height: max(1.55, var(--editor-line-height, 1.5));
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror > * + * {
|
||||
margin-top: 0.7em;
|
||||
}
|
||||
|
||||
.ProseMirror h1,
|
||||
.ProseMirror h2,
|
||||
.ProseMirror h3 {
|
||||
color: hsl(var(--foreground));
|
||||
font-weight: 750;
|
||||
line-height: 1.28;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.ProseMirror h1 {
|
||||
margin-top: 1.2em;
|
||||
font-size: clamp(1.7rem, 1.3rem + 1.3vw, 2.15rem);
|
||||
}
|
||||
|
||||
.ProseMirror h2 {
|
||||
margin-top: 1.05em;
|
||||
font-size: clamp(1.35rem, 1.18rem + 0.8vw, 1.7rem);
|
||||
}
|
||||
|
||||
.ProseMirror h3 {
|
||||
margin-top: 0.9em;
|
||||
font-size: clamp(1.12rem, 1.02rem + 0.45vw, 1.3rem);
|
||||
}
|
||||
|
||||
.ProseMirror p {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.ProseMirror ul,
|
||||
.ProseMirror ol {
|
||||
margin: 0.5em 0;
|
||||
padding-left: 1.2em;
|
||||
}
|
||||
|
||||
.ProseMirror li {
|
||||
margin: 0.22em 0;
|
||||
}
|
||||
|
||||
.ProseMirror a {
|
||||
color: hsl(var(--primary));
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.ProseMirror hr {
|
||||
margin: 1.1em 0;
|
||||
border: 0;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.ProseMirror p.is-editor-empty:first-child::before {
|
||||
@@ -176,20 +240,15 @@
|
||||
|
||||
.ProseMirror blockquote {
|
||||
border-left: 3px solid hsl(var(--primary));
|
||||
/* Make border color slightly more visible (primary) */
|
||||
padding-left: 1rem;
|
||||
padding-left: 0.95rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-family: "Georgia", "Cambria", "Times New Roman", serif !important;
|
||||
/* Force serif font */
|
||||
font-style: italic;
|
||||
margin: 1rem 0;
|
||||
margin: 0.95rem 0;
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
/* Subtle background */
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
padding-top: 0.45rem;
|
||||
padding-bottom: 0.45rem;
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
/* Rounded right corners */
|
||||
/* Rounded right corners */
|
||||
}
|
||||
|
||||
.ProseMirror code {
|
||||
@@ -217,19 +276,16 @@
|
||||
/* Table Styles - Ensure content is compact */
|
||||
.ProseMirror table p {
|
||||
margin: 0;
|
||||
/* Remove paragraph margin inside tables */
|
||||
line-height: var(--table-line-height, 1.2);
|
||||
/* Use CSS variable with fallback */
|
||||
/* Tighter line height for table content */
|
||||
}
|
||||
|
||||
.ProseMirror table {
|
||||
border-collapse: collapse;
|
||||
margin: 1em 0;
|
||||
/* Add some vertical space around the table itself */
|
||||
overflow: hidden;
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.ProseMirror td,
|
||||
@@ -240,7 +296,6 @@
|
||||
box-sizing: border-box;
|
||||
min-width: 1em;
|
||||
padding: 2px 4px;
|
||||
/* Further reduced padding */
|
||||
position: relative;
|
||||
vertical-align: top;
|
||||
}
|
||||
@@ -249,13 +304,12 @@
|
||||
background-color: hsl(var(--muted));
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
/* Make header borders visible by using a contrasting color if bg matches border */
|
||||
border-color: hsl(var(--foreground) / 0.1);
|
||||
}
|
||||
|
||||
/* AI Content Style */
|
||||
.ai-content {
|
||||
font-family: "KaiTi", "STKaiti", "楷体", "Georgia", serif;
|
||||
font-family: "KaiTi", "STKaiti", "Kaiti SC", "Noto Serif SC", "Georgia", serif;
|
||||
font-style: italic;
|
||||
font-weight: normal;
|
||||
color: hsl(var(--foreground));
|
||||
@@ -265,7 +319,6 @@
|
||||
/* Specific fix for dark mode if needed, but opacity trick usually works */
|
||||
.dark .ProseMirror th {
|
||||
border-color: hsl(var(--background));
|
||||
/* Use background color for borders in dark mode header to show separation */
|
||||
}
|
||||
|
||||
/* Task List Styles */
|
||||
@@ -279,7 +332,6 @@ ul[data-type="taskList"],
|
||||
.ProseMirror li[data-type="taskItem"] div,
|
||||
.ProseMirror li[data-type="taskItem"] label {
|
||||
line-height: normal !important;
|
||||
/* Force tight line height for checkbox items */
|
||||
margin-top: 2px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
@@ -289,12 +341,15 @@ ul[data-type="taskList"],
|
||||
|
||||
/* Override Highlight.js background for a softer look */
|
||||
.ProseMirror pre {
|
||||
background: #2b313a !important;
|
||||
/* Slightly lifted code block background for comfortable reading */
|
||||
background: hsl(var(--code-bg)) !important;
|
||||
border: 1px solid hsl(var(--border) / 0.5);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
margin: 0.95rem 0;
|
||||
}
|
||||
|
||||
.hljs {
|
||||
background: transparent !important;
|
||||
/* Let pre handle the background */
|
||||
}
|
||||
|
||||
|
||||
+94
-55
@@ -3,7 +3,7 @@
|
||||
import { SidebarContent, ResizableSidebar } from "@/components/sidebar";
|
||||
import { Editor } from "@/components/editor";
|
||||
import { useEditorStore } from "@/lib/store";
|
||||
import { Hash, X, Plus, ChevronLeft, ChevronRight, Sparkles, Lock, Unlock, FolderOpen, History, RotateCcw } from "lucide-react";
|
||||
import { Hash, X, Plus, ChevronLeft, ChevronRight, ChevronDown, Sparkles, Lock, Unlock, FolderOpen, History, RotateCcw } from "lucide-react";
|
||||
import { useSettingsStore } from "@/lib/settings-store";
|
||||
import { exportPageAsMarkdown } from "@/lib/export";
|
||||
import { useMemo, useState } from "react";
|
||||
@@ -12,6 +12,7 @@ import { useSearchStore } from "@/lib/search-store";
|
||||
import { type Editor as TiptapEditor } from "@tiptap/react";
|
||||
import { ImportProvider } from "@/components/import-context";
|
||||
import { AIChatPanel } from "@/components/chat/ai-chat-panel";
|
||||
import { SearchCommand } from "@/components/search-command";
|
||||
import { getBacklinks, getOutgoingLinks, getUnresolvedLinkTitles } from "@/lib/wiki-links";
|
||||
import { getPageHistory } from "@/lib/page-history";
|
||||
|
||||
@@ -34,6 +35,8 @@ export default function Home() {
|
||||
const [isAddingTag, setIsAddingTag] = useState(false);
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
const [isIconPickerOpen, setIsIconPickerOpen] = useState(false);
|
||||
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
||||
const [selectedHistoryIndex, setSelectedHistoryIndex] = useState(0);
|
||||
|
||||
const breadcrumbs = useMemo(() => {
|
||||
if (!activePage) return [];
|
||||
@@ -68,6 +71,7 @@ export default function Home() {
|
||||
if (!activePage || activePage.type !== "file") return [];
|
||||
return getPageHistory(activePage.id);
|
||||
}, [activePage]);
|
||||
const effectiveHistoryIndex = Math.min(selectedHistoryIndex, Math.max(localHistory.length - 1, 0));
|
||||
|
||||
const addTag = () => {
|
||||
if (!activePage) return;
|
||||
@@ -93,6 +97,8 @@ export default function Home() {
|
||||
return (
|
||||
<ImportProvider>
|
||||
<div className="relative flex h-[100dvh] w-full overflow-hidden bg-background">
|
||||
<SearchCommand />
|
||||
|
||||
<div className={cn(activePageId ? "hidden" : "block h-full flex-1 md:hidden")}>
|
||||
<SidebarContent />
|
||||
</div>
|
||||
@@ -102,50 +108,50 @@ export default function Home() {
|
||||
<main className={cn("relative z-0 flex h-full flex-1 flex-col overflow-hidden", !activePageId && "hidden md:flex")}>
|
||||
{activePage ? (
|
||||
<div className="flex-1 overflow-y-auto scroll-smooth">
|
||||
<div className="mx-auto min-h-screen max-w-7xl px-4 py-6 md:px-16">
|
||||
<div className="ui-enter group relative mb-8">
|
||||
<div className="mx-auto min-h-screen max-w-7xl px-3 py-4 md:px-16 md:py-6">
|
||||
<div className="ui-enter group relative mb-6 pt-10 md:mb-8 md:pt-0">
|
||||
<button
|
||||
className="absolute -top-12 left-0 flex items-center gap-1 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground md:hidden"
|
||||
className="absolute left-0 top-0 flex h-9 items-center gap-1 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground md:hidden"
|
||||
onClick={() => useEditorStore.getState().setActivePageId(null)}
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
返回列表
|
||||
</button>
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-1 text-sm text-muted-foreground">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-1 text-xs text-muted-foreground md:mb-4 md:text-sm">
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<div key={crumb.id} className="flex items-center gap-1">
|
||||
<div key={crumb.id} className="flex min-w-0 items-center gap-1">
|
||||
{index > 0 && <ChevronRight size={14} className="opacity-50" />}
|
||||
<button
|
||||
onClick={() => useEditorStore.getState().setActivePageId(crumb.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-1 transition-colors hover:text-foreground hover:underline",
|
||||
"flex min-w-0 items-center gap-1 transition-colors hover:text-foreground hover:underline",
|
||||
crumb.id === activePage.id && "pointer-events-none font-medium text-foreground"
|
||||
)}
|
||||
>
|
||||
{crumb.icon && <span>{crumb.icon}</span>}
|
||||
<span>{crumb.title || "无标题"}</span>
|
||||
<span className="truncate">{crumb.title || "无标题"}</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{activePage.icon && <span className="select-none text-3xl">{activePage.icon}</span>}
|
||||
<div className="flex items-start gap-2">
|
||||
{activePage.icon && <span className="select-none pt-0.5 text-[28px] md:text-3xl">{activePage.icon}</span>}
|
||||
<input
|
||||
value={activePage.title}
|
||||
onChange={(e) => updatePage(activePage.id, { title: e.target.value })}
|
||||
placeholder="无标题"
|
||||
disabled={activePage.isLocked}
|
||||
className={cn(
|
||||
"w-full border-none bg-transparent text-3xl font-bold text-foreground outline-none placeholder:text-muted-foreground/30",
|
||||
"w-full border-none bg-transparent text-2xl font-bold leading-tight text-foreground outline-none placeholder:text-muted-foreground/30 md:text-3xl",
|
||||
activePage.isLocked && "cursor-not-allowed select-none opacity-80"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ui-enter-delayed mb-6 flex flex-wrap items-center gap-2">
|
||||
<div className="ui-enter-delayed relative z-20 mb-6 flex flex-wrap items-center gap-2">
|
||||
{activePage.tags?.map((tag) => {
|
||||
const colors = getTagColor(tag);
|
||||
return (
|
||||
@@ -167,7 +173,7 @@ export default function Home() {
|
||||
const newTags = activePage.tags?.filter((t) => t !== tag) || [];
|
||||
updatePage(activePage.id, { tags: newTags });
|
||||
}}
|
||||
className="ml-1 rounded-full p-0.5 opacity-0 transition-all group-hover/tag:opacity-100 hover:bg-black/10 dark:hover:bg-white/10"
|
||||
className="ml-1 rounded-full p-1 opacity-100 transition-all hover:bg-black/10 md:p-0.5 md:opacity-0 md:group-hover/tag:opacity-100 dark:hover:bg-white/10"
|
||||
title="移除标签"
|
||||
>
|
||||
<X size={10} />
|
||||
@@ -205,7 +211,7 @@ export default function Home() {
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<div className="relative z-30">
|
||||
<button
|
||||
onClick={() => setIsIconPickerOpen((v) => !v)}
|
||||
className="ui-btn-secondary px-2 py-1 text-xs text-muted-foreground"
|
||||
@@ -213,8 +219,8 @@ export default function Home() {
|
||||
<Sparkles size={12} /> {activePage.icon ? "更换图标" : "添加图标"}
|
||||
</button>
|
||||
{isIconPickerOpen && (
|
||||
<div className="ui-card absolute left-0 top-full z-50 mt-1 w-80 p-2 shadow-xl animate-in fade-in zoom-in-95">
|
||||
<div className="grid h-48 grid-cols-8 gap-1 overflow-y-auto pr-1">
|
||||
<div className="ui-card absolute left-0 top-full z-[120] mt-1 w-[min(92vw,20rem)] p-2 shadow-xl animate-in fade-in zoom-in-95 sm:w-80">
|
||||
<div className="grid h-48 grid-cols-6 gap-1 overflow-y-auto pr-1 sm:grid-cols-8">
|
||||
{ICONS.map((icon) => (
|
||||
<button
|
||||
key={icon}
|
||||
@@ -258,7 +264,7 @@ export default function Home() {
|
||||
</button>
|
||||
|
||||
{activePage.updatedAt && (
|
||||
<div className="ml-1 h-4 border-l pl-2 text-xs text-muted-foreground/50">
|
||||
<div className="w-full pt-0.5 text-[11px] text-muted-foreground/60 md:ml-1 md:h-4 md:w-auto md:border-l md:pl-2 md:pt-0 md:text-xs md:text-muted-foreground/50">
|
||||
{new Date(activePage.updatedAt).toLocaleString("zh-CN", {
|
||||
timeZone: timezone || "Asia/Shanghai",
|
||||
hour12: false,
|
||||
@@ -268,7 +274,7 @@ export default function Home() {
|
||||
</div>
|
||||
|
||||
{activePage.type === "folder" ? (
|
||||
<div className="ui-card ui-enter flex h-[50vh] flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<div className="ui-card ui-enter flex h-[45vh] flex-col items-center justify-center gap-3 text-muted-foreground md:h-[50vh]">
|
||||
<div className="ui-float rounded-2xl border border-border/70 bg-muted/40 p-4">
|
||||
<FolderOpen size={34} className="text-primary/80" />
|
||||
</div>
|
||||
@@ -287,8 +293,8 @@ export default function Home() {
|
||||
editable={!activePage.isLocked}
|
||||
/>
|
||||
|
||||
<section className="ui-enter mt-8 space-y-4">
|
||||
<div className="ui-card p-4">
|
||||
<section className="ui-enter mt-6 space-y-3 md:mt-8 md:space-y-4">
|
||||
<div className="ui-card p-3 md:p-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">关联页面</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">在正文中使用 `[[页面名]]` 自动建立链接关系,支持 `Ctrl/Cmd + 点击` 跳转。</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
@@ -309,7 +315,7 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ui-card p-4">
|
||||
<div className="ui-card p-3 md:p-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">反向链接</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">这些页面提到了当前页面。</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
@@ -330,7 +336,7 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ui-card p-4">
|
||||
<div className="ui-card p-3 md:p-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">未解析链接</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">这些 `[[页面名]]` 还没有对应页面,可一键创建。</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
@@ -353,47 +359,78 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ui-card p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<History size={14} className="text-muted-foreground" />
|
||||
<h3 className="text-sm font-semibold text-foreground">本地历史版本</h3>
|
||||
</div>
|
||||
<div className="ui-card p-3 md:p-4">
|
||||
<button
|
||||
onClick={() => setIsHistoryOpen((v) => !v)}
|
||||
className="flex w-full items-center justify-between rounded-md px-1 py-1 text-left transition-colors hover:bg-muted/45"
|
||||
aria-expanded={isHistoryOpen}
|
||||
aria-label="切换本地历史版本面板"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<History size={14} className="text-muted-foreground" />
|
||||
<h3 className="text-sm font-semibold text-foreground">本地历史版本</h3>
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">{localHistory.length}</span>
|
||||
</div>
|
||||
<ChevronDown size={15} className={cn("text-muted-foreground transition-transform", isHistoryOpen && "rotate-180")} />
|
||||
</button>
|
||||
<p className="mt-1 text-xs text-muted-foreground">自动保存最近修改快照(当前浏览器本地)。</p>
|
||||
<div className="mt-3 space-y-2">
|
||||
{localHistory.length > 0 ? (
|
||||
localHistory.slice(0, 8).map((snapshot, index) => (
|
||||
<div key={`${snapshot.timestamp}-${index}`} className="flex items-center justify-between rounded-md border border-border/60 bg-muted/20 px-2.5 py-2">
|
||||
<div className="min-w-0">
|
||||
{isHistoryOpen && (
|
||||
<div className="mt-3 space-y-3">
|
||||
{localHistory.length > 0 ? (
|
||||
<>
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-2">
|
||||
<label className="mb-1 block text-[11px] text-muted-foreground">选择要恢复的版本</label>
|
||||
<select
|
||||
value={selectedHistoryIndex}
|
||||
onChange={(e) => setSelectedHistoryIndex(parseInt(e.target.value, 10))}
|
||||
className="ui-select h-9 py-1 text-xs"
|
||||
>
|
||||
{localHistory.slice(0, 20).map((snapshot, index) => (
|
||||
<option key={`${snapshot.timestamp}-${index}`} value={index}>
|
||||
{new Date(snapshot.timestamp).toLocaleString("zh-CN", {
|
||||
timeZone: timezone || "Asia/Shanghai",
|
||||
hour12: false,
|
||||
})}
|
||||
{` · ${snapshot.title || "无标题"}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="rounded-md border border-border/60 bg-muted/15 px-2.5 py-2">
|
||||
<div className="truncate text-xs text-foreground">
|
||||
{snapshot.title || "无标题"}
|
||||
{localHistory[effectiveHistoryIndex]?.title || "无标题"}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{new Date(snapshot.timestamp).toLocaleString("zh-CN", {
|
||||
timeZone: timezone || "Asia/Shanghai",
|
||||
hour12: false,
|
||||
})}
|
||||
{localHistory[effectiveHistoryIndex]
|
||||
? new Date(localHistory[effectiveHistoryIndex].timestamp).toLocaleString("zh-CN", {
|
||||
timeZone: timezone || "Asia/Shanghai",
|
||||
hour12: false,
|
||||
})
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const target = localHistory[effectiveHistoryIndex];
|
||||
if (!target || !activePage) return;
|
||||
const ok = window.confirm("恢复该历史版本后将覆盖当前内容,是否继续?");
|
||||
if (!ok || !activePage) return;
|
||||
if (!ok) return;
|
||||
await updatePage(activePage.id, {
|
||||
title: snapshot.title,
|
||||
content: snapshot.content,
|
||||
title: target.title,
|
||||
content: target.content,
|
||||
});
|
||||
}}
|
||||
className="ui-btn-secondary px-2 py-1 text-xs"
|
||||
className="ui-btn-secondary h-9 w-full justify-center px-2 py-1 text-xs sm:h-auto sm:w-auto sm:justify-start"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
恢复
|
||||
恢复选中版本
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">暂无历史记录</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">暂无历史记录</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -401,13 +438,15 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ui-enter flex h-full flex-col items-center justify-center gap-4 text-muted-foreground">
|
||||
<div className="ui-float rounded-2xl border border-border/70 bg-muted/40 p-4">
|
||||
<span className="text-4xl">🧠</span>
|
||||
</div>
|
||||
<div className="space-y-1 text-center">
|
||||
<h3 className="text-lg font-semibold text-foreground">欢迎使用 NoteAI</h3>
|
||||
<p className="text-sm text-muted-foreground/90">在左侧选择一个页面,或新建文档开始写作。</p>
|
||||
<div className="ui-enter flex h-full items-center justify-center p-4 md:p-8">
|
||||
<div className="ui-card flex w-full max-w-md flex-col items-center gap-3 px-6 py-8 text-center text-muted-foreground">
|
||||
<div className="ui-float rounded-2xl border border-border/70 bg-muted/40 p-4">
|
||||
<span className="text-4xl">🧠</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-semibold text-foreground">欢迎使用 NoteAI</h3>
|
||||
<p className="text-sm text-muted-foreground/90">在左侧选择一个页面,或新建文档开始写作。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+36
-19
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Lock, Save, Sparkles, Type, Upload, Download } from "lucide-react";
|
||||
import { Lock, Save, Sparkles, Type, Upload, Download, Loader2 } from "lucide-react";
|
||||
import { ResizableSidebar } from "@/components/sidebar";
|
||||
import { PromptManagement } from "@/components/settings/prompt-management";
|
||||
import { ImportProvider } from "@/components/import-context";
|
||||
@@ -28,6 +28,8 @@ export default function SettingsPage() {
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [isRestoring, setIsRestoring] = useState(false);
|
||||
const [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
|
||||
const handlePasswordChange = async (e: React.FormEvent) => {
|
||||
@@ -62,7 +64,7 @@ export default function SettingsPage() {
|
||||
|
||||
return (
|
||||
<ImportProvider>
|
||||
<div className="flex h-screen w-full bg-background overflow-hidden">
|
||||
<div className="flex h-[100dvh] w-full overflow-hidden bg-background">
|
||||
<ResizableSidebar />
|
||||
<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">
|
||||
@@ -71,10 +73,10 @@ export default function SettingsPage() {
|
||||
<p className="text-muted-foreground">管理外观、AI、安全与备份。</p>
|
||||
</header>
|
||||
|
||||
<section className="ui-card ui-enter-delayed p-6 space-y-5">
|
||||
<section className="ui-card ui-enter-delayed space-y-5 p-4 md:p-6">
|
||||
<div className="flex items-center gap-2 border-b pb-2">
|
||||
<Type size={18} className="text-primary" />
|
||||
<h2 className="text-xl font-semibold">外观设置</h2>
|
||||
<h2 className="text-lg font-semibold md:text-xl">外观设置</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
@@ -149,10 +151,10 @@ export default function SettingsPage() {
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="ui-card ui-enter-delayed p-6 space-y-5">
|
||||
<section className="ui-card ui-enter-delayed space-y-5 p-4 md:p-6">
|
||||
<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>
|
||||
<h2 className="text-lg font-semibold md:text-xl">AI 配置</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
@@ -191,14 +193,14 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ui-card ui-enter-delayed p-6">
|
||||
<section className="ui-card ui-enter-delayed p-4 md:p-6">
|
||||
<PromptManagement />
|
||||
</section>
|
||||
|
||||
<section className="ui-card ui-enter-delayed p-6 space-y-5">
|
||||
<section className="ui-card ui-enter-delayed space-y-5 p-4 md:p-6">
|
||||
<div className="flex items-center gap-2 border-b pb-2">
|
||||
<Lock size={18} className="text-primary" />
|
||||
<h2 className="text-xl font-semibold">安全设置</h2>
|
||||
<h2 className="text-lg font-semibold md:text-xl">安全设置</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handlePasswordChange} className="space-y-3 max-w-md">
|
||||
@@ -243,30 +245,43 @@ export default function SettingsPage() {
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="ui-card ui-enter-delayed p-6 space-y-4">
|
||||
<h2 className="text-xl font-semibold">备份与恢复</h2>
|
||||
<section className="ui-card ui-enter-delayed space-y-4 p-4 md:p-6">
|
||||
<h2 className="text-lg font-semibold md:text-xl">备份与恢复</h2>
|
||||
<p className="text-sm text-muted-foreground">恢复会覆盖当前全部文档,请谨慎操作。</p>
|
||||
|
||||
<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);
|
||||
if (isExporting || isRestoring) return;
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const { pages } = await import("@/lib/store").then((m) => m.useEditorStore.getState());
|
||||
const { exportAllPagesAsZip } = await import("@/lib/export");
|
||||
await exportAllPagesAsZip(pages);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
}}
|
||||
disabled={isExporting || isRestoring}
|
||||
aria-busy={isExporting}
|
||||
className="ui-btn-secondary"
|
||||
>
|
||||
<Download size={14} />
|
||||
下载备份 (Zip)
|
||||
{isExporting ? <Loader2 size={14} className="animate-spin" /> : <Download size={14} />}
|
||||
{isExporting ? "导出中..." : "下载备份 (Zip)"}
|
||||
</button>
|
||||
|
||||
<label className="ui-btn inline-flex cursor-pointer border border-destructive/30 bg-destructive/10 text-destructive hover:bg-destructive/20">
|
||||
<Upload size={14} />
|
||||
上传备份并恢复
|
||||
<label
|
||||
className={`ui-btn inline-flex border border-destructive/30 bg-destructive/10 text-destructive hover:bg-destructive/20 ${
|
||||
isExporting || isRestoring ? "cursor-not-allowed opacity-55" : "cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
{isRestoring ? <Loader2 size={14} className="animate-spin" /> : <Upload size={14} />}
|
||||
{isRestoring ? "恢复中..." : "上传备份并恢复"}
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip"
|
||||
className="hidden"
|
||||
disabled={isExporting || isRestoring}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -284,6 +299,7 @@ export default function SettingsPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
setIsRestoring(true);
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const res = await fetch("/api/settings/restore", { method: "POST", body: formData });
|
||||
@@ -295,6 +311,7 @@ export default function SettingsPage() {
|
||||
} catch (error) {
|
||||
alert(String(error));
|
||||
} finally {
|
||||
setIsRestoring(false);
|
||||
e.target.value = "";
|
||||
}
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user