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, + '$1' + ) + .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) + + `![${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 })); + 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 ( +
    + {/* ---------- 顶部导航栏 ---------- */} +
    +
    + + + {articleId ? "编辑文章" : "写文章"} + +
    + +
    + {/* 视图切换 */} +
    + {VIEW_TABS.map((tab) => ( + + ))} +
    + + + + + + + + +
    +
    + + {/* ---------- 草稿恢复提示 ---------- */} + {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 ( + + ); + })} +
    + ))} +
    + + {/* 同步滚动开关 */} +
    + + 同步滚动 +
    + + {/* 文本编辑器 */} +