feat(content): 置顶契约与计数徽标组件(5h T1 契约部分)

置顶(T1):
- src/lib/post-order.ts 新增 sortPosts:置顶优先 → pubDate 倒序 → id 兜底,
  把首页双轨/归档/书架四处排序口径收敛到一处
- postsSchema 增 pinned;PostList 三处渲染路径加灰阶 pin 小徽标;后台置顶开关
- 语义边界:只影响列表顺序,不改 pubDate/URL/RSS 时间语义

计数徽标(T4 契约组件):
- src/components/geek/ViewMeta.astro:眼睛 = 浏览数、气泡 = 评论数,
  本地 SVG + tokens 着色,不引第三方图片
- 数据只用 Artalk 匿名端点:GET comments?limit=1 一次拿到 count 与 page.pv;
  page 模式先 POST pages/pv 计一次浏览,readonly 模式零写入
- SSR 占位「–」:无 JS/接口失败不显示 0 误导读者

验证: pnpm astro build 48 页 exit 0;config-check 208 字段/65 hint 全过;
      sortPosts 四调用点替换后构建通过

Spec: 2026-11-02_blog-5h-content-social (T1 契约)

Co-authored-by: GLM-5.3
This commit is contained in:
wait4xandGLM-5.3 committed 2026-11-02 20:02:08 +08:00
1 parent ed473e996a
commit 6538311387
12 files changed
+388 -9

No files matched your search

+179
View File
@@ -0,0 +1,179 @@
---
/**
* 浏览 / 评论计数徽标(Phase 5h T4):眼睛图标 = 浏览数,气泡图标 = 评论数。
*
* 为什么这么设计:
* - 语汇对齐开源页从 GitHub README 移植过来的「👀 Visitors」徽章,但改成本地
* SVG + tokens 着色,不引入任何第三方图片(komarev/shields 在慢网下空白破版)。
* - 数据只用 Artalk 匿名可读端点(2026-11-02 生产实测):
* GET /artalk/api/v2/comments?site_name=…&page_key=…&limit=1
* → { count, roots_count, page: { pv } } (一次拿到评论数与浏览量)
* POST /artalk/api/v2/pages/pv { page_key, site_name } → { pv }
* - `mode="readonly"` 只读(瞬间列表每条都要显示,逐条计数会污染数据);
* `mode="page"` 计入一次浏览(文章/关于/开源等整页场景)。
*
* SSR 渲染占位「–」:无 JS / 接口失败时退化为一个短横,不显示 0 误导读者。
*/
interface Props {
/** 评论流键(与 ArtalkComments 的 pageKey 同语义,双语页共用) */
pageKey: string;
locale?: 'zh' | 'en';
/** page = 计入一次浏览;readonly = 只读,不改动计数 */
mode?: 'page' | 'readonly';
/** 是否显示评论数(瞬间逐条场景也显示) */
showComments?: boolean;
/** 是否显示浏览数 */
showPv?: boolean;
/** 视觉变体:inline = 行内小字(默认);chip = 带底框的徽标条 */
variant?: 'inline' | 'chip';
}
const {
pageKey,
locale = 'zh',
mode = 'page',
showComments = true,
showPv = true,
variant = 'inline',
} = Astro.props;
const L = {
pv: locale === 'zh' ? '次浏览' : 'views',
cm: locale === 'zh' ? '条评论' : 'comments',
};
---
<p
class:list={['viewmeta', variant === 'chip' && 'chip']}
data-viewmeta
data-page-key={pageKey}
data-mode={mode}
data-i18n-pv={L.pv}
data-i18n-cm={L.cm}
>
{showPv && (
<span class="vm-item" title={L.pv}>
<svg class="vm-icon" viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">
<path
d="M1.3 8S3.9 3.9 8 3.9 14.7 8 14.7 8 12.1 12.1 8 12.1 1.3 8 1.3 8Z"
fill="none"
stroke="currentColor"
stroke-width="1.25"
stroke-linecap="round"
stroke-linejoin="round"></path>
<circle cx="8" cy="8" r="1.9" fill="none" stroke="currentColor" stroke-width="1.25"></circle>
</svg>
<span data-vm-pv aria-label={L.pv}>–</span>
</span>
)}
{showComments && (
<span class="vm-item" title={L.cm}>
<svg class="vm-icon" viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">
<path
d="M2.4 3.2h11.2v7.3H7.1L4 13.2v-2.7H2.4z"
fill="none"
stroke="currentColor"
stroke-width="1.25"
stroke-linecap="round"
stroke-linejoin="round"></path>
</svg>
<span data-vm-cm aria-label={L.cm}>–</span>
</span>
)}
</p>
<script>
// 同一页可能有多个实例(瞬间时间线逐条),脚本只跑一份,统一批量处理。
const SITE = 'wait4x.online';
const API = '/artalk/api/v2';
interface Meta {
count?: number;
page?: { pv?: number };
}
const fill = (root: HTMLElement, meta: Meta) => {
const pv = root.querySelector<HTMLElement>('[data-vm-pv]');
const cm = root.querySelector<HTMLElement>('[data-vm-cm]');
const locale = document.documentElement.lang === 'en' ? 'en' : 'zh';
const unitPv = locale === 'zh' ? '次浏览' : 'views';
const unitCm = locale === 'zh' ? '条评论' : 'comments';
if (pv) {
const n = meta.page?.pv;
pv.textContent = typeof n === 'number' ? `${n} ${unitPv}` : '–';
}
if (cm) {
const n = meta.count;
cm.textContent = typeof n === 'number' ? `${n} ${unitCm}` : '–';
}
};
const loadOne = async (root: HTMLElement) => {
const key = root.dataset.pageKey;
if (!key) return;
const mode = root.dataset.mode ?? 'page';
try {
// page 模式先计一次浏览(POST 绕 ESA 缓存),再读评论数;
// readonly 模式只读:一次 GET 同时拿到 pv 与 count,零写入。
if (mode === 'page') {
const bumped = await fetch(`${API}/pages/pv`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ page_key: key, site_name: SITE }),
});
const data = (await bumped.json()) as Meta;
const res = await fetch(
`${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 } });
return;
}
const res = await fetch(
`${API}/comments?site_name=${encodeURIComponent(SITE)}&page_key=${encodeURIComponent(key)}&limit=1`,
);
fill(root, (await res.json()) as Meta);
} catch {
/* 接口不可用:保留占位「–」,不显示 0 误导读者 */
}
};
const run = () => {
for (const root of document.querySelectorAll<HTMLElement>('[data-viewmeta]')) {
void loadOne(root);
}
};
document.addEventListener('astro:page-load', run);
run();
</script>
<style>
.viewmeta {
display: inline-flex;
align-items: center;
gap: var(--space-2);
margin: 0;
font-family: var(--font-mono);
font-size: 12px;
color: var(--ink-4);
font-variant-numeric: tabular-nums;
}
.viewmeta.chip {
padding: 3px 9px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--bg-soft);
gap: var(--space-3);
}
.vm-item {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.vm-icon {
flex: none;
opacity: 0.85;
}
</style>