Misaka Network Blog 项目重构记录

今天对 Misaka Network Blog 项目进行了一次重要的架构重构,主要涉及目录结构优化、功能增强和后台系统适配。

📁 目录结构重构

背景

随着博客文章数量增长(当前 45+ 篇),将所有 Markdown 文件平铺在单一目录下的方案已经不够优雅。为了更好的组织管理,决定采用 年/月 层级目录结构。

实施方案

旧结构:

src/content/blog/
├── 25-11-24-16-00.md
├── 25-11-24-18-30.md
├── 26-01-07-10-37.md
└── ...

新结构:

src/content/blog/
├── 2025/
│   ├── 11/
│   │   ├── 25-11-24-16-00.md
│   │   └── 25-11-24-18-30.md
│   └── 12/
│       └── 25-12-30-09-28.md
└── 2026/
    └── 01/
        ├── 26-01-07-10-37.md
        └── 26-01-07-12-21.md

自动化迁移

创建了迁移脚本 tools/scripts/migrate-blog-structure.js

关键功能:

  • 自动从文件名提取年月(YY-MM-DD-HH-MM.md
  • 递归创建目标目录
  • 批量移动文件
  • 验证迁移完整性

执行结果:

 成功迁移 45 篇文章
 Build 通过,178 页面生成

Astro 配置适配

Content Collections 已原生支持嵌套目录:

// src/content.config.ts
const blog = defineCollection({
  loader: glob({
    base: './src/content/blog',
    pattern: '**/*.{md,mdx}'  // 递归匹配
  }),
  schema: {...}
});

文章 ID 变化:

  • 旧 ID: 26-01-07-10-37
  • 新 ID: 2026/01/26-01-07-10-37

代码适配要点

1. 排序工具修复

src/utils/sortPosts.ts 需要从路径中提取文件名:

export function getTimestampFromFilename(id: string): number {
  // 从 ID 中提取文件名部分(去除可能的路径前缀)
  // 例如:"2026/01/26-01-07-10-37" → "26-01-07-10-37"
  const filename = id.split('/').pop() || id;

  const match = filename.match(/^(\d{2})-(\d{2})-(\d{2})-(\d{2})-(\d{2})/);
  // ... 解析时间戳
}

2. 图片路径修正

嵌套目录增加了两层深度,需要修正相对路径:

- heroImage: '../../assets/hero.jpg'
+ heroImage: '../../../../assets/hero.jpg'

受影响文件: 3 篇含封面图的文章

3. 脚本自动适配

tools/scripts/new-post.js 更新为自动创建年月目录:

const now = new Date();
const year = now.getFullYear().toString();
const month = String(now.getMonth() + 1).padStart(2, '0');

const outputDir = join(__dirname, '..', '..', 'src', 'content', 'blog', year, month);

// 确保目录存在
if (!fs.existsSync(outputDir)) {
  fs.mkdirSync(outputDir, { recursive: true });
}

📊 博客卡片字数显示功能

需求

在首页、博客列表页、标签页的文章卡片上显示每篇文章的字数统计。

实现方案

1. 字数统计工具

src/utils/wordCount.ts 支持中英文混合计数:

export function countWords(markdown: string): number {
  let content = markdown;

  // 移除代码块、内联代码、链接等
  content = content.replace(/```[\s\S]*?```/g, '');
  content = content.replace(/`[^`]+`/g, '');
  content = content.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');

  // 分别统计中文和英文
  const chineseCount = (content.match(/[\u4e00-\u9fa5]/g) || []).length;
  const englishWords = content.match(/[a-zA-Z]+/g) || [];

  return chineseCount + englishWords.length;
}

export function formatWordCount(count: number): string {
  if (count >= 10000) {
    return `${(count / 10000).toFixed(1)}w 字`;
  } else if (count >= 1000) {
    return `${(count / 1000).toFixed(1)}k 字`;
  } else {
    return `${count} 字`;
  }
}

2. Card 组件增强

src/components/Card.astro

---
import { formatWordCount } from '../utils/wordCount';

export interface Props {
  // ... 其他属性
  wordCount?: number;
}
---

<!-- 日期和字数 -->
<div class="flex items-center flex-wrap gap-x-4 gap-y-2 text-sm">
  <!-- 日期 -->
  <div class="flex items-center space-x-2">
    <svg>...</svg>
    <FormattedDate date={pubDate}/>
  </div>

  <!-- 字数 -->
  {wordCount && (
    <div class="flex items-center space-x-2">
      <svg>...</svg>
      <span>{formatWordCount(wordCount)}</span>
    </div>
  )}
</div>

3. 页面集成

// src/pages/index.astro
import {countWords} from '../utils/wordCount';

<Card
  title={post.data.title}
  description={post.data.description}
  pubDate={post.data.pubDate}
  tags={post.data.tags}
  slug={post.id}
  wordCount={countWords(post.body || '')}
/>

已集成页面:

  • ✅ 首页 (src/pages/index.astro)
  • ✅ 博客列表页 (src/pages/blog/[...page].astro)
  • ✅ 标签页 (src/pages/tags/[tag].astro)

🛠️ Admin 后台适配

核心挑战

Admin 后台原先只扫描单层目录,无法处理年月嵌套结构。

解决方案

1. 递归扫描函数

tools/admin/server.js

/**
 * 递归扫描目录,获取所有博客文件
 * @param {string} dir - 要扫描的目录路径
 * @param {string} baseDir - 基础目录(用于计算相对路径)
 * @returns {Array<{relativePath: string, fullPath: string}>}
 */
function getAllBlogFiles(dir, baseDir = dir) {
  const results = [];
  const entries = fs.readdirSync(dir, { withFileTypes: true });

  for (const entry of entries) {
    const fullPath = path.join(dir, entry.name);

    if (entry.isDirectory()) {
      // 递归扫描子目录
      results.push(...getAllBlogFiles(fullPath, baseDir));
    } else if (entry.isFile() &&
               (entry.name.endsWith('.md') || entry.name.endsWith('.mdx'))) {
      // 计算相对路径(如 "2026/01/26-01-07-10-37.md")
      const relativePath = path.relative(baseDir, fullPath)
                                .replace(/\\/g, '/');
      results.push({ relativePath, fullPath });
    }
  }

  return results;
}

2. API 数据格式调整

GET /api/posts 返回格式:

{
  "id": "2026/01/26-01-07-10-37",
  "filename": "26-01-07-10-37.md",
  "relativePath": "2026/01/26-01-07-10-37.md",
  "title": "文章标题",
  "description": "...",
  "pubDate": "2026-01-07",
  "tags": ["标签"],
  "draft": false,
  "updatedAt": "2026-01-07T04:21:28.281Z"
}

关键字段说明:

  • id: 不含扩展名的相对路径(用于前端识别)
  • filename: 纯文件名(用于时间戳提取和排序)
  • relativePath: 完整相对路径(用于文件定位)

3. 路由参数处理

Express 路由通配符:

// 支持路径参数(如 2026/01/26-01-07-10-37)
app.get('/api/posts/:id(*)', (req, res) => {
  let fileId = req.params.id;

  // 确保有扩展名
  if (!fileId.endsWith('.md') && !fileId.endsWith('.mdx')) {
    const mdPath = path.join(BLOG_DIR, `${fileId}.md`);
    if (fs.existsSync(mdPath)) {
      fileId = `${fileId}.md`;
    }
  }

  const filePath = path.join(BLOG_DIR, fileId);
  // ...
});

:id(*) 语法: 允许 :id 参数包含斜杠

4. 创建文章自动分类

POST /api/posts 自动创建年月目录:

app.post('/api/posts', (req, res) => {
  const {filename, frontmatter, content} = req.body;

  // 从文件名提取年月信息(YY-MM-DD-HH-MM.md)
  const match = filename.match(/^(\d{2})-(\d{2})-/);
  let targetDir = BLOG_DIR;

  if (match) {
    const [, yy, mm] = match;
    const year = `20${yy}`;

    // 创建年月目录结构
    targetDir = path.join(BLOG_DIR, year, mm);
    if (!fs.existsSync(targetDir)) {
      fs.mkdirSync(targetDir, { recursive: true });
    }
  }

  const filePath = path.join(targetDir, filename);
  fs.writeFileSync(filePath, fullContent, 'utf-8');
  // ...
});

5. 完善的日志系统

新增彩色日志工具:

const logger = {
  info: (category, message, data) => log('INFO', category, message, data),
  success: (category, message, data) => log('SUCCESS', category, message, data),
  warn: (category, message, data) => log('WARN', category, message, data),
  error: (category, message, data) => log('ERROR', category, message, data),
  debug: (category, message, data) => log('DEBUG', category, message, data),
};

日志输出示例:

2026-01-07 04:36:34 [INFO]    [HTTP]       GET /api/posts 200 45ms
2026-01-07 04:36:35 [DEBUG]   [API]        Loading post: 2026/01/26-01-07-10-37
2026-01-07 04:36:35 [SUCCESS] [API]        Post loaded successfully: Photoshop 常用快捷键速查表

请求日志中间件:

app.use((req, res, next) => {
  const start = Date.now();
  const originalSend = res.send;

  res.send = function(data) {
    const duration = Date.now() - start;
    const statusColor = res.statusCode >= 400 ? LOG_COLORS.red : LOG_COLORS.green;
    logger.info('HTTP', `${req.method} ${req.path} ${statusColor}${res.statusCode}${LOG_COLORS.reset} ${duration}ms`);
    return originalSend.call(this, data);
  };

  next();
});

🐛 已修复的问题

1. 文件名格式不匹配警告

问题: 目录迁移后,sortPosts.ts 无法识别带路径的文章 ID。

现象:

文件名格式不匹配: 2026/01/26-01-07-10-37
文件名格式不匹配: 2026/01/26-01-07-10-37
... (大量警告)

原因: getTimestampFromFilename() 直接对 ID 进行正则匹配,但 ID 现在包含路径前缀。

修复: 使用 split('/').pop() 提取文件名后再匹配。

2. Admin 后台加载不到文章

问题: 后台文章列表显示为空。

原因: 缺少 GET /api/posts/:id 端点,前端无法加载单篇文章详情。

修复: 新增端点并支持路径参数:

app.get('/api/posts/:id(*)', (req, res) => {
  // 支持 "2026/01/26-01-07-10-37" 格式的 ID
  // ...
});

3. 图片路径失效

问题: Build 失败,提示 “Could not find requested image”。

原因: 文件移动后相对路径深度改变。

修复: 更新 3 篇文章的 heroImage 路径,增加两个 ../


📈 性能与统计

构建性能:

  • 构建时间:~2.6s
  • 生成页面:178 页
  • 文章总数:46 篇
  • 标签总数:114 个

代码质量:

  • ✅ 零 TypeScript 错误
  • ✅ 零构建警告
  • ✅ 所有测试通过

🔮 后续计划

  • Admin 后台 UI 优化(响应式布局)
  • 文章编辑器增强(Markdown 预览)
  • 批量操作功能(标签管理、草稿发布)
  • 数据备份与恢复
  • 文章版本历史

💡 经验总结

1. 目录结构设计原则

采用:

  • 按时间维度(年/月)组织,易于归档和浏览
  • 保持文件名时间戳,确保排序准确性
  • 使用 Glob pattern 递归匹配

避免:

  • 过深的嵌套(不超过 3 层)
  • 混合不同维度(如年/类别/月)
  • 依赖数据库索引

2. API 向后兼容

关键策略:

  • 返回数据包含多种 ID 格式(id, filename, relativePath
  • 路由支持通配符参数(:id(*)
  • 自动处理扩展名缺失情况

3. 迁移脚本设计

必备功能:

  • Dry-run 模式(预览而不执行)
  • 详细日志输出
  • 迁移完整性验证
  • 回滚能力(通过 Git)

📚 相关资源


重构完成时间: 2026-01-07 重构耗时: 约 3 小时 代码变更: 15+ 文件修改

🚀 Misaka Network - Level 5 Railgun