如果你也经历过:
- 预算定了,但不知道该选轻一点还是头重一点
- 看参数看懵:重量、平衡点、硬度到底怎么影响手感
- 想要更适合自己打法的球拍清单,还想看看球友怎么说
我把这些需求做成了一个「羽毛球拍推荐系统」,把选拍流程变得更可视化、更好用,也更适合日常自用、项目展示。
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.json
5. 前后端联调方式(Vite 代理)
前端开发时通过 Vite 代理把 /api 转发到后端,避免跨域 & 简化请求地址:
文件:frontend/vite.config.js
js
export default defineConfig({
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
python
weights = {
'price': 0.2,
'weight': 0.25,
'balance_point': 0.25,
'hardness': 0.2,
'suitable_level': 0.1
}
推荐接口(核心片段):
python
@app.route('/api/recommend', methods=['POST'])
def recommend_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 = []
for racket in rackets:
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 * 3
hardness_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=lambda x: x['score'], reverse=True)
return jsonify(scores[:5])
前端调用(Pinia action):
文件:frontend/src/store/racket.js
js
async recommendRackets(criteria) {
const response = await axios.post('/api/recommend', criteria)
this.recommendedRackets = response.data
return response.data
}
6.2 管理员鉴权(Token + 装饰器)
管理员登录后返回 Token,前端保存到 localStorage,后续在需要管理员权限的接口中通过 Authorization: Bearer <token> 传递。
文件:backend/app.py
python
def _get_bearer_token():
auth_header = request.headers.get('Authorization', '')
parts = auth_header.split(' ', 1)
if len(parts) != 2:
return None
scheme, value = parts[0].strip().lower(), parts[1].strip()
if scheme != 'bearer' or not value:
return None
return value
def _get_admin_from_request():
token = _get_bearer_token() or request.headers.get('X-Admin-Token')
if not token:
return None
return Admin.query.filter_by(token=token).first()
def require_admin(func):
@wraps(func)
def wrapper(*args, **kwargs):
admin = _get_admin_from_request()
if not admin:
return jsonify({'success': False, 'message': '需要管理员登录'}), 401
return func(*args, **kwargs)
return wrapper
前端保存登录态(Pinia):
文件:frontend/src/store/user.js
js
login(userData) {
const id = userData.user_id ?? userData.admin_id ?? null
this.userId = id
this.username = userData.username || null
this.role = userData.role || (userData.admin_id ? 'admin' : 'user')
this.token = userData.token || null
this.isLoggedIn = !!id
localStorage.setItem('role', this.role)
if (this.token) localStorage.setItem('token', this.token)
}
6.3 球拍列表筛选 + 分页(/api/rackets)
后端通过 query 参数拼装 SQLAlchemy 查询,再分页返回:
文件:backend/app.py
python
@app.route('/api/rackets', methods=['GET'])
def get_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.query
if brand:
query = query.filter(Racket.brand.like(f'%{brand}%'))
if price_min is not None:
query = query.filter(Racket.price >= price_min)
if price_max is not None:
query = query.filter(Racket.price <= price_max)
total_count = query.count()
rackets = query.offset((page - 1) * per_page).limit(per_page).all()
return jsonify({
'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
js
const response = await axios.get('/api/rackets', { params })
this.racketItems = response.data.items
this.total = response.data.total
this.page = response.data.page
this.perPage = response.data.per_page
this.totalPages = response.data.pages
6.4 系统公告(置顶排序 + 分页)
公告列表按"置顶优先 + 时间倒序":
文件:backend/app.py
python
query = Announcement.query.order_by(
Announcement.is_pinned.desc(),
Announcement.created_at.desc(),
Announcement.id.desc()
)
管理员发布公告加上 @require_admin:
python
@app.route('/api/announcements', methods=['POST'])
@require_admin
def create_announcement():
# ...
db.session.add(announcement)
db.session.commit()
return jsonify({'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
python
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) for qid, cnt in rows}
前端发起提问(带上 author_id/author_role):
文件:frontend/src/views/Forum.vue
js
const response = await axios.post('/api/forum/questions', {
title,
content,
author_id: userStore.userId,
author_role: userStore.role
})
前端发布回答:
文件:frontend/src/views/ForumDetail.vue
js
const response = await axios.post(`/api/forum/questions/${questionId}/answers`, {
content,
author_id: userStore.userId,
author_role: userStore.role
})
7. 路由与登录拦截(个人中心示例)
个人中心需要登录后访问,使用 meta.requiresAuth + beforeEach 做拦截:
文件:frontend/src/router/index.js
js
{
path: '/profile',
name: 'Profile',
component: Profile,
meta: { requiresAuth: true }
}
router.beforeEach((to, from, next) => {
if (to.meta?.requiresAuth) {
const isLoggedIn = !!localStorage.getItem('userId')
if (!isLoggedIn) {
next({ path: '/login', query: { redirect: to.fullPath } })
return
}
}
next()
})
8. 可优化点
- 论坛增强:点赞、采纳最佳答案、搜索、标签、分页加载
- 推荐升级:引入"历史评价/行为",做协同过滤或内容推荐
- 管理后台:用户管理、数据统计、敏感词过滤等
如果你也想要一个"选拍更直观 + 有球友讨论氛围"的小系统,欢迎交流,我也可以继续把功能打磨得更完整的选购辅助小工具。