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 = { + 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" } } + ); + } +} diff --git a/src/components/BlogList.tsx b/src/components/BlogList.tsx index 4454d3e..cc12e6f 100644 --- a/src/components/BlogList.tsx +++ b/src/components/BlogList.tsx @@ -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((_, 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) { {/* Post list */} - {filtered.length > 0 ? ( + {paged.length > 0 ? (
} className="space-y-0"> - {filtered.map((post) => ( + {paged.map((post) => (
-
+
+ {post.coverImage && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+ )}
@@ -184,6 +209,39 @@ export default function BlogList({ posts }: BlogListProps) {

该筛选条件下暂无文章。

)} + + {/* Pagination */} + {totalPages > 1 && ( +
+ + {Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => ( + + ))} + +
+ )}
); } diff --git a/src/components/PostContent.tsx b/src/components/PostContent.tsx index 5d0dca6..be232c1 100644 --- a/src/components/PostContent.tsx +++ b/src/components/PostContent.tsx @@ -105,6 +105,18 @@ export default function PostContent({
+ {/* Cover image */} + {post.coverImage && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {post.title} +
+ )} + {/* Decorative divider */}
diff --git a/src/components/admin/AiAssistant.tsx b/src/components/admin/AiAssistant.tsx new file mode 100644 index 0000000..3f071b5 --- /dev/null +++ b/src/components/admin/AiAssistant.tsx @@ -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: }, + { key: "expand", label: "扩写", icon: }, + { key: "shorten", label: "精简", icon: }, + { key: "continue", label: "续写", icon: }, + { key: "fix_grammar", label: "纠错", icon: }, + { key: "translate_en", label: "中→英", icon: }, + { key: "translate_zh", label: "英→中", icon: }, + { key: "summarize", label: "摘要", icon: }, +]; + +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(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 ( +
+ {/* 触发按钮 */} + + + {open && ( +
+ {/* 关闭按钮 */} +
+ 选择操作或输入自定义指令 + +
+ + {/* 操作按钮组 */} +
+ {ACTIONS.map((a) => ( + + ))} +
+ + {/* 自定义指令 */} +
+ 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} + /> + +
+ + {/* 结果 */} + {result && ( +
+
+
{result}
+ {loading && ( + + )} +
+ + {/* 操作按钮 */} + {!loading && result && ( +
+ + + +
+ )} +
+ )} +
+ )} +
+ ); +} diff --git a/src/components/admin/MarkdownEditor.tsx b/src/components/admin/MarkdownEditor.tsx new file mode 100644 index 0000000..310296a --- /dev/null +++ b/src/components/admin/MarkdownEditor.tsx @@ -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>/gi, "# $1\n\n"); + md = md.replace(/]*>(.*?)<\/h2>/gi, "## $1\n\n"); + md = md.replace(/]*>(.*?)<\/h3>/gi, "### $1\n\n"); + md = md.replace(/]*>(.*?)<\/strong>/gi, "**$1**"); + md = md.replace(/]*>(.*?)<\/b>/gi, "**$1**"); + md = md.replace(/]*>(.*?)<\/em>/gi, "*$1*"); + md = md.replace(/]*>(.*?)<\/i>/gi, "*$1*"); + md = md.replace(/]*>(.*?)<\/s>/gi, "~~$1~~"); + md = md.replace(/]*>(.*?)<\/strike>/gi, "~~$1~~"); + md = md.replace(/]*>(.*?)<\/del>/gi, "~~$1~~"); + md = md.replace(/]*>(.*?)<\/code>/gi, "`$1`"); + md = md.replace(/]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, "[$2]($1)"); + md = md.replace(/]*src="([^"]*)"[^>]*alt="([^"]*)"[^>]*\/?>/gi, "![$2]($1)"); + md = md.replace(/]*src="([^"]*)"[^>]*\/?>/gi, "![]($1)"); + 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 +
+