Commit a91e4cea authored by tao355667's avatar tao355667

refactor: decouple CMS content from Git

parent 6dc1c171
Pipeline #468 passed with stage
in 25 seconds
...@@ -6,5 +6,6 @@ dist/ ...@@ -6,5 +6,6 @@ dist/
.env .env
!.env.example !.env.example
public/uploads/ public/uploads/
src/content/
.DS_Store .DS_Store
.firecrawl/ .firecrawl/
...@@ -40,15 +40,17 @@ CMS_CONTENT_LOCK_STALE_MS=900000 ...@@ -40,15 +40,17 @@ CMS_CONTENT_LOCK_STALE_MS=900000
## 内容工作流 ## 内容工作流
- 工作稿:`src/content/articles/` - 工作稿:`${CMS_DATA_DIR}/articles/`
- 已发布:`src/content/published/` - 已发布:`${CMS_DATA_DIR}/published/`
- 分类数据:`${CMS_DATA_DIR}/categories.json`
- 上传文件:`${CMS_DATA_DIR}/uploads/`
- 默认分类:学情指南、学习方法、家长课堂、服务说明 - 默认分类:学情指南、学习方法、家长课堂、服务说明
- 支持新建、编辑、发布、下架、删除与未发布修改回退 - 支持新建、编辑、发布、下架、删除与未发布修改回退
- 支持分类新增、重命名、删除与文章迁移 - 支持分类新增、重命名、删除与文章迁移
- 支持 Markdown、Word `.docx` 导入和内嵌图片提取 - 支持 Markdown、Word `.docx` 导入和内嵌图片提取
- 支持图片上传、Markdown 预览和后台密码修改 - 支持图片上传、Markdown 预览和后台密码修改
生产环境配置 `CMS_DATA_DIR` 后,内容持久化到仓库外,不随单目录部署被覆盖。发布、下架、删除与分类变更在 `CMS_CONTENT_LOCK` 临界区内通过同目录临时文件和原子 rename 写入;已有写操作进行中时接口立即返回 HTTP 409,不等待整站构建。锁文件记录 PID、创建时间与操作名称,支持异常进程和超时锁恢复。 文章内容不纳入 Git 版本管理。本地默认保存在 `.runtime/cms-data/`生产环境配置 `CMS_DATA_DIR` 后,内容持久化到仓库外,不随单目录部署被覆盖。发布、下架、删除与分类变更在 `CMS_CONTENT_LOCK` 临界区内通过同目录临时文件和原子 rename 写入;已有写操作进行中时接口立即返回 HTTP 409,不等待整站构建。锁文件记录 PID、创建时间与操作名称,支持异常进程和超时锁恢复。
文章公开入口采用 SSR:下一次请求会读取已发布目录并立即反映新内容。`llms.txt``llms-full.txt``/article-sitemap.xml` 同样动态生成并支持 ETag。进程内缓存使用文件的 `mtimeMs` 与大小校验,多进程也能发现文件替换或删除。 文章公开入口采用 SSR:下一次请求会读取已发布目录并立即反映新内容。`llms.txt``llms-full.txt``/article-sitemap.xml` 同样动态生成并支持 ETag。进程内缓存使用文件的 `mtimeMs` 与大小校验,多进程也能发现文件替换或删除。
......
...@@ -34,7 +34,7 @@ npm start ...@@ -34,7 +34,7 @@ npm start
## 内容管理 ## 内容管理
学习资讯使用 Markdown 存储,并由同端口 `/admin/` 后台管理。工作稿位于 `src/content/articles/`,线上稿位于 `src/content/published/`;生产环境通过 `CMS_DATA_DIR` 将内容持久化到仓库外。后台支持草稿、发布、下架、删除、分类管理、Markdown/Word 导入、图片上传、预览、修改密码与 Bearer API。 学习资讯使用 Markdown 存储,并由同端口 `/admin/` 后台管理。文章、分类和上传文件统一保存在 `CMS_DATA_DIR`,默认使用被 Git 忽略的 `.runtime/cms-data/`;生产环境使用 `/root/qiyouxue/shared/cms-data`,内容与代码版本完全独立。后台支持草稿、发布、下架、删除、分类管理、Markdown/Word 导入、图片上传、预览、修改密码与 Bearer API。
完整说明见 [CMS_README.md](./CMS_README.md) 完整说明见 [CMS_README.md](./CMS_README.md)
......
import { defineCollection, z } from "astro:content"; // CMS content is loaded from CMS_DATA_DIR at request time. Keep Astro's build-time
import { glob } from "astro/loaders"; // content collections empty so local legacy files under src/content are not scanned.
export const collections = {};
const articleSchema = z.object({
title: z.string(),
slug: z.string(),
date: z.coerce.date(),
updated: z.coerce.date(),
category: z.string(),
author: z.string(),
excerpt: z.string(),
readingMinutes: z.number().int().positive().optional(),
featured: z.boolean().optional(),
status: z.enum(["draft", "published"]),
});
const articles = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/articles" }),
schema: articleSchema,
});
const published = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/published" }),
schema: articleSchema,
});
export const collections = { articles, published };
---
title: "学情分析在分析什么:从一次错题看见真正的问题"
slug: "how-to-read-learning-profile"
date: 2026-08-08
updated: 2026-08-12
category: "学情指南"
author: "启优学教研中心"
excerpt: "同一道错题背后,可能是知识缺口、方法不熟,也可能是审题和学习习惯问题。有效的学情分析需要先把原因分清楚。"
status: "published"
---
# 学情分析在分析什么:从一次错题看见真正的问题
孩子说“这道题不会”,并不等于再讲一遍答案就能解决。一次错误可能来自概念没有理解、题型方法不熟、条件遗漏,或计算和表达习惯不稳定。
## 先看错误发生在哪一步
有效的学情沟通会结合近期试卷、错题、教材进度和学生自己的解题过程,判断问题更接近知识、方法还是习惯。只有原因清楚,近期任务才不会变成泛泛刷题。
## 把宽泛目标拆小
“提高数学”很难直接执行,“本周检查一次函数取值范围的分类讨论”则更具体。阶段方案需要说明先做什么、如何检查,以及什么时候根据表现调整。
---
title: "错题复盘不只是重做:把失分原因分成四类"
slug: "how-to-review-mistakes"
date: 2026-08-10
updated: 2026-08-12
category: "学习方法"
author: "启优学教研中心"
excerpt: "知识概念、解题方法、审题表达和计算习惯需要不同的改进方式。错题复盘的第一步,是不再把所有错误归为粗心。"
status: "published"
---
# 错题复盘不只是重做:把失分原因分成四类
把错题答案抄一遍,并不一定能减少下一次错误。更有效的方式,是先判断错误发生的类型:知识概念、解题方法、审题表达和计算习惯,需要不同的改进方式。
复盘的目标不是收藏更多错题,而是发现重复出现的原因,并安排下一次可以检查的任务。
---
title: "在线一对一适合哪些学生?先看三个判断维度"
slug: "online-one-to-one-fit"
date: 2026-08-09
updated: 2026-08-12
category: "家长课堂"
author: "启优学教研中心"
excerpt: "一对一更适合问题较明确、需要个别讲解节奏或持续反馈的学生,是否适合还要结合目标、配合情况与学习周期判断。"
status: "published"
---
# 在线一对一适合哪些学生?先看三个判断维度
在线一对一并不是所有学习问题的统一答案。判断是否适合,可以先看问题是否明确、学生是否需要个别节奏,以及家庭是否能为持续练习与反馈留出空间。
## 学习问题是否可以被具体描述
单科短板、某一章节基础断层、校内进度衔接困难,通常比“想全面提高”更容易形成明确方案。
## 是否需要更多互动检查
如果学生在大班课堂中不容易表达思路,或需要教师根据作答即时调整讲解,一对一互动会提供更集中的反馈机会。
[
"学情指南",
"学习方法",
"家长课堂",
"服务说明"
]
---
title: "学情分析在分析什么:从一次错题看见真正的问题"
slug: "how-to-read-learning-profile"
date: 2026-08-08
updated: 2026-08-12
category: "学情指南"
author: "启优学教研中心"
excerpt: "同一道错题背后,可能是知识缺口、方法不熟,也可能是审题和学习习惯问题。有效的学情分析需要先把原因分清楚。"
status: "published"
---
# 学情分析在分析什么:从一次错题看见真正的问题
孩子说“这道题不会”,并不等于再讲一遍答案就能解决。一次错误可能来自概念没有理解、题型方法不熟、条件遗漏,或计算和表达习惯不稳定。
## 先看错误发生在哪一步
有效的学情沟通会结合近期试卷、错题、教材进度和学生自己的解题过程,判断问题更接近知识、方法还是习惯。只有原因清楚,近期任务才不会变成泛泛刷题。
## 把宽泛目标拆小
“提高数学”很难直接执行,“本周检查一次函数取值范围的分类讨论”则更具体。阶段方案需要说明先做什么、如何检查,以及什么时候根据表现调整。
学习结果受基础、频率、练习与学习周期等多种因素影响。学情分析的价值,是让下一步安排更有依据,而不是给出统一答案。
---
title: "错题复盘不只是重做:把失分原因分成四类"
slug: "how-to-review-mistakes"
date: 2026-08-10
updated: 2026-08-12
category: "学习方法"
author: "启优学教研中心"
excerpt: "知识概念、解题方法、审题表达和计算习惯需要不同的改进方式。错题复盘的第一步,是不再把所有错误归为粗心。"
status: "published"
---
# 错题复盘不只是重做:把失分原因分成四类
把错题答案抄一遍,并不一定能减少下一次错误。更有效的方式,是先判断错误发生的类型。
## 知识概念
定义、公式或概念边界没有理解,需要回到基础知识并用相近问题检查。
## 解题方法
知道知识点,却不知道从什么条件切入,需要梳理题型结构和步骤。
## 审题与表达
遗漏条件、答非所问或步骤不完整,需要把读题标记与答题规范纳入练习。
## 计算与学习习惯
符号、单位、抄写和检查中的重复错误,需要用固定的自查流程逐步减少。
复盘的目标不是收藏更多错题,而是发现重复出现的原因,并安排下一次可以检查的任务。
---
title: "在线一对一适合哪些学生?先看三个判断维度"
slug: "online-one-to-one-fit"
date: 2026-08-09
updated: 2026-08-12
category: "家长课堂"
author: "启优学教研中心"
excerpt: "一对一更适合问题较明确、需要个别讲解节奏或持续反馈的学生,是否适合还要结合目标、配合情况与学习周期判断。"
status: "published"
---
# 在线一对一适合哪些学生?先看三个判断维度
在线一对一并不是所有学习问题的统一答案。判断是否适合,可以先看问题是否明确、学生是否需要个别节奏,以及家庭是否能为持续练习与反馈留出空间。
## 学习问题是否可以被具体描述
单科短板、某一章节基础断层、校内进度衔接困难,通常比“想全面提高”更容易形成明确方案。
## 是否需要更多互动检查
如果学生在大班课堂中不容易表达思路,或需要教师根据作答即时调整讲解,一对一互动会提供更集中的反馈机会。
## 是否能配合阶段任务
课堂只是学习的一部分。课后巩固、错题复盘与阶段沟通同样重要。具体课程与教师安排,应在学情沟通后由双方确认。
...@@ -5,14 +5,11 @@ import matter from "gray-matter"; ...@@ -5,14 +5,11 @@ import matter from "gray-matter";
import { marked } from "marked"; import { marked } from "marked";
const ROOT = process.cwd(); const ROOT = process.cwd();
const DATA_ROOT = process.env.CMS_DATA_DIR ? path.resolve(process.env.CMS_DATA_DIR) : null; const DATA_ROOT = path.resolve(process.env.CMS_DATA_DIR || path.join(ROOT, ".runtime", "cms-data"));
const SOURCE_ARTICLES_DIR = path.join(ROOT, "src", "content", "articles"); export const ARTICLES_DIR = path.join(DATA_ROOT, "articles");
const SOURCE_PUBLISHED_DIR = path.join(ROOT, "src", "content", "published"); export const PUBLISHED_DIR = path.join(DATA_ROOT, "published");
export const ARTICLES_DIR = DATA_ROOT ? path.join(DATA_ROOT, "articles") : path.join(ROOT, "src", "content", "articles"); export const CATEGORIES_FILE = path.join(DATA_ROOT, "categories.json");
export const PUBLISHED_DIR = DATA_ROOT ? path.join(DATA_ROOT, "published") : path.join(ROOT, "src", "content", "published"); export const UPLOADS_DIR = path.join(DATA_ROOT, "uploads");
export const CATEGORIES_FILE = DATA_ROOT ? path.join(DATA_ROOT, "categories.json") : path.join(ROOT, "src", "content", "categories.json");
export const UPLOADS_DIR = DATA_ROOT ? path.join(DATA_ROOT, "uploads") : path.join(ROOT, "public", "uploads");
const DATA_SEED_MARKER = DATA_ROOT ? path.join(DATA_ROOT, ".content-initialized") : null;
const DEFAULT_CATEGORIES = ["学情指南", "学习方法", "家长课堂", "服务说明"]; const DEFAULT_CATEGORIES = ["学情指南", "学习方法", "家长课堂", "服务说明"];
const DEFAULT_AUTHOR = "启优学教研中心"; const DEFAULT_AUTHOR = "启优学教研中心";
...@@ -143,23 +140,6 @@ async function ensureStore(): Promise<void> { ...@@ -143,23 +140,6 @@ async function ensureStore(): Promise<void> {
fs.mkdir(PUBLISHED_DIR, { recursive: true }), fs.mkdir(PUBLISHED_DIR, { recursive: true }),
fs.mkdir(UPLOADS_DIR, { recursive: true }), fs.mkdir(UPLOADS_DIR, { recursive: true }),
]); ]);
if (DATA_ROOT && DATA_SEED_MARKER && !await fileExists(DATA_SEED_MARKER)) {
const seedDirectory = async (source: string, target: string) => {
const seeds = await fs.readdir(source).catch(() => [] as string[]);
await Promise.all(seeds.filter((file) => file.endsWith(".md")).map(async (file) => {
await writeAtomic(path.join(target, file), await fs.readFile(path.join(source, file)));
}));
};
const [workingFiles, publishedFiles] = await Promise.all([fs.readdir(ARTICLES_DIR), fs.readdir(PUBLISHED_DIR)]);
const hasExistingContent = [...workingFiles, ...publishedFiles].some((file) => file.endsWith(".md"));
if (!hasExistingContent) {
await Promise.all([
seedDirectory(SOURCE_ARTICLES_DIR, ARTICLES_DIR),
seedDirectory(SOURCE_PUBLISHED_DIR, PUBLISHED_DIR),
]);
}
await writeAtomic(DATA_SEED_MARKER, `${new Date().toISOString()}\n`);
}
if (!await fileExists(CATEGORIES_FILE)) { if (!await fileExists(CATEGORIES_FILE)) {
await writeAtomic(CATEGORIES_FILE, `${JSON.stringify(DEFAULT_CATEGORIES, null, 2)}\n`); await writeAtomic(CATEGORIES_FILE, `${JSON.stringify(DEFAULT_CATEGORIES, null, 2)}\n`);
} }
......
...@@ -79,6 +79,15 @@ after(async () => { ...@@ -79,6 +79,15 @@ after(async () => {
}); });
test("article lists read metadata without rendering every Markdown body", async () => { test("article lists read metadata without rendering every Markdown body", async () => {
const created = await apiRequest("articles", "POST", {
title: "元数据读取测试文章",
category: "学习方法",
body: "# 元数据正文\n\n列表读取时不应渲染这段 Markdown 正文。",
});
assert.equal(created.status, 201);
const result = await created.json() as { slug: string };
const published = await apiRequest(`articles/${result.slug}/publish`, "POST");
assert.equal(published.status, 200);
articleStore.resetArticleStoreDiagnostics(); articleStore.resetArticleStoreDiagnostics();
const articles = await articleStore.getPublishedArticles(); const articles = await articleStore.getPublishedArticles();
assert.ok(articles.length > 0); assert.ok(articles.length > 0);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment