diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..df8585b
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,16 @@
+
+
+## This is NOT the Next.js you know
+
+This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
+
+This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
+
+
+
+## 交付与视觉验收
+
+- 用户指定服务器为唯一运行与验收入口:http://8.211.154.134:18131/。
+- 页面改动必须先 push,等待 ces-control 的 CI/CD 成功部署,再直接访问服务器检查效果。
+- 不启动本地网页预览,不使用 localhost / 127.0.0.1 页面进行视觉验收,也不把本地预览链接交付给用户。
+- 类型检查、单元测试等不提供网页预览的检查可以本地执行;完整运行检查由 Docker CI 完成。
diff --git a/Dockerfile b/Dockerfile
index 3e9b9ae..cf8c16f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -21,7 +21,7 @@ FROM base AS runtime
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 \
FFMPEG_PATH=/usr/bin/ffmpeg FFPROBE_PATH=/usr/bin/ffprobe
COPY --from=build --chown=node:node /app/.next/standalone/ ./
-COPY --from=build --chown=node:node /app/dist/worker.cjs /app/dist/bootstrap.cjs ./
+COPY --from=build --chown=node:node /app/dist/worker.cjs /app/dist/bootstrap.cjs /app/dist/publish-learning-series.cjs ./
COPY --from=build --chown=node:node /app/db/ ./db/
COPY --chown=node:node scripts/container-health.mjs ./
USER node
diff --git a/compose.yml b/compose.yml
index d9c0a6d..669ff6a 100644
--- a/compose.yml
+++ b/compose.yml
@@ -50,6 +50,11 @@ services:
profiles: [tools]
restart: "no"
command: ["node", "bootstrap.cjs"]
+ publish:
+ <<: *app
+ profiles: [tools]
+ restart: "no"
+ command: ["node", "publish-learning-series.cjs"]
tests:
image: ${PROMPT_TEST_IMAGE:-prompt:tests}
profiles: [tools]
diff --git a/docs/learning-series.md b/docs/learning-series.md
new file mode 100644
index 0000000..75b92f9
--- /dev/null
+++ b/docs/learning-series.md
@@ -0,0 +1,25 @@
+# 学习资料自己做
+
+三个代码编写的图文作品,每篇 8 张 3:4 画板、10 项工具。三篇共用一张 1080×1440 PNG 封面,通过同一个 S3 图片资产引用,不复制图片。
+
+| 篇章 | 详情路径 | 主题 |
+| --- | --- | --- |
+| 01 | `/p/learning-skills-notes` | 笔记、网课与复习 |
+| 02 | `/p/learning-skills-teaching` | 练习、试卷与教学 |
+| 03 | `/p/learning-skills-english` | 词汇、精读、口语与写作 |
+
+内容位于 `src/content/learning-skills/data.ts`,版式位于 `Series.tsx` 和 `src/styles/learning-series.css`。每篇依次为共同封面、索引、五张双工具介绍、使用路线与来源;支持分页、索引跳转、连续阅读与同系列跳转。所有小样标注为排版示意;GitHub 来源已于 2026-10-23 核对,尚未逐项安装实测。
+
+`public/learning-skills/cover.svg` 是封面设计源稿,`cover.png` 是部署与素材库实际使用的图片,不依赖服务器安装中文字体重新渲染。
+
+## 实际发布
+
+存储初始化不会自动插入示例帖子。生产发布时,明确执行 Compose 的 `publish` 一次性容器,运行 `publish-learning-series.cjs`,在真实 PostgreSQL/S3 中发布这三篇。
+
+发布使用稳定 ID、数据库事务和锁;重复发布更新相同作品,保留创建时间,不新增重复帖子、封面资产或贡献记录。遇到相同 slug 被其他作品占用时失败,不覆盖其他作品。封面对象按内容摘要命名,图片与独立文字资产通过 `post_assets` 引用。分类置于学习工具及对应的笔记复习、教学资料、英语学习分支。
+
+Docker 集成测试会重复执行发布,检查三个作品、共用封面的三次引用、封面尺寸与媒体读取、分类筛选、详情 HTTP 200,以及没有擅自增加贡献。
+
+## 验收约定
+
+先推送 Git,再等 CI/CD 成功,最后直接访问服务器 http://8.211.154.134:18131/ 验收。不要启动本地网页预览,不把 localhost 作为展示或交付入口。
diff --git a/package.json b/package.json
index adc1ff0..fd1a9cc 100644
--- a/package.json
+++ b/package.json
@@ -7,12 +7,13 @@
},
"scripts": {
"dev": "next dev",
- "build": "next build && esbuild src/worker.ts --bundle --platform=node --format=cjs --external:pg-native --outfile=dist/worker.cjs && esbuild scripts/bootstrap.ts --bundle --platform=node --format=cjs --external:pg-native --outfile=dist/bootstrap.cjs && node scripts/prepare-standalone.mjs",
+ "build": "next build && esbuild src/worker.ts --bundle --platform=node --format=cjs --external:pg-native --outfile=dist/worker.cjs && esbuild scripts/bootstrap.ts --bundle --platform=node --format=cjs --external:pg-native --outfile=dist/bootstrap.cjs && esbuild scripts/publish-learning-series.ts --bundle --platform=node --format=cjs --external:pg-native --outfile=dist/publish-learning-series.cjs && node scripts/prepare-standalone.mjs",
"start": "next start",
"typecheck": "next typegen && tsc --noEmit",
- "test": "tsx --test tests/health.test.ts tests/model.test.ts",
+ "test": "tsx --test tests/health.test.ts tests/model.test.ts tests/learning-series.test.ts",
"test:smoke": "node scripts/smoke.mjs",
"storage:prepare": "tsx scripts/bootstrap.ts",
+ "content:publish-learning": "tsx scripts/publish-learning-series.ts",
"worker": "tsx src/worker.ts",
"test:integration": "tsx --test tests/integration.test.ts"
},
diff --git a/public/learning-skills/cover.png b/public/learning-skills/cover.png
new file mode 100644
index 0000000..5b11bc1
Binary files /dev/null and b/public/learning-skills/cover.png differ
diff --git a/public/learning-skills/cover.svg b/public/learning-skills/cover.svg
new file mode 100644
index 0000000..3025af4
--- /dev/null
+++ b/public/learning-skills/cover.svg
@@ -0,0 +1,26 @@
+
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
index 2a4d81f..c315d8f 100644
--- a/scripts/deploy.sh
+++ b/scripts/deploy.sh
@@ -67,6 +67,7 @@ install -d -o root -g root -m 0700 /var/lib/prompt/backups
compose exec -T postgres pg_dump -U prompt -d prompt -Fc > "/var/lib/prompt/backups/$(date -u +%Y%m%dT%H%M%S)-$RELEASE_SHA.dump"
find /var/lib/prompt/backups -name '*.dump' -printf '%T@ %p\n' | sort -rn | tail -n +11 | cut -d' ' -f2- | xargs -r rm --
compose run --rm --no-deps migrate
+compose run --rm --no-deps publish
CUTOVER=1
# One-time migration: finish a current host worker job before switching the web port.
if [ "$LEGACY_WORKER" -eq 1 ] || [ "$LEGACY_WORKER_ENABLED" -eq 1 ]; then systemctl disable --now prompt-worker.service; fi
@@ -75,6 +76,7 @@ compose up -d --no-build --wait --wait-timeout 180 web worker
compose exec -T web node container-health.mjs
compose exec -T web node -e "fetch('http://127.0.0.1:3000/').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
compose exec -T worker sh -c 'test "$(id -u)" -ne 0 && ffmpeg -version >/dev/null'
+compose exec -T web node -e "(async()=>{const feed=await fetch('http://127.0.0.1:3000/api/feed?section=articles').then(r=>r.json());const slugs=['learning-skills-notes','learning-skills-teaching','learning-skills-english'];for(const slug of slugs){const post=feed.posts.find(p=>p.slug===slug);if(!post)throw Error('Published article missing: '+slug);const res=await fetch('http://127.0.0.1:3000/p/'+slug);if(!res.ok)throw Error('Article unavailable: '+slug)}if(new Set(feed.posts.filter(p=>slugs.includes(p.slug)).map(p=>p.cover_asset_id)).size!==1)throw Error('Series must share one cover');console.log('Verified three published article pages and their shared cover')})().catch(e=>{console.error(e.message);process.exit(1)})"
ln -sfn "$RELEASE_DIR" "$APP_ROOT/.current.next"
mv -Tf "$APP_ROOT/.current.next" "$APP_ROOT/current"
compose ps
diff --git a/scripts/publish-learning-series.ts b/scripts/publish-learning-series.ts
new file mode 100644
index 0000000..580ee3a
--- /dev/null
+++ b/scripts/publish-learning-series.ts
@@ -0,0 +1,5 @@
+import { db } from "../src/lib/db";
+import { publishLearningSeries } from "../src/lib/publish-learning-series";
+publishLearningSeries().then(result=>console.log(`Published learning series: ${result.published.join(", ")}; one shared cover: ${result.coverId}`))
+ .catch(error=>{console.error("Learning series publication failed:",error instanceof Error?error.message:"Unknown error");process.exitCode=1;})
+ .finally(()=>db().end());
diff --git a/src/content/learning-skills/Series.tsx b/src/content/learning-skills/Series.tsx
new file mode 100644
index 0000000..4cec45f
--- /dev/null
+++ b/src/content/learning-skills/Series.tsx
@@ -0,0 +1,35 @@
+import Link from "next/link";
+import { useState, type CSSProperties, type ReactNode } from "react";
+import { ArrowUpRight, ChevronLeft, ChevronRight, List, BookOpen, Check, ArrowDown } from "lucide-react";
+import { chapters, seriesCover, sourceChecked, type Chapter, type Skill } from "./data";
+
+function Footer({chapter,page}:{chapter:Chapter;page:number}) {
+ return ;
+}
+function Sheet({chapter,page,children,className=""}:{chapter:Chapter;page:number;children:ReactNode;className?:string}) {
+ return 学习资料自己做 / VOL. {chapter.number}THE LEARNING EDIT
{children};
+}
+function Sample({skill,index}:{skill:Skill;index:number}) {
+ return
成品形态 · 排版示意{skill.sample.map((line,i)=>
{String(i+1).padStart(2,"0")}{line}
{i===0&&
} )}
;
+}
+function SkillCard({skill,index}:{skill:Skill;index:number}) {
+ return {String(index+1).padStart(2,"0")}{skill.category}
{skill.title}
{skill.name}给它{skill.input}
得到{skill.output}
{skill.note}
;
+}
+const labels=["系列封面","本篇索引","01—02","03—04","05—06","07—08","09—10","使用路线"];
+export function LearningSeries({chapterKey}:{chapterKey:string}) {
+ const chapter=chapters.find(c=>c.key===chapterKey)!;
+ const [page,setPage]=useState(0);const [continuous,setContinuous]=useState(false);
+ const boards:ReactNode[]=[
+ ,
+ 第 {chapter.number} 辑 · 10 个 Skills{chapter.headline[0]}
{chapter.headline[1]}
{chapter.subtitle}
{chapter.audience} {chapter.skills.map((skill,i)=>
)}
按需求挑选,不必一次装完。点击条目查看介绍。
,
+ ...Array.from({length:5},(_,i)=>把工具用在具体的事情上{["先整理,再开始。","把资料做成自己的。","让学习多一条路径。","把看过变成理解。","留下一份能复用的资料。"][i]}
{chapter.skills.slice(i*2,i*2+2).map((skill,j)=>)}),
+ 收藏之后,试着做一份。先选一个需求,
再串成工作流。
不求用遍所有工具,先完成一份自己的资料。
{chapter.workflow.map((line,i)=>- {i+1}{line}{i<2&&}
)}
10 个来源,放在这里了。
依据 GitHub 项目说明整理 · 核对于 {sourceChecked}
尚未逐项安装实测;文中小样为排版示意。
同系列还有{chapters.filter(c=>c.key!==chapter.key).map(c=>
{c.number} / {c.subtitle}
)} ,
+ ];
+ return
+
系列 {chapter.number} / 03{chapter.subtitle}
+
{if(e.target!==e.currentTarget||continuous)return;if(e.key==="ArrowRight"){e.preventDefault();setPage(p=>Math.min(7,p+1));}if(e.key==="ArrowLeft"){e.preventDefault();setPage(p=>Math.max(0,p-1));}}} tabIndex={0} aria-label="图文画板,可使用左右方向键翻页">{continuous?boards:boards[page]}
+ {!continuous&&
}
+
{labels.map((label,i)=>)}
+
学习资料自己做 · 系列三篇{chapters.map(c=>
{c.number}{c.key===chapter.key?"正在阅读 · ":""}{c.subtitle}
)}
+
;
+}
diff --git a/src/content/learning-skills/data.ts b/src/content/learning-skills/data.ts
new file mode 100644
index 0000000..42b5df2
--- /dev/null
+++ b/src/content/learning-skills/data.ts
@@ -0,0 +1,70 @@
+export type Skill = {
+ name: string; title: string; category: string; input: string; output: string;
+ prompt: string; note: string; source: string; sample: [string, string, string];
+};
+export type Chapter = {
+ key: string; slug: string; number: string; title: string; headline: [string,string];
+ subtitle: string; color: string; audience: string; workflow: [string,string,string]; skills: Skill[];
+};
+const github = (path: string) => `https://github.com/${path}`;
+export const seriesCover = "/learning-skills/cover.png";
+export const sourceChecked = "2026-10-23";
+export const chapters: Chapter[] = [
+ {
+ key: "learning-skills-notes", slug: "learning-skills-notes", number: "01",
+ title: "课件太厚?这 10 个 Skills 帮你整理复习资料",
+ headline: ["课件太厚,", "复习有方法。"], subtitle: "10 个整理笔记、网课与记忆卡的 Skills",
+ color: "#c3dc81", audience: "学生党 / 自学者 / 考前复习",
+ workflow: ["整理原始课件与网课", "搭框架,再做详细笔记", "用速记页与卡片检查理解"],
+ skills: [
+ {name:"study-notes",title:"厚课件,拆成章节笔记",category:"课件整理",input:"PDF、PPT、教材或技术文档",output:"Obsidian 笔记、图解、例题与练习",prompt:"把这章课件整理成复习笔记,保留例题,最后给我一组自测题。",note:"输出以 Markdown 笔记库为主。",source:github("siluy/obsidian-study-notes-skill/blob/main/SKILL.md"),sample:["01 章节知识框架","02 关键概念与例题","03 自测与易错点"]},
+ {name:"exam-cheatsheet",title:"考前,把重点收成几页",category:"速记清单",input:"备考工作区里的笔记、错题与知识库",output:"可溯源的速记资料;按需生成打印 PDF",prompt:"从已掌握的笔记和错题里,编译两页公式与例题速查表。",note:"依赖原项目的备考工作区,上游资料需先准备。",source:github("ZeKaiNie/universal-examprep-skill/blob/main/full/skills/exam-cheatsheet/SKILL.md"),sample:["必背结论 / 公式","典型例题 / 解题过程","来源锚点 / 方法提醒"]},
+ {name:"学霸笔记",title:"知识点,也能有笔记感",category:"视觉笔记",input:"需要重新组织的知识与说明文字",output:"手写笔记本风格的单文件 HTML",prompt:"把这些知识点排成横线纸笔记,用高亮标出三个易混概念。",note:"负责内容与视觉编排,不代替知识核对。",source:github("Unclecheng-li/note-skill/blob/main/SKILL.md"),sample:["CHAPTER 01 学习笔记","重点概念 → 高亮标记","我的理解 / 旁注留白"]},
+ {name:"video2knowledge",title:"一段网课,留下可复习的笔记",category:"视频学习",input:"本地课程视频或录屏",output:"时间戳转写、学习笔记、HTML 与记忆卡",prompt:"整理这段课程视频,给出时间戳笔记,再提炼可复习的知识卡。",note:"需要本地视频处理与识别依赖;云服务可选。",source:github("CacinieP/video2knowledge/blob/main/SKILL.md"),sample:["00:42 本节核心问题","03:18 概念与演示","08:06 总结 / 复习卡"]},
+ {name:"bili-content-organizer",title:"收藏的系列课,终于整理了",category:"系列课程",input:"B 站链接、分 P、合集或已有字幕",output:"按主题组织的结构化课程笔记",prompt:"按知识模块整理这个系列课,保留各节对应的来源。",note:"需能取得字幕;平台访问与登录按原项目要求。",source:github("Mocooa/bili-content-organizer/blob/main/SKILL.md"),sample:["课程目录 / 知识模块","每节课的核心观点","来源视频 / 对应笔记"]},
+ {name:"learning-pack",title:"想学一个主题,先有路线",category:"学习路线",input:"想系统学习的主题与当前基础",output:"学习路径、资料、视频清单与学习笔记",prompt:"我想从零学摄影,帮我整理循序渐进的学习路径与资料。",note:"资料检索与部分视觉产物依赖联网及配套能力。",source:github("irenerachel/learning-pack-skill/blob/main/SKILL.md"),sample:["入门 → 理解 → 实践","按阶段选择资料","边学边整理一本笔记"]},
+ {name:"flashcard-generator",title:"看过的笔记,变成会答的卡片",category:"主动回忆",input:"笔记、文章或一个知识主题",output:"问答卡、挖空卡与 Anki CSV",prompt:"把这章笔记做成 20 张单知识点记忆卡,不要把答案写得太长。",note:"导入 Anki 后的复习排程由 Anki 管理。",source:github("JayRHa/agent-skills/blob/main/flashcard-generator/SKILL.md"),sample:["Q 这个概念的核心是什么?","A 用一句话解释","CLOZE 关键术语填空"]},
+ {name:"processon-mindmap-generator",title:"零散内容,画出知识骨架",category:"知识脑图",input:"文字、文档、网页或已有提纲",output:"可在 ProcessOn 编辑的结构化脑图",prompt:"把这章内容整理成三层脑图,区分核心概念、例子与易错点。",note:"集成 ProcessOn 在线服务,需按项目配置访问凭据。",source:github("processonai/processon-skills/blob/main/skills/processon-mindmap-generator/SKILL.md"),sample:["章节主题","├ 核心概念 / 关联","└ 例题 / 常见误区"]},
+ {name:"learn-anything",title:"卡住的概念,拆开再学",category:"概念理解",input:"主题、文档、URL 或不理解的概念",output:"模块化学习内容、概念卡与复习安排",prompt:"我总分不清相关和因果,请拆开讲,再用问题检查我是否理解。",note:"仓库名为 learn-anything,技能声明名称为 learn。",source:github("d-wwei/learn-anything/blob/main/SKILL.md"),sample:["概念是什么 / 为什么","常见误区 / 反例","用自己的话重新解释"]},
+ {name:"revision-tutor",title:"复习结束,再检查一次盲点",category:"理解检查",input:"讲义、笔记与自己的回答",output:"概念解释、理解检查与复习记录卡",prompt:"围绕这份讲义提问,记录我答不清楚的地方,最后给我复习卡。",note:"原项目面向 Claude;跨会话需携带复习记录。",source:github("yangcodingmaster/Revision-Tutor/blob/main/SKILL.md"),sample:["已经讲清楚的概念","仍然卡住的推导","下一轮需要复习的点"]},
+ ],
+ },
+ {
+ key:"learning-skills-teaching",slug:"learning-skills-teaching",number:"02",
+ title:"老师家长收藏|10 个出题、组卷与教学资料 Skills",
+ headline:["从一道错题,","到一套资料。"],subtitle:"10 个出题、排卷与制作课件的 Skills",
+ color:"#f2b994",audience:"教师 / 家长 / 教学内容创作者",
+ workflow:["收集题目,找到学习难点","生成练习,整理可打印试卷","检查命题,再准备讲评与课件"],
+ skills:[
+ {name:"k12-smart-teacher",title:"一道错题,多练几个角度",category:"错题巩固",input:"作业、错题与学段信息",output:"错因解释和基础、提高、挑战三层练习",prompt:"分析这道错题,先讲清楚错因,再给三档举一反三练习。",note:"Word 输出需要 python-docx;题目与答案需教师复核。",source:github("shellery1988/k12-smart-teacher/blob/main/SKILL.md"),sample:["① 找出错因","② 基础 / 提高 / 挑战","③ 讲解与巩固练习"]},
+ {name:"exam-paper",title:"从一份资料,整理一套练习",category:"练习生成",input:"教材、文档、图像或知识点材料",output:"试题、复习讲义与 Markdown / PDF",prompt:"围绕这一单元生成练习与讲义,附答案和解析,保持题目可追溯。",note:"排版与 PDF 生成依赖项目配套运行环境。",source:github("haitanghuaweimianTom/exam-paper-skill/blob/main/SKILL.md"),sample:["单元知识要点","基础练习 / 迁移练习","参考答案 / 解题提示"]},
+ {name:"wps-exam-paper",title:"题目有了,卷面也要清楚",category:"Word 试卷",input:"已有题目、分值与试卷要求",output:"带评分栏、题号、答题空间的 Word 试卷",prompt:"把这些题排成一份单元检测卷,留足答题空间并检查总分。",note:"来自 Bwkyd/wps-skills;运行依赖见原项目。",source:github("Bwkyd/wps-skills/blob/main/skills/wps-exam-paper/SKILL.md"),sample:["单元检测 / 姓名 / 班级","题号 一 二 三","得分 __ __ __"]},
+ {name:"video-exam-extractor",title:"散落在截图里的题,收成文档",category:"题目提取",input:"视频、题目截图或 PDF",output:"提取并组织好的 Word 题目文档",prompt:"把这些截图中的题目提取到 Word,保留题号并检查公式。",note:"识别结果中的公式、图形和选项需要人工核对。",source:github("delancy827/video-exam-extractor/blob/main/SKILL.md"),sample:["题目截图 / 视频画面","识别题干 / 选项 / 图示","结构化题目文档"]},
+ {name:"exam-paper-formater",title:"试卷、答案、答题卡,一起排",category:"打印排版",input:"已经准备好的题目与答案",output:"打印 PDF、独立答案卷与答题卡",prompt:"把题目排成 A4 试卷,答案单独成卷,再生成配套答题卡。",note:"仓库原名 formater;不把排版工具当作命题工具。",source:github("flyboat403/exam-paper-formater/blob/main/SKILL.md"),sample:["试卷 / 清楚的题目层级","答案 / 独立保存","答题卡 / 对应题号"]},
+ {name:"exam-evaluator",title:"印卷之前,先看命题结构",category:"命题评估",input:"试卷与相应教学或考试要求",output:"覆盖、结构和难度等分析的 HTML 报告",prompt:"检查这份试卷的知识覆盖和难度结构,指出需要调整的题目。",note:"分析属于辅助建议,不能代替教师审题。",source:github("flyboat403/exam-evaluator/blob/main/SKILL.md"),sample:["知识点是否覆盖?","题型与难度是否合理?","哪些题目需要调整?"]},
+ {name:"english-exam-studio",title:"英语试卷,变成讲评课件",category:"互动讲评",input:"英语试卷、答案与听力音频",output:"离线互动讲评页面与听力精听内容",prompt:"根据试卷和答案制作讲评页,把听力材料整理成精听环节。",note:"需准备对应试卷与音频,非通用题库。",source:github("hututu-ai/english-exam-studio/blob/main/SKILL.md"),sample:["阅读题 / 对应解析","听力片段 / 精听练习","课堂讲评 / 离线打开"]},
+ {name:"scriptorium",title:"一节课,配齐讲义和随堂测",category:"课程讲义",input:"计算机课程主题或课程仓库",output:"讲义、康奈尔学习单、测验与题库",prompt:"为这节 Python 循环课制作学生讲义、随堂测验和教师答案。",note:"主要面向计算机课程,内容需结合教学目标。",source:github("agiacalone/scriptorium/blob/main/SKILL.md"),sample:["课前 / 课程目标","课中 / 笔记与填空","课后 / 随堂测验"]},
+ {name:"ai-study-hero",title:"把每日学习,做成闯关任务",category:"学习任务卡",input:"小学生的主题、基础与学习安排",output:"学习计划、RPG 任务卡与交互知识库",prompt:"把一周的分数复习做成每日任务卡,每天安排少量练习。",note:"面向小学学习场景;计划需家长或教师调整。",source:github("mangoyc/ai-study-hero/blob/main/skill/SKILL.md"),sample:["今日任务 / 小目标","完成练习 / 检查理解","记录进度 / 下次复习"]},
+ {name:"wps-courseware",title:"课件也要顺着教学思路走",category:"教学 PPT",input:"学科、课题、年级与教学目标",output:"按教学结构组织的 PPT 课件",prompt:"为这节课准备导入、新授、练习和总结四个环节的课件。",note:"与 wps-exam-paper 同仓库,属于不同技能。",source:github("Bwkyd/wps-skills/blob/main/skills/wps-courseware/SKILL.md"),sample:["导入 → 建立问题","新授 → 解释与互动","练习 → 总结与迁移"]},
+ ],
+ },
+ {
+ key:"learning-skills-english",slug:"learning-skills-english",number:"03",
+ title:"英语资料自己做|10 个词汇、精读、口语与写作 Skills",
+ headline:["英语资料,","按自己来。"],subtitle:"10 个词汇、精读、口语与写作 Skills",
+ color:"#afcbe9",audience:"英语自学 / 四六级 / 雅思备考",
+ workflow:["从文章或主题收集表达","练阅读、口语与写作输出","根据反馈修改,安排下一轮练习"],
+ skills:[
+ {name:"ielts-vocabulary-builder",title:"背单词,也把搭配一起带上",category:"主题词汇",input:"雅思主题、已有表达与练习需求",output:"词汇、搭配、词族与应用练习",prompt:"按教育主题整理常用表达,每个词给出搭配和一条使用练习。",note:"与本篇第 2—6 项同属 jurgendn/agent-skills。",source:github("jurgendn/agent-skills/blob/main/skills/test-prep/ielts-vocabulary-builder/SKILL.md"),sample:["education / lifelong learning","搭配 → 语境 → 自己造句","近义表达 / 语域差别"]},
+ {name:"ielts-reading-strategies",title:"阅读错了,先看错在哪里",category:"阅读方法",input:"阅读文章、题目与自己的答案",output:"题型分析、解题策略与错因反馈",prompt:"分析这组判断题,解释 False 和 Not Given 的区别并定位依据。",note:"训练方法与反馈,不是当季真题库。",source:github("jurgendn/agent-skills/blob/main/skills/test-prep/ielts-reading-strategies/SKILL.md"),sample:["题干关键词 / 文中定位","True / False / Not Given","证据在哪里?"]},
+ {name:"ielts-speaking-coach",title:"口语练习,不只背一份答案",category:"口语反馈",input:"Part 1—3 题目与文字或转写回答",output:"模拟练习、示例与预估评分反馈",prompt:"用 Part 2 方式陪我练习,回答后从结构和表达给出具体建议。",note:"文字回答无法充分判断发音;评分仅为练习参考。",source:github("jurgendn/agent-skills/blob/main/skills/test-prep/ielts-speaking-coach/SKILL.md"),sample:["Cue card / 一个话题","先说经历,再补充细节","修改重复与含混表达"]},
+ {name:"ielts-writing-task1",title:"图表作文,先读懂再落笔",category:"图表写作",input:"学术类 Task 1 图表与作文",output:"图表分析、概述、示例与修改建议",prompt:"先找这张图的主要趋势和对比,再帮我检查概述段。",note:"需提供清晰图表或数据;预估分数非官方评分。",source:github("jurgendn/agent-skills/blob/main/skills/test-prep/ielts-writing-task1/SKILL.md"),sample:["主要趋势 / 显著对比","Overview / 先概括","Details / 再给数据"]},
+ {name:"ielts-writing-task2",title:"大作文,先把论证搭稳",category:"议论文写作",input:"Task 2 题目、提纲或已有作文",output:"审题、论证结构、范文与修改反馈",prompt:"检查这道题的立场和论证链,指出例子没有支撑观点的地方。",note:"示例用于学习,不能替代自己的练习。",source:github("jurgendn/agent-skills/blob/main/skills/test-prep/ielts-writing-task2/SKILL.md"),sample:["观点 → 为什么成立","例子 → 如何支撑","回应题目 → 收束论证"]},
+ {name:"ielts-learning-planner",title:"备考计划,从自己的时间出发",category:"学习规划",input:"目前基础、目标与每周可用时间",output:"每周安排、专项练习和阶段检查点",prompt:"我每周能学五小时,请按当前薄弱项制定可调整的四周计划。",note:"计划需要按实际进展更新,不保证提分。",source:github("jurgendn/agent-skills/blob/main/skills/test-prep/ielts-learning-planner/SKILL.md"),sample:["本周重点 / 时间分配","专项练习 / 回顾反馈","检查点 / 下一周调整"]},
+ {name:"english-intensive-reader",title:"一篇文章,左右对照着精读",category:"双栏精读",input:"英文文章、URL、PDF 或 Word",output:"原文与分析双栏笔记、生词和句型",prompt:"逐句精读这篇文章,解释长难句,把值得复用的表达单独列出。",note:"可按原项目保存词汇与导出 Anki CSV。",source:github("infometa/workbuddyskills/blob/main/skills/english-intensive-reader/SKILL.md"),sample:["Original text │ 句子结构","Key phrases │ 生词与搭配","Summary │ 我的复述"]},
+ {name:"cet-english-learner",title:"四六级,把读懂变成会用",category:"四六级学习",input:"英文文章或视频字幕",output:"词汇、逐段精读、复述引导与墨墨卡片",prompt:"用这篇文章练四六级,拆解重点句式,最后引导我自己复述。",note:"训练需要主动输出,生成资料不等于掌握。",source:github("moyu12-ae/cet-english-learner/blob/main/SKILL.md"),sample:["读懂段落 / 提取表达","拆解句式 / 联系语境","合上原文 / 自己复述"]},
+ {name:"shadowing-practice",title:"跟读,从一句话开始",category:"影子跟读",input:"适合自己水平的音频与文本",output:"分段、逐句到脱稿的跟读训练流程",prompt:"根据我的水平安排跟读步骤,先逐句练,再过渡到不看字幕。",note:"提供训练指导,不自带语音识别或自动发音评分。",source:github("nexaforgelab/english-skill/blob/main/shadowing-practice/SKILL.md"),sample:["听一句 → 停顿跟读","延长片段 → 接近同步","去掉字幕 → 记录卡点"]},
+ {name:"IELTS 写作批改助手",title:"作文改完,还要知道怎么改",category:"作文批改",input:"大小作文题目、图表与自己的作文",output:"分项反馈、修改建议与 Word 批改报告",prompt:"逐条解释这篇作文的问题,给出可执行的改法并生成 Word 报告。",note:"该项目包含两个批改 Skills;评分为辅助预估。",source:github("NiceGuo2000/ielts-writing-assistant"),sample:["原句 / 问题位置","影响 / 具体改法","修改示例 / 下次练习"]},
+ ],
+ },
+];
diff --git a/src/content/registry.tsx b/src/content/registry.tsx
index caaa782..47d9741 100644
--- a/src/content/registry.tsx
+++ b/src/content/registry.tsx
@@ -1,4 +1,9 @@
import type { ComponentType } from "react";
+import { LearningSeries } from "./learning-skills/Series";
// Register code-authored pages here, then reference their key in the posts API.
// No page or sample post is inserted into the user's library automatically.
-export const works: Record = {};
+export const works: Record = {
+ "learning-skills-notes": {kind:"article", Component:()=> },
+ "learning-skills-teaching": {kind:"article", Component:()=> },
+ "learning-skills-english": {kind:"article", Component:()=> },
+};
diff --git a/src/lib/library.ts b/src/lib/library.ts
index 8b44dd2..4e44922 100644
--- a/src/lib/library.ts
+++ b/src/lib/library.ts
@@ -7,7 +7,7 @@ const expand = `SELECT p.*,
COALESCE((SELECT jsonb_agg(ae ORDER BY ae.position) FROM (SELECT a.*,pa.position,pa.role,COALESCE((SELECT jsonb_agg(v.role) FROM asset_variants v WHERE v.asset_id=a.id),'[]') AS variants FROM post_assets pa JOIN assets a ON a.id=pa.asset_id WHERE pa.post_id=p.id) ae),'[]') AS assets,
COALESCE((SELECT jsonb_agg(t) FROM tags t JOIN post_tags pt ON pt.tag_id=t.id WHERE pt.post_id=p.id),'[]') AS tags FROM posts p`;
function serialize(posts: Post[], allTags: Tag[]): Post[] {
- return posts.map(p => ({...p, created_at: new Date(p.created_at).toISOString(), tags:p.tags.map(t=>allTags.find(a=>a.id===t.id)!).filter(Boolean)}));
+ return posts.map(p => ({...p, created_at: new Date(p.created_at).toISOString(), updated_at:new Date(p.updated_at).toISOString(), tags:p.tags.map(t=>allTags.find(a=>a.id===t.id)!).filter(Boolean)}));
}
export async function getPost(slug: string): Promise {
const result = await db().query(`${expand} WHERE p.slug=$1`,[slug]);
diff --git a/src/lib/model.ts b/src/lib/model.ts
index 6c5fb90..6bd4662 100644
--- a/src/lib/model.ts
+++ b/src/lib/model.ts
@@ -8,7 +8,7 @@ export const sections = [
export type PostKind = typeof sections[number]["kind"];
export type Asset = { id: string; kind: "image" | "video" | "audio" | "text"; filename: string | null; body: string | null; width: number | null; height: number | null; duration: number | null; has_audio: boolean | null; processing_status: string; variants: string[] };
export type Tag = { id: string; parent_id: string | null; name: string; slug: string; path: string };
-export type Post = { id: string; slug: string; kind: PostKind; title: string; created_at: string; page_key: string | null; cover_asset_id: string | null; assets: Asset[]; tags: Tag[] };
+export type Post = { id: string; slug: string; kind: PostKind; title: string; created_at: string; updated_at: string; page_key: string | null; cover_asset_id: string | null; assets: Asset[]; tags: Tag[] };
export type FeedData = { posts: Post[]; tags: Tag[]; contributions: { day: string; total: number }[]; nextCursor: string | null; unavailable?: boolean };
export function validSection(path: string) { return sections.find(s => s.path === path); }
export const mediaUrl = (id: string, variant?: string) => `/api/media/${id}${variant ? `?variant=${variant}` : ""}`;
diff --git a/src/lib/publish-learning-series.ts b/src/lib/publish-learning-series.ts
new file mode 100644
index 0000000..91ef3a4
--- /dev/null
+++ b/src/lib/publish-learning-series.ts
@@ -0,0 +1,45 @@
+import { createHash } from "node:crypto";
+import { readFile } from "node:fs/promises";
+import { chapters } from "../content/learning-skills/data";
+import { transaction } from "./db";
+import { putFile } from "./storage";
+
+function stableId(name: string) {
+ const bytes=createHash("sha256").update(`prompt:learning-series:${name}`).digest().subarray(0,16);
+ bytes[6]=(bytes[6]&15)|80;bytes[8]=(bytes[8]&63)|128;
+ const hex=bytes.toString("hex");return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`;
+}
+// Explicit editorial publication, never part of the generic storage bootstrap.
+// Re-running updates these three authored posts without duplicating posts or media.
+export async function publishLearningSeries() {
+ const path="public/learning-skills/cover.png";
+ const cover=await readFile(path);const digest=createHash("sha256").update(cover).digest("hex");
+ const storageKey=`editorial/learning-skills/${digest}.png`;
+ const coverId=stableId("cover");
+ await putFile(storageKey,path,"image/png");
+ return transaction(async client=>{
+ await client.query("SELECT pg_advisory_xact_lock(18131002)");
+ await client.query(`INSERT INTO assets(id,kind,filename,storage_key,mime_type,byte_size,sha256,width,height)
+ VALUES($1,'image','学习资料自己做-系列共用封面.png',$2,'image/png',$3,$4,1080,1440)
+ ON CONFLICT(id) DO UPDATE SET storage_key=excluded.storage_key,byte_size=excluded.byte_size,sha256=excluded.sha256`,[coverId,storageKey,cover.length,digest]);
+ const root=(await client.query(`INSERT INTO tags(id,parent_id,slug,name) VALUES($1,NULL,'learning-skills','学习工具')
+ ON CONFLICT(parent_id,slug) DO UPDATE SET name=excluded.name RETURNING id`,[stableId("tag-root")])).rows[0].id;
+ const published:string[]=[];
+ for(const chapter of chapters){
+ const child=(await client.query(`INSERT INTO tags(id,parent_id,slug,name) VALUES($1,$2,$3,$4)
+ ON CONFLICT(parent_id,slug) DO UPDATE SET name=excluded.name RETURNING id`,[stableId(`tag-${chapter.key}`),root,chapter.number==="01"?"notes":chapter.number==="02"?"teaching":"english",chapter.number==="01"?"笔记复习":chapter.number==="02"?"教学资料":"英语学习"])).rows[0].id;
+ const result=await client.query(`INSERT INTO posts(id,slug,kind,title,page_key,cover_asset_id) VALUES($1,$2,'article',$3,$4,$5)
+ ON CONFLICT(slug) DO UPDATE SET title=excluded.title,cover_asset_id=excluded.cover_asset_id,updated_at=now()
+ WHERE posts.page_key=excluded.page_key AND posts.kind='article' RETURNING id`,[stableId(chapter.key),chapter.slug,chapter.title,chapter.key,coverId]);
+ if(!result.rows[0])throw new Error(`Publication slug is already used by another post: ${chapter.slug}`);
+ const postId=result.rows[0].id;const captionId=stableId(`caption-${chapter.key}`);
+ const caption=`学习资料自己做 · 第 ${chapter.number} 辑\n${chapter.subtitle}。三篇共用系列封面,每篇 8 页,介绍 10 项工具。\n\n${chapter.skills.map((skill,i)=>`${String(i+1).padStart(2,"0")} ${skill.name} — ${skill.title}`).join("\n")}\n\n根据 GitHub 项目说明整理,尚未逐项安装实测。内页小样为排版示意;可在末页访问全部来源。`;
+ await client.query("INSERT INTO assets(id,kind,body) VALUES($1,'text',$2) ON CONFLICT(id) DO UPDATE SET body=excluded.body",[captionId,caption]);
+ await client.query(`INSERT INTO post_assets(post_id,asset_id,position,role) VALUES($1,$2,0,'content'),($1,$3,1,'caption')
+ ON CONFLICT(post_id,position) DO UPDATE SET asset_id=excluded.asset_id,role=excluded.role`,[postId,coverId,captionId]);
+ await client.query("INSERT INTO post_tags(post_id,tag_id) VALUES($1,$2) ON CONFLICT DO NOTHING",[postId,child]);
+ published.push(chapter.slug);
+ }
+ return {published,coverId};
+ });
+}
diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx
index 160eac1..27419c8 100644
--- a/src/pages/_app.tsx
+++ b/src/pages/_app.tsx
@@ -1,5 +1,6 @@
import type { AppProps } from "next/app";
import "@/styles/globals.css";
+import "@/styles/learning-series.css";
export default function App({ Component, pageProps }: AppProps) {
return ;
diff --git a/src/styles/learning-series.css b/src/styles/learning-series.css
new file mode 100644
index 0000000..c3c7ec8
--- /dev/null
+++ b/src/styles/learning-series.css
@@ -0,0 +1,4 @@
+.learning-reader{--learning-ink:#282b23;--learning-paper:#fbfaf5;background:#eeeee9;color:var(--learning-ink);padding:25px 28px;border-radius:20px}.learning-reader button{cursor:pointer}.learning-reader button:focus-visible,.learning-reader a:focus-visible{outline:3px solid #709443;outline-offset:4px}.learning-reader-bar{display:flex;align-items:center;justify-content:space-between;gap:20px;max-width:780px;margin:0 auto 24px}.learning-reader-bar>div{display:grid;gap:9px}.learning-reader-badge{font-size:11px;letter-spacing:1px;color:#737869}.learning-reader-bar strong{font-size:15px;font-weight:550}.learning-reader-bar>button{display:flex;align-items:center;gap:7px;font-size:12px;white-space:nowrap;background:#fff;padding:10px 13px;border-radius:25px;border:1px solid #dfdfd7}.learning-boards{max-width:720px;margin:auto;outline-offset:6px;container-type:inline-size}.learning-boards.is-continuous{display:grid;gap:24px}.learning-cover,.learning-sheet{width:100%;aspect-ratio:3/4;box-shadow:0 5px 22px #343b2810;overflow:hidden}.learning-cover img{display:block;width:100%;height:100%;object-fit:contain}.learning-sheet{position:relative;background:var(--learning-paper);padding:5.6cqw 6.8cqw;color:var(--learning-ink);font-size:1.8cqw;line-height:1.5}.learning-sheet-top{display:flex;justify-content:space-between;align-items:center;letter-spacing:.08em;font-size:1.25cqw;color:#707665;padding-bottom:2.6cqw;border-bottom:1px solid #dcded1}.learning-sheet-top>span:last-child{font-family:Arial,sans-serif;font-size:1.15cqw}.learning-sheet-footer{position:absolute;bottom:3.3cqw;left:6.8cqw;right:6.8cqw;border-top:1px solid #dcded1;padding-top:1.9cqw;display:flex;justify-content:space-between;font-size:1.2cqw;color:#787c70}.learning-sheet-footer b{margin-left:2cqw;color:#34392b;font-weight:600}.learning-intro{padding-top:4.5cqw;margin-bottom:3.6cqw}.learning-chapter{font-size:1.5cqw;letter-spacing:.08em;color:#727963}.learning-intro h2,.learning-ending h2{font-size:5.8cqw;line-height:1.28;font-weight:800;letter-spacing:-.04em;margin:2.6cqw 0 2cqw}.learning-sheet mark{color:inherit;background:linear-gradient(transparent 45%,var(--learning-accent) 45%,var(--learning-accent) 93%,transparent 93%);padding:0 .15em}.learning-intro>p,.learning-ending>p{color:#676e5f;font-size:1.85cqw;margin:0}.learning-audience{display:inline-block;font-size:1.35cqw;letter-spacing:.02em;margin-top:2.2cqw;padding:.7cqw 1.4cqw;border:1px solid #d6d9cb;border-radius:20px}.learning-index{display:grid;grid-template-columns:1fr;gap:0}.learning-index button{display:flex;text-align:left;align-items:center;gap:1.6cqw;padding:1.35cqw 0;border-bottom:1px solid #e2e4d9;background:transparent}.learning-index button>span{font-family:Georgia,serif;font-size:3.2cqw;min-width:4cqw;color:#84916a;font-style:italic}.learning-index button>div{flex:1;min-width:0}.learning-index strong{font-size:2cqw;font-weight:650;display:block}.learning-index small{font-size:1.2cqw;color:#858c79;display:block;line-height:1.55}.learning-index svg{width:2.3cqw;flex-shrink:0;color:#7e886c}.learning-index-note{display:flex;align-items:center;gap:1cqw;font-size:1.4cqw;color:#727b63;margin-top:2.4cqw}.learning-index-note svg{width:2cqw}.learning-pair-title{padding:3.7cqw 0 2.7cqw}.learning-pair-title>span{font-size:1.3cqw;color:#7c846d}.learning-pair-title h2{margin:.6cqw 0 0;font-size:3.5cqw;font-weight:750;letter-spacing:-.035em}.learning-skill{padding:2.6cqw 0;border-top:1px solid #d9ddcf}.learning-skill-heading{display:flex;gap:1.9cqw;align-items:center}.learning-number{font-family:Georgia,serif;font-size:5.3cqw;font-style:italic;line-height:1;color:#343b2a;min-width:6.7cqw;position:relative;z-index:0}.learning-number:after{content:"";position:absolute;left:0;right:-.2cqw;height:2cqw;bottom:.1cqw;z-index:-1;background:var(--learning-accent);transform:rotate(-5deg)}.learning-category{font-size:1.2cqw;color:#778168;letter-spacing:.08em}.learning-skill h3{font-size:2.7cqw;line-height:1.4;font-weight:750;letter-spacing:-.025em;margin:.3cqw 0 0}.learning-skill-name{margin:1.15cqw 0 1.55cqw 8.6cqw;display:flex;gap:.7cqw;align-items:center;width:fit-content;max-width:calc(100% - 8.6cqw);font-size:1.25cqw;font-family:Arial,sans-serif;color:#717b62;overflow-wrap:anywhere}.learning-skill-name svg{width:1.4cqw;height:1.4cqw;flex-shrink:0}.learning-io{display:grid;gap:.75cqw;margin-bottom:1.7cqw}.learning-io p{display:flex;align-items:baseline;gap:1.2cqw;margin:0;font-size:1.7cqw;line-height:1.6}.learning-io b{font-size:1.35cqw;flex-shrink:0;font-weight:600;padding:.2cqw .8cqw;background:#e8eadd;border-radius:4px;color:#626c53}.learning-sample{border:1px solid #dce0d1;border-radius:.9cqw;padding:1.2cqw 1.9cqw;background:#fffefa;position:relative;margin-bottom:1.7cqw}.sample-label{font-size:1.05cqw;display:block;color:#939987;margin-bottom:.8cqw}.learning-sample>div{display:flex;align-items:center;gap:1.2cqw;font-size:1.5cqw;padding:.4cqw 0}.learning-sample>div>span{font-family:Arial,sans-serif;font-size:1cqw;color:#9ca48e}.learning-sample p{margin:0;flex:1}.learning-sample svg{color:#849c59;width:1.6cqw;height:1.6cqw}.sample-1{background:repeating-linear-gradient(#fffefa 0,#fffefa 3.05cqw,#e8eade 3.05cqw,#e8eade 3.15cqw)}.sample-2>div:first-of-type p{background:linear-gradient(transparent 55%,var(--learning-accent) 55%);width:fit-content;flex:initial}.learning-prompt{display:flex;gap:1.2cqw;align-items:baseline}.learning-prompt>span{font-size:1.2cqw;white-space:nowrap;font-weight:600;color:#63714f}.learning-prompt p{font-size:1.55cqw;line-height:1.8;margin:0}.learning-caveat{font-size:1.2cqw;line-height:1.6;color:#858c79;margin:1.1cqw 0 0}.learning-ending{padding:5cqw 0 3cqw}.learning-ending h2{font-size:5.2cqw}.learning-workflow{list-style:none;padding:0;margin:1cqw 0 3.5cqw;display:grid;gap:1cqw}.learning-workflow li{position:relative;display:flex;align-items:center;gap:1.6cqw;padding:1.4cqw 1.8cqw;background:#ecefdf;border-radius:.7cqw;font-size:1.9cqw}.learning-workflow b{font-family:Georgia,serif;font-size:2.6cqw;font-style:italic}.learning-workflow svg{margin-left:auto;width:2cqw;height:2cqw;color:#8b9579}.learning-source-box{border:1px solid #d5dac8;padding:2.5cqw;border-radius:.8cqw;background:#fffefa}.learning-source-box h3{display:flex;align-items:center;justify-content:space-between;font-size:2.3cqw;margin:0 0 1.6cqw;font-weight:700}.learning-source-box h3 svg{width:2.4cqw;height:2.4cqw}.learning-source-box>div{display:grid;grid-template-columns:1fr 1fr;column-gap:2.5cqw}.learning-source-box a{display:flex;align-items:center;gap:.9cqw;font-family:Arial,sans-serif;font-size:1.25cqw;padding:1.1cqw 0;border-bottom:1px solid #e8ebdf;overflow-wrap:anywhere;min-width:0}.learning-source-box a>span{font-size:1cqw;color:#98a18b}.learning-source-box a svg{margin-left:auto;flex-shrink:0;width:1.3cqw;height:1.3cqw}.learning-source-box p{font-size:1.2cqw;line-height:1.8;color:#87917a;margin:1.8cqw 0 0}.learning-series-reminder{display:grid;gap:.9cqw;margin-top:3cqw;font-size:1.35cqw}.learning-series-reminder>b{color:#7e886d;font-size:1.2cqw}.learning-series-reminder a{display:flex;align-items:center;gap:.8cqw}.learning-series-reminder svg{width:1.4cqw;height:1.4cqw}.learning-pagination{display:flex;align-items:center;justify-content:center;gap:30px;margin:22px 0 18px}.learning-pagination>button{width:39px;height:39px;display:grid;place-items:center;border-radius:50%;background:#fff;border:1px solid #dedfd5}.learning-pagination>button:disabled{opacity:.35;cursor:default}.learning-pagination>span{min-width:105px;text-align:center;font-size:13px;font-variant-numeric:tabular-nums}.learning-pagination small{display:block;font-size:10px;margin-top:5px;color:#7b826e}.learning-page-tabs{max-width:720px;margin:0 auto;display:flex;gap:7px;overflow-x:auto;padding:5px 2px 12px}.learning-page-tabs>button{flex:1;min-width:65px;display:grid;gap:5px;border:1px solid #dcdfd2;border-radius:9px;background:#f9faf5;padding:10px 8px;font-size:10px;color:#7e866f;white-space:nowrap}.learning-page-tabs>button>span{font-size:12px;font-family:Georgia,serif;font-style:italic}.learning-page-tabs>button.selected{background:#31372a;color:white;border-color:#31372a}.learning-related{max-width:720px;margin:22px auto 0;border-top:1px solid #d8dcce;padding-top:20px}.learning-related>span{font-size:11px;color:#7a836a}.learning-related>div{display:grid;gap:8px;margin-top:12px}.learning-related a{display:flex;align-items:center;gap:10px;padding:10px 0;font-size:12px;color:#697358}.learning-related a>b{font-family:Georgia,serif;font-style:italic;font-size:19px;color:#8d9b76}.learning-related a>svg{margin-left:auto;flex-shrink:0}.learning-related a.selected{color:#303b22;font-weight:600}
+@media(max-width:650px){.learning-reader{padding:16px 10px;border-radius:14px}.learning-reader-bar{gap:10px;margin-bottom:16px;padding:0 3px}.learning-reader-bar strong{font-size:12px;line-height:1.6}.learning-reader-bar>button{font-size:11px;padding:9px 10px}.learning-reader-badge{font-size:10px}.learning-related a{font-size:11px;line-height:1.7}.learning-pagination{margin-top:17px}.learning-sheet{font-synthesis:none}}
+.detail-layout.coded-detail{grid-template-columns:minmax(0,1fr);max-width:1050px;border:0;box-shadow:none;overflow:visible}.coded-detail .detail-media{background:transparent}.coded-detail .detail-info{margin:28px auto 0;max-width:780px;width:100%;border-top:1px solid #e5e7df}.coded-detail .detail-info>.eyebrow{display:none}
+.learning-index button{padding:1cqw 0}
diff --git a/tests/integration.test.ts b/tests/integration.test.ts
index 4ea8d14..d7c5ff6 100644
--- a/tests/integration.test.ts
+++ b/tests/integration.test.ts
@@ -11,6 +11,7 @@ import {randomUUID} from 'node:crypto';
import sharp from 'sharp';
import {db} from '../src/lib/db';
import {probe,run} from '../src/lib/probe';
+import {publishLearningSeries} from '../src/lib/publish-learning-series';
test('real PostgreSQL + S3 library, sharing, taxonomy, uploads, range and video separation', {timeout:180000}, async()=>{
assert.equal(process.env.PROMPT_INTEGRATION_TEST,'1','Run only against an explicitly isolated test database');
@@ -46,6 +47,16 @@ test('real PostgreSQL + S3 library, sharing, taxonomy, uploads, range and video
}
assert.equal((await db().query("SELECT count(*)::integer AS n FROM assets WHERE kind='audio'")).rows[0].n,0,'Bound soundtracks are variants, not independent audio assets');
const audio=join(dir,'audio.wav');await run('ffmpeg',['-y','-v','error','-f','lavfi','-i','sine=frequency=220','-t','0.2',audio]);await upload('audio',[{data:await readFile(audio),name:'audio.wav',mime:'audio/wav'}]);assert.equal((await request('/api/feed?section=audio')).posts.length,1);
+ const published=await publishLearningSeries();await publishLearningSeries();
+ const articles=(await request('/api/feed?section=articles')).posts;
+ assert.equal(articles.length,3,'Repeated publication must not duplicate posts');
+ assert.equal(new Set(articles.map((p:{cover_asset_id:string})=>p.cover_asset_id)).size,1,'Three posts share one independent image asset');
+ assert.equal((await db().query('SELECT count(*)::integer AS n FROM post_assets WHERE asset_id=$1',[published.coverId])).rows[0].n,3);
+ for(const slug of published.published){const response=await fetch(base+'/p/'+slug);assert.equal(response.status,200);assert.ok((await response.text()).includes('学习资料'))}
+ assert.equal((await request('/api/feed?section=articles&path=learning-skills')).posts.length,3);
+ const coverResponse=await fetch(base+'/api/media/'+published.coverId);assert.equal(coverResponse.headers.get('content-type'),'image/png');
+ const coverInfo=await sharp(Buffer.from(await coverResponse.arrayBuffer())).metadata();assert.equal(coverInfo.width,1080);assert.equal(coverInfo.height,1440);
+ assert.equal((await request('/api/feed')).contributions.reduce((n:number,c:{total:number})=>n+c.total,0),5,'Editorial publication does not invent contribution events');
}finally{
for(const child of [worker,server])if(child&&child.exitCode===null&&child.signalCode===null){const stopped=once(child,'exit');child.kill('SIGTERM');await stopped;}
await rm(dir,{recursive:true,force:true});await db().end();
diff --git a/tests/learning-series.test.ts b/tests/learning-series.test.ts
new file mode 100644
index 0000000..f1d0b26
--- /dev/null
+++ b/tests/learning-series.test.ts
@@ -0,0 +1,16 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { chapters, seriesCover } from "../src/content/learning-skills/data";
+
+test("the authored series has three distinct chapters of ten sourced skills and one cover",()=>{
+ assert.equal(chapters.length,3);assert.equal(new Set(chapters.map(c=>c.key)).size,3);
+ assert.equal(new Set(chapters.map(c=>c.slug)).size,3);assert.equal(seriesCover,"/learning-skills/cover.png");
+ for(const chapter of chapters){
+ assert.equal(chapter.skills.length,10);assert.equal(new Set(chapter.skills.map(s=>s.source)).size,10);
+ for(const skill of chapter.skills){
+ const source=new URL(skill.source);assert.equal(source.protocol,"https:");assert.equal(source.hostname,"github.com");
+ assert.ok(skill.title&&skill.input&&skill.output&&skill.prompt&&skill.note);
+ assert.equal(skill.sample.length,3);
+ }
+ }
+});