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