基于Vue和Python的羽毛球拍智能推荐系统, 从“不会选羽毛球拍”到“选对拍”的一站式小工具

如果你也经历过:

  • 预算定了,但不知道该选轻一点还是头重一点
  • 看参数看懵:重量、平衡点、硬度到底怎么影响手感
  • 想要更适合自己打法的球拍清单,还想看看球友怎么说

我把这些需求做成了一个「羽毛球拍推荐系统」,把选拍流程变得更可视化、更好用,也更适合日常自用、项目展示。


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. 可优化点

  • 论坛增强:点赞、采纳最佳答案、搜索、标签、分页加载
  • 推荐升级:引入"历史评价/行为",做协同过滤或内容推荐
  • 管理后台:用户管理、数据统计、敏感词过滤等

如果你也想要一个"选拍更直观 + 有球友讨论氛围"的小系统,欢迎交流,我也可以继续把功能打磨得更完整的选购辅助小工具。

相关推荐
invicinble2 小时前
关于对前端项目(架子级别)的理解和认识
前端
冰冰菜的扣jio2 小时前
理解类加载过程
开发语言·python
qilei20102 小时前
【Python】创建日期列表
python
Sapphire~2 小时前
【前端基础】02-命令式组件系统 | 声明式组件系统 | 响应式组件系统
前端
风筝在晴天搁浅2 小时前
hot100 438.找到字符串中所有字母异位词
算法
骑驴看星星a2 小时前
【回顾React的一些小细节】render里不可包含的东西
前端·javascript·react.js
小白阿龙2 小时前
浮动元素导致父元素高度塌陷
前端
zmzb01032 小时前
C++课后习题训练记录Day53
数据结构·c++·算法
百***07452 小时前
GPT-5.2国内稳定接入实战指南:中转调用全链路方案(Python适配)
python·gpt·php