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
This commit is contained in:
1 parent
8e74bead62
commit
63c9c8ad4a
6 files changed
+52
-67
No files matched your search
@@ -23,7 +23,7 @@
|
||||
- boundary: 新增 src/components/MomentComments.astro(折叠 disclosure,展开时首次挂载 Artalk;page_key 约定 `/moments/#<slug>`,en 页同一 key 不分裂;评论数徽标复用 T4 的 ViewMeta 计数接口)、src/components/pages/MomentsPage.astro(每条卡片挂评论区)
|
||||
- verify: `test -f src/components/MomentComments.astro && grep -q "MomentComments" src/components/pages/MomentsPage.astro && grep -q "moments/#" src/components/MomentComments.astro && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS"`
|
||||
|
||||
- [ ] T4 计数徽标(眼睛图标 = 浏览数,气泡图标 = 评论数)
|
||||
- [x] T4 计数徽标(眼睛图标 = 浏览数,气泡图标 = 评论数)
|
||||
- claim: op-artalk
|
||||
- boundary: src/components/geek/ViewMeta.astro(**契约组件,由 orchestrator 在 T1 提交时一并落盘**,车道只消费不改语义)、src/components/ArtalkComments.astro(pv 小字行改为 ViewMeta,评论数取 `comments?limit=1` 的 count)、src/pages/archives/[...slug].astro、src/pages/en/archives/[...slug].astro(挂 ViewMeta)
|
||||
- verify: `test -f src/components/geek/ViewMeta.astro && grep -q "ViewMeta" src/components/ArtalkComments.astro && grep -q "ViewMeta" src/pages/archives/[...slug].astro && E2E_BASE=http://127.0.0.1:18925 node tests/e2e.mjs 2>&1 | grep -q "37/37 PASS"`
|
||||
|
||||
@@ -7,15 +7,14 @@ interface Props {
|
||||
}
|
||||
const { pageKey, pageTitle } = Astro.props;
|
||||
import { t } from '../i18n/ui';
|
||||
import ViewMeta from './geek/ViewMeta.astro';
|
||||
import { localeOf } from '../lib/locale';
|
||||
const locale = localeOf(Astro.url.pathname);
|
||||
---
|
||||
|
||||
<!-- pv 小字:计数由本组件下方脚本独立 POST(页面加载即计),与 Artalk init 的 pvAdd 解耦 -->
|
||||
<p class="atk-pv-line" aria-label={t('pv_viewed', locale)}>
|
||||
<span data-artalk-pv data-page-key={pageKey} data-page-title={pageTitle}>–</span>
|
||||
{' '}{t('pv_viewed', locale)}
|
||||
</p>
|
||||
<!-- 浏览/评论徽标(Phase 5h T4):评论区这枚是 **readonly** —— 整页的计数点唯一在
|
||||
详情页标题下的 ViewMeta(mode="page"),此处只读展示,避免同页两处各计一次。 -->
|
||||
<ViewMeta pageKey={pageKey} locale={locale} mode="readonly" variant="chip" />
|
||||
<div id="artalk-container" data-page-key={pageKey} data-page-title={pageTitle}></div>
|
||||
<p class="atk-load-hint" id="artalk-retry" hidden>
|
||||
<span>评论区加载失败。</span>
|
||||
@@ -100,37 +99,11 @@ const locale = localeOf(Astro.url.pathname);
|
||||
cursor: pointer;
|
||||
}
|
||||
.atk-retry-btn:hover { border-color: var(--accent); }
|
||||
.atk-pv-line {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--ink-4);
|
||||
margin: var(--space-2) 0 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// 访问量:POST /artalk/api/v2/pages/pv 计数并取数(POST 天然绕过 ESA 边缘缓存;
|
||||
// GET /stats/* 会被 ESA 缓存出冻结数字,勿改查询式)。page-load 兼容软导航换页重计。
|
||||
const bumpPv = async () => {
|
||||
const el = document.querySelector('[data-artalk-pv]');
|
||||
if (!el) return;
|
||||
const key = el.dataset.pageKey ?? '';
|
||||
if (!key) return;
|
||||
try {
|
||||
const res = await fetch('/artalk/api/v2/pages/pv', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ page_key: key, page_title: el.dataset.pageTitle ?? '', site_name: 'wait4x.online' }),
|
||||
});
|
||||
const d = await res.json();
|
||||
el.textContent = String(d.pv ?? '–');
|
||||
} catch {
|
||||
el.textContent = '–'; // 计数失败静默:小字不可见性优于报错
|
||||
}
|
||||
};
|
||||
document.addEventListener('astro:page-load', () => void bumpPv());
|
||||
void bumpPv();
|
||||
|
||||
// 浏览计数已移交 ViewMeta 组件(Phase 5h T4),此处只管评论挂载。
|
||||
//
|
||||
// ── 挂载健壮化(用户实测「总没加载出来」)──
|
||||
// 触发路径三条:IO 进入视口(主)/ 点击评论区锚点(兜底)/ 滚到页底 2s 后(兜底);
|
||||
// 失败路径:脚本 5s 未 onload → 灰阶提示 + 重试钮;成功后容器打 data-artalk-mounted 供 E2E 断言。
|
||||
|
||||
@@ -111,6 +111,12 @@ const L = {
|
||||
const loadOne = async (root: HTMLElement) => {
|
||||
const key = root.dataset.pageKey;
|
||||
if (!key) return;
|
||||
// 每元素只处理一次(2026-11-02 实测教训):首屏 astro:page-load 与本脚本的
|
||||
// 立即调用会双触发,page 模式每元素会 POST 两次 → 浏览量翻倍(实测
|
||||
// 15→17→19 每载 +2,加此守卫后 1/2/3 每载 +1)。软导航新页面元素是新的,
|
||||
// 故不牺牲「换页重计」语义。
|
||||
if (root.dataset.vmDone === '1') return;
|
||||
root.dataset.vmDone = '1';
|
||||
const mode = root.dataset.mode ?? 'page';
|
||||
try {
|
||||
// page 模式先计一次浏览(POST 绕 ESA 缓存),再读评论数;
|
||||
@@ -126,7 +132,15 @@ const L = {
|
||||
`${API}/comments?site_name=${encodeURIComponent(SITE)}&page_key=${encodeURIComponent(key)}&limit=1`,
|
||||
);
|
||||
const meta = (await res.json()) as Meta;
|
||||
fill(root, { count: meta.count, page: { pv: data.pv ?? meta.page?.pv } });
|
||||
const merged: Meta = { count: meta.count, page: { pv: data.pv ?? meta.page?.pv } };
|
||||
fill(root, merged);
|
||||
// 同页同键的其它实例(如评论区 chip)复用本次结果:否则它独立 GET 到的
|
||||
// pv 可能比计过数的这份少 1,两个徽标同页显示不同数字会像 bug。
|
||||
for (const other of document.querySelectorAll<HTMLElement>('[data-viewmeta]')) {
|
||||
if (other === root || other.dataset.pageKey !== key) continue;
|
||||
other.dataset.vmDone = '1';
|
||||
fill(other, merged);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const res = await fetch(
|
||||
@@ -134,12 +148,27 @@ const L = {
|
||||
);
|
||||
fill(root, (await res.json()) as Meta);
|
||||
} catch {
|
||||
// 页模式失败:同键 readonly 兄弟此前被跳过,这里放它们自己读,避免永远「–」
|
||||
for (const other of document.querySelectorAll<HTMLElement>('[data-viewmeta]')) {
|
||||
if (other === root || other.dataset.pageKey !== key || other.dataset.vmDone === '1') continue;
|
||||
delete other.dataset.vmDone;
|
||||
void loadOne(other);
|
||||
}
|
||||
/* 接口不可用:保留占位「–」,不显示 0 误导读者 */
|
||||
}
|
||||
};
|
||||
|
||||
const run = () => {
|
||||
for (const root of document.querySelectorAll<HTMLElement>('[data-viewmeta]')) {
|
||||
const all = [...document.querySelectorAll<HTMLElement>('[data-viewmeta]')];
|
||||
// 同键存在 page 实例时,readonly 实例等它回填(它拿到的是计过数的 pv,
|
||||
// 与 section 内另一枚徽标必然一致);否则同页两枚会显示差 1 的两个数字。
|
||||
const pageKeys = new Set(
|
||||
all
|
||||
.filter((el) => (el.dataset.mode ?? 'page') === 'page' && el.dataset.pageKey)
|
||||
.map((el) => el.dataset.pageKey as string),
|
||||
);
|
||||
for (const root of all) {
|
||||
if ((root.dataset.mode ?? 'page') === 'readonly' && pageKeys.has(root.dataset.pageKey ?? '')) continue;
|
||||
void loadOne(root);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
@@ -32,10 +33,9 @@ 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>
|
||||
<p class="pv-line" aria-label={t('pv_viewed', locale)}>
|
||||
<span data-artalk-pv data-page-key={locale === 'en' ? '/en/moments/' : '/moments/'}>–</span>
|
||||
{' '}{t('pv_viewed', 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">
|
||||
@@ -80,25 +80,6 @@ const iso = (d: Date) => d.toISOString();
|
||||
</BaseLayout>
|
||||
|
||||
<script>
|
||||
// 访问量:与 ArtalkComments 同款独立 POST(双轨 pageKey 分开计数;POST 绕 ESA 缓存)
|
||||
const bumpPv = async () => {
|
||||
const el = document.querySelector('[data-artalk-pv]');
|
||||
if (!el) return;
|
||||
const key = el.dataset.pageKey ?? '';
|
||||
if (!key) return;
|
||||
try {
|
||||
const res = await fetch('/artalk/api/v2/pages/pv', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ page_key: key, site_name: 'wait4x.online' }),
|
||||
});
|
||||
const d = await res.json();
|
||||
el.textContent = String(d.pv ?? '–');
|
||||
} catch {
|
||||
el.textContent = '–';
|
||||
}
|
||||
};
|
||||
|
||||
// 时间线渐显:html[data-mom-anim] 由 JS 前置标记——无 JS / reduced-motion 时
|
||||
// 标记缺席,CSS 初始隐藏规则整体不生效(渐进降级,元素永远可见)
|
||||
let io: IntersectionObserver | null = null;
|
||||
@@ -122,11 +103,9 @@ const iso = (d: Date) => d.toISOString();
|
||||
};
|
||||
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
void bumpPv();
|
||||
armAnim();
|
||||
refreshInstallCta();
|
||||
});
|
||||
void bumpPv();
|
||||
armAnim();
|
||||
|
||||
// —— PWA 安装引导:beforeinstallprompt 早在页面加载初期触发,
|
||||
@@ -318,12 +297,7 @@ const iso = (d: Date) => d.toISOString();
|
||||
transition: border-color var(--dur) var(--ease);
|
||||
}
|
||||
.install-btn:hover { border-color: var(--accent); }
|
||||
.pv-line {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--ink-4);
|
||||
margin: 0 0 var(--space-4);
|
||||
}
|
||||
.mom-page-meta { margin: 0 0 var(--space-4); }
|
||||
/* 初始隐藏仅在 JS 标记存在时生效:宁轻勿闹——透明度+8px 上移,进入视口一次性点亮 */
|
||||
[data-mom-anim] .tl-item {
|
||||
opacity: 0;
|
||||
|
||||
@@ -5,6 +5,7 @@ import LinkCardStyles from '../../components/geek/LinkCardStyles.astro';
|
||||
import PhotoZoom from '../../components/geek/PhotoZoom.astro';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import ArtalkComments from '../../components/ArtalkComments.astro';
|
||||
import ViewMeta from '../../components/geek/ViewMeta.astro';
|
||||
import { trackOf } from '../../content.config';
|
||||
|
||||
// URL = /archives/{slug},slug 即文件名,与 Halo 旧结构 1:1(不可改)
|
||||
@@ -61,6 +62,7 @@ const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>阅读时长 {minutes} 分钟</span>
|
||||
</p>
|
||||
<ViewMeta pageKey={`/archives/${entry.id}/`} mode="page" variant="inline" />
|
||||
</header>
|
||||
<div class="prose-reading">
|
||||
<Content />
|
||||
@@ -88,6 +90,7 @@ const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>阅读时长 {minutes} 分钟</span>
|
||||
</p>
|
||||
<ViewMeta pageKey={`/archives/${entry.id}/`} mode="page" variant="inline" />
|
||||
</header>
|
||||
<div class="tech-grid">
|
||||
<div class="prose-tech">
|
||||
@@ -209,6 +212,8 @@ const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
|
||||
margin-top: var(--space-2);
|
||||
text-wrap: balance;
|
||||
}
|
||||
/* 计数徽标(ViewMeta 组件根元素)紧贴 meta 行;scoped 样式够不着子组件内部,需 :global */
|
||||
.post-head :global(.viewmeta) { margin-top: 8px; }
|
||||
.post-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -6,6 +6,7 @@ import LinkCardStyles from '../../../components/geek/LinkCardStyles.astro';
|
||||
import PhotoZoom from '../../../components/geek/PhotoZoom.astro';
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro';
|
||||
import ArtalkComments from '../../../components/ArtalkComments.astro';
|
||||
import ViewMeta from '../../../components/geek/ViewMeta.astro';
|
||||
import AiTranslatedBadge from '../../../components/AiTranslatedBadge.astro';
|
||||
import { t } from '../../../i18n/ui';
|
||||
|
||||
@@ -58,6 +59,7 @@ const minutes = Math.max(1, Math.ceil(words / 220));
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{minutes} {t('reading_time_min', 'en')}</span>
|
||||
</p>
|
||||
<ViewMeta pageKey={`/archives/${entry.id}/`} locale="en" mode="page" variant="inline" />
|
||||
</header>
|
||||
<AiTranslatedBadge />
|
||||
<div class={isReading ? 'prose-reading' : 'prose-tech'}>
|
||||
@@ -85,6 +87,8 @@ const minutes = Math.max(1, Math.ceil(words / 220));
|
||||
.post-head { margin-bottom: var(--space-4); }
|
||||
.post-cat { font-family: var(--font-mono); font-size: 12px; color: var(--ink-3); letter-spacing: 0.08em; }
|
||||
.post-title { font-size: var(--text-h1); line-height: var(--leading-heading); color: var(--ink-1); text-wrap: balance; }
|
||||
/* 计数徽标紧贴 meta 行(组件根元素在子作用域,需 :global) */
|
||||
.post-head :global(.viewmeta) { margin-top: 8px; }
|
||||
.post-meta { display: flex; align-items: center; gap: var(--space-2); margin-top: var(--space-2); font-size: 13px; color: var(--ink-3); }
|
||||
.emo { display: inline-block; width: 10px; height: 10px; border-radius: 50%; }
|
||||
.pn { display: flex; flex-direction: column; gap: var(--space-2); margin-top: var(--space-5); }
|
||||
|
||||
Reference in new issue
Block a user