论坛(八)选择话题与全局 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秒级差值 |
| 话题筛选联动 | activeTopic→SET_TOPIC_FILTER→fetchPosts |
| 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没有包含在PostItem的summary计算中——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>activeTopic为null时表示"全部",发起不带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"浏览器验证
- 打开首页 → 看到话题筛选栏(全部/技术讨论/生活分享/项目展示/问答求助)
- 默认选中「全部」
- 点击「技术讨论」→ 列表刷新,只显示技术话题的帖子
- 被选中的 chip 变为蓝色
- 每条帖子都显示作者、话题、时间、评论数
- 时间显示为"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。