Publish three learning skill articles with one shared Xiaohongshu-style cover
PROMPT Docker CI and deployment / verify-and-deploy (push) Has been cancelled
PROMPT Docker CI and deployment / verify-and-deploy (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
## 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.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
|
||||
## 交付与视觉验收
|
||||
|
||||
- 用户指定服务器为唯一运行与验收入口:http://8.211.154.134:18131/。
|
||||
- 页面改动必须先 push,等待 ces-control 的 CI/CD 成功部署,再直接访问服务器检查效果。
|
||||
- 不启动本地网页预览,不使用 localhost / 127.0.0.1 页面进行视觉验收,也不把本地预览链接交付给用户。
|
||||
- 类型检查、单元测试等不提供网页预览的检查可以本地执行;完整运行检查由 Docker CI 完成。
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 作为展示或交付入口。
|
||||
+3
-2
@@ -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"
|
||||
},
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 204 KiB |
@@ -0,0 +1,26 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1440" viewBox="0 0 1080 1440">
|
||||
<defs>
|
||||
<pattern id="grid" width="36" height="36" patternUnits="userSpaceOnUse"><path d="M36 0H0V36" fill="none" stroke="#deddd2" stroke-width="1"/></pattern>
|
||||
<filter id="shadow" x="-30%" y="-30%" width="160%" height="170%"><feDropShadow dx="0" dy="15" stdDeviation="14" flood-color="#413d2e" flood-opacity=".1"/></filter>
|
||||
</defs>
|
||||
<rect width="1080" height="1440" fill="#f8f7f0"/><rect width="1080" height="1440" fill="url(#grid)" opacity=".45"/>
|
||||
<g font-family="PingFang SC, Noto Sans CJK SC, sans-serif" fill="#24251f">
|
||||
<rect x="74" y="72" width="238" height="52" rx="26" fill="#25271f"/><text x="193" y="107" text-anchor="middle" font-size="25" font-weight="600" fill="#fff">PROMPT 收藏夹</text>
|
||||
<text x="1005" y="106" text-anchor="end" font-family="Arial,sans-serif" font-size="24" letter-spacing="3">THE LEARNING EDIT</text>
|
||||
<text x="70" y="278" font-size="112" font-weight="800" letter-spacing="-5">学习资料,</text>
|
||||
<path d="M70 325Q350 305 702 324L710 430Q353 422 69 438Z" fill="#c3dc81"/>
|
||||
<text x="70" y="424" font-size="144" font-weight="800" letter-spacing="-6">自己做。</text>
|
||||
<text x="77" y="507" font-size="33" fill="#626458">笔记整理 / 练习试卷 / 英语学习</text>
|
||||
<g transform="translate(787 316) rotate(10)"><circle cx="70" cy="70" r="83" fill="#f8f7f0" stroke="#303429" stroke-width="2" stroke-dasharray="3 5"/><text x="70" y="66" text-anchor="middle" font-family="Arial,sans-serif" font-size="52" font-weight="800">30</text><text x="70" y="101" text-anchor="middle" font-size="22">个 AI Skills</text></g>
|
||||
<path d="M925 524q-20 47-68 68m30-1-33 6 10-30" fill="none" stroke="#52584c" stroke-width="4" stroke-linecap="round"/>
|
||||
<g filter="url(#shadow)">
|
||||
<g transform="translate(76 640) rotate(-7 180 245)"><rect width="362" height="490" rx="7" fill="#fff" stroke="#d5d8cb" stroke-width="2"/><rect x="0" y="0" width="362" height="70" fill="#dbe8b6"/><text x="28" y="46" font-size="28" font-weight="700">01 / 复习笔记</text><text x="28" y="122" font-size="26" font-weight="700">把知识梳理清楚</text><g stroke="#d8dcd0" stroke-width="2"><path d="M28 160h306M28 200h306M28 240h306M28 280h306M28 320h306M28 360h306M28 400h306M28 440h306"/></g><path d="M35 193h226M35 313h156" stroke="#c3dc81" stroke-width="21" opacity=".75"/><path d="M38 158h210m-210 80h262m-262 40h218m-218 80h253m-253 40h190" stroke="#7f8576" stroke-width="7" stroke-linecap="round"/></g>
|
||||
<g transform="translate(350 588) rotate(5 180 255)"><rect width="362" height="510" rx="7" fill="#fff" stroke="#dccfc6" stroke-width="2"/><rect width="362" height="70" fill="#f5cfb6"/><text x="28" y="46" font-size="28" font-weight="700">02 / 教学资料</text><text x="30" y="122" font-size="27" font-weight="700">单元练习与讲评</text><rect x="28" y="151" width="306" height="54" fill="none" stroke="#b7b3a8"/><path d="M104 151v54m76-54v54m76-54v54" stroke="#b7b3a8"/><text x="41" y="185" font-size="18">题号</text><text x="129" y="185" font-size="18">一</text><text x="205" y="185" font-size="18">二</text><text x="282" y="185" font-size="18">三</text><text x="28" y="253" font-size="23" font-weight="600">一、先理解,再练习。</text><g stroke="#bcb9ad" stroke-width="3"><path d="M32 292h290m-290 41h290m-290 41h220M32 449h290"/></g><circle cx="290" cy="412" r="25" fill="none" stroke="#df8b65" stroke-width="3"/><path d="m278 410 9 9 19-22" fill="none" stroke="#df8b65" stroke-width="4"/></g>
|
||||
<g transform="translate(650 675) rotate(11 180 240)"><rect width="350" height="467" rx="7" fill="#fff" stroke="#c8d3dd" stroke-width="2"/><rect width="350" height="70" fill="#c9ddf1"/><text x="26" y="46" font-size="28" font-weight="700">03 / 英语学习</text><text x="27" y="121" font-family="Georgia,serif" font-size="33" font-weight="700">Read. Learn. Use.</text><rect x="25" y="157" width="159" height="27" fill="#dbe8b6"/><text x="28" y="179" font-family="Georgia,serif" font-size="24">lifelong learning</text><text x="28" y="226" font-size="22" fill="#606d7a">语境 / 搭配 / 自己造句</text><path d="M29 267h292M29 305h292M29 343h292M29 381h235" stroke="#b8c3cb" stroke-width="6" stroke-linecap="round"/><path d="M29 415h146" stroke="#afcbe9" stroke-width="18"/></g>
|
||||
</g>
|
||||
<g transform="translate(134 569) rotate(-8)"><rect width="143" height="42" fill="#e2dab7" opacity=".75"/></g><g transform="translate(788 618) rotate(14)"><rect width="135" height="42" fill="#e2dab7" opacity=".75"/></g>
|
||||
<rect x="74" y="1225" width="932" height="2" fill="#d4d6c9"/>
|
||||
<text x="76" y="1301" font-size="37" font-weight="700">三篇合集,每篇 10 个。</text><text x="77" y="1353" font-size="24" fill="#767a6d">从 GitHub 出发,把资料做成适合自己的样子。</text>
|
||||
<text x="1005" y="1349" text-anchor="end" font-family="Arial,sans-serif" font-size="23" fill="#767a6d">SAVE / LEARN / MAKE</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -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
|
||||
|
||||
@@ -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());
|
||||
@@ -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 <footer className="learning-sheet-footer"><span>PROMPT / 学习资料自己做</span><span>第 {chapter.number} 辑 <b>{String(page+1).padStart(2,"0")} / 08</b></span></footer>;
|
||||
}
|
||||
function Sheet({chapter,page,children,className=""}:{chapter:Chapter;page:number;children:ReactNode;className?:string}) {
|
||||
return <section className={`learning-sheet ${className}`} aria-label={`第 ${page+1} 页`}><div className="learning-sheet-top"><span>学习资料自己做 / VOL. {chapter.number}</span><span>THE LEARNING EDIT</span></div>{children}<Footer chapter={chapter} page={page}/></section>;
|
||||
}
|
||||
function Sample({skill,index}:{skill:Skill;index:number}) {
|
||||
return <div className={`learning-sample sample-${index%3}`} aria-label="排版示意,不是工具实测结果"><span className="sample-label">成品形态 · 排版示意</span>{skill.sample.map((line,i)=><div key={line}><span>{String(i+1).padStart(2,"0")}</span><p>{line}</p>{i===0&&<Check size={15}/>}</div>)}</div>;
|
||||
}
|
||||
function SkillCard({skill,index}:{skill:Skill;index:number}) {
|
||||
return <article className="learning-skill"><div className="learning-skill-heading"><span className="learning-number">{String(index+1).padStart(2,"0")}</span><div><span className="learning-category">{skill.category}</span><h3>{skill.title}</h3></div></div><a className="learning-skill-name" href={skill.source} target="_blank" rel="noreferrer">{skill.name}<ArrowUpRight size={13}/></a><div className="learning-io"><p><b>给它</b><span>{skill.input}</span></p><p><b>得到</b><span>{skill.output}</span></p></div><Sample skill={skill} index={index}/><div className="learning-prompt"><span>可以这样问</span><p>“{skill.prompt}”</p></div><p className="learning-caveat">{skill.note}</p></article>;
|
||||
}
|
||||
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[]=[
|
||||
<div className="learning-cover" key="cover"><img src={seriesCover} alt="学习资料,自己做。笔记整理、练习试卷、英语学习,30 个 AI Skills 收藏清单。" width={1080} height={1440} fetchPriority="high"/></div>,
|
||||
<Sheet chapter={chapter} page={1} key="index"><div className="learning-intro"><span className="learning-chapter">第 {chapter.number} 辑 · 10 个 Skills</span><h2>{chapter.headline[0]}<br/><mark>{chapter.headline[1]}</mark></h2><p>{chapter.subtitle}</p><span className="learning-audience">{chapter.audience}</span></div><div className="learning-index">{chapter.skills.map((skill,i)=><button key={skill.name} onClick={()=>{setContinuous(false);setPage(2+Math.floor(i/2));}}><span>{String(i+1).padStart(2,"0")}</span><div><strong>{skill.title}</strong><small>{skill.name}</small></div><ArrowUpRight size={18}/></button>)}</div><p className="learning-index-note"><BookOpen size={16}/>按需求挑选,不必一次装完。点击条目查看介绍。</p></Sheet>,
|
||||
...Array.from({length:5},(_,i)=><Sheet chapter={chapter} page={i+2} key={`skills-${i}`} className="learning-pair"><header className="learning-pair-title"><span>把工具用在具体的事情上</span><h2>{["先整理,再开始。","把资料做成自己的。","让学习多一条路径。","把看过变成理解。","留下一份能复用的资料。"][i]}</h2></header>{chapter.skills.slice(i*2,i*2+2).map((skill,j)=><SkillCard key={skill.name} skill={skill} index={i*2+j}/>)}</Sheet>),
|
||||
<Sheet chapter={chapter} page={7} key="workflow"><div className="learning-ending"><span className="learning-chapter">收藏之后,试着做一份。</span><h2>先选一个需求,<br/><mark>再串成工作流。</mark></h2><p>不求用遍所有工具,先完成一份自己的资料。</p></div><ol className="learning-workflow">{chapter.workflow.map((line,i)=><li key={line}><b>{i+1}</b><span>{line}</span>{i<2&&<ArrowDown size={19}/>}</li>)}</ol><div className="learning-source-box"><h3>10 个来源,放在这里了。<ArrowUpRight size={20}/></h3><div>{chapter.skills.map((skill,i)=><a key={skill.name} href={skill.source} target="_blank" rel="noreferrer"><span>{String(i+1).padStart(2,"0")}</span>{skill.name}<ArrowUpRight size={12}/></a>)}</div><p>依据 GitHub 项目说明整理 · 核对于 {sourceChecked}<br/>尚未逐项安装实测;文中小样为排版示意。</p></div><div className="learning-series-reminder"><b>同系列还有</b>{chapters.filter(c=>c.key!==chapter.key).map(c=><Link key={c.key} href={`/p/${c.slug}`}>{c.number} / {c.subtitle}<ArrowUpRight size={14}/></Link>)}</div></Sheet>,
|
||||
];
|
||||
return <div className="learning-reader" style={{"--learning-accent":chapter.color} as CSSProperties}>
|
||||
<div className="learning-reader-bar"><div><span className="learning-reader-badge">系列 {chapter.number} / 03</span><strong>{chapter.subtitle}</strong></div><button onClick={()=>setContinuous(v=>!v)} aria-pressed={continuous}><List size={16}/>{continuous?"分页阅读":"连续阅读"}</button></div>
|
||||
<div className={`learning-boards ${continuous?"is-continuous":""}`} onKeyDown={e=>{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]}</div>
|
||||
{!continuous&&<nav className="learning-pagination" aria-label="图文翻页"><button aria-label="上一页" disabled={page===0} onClick={()=>setPage(p=>p-1)}><ChevronLeft size={20}/></button><span aria-live="polite">{page+1} / 8 <small>{labels[page]}</small></span><button aria-label="下一页" disabled={page===7} onClick={()=>setPage(p=>p+1)}><ChevronRight size={20}/></button></nav>}
|
||||
<div className="learning-page-tabs" aria-label="选择图文页">{labels.map((label,i)=><button key={label} className={!continuous&&page===i?"selected":""} aria-current={!continuous&&page===i?"page":undefined} onClick={()=>{setContinuous(false);setPage(i);}}><span>{String(i+1).padStart(2,"0")}</span>{label}</button>)}</div>
|
||||
<div className="learning-related"><span>学习资料自己做 · 系列三篇</span><div>{chapters.map(c=><Link className={c.key===chapter.key?"selected":""} key={c.key} href={`/p/${c.slug}`}><b>{c.number}</b>{c.key===chapter.key?"正在阅读 · ":""}{c.subtitle}<ArrowUpRight size={15}/></Link>)}</div></div>
|
||||
</div>;
|
||||
}
|
||||
@@ -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:["原句 / 问题位置","影响 / 具体改法","修改示例 / 下次练习"]},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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<string, { kind: "article" | "motion"; Component: ComponentType }> = {};
|
||||
export const works: Record<string, { kind: "article" | "motion"; Component: ComponentType }> = {
|
||||
"learning-skills-notes": {kind:"article", Component:()=> <LearningSeries chapterKey="learning-skills-notes"/>},
|
||||
"learning-skills-teaching": {kind:"article", Component:()=> <LearningSeries chapterKey="learning-skills-teaching"/>},
|
||||
"learning-skills-english": {kind:"article", Component:()=> <LearningSeries chapterKey="learning-skills-english"/>},
|
||||
};
|
||||
|
||||
+1
-1
@@ -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<Post | null> {
|
||||
const result = await db().query(`${expand} WHERE p.slug=$1`,[slug]);
|
||||
|
||||
+1
-1
@@ -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}` : ""}`;
|
||||
|
||||
@@ -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};
|
||||
});
|
||||
}
|
||||
@@ -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 <Component {...pageProps} />;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user