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
+1
View File
@@ -23,6 +23,7 @@
"gsap": "^3.15.0",
"lowlight": "^3.3.0",
"lucide-react": "^1.21.0",
"marked": "^18.0.5",
"next": "16.2.9",
"next-themes": "^0.4.6",
"prisma": "5",
+10
View File
@@ -50,6 +50,9 @@ importers:
lucide-react:
specifier: ^1.21.0
version: 1.21.0(react@19.2.4)
marked:
specifier: ^18.0.5
version: 18.0.5
next:
specifier: 16.2.9
version: 16.2.9(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -2464,6 +2467,11 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
marked@18.0.5:
resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==}
engines: {node: '>= 20'}
hasBin: true
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -5777,6 +5785,8 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
marked@18.0.5: {}
math-intrinsics@1.1.0: {}
media-typer@1.1.0: {}
+19 -13
View File
@@ -3,24 +3,24 @@ import GsapReveal from "@/components/GsapReveal";
export const metadata = {
title: "关于",
description: "关于胡旭和这个博客",
description: "关于和这个博客",
};
const timeline = [
{
year: "2026",
title: "开始写博客",
desc: "用 Next.js 搭建个人博客,记录技术与生活",
title: "AI 图像生成",
desc: "深入研究 Stable Diffusion,在 Apple Silicon 上部署本地 AI 环境",
},
{
year: "2025",
title: "sui_lightbox 项目",
title: "lightbox 项目",
desc: "开发3D灯箱模拟平台,探索标识行业的数字化可能",
},
{
year: "2024",
title: "AI 图像生成",
desc: "深入研究 Stable Diffusion,在 Apple Silicon 上部署本地 AI 环境",
title: "开始学习AI",
desc: "从大模型到Dify,探索AI在开发中的应用",
},
{
year: "2023",
@@ -29,8 +29,13 @@ const timeline = [
},
{
year: "2022",
title: "前端开发",
desc: "从后端转向全栈,React + TypeScript 成为主力技术栈",
title: "搭建博客",
desc: "用 React 搭建个人博客1.0,记录技术与生活",
},
{
year: "2021",
title: "入行前端",
desc: "从学校走出来,开始在互联网公司做前端开发",
},
];
@@ -55,8 +60,8 @@ export default function AboutPage() {
className="space-y-6 font-body text-base text-ink-light leading-relaxed"
>
<p>
<span className="text-ink font-medium"></span>
<span className="text-ink font-medium">Sui</span>
00
</p>
<p>
AI Web 3D
@@ -69,7 +74,7 @@ export default function AboutPage() {
</p>
<p className="text-ink-muted">
</p>
</GsapReveal>
@@ -204,8 +209,9 @@ export default function AboutPage() {
使 <span className="text-terracotta">Next.js 16</span>{" "}
{" "}
<span className="text-terracotta">Tailwind CSS 4</span>
SQLite 使 Noto Serif SC
Cormorant Garamond
SQLite 使 Noto Serif
SC Cormorant Garamond
</p>
</div>
</GsapReveal>
+1 -1
View File
@@ -20,7 +20,7 @@ export default function PostsPage() {
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [counts, setCounts] = useState({ all: 0, published: 0, draft: 0 });
const pageSize = 20;
const pageSize = 10;
const { toast } = useToast();
// 搜索 debounce300ms 后才更新 debouncedSearch
+94
View File
@@ -0,0 +1,94 @@
import { NextRequest } from "next/server";
import { requireAuth } from "@/lib/http";
const BASE_URL = process.env.AI_BASE_URL || "https://api.openai.com/v1";
const API_KEY = process.env.AI_API_KEY || "";
const MODEL = process.env.AI_MODEL || "gpt-4o-mini";
export async function POST(request: NextRequest) {
const deny = await requireAuth();
if (deny) return deny;
if (!API_KEY) {
return new Response(JSON.stringify({ error: "未配置 AI_API_KEY" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
let body: { prompt: string; selectedText?: string; action?: string };
try {
body = await request.json();
} catch {
return new Response(JSON.stringify({ error: "请求格式错误" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const { prompt, selectedText, action } = body;
// 构建系统提示
const systemPrompt = `你是一个中文博客写作助手。请用简洁、自然的中文回复。
- 保持原文风格和语气
- 不要添加多余的解释或前缀
- 直接输出结果文本`;
// 根据 action 构建用户提示
let userPrompt = prompt;
if (action && selectedText) {
const actionMap: Record<string, string> = {
polish: `请润色以下文字,使其更流畅、更优美,保持原意:\n\n${selectedText}`,
expand: `请扩写以下文字,补充更多细节和论述,保持风格一致:\n\n${selectedText}`,
shorten: `请精简以下文字,保留核心意思,去除冗余:\n\n${selectedText}`,
continue: `请根据以下内容自然地续写下去,保持风格和语气一致:\n\n${selectedText}`,
translate_en: `请将以下中文翻译为英文,保持自然流畅:\n\n${selectedText}`,
translate_zh: `请将以下英文翻译为中文,保持自然流畅:\n\n${selectedText}`,
summarize: `请为以下文章写一段简短的摘要(2-3句话):\n\n${selectedText}`,
fix_grammar: `请修正以下文字中的语法错误和错别字,保持原意:\n\n${selectedText}`,
};
userPrompt = actionMap[action] || `请处理以下文字:\n\n${selectedText}`;
}
try {
const res = await fetch(`${BASE_URL}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({
model: MODEL,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
stream: true,
temperature: 0.7,
max_tokens: 2000,
}),
});
if (!res.ok) {
const err = await res.text();
return new Response(JSON.stringify({ error: `AI 请求失败: ${err}` }), {
status: res.status,
headers: { "Content-Type": "application/json" },
});
}
// 转发 SSE 流
return new Response(res.body, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
} catch (err) {
return new Response(
JSON.stringify({ error: `AI 请求异常: ${err instanceof Error ? err.message : "未知错误"}` }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
}
+61 -3
View File
@@ -8,6 +8,7 @@ import { ScrollTrigger } from "gsap/ScrollTrigger";
import type { PublicPost } from "@/lib/store";
import { formatDate, readingTimeLabel } from "@/lib/utils";
import { useGsapAnimation } from "./useGsapAnimation";
import { ChevronLeft, ChevronRight } from "lucide-react";
gsap.registerPlugin(ScrollTrigger);
@@ -20,6 +21,8 @@ export default function BlogList({ posts }: BlogListProps) {
const searchParams = useSearchParams();
const activeCategory = searchParams.get("category") || "";
const activeTag = searchParams.get("tag") || "";
const page = Number(searchParams.get("page")) || 1;
const pageSize = 10;
const headerRef = useGsapAnimation<HTMLDivElement>((_, isReduced) => {
if (isReduced) return;
@@ -66,6 +69,13 @@ export default function BlogList({ posts }: BlogListProps) {
});
}, [posts, activeCategory, activeTag]);
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
const currentPage = Math.min(page, totalPages);
const paged = useMemo(() => {
const start = (currentPage - 1) * pageSize;
return filtered.slice(start, start + pageSize);
}, [filtered, currentPage]);
function setFilter(key: "category" | "tag", value: string) {
const params = new URLSearchParams(searchParams.toString());
if (value) params.set(key, value);
@@ -73,6 +83,15 @@ export default function BlogList({ posts }: BlogListProps) {
// 切换一个维度时清除另一个,避免组合空结果困惑
if (key === "category") params.delete("tag");
if (key === "tag") params.delete("category");
params.delete("page"); // 切换筛选时重置分页
const qs = params.toString();
router.push(qs ? `/blog?${qs}` : "/blog", { scroll: false });
}
function setPage(p: number) {
const params = new URLSearchParams(searchParams.toString());
if (p > 1) params.set("page", String(p));
else params.delete("page");
const qs = params.toString();
router.push(qs ? `/blog?${qs}` : "/blog", { scroll: false });
}
@@ -136,12 +155,12 @@ export default function BlogList({ posts }: BlogListProps) {
</div>
{/* Post list */}
{filtered.length > 0 ? (
{paged.length > 0 ? (
<div ref={listRef as React.RefObject<HTMLDivElement>} className="space-y-0">
{filtered.map((post) => (
{paged.map((post) => (
<Link key={post.slug} href={`/posts/${post.slug}`} className="blog-list-item group block">
<article className="relative py-8 border-b border-warm-gray/10 hover:bg-cream -mx-4 px-4 rounded-xl transition-all duration-300">
<div className="flex flex-col md:flex-row md:items-start gap-3 md:gap-8">
<div className="flex flex-col md:flex-row md:items-start gap-3 md:gap-6">
<div className="shrink-0 md:w-36 md:pt-1">
<time className="font-sans text-sm text-ink-muted tabular-nums">
{formatDate(post.date)}
@@ -168,6 +187,12 @@ export default function BlogList({ posts }: BlogListProps) {
))}
</div>
</div>
{post.coverImage && (
<div className="hidden md:block shrink-0 w-28 h-20 rounded-lg overflow-hidden bg-parchment-deep">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={post.coverImage} alt="" className="w-full h-full object-cover" />
</div>
)}
<div className="hidden md:flex shrink-0 items-center opacity-0 group-hover:opacity-100 transition-opacity duration-300 pt-2">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" className="text-terracotta">
<path d="M7 12h10M13 8l4 4-4 4" />
@@ -184,6 +209,39 @@ export default function BlogList({ posts }: BlogListProps) {
<p className="font-sans text-sm text-ink-muted"></p>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-12 font-sans text-sm">
<button
onClick={() => setPage(currentPage - 1)}
disabled={currentPage <= 1}
className="p-2 rounded-lg text-ink-muted hover:text-terracotta disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft className="w-4 h-4" />
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
<button
key={p}
onClick={() => setPage(p)}
className={`w-8 h-8 rounded-lg transition-colors ${
p === currentPage
? "bg-ink text-cream"
: "text-ink-muted hover:text-terracotta hover:bg-cream"
}`}
>
{p}
</button>
))}
<button
onClick={() => setPage(currentPage + 1)}
disabled={currentPage >= totalPages}
className="p-2 rounded-lg text-ink-muted hover:text-terracotta disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
)}
</div>
);
}
+12
View File
@@ -105,6 +105,18 @@ export default function PostContent({
</div>
</header>
{/* Cover image */}
{post.coverImage && (
<div className="max-w-3xl mx-auto mb-14">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={post.coverImage}
alt={post.title}
className="w-full rounded-2xl object-cover max-h-[480px]"
/>
</div>
)}
{/* Decorative divider */}
<div className="max-w-2xl mx-auto mb-14">
<div className="flex items-center justify-center gap-3">
+242
View File
@@ -0,0 +1,242 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { Button } from "@/components/ui/button";
import {
Sparkles,
Wand2,
Maximize2,
Minimize2,
ArrowRight,
Languages,
FileText,
Bug,
X,
Loader2,
Copy,
Check,
} from "lucide-react";
interface AiAssistantProps {
/** 当前编辑器的纯文本内容 */
content: string;
/** 将 AI 结果插入/替换到编辑器 */
onInsert: (text: string, mode: "replace" | "append") => void;
}
type Action = "polish" | "expand" | "shorten" | "continue" | "translate_en" | "translate_zh" | "summarize" | "fix_grammar";
const ACTIONS: { key: Action; label: string; icon: React.ReactNode; needSelection?: boolean }[] = [
{ key: "polish", label: "润色", icon: <Wand2 className="w-3.5 h-3.5" /> },
{ key: "expand", label: "扩写", icon: <Maximize2 className="w-3.5 h-3.5" /> },
{ key: "shorten", label: "精简", icon: <Minimize2 className="w-3.5 h-3.5" /> },
{ key: "continue", label: "续写", icon: <ArrowRight className="w-3.5 h-3.5" /> },
{ key: "fix_grammar", label: "纠错", icon: <Bug className="w-3.5 h-3.5" /> },
{ key: "translate_en", label: "中→英", icon: <Languages className="w-3.5 h-3.5" /> },
{ key: "translate_zh", label: "英→中", icon: <Languages className="w-3.5 h-3.5" /> },
{ key: "summarize", label: "摘要", icon: <FileText className="w-3.5 h-3.5" /> },
];
export default function AiAssistant({ content, onInsert }: AiAssistantProps) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState("");
const [customPrompt, setCustomPrompt] = useState("");
const [copied, setCopied] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const stopGeneration = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setLoading(false);
}, []);
async function runAction(action?: Action) {
const text = content.replace(/<[^>]+>/g, "").trim();
if (!text && action !== "summarize") {
setResult("请先输入文章内容");
setOpen(true);
return;
}
setLoading(true);
setResult("");
setOpen(true);
abortRef.current = new AbortController();
try {
const res = await fetch("/api/ai", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: customPrompt || undefined,
selectedText: text || undefined,
action,
}),
signal: abortRef.current.signal,
});
if (!res.ok) {
const err = await res.json();
setResult(`错误:${err.error || "请求失败"}`);
setLoading(false);
return;
}
// 读取 SSE 流
const reader = res.body?.getReader();
const decoder = new TextDecoder();
let accumulated = "";
if (reader) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
// 解析 SSE 数据
for (const line of chunk.split("\n")) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") continue;
try {
const parsed = JSON.parse(data);
const delta = parsed.choices?.[0]?.delta?.content;
if (delta) {
accumulated += delta;
setResult(accumulated);
}
} catch {
// 忽略解析错误
}
}
}
}
}
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
setResult((prev) => prev + "\n\n[已停止]");
} else {
setResult(`错误:${err instanceof Error ? err.message : "未知错误"}`);
}
} finally {
setLoading(false);
abortRef.current = null;
}
}
function handleCopy() {
navigator.clipboard.writeText(result);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
function handleInsert(mode: "replace" | "append") {
onInsert(result, mode);
setResult("");
setOpen(false);
}
return (
<div className="space-y-2">
{/* 触发按钮 */}
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-1.5 font-sans text-xs text-muted-foreground hover:text-primary transition-colors"
>
<Sparkles className="w-3.5 h-3.5" />
AI
</button>
{open && (
<div className="rounded-xl border border-border bg-card p-4 space-y-3">
{/* 关闭按钮 */}
<div className="flex items-center justify-between">
<span className="font-sans text-xs text-muted-foreground"></span>
<button type="button" onClick={() => { setOpen(false); stopGeneration(); }} className="text-muted-foreground hover:text-foreground">
<X className="w-4 h-4" />
</button>
</div>
{/* 操作按钮组 */}
<div className="flex flex-wrap gap-1.5">
{ACTIONS.map((a) => (
<button
key={a.key}
type="button"
disabled={loading}
onClick={() => runAction(a.key)}
className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg border border-border font-sans text-xs text-foreground hover:bg-muted/50 disabled:opacity-50 transition-colors"
>
{a.icon}
{a.label}
</button>
))}
</div>
{/* 自定义指令 */}
<div className="flex gap-2">
<input
value={customPrompt}
onChange={(e) => setCustomPrompt(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); runAction(); } }}
placeholder="或输入自定义指令..."
className="flex-1 h-8 px-3 rounded-lg border border-border bg-transparent font-sans text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
disabled={loading}
/>
<Button size="sm" variant="outline" disabled={loading || !customPrompt.trim()} onClick={() => runAction()} className="h-8 px-3 text-xs">
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Sparkles className="w-3.5 h-3.5" />}
</Button>
</div>
{/* 结果 */}
{result && (
<div className="space-y-2">
<div className="relative rounded-lg border border-border bg-muted/30 p-3 max-h-60 overflow-auto">
<pre className="font-sans text-sm text-foreground whitespace-pre-wrap leading-relaxed">{result}</pre>
{loading && (
<button
type="button"
onClick={stopGeneration}
className="absolute top-2 right-2 px-2 py-0.5 rounded text-xs bg-red-600 text-white hover:bg-red-700 transition-colors"
>
</button>
)}
</div>
{/* 操作按钮 */}
{!loading && result && (
<div className="flex gap-2">
<button
type="button"
onClick={handleCopy}
className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg border border-border font-sans text-xs text-foreground hover:bg-muted/50 transition-colors"
>
{copied ? <Check className="w-3.5 h-3.5 text-green-600" /> : <Copy className="w-3.5 h-3.5" />}
{copied ? "已复制" : "复制"}
</button>
<button
type="button"
onClick={() => handleInsert("replace")}
className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg bg-primary text-primary-foreground font-sans text-xs hover:bg-primary/90 transition-colors"
>
</button>
<button
type="button"
onClick={() => handleInsert("append")}
className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg border border-border font-sans text-xs text-foreground hover:bg-muted/50 transition-colors"
>
</button>
</div>
)}
</div>
)}
</div>
)}
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { marked } from "marked";
interface MarkdownEditorProps {
value: string; // HTML from parent
onChange: (html: string) => void;
placeholder?: string;
}
/** 简易 HTML → Markdown 转换(仅处理常见标签) */
function htmlToMarkdown(html: string): string {
if (!html.trim()) return "";
let md = html;
md = md.replace(/<h1[^>]*>(.*?)<\/h1>/gi, "# $1\n\n");
md = md.replace(/<h2[^>]*>(.*?)<\/h2>/gi, "## $1\n\n");
md = md.replace(/<h3[^>]*>(.*?)<\/h3>/gi, "### $1\n\n");
md = md.replace(/<strong[^>]*>(.*?)<\/strong>/gi, "**$1**");
md = md.replace(/<b[^>]*>(.*?)<\/b>/gi, "**$1**");
md = md.replace(/<em[^>]*>(.*?)<\/em>/gi, "*$1*");
md = md.replace(/<i[^>]*>(.*?)<\/i>/gi, "*$1*");
md = md.replace(/<s[^>]*>(.*?)<\/s>/gi, "~~$1~~");
md = md.replace(/<strike[^>]*>(.*?)<\/strike>/gi, "~~$1~~");
md = md.replace(/<del[^>]*>(.*?)<\/del>/gi, "~~$1~~");
md = md.replace(/<code[^>]*>(.*?)<\/code>/gi, "`$1`");
md = md.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, "[$2]($1)");
md = md.replace(/<img[^>]*src="([^"]*)"[^>]*alt="([^"]*)"[^>]*\/?>/gi, "![$2]($1)");
md = md.replace(/<img[^>]*src="([^"]*)"[^>]*\/?>/gi, "![]($1)");
md = md.replace(/<blockquote[^>]*>(.*?)<\/blockquote>/gis, (_, content) =>
content.trim().split("\n").map((l: string) => `> ${l.trim()}`).join("\n") + "\n\n"
);
md = md.replace(/<li[^>]*>(.*?)<\/li>/gi, "- $1\n");
md = md.replace(/<\/?[uo]l[^>]*>/gi, "\n");
md = md.replace(/<hr[^>]*\/?>/gi, "\n---\n\n");
md = md.replace(/<p[^>]*>(.*?)<\/p>/gis, "$1\n\n");
md = md.replace(/<br[^>]*\/?>/gi, "\n");
md = md.replace(/<pre[^>]*><code[^>]*>(.*?)<\/code><\/pre>/gis, "```\n$1\n```\n\n");
md = md.replace(/<[^>]+>/g, "");
md = md.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"');
md = md.replace(/\n{3,}/g, "\n\n");
return md.trim();
}
export default function MarkdownEditor({
value,
onChange,
placeholder = "使用 Markdown 语法写作...",
}: MarkdownEditorProps) {
const [markdown, setMarkdown] = useState(() => htmlToMarkdown(value));
// 外部 value 变化时(如恢复草稿),同步到 markdown
useEffect(() => {
const converted = htmlToMarkdown(value);
// 只在内容真正不同时更新,避免光标跳动
if (converted !== markdown && marked.parse(markdown) !== value) {
setMarkdown(converted);
}
}, [value]); // eslint-disable-line react-hooks/exhaustive-deps
// Markdown → HTML,同步给父组件
const html = useMemo(() => {
try {
return marked.parse(markdown) as string;
} catch {
return markdown;
}
}, [markdown]);
function handleChange(md: string) {
setMarkdown(md);
try {
const newHtml = marked.parse(md) as string;
onChange(newHtml);
} catch {
onChange(md);
}
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 divide-y md:divide-y-0 md:divide-x divide-border h-full">
{/* 编辑区 */}
<div className="flex flex-col">
<div className="px-3 py-1.5 border-b border-border bg-muted/30">
<span className="font-sans text-xs text-muted-foreground">Markdown</span>
</div>
<textarea
value={markdown}
onChange={(e) => handleChange(e.target.value)}
placeholder={placeholder}
className="flex-1 min-h-0 px-4 py-3 bg-transparent font-mono text-sm text-foreground resize-none focus:outline-none leading-relaxed"
spellCheck={false}
/>
</div>
{/* 预览区 */}
<div className="flex flex-col">
<div className="px-3 py-1.5 border-b border-border bg-muted/30">
<span className="font-sans text-xs text-muted-foreground"></span>
</div>
<div
className="flex-1 min-h-0 px-4 py-3 overflow-auto prose-literary"
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
</div>
);
}
+189 -2
View File
@@ -10,6 +10,8 @@ import { Switch } from "@/components/ui/switch";
import dynamic from "next/dynamic";
const RichEditor = dynamic(() => import("./RichEditor"), { ssr: false });
const MarkdownEditor = dynamic(() => import("./MarkdownEditor"), { ssr: false });
import AiAssistant from "./AiAssistant";
import {
Select,
SelectContent,
@@ -50,6 +52,7 @@ export default function PostForm({
slug: initialData?.slug ?? "",
excerpt: initialData?.excerpt ?? "",
content: initialData?.content ?? "",
coverImage: initialData?.coverImage ?? "",
category: initialData?.category ?? "",
tags: initialData?.tags ?? ([] as string[]),
readingTime: initialData?.readingTime ?? 5,
@@ -63,6 +66,17 @@ export default function PostForm({
const [dirty, setDirty] = useState(false);
const originalRef = useRef(JSON.stringify(form));
// 全屏 / Markdown 模式
const [isFullscreen, setIsFullscreen] = useState(false);
const [isMarkdown, setIsMarkdown] = useState(false);
const fullscreenRef = useRef<HTMLDivElement>(null);
// 自动保存
const autoSaveKey = `draft:${mode === "edit" ? initialData?.id ?? "edit" : "new"}`;
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const formRef = useRef<HTMLFormElement>(null);
// 离开确认
useEffect(() => {
if (!dirty) return;
@@ -80,6 +94,83 @@ export default function PostForm({
}
}, [form]);
// ── 自动保存到 localStoragedebounce 2s ──
useEffect(() => {
if (!dirty) return;
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(() => {
try {
localStorage.setItem(autoSaveKey, JSON.stringify(form));
setLastSaved(new Date());
} catch { /* quota exceeded, ignore */ }
}, 2000);
return () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
};
}, [form, dirty, autoSaveKey]);
// ── 页面加载时恢复草稿 ──
useEffect(() => {
if (mode === "edit") return; // 编辑模式不自动恢复,避免覆盖已有内容
try {
const saved = localStorage.getItem(autoSaveKey);
if (saved) {
const parsed = JSON.parse(saved);
// 只恢复有实际内容的草稿
if (parsed.title || parsed.content) {
setForm((prev) => ({ ...prev, ...parsed }));
setLastSaved(new Date());
}
}
} catch { /* corrupt data, ignore */ }
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// ── 快捷键 ──
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
const isMod = e.metaKey || e.ctrlKey;
// Ctrl/Cmd + S → 保存
if (isMod && e.key === "s") {
e.preventDefault();
formRef.current?.requestSubmit();
}
// Ctrl/Cmd + Enter → 保存
if (isMod && e.key === "Enter") {
e.preventDefault();
formRef.current?.requestSubmit();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
// ── 浏览器原生全屏 API ──
function enterFullscreen() {
setIsFullscreen(true);
// 等 DOM 更新后再调用原生全屏
requestAnimationFrame(() => {
fullscreenRef.current?.requestFullscreen?.().catch(() => {});
});
}
function exitFullscreen() {
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
}
setIsFullscreen(false);
}
// 监听浏览器全屏变化(用户按 Esc 或 F11 退出时同步状态)
useEffect(() => {
function onFullscreenChange() {
if (!document.fullscreenElement && isFullscreen) {
setIsFullscreen(false);
}
}
document.addEventListener("fullscreenchange", onFullscreenChange);
return () => document.removeEventListener("fullscreenchange", onFullscreenChange);
}, [isFullscreen]);
function validate(): boolean {
const errs: Record<string, string> = {};
if (!form.title.trim()) errs.title = "请输入标题";
@@ -99,6 +190,9 @@ export default function PostForm({
try {
const slug = form.slug || autoSlug(form.title);
await onSubmit({ ...form, slug });
// 保存成功,清除草稿
localStorage.removeItem(autoSaveKey);
setDirty(false);
} finally {
setSubmitting(false);
}
@@ -125,8 +219,52 @@ export default function PostForm({
}
}
// ── 全屏专注模式 ──
if (isFullscreen) {
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div ref={fullscreenRef} className="fixed inset-0 z-[100] bg-background flex flex-col">
{/* 顶部极简栏 */}
<div className="flex items-center gap-4 px-6 py-3 border-b border-border/50 shrink-0">
<span className="font-display text-base text-foreground truncate flex-1 opacity-60">
{form.title || "未命名文章"}
</span>
<span className="font-sans text-xs text-muted-foreground tabular-nums">
{form.content.replace(/<[^>]+>/g, "").length}
</span>
{lastSaved && (
<span className="font-sans text-xs text-muted-foreground tabular-nums">
{dirty ? "未保存" : "已保存"} {lastSaved.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}
</span>
)}
<button
type="button"
onClick={exitFullscreen}
className="font-sans text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-1 rounded"
>
退 Esc
</button>
</div>
{/* 编辑器主体 */}
<div className="flex-1 overflow-hidden">
<div className="w-full h-full px-6 flex flex-col">
<RichEditor
value={form.content}
onChange={(html) => update("content", html)}
isFullscreen
isMarkdown={isMarkdown}
onToggleFullscreen={exitFullscreen}
onSwitchToMarkdown={() => setIsMarkdown((v) => !v)}
/>
</div>
</div>
{/* 隐藏的 form 用于快捷键提交 */}
<form ref={formRef} onSubmit={handleSubmit} className="hidden" />
</div>
);
}
return (
<form ref={formRef} onSubmit={handleSubmit} className="space-y-6">
{/* 标题 */}
<div className="space-y-1.5">
<Label htmlFor="post-title"></Label>
@@ -179,12 +317,50 @@ export default function PostForm({
{errors.excerpt && <p className="text-xs text-red-600">{errors.excerpt}</p>}
</div>
{/* 封面图 */}
<div className="space-y-1.5">
<Label htmlFor="post-cover"></Label>
<Input
id="post-cover"
value={form.coverImage}
onChange={(e) => update("coverImage", e.target.value)}
placeholder="https://example.com/cover.jpg"
/>
{form.coverImage && (
<div className="mt-2 relative w-full max-w-sm aspect-video rounded-lg overflow-hidden border border-border bg-muted">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={form.coverImage}
alt="封面预览"
className="w-full h-full object-cover"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
</div>
)}
</div>
{/* 内容 */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label></Label>
<AiAssistant
content={form.content}
onInsert={(text, mode) => {
if (mode === "replace") {
update("content", text);
} else {
update("content", form.content + "\n\n" + text);
}
}}
/>
</div>
<RichEditor
value={form.content}
onChange={(html) => update("content", html)}
isFullscreen={false}
isMarkdown={isMarkdown}
onToggleFullscreen={enterFullscreen}
onSwitchToMarkdown={() => setIsMarkdown((v) => !v)}
/>
{errors.content && <p className="text-xs text-red-600">{errors.content}</p>}
</div>
@@ -270,14 +446,25 @@ export default function PostForm({
</div>
{/* 操作按钮 */}
<div className="flex gap-3 pt-4">
<div className="flex items-center gap-3 pt-4">
<Button type="submit" disabled={submitting}>
{submitting ? "保存中..." : mode === "create" ? "保存" : "保存修改"}
</Button>
<Button type="button" variant="outline" onClick={onCancel}>
</Button>
<div className="flex-1" />
{lastSaved && (
<span className="font-sans text-xs text-muted-foreground">
{dirty ? "未保存" : "已保存"} · {lastSaved.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}
</span>
)}
</div>
{/* 快捷键提示 */}
<p className="font-sans text-xs text-muted-foreground/60">
Ctrl+S · Ctrl+Enter
</p>
</form>
);
}
+82 -41
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>
{/* 编辑区域 */}
{/* 内容区域 — 根据模式切换 */}
<div className={isFs ? "flex-1 overflow-auto" : ""}>
{isMarkdown ? (
<MarkdownEditor
value={value}
onChange={onChange}
/>
) : (
<EditorContent editor={editor} />
)}
</div>
</div>
);
}