diff --git a/deploy/cleanup-docker.sh b/deploy/cleanup-docker.sh
new file mode 100644
index 0000000..5a48284
--- /dev/null
+++ b/deploy/cleanup-docker.sh
@@ -0,0 +1,43 @@
+# Docker 清理脚本
+# 使用方法: ./cleanup-docker.sh
+
+echo "===== Docker 空间清理 ====="
+echo ""
+
+# 查看当前空间使用
+echo "📊 当前 Docker 空间使用情况:"
+docker system df
+
+echo ""
+echo "===== 开始清理 ====="
+
+# 1. 清理停止的容器
+echo "🗑️ 清理已停止的容器..."
+docker container prune -f
+
+# 2. 清理 dangling images(无标签的镜像)
+echo "🗑️ 清理无标签镜像..."
+docker image prune -f
+
+# 3. 清理 build cache
+echo "🗑️ 清理构建缓存..."
+docker builder prune -f
+
+# 4. 清理未使用的 volumes(谨慎使用,确认数据已备份)
+echo "❓ 是否清理未使用的 volumes?(数据卷不常用不会自动清理)"
+echo " 当前 volumes:"
+docker volume ls
+
+# 5. 清理未使用的网络
+echo "🗑️ 清理未使用的网络..."
+docker network prune -f
+
+echo ""
+echo "===== 清理完成 ====="
+echo "📊 清理后空间使用情况:"
+docker system df
+
+echo ""
+echo "===== 完整清理(可选) ====="
+echo "如需释放更多空间,可以运行以下命令(会删除所有未使用的镜像、容器、网络):"
+echo " docker system prune -a"
diff --git a/frontend/src/app/(admin)/admin/articles/create/page.tsx b/frontend/src/app/(admin)/admin/articles/create/page.tsx
new file mode 100644
index 0000000..ee76349
--- /dev/null
+++ b/frontend/src/app/(admin)/admin/articles/create/page.tsx
@@ -0,0 +1,1148 @@
+"use client";
+
+import { useEffect, useState, useCallback, useRef, Suspense } from "react";
+import { useRouter, useSearchParams } from "next/navigation";
+import { flushSync } from "react-dom";
+import { api } from "@/lib/api";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import { SmartTagInput } from "@/components/editor/smart-tag-input";
+import { CoverImageUploader } from "@/components/editor/cover-image-uploader";
+import { useAutoSave } from "@/hooks/use-auto-save";
+import { articleSchema, type ArticleFormData } from "@/lib/validations/article";
+import { toast } from "sonner";
+import { ZodError } from "zod";
+import { cn } from "@/lib/utils";
+import {
+ ArrowLeft,
+ Save,
+ Eye,
+ EyeOff,
+ Columns2,
+ FileText,
+ X,
+ Settings2,
+ Send,
+ List,
+ FileEdit,
+ Trash2,
+ Loader2,
+} from "lucide-react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+
+/* ==============================
+ * 常量
+ * ============================== */
+
+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<
+ string,
+ { icon: string; label: string; title: string }
+> = {
+ 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: "有序列表" },
+};
+
+/* ==============================
+ * 类型
+ * ============================== */
+
+interface Article {
+ id: number;
+ articleTitle: string;
+ categoryName: string;
+ tagNameList: string[];
+ type: number;
+ status: number;
+ isTop: boolean;
+ createTime: string;
+}
+
+interface Category {
+ id: number;
+ categoryName: string;
+}
+
+interface Tag {
+ id: number;
+ tagName: string;
+}
+
+type ViewMode = "editor" | "split" | "preview";
+
+/* ==============================
+ * Markdown 渲染(简单实现)
+ * ============================== */
+
+function renderPreview(markdown: string): string {
+ const html = markdown
+ .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,
+ '
'
+ )
+ .replace(/\n\n/g, "")
+ .replace(/^---$/gm, "
");
+
+ return `${html}
`;
+}
+
+/* ==============================
+ * 主内容组件
+ * ============================== */
+
+function UnifiedEditorContent() {
+ 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 [form, setForm] = useState({
+ id: undefined,
+ articleTitle: "",
+ articleContent: "",
+ categoryName: "",
+ tagNameList: [],
+ articleCover: "",
+ type: 1,
+ status: 1,
+ });
+
+ const [categories, setCategories] = useState([]);
+ const [availableTags, setAvailableTags] = useState([]);
+ const [viewMode, setViewMode] = useState("split");
+ const [syncScroll, setSyncScroll] = useState(true);
+ const [showSettings, setShowSettings] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [isDirty, setIsDirty] = useState(false);
+ const [errors, setErrors] = useState>({});
+
+ // 草稿恢复提示
+ const [draftBanner, setDraftBanner] = useState<{
+ show: boolean;
+ articleTitle: string;
+ articleContent: string;
+ savedAt: string;
+ }>({ show: false, articleTitle: "", articleContent: "", savedAt: "" });
+
+ // 发布管理弹窗
+ const [showPublishDialog, setShowPublishDialog] = useState(false);
+ const [draftArticles, setDraftArticles] = useState([]);
+ const [loadingDrafts, setLoadingDrafts] = useState(false);
+ const [publishingId, setPublishingId] = useState(null);
+
+ /* ---------- Auto Save ---------- */
+ const { saveDraft, loadDraft, clearDraft } = useAutoSave({
+ id: articleId,
+ title: form.articleTitle,
+ content: form.articleContent,
+ });
+
+ /* ---------- 初始化 ---------- */
+ useEffect(() => {
+ // 加载草稿
+ const draft = loadDraft();
+ if (draft && !articleId) {
+ setDraftBanner({
+ show: true,
+ articleTitle: draft.title,
+ articleContent: draft.content,
+ savedAt: draft.savedAt ? new Date(draft.savedAt).toLocaleString() : "",
+ });
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ // 读取从导入文章传递过来的内容
+ if (articleId) return;
+ if (loadDraft()) return;
+ try {
+ const raw = sessionStorage.getItem("import_article");
+ if (!raw) return;
+ sessionStorage.removeItem("import_article");
+ const { fileName, content } = JSON.parse(raw) as {
+ fileName?: string;
+ content?: string;
+ };
+ if (!content) return;
+ const h1Match = content.match(/^\s*#\s+(.+)\s*$/m);
+ flushSync(() => {
+ setForm((prev) => ({
+ ...prev,
+ articleTitle: h1Match?.[1] ?? fileName ?? "",
+ articleContent: content,
+ }));
+ setIsDirty(true);
+ });
+ toast.success(`已导入:${fileName ?? "Markdown 文件"}`);
+ } catch {
+ // 静默失败
+ }
+ }, [articleId, loadDraft]);
+
+ // 加载分类和标签
+ useEffect(() => {
+ api
+ .get("/admin/categories")
+ .then((res) => {
+ if (res.flag) {
+ const data = res.data;
+ setCategories(Array.isArray(data) ? data : data.records);
+ }
+ })
+ .catch((err) => console.error("加载分类失败:", err));
+
+ api
+ .get("/admin/tags")
+ .then((res) => {
+ if (res.flag) {
+ const data = res.data;
+ setAvailableTags(Array.isArray(data) ? data : data.records);
+ }
+ })
+ .catch((err) => console.error("加载标签失败:", err));
+ }, []);
+
+ // 加载文章数据(编辑模式)
+ useEffect(() => {
+ if (!articleId) return;
+ api
+ .get<{
+ id: number;
+ articleTitle: string;
+ articleContent: string;
+ articleCover: string;
+ type: number;
+ status: number;
+ category?: { categoryName: string };
+ tags?: { tagName: string }[];
+ }>(`/admin/articles/${articleId}`)
+ .then((res) => {
+ if (!res.flag || !res.data) return;
+ const d = res.data;
+ setForm({
+ id: d.id,
+ articleTitle: d.articleTitle ?? "",
+ articleContent: d.articleContent ?? "",
+ articleCover: d.articleCover ?? "",
+ type: d.type ?? 1,
+ status: d.status ?? 1,
+ categoryName: d.category?.categoryName ?? "",
+ tagNameList: (d.tags ?? []).map((t) => t.tagName),
+ });
+ });
+ }, [articleId]);
+
+ // 离开页面提示
+ useEffect(() => {
+ const handleBeforeUnload = (e: BeforeUnloadEvent) => {
+ if (isDirty) {
+ e.preventDefault();
+ // 现代浏览器需要设置 returnValue 才能显示对话框
+ Object.defineProperty(e, "returnValue", {
+ get: () => "",
+ configurable: true,
+ });
+ }
+ };
+ window.addEventListener("beforeunload", handleBeforeUnload);
+ return () => window.removeEventListener("beforeunload", handleBeforeUnload);
+ }, [isDirty]);
+
+ /* ---------- 字段更新 ---------- */
+ const updateField = useCallback(
+ (key: K, value: ArticleFormData[K]) => {
+ setForm((prev) => ({ ...prev, [key]: value }));
+ setIsDirty(true);
+ setErrors((prev) => {
+ const next = { ...prev };
+ delete next[key];
+ return next;
+ });
+ },
+ []
+ );
+
+ const validateField = useCallback(
+ (key: keyof ArticleFormData) => {
+ try {
+ articleSchema.shape[key].parse(form[key]);
+ setErrors((prev) => {
+ const next = { ...prev };
+ delete next[key];
+ return next;
+ });
+ } catch (err) {
+ if (err instanceof ZodError) {
+ const issues = err.issues || [];
+ setErrors((prev) => ({
+ ...prev,
+ [key]: issues[0]?.message || "",
+ }));
+ }
+ }
+ },
+ [form]
+ );
+
+ /* ---------- 草稿操作 ---------- */
+ const restoreDraft = () => {
+ setForm((prev) => ({
+ ...prev,
+ articleTitle: draftBanner.articleTitle,
+ articleContent: draftBanner.articleContent,
+ }));
+ setIsDirty(true);
+ setDraftBanner((prev) => ({ ...prev, show: false }));
+ toast.success("已恢复草稿");
+ };
+
+ const discardDraft = () => {
+ setDraftBanner((prev) => ({ ...prev, show: false }));
+ };
+
+ /* ---------- 工具栏命令 ---------- */
+ 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) +
+ `` +
+ 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 }));
+ updateField("articleContent", newText);
+ textarea.focus();
+ },
+ [updateField]
+ );
+
+ /* ---------- 同步滚动 ---------- */
+ 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();
+ saveDraft();
+ toast.success("草稿已保存");
+ break;
+ }
+ }
+ };
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [handleCommand, saveDraft]);
+
+ /* ---------- 发布 / 保存 ---------- */
+ const handlePublish = async () => {
+ try {
+ articleSchema.parse(form);
+ } catch (err) {
+ if (err instanceof ZodError) {
+ const newErrors: Record = {};
+ (err.issues || []).forEach((e) => {
+ const path = String(e.path[0]);
+ newErrors[path] = e.message;
+ });
+ setErrors(newErrors);
+ toast.error("请检查表单填写是否正确");
+ setShowSettings(true);
+ return;
+ }
+ }
+
+ setSaving(true);
+ try {
+ const res = await api.post("/admin/articles", form);
+ if (res.flag) {
+ toast.success("发布成功");
+ clearDraft();
+ setIsDirty(false);
+ router.push("/admin/articles");
+ } else {
+ toast.error(res.message);
+ }
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "发布失败");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleSaveDraft = () => {
+ saveDraft();
+ toast.success("草稿已保存");
+ };
+
+ // 获取草稿列表
+ const fetchDraftArticles = async () => {
+ setLoadingDrafts(true);
+ try {
+ const res = await api.get<{ records: Article[] }>("/admin/articles?current=1&size=100&status=1");
+ if (res.flag && res.data) {
+ setDraftArticles(res.data.records || []);
+ }
+ } catch {
+ toast.error("获取草稿列表失败");
+ } finally {
+ setLoadingDrafts(false);
+ }
+ };
+
+ // 打开发布弹窗
+ const handleOpenPublishDialog = () => {
+ fetchDraftArticles();
+ setShowPublishDialog(true);
+ };
+
+ // 一键发布草稿
+ const handleQuickPublish = async (id: number) => {
+ setPublishingId(id);
+ try {
+ const res = await api.put("/admin/articles/publish", { id });
+ if (res.flag) {
+ toast.success("发布成功");
+ fetchDraftArticles();
+ } else {
+ toast.error(res.message);
+ }
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "发布失败");
+ } finally {
+ setPublishingId(null);
+ }
+ };
+
+ // 删除草稿
+ const handleDeleteDraft = async (id: number) => {
+ if (!confirm("确定要删除这篇草稿吗?")) return;
+ try {
+ const res = await api.delete("/admin/articles", [id]);
+ if (res.flag) {
+ toast.success("删除成功");
+ fetchDraftArticles();
+ } else {
+ toast.error(res.message);
+ }
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "删除失败");
+ }
+ };
+
+ const handleCoverUpload = useCallback(async (file: File): Promise => {
+ const res = await api.upload("/admin/articles/images", file);
+ if (!res.flag) throw new Error(res.message);
+ return res.data;
+ }, []);
+
+ /* ---------- 视图切换标签 ---------- */
+ const VIEW_TABS: { mode: ViewMode; label: string; icon: React.ReactNode }[] = [
+ { mode: "editor", label: "编辑", icon: },
+ { mode: "split", label: "分屏", icon: },
+ {
+ mode: "preview",
+ label: "预览",
+ icon:
+ viewMode === "preview" ? (
+
+ ) : (
+
+ ),
+ },
+ ];
+
+ /* ==============================
+ * 渲染
+ * ============================== */
+
+ return (
+
+ {/* ---------- 顶部导航栏 ---------- */}
+
+
+ {/* ---------- 草稿恢复提示 ---------- */}
+ {draftBanner.show && (
+
+
+
+
+
+
+
检测到未保存的草稿
+
+ {draftBanner.savedAt} · {draftBanner.articleTitle || "无标题"}
+
+
+
+
+
+
+
+
+ )}
+
+ {/* ---------- 主内容区 ---------- */}
+
+ {/* 左侧:编辑器 */}
+
+ {/* 标题输入 */}
+
+
updateField("articleTitle", e.target.value)}
+ onBlur={() => validateField("articleTitle")}
+ placeholder="在这里输入文章标题..."
+ className={cn(
+ "w-full bg-transparent text-xl font-semibold outline-none placeholder:text-muted-foreground",
+ errors.articleTitle && "text-destructive"
+ )}
+ />
+ {errors.articleTitle && (
+
+ {errors.articleTitle}
+
+ )}
+
+
+ {/* 工具栏 */}
+
+ {TOOLBAR_GROUPS.map((group, groupIndex) => (
+
+ {groupIndex > 0 && (
+
+ )}
+
+ {group.label}
+
+ {group.items.map((item) => {
+ const cmd = TOOLBAR_COMMANDS[item];
+ return (
+
+ );
+ })}
+
+ ))}
+
+
+ {/* 同步滚动开关 */}
+
+
+ 同步滚动
+
+
+ {/* 文本编辑器 */}
+
+
+ {/* 右侧:预览 */}
+
+
+ {/* 预览标题 */}
+
+ {form.articleTitle || "在这里输入文章标题..."}
+
+
+ {/* 预览内容 */}
+ {form.articleContent ? (
+
+ ) : (
+
+ 预览将在您开始输入后显示...
+
+ )}
+
+
+
+ {/* ---------- 右侧设置面板 ---------- */}
+
+
+
+ {/* 发布管理弹窗 */}
+
+
+ );
+}
+
+/* ==============================
+ * 页面入口
+ * ============================== */
+
+export default function UnifiedEditorPage() {
+ return (
+
+ 加载中...
+
+ }
+ >
+
+
+ );
+}
diff --git a/frontend/src/app/(admin)/admin/articles/editor/page.tsx b/frontend/src/app/(admin)/admin/articles/editor/page.tsx
deleted file mode 100644
index 270c007..0000000
--- a/frontend/src/app/(admin)/admin/articles/editor/page.tsx
+++ /dev/null
@@ -1,458 +0,0 @@
-"use client";
-
-import { useEffect, useState, Suspense, useCallback } from "react";
-import { useRouter, useSearchParams } from "next/navigation";
-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, FileText } from "lucide-react";
-import { SmartTagInput, CoverImageUploader } from "@/components/editor";
-import { useAutoSave } from "@/hooks/use-auto-save";
-import { articleSchema, type ArticleFormData } from "@/lib/validations/article";
-import { ZodError } from "zod";
-import { cn } from "@/lib/utils";
-
-/* ==============================
- * 数据类型
- * ============================== */
-
-interface Category {
- id: number;
- categoryName: string;
-}
-
-interface Tag {
- id: number;
- tagName: string;
-}
-
-/* ==============================
- * 文章编辑器内容组件
- * ============================== */
-
-function EditorContent() {
- const router = useRouter();
- const searchParams = useSearchParams();
- const rawId = searchParams.get("id");
- const articleId = rawId && /^\d+$/.test(rawId) ? Number(rawId) : undefined;
-
- const [form, setForm] = useState({
- id: undefined,
- articleTitle: "",
- articleContent: "",
- categoryName: "",
- tagNameList: [],
- articleCover: "",
- type: 1,
- status: 1,
- });
- const [categories, setCategories] = useState([]);
- const [tags, setTags] = useState([]);
- const [saving, setSaving] = useState(false);
- const [errors, setErrors] = useState>({});
- const [showDraftDialog, setShowDraftDialog] = useState(false);
- const [draftData, setDraftData] = useState(null);
- const [isDirty, setIsDirty] = useState(false);
-
- // Auto save
- const { loadDraft, clearDraft } = useAutoSave({
- id: articleId,
- title: form.articleTitle,
- content: form.articleContent,
- });
-
- // Load draft on mount
- useEffect(() => {
- const draft = loadDraft();
- if (draft && !articleId) {
- setDraftData({
- articleTitle: draft.title,
- articleContent: draft.content,
- categoryName: "",
- tagNameList: [],
- articleCover: "",
- type: 1,
- status: 1,
- });
- setShowDraftDialog(true);
- }
- }, [loadDraft, articleId]);
-
- // 读取从写作页面传来的文章内容
- useEffect(() => {
- if (articleId) return;
- if (loadDraft()) return;
- 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;
- sessionStorage.removeItem("import_article");
- const { fileName, content } = JSON.parse(raw) as {
- fileName?: string;
- content?: string;
- };
- if (!content) return;
- const h1Match = content.match(/^\s*#\s+(.+)\s*$/m);
- const title = h1Match?.[1] ?? fileName ?? "";
- setForm((prev) => ({
- ...prev,
- articleTitle: title,
- articleContent: content,
- }));
- 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]);
-
- // Load categories and tags
- useEffect(() => {
- api
- .get("/admin/categories")
- .then((res) => {
- if (res.flag) {
- const data = res.data;
- setCategories(Array.isArray(data) ? data : data.records);
- }
- })
- .catch((err) => console.error("加载分类失败:", err));
-
- api
- .get("/admin/tags")
- .then((res) => {
- if (res.flag) {
- const data = res.data;
- setTags(Array.isArray(data) ? data : data.records);
- }
- })
- .catch((err) => console.error("加载标签失败:", err));
- }, []);
-
- // Load article data in edit mode
- useEffect(() => {
- if (articleId) {
- api
- .get<{
- id: number;
- articleTitle: string;
- articleContent: string;
- articleCover: string;
- type: number;
- status: number;
- category?: { categoryName: string };
- tags?: { tagName: string }[];
- }>(`/admin/articles/${articleId}`)
- .then((res) => {
- if (!res.flag || !res.data) return;
- const d = res.data;
- setForm({
- id: d.id,
- articleTitle: d.articleTitle ?? "",
- articleContent: d.articleContent ?? "",
- articleCover: d.articleCover ?? "",
- type: d.type ?? 1,
- status: d.status ?? 1,
- categoryName: d.category?.categoryName ?? "",
- tagNameList: (d.tags ?? []).map((t) => t.tagName),
- });
- });
- }
- }, [articleId]);
-
- const updateField = useCallback((
- key: K,
- value: ArticleFormData[K],
- ) => {
- setForm((prev) => ({ ...prev, [key]: value }));
- setIsDirty(true);
- setErrors((prev) => {
- const next = { ...prev };
- delete next[key];
- return next;
- });
- }, []);
-
- const validateField = useCallback((key: keyof ArticleFormData) => {
- try {
- articleSchema.shape[key].parse(form[key]);
- setErrors((prev) => {
- const next = { ...prev };
- delete next[key];
- return next;
- });
- } catch (err) {
- if (err instanceof ZodError) {
- const issues = err.issues || [];
- setErrors((prev) => ({ ...prev, [key]: issues[0]?.message || "" }));
- }
- }
- }, [form]);
-
- const handleSave = async () => {
- try {
- articleSchema.parse(form);
- } catch (err) {
- if (err instanceof ZodError) {
- const newErrors: Record = {};
- const issues = err.issues || [];
- issues.forEach((e) => {
- const path = String(e.path[0]);
- newErrors[path] = e.message;
- });
- setErrors(newErrors);
- toast.error("请检查表单填写是否正确");
- return;
- }
- }
-
- setSaving(true);
- try {
- const res = await api.post("/admin/articles", form);
- if (res.flag) {
- toast.success("保存成功");
- clearDraft();
- setIsDirty(false);
- router.push("/admin/articles");
- } else {
- toast.error(res.message);
- }
- } catch (err) {
- toast.error(err instanceof Error ? err.message : "保存失败");
- } finally {
- setSaving(false);
- }
- };
-
- const handleCoverUpload = useCallback(async (file: File): Promise => {
- const res = await api.upload("/admin/articles/images", file);
- if (!res.flag) throw new Error(res.message);
- return res.data;
- }, []);
-
- const restoreDraft = () => {
- if (draftData) {
- setForm(draftData);
- setIsDirty(true);
- }
- setShowDraftDialog(false);
- };
-
- const discardDraft = () => {
- setShowDraftDialog(false);
- };
-
- return (
-
- {/* 顶部工具栏 */}
-
-
-
updateField("articleTitle", e.target.value)}
- onBlur={() => validateField("articleTitle")}
- className={cn(
- "h-10 flex-1 border-transparent bg-transparent text-lg font-semibold shadow-none focus-visible:border-input focus-visible:ring-0",
- errors.articleTitle && "border-destructive"
- )}
- />
-
-
-
-
- {/* 错误提示 */}
- {errors.articleTitle && (
-
{errors.articleTitle}
- )}
-
- {/* Draft Recovery Dialog */}
- {showDraftDialog && (
-
-
-
-
-
-
-
- 检测到未保存的草稿
-
-
- {draftData?.articleTitle || "无标题"}
-
-
-
-
-
-
-
-
- )}
-
- {/* 主内容区 - 卡片布局 */}
-
- {/* 元信息卡片 */}
-
-
文章设置
-
- {/* 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}
- />
-
-
- {/* 文章内容预览 */}
-
-
文章内容预览
-
- {form.articleContent ? (
-
- {form.articleContent.slice(0, 1000)}
- {form.articleContent.length > 1000 && "..."}
-
- ) : (
-
- 暂无内容,请点击「写作」按钮编写文章
-
- )}
-
-
- 字数:{form.articleContent.length}
-
-
-
-
- );
-}
-
-/* ==============================
- * 页面入口
- * ============================== */
-
-export default function ArticleEditorPage() {
- return (
-
- 加载中...
-
- }
- >
-
-
- );
-}
diff --git a/frontend/src/app/(admin)/admin/articles/page.tsx b/frontend/src/app/(admin)/admin/articles/page.tsx
index 0420c58..12ee462 100644
--- a/frontend/src/app/(admin)/admin/articles/page.tsx
+++ b/frontend/src/app/(admin)/admin/articles/page.tsx
@@ -359,7 +359,7 @@ export default function ArticlesPage() {
variant="ghost"
size="icon-sm"
onClick={() =>
- router.push(`/admin/articles/editor?id=${article.id}`)
+ router.push(`/admin/articles/create?id=${article.id}`)
}
>
@@ -426,7 +426,7 @@ export default function ArticlesPage() {
content: text,
}),
);
- router.push("/admin/articles/editor");
+ router.push("/admin/articles/create");
} catch (err) {
toast.error(err instanceof Error ? err.message : "读取文件失败");
}
@@ -451,7 +451,7 @@ export default function ArticlesPage() {
导入文章
-