diff --git a/README.md b/README.md index 5d9cb8c..71fb5a5 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ moon/ - JWT 鉴权 + Casbin RBAC 权限 - 后台管理:`/admin` - 公开博客:`/`(文章列表、详情、归档、关于、友链) +- Markdown 写作(独立的写作页面 + 元信息设置页面) --- diff --git a/frontend/public/Hero.jpg b/frontend/public/Hero.jpg index 70c0f0b..1cf6ae1 100644 Binary files a/frontend/public/Hero.jpg and b/frontend/public/Hero.jpg differ diff --git a/frontend/src/app/(admin)/admin/articles/editor/page.tsx b/frontend/src/app/(admin)/admin/articles/editor/page.tsx index 0e331ec..270c007 100644 --- a/frontend/src/app/(admin)/admin/articles/editor/page.tsx +++ b/frontend/src/app/(admin)/admin/articles/editor/page.tsx @@ -2,34 +2,18 @@ import { useEffect, useState, Suspense, useCallback } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import dynamic from "next/dynamic"; import { api } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { toast } from "sonner"; -import { Save, ArrowLeft, X, Eye, EyeOff, Columns2, FileText } from "lucide-react"; -import { Switch } from "@/components/ui/switch"; +import { Save, ArrowLeft, X, FileText } from "lucide-react"; import { SmartTagInput, CoverImageUploader } from "@/components/editor"; import { useAutoSave } from "@/hooks/use-auto-save"; -import { useMarkdownEditor } from "@/hooks/use-markdown-editor"; import { articleSchema, type ArticleFormData } from "@/lib/validations/article"; import { ZodError } from "zod"; import { cn } from "@/lib/utils"; -const MDEditor = dynamic(() => import("@uiw/react-md-editor"), { ssr: false }); - -/* ============================== - * 常量 - * ============================== */ - -const TOOLBAR_GROUPS = [ - { label: "格式", items: ["bold", "italic", "strikethrough"] }, - { label: "标题", items: ["heading", "quote", "code"] }, - { label: "媒体", items: ["link", "image", "hr"] }, - { label: "列表", items: ["unorderedList", "orderedList"] }, -] as const; - /* ============================== * 数据类型 * ============================== */ @@ -71,8 +55,6 @@ function EditorContent() { const [showDraftDialog, setShowDraftDialog] = useState(false); const [draftData, setDraftData] = useState(null); const [isDirty, setIsDirty] = useState(false); - const [viewMode, setViewMode] = useState<"edit" | "preview">("edit"); - const { wrapSelection, insertAtCursor } = useMarkdownEditor(); // Auto save const { loadDraft, clearDraft } = useAutoSave({ @@ -98,11 +80,10 @@ function EditorContent() { } }, [loadDraft, articleId]); - // 读取从「导入文章」传递过来的 .md 文件内容 + // 读取从写作页面传来的文章内容 useEffect(() => { if (articleId) return; if (loadDraft()) return; - // 也检查从 write 页面传来的草稿 try { const draftRaw = sessionStorage.getItem("article_draft"); if (draftRaw) { @@ -114,13 +95,14 @@ function EditorContent() { articleContent: articleContent || prev.articleContent, })); setIsDirty(true); - toast.success("已加载文章草稿"); + toast.success("已加载文章内容"); return; } } catch { // 静默失败 } + // 也检查从导入文章传来的内容 try { const raw = sessionStorage.getItem("import_article"); if (!raw) return; @@ -274,66 +256,6 @@ function EditorContent() { } }; - const handleToolbarCommand = useCallback((command: string) => { - switch (command) { - case "bold": - wrapSelection("**"); - break; - case "italic": - wrapSelection("*"); - break; - case "strikethrough": - wrapSelection("~~"); - break; - case "heading": { - const textarea = document.querySelector('.w-md-editor-text-input') as HTMLTextAreaElement | null; - if (!textarea) return; - const start = textarea.selectionStart; - const text = textarea.value; - const lineStart = text.lastIndexOf("\n", start - 1) + 1; - const line = text.slice(lineStart, start); - const match = line.match(/^(#{0,5})\s/); - if (match) { - const newText = text.slice(0, lineStart) + text.slice(lineStart + match[0].length); - textarea.value = newText; - textarea.setSelectionRange(start - match[0].length, start - match[0].length); - textarea.dispatchEvent(new Event("input", { bubbles: true })); - } else { - const before = text.slice(0, lineStart); - const after = text.slice(lineStart); - textarea.value = before + "# " + after; - textarea.setSelectionRange(start + 2, start + 2); - textarea.dispatchEvent(new Event("input", { bubbles: true })); - } - break; - } - case "quote": - insertAtCursor("> "); - break; - case "code": - wrapSelection("`"); - break; - case "codeBlock": - insertAtCursor("\n```\n\n```\n"); - break; - case "link": - insertAtCursor("[链接文字](url)"); - break; - case "image": - insertAtCursor("![图片描述](url)"); - break; - case "hr": - insertAtCursor("\n---\n"); - break; - case "unorderedList": - insertAtCursor("- "); - break; - case "orderedList": - insertAtCursor("1. "); - break; - } - }, [wrapSelection, insertAtCursor]); - const handleCoverUpload = useCallback(async (file: File): Promise => { const res = await api.upload("/admin/articles/images", file); if (!res.flag) throw new Error(res.message); @@ -370,31 +292,6 @@ function EditorContent() { errors.articleTitle && "border-destructive" )} /> - {/* 视图切换 */} -
- - -
- ); - })} - - ))} - - - {/* Markdown 编辑器 */} -
- { - updateField("articleContent", val || ""); - }} - height={typeof window !== "undefined" && window.innerWidth < 768 ? 420 : 520} - preview={viewMode === "preview" ? "preview" : "edit"} - visibleDragbar={false} - /> + {/* 文章内容预览 */} +
+

文章内容预览

+
+ {form.articleContent ? ( +
+                {form.articleContent.slice(0, 1000)}
+                {form.articleContent.length > 1000 && "..."}
+              
+ ) : ( +

+ 暂无内容,请点击「写作」按钮编写文章 +

+ )}
- {errors.articleContent && ( -

{errors.articleContent}

- )} +

+ 字数:{form.articleContent.length} +

diff --git a/frontend/src/app/(blog)/archives/page.tsx b/frontend/src/app/(blog)/archives/page.tsx index d149a2c..7945e18 100644 --- a/frontend/src/app/(blog)/archives/page.tsx +++ b/frontend/src/app/(blog)/archives/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import Link from "next/link"; import { Skeleton } from "@/components/ui/skeleton"; +import { PageHeader } from "@/components/ui/page-header"; import { api } from "@/lib/api"; interface ArchiveArticle { @@ -74,74 +75,51 @@ export default function ArchivesPage() { return (
-
-
-
-

- - 文章归档 - -

-

- 共 {total} 篇文章 · 按时间倒序 -

-
-
+
{grouped.map(([yearMonth, items]) => { const [year, month] = yearMonth.split("-"); return (
-

- +

{year} 年 {parseInt(month, 10)} 月 - + ({items.length})

-
+
    {items.map((article) => { const date = new Date(article.createTime); const day = `${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; return ( -
    - -
    -
    -
    + + + ); })} -
+

); })} {grouped.length === 0 && ( -

暂无归档文章

+

暂无归档文章

)}
diff --git a/frontend/src/app/(blog)/articles/[id]/page.tsx b/frontend/src/app/(blog)/articles/[id]/page.tsx index fee504d..cc0c760 100644 --- a/frontend/src/app/(blog)/articles/[id]/page.tsx +++ b/frontend/src/app/(blog)/articles/[id]/page.tsx @@ -234,7 +234,7 @@ export default function ArticleDetailPage() { {article.categoryName && ( {article.categoryName} @@ -259,12 +259,12 @@ export default function ArticleDetailPage() {
)} - {/* 三栏布局:左侧 TOC + 中间正文 + 右侧 meta */} -
+ {/* 双栏布局:左侧 TOC + 右侧正文 */} +
{/* 左侧 TOC */} - {/* 中间内容 */} + {/* 右侧内容 */}
{/* 没有封面时显示标题区 */} {!article.articleCover && ( @@ -272,7 +272,7 @@ export default function ArticleDetailPage() { {article.categoryName && ( {article.categoryName} @@ -307,7 +307,7 @@ export default function ArticleDetailPage() { #{t.tagName} @@ -315,8 +315,28 @@ export default function ArticleDetailPage() {
)} - {/* Markdown 正文 */} -
+ {/* Markdown 正文 - 增强样式 */} +
+

{children}

); @@ -365,38 +381,42 @@ export default function ArticleDetailPage() { const isExternal = /^https?:\/\//.test(href ?? ""); if (isExternal) { return ( - + {children} ); } - return ( - - {children} - - ); + return {children}; }, + // 增强代码块样式 + pre: ({ children, ...props }) => ( +
+                    {children}
+                  
+ ), + // 增强图片样式 + img: ({ src, alt, ...props }) => ( +
+ {alt} + {alt &&
{alt}
} +
+ ), }} > {article.articleContent}
- {/* 上下篇(spec §4.6) */} + {/* 上下篇导航 */}