Files
blog/src/pages/en/archives/[...slug].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

169 lines
6.7 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.
---
// /en/archives/{slug}:英文版文章页——postsEn 优先;缺译回退 zh 正文 + 醒目标注
import type { CollectionEntry } from 'astro:content';
import { getCollection, render } from 'astro:content';
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';
export async function getStaticPaths() {
// 路径全量(hidden 样张出页);prev/next 滤 hidden(同 zh 轨修正)
const all = (await getCollection('posts', ({ data }) => !data.draft)).sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
);
const nav = all.filter(({ data }) => !data.tags.includes('hidden'));
const en = new Map((await getCollection('postsEn')).map((e) => [e.id, e]));
return all.map((entry) => {
const i = nav.findIndex((e) => e.id === entry.id);
return {
params: { slug: entry.id },
props: {
entry,
enEntry: en.get(entry.id) ?? null,
prev: i >= 0 ? (nav[i + 1] ?? null) : null,
next: i > 0 ? (nav[i - 1] ?? null) : null,
},
};
});
}
interface Props {
entry: CollectionEntry<'posts'>;
enEntry: CollectionEntry<'postsEn'> | null;
prev: CollectionEntry<'posts'> | null;
next: CollectionEntry<'posts'> | null;
}
const { entry, enEntry, prev, next } = Astro.props;
// 译文存在则渲染译文;否则回退 zh 正文(语言切换的降级承诺:单篇未译不破站)
const { Content } = await render(enEntry ?? entry);
const isReading = entry.data.category === 'reading';
const date = entry.data.pubDate.toISOString().slice(0, 10);
const words = (enEntry?.body ?? entry.body ?? '').split(/\s+/).length;
const minutes = Math.max(1, Math.ceil(words / 220));
---
<BaseLayout title={`${enEntry?.data.title ?? entry.data.title} · wait4x`} track={isReading ? 'reading' : 'tech'}>
<article class={`post ${isReading ? 'post-reading' : 'post-tech'}`}>
<header class="post-head">
<p class="post-cat">
{isReading ? t('category_reading', 'en') : entry.data.category === 'tech' ? t('category_tech', 'en') : t('category_photo', 'en')}
</p>
<h1 class="post-title">{enEntry?.data.title ?? entry.data.title}</h1>
<p class="post-meta">
<i class="emo" style={`background:${entry.data.mood ?? 'var(--mood)'}`} aria-hidden="true" />
<time datetime={entry.data.pubDate.toISOString()}>{date}</time>
<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'}>
<Content />
{entry.data.video && (
<figure class="post-video">
<video controls preload="metadata" playsinline poster={entry.data.poster}>
<source src={entry.data.video} />
Your browser cannot play embedded video — <a href={entry.data.video}>download it instead</a>.
</video>
</figure>
)}
</div>
<nav class="pn">
{prev && <a class="pn-item" href={`/en/archives/${prev.id}/`}>← {t('prev_post', 'en')} · {prev.data.title}</a>}
{next && <a class="pn-item" href={`/en/archives/${next.id}/`}>{t('next_post', 'en')} · {next.data.title} →</a>}
</nav>
<ArtalkComments pageKey={`/archives/${entry.id}/`} pageTitle={entry.data.title} />
</article>
<LinkCardStyles />
<PhotoZoom />
</BaseLayout>
<style>
.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); }
.pn-item { font-size: 14px; color: var(--ink-2); transition: color var(--dur) var(--ease); }
.pn-item:hover { color: var(--accent); }
/* ---- T5 code window bar (§6-3): ≥4-line blocks only ---- */
.prose-tech :global(pre.code-win) {
position: relative;
padding-top: calc(var(--space-3) + 30px);
}
.prose-tech :global(pre.code-win::before) {
content: attr(data-lang);
position: absolute;
inset: 0 0 auto 0;
height: calc(var(--space-3) + 30px - 1px);
display: flex;
align-items: flex-end;
padding: 0 14px 8px 66px;
background: #161b22;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 8px 8px 0 0;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.04em;
color: #6e7681;
text-transform: lowercase;
}
.prose-tech :global(pre.code-win::after) {
content: '';
position: absolute;
top: 10px;
left: 14px;
width: 10px;
height: 10px;
border-radius: 50%;
background: #30363d;
box-shadow: 18px 0 0 #30363d, 36px 0 0 #30363d;
}
/* ---- T6 reading paper card (§6-2) ---- */
/* 文章附视频:与 zh 版同构(cms 车道 B) */
.post-video {
margin: var(--space-4) 0 0;
}
.post-video video {
width: 100%;
height: auto;
border-radius: 10px;
background: #000;
display: block;
}
.prose-reading {
border: 1px solid var(--paper-line);
border-radius: 12px;
padding: var(--space-5) clamp(20px, 4vw, 32px);
}
</style>
<script>
// Code window bar marker (same as zh track): count lines, tag .code-win + data-lang
const markCodeWins = () => {
document.querySelectorAll('.prose-tech pre:not(.code-win)').forEach((pre) => {
const code = pre.querySelector('code');
if (!code) return;
const lines = (code.textContent ?? '').split('\n').length;
if (lines >= 4) {
// astro shiki 语言落 pre[data-language](code 类名为空)
const lang = pre.dataset.language ?? /language-([\w-]+)/.exec(code.className)?.[1] ?? 'text';
pre.dataset.lang = lang;
pre.classList.add('code-win');
}
});
};
document.addEventListener('astro:page-load', markCodeWins);
markCodeWins();
</script>