From 895f5b6fc1069a498c00c3085a393024e51c79ab Mon Sep 17 00:00:00 2001 From: "Yangkai.Shen" <237497819@qq.com> Date: Mon, 19 Jan 2026 21:45:44 +0800 Subject: [PATCH] feat: add cover image display for article detail and list pages - Display ogImage on article detail page above title with hover effects - Add thumbnail in article list cards (responsive layout) - Support cover image in RSS feed via enclosure tag - Auto-detect MIME type for RSS enclosure Co-Authored-By: Claude Opus 4.5 --- .../design.md | 132 ++++++++++++++++++ .../proposal.md | 44 ++++++ .../specs/cover-image/spec.md | 78 +++++++++++ .../tasks.md | 57 ++++++++ src/components/Card.astro | 41 +++++- ...7.extend-claude-code-with-slash-command.md | 1 + src/layouts/PostDetails.astro | 13 ++ src/pages/rss.xml.ts | 59 ++++++-- 8 files changed, 415 insertions(+), 10 deletions(-) create mode 100644 openspec/changes/archive/2026-01-19-add-cover-image-display/design.md create mode 100644 openspec/changes/archive/2026-01-19-add-cover-image-display/proposal.md create mode 100644 openspec/changes/archive/2026-01-19-add-cover-image-display/specs/cover-image/spec.md create mode 100644 openspec/changes/archive/2026-01-19-add-cover-image-display/tasks.md diff --git a/openspec/changes/archive/2026-01-19-add-cover-image-display/design.md b/openspec/changes/archive/2026-01-19-add-cover-image-display/design.md new file mode 100644 index 00000000..f52ecdc5 --- /dev/null +++ b/openspec/changes/archive/2026-01-19-add-cover-image-display/design.md @@ -0,0 +1,132 @@ +# Design: Add Cover Image Display + +## Architecture Overview + +本变更涉及两个独立的 UI 组件修改,不引入新的架构模式。 + +``` +┌─────────────────────────────────────────────────────┐ +│ PostDetails.astro │ +│ ┌───────────────────────────────────────────────┐ │ +│ │ Cover Image (NEW) │ │ +│ │ - 条件渲染 (ogImageUrl 存在时) │ │ +│ │ - 响应式图片容器 │ │ +│ └───────────────────────────────────────────────┘ │ +│ ┌───────────────────────────────────────────────┐ │ +│ │ 导读区块 (existing) │ │ +│ └───────────────────────────────────────────────┘ │ +│ ┌───────────────────────────────────────────────┐ │ +│ │ 文章内容 (existing) │ │ +│ └───────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────┐ +│ Card.astro │ +│ ┌─────────┬────────────────────────────────────┐ │ +│ │ Thumb │ Title + Badge │ │ +│ │ (NEW) │ Datetime │ │ +│ │ │ Description │ │ +│ └─────────┴────────────────────────────────────┘ │ +│ ↑ 有 ogImage 时的布局 │ +│ │ +│ ┌───────────────────────────────────────────────┐ │ +│ │ Title + Badge │ │ +│ │ Datetime │ │ +│ │ Description │ │ +│ └───────────────────────────────────────────────┘ │ +│ ↑ 无 ogImage 时的布局(保持不变) │ +└─────────────────────────────────────────────────────┘ +``` + +## Data Flow + +### ogImage 数据类型 + +根据 `content.config.ts` 定义: +```typescript +ogImage: image().or(z.string()).optional() +``` + +可能的值: +1. `undefined` - 未配置 +2. `string` - 远程 URL(如 CDN 地址) +3. `ImageMetadata` - 本地图片 asset(有 `.src` 属性) + +### PostDetails.astro 中已有的解析逻辑 + +```typescript +let ogImageUrl: string | undefined; + +if (typeof initOgImage === "string") { + ogImageUrl = initOgImage; // 远程图片 +} else if (initOgImage?.src) { + ogImageUrl = initOgImage.src; // 本地 asset +} +``` + +直接复用此变量即可。 + +### Card.astro 需要新增解析 + +Card 组件当前未使用 ogImage,需要: +1. 从 `data` 中解构 `ogImage` +2. 添加类似的类型判断逻辑 + +## Styling Decisions + +### 文章详情页头图 + +``` +容器: +- 宽度:100% +- 最大高度:400px(桌面)/ 250px(移动) +- 圆角:rounded-lg (8px) +- 下边距:mb-6 + +图片: +- object-fit: cover +- width: 100% +- height: auto +- loading: lazy +``` + +### 文章列表缩略图 + +``` +容器: +- 尺寸:80px × 80px(移动)/ 120px × 80px(桌面) +- 圆角:rounded-md (6px) +- flex-shrink: 0 + +图片: +- object-fit: cover +- 100% 填充容器 +- loading: lazy +``` + +## Responsive Breakpoints + +| Breakpoint | 详情页头图高度 | 列表缩略图 | +|------------|---------------|-----------| +| < 640px (sm) | max-h-[250px] | 80×80px,图片在标题上方 | +| ≥ 640px | max-h-[400px] | 120×80px,图片在左侧 | + +## Trade-offs + +### 选项 A:列表页图片在左侧 +- ✅ 视觉层次清晰 +- ✅ 符合常见博客布局 +- ❌ 需要调整现有 flex 结构 + +### 选项 B:列表页图片在上方 +- ✅ 实现简单 +- ❌ 占用更多垂直空间 +- ❌ 有图/无图的卡片高度差异大 + +**决策**:采用选项 A,桌面端图片在左侧,移动端图片在上方。 + +## Backward Compatibility + +- 无 ogImage 的文章保持原有布局,无任何变化 +- 不修改 ogImage 的 schema 定义 +- 不影响 Open Graph 元数据生成 diff --git a/openspec/changes/archive/2026-01-19-add-cover-image-display/proposal.md b/openspec/changes/archive/2026-01-19-add-cover-image-display/proposal.md new file mode 100644 index 00000000..6cbd1ebc --- /dev/null +++ b/openspec/changes/archive/2026-01-19-add-cover-image-display/proposal.md @@ -0,0 +1,44 @@ +# Proposal: Add Cover Image Display + +## Change ID +`add-cover-image-display` + +## Summary +在文章页面和文章列表中显示配置的头图(ogImage)。当前 ogImage 仅用于社交媒体分享时的 Open Graph 元数据,本提案将其扩展为可选的视觉展示元素。 + +## Motivation +- 用户配置了 ogImage 后,期望在文章页面中能直接看到这张图片 +- 头图可以增强文章的视觉吸引力,提升阅读体验 +- 在文章列表中显示缩略图可以帮助读者快速识别感兴趣的内容 + +## Scope + +### In Scope +1. **文章详情页**:在"导读"区块上方显示头图 +2. **文章列表卡片**:在 description 左侧显示头图缩略图 + +### Out of Scope +- 修改 ogImage 的 schema 定义 +- 修改 Open Graph 元数据生成逻辑 +- 添加新的配置选项(如全局开关) + +## Affected Files +- `src/layouts/PostDetails.astro` - 文章详情页布局 +- `src/components/Card.astro` - 文章列表卡片组件 + +## Dependencies +- 无外部依赖 +- 复用现有的 ogImage 解析逻辑 + +## Risks & Mitigations +| Risk | Impact | Mitigation | +|------|--------|------------| +| 图片加载失败 | 页面布局错乱 | 使用 CSS object-fit 和固定容器尺寸 | +| 外部图片 URL 可能较慢 | 页面加载体验下降 | 使用 loading="lazy" 延迟加载 | +| 响应式布局适配 | 移动端显示异常 | 使用 Tailwind 响应式类,移动端调整布局 | + +## Success Criteria +- [ ] 配置了 ogImage 的文章在详情页"导读"上方显示头图 +- [ ] 配置了 ogImage 的文章在列表卡片中 description 左侧显示缩略图 +- [ ] 未配置 ogImage 的文章保持原有布局不变 +- [ ] 响应式布局在移动端和桌面端均正常显示 diff --git a/openspec/changes/archive/2026-01-19-add-cover-image-display/specs/cover-image/spec.md b/openspec/changes/archive/2026-01-19-add-cover-image-display/specs/cover-image/spec.md new file mode 100644 index 00000000..b57a9531 --- /dev/null +++ b/openspec/changes/archive/2026-01-19-add-cover-image-display/specs/cover-image/spec.md @@ -0,0 +1,78 @@ +# Spec: Cover Image Display + +## Overview +为配置了 ogImage 的文章提供页面内头图展示能力。 + +--- + +## ADDED Requirements + +### Requirement: Post Detail Cover Image +当文章配置了 ogImage 时,系统 SHALL 在文章详情页的"导读"区块上方显示头图。 + +- 图片 SHALL 显示在标题、日期之后,"导读"区块之前 +- 图片宽度 SHALL 为 100%,高度自适应,最大高度受限 +- 系统 SHALL 使用 lazy loading 优化加载性能 +- 图片加载期间 SHALL NOT 影响页面布局 + +#### Scenario: Article with ogImage displays cover +- **GIVEN** 一篇文章配置了 `ogImage: "https://example.com/cover.png"` +- **WHEN** 用户访问该文章详情页 +- **THEN** 应在"导读"区块上方看到该头图 +- **AND** 图片应为圆角样式,宽度撑满内容区域 + +#### Scenario: Article without ogImage shows no cover +- **GIVEN** 一篇文章未配置 ogImage +- **WHEN** 用户访问该文章详情页 +- **THEN** 页面布局与当前保持一致,无头图区域 + +--- + +### Requirement: Card Thumbnail Image +当文章配置了 ogImage 时,系统 SHALL 在文章列表卡片中显示缩略图。 + +- 缩略图 SHALL 显示在卡片左侧(桌面端)或上方(移动端) +- 系统 SHALL 使用固定尺寸容器,图片使用 object-cover 裁切 +- 系统 SHALL 使用 lazy loading 优化加载性能 +- 无 ogImage 的文章 SHALL 保持原有纯文字布局 + +#### Scenario: Article with ogImage shows thumbnail in list +- **GIVEN** 首页或文章列表页 +- **AND** 某篇文章配置了 ogImage +- **WHEN** 该文章卡片被渲染 +- **THEN** 应在卡片左侧(桌面端)显示缩略图 +- **AND** 缩略图为固定尺寸、圆角样式 + +#### Scenario: Article without ogImage shows text-only card +- **GIVEN** 首页或文章列表页 +- **AND** 某篇文章未配置 ogImage +- **WHEN** 该文章卡片被渲染 +- **THEN** 卡片布局保持原有样式,无缩略图区域 + +--- + +### Requirement: Responsive Layout +头图展示 SHALL 在不同屏幕尺寸下正常显示。 + +- 移动端(<640px):详情页头图最大高度 SHALL 为 250px +- 移动端(<640px):列表缩略图 SHALL 显示在标题上方 +- 桌面端(≥640px):详情页头图最大高度 SHALL 为 400px +- 桌面端(≥640px):列表缩略图 SHALL 显示在内容左侧 + +#### Scenario: Mobile view of article with cover +- **GIVEN** 用户使用移动设备(屏幕宽度 < 640px) +- **WHEN** 访问配置了 ogImage 的文章 +- **THEN** 详情页头图高度不超过 250px +- **AND** 列表页缩略图显示在标题上方 + +#### Scenario: Desktop view of article with cover +- **GIVEN** 用户使用桌面设备(屏幕宽度 ≥ 640px) +- **WHEN** 访问配置了 ogImage 的文章 +- **THEN** 详情页头图高度不超过 400px +- **AND** 列表页缩略图显示在内容左侧 + +--- + +## Related Capabilities +- Open Graph 元数据生成(不受影响) +- 动态 OG 图片生成(不受影响) diff --git a/openspec/changes/archive/2026-01-19-add-cover-image-display/tasks.md b/openspec/changes/archive/2026-01-19-add-cover-image-display/tasks.md new file mode 100644 index 00000000..97d45aed --- /dev/null +++ b/openspec/changes/archive/2026-01-19-add-cover-image-display/tasks.md @@ -0,0 +1,57 @@ +# Tasks: Add Cover Image Display + +## Task List + +### Phase 1: 文章详情页头图展示 + +- [x] **T1.1** 修改 `PostDetails.astro`,在标题上方添加头图展示区域 + - 条件渲染:仅当 `ogImageUrl` 存在时显示 + - 容器样式:圆角、阴影、浅色边框 + - 图片样式:object-cover、lazy loading + - hover 效果:阴影增强 + 轻微放大 + - 验证:本地预览确认有/无头图的文章显示正确 + +### Phase 2: 文章列表卡片头图展示 + +- [x] **T2.1** 修改 `Card.astro`,获取 ogImage 数据 + - 从 props 中解构 ogImage 字段 + - 处理 string 和 image asset 两种类型 + +- [x] **T2.2** 调整 Card 布局结构 + - 布局:标题行 → 日期行 → 头图+description 行 + - 有头图时:左侧图片 + 右侧内容(flex 布局) + - 无头图时:保持原有纯文字布局 + - 响应式:移动端图片在上,桌面端图片在左 + +- [x] **T2.3** 添加缩略图样式 + - 尺寸:96px × 160px + - object-cover 裁切 + - 圆角边框 + 浅色边框 + 阴影 + - hover 效果:放大 5% + - lazy loading + +### Phase 3: RSS 头图支持 + +- [x] **T3.1** 修改 `rss.xml.ts`,添加头图到 RSS feed + - 使用 `enclosure` 标签(RSS 2.0 标准) + - 自动识别图片 MIME type + +### Phase 4: 验证与测试 + +- [x] **T4.1** 本地预览测试 + - 有 ogImage 的文章详情页 + - 无 ogImage 的文章详情页 + - 首页文章列表 + - 文章归档页面 + +- [x] **T4.2** 响应式测试 + - 桌面端(>1024px) + - 移动端(<768px) + +## Dependencies +- T2.2 依赖 T2.1 +- T2.3 依赖 T2.2 +- T4.1 依赖 T1.1, T2.3, T3.1 + +## Parallelizable Work +- T1.1 和 T2.1 可并行开发 diff --git a/src/components/Card.astro b/src/components/Card.astro index 7924174f..35f0af99 100644 --- a/src/components/Card.astro +++ b/src/components/Card.astro @@ -24,7 +24,15 @@ const { section = "", } = Astro.props; -const { title, description, featured, ...props } = data; +const { title, description, featured, ogImage, ...props } = data; + +// 解析 ogImage URL +let ogImageUrl: string | undefined; +if (typeof ogImage === "string") { + ogImageUrl = ogImage; +} else if (ogImage?.src) { + ogImageUrl = ogImage.src; +} // 自动生成摘要:如果 description 为空,从文章内容提取 function getExcerpt(content: string, maxLength: number = 350): string { @@ -76,5 +84,34 @@ const excerpt = description || (body ? getExcerpt(body) : ""); } - {excerpt &&

{excerpt}

} + { + excerpt && ( +
+ {ogImageUrl && ( +
+ {title} +
+ )} +

+ {excerpt} +

+
+ ) + } diff --git a/src/data/blog/2026-01-17.extend-claude-code-with-slash-command.md b/src/data/blog/2026-01-17.extend-claude-code-with-slash-command.md index 92bff940..db4d5a7e 100644 --- a/src/data/blog/2026-01-17.extend-claude-code-with-slash-command.md +++ b/src/data/blog/2026-01-17.extend-claude-code-with-slash-command.md @@ -5,6 +5,7 @@ pubDatetime: 2026-01-17T00:00:00.000Z slug: extend-claude-code-with-slash-command featured: true draft: false +ogImage: https://cdn.xkcoding.com/blog/2026-01-19-cover.png?x-oss-process=style/tag_compress tags: - ai - claude-code diff --git a/src/layouts/PostDetails.astro b/src/layouts/PostDetails.astro index 335e485a..3e938d19 100644 --- a/src/layouts/PostDetails.astro +++ b/src/layouts/PostDetails.astro @@ -99,6 +99,19 @@ const nextPost = class:list={["app-layout pb-12", { "mt-8": !SITE.showBackButton }]} data-pagefind-body > + { + ogImageUrl && ( +
+ {title} +
+ ) + }

["data"]["ogImage"], + site: string +): string | undefined { + if (!ogImage) return undefined; + if (typeof ogImage === "string") { + // 远程 URL 直接返回,相对路径拼接 site + return ogImage.startsWith("http") ? ogImage : new URL(ogImage, site).href; + } + if (ogImage.src) { + // 本地 asset + return new URL(ogImage.src, site).href; + } + return undefined; +} + +// 根据 URL 获取图片 MIME type +function getImageMimeType(url: string): string { + const ext = url.split("?")[0].split(".").pop()?.toLowerCase(); + const mimeTypes: Record = { + jpg: "image/jpeg", + jpeg: "image/jpeg", + png: "image/png", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", + }; + return mimeTypes[ext || ""] || "image/jpeg"; +} + // 截断文本到指定长度 function truncate(text: string, maxLength: number): string { if (!text || text.length <= maxLength) return text; @@ -36,13 +68,24 @@ export async function GET() { title: SITE.title, description: SITE.desc, site: SITE.website, - items: sortedPosts.map(({ data, id, filePath, body }) => ({ - link: getPath(id, filePath), - title: data.title, - description: - truncate(data.description, 350) || getExcerpt(body || "", 350), - content: getExcerpt(body || "", 500), - pubDate: new Date(data.modDatetime ?? data.pubDatetime), - })), + items: sortedPosts.map(({ data, id, filePath, body }) => { + const ogImageUrl = getOgImageUrl(data.ogImage, SITE.website); + return { + link: getPath(id, filePath), + title: data.title, + description: + truncate(data.description, 350) || getExcerpt(body || "", 350), + content: getExcerpt(body || "", 500), + pubDate: new Date(data.modDatetime ?? data.pubDatetime), + // 添加头图作为 enclosure + ...(ogImageUrl && { + enclosure: { + url: ogImageUrl, + type: getImageMimeType(ogImageUrl), + length: 0, + }, + }), + }; + }), }); }