Skip to content
Merged

Web #16

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
20 changes: 9 additions & 11 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -140,19 +140,17 @@ jobs:
fi

echo ">>> 创建/重置管理员(依赖 .env 中的 SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD)"
# 缺失任一变量则跳过:保留"手工管理管理员"的部署模式不被强制覆盖。
# 无条件调用 ./seed:seed 命令自身已具备"未设置则优雅跳过"的逻辑,
# 这里不再做 shell 层的 env 检查 —— docker compose --env-file .env
# 只把变量喂给 compose 插值,不会 export 到 SSH shell,
# 此前用 ${SEED_ADMIN_EMAIL:-} 判断会始终为假,导致有 .env 也会被误判。
# 已存在同名账号时 ./seed 仅重置密码 + bump token_version,旧 JWT 立即失效。
if [ -n "${SEED_ADMIN_EMAIL:-}" ] && [ -n "${SEED_ADMIN_PASSWORD:-}" ]; then
if $COMPOSE run --rm backend /app/seed; then
echo "✅ 管理员账号已同步(创建 / 密码重置 / admin 角色补绑)"
else
echo "::error::管理员种子失败,请看下方日志"
$COMPOSE logs --tail=80 backend
exit 1
fi
if $COMPOSE run --rm backend /app/seed; then
echo "✅ 管理员账号已同步(创建 / 密码重置 / admin 角色补绑,或因未配置 seed 而跳过)"
else
echo "⚠️ SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD 未在 .env 中设置,跳过 ./seed。"
echo " 若 /admin/login 返回\"用户名或密码错误\",请在 .env 中填好这两个变量后重新部署。"
echo "::error::管理员种子失败,请看下方日志"
$COMPOSE logs --tail=80 backend
exit 1
fi

echo ">>> 启动 backend / frontend / nginx(强制用新镜像)"
Expand Down
12 changes: 7 additions & 5 deletions backend/cmd/seed/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,13 @@ func main() {
email := os.Getenv("SEED_ADMIN_EMAIL")
password := os.Getenv("SEED_ADMIN_PASSWORD")

if email == "" {
log.Fatal("SEED_ADMIN_EMAIL 未设置")
}
if password == "" {
log.Fatal("SEED_ADMIN_PASSWORD 未设置")
// 缺失任一变量时优雅退出(exit 0)而非 log.Fatal:
// - 让 CI/CD 流水线可以在不强制配置 seed 的情况下安全地调用 ./seed
// - 仍打印醒目日志,运维一看就知道为什么没建账号
if email == "" || password == "" {
log.Println("ℹ️ SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD 未设置,跳过 seed。" +
"如需创建/重置管理员,请在 .env 或当前 shell 中设置后重跑。")
return
}
if len(password) < minPasswordLen {
log.Fatalf("SEED_ADMIN_PASSWORD 至少需要 %d 个字符", minPasswordLen)
Expand Down
112 changes: 72 additions & 40 deletions frontend/src/app/(admin)/admin/articles/editor/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ 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 } from "lucide-react";
import { Save, ArrowLeft, X } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { MarkdownToolbar, CoverImageUploader, SmartTagInput, ImageUploader } from "@/components/editor";
import { useAutoSave } from "@/hooks/use-auto-save";
import { useMarkdownEditor } from "@/hooks/use-markdown-editor";
Expand Down Expand Up @@ -58,6 +59,7 @@ function EditorContent() {
const [showDraftDialog, setShowDraftDialog] = useState(false);
const [draftData, setDraftData] = useState<ArticleFormData | null>(null);
const [isDirty, setIsDirty] = useState(false);
const [syncScroll, setSyncScroll] = useState(true);
const { wrapSelection, insertAtCursor } = useMarkdownEditor();

// Auto save
Expand All @@ -84,6 +86,34 @@ function EditorContent() {
}
}, [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 } = JSON.parse(raw) as {
fileName?: string;
content?: string;
};
if (!content) return;
// 提取首个 H1 作为标题(找不到则用文件名)
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) => {
Expand Down Expand Up @@ -295,31 +325,47 @@ function EditorContent() {
};

return (
<div className="space-y-6 max-w-5xl mx-auto">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeft className="size-4" />
</Button>
<h1 className="text-xl font-semibold">
{articleId ? "编辑文章" : "发布文章"}
</h1>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button variant="outline" onClick={() => {
saveDraft();
toast.success("草稿已保存");
}}>
<Save className="mr-1 size-4" />
保存草稿
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? "保存中..." : "发布文章"}
</Button>
<div className="flex h-full flex-col gap-4">
{/* 顶部工具栏:标题 + 同步滚动 + 取消/保存 */}
<div className="flex items-center gap-3 border-b pb-3">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeft className="size-4" />
</Button>
<Input
id="title"
placeholder="请输入文章标题"
value={form.articleTitle}
onChange={(e) => updateField("articleTitle", e.target.value)}
onBlur={() => validateField("articleTitle")}
className={`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" : ""
}`}
/>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Switch
id="sync-scroll"
checked={syncScroll}
onCheckedChange={setSyncScroll}
/>
<label htmlFor="sync-scroll" className="cursor-pointer select-none">
同步滚动
</label>
</div>
<Button variant="ghost" onClick={() => router.push("/admin/articles")}>
<X className="size-4" />
取消
</Button>
<Button onClick={handleSave} disabled={saving}>
<Save className="mr-1 size-4" />
{saving ? "保存中..." : "保存"}
</Button>
</div>

{/* 错误提示 */}
{errors.articleTitle && (
<p className="text-sm text-destructive">{errors.articleTitle}</p>
)}

{/* Draft Recovery Dialog */}
{showDraftDialog && (
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4 dark:border-yellow-800 dark:bg-yellow-950">
Expand All @@ -337,22 +383,6 @@ function EditorContent() {
</div>
)}

{/* Title */}
<div className="space-y-2">
<Label htmlFor="title">文章标题 *</Label>
<Input
id="title"
placeholder="请输入文章标题"
value={form.articleTitle}
onChange={(e) => updateField("articleTitle", e.target.value)}
onBlur={() => validateField("articleTitle")}
className={errors.articleTitle ? "border-destructive" : ""}
/>
{errors.articleTitle && (
<p className="text-sm text-destructive">{errors.articleTitle}</p>
)}
</div>

{/* Category & Type & Status */}
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
Expand Down Expand Up @@ -451,7 +481,9 @@ function EditorContent() {
onChange={(val) => {
updateField("articleContent", val || "");
}}
height={500}
height={typeof window !== "undefined" && window.innerWidth < 768 ? 420 : 620}
preview={syncScroll ? "live" : "edit"}
visibleDragbar={false}
/>
</div>
{errors.articleContent && (
Expand Down
57 changes: 51 additions & 6 deletions frontend/src/app/(admin)/admin/articles/page.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
"use client";

import { useState, useEffect, useCallback, useMemo } from "react";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useRouter } from "next/navigation";
import { type ColumnDef } from "@tanstack/react-table";
import { api } from "@/lib/api";
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 } from "lucide-react";
import { Pencil, Pin, Trash2, Plus, Filter, Upload, FileText } from "lucide-react";
import {
Dialog,
DialogContent,
Expand Down Expand Up @@ -403,14 +403,59 @@ export default function ArticlesPage() {
[handleDelete],
);

// 导入 .md 文件
const fileInputRef = useRef<HTMLInputElement>(null);
const handleImportClick = () => fileInputRef.current?.click();
const handleImportFile = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
// 清空 input,允许重复导入同一文件
e.target.value = "";
if (!file) return;
if (!file.name.toLowerCase().endsWith(".md") && !file.name.toLowerCase().endsWith(".markdown")) {
toast.error("请选择 Markdown 文件 (.md)");
return;
}
try {
const text = await file.text();
// 通过 sessionStorage 传递给编辑器(带文件名做标题兜底)
sessionStorage.setItem(
"import_article",
JSON.stringify({
fileName: file.name.replace(/\.(md|markdown)$/i, ""),
content: text,
}),
);
router.push("/admin/articles/editor");
} catch (err) {
toast.error(err instanceof Error ? err.message : "读取文件失败");
}
},
[router],
);

return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">文章列表</h1>
<Button onClick={() => router.push("/admin/articles/editor")}>
<Plus className="mr-1 size-4" />
发布文章
</Button>
<div className="flex items-center gap-2">
{/* 隐藏的 file input */}
<input
ref={fileInputRef}
type="file"
accept=".md,.markdown,text/markdown"
className="hidden"
onChange={handleImportFile}
/>
<Button variant="outline" onClick={handleImportClick}>
<Upload className="mr-1 size-4" />
导入文章
</Button>
<Button onClick={() => router.push("/admin/articles/editor")}>
<Plus className="mr-1 size-4" />
新建文章
</Button>
</div>
</div>

{loading ? (
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/app/(blog)/ArticleList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,13 @@ export default function ArticleList() {
const totalPages = Math.ceil(total / PAGE_SIZE);

return (
<div id="articles" className="grid gap-8 lg:grid-cols-[1fr_310px]">
<div id="articles" className="grid gap-6 lg:grid-cols-[1fr_310px]">
{/* 主列表 */}
<main className="min-w-0">
{/* 工具栏 */}
<div className="mb-6 flex items-center justify-between border-b border-gray-200 pb-3 dark:border-gray-700">
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{hasFilter ? "文章筛选" : "最新推荐"}
{hasFilter ? "文章筛选" : "最新文章"}
</h2>
<div className="flex items-center gap-3">
{/* 图片开关 */}
Expand Down Expand Up @@ -202,7 +202,6 @@ export default function ArticleList() {

{/* 侧栏 */}
<aside className="sticky top-28 hidden self-start space-y-0 lg:block">
<RecommendedArticles />
<CategorySidebar
activeId={categoryId}
onSelect={(id) => setFilter("category", id)}
Expand All @@ -211,11 +210,11 @@ export default function ArticleList() {
activeId={tagId}
onSelect={(id) => setFilter("tag", id)}
/>
<RecommendedArticles />
</aside>

{/* 移动端:筛选在底部 */}
<aside className="space-y-0 lg:hidden">
<RecommendedArticles />
<CategorySidebar
activeId={categoryId}
onSelect={(id) => setFilter("category", id)}
Expand All @@ -224,6 +223,7 @@ export default function ArticleList() {
activeId={tagId}
onSelect={(id) => setFilter("tag", id)}
/>
<RecommendedArticles />
</aside>
</div>
);
Expand Down
19 changes: 7 additions & 12 deletions frontend/src/app/(blog)/articles/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,9 +191,9 @@ export default function ArticleDetailPage() {

return (
<div className="relative">
{/* 封面(spec §4.6) */}
{/* 封面(full-bleed 全宽铺满视口) */}
{article.articleCover && (
<div className="relative h-72 w-full overflow-hidden md:h-96">
<div className="full-bleed relative h-72 w-full overflow-hidden bg-gray-900 md:h-[420px]">
<Image
src={article.articleCover}
alt={article.articleTitle}
Expand All @@ -203,8 +203,8 @@ export default function ArticleDetailPage() {
className="object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-b from-black/30 to-black/70" />
<div className="absolute inset-x-0 bottom-0 px-4 pb-8 md:px-8">
<div className="mx-auto max-w-4xl">
<div className="absolute inset-x-0 bottom-0 px-4 pb-10 md:px-8 md:pb-14">
<div className="mx-auto max-w-[1240px]">
{article.categoryName && (
<Link
href={`/?category=${article.categoryId ?? ""}`}
Expand All @@ -216,7 +216,7 @@ export default function ArticleDetailPage() {
<h1 className="text-3xl font-bold text-white md:text-5xl">
{article.articleTitle}
</h1>
<div className="mt-3 flex flex-wrap items-center gap-3 text-xs text-gray-200">
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-gray-200">
<span className="inline-flex items-center gap-1">
<Calendar className="h-3 w-3" />
{article.createTime}
Expand All @@ -233,13 +233,8 @@ export default function ArticleDetailPage() {
</div>
)}

{/* 三栏布局 */}
<div
className={cn(
"mx-auto grid max-w-[1240px] gap-8 px-4 py-8 lg:grid-cols-[1fr_4fr_1fr]",
article.articleCover && "-mt-20 relative z-10",
)}
>
{/* 三栏布局:自然衔接封面,不做 -mt-20 覆盖以免 TOC 卡粘性时盖住标题 */}
<div className="mx-auto grid max-w-[1240px] gap-8 px-4 py-8 lg:grid-cols-[1fr_4fr_1fr]">
{/* 左侧 TOC */}
<TableOfContents headings={headings} />

Expand Down
Loading
Loading