From c0ca5b42efbf8065d15d3eddbe848422f9bf4ae5 Mon Sep 17 00:00:00 2001 From: Easy Date: Sun, 3 May 2026 15:36:16 +0800 Subject: [PATCH 1/7] fix(db): move D1 schema setup out of request path --- .gitignore | 21 +++- package-lock.json | 8 +- package.json | 2 + scripts/deploy-cloudflare.mjs | 102 +++++++++++----- scripts/reconcile-d1-schema.mjs | 111 ++++++++++++++++++ server/src/index.ts | 1 + .../0008_runtime_schema_baseline.sql | 47 ++++++++ server/src/storage/factory.ts | 6 +- server/wrangler.toml | 2 + 9 files changed, 261 insertions(+), 39 deletions(-) create mode 100644 scripts/reconcile-d1-schema.mjs create mode 100644 server/src/migrations/0008_runtime_schema_baseline.sql diff --git a/.gitignore b/.gitignore index a9f0382..2eaecd5 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,11 @@ node_modules/ # ────────────────────── 构建产出(不推送)────────────────────── dist/ .wrangler/ +.cache/ +.vite/ +coverage/ +playwright-report/ +test-results/ # ────────────────────── 环境变量与密钥(绝不推送)────────────────── # 所有 .env 文件都可能含密钥,禁止推送 @@ -17,6 +22,7 @@ dist/ # Wrangler 本地密钥(含 ADMIN_PASSWORD / JWT_SECRET 等) .dev.vars +.dev.vars.* # ────────────────────── 系统与编辑器临时文件(不推送)────────────── .DS_Store @@ -31,15 +37,15 @@ Thumbs.db *.code-workspace # ────────────────────── AI 工具私有数据(绝不推送)────────────────── -# 通用记忆库(Aider / Cline / 其他) +# 通用记忆库(Aider / Cline / Serena / 其他) .agents/ +.serena/ -# Trae 项目私有数据:memory/ 含密码教训、PAT 用法、踩坑记录等敏感信息 -# rules/ skills/ 也仅供本机女仆使用,不入仓库 -.trae/ +# Codex 项目私有 AI 上下文与本机配置,不入仓库 +.codex/ +scripts/maintain-codex-context.mjs # 各家 AI 编辑器的项目入口与全局配置(含个人偏好/上下文,不入仓库) -CLAUDE.md GEMINI.md AGENTS.md opencode.json @@ -51,6 +57,9 @@ opencode.json # ────────────────────── 日志与调试产物(不推送)────────────────── *.log npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* # ────────────────────── 测试与临时输出(不推送)────────────────── tmp/ @@ -61,6 +70,8 @@ tests/output/ # ✅ package-lock.json — 锁定依赖版本,CI 的 npm ci 依赖它 # ✅ client/functions/ — Pages Functions 反向代理,漏掉则 /api/* 回退为首页 HTML # ✅ scripts/ — 部署脚本 (deploy-cloudflare.mjs) +# ✅ scripts/reconcile-d1-schema.mjs — 部署前 D1 schema 兼容补齐 +# ✅ server/src/migrations/ — D1 正式迁移,生产 schema 入口 # ✅ .github/ — CI/CD workflows、Issue/PR 模板、分支保护 # ✅ .coderabbit.yaml — CodeRabbit 代码审查配置 # ✅ SECURITY.md — 安全政策 diff --git a/package-lock.json b/package-lock.json index cc3d013..6d0137a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "monolith", - "version": "2.3.1", + "version": "2.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "monolith", - "version": "2.3.1", + "version": "2.3.2", "workspaces": [ "client", "server" @@ -20,7 +20,7 @@ }, "client": { "name": "monolith-client", - "version": "2.3.1", + "version": "2.3.2", "dependencies": { "@base-ui/react": "^1.3.0", "@monaco-editor/react": "^4.7.0", @@ -14824,7 +14824,7 @@ }, "server": { "name": "monolith-server", - "version": "2.3.1", + "version": "2.3.2", "dependencies": { "@aws-sdk/client-s3": "^3.1024.0", "@libsql/client": "^0.17.2", diff --git a/package.json b/package.json index c7e88c8..3572255 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "deploy:server": "npm -w monolith-server run deploy", "deploy:client": "node -e \"const {spawnSync}=require('node:child_process'); const result=spawnSync('npx',['wrangler','pages','deploy','dist','--project-name','monolith-client','--branch','main','--commit-dirty=true'],{cwd:'client',stdio:'inherit',shell:process.platform==='win32'}); process.exit(result.status ?? 1);\"", "deploy:cloudflare": "node scripts/deploy-cloudflare.mjs", + "db:reconcile:d1:local": "node scripts/reconcile-d1-schema.mjs --local", + "db:reconcile:d1:remote": "node scripts/reconcile-d1-schema.mjs --remote", "db:migrate:local": "npm -w monolith-server run db:migrate:local", "db:migrate:remote": "npm -w monolith-server run db:migrate:remote", "db:seed:local": "npm -w monolith-server run db:seed:local", diff --git a/scripts/deploy-cloudflare.mjs b/scripts/deploy-cloudflare.mjs index f38df80..c9c8956 100644 --- a/scripts/deploy-cloudflare.mjs +++ b/scripts/deploy-cloudflare.mjs @@ -56,16 +56,17 @@ function parseArgs(argv) { return options; } -function runStep(title, command, args, extra = {}) { - console.log(`\n==> ${title}`); - const result = spawnSync(command, args, { +function runResult(command, args, extra = {}) { + return spawnSync(command, args, { cwd: extra.cwd || projectRoot, - stdio: extra.input ? ["pipe", "inherit", "inherit"] : "inherit", + stdio: extra.stdio || (extra.input ? ["pipe", "inherit", "inherit"] : "inherit"), input: extra.input, encoding: "utf8", shell: SHELL, }); +} +function failStep(title, command, result) { if (result.error) { console.error(`\n[error] 步骤 "${title}" 启动失败:${result.error.message}`); if (result.error.code === "ENOENT") { @@ -75,33 +76,30 @@ function runStep(title, command, args, extra = {}) { process.exit(1); } - if (result.status !== 0) { - const code = result.status === null ? "signal/null" : result.status; - console.error(`\n[error] 步骤 "${title}" 失败 (exit code ${code})。`); - if (result.signal) console.error(`[hint] 子进程被信号中断:${result.signal}`); - process.exit(typeof result.status === "number" ? result.status : 1); + const code = result.status === null ? "signal/null" : result.status; + console.error(`\n[error] 步骤 "${title}" 失败 (exit code ${code})。`); + if (result.signal) console.error(`[hint] 子进程被信号中断:${result.signal}`); + process.exit(typeof result.status === "number" ? result.status : 1); +} + +function runStep(title, command, args, extra = {}) { + console.log(`\n==> ${title}`); + const result = runResult(command, args, extra); + + if (result.error || result.status !== 0) { + failStep(title, command, result); } } function runCapture(title, command, args) { console.log(`\n==> ${title}`); - const result = spawnSync(command, args, { - cwd: projectRoot, - encoding: "utf8", - shell: SHELL, - }); + const result = runResult(command, args, { stdio: "pipe" }); if (result.stdout) process.stdout.write(result.stdout); if (result.stderr) process.stderr.write(result.stderr); - if (result.error) { - console.error(`\n[error] 步骤 "${title}" 启动失败:${result.error.message}`); - process.exit(1); - } - - if (result.status !== 0) { - console.error(`\n[error] 步骤 "${title}" 失败 (exit code ${result.status})。`); - process.exit(result.status || 1); + if (result.error || result.status !== 0) { + failStep(title, command, result); } return `${result.stdout || ""}\n${result.stderr || ""}`; @@ -176,6 +174,28 @@ function resolvePagesEnv(branch) { return branch === "main" ? "production" : "preview"; } +function ensurePagesProject(projectName, branch) { + console.log(`[info] 未发现 Pages 项目 "${projectName}",将自动创建(生产分支:${branch})。`); + runStep( + `创建 Cloudflare Pages 项目 "${projectName}"`, + "npx", + [ + "wrangler", + "pages", + "project", + "create", + projectName, + "--production-branch", + branch, + ], + ); +} + +function shouldCreatePagesProject(result) { + const output = `${result.stdout || ""}\n${result.stderr || ""}`.toLowerCase(); + return output.includes("not found") || output.includes("does not exist") || output.includes("404"); +} + function printPrerequisiteHints() { if (!process.env.CLOUDFLARE_API_TOKEN) { console.warn("[warn] 未检测到 CLOUDFLARE_API_TOKEN,当前依赖本机 wrangler 已登录状态。"); @@ -198,6 +218,11 @@ if (!options.skipMigrate) { ["wrangler", "d1", "migrations", "apply", "monolith-db", "--remote"], { cwd: `${projectRoot}/server`, input: "y\n" }, ); + runStep( + "补齐远程 D1 schema 兼容列", + "node", + ["scripts/reconcile-d1-schema.mjs", "--remote"], + ); } if (!options.skipServer) { @@ -226,12 +251,33 @@ if (!options.skipClient) { const pagesEnv = resolvePagesEnv(options.branch); - runStep( - "写入 Cloudflare Pages 的 API_BASE", - "npx", - ["wrangler", "pages", "secret", "put", "API_BASE", "--project-name", options.pagesProject, "--env", pagesEnv], - { input: `${options.apiBase}\n` } - ); + const pagesSecretArgs = [ + "wrangler", + "pages", + "secret", + "put", + "API_BASE", + "--project-name", + options.pagesProject, + "--env", + pagesEnv, + ]; + console.log(`\n==> 写入 Cloudflare Pages 的 API_BASE`); + let pagesSecret = runResult("npx", pagesSecretArgs, { input: `${options.apiBase}\n` }); + + if (pagesSecret.error || pagesSecret.status !== 0) { + if (!shouldCreatePagesProject(pagesSecret)) { + failStep("写入 Cloudflare Pages 的 API_BASE", "npx", pagesSecret); + } + console.warn("[warn] Pages 项目不存在,创建后重试 API_BASE 写入。"); + ensurePagesProject(options.pagesProject, options.branch); + console.log(`\n==> 重试写入 Cloudflare Pages 的 API_BASE`); + pagesSecret = runResult("npx", pagesSecretArgs, { input: `${options.apiBase}\n` }); + } + + if (pagesSecret.error || pagesSecret.status !== 0) { + failStep("写入 Cloudflare Pages 的 API_BASE", "npx", pagesSecret); + } runStep("构建前端", "npm", ["run", "build"]); runStep("部署 Cloudflare Pages 前端", "npx", [ diff --git a/scripts/reconcile-d1-schema.mjs b/scripts/reconcile-d1-schema.mjs new file mode 100644 index 0000000..3075d0c --- /dev/null +++ b/scripts/reconcile-d1-schema.mjs @@ -0,0 +1,111 @@ +import { spawnSync } from "node:child_process"; +import process from "node:process"; + +const projectRoot = process.cwd(); +const serverRoot = `${projectRoot}/server`; +const IS_WIN = process.platform === "win32"; +const SHELL = IS_WIN; + +function parseArgs(argv) { + const options = { + mode: "local", + database: "monolith-db", + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + + if (arg === "--remote") { + options.mode = "remote"; + continue; + } + + if (arg === "--local") { + options.mode = "local"; + continue; + } + + if (arg === "--database") { + options.database = argv[i + 1] || options.database; + i += 1; + } + } + + return options; +} + +function runWrangler(args, title) { + const result = spawnSync("npx", ["wrangler", ...args], { + cwd: serverRoot, + encoding: "utf8", + shell: SHELL, + }); + + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + + if (result.error || result.status !== 0) { + const detail = result.error ? result.error.message : `exit code ${result.status}`; + console.error(`\n[error] ${title} 失败:${detail}`); + process.exit(typeof result.status === "number" ? result.status : 1); + } + + return `${result.stdout || ""}\n${result.stderr || ""}`; +} + +function d1Scope(mode) { + return mode === "remote" ? "--remote" : "--local"; +} + +function queryPostsColumns(options) { + const output = runWrangler( + [ + "d1", + "execute", + options.database, + d1Scope(options.mode), + "--command", + "SELECT name FROM pragma_table_info('posts');", + ], + "读取 posts 表结构", + ); + + return new Set(output.match(/[A-Za-z_][A-Za-z0-9_]*/g) || []); +} + +function addColumn(options, column) { + runWrangler( + [ + "d1", + "execute", + options.database, + d1Scope(options.mode), + "--command", + `ALTER TABLE posts ADD COLUMN ${column.sql};`, + ], + `补全 posts.${column.name}`, + ); +} + +const POST_COLUMNS = [ + { name: "series_slug", sql: "series_slug TEXT" }, + { name: "series_order", sql: "series_order INTEGER NOT NULL DEFAULT 0" }, + { name: "category", sql: "category TEXT DEFAULT ''" }, +]; + +const options = parseArgs(process.argv.slice(2)); +console.log(`[info] D1 schema reconcile: ${options.database} (${options.mode})`); + +const columns = queryPostsColumns(options); +const missing = POST_COLUMNS.filter((column) => !columns.has(column.name)); + +if (missing.length === 0) { + console.log("[ok] posts 表列已完整,无需补全。"); + process.exit(0); +} + +for (const column of missing) { + addColumn(options, column); +} + +console.log(`[ok] 已补全 posts 表列:${missing.map((column) => column.name).join(", ")}`); diff --git a/server/src/index.ts b/server/src/index.ts index 94f6265..3c7ea7c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -22,6 +22,7 @@ type Bindings = { JWT_SECRET: string; REACTION_SALT?: string; DB_PROVIDER?: string; + AUTO_SCHEMA_MIGRATION?: string; STORAGE_PROVIDER?: string; WEBHOOK_URLS?: string; // 逗号分隔的 Webhook 目标地址 SITE_ORIGIN?: string; // 对外公开域名(如 https://monolith-client.pages.dev),用于 sitemap/robots diff --git a/server/src/migrations/0008_runtime_schema_baseline.sql b/server/src/migrations/0008_runtime_schema_baseline.sql new file mode 100644 index 0000000..2034b50 --- /dev/null +++ b/server/src/migrations/0008_runtime_schema_baseline.sql @@ -0,0 +1,47 @@ +-- 0008: 将过去运行时自动补全的表结构纳入正式迁移 +-- 列级补全由 scripts/reconcile-d1-schema.mjs 负责,避免生产库字段已存在时 ALTER 失败。 + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS pages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + sort_order INTEGER NOT NULL DEFAULT 0, + published INTEGER NOT NULL DEFAULT 1, + show_in_nav INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + author_name TEXT NOT NULL, + author_email TEXT NOT NULL DEFAULT '', + content TEXT NOT NULL, + approved INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS reactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_slug TEXT NOT NULL REFERENCES posts(slug) ON DELETE CASCADE, + type TEXT NOT NULL, + ip_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(post_slug, type, ip_hash) +); + +CREATE TABLE IF NOT EXISTS visits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT NOT NULL, + country TEXT NOT NULL DEFAULT 'XX', + referer_domain TEXT NOT NULL DEFAULT '', + device_type TEXT NOT NULL DEFAULT 'desktop', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/server/src/storage/factory.ts b/server/src/storage/factory.ts index 858324d..3567196 100644 --- a/server/src/storage/factory.ts +++ b/server/src/storage/factory.ts @@ -19,12 +19,15 @@ import { S3Adapter } from "./object/s3"; */ export async function createDatabase(env: Record): Promise { const provider = (env.DB_PROVIDER as string) || "d1"; + const autoSchemaMigration = env.AUTO_SCHEMA_MIGRATION === "true"; switch (provider) { case "d1": { if (!env.DB) throw new Error("缺少 D1 数据库绑定 (env.DB)"); const d1Adapter = new D1Adapter(env.DB as D1Database); - await d1Adapter.ensureSchema(); + if (autoSchemaMigration) { + await d1Adapter.ensureSchema(); + } return d1Adapter; } @@ -106,4 +109,3 @@ export function createObjectStorage(env: Record): IObjectStorag throw new Error(`不支持的存储提供者: ${provider}。可选值: r2, s3`); } } - diff --git a/server/wrangler.toml b/server/wrangler.toml index d2807f3..a31c274 100644 --- a/server/wrangler.toml +++ b/server/wrangler.toml @@ -8,6 +8,8 @@ compatibility_flags = ["nodejs_compat"] BLOG_NAME = "Monolith" # 对外公开域名(用于 sitemap.xml / robots.txt 的绝对 URL) SITE_ORIGIN = "https://monolith-client.pages.dev" +# 运行时默认不做建表/补列;部署流程负责迁移与 schema reconcile。 +AUTO_SCHEMA_MIGRATION = "false" # D1 数据库绑定 [[d1_databases]] From 40fddfa557b3d736b891e9ce7968e3c5699eee5b Mon Sep 17 00:00:00 2001 From: Easy Date: Sun, 3 May 2026 17:03:20 +0800 Subject: [PATCH 2/7] fix(auth): keep password manager icons aligned --- client/src/components/admin-gate.tsx | 2 +- client/src/globals.css | 5 ----- client/src/pages/admin/login.tsx | 2 +- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/client/src/components/admin-gate.tsx b/client/src/components/admin-gate.tsx index 166fbd3..91fb97c 100644 --- a/client/src/components/admin-gate.tsx +++ b/client/src/components/admin-gate.tsx @@ -127,7 +127,7 @@ export function AdminGate({ placeholder="输入密码" autoComplete="current-password" aria-label="管理员密码" - className="h-[40px] w-full rounded-lg border border-border/40 bg-background/50 px-[14px] text-[14px] text-foreground placeholder:text-muted-foreground/30 outline-none focus:border-foreground/25 focus:ring-1 focus:ring-foreground/10 transition-all" + className="h-[40px] w-full rounded-lg border border-border/40 bg-background/50 px-[14px] pr-[48px] text-[14px] text-foreground placeholder:text-muted-foreground/30 outline-none focus:border-foreground/25 focus:ring-1 focus:ring-foreground/10 transition-all" /> {error && ( diff --git a/client/src/globals.css b/client/src/globals.css index 50e81e6..25ff29f 100644 --- a/client/src/globals.css +++ b/client/src/globals.css @@ -132,11 +132,6 @@ * { @apply border-border; } - html { - @media (min-width: 1024px) { - zoom: 1.15; - } - } *:focus-visible { @apply outline-ring/50; } diff --git a/client/src/pages/admin/login.tsx b/client/src/pages/admin/login.tsx index 18cee40..36af0e2 100644 --- a/client/src/pages/admin/login.tsx +++ b/client/src/pages/admin/login.tsx @@ -57,7 +57,7 @@ export function AdminLogin() { autoComplete="current-password" aria-label="管理员密码" autoFocus - className="h-[40px] rounded-md border border-border/60 bg-background/50 px-[12px] text-[14px] text-foreground placeholder:text-muted-foreground/40 outline-none focus:border-foreground/30 transition-colors" + className="h-[40px] rounded-md border border-border/60 bg-background/50 px-[12px] pr-[48px] text-[14px] text-foreground placeholder:text-muted-foreground/40 outline-none focus:border-foreground/30 transition-colors" /> {error &&

{error}

} + + +
{checking ? ( -
- 验证中... +
+
+ 正在检查登录状态
) : (
-
-
- 🔐 +
+
+
+ +
+
+

进入管理后台

+

+ 输入本地或生产环境配置的管理密码,验证通过后进入内容控制台。 +

+
+
+
+ 内容 + 媒体 + SEO
-

- 管理员验证 -

{/* 隐藏 username 字段:让 Bitwarden / 1Password / Chrome 等密码管理器识别为登录表单 */} @@ -127,11 +160,11 @@ export function AdminGate({ placeholder="输入密码" autoComplete="current-password" aria-label="管理员密码" - className="h-[40px] w-full rounded-lg border border-border/40 bg-background/50 px-[14px] pr-[48px] text-[14px] text-foreground placeholder:text-muted-foreground/30 outline-none focus:border-foreground/25 focus:ring-1 focus:ring-foreground/10 transition-all" + className="h-[46px] w-full rounded-md border border-border/45 bg-background/55 px-[14px] pr-[48px] text-[15px] text-foreground outline-none transition-all placeholder:text-muted-foreground/35 focus:border-cyan-400/45 focus:ring-1 focus:ring-cyan-400/20" /> {error && ( -

+

{error}

)} @@ -139,16 +172,18 @@ export function AdminGate({ -

- 按 ESC 关闭 +

+ ESC 关闭 · Ctrl Shift A 呼出

)} +
diff --git a/client/src/components/admin-layout.tsx b/client/src/components/admin-layout.tsx index cdaa976..b772148 100644 --- a/client/src/components/admin-layout.tsx +++ b/client/src/components/admin-layout.tsx @@ -13,6 +13,7 @@ import { LogOut, ExternalLink, Menu, + Search, } from "lucide-react"; import { ThemeToggle } from "@/components/theme-toggle"; @@ -64,9 +65,14 @@ export function AdminLayout({ children }: AdminLayoutProps) { }, ]; + const currentTitle = + navGroups.flatMap((group) => group.items).find((item) => + item.href === "/admin" ? location === "/admin" : location.startsWith(item.href) + )?.label || "管理后台"; + const SidebarFooter = () => ( -
-
+
+
主题
@@ -74,14 +80,14 @@ export function AdminLayout({ children }: AdminLayoutProps) { href="/" target="_blank" rel="noopener noreferrer" - className="flex items-center gap-[10px] px-[12px] py-[8px] rounded-md text-[13px] font-medium text-muted-foreground/60 hover:bg-muted/50 hover:text-foreground transition-colors" + className="flex min-h-[44px] items-center gap-[10px] rounded-md px-[12px] py-[8px] text-[13px] font-medium text-muted-foreground/60 transition-colors hover:bg-muted/40 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring" > 查看站点
+
+
+

Monolith 管理后台

+

{currentTitle}

+
+ +
{children}
); -} \ No newline at end of file +} diff --git a/client/src/components/article-card.tsx b/client/src/components/article-card.tsx index d14362c..be48fa4 100644 --- a/client/src/components/article-card.tsx +++ b/client/src/components/article-card.tsx @@ -1,6 +1,7 @@ import { Link } from "wouter"; import { Badge } from "@/components/ui/badge"; import type { PostMeta } from "@/lib/api"; +import { ArrowRight, Pin } from "lucide-react"; function formatDate(dateStr: string): string { const date = new Date(dateStr); @@ -19,12 +20,15 @@ export function ArticleCard({ post }: { post: PostMeta }) { const gradient = post.coverColor || "from-gray-500/20 to-gray-600/20"; return ( - -
+ +
{/* 封面区 */} -
-
+
+
{cover ? ( {/* 内容区 */} -
+
{post.pinned && ( - 📌 + + + 置顶 + )} {post.tags.slice(0, 2).map((tag) => ( - {tag} + {tag} ))} {formatDate(post.createdAt)}
-

+

{post.title}

{post.excerpt}

-
+
阅读全文 - - - +
diff --git a/client/src/components/cookie-consent.tsx b/client/src/components/cookie-consent.tsx index 4534f82..01c9196 100644 --- a/client/src/components/cookie-consent.tsx +++ b/client/src/components/cookie-consent.tsx @@ -40,21 +40,21 @@ export function CookieConsent() { return (
-
+
本站使用 Cookie 进行访问统计与第三方脚本加载。继续访问即表示您同意我们的{" "} 隐私政策
-
+
@@ -62,4 +62,4 @@ export function CookieConsent() {
); -} \ No newline at end of file +} diff --git a/client/src/components/hero.tsx b/client/src/components/hero.tsx index 7642275..ebccf6d 100644 --- a/client/src/components/hero.tsx +++ b/client/src/components/hero.tsx @@ -1,20 +1,23 @@ export function Hero() { return ( -
+
-
+
-

Monolith

+

+ Monolith +

书写代码、设计与边缘计算的个人博客。
- 在秩序与混沌的交界处,寻找属于自己的巨石碑。 + + 在秩序与混沌的交界处,寻找属于自己的巨石碑。 +

-
); } diff --git a/client/src/components/navbar.tsx b/client/src/components/navbar.tsx index 1073fb4..b048a1d 100644 --- a/client/src/components/navbar.tsx +++ b/client/src/components/navbar.tsx @@ -55,7 +55,7 @@ export function Navbar() { return ( <> -
+
- + @@ -126,4 +129,4 @@ export function Navbar() { )} ); -} \ No newline at end of file +} diff --git a/client/src/components/reading-controls.tsx b/client/src/components/reading-controls.tsx index 7d80e88..e73a04c 100644 --- a/client/src/components/reading-controls.tsx +++ b/client/src/components/reading-controls.tsx @@ -86,12 +86,13 @@ export function ReadingControls({ > {/* 选项面板 (Popover) */} {isOpen && ( -
+
- 阅读偏好 + 阅读偏好 @@ -104,22 +105,22 @@ export function ReadingControls({
@@ -131,13 +132,13 @@ export function ReadingControls({
@@ -149,22 +150,22 @@ export function ReadingControls({ 排版 (A: {preferences.fontSize} / H: {preferences.lineHeight.toFixed(1)})
{/* 字号 */} -
- -
{/* 行距 */} -
- Hgt -
@@ -174,12 +175,12 @@ export function ReadingControls({ {/* 专注宽度 */}
阅读区宽度 -
- {preferences.maxWidth}px -
diff --git a/client/src/components/search.tsx b/client/src/components/search.tsx index 8d34dc9..d5a0767 100644 --- a/client/src/components/search.tsx +++ b/client/src/components/search.tsx @@ -155,7 +155,7 @@ export function SearchOverlay() { return (
setOpen(false)} > {/* 背景遮罩 */} @@ -163,11 +163,14 @@ export function SearchOverlay() { {/* 搜索面板 */}
e.stopPropagation()} > {/* 搜索输入框 */} -
+
handleInputChange(e.target.value)} onKeyDown={handleKeyDown} - className="flex-1 bg-transparent text-[15px] text-foreground placeholder:text-muted-foreground/60 outline-none" + className="min-h-[44px] flex-1 bg-transparent text-[16px] text-foreground outline-none placeholder:text-muted-foreground/60 sm:text-[15px]" /> {loading && }
{/* 搜索结果 */} -
+
{query.trim() && !loading && results.length === 0 && (
@@ -202,13 +206,13 @@ export function SearchOverlay() { key={result.slug} href={`/posts/${result.slug}`} onClick={() => setOpen(false)} - className={`flex items-start gap-[12px] px-[20px] py-[14px] transition-colors duration-150 cursor-pointer ${ + className={`flex min-h-[72px] cursor-pointer items-start gap-[12px] px-[14px] py-[14px] transition-colors duration-150 sm:px-[20px] ${ index === selectedIndex ? "bg-accent/60" : "hover:bg-accent/30" }`} > -
+
@@ -238,7 +242,7 @@ export function SearchOverlay() {
{/* 底部提示 */} -
+
↑↓ diff --git a/client/src/components/theme-toggle.tsx b/client/src/components/theme-toggle.tsx index 1172b94..7be9016 100644 --- a/client/src/components/theme-toggle.tsx +++ b/client/src/components/theme-toggle.tsx @@ -54,7 +54,7 @@ export function ThemeToggle() { ))} -
+
- 浏览量 + 浏览量

{viewStats?.totalViews?.toLocaleString() ?? "—"}

@@ -167,7 +173,7 @@ export function AdminDashboard() { setSearch(e.target.value)} placeholder="搜索标题、Slug 或标签..." - className="h-[38px] w-full rounded-lg border border-border/20 bg-card/8 pl-[36px] pr-[14px] text-[13px] text-foreground placeholder:text-muted-foreground/25 outline-none focus:border-foreground/15 transition-all" + 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" />
@@ -178,7 +184,7 @@ export function AdminDashboard() { {/* ─── 文章列表 ─── */}
-

+

{filter === "all" ? "所有文章" : filter === "published" ? "已发布" : "草稿箱"} {selectedTag && <>·{selectedTag}}

@@ -187,9 +193,9 @@ export function AdminDashboard() { {/* 批量操作工具栏 */} {filteredPosts.length > 0 && ( -
0 ? "border-cyan-500/30 bg-cyan-500/5" : ""}`}> +
0 ? "border-cyan-500/30 bg-cyan-500/5" : ""}`}>
- @@ -197,13 +203,13 @@ export function AdminDashboard() { {selectedSlugs.size > 0 && (
- - -
@@ -229,10 +235,10 @@ export function AdminDashboard() {
{filteredPosts.map((post) => ( -
+
{/* 复选框 */} - @@ -241,7 +247,7 @@ export function AdminDashboard() {
- {post.title} + {post.title} {post.pinned && 置顶}
@@ -252,14 +258,14 @@ export function AdminDashboard() {
{/* 操作按钮 — hover 显现 */} -
- +
+ - + -
@@ -297,9 +303,9 @@ export function AdminDashboard() { const scoreBorder = score >= 90 ? "border-emerald-500/20" : score >= 70 ? "border-amber-500/20" : "border-red-500/20"; return ( -
+
-

+

SEO 健康

{score}% @@ -340,7 +346,7 @@ export function AdminDashboard() { {allTags.length > 0 && (
-

标签

+

标签

{allTags.length > 8 && ( diff --git a/client/src/pages/admin/settings.tsx b/client/src/pages/admin/settings.tsx index 0c05223..2adc00a 100644 --- a/client/src/pages/admin/settings.tsx +++ b/client/src/pages/admin/settings.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from "react"; import { getToken } from "@/lib/api"; -import { Save, Globe, User, Link2, ToggleLeft, ToggleRight, Code, Rss } from "lucide-react"; +import { Save, Globe, User, Link2, ToggleLeft, ToggleRight, Code, Rss, Plus, Trash2, GripVertical } from "lucide-react"; type Settings = { site_title: string; @@ -13,6 +13,7 @@ type Settings = { github_url: string; twitter_url: string; email: string; + social_links: string; footer_text: string; rss_enabled: string; custom_header: string; @@ -30,6 +31,7 @@ const defaultSettings: Settings = { github_url: "", twitter_url: "", email: "", + social_links: "", footer_text: "© 2026 Monolith. 使用 Hono + Vite 构建,部署于 Cloudflare 边缘。", rss_enabled: "true", custom_header: "", @@ -46,6 +48,93 @@ const TABS: TabDefinition[] = [ { id: "advanced", label: "扩展与注入", icon: Code }, ]; +type SocialIcon = "github" | "x" | "mail" | "rss" | "link"; + +type SocialLinkConfig = { + id: string; + label: string; + url: string; + icon: SocialIcon; + enabled: boolean; +}; + +const SOCIAL_ICON_OPTIONS: { value: SocialIcon; label: string }[] = [ + { value: "link", label: "链接" }, + { value: "github", label: "GitHub" }, + { value: "x", label: "X" }, + { value: "mail", label: "邮箱" }, + { value: "rss", label: "RSS" }, +]; + +function createSocialLink(link: Partial = {}): SocialLinkConfig { + return { + id: link.id || (typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `social-${Date.now()}`), + label: link.label || "", + url: link.url || "", + icon: link.icon || "link", + enabled: link.enabled ?? true, + }; +} + +function isSocialIcon(value: unknown): value is SocialIcon { + return typeof value === "string" && SOCIAL_ICON_OPTIONS.some((option) => option.value === value); +} + +function parseSocialLinks(value: string): SocialLinkConfig[] { + if (!value.trim()) return []; + try { + const parsed: unknown = JSON.parse(value); + if (!Array.isArray(parsed)) return []; + + return parsed + .filter((item): item is Record => typeof item === "object" && item !== null) + .map((item) => createSocialLink({ + id: typeof item.id === "string" ? item.id : undefined, + label: typeof item.label === "string" ? item.label : "", + url: typeof item.url === "string" ? item.url : "", + icon: isSocialIcon(item.icon) ? item.icon : "link", + enabled: typeof item.enabled === "boolean" ? item.enabled : true, + })); + } catch { + return []; + } +} + +function getLegacySocialLinks(settings: Settings): SocialLinkConfig[] { + const links: SocialLinkConfig[] = []; + if (settings.github_url) links.push(createSocialLink({ id: "legacy-github", label: "GitHub", url: settings.github_url, icon: "github" })); + if (settings.twitter_url) links.push(createSocialLink({ id: "legacy-x", label: "X", url: settings.twitter_url, icon: "x" })); + if (settings.email) links.push(createSocialLink({ id: "legacy-email", label: "邮箱", url: settings.email, icon: "mail" })); + return links; +} + +function getSocialLinks(settings: Settings): SocialLinkConfig[] { + return settings.social_links.trim() ? parseSocialLinks(settings.social_links) : getLegacySocialLinks(settings); +} + +function serializeSocialLinks(links: SocialLinkConfig[]) { + return JSON.stringify(links.map((link) => ({ + id: link.id, + label: link.label.trim(), + url: link.url.trim(), + icon: link.icon, + enabled: link.enabled, + }))); +} + +function toLegacySocialFields(links: SocialLinkConfig[]) { + const enabledLinks = links.filter((link) => link.enabled); + const github = enabledLinks.find((link) => link.icon === "github"); + const x = enabledLinks.find((link) => link.icon === "x"); + const email = enabledLinks.find((link) => link.icon === "mail"); + + return { + github_url: github?.url.trim() || "", + twitter_url: x?.url.trim() || "", + email: email?.url.trim().replace(/^mailto:/i, "") || "", + }; +} + export function AdminSettings() { const [settings, setSettings] = useState(defaultSettings); const [saving, setSaving] = useState(false); @@ -89,6 +178,12 @@ export function AdminSettings() { const handleSave = async () => { setSaving(true); + const socialLinks = getSocialLinks(settings); + const nextSettings = { + ...settings, + ...toLegacySocialFields(socialLinks), + social_links: serializeSocialLinks(socialLinks), + }; try { const res = await fetch("/api/admin/settings", { method: "PUT", @@ -96,9 +191,10 @@ export function AdminSettings() { "Content-Type": "application/json", Authorization: `Bearer ${getToken()}`, }, - body: JSON.stringify(settings), + body: JSON.stringify(nextSettings), }); if (!res.ok) throw new Error("保存失败"); + setSettings(nextSettings); showMsg("设置已保存", "success"); } catch { showMsg("保存失败", "error"); @@ -116,6 +212,23 @@ export function AdminSettings() { }, [settings.author_avatar]); const rssEnabled = settings.rss_enabled !== "false"; + const socialLinks = getSocialLinks(settings); + + const updateSocialLinks = (links: SocialLinkConfig[]) => { + setSettings((prev) => ({ ...prev, social_links: serializeSocialLinks(links) })); + }; + + const updateSocialLink = (id: string, patch: Partial) => { + updateSocialLinks(socialLinks.map((link) => link.id === id ? { ...link, ...patch } : link)); + }; + + const addSocialLink = () => { + updateSocialLinks([...socialLinks, createSocialLink({ label: "新链接", icon: "link" })]); + }; + + const removeSocialLink = (id: string) => { + updateSocialLinks(socialLinks.filter((link) => link.id !== id)); + }; if (loading) return
加载中...
; @@ -244,15 +357,90 @@ export function AdminSettings() { {activeTab === "social" && (
-

社交网络

-

提供连接外部平台与流量留存的入口。

-
- updateSetting("github_url", v)} placeholder="https://github.com/username" /> - updateSetting("twitter_url", v)} placeholder="https://x.com/username" /> - updateSetting("email", v)} placeholder="you@example.com" /> -
-
- 填入有效链接后将自动在首页侧边卡片中展示对应图标。 +
+
+

友链与社交入口

+

按需添加任意平台链接,启用后会展示在首页博主名片中。

+
+ +
+
+ {socialLinks.length > 0 ? ( +
+ {socialLinks.map((link) => ( +
+
+ +
+ + + + + +
+ ))} +
+ ) : ( +
+

还没有配置链接

+

添加 GitHub、邮箱、项目页或任意友链入口。

+
+ )} +
+
+ 旧版 GitHub、X、邮箱字段会自动迁移为列表项,保存后继续兼容旧接口。
diff --git a/client/src/pages/archive.tsx b/client/src/pages/archive.tsx index 08600ac..7959627 100644 --- a/client/src/pages/archive.tsx +++ b/client/src/pages/archive.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { Link } from "wouter"; +import { Link, useLocation } from "wouter"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; import { fetchPosts, type PostMeta } from "@/lib/api"; @@ -11,6 +11,7 @@ function formatDate(dateStr: string): string { } export function ArchivePage() { + const [location] = useLocation(); const [posts, setPosts] = useState([]); const [loading, setLoading] = useState(true); @@ -18,32 +19,54 @@ export function ArchivePage() { fetchPosts().then(setPosts).catch(console.error).finally(() => setLoading(false)); }, []); + const queryString = location.includes("?") ? location.split("?")[1] : (typeof window !== "undefined" ? window.location.search.slice(1) : ""); + const selectedCategory = new URLSearchParams(queryString).get("category") || ""; + const visiblePosts = selectedCategory ? posts.filter((post) => post.category === selectedCategory) : posts; + const grouped = new Map(); - for (const post of posts) { + for (const post of visiblePosts) { const year = new Date(post.createdAt).getFullYear().toString(); if (!grouped.has(year)) grouped.set(year, []); grouped.get(year)!.push(post); } const years = Array.from(grouped.keys()).sort((a, b) => Number(b) - Number(a)); + const archiveTitle = selectedCategory ? `分类:${selectedCategory}` : "归档"; + const archiveDescription = selectedCategory + ? `共 ${visiblePosts.length} 篇 ${selectedCategory} 分类文章,按时间倒序排列。` + : `共 ${posts.length} 篇文章,按时间倒序排列。`; return ( -
- +
+
-

归档

-

共 {posts.length} 篇文章,按时间倒序排列。

+

{archiveTitle}

+
+

{archiveDescription}

+ {selectedCategory && ( + + 查看全部 + + )} +
- + {loading ? (
{[1, 2, 3, 4].map((i) =>
)}
+ ) : visiblePosts.length === 0 ? ( +
+

没有找到匹配文章

+

+ 当前分类下暂无可见文章,可以返回全部归档继续浏览。 +

+
) : ( years.map((year, yi) => (

{year}

-
+
{grouped.get(year)!.map((post) => ( - + {formatDate(post.createdAt).replace(/\d{4}年/, "")} {post.title}
diff --git a/client/src/pages/home.tsx b/client/src/pages/home.tsx index 5df4a41..9a6e17e 100644 --- a/client/src/pages/home.tsx +++ b/client/src/pages/home.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from "react"; +import { Link } from "wouter"; import { Hero } from "@/components/hero"; import { ArticleCard } from "@/components/article-card"; @@ -6,7 +7,7 @@ import { Separator } from "@/components/ui/separator"; import { fetchPosts, fetchCategories, type PostMeta, type CategoryInfo } from "@/lib/api"; import { AnimateIn } from "@/hooks/use-animate"; import { SeoHead } from "@/components/seo-head"; -import { ExternalLink, Mail, Rss, Eye, FolderOpen, Hash, ChevronDown } from "lucide-react"; +import { ExternalLink, Mail, Rss, Eye, FolderOpen, Hash, ChevronDown, Link2 } from "lucide-react"; type PublicSettings = { site_title: string; @@ -19,6 +20,7 @@ type PublicSettings = { github_url: string; twitter_url: string; email: string; + social_links: string; rss_enabled: string; }; @@ -28,15 +30,91 @@ type TrafficData = { chart: { date: string; count: number }[]; }; +type SocialIcon = "github" | "x" | "mail" | "rss" | "link"; + +type SocialLinkConfig = { + id: string; + label: string; + url: string; + icon: SocialIcon; + enabled: boolean; +}; + +const SOCIAL_ICON_MAP: Record = { + github: ExternalLink, + x: ExternalLink, + mail: Mail, + rss: Rss, + link: Link2, +}; + +function isSocialIcon(value: unknown): value is SocialIcon { + return typeof value === "string" && ["github", "x", "mail", "rss", "link"].includes(value); +} + +function parseSocialLinks(value: string): SocialLinkConfig[] { + if (!value.trim()) return []; + try { + const parsed: unknown = JSON.parse(value); + if (!Array.isArray(parsed)) return []; + + return parsed + .filter((item): item is Record => typeof item === "object" && item !== null) + .map((item, index) => ({ + id: typeof item.id === "string" ? item.id : `social-${index}`, + label: typeof item.label === "string" ? item.label : "", + url: typeof item.url === "string" ? item.url : "", + icon: isSocialIcon(item.icon) ? item.icon : "link", + enabled: typeof item.enabled === "boolean" ? item.enabled : true, + })) + .filter((link) => link.enabled && link.label.trim() && link.url.trim()); + } catch { + return []; + } +} + +function normalizeSocialHref(link: SocialLinkConfig) { + const url = link.url.trim(); + if (link.icon === "mail" && !url.startsWith("mailto:")) return `mailto:${url}`; + if (link.icon === "rss" && !url) return "/rss.xml"; + return url; +} + +function getPublicSocialLinks(settings: PublicSettings | null): { icon: React.ElementType; href: string; label: string }[] { + if (!settings) return []; + + const configuredLinks = settings.social_links.trim() ? parseSocialLinks(settings.social_links) : []; + const legacyLinks: SocialLinkConfig[] = []; + if (settings.github_url) legacyLinks.push({ id: "legacy-github", label: "GitHub", url: settings.github_url, icon: "github", enabled: true }); + if (settings.twitter_url) legacyLinks.push({ id: "legacy-x", label: "X", url: settings.twitter_url, icon: "x", enabled: true }); + if (settings.email) legacyLinks.push({ id: "legacy-email", label: "邮箱", url: settings.email, icon: "mail", enabled: true }); + + const sourceLinks = configuredLinks.length > 0 || settings.social_links.trim() ? configuredLinks : legacyLinks; + + const links = sourceLinks.map((link) => ({ + icon: SOCIAL_ICON_MAP[link.icon] || ExternalLink, + href: normalizeSocialHref(link), + label: link.label.trim(), + })); + + if (links.length > 0 && settings.rss_enabled !== "false" && !links.some((link) => link.href === "/rss.xml")) { + links.push({ icon: Rss, href: "/rss.xml", label: "RSS" }); + } + + return links; +} + /* ── 紧凑标签云 ── */ const TAG_VISIBLE = 15; +const CATEGORY_VISIBLE = 5; + function TagCloud({ tags, maxCount }: { tags: [string, number][]; maxCount: number }) { const [expanded, setExpanded] = useState(false); const hasMore = tags.length > TAG_VISIBLE; const visible = expanded ? tags : tags.slice(0, TAG_VISIBLE); return ( -
-

+
+

标签 {tags.length} @@ -45,13 +123,13 @@ function TagCloud({ tags, maxCount }: { tags: [string, number][]; maxCount: numb {visible.map(([tag, count]) => { // 频率归一化 0~1 映射透明度与字号 const ratio = maxCount > 1 ? (count - 1) / (maxCount - 1) : 0; - const opacity = 0.35 + ratio * 0.55; // 0.35 ~ 0.90 + const weight = 42 + ratio * 44; // 42% ~ 86% const size = 11 + ratio * 3; // 11px ~ 14px return ( {tag} @@ -62,7 +140,7 @@ function TagCloud({ tags, maxCount }: { tags: [string, number][]; maxCount: numb {hasMore && !expanded && ( @@ -71,6 +149,45 @@ function TagCloud({ tags, maxCount }: { tags: [string, number][]; maxCount: numb ); } +function CategoryList({ categories }: { categories: CategoryInfo[] }) { + const [expanded, setExpanded] = useState(false); + const hasMore = categories.length > CATEGORY_VISIBLE; + const visibleCategories = expanded ? categories : categories.slice(0, CATEGORY_VISIBLE); + + return ( +
+

+ + 分类 + {categories.length} +

+
8 ? "max-h-[280px] overflow-y-auto pr-[4px]" : ""}`}> + {visibleCategories.map((cat) => ( + + {cat.name} + {cat.count} + + ))} +
+ {hasMore && ( + + )} +
+ ); +} + /* ── 纯 SVG 迷你折线图 ── */ function SparkLine({ data, width = 240, height = 48 }: { data: number[]; width?: number; height?: number }) { const gradId = `sparkGrad-${React.useId().replace(/:/g, "")}`; @@ -144,21 +261,25 @@ export function HomePage() { const authorBio = settings?.author_bio || "热衷于前端架构、设计系统与边缘计算。相信技术应当服务于人,而非反过来。"; const authorAvatar = settings?.author_avatar || ""; - // 社交链接(只在有实质性社交信息时显示此行) - const socialLinks: { icon: React.ElementType; href: string; label: string }[] = []; - if (settings?.github_url) socialLinks.push({ icon: ExternalLink, href: settings.github_url, label: "GitHub" }); - if (settings?.email) socialLinks.push({ icon: Mail, href: `mailto:${settings.email}`, label: "邮箱" }); - if (socialLinks.length > 0 && settings?.rss_enabled !== "false") socialLinks.push({ icon: Rss, href: "/rss.xml", label: "RSS" }); + // 社交链接(优先读取新版可扩展列表,旧字段作为兼容回退) + const socialLinks = getPublicSocialLinks(settings); return (
- -
+
-

最新文章

+
+
+

Latest Posts

+

最新文章

+
+ {!loading && ( + {posts.length} 篇可读内容 + )} +
{loading ? (
@@ -168,20 +289,29 @@ export function HomePage() {
) : (
- {posts.map((post, i) => ( - - - - ))} + {posts.length > 0 ? ( + posts.map((post, i) => ( + + + + )) + ) : ( +
+

还没有发布文章

+

+ 本地数据库初始化后,最新文章会直接出现在这里。 +

+
+ )}
)}
-

-
-
-

Monolith 管理后台

-

{currentTitle}

+
+
+ Monolith 管理后台 + / + {currentTitle}
-
-
+
+
{/* 封面区 */} -
-
+
+
{cover ? ( ) : ( -
- +
+ {getInitial(post.title)}
@@ -49,7 +49,7 @@ export function ArticleCard({ post }: { post: PostMeta }) { {/* 内容区 */}
-
+
{post.pinned && ( @@ -59,17 +59,26 @@ export function ArticleCard({ post }: { post: PostMeta }) { {post.tags.slice(0, 2).map((tag) => ( {tag} ))} - {formatDate(post.createdAt)} + + + {formatDate(post.createdAt)} +
-

+

{post.title}

-

+

{post.excerpt}

-
- 阅读全文 - +
+ + + {post.category || "未分类"} + + + 阅读全文 + +
diff --git a/client/src/components/cookie-consent.tsx b/client/src/components/cookie-consent.tsx index 01c9196..89131e9 100644 --- a/client/src/components/cookie-consent.tsx +++ b/client/src/components/cookie-consent.tsx @@ -41,8 +41,14 @@ export function CookieConsent() { return (
-
- 本站使用 Cookie 进行访问统计与第三方脚本加载。继续访问即表示您同意我们的{" "} +
+ + 本站使用 Cookie 进行访问统计与第三方脚本加载。继续访问即表示您同意我们的{" "} + + + 本站使用 Cookie 进行访问统计。 + 继续访问即表示您同意 + 隐私政策
diff --git a/client/src/components/hero.tsx b/client/src/components/hero.tsx index 611e208..65ef6cd 100644 --- a/client/src/components/hero.tsx +++ b/client/src/components/hero.tsx @@ -1,23 +1,42 @@ export function Hero() { return ( -
-
-
+
+
+
+
+
+
+
+
+
+ EDGE / DESIGN / CODE +
+ +

+ Monolith +

+

+ 书写代码、设计系统与边缘计算的个人技术档案。以更清晰的网格组织阅读路径,让文章、标签和长期主题更容易被发现。 +

+

+ 书写代码、设计系统与边缘计算。 + 以清晰网格组织阅读路径。 + 让文章、标签和长期主题更容易被发现。 +

+
+ +
+

CURRENT FOCUS

+
+ {["工程笔记", "设计观察", "边缘计算"].map((item) => ( +
+ {item} + +
+ ))} +
+
-
-
-
-
-

- Monolith -

-

- 书写代码、设计与边缘计算的个人博客。 -
- - 在秩序与混沌的交界处,寻找属于自己的巨石碑。 - -

); } diff --git a/client/src/components/navbar.tsx b/client/src/components/navbar.tsx index b048a1d..3f9eee9 100644 --- a/client/src/components/navbar.tsx +++ b/client/src/components/navbar.tsx @@ -63,7 +63,7 @@ export function Navbar() { onDoubleClick={handleLogoDoubleClick} >
-
+
Monolith diff --git a/client/src/components/reading-controls.tsx b/client/src/components/reading-controls.tsx index e73a04c..5630e47 100644 --- a/client/src/components/reading-controls.tsx +++ b/client/src/components/reading-controls.tsx @@ -105,7 +105,7 @@ export function ReadingControls({
@@ -132,13 +132,13 @@ export function ReadingControls({
@@ -193,7 +193,7 @@ export function ReadingControls({
))}
浏览量 - +
-

{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 && (
- - -
@@ -226,40 +280,46 @@ export function AdminDashboard() { {search || selectedTag ? "没有符合条件的文章" : "暂无文章"}

{!search && !selectedTag && ( - + 写第一篇 )}
) : (
-
+
+
+ + 文章 + 浏览 + 操作 +
{filteredPosts.map((post) => ( -
+
{/* 复选框 */} - {/* 状态指示点 */} -
+
-
- {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 && ( - )} @@ -358,10 +355,10 @@ export function AdminDashboard() { const count = posts.filter((p) => p.tags.includes(tag)).length; return ( - {wordCount} 字 +
+

{isEdit ? "EDITING" : "NEW DRAFT"}

+

+ {form.title || "未命名文章"} +

+
+ {wordCount} 字 {lastSaved && ( <> - | - 上次保存 {lastSaved} + / + 上次保存 {lastSaved} )}
-
+
{message.text && ( - {message.type === "success" ? "✓" : "✕"} {message.text} )} - -