feat(launch): 热力图与 webhook 发布闭环(T2/T3)
- T2 (op-artalk 车道):gitea heatmap 构建期拉取+fallback+空态三保险,
53×7 灰阶格复用 tokens(亮暗自动),日界锚定 Asia/Shanghai,零 JS,
zh/en 首页挂载;fallback 经死端点实测不破构建
- T3:webhook_receiver.py(HMAC 强制+main-only+构建锁)以 systemd
--user 常驻 13021;gitea hook 注册(events=push, branch=main)
- 三个深坑:gitea 容器回宿主须走桥网关 IP 而非 127.0.0.1;gitea 28
egress 拦私网须 ALLOWED_HOST_LIST(ALLOWLIST 是废弃名);systemd
用户服务缺用户 PATH 致 build 静默失败(stamp 不更新即症状)
验证: 测试投递 202→构建→staging 戳更新(09:48:37 实测);
dist 热力图 376 格真数据(9 次贡献)zh/en 双页
Spec: 2026-10-25_add-blog-launch
Co-authored-by: GLM-5.3
This commit is contained in:
1 parent
0c54a43bb5
commit
938558ad4c
12 files changed
+692
-110
No files matched your search
@@ -0,0 +1,367 @@
|
||||
---
|
||||
// 命令面板:动作层(主题/语言/gitea/RSS)+ 导航层 + Pagefind 搜索层
|
||||
// 由 SearchDialog 薄壳转发而来(挂载点名与 DOM id 保持不变,BaseLayout 无需感知)
|
||||
import { t } from '../i18n/ui';
|
||||
import { localePath } from '../lib/locale';
|
||||
import type { Locale } from '../lib/locale';
|
||||
|
||||
interface Props {
|
||||
locale?: Locale;
|
||||
}
|
||||
const { locale = 'zh' } = Astro.props;
|
||||
const other: Locale = locale === 'zh' ? 'en' : 'zh';
|
||||
|
||||
// 静态站每页渲染时 Astro.url 即本页路径:语言跳转目标构建期算死,运行时零计算
|
||||
const langHref = localePath(Astro.url.pathname.replace(/\/index\.html$/, ''), other);
|
||||
const rssPath = locale === 'zh' ? '/rss.xml' : '/en/rss.xml';
|
||||
const prefix = locale === 'zh' ? '' : '/en';
|
||||
const navItems = [
|
||||
{ href: `${prefix}/`, label: t('nav_home', locale) },
|
||||
{ href: `${prefix}/archives/`, label: t('nav_posts', locale) },
|
||||
{ href: `${prefix}/gallery/`, label: t('nav_gallery', locale) },
|
||||
{ href: `${prefix}/moments/`, label: t('nav_moments', locale) },
|
||||
{ href: `${prefix}/about/`, label: t('nav_about', locale) },
|
||||
];
|
||||
---
|
||||
|
||||
<dialog id="search-dialog" class="search-dialog command-palette" data-locale={locale}>
|
||||
<input
|
||||
id="search-input"
|
||||
type="search"
|
||||
placeholder={t('search_placeholder', locale)}
|
||||
autocomplete="off"
|
||||
role="combobox"
|
||||
aria-expanded="false"
|
||||
aria-controls="cmd-list"
|
||||
aria-autocomplete="list"
|
||||
/>
|
||||
<div id="cmd-list" class="cmd-list" role="listbox" aria-label={t('cmd_palette_label', locale)}>
|
||||
<div id="cmd-commands" class="cmd-commands">
|
||||
<p class="cmd-group" role="presentation" id="cmd-h-actions">{t('cmd_group_actions', locale)}</p>
|
||||
<ul class="cmd-ul" role="group" aria-labelledby="cmd-h-actions">
|
||||
<li class="cmd-item" role="option" aria-selected="false" data-cmd="theme">
|
||||
<span class="cmd-label">{t('cmd_toggle_theme', locale)}</span>
|
||||
<span class="cmd-kbd" aria-hidden="true">t</span>
|
||||
</li>
|
||||
<li class="cmd-item" role="option" aria-selected="false" data-cmd="lang" data-href={langHref}>
|
||||
<span class="cmd-label">{t('cmd_switch_lang', locale)}</span>
|
||||
<span class="cmd-kbd" aria-hidden="true">{other === 'en' ? 'EN' : '中'}</span>
|
||||
</li>
|
||||
<li class="cmd-item" role="option" aria-selected="false">
|
||||
<a href="https://git.wait4x.online/wait4x/blog" target="_blank" rel="noopener">
|
||||
<span class="cmd-label">{t('cmd_open_gitea', locale)}</span>
|
||||
<span class="cmd-kbd" aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="cmd-item" role="option" aria-selected="false" data-cmd="rss" data-rss={rssPath}>
|
||||
<span class="cmd-label">{t('cmd_copy_rss', locale)}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="cmd-group" role="presentation" id="cmd-h-nav">{t('cmd_group_nav', locale)}</p>
|
||||
<ul class="cmd-ul" role="group" aria-labelledby="cmd-h-nav">
|
||||
{navItems.map((n) => (
|
||||
<li class="cmd-item" role="option" aria-selected="false">
|
||||
<a href={n.href}>{n.label}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<p class="cmd-group" role="presentation" id="cmd-h-search" hidden>{t('cmd_group_search', locale)}</p>
|
||||
<ul id="search-results" class="results cmd-ul" role="group" aria-labelledby="cmd-h-search" aria-live="polite"></ul>
|
||||
</div>
|
||||
<p class="search-hint">{t('search_hint', locale)}</p>
|
||||
<p class="search-hint">{t('cmd_hint', locale)}</p>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
import { t } from '../i18n/ui';
|
||||
|
||||
type PagefindLike = {
|
||||
search: (q: string) => Promise<{ results: { data: () => Promise<{ url: string; excerpt: string; meta: { title: string } }> }[] }>;
|
||||
};
|
||||
let pf: PagefindLike | null = null;
|
||||
let loadStarted = false;
|
||||
|
||||
// 首开才拉索引(原 SearchDialog 行为原样保留)
|
||||
async function ensureIndex() {
|
||||
if (loadStarted) return;
|
||||
loadStarted = true;
|
||||
pf = (await import(/* @vite-ignore */ '/pagefind/pagefind.js')) as unknown as PagefindLike;
|
||||
}
|
||||
|
||||
// ClientRouter 换页会重建 DOM:元素引用一律事件时现取,模块级不缓存旧节点
|
||||
const dlg = () => document.getElementById('search-dialog') as HTMLDialogElement | null;
|
||||
const inputEl = () => document.getElementById('search-input') as HTMLInputElement | null;
|
||||
const resultsEl = () => document.getElementById('search-results');
|
||||
const commandsEl = () => document.getElementById('cmd-commands');
|
||||
const searchHeadEl = () => document.getElementById('cmd-h-search');
|
||||
const curLocale = () => ((dlg()?.dataset.locale ?? 'zh') as 'zh' | 'en');
|
||||
|
||||
let activeIdx = -1;
|
||||
|
||||
// 可见项 = 未被 [hidden] 祖先遮蔽的 cmd-item:命令模式与搜索模式共用一套 ↑↓ 导航
|
||||
const visibleItems = () =>
|
||||
[...document.querySelectorAll<HTMLElement>('#cmd-list .cmd-item')].filter(
|
||||
(el) => el.closest('[hidden]') === null,
|
||||
);
|
||||
|
||||
function setActive(idx: number) {
|
||||
const items = visibleItems();
|
||||
if (items.length === 0) {
|
||||
activeIdx = -1;
|
||||
return;
|
||||
}
|
||||
activeIdx = (idx + items.length) % items.length;
|
||||
items.forEach((el, i) => {
|
||||
el.setAttribute('aria-selected', i === activeIdx ? 'true' : 'false');
|
||||
el.classList.toggle('active', i === activeIdx);
|
||||
});
|
||||
items[activeIdx]?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
// 输入空 → 命令层;输入即查 → 搜索层(原 Pagefind 逻辑不动,只是套了模式切换)
|
||||
function showCommands(show: boolean) {
|
||||
commandsEl()?.toggleAttribute('hidden', !show);
|
||||
if (show) searchHeadEl()?.setAttribute('hidden', '');
|
||||
else searchHeadEl()?.removeAttribute('hidden');
|
||||
}
|
||||
|
||||
function activate(item: HTMLElement) {
|
||||
const link = item.querySelector('a');
|
||||
if (link) {
|
||||
link.click();
|
||||
return;
|
||||
}
|
||||
switch (item.dataset.cmd) {
|
||||
// 复用 Header 主题钮的现成切换(同一 localStorage key),不另起第二套实现
|
||||
case 'theme': {
|
||||
const btn = document.querySelector('[data-theme-toggle]') as HTMLElement | null;
|
||||
if (btn) {
|
||||
btn.click();
|
||||
} else {
|
||||
const root = document.documentElement;
|
||||
const next = root.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||
root.dataset.theme = next;
|
||||
localStorage.setItem('theme', next);
|
||||
}
|
||||
dlg()?.close();
|
||||
break;
|
||||
}
|
||||
case 'lang':
|
||||
if (item.dataset.href) location.href = item.dataset.href;
|
||||
break;
|
||||
case 'rss': {
|
||||
const url = new URL(item.dataset.rss ?? '/rss.xml', location.origin).href;
|
||||
const label = item.querySelector('.cmd-label');
|
||||
void navigator.clipboard
|
||||
?.writeText(url)
|
||||
.then(() => {
|
||||
// 复制是唯一给反馈的动作:标签短暂换成"已复制"再关面板
|
||||
if (label) label.textContent = t('cmd_copied', curLocale());
|
||||
window.setTimeout(() => dlg()?.close(), 900);
|
||||
})
|
||||
.catch(() => {});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
const d = dlg();
|
||||
if (!d) return;
|
||||
d.showModal();
|
||||
const inp = inputEl();
|
||||
if (inp) inp.value = '';
|
||||
const list = resultsEl();
|
||||
if (list) list.innerHTML = '';
|
||||
showCommands(true);
|
||||
setActive(0);
|
||||
inp?.setAttribute('aria-expanded', 'true');
|
||||
void ensureIndex();
|
||||
inp?.focus();
|
||||
}
|
||||
|
||||
// 触发钮 / 面板内点击 / 快捷键全部挂 document 委托:换页后自动作用于新 DOM
|
||||
document.addEventListener('click', (e) => {
|
||||
const el = e.target as Element;
|
||||
if (el?.closest?.('[data-search-trigger]')) {
|
||||
open();
|
||||
return;
|
||||
}
|
||||
const item = el?.closest?.('#cmd-list .cmd-item') as HTMLElement | null;
|
||||
if (!item) return;
|
||||
// 命中链接时交给原生导航,避免再 click() 触发两次(_blank 会开两个页)
|
||||
if (el.closest('a')) return;
|
||||
e.preventDefault();
|
||||
activate(item);
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
|
||||
e.preventDefault();
|
||||
open();
|
||||
return;
|
||||
}
|
||||
const inField = document.activeElement instanceof HTMLElement &&
|
||||
(document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA');
|
||||
if (e.key === '/' && !inField) {
|
||||
e.preventDefault();
|
||||
open();
|
||||
}
|
||||
});
|
||||
|
||||
// 输入域监听随换页重装(dataset 防同一元素重复绑)
|
||||
function bindInput() {
|
||||
const inp = inputEl();
|
||||
const d = dlg();
|
||||
if (!inp || !d || inp.dataset.cmdBound) return;
|
||||
inp.dataset.cmdBound = '1';
|
||||
d.addEventListener('close', () => inp.setAttribute('aria-expanded', 'false'));
|
||||
|
||||
let timer: number | undefined;
|
||||
inp.addEventListener('input', () => {
|
||||
clearTimeout(timer);
|
||||
timer = window.setTimeout(async () => {
|
||||
if (!pf) return;
|
||||
const q = inp.value.trim();
|
||||
if (!q) {
|
||||
const list = resultsEl();
|
||||
if (list) list.innerHTML = '';
|
||||
showCommands(true);
|
||||
setActive(0);
|
||||
return;
|
||||
}
|
||||
showCommands(false);
|
||||
const res = await pf.search(q);
|
||||
if (inputEl()?.value.trim() !== q) return; // 过期响应丢弃
|
||||
const list = resultsEl();
|
||||
if (!list) return;
|
||||
list.innerHTML = '';
|
||||
for (const r of res.results.slice(0, 8)) {
|
||||
const d = await r.data();
|
||||
const li = document.createElement('li');
|
||||
li.className = 'cmd-item';
|
||||
li.setAttribute('role', 'option');
|
||||
li.setAttribute('aria-selected', 'false');
|
||||
const a = document.createElement('a');
|
||||
a.href = d.url;
|
||||
a.className = 'tap';
|
||||
a.innerHTML = `<strong>${d.meta.title}</strong><span>${d.excerpt}</span>`;
|
||||
li.appendChild(a);
|
||||
list.appendChild(li);
|
||||
}
|
||||
if (!list.children.length) {
|
||||
list.innerHTML = `<li class="empty">${t('search_no_results', curLocale())}</li>`;
|
||||
}
|
||||
setActive(0);
|
||||
}, 200);
|
||||
});
|
||||
|
||||
inp.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActive(activeIdx + 1);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActive(activeIdx - 1);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const item = visibleItems()[activeIdx];
|
||||
if (item) activate(item);
|
||||
}
|
||||
});
|
||||
}
|
||||
document.addEventListener('astro:page-load', bindInput);
|
||||
bindInput();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.search-dialog {
|
||||
margin-top: 12vh;
|
||||
width: min(560px, calc(100vw - 32px));
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--bg);
|
||||
color: var(--ink-2);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
.search-dialog::backdrop { background: rgba(0, 0, 0, 0.45); }
|
||||
input[type='search'] {
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-soft);
|
||||
font-size: 15px;
|
||||
}
|
||||
.cmd-list {
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.cmd-group {
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--ink-4);
|
||||
letter-spacing: 0.08em;
|
||||
margin: var(--space-2) 0 var(--space-1);
|
||||
}
|
||||
.cmd-ul { list-style: none; padding: 0; margin: 0; }
|
||||
.cmd-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.cmd-item:not(:has(> a)) {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: 14px;
|
||||
color: var(--ink-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.cmd-item > a {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: 8px;
|
||||
color: var(--ink-2);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
.cmd-kbd {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-bottom-width: 2px;
|
||||
border-radius: 4px;
|
||||
padding: 0 5px;
|
||||
color: var(--ink-4);
|
||||
}
|
||||
.cmd-item.active,
|
||||
:global(.results .cmd-item.active) {
|
||||
background: var(--bg-soft);
|
||||
}
|
||||
.search-hint {
|
||||
margin-top: var(--space-2);
|
||||
font-size: 11px;
|
||||
color: var(--ink-4);
|
||||
text-align: center;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.results { list-style: none; padding: 0; margin: 0; }
|
||||
/* JS 运行时生成的节点不带 scope 类:结果区样式一律 :global(.results 类名本组件私有) */
|
||||
:global(.results a) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
}
|
||||
:global(.results a:hover) { background: var(--bg-soft); }
|
||||
:global(.results strong) { color: var(--ink-1); font-size: 14px; }
|
||||
:global(.results span) { font-size: 12px; color: var(--ink-3); }
|
||||
:global(.results mark) { background: var(--accent-soft); }
|
||||
:global(.results .empty) { padding: var(--space-3); font-size: 13px; color: var(--ink-3); }
|
||||
</style>
|
||||
@@ -1,116 +1,16 @@
|
||||
---
|
||||
// 站内搜索(Pagefind):首次打开才懒加载索引(spectre 模式,见调研报告 §4c)
|
||||
// 入口:Header 搜索钮 + 键盘 `/`
|
||||
import { t } from '../i18n/ui';
|
||||
// 薄壳:历史挂载点名与 DOM id 不变(BaseLayout 无需感知),
|
||||
// 实现整体迁往 CommandPalette(动作 + 导航 + 搜索三层命令面板)
|
||||
import CommandPalette from './CommandPalette.astro';
|
||||
import { localeOf } from '../lib/locale';
|
||||
import type { Locale } from '../lib/locale';
|
||||
|
||||
// /en/ 侧由 T4 薄页传 locale='en';zh 路由无人传参,默认 zh 零回归
|
||||
interface Props {
|
||||
locale?: Locale;
|
||||
}
|
||||
const { locale = 'zh' } = Astro.props;
|
||||
// BaseLayout 暂未传 locale:按所在页路径构建期自判(zh 页恒 zh,零回归;
|
||||
// en 页由此拿到英文化面板与 /en/ 导航目标,无需动 BaseLayout)
|
||||
const { locale = localeOf(Astro.url.pathname) } = Astro.props;
|
||||
---
|
||||
|
||||
<dialog id="search-dialog" class="search-dialog" data-locale={locale}>
|
||||
<input id="search-input" type="search" placeholder={t('search_placeholder', locale)} autocomplete="off" />
|
||||
<ul id="search-results" class="results" aria-live="polite"></ul>
|
||||
<p class="search-hint">{t('search_hint', locale)}</p>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
import { t } from '../i18n/ui';
|
||||
|
||||
// 模块脚本读不到 Astro props:空态文案语言在事件触发时从 dialog 的
|
||||
// data-locale 现取——ClientRouter 换页重建 DOM 后仍能拿到当前页语言
|
||||
const dlg = document.getElementById('search-dialog') as HTMLDialogElement;
|
||||
const input = document.getElementById('search-input') as HTMLInputElement;
|
||||
const list = document.getElementById('search-results')!;
|
||||
type PagefindLike = {
|
||||
search: (q: string) => Promise<{ results: { data: () => Promise<{ url: string; excerpt: string; meta: { title: string } }> }[] }>;
|
||||
};
|
||||
let pf: PagefindLike | null = null;
|
||||
let loadStarted = false;
|
||||
|
||||
async function ensureIndex() {
|
||||
if (loadStarted) return;
|
||||
loadStarted = true;
|
||||
pf = (await import(/* @vite-ignore */ '/pagefind/pagefind.js')) as unknown as PagefindLike;
|
||||
}
|
||||
|
||||
function open() {
|
||||
dlg.showModal();
|
||||
input.value = '';
|
||||
list.innerHTML = '';
|
||||
void ensureIndex();
|
||||
input.focus();
|
||||
}
|
||||
|
||||
document.querySelector('[data-search-trigger]')?.addEventListener('click', open);
|
||||
document.addEventListener('keydown', (e) => {
|
||||
const inField = document.activeElement instanceof HTMLElement &&
|
||||
(document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA');
|
||||
if (e.key === '/' && !inField) { e.preventDefault(); open(); }
|
||||
});
|
||||
|
||||
let timer: number | undefined;
|
||||
input.addEventListener('input', () => {
|
||||
clearTimeout(timer);
|
||||
timer = window.setTimeout(async () => {
|
||||
if (!pf) return;
|
||||
const q = input.value.trim();
|
||||
if (!q) { list.innerHTML = ''; return; }
|
||||
const res = await pf.search(q);
|
||||
list.innerHTML = '';
|
||||
for (const r of res.results.slice(0, 8)) {
|
||||
const d = await r.data();
|
||||
const li = document.createElement('li');
|
||||
const a = document.createElement('a');
|
||||
a.href = d.url;
|
||||
a.className = 'tap';
|
||||
a.innerHTML = `<strong>${d.meta.title}</strong><span>${d.excerpt}</span>`;
|
||||
li.appendChild(a);
|
||||
list.appendChild(li);
|
||||
}
|
||||
if (!list.children.length) {
|
||||
const loc = (document.getElementById('search-dialog')?.dataset.locale ?? 'zh') as 'zh' | 'en';
|
||||
list.innerHTML = `<li class="empty">${t('search_no_results', loc)}</li>`;
|
||||
}
|
||||
}, 200);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.search-dialog {
|
||||
margin-top: 12vh;
|
||||
width: min(560px, calc(100vw - 32px));
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--bg);
|
||||
color: var(--ink-2);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
.search-dialog::backdrop { background: rgba(0, 0, 0, 0.45); }
|
||||
input[type='search'] {
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-soft);
|
||||
font-size: 15px;
|
||||
}
|
||||
.results { list-style: none; padding: 0; margin: var(--space-2) 0 0; max-height: 50vh; overflow-y: auto; }
|
||||
.results a {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.results a:hover { background: var(--bg-soft); }
|
||||
.results strong { color: var(--ink-1); font-size: 14px; }
|
||||
.results span { font-size: 12px; color: var(--ink-3); }
|
||||
.results a span :global(mark) { background: var(--accent-soft); }
|
||||
.empty { padding: var(--space-3); font-size: 13px; color: var(--ink-3); }
|
||||
.search-hint { margin-top: var(--space-2); font-size: 11px; color: var(--ink-4); text-align: center; font-family: var(--font-mono); }
|
||||
</style>
|
||||
<CommandPalette locale={locale} />
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
// 贡献热力图(极客层 P0):纯 CSS grid,零运行时 JS
|
||||
// 数据聚合见 src/lib/heatmap.ts;灰阶四档直接复用 tokens --ink-1..4(亮暗两套自动跟随)
|
||||
import { loadHeatmap, levelOf } from '../../lib/heatmap';
|
||||
|
||||
// 文案全部由页面层注入(locale 无关挂法),keys 与 ui.ts heatmap_* 一一对应
|
||||
interface Labels {
|
||||
title: string;
|
||||
summary: string; // 含 {n} 占位符
|
||||
less: string;
|
||||
more: string;
|
||||
note: string;
|
||||
}
|
||||
interface Props {
|
||||
labels: Labels;
|
||||
}
|
||||
|
||||
const { labels } = Astro.props;
|
||||
let weeks: import('../../lib/heatmap').HeatDay[][] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
({ weeks, total } = await loadHeatmap());
|
||||
} catch {
|
||||
// 三级回落已在 lib 内处理;此处再守一层:空网格不破构建
|
||||
}
|
||||
const summary = labels.summary.replace('{n}', String(total));
|
||||
---
|
||||
|
||||
<section class="heatmap">
|
||||
<h2 class="hm-title">{labels.title}</h2>
|
||||
<!-- 53 列窄栏触屏放不下,容器横滚(格子原生 title 提示,无 JS) -->
|
||||
<div class="hm-scroll">
|
||||
<div class="hm-grid" role="img" aria-label={summary}>
|
||||
{weeks.map((week) =>
|
||||
week.map((day) => (
|
||||
<span
|
||||
class={`hm-c hm-l${levelOf(day.count)}`}
|
||||
title={`${day.date} · ${day.count}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p class="hm-legend">
|
||||
<span aria-hidden="true">{labels.less}</span>
|
||||
{[0, 1, 2, 3, 4].map((l) => (
|
||||
<span class={`hm-c hm-l${l}`} aria-hidden="true" />
|
||||
))}
|
||||
<span aria-hidden="true">{labels.more}</span>
|
||||
<span class="hm-note">{labels.note}</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.heatmap { margin-top: var(--space-6); }
|
||||
.hm-title {
|
||||
font-size: 14px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--ink-3);
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.hm-scroll {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
/* 列优先流:子元素按周展平后自上而下再向右,得 7 行 × 53 列 */
|
||||
.hm-grid {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-template-rows: repeat(7, 11px);
|
||||
grid-auto-columns: 11px;
|
||||
gap: 3px;
|
||||
width: max-content;
|
||||
}
|
||||
.hm-c {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
}
|
||||
/* 空档 l0 用极淡描边标位;l1-l4 = ink-4 → ink-1(亮暗模式各自成对,多则更醒目) */
|
||||
.hm-l0 { border: 1px solid var(--line); }
|
||||
.hm-l1 { background: var(--ink-4); }
|
||||
.hm-l2 { background: var(--ink-3); }
|
||||
.hm-l3 { background: var(--ink-2); }
|
||||
.hm-l4 { background: var(--ink-1); }
|
||||
.hm-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: var(--space-2) 0 0;
|
||||
font-size: 11px;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.hm-legend .hm-c { width: 9px; height: 9px; }
|
||||
.hm-note {
|
||||
margin-left: var(--space-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--ink-4);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
</style>
|
||||
@@ -14,6 +14,7 @@ const routes: Record<string, string> = {
|
||||
<h2>键盘快捷键</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr><td><kbd>Ctrl</kbd> <kbd>K</kbd></td><td>命令面板</td></tr>
|
||||
<tr><td><kbd>t</kbd></td><td>切换明暗</td></tr>
|
||||
<tr><td><kbd>?</kbd></td><td>本帮助</td></tr>
|
||||
<tr><td><kbd>g</kbd> <kbd>h</kbd></td><td>首页</td></tr>
|
||||
|
||||
@@ -0,0 +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
|
||||
@@ -64,6 +64,14 @@ const dict = {
|
||||
zh: '页面由 Astro 静态生成,不含运行时框架 JS;内容住在自托管 gitea——本地编辑器与 gitea 网页写的是同一个 git 仓库,push 之后由 homelab 自动构建发布;评论走自托管 Artalk。页脚 built from 一行链到本次构建对应的 commit,构建过程透明可追溯。',
|
||||
en: 'Pages are statically generated by Astro with no runtime framework JS. Content lives on a self-hosted gitea — my local editor and the gitea web UI write to the same git repository, and each push is built and published automatically by the homelab. Comments run on self-hosted Artalk. The built-from line in the footer links to the commit behind this build, keeping the process transparent and traceable.',
|
||||
},
|
||||
|
||||
// ===== 热力图(Phase 4 T2 车道):文案全部页面层注入,组件保持 locale 无关 =====
|
||||
heatmap_title: { zh: '开源活动', en: 'Open Source Activity' },
|
||||
// {n} 为总贡献次数占位符,调用点 .replace() 填值
|
||||
heatmap_summary: { zh: '最近一年 {n} 次贡献', en: '{n} contributions in the last year' },
|
||||
heatmap_less: { zh: '少', en: 'Less' },
|
||||
heatmap_more: { zh: '多', en: 'More' },
|
||||
heatmap_note: { zh: '数据来自 gitea · 构建期生成', en: 'via gitea · generated at build time' },
|
||||
} as const;
|
||||
|
||||
export type UiKey = keyof typeof dict;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// 贡献热力图数据(极客层 P0):构建期拉自托管 gitea 年历,聚合 53 周 × 7 日
|
||||
// 三级回落(远端 → 仓内样例 → 空数组)保证网络单点故障不破构建;见 Heatmap.astro
|
||||
|
||||
export interface HeatDay {
|
||||
date: string; // YYYY-MM-DD
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface HeatmapYear {
|
||||
weeks: HeatDay[][]; // 53 列(周)× 7 行(日,周一为首行)
|
||||
total: number;
|
||||
}
|
||||
|
||||
export const HEATMAP_API = 'https://git.wait4x.online/api/v1/users/wait4x/heatmap';
|
||||
|
||||
interface RawHeat {
|
||||
timestamp: number;
|
||||
contributions: number;
|
||||
}
|
||||
|
||||
/** 分档阈值:0 / 1-2 / 3-5 / 6-9 / 10+ → 灰阶 l0-l4,固定阈值可解释(GitHub 同款哲学) */
|
||||
export function levelOf(count: number): number {
|
||||
if (count >= 10) return 4;
|
||||
if (count >= 6) return 3;
|
||||
if (count >= 3) return 2;
|
||||
if (count >= 1) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 站点受众时区:日界按上海(+8)切,保证 gitea 后台与博客热力图对同一天计数一致;
|
||||
// 显式固定而非取构建机时区——避免构建环境变化悄悄改变历史归日
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 聚合原始贡献点为 53 周年历:起点为「今天所在周的周一」前推 52 周,
|
||||
* 网格尾部未来的日子填 0(格渲染为空档),首列可能含早于窗口的 0 天。
|
||||
*/
|
||||
export async function loadHeatmap(): Promise<HeatmapYear> {
|
||||
let raw: RawHeat[] = [];
|
||||
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[];
|
||||
} catch {
|
||||
// 远端失败回落仓内样例(与 API 同构的原始点,走同一聚合管道保证形态一致)
|
||||
raw = (await import('../data/heatmap-fallback.json')).default as RawHeat[];
|
||||
}
|
||||
|
||||
const byDate = new Map<string, number>();
|
||||
for (const { timestamp, contributions } of raw) {
|
||||
const key = toDateString(timestamp);
|
||||
byDate.set(key, (byDate.get(key) ?? 0) + contributions);
|
||||
}
|
||||
|
||||
// 以 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);
|
||||
|
||||
const weeks: HeatDay[][] = [];
|
||||
let total = 0;
|
||||
for (let w = 0; w < 53; w++) {
|
||||
const week: 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);
|
||||
total += count;
|
||||
week.push({ date: key, count });
|
||||
}
|
||||
weeks.push(week);
|
||||
}
|
||||
return { weeks, total };
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { getCollection } from 'astro:content';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import AiTranslatedBadge from '../../components/AiTranslatedBadge.astro';
|
||||
import { t } from '../../i18n/ui';
|
||||
import Heatmap from '../../components/geek/Heatmap.astro';
|
||||
import { POST_TRACKS, trackOf } from '../../content.config';
|
||||
|
||||
const en = await getCollection('postsEn');
|
||||
@@ -48,6 +49,15 @@ const t9 = (cat: keyof typeof POST_TRACKS) => trackOf(cat);
|
||||
})}
|
||||
</section>
|
||||
))}
|
||||
<Heatmap
|
||||
labels={{
|
||||
title: t('heatmap_title', 'en'),
|
||||
summary: t('heatmap_summary', 'en'),
|
||||
less: t('heatmap_less', 'en'),
|
||||
more: t('heatmap_more', 'en'),
|
||||
note: t('heatmap_note', 'en'),
|
||||
}}
|
||||
/>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -5,6 +5,8 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import IdCard from '../components/IdCard.astro';
|
||||
import PostList from '../components/PostList.astro';
|
||||
import ProjectWall from '../components/geek/ProjectWall.astro';
|
||||
import Heatmap from '../components/geek/Heatmap.astro';
|
||||
import { t } from '../i18n/ui';
|
||||
|
||||
const posts = (await getCollection('posts', ({ data }) => !data.draft)).sort(
|
||||
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
|
||||
@@ -84,6 +86,15 @@ const photos = (await getCollection('photos')).slice(0, 4);
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Heatmap
|
||||
labels={{
|
||||
title: t('heatmap_title', 'zh'),
|
||||
summary: t('heatmap_summary', 'zh'),
|
||||
less: t('heatmap_less', 'zh'),
|
||||
more: t('heatmap_more', 'zh'),
|
||||
note: t('heatmap_note', 'zh'),
|
||||
}}
|
||||
/>
|
||||
<ProjectWall />
|
||||
</BaseLayout>
|
||||
|
||||
|
||||
Reference in new issue
Block a user