如果你也经历过:
- 预算定了,但不知道该选轻一点还是头重一点
- 看参数看懵:重量、平衡点、硬度到底怎么影响手感
- 想要更适合自己打法的球拍清单,还想看看球友怎么说
我把这些需求做成了一个「羽毛球拍推荐系统」,把选拍流程变得更可视化、更好用,也更适合日常自用、项目展示。
1. 项目简介
这是一个面向羽毛球爱好者的 Web 系统,核心目标是把“选拍”从主观经验变成可输入、可对比、可解释的流程:
- 通过推荐算法根据预算/重量/平衡点/硬度/技术水平匹配球拍;
- 通过球拍库+筛选分页快速缩小选择范围;
- 通过评价体系形成球友口碑;
- 通过公告支持系统更新通知;
- 通过论坛问答支持球友提问与答疑,增强内容与互动;
- 通过个人中心管理账号信息、修改密码、查看我的评价。
2. 功能清单
2.1 前台功能
- 首页:入口聚合、公告预览、快速开始
- 羽毛球拍列表:筛选 + 分页
- 羽毛球拍详情:参数展示 + 用户评价列表 +(普通用户)发布评价
- 推荐系统:输入偏好 → 返回 Top 推荐(含匹配度)
- 系统公告:公告列表展示
- 球友论坛:提问列表、问题详情、发布提问、发布回答
- 个人中心:账号信息、修改密码、我的评价(普通用户)
2.2 管理员功能
- 管理员登录后通过 Token 调用受保护接口
- 羽毛球拍增删改
- 公告发布/编辑/删除(置顶排序)
2.3 系统功能展示
首页
羽毛球球拍列表
球拍推荐
论坛
系统公告
注册
个人中心
3. 技术栈
3.1 前端
- Vue 3 + Vite
- Tailwind CSS(统一的绿/蓝渐变主题、卡片式布局)
- Pinia(状态管理:用户信息、球拍数据等)
- Vue Router(路由与登录拦截)
- Axios(请求后端 API)
3.2 后端
- Flask + Flask-CORS
- Flask-SQLAlchemy(ORM)
- MySQL(通过 PyMySQL 连接)
4. 项目结构(建议读者先有全局概念)
badminton-recommend/ ├── backend/ │ ├── app.py # Flask 后端(模型 + API) │ └── requirements.txt # Python 依赖 ├── frontend/ │ ├── vite.config.js # Vite 代理配置 │ ├── src/ │ │ ├── App.vue # 全局布局(导航 + router-view + Footer) │ │ ├── router/index.js # 路由定义 + 登录拦截 │ │ ├── store/ # Pinia store │ │ └── views/ # 页面(推荐/列表/详情/公告/论坛/个人中心) │ └── package.json5. 前后端联调方式(Vite 代理)
前端开发时通过 Vite 代理把/api转发到后端,避免跨域 & 简化请求地址:
文件:frontend/vite.config.js
exportdefaultdefineConfig({server:{port:3999,proxy:{'/api':{target:'http://127.0.0.1:5999',changeOrigin:true,secure:false}}}})这样前端只需要请求/api/...,即可在开发环境自动走后端服务。
6. 核心模块实现(重点:推荐 / 鉴权 / 筛选分页 / 论坛问答)
6.1 推荐算法(/api/recommend)
推荐逻辑核心是:对每个球拍计算多维得分,按权重加权后取 Top N。
权重配置(可调参):
文件:backend/app.py
weights={'price':0.2,'weight':0.25,'balance_point':0.25,'hardness':0.2,'suitable_level':0.1}推荐接口(核心片段):
@app.route('/api/recommend',methods=['POST'])defrecommend_racket():data=request.get_json()user_price=float(data.get('price',0))user_weight=int(data.get('weight',0))user_balance_point=int(data.get('balance_point',0))user_hardness=int(data.get('hardness',0))user_level=str(data.get('suitable_level','初级'))rackets=Racket.query.all()scores=[]forracketinrackets:price_score=max(0,1-abs(racket.price-user_price)/max(1000,user_price))weight_score=max(0,1-abs(racket.weight-user_weight)/30)balance_score=max(0,1-abs(racket.balance_point-user_balance_point)/30)user_hardness_normalized=min(10,max(1,user_hardness))/10*3hardness_score=max(0,1-abs(racket.hardness-user_hardness_normalized)/3)user_level_value=level_mapping.get(user_level,2)racket_level_value=level_mapping.get(racket.suitable_level,2)level_score=max(0,1-abs(racket_level_value-user_level_value)/3)total_score=(weights['price']*price_score+weights['weight']*weight_score+weights['balance_point']*balance_score+weights['hardness']*hardness_score+weights['suitable_level']*level_score)scores.append({'id':racket.id,'brand':racket.brand,'model':racket.model,'score':total_score})scores.sort(key=lambdax:x['score'],reverse=True)returnjsonify(scores[:5])前端调用(Pinia action):
文件:frontend/src/store/racket.js
asyncrecommendRackets(criteria){constresponse=awaitaxios.post('/api/recommend',criteria)this.recommendedRackets=response.datareturnresponse.data}6.2 管理员鉴权(Token + 装饰器)
管理员登录后返回 Token,前端保存到localStorage,后续在需要管理员权限的接口中通过Authorization: Bearer <token>传递。
文件:backend/app.py
def_get_bearer_token():auth_header=request.headers.get('Authorization','')parts=auth_header.split(' ',1)iflen(parts)!=2:returnNonescheme,value=parts[0].strip().lower(),parts[1].strip()ifscheme!='bearer'ornotvalue:returnNonereturnvaluedef_get_admin_from_request():token=_get_bearer_token()orrequest.headers.get('X-Admin-Token')ifnottoken:returnNonereturnAdmin.query.filter_by(token=token).first()defrequire_admin(func):@wraps(func)defwrapper(*args,**kwargs):admin=_get_admin_from_request()ifnotadmin:returnjsonify({'success':False,'message':'需要管理员登录'}),401returnfunc(*args,**kwargs)returnwrapper前端保存登录态(Pinia):
文件:frontend/src/store/user.js
login(userData){constid=userData.user_id??userData.admin_id??nullthis.userId=idthis.username=userData.username||nullthis.role=userData.role||(userData.admin_id?'admin':'user')this.token=userData.token||nullthis.isLoggedIn=!!id localStorage.setItem('role',this.role)if(this.token)localStorage.setItem('token',this.token)}6.3 球拍列表筛选 + 分页(/api/rackets)
后端通过 query 参数拼装 SQLAlchemy 查询,再分页返回:
文件:backend/app.py
@app.route('/api/rackets',methods=['GET'])defget_rackets():page=request.args.get('page',1,type=int)per_page=request.args.get('per_page',10,type=int)brand=request.args.get('brand')price_min=request.args.get('price_min',type=float)price_max=request.args.get('price_max',type=float)# ... 省略其他过滤条件query=Racket.queryifbrand:query=query.filter(Racket.brand.like(f'%{brand}%'))ifprice_minisnotNone:query=query.filter(Racket.price>=price_min)ifprice_maxisnotNone:query=query.filter(Racket.price<=price_max)total_count=query.count()rackets=query.offset((page-1)*per_page).limit(per_page).all()returnjsonify({'items':[...],'total':total_count,'page':page,'per_page':per_page,'pages':(total_count+per_page-1)//per_page})前端 Pinia 统一接收分页结构(total/pages/page/per_page),页面只消费 store:
文件:frontend/src/store/racket.js
constresponse=awaitaxios.get('/api/rackets',{params})this.racketItems=response.data.itemsthis.total=response.data.totalthis.page=response.data.pagethis.perPage=response.data.per_pagethis.totalPages=response.data.pages6.4 系统公告(置顶排序 + 分页)
公告列表按“置顶优先 + 时间倒序”:
文件:backend/app.py
query=Announcement.query.order_by(Announcement.is_pinned.desc(),Announcement.created_at.desc(),Announcement.id.desc())管理员发布公告加上@require_admin:
@app.route('/api/announcements',methods=['POST'])@require_admindefcreate_announcement():# ...db.session.add(announcement)db.session.commit()returnjsonify({'success':True,'item':_announcement_to_dict(announcement)})6.5 球友论坛问答(提问/答疑)
论坛接口包括:
GET /api/forum/questions:问题列表(分页 + 回复数)POST /api/forum/questions:发布提问GET /api/forum/questions/<id>:详情 + 回答列表POST /api/forum/questions/<id>/answers:发布回答
列表接口中通过 SQL 聚合统计answer_count(用于列表显示“xx 回复”):
文件:backend/app.py
rows=(db.session.query(ForumAnswer.question_id,func.count(ForumAnswer.id)).filter(ForumAnswer.question_id.in_(ids)).group_by(ForumAnswer.question_id).all())counts={qid:int(cnt)forqid,cntinrows}前端发起提问(带上author_id/author_role):
文件:frontend/src/views/Forum.vue
constresponse=awaitaxios.post('/api/forum/questions',{title,content,author_id:userStore.userId,author_role:userStore.role})前端发布回答:
文件:frontend/src/views/ForumDetail.vue
constresponse=awaitaxios.post(`/api/forum/questions/${questionId}/answers`,{content,author_id:userStore.userId,author_role:userStore.role})7. 路由与登录拦截(个人中心示例)
个人中心需要登录后访问,使用meta.requiresAuth+beforeEach做拦截:
文件:frontend/src/router/index.js
{path:'/profile',name:'Profile',component:Profile,meta:{requiresAuth:true}}router.beforeEach((to,from,next)=>{if(to.meta?.requiresAuth){constisLoggedIn=!!localStorage.getItem('userId')if(!isLoggedIn){next({path:'/login',query:{redirect:to.fullPath}})return}}next()})8. 可优化点
- 论坛增强:点赞、采纳最佳答案、搜索、标签、分页加载
- 推荐升级:引入“历史评价/行为”,做协同过滤或内容推荐
- 管理后台:用户管理、数据统计、敏感词过滤等
如果你也想要一个“选拍更直观 + 有球友讨论氛围”的小系统,欢迎交流,我也可以继续把功能打磨得更完整的选购辅助小工具。