feat: 写作体验优化 + 封面图 + AI辅助写作 + 博客分页

写作体验优化:
- 自动保存草稿到 localStorage(debounce 2s,刷新不丢内容)
- 浏览器原生全屏专注写作模式
- Markdown 编辑模式(左右分栏,实时预览)
- 快捷键:Ctrl+S / Ctrl+Enter 保存,ESC 退出全屏

封面图功能:
- PostForm 新增封面图 URL 输入 + 实时预览
- BlogList 文章卡片显示封面缩略图
- PostContent 文章详情页显示封面大图

AI 辅助写作:
- OpenAI 兼容接口,SSE 流式返回
- 8 种预设操作:润色、扩写、精简、续写、纠错、翻译、摘要
- 自定义指令输入,结果可复制/替换/追加

其他:
- 后台文章列表改为一页10篇
- 前台 /blog 页面添加分页功能(一页10篇)
This commit is contained in:
胡旭
2026-06-24 15:22:03 +08:00
parent 18e915bcbb
commit 43e1c2f61d
11 changed files with 821 additions and 62 deletions
+83 -42
View File
@@ -8,6 +8,7 @@ import Placeholder from "@tiptap/extension-placeholder";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import { common, createLowlight } from "lowlight";
import { Toggle } from "@/components/ui/toggle";
import dynamic from "next/dynamic";
import {
Bold,
Italic,
@@ -25,20 +26,33 @@ import {
Minus,
Undo2,
Redo2,
Maximize2,
Minimize2,
FileText,
} from "lucide-react";
const MarkdownEditor = dynamic(() => import("./MarkdownEditor"), { ssr: false });
const lowlight = createLowlight(common);
interface RichEditorProps {
value: string;
onChange: (html: string) => void;
placeholder?: string;
isFullscreen?: boolean;
isMarkdown?: boolean;
onToggleFullscreen?: () => void;
onSwitchToMarkdown?: () => void;
}
export default function RichEditor({
value,
onChange,
placeholder = "开始写文章...",
isFullscreen,
isMarkdown,
onToggleFullscreen,
onSwitchToMarkdown,
}: RichEditorProps) {
const editor = useEditor({
extensions: [
@@ -63,8 +77,7 @@ export default function RichEditor({
},
editorProps: {
attributes: {
class:
"prose-literary min-h-[300px] px-4 py-3 focus:outline-none",
class: "prose-literary min-h-[300px] px-4 py-3 focus:outline-none",
},
},
});
@@ -90,17 +103,18 @@ export default function RichEditor({
}
}
const isFs = !!isFullscreen;
return (
<div className="rounded-xl border border-border bg-card overflow-hidden">
{/* 工具栏 */}
<div className="flex flex-wrap items-center gap-0.5 px-2 py-1.5 border-b border-border bg-muted/30">
{/* 格式 */}
<div className={isFs ? "flex flex-col h-full bg-transparent" : "rounded-xl border border-border bg-card overflow-hidden"}>
{/* 工具栏 — 始终显示 */}
<div className={`flex flex-wrap items-center gap-0.5 border-b border-border/50 shrink-0 ${isFs ? "px-4 py-2.5" : "px-2 py-1.5 bg-muted/30"}`}>
{/* 富文本格式按钮 — Markdown 模式下禁用 */}
<Toggle
size="sm"
pressed={editor.isActive("heading", { level: 1 })}
onPressedChange={() =>
editor.chain().focus().toggleHeading({ level: 1 }).run()
}
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
disabled={!!isMarkdown}
aria-label="标题 1"
>
<Heading1 className="h-4 w-4" />
@@ -108,9 +122,8 @@ export default function RichEditor({
<Toggle
size="sm"
pressed={editor.isActive("heading", { level: 2 })}
onPressedChange={() =>
editor.chain().focus().toggleHeading({ level: 2 }).run()
}
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
disabled={!!isMarkdown}
aria-label="标题 2"
>
<Heading2 className="h-4 w-4" />
@@ -118,9 +131,8 @@ export default function RichEditor({
<Toggle
size="sm"
pressed={editor.isActive("heading", { level: 3 })}
onPressedChange={() =>
editor.chain().focus().toggleHeading({ level: 3 }).run()
}
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
disabled={!!isMarkdown}
aria-label="标题 3"
>
<Heading3 className="h-4 w-4" />
@@ -132,6 +144,7 @@ export default function RichEditor({
size="sm"
pressed={editor.isActive("bold")}
onPressedChange={() => editor.chain().focus().toggleBold().run()}
disabled={!!isMarkdown}
aria-label="粗体"
>
<Bold className="h-4 w-4" />
@@ -140,6 +153,7 @@ export default function RichEditor({
size="sm"
pressed={editor.isActive("italic")}
onPressedChange={() => editor.chain().focus().toggleItalic().run()}
disabled={!!isMarkdown}
aria-label="斜体"
>
<Italic className="h-4 w-4" />
@@ -148,6 +162,7 @@ export default function RichEditor({
size="sm"
pressed={editor.isActive("strike")}
onPressedChange={() => editor.chain().focus().toggleStrike().run()}
disabled={!!isMarkdown}
aria-label="删除线"
>
<Strikethrough className="h-4 w-4" />
@@ -156,6 +171,7 @@ export default function RichEditor({
size="sm"
pressed={editor.isActive("code")}
onPressedChange={() => editor.chain().focus().toggleCode().run()}
disabled={!!isMarkdown}
aria-label="行内代码"
>
<Code className="h-4 w-4" />
@@ -166,9 +182,8 @@ export default function RichEditor({
<Toggle
size="sm"
pressed={editor.isActive("blockquote")}
onPressedChange={() =>
editor.chain().focus().toggleBlockquote().run()
}
onPressedChange={() => editor.chain().focus().toggleBlockquote().run()}
disabled={!!isMarkdown}
aria-label="引用"
>
<Quote className="h-4 w-4" />
@@ -176,9 +191,8 @@ export default function RichEditor({
<Toggle
size="sm"
pressed={editor.isActive("bulletList")}
onPressedChange={() =>
editor.chain().focus().toggleBulletList().run()
}
onPressedChange={() => editor.chain().focus().toggleBulletList().run()}
disabled={!!isMarkdown}
aria-label="无序列表"
>
<List className="h-4 w-4" />
@@ -186,9 +200,8 @@ export default function RichEditor({
<Toggle
size="sm"
pressed={editor.isActive("orderedList")}
onPressedChange={() =>
editor.chain().focus().toggleOrderedList().run()
}
onPressedChange={() => editor.chain().focus().toggleOrderedList().run()}
disabled={!!isMarkdown}
aria-label="有序列表"
>
<ListOrdered className="h-4 w-4" />
@@ -199,29 +212,22 @@ export default function RichEditor({
<Toggle
size="sm"
pressed={editor.isActive("codeBlock")}
onPressedChange={() =>
editor.chain().focus().toggleCodeBlock().run()
}
onPressedChange={() => editor.chain().focus().toggleCodeBlock().run()}
disabled={!!isMarkdown}
aria-label="代码块"
>
<CodeSquare className="h-4 w-4" />
</Toggle>
<Toggle
size="sm"
pressed={editor.isActive("link")}
onPressedChange={addLink}
aria-label="链接"
>
<Toggle size="sm" onPressedChange={addLink} disabled={!!isMarkdown} aria-label="链接">
<LinkIcon className="h-4 w-4" />
</Toggle>
<Toggle size="sm" onPressedChange={addImage} aria-label="图片">
<Toggle size="sm" onPressedChange={addImage} disabled={!!isMarkdown} aria-label="图片">
<ImageIcon className="h-4 w-4" />
</Toggle>
<Toggle
size="sm"
onPressedChange={() =>
editor.chain().focus().setHorizontalRule().run()
}
onPressedChange={() => editor.chain().focus().setHorizontalRule().run()}
disabled={!!isMarkdown}
aria-label="分割线"
>
<Minus className="h-4 w-4" />
@@ -229,11 +235,10 @@ export default function RichEditor({
<div className="w-px h-5 bg-border mx-1" />
{/* 撤销/重做 */}
<Toggle
size="sm"
onPressedChange={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
disabled={!!isMarkdown || !editor.can().undo()}
aria-label="撤销"
>
<Undo2 className="h-4 w-4" />
@@ -241,15 +246,51 @@ export default function RichEditor({
<Toggle
size="sm"
onPressedChange={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
disabled={!!isMarkdown || !editor.can().redo()}
aria-label="重做"
>
<Redo2 className="h-4 w-4" />
</Toggle>
<div className="flex-1" />
{/* Markdown 切换 */}
{onSwitchToMarkdown && (
<Toggle
size="sm"
pressed={!!isMarkdown}
onPressedChange={onSwitchToMarkdown}
aria-label={isMarkdown ? "切换到富文本" : "Markdown 模式"}
title={isMarkdown ? "切换到富文本" : "Markdown 模式"}
>
<FileText className="h-4 w-4" />
</Toggle>
)}
{/* 全屏切换 */}
{onToggleFullscreen && (
<Toggle
size="sm"
onPressedChange={onToggleFullscreen}
aria-label={isFullscreen ? "退出全屏" : "全屏写作"}
title={isFullscreen ? "退出全屏" : "全屏写作"}
>
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</Toggle>
)}
</div>
{/* 编辑区域 */}
<EditorContent editor={editor} />
{/* 内容区域 — 根据模式切换 */}
<div className={isFs ? "flex-1 overflow-auto" : ""}>
{isMarkdown ? (
<MarkdownEditor
value={value}
onChange={onChange}
/>
) : (
<EditorContent editor={editor} />
)}
</div>
</div>
);
}