Files
blog/src/components/pages/MomentsPage.astro
T
wait4xandGLM-5.3 63c9c8ad4a feat(counts): 浏览/评论徽标接入文章与瞬间页,修同载双计(5h T4)
用户要「加一个访客数量、评论数量,采用类似于开源页面从 GitHub 移植过来的那个
眼睛图标」——落地为 ViewMeta 本地 SVG 徽标,无第三方图片依赖。

接入(op-artalk 车道):
- 文章详情页 reading/tech 两分支各挂一枚 mode="page"(两分支互斥,同页仅一处计数)
- ArtalkComments 顶部旧的纯文本 pv 行与自成一套的 bumpPv 脚本整段删除,改为
  readonly chip:评论区只读展示,不再各计一次
- 瞬间页顶部整页 pv 行同样改 chip;每条瞬间卡片底部为 readonly 徽标

组件修复(本人,op-artalk 实测揪出):
- **同载双计**:脚本既注册 astro:page-load 又立即调用 run(),首屏两者都会触发
  → 每元素 POST 两次、浏览量翻倍(实测 15→17→19 每载 +2)。加每元素一次性
  守卫后 1/2/3 每载 +1;软导航新元素仍照常计数
- **同页两枚数字差 1**:readonly 实例独立 GET 到的 pv 可能比计过数的少 1;
  改为同键存在 page 实例时 readonly 让位,由 page 实例回填同一份结果
- page 实例请求失败时释放兄弟自读,避免评论区永远停在「–」

验证: 构建 exit 0;E2E 37/37;Playwright 拦截对账——
      每载 POST=1、同页两枚徽标文本一致(101/101)、刷新后累计 2、
      接口全挂时两枚均显示占位「–」不报错

Spec: 2026-11-02_blog-5h-content-social (T4)

Co-authored-by: GLM-5.3
2026-11-02 20:29:44 +08:00

312 lines
9.9 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.
---
import { Image } from 'astro:assets';
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import MomentComments from '../MomentComments.astro';
import ViewMeta from '../geek/ViewMeta.astro';
import { t } from '../../i18n/ui';
import { renderRich } from '../../lib/site-content';
import type { Locale } from '../../lib/locale';
// /en/ 侧由 T4 薄页传 locale='en';zh 路由无人传参,默认 zh 零回归
interface Props {
locale?: Locale;
}
const { locale = 'zh' } = Astro.props;
const moments = (await getCollection('moments')).sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
);
// 富文本预渲染(Phase 5h T2):text 走与页面文案同一条管线(markdown → 内容净化 →
// class 白名单 → 链接卡片),因此瞬间支持 **加粗**、<span class="c-blue font-kai">、列表与换行。
// 预渲染成 map 再进模板:renderRich 是 async,模板里不能 await。
// 纯文本老数据零回归:无 markdown 的文本会得到单个 <p>,与旧的 {text} 直出视觉等价。
const richText = new Map<string, string>();
for (const m of moments) {
if (!m.data.text) continue;
richText.set(m.id, await renderRich(m.data.text));
}
const iso = (d: Date) => d.toISOString();
---
<BaseLayout title={`${t('moments_title', locale)} · wait4x`}>
<h1 class="page-title">{t('moments_title', locale)}</h1>
<p class="page-note">{t('moments_note', locale)}</p>
<!-- 整页计数(Phase 5h T4):这是本页唯一的计数点(page 模式);
每条瞬间卡片底部的徽标是 readonly,读者开评论不会虚增浏览数 -->
<ViewMeta class="mom-page-meta" pageKey={locale === 'en' ? '/en/moments/' : '/moments/'} {locale} variant="chip" />
<ol class="tl">
{moments.map((m) => (
<li class="tl-item">
<time class="tl-date" datetime={iso(m.data.pubDate)}>
{iso(m.data.pubDate).slice(0, 10)}
</time>
<div class="mom-card">
{m.data.image && (
<figure
class:list={[
'mom-fig',
(m.data.image.width ?? 0) < (m.data.image.height ?? 0) && 'vt',
]}
>
<Image
src={m.data.image}
alt={m.data.text ?? m.data.tags.join(' ')}
widths={[480, 840]}
sizes="(min-width: 760px) 420px, calc(100vw - 140px)"
/>
</figure>
)}
{m.data.text && <div class="mom-text" set:html={richText.get(m.id) ?? ''} />}
{m.data.tags.length > 0 && (
<p class="mom-tags">
{m.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
</p>
)}
<MomentComments slug={m.id} locale={locale} />
</div>
</li>
))}
</ol>
<!-- 安装引导(disclosure 小条):仅浏览器明确可装且未安装时现形,克制不打扰 -->
<div class="install-cta" id="install-cta" hidden>
<span>{locale === 'en' ? 'Add to Home Screen — moments work offline' : '装到主屏,离线也能刷瞬间'}</span>
<button type="button" id="install-btn" class="install-btn">
{locale === 'en' ? 'Install' : '安装'}
</button>
</div>
</BaseLayout>
<script>
// 时间线渐显: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', () => {
armAnim();
refreshInstallCta();
});
armAnim();
// —— PWA 安装引导:beforeinstallprompt 早在页面加载初期触发,
// 故模块级立即绑定存事件,page-load 只负责刷新 UI 显隐 ——
let deferredPrompt: Event & { prompt?: () => Promise<void>; userChoice?: Promise<{ outcome: string }> } | null = null;
document.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e as typeof deferredPrompt;
refreshInstallCta();
});
document.addEventListener('appinstalled', () => {
deferredPrompt = null;
refreshInstallCta();
});
const isInstalled = () =>
matchMedia('(display-mode: standalone)').matches ||
(navigator as Navigator & { standalone?: boolean }).standalone === true;
function refreshInstallCta() {
const cta = document.getElementById('install-cta');
if (!cta) return;
cta.hidden = !(deferredPrompt && !isInstalled());
}
document.addEventListener('click', (e) => {
if ((e.target as HTMLElement)?.id !== 'install-btn') return;
if (!deferredPrompt) return;
void (async () => {
try {
await deferredPrompt?.prompt?.();
const choice = await deferredPrompt?.userChoice;
if (choice?.outcome === 'accepted') {
deferredPrompt = null;
refreshInstallCta();
}
} catch { /* 用户关闭安装框:保留事件,小条仍在 */ }
})();
});
refreshInstallCta();
</script>
<style>
.page-title {
font-size: var(--text-h1);
line-height: var(--leading-heading);
color: var(--ink-1);
margin-top: var(--space-5);
text-wrap: balance;
}
.page-note {
font-size: 13px;
color: var(--ink-4);
margin-block: var(--space-1) var(--space-4);
}
.tl {
list-style: none;
margin: var(--space-4) 0 0;
padding: 0;
}
/* 时间线骨架:左 mono 日期 + 竖线 + --accent 节点 + 右侧内容卡 */
.tl-item {
position: relative;
margin: 0 0 var(--space-4) calc(var(--space-5) + var(--space-4));
padding-left: var(--space-4);
border-left: 1px solid var(--line);
}
.tl-item:last-child { margin-bottom: 0; }
.tl-item::before {
content: '';
position: absolute;
left: -5px;
top: 7px;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 3px var(--bg);
}
.tl-date {
position: absolute;
left: calc(-1 * (var(--space-5) + var(--space-4)));
top: 4px;
width: var(--space-5);
text-align: right;
font-family: var(--font-mono);
font-size: 12.5px;
color: var(--ink-4);
font-variant-numeric: tabular-nums;
}
.mom-card {
border: 1px solid var(--line);
border-radius: 12px;
padding: var(--space-3) var(--space-4);
background: var(--bg-soft);
}
.mom-card > p { font-size: 15px; color: var(--ink-2); }
/* 富文本正文(T2):首尾 margin 归零,避免卡片被 <p> 的默认外边距撑开;
段间只留半档间距,列表缩进对齐正文基线。基调保持 15px / ink-2。 */
.mom-text {
font-size: 15px;
color: var(--ink-2);
line-height: 1.7;
}
.mom-text :global(> :first-child) { margin-top: 0; }
.mom-text :global(> :last-child) { margin-bottom: 0; }
.mom-text :global(p) { margin: 0 0 var(--space-1); }
.mom-text :global(ul),
.mom-text :global(ol) {
margin: 0 0 var(--space-1);
padding-left: 1.35em;
}
.mom-text :global(li) { margin: 0; }
.mom-text :global(li + li) { margin-top: 2px; }
.mom-text :global(blockquote) {
margin: var(--space-1) 0;
padding-left: var(--space-2);
border-left: 2px solid var(--line);
color: var(--ink-3);
}
.mom-text :global(code) {
font-family: var(--font-mono);
font-size: 0.92em;
background: var(--bg);
border-radius: 4px;
padding: 1px 5px;
}
.mom-text :global(a) {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 0.18em;
}
.mom-fig {
border-radius: 8px;
overflow: hidden;
margin: 0 0 var(--space-2);
max-width: 420px;
}
.mom-fig.vt { max-width: 260px; }
.mom-fig :global(img) { width: 100%; height: auto; }
.mom-tags {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
margin-top: var(--space-2);
}
.tag {
font-family: var(--font-mono);
font-size: 12px;
color: var(--ink-3);
background: var(--bg);
border-radius: 5px;
padding: 1px 7px;
white-space: nowrap;
}
/* 窄屏:日期卡到卡片上方,竖线左移收窄 */
@media (max-width: 760px) {
.tl-item { margin-left: var(--space-3); }
.tl-date {
position: static;
display: block;
width: auto;
text-align: left;
margin-bottom: var(--space-1);
}
}
@media (max-width: 560px) {
.mom-card { padding: var(--space-2) var(--space-3); }
.mom-fig { max-width: 100%; }
}
.install-cta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
margin-top: var(--space-5);
padding: var(--space-3);
border: 1px dashed var(--line);
border-radius: 8px;
font-size: 13px;
color: var(--ink-3);
}
.install-btn {
font-family: var(--font-mono);
font-size: 12px;
color: var(--ink-2);
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 6px;
padding: 4px 12px;
cursor: pointer;
transition: border-color var(--dur) var(--ease);
}
.install-btn:hover { border-color: var(--accent); }
.mom-page-meta { 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>