Files
ephron-ren-prd/prd-blog-post-collections-a-nesting-fix.md

251 lines
7.7 KiB
Markdown
Raw Permalink 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.
# Blog 文章列表 `<a>` 嵌套导致卡片分裂 — 修复 PRD
> **版本**: v1.0
> **日期**: 2026-05-06
> **状态**: 📝 待评审
---
## 一、问题描述
### 1.1 现象
`blog.ephron.ren/posts` 页面,带有 collection 标签的文章(如 `algorithm-efficiency-measure`)被浏览器渲染为 **2~3 张独立卡片**,而非一张完整的卡片。
视觉效果:
```
┌─────────────────────────────┐
│ 【数据结构】算法效率的度量 │ ← 卡片1: 标题 + 摘要
│ 算法执行时间随问题规模... │
└─────────────────────────────┘
┌─────────────────────────────┐
│ 2026-03-04 │ ← 卡片2: 日期 + 标签
│ [数据结构] [算法] ... │
└─────────────────────────────┘
┌─────────────────────────────┐
│ [数据结构 collection] │ ← 卡片3: collection badge
└─────────────────────────────┘
```
### 1.2 影响范围
- 所有**拥有 collection 关联的文章**在 posts 列表页均受影响
- 不带 collection 的文章显示正常(无嵌套 `<a>` 冲突)
- 首页文章列表home 服务)不受影响(使用不同模板)
### 1.3 严重程度
🟡 中等 — 功能可用但视觉体验明显异常,影响专业度
---
## 二、根因分析
### 2.1 HTML 源码结构(模板)
`blog/templates/index.html` 中,文章卡片的结构如下:
```html
<li>
<a href="/posts/{{ post.slug }}" class="post-item">
<span class="post-title">标题</span>
<p class="post-excerpt">摘要...</p>
<div class="post-meta">
<span class="post-date">2026-03-04</span>
<div class="post-tags">...</div>
<!-- ⚠️ 问题在这里 -->
{% if item.collections %}
<div class="post-collections">
{% for col in item.collections %}
<a href="/collections/{{ col.key }}" class="collection-badge">{{ col.title }}</a>
{% endfor %}
</div>
{% endif %}
</div>
</a>
</li>
```
### 2.2 浏览器解析行为
**HTML 规范禁止 `<a>` 标签嵌套 `<a>`**`<a>` 的内容模型不能包含 interactive content
当浏览器遇到嵌套的 `<a>` 时,会自动"修复"DOM
1. 遇到第一个 `<a class="post-item">` → 打开
2. 遇到内部 `<a class="collection-badge">` → **自动关闭**外层 `<a>`
3. 后续的 `</div>``</a>` 变成游离节点
### 2.3 实际渲染的 DOMPlaywright 提取)
```html
<li>
<!-- 第1个卡片标题 + 摘要 -->
<a class="post-item">
<span class="post-title">【数据结构】算法效率的度量</span>
<p class="post-excerpt">...</p>
</a>
<!-- 浏览器自动关闭后的 post-meta -->
<div class="post-meta">
<!-- 第2个卡片日期 + 标签 -->
<a class="post-item">
<span class="post-date">2026-03-04</span>
<div class="post-tags">...</div>
</a>
<div class="post-collections">
<!-- 第3个卡片collection badge -->
<a class="post-item">...</a>
<a class="collection-badge">数据结构</a>
</div>
</div>
</li>
```
一个 `<li>` 里出现了 **3 个 `<a class="post-item">` 兄弟节点**,浏览器将它们渲染为 3 张独立卡片。
---
## 三、修复方案
### 3.1 方案概述
`<a>` 标签的包裹范围缩小,**不包含 `post-collections` 部分**。将整个卡片改为 `<div>` + JS 点击跳转,或将 collection badge 移到 `<a>` 外部。
### 3.2 推荐方案:改为 `<div>` + 点击事件
将外层 `<a class="post-item">` 替换为 `<div class="post-item">`,通过 CSS `cursor: pointer` 和 JS `click` 事件实现整卡点击跳转。
**优势**
- 根本性解决嵌套问题
- 不影响现有样式class 不变)
- collection badge 的 `<a>` 链接可独立工作
**修改文件**`blog/templates/index.html`
### 3.3 代码 Diff
```diff
--- a/blog/templates/index.html
+++ b/blog/templates/index.html
@@ -268,7 +268,7 @@
{% set excerpt = post.content | striptags | truncate(120) %}
<li>
- <a
+ <div
href="/posts/{{ post.slug }}"
class="post-item"
data-post-slug="{{ post.slug }}"
@@ -298,7 +298,7 @@
{% endif %}
</div>
- </a>
+ </div>
</li>
```
### 3.4 CSS 补充
确保 `.post-item` 保留点击样式:
```css
.post-item {
cursor: pointer;
display: block;
/* 保留现有样式 */
}
```
### 3.5 JS 补充(如需)
如果不想依赖 `<a>` 标签的原生跳转,添加点击事件:
```javascript
document.querySelectorAll('.post-item').forEach(item => {
item.addEventListener('click', (e) => {
// 如果点击的是 collection badge不触发卡片跳转
if (e.target.closest('.collection-badge')) return;
const slug = item.dataset.postSlug;
if (slug) window.location.href = `/posts/${slug}`;
});
});
```
---
## 四、验证方法
### 4.1 测试用例
| # | 测试项 | 预期结果 |
|---|--------|----------|
| 1 | 带 collection 的文章卡片 | 显示为**一张**完整卡片 |
| 2 | 不带 collection 的文章卡片 | 显示正常(无回归) |
| 3 | 点击卡片标题/摘要区域 | 跳转到文章详情页 |
| 4 | 点击 collection badge | 跳转到 collection 页面(不触发卡片跳转) |
| 5 | 卡片 hover 效果 | 正常显示 |
| 6 | 移动端响应式 | 卡片布局正常 |
### 4.2 验证命令
```bash
# 检查 DOM 中不应出现多个 post-item
python3 -c "
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto('https://blog.ephron.ren/posts')
page.wait_for_selector('.post-item')
# 每个 li 应该只有一个 post-item
lis = page.locator('li:has(.post-item)').all()
for li in lis:
count = li.locator('.post-item').count()
assert count == 1, f'Expected 1 post-item, got {count}'
print('PASS: All cards have single post-item')
browser.close()
"
```
---
## 五、风险评估
| 风险 | 影响 | 缓解措施 |
|------|------|----------|
| 改为 `<div>` 后 SEO 影响 | 🟡 低 — 搜索引擎已通过 sitemap 索引 | 保留 `data-post-slug` 属性 |
| 点击事件与 collection badge 冲突 | 🟡 中 | JS 中通过 `e.target.closest('.collection-badge')` 排除 |
| 现有 CSS 依赖 `<a>` 标签 | 🟢 低 | `.post-item` 使用 class 选择器,不依赖标签名 |
---
## 六、附录
### A. 相关文件
| 文件 | 说明 |
|------|------|
| `blog/templates/index.html` | 文章列表模板(问题所在) |
| `blog/static/css/style.css` | 卡片样式 |
| `blog/src/routes/pages.py` | 路由:传递 `item.collections` 到模板 |
### B. 复现截图
通过 Playwright 提取的 DOM 片段algorithm-efficiency-measure 文章):
```
原始模板 → 浏览器修复后
<a class="post-item"> → <a class="post-item">标题+摘要</a>
标题+摘要 → <div class="post-meta">
<div class="post-meta"> → <a class="post-item">日期+标签</a>
日期+标签 → <div class="post-collections">
<div class="post-col"> → <a class="post-item">...</a>
<a class="collection">→ <a class="collection-badge">数据结构</a>
</div> → </div>
</div> → </div>
</a>
```