diff --git a/frontend/src/app/(admin)/admin/articles/editor/page.tsx b/frontend/src/app/(admin)/admin/articles/editor/page.tsx index 5edbf5c..0e331ec 100644 --- a/frontend/src/app/(admin)/admin/articles/editor/page.tsx +++ b/frontend/src/app/(admin)/admin/articles/editor/page.tsx @@ -8,16 +8,28 @@ 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 } from "lucide-react"; +import { Save, ArrowLeft, X, Eye, EyeOff, Columns2, FileText } from "lucide-react"; import { Switch } from "@/components/ui/switch"; -import { MarkdownToolbar, CoverImageUploader, SmartTagInput, ImageUploader } from "@/components/editor"; +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; + /* ============================== * 数据类型 * ============================== */ @@ -59,11 +71,11 @@ function EditorContent() { const [showDraftDialog, setShowDraftDialog] = useState(false); const [draftData, setDraftData] = useState(null); const [isDirty, setIsDirty] = useState(false); - const [syncScroll, setSyncScroll] = useState(true); + const [viewMode, setViewMode] = useState<"edit" | "preview">("edit"); const { wrapSelection, insertAtCursor } = useMarkdownEditor(); // Auto save - const { saveDraft, loadDraft, clearDraft } = useAutoSave({ + const { loadDraft, clearDraft } = useAutoSave({ id: articleId, title: form.articleTitle, content: form.articleContent, @@ -86,10 +98,29 @@ function EditorContent() { } }, [loadDraft, articleId]); - // 读取从「导入文章」传递过来的 .md 文件内容(仅在新建且无草稿时生效) + // 读取从「导入文章」传递过来的 .md 文件内容 useEffect(() => { if (articleId) return; - if (loadDraft()) return; // 有草稿优先用草稿 + if (loadDraft()) return; + // 也检查从 write 页面传来的草稿 + try { + const draftRaw = sessionStorage.getItem("article_draft"); + if (draftRaw) { + sessionStorage.removeItem("article_draft"); + const { articleTitle, articleContent } = JSON.parse(draftRaw); + setForm((prev) => ({ + ...prev, + articleTitle: articleTitle || prev.articleTitle, + articleContent: articleContent || prev.articleContent, + })); + setIsDirty(true); + toast.success("已加载文章草稿"); + return; + } + } catch { + // 静默失败 + } + try { const raw = sessionStorage.getItem("import_article"); if (!raw) return; @@ -99,7 +130,6 @@ function EditorContent() { content?: string; }; if (!content) return; - // 提取首个 H1 作为标题(找不到则用文件名) const h1Match = content.match(/^\s*#\s+(.+)\s*$/m); const title = h1Match?.[1] ?? fileName ?? ""; setForm((prev) => ({ @@ -264,13 +294,11 @@ function EditorContent() { const line = text.slice(lineStart, start); const match = line.match(/^(#{0,5})\s/); if (match) { - // Remove existing heading 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 { - // Add heading const before = text.slice(0, lineStart); const after = text.slice(lineStart); textarea.value = before + "# " + after; @@ -326,7 +354,7 @@ function EditorContent() { return (
- {/* 顶部工具栏:标题 + 同步滚动 + 取消/保存 */} + {/* 顶部工具栏 */}
+
+
+
+
+ +
+
+

+ 检测到未保存的草稿 +

+

+ {draftData?.articleTitle || "无标题"} +

+
+
+
+
)} - {/* Category & Type & Status */} -
-
- - - {errors.categoryName && ( -

{errors.categoryName}

- )} + {/* 主内容区 - 卡片布局 */} +
+ {/* 元信息卡片 */} +
+

文章设置

+ + {/* Category & Type & Status */} +
+
+ + + {errors.categoryName && ( +

{errors.categoryName}

+ )} +
+
+ + +
+
+ + +
+
-
- - + + {/* 标签卡片 */} +
+

标签

+ updateField("tagNameList", tags)} + availableTags={tags.map((t) => t.tagName)} + maxTags={10} + /> + {errors.tagNameList && ( +

{errors.tagNameList}

+ )}
-
- - + + {/* 封面图卡片 */} +
+

封面图

+ updateField("articleCover", url)} + onUpload={handleCoverUpload} + />
-
- {/* Tags */} -
- - updateField("tagNameList", tags)} - availableTags={tags.map((t) => t.tagName)} - maxTags={10} - /> - {errors.tagNameList && ( -

{errors.tagNameList}

- )} -
+ {/* 编辑器卡片 */} +
+
+

文章内容

+
+ setViewMode(checked ? "preview" : "edit")} + /> + +
+
- {/* Cover Image */} -
- - updateField("articleCover", url)} - onUpload={handleCoverUpload} - /> -
+ {/* 工具栏 */} +
+ {TOOLBAR_GROUPS.map((group, groupIndex) => ( +
+ {groupIndex > 0 &&
} + {group.items.map((item) => { + const label = item.charAt(0).toUpperCase() + item.slice(1); + return ( + + ); + })} +
+ ))} +
- {/* Markdown Editor */} -
- - - { - const urls: string[] = []; - for (const file of files) { - const res = await api.upload("/admin/articles/images", file); - if (res.flag) urls.push(res.data); - } - return urls; - }} - onInsert={(urls) => { - const markdown = urls.map((url) => `![图片](${url})`).join("\n"); - insertAtCursor(markdown + "\n"); - }} - /> -
- { - updateField("articleContent", val || ""); - }} - height={typeof window !== "undefined" && window.innerWidth < 768 ? 420 : 620} - preview={syncScroll ? "live" : "edit"} - visibleDragbar={false} - /> + {/* Markdown 编辑器 */} +
+ { + updateField("articleContent", val || ""); + }} + height={typeof window !== "undefined" && window.innerWidth < 768 ? 420 : 520} + preview={viewMode === "preview" ? "preview" : "edit"} + visibleDragbar={false} + /> +
+ {errors.articleContent && ( +

{errors.articleContent}

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

{errors.articleContent}

- )}
); diff --git a/frontend/src/app/(admin)/admin/articles/page.tsx b/frontend/src/app/(admin)/admin/articles/page.tsx index 61f25e8..0420c58 100644 --- a/frontend/src/app/(admin)/admin/articles/page.tsx +++ b/frontend/src/app/(admin)/admin/articles/page.tsx @@ -8,7 +8,7 @@ import type { PageResult } from "@/lib/types"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { toast } from "sonner"; -import { Pencil, Pin, Trash2, Plus, Filter, Upload, FileText } from "lucide-react"; +import { Pencil, Pin, Trash2, Plus, Filter, Upload } from "lucide-react"; import { Dialog, DialogContent, @@ -451,9 +451,9 @@ export default function ArticlesPage() { 导入文章 -
diff --git a/frontend/src/app/(admin)/admin/articles/write/page.tsx b/frontend/src/app/(admin)/admin/articles/write/page.tsx new file mode 100644 index 0000000..501d845 --- /dev/null +++ b/frontend/src/app/(admin)/admin/articles/write/page.tsx @@ -0,0 +1,572 @@ +"use client"; + +import { useEffect, useState, useCallback, useRef, Suspense } from "react"; +import { flushSync } from "react-dom"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import { ArrowLeft, Save, Eye, EyeOff, Columns2, FileText } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useAutoSave } from "@/hooks/use-auto-save"; + +/* ============================== + * 常量 + * ============================== */ + +const TOOLBAR_GROUPS = [ + { label: "格式", items: ["bold", "italic", "strikethrough"] }, + { label: "标题", items: ["h2", "h3"] }, + { label: "内容", items: ["quote", "code", "codeBlock"] }, + { label: "媒体", items: ["link", "image", "hr"] }, + { label: "列表", items: ["ul", "ol"] }, +] as const; + +const TOOLBAR_COMMANDS: Record = { + bold: { icon: "B", label: "format", title: "加粗" }, + italic: { icon: "I", label: "italic", title: "斜体" }, + strikethrough: { icon: "S", label: "strikethrough", title: "删除线" }, + h2: { icon: "H2", label: "heading", title: "二级标题" }, + h3: { icon: "H3", label: "heading", title: "三级标题" }, + quote: { icon: "❝", label: "quote", title: "引用" }, + code: { icon: "", label: "code", title: "行内代码" }, + codeBlock: { icon: "⌨", label: "codeBlock", title: "代码块" }, + link: { icon: "🔗", label: "link", title: "链接" }, + image: { icon: "🖼", label: "image", title: "图片" }, + hr: { icon: "—", label: "hr", title: "分割线" }, + ul: { icon: "≡", label: "ul", title: "无序列表" }, + ol: { icon: "1.", label: "ol", title: "有序列表" }, +}; + +/* + * Markdown 编辑器内容组件 + */ + +function WriteEditorContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const rawId = searchParams.get("id"); + const articleId = rawId && /^\d+$/.test(rawId) ? Number(rawId) : undefined; + + const textareaRef = useRef(null); + const previewRef = useRef(null); + const editorContainerRef = useRef(null); + + // 状态 + const [title, setTitle] = useState(""); + const [content, setContent] = useState(""); + const [isDirty, setIsDirty] = useState(false); + const [viewMode, setViewMode] = useState<"split" | "editor" | "preview">("split"); + const [syncScroll, setSyncScroll] = useState(true); + const [draftBanner, setDraftBanner] = useState<{ + show: boolean; + title: string; + content: string; + time: string; + }>({ show: false, title: "", content: "", time: "" }); + + // Auto save + const { saveDraft, loadDraft } = useAutoSave({ + id: articleId, + title, + content, + }); + + // 加载草稿 + useEffect(() => { + const draft = loadDraft(); + if (draft && !articleId) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- Draft loading is a one-time initialization, not a cascading render issue + setDraftBanner({ + show: true, + title: draft.title, + content: draft.content, + time: draft.savedAt ? new Date(draft.savedAt).toLocaleString() : "", + }); + } + }, [loadDraft, articleId]); + + // 读取从「导入文章」传递过来的 .md 文件内容 + useEffect(() => { + if (articleId) return; + if (loadDraft()) return; + try { + const raw = sessionStorage.getItem("import_article"); + if (!raw) return; + sessionStorage.removeItem("import_article"); + const { fileName, content: importedContent } = JSON.parse(raw) as { + fileName?: string; + content?: string; + }; + if (!importedContent) return; + const h1Match = importedContent.match(/^\s*#\s+(.+)\s*$/m); + // 使用 flushSync 批量同步更新状态,避免多次渲染 + flushSync(() => { + setTitle(h1Match?.[1] ?? fileName ?? ""); + setContent(importedContent); + setIsDirty(true); + }); + toast.success(`已导入:${fileName ?? "Markdown 文件"}`); + } catch { + // 静默失败 + } + }, [articleId, loadDraft]); + + // Warn on unsaved changes + useEffect(() => { + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + if (isDirty) { + e.preventDefault(); + e.returnValue = ""; + } + }; + window.addEventListener("beforeunload", handleBeforeUnload); + return () => window.removeEventListener("beforeunload", handleBeforeUnload); + }, [isDirty]); + + // 恢复草稿 + const restoreDraft = () => { + setTitle(draftBanner.title); + setContent(draftBanner.content); + setIsDirty(true); + setDraftBanner((prev) => ({ ...prev, show: false })); + toast.success("已恢复草稿"); + }; + + // 丢弃草稿 + const discardDraft = () => { + setDraftBanner((prev) => ({ ...prev, show: false })); + }; + + // 更新内容 + const updateContent = useCallback((value: string) => { + setContent(value); + setIsDirty(true); + }, []); + + const updateTitle = useCallback((value: string) => { + setTitle(value); + setIsDirty(true); + }, []); + + // 工具栏命令处理 + const handleCommand = useCallback((command: string) => { + const textarea = textareaRef.current; + if (!textarea) return; + + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const text = textarea.value; + const selected = text.slice(start, end); + + let newText = text; + let newCursorPos = start; + + switch (command) { + case "bold": + newText = text.slice(0, start) + `**${selected || "粗体文本"}**` + text.slice(end); + newCursorPos = start + 2 + (selected ? selected.length + 2 : 4); + break; + case "italic": + newText = text.slice(0, start) + `*${selected || "斜体文本"}*` + text.slice(end); + newCursorPos = start + 1 + (selected ? selected.length + 1 : 4); + break; + case "strikethrough": + newText = text.slice(0, start) + `~~${selected || "删除线文本"}~~` + text.slice(end); + newCursorPos = start + 2 + (selected ? selected.length + 2 : 6); + break; + case "h2": + // 在当前行开头添加 ## + const lineStart2 = text.lastIndexOf("\n", start - 1) + 1; + newText = text.slice(0, lineStart2) + "## " + text.slice(lineStart2); + newCursorPos = start + 3; + break; + case "h3": + const lineStart3 = text.lastIndexOf("\n", start - 1) + 1; + newText = text.slice(0, lineStart3) + "### " + text.slice(lineStart3); + newCursorPos = start + 4; + break; + case "quote": + const lineStartQ = text.lastIndexOf("\n", start - 1) + 1; + newText = text.slice(0, lineStartQ) + "> " + text.slice(lineStartQ); + newCursorPos = start + 2; + break; + case "code": + if (selected) { + newText = text.slice(0, start) + "`" + selected + "`" + text.slice(end); + newCursorPos = start + selected.length + 2; + } else { + newText = text.slice(0, start) + "`代码`" + text.slice(end); + newCursorPos = start + 5; + } + break; + case "codeBlock": + newText = text.slice(0, start) + "\n```\n\n```\n" + text.slice(end); + newCursorPos = start + 5; + break; + case "link": + newText = text.slice(0, start) + `[${selected || "链接文本"}](url)` + text.slice(end); + newCursorPos = start + (selected ? selected.length + 3 : 6); + break; + case "image": + newText = text.slice(0, start) + `![${selected || "图片描述"}](url)` + text.slice(end); + newCursorPos = start + (selected ? selected.length + 4 : 7); + break; + case "hr": + newText = text.slice(0, start) + "\n---\n" + text.slice(end); + newCursorPos = start + 5; + break; + case "ul": + const lineStartUl = text.lastIndexOf("\n", start - 1) + 1; + newText = text.slice(0, lineStartUl) + "- " + text.slice(lineStartUl); + newCursorPos = start + 2; + break; + case "ol": + const lineStartOl = text.lastIndexOf("\n", start - 1) + 1; + newText = text.slice(0, lineStartOl) + "1. " + text.slice(lineStartOl); + newCursorPos = start + 3; + break; + } + + textarea.value = newText; + textarea.setSelectionRange(newCursorPos, newCursorPos); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + updateContent(newText); + textarea.focus(); + }, [updateContent]); + + // 同步滚动处理 + const handleEditorScroll = useCallback(() => { + if (!syncScroll || !previewRef.current || !textareaRef.current) return; + const textarea = textareaRef.current; + const preview = previewRef.current; + const scrollPercent = textarea.scrollTop / (textarea.scrollHeight - textarea.clientHeight); + preview.scrollTop = scrollPercent * (preview.scrollHeight - preview.clientHeight); + }, [syncScroll]); + + // 键盘快捷键 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.ctrlKey || e.metaKey) { + switch (e.key.toLowerCase()) { + case "b": + e.preventDefault(); + handleCommand("bold"); + break; + case "i": + e.preventDefault(); + handleCommand("italic"); + break; + case "s": + e.preventDefault(); + // 保存操作 + break; + } + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [handleCommand]); + + // 渲染 Markdown 预览(简单实现,可根据需要扩展) + const renderPreview = (markdown: string) => { + // 简单转换,完整实现可使用 react-markdown + const html = markdown + // 转义 HTML + .replace(/&/g, "&") + .replace(//g, ">") + // 标题 + .replace(/^### (.+)$/gm, "

$1

") + .replace(/^## (.+)$/gm, "

$1

") + .replace(/^# (.+)$/gm, "

$1

") + // 引用 + .replace(/^> (.+)$/gm, "
$1
") + // 列表 + .replace(/^- (.+)$/gm, "
  • $1
  • ") + .replace(/^(\d+)\. (.+)$/gm, "
  • $2
  • ") + // 粗体和斜体 + .replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/\*(.+?)\*/g, "$1") + .replace(/~~(.+?)~~/g, "$1") + // 代码块 + .replace(/```(\w*)\n([\s\S]*?)```/g, '
    $2
    ') + // 行内代码 + .replace(/`([^`]+)`/g, "$1") + // 链接 + .replace(/\[(.+?)\]\((.+?)\)/g, '$1') + // 图片 + .replace(/!\[(.+?)\]\((.+?)\)/g, '$1') + // 段落 + .replace(/\n\n/g, "

    ") + // 分割线 + .replace(/^---$/gm, "


    "); + + return `

    ${html}

    `; + }; + + return ( +
    + {/* 顶部工具栏 */} +
    +
    + + + {articleId ? "编辑文章" : "新建文章"} + +
    + +
    + {/* 视图切换 */} +
    + + + +
    + + + +
    +
    + + {/* 草稿恢复提示 */} + {draftBanner.show && ( +
    +
    +
    + +
    +
    +

    检测到未保存的草稿

    +

    + 最后编辑于 {draftBanner.time} +

    +
    +
    +
    + + +
    +
    + )} + + {/* 标题输入 */} +
    + updateTitle(e.target.value)} + placeholder="输入文章标题..." + className="w-full bg-transparent text-xl font-semibold outline-none placeholder:text-muted-foreground" + /> +
    + + {/* 编辑器区域 */} +
    + {/* 左侧:工具栏 + 编辑器 */} +
    + {/* 工具栏 */} +
    + {TOOLBAR_GROUPS.map((group, groupIndex) => ( +
    + {groupIndex > 0 &&
    } + + {group.label} + + {group.items.map((item) => { + const cmd = TOOLBAR_COMMANDS[item]; + return ( + + ); + })} +
    + ))} +
    + + {/* 同步滚动开关 */} +
    + + 同步滚动 +
    + + {/* 文本编辑器 */} +