前言
在之前的文章中,我为博客添加了影视墙功能。经过一段时间的使用,我发现了一些需要改进的地方:
- 排序问题:影视作品按添加时间排序,但我想手动控制某些作品的展示顺序
- 分类需求:电影、番剧、电视剧混在一起,希望能够按类型筛选查看
- 用户体验:目前的列表页只支持全量展示,缺乏浏览效率
因此,我决定为影视墙添加以下功能:
- 自定义排序:通过
order字段手动控制展示顺序 - 分类筛选:支持按”电影/番剧/电视剧”三类筛选
- 简洁交互:顶部标签式筛选栏,点击即可切换
同样,这次的开发工作由 Claude Code 完成。本文将记录整个开发过程,重点关注技术难点和解决方案。
需求分析
功能需求
排序功能:
- 新增
order字段(数字越小越靠前) - 支持 1-999 的排序值
- 未设置的作品默认排在最后
- 相同 order 时按日期排序
分类功能:
- 三个固定分类:电影、番剧、电视剧
- 顶部标签栏显示每个分类的数量
- 点击标签即时筛选,无需刷新页面
- 保持简洁的卡片设计(只显示封面和标题)
技术挑战
- Schema 重构:需要修改现有字段,保证向后兼容
- 筛选实现:Astro 服务端渲染的特点导致 URL 参数获取困难
- 客户端导航:需要处理 Astro 客户端路由时的脚本重执行
- 数据传递:如何在 Astro 组件中正确传递筛选数据
技术架构
基于现有 Fuwari 模板:
- Astro 5.x:静态站点生成器(SSR)
- JavaScript:客户端筛选逻辑
- Zod:Schema 验证和类型安全
实现步骤
1. Schema 重构
将原有的 genre 字段(字符串数组)改为 category 字段(枚举),并新增 order 字段:
修改前:
const moviesCollection = defineCollection({ schema: z.object({ title: z.string(), published: z.date(), draft: z.boolean().optional().default(false), description: z.string().optional().default(""), rating: z.number().min(0).max(10).optional(), image: z.string().optional().default(""), year: z.number().optional(), director: z.string().optional(), genre: z.array(z.string()).optional().default([]), // 旧字段 }),});修改后:
const moviesCollection = defineCollection({ schema: z.object({ title: z.string(), published: z.date(), draft: z.boolean().optional().default(false), description: z.string().optional().default(""), rating: z.number().min(0).max(10).optional(), image: z.string().optional().default(""), year: z.number().optional(), director: z.string().optional(), // 修改:从数组改为枚举 category: z.enum(['电影', '番剧', '电视剧']).optional().default('电影'), // 新增:手动排序字段 order: z.number().optional().default(999), }),});设计考虑:
- 使用
enum确保分类值的一致性,避免拼写错误 - 默认值设为
'电影',降低使用门槛 order默认值 999 确保新内容排在最后- 保留所有旧字段,确保向后兼容
2. 工具函数升级
更新 movie-utils.ts,支持排序和筛选:
import { type CollectionEntry, getCollection } from "astro:content";
export type MovieForList = { slug: string; data: CollectionEntry<"movies">["data"];};
// 两级排序:order 主排序,published 次排序async function getRawSortedMovies() { const allMovies = await getCollection("movies", ({ data }) => { return import.meta.env.PROD ? data.draft !== true : true; });
return allMovies.sort((a, b) => { // 主排序:order 字段(越小越靠前) const orderA = a.data.order ?? 999; const orderB = b.data.order ?? 999;
if (orderA !== orderB) { return orderA - orderB; }
// 次排序:发布日期(新→旧) const dateA = new Date(a.data.published); const dateB = new Date(b.data.published); return dateA > dateB ? -1 : 1; });}
// 支持按分类筛选export async function getSortedMoviesList(category?: string): Promise<MovieForList[]> { const sortedFullMovies = await getRawSortedMovies();
// 如果指定分类,进行筛选 const filteredMovies = category ? sortedFullMovies.filter(movie => movie.data.category === category) : sortedFullMovies;
return filteredMovies.map((movie) => ({ slug: movie.slug, data: movie.data, }));}
// 获取分类统计export type MovieCategory = { name: string; count: number;};
export async function getMovieCategoryList(): Promise<MovieCategory[]> { const allMovies = await getCollection("movies", ({ data }) => { return import.meta.env.PROD ? data.draft !== true : true; });
const countMap: { [key: string]: number } = {}; const validCategories = ['电影', '番剧', '电视剧'];
validCategories.forEach(cat => countMap[cat] = 0);
allMovies.forEach((movie) => { const category = movie.data.category || '电影'; if (countMap[category] !== undefined) { countMap[category]++; } });
return validCategories.map(cat => ({ name: cat, count: countMap[cat] || 0, }));}关键改进:
- 两级排序逻辑,确保灵活性和确定性
getSortedMoviesList支持可选的分类筛选参数- 新增
getMovieCategoryList提供分类统计数据
3. 客户端筛选实现
由于 Astro 在服务端渲染时无法正确获取 URL 参数(这是 Astro 的已知限制),我采用了客户端 JavaScript 筛选方案:
---// 服务端获取所有数据const allMoviesList = await getSortedMoviesList();const categoryList = await getMovieCategoryList();---
<MainGridLayout title="影视墙"> <!-- 分类筛选栏 --> <div class="card-base px-6 py-4"> <div class="flex flex-wrap items-center gap-3"> {categoryList.map(cat => ( <button data-category={cat.name} class="px-4 py-2 rounded-lg transition text-sm font-bold cursor-pointer bg-[var(--btn-regular-bg)] text-[var(--btn-content)] hover:opacity-90" > {cat.name}<span class="ml-1.5 opacity-75">({cat.count})</span> </button> ))} </div> </div>
<!-- 电影卡片网格 --> <div id="movie-grid-container" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6 px-2"> {allMoviesList.map((movie) => ( <MovieCard {...movie.data} slug={movie.slug} category={movie.data.category} /> ))} </div>
<!-- 客户端筛选脚本 --> <script> function initMovieFilter() { const buttons = document.querySelectorAll('button[data-category]'); const movieCards = document.querySelectorAll('[data-movie-category]');
if (buttons.length === 0 || movieCards.length === 0) { setTimeout(initMovieFilter, 100); return; }
buttons.forEach(btn => { const cat = btn.dataset.category; if (cat === '电影') { btn.style.backgroundColor = 'var(--primary)'; btn.style.color = 'white'; }
btn.addEventListener('click', () => { const selectedCategory = btn.dataset.category;
// 更新按钮样式 buttons.forEach(b => { b.style.backgroundColor = 'var(--btn-regular-bg)'; b.style.color = 'var(--btn-content)'; }); btn.style.backgroundColor = 'var(--primary)'; btn.style.color = 'white';
// 筛选电影 movieCards.forEach(card => { if (card.dataset.movieCategory === selectedCategory) { card.style.display = ''; } else { card.style.display = 'none'; } }); }); }); }
// 页面加载时初始化 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initMovieFilter); } else { initMovieFilter(); }
// Astro 客户端导航时重新初始化 document.addEventListener('astro:page-load', initMovieFilter); </script></MainGridLayout>技术要点:
- 服务端渲染所有数据,确保 SEO 和首屏性能
- 客户端 JavaScript 处理筛选逻辑,无需页面刷新
- 重试机制:如果 DOM 元素未就绪,100ms 后重试
- 事件监听:监听
astro:page-load事件,处理客户端导航
4. MovieCard 组件更新
将 data-movie-category 属性直接添加到组件的最外层元素:
---interface Props { // ... 其他 props category?: string; // ...}
const { category, ... } = Astro.props;---
<div class:list={["card-base flex flex-col w-full rounded-[var(--radius-large)] overflow-hidden relative", className]} style={style} data-movie-category={category}> <!-- 封面图和标题 --></div>关键点:必须将 data-movie-category 属性添加到组件根元素,否则客户端 JavaScript 无法通过选择器选中。
5. 数据迁移
更新现有的电影文件,添加新字段:
Before Sunrise.md:
---title: 'Before Sunrise'published: 2026-02-15image: '/movies/Before Sunrise.jpg'category: '电影' # 新增order: 1 # 新增---Before Sunset.md:
---title: 'Before Sunset'published: 2026-02-15image: '/movies/Before Sunset.jpg'category: '电影'order: 2---Before Midnight.md:
---title: 'Before Midnight'published: 2026-02-15image: '/movies/Before Midnight.jpg'category: '电视剧' # 测试分类功能order: 3---遇到的问题与解决方案
问题 1:Astro 无法获取 URL 查询参数
现象:
const url = Astro.url;const category = url.searchParams.get("category"); // 始终为 null即使访问 /movies/?category=番剧,服务端获取到的 URL 却是 /movies/(无参数)。
原因:这是 Astro 在开发环境中的已知问题,服务端渲染时无法正确获取 URL 查询参数。
解决方案:
- 改用客户端 JavaScript 筛选
- 服务端渲染所有数据
- 客户端根据用户点击即时筛选
问题 2:Astro 中字符串模板不能渲染组件
错误代码:
{['电影', '番剧', '电视剧'].map(cat => ` <button data-category="${cat}"> ${cat} </button>`).join('')}现象:页面中看不到按钮元素。
原因:在 Astro 中,.map() 返回的字符串模板不会被渲染为 HTML 元素,而是作为纯文本输出。
解决方案:
{categoryList.map(cat => ( <button data-category={cat.name}> {cat.name} </button>))}使用 Astro 的表达式语法,返回 JSX/ASTro 元素而不是字符串。
问题 3:客户端 JavaScript 选择器选中了错误元素
错误代码:
const movieCards = document.querySelectorAll('[data-category]');现象:点击分类按钮后,按钮本身也被隐藏了。
原因:[data-category] 同时选中了按钮和电影卡片,因为两者都有这个属性。
解决方案:
- 按钮使用
data-category - 电影卡片使用
data-movie-category - 选择器改为
document.querySelectorAll('[data-movie-category]')
问题 4:客户端导航时脚本不执行
现象:从其他页面点击导航链接进入影视墙,筛选功能不工作。
原因:Astro 使用客户端路由(View Transitions)时,不会重新执行页面的 <script> 标签。
解决方案:
// 监听 Astro 的页面导航事件document.addEventListener('astro:page-load', initMovieFilter);
// 包装初始化函数,支持重复调用function initMovieFilter() { // 避免重复绑定事件监听器 const buttons = document.querySelectorAll('button[data-category]'); // ...}问题 5:MovieCard 组件属性未渲染到 HTML
现象:
document.querySelectorAll('[data-movie-category]'); // 返回空数组原因:在 movies.astro 中传递 data-movie-category 属性给 MovieCard 组件,但组件没有将这个属性添加到最终的 HTML 元素上。
解决方案:在 MovieCard 组件的根元素上显式添加属性:
<div data-movie-category={category}> <!-- 内容 --></div>最终效果
自定义排序
---title: '肖申克的救赎'category: '电影'order: 1 # 优先显示---
title: '盗梦空间'category: '电影'order: 2 # 第二显示---
title: '新电影'category: '电影'# order 未设置,默认 999,排在最后---分类筛选
- 点击”电影”:只显示电影分类的作品
- 点击”番剧”:只显示番剧分类的作品
- 点击”电视剧”:只显示电视剧分类的作品
- 当前选中的分类高亮显示
响应式设计
- 移动端:2 列网格
- 平板:3 列网格
- 桌面:4 列网格
- 大屏:5 列网格
技术总结
关键技术点
- Schema 设计:使用 Zod 枚举确保数据一致性
- 两级排序:order 主排序 + published 次排序
- 客户端筛选:绕过 Astro URL 参数限制
- 事件监听:处理 Astro 客户端导航
- 数据属性:使用
data-*属性关联 DOM 和逻辑
性能优化
- 服务端渲染所有数据,确保 SEO
- 客户端筛选无需页面刷新,体验流畅
- 图片懒加载:
loading="lazy"优化首屏加载
向后兼容
- 所有字段都是可选的
- 默认值确保现有内容不报错
- 保留所有旧字段,方便迁移
开发体验
整个开发过程由 Claude Code 完成,遇到了多个技术难题:
- Astro URL 参数问题 → 改用客户端筛选
- 字符串模板渲染 → 使用 Astro 表达式语法
- 选择器冲突 → 分离按钮和卡片的 data 属性
- 客户端导航 → 监听
astro:page-load事件 - 属性未渲染 → 在组件根元素显式添加
每个问题都在几分钟内被定位和解决,展现了 AI 辅助编程的问题解决能力。
未来改进
目前的基础功能已经完善,未来可以考虑:
- 高级筛选:支持多条件组合筛选(分类+年份+评分)
- 排序方式:添加按评分、年份等排序选项
- 搜索功能:支持搜索电影名称、导演
- 详情页面:点击卡片查看详细信息
- 批量编辑:可视化调整 order 值