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
@@ -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>
|
||||
Reference in new issue
Block a user