2599 字
13 分钟
为影视墙添加排序和分类功能

前言#

在之前的文章中,我为博客添加了影视墙功能。经过一段时间的使用,我发现了一些需要改进的地方:

  1. 排序问题:影视作品按添加时间排序,但我想手动控制某些作品的展示顺序
  2. 分类需求:电影、番剧、电视剧混在一起,希望能够按类型筛选查看
  3. 用户体验:目前的列表页只支持全量展示,缺乏浏览效率

因此,我决定为影视墙添加以下功能:

  • 自定义排序:通过 order 字段手动控制展示顺序
  • 分类筛选:支持按”电影/番剧/电视剧”三类筛选
  • 简洁交互:顶部标签式筛选栏,点击即可切换

同样,这次的开发工作由 Claude Code 完成。本文将记录整个开发过程,重点关注技术难点和解决方案。

需求分析#

功能需求#

排序功能

  • 新增 order 字段(数字越小越靠前)
  • 支持 1-999 的排序值
  • 未设置的作品默认排在最后
  • 相同 order 时按日期排序

分类功能

  • 三个固定分类:电影、番剧、电视剧
  • 顶部标签栏显示每个分类的数量
  • 点击标签即时筛选,无需刷新页面
  • 保持简洁的卡片设计(只显示封面和标题)

技术挑战#

  1. Schema 重构:需要修改现有字段,保证向后兼容
  2. 筛选实现:Astro 服务端渲染的特点导致 URL 参数获取困难
  3. 客户端导航:需要处理 Astro 客户端路由时的脚本重执行
  4. 数据传递:如何在 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-15
image: '/movies/Before Sunrise.jpg'
category: '电影' # 新增
order: 1 # 新增
---

Before Sunset.md

---
title: 'Before Sunset'
published: 2026-02-15
image: '/movies/Before Sunset.jpg'
category: '电影'
order: 2
---

Before Midnight.md

---
title: 'Before Midnight'
published: 2026-02-15
image: '/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 列网格

技术总结#

关键技术点#

  1. Schema 设计:使用 Zod 枚举确保数据一致性
  2. 两级排序:order 主排序 + published 次排序
  3. 客户端筛选:绕过 Astro URL 参数限制
  4. 事件监听:处理 Astro 客户端导航
  5. 数据属性:使用 data-* 属性关联 DOM 和逻辑

性能优化#

  • 服务端渲染所有数据,确保 SEO
  • 客户端筛选无需页面刷新,体验流畅
  • 图片懒加载loading="lazy" 优化首屏加载

向后兼容#

  • 所有字段都是可选的
  • 默认值确保现有内容不报错
  • 保留所有旧字段,方便迁移

开发体验#

整个开发过程由 Claude Code 完成,遇到了多个技术难题:

  1. Astro URL 参数问题 → 改用客户端筛选
  2. 字符串模板渲染 → 使用 Astro 表达式语法
  3. 选择器冲突 → 分离按钮和卡片的 data 属性
  4. 客户端导航 → 监听 astro:page-load 事件
  5. 属性未渲染 → 在组件根元素显式添加

每个问题都在几分钟内被定位和解决,展现了 AI 辅助编程的问题解决能力。

未来改进#

目前的基础功能已经完善,未来可以考虑:

  1. 高级筛选:支持多条件组合筛选(分类+年份+评分)
  2. 排序方式:添加按评分、年份等排序选项
  3. 搜索功能:支持搜索电影名称、导演
  4. 详情页面:点击卡片查看详细信息
  5. 批量编辑:可视化调整 order 值

参考资源#

相关文章#

为影视墙添加排序和分类功能
https://fuwari.vercel.app/posts/movie-wall-sorting-and-filtering/
作者
Croco
发布于
2026-02-18
许可协议
CC BY-NC-SA 4.0