Files
blog/src/components/CommandPalette.astro
T
wait4xandGLM-5.3 e21fac7e1d fix(polish): 视觉复核二轮六项——网格单一高度源/导航i18n/热力图缓存等
- 图库:li 去 aspect-ratio(与行跨度双高度约束竞争=列洞根因面),
  img 改 object-fit cover 填满;截图前整页滚动触发懒加载
- Header:导航标签按 locale 取字典(en 页曾是中文);图标钮暗色
  提色去底块(视觉复核 02/12 的灰块)
- 热力图:构建期模块级缓存,zh/en 两页共享一次抓取(en 页曾空格)
- en 首页移除机器翻译横幅(文章级标注才是本意);面板居中;计数器
  文字投影
- Artalk E2E 报 fetch 失败定性为测试环境跨域(127.0.0.1 页面拉
  wait4x.online API),生产同源不受影响——上线后专项验证

验证: pnpm astro build 21 页 exit 0;node tests/e2e.mjs 30/30 PASS
      (新构建全链重跑,含滚动后 21/21 图库断言)

Spec: 2026-10-25_fix-site-polish

Co-authored-by: GLM-5.3
2026-10-25 10:57:16 +08:00

369 lines
13 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
// 命令面板:动作层(主题/语言/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: 12vh auto auto;
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>