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
This commit is contained in:
wait4xandGLM-5.3 committed 2026-10-25 22:19:40 +08:00
1 parent 363cc616f7
commit d00ca7e447
5 files changed
+120 -63

No files matched your search

+92 -50
View File
@@ -1,5 +1,10 @@
// 贡献热力图数据(极客层 P0):构建期拉自托管 gitea 年历,聚合 53 周 × 7 日
// 三级回落(远端 → 仓内样例 → 空数组)保证网络单点故障不破构建;见 Heatmap.astro
// 贡献热力图数据(极客层 P0):构建期拉 GitHub GraphQL 年历,聚合 53 周 × 7 日
// 三级回落(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 {
date: string; // YYYY-MM-DD
@@ -11,12 +16,9 @@ export interface HeatmapYear {
total: number;
}
export const HEATMAP_API = 'https://git.wait4x.online/api/v1/users/wait4x/heatmap';
interface RawHeat {
timestamp: number;
contributions: number;
}
const GITHUB_LOGIN = 'wait4xx';
const GITHUB_API = 'https://api.github.com/graphql';
const TOKEN_FILE = join(process.env.HOME ?? '', 'docker_apps/blog/github-token.txt');
/** 分档阈值:0 / 1-2 / 3-5 / 6-9 / 10+ → 灰阶 l0-l4,固定阈值可解释(GitHub 同款哲学) */
export function levelOf(count: number): number {
@@ -27,66 +29,106 @@ export function levelOf(count: number): number {
return 0;
}
// 站点受众时区:日界按上海(+8)切,保证 gitea 后台与博客热力图对同一天计数一致;
// 显式固定而非取构建机时区——避免构建环境变化悄悄改变历史归日
// 站点受众时区:日界按上海(+8)切——显式固定而非取构建机时区,避免构建环境
// 变化悄悄改变历史归日;GitHub 返回的日计数直接采用(其分桶与本地午夜口径
// 至多偏一天,年历粒度无感)
const DAY_FMT = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
});
function toDateString(tsSec: number): string {
return DAY_FMT.format(new Date(tsSec * 1000));
/** 网格窗口起点:上海"今天"所在周的周一,前推 52 周(共 53 列) */
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);
}
/**
* 聚合原始贡献点为 53 周年历:起点为「今天所在周的周一」前推 52 周,
* 网格尾部未来的日子填 0(格渲染为空档),首列可能含早于窗口的 0 天。
*/
async function loadHeatmapInternal(): Promise<HeatmapYear> {
let raw: RawHeat[] = [];
const iso = (dt: Date) => dt.toISOString().slice(0, 10);
const QUERY = `query($login:String!,$from:DateTime!,$to:DateTime!){
user(login:$login){ contributionsCollection(from:$from,to:$to){
contributionCalendar{ weeks{ contributionDays{ date contributionCount } } }
} } }`;
/** GitHub GraphQL 拉全年贡献日计;token 缺位/请求失败返回 null(调用方回落) */
async function fetchGitHubDays(): Promise<Map<string, number> | null> {
let token: string | null = null;
try {
const res = await fetch(HEATMAP_API, {
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[];
token = readFileSync(TOKEN_FILE, 'utf8').trim() || null;
} catch {
// 远端失败回落仓内样例(与 API 同构的原始点,走同一聚合管道保证形态一致)
raw = (await import('../data/heatmap-fallback.json')).default as RawHeat[];
// token 文件缺位是合法降级路径,非错误
}
const byDate = new Map<string, number>();
for (const { timestamp, contributions } of raw) {
const key = toDateString(timestamp);
byDate.set(key, (byDate.get(key) ?? 0) + contributions);
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;
}
}
// 以 Asia/Shanghai 的“今天”定窗(与归日时区一致)
const today = new Date(toDateString(Math.floor(Date.now() / 1000)) + 'T12:00:00+08:00');
const dow = (today.getUTCDay() + 6) % 7; // 周一=0
const gridStart = new Date(today.getTime() - (dow + 52 * 7) * 86_400_000);
/** 仓内样例(GitHub 日计口径形状);文件缺位再回落空网格(热力图空态但不破构建) */
async function loadFallbackDays(): Promise<Map<string, number>> {
try {
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();
}
}
export async function loadHeatmap(): Promise<HeatmapYear> {
const byDate = (await fetchGitHubDays()) ?? (await loadFallbackDays());
// 53 周网格装配:窗口内逐日取数,未来日 0(渲染空档)
const start = gridStart();
const weeks: HeatDay[][] = [];
let total = 0;
for (let w = 0; w < 53; w++) {
const week: HeatDay[] = [];
const col: HeatDay[] = [];
for (let d = 0; d < 7; d++) {
const day = new Date(gridStart.getTime() + (w * 7 + d) * 86_400_000);
const key = toDateString(Math.floor(day.getTime() / 1000));
const count = day > today ? 0 : (byDate.get(key) ?? 0);
const dt = new Date(start.getTime() + (w * 7 + d) * 86400000);
const count = byDate.get(iso(dt)) ?? 0;
total += count;
week.push({ date: key, count });
col.push({ date: iso(dt), count });
}
weeks.push(week);
weeks.push(col);
}
return { weeks, total };
}
// 构建期模块级缓存:zh/en 两个首页各渲染一次,若各自直连远端会抓两遍;
// 缓存后第二个页面复用同一份结果(视觉复核发现 en 页数据偶发为空)
let _cache: Promise<HeatmapYear> | null = null;
export function loadHeatmap(): Promise<HeatmapYear> {
_cache ??= loadHeatmapInternal();
return _cache;
}