277 Commits
Author SHA1 Message Date
bwstudio 1214bfdf4a feat(ops): 环境变量化配置 + 健康检查 + 启动脚本 + 部署/安全文档
主要变更:

config
- 引入 python-dotenv,从 .env 加载 SECRET_KEY / MAIL_* / SQLALCHEMY_DATABASE_URI
- 拆分 BaseConfig / DevConfig / ProConfig,PEAR_ENV 控制切换
- ProConfig 在 init_app() 校验 SECRET_KEY,禁止使用默认值

ops
- 新增 /healthz 端点:DB 探活 + 环境名 + 时间戳,失败返回 503
- 新增 start.bat (Windows) / Makefile (跨平台) 一键启动 / 初始化
- 新增 .env.example 模板;.gitignore 排除 .env / logs / vscode 等
- 新增 docs/DEPLOY.md(部署指南)和 docs/SECURITY.md(安全清单)
- app.py 不再硬编码 debug=False,交给配置类决定

本提交不包含上一次会话的其它工作树修改(模板/导航相关),后续单独 PR。
2026-09-05 20:46:12 +08:00
bwstudio 9ba96bcc3e style(public): 前台导航页响应式适配 + 暗色模式 + 锚点目录
主要改动
- templates/public/base.html
  - 增加 3 套响应式断点:≤900px(平板)、≤600px(手机)、≤360px(小屏)
  - topbar 在小屏下压缩 padding/字号,actions 自适应换行
  - grid 卡片在手机端最小 160px,小屏单列
  - 卡片字号、间距在手机端缩放
  - 增加 sticky 分类目录条占位
  - 增加回到顶部按钮(滚动后显示)
  - 增加暗色模式 CSS(prefers-color-scheme + data-theme 手动覆盖)和主题切换按钮
  - 补充 theme-color、viewport 等移动端 meta
- templates/public/index.html
  - 输出 5 个分类的 sticky 目录导航(点击锚点跳转)
  - 每个 section 设置 id=cat-N 锚点
  - 卡片 title/url 增加 title 提示
- templates/public/category.html
  - 目录条加返回首页 + 当前分类名
  - section 锚点统一为 cat-detail

验证
- curl 抓取 /site/ 响应 200,HTML 中 10 项响应式/交互特性检查全部通过
- 服务器本地运行无异常
2026-09-05 15:33:45 +08:00
bwstudio 115585c00d feat(nav): 从 nav.bwhome.top 同步 5 个分类共 54 条导航
分类与数量
- 常用分类(4 条):短信平台、BT网站大全、菜鸟教程、旺柯博客
- 旺珂内网(10 条):MoviePilot / Jellyfin / qBittorent / 旁路由 / 小雅电影 / QNAP / NextCloud / PVE / BW gitea / 家庭服务器
- 常用网站(4 条):百度 / 豆瓣 / 新浪 / DeepSeek
- 休闲娱乐(18 条):HDFans / OKPT / HDTime / 小蚂蚁PT / HDArea / 铂金学院 / carpt / hdkyl / tosky / 52PT / 1PTBA / PTZONE / 馒头 / 铜钟音乐 / PTFans / 咖啡 / BTSCHOOL / 库非
- 技术网站(18 条):JFinal / shadmin / layuijfinal / respeed / 网站代理 / Layui / GitHub加速 / down.npee / DNS查询 / DOCK状态 / 博旺 / chromeDriver / 系统更新 / 免费节点 / Clash节点 / 狐蒂云 / chatgpt / NVIDIA AI

数据来源:https://nav.bwhome.top(OneNav 系统)
已 flask admin init --force 灌入数据库并通过 /site/api/navs 验证
2026-09-05 15:15:33 +08:00
bwstudio b208087f74 feat(nav): 新增前台公开导航页与后台导航管理
新增模块
- Nav 模型(applications/models/admin_nav.py):公开导航表,与 admin_power 解耦
- NavOutSchema / NavManageSchema(applications/schemas/admin_nav.py)
- 前台公开视图 applications/view/public/nav.py:/site/、/site/category/<name>、/site/api/navs,无需登录
- 后台管理视图 applications/view/system/nav.py:CRUD + 启用/禁用,挂入 system_bp
- 前台模板 templates/public/{base,index,category}.html(独立配色,无需登录即可访问)
- 后台模板 templates/system/nav/{main,add,edit}.html(Layui table + form)

调整
- applications/view/__init__.py:注册前台蓝图到根
- applications/view/system/__init__.py:注册后台 nav 子蓝图
- applications/common/script/admin.py:admin init/export 支持 navs 数组
- applications/common/script/app_data/init.json:新增 10 条导航种子 + 4 条 system:nav 权限

数据库
- 新增 public_nav 表(含 category 索引)
- migration: 38751d88d42c_add_public_nav_table.py
2026-09-05 14:58:08 +08:00
bwstudio 3debece5d3 0905初始化
- 在 .gitignore 中新增 .workbuddy/ 目录(WorkBuddy 运行时数据,不入版本库)
- 工程基线:venv 已建(Python 3.11.7)、迁移已生成、admin 种子数据已灌入
2026-09-05 14:40:26 +08:00
不胜舟 97eda740fe 区分测试生产环境部署配置 2026-01-16 19:21:30 +08:00
不胜舟 372b0030c3 重构 admin 初始化:改为 JSON 配置,增加导入导出功能 2026-01-16 18:48:57 +08:00
不胜舟 12c31a8e8a 重构 admin 初始化:改为 JSON 配置,增加导入导出功能 2026-01-16 18:43:03 +08:00
wojiaoyishang 490f538261 移除启用的定时任务文件夹 2025-12-14 13:26:12 +08:00
梦见吃了一大碗皮卡丘andGitee 9f64f7bdac !143 update templates/system/task/main.html.
Merge pull request !143 from laoknas/N/A
2025-12-14 01:47:53 +00:00
梦见吃了一大碗皮卡丘andGitee 96b93b08a1 !142 update templates/system/task/add.html.
Merge pull request !142 from laoknas/N/A
2025-12-14 01:47:44 +00:00
梦见吃了一大碗皮卡丘andGitee 3bfcd1d21f !141 update applications/extensions/__init__.py.
Merge pull request !141 from laoknas/N/A
2025-12-14 01:47:35 +00:00
laoknasandGitee c55e47ba4d update templates/system/task/main.html.
程序中已经没有改计划任务了,应该移除此模板。(会与计划任务插件冲突)

Signed-off-by: laoknas <kuai410022283@qq.com>
2025-12-13 16:55:45 +00:00
laoknasandGitee 29c6e5dbb6 update templates/system/task/add.html.
程序中已经没有改计划任务了,应该移除此模板。(会与计划任务插件冲突)

Signed-off-by: laoknas <kuai410022283@qq.com>
2025-12-13 16:54:57 +00:00
laoknasandGitee 0ebc9394ad update applications/extensions/__init__.py.
在 init_plugs 函数中添加了两个关键的事件广播调用:
- 第33行: broadcast_execute(app, 'event_init')
- 第34行: broadcast_execute(app, 'event_finish')

### 执行流程优化
修改后的 init_plugs 函数执行流程更加合理:

1. 加载插件模块 :注册所有启用的插件
2. 触发 event_begin :通知插件系统开始初始化
3. 初始化核心功能 :登录、数据库、邮件、上传等
4. 初始化系统蓝图 :模板指令、错误视图等
5. 触发 event_init :执行插件的主要初始化逻辑
6. 触发 event_finish :执行插件的最终初始化操作


### 事件触发时机
事件名称 触发时机 用途 event_begin 插件加载后,核心功能初始化前 插件准备工作 event_init 核心功能初始化完成后 插件主要初始化逻辑 event_finish 所有初始化完成后 插件最终初始化操作,包括应用上下文初始化

这个修改是插件系统架构的重要优化,确保了所有插件能够在正确的时机执行初始化逻辑,特别是对于复杂的任务插件至关重要。

Signed-off-by: laoknas <kuai410022283@qq.com>
2025-12-13 16:50:39 +00:00
不胜舟andGitee da453c839e !138 fix #ICW0DF:修复readme资源结果错别字
Merge pull request !138 from ChenWenhui/master
2025-09-02 08:47:50 +00:00
chenwenhui01 3986210925 fix:readme资源结果错别字 2025-09-02 16:38:52 +08:00
不胜舟 5bcc28a492 feat(api): 新增 CSRF 校验 2025-09-02 11:37:27 +08:00
不胜舟 000330dbd7 feat(cli): 新增检查路由鉴权装饰器的命令 2025-09-01 20:22:54 +08:00
不胜舟 ad7b79129e 引入接口频率限制机制 2025-08-25 22:42:52 +08:00
不胜舟 4da732538f 引入接口频率限制机制 2025-08-25 22:01:33 +08:00
我叫以赏andGitee 041447585c !136 fix: 当在lxc容器运行时,获取磁盘信息失败
Merge pull request !136 from zmf96/master
2025-05-18 15:22:58 +00:00
zmf96andGitee 49d1db3571 fix: 当在lxc容器运行时,获取磁盘信息失败
Signed-off-by: zmf96 <1602893+zmf96@user.noreply.gitee.com>
2025-05-18 15:21:49 +00:00
我叫以赏andGitee d9286699bf !135 fix:打开基本资料页面后,刷新浏览器会引发js无法运行
Merge pull request !135 from 浩浩/master
2025-04-18 12:37:40 +00:00
dinghao d5dd7302ad fix:打开基本资料页面后,刷新浏览器会引发js无法运行 2025-04-18 18:40:52 +08:00
我叫以赏andGitee 9561e8ae00 更新 README
Signed-off-by: 我叫以赏 <lovepikachu@foxmail.com>
2025-03-02 04:31:58 +00:00
我叫以赏andGitee d9e690b5e9 !134 【轻量级 PR】:update applications/view/system/rights.py.
Merge pull request !134 from 星辰大海/N/A
2025-02-25 11:28:31 +00:00
星辰大海andGitee d49b3c1a79 update applications/view/system/rights.py.
管理员也需要隐藏不要显示的菜单。

Signed-off-by: 星辰大海 <2317903+shaodj@user.noreply.gitee.com>
2025-02-25 11:27:47 +00:00
我叫以赏andGitee 7340e45f4b Update: 同步项目 Pear Admin Layui 的框架
Signed-off-by: 我叫以赏 <lovepikachu@foxmail.com>
2025-02-13 02:17:34 +00:00
我叫以赏andGitee ee8bf1cbfc Update: 更新文档
Signed-off-by: 我叫以赏 <lovepikachu@foxmail.com>
2025-02-13 02:16:35 +00:00
我叫以赏andGitee 634dabb8cc !133 Update: 同步项目框架与 Pear Admin 4.0 同步,完善部分功能
Merge pull request !133 from 我叫以赏/master
2025-02-13 02:14:07 +00:00
wojiaoyishang 23db487356 添加文档构建章节 2025-01-31 10:51:11 +08:00
wojiaoyishang c5b70d90fb 更新文档与预览地址 2025-01-30 17:04:30 +08:00
wojiaoyishang 552bf77924 完善文档并添加示例插件 2025-01-30 16:56:12 +08:00
wojiaoyishang ac1f82e81d 基本完成文档 2025-01-30 10:31:50 +08:00
wojiaoyishang 41bda82c60 完善开发文档与调整初始化项目架构 2025-01-28 17:47:41 +08:00
wojiaoyishang da5917344e 更新文档,完善插件功能 2025-01-27 21:34:42 +08:00
wojiaoyishang feed91dcd0 完善文档最佳实践 2025-01-27 20:41:22 +08:00
wojiaoyishang 5153daf7da 更新文档 2025-01-27 19:48:27 +08:00
wojiaoyishang 155af93c77 完善开发文档与合并 admin_log 文件 2025-01-27 11:30:59 +08:00
wojiaoyishang 0db36a6218 完善开发文档与更新函数注释 2025-01-27 11:16:26 +08:00
wojiaoyishang de4f2c6d52 完善快速开始文档 2025-01-26 20:45:54 +08:00
wojiaoyishang fa6cc47063 更新 docker 构建 2025-01-26 16:38:32 +08:00
wojiaoyishang c9b08a07f4 修改搜索表单的按钮样式 2025-01-26 16:12:59 +08:00
wojiaoyishang 145ccbfb3e 修复权限按钮类型添加问题,修复日志页面表格未对齐问题 2025-01-26 16:04:05 +08:00
wojiaoyishang 40d0ea956d 更正 docker 配置文件 2025-01-25 14:08:43 +08:00
wojiaoyishang 184c4ad80d 更正插件与配置文件 2025-01-25 14:03:37 +08:00
wojiaoyishang ba7cc42628 数据库中的权限地址直接改为跳转之后的地址,防止 https 出现错误 2025-01-25 10:07:31 +08:00
我叫以赏andGitee cb5e352e8d fix: 修正序列化表述错误
Merge pull request !1 from 星辰大海/N/A
2025-01-25 01:52:12 +00:00
wojiaoyishang 8a99864b41 删除用于测试的 print 2025-01-25 09:51:02 +08:00
星辰大海andGitee de644f0f34 update applications/models/admin_dept.py.
Signed-off-by: 星辰大海 <2317903+shaodj@user.noreply.gitee.com>
2025-01-24 22:33:09 +00:00
wojiaoyishang 2ee703d2eb 错误处理页面进行更新 2025-01-24 19:13:41 +08:00
wojiaoyishang 3c4377915b Update: 增加后台消息接口 2025-01-24 18:44:53 +08:00
wojiaoyishang f9ad038805 Update: 增加主页页脚 2025-01-24 18:37:40 +08:00
wojiaoyishang e27fa53ec8 Update: 同步项目框架与 Pear Admin 4.0 同步,完善部分功能 2025-01-24 18:02:49 +08:00
wojiaoyishang ed1f402c04 Update: (步骤备份)完善邮件管理、文件上传的页面更改 2025-01-24 18:01:02 +08:00
wojiaoyishang 52838222db Update: (步骤备份)完善字典 2025-01-23 21:16:16 +08:00
wojiaoyishang de36a9936a Update: (步骤备份)完善系统监控 2025-01-21 20:58:37 +08:00
wojiaoyishang 3bb59d3786 Update: (步骤备份)修复刷新内置标签页的问题 2025-01-21 16:09:27 +08:00
wojiaoyishang fa7e62b12b Update: (步骤备份)部门管理更新完成 2025-01-21 12:07:37 +08:00
wojiaoyishang b6a64f15dc Update: (步骤备份)更新夜间模式同步 2025-01-21 11:02:03 +08:00
wojiaoyishang 813363f0d4 Update: (步骤备份)更新部门管理前端 2025-01-20 21:38:08 +08:00
wojiaoyishang 517d6f3d7f Update: (步骤备份)修复权限搜索 2025-01-20 21:13:10 +08:00
wojiaoyishang 83eb664a45 Update: (步骤备份)更改主题色通知方式 2025-01-20 16:44:39 +08:00
wojiaoyishang 61be7a09c6 Update: (步骤备份)选项管理修改完成,增加批量删除功能 2025-01-20 16:38:38 +08:00
wojiaoyishang 9f8fe2c83e Update: (步骤备份)角色管理页面更新完成并增加参数效验 2025-01-20 14:59:41 +08:00
wojiaoyishang 830b9b246d Update: (步骤备份)登录界面和用户管理页面修改完成。 2025-01-19 20:24:13 +08:00
wojiaoyishang d4a1b0ec57 Update: (步骤备份)更新 Pear Admin 框架版本与 Pear Admin Layui 一致,尝试重写页面 2025-01-19 20:03:18 +08:00
我叫以赏andGitee 43370ad078 更新预览地址
Signed-off-by: 我叫以赏 <lovepikachu@foxmail.com>
2024-12-27 13:00:52 +00:00
我叫以赏andGitee 22fa797809 !121 fix 字典分类提交时候,验证type_code 是否为空
Merge pull request !121 from jiangsi/N/A
2024-12-27 09:44:27 +00:00
我叫以赏andGitee 1c6ac42b96 !128 【轻量级PR】remove put session operation in index_curd.get_captcha
Merge pull request !128 from qxdo/master
2024-12-27 09:36:41 +00:00
我叫以赏andGitee 149f12ce18 !123 update templates/system/mail/main.html.
Merge pull request !123 from laoknas/N/A
2024-08-14 13:32:05 +00:00
我叫以赏andGitee a9b304ab5a !124 update templates/system/admin_log/main.html.
Merge pull request !124 from laoknas/N/A
2024-08-14 13:31:48 +00:00
我叫以赏andGitee b7833142b0 !125 update templates/system/monitor.html.
Merge pull request !125 from laoknas/N/A
2024-08-14 13:31:29 +00:00
qxdoandGitee 6def11b009 移除内部session操作,会导致session首次创建不一致的问题
Signed-off-by: qxdo <qxdo123@gmail.com>
2024-08-14 12:28:46 +00:00
不胜舟andGitee 15b5fa4dd8 !127 【fix】修复修改主题为light-theme后菜单栏选中菜单名称不显示问题
Merge pull request !127 from redoing/master
2024-08-08 04:04:11 +00:00
redoing bbc545494b fix#修复修改主题为light-theme后菜单栏选中菜单名称不显示问题
修改主题theme.js中颜色和次要颜色逻辑
2024-08-08 11:03:25 +08:00
不胜舟andGitee 4b26a5ddcc !126 update templates/system/console/console.html.
Merge pull request !126 from laoknas/N/A
2024-07-25 06:08:22 +00:00
laoknasandGitee a7e0b18396 update templates/system/console/console.html.
src="{{ url_for('static', filename='system/admin/images/avatar.jpg') }}" avatar.jpg路径错误

Signed-off-by: laoknas <kuai410022283@qq.com>
2024-07-25 05:48:07 +00:00
laoknasandGitee da83a2a3f2 update templates/system/monitor.html.
<link rel="stylesheet" href="{{ url_for('static', filename='system/admin/css/other/console2.css') }}"/> css路径错误

Signed-off-by: laoknas <kuai410022283@qq.com>
2024-07-25 05:24:24 +00:00
laoknasandGitee 0037087c1e update templates/system/admin_log/main.html.
Signed-off-by: laoknas <kuai410022283@qq.com>
2024-07-25 05:19:43 +00:00
laoknasandGitee 5266ea4fc6 update templates/system/mail/main.html.
Signed-off-by: laoknas <kuai410022283@qq.com>
2024-07-25 05:18:46 +00:00
我叫以赏andGitee dad768cb18 更新 requirements.txt
Signed-off-by: 我叫以赏 <lovepikachu@foxmail.com>
2024-06-26 05:56:49 +00:00
我叫以赏andGitee 175b3f5f5b !106 更新 applications/common/helper.py 中函数注释的错误
Merge pull request !106 from 星辰大海/N/A
2024-06-26 02:36:54 +00:00
我叫以赏andGitee eb4f95e2bd !101 更新 docs/install.md.docs 中一处描述错误
Merge pull request !101 from z601350993/N/A
2024-06-26 02:36:21 +00:00
不胜舟andGitee a8642dcf29 !122 更新 README.md 使项目介绍更加完善
Merge pull request !122 from 我叫以赏/master
2024-06-26 02:28:01 +00:00
我叫以赏andGitee 1cadad5076 更新需要忽略的文件
Signed-off-by: 我叫以赏 <lovepikachu@foxmail.com>
2024-06-26 02:25:12 +00:00
我叫以赏andGitee fabe7a09bf doc: 更新 README 文档
Signed-off-by: 我叫以赏 <lovepikachu@foxmail.com>
2024-06-26 02:23:38 +00:00
我叫以赏andGitee 874084492e update applications/common/utils/validate.py.
新版本将会移除 flask.escape 按照官方说法使用 markupsafe.escape  代替,可以避免 Python Warning。

Signed-off-by: 我叫以赏 <lovepikachu@foxmail.com>
2024-02-10 04:21:13 +00:00
我叫以赏andGitee dff1cc18ce fix: 移出不必要的导入
Signed-off-by: 我叫以赏 <lovepikachu@foxmail.com>
2024-02-10 04:08:09 +00:00
不胜舟 21408f1bf3 add flask-session扩展 2024-01-14 17:56:46 +08:00
不胜舟 f59767096b fix 记住密码 2024-01-12 21:29:27 +08:00
不胜舟 57f2ce1413 add flask-session扩展 2024-01-12 15:52:27 +08:00
jiangsiandGitee 60674d7ab7 数据字典功能,具体的字典需要依赖type_code 和字典分类关联,需要在添加字典分类的时候验证是否填写了typecode,不然会出现type_code 为none的情况。 并且type_code在字典分类的编辑状态为readonly的。
Signed-off-by: jiangsi <jiangsi@gmail.com>
2023-12-24 18:39:56 +00:00
不胜舟 e984df7cd0 update README.md 2023-12-07 14:53:19 +08:00
不胜舟 1245dc541f 增加flask-wtf,引入csrf token验证 2023-12-07 14:00:29 +08:00
不胜舟andGitee 61f82f826b !119 修复登录页面验证码刷新问题
Merge pull request !119 from Huo-WenBo/master
2023-11-22 05:57:33 +00:00
HuoWenBo 5ea61e40d0 Merge remote-tracking branch 'origin/master' 2023-11-19 12:01:50 +08:00
HuoWenBo c09bca8419 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	templates/system/login.html
2023-11-19 12:00:53 +08:00
HuoWenBo 05d19f1dad Merge remote-tracking branch 'origin/master'
# Conflicts:
#	templates/system/login.html
2023-11-18 12:54:00 +08:00
HuoWenBo 9aa7420da7 🔧修复验证码刷新BUG 2023-11-18 12:53:46 +08:00
HuoWenBo a476856ce1 🔧修复验证码刷新BUG 2023-11-18 12:49:26 +08:00
HuoWenBo 009ab8effe Merge remote-tracking branch 'origin/master'
# Conflicts:
#	requirements.txt
2023-11-18 12:46:28 +08:00
不胜舟andGitee 749e25d22d !111 【轻量级 PR】:update templates/system/power/add.html.
Merge pull request !111 from Teishorin/N/A
2023-11-15 07:10:52 +00:00
不胜舟andGitee 8ca48511e9 !112 【轻量级 PR】:update templates/system/power/edit.html.
Merge pull request !112 from Teishorin/N/A
2023-11-15 07:10:26 +00:00
不胜舟 2683e1214c 修复用户管理部门树显示,回退sqlalchemy引用,修复头像修改 2023-11-14 19:44:38 +08:00
不胜舟andGitee af6fb5d273 !117 update applications/view/system/dept.py.
Merge pull request !117 from 陈心巧/N/A
2023-11-10 05:08:49 +00:00
陈心巧andGitee 2e806f1dcf update applications/view/system/dept.py.
修正部门不能更新bug。

id = json.get("deptId"),  会得到tuple
在其后的 Dept.query.filter_by(id=id) , 会生成形如 update 。。。。 from xx where id=( id, ) 的sql,导致执行sql失败
#id = json.get("deptId"), 
改成 
id = str_escape(json.get("deptId")) 即可。

Signed-off-by: 陈心巧 <12432082+chenxinqiao@user.noreply.gitee.com>
2023-11-09 08:11:17 +00:00
不胜舟andGitee 8a51e19cc0 !116 【轻量级 PR】:update templates/system/dept/main.html.
Merge pull request !116 from 陈心巧/N/A
2023-11-09 08:05:45 +00:00
陈心巧andGitee f02362859d update templates/system/dept/main.html.
点击编辑按钮后,obj.data['deptId'] 因其未定义会抛出错误,导致ajax请求获取该项内容失败,导致该项待编辑内容不能读取。改成 obj.data['id'] 即可。

Signed-off-by: 陈心巧 <12432082+chenxinqiao@user.noreply.gitee.com>
2023-11-09 08:05:03 +00:00
不胜舟andGitee a37e6928d2 !115 【轻量级 PR】:update docs/install.md.
Merge pull request !115 from 陈心巧/N/A
2023-09-22 04:23:31 +00:00
陈心巧andGitee dbb03e3e44 update docs/install.md.
requirement.txt 改成了requirements.txt。

bat 是 win下的, 这里原来写反了。

另外 在执行 flask db init 会报错导入不了BaseQuery,因为  Flask-SQLAlchemy 在3.0后,BaseQuery改成了Query。

Signed-off-by: 陈心巧 <12432082+chenxinqiao@user.noreply.gitee.com>
2023-09-22 04:15:08 +00:00
不胜舟andGitee 4a55d8feb2 !114 更新一些小 BUG
Merge pull request !114 from Huo-WenBo/master
2023-09-21 09:36:35 +00:00
Huo-WenBoandGitee 9abb4ae6a9 修复 from flask_sqlalchemyimport BaseQuery 不能导入的问题
Signed-off-by: Huo-WenBo <1684240320@qq.com>
2023-09-21 06:44:33 +00:00
Huo-WenBoandGitee 09c210363e update requirements.txt.
重新生成项目 requirements.txt

Signed-off-by: Huo-WenBo <1684240320@qq.com>
2023-09-21 06:43:20 +00:00
HuoWenBo 895377714c 重新生成 requirements.txt 需求文件
修复了 from flask_sqlalchemy import BaseQuery 不能导入的问题
2023-09-21 14:26:16 +08:00
HuoWenBo 0db116d729 Merge remote-tracking branch 'origin/master' 2023-09-21 14:17:15 +08:00
TeishorinandGitee e4e04f5c1a update templates/system/power/edit.html.
将排序改为必填项,防止因为没有填写排序号导致左侧导航条消失的问题

Signed-off-by: Teishorin <shandongdxl2004@126.com>
2023-09-08 11:26:45 +00:00
TeishorinandGitee db118f04ff update templates/system/power/add.html.
把排序列为必填项,防止因为没有填写序号导致左侧边栏消失

Signed-off-by: Teishorin <shandongdxl2004@126.com>
2023-09-08 11:22:18 +00:00
VincentFuand不胜舟 201ba7915e !107 【轻量级 PR】:update applications/common/utils/validate.py.
Merge pull request !107 from VincentFu/N/A
2023-08-16 07:14:31 +00:00
星辰大海andGitee 7ea6fd0f58 update applications/common/helper.py.
小于描述错误

Signed-off-by: 星辰大海 <shaodj@wxxingda.com>
2023-08-13 00:38:04 +00:00
不胜舟andGitee fa48eac75a !105 更改内存硬盘单位自适应
Merge pull request !105 from Huo-WenBo/master
2023-07-25 03:54:29 +00:00
HuoWenBo e199410bcb 更改内存单位显示为自适应 2023-07-25 11:49:59 +08:00
HuoWenBo c058e03fbf 更改内存硬盘单位自适应 2023-07-25 11:40:54 +08:00
不胜舟andGitee 10ae5f39f0 !102 【轻量级 PR】:启动时报错“No module named 'validators'”
Merge pull request !102 from z601350993/N/A
2023-07-16 12:03:05 +00:00
不胜舟andGitee dbea226c89 !103 【轻量级 PR】:update templates/system/dept/main.html.
Merge pull request !103 from 星辰大海/N/A
2023-07-16 10:34:31 +00:00
星辰大海andGitee 442279ab0d update templates/system/dept/main.html.
Signed-off-by: 星辰大海 <shaodj@wxxingda.com>
2023-07-16 09:56:53 +00:00
z601350993andGitee a2c6de82df 启动时报错“No module named 'validators'”
Signed-off-by: z601350993 <601350993@qq.com>
2023-07-15 23:06:41 +00:00
z601350993andGitee 757bafa8d2 update docs/install.md.docs
#### 运行项目   
windows 和 linux 写反了。

Signed-off-by: z601350993 <601350993@qq.com>
2023-07-15 22:51:23 +00:00
我叫以赏andGitee 4cabce3a8b fix: 修复插件报错提示 2023-07-04 05:39:47 +00:00
不胜舟andGitee c8140399c4 !99 权限管理 编辑、移除按钮失效
Merge pull request !99 from finalpro/master
2023-06-26 12:57:58 +00:00
finalproandGitee 5596f32940 修复编辑、移除按钮动作传参取值
Signed-off-by: finalpro <finalpro@163.com>
2023-06-25 13:11:21 +00:00
不胜舟 6cd58bbd9d fix: 部门禁用 2023-06-21 19:43:33 +08:00
不胜舟 90b6564ce6 完善文档 2023-06-21 01:44:52 +08:00
不胜舟 917579684e 去除首页面,优化命名,优化schema 2023-06-21 01:25:44 +08:00
不胜舟 4e7a85bead 去除首页面,优化命名,优化schema 2023-06-21 01:15:56 +08:00
不胜舟 d71e2e220f 去除logo 2023-05-31 00:12:30 +08:00
不胜舟 f60a7dfd85 精简功能 2023-05-30 18:46:30 +08:00
不胜舟 b3b5547116 Merge remote-tracking branch 'origin/master' 2023-05-30 01:00:16 +08:00
不胜舟 3690b8c22a 精简功能 2023-05-30 01:00:06 +08:00
六翼羊王 c6b71d2dfe 修改docker-compose文件路径 2023-05-29 07:48:55 +00:00
六翼羊王andGitee 47dffad5a7 删除文件 start.sh 2023-05-29 07:44:16 +00:00
不胜舟 c760302ff8 更改项目启动方式 2023-05-29 00:26:42 +08:00
不胜舟andGitee 384385dd10 !71 【轻量级 PR】:请求中的 Content-Type 不是 application/json,则从 request.values 中读取请求参数
Merge pull request !71 from funewa/N/A
2023-05-18 03:26:17 +00:00
不胜舟andGitee 7c68c8f572 !95 图标选择器不回显
Merge pull request !95 from BuerYouth/N/A
2023-05-11 04:11:47 +00:00
BuerYouthandGitee c035e024e4 fix:修复图标选择器不回显
Signed-off-by: BuerYouth <bueryouth@qq.com>
2023-05-10 12:26:02 +00:00
六翼羊王andGitee 7743580873 update README.md.
Signed-off-by: 六翼羊王 <1993333339@qq.com>
2023-05-07 09:57:52 +00:00
六翼羊王andGitee d4dcb55a36 update dockercompose.yaml.
Signed-off-by: 六翼羊王 <1993333339@qq.com>
2023-05-07 09:26:50 +00:00
六翼羊王andGitee 7ce0e0192e update dockercompose.yaml.
Signed-off-by: 六翼羊王 <1993333339@qq.com>
2023-05-07 09:26:01 +00:00
六翼羊王andGitee 1cf2a6a2e2 update .dockerignore.
Signed-off-by: 六翼羊王 <1993333339@qq.com>
2023-05-07 09:18:41 +00:00
六翼羊王andGitee 8e4d9a9151 update Dockerfile.
Signed-off-by: 六翼羊王 <1993333339@qq.com>
2023-05-07 09:18:22 +00:00
六翼羊王andGitee 65bf586d0c update Dockerfile.
Signed-off-by: 六翼羊王 <1993333339@qq.com>
2023-05-07 09:18:06 +00:00
六翼羊王andGitee 9896d87968 update start.sh.
Signed-off-by: 六翼羊王 <1993333339@qq.com>
2023-05-07 09:17:18 +00:00
六翼羊王andGitee 9d618d1fde update Dockerfile.
Signed-off-by: 六翼羊王 <1993333339@qq.com>
2023-05-07 08:36:57 +00:00
六翼羊王andGitee 12a6baf599 !94 修改了docker 一键部署,测试OK
Merge pull request !94 from Ranee/master
2023-05-07 08:18:37 +00:00
ranee@ubuntu 157a83711f 修改了基于docker 一键部署的Dockerfile 和dockercompose.yaml 2023-05-07 02:34:58 +08:00
ranee@ubuntu 0d0998ec3c 移动到了dockerdata 文件中 2023-05-07 02:33:03 +08:00
ranee@ubuntu 7ceb114822 移动到了dockerdata 文件中 2023-05-07 02:32:18 +08:00
ranee@ubuntu acc1b7d10b 修改了docker部署部分 2023-05-07 02:31:46 +08:00
ranee@ubuntu 6685310b5f 移动到dockerdata 文件夹中 2023-05-07 02:31:08 +08:00
ranee@ubuntu 631d86e22a 去掉了requirement,会导致主目录的同名文件被忽略 2023-05-07 02:30:37 +08:00
六翼羊王andGitee f45d202af6 update dockerdata/restart_mysql_data.sh.
Signed-off-by: 六翼羊王 <1993333339@qq.com>
2023-05-03 06:46:58 +00:00
六翼羊王andGitee 85d60365cf update README.md.
Signed-off-by: 六翼羊王 <1993333339@qq.com>
2023-05-03 03:36:07 +00:00
六翼羊王andGitee 1c4edb9cee 重置docker数据库
Signed-off-by: 六翼羊王 <1993333339@qq.com>
2023-05-03 03:34:30 +00:00
六翼羊王andGitee 251cd14810 update dockerdata/start.sh.
Signed-off-by: 六翼羊王 <1993333339@qq.com>
2023-05-03 03:23:21 +00:00
不胜舟andGitee 575fc3fa88 !90 【轻量级 PR】:fix database connect fail when password contain "@"
Merge pull request !90 from 彭华林/N/A
2023-05-02 17:12:01 +00:00
彭华林andGitee 31a6c83222 fix database connect fail when password contain "@"
# 
MYSQL_PASSWORD = "test@1234"
python app.py will run fail.

Signed-off-by: 彭华林 <12873625+hualpeng@user.noreply.gitee.com>
2023-05-02 16:31:59 +00:00
不胜舟andGitee a340533ff9 update applications/extensions/init_sqlalchemy.py.
Signed-off-by: 不胜舟 <1650473152@qq.com>
2023-04-26 14:07:50 +00:00
不胜舟 361e3c1bba edit md 2023-04-26 11:42:44 +08:00
六翼羊王andGitee 5b5e989f8a update docker-compose.yml.
Signed-off-by: 六翼羊王 <1993333339@qq.com>
2023-04-21 01:34:07 +00:00
六翼羊王andGitee 6092922bc2 update README.md.
Signed-off-by: 六翼羊王 <1993333339@qq.com>
2023-04-17 08:01:08 +00:00
六翼羊王andGitee c2d12db216 update dockerdata/start.sh.
修改启动文件

Signed-off-by: 六翼羊王 <1993333339@qq.com>
2023-04-17 07:29:39 +00:00
六翼羊王andGitee 392301bec5 update dockerdata/start.sh.
Signed-off-by: 六翼羊王 <1993333339@qq.com>
2023-04-17 07:26:16 +00:00
root 39b8a43685 修改docker-compose部署方式 2023-04-17 07:20:51 +00:00
我叫以赏andGitee 08713149b1 doc: 补充 README.md 文件缺失图片
Signed-off-by: 我叫以赏 <lovepikachu@foxmail.com>
2023-04-16 22:01:11 +00:00
不胜舟andGitee cbe5894c1d !89 doc: 完善 master 分支文档,并移除不需要的图片 & fix: 简化插件结构同步主项目
Merge pull request !89 from 我叫以赏/master
2023-04-16 08:57:37 +00:00
我叫以赏 72bf49d19e doc: 删除多余文件 2023-04-16 16:51:57 +08:00
我叫以赏 1c11295163 fix: 优化插件目录结构,默认不启用插件 2023-04-16 16:48:49 +08:00
我叫以赏 9e6b085ac5 fix: 修复插件不能使用的问题 2023-04-16 16:47:19 +08:00
我叫以赏 a1a318da44 doc: 更新 master 分支文档,并移除不需要的图片 2023-04-16 16:46:43 +08:00
我叫以赏 c86488d5bd doc: 更新 master 分支文档,并移除不需要的图片 2023-04-16 16:43:44 +08:00
我叫以赏 a88af0091b doc: 更新 master 分支文档,并移除不需要的 2023-04-16 16:41:56 +08:00
我叫以赏 0569d32784 doc: 更新 master 文档,并移除多余图片 2023-04-16 16:38:18 +08:00
我叫以赏 d71f8ce368 修复在更新目录结构后插件无法使用的问题 2023-04-16 15:55:29 +08:00
六翼羊王 787c657818 dockerignore文件 2023-04-13 01:22:48 +08:00
六翼羊王 5c9d8f4c36 修改docker-compose部署方式 2023-04-13 01:20:57 +08:00
不胜舟andGitee 7e6d48510c !87 修改docker-compose部署方式
Merge pull request !87 from 六翼羊王/master
2023-04-11 02:16:55 +00:00
六翼羊王 4a93167036 服务镜像构建文件
rename

增加docker-compose部署方式

提交docker-compose.yml

完整镜像构建文件

环境镜像构建文件

服务镜像构建文件

docker-compose方式使用的config.py

MySQL初始化数据

MySQL配置文件

rename

服务镜像启动脚本
2023-04-11 18:02:05 +08:00
不胜舟andGitee ab4a13bd4f !85 修正封装js命名问题
Merge pull request !85 from jmj/origin
2023-04-10 04:45:56 +00:00
jmj 4e20962935 修正icon封装js命名问题---会导致权限管理页面父级显示问题。 2023-04-10 12:36:21 +08:00
不胜舟 964d81a377 edit md 2023-04-07 02:14:05 +08:00
不胜舟 e18a50ba30 修复命令行工具 2023-04-06 20:08:27 +08:00
不胜舟 34f12842b0 rename 2023-04-06 17:50:02 +08:00
不胜舟 24aed42d69 Merge remote-tracking branch 'origin/master' 2023-04-06 17:44:00 +08:00
不胜舟 0b4dbf4259 简化配置文件 2023-04-06 17:43:27 +08:00
不胜舟andGitee 38301b8376 update README.md.
Signed-off-by: 不胜舟 <1650473152@qq.com>
2023-04-06 09:01:44 +00:00
不胜舟andGitee 347ee49d46 删除文件 pear.sql 2023-04-06 09:00:30 +00:00
不胜舟 5d10a304b3 更新前端 2023-04-06 16:41:48 +08:00
不胜舟 2364ebb519 修改文档 2023-04-06 16:25:04 +08:00
不胜舟 c5a8a1a653 修复json请求获取错误 2023-04-06 16:19:59 +08:00
不胜舟 186cc7e5a1 Merge remote-tracking branch 'origin/master' 2023-04-06 16:13:58 +08:00
不胜舟 43fd86a2ce 修改文档 2023-04-06 16:13:31 +08:00
不胜舟andGitee 08b2249e45 删除文件 migrations 2023-04-06 08:11:54 +00:00
不胜舟 bced84f92d 修改数据库初始化逻辑,完善权限,废弃flaskenv 2023-04-06 16:09:49 +08:00
不胜舟 269cc154ab 规范python包文件名,更新群聊 2023-04-05 23:53:44 +08:00
不胜舟 ac333f6027 修复图片显示错误 2023-03-12 23:13:42 +08:00
不胜舟 16e04fd8f2 增加参数验证 2023-03-08 14:09:36 +08:00
不胜舟andGitee 75adf7393c !79 【轻量级 PR】:update applications/view/admin/user.py.
Merge pull request !79 from ω•ƒεï/N/A
2023-03-08 03:30:57 +00:00
ω•ƒεïandGitee 2a523a3b41 update applications/view/admin/user.py.
用户管理 分页插件报错,TypeError: 'Pagination' object is not iterable,需要query 改成query.items解决

Signed-off-by: ω•ƒεï <164175317@qq.com>
2023-03-08 02:32:14 +00:00
不胜舟 990b8dd73e 修改用户管理bug 2023-02-19 18:01:44 +08:00
不胜舟 3a73465365 修复用户管理bug 2023-02-18 23:10:22 +08:00
不胜舟andGitee df756f9f0b !75 插件功能修改
Merge pull request !75 from 我叫以赏/master
2023-02-13 05:36:07 +00:00
我叫以赏andGitee fb993d3ade upload
Signed-off-by: 我叫以赏 <422880152@qq.com>
2023-02-13 00:26:48 +00:00
我叫以赏andGitee 0fb0937a8e update applications/common/utils/validate.py.
Signed-off-by: 我叫以赏 <422880152@qq.com>
2023-02-13 00:22:20 +00:00
我叫以赏andGitee 7c91bb3e0d update applications/common/utils/validate.py.
Signed-off-by: 我叫以赏 <422880152@qq.com>
2023-02-13 00:21:43 +00:00
我叫以赏andGitee c849c5cfed update applications/common/curd.py.
Signed-off-by: 我叫以赏 <422880152@qq.com>
2023-02-13 00:20:58 +00:00
我叫以赏andGitee c0b179eff1 update plugins/realip/__init__.py.
Signed-off-by: 我叫以赏 <422880152@qq.com>
2023-02-12 23:27:03 +00:00
我叫以赏andGitee 910227ef0c 删除文件 applications/common/utils/console.py 2023-02-12 23:26:04 +00:00
我叫以赏andGitee 3dcc167c27 删除文件 applications/common/utils/role.py 2023-02-12 23:25:08 +00:00
我叫以赏andGitee bf7f826250 删除文件 applications/common/utils/user.py 2023-02-12 23:24:59 +00:00
我叫以赏andGitee 25abd7e564 删除文件 applications/common/utils/power.py 2023-02-12 23:24:46 +00:00
我叫以赏andGitee 035dd21d11 删除文件 applications/common/utils/department.py 2023-02-12 23:24:36 +00:00
wojiaoyishang 9c920fefcc 修正init 2023-02-12 12:29:20 +08:00
wojiaoyishang 8e9d3519e4 修正init 2023-02-12 12:24:25 +08:00
wojiaoyishang e2ec08df62 更新注释 2023-02-11 20:48:07 +08:00
wojiaoyishang 6ef0de9d9e 更新注释 2023-02-11 20:39:12 +08:00
我叫以赏andGitee c7448f7833 删除文件 .flaskenv 2023-02-11 12:34:31 +00:00
wojiaoyishang a3c9ba1e3a 更新注释 2023-02-11 20:32:33 +08:00
wojiaoyishang 54ac9cefa3 更新注释 2023-02-11 20:29:56 +08:00
wojiaoyishang 196d53aa88 保留原邮件发送函数 2023-02-11 13:52:58 +08:00
wojiaoyishang 67b1462453 dev文件夹合并至utils 2023-02-11 13:34:46 +08:00
wojiaoyishang 0ad00448d8 移除表plugin_setting 2023-02-11 12:37:16 +08:00
wojiaoyishang 5b8c441b8b 去除插开发者信息 2023-02-11 08:43:48 +08:00
wojiaoyishang 7f7e142dc4 去除插开发者信息 2023-02-11 08:28:23 +08:00
不胜舟andGitee d36d312c9b !72 更新插件功能
Merge pull request !72 from 我叫以赏/master
2023-02-10 15:59:52 +00:00
wojiaoyishang b92c450be4 更新插件功能 2023-02-10 23:05:54 +08:00
wojiaoyishang 7bef64f632 更新插件功能 2023-02-10 21:29:39 +08:00
funewaandGitee 133774ced7 请求中的 Content-Type 不是 application/json,则从 request.values 中读取请求参数
Signed-off-by: funewa <yuge@funewa.com>
2023-02-07 06:19:53 +00:00
不胜舟andGitee 51db1944ed !60 update templates/admin/user/main.html.
Merge pull request !60 from Teishorin/N/A
2023-02-07 05:26:11 +00:00
不胜舟andGitee 23aebc2513 !58 update templates/admin/console/console.html.
Merge pull request !58 from Teishorin/N/A
2023-02-07 05:25:59 +00:00
不胜舟andGitee 417471bd79 !59 update templates/admin/common/footer.html.
Merge pull request !59 from Teishorin/N/A
2023-02-07 05:25:48 +00:00
不胜舟andGitee 61f0ea26a0 !61 update templates/admin/admin_log/main.html.
Merge pull request !61 from Teishorin/N/A
2023-02-07 05:25:37 +00:00
不胜舟andGitee 54a9acaf18 !62 update templates/admin/console/console.html.
Merge pull request !62 from Teishorin/N/A
2023-02-07 05:25:23 +00:00
不胜舟andGitee d6404fc1b4 !63 update templates/admin/mail/main.html.
Merge pull request !63 from Teishorin/N/A
2023-02-07 05:25:01 +00:00
不胜舟andGitee d4a298234d !64 update templates/admin/monitor.html.
Merge pull request !64 from Teishorin/N/A
2023-02-07 05:24:51 +00:00
不胜舟andGitee bd494c5aa1 !65 update templates/index/index.html.
Merge pull request !65 from Teishorin/N/A
2023-02-07 05:24:07 +00:00
不胜舟andGitee 0e5a389c12 !67 update applications/configs/config.py.
Merge pull request !67 from Teishorin/N/A
2023-02-07 05:23:32 +00:00
不胜舟andGitee b178f7fea5 !69 update applications/common/admin_log.py.
Merge pull request !69 from funewa/N/A
2023-02-07 05:21:45 +00:00
funewaandGitee 2c89cdb344 update applications/common/admin_log.py.
BUG修复原来只记录GET数据-》更新后可以记录POST PUT记录

Signed-off-by: funewa <yuge@funewa.com>
2023-02-07 04:15:27 +00:00
TeishorinandGitee 9189e0cb58 update applications/configs/config.py.
新增配置多数据库连接的连接串写法示例
涵盖 MySQL、MsSQL、Oracle、SQLite、Postgres

Signed-off-by: Teishorin <shandongdxl2004@126.com>
2023-01-24 07:52:20 +00:00
TeishorinandGitee 7368cb58b7 update templates/index/index.html.
修正url_for函数拼接静态文件路径可能发生的308错误:filename这里的路径不需要以“/”开头

Signed-off-by: Teishorin <shandongdxl2004@126.com>
2023-01-24 07:37:41 +00:00
TeishorinandGitee 627c51df8a update templates/admin/monitor.html.
修正url_for函数拼接静态文件路径可能发生的308错误:filename这里的路径不需要以“/”开头

Signed-off-by: Teishorin <shandongdxl2004@126.com>
2023-01-24 07:35:37 +00:00
TeishorinandGitee 3bf759aaac update templates/admin/mail/main.html.
修正url_for函数拼接静态文件路径可能发生的308错误:filename这里的路径不需要以“/”开头

Signed-off-by: Teishorin <shandongdxl2004@126.com>
2023-01-24 07:34:36 +00:00
TeishorinandGitee 7d406a9b06 update templates/admin/console/console.html.
修正url_for函数拼接静态文件路径可能发生的308错误:filename这里的路径不需要以“/”开头

Signed-off-by: Teishorin <shandongdxl2004@126.com>
2023-01-24 07:33:50 +00:00
TeishorinandGitee 42895b182f update templates/admin/admin_log/main.html.
修正url_for函数拼接静态文件路径可能发生的308错误:filename这里的路径不需要以“/”开头

Signed-off-by: Teishorin <shandongdxl2004@126.com>
2023-01-24 07:32:43 +00:00
TeishorinandGitee 21ba21bdf6 update templates/admin/user/main.html.
修正url_for函数拼接静态文件路径可能发生的308错误:filename这里的路径不需要以“/”开头

Signed-off-by: Teishorin <shandongdxl2004@126.com>
2023-01-24 07:31:18 +00:00
TeishorinandGitee 18d773ed9b update templates/admin/common/footer.html.
修正url_for函数拼接静态文件路径可能发生的308错误:filename这里的路径不需要以“/”开头

Signed-off-by: Teishorin <shandongdxl2004@126.com>
2023-01-24 07:27:29 +00:00
TeishorinandGitee f1fc957e3d update templates/admin/console/console.html.
修复URL拼接错误,在flask 2.2.2中,不修改的话会报如下错误:
127.0.0.1 - - [23/Jan/2023 23:07:45] "GET /static//admin/admin/css/other/console1.css HTTP/1.1" 308 -

Signed-off-by: Teishorin <shandongdxl2004@126.com>
2023-01-23 15:10:42 +00:00
不胜舟andGitee fd42cae20f !57 update templates/admin/login.html.
Merge pull request !57 from Teishorin/N/A
2023-01-23 10:02:03 +00:00
不胜舟andGitee 0b3406b55e !56 update templates/admin/common/header.html.
Merge pull request !56 from Teishorin/N/A
2023-01-23 09:59:59 +00:00
TeishorinandGitee 1b63075333 update templates/admin/login.html.
修正地址拼接的 308异常,删除多余的“/”,否则会显示:
“127.0.0.1 - - [23/Jan/2023 17:10:18] "GET /static//admin/admin/css/other/login.css HTTP/1.1" 308 -”
“127.0.0.1 - - [23/Jan/2023 17:10:18] "GET /static//admin/component/pear/css/pear.css HTTP/1.1" 308 -”

测试环境,python3.8  Flask2.2.2

Signed-off-by: Teishorin <shandongdxl2004@126.com>
2023-01-23 09:18:52 +00:00
TeishorinandGitee 2c94ad94e6 update templates/admin/common/header.html.
修正地址拼接的 308异常,删除多余的“/”,否则会显示:“127.0.0.1 - - [23/Jan/2023 17:10:18] "GET /static//admin/component/pear/css/pear.css HTTP/1.1" 308 -”

Signed-off-by: Teishorin <shandongdxl2004@126.com>
2023-01-23 09:14:50 +00:00
不胜舟 83964bfa78 修改鉴权逻辑 --> 管理员获得访问全部权限白名单 2022-12-28 01:38:40 +08:00
不胜舟andGitee b2207e277f !49 加入 Flask-APScheduler 設定
Merge pull request !49 from CHunYenc/master
2022-12-27 16:45:37 +00:00
不胜舟andGitee 017272cbde !51 添加邮件管理模块
Merge pull request !51 from mengfu188/master
2022-12-27 16:43:31 +00:00
不胜舟 f6d7d7d84c edit md 2022-12-28 00:34:08 +08:00
不胜舟 123f62f9d5 优化验证与过滤 2022-12-28 00:21:20 +08:00
不胜舟 656548eb64 remove 2022-12-27 22:59:18 +08:00
不胜舟 60cf94cc05 同步pear admin layui 2022-12-27 22:54:35 +08:00
不胜舟 883e7f88d5 优化用户部门查询逻辑 2022-12-27 21:56:31 +08:00
不胜舟 078b838374 更改验证码,简化目录 2022-12-27 20:48:50 +08:00
mengfu188 490302960a 添加邮件管理模块 2022-10-11 15:51:15 +08:00
CHunYenc d865081199 fix: 無法刪除 task. 2022-09-17 15:07:03 +08:00
CHunYenc 8e356923c7 feat: scheduler 儲存於 SQLAlchemyJobStore, 並在定時輸入時間時加入判斷. 2022-09-17 14:44:23 +08:00
zhengxinonly c811b895d7 修改说明文档 2022-08-31 04:31:17 +08:00
逆天的蝈蝈and不胜舟 8c4039dbb2 !47 del qq group and add issue
* 😊 del qq group and add issue
2022-08-16 08:52:02 +00:00
就眠儀式andGitee 8ca32d3d77 update README.md. 2022-08-04 05:39:29 +00:00
1037 changed files with 220284 additions and 103750 deletions
+11
View File
@@ -0,0 +1,11 @@
# 构建的相关文件
README.md
# Python 相关文件
migrations/
instance/
flask_session/
venv/
.idea/
*.log
*.db
+40
View File
@@ -0,0 +1,40 @@
# ============================================================
# Pear Admin Flask - 环境变量示例
# ============================================================
# 用法:
# 1) 复制本文件:cp .env.example .env
# 2) 按需修改 .env 中的值(不要提交 .env 到 git)
# 3) 应用启动时由 applications/__init__.py 自动 load_dotenv 读取
#
# 加载优先级:系统环境变量 > .env > 代码默认值
# ============================================================
# ---- 运行模式 ----
# dev: 开发模式(DEBUG=True,自动生成 SECRET_KEY,无需外部传入)
# production: 生产模式(DEBUG=False,强制要求 SECRET_KEY
PEAR_ENV=dev
# ---- 安全 ----
# 生产模式必填。可用以下命令生成:
# python -c "import secrets; print(secrets.token_urlsafe(48))"
SECRET_KEY=
# ---- 数据库 ----
# 默认是项目根目录下的 SQLite pear.db;生产推荐 MySQL。
# MySQL 示例:
# SQLALCHEMY_DATABASE_URI=mysql+pymysql://user:pass@127.0.0.1:3306/pear?charset=utf8mb4
# SQLALCHEMY_DATABASE_URI=
# ---- 邮件(Flask-Mail----
# QQ 邮箱示例
MAIL_SERVER=smtp.qq.com
MAIL_USE_TLS=false
MAIL_USE_SSL=true
MAIL_PORT=465
MAIL_USERNAME=your_account@qq.com
MAIL_PASSWORD=your_authorization_code_here
MAIL_DEFAULT_SENDER=your_account@qq.com
# ---- Flask ----
# FLASK_APP=app.py
# FLASK_DEBUG=1
-29
View File
@@ -1,29 +0,0 @@
# flask配置
FLASK_APP=app.py
FLASK_ENV=development
FLASK_DEBUG=1
FLASK_RUN_HOST = 127.0.0.1
FLASK_RUN_PORT = 5000
# pear admin flask配置
SYSTEM_NAME = Pear Admin
# MySql配置信息
MYSQL_HOST=127.0.0.1
# MYSQL_HOST=dbserver
MYSQL_PORT=3306
MYSQL_DATABASE=PearAdminFlask
MYSQL_USERNAME=root
MYSQL_PASSWORD=123456
# Redis 配置
# REDIS_HOST=127.0.0.1
# REDIS_PORT=6379
# 密钥配置(记得改)
SECRET_KEY='pear-admin-flask'
# 邮箱配置
MAIL_SERVER='smtp.qq.com'
MAIL_USERNAME='123@qq.com'
MAIL_PASSWORD='XXXXX' # 生成的授权码
+37 -1
View File
@@ -114,5 +114,41 @@ dmypy.json
# Pyre type checker # Pyre type checker
.pyre/ .pyre/
#ide # idea
.idea/ .idea/
# 迁移文件
migrations/
# sqlite
*.db
# 文件上传
static/upload/
# flask session
flask_session/
# WorkBuddy 运行时目录(不应纳入版本管理)
.workbuddy/
# ---- Pear Admin Flask 额外忽略 ----
# 私有环境变量模板:保留 .env.example 入库;本地 .env 不入
.env
.env.*
!.env.example
# 系统监控日志(如果产生)
*.pid
*.sock
# 运行日志
logs/
*.log
# pycharm / vscode
.vscode/
# 临时下载/构建产物
tmp/
build/
-25
View File
@@ -1,25 +0,0 @@
FROM python:3.7-alpine
RUN apk update \
&& apk --update add --no-cache gcc \
&& apk --update add --no-cache g++ \
&& apk --update add --no-cache tzdata \
&& apk --update add --no-cache libffi-dev \
&& apk --update add --no-cache libxslt-dev \
&& apk --update add --no-cache jpeg-dev
ENV TIME_ZONE Asia/Shanghai
ENV PIPURL "https://pypi.tuna.tsinghua.edu.cn/simple"
RUN echo "${TIME_ZONE}" > /etc/timezone \
&& ln -sf /usr/share/zoneinfo/${TIME_ZONE} /etc/localtime
COPY . /app
WORKDIR /app
RUN pip --no-cache-dir install -i ${PIPURL} --upgrade pip \
&& pip --no-cache-dir install -i ${PIPURL} -r requirement/requirement-dev.txt \
&& pip --no-cache-dir install -i ${PIPURL} gunicorn \
&& chmod +x start.sh
CMD ./start.sh
+69
View File
@@ -0,0 +1,69 @@
# ============================================================
# Pear Admin Flask - Makefile(跨平台,Windows 下需用 Git Bash / WSL
#
# 用法(在项目根目录):
# make help 查看所有命令
# make venv 创建虚拟环境
# make install 安装依赖
# make migrate 执行 db migrate + upgrade
# make seed 导入 admin 基础数据(app_data/init.json
# make init 等价于 migrate + seed
# make run 开发模式启动(PEAR_ENV=dev
# make run-prod 生产模式启动(PEAR_ENV=production,要求 .env SECRET_KEY
# make clean 清理缓存与数据库(危险!请先备份)
# make health curl /healthz
# ============================================================
PY ?= python
VENV ?= venv
PYBIN := $(VENV)/bin/python
PIP := $(PYBIN) -m pip
FLASK := $(PYBIN) -m flask
APP := app.py
.PHONY: help venv install migrate seed init run run-prod clean health
help:
@echo "可用命令:"
@echo " make venv 创建虚拟环境"
@echo " make install 安装依赖"
@echo " make migrate db migrate + upgrade"
@echo " make seed flask admin init"
@echo " make init migrate + seed"
@echo " make run 开发模式启动(默认端口 5000)"
@echo " make run-prod 生产模式启动(强制要求 .env SECRET_KEY"
@echo " make health curl http://127.0.0.1:5000/healthz"
@echo " make clean 清理 pyc / __pycache__ / pear.db / flask_session"
venv:
@test -d "$(VENV)" || $(PY) -m venv $(VENV)
@echo "[venv] 已创建 $(VENV)"
install: venv
$(PIP) install --upgrade pip
$(PIP) install -r requirements.txt
migrate:
$(FLASK) db migrate
$(FLASK) db upgrade
seed:
$(FLASK) admin init
init: migrate seed
run:
PEAR_ENV=dev $(PYBIN) $(APP)
run-prod:
PEAR_ENV=production $(PYBIN) $(APP)
health:
@curl -sS http://127.0.0.1:5000/healthz | python -m json.tool
clean:
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true
find . -type f -name "*.pyc" -delete
rm -rf flask_session
@echo "[clean] 已清理缓存。注意:pear.db 请手动备份后删除。"
+159 -87
View File
@@ -8,15 +8,14 @@
开 箱 即 用 的 Flask 快 速 开 发 平 台 开 箱 即 用 的 Flask 快 速 开 发 平 台
</h4> </h4>
[ ](http://flask.pearadmin.com) | [ ](http://www.pearadmin.com/) | [群聊](https://jq.qq.com/?_wv=1027&k=TkLAKUxC) | [社区](http://forum.pearadmin.com/) [预览](https://pear.lovepikachu.top/) | [官网](http://www.pearadmin.com/) | [群聊](docs/source/_static/qqgroup.jpg) | [文档](https://lab.lovepikachu.top/document/pear-admin-flask)
<p align="center"> <p align="center">
<a href="#"> <a href="#">
<img src="https://img.shields.io/badge/pear%20admin%20flask-1.0.0-green" alt="Pear Admin Layui Version"> <img src="https://img.shields.io/badge/pear%20admin%20flask-2.0.0-green" alt="Pear Admin Layui Version">
</a> </a>
<a href="#"> <a href="#">
<img src="https://img.shields.io/badge/Python-3.6+-green.svg" alt="Python Version"> <img src="https://img.shields.io/badge/Python-3.8+-green.svg" alt="Python Version">
</a> </a>
<a href="#"> <a href="#">
<img src="https://img.shields.io/badge/Mysql-5.3.2+-green.svg" alt="Mysql Version"> <img src="https://img.shields.io/badge/Mysql-5.3.2+-green.svg" alt="Mysql Version">
@@ -25,37 +24,19 @@
</div> </div>
<div align="center"> <div align="center">
<img width="92%" style="border-radius:10px;margin-top:20px;margin-bottom:20px;box-shadow: 2px 0 6px gray;" src="https://images.gitee.com/uploads/images/2020/1019/104805_042b888c_4835367.png" /> <img width="92%" style="border-radius:10px;margin-top:20px;margin-bottom:20px;box-shadow: 2px 0 6px gray;" src="docs/source/_static/feature.png" />
</div> </div>
#### 项目简介 # 项目简介
>Pear Admin Flask 基于 Flask 的后台管理系统,拥抱应用广泛的python语言,通过使用本系统,即可快速构建你的功能业务
>
>项目旨在为python开发者提供一个后台管理系统的模板,成为您构建信息管理系统,物联网后台....等等应用时灵活,简单的工具
>
>众人拾柴火焰高,欢迎pythoner参与项目~
Pear Admin Flask 有以下几个版本: > **⚠️注意** Pear Admin Flask 已同步主项目 Pear Admin Layui 4.0,部分页面变化较大,如果是从旧的框架迁移,请查阅 [从旧项目迁移](https://lab.lovepikachu.top/document/pear-admin-flask/welcome/migration.html) 章节。另外,可以在此查看 [更新日志](https://lab.lovepikachu.top/document/pear-admin-flask/welcome/update.html#v2-0-0-4-0-5) 。
[master分支版本 ](https://gitee.com/pear-admin/pear-admin-flask/tree/master/) Pear Admin Flask 基于 Flask 的后台管理系统,拥抱应用广泛的python语言,通过使用本系统,即可快速构建你的功能业务
项目旨在为 python 开发者提供一个后台管理系统的模板,可以快速构建信息管理系统。
> flask 2.0.1 + flask-sqlalchemy + mysql + 权限验证 + Flask-APScheduler 定时任务 + marshmallow 序列化与数据验证 项目使用 flask-sqlalchemy + 权限验证 + marshmallow 序列化与数据验证,以此方式集成了若干不同的功能。
[Mini 分支版本 ](https://gitee.com/pear-admin/pear-admin-flask/tree/mini/) # 内置功能
>flask 2.0.1 + flask-sqlalchemy + sqllite + 权限验证 + Flask-RESTful 序列化与数据验证
[v1 分支版本(不再更新,仅供参考) ](https://gitee.com/pear-admin/pear-admin-flask/tree/v1/)
>flask 1.12 + flask-sqlalchemy + mysql + 权限验证 + marshmallow 序列化与数据验证
[simple 分支版本(不再更新,仅供参考) ](https://gitee.com/pear-admin/pear-admin-flask/tree/simple/)
>flask 1.12 + flask-sqlalchemy + mysql + 极简权限 + marshmallow 序列化与数据验证
>
>项目最简版本
#### 内置功能
- [x] 用户管理:用户是系统操作者,该功能主要完成系统用户配置。 - [x] 用户管理:用户是系统操作者,该功能主要完成系统用户配置。
- [x] 权限管理:配置系统菜单,操作权限,按钮权限标识等。 - [x] 权限管理:配置系统菜单,操作权限,按钮权限标识等。
@@ -64,101 +45,192 @@ Pear Admin Flask 有以下几个版本:
- [x] 登录日志:系统登录日志记录查询包含登录异常。 - [x] 登录日志:系统登录日志记录查询包含登录异常。
- [x] 服务监控:监视当前系统CPU、内存、磁盘、python版本,运行时长等相关信息。 - [x] 服务监控:监视当前系统CPU、内存、磁盘、python版本,运行时长等相关信息。
- [x] 文件上传: 图片上传示例 - [x] 文件上传: 图片上传示例
- [x] 定时任务: 简单的定时任务
- [ ] 代码生成: 构想中....
#### 项目结构 # 项目分支说明
> **⚠️注意** Pear Admin Flask 不仅仅只提供一种对于 Pear Admin 后端的实现方式,所以提供了不同的分支版本,不同分支版本各有其优劣,并且由不同的开发者维护。
| 分支名称 | 特点 |
|------------------------------------------------------------------|------------------------|
| master(您目前浏览的分支版本) | 功能齐全,处于开发阶段,代码量较大。 |
| [main](https://gitee.com/pear-admin/pear-admin-flask/tree/main/) | 功能精简,代码量小,处于开发阶段,易于维护。 |
| [mini](https://gitee.com/pear-admin/pear-admin-flask/tree/mini/) | 不再更新,是最初版本的镜像。 |
> **⚠️注意** 由于 master 分支项目需要,暂时移除了 Flask-APScheduler 定时任务 功能。
# 版本支持情况
经过测试,此项目的(master分支)运行要求是 `>= Python 3.8` ,推荐使用 `Python 3.11`
> **💡提示** 由于 Flask 中使用的 Werkzeug 模块更新,Flask 官方并未进行更新,所以可能会出现 ImportError 。
> 此类情况的出现可以通过正确安装 `requirements.txt` 中的模块(以及其对应版本)解决。
# 项目结构
## 应用结构
```应用结构
Pear Admin Flask (master)
├─applications # 项目核心模块
│ ├─common # 公共模块(初始化数据库、公用函数)
│ ├─extensions # 注册项目插件
│ ├─schemas # 序列化模型
│ ├─models # 数据库模型
│ ├─views # 视图部分
│ ├─config.py # 项目配置
│ └─__init__.py # 项目初始化入口
├─docs # 文档说明
├─static # 静态资源文件
├─templates # 静态模板文件
└─app.py # 程序入口
``` ```
## 资源结构
```资源结构
Pear Admin Flask Pear Admin Flask
├─applications # 应用 ├─static # 项目设定的 Flask 资源文件夹
│ ├─configs # 配置文件 │ ├─admin # pear admin flask 的后端资源文件(与 pear admin layui 同步)
│ ├─ common.py # 普通配置 │ ├─index # pear admin flask 的前端资源文件
│ └─ config.py # 配置文件对象 │ └─upload # 用户上传保存目录
│ ├─extensions # 注册插件 └─templates # 项目设定的 Flask 模板文件夹
├─models # 数据模型 ├─admin # pear admin flask 的后端管理页面模板
│ ├─static # 静态资源文件 │ ├─admin_log # 日志页面
│ ├─templates # 静态模板文件 │ ├─common # 基本模板页面(头部模板与页脚模板)
└─views # 视图部分 ├─console # 系统监控页面模板
├─admin # 后台管理视图模块 ├─dept # 部门管理页面模板
│ └─index # 前台视图模块 │ ├─dict # 数据字典页面模板
├─docs # 文档说明(占坑) │ ├─mail # 邮件管理页面模板
├─migrations # 迁移文件记录 │ ├─photo # 图片上传页面模板
├─requirement # 依赖文件 │ ├─power # 权限(菜单)管理页面模板
├─test # 测试文件夹(占坑) │ ├─role # 角色管理页面模板
└─.env # 项目的配置文件 │ ├─task # 任务设置页面模板
│ └─user # 用户管理页面模板
├─errors # 错误页面模板
└─index # 主页模板
``` ```
#### 项目安装 # 项目安装
## 从仓库获取
```bash ```bash
# 下 # 克隆仓库 / 手动下载
git clone https://gitee.com/pear-admin/pear-admin-flask git clone https://gitee.com/pear-admin/pear-admin-flask
cd pear-admin-flask # 进入到项目目录
# 安 装
pip install -r requirement\requirement-dev.txt
# 配 置
.env
``` ```
#### 修改配置 ## 修改配置
> **💡提示** 配置文件位于 `applications/config.py` ,打开配置文件看到的是位于 `BaseConfig` 类下的默认配置文件,您可以编写自己的配置类并继承 `BaseConfig` 类。
项目启动时,会调用 `applications/__init__.py` ,这个文件中加载了程序的配置,所以在您编写了自己的类后不要忘记在文件 `applications/__init__.py` 中修改使用的配置类。
> **⚠️注意** 配置文件中对于数据库的配置有所更改,请查看代码中的注释修改配置。
```python ```python
.env # 部分配置信息如下所示
# MySql配置信息
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_DATABASE=PearAdminFlask
MYSQL_USERNAME=root
MYSQL_PASSWORD=root
# Redis 配置 # 验证密钥(⚠️ 一定要记得修改 ⚠️
REDIS_HOST=127.0.0.1 SECRET_KEY = "pear-system-flask"
REDIS_PORT=6379
# 密钥配置 # 数据库的配置信息
SECRET_KEY='pear-admin-flask' SQLALCHEMY_DATABASE_URI = 'sqlite:///../pear.db'
# 邮箱配置 # 默认日志等级
LOG_LEVEL = logging.WARN
# flask-mail配置
MAIL_SERVER = 'smtp.qq.com' MAIL_SERVER = 'smtp.qq.com'
MAIL_USE_TLS = False
MAIL_USE_SSL = True
MAIL_PORT = 465
MAIL_USERNAME = '123@qq.com' MAIL_USERNAME = '123@qq.com'
MAIL_PASSWORD = 'XXXXX' # 生成的授权码 MAIL_PASSWORD = 'XXXXX' # 生成的授权码
MAIL_DEFAULT_SENDER = MAIL_USERNAME
``` ```
#### Venv 安装
## 虚拟环境安装项目(推荐)
> **💡提示** 为了保证项目所依赖的库不影响其他部署在同一主机上的项目,我们推荐使用虚拟环境安装。
```bash ```bash
python -m venv venv python -m venv venv
# 进入虚拟环境下
venv\Scripts\activate.bat # Windows 提示命令符
venv\Scripts\Activate.ps1 # Windows Powershell
source venv/bin/activate # Linux
# 使用 pip 安装
pip install -r requirements.txt
``` ```
#### 运行项目 ## 直接安装项目
```bash
# 使用 pip 安装
pip install -r requirements.txt
# 同时你可以选择以模块的方式调用 pip
python -m pip install -r requirements.txt
```
# 运行项目
+ 一般情况运行项目
```bash ```bash
# 初始化数据库 # 初始化数据库
flask db init
flask db migrate
flask db upgrade
flask admin init
flask init # 运行项目
flask --app app.py run -h 0.0.0.0 -p 8000 --debug
# 或者直接调用 app.py
python app.py
``` ```
执行 flask run 命令启动项目 + 使用 docker-compose 运行项目
#### 命令行创建视图
```bash ```bash
# 示例 git clone https://gitee.com/pear-admin/pear-admin-flask
flask new --type view --name test/a # 安装 docker-compose
curl -L https://github.com/docker/compose/releases/download/1.26.2/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose
ln -s /usr/local/bin/docker-compose /usr/bin/docker-compose
# 自动注册蓝图 # 运行如下命令,有输出版本,表示 docker-compose 可以用了
# 访问http://127.0.0.1:5000/test/a/ docker-compose --version
# 在当前目录执行如下命令即可以运行 app
docker-compose -f dockercompose.yaml up
# 看到如下表示运行成功,由于 pip 下载慢,需要一些时间,请耐心等待;如果安装失败,重新执行上面的命令即可。
# 运行后在浏览器访问 127.0.0.1:5000
#如果要停止容器运行,在当前文件夹执行如下命令:
docker-compose -f dockercompose.yaml down
``` ```
#### 预览项目
# 预览项目
| | | | | |
|---------------------|---------------------| | ---------------------- | ---------------------- |
| ![](docs/assets/1.jpg) | ![](docs/assets/2.jpg) | | ![](docs/source/_static/1.jpg) | ![](docs/source/_static/2.jpg) |
| ![](docs/assets/3.jpg)| ![](docs/assets/4.jpg) | | ![](docs/source/_static/3.jpg) | ![](docs/source/_static/6.jpg) |
| ![](docs/assets/5.jpg) | ![](docs/assets/6.jpg) |
# 其他说明
## 项目初始用户以及其密码
默认用户为 `admin` ,密码默认为 `123456`
+9 -5
View File
@@ -1,10 +1,14 @@
from applications import create_app from applications import create_app
from flask_migrate import Migrate
from applications.extensions import db
app = create_app() app = create_app()
migrate = Migrate(app, db)
if __name__ == '__main__': if __name__ == '__main__':
app.run() # 绑定 0.0.0.0 后,同局域网手机可通过本机 IP 访问(例如 http://192.168.1.10:5000/site/
# debug 标志交给配置类决定:PEAR_ENV=dev → DEBUG=TruePEAR_ENV=production → DEBUG=False
# 防止重载进程抢占端口;局域网访问保持稳定
app.run(
host='0.0.0.0',
port=5000,
debug=app.config.get('DEBUG', False),
use_reloader=False,
)
+26 -30
View File
@@ -1,44 +1,40 @@
import os import os
from dotenv import load_dotenv
from flask import Flask from flask import Flask
from applications.common.script import init_script from applications.common.script import init_script
from applications.config import BaseConfig, get_config_by_name
from applications.extensions import init_plugs from applications.extensions import init_plugs
from applications.view import init_view from applications.view import init_bps
from applications.configs import config
# 项目根目录 = 本文件父目录的父目录
BASE_DIR = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
def create_app(config_name=None): def create_app():
app = Flask(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) # 1) 先加载 .env(在 BaseConfig 读取 os.environ 之前)
# override=False:环境变量优先级高于 .env,便于 CI/CD 在部署时再覆盖
load_dotenv(os.path.join(BASE_DIR, ".env"), override=False)
if not config_name: app = Flask(BASE_DIR)
# 尝试从本地环境中读取
config_name = os.getenv('FLASK_CONFIG', 'development')
# 引入数据库配置 # 2) 根据 PEAR_ENV / FLASK_ENV 选择配置类,默认 dev
app.config.from_object(config[config_name]) env_name = os.environ.get("PEAR_ENV") or os.environ.get("FLASK_ENV") or "dev"
config_cls = get_config_by_name(env_name)
app.config.from_object(config_cls)
# 注册各种插件 # 3) 把 BaseConfig 默认值里没写、但 .env 可能写了的 SQLALCHEMY_DATABASE_URI 等再覆盖一次
if os.environ.get("SQLALCHEMY_DATABASE_URI"):
app.config["SQLALCHEMY_DATABASE_URI"] = os.environ["SQLALCHEMY_DATABASE_URI"]
# 3.5) 让配置类有机会做运行时校验(例如 ProConfig 强制要求 SECRET_KEY
if hasattr(config_cls, "init_app"):
config_cls.init_app(app)
# 4) 注册 flask 组件 / 蓝图 / 命令
init_plugs(app) init_plugs(app)
init_bps(app)
# 注册路由
init_view(app)
# 注册命令
init_script(app) init_script(app)
if os.environ.get('WERKZEUG_RUN_MAIN') == 'true':
logo()
return app return app
def logo():
print('''
_____ _ _ ______ _ _
| __ \ /\ | | (_) | ____| | | |
| |__) |__ __ _ _ __ / \ __| |_ __ ___ _ _ __ | |__ | | __ _ ___| | __
| ___/ _ \/ _` | '__| / /\ \ / _` | '_ ` _ \| | '_ \ | __| | |/ _` / __| |/ /
| | | __/ (_| | | / ____ \ (_| | | | | | | | | | | | | | | (_| \__ \ <
|_| \___|\__,_|_| /_/ \_\__,_|_| |_| |_|_|_| |_| |_| |_|\__,_|___/_|\_\\
''')
+81 -130
View File
@@ -1,69 +1,22 @@
import copy
from collections import OrderedDict
from io import BytesIO from io import BytesIO
from flask import session, make_response, current_app from flask import make_response
from flask_login import current_user from flask_login import current_user
from applications.common.utils.gen_captcha import gen_captcha from applications.common.utils.validate import str_escape
from applications.schemas import PowerOutSchema from applications.common.utils.captcha import vieCode
from applications.extensions import db
from applications.models import AdminLog
# 授权路由存入session
def add_auth_session():
role = current_user.role
user_power = []
for i in role:
if i.enable == 0:
continue
for p in i.power:
if p.enable == 0:
continue
user_power.append(p.code)
session['permissions'] = user_power
# 生成菜单树
def make_menu_tree():
role = current_user.role
powers = []
for i in role:
# 如果角色没有被启用就直接跳过
if i.enable == 0:
continue
# 变量角色用户的权限
for p in i.power:
# 如果权限关闭了就直接跳过
if p.enable == 0:
continue
# 一二级菜单
if int(p.type) in [0,1] and p not in powers:
powers.append(p)
power_schema = PowerOutSchema(many=True) # 用已继承 ma.ModelSchema 类的自定制类生成序列化类
power_dict = power_schema.dump(powers) # 生成可序列化对象
power_dict.sort(key=lambda x: (x['parent_id'], x['id']), reverse=True)
menu_dict = OrderedDict()
for _dict in power_dict:
if _dict['id'] in menu_dict:
# 当前节点添加子节点
_dict['children'] = copy.deepcopy(menu_dict[_dict['id']])
_dict['children'].sort(key=lambda item: item['sort'])
# 删除子节点
del menu_dict[_dict['id']]
if _dict['parent_id'] not in menu_dict:
menu_dict[_dict['parent_id']] = [_dict]
else:
menu_dict[_dict['parent_id']].append(_dict)
return sorted(menu_dict.get(0), key=lambda item: item['sort'])
# 生成验证码
def get_captcha(): def get_captcha():
code, image = gen_captcha() """
生成验证码图片及其对应的验证码字符串。
:return: 返回验证码图片的响应对象和验证码字符串。
"""
image, code = vieCode().GetCodeImage()
code = ''.join(code).lower()
out = BytesIO() out = BytesIO()
session["code"] = code
image.save(out, 'png') image.save(out, 'png')
out.seek(0) out.seek(0)
resp = make_response(out.read()) resp = make_response(out.read())
@@ -71,75 +24,73 @@ def get_captcha():
return resp, code return resp, code
def get_render_config(): def normal_log(method, url, ip, user_agent, desc, uid, is_access):
# 网站配置 """
config = dict(logo={ 记录通用日志信息到数据库。
# 网站名称
"title": current_app.config.get("SYSTEM_NAME"), :param method: 请求方法(如 GET、POST)。
# 网站图标 :param url: 请求的 URL。
"image": "/static/admin/admin/images/logo.png" :param ip: 客户端的 IP 地址。
# 菜单配置 :param user_agent: 客户端的 User-Agent 信息。
}, menu={ :param desc: 日志描述信息。
# 菜单数据来源 :param uid: 用户 ID。
"data": "/rights/menu", :param is_access: 是否成功访问(True 或 False)。
"collaspe": False, :return: 返回日志记录的 ID。
# 是否同时只打开一个菜单目录 """
"accordion": True, info = {
"method": "GET", 'method': method,
# 是否开启多系统菜单模式 'url': url,
"control": False, 'ip': ip,
# 顶部菜单宽度 PX 'user_agent': user_agent,
"controlWidth": 500, 'desc': desc,
# 默认选中的菜单项 'uid': uid,
"select": "0", 'success': int(is_access)
# 是否开启异步菜单,false 时 data 属性设置为菜单数据,false 时为 json 文件或后端接口
"async": True
}, tab={
# 是否开启多选项卡
"enable": True,
# 切换选项卡时,是否刷新页面状态
"keepState": True,
# 是否开启 Tab 记忆
"session": True,
# 最大可打开的选项卡数量
"max": 30,
"index": {
# 标识 ID , 建议与菜单项中的 ID 一致
"id": "10",
# 页面地址
"href": "/admin/welcome",
# 标题
"title": "首页"
} }
}, theme={ log = AdminLog(
# 默认主题色,对应 colors 配置中的 ID 标识 url=info.get('url'),
"defaultColor": "2", ip=info.get('ip'),
# 默认的菜单主题 dark-theme 黑 / light-theme 白 user_agent=info.get('user_agent'),
"defaultMenu": "dark-theme", desc=info.get('desc'),
# 是否允许用户切换主题,false 时关闭自定义主题面板 uid=info.get('uid'),
"allowCustom": True method=info.get('method'),
}, colors=[{ success=info.get('success')
"id": "1", )
"color": "#2d8cf0" db.session.add(log)
}, db.session.commit()
{ return log.id
"id": "2",
"color": "#5FB878"
}, def login_log(request, uid, is_access):
{ """
"id": "3", 记录用户登录日志。
"color": "#1E9FFF"
}, { :param request: Flask 请求对象。
"id": "4", :param uid: 用户 ID。
"color": "#FFB800" :param is_access: 是否成功登录(True 或 False)。
}, { :return: 返回日志记录的 ID。
"id": "5", """
"color": "darkgray" method = request.method
} url = request.path
], links=current_app.config.get("SYSTEM_PANEL_LINKS"), other={ ip = request.remote_addr
# 主页动画时长 user_agent = str_escape(request.headers.get('User-Agent'))
"keepLoad": 1200, desc = str_escape(request.form.get('username'))
# 布局顶部主题 return normal_log(method, url, ip, user_agent, desc, uid, is_access)
"autoHead": False
}, header=False)
return config def admin_log(request, is_access, desc=None):
"""
记录管理员操作日志。
:param request: Flask 请求对象。
:param is_access: 是否成功操作(True 或 False)。
:param desc: 日志描述信息(可选)。如果未提供,则从请求数据中提取。
:return: 返回日志记录的 ID。
"""
method = request.method
url = request.path
ip = request.remote_addr
user_agent = str_escape(request.headers.get('User-Agent'))
request_data = request.json if request.headers.get('Content-Type') == 'application/json' else request.values
if desc is None:
desc = str_escape(str(dict(request_data)))
return normal_log(method, url, ip, user_agent, desc, current_user.id, is_access)
-57
View File
@@ -1,57 +0,0 @@
from flask_login import current_user
from applications.common.utils.validate import xss_escape
from applications.extensions import db
from applications.models import AdminLog
def login_log(request, uid, is_access):
info = {
'method': request.method,
'url': request.path,
'ip': request.remote_addr,
'user_agent': xss_escape(request.headers.get('User-Agent')),
'desc': xss_escape(request.form.get('username')),
'uid': uid,
'success': int(is_access)
}
log = AdminLog(
url=info.get('url'),
ip=info.get('ip'),
user_agent=info.get('user_agent'),
desc=info.get('desc'),
uid=info.get('uid'),
method=info.get('method'),
success=info.get('success')
)
db.session.add(log)
db.session.flush()
db.session.commit()
return log.id
def admin_log(request, is_access):
info = {
'method': request.method,
'url': request.path,
'ip': request.remote_addr,
'user_agent': xss_escape(request.headers.get('User-Agent')),
'desc': xss_escape(str(dict(request.values))),
'uid': current_user.id,
'success': int(is_access)
}
log = AdminLog(
url=info.get('url'),
ip=info.get('ip'),
user_agent=info.get('user_agent'),
desc=info.get('desc'),
uid=info.get('uid'),
method=info.get('method'),
success=info.get('success')
)
db.session.add(log)
db.session.commit()
return log.id
+49 -24
View File
@@ -1,31 +1,39 @@
import datetime import datetime
from marshmallow import Schema from marshmallow import Schema
from marshmallow_sqlalchemy import SQLAlchemyAutoSchema from marshmallow_sqlalchemy import SQLAlchemyAutoSchema
from applications.extensions import db, ma from applications.extensions import db, ma
class LogicalDeleteMixin(object): class LogicalDeleteMixin(object):
""" """
逻辑删除混入类,为模型提供软删除功能。
示例:
class Test(db.Model, LogicalDeleteMixin): class Test(db.Model, LogicalDeleteMixin):
__tablename__ = 'admin_test' __tablename__ = 'admin_test'
id = db.Column(db.Integer, primary_key=True, comment='角色ID') id = db.Column(db.Integer, primary_key=True, comment='角色ID')
# 软删除
Test.query.filter_by(id=1).soft_delete() Test.query.filter_by(id=1).soft_delete()
# 查询所有未删除的记录
Test.query.logic_all() Test.query.logic_all()
""" """
create_at = db.Column(db.DateTime, default=datetime.datetime.now, comment='创建时间') create_at = db.Column(db.DateTime, default=datetime.datetime.now, comment='创建时间')
update_at = db.Column(db.DateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now, comment='创建时间') update_at = db.Column(db.DateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now, comment='更新时间')
delete_at = db.Column(db.DateTime, comment='删除时间') delete_at = db.Column(db.DateTime, comment='删除时间')
def auto_model_jsonify(data, model: db.Model): def auto_model_jsonify(data, model: db.Model):
""" """
不需要建立schemas,直接使用orm的定义模型进行序列化 自动序列化模型数据为 JSON 格式,无需手动定义 Schema。
基本功能,待完善
示例 示例
power_data = curd.auto_model_jsonify(model=Dept, data=dept) power_data = curd.auto_model_jsonify(model=Dept, data=dept)
:param data: 需要序列化的 SQLAlchemy 查询结果。
:param model: SQLAlchemy 模型类。
:return: 返回序列化后的 JSON 数据。
""" """
def get_model(): def get_model():
return model return model
@@ -33,49 +41,60 @@ def auto_model_jsonify(data, model: db.Model):
class AutoSchema(SQLAlchemyAutoSchema): class AutoSchema(SQLAlchemyAutoSchema):
class Meta(Schema): class Meta(Schema):
model = get_model() model = get_model()
include_fk = True include_fk = True # 包含外键
include_relationships = True include_relationships = True # 包含关联关系
load_instance = True load_instance = True # 反序列化时加载为模型实例
common_schema = AutoSchema(many=True) # 用已继承ma.ModelSchema类的自定制类生成序列化类 common_schema = AutoSchema(many=True) # 支持序列化多个对象
output = common_schema.dump(data) output = common_schema.dump(data)
return output return output
def model_to_dicts(schema: ma.Schema, data): def model_to_dicts(schema: ma.Schema, data):
""" """
:param schema: schema类 使用指定的 Schema 序列化 SQLAlchemy 查询结果。
:param model: sqlalchemy查询结果
:return: 返回单个查询结果 :param schema: Marshmallow Schema 类。
:param data: SQLAlchemy 查询结果。
:return: 返回序列化后的数据,返回字典。
""" """
# 如果是分页器返回,需要传入model.items common_schema = schema(many=True) # 支持序列化多个对象
common_schema = schema(many=True) # 用已继承ma.ModelSchema类的自定制类生成序列化类 output = common_schema.dump(data)
output = common_schema.dump(data) # 生成可序列化对象
return output return output
def get_one_by_id(model: db.Model, id): def get_one_by_id(model: db.Model, id):
""" """
:param model: 模型类 根据 ID 查询单个记录。
:param id: id
:return: 返回单个查询结果 :param model: SQLAlchemy 模型类。
:param id: 记录的主键 ID。
:return: 返回查询到的记录,如果未找到则返回 None。
""" """
return model.query.filter_by(id=id).first() return model.query.filter_by(id=id).first()
def delete_one_by_id(model: db.Model, id): def delete_one_by_id(model: db.Model, id):
""" """
:param model: 模型类 根据 ID 删除单个记录。
:param id: id
:return: 返回单个查询结果 :param model: SQLAlchemy 模型类。
:param id: 记录的主键 ID。
:return: 返回删除操作影响的行数。
""" """
r = model.query.filter_by(id=id).delete() r = model.query.filter_by(id=id).delete()
db.session.commit() db.session.commit()
return r return r
# 启动状态
def enable_status(model: db.Model, id): def enable_status(model: db.Model, id):
"""
启用指定 ID 的记录。
:param model: SQLAlchemy 模型类。
:param id: 记录的主键 ID。
:return: 如果操作成功返回 True,否则返回 False。
"""
enable = 1 enable = 1
role = model.query.filter_by(id=id).update({"enable": enable}) role = model.query.filter_by(id=id).update({"enable": enable})
if role: if role:
@@ -84,8 +103,14 @@ def enable_status(model: db.Model, id):
return False return False
# 停用状态
def disable_status(model: db.Model, id): def disable_status(model: db.Model, id):
"""
停用指定 ID 的记录。
:param model: SQLAlchemy 模型类。
:param id: 记录的主键 ID。
:return: 如果操作成功返回 True,否则返回 False。
"""
enable = 0 enable = 0
role = model.query.filter_by(id=id).update({"enable": enable}) role = model.query.filter_by(id=id).update({"enable": enable})
if role: if role:
+105 -58
View File
@@ -1,126 +1,173 @@
from sqlalchemy import and_ from sqlalchemy import and_, func
from applications.extensions import db from applications.extensions import db
class ModelFilter: class ModelFilter:
""" """
orm多参数构造器 ORM 多条件查询构造器,支持多种查询条件组合,自动转义特殊字符防止SQL注入。
"""
filter_field = {}
filter_list = []
type_exact = "exact" 示例:
type_neq = "neq" mf = ModelFilter()
type_greater = "greater" mf.exact('name', 'John')
type_less = "less" mf.vague('email', 'example.com')
type_vague = "vague" query = User.query.filter(mf.get_filter(User))
type_contains = "contains" """
type_between = "between" filter_field = {} # 存储字段过滤条件
filter_list = [] # 存储最终的过滤条件列表
# 查询类型常量
type_exact = "exact" # 精确匹配
type_neq = "neq" # 不等于
type_greater = "greater" # 大于
type_less = "less" # 小于
type_vague = "vague" # 模糊匹配
type_contains = "contains" # 包含
type_between = "between" # 范围查询
def __init__(self): def __init__(self):
"""初始化过滤条件存储字典和列表。"""
self.filter_field = {} self.filter_field = {}
self.filter_list = [] self.filter_list = []
@staticmethod
def escape_like(value: str, escape_char: str = '\\') -> str:
"""
转义LIKE查询中的特殊字符(%, _ 和转义字符本身)
:param value: 需要转义的原始字符串
:param escape_char: 转义字符(默认反斜杠)
:return: 转义后的安全字符串
"""
return (
value.replace(escape_char, escape_char * 2)
.replace('%', escape_char + '%')
.replace('_', escape_char + '_')
)
def exact(self, field_name, value): def exact(self, field_name, value):
""" """
准确查询字段 添加精确匹配条件(自动处理字符串类型参数)
:param field_name: 模型字段名称 :param field_name: 模型字段名称
:param value: :param value: 匹配的值(自动过滤空字符串)
""" """
if value and value != '': if value is not None and value != '':
self.filter_field[field_name] = {"data": value, "type": self.type_exact} # 字符串类型自动调用escape_like(防止特殊字符影响精确匹配)
processed_value = self.escape_like(str(value)) if isinstance(value, str) else value
self.filter_field[field_name] = {"data": processed_value, "type": self.type_exact}
def neq(self, field_name, value): def neq(self, field_name, value):
""" """
不等于查询字段 添加不等于条件(自动处理字符串类型参数)
:param field_name: 模型字段名称 :param field_name: 模型字段名称
:param value: :param value: 不匹配的值(自动过滤空字符串)
""" """
if value and value != '': if value is not None and value != '':
self.filter_field[field_name] = {"data": value, "type": self.type_neq} # 字符串类型自动调用escape_like
processed_value = self.escape_like(str(value)) if isinstance(value, str) else value
self.filter_field[field_name] = {"data": processed_value, "type": self.type_neq}
def greater(self, field_name, value): def greater(self, field_name, value):
""" """
大于查询字段 添加大于条件(数值/日期比较)
:param field_name: 模型字段名称 :param field_name: 模型字段名称
:param value: :param value: 比较的数值/日期
""" """
if value and value != '': if value is not None and value != '':
self.filter_field[field_name] = {"data": value, "type": self.type_greater} self.filter_field[field_name] = {"data": value, "type": self.type_greater}
def less(self, field_name, value): def less(self, field_name, value):
""" """
大于查询字段 添加小于条件(数值/日期比较)
:param field_name: 模型字段名称 :param field_name: 模型字段名称
:param value: :param value: 比较的数值/日期
""" """
if value and value != '': if value is not None and value != '':
self.filter_field[field_name] = {"data": value, "type": self.type_less} self.filter_field[field_name] = {"data": value, "type": self.type_less}
def vague(self, field_name, value: str): def vague(self, field_name, value: str):
""" """
模糊查询字段 添加安全模糊匹配(自动转义特殊字符,左右加%
:param field_name: 模型字段名称 :param field_name: 模型字段名称
:param value: :param value: 需要模糊匹配的字符串(自动过滤空值)
""" """
if value and value != '': if value and value != '':
self.filter_field[field_name] = {"data": ('%' + value + '%'), "type": self.type_vague} escaped_value = self.escape_like(value)
self.filter_field[field_name] = {"data": f'%{escaped_value}%', "type": self.type_vague}
def left_vague(self, field_name, value: str): def left_vague(self, field_name, value: str):
""" """
左模糊查询字段 添加安全左模糊匹配(自动转义特殊字符,左侧加%
:param field_name: 模型字段名称 :param field_name: 模型字段名称
:param value: :param value: 需要左模糊匹配的字符串
""" """
if value and value != '': if value and value != '':
self.filter_field[field_name] = {"data": ('%' + value), "type": self.type_vague} escaped_value = self.escape_like(value)
self.filter_field[field_name] = {"data": f'%{escaped_value}', "type": self.type_vague}
def right_vague(self, field_name, value: str): def right_vague(self, field_name, value: str):
""" """
左模糊查询字段 添加安全右模糊匹配(自动转义特殊字符,右侧加%
:param field_name: 模型字段名称 :param field_name: 模型字段名称
:param value: :param value: 需要右模糊匹配的字符串
""" """
if value and value != '': if value and value != '':
self.filter_field[field_name] = {"data": (value + '%'), "type": self.type_vague} escaped_value = self.escape_like(value)
self.filter_field[field_name] = {"data": f'{escaped_value}%', "type": self.type_vague}
def contains(self, field_name, value: str): def contains(self, field_name, value: str):
""" """
包含查询字段 添加安全包含条件(自动转义特殊字符,等效于vague)
:param field_name: 模型字段名称 :param field_name: 模型字段名称
:param value: :param value: 需要包含的字符串
""" """
if value and value != '': if value and value != '':
self.filter_field[field_name] = {"data": value, "type": self.type_contains} escaped_value = self.escape_like(value)
self.filter_field[field_name] = {"data": f'%{escaped_value}%', "type": self.type_contains}
def between(self, field_name, value1, value2): def between(self, field_name, value1, value2):
""" """
范围查询字段 添加范围查询条件(自动过滤无效值)
:param field_name: 模型字段名称 :param field_name: 模型字段名称
:param value: 值 :param value1: 范围起始
:param value2: 范围结束值
""" """
if value1 and value2 and value1 != '' and value2 != '': if all([v is not None and v != '' for v in [value1, value2]]):
self.filter_field[field_name] = {"data": [value1, value2], "type": self.type_between} self.filter_field[field_name] = {"data": [value1, value2], "type": self.type_between}
def get_filter(self, model: db.Model): def get_filter(self, model: db.Model):
""" """
获取过滤条件 生成安全的SQLAlchemy过滤条件
:param model: 模型字段名称
:param model: SQLAlchemy 模型类
:return: 组合后的过滤条件(使用and_连接)
""" """
for k, v in self.filter_field.items(): for k, v in self.filter_field.items():
if v.get("type") == self.type_vague: field = getattr(model, k)
self.filter_list.append(getattr(model, k).like(v.get("data"))) data = v.get("data")
if v.get("type") == self.type_contains: query_type = v.get("type")
self.filter_list.append(getattr(model, k).contains(v.get("data")))
if v.get("type") == self.type_exact: if query_type == self.type_vague:
self.filter_list.append(getattr(model, k) == v.get("data")) self.filter_list.append(field.like(data, escape='\\'))
if v.get("type") == self.type_neq: elif query_type == self.type_contains:
self.filter_list.append(getattr(model, k) != v.get("data")) self.filter_list.append(field.like(data, escape='\\'))
if v.get("type") == self.type_greater: elif query_type == self.type_exact:
self.filter_list.append(getattr(model, k) > v.get("data")) self.filter_list.append(field == data)
if v.get("type") == self.type_less: elif query_type == self.type_neq:
self.filter_list.append(getattr(model, k) < v.get("data")) self.filter_list.append(field != data)
if v.get("type") == self.type_between: elif query_type == self.type_greater:
self.filter_list.append(getattr(model, k).between(v.get("data")[0], v.get("data")[1])) self.filter_list.append(field > data)
elif query_type == self.type_less:
self.filter_list.append(field < data)
elif query_type == self.type_between:
self.filter_list.append(field.between(data[0], data[1]))
return and_(*self.filter_list) return and_(*self.filter_list)
+8 -23
View File
@@ -1,26 +1,11 @@
import os from flask import Flask
import click from .admin import admin_cli
from .check import check_cli
from applications.common.script.initdb import init_db from applications.extensions.init_plugins import broadcast_execute
from applications.common.script.newmodular.new import NewViewModular
def init_script(app): def init_script(app: Flask):
@app.cli.command() app.cli.add_command(admin_cli)
def init(): app.cli.add_command(check_cli)
init_db() broadcast_execute(app, 'event_finish')
@app.cli.command()
@click.option('--type', prompt="请输入类型", help='新增的类型')
@click.option('--name', prompt="请输入新增的名称", help='新增的名称')
def new(type,name):
if type == 'view':
if name.count('/') > 1:
print("目前只支持二级目录,多级目录需要蓝图嵌套,本命令暂不支持,请手动创建")
quit()
if type == "view" and os.path.exists(f"applications/view/{name}.py"):
print(f'已经存在视图模块{name}.py')
quit()
NewViewModular(name=name).new_view()
+247
View File
@@ -0,0 +1,247 @@
import click
import json
import datetime
import os
from flask.cli import AppGroup
from applications.extensions import db
from applications.models import User, Role, Dept, Power
# 1. 定义命令组
admin_cli = AppGroup("admin", help="系统数据管理:包括初始化、导入和导出")
# 2. 定义数据存储的文件夹名称
DATA_DIR = "app_data"
# --- 辅助工具类 ---
class DateEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, datetime.datetime):
return obj.strftime('%Y-%m-%d %H:%M:%S')
return json.JSONEncoder.default(self, obj)
def parse_time(item):
"""辅助函数:尝试将字典中的时间字符串转换为 datetime 对象"""
for k, v in item.items():
if k in ['create_at', 'create_time', 'update_at'] and isinstance(v, str):
try:
item[k] = datetime.datetime.strptime(v, '%Y-%m-%d %H:%M:%S')
except ValueError:
pass
return item
def model_to_dict(obj):
"""将 SQLAlchemy 模型对象转换为字典"""
data = {}
for c in obj.__table__.columns:
val = getattr(obj, c.name)
data[c.name] = val
return data
def ensure_dir_exists():
"""
确保数据文件夹存在。
修改:路径基于当前脚本文件的位置,而不是执行命令的位置。
"""
# 获取当前脚本(admin_cli.py)所在的目录
current_script_dir = os.path.dirname(os.path.abspath(__file__))
# 拼接得到 app_data 的绝对路径
base_path = os.path.join(current_script_dir, DATA_DIR)
if not os.path.exists(base_path):
os.makedirs(base_path)
return base_path
# --- 核心逻辑:导入数据 ---
def import_logic(file_path, is_force):
"""具体的导入逻辑封装"""
if not os.path.exists(file_path):
click.echo(f"错误: 找不到文件 {file_path}")
return False
click.echo(f"正在读取文件: {file_path}")
with open(file_path, 'r', encoding='utf-8') as f:
raw_data = json.load(f)
if is_force:
click.echo("警告: 正在清空旧数据...")
try:
db.session.query(User).delete()
db.session.query(Role).delete()
db.session.query(Power).delete()
db.session.query(Dept).delete()
# 新增:清空 Nav 表
from applications.models import Nav
db.session.query(Nav).delete()
db.session.commit()
except Exception as e:
db.session.rollback()
click.echo(f"清空数据失败: {str(e)}")
return False
try:
# 1. 导入部门
count_dept = 0
for item in raw_data.get("depts", []):
item = parse_time(item)
if not Dept.query.get(item['id']):
db.session.add(Dept(**item))
count_dept += 1
db.session.commit()
# 2. 导入权限
count_power = 0
for item in raw_data.get("powers", []):
item = parse_time(item)
if not Power.query.get(item['id']):
db.session.add(Power(**item))
count_power += 1
db.session.commit()
# 3. 导入角色
count_role = 0
for item in raw_data.get("roles", []):
item = parse_time(item)
power_ids = item.pop('power_ids', [])
role = Role.query.get(item['id'])
if not role:
role = Role(**item)
db.session.add(role)
count_role += 1
if power_ids:
powers = Power.query.filter(Power.id.in_(power_ids)).all()
role.power = powers
db.session.commit()
# 4. 导入用户
count_user = 0
for item in raw_data.get("users", []):
item = parse_time(item)
role_ids = item.pop('role_ids', [])
user = User.query.get(item['id'])
if not user:
user = User(**item)
db.session.add(user)
count_user += 1
if role_ids:
roles = Role.query.filter(Role.id.in_(role_ids)).all()
user.role = roles
db.session.commit()
# 5. 导入公开导航(Nav 表)
from applications.models import Nav
count_nav = 0
for item in raw_data.get("navs", []):
item = parse_time(item)
if not Nav.query.get(item.get('id')) if item.get('id') else True:
db.session.add(Nav(**item))
count_nav += 1
db.session.commit()
click.echo(f"导入成功! 新增: 部门{count_dept}, 权限{count_power}, 角色{count_role}, 用户{count_user}, 导航{count_nav}")
return True
except Exception as e:
db.session.rollback()
click.echo(f"导入失败,已回滚。错误信息: {str(e)}")
return False
# --- 命令:init (系统初始化) ---
@admin_cli.command("init", help="系统初始化:读取 app_data/init.json 并导入数据库")
@click.option('--force', is_flag=True, help='是否强制清空旧数据再导入')
def init_db(force):
"""
系统初始化命令。
默认读取当前脚本同级目录下 app_data/init.json 文件。
"""
base_path = ensure_dir_exists()
target_file = os.path.join(base_path, 'init.json')
if not os.path.exists(target_file):
click.echo(f"错误:在 {base_path} 目录下未找到 init.json 文件。")
click.echo(f"请先将基础数据文件放入该位置,或运行 export 生成。")
return
import_logic(target_file, force)
# --- 命令:export (导出) ---
@admin_cli.command("export", help="导出数据:将数据库数据保存为 JSON 文件")
@click.option('--name', default=None, help='指定导出文件名,不带后缀。如果不填则使用时间戳。')
def export_data(name):
"""
导出数据到当前脚本同级目录下的 app_data 文件夹。
"""
base_path = ensure_dir_exists()
if name:
filename = f"{name}.json"
else:
now_str = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f"data_{now_str}.json"
file_path = os.path.join(base_path, filename)
click.echo(f"准备导出数据到: {file_path} ...")
data = {
"depts": [],
"powers": [],
"roles": [],
"users": [],
"navs": []
}
# 1. 导出部门
data["depts"] = [model_to_dict(d) for d in Dept.query.order_by(Dept.id).all()]
# 2. 导出权限
data["powers"] = [model_to_dict(p) for p in Power.query.order_by(Power.id).all()]
# 3. 导出角色
roles = Role.query.order_by(Role.id).all()
for r in roles:
r_dict = model_to_dict(r)
r_dict['power_ids'] = [p.id for p in r.power]
data["roles"].append(r_dict)
# 4. 导出用户
users = User.query.order_by(User.id).all()
for u in users:
u_dict = model_to_dict(u)
u_dict['role_ids'] = [r.id for r in u.role]
data["users"].append(u_dict)
# 5. 导出公开导航
from applications.models import Nav
data["navs"] = [model_to_dict(n) for n in Nav.query.order_by(Nav.category, Nav.sort, Nav.id).all()]
try:
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(data, f, cls=DateEncoder, ensure_ascii=False, indent=4)
click.echo(f"导出成功!")
except Exception as e:
click.echo(f"导出失败: {str(e)}")
# --- 命令:import (指定文件导入) ---
@admin_cli.command("import", help="导入数据:从指定的 JSON 文件恢复数据")
@click.option('--file', required=True, help='要导入的文件名 (需包含路径或位于 app_data 下)')
@click.option('--force', is_flag=True, help='是否强制清空旧数据')
def import_data(file, force):
"""
从指定文件导入数据。
"""
# 如果用户只提供了文件名,没有路径,则假设在 app_data 目录下
if not os.path.dirname(file):
file_path = os.path.join(ensure_dir_exists(), file)
else:
file_path = file
import_logic(file_path, force)
File diff suppressed because it is too large Load Diff
+95
View File
@@ -0,0 +1,95 @@
import re
import inspect
from flask.cli import AppGroup
from flask import current_app
check_cli = AppGroup('check', help='Commands for checking application aspects.')
@check_cli.command('auth')
def check_auth():
"""检查路由视图函数的 @login_required 和 @authorize(...) 装饰器使用情况。"""
app = current_app
if not app:
print("错误:未在应用上下文中运行。请使用 'flask check auth' 命令。")
return
print("\n" + "="*120)
print("Flask 路由鉴权装饰器检查报告".center(120))
print("="*120)
# 调整列宽以适应更长的权限字符串
header_format = "{:<35} {:<35} {:<30} {:<20} {:<30}"
row_format = "{:<35} {:<35} {:<30} {:<20} {:<30}"
print(header_format.format("Endpoint", "URL Rule", "View Function", "@login_required", "@authorize(...)"))
print("-" * 120)
has_missing_auth = False # 标记是否有缺失鉴权的路由
with app.app_context():
for rule in app.url_map.iter_rules():
if rule.endpoint == 'static':
continue
view_func = app.view_functions.get(rule.endpoint)
if not view_func:
continue
# --- 获取源代码 ---
try:
# 使用 getsourcelines 可能更稳定,获取整个函数定义
source_lines = inspect.getsourcelines(view_func)[0]
source_code = "".join(source_lines)
except (OSError, TypeError):
# 如果无法获取源代码(如 C 扩展),则跳过
source_code = ""
# --- 检查 @login_required 装饰器 ---
# 匹配常见的形式,包括可能的模块前缀
has_login_required = bool(re.search(r"@.*login_required", source_code))
# --- 检查 @authorize 装饰器并提取权限 ---
authorize_permissions_list = []
# 改进的正则表达式:
# @ : 匹配 @ 符号
# (?:.*?\.)? : 非捕获组,匹配可能的模块名和点 (例如 auth.)
# authorize : 匹配 authorize 函数名
# \( : 匹配左圆括号
# ([^)]*) : 捕获组,匹配括号内的所有内容 (非右括号字符)
# \) : 匹配右圆括号
# 这个模式会找到 @authorize(...) 的整个调用
authorize_calls = re.findall(r"@.*?authorize\s*\(([^)]*)\)", source_code, re.DOTALL)
for call_args in authorize_calls:
# 在找到的参数字符串中,再次使用正则提取被引号包围的权限字符串
# 匹配单引号或双引号内的内容
permissions_found = re.findall(r"['\"]([^'\"]+)['\"]", call_args)
authorize_permissions_list.extend(permissions_found)
# 将找到的所有权限字符串用逗号连接
authorize_permissions = ", ".join(authorize_permissions_list) if authorize_permissions_list else "N/A"
# --- 判断是否可能缺失鉴权 ---
# 简单判断:如果既没有 login_required 也没有 authorize 权限,则标记
if not has_login_required and not authorize_permissions_list:
has_missing_auth = True
# --- 格式化输出 ---
login_status = "✅ Yes" if has_login_required else "❌ No"
auth_status = authorize_permissions if authorize_permissions != "N/A" else "❌ N/A"
# 格式化字符串长度
def truncate(s, length):
return s if len(s) <= length else s[:length-2] + ".."
endpoint_str = truncate(rule.endpoint, 34)
rule_str = truncate(str(rule), 34)
func_name_str = truncate(view_func.__name__, 29)
print(row_format.format(endpoint_str, rule_str, func_name_str, login_status, auth_status))
print("-" * 120)
if has_missing_auth:
print("\n⚠️ 注意:以上标记为 '❌ No''❌ N/A' 的路由可能缺少鉴权,请仔细检查!")
else:
print("\n✅ 所有路由似乎都应用了至少一种鉴权机制。")
print("="*120 + "\n")
-60
View File
@@ -1,60 +0,0 @@
from dotenv import dotenv_values
import sqlparse
import pymysql
config = dotenv_values('.flaskenv')
# MySql配置信息
HOST = config.get('MYSQL_HOST') or '127.0.0.1'
PORT = config.get('MYSQL_PORT') or 3306
DATABASE = config.get('MYSQL_DATABASE') or 'PearAdminFlask'
USERNAME = config.get('MYSQL_USERNAME') or 'root'
PASSWORD = config.get('MYSQL_PASSWORD') or '123456'
def is_exist_database():
db = pymysql.connect(host=HOST, port=int(PORT), user=USERNAME, password=PASSWORD, charset='utf8mb4')
cursor1 = db.cursor()
sql = "select * from information_schema.SCHEMATA WHERE SCHEMA_NAME = '%s' ; " % DATABASE
res = cursor1.execute(sql)
db.close()
return res
def init_database():
db = pymysql.connect(host=HOST, port=int(PORT), user=USERNAME, password=PASSWORD, charset='utf8mb4')
cursor1 = db.cursor()
sql = "CREATE DATABASE IF NOT EXISTS %s CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;" % DATABASE
res = cursor1.execute(sql)
db.close()
return res
def execute_fromfile(filename):
db = pymysql.connect(host=HOST, port=int(PORT), user=USERNAME, password=PASSWORD, database=DATABASE,
charset='utf8mb4')
fd = open(filename, 'r', encoding='utf-8')
cursor = db.cursor()
sqlfile = fd.read()
sqlfile = sqlparse.format(sqlfile, strip_comments=True).strip()
sqlcommamds = sqlfile.split(';')
for command in sqlcommamds:
try:
cursor.execute(command)
db.commit()
except Exception as msg:
db.rollback()
db.close()
def init_db():
if is_exist_database():
print('数据库已经存在,为防止误初始化,请手动删除 %s 数据库' % str(DATABASE))
return
if init_database():
print('数据库%s创建成功' % str(DATABASE))
execute_fromfile('test/pear.sql')
print('表创建成功')
print('欢迎使用pear-admin-flask,请使用 flask run 命令启动程序')
@@ -1,92 +0,0 @@
import os
import jinja2
class NewViewModular():
def __init__(self, name):
self.name = name
def path_is_exists(self):
return os.path.exists(f"applications/view/{self.name.split('/')[0]}")
def new_dirs(self):
if not self.path_is_exists():
# 如果不存在则创建目录
# 创建目录操作函数
os.makedirs(f"applications/view/{self.name.split('/')[0]}")
print(self.name + ' 创建成功')
return True
else:
return False
def add_view(self):
# jinja渲染
templateLoader = jinja2.FileSystemLoader(searchpath='applications/common/script/newmodular/template/')
templateEnv = jinja2.Environment(loader=templateLoader)
TEMPLATE_FILE = "view"
template = templateEnv.get_template(TEMPLATE_FILE, )
templateVars = {"name": self.name}
outputText = template.render(templateVars)
with open(f"applications/view/{self.name}.py", "a", encoding='utf-8') as f:
f.write(outputText)
f.close()
def add_init(self):
# 判断__init__.py
if os.path.exists(f"applications/view/{self.name.split('/')[0]}/__init__.py"):
with open(f"applications/view/{self.name.split('/')[0]}/__init__.py", "r") as file:
lines = file.readlines()
print(lines)
file.close()
import_line_num = 0
for line in lines:
if 'def' in line:
import_line_num = lines.index(line) - 2
with open(f"applications/view/{self.name.split('/')[0]}/__init__.py", "w") as file:
lines.insert(import_line_num,
f"from applications.view.{self.name.replace('/', '.')} import {self.name.replace('/', '_')}\n")
print(lines)
lines.insert(len(lines) - 1, f" app.register_blueprint({self.name.replace('/', '_')})\n")
print(lines)
file.writelines(lines)
file.close()
return True
else:
templateLoader = jinja2.FileSystemLoader(searchpath='applications/common/script/newmodular/template/')
templateEnv = jinja2.Environment(loader=templateLoader)
TEMPLATE_FILE = "__init__"
template = templateEnv.get_template(TEMPLATE_FILE, )
templateVars = {"name": self.name}
output = template.render(templateVars)
with open(f"applications/view/{self.name.split('/')[0]}/__init__.py", "a", encoding='utf-8') as f:
f.write(output)
f.close()
self.add_root_init()
return True
def add_root_init(self):
with open(f"applications/view/__init__.py", "r") as file:
lines = file.readlines()
file.close()
import_line_num = 0
for line in lines:
if 'def' in line:
import_line_num = lines.index(line) - 2
with open(f"applications/view/__init__.py", "w") as file:
lines.insert(
import_line_num,
f"from applications.view.{self.name.split('/')[0]} import register_{self.name.split('/')[0]}_views\n")
print(lines)
lines.insert(len(lines) - 1, f" register_{self.name.split('/')[0]}_views(app)\n")
print(lines)
file.writelines(lines)
file.close()
def new_view(self):
self.new_dirs()
self.add_view()
self.add_init()
@@ -1,6 +0,0 @@
from flask import Flask
from applications.view.{{name.replace('/','.')}} import {{name.replace('/','_')}}
def register_{{name.split('/')[0]}}_views(app: Flask):
app.register_blueprint({{name.replace('/','_')}})
@@ -1,8 +0,0 @@
from flask import Blueprint
{{name.replace('/','_')}} = Blueprint('{{name.replace('/','_')}}', __name__, url_prefix='/{{name}}')
@{{name.replace('/','_')}}.route('/')
def index():
return "这是{{name}}路由"
-54
View File
@@ -1,54 +0,0 @@
from apscheduler.events import (
EVENT_JOB_ADDED,
EVENT_JOB_ERROR,
EVENT_JOB_EXECUTED,
EVENT_JOB_MISSED,
EVENT_JOB_REMOVED,
EVENT_JOB_SUBMITTED,
)
from applications.extensions.init_apscheduler import scheduler
def job_missed(event):
"""Job missed event."""
with scheduler.app.app_context():
print(event) # noqa: T001
def job_error(event):
"""Job error event."""
with scheduler.app.app_context():
print(event) # noqa: T001
def job_executed(event):
"""Job executed event."""
with scheduler.app.app_context():
print(event) # noqa: T001
def job_added(event):
"""Job added event."""
with scheduler.app.app_context():
print(event) # noqa: T001
def job_removed(event):
"""Job removed event."""
with scheduler.app.app_context():
print(event) # noqa: T001
def job_submitted(event):
"""Job scheduled to run event."""
with scheduler.app.app_context():
print(event) # noqa: T001
scheduler.add_listener(job_missed, EVENT_JOB_MISSED)
scheduler.add_listener(job_error, EVENT_JOB_ERROR)
scheduler.add_listener(job_executed, EVENT_JOB_EXECUTED)
scheduler.add_listener(job_added, EVENT_JOB_ADDED)
scheduler.add_listener(job_removed, EVENT_JOB_REMOVED)
scheduler.add_listener(job_submitted, EVENT_JOB_SUBMITTED)
-15
View File
@@ -1,15 +0,0 @@
import datetime
task_list = ['task2', 'task3', 'task4']
def task2(a, b):
print(f'定时任务_1_{a},{b},{datetime.datetime.now()}')
def task3(a, b):
print(f'定时任务_2_{a}{b}{datetime.datetime.now()}')
def task4(a, b):
print(f'定时任务_4_{a}{b}{datetime.datetime.now()}')
+55
View File
@@ -0,0 +1,55 @@
import time
cache_dict = {}
def cache_set_internal(key, value, expired=5):
"""
程序内部实现的记录缓存,用于简单、体量不大的缓存记录,在程序结束后销毁。对于高速、体量大的环境请配置 Redis 等服务自行记录。
记录缓存,存储键值对,并记录当前时间作为缓存的时间戳。
:param key: 键
:param value: 值
:param expired: 过期时间(秒),默认5秒
"""
cache_dict[key] = {
'value': value,
'expired_time': time.time() + expired
}
def cache_get_internal(key):
"""
获取缓存,根据键从缓存中获取值,并检查是否过期。
:param key: 键
:return: 如果缓存存在且未过期,返回缓存的值;否则返回 None
"""
if key in cache_dict:
cache_item = cache_dict[key]
if time.time() < cache_item['expired_time']:
return cache_item['value']
else:
# 如果缓存已过期,删除该缓存
del cache_dict[key]
return None
def cache_auto_internal(key, call, expired=5):
"""
如果缓存存在直接返回缓存内容,缓存不存在或者过期执行 call 函数,并取得返回值记录并返回。
:param key: 键
:param call: 获取新值的地方
:param expired: 过期时间(秒),默认5秒
"""
data = cache_get_internal(key)
if data is not None:
return data
data = call()
cache_set_internal(key, data, expired)
return data
+135
View File
@@ -0,0 +1,135 @@
import random, math
from PIL import Image, ImageDraw, ImageFont, ImageFilter
class vieCode:
__fontSize = 20 # 字体大小
__width = 120 # 画布宽度
__heigth = 45 # 画布高度
__length = 4 # 验证码长度
__draw = None # 画布对象
__img = None # 图片对象
__code = None # 验证码字符
__str = None # 自定义验证码字符集
__inCurve = True # 是否绘制干扰曲线
__inNoise = True # 是否绘制干扰点
__type = 2 # 验证码类型:1-纯字母,2-数字字母混合
__fontPatn = 'applications/common/utils/fonts/captcha.ttf' # 字体路径
def GetCodeImage(self, size=80, length=4):
"""
生成验证码图片及其对应的验证码字符。
:param size: 验证码字体大小,默认为 80。
:param length: 验证码字符长度,默认为 4。
:return: 返回验证码图片对象和验证码字符。
"""
# 准备基础数据
self.__length = length
self.__fontSize = size
self.__width = self.__fontSize * self.__length
self.__heigth = int(self.__fontSize * 1.5)
# 生成验证码图片
self.__createCode()
self.__createImage()
self.__createNoise()
self.__printString()
self.__cerateFilter()
return self.__img, self.__code
def __cerateFilter(self):
"""
对验证码图片进行模糊处理,增加识别难度。
"""
self.__img = self.__img.filter(ImageFilter.BLUR)
filter = ImageFilter.ModeFilter(8)
self.__img = self.__img.filter(filter)
def __createCode(self):
"""
生成验证码字符。
"""
# 是否使用自定义字符集
if not self.__str:
# 源文本
number = "3456789"
srcLetter = "qwertyuipasdfghjkzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM"
srcUpper = srcLetter.upper()
if self.__type == 1:
self.__str = number
else:
self.__str = srcLetter + srcUpper + number
# 随机生成验证码字符
self.__code = random.sample(self.__str, self.__length)
def __createImage(self):
"""
创建画布并设置背景颜色。
"""
bgColor = (random.randint(200, 255), random.randint(200, 255), random.randint(200, 255))
self.__img = Image.new('RGB', (self.__width, self.__heigth), bgColor)
self.__draw = ImageDraw.Draw(self.__img)
def __createNoise(self):
"""
在验证码图片上绘制干扰点。
"""
if not self.__inNoise:
return
font = ImageFont.truetype(self.__fontPatn, int(self.__fontSize / 1.5))
for i in range(5):
# 干扰点颜色
noiseColor = (random.randint(150, 200), random.randint(150, 200), random.randint(150, 200))
putStr = random.sample(self.__str, 2)
for j in range(2):
# 绘制干扰点
size = (random.randint(-10, self.__width), random.randint(-10, self.__heigth))
self.__draw.text(size, putStr[j], font=font, fill=noiseColor)
def __createCurve(self):
"""
在验证码图片上绘制干扰曲线。
"""
if not self.__inCurve:
return
x = y = 0
# 计算曲线系数
a = random.uniform(1, self.__heigth / 2)
b = random.uniform(-self.__width / 4, self.__heigth / 4)
f = random.uniform(-self.__heigth / 4, self.__heigth / 4)
t = random.uniform(self.__heigth, self.__width * 2)
xend = random.randint(self.__width / 2, self.__width * 2)
w = (2 * math.pi) / t
# 绘制曲线
color = (random.randint(30, 150), random.randint(30, 150), random.randint(30, 150))
for x in range(xend):
if w != 0:
for k in range(int(self.__heigth / 10)):
y = a * math.sin(w * x + f) + b + self.__heigth / 2
i = int(self.__fontSize / 5)
while i > 0:
px = x + i
py = y + i + k
self.__draw.point((px, py), color)
i -= i
def __printString(self):
"""
在画布上打印验证码字符。
"""
font = ImageFont.truetype(self.__fontPatn, self.__fontSize)
x = 0
# 打印字符到画布
for i in range(self.__length):
# 设置字体随机颜色
color = (random.randint(30, 150), random.randint(30, 150), random.randint(30, 150))
# 计算坐标
x = random.uniform(self.__fontSize * i * 0.95, self.__fontSize * i * 1.1)
y = self.__fontSize * random.uniform(0.3, 0.5)
# 打印字符
self.__draw.text((x, y), self.__code[i], font=font, fill=color)
Binary file not shown.
-14
View File
@@ -1,14 +0,0 @@
from captcha.image import ImageCaptcha
from PIL import Image
from random import choices
def gen_captcha(content='2345689abcdefghijklmnpqrstuvwxyzABCDEFGHIJKLMNPQRSTUVWXYZ'):
""" 生成验证码 """
image = ImageCaptcha()
# 获取字符串
captcha_text = "".join(choices(content, k=4)).lower()
# 生成图像
captcha_image = Image.open(image.generate(captcha_text))
return captcha_text, captcha_image
+23 -6
View File
@@ -1,24 +1,41 @@
from flask import jsonify from flask import jsonify, request
def success_api(msg: str = "成功"): def success_api(msg: str = "成功"):
""" 成功响应 默认值”成功“ """ """
返回成功的 API 响应。
:param msg: 成功消息内容,默认为 "成功"
:return: 返回 JSON 格式的响应,包含 `success` 和 `msg` 字段。
"""
return jsonify(success=True, msg=msg) return jsonify(success=True, msg=msg)
def fail_api(msg: str = "失败"): def fail_api(msg: str = "失败"):
""" 失败响应 默认值“失败” """ """
返回失败的 API 响应。
:param msg: 失败消息内容,默认为 "失败"
:return: 返回 JSON 格式的响应,包含 `success` 和 `msg` 字段。
"""
return jsonify(success=False, msg=msg) return jsonify(success=False, msg=msg)
def table_api(msg: str = "", count=0, data=None, limit=10): def table_api(msg: str = "", count=0, data=None, limit=10):
""" 动态表格渲染响应 """ """
返回动态表格渲染所需的 API 响应。
:param msg: 响应消息内容,默认为空字符串。
:param count: 数据总数,默认为 0。
:param data: 表格数据,默认为 None。
:param limit: 每页数据条数,默认为 10。
:return: 返回 JSON 格式的响应,包含 `msg`、`code`、`data`、`count` 和 `limit` 字段。
"""
res = { res = {
'msg': msg, 'msg': msg,
'code': 0, 'code': 0,
'data': data, 'data': data,
'count': count, 'count': count,
'limit': limit 'limit': min(request.args.get('limit', default=10, type=int), 90)
} }
return jsonify(res) return jsonify(res)
+88 -8
View File
@@ -1,13 +1,93 @@
"""
集成了对 Pear Admin Flask 二次开发的邮件操作模块,并提供了相应的示例。
"""
from flask import current_app
from flask_mail import Message from flask_mail import Message
from applications.extensions.init_mail import mail from applications.common.curd import model_to_dicts
from applications.common.helper import ModelFilter
from applications.extensions import db, flask_mail
from applications.models import Mail
from applications.schemas import MailOutSchema
def get_all(receiver=None, subject=None, content=None):
"""
获取邮件列表,支持根据接收者、主题和内容进行筛选。
返回的列表中的字典结构如下::
{
"content": "", # HTML 内容
"create_at": "2022-12-25T10:51:17", # 创建时间
"id": 17, # 邮件ID
"realname": "超级管理", # 创建者姓名
"receiver": "", # 接收者
"subject": "" # 邮件主题
}
:param receiver: 接收者邮箱地址,支持模糊查询。
:param subject: 邮件主题,支持模糊查询。
:param content: 邮件内容,支持模糊查询。
:return: 返回符合条件的邮件列表。
"""
# 构造查询条件
mf = ModelFilter()
if receiver:
mf.contains(field_name="receiver", value=receiver)
if subject:
mf.contains(field_name="subject", value=subject)
if content:
mf.exact(field_name="content", value=content)
# 查询邮件数据并分页
mail = Mail.query.filter(mf.get_filter(Mail)).layui_paginate()
return model_to_dicts(schema=MailOutSchema, data=mail.items)
def add(receiver, subject, content, user_id):
"""
发送一封邮件,并将发送记录保存到数据库。 **该方法被邮件发送的视图函数调用。**
:param receiver: 接收者邮箱地址,多个邮箱用英文分号隔开。
:param subject: 邮件主题。
:param content: 邮件内容(HTML 格式)。
:param user_id: 发送者用户ID,表示谁发送了这封邮件。
可以使用 `from flask_login import current_user; current_user.id` 获取当前登录用户的ID。
:return: 发送成功返回 True,失败报错。
"""
send_mail(subject=subject, recipients=receiver.split(";"), content=content)
# 保存邮件记录到数据库
mail = Mail(receiver=receiver, subject=subject, content=content, user_id=user_id)
db.session.add(mail)
db.session.commit()
return True
def delete(id):
"""
删除指定的邮件记录。
:param id: 邮件ID。
:return: 删除成功返回 True,失败返回 False。
"""
res = Mail.query.filter_by(id=id).delete()
if not res:
return False
db.session.commit()
return True
# send_mail(subject='title', recipients=['123@qq.com'], content='body')
def send_mail(subject, recipients, content): def send_mail(subject, recipients, content):
try: """
message = Message(subject=subject, recipients=recipients, body=content) 发送邮件(不记录发送日志)。
mail.send(message)
except Exception as e: 注意:如果发送失败会抛出异常,请使用 try-except 进行捕获。
print('邮箱发送出错了')
raise :param subject: 邮件主题。
:param recipients: 接收者邮箱地址,多个邮箱用英文分号隔开。
:param content: 邮件内容(HTML 格式)。
"""
message = Message(subject=subject, recipients=recipients, html=content)
flask_mail.send(message)
+22 -3
View File
@@ -1,14 +1,31 @@
from functools import wraps from functools import wraps
from flask import abort, request, jsonify, session from flask import abort, request, jsonify, session, current_app
from flask_login import login_required from flask_login import login_required, current_user
from applications.common.admin_log import admin_log from applications.common.admin import admin_log
def authorize(power: str, log: bool = False): def authorize(power: str, log: bool = False):
"""
用户权限判断,用于判断目前会话用户是否拥有访问权限。
在模板中有与之对应的全局非修饰函数 authorize ,此函数定义位于 `applications/extensions/init_template_directives.py` 。
:param power: 权限标识
:type power: str
:param log: 是否记录日志, defaults to False
:type log: bool, optional
"""
def decorator(func): def decorator(func):
@login_required @login_required
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
# 定义管理员的id为1
if current_user.username == current_app.config.get("SUPERADMIN"):
if log:
admin_log(request=request, is_access=True)
return func(*args, **kwargs)
if not power in session.get('permissions'): if not power in session.get('permissions'):
if log: if log:
admin_log(request=request, is_access=False) admin_log(request=request, is_access=False)
@@ -16,8 +33,10 @@ def authorize(power: str, log: bool = False):
abort(403) abort(403)
else: else:
return jsonify(success=False, msg="权限不足!") return jsonify(success=False, msg="权限不足!")
if log: if log:
admin_log(request=request, is_access=True) admin_log(request=request, is_access=True)
return func(*args, **kwargs) return func(*args, **kwargs)
return wrapper return wrapper
+235 -15
View File
@@ -1,20 +1,240 @@
# xss过滤 # XSS 过滤
from flask import abort, make_response, jsonify import validators
from markupsafe import escape
from validators import validator
def xss_escape(s: str): def str_escape(s):
if s is None: """
对字符串进行 XSS 过滤,返回转义后的安全字符串。
:param s: 需要转义的字符串。
:return: 返回转义后的字符串,如果输入为空则返回 None。
"""
if not s:
return None return None
else: return str(escape(s))
return s.replace("&", "&amp;").replace(">", "&gt;").replace("<", "&lt;").replace("'", "&#39;").replace('"',
"&#34;")
def check_data(schema, data): def between(*args, **kwargs):
errors = schema.validate(data) """
for k, v in errors.items(): 验证数字是否介于最小值和最大值之间。
for i in v: 适用于整数、浮点数、小数和日期等类型。
# print("{}{}".format(k, i))
msg = "{}{}".format(k, i) :param value: 需要验证的数字。
if errors: :param min: 数字的最小值(可选)。
abort(make_response(jsonify(result=False, msg=msg), 200)) :param max: 数字的最大值(可选)。
:return: 如果验证成功返回 True,否则返回 ValidationFailure。
示例:
>>> between(5, min=2)
True
>>> between(13.2, min=13, max=14)
True
>>> between(500, max=400)
ValidationFailure(func=between, args=...)
"""
return validators.between(*args, **kwargs)
def domain(*args, **kwargs):
"""
验证给定值是否为有效的域名。
:param value: 需要验证的域名字符串。
:return: 如果验证成功返回 True,否则返回 ValidationFailure。
示例:
>>> domain('example.com')
True
>>> domain('example.com/')
ValidationFailure(func=domain, ...)
"""
return validators.domain(*args, **kwargs)
def email(*args, **kwargs):
"""
验证给定值是否为有效的电子邮件地址。
:param value: 需要验证的电子邮件地址。
:return: 如果验证成功返回 True,否则返回 ValidationFailure。
示例:
>>> email('someone@example.com')
True
>>> email('bogus@@')
ValidationFailure(func=email, ...)
"""
return validators.email(*args, **kwargs)
def iban(*args, **kwargs):
"""
验证给定值是否为有效的 IBAN 代码。
:param value: 需要验证的 IBAN 代码。
:return: 如果验证成功返回 True,否则返回 ValidationFailure。
示例:
>>> iban('DE29100500001061045672')
True
>>> iban('123456')
ValidationFailure(func=iban, ...)
"""
return validators.iban(*args, **kwargs)
def ipv4(*args, **kwargs):
"""
验证给定值是否为有效的 IPv4 地址。
:param value: 需要验证的 IPv4 地址。
:return: 如果验证成功返回 True,否则返回 ValidationFailure。
示例:
>>> ipv4('123.0.0.7')
True
>>> ipv4('900.80.70.11')
ValidationFailure(func=ipv4, args={'value': '900.80.70.11'})
"""
return validators.ipv4(*args, **kwargs)
def ipv6(*args, **kwargs):
"""
验证给定值是否为有效的 IPv6 地址。
:param value: 需要验证的 IPv6 地址。
:return: 如果验证成功返回 True,否则返回 ValidationFailure。
示例:
>>> ipv6('abcd:ef::42:1')
True
>>> ipv6('abc.0.0.1')
ValidationFailure(func=ipv6, args={'value': 'abc.0.0.1'})
"""
return validators.ipv6(*args, **kwargs)
def length(*args, **kwargs):
"""
验证给定字符串的长度是否在指定范围内。
:param value: 需要验证的字符串。
:param min: 字符串的最小长度(可选)。
:param max: 字符串的最大长度(可选)。
:return: 如果验证成功返回 True,否则返回 ValidationFailure。
示例:
>>> length('something', min=2)
True
>>> length('something', min=9, max=9)
True
>>> length('something', max=5)
ValidationFailure(func=length, ...)
"""
return validators.length(*args, **kwargs)
def mac_address(*args, **kwargs):
"""
验证给定值是否为有效的 MAC 地址。
:param value: 需要验证的 MAC 地址。
:return: 如果验证成功返回 True,否则返回 ValidationFailure。
示例:
>>> mac_address('01:23:45:67:ab:CD')
True
>>> mac_address('00:00:00:00:00')
ValidationFailure(func=mac_address, args={'value': '00:00:00:00:00'})
"""
return validators.mac_address(*args, **kwargs)
def slug(*args, **kwargs):
"""
验证给定值是否为有效的 Slug 格式。
有效的 Slug 只能包含字母数字字符、连字符和下划线。
:param value: 需要验证的字符串。
:return: 如果验证成功返回 True,否则返回 ValidationFailure。
示例:
>>> slug('my.slug')
ValidationFailure(func=slug, args={'value': 'my.slug'})
>>> slug('my-slug-2134')
True
"""
return validators.slug(*args, **kwargs)
def url(*args, **kwargs):
"""
验证给定值是否为有效的 URL。
:param value: 需要验证的 URL。
:param public: 是否仅允许公共 URL(可选)。
:return: 如果验证成功返回 True,否则返回 ValidationFailure。
示例:
>>> url('http://foobar.dk')
True
>>> url('http://10.0.0.1')
True
>>> url('http://foobar.d')
ValidationFailure(func=url, ...)
>>> url('http://10.0.0.1', public=True)
ValidationFailure(func=url, ...)
"""
return validators.url(*args, **kwargs)
def uuid(*args, **kwargs):
"""
验证给定值是否为有效的 UUID。
:param value: 需要验证的 UUID。
:return: 如果验证成功返回 True,否则返回 ValidationFailure。
示例:
>>> uuid('2bc1c94f-0deb-43e9-92a1-4775189ec9f8')
True
>>> uuid('2bc1c94f 0deb-43e9-92a1-4775189ec9f8')
ValidationFailure(func=uuid, ...)
"""
return validators.uuid(*args, **kwargs)
@validator
def even(value):
"""
验证给定值是否为偶数。
:param value: 需要验证的数字。
:return: 如果是偶数返回 True,否则返回 ValidationFailure。
示例:
>>> even(4)
True
>>> even(5)
ValidationFailure(func=even, args={'value': 5})
"""
return not (value % 2)
+130
View File
@@ -0,0 +1,130 @@
import logging
import os
import secrets
from datetime import timedelta
def _env_bool(key: str, default: bool = False) -> bool:
"""读取布尔环境变量,兼容 true/1/yes/on(大小写不敏感)。"""
val = os.environ.get(key)
if val is None:
return default
return val.strip().lower() in {"1", "true", "yes", "on"}
def _env_str(key: str, default: str = "") -> str:
val = os.environ.get(key)
return val if val is not None and val != "" else default
class BaseConfig:
"""通用配置:所有环境共有的项。子类按需覆盖。"""
# ---- 应用基础 ----
SUPERADMIN = 'admin'
SYSTEM_NAME = 'Pear Admin'
SYSTEM_PANEL_LINKS = [
{"icon": "layui-icon layui-icon-auz", "title": "官方网站", "href": "http://www.pearadmin.com"},
{"icon": "layui-icon layui-icon-auz", "title": "开发文档", "href": "http://www.pearadmin.com"},
{"icon": "layui-icon layui-icon-auz", "title": "开源地址", "href": "https://gitee.com/Jmysy/Pear-Admin-Layui"},
]
# ---- 上传 ----
UPLOADED_PHOTOS_DEST = 'static/upload'
UPLOADED_FILES_ALLOW = ['gif', 'jpg', 'jpeg', 'png', 'webp']
UPLOADS_AUTOSERVE = True
# ---- JSON ----
JSON_AS_ASCII = False
# ---- 数据库 ----
# 默认走项目根目录下的 pear.db(与 instance/ 同一级,Flask 2.x 行为)。
# 生产环境推荐通过 SQLALCHEMY_DATABASE_URI 环境变量切换到 MySQL。
SQLALCHEMY_DATABASE_URI = 'sqlite:///../pear.db'
SQLALCHEMY_TRACK_MODIFICATIONS = False
# ---- 日志 ----
LOG_LEVEL = logging.WARN
# ---- Session ----
PERMANENT_SESSION_LIFETIME = timedelta(days=7)
SESSION_TYPE = "filesystem"
SESSION_PERMANENT = False
SESSION_USE_SIGNER = True
# ---- 安全 / 密钥 ----
# SECRET_KEY 的优先级:
# 1. 环境变量 SECRET_KEY
# 2. .env 中的 SECRET_KEY
# 3. 自动生成一个临时随机值(仅用于开发启动;生产模式强制要求外部传入)
SECRET_KEY = os.environ.get("SECRET_KEY") or "pear-system-flask"
# ---- Mail(默认占位;环境变量可覆盖)----
MAIL_SERVER = _env_str("MAIL_SERVER", "smtp.qq.com")
MAIL_USE_TLS = _env_bool("MAIL_USE_TLS", False)
MAIL_USE_SSL = _env_bool("MAIL_USE_SSL", True)
MAIL_PORT = int(_env_str("MAIL_PORT", "465"))
MAIL_USERNAME = _env_str("MAIL_USERNAME", "123@qq.com")
MAIL_PASSWORD = _env_str("MAIL_PASSWORD", "XXXXX") # QQ 邮箱授权码
MAIL_DEFAULT_SENDER = _env_str("MAIL_DEFAULT_SENDER", MAIL_USERNAME)
# ---- 插件 ----
PLUGIN_ENABLE_FOLDERS = []
class DevConfig(BaseConfig):
"""开发环境配置:DEBUG=True,详细日志。"""
DEBUG = True
TESTING = False
LOG_LEVEL = logging.INFO
# 开发环境下 SECRET_KEY 自动生成(每次启动都不同,仅用于本地调试)。
SECRET_KEY = os.environ.get("SECRET_KEY") or secrets.token_urlsafe(48)
class ProConfig(BaseConfig):
"""生产环境配置:DEBUG=False,强制要求外部传入 SECRET_KEY。"""
DEBUG = False
TESTING = False
LOG_LEVEL = logging.WARN
# 生产推荐使用 MySQL,例如:
# SQLALCHEMY_DATABASE_URI=mysql+pymysql://user:pass@host:3306/pear?charset=utf8mb4
SQLALCHEMY_DATABASE_URI = os.environ.get(
"SQLALCHEMY_DATABASE_URI",
BaseConfig.SQLALCHEMY_DATABASE_URI,
)
@classmethod
def init_app(cls, app):
"""Flask 在 app.config.from_object 之后调用此钩子。
生产模式要求:必须从环境变量传入强随机 SECRET_KEY,
否则启动失败 —— 防止线上仍用默认密钥。
"""
secret = os.environ.get("SECRET_KEY")
if not secret or secret == "pear-system-flask":
raise RuntimeError(
"[ProConfig] 生产环境必须通过环境变量 SECRET_KEY 传入强随机密钥,"
"禁止使用默认值。可用以下命令生成:\n"
" python -c \"import secrets; print(secrets.token_urlsafe(48))\""
)
app.config["SECRET_KEY"] = secret
# 暴露给外部按名字取
config_map = {
"dev": DevConfig,
"development": DevConfig,
"pro": ProConfig,
"production": ProConfig,
}
def get_config_by_name(name: str):
"""根据名字取配置类,未知名字退回 DevConfig。"""
if not name:
return DevConfig
return config_map.get(name.strip().lower(), DevConfig)
-1
View File
@@ -1 +0,0 @@
from .config import config
-87
View File
@@ -1,87 +0,0 @@
import logging
import os
from urllib.parse import quote_plus as urlquote
class BaseConfig:
SYSTEM_NAME = os.getenv('SYSTEM_NAME', 'Pear Admin')
# 主题面板的链接列表配置
SYSTEM_PANEL_LINKS = [
{
"icon": "layui-icon layui-icon-auz",
"title": "官方网站",
"href": "http://www.pearadmin.com"
},
{
"icon": "layui-icon layui-icon-auz",
"title": "开发文档",
"href": "http://www.pearadmin.com"
},
{
"icon": "layui-icon layui-icon-auz",
"title": "开源地址",
"href": "https://gitee.com/Jmysy/Pear-Admin-Layui"
}
]
UPLOADED_PHOTOS_DEST = 'static/upload'
UPLOADED_FILES_ALLOW = ['gif', 'jpg']
# JSON配置
JSON_AS_ASCII = False
SECRET_KEY = os.getenv('SECRET_KEY', 'dev key')
# redis配置
REDIS_HOST = os.getenv('REDIS_HOST') or "127.0.0.1"
REDIS_PORT = int(os.getenv('REDIS_PORT') or 6379)
# mysql 配置
MYSQL_USERNAME = os.getenv('MYSQL_USERNAME') or "root"
MYSQL_PASSWORD = os.getenv('MYSQL_PASSWORD') or "123456"
MYSQL_HOST = os.getenv('MYSQL_HOST') or "127.0.0.1"
MYSQL_PORT = int(os.getenv('MYSQL_PORT') or 3306)
MYSQL_DATABASE = os.getenv('MYSQL_DATABASE') or "PearAdminFlask"
# mysql 数据库的配置信息
SQLALCHEMY_DATABASE_URI = f"mysql+pymysql://{MYSQL_USERNAME}:{urlquote(MYSQL_PASSWORD)}@{MYSQL_HOST}:{MYSQL_PORT}/{MYSQL_DATABASE}?charset=utf8mb4"
# 默认日志等级
LOG_LEVEL = logging.WARN
#
MAIL_SERVER = os.getenv('MAIL_SERVER') or 'smtp.qq.com'
MAIL_USE_TLS = False
MAIL_USE_SSL = True
MAIL_PORT = 465
MAIL_USERNAME = os.getenv('MAIL_USERNAME') or '123@qq.com'
MAIL_PASSWORD = os.getenv('MAIL_PASSWORD') or 'XXXXX' # 生成的授权码
# 默认发件人的邮箱,这里填写和MAIL_USERNAME一致即可
MAIL_DEFAULT_SENDER = ('pear admin', os.getenv('MAIL_USERNAME') or '123@qq.com')
class TestingConfig(BaseConfig):
""" 测试配置 """
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:' # 内存数据库
class DevelopmentConfig(BaseConfig):
""" 开发配置 """
SQLALCHEMY_TRACK_MODIFICATIONS = True
SQLALCHEMY_ECHO = False
class ProductionConfig(BaseConfig):
"""生成环境配置"""
SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_ECHO = False
SQLALCHEMY_POOL_RECYCLE = 8
LOG_LEVEL = logging.ERROR
config = {
'development': DevelopmentConfig,
'testing': TestingConfig,
'production': ProductionConfig
}
+21 -10
View File
@@ -1,23 +1,34 @@
from flask import Flask from flask import Flask
from .init_sqlalchemy import db, ma, init_databases from .init_sqlalchemy import db, ma, init_databases
from .init_login import init_login_manager from .init_login import init_login_manager
from .init_debug_tool import init_debug_tool
from .init_template_directives import init_template_directives from .init_template_directives import init_template_directives
from .init_error_views import init_error_views from .init_error_views import init_error_views
from .init_mail import init_mail from .init_mail import init_mail, mail as flask_mail
from .init_apscheduler import init_scheduler
from .init_upload import init_upload from .init_upload import init_upload
from .init_dotenv import init_dotenv from .init_migrate import init_migrate
from .init_session import init_session
from .init_limit import init_limit
from .init_plugins import register_plugin, broadcast_execute
def init_plugs(app: Flask) -> None: def init_plugs(app: Flask) -> None:
# init_debug_tool(app) # 注册插件a
register_plugin(app)
broadcast_execute(app, 'event_begin')
# 注册 Flask 功能
init_login_manager(app) init_login_manager(app)
init_databases(app) init_databases(app)
init_mail(app)
init_upload(app)
init_migrate(app)
init_session(app)
init_limit(app)
# 系统蓝图相关
init_template_directives(app) init_template_directives(app)
init_error_views(app) init_error_views(app)
init_mail(app)
init_scheduler(app) # 初始化插件
init_upload(app) broadcast_execute(app, 'event_init')
init_dotenv() broadcast_execute(app, 'event_finish')
@@ -1,12 +0,0 @@
from flask import Flask
from flask_apscheduler import APScheduler
scheduler = APScheduler()
def init_scheduler(app: Flask):
scheduler.init_app(app)
with app.app_context():
from applications.common.tasks import tasks
scheduler.start()
from applications.common.tasks import events
@@ -1,7 +0,0 @@
from flask import Flask
from flask_debugtoolbar import DebugToolbarExtension
def init_debug_tool(app: Flask):
toolbar = DebugToolbarExtension()
toolbar.init_app(app)
-14
View File
@@ -1,14 +0,0 @@
import os
from dotenv import load_dotenv
root_path = os.path.abspath(os.path.dirname(__file__)).split('applications')[0]
# dot_env_path = os.path.join(root_path, '.env')
flask_env_path = os.path.join(root_path, '.flaskenv')
# if os.path.exists(dot_env_path):
# load_dotenv(dot_env_path)
def init_dotenv():
if os.path.exists(flask_env_path):
load_dotenv(flask_env_path)
+9 -18
View File
@@ -10,25 +10,16 @@ def init_error_views(app):
def page_not_found(e): def page_not_found(e):
return render_template('errors/404.html'), 404 return render_template('errors/404.html'), 404
@app.errorhandler(405)
def page_not_found(e):
return render_template('errors/404.html'), 404
@app.errorhandler(500) @app.errorhandler(500)
def internal_server_error(e): def internal_server_error(e):
return render_template('errors/500.html'), 500 return render_template('errors/500.html'), 500
# Return validation errors as JSON @app.errorhandler(429)
@app.errorhandler(422) def ratelimit_exceeded(e):
@app.errorhandler(400) return jsonify(
def handle_error(err): success=False, msg="请求频率超限,请稍后再试。"
headers = err.data.get("headers", None) )
messages = err.data.get("messages", ["Invalid request."]).get('json')
print(err.data.get("messages"))
print(messages.items())
msg = ''
for i in messages.items():
msg = str(i[0]) + str(i[1][0])
break
if headers:
return jsonify({"success": False, "msg": msg})
else:
return jsonify({"success": False, "msg": msg})
+20
View File
@@ -0,0 +1,20 @@
from flask_limiter import Limiter
from flask_login import current_user
from flask import request, Flask
# 定义 key 函数:优先使用 current_user.id,否则用 IP
def get_user_identifier():
# 注意:current_user 可能是 AnonymousUserMixin(未登录)
if hasattr(current_user, 'id') and current_user.is_authenticated:
return str(current_user.id) # 用户 ID 作为 key
return request.remote_addr # 未登录用户则按 IP 限流
limiter = Limiter(
key_func=get_user_identifier,
# default_limits=["100 per hour"], # 可选全局默认
storage_uri="memory://" # 生产建议: "redis://localhost:6379"
)
def init_limit(app: Flask) -> None:
limiter.init_app(app)
+1 -2
View File
@@ -5,8 +5,7 @@ def init_login_manager(app):
login_manager = LoginManager() login_manager = LoginManager()
login_manager.init_app(app) login_manager.init_app(app)
login_manager.login_view = 'passport.login' login_manager.login_view = 'system.passport.login'
login_manager.login_message = u'请登录以访问此页面'
@login_manager.user_loader @login_manager.user_loader
def load_user(user_id): def load_user(user_id):
+11
View File
@@ -0,0 +1,11 @@
from flask import Flask
from applications.extensions.init_sqlalchemy import db
from flask_migrate import Migrate
from applications.models import *
migrate = Migrate()
def init_migrate(app: Flask):
migrate.init_app(app, db)
+61
View File
@@ -0,0 +1,61 @@
import os
from flask import Flask
from flask import Blueprint
import json
import importlib
plugin_bp = Blueprint('plugin', __name__, url_prefix='/plugin')
PLUGIN_ENABLE_FOLDERS = []
PLUGIN_IMPORTLIB = []
def register_plugin(app: Flask):
"""
获取所有插件并加载
"""
global PLUGIN_ENABLE_FOLDERS
app.register_blueprint(plugin_bp)
# 载入插件过程
# plugin_folder 配置的是插件的文件夹名
PLUGIN_ENABLE_FOLDERS = app.config['PLUGIN_ENABLE_FOLDERS']
for plugin_folder in PLUGIN_ENABLE_FOLDERS:
plugin_info = {
'plugin_name': plugin_folder
}
try:
if os.path.exists("plugins/" + plugin_folder + "/__init__.json"):
with open("plugins/" + plugin_folder + "/__init__.json", "r", encoding='utf-8') as f:
plugin_info = json.loads(f.read())
# 将插件全部载入
PLUGIN_IMPORTLIB.append(importlib.import_module('plugins.' + plugin_folder))
print(f" * Plugin: Loaded plugin: {plugin_info['plugin_name']} .")
except BaseException as e:
info = f" * Plugin: Crash a error when loading {plugin_info['plugin_name'] if len(plugin_info) != 0 else 'plugin'} :" + "\n"
app.logger.error(info)
app.logger.exception(e)
def broadcast_execute(app: Flask, function_name):
for plugin in PLUGIN_IMPORTLIB:
try:
# 初始化完成事件
try:
getattr(plugin, function_name)(app)
except AttributeError: # 没有插件启用事件就不调用
pass
except BaseException as e:
app.logger.exception(e)
if function_name == 'event_finish':
with app.app_context():
broadcast_execute(app, 'event_context')
+7
View File
@@ -0,0 +1,7 @@
from flask_session import Session
sess = Session()
def init_session(app):
sess.init_app(app)
+49 -19
View File
@@ -1,7 +1,9 @@
import datetime import datetime
import os
from flask import Flask, request from flask import Flask, request
from flask_sqlalchemy import SQLAlchemy, BaseQuery from flask_sqlalchemy import SQLAlchemy
from flask_sqlalchemy.query import Query as BaseQuery
from flask_marshmallow import Marshmallow from flask_marshmallow import Marshmallow
from marshmallow import fields from marshmallow import fields
from marshmallow.validate import ( from marshmallow.validate import (
@@ -58,30 +60,52 @@ class Query(BaseQuery):
def all_json(self, schema: Marshmallow().Schema): def all_json(self, schema: Marshmallow().Schema):
return schema(many=True).dump(self.all()) return schema(many=True).dump(self.all())
def layui_paginate(self): def layui_paginate(self, page=None, limit=None):
return self.paginate(page=request.args.get('page', type=int), if page is None:
per_page=request.args.get('limit', type=int), page = request.args.get('page', type=int)
error_out=False) if limit is None:
limit = min(request.args.get('limit', default=10, type=int), 90)
return self.paginate(page=page,
per_page=limit,
error_out=False
)
def layui_paginate_json(self, schema, page=None, limit=None):
if page is None:
page = request.args.get('page', 1, type=int) # 添加默认值
if limit is None:
limit = request.args.get('limit', 10, type=int) # 添加默认值
def layui_paginate_json(self, schema: Marshmallow().Schema):
"""
返回dict
"""
_res = self.paginate( _res = self.paginate(
page=request.args.get('page', type=int), page=page,
per_page=request.args.get('limit', type=int), per_page=limit,
error_out=False error_out=False
) )
return schema(many=True).dump(_res.items), _res.total, _res.page, _res.per_page return schema(many=True).dump(_res.items), _res.total, _res.page, _res.per_page
def layui_paginate_db_json(self): def layui_paginate_db_json(self, page=None, limit=None):
""" if page is None:
db.query(A.name).layui_paginate_db_json() page = request.args.get('page', 1, type=int) # 添加默认值
""" if limit is None:
_res = self.paginate(page=request.args.get('page', type=int), limit = request.args.get('limit', 10, type=int) # 添加默认值
per_page=request.args.get('limit', type=int),
error_out=False) _res = self.paginate(
return [dict(i) for i in _res.items], _res.total page=page,
per_page=limit,
error_out=False
)
# 获取查询的列名列表
column_names = [col["name"] for col in self.column_descriptions]
# 将元组转换为字典(支持单列或多列)
data = [
dict(zip(column_names, row))
for row in _res.items
]
return data, _res.total, _res.page, _res.per_page
db = SQLAlchemy(query_class=Query) db = SQLAlchemy(query_class=Query)
@@ -91,3 +115,9 @@ ma = Marshmallow()
def init_databases(app: Flask): def init_databases(app: Flask):
db.init_app(app) db.init_app(app)
ma.init_app(app) ma.init_app(app)
if os.environ.get('WERKZEUG_RUN_MAIN') == 'true':
with app.app_context():
try:
db.engine.connect()
except Exception as e:
exit(f"数据库连接失败: {e}")
@@ -1,7 +1,17 @@
from flask import session from flask import session, current_app
from flask_login import current_user
from flask_wtf.csrf import generate_csrf
def init_template_directives(app): def init_template_directives(app):
@app.template_global() @app.template_global()
def authorize(power): def authorize(power):
if current_user.username != current_app.config.get("SUPERADMIN"):
return bool(power in session.get('permissions')) return bool(power in session.get('permissions'))
else:
return True
@app.template_global()
def csrf_input():
return f'<input type="hidden" name="csrf_token" value="{generate_csrf()}">'
-9
View File
@@ -1,9 +0,0 @@
from marshmallow import EXCLUDE
from webargs.flaskparser import FlaskParser
class Parser(FlaskParser):
DEFAULT_UNKNOWN_BY_LOCATION = {"query": EXCLUDE}
parser = Parser()
+2
View File
@@ -7,3 +7,5 @@ from .admin_role import Role
from .admin_role_power import role_power from .admin_role_power import role_power
from .admin_user import User from .admin_user import User
from .admin_user_role import user_role from .admin_user_role import user_role
from .admin_mail import Mail
from .admin_nav import Nav
+1 -1
View File
@@ -15,4 +15,4 @@ class Dept(db.Model):
remark = db.Column(db.Text, comment="备注") remark = db.Column(db.Text, comment="备注")
address = db.Column(db.String(255), comment="详细地址") address = db.Column(db.String(255), comment="详细地址")
create_at = db.Column(db.DateTime, default=datetime.datetime.now, comment='创建时间') create_at = db.Column(db.DateTime, default=datetime.datetime.now, comment='创建时间')
update_at = db.Column(db.DateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now, comment='创建时间') update_at = db.Column(db.DateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now, comment='更新时间')
+12
View File
@@ -0,0 +1,12 @@
import datetime
from applications.extensions import db
class Mail(db.Model):
__tablename__ = 'admin_mail'
id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='邮件编号')
receiver = db.Column(db.String(1024), comment='收件人邮箱')
subject = db.Column(db.String(128), comment='邮件主题')
content = db.Column(db.Text(), comment='邮件正文')
user_id = db.Column(db.Integer, comment='发送人id')
create_at = db.Column(db.DateTime, default=datetime.datetime.now, comment='创建时间')
+69
View File
@@ -0,0 +1,69 @@
"""
前台公开导航模型。
Nav 用于「公开访问的导航聚合页」,与 admin_power 表完全解耦:
- admin_power 控制后台管理菜单,需要登录
- public_nav 控制前台公开导航,无需登录
后续若要做前台用户(收藏、提交链接),只需加独立 User 模型,
不需要碰 RBAC。
"""
import datetime
from applications.extensions import db
class Nav(db.Model):
__tablename__ = 'public_nav'
id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='导航ID')
# 分类名(如"开发工具"/"AI"/"设计资源"),用于首页聚合展示
category = db.Column(db.String(50), nullable=False, comment='分类', index=True)
# 单条导航的标题
title = db.Column(db.String(100), nullable=False, comment='标题')
# 链接 URL(站内或外链)
url = db.Column(db.String(255), nullable=False, comment='链接')
# 简短描述(鼠标悬停时可见)
description = db.Column(db.String(255), default='', comment='简介')
# layui-icon 的 class 名,例如 "layui-icon-website"
icon = db.Column(db.String(100), default='layui-icon-link', comment='图标')
# 同分类下的显示顺序
sort = db.Column(db.Integer, default=0, comment='排序')
# 1=启用,0=禁用(禁用后前台不再展示,后台列表仍可见)
status = db.Column(db.Integer, default=1, comment='状态(1启用,0关闭)')
# 1=外链(新窗口打开),0=站内(当前窗口)
is_external = db.Column(db.Integer, default=1, comment='是否外链')
# 创建者(后台管理员用户名,便于追溯),非空约束放宽以便匿名维护
create_by = db.Column(db.String(50), default='admin', comment='创建者')
create_at = db.Column(db.DateTime, default=datetime.datetime.now, comment='创建时间')
update_at = db.Column(
db.DateTime,
default=datetime.datetime.now,
onupdate=datetime.datetime.now,
comment='更新时间',
)
def to_dict(self):
"""方便模板直接读取"""
return {
'id': self.id,
'category': self.category,
'title': self.title,
'url': self.url,
'description': self.description or '',
'icon': self.icon or 'layui-icon-link',
'sort': self.sort or 0,
'status': self.status,
'is_external': self.is_external,
'create_by': self.create_by,
'create_at': self.create_at.strftime('%Y-%m-%d %H:%M:%S') if self.create_at else '',
}
+1 -1
View File
@@ -9,7 +9,7 @@ class User(db.Model, UserMixin):
id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='用户ID') id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='用户ID')
username = db.Column(db.String(20), comment='用户名') username = db.Column(db.String(20), comment='用户名')
realname = db.Column(db.String(20), comment='真实名字') realname = db.Column(db.String(20), comment='真实名字')
avatar = db.Column(db.String(255), comment='头像', default="/static/admin/admin/images/avatar.jpg") avatar = db.Column(db.String(255), comment='头像', default="/static/system/admin/images/avatar.jpg")
remark = db.Column(db.String(255), comment='备注') remark = db.Column(db.String(255), comment='备注')
password_hash = db.Column(db.String(128), comment='哈希密码') password_hash = db.Column(db.String(128), comment='哈希密码')
enable = db.Column(db.Integer, default=0, comment='启用') enable = db.Column(db.Integer, default=0, comment='启用')
+4 -2
View File
@@ -1,7 +1,9 @@
from .admin_user import UserOutSchema
from .admin_role import RoleOutSchema from .admin_role import RoleOutSchema
from .admin_power import PowerOutSchema, PowerOutSchema2 from .admin_power import PowerOutSchema, PowerOutSchema2
from .admin_dict import DictDataOutSchema, DictTypeOutSchema from .admin_dict import DictDataOutSchema, DictTypeOutSchema
from .admin_dept import DeptOutSchema from .admin_dept import DeptSchema
from .admin_log import LogOutSchema from .admin_log import LogOutSchema
from .admin_photo import PhotoOutSchema from .admin_photo import PhotoOutSchema
from .admin_mail import MailOutSchema
from .admin_nav import NavOutSchema, NavManageSchema
+10 -23
View File
@@ -1,25 +1,12 @@
from applications.extensions import ma from flask_marshmallow.sqla import SQLAlchemyAutoSchema
from marshmallow import fields, validate
from applications.models import Dept
class DeptInSchema(ma.Schema): class DeptSchema(SQLAlchemyAutoSchema):
parentId = fields.Integer(required=True) class Meta:
deptName = fields.Str(required=True) model = Dept # table = models.Album.__table__
leader = fields.Str(required=True) # include_relationships = True # 输出模型对象时同时对外键,是否也一并进行处理
phone = fields.Str(required=True) include_fk = True # 序列化阶段是否也一并返回主键
email = fields.Str(validate=validate.Email()) # fields= ["id","name"] # 启动的字段列表
address = fields.Str() # exclude = ["id","name"] # 排除字段列表
status = fields.Str(validate=validate.OneOf(["0", "1"]))
sort = fields.Integer()
class DeptOutSchema(ma.Schema):
deptId = fields.Integer(attribute="id")
parentId = fields.Integer(attribute="parent_id")
deptName = fields.Str(attribute="dept_name")
leader = fields.Str()
phone = fields.Str()
email = fields.Str(validate=validate.Email())
address = fields.Str()
status = fields.Str(validate=validate.OneOf(["0", "1"]))
sort = fields.Integer()
+19
View File
@@ -0,0 +1,19 @@
from applications.extensions import ma
from marshmallow import fields
from applications.models import User
# 用户models的序列化类
class MailOutSchema(ma.Schema):
id = fields.Integer()
receiver = fields.Str()
subject = fields.Str()
content = fields.Str()
realname = fields.Method("get_realname")
create_at = fields.DateTime()
def get_realname(self, obj):
if obj.user_id != None:
return User.query.filter_by(id=obj.user_id).first().realname
else:
return None
+17
View File
@@ -0,0 +1,17 @@
from flask_marshmallow.sqla import SQLAlchemyAutoSchema
from applications.models import Nav
class NavOutSchema(SQLAlchemyAutoSchema):
"""前台展示用:序列化 Nav 对象为 JSON"""
class Meta:
model = Nav
include_fk = True
class NavManageSchema(SQLAlchemyAutoSchema):
"""后台管理用:同上,前缀分开便于将来按需差异化"""
class Meta:
model = Nav
include_fk = True
+12
View File
@@ -1,6 +1,18 @@
from flask_marshmallow.sqla import SQLAlchemyAutoSchema
from applications.extensions import ma from applications.extensions import ma
from marshmallow import fields from marshmallow import fields
from applications.models import Power
class PowerSchema(SQLAlchemyAutoSchema):
class Meta:
model = Power # table = models.Album.__table__
# include_relationships = True # 输出模型对象时同时对外键,是否也一并进行处理
include_fk = True # 序列化阶段是否也一并返回主键
# fields= ["id","name"] # 启动的字段列表
# exclude = ["id","name"] # 排除字段列表
# 权限models序列化类 # 权限models序列化类
class PowerOutSchema(ma.Schema): class PowerOutSchema(ma.Schema):
+10 -12
View File
@@ -1,14 +1,12 @@
from applications.extensions import ma from flask_marshmallow.sqla import SQLAlchemyAutoSchema
from marshmallow import fields
from applications.models import Role
class RoleOutSchema(ma.Schema): class RoleOutSchema(SQLAlchemyAutoSchema):
id = fields.Integer() class Meta:
roleName = fields.Str(attribute="name") model = Role # table = models.Album.__table__
roleCode = fields.Str(attribute="code") # include_relationships = True # 输出模型对象时同时对外键,是否也一并进行处理
enable = fields.Str() include_fk = True # 序列化阶段是否也一并返回主键
remark = fields.Str() # fields= ["id","name"] # 启动的字段列表
details = fields.Str() # exclude = ["id","name"] # 排除字段列表
sort = fields.Integer()
create_at = fields.DateTime()
update_at = fields.DateTime()
-20
View File
@@ -1,20 +0,0 @@
from applications.extensions import ma
from marshmallow import fields
from applications.models import Dept
# 用户models的序列化类
class UserOutSchema(ma.Schema):
id = fields.Integer()
username = fields.Str()
realname = fields.Str()
enable = fields.Integer()
create_at = fields.DateTime()
update_at = fields.DateTime()
dept = fields.Method("get_dept")
def get_dept(self, obj):
if obj.dept_id != None:
return Dept.query.filter_by(id=obj.dept_id).first().dept_name
else:
return None
+17 -11
View File
@@ -1,13 +1,19 @@
from applications.view.admin import register_admin_views from applications.view.system import register_system_bps
from applications.view.index import register_index_views from applications.view.public import bp as public_bp, register_public_bp # public 包的入口就是 nav_bp
from applications.view.passport import register_passport_views from applications.view.health import bp as health_bp
from applications.view.rights import register_rights_view from applications.extensions.init_plugins import broadcast_execute
from applications.view.department import register_dept_views
def init_view(app): def init_bps(app):
register_admin_views(app) # 健康检查端点(无需登录,应在负载均衡/容器探活里使用)
register_index_views(app) app.register_blueprint(health_bp)
register_rights_view(app)
register_passport_views(app) # 前台公开蓝图(无需登录)
register_dept_views(app) app.register_blueprint(public_bp)
register_public_bp(app)
# 后台系统蓝图(需要登录 + 权限码)
register_system_bps(app)
# 插件初始化函数
broadcast_execute(app, 'event_init')
-23
View File
@@ -1,23 +0,0 @@
from flask import Flask
from applications.view.admin.admin_log import admin_log
from applications.view.admin.dict import admin_dict
from applications.view.admin.index import admin_bp
from applications.view.admin.file import admin_file
from applications.view.admin.power import admin_power
from applications.view.admin.role import admin_role
from applications.view.admin.user import admin_user
from applications.view.admin.monitor import admin_monitor_bp
from applications.view.admin.task import admin_task
def register_admin_views(app: Flask):
app.register_blueprint(admin_bp)
app.register_blueprint(admin_user)
app.register_blueprint(admin_file)
app.register_blueprint(admin_monitor_bp)
app.register_blueprint(admin_log)
app.register_blueprint(admin_power)
app.register_blueprint(admin_role)
app.register_blueprint(admin_dict)
app.register_blueprint(admin_task)
-221
View File
@@ -1,221 +0,0 @@
from flask import Blueprint, render_template, request, jsonify
from applications.common import curd
from applications.common.helper import ModelFilter
from applications.common.utils.http import table_api, success_api, fail_api
from applications.common.utils.rights import authorize
from applications.common.utils.validate import xss_escape
from applications.extensions import db
from applications.models import DictType, DictData
from applications.schemas import DictTypeOutSchema, DictDataOutSchema
admin_dict = Blueprint('adminDict', __name__, url_prefix='/admin/dict')
# 数据字典
@admin_dict.get('/')
@authorize("admin:dict:main", log=True)
def main():
return render_template('admin/dict/main.html')
@admin_dict.get('/dictType/data')
@authorize("admin:dict:main", log=True)
def dict_type_data():
# 获取请求参数
type_name = xss_escape(request.args.get('typeName', type=str))
# 查询参数构造
mf = ModelFilter()
if type_name:
mf.vague(field_name="type_name", value=type_name)
# orm查询
# 使用分页获取data需要.items
dict_all = DictType.query.filter(mf.get_filter(DictType)).layui_paginate()
count = dict_all.total
data = curd.model_to_dicts(schema=DictTypeOutSchema, data=dict_all.items)
return table_api(data=data, count=count)
@admin_dict.get('/dictType/add')
@authorize("admin:dict:add", log=True)
def dict_type_add():
return render_template('admin/dict/add.html')
@admin_dict.post('/dictType/save')
@authorize("admin:dict:add", log=True)
def dict_type_save():
req_json = request.json
description = xss_escape(req_json.get("description"))
enable = xss_escape(req_json.get("enable"))
type_code = xss_escape(req_json.get("typeCode"))
type_name = xss_escape(req_json.get("typeName"))
d = DictType(type_name=type_name, type_code=type_code, enable=enable, description=description)
db.session.add(d)
db.session.commit()
if d.id is None:
return fail_api(msg="增加失败")
return success_api(msg="增加成功")
# 编辑字典类型
@admin_dict.get('/dictType/edit')
@authorize("admin:dict:edit", log=True)
def dict_type_edit():
_id = request.args.get('dictTypeId', type=int)
dict_type = DictType.query.filter_by(id=_id).first()
return render_template('admin/dict/edit.html', dict_type=dict_type)
# 编辑字典类型
@admin_dict.put('/dictType/update')
@authorize("admin:dict:edit", log=True)
def dict_type_update():
req_json = request.json
id = xss_escape(req_json.get("id"))
description = xss_escape(req_json.get("description"))
enable = xss_escape(req_json.get("enable"))
type_code = xss_escape(req_json.get("typeCode"))
type_name = xss_escape(req_json.get("typeName"))
DictType.query.filter_by(id=id).update({
"description": description,
"enable": enable,
"type_code": type_code,
"type_name": type_name
})
db.session.commit()
return success_api(msg="更新成功")
# 启用字典
@admin_dict.put('/dictType/enable')
@authorize("admin:dict:edit", log=True)
def dict_type_enable():
_id = request.json.get('id')
if id:
res = curd.enable_status(DictType,_id)
if not res:
return fail_api(msg="出错啦")
return success_api("启动成功")
return fail_api(msg="数据错误")
# 禁用字典
@admin_dict.put('/dictType/disable')
@authorize("admin:dict:edit", log=True)
def dict_type_dis_enable():
_id = request.json.get('id')
if id:
res = curd.disable_status(DictType,_id)
if not res:
return fail_api(msg="出错啦")
return success_api("禁用成功")
return fail_api(msg="数据错误")
# 删除字典类型
@admin_dict.delete('/dictType/remove/<int:_id>')
@authorize("admin:dict:remove", log=True)
def dict_type_delete(_id):
res = curd.delete_one_by_id(DictType,_id)
if not res:
return fail_api(msg="删除失败")
return success_api(msg="删除成功")
@admin_dict.get('/dictData/data')
@authorize("admin:dict:main", log=True)
def dict_code_data():
type_code = xss_escape(request.args.get('typeCode', type=str))
dict_data = DictData.query.filter_by(type_code=type_code).layui_paginate()
count = dict_data.total
data = curd.model_to_dicts(schema=DictDataOutSchema, data=dict_data.items)
return table_api(data=data, count=count)
# 增加字典数据
@admin_dict.get('/dictData/add')
@authorize("admin:dict:add", log=True)
def dict_data_add():
type_code = request.args.get('typeCode', type=str)
return render_template('admin/dict/data/add.html', type_code=type_code)
# 增加字典数据
@admin_dict.post('/dictData/save')
@authorize("admin:dict:add", log=True)
def dict_data_save():
req_json = request.json
data_label = xss_escape(req_json.get("dataLabel"))
data_value = xss_escape(req_json.get("dataValue"))
enable = xss_escape(req_json.get("enable"))
remark = xss_escape(req_json.get("remark"))
type_code = xss_escape(req_json.get("typeCode"))
d = DictData(data_label=data_label, data_value=data_value, enable=enable, remark=remark, type_code=type_code)
db.session.add(d)
db.session.commit()
if not d.id:
return jsonify(success=False, msg="增加失败")
return jsonify(success=True, msg="增加成功")
# 编辑字典数据
@admin_dict.get('/dictData/edit')
@authorize("admin:dict:edit", log=True)
def dict_data_edit():
_id = request.args.get('dataId', type=str)
dict_data = curd.get_one_by_id(DictData, _id)
return render_template('admin/dict/data/edit.html', dict_data=dict_data)
# 编辑字典数据
@admin_dict.put('/dictData/update')
@authorize("admin:dict:edit", log=True)
def dict_data_update():
req_json = request.json
id = req_json.get("dataId")
DictData.query.filter_by(id=id).update({
"data_label": xss_escape(req_json.get("dataLabel")),
"data_value": xss_escape(req_json.get("dataValue")),
"enable": xss_escape(req_json.get("enable")),
"remark": xss_escape(req_json.get("remark")),
"type_code": xss_escape(req_json.get("typeCode"))
})
db.session.commit()
return success_api(msg="更新成功")
# 启用字典数据
@admin_dict.put('/dictData/enable')
@authorize("admin:dict:edit", log=True)
def dict_data_enable():
_id = request.json.get('dataId')
if _id:
res = curd.enable_status(model=DictData, id=_id)
if not res:
return fail_api(msg="出错啦")
return success_api(msg="启动成功")
return fail_api(msg="数据错误")
# 禁用字典数据
@admin_dict.put('/dictData/disable')
@authorize("admin:dict:edit", log=True)
def dict_data_disenable():
_id = request.json.get('dataId')
if _id:
res = curd.disable_status(model=DictData, id=_id)
if not res:
return fail_api(msg="出错啦")
return success_api(msg="禁用成功")
return fail_api(msg="数据错误")
# 删除字典类型
@admin_dict.delete('dictData/remove/<int:id>')
@authorize("admin:dict:remove", log=True)
def dict_data_delete(id):
res = curd.delete_one_by_id(model=DictData, id=id)
if not res:
return fail_api(msg="删除失败")
return success_api(msg="删除成功")
-19
View File
@@ -1,19 +0,0 @@
from flask import Blueprint, render_template
from flask_login import login_required, current_user
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
# 首页
@admin_bp.get('/')
@login_required
def index():
user = current_user
return render_template('admin/index.html', user=user)
# 控制台页面
@admin_bp.get('/welcome')
@login_required
def welcome():
return render_template('admin/console/console.html')
-88
View File
@@ -1,88 +0,0 @@
import os
import platform
import re
from datetime import datetime
import time
import psutil
from flask import Blueprint, render_template, jsonify
from applications.common.utils.rights import authorize
admin_monitor_bp = Blueprint('adminMonitor', __name__, url_prefix='/admin/monitor')
# 系统监控
@admin_monitor_bp.get('/')
@authorize("admin:monitor:main", log=True)
def main():
# 主机名称
hostname = platform.node()
# 系统版本
system_version = platform.platform()
# python版本
python_version = platform.python_version()
# 逻辑cpu数量
cpu_count = psutil.cpu_count()
# cup使用率
cpus_percent = psutil.cpu_percent(interval=0.1)
# 内存
memory_information = psutil.virtual_memory()
# 内存使用率
memory_usage = memory_information.percent
memory_used = str(round(memory_information.used / 1024 / 1024))
memory_total = str(round(memory_information.total / 1024 / 1024))
memory_free = str(round(memory_information.free / 1024 / 1024))
# 磁盘信息
disk_partitions_list = []
# 判断是否在容器中
if not os.path.exists('/.dockerenv'):
disk_partitions = psutil.disk_partitions()
for i in disk_partitions:
a = psutil.disk_usage(i.device)
disk_partitions_dict = {
'device': i.device,
'fstype': i.fstype,
'total': str(round(a.total / 1024 / 1024)),
'used': str(round(a.used / 1024 / 1024)),
'free': str(round(a.free / 1024 / 1024)),
'percent': a.percent
}
disk_partitions_list.append(disk_partitions_dict)
# 开机时间
boot_time = datetime.fromtimestamp(psutil.boot_time()).replace(microsecond=0)
up_time = datetime.now().replace(microsecond=0) - boot_time
up_time_list = re.split(r':', str(up_time))
up_time_format = " {} 小时{} 分钟{}".format(up_time_list[0], up_time_list[1], up_time_list[2])
# 当前时间
time_now = time.strftime('%H:%M:%S ', time.localtime(time.time()))
return render_template('admin/monitor.html',
hostname=hostname,
system_version=system_version,
python_version=python_version,
cpus_percent=cpus_percent,
memory_usage=memory_usage,
cpu_count=cpu_count,
memory_used=memory_used,
memory_total=memory_total,
memory_free=memory_free,
boot_time=boot_time,
up_time_format=up_time_format,
disk_partitions_list=disk_partitions_list,
time_now=time_now
)
# 图表 api
@admin_monitor_bp.get('/polling')
@authorize("admin:monitor:main")
def ajax_polling():
# 获取cup使用率
cpus_percent = psutil.cpu_percent(interval=0.1)
# 获取内存使用率
memory_information = psutil.virtual_memory()
memory_usage = memory_information.percent
time_now = time.strftime('%H:%M:%S ', time.localtime(time.time()))
return jsonify(cups_percent=cpus_percent, memory_used=memory_usage, time_now=time_now)
-167
View File
@@ -1,167 +0,0 @@
from flask import Blueprint, render_template, request, jsonify
from applications.common import curd
from applications.common.utils.http import success_api, fail_api
from applications.common.utils.rights import authorize
from applications.common.utils.validate import xss_escape
from applications.extensions import db
from applications.models import Power
from applications.schemas import PowerOutSchema2
admin_power = Blueprint('adminPower', __name__, url_prefix='/admin/power')
@admin_power.get('/')
@authorize("admin:power:main", log=True)
def index():
return render_template('admin/power/main.html')
@admin_power.post('/data')
@authorize("admin:power:main", log=True)
def data():
power = Power.query.all()
res = {
"data": curd.model_to_dicts(schema=PowerOutSchema2, data=power)
}
return jsonify(res)
@admin_power.get('/add')
@authorize("admin:power:add", log=True)
def add():
return render_template('admin/power/add.html')
@admin_power.get('/selectParent')
@authorize("admin:power:main", log=True)
def select_parent():
power = Power.query.all()
res = curd.model_to_dicts(schema=PowerOutSchema2, data=power)
res.append({"powerId": 0, "powerName": "顶级权限", "parentId": -1})
res = {
"status": {"code": 200, "message": "默认"},
"data": res
}
return jsonify(res)
# 增加
@admin_power.post('/save')
@authorize("admin:power:add", log=True)
def save():
req = request.json
icon = xss_escape(req.get("icon"))
openType = xss_escape(req.get("openType"))
parentId = xss_escape(req.get("parentId"))
powerCode = xss_escape(req.get("powerCode"))
powerName = xss_escape(req.get("powerName"))
powerType = xss_escape(req.get("powerType"))
powerUrl = xss_escape(req.get("powerUrl"))
sort = xss_escape(req.get("sort"))
power = Power(
icon=icon,
open_type=openType,
parent_id=parentId,
code=powerCode,
name=powerName,
type=powerType,
url=powerUrl,
sort=sort,
enable=1
)
r = db.session.add(power)
db.session.commit()
return success_api(msg="成功")
# 权限编辑
@admin_power.get('/edit/<int:_id>')
@authorize("admin:power:edit", log=True)
def edit(_id):
power = curd.get_one_by_id(Power, _id)
icon = str(power.icon).split()
if len(icon) == 2:
icon = icon[1]
else:
icon = None
return render_template('admin/power/edit.html', power=power, icon=icon)
# 权限更新
@admin_power.put('/update')
@authorize("admin:power:edit", log=True)
def update():
req_json = request.json
id = request.json.get("powerId")
data = {
"icon": xss_escape(req_json.get("icon")),
"open_type": xss_escape(req_json.get("openType")),
"parent_id": xss_escape(req_json.get("parentId")),
"code": xss_escape(req_json.get("powerCode")),
"name": xss_escape(req_json.get("powerName")),
"type": xss_escape(req_json.get("powerType")),
"url": xss_escape(req_json.get("powerUrl")),
"sort": xss_escape(req_json.get("sort"))
}
res = Power.query.filter_by(id=id).update(data)
db.session.commit()
if not res:
return fail_api(msg="更新权限失败")
return success_api(msg="更新权限成功")
# 启用权限
@admin_power.put('/enable')
@authorize("admin:power:edit", log=True)
def enable():
_id = request.json.get('powerId')
if id:
res = curd.enable_status(Power, _id)
if not res:
return fail_api(msg="出错啦")
return success_api(msg="启用成功")
return fail_api(msg="数据错误")
# 禁用权限
@admin_power.put('/disable')
@authorize("admin:power:edit", log=True)
def dis_enable():
_id = request.json.get('powerId')
if id:
res = curd.disable_status(Power, _id)
if not res:
return fail_api(msg="出错啦")
return success_api(msg="禁用成功")
return fail_api(msg="数据错误")
# 权限删除
@admin_power.delete('/remove/<int:id>')
@authorize("admin:power:remove", log=True)
def remove(id):
power = Power.query.filter_by(id=id).first()
power.role = []
r = Power.query.filter_by(id=id).delete()
db.session.commit()
if r:
return success_api(msg="删除成功")
else:
return fail_api(msg="删除失败")
# 批量删除
@admin_power.delete('/batchRemove')
@authorize("admin:power:remove", log=True)
def batch_remove():
ids = request.form.getlist('ids[]')
for id in ids:
power = Power.query.filter_by(id=id).first()
power.role = []
r = Power.query.filter_by(id=id).delete()
db.session.commit()
return success_api(msg="批量删除成功")
-204
View File
@@ -1,204 +0,0 @@
from flask import Blueprint, render_template, request, jsonify
from flask_login import login_required
from applications.common.curd import model_to_dicts, enable_status, disable_status, get_one_by_id
from applications.common.helper import ModelFilter
from applications.common.utils.http import table_api, success_api, fail_api
from applications.common.utils.rights import authorize
from applications.common.utils.validate import xss_escape
from applications.extensions import db
from applications.models import Role, Power, User
from applications.schemas import RoleOutSchema, PowerOutSchema2
admin_role = Blueprint('adminRole', __name__, url_prefix='/admin/role')
# 用户管理
@admin_role.get('/')
@authorize("admin:role:main", log=True)
def main():
return render_template('admin/role/main.html')
# 表格数据
@admin_role.get('/data')
@authorize("admin:role:main", log=True)
def table():
# 获取请求参数
role_name = xss_escape(request.args.get('roleName', type=str))
role_code = xss_escape(request.args.get('roleCode', type=str))
# 查询参数构造
mf = ModelFilter()
if role_name:
mf.vague(field_name="name", value=role_name)
if role_code:
mf.vague(field_name="code", value=role_code)
# orm查询
# 使用分页获取data需要.items
role = Role.query.filter(mf.get_filter(Role)).layui_paginate()
count = role.total
# 返回api
return table_api(data=model_to_dicts(schema=RoleOutSchema, data=role.items), count=count)
# 角色增加
@admin_role.get('/add')
@authorize("admin:role:add", log=True)
def add():
return render_template('admin/role/add.html')
# 角色增加
@admin_role.post('/save')
@authorize("admin:role:add", log=True)
def save():
req = request.json
details = xss_escape(req.get("details"))
enable = xss_escape(req.get("enable"))
roleCode = xss_escape(req.get("roleCode"))
roleName = xss_escape(req.get("roleName"))
sort = xss_escape(req.get("sort"))
role = Role(
details=details,
enable=enable,
code=roleCode,
name=roleName,
sort=sort
)
db.session.add(role)
db.session.commit()
return success_api(msg="成功")
# 角色授权
@admin_role.get('/power/<int:_id>')
@authorize("admin:role:power", log=True)
def power(_id):
return render_template('admin/role/power.html', id=_id)
# 获取角色权限
@admin_role.get('/getRolePower/<int:id>')
@authorize("admin:role:main", log=True)
def get_role_power(id):
role = Role.query.filter_by(id=id).first()
check_powers = role.power
check_powers_list = []
for cp in check_powers:
check_powers_list.append(cp.id)
powers = Power.query.all()
power_schema = PowerOutSchema2(many=True) # 用已继承ma.ModelSchema类的自定制类生成序列化类
output = power_schema.dump(powers) # 生成可序列化对象
for i in output:
if int(i.get("powerId")) in check_powers_list:
i["checkArr"] = "1"
else:
i["checkArr"] = "0"
res = {
"data": output,
"status": {"code": 200, "message": "默认"}
}
return jsonify(res)
# 保存角色权限
@admin_role.put('/saveRolePower')
@authorize("admin:role:edit", log=True)
def save_role_power():
req_form = request.form
power_ids = req_form.get("powerIds")
power_list = power_ids.split(',')
role_id = req_form.get("roleId")
role = Role.query.filter_by(id=role_id).first()
powers = Power.query.filter(Power.id.in_(power_list)).all()
role.power = powers
db.session.commit()
return success_api(msg="授权成功")
# 角色编辑
@admin_role.get('/edit/<int:id>')
@authorize("admin:role:edit", log=True)
def edit(id):
r = get_one_by_id(model=Role, id=id)
return render_template('admin/role/edit.html', role=r)
# 更新角色
@admin_role.put('/update')
@authorize("admin:role:edit", log=True)
def update():
req_json = request.json
id = req_json.get("roleId")
data = {
"code": xss_escape(req_json.get("roleCode")),
"name": xss_escape(req_json.get("roleName")),
"sort": xss_escape(req_json.get("sort")),
"enable": xss_escape(req_json.get("enable")),
"details": xss_escape(req_json.get("details"))
}
role = Role.query.filter_by(id=id).update(data)
db.session.commit()
if not role:
return fail_api(msg="更新角色失败")
return success_api(msg="更新角色成功")
# 启用用户
@admin_role.put('/enable')
@authorize("admin:role:edit", log=True)
def enable():
id = request.json.get('roleId')
if id:
res = enable_status(Role, id)
if not res:
return fail_api(msg="出错啦")
return success_api(msg="启动成功")
return fail_api(msg="数据错误")
# 禁用用户
@admin_role.put('/disable')
@authorize("admin:role:edit", log=True)
def dis_enable():
_id = request.json.get('roleId')
if _id:
res = disable_status(Role, _id)
if not res:
return fail_api(msg="出错啦")
return success_api(msg="禁用成功")
return fail_api(msg="数据错误")
# 角色删除
@admin_role.delete('/remove/<int:id>')
@authorize("admin:role:remove", log=True)
def remove(id):
role = Role.query.filter_by(id=id).first()
# 删除该角色的权限和用户
role.power = []
role.user = []
r = Role.query.filter_by(id=id).delete()
db.session.commit()
if not r:
return fail_api(msg="角色删除失败")
return success_api(msg="角色删除成功")
# 批量删除
@admin_role.delete('/batchRemove')
@authorize("admin:role:remove", log=True)
@login_required
def batch_remove():
ids = request.form.getlist('ids[]')
for id in ids:
role = Role.query.filter_by(id=id).first()
# 删除该角色的权限和用户
role.power = []
role.user = []
r = Role.query.filter_by(id=id).delete()
db.session.commit()
return success_api(msg="批量删除成功")
-112
View File
@@ -1,112 +0,0 @@
from flask import Blueprint, request, render_template
from flask_apscheduler.utils import job_to_dict
from applications.common.tasks import tasks
from applications.common.tasks.tasks import task_list
from applications.common.utils.http import table_api, fail_api, success_api
from applications.extensions.init_apscheduler import scheduler
admin_task = Blueprint('adminTask', __name__, url_prefix='/admin/task')
@admin_task.route('/add_job', methods=['GET'])
def add_task():
scheduler.add_job(func=tasks.get(), id='4', args=(1, 1), trigger='interval', seconds=3,
replace_existing=True)
return '6'
@admin_task.get('/')
def main():
return render_template('admin/task/main.html')
@admin_task.route('/data', methods=['GET'])
def get_task(): # 获取
jobs = scheduler.get_jobs()
jobs_list = []
for job in jobs:
jobs_list.append(job_to_dict(job))
return table_api(data=jobs_list, count=len(jobs_list))
# 增加
@admin_task.get('/add')
def add():
return render_template('admin/task/add.html', task_list=task_list)
@admin_task.post('/save')
def save():
_id = request.json.get("id")
name = request.json.get("id")
type = request.json.get("type")
functions = request.json.get("functions")
datetime = request.json.get("datetime")
time = request.json.get("time")
if not hasattr(tasks, functions):
return fail_api()
if type == 'date':
scheduler.add_job(
func=getattr(tasks, functions),
id=_id,
name=name,
args=(1, 1),
trigger=type,
run_date=datetime,
replace_existing=True)
elif type == 'interval':
scheduler.add_job(
func=getattr(tasks, functions),
id=_id,
name=name,
args=(1, 1),
trigger=type,
replace_existing=True)
elif type == 'cron':
scheduler.add_job(
func=getattr(tasks, functions),
id=_id,
name=name,
args=(1, 1),
trigger=type,
replace_existing=True)
return success_api()
# 恢复
@admin_task.put('/enable')
def enable():
_id = request.json.get('id')
# print(id)
if _id:
scheduler.resume_job(str(_id))
return success_api(msg="启动成功")
return fail_api(msg="数据错误")
# 暂停
@admin_task.put('/disable')
def dis_enable():
_id = request.json.get('id')
if _id:
scheduler.pause_job(str(_id))
return success_api(msg="暂停成功")
return fail_api(msg="数据错误")
@admin_task.delete('/remove/<int:_id>')
def remove_job(_id): # 移除
scheduler.remove_job(str(_id))
return success_api(msg="删除成功")
# scheduler.add_job(func=task1, id='2', args=(1, 1), trigger='cron', day_of_week='0-6', hour=18, minute=24,
# second=10, replace_existing=True)
# scheduler.add_job(func=task4, id='4', args=(2, 2), trigger='interval', seconds=3,
# replace_existing=True, misfire_grace_time=3)
# # trigger='interval' 表示是一个循环任务,每隔多久执行一次
# scheduler.add_job(func=task2, id='3', args=(2, 2), trigger='interval', seconds=3,
# replace_existing=True)
-144
View File
@@ -1,144 +0,0 @@
from flask import Blueprint, render_template, request, jsonify
from applications.common import curd
from applications.common.utils import validate
from applications.common.utils.http import success_api, fail_api
from applications.common.utils.rights import authorize
from applications.common.utils.validate import xss_escape
from applications.extensions import db
from applications.models import Dept, User
from applications.schemas import DeptOutSchema
dept_bp = Blueprint('dept', __name__, url_prefix='/dept')
def register_dept_views(app):
app.register_blueprint(dept_bp)
@dept_bp.get('/')
@authorize("admin:dept:main", log=True)
def main():
return render_template('admin/dept/main.html')
@dept_bp.post('/data')
@authorize("admin:dept:main", log=True)
def data():
dept = Dept.query.order_by(Dept.sort).all()
power_data = curd.model_to_dicts(schema=DeptOutSchema, data=dept)
res = {
"data": power_data
}
return jsonify(res)
@dept_bp.get('/add')
@authorize("admin:dept:add", log=True)
def add():
return render_template('admin/dept/add.html')
@dept_bp.get('/tree')
@authorize("admin:dept:main", log=True)
def tree():
dept = Dept.query.order_by(Dept.sort).all()
power_data = curd.model_to_dicts(schema=DeptOutSchema, data=dept)
res = {
"status": {"code": 200, "message": "默认"},
"data": power_data
}
return jsonify(res)
@dept_bp.post('/save')
@authorize("admin:dept:add", log=True)
def save():
req_json = request.json
dept = Dept(
parent_id=req_json.get('parentId'),
dept_name=xss_escape(req_json.get('deptName')),
sort=xss_escape(req_json.get('sort')),
leader=xss_escape(req_json.get('leader')),
phone=xss_escape(req_json.get('phone')),
email=xss_escape(req_json.get('email')),
status=xss_escape(req_json.get('status')),
address=xss_escape(req_json.get('address'))
)
r = db.session.add(dept)
db.session.commit()
return success_api(msg="成功")
@dept_bp.get('/edit')
@authorize("admin:dept:edit", log=True)
def edit():
_id = request.args.get("deptId")
dept = curd.get_one_by_id(model=Dept, id=_id)
return render_template('admin/dept/edit.html', dept=dept)
# 启用
@dept_bp.put('/enable')
@authorize("admin:dept:edit", log=True)
def enable():
id = request.json.get('deptId')
if id:
enable = 1
d = Dept.query.filter_by(id=id).update({"status": enable})
if d:
db.session.commit()
return success_api(msg="启用成功")
return fail_api(msg="出错啦")
return fail_api(msg="数据错误")
# 禁用
@dept_bp.put('/disable')
@authorize("admin:dept:edit", log=True)
def dis_enable():
id = request.json.get('deptId')
if id:
enable = 0
d = Dept.query.filter_by(id=id).update({"status": enable})
if d:
db.session.commit()
return success_api(msg="禁用成功")
return fail_api(msg="出错啦")
return fail_api(msg="数据错误")
@dept_bp.put('/update')
@authorize("admin:dept:edit", log=True)
def update():
json = request.json
id = json.get("deptId"),
data = {
"dept_name": validate.xss_escape(json.get("deptName")),
"sort": validate.xss_escape(json.get("sort")),
"leader": validate.xss_escape(json.get("leader")),
"phone": validate.xss_escape(json.get("phone")),
"email": validate.xss_escape(json.get("email")),
"status": validate.xss_escape(json.get("status")),
"address": validate.xss_escape(json.get("address"))
}
d = Dept.query.filter_by(id=id).update(data)
if not d:
return fail_api(msg="更新失败")
db.session.commit()
return success_api(msg="更新成功")
@dept_bp.delete('/remove/<int:_id>')
@authorize("admin:dept:remove", log=True)
def remove(_id):
d = Dept.query.filter_by(id=_id).delete()
if not d:
return fail_api(msg="删除失败")
res = User.query.filter_by(dept_id=_id).update({"dept_id": None})
db.session.commit()
if res:
return success_api(msg="删除成功")
else:
return fail_api(msg="删除失败")
+71
View File
@@ -0,0 +1,71 @@
"""
健康检查端点(无需登录)
- GET /healthz
用于探活/负载均衡/容器 readinessProbe。
返回:
{
"status": "ok" | "degraded",
"db_ok": true | false,
"env": "dev" | "production",
"system": "Pear Admin",
"timestamp": "2026-09-05T17:00:00+08:00"
}
HTTP 状态码:
200 - 全部正常
503 - DB 不可达(用于触发 k8s readinessProbe 失败)
"""
from datetime import datetime, timedelta, timezone
import os
from flask import Blueprint, current_app, jsonify
from sqlalchemy import text
from applications.extensions import db
bp = Blueprint("health", __name__)
def _beijing_now() -> str:
"""返回 +08:00 时区 ISO8601 时间字符串。"""
tz = timezone(timedelta(hours=8))
return datetime.now(tz).isoformat()
@bp.get("/healthz")
def healthz():
# 优先按 PEAR_ENV/FLASK_ENV 取环境名;兜底按 DEBUG 反推
env = (
os.environ.get("PEAR_ENV")
or os.environ.get("FLASK_ENV")
or current_app.config.get("PEAR_ENV")
or ("dev" if current_app.config.get("DEBUG") else "production")
)
system_name = current_app.config.get("SYSTEM_NAME", "Pear Admin")
# DB 探活:跑一个最轻量的 SELECT 1
db_ok = True
db_error = None
try:
db.session.execute(text("SELECT 1"))
except Exception as e: # noqa: BLE001 - 这里就是要把异常吃掉,转换成 False
db_ok = False
db_error = str(e)
finally:
try:
db.session.rollback()
except Exception:
pass
payload = {
"status": "ok" if db_ok else "degraded",
"db_ok": db_ok,
"env": env,
"system": system_name,
"timestamp": _beijing_now(),
}
if db_error:
payload["db_error"] = db_error
return jsonify(payload), (200 if db_ok else 503)
-12
View File
@@ -1,12 +0,0 @@
from applications.view.index.index import index_bp
from . import index
def register_index_views(app):
"""
初始化蓝图
"""
app.register_blueprint(index_bp)
-8
View File
@@ -1,8 +0,0 @@
from flask import render_template, Blueprint
index_bp = Blueprint('Index', __name__, url_prefix='/')
@index_bp.route('/')
def index():
return render_template('index/index.html')
+13
View File
@@ -0,0 +1,13 @@
"""
前台公开蓝图:导航聚合页 / 分类详情页
URL 前缀:/site(避免与后台 / 路由冲突)
鉴权要求:无(任何用户都可访问,含未登录匿名用户)
注意:不要给这些视图加 @authorize 装饰器,否则会强制登录_required。
"""
from applications.view.public.nav import bp as nav_bp
# 把 nav 蓝图作为 public 包的对外入口
bp = nav_bp
+80
View File
@@ -0,0 +1,80 @@
"""
前台导航页视图(无需登录)。
URL 路径(挂在根路径下,便于匿名访问):
- GET /site 公开导航聚合首页(按分类展示所有启用的导航)
- GET /site/category/<name> 分类详情:展示指定分类下所有启用的导航
- GET /site/api/navs JSON 接口(供前端动态加载)
注意:
- 这些视图**不加** @authorize,避免 login_required 强制登录。
- 使用 url_prefix='/site' 避免与后台 / 路由冲突(后台 / 是登录后的工作台)。
"""
from collections import OrderedDict
from flask import Blueprint, render_template, request, jsonify
from applications.models import Nav
bp = Blueprint('public_nav', __name__, url_prefix='/site')
@bp.get('/')
def index():
"""
公开首页:按 category 分组聚合,渲染模板
"""
groups = _grouped_navs()
total = sum(len(items) for items in groups.values())
return render_template(
'public/index.html',
groups=groups,
total=total,
site_name='BWStudio · 导航',
)
@bp.get('/category/<string:name>')
def category_detail(name: str):
"""
分类详情页:列出指定分类下所有启用的导航
"""
items = (
Nav.query
.filter(Nav.category == name, Nav.status == 1)
.order_by(Nav.sort.asc(), Nav.id.asc())
.all()
)
return render_template(
'public/category.html',
category=name,
items=items,
site_name='BWStudio · 导航',
)
@bp.get('/api/navs')
def api_navs():
"""
JSON 接口:返回按分类聚合的导航数据
可用于首页卡片动态加载、第三方嵌入等
"""
return jsonify({
'success': True,
'msg': '请求成功',
'data': _grouped_navs(),
})
def _grouped_navs() -> "OrderedDict[str, list]":
"""按 category 分组,组内按 sort asc 排序"""
items = (
Nav.query
.filter(Nav.status == 1)
.order_by(Nav.category.asc(), Nav.sort.asc(), Nav.id.asc())
.all()
)
groups = OrderedDict()
for item in items:
groups.setdefault(item.category, []).append(item.to_dict())
return groups
-9
View File
@@ -1,9 +0,0 @@
from flask import Blueprint, Flask
rights_bp = Blueprint('rights', __name__, url_prefix='/rights')
from . import routes
def register_rights_view(app: Flask):
app.register_blueprint(rights_bp)
-20
View File
@@ -1,20 +0,0 @@
# 渲染配置
from flask import jsonify
from flask_login import login_required
from . import rights_bp
from ...common import admin
@rights_bp.get('/configs')
@login_required
def configs():
return admin.get_render_config()
# 菜单
@rights_bp.get('/menu')
@login_required
def menu():
menu_tree = admin.make_menu_tree()
return jsonify(menu_tree)
+36
View File
@@ -0,0 +1,36 @@
from flask import Flask, Blueprint
from applications.view.system.dict import bp as dict_bp
from applications.view.system.file import bp as file_bp
from applications.view.system.index import bp as index_bp
from applications.view.system.log import bp as log_bp
from applications.view.system.mail import bp as mail_bp
from applications.view.system.monitor import bp as monitor_bp
from applications.view.system.nav import bp as nav_bp
from applications.view.system.passport import bp as passport_bp
from applications.view.system.power import bp as power_bp
from applications.view.system.rights import bp as right_bp
from applications.view.system.role import bp as role_bp
from applications.view.system.user import bp as user_bp
from applications.view.system.dept import bp as dept_bp
# 创建sys
system_bp = Blueprint('system', __name__, url_prefix='/system')
def register_system_bps(app: Flask):
# 在admin_bp下注册子蓝图
system_bp.register_blueprint(user_bp)
system_bp.register_blueprint(file_bp)
system_bp.register_blueprint(monitor_bp)
system_bp.register_blueprint(log_bp)
system_bp.register_blueprint(power_bp)
system_bp.register_blueprint(role_bp)
system_bp.register_blueprint(dict_bp)
system_bp.register_blueprint(mail_bp)
system_bp.register_blueprint(passport_bp)
system_bp.register_blueprint(right_bp)
system_bp.register_blueprint(dept_bp)
system_bp.register_blueprint(nav_bp)
app.register_blueprint(index_bp)
app.register_blueprint(system_bp)
+181
View File
@@ -0,0 +1,181 @@
from flask import Blueprint, render_template, request, jsonify
from applications.common import curd
from applications.common.utils import validate
from applications.common.utils.http import success_api, fail_api, table_api
from applications.common.utils.rights import authorize
from applications.common.utils.validate import str_escape
from applications.extensions import db
from applications.extensions.init_limit import limiter
from applications.models import Dept, User
from applications.schemas import DeptSchema
bp = Blueprint('dept', __name__, url_prefix='/dept')
@bp.get('/')
@authorize("system:dept:main", log=True)
def main():
return render_template('system/dept/main.html')
@bp.post('/data')
@limiter.limit("60 per minute")
@authorize("system:dept:main", log=True)
def data():
dept = Dept.query.order_by(Dept.sort).all()
data = DeptSchema(many=True).dump(dept)
# 创建一个字典,用于存储每个节点的子节点
tree = {}
for item in data:
item["children"] = []
tree[item["id"]] = item
# 构建树形结构
root_nodes = []
for item in data:
parent_id = item["parent_id"] if item["parent_id"] != 0 else None
if parent_id is None:
root_nodes.append(item)
else:
if parent_id in tree:
tree[parent_id]["children"].append(item)
return table_api(msg="请求成功", data=root_nodes)
@bp.get('/add')
@authorize("system:dept:add", log=True)
def add():
return render_template('system/dept/add.html')
@bp.get('/tree')
@authorize("system:dept:main", log=True)
def tree():
dept = Dept.query.order_by(Dept.sort).all()
power_data = curd.model_to_dicts(schema=DeptSchema, data=dept)
res = {
"status": {"code": 200, "message": "默认"},
"data": power_data
}
return jsonify(res)
@bp.post('/save')
@authorize("system:dept:add", log=True)
def save():
req_json = request.get_json(force=True)
dept = Dept(
parent_id=req_json.get('parentId'),
dept_name=str_escape(req_json.get('deptName')),
sort=str_escape(req_json.get('sort')),
leader=str_escape(req_json.get('leader')),
phone=str_escape(req_json.get('phone')),
email=str_escape(req_json.get('email')),
status=str_escape(req_json.get('status')),
address=str_escape(req_json.get('address'))
)
r = db.session.add(dept)
db.session.commit()
return success_api(msg="添加部门成功")
@bp.get('/edit')
@authorize("system:dept:edit", log=True)
def edit():
_id = request.args.get("deptId")
dept = curd.get_one_by_id(model=Dept, id=_id)
return render_template('system/dept/edit.html', dept=dept)
# 启用
@bp.put('/enable')
@authorize("system:dept:edit", log=True)
def enable():
id = request.get_json(force=True).get('deptId')
if id:
enable = 1
d = Dept.query.filter_by(id=id).update({"status": enable})
if d:
db.session.commit()
return success_api(msg="启用部门成功")
return fail_api(msg="出错啦")
return fail_api(msg="数据错误")
# 禁用
@bp.put('/disable')
@authorize("system:dept:edit", log=True)
def dis_enable():
id = request.get_json(force=True).get('deptId')
if id:
enable = 0
d = Dept.query.filter_by(id=id).update({"status": enable})
if d:
db.session.commit()
return success_api(msg="禁用部门成功")
return fail_api(msg="出错啦")
return fail_api(msg="数据错误")
@bp.put('/update')
@authorize("system:dept:edit", log=True)
def update():
json = request.get_json(force=True)
# id = json.get("deptId"),
id = str_escape(json.get("deptId"))
data = {
"dept_name": validate.str_escape(json.get("deptName")),
"sort": validate.str_escape(json.get("sort")),
"leader": validate.str_escape(json.get("leader")),
"phone": validate.str_escape(json.get("phone")),
"email": validate.str_escape(json.get("email")),
"status": validate.str_escape(json.get("status")),
"address": validate.str_escape(json.get("address"))
}
d = Dept.query.filter_by(id=id).update(data)
if not d:
return fail_api(msg="更新部门失败")
db.session.commit()
return success_api(msg="更新部门成功")
@bp.delete('/remove/<int:_id>')
@authorize("system:dept:remove", log=True)
def remove(_id):
d = Dept.query.filter_by(id=_id).delete()
if not d:
return fail_api(msg="删除部门失败")
User.query.filter_by(dept_id=_id).update({"dept_id": None})
db.session.commit()
return success_api(msg="删除部门成功")
# 批量删除
@bp.delete('/batchRemove')
@authorize("system:dept:remove", log=True)
def batch_remove():
ids = request.form.getlist('ids[]')
if not ids:
return fail_api(msg="未提供删除 ID")
for id in ids:
if not id.isdigit():
db.session.rollback()
return fail_api(msg="参数提供错误")
d = Dept.query.filter_by(id=id).delete()
if not d:
return fail_api(msg="删除部门失败")
User.query.filter_by(dept_id=id).update({"dept_id": None})
db.session.commit()
return success_api(msg="删除部门成功")
+299
View File
@@ -0,0 +1,299 @@
from flask import Blueprint, render_template, request, jsonify
from flask_wtf.csrf import validate_csrf
from wtforms.validators import ValidationError
from applications.common import curd
from applications.common.helper import ModelFilter
from applications.common.utils.http import table_api, success_api, fail_api
from applications.common.utils.rights import authorize
from applications.common.utils.validate import str_escape
from applications.extensions import db
from applications.extensions.init_limit import limiter
from applications.models import DictType, DictData
from applications.schemas import DictTypeOutSchema, DictDataOutSchema
bp = Blueprint('dict', __name__, url_prefix='/dict')
# 数据字典
@bp.get('/')
@authorize("system:dict:main")
def main():
return render_template('system/dict/main.html')
@bp.get('/dictType/data')
@limiter.limit("60 per minute")
@authorize("system:dict:main")
def dict_type_data():
# 获取请求参数
type_name = str_escape(request.args.get('typeName', type=str))
# 查询参数构造
mf = ModelFilter()
if type_name:
mf.vague(field_name="type_name", value=type_name)
# orm查询
# 使用分页获取data需要.items
dict_all = DictType.query.filter(mf.get_filter(DictType)).layui_paginate()
count = dict_all.total
data = curd.model_to_dicts(schema=DictTypeOutSchema, data=dict_all.items)
return table_api(data=data, count=count)
@bp.get('/dictType/add')
@authorize("system:dict:add", log=True)
def dict_type_add():
return render_template('system/dict/add.html')
@bp.post('/dictType/save')
@authorize("system:dict:add", log=True)
def dict_type_save():
req_json = request.get_json(force=True)
try:
validate_csrf(req_json.get("csrf_token"))
except ValidationError:
return fail_api(msg='非法请求')
data = {
'type_name': str_escape(req_json.get("typeName")),
'type_code': str_escape(req_json.get("typeCode")),
'enable': str_escape(req_json.get("enable")),
'description': str_escape(req_json.get("description"))
}
description = data['description']
del data['description']
if not all(data.values()):
return fail_api(msg="参数提供不足")
if description is not None:
data['description'] = description
d = DictType(**data)
db.session.add(d)
db.session.commit()
if d.id is None:
return fail_api(msg="增加失败")
return success_api(msg="增加成功")
# 编辑字典类型
@bp.get('/dictType/edit')
@authorize("system:dict:edit", log=True)
def dict_type_edit():
_id = request.args.get('dictTypeId', type=int)
dict_type = DictType.query.filter_by(id=_id).first()
return render_template('system/dict/edit.html', dict_type=dict_type)
# 编辑字典类型
@bp.put('/dictType/update')
@authorize("system:dict:edit", log=True)
def dict_type_update():
req_json = request.get_json(force=True)
id = str_escape(req_json.get("id"))
data = {
"description": str_escape(req_json.get("description")),
"enable": str_escape(req_json.get("enable")),
"type_code": str_escape(req_json.get("typeCode")),
"type_name": str_escape(req_json.get("typeName"))
}
if id is None:
return fail_api(msg="字典类型不存在")
description = data['description']
del data['description']
if not all(data.values()):
return fail_api(msg="参数提供不足")
data['description'] = description
DictType.query.filter_by(id=id).update(data)
db.session.commit()
return success_api(msg="更新成功")
# 启用字典
@bp.put('/dictType/enable')
@authorize("system:dict:edit", log=True)
def dict_type_enable():
_id = request.get_json(force=True).get('id')
if _id:
res = curd.enable_status(DictType, _id)
if not res:
return fail_api(msg="出错啦")
return success_api("启用成功")
return fail_api(msg="数据错误")
# 禁用字典
@bp.put('/dictType/disable')
@authorize("system:dict:edit", log=True)
def dict_type_dis_enable():
_id = request.get_json(force=True).get('id')
if _id:
res = curd.disable_status(DictType, _id)
if not res:
return fail_api(msg="出错啦")
return success_api("禁用成功")
return fail_api(msg="数据错误")
# 删除字典类型
@bp.delete('/dictType/remove/<int:_id>')
@authorize("system:dict:remove", log=True)
def dict_type_delete(_id):
DictData.query.filter_by(
type_code=DictType.query.filter_by(id=_id).first().type_code
).all()
res = DictType.query.filter_by(id=_id).delete()
if not res:
return fail_api(msg="删除失败")
db.session.commit()
return success_api(msg="删除成功")
@bp.get('/dictData/data')
@limiter.limit("60 per minute")
@authorize("system:dict:main", log=True)
def dict_code_data():
type_code = str_escape(request.args.get('typeCode', type=str))
dict_data = DictData.query.filter_by(type_code=type_code).layui_paginate()
count = dict_data.total
data = curd.model_to_dicts(schema=DictDataOutSchema, data=dict_data.items)
return table_api(data=data, count=count)
# 增加字典数据
@bp.get('/dictData/add')
@authorize("system:dict:add", log=True)
def dict_data_add():
type_code = request.args.get('typeCode', type=str)
return render_template('system/dict/data/add.html', type_code=type_code)
# 增加字典数据
@bp.post('/dictData/save')
@authorize("system:dict:add", log=True)
def dict_data_save():
req_json = request.get_json(force=True)
data_label = str_escape(req_json.get("dataLabel"))
data_value = str_escape(req_json.get("dataValue"))
enable = str_escape(req_json.get("enable"))
remark = str_escape(req_json.get("remark"))
type_code = str_escape(req_json.get("typeCode"))
d = DictData(data_label=data_label, data_value=data_value, enable=enable, remark=remark, type_code=type_code)
db.session.add(d)
db.session.commit()
if not d.id:
return jsonify(success=False, msg="增加失败")
return jsonify(success=True, msg="增加成功")
# 编辑字典数据
@bp.get('/dictData/edit')
@authorize("system:dict:edit", log=True)
def dict_data_edit():
_id = request.args.get('dataId', type=str)
dict_data = curd.get_one_by_id(DictData, _id)
return render_template('system/dict/data/edit.html', dict_data=dict_data)
# 编辑字典数据
@bp.put('/dictData/update')
@authorize("system:dict:edit", log=True)
def dict_data_update():
req_json = request.get_json(force=True)
id = req_json.get("dataId")
DictData.query.filter_by(id=id).update({
"data_label": str_escape(req_json.get("dataLabel")),
"data_value": str_escape(req_json.get("dataValue")),
"enable": str_escape(req_json.get("enable")),
"remark": str_escape(req_json.get("remark")),
"type_code": str_escape(req_json.get("typeCode"))
})
db.session.commit()
return success_api(msg="更新成功")
# 启用字典数据
@bp.put('/dictData/enable')
@authorize("system:dict:edit", log=True)
def dict_data_enable():
_id = request.get_json(force=True).get('dataId')
if _id:
res = curd.enable_status(model=DictData, id=_id)
if not res:
return fail_api(msg="出错啦")
return success_api(msg="启动成功")
return fail_api(msg="数据错误")
# 禁用字典数据
@bp.put('/dictData/disable')
@authorize("system:dict:edit", log=True)
def dict_data_disenable():
_id = request.get_json(force=True).get('dataId')
if _id:
res = curd.disable_status(model=DictData, id=_id)
if not res:
return fail_api(msg="出错啦")
return success_api(msg="禁用成功")
return fail_api(msg="数据错误")
# 删除字典数据
@bp.delete('dictData/remove/<int:id>')
@authorize("system:dict:remove", log=True)
def dict_data_delete(id):
res = curd.delete_one_by_id(model=DictData, id=id)
if not res:
return fail_api(msg="删除失败")
return success_api(msg="删除成功")
# 批量删除字典
@bp.delete('dictData/batchRemoveDictType')
@authorize("system:dict:remove", log=True)
def dict_type_batch_remove():
ids = request.form.getlist('ids[]')
for _id in ids:
DictData.query.filter_by(
type_code=DictType.query.filter_by(id=_id).first().type_code
).all()
res = DictType.query.filter_by(id=_id).delete()
if res == 0:
db.session.rollback()
return fail_api(msg="删除失败,请重试")
db.session.commit()
return success_api(msg="删除成功")
# 批量删除字典数据
@bp.delete('dictData/batchRemoveDictData')
@authorize("system:dict:remove", log=True)
def dict_data_batch_remove():
ids = request.form.getlist('ids[]')
for _id in ids:
res = curd.delete_one_by_id(model=DictData, id=_id)
if not res:
db.session.rollback()
return fail_api(msg="删除失败,请重试")
db.session.commit()
return success_api(msg="删除成功")
@@ -4,22 +4,24 @@ from flask import Blueprint, request, render_template, jsonify, current_app
from applications.common.utils.http import fail_api, success_api, table_api from applications.common.utils.http import fail_api, success_api, table_api
from applications.common.utils.rights import authorize from applications.common.utils.rights import authorize
from applications.extensions import db from applications.extensions import db
from applications.extensions.init_limit import limiter
from applications.models import Photo from applications.models import Photo
from applications.common.utils import upload as upload_curd from applications.common.utils import upload as upload_curd
admin_file = Blueprint('adminFile', __name__, url_prefix='/admin/file') bp = Blueprint('adminFile', __name__, url_prefix='/file')
# 图片管理 # 图片管理
@admin_file.get('/') @bp.get('/')
@authorize("admin:file:main", log=True) @authorize("system:file:main")
def index(): def index():
return render_template('admin/photo/photo.html') return render_template('system/photo/photo.html')
# 图片数据 # 图片数据
@admin_file.get('/table') @bp.get('/table')
@authorize("admin:file:main", log=True) @limiter.limit("60 per minute")
@authorize("system:file:main")
def table(): def table():
page = request.args.get('page', type=int) page = request.args.get('page', type=int)
limit = request.args.get('limit', type=int) limit = request.args.get('limit', type=int)
@@ -28,15 +30,15 @@ def table():
# 上传 # 上传
@admin_file.get('/upload') @bp.get('/upload')
@authorize("admin:file:add", log=True) @authorize("system:file:add", log=True)
def upload(): def upload():
return render_template('admin/photo/photo_add.html') return render_template('system/photo/photo_add.html')
# 上传接口 # 上传接口
@admin_file.post('/upload') @bp.post('/upload')
@authorize("admin:file:add", log=True) @authorize("system:file:add", log=True)
def upload_api(): def upload_api():
if 'file' in request.files: if 'file' in request.files:
photo = request.files['file'] photo = request.files['file']
@@ -55,8 +57,8 @@ def upload_api():
# 图片删除 # 图片删除
@admin_file.route('/delete', methods=['GET', 'POST']) @bp.route('/delete', methods=['GET', 'POST'])
@authorize("admin:file:delete", log=True) @authorize("system:file:delete", log=True)
def delete(): def delete():
_id = request.form.get('id') _id = request.form.get('id')
res = upload_curd.delete_photo_by_id(_id) res = upload_curd.delete_photo_by_id(_id)
@@ -67,8 +69,8 @@ def delete():
# 图片批量删除 # 图片批量删除
@admin_file.route('/batchRemove', methods=['GET', 'POST']) @bp.route('/batchRemove', methods=['GET', 'POST'])
@authorize("admin:file:delete", log=True) @authorize("system:file:delete", log=True)
def batch_remove(): def batch_remove():
ids = request.form.getlist('ids[]') ids = request.form.getlist('ids[]')
photo_name = Photo.query.filter(Photo.id.in_(ids)).all() photo_name = Photo.query.filter(Photo.id.in_(ids)).all()
+14
View File
@@ -0,0 +1,14 @@
from flask import Blueprint, render_template
from flask_login import login_required, current_user
bp = Blueprint('index', __name__, url_prefix='/')
# 首页
@bp.get('/')
@login_required
def index():
user = current_user
return render_template('system/index.html', user=user)
@@ -6,35 +6,38 @@ from applications.models import AdminLog
from applications.schemas import LogOutSchema from applications.schemas import LogOutSchema
from applications.common.curd import model_to_dicts from applications.common.curd import model_to_dicts
admin_log = Blueprint('adminLog', __name__, url_prefix='/admin/log') bp = Blueprint('log', __name__, url_prefix='/log')
# 日志管理 # 日志管理
@admin_log.get('/') @bp.get('/')
@authorize("admin:log:main") @authorize("system:log:main")
def index(): def index():
return render_template('admin/admin_log/main.html') return render_template('system/admin_log/main.html')
# 登录日志 # 登录日志
@admin_log.get('/loginLog') @bp.get('/loginLog')
@authorize("admin:log:main") @authorize("system:log:main")
def login_log(): def login_log():
# orm查询 # orm查询
# 使用分页获取data需要.items # 使用分页获取data需要.items
log = AdminLog.query.filter_by(url='/passport/login').order_by(desc(AdminLog.create_time)).layui_paginate() log = AdminLog.query.filter_by(
url='/system/passport/login'
).order_by(desc(AdminLog.create_time)).layui_paginate()
count = log.total count = log.total
return table_api(data=model_to_dicts(schema=LogOutSchema, data=log.items), count=count) return table_api(data=model_to_dicts(schema=LogOutSchema, data=log.items), count=count)
# 操作日志 # 操作日志
@admin_log.get('/operateLog') @bp.get('/operateLog')
@authorize("admin:log:main") @authorize("system:log:main")
def operate_log(): def operate_log():
# orm查询 # orm查询
# 使用分页获取data需要.items # 使用分页获取data需要.items
log = AdminLog.query.filter( log = AdminLog.query.filter(
AdminLog.url != '/passport/login').order_by( AdminLog.url != '/system/passport/login'
).order_by(
desc(AdminLog.create_time)).layui_paginate() desc(AdminLog.create_time)).layui_paginate()
count = log.total count = log.total
return table_api(data=model_to_dicts(schema=LogOutSchema, data=log.items), count=count) return table_api(data=model_to_dicts(schema=LogOutSchema, data=log.items), count=count)
+98
View File
@@ -0,0 +1,98 @@
from flask import Blueprint, render_template, request, current_app
from flask_login import current_user
from applications.common.curd import model_to_dicts
from applications.common.helper import ModelFilter
from applications.common.utils.http import table_api, fail_api, success_api
from applications.common.utils.rights import authorize
from applications.common.utils.validate import str_escape
from applications.extensions import db
from applications.extensions.init_limit import limiter
from applications.models import Mail
from applications.schemas import MailOutSchema
from applications.common.utils import mail
from applications.common.admin import admin_log
bp = Blueprint('adminMail', __name__, url_prefix='/mail')
# 用户管理
@bp.get('/')
@authorize("system:mail:main")
def main():
return render_template('system/mail/main.html')
# 用户分页查询
@bp.get('/data')
@limiter.limit("60 per minute")
@authorize("system:mail:main")
def data():
# 获取请求参数
receiver = str_escape(request.args.get("receiver"))
subject = str_escape(request.args.get('subject'))
content = str_escape(request.args.get('content'))
# 查询参数构造
mf = ModelFilter()
if receiver:
mf.contains(field_name="receiver", value=receiver)
if subject:
mf.contains(field_name="subject", value=subject)
if content:
mf.exact(field_name="content", value=content)
# orm查询
# 使用分页获取data需要.items
mail = Mail.query.filter(mf.get_filter(Mail)).layui_paginate()
count = mail.total
# 返回api
return table_api(data=model_to_dicts(schema=MailOutSchema, data=mail.items), count=count)
# 用户增加
@bp.get('/add')
@authorize("system:mail:add", log=True)
def add():
return render_template('system/mail/add.html')
@bp.post('/save')
@authorize("system:mail:add", log=True)
def save():
req_json = request.get_json(force=True)
receiver = str_escape(req_json.get("receiver"))
subject = str_escape(req_json.get('subject'))
content = str_escape(req_json.get('content'))
user_id = current_user.id
try:
if mail.add(receiver=receiver, subject=subject, content=content, user_id=user_id):
return success_api(msg="增加成功")
except Exception as e:
current_app.log_exception(e)
admin_log(request, False, desc="发送日志失败:" + str(e))
return success_api(msg="发送失败,请检查日志。")
# 删除用户
@bp.delete('/remove/<int:id>')
@authorize("system:mail:remove", log=True)
def delete(id):
res = Mail.query.filter_by(id=id).delete()
if not res:
return fail_api(msg="删除失败")
db.session.commit()
return success_api(msg="删除成功")
# 批量删除
@bp.delete('/batchRemove')
@authorize("system:mail:remove", log=True)
def batch_remove():
ids = request.form.getlist('ids[]')
for id in ids:
res = Mail.query.filter_by(id=id).delete()
if not res:
return fail_api(msg="批量删除失败")
db.session.commit()
return success_api(msg="批量删除成功")
+170
View File
@@ -0,0 +1,170 @@
import os
import re
import sys
import time
import psutil
import platform
from datetime import datetime
from flask import Blueprint, render_template
from applications.common.utils.http import table_api, success_api
from applications.common.utils.rights import authorize
from applications.common.utils.cache import cache_auto_internal
bp = Blueprint('adminMonitor', __name__, url_prefix='/monitor')
def get_disk_partitions_list():
disk_partitions_list = []
# 判断是否在容器中
if not os.path.exists('/.dockerenv'):
disk_partitions = psutil.disk_partitions()
for i in disk_partitions:
try:
a = psutil.disk_usage(i.mountpoint)
except PermissionError:
continue
disk_partitions_dict = {
'device': i.device,
'fstype': i.fstype,
'total': a.total,
'used': a.used,
'free': a.free,
'percent': a.percent
}
disk_partitions_list.append(disk_partitions_dict)
else:
try:
usage = psutil.disk_usage('/')
except PermissionError:
pass
disk_partitions_list.append({
'device': '/', # 设备名称(根文件系统)
'fstype': psutil.disk_partitions()[0].fstype, # 文件系统类型
'total': usage.total, # 总容量(字节)
'used': usage.used, # 已用空间(字节)
'free': usage.free, # 可用空间(字节)
'percent': usage.percent # 使用百分比
})
return disk_partitions_list
def get_basic_info():
# 主机名称
hostname = platform.node()
# 系统版本
system_version = platform.platform()
# Python 版本
python_version = platform.python_version()
# 开机时间
boot_time = datetime.fromtimestamp(psutil.boot_time()).replace(microsecond=0)
up_time = datetime.now().replace(microsecond=0) - boot_time
up_time_list = re.split(r':', str(up_time))
up_time_format = "{} 小时 {} 分钟 {}".format(up_time_list[0], up_time_list[1], up_time_list[2])
up_time_format = up_time_format.replace("days,", "")
return {
'hostname': hostname,
'system_version': system_version,
'python_version': python_version,
'boot_time': boot_time,
'up_time_format': up_time_format
}
# 系统监控
@bp.get('/')
@authorize("system:monitor:main")
def main():
# 当前时间
time_now = time.strftime('%H:%M:%S ', time.localtime(time.time()))
return render_template(
'system/monitor.html',
time_now=time_now,
**get_basic_info()
)
# 图表 api
@bp.get('/polling')
@authorize("system:monitor:main")
def ajax_polling():
# 获取 CPU 核心数
cpu_count = cache_auto_internal('cpu_count', lambda: psutil.cpu_count(), expired=999999999)
# 获取 CPU 使用率
cpus_percent = cache_auto_internal('cpus_percent',
lambda: psutil.cpu_percent(interval=1, percpu=False),
expired=5)
# 每个 CPU 的使用率
cpu_percent_per_core = cache_auto_internal('cpu_percent_per_core',
lambda: list(enumerate(psutil.cpu_percent(interval=1, percpu=True))),
expired=5)
# 获取空闲率、等待率
cpu_times_percent = cache_auto_internal('cpu_times_percent',
lambda: psutil.cpu_times_percent(interval=1, percpu=False),
expired=5)
# 内存信息
memory_information = cache_auto_internal('memory_information',
psutil.virtual_memory,
expired=5)
# 硬盘信息
disk_partitions_list = cache_auto_internal('disk_partitions_list',
get_disk_partitions_list,
expired=5)
# 系统信息
basic_info = cache_auto_internal('basic_info',
get_basic_info,
expired=5)
memory_usage = memory_information.percent
memory_used = memory_information.used
memory_total = memory_information.total
memory_free = memory_information.free
cpu_idle_percent = cpu_times_percent.idle
if hasattr(cpu_times_percent, 'iowait'):
cpu_wait_percent = cpu_times_percent.iowait
else:
cpu_wait_percent = "-"
return table_api(msg="请求成功",
count=0,
data={
'cpu_count': cpu_count,
'cpus_percent': cpus_percent,
'cpu_idle_percent': cpu_idle_percent,
'cpu_wait_percent': cpu_wait_percent,
'cpu_percent_per_core': cpu_percent_per_core,
'memory_used': memory_used,
'memory_total': memory_total,
'memory_free': memory_free,
'memory_usage': memory_usage,
'disk_partitions_list': disk_partitions_list,
'time_now': time.strftime('%H:%M:%S', time.localtime(time.time())),
'basic_info': basic_info
})
# 关闭程序
@bp.get('/kill')
@authorize("system:monitor:main")
def kill():
# 注:若是多 worker 则不生效
return success_api(msg="关闭命令已发送,请修改代码以生效。")
for proc in psutil.process_iter():
if proc.pid == os.getpid():
proc.kill()
sys.exit(1)
+142
View File
@@ -0,0 +1,142 @@
"""
后台导航管理视图(需要登录 + 权限码 system:nav:*
提供 Nav 模型的 CRUD 接口,供后台管理员维护前台公开导航。
"""
from flask import Blueprint, render_template, request
from flask_login import current_user
from applications.common.utils.http import table_api, fail_api, success_api
from applications.common.utils.rights import authorize
from applications.common.utils.validate import str_escape
from applications.extensions import db
from applications.extensions.init_limit import limiter
from applications.models import Nav
from applications.schemas import NavManageSchema
bp = Blueprint('nav', __name__, url_prefix='/nav')
@bp.get('/')
@authorize("system:nav:main")
def main():
"""导航管理主页"""
return render_template('system/nav/main.html')
@bp.get('/data')
@limiter.limit("60 per minute")
@authorize("system:nav:main")
def data():
"""表格数据接口"""
keyword = str_escape(request.args.get('keyword', type=str))
category = str_escape(request.args.get('category', type=str))
query = Nav.query
if keyword:
query = query.filter(
db.or_(
Nav.title.contains(keyword),
Nav.url.contains(keyword),
Nav.description.contains(keyword),
)
)
if category:
query = query.filter(Nav.category == category)
items = query.order_by(Nav.category.asc(), Nav.sort.asc(), Nav.id.asc()).all()
data = NavManageSchema(many=True).dump(items)
return table_api(msg="请求成功", data=data, count=len(data))
@bp.get('/add')
@authorize("system:nav:add", log=True)
def add():
return render_template('system/nav/add.html')
@bp.post('/save')
@authorize("system:nav:add", log=True)
def save():
req = request.get_json(force=True)
try:
nav = Nav(
category=str_escape(req.get('category')),
title=str_escape(req.get('title')),
url=str_escape(req.get('url')),
description=str_escape(req.get('description')),
icon=str_escape(req.get('icon')) or 'layui-icon-link',
sort=int(req.get('sort') or 0),
status=int(req.get('status') or 1),
is_external=int(req.get('isExternal') or 1),
create_by=current_user.username if current_user.is_authenticated else 'admin',
)
db.session.add(nav)
db.session.commit()
except Exception as e:
db.session.rollback()
return fail_api(msg=f"新增失败:{e}")
return success_api(msg="新增成功")
@bp.get('/edit')
@authorize("system:nav:edit", log=True)
def edit():
nav_id = request.args.get("navId", type=int)
nav = Nav.query.get(nav_id)
if not nav:
return fail_api(msg="导航不存在")
return render_template('system/nav/edit.html', nav=nav)
@bp.put('/update')
@authorize("system:nav:edit", log=True)
def update():
req = request.get_json(force=True)
nav_id = req.get('id')
nav = Nav.query.get(nav_id)
if not nav:
return fail_api(msg="导航不存在")
try:
nav.category = str_escape(req.get('category')) or nav.category
nav.title = str_escape(req.get('title')) or nav.title
nav.url = str_escape(req.get('url')) or nav.url
nav.description = str_escape(req.get('description'))
nav.icon = str_escape(req.get('icon')) or 'layui-icon-link'
nav.sort = int(req.get('sort') or 0)
nav.status = int(req.get('status') or 1)
nav.is_external = int(req.get('isExternal') or 1)
db.session.commit()
except Exception as e:
db.session.rollback()
return fail_api(msg=f"更新失败:{e}")
return success_api(msg="更新成功")
@bp.delete('/remove/<int:_id>')
@authorize("system:nav:remove", log=True)
def remove(_id):
nav = Nav.query.get(_id)
if not nav:
return fail_api(msg="导航不存在")
db.session.delete(nav)
db.session.commit()
return success_api(msg="删除成功")
@bp.put('/enable')
@authorize("system:nav:edit", log=True)
def enable():
nav_id = request.get_json(force=True).get('navId')
Nav.query.filter_by(id=nav_id).update({"status": 1})
db.session.commit()
return success_api(msg="已启用")
@bp.put('/disable')
@authorize("system:nav:edit", log=True)
def disable():
nav_id = request.get_json(force=True).get('navId')
Nav.query.filter_by(id=nav_id).update({"status": 0})
db.session.commit()
return success_api(msg="已禁用")
@@ -1,40 +1,36 @@
from flask import Blueprint, session, redirect, url_for, render_template, request from flask import Blueprint, session, redirect, url_for, render_template, request
from flask_login import current_user, login_user, login_required, logout_user from flask_login import current_user, login_user, login_required, logout_user
from applications.common import admin as index_curd from applications.common.admin import get_captcha, login_log
from applications.common.admin_log import login_log
from applications.common.utils.http import fail_api, success_api from applications.common.utils.http import fail_api, success_api
from applications.models import User from applications.models import User
passport_bp = Blueprint('passport', __name__, url_prefix='/passport') bp = Blueprint('passport', __name__, url_prefix='/passport')
def register_passport_views(app):
app.register_blueprint(passport_bp)
# 获取验证码 # 获取验证码
@passport_bp.get('/getCaptcha') @bp.get('/getCaptcha')
def get_captcha(): def captcha():
resp, code = index_curd.get_captcha() resp, code = get_captcha()
session["code"] = code session["code"] = code
return resp return resp
# 登录 # 登录
@passport_bp.get('/login') @bp.get('/login')
def login(): def login():
if current_user.is_authenticated: if current_user.is_authenticated:
return redirect(url_for('admin.index')) return redirect(url_for('index.index'))
return render_template('admin/login.html') return render_template('system/login.html')
# 登录 # 登录
@passport_bp.post('/login') @bp.post('/login')
def login_post(): def login_post():
req = request.form req = request.form
username = req.get('username') username = req.get('username')
password = req.get('password') password = req.get('password')
remember = bool(req.get('remember-me'))
code = req.get('captcha').__str__().lower() code = req.get('captcha').__str__().lower()
if not username or not password or not code: if not username or not password or not code:
@@ -57,20 +53,39 @@ def login_post():
if username == user.username and user.validate_password(password): if username == user.username and user.validate_password(password):
# 登录 # 登录
login_user(user) login_user(user, remember=remember)
# 记录登录日志 # 记录登录日志
login_log(request, uid=user.id, is_access=True) login_log(request, uid=user.id, is_access=True)
# 存入权限 # 授权路由存入session
index_curd.add_auth_session() role = current_user.role
user_power = []
for i in role:
if i.enable == 0:
continue
for p in i.power:
if p.enable == 0:
continue
user_power.append(p.code)
session['permissions'] = user_power
# # 角色存入session
# roles = []
# for role in current_user.role.all():
# roles.append(role.id)
# session['role'] = [roles]
return success_api(msg="登录成功") return success_api(msg="登录成功")
login_log(request, uid=user.id, is_access=False) login_log(request, uid=user.id, is_access=False)
return fail_api(msg="用户名或密码错误") return fail_api(msg="用户名或密码错误")
# 退出登录 # 退出登录
@passport_bp.post('/logout') @bp.post('/logout')
@login_required @login_required
def logout(): def logout():
logout_user() logout_user()
if 'permissions' in session:
session.pop('permissions') session.pop('permissions')
return success_api(msg="注销成功") return success_api(msg="注销成功")
+203
View File
@@ -0,0 +1,203 @@
from flask import Blueprint, render_template, request, jsonify
from applications.common import curd
from applications.common.utils.http import success_api, fail_api, table_api
from applications.common.utils.rights import authorize
from applications.common.utils.validate import str_escape
from applications.extensions import db
from applications.extensions.init_limit import limiter
from applications.models import Power
from applications.schemas import PowerOutSchema2
from applications.schemas.admin_power import PowerSchema
bp = Blueprint('power', __name__, url_prefix='/power')
@bp.get('/')
@authorize("system:power:main")
def index():
return render_template('system/power/main.html')
@bp.post('/data')
@limiter.limit("60 per minute")
@authorize("system:power:main")
def data():
power = Power.query.all()
data = PowerSchema(many=True).dump(power)
# 创建一个字典,用于存储每个节点的子节点
tree = {}
for item in data:
item["children"] = []
tree[item["id"]] = item
# 构建树形结构
root_nodes = []
for item in data:
parent_id = item["parent_id"] if item["parent_id"] != 0 else None
if parent_id is None:
root_nodes.append(item)
else:
if parent_id in tree:
tree[parent_id]["children"].append(item)
return table_api(msg="请求成功", data=root_nodes)
@bp.get('/add')
@authorize("system:power:add", log=True)
def add():
return render_template('system/power/add.html')
@bp.get('/selectParent')
@authorize("system:power:main", log=True)
def select_parent():
power = Power.query.all()
res = curd.model_to_dicts(schema=PowerOutSchema2, data=power)
res.append({"powerId": 0, "powerName": "顶级权限", "parentId": -1})
res = {
"status": {"code": 200, "message": "默认"},
"data": res
}
return jsonify(res)
# 增加
@bp.post('/save')
@authorize("system:power:add", log=True)
def save():
req = request.get_json(force=True)
data = {
"icon": str_escape(req.get("icon")),
"open_type": str_escape(req.get("openType")),
"parent_id": str_escape(req.get("parentId")),
"code": str_escape(req.get("powerCode")),
"name": str_escape(req.get("powerName")),
"type": str_escape(req.get("powerType")),
"url": str_escape(req.get("powerUrl")),
"sort": str_escape(req.get("sort"))
}
if not data['sort'].isdigit():
return fail_api(msg="参数 sort 需为整数")
power = Power(**data)
db.session.add(power)
db.session.commit()
return success_api(msg="权限添加成功")
# 权限编辑
@bp.get('/edit/<int:_id>')
@authorize("system:power:edit", log=True)
def edit(_id):
power = curd.get_one_by_id(Power, _id)
icon = str(power.icon).split()
if len(icon) == 2:
icon = icon[1]
else:
icon = None
return render_template('system/power/edit.html', power=power, icon=icon)
# 权限更新
@bp.put('/update')
@authorize("system:power:edit", log=True)
def update():
req_json = request.get_json(force=True)
id = request.get_json(force=True).get("powerId")
data = {
"icon": str_escape(req_json.get("icon")),
"open_type": str_escape(req_json.get("openType")),
"parent_id": str_escape(req_json.get("parentId")),
"code": str_escape(req_json.get("powerCode")),
"name": str_escape(req_json.get("powerName")),
"type": str_escape(req_json.get("powerType")),
"url": str_escape(req_json.get("powerUrl")),
"sort": str_escape(req_json.get("sort"))
}
if not data['sort'].isdigit():
return fail_api(msg="参数 sort 需为整数")
res = Power.query.filter_by(id=id).update(data)
db.session.commit()
if not res:
return fail_api(msg="更新权限失败")
return success_api(msg="更新权限成功")
# 启用权限
@bp.put('/enable')
@authorize("system:power:edit", log=True)
def enable():
_id = request.get_json(force=True).get('powerId')
if _id:
res = curd.enable_status(Power, _id)
if not res:
return fail_api(msg="出错啦")
return success_api(msg="启用成功")
return fail_api(msg="数据错误")
# 禁用权限
@bp.put('/disable')
@authorize("system:power:edit", log=True)
def dis_enable():
_id = request.get_json(force=True).get('powerId')
if id:
res = curd.disable_status(Power, _id)
if not res:
return fail_api(msg="出错啦")
return success_api(msg="禁用成功")
return fail_api(msg="数据错误")
# 权限删除
@bp.delete('/remove/<int:id>')
@authorize("system:power:remove", log=True)
def remove(id):
power = Power.query.filter_by(id=id).first()
if power:
power.role = []
r = Power.query.filter_by(id=id).delete()
db.session.commit()
if r:
return success_api(msg="删除成功")
else:
return fail_api(msg="删除失败")
# 批量删除
@bp.delete('/batchRemove')
@authorize("system:power:remove", log=True)
def batch_remove():
ids = request.form.getlist('ids[]')
if not ids:
return fail_api(msg="未提供删除 ID")
for id in ids:
if not id.isdigit():
db.session.rollback()
return fail_api(msg="参数提供错误")
id = int(id)
power = Power.query.filter_by(id=id).first()
if power:
# 清空关联的角色(如果需要)
power.role = []
db.session.delete(power)
db.session.commit()
return success_api(msg="批量删除成功")
+265
View File
@@ -0,0 +1,265 @@
import copy
from collections import OrderedDict
from flask import jsonify, current_app, Blueprint, render_template
from flask_login import login_required, current_user
from ...common.utils.http import table_api
from ...models import Power
from ...schemas import PowerOutSchema
bp = Blueprint('rights', __name__, url_prefix='/rights')
# 渲染配置
@bp.get('/configs')
@login_required
def configs():
# 网站配置
config = dict(logo={
# 网站名称
"title": current_app.config.get("SYSTEM_NAME"),
# 网站图标
"image": "/static/system/admin/images/logo.png"
# 菜单配置
}, menu={
# 菜单数据来源
"data": "/system/rights/menu",
"collaspe": False,
# 是否同时只打开一个菜单目录
"accordion": True,
"method": "GET",
# 是否开启多系统菜单模式
"control": False,
# 顶部菜单宽度 PX
"controlWidth": 500,
# 默认选中的菜单项
"select": "0",
# 是否开启异步菜单,false 时 data 属性设置为菜单数据,false 时为 json 文件或后端接口
"async": True
}, tab={
# 是否开启多选项卡
"enable": True,
# 切换选项卡时,是否刷新页面状态
"keepState": True,
# 是否开启 Tab 记忆
"session": True,
# 预加载
"preload": False,
# 最大可打开的选项卡数量
"max": 30,
"index": {
# 标识 ID , 建议与菜单项中的 ID 一致
"id": "10",
# 页面地址
"href": "/system/rights/welcome",
# 标题
"title": "首页"
}
}, theme={
# 默认主题色,对应 colors 配置中的 ID 标识
"defaultColor": "2",
# 默认的菜单主题 dark-theme 黑 / light-theme 白
"defaultMenu": "dark-theme",
# 是否允许用户切换主题,false 时关闭自定义主题面板
"allowCustom": True
}, colors=[{
"id": "1",
"color": "#2d8cf0"
},
{
"id": "2",
"color": "#5FB878"
},
{
"id": "3",
"color": "#1E9FFF"
}, {
"id": "4",
"color": "#FFB800"
}, {
"id": "5",
"color": "darkgray"
}
], links=current_app.config.get("SYSTEM_PANEL_LINKS"), other={
# 主页动画时长
"keepLoad": 0,
# 布局顶部主题
"autoHead": False
}, header={
'message': '/system/rights/message'
})
return jsonify(config)
# 消息
@bp.get('/message')
@login_required
def message():
return dict(code=200,
data=[
{
"id": 1,
"title": "通知",
"children": [
{
"id": 11,
"avatar": "https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png",
"title": "你收到了 14 份新周报",
"context": "这是消息内容。",
"form": "就眠仪式",
"time": "刚刚"
},
{
"id": 12,
"avatar": "https://gw.alipayobjects.com/zos/rmsportal/OKJXDXrmkNshAMvwtvhu.png",
"title": "曲妮妮 已通过第三轮面试",
"context": "这是消息内容。",
"form": "就眠仪式",
"time": "刚刚"
},
{
"id": 11,
"avatar": "https://gw.alipayobjects.com/zos/rmsportal/kISTdvpyTAhtGxpovNWd.png",
"title": "可以区分多种通知类型",
"context": "这是消息内容。",
"form": "就眠仪式",
"time": "刚刚"
},
{
"id": 12,
"avatar": "https://gw.alipayobjects.com/zos/rmsportal/GvqBnKhFgObvnSGkDsje.png",
"title": "左侧图标用于区分不同的类型",
"context": "这是消息内容。",
"form": "就眠仪式",
"time": "刚刚"
},
{
"id": 11,
"avatar": "https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png",
"title": "内容不要超过两行字",
"context": "这是消息内容。",
"form": "就眠仪式",
"time": "刚刚"
}
]
},
{
"id": 2,
"title": "消息",
"children": [
{
"id": 11,
"avatar": "https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png",
"title": "你收到了 14 份新周报",
"context": "这是消息内容。",
"form": "就眠仪式",
"time": "刚刚"
},
{
"id": 12,
"avatar": "https://gw.alipayobjects.com/zos/rmsportal/OKJXDXrmkNshAMvwtvhu.png",
"title": "曲妮妮 已通过第三轮面试",
"context": "这是消息内容。",
"form": "就眠仪式",
"time": "刚刚"
},
{
"id": 11,
"avatar": "https://gw.alipayobjects.com/zos/rmsportal/kISTdvpyTAhtGxpovNWd.png",
"title": "可以区分多种通知类型",
"context": "这是消息内容。",
"form": "就眠仪式",
"time": "刚刚"
},
{
"id": 12,
"avatar": "https://gw.alipayobjects.com/zos/rmsportal/GvqBnKhFgObvnSGkDsje.png",
"title": "左侧图标用于区分不同的类型",
"context": "这是消息内容。",
"form": "就眠仪式",
"time": "刚刚"
},
{
"id": 11,
"avatar": "https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png",
"title": "内容不要超过两行字",
"context": "这是消息内容。",
"form": "就眠仪式",
"time": "刚刚"
}
]
},
{
"id": 3,
"title": "代办",
"children": []
}
])
# 菜单
@bp.get('/menu')
@login_required
def menu():
if current_user.username != current_app.config.get("SUPERADMIN"):
role = current_user.role
powers = []
for i in role:
# 如果角色没有被启用就直接跳过
if i.enable == 0:
continue
# 变量角色用户的权限
for p in i.power:
# 如果权限关闭了就直接跳过
if p.enable == 0:
continue
# 一二级菜单
if int(p.type) in [0, 1] and p not in powers:
powers.append(p)
power_schema = PowerOutSchema(many=True) # 用已继承 ma.ModelSchema 类的自定制类生成序列化类
power_dict = power_schema.dump(powers) # 生成可序列化对象
power_dict.sort(key=lambda x: (x['parent_id'], x['id']), reverse=True)
menu_dict = OrderedDict()
for _dict in power_dict:
if _dict['id'] in menu_dict:
# 当前节点添加子节点
_dict['children'] = copy.deepcopy(menu_dict[_dict['id']])
_dict['children'].sort(key=lambda item: item['sort'])
# 删除子节点
del menu_dict[_dict['id']]
if _dict['parent_id'] not in menu_dict:
menu_dict[_dict['parent_id']] = [_dict]
else:
menu_dict[_dict['parent_id']].append(_dict)
return jsonify(sorted(menu_dict.get(0), key=lambda item: item['sort']))
else:
powers = Power.query.filter(Power.enable == 1).all()
power_schema = PowerOutSchema(many=True) # 用已继承 ma.ModelSchema 类的自定制类生成序列化类
power_dict = power_schema.dump(powers) # 生成可序列化对象
power_dict.sort(key=lambda x: (x['parent_id'], x['id']), reverse=True)
menu_dict = OrderedDict()
for _dict in power_dict:
if _dict['id'] in menu_dict:
# 当前节点添加子节点
_dict['children'] = copy.deepcopy(menu_dict[_dict['id']])
_dict['children'].sort(key=lambda item: item['sort'])
# 删除子节点
del menu_dict[_dict['id']]
if _dict['parent_id'] not in menu_dict:
menu_dict[_dict['parent_id']] = [_dict]
else:
menu_dict[_dict['parent_id']].append(_dict)
return jsonify(sorted(menu_dict.get(0), key=lambda item: item['sort']))
# 控制台页面
@bp.get('/welcome')
@login_required
def welcome():
return render_template('system/analysis/main.html')
+208
View File
@@ -0,0 +1,208 @@
from flask import Blueprint, render_template, request, jsonify
from flask_login import login_required
from applications.common.curd import model_to_dicts, enable_status, disable_status, get_one_by_id
from applications.common.utils.http import table_api, success_api, fail_api
from applications.common.utils.rights import authorize
from applications.common.utils.validate import str_escape
from applications.extensions import db
from applications.extensions.init_limit import limiter
from applications.models import Role, Power, User
from applications.schemas import RoleOutSchema, PowerOutSchema2
bp = Blueprint('role', __name__, url_prefix='/role')
# 用户管理
@bp.get('/')
@authorize("system:role:main")
def main():
return render_template('system/role/main.html')
# 表格数据
@bp.get('/data')
@limiter.limit("60 per minute")
@authorize("system:role:main")
def table():
role_name = str_escape(request.args.get('roleName', type=str))
role_code = str_escape(request.args.get('roleCode', type=str))
filters = []
if role_name:
filters.append(Role.name.contains(role_name))
if role_code:
filters.append(Role.code.contains(role_code))
roles = Role.query.filter(*filters).layui_paginate()
return table_api(data=RoleOutSchema(many=True).dump(roles), count=roles.total)
# 角色增加
@bp.get('/add')
@authorize("system:role:add", log=True)
def add():
return render_template('system/role/add.html')
# 角色增加
@bp.post('/save')
@authorize("system:role:add", log=True)
def save():
req = request.get_json(force=True)
data = {
"details": str_escape(req.get("details")),
"enable": str_escape(req.get("enable")),
"code": str_escape(req.get("roleCode")), # 角色标识
"name": str_escape(req.get("roleName")), # 角色名称
"sort": str_escape(req.get("sort"))
}
details = data.get('details')
del data['details']
if not all(data.values()):
return fail_api(msg="参数不足")
data['details'] = details
# 参数效验
if not data['sort'].isdigit():
return fail_api(msg="参数 sort 需为整数")
role = Role(**data)
db.session.add(role)
db.session.commit()
return success_api(msg="添加角色成功")
# 角色授权
@bp.get('/power/<int:_id>')
@authorize("system:role:power", log=True)
def power(_id):
return render_template('system/role/power.html', id=_id)
# 获取角色权限
@bp.get('/getRolePower/<int:id>')
@authorize("system:role:main", log=True)
def get_role_power(id):
role = Role.query.filter_by(id=id).first()
check_powers = role.power
check_powers_list = []
for cp in check_powers:
check_powers_list.append(cp.id)
powers = Power.query.all()
power_schema = PowerOutSchema2(many=True) # 用已继承ma.ModelSchema类的自定制类生成序列化类
output = power_schema.dump(powers) # 生成可序列化对象
for i in output:
if int(i.get("powerId")) in check_powers_list:
i["checkArr"] = "1"
else:
i["checkArr"] = "0"
res = {
"data": output,
"status": {"code": 200, "message": "默认"}
}
return jsonify(res)
# 保存角色权限
@bp.put('/saveRolePower')
@authorize("system:role:edit", log=True)
def save_role_power():
req_form = request.form
power_ids = req_form.get("powerIds")
power_list = power_ids.split(',')
role_id = req_form.get("roleId")
role = Role.query.filter_by(id=role_id).first()
if not role:
return fail_api(msg="角色不存在")
powers = Power.query.filter(Power.id.in_(power_list)).all()
role.power = powers
db.session.commit()
return success_api(msg="授权成功")
# 角色编辑
@bp.get('/edit/<int:id>')
@authorize("system:role:edit", log=True)
def edit(id):
r = get_one_by_id(model=Role, id=id)
return render_template('system/role/edit.html', role=r)
# 更新角色
@bp.put('/update')
@authorize("system:role:edit", log=True)
def update():
req_json = request.get_json(force=True)
id = req_json.get("roleId")
data = {
"code": str_escape(req_json.get("roleCode")),
"name": str_escape(req_json.get("roleName")),
"sort": str_escape(req_json.get("sort")),
"enable": str_escape(req_json.get("enable")),
"details": str_escape(req_json.get("details"))
}
if not data['enable'].isdigit():
return fail_api(msg="参数 enable 需为整数")
if not data['sort'].isdigit():
return fail_api(msg="参数 sort 需为整数")
role = Role.query.filter_by(id=id).update(data)
db.session.commit()
if not role:
return fail_api(msg="更新角色失败")
return success_api(msg="更新角色成功")
# 启用用户
@bp.put('/enable')
@authorize("system:role:edit", log=True)
def enable():
_id = request.get_json(force=True).get('roleId')
if _id:
res = enable_status(Role, _id)
if not res:
return fail_api(msg="出错啦")
return success_api(msg="启动成功")
return fail_api(msg="数据错误")
# 禁用用户
@bp.put('/disable')
@authorize("system:role:edit", log=True)
def dis_enable():
_id = request.get_json(force=True).get('roleId')
if _id:
res = disable_status(Role, _id)
if not res:
return fail_api(msg="出错啦")
return success_api(msg="禁用成功")
return fail_api(msg="数据错误")
# 角色删除
@bp.delete('/remove/<int:id>')
@authorize("system:role:remove", log=True)
def remove(id):
role = Role.query.filter_by(id=id).first()
if not role:
return fail_api(msg="角色不存在")
# 删除该角色的权限和用户
role.power = []
role.user = []
r = Role.query.filter_by(id=id).delete()
db.session.commit()
if not r:
return fail_api(msg="角色删除失败")
return success_api(msg="角色删除成功")
@@ -3,66 +3,79 @@ from flask_login import login_required, current_user
from sqlalchemy import desc from sqlalchemy import desc
from applications.common import curd from applications.common import curd
from applications.common.curd import model_to_dicts, enable_status, disable_status from applications.common.curd import enable_status, disable_status
from applications.common.helper import ModelFilter
from applications.common.utils.http import table_api, fail_api, success_api from applications.common.utils.http import table_api, fail_api, success_api
from applications.common.utils.rights import authorize from applications.common.utils.rights import authorize
from applications.common.utils.validate import xss_escape from applications.common.utils.validate import str_escape
from applications.extensions import db from applications.extensions import db
from applications.models import Role from applications.extensions.init_limit import limiter
from applications.models import Role, Dept
from applications.models import User, AdminLog from applications.models import User, AdminLog
from applications.schemas import UserOutSchema
admin_user = Blueprint('adminUser', __name__, url_prefix='/admin/user') bp = Blueprint('user', __name__, url_prefix='/user')
# 用户管理 # 用户管理
@admin_user.get('/') @bp.get('/')
@authorize("admin:user:main", log=True) @authorize("system:user:main")
def main(): def main():
return render_template('admin/user/main.html') return render_template('system/user/main.html')
# 用户分页查询 # 用户分页查询
@admin_user.get('/data') @bp.get('/data')
@authorize("admin:user:main", log=True) @limiter.limit("60 per minute")
@authorize("system:user:main")
def data(): def data():
# 获取请求参数 # 获取请求参数
real_name = xss_escape(request.args.get('realName', type=str)) real_name = str_escape(request.args.get('realname', type=str))
username = xss_escape(request.args.get('username', type=str))
dept_id = request.args.get('deptId', type=int)
# 查询参数构造
mf = ModelFilter()
if real_name:
mf.contains(field_name="realname", value=real_name)
if username:
mf.contains(field_name="username", value=username)
if dept_id:
mf.exact(field_name="dept_id", value=dept_id)
# orm查询
# 使用分页获取data需要.items
user = User.query.filter(mf.get_filter(model=User)).layui_paginate()
count = user.total
# 返回api
return table_api(data=model_to_dicts(schema=UserOutSchema, data=user.items), count=count)
username = str_escape(request.args.get('username', type=str))
dept_id = request.args.get('deptId', type=int)
filters = []
if real_name:
filters.append(User.realname.contains(real_name))
if username:
filters.append(User.username.contains(username))
if dept_id:
filters.append(User.dept_id == dept_id)
# print(*filters)
query = db.session.query(
User,
Dept
).filter(*filters).outerjoin(Dept, User.dept_id == Dept.id).layui_paginate()
return table_api(
data=[{
'id': user.id,
'username': user.username,
'realname': user.realname,
'enable': user.enable,
'create_at': user.create_at,
'update_at': user.update_at,
'dept_name': dept.dept_name if dept else None
} for user, dept in query.items],
count=query.total)
# 用户增加 # 用户增加
@admin_user.get('/add') @bp.get('/add')
@authorize("admin:user:add", log=True) @authorize("system:user:add", log=True)
def add(): def add():
roles = Role.query.all() roles = Role.query.all()
return render_template('admin/user/add.html', roles=roles) return render_template('system/user/add.html', roles=roles)
@admin_user.post('/save') @bp.post('/save')
@authorize("admin:user:add", log=True) @authorize("system:user:add", log=True)
def save(): def save():
req_json = request.json req_json = request.get_json(force=True)
a = req_json.get("roleIds") a = req_json.get("roleIds")
username = xss_escape(req_json.get('username')) username = str_escape(req_json.get('username'))
real_name = xss_escape(req_json.get('realName')) real_name = str_escape(req_json.get('realName'))
password = xss_escape(req_json.get('password')) password = str_escape(req_json.get('password'))
dept_id = str_escape(req_json.get('deptId'))
role_ids = a.split(',') role_ids = a.split(',')
if not username or not real_name or not password: if not username or not real_name or not password:
@@ -70,19 +83,21 @@ def save():
if bool(User.query.filter_by(username=username).count()): if bool(User.query.filter_by(username=username).count()):
return fail_api(msg="用户已经存在") return fail_api(msg="用户已经存在")
user = User(username=username, realname=real_name)
user = User(username=username, realname=real_name, enable=1, dept_id=dept_id)
user.set_password(password) user.set_password(password)
db.session.add(user) db.session.add(user)
roles = Role.query.filter(Role.id.in_(role_ids)).all() roles = Role.query.filter(Role.id.in_(role_ids)).all()
for r in roles: for r in roles:
user.role.append(r) user.role.append(r)
db.session.commit() db.session.commit()
return success_api(msg="增加成功") return success_api(msg="增加成功")
# 删除用户 # 删除用户
@admin_user.delete('/remove/<int:id>') @bp.delete('/remove/<int:id>')
@authorize("admin:user:remove", log=True) @authorize("system:user:remove", log=True)
def delete(id): def delete(id):
user = User.query.filter_by(id=id).first() user = User.query.filter_by(id=id).first()
user.role = [] user.role = []
@@ -95,27 +110,27 @@ def delete(id):
# 编辑用户 # 编辑用户
@admin_user.get('/edit/<int:id>') @bp.get('/edit/<int:id>')
@authorize("admin:user:edit", log=True) @authorize("system:user:edit", log=True)
def edit(id): def edit(id):
user = curd.get_one_by_id(User, id) user = curd.get_one_by_id(User, id)
roles = Role.query.all() roles = Role.query.all()
checked_roles = [] checked_roles = []
for r in user.role: for r in user.role:
checked_roles.append(r.id) checked_roles.append(r.id)
return render_template('admin/user/edit.html', user=user, roles=roles, checked_roles=checked_roles) return render_template('system/user/edit.html', user=user, roles=roles, checked_roles=checked_roles)
# 编辑用户 # 编辑用户
@admin_user.put('/update') @bp.put('/update')
@authorize("admin:user:edit", log=True) @authorize("system:user:edit", log=True)
def update(): def update():
req_json = request.json req_json = request.get_json(force=True)
a = xss_escape(req_json.get("roleIds")) a = str_escape(req_json.get("roleIds"))
id = xss_escape(req_json.get("userId")) id = str_escape(req_json.get("userId"))
username = xss_escape(req_json.get('username')) username = str_escape(req_json.get('username'))
real_name = xss_escape(req_json.get('realName')) real_name = str_escape(req_json.get('realName'))
dept_id = xss_escape(req_json.get('deptId')) dept_id = str_escape(req_json.get('deptId'))
role_ids = a.split(',') role_ids = a.split(',')
User.query.filter_by(id=id).update({'username': username, 'realname': real_name, 'dept_id': dept_id}) User.query.filter_by(id=id).update({'username': username, 'realname': real_name, 'dept_id': dept_id})
u = User.query.filter_by(id=id).first() u = User.query.filter_by(id=id).first()
@@ -128,27 +143,27 @@ def update():
# 个人中心 # 个人中心
@admin_user.get('/center') @bp.get('/center')
@login_required @login_required
def center(): def center():
user_info = current_user user_info = current_user
user_logs = AdminLog.query.filter_by(url='/passport/login').filter_by(uid=current_user.id).order_by( user_logs = AdminLog.query.filter_by(url='/passport/login').filter_by(uid=current_user.id).order_by(
desc(AdminLog.create_time)).limit(10) desc(AdminLog.create_time)).limit(10)
return render_template('admin/user/center.html', user_info=user_info, user_logs=user_logs) return render_template('system/user/center.html', user_info=user_info, user_logs=user_logs)
# 修改头像 # 修改头像
@admin_user.get('/profile') @bp.get('/profile')
@login_required @login_required
def profile(): def profile():
return render_template('admin/user/profile.html') return render_template('system/user/profile.html')
# 修改头像 # 修改头像
@admin_user.put('/updateAvatar') @bp.put('/updateAvatar')
@login_required @login_required
def update_avatar(): def update_avatar():
url = request.json.get("avatar").get("src") url = request.get_json(force=True).get("avatar").get("src")
r = User.query.filter_by(id=current_user.id).update({"avatar": url}) r = User.query.filter_by(id=current_user.id).update({"avatar": url})
db.session.commit() db.session.commit()
if not r: if not r:
@@ -157,10 +172,10 @@ def update_avatar():
# 修改当前用户信息 # 修改当前用户信息
@admin_user.put('/updateInfo') @bp.put('/updateInfo')
@login_required @login_required
def update_info(): def update_info():
req_json = request.json req_json = request.get_json(force=True)
r = User.query.filter_by(id=current_user.id).update( r = User.query.filter_by(id=current_user.id).update(
{"realname": req_json.get("realName"), "remark": req_json.get("details")}) {"realname": req_json.get("realName"), "remark": req_json.get("details")})
db.session.commit() db.session.commit()
@@ -170,21 +185,21 @@ def update_info():
# 修改当前用户密码 # 修改当前用户密码
@admin_user.get('/editPassword') @bp.get('/editPassword')
@login_required @login_required
def edit_password(): def edit_password():
return render_template('admin/user/edit_password.html') return render_template('system/user/edit_password.html')
# 修改当前用户密码 # 修改当前用户密码
@admin_user.put('/editPassword') @bp.put('/editPassword')
@login_required @login_required
def edit_password_put(): def edit_password_put():
res_json = request.json res_json = request.get_json(force=True)
if res_json.get("newPassword") == '': if res_json.get("newPassword") == '':
return fail_api("新密码不得为空") return fail_api("新密码不得为空")
if res_json.get("newPassword") != res_json.get("confirmPassword"): if res_json.get("newPassword") != res_json.get("confirmPassword"):
return fail_api("次密码不一样") return fail_api("次密码不一样")
user = current_user user = current_user
is_right = user.validate_password(res_json.get("oldPassword")) is_right = user.validate_password(res_json.get("oldPassword"))
if not is_right: if not is_right:
@@ -196,10 +211,10 @@ def edit_password_put():
# 启用用户 # 启用用户
@admin_user.put('/enable') @bp.put('/enable')
@authorize("admin:user:edit", log=True) @authorize("system:user:edit", log=True)
def enable(): def enable():
_id = request.json.get('userId') _id = request.get_json(force=True).get('userId')
if _id: if _id:
res = enable_status(model=User, id=_id) res = enable_status(model=User, id=_id)
if not res: if not res:
@@ -209,27 +224,13 @@ def enable():
# 禁用用户 # 禁用用户
@admin_user.put('/disable') @bp.put('/disable')
@authorize("admin:user:edit", log=True) @authorize("system:user:edit", log=True)
def dis_enable(): def dis_enable():
_id = request.json.get('userId') _id = request.get_json(force=True).get('userId')
if _id: if _id:
res = disable_status(model=User, id=_id) res = disable_status(model=User, id=_id)
if not res: if not res:
return fail_api(msg="出错啦") return fail_api(msg="出错啦")
return success_api(msg="禁用成功") return success_api(msg="禁用成功")
return fail_api(msg="数据错误") return fail_api(msg="数据错误")
# 批量删除
@admin_user.delete('/batchRemove')
@authorize("admin:user:remove", log=True)
def batch_remove():
ids = request.form.getlist('ids[]')
for id in ids:
user = User.query.filter_by(id=id).first()
user.role = []
res = User.query.filter_by(id=id).delete()
db.session.commit()
return success_api(msg="批量删除成功")
+25
View File
@@ -0,0 +1,25 @@
FROM python:3.11-bookworm
# 设置时区
ENV TIME_ZONE Asia/Shanghai
RUN echo "${TIME_ZONE}" > /etc/timezone \
&& ln -sf /usr/share/zoneinfo/${TIME_ZONE} /etc/localtime
WORKDIR /app
# 1. [优化] 先只拷贝依赖文件,利用 Docker 缓存
# 只要 requirements.txt 没变,这一步和 pip install 就会直接用缓存,极大加快构建速度
COPY requirements.txt /app/
# 2. 安装依赖 (使用清华源)
RUN python3 -m pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple/ \
&& python3 -m pip install --no-cache-dir gunicorn -i https://pypi.tuna.tsinghua.edu.cn/simple/
# 3. 拷贝其余项目代码
COPY . /app/
# 4. 确保脚本有执行权限 (直接处理源路径,防止移动失败)
RUN chmod +x /app/deploy/dev/start.sh
# 5. 指定启动入口
CMD ["/bin/bash", "/app/deploy/dev/start.sh"]
+29
View File
@@ -0,0 +1,29 @@
version: '3.8'
services:
flask:
build:
context: ../.. # 指向项目根目录 (pear-admin-flask)
dockerfile: deploy/dev/Dockerfile
container_name: flask_dev
restart: always
ports:
- "5000:5000"
# [优化] 挂载卷:将本地代码映射到容器内部
# 效果:你在 PyCharm 修改代码,容器里立马生效,不需要重启容器
volumes:
- ../..:/app
# [优化] 环境变量
environment:
- FLASK_DEBUG=0 # 是否开启 Flask 调试模式
- FLASK_APP=app.py
# 网络设置
networks:
- dev_network
networks:
dev_network:
driver: bridge
+25
View File
@@ -0,0 +1,25 @@
#!/bin/bash
# 确保进入工作目录
cd /app
echo "=== [Step 1] 检查并初始化数据库 ==="
# 使用 || true 确保即使命令报错(例如migrations文件夹已存在)也不会中断脚本
flask db init || true
flask db migrate || true
flask db upgrade || true
# (可选) 每次重启尝试初始化数据,如果已存在则会被代码逻辑跳过
flask admin init || true
echo "=== [Step 2] 启动服务 (开发模式) ==="
# [优化] 增加 --reload 参数
# 配合 docker-compose 的 volumes,当检测到代码变化时,Gunicorn 会自动重启 worker
# access-logfile - 表示将日志输出到控制台
exec gunicorn --bind 0.0.0.0:5000 \
--workers 2 \
--reload \
--access-logfile - \
--error-logfile - \
app:app
-14
View File
@@ -1,14 +0,0 @@
version: '3'
services:
flask:
build: .
ports:
- "8000:8000"
restart: always
links:
- mysql:dbserver
mysql:
image: "mysql:5.7"
environment:
- MYSQL_ROOT_PASSWORD=root
restart: always
+164
View File
@@ -0,0 +1,164 @@
# Pear Admin Flask - 部署指南
本文档面向把本项目部署到生产环境的运维 / 二次开发者。
请结合根目录的 `README.md``docs/SECURITY.md` 一起阅读。
## 0. 环境要求
| 项目 | 版本 |
| --- | --- |
| Python | 3.8(推荐 3.11 |
| SQLite | 3.x(仅开发用) |
| MySQL | 5.7+ / 8.0(生产推荐) |
| 操作系统 | Windows / Linux 均可 |
## 1. 克隆代码
```bash
git clone https://gitea.bwhome.top/bwadmin/pear-admin-flask.git
cd pear-admin-flask
```
> 如仓库使用自签证书导致 `SEC_E_WRONG_PRINCIPAL` 报错,
> 可临时关闭 SSL 校验:`git -c http.sslVerify=false clone <url>`。
> 长期建议把根证书导入到系统信任库。
## 2. 创建虚拟环境 + 安装依赖
```bash
# Linux / macOS
python -m venv venv
source venv/bin/activate
# Windows (cmd)
python -m venv venv
venv\Scripts\activate.bat
pip install -r requirements.txt
```
## 3. 配置 .env
复制模板:
```bash
cp .env.example .env # Linux
copy .env.example .env # Windows
```
按需修改(**生产模式 SECRET_KEY 必填**,参见下文):
```dotenv
PEAR_ENV=production
SECRET_KEY=<用 python -c "import secrets; print(secrets.token_urlsafe(48))" 生成>
# MySQL 示例
SQLALCHEMY_DATABASE_URI=mysql+pymysql://user:pass@127.0.0.1:3306/pear?charset=utf8mb4
# 邮件(可选)
MAIL_SERVER=smtp.qq.com
MAIL_USE_SSL=true
MAIL_PORT=465
MAIL_USERNAME=your_account@qq.com
MAIL_PASSWORD=your_authorization_code_here
```
⚠️ `.env` 文件已在 `.gitignore` 中,**不会**被提交到 git。
## 4. 数据库初始化
```bash
# 1) 生成迁移脚本
flask db migrate
# 2) 升级到当前 head
flask db upgrade
# 3) 导入 admin 基础数据(用户 / 角色 / 菜单)
flask admin init
```
MySQL 用户需提前建库:
```sql
CREATE DATABASE pear DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'pear'@'%' IDENTIFIED BY 'your_strong_password';
GRANT ALL PRIVILEGES ON pear.* TO 'pear'@'%';
FLUSH PRIVILEGES;
```
## 5. 启动
### 5.1 开发
```bash
# Linux
make run
# Windows
start.bat
```
### 5.2 生产
推荐用 `gunicorn``pip install gunicorn`),或反向代理到 `waitress`
```bash
# Linux
PEAR_ENV=production gunicorn -w 4 -b 0.0.0.0:5000 app:app
# Windows
pip install waitress
PEAR_ENV=production waitress-serve --port=5000 app:app
```
### 5.3 健康检查
服务起来后:
```bash
curl http://127.0.0.1:5000/healthz
# 200 OK → {"status":"ok","db_ok":true,...}
# 503 → {"status":"degraded","db_ok":false,...}
```
## 6. 反向代理(Nginx 示例)
```nginx
server {
listen 80;
server_name pear.example.com;
location / {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 50m;
}
location = /healthz {
proxy_pass http://127.0.0.1:5000/healthz;
access_log off;
}
}
```
## 7. 升级流程
```bash
git pull
source venv/bin/activate
pip install -r requirements.txt
flask db migrate
flask db upgrade
# 重启进程
systemctl restart pear-admin-flask
```
## 8. 常见问题
| 现象 | 处理 |
| --- | --- |
| `[ProConfig] 生产环境必须通过环境变量 SECRET_KEY 传入强随机密钥` | 在 `.env` 或系统环境变量中设置 `SECRET_KEY`。 |
| `SEC_E_WRONG_PRINCIPAL` 克隆失败 | `git -c http.sslVerify=false clone ...` 或导入根证书。 |
| 启动后 `/` 跳转到登录页 | 正常。后台默认账号 `admin` / 密码 `123456`**请立即修改**。 |
| 数据库表缺失 | `flask db upgrade``flask admin init`。 |
+20
View File
@@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = source
BUILDDIR = _build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+89
View File
@@ -0,0 +1,89 @@
# Pear Admin Flask - 安全指南
## 1. 密钥(SECRET_KEY
`SECRET_KEY` 用于 Flask `session` 签名、`csrf_token`、Flask-Session
等。**一旦泄露,攻击者可伪造任意管理员会话。**
要求:
- 长度 ≥ 32 字节(推荐 48+);
- 高熵随机(`secrets.token_urlsafe(48)` / `openssl rand -base64 48`);
- **禁止**使用默认值 `pear-system-flask`
- **禁止**把 `.env` / 含密钥的部署脚本提交到 git。
开发模式自动生成的密钥仅供本地调试,**重启即失效**。
## 2. 凭据(Mail / DB
所有凭据通过 `.env` 注入,代码里只放占位符。强烈建议:
- 生产数据库单独建用户,**只授必要的库权限**;
- Mail 使用**授权码**而非登录密码(QQ / 163 / Gmail 均提供);
- 定期轮换凭据,并在变更后重启服务。
## 3. 默认管理员账号
`flask admin init` 会写入默认账号 `admin / 123456`,**首次登录后必须立即修改密码**。
可执行:
```bash
# 1) 登录后台 → 用户管理 → 修改 admin 密码;
# 2) 或直接 SQL
sqlite3 pear.db "UPDATE rbac_user SET password='<pbkdf2:sha256:...>' WHERE username='admin';"
```
## 4. Cookie / Session
`SESSION_COOKIE_HTTPONLY = True`(默认)与 `SESSION_COOKIE_SAMESITE = 'Lax'`
已经在配置中体现。生产部署在 HTTPS 下请额外设置:
```python
SESSION_COOKIE_SECURE = True
```
> 若启用 Flask-Sessionfilesystem / redis),请把 `flask_session/`
> 目录排除在 Web 静态目录之外,并定期清理。
## 5. CSRF
本项目基于 `flask_wtf`,所有写操作均有 CSRF 校验。
前端表单务必带上 `{{ csrf_token() }}` 或请求头 `X-CSRFToken`
## 6. 上传文件
`UPLOADED_PHOTOS_DEST = static/upload`,后缀白名单已限制为图片。
但**用户可上传 SVG / 伪装为图片的 JS** 等——建议:
- 上传目录关闭脚本执行(Nginx: `location ^~ /static/upload/ { ... }`);
- 后端校验真实文件类型(`python-magic`),而非仅看后缀。
## 7. 限流
`Flask-Limiter` 已挂载(`applications/extensions/init_limit.py`)。
生产模式请按业务需求调整默认速率,避免误伤。
## 8. 日志
`LOG_LEVEL` 控制根 logger 输出等级。生产推荐 `WARN` / `ERROR`
**不要把 `SECRET_KEY`、用户密码、token 写入日志。**
## 9. 依赖漏洞
建议在 CI 中加入 `pip-audit`
```bash
pip install pip-audit
pip-audit -r requirements.txt
```
## 10. 上线前 Checklist
- [ ] `PEAR_ENV=production`
- [ ] `SECRET_KEY` 已替换为强随机值
- [ ] 数据库用户仅授必要权限
- [ ] 默认 admin 密码已修改
- [ ] HTTPS 已上线(`SESSION_COOKIE_SECURE` 同步开启)
- [ ] `flask_session/` 目录已加入备份策略
- [ ] `static/upload/` 已禁用脚本执行
- [ ] `pip-audit` 跑过
- [ ] `/healthz` 已接入负载均衡 / 容器探活
+2
View File
@@ -0,0 +1,2 @@
del _build /s /q
sphinx-build .\source .\_build
+3
View File
@@ -0,0 +1,3 @@
sphinx>=5
docutils<=0.21
sphinx-rtd-theme

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Some files were not shown because too many files have changed in this diff Show More