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:
wait4xandGLM-5.3 committed 2026-10-25 09:49:19 +08:00
1 parent 0c54a43bb5
commit 938558ad4c
12 files changed
+692 -110

No files matched your search

+8 -108
View File
@@ -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} />