用户要「加一个访客数量、评论数量,采用类似于开源页面从 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
169 lines
6.7 KiB
Plaintext
169 lines
6.7 KiB
Plaintext
---
|
||
// /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>
|