我接手这类项目的时候,第一反应就是标题里的技术栈一眼就能看出选题阶段的纠结:Flask、Django、Vue、PyCharm全挤在一起,说明需求方其实只清楚“要做个红色旅游景点管理系统”,但技术上还没完全拿定主意。这类系统说到底就是一个带后台维护的内容展示平台,业务量不大,却要覆盖权限控制、内容管理、数据检索、用户互动这几条线,非常适合当作全栈练手项目或者是毕业设计来做。
如果你也正在纠结这套栈怎么搭,这篇文章我会从需求拆解开始,讲到Flask后端、Vue前端、数据库设计、联调排错,一直到最后部署,把自己实际开发中沉淀下来的方案和教训一次讲清楚。不吹不黑,手把手思路,读完你至少知道这个系统每一步该怎么落。
1. 这类系统到底在做什么:需求拆解与功能边界
很多人拿到题目就开始写代码,这个顺序其实是反的。红色旅游景点管理系统,本质上是一个信息管理系统,核心不是“景点”这两个字,而是“管理”这两个字。你要做的不是旅游网站,而是把一个区域内的红色文化资源数字化,让游客能查、能看、能预约,让运营人员能维护、能回复、能统计。
1.1 面向的用户角色与核心业务流
系统里有两种角色:前台游客和管理员。游客不注册也能浏览,想看预约详情或者提交互动再填信息;管理员必须登录才能进后台。这个“游客免登录、管理员需鉴权”的设计,最符合这类系统的真实使用习惯,也省掉了大家最头疼的注册验证码逻辑。
核心业务流也很清楚:管理员录入景点资料,前台展示出来;游客看到景点后可以查阅开放时间、交通方式、预约情况,提交参观预约或者留言;管理员登录后台审核预约、处理留言、维护内容。流程不复杂,但每个环节都有独立的数据记录,这就是一个标准的信息管理系统雏形。
1.2 功能模块怎么划分才不虚
功能模块的划分直接决定你数据库里要建多少张表。我建议切成这么五块:
| 模块 | 用户端能力 | 管理端能力 |
|---|---|---|
| 景点管理 | 列表浏览、关键字搜索、区域/主题筛选、查看详情 | 新增、编辑、删除、上下架、浏览量统计 |
| 资讯展示 | 红色故事、参观指南等图文内容阅读 | 发布文章、修改、下架 |
| 预约服务 | 提交参观预约、查看预约结果 | 审核、备注、导出 |
| 互动反馈 | 在线留言 | 回复、删除、展示控制 |
| 系统管理 | 无 | 管理员登录、密码修改 |
这里要提醒一点:功能别做太多。曾经有人为了让系统“看起来高级”,加了实时聊天、虚拟展厅、VR全景,结果工期内全烂尾了。一个景点管理系统,把以上五块做扎实,数据串联起来,已经足够体现工程能力。所谓的高质量,是每个功能完整闭环,而不是数量堆砌。
2. 技术栈怎么选:Flask、Django还是Vue全家桶
标题里同时出现Flask和Django,说明这是一个经典的框架选择困难。我的观点很直接:如果你需要快速出成果、希望每一行代码都能写下清晰的注释,选Flask;如果你希望后台管理一张表都不用写就自动生成,可以考虑Django的admin,但代价是你得接受它宏观默认逻辑的“重”。
这个项目要是问我,我选Flask。不是Django不好,而是从项目体量和技术讲解两个角度考虑,Flask更合适。
2.1 Flask和Django,别停留在“哪个更好”的争论上
Flask的好处是灵活,它只给你最基本的路由、请求、响应,其余的ORM、登录验证、表单校验都用扩展自由组合。这种自由意味着你可以精准控制每一个环节,对于需要写技术文档、画架构图的场景非常友好。Django则是一套全家桶,自带后台、ORM、迁移工具、认证系统,适合功能多、规模大的业务。
但在这个项目里,Django的admin虽然能省掉大量后台代码,却很难按你的设计定制界面。很多人的做法是先靠Django admin交差,然后发现需求里写着“景点管理需要图片上传、区域筛选”等字段,自己在admin里改来改去,比从头写还费劲。这就像买了一套精装房,非要把墙砸了重装,不如毛坯房来得直接。
如果你的题目里明确写了Django,那一套完整方案也可以做,但核心思路不变,模型换Django的Model,视图换Class-Based View,模板换DTL。整体架构还是那套信息管理系统的底子。有人问过我不写Django会不会显得没用到题目关键词,我的建议是:题目是参考不是枷锁,你用哪个框架不重要,重要的是把系统逻辑贯通。
2.2 模板渲染和前后端分离的分界线
标题里有Vue,这是个需要认真掂量的点。Vue的使用方式有两种:一种是完全的前后端分离,Flask只提供接口,Vue单独起一个工程,通过Axios调用接口;另一种是Flask用Jinja2模板渲染页面,部分交互用Vue的CDN来增强。
我做了几年的这类项目,经验是这样的:只要你有超过三周的开发时间,并且以后想把前端部分扩展成小程序或者App,那就认真做前后端分离。真正的前后端分离有三个标志:路由由前端控制,数据全靠接口获取,动态页面渲染用的是JavaScript请求返回的数据。很多人只是把Vue当一个JS库在页面里局部用,那不叫前后端分离,答辩时很容易被问住。
但如果你时间非常紧,比如只剩两周,务必退回Jinja2模板方案。Flask自带的模板加上Bootstrap,足够做出一个干净的管理系统页面。不要为了“用了Vue”这个表面的技术亮点而牺牲交付质量。
2.3 PyCharm在整个开发流程里的真实定位
PyCharm这个关键词经常出现在题目里,其实它就是个开发工具,核心作用有三块:管理Python解释器和依赖环境、支持Flask的调试模式、内置数据库工具直接查看SQLite或者MySQL的数据表。
我用PyCharm开发这类Flask项目时,最常用的是调试断点功能。接口返回的数据不对,直接在后端代码里打几个断点,可以看到每一个变量的真实值,比到处用print()高效得多。需要注意的一点:PyCharm里的运行配置要选对Flask服务,而不是普通Python脚本,否则你会发现访问不了页面。常规做法是配置一个Flask Server类型的Run Configuration,并在环境变量里填入开发配置。
前端工程建议单独用支持Vue比较好的工具打开,哪怕也是PyCharm,只要把Vue插件装上就行。不要在一个窗口里同时打开后端和前端两个工程,容易造成文件跳转混乱,实际开发体验很差。
3. 数据库设计与接口约定,先把地基打牢
信息管理系统的核心在一张张表和一条条接口约定上。系统功能看似多,但只要表结构清晰,所有接口写起来就非常顺手。
3.1 六张表的结构设计
我会这样设计表,一共六张,刚好覆盖上一章说的五个模块。用SQLAlchemy模型表示如下:
from datetime import datetime from app import db class Admin(db.Model): __tablename__ = "admin" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(50), unique=True, nullable=False) password_hash = db.Column(db.String(255), nullable=False) nickname = db.Column(db.String(50)) created_at = db.Column(db.DateTime, default=datetime.now) class ScenicSpot(db.Model): __tablename__ = "scenic_spot" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(100), nullable=False) region = db.Column(db.String(50)) theme = db.Column(db.String(50)) cover_image = db.Column(db.String(255)) images = db.Column(db.JSON) intro = db.Column(db.Text) story = db.Column(db.Text) open_time = db.Column(db.String(100)) traffic = db.Column(db.String(200)) ticket = db.Column(db.String(100)) status = db.Column(db.SmallInteger, default=1) click_count = db.Column(db.Integer, default=0) created_at = db.Column(db.DateTime, default=datetime.now) class Article(db.Model): __tablename__ = "article" id = db.Column(db.Integer, primary_key=True) title = db.Column(db.String(200), nullable=False) cover = db.Column(db.String(255)) content = db.Column(db.Text) source = db.Column(db.String(100)) publish_time = db.Column(db.DateTime, default=datetime.now) status = db.Column(db.SmallInteger, default=1) class Reservation(db.Model): __tablename__ = "reservation" id = db.Column(db.Integer, primary_key=True) spot_id = db.Column(db.Integer, index=True) user_name = db.Column(db.String(50)) phone = db.Column(db.String(20)) visit_date = db.Column(db.String(20)) people_num = db.Column(db.Integer, default=1) remark = db.Column(db.String(255)) status = db.Column(db.SmallInteger, default=0) # 0待审核 1通过 2拒绝 created_at = db.Column(db.DateTime, default=datetime.now) class Message(db.Model): __tablename__ = "message" id = db.Column(db.Integer, primary_key=True) user_name = db.Column(db.String(50)) phone = db.Column(db.String(20)) content = db.Column(db.Text) reply = db.Column(db.Text) status = db.Column(db.SmallInteger, default=0) created_at = db.Column(db.DateTime, default=datetime.now)几个设计细节值得说一下。第一,景点图片列表用JSON字段存,省掉了一张图片子表,对于这种图片数量有限的系统非常方便。第二,预约和留言都不做强外键约束,只留下景区ID关联查询,减少级联更新带来的麻烦。因为这类系统的数据量小,不需要为了严谨的完整性牺牲灵活性。第三,每张表都有status状态字段,实现“软删除”和“上下架”非常顺手,前端列表也只需要针对状态过滤。
3.2 接口路径与统一返回格式
接口设计要让人看一眼就懂。我习惯把所有接口放在 /api 前缀下,按资源命名:
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /api/admin/login | 管理员登录 |
| GET | /api/spot/list | 景点分页列表,支持keyword/region/theme |
| GET | /api/spot/info/ | 景点详情 |
| POST | /api/reservation/add | 提交预约 |
| POST | /api/message/add | 提交留言 |
| GET | /api/article/list | 资讯列表 |
| POST | /api/admin/spot/save | 后台新增或保存景点 |
| GET | /api/admin/order/list | 后台预约列表 |
统一返回格式是我反复强调的事情。所有接口都返回一个结构:
{ "code": 0, "message": "success", "data": {} }这样做的好处是前端只需要在Axios拦截器里处理一次错误,各个页面不用每个请求都写重复的异常判断。code为0表示成功,非0表示业务异常,HTTP状态码只管网络层面的对不对。
3.3 密码安全与权限校验不能糊弄
管理员密码一定不能存明文。在Flask里最方便的是用Werkzeug自带的密码散列函数:
from werkzeug.security import generate_password_hash, check_password_hash password_hash = generate_password_hash("admin123") check_password_hash(password_hash, "admin123")登录成功之后,我给前端返回一个token,后续的增删改接口都要校验这个token。用简单的装饰器就能实现:
from functools import wraps from flask import request, jsonify import jwt def login_required(f): @wraps(f) def wrapper(*args, **kwargs): token = request.headers.get("Authorization", "") try: jwt.decode(token, app.config["SECRET_KEY"], algorithms=["HS256"]) except Exception: return jsonify({"code": 401, "message": "登录已过期", "data": None}) return f(*args, **kwargs) return wrapper这里用了JWT做无状态鉴权,好处是后端不用把登录状态存在Session里,前端拿到token存到localStorage,每次请求带上就行,非常契合前后端分离的架构。
4. 后端编码:从零到一套可运行的API
前面把整体设计和数据库定下来之后,写代码就是顺水推舟。我用Flask 3 + SQLAlchemy 2来演示,这套组合在PyCharm里的体验很好,也是目前最常见的搭配。
4.1 搭建Flask应用骨架
工程结构建议分成应用工厂、模型、蓝图、工具四个层次。作为中小型项目,过度分层反而难维护,下面这个结构刚刚好:
project/ ├── app/ │ ├── __init__.py # 应用工厂函数 │ ├── models.py # 所有ORM模型 │ ├── utils.py # 统一返回、token校验 │ ├── api/ │ │ ├── __init__.py │ │ ├── admin.py # 后台管理接口 │ │ └── site.py # 前台接口 │ └── config.py # 配置 ├── frontend/ # Vue工程 └── run.py应用工厂模式是Flask官方推荐的做法,方便以后做测试、迁移,也避免了全局循环导入问题:
from flask import Flask, jsonify from flask_sqlalchemy import SQLAlchemy from flask_cors import CORS db = SQLAlchemy() def create_app(config_name="default"): app = Flask(__name__) app.config.from_object(Config) CORS(app, resources={r"/api/*": {"origins": "*"}}) db.init_app(app) from app.api.site import site_bp from app.api.admin import admin_bp app.register_blueprint(site_bp, url_prefix="/api") app.register_blueprint(admin_bp, url_prefix="/api/admin") @app.errorhandler(Exception) def global_exception(e): return jsonify({"code": 500, "message": str(e), "data": None}), 500 return app注意这里注册了两个蓝图,前台接口挂在/api,后台接口挂在/api/admin,职责清晰。全局异常捕获器保证了即使代码里有小问题,前端拿到的也是JSON而不是一堆HTML报错页面。
4.2 景点列表与搜索筛选的实现
前台最核心的接口是景点列表。它要同时支持分页、关键词搜索、区域筛选、主题筛选和浏览量排序。如果直接写一堆if拼SQL会很难看,SQLAlchemy的做法是这样:
from flask import request, jsonify from sqlalchemy import or_ @site_bp.get("/spot/list") def spot_list(): page = request.args.get("page", 1, type=int) page_size = request.args.get("page_size", 8, type=int) keyword = request.args.get("keyword", "", type=str).strip() region = request.args.get("region", "", type=str).strip() theme = request.args.get("theme", "", type=str).strip() query = ScenicSpot.query.filter(ScenicSpot.status == 1) if keyword: like = f"%{keyword}%" query = query.filter( or_(ScenicSpot.name.like(like), ScenicSpot.intro.like(like)) ) if region: query = query.filter(ScenicSpot.region == region) if theme: query = query.filter(ScenicSpot.theme == theme) total = query.count() pagination = query.order_by(ScenicSpot.click_count.desc()).paginate( page=page, per_page=page_size, error_out=False ) data = [] for item in pagination.items: data.append({ "id": item.id, "name": item.name, "region": item.region, "theme": item.theme, "cover": item.cover_image, "intro": item.intro, "click_count": item.click_count, }) return jsonify({"code": 0, "message": "success", "data": { "list": data, "total": total, "page": page, "page_size": page_size }})这里有个关键技巧:分页要返回total总数,前端表格或者列表才能正确计算出总页数。很多人只返回当前页数据,导致页码组件显示不正确,找半天bug不知道问题出在哪。
景点详情的接口实现比较直白,按id查询后直接返回所有字段。不过记得做一步:每次被访问时点击量累加。有一个细节:使用db.session.commit()后资源会被释放,如果紧接着做其他操作容易报错,所以点击量更新要放在查询构造之后、返回数据之前。
4.3 后台管理功能的落地思路
后台的景点管理、预约管理在代码层面就是增删改查,但有两点值得展开。第一点是保存景点的接口要同时处理“新增”和“编辑”两种请求,判断依据就是id是否存在:
@admin_bp.post("/spot/save") @login_required def spot_save(): data = request.get_json() spot_id = data.get("id") if spot_id: spot = ScenicSpot.query.get(spot_id) if not spot: return jsonify({"code": 404, "message": "景点不存在", "data": None}) else: spot = ScenicSpot() db.session.add(spot) spot.name = data.get("name") spot.region = data.get("region") spot.theme = data.get("theme") spot.cover_image = data.get("cover") spot.images = data.get("images", []) spot.intro = data.get("intro") spot.story = data.get("story") spot.open_time = data.get("open_time") spot.traffic = data.get("traffic") spot.ticket = data.get("ticket") db.session.commit() return jsonify({"code": 0, "message": "保存成功", "data": {"id": spot.id}})第二点是图片上传。实际项目里图片一般不是存MySQL里,而是存到服务器磁盘或者对象存储里,数据库只保留URL。上传接口用Flask的request.files接收文件,然后按时间戳生成文件名,保存到静态目录。这一步网上资料很多,我只提醒一个坑:文件名不能用用户原始的文件名,必须重命名,否则中文文件名和重名文件会把你折磨疯。
5. Vue前端联调:数据真正跑起来才算数
前端部分如果你已经准备了Vue工程,这个阶段就是把后端数据一一接到页面上。我习惯把前端工程放在和后端同级的frontend目录里,用Vite构建。
5.1 前端工程结构与API封装
Vue工程的目录结构就按标准Vue3方式做,views下面放页面组件,api下面放接口请求函数,router下面放路由配置。页面上要用的请求全部通过独立封装的axios实例发送:
// api/request.js import axios from 'axios' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.response.use( response => { const res = response.data if (res.code !== 0) { // message组件弹出错误 return Promise.reject(new Error(res.message)) } return res.data }, error => { return Promise.reject(error) } ) export default request这样每个页面的请求代码变成:
import request from './request' export function getSpotList(params) { return request.get('/spot/list', { params }) }网上很多示例代码会写出一大堆重复的axios配置,其实封装一次之后,所有页面都清爽很多。
5.2 列表页、详情页与表单提交
列表页是前后端联调里最容易出问题的地方。我给一个通用流程:进入页面时先请求列表数据,同时根据接口返回的total总数初始化分页组件,筛选条件变化时重置页码为1再请求。很多人漏了“重置页码”这一步,导致在第三页搜索关键词时,后端返回空列表。
详情页建议用路由参数传景点id,然后调用详情接口加载数据。景点图片轮播用Element Plus的el-carousel组件即可,数据源就是images字段。页面上展示“背后的故事”时,注意后端返回的story字段换成换行显示,建议用v-html渲染,如果故事里有HTML片段就能正常展示。
表单提交的重点是做好完整校验。预约表单至少校验手机号格式和参观日期不能早于当前日期。校验通过后才调用接口,接口返回成功就跳转到成功页或者列表页。这里有一个交互细节:提交按钮点击后要加loading状态,防止用户重复点击产生两条预约记录。很多人开发时没在意,测试时偶尔手快点了两下,后台就多了一条重复单。
5.3 跨域与代理的几种处理方式
前后端分离第一个拦路虎就是跨域。我在后端示例里加了CORS,这是后端处理方式。但在开发环境中,我更喜欢用Vite的代理,这样前端请求地址不用写上完整的后端URL,所有/api开头的请求都代理到本地5000端口:
// vite.config.js export default { server: { proxy: { '/api': { target: 'http://localhost:5000', changeOrigin: true } } } }这个配置生效后,浏览器里请求的是当前前端开发服务器的地址,不存在跨域,也解决了生产环境Nginx反向代理的路径问题。提示一下:如果你改了vite.config.js以后没生效,重启一下前端开发服务器,很多配置不是热更新的。
6. 常见问题排查与上线部署记录
代码能跑起来不代表项目结束了,部署上线才是最后一环。我把自己在这个项目里遇到频率最高的问题整理成表格,另外再给一套完整的部署路径。
6.1 开发阶段高频问题速查表
| 现象 | 常见原因 | 解决办法 |
|---|---|---|
| 前端请求返回 404 | 后端蓝图url_prefix配置导致路径拼接错误 | 打开后端控制台看请求路径和实际路由是否匹配 |
| 数据全是英文乱码 | MySQL连接串没加charset,或数据库建库时字符集不对 | 连接串写成mysql+pymysql://user:pass@host/db?charset=utf8mb4 |
| 跨域请求成功但看不到数据 | 前端用了代理,但后端仍写了CORS导致重复 | 开发期用代理就注释掉CORS,避免冲突 |
| 图片上传后刷新404 | 上传目录在外层目录,Flask静态目录没映射 | 用Flask的static_folder参数指定真实目录 |
| Vue刷新页面404 | 前端路由用了history模式,服务器没配置回退 | Nginx配置try_files,见下文 |
| 接口改了前端还是旧数据 | Vite或浏览器缓存 | 强制刷新、重启dev server,检查缓存策略 |
开发阶段建议在PyCharm里把Flask的debug=True打开,这样改了后端代码不用手动重启,保存后自动加载,省下大量时间。
6.2 打包部署:从本地到服务器的完整流程
前端打包后是静态文件,后端是Flask接口服务,两者最经典的部署方式是用Nginx做入口。先打包Vue:
npm run build打包产物在frontend/dist目录,把里面的文件传到服务器某个目录,比如/usr/share/nginx/html。后端用gunicorn启动,多worker提升并发能力:
gunicorn -w 4 -b 0.0.0.0:8000 "app:create_app()"Nginx配置的核心思路:静态文件直接返回,遇到/api开头的请求转发给Flask,其他路径统一返回index.html,解决前端history路由刷新404的问题:
server { listen 80; server_name _; root /usr/share/nginx/html; index index.html; location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } }配置完成后先测试配置文件语法,再平滑重启Nginx:
nginx -t nginx -s reload部署里最容易忽略的是静态目录权限。上传目录如果跟前端静态目录在同一文件夹,Nginx默认用户可能没有写权限,图片上传就会出现500。赋权或者改上传目录归属,按服务器的用户来设置即可。
6.3 系统后续还能怎么扩展
如果做完基础功能之后时间还充裕,可以往这些方向扩展。比较划算的是在景点详情页加上地图定位展示,用ECharts的地图组件或者第三方地图,标注各景点坐标,视觉效果好,技术难度也不大。再比如预约管理可以增加Excel导出功能,用openpyxl生成表格,后台统计预约数量会更方便。
更复杂一些的方向是加入用户注册登录和收藏功能,以及基于景点主题标签的推荐。比如根据你浏览过的主题推荐同类景点。这些扩展能让系统在答辩时更有话讲,但要注意控制边界,不要影响主体功能的稳定性。我自己的习惯是优先保证主流程跑通,再把“低成本、高展示度”的功能一个个加进去。毕竟一个能稳定运行、逻辑完整的信息管理系统,比十个半成品功能更有说服力。