fix(posts): 隐藏开关是假开关——修「隐藏+置顶仍显示并排第一」;贡献数据去重

用户实测:「有的文章同时选择了置顶和隐藏,还会显示并置顶」。

根因:后台「隐藏」开关(hiddenTag)是个假开关——它只写 `hiddenTag: true` 这个
没人读的字段,而全站过滤只认 `tags` 里的 `hidden`。于是隐藏从未生效;
叠加置顶后,这篇还在列表最前面。

- 新增 src/lib/post-visibility.ts:可见性契约单一事实源——`!draft && !hidden &&
  !tags.includes('hidden')`(两条通道都认,历史写法兼容)
- postsSchema 增真字段 `hidden`;七处消费点(首页双轨/归档 zh/en/书架/RSS zh/en/
  文章页导航)统一走 isVisiblePost
- 后台假开关 hiddenTag 换成真开关 hidden,hint 写清语义:不进列表/RSS/sitemap,
  页面本身仍生成(链接可分享);与置顶同时打开时以隐藏为准
- sitemap 过滤隐藏文章(astro.config 配置期扫 frontmatter,两种写法都认;
  内容目录不可读时宁可不排除也不炸构建)

贡献数据去重(5j T2,回答用户「连续贡献是否实时」):
- 连续贡献不再构建期抓取 streak.svg 快照(不实时、暗色需滤镜、与热力图同源重复),
  改为热力图同一份 GraphQL 日历本地计算(src/lib/heatmap.ts streakStats):
  当前连续(以最后有贡献日为终点)/ 最长 / 总贡献,界面上标注「数据更新于 X」
- 贡献蛇块与自托管 snake/streak SVG 删除,fetch 脚本 ASSETS 清空,
  build.sh 守卫改为退役说明;开源页数据板整块并入热力图区

验证: 构建 51 页 exit 0;content-lint 52 页 0/0/0;E2E 56/56;
      临时两篇「置顶+隐藏」(新字段/旧 tags 各一)实测——首页/归档/书架/
      双语首页/RSS 双语/sitemap 全部零命中,页面本身仍生成;
      清理后归档首条恢复 writing-template(普通置顶不受影响);
      开源页 snake/streak 资产零残留、连续统计条与更新时间标注就位

Spec: 2026-11-03_blog-5j-content-refine (T2 + 隐藏 bug 修复)

Co-authored-by: GLM-5.3
This commit is contained in:
wait4xandGLM-5.3 committed 2026-11-04 22:00:15 +08:00
1 parent 34a78dacb9
commit 49acb4cbd1
21 files changed
+398 -596

No files matched your search

+31 -1
View File
@@ -3,6 +3,7 @@ import { defineConfig } from 'astro/config';
import pagefind from 'astro-pagefind';
import sitemap from '@astrojs/sitemap';
import { execSync } from 'node:child_process';
import { readdirSync, readFileSync } from 'node:fs';
import rehypeLinkCards, { linkCardsReport } from './src/lib/rehype-link-cards';
import rehypeClassWhitelist from './src/lib/rehype-class-whitelist';
import rehypeRaw from 'rehype-raw';
@@ -18,9 +19,38 @@ const date =
process.env.GITHUB_COMMIT_DATE ||
(() => { try { return execSync('git log -1 --format=%cI').toString().trim(); } catch { return new Date().toISOString(); } })();
// 隐藏文章不进 sitemap(内容侧契约见 src/lib/post-visibility.ts)。
// 这里在配置期直接扫 frontmatter:Astro 的 content collections 在配置上下文不可用,
// 而 sitemap 的 filter 只拿到 URL。判定两条通道:`hidden: true` 与 tags 里的 hidden。
const hiddenSlugs = (() => {
const set = new Set();
try {
const dir = 'content/posts';
for (const file of readdirSync(dir)) {
if (!file.endsWith('.md')) continue;
const raw = readFileSync(`${dir}/${file}`, 'utf8');
const head = (raw.split('\n---\n')[0] ?? '').replace(/^---\n/, '');
if (/^hidden:\s*true\s*$/m.test(head)) { set.add(file.replace(/\.md$/, '')); continue; }
// tags 可能是行内数组,也可能是多行列表:抓出 tags 段再找 hidden 词
const m = /^tags:[ \t]*([\s\S]*?)(?=^\w|\s*$)/m.exec(head);
const tagBlock = m ? m[1] : '';
if (/\bhidden\b/.test(tagBlock)) set.add(file.replace(/\.md$/, ''));
}
} catch {
/* 内容目录不可读时宁可不排除,也不让构建挂掉 */
}
return [...set];
})();
export default defineConfig({
site: 'https://wait4x.online',
integrations: [pagefind(), sitemap(), linkCardsReport()],
integrations: [
pagefind(),
sitemap({
filter: (page) => !hiddenSlugs.some((slug) => page.includes(`/archives/${slug}/`)),
}),
linkCardsReport(),
],
markdown: {
// 独立成段的裸链接 → OG 预览卡(构建期抓取,失败回落普通链接;见 src/lib/rehype-link-cards.ts)
// 顺序:rehypeRaw 先把 markdown 里的 raw HTML 解析成 hast element(否则白名单看不到 span),
+3 -6
View File
@@ -39,12 +39,9 @@ pnpm astro build
[ -f scripts/build-perf/gen-sw-precache.mjs ] && node scripts/build-perf/gen-sw-precache.mjs dist || echo "[build] sw-precache 生成器未就绪,跳过"
[ -f scripts/build-perf/inline-home-css.mjs ] && node scripts/build-perf/inline-home-css.mjs || echo "[build] inline-home 脚本未就绪,跳过"
# README 动效资产(5i T8):**不在此跑抓取**(避免发布链被外网抖动拖累),只做存在性守卫 + 缺资产告警
if [ -f public/github-assets/manifest.json ]; then
echo "[build] github-assets 就绪:$(find public/github-assets -name '*.svg' | wc -l) 个自托管 SVG"
else
echo "[build] WARN public/github-assets/manifest.json 缺失——开源页数据板将只显示原生统计(可手动跑 node scripts/build-perf/fetch-readme-assets.mjs 补齐)"
fi
# README 动效资产(5i 引入、5j T2 退役):贡献蛇与 streak 快照已删——连续贡献改为
# 热力图同源本地计算(见 src/lib/heatmap.ts 的 streakStats),界面上标注"数据更新于"。
# fetch-readme-assets.mjs 保留骨架但 ASSETS 已清空,如将来要加新的自托管资产再启用。
# 内容 lint(T3):断链/缺图在部署前拦截;CONTENT_LINT=skip|warn 逃生门见脚本头注
[ -f scripts/build-perf/content-lint.mjs ] && node scripts/build-perf/content-lint.mjs dist || echo "[build] content-lint 未就绪,跳过"
+6 -1
View File
@@ -55,7 +55,12 @@ collections:
widget: string
required: false
hint: '填 /media/xxx.jpg(先上传到 public/media 取路径);缺省浏览器自取首帧'
- { label: 隐藏(不进列表/sitemap), name: hiddenTag, widget: boolean, required: false, hint: '对应 tags 含 hidden;保存后请人工核对' }
- label: 隐藏(不进列表 / RSS / sitemap)
name: hidden
widget: boolean
required: false
default: false
hint: '打开后这篇不进首页/归档/书架/RSS/sitemap,但页面本身仍生成、链接可分享(想彻底不生成请用草稿)。与「置顶」同时打开时以隐藏为准——2026-11-04 修:旧开关只写一个没人读的字段,导致隐藏+置顶的文章照样显示还排第一'
- { label: 草稿, name: draft, widget: boolean, required: false, default: false }
- { label: 正文, name: body, widget: markdown }
-1
View File
@@ -1 +0,0 @@
<svg viewBox="-16 -32 880 192" width="880" height="192" xmlns="http://www.w3.org/2000/svg"><desc>Generated with https://github.com/Platane/snk</desc><style>:root{--cb:#1b1f230a;--cs:purple;--ce:#ebedf0;--c0:#ebedf0;--c1:#9be9a8;--c2:#40c463;--c3:#30a14e;--c4:#216e39}.c{shape-rendering:geometricPrecision;fill:var(--ce);stroke-width:1px;stroke:var(--cb);width:12px;height:12px}.c.c0{fill:var(--c1);animation-name:c0}.c.c1{fill:var(--c2);animation-name:c1}.c.c2{fill:var(--c1);animation-name:c2}.c.c3{fill:var(--c1);animation-name:c3}.c.c4{fill:var(--c1);animation-name:c4}.c.c5{fill:var(--c1);animation-name:c5}.c.c6{fill:var(--c1);animation-name:c6}.c.c7{fill:var(--c1);animation-name:c7}.c.c8{fill:var(--c1);animation-name:c8}.c.c9{fill:var(--c1);animation-name:c9}.c.ca{fill:var(--c1);animation-name:ca}.c.cb{fill:var(--c1);animation-name:cb}.c.cc{fill:var(--c1);animation-name:cc}.c.cd{fill:var(--c1);animation-name:cd}.c.ce{fill:var(--c1);animation-name:ce}.c.cf{fill:var(--c1);animation-name:cf}.c.cg{fill:var(--c1);animation-name:cg}.c.ch{fill:var(--c1);animation-name:ch}.c.ci{fill:var(--c1);animation-name:ci}.c.cj{fill:var(--c1);animation-name:cj}.c.ck{fill:var(--c1);animation-name:ck}.c.cl{fill:var(--c1);animation-name:cl}.c.cm{fill:var(--c2);animation-name:cm}.c.cn{fill:var(--c1);animation-name:cn}.c.co{fill:var(--c1);animation-name:co}.c.cp{fill:var(--c1);animation-name:cp}.c.cq{fill:var(--c1);animation-name:cq}.c.cr{fill:var(--c1);animation-name:cr}.c.cs{fill:var(--c1);animation-name:cs}.c.ct{fill:var(--c4);animation-name:ct}.c.cu{fill:var(--c1);animation-name:cu}.c.cv{fill:var(--c4);animation-name:cv}.c.cw{fill:var(--c2);animation-name:cw}.c.cx{fill:var(--c1);animation-name:cx}.c.cy{fill:var(--c1);animation-name:cy}.c.cz{fill:var(--c1);animation-name:cz}.c.c10{fill:var(--c1);animation-name:c10}.c.c11{fill:var(--c1);animation-name:c11}.c.c12{fill:var(--c2);animation-name:c12}.c.c13{fill:var(--c4);animation-name:c13}.c.c14{fill:var(--c2);animation-name:c14}.c.c15{fill:var(--c1);animation-name:c15}.c.c16{fill:var(--c4);animation-name:c16}.c.c17{fill:var(--c1);animation-name:c17}.c.c18{fill:var(--c1);animation-name:c18}.c.c19{fill:var(--c1);animation-name:c19}.c.c1a{fill:var(--c2);animation-name:c1a}.c.c1b{fill:var(--c1);animation-name:c1b}.c.c1c{fill:var(--c1);animation-name:c1c}.c.c1d{fill:var(--c4);animation-name:c1d}.c.c1e{fill:var(--c4);animation-name:c1e}.u{transform-origin:0 0;transform:scale(0,1);animation-name:u0;transform-origin:0.0px 0}.u.u1{fill:var(--c2);animation-name:u1;transform-origin:648.5px 0}.u.u2{fill:var(--c4);animation-name:u2;transform-origin:748.2px 0}.s{shape-rendering:geometricPrecision;fill:var(--cs);animation-name:s0}.s.s1{transform:translate(16px,-16px);animation-name:s1}.s.s2{transform:translate(32px,-16px);animation-name:s2}.s.s3{transform:translate(48px,-16px);animation-name:s3}</style><rect class="c" x="2" y="2" rx="2" ry="2"/><rect class="c" x="2" y="18" rx="2" ry="2"/><rect class="c" x="2" y="34" rx="2" ry="2"/><rect class="c" x="2" y="50" rx="2" ry="2"/><rect class="c" x="2" y="66" rx="2" ry="2"/><rect class="c" x="2" y="82" rx="2" ry="2"/><rect class="c" x="2" y="98" rx="2" ry="2"/><rect class="c" x="18" y="2" rx="2" ry="2"/><rect class="c" x="18" y="18" rx="2" ry="2"/><rect class="c" x="18" y="34" rx="2" ry="2"/><rect class="c" x="18" y="50" rx="2" ry="2"/><rect class="c" x="18" y="66" rx="2" ry="2"/><rect class="c" x="18" y="82" rx="2" ry="2"/><rect class="c c0" x="18" y="98" rx="2" ry="2"/><rect class="c" x="34" y="2" rx="2" ry="2"/><rect class="c" x="34" y="18" rx="2" ry="2"/><rect class="c" x="34" y="34" rx="2" ry="2"/><rect class="c" x="34" y="50" rx="2" ry="2"/><rect class="c" x="34" y="66" rx="2" ry="2"/><rect class="c" x="34" y="82" rx="2" ry="2"/><rect class="c" x="34" y="98" rx="2" ry="2"/><rect class="c" x="50" y="2" rx="2" ry="2"/><rect class="c" x="50" y="18" rx="2" ry="2"/><rect class="c" x="50" y="34" rx="2" ry="2"/><rect class="c" x="50" y="50" rx="2" ry="2"/><rect class="c c1" x="50" y="66" rx="2" ry="2"/><rect class="c c2" x="50" y="82" rx="2" ry="2"/><rect class="c" x="50" y="98" rx="2" ry="2"/><rect class="c" x="66" y="2" rx="2" ry="2"/><rect class="c" x="66" y="18" rx="2" ry="2"/><rect class="c c3" x="66" y="34" rx="2" ry="2"/><rect class="c c4" x="66" y="50" rx="2" ry="2"/><rect class="c" x="66" y="66" rx="2" ry="2"/><rect class="c c5" x="66" y="82" rx="2" ry="2"/><rect class="c" x="66" y="98" rx="2" ry="2"/><rect class="c" x="82" y="2" rx="2" ry="2"/><rect class="c" x="82" y="18" rx="2" ry="2"/><rect class="c c6" x="82" y="34" rx="2" ry="2"/><rect class="c" x="82" y="50" rx="2" ry="2"/><rect class="c" x="82" y="66" rx="2" ry="2"/><rect class="c" x="82" y="82" rx="2" ry="2"/><rect class="c" x="82" y="98" rx="2" ry="2"/><rect class="c" x="98" y="2" rx="2" ry="2"/><rect class="c" x="98" y="18" rx="2" ry="2"/><rect class="c" x="98" y="34" rx="2" ry="2"/><rect class="c" x="98" y="50" rx="2" ry="2"/><rect class="c" x="98" y="66" rx="2" ry="2"/><rect class="cLine truncated

Before

Width:  |  Height:  |  Size: 20 KiB

-104
View File
@@ -1,104 +0,0 @@
<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'
style='isolation: isolate' viewBox='0 0 495 195' width='495px' height='195px' direction='ltr'>
<style>
</style>
<defs>
<clipPath id='outer_rectangle'>
<rect width='495' height='195' rx='4.5'/>
</clipPath>
<mask id='mask_out_ring_behind_fire'>
<rect width='495' height='195' fill='white'/>
<ellipse id='mask-ellipse' cx='247.5' cy='32' rx='13' ry='18' fill='black'/>
</mask>
</defs>
<g clip-path='url(#outer_rectangle)'>
<g style='isolation: isolate'>
<rect stroke='#000000' stroke-opacity='0' fill='#000000' fill-opacity='0' rx='4.5' x='0.5' y='0.5' width='494' height='194'/>
</g>
<g style='isolation: isolate'>
<line x1='165' y1='28' x2='165' y2='170' vector-effect='non-scaling-stroke' stroke-width='1' stroke='#E4E2E2' stroke-linejoin='miter' stroke-linecap='square' stroke-miterlimit='3'/>
<line x1='330' y1='28' x2='330' y2='170' vector-effect='non-scaling-stroke' stroke-width='1' stroke='#E4E2E2' stroke-linejoin='miter' stroke-linecap='square' stroke-miterlimit='3'/>
</g>
<g style='isolation: isolate'>
<!-- Total Contributions big number -->
<g transform='translate(82.5, 48)'>
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#c9ccd4' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' style='opacity: 1'>
344
</text>
</g>
<!-- Total Contributions label -->
<g transform='translate(82.5, 84)'>
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#b9bec8' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='14px' font-style='normal' style='opacity: 1'>
Total Contributions
</text>
</g>
<!-- Total Contributions range -->
<g transform='translate(82.5, 114)'>
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#b9bec8' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 1'>
May 24, 2019 - Present
</text>
</g>
</g>
<g style='isolation: isolate'>
<!-- Current Streak label -->
<g transform='translate(247.5, 108)'>
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#3454d1' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='14px' font-style='normal' style='opacity: 1'>
Current Streak
</text>
</g>
<!-- Current Streak range -->
<g transform='translate(247.5, 145)'>
<text x='0' y='21' stroke-width='0' text-anchor='middle' fill='#b9bec8' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 1'>
Nov 3
</text>
</g>
<!-- Ring around number -->
<g mask='url(#mask_out_ring_behind_fire)'>
<circle cx='247.5' cy='71' r='40' fill='none' stroke='#3454d1' stroke-width='5' style='opacity: 1'></circle>
</g>
<!-- Fire icon -->
<g transform='translate(247.5, 19.5)' stroke-opacity='0' style='opacity: 1'>
<path d='M -12 -0.5 L 15 -0.5 L 15 23.5 L -12 23.5 L -12 -0.5 Z' fill='none'/>
<path d='M 1.5 0.67 C 1.5 0.67 2.24 3.32 2.24 5.47 C 2.24 7.53 0.89 9.2 -1.17 9.2 C -3.23 9.2 -4.79 7.53 -4.79 5.47 L -4.76 5.11 C -6.78 7.51 -8 10.62 -8 13.99 C -8 18.41 -4.42 22 0 22 C 4.42 22 8 18.41 8 13.99 C 8 8.6 5.41 3.79 1.5 0.67 Z M -0.29 19 C -2.07 19 -3.51 17.6 -3.51 15.86 C -3.51 14.24 -2.46 13.1 -0.7 12.74 C 1.07 12.38 2.9 11.53 3.92 10.16 C 4.31 11.45 4.51 12.81 4.51 14.2 C 4.51 16.85 2.36 19 -0.29 19 Z' fill='#3454d1' stroke-opacity='0'/>
</g>
<!-- Current Streak big number -->
<g transform='translate(247.5, 48)'>
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#c9ccd4' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' >
0
</text>
</g>
</g>
<g style='isolation: isolate'>
<!-- Longest Streak big number -->
<g transform='translate(412.5, 48)'>
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#c9ccd4' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' style='opacity: 1'>
6
</text>
</g>
<!-- Longest Streak label -->
<g transform='translate(412.5, 84)'>
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#b9bec8' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='14px' font-style='normal' style='opacity: 1'>
Longest Streak
</text>
</g>
<!-- Longest Streak range -->
<g transform='translate(412.5, 114)'>
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#b9bec8' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 1'>
Oct 19 - Oct 24
</text>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.1 KiB

-104
View File
@@ -1,104 +0,0 @@
<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'
style='isolation: isolate' viewBox='0 0 495 195' width='495px' height='195px' direction='ltr'>
<style>
</style>
<defs>
<clipPath id='outer_rectangle'>
<rect width='495' height='195' rx='4.5'/>
</clipPath>
<mask id='mask_out_ring_behind_fire'>
<rect width='495' height='195' fill='white'/>
<ellipse id='mask-ellipse' cx='247.5' cy='32' rx='13' ry='18' fill='black'/>
</mask>
</defs>
<g clip-path='url(#outer_rectangle)'>
<g style='isolation: isolate'>
<rect stroke='#000000' stroke-opacity='0' fill='#000000' fill-opacity='0' rx='4.5' x='0.5' y='0.5' width='494' height='194'/>
</g>
<g style='isolation: isolate'>
<line x1='165' y1='28' x2='165' y2='170' vector-effect='non-scaling-stroke' stroke-width='1' stroke='#E4E2E2' stroke-linejoin='miter' stroke-linecap='square' stroke-miterlimit='3'/>
<line x1='330' y1='28' x2='330' y2='170' vector-effect='non-scaling-stroke' stroke-width='1' stroke='#E4E2E2' stroke-linejoin='miter' stroke-linecap='square' stroke-miterlimit='3'/>
</g>
<g style='isolation: isolate'>
<!-- Total Contributions big number -->
<g transform='translate(82.5, 48)'>
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#33343a' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' style='opacity: 1'>
344
</text>
</g>
<!-- Total Contributions label -->
<g transform='translate(82.5, 84)'>
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#3f424a' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='14px' font-style='normal' style='opacity: 1'>
Total Contributions
</text>
</g>
<!-- Total Contributions range -->
<g transform='translate(82.5, 114)'>
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#3f424a' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 1'>
May 24, 2019 - Present
</text>
</g>
</g>
<g style='isolation: isolate'>
<!-- Current Streak label -->
<g transform='translate(247.5, 108)'>
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#3454d1' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='14px' font-style='normal' style='opacity: 1'>
Current Streak
</text>
</g>
<!-- Current Streak range -->
<g transform='translate(247.5, 145)'>
<text x='0' y='21' stroke-width='0' text-anchor='middle' fill='#3f424a' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 1'>
Nov 3
</text>
</g>
<!-- Ring around number -->
<g mask='url(#mask_out_ring_behind_fire)'>
<circle cx='247.5' cy='71' r='40' fill='none' stroke='#3454d1' stroke-width='5' style='opacity: 1'></circle>
</g>
<!-- Fire icon -->
<g transform='translate(247.5, 19.5)' stroke-opacity='0' style='opacity: 1'>
<path d='M -12 -0.5 L 15 -0.5 L 15 23.5 L -12 23.5 L -12 -0.5 Z' fill='none'/>
<path d='M 1.5 0.67 C 1.5 0.67 2.24 3.32 2.24 5.47 C 2.24 7.53 0.89 9.2 -1.17 9.2 C -3.23 9.2 -4.79 7.53 -4.79 5.47 L -4.76 5.11 C -6.78 7.51 -8 10.62 -8 13.99 C -8 18.41 -4.42 22 0 22 C 4.42 22 8 18.41 8 13.99 C 8 8.6 5.41 3.79 1.5 0.67 Z M -0.29 19 C -2.07 19 -3.51 17.6 -3.51 15.86 C -3.51 14.24 -2.46 13.1 -0.7 12.74 C 1.07 12.38 2.9 11.53 3.92 10.16 C 4.31 11.45 4.51 12.81 4.51 14.2 C 4.51 16.85 2.36 19 -0.29 19 Z' fill='#3454d1' stroke-opacity='0'/>
</g>
<!-- Current Streak big number -->
<g transform='translate(247.5, 48)'>
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#33343a' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' >
0
</text>
</g>
</g>
<g style='isolation: isolate'>
<!-- Longest Streak big number -->
<g transform='translate(412.5, 48)'>
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#33343a' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='700' font-size='28px' font-style='normal' style='opacity: 1'>
6
</text>
</g>
<!-- Longest Streak label -->
<g transform='translate(412.5, 84)'>
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#3f424a' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='14px' font-style='normal' style='opacity: 1'>
Longest Streak
</text>
</g>
<!-- Longest Streak range -->
<g transform='translate(412.5, 114)'>
<text x='0' y='32' stroke-width='0' text-anchor='middle' fill='#3f424a' stroke='none' font-family='"Segoe UI", Ubuntu, sans-serif' font-weight='400' font-size='12px' font-style='normal' style='opacity: 1'>
Oct 19 - Oct 24
</text>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.1 KiB

@@ -42,18 +42,6 @@ const ASSETS = [
`&text_color=${theme === 'dark' ? INK_DARK : INK_LIGHT}`,
label: '语言分布',
},
{
name: 'streak',
url: (theme) =>
`https://streak-stats.demolab.com/?user=${LOGIN}&hide_border=true` +
'&background=00000000' +
`&ring=${ACCENT}&fire=${ACCENT}&currStreakLabel=${ACCENT}` +
`&currStreakNum=${theme === 'dark' ? INK_DARK : INK_LIGHT}` +
`&sideNums=${theme === 'dark' ? INK_DARK : INK_LIGHT}` +
`&sideLabels=${theme === 'dark' ? MUTED_DARK : MUTED_LIGHT}` +
`&dates=${theme === 'dark' ? MUTED_DARK : MUTED_LIGHT}`,
label: '连续贡献',
},
{
// 贡献蛇:GitHub Action 生成,配色由 action 侧决定,无法传参 → 只抓一份
name: 'snake',
+1 -77
View File
@@ -5,7 +5,6 @@
// 0600 文件,缺位或失败即隐藏该数字)④src/lib/projects.ts 的仓库列表(语言占比与星标汇总)
// 降级纪律:任何一项取数失败只隐藏该项,绝不炸构建、不出现空图片框
import { Image } from 'astro:assets';
import { existsSync } from 'node:fs';
import TypingLines from './TypingLines.astro';
import { homeAvatar, homeSocials, openSourceCopy, renderRich } from '../../lib/site-content';
import { githubUserStats, loadProjects } from '../../lib/projects';
@@ -71,26 +70,7 @@ const lines = typingLines.length ? typingLines : DEFAULT_TYPING;
// 自托管 SVG 数据卡(public/github-assets/,由 scripts/build-perf/fetch-readme-assets.mjs 产出入仓)
// 缺文件即跳过该卡(异步抓取失败不影响构建),明暗各一份时用 CSS 切换
const assetPath = (name: string) => `/github-assets/${name}`;
const hasAsset = (name: string) => existsSync(`public/github-assets/${name}`);
const boardCards: { label: string; light: string; dark?: string }[] = [];
for (const [key, label] of [
['stats', locale === 'zh' ? '统计' : 'Stats'],
['top-langs', locale === 'zh' ? '语言分布' : 'Top languages'],
['streak', locale === 'zh' ? '连续贡献' : 'Streak'],
] as const) {
if (!hasAsset(`${key}-light.svg`)) continue;
boardCards.push({
label,
light: assetPath(`${key}-light.svg`),
dark: hasAsset(`${key}-dark.svg`) ? assetPath(`${key}-dark.svg`) : undefined,
});
}
// 只有一张非通栏卡时让它占满整行(否则右半空档;stats/top-langs 上游 503 时就是这种情形)
const soloCard = boardCards.length === 1;
const snakeCard = hasAsset('snake.svg')
? { label: locale === 'zh' ? '贡献蛇' : 'Contribution snake', src: assetPath('snake.svg') }
: null;
// 数据板已并入热力图区(Phase 5j T2):连续贡献由同源计算 + 更新时间标注,不再使用第三方 SVG
const L = {
zh: { followers: '关注者', repos: '公开仓库', stars: '总星标', langs: '语言占比' },
en: { followers: 'Followers', repos: 'Public repos', stars: 'Total stars', langs: 'Languages' },
@@ -171,33 +151,6 @@ const L = {
</ul>
</div>
)}
{(boardCards.length > 0 || snakeCard) && (
<div class="pf-board">
<p class="pf-board-title mono">{locale === 'zh' ? '数据板' : 'Data board'}</p>
<div class="pf-board-grid">
{boardCards.map((card) => (
<figure
class:list={[
'pf-card',
{ 'pf-card-streak': card.light.includes('streak'), 'pf-card-solo': soloCard },
]}
>
<img class="pf-card-img only-light" src={card.light} alt={card.label} loading="lazy" decoding="async" />
{card.dark && (
<img class="pf-card-img only-dark" src={card.dark} alt={card.label} loading="lazy" decoding="async" />
)}
<figcaption class="mono">{card.label}</figcaption>
</figure>
))}
{snakeCard && (
<figure class="pf-card pf-card-wide pf-card-snake">
<img class="pf-card-img" src={snakeCard.src} alt={snakeCard.label} loading="lazy" decoding="async" />
<figcaption class="mono">{snakeCard.label}</figcaption>
</figure>
)}
</div>
</div>
)}
</section>
<style>
@@ -331,11 +284,6 @@ const L = {
text-align: right;
font-variant-numeric: tabular-nums;
}
.pf-board {
margin-top: var(--space-3);
padding-top: var(--space-3);
border-top: 1px solid var(--line);
}
.pf-board-title {
font-size: 11.5px;
color: var(--ink-4);
@@ -344,29 +292,6 @@ const L = {
}
/* 自适应列:只有 1 张卡时自动占满整行(避免右半空档),≥2 张时并排;
320px 下限保证车卡内 SVG 不被压到字号过小 */
.pf-board-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: var(--space-2);
}
.pf-card {
margin: 0;
padding: var(--space-2);
border: 1px solid var(--line);
border-radius: 10px;
background: transparent;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.pf-card-wide, .pf-card-solo { grid-column: 1 / -1; }
.pf-card-img { max-width: 100%; height: auto; display: block; }
.pf-card figcaption {
font-size: 11px;
color: var(--ink-4);
letter-spacing: 0.03em;
}
/* 明暗两版自托管 SVG:默认只显示亮色,暗色主题切到暗色版 */
/* 贡献蛇:SVG 内部用自带 CSS 变量(绿阶 + #ebedf0 空格),<img> 无法改写内部配色 →
滤镜归一到站点灰阶;暗色下反相,避免浅灰格子贴在深底上刺眼(想去掉绿→灰只改这一行) */
@@ -378,7 +303,6 @@ const L = {
filter: grayscale(1) invert(1) contrast(1.18) brightness(1.06);
}
.only-dark { display: none; }
:global([data-theme='dark']) .only-light { display: none; }
:global([data-theme='dark']) .only-dark { display: block; }
+21 -1
View File
@@ -1,7 +1,8 @@
---
// 贡献热力图(极客层 P0):纯 CSS grid,零运行时 JS
// 数据聚合见 src/lib/heatmap.ts;灰阶四档直接复用 tokens --ink-1..4(亮暗两套自动跟随)
import { loadHeatmap, levelOf } from '../../lib/heatmap';
import { loadHeatmap, levelOf, streakStats } from '../../lib/heatmap';
import { localeOf } from '../../lib/locale';
// 文案全部由页面层注入(locale 无关挂法),keys 与 ui.ts heatmap_* 一一对应
interface Labels {
@@ -16,6 +17,7 @@ interface Props {
}
const { labels } = Astro.props;
const locale = localeOf(Astro.url.pathname);
let weeks: import('../../lib/heatmap').HeatDay[][] = [];
let total = 0;
try {
@@ -23,12 +25,21 @@ try {
} catch {
// 三级回落已在 lib 内处理;此处再守一层:空网格不破构建
}
// 连续贡献(Phase 5j T2):与热力图同源本地计算 + 标注更新时间(不宣称实时)
const streak = streakStats(weeks.flat());
const updatedOn = new Date().toISOString().slice(0, 10);
const summary = labels.summary.replace('{n}', String(total));
---
<section class="heatmap">
<h2 class="hm-title">{labels.title}</h2>
<!-- 53 列窄栏触屏放不下,容器横滚(格子原生 title 提示,无 JS) -->
<p class="hm-streak mono">
<span class="hm-streak-item"><strong>{streak.current}</strong>{locale === 'en' ? 'day streak' : '天连续'}</span>
<span class="hm-streak-item"><strong>{streak.longest}</strong>{locale === 'en' ? 'longest' : '最长'}</span>
<span class="hm-streak-item"><strong>{streak.total}</strong>{locale === 'en' ? 'total' : '总贡献'}</span>
<span class="hm-streak-note">数据更新于 {updatedOn}</span>
</p>
<div class="hm-scroll" data-anchor-latest>
<div class="hm-grid" role="img" aria-label={summary}>
{weeks.map((week) =>
@@ -91,6 +102,15 @@ const summary = labels.summary.replace('{n}', String(total));
.hm-l2 { background: var(--ink-3); }
.hm-l3 { background: var(--ink-2); }
.hm-l4 { background: var(--ink-1); }
.hm-streak {
display: flex; align-items: baseline; gap: var(--space-4); flex-wrap: wrap;
margin: 0 0 var(--space-2); font-size: 12.5px; color: var(--ink-3);
}
.hm-streak-item strong {
font-size: 20px; color: var(--ink-1); font-variant-numeric: tabular-nums;
margin-right: 4px;
}
.hm-streak-note { color: var(--ink-4); font-size: 11.5px; }
.hm-legend {
display: flex;
align-items: center;
+2 -1
View File
@@ -13,6 +13,7 @@
* 展开/收起是另一个形状的 chevron,避免两个箭头混读
*/
import { getCollection } from 'astro:content';
import { isVisiblePost } from '../../lib/post-visibility';
import { Image } from 'astro:assets';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { t } from '../../i18n/ui';
@@ -30,7 +31,7 @@ const note = await pageNote('books', locale);
const books = sortPosts(
await getCollection(
'posts',
({ data }) => !data.draft && data.category === 'reading' && !data.tags.includes('hidden'),
({ data }) => isVisiblePost(data) && data.category === 'reading',
),
);
+2 -1
View File
@@ -2,6 +2,7 @@
// 首页(zh/en 共用):名片区 / 文章列表 / 最新影像 / 热力图 / 项目墙 / 心跳
// 两个 index.astro 只是薄壳(传 locale),区块逻辑与样式集中在这里,避免双份漂移。
import { Image } from 'astro:assets';
import { isVisiblePost } from '../../lib/post-visibility';
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import IdCard from '../IdCard.astro';
@@ -33,7 +34,7 @@ const announcementHtml = cfg.announcement ? await renderRich(cfg.announcement) :
// 置顶优先 → 时间倒序(sortPosts 契约见 src/lib/post-order.ts)
const posts = sortPosts(
await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden')),
await getCollection('posts', ({ data }) => isVisiblePost(data)),
);
// en 轨列表标题吃 postsEn 译题(缺译回退 zh,与 /en/archives 同策略)
+6
View File
@@ -125,6 +125,12 @@ const postsSchema = z.object({
poster: z.string().optional(),
/** 置顶:列表排序优先(只影响顺序,不改 pubDate/URL/RSS 时间语义) */
pinned: z.boolean().optional(),
/**
* 隐藏:不进首页/归档/书架/RSS/sitemap(页面本身仍生成,链接可分享)。
* 与置顶可以同时为真——此时隐藏优先,不显示也不排序。
* 历史写法 `tags: [hidden]` 仍然生效,判定收敛在 src/lib/post-visibility.ts。
*/
hidden: z.boolean().optional(),
draft: z.boolean().optional(),
});
+44
View File
@@ -114,6 +114,50 @@ async function loadFallbackDays(): Promise<Map<string, number>> {
}
}
/**
* 连续贡献统计(Phase 5j T2):从同一份日历数据本地计算,不再构建期抓取 streak.svg。
*
* 为什么这么做(用户 2026-11-03 的问题:「连续贡献是否是实时更新的?」)——旧实现抓的是
* 第三方 SVG 快照,构建不跑就不更新,且与热力图数据源重复。现在热力图与连续天数共用
* 一份 GraphQL 日历,天然同源;界面标注「数据更新于 <构建日期>」,不宣称实时。
*
* 口径:「当前连续」以最后一个有贡献的日子为终点往回数——今天还没贡献不该把连续归零
* (GitHub 同口径);「最长连续」按日历顺序数。
*/
export interface StreakStats {
total: number;
current: number;
longest: number;
/** 最后一个有贡献的日子(YYYY-MM-DD),用于界面标注截至日期 */
lastActive: string;
}
export function streakStats(days: HeatDay[]): StreakStats {
const countOf = new Map(days.map((d) => [d.date, d.count]));
const isoOf = (d: Date) => d.toISOString().slice(0, 10);
const lastActive = [...days].reverse().find((d) => d.count > 0)?.date ?? '';
let current = 0;
if (lastActive) {
const start = new Date(`${lastActive}T00:00:00Z`);
for (let i = 0; ; i++) {
const d = new Date(start);
d.setUTCDate(start.getUTCDate() - i);
if ((countOf.get(isoOf(d)) ?? 0) > 0) current++;
else break;
}
}
let longest = 0;
let run = 0;
for (const d of days) {
run = d.count > 0 ? run + 1 : 0;
if (run > longest) longest = run;
}
return { total: days.reduce((a, b) => a + b.count, 0), current, longest, lastActive };
}
export async function loadHeatmap(): Promise<HeatmapYear> {
const byDate = (await fetchGitHubDays()) ?? (await loadFallbackDays());
// 53 周网格装配:窗口内逐日取数,未来日 0(渲染空档)
+22
View File
@@ -0,0 +1,22 @@
/**
* 文章可见性契约(单一事实源)。
*
* 为什么需要它:后台「隐藏」开关长期是个假开关——它写的是 `hiddenTag: true`,
* 而全站过滤只认 `tags` 里有没有 `hidden`,于是「隐藏 + 置顶」的文章照样出现在
* 首页/归档/书架,还因为置顶排在第一位(2026-11-04 用户实测报障)。
*
* 现在两条通道都认:
* - `hidden: true`(后台开关写入的真字段)
* - `tags` 含 `hidden`(历史写法,保留兼容)
* - `draft: true`(草稿,构建期即不生成页面的语义保持由各处 getCollection 过滤承担)
*/
type PostVisibility = {
draft?: boolean;
hidden?: boolean;
tags?: string[];
};
/** 文章是否应在列表/RSS/站点地图等处出现 */
export function isVisiblePost(data: PostVisibility): boolean {
return !data.draft && !data.hidden && !(data.tags ?? []).includes('hidden');
}
+2 -1
View File
@@ -1,5 +1,6 @@
---
import type { CollectionEntry } from 'astro:content';
import { isVisiblePost } from '../../lib/post-visibility';
import { getCollection, render } from 'astro:content';
import LinkCardStyles from '../../components/geek/LinkCardStyles.astro';
import PhotoZoom from '../../components/geek/PhotoZoom.astro';
@@ -15,7 +16,7 @@ export async function getStaticPaths() {
const all = (await getCollection('posts', ({ data }) => !data.draft)).sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
);
const nav = all.filter(({ data }) => !data.tags.includes('hidden'));
const nav = all.filter(({ data }) => isVisiblePost(data));
return all.map((entry) => {
const i = nav.findIndex((e) => e.id === entry.id);
return {
+2 -1
View File
@@ -1,13 +1,14 @@
---
// 导航「文章」落点:全量归档列表(年份/轨道分节逻辑与首页共用 PostList)
import { getCollection } from 'astro:content';
import { isVisiblePost } from '../../lib/post-visibility';
import BaseLayout from '../../layouts/BaseLayout.astro';
import PostList from '../../components/PostList.astro';
import { sortPosts } from '../../lib/post-order';
// 置顶优先 → 时间倒序(sortPosts 契约见 src/lib/post-order.ts)
const posts = sortPosts(
await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden')),
await getCollection('posts', ({ data }) => isVisiblePost(data)),
);
---
+2 -1
View File
@@ -1,6 +1,7 @@
---
// /en/archives/{slug}:英文版文章页——postsEn 优先;缺译回退 zh 正文 + 醒目标注
import type { CollectionEntry } from 'astro:content';
import { isVisiblePost } from '../../../lib/post-visibility';
import { getCollection, render } from 'astro:content';
import LinkCardStyles from '../../../components/geek/LinkCardStyles.astro';
import PhotoZoom from '../../../components/geek/PhotoZoom.astro';
@@ -15,7 +16,7 @@ export async function getStaticPaths() {
const all = (await getCollection('posts', ({ data }) => !data.draft)).sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
);
const nav = all.filter(({ data }) => !data.tags.includes('hidden'));
const nav = all.filter(({ data }) => isVisiblePost(data));
const en = new Map((await getCollection('postsEn')).map((e) => [e.id, e]));
return all.map((entry) => {
const i = nav.findIndex((e) => e.id === entry.id);
+2 -1
View File
@@ -1,12 +1,13 @@
---
// /en/archives/ 列表页(补缺:此前 404)
import { getCollection } from 'astro:content';
import { isVisiblePost } from '../../../lib/post-visibility';
import BaseLayout from '../../../layouts/BaseLayout.astro';
import PostList from '../../../components/PostList.astro';
import { sortPosts } from '../../../lib/post-order';
const posts = sortPosts(
await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden')),
await getCollection('posts', ({ data }) => isVisiblePost(data)),
);
const enTitle = Object.fromEntries(
(await getCollection('postsEn')).map((e) => [e.id, e.data.title]),
+2 -1
View File
@@ -1,11 +1,12 @@
import rss from '@astrojs/rss';
import { isVisiblePost } from '../../lib/post-visibility';
import { getCollection } from 'astro:content';
import type { APIContext } from 'astro';
// 英文 RSS:postsEn 全量;标题缺译时回退 zh 标题(与 /en/ 页同策略)
export async function GET(context: APIContext) {
const en = new Map((await getCollection('postsEn')).map((e) => [e.id, e]));
const posts = (await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden'))).sort(
const posts = (await getCollection('posts', ({ data }) => isVisiblePost(data))).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
);
return rss({
+248 -281
View File
@@ -1,16 +1,14 @@
---
/**
* 热力图动效方案 v3(Phase 5j)——用户选定方向「F+J」,但要求贪吃蛇能**循环**,
* 并希望再看几个新变体。故本页给四个蛇家族方案 + J 柱状图参考:
* 热力图方案 v4(Phase 5j):用户认可 FJ(蛇 + 月柱联动),要求**优化蛇的行动路径**。
*
* F1 循环贪吃蛇(GitHub README 蛇的循环逻辑)
* F2 生长蛇(吃得越多身体越长)
* F3 双蛇对开(两头同时吃,交汇于年中)
* FJ 蛇 + 月度柱联动(用户要的 F+J 组合)
* J 月度柱状图(单独保留作参考)
* 现状问题(真实数据):371 格里只有 51 天有贡献(14%),原路径是"按列扫整张网格",
* 于是 86% 的行程在空格上、且格子跳变(每 26ms 瞬移一格)显得机械。
*
* 设计红线:accent 只做高亮不参与数据编码(数据密度仍由 ink 四档承载)、
* prefers-reduced-motion 静态化、离开视口即停、零第三方依赖。
* 本版给三条路径优化,都保留 FJ 的月柱联动:
* P1 智能寻食:只按"最近的有贡献格子"走,中间用子格插值滑行——蛇总在吃东西附近
* P2 平滑蛇体:仍扫全网,但头是连续坐标滑行 + 拖尾,看起来像真蛇在游
* P3 分月清扫:按月成块清扫,月底停一拍并让该月柱子立起(节奏感来自"月")
*/
import BaseLayout from '../layouts/BaseLayout.astro';
import HeatGrid from '../components/geek/HeatGrid.astro';
@@ -19,29 +17,9 @@ import { loadHeatmap, type HeatDay } from '../lib/heatmap';
const { weeks, total } = await loadHeatmap();
const days: HeatDay[] = weeks.flat();
const buildTime = new Date().toISOString().slice(0, 10);
const activeDays = days.filter((d) => d.count > 0).length;
/* 连续贡献(本地计算,口径:以最后一个有贡献的日子为终点往回数) */
const countOf = new Map(days.map((d) => [d.date, d.count]));
const isoOf = (d: Date) => d.toISOString().slice(0, 10);
const lastActive = [...days].reverse().find((d) => d.count > 0)?.date ?? '';
let current = 0;
if (lastActive) {
const start = new Date(`${lastActive}T00:00:00Z`);
for (let i = 0; ; i++) {
const d = new Date(start);
d.setUTCDate(start.getUTCDate() - i);
if ((countOf.get(isoOf(d)) ?? 0) > 0) current++;
else break;
}
}
let longest = 0;
let run = 0;
for (const d of days) {
run = d.count > 0 ? run + 1 : 0;
if (run > longest) longest = run;
}
/* 月度聚合(J / FJ 用) */
/* 月度聚合(柱子) */
const byMonth = new Map<string, number>();
for (const d of days) {
const key = d.date.slice(0, 7);
@@ -49,81 +27,38 @@ for (const d of days) {
}
const months = [...byMonth.entries()].sort(([a], [b]) => a.localeCompare(b));
const maxMonth = Math.max(...months.map(([, v]) => v));
const activeDays = days.filter((d) => d.count > 0).length;
const stats = { total, current, longest, activeDays };
---
<BaseLayout title="热力图动效方案 v3 · wait4x" description="贪吃蛇的四个循环变体 + 月度柱状图">
<h1 class="pv-title">热力图:蛇的四个变体</h1>
<BaseLayout title="热力图方案 v4 · wait4x" description="蛇的行动路径优化三案(FJ 骨架)">
<h1 class="pv-title">蛇的路径优化(FJ 骨架)</h1>
<p class="pv-note">
按你的选择做成 <strong>F + J</strong> 方向,并让蛇<strong>循环</strong>爬(学 GitHub README 蛇的
节奏:吃完一轮 → 停顿 → 清空 → 再来)。真实数据:近一年 <strong>{stats.total}</strong> 次贡献 ·
{stats.activeDays} 天有贡献 · 当前连续 {stats.current} 天 · 数据更新于 {buildTime}。
<br />
<strong>回字母告诉我选哪个</strong>(F1 / F2 / F3 / FJ / J),也可以说「FJ 但循环慢一点」。
你认可 FJ,问题在路径。真实数据:371 格里只有 <strong>{activeDays}</strong> 天有贡献(
{Math.round((activeDays / days.length) * 100)}%),原路径"按列扫全网"意味着
<strong>{100 - Math.round((activeDays / days.length) * 100)}% 的行程在空转</strong>,
而且每 26ms 瞬移一格,看着机械。三案都保留月柱联动。<strong>回 P1 / P2 / P3 告诉我选哪个。</strong>
</p>
<nav class="pv-switch" aria-label="方案跳转">
<a href="#f1" class="pv-chip">F1 · 循环贪吃蛇</a>
<a href="#f2" class="pv-chip">F2 · 生长蛇</a>
<a href="#f3" class="pv-chip">F3 · 双蛇对开</a>
<a href="#fj" class="pv-chip">FJ · 蛇 + 柱联动</a>
<a href="#j" class="pv-chip">J · 月度柱</a>
<a href="#p1" class="pv-chip">P1 · 智能寻食</a>
<a href="#p2" class="pv-chip">P2 · 平滑蛇体</a>
<a href="#p3" class="pv-chip">P3 · 分月清扫</a>
</nav>
<!-- ══════ F1 · 循环贪吃蛇 ══════ -->
<section id="f1" class="pv-sec">
<!-- ══════ P1 · 智能寻食 ══════ -->
<section id="p1" class="pv-sec">
<header class="pv-head">
<span class="pv-tag mono">F1</span>
<h2>循环贪吃蛇</h2>
<p class="pv-pros">蛇沿格子蛇形爬一年,吃到有贡献的日子就点亮;爬到尽头停一拍、清空、重新开始,
<b>无限循环</b>。身体保持 6 格长度(GitHub README 蛇手感)。</p>
<span class="pv-tag mono">P1</span>
<h2>智能寻食</h2>
<p class="pv-pros">
蛇只按「离当前最近的有贡献格子」前进(最近邻路径),两格之间用<strong>子格插值滑行</strong>,
不再逐格瞬移。<b>优点</b>行程从 371 格压到 ~<span data-calc="p1">—</span> 步,动作始终有目的、
永远在吃东西;<b>代价</b>不再"扫过整年",没贡献的日子只是路过。
</p>
</header>
<div class="hm-wrap" data-snake data-body={6}>
<HeatGrid weeks={weeks} variant="f1" />
</div>
<p class="hm-read mono"><span data-snake-read>准备出发…</span> · 第 <span data-snake-loop>1</span> 轮 ·
<button class="hm-btn" type="button" data-snake-toggle>暂停</button></p>
</section>
<!-- ══════ F2 · 生长蛇 ══════ -->
<section id="f2" class="pv-sec">
<header class="pv-head">
<span class="pv-tag mono">F2</span>
<h2>生长蛇</h2>
<p class="pv-pros">每吃到一天就长一节(最长 26 节),前半程是细蛇、后半程拖成长尾,吃完循环重置。
<b>优点</b>"积累"的隐喻最强;<b>代价</b>尾长时略挡格子。</p>
</header>
<div class="hm-wrap" data-snake data-body={3} data-grow={26}>
<HeatGrid weeks={weeks} variant="f2" />
</div>
<p class="hm-read mono"><span data-snake-read>准备出发…</span> · 蛇长 <span data-snake-len>3</span> 节</p>
</section>
<!-- ══════ F3 · 双蛇对开 ══════ -->
<section id="f3" class="pv-sec">
<header class="pv-head">
<span class="pv-tag mono">F3</span>
<h2>双蛇对开</h2>
<p class="pv-pros">两条蛇分别从最早与最新一头出发对向而吃,在年中附近交汇,吃完一起循环。
<b>优点</b>更有戏剧性、时间感双向;<b>代价</b>两条同时动,注意力会被分走。</p>
</header>
<div class="hm-wrap" data-snake data-body={5} data-dual="1">
<HeatGrid weeks={weeks} variant="f3" />
</div>
<p class="hm-read mono"><span data-snake-read>准备出发…</span></p>
</section>
<!-- ══════ FJ · 蛇 + 月柱联动 ══════ -->
<section id="fj" class="pv-sec">
<header class="pv-head">
<span class="pv-tag mono">FJ</span>
<h2>蛇 + 月度柱联动 <span class="pv-rec">你的选择方向</span></h2>
<p class="pv-pros">蛇每爬进一个月,对应的柱子就同步升起并高亮;爬到年末时十二根柱子全部立起,
然后蛇回到起点、柱子清零、再来一轮。<b>优点</b>格子与柱状图互相解释,信息与动感兼得。</p>
</header>
<div class="hm-wrap" data-snake data-body={6} data-bars-link="1">
<HeatGrid weeks={weeks} variant="fj" />
<div class="hm-stage" data-run data-mode="smart" data-body="5">
<div class="hm-wrap"><HeatGrid weeks={weeks} variant="p1" /></div>
<span class="hm-head" data-head aria-hidden="true"></span>
<span class="hm-trail" data-trail aria-hidden="true"></span>
</div>
<ul class="hm-bars" data-months>
{months.map(([m, v], i) => (
@@ -133,216 +68,256 @@ const stats = { total, current, longest, activeDays };
</li>
))}
</ul>
<p class="hm-read mono"><span data-snake-read>准备出发…</span></p>
<p class="hm-read mono"><span data-read>准备出发…</span> · 第 <span data-loop>1</span> 轮</p>
</section>
<!-- ══════ J · 月度柱(单独参考)══════ -->
<section id="j" class="pv-sec">
<!-- ══════ P2 · 平滑蛇体 ══════ -->
<section id="p2" class="pv-sec">
<header class="pv-head">
<span class="pv-tag mono">J</span>
<h2>月度柱状图</h2>
<p class="pv-pros">不带动效时的样子:进视口逐根升起,指针移到柱子上,对应月份的格子同时高亮。
<b>可单独用,也可像 FJ 那样与蛇联动。</b></p>
<span class="pv-tag mono">P2</span>
<h2>平滑蛇体</h2>
<p class="pv-pros">
仍然扫过整年,但蛇头是<strong>连续坐标滑行</strong>(不是格子跳变),身后拖 8 节渐隐的身体,
转弯处带一点缓动。<b>优点</b>最像真蛇、动作最顺;<b>代价</b>依旧会经过空格(但不再"顿")。
</p>
</header>
<div class="hm-wrap" data-bars>
<HeatGrid weeks={weeks} variant="j" />
<div class="hm-stage" data-run data-mode="smooth" data-body="8">
<div class="hm-wrap"><HeatGrid weeks={weeks} variant="p2" /></div>
<span class="hm-head" data-head aria-hidden="true"></span>
<span class="hm-trail" data-trail aria-hidden="true"></span>
</div>
<ul class="hm-bars" data-bars-chart>
{months.map(([m, v]) => (
<ul class="hm-bars" data-months>
{months.map(([m, v], i) => (
<li>
<button class="hm-bar-btn" type="button" data-month={m} aria-label={`${m} 共 ${v} 次`}>
<span class="hm-bar-fill" style={`--h:${Math.round((v / maxMonth) * 100)}%`} />
</button>
<span class="hm-bar-btn"><span class="hm-bar-fill" data-month-idx={i} style={`--h:${Math.round((v / maxMonth) * 100)}%`} /></span>
<span class="hm-bar-label mono">{m.slice(5)}</span>
</li>
))}
</ul>
<p class="hm-read mono"><span data-read>准备出发…</span> · 第 <span data-loop>1</span> 轮</p>
</section>
<p class="pv-foot mono">数据口径:GitHub GraphQL 贡献日历,构建期快照({buildTime})。"减少动态效果"下全部静态;离开视口自动暂停。</p>
<!-- ══════ P3 · 分月清扫 ══════ -->
<section id="p3" class="pv-sec">
<header class="pv-head">
<span class="pv-tag mono">P3</span>
<h2>分月清扫</h2>
<p class="pv-pros">
按月成块清扫:一个月扫完停一拍(180ms)并让该月柱子"立起来",再进下个月。
<b>优点</b>节奏由"月"提供、与柱子天然同步、扫过空格时速度更快;<b>代价</b>每月有个小停顿。
</p>
</header>
<div class="hm-stage" data-run data-mode="month" data-body="6">
<div class="hm-wrap"><HeatGrid weeks={weeks} variant="p3" /></div>
<span class="hm-head" data-head aria-hidden="true"></span>
<span class="hm-trail" data-trail aria-hidden="true"></span>
</div>
<ul class="hm-bars" data-months>
{months.map(([m, v], i) => (
<li>
<span class="hm-bar-btn"><span class="hm-bar-fill" data-month-idx={i} style={`--h:${Math.round((v / maxMonth) * 100)}%`} /></span>
<span class="hm-bar-label mono">{m.slice(5)}</span>
</li>
))}
</ul>
<p class="hm-read mono"><span data-read>准备出发…</span> · 当前 <span data-month-label>—</span></p>
</section>
<span hidden data-months-map={JSON.stringify(months.map(([m]) => m))} data-weeks={weeks.length}></span>
<p class="pv-foot mono">
数据口径:GitHub GraphQL 贡献日历,构建期快照({buildTime})· 共 {total} 次贡献 / {activeDays} 天。
三案共用同一套「头 + 拖尾 + 月柱」引擎,只换路径策略;"减少动态效果"下静态呈终态。
</p>
</BaseLayout>
<script>
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const weeksN = Number(document.querySelector<HTMLElement>('[data-weeks]')?.dataset.weeks ?? 53);
const monthKeys: string[] = JSON.parse(
document.querySelector<HTMLElement>('[data-months-map]')?.dataset.monthsMap ?? '[]',
);
const CELL = 10;
const GAP = 3;
const PITCH = CELL + GAP;
/** 蛇形路径:按列推进,奇数列自下而上(与 GitHub README 蛇同构) */
const buildPath = () => {
const p: number[] = [];
for (let c = 0; c < weeksN; c++) {
const rows = [0, 1, 2, 3, 4, 5, 6];
if (c % 2 === 1) rows.reverse();
for (const r of rows) p.push(c * 7 + r);
}
return p;
};
const PATH = buildPath();
for (const stage of document.querySelectorAll<HTMLElement>('[data-run]')) {
const wrap = stage.querySelector<HTMLElement>('.hm-wrap');
const head = stage.querySelector<HTMLElement>('[data-head]');
const trailEl = stage.querySelector<HTMLElement>('[data-trail]');
const bars = [...stage.parentElement!.querySelectorAll<HTMLElement>('[data-month-idx]')];
const read = stage.parentElement?.querySelector<HTMLElement>('[data-read]');
const loopEl = stage.parentElement?.querySelector<HTMLElement>('[data-loop]');
const monthLabel = stage.parentElement?.querySelector<HTMLElement>('[data-month-label]');
if (!wrap || !head || !trailEl) continue;
for (const wrap of document.querySelectorAll<HTMLElement>('[data-snake]')) {
const cells = [...wrap.querySelectorAll<HTMLElement>('.hg-c')];
const readEl = wrap.parentElement?.querySelector<HTMLElement>('[data-snake-read]');
const loopEl = wrap.parentElement?.querySelector<HTMLElement>('[data-snake-loop]');
const lenEl = wrap.parentElement?.querySelector<HTMLElement>('[data-snake-len]');
const toggle = wrap.parentElement?.querySelector<HTMLButtonElement>('[data-snake-toggle]');
const barsEl = wrap.parentElement?.querySelector<HTMLElement>('[data-months]');
const bars = barsEl ? [...barsEl.querySelectorAll<HTMLElement>('[data-month-idx]')] : [];
const mode = stage.dataset.mode ?? 'smart';
const bodyLen = Number(stage.dataset.body ?? 5);
const weeks = Math.ceil(cells.length / 7);
const posOf = (idx: number) => ({ x: (idx % 7) * PITCH, y: Math.floor(idx / 7) * PITCH });
const baseBody = Number(wrap.dataset.body ?? 6);
const growTo = Number(wrap.dataset.grow ?? 0);
const dual = wrap.dataset.dual === '1';
const barsLink = wrap.dataset.barsLink === '1';
/** 最近邻路径:只走有贡献的格子(P1 用;P2/P3 用蛇形全网) */
const smartPath = () => {
const startAt = cells.findIndex((c) => Number(c.dataset.count ?? 0) > 0);
const pool = cells
.map((c, i) => ({ i, n: Number(c.dataset.count ?? 0) }))
.filter((c) => c.n > 0)
.map((c) => c.i);
if (!pool.length) return [];
const order: number[] = [];
let cur = pool.includes(startAt) ? startAt : pool[0];
pool.splice(pool.indexOf(cur), 1);
order.push(cur);
while (pool.length) {
let best = 0;
let bestD = Infinity;
for (let k = 0; k < pool.length; k++) {
const a = posOf(cur);
const b = posOf(pool[k]);
const d = Math.hypot(a.x - b.x, a.y - b.y) + Math.abs(a.y - b.y) * 0.3; // 轻微偏好同一列,少横跳
if (d < bestD) {
bestD = d;
best = k;
}
}
cur = pool[best];
pool.splice(best, 1);
order.push(cur);
}
return order;
};
let body = baseBody;
const serpentine = () => {
const p: number[] = [];
for (let c = 0; c < weeks; c++) {
const rows = [0, 1, 2, 3, 4, 5, 6];
if (c % 2 === 1) rows.reverse();
for (const r of rows) p.push(c * 7 + r);
}
return p.filter((i) => i < cells.length);
};
/** 真实月份序列(从格子日期去重排序):柱子的顺序与它一致 */
const monthsReal = (() => {
const set = new Set<string>();
cells.forEach((c) => set.add((c.dataset.date ?? '').slice(0, 7)));
return [...set].filter(Boolean).sort();
})();
const waypoints = mode === 'smart' ? smartPath() : serpentine();
// 子格插值:把路径展开成"连续坐标序列"(越长的段走越快,节奏才不拖)
const samples: { x: number; y: number; idx: number }[] = [];
for (let k = 0; k < waypoints.length; k++) {
const from = posOf(waypoints[k]);
const to = posOf(waypoints[(k + 1) % waypoints.length]);
const dist = Math.hypot(to.x - from.x, to.y - from.y);
const steps = Math.max(1, Math.round(dist / (mode === 'smart' ? 7 : 5)));
for (let s = 0; s < steps; s++) {
const t = s / steps;
samples.push({
x: from.x + (to.x - from.x) * t,
y: from.y + (to.y - from.y) * t,
idx: waypoints[k],
});
}
}
const calc = stage.parentElement?.querySelector<HTMLElement>('[data-calc="p1"]');
if (calc) calc.textContent = String(waypoints.length);
let i = 0;
let loop = 1;
let eaten = 0;
let loopNo = 1;
let timer = 0;
let paused = false;
// 双蛇:两条独立游标,反向推进
const cursors = dual ? [0, PATH.length - 1] : [0];
const dirs = dual ? [1, -1] : [1];
const trails: number[][] = cursors.map(() => []);
let monthSeen = '';
const paint = () => {
cells.forEach((c) => c.classList.remove('snake-head', 'snake-body', 'snake-eaten'));
trails.forEach((t) => t.forEach((i) => cells[i]?.classList.add('snake-body')));
cursors.forEach((cur, si) => {
const cell = cells[PATH[cur]];
if (cell) cell.classList.add('snake-head');
const e = trails[si];
if (e) e[e.length - 1] = PATH[cur];
void cur;
});
const setHead = (x: number, y: number, visible = true) => {
head.style.transform = `translate(${x}px, ${y}px)`;
head.style.opacity = visible ? '1' : '0';
};
const trail: { x: number; y: number }[] = [];
const paintTrail = () => {
trailEl.style.boxShadow = trail
.map((p, k) => `${p.x}px ${p.y}px 0 ${Math.max(1, 4 - k)}px color-mix(in oklab, var(--accent) ${45 - k * 4}%, transparent)`)
.join(',');
};
const reset = (full: boolean) => {
const reset = () => {
i = 0;
eaten = 0;
body = baseBody;
cursors.forEach((_, i) => (cursors[i] = i === 0 ? 0 : PATH.length - 1));
trails.forEach((t) => (t.length = 0));
cells.forEach((c) => c.classList.remove('snake-head', 'snake-body', 'snake-eaten'));
if (full) bars.forEach((b) => b.classList.remove('on'));
if (lenEl) lenEl.textContent = String(body);
if (loopEl) loopEl.textContent = String(loopNo);
trail.length = 0;
cells.forEach((c) => c.classList.remove('snake-eaton', 'snake-path'));
bars.forEach((b) => b.classList.remove('on'));
monthSeen = '';
paintTrail();
};
const step = () => {
let moved = false;
cursors.forEach((cur, si) => {
const idx = PATH[cur];
if (idx === undefined) return;
moved = true;
const cell = cells[idx];
if (!cell) return;
// 轨迹队列:保持 body 长度
const trail = trails[si];
trail.push(idx);
if (trail.length > body) {
const dropped = trail.shift();
cells[dropped ?? -1]?.classList.remove('snake-body');
}
cells.forEach((c) => c.classList.remove('snake-head'));
cursors.forEach((c2) => cells[PATH[c2]]?.classList.add('snake-head'));
cells[idx].classList.add('snake-body');
const count = Number(cell.dataset.count ?? 0);
if (count > 0) {
cell.classList.add('snake-eaten');
eaten++;
if (growTo) body = Math.min(growTo, body + 1);
if (readEl)
readEl.textContent = `${cell.dataset.date} · 吃到 ${count} 次(累计 ${eaten} 天)`;
}
// 月份联动:柱子按当前列所属月份点亮
if (barsLink && bars.length) {
const col = Math.floor(idx / 7);
const date = cell.dataset.date ?? '';
const mi = monthKeys.indexOf(date.slice(0, 7));
if (mi >= 0) bars.slice(0, mi + 1).forEach((b) => b.classList.add('on'));
}
if (lenEl) lenEl.textContent = String(body);
// 推进游标
const next = cur + dirs[si];
cursors[si] = next;
if (next < 0 || next >= PATH.length) cursors[si] = -1 as number;
});
// 全部游标到端点 → 一轮结束
if (!moved || cursors.every((c) => c < 0)) {
clearInterval(timer);
timer = 0;
if (readEl) readEl.textContent = `第 ${loopNo} 轮完成:吃到 ${eaten} 天`;
loopNo++;
setTimeout(() => {
reset(true);
let holdUntil = 0;
const tick = () => {
if (holdUntil && Date.now() < holdUntil) return; // 换月小停顿
holdUntil = 0;
if (i >= samples.length) {
if (read) read.textContent = `第 ${loop} 轮完成:吃到 ${eaten} 天`;
loop++;
if (loopEl) loopEl.textContent = String(loop);
window.setTimeout(() => {
reset();
if (!paused) start();
}, 900);
}, 700);
window.clearInterval(timer);
timer = 0;
return;
}
const s = samples[i++];
setHead(s.x + CELL / 2, s.y + CELL / 2);
trail.unshift({ x: s.x + CELL / 2, y: s.y + CELL / 2 });
if (trail.length > bodyLen) trail.pop();
paintTrail();
const cell = cells[s.idx];
if (!cell) return;
// 月柱联动(三案共用):柱子点亮到当前格所属月份
const mk = (cell.dataset.date ?? '').slice(0, 7);
const mi = monthsReal.indexOf(mk);
if (mi >= 0) bars.slice(0, mi + 1).forEach((b) => b.classList.add('on'));
// P3:换月时额外停一拍 + 显示月份(只影响节奏,不重建计时器)
if (mode === 'month' && mk && mk !== monthSeen) {
monthSeen = mk;
if (monthLabel) monthLabel.textContent = mk;
if (holdUntil === 0) holdUntil = Date.now() + 180;
}
cell.classList.add('snake-path');
if (Number(cell.dataset.count ?? 0) > 0) {
cell.classList.add('snake-eaten');
eaten++;
if (read) read.textContent = `${cell.dataset.date} · 吃到 ${cell.dataset.count} 次(累计 ${eaten} 天)`;
}
void paint;
};
const speed = mode === 'smart' ? 22 : mode === 'smooth' ? 20 : 18;
const start = () => {
if (timer || paused) return;
reset(false);
timer = window.setInterval(step, 26);
if (!samples.length) return;
timer = window.setInterval(tick, speed);
};
const stop = () => {
if (timer) clearInterval(timer);
timer = 0;
};
toggle?.addEventListener('click', () => {
paused = !paused;
toggle.textContent = paused ? '继续' : '暂停';
if (paused) stop();
else start();
});
// 减少动态:静态终态
if (reduce) {
// 减少动态:直接展示终态(所有有贡献的格子点亮 + 柱子立起)
cells.forEach((c) => {
if (Number(c.dataset.count ?? 0) > 0) c.classList.add('snake-eaten');
});
bars.forEach((b) => b.classList.add('on'));
if (readEl) readEl.textContent = '已按"减少动态效果"静态呈现';
setHead(0, 0, false);
if (read) read.textContent = '已按"减少动态效果"静态呈现';
} else {
new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) start();
else stop();
else {
if (timer) window.clearInterval(timer);
timer = 0;
}
}
},
{ threshold: 0.15 },
).observe(wrap);
}
}
/* J:单独的柱状图(进视口升起 + hover 联动) */
const chart = document.querySelector<HTMLElement>('[data-bars-chart]');
if (chart) {
new IntersectionObserver(
(entries) => {
for (const e of entries) if (e.isIntersecting) e.target.classList.add('grow');
},
{ threshold: 0.2 },
).observe(chart);
const grid = document.querySelector<HTMLElement>('[data-bars]');
for (const btn of chart.querySelectorAll<HTMLElement>('[data-month]')) {
const m = btn.dataset.month ?? '';
const on = () =>
grid?.querySelectorAll<HTMLElement>('.hg-c').forEach((c) =>
c.classList.toggle('hg-month', (c.dataset.date ?? '').startsWith(m)),
);
const off = () => grid?.querySelectorAll<HTMLElement>('.hg-c').forEach((c) => c.classList.remove('hg-month'));
btn.addEventListener('pointerenter', on);
btn.addEventListener('pointerleave', off);
btn.addEventListener('focus', on);
btn.addEventListener('blur', off);
).observe(stage);
}
}
</script>
@@ -364,55 +339,47 @@ const stats = { total, current, longest, activeDays };
.pv-sec { margin-top: var(--space-6); scroll-margin-top: 56px; }
.pv-head { border-top: 1px solid var(--line); padding-top: var(--space-3); }
.pv-tag { display: inline-block; font-size: 11px; color: var(--ink-4); border: 1px solid var(--line); border-radius: 4px; padding: 0 5px; }
.pv-rec { font-size: 11px; color: var(--accent); border: 1px solid color-mix(in oklab, var(--accent) 40%, var(--line)); border-radius: 4px; padding: 0 5px; margin-left: 6px; font-family: var(--font-mono); }
.pv-head h2 { font-size: 20px; color: var(--ink-1); margin: var(--space-1) 0; }
.pv-pros { font-size: 13px; color: var(--ink-3); line-height: 1.7; max-width: var(--measure); margin: 0 0 var(--space-3); }
.pv-pros b { color: var(--ink-2); }
.pv-foot { font-size: 12px; color: var(--ink-4); margin-top: var(--space-6); }
.hm-wrap { overflow-x: auto; padding: 6px 0; }
.hm-read { font-size: 12px; color: var(--ink-4); margin-top: var(--space-2); display: flex; gap: var(--space-2); align-items: center; flex-wrap: wrap; }
.hm-btn {
font-family: var(--font-mono); font-size: 12px; color: var(--ink-2);
background: var(--bg-soft); border: 1px solid var(--line); border-radius: 6px; padding: 3px 10px; cursor: pointer;
}
.hm-btn:hover { border-color: var(--accent); }
.hm-read { font-size: 12px; color: var(--ink-4); margin-top: var(--space-2); }
/* 蛇:头 / 身 / 吃过 */
:global(.snake-head) {
outline: 2px solid var(--accent);
outline-offset: 1px;
z-index: 2;
position: relative;
}
:global(.snake-body) {
background: color-mix(in oklab, var(--accent) 32%, transparent) !important;
/* 舞台:网格 + 浮动的蛇头/拖尾(位置由 JS 用 transform 控制) */
.hm-stage { position: relative; }
.hm-stage .hm-wrap { overflow-x: auto; padding: 6px 0; }
.hm-head {
position: absolute; left: 0; top: 6px; width: 10px; height: 10px; border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent) 28%, transparent), 0 0 10px color-mix(in oklab, var(--accent) 55%, transparent);
pointer-events: none;
transition: transform 90ms linear, opacity 200ms var(--ease);
z-index: 3;
}
.hm-trail { position: absolute; left: 0; top: 6px; width: 1px; height: 1px; pointer-events: none; z-index: 2; }
:global(.snake-path) { background: color-mix(in oklab, var(--accent) 18%, transparent); }
:global(.snake-eaten) {
background: var(--accent) !important;
box-shadow: 0 0 0 1px color-mix(in oklab, var(--accent) 45%, transparent);
transition: background 180ms var(--ease);
transition: background 160ms var(--ease);
}
/* 月度柱 */
.hm-bars {
display: flex; align-items: flex-end; gap: 6px; list-style: none;
padding: 0; margin: var(--space-2) 0 0; height: 84px;
padding: 0; margin: var(--space-2) 0 0; height: 76px;
}
.hm-bars li { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; height: 100%; justify-content: flex-end; }
.hm-bar-btn { width: 100%; height: 100%; display: flex; align-items: flex-end; background: none; border: 0; padding: 0; cursor: pointer; }
.hm-bar-btn { width: 100%; height: 100%; display: flex; align-items: flex-end; }
.hm-bar-fill {
display: block; width: 100%; height: 0;
background: var(--ink-4); border-radius: 2px 2px 0 0;
transition: height 420ms cubic-bezier(0.22, 0.8, 0.3, 1), background 160ms var(--ease);
transition: height 380ms cubic-bezier(0.22, 0.8, 0.3, 1), background 160ms var(--ease);
}
.grow .hm-bar-fill { height: var(--h, 0%); }
/* FJ:蛇爬到该月时柱子升起(由 JS 加 .on) */
.hm-bar-fill.on { height: var(--h, 0%); background: var(--accent); }
.hm-bar-btn:hover .hm-bar-fill { background: var(--accent); }
.hm-bar-label { font-size: 10.5px; color: var(--ink-4); }
:global(.hg-month) { background: var(--accent) !important; }
@media (prefers-reduced-motion: reduce) {
.hm-head { transition: none; }
.hm-bar-fill { transition: none; }
}
</style>
+2 -1
View File
@@ -1,10 +1,11 @@
import rss from '@astrojs/rss';
import { isVisiblePost } from '../lib/post-visibility';
import { getCollection } from 'astro:content';
import type { APIContext } from 'astro';
// RSS:posts 集合全量(draft 排除);Phase 3 起双语各出一路(/rss.xml 与 /en/rss.xml)
export async function GET(context: APIContext) {
const posts = (await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden')))
const posts = (await getCollection('posts', ({ data }) => isVisiblePost(data)))
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
return rss({
title: 'wait4x',