From 8b202dfae9abf7f9c8dd0ad76bf6ac040bb772c7 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 23 Sep 2026 19:03:03 +0800 Subject: [PATCH 1/2] =?UTF-8?q?refactor(article):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=96=87=E7=AB=A0=E8=AF=A6=E6=83=85=E9=A1=B5=E6=A0=B7=E5=BC=8F?= =?UTF-8?q?=EF=BC=8C=E8=B0=83=E6=95=B4=E5=B8=83=E5=B1=80=E5=92=8C=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/app/(blog)/articles/[id]/page.tsx | 254 +++++++++----- frontend/src/app/globals.css | 309 +++++++++++++++++- .../src/components/blog/TableOfContents.tsx | 101 +++--- 3 files changed, 514 insertions(+), 150 deletions(-) diff --git a/frontend/src/app/(blog)/articles/[id]/page.tsx b/frontend/src/app/(blog)/articles/[id]/page.tsx index 60f9981..41f832f 100644 --- a/frontend/src/app/(blog)/articles/[id]/page.tsx +++ b/frontend/src/app/(blog)/articles/[id]/page.tsx @@ -7,11 +7,18 @@ import Image from "next/image"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import rehypeHighlight from "rehype-highlight"; -import { Calendar, Eye, ChevronUp, Lock, ChevronLeft, ChevronRight } from "lucide-react"; +import { + Calendar, + Eye, + ChevronUp, + Lock, + ChevronLeft, + ChevronRight, +} from "lucide-react"; import { TableOfContents } from "@/components/blog/TableOfContents"; import { CommentSection, type CommentNode } from "@/components/blog/CommentSection"; import { api } from "@/lib/api"; -import { cn, extractHeadings, formatViews } from "@/lib/utils"; +import { cn, extractHeadings, formatTime, formatViews } from "@/lib/utils"; import { toast } from "sonner"; interface ArticleDetail { @@ -22,6 +29,7 @@ interface ArticleDetail { categoryName?: string; categoryId?: number; createTime: string; + updateTime?: string; viewCount?: number; tagVOList?: { id: number; tagName: string }[]; isPassword?: boolean; @@ -36,6 +44,14 @@ type ErrorKind = "notFound" | "server" | "network"; const BACKEND_CODE_NOT_FOUND = 40004; +/** 标题转 id */ +function headingId(text: string): string { + return text + .toLowerCase() + .replace(/[^\w一-龥]+/g, "-") + .replace(/^-|-$/g, ""); +} + export default function ArticleDetailPage() { const params = useParams(); const id = params.id as string; @@ -49,7 +65,7 @@ export default function ArticleDetailPage() { const [reloadTick, setReloadTick] = useState(0); const [showBackTop, setShowBackTop] = useState(false); const [password, setPassword] = useState(""); - const [comments, setComments] = useState([]); + const [comments] = useState([]); useEffect(() => { if (!id) return; @@ -199,32 +215,32 @@ export default function ArticleDetailPage() { alt={article.articleTitle} fill sizes="100vw" - priority + preload className="object-cover" /> -
+
{article.categoryName && ( {article.categoryName} )} -

+

{article.articleTitle}

- - - {article.createTime} + + + {formatTime(article.createTime)} {article.viewCount !== undefined && ( - - - {formatViews(article.viewCount)} + + + {formatViews(article.viewCount)} 阅读 )}
@@ -233,50 +249,55 @@ export default function ArticleDetailPage() {
)} - {/* 三栏布局:自然衔接封面,不做 -mt-20 覆盖以免 TOC 卡粘性时盖住标题 */} -
+ {/* 三栏布局:左侧 TOC + 中间正文 + 右侧 meta */} +
{/* 左侧 TOC */} {/* 中间内容 */} -
- {/* 如果没有封面则在文章卡片内显示标题 */} +
+ {/* 没有封面时显示标题区 */} {!article.articleCover && ( - <> +
{article.categoryName && ( {article.categoryName} )} -

+

{article.articleTitle}

-
- - - {article.createTime} +
+ + + {formatTime(article.createTime)} {article.viewCount !== undefined && ( - - - {formatViews(article.viewCount)} + + + {formatViews(article.viewCount)} 阅读 )}
- +
)} {/* 标签 */} {article.tagVOList && article.tagVOList.length > 0 && ( -
+
{article.tagVOList.map((t) => ( #{t.tagName} @@ -284,31 +305,71 @@ export default function ArticleDetailPage() {
)} - {/* Markdown 正文(spec §7.3 prose 适配暗色) */} -
+ {/* Markdown 正文 */} +
{ + const text = String(children); + const id = headingId(text); + return ( +

+ {children} +

+ ); + }, h2: ({ children, ...props }) => { const text = String(children); - const id = text - .toLowerCase() - .replace(/[^\w一-龥]+/g, "-") - .replace(/^-|-$/g, ""); - return

{children}

; + const id = headingId(text); + return ( +

+ {children} +

+ ); }, h3: ({ children, ...props }) => { const text = String(children); - const id = text - .toLowerCase() - .replace(/[^\w一-龥]+/g, "-") - .replace(/^-|-$/g, ""); - return

{children}

; + const id = headingId(text); + return ( +

+ {children} +

+ ); + }, + h4: ({ children, ...props }) => { + const text = String(children); + const id = headingId(text); + return ( +

+ {children} +

+ ); + }, + a: ({ href, children, ...props }) => { + const isExternal = /^https?:\/\//.test(href ?? ""); + if (isExternal) { + return ( + + {children} + + ); + } + return ( + + {children} + + ); }, }} > @@ -317,15 +378,15 @@ export default function ArticleDetailPage() {
{/* 上下篇(spec §4.6) */} -
{/* 右侧 meta */}
@@ -393,7 +495,7 @@ export default function ArticleDetailPage() { onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })} aria-label="回到顶部" className={cn( - "fixed bottom-8 right-8 z-50 rounded-full bg-white p-3 text-gray-700 shadow-lg transition-all duration-300 hover:bg-gray-100 dark:bg-gray-800 dark:text-white dark:hover:bg-gray-700", + "fixed bottom-8 right-8 z-50 rounded-full bg-white p-3 text-gray-700 shadow-lg ring-1 ring-gray-200 transition-all duration-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-white dark:ring-gray-700 dark:hover:bg-gray-700", showBackTop ? "opacity-100" : "pointer-events-none opacity-0", )} > diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index d7df2b6..aa4e0e7 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -493,40 +493,195 @@ color: #f9fafb; } -/* ===== 文章正文 prose 微调 ===== */ +/* ===== 文章正文 prose 样式(参考 Simon Willison 文章页) ===== */ .prose { --tw-prose-links: #2563eb; --tw-prose-headings: #111827; - color: #1f2937; + --tw-prose-body: #374151; + --tw-prose-bold: #111827; + --tw-prose-quotes: #1f2937; + --tw-prose-quote-borders: #8b5cf6; + --tw-prose-counters: #6b7280; + --tw-prose-bullets: #9ca3af; + --tw-prose-hr: #e5e7eb; + --tw-prose-quote-borders: #8b5cf6; + --tw-prose-th-borders: #d1d5db; + --tw-prose-td-borders: #e5e7eb; + color: #374151; + line-height: 1.8; + font-size: 1rem; } .dark .prose { - --tw-prose-links: #60a5fa; + --tw-prose-links: #93c5fd; --tw-prose-headings: #f9fafb; - color: #e5e7eb; + --tw-prose-body: #d1d5db; + --tw-prose-bold: #f9fafb; + --tw-prose-quotes: #e5e7eb; + --tw-prose-quote-borders: #a78bfa; + --tw-prose-counters: #9ca3af; + --tw-prose-bullets: #6b7280; + --tw-prose-hr: #374151; + --tw-prose-th-borders: #4b5563; + --tw-prose-td-borders: #374151; + color: #d1d5db; +} + +/* 段落 */ +.prose p { + margin: 1.25em 0; + line-height: 1.85; } +.prose p:first-child { + margin-top: 0; +} +.prose p:last-child { + margin-bottom: 0; +} + +/* 链接 */ .prose a { + color: #2563eb; + text-decoration: underline; text-decoration-color: rgba(59, 130, 246, 0.4); + text-decoration-thickness: 1.5px; text-underline-offset: 3px; + font-weight: 500; + transition: all 200ms ease; } .prose a:hover { + color: #1d4ed8; text-decoration-color: #3b82f6; } -.prose pre { - background: #1f2937 !important; - color: #f9fafb !important; - border-radius: 0.5rem; - padding: 1rem; - overflow-x: auto; +.dark .prose a { + color: #93c5fd; + text-decoration-color: rgba(147, 197, 253, 0.4); } -.dark .prose pre { - background: #111827 !important; +.dark .prose a:hover { + color: #bfdbfe; +} + +/* 标题 */ +.prose h1, +.prose h2, +.prose h3, +.prose h4 { + font-weight: 700; + letter-spacing: -0.015em; + color: #111827; +} +.dark .prose h1, +.dark .prose h2, +.dark .prose h3, +.dark .prose h4 { + color: #f9fafb; +} +.prose h2 { + font-size: 1.625rem; + margin: 2.25em 0 1em; + padding-bottom: 0.5em; + border-bottom: 1px solid #e5e7eb; + line-height: 1.4; +} +.prose h2:first-child { + margin-top: 0.5em; +} +.dark .prose h2 { + border-bottom-color: #374151; +} +.prose h3 { + font-size: 1.25rem; + margin: 1.75em 0 0.75em; + line-height: 1.45; +} +.prose h4 { + font-size: 1.1rem; + margin: 1.5em 0 0.5em; } + +/* 加粗 / 强调 */ +.prose strong { + color: #111827; + font-weight: 700; +} +.dark .prose strong { + color: #f9fafb; +} +.prose em { + font-style: italic; + color: #4b5563; +} +.dark .prose em { + color: #d1d5db; +} + +/* 列表 */ +.prose ul, +.prose ol { + margin: 1.25em 0; + padding-left: 1.75em; +} +.prose ul > li, +.prose ol > li { + margin: 0.5em 0; + padding-left: 0.4em; +} +.prose ul > li::marker { + color: #9ca3af; +} +.prose ol > li::marker { + color: #6b7280; + font-weight: 600; +} +.prose li > p { + margin: 0.6em 0; +} +.prose li > ul, +.prose li > ol { + margin: 0.4em 0; +} + +/* 引用块(blockquotes):紫色左边线 */ +.prose blockquote { + margin: 1.75em 0; + padding: 0.4em 1.25em; + border-left: 3px solid #8b5cf6; + background: rgba(139, 92, 246, 0.04); + color: #1f2937; + font-style: normal; + border-radius: 0 0.375rem 0.375rem 0; +} +.prose blockquote p { + margin: 0.6em 0; +} +.prose blockquote p:first-child { + margin-top: 0; +} +.prose blockquote p:last-child { + margin-bottom: 0; +} +.prose blockquote strong { + color: #111827; +} +.dark .prose blockquote { + background: rgba(167, 139, 250, 0.08); + color: #e5e7eb; + border-left-color: #a78bfa; +} + +/* 引用块中的引用块内嵌时不重复边线 */ +.prose blockquote blockquote { + margin: 0.75em 0; +} + +/* 行内代码 */ .prose code { background: rgba(59, 130, 246, 0.08); color: #1d4ed8; - padding: 0.125rem 0.375rem; - border-radius: 0.25rem; + padding: 0.15em 0.45em; + border-radius: 0.3rem; font-size: 0.875em; + font-weight: 500; + font-family: var(--font-mono, ui-monospace, monospace); } .dark .prose code { background: rgba(96, 165, 250, 0.15); @@ -537,6 +692,132 @@ content: none !important; } +/* 代码块(pre code) */ +.prose pre { + background: #1f2937 !important; + color: #f9fafb !important; + border-radius: 0.625rem; + padding: 1.1rem 1.25rem; + margin: 1.75em 0; + overflow-x: auto; + font-size: 0.875rem; + line-height: 1.65; + border: 1px solid #374151; + font-family: var(--font-mono, ui-monospace, monospace); +} +.dark .prose pre { + background: #0f172a !important; + border-color: #1f2937; +} +.prose pre code { + background: transparent !important; + color: inherit; + padding: 0; + border-radius: 0; + font-size: inherit; + font-weight: 400; +} + +/* 表格 */ +.prose table { + width: 100%; + margin: 1.75em 0; + border-collapse: collapse; + font-size: 0.9375rem; + border: 1px solid #e5e7eb; + border-radius: 0.5rem; + overflow: hidden; +} +.dark .prose table { + border-color: #374151; +} +.prose thead { + background: #f3f4f6; +} +.dark .prose thead { + background: #1f2937; +} +.prose th { + padding: 0.75rem 1rem; + text-align: left; + font-weight: 600; + color: #1f2937; + border-bottom: 1px solid #e5e7eb; + font-size: 0.875rem; +} +.dark .prose th { + color: #f9fafb; + border-bottom-color: #374151; +} +.prose td { + padding: 0.85rem 1rem; + border-bottom: 1px solid #f3f4f6; + vertical-align: top; +} +.prose tbody tr:last-child td { + border-bottom: none; +} +.prose tbody tr:hover { + background: #f9fafb; +} +.dark .prose td { + border-bottom-color: #374151; +} +.dark .prose tbody tr:hover { + background: rgba(55, 65, 81, 0.4); +} + +/* 图片 */ +.prose img { + border-radius: 0.625rem; + margin: 1.75em auto; + max-width: 100%; + height: auto; + box-shadow: 0 4px 12px -2px rgb(0 0 0 / 0.08); + display: block; +} +.prose figure { + margin: 2em 0; +} +.prose figcaption { + margin-top: 0.6em; + text-align: center; + font-size: 0.875rem; + color: #6b7280; +} +.dark .prose figcaption { + color: #9ca3af; +} + +/* 分隔线 */ +.prose hr { + border: none; + border-top: 1px solid #e5e7eb; + margin: 2.5em 0; +} +.dark .prose hr { + border-top-color: #374151; +} + +/* 键盘按键 kbd */ +.prose kbd { + display: inline-block; + padding: 0.1em 0.5em; + font-size: 0.85em; + font-family: var(--font-mono, ui-monospace, monospace); + color: #1f2937; + background: #f9fafb; + border: 1px solid #e5e7eb; + border-bottom-width: 2px; + border-radius: 0.3rem; + font-weight: 500; +} +.dark .prose kbd { + background: #1f2937; + color: #f9fafb; + border-color: #374151; +} + /* ===== 公告弹窗正文 ===== */ .announcement-body img { max-width: 100%; diff --git a/frontend/src/components/blog/TableOfContents.tsx b/frontend/src/components/blog/TableOfContents.tsx index f570b19..c0c32f4 100644 --- a/frontend/src/components/blog/TableOfContents.tsx +++ b/frontend/src/components/blog/TableOfContents.tsx @@ -9,10 +9,9 @@ interface Heading { id: string; } -/** 目录组件(spec §4.6 TOC) */ +/** 目录组件(spec §4.6 TOC,仿 Simon Willison 风格) */ export function TableOfContents({ headings }: { headings: Heading[] }) { const [activeId, setActiveId] = useState(headings[0]?.id ?? null); - const [collapsed, setCollapsed] = useState(false); useEffect(() => { if (headings.length === 0) return; @@ -45,65 +44,47 @@ export function TableOfContents({ headings }: { headings: Heading[] }) { return ( ); } \ No newline at end of file From 1c287249933604dc1258394928a27d1a7caad69b Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 23 Sep 2026 19:31:04 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(articles):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=86=99=E4=BD=9C=E9=A1=B5=E9=9D=A2=EF=BC=8C=E9=87=8D=E5=91=BD?= =?UTF-8?q?=E5=90=8D=E6=96=87=E7=AB=A0=E7=BC=96=E8=BE=91=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E5=B9=B6=E4=BC=98=E5=8C=96=E5=AF=BC=E5=85=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(admin)/admin/articles/editor/page.tsx | 342 +++++++---- .../src/app/(admin)/admin/articles/page.tsx | 6 +- .../app/(admin)/admin/articles/write/page.tsx | 572 ++++++++++++++++++ .../src/app/(blog)/articles/[id]/page.tsx | 18 +- 4 files changed, 802 insertions(+), 136 deletions(-) create mode 100644 frontend/src/app/(admin)/admin/articles/write/page.tsx 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 ( + + ); + })} +
    + ))} +
    + + {/* 同步滚动开关 */} +
    + + 同步滚动 +
    + + {/* 文本编辑器 */} +