feat(cms): 首页四项可配 + 开源页项目可见性 + GitHub README 开篇

- 首页:subtitle(markdown 短文本)|latestPhotos 0-6(0 隐藏)|
  perTrackLimit 4-8|sections{tracks,photos,heatmap,projects} 开关,
  访问器 homeConfig() 夹取越界值,字段级回落默认(零配置=原形态)
- 开源页项目可见性:mode all|only|exclude + include[]/exclude[] +
  sort pinned-first|stars|updated + perSource both|github|gitea;
  projects.ts 加 LoadProjectsOptions + arrange()(来源→可见性→排序)
- 开源页开篇支持 GitHub README:intro_from off|github-readme,构建期
  取 wait4xx/wait4xx README(token 复用宿主 0600 文件)→ 缓存
  content/.github-readme.md 入仓 → renderMarkdown;失败逐级回落
  (缓存 → yaml blurb)。实测取到 3117 字符,现状仍 off 待用户开启
- 修既有 bug:orderPinned 内硬 slice(0,6) 把开源页 limit=99 也截到 6 条
  → 移除,开源页恢复全量 16 条
- 文档:写作指南加首页/项目墙两张字段表(含 YAML 1.1 `off` 需加引号坑、
  README 行为与外部徽章风险说明)

车道:op-gitea(3/3);收编复验:构建 48 页 + E2E 37/37 + 开源页 16 条 +
首页默认形态(2 轨/热力图/项目墙)无回归

验证: node v26.13.0 构建 48 页 exit 0;E2E 37/37 PASS;playwright 实测

Spec: none (首页与开源页后台扩展,车道级交付)

Co-authored-by: GLM-5.3
This commit is contained in:
wait4xandGLM-5.3 committed 2026-11-02 16:45:01 +08:00
1 parent 8d221ab36e
commit 859cac19a2
11 files changed
+387 -26

No files matched your search

+39 -8
View File
@@ -54,11 +54,25 @@ function toCard(r: RawRepo, source: RepoCard['source']): RepoCard | null {
* 拉双源合并去重(同名优先 gitea 自托管叙事,star 取 GitHub 真值)。
* 任一源失败不抛:置顶项回落仓内缓存 JSON(构建产物绝不被网络单点卡死)。
*/
export interface LoadProjectsOptions {
/** 置顶仓短名(顺序即顺序),缺省 PINNED_DEFAULT */
pinned?: string[];
/** 可见性:all=全部 / only=仅 include / exclude=排除 exclude */
mode?: 'all' | 'only' | 'exclude';
include?: string[];
exclude?: string[];
/** 排序:pinned-first(默认)/ stars / updated */
sort?: 'pinned-first' | 'stars' | 'updated';
/** 来源过滤:both(默认)/ github / gitea */
perSource?: 'both' | 'github' | 'gitea';
}
/**
* 拉取双源并排序(置顶名单由 content/site/opensource.yaml 供给,缺省用 PINNED_DEFAULT)
* @param pinned 置顶仓短名,顺序即展示顺序
* 拉取双源并按选项过滤/排序(选项来自 content/site/opensource.yaml,缺省 = 现状行为)
* @returns 未截断的完整列表:展示上限由调用方 slice(首页 6 / 开源页 99)
*/
export async function loadProjects(pinned: string[] = PINNED_DEFAULT): Promise<RepoCard[]> {
export async function loadProjects(options: LoadProjectsOptions = {}): Promise<RepoCard[]> {
const { pinned = PINNED_DEFAULT, mode = 'all', include = [], exclude = [], sort = 'pinned-first', perSource = 'both' } = options;
const [gitea, github] = await Promise.allSettled([fetchJson(GITEA_API), fetchJson(GITHUB_API)]);
const byName = new Map<string, RepoCard>();
@@ -76,15 +90,32 @@ export async function loadProjects(pinned: string[] = PINNED_DEFAULT): Promise<R
if (byName.size === 0) {
const fb = (await import('../data/projects-fallback.json')).default as RepoCard[];
return orderPinned(fb, pinned);
return arrange(fb, pinned, mode, include, exclude, sort, perSource);
}
return orderPinned([...byName.values()], pinned);
return arrange([...byName.values()], pinned, mode, include, exclude, sort, perSource);
}
function orderPinned(list: RepoCard[], pinned: string[]): RepoCard[] {
return [...list].sort((a, b) => {
/** 来源过滤 + 可见性过滤 + 排序(不截断:上限交给调用方) */
function arrange(
list: RepoCard[],
pinned: string[],
mode: 'all' | 'only' | 'exclude',
include: string[],
exclude: string[],
sort: 'pinned-first' | 'stars' | 'updated',
perSource: 'both' | 'github' | 'gitea',
): RepoCard[] {
let out = perSource === 'both' ? list : list.filter((r) => r.source === perSource);
if (mode === 'only') out = out.filter((r) => include.includes(r.name));
else if (mode === 'exclude') out = out.filter((r) => !exclude.includes(r.name));
if (sort === 'stars') return [...out].sort((a, b) => b.stars - a.stars);
if (sort === 'updated') {
return [...out].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
}
return [...out].sort((a, b) => {
const pa = pinned.indexOf(a.name), pb = pinned.indexOf(b.name);
if (pa !== -1 || pb !== -1) return (pa === -1 ? 99 : pa) - (pb === -1 ? 99 : pb);
return b.stars - a.stars;
}).slice(0, 6);
});
}