论坛(八)选择话题与全局 Filters

论坛(八)选择话题与全局 Filters

论坛(八)选择话题与全局 Filters

本文是「AtomCode 驱动全栈开发」系列的第 19 篇。我们将深入分析 PostMeta 组件的封装、Vue 全局过滤器的注册与使用,以及话题筛选联动的完整实现。


目录

  • 1. 引言
  • 2. 知识要点
  • 3. 完整代码
    • 3.1 PostMeta 组件
    • 3.2 全局 Filters(app.js)
    • 3.3 话题筛选(PostList)
    • 3.4 PostItem 中的 filters 使用
    • 3.5 Store 中的话题筛选状态
  • 4. 逐段/逐行讲解
    • 4.1 PostMeta 组件设计
    • 4.2 timeAgo 过滤器实现
    • 4.3 truncate 与 uppercase 过滤器
    • 4.4 markdown 与 stripMarkdown 过滤器
    • 4.5 话题筛选联动
  • 5. AtomCode 实战
  • 6. 运行与验证
  • 7. 常见错误与排查
  • 8. 小结 + 下一篇预告

1. 引言

在论坛系统中,有大量重复的 UI 模式:

  • 每条帖子都显示作者、话题、时间、评论数
  • 时间需要格式化为"3 分钟前"、"2 天前"等相对时间
  • 帖子摘要要去除 Markdown 符号
  • 用户可以从话题列表中选择一个话题来筛选帖子

这些横切关注点(cross-cutting concerns)可以通过Vue 全局过滤器(Filters)可复用组件来优雅地解决。


2. 知识要点

知识点说明
Vue.filter()全局注册过滤器,可在模板中用 `
Vue.component()全局注册组件
相对时间计算(now - date) / 1000秒级差值
话题筛选联动activeTopicSET_TOPIC_FILTERfetchPosts
props 传递父组件通过:prop-name="value"向子组件传数据
computed 属性基于 props 派生显示数据

3. 完整代码

3.1 PostMeta 组件

文件:/root/projects/forum/static/js/components/PostMeta.js

/** * PostMeta — 帖子的元信息(作者、话题、时间、评论数) */Vue.component('post-meta',{props:{author:String,topicName:String,createdAt:String,commentCount:Number,},computed:{timeAgo(){if(!this.createdAt)return'';constdate=newDate(this.createdAt);constnow=newDate();constdiff=Math.floor((now-date)/1000);if(diff<60)return'刚刚';if(diff<3600)returnMath.floor(diff/60)+' 分钟前';if(diff<86400)returnMath.floor(diff/3600)+' 小时前';if(diff<2592000)returnMath.floor(diff/86400)+' 天前';returndate.toLocaleDateString('zh-CN');},},template:`<div class="post-meta"> <span v-if="author">👤 {{ author }}</span> <span v-if="topicName">📂 {{ topicName }}</span> <span v-if="createdAt">🕐 {{ timeAgo }}</span> <span v-if="commentCount !== undefined">💬 {{ commentCount }} 评论</span> </div>`,});

3.2 全局 Filters(app.js)

文件:/root/projects/forum/static/js/app.js

/** * 全局 Filters */Vue.filter('timeAgo',function(value){if(!value)return'';constdate=newDate(value);constnow=newDate();constdiff=Math.floor((now-date)/1000);if(diff<60)return'刚刚';if(diff<3600)returnMath.floor(diff/60)+' 分钟前';if(diff<86400)returnMath.floor(diff/3600)+' 小时前';if(diff<2592000)returnMath.floor(diff/86400)+' 天前';returndate.toLocaleDateString('zh-CN');});Vue.filter('truncate',function(value,length){if(!value)return'';length=length||100;if(value.length<=length)returnvalue;returnvalue.slice(0,length)+'...';});Vue.filter('uppercase',function(value){if(!value)return'';returnvalue.toUpperCase();});Vue.filter('markdown',function(value){if(!value)return'';try{returnmarked.parse(value);}catch(e){returnvalue;}});Vue.filter('stripMarkdown',function(value){if(!value)return'';returnvalue.replace(/[#*_`~\[\]()>|\\-]/g,'').replace(/\s+/g,' ').trim();});

3.3 话题筛选(PostList)

文件:/root/projects/forum/static/js/components/PostList.js(节选)

template:`<div> <!-- 话题筛选栏 --> <div class="topics-bar" v-if="topics.length"> <button class="topic-chip" :class="{ active: !activeTopic }" @click="filterByTopic(null)" >全部</button> <button v-for="t in topics" :key="t.slug" class="topic-chip" :class="{ active: activeTopic === t.slug }" @click="filterByTopic(t.slug)" >{{ t.name }}</button> </div> <!-- ... --> </div>`,methods:{asyncfilterByTopic(slug){this.activeTopic=slug;this.page=1;this.$store.commit('RESET_POSTS');this.$store.commit('SET_TOPIC_FILTER',slug);awaitthis.$store.dispatch('fetchPosts',{topic:slug,page:1,append:false});},},

3.4 PostItem 中的 filters 使用

文件:/root/projects/forum/static/js/components/PostItem.js

Vue.component('post-item',{props:{post:Object,},computed:{summary(){lettext=this.post.summary||this.post.body||'';text=text.replace(/[#*_`~\[\]()>|\\-]/g,'').replace(/\s+/g,' ').trim();if(text.length>150)text=text.slice(0,150)+'...';returntext;},},template:`<div class="post-item"> <div> <span class="post-topic">{{ post.topic_name }}</span> <router-link :to="'/posts/' + post.id" class="post-title">{{ post.title }}</router-link> </div> <div class="post-summary">{{ summary }}</div> <post-meta :author="post.author" :topic-name="post.topic_name" :created-at="post.created_at" :comment-count="post.comment_count" ></post-meta> </div>`,});

3.5 Store 中的话题筛选状态

文件:/root/projects/forum/static/js/store.js(节选)

state:{topics:[],topicFilter:null,},mutations:{SET_TOPICS(state,topics){state.topics=topics;},SET_TOPIC_FILTER(state,slug){state.topicFilter=slug;},},getters:{topics:state=>state.topics,topicFilter:state=>state.topicFilter,},actions:{asyncfetchTopics({commit}){constres=awaitaxios.get('/api/topics');commit('SET_TOPICS',res.data.topics);returnres.data.topics;},},

4. 逐段/逐行讲解

4.1 PostMeta 组件设计

PostMeta 是一个纯展示组件,接收 4 个 props:

props:{author:String,topicName:String,createdAt:String,commentCount:Number,},

为什么用 props 而非直接读取 store?

  • 组件更通用——可以在任何地方复用,不依赖 store
  • 父组件可以灵活控制显示哪些字段
  • 便于测试

条件渲染:

<spanv-if="author">👤 {{ author }}</span><spanv-if="topicName">📂 {{ topicName }}</span><spanv-if="createdAt">🕐 {{ timeAgo }}</span><spanv-if="commentCount !== undefined">💬 {{ commentCount }} 评论</span>

每个字段都用了v-if,当 props 未传递时对应的标签不渲染。注意commentCount用了!== undefined而非v-if="commentCount",因为0评论时v-if会误判为 false。

computed timeAgo:

PostMeta 内部自己实现了相对时间计算,而不是用全局timeAgo过滤器。这是为了组件自包含——PostMeta 可以在任何地方使用,无需依赖外部过滤器注册。

4.2 timeAgo 过滤器实现

Vue.filter('timeAgo',function(value){if(!value)return'';constdate=newDate(value);constnow=newDate();constdiff=Math.floor((now-date)/1000);if(diff<60)return'刚刚';if(diff<3600)returnMath.floor(diff/60)+' 分钟前';if(diff<86400)returnMath.floor(diff/3600)+' 小时前';if(diff<2592000)returnMath.floor(diff/86400)+' 天前';returndate.toLocaleDateString('zh-CN');});

时间区间对照:

差值范围显示
< 60 秒刚刚
< 1 小时N 分钟前
< 1 天N 小时前
< 30 天N 天前
≥ 30 天2024/1/15(本地日期格式)

使用方式:在模板中用管道语法:

<span>{{ post.created_at | timeAgo }}</span>

4.3 truncate 与 uppercase 过滤器

truncate:截断字符串到指定长度,追加...

Vue.filter('truncate',function(value,length){if(!value)return'';length=length||100;if(value.length<=length)returnvalue;returnvalue.slice(0,length)+'...';});

支持参数传递:{{ text | truncate(50) }}截断到 50 字符。

uppercase:将字符串转为大写

Vue.filter('uppercase',function(value){if(!value)return'';returnvalue.toUpperCase();});

4.4 markdown 与 stripMarkdown 过滤器

markdown 过滤器:使用marked.parse()将 Markdown 文本转为 HTML

Vue.filter('markdown',function(value){if(!value)return'';try{returnmarked.parse(value);}catch(e){returnvalue;}});

使用v-html配合过滤器:

<divv-html="post.body | markdown"></div>

stripMarkdown 过滤器:去除 Markdown 标记符号,获取纯文本

Vue.filter('stripMarkdown',function(value){if(!value)return'';returnvalue.replace(/[#*_`~\[\]()>|\\-]/g,'').replace(/\s+/g,' ').trim();});

正则说明:

模式匹配内容
#标题标记
*粗体/斜体/列表
_粗体/斜体
`行内代码
~删除线
[...]链接/图片语法
()链接/图片 URL
<>|\\-引用/表格/分隔线

注意:stripMarkdown没有包含在PostItemsummary计算中——PostItem单独实现了类似逻辑。可以通过重构统一使用全局过滤器。

4.5 话题筛选联动

话题筛选的完整流程:

用户点击「技术讨论」chip │ ▼ filterByTopic('tech') ① this.activeTopic = 'tech' → 高亮当前 chip ② this.page = 1 → 重置页码 ③ RESET_POSTS → 清空帖子列表 ④ SET_TOPIC_FILTER('tech') → 更新 store 中的筛选状态 ⑤ fetchPosts({ topic: 'tech', page: 1, append: false }) │ ▼ GET /api/posts?topic=tech&page=1&per_page=10 │ ▼ 后端:Topic.query.filter_by(slug='tech') → 获取 topic_id → Post.query.filter_by(topic_id=id) → 筛选帖子 │ ▼ 返回该话题的帖子列表

active 高亮:

<buttonclass="topic-chip":class="{ active: activeTopic === t.slug }"@click="filterByTopic(t.slug)">{{ t.name }}</button>

activeTopic === t.slug时,active类被添加,chip 显示为蓝色背景。

「全部」按钮:

<buttonclass="topic-chip":class="{ active: !activeTopic }"@click="filterByTopic(null)">全部</button>

activeTopicnull时表示"全部",发起不带topic参数的请求。


5. AtomCode 实战

提示词:生成 PostMeta 组件

请生成 Vue 2 的 PostMeta 组件: 1. 接收 4 个 props:author(String), topicName(String), createdAt(String), commentCount(Number) 2. computed timeAgo:将 ISO 时间转为相对时间(刚刚/N分钟前/N小时前/N天前/日期) 3. 模板中条件渲染每个字段,带 emoji 图标 4. 使用 Vue.component 全局注册 5. commentCount 为 0 时也要显示

提示词:注册全局过滤器

请注册以下 Vue 2 全局过滤器: 1. timeAgo:ISO 时间 → 相对时间(同 PostMeta 逻辑) 2. truncate:字符串截断,支持长度参数,超长追加 ... 3. uppercase:字符串转大写 4. markdown:使用 marked.parse 渲染 Markdown 为 HTML,try-catch 保护 5. stripMarkdown:正则去除 Markdown 标记符号,保留纯文本

6. 运行与验证

启动服务

cd/root/projects/forum python run.py

测试话题筛选 API

# 技术话题curl"http://localhost:5000/api/posts?topic=tech&page=1"|python3-c" import sys, json d = json.load(sys.stdin) print(f'Topic: tech, total: {d[\"total\"]}') for p in d['posts']: print(f' [{p[\"topic_name\"]}] {p[\"title\"]}') "# 生活分享话题curl"http://localhost:5000/api/posts?topic=life&page=1"|python3-c" import sys, json d = json.load(sys.stdin) print(f'Topic: life, total: {d[\"total\"]}') for p in d['posts']: print(f' [{p[\"topic_name\"]}] {p[\"title\"]}') "

验证过滤器

在浏览器控制台中测试:

// timeAgoVue.filter('timeAgo')('2026-07-10T12:00:00');// "1 天前"Vue.filter('timeAgo')('2026-07-11T00:00:00');// "刚刚"// truncateVue.filter('truncate')('这是一段很长的文本',5);// "这是一段..."// markdownVue.filter('markdown')('# Hello');// "<h1>Hello</h1>"// stripMarkdownVue.filter('stripMarkdown')('# Hello **World**');// "Hello World"

浏览器验证

  1. 打开首页 → 看到话题筛选栏(全部/技术讨论/生活分享/项目展示/问答求助)
  2. 默认选中「全部」
  3. 点击「技术讨论」→ 列表刷新,只显示技术话题的帖子
  4. 被选中的 chip 变为蓝色
  5. 每条帖子都显示作者、话题、时间、评论数
  6. 时间显示为"3 分钟前"、"2 天前"等相对时间

7. 常见错误与排查

7.1 过滤器未定义

现象:浏览器控制台报错[Vue warn]: Failed to resolve filter: timeAgo

原因:过滤器注册在app.js中,但组件在app.js之前加载,导致使用时过滤器尚未注册。

解决:确保app.js在组件之后加载,但要先于new Vue()实例化:

<!-- 正确的加载顺序 --><scriptsrc="components/PostMeta.js"></script><scriptsrc="app.js"></script><!-- 注册过滤器 --><script>newVue({...}).$mount('#app');</script>

7.2 话题筛选不生效

现象:点击话题 chip,URL 参数没有变化,列表也没有刷新。

原因:filterByTopic方法可能没有正确绑定到@click事件。

排查:filterByTopic方法中添加console.log('filtering:', slug),检查是否被调用。

7.3 切换话题后列表为空

现象:点击某个话题 chip,列表显示"暂无帖子"。

原因:后端返回了空列表,可能是因为该话题 slug 不正确。

排查:

# 检查数据库中的话题数据cd/root/projects/forum python3-c" from app import create_app from app.models import Topic app = create_app() with app.app_context(): for t in Topic.query.all(): print(t.id, t.name, t.slug) "

7.4 评论数显示为 0 但不显示

现象:帖子有 0 条评论,但💬 0 评论没有显示。

原因:v-if="commentCount"0时被当作 falsy 值。

解决:改为v-if="commentCount !== undefined"


8. 小结 + 下一篇预告

模块核心实现
PostMeta 组件4 个 props + computed timeAgo + 条件渲染
全局过滤器timeAgo / truncate / uppercase / markdown / stripMarkdown
话题筛选activeTopic状态 +RESET_POSTS+fetchPosts
筛选高亮:class="{ active: activeTopic === t.slug }"

下一篇:论坛(九)帖子评论 将分析 CommentItem 和 CommentBox 组件的实现、评论接口设计、以及未登录用户 401 拦截的完整处理。


本文由 AtomCode 驱动生成,代码基于真实项目/root/projects/forum