289 Commits
Author SHA1 Message Date
bwadmin e10ef5f243 feat(public): 主题切换改为下拉菜单 + 新增4个主题 + 优化搜索与抽屉交互
- 主题切换由循环切换改为弹出下拉菜单,显示全部可用主题
- 新增紫韵/深海/青空/玫红 4 个主题,共 8 个主题 + 跟随系统
- 清理顶部 .actions 菜单栏与旧主题下拉的残余死 CSS/JS
- 搜索无结果时显示空态提示
- 移动端抽屉打开时锁定背景滚动
- 搜索输入防抖 120ms
- 超宽屏下卡片描述放宽到 2 行,搜索框同步放大
- 修复抽屉主题按钮缺少 JS 绑定的 bug
- .card .url 对比度由 --text-soft 改为 --text-muted
2026-09-07 16:50:19 +08:00
bwadmin 4d6325c70e feat(public): 首页分类移至左侧侧栏 + 大屏自适应布局
- 分类导航由顶部横条改为左侧 sticky 侧栏(≥1025px),含快速导航/我常访问/热门/书签分类/站点页面
- 首页左右去留白:.container 与 .topbar-inner 改为 max-width:100%,布局铺满视口
- 卡片网格按屏宽自适应(minmax(var(--card-min),1fr)),1920 固定 6 列
- 移除顶部 .actions 菜单(与侧栏站点页面重复),主题切换改由侧栏按钮循环切换
- 新增 get_nav_categories() 统一锚点(侧栏/顶部/抽屉/section id 四者同源),修复空分类错位;后台分类增删改/启停后失效前台缓存
2026-09-07 11:11:22 +08:00
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
1102 changed files with 226996 additions and 103474 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
-29
View File
@@ -1,29 +0,0 @@
# flask配置
FLASK_APP=app.py
FLASK_ENV=development
FLASK_DEBUG=1
FLASK_RUN_HOST = 127.0.0.1
FLASK_RUN_PORT = 5000
# pear admin flask配置
SYSTEM_NAME = Pear Admin
# MySql配置信息
MYSQL_HOST=127.0.0.1
# MYSQL_HOST=dbserver
MYSQL_PORT=3306
MYSQL_DATABASE=PearAdminFlask
MYSQL_USERNAME=root
MYSQL_PASSWORD=123456
# Redis 配置
# REDIS_HOST=127.0.0.1
# REDIS_PORT=6379
# 密钥配置(记得改)
SECRET_KEY='pear-admin-flask'
# 邮箱配置
MAIL_SERVER='smtp.qq.com'
MAIL_USERNAME='123@qq.com'
MAIL_PASSWORD='XXXXX' # 生成的授权码
+52 -2
View File
@@ -114,5 +114,55 @@ dmypy.json
# Pyre type checker
.pyre/
#ide
.idea/
# idea
.idea/
# 迁移文件 — 必须入库,让别人的环境也能跑 flask db upgrade
!migrations/
# sqlite
*.db
# 数据目录(SQLite 库 + 备份 + session;不入库,部署时靠挂载恢复)
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/
-25
View File
@@ -1,25 +0,0 @@
FROM python:3.7-alpine
RUN apk update \
&& apk --update add --no-cache gcc \
&& apk --update add --no-cache g++ \
&& apk --update add --no-cache tzdata \
&& apk --update add --no-cache libffi-dev \
&& apk --update add --no-cache libxslt-dev \
&& apk --update add --no-cache jpeg-dev
ENV TIME_ZONE Asia/Shanghai
ENV PIPURL "https://pypi.tuna.tsinghua.edu.cn/simple"
RUN echo "${TIME_ZONE}" > /etc/timezone \
&& ln -sf /usr/share/zoneinfo/${TIME_ZONE} /etc/localtime
COPY . /app
WORKDIR /app
RUN pip --no-cache-dir install -i ${PIPURL} --upgrade pip \
&& pip --no-cache-dir install -i ${PIPURL} -r requirement/requirement-dev.txt \
&& pip --no-cache-dir install -i ${PIPURL} gunicorn \
&& chmod +x start.sh
CMD ./start.sh
+69
View File
@@ -0,0 +1,69 @@
# ============================================================
# Pear Admin Flask - Makefile(跨平台,Windows 下需用 Git Bash / WSL
#
# 用法(在项目根目录):
# make help 查看所有命令
# make venv 创建虚拟环境
# make install 安装依赖
# make migrate 执行 db migrate + upgrade
# make seed 导入 admin 基础数据(app_data/init.json
# make init 等价于 migrate + seed
# make run 开发模式启动(PEAR_ENV=dev
# make run-prod 生产模式启动(PEAR_ENV=production,要求 .env SECRET_KEY
# make clean 清理缓存与数据库(危险!请先备份)
# make health curl /healthz
# ============================================================
PY ?= python
VENV ?= venv
PYBIN := $(VENV)/bin/python
PIP := $(PYBIN) -m pip
FLASK := $(PYBIN) -m flask
APP := app.py
.PHONY: help venv install migrate seed init run run-prod clean health
help:
@echo "可用命令:"
@echo " make venv 创建虚拟环境"
@echo " make install 安装依赖"
@echo " make migrate db migrate + upgrade"
@echo " make seed flask admin init"
@echo " make init migrate + seed"
@echo " make run 开发模式启动(默认端口 5000)"
@echo " make run-prod 生产模式启动(强制要求 .env SECRET_KEY"
@echo " make health curl http://127.0.0.1:5000/healthz"
@echo " make clean 清理 pyc / __pycache__ / pear.db / flask_session"
venv:
@test -d "$(VENV)" || $(PY) -m venv $(VENV)
@echo "[venv] 已创建 $(VENV)"
install: venv
$(PIP) install --upgrade pip
$(PIP) install -r requirements.txt
migrate:
$(FLASK) db migrate
$(FLASK) db upgrade
seed:
$(FLASK) admin init
init: migrate seed
run:
PEAR_ENV=dev $(PYBIN) $(APP)
run-prod:
PEAR_ENV=production $(PYBIN) $(APP)
health:
@curl -sS http://127.0.0.1:5000/healthz | python -m json.tool
clean:
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true
find . -type f -name "*.pyc" -delete
rm -rf flask_session
@echo "[clean] 已清理缓存。注意:pear.db 请手动备份后删除。"
+165 -93
View File
@@ -6,17 +6,16 @@
</h1>
<h4 align="center">
开 箱 即 用 的 Flask 快 速 开 发 平 台
</h4>
[预 览](http://flask.pearadmin.com) | [官 网](http://www.pearadmin.com/) | [群聊](https://jq.qq.com/?_wv=1027&k=TkLAKUxC) | [社区](http://forum.pearadmin.com/)
</h4>
[预览](https://pear.lovepikachu.top/) | [官网](http://www.pearadmin.com/) | [群聊](docs/source/_static/qqgroup.jpg) | [文档](https://lab.lovepikachu.top/document/pear-admin-flask)
<p align="center">
<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 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 href="#">
<img src="https://img.shields.io/badge/Mysql-5.3.2+-green.svg" alt="Mysql Version">
@@ -25,37 +24,19 @@
</div>
<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>
#### 项目简介
>Pear Admin Flask 基于 Flask 的后台管理系统,拥抱应用广泛的python语言,通过使用本系统,即可快速构建你的功能业务
>
>项目旨在为python开发者提供一个后台管理系统的模板,成为您构建信息管理系统,物联网后台....等等应用时灵活,简单的工具
>
>众人拾柴火焰高,欢迎pythoner参与项目~
# 项目简介
Pear Admin Flask 有以下几个版本:
> **⚠️注意** Pear Admin Flask 已同步主项目 Pear Admin Layui 4.0,部分页面变化较大,如果是从旧的框架迁移,请查阅 [从旧项目迁移](https://lab.lovepikachu.top/document/pear-admin-flask/welcome/migration.html) 章节。另外,可以在此查看 [更新日志](https://lab.lovepikachu.top/document/pear-admin-flask/welcome/update.html#v2-0-0-4-0-5) 。
[master分支版本 ](https://gitee.com/pear-admin/pear-admin-flask/tree/master/)
Pear Admin Flask 基于 Flask 的后台管理系统,拥抱应用广泛的python语言,通过使用本系统,即可快速构建你的功能业务
项目旨在为 python 开发者提供一个后台管理系统的模板,可以快速构建信息管理系统。
> flask 2.0.1 + flask-sqlalchemy + mysql + 权限验证 + Flask-APScheduler 定时任务 + marshmallow 序列化与数据验证
项目使用 flask-sqlalchemy + 权限验证 + marshmallow 序列化与数据验证,以此方式集成了若干不同的功能。
[Mini 分支版本 ](https://gitee.com/pear-admin/pear-admin-flask/tree/mini/)
>flask 2.0.1 + flask-sqlalchemy + sqllite + 权限验证 + Flask-RESTful 序列化与数据验证
[v1 分支版本(不再更新,仅供参考) ](https://gitee.com/pear-admin/pear-admin-flask/tree/v1/)
>flask 1.12 + flask-sqlalchemy + mysql + 权限验证 + marshmallow 序列化与数据验证
[simple 分支版本(不再更新,仅供参考) ](https://gitee.com/pear-admin/pear-admin-flask/tree/simple/)
>flask 1.12 + flask-sqlalchemy + mysql + 极简权限 + marshmallow 序列化与数据验证
>
>项目最简版本
#### 内置功能
# 内置功能
- [x] 用户管理:用户是系统操作者,该功能主要完成系统用户配置。
- [x] 权限管理:配置系统菜单,操作权限,按钮权限标识等。
@@ -64,101 +45,192 @@ Pear Admin Flask 有以下几个版本:
- [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
├─applications # 应用
│ ├─configs # 配置文件
│ ├─ common.py # 普通配置
│ └─ config.py # 配置文件对象
│ ├─extensions # 注册插件
├─models # 数据模型
│ ├─static # 静态资源文件
│ ├─templates # 静态模板文件
└─views # 视图部分
├─admin # 后台管理视图模块
│ └─index # 前台视图模块
├─docs # 文档说明(占坑)
├─migrations # 迁移文件记录
├─requirement # 依赖文件
├─test # 测试文件夹(占坑)
└─.env # 项目的配置文件
├─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
# 安 装
pip install -r requirement\requirement-dev.txt
# 配 置
.env
cd pear-admin-flask # 进入到项目目录
```
#### 修改配置
## 修改配置
> **💡提示** 配置文件位于 `applications/config.py` ,打开配置文件看到的是位于 `BaseConfig` 类下的默认配置文件,您可以编写自己的配置类并继承 `BaseConfig` 类。
项目启动时,会调用 `applications/__init__.py` ,这个文件中加载了程序的配置,所以在您编写了自己的类后不要忘记在文件 `applications/__init__.py` 中修改使用的配置类。
> **⚠️注意** 配置文件中对于数据库的配置有所更改,请查看代码中的注释修改配置。
```python
.env
# MySql配置信息
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_DATABASE=PearAdminFlask
MYSQL_USERNAME=root
MYSQL_PASSWORD=root
# 部分配置信息如下所示
# Redis 配置
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
# 验证密钥(⚠️ 一定要记得修改 ⚠️
SECRET_KEY = "pear-system-flask"
# 密钥配置
SECRET_KEY='pear-admin-flask'
# 数据库的配置信息
SQLALCHEMY_DATABASE_URI = 'sqlite:///../pear.db'
# 邮箱配置
MAIL_SERVER='smtp.qq.com'
MAIL_USERNAME='123@qq.com'
MAIL_PASSWORD='XXXXX' # 生成的授权码
# 默认日志等级
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
```
#### Venv 安装
## 虚拟环境安装项目(推荐)
> **💡提示** 为了保证项目所依赖的库不影响其他部署在同一主机上的项目,我们推荐使用虚拟环境安装。
```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
# 初 始 化 数 据 库
flask init
# 使用 pip 安装
pip install -r requirements.txt
# 同时你可以选择以模块的方式调用 pip
python -m pip install -r requirements.txt
```
执行 flask run 命令启动项目
# 运行项目
#### 命令行创建视图
+ 一般情况运行项目
```bash
# 示例
# 初始化数据库
flask db init
flask db migrate
flask db upgrade
flask admin init
flask new --type view --name test/a
# 运行项目
flask --app app.py run -h 0.0.0.0 -p 8000 --debug
# 自动注册蓝图
# 访问http://127.0.0.1:5000/test/a/
# 或者直接调用 app.py
python app.py
```
#### 预览项目
+ 使用 docker-compose 运行项目
```bash
git clone https://gitee.com/pear-admin/pear-admin-flask
# 安装 docker-compose
curl -L https://github.com/docker/compose/releases/download/1.26.2/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose
ln -s /usr/local/bin/docker-compose /usr/bin/docker-compose
# 运行如下命令,有输出版本,表示 docker-compose 可以用了
docker-compose --version
# 在当前目录执行如下命令即可以运行 app
docker-compose -f dockercompose.yaml up
# 看到如下表示运行成功,由于 pip 下载慢,需要一些时间,请耐心等待;如果安装失败,重新执行上面的命令即可。
# 运行后在浏览器访问 127.0.0.1:5000
#如果要停止容器运行,在当前文件夹执行如下命令:
docker-compose -f dockercompose.yaml down
```
# 预览项目
| | |
| ---------------------- | ---------------------- |
| ![](docs/source/_static/1.jpg) | ![](docs/source/_static/2.jpg) |
| ![](docs/source/_static/3.jpg) | ![](docs/source/_static/6.jpg) |
# 其他说明
## 项目初始用户以及其密码
默认用户为 `admin` ,密码默认为 `123456`
| | |
|---------------------|---------------------|
| ![](docs/assets/1.jpg) | ![](docs/assets/2.jpg) |
| ![](docs/assets/3.jpg)| ![](docs/assets/4.jpg) |
| ![](docs/assets/5.jpg) | ![](docs/assets/6.jpg) |
+9 -5
View File
@@ -1,10 +1,14 @@
from applications import create_app
from flask_migrate import Migrate
from applications.extensions import db
app = create_app()
migrate = Migrate(app, db)
if __name__ == '__main__':
app.run()
# 绑定 0.0.0.0 后,同局域网手机可通过本机 IP 访问(例如 http://192.168.1.10:5000/site/
# debug 标志交给配置类决定:PEAR_ENV=dev → DEBUG=TruePEAR_ENV=production → DEBUG=False
# 防止重载进程抢占端口;局域网访问保持稳定
app.run(
host='0.0.0.0',
port=5000,
debug=app.config.get('DEBUG', False),
use_reloader=False,
)
+40 -29
View File
@@ -1,44 +1,55 @@
import os
from dotenv import load_dotenv
from flask import Flask
# 项目根目录 = 本文件父目录的父目录
BASE_DIR = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
# 必须先于 applications.config 加载:config 在模块级就要读 PEAR_DATA_DIR
# 等环境变量来拼 data 目录路径,晚一步就会沿用默认值。
load_dotenv(os.path.join(BASE_DIR, ".env"), override=False)
from applications.common.script import init_script
from applications.config import BaseConfig, get_config_by_name, ensure_data_dir
from applications.extensions import init_plugs
from applications.view import init_view
from applications.configs import config
from applications.view import init_bps
def create_app(config_name=None):
app = Flask(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
def create_app():
# 1) 再加载一次 .env(幂等;override=False 保证环境变量优先级更高)
load_dotenv(os.path.join(BASE_DIR, ".env"), override=False)
if not config_name:
# 尝试从本地环境中读取
config_name = os.getenv('FLASK_CONFIG', 'development')
app = Flask(BASE_DIR)
# 引入数据库配置
app.config.from_object(config[config_name])
# 2) 根据 PEAR_ENV / FLASK_ENV 选择配置类,默认 dev
env_name = os.environ.get("PEAR_ENV") or os.environ.get("FLASK_ENV") or "dev"
config_cls = get_config_by_name(env_name)
app.config.from_object(config_cls)
# 注册各种插件
# 3) 把 BaseConfig 默认值里没写、但 .env 可能写了的 SQLALCHEMY_DATABASE_URI 等再覆盖一次
if os.environ.get("SQLALCHEMY_DATABASE_URI"):
app.config["SQLALCHEMY_DATABASE_URI"] = os.environ["SQLALCHEMY_DATABASE_URI"]
# 3.5) 让配置类有机会做运行时校验(例如 ProConfig 强制要求 SECRET_KEY
if hasattr(config_cls, "init_app"):
config_cls.init_app(app)
# 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_view(app)
# 注册命令
init_bps(app)
init_script(app)
if os.environ.get('WERKZEUG_RUN_MAIN') == 'true':
logo()
# 5) 全站访问埋点已迁移到 plugins/siteStats@app.before_request → record_visit
return app
def logo():
print('''
_____ _ _ ______ _ _
| __ \ /\ | | (_) | ____| | | |
| |__) |__ __ _ _ __ / \ __| |_ __ ___ _ _ __ | |__ | | __ _ ___| | __
| ___/ _ \/ _` | '__| / /\ \ / _` | '_ ` _ \| | '_ \ | __| | |/ _` / __| |/ /
| | | __/ (_| | | / ____ \ (_| | | | | | | | | | | | | | | (_| \__ \ <
|_| \___|\__,_|_| /_/ \_\__,_|_| |_| |_|_|_| |_| |_| |_|\__,_|___/_|\_\\
''')
+82 -131
View File
@@ -1,69 +1,22 @@
import copy
from collections import OrderedDict
from io import BytesIO
from flask import session, make_response, current_app
from flask import make_response
from flask_login import current_user
from applications.common.utils.gen_captcha import gen_captcha
from applications.schemas import PowerOutSchema
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
# 授权路由存入session
def add_auth_session():
role = current_user.role
user_power = []
for i in role:
if i.enable == 0:
continue
for p in i.power:
if p.enable == 0:
continue
user_power.append(p.code)
session['permissions'] = user_power
# 生成菜单树
def make_menu_tree():
role = current_user.role
powers = []
for i in role:
# 如果角色没有被启用就直接跳过
if i.enable == 0:
continue
# 变量角色用户的权限
for p in i.power:
# 如果权限关闭了就直接跳过
if p.enable == 0:
continue
# 一二级菜单
if int(p.type) in [0,1] and p not in powers:
powers.append(p)
power_schema = PowerOutSchema(many=True) # 用已继承 ma.ModelSchema 类的自定制类生成序列化类
power_dict = power_schema.dump(powers) # 生成可序列化对象
power_dict.sort(key=lambda x: (x['parent_id'], x['id']), reverse=True)
menu_dict = OrderedDict()
for _dict in power_dict:
if _dict['id'] in menu_dict:
# 当前节点添加子节点
_dict['children'] = copy.deepcopy(menu_dict[_dict['id']])
_dict['children'].sort(key=lambda item: item['sort'])
# 删除子节点
del menu_dict[_dict['id']]
if _dict['parent_id'] not in menu_dict:
menu_dict[_dict['parent_id']] = [_dict]
else:
menu_dict[_dict['parent_id']].append(_dict)
return sorted(menu_dict.get(0), key=lambda item: item['sort'])
# 生成验证码
def get_captcha():
code, image = gen_captcha()
"""
生成验证码图片及其对应的验证码字符串。
:return: 返回验证码图片的响应对象和验证码字符串。
"""
image, code = vieCode().GetCodeImage()
code = ''.join(code).lower()
out = BytesIO()
session["code"] = code
image.save(out, 'png')
out.seek(0)
resp = make_response(out.read())
@@ -71,75 +24,73 @@ def get_captcha():
return resp, code
def get_render_config():
# 网站配置
config = dict(logo={
# 网站名称
"title": current_app.config.get("SYSTEM_NAME"),
# 网站图标
"image": "/static/admin/admin/images/logo.png"
# 菜单配置
}, menu={
# 菜单数据来源
"data": "/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,
# 最大可打开的选项卡数量
"max": 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 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)
-57
View File
@@ -1,57 +0,0 @@
from flask_login import current_user
from applications.common.utils.validate import xss_escape
from applications.extensions import db
from applications.models import AdminLog
def login_log(request, uid, is_access):
info = {
'method': request.method,
'url': request.path,
'ip': request.remote_addr,
'user_agent': xss_escape(request.headers.get('User-Agent')),
'desc': xss_escape(request.form.get('username')),
'uid': uid,
'success': int(is_access)
}
log = AdminLog(
url=info.get('url'),
ip=info.get('ip'),
user_agent=info.get('user_agent'),
desc=info.get('desc'),
uid=info.get('uid'),
method=info.get('method'),
success=info.get('success')
)
db.session.add(log)
db.session.flush()
db.session.commit()
return log.id
def admin_log(request, is_access):
info = {
'method': request.method,
'url': request.path,
'ip': request.remote_addr,
'user_agent': xss_escape(request.headers.get('User-Agent')),
'desc': xss_escape(str(dict(request.values))),
'uid': current_user.id,
'success': int(is_access)
}
log = AdminLog(
url=info.get('url'),
ip=info.get('ip'),
user_agent=info.get('user_agent'),
desc=info.get('desc'),
uid=info.get('uid'),
method=info.get('method'),
success=info.get('success')
)
db.session.add(log)
db.session.commit()
return log.id
+56 -31
View File
@@ -1,31 +1,39 @@
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()
示例:
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='创建时间')
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):
"""
不需要建立schemas,直接使用orm的定义模型进行序列化
基本功能,待完善
示例
power_data = curd.auto_model_jsonify(model=Dept, data=dept)
自动序列化模型数据为 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
@@ -33,49 +41,60 @@ def auto_model_jsonify(data, model: db.Model):
class AutoSchema(SQLAlchemyAutoSchema):
class Meta(Schema):
model = get_model()
include_fk = True
include_relationships = True
load_instance = True
include_fk = True # 包含外键
include_relationships = True # 包含关联关系
load_instance = True # 反序列化时加载为模型实例
common_schema = AutoSchema(many=True) # 用已继承ma.ModelSchema类的自定制类生成序列化类
common_schema = AutoSchema(many=True) # 支持序列化多个对象
output = common_schema.dump(data)
return output
def model_to_dicts(schema: ma.Schema, data):
"""
:param schema: schema类
:param model: sqlalchemy查询结果
:return: 返回单个查询结果
使用指定的 Schema 序列化 SQLAlchemy 查询结果。
:param schema: Marshmallow Schema 类。
:param data: SQLAlchemy 查询结果。
:return: 返回序列化后的数据,返回字典。
"""
# 如果是分页器返回,需要传入model.items
common_schema = schema(many=True) # 用已继承ma.ModelSchema类的自定制类生成序列化类
output = common_schema.dump(data) # 生成可序列化对象
common_schema = schema(many=True) # 支持序列化多个对象
output = common_schema.dump(data)
return output
def get_one_by_id(model: db.Model, id):
"""
:param model: 模型类
:param id: id
:return: 返回单个查询结果
根据 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):
"""
:param model: 模型类
:param id: id
:return: 返回单个查询结果
根据 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:
@@ -84,11 +103,17 @@ def enable_status(model: db.Model, id):
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
return False
+106 -59
View File
@@ -1,126 +1,173 @@
from sqlalchemy import and_
from sqlalchemy import and_, func
from applications.extensions import db
class ModelFilter:
"""
orm多参数构造器
"""
filter_field = {}
filter_list = []
ORM 多条件查询构造器,支持多种查询条件组合,自动转义特殊字符防止SQL注入。
type_exact = "exact"
type_neq = "neq"
type_greater = "greater"
type_less = "less"
type_vague = "vague"
type_contains = "contains"
type_between = "between"
示例:
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:
:param value: 匹配的值(自动过滤空字符串)
"""
if value and value != '':
self.filter_field[field_name] = {"data": value, "type": self.type_exact}
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:
:param value: 不匹配的值(自动过滤空字符串)
"""
if value and value != '':
self.filter_field[field_name] = {"data": value, "type": self.type_neq}
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:
:param value: 比较的数值/日期
"""
if value and 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:
:param value: 比较的数值/日期
"""
if value and 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:
:param value: 需要模糊匹配的字符串(自动过滤空值)
"""
if value and value != '':
self.filter_field[field_name] = {"data": ('%' + value + '%'), "type": self.type_vague}
escaped_value = self.escape_like(value)
self.filter_field[field_name] = {"data": f'%{escaped_value}%', "type": self.type_vague}
def left_vague(self, field_name, value: str):
"""
左模糊查询字段
添加安全左模糊匹配(自动转义特殊字符,左侧加%
:param field_name: 模型字段名称
:param value:
:param value: 需要左模糊匹配的字符串
"""
if value and value != '':
self.filter_field[field_name] = {"data": ('%' + value), "type": self.type_vague}
escaped_value = self.escape_like(value)
self.filter_field[field_name] = {"data": f'%{escaped_value}', "type": self.type_vague}
def right_vague(self, field_name, value: str):
"""
左模糊查询字段
添加安全右模糊匹配(自动转义特殊字符,右侧加%
:param field_name: 模型字段名称
:param value:
:param value: 需要右模糊匹配的字符串
"""
if value and value != '':
self.filter_field[field_name] = {"data": (value + '%'), "type": self.type_vague}
escaped_value = self.escape_like(value)
self.filter_field[field_name] = {"data": f'{escaped_value}%', "type": self.type_vague}
def contains(self, field_name, value: str):
"""
包含查询字段
添加安全包含条件(自动转义特殊字符,等效于vague)
:param field_name: 模型字段名称
:param value:
:param value: 需要包含的字符串
"""
if value and value != '':
self.filter_field[field_name] = {"data": value, "type": self.type_contains}
escaped_value = self.escape_like(value)
self.filter_field[field_name] = {"data": f'%{escaped_value}%', "type": self.type_contains}
def between(self, field_name, value1, value2):
"""
范围查询字段
添加范围查询条件(自动过滤无效值)
:param field_name: 模型字段名称
:param value: 值
:param value1: 范围起始
:param value2: 范围结束值
"""
if value1 and value2 and value1 != '' and value2 != '':
if all([v is not None and v != '' for v in [value1, value2]]):
self.filter_field[field_name] = {"data": [value1, value2], "type": self.type_between}
def get_filter(self, model: db.Model):
"""
获取过滤条件
:param model: 模型字段名称
生成安全的SQLAlchemy过滤条件
:param model: SQLAlchemy 模型类
:return: 组合后的过滤条件(使用and_连接)
"""
for k, v in self.filter_field.items():
if v.get("type") == self.type_vague:
self.filter_list.append(getattr(model, k).like(v.get("data")))
if v.get("type") == self.type_contains:
self.filter_list.append(getattr(model, k).contains(v.get("data")))
if v.get("type") == self.type_exact:
self.filter_list.append(getattr(model, k) == v.get("data"))
if v.get("type") == self.type_neq:
self.filter_list.append(getattr(model, k) != v.get("data"))
if v.get("type") == self.type_greater:
self.filter_list.append(getattr(model, k) > v.get("data"))
if v.get("type") == self.type_less:
self.filter_list.append(getattr(model, k) < v.get("data"))
if v.get("type") == self.type_between:
self.filter_list.append(getattr(model, k).between(v.get("data")[0], v.get("data")[1]))
return and_(*self.filter_list)
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)
+8 -23
View File
@@ -1,26 +1,11 @@
import os
from flask import Flask
import click
from applications.common.script.initdb import init_db
from applications.common.script.newmodular.new import NewViewModular
from .admin import admin_cli
from .check import check_cli
from applications.extensions.init_plugins import broadcast_execute
def init_script(app):
@app.cli.command()
def init():
init_db()
@app.cli.command()
@click.option('--type', prompt="请输入类型", help='新增的类型')
@click.option('--name', prompt="请输入新增的名称", help='新增的名称')
def new(type,name):
if type == 'view':
if name.count('/') > 1:
print("目前只支持二级目录,多级目录需要蓝图嵌套,本命令暂不支持,请手动创建")
quit()
if type == "view" and os.path.exists(f"applications/view/{name}.py"):
print(f'已经存在视图模块{name}.py')
quit()
NewViewModular(name=name).new_view()
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")
-60
View File
@@ -1,60 +0,0 @@
from dotenv import dotenv_values
import sqlparse
import pymysql
config = dotenv_values('.flaskenv')
# MySql配置信息
HOST = config.get('MYSQL_HOST') or '127.0.0.1'
PORT = config.get('MYSQL_PORT') or 3306
DATABASE = config.get('MYSQL_DATABASE') or 'PearAdminFlask'
USERNAME = config.get('MYSQL_USERNAME') or 'root'
PASSWORD = config.get('MYSQL_PASSWORD') or '123456'
def is_exist_database():
db = pymysql.connect(host=HOST, port=int(PORT), user=USERNAME, password=PASSWORD, charset='utf8mb4')
cursor1 = db.cursor()
sql = "select * from information_schema.SCHEMATA WHERE SCHEMA_NAME = '%s' ; " % DATABASE
res = cursor1.execute(sql)
db.close()
return res
def init_database():
db = pymysql.connect(host=HOST, port=int(PORT), user=USERNAME, password=PASSWORD, charset='utf8mb4')
cursor1 = db.cursor()
sql = "CREATE DATABASE IF NOT EXISTS %s CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;" % DATABASE
res = cursor1.execute(sql)
db.close()
return res
def execute_fromfile(filename):
db = pymysql.connect(host=HOST, port=int(PORT), user=USERNAME, password=PASSWORD, database=DATABASE,
charset='utf8mb4')
fd = open(filename, 'r', encoding='utf-8')
cursor = db.cursor()
sqlfile = fd.read()
sqlfile = sqlparse.format(sqlfile, strip_comments=True).strip()
sqlcommamds = sqlfile.split(';')
for command in sqlcommamds:
try:
cursor.execute(command)
db.commit()
except Exception as msg:
db.rollback()
db.close()
def init_db():
if is_exist_database():
print('数据库已经存在,为防止误初始化,请手动删除 %s 数据库' % str(DATABASE))
return
if init_database():
print('数据库%s创建成功' % str(DATABASE))
execute_fromfile('test/pear.sql')
print('表创建成功')
print('欢迎使用pear-admin-flask,请使用 flask run 命令启动程序')
@@ -1,92 +0,0 @@
import os
import jinja2
class NewViewModular():
def __init__(self, name):
self.name = name
def path_is_exists(self):
return os.path.exists(f"applications/view/{self.name.split('/')[0]}")
def new_dirs(self):
if not self.path_is_exists():
# 如果不存在则创建目录
# 创建目录操作函数
os.makedirs(f"applications/view/{self.name.split('/')[0]}")
print(self.name + ' 创建成功')
return True
else:
return False
def add_view(self):
# jinja渲染
templateLoader = jinja2.FileSystemLoader(searchpath='applications/common/script/newmodular/template/')
templateEnv = jinja2.Environment(loader=templateLoader)
TEMPLATE_FILE = "view"
template = templateEnv.get_template(TEMPLATE_FILE, )
templateVars = {"name": self.name}
outputText = template.render(templateVars)
with open(f"applications/view/{self.name}.py", "a", encoding='utf-8') as f:
f.write(outputText)
f.close()
def add_init(self):
# 判断__init__.py
if os.path.exists(f"applications/view/{self.name.split('/')[0]}/__init__.py"):
with open(f"applications/view/{self.name.split('/')[0]}/__init__.py", "r") as file:
lines = file.readlines()
print(lines)
file.close()
import_line_num = 0
for line in lines:
if 'def' in line:
import_line_num = lines.index(line) - 2
with open(f"applications/view/{self.name.split('/')[0]}/__init__.py", "w") as file:
lines.insert(import_line_num,
f"from applications.view.{self.name.replace('/', '.')} import {self.name.replace('/', '_')}\n")
print(lines)
lines.insert(len(lines) - 1, f" app.register_blueprint({self.name.replace('/', '_')})\n")
print(lines)
file.writelines(lines)
file.close()
return True
else:
templateLoader = jinja2.FileSystemLoader(searchpath='applications/common/script/newmodular/template/')
templateEnv = jinja2.Environment(loader=templateLoader)
TEMPLATE_FILE = "__init__"
template = templateEnv.get_template(TEMPLATE_FILE, )
templateVars = {"name": self.name}
output = template.render(templateVars)
with open(f"applications/view/{self.name.split('/')[0]}/__init__.py", "a", encoding='utf-8') as f:
f.write(output)
f.close()
self.add_root_init()
return True
def add_root_init(self):
with open(f"applications/view/__init__.py", "r") as file:
lines = file.readlines()
file.close()
import_line_num = 0
for line in lines:
if 'def' in line:
import_line_num = lines.index(line) - 2
with open(f"applications/view/__init__.py", "w") as file:
lines.insert(
import_line_num,
f"from applications.view.{self.name.split('/')[0]} import register_{self.name.split('/')[0]}_views\n")
print(lines)
lines.insert(len(lines) - 1, f" register_{self.name.split('/')[0]}_views(app)\n")
print(lines)
file.writelines(lines)
file.close()
def new_view(self):
self.new_dirs()
self.add_view()
self.add_init()
@@ -1,6 +0,0 @@
from flask import Flask
from applications.view.{{name.replace('/','.')}} import {{name.replace('/','_')}}
def register_{{name.split('/')[0]}}_views(app: Flask):
app.register_blueprint({{name.replace('/','_')}})
@@ -1,8 +0,0 @@
from flask import Blueprint
{{name.replace('/','_')}} = Blueprint('{{name.replace('/','_')}}', __name__, url_prefix='/{{name}}')
@{{name.replace('/','_')}}.route('/')
def index():
return "这是{{name}}路由"
-54
View File
@@ -1,54 +0,0 @@
from apscheduler.events import (
EVENT_JOB_ADDED,
EVENT_JOB_ERROR,
EVENT_JOB_EXECUTED,
EVENT_JOB_MISSED,
EVENT_JOB_REMOVED,
EVENT_JOB_SUBMITTED,
)
from applications.extensions.init_apscheduler import scheduler
def job_missed(event):
"""Job missed event."""
with scheduler.app.app_context():
print(event) # noqa: T001
def job_error(event):
"""Job error event."""
with scheduler.app.app_context():
print(event) # noqa: T001
def job_executed(event):
"""Job executed event."""
with scheduler.app.app_context():
print(event) # noqa: T001
def job_added(event):
"""Job added event."""
with scheduler.app.app_context():
print(event) # noqa: T001
def job_removed(event):
"""Job removed event."""
with scheduler.app.app_context():
print(event) # noqa: T001
def job_submitted(event):
"""Job scheduled to run event."""
with scheduler.app.app_context():
print(event) # noqa: T001
scheduler.add_listener(job_missed, EVENT_JOB_MISSED)
scheduler.add_listener(job_error, EVENT_JOB_ERROR)
scheduler.add_listener(job_executed, EVENT_JOB_EXECUTED)
scheduler.add_listener(job_added, EVENT_JOB_ADDED)
scheduler.add_listener(job_removed, EVENT_JOB_REMOVED)
scheduler.add_listener(job_submitted, EVENT_JOB_SUBMITTED)
-15
View File
@@ -1,15 +0,0 @@
import datetime
task_list = ['task2', 'task3', 'task4']
def task2(a, b):
print(f'定时任务_1_{a},{b},{datetime.datetime.now()}')
def task3(a, b):
print(f'定时任务_2_{a}{b}{datetime.datetime.now()}')
def task4(a, b):
print(f'定时任务_4_{a}{b}{datetime.datetime.now()}')
+55
View File
@@ -0,0 +1,55 @@
import time
cache_dict = {}
def cache_set_internal(key, value, expired=5):
"""
程序内部实现的记录缓存,用于简单、体量不大的缓存记录,在程序结束后销毁。对于高速、体量大的环境请配置 Redis 等服务自行记录。
记录缓存,存储键值对,并记录当前时间作为缓存的时间戳。
:param key: 键
:param value: 值
:param expired: 过期时间(秒),默认5秒
"""
cache_dict[key] = {
'value': value,
'expired_time': time.time() + expired
}
def cache_get_internal(key):
"""
获取缓存,根据键从缓存中获取值,并检查是否过期。
:param key: 键
:return: 如果缓存存在且未过期,返回缓存的值;否则返回 None
"""
if key in cache_dict:
cache_item = cache_dict[key]
if time.time() < cache_item['expired_time']:
return cache_item['value']
else:
# 如果缓存已过期,删除该缓存
del cache_dict[key]
return None
def cache_auto_internal(key, call, expired=5):
"""
如果缓存存在直接返回缓存内容,缓存不存在或者过期执行 call 函数,并取得返回值记录并返回。
:param key: 键
:param call: 获取新值的地方
:param expired: 过期时间(秒),默认5秒
"""
data = cache_get_internal(key)
if data is not None:
return data
data = call()
cache_set_internal(key, data, expired)
return data
+135
View File
@@ -0,0 +1,135 @@
import random, math
from PIL import Image, ImageDraw, ImageFont, ImageFilter
class vieCode:
__fontSize = 20 # 字体大小
__width = 120 # 画布宽度
__heigth = 45 # 画布高度
__length = 4 # 验证码长度
__draw = None # 画布对象
__img = None # 图片对象
__code = None # 验证码字符
__str = None # 自定义验证码字符集
__inCurve = True # 是否绘制干扰曲线
__inNoise = True # 是否绘制干扰点
__type = 2 # 验证码类型:1-纯字母,2-数字字母混合
__fontPatn = 'applications/common/utils/fonts/captcha.ttf' # 字体路径
def GetCodeImage(self, size=80, length=4):
"""
生成验证码图片及其对应的验证码字符。
:param size: 验证码字体大小,默认为 80。
:param length: 验证码字符长度,默认为 4。
:return: 返回验证码图片对象和验证码字符。
"""
# 准备基础数据
self.__length = length
self.__fontSize = size
self.__width = self.__fontSize * self.__length
self.__heigth = int(self.__fontSize * 1.5)
# 生成验证码图片
self.__createCode()
self.__createImage()
self.__createNoise()
self.__printString()
self.__cerateFilter()
return self.__img, self.__code
def __cerateFilter(self):
"""
对验证码图片进行模糊处理,增加识别难度。
"""
self.__img = self.__img.filter(ImageFilter.BLUR)
filter = ImageFilter.ModeFilter(8)
self.__img = self.__img.filter(filter)
def __createCode(self):
"""
生成验证码字符。
"""
# 是否使用自定义字符集
if not self.__str:
# 源文本
number = "3456789"
srcLetter = "qwertyuipasdfghjkzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM"
srcUpper = srcLetter.upper()
if self.__type == 1:
self.__str = number
else:
self.__str = srcLetter + srcUpper + number
# 随机生成验证码字符
self.__code = random.sample(self.__str, self.__length)
def __createImage(self):
"""
创建画布并设置背景颜色。
"""
bgColor = (random.randint(200, 255), random.randint(200, 255), random.randint(200, 255))
self.__img = Image.new('RGB', (self.__width, self.__heigth), bgColor)
self.__draw = ImageDraw.Draw(self.__img)
def __createNoise(self):
"""
在验证码图片上绘制干扰点。
"""
if not self.__inNoise:
return
font = ImageFont.truetype(self.__fontPatn, int(self.__fontSize / 1.5))
for i in range(5):
# 干扰点颜色
noiseColor = (random.randint(150, 200), random.randint(150, 200), random.randint(150, 200))
putStr = random.sample(self.__str, 2)
for j in range(2):
# 绘制干扰点
size = (random.randint(-10, self.__width), random.randint(-10, self.__heigth))
self.__draw.text(size, putStr[j], font=font, fill=noiseColor)
def __createCurve(self):
"""
在验证码图片上绘制干扰曲线。
"""
if not self.__inCurve:
return
x = y = 0
# 计算曲线系数
a = random.uniform(1, self.__heigth / 2)
b = random.uniform(-self.__width / 4, self.__heigth / 4)
f = random.uniform(-self.__heigth / 4, self.__heigth / 4)
t = random.uniform(self.__heigth, self.__width * 2)
xend = random.randint(self.__width / 2, self.__width * 2)
w = (2 * math.pi) / t
# 绘制曲线
color = (random.randint(30, 150), random.randint(30, 150), random.randint(30, 150))
for x in range(xend):
if w != 0:
for k in range(int(self.__heigth / 10)):
y = a * math.sin(w * x + f) + b + self.__heigth / 2
i = int(self.__fontSize / 5)
while i > 0:
px = x + i
py = y + i + k
self.__draw.point((px, py), color)
i -= i
def __printString(self):
"""
在画布上打印验证码字符。
"""
font = ImageFont.truetype(self.__fontPatn, self.__fontSize)
x = 0
# 打印字符到画布
for i in range(self.__length):
# 设置字体随机颜色
color = (random.randint(30, 150), random.randint(30, 150), random.randint(30, 150))
# 计算坐标
x = random.uniform(self.__fontSize * i * 0.95, self.__fontSize * i * 1.1)
y = self.__fontSize * random.uniform(0.3, 0.5)
# 打印字符
self.__draw.text((x, y), self.__code[i], font=font, fill=color)
Binary file not shown.
-14
View File
@@ -1,14 +0,0 @@
from captcha.image import ImageCaptcha
from PIL import Image
from random import choices
def gen_captcha(content='2345689abcdefghijklmnpqrstuvwxyzABCDEFGHIJKLMNPQRSTUVWXYZ'):
""" 生成验证码 """
image = ImageCaptcha()
# 获取字符串
captcha_text = "".join(choices(content, k=4)).lower()
# 生成图像
captcha_image = Image.open(image.generate(captcha_text))
return captcha_text, captcha_image
+24 -7
View File
@@ -1,24 +1,41 @@
from flask import jsonify
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': limit
'limit': min(request.args.get('limit', default=10, type=int), 90)
}
return jsonify(res)
return jsonify(res)
+88 -8
View File
@@ -1,13 +1,93 @@
"""
集成了对 Pear Admin Flask 二次开发的邮件操作模块,并提供了相应的示例。
"""
from flask import current_app
from flask_mail import Message
from applications.extensions.init_mail import mail
from applications.common.curd import model_to_dicts
from applications.common.helper import ModelFilter
from applications.extensions import db, flask_mail
from applications.models import Mail
from applications.schemas import MailOutSchema
def get_all(receiver=None, subject=None, content=None):
"""
获取邮件列表,支持根据接收者、主题和内容进行筛选。
返回的列表中的字典结构如下::
{
"content": "", # HTML 内容
"create_at": "2022-12-25T10:51:17", # 创建时间
"id": 17, # 邮件ID
"realname": "超级管理", # 创建者姓名
"receiver": "", # 接收者
"subject": "" # 邮件主题
}
:param receiver: 接收者邮箱地址,支持模糊查询。
:param subject: 邮件主题,支持模糊查询。
:param content: 邮件内容,支持模糊查询。
:return: 返回符合条件的邮件列表。
"""
# 构造查询条件
mf = ModelFilter()
if receiver:
mf.contains(field_name="receiver", value=receiver)
if subject:
mf.contains(field_name="subject", value=subject)
if content:
mf.exact(field_name="content", value=content)
# 查询邮件数据并分页
mail = Mail.query.filter(mf.get_filter(Mail)).layui_paginate()
return model_to_dicts(schema=MailOutSchema, data=mail.items)
def add(receiver, subject, content, user_id):
"""
发送一封邮件,并将发送记录保存到数据库。 **该方法被邮件发送的视图函数调用。**
:param receiver: 接收者邮箱地址,多个邮箱用英文分号隔开。
:param subject: 邮件主题。
:param content: 邮件内容(HTML 格式)。
:param user_id: 发送者用户ID,表示谁发送了这封邮件。
可以使用 `from flask_login import current_user; current_user.id` 获取当前登录用户的ID。
:return: 发送成功返回 True,失败报错。
"""
send_mail(subject=subject, recipients=receiver.split(";"), content=content)
# 保存邮件记录到数据库
mail = Mail(receiver=receiver, subject=subject, content=content, user_id=user_id)
db.session.add(mail)
db.session.commit()
return True
def delete(id):
"""
删除指定的邮件记录。
:param id: 邮件ID。
:return: 删除成功返回 True,失败返回 False。
"""
res = Mail.query.filter_by(id=id).delete()
if not res:
return False
db.session.commit()
return True
# send_mail(subject='title', recipients=['123@qq.com'], content='body')
def send_mail(subject, recipients, content):
try:
message = Message(subject=subject, recipients=recipients, body=content)
mail.send(message)
except Exception as e:
print('邮箱发送出错了')
raise
"""
发送邮件(不记录发送日志)。
注意:如果发送失败会抛出异常,请使用 try-except 进行捕获。
:param subject: 邮件主题。
:param recipients: 接收者邮箱地址,多个邮箱用英文分号隔开。
:param content: 邮件内容(HTML 格式)。
"""
message = Message(subject=subject, recipients=recipients, html=content)
flask_mail.send(message)
+22 -3
View File
@@ -1,14 +1,31 @@
from functools import wraps
from flask import abort, request, jsonify, session
from flask_login import login_required
from applications.common.admin_log import admin_log
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)
@@ -16,8 +33,10 @@ def authorize(power: str, log: bool = False):
abort(403)
else:
return jsonify(success=False, msg="权限不足!")
if log:
admin_log(request=request, is_access=True)
return func(*args, **kwargs)
return wrapper
+1 -1
View File
@@ -17,7 +17,7 @@ def get_photo(page, limit):
def upload_one(photo, mime):
filename = photos.save(photo)
file_url = '/_uploads/photos/'+filename
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)
+235 -15
View File
@@ -1,20 +1,240 @@
# xss过滤
from flask import abort, make_response, jsonify
# XSS 过滤
import validators
from markupsafe import escape
from validators import validator
def xss_escape(s: str):
if s is None:
def str_escape(s):
"""
对字符串进行 XSS 过滤,返回转义后的安全字符串。
:param s: 需要转义的字符串。
:return: 返回转义后的字符串,如果输入为空则返回 None。
"""
if not s:
return None
else:
return s.replace("&", "&amp;").replace(">", "&gt;").replace("<", "&lt;").replace("'", "&#39;").replace('"',
"&#34;")
return str(escape(s))
def check_data(schema, data):
errors = schema.validate(data)
for k, v in errors.items():
for i in v:
# print("{}{}".format(k, i))
msg = "{}{}".format(k, i)
if errors:
abort(make_response(jsonify(result=False, msg=msg), 200))
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)
-1
View File
@@ -1 +0,0 @@
from .config import config
-87
View File
@@ -1,87 +0,0 @@
import logging
import os
from urllib.parse import quote_plus as urlquote
class BaseConfig:
SYSTEM_NAME = os.getenv('SYSTEM_NAME', 'Pear Admin')
# 主题面板的链接列表配置
SYSTEM_PANEL_LINKS = [
{
"icon": "layui-icon layui-icon-auz",
"title": "官方网站",
"href": "http://www.pearadmin.com"
},
{
"icon": "layui-icon layui-icon-auz",
"title": "开发文档",
"href": "http://www.pearadmin.com"
},
{
"icon": "layui-icon layui-icon-auz",
"title": "开源地址",
"href": "https://gitee.com/Jmysy/Pear-Admin-Layui"
}
]
UPLOADED_PHOTOS_DEST = 'static/upload'
UPLOADED_FILES_ALLOW = ['gif', 'jpg']
# JSON配置
JSON_AS_ASCII = False
SECRET_KEY = os.getenv('SECRET_KEY', 'dev key')
# redis配置
REDIS_HOST = os.getenv('REDIS_HOST') or "127.0.0.1"
REDIS_PORT = int(os.getenv('REDIS_PORT') or 6379)
# mysql 配置
MYSQL_USERNAME = os.getenv('MYSQL_USERNAME') or "root"
MYSQL_PASSWORD = os.getenv('MYSQL_PASSWORD') or "123456"
MYSQL_HOST = os.getenv('MYSQL_HOST') or "127.0.0.1"
MYSQL_PORT = int(os.getenv('MYSQL_PORT') or 3306)
MYSQL_DATABASE = os.getenv('MYSQL_DATABASE') or "PearAdminFlask"
# mysql 数据库的配置信息
SQLALCHEMY_DATABASE_URI = f"mysql+pymysql://{MYSQL_USERNAME}:{urlquote(MYSQL_PASSWORD)}@{MYSQL_HOST}:{MYSQL_PORT}/{MYSQL_DATABASE}?charset=utf8mb4"
# 默认日志等级
LOG_LEVEL = logging.WARN
#
MAIL_SERVER = os.getenv('MAIL_SERVER') or 'smtp.qq.com'
MAIL_USE_TLS = False
MAIL_USE_SSL = True
MAIL_PORT = 465
MAIL_USERNAME = os.getenv('MAIL_USERNAME') or '123@qq.com'
MAIL_PASSWORD = os.getenv('MAIL_PASSWORD') or 'XXXXX' # 生成的授权码
# 默认发件人的邮箱,这里填写和MAIL_USERNAME一致即可
MAIL_DEFAULT_SENDER = ('pear admin', os.getenv('MAIL_USERNAME') or '123@qq.com')
class TestingConfig(BaseConfig):
""" 测试配置 """
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:' # 内存数据库
class DevelopmentConfig(BaseConfig):
""" 开发配置 """
SQLALCHEMY_TRACK_MODIFICATIONS = True
SQLALCHEMY_ECHO = False
class ProductionConfig(BaseConfig):
"""生成环境配置"""
SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_ECHO = False
SQLALCHEMY_POOL_RECYCLE = 8
LOG_LEVEL = logging.ERROR
config = {
'development': DevelopmentConfig,
'testing': TestingConfig,
'production': ProductionConfig
}
+21 -10
View File
@@ -1,23 +1,34 @@
from flask import Flask
from .init_sqlalchemy import db, ma, init_databases
from .init_login import init_login_manager
from .init_debug_tool import init_debug_tool
from .init_template_directives import init_template_directives
from .init_error_views import init_error_views
from .init_mail import init_mail
from .init_apscheduler import init_scheduler
from .init_mail import init_mail, mail as flask_mail
from .init_upload import init_upload
from .init_dotenv import init_dotenv
from .init_migrate import init_migrate
from .init_session import init_session
from .init_limit import init_limit
from .init_plugins import register_plugin, broadcast_execute
def init_plugs(app: Flask) -> None:
# init_debug_tool(app)
# 注册插件 (仅 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)
init_mail(app)
init_scheduler(app)
init_upload(app)
init_dotenv()
# 注:event_init / event_finish / event_context 由具体的
# applications.view.init_bps / applications.common.script.init_script
# 触发,避免 init_plugs 内重复调用导致 Blueprint 注册冲突
@@ -1,12 +0,0 @@
from flask import Flask
from flask_apscheduler import APScheduler
scheduler = APScheduler()
def init_scheduler(app: Flask):
scheduler.init_app(app)
with app.app_context():
from applications.common.tasks import tasks
scheduler.start()
from applications.common.tasks import events
@@ -1,7 +0,0 @@
from flask import Flask
from flask_debugtoolbar import DebugToolbarExtension
def init_debug_tool(app: Flask):
toolbar = DebugToolbarExtension()
toolbar.init_app(app)
-14
View File
@@ -1,14 +0,0 @@
import os
from dotenv import load_dotenv
root_path = os.path.abspath(os.path.dirname(__file__)).split('applications')[0]
# dot_env_path = os.path.join(root_path, '.env')
flask_env_path = os.path.join(root_path, '.flaskenv')
# if os.path.exists(dot_env_path):
# load_dotenv(dot_env_path)
def init_dotenv():
if os.path.exists(flask_env_path):
load_dotenv(flask_env_path)
+9 -18
View File
@@ -10,25 +10,16 @@ def init_error_views(app):
def page_not_found(e):
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)
def internal_server_error(e):
return render_template('errors/500.html'), 500
# Return validation errors as JSON
@app.errorhandler(422)
@app.errorhandler(400)
def handle_error(err):
headers = err.data.get("headers", None)
messages = err.data.get("messages", ["Invalid request."]).get('json')
print(err.data.get("messages"))
print(messages.items())
msg = ''
for i in messages.items():
msg = str(i[0]) + str(i[1][0])
break
if headers:
return jsonify({"success": False, "msg": msg})
else:
return jsonify({"success": False, "msg": msg})
@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)
+1 -2
View File
@@ -5,8 +5,7 @@ def init_login_manager(app):
login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = 'passport.login'
login_manager.login_message = u'请登录以访问此页面'
login_manager.login_view = 'system.passport.login'
@login_manager.user_loader
def load_user(user_id):
+11
View File
@@ -0,0 +1,11 @@
from flask import Flask
from applications.extensions.init_sqlalchemy import db
from flask_migrate import Migrate
from applications.models import *
migrate = Migrate()
def init_migrate(app: Flask):
migrate.init_app(app, db)
+61
View File
@@ -0,0 +1,61 @@
import os
from flask import Flask
from flask import Blueprint
import json
import importlib
plugin_bp = Blueprint('plugin', __name__, url_prefix='/plugin')
PLUGIN_ENABLE_FOLDERS = []
PLUGIN_IMPORTLIB = []
def register_plugin(app: Flask):
"""
获取所有插件并加载
"""
global PLUGIN_ENABLE_FOLDERS
app.register_blueprint(plugin_bp)
# 载入插件过程
# plugin_folder 配置的是插件的文件夹名
PLUGIN_ENABLE_FOLDERS = app.config['PLUGIN_ENABLE_FOLDERS']
for plugin_folder in PLUGIN_ENABLE_FOLDERS:
plugin_info = {
'plugin_name': plugin_folder
}
try:
if os.path.exists("plugins/" + plugin_folder + "/__init__.json"):
with open("plugins/" + plugin_folder + "/__init__.json", "r", encoding='utf-8') as f:
plugin_info = json.loads(f.read())
# 将插件全部载入
PLUGIN_IMPORTLIB.append(importlib.import_module('plugins.' + plugin_folder))
print(f" * Plugin: Loaded plugin: {plugin_info['plugin_name']} .")
except BaseException as e:
info = f" * Plugin: Crash a error when loading {plugin_info['plugin_name'] if len(plugin_info) != 0 else 'plugin'} :" + "\n"
app.logger.error(info)
app.logger.exception(e)
def broadcast_execute(app: Flask, function_name):
for plugin in PLUGIN_IMPORTLIB:
try:
# 初始化完成事件
try:
getattr(plugin, function_name)(app)
except AttributeError: # 没有插件启用事件就不调用
pass
except BaseException as e:
app.logger.exception(e)
if function_name == 'event_finish':
with app.app_context():
broadcast_execute(app, 'event_context')
+7
View File
@@ -0,0 +1,7 @@
from flask_session import Session
sess = Session()
def init_session(app):
sess.init_app(app)
+69 -19
View File
@@ -1,7 +1,11 @@
import datetime
import os
from flask import Flask, request
from flask_sqlalchemy import SQLAlchemy, BaseQuery
from flask_sqlalchemy import SQLAlchemy
from flask_sqlalchemy.query import Query as BaseQuery
from sqlalchemy import event
from sqlalchemy.engine import Engine
from flask_marshmallow import Marshmallow
from marshmallow import fields
from marshmallow.validate import (
@@ -58,30 +62,52 @@ class Query(BaseQuery):
def all_json(self, schema: Marshmallow().Schema):
return schema(many=True).dump(self.all())
def layui_paginate(self):
return self.paginate(page=request.args.get('page', type=int),
per_page=request.args.get('limit', type=int),
error_out=False)
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) # 添加默认值
def layui_paginate_json(self, schema: Marshmallow().Schema):
"""
返回dict
"""
_res = self.paginate(
page=request.args.get('page', type=int),
per_page=request.args.get('limit', type=int),
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):
"""
db.query(A.name).layui_paginate_db_json()
"""
_res = self.paginate(page=request.args.get('page', type=int),
per_page=request.args.get('limit', type=int),
error_out=False)
return [dict(i) for i in _res.items], _res.total
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)
@@ -91,3 +117,27 @@ 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}")
@@ -1,7 +1,26 @@
from flask import session
from flask import session, current_app
from flask_login import current_user
from flask_wtf.csrf import generate_csrf
def init_template_directives(app):
@app.template_global()
def authorize(power):
return bool(power in session.get('permissions'))
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()}">'
@app.template_global()
def nav_categories():
"""前台左侧侧栏 / 移动端抽屉共用的分类列表(带 anchor 锚点)。
以函数形式提供而非 context_processor,后台模板不调用就不会查库。
"""
from applications.view.public.nav import get_nav_categories
return get_nav_categories()
-9
View File
@@ -1,9 +0,0 @@
from marshmallow import EXCLUDE
from webargs.flaskparser import FlaskParser
class Parser(FlaskParser):
DEFAULT_UNKNOWN_BY_LOCATION = {"query": EXCLUDE}
parser = Parser()
+8 -1
View File
@@ -6,4 +6,11 @@ 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_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 '',
}
+1 -1
View File
@@ -15,4 +15,4 @@ class Dept(db.Model):
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='创建时间')
update_at = 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 '',
}
+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='点击时间')
+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 '',
}
+1 -1
View File
@@ -9,7 +9,7 @@ class User(db.Model, UserMixin):
id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='用户ID')
username = db.Column(db.String(20), comment='用户名')
realname = db.Column(db.String(20), comment='真实名字')
avatar = db.Column(db.String(255), comment='头像', default="/static/admin/admin/images/avatar.jpg")
avatar = db.Column(db.String(255), comment='头像', default="/static/system/admin/images/avatar.jpg")
remark = db.Column(db.String(255), comment='备注')
password_hash = db.Column(db.String(128), comment='哈希密码')
enable = db.Column(db.Integer, default=0, comment='启用')
+6 -2
View File
@@ -1,7 +1,11 @@
from .admin_user import UserOutSchema
from .admin_role import RoleOutSchema
from .admin_power import PowerOutSchema, PowerOutSchema2
from .admin_dict import DictDataOutSchema, DictTypeOutSchema
from .admin_dept import DeptOutSchema
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
+10 -23
View File
@@ -1,25 +1,12 @@
from applications.extensions import ma
from marshmallow import fields, validate
from flask_marshmallow.sqla import SQLAlchemyAutoSchema
from applications.models import Dept
class DeptInSchema(ma.Schema):
parentId = fields.Integer(required=True)
deptName = fields.Str(required=True)
leader = fields.Str(required=True)
phone = fields.Str(required=True)
email = fields.Str(validate=validate.Email())
address = fields.Str()
status = fields.Str(validate=validate.OneOf(["0", "1"]))
sort = fields.Integer()
class DeptOutSchema(ma.Schema):
deptId = fields.Integer(attribute="id")
parentId = fields.Integer(attribute="parent_id")
deptName = fields.Str(attribute="dept_name")
leader = fields.Str()
phone = fields.Str()
email = fields.Str(validate=validate.Email())
address = fields.Str()
status = fields.Str(validate=validate.OneOf(["0", "1"]))
sort = fields.Integer()
class DeptSchema(SQLAlchemyAutoSchema):
class Meta:
model = Dept # table = models.Album.__table__
# include_relationships = True # 输出模型对象时同时对外键,是否也一并进行处理
include_fk = True # 序列化阶段是否也一并返回主键
# fields= ["id","name"] # 启动的字段列表
# exclude = ["id","name"] # 排除字段列表
+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
+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
@@ -1,6 +1,18 @@
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):
+10 -12
View File
@@ -1,14 +1,12 @@
from applications.extensions import ma
from marshmallow import fields
from flask_marshmallow.sqla import SQLAlchemyAutoSchema
from applications.models import Role
class RoleOutSchema(ma.Schema):
id = fields.Integer()
roleName = fields.Str(attribute="name")
roleCode = fields.Str(attribute="code")
enable = fields.Str()
remark = fields.Str()
details = fields.Str()
sort = fields.Integer()
create_at = fields.DateTime()
update_at = fields.DateTime()
class RoleOutSchema(SQLAlchemyAutoSchema):
class Meta:
model = Role # table = models.Album.__table__
# include_relationships = True # 输出模型对象时同时对外键,是否也一并进行处理
include_fk = True # 序列化阶段是否也一并返回主键
# fields= ["id","name"] # 启动的字段列表
# exclude = ["id","name"] # 排除字段列表
-20
View File
@@ -1,20 +0,0 @@
from applications.extensions import ma
from marshmallow import fields
from applications.models import Dept
# 用户models的序列化类
class UserOutSchema(ma.Schema):
id = fields.Integer()
username = fields.Str()
realname = fields.Str()
enable = fields.Integer()
create_at = fields.DateTime()
update_at = fields.DateTime()
dept = fields.Method("get_dept")
def get_dept(self, obj):
if obj.dept_id != None:
return Dept.query.filter_by(id=obj.dept_id).first().dept_name
else:
return None
+17 -11
View File
@@ -1,13 +1,19 @@
from applications.view.admin import register_admin_views
from applications.view.index import register_index_views
from applications.view.passport import register_passport_views
from applications.view.rights import register_rights_view
from applications.view.department import register_dept_views
from applications.view.system import register_system_bps
from applications.view.public import bp as public_bp, register_public_bp # public 包的入口就是 nav_bp
from applications.view.health import bp as health_bp
from applications.extensions.init_plugins import broadcast_execute
def init_view(app):
register_admin_views(app)
register_index_views(app)
register_rights_view(app)
register_passport_views(app)
register_dept_views(app)
def init_bps(app):
# 健康检查端点(无需登录,应在负载均衡/容器探活里使用)
app.register_blueprint(health_bp)
# 前台公开蓝图(无需登录)
app.register_blueprint(public_bp)
register_public_bp(app)
# 后台系统蓝图(需要登录 + 权限码)
register_system_bps(app)
# 插件初始化函数
broadcast_execute(app, 'event_init')
-23
View File
@@ -1,23 +0,0 @@
from flask import Flask
from applications.view.admin.admin_log import admin_log
from applications.view.admin.dict import admin_dict
from applications.view.admin.index import admin_bp
from applications.view.admin.file import admin_file
from applications.view.admin.power import admin_power
from applications.view.admin.role import admin_role
from applications.view.admin.user import admin_user
from applications.view.admin.monitor import admin_monitor_bp
from applications.view.admin.task import admin_task
def register_admin_views(app: Flask):
app.register_blueprint(admin_bp)
app.register_blueprint(admin_user)
app.register_blueprint(admin_file)
app.register_blueprint(admin_monitor_bp)
app.register_blueprint(admin_log)
app.register_blueprint(admin_power)
app.register_blueprint(admin_role)
app.register_blueprint(admin_dict)
app.register_blueprint(admin_task)
-221
View File
@@ -1,221 +0,0 @@
from flask import Blueprint, render_template, request, jsonify
from applications.common import curd
from applications.common.helper import ModelFilter
from applications.common.utils.http import table_api, success_api, fail_api
from applications.common.utils.rights import authorize
from applications.common.utils.validate import xss_escape
from applications.extensions import db
from applications.models import DictType, DictData
from applications.schemas import DictTypeOutSchema, DictDataOutSchema
admin_dict = Blueprint('adminDict', __name__, url_prefix='/admin/dict')
# 数据字典
@admin_dict.get('/')
@authorize("admin:dict:main", log=True)
def main():
return render_template('admin/dict/main.html')
@admin_dict.get('/dictType/data')
@authorize("admin:dict:main", log=True)
def dict_type_data():
# 获取请求参数
type_name = xss_escape(request.args.get('typeName', type=str))
# 查询参数构造
mf = ModelFilter()
if type_name:
mf.vague(field_name="type_name", value=type_name)
# orm查询
# 使用分页获取data需要.items
dict_all = DictType.query.filter(mf.get_filter(DictType)).layui_paginate()
count = dict_all.total
data = curd.model_to_dicts(schema=DictTypeOutSchema, data=dict_all.items)
return table_api(data=data, count=count)
@admin_dict.get('/dictType/add')
@authorize("admin:dict:add", log=True)
def dict_type_add():
return render_template('admin/dict/add.html')
@admin_dict.post('/dictType/save')
@authorize("admin:dict:add", log=True)
def dict_type_save():
req_json = request.json
description = xss_escape(req_json.get("description"))
enable = xss_escape(req_json.get("enable"))
type_code = xss_escape(req_json.get("typeCode"))
type_name = xss_escape(req_json.get("typeName"))
d = DictType(type_name=type_name, type_code=type_code, enable=enable, description=description)
db.session.add(d)
db.session.commit()
if d.id is None:
return fail_api(msg="增加失败")
return success_api(msg="增加成功")
# 编辑字典类型
@admin_dict.get('/dictType/edit')
@authorize("admin:dict:edit", log=True)
def dict_type_edit():
_id = request.args.get('dictTypeId', type=int)
dict_type = DictType.query.filter_by(id=_id).first()
return render_template('admin/dict/edit.html', dict_type=dict_type)
# 编辑字典类型
@admin_dict.put('/dictType/update')
@authorize("admin:dict:edit", log=True)
def dict_type_update():
req_json = request.json
id = xss_escape(req_json.get("id"))
description = xss_escape(req_json.get("description"))
enable = xss_escape(req_json.get("enable"))
type_code = xss_escape(req_json.get("typeCode"))
type_name = xss_escape(req_json.get("typeName"))
DictType.query.filter_by(id=id).update({
"description": description,
"enable": enable,
"type_code": type_code,
"type_name": type_name
})
db.session.commit()
return success_api(msg="更新成功")
# 启用字典
@admin_dict.put('/dictType/enable')
@authorize("admin:dict:edit", log=True)
def dict_type_enable():
_id = request.json.get('id')
if id:
res = curd.enable_status(DictType,_id)
if not res:
return fail_api(msg="出错啦")
return success_api("启动成功")
return fail_api(msg="数据错误")
# 禁用字典
@admin_dict.put('/dictType/disable')
@authorize("admin:dict:edit", log=True)
def dict_type_dis_enable():
_id = request.json.get('id')
if id:
res = curd.disable_status(DictType,_id)
if not res:
return fail_api(msg="出错啦")
return success_api("禁用成功")
return fail_api(msg="数据错误")
# 删除字典类型
@admin_dict.delete('/dictType/remove/<int:_id>')
@authorize("admin:dict:remove", log=True)
def dict_type_delete(_id):
res = curd.delete_one_by_id(DictType,_id)
if not res:
return fail_api(msg="删除失败")
return success_api(msg="删除成功")
@admin_dict.get('/dictData/data')
@authorize("admin:dict:main", log=True)
def dict_code_data():
type_code = xss_escape(request.args.get('typeCode', type=str))
dict_data = DictData.query.filter_by(type_code=type_code).layui_paginate()
count = dict_data.total
data = curd.model_to_dicts(schema=DictDataOutSchema, data=dict_data.items)
return table_api(data=data, count=count)
# 增加字典数据
@admin_dict.get('/dictData/add')
@authorize("admin:dict:add", log=True)
def dict_data_add():
type_code = request.args.get('typeCode', type=str)
return render_template('admin/dict/data/add.html', type_code=type_code)
# 增加字典数据
@admin_dict.post('/dictData/save')
@authorize("admin:dict:add", log=True)
def dict_data_save():
req_json = request.json
data_label = xss_escape(req_json.get("dataLabel"))
data_value = xss_escape(req_json.get("dataValue"))
enable = xss_escape(req_json.get("enable"))
remark = xss_escape(req_json.get("remark"))
type_code = xss_escape(req_json.get("typeCode"))
d = DictData(data_label=data_label, data_value=data_value, enable=enable, remark=remark, type_code=type_code)
db.session.add(d)
db.session.commit()
if not d.id:
return jsonify(success=False, msg="增加失败")
return jsonify(success=True, msg="增加成功")
# 编辑字典数据
@admin_dict.get('/dictData/edit')
@authorize("admin:dict:edit", log=True)
def dict_data_edit():
_id = request.args.get('dataId', type=str)
dict_data = curd.get_one_by_id(DictData, _id)
return render_template('admin/dict/data/edit.html', dict_data=dict_data)
# 编辑字典数据
@admin_dict.put('/dictData/update')
@authorize("admin:dict:edit", log=True)
def dict_data_update():
req_json = request.json
id = req_json.get("dataId")
DictData.query.filter_by(id=id).update({
"data_label": xss_escape(req_json.get("dataLabel")),
"data_value": xss_escape(req_json.get("dataValue")),
"enable": xss_escape(req_json.get("enable")),
"remark": xss_escape(req_json.get("remark")),
"type_code": xss_escape(req_json.get("typeCode"))
})
db.session.commit()
return success_api(msg="更新成功")
# 启用字典数据
@admin_dict.put('/dictData/enable')
@authorize("admin:dict:edit", log=True)
def dict_data_enable():
_id = request.json.get('dataId')
if _id:
res = curd.enable_status(model=DictData, id=_id)
if not res:
return fail_api(msg="出错啦")
return success_api(msg="启动成功")
return fail_api(msg="数据错误")
# 禁用字典数据
@admin_dict.put('/dictData/disable')
@authorize("admin:dict:edit", log=True)
def dict_data_disenable():
_id = request.json.get('dataId')
if _id:
res = curd.disable_status(model=DictData, id=_id)
if not res:
return fail_api(msg="出错啦")
return success_api(msg="禁用成功")
return fail_api(msg="数据错误")
# 删除字典类型
@admin_dict.delete('dictData/remove/<int:id>')
@authorize("admin:dict:remove", log=True)
def dict_data_delete(id):
res = curd.delete_one_by_id(model=DictData, id=id)
if not res:
return fail_api(msg="删除失败")
return success_api(msg="删除成功")
-19
View File
@@ -1,19 +0,0 @@
from flask import Blueprint, render_template
from flask_login import login_required, current_user
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
# 首页
@admin_bp.get('/')
@login_required
def index():
user = current_user
return render_template('admin/index.html', user=user)
# 控制台页面
@admin_bp.get('/welcome')
@login_required
def welcome():
return render_template('admin/console/console.html')
-88
View File
@@ -1,88 +0,0 @@
import os
import platform
import re
from datetime import datetime
import time
import psutil
from flask import Blueprint, render_template, jsonify
from applications.common.utils.rights import authorize
admin_monitor_bp = Blueprint('adminMonitor', __name__, url_prefix='/admin/monitor')
# 系统监控
@admin_monitor_bp.get('/')
@authorize("admin:monitor:main", log=True)
def main():
# 主机名称
hostname = platform.node()
# 系统版本
system_version = platform.platform()
# python版本
python_version = platform.python_version()
# 逻辑cpu数量
cpu_count = psutil.cpu_count()
# cup使用率
cpus_percent = psutil.cpu_percent(interval=0.1)
# 内存
memory_information = psutil.virtual_memory()
# 内存使用率
memory_usage = memory_information.percent
memory_used = str(round(memory_information.used / 1024 / 1024))
memory_total = str(round(memory_information.total / 1024 / 1024))
memory_free = str(round(memory_information.free / 1024 / 1024))
# 磁盘信息
disk_partitions_list = []
# 判断是否在容器中
if not os.path.exists('/.dockerenv'):
disk_partitions = psutil.disk_partitions()
for i in disk_partitions:
a = psutil.disk_usage(i.device)
disk_partitions_dict = {
'device': i.device,
'fstype': i.fstype,
'total': str(round(a.total / 1024 / 1024)),
'used': str(round(a.used / 1024 / 1024)),
'free': str(round(a.free / 1024 / 1024)),
'percent': a.percent
}
disk_partitions_list.append(disk_partitions_dict)
# 开机时间
boot_time = datetime.fromtimestamp(psutil.boot_time()).replace(microsecond=0)
up_time = datetime.now().replace(microsecond=0) - boot_time
up_time_list = re.split(r':', str(up_time))
up_time_format = " {} 小时{} 分钟{}".format(up_time_list[0], up_time_list[1], up_time_list[2])
# 当前时间
time_now = time.strftime('%H:%M:%S ', time.localtime(time.time()))
return render_template('admin/monitor.html',
hostname=hostname,
system_version=system_version,
python_version=python_version,
cpus_percent=cpus_percent,
memory_usage=memory_usage,
cpu_count=cpu_count,
memory_used=memory_used,
memory_total=memory_total,
memory_free=memory_free,
boot_time=boot_time,
up_time_format=up_time_format,
disk_partitions_list=disk_partitions_list,
time_now=time_now
)
# 图表 api
@admin_monitor_bp.get('/polling')
@authorize("admin:monitor:main")
def ajax_polling():
# 获取cup使用率
cpus_percent = psutil.cpu_percent(interval=0.1)
# 获取内存使用率
memory_information = psutil.virtual_memory()
memory_usage = memory_information.percent
time_now = time.strftime('%H:%M:%S ', time.localtime(time.time()))
return jsonify(cups_percent=cpus_percent, memory_used=memory_usage, time_now=time_now)
-167
View File
@@ -1,167 +0,0 @@
from flask import Blueprint, render_template, request, jsonify
from applications.common import curd
from applications.common.utils.http import success_api, fail_api
from applications.common.utils.rights import authorize
from applications.common.utils.validate import xss_escape
from applications.extensions import db
from applications.models import Power
from applications.schemas import PowerOutSchema2
admin_power = Blueprint('adminPower', __name__, url_prefix='/admin/power')
@admin_power.get('/')
@authorize("admin:power:main", log=True)
def index():
return render_template('admin/power/main.html')
@admin_power.post('/data')
@authorize("admin:power:main", log=True)
def data():
power = Power.query.all()
res = {
"data": curd.model_to_dicts(schema=PowerOutSchema2, data=power)
}
return jsonify(res)
@admin_power.get('/add')
@authorize("admin:power:add", log=True)
def add():
return render_template('admin/power/add.html')
@admin_power.get('/selectParent')
@authorize("admin:power:main", log=True)
def select_parent():
power = Power.query.all()
res = curd.model_to_dicts(schema=PowerOutSchema2, data=power)
res.append({"powerId": 0, "powerName": "顶级权限", "parentId": -1})
res = {
"status": {"code": 200, "message": "默认"},
"data": res
}
return jsonify(res)
# 增加
@admin_power.post('/save')
@authorize("admin:power:add", log=True)
def save():
req = request.json
icon = xss_escape(req.get("icon"))
openType = xss_escape(req.get("openType"))
parentId = xss_escape(req.get("parentId"))
powerCode = xss_escape(req.get("powerCode"))
powerName = xss_escape(req.get("powerName"))
powerType = xss_escape(req.get("powerType"))
powerUrl = xss_escape(req.get("powerUrl"))
sort = xss_escape(req.get("sort"))
power = Power(
icon=icon,
open_type=openType,
parent_id=parentId,
code=powerCode,
name=powerName,
type=powerType,
url=powerUrl,
sort=sort,
enable=1
)
r = db.session.add(power)
db.session.commit()
return success_api(msg="成功")
# 权限编辑
@admin_power.get('/edit/<int:_id>')
@authorize("admin:power:edit", log=True)
def edit(_id):
power = curd.get_one_by_id(Power, _id)
icon = str(power.icon).split()
if len(icon) == 2:
icon = icon[1]
else:
icon = None
return render_template('admin/power/edit.html', power=power, icon=icon)
# 权限更新
@admin_power.put('/update')
@authorize("admin:power:edit", log=True)
def update():
req_json = request.json
id = request.json.get("powerId")
data = {
"icon": xss_escape(req_json.get("icon")),
"open_type": xss_escape(req_json.get("openType")),
"parent_id": xss_escape(req_json.get("parentId")),
"code": xss_escape(req_json.get("powerCode")),
"name": xss_escape(req_json.get("powerName")),
"type": xss_escape(req_json.get("powerType")),
"url": xss_escape(req_json.get("powerUrl")),
"sort": xss_escape(req_json.get("sort"))
}
res = Power.query.filter_by(id=id).update(data)
db.session.commit()
if not res:
return fail_api(msg="更新权限失败")
return success_api(msg="更新权限成功")
# 启用权限
@admin_power.put('/enable')
@authorize("admin:power:edit", log=True)
def enable():
_id = request.json.get('powerId')
if id:
res = curd.enable_status(Power, _id)
if not res:
return fail_api(msg="出错啦")
return success_api(msg="启用成功")
return fail_api(msg="数据错误")
# 禁用权限
@admin_power.put('/disable')
@authorize("admin:power:edit", log=True)
def dis_enable():
_id = request.json.get('powerId')
if id:
res = curd.disable_status(Power, _id)
if not res:
return fail_api(msg="出错啦")
return success_api(msg="禁用成功")
return fail_api(msg="数据错误")
# 权限删除
@admin_power.delete('/remove/<int:id>')
@authorize("admin:power:remove", log=True)
def remove(id):
power = Power.query.filter_by(id=id).first()
power.role = []
r = Power.query.filter_by(id=id).delete()
db.session.commit()
if r:
return success_api(msg="删除成功")
else:
return fail_api(msg="删除失败")
# 批量删除
@admin_power.delete('/batchRemove')
@authorize("admin:power:remove", log=True)
def batch_remove():
ids = request.form.getlist('ids[]')
for id in ids:
power = Power.query.filter_by(id=id).first()
power.role = []
r = Power.query.filter_by(id=id).delete()
db.session.commit()
return success_api(msg="批量删除成功")
-204
View File
@@ -1,204 +0,0 @@
from flask import Blueprint, render_template, request, jsonify
from flask_login import login_required
from applications.common.curd import model_to_dicts, enable_status, disable_status, get_one_by_id
from applications.common.helper import ModelFilter
from applications.common.utils.http import table_api, success_api, fail_api
from applications.common.utils.rights import authorize
from applications.common.utils.validate import xss_escape
from applications.extensions import db
from applications.models import Role, Power, User
from applications.schemas import RoleOutSchema, PowerOutSchema2
admin_role = Blueprint('adminRole', __name__, url_prefix='/admin/role')
# 用户管理
@admin_role.get('/')
@authorize("admin:role:main", log=True)
def main():
return render_template('admin/role/main.html')
# 表格数据
@admin_role.get('/data')
@authorize("admin:role:main", log=True)
def table():
# 获取请求参数
role_name = xss_escape(request.args.get('roleName', type=str))
role_code = xss_escape(request.args.get('roleCode', type=str))
# 查询参数构造
mf = ModelFilter()
if role_name:
mf.vague(field_name="name", value=role_name)
if role_code:
mf.vague(field_name="code", value=role_code)
# orm查询
# 使用分页获取data需要.items
role = Role.query.filter(mf.get_filter(Role)).layui_paginate()
count = role.total
# 返回api
return table_api(data=model_to_dicts(schema=RoleOutSchema, data=role.items), count=count)
# 角色增加
@admin_role.get('/add')
@authorize("admin:role:add", log=True)
def add():
return render_template('admin/role/add.html')
# 角色增加
@admin_role.post('/save')
@authorize("admin:role:add", log=True)
def save():
req = request.json
details = xss_escape(req.get("details"))
enable = xss_escape(req.get("enable"))
roleCode = xss_escape(req.get("roleCode"))
roleName = xss_escape(req.get("roleName"))
sort = xss_escape(req.get("sort"))
role = Role(
details=details,
enable=enable,
code=roleCode,
name=roleName,
sort=sort
)
db.session.add(role)
db.session.commit()
return success_api(msg="成功")
# 角色授权
@admin_role.get('/power/<int:_id>')
@authorize("admin:role:power", log=True)
def power(_id):
return render_template('admin/role/power.html', id=_id)
# 获取角色权限
@admin_role.get('/getRolePower/<int:id>')
@authorize("admin:role:main", log=True)
def get_role_power(id):
role = Role.query.filter_by(id=id).first()
check_powers = role.power
check_powers_list = []
for cp in check_powers:
check_powers_list.append(cp.id)
powers = Power.query.all()
power_schema = PowerOutSchema2(many=True) # 用已继承ma.ModelSchema类的自定制类生成序列化类
output = power_schema.dump(powers) # 生成可序列化对象
for i in output:
if int(i.get("powerId")) in check_powers_list:
i["checkArr"] = "1"
else:
i["checkArr"] = "0"
res = {
"data": output,
"status": {"code": 200, "message": "默认"}
}
return jsonify(res)
# 保存角色权限
@admin_role.put('/saveRolePower')
@authorize("admin:role:edit", log=True)
def save_role_power():
req_form = request.form
power_ids = req_form.get("powerIds")
power_list = power_ids.split(',')
role_id = req_form.get("roleId")
role = Role.query.filter_by(id=role_id).first()
powers = Power.query.filter(Power.id.in_(power_list)).all()
role.power = powers
db.session.commit()
return success_api(msg="授权成功")
# 角色编辑
@admin_role.get('/edit/<int:id>')
@authorize("admin:role:edit", log=True)
def edit(id):
r = get_one_by_id(model=Role, id=id)
return render_template('admin/role/edit.html', role=r)
# 更新角色
@admin_role.put('/update')
@authorize("admin:role:edit", log=True)
def update():
req_json = request.json
id = req_json.get("roleId")
data = {
"code": xss_escape(req_json.get("roleCode")),
"name": xss_escape(req_json.get("roleName")),
"sort": xss_escape(req_json.get("sort")),
"enable": xss_escape(req_json.get("enable")),
"details": xss_escape(req_json.get("details"))
}
role = Role.query.filter_by(id=id).update(data)
db.session.commit()
if not role:
return fail_api(msg="更新角色失败")
return success_api(msg="更新角色成功")
# 启用用户
@admin_role.put('/enable')
@authorize("admin:role:edit", log=True)
def enable():
id = request.json.get('roleId')
if id:
res = enable_status(Role, id)
if not res:
return fail_api(msg="出错啦")
return success_api(msg="启动成功")
return fail_api(msg="数据错误")
# 禁用用户
@admin_role.put('/disable')
@authorize("admin:role:edit", log=True)
def dis_enable():
_id = request.json.get('roleId')
if _id:
res = disable_status(Role, _id)
if not res:
return fail_api(msg="出错啦")
return success_api(msg="禁用成功")
return fail_api(msg="数据错误")
# 角色删除
@admin_role.delete('/remove/<int:id>')
@authorize("admin:role:remove", log=True)
def remove(id):
role = Role.query.filter_by(id=id).first()
# 删除该角色的权限和用户
role.power = []
role.user = []
r = Role.query.filter_by(id=id).delete()
db.session.commit()
if not r:
return fail_api(msg="角色删除失败")
return success_api(msg="角色删除成功")
# 批量删除
@admin_role.delete('/batchRemove')
@authorize("admin:role:remove", log=True)
@login_required
def batch_remove():
ids = request.form.getlist('ids[]')
for id in ids:
role = Role.query.filter_by(id=id).first()
# 删除该角色的权限和用户
role.power = []
role.user = []
r = Role.query.filter_by(id=id).delete()
db.session.commit()
return success_api(msg="批量删除成功")
-112
View File
@@ -1,112 +0,0 @@
from flask import Blueprint, request, render_template
from flask_apscheduler.utils import job_to_dict
from applications.common.tasks import tasks
from applications.common.tasks.tasks import task_list
from applications.common.utils.http import table_api, fail_api, success_api
from applications.extensions.init_apscheduler import scheduler
admin_task = Blueprint('adminTask', __name__, url_prefix='/admin/task')
@admin_task.route('/add_job', methods=['GET'])
def add_task():
scheduler.add_job(func=tasks.get(), id='4', args=(1, 1), trigger='interval', seconds=3,
replace_existing=True)
return '6'
@admin_task.get('/')
def main():
return render_template('admin/task/main.html')
@admin_task.route('/data', methods=['GET'])
def get_task(): # 获取
jobs = scheduler.get_jobs()
jobs_list = []
for job in jobs:
jobs_list.append(job_to_dict(job))
return table_api(data=jobs_list, count=len(jobs_list))
# 增加
@admin_task.get('/add')
def add():
return render_template('admin/task/add.html', task_list=task_list)
@admin_task.post('/save')
def save():
_id = request.json.get("id")
name = request.json.get("id")
type = request.json.get("type")
functions = request.json.get("functions")
datetime = request.json.get("datetime")
time = request.json.get("time")
if not hasattr(tasks, functions):
return fail_api()
if type == 'date':
scheduler.add_job(
func=getattr(tasks, functions),
id=_id,
name=name,
args=(1, 1),
trigger=type,
run_date=datetime,
replace_existing=True)
elif type == 'interval':
scheduler.add_job(
func=getattr(tasks, functions),
id=_id,
name=name,
args=(1, 1),
trigger=type,
replace_existing=True)
elif type == 'cron':
scheduler.add_job(
func=getattr(tasks, functions),
id=_id,
name=name,
args=(1, 1),
trigger=type,
replace_existing=True)
return success_api()
# 恢复
@admin_task.put('/enable')
def enable():
_id = request.json.get('id')
# print(id)
if _id:
scheduler.resume_job(str(_id))
return success_api(msg="启动成功")
return fail_api(msg="数据错误")
# 暂停
@admin_task.put('/disable')
def dis_enable():
_id = request.json.get('id')
if _id:
scheduler.pause_job(str(_id))
return success_api(msg="暂停成功")
return fail_api(msg="数据错误")
@admin_task.delete('/remove/<int:_id>')
def remove_job(_id): # 移除
scheduler.remove_job(str(_id))
return success_api(msg="删除成功")
# scheduler.add_job(func=task1, id='2', args=(1, 1), trigger='cron', day_of_week='0-6', hour=18, minute=24,
# second=10, replace_existing=True)
# scheduler.add_job(func=task4, id='4', args=(2, 2), trigger='interval', seconds=3,
# replace_existing=True, misfire_grace_time=3)
# # trigger='interval' 表示是一个循环任务,每隔多久执行一次
# scheduler.add_job(func=task2, id='3', args=(2, 2), trigger='interval', seconds=3,
# replace_existing=True)
-144
View File
@@ -1,144 +0,0 @@
from flask import Blueprint, render_template, request, jsonify
from applications.common import curd
from applications.common.utils import validate
from applications.common.utils.http import success_api, fail_api
from applications.common.utils.rights import authorize
from applications.common.utils.validate import xss_escape
from applications.extensions import db
from applications.models import Dept, User
from applications.schemas import DeptOutSchema
dept_bp = Blueprint('dept', __name__, url_prefix='/dept')
def register_dept_views(app):
app.register_blueprint(dept_bp)
@dept_bp.get('/')
@authorize("admin:dept:main", log=True)
def main():
return render_template('admin/dept/main.html')
@dept_bp.post('/data')
@authorize("admin:dept:main", log=True)
def data():
dept = Dept.query.order_by(Dept.sort).all()
power_data = curd.model_to_dicts(schema=DeptOutSchema, data=dept)
res = {
"data": power_data
}
return jsonify(res)
@dept_bp.get('/add')
@authorize("admin:dept:add", log=True)
def add():
return render_template('admin/dept/add.html')
@dept_bp.get('/tree')
@authorize("admin:dept:main", log=True)
def tree():
dept = Dept.query.order_by(Dept.sort).all()
power_data = curd.model_to_dicts(schema=DeptOutSchema, data=dept)
res = {
"status": {"code": 200, "message": "默认"},
"data": power_data
}
return jsonify(res)
@dept_bp.post('/save')
@authorize("admin:dept:add", log=True)
def save():
req_json = request.json
dept = Dept(
parent_id=req_json.get('parentId'),
dept_name=xss_escape(req_json.get('deptName')),
sort=xss_escape(req_json.get('sort')),
leader=xss_escape(req_json.get('leader')),
phone=xss_escape(req_json.get('phone')),
email=xss_escape(req_json.get('email')),
status=xss_escape(req_json.get('status')),
address=xss_escape(req_json.get('address'))
)
r = db.session.add(dept)
db.session.commit()
return success_api(msg="成功")
@dept_bp.get('/edit')
@authorize("admin:dept:edit", log=True)
def edit():
_id = request.args.get("deptId")
dept = curd.get_one_by_id(model=Dept, id=_id)
return render_template('admin/dept/edit.html', dept=dept)
# 启用
@dept_bp.put('/enable')
@authorize("admin:dept:edit", log=True)
def enable():
id = request.json.get('deptId')
if id:
enable = 1
d = Dept.query.filter_by(id=id).update({"status": enable})
if d:
db.session.commit()
return success_api(msg="启用成功")
return fail_api(msg="出错啦")
return fail_api(msg="数据错误")
# 禁用
@dept_bp.put('/disable')
@authorize("admin:dept:edit", log=True)
def dis_enable():
id = request.json.get('deptId')
if id:
enable = 0
d = Dept.query.filter_by(id=id).update({"status": enable})
if d:
db.session.commit()
return success_api(msg="禁用成功")
return fail_api(msg="出错啦")
return fail_api(msg="数据错误")
@dept_bp.put('/update')
@authorize("admin:dept:edit", log=True)
def update():
json = request.json
id = json.get("deptId"),
data = {
"dept_name": validate.xss_escape(json.get("deptName")),
"sort": validate.xss_escape(json.get("sort")),
"leader": validate.xss_escape(json.get("leader")),
"phone": validate.xss_escape(json.get("phone")),
"email": validate.xss_escape(json.get("email")),
"status": validate.xss_escape(json.get("status")),
"address": validate.xss_escape(json.get("address"))
}
d = Dept.query.filter_by(id=id).update(data)
if not d:
return fail_api(msg="更新失败")
db.session.commit()
return success_api(msg="更新成功")
@dept_bp.delete('/remove/<int:_id>')
@authorize("admin:dept:remove", log=True)
def remove(_id):
d = Dept.query.filter_by(id=_id).delete()
if not d:
return fail_api(msg="删除失败")
res = User.query.filter_by(dept_id=_id).update({"dept_id": None})
db.session.commit()
if res:
return success_api(msg="删除成功")
else:
return fail_api(msg="删除失败")
+71
View File
@@ -0,0 +1,71 @@
"""
健康检查端点(无需登录)
- GET /healthz
用于探活/负载均衡/容器 readinessProbe。
返回:
{
"status": "ok" | "degraded",
"db_ok": true | false,
"env": "dev" | "production",
"system": "Pear Admin",
"timestamp": "2026-09-05T17:00:00+08:00"
}
HTTP 状态码:
200 - 全部正常
503 - DB 不可达(用于触发 k8s readinessProbe 失败)
"""
from datetime import datetime, timedelta, timezone
import os
from flask import Blueprint, current_app, jsonify
from sqlalchemy import text
from applications.extensions import db
bp = Blueprint("health", __name__)
def _beijing_now() -> str:
"""返回 +08:00 时区 ISO8601 时间字符串。"""
tz = timezone(timedelta(hours=8))
return datetime.now(tz).isoformat()
@bp.get("/healthz")
def healthz():
# 优先按 PEAR_ENV/FLASK_ENV 取环境名;兜底按 DEBUG 反推
env = (
os.environ.get("PEAR_ENV")
or os.environ.get("FLASK_ENV")
or current_app.config.get("PEAR_ENV")
or ("dev" if current_app.config.get("DEBUG") else "production")
)
system_name = current_app.config.get("SYSTEM_NAME", "Pear Admin")
# DB 探活:跑一个最轻量的 SELECT 1
db_ok = True
db_error = None
try:
db.session.execute(text("SELECT 1"))
except Exception as e: # noqa: BLE001 - 这里就是要把异常吃掉,转换成 False
db_ok = False
db_error = str(e)
finally:
try:
db.session.rollback()
except Exception:
pass
payload = {
"status": "ok" if db_ok else "degraded",
"db_ok": db_ok,
"env": env,
"system": system_name,
"timestamp": _beijing_now(),
}
if db_error:
payload["db_error"] = db_error
return jsonify(payload), (200 if db_ok else 503)
-12
View File
@@ -1,12 +0,0 @@
from applications.view.index.index import index_bp
from . import index
def register_index_views(app):
"""
初始化蓝图
"""
app.register_blueprint(index_bp)
-8
View File
@@ -1,8 +0,0 @@
from flask import render_template, Blueprint
index_bp = Blueprint('Index', __name__, url_prefix='/')
@index_bp.route('/')
def index():
return render_template('index/index.html')
+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='旺珂 · 导航',
)
+230
View File
@@ -0,0 +1,230 @@
"""
前台导航页视图(无需登录)。
URL 路径(挂在根路径下,便于匿名访问):
- GET /site 公开导航聚合首页(按分类展示所有启用的导航)
- GET /site/category/<name> 分类详情:展示指定分类下所有启用的导航
- GET /site/api/navs JSON 接口(供前端动态加载)
注意:
- 这些视图**不加** @authorize,避免 login_required 强制登录。
- 使用 url_prefix='/site' 避免与后台 / 路由冲突(后台 / 是登录后的工作台)。
"""
from collections import OrderedDict
import time
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')
# 分类列表缓存(左侧侧栏 / 移动端抽屉共用,避免每个请求都查库)
_NAV_CATS_CACHE = {'ts': 0.0, 'data': []}
_NAV_CATS_TTL = 30.0
def get_nav_categories() -> "list[dict]":
"""返回前台导航用的分类列表(按后台 sort 排序,带稳定锚点)。
返回形如::
[{'name': '开发工具', 'icon': 'layui-icon-list',
'description': '', 'anchor': 'cat-1'}, ...]
锚点 ``anchor`` 基于「启用分类」的排序索引生成(从 1 开始),与首页
各 ``<section>`` 的 id 严格一致——即使某个分类下暂时没有导航(空分组
被过滤)也不会错位。
带 30s 进程内缓存:后台新增/调整分类后最多 30s 生效。
"""
now = time.time()
cached = _NAV_CATS_CACHE.get('data') or []
if cached and (now - _NAV_CATS_CACHE.get('ts', 0.0)) < _NAV_CATS_TTL:
return cached
try:
cats = (
NavCategory.query
.filter(NavCategory.enable == 1)
.order_by(NavCategory.sort.desc(), NavCategory.id.asc())
.all()
)
names = [c.name for c in cats]
meta = {c.name: (c.icon, c.description) for c in cats}
# 兜底:只在 Nav 表里出现、未登记到 NavCategory 的历史分类,
# 追加到末尾(与 _grouped_navs 的兜底顺序一致:按分类名升序),
# 保证侧栏锚点与首页 section 一一对应。
used = Nav.query.filter(Nav.enable == 1).with_entities(Nav.category).distinct().all()
names.extend(sorted(n for (n,) in used if n and n not in meta))
data = []
for idx, name in enumerate(names):
icon, desc = meta.get(name, (None, None))
data.append({
'name': name,
'icon': icon or 'layui-icon-list',
'description': desc or '',
'anchor': 'cat-%d' % (idx + 1),
})
except Exception: # 数据库尚未初始化(如迁移前)时不影响页面渲染
data = []
_NAV_CATS_CACHE['ts'] = now
_NAV_CATS_CACHE['data'] = data
return data
def get_cat_anchor_map() -> "dict[str, str]":
"""分类名 -> 锚点 id 的映射,供首页渲染 section id 使用。"""
return {c['name']: c['anchor'] for c in get_nav_categories()}
def clear_nav_categories_cache():
"""后台改动分类后调用:立即失效缓存。"""
_NAV_CATS_CACHE['ts'] = 0.0
_NAV_CATS_CACHE['data'] = []
@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(),
cat_anchors=get_cat_anchor_map(),
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}
-9
View File
@@ -1,9 +0,0 @@
from flask import Blueprint, Flask
rights_bp = Blueprint('rights', __name__, url_prefix='/rights')
from . import routes
def register_rights_view(app: Flask):
app.register_blueprint(rights_bp)
-20
View File
@@ -1,20 +0,0 @@
# 渲染配置
from flask import jsonify
from flask_login import login_required
from . import rights_bp
from ...common import admin
@rights_bp.get('/configs')
@login_required
def configs():
return admin.get_render_config()
# 菜单
@rights_bp.get('/menu')
@login_required
def menu():
menu_tree = admin.make_menu_tree()
return jsonify(menu_tree)
+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="删除成功")
@@ -4,22 +4,24 @@ from flask import Blueprint, request, render_template, jsonify, current_app
from applications.common.utils.http import fail_api, success_api, table_api
from applications.common.utils.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
admin_file = Blueprint('adminFile', __name__, url_prefix='/admin/file')
bp = Blueprint('adminFile', __name__, url_prefix='/file')
# 图片管理
@admin_file.get('/')
@authorize("admin:file:main", log=True)
@bp.get('/')
@authorize("system:file:main")
def index():
return render_template('admin/photo/photo.html')
return render_template('system/photo/photo.html')
# 图片数据
@admin_file.get('/table')
@authorize("admin:file:main", log=True)
@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)
@@ -28,15 +30,15 @@ def table():
# 上传
@admin_file.get('/upload')
@authorize("admin:file:add", log=True)
@bp.get('/upload')
@authorize("system:file:add", log=True)
def upload():
return render_template('admin/photo/photo_add.html')
return render_template('system/photo/photo_add.html')
# 上传接口
@admin_file.post('/upload')
@authorize("admin:file:add", log=True)
@bp.post('/upload')
@authorize("system:file:add", log=True)
def upload_api():
if 'file' in request.files:
photo = request.files['file']
@@ -55,8 +57,8 @@ def upload_api():
# 图片删除
@admin_file.route('/delete', methods=['GET', 'POST'])
@authorize("admin:file:delete", log=True)
@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)
@@ -67,8 +69,8 @@ def delete():
# 图片批量删除
@admin_file.route('/batchRemove', methods=['GET', 'POST'])
@authorize("admin:file:delete", log=True)
@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()
+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()
@@ -6,35 +6,38 @@ from applications.models import AdminLog
from applications.schemas import LogOutSchema
from applications.common.curd import model_to_dicts
admin_log = Blueprint('adminLog', __name__, url_prefix='/admin/log')
bp = Blueprint('log', __name__, url_prefix='/log')
# 日志管理
@admin_log.get('/')
@authorize("admin:log:main")
@bp.get('/')
@authorize("system:log:main")
def index():
return render_template('admin/admin_log/main.html')
return render_template('system/admin_log/main.html')
# 登录日志
@admin_log.get('/loginLog')
@authorize("admin:log:main")
@bp.get('/loginLog')
@authorize("system:log:main")
def login_log():
# orm查询
# 使用分页获取data需要.items
log = AdminLog.query.filter_by(url='/passport/login').order_by(desc(AdminLog.create_time)).layui_paginate()
log = AdminLog.query.filter_by(
url='/system/passport/login'
).order_by(desc(AdminLog.create_time)).layui_paginate()
count = log.total
return table_api(data= model_to_dicts(schema=LogOutSchema, data=log.items), count=count)
return table_api(data=model_to_dicts(schema=LogOutSchema, data=log.items), count=count)
# 操作日志
@admin_log.get('/operateLog')
@authorize("admin:log:main")
@bp.get('/operateLog')
@authorize("system:log:main")
def operate_log():
# orm查询
# 使用分页获取data需要.items
log = AdminLog.query.filter(
AdminLog.url != '/passport/login').order_by(
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)
@@ -1,40 +1,36 @@
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 import admin as index_curd
from applications.common.admin_log import login_log
from applications.common.admin import get_captcha, login_log
from applications.common.utils.http import fail_api, success_api
from applications.models import User
passport_bp = Blueprint('passport', __name__, url_prefix='/passport')
def register_passport_views(app):
app.register_blueprint(passport_bp)
bp = Blueprint('passport', __name__, url_prefix='/passport')
# 获取验证码
@passport_bp.get('/getCaptcha')
def get_captcha():
resp, code = index_curd.get_captcha()
@bp.get('/getCaptcha')
def captcha():
resp, code = get_captcha()
session["code"] = code
return resp
# 登录
@passport_bp.get('/login')
@bp.get('/login')
def login():
if current_user.is_authenticated:
return redirect(url_for('admin.index'))
return render_template('admin/login.html')
return redirect(url_for('index.index'))
return render_template('system/login.html')
# 登录
@passport_bp.post('/login')
@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:
@@ -57,20 +53,39 @@ def login_post():
if username == user.username and user.validate_password(password):
# 登录
login_user(user)
login_user(user, remember=remember)
# 记录登录日志
login_log(request, uid=user.id, is_access=True)
# 存入权限
index_curd.add_auth_session()
# 授权路由存入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="用户名或密码错误")
# 退出登录
@passport_bp.post('/logout')
@bp.post('/logout')
@login_required
def logout():
logout_user()
session.pop('permissions')
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="角色删除成功")
@@ -3,66 +3,79 @@ from flask_login import login_required, current_user
from sqlalchemy import desc
from applications.common import curd
from applications.common.curd import model_to_dicts, enable_status, disable_status
from applications.common.helper import ModelFilter
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 xss_escape
from applications.common.utils.validate import str_escape
from applications.extensions import db
from applications.models import Role
from applications.extensions.init_limit import limiter
from applications.models import Role, Dept
from applications.models import User, AdminLog
from applications.schemas import UserOutSchema
admin_user = Blueprint('adminUser', __name__, url_prefix='/admin/user')
bp = Blueprint('user', __name__, url_prefix='/user')
# 用户管理
@admin_user.get('/')
@authorize("admin:user:main", log=True)
@bp.get('/')
@authorize("system:user:main")
def main():
return render_template('admin/user/main.html')
return render_template('system/user/main.html')
# 用户分页查询
@admin_user.get('/data')
@authorize("admin:user:main", log=True)
@bp.get('/data')
@limiter.limit("60 per minute")
@authorize("system:user:main")
def data():
# 获取请求参数
real_name = xss_escape(request.args.get('realName', type=str))
username = xss_escape(request.args.get('username', type=str))
dept_id = request.args.get('deptId', type=int)
# 查询参数构造
mf = ModelFilter()
if real_name:
mf.contains(field_name="realname", value=real_name)
if username:
mf.contains(field_name="username", value=username)
if dept_id:
mf.exact(field_name="dept_id", value=dept_id)
# orm查询
# 使用分页获取data需要.items
user = User.query.filter(mf.get_filter(model=User)).layui_paginate()
count = user.total
# 返回api
return table_api(data=model_to_dicts(schema=UserOutSchema, data=user.items), count=count)
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)
# 用户增加
@admin_user.get('/add')
@authorize("admin:user:add", log=True)
@bp.get('/add')
@authorize("system:user:add", log=True)
def add():
roles = Role.query.all()
return render_template('admin/user/add.html', roles=roles)
return render_template('system/user/add.html', roles=roles)
@admin_user.post('/save')
@authorize("admin:user:add", log=True)
@bp.post('/save')
@authorize("system:user:add", log=True)
def save():
req_json = request.json
req_json = request.get_json(force=True)
a = req_json.get("roleIds")
username = xss_escape(req_json.get('username'))
real_name = xss_escape(req_json.get('realName'))
password = xss_escape(req_json.get('password'))
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:
@@ -70,23 +83,25 @@ def save():
if bool(User.query.filter_by(username=username).count()):
return fail_api(msg="用户已经存在")
user = User(username=username, realname=real_name)
user = User(username=username, realname=real_name, enable=1, dept_id=dept_id)
user.set_password(password)
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="增加成功")
# 删除用户
@admin_user.delete('/remove/<int:id>')
@authorize("admin:user:remove", log=True)
@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:
@@ -95,27 +110,27 @@ def delete(id):
# 编辑用户
@admin_user.get('/edit/<int:id>')
@authorize("admin:user:edit", log=True)
@bp.get('/edit/<int:id>')
@authorize("system:user:edit", log=True)
def edit(id):
user = curd.get_one_by_id(User,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('admin/user/edit.html', user=user, roles=roles, checked_roles=checked_roles)
return render_template('system/user/edit.html', user=user, roles=roles, checked_roles=checked_roles)
# 编辑用户
@admin_user.put('/update')
@authorize("admin:user:edit", log=True)
@bp.put('/update')
@authorize("system:user:edit", log=True)
def update():
req_json = request.json
a = xss_escape(req_json.get("roleIds"))
id = xss_escape(req_json.get("userId"))
username = xss_escape(req_json.get('username'))
real_name = xss_escape(req_json.get('realName'))
dept_id = xss_escape(req_json.get('deptId'))
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()
@@ -128,27 +143,27 @@ def update():
# 个人中心
@admin_user.get('/center')
@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('admin/user/center.html', user_info=user_info, user_logs=user_logs)
return render_template('system/user/center.html', user_info=user_info, user_logs=user_logs)
# 修改头像
@admin_user.get('/profile')
@bp.get('/profile')
@login_required
def profile():
return render_template('admin/user/profile.html')
return render_template('system/user/profile.html')
# 修改头像
@admin_user.put('/updateAvatar')
@bp.put('/updateAvatar')
@login_required
def update_avatar():
url = request.json.get("avatar").get("src")
url = request.get_json(force=True).get("avatar").get("src")
r = User.query.filter_by(id=current_user.id).update({"avatar": url})
db.session.commit()
if not r:
@@ -157,10 +172,10 @@ def update_avatar():
# 修改当前用户信息
@admin_user.put('/updateInfo')
@bp.put('/updateInfo')
@login_required
def update_info():
req_json = request.json
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()
@@ -170,21 +185,21 @@ def update_info():
# 修改当前用户密码
@admin_user.get('/editPassword')
@bp.get('/editPassword')
@login_required
def edit_password():
return render_template('admin/user/edit_password.html')
return render_template('system/user/edit_password.html')
# 修改当前用户密码
@admin_user.put('/editPassword')
@bp.put('/editPassword')
@login_required
def edit_password_put():
res_json = request.json
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("次密码不一样")
return fail_api("次密码不一样")
user = current_user
is_right = user.validate_password(res_json.get("oldPassword"))
if not is_right:
@@ -196,10 +211,10 @@ def edit_password_put():
# 启用用户
@admin_user.put('/enable')
@authorize("admin:user:edit", log=True)
@bp.put('/enable')
@authorize("system:user:edit", log=True)
def enable():
_id = request.json.get('userId')
_id = request.get_json(force=True).get('userId')
if _id:
res = enable_status(model=User, id=_id)
if not res:
@@ -209,27 +224,13 @@ def enable():
# 禁用用户
@admin_user.put('/disable')
@authorize("admin:user:edit", log=True)
@bp.put('/disable')
@authorize("system:user:edit", log=True)
def dis_enable():
_id = request.json.get('userId')
_id = request.get_json(force=True).get('userId')
if _id:
res = disable_status(model=User,id=_id)
res = disable_status(model=User, id=_id)
if not res:
return fail_api(msg="出错啦")
return success_api(msg="禁用成功")
return fail_api(msg="数据错误")
# 批量删除
@admin_user.delete('/batchRemove')
@authorize("admin:user:remove", log=True)
def batch_remove():
ids = request.form.getlist('ids[]')
for id in ids:
user = User.query.filter_by(id=id).first()
user.role = []
res = User.query.filter_by(id=id).delete()
db.session.commit()
return success_api(msg="批量删除成功")
+25
View File
@@ -0,0 +1,25 @@
FROM python:3.11-bookworm
# 设置时区
ENV TIME_ZONE Asia/Shanghai
RUN echo "${TIME_ZONE}" > /etc/timezone \
&& ln -sf /usr/share/zoneinfo/${TIME_ZONE} /etc/localtime
WORKDIR /app
# 1. [优化] 先只拷贝依赖文件,利用 Docker 缓存
# 只要 requirements.txt 没变,这一步和 pip install 就会直接用缓存,极大加快构建速度
COPY requirements.txt /app/
# 2. 安装依赖 (使用清华源)
RUN python3 -m pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple/ \
&& python3 -m pip install --no-cache-dir gunicorn -i https://pypi.tuna.tsinghua.edu.cn/simple/
# 3. 拷贝其余项目代码
COPY . /app/
# 4. 确保脚本有执行权限 (直接处理源路径,防止移动失败)
RUN chmod +x /app/deploy/dev/start.sh
# 5. 指定启动入口
CMD ["/bin/bash", "/app/deploy/dev/start.sh"]

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