From 43e1c2f61db2110be493f95df5a5b4079b487065 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E6=97=AD?= <> Date: Wed, 24 Jun 2026 15:22:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=86=99=E4=BD=9C=E4=BD=93=E9=AA=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96=20+=20=E5=B0=81=E9=9D=A2=E5=9B=BE=20+=20AI?= =?UTF-8?q?=E8=BE=85=E5=8A=A9=E5=86=99=E4=BD=9C=20+=20=E5=8D=9A=E5=AE=A2?= =?UTF-8?q?=E5=88=86=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 写作体验优化: - 自动保存草稿到 localStorage(debounce 2s,刷新不丢内容) - 浏览器原生全屏专注写作模式 - Markdown 编辑模式(左右分栏,实时预览) - 快捷键:Ctrl+S / Ctrl+Enter 保存,ESC 退出全屏 封面图功能: - PostForm 新增封面图 URL 输入 + 实时预览 - BlogList 文章卡片显示封面缩略图 - PostContent 文章详情页显示封面大图 AI 辅助写作: - OpenAI 兼容接口,SSE 流式返回 - 8 种预设操作:润色、扩写、精简、续写、纠错、翻译、摘要 - 自定义指令输入,结果可复制/替换/追加 其他: - 后台文章列表改为一页10篇 - 前台 /blog 页面添加分页功能(一页10篇) --- package.json | 1 + pnpm-lock.yaml | 10 + src/app/about/page.tsx | 32 ++-- src/app/admin/posts/page.tsx | 2 +- src/app/api/ai/route.ts | 94 +++++++++ src/components/BlogList.tsx | 64 ++++++- src/components/PostContent.tsx | 12 ++ src/components/admin/AiAssistant.tsx | 242 ++++++++++++++++++++++++ src/components/admin/MarkdownEditor.tsx | 108 +++++++++++ src/components/admin/PostForm.tsx | 193 ++++++++++++++++++- src/components/admin/RichEditor.tsx | 125 ++++++++---- 11 files changed, 821 insertions(+), 62 deletions(-) create mode 100644 src/app/api/ai/route.ts create mode 100644 src/components/admin/AiAssistant.tsx create mode 100644 src/components/admin/MarkdownEditor.tsx diff --git a/package.json b/package.json index 24dc730..7af279d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ffb0af8..ecda145 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index b8681c0..4e99a82 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -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" >
- 你好,我是胡旭 - ,一个来自安徽六安的前端开发者。 + 你好,我是Sui + ,一个00后废柴青年兼前端开发者。
我对 AI 图像生成、Web 3D @@ -69,7 +74,7 @@ export default function AboutPage() { 当你试图把一个想法写下来的时候,你会发现自己对它的理解远比想象中要浅。
- 如果你有任何想法或合作意向,欢迎通过以下方式联系我。 + 如果你有任何想法或意见,欢迎通过以下方式联系我。
@@ -204,8 +209,9 @@ export default function AboutPage() { 这个博客使用 Next.js 16{" "} 构建,样式基于{" "} Tailwind CSS 4 - ,数据存储在本地 SQLite 数据库中。字体使用了 Noto Serif SC(宋体)与 - Cormorant Garamond 的组合,追求一种接近纸质杂志的阅读体验。 + ,数据存储在本地 SQLite 数据库中。字体使用了 Noto Serif + SC(宋体)与 Cormorant Garamond + 的组合,追求一种接近纸质杂志的阅读体验。 diff --git a/src/app/admin/posts/page.tsx b/src/app/admin/posts/page.tsx index 48e4b0a..65b2c9d 100644 --- a/src/app/admin/posts/page.tsx +++ b/src/app/admin/posts/page.tsx @@ -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(); // 搜索 debounce:300ms 后才更新 debouncedSearch diff --git a/src/app/api/ai/route.ts b/src/app/api/ai/route.ts new file mode 100644 index 0000000..4f01222 --- /dev/null +++ b/src/app/api/ai/route.ts @@ -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{result}
+ {loading && (
+
+ )}
+ ]*>(.*?)<\/code>/gi, "`$1`");
+ md = md.replace(/]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, "[$2]($1)");
+ md = md.replace(/
]*src="([^"]*)"[^>]*alt="([^"]*)"[^>]*\/?>/gi, "");
+ md = md.replace(/
]*src="([^"]*)"[^>]*\/?>/gi, "");
+ md = md.replace(/]*>(.*?)<\/blockquote>/gis, (_, content) =>
+ content.trim().split("\n").map((l: string) => `> ${l.trim()}`).join("\n") + "\n\n"
+ );
+ md = md.replace(/]*>(.*?)<\/li>/gi, "- $1\n");
+ md = md.replace(/<\/?[uo]l[^>]*>/gi, "\n");
+ md = md.replace(/
]*\/?>/gi, "\n---\n\n");
+ md = md.replace(/]*>(.*?)<\/p>/gis, "$1\n\n");
+ md = md.replace(/
]*\/?>/gi, "\n");
+ md = md.replace(/
]*>]*>(.*?)<\/code><\/pre>/gis, "```\n$1\n```\n\n");
+ md = md.replace(/<[^>]+>/g, "");
+ md = md.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/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 (
+
+ {/* 编辑区 */}
+
+
+ Markdown
+
+
+
+ {/* 预览区 */}
+
+
+ 预览
+
+
+
+
+ );
+}
diff --git a/src/components/admin/PostForm.tsx b/src/components/admin/PostForm.tsx
index 77850be..eb51d7d 100644
--- a/src/components/admin/PostForm.tsx
+++ b/src/components/admin/PostForm.tsx
@@ -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(null);
+
+ // 自动保存
+ const autoSaveKey = `draft:${mode === "edit" ? initialData?.id ?? "edit" : "new"}`;
+ const [lastSaved, setLastSaved] = useState(null);
+ const saveTimerRef = useRef | null>(null);
+ const formRef = useRef(null);
+
// 离开确认
useEffect(() => {
if (!dirty) return;
@@ -80,6 +94,83 @@ export default function PostForm({
}
}, [form]);
+ // ── 自动保存到 localStorage(debounce 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 = {};
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.title || "未命名文章"}
+
+
+ {form.content.replace(/<[^>]+>/g, "").length} 字
+
+ {lastSaved && (
+
+ {dirty ? "未保存" : "已保存"} {lastSaved.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}
+
+ )}
+
+
+ {/* 编辑器主体 */}
+
+
+ update("content", html)}
+ isFullscreen
+ isMarkdown={isMarkdown}
+ onToggleFullscreen={exitFullscreen}
+ onSwitchToMarkdown={() => setIsMarkdown((v) => !v)}
+ />
+
+
+ {/* 隐藏的 form 用于快捷键提交 */}
+
+
+ );
+ }
+
return (
-
+ 快捷键:Ctrl+S 保存 · Ctrl+Enter 保存 +
); } diff --git a/src/components/admin/RichEditor.tsx b/src/components/admin/RichEditor.tsx index 705aed0..5b93f5c 100644 --- a/src/components/admin/RichEditor.tsx +++ b/src/components/admin/RichEditor.tsx @@ -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 ( -
@@ -166,9 +182,8 @@ export default function RichEditor({
@@ -176,9 +191,8 @@ export default function RichEditor({