Skip to content
Merged

Web #18

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ moon/
- JWT 鉴权 + Casbin RBAC 权限
- 后台管理:`/admin`
- 公开博客:`/`(文章列表、详情、归档、关于、友链)
- Markdown 写作(独立的写作页面 + 元信息设置页面)

---

Expand Down
Binary file modified frontend/public/Hero.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
181 changes: 21 additions & 160 deletions frontend/src/app/(admin)/admin/articles/editor/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/* ==============================
* 数据类型
* ============================== */
Expand Down Expand Up @@ -71,8 +55,6 @@ function EditorContent() {
const [showDraftDialog, setShowDraftDialog] = useState(false);
const [draftData, setDraftData] = useState<ArticleFormData | null>(null);
const [isDirty, setIsDirty] = useState(false);
const [viewMode, setViewMode] = useState<"edit" | "preview">("edit");
const { wrapSelection, insertAtCursor } = useMarkdownEditor();

// Auto save
const { loadDraft, clearDraft } = useAutoSave({
Expand All @@ -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) {
Expand All @@ -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;
Expand Down Expand Up @@ -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<string> => {
const res = await api.upload<string>("/admin/articles/images", file);
if (!res.flag) throw new Error(res.message);
Expand Down Expand Up @@ -370,31 +292,6 @@ function EditorContent() {
errors.articleTitle && "border-destructive"
)}
/>
{/* 视图切换 */}
<div className="flex items-center gap-2 rounded-md border bg-muted p-0.5">
<Button
variant="ghost"
size="icon-sm"
className={cn("h-7 w-7", viewMode === "edit" && "bg-background shadow-sm")}
onClick={() => setViewMode("edit")}
title="编辑模式"
>
<Columns2 className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
className={cn("h-7 w-7", viewMode === "preview" && "bg-background shadow-sm")}
onClick={() => setViewMode("preview")}
title="预览模式"
>
{viewMode === "preview" ? (
<Eye className="size-3.5" />
) : (
<EyeOff className="size-3.5" />
)}
</Button>
</div>
<Button variant="ghost" onClick={() => router.push("/admin/articles")}>
<X className="size-4" />
取消
Expand Down Expand Up @@ -518,60 +415,24 @@ function EditorContent() {
/>
</div>

{/* 编辑器卡片 */}
<div className="rounded-xl border bg-card shadow-sm overflow-hidden">
<div className="flex items-center justify-between border-b bg-muted/50 px-4 py-2">
<h3 className="text-sm font-medium text-muted-foreground">文章内容</h3>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Switch
id="live-preview"
checked={viewMode === "preview"}
onCheckedChange={(checked) => setViewMode(checked ? "preview" : "edit")}
/>
<label htmlFor="live-preview" className="cursor-pointer select-none">
实时预览
</label>
</div>
</div>

{/* 工具栏 */}
<div className="flex flex-wrap items-center gap-1 border-b bg-muted/30 px-3 py-2">
{TOOLBAR_GROUPS.map((group, groupIndex) => (
<div key={group.label} className="flex items-center">
{groupIndex > 0 && <div className="mx-1.5 h-4 w-px bg-border" />}
{group.items.map((item) => {
const label = item.charAt(0).toUpperCase() + item.slice(1);
return (
<button
key={item}
type="button"
onClick={() => handleToolbarCommand(item)}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
title={label}
>
{label}
</button>
);
})}
</div>
))}
</div>

{/* Markdown 编辑器 */}
<div data-color-mode="light">
<MDEditor
value={form.articleContent}
onChange={(val) => {
updateField("articleContent", val || "");
}}
height={typeof window !== "undefined" && window.innerWidth < 768 ? 420 : 520}
preview={viewMode === "preview" ? "preview" : "edit"}
visibleDragbar={false}
/>
{/* 文章内容预览 */}
<div className="rounded-xl border bg-card p-5 shadow-sm">
<h3 className="mb-4 text-sm font-medium text-muted-foreground">文章内容预览</h3>
<div className="max-h-96 overflow-y-auto rounded-md bg-muted/30 p-4">
{form.articleContent ? (
<pre className="whitespace-pre-wrap text-sm text-muted-foreground">
{form.articleContent.slice(0, 1000)}
{form.articleContent.length > 1000 && "..."}
</pre>
) : (
<p className="text-sm text-muted-foreground/50">
暂无内容,请点击「写作」按钮编写文章
</p>
)}
</div>
{errors.articleContent && (
<p className="px-4 py-2 text-sm text-destructive">{errors.articleContent}</p>
)}
<p className="mt-2 text-xs text-muted-foreground">
字数:{form.articleContent.length}
</p>
</div>
</div>
</div>
Expand Down
64 changes: 21 additions & 43 deletions frontend/src/app/(blog)/archives/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -74,74 +75,51 @@ export default function ArchivesPage() {

return (
<div>
<div className="relative mb-10 overflow-hidden rounded-2xl border border-border/60
bg-linear-to-br from-brand-50 via-background to-purple-50/30
dark:from-brand-900/20 dark:via-background dark:to-purple-900/10
px-6 py-8 md:px-10 md:py-10">
<div className="pointer-events-none absolute -top-12 -right-12 h-48 w-48
rounded-full bg-brand-400/20 blur-3xl" />
<div className="relative">
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">
<span className="bg-linear-to-r from-brand-500 via-brand-600 to-purple-500
bg-clip-text text-transparent">
文章归档
</span>
</h1>
<p className="mt-2 text-muted-foreground">
共 {total} 篇文章 · 按时间倒序
</p>
</div>
</div>
<PageHeader
title="归档"
description={`共 ${total} 篇文章 · 按时间倒序`}
/>

<div className="space-y-10">
{grouped.map(([yearMonth, items]) => {
const [year, month] = yearMonth.split("-");
return (
<section key={yearMonth}>
<h2 className="mb-4 flex items-center gap-3 text-xl font-semibold">
<span className="inline-block h-6 w-1 rounded-full bg-linear-to-b from-brand-400 to-brand-600" />
<h2 className="mb-4 flex items-baseline gap-2 text-base font-semibold text-gray-900 dark:text-gray-100">
{year} 年 {parseInt(month, 10)} 月
<span className="text-sm font-normal text-muted-foreground">
<span className="text-sm font-normal text-gray-400">
({items.length})
</span>
</h2>

<div className="relative border-l-2 border-brand-200/60 pl-6 dark:border-brand-900/40">
<ul className="divide-y divide-gray-100 border-y border-gray-100 dark:divide-gray-800 dark:border-gray-800">
{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 (
<div key={article.id} className="group relative mb-4 last:mb-0">
<span className="absolute -left-7.75 top-1.5 h-3 w-3 rounded-full
border-2 border-brand-500 bg-background
transition-all duration-200
group-hover:scale-125 group-hover:bg-brand-500
group-hover:shadow-[0_0_0_4px_rgb(59_130_246/0.18)]" />
<div className="flex items-baseline gap-3 rounded-md
px-2 py-1 -mx-2 transition-colors
group-hover:bg-brand-50/60
dark:group-hover:bg-brand-900/20">
<time className="shrink-0 font-mono text-sm text-muted-foreground tabular-nums">
<li key={article.id} className="group">
<Link
href={`/articles/${article.id}`}
className="flex items-baseline gap-4 px-2 py-3 transition-colors
hover:bg-gray-50 dark:hover:bg-gray-800/50"
>
<time className="shrink-0 font-mono text-sm tabular-nums text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300">
{day}
</time>
<Link
href={`/articles/${article.id}`}
className="text-foreground transition-colors group-hover:text-brand-600
dark:group-hover:text-brand-400"
>
<span className="text-gray-700 group-hover:text-gray-900 dark:text-gray-300 dark:group-hover:text-gray-100">
{article.articleTitle}
</Link>
</div>
</div>
</span>
</Link>
</li>
);
})}
</div>
</ul>
</section>
);
})}

{grouped.length === 0 && (
<p className="text-center text-muted-foreground">暂无归档文章</p>
<p className="text-center text-gray-400">暂无归档文章</p>
)}
</div>
</div>
Expand Down
Loading
Loading