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:
1 parent
ed473e996a
commit
6538311387
12 files changed
+388
-9
No files matched your search
@@ -89,6 +89,9 @@ const byMonth = (list: CollectionEntry<'posts'>[]) => {
|
||||
/>
|
||||
)}
|
||||
<span class="row-title">{titleOverrides?.[post.id] ?? post.data.title}</span>
|
||||
{post.data.pinned && (
|
||||
<span class="pin" title={t('pinned_label', locale)}>{t('pinned_label', locale)}</span>
|
||||
)}
|
||||
{key !== 'reading' &&
|
||||
post.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
|
||||
<time class="row-date" datetime={post.data.pubDate.toISOString()}>
|
||||
@@ -130,6 +133,9 @@ const byMonth = (list: CollectionEntry<'posts'>[]) => {
|
||||
/>
|
||||
)}
|
||||
<span class="row-title">{titleOverrides?.[post.id] ?? post.data.title}</span>
|
||||
{post.data.pinned && (
|
||||
<span class="pin" title={t('pinned_label', locale)}>{t('pinned_label', locale)}</span>
|
||||
)}
|
||||
{post.data.category !== 'reading' &&
|
||||
post.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
|
||||
<time class="row-date" datetime={post.data.pubDate.toISOString()}>
|
||||
@@ -174,6 +180,9 @@ const byMonth = (list: CollectionEntry<'posts'>[]) => {
|
||||
/>
|
||||
)}
|
||||
<span class="row-title">{titleOverrides?.[post.id] ?? post.data.title}</span>
|
||||
{post.data.pinned && (
|
||||
<span class="pin" title={t('pinned_label', locale)}>{t('pinned_label', locale)}</span>
|
||||
)}
|
||||
{cat !== 'reading' &&
|
||||
post.data.tags.map((tag) => <code class="tag">{`#${tag}`}</code>)}
|
||||
<time class="row-date" datetime={post.data.pubDate.toISOString()}>
|
||||
@@ -261,6 +270,18 @@ const byMonth = (list: CollectionEntry<'posts'>[]) => {
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden; color: var(--accent); }
|
||||
/* 置顶标记:灰阶 hairline 小徽标,不抢标题注意力 */
|
||||
.pin {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--ink-4);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
padding: 0 5px;
|
||||
white-space: nowrap;
|
||||
flex: none;
|
||||
}
|
||||
.tag {
|
||||
flex: none;
|
||||
font-family: var(--font-mono);
|
||||
|
||||
@@ -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>
|
||||
@@ -5,13 +5,15 @@ import { getCollection } from 'astro:content';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { t } from '../../i18n/ui';
|
||||
import type { Locale } from '../../lib/locale';
|
||||
import { sortPosts } from '../../lib/post-order';
|
||||
import { Image } from 'astro:assets';
|
||||
|
||||
interface Props { locale?: Locale }
|
||||
const { locale = 'zh' } = Astro.props;
|
||||
|
||||
const books = (await getCollection('posts', ({ data }) => !data.draft && data.category === 'reading' && !data.tags.includes('hidden')))
|
||||
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
|
||||
const books = sortPosts(
|
||||
await getCollection('posts', ({ data }) => !data.draft && data.category === 'reading' && !data.tags.includes('hidden')),
|
||||
);
|
||||
|
||||
// 回退契约(schema 注释声称而此前未实现——视觉复核抓到的缺口):
|
||||
// description 缺省取正文第一个非空段落(去 markdown 记号)前 60 字
|
||||
|
||||
@@ -10,6 +10,7 @@ import ProjectWall from '../geek/ProjectWall.astro';
|
||||
import Heatmap from '../geek/Heatmap.astro';
|
||||
import SocialLinks from '../SocialLinks.astro';
|
||||
import { t } from '../../i18n/ui';
|
||||
import { sortPosts } from '../../lib/post-order';
|
||||
import type { Locale } from '../../lib/locale';
|
||||
import {
|
||||
homeConfig,
|
||||
@@ -30,9 +31,10 @@ const cfg = await homeConfig(locale);
|
||||
const subtitleHtml = cfg.subtitle ? await renderMarkdown(cfg.subtitle) : '';
|
||||
const announcementHtml = cfg.announcement ? await renderRich(cfg.announcement) : '';
|
||||
|
||||
const posts = (
|
||||
await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden'))
|
||||
).sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime());
|
||||
// 置顶优先 → 时间倒序(sortPosts 契约见 src/lib/post-order.ts)
|
||||
const posts = sortPosts(
|
||||
await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden')),
|
||||
);
|
||||
|
||||
// en 轨列表标题吃 postsEn 译题(缺译回退 zh,与 /en/archives 同策略)
|
||||
const enTitle = locale === 'en'
|
||||
|
||||
Reference in new issue
Block a user