@@ -73,7 +73,7 @@ export function RelatedPosts({ currentSlug, currentTags }: RelatedPostsProps) {
{/* 底部箭头 */}
-
+
diff --git a/client/src/globals.css b/client/src/globals.css
index 913e0bd..5e36b22 100644
--- a/client/src/globals.css
+++ b/client/src/globals.css
@@ -1,3 +1,4 @@
+@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@450;500;600;700&family=Work+Sans:wght@400;500;600;700&display=swap");
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@@ -9,9 +10,9 @@
--color-background: var(--background);
--color-foreground: var(--foreground);
/* 调用苹果原厂 SF Pro / 苹方 fallback 魔法 */
- --font-sans: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, "Noto Sans SC", sans-serif;
- --font-mono: ui-monospace, "SF Mono", "Cascadia Code", "Fira Code", Consolas, monospace;
- --font-heading: var(--font-sans);
+ --font-sans: "Work Sans", -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, "Noto Sans SC", sans-serif;
+ --font-mono: "Space Grotesk", ui-monospace, "SF Mono", "Cascadia Code", "Fira Code", Consolas, monospace;
+ --font-heading: "Space Grotesk", var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -146,8 +147,8 @@
-webkit-overflow-scrolling: touch;
-webkit-tap-highlight-color: transparent;
background-image:
- linear-gradient(to right, color-mix(in oklch, var(--border) 18%, transparent) 1px, transparent 1px),
- linear-gradient(to bottom, color-mix(in oklch, var(--border) 16%, transparent) 1px, transparent 1px);
+ linear-gradient(to right, color-mix(in oklch, var(--border) 12%, transparent) 1px, transparent 1px),
+ linear-gradient(to bottom, color-mix(in oklch, var(--border) 10%, transparent) 1px, transparent 1px);
background-size: 96px 96px;
background-position: center top;
}
@@ -1092,7 +1093,7 @@
top: 0;
left: 0;
height: 2px;
- background: linear-gradient(90deg, oklch(0.65 0.18 220), oklch(0.7 0.2 200));
+ background: linear-gradient(90deg, color-mix(in oklch, var(--foreground) 55%, transparent), color-mix(in oklch, var(--foreground) 82%, transparent));
z-index: 100;
transition: width 80ms linear;
pointer-events: none;
diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts
index 1140753..e7194d0 100644
--- a/client/src/lib/api.ts
+++ b/client/src/lib/api.ts
@@ -151,6 +151,7 @@ function authHeaders(): HeadersInit {
export async function login(password: string): Promise
{
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
+ cache: "no-store",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
@@ -163,6 +164,7 @@ export async function login(password: string): Promise {
export async function checkAuth(): Promise {
try {
const res = await fetch(`${API_BASE}/api/auth/me`, {
+ cache: "no-store",
headers: authHeaders(),
});
const data = await res.json();
diff --git a/client/src/lib/importers/frontmatter.ts b/client/src/lib/importers/frontmatter.ts
index 0b82677..e550683 100644
--- a/client/src/lib/importers/frontmatter.ts
+++ b/client/src/lib/importers/frontmatter.ts
@@ -12,6 +12,7 @@ export interface FrontmatterData {
draft: boolean;
slug: string;
excerpt: string;
+ coverImage: string;
[key: string]: unknown;
}
@@ -82,6 +83,7 @@ function createEmptyFrontmatter(): FrontmatterData {
draft: false,
slug: "",
excerpt: "",
+ coverImage: "",
};
}
@@ -179,6 +181,14 @@ function parseYamlFrontmatter(yaml: string): FrontmatterData {
case "description":
result.excerpt = value;
break;
+ case "cover":
+ case "cover_image":
+ case "coverimage":
+ case "thumbnail":
+ case "banner":
+ case "image":
+ result.coverImage = value;
+ break;
case "tags":
// 可能是单个值
if (value) result.tags.push(value);
@@ -242,6 +252,14 @@ function parseTomlFrontmatter(toml: string): FrontmatterData {
case "summary":
result.excerpt = value;
break;
+ case "cover":
+ case "cover_image":
+ case "coverimage":
+ case "thumbnail":
+ case "banner":
+ case "image":
+ result.coverImage = value;
+ break;
}
}
diff --git a/client/src/lib/importers/hexo.ts b/client/src/lib/importers/hexo.ts
index dfc63ec..19fd6b6 100644
--- a/client/src/lib/importers/hexo.ts
+++ b/client/src/lib/importers/hexo.ts
@@ -32,6 +32,7 @@ async function convertHexoFiles(files: File[]): Promise {
if (!content.trim() && !frontmatter.title) continue; // 跳过空文件
// 合并 tags 和 categories
+ const category = frontmatter.categories[0] || "";
const tags = [
...new Set([...frontmatter.tags, ...frontmatter.categories]),
];
@@ -49,9 +50,12 @@ async function convertHexoFiles(files: File[]): Promise {
title: frontmatter.title || slug,
content,
excerpt: frontmatter.excerpt,
+ coverImage: frontmatter.coverImage || undefined,
published: !frontmatter.draft,
pinned: false,
listed: true,
+ publishAt: normalizePublishAt(frontmatter.date),
+ category,
tags,
});
}
@@ -65,7 +69,7 @@ async function convertHexoFiles(files: File[]): Promise {
platform: "Hexo",
postCount: posts.length,
tagCount: tagNames.length,
- categoryCount: 0,
+ categoryCount: new Set(posts.map((post) => post.category).filter(Boolean)).size,
commentCount: 0,
postTitles: posts
.slice(0, 20)
@@ -90,3 +94,10 @@ export const hexoPlatform: PlatformInfo = {
color: "blue",
parse: convertHexoFiles,
};
+
+function normalizePublishAt(date: string): string | null {
+ if (!date) return null;
+ const normalized = date.includes("T") ? date : date.replace(" ", "T");
+ const parsed = new Date(normalized);
+ return Number.isNaN(parsed.getTime()) ? date : parsed.toISOString();
+}
diff --git a/client/src/lib/importers/types.ts b/client/src/lib/importers/types.ts
index dcf3677..89af8bf 100644
--- a/client/src/lib/importers/types.ts
+++ b/client/src/lib/importers/types.ts
@@ -9,9 +9,12 @@ export interface ImportedPost {
title: string;
content: string;
excerpt: string;
+ coverImage?: string;
published: boolean;
pinned: boolean;
listed: boolean;
+ publishAt?: string | null;
+ category?: string;
tags: string[];
}
diff --git a/client/src/pages/admin/dashboard.tsx b/client/src/pages/admin/dashboard.tsx
index 88e8b43..bb9f5d8 100644
--- a/client/src/pages/admin/dashboard.tsx
+++ b/client/src/pages/admin/dashboard.tsx
@@ -1,7 +1,7 @@
import { useState, useEffect, useMemo } from "react";
import { Link } from "wouter";
import { fetchAdminPosts, deletePost, batchOperatePosts, fetchViewStats, type Post, type ViewStats } from "@/lib/api";
-import { Plus, Edit, Trash2, Eye, FileText, Clock, Search, ExternalLink, Globe, CheckCircle2, AlertTriangle, XCircle, CheckSquare, Square, EyeOff, TrendingUp, ArrowRight, BarChart3 } from "lucide-react";
+import { Plus, Edit, Trash2, Eye, FileText, Clock, Search, ExternalLink, Globe, CheckSquare, Square, EyeOff, TrendingUp, ArrowRight, BarChart3 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
function timeAgo(d: string): string {
@@ -118,22 +118,76 @@ export function AdminDashboard() {
return Array.from(tagSet).sort();
}, [posts]);
+ const seoHealth = useMemo(() => {
+ if (posts.length === 0) return null;
+
+ const published = posts.filter((p) => p.published);
+ const withExcerpt = published.filter((p) => p.excerpt && p.excerpt.trim().length > 0);
+ const withTags = published.filter((p) => p.tags.length > 0);
+ const goodSlug = published.filter((p) => /^[a-z0-9-]+$/.test(p.slug) && !p.slug.includes("--") && !p.slug.startsWith("-") && !p.slug.endsWith("-"));
+ const withTitle50 = published.filter((p) => p.title.length <= 60 && p.title.length >= 5);
+
+ const checks = [
+ { label: "Meta", ok: withExcerpt.length, total: published.length, desc: "摘要" },
+ { label: "标签", ok: withTags.length, total: published.length, desc: "覆盖" },
+ { label: "URL", ok: goodSlug.length, total: published.length, desc: "规范" },
+ { label: "标题", ok: withTitle50.length, total: published.length, desc: "长度" },
+ ];
+
+ const totalOk = checks.reduce((sum, check) => sum + check.ok, 0);
+ const totalAll = checks.reduce((sum, check) => sum + check.total, 0);
+ const score = totalAll > 0 ? Math.round((totalOk / totalAll) * 100) : 0;
+ const tone = score >= 90
+ ? { label: "优秀", text: "text-foreground/85", dot: "bg-cyan-300/80", bar: "bg-cyan-300/85" }
+ : score >= 70
+ ? { label: "需关注", text: "text-foreground/75", dot: "bg-amber-400/70", bar: "bg-amber-400/75" }
+ : { label: "待修复", text: "text-foreground/75", dot: "bg-red-400/70", bar: "bg-red-400/75" };
+
+ return { score, tone };
+ }, [posts]);
+
return (
-
+
{/* ═══════════ 顶栏:标题 + 操作 ═══════════ */}
-
-
+
+
-
今日工作台
-
内容运营控制台
-
+
今日工作台
+
内容运营控制台
+
集中处理文章状态、搜索筛选、批量发布、SEO 健康与访问趋势。
-
-
+
+ {seoHealth && (
+
+
+
+
+
+
+
+
+ SEO 健康
+ {seoHealth.score}%
+
+
+
+
+
+
+ )}
+
写文章
@@ -141,28 +195,28 @@ export function AdminDashboard() {
{/* ═══════════ 数据概览行 ═══════════ */}
-
+
{([
- { key: "all" as FilterType, label: "全部", value: posts.length, icon: FileText, activeColor: "border-foreground/20 bg-foreground/[0.03]", iconColor: "text-foreground/60" },
- { key: "published" as FilterType, label: "已发布", value: publishedCount, icon: Eye, activeColor: "border-emerald-500/25 bg-emerald-500/[0.04]", iconColor: "text-emerald-400/70" },
- { key: "draft" as FilterType, label: "草稿", value: draftCount, icon: Clock, activeColor: "border-amber-500/25 bg-amber-500/[0.04]", iconColor: "text-amber-400/70" },
+ { key: "all" as FilterType, label: "全部", value: posts.length, icon: FileText },
+ { key: "published" as FilterType, label: "已发布", value: publishedCount, icon: Eye },
+ { key: "draft" as FilterType, label: "草稿", value: draftCount, icon: Clock },
] as const).map((stat) => (
))}
浏览量
-
+
-
{viewStats?.totalViews?.toLocaleString() ?? "—"}
+
{viewStats?.totalViews?.toLocaleString() ?? "—"}
@@ -173,7 +227,7 @@ export function AdminDashboard() {
setSearch(e.target.value)}
placeholder="搜索标题、Slug 或标签..."
- className="h-[44px] w-full rounded-md border border-border/20 bg-background/35 pl-[36px] pr-[14px] text-[14px] text-foreground outline-none transition-all placeholder:text-muted-foreground/35 focus:border-foreground/25 focus:bg-background/55"
+ className="h-[36px] w-full rounded-md border border-border/20 bg-background/35 pl-[36px] pr-[14px] text-[13px] text-foreground outline-none transition-all placeholder:text-muted-foreground/35 focus:border-foreground/25 focus:bg-background/55"
/>
@@ -184,32 +238,32 @@ export function AdminDashboard() {
{/* ─── 文章列表 ─── */}
-
+
{filter === "all" ? "所有文章" : filter === "published" ? "已发布" : "草稿箱"}
- {selectedTag && <>·{selectedTag}>}
+ {selectedTag && <>·{selectedTag}>}
{filteredPosts.length} 篇
{/* 批量操作工具栏 */}
{filteredPosts.length > 0 && (
-
0 ? "border-cyan-500/30 bg-cyan-500/5" : ""}`}>
+
0 ? "border-foreground/20 bg-foreground/[0.03]" : ""}`}>
-
{selectedSlugs.size > 0 && (
- handleBatchOperate("publish")} disabled={batchOperating} className="flex min-h-[36px] items-center gap-[4px] rounded-md border border-border/20 px-[10px] py-[4px] text-[11px] text-emerald-400 transition-colors hover:bg-emerald-400/10 disabled:opacity-50">
+ handleBatchOperate("publish")} disabled={batchOperating} className="flex h-[28px] items-center gap-[4px] rounded-md border border-border/20 px-[10px] text-[11px] text-foreground/70 transition-colors hover:bg-foreground/[0.06] hover:text-foreground disabled:opacity-50">
发布
- handleBatchOperate("unpublish")} disabled={batchOperating} className="flex min-h-[36px] items-center gap-[4px] rounded-md border border-border/20 px-[10px] py-[4px] text-[11px] text-amber-400 transition-colors hover:bg-amber-400/10 disabled:opacity-50">
+ handleBatchOperate("unpublish")} disabled={batchOperating} className="flex h-[28px] items-center gap-[4px] rounded-md border border-border/20 px-[10px] text-[11px] text-muted-foreground/70 transition-colors hover:bg-foreground/[0.06] hover:text-foreground disabled:opacity-50">
撤回
- handleBatchOperate("delete")} disabled={batchOperating} className="flex min-h-[36px] items-center gap-[4px] rounded-md border border-red-500/30 px-[10px] py-[4px] text-[11px] text-red-400 transition-colors hover:bg-red-400/10 disabled:opacity-50">
+ handleBatchOperate("delete")} disabled={batchOperating} className="flex h-[28px] items-center gap-[4px] rounded-md border border-red-500/30 px-[10px] text-[11px] text-red-400 transition-colors hover:bg-red-400/10 disabled:opacity-50">
删除
@@ -226,40 +280,46 @@ export function AdminDashboard() {
{search || selectedTag ? "没有符合条件的文章" : "暂无文章"}
{!search && !selectedTag && (
-
+
写第一篇
)}
) : (
-
+
+
+
+ 文章
+ 浏览
+ 操作
+
{filteredPosts.map((post) => (
-
+
{/* 复选框 */}
-
toggleSelect(post.slug)} className={`flex h-[36px] w-[36px] shrink-0 items-center justify-center rounded-md transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring ${selectedSlugs.has(post.slug) ? "text-cyan-400" : "text-muted-foreground/25 group-hover:text-muted-foreground/50"}`} aria-label={`选择 ${post.title}`}>
+ toggleSelect(post.slug)} className={`flex h-[36px] w-[36px] shrink-0 items-center justify-center rounded-md transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring ${selectedSlugs.has(post.slug) ? "text-foreground/75" : "text-muted-foreground/25 group-hover:text-muted-foreground/50"}`} aria-label={`选择 ${post.title}`}>
{selectedSlugs.has(post.slug) ? : }
{/* 状态指示点 */}
-
+
-
-
{post.title}
+
+ {post.title}
{post.pinned && 置顶}
{timeAgo(post.updatedAt || post.createdAt)}
- {(post.viewCount ?? 0).toLocaleString()}
+ {(post.viewCount ?? 0).toLocaleString()}
{post.tags.length > 0 && {post.tags.slice(0, 2).join(" · ")}}
{/* 操作按钮 — hover 显现 */}
-
+
@@ -276,79 +336,16 @@ export function AdminDashboard() {
)}
- {/* ─── 右侧边栏:标签 + 热门 + SEO ─── */}
+ {/* ─── 右侧边栏:标签 + 热门 ─── */}
- {/* SEO 健康状态 */}
- {posts.length > 0 && (() => {
- const published = posts.filter(p => p.published);
- const withExcerpt = published.filter(p => p.excerpt && p.excerpt.trim().length > 0);
- const withTags = published.filter(p => p.tags.length > 0);
- const goodSlug = published.filter(p => /^[a-z0-9-]+$/.test(p.slug) && !p.slug.includes("--") && !p.slug.startsWith("-") && !p.slug.endsWith("-"));
- const withTitle50 = published.filter(p => p.title.length <= 60 && p.title.length >= 5);
-
- const checks = [
- { label: "Meta 摘要", ok: withExcerpt.length, total: published.length, desc: "已填写 excerpt" },
- { label: "标签覆盖", ok: withTags.length, total: published.length, desc: "至少 1 个标签" },
- { label: "URL 规范", ok: goodSlug.length, total: published.length, desc: "slug 为小写+连字符" },
- { label: "标题长度", ok: withTitle50.length, total: published.length, desc: "5-60 字符" },
- ];
-
- const totalOk = checks.reduce((s, c) => s + c.ok, 0);
- const totalAll = checks.reduce((s, c) => s + c.total, 0);
- const score = totalAll > 0 ? Math.round((totalOk / totalAll) * 100) : 0;
-
- const scoreColor = score >= 90 ? "text-emerald-400" : score >= 70 ? "text-amber-400" : "text-red-400";
- const scoreBg = score >= 90 ? "bg-emerald-500/8" : score >= 70 ? "bg-amber-500/8" : "bg-red-500/8";
- const scoreBorder = score >= 90 ? "border-emerald-500/20" : score >= 70 ? "border-amber-500/20" : "border-red-500/20";
-
- return (
-
-
-
- SEO 健康
-
- {score}%
-
-
- {checks.map(c => {
- const pct = c.total > 0 ? Math.round((c.ok / c.total) * 100) : 0;
- const Icon = pct === 100 ? CheckCircle2 : pct >= 70 ? AlertTriangle : XCircle;
- const color = pct === 100 ? "text-emerald-400/70" : pct >= 70 ? "text-amber-400/70" : "text-red-400/60";
- return (
-
-
- {c.label}
- {c.ok}/{c.total}
-
- );
- })}
-
- {/* sitemap + robots 固定指标 */}
-
- {[
- { label: "Sitemap", ok: true },
- { label: "Robots noindex (404)", ok: true },
- { label: "JSON-LD 结构化", ok: true },
- { label: "OG 社交标签", ok: true },
- ].map(item => (
-
-
- {item.label}
-
- ))}
-
-
- );
- })()}
-
{/* 标签 */}
{allTags.length > 0 && (
标签
{allTags.length > 8 && (
-
setTagExpanded(!tagExpanded)} className="text-[10px] text-cyan-400/60 hover:text-cyan-400 transition-colors">
+ setTagExpanded(!tagExpanded)} className="text-[10px] text-muted-foreground/55 transition-colors hover:text-foreground/75">
{tagExpanded ? "收起" : `+${allTags.length - 8}`}
)}
@@ -358,10 +355,10 @@ export function AdminDashboard() {
const count = posts.filter((p) => p.tags.includes(tag)).length;
return (
setSelectedTag(selectedTag === tag ? "" : tag)}
- className={`inline-flex min-h-[32px] items-center gap-[4px] rounded-md px-[8px] text-[11px] transition-all focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring ${
+ className={`inline-flex h-[24px] items-center gap-[4px] rounded-md border px-[8px] text-[11px] transition-all focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring ${
selectedTag === tag
- ? "bg-cyan-500/12 text-cyan-400 font-medium"
- : "text-muted-foreground/40 hover:text-foreground/70 hover:bg-card/30"
+ ? "border-foreground/18 bg-foreground/[0.08] text-foreground/85 font-medium"
+ : "border-border/10 text-muted-foreground/40 hover:text-foreground/70 hover:bg-card/30"
}`}
>
{tag}{count}
diff --git a/client/src/pages/admin/editor.tsx b/client/src/pages/admin/editor.tsx
index 5bcf215..5fd9c0a 100644
--- a/client/src/pages/admin/editor.tsx
+++ b/client/src/pages/admin/editor.tsx
@@ -113,7 +113,7 @@ export function AdminEditor() {
title: "",
content: "",
excerpt: "",
- coverColor: "from-cyan-500/20 to-blue-600/20",
+ coverColor: "from-zinc-500/20 to-slate-500/20",
coverImage: "",
tags: "",
published: true,
@@ -450,73 +450,79 @@ export function AdminEditor() {
];
const colorPresets = [
- { label: "赛博青", value: "from-cyan-500/20 to-blue-600/20" },
- { label: "冷翡翠", value: "from-emerald-500/20 to-teal-600/20" },
- { label: "深海蓝", value: "from-indigo-500/20 to-blue-700/20" },
- { label: "琥珀金", value: "from-amber-500/20 to-orange-600/20" },
{ label: "钛金灰", value: "from-zinc-500/20 to-slate-500/20" },
{ label: "石板墨", value: "from-slate-600/20 to-gray-700/20" },
+ { label: "冷白", value: "from-neutral-100/16 to-zinc-500/16" },
+ { label: "低饱和青", value: "from-cyan-500/12 to-slate-500/12" },
+ { label: "琥珀状态", value: "from-amber-500/14 to-zinc-500/14" },
+ { label: "红色状态", value: "from-red-500/12 to-zinc-500/12" },
];
return (
e.preventDefault()}
onPaste={handlePaste}
>
{/* ─── 顶栏 ─── */}
-
-
+
+
setLocation("/admin")}
- className="inline-flex items-center gap-[5px] h-[30px] px-[10px] rounded-md border border-border/20 bg-card/10 text-[12px] text-muted-foreground/85 hover:text-foreground hover:bg-card/20 transition-colors"
+ className="inline-flex min-h-[36px] items-center gap-[6px] rounded-md border border-border/20 bg-card/10 px-[10px] text-[12px] text-muted-foreground/85 transition-colors hover:bg-card/20 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
>
返回
-
{wordCount} 字
+
+
{isEdit ? "EDITING" : "NEW DRAFT"}
+
+ {form.title || "未命名文章"}
+
+
+
{wordCount} 字
{lastSaved && (
<>
-
|
-
上次保存 {lastSaved}
+
/
+
上次保存 {lastSaved}
>
)}
-
+
{message.text && (
-
{message.type === "success" ? "✓" : "✕"} {message.text}
)}
-
setZenMode(!zenMode)} title="专注模式" className="h-[30px] px-[8px] rounded-md text-muted-foreground/85 hover:text-foreground hover:bg-accent/20 transition-colors">
- {zenMode ? : }
+ setZenMode(!zenMode)} title="专注模式" className="h-[36px] px-[10px] rounded-md text-muted-foreground/85 transition-colors hover:bg-accent/20 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring">
+ {zenMode ? : }
-