2 Commits
Author SHA1 Message Date
wait4xandGLM-5.3 55cfc73cb7 feat(interact): 访问量小字+瞬间时间线动效(车道 F)
- pv:ArtalkComments/MomentsPage 组件级独立 POST /artalk/api/v2/
  pages/pv(页面加载即计,不等懒加载;POST 天然绕 ESA 边缘缓存);
  init 加 pvAdd:false 防双计(默认 true 此前评论加载一直在默默计
  数——mian-sha pv=2 即此来源);双轨 pageKey 分开计数
- 瞬间动效:.tl-item IntersectionObserver 进入视口一次性渐显
  (opacity 0+8px);降级链=data-mom-anim 前置标记——无 JS/
  reduced-motion/无 IO 三情形标记缺席则初始隐藏不生效;软导航
  astro:page-load 重注册
- Artalk 后台真根因修复(车道侧宿主件,非仓内):容器日志实锤
  登录从未成功过(12+ 次 401/400,非会话丢失)——CLI 重置密码+
  凭据入 INITIAL-CREDENTIALS.txt 0600+login_timeout 3d→30d+
  容器重启;API 全链 200 实测
- 遗留(ESA 侧归 orchestrator):ESA 缓存 /artalk/api GET 响应
  (stats Age 头实锤),需边缘规则绕缓存

车道:op-artalk(自验 8/8 含 route mock/reduced-motion/无 JS 三降
级);收编验收:pv 元素+动效标记+reduced-motion 豁免实测

验证: pnpm astro build exit 0;E2E 33/33;三降级链 playwright 实测

Spec: none (pv+动效,车道级交付)

Co-authored-by: GLM-5.3
2026-10-25 22:19:41 +08:00
wait4xandGLM-5.3 d00ca7e447 fix(i18n): 语言轨导航修复+重排,热力图切 GitHub(车道 E)
- 真 bug(用户实测:切 en 后点板块回中文):Header nav href 硬编码
  zh 路径,curLocale 只喂了词条——localeOf(Astro.url) 构建期自推断
  +五项+品牌链接全轨化(brand 原指 / 同病同修),aria 同步
- 导航重排(用户拍板):开源 · 文章 · 图库 · 瞬间 · 关于;瞬间
  新进导航;移动 320-430×双轨八格页面级溢出全 0(内滚阈值 350→450
  +中段地板再收;en 390-430 尾部约 55px 靠内滚——'Open Source'→
  'Projects' 改名可省 35px,留用户拍板)
- 热力图纯切 GitHub(用户拍板):GraphQL contributionsCollection
  53 周窗口(上海日界);token=宿主 0600 github-token.txt,缺位→
  WARN 降级样例数据(当前站点即样例);三级回落 token→样例→空网格
- E2E 新增 2 断言:en 页 nav 全 /en/ 前缀+brand 指 /en/(本 bug
  的回归测试)

车道:op-gitea(自验 33/33);收编验收:en/zh 序实测+三宽度溢出 0+
E2E 33/33(本地 dist 口径——staging 未含未推代码为既定纪律)

验证: pnpm astro build 34 页 exit 0;node tests/e2e.mjs 33/33;
      en/zh 导航序与 href 前缀 playwright 实测

Spec: none (Header 修复+重排+热力图换源,车道级交付)

Co-authored-by: GLM-5.3
2026-10-25 22:19:40 +08:00
7 changed files with 231 additions and 63 deletions

No files matched your search

+38
View File
@@ -6,8 +6,16 @@ interface Props {
pageTitle: string; pageTitle: string;
} }
const { pageKey, pageTitle } = Astro.props; const { pageKey, pageTitle } = Astro.props;
import { t } from '../i18n/ui';
import { localeOf } from '../lib/locale';
const locale = localeOf(Astro.url.pathname);
--- ---
<!-- pv 小字:计数由本组件下方脚本独立 POST(页面加载即计),与 Artalk init 的 pvAdd 解耦 -->
<p class="atk-pv-line" aria-label={t('pv_viewed', locale)}>
<span data-artalk-pv data-page-key={pageKey} data-page-title={pageTitle}>–</span>
{' '}{t('pv_viewed', locale)}
</p>
<div id="artalk-container" data-page-key={pageKey} data-page-title={pageTitle}></div> <div id="artalk-container" data-page-key={pageKey} data-page-title={pageTitle}></div>
<!-- Artalk 主题接管:--at-color-* 全套映射到站点 tokens(值均为 var(),明暗随 <!-- Artalk 主题接管:--at-color-* 全套映射到站点 tokens(值均为 var(),明暗随
@@ -68,9 +76,37 @@ const { pageKey, pageTitle } = Astro.props;
#artalk-container .atk-content a:hover { #artalk-container .atk-content a:hover {
text-decoration-color: var(--accent); text-decoration-color: var(--accent);
} }
.atk-pv-line {
font-family: var(--font-mono);
font-size: 12px;
color: var(--ink-4);
margin: var(--space-2) 0 0;
}
</style> </style>
<script> <script>
// 访问量:POST /artalk/api/v2/pages/pv 计数并取数(POST 天然绕过 ESA 边缘缓存;
// GET /stats/* 会被 ESA 缓存出冻结数字,勿改查询式)。page-load 兼容软导航换页重计。
const bumpPv = async () => {
const el = document.querySelector('[data-artalk-pv]');
if (!el) return;
const key = el.dataset.pageKey ?? '';
if (!key) return;
try {
const res = await fetch('/artalk/api/v2/pages/pv', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ page_key: key, page_title: el.dataset.pageTitle ?? '', site_name: 'wait4x.online' }),
});
const d = await res.json();
el.textContent = String(d.pv ?? '–');
} catch {
el.textContent = '–'; // 计数失败静默:小字不可见性优于报错
}
};
document.addEventListener('astro:page-load', () => void bumpPv());
void bumpPv();
const el = document.getElementById('artalk-container'); const el = document.getElementById('artalk-container');
if (el) { if (el) {
const mount = () => { const mount = () => {
@@ -103,6 +139,8 @@ const { pageKey, pageTitle } = Astro.props;
locale: 'zh-CN', locale: 'zh-CN',
// 锁亮皮:明暗由页面 tokens 接管(见上方 --at-* 映射),避免双暗色机制叠加 // 锁亮皮:明暗由页面 tokens 接管(见上方 --at-* 映射),避免双暗色机制叠加
darkMode: false, darkMode: false,
// pv 计数移交组件级独立 POST(页面加载即计,不等评论区懒加载进入视口)
pvAdd: false,
}); });
}; };
// 进入视口 ~200px 前挂载;无 IO 环境直接挂 // 进入视口 ~200px 前挂载;无 IO 环境直接挂
+20 -11
View File
@@ -2,26 +2,30 @@
import { localeOf, localePath } from '../lib/locale'; import { localeOf, localePath } from '../lib/locale';
import { t } from '../i18n/ui'; import { t } from '../i18n/ui';
// 顶部导航:≤4 项纪律(防 higan 十项复发);当前页 aria-current // 顶部导航:单行纪律 + 双语整轨(zh 无前缀 ↔ /en/,locale 构建期按页自推断——
// 曾只把 locale 喂给词条/语言钮而 href 裸奔 zh 路径,en 页点导航全部跌回中文轨)
const path = Astro.url.pathname; const path = Astro.url.pathname;
const curLocale = localeOf(path);
const prefix = curLocale === 'en' ? '/en' : '';
const isCurrent = (href: string) => const isCurrent = (href: string) =>
path === href || path === href.replace(/\/$/, '') || path.startsWith(href); path === href || path === href.replace(/\/$/, '') || path.startsWith(href);
// 语言切换:同路径跨前缀跳转(zh 无前缀 ↔ /en/),预构建双语毫秒级 // 顺序:开源 · 文章 · 图库 · 瞬间 · 关于(瞬间新进,开源前置)
const curLocale = localeOf(path);
const nav = [ const nav = [
{ href: '/archives/', label: t('nav_posts', curLocale) }, { href: `${prefix}/opensource/`, label: t('nav_opensource', curLocale) },
{ href: '/gallery/', label: t('nav_gallery', curLocale) }, { href: `${prefix}/archives/`, label: t('nav_posts', curLocale) },
{ href: '/about/', label: t('nav_about', curLocale) }, { href: `${prefix}/gallery/`, label: t('nav_gallery', curLocale) },
{ href: '/opensource/', label: t('nav_opensource', curLocale) }, { href: `${prefix}/moments/`, label: t('nav_moments', curLocale) },
{ href: `${prefix}/about/`, label: t('nav_about', curLocale) },
]; ];
const homeHref = curLocale === 'en' ? '/en/' : '/';
const switchHref = localePath(path, curLocale === 'zh' ? 'en' : 'zh'); const switchHref = localePath(path, curLocale === 'zh' ? 'en' : 'zh');
const switchLabel = curLocale === 'zh' ? 'EN' : '中'; const switchLabel = curLocale === 'zh' ? 'EN' : '中';
--- ---
<header class="site-header safe-area"> <header class="site-header safe-area">
<div class="container-wide header-inner"> <div class="container-wide header-inner">
<a href="/" class="brand" aria-label="wait4x 首页">wait4x</a> <a href={homeHref} class="brand" aria-label={`wait4x ${t('nav_home', curLocale)}`}>wait4x</a>
<nav aria-label="主导航"> <nav aria-label="主导航">
<ul> <ul>
{nav.map((item) => ( {nav.map((item) => (
@@ -133,11 +137,16 @@ const switchLabel = curLocale === 'zh' ? 'EN' : '中';
nav ul { gap: clamp(2px, 0.8vw, 8px); } nav ul { gap: clamp(2px, 0.8vw, 8px); }
.header-inner .tap { min-width: clamp(28px, 8vw, 44px); min-height: clamp(28px, 8vw, 44px); } .header-inner .tap { min-width: clamp(28px, 8vw, 44px); min-height: clamp(28px, 8vw, 44px); }
.nav-lang { font-size: clamp(11.5px, 3vw, 13px); } .nav-lang { font-size: clamp(11.5px, 3vw, 13px); }
/* 超窄兜底(<350px,en 长词流式下限后仍溢):导航内滚代替页面晃动—— /* 超窄兜底:导航内滚代替页面晃动——页面级 scrollWidth 恒等于视口,任何机型不破版。
页面级 scrollWidth 恒等于视口,任何机型不破版 */ 阈值 350→450(E 车道 6 项后 en 词长实测:360-430px 页溢 42-51px;zh 无感知) */
@media (max-width: 350px) { @media (max-width: 450px) {
nav ul { overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; } nav ul { overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
nav { min-width: 0; } nav { min-width: 0; }
.header-inner { flex-wrap: nowrap; } .header-inner { flex-wrap: nowrap; }
/* 中段再收一档地板(en 五词在 360-430 差 55-61px):字号 11px 下限仍可读,
目标是常规视口零内滚、内滚只作极端巪余 */
.nav-link { font-size: clamp(11px, 3.2vw, 14px); padding-inline: clamp(1px, 1vw, 8px); }
nav ul { gap: clamp(1px, 0.8vw, 8px); }
.header-inner .tap { min-width: clamp(26px, 8vw, 44px); }
} }
</style> </style>
+70
View File
@@ -20,6 +20,10 @@ const iso = (d: Date) => d.toISOString();
<BaseLayout title={`${t('moments_title', locale)} · wait4x`}> <BaseLayout title={`${t('moments_title', locale)} · wait4x`}>
<h1 class="page-title">{t('moments_title', locale)}</h1> <h1 class="page-title">{t('moments_title', locale)}</h1>
<p class="page-note">{t('moments_note', locale)}</p> <p class="page-note">{t('moments_note', locale)}</p>
<p class="pv-line" aria-label={t('pv_viewed', locale)}>
<span data-artalk-pv data-page-key={locale === 'en' ? '/en/moments/' : '/moments/'}>–</span>
{' '}{t('pv_viewed', locale)}
</p>
<ol class="tl"> <ol class="tl">
{moments.map((m) => ( {moments.map((m) => (
<li class="tl-item"> <li class="tl-item">
@@ -54,6 +58,56 @@ const iso = (d: Date) => d.toISOString();
</ol> </ol>
</BaseLayout> </BaseLayout>
<script>
// 访问量:与 ArtalkComments 同款独立 POST(双轨 pageKey 分开计数;POST 绕 ESA 缓存)
const bumpPv = async () => {
const el = document.querySelector('[data-artalk-pv]');
if (!el) return;
const key = el.dataset.pageKey ?? '';
if (!key) return;
try {
const res = await fetch('/artalk/api/v2/pages/pv', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ page_key: key, site_name: 'wait4x.online' }),
});
const d = await res.json();
el.textContent = String(d.pv ?? '–');
} catch {
el.textContent = '–';
}
};
// 时间线渐显:html[data-mom-anim] 由 JS 前置标记——无 JS / reduced-motion 时
// 标记缺席,CSS 初始隐藏规则整体不生效(渐进降级,元素永远可见)
let io: IntersectionObserver | null = null;
const armAnim = () => {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
if (!('IntersectionObserver' in window)) return;
document.documentElement.dataset.momAnim = 'on';
io?.disconnect();
io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) {
e.target.classList.add('in-view');
io?.unobserve(e.target);
}
}
},
{ rootMargin: '0px 0px -8% 0px' },
);
document.querySelectorAll('.tl-item:not(.in-view)').forEach((el) => io?.observe(el));
};
document.addEventListener('astro:page-load', () => {
void bumpPv();
armAnim();
});
void bumpPv();
armAnim();
</script>
<style> <style>
.page-title { .page-title {
font-size: var(--text-h1); font-size: var(--text-h1);
@@ -147,4 +201,20 @@ const iso = (d: Date) => d.toISOString();
.mom-card { padding: var(--space-2) var(--space-3); } .mom-card { padding: var(--space-2) var(--space-3); }
.mom-fig { max-width: 100%; } .mom-fig { max-width: 100%; }
} }
.pv-line {
font-family: var(--font-mono);
font-size: 12px;
color: var(--ink-4);
margin: 0 0 var(--space-4);
}
/* 初始隐藏仅在 JS 标记存在时生效:宁轻勿闹——透明度+8px 上移,进入视口一次性点亮 */
[data-mom-anim] .tl-item {
opacity: 0;
translate: 0 8px;
transition: opacity 0.45s var(--ease), translate 0.45s var(--ease);
}
[data-mom-anim] .tl-item.in-view {
opacity: 1;
translate: 0 0;
}
</style> </style>
+1 -1
View File
@@ -1 +1 @@
[{"contributions":1,"timestamp":1760925600},{"contributions":3,"timestamp":1761012000},{"contributions":2,"timestamp":1761184800},{"contributions":1,"timestamp":1761271200},{"contributions":2,"timestamp":1761530400},{"contributions":1,"timestamp":1761616800},{"contributions":2,"timestamp":1761876000},{"contributions":1,"timestamp":1762135200},{"contributions":2,"timestamp":1762221600},{"contributions":1,"timestamp":1762308000},{"contributions":2,"timestamp":1762480800},{"contributions":2,"timestamp":1762740000},{"contributions":2,"timestamp":1762826400},{"contributions":1,"timestamp":1763085600},{"contributions":1,"timestamp":1763258400},{"contributions":2,"timestamp":1763344800},{"contributions":2,"timestamp":1763517600},{"contributions":3,"timestamp":1763690400},{"contributions":1,"timestamp":1763949600},{"contributions":2,"timestamp":1764036000},{"contributions":2,"timestamp":1764295200},{"contributions":1,"timestamp":1764813600},{"contributions":3,"timestamp":1765936800},{"contributions":1,"timestamp":1766455200},{"contributions":1,"timestamp":1766541600},{"contributions":1,"timestamp":1766800800},{"contributions":2,"timestamp":1767232800},{"contributions":2,"timestamp":1767319200},{"contributions":1,"timestamp":1768442400},{"contributions":1,"timestamp":1768528800},{"contributions":2,"timestamp":1768788000},{"contributions":2,"timestamp":1768960800},{"contributions":1,"timestamp":1769047200},{"contributions":2,"timestamp":1769133600},{"contributions":1,"timestamp":1769392800},{"contributions":1,"timestamp":1769479200},{"contributions":3,"timestamp":1769652000},{"contributions":1,"timestamp":1769911200},{"contributions":1,"timestamp":1769997600},{"contributions":2,"timestamp":1770256800},{"contributions":1,"timestamp":1770429600},{"contributions":1,"timestamp":1770516000},{"contributions":6,"timestamp":1770602400},{"contributions":1,"timestamp":1770688800},{"contributions":2,"timestamp":1770775200},{"contributions":3,"timestamp":1770861600},{"contributions":4,"timestamp":1770948000},{"contributions":12,"timestamp":1771034400},{"contributions":2,"timestamp":1771120800},{"contributions":12,"timestamp":1771207200},{"contributions":12,"timestamp":1771293600},{"contributions":4,"timestamp":1771380000},{"contributions":1,"timestamp":1771466400},{"contributions":6,"timestamp":1771552800},{"contributions":1,"timestamp":1771639200},{"contributions":8,"timestamp":1771725600},{"contributions":1,"timestamp":1771984800},{"contributions":1,"timestamp":1772503200},{"contributions":3,"timestamp":1772676000},{"contributions":1,"timestamp":1772762400},{"contributions":1,"timestamp":1773021600},{"contributions":2,"timestamp":1773194400},{"contributions":2,"timestamp":1773280800},{"contributions":3,"timestamp":1773367200},{"contributions":2,"timestamp":1773626400},{"contributions":2,"timestamp":1773712800},{"contributions":1,"timestamp":1773972000},{"contributions":1,"timestamp":1774576800},{"contributions":3,"timestamp":1774922400},{"contributions":3,"timestamp":1775008800},{"contributions":3,"timestamp":1775095200},{"contributions":1,"timestamp":1775181600},{"contributions":3,"timestamp":1775613600},{"contributions":2,"timestamp":1775700000},{"contributions":2,"timestamp":1775872800},{"contributions":1,"timestamp":1776132000},{"contributions":2,"timestamp":1776218400},{"contributions":2,"timestamp":1776564000},{"contributions":1,"timestamp":1776736800},{"contributions":2,"timestamp":1776823200},{"contributions":1,"timestamp":1777255200},{"contributions":3,"timestamp":1777341600},{"contributions":1,"timestamp":1777514400},{"contributions":1,"timestamp":1777600800},{"contributions":3,"timestamp":1777860000},{"contributions":2,"timestamp":1778205600},{"contributions":1,"timestamp":1778464800},{"contributions":1,"timestamp":1778551200},{"contributions":2,"timestamp":1778896800},{"contributions":2,"timestamp":1779069600},{"contributions":1,"timestamp":1779242400},{"contributions":1,"timestamp":1779415200},{"contributions":1,"timestamp":1779501600},{"contributions":2,"timestamp":1779847200},{"contributions":2,"timestamp":1780020000},{"contributions":2,"timestamp":1780365600},{"contributions":2,"timestamp":1780711200},{"contributions":2,"timestamp":1780884000},{"contributions":1,"timestamp":1780970400},{"contributions":1,"timestamp":1781056800},{"contributions":2,"timestamp":1781143200},{"contributions":2,"timestamp":1781229600},{"contributions":1,"timestamp":1781488800},{"contributions":1,"timestamp":1781575200},{"contributions":2,"timestamp":1781748000},{"contributions":1,"timestamp":1781834400},{"contributions":1,"timestamp":1782093600},{"contributions":3,"timestamp":1782525600},{"contributions":6,"timestamp":1782612000},{"contributions":2,"timestamp":1782698400},{"contributions":8,"timestamp":1782784800},{"contributions":2,"timestamp":1782871200},{"contributions":2,"timestamp":1782957600},{"contributions":1,"timestamp":1783044000},{"contributions":5,"timestamp":1783130400},{"contributions":1,"timestamp":1783216800},{"contrLine truncated [{"date":"2025-10-20","count":1},{"date":"2025-10-21","count":3},{"date":"2025-10-23","count":2},{"date":"2025-10-24","count":1},{"date":"2025-10-27","count":2},{"date":"2025-10-28","count":1},{"date":"2025-10-31","count":2},{"date":"2025-11-03","count":1},{"date":"2025-11-04","count":2},{"date":"2025-11-05","count":1},{"date":"2025-11-07","count":2},{"date":"2025-11-10","count":2},{"date":"2025-11-11","count":2},{"date":"2025-11-14","count":1},{"date":"2025-11-16","count":1},{"date":"2025-11-17","count":2},{"date":"2025-11-19","count":2},{"date":"2025-11-21","count":3},{"date":"2025-11-24","count":1},{"date":"2025-11-25","count":2},{"date":"2025-11-28","count":2},{"date":"2025-12-04","count":1},{"date":"2025-12-17","count":3},{"date":"2025-12-23","count":1},{"date":"2025-12-24","count":1},{"date":"2025-12-27","count":1},{"date":"2026-01-01","count":2},{"date":"2026-01-02","count":2},{"date":"2026-01-15","count":1},{"date":"2026-01-16","count":1},{"date":"2026-01-19","count":2},{"date":"2026-01-21","count":2},{"date":"2026-01-22","count":1},{"date":"2026-01-23","count":2},{"date":"2026-01-26","count":1},{"date":"2026-01-27","count":1},{"date":"2026-01-29","count":3},{"date":"2026-02-01","count":1},{"date":"2026-02-02","count":1},{"date":"2026-02-05","count":2},{"date":"2026-02-07","count":1},{"date":"2026-02-08","count":1},{"date":"2026-02-09","count":6},{"date":"2026-02-10","count":1},{"date":"2026-02-11","count":2},{"date":"2026-02-12","count":3},{"date":"2026-02-13","count":4},{"date":"2026-02-14","count":12},{"date":"2026-02-15","count":2},{"date":"2026-02-16","count":12},{"date":"2026-02-17","count":12},{"date":"2026-02-18","count":4},{"date":"2026-02-19","count":1},{"date":"2026-02-20","count":6},{"date":"2026-02-21","count":1},{"date":"2026-02-22","count":8},{"date":"2026-02-25","count":1},{"date":"2026-03-03","count":1},{"date":"2026-03-05","count":3},{"date":"2026-03-06","count":1},{"date":"2026-03-09","count":1},{"date":"2026-03-11","count":2},{"date":"2026-03-12","count":2},{"date":"2026-03-13","count":3},{"date":"2026-03-16","count":2},{"date":"2026-03-17","count":2},{"date":"2026-03-20","count":1},{"date":"2026-03-27","count":1},{"date":"2026-03-31","count":3},{"date":"2026-04-01","count":3},{"date":"2026-04-02","count":3},{"date":"2026-04-03","count":1},{"date":"2026-04-08","count":3},{"date":"2026-04-09","count":2},{"date":"2026-04-11","count":2},{"date":"2026-04-14","count":1},{"date":"2026-04-15","count":2},{"date":"2026-04-19","count":2},{"date":"2026-04-21","count":1},{"date":"2026-04-22","count":2},{"date":"2026-04-27","count":1},{"date":"2026-04-28","count":3},{"date":"2026-04-30","count":1},{"date":"2026-05-01","count":1},{"date":"2026-05-04","count":3},{"date":"2026-05-08","count":2},{"date":"2026-05-11","count":1},{"date":"2026-05-12","count":1},{"date":"2026-05-16","count":2},{"date":"2026-05-18","count":2},{"date":"2026-05-20","count":1},{"date":"2026-05-22","count":1},{"date":"2026-05-23","count":1},{"date":"2026-05-27","count":2},{"date":"2026-05-29","count":2},{"date":"2026-06-02","count":2},{"date":"2026-06-06","count":2},{"date":"2026-06-08","count":2},{"date":"2026-06-09","count":1},{"date":"2026-06-10","count":1},{"date":"2026-06-11","count":2},{"date":"2026-06-12","count":2},{"date":"2026-06-15","count":1},{"date":"2026-06-16","count":1},{"date":"2026-06-18","count":2},{"date":"2026-06-19","count":1},{"date":"2026-06-22","count":1},{"date":"2026-06-27","count":3},{"date":"2026-06-28","count":6},{"date":"2026-06-29","count":2},{"date":"2026-06-30","count":8},{"date":"2026-07-01","count":2},{"date":"2026-07-02","count":2},{"date":"2026-07-03","count":1},{"date":"2026-07-04","count":5},{"date":"2026-07-05","count":1},{"date":"2026-07-06","count":1},{"date":"2026-07-07","count":3},{"date":"2026-07-08","count":10},{"date":"2026-07-09","count":8},{"date":"2026-07-10","count":12},{"date":"2026-07-11","count":2},{"date":"2026-07-12","count":2},{"date":"2026-07-13","count":3},{"date":"2026-07-14","count":10},{"date":"2026-07-15","count":5},{"date":"2026-07-16","count":6},{"date":"2026-07-17","count":8},{"date":"2026-07-22","count":2},{"date":"2026-07-24","count":1},{"date":"2026-07-26","count":1},{"date":"2026-07-29","count":2},{"date":"2026-07-30","count":3},{"date":"2026-08-03","count":1},{"date":"2026-08-04","count":1},{"date":"2026-08-05","count":1},{"date":"2026-08-07","count":2},{"date":"2026-08-14","count":1},{"date":"2026-08-18","count":3},{"date":"2026-08-21","count":1},{"date":"2026-08-28","count":2},{"date":"2026-08-31","count":3},{"date":"2026-09-02","count":3},{"date":"2026-09-04","count":1},{"date":"2026-09-06","count":1},{"date":"2026-09-07","count":1},{"date":"2026-09-08","count":1},{"date":"2026-09-10","count":3},{"date":"2026-09-14","count":2},{"date":"2026-09-17","count":1},{"date":"2026-09-18","count":1},{"date":"2026-09-21","count":2},{"date":"2026-09-22","count":2},{"date":"2026-09-23","count":1},{"date":"2026-09-24","count":2},{"date":"2026-09-29","count":2},Line truncated
+4 -1
View File
@@ -72,7 +72,7 @@ const dict = {
heatmap_summary: { zh: '最近一年 {n} 次贡献', en: '{n} contributions in the last year' }, heatmap_summary: { zh: '最近一年 {n} 次贡献', en: '{n} contributions in the last year' },
heatmap_less: { zh: '少', en: 'Less' }, heatmap_less: { zh: '少', en: 'Less' },
heatmap_more: { zh: '多', en: 'More' }, heatmap_more: { zh: '多', en: 'More' },
heatmap_note: { zh: '数据来自 gitea · 构建期生成', en: 'via gitea · generated at build time' }, heatmap_note: { zh: '数据来自 GitHub · 构建期生成', en: 'via GitHub · generated at build time' },
// ===== B 车道:Footer 极客双页入口(stack/colophon 页内文案各自内联,不入本表) ===== // ===== B 车道:Footer 极客双页入口(stack/colophon 页内文案各自内联,不入本表) =====
footer_stack: { zh: '装备', en: 'stack' }, footer_stack: { zh: '装备', en: 'stack' },
@@ -91,6 +91,9 @@ const dict = {
kbd_esc_close: { zh: '关闭浮层', en: 'Close dialogs' }, kbd_esc_close: { zh: '关闭浮层', en: 'Close dialogs' },
kbd_hint: { zh: '提示:/terminal 终端彩蛋等你探索', en: 'Tip: the /terminal easter egg awaits' }, kbd_hint: { zh: '提示:/terminal 终端彩蛋等你探索', en: 'Tip: the /terminal easter egg awaits' },
// ===== F 车道:访问量小字(Artalk pv,组件内独立计数) =====
pv_viewed: { zh: '人看过', en: 'views' },
// ===== 命令面板(Phase 4 T1 车道):动作/导航/分组与键位提示 ===== // ===== 命令面板(Phase 4 T1 车道):动作/导航/分组与键位提示 =====
nav_home: { zh: '首页', en: 'Home' }, nav_home: { zh: '首页', en: 'Home' },
nav_moments: { zh: '瞬间', en: 'Moments' }, nav_moments: { zh: '瞬间', en: 'Moments' },
+92 -50
View File
@@ -1,5 +1,10 @@
// 贡献热力图数据(极客层 P0):构建期拉自托管 gitea 年历,聚合 53 周 × 7 日 // 贡献热力图数据(极客层 P0):构建期拉 GitHub GraphQL 年历,聚合 53 周 × 7 日
// 三级回落(远端 → 仓内样例 → 空数组)保证网络单点故障不破构建;见 Heatmap.astro // 三级回落(GitHub(token) → 仓内样例缓存 → 空网格):token 缺位或网络单点不破构建
// token 红线:宿主 0600 文件 ~/docker_apps/blog/github-token.txt(只读 public read 足矣,
// GraphQL 匿名不可用;泄漏即撤换);fallback 为样例数据(GitHub 日计口径形状),
// token 就位后用一次真实构建产物回填即可换真
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
export interface HeatDay { export interface HeatDay {
date: string; // YYYY-MM-DD date: string; // YYYY-MM-DD
@@ -11,12 +16,9 @@ export interface HeatmapYear {
total: number; total: number;
} }
export const HEATMAP_API = 'https://git.wait4x.online/api/v1/users/wait4x/heatmap'; const GITHUB_LOGIN = 'wait4xx';
const GITHUB_API = 'https://api.github.com/graphql';
interface RawHeat { const TOKEN_FILE = join(process.env.HOME ?? '', 'docker_apps/blog/github-token.txt');
timestamp: number;
contributions: number;
}
/** 分档阈值:0 / 1-2 / 3-5 / 6-9 / 10+ → 灰阶 l0-l4,固定阈值可解释(GitHub 同款哲学) */ /** 分档阈值:0 / 1-2 / 3-5 / 6-9 / 10+ → 灰阶 l0-l4,固定阈值可解释(GitHub 同款哲学) */
export function levelOf(count: number): number { export function levelOf(count: number): number {
@@ -27,66 +29,106 @@ export function levelOf(count: number): number {
return 0; return 0;
} }
// 站点受众时区:日界按上海(+8)切,保证 gitea 后台与博客热力图对同一天计数一致; // 站点受众时区:日界按上海(+8)切——显式固定而非取构建机时区,避免构建环境
// 显式固定而非取构建机时区——避免构建环境变化悄悄改变历史归日 // 变化悄悄改变历史归日;GitHub 返回的日计数直接采用(其分桶与本地午夜口径
// 至多偏一天,年历粒度无感)
const DAY_FMT = new Intl.DateTimeFormat('en-CA', { const DAY_FMT = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
}); });
function toDateString(tsSec: number): string { /** 网格窗口起点:上海"今天"所在周的周一,前推 52 周(共 53 列) */
return DAY_FMT.format(new Date(tsSec * 1000)); function gridStart(): Date {
const [y, m, d] = DAY_FMT.format(new Date()).split('-').map(Number);
const today = Date.UTC(y, m - 1, d);
const dow = (new Date(today).getUTCDay() + 6) % 7; // 周一=0
return new Date(today - (dow + 52 * 7) * 86400000);
} }
/** const iso = (dt: Date) => dt.toISOString().slice(0, 10);
* 聚合原始贡献点为 53 周年历:起点为「今天所在周的周一」前推 52 周,
* 网格尾部未来的日子填 0(格渲染为空档),首列可能含早于窗口的 0 天。 const QUERY = `query($login:String!,$from:DateTime!,$to:DateTime!){
*/ user(login:$login){ contributionsCollection(from:$from,to:$to){
async function loadHeatmapInternal(): Promise<HeatmapYear> { contributionCalendar{ weeks{ contributionDays{ date contributionCount } } }
let raw: RawHeat[] = []; } } }`;
/** GitHub GraphQL 拉全年贡献日计;token 缺位/请求失败返回 null(调用方回落) */
async function fetchGitHubDays(): Promise<Map<string, number> | null> {
let token: string | null = null;
try { try {
const res = await fetch(HEATMAP_API, { token = readFileSync(TOKEN_FILE, 'utf8').trim() || null;
headers: { Accept: 'application/json', 'User-Agent': 'wait4x-blog-build' },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) throw new Error(`${HEATMAP_API} -> ${res.status}`);
raw = (await res.json()) as RawHeat[];
} catch { } catch {
// 远端失败回落仓内样例(与 API 同构的原始点,走同一聚合管道保证形态一致) // token 文件缺位是合法降级路径,非错误
raw = (await import('../data/heatmap-fallback.json')).default as RawHeat[]; }
if (!token) {
console.warn(`[heatmap] GitHub token 缺位(${TOKEN_FILE}),回落仓内样例`);
return null;
}
const start = gridStart();
const to = new Date(start.getTime() + 53 * 7 * 86400000); // 半开区间多给一天防掉尾
try {
const res = await fetch(GITHUB_API, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
'User-Agent': 'wait4x-blog-build',
},
body: JSON.stringify({
query: QUERY,
variables: { login: GITHUB_LOGIN, from: start.toISOString(), to: to.toISOString() },
}),
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) throw new Error(`GitHub GraphQL ${res.status}`);
const json = (await res.json()) as {
data?: {
user?: {
contributionsCollection?: {
contributionCalendar?: {
weeks?: { contributionDays?: { date: string; contributionCount: number }[] }[];
};
};
};
};
};
const cal = json.data?.user?.contributionsCollection?.contributionCalendar;
if (!cal?.weeks) throw new Error('响应缺 contributionsCollection(token 权限或 login 错)');
const days = new Map<string, number>();
for (const w of cal.weeks) for (const d of w.contributionDays ?? []) days.set(d.date, d.contributionCount);
console.log(`[heatmap] GitHub 拉取成功:${days.size} 个贡献日`);
return days;
} catch (e) {
console.warn(`[heatmap] GitHub 拉取失败(${String(e).slice(0, 120)}),回落仓内样例`);
return null;
}
} }
const byDate = new Map<string, number>(); /** 仓内样例(GitHub 日计口径形状);文件缺位再回落空网格(热力图空态但不破构建) */
for (const { timestamp, contributions } of raw) { async function loadFallbackDays(): Promise<Map<string, number>> {
const key = toDateString(timestamp); try {
byDate.set(key, (byDate.get(key) ?? 0) + contributions); const days = (await import('../data/heatmap-fallback.json')).default as { date: string; count: number }[];
return new Map(days.map((d) => [d.date, d.count]));
} catch {
console.warn('[heatmap] 仓内样例缺位,热力图将为空态');
return new Map();
}
} }
// 以 Asia/Shanghai 的“今天”定窗(与归日时区一致) export async function loadHeatmap(): Promise<HeatmapYear> {
const today = new Date(toDateString(Math.floor(Date.now() / 1000)) + 'T12:00:00+08:00'); const byDate = (await fetchGitHubDays()) ?? (await loadFallbackDays());
const dow = (today.getUTCDay() + 6) % 7; // 周一=0 // 53 周网格装配:窗口内逐日取数,未来日 0(渲染空档)
const gridStart = new Date(today.getTime() - (dow + 52 * 7) * 86_400_000); const start = gridStart();
const weeks: HeatDay[][] = []; const weeks: HeatDay[][] = [];
let total = 0; let total = 0;
for (let w = 0; w < 53; w++) { for (let w = 0; w < 53; w++) {
const week: HeatDay[] = []; const col: HeatDay[] = [];
for (let d = 0; d < 7; d++) { for (let d = 0; d < 7; d++) {
const day = new Date(gridStart.getTime() + (w * 7 + d) * 86_400_000); const dt = new Date(start.getTime() + (w * 7 + d) * 86400000);
const key = toDateString(Math.floor(day.getTime() / 1000)); const count = byDate.get(iso(dt)) ?? 0;
const count = day > today ? 0 : (byDate.get(key) ?? 0);
total += count; total += count;
week.push({ date: key, count }); col.push({ date: iso(dt), count });
} }
weeks.push(week); weeks.push(col);
} }
return { weeks, total }; return { weeks, total };
} }
// 构建期模块级缓存:zh/en 两个首页各渲染一次,若各自直连远端会抓两遍;
// 缓存后第二个页面复用同一份结果(视觉复核发现 en 页数据偶发为空)
let _cache: Promise<HeatmapYear> | null = null;
export function loadHeatmap(): Promise<HeatmapYear> {
_cache ??= loadHeatmapInternal();
return _cache;
}
+6
View File
@@ -80,6 +80,12 @@ ok('英文首页内容', bodyEn.includes('Open Source') || bodyEn.includes('Read
await page.screenshot({ path: `${SHOTS}/03-en-home.png`, fullPage: true }); await page.screenshot({ path: `${SHOTS}/03-en-home.png`, fullPage: true });
await page.goto(BASE + '/en/gallery/', { waitUntil: 'networkidle' }); await page.goto(BASE + '/en/gallery/', { waitUntil: 'networkidle' });
ok('en 图库可达', page.url().includes('/en/gallery')); ok('en 图库可达', page.url().includes('/en/gallery'));
// E 车道回归:en 页导航必须整轨 /en/(用户实测"切语言后点导航回中文"的防线)
const enNavHrefs = await page.$$eval('nav a[href^="/"]:not(.nav-lang)', (as) => as.map((a) => a.getAttribute('href')));
const enNavBad = await page.$$eval('nav a[href^="/"]:not(.nav-lang)', (as) => as.filter((a) => !(a.getAttribute('href') ?? '').startsWith('/en/')).map((a) => a.outerHTML.slice(0, 140)));
ok('en 导航全轨 /en/ 前缀', enNavBad.length === 0 && enNavHrefs.length >= 5, enNavBad.join(' ; ') || enNavHrefs.join(' '));
const enBrand = await page.getAttribute('a.brand', 'href');
ok('en 品牌链接指 /en/', enBrand === '/en/', String(enBrand));
// 4) 命令面板 + 搜索 // 4) 命令面板 + 搜索
await page.keyboard.press('Control+k'); await page.keyboard.press('Control+k');