简介:本资源为基于Python实现的Web项目管理信息系统课程设计完整资料,面向计算机相关专业学生及需要完成信息系统与设计类项目的开发者。内容涵盖数据设计与界面设计两大板块:数据设计部分对实体、功能点进行了详细规划,功能覆盖系统管理、项目与任务管理、通知管理等模块,并配有功能导图与流程图;页面设计则包含首页、系统管理、项目、任务、通知等页面实现。压缩包共193个文件,以png界面截图、js脚本、vue组件、py后端代码为主,另含css样式、html模板、yml配置及Dockerfile等部署文件,整体约9.39MB,结构清晰便于按模块查阅。目前已有251人学习下载,适合作为课程设计参考、前后端分离项目练手或功能设计思路借鉴,帮助读者快速理解项目管理系统的整体架构与实现路径。
1. 从一张 Excel 管三个项目说起:Python Web 项目管理信息系统到底解决什么问题
三个人同时改一张项目进度表,周五下午合并出四个版本,这种事我经历过不止一次。后来我们上了基于 Python 实现的项目管理信息系统,任务分配、进度跟踪、工时统计全在一个 Web 页面里完成,谁改了什么、什么时候改的,数据库里都有记录。这套东西的核心就是用 Python 写后端逻辑,用 Web 页面做交互入口,把项目从立项到交付的全过程管起来。适合谁?适合手里同时跑着两三个项目、还在用 Excel 或聊天工具同步进度的团队,也适合想拿一个完整项目练手的 Python 开发者。下面我从技术选型一路讲到部署上线,把踩过的坑都摊开说。
2. 技术选型:为什么是 Flask + SQLAlchemy 而不是 Django
2.1 框架选型的三个实际考量
项目管理系统的业务复杂度处于中等水平:有用户体系、有权限控制、有 CRUD 操作、有统计报表,但不需要内容管理、电商交易那种重型基础设施。Django 自带 Admin 和 ORM,开箱即用,但它的 Admin 定制成本不低,一旦要改字段展示逻辑就得跟源码较劲。Flask 轻,路由和扩展自己选,对于项目管理这种“表结构清晰、业务逻辑线性”的场景,反而更顺手。
我一般会从三个维度判断:第一,团队对框架的熟悉程度。如果组里没人写过 Django,光理解它的 App 结构和 settings 分层就要花两天。第二,项目后续的扩展方向。如果半年内要接移动端 API,Flask 的蓝图机制做版本化接口更灵活。第三,部署环境的限制。有些客户的服务器上 Python 版本锁死在 3.8,Django 4.x 直接不支持,Flask 2.x 还能跑。
数据库层面选 PostgreSQL 而不是 MySQL,主要看中它的 JSONB 字段类型。项目管理里经常要存自定义字段,比如某个任务额外记录“风险等级”或“关联需求编号”,用 JSONB 存比开一张扩展表再 JOIN 查询要省事得多。SQLAlchemy 作为 ORM,配合 Alembic 做迁移,表结构变更时不用手写 ALTER TABLE。
2.2 最小可运行环境搭建
先确认 Python 版本,建议 3.9 以上。Windows 下去 python.org 下载安装包,安装时勾选“Add Python to PATH”,不勾后面在命令行里敲 python 会提示找不到命令。macOS 用 Homebrew 装最省心。Linux 上如果系统自带的 Python 版本太低,用 pyenv 管理多版本。
# 创建虚拟环境,避免污染系统 Python python -m venv venv # 激活虚拟环境 # Windows: venv\Scripts\activate # macOS / Linux: source venv/bin/activate # 安装核心依赖 pip install flask flask-sqlalchemy flask-login flask-migrate psycopg2-binary python-dotenv这里解释一下每个包的作用。flask 是 Web 框架本体;flask-sqlalchemy 把 SQLAlchemy 集成进 Flask 的应用上下文;flask-login 处理用户会话和登录状态;flask-migrate 封装 Alembic,用来做数据库迁移;psycopg2-binary 是 PostgreSQL 的驱动,用 binary 版本省去编译依赖;python-dotenv 从 .env 文件读取配置,避免把数据库密码硬编码在代码里。
安装完成后用 pip list 确认一下版本,Flask 3.x 和 Flask-SQLAlchemy 3.x 搭配没问题,但如果你的 Flask 是 2.x,Flask-SQLAlchemy 要降到 2.5.x,否则初始化时会报 “init_app() takes 1 positional argument” 这类错误。
2.3 项目目录结构与配置管理
目录结构直接影响后续维护成本。我习惯按功能模块划分,而不是按文件类型划分:
pm_system/ ├── app/ │ ├── __init__.py # 应用工厂 │ ├── models/ │ │ ├── __init__.py │ │ ├── user.py # 用户模型 │ │ ├── project.py # 项目模型 │ │ └── task.py # 任务模型 │ ├── views/ │ │ ├── __init__.py │ │ ├── auth.py # 登录注册 │ │ ├── project.py # 项目 CRUD │ │ └── task.py # 任务管理 │ ├── templates/ │ └── static/ ├── migrations/ # Alembic 迁移文件 ├── config.py # 配置类 ├── .env # 环境变量(不提交到 Git) ├── requirements.txt └── run.py # 启动入口配置类里区分开发和生产两套参数。开发环境开 DEBUG,数据库连接本地;生产环境关 DEBUG,数据库连接串从环境变量读。SECRET_KEY 必须设置,flask-login 用它来签名会话 cookie,不设的话每次重启服务用户登录状态就丢了。
# config.py import os from dotenv import load_dotenv load_dotenv() class Config: SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-key-change-in-production') SQLALCHEMY_TRACK_MODIFICATIONS = False class DevelopmentConfig(Config): DEBUG = True SQLALCHEMY_DATABASE_URI = os.environ.get( 'DEV_DATABASE_URL', 'postgresql://localhost:5432/pm_dev' ) class ProductionConfig(Config): DEBUG = False SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL')SQLALCHEMY_TRACK_MODIFICATIONS 设为 False 是必须的,否则每次修改模型对象都会触发信号,内存占用会持续增长,跑几天后进程被 OOM Killer 干掉。这个坑我在早期项目里踩过,日志里只看到进程莫名退出,查了半天才定位到。
3. 核心模块实现:用户、项目、任务三张表怎么串起来
3.1 数据模型设计与关系映射
项目管理系统的数据模型围绕三个核心实体展开:用户(User)、项目(Project)、任务(Task)。关系是:一个用户可以参与多个项目,一个项目包含多个任务,每个任务分配给一个负责人。多对多关系需要一张中间表。
# app/models/user.py from app import db from flask_login import UserMixin from werkzeug.security import generate_password_hash, check_password_hash # 项目成员关联表,不需要额外的模型类 project_members = db.Table( 'project_members', db.Column('user_id', db.Integer, db.ForeignKey('users.id'), primary_key=True), db.Column('project_id', db.Integer, db.ForeignKey('projects.id'), primary_key=True), db.Column('role', db.String(20), default='member') # owner / member ) class User(UserMixin, db.Model): __tablename__ = 'users' id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(64), unique=True, nullable=False, index=True) email = db.Column(db.String(120), unique=True, nullable=False) password_hash = db.Column(db.String(256)) created_at = db.Column(db.DateTime, default=db.func.now()) # 反向关系:用户拥有的项目 owned_projects = db.relationship( 'Project', backref='owner', lazy='dynamic', foreign_keys='Project.owner_id' ) # 用户参与的项目(多对多) projects = db.relationship( 'Project', secondary=project_members, backref=db.backref('members', lazy='dynamic') ) def set_password(self, password): self.password_hash = generate_password_hash(password) def check_password(self, password): return check_password_hash(self.password_hash, password)这里有几个设计决策值得说明。密码用 werkzeug 的 generate_password_hash,默认算法是 scrypt,比 md5 或 sha1 安全得多。username 字段加了 index=True,因为登录时要按用户名查询,不加索引在用户量上千后查询会明显变慢。lazy='dynamic' 返回的是查询对象而不是列表,适合项目数量可能增长的场景,但要注意在模板里不能直接遍历,得加 .all()。
任务模型里状态字段用枚举而不是自由文本,避免出现“进行中”“进行中 ”“in progress”三种写法并存的情况:
# app/models/task.py import enum from app import db class TaskStatus(enum.Enum): TODO = 'todo' IN_PROGRESS = 'in_progress' REVIEW = 'review' DONE = 'done' class Task(db.Model): __tablename__ = 'tasks' id = db.Column(db.Integer, primary_key=True) title = db.Column(db.String(200), nullable=False) description = db.Column(db.Text) status = db.Column(db.Enum(TaskStatus), default=TaskStatus.TODO, nullable=False) priority = db.Column(db.Integer, default=2) # 1高 2中 3低 due_date = db.Column(db.Date) project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=False) assignee_id = db.Column(db.Integer, db.ForeignKey('users.id')) created_at = db.Column(db.DateTime, default=db.func.now()) updated_at = db.Column(db.DateTime, default=db.func.now(), onupdate=db.func.now())updated_at 字段的 onupdate 参数让 SQLAlchemy 在每次更新记录时自动刷新时间戳,不用在业务代码里手动赋值。priority 用整数而不是字符串,排序时直接 ORDER BY priority ASC 就行,不用写 CASE WHEN。
3.2 任务看板与状态流转
任务看板是项目管理系统的核心交互界面。前端用原生 JavaScript 配合 fetch API 做无刷新更新,后端提供状态变更接口。这里不引入前端框架的原因是:看板页面逻辑不复杂,用 Vue 或 React 反而增加构建步骤和部署复杂度。
# app/views/task.py from flask import Blueprint, request, jsonify from flask_login import login_required, current_user from app import db from app.models.task import Task, TaskStatus from app.models.project import Project task_bp = Blueprint('task', __name__, url_prefix='/api/tasks') @task_bp.route('/<int:task_id>/status', methods=['PATCH']) @login_required def update_task_status(task_id): task = Task.query.get_or_404(task_id) project = Project.query.get(task.project_id) # 权限检查:只有项目成员才能修改任务状态 if current_user not in project.members.all() and current_user != project.owner: return jsonify({'error': '无权限操作此任务'}), 403 data = request.get_json() new_status = data.get('status') # 校验状态值是否合法 try: task.status = TaskStatus(new_status) except ValueError: return jsonify({'error': f'无效状态: {new_status}'}), 400 db.session.commit() return jsonify({ 'id': task.id, 'status': task.status.value, 'updated_at': task.updated_at.isoformat() })这段代码里权限检查放在状态校验之前,因为如果用户没有权限,不应该泄露任务是否存在的信息。get_or_404 在任务不存在时直接返回 404,不会继续执行后面的逻辑。状态值用 TaskStatus(new_status) 做转换,如果传入的值不在枚举范围内会抛 ValueError,捕获后返回 400 而不是 500。
前端拖拽卡片时调这个接口:
// static/js/kanban.js async function moveTask(taskId, newStatus) { const response = await fetch(`/api/tasks/${taskId}/status`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken() // 从 meta 标签读取 }, body: JSON.stringify({ status: newStatus }) }); if (!response.ok) { const err = await response.json(); alert(`操作失败: ${err.error}`); // 回滚 UI 上的拖拽效果 location.reload(); return; } const result = await response.json(); console.log(`任务 ${result.id} 状态更新为 ${result.status}`); }CSRF token 从页面 meta 标签读取,Flask-WTF 会自动校验。如果没带这个头,请求会被拒绝并返回 400。拖拽失败时直接 reload 页面是最简单的回滚方式,比手动操作 DOM 恢复位置要可靠。
3.3 进度统计与报表查询
项目经理最关心的是“当前有多少任务逾期”“每个成员手上有多少活”。这些统计用 SQL 聚合查询实现,不把数据拉到 Python 里再算。
# app/views/project.py from sqlalchemy import func, case from datetime import date @project_bp.route('/<int:project_id>/stats') @login_required def project_stats(project_id): project = Project.query.get_or_404(project_id) # 按状态统计任务数量 status_counts = db.session.query( Task.status, func.count(Task.id) ).filter(Task.project_id == project_id).group_by(Task.status).all() # 按负责人统计未完成任务数 assignee_stats = db.session.query( User.username, func.count(Task.id).label('total'), func.sum( case((Task.due_date < date.today(), 1), else_=0) ).label('overdue') ).join(Task, Task.assignee_id == User.id).filter( Task.project_id == project_id, Task.status != TaskStatus.DONE ).group_by(User.username).all() return jsonify({ 'status_distribution': {s.value: c for s, c in status_counts}, 'assignee_workload': [ {'name': name, 'total': total, 'overdue': overdue or 0} for name, total, overdue in assignee_stats ] })case 表达式在 SQL 层面做条件计数,比查出来再用 Python 循环判断快一个数量级。overdue 可能返回 None(当没有逾期任务时 sum 返回 NULL),所以用or 0兜底。这个接口返回的 JSON 直接给前端 ECharts 渲染饼图和柱状图。
4. 避坑与排查:部署上线时最容易翻车的五个地方
4.1 数据库连接池耗尽导致接口超时
现象:系统跑了一两天后,所有接口响应变慢,最后直接返回 500,重启服务后恢复正常。
原因:SQLAlchemy 默认的连接池大小是 5,溢出上限是 10。如果代码里有地方拿了连接没释放(比如手动写了 db.session.execute 但没 commit 或 rollback),连接会被一直占用。Flask-SQLAlchemy 在请求结束时自动回收连接,但如果用了多线程或异步任务,回收时机就不确定了。
解决:在配置里显式设置连接池参数,并开启连接回收。
SQLALCHEMY_ENGINE_OPTIONS = { 'pool_size': 10, 'max_overflow': 20, 'pool_recycle': 1800, # 30分钟回收空闲连接 'pool_pre_ping': True # 取连接前先 ping 一下 }pool_pre_ping 会稍微增加每次查询的延迟(大约 1-2ms),但能避免拿到已经断开的连接。pool_recycle 设为 1800 秒是因为 PostgreSQL 默认的 idle_in_transaction_session_timeout 是 30 分钟,超过这个时间空闲连接会被服务端断开。
4.2 静态文件 404 但路径明明是对的
现象:CSS 和 JS 文件在开发环境正常加载,部署到 Nginx 后面就报 404。
原因:Flask 的 static 目录默认在 app/static,url_for('static', filename='...') 生成的路径是 /static/...。Nginx 配置里如果只转发了 / 到 Flask,没有单独处理 /static/,请求会走到 Flask 的路由但找不到对应文件。或者 Nginx 的 root 指向了错误的目录。
解决:Nginx 配置里加一条 location /static/ 规则,直接由 Nginx 返回文件,不经过 Flask。
server { listen 80; server_name pm.example.com; location /static/ { alias /path/to/pm_system/app/static/; expires 7d; } location / { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }alias 和 root 的区别要注意:alias 会把 location 匹配的部分替换掉,root 会拼接。用 alias 时路径末尾的斜杠必须和 location 保持一致,否则会拼出 /path/to/pm_system/app/staticstatic/js/... 这种诡异路径。
4.3 中文用户名导致登录后跳转异常
现象:用户注册时用了中文用户名,登录成功后页面跳回登录页,但浏览器 cookie 里确实有 session。
原因:flask-login 默认用 user.get_id() 的返回值作为 session 里的标识。如果 id 是中文,在某些 WSGI 服务器(比如 gunicorn 的 sync worker)下,session cookie 的编码处理会出问题。另外,如果用户名被直接拼进 URL 做跳转参数,中文需要 URL 编码,没编码的话重定向会失败。
解决:get_id() 返回用户的主键 id(整数),不要返回用户名。跳转 URL 里如果需要带用户名,用 urllib.parse.quote 编码。
from urllib.parse import quote # 登录成功后跳转 next_page = request.args.get('next') if next_page: return redirect(quote(next_page, safe='/?=&')) return redirect(url_for('project.dashboard'))4.4 时区问题导致截止日期差一天
现象:任务截止日期设的是 2025-03-15,前端显示出来变成 2025-03-14。
原因:PostgreSQL 的 date 类型不带时区,但 Python 的 datetime.date 和 JavaScript 的 Date 对象在转换时默认按 UTC 处理。如果服务器时区是 UTC+8,前端 new Date('2025-03-15') 会解析成 UTC 时间的 2025-03-15 00:00:00,转成本地时间就变成了 03-14 08:00:00。
解决:后端返回日期时统一格式化为字符串 'YYYY-MM-DD',前端不要用 new Date() 解析,直接当字符串展示。如果要做日期计算,用 dayjs 或 date-fns 这类库,明确指定时区。
# 序列化时统一转字符串 def serialize_task(task): return { 'id': task.id, 'title': task.title, 'due_date': task.due_date.isoformat() if task.due_date else None, 'status': task.status.value }4.5 并发修改同一条记录导致数据覆盖
现象:两个人同时编辑同一个任务,A 改了标题保存,B 改了描述保存,结果 A 的标题修改被 B 的保存覆盖了。
原因:默认的更新逻辑是“读-改-写”,两个请求都读到了旧数据,后写的覆盖了先写的。这在项目管理里很常见,因为任务详情页可能被多人同时打开。
解决:加乐观锁。在任务表里加一个 version 字段,每次更新时检查版本号是否变化。
# 更新时带版本号检查 def update_task(task_id, data, expected_version): task = Task.query.get_or_404(task_id) if task.version != expected_version: return jsonify({'error': '数据已被他人修改,请刷新后重试'}), 409 task.title = data.get('title', task.title) task.version += 1 db.session.commit() return jsonify(serialize_task(task))前端在表单里放一个隐藏字段存 version,提交时带上。返回 409 时提示用户刷新页面。这个方案比悲观锁(SELECT FOR UPDATE)对用户体验更友好,不会长时间锁住记录。
5. 进阶技巧:用 APScheduler 做逾期提醒和报表自动生成
系统上线后,项目经理不可能每天手动刷新看板查逾期任务。我一般会加一个定时任务模块,用 APScheduler 在后台跑,每天早上八点检查逾期任务并发送提醒,每周一生成项目周报。
# app/scheduler.py from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger from datetime import date, timedelta from app import db from app.models.task import Task, TaskStatus from app.models.user import User scheduler = BackgroundScheduler(daemon=True) def check_overdue_tasks(): """每天早上8点检查逾期任务,给负责人发提醒""" overdue = Task.query.filter( Task.due_date < date.today(), Task.status != TaskStatus.DONE ).all() # 按负责人分组,每人发一条汇总提醒 by_assignee = {} for task in overdue: if task.assignee_id: by_assignee.setdefault(task.assignee_id, []).append(task) for user_id, tasks in by_assignee.items(): user = User.query.get(user_id) if user: task_list = '\n'.join([f'- {t.title} (截止: {t.due_date})' for t in tasks]) # 这里对接邮件或站内信,具体实现略 print(f'提醒 {user.username}: 你有 {len(tasks)} 个逾期任务\n{task_list}') def generate_weekly_report(): """每周一生成上周项目周报""" last_monday = date.today() - timedelta(days=date.today().weekday() + 7) last_sunday = last_monday + timedelta(days=6) completed = Task.query.filter( Task.status == TaskStatus.DONE, Task.updated_at >= last_monday, Task.updated_at <= last_sunday ).count() created = Task.query.filter( Task.created_at >= last_monday, Task.created_at <= last_sunday ).count() print(f'周报 [{last_monday} ~ {last_sunday}]: 新建 {created} 个任务,完成 {completed} 个') # 注册定时任务 scheduler.add_job( check_overdue_tasks, CronTrigger(hour=8, minute=0), id='overdue_check', replace_existing=True ) scheduler.add_job( generate_weekly_report, CronTrigger(day_of_week='mon', hour=9, minute=0), id='weekly_report', replace_existing=True )在应用工厂里启动调度器:
# app/__init__.py from app.scheduler import scheduler def create_app(config_name='development'): app = Flask(__name__) app.config.from_object(config[config_name]) db.init_app(app) # ... 注册蓝图等 if not scheduler.running: scheduler.start() return app这里有几个关键参数。daemon=True 让调度器线程随主进程退出,不会阻止程序关闭。replace_existing=True 在开发环境热重载时避免重复注册任务。CronTrigger 的 hour 和 minute 用的是服务器本地时间,如果服务器时区不对,提醒会在错误的时间发出,部署前用timedatectl确认一下。
APScheduler 的 BackgroundScheduler 在 gunicorn 多 worker 模式下会有一个问题:每个 worker 都会启动一个调度器,导致任务重复执行。解决办法是用--preload参数让 gunicorn 先加载应用再 fork worker,这样调度器只在主进程里启动一次。或者把调度器拆成独立进程,用 systemd 管理。
验证定时任务是否生效,最直接的方式是看日志。在任务函数里加 logging,把执行时间和结果打出来。如果发现任务没跑,先检查 scheduler.get_jobs() 返回的列表是否为空,再确认时区设置。
我自己的习惯是:任何定时任务上线前,先把触发时间改成当前时间加两分钟,观察一轮执行日志,确认没问题再改回正式时间。这个“后悔药”操作帮我省过好几次半夜被叫起来查问题的麻烦。希望帮到你。
本文还有配套的精品资源,点击获取