选择话题与全局 Filters)
论坛八选择话题与全局 Filters本文是「AtomCode 驱动全栈开发」系列的第 19 篇。我们将深入分析 PostMeta 组件的封装、Vue 全局过滤器的注册与使用以及话题筛选联动的完整实现。目录1. 引言2. 知识要点3. 完整代码3.1 PostMeta 组件3.2 全局 Filtersapp.js3.3 话题筛选PostList3.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→fetchPostsprops 传递父组件通过:prop-namevalue向子组件传数据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;constdatenewDate(this.createdAt);constnownewDate();constdiffMath.floor((now-date)/1000);if(diff60)return刚刚;if(diff3600)returnMath.floor(diff/60) 分钟前;if(diff86400)returnMath.floor(diff/3600) 小时前;if(diff2592000)returnMath.floor(diff/86400) 天前;returndate.toLocaleDateString(zh-CN);},},template:div classpost-meta span v-ifauthor {{ author }}/span span v-iftopicName {{ topicName }}/span span v-ifcreatedAt {{ timeAgo }}/span span v-ifcommentCount ! undefined {{ commentCount }} 评论/span /div,});3.2 全局 Filtersapp.js文件/root/projects/forum/static/js/app.js/** * 全局 Filters */Vue.filter(timeAgo,function(value){if(!value)return;constdatenewDate(value);constnownewDate();constdiffMath.floor((now-date)/1000);if(diff60)return刚刚;if(diff3600)returnMath.floor(diff/60) 分钟前;if(diff86400)returnMath.floor(diff/3600) 小时前;if(diff2592000)returnMath.floor(diff/86400) 天前;returndate.toLocaleDateString(zh-CN);});Vue.filter(truncate,function(value,length){if(!value)return;lengthlength||100;if(value.lengthlength)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 classtopics-bar v-iftopics.length button classtopic-chip :class{ active: !activeTopic } clickfilterByTopic(null) 全部/button button v-fort in topics :keyt.slug classtopic-chip :class{ active: activeTopic t.slug } clickfilterByTopic(t.slug) {{ t.name }}/button /div !-- ... -- /div,methods:{asyncfilterByTopic(slug){this.activeTopicslug;this.page1;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.jsVue.component(post-item,{props:{post:Object,},computed:{summary(){lettextthis.post.summary||this.post.body||;texttext.replace(/[#*_~\[\]()|\\-]/g,).replace(/\s/g, ).trim();if(text.length150)texttext.slice(0,150)...;returntext;},},template:div classpost-item div span classpost-topic{{ post.topic_name }}/span router-link :to/posts/ post.id classpost-title{{ post.title }}/router-link /div div classpost-summary{{ summary }}/div post-meta :authorpost.author :topic-namepost.topic_name :created-atpost.created_at :comment-countpost.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.topicstopics;},SET_TOPIC_FILTER(state,slug){state.topicFilterslug;},},getters:{topics:statestate.topics,topicFilter:statestate.topicFilter,},actions:{asyncfetchTopics({commit}){constresawaitaxios.get(/api/topics);commit(SET_TOPICS,res.data.topics);returnres.data.topics;},},4. 逐段/逐行讲解4.1 PostMeta 组件设计PostMeta 是一个纯展示组件接收 4 个 propsprops:{author:String,topicName:String,createdAt:String,commentCount:Number,},为什么用 props 而非直接读取 store组件更通用——可以在任何地方复用不依赖 store父组件可以灵活控制显示哪些字段便于测试条件渲染spanv-ifauthor {{ author }}/spanspanv-iftopicName {{ topicName }}/spanspanv-ifcreatedAt {{ timeAgo }}/spanspanv-ifcommentCount ! undefined {{ commentCount }} 评论/span每个字段都用了v-if当 props 未传递时对应的标签不渲染。注意commentCount用了! undefined而非v-ifcommentCount因为0评论时v-if会误判为 false。computed timeAgoPostMeta 内部自己实现了相对时间计算而不是用全局timeAgo过滤器。这是为了组件自包含——PostMeta 可以在任何地方使用无需依赖外部过滤器注册。4.2 timeAgo 过滤器实现Vue.filter(timeAgo,function(value){if(!value)return;constdatenewDate(value);constnownewDate();constdiffMath.floor((now-date)/1000);if(diff60)return刚刚;if(diff3600)returnMath.floor(diff/60) 分钟前;if(diff86400)returnMath.floor(diff/3600) 小时前;if(diff2592000)returnMath.floor(diff/86400) 天前;returndate.toLocaleDateString(zh-CN);});时间区间对照差值范围显示 60 秒刚刚 1 小时N 分钟前 1 天N 小时前 30 天N 天前≥ 30 天2024/1/15本地日期格式使用方式在模板中用管道语法span{{ post.created_at | timeAgo }}/span4.3 truncate 与 uppercase 过滤器truncate截断字符串到指定长度追加...Vue.filter(truncate,function(value,length){if(!value)return;lengthlength||100;if(value.lengthlength)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 文本转为 HTMLVue.filter(markdown,function(value){if(!value)return;try{returnmarked.parse(value);}catch(e){returnvalue;}});使用v-html配合过滤器divv-htmlpost.body | markdown/divstripMarkdown 过滤器去除 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?topictechpage1per_page10 │ ▼ 后端Topic.query.filter_by(slugtech) → 获取 topic_id → Post.query.filter_by(topic_idid) → 筛选帖子 │ ▼ 返回该话题的帖子列表active 高亮buttonclasstopic-chip:class{ active: activeTopic t.slug }clickfilterByTopic(t.slug){{ t.name }}/button当activeTopic t.slug时active类被添加chip 显示为蓝色背景。「全部」按钮buttonclasstopic-chip:class{ active: !activeTopic }clickfilterByTopic(null)全部/buttonactiveTopic为null时表示全部发起不带topic参数的请求。5. AtomCode 实战提示词生成 PostMeta 组件请生成 Vue 2 的 PostMeta 组件 1. 接收 4 个 propsauthor(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. timeAgoISO 时间 → 相对时间同 PostMeta 逻辑 2. truncate字符串截断支持长度参数超长追加 ... 3. uppercase字符串转大写 4. markdown使用 marked.parse 渲染 Markdown 为 HTMLtry-catch 保护 5. stripMarkdown正则去除 Markdown 标记符号保留纯文本6. 运行与验证启动服务cd/root/projects/forum python run.py测试话题筛选 API# 技术话题curlhttp://localhost:5000/api/posts?topictechpage1|python3-c import sys, json d json.load(sys.stdin) print(fTopic: tech, total: {d[\total\]}) for p in d[posts]: print(f [{p[\topic_name\]}] {p[\title\]}) # 生活分享话题curlhttp://localhost:5000/api/posts?topiclifepage1|python3-c import sys, json d json.load(sys.stdin) print(fTopic: 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);// h1Hello/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()实例化!-- 正确的加载顺序 --scriptsrccomponents/PostMeta.js/scriptscriptsrcapp.js/script!-- 注册过滤器 --scriptnewVue({...}).$mount(#app);/script7.2 话题筛选不生效现象点击话题 chipURL 参数没有变化列表也没有刷新。原因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-ifcommentCount在0时被当作 falsy 值。解决改为v-ifcommentCount ! undefined。8. 小结 下一篇预告模块核心实现PostMeta 组件4 个 props computed timeAgo 条件渲染全局过滤器timeAgo / truncate / uppercase / markdown / stripMarkdown话题筛选activeTopic状态 RESET_POSTSfetchPosts筛选高亮:class{ active: activeTopic t.slug }下一篇论坛九帖子评论 将分析 CommentItem 和 CommentBox 组件的实现、评论接口设计、以及未登录用户 401 拦截的完整处理。本文由 AtomCode 驱动生成代码基于真实项目/root/projects/forum。