396 Commits
Author SHA1 Message Date
bwstudio 1a33648b9a feat(migrations): 新增 alembic 迁移(public_nav / nav_category / 友链 / 访问统计) 2026-09-06 21:34:39 +08:00
bwstudio 0bfe6f9d8c fix(script): admin init 重复插入种子导航,改为按 url 幂等去重
原 nav 导入条件 `if not Nav.query.get(id) if id else True` 对无 id 的种子项
永远为 True,导致每次容器启动 flask admin init 都重复插入 54 条种子导航,
与真实数据叠加产生重复。改为按 id 或 url 去重,已存在则跳过。
2026-09-06 21:34:37 +08:00
bwstudio 05f1ac6a00 fix(deps): 补充 markdown 依赖(about.py 用到,否则容器启动崩溃) 2026-09-06 21:34:35 +08:00
bwstudio be99b8a925 feat(data): 数据库搬到 data/ 目录 + SQLite WAL + 单卷绑定挂载
- config.py 新增 PEAR_DATA_DIR(默认 <项目根>/data),SQLite 库、Flask-Session
  统一收进 DATA_DIR;不设时本地开发行为不变
- applications/__init__.py 顶部先 load_dotenv,保证 .env 里的 PEAR_DATA_DIR
  在 config 导入时就生效
- extensions/init_sqlalchemy.py 接 SQLite connect 事件:WAL + synchronous=NORMAL
  + busy_timeout=30s(多 worker 写不再撞锁)
- config.py 注入 SQLALCHEMY_ENGINE_OPTIONS(pool_pre_ping + SQLite connect_args
  超时;MySQL 路径下自动跳过 connect_args 防止参数错误)
- 新增 alembic 迁移 a47a5d2a3f1b 建 site_nav_click(含 anon_id 字段),
  解决之前该表只由 db.create_all 建、不在迁移链里的隐患
- .gitignore / .dockerignore 加 data/ 排除规则(且把 migrations/ 从
  ignore 里重新放行 —— 否则新加的迁移进不了库)
- NAS Docker 部署全面重写:
  - compose 唯一绑定挂载 ./data:/app/data,宿主 File Station 看得见
  - start.sh 自检 data 目录 + 子目录;workers 默认改为 1 threads 8
    (SQLite 写串行,单进程最稳)
  - 新增 backup.sh:sqlite3 .backup 在线热备(确保 WAL 一致性)+ 压缩
    + 保留 14 天
  - Dockerfile 安装 sqlite3 客户端备用,建 /app/data 子目录并 chown
- docs/deployment-nas.md 重写数据持久化章节(路径表 + 热备命令 +
  恢复步骤 + 整库迁移 tar/untar 流程)
2026-09-06 19:43:28 +08:00
bwstudio 34e8010830 feat(site): 方案 C - 「我常访问」个人榜(匿名 ID + localStorage 合并)
- NavClick 加 anon_id 字段(已为历史表自动迁移)
- 后端静默下发 wk_anon cookie(uuid4,1 年,HttpOnly)
- 点击埋点写入 anon_id;新增 GET /site/api/my-top 与 POST /site/api/my-clear
- 首页服务端渲染「我常访问」区(时间衰减加权分),与「热门网址」共显
- 前端 localStorage 累计 + 与服务端榜取 max 合并;冷启动阈值 2 个网址
- 一键清除:清 localStorage + 服务端删该匿名 ID 的全部记录
- 移动端抽屉、顶部导航同步加「我常访问」入口
- 底部隐私说明更新
2026-09-06 19:16:45 +08:00
bwstudio a47ad991d0 feat(public): 首页增加「热门网址」分类(访问量 TOP12)
跨分类聚合:按 NavClick 总点击数倒序,取前 12 个有效 nav。

实现要点:
- applications/view/public/nav.py: 新增 _top_navs(limit=12),
  复用 get_nav_visit_counts() 的 60s 进程内缓存;同点击数按 id asc 稳定排序;
  0 点击返回空列表(前端整段不渲染)
- _render_index() 注入 top_navs 到模板
- templates/public/index.html: 在 groups 循环前渲染 .section-hot,标题「🔥 热门网址」,
  meta 显示「近期访问量最大的 N 个」;卡片复用 .card,新增 .card-top 类
- templates/public/base.html:
  - .card-top:左侧 3px 品牌色边框 + 浅色背景渐变 + hover 增强阴影
  - .section-hot h2 图标用 brand-orange #ff6b35
  - .cat-nav-inner a.cat-nav-hot 顶部导航热门入口用品牌色
  - 抽屉:首页 top_navs 非空时插入「🔥 热门」区段,链接到 #cat-top

边界:
- 没有任何点击时整段隐藏(包括抽屉和顶部 nav),不出现「0 个」空标题
- 同一 nav 不会重复(按 nav.id 维度聚合)
- Nav.enable=0 的不会进 TOP12

验证:
- 15 个 nav 各灌入 20..6 次点击,前 12 个按降序显示访问数 20..9
- 清空所有 NavClick 后首页 section-hot / #cat-top / .cat-drawer-hot 三处全不渲染
- 测试数据已清理
2026-09-06 18:52:05 +08:00
bwstudio 2fe59e15ef feat(scripts): 从 CSV 增量导入 Nav + 同步 NavCategory
scripts/import_nav_urls.py:从 C://Users//PF//Desktop//nav_urls.csv 导入

业务规则(与用户确认):
- 「热门网址」分类不导入(仅是使用频度的复述,与内网应用重叠 7 条)
- 新建 4 个 NavCategory:内网应用(sort=9) / 外网应用(sort=8) / 其他应用(sort=7) / 常用工具(sort=6)
- 删除 2 个 E2E 测试残留 NavCategory(icon=layui-icon-test 且 nav_count=0)
- Nav 增量导入:URL 已存在(按 category+url_normalized 去重)则跳过
- 同 URL 在不同分类可双写(按用户语义分类的本质)
- 同分类内 URL 末尾 / 差异视作重复

支持 --dry-run 干跑模式。

本次实跑结果:
- NavCategory created: 4
- Nav total inserted: 67
  - 内网应用: 35
  - 外网应用: 18
  - 其他应用: 12
  - 常用工具: 2
- Skipped (热门网址): 10
- Skipped (duplicate url in same cat): 1(液位仪末尾 /)
- E2E trash NavCategory deleted: 2

Nav 总数从 54 → 121;NavCategory 从 7 → 9。
2026-09-06 18:42:05 +08:00
bwstudio 98edfdaf2d feat(deploy): 个人 NAS 单机 Docker 部署方案
适用:内网 / 家庭 / 小团队(≤50 并发),无 K8s 需求。
特性:单容器 + SQLite + 端口暴露,最小维护成本。

新增:
- deploy/nas/Dockerfile: python:3.11-slim 基础镜像 + 非 root + HEALTHCHECK
- deploy/nas/start.sh: flask db upgrade 幂等迁移 + gunicorn (2 workers × 4 threads)
- deploy/nas/docker-compose.yaml: 单服务 + 4 个命名卷 (db/logs/session/upload) + 资源限制
- docs/deployment-nas.md: 完整部署/备份/升级/排障指南

完善:
- .env.example: 改为生产环境导向 (PEAR_ENV=pro,SECRET_KEY 必填)
- .dockerignore: 排除 deploy/dev (开发用容器配置)、tests/截图、一次性脚本
- applications/view/public/__init__.py: 注释说明 /healthz 已在 applications/view/health.py 注册

复用现有:
- /healthz 接口 (applications/view/health.py) 含 DB 探活,DB 不可达返回 503
  → 容器 HEALTHCHECK 直接用 /healthz 即可

⚠️ 本机无 docker,未执行 docker build 验证;已静态校验 YAML/bash 语法、
   文件路径、.env 字段消费、ProConfig 严格性、/healthz 端点可用性。
   首次 NAS 上部署时跑一遍文档步骤即可。
2026-09-06 18:35:31 +08:00
bwstudio 0ffc103e46 feat(plugins): 4 个业务插件代码 + 前台公开页面改造
plugins/navManager:导航 CRUD + 导航分类 CRUD(独立 blueprint)
plugins/friendManager:友情链接 CRUD
plugins/aboutManager:关于本站 CRUD
plugins/siteStats:访问统计主页 + JSON 接口 + /site/* GET 埋点 + nav 卡片点击埋点

applications/view/public/nav.py:导航聚合页 / 分类详情 / JSON 接口,支持 visit_count 注入
applications/view/public/about.py:关于本站公开页
applications/view/public/friend.py:友情链接公开页

templates/public/base.html:响应式首页(搜索框 / 分类抽屉 / 5 主题切换 / footer 隐私小字)
templates/public/index.html / category.html:卡片右下角访问次数
templates/public/about.html / friend.html:关于 + 友链前台页

docs/plugins-development.md:插件开发完整指南(生命周期 + 4 个示例 + FAQ + framework 迁移)

scripts/:探针与端到端验证脚本(probe_*/verify_*/test_*)
2026-09-06 18:18:06 +08:00
bwstudio 1ea69c2c34 refactor(plugins): 将导航/友链/关于/统计 4 模块迁出 framework 至 plugins
- 在 applications/config.py 中通过 PLUGIN_ENABLE_FOLDERS 启用 4 个业务插件
- 删除 framework 内 applications/view/system/nav.py 与 templates/system/nav/* 模板
- applications/extensions/init_plugs 移除重复的 broadcast_execute('event_init'/'event_finish')
  (由 init_bps / init_script 触发,避免 Blueprint 注册冲突)
- applications/view/system/index.py:未登录访问 / 直接渲染前台公开聚合页
- templates/system/index.html:移动端首屏即折叠侧边栏
- 新增 5 个 model(Nav 字段 status→enable 兼容、NavCategory、About、Friend、VisitLog/PageStat、NavClick)
- 新增 2 个 schema(NavCategorySchema、FriendOutSchema/ManageSchema)
- admin_about/admin_friend/admin_nav_category 在 init.json 中以「网站管理」分组挂载
- .gitignore 补充一次性备份、调试截图、探针截图不入库
2026-09-06 18:17:52 +08:00
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
zhengxinonlyandGitee dc1e9afe0c !39 修复账号多角色时菜单重复,子菜单ID小于父菜单ID时无法显示的问题
Merge pull request !39 from waizking/N/A
2022-06-16 08:44:53 +00:00
就眠儀式andGitee 6220f979bf !42 update Dockerfile.
Merge pull request !42 from kyqv/N/A
2022-06-16 04:09:10 +00:00
kyqvandGitee 6ec05fd3f9 update Dockerfile.
阿里镜像源问题更新 导致docker 无法安装
2022-06-16 03:45:10 +00:00
不胜舟andGitee 8c3e43b85b !41 修复mysql连接中密码包含@特殊字符时的错误
Merge pull request !41 from stevenhu/N/A
2022-06-02 07:11:05 +00:00
stevenhuandGitee 248145b821 修复mysql连接中密码包含@特殊字符时的错误 2022-06-02 05:08:36 +00:00
就眠儀式andGitee 261a983678 update README.md. 2022-05-13 06:52:00 +00:00
sunshine 1a7e457054 解决bug 2022-05-08 21:29:44 +08:00
不胜舟andGitee 7ae59415e8 update applications/view/passport/__init__.py. 2022-04-12 11:16:54 +00:00
sunshine c50d5e7921 重写sqlalchemy的query,增加序列化方法 2022-04-12 16:55:27 +08:00
sunshine af873bf45e 升级依赖 2022-04-12 16:50:48 +08:00
waizkingandGitee 4384a3000c 修复账号多角色时菜单重复,子菜单ID小于父菜单ID时无法显示的问题 2022-03-21 03:15:25 +00:00
不胜舟andGitee 72a951a2b6 !34 修改数据库的字符集
Merge pull request !34 from zebra/N/A
2022-02-08 10:20:02 +00:00
zebraandGitee 38a5136e69 修改数据库的字符集 2022-02-08 10:18:47 +00:00
不胜舟andGitee 96dbeb0cbd !33 【轻量级 PR】:update applications/configs/config.py.
Merge pull request !33 from VincentFu/N/A
2022-01-25 07:14:04 +00:00
VincentFuandGitee 936c37c65c update applications/configs/config.py.
数据库编码设置为utf8mb4
2022-01-25 07:12:58 +00:00
resonate e51c86c83f reset 2022-01-14 12:46:40 +08:00
resonate 71a25f4774 session存入redis 2022-01-14 12:39:42 +08:00
杭州-伍个一andGitee 56563657e8 update applications/common/helper.py.
修正between查询的类型
2021-12-15 03:18:23 +00:00
mkg 6bfb10e4a4 增加自动序列化(根据orm模型)方法,增加软(逻辑)删除 2021-12-07 15:39:56 +08:00
mkg 70cb8eb35b 更新依赖 2021-11-22 14:13:08 +08:00
mkg cdea48c7d4 加入webargs验证扩展,分区用于验证的inschema和用户序列化的outschema 2021-11-17 18:07:36 +08:00
mkg fba22bbcbf 构造多级菜单树(来源mini分支) 2021-11-17 16:02:51 +08:00
不胜舟andGitee 14f96abdb3 !28 回退 'Pull Request !25 : 【轻量级 PR】:update applications/common/admin.py.'
Merge pull request !28 from 不胜舟/revert-merge-25-master
2021-11-17 02:42:15 +00:00
不胜舟andGitee 28ff95bf0d 回退 'Pull Request !25 : 【轻量级 PR】:update applications/common/admin.py.' 2021-11-17 02:41:27 +00:00
mkg 6cee5a6104 合并配置文件,解决logo打印多次问题 2021-11-16 23:18:20 +08:00
不胜舟andGitee 3add95e910 update requirement/requirement-dev.txt. 2021-11-16 09:41:18 +00:00
mkg 1db1064c11 图片上传数据库存储改为相对路径 2021-11-14 22:40:44 +08:00
mkg f75928119e 修改目录 2021-11-14 20:15:26 +08:00
mkg 9051e8138c 修改环境配置文件 2021-11-14 18:36:08 +08:00
mkg 8396f49105 简化requirement 2021-11-10 23:02:28 +08:00
不胜舟andGitee c664c70bbc !26 update applications/view/department/__init__.py.
Merge pull request !26 from sys/N/A
2021-11-09 13:30:33 +00:00
不胜舟andGitee 7a503cd94a !27 update applications/templates/admin/dept/edit.html.
Merge pull request !27 from sys/N/A
2021-11-09 13:30:20 +00:00
sysandGitee 7a9aeec8db update applications/templates/admin/dept/edit.html. 2021-11-09 06:33:20 +00:00
sysandGitee 2c57bc36ea update applications/view/department/__init__.py. 2021-11-09 06:17:05 +00:00
mkg c0fc70425b 删除.env 2021-11-08 21:39:39 +08:00
mkg f95944a781 Merge branch 'master' of https://gitee.com/pear-admin/pear-admin-flask into master 2021-11-08 18:52:46 +08:00
mkg 589e0c8ce7 提交docker-compose.yml 2021-11-08 18:52:36 +08:00
不胜舟andGitee 6e0ff64893 !25 update applications/common/admin.py.
Merge pull request !25 from sys/N/A
2021-11-06 17:26:18 +00:00
sysandGitee 044e432eb7 update applications/common/admin.py. 2021-11-06 17:22:05 +00:00
不胜舟andGitee 5887079ade !24 update applications/view/admin/user.py.
Merge pull request !24 from sys/N/A
2021-11-06 12:34:50 +00:00
sysandGitee 724e0817a5 update applications/view/admin/user.py. 2021-11-06 12:31:52 +00:00
不胜舟andGitee 9caa7a22e5 update applications/configs/config.py. 2021-11-06 09:17:49 +00:00
mkgandGitee c2042d5f80 update start.sh. 2021-10-28 07:56:41 +00:00
mkg 51f17c9539 提交Dockerfile 2021-10-28 13:12:18 +08:00
mkg 27ed99ee27 增加gunicorn启动脚本 2021-10-28 10:00:21 +08:00
mkg 7eb41e9754 修补日志的xss漏洞 2021-10-13 19:38:28 +08:00
mkg c7f266a482 Merge branch 'master' of https://gitee.com/pear-admin/pear-admin-flask into master 2021-10-08 22:58:28 +08:00
mkg f2d444b877 修补iconPicker -> icon 导致的bug 2021-10-08 22:58:14 +08:00
mkgandGitee 9e59035300 !23 update applications/view/admin/dict.py.
Merge pull request !23 from VincentFu/N/A
2021-10-06 08:23:54 +00:00
VincentFuandGitee b0ed2100a2 update applications/view/admin/dict.py.
修复字典类型更新
2021-10-05 05:53:05 +00:00
mkgandGitee 6f0c86fba6 !18 update applications/view/department/__init__.py.
Merge pull request !18 from VincentFu/N/A
2021-10-04 00:42:32 +00:00
VincentFuandGitee c71562a12f update applications/view/department/__init__.py.
这个地方也需要相应的修改,和power.py一样。
2021-10-03 17:31:39 +00:00
mkg a7fa415545 修改权限管理为post 2021-10-02 23:06:20 +08:00
mkg 24d2523385 更新依赖pearadminlayui 2021-10-02 16:11:07 +08:00
Rabbitandmkg d30a3d79de !17 修改角色状态值
* 角色状态值修复
2021-09-23 02:35:20 +00:00
mkgandGitee 860d5778cc !16 简化分页取count
Merge pull request !16 from Rabbit/master
2021-09-23 02:09:34 +00:00
xz 23dbe9ee9c 简化分页取count 2021-09-23 09:57:13 +08:00
就眠儀式andGitee 9114964ef2 update README.md. 2021-09-21 18:36:34 +00:00
mkgandGitee 56615528b9 !15 新增字典数据405
Merge pull request !15 from yanyuan/master
2021-09-09 09:54:12 +00:00
yanyuanandGitee 1b5c54daf9 update applications/view/admin/dict.py.
新增字典数据有误
2021-09-09 09:50:05 +00:00
Rabbitandmkg c0a3f5243b !14 【轻量级 PR】:update applications/view/admin/user.py.
Merge pull request !14 from Rabbit/N/A
2021-09-09 02:29:22 +00:00
Rabbitandmkg d22eb22f74 !13 【轻量级 PR】:update applications/view/admin/role.py.
Merge pull request !13 from Rabbit/N/A
2021-09-09 02:29:04 +00:00
就眠儀式andGitee 7eeb4c2489 !12 update applications/templates/admin/user/main.html.
Merge pull request !12 from Rabbit/N/A
2021-09-08 14:29:40 +00:00
RabbitandGitee d691c444ae update applications/templates/admin/user/main.html.
312行:table.reload('role-table') -> table.reload('user-table')
2021-09-08 14:27:39 +00:00
mkgandGitee 83c23ea276 !10 update applications/view/admin/power.py.
Merge pull request !10 from Rabbit/N/A
2021-09-06 11:23:43 +00:00
mkgandGitee cff67d152f !11 update applications/view/admin/user.py.
Merge pull request !11 from Rabbit/N/A
2021-09-06 11:23:25 +00:00
mkgandGitee ad67c289d3 !9 update applications/view/admin/role.py.
Merge pull request !9 from Rabbit/N/A
2021-09-06 11:23:05 +00:00
RabbitandGitee 4a4d8f763d update applications/view/admin/user.py.
多对多删除简化
2021-09-06 10:24:05 +00:00
RabbitandGitee dfc734c13a update applications/view/admin/power.py.
多对多关系删除简化
2021-09-06 10:21:51 +00:00
RabbitandGitee 654f441d05 update applications/view/admin/role.py. 2021-09-06 10:19:02 +00:00
mkg 48eebc1c95 定时任务(部分) 2021-08-26 15:31:36 +08:00
mkg 3c1764e58d 图形验证码 2021-08-26 12:12:39 +08:00
mkg 233241c962 优化验证码字符 2021-08-26 12:06:27 +08:00
mkg 30f9774ab1 Merge branch 'master' of https://gitee.com/pear-admin/pear-admin-flask into master 2021-08-25 21:17:33 +08:00
mkg a255c746d4 解决验证码可绕过安全问题 2021-08-25 21:17:16 +08:00
mkgandGitee eac2a1c941 update requirement/requirement-dev.txt. 2021-07-19 13:51:58 +00:00
mkg 110a5faaff 回退依赖版本解决不能上传问题 2021-07-19 21:40:03 +08:00
mkg f3cfdcf580 _id和id混乱导致的role编辑bug 2021-07-19 15:55:32 +08:00
mkg 34a1066cd0 user中_id改为id 2021-07-18 20:16:49 +08:00
mkg 67d05c39f3 修复过滤后count数量 2021-07-18 14:45:07 +08:00
mkg fa49130c4f 更新readme 2021-07-16 17:48:08 +08:00
mkg 5b76973d9c 删除因去除curd层复制错误导致的多余代码 2021-07-16 17:45:09 +08:00
mkg 0d57a2a8f2 更新md 2021-07-16 13:10:01 +08:00
mkg 78b7dae880 增加命令行新建空白视图功能 2021-07-16 13:04:49 +08:00
mkg 04a89144ba 扩充过滤器 2021-07-12 14:38:11 +08:00
mkg 3b3eecc1d1 删除curd层 2021-07-12 13:04:16 +08:00
mkg ca92d949dd 增加layui表格分页器layui_paginate() 2021-07-12 12:13:29 +08:00
mkg 663c7925e3 Merge branch 'master' of https://gitee.com/pear-admin/pear-admin-flask into master 2021-07-12 11:28:34 +08:00
mkg 76b2ad6f58 增加多条件查询构造器,删除部分curd层 2021-07-12 11:28:21 +08:00
mkgandGitee fd8dcfee40 update README.md. 2021-07-07 23:55:21 +00:00
mkg 0a60c1960d 更新layui,修复数据库初始化路径问题 2021-07-07 13:54:37 +08:00
mkg 28021df2ab 更新文档 2021-07-04 17:17:14 +08:00
mkg 6d42b6d56c 更新文档 2021-07-04 17:15:51 +08:00
mkgandGitee 3b467f42fe update requirement/requirement-dev.txt. 2021-07-04 09:01:37 +00:00
mkg e37749c669 Merge branch 'v2' of https://gitee.com/pear-admin/pear-admin-flask into v2 2021-06-24 13:17:45 +08:00
mkg 2cbbf25060 增加定时任务前端(部分) 2021-06-24 13:17:34 +08:00
mkgandGitee 986ae4e4bc update requirement/requirement-dev.txt. 2021-06-17 22:00:40 +08:00
mkg 103aaf4911 增加定时任务 2021-06-17 21:59:28 +08:00
mkgandGitee 7038ce64a6 update requirement/requirement-dev.txt. 2021-06-17 16:54:53 +08:00
mkgandGitee 9b8525ca1e update requirement/requirement-dev.txt. 2021-06-17 13:06:55 +08:00
mkgandGitee 254ce53387 update requirement/requirement-dev.txt. 2021-06-16 15:53:10 +08:00
mkgandGitee 0ff0c456b0 update .env. 2021-06-16 15:44:50 +08:00
mkg 57a54090bb Merge branch 'v2' of https://gitee.com/pear-admin/pear-admin-flask into v2 2021-06-16 15:40:48 +08:00
mkg e3d3424ee1 增加邮箱功能 2021-06-16 15:34:39 +08:00
mkgandGitee 99b2f611ec add .env. 2021-06-13 16:39:01 +08:00
mkgandGitee a675d78b82 !6 解决冲突
Merge pull request !6 from mkg/pr_5
2021-06-13 16:16:43 +08:00
mkg 62bd10cc60 Merge branch 'v2' into pr_5
# Conflicts:
#	applications/view/__init__.py
2021-06-13 16:14:23 +08:00
mkg 14a32e6d3c 增加数据库迁移命令和数据库初始化命令,分离schemas 2021-06-12 23:05:30 +08:00
mkgandGitee 5e08f3e49b !4 调整目录结构
Merge pull request !4 from zhengxinonly/v2
2021-06-12 21:26:24 +08:00
1374 changed files with 138763 additions and 21818 deletions
+67
View File
@@ -0,0 +1,67 @@
# =====================================================
# Pear Admin Flask - Docker 构建期排除
# =====================================================
# 防止 .git / 缓存 / 测试截图 / 一次性脚本进镜像
# 注意:运行时数据(pear.db)通过 docker-compose volume 挂载,
# 这里排除 *.db 只是防止源代码里的 db 文件被误 COPY
# ---- VCS ----
.git/
.gitignore
.github/
# ---- Python 缓存 ----
__pycache__/
*.py[cod]
*$py.class
*.so
# ---- 虚拟环境 ----
venv/
.venv/
env/
# ---- 数据库 / 运行时数据 ----
# pear.db 不在源码里(运行时由容器创建 + 卷挂载),这里排除只是兜底
*.db
*.db.bak
pear.db.before_dedup.*
# 数据目录(含 pear.db / backup / flask_session),整目录不进镜像,由 volume 接管
data/
data/*/
# ---- 日志 ----
*.log
logs/
# ---- Flask ----
instance/
flask_session/
# ---- IDE / 编辑器 ----
.idea/
.vscode/
*.swp
# ---- 构建 / 测试产物 ----
build/
dist/
*.egg-info/
.pytest_cache/
htmlcov/
# ---- 测试截图 / 探针脚本输出 ----
scripts/_verify_*.png
scripts/screenshots/
output/
# ---- 开发用 docker 配置(不进 NAS 镜像)----
deploy/dev/
# ---- 一次性工具 ----
nav_extracted.js
dedup_nav.py
# ---- 环境变量文件(不进镜像)----
.env
.env.*.local
+52
View File
@@ -0,0 +1,52 @@
# =====================================================
# Pear Admin Flask - 生产环境变量模板
# =====================================================
# 使用方式:
# 1. 复制本文件为 .env cp .env.example .env
# 2. 修改 SECRET_KEY 为强随机值(必填)
# 3. 邮件 / 数据库如不需要可保持注释状态
# 4. docker compose 会自动读取同级目录的 .env
# =====================================================
# ---- 运行模式 ----
# 必填:dev / propro 会强制校验 SECRET_KEY
PEAR_ENV=pro
# ---- Flask 应用入口 ----
FLASK_APP=app.py
# ---- 安全密钥(生产必填,强随机)----
# 生成命令:python -c "import secrets; print(secrets.token_urlsafe(48))"
SECRET_KEY=PLEASE_REPLACE_WITH_RANDOM_STRING
# ---- 数据库 ----
# 默认 SQLite;库文件、session、备份都收进 PEAR_DATA_DIR 指向的目录。
# docker-compose.yaml 已经把宿主 ./data 绑到容器 /app/dataPEAR_DATA_DIR 默认
# 就是 /app/data,不需要额外改。这里只是给你本地开发调整用的口子。
# PEAR_DATA_DIR=D:/bwstudio/pear-admin-flask/data
# 默认 SQLiteWAL 模式由 extensions 自动开启(多 worker 写也不容易撞锁)。
# 想换成 MySQL 就解注释下面这行;docker-compose.yaml 里也写好了 db 服务可参考:
# SQLALCHEMY_DATABASE_URI=mysql+pymysql://pear:pear@db:3306/pear?charset=utf8mb4
# SQLALCHEMY_DATABASE_URI=sqlite:///../pear.db
# ---- 时区 ----
TZ=Asia/Shanghai
# ---- 邮件(可选,未启用找回密码等功能可不填)----
# MAIL_SERVER=smtp.qq.com
# MAIL_PORT=465
# MAIL_USE_SSL=true
# MAIL_USERNAME=yourname@qq.com
# MAIL_PASSWORD=authorization_code_here
# MAIL_DEFAULT_SENDER=yourname@qq.com
# ---- 监听端口(容器内部)----
# 仅在 docker-compose.yaml 的 ports 段同步修改时使用;默认 5000
APP_PORT=5000
# ---- Gunicorn worker / thread 数 ----
# SQLite + WAL 推荐 workers=1 threads=8:单进程写最稳,线程吃满并发。
# 想更高吞吐可改 workers=2,但务必确认没有长事务(埋点已经够短)。
GUNICORN_WORKERS=1
GUNICORN_THREADS=8
-3
View File
@@ -1,3 +0,0 @@
FLASK_ENV=development
FLASK_RUN_HOST=0.0.0.0
FLASK_APP='applications:create_app'
+49 -5
View File
@@ -114,11 +114,55 @@ dmypy.json
# Pyre type checker # Pyre type checker
.pyre/ .pyre/
#ide # idea
.idea/ .idea/
# 数据库文件 # 迁移文件 — 必须入库,让别人的环境也能跑 flask db upgrade
!migrations/
# sqlite
*.db *.db
#上传到本地的图片文件
*upload/ # 数据目录(SQLite 库 + 备份 + session;不入库,部署时靠挂载恢复)
migrations/ data/
data/*/
# 文件上传
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/
# 一次性数据备份 / 临时调试产物
pear.db.*.bak
nav_extracted.js
dedup_nav.py
# 测试截图与探针截图(不入库,回归测试现场本地保留即可)
scripts/_verify_*.png
scripts/screenshots/
output/
+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 请手动备份后删除。"
+182 -82
View File
@@ -1,7 +1,6 @@
<div align="center"> <div align="center">
<br/> <br/>
<br/> <br/>
<img src="https://gitee.com/pear-admin/Pear-Admin-Layui/raw/master/admin/images/logo.png" width="90px" style="margin-top:30px;"/>
<h1 align="center"> <h1 align="center">
Pear Admin Flask Pear Admin Flask
</h1> </h1>
@@ -9,15 +8,14 @@
开 箱 即 用 的 Flask 快 速 开 发 平 台 开 箱 即 用 的 Flask 快 速 开 发 平 台
</h4> </h4>
[ ](http://flask.pearadmin.com) | [ ](http://www.pearadmin.com/) | [群聊](https://jq.qq.com/?_wv=1027&k=5OdSmve) | [社区](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">
@@ -26,111 +24,213 @@
</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 开发者提供一个快速开发前后端半分离的后台管理系统的模板
在使用本项目之前最好掌握以下知识点 > **⚠️注意** 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) 。
+ [html、css、JavaScript](https://developer.mozilla.org/zh-CN/docs/Learn/HTML) Pear Admin Flask 基于 Flask 的后台管理系统,拥抱应用广泛的python语言,通过使用本系统,即可快速构建你的功能业务
+ [jQuery](https://www.w3school.com.cn/jquery/index.asp) 项目旨在为 python 开发者提供一个后台管理系统的模板,可以快速构建信息管理系统。
+ [layui](https://www.layui.com/)
+ [flask](https://dormousehole.readthedocs.io/en/latest/)
+ [flask-login](https://flask-login.readthedocs.io/en/latest/)
+ [flask-sqlalchemy](http://www.pythondoc.com/flask-sqlalchemy/quickstart.html)
项目使用 flask-sqlalchemy + 权限验证 + marshmallow 序列化与数据验证,以此方式集成了若干不同的功能。
## 预览 # 内置功能
Pear Admin Flask 有以下几个版本:
[master分支版本 ](https://gitee.com/pear-admin/pear-admin-flask/tree/master/)
>flask 2.x + flask-sqlalchemy + 权限验证 + Flask-APScheduler 定时任务 + marshmallow 序列化与数据验证
[Mini 分支版本 ](https://gitee.com/pear-admin/pear-admin-flask/tree/mini/)
>flask 2.x + flask-sqlalchemy + 基于角色的权限管理
| | |
|---------------------|---------------------|
| ![](docs/assets/1.jpg) | ![](docs/assets/2.jpg) |
| ![](docs/assets/3.jpg)| ![](docs/assets/4.jpg) |
| ![](docs/assets/5.jpg) | ![](docs/assets/6.jpg) |
## 内置功能
- [x] 用户管理:用户是系统操作者,该功能主要完成系统用户配置。 - [x] 用户管理:用户是系统操作者,该功能主要完成系统用户配置。
- [x] 权限管理:配置系统菜单,操作权限,按钮权限标识等。 - [x] 权限管理:配置系统菜单,操作权限,按钮权限标识等。
- [x] 角色管理:角色菜单权限分配。 - [x] 角色管理:角色菜单权限分配。
- [x] 操作日志:系统正常操作日志记录和查询;系统异常信息日志记录和查询。 - [x] 操作日志:系统正常操作日志记录和查询;系统异常信息日志记录和查询。
- [x] 登录日志:系统登录日志记录查询包含登录异常。 - [x] 登录日志:系统登录日志记录查询包含登录异常。
- [x] 服务监控:监视当前系统CPU、内存、磁盘、python版本,运行时长等相关信息。
- [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
├─static # 项目设定的 Flask 资源文件夹
│ ├─admin # pear admin flask 的后端资源文件(与 pear admin layui 同步)
│ ├─index # pear admin flask 的前端资源文件
│ └─upload # 用户上传保存目录
└─templates # 项目设定的 Flask 模板文件夹
├─admin # pear admin flask 的后端管理页面模板
│ ├─admin_log # 日志页面
│ ├─common # 基本模板页面(头部模板与页脚模板)
│ ├─console # 系统监控页面模板
│ ├─dept # 部门管理页面模板
│ ├─dict # 数据字典页面模板
│ ├─mail # 邮件管理页面模板
│ ├─photo # 图片上传页面模板
│ ├─power # 权限(菜单)管理页面模板
│ ├─role # 角色管理页面模板
│ ├─task # 任务设置页面模板
│ └─user # 用户管理页面模板
├─errors # 错误页面模板
└─index # 主页模板
```
# 项目安装
## 从仓库获取
```bash
# 克隆仓库 / 手动下载
git clone https://gitee.com/pear-admin/pear-admin-flask
cd pear-admin-flask # 进入到项目目录
```
## 修改配置
> **💡提示** 配置文件位于 `applications/config.py` ,打开配置文件看到的是位于 `BaseConfig` 类下的默认配置文件,您可以编写自己的配置类并继承 `BaseConfig` 类。
项目启动时,会调用 `applications/__init__.py` ,这个文件中加载了程序的配置,所以在您编写了自己的类后不要忘记在文件 `applications/__init__.py` 中修改使用的配置类。
> **⚠️注意** 配置文件中对于数据库的配置有所更改,请查看代码中的注释修改配置。
```python
# 部分配置信息如下所示
# 验证密钥(⚠️ 一定要记得修改 ⚠️)
SECRET_KEY = "pear-system-flask"
# 数据库的配置信息
SQLALCHEMY_DATABASE_URI = 'sqlite:///../pear.db'
# 默认日志等级
LOG_LEVEL = logging.WARN
# flask-mail配置
MAIL_SERVER = 'smtp.qq.com'
MAIL_USE_TLS = False
MAIL_USE_SSL = True
MAIL_PORT = 465
MAIL_USERNAME = '123@qq.com'
MAIL_PASSWORD = 'XXXXX' # 生成的授权码
MAIL_DEFAULT_SENDER = MAIL_USERNAME
```
## 虚拟环境安装项目(推荐)
> **💡提示** 为了保证项目所依赖的库不影响其他部署在同一主机上的项目,我们推荐使用虚拟环境安装。
```bash
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
# 初始化数据库
flask db init
flask db migrate
flask db upgrade
flask admin init
# 运行项目
flask --app app.py run -h 0.0.0.0 -p 8000 --debug
# 或者直接调用 app.py
python app.py
```
+ 使用 docker-compose 运行项目
+ 下载源码
```bash ```bash
git clone https://gitee.com/pear-admin/pear-admin-flask git clone https://gitee.com/pear-admin/pear-admin-flask
# 切换分支 # 安装 docker-compose
git checkout mini 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 可以用了
docker-compose --version
# 在当前目录执行如下命令即可以运行 app
docker-compose -f dockercompose.yaml up
# 看到如下表示运行成功,由于 pip 下载慢,需要一些时间,请耐心等待;如果安装失败,重新执行上面的命令即可。
# 运行后在浏览器访问 127.0.0.1:5000
#如果要停止容器运行,在当前文件夹执行如下命令:
docker-compose -f dockercompose.yaml down
``` ```
+ 安装依赖
```bash
# 创建虚拟环境
python -m venv venv
# 然后使虚拟环境生效(windows # 预览项目
venv\Scripts\activate
# source venv/bin/activate # Linux激活虚拟环境)
# 安装依赖 | | |
pip install -r requirement.txt | ---------------------- | ---------------------- |
``` | ![](docs/source/_static/1.jpg) | ![](docs/source/_static/2.jpg) |
| ![](docs/source/_static/3.jpg) | ![](docs/source/_static/6.jpg) |
+ 数据迁移
默认的使用 `sqlite3` 作为测试环境的数据库进行演示,不需要按照mysql即可查看演示。如果需要二次开发,建议改成 `mysql` # 其他说明
如果需要在开发环境使用 mysql 作为数据库,请查看 `applications/configs/config.py` 文件里面的相关配置文件, 注释掉 sqlite 的配置即可 ## 项目初始用户以及其密码
如果需要修改数据的配置信息,请在 `.flaskenv` 里面调整即可 默认用户为 `admin` ,密码默认为 `123456`
```bash
flask db init
flask db migrate -m '数据初始化'
flask db upgrade
flask init-db
```
+ 项目启动
```bash
flask run
```
## Git 贡献提交规范
- 参考 [vue](https://github.com/vuejs/vue/blob/dev/.github/COMMIT_CONVENTION.md) 规范 ([Angular](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-angular))
- `feat` 增加新功能
- `fix` 修复问题/BUG
- `style` 代码风格相关无影响运行结果的
- `perf` 优化/性能提升
- `refactor` 重构
- `revert` 撤销修改
- `test` 测试相关
- `docs` 文档/注释
- `chore` 依赖更新/脚手架配置修改等
- `workflow` 工作流改进
- `ci` 持续集成
- `types` 类型定义文件更改
- `wip` 开发中
+14
View File
@@ -0,0 +1,14 @@
from applications import create_app
app = create_app()
if __name__ == '__main__':
# 绑定 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,
)
+40 -45
View File
@@ -1,60 +1,55 @@
import os import os
from flask import Flask, Blueprint from dotenv import load_dotenv
from flask import Flask
import config # 项目根目录 = 本文件父目录的父目录
from applications.view import init_view BASE_DIR = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
from common import register_api
from common.flask_uploads import configure_uploads
from common.utils.upload import photos
from extensions import init_plugs
api_bp: Blueprint = Blueprint('api', __name__, url_prefix='/api/v1') # 必须先于 applications.config 加载:config 在模块级就要读 PEAR_DATA_DIR
# 等环境变量来拼 data 目录路径,晚一步就会沿用默认值。
load_dotenv(os.path.join(BASE_DIR, ".env"), override=False)
from .rights import register_rights_api from applications.common.script import init_script
from .system import register_sys_api from applications.config import BaseConfig, get_config_by_name, ensure_data_dir
from .users import register_users_api from applications.extensions import init_plugs
from applications.view import init_bps
register_rights_api(api_bp)
register_users_api(api_bp)
register_sys_api(api_bp)
def init_api(app: Flask) -> None: def create_app():
app.register_blueprint(api_bp) # 1) 再加载一次 .env(幂等;override=False 保证环境变量优先级更高)
load_dotenv(os.path.join(BASE_DIR, ".env"), override=False)
app = Flask(BASE_DIR)
def create_app() -> Flask: # 2) 根据 PEAR_ENV / FLASK_ENV 选择配置类,默认 dev
app = Flask('pear-admin-flask') 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 等再覆盖一次
app.config.from_object(config) 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)
# 3.6) 数据目录兜底:确保 data/ 存在,并以运行时解析结果为准写回路径
# .env 里配了 PEAR_DATA_DIR 时,这里保证它一定生效)
data_dir = ensure_data_dir()
app.config["DATA_DIR"] = data_dir
if not os.environ.get("SQLALCHEMY_DATABASE_URI"):
app.config["SQLALCHEMY_DATABASE_URI"] = (
"sqlite:///" + os.path.join(data_dir, "pear.db").replace("\\", "/")
)
app.config["SESSION_FILE_DIR"] = os.path.join(data_dir, "flask_session")
# 4) 注册 flask 组件 / 蓝图 / 命令
init_plugs(app) init_plugs(app)
init_bps(app)
init_script(app)
# 注册路由 # 5) 全站访问埋点已迁移到 plugins/siteStats@app.before_request → record_visit
init_view(app)
# 注册接口(restful api
init_api(app)
# 文件上传
configure_uploads(app, photos)
if os.environ.get('WERKZEUG_RUN_MAIN') == 'true':
logo()
return app return app
def logo():
print('''
_____ _ _ ______ _ _
| __ \ /\ | | (_) | ____| | | |
| |__) |__ __ _ _ __ / \ __| |_ __ ___ _ _ __ | |__ | | __ _ ___| | __
| ___/ _ \/ _` | '__| / /\ \ / _` | '_ ` _ \| | '_ \ | __| | |/ _` / __| |/ /
| | | __/ (_| | | / ____ \ (_| | | | | | | | | | | | | | | (_| \__ \ <
|_| \___|\__,_|_| /_/ \_\__,_|_| |_| |_|_|_| |_| |_| |_|\__,_|___/_|\_\\
''')
+96
View File
@@ -0,0 +1,96 @@
from io import BytesIO
from flask import make_response
from flask_login import current_user
from applications.common.utils.validate import str_escape
from applications.common.utils.captcha import vieCode
from applications.extensions import db
from applications.models import AdminLog
def get_captcha():
"""
生成验证码图片及其对应的验证码字符串。
:return: 返回验证码图片的响应对象和验证码字符串。
"""
image, code = vieCode().GetCodeImage()
code = ''.join(code).lower()
out = BytesIO()
image.save(out, 'png')
out.seek(0)
resp = make_response(out.read())
resp.content_type = 'image/png'
return resp, code
def normal_log(method, url, ip, user_agent, desc, uid, is_access):
"""
记录通用日志信息到数据库。
:param method: 请求方法(如 GET、POST)。
:param url: 请求的 URL。
:param ip: 客户端的 IP 地址。
:param user_agent: 客户端的 User-Agent 信息。
:param desc: 日志描述信息。
:param uid: 用户 ID。
:param is_access: 是否成功访问(True 或 False)。
:return: 返回日志记录的 ID。
"""
info = {
'method': method,
'url': url,
'ip': ip,
'user_agent': user_agent,
'desc': desc,
'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.commit()
return log.id
def login_log(request, uid, is_access):
"""
记录用户登录日志。
:param request: Flask 请求对象。
:param uid: 用户 ID。
:param is_access: 是否成功登录(True 或 False)。
:return: 返回日志记录的 ID。
"""
method = request.method
url = request.path
ip = request.remote_addr
user_agent = str_escape(request.headers.get('User-Agent'))
desc = str_escape(request.form.get('username'))
return normal_log(method, url, ip, user_agent, desc, uid, is_access)
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)
+119
View File
@@ -0,0 +1,119 @@
import datetime
from marshmallow import Schema
from marshmallow_sqlalchemy import SQLAlchemyAutoSchema
from applications.extensions import db, ma
class LogicalDeleteMixin(object):
"""
逻辑删除混入类,为模型提供软删除功能。
示例:
class Test(db.Model, LogicalDeleteMixin):
__tablename__ = 'admin_test'
id = db.Column(db.Integer, primary_key=True, comment='角色ID')
# 软删除
Test.query.filter_by(id=1).soft_delete()
# 查询所有未删除的记录
Test.query.logic_all()
"""
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='更新时间')
delete_at = db.Column(db.DateTime, comment='删除时间')
def auto_model_jsonify(data, model: db.Model):
"""
自动序列化模型数据为 JSON 格式,无需手动定义 Schema。
示例:
power_data = curd.auto_model_jsonify(model=Dept, data=dept)
:param data: 需要序列化的 SQLAlchemy 查询结果。
:param model: SQLAlchemy 模型类。
:return: 返回序列化后的 JSON 数据。
"""
def get_model():
return model
class AutoSchema(SQLAlchemyAutoSchema):
class Meta(Schema):
model = get_model()
include_fk = True # 包含外键
include_relationships = True # 包含关联关系
load_instance = True # 反序列化时加载为模型实例
common_schema = AutoSchema(many=True) # 支持序列化多个对象
output = common_schema.dump(data)
return output
def model_to_dicts(schema: ma.Schema, data):
"""
使用指定的 Schema 序列化 SQLAlchemy 查询结果。
:param schema: Marshmallow Schema 类。
:param data: SQLAlchemy 查询结果。
:return: 返回序列化后的数据,返回字典。
"""
common_schema = schema(many=True) # 支持序列化多个对象
output = common_schema.dump(data)
return output
def get_one_by_id(model: db.Model, id):
"""
根据 ID 查询单个记录。
:param model: SQLAlchemy 模型类。
:param id: 记录的主键 ID。
:return: 返回查询到的记录,如果未找到则返回 None。
"""
return model.query.filter_by(id=id).first()
def delete_one_by_id(model: db.Model, id):
"""
根据 ID 删除单个记录。
:param model: SQLAlchemy 模型类。
:param id: 记录的主键 ID。
:return: 返回删除操作影响的行数。
"""
r = model.query.filter_by(id=id).delete()
db.session.commit()
return r
def enable_status(model: db.Model, id):
"""
启用指定 ID 的记录。
:param model: SQLAlchemy 模型类。
:param id: 记录的主键 ID。
:return: 如果操作成功返回 True,否则返回 False。
"""
enable = 1
role = model.query.filter_by(id=id).update({"enable": enable})
if role:
db.session.commit()
return True
return False
def disable_status(model: db.Model, id):
"""
停用指定 ID 的记录。
:param model: SQLAlchemy 模型类。
:param id: 记录的主键 ID。
:return: 如果操作成功返回 True,否则返回 False。
"""
enable = 0
role = model.query.filter_by(id=id).update({"enable": enable})
if role:
db.session.commit()
return True
return False
+173
View File
@@ -0,0 +1,173 @@
from sqlalchemy import and_, func
from applications.extensions import db
class ModelFilter:
"""
ORM 多条件查询构造器,支持多种查询条件组合,自动转义特殊字符防止SQL注入。
示例:
mf = ModelFilter()
mf.exact('name', 'John')
mf.vague('email', 'example.com')
query = User.query.filter(mf.get_filter(User))
"""
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):
"""初始化过滤条件存储字典和列表。"""
self.filter_field = {}
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):
"""
添加精确匹配条件(自动处理字符串类型参数)
:param field_name: 模型字段名称
:param value: 匹配的值(自动过滤空字符串)
"""
if value is not None and value != '':
# 字符串类型自动调用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):
"""
添加不等于条件(自动处理字符串类型参数)
:param field_name: 模型字段名称
:param value: 不匹配的值(自动过滤空字符串)
"""
if value is not None and value != '':
# 字符串类型自动调用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):
"""
添加大于条件(数值/日期比较)
:param field_name: 模型字段名称
:param value: 比较的数值/日期
"""
if value is not None and value != '':
self.filter_field[field_name] = {"data": value, "type": self.type_greater}
def less(self, field_name, value):
"""
添加小于条件(数值/日期比较)
:param field_name: 模型字段名称
:param value: 比较的数值/日期
"""
if value is not None and value != '':
self.filter_field[field_name] = {"data": value, "type": self.type_less}
def vague(self, field_name, value: str):
"""
添加安全模糊匹配(自动转义特殊字符,左右加%
:param field_name: 模型字段名称
:param value: 需要模糊匹配的字符串(自动过滤空值)
"""
if value and value != '':
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):
"""
添加安全左模糊匹配(自动转义特殊字符,左侧加%
:param field_name: 模型字段名称
:param value: 需要左模糊匹配的字符串
"""
if value and value != '':
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):
"""
添加安全右模糊匹配(自动转义特殊字符,右侧加%
:param field_name: 模型字段名称
:param value: 需要右模糊匹配的字符串
"""
if value and value != '':
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):
"""
添加安全包含条件(自动转义特殊字符,等效于vague)
:param field_name: 模型字段名称
:param value: 需要包含的字符串
"""
if value and value != '':
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):
"""
添加范围查询条件(自动过滤无效值)
:param field_name: 模型字段名称
:param value1: 范围起始值
:param 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}
def get_filter(self, model: db.Model):
"""
生成安全的SQLAlchemy过滤条件
:param model: SQLAlchemy 模型类
:return: 组合后的过滤条件(使用and_连接)
"""
for k, v in self.filter_field.items():
field = getattr(model, k)
data = v.get("data")
query_type = v.get("type")
if query_type == self.type_vague:
self.filter_list.append(field.like(data, escape='\\'))
elif query_type == self.type_contains:
self.filter_list.append(field.like(data, escape='\\'))
elif query_type == self.type_exact:
self.filter_list.append(field == data)
elif query_type == self.type_neq:
self.filter_list.append(field != data)
elif query_type == self.type_greater:
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)
+11
View File
@@ -0,0 +1,11 @@
from flask import Flask
from .admin import admin_cli
from .check import check_cli
from applications.extensions.init_plugins import broadcast_execute
def init_script(app: Flask):
app.cli.add_command(admin_cli)
app.cli.add_command(check_cli)
broadcast_execute(app, 'event_finish')
+256
View File
@@ -0,0 +1,256 @@
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 表)—— 幂等:按 id 或 url 去重,避免每次启动重复插入
from applications.models import Nav
count_nav = 0
existing_urls = {row[0] for row in db.session.query(Nav.url).all()}
for item in raw_data.get("navs", []):
item = parse_time(item)
# 已存在相同主键 id 则跳过
if item.get("id") and Nav.query.get(item["id"]):
continue
# 已存在相同 url 则跳过(典型场景:用户真实导航已覆盖种子数据,避免重复)
url = item.get("url")
if url and url in existing_urls:
continue
db.session.add(Nav(**item))
count_nav += 1
if url:
existing_urls.add(url)
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")
+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.
+41
View File
@@ -0,0 +1,41 @@
from flask import jsonify, request
def success_api(msg: str = "成功"):
"""
返回成功的 API 响应。
:param msg: 成功消息内容,默认为 "成功"
:return: 返回 JSON 格式的响应,包含 `success` 和 `msg` 字段。
"""
return jsonify(success=True, msg=msg)
def fail_api(msg: str = "失败"):
"""
返回失败的 API 响应。
:param msg: 失败消息内容,默认为 "失败"
:return: 返回 JSON 格式的响应,包含 `success` 和 `msg` 字段。
"""
return jsonify(success=False, msg=msg)
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 = {
'msg': msg,
'code': 0,
'data': data,
'count': count,
'limit': min(request.args.get('limit', default=10, type=int), 90)
}
return jsonify(res)
+93
View File
@@ -0,0 +1,93 @@
"""
集成了对 Pear Admin Flask 二次开发的邮件操作模块,并提供了相应的示例。
"""
from flask import current_app
from flask_mail import Message
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
def send_mail(subject, recipients, content):
"""
发送邮件(不记录发送日志)。
注意:如果发送失败会抛出异常,请使用 try-except 进行捕获。
:param subject: 邮件主题。
:param recipients: 接收者邮箱地址,多个邮箱用英文分号隔开。
:param content: 邮件内容(HTML 格式)。
"""
message = Message(subject=subject, recipients=recipients, html=content)
flask_mail.send(message)
+44
View File
@@ -0,0 +1,44 @@
from functools import wraps
from flask import abort, request, jsonify, session, current_app
from flask_login import login_required, current_user
from applications.common.admin import admin_log
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):
@login_required
@wraps(func)
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 log:
admin_log(request=request, is_access=False)
if request.method == 'GET':
abort(403)
else:
return jsonify(success=False, msg="权限不足!")
if log:
admin_log(request=request, is_access=True)
return func(*args, **kwargs)
return wrapper
return decorator
+36
View File
@@ -0,0 +1,36 @@
import os
from flask import current_app
from sqlalchemy import desc
from applications.extensions import db
from applications.extensions.init_upload import photos
from applications.models import Photo
from applications.schemas import PhotoOutSchema
from applications.common.curd import model_to_dicts
def get_photo(page, limit):
photo = Photo.query.order_by(desc(Photo.create_time)).paginate(page=page, per_page=limit, error_out=False)
count = Photo.query.count()
data = model_to_dicts(schema=PhotoOutSchema, data=photo.items)
return data, count
def upload_one(photo, mime):
filename = photos.save(photo)
file_url = '/_uploads/photos/' + filename
# file_url = photos.url(filename)
upload_url = current_app.config.get("UPLOADED_PHOTOS_DEST")
size = os.path.getsize(upload_url + '/' + filename)
photo = Photo(name=filename, href=file_url, mime=mime, size=size)
db.session.add(photo)
db.session.commit()
return file_url
def delete_photo_by_id(_id):
photo_name = Photo.query.filter_by(id=_id).first().name
photo = Photo.query.filter_by(id=_id).delete()
db.session.commit()
upload_url = current_app.config.get("UPLOADED_PHOTOS_DEST")
os.remove(upload_url + '/' + photo_name)
return photo
+240
View File
@@ -0,0 +1,240 @@
# XSS 过滤
import validators
from markupsafe import escape
from validators import validator
def str_escape(s):
"""
对字符串进行 XSS 过滤,返回转义后的安全字符串。
:param s: 需要转义的字符串。
:return: 返回转义后的字符串,如果输入为空则返回 None。
"""
if not s:
return None
return str(escape(s))
def between(*args, **kwargs):
"""
验证数字是否介于最小值和最大值之间。
适用于整数、浮点数、小数和日期等类型。
:param value: 需要验证的数字。
:param min: 数字的最小值(可选)。
: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)
+178
View File
@@ -0,0 +1,178 @@
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
# ---- 数据目录(持久化根目录)----
#
# 所有「会变、需要留下来」的东西统一放这里:
# data/pear.db SQLite 主库(含 -wal / -shm
# data/backup/ 在线热备产物
# data/flask_session/ Flask-Session 文件会话
#
# 为什么要单独一个目录:
# 项目根目录会被 Git / Docker 镜像整包替换,数据库混在源码里既容易误提交,
# 也会在容器重建时随镜像层一起丢掉。独立成 data/ 之后:
# - 宿主机/NAS 上只要备份或绑定挂载这一个目录,数据就在;
# - .gitignore / .dockerignore 各写一条就能彻底隔开。
#
# 默认值:项目根目录下的 data/applications/ 的上一级)。
# Docker 部署时用环境变量 PEAR_DATA_DIR=/app/data 覆盖,
# 再把 NAS 共享文件夹绑到 /app/data 即可。
_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA_DIR = os.path.abspath(os.environ.get("PEAR_DATA_DIR") or os.path.join(_PROJECT_ROOT, "data"))
def _sqlite_uri(filename: str) -> str:
"""拼 SQLite URI。统一换成正斜杠,保证 Linux 容器与 Windows 都能解析。"""
return "sqlite:///" + os.path.join(DATA_DIR, filename).replace("\\", "/")
def ensure_data_dir() -> str:
"""确保数据目录(及子目录)存在,返回根目录绝对路径。
在 create_app 里调用;这里再兜一层 try,避免只读环境下 import 就崩。
"""
for sub in ("", "backup", "flask_session"):
try:
os.makedirs(os.path.join(DATA_DIR, sub), exist_ok=True)
except OSError:
pass
return DATA_DIR
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
# ---- 数据库 ----
# 默认走 data/pear.dbDocker 下由 PEAR_DATA_DIR 重定向到挂载点)。
# 想换成 MySQL 就直接设环境变量 SQLALCHEMY_DATABASE_URI,优先级最高。
SQLALCHEMY_DATABASE_URI = os.environ.get(
"SQLALCHEMY_DATABASE_URI", _sqlite_uri("pear.db")
)
SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_ENGINE_OPTIONS = {"pool_pre_ping": True}
if SQLALCHEMY_DATABASE_URI.startswith("sqlite"):
# SQLite 写保护:拿不到锁最多等 30s 再放弃,避免 gunicorn 多 worker 下
# 偶发 "database is locked"。WAL 模式由 extensions 里的 connect 事件开启。
# 注意:这组 connect_args 是 sqlite3 专用的,切 MySQL 时不能带上。
SQLALCHEMY_ENGINE_OPTIONS["connect_args"] = {"timeout": 30}
# ---- 日志 ----
LOG_LEVEL = logging.WARN
# ---- Session ----
PERMANENT_SESSION_LIFETIME = timedelta(days=7)
SESSION_TYPE = "filesystem"
SESSION_FILE_DIR = os.path.join(DATA_DIR, "flask_session")
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)
# ---- 插件 ----
# 站点业务模块(导航 / 友链 / 关于 / 访问统计)均通过 plugins 方式开发,启用即可挂载路由
PLUGIN_ENABLE_FOLDERS = ["navManager", "friendManager", "aboutManager", "siteStats"]
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)
+34
View File
@@ -0,0 +1,34 @@
from flask import Flask
from .init_sqlalchemy import db, ma, init_databases
from .init_login import init_login_manager
from .init_template_directives import init_template_directives
from .init_error_views import init_error_views
from .init_mail import init_mail, mail as flask_mail
from .init_upload import init_upload
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:
# 注册插件 (仅 import + 注册动作)
register_plugin(app)
broadcast_execute(app, 'event_begin')
# 注册 Flask 功能
init_login_manager(app)
init_databases(app)
init_mail(app)
init_upload(app)
init_migrate(app)
init_session(app)
init_limit(app)
# 系统蓝图相关(含 @template_global 等 Jinja 注入)
init_template_directives(app)
init_error_views(app)
# 注:event_init / event_finish / event_context 由具体的
# applications.view.init_bps / applications.common.script.init_script
# 触发,避免 init_plugs 内重复调用导致 Blueprint 注册冲突
@@ -1,4 +1,4 @@
from flask import render_template from flask import render_template, jsonify
def init_error_views(app): def init_error_views(app):
@@ -10,6 +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
@app.errorhandler(429)
def ratelimit_exceeded(e):
return jsonify(
success=False, 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)
@@ -5,11 +5,10 @@ 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 = 'index.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):
from models import UserModel from applications.models import User
user = UserModel.query.get(int(user_id)) user = User.query.get(int(user_id))
return user return user
+8
View File
@@ -0,0 +1,8 @@
from flask import Flask
from flask_mail import Mail
mail = Mail()
def init_mail(app: Flask):
mail.init_app(app)
+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)
+143
View File
@@ -0,0 +1,143 @@
import datetime
import os
from flask import Flask, request
from flask_sqlalchemy import SQLAlchemy
from flask_sqlalchemy.query import Query as BaseQuery
from sqlalchemy import event
from sqlalchemy.engine import Engine
from flask_marshmallow import Marshmallow
from marshmallow import fields
from marshmallow.validate import (
URL, Email, Range, Length, Equal, Regexp,
Predicate, NoneOf, OneOf, ContainsOnly
)
URL.default_message = '无效的链接'
Email.default_message = '无效的邮箱地址'
Range.message_min = '不能小于{min}'
Range.message_max = '不能小于{max}'
Range.message_all = '不能超过{min}{max}这个范围'
Length.message_min = '长度不得小于{min}'
Length.message_max = '长度不得大于{max}'
Length.message_all = '长度不能超过{min}{max}这个范围'
Length.message_equal = '长度必须等于{equal}'
Equal.default_message = '必须等于{other}'
Regexp.default_message = '非法输入'
Predicate.default_message = '非法输入'
NoneOf.default_message = '非法输入'
OneOf.default_message = '无效的选择'
ContainsOnly.default_message = '一个或多个无效的选择'
fields.Field.default_error_messages = {
"required": "缺少必要数据",
"null": "数据不能为空",
"validator_failed": "非法数据",
}
fields.Str.default_error_messages = {
'invalid': "不是合法文本"
}
fields.Int.default_error_messages = {
"invalid": "不是合法整数"
}
fields.Number.default_error_messages = {
"invalid": "不是合法数字"
}
fields.Boolean.default_error_messages = {
"invalid": "不是合法布尔值"
}
class Query(BaseQuery):
def soft_delete(self):
return self.update({"delete_at": datetime.datetime.now()})
def logic_all(self):
return self.filter_by(delete_at=None).all()
def all_json(self, schema: Marshmallow().Schema):
return schema(many=True).dump(self.all())
def layui_paginate(self, page=None, limit=None):
if page is None:
page = request.args.get('page', type=int)
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) # 添加默认值
_res = self.paginate(
page=page,
per_page=limit,
error_out=False
)
return schema(many=True).dump(_res.items), _res.total, _res.page, _res.per_page
def layui_paginate_db_json(self, 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) # 添加默认值
_res = self.paginate(
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)
ma = Marshmallow()
def init_databases(app: Flask):
db.init_app(app)
ma.init_app(app)
# SQLite 才启用 WAL。WAL 把读与写互不阻塞,gunicorn 多 worker 写时基本不再
# 撞 "database is locked";同目录下会产生 pear.db-wal / pear.db-shm 两个文件,
# 备份必须用 VACUUM INTO 或 sqlite3 .backup,不能直接 cp(见 deploy/nas/backup.sh)。
if app.config.get("SQLALCHEMY_DATABASE_URI", "").startswith("sqlite"):
@event.listens_for(Engine, "connect")
def _sqlite_pragmas(dbapi_connection, connection_record):
cur = dbapi_connection.cursor()
try:
cur.execute("PRAGMA journal_mode=WAL")
cur.execute("PRAGMA synchronous=NORMAL")
cur.execute("PRAGMA foreign_keys=ON")
# busy_timeout 在 config.py 的 connect_args 里已设;这里再兜一次
# 兜底,防止用户自定义 connect_args 时漏配
cur.execute("PRAGMA busy_timeout=30000")
finally:
cur.close()
if os.environ.get('WERKZEUG_RUN_MAIN') == 'true':
with app.app_context():
try:
db.engine.connect()
except Exception as e:
exit(f"数据库连接失败: {e}")
@@ -0,0 +1,17 @@
from flask import session, current_app
from flask_login import current_user
from flask_wtf.csrf import generate_csrf
def init_template_directives(app):
@app.template_global()
def authorize(power):
if current_user.username != current_app.config.get("SUPERADMIN"):
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
@@ -0,0 +1,9 @@
from flask import Flask
from flask_uploads import configure_uploads
from flask_uploads import UploadSet, IMAGES
photos = UploadSet('photos', IMAGES)
def init_upload(app: Flask):
configure_uploads(app, photos)
+16
View File
@@ -0,0 +1,16 @@
from .admin_dept import Dept
from .admin_dict import DictType, DictData
from .admin_log import AdminLog
from .admin_photo import Photo
from .admin_power import Power
from .admin_role import Role
from .admin_role_power import role_power
from .admin_user import User
from .admin_user_role import user_role
from .admin_mail import Mail
from .admin_nav import Nav
from .admin_nav_category import NavCategory
from .admin_about import About
from .admin_friend import Friend
from .admin_stat import VisitLog, PageStat
from .admin_nav_click import NavClick
+62
View File
@@ -0,0 +1,62 @@
"""
「关于本站」内容管理。
单条记录表 (id=1)
- 用于站点"关于"页面:自我介绍、备案号、联系方式、捐赠等。
- 不限制多份:单条表单就能维护全部内容,幂等 upsert。
设计原则:
- 内容字段全可空(除 id),发布前可以临时空着。
- Markdown 字段长度无限制,存原始文本,渲染时由前端解析。
"""
import datetime
from applications.extensions import db
class About(db.Model):
__tablename__ = 'site_about'
id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='主键')
# 站点名称(公开页头部)
site_name = db.Column(db.String(120), default='', comment='站点名')
# 自我介绍 / 关于(Markdown 格式,前台渲染)
content_md = db.Column(db.Text, default='', comment='关于本站正文 (Markdown)')
# 备案信息
icp = db.Column(db.String(120), default='', comment='ICP 备案号')
# 联系信息(任选)
contact_email = db.Column(db.String(120), default='', comment='联系邮箱')
contact_qq = db.Column(db.String(32), default='', comment='联系 QQ')
contact_wechat = db.Column(db.String(120), default='', comment='微信号')
contact_telegram = db.Column(db.String(120), default='', comment='Telegram')
contact_github = db.Column(db.String(255), default='', comment='GitHub 链接')
# 捐赠 / 打赏 URL(一般挂二维码图片)
donate_url = db.Column(db.String(255), default='', comment='捐赠/打赏 URL 或二维码图片地址')
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) -> dict:
return {
'id': self.id,
'site_name': self.site_name or '',
'content_md': self.content_md or '',
'icp': self.icp or '',
'contact_email': self.contact_email or '',
'contact_qq': self.contact_qq or '',
'contact_wechat': self.contact_wechat or '',
'contact_telegram': self.contact_telegram or '',
'contact_github': self.contact_github or '',
'donate_url': self.donate_url or '',
'create_at': self.create_at.strftime('%Y-%m-%d %H:%M:%S') if self.create_at else '',
'update_at': self.update_at.strftime('%Y-%m-%d %H:%M:%S') if self.update_at else '',
}
+18
View File
@@ -0,0 +1,18 @@
import datetime
from applications.extensions import db
class Dept(db.Model):
__tablename__ = 'admin_dept'
id = db.Column(db.Integer, primary_key=True, comment="部门ID")
parent_id = db.Column(db.Integer, comment="父级编号")
dept_name = db.Column(db.String(50), comment="部门名称")
sort = db.Column(db.Integer, comment="排序")
leader = db.Column(db.String(50), comment="负责人")
phone = db.Column(db.String(20), comment="联系方式")
email = db.Column(db.String(50), comment="邮箱")
status = db.Column(db.Integer, comment='状态(1开启,0关闭)')
remark = db.Column(db.Text, comment="备注")
address = db.Column(db.String(255), 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='更新时间')
+26
View File
@@ -0,0 +1,26 @@
import datetime
from applications.extensions import db
class DictType(db.Model):
__tablename__ = 'admin_dict_type'
id = db.Column(db.Integer, primary_key=True)
type_name = db.Column(db.String(255), comment='字典类型名称')
type_code = db.Column(db.String(255), comment='字典类型标识')
description = db.Column(db.String(255), comment='字典类型描述')
enable = db.Column(db.Integer, comment='是否开启')
create_time = db.Column(db.DateTime, default=datetime.datetime.now, comment='创建时间')
update_time = db.Column(db.DateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now, comment='更新时间')
class DictData(db.Model):
__tablename__ = 'admin_dict_data'
id = db.Column(db.Integer, primary_key=True)
data_label = db.Column(db.String(255), comment='字典类型名称')
data_value = db.Column(db.String(255), comment='字典类型标识')
type_code = db.Column(db.String(255), comment='字典类型描述')
is_default = db.Column(db.Integer, comment='是否默认')
enable = db.Column(db.Integer, comment='是否开启')
remark = db.Column(db.String(255), comment='备注')
create_time = db.Column(db.DateTime, default=datetime.datetime.now, comment='创建时间')
update_time = db.Column(db.DateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now, comment='更新时间')
+56
View File
@@ -0,0 +1,56 @@
"""
「友情链接」管理。
- 后台:列表 + 新增 / 编辑 / 删除 / 启用 / 禁用(与 Nav 一致接口风格)
- 前台:公开只读
字段:
- title : 友链名称
- url : 友链 URL
- logo : 站点头像 / favicon(可空,自动用 url 的 favicon
- description : 简介
- category : 友链分组(默认「推荐友链」)
- sort : 同分类下排序
- enable : 1 启用,0 禁用
- is_external: 1 新窗口打开 / 0 当前窗口
"""
import datetime
from applications.extensions import db
class Friend(db.Model):
__tablename__ = 'site_friend'
id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='友情链接ID')
title = db.Column(db.String(100), nullable=False, comment='名称')
url = db.Column(db.String(255), nullable=False, comment='链接')
logo = db.Column(db.String(255), default='', comment='Logo / favicon')
description = db.Column(db.String(255), default='', comment='简介')
category = db.Column(db.String(50), default='推荐友链', comment='分组', index=True)
sort = db.Column(db.Integer, default=0, comment='排序')
enable = db.Column(db.Integer, default=1, comment='状态(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,
'title': self.title,
'url': self.url,
'logo': self.logo or '',
'description': self.description or '',
'category': self.category or '推荐友链',
'sort': self.sort or 0,
'enable': self.enable,
'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 '',
'update_at': self.update_at.strftime('%Y-%m-%d %H:%M:%S') if self.update_at else '',
}
+14
View File
@@ -0,0 +1,14 @@
import datetime
from applications.extensions import db
class AdminLog(db.Model):
__tablename__ = 'admin_admin_log'
id = db.Column(db.Integer, primary_key=True)
method = db.Column(db.String(10))
uid = db.Column(db.Integer)
url = db.Column(db.String(255))
desc = db.Column(db.Text)
ip = db.Column(db.String(255))
success = db.Column(db.Integer)
user_agent = db.Column(db.Text)
create_time = db.Column(db.DateTime, default=datetime.datetime.now)
+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=禁用(禁用后前台不再展示,后台列表仍可见)
enable = 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,
'enable': self.enable,
'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 '',
}
+54
View File
@@ -0,0 +1,54 @@
"""
导航分类表
Nav.category 是字符串字段,难以管理顺序/启用/图标。
NavCategory 把"分类"提到一张独立表,允许后台做完整的 CRUD。
数据关系:
- NavCategory 1 — N Nav
- 保留 Nav.category 字符串字段为冗余存储,避免前端热路径上做 LEFT JOIN
"""
import datetime
from applications.extensions import db
class NavCategory(db.Model):
__tablename__ = 'nav_category'
id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='分类ID')
# 同名分类不能重复,方便前台 URL /site/category/<name> 用作 slug
name = db.Column(db.String(50), nullable=False, unique=True, index=True, comment='分类名(前端分类详情 URL')
# 分类图标(前端首页标题旁小图标)
icon = db.Column(db.String(50), default='layui-icon-list', comment='layui-icon class')
# 分类简介(前端首页卡片标题下方描述)
description = db.Column(db.String(255), default='', comment='分类简介')
# 同级别的展示顺序(前端首页自上而下)
sort = db.Column(db.Integer, default=0, comment='排序')
# 1=启用,0=禁用(禁用后前台不再展示该分类,但 Nav 记录仍保留)
enable = db.Column(db.Integer, default=1, comment='状态(1启用,0关闭)')
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,
'name': self.name,
'icon': self.icon or 'layui-icon-list',
'description': self.description or '',
'sort': self.sort or 0,
'enable': self.enable,
'create_at': self.create_at.strftime('%Y-%m-%d %H:%M:%S') if self.create_at else '',
'update_at': self.update_at.strftime('%Y-%m-%d %H:%M:%S') if self.update_at else '',
}
+23
View File
@@ -0,0 +1,23 @@
"""
导航卡片点击埋点模型。
每条记录是用户点了一次某张 Nav 卡片跳出去的事件。
- nav_id 关联 public_nav.id
- anon_id 匿名访客 ID(后端静默下发的 cookie,不关联账号)
- day YYYY-MM-DD 日期
- ip 访客 IP(用于去重 / 隐私留存期控制)
- clicked_at 点击时间
"""
import datetime
from applications.extensions import db
class NavClick(db.Model):
__tablename__ = 'site_nav_click'
id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='记录ID')
nav_id = db.Column(db.Integer, nullable=False, index=True, comment='被点击的导航ID')
anon_id = db.Column(db.String(36), default='', index=True, comment='匿名访客ID')
day = db.Column(db.String(10), default='', index=True, comment='日期 YYYY-MM-DD')
ip = db.Column(db.String(64), default='', comment='访客IP')
clicked_at = db.Column(db.DateTime, default=datetime.datetime.now, index=True, comment='点击时间')
+12
View File
@@ -0,0 +1,12 @@
import datetime
from applications.extensions import db
class Photo(db.Model):
__tablename__ = 'admin_photo'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), nullable=False)
href = db.Column(db.String(255))
mime = db.Column(db.CHAR(50), nullable=False)
size = db.Column(db.CHAR(30), nullable=False)
create_time = db.Column(db.DateTime, default=datetime.datetime.now)
+18
View File
@@ -0,0 +1,18 @@
import datetime
from applications.extensions import db
class Power(db.Model):
__tablename__ = 'admin_power'
id = db.Column(db.Integer, primary_key=True, comment='权限编号')
name = db.Column(db.String(255), comment='权限名称')
type = db.Column(db.String(1), comment='权限类型')
code = db.Column(db.String(30), comment='权限标识')
url = db.Column(db.String(255), comment='权限路径')
open_type = db.Column(db.String(10), comment='打开方式')
parent_id = db.Column(db.Integer, comment='父类编号')
icon = db.Column(db.String(128), comment='图标')
sort = db.Column(db.Integer, comment='排序')
create_time = db.Column(db.DateTime, default=datetime.datetime.now, comment='创建时间')
update_time = db.Column(db.DateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now, comment='更新时间')
enable = db.Column(db.Integer, comment='是否开启')
+16
View File
@@ -0,0 +1,16 @@
import datetime
from applications.extensions import db
class Role(db.Model):
__tablename__ = 'admin_role'
id = db.Column(db.Integer, primary_key=True, comment='角色ID')
name = db.Column(db.String(255), comment='角色名称')
code = db.Column(db.String(255), comment='角色标识')
enable = db.Column(db.Integer, comment='是否启用')
remark = db.Column(db.String(255), comment='备注')
details = db.Column(db.String(255), comment='详情')
sort = db.Column(db.Integer, comment='排序')
create_time = db.Column(db.DateTime, default=datetime.datetime.now, comment='创建时间')
update_time = db.Column(db.DateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now, comment='更新时间')
power = db.relationship('Power', secondary="admin_role_power", backref=db.backref('role'))
+9
View File
@@ -0,0 +1,9 @@
from applications.extensions import db
# 创建中间表
role_power = db.Table(
"admin_role_power", # 中间表名称
db.Column("id", db.Integer, primary_key=True, autoincrement=True, comment='标识'), # 主键
db.Column("power_id", db.Integer, db.ForeignKey("admin_power.id"), comment='用户编号'), # 属性 外键
db.Column("role_id", db.Integer, db.ForeignKey("admin_role.id"), comment='角色编号'), # 属性 外键
)
+57
View File
@@ -0,0 +1,57 @@
"""
「访问统计」数据模型。
每条记录是一次访问事件:
- path 访问路径
- referer 来源页(可空)
- ua User-Agent 摘要(可空)
- ip 访客 IP(如果能拿到)
- day YYYY-MM-DD 日期(用于按天聚合)
- visit_at 访问时间
"""
import datetime
from applications.extensions import db
class VisitLog(db.Model):
__tablename__ = 'site_visit_log'
id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='记录ID')
path = db.Column(db.String(255), default='', index=True, comment='访问路径')
referer = db.Column(db.String(255), default='', comment='来源页')
ua = db.Column(db.String(255), default='', comment='UA')
ip = db.Column(db.String(64), default='', comment='访问IP')
day = db.Column(db.String(10), default='', index=True, comment='日期 YYYY-MM-DD')
visit_at = db.Column(db.DateTime, default=datetime.datetime.now, index=True, comment='访问时间')
class PageStat(db.Model):
"""
路径级累计统计:每次访问做 upsert(按 path+day)。
字段:
- path
- day
- pv 当日 / 累计 Page View
- uv IP 去重计数(用 set 维护在内存里,但用户规模小可以按 (path, day, ip) 简单去重)
- last_hit 最后一次访问时间
"""
__tablename__ = 'site_page_stat'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
path = db.Column(db.String(255), default='', index=True)
day = db.Column(db.String(10), default='', index=True)
pv = db.Column(db.Integer, default=0, comment='Page View')
uv = db.Column(db.Integer, default=0, comment='独立访客数(IP 去重后)')
uv_ip_set = db.Column(db.Text, default='', comment='独立 IP 列表 CSV')
last_hit = db.Column(db.DateTime, default=datetime.datetime.now, comment='最近访问时间')
def to_dict(self):
return {
'id': self.id,
'path': self.path,
'day': self.day,
'pv': self.pv or 0,
'uv': self.uv or 0,
'last_hit': self.last_hit.strftime('%Y-%m-%d %H:%M:%S') if self.last_hit else '',
}
+25
View File
@@ -0,0 +1,25 @@
import datetime
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from applications.extensions import db
class User(db.Model, UserMixin):
__tablename__ = 'admin_user'
id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='用户ID')
username = db.Column(db.String(20), comment='用户名')
realname = db.Column(db.String(20), comment='真实名字')
avatar = db.Column(db.String(255), comment='头像', default="/static/system/admin/images/avatar.jpg")
remark = db.Column(db.String(255), comment='备注')
password_hash = db.Column(db.String(128), comment='哈希密码')
enable = db.Column(db.Integer, default=0, comment='启用')
dept_id = db.Column(db.Integer, comment='部门id')
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='创建时间')
role = db.relationship('Role', secondary="admin_user_role", backref=db.backref('user'), lazy='dynamic')
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def validate_password(self, password):
return check_password_hash(self.password_hash, password)
+9
View File
@@ -0,0 +1,9 @@
from applications.extensions import db
# 创建中间表
user_role = db.Table(
"admin_user_role", # 中间表名称
db.Column("id", db.Integer, primary_key=True, autoincrement=True, comment='标识'), # 主键
db.Column("user_id", db.Integer, db.ForeignKey("admin_user.id"), comment='用户编号'), # 属性 外键
db.Column("role_id", db.Integer, db.ForeignKey("admin_role.id"), comment='角色编号'), # 属性 外键
)
-42
View File
@@ -1,42 +0,0 @@
from common import register_api
from .department import DepartmentsApi, DeptEnableAPI
from .rights import (
RightsApi, PowerApi, right_power_enable_resource,
admin_configs_resource, admin_menu_resource
)
from .roles import RoleRoleApi, RolePowerApi, role_deletes, role_enable_resource
def register_rights_api(api_bp):
register_api(DepartmentsApi, 'rights_dept_api', '/dept/department/', pk='_id', app=api_bp)
api_bp.add_url_rule('/dept/department/<int:_id>/status',
view_func=DeptEnableAPI.as_view('dept_status_api'),
methods=['PUT', ])
api_bp.add_url_rule('/rights/rights',
view_func=RightsApi.as_view('dept_rights_api'),
methods=['GET', 'DELETE'])
register_api(PowerApi, 'rights_power_api', '/rights/power/', pk='_id', app=api_bp)
api_bp.add_url_rule('/rights/configs',
endpoint='rights_config_api',
view_func=admin_configs_resource,
methods=['GET'])
api_bp.add_url_rule('/rights/menu',
endpoint='rights_menu_api',
view_func=admin_menu_resource,
methods=['GET'])
api_bp.add_url_rule('/rights/power/<int:_id>/<action>',
endpoint='rights_power_action_api',
view_func=right_power_enable_resource,
methods=['PUT'])
api_bp.add_url_rule('/roles/role',
endpoint='rights_roles_role_api',
view_func=role_deletes,
methods=['DELETE'])
register_api(RoleRoleApi, 'rights_role_api', '/roles/role/', pk='_id', app=api_bp)
register_api(RolePowerApi, 'rights_role_power_api', '/roles/role_power/', pk='_id', app=api_bp)
api_bp.add_url_rule('/roles/role/<int:_id>/status',
endpoint='rights_roles_enable_api',
view_func=role_enable_resource,
methods=['PUT'])
-112
View File
@@ -1,112 +0,0 @@
import typing as t
from flask.views import MethodView
from flask_pydantic import validate
from pydantic import BaseModel, Field
from common.utils.http import success_api, fail_api, table_api
from extensions import db
from models import DepartmentModel, UserModel
class DeptSchema(BaseModel):
address: t.Optional[str]
dept_name: t.Optional[str] = Field(alias='deptName')
email: t.Optional[str]
leader: t.Optional[str]
parent_id: t.Optional[str] = Field(alias='parentId')
phone: t.Optional[str]
sort: t.Optional[int]
status: t.Optional[int]
class DepartmentsApi(MethodView):
def get(self, _id):
if _id:
dept = DepartmentModel.query.filter_by(id=_id).first()
dept_data = {
'id': dept.id,
'dept_name': dept.dept_name,
'leader': dept.leader,
'email': dept.email,
'phone': dept.phone,
'status': dept.status,
'sort': dept.sort,
'address': dept.address,
}
return dict(success=True, message='ok', dept=dept_data)
dept_data = DepartmentModel.query.order_by(DepartmentModel.sort).all()
return table_api(
result={
'items': [
{
'deptId': item.id,
'parentId': item.parent_id,
'deptName': item.dept_name,
'sort': item.sort,
'leader': item.leader,
'phone': item.phone,
'email': item.email,
'status': item.status,
'comment': item.comment,
'address': item.address,
'create_at': item.create_at.strftime('%Y-%m-%d %H:%M:%S')
} for item in dept_data
],
'total': len(dept_data)}
, code=0)
@validate()
def post(self, body: DeptSchema):
dept = DepartmentModel(
parent_id=body.parent_id,
dept_name=body.dept_name,
sort=body.sort,
leader=body.leader,
phone=body.phone,
email=body.email,
status=body.status,
address=body.address
)
db.session.add(dept)
db.session.commit()
return success_api(message="成功")
@validate()
def put(self, _id, body: DeptSchema):
data = {
"dept_name": body.dept_name,
"sort": body.sort,
"leader": body.leader,
"phone": body.phone,
"email": body.email,
"status": body.status,
"address": body.address
}
body = DepartmentModel.query.filter_by(id=_id).update(data)
if not body:
return {'success': False, 'message': "更新失败", 'code': 404}
db.session.commit()
return {'success': True, 'message': '更新成功', 'code': 200}
def delete(self, _id):
ret = DepartmentModel.query.filter_by(id=_id).delete()
UserModel.query.filter_by(dept_id=_id).update({"dept_id": None})
db.session.commit()
if ret:
return success_api(message="删除成功")
return fail_api(message="删除失败")
class DeptEnableAPI(MethodView):
def put(self, _id):
d = DepartmentModel.query.get(_id)
if d:
d.status = not d.status
db.session.commit()
message = '修改成功'
return success_api(message=message)
return fail_api(message="出错啦")
-278
View File
@@ -1,278 +0,0 @@
import copy
from collections import OrderedDict
from typing import Optional
from flask import request, jsonify, current_app
from flask.views import MethodView
from flask_login import current_user
from flask_pydantic import validate
from pydantic import BaseModel, Field
from common.utils.http import success_api, fail_api
from extensions import db
from models import RightModel, RoleModel
def get_render_config():
# 网站配置
config = {
'logo': {
# 网站名称
"title": current_app.config.get("SYSTEM_NAME"),
# 网站图标
"image": "/static/admin/admin/images/logo.png"
# 菜单配置
},
'menu': {
# 菜单数据来源
"data": "/api/v1/rights/menu",
"collaspe": True,
# 是否同时只打开一个菜单目录
"accordion": True,
"method": "GET",
# 是否开启多系统菜单模式
"control": False,
# 默认选中的菜单项
"select": "0",
# 是否开启异步菜单,false 时 data 属性设置为菜单数据,false 时为 json 文件或后端接口
"async": True
},
'tab': {
# 是否开启多选项卡
"muiltTab": True,
# 切换选项卡时,是否刷新页面状态
"keepState": True,
# 是否开启 Tab 记忆
"session": True,
# 最大可打开的选项卡数量
"tabMax": 30,
"index": {
# 标识 ID , 建议与菜单项中的 ID 一致
"id": "10",
# 页面地址
"href": "/admin/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": 1200,
# 布局顶部主题
"autoHead": False
},
'header': False
}
return config
# 生成菜单树
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 p.type == 0 or p.type == 1:
powers.append(p)
power_dict = [
{
'id': item.id,
'title': item.name,
'type': item.type,
'code': item.code,
'href': item.url,
'openType': item.open_type,
'parent_id': item.parent_id,
'icon': item.icon,
'sort': item.sort,
'enable': item.enable,
} for item in powers
]
power_dict.sort(key=lambda x: 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'])
class PowerSchema(BaseModel):
icon: str
open_type: Optional[str] = Field(alias='openType')
parent_id: Optional[str] = Field(alias='parentId')
power_code: Optional[str] = Field(alias='powerCode')
power_name: Optional[str] = Field(alias='powerName')
power_type: Optional[str] = Field(alias='powerType')
power_url: Optional[str] = Field(alias='powerUrl')
sort: Optional[int] = Field(alias='sort')
class RightsApi(MethodView):
def get(self):
"""获取选择父节点"""
power = RightModel.query.all()
# power_data = marshal(power, RightModel.fields())
power_data = [
{
'powerId': item.id,
'powerName': item.name,
'powerType': item.type,
'powerUrl': item.url,
'openType': item.open_type,
'parentId': item.parent_id,
'icon': item.icon,
'sort': item.sort,
'enable': item.enable,
} for item in power
]
power_data.append({"powerId": 0, "powerName": "顶级权限", "parentId": -1})
res = {
"status": {"code": 200, "message": "默认"},
"data": power_data
}
return res
def delete(self):
ids = request.form.getlist('ids[]')
for id in ids:
role = RoleModel.query.filter_by(id=id).first()
# 删除该角色的权限和用户
role.power = []
role.user = []
r = RoleModel.query.filter_by(id=id).delete()
db.session.commit()
return success_api(message="批量删除成功")
class PowerApi(MethodView):
@validate()
def post(self, body: PowerSchema):
power = RightModel(
icon=body.icon,
open_type=body.open_type,
parent_id=body.parent_id,
code=body.power_code,
name=body.power_name,
type=body.power_type,
url=body.power_url,
sort=body.sort,
enable=1
)
try:
db.session.add(power)
db.session.commit()
except Exception as e:
print(e)
return fail_api(message='数据提交失败')
return success_api(message="成功")
def delete(self, _id):
# 删除权限(目前没有判断父节点自动删除子节点)
power = RightModel.query.filter_by(id=_id).first()
role_id_list = []
roles = power.role
for role in roles:
role_id_list.append(role.id)
roles = RoleModel.query.filter(RoleModel.id.in_(role_id_list)).all()
for p in roles:
power.role.remove(p)
r = RightModel.query.filter_by(id=_id).delete()
db.session.commit()
if r:
return success_api(message="删除成功")
else:
return fail_api(message="删除失败")
@validate()
def put(self, _id, body: PowerSchema):
data = {
"icon": body.icon,
"open_type": body.open_type,
"parent_id": body.parent_id,
"code": body.power_code,
"name": body.power_name,
"type": body.power_type,
"url": body.power_url,
"sort": body.sort
}
power = RightModel.query.filter_by(id=_id).update(data)
db.session.commit()
if not power:
return fail_api(message="更新权限失败")
return success_api(message="更新权限成功")
def right_power_enable_resource(_id, action):
power = RightModel.query.get(_id)
if power:
power.enable = not power.enable
db.session.commit()
message = "修改成功"
return success_api(message=message)
else:
return fail_api(message="出错啦")
def admin_configs_resource():
return get_render_config()
def admin_menu_resource():
menu_tree = make_menu_tree()
return jsonify(menu_tree)
-162
View File
@@ -1,162 +0,0 @@
from flask import request
from flask.views import MethodView
from common.utils.http import table_api, success_api, fail_api
from extensions import db
from models import RightModel, RoleModel
def role_deletes():
ids = request.form.getlist('ids[]')
for id in ids:
role = RoleModel.query.filter_by(id=id).first()
# 删除该角色的权限和用户
role.power = []
role.user = []
r = RoleModel.query.filter_by(id=id).delete()
db.session.commit()
return success_api(message="批量删除成功")
class RoleRoleApi(MethodView):
def get(self, _id):
if not _id:
page = request.args.get('page', default=1, type=int)
limit = request.args.get('limit', default=10, type=int)
role_name = request.args.get('roleName', default="", type=str)
role_code = request.args.get('roleCode', default="", type=str)
filters = []
if role_name:
filters.append(RoleModel.name.like('%' + role_name + '%'))
if role_code:
filters.append(RoleModel.code.like('%' + role_code + '%'))
paginate = RoleModel.query.filter(*filters).paginate(page=page, per_page=limit, error_out=False)
return table_api(
result={
'items': [{'id': item.id,
'roleName': item.name,
'roleCode': item.code,
'enable': item.enable,
'comment': item.comment,
'details': item.details,
'sort': item.sort,
} for item in paginate.items],
'total': paginate.total}
, code=0)
def post(self):
# TODO 添加校验
details = request.json.get('details', '')
enable = request.json.get('enable', 0)
role_code = request.json.get('roleCode', '')
role_name = request.json.get('roleName', '')
sort = request.json.get('sort', 0)
role = RoleModel(
details=details,
enable=int(enable),
code=role_code,
name=role_name,
sort=int(sort)
)
db.session.add(role)
db.session.commit()
return success_api(message="成功")
# 更新角色
def put(self, _id):
# TODO 添加校验
role_code = request.json.get('roleCode', 0)
role_name = request.json.get('roleName', '')
sort = request.json.get('sort', 0) # int
enable = request.json.get('enable', 0) # int
details = request.json.get('details', '')
data = {
"code": role_code,
"name": role_name,
"sort": sort,
"enable": enable,
"details": details
}
role = RoleModel.query.filter_by(id=_id).update(data)
db.session.commit()
if not role:
return fail_api(message="更新角色失败")
return success_api(message="更新角色成功")
def role_enable_resource(_id):
"""启用用户"""
ret = RoleModel.query.get(_id)
ret.enable = not ret.enable
db.session.commit()
message = "修改成功"
if not ret:
return fail_api(message="出错啦")
return success_api(message=message)
class RolePowerApi(MethodView):
def get(self, _id):
# 获取角色权限
role = RoleModel.query.filter_by(id=_id).first()
# 获取权限列表的 id
check_powers_list = [rp.id for rp in role.power]
powers = RightModel.query.all() # 获取所有的权限
powers = [
{
'powerId': item.id,
'powerName': item.name,
'powerType': item.type,
'powerUrl': item.url,
'openType': item.open_type,
'parentId': item.parent_id,
'icon': item.icon,
'sort': item.sort,
'enable': item.enable,
} for item in powers]
for i in powers:
if int(i.get("powerId")) in check_powers_list:
i["checkArr"] = "1"
else:
i["checkArr"] = "0"
return {
"data": powers,
"status": {"code": 200, "message": "默认"}
}
# 保存角色权限
def put(self, _id):
power_ids = request.json.get('powerIds', '')
power_list = power_ids.split(',')
""" 更新角色权限 """
role = RoleModel.query.filter_by(id=_id).first()
powers = RightModel.query.filter(RightModel.id.in_(power_list)).all()
role.power = powers
db.session.commit()
return success_api(message="授权成功")
# 角色删除
def delete(self, _id):
role = RoleModel.query.filter_by(id=_id).first()
# 删除该角色的权限和用户
role.power = []
role.user = []
r = RoleModel.query.filter_by(id=_id).delete()
db.session.commit()
if not r:
return fail_api(message="角色删除失败")
return success_api(message="角色删除成功")
+11
View File
@@ -0,0 +1,11 @@
from .admin_role import RoleOutSchema
from .admin_power import PowerOutSchema, PowerOutSchema2
from .admin_dict import DictDataOutSchema, DictTypeOutSchema
from .admin_dept import DeptSchema
from .admin_log import LogOutSchema
from .admin_photo import PhotoOutSchema
from .admin_mail import MailOutSchema
from .admin_nav import NavOutSchema, NavManageSchema
from .admin_nav_category import NavCategorySchema
from .admin_friend import FriendOutSchema, FriendManageSchema
+12
View File
@@ -0,0 +1,12 @@
from flask_marshmallow.sqla import SQLAlchemyAutoSchema
from applications.models import Dept
class DeptSchema(SQLAlchemyAutoSchema):
class Meta:
model = Dept # table = models.Album.__table__
# include_relationships = True # 输出模型对象时同时对外键,是否也一并进行处理
include_fk = True # 序列化阶段是否也一并返回主键
# fields= ["id","name"] # 启动的字段列表
# exclude = ["id","name"] # 排除字段列表
+21
View File
@@ -0,0 +1,21 @@
from applications.extensions import ma
from marshmallow import fields
class DictTypeOutSchema(ma.Schema):
id = fields.Str(attribute="id")
typeName = fields.Str(attribute="type_name")
typeCode = fields.Str(attribute="type_code")
description = fields.Str(attribute="description")
createTime = fields.Str(attribute="create_time")
updateName = fields.Str(attribute="update_time")
remark = fields.Str()
enable = fields.Str()
class DictDataOutSchema(ma.Schema):
dataId = fields.Str(attribute="id")
dataLabel = fields.Str(attribute="data_label")
dataValue = fields.Str(attribute="data_value")
remark = fields.Str()
enable = fields.Str()
+17
View File
@@ -0,0 +1,17 @@
from flask_marshmallow.sqla import SQLAlchemyAutoSchema
from applications.models import Friend
class FriendOutSchema(SQLAlchemyAutoSchema):
"""前台展示用:序列化 Friend 对象为 JSON"""
class Meta:
model = Friend
include_fk = True
class FriendManageSchema(SQLAlchemyAutoSchema):
"""后台管理用:序列化为表格数据 / 表单回填"""
class Meta:
model = Friend
include_fk = True
+14
View File
@@ -0,0 +1,14 @@
from applications.extensions import ma
from marshmallow import fields
class LogOutSchema(ma.Schema):
id = fields.Integer()
method = fields.Str()
uid = fields.Str()
url = fields.Str()
desc = fields.Str()
ip = fields.Str()
user_agent = fields.Str()
success = fields.Bool()
create_time = fields.DateTime()
+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
@@ -0,0 +1,10 @@
from flask_marshmallow.sqla import SQLAlchemyAutoSchema
from applications.models import NavCategory
class NavCategorySchema(SQLAlchemyAutoSchema):
"""导航分类序列化:用于后台 table_api 的 data 字段"""
class Meta:
model = NavCategory
include_fk = True
+12
View File
@@ -0,0 +1,12 @@
from applications.extensions import ma
from marshmallow import fields
class PhotoOutSchema(ma.Schema):
id = fields.Integer()
name = fields.Str()
href = fields.Str()
mime = fields.Str()
size = fields.Str()
ext = fields.Str()
create_time = fields.DateTime()
+44
View File
@@ -0,0 +1,44 @@
from flask_marshmallow.sqla import SQLAlchemyAutoSchema
from applications.extensions import ma
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序列化类
class PowerOutSchema(ma.Schema):
id = fields.Integer()
title = fields.Str(attribute="name")
type = fields.Str()
code = fields.Str()
href = fields.Str(attribute="url")
openType = fields.Str(attribute="open_type")
parent_id = fields.Integer()
icon = fields.Str()
sort = fields.Integer()
create_time = fields.DateTime()
update_time = fields.DateTime()
enable = fields.Integer()
class PowerOutSchema2(ma.Schema): # 序列化类
powerId = fields.Str(attribute="id")
powerName = fields.Str(attribute="name")
powerType = fields.Str(attribute="type")
powerUrl = fields.Str(attribute="url")
openType = fields.Str(attribute="open_type")
parentId = fields.Str(attribute="parent_id")
icon = fields.Str()
sort = fields.Integer()
create_time = fields.DateTime()
update_time = fields.DateTime()
enable = fields.Integer()
+12
View File
@@ -0,0 +1,12 @@
from flask_marshmallow.sqla import SQLAlchemyAutoSchema
from applications.models import Role
class RoleOutSchema(SQLAlchemyAutoSchema):
class Meta:
model = Role # table = models.Album.__table__
# include_relationships = True # 输出模型对象时同时对外键,是否也一并进行处理
include_fk = True # 序列化阶段是否也一并返回主键
# fields= ["id","name"] # 启动的字段列表
# exclude = ["id","name"] # 排除字段列表
-8
View File
@@ -1,8 +0,0 @@
from common import register_api
from .file import FilePhotoAPI
from .passport import LoginAPI
def register_sys_api(api_bp):
register_api(LoginAPI, 'login_api', '/passport/login', pk='_id', app=api_bp)
register_api(FilePhotoAPI, 'photo_api', '/file/photo', pk='photo_id', app=api_bp)
-78
View File
@@ -1,78 +0,0 @@
from flask import request, jsonify
from flask.views import MethodView
from sqlalchemy import desc
from common.utils.http import fail_api, success_api, table_api
from common.utils.upload import upload_one, delete_photo_by_id
from models import PhotoModel
class FilePhotoAPI(MethodView):
def get(self, photo_id):
if photo_id is None:
page = request.args.get('page', type=int, default=1)
limit = request.args.get('limit', type=int, default=10)
photo_paginate = PhotoModel.query.order_by(
desc(PhotoModel.create_at)).paginate(
page=page, per_page=limit, error_out=False)
data = [
{
'id': item.id,
'name': item.name,
'href': item.href,
'mime': item.mime,
'size': item.size,
'ext': item.ext if hasattr(item, 'ext') else "",
'create_at': str(item.create_at),
} for item in photo_paginate.items
]
return table_api(
result={
'items': data,
'total': photo_paginate.total,
},
code=0)
else:
# 显示一张图片
item = PhotoModel.query.get(photo_id)
return table_api(
result={
'items': {
'id': item.id,
'name': item.name,
'href': item.href,
'mime': item.mime,
'size': item.size,
'ext': item.ext if hasattr(item, 'ext') else "",
'create_at': str(item.create_at),
},
'total': 1,
},
code=0
)
def post(self):
if 'file' in request.files:
photo = request.files['file']
mime = request.files['file'].content_type
file_url = upload_one(photo=photo, mime=mime)
res = {
"message": "上传成功",
"code": 0,
"success": True,
"data": {"src": file_url},
}
return jsonify(res)
return fail_api()
def delete(self, photo_id):
res = delete_photo_by_id(photo_id)
if res:
return success_api(message="删除成功")
else:
return fail_api(message="删除失败")
def put(self, photo_id):
pass
-52
View File
@@ -1,52 +0,0 @@
from flask import render_template, make_response
from flask import session, redirect, url_for
from flask.views import MethodView
from flask_login import current_user, login_user
from flask_pydantic import validate
from pydantic import BaseModel
from common.gen_captcha import add_auth_session
from common.utils.http import fail_api, success_api
from common.utils.rights import record_logging
from models import UserModel
class LoginSchema(BaseModel):
username: str
password: str
captcha: str
class LoginAPI(MethodView):
def get(self):
if current_user.is_authenticated:
return redirect(url_for('admin.index'))
return make_response(render_template('index/login.html'))
@validate()
def post(self, body: LoginSchema):
s_code = session.get("code", None)
session["code"] = None
if body.captcha != s_code:
return fail_api(message="验证码错误")
user = UserModel.query.filter_by(username=body.username).first()
if user is None:
return fail_api(message="不存在的用户")
if user.enable == 0:
return fail_api(message="用户被暂停使用")
if user.validate_password(body.password):
# 登录
login_user(user)
# 记录登录日志
record_logging()
# 存入权限
add_auth_session()
return success_api(message="登录成功")
record_logging()
return fail_api(message="用户名或密码错误")
-14
View File
@@ -1,14 +0,0 @@
from common import register_api
from .user import UserApi, user_role_resource, user_info
def register_users_api(api_bp):
api_bp.add_url_rule('/users/user/<int:_id>/<action>',
view_func=user_info,
methods=['PUT'])
register_api(UserApi, 'users_api', '/users/user/', pk='_id', app=api_bp)
api_bp.add_url_rule('/users/user/<int:_id>/role',
view_func=user_role_resource,
methods=['PUT'])
-243
View File
@@ -1,243 +0,0 @@
import typing as t
from flask import request, jsonify
from flask.views import MethodView
from flask_login import current_user
from flask_pydantic import validate
from pydantic import BaseModel, Field
from sqlalchemy import desc
from common.utils.http import fail_api, success_api, table_api
from extensions import db
from models import LogModel
from models import UserModel, RoleModel, DepartmentModel
def get_current_user_logs():
""" 获取当前用户日志 """
log = LogModel.query.filter_by(url='/passport/login').filter_by(uid=current_user.id).order_by(
desc(LogModel.create_at)).limit(10)
return log
def is_user_exists(username):
""" 判断用户是否存在 """
res = UserModel.query.filter_by(username=username).count()
return bool(res)
def delete_by_id(_id):
""" 删除用户 """
user = UserModel.query.filter_by(id=_id).first()
roles_id = []
for role in user.role:
roles_id.append(role.id)
roles = RoleModel.query.filter(RoleModel.id.in_(roles_id)).all()
for r in roles:
user.role.remove(r)
res = UserModel.query.filter_by(id=_id).delete()
db.session.commit()
return res
def batch_remove(ids):
""" 批量删除 """
for _id in ids:
delete_by_id(_id)
def update_user_role(_id, roles_list):
user = UserModel.query.filter_by(id=_id).first()
roles_id = []
for role in user.role:
roles_id.append(role.id)
roles = RoleModel.query.filter(RoleModel.id.in_(roles_id)).all()
for r in roles:
user.role.remove(r)
roles = RoleModel.query.filter(RoleModel.id.in_(roles_list)).all()
for r in roles:
user.role.append(r)
db.session.commit()
def users_delete():
"""批量删除"""
ids = request.form.getlist('ids[]')
batch_remove(ids)
return success_api(message="批量删除成功")
class QuerySchema(BaseModel):
page: int = 1
limit: int = 10
real_name: t.Optional[str] = Field(alias='realName')
username: t.Optional[str]
dept_id: t.Optional[str] = Field(alias='deptId', default=0)
phone: t.Optional[str]
sort: t.Optional[int]
status: t.Optional[int]
class PersonSchema(BaseModel):
role_ids: str = Field(alias='roleIds')
username: str
real_name: str = Field(alias='realName')
password: str
class UserApi(MethodView):
"""修改用户数据"""
@validate()
def get(self, _id, query: QuerySchema):
filters = []
if query.real_name:
filters.append(UserModel.realname.like('%' + query.real_name + '%'))
if query.username:
filters.append(UserModel.username.like('%' + query.username + '%'))
if query.dept_id:
filters.append(UserModel.dept_id == query.dept_id)
paginate = UserModel.query.filter(
*filters).paginate(
page=query.page, per_page=query.limit, error_out=False)
dept_name = lambda _id: DepartmentModel.query.filter_by(id=_id).first().dept_name if _id else ""
user_data = [{
'id': item.id,
'username': item.username,
'realname': item.realname,
'enable': item.enable,
'create_at': str(item.create_at),
'update_at': str(item.update_at),
'dept': dept_name(item.dept_id),
} for item in paginate.items]
return table_api(
result={
'items': user_data,
'total': paginate.total,
}
, code=0
)
@validate()
def post(self, body: PersonSchema):
"""新建单个用户"""
role_ids = body.role_ids.split(',')
if is_user_exists(body.username):
return fail_api(message="用户已经存在")
user = UserModel()
user.username = body.username
user.realname = body.real_name
user.set_password(body.password)
db.session.add(user)
db.session.commit()
""" 增加用户角色 """
user = UserModel.query.filter_by(id=user.id).first()
roles = RoleModel.query.filter(RoleModel.id.in_(role_ids)).all()
for r in roles:
user.role.append(r)
db.session.commit()
return success_api(message="增加成功", code=0)
def delete(self, _id):
# 删除用户
res = delete_by_id(_id)
if not res:
return fail_api(message="删除失败")
return success_api(message="删除成功")
class PersonModel2(BaseModel):
role_ids: str = Field(alias='roleIds')
user_id: str = Field(alias='userId')
username: str
real_name: str = Field(alias='realName')
dept_id: str = Field(alias='deptId')
@validate()
def user_role_resource(_id, body: PersonModel2):
role_ids = body.role_ids.split(',')
# 更新用户数据
UserModel.query.filter_by(id=_id).update({'username': body.username,
'realname': body.real_name,
'dept_id': body.dept_id})
db.session.commit()
update_user_role(_id, role_ids)
return success_api(message="更新成功")
def user_info(_id, action):
if action == 'info':
real_name = request.json.get('realName', '')
username = request.json.get('username', '')
remark = request.json.get('remark', '')
details = request.json.get('details', '')
ret = UserModel.query.get(_id)
ret.username = username
ret.realname = real_name
ret.remark = details
db.session.commit()
if not ret:
return fail_api(message="出错啦")
return success_api(message="更新成功")
elif action == 'status':
user_id = int(request.json.get('userId', 0)) # int
operate = int(request.json.get('operate', 0)) # int
if operate not in [0, 1]:
return {'status': 'error', 'message': '请求有误'}
if operate == 1:
user = UserModel.query.get(_id)
user.enable = operate
message = success_api(message="启动成功")
else:
user = UserModel.query.filter_by(id=user_id).update({"enable": operate})
message = success_api(message="禁用成功")
if user:
db.session.commit()
else:
return fail_api(message="出错啦")
return message
elif action == 'avatar':
url = request.json.get("avatar").get("src")
ret = UserModel.query.get(_id)
ret.avatar = url
db.session.commit()
if not ret:
return fail_api(message="出错啦")
return success_api(message="修改成功")
elif action == 'password':
oldPassword = request.json.get('oldPassword', '')
newPassword = request.json.get('newPassword', '')
confirmPassword = request.json.get('confirmPassword', '')
if not all([oldPassword, newPassword, confirmPassword]):
return {'status': 'error', 'message': '密码不能为空'}
if newPassword != confirmPassword:
return fail_api(message='确认密码不一致')
""" 修改当前用户密码 """
user = UserModel.query.get(_id)
is_right = user.validate_password(oldPassword)
if not is_right:
return jsonify(success=False, message="旧密码错误")
user.set_password(newPassword)
db.session.add(user)
db.session.commit()
return jsonify(success=True, message="更改成功")
else:
return jsonify(success=False, message="操作有误")
+17 -18
View File
@@ -1,20 +1,19 @@
from flask import Flask from applications.view.system import register_system_bps
from applications.view.public import bp as public_bp, register_public_bp # public 包的入口就是 nav_bp
from .index import index_bp from applications.view.health import bp as health_bp
from .logs_view import logs_bp from applications.extensions.init_plugins import broadcast_execute
from .roles import role_bp
from .system_view import system_bp
from .document_view import document_bp
from . import department
from . import file
from . import rights
from . import users
def init_view(app: Flask): def init_bps(app):
app.register_blueprint(index_bp) # 健康检查端点(无需登录,应在负载均衡/容器探活里使用)
app.register_blueprint(logs_bp) app.register_blueprint(health_bp)
app.register_blueprint(role_bp)
app.register_blueprint(system_bp) # 前台公开蓝图(无需登录)
app.register_blueprint(document_bp) app.register_blueprint(public_bp)
register_public_bp(app)
# 后台系统蓝图(需要登录 + 权限码)
register_system_bps(app)
# 插件初始化函数
broadcast_execute(app, 'event_init')
-21
View File
@@ -1,21 +0,0 @@
from flask import render_template, request
from models import DepartmentModel
from common.utils.rights import permission_required, view_logging_required
from applications.view import index_bp
@index_bp.get('/dept/add')
@view_logging_required
@permission_required("admin:dept:add")
def add():
return render_template('view/system/department_add.html')
@index_bp.get('/dept/edit')
@view_logging_required
@permission_required("admin:dept:edit")
def edit():
dept_id = request.args.get("deptId", type=int)
dept = DepartmentModel.query.get(dept_id)
return render_template('view/system/department_edit.html', dept=dept)
-18
View File
@@ -1,18 +0,0 @@
from flask import Blueprint, render_template
document_bp = Blueprint('document', __name__)
@document_bp.route('/component/code/index.html')
def component_code():
return render_template('component/code/index.html')
@document_bp.route('/view/<document>/<file>')
def document_view(document, file):
return render_template(f'view/{document}/{file}')
@document_bp.get('/login.html')
def login_html():
return render_template('login.html')
-18
View File
@@ -1,18 +0,0 @@
from flask import render_template
from applications.view import index_bp
from common.utils.rights import view_logging_required, permission_required
@index_bp.get('/file')
@view_logging_required
@permission_required("admin:file:main")
def file_index():
return render_template('view/system/photo.html')
@index_bp.get('/file/photo/add')
@view_logging_required
@permission_required("admin:file:main")
def file_photo_add():
return render_template('view/system/photo_add.html')
+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)
-78
View File
@@ -1,78 +0,0 @@
from flask import Blueprint, redirect, url_for
from flask import send_file
from flask import session, render_template
from flask_login import current_user, logout_user, login_required
from common.gen_captcha import get_captcha_image
index_bp = Blueprint('index', __name__)
@index_bp.route('/')
@index_bp.route('/admin')
@login_required
def index():
return render_template('index.html')
@index_bp.route('/config/pear.config.json')
def pear_config():
return send_file('static/config/pear.config.json')
@index_bp.route('/admin/data/menu.json')
def menu():
return send_file('static/admin/data/menu.json')
@index_bp.route('/admin/data/message.json')
def message():
return send_file('static/admin/data/message.json')
@index_bp.route('/view/console/console1.html')
def console1():
# 控制后台
return render_template('view/console/console1.html')
@index_bp.route('/view/console/console2.html')
def console2():
# 数据分析
return render_template('view/console/console2.html')
@index_bp.route('/view/system/theme.html')
def theme():
# 酸爽翻倍
return render_template('view/system/theme.html')
@index_bp.route('/view/document/core.html')
def core():
# 酸爽翻倍
return render_template('view/document/core.html')
@index_bp.get('/passport/getCaptcha')
def get_captcha():
resp, code = get_captcha_image()
session["code"] = code
return resp
# 退出登录
@index_bp.post('/logout')
@login_required
def logout():
logout_user()
session.pop('permissions')
print({"message": "注销成功", "success": True})
return {"message": "注销成功", "success": True}
@index_bp.get('/login')
def login():
if current_user.is_authenticated:
return redirect(url_for('admin.index'))
return render_template('login.html')
-63
View File
@@ -1,63 +0,0 @@
from flask import Blueprint, request, render_template
from sqlalchemy import desc
from common.utils.http import table_api
from common.utils.rights import permission_required
from models import LogModel
logs_bp = Blueprint('logs', __name__, url_prefix='/logs')
@logs_bp.get('/login_log')
@permission_required("admin:log:main")
def login_log():
page = request.args.get('page', type=int)
limit = request.args.get('limit', type=int)
log_paginate = LogModel.query.filter_by(
url='/api/v1/passport/login').order_by(
desc(LogModel.create_at)).paginate(
page=page, per_page=limit, error_out=False)
data = [
{
'id': item.id,
'method': item.method,
'uid': item.uid,
'url': item.url,
'desc': item.desc,
'ip': item.ip,
'success': item.success,
'user_agent': item.user_agent,
'create_at': item.create_at.strftime('%Y-%m-%d %H:%M:%S'),
} for item in log_paginate.items
]
return table_api(result={'items': data,
'total': log_paginate.total, },
code=0)
@logs_bp.get('/access_log')
@permission_required("admin:log:main")
def operate_log():
page = request.args.get('page', type=int)
limit = request.args.get('limit', type=int)
log_paginate = LogModel.query.filter(
LogModel.url != '/api/v1/passport/login').order_by(
desc(LogModel.create_at)).paginate(
page=page, per_page=limit, error_out=False)
data = [
{
'id': item.id,
'method': item.method,
'uid': item.uid,
'url': item.url,
'desc': item.desc,
'ip': item.ip,
'success': item.success,
'user_agent': item.user_agent,
'create_at': item.create_at.strftime('%Y-%m-%d %H:%M:%S'),
} for item in log_paginate.items
]
return table_api(result={'items': data,
'total': log_paginate.total, },
code=0)
+22
View File
@@ -0,0 +1,22 @@
"""
前台公开蓝图:导航聚合页 / 分类详情页 / 关于本站
URL 前缀:/site(避免与后台 / 路由冲突)
鉴权要求:无(任何用户都可访问,含未登录匿名用户)
注意:不要给这些视图加 @authorize 装饰器,否则会强制登录_required。
"""
from applications.view.public.nav import bp as nav_bp
from applications.view.public.about import bp as about_bp
from applications.view.public.friend import bp as friend_bp
# 把 nav 蓝图作为 public 包的对外入口
bp = nav_bp
def register_public_bp(app):
"""注册 public 子蓝图"""
app.register_blueprint(about_bp)
app.register_blueprint(friend_bp)
# /healthz 已在 applications/view/health.py 注册(位于 applications/view/__init__.py),不在这里重复
+49
View File
@@ -0,0 +1,49 @@
"""
前台公开"关于本站"页面。
不需要登录,匿名可访问。Markdown 在模板里渲染。
"""
import re
import markdown as _markdown
from flask import Blueprint, render_template
from applications.models import About
bp = Blueprint('public_about', __name__, url_prefix='/site')
def _get_about() -> About:
"""复用 model 的种子逻辑,避免空表时炸"""
a = About.query.get(1)
if a is None:
from applications.extensions import db
a = About(id=1, site_name='旺珂 · 导航')
db.session.add(a)
db.session.commit()
return a
@bp.get('/about')
def about():
a = _get_about()
html = _render_md(a.content_md)
return render_template('public/about.html', about=a, content_html=html)
_URL_RE = re.compile(r'(https?://[^\s)<>"\\]+|[\w.+-]+@[\w-]+\.[\w.-]+)')
def _render_md(text: str) -> str:
"""Markdown → HTML;自动把裸链接/邮箱变可点;同时禁掉 script/iframe。"""
if not text or not text.strip():
return ''
html = _markdown.markdown(
text,
extensions=['fenced_code', 'tables', 'nl2br', 'sane_lists'],
output_format='html5',
)
# 防御:清掉 <script>/<iframe>
html = re.sub(r'<\s*script[^>]*>.*?<\s*/\s*script\s*>', '', html, flags=re.I | re.S)
html = re.sub(r'<\s*iframe[^>]*>.*?<\s*/\s*iframe\s*>', '', html, flags=re.I | re.S)
return html
+41
View File
@@ -0,0 +1,41 @@
"""
前台友情链接页视图(无需登录)。
URL
- GET /site/friend 按分组聚合展示所有启用友情链接
注:不是 /site/friends(避免与系统内部 hyperlink 对齐)
"""
from collections import OrderedDict
from flask import Blueprint, render_template
from applications.models import Friend
bp = Blueprint('public_friend', __name__, url_prefix='/site')
@bp.get('/friend')
def friend_index():
"""
公开友情链接页:按 category 分组聚合展示
"""
items = (
Friend.query
.filter(Friend.enable == 1)
.order_by(Friend.category.asc(), Friend.sort.asc(), Friend.id.asc())
.all()
)
groups = OrderedDict()
for item in items:
if not item.url:
continue
groups.setdefault(item.category or '推荐友链', []).append(item.to_dict())
total = sum(len(g) for g in groups.values())
return render_template(
'public/friend.html',
groups=groups,
total=total,
site_name='旺珂 · 导航',
)
+164
View File
@@ -0,0 +1,164 @@
"""
前台导航页视图(无需登录)。
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, NavCategory
from plugins.siteStats.view.stat import get_nav_visit_counts, my_top_navs
# 「我常访问」冷启动阈值:至少点过这么多个不同网址才展示个人区
MINE_MIN_ITEMS = 2
bp = Blueprint('public_nav', __name__, url_prefix='/site')
@bp.get('/')
def index():
"""
公开首页:按 category 分组聚合,渲染模板
"""
return _render_index()
@bp.get('/category/<string:name>')
def category_detail(name: str):
"""
分类详情页:列出指定分类下所有启用的导航
"""
items = (
Nav.query
.filter(Nav.category == name, Nav.enable == 1)
.order_by(Nav.sort.asc(), Nav.id.asc())
.all()
)
counts = get_nav_visit_counts()
dict_items = []
for it in items:
d = it.to_dict()
d['visit_count'] = counts.get(it.id, 0)
dict_items.append(d)
return render_template(
'public/category.html',
category=name,
items=dict_items,
site_name='旺珂 · 导航',
)
@bp.get('/api/navs')
def api_navs():
"""
JSON 接口:返回按分类聚合的导航数据
可用于首页卡片动态加载、第三方嵌入等
"""
return jsonify({
'success': True,
'msg': '请求成功',
'data': _grouped_navs(),
})
def render_public_index():
"""给外部复用:渲染前台公开导航首页(无需登录)。"""
return _render_index()
def get_category_meta() -> "dict[str, dict]":
"""给外部复用:返回启用的分类元信息。"""
return _category_meta()
def _render_index():
groups = _grouped_navs()
total = sum(len(items) for items in groups.values())
mine = my_top_navs(8)
return render_template(
'public/index.html',
groups=groups,
total=total,
site_name='旺珂 · 导航',
cat_meta=_category_meta(),
top_navs=_top_navs(12),
my_navs=mine if len(mine) >= MINE_MIN_ITEMS else [],
)
def _top_navs(limit: int = 12) -> "list[dict]":
"""跨分类聚合:返回点击数 TOP N 的有效 nav 列表(按点击数降序)。
- 复用 get_nav_visit_counts() 的 60s 进程内缓存
- 0 点击时返回空列表(调用方负责隐藏整个分类块)
- 同一个 nav 不可能重复出现(按 nav.id 维度)
"""
counts = get_nav_visit_counts()
if not counts:
return []
# 取点击数最高的 N 个 nav_id(点击数相同的按 id asc 稳定排序)
top_ids = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[:limit]
nav_ids = [nid for nid, _ in top_ids]
rows = (
Nav.query
.filter(Nav.id.in_(nav_ids), Nav.enable == 1)
.all()
)
by_id = {n.id: n.to_dict() for n in rows}
out = []
for nid in nav_ids:
if nid in by_id:
d = by_id[nid]
d['visit_count'] = counts.get(nid, 0)
out.append(d)
return out
def _grouped_navs() -> "OrderedDict[str, list]":
"""按 category 分组,组内按 sort asc 排序;分组顺序优先按 NavCategory.sort 排列"""
# 先取启用的分类,按 sort 排好序
cats = (
NavCategory.query
.filter(NavCategory.enable == 1)
.order_by(NavCategory.sort.desc(), NavCategory.id.asc())
.all()
)
ordered_names = [c.name for c in cats]
cat_order = {name: idx for idx, name in enumerate(ordered_names)}
items = (
Nav.query
.filter(Nav.enable == 1)
.order_by(Nav.category.asc(), Nav.sort.asc(), Nav.id.asc())
.all()
)
# 一次性取所有 nav 的点击数(带缓存)
counts = get_nav_visit_counts()
groups = OrderedDict()
# 按 cat_order 初始化空分组,保证顺序
for name in ordered_names:
groups[name] = []
for item in items:
d = item.to_dict()
d['visit_count'] = counts.get(item.id, 0)
if item.category in groups:
groups[item.category].append(d)
else:
# 对于未在 NavCategory 登记的历史分类,放在最后
groups.setdefault(item.category, []).append(d)
# 过滤空分组(启用的分类下可能没有导航)
return OrderedDict((k, v) for k, v in groups.items() if v)
def _category_meta() -> "dict[str, dict]":
"""返回分类元信息 {name: {icon, description, ...}},用于抽屉图标等"""
cats = NavCategory.query.filter(NavCategory.enable == 1).all()
return {c.name: {'icon': c.icon or 'layui-icon-list', 'description': c.description or ''} for c in cats}
-25
View File
@@ -1,25 +0,0 @@
from flask import render_template
from common.utils.rights import permission_required, view_logging_required
from models import RightModel
from applications.view import index_bp
@index_bp.get('/rights/power/<int:power_id>')
@view_logging_required
@permission_required("admin:power:edit")
def rights_edit(power_id):
power = RightModel.query.filter_by(id=power_id).first()
icon = str(power.icon).split()
if len(icon) == 2:
icon = icon[1]
else:
icon = None
return render_template('view/system/power_edit.html', power=power, icon=icon)
@index_bp.get('/rights/add')
@view_logging_required
@permission_required("admin:power:main")
def rights_add():
return render_template('view/system/power_add.html')
-31
View File
@@ -1,31 +0,0 @@
from flask import Blueprint, render_template
from common.utils.rights import permission_required, view_logging_required
from models import RoleModel
role_bp = Blueprint('role', __name__, url_prefix='/admin/role')
# 角色授权操作
@role_bp.get('/power/<int:role_id>')
@view_logging_required
@permission_required("admin:role:power")
def power(role_id):
return render_template('view/system/roles_power.html', role_id=role_id)
# 角色编辑
@role_bp.get('/edit/<int:role_id>')
@view_logging_required
@permission_required("admin:role:edit")
def role_editor(role_id):
role = RoleModel.query.filter_by(id=role_id).first()
return render_template('view/system/roles_edit.html', role=role)
@role_bp.get('/add')
@view_logging_required
@permission_required("admin:role:edit")
def role_add():
return render_template('view/system/roles_add.html')
+35
View File
@@ -0,0 +1,35 @@
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
# 4 个业务模块(nav / friend / about / stat)已迁出到 plugins/* ,下文的 register 也不再挂载
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)
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="删除成功")
+85
View File
@@ -0,0 +1,85 @@
import os
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.rights import authorize
from applications.extensions import db
from applications.extensions.init_limit import limiter
from applications.models import Photo
from applications.common.utils import upload as upload_curd
bp = Blueprint('adminFile', __name__, url_prefix='/file')
# 图片管理
@bp.get('/')
@authorize("system:file:main")
def index():
return render_template('system/photo/photo.html')
# 图片数据
@bp.get('/table')
@limiter.limit("60 per minute")
@authorize("system:file:main")
def table():
page = request.args.get('page', type=int)
limit = request.args.get('limit', type=int)
data, count = upload_curd.get_photo(page=page, limit=limit)
return table_api(data=data, count=count)
# 上传
@bp.get('/upload')
@authorize("system:file:add", log=True)
def upload():
return render_template('system/photo/photo_add.html')
# 上传接口
@bp.post('/upload')
@authorize("system:file:add", log=True)
def upload_api():
if 'file' in request.files:
photo = request.files['file']
mime = request.files['file'].content_type
file_url = upload_curd.upload_one(photo=photo, mime=mime)
res = {
"msg": "上传成功",
"code": 0,
"success": True,
"data":
{"src": file_url}
}
return jsonify(res)
return fail_api()
# 图片删除
@bp.route('/delete', methods=['GET', 'POST'])
@authorize("system:file:delete", log=True)
def delete():
_id = request.form.get('id')
res = upload_curd.delete_photo_by_id(_id)
if res:
return success_api(msg="删除成功")
else:
return fail_api(msg="删除失败")
# 图片批量删除
@bp.route('/batchRemove', methods=['GET', 'POST'])
@authorize("system:file:delete", log=True)
def batch_remove():
ids = request.form.getlist('ids[]')
photo_name = Photo.query.filter(Photo.id.in_(ids)).all()
upload_url = current_app.config.get("UPLOADED_PHOTOS_DEST")
for p in photo_name:
os.remove(upload_url + '/' + p.name)
photo = Photo.query.filter(Photo.id.in_(ids)).delete(synchronize_session=False)
db.session.commit()
if photo:
return success_api(msg="删除成功")
else:
return fail_api(msg="删除失败")
+18
View File
@@ -0,0 +1,18 @@
from flask import Blueprint, render_template
from flask_login import login_required, current_user, login_fresh
from applications.view.public.nav import render_public_index
bp = Blueprint('index', __name__, url_prefix='/')
# 首页:未登录 → 前台公开导航聚合页;已登录 → 跳到工作台
@bp.get('/')
def index():
# 已登录用户访问根路径时,直接进后台工作台;
# 匿名访问则直接渲染前台导航页(不重定向,地址栏保持 /)
if current_user.is_authenticated:
return render_template('system/index.html', user=current_user)
return render_public_index()
+43
View File
@@ -0,0 +1,43 @@
from flask import Blueprint, request, render_template
from sqlalchemy import desc
from applications.common.utils.http import table_api
from applications.common.utils.rights import authorize
from applications.models import AdminLog
from applications.schemas import LogOutSchema
from applications.common.curd import model_to_dicts
bp = Blueprint('log', __name__, url_prefix='/log')
# 日志管理
@bp.get('/')
@authorize("system:log:main")
def index():
return render_template('system/admin_log/main.html')
# 登录日志
@bp.get('/loginLog')
@authorize("system:log:main")
def login_log():
# orm查询
# 使用分页获取data需要.items
log = AdminLog.query.filter_by(
url='/system/passport/login'
).order_by(desc(AdminLog.create_time)).layui_paginate()
count = log.total
return table_api(data=model_to_dicts(schema=LogOutSchema, data=log.items), count=count)
# 操作日志
@bp.get('/operateLog')
@authorize("system:log:main")
def operate_log():
# orm查询
# 使用分页获取data需要.items
log = AdminLog.query.filter(
AdminLog.url != '/system/passport/login'
).order_by(
desc(AdminLog.create_time)).layui_paginate()
count = log.total
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)
+91
View File
@@ -0,0 +1,91 @@
from flask import Blueprint, session, redirect, url_for, render_template, request
from flask_login import current_user, login_user, login_required, logout_user
from applications.common.admin import get_captcha, login_log
from applications.common.utils.http import fail_api, success_api
from applications.models import User
bp = Blueprint('passport', __name__, url_prefix='/passport')
# 获取验证码
@bp.get('/getCaptcha')
def captcha():
resp, code = get_captcha()
session["code"] = code
return resp
# 登录
@bp.get('/login')
def login():
if current_user.is_authenticated:
return redirect(url_for('index.index'))
return render_template('system/login.html')
# 登录
@bp.post('/login')
def login_post():
req = request.form
username = req.get('username')
password = req.get('password')
remember = bool(req.get('remember-me'))
code = req.get('captcha').__str__().lower()
if not username or not password or not code:
return fail_api(msg="用户名或密码没有输入")
s_code = session.get("code", None)
session["code"] = None
if not all([code, s_code]):
return fail_api(msg="参数错误")
if code != s_code:
return fail_api(msg="验证码错误")
user = User.query.filter_by(username=username).first()
if not user:
return fail_api(msg="不存在的用户")
if user.enable == 0:
return fail_api(msg="用户被暂停使用")
if username == user.username and user.validate_password(password):
# 登录
login_user(user, remember=remember)
# 记录登录日志
login_log(request, uid=user.id, is_access=True)
# 授权路由存入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="登录成功")
login_log(request, uid=user.id, is_access=False)
return fail_api(msg="用户名或密码错误")
# 退出登录
@bp.post('/logout')
@login_required
def logout():
logout_user()
if 'permissions' in session:
session.pop('permissions')
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="角色删除成功")
+236
View File
@@ -0,0 +1,236 @@
from flask import Blueprint, render_template, request
from flask_login import login_required, current_user
from sqlalchemy import desc
from applications.common import curd
from applications.common.curd import enable_status, disable_status
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 Role, Dept
from applications.models import User, AdminLog
bp = Blueprint('user', __name__, url_prefix='/user')
# 用户管理
@bp.get('/')
@authorize("system:user:main")
def main():
return render_template('system/user/main.html')
# 用户分页查询
@bp.get('/data')
@limiter.limit("60 per minute")
@authorize("system:user:main")
def data():
# 获取请求参数
real_name = str_escape(request.args.get('realname', type=str))
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)
# 用户增加
@bp.get('/add')
@authorize("system:user:add", log=True)
def add():
roles = Role.query.all()
return render_template('system/user/add.html', roles=roles)
@bp.post('/save')
@authorize("system:user:add", log=True)
def save():
req_json = request.get_json(force=True)
a = req_json.get("roleIds")
username = str_escape(req_json.get('username'))
real_name = str_escape(req_json.get('realName'))
password = str_escape(req_json.get('password'))
dept_id = str_escape(req_json.get('deptId'))
role_ids = a.split(',')
if not username or not real_name or not password:
return fail_api(msg="账号姓名密码不得为空")
if bool(User.query.filter_by(username=username).count()):
return fail_api(msg="用户已经存在")
user = User(username=username, realname=real_name, enable=1, dept_id=dept_id)
user.set_password(password)
db.session.add(user)
roles = Role.query.filter(Role.id.in_(role_ids)).all()
for r in roles:
user.role.append(r)
db.session.commit()
return success_api(msg="增加成功")
# 删除用户
@bp.delete('/remove/<int:id>')
@authorize("system:user:remove", log=True)
def delete(id):
user = User.query.filter_by(id=id).first()
user.role = []
res = User.query.filter_by(id=id).delete()
db.session.commit()
if not res:
return fail_api(msg="删除失败")
return success_api(msg="删除成功")
# 编辑用户
@bp.get('/edit/<int:id>')
@authorize("system:user:edit", log=True)
def edit(id):
user = curd.get_one_by_id(User, id)
roles = Role.query.all()
checked_roles = []
for r in user.role:
checked_roles.append(r.id)
return render_template('system/user/edit.html', user=user, roles=roles, checked_roles=checked_roles)
# 编辑用户
@bp.put('/update')
@authorize("system:user:edit", log=True)
def update():
req_json = request.get_json(force=True)
a = str_escape(req_json.get("roleIds"))
id = str_escape(req_json.get("userId"))
username = str_escape(req_json.get('username'))
real_name = str_escape(req_json.get('realName'))
dept_id = str_escape(req_json.get('deptId'))
role_ids = a.split(',')
User.query.filter_by(id=id).update({'username': username, 'realname': real_name, 'dept_id': dept_id})
u = User.query.filter_by(id=id).first()
roles = Role.query.filter(Role.id.in_(role_ids)).all()
u.role = roles
db.session.commit()
return success_api(msg="更新成功")
# 个人中心
@bp.get('/center')
@login_required
def center():
user_info = current_user
user_logs = AdminLog.query.filter_by(url='/passport/login').filter_by(uid=current_user.id).order_by(
desc(AdminLog.create_time)).limit(10)
return render_template('system/user/center.html', user_info=user_info, user_logs=user_logs)
# 修改头像
@bp.get('/profile')
@login_required
def profile():
return render_template('system/user/profile.html')
# 修改头像
@bp.put('/updateAvatar')
@login_required
def update_avatar():
url = request.get_json(force=True).get("avatar").get("src")
r = User.query.filter_by(id=current_user.id).update({"avatar": url})
db.session.commit()
if not r:
return fail_api(msg="出错啦")
return success_api(msg="修改成功")
# 修改当前用户信息
@bp.put('/updateInfo')
@login_required
def update_info():
req_json = request.get_json(force=True)
r = User.query.filter_by(id=current_user.id).update(
{"realname": req_json.get("realName"), "remark": req_json.get("details")})
db.session.commit()
if not r:
return fail_api(msg="出错啦")
return success_api(msg="更新成功")
# 修改当前用户密码
@bp.get('/editPassword')
@login_required
def edit_password():
return render_template('system/user/edit_password.html')
# 修改当前用户密码
@bp.put('/editPassword')
@login_required
def edit_password_put():
res_json = request.get_json(force=True)
if res_json.get("newPassword") == '':
return fail_api("新密码不得为空")
if res_json.get("newPassword") != res_json.get("confirmPassword"):
return fail_api("两次密码不一样")
user = current_user
is_right = user.validate_password(res_json.get("oldPassword"))
if not is_right:
return fail_api("旧密码错误")
user.set_password(res_json.get("newPassword"))
db.session.add(user)
db.session.commit()
return success_api("更改成功")
# 启用用户
@bp.put('/enable')
@authorize("system:user:edit", log=True)
def enable():
_id = request.get_json(force=True).get('userId')
if _id:
res = enable_status(model=User, id=_id)
if not res:
return fail_api(msg="出错啦")
return success_api(msg="启动成功")
return fail_api(msg="数据错误")
# 禁用用户
@bp.put('/disable')
@authorize("system:user:edit", log=True)
def dis_enable():
_id = request.get_json(force=True).get('userId')
if _id:
res = disable_status(model=User, id=_id)
if not res:
return fail_api(msg="出错啦")
return success_api(msg="禁用成功")
return fail_api(msg="数据错误")
-48
View File
@@ -1,48 +0,0 @@
from flask import Blueprint, render_template
system_bp = Blueprint('system', __name__)
@system_bp.route('/view/system/user.html')
def system_user():
return render_template('view/system/user.html')
@system_bp.route('/view/system/role.html')
def system_role():
return render_template('view/system/role.html')
@system_bp.route('/view/system/power.html')
def system_power():
return render_template('view/system/power.html')
@system_bp.route('/view/system/department.html')
def system_department():
return render_template('view/system/department.html')
@system_bp.route('/view/system/log.html')
def system_log():
return render_template('view/system/log.html')
@system_bp.route('/view/system/dict.html')
def system_dict():
return render_template('view/system/dict.html')
@system_bp.route('/view/system/operate/add.html')
def system_operate_add():
return render_template('view/system/operate/add.html')
@system_bp.route('/view/system/operate/edit.html')
def system_operate_edit():
return render_template('view/system/operate/edit.html')
@system_bp.route('/view/system/operate/profile.html')
def system_operate_profile():
return render_template('view/system/operate/profile.html')

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