Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a33648b9a | ||
|
|
0bfe6f9d8c | ||
|
|
05f1ac6a00 | ||
|
|
be99b8a925 | ||
|
|
34e8010830 | ||
|
|
a47ad991d0 | ||
|
|
2fe59e15ef | ||
|
|
98edfdaf2d | ||
|
|
0ffc103e46 | ||
|
|
1ea69c2c34 | ||
|
|
1214bfdf4a | ||
|
|
9ba96bcc3e | ||
|
|
115585c00d | ||
|
|
b208087f74 | ||
|
|
3debece5d3 | ||
|
|
97eda740fe | ||
|
|
372b0030c3 | ||
|
|
12c31a8e8a | ||
|
|
490f538261 | ||
|
|
9f64f7bdac | ||
|
|
96b93b08a1 | ||
|
|
3bfcd1d21f | ||
|
|
c55e47ba4d | ||
|
|
29c6e5dbb6 | ||
|
|
0ebc9394ad | ||
|
|
da453c839e | ||
|
|
3986210925 | ||
|
|
5bcc28a492 | ||
|
|
000330dbd7 | ||
|
|
ad7b79129e | ||
|
|
4da732538f | ||
|
|
041447585c | ||
|
|
49d1db3571 | ||
|
|
d9286699bf | ||
|
|
d5dd7302ad | ||
|
|
9561e8ae00 | ||
|
|
d9e690b5e9 | ||
|
|
d49b3c1a79 | ||
|
|
7340e45f4b | ||
|
|
ee8bf1cbfc | ||
|
|
634dabb8cc | ||
|
|
23db487356 | ||
|
|
c5b70d90fb | ||
|
|
552bf77924 | ||
|
|
ac1f82e81d | ||
|
|
41bda82c60 | ||
|
|
da5917344e | ||
|
|
feed91dcd0 | ||
|
|
5153daf7da | ||
|
|
155af93c77 | ||
|
|
0db36a6218 | ||
|
|
de4f2c6d52 | ||
|
|
fa6cc47063 | ||
|
|
c9b08a07f4 | ||
|
|
145ccbfb3e | ||
|
|
40d0ea956d | ||
|
|
184c4ad80d | ||
|
|
ba7cc42628 | ||
|
|
cb5e352e8d | ||
|
|
8a99864b41 | ||
|
|
de644f0f34 | ||
|
|
2ee703d2eb | ||
|
|
3c4377915b | ||
|
|
f9ad038805 | ||
|
|
e27fa53ec8 | ||
|
|
ed1f402c04 | ||
|
|
52838222db | ||
|
|
de36a9936a | ||
|
|
3bb59d3786 | ||
|
|
fa7e62b12b | ||
|
|
b6a64f15dc | ||
|
|
813363f0d4 | ||
|
|
517d6f3d7f | ||
|
|
83eb664a45 | ||
|
|
61be7a09c6 | ||
|
|
9f8fe2c83e | ||
|
|
830b9b246d | ||
|
|
d4a1b0ec57 | ||
|
|
43370ad078 | ||
|
|
22fa797809 | ||
|
|
1c6ac42b96 | ||
|
|
149f12ce18 | ||
|
|
a9b304ab5a | ||
|
|
b7833142b0 | ||
|
|
6def11b009 | ||
|
|
15b5fa4dd8 | ||
|
|
bbc545494b | ||
|
|
4b26a5ddcc | ||
|
|
a7e0b18396 | ||
|
|
da83a2a3f2 | ||
|
|
0037087c1e | ||
|
|
5266ea4fc6 | ||
|
|
dad768cb18 | ||
|
|
175b3f5f5b | ||
|
|
eb4f95e2bd | ||
|
|
a8642dcf29 | ||
|
|
1cadad5076 | ||
|
|
fabe7a09bf | ||
|
|
874084492e | ||
|
|
dff1cc18ce | ||
|
|
21408f1bf3 | ||
|
|
f59767096b | ||
|
|
57f2ce1413 | ||
|
|
60674d7ab7 | ||
|
|
e984df7cd0 | ||
|
|
1245dc541f | ||
|
|
61f82f826b | ||
|
|
5ea61e40d0 | ||
|
|
c09bca8419 | ||
|
|
05d19f1dad | ||
|
|
9aa7420da7 | ||
|
|
a476856ce1 | ||
|
|
009ab8effe | ||
|
|
749e25d22d | ||
|
|
8ca48511e9 | ||
|
|
2683e1214c | ||
|
|
af6fb5d273 | ||
|
|
2e806f1dcf | ||
|
|
8a51e19cc0 | ||
|
|
f02362859d | ||
|
|
a37e6928d2 | ||
|
|
dbb03e3e44 | ||
|
|
4a55d8feb2 | ||
|
|
9abb4ae6a9 | ||
|
|
09c210363e | ||
|
|
895377714c | ||
|
|
0db116d729 | ||
|
|
e4e04f5c1a | ||
|
|
db118f04ff | ||
|
|
201ba7915e | ||
|
|
7ea6fd0f58 | ||
|
|
fa48eac75a | ||
|
|
e199410bcb | ||
|
|
c058e03fbf | ||
|
|
10ae5f39f0 | ||
|
|
dbea226c89 | ||
|
|
442279ab0d | ||
|
|
a2c6de82df | ||
|
|
757bafa8d2 | ||
|
|
4cabce3a8b | ||
|
|
c8140399c4 | ||
|
|
5596f32940 | ||
|
|
6cd58bbd9d | ||
|
|
90b6564ce6 | ||
|
|
917579684e | ||
|
|
4e7a85bead | ||
|
|
d71e2e220f | ||
|
|
f60a7dfd85 | ||
|
|
b3b5547116 | ||
|
|
3690b8c22a | ||
|
|
c6b71d2dfe | ||
|
|
47dffad5a7 | ||
|
|
c760302ff8 | ||
|
|
384385dd10 | ||
|
|
7c68c8f572 | ||
|
|
c035e024e4 | ||
|
|
7743580873 | ||
|
|
d4dcb55a36 | ||
|
|
7ce0e0192e | ||
|
|
1cf2a6a2e2 | ||
|
|
8e4d9a9151 | ||
|
|
65bf586d0c | ||
|
|
9896d87968 | ||
|
|
9d618d1fde | ||
|
|
12a6baf599 | ||
|
|
157a83711f | ||
|
|
0d0998ec3c | ||
|
|
7ceb114822 | ||
|
|
acc1b7d10b | ||
|
|
6685310b5f | ||
|
|
631d86e22a | ||
|
|
f45d202af6 | ||
|
|
85d60365cf | ||
|
|
1c4edb9cee | ||
|
|
251cd14810 | ||
|
|
575fc3fa88 | ||
|
|
31a6c83222 | ||
|
|
a340533ff9 | ||
|
|
361e3c1bba | ||
|
|
5b5e989f8a | ||
|
|
6092922bc2 | ||
|
|
c2d12db216 | ||
|
|
392301bec5 | ||
|
|
39b8a43685 | ||
|
|
08713149b1 | ||
|
|
cbe5894c1d | ||
|
|
72bf49d19e | ||
|
|
1c11295163 | ||
|
|
9e6b085ac5 | ||
|
|
a1a318da44 | ||
|
|
c86488d5bd | ||
|
|
a88af0091b | ||
|
|
0569d32784 | ||
|
|
d71f8ce368 | ||
|
|
787c657818 | ||
|
|
5c9d8f4c36 | ||
|
|
7e6d48510c | ||
|
|
4a93167036 | ||
|
|
ab4a13bd4f | ||
|
|
4e20962935 | ||
|
|
964d81a377 | ||
|
|
e18a50ba30 | ||
|
|
34f12842b0 | ||
|
|
24aed42d69 | ||
|
|
0b4dbf4259 | ||
|
|
38301b8376 | ||
|
|
347ee49d46 | ||
|
|
5d10a304b3 | ||
|
|
2364ebb519 | ||
|
|
c5a8a1a653 | ||
|
|
186cc7e5a1 | ||
|
|
43fd86a2ce | ||
|
|
08b2249e45 | ||
|
|
bced84f92d | ||
|
|
269cc154ab | ||
|
|
ac333f6027 | ||
|
|
16e04fd8f2 | ||
|
|
75adf7393c | ||
|
|
2a523a3b41 | ||
|
|
990b8dd73e | ||
|
|
3a73465365 | ||
|
|
df756f9f0b | ||
|
|
fb993d3ade | ||
|
|
0fb0937a8e | ||
|
|
7c91bb3e0d | ||
|
|
c849c5cfed | ||
|
|
c0b179eff1 | ||
|
|
910227ef0c | ||
|
|
3dcc167c27 | ||
|
|
bf7f826250 | ||
|
|
25abd7e564 | ||
|
|
035dd21d11 | ||
|
|
9c920fefcc | ||
|
|
8e9d3519e4 | ||
|
|
e2ec08df62 | ||
|
|
6ef0de9d9e | ||
|
|
c7448f7833 | ||
|
|
a3c9ba1e3a | ||
|
|
54ac9cefa3 | ||
|
|
196d53aa88 | ||
|
|
67b1462453 | ||
|
|
0ad00448d8 | ||
|
|
5b8c441b8b | ||
|
|
7f7e142dc4 | ||
|
|
d36d312c9b | ||
|
|
b92c450be4 | ||
|
|
7bef64f632 | ||
|
|
133774ced7 | ||
|
|
51db1944ed | ||
|
|
23aebc2513 | ||
|
|
417471bd79 | ||
|
|
61f0ea26a0 | ||
|
|
54a9acaf18 | ||
|
|
d6404fc1b4 | ||
|
|
d4a298234d | ||
|
|
bd494c5aa1 | ||
|
|
0e5a389c12 | ||
|
|
b178f7fea5 | ||
|
|
2c89cdb344 | ||
|
|
9189e0cb58 | ||
|
|
7368cb58b7 | ||
|
|
627c51df8a | ||
|
|
3bf759aaac | ||
|
|
7d406a9b06 | ||
|
|
42895b182f | ||
|
|
21ba21bdf6 | ||
|
|
18d773ed9b | ||
|
|
f1fc957e3d | ||
|
|
fd42cae20f | ||
|
|
0b3406b55e | ||
|
|
1b63075333 | ||
|
|
2c94ad94e6 | ||
|
|
83964bfa78 | ||
|
|
b2207e277f | ||
|
|
017272cbde | ||
|
|
f6d7d7d84c | ||
|
|
123f62f9d5 | ||
|
|
656548eb64 | ||
|
|
60cf94cc05 | ||
|
|
883e7f88d5 | ||
|
|
078b838374 | ||
|
|
490302960a | ||
|
|
d865081199 | ||
|
|
8e356923c7 | ||
|
|
c811b895d7 | ||
|
|
8c4039dbb2 | ||
|
|
8ca32d3d77 | ||
|
|
dc1e9afe0c | ||
|
|
6220f979bf | ||
|
|
6ec05fd3f9 | ||
|
|
8c3e43b85b | ||
|
|
248145b821 | ||
|
|
261a983678 | ||
|
|
1a7e457054 | ||
|
|
7ae59415e8 | ||
|
|
c50d5e7921 | ||
|
|
af873bf45e | ||
|
|
4384a3000c | ||
|
|
72a951a2b6 | ||
|
|
38a5136e69 | ||
|
|
96dbeb0cbd | ||
|
|
936c37c65c | ||
|
|
e51c86c83f | ||
|
|
71a25f4774 | ||
|
|
56563657e8 | ||
|
|
6bfb10e4a4 | ||
|
|
70cb8eb35b | ||
|
|
cdea48c7d4 | ||
|
|
fba22bbcbf | ||
|
|
14f96abdb3 | ||
|
|
28ff95bf0d | ||
|
|
6cee5a6104 | ||
|
|
3add95e910 | ||
|
|
1db1064c11 | ||
|
|
f75928119e | ||
|
|
9051e8138c | ||
|
|
8396f49105 | ||
|
|
c664c70bbc | ||
|
|
7a503cd94a | ||
|
|
7a9aeec8db | ||
|
|
2c57bc36ea | ||
|
|
c0fc70425b | ||
|
|
f95944a781 | ||
|
|
589e0c8ce7 | ||
|
|
6e0ff64893 | ||
|
|
044e432eb7 | ||
|
|
5887079ade | ||
|
|
724e0817a5 | ||
|
|
9caa7a22e5 | ||
|
|
c2042d5f80 | ||
|
|
51f17c9539 | ||
|
|
27ed99ee27 | ||
|
|
7eb41e9754 | ||
|
|
c7f266a482 | ||
|
|
f2d444b877 | ||
|
|
9e59035300 | ||
|
|
b0ed2100a2 | ||
|
|
6f0c86fba6 | ||
|
|
c71562a12f | ||
|
|
a7fa415545 | ||
|
|
24d2523385 | ||
|
|
d30a3d79de | ||
|
|
860d5778cc | ||
|
|
23dbe9ee9c | ||
|
|
9114964ef2 | ||
|
|
56615528b9 | ||
|
|
1b5c54daf9 | ||
|
|
c0a3f5243b | ||
|
|
d22eb22f74 | ||
|
|
7eeb4c2489 | ||
|
|
d691c444ae | ||
|
|
83c23ea276 | ||
|
|
cff67d152f | ||
|
|
ad67c289d3 | ||
|
|
4a4d8f763d | ||
|
|
dfc734c13a | ||
|
|
654f441d05 | ||
|
|
48eebc1c95 | ||
|
|
3c1764e58d | ||
|
|
233241c962 | ||
|
|
30f9774ab1 | ||
|
|
a255c746d4 | ||
|
|
eac2a1c941 | ||
|
|
110a5faaff | ||
|
|
f3cfdcf580 | ||
|
|
34a1066cd0 | ||
|
|
67d05c39f3 | ||
|
|
fa49130c4f | ||
|
|
5b76973d9c | ||
|
|
0d57a2a8f2 | ||
|
|
78b7dae880 | ||
|
|
04a89144ba | ||
|
|
3b3eecc1d1 | ||
|
|
ca92d949dd | ||
|
|
663c7925e3 | ||
|
|
76b2ad6f58 | ||
|
|
fd8dcfee40 | ||
|
|
0a60c1960d | ||
|
|
28021df2ab | ||
|
|
6d42b6d56c | ||
|
|
3b467f42fe | ||
|
|
e37749c669 | ||
|
|
2cbbf25060 | ||
|
|
986ae4e4bc | ||
|
|
103aaf4911 | ||
|
|
7038ce64a6 | ||
|
|
9b8525ca1e | ||
|
|
254ce53387 | ||
|
|
0ff0c456b0 | ||
|
|
57a54090bb | ||
|
|
e3d3424ee1 | ||
|
|
99b2f611ec | ||
|
|
a675d78b82 | ||
|
|
62bd10cc60 | ||
|
|
9e8a5357be | ||
|
|
5faf88283f | ||
|
|
14a32e6d3c | ||
|
|
5e08f3e49b | ||
|
|
d9a5bcb0de | ||
|
|
48633729fb | ||
|
|
f274e060f4 | ||
|
|
9784f7816a | ||
|
|
f93a36c094 | ||
|
|
375d3a10b2 | ||
|
|
a1fbf5a0ae | ||
|
|
10035cc158 | ||
|
|
3b1150ad1f | ||
|
|
73222e854c | ||
|
|
3db99422b7 | ||
|
|
d876c8309f | ||
|
|
e0738aa5b2 | ||
|
|
f051693690 | ||
|
|
c6ffbbc062 | ||
|
|
2836fc1139 | ||
|
|
e111f4a763 | ||
|
|
3d5e4b9e9b | ||
|
|
8f4054e576 | ||
|
|
3ea4edd117 | ||
|
|
2f0df0a700 | ||
|
|
a709095dc2 | ||
|
|
4a8ac0ac83 | ||
|
|
b9301046da | ||
|
|
3e98b4e656 | ||
|
|
dd97ccf8c4 | ||
|
|
43d2ffa132 | ||
|
|
f4420c211e | ||
|
|
6757c5d595 | ||
|
|
2455351347 | ||
|
|
0672a13c0a | ||
|
|
cc6a96fc94 | ||
|
|
d97b600f88 | ||
|
|
94b05256b3 | ||
|
|
c15d5aff66 | ||
|
|
229cb88179 | ||
|
|
03df5e8014 | ||
|
|
a521fcb7dc | ||
|
|
51f1201bca | ||
|
|
4c8917fe05 | ||
|
|
115d0c0c5a | ||
|
|
328458eda9 | ||
|
|
3dcdf8e1a3 | ||
|
|
d2a2b35317 | ||
|
|
3cf62d4305 |
@@ -1,69 +0,0 @@
|
|||||||
[tool.commitizen]
|
|
||||||
name = "cz_customize"
|
|
||||||
tag_format = "$version"
|
|
||||||
version_scheme = "pep440"
|
|
||||||
version_provider = "poetry"
|
|
||||||
update_changelog_on_bump = true
|
|
||||||
major_version_zero = true
|
|
||||||
|
|
||||||
[tool.commitizen.customize]
|
|
||||||
message_template = "{{change_type}}{% if scope %}({{scope}}){% endif %}:{% if subject %} {{subject}}{% endif %} {% if footer %} {{footer}}{% endif %}"
|
|
||||||
example = "feature: this feature enable customize through config file"
|
|
||||||
schema = "<type>: <body>"
|
|
||||||
schema_pattern = "(feature|fix|docs|style|refactor|perf|test|revert|build).*?:(\\s.*)"
|
|
||||||
bump_pattern = "^(break|new|fix|hotfix)"
|
|
||||||
bump_map = { "break" = "MAJOR", "new" = "MINOR", "fix" = "PATCH", "hotfix" = "PATCH" }
|
|
||||||
change_type_order = ["BREAKING CHANGE", "feat", "fix", "refactor", "perf"]
|
|
||||||
info_path = "cz_customize_info.txt"
|
|
||||||
info = """
|
|
||||||
This is customized info
|
|
||||||
"""
|
|
||||||
commit_parser = "^(?P<change_type>feature|fix|docs|style|refactor|perf|test|revert|build).*?:\\s(?P<message>.*)?"
|
|
||||||
changelog_pattern = "^(feature|fix|docs|style|refactor|perf|test|revert|build)?(!)?"
|
|
||||||
change_type_map = { "feature" = "Feat", "bug fix" = "Fix" }
|
|
||||||
|
|
||||||
|
|
||||||
# 自定义提交指令
|
|
||||||
[[tool.commitizen.customize.questions]]
|
|
||||||
type = "list"
|
|
||||||
name = "change_type"
|
|
||||||
choices = [
|
|
||||||
{ value = "feature", name = "feature: 新功能" },
|
|
||||||
{ value = "fix", name = "fix: 修复 bug" },
|
|
||||||
{ value = "docs", name = "docs: 文档的修改" },
|
|
||||||
{ value = "style", name = "style: 格式化变动,不影响代码逻辑" },
|
|
||||||
{ value = "refactor", name = "refactor: 重构,即不是新增功能,也不是修改 bug 的代码变动" },
|
|
||||||
{ value = "perf", name = "perf: 性能优化" },
|
|
||||||
{ value = "test", name = "test: 增加测试" },
|
|
||||||
{ value = "revert", name = "revert: 回退" },
|
|
||||||
{ value = "build", name = "build: 打包" },
|
|
||||||
]
|
|
||||||
# choices = ["feature", "fix"] # short version
|
|
||||||
message = "请选择提交类型:"
|
|
||||||
|
|
||||||
|
|
||||||
# 修改内容的范围
|
|
||||||
[[tool.commitizen.customize.questions]]
|
|
||||||
type = "input"
|
|
||||||
name = "scope"
|
|
||||||
message = "请输入修改内容的范围(可选):"
|
|
||||||
|
|
||||||
|
|
||||||
# 自己的提交内容
|
|
||||||
[[tool.commitizen.customize.questions]]
|
|
||||||
type = "input"
|
|
||||||
name = "subject"
|
|
||||||
message = "请输入简要描述(必填):"
|
|
||||||
|
|
||||||
|
|
||||||
# 自己的提交内容
|
|
||||||
[[tool.commitizen.customize.questions]]
|
|
||||||
type = "input"
|
|
||||||
name = "body"
|
|
||||||
message = "请输入详细描述(可选):"
|
|
||||||
|
|
||||||
|
|
||||||
[[tool.commitizen.customize.questions]]
|
|
||||||
type = "input"
|
|
||||||
name = "footer"
|
|
||||||
message = "请输入需要关闭的 issue(可选):"
|
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# =====================================================
|
||||||
|
# Pear Admin Flask - 生产环境变量模板
|
||||||
|
# =====================================================
|
||||||
|
# 使用方式:
|
||||||
|
# 1. 复制本文件为 .env: cp .env.example .env
|
||||||
|
# 2. 修改 SECRET_KEY 为强随机值(必填)
|
||||||
|
# 3. 邮件 / 数据库如不需要可保持注释状态
|
||||||
|
# 4. docker compose 会自动读取同级目录的 .env
|
||||||
|
# =====================================================
|
||||||
|
|
||||||
|
# ---- 运行模式 ----
|
||||||
|
# 必填:dev / pro(pro 会强制校验 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/data,PEAR_DATA_DIR 默认
|
||||||
|
# 就是 /app/data,不需要额外改。这里只是给你本地开发调整用的口子。
|
||||||
|
# PEAR_DATA_DIR=D:/bwstudio/pear-admin-flask/data
|
||||||
|
|
||||||
|
# 默认 SQLite,WAL 模式由 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
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
FLASK_DEBUG=True # 开启调试模式
|
|
||||||
FLASK_RUN_PORT=5000 # 设置运行的端口
|
|
||||||
FLASK_RUN_HOST=0.0.0.0 # 设置监听的 ip
|
|
||||||
FLASK_APP="pear_admin:create_app()" # 运行程序
|
|
||||||
+166
-3
@@ -1,5 +1,168 @@
|
|||||||
.idea/
|
# Byte-compiled / optimized / DLL files
|
||||||
.venv/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
instance/
|
instance/
|
||||||
migrations/
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# celery beat schedule file
|
||||||
|
celerybeat-schedule
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# 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/
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
repos:
|
|
||||||
- repo: https://github.com/python/black
|
|
||||||
rev: 23.9.1
|
|
||||||
hooks:
|
|
||||||
- id: black
|
|
||||||
language_version: python3.10
|
|
||||||
- repo: https://github.com/pycqa/isort
|
|
||||||
rev: 5.12.0
|
|
||||||
hooks:
|
|
||||||
- id: isort
|
|
||||||
args: [ "--profile", "black", "--filter-files" ]
|
|
||||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
|
||||||
rev: "v3.0.3"
|
|
||||||
hooks:
|
|
||||||
- id: prettier
|
|
||||||
exclude: >
|
|
||||||
(?x)^(
|
|
||||||
.*.yaml|
|
|
||||||
package-lock.json|
|
|
||||||
yarn.lock|
|
|
||||||
^.+\.min\.(js|css)$
|
|
||||||
)$
|
|
||||||
stages: [ commit ]
|
|
||||||
- repo: https://github.com/commitizen-tools/commitizen
|
|
||||||
rev: 3.5.3
|
|
||||||
hooks:
|
|
||||||
- id: commitizen
|
|
||||||
stages: [ commit-msg ]
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2021 mkg
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -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 请手动备份后删除。"
|
||||||
@@ -1,55 +1,236 @@
|
|||||||
## 本地启动
|
<div align="center">
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
<h1 align="center">
|
||||||
|
Pear Admin Flask
|
||||||
|
</h1>
|
||||||
|
<h4 align="center">
|
||||||
|
开 箱 即 用 的 Flask 快 速 开 发 平 台
|
||||||
|
</h4>
|
||||||
|
|
||||||
环境要求:python 3.10 以上,安装了 poetry
|
[预览](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-2.0.0-green" alt="Pear Admin Layui Version">
|
||||||
|
</a>
|
||||||
|
<a href="#">
|
||||||
|
<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">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
```shell
|
<div align="center">
|
||||||
poetry install
|
<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 已同步主项目 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) 。
|
||||||
|
|
||||||
|
Pear Admin Flask 基于 Flask 的后台管理系统,拥抱应用广泛的python语言,通过使用本系统,即可快速构建你的功能业务
|
||||||
|
项目旨在为 python 开发者提供一个后台管理系统的模板,可以快速构建信息管理系统。
|
||||||
|
|
||||||
|
项目使用 flask-sqlalchemy + 权限验证 + marshmallow 序列化与数据验证,以此方式集成了若干不同的功能。
|
||||||
|
|
||||||
|
# 内置功能
|
||||||
|
|
||||||
|
- [x] 用户管理:用户是系统操作者,该功能主要完成系统用户配置。
|
||||||
|
- [x] 权限管理:配置系统菜单,操作权限,按钮权限标识等。
|
||||||
|
- [x] 角色管理:角色菜单权限分配。
|
||||||
|
- [x] 操作日志:系统正常操作日志记录和查询;系统异常信息日志记录和查询。
|
||||||
|
- [x] 登录日志:系统登录日志记录查询包含登录异常。
|
||||||
|
- [x] 服务监控:监视当前系统CPU、内存、磁盘、python版本,运行时长等相关信息。
|
||||||
|
- [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 # 程序入口
|
||||||
```
|
```
|
||||||
|
|
||||||
初始化数据库
|
## 资源结构
|
||||||
|
|
||||||
```shell
|
```资源结构
|
||||||
flask init
|
Pear Admin Flask
|
||||||
|
├─static # 项目设定的 Flask 资源文件夹
|
||||||
|
│ ├─admin # pear admin flask 的后端资源文件(与 pear admin layui 同步)
|
||||||
|
│ ├─index # pear admin flask 的前端资源文件
|
||||||
|
│ └─upload # 用户上传保存目录
|
||||||
|
└─templates # 项目设定的 Flask 模板文件夹
|
||||||
|
├─admin # pear admin flask 的后端管理页面模板
|
||||||
|
│ ├─admin_log # 日志页面
|
||||||
|
│ ├─common # 基本模板页面(头部模板与页脚模板)
|
||||||
|
│ ├─console # 系统监控页面模板
|
||||||
|
│ ├─dept # 部门管理页面模板
|
||||||
|
│ ├─dict # 数据字典页面模板
|
||||||
|
│ ├─mail # 邮件管理页面模板
|
||||||
|
│ ├─photo # 图片上传页面模板
|
||||||
|
│ ├─power # 权限(菜单)管理页面模板
|
||||||
|
│ ├─role # 角色管理页面模板
|
||||||
|
│ ├─task # 任务设置页面模板
|
||||||
|
│ └─user # 用户管理页面模板
|
||||||
|
├─errors # 错误页面模板
|
||||||
|
└─index # 主页模板
|
||||||
```
|
```
|
||||||
|
|
||||||
启动
|
# 项目安装
|
||||||
|
|
||||||
```shell
|
## 从仓库获取
|
||||||
flask run
|
|
||||||
|
```bash
|
||||||
|
# 克隆仓库 / 手动下载
|
||||||
|
git clone https://gitee.com/pear-admin/pear-admin-flask
|
||||||
|
cd pear-admin-flask # 进入到项目目录
|
||||||
```
|
```
|
||||||
|
|
||||||
## 贡献指南
|
## 修改配置
|
||||||
|
|
||||||
如果想参与项目的贡献,提交代码之前需要启用 pre-commit、commitizen 对代码进行校验,运行以下指令即可。
|
> **💡提示** 配置文件位于 `applications/config.py` ,打开配置文件看到的是位于 `BaseConfig` 类下的默认配置文件,您可以编写自己的配置类并继承 `BaseConfig` 类。
|
||||||
|
项目启动时,会调用 `applications/__init__.py` ,这个文件中加载了程序的配置,所以在您编写了自己的类后不要忘记在文件 `applications/__init__.py` 中修改使用的配置类。
|
||||||
|
|
||||||
初始化 pre-commit
|
> **⚠️注意** 配置文件中对于数据库的配置有所更改,请查看代码中的注释修改配置。
|
||||||
|
|
||||||
```shell
|
```python
|
||||||
pre-commit install
|
# 部分配置信息如下所示
|
||||||
|
|
||||||
|
# 验证密钥(⚠️ 一定要记得修改 ⚠️)
|
||||||
|
SECRET_KEY = "pear-system-flask"
|
||||||
|
|
||||||
|
# 数据库的配置信息
|
||||||
|
SQLALCHEMY_DATABASE_URI = 'sqlite:///../pear.db'
|
||||||
|
|
||||||
|
# 默认日志等级
|
||||||
|
LOG_LEVEL = logging.WARN
|
||||||
|
|
||||||
|
# flask-mail配置
|
||||||
|
MAIL_SERVER = 'smtp.qq.com'
|
||||||
|
MAIL_USE_TLS = False
|
||||||
|
MAIL_USE_SSL = True
|
||||||
|
MAIL_PORT = 465
|
||||||
|
MAIL_USERNAME = '123@qq.com'
|
||||||
|
MAIL_PASSWORD = 'XXXXX' # 生成的授权码
|
||||||
|
MAIL_DEFAULT_SENDER = MAIL_USERNAME
|
||||||
```
|
```
|
||||||
|
|
||||||
检查代码是否符合规范
|
|
||||||
|
|
||||||
```shell
|
## 虚拟环境安装项目(推荐)
|
||||||
git add .
|
|
||||||
|
> **💡提示** 为了保证项目所依赖的库不影响其他部署在同一主机上的项目,我们推荐使用虚拟环境安装。
|
||||||
|
|
||||||
|
```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
|
||||||
```
|
```
|
||||||
|
|
||||||
```shell
|
## 直接安装项目
|
||||||
pre-commit run --all-files
|
|
||||||
|
```bash
|
||||||
|
# 使用 pip 安装
|
||||||
|
pip install -r requirements.txt
|
||||||
|
# 同时你可以选择以模块的方式调用 pip
|
||||||
|
python -m pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
初始化 commitizen
|
# 运行项目
|
||||||
|
|
||||||
```shell
|
+ 一般情况运行项目
|
||||||
pre-commit install -t commit-msg
|
|
||||||
|
```bash
|
||||||
|
# 初始化数据库
|
||||||
|
flask db init
|
||||||
|
flask db migrate
|
||||||
|
flask db upgrade
|
||||||
|
flask admin init
|
||||||
|
|
||||||
|
# 运行项目
|
||||||
|
flask --app app.py run -h 0.0.0.0 -p 8000 --debug
|
||||||
|
|
||||||
|
# 或者直接调用 app.py
|
||||||
|
python app.py
|
||||||
```
|
```
|
||||||
|
|
||||||
使用
|
+ 使用 docker-compose 运行项目
|
||||||
|
|
||||||
```shell
|
```bash
|
||||||
cz commit
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
代替 `git commit` 进行提交
|
|
||||||
|
# 预览项目
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| ---------------------- | ---------------------- |
|
||||||
|
|  |  |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
|
||||||
|
# 其他说明
|
||||||
|
|
||||||
|
## 项目初始用户以及其密码
|
||||||
|
|
||||||
|
默认用户为 `admin` ,密码默认为 `123456` 。
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from applications import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
# 绑定 0.0.0.0 后,同局域网手机可通过本机 IP 访问(例如 http://192.168.1.10:5000/site/)
|
||||||
|
# debug 标志交给配置类决定:PEAR_ENV=dev → DEBUG=True;PEAR_ENV=production → DEBUG=False
|
||||||
|
# 防止重载进程抢占端口;局域网访问保持稳定
|
||||||
|
app.run(
|
||||||
|
host='0.0.0.0',
|
||||||
|
port=5000,
|
||||||
|
debug=app.config.get('DEBUG', False),
|
||||||
|
use_reloader=False,
|
||||||
|
)
|
||||||
@@ -0,0 +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_bps
|
||||||
|
|
||||||
|
|
||||||
|
def create_app():
|
||||||
|
# 1) 再加载一次 .env(幂等;override=False 保证环境变量优先级更高)
|
||||||
|
load_dotenv(os.path.join(BASE_DIR, ".env"), override=False)
|
||||||
|
|
||||||
|
app = Flask(BASE_DIR)
|
||||||
|
|
||||||
|
# 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_bps(app)
|
||||||
|
init_script(app)
|
||||||
|
|
||||||
|
# 5) 全站访问埋点已迁移到 plugins/siteStats(@app.before_request → record_visit)
|
||||||
|
|
||||||
|
return app
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
from io import BytesIO
|
||||||
|
from flask import make_response
|
||||||
|
from flask_login import current_user
|
||||||
|
|
||||||
|
from applications.common.utils.validate import str_escape
|
||||||
|
from applications.common.utils.captcha import vieCode
|
||||||
|
from applications.extensions import db
|
||||||
|
from applications.models import AdminLog
|
||||||
|
|
||||||
|
|
||||||
|
def get_captcha():
|
||||||
|
"""
|
||||||
|
生成验证码图片及其对应的验证码字符串。
|
||||||
|
|
||||||
|
:return: 返回验证码图片的响应对象和验证码字符串。
|
||||||
|
"""
|
||||||
|
image, code = vieCode().GetCodeImage()
|
||||||
|
code = ''.join(code).lower()
|
||||||
|
out = BytesIO()
|
||||||
|
image.save(out, 'png')
|
||||||
|
out.seek(0)
|
||||||
|
resp = make_response(out.read())
|
||||||
|
resp.content_type = 'image/png'
|
||||||
|
return resp, code
|
||||||
|
|
||||||
|
|
||||||
|
def normal_log(method, url, ip, user_agent, desc, uid, is_access):
|
||||||
|
"""
|
||||||
|
记录通用日志信息到数据库。
|
||||||
|
|
||||||
|
:param method: 请求方法(如 GET、POST)。
|
||||||
|
:param url: 请求的 URL。
|
||||||
|
:param ip: 客户端的 IP 地址。
|
||||||
|
:param user_agent: 客户端的 User-Agent 信息。
|
||||||
|
:param desc: 日志描述信息。
|
||||||
|
:param uid: 用户 ID。
|
||||||
|
:param is_access: 是否成功访问(True 或 False)。
|
||||||
|
:return: 返回日志记录的 ID。
|
||||||
|
"""
|
||||||
|
info = {
|
||||||
|
'method': method,
|
||||||
|
'url': url,
|
||||||
|
'ip': ip,
|
||||||
|
'user_agent': user_agent,
|
||||||
|
'desc': desc,
|
||||||
|
'uid': uid,
|
||||||
|
'success': int(is_access)
|
||||||
|
}
|
||||||
|
log = AdminLog(
|
||||||
|
url=info.get('url'),
|
||||||
|
ip=info.get('ip'),
|
||||||
|
user_agent=info.get('user_agent'),
|
||||||
|
desc=info.get('desc'),
|
||||||
|
uid=info.get('uid'),
|
||||||
|
method=info.get('method'),
|
||||||
|
success=info.get('success')
|
||||||
|
)
|
||||||
|
db.session.add(log)
|
||||||
|
db.session.commit()
|
||||||
|
return log.id
|
||||||
|
|
||||||
|
|
||||||
|
def login_log(request, uid, is_access):
|
||||||
|
"""
|
||||||
|
记录用户登录日志。
|
||||||
|
|
||||||
|
:param request: Flask 请求对象。
|
||||||
|
:param uid: 用户 ID。
|
||||||
|
:param is_access: 是否成功登录(True 或 False)。
|
||||||
|
:return: 返回日志记录的 ID。
|
||||||
|
"""
|
||||||
|
method = request.method
|
||||||
|
url = request.path
|
||||||
|
ip = request.remote_addr
|
||||||
|
user_agent = str_escape(request.headers.get('User-Agent'))
|
||||||
|
desc = str_escape(request.form.get('username'))
|
||||||
|
return normal_log(method, url, ip, user_agent, desc, uid, is_access)
|
||||||
|
|
||||||
|
|
||||||
|
def admin_log(request, is_access, desc=None):
|
||||||
|
"""
|
||||||
|
记录管理员操作日志。
|
||||||
|
|
||||||
|
:param request: Flask 请求对象。
|
||||||
|
:param is_access: 是否成功操作(True 或 False)。
|
||||||
|
:param desc: 日志描述信息(可选)。如果未提供,则从请求数据中提取。
|
||||||
|
:return: 返回日志记录的 ID。
|
||||||
|
"""
|
||||||
|
method = request.method
|
||||||
|
url = request.path
|
||||||
|
ip = request.remote_addr
|
||||||
|
user_agent = str_escape(request.headers.get('User-Agent'))
|
||||||
|
request_data = request.json if request.headers.get('Content-Type') == 'application/json' else request.values
|
||||||
|
if desc is None:
|
||||||
|
desc = str_escape(str(dict(request_data)))
|
||||||
|
return normal_log(method, url, ip, user_agent, desc, current_user.id, is_access)
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import datetime
|
||||||
|
from marshmallow import Schema
|
||||||
|
from marshmallow_sqlalchemy import SQLAlchemyAutoSchema
|
||||||
|
from applications.extensions import db, ma
|
||||||
|
|
||||||
|
|
||||||
|
class LogicalDeleteMixin(object):
|
||||||
|
"""
|
||||||
|
逻辑删除混入类,为模型提供软删除功能。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
class Test(db.Model, LogicalDeleteMixin):
|
||||||
|
__tablename__ = 'admin_test'
|
||||||
|
id = db.Column(db.Integer, primary_key=True, comment='角色ID')
|
||||||
|
|
||||||
|
# 软删除
|
||||||
|
Test.query.filter_by(id=1).soft_delete()
|
||||||
|
|
||||||
|
# 查询所有未删除的记录
|
||||||
|
Test.query.logic_all()
|
||||||
|
"""
|
||||||
|
create_at = db.Column(db.DateTime, default=datetime.datetime.now, comment='创建时间')
|
||||||
|
update_at = db.Column(db.DateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now, comment='更新时间')
|
||||||
|
delete_at = db.Column(db.DateTime, comment='删除时间')
|
||||||
|
|
||||||
|
|
||||||
|
def auto_model_jsonify(data, model: db.Model):
|
||||||
|
"""
|
||||||
|
自动序列化模型数据为 JSON 格式,无需手动定义 Schema。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
power_data = curd.auto_model_jsonify(model=Dept, data=dept)
|
||||||
|
|
||||||
|
:param data: 需要序列化的 SQLAlchemy 查询结果。
|
||||||
|
:param model: SQLAlchemy 模型类。
|
||||||
|
:return: 返回序列化后的 JSON 数据。
|
||||||
|
"""
|
||||||
|
def get_model():
|
||||||
|
return model
|
||||||
|
|
||||||
|
class AutoSchema(SQLAlchemyAutoSchema):
|
||||||
|
class Meta(Schema):
|
||||||
|
model = get_model()
|
||||||
|
include_fk = True # 包含外键
|
||||||
|
include_relationships = True # 包含关联关系
|
||||||
|
load_instance = True # 反序列化时加载为模型实例
|
||||||
|
|
||||||
|
common_schema = AutoSchema(many=True) # 支持序列化多个对象
|
||||||
|
output = common_schema.dump(data)
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def model_to_dicts(schema: ma.Schema, data):
|
||||||
|
"""
|
||||||
|
使用指定的 Schema 序列化 SQLAlchemy 查询结果。
|
||||||
|
|
||||||
|
:param schema: Marshmallow Schema 类。
|
||||||
|
:param data: SQLAlchemy 查询结果。
|
||||||
|
:return: 返回序列化后的数据,返回字典。
|
||||||
|
"""
|
||||||
|
common_schema = schema(many=True) # 支持序列化多个对象
|
||||||
|
output = common_schema.dump(data)
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def get_one_by_id(model: db.Model, id):
|
||||||
|
"""
|
||||||
|
根据 ID 查询单个记录。
|
||||||
|
|
||||||
|
:param model: SQLAlchemy 模型类。
|
||||||
|
:param id: 记录的主键 ID。
|
||||||
|
:return: 返回查询到的记录,如果未找到则返回 None。
|
||||||
|
"""
|
||||||
|
return model.query.filter_by(id=id).first()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_one_by_id(model: db.Model, id):
|
||||||
|
"""
|
||||||
|
根据 ID 删除单个记录。
|
||||||
|
|
||||||
|
:param model: SQLAlchemy 模型类。
|
||||||
|
:param id: 记录的主键 ID。
|
||||||
|
:return: 返回删除操作影响的行数。
|
||||||
|
"""
|
||||||
|
r = model.query.filter_by(id=id).delete()
|
||||||
|
db.session.commit()
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def enable_status(model: db.Model, id):
|
||||||
|
"""
|
||||||
|
启用指定 ID 的记录。
|
||||||
|
|
||||||
|
:param model: SQLAlchemy 模型类。
|
||||||
|
:param id: 记录的主键 ID。
|
||||||
|
:return: 如果操作成功返回 True,否则返回 False。
|
||||||
|
"""
|
||||||
|
enable = 1
|
||||||
|
role = model.query.filter_by(id=id).update({"enable": enable})
|
||||||
|
if role:
|
||||||
|
db.session.commit()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def disable_status(model: db.Model, id):
|
||||||
|
"""
|
||||||
|
停用指定 ID 的记录。
|
||||||
|
|
||||||
|
:param model: SQLAlchemy 模型类。
|
||||||
|
:param id: 记录的主键 ID。
|
||||||
|
:return: 如果操作成功返回 True,否则返回 False。
|
||||||
|
"""
|
||||||
|
enable = 0
|
||||||
|
role = model.query.filter_by(id=id).update({"enable": enable})
|
||||||
|
if role:
|
||||||
|
db.session.commit()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
from sqlalchemy import and_, func
|
||||||
|
from applications.extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
class ModelFilter:
|
||||||
|
"""
|
||||||
|
ORM 多条件查询构造器,支持多种查询条件组合,自动转义特殊字符防止SQL注入。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
mf = ModelFilter()
|
||||||
|
mf.exact('name', 'John')
|
||||||
|
mf.vague('email', 'example.com')
|
||||||
|
query = User.query.filter(mf.get_filter(User))
|
||||||
|
"""
|
||||||
|
filter_field = {} # 存储字段过滤条件
|
||||||
|
filter_list = [] # 存储最终的过滤条件列表
|
||||||
|
|
||||||
|
# 查询类型常量
|
||||||
|
type_exact = "exact" # 精确匹配
|
||||||
|
type_neq = "neq" # 不等于
|
||||||
|
type_greater = "greater" # 大于
|
||||||
|
type_less = "less" # 小于
|
||||||
|
type_vague = "vague" # 模糊匹配
|
||||||
|
type_contains = "contains" # 包含
|
||||||
|
type_between = "between" # 范围查询
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""初始化过滤条件存储字典和列表。"""
|
||||||
|
self.filter_field = {}
|
||||||
|
self.filter_list = []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def escape_like(value: str, escape_char: str = '\\') -> str:
|
||||||
|
"""
|
||||||
|
转义LIKE查询中的特殊字符(%, _ 和转义字符本身)
|
||||||
|
|
||||||
|
:param value: 需要转义的原始字符串
|
||||||
|
:param escape_char: 转义字符(默认反斜杠)
|
||||||
|
:return: 转义后的安全字符串
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
value.replace(escape_char, escape_char * 2)
|
||||||
|
.replace('%', escape_char + '%')
|
||||||
|
.replace('_', escape_char + '_')
|
||||||
|
)
|
||||||
|
|
||||||
|
def exact(self, field_name, value):
|
||||||
|
"""
|
||||||
|
添加精确匹配条件(自动处理字符串类型参数)
|
||||||
|
|
||||||
|
:param field_name: 模型字段名称
|
||||||
|
:param value: 匹配的值(自动过滤空字符串)
|
||||||
|
"""
|
||||||
|
if value is not None and value != '':
|
||||||
|
# 字符串类型自动调用escape_like(防止特殊字符影响精确匹配)
|
||||||
|
processed_value = self.escape_like(str(value)) if isinstance(value, str) else value
|
||||||
|
self.filter_field[field_name] = {"data": processed_value, "type": self.type_exact}
|
||||||
|
|
||||||
|
def neq(self, field_name, value):
|
||||||
|
"""
|
||||||
|
添加不等于条件(自动处理字符串类型参数)
|
||||||
|
|
||||||
|
:param field_name: 模型字段名称
|
||||||
|
:param value: 不匹配的值(自动过滤空字符串)
|
||||||
|
"""
|
||||||
|
if value is not None and value != '':
|
||||||
|
# 字符串类型自动调用escape_like
|
||||||
|
processed_value = self.escape_like(str(value)) if isinstance(value, str) else value
|
||||||
|
self.filter_field[field_name] = {"data": processed_value, "type": self.type_neq}
|
||||||
|
|
||||||
|
def greater(self, field_name, value):
|
||||||
|
"""
|
||||||
|
添加大于条件(数值/日期比较)
|
||||||
|
|
||||||
|
:param field_name: 模型字段名称
|
||||||
|
:param value: 比较的数值/日期
|
||||||
|
"""
|
||||||
|
if value is not None and value != '':
|
||||||
|
self.filter_field[field_name] = {"data": value, "type": self.type_greater}
|
||||||
|
|
||||||
|
def less(self, field_name, value):
|
||||||
|
"""
|
||||||
|
添加小于条件(数值/日期比较)
|
||||||
|
|
||||||
|
:param field_name: 模型字段名称
|
||||||
|
:param value: 比较的数值/日期
|
||||||
|
"""
|
||||||
|
if value is not None and value != '':
|
||||||
|
self.filter_field[field_name] = {"data": value, "type": self.type_less}
|
||||||
|
|
||||||
|
def vague(self, field_name, value: str):
|
||||||
|
"""
|
||||||
|
添加安全模糊匹配(自动转义特殊字符,左右加%)
|
||||||
|
|
||||||
|
:param field_name: 模型字段名称
|
||||||
|
:param value: 需要模糊匹配的字符串(自动过滤空值)
|
||||||
|
"""
|
||||||
|
if value and value != '':
|
||||||
|
escaped_value = self.escape_like(value)
|
||||||
|
self.filter_field[field_name] = {"data": f'%{escaped_value}%', "type": self.type_vague}
|
||||||
|
|
||||||
|
def left_vague(self, field_name, value: str):
|
||||||
|
"""
|
||||||
|
添加安全左模糊匹配(自动转义特殊字符,左侧加%)
|
||||||
|
|
||||||
|
:param field_name: 模型字段名称
|
||||||
|
:param value: 需要左模糊匹配的字符串
|
||||||
|
"""
|
||||||
|
if value and value != '':
|
||||||
|
escaped_value = self.escape_like(value)
|
||||||
|
self.filter_field[field_name] = {"data": f'%{escaped_value}', "type": self.type_vague}
|
||||||
|
|
||||||
|
def right_vague(self, field_name, value: str):
|
||||||
|
"""
|
||||||
|
添加安全右模糊匹配(自动转义特殊字符,右侧加%)
|
||||||
|
|
||||||
|
:param field_name: 模型字段名称
|
||||||
|
:param value: 需要右模糊匹配的字符串
|
||||||
|
"""
|
||||||
|
if value and value != '':
|
||||||
|
escaped_value = self.escape_like(value)
|
||||||
|
self.filter_field[field_name] = {"data": f'{escaped_value}%', "type": self.type_vague}
|
||||||
|
|
||||||
|
def contains(self, field_name, value: str):
|
||||||
|
"""
|
||||||
|
添加安全包含条件(自动转义特殊字符,等效于vague)
|
||||||
|
|
||||||
|
:param field_name: 模型字段名称
|
||||||
|
:param value: 需要包含的字符串
|
||||||
|
"""
|
||||||
|
if value and value != '':
|
||||||
|
escaped_value = self.escape_like(value)
|
||||||
|
self.filter_field[field_name] = {"data": f'%{escaped_value}%', "type": self.type_contains}
|
||||||
|
|
||||||
|
def between(self, field_name, value1, value2):
|
||||||
|
"""
|
||||||
|
添加范围查询条件(自动过滤无效值)
|
||||||
|
|
||||||
|
:param field_name: 模型字段名称
|
||||||
|
:param value1: 范围起始值
|
||||||
|
:param value2: 范围结束值
|
||||||
|
"""
|
||||||
|
if all([v is not None and v != '' for v in [value1, value2]]):
|
||||||
|
self.filter_field[field_name] = {"data": [value1, value2], "type": self.type_between}
|
||||||
|
|
||||||
|
def get_filter(self, model: db.Model):
|
||||||
|
"""
|
||||||
|
生成安全的SQLAlchemy过滤条件
|
||||||
|
|
||||||
|
:param model: SQLAlchemy 模型类
|
||||||
|
:return: 组合后的过滤条件(使用and_连接)
|
||||||
|
"""
|
||||||
|
for k, v in self.filter_field.items():
|
||||||
|
field = getattr(model, k)
|
||||||
|
data = v.get("data")
|
||||||
|
query_type = v.get("type")
|
||||||
|
|
||||||
|
if query_type == self.type_vague:
|
||||||
|
self.filter_list.append(field.like(data, escape='\\'))
|
||||||
|
elif query_type == self.type_contains:
|
||||||
|
self.filter_list.append(field.like(data, escape='\\'))
|
||||||
|
elif query_type == self.type_exact:
|
||||||
|
self.filter_list.append(field == data)
|
||||||
|
elif query_type == self.type_neq:
|
||||||
|
self.filter_list.append(field != data)
|
||||||
|
elif query_type == self.type_greater:
|
||||||
|
self.filter_list.append(field > data)
|
||||||
|
elif query_type == self.type_less:
|
||||||
|
self.filter_list.append(field < data)
|
||||||
|
elif query_type == self.type_between:
|
||||||
|
self.filter_list.append(field.between(data[0], data[1]))
|
||||||
|
|
||||||
|
return and_(*self.filter_list)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
from .admin import admin_cli
|
||||||
|
from .check import check_cli
|
||||||
|
from applications.extensions.init_plugins import broadcast_execute
|
||||||
|
|
||||||
|
|
||||||
|
def init_script(app: Flask):
|
||||||
|
app.cli.add_command(admin_cli)
|
||||||
|
app.cli.add_command(check_cli)
|
||||||
|
broadcast_execute(app, 'event_finish')
|
||||||
@@ -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
@@ -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")
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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.
@@ -0,0 +1,41 @@
|
|||||||
|
from flask import jsonify, request
|
||||||
|
|
||||||
|
|
||||||
|
def success_api(msg: str = "成功"):
|
||||||
|
"""
|
||||||
|
返回成功的 API 响应。
|
||||||
|
|
||||||
|
:param msg: 成功消息内容,默认为 "成功"。
|
||||||
|
:return: 返回 JSON 格式的响应,包含 `success` 和 `msg` 字段。
|
||||||
|
"""
|
||||||
|
return jsonify(success=True, msg=msg)
|
||||||
|
|
||||||
|
|
||||||
|
def fail_api(msg: str = "失败"):
|
||||||
|
"""
|
||||||
|
返回失败的 API 响应。
|
||||||
|
|
||||||
|
:param msg: 失败消息内容,默认为 "失败"。
|
||||||
|
:return: 返回 JSON 格式的响应,包含 `success` 和 `msg` 字段。
|
||||||
|
"""
|
||||||
|
return jsonify(success=False, msg=msg)
|
||||||
|
|
||||||
|
|
||||||
|
def table_api(msg: str = "", count=0, data=None, limit=10):
|
||||||
|
"""
|
||||||
|
返回动态表格渲染所需的 API 响应。
|
||||||
|
|
||||||
|
:param msg: 响应消息内容,默认为空字符串。
|
||||||
|
:param count: 数据总数,默认为 0。
|
||||||
|
:param data: 表格数据,默认为 None。
|
||||||
|
:param limit: 每页数据条数,默认为 10。
|
||||||
|
:return: 返回 JSON 格式的响应,包含 `msg`、`code`、`data`、`count` 和 `limit` 字段。
|
||||||
|
"""
|
||||||
|
res = {
|
||||||
|
'msg': msg,
|
||||||
|
'code': 0,
|
||||||
|
'data': data,
|
||||||
|
'count': count,
|
||||||
|
'limit': min(request.args.get('limit', default=10, type=int), 90)
|
||||||
|
}
|
||||||
|
return jsonify(res)
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""
|
||||||
|
集成了对 Pear Admin Flask 二次开发的邮件操作模块,并提供了相应的示例。
|
||||||
|
"""
|
||||||
|
from flask import current_app
|
||||||
|
from flask_mail import Message
|
||||||
|
|
||||||
|
from applications.common.curd import model_to_dicts
|
||||||
|
from applications.common.helper import ModelFilter
|
||||||
|
from applications.extensions import db, flask_mail
|
||||||
|
from applications.models import Mail
|
||||||
|
from applications.schemas import MailOutSchema
|
||||||
|
|
||||||
|
|
||||||
|
def get_all(receiver=None, subject=None, content=None):
|
||||||
|
"""
|
||||||
|
获取邮件列表,支持根据接收者、主题和内容进行筛选。
|
||||||
|
|
||||||
|
返回的列表中的字典结构如下::
|
||||||
|
|
||||||
|
{
|
||||||
|
"content": "", # HTML 内容
|
||||||
|
"create_at": "2022-12-25T10:51:17", # 创建时间
|
||||||
|
"id": 17, # 邮件ID
|
||||||
|
"realname": "超级管理", # 创建者姓名
|
||||||
|
"receiver": "", # 接收者
|
||||||
|
"subject": "" # 邮件主题
|
||||||
|
}
|
||||||
|
|
||||||
|
:param receiver: 接收者邮箱地址,支持模糊查询。
|
||||||
|
:param subject: 邮件主题,支持模糊查询。
|
||||||
|
:param content: 邮件内容,支持模糊查询。
|
||||||
|
:return: 返回符合条件的邮件列表。
|
||||||
|
"""
|
||||||
|
# 构造查询条件
|
||||||
|
mf = ModelFilter()
|
||||||
|
if receiver:
|
||||||
|
mf.contains(field_name="receiver", value=receiver)
|
||||||
|
if subject:
|
||||||
|
mf.contains(field_name="subject", value=subject)
|
||||||
|
if content:
|
||||||
|
mf.exact(field_name="content", value=content)
|
||||||
|
|
||||||
|
# 查询邮件数据并分页
|
||||||
|
mail = Mail.query.filter(mf.get_filter(Mail)).layui_paginate()
|
||||||
|
return model_to_dicts(schema=MailOutSchema, data=mail.items)
|
||||||
|
|
||||||
|
|
||||||
|
def add(receiver, subject, content, user_id):
|
||||||
|
"""
|
||||||
|
发送一封邮件,并将发送记录保存到数据库。 **该方法被邮件发送的视图函数调用。**
|
||||||
|
|
||||||
|
:param receiver: 接收者邮箱地址,多个邮箱用英文分号隔开。
|
||||||
|
:param subject: 邮件主题。
|
||||||
|
:param content: 邮件内容(HTML 格式)。
|
||||||
|
:param user_id: 发送者用户ID,表示谁发送了这封邮件。
|
||||||
|
可以使用 `from flask_login import current_user; current_user.id` 获取当前登录用户的ID。
|
||||||
|
:return: 发送成功返回 True,失败报错。
|
||||||
|
"""
|
||||||
|
send_mail(subject=subject, recipients=receiver.split(";"), content=content)
|
||||||
|
|
||||||
|
# 保存邮件记录到数据库
|
||||||
|
mail = Mail(receiver=receiver, subject=subject, content=content, user_id=user_id)
|
||||||
|
db.session.add(mail)
|
||||||
|
db.session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def delete(id):
|
||||||
|
"""
|
||||||
|
删除指定的邮件记录。
|
||||||
|
|
||||||
|
:param id: 邮件ID。
|
||||||
|
:return: 删除成功返回 True,失败返回 False。
|
||||||
|
"""
|
||||||
|
res = Mail.query.filter_by(id=id).delete()
|
||||||
|
if not res:
|
||||||
|
return False
|
||||||
|
db.session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def send_mail(subject, recipients, content):
|
||||||
|
"""
|
||||||
|
发送邮件(不记录发送日志)。
|
||||||
|
|
||||||
|
注意:如果发送失败会抛出异常,请使用 try-except 进行捕获。
|
||||||
|
|
||||||
|
:param subject: 邮件主题。
|
||||||
|
:param recipients: 接收者邮箱地址,多个邮箱用英文分号隔开。
|
||||||
|
:param content: 邮件内容(HTML 格式)。
|
||||||
|
"""
|
||||||
|
message = Message(subject=subject, recipients=recipients, html=content)
|
||||||
|
flask_mail.send(message)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from functools import wraps
|
||||||
|
from flask import abort, request, jsonify, session, current_app
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from applications.common.admin import admin_log
|
||||||
|
|
||||||
|
|
||||||
|
def authorize(power: str, log: bool = False):
|
||||||
|
"""
|
||||||
|
用户权限判断,用于判断目前会话用户是否拥有访问权限。
|
||||||
|
在模板中有与之对应的全局非修饰函数 authorize ,此函数定义位于 `applications/extensions/init_template_directives.py` 。
|
||||||
|
|
||||||
|
:param power: 权限标识
|
||||||
|
:type power: str
|
||||||
|
:param log: 是否记录日志, defaults to False
|
||||||
|
:type log: bool, optional
|
||||||
|
"""
|
||||||
|
def decorator(func):
|
||||||
|
@login_required
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
# 定义管理员的id为1
|
||||||
|
if current_user.username == current_app.config.get("SUPERADMIN"):
|
||||||
|
|
||||||
|
if log:
|
||||||
|
admin_log(request=request, is_access=True)
|
||||||
|
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
|
if not power in session.get('permissions'):
|
||||||
|
if log:
|
||||||
|
admin_log(request=request, is_access=False)
|
||||||
|
if request.method == 'GET':
|
||||||
|
abort(403)
|
||||||
|
else:
|
||||||
|
return jsonify(success=False, msg="权限不足!")
|
||||||
|
|
||||||
|
if log:
|
||||||
|
admin_log(request=request, is_access=True)
|
||||||
|
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import os
|
||||||
|
from flask import current_app
|
||||||
|
from sqlalchemy import desc
|
||||||
|
from applications.extensions import db
|
||||||
|
from applications.extensions.init_upload import photos
|
||||||
|
from applications.models import Photo
|
||||||
|
from applications.schemas import PhotoOutSchema
|
||||||
|
from applications.common.curd import model_to_dicts
|
||||||
|
|
||||||
|
|
||||||
|
def get_photo(page, limit):
|
||||||
|
photo = Photo.query.order_by(desc(Photo.create_time)).paginate(page=page, per_page=limit, error_out=False)
|
||||||
|
count = Photo.query.count()
|
||||||
|
data = model_to_dicts(schema=PhotoOutSchema, data=photo.items)
|
||||||
|
return data, count
|
||||||
|
|
||||||
|
|
||||||
|
def upload_one(photo, mime):
|
||||||
|
filename = photos.save(photo)
|
||||||
|
file_url = '/_uploads/photos/' + filename
|
||||||
|
# file_url = photos.url(filename)
|
||||||
|
upload_url = current_app.config.get("UPLOADED_PHOTOS_DEST")
|
||||||
|
size = os.path.getsize(upload_url + '/' + filename)
|
||||||
|
photo = Photo(name=filename, href=file_url, mime=mime, size=size)
|
||||||
|
db.session.add(photo)
|
||||||
|
db.session.commit()
|
||||||
|
return file_url
|
||||||
|
|
||||||
|
|
||||||
|
def delete_photo_by_id(_id):
|
||||||
|
photo_name = Photo.query.filter_by(id=_id).first().name
|
||||||
|
photo = Photo.query.filter_by(id=_id).delete()
|
||||||
|
db.session.commit()
|
||||||
|
upload_url = current_app.config.get("UPLOADED_PHOTOS_DEST")
|
||||||
|
os.remove(upload_url + '/' + photo_name)
|
||||||
|
return photo
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
# XSS 过滤
|
||||||
|
import validators
|
||||||
|
from markupsafe import escape
|
||||||
|
from validators import validator
|
||||||
|
|
||||||
|
|
||||||
|
def str_escape(s):
|
||||||
|
"""
|
||||||
|
对字符串进行 XSS 过滤,返回转义后的安全字符串。
|
||||||
|
|
||||||
|
:param s: 需要转义的字符串。
|
||||||
|
:return: 返回转义后的字符串,如果输入为空则返回 None。
|
||||||
|
"""
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
return str(escape(s))
|
||||||
|
|
||||||
|
|
||||||
|
def between(*args, **kwargs):
|
||||||
|
"""
|
||||||
|
验证数字是否介于最小值和最大值之间。
|
||||||
|
适用于整数、浮点数、小数和日期等类型。
|
||||||
|
|
||||||
|
:param value: 需要验证的数字。
|
||||||
|
:param min: 数字的最小值(可选)。
|
||||||
|
:param max: 数字的最大值(可选)。
|
||||||
|
:return: 如果验证成功返回 True,否则返回 ValidationFailure。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
>>> between(5, min=2)
|
||||||
|
True
|
||||||
|
|
||||||
|
>>> between(13.2, min=13, max=14)
|
||||||
|
True
|
||||||
|
|
||||||
|
>>> between(500, max=400)
|
||||||
|
ValidationFailure(func=between, args=...)
|
||||||
|
"""
|
||||||
|
return validators.between(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def domain(*args, **kwargs):
|
||||||
|
"""
|
||||||
|
验证给定值是否为有效的域名。
|
||||||
|
|
||||||
|
:param value: 需要验证的域名字符串。
|
||||||
|
:return: 如果验证成功返回 True,否则返回 ValidationFailure。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
>>> domain('example.com')
|
||||||
|
True
|
||||||
|
|
||||||
|
>>> domain('example.com/')
|
||||||
|
ValidationFailure(func=domain, ...)
|
||||||
|
"""
|
||||||
|
return validators.domain(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def email(*args, **kwargs):
|
||||||
|
"""
|
||||||
|
验证给定值是否为有效的电子邮件地址。
|
||||||
|
|
||||||
|
:param value: 需要验证的电子邮件地址。
|
||||||
|
:return: 如果验证成功返回 True,否则返回 ValidationFailure。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
>>> email('someone@example.com')
|
||||||
|
True
|
||||||
|
|
||||||
|
>>> email('bogus@@')
|
||||||
|
ValidationFailure(func=email, ...)
|
||||||
|
"""
|
||||||
|
return validators.email(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def iban(*args, **kwargs):
|
||||||
|
"""
|
||||||
|
验证给定值是否为有效的 IBAN 代码。
|
||||||
|
|
||||||
|
:param value: 需要验证的 IBAN 代码。
|
||||||
|
:return: 如果验证成功返回 True,否则返回 ValidationFailure。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
>>> iban('DE29100500001061045672')
|
||||||
|
True
|
||||||
|
|
||||||
|
>>> iban('123456')
|
||||||
|
ValidationFailure(func=iban, ...)
|
||||||
|
"""
|
||||||
|
return validators.iban(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def ipv4(*args, **kwargs):
|
||||||
|
"""
|
||||||
|
验证给定值是否为有效的 IPv4 地址。
|
||||||
|
|
||||||
|
:param value: 需要验证的 IPv4 地址。
|
||||||
|
:return: 如果验证成功返回 True,否则返回 ValidationFailure。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
>>> ipv4('123.0.0.7')
|
||||||
|
True
|
||||||
|
|
||||||
|
>>> ipv4('900.80.70.11')
|
||||||
|
ValidationFailure(func=ipv4, args={'value': '900.80.70.11'})
|
||||||
|
"""
|
||||||
|
return validators.ipv4(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def ipv6(*args, **kwargs):
|
||||||
|
"""
|
||||||
|
验证给定值是否为有效的 IPv6 地址。
|
||||||
|
|
||||||
|
:param value: 需要验证的 IPv6 地址。
|
||||||
|
:return: 如果验证成功返回 True,否则返回 ValidationFailure。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
>>> ipv6('abcd:ef::42:1')
|
||||||
|
True
|
||||||
|
|
||||||
|
>>> ipv6('abc.0.0.1')
|
||||||
|
ValidationFailure(func=ipv6, args={'value': 'abc.0.0.1'})
|
||||||
|
"""
|
||||||
|
return validators.ipv6(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def length(*args, **kwargs):
|
||||||
|
"""
|
||||||
|
验证给定字符串的长度是否在指定范围内。
|
||||||
|
|
||||||
|
:param value: 需要验证的字符串。
|
||||||
|
:param min: 字符串的最小长度(可选)。
|
||||||
|
:param max: 字符串的最大长度(可选)。
|
||||||
|
:return: 如果验证成功返回 True,否则返回 ValidationFailure。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
>>> length('something', min=2)
|
||||||
|
True
|
||||||
|
|
||||||
|
>>> length('something', min=9, max=9)
|
||||||
|
True
|
||||||
|
|
||||||
|
>>> length('something', max=5)
|
||||||
|
ValidationFailure(func=length, ...)
|
||||||
|
"""
|
||||||
|
return validators.length(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def mac_address(*args, **kwargs):
|
||||||
|
"""
|
||||||
|
验证给定值是否为有效的 MAC 地址。
|
||||||
|
|
||||||
|
:param value: 需要验证的 MAC 地址。
|
||||||
|
:return: 如果验证成功返回 True,否则返回 ValidationFailure。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
>>> mac_address('01:23:45:67:ab:CD')
|
||||||
|
True
|
||||||
|
|
||||||
|
>>> mac_address('00:00:00:00:00')
|
||||||
|
ValidationFailure(func=mac_address, args={'value': '00:00:00:00:00'})
|
||||||
|
"""
|
||||||
|
return validators.mac_address(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def slug(*args, **kwargs):
|
||||||
|
"""
|
||||||
|
验证给定值是否为有效的 Slug 格式。
|
||||||
|
有效的 Slug 只能包含字母数字字符、连字符和下划线。
|
||||||
|
|
||||||
|
:param value: 需要验证的字符串。
|
||||||
|
:return: 如果验证成功返回 True,否则返回 ValidationFailure。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
>>> slug('my.slug')
|
||||||
|
ValidationFailure(func=slug, args={'value': 'my.slug'})
|
||||||
|
|
||||||
|
>>> slug('my-slug-2134')
|
||||||
|
True
|
||||||
|
"""
|
||||||
|
return validators.slug(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def url(*args, **kwargs):
|
||||||
|
"""
|
||||||
|
验证给定值是否为有效的 URL。
|
||||||
|
|
||||||
|
:param value: 需要验证的 URL。
|
||||||
|
:param public: 是否仅允许公共 URL(可选)。
|
||||||
|
:return: 如果验证成功返回 True,否则返回 ValidationFailure。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
>>> url('http://foobar.dk')
|
||||||
|
True
|
||||||
|
|
||||||
|
>>> url('http://10.0.0.1')
|
||||||
|
True
|
||||||
|
|
||||||
|
>>> url('http://foobar.d')
|
||||||
|
ValidationFailure(func=url, ...)
|
||||||
|
|
||||||
|
>>> url('http://10.0.0.1', public=True)
|
||||||
|
ValidationFailure(func=url, ...)
|
||||||
|
"""
|
||||||
|
return validators.url(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def uuid(*args, **kwargs):
|
||||||
|
"""
|
||||||
|
验证给定值是否为有效的 UUID。
|
||||||
|
|
||||||
|
:param value: 需要验证的 UUID。
|
||||||
|
:return: 如果验证成功返回 True,否则返回 ValidationFailure。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
>>> uuid('2bc1c94f-0deb-43e9-92a1-4775189ec9f8')
|
||||||
|
True
|
||||||
|
|
||||||
|
>>> uuid('2bc1c94f 0deb-43e9-92a1-4775189ec9f8')
|
||||||
|
ValidationFailure(func=uuid, ...)
|
||||||
|
"""
|
||||||
|
return validators.uuid(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@validator
|
||||||
|
def even(value):
|
||||||
|
"""
|
||||||
|
验证给定值是否为偶数。
|
||||||
|
|
||||||
|
:param value: 需要验证的数字。
|
||||||
|
:return: 如果是偶数返回 True,否则返回 ValidationFailure。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
>>> even(4)
|
||||||
|
True
|
||||||
|
|
||||||
|
>>> even(5)
|
||||||
|
ValidationFailure(func=even, args={'value': 5})
|
||||||
|
"""
|
||||||
|
return not (value % 2)
|
||||||
@@ -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.db(Docker 下由 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)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from flask import Flask
|
||||||
|
from .init_sqlalchemy import db, ma, init_databases
|
||||||
|
from .init_login import init_login_manager
|
||||||
|
from .init_template_directives import init_template_directives
|
||||||
|
from .init_error_views import init_error_views
|
||||||
|
from .init_mail import init_mail, mail as flask_mail
|
||||||
|
from .init_upload import init_upload
|
||||||
|
from .init_migrate import init_migrate
|
||||||
|
from .init_session import init_session
|
||||||
|
from .init_limit import init_limit
|
||||||
|
from .init_plugins import register_plugin, broadcast_execute
|
||||||
|
|
||||||
|
|
||||||
|
def init_plugs(app: Flask) -> None:
|
||||||
|
# 注册插件 (仅 import + 注册动作)
|
||||||
|
register_plugin(app)
|
||||||
|
broadcast_execute(app, 'event_begin')
|
||||||
|
|
||||||
|
# 注册 Flask 功能
|
||||||
|
init_login_manager(app)
|
||||||
|
init_databases(app)
|
||||||
|
init_mail(app)
|
||||||
|
init_upload(app)
|
||||||
|
init_migrate(app)
|
||||||
|
init_session(app)
|
||||||
|
init_limit(app)
|
||||||
|
|
||||||
|
# 系统蓝图相关(含 @template_global 等 Jinja 注入)
|
||||||
|
init_template_directives(app)
|
||||||
|
init_error_views(app)
|
||||||
|
|
||||||
|
# 注:event_init / event_finish / event_context 由具体的
|
||||||
|
# applications.view.init_bps / applications.common.script.init_script
|
||||||
|
# 触发,避免 init_plugs 内重复调用导致 Blueprint 注册冲突
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from flask import render_template, jsonify
|
||||||
|
|
||||||
|
|
||||||
|
def init_error_views(app):
|
||||||
|
@app.errorhandler(403)
|
||||||
|
def page_not_found(e):
|
||||||
|
return render_template('errors/403.html'), 403
|
||||||
|
|
||||||
|
@app.errorhandler(404)
|
||||||
|
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
|
||||||
|
|
||||||
|
@app.errorhandler(429)
|
||||||
|
def ratelimit_exceeded(e):
|
||||||
|
return jsonify(
|
||||||
|
success=False, msg="请求频率超限,请稍后再试。"
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from flask_login import LoginManager
|
||||||
|
|
||||||
|
|
||||||
|
def init_login_manager(app):
|
||||||
|
login_manager = LoginManager()
|
||||||
|
login_manager.init_app(app)
|
||||||
|
|
||||||
|
login_manager.login_view = 'system.passport.login'
|
||||||
|
|
||||||
|
@login_manager.user_loader
|
||||||
|
def load_user(user_id):
|
||||||
|
from applications.models import User
|
||||||
|
user = User.query.get(int(user_id))
|
||||||
|
return user
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from flask import Flask
|
||||||
|
from flask_mail import Mail
|
||||||
|
|
||||||
|
mail = Mail()
|
||||||
|
|
||||||
|
|
||||||
|
def init_mail(app: Flask):
|
||||||
|
mail.init_app(app)
|
||||||
@@ -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)
|
||||||
@@ -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')
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from flask_session import Session
|
||||||
|
|
||||||
|
sess = Session()
|
||||||
|
|
||||||
|
|
||||||
|
def init_session(app):
|
||||||
|
sess.init_app(app)
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
|
||||||
|
from flask import Flask, request
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_sqlalchemy.query import Query as BaseQuery
|
||||||
|
from sqlalchemy import event
|
||||||
|
from sqlalchemy.engine import Engine
|
||||||
|
from flask_marshmallow import Marshmallow
|
||||||
|
from marshmallow import fields
|
||||||
|
from marshmallow.validate import (
|
||||||
|
URL, Email, Range, Length, Equal, Regexp,
|
||||||
|
Predicate, NoneOf, OneOf, ContainsOnly
|
||||||
|
)
|
||||||
|
|
||||||
|
URL.default_message = '无效的链接'
|
||||||
|
Email.default_message = '无效的邮箱地址'
|
||||||
|
Range.message_min = '不能小于{min}'
|
||||||
|
Range.message_max = '不能小于{max}'
|
||||||
|
Range.message_all = '不能超过{min}和{max}这个范围'
|
||||||
|
Length.message_min = '长度不得小于{min}位'
|
||||||
|
Length.message_max = '长度不得大于{max}位'
|
||||||
|
Length.message_all = '长度不能超过{min}和{max}这个范围'
|
||||||
|
Length.message_equal = '长度必须等于{equal}位'
|
||||||
|
Equal.default_message = '必须等于{other}'
|
||||||
|
Regexp.default_message = '非法输入'
|
||||||
|
Predicate.default_message = '非法输入'
|
||||||
|
NoneOf.default_message = '非法输入'
|
||||||
|
OneOf.default_message = '无效的选择'
|
||||||
|
ContainsOnly.default_message = '一个或多个无效的选择'
|
||||||
|
|
||||||
|
fields.Field.default_error_messages = {
|
||||||
|
"required": "缺少必要数据",
|
||||||
|
"null": "数据不能为空",
|
||||||
|
"validator_failed": "非法数据",
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.Str.default_error_messages = {
|
||||||
|
'invalid': "不是合法文本"
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.Int.default_error_messages = {
|
||||||
|
"invalid": "不是合法整数"
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.Number.default_error_messages = {
|
||||||
|
"invalid": "不是合法数字"
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.Boolean.default_error_messages = {
|
||||||
|
"invalid": "不是合法布尔值"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Query(BaseQuery):
|
||||||
|
def soft_delete(self):
|
||||||
|
return self.update({"delete_at": datetime.datetime.now()})
|
||||||
|
|
||||||
|
def logic_all(self):
|
||||||
|
return self.filter_by(delete_at=None).all()
|
||||||
|
|
||||||
|
def all_json(self, schema: Marshmallow().Schema):
|
||||||
|
return schema(many=True).dump(self.all())
|
||||||
|
|
||||||
|
def layui_paginate(self, page=None, limit=None):
|
||||||
|
if page is None:
|
||||||
|
page = request.args.get('page', type=int)
|
||||||
|
if limit is None:
|
||||||
|
limit = min(request.args.get('limit', default=10, type=int), 90)
|
||||||
|
|
||||||
|
return self.paginate(page=page,
|
||||||
|
per_page=limit,
|
||||||
|
error_out=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def layui_paginate_json(self, schema, page=None, limit=None):
|
||||||
|
if page is None:
|
||||||
|
page = request.args.get('page', 1, type=int) # 添加默认值
|
||||||
|
if limit is None:
|
||||||
|
limit = request.args.get('limit', 10, type=int) # 添加默认值
|
||||||
|
|
||||||
|
_res = self.paginate(
|
||||||
|
page=page,
|
||||||
|
per_page=limit,
|
||||||
|
error_out=False
|
||||||
|
)
|
||||||
|
return schema(many=True).dump(_res.items), _res.total, _res.page, _res.per_page
|
||||||
|
|
||||||
|
def layui_paginate_db_json(self, page=None, limit=None):
|
||||||
|
if page is None:
|
||||||
|
page = request.args.get('page', 1, type=int) # 添加默认值
|
||||||
|
if limit is None:
|
||||||
|
limit = request.args.get('limit', 10, type=int) # 添加默认值
|
||||||
|
|
||||||
|
_res = self.paginate(
|
||||||
|
page=page,
|
||||||
|
per_page=limit,
|
||||||
|
error_out=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取查询的列名列表
|
||||||
|
column_names = [col["name"] for col in self.column_descriptions]
|
||||||
|
|
||||||
|
# 将元组转换为字典(支持单列或多列)
|
||||||
|
data = [
|
||||||
|
dict(zip(column_names, row))
|
||||||
|
for row in _res.items
|
||||||
|
]
|
||||||
|
|
||||||
|
return data, _res.total, _res.page, _res.per_page
|
||||||
|
|
||||||
|
|
||||||
|
db = SQLAlchemy(query_class=Query)
|
||||||
|
ma = Marshmallow()
|
||||||
|
|
||||||
|
|
||||||
|
def init_databases(app: Flask):
|
||||||
|
db.init_app(app)
|
||||||
|
ma.init_app(app)
|
||||||
|
|
||||||
|
# SQLite 才启用 WAL。WAL 把读与写互不阻塞,gunicorn 多 worker 写时基本不再
|
||||||
|
# 撞 "database is locked";同目录下会产生 pear.db-wal / pear.db-shm 两个文件,
|
||||||
|
# 备份必须用 VACUUM INTO 或 sqlite3 .backup,不能直接 cp(见 deploy/nas/backup.sh)。
|
||||||
|
if app.config.get("SQLALCHEMY_DATABASE_URI", "").startswith("sqlite"):
|
||||||
|
@event.listens_for(Engine, "connect")
|
||||||
|
def _sqlite_pragmas(dbapi_connection, connection_record):
|
||||||
|
cur = dbapi_connection.cursor()
|
||||||
|
try:
|
||||||
|
cur.execute("PRAGMA journal_mode=WAL")
|
||||||
|
cur.execute("PRAGMA synchronous=NORMAL")
|
||||||
|
cur.execute("PRAGMA foreign_keys=ON")
|
||||||
|
# busy_timeout 在 config.py 的 connect_args 里已设;这里再兜一次
|
||||||
|
# 兜底,防止用户自定义 connect_args 时漏配
|
||||||
|
cur.execute("PRAGMA busy_timeout=30000")
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
if os.environ.get('WERKZEUG_RUN_MAIN') == 'true':
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
db.engine.connect()
|
||||||
|
except Exception as e:
|
||||||
|
exit(f"数据库连接失败: {e}")
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from flask import session, current_app
|
||||||
|
from flask_login import current_user
|
||||||
|
from flask_wtf.csrf import generate_csrf
|
||||||
|
|
||||||
|
|
||||||
|
def init_template_directives(app):
|
||||||
|
@app.template_global()
|
||||||
|
def authorize(power):
|
||||||
|
if current_user.username != current_app.config.get("SUPERADMIN"):
|
||||||
|
return bool(power in session.get('permissions'))
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
|
@app.template_global()
|
||||||
|
def csrf_input():
|
||||||
|
return f'<input type="hidden" name="csrf_token" value="{generate_csrf()}">'
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from flask import Flask
|
||||||
|
from flask_uploads import configure_uploads
|
||||||
|
from flask_uploads import UploadSet, IMAGES
|
||||||
|
|
||||||
|
photos = UploadSet('photos', IMAGES)
|
||||||
|
|
||||||
|
|
||||||
|
def init_upload(app: Flask):
|
||||||
|
configure_uploads(app, photos)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from .admin_dept import Dept
|
||||||
|
from .admin_dict import DictType, DictData
|
||||||
|
from .admin_log import AdminLog
|
||||||
|
from .admin_photo import Photo
|
||||||
|
from .admin_power import Power
|
||||||
|
from .admin_role import Role
|
||||||
|
from .admin_role_power import role_power
|
||||||
|
from .admin_user import User
|
||||||
|
from .admin_user_role import user_role
|
||||||
|
from .admin_mail import Mail
|
||||||
|
from .admin_nav import Nav
|
||||||
|
from .admin_nav_category import NavCategory
|
||||||
|
from .admin_about import About
|
||||||
|
from .admin_friend import Friend
|
||||||
|
from .admin_stat import VisitLog, PageStat
|
||||||
|
from .admin_nav_click import NavClick
|
||||||
@@ -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 '',
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import datetime
|
||||||
|
from applications.extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
class Dept(db.Model):
|
||||||
|
__tablename__ = 'admin_dept'
|
||||||
|
id = db.Column(db.Integer, primary_key=True, comment="部门ID")
|
||||||
|
parent_id = db.Column(db.Integer, comment="父级编号")
|
||||||
|
dept_name = db.Column(db.String(50), comment="部门名称")
|
||||||
|
sort = db.Column(db.Integer, comment="排序")
|
||||||
|
leader = db.Column(db.String(50), comment="负责人")
|
||||||
|
phone = db.Column(db.String(20), comment="联系方式")
|
||||||
|
email = db.Column(db.String(50), comment="邮箱")
|
||||||
|
status = db.Column(db.Integer, comment='状态(1开启,0关闭)')
|
||||||
|
remark = db.Column(db.Text, comment="备注")
|
||||||
|
address = db.Column(db.String(255), comment="详细地址")
|
||||||
|
create_at = db.Column(db.DateTime, default=datetime.datetime.now, comment='创建时间')
|
||||||
|
update_at = db.Column(db.DateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now, comment='更新时间')
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import datetime
|
||||||
|
from applications.extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
class DictType(db.Model):
|
||||||
|
__tablename__ = 'admin_dict_type'
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
type_name = db.Column(db.String(255), comment='字典类型名称')
|
||||||
|
type_code = db.Column(db.String(255), comment='字典类型标识')
|
||||||
|
description = db.Column(db.String(255), comment='字典类型描述')
|
||||||
|
enable = db.Column(db.Integer, comment='是否开启')
|
||||||
|
create_time = db.Column(db.DateTime, default=datetime.datetime.now, comment='创建时间')
|
||||||
|
update_time = db.Column(db.DateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now, comment='更新时间')
|
||||||
|
|
||||||
|
|
||||||
|
class DictData(db.Model):
|
||||||
|
__tablename__ = 'admin_dict_data'
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
data_label = db.Column(db.String(255), comment='字典类型名称')
|
||||||
|
data_value = db.Column(db.String(255), comment='字典类型标识')
|
||||||
|
type_code = db.Column(db.String(255), comment='字典类型描述')
|
||||||
|
is_default = db.Column(db.Integer, comment='是否默认')
|
||||||
|
enable = db.Column(db.Integer, comment='是否开启')
|
||||||
|
remark = db.Column(db.String(255), comment='备注')
|
||||||
|
create_time = db.Column(db.DateTime, default=datetime.datetime.now, comment='创建时间')
|
||||||
|
update_time = db.Column(db.DateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now, comment='更新时间')
|
||||||
@@ -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 '',
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import datetime
|
||||||
|
from applications.extensions import db
|
||||||
|
|
||||||
|
class AdminLog(db.Model):
|
||||||
|
__tablename__ = 'admin_admin_log'
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
method = db.Column(db.String(10))
|
||||||
|
uid = db.Column(db.Integer)
|
||||||
|
url = db.Column(db.String(255))
|
||||||
|
desc = db.Column(db.Text)
|
||||||
|
ip = db.Column(db.String(255))
|
||||||
|
success = db.Column(db.Integer)
|
||||||
|
user_agent = db.Column(db.Text)
|
||||||
|
create_time = db.Column(db.DateTime, default=datetime.datetime.now)
|
||||||
@@ -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='创建时间')
|
||||||
@@ -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 '',
|
||||||
|
}
|
||||||
@@ -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 '',
|
||||||
|
}
|
||||||
@@ -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='点击时间')
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import datetime
|
||||||
|
from applications.extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
class Photo(db.Model):
|
||||||
|
__tablename__ = 'admin_photo'
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(255), nullable=False)
|
||||||
|
href = db.Column(db.String(255))
|
||||||
|
mime = db.Column(db.CHAR(50), nullable=False)
|
||||||
|
size = db.Column(db.CHAR(30), nullable=False)
|
||||||
|
create_time = db.Column(db.DateTime, default=datetime.datetime.now)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import datetime
|
||||||
|
from applications.extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
class Power(db.Model):
|
||||||
|
__tablename__ = 'admin_power'
|
||||||
|
id = db.Column(db.Integer, primary_key=True, comment='权限编号')
|
||||||
|
name = db.Column(db.String(255), comment='权限名称')
|
||||||
|
type = db.Column(db.String(1), comment='权限类型')
|
||||||
|
code = db.Column(db.String(30), comment='权限标识')
|
||||||
|
url = db.Column(db.String(255), comment='权限路径')
|
||||||
|
open_type = db.Column(db.String(10), comment='打开方式')
|
||||||
|
parent_id = db.Column(db.Integer, comment='父类编号')
|
||||||
|
icon = db.Column(db.String(128), comment='图标')
|
||||||
|
sort = db.Column(db.Integer, comment='排序')
|
||||||
|
create_time = db.Column(db.DateTime, default=datetime.datetime.now, comment='创建时间')
|
||||||
|
update_time = db.Column(db.DateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now, comment='更新时间')
|
||||||
|
enable = db.Column(db.Integer, comment='是否开启')
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import datetime
|
||||||
|
from applications.extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
class Role(db.Model):
|
||||||
|
__tablename__ = 'admin_role'
|
||||||
|
id = db.Column(db.Integer, primary_key=True, comment='角色ID')
|
||||||
|
name = db.Column(db.String(255), comment='角色名称')
|
||||||
|
code = db.Column(db.String(255), comment='角色标识')
|
||||||
|
enable = db.Column(db.Integer, comment='是否启用')
|
||||||
|
remark = db.Column(db.String(255), comment='备注')
|
||||||
|
details = db.Column(db.String(255), comment='详情')
|
||||||
|
sort = db.Column(db.Integer, comment='排序')
|
||||||
|
create_time = db.Column(db.DateTime, default=datetime.datetime.now, comment='创建时间')
|
||||||
|
update_time = db.Column(db.DateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now, comment='更新时间')
|
||||||
|
power = db.relationship('Power', secondary="admin_role_power", backref=db.backref('role'))
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from applications.extensions import db
|
||||||
|
|
||||||
|
# 创建中间表
|
||||||
|
role_power = db.Table(
|
||||||
|
"admin_role_power", # 中间表名称
|
||||||
|
db.Column("id", db.Integer, primary_key=True, autoincrement=True, comment='标识'), # 主键
|
||||||
|
db.Column("power_id", db.Integer, db.ForeignKey("admin_power.id"), comment='用户编号'), # 属性 外键
|
||||||
|
db.Column("role_id", db.Integer, db.ForeignKey("admin_role.id"), comment='角色编号'), # 属性 外键
|
||||||
|
)
|
||||||
@@ -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 '',
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import datetime
|
||||||
|
from flask_login import UserMixin
|
||||||
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
|
from applications.extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
class User(db.Model, UserMixin):
|
||||||
|
__tablename__ = 'admin_user'
|
||||||
|
id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='用户ID')
|
||||||
|
username = db.Column(db.String(20), comment='用户名')
|
||||||
|
realname = db.Column(db.String(20), comment='真实名字')
|
||||||
|
avatar = db.Column(db.String(255), comment='头像', default="/static/system/admin/images/avatar.jpg")
|
||||||
|
remark = db.Column(db.String(255), comment='备注')
|
||||||
|
password_hash = db.Column(db.String(128), comment='哈希密码')
|
||||||
|
enable = db.Column(db.Integer, default=0, comment='启用')
|
||||||
|
dept_id = db.Column(db.Integer, comment='部门id')
|
||||||
|
create_at = db.Column(db.DateTime, default=datetime.datetime.now, comment='创建时间')
|
||||||
|
update_at = db.Column(db.DateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now, comment='创建时间')
|
||||||
|
role = db.relationship('Role', secondary="admin_user_role", backref=db.backref('user'), lazy='dynamic')
|
||||||
|
|
||||||
|
def set_password(self, password):
|
||||||
|
self.password_hash = generate_password_hash(password)
|
||||||
|
|
||||||
|
def validate_password(self, password):
|
||||||
|
return check_password_hash(self.password_hash, password)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from applications.extensions import db
|
||||||
|
|
||||||
|
# 创建中间表
|
||||||
|
user_role = db.Table(
|
||||||
|
"admin_user_role", # 中间表名称
|
||||||
|
db.Column("id", db.Integer, primary_key=True, autoincrement=True, comment='标识'), # 主键
|
||||||
|
db.Column("user_id", db.Integer, db.ForeignKey("admin_user.id"), comment='用户编号'), # 属性 外键
|
||||||
|
db.Column("role_id", db.Integer, db.ForeignKey("admin_role.id"), comment='角色编号'), # 属性 外键
|
||||||
|
)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
|
||||||
|
from .admin_role import RoleOutSchema
|
||||||
|
from .admin_power import PowerOutSchema, PowerOutSchema2
|
||||||
|
from .admin_dict import DictDataOutSchema, DictTypeOutSchema
|
||||||
|
from .admin_dept import DeptSchema
|
||||||
|
from .admin_log import LogOutSchema
|
||||||
|
from .admin_photo import PhotoOutSchema
|
||||||
|
from .admin_mail import MailOutSchema
|
||||||
|
from .admin_nav import NavOutSchema, NavManageSchema
|
||||||
|
from .admin_nav_category import NavCategorySchema
|
||||||
|
from .admin_friend import FriendOutSchema, FriendManageSchema
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from flask_marshmallow.sqla import SQLAlchemyAutoSchema
|
||||||
|
|
||||||
|
from applications.models import Dept
|
||||||
|
|
||||||
|
|
||||||
|
class DeptSchema(SQLAlchemyAutoSchema):
|
||||||
|
class Meta:
|
||||||
|
model = Dept # table = models.Album.__table__
|
||||||
|
# include_relationships = True # 输出模型对象时同时对外键,是否也一并进行处理
|
||||||
|
include_fk = True # 序列化阶段是否也一并返回主键
|
||||||
|
# fields= ["id","name"] # 启动的字段列表
|
||||||
|
# exclude = ["id","name"] # 排除字段列表
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from applications.extensions import ma
|
||||||
|
from marshmallow import fields
|
||||||
|
|
||||||
|
|
||||||
|
class DictTypeOutSchema(ma.Schema):
|
||||||
|
id = fields.Str(attribute="id")
|
||||||
|
typeName = fields.Str(attribute="type_name")
|
||||||
|
typeCode = fields.Str(attribute="type_code")
|
||||||
|
description = fields.Str(attribute="description")
|
||||||
|
createTime = fields.Str(attribute="create_time")
|
||||||
|
updateName = fields.Str(attribute="update_time")
|
||||||
|
remark = fields.Str()
|
||||||
|
enable = fields.Str()
|
||||||
|
|
||||||
|
|
||||||
|
class DictDataOutSchema(ma.Schema):
|
||||||
|
dataId = fields.Str(attribute="id")
|
||||||
|
dataLabel = fields.Str(attribute="data_label")
|
||||||
|
dataValue = fields.Str(attribute="data_value")
|
||||||
|
remark = fields.Str()
|
||||||
|
enable = fields.Str()
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from applications.extensions import ma
|
||||||
|
from marshmallow import fields
|
||||||
|
|
||||||
|
|
||||||
|
class LogOutSchema(ma.Schema):
|
||||||
|
id = fields.Integer()
|
||||||
|
method = fields.Str()
|
||||||
|
uid = fields.Str()
|
||||||
|
url = fields.Str()
|
||||||
|
desc = fields.Str()
|
||||||
|
ip = fields.Str()
|
||||||
|
user_agent = fields.Str()
|
||||||
|
success = fields.Bool()
|
||||||
|
create_time = fields.DateTime()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from applications.extensions import ma
|
||||||
|
from marshmallow import fields
|
||||||
|
|
||||||
|
|
||||||
|
class PhotoOutSchema(ma.Schema):
|
||||||
|
id = fields.Integer()
|
||||||
|
name = fields.Str()
|
||||||
|
href = fields.Str()
|
||||||
|
mime = fields.Str()
|
||||||
|
size = fields.Str()
|
||||||
|
ext = fields.Str()
|
||||||
|
create_time = fields.DateTime()
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from flask_marshmallow.sqla import SQLAlchemyAutoSchema
|
||||||
|
|
||||||
|
from applications.extensions import ma
|
||||||
|
from marshmallow import fields
|
||||||
|
|
||||||
|
from applications.models import Power
|
||||||
|
|
||||||
|
|
||||||
|
class PowerSchema(SQLAlchemyAutoSchema):
|
||||||
|
class Meta:
|
||||||
|
model = Power # table = models.Album.__table__
|
||||||
|
# include_relationships = True # 输出模型对象时同时对外键,是否也一并进行处理
|
||||||
|
include_fk = True # 序列化阶段是否也一并返回主键
|
||||||
|
# fields= ["id","name"] # 启动的字段列表
|
||||||
|
# exclude = ["id","name"] # 排除字段列表
|
||||||
|
|
||||||
|
# 权限models序列化类
|
||||||
|
class PowerOutSchema(ma.Schema):
|
||||||
|
id = fields.Integer()
|
||||||
|
title = fields.Str(attribute="name")
|
||||||
|
type = fields.Str()
|
||||||
|
code = fields.Str()
|
||||||
|
href = fields.Str(attribute="url")
|
||||||
|
openType = fields.Str(attribute="open_type")
|
||||||
|
parent_id = fields.Integer()
|
||||||
|
icon = fields.Str()
|
||||||
|
sort = fields.Integer()
|
||||||
|
create_time = fields.DateTime()
|
||||||
|
update_time = fields.DateTime()
|
||||||
|
enable = fields.Integer()
|
||||||
|
|
||||||
|
|
||||||
|
class PowerOutSchema2(ma.Schema): # 序列化类
|
||||||
|
powerId = fields.Str(attribute="id")
|
||||||
|
powerName = fields.Str(attribute="name")
|
||||||
|
powerType = fields.Str(attribute="type")
|
||||||
|
powerUrl = fields.Str(attribute="url")
|
||||||
|
openType = fields.Str(attribute="open_type")
|
||||||
|
parentId = fields.Str(attribute="parent_id")
|
||||||
|
icon = fields.Str()
|
||||||
|
sort = fields.Integer()
|
||||||
|
create_time = fields.DateTime()
|
||||||
|
update_time = fields.DateTime()
|
||||||
|
enable = fields.Integer()
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from flask_marshmallow.sqla import SQLAlchemyAutoSchema
|
||||||
|
|
||||||
|
from applications.models import Role
|
||||||
|
|
||||||
|
|
||||||
|
class RoleOutSchema(SQLAlchemyAutoSchema):
|
||||||
|
class Meta:
|
||||||
|
model = Role # table = models.Album.__table__
|
||||||
|
# include_relationships = True # 输出模型对象时同时对外键,是否也一并进行处理
|
||||||
|
include_fk = True # 序列化阶段是否也一并返回主键
|
||||||
|
# fields= ["id","name"] # 启动的字段列表
|
||||||
|
# exclude = ["id","name"] # 排除字段列表
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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_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')
|
||||||
@@ -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)
|
||||||
@@ -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),不在这里重复
|
||||||
@@ -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
|
||||||
@@ -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='旺珂 · 导航',
|
||||||
|
)
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""
|
||||||
|
前台导航页视图(无需登录)。
|
||||||
|
|
||||||
|
URL 路径(挂在根路径下,便于匿名访问):
|
||||||
|
- GET /site 公开导航聚合首页(按分类展示所有启用的导航)
|
||||||
|
- GET /site/category/<name> 分类详情:展示指定分类下所有启用的导航
|
||||||
|
- GET /site/api/navs JSON 接口(供前端动态加载)
|
||||||
|
|
||||||
|
注意:
|
||||||
|
- 这些视图**不加** @authorize,避免 login_required 强制登录。
|
||||||
|
- 使用 url_prefix='/site' 避免与后台 / 路由冲突(后台 / 是登录后的工作台)。
|
||||||
|
"""
|
||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
from flask import Blueprint, render_template, request, jsonify
|
||||||
|
|
||||||
|
from applications.models import Nav, NavCategory
|
||||||
|
from plugins.siteStats.view.stat import get_nav_visit_counts, my_top_navs
|
||||||
|
|
||||||
|
# 「我常访问」冷启动阈值:至少点过这么多个不同网址才展示个人区
|
||||||
|
MINE_MIN_ITEMS = 2
|
||||||
|
|
||||||
|
bp = Blueprint('public_nav', __name__, url_prefix='/site')
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get('/')
|
||||||
|
def index():
|
||||||
|
"""
|
||||||
|
公开首页:按 category 分组聚合,渲染模板
|
||||||
|
"""
|
||||||
|
return _render_index()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get('/category/<string:name>')
|
||||||
|
def category_detail(name: str):
|
||||||
|
"""
|
||||||
|
分类详情页:列出指定分类下所有启用的导航
|
||||||
|
"""
|
||||||
|
items = (
|
||||||
|
Nav.query
|
||||||
|
.filter(Nav.category == name, Nav.enable == 1)
|
||||||
|
.order_by(Nav.sort.asc(), Nav.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
counts = get_nav_visit_counts()
|
||||||
|
dict_items = []
|
||||||
|
for it in items:
|
||||||
|
d = it.to_dict()
|
||||||
|
d['visit_count'] = counts.get(it.id, 0)
|
||||||
|
dict_items.append(d)
|
||||||
|
return render_template(
|
||||||
|
'public/category.html',
|
||||||
|
category=name,
|
||||||
|
items=dict_items,
|
||||||
|
site_name='旺珂 · 导航',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get('/api/navs')
|
||||||
|
def api_navs():
|
||||||
|
"""
|
||||||
|
JSON 接口:返回按分类聚合的导航数据
|
||||||
|
可用于首页卡片动态加载、第三方嵌入等
|
||||||
|
"""
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'msg': '请求成功',
|
||||||
|
'data': _grouped_navs(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def render_public_index():
|
||||||
|
"""给外部复用:渲染前台公开导航首页(无需登录)。"""
|
||||||
|
return _render_index()
|
||||||
|
|
||||||
|
|
||||||
|
def get_category_meta() -> "dict[str, dict]":
|
||||||
|
"""给外部复用:返回启用的分类元信息。"""
|
||||||
|
return _category_meta()
|
||||||
|
|
||||||
|
|
||||||
|
def _render_index():
|
||||||
|
groups = _grouped_navs()
|
||||||
|
total = sum(len(items) for items in groups.values())
|
||||||
|
mine = my_top_navs(8)
|
||||||
|
return render_template(
|
||||||
|
'public/index.html',
|
||||||
|
groups=groups,
|
||||||
|
total=total,
|
||||||
|
site_name='旺珂 · 导航',
|
||||||
|
cat_meta=_category_meta(),
|
||||||
|
top_navs=_top_navs(12),
|
||||||
|
my_navs=mine if len(mine) >= MINE_MIN_ITEMS else [],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _top_navs(limit: int = 12) -> "list[dict]":
|
||||||
|
"""跨分类聚合:返回点击数 TOP N 的有效 nav 列表(按点击数降序)。
|
||||||
|
|
||||||
|
- 复用 get_nav_visit_counts() 的 60s 进程内缓存
|
||||||
|
- 0 点击时返回空列表(调用方负责隐藏整个分类块)
|
||||||
|
- 同一个 nav 不可能重复出现(按 nav.id 维度)
|
||||||
|
"""
|
||||||
|
counts = get_nav_visit_counts()
|
||||||
|
if not counts:
|
||||||
|
return []
|
||||||
|
# 取点击数最高的 N 个 nav_id(点击数相同的按 id asc 稳定排序)
|
||||||
|
top_ids = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[:limit]
|
||||||
|
nav_ids = [nid for nid, _ in top_ids]
|
||||||
|
rows = (
|
||||||
|
Nav.query
|
||||||
|
.filter(Nav.id.in_(nav_ids), Nav.enable == 1)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
by_id = {n.id: n.to_dict() for n in rows}
|
||||||
|
out = []
|
||||||
|
for nid in nav_ids:
|
||||||
|
if nid in by_id:
|
||||||
|
d = by_id[nid]
|
||||||
|
d['visit_count'] = counts.get(nid, 0)
|
||||||
|
out.append(d)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _grouped_navs() -> "OrderedDict[str, list]":
|
||||||
|
"""按 category 分组,组内按 sort asc 排序;分组顺序优先按 NavCategory.sort 排列"""
|
||||||
|
# 先取启用的分类,按 sort 排好序
|
||||||
|
cats = (
|
||||||
|
NavCategory.query
|
||||||
|
.filter(NavCategory.enable == 1)
|
||||||
|
.order_by(NavCategory.sort.desc(), NavCategory.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
ordered_names = [c.name for c in cats]
|
||||||
|
cat_order = {name: idx for idx, name in enumerate(ordered_names)}
|
||||||
|
|
||||||
|
items = (
|
||||||
|
Nav.query
|
||||||
|
.filter(Nav.enable == 1)
|
||||||
|
.order_by(Nav.category.asc(), Nav.sort.asc(), Nav.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
# 一次性取所有 nav 的点击数(带缓存)
|
||||||
|
counts = get_nav_visit_counts()
|
||||||
|
groups = OrderedDict()
|
||||||
|
# 按 cat_order 初始化空分组,保证顺序
|
||||||
|
for name in ordered_names:
|
||||||
|
groups[name] = []
|
||||||
|
for item in items:
|
||||||
|
d = item.to_dict()
|
||||||
|
d['visit_count'] = counts.get(item.id, 0)
|
||||||
|
if item.category in groups:
|
||||||
|
groups[item.category].append(d)
|
||||||
|
else:
|
||||||
|
# 对于未在 NavCategory 登记的历史分类,放在最后
|
||||||
|
groups.setdefault(item.category, []).append(d)
|
||||||
|
# 过滤空分组(启用的分类下可能没有导航)
|
||||||
|
return OrderedDict((k, v) for k, v in groups.items() if v)
|
||||||
|
|
||||||
|
|
||||||
|
def _category_meta() -> "dict[str, dict]":
|
||||||
|
"""返回分类元信息 {name: {icon, description, ...}},用于抽屉图标等"""
|
||||||
|
cats = NavCategory.query.filter(NavCategory.enable == 1).all()
|
||||||
|
return {c.name: {'icon': c.icon or 'layui-icon-list', 'description': c.description or ''} for c in cats}
|
||||||
@@ -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)
|
||||||
@@ -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="删除部门成功")
|
||||||
@@ -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="删除成功")
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import os
|
||||||
|
from flask import Blueprint, request, render_template, jsonify, current_app
|
||||||
|
|
||||||
|
from applications.common.utils.http import fail_api, success_api, table_api
|
||||||
|
from applications.common.utils.rights import authorize
|
||||||
|
from applications.extensions import db
|
||||||
|
from applications.extensions.init_limit import limiter
|
||||||
|
from applications.models import Photo
|
||||||
|
from applications.common.utils import upload as upload_curd
|
||||||
|
|
||||||
|
bp = Blueprint('adminFile', __name__, url_prefix='/file')
|
||||||
|
|
||||||
|
|
||||||
|
# 图片管理
|
||||||
|
@bp.get('/')
|
||||||
|
@authorize("system:file:main")
|
||||||
|
def index():
|
||||||
|
return render_template('system/photo/photo.html')
|
||||||
|
|
||||||
|
|
||||||
|
# 图片数据
|
||||||
|
@bp.get('/table')
|
||||||
|
@limiter.limit("60 per minute")
|
||||||
|
@authorize("system:file:main")
|
||||||
|
def table():
|
||||||
|
page = request.args.get('page', type=int)
|
||||||
|
limit = request.args.get('limit', type=int)
|
||||||
|
data, count = upload_curd.get_photo(page=page, limit=limit)
|
||||||
|
return table_api(data=data, count=count)
|
||||||
|
|
||||||
|
|
||||||
|
# 上传
|
||||||
|
@bp.get('/upload')
|
||||||
|
@authorize("system:file:add", log=True)
|
||||||
|
def upload():
|
||||||
|
return render_template('system/photo/photo_add.html')
|
||||||
|
|
||||||
|
|
||||||
|
# 上传接口
|
||||||
|
@bp.post('/upload')
|
||||||
|
@authorize("system:file:add", log=True)
|
||||||
|
def upload_api():
|
||||||
|
if 'file' in request.files:
|
||||||
|
photo = request.files['file']
|
||||||
|
mime = request.files['file'].content_type
|
||||||
|
|
||||||
|
file_url = upload_curd.upload_one(photo=photo, mime=mime)
|
||||||
|
res = {
|
||||||
|
"msg": "上传成功",
|
||||||
|
"code": 0,
|
||||||
|
"success": True,
|
||||||
|
"data":
|
||||||
|
{"src": file_url}
|
||||||
|
}
|
||||||
|
return jsonify(res)
|
||||||
|
return fail_api()
|
||||||
|
|
||||||
|
|
||||||
|
# 图片删除
|
||||||
|
@bp.route('/delete', methods=['GET', 'POST'])
|
||||||
|
@authorize("system:file:delete", log=True)
|
||||||
|
def delete():
|
||||||
|
_id = request.form.get('id')
|
||||||
|
res = upload_curd.delete_photo_by_id(_id)
|
||||||
|
if res:
|
||||||
|
return success_api(msg="删除成功")
|
||||||
|
else:
|
||||||
|
return fail_api(msg="删除失败")
|
||||||
|
|
||||||
|
|
||||||
|
# 图片批量删除
|
||||||
|
@bp.route('/batchRemove', methods=['GET', 'POST'])
|
||||||
|
@authorize("system:file:delete", log=True)
|
||||||
|
def batch_remove():
|
||||||
|
ids = request.form.getlist('ids[]')
|
||||||
|
photo_name = Photo.query.filter(Photo.id.in_(ids)).all()
|
||||||
|
upload_url = current_app.config.get("UPLOADED_PHOTOS_DEST")
|
||||||
|
for p in photo_name:
|
||||||
|
os.remove(upload_url + '/' + p.name)
|
||||||
|
photo = Photo.query.filter(Photo.id.in_(ids)).delete(synchronize_session=False)
|
||||||
|
db.session.commit()
|
||||||
|
if photo:
|
||||||
|
return success_api(msg="删除成功")
|
||||||
|
else:
|
||||||
|
return fail_api(msg="删除失败")
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from flask import Blueprint, request, render_template
|
||||||
|
from sqlalchemy import desc
|
||||||
|
from applications.common.utils.http import table_api
|
||||||
|
from applications.common.utils.rights import authorize
|
||||||
|
from applications.models import AdminLog
|
||||||
|
from applications.schemas import LogOutSchema
|
||||||
|
from applications.common.curd import model_to_dicts
|
||||||
|
|
||||||
|
bp = Blueprint('log', __name__, url_prefix='/log')
|
||||||
|
|
||||||
|
|
||||||
|
# 日志管理
|
||||||
|
@bp.get('/')
|
||||||
|
@authorize("system:log:main")
|
||||||
|
def index():
|
||||||
|
return render_template('system/admin_log/main.html')
|
||||||
|
|
||||||
|
|
||||||
|
# 登录日志
|
||||||
|
@bp.get('/loginLog')
|
||||||
|
@authorize("system:log:main")
|
||||||
|
def login_log():
|
||||||
|
# orm查询
|
||||||
|
# 使用分页获取data需要.items
|
||||||
|
log = AdminLog.query.filter_by(
|
||||||
|
url='/system/passport/login'
|
||||||
|
).order_by(desc(AdminLog.create_time)).layui_paginate()
|
||||||
|
count = log.total
|
||||||
|
return table_api(data=model_to_dicts(schema=LogOutSchema, data=log.items), count=count)
|
||||||
|
|
||||||
|
|
||||||
|
# 操作日志
|
||||||
|
@bp.get('/operateLog')
|
||||||
|
@authorize("system:log:main")
|
||||||
|
def operate_log():
|
||||||
|
# orm查询
|
||||||
|
# 使用分页获取data需要.items
|
||||||
|
log = AdminLog.query.filter(
|
||||||
|
AdminLog.url != '/system/passport/login'
|
||||||
|
).order_by(
|
||||||
|
desc(AdminLog.create_time)).layui_paginate()
|
||||||
|
count = log.total
|
||||||
|
return table_api(data=model_to_dicts(schema=LogOutSchema, data=log.items), count=count)
|
||||||
@@ -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="批量删除成功")
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
from flask import Blueprint, session, redirect, url_for, render_template, request
|
||||||
|
from flask_login import current_user, login_user, login_required, logout_user
|
||||||
|
|
||||||
|
from applications.common.admin import get_captcha, login_log
|
||||||
|
from applications.common.utils.http import fail_api, success_api
|
||||||
|
from applications.models import User
|
||||||
|
|
||||||
|
bp = Blueprint('passport', __name__, url_prefix='/passport')
|
||||||
|
|
||||||
|
|
||||||
|
# 获取验证码
|
||||||
|
@bp.get('/getCaptcha')
|
||||||
|
def captcha():
|
||||||
|
resp, code = get_captcha()
|
||||||
|
session["code"] = code
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
# 登录
|
||||||
|
@bp.get('/login')
|
||||||
|
def login():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for('index.index'))
|
||||||
|
return render_template('system/login.html')
|
||||||
|
|
||||||
|
|
||||||
|
# 登录
|
||||||
|
@bp.post('/login')
|
||||||
|
def login_post():
|
||||||
|
req = request.form
|
||||||
|
username = req.get('username')
|
||||||
|
password = req.get('password')
|
||||||
|
remember = bool(req.get('remember-me'))
|
||||||
|
code = req.get('captcha').__str__().lower()
|
||||||
|
|
||||||
|
if not username or not password or not code:
|
||||||
|
return fail_api(msg="用户名或密码没有输入")
|
||||||
|
s_code = session.get("code", None)
|
||||||
|
session["code"] = None
|
||||||
|
|
||||||
|
if not all([code, s_code]):
|
||||||
|
return fail_api(msg="参数错误")
|
||||||
|
|
||||||
|
if code != s_code:
|
||||||
|
return fail_api(msg="验证码错误")
|
||||||
|
user = User.query.filter_by(username=username).first()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return fail_api(msg="不存在的用户")
|
||||||
|
|
||||||
|
if user.enable == 0:
|
||||||
|
return fail_api(msg="用户被暂停使用")
|
||||||
|
|
||||||
|
if username == user.username and user.validate_password(password):
|
||||||
|
# 登录
|
||||||
|
login_user(user, remember=remember)
|
||||||
|
# 记录登录日志
|
||||||
|
login_log(request, uid=user.id, is_access=True)
|
||||||
|
# 授权路由存入session
|
||||||
|
role = current_user.role
|
||||||
|
user_power = []
|
||||||
|
for i in role:
|
||||||
|
if i.enable == 0:
|
||||||
|
continue
|
||||||
|
for p in i.power:
|
||||||
|
if p.enable == 0:
|
||||||
|
continue
|
||||||
|
user_power.append(p.code)
|
||||||
|
session['permissions'] = user_power
|
||||||
|
# # 角色存入session
|
||||||
|
# roles = []
|
||||||
|
# for role in current_user.role.all():
|
||||||
|
# roles.append(role.id)
|
||||||
|
# session['role'] = [roles]
|
||||||
|
|
||||||
|
return success_api(msg="登录成功")
|
||||||
|
|
||||||
|
login_log(request, uid=user.id, is_access=False)
|
||||||
|
return fail_api(msg="用户名或密码错误")
|
||||||
|
|
||||||
|
|
||||||
|
# 退出登录
|
||||||
|
@bp.post('/logout')
|
||||||
|
@login_required
|
||||||
|
def logout():
|
||||||
|
logout_user()
|
||||||
|
|
||||||
|
if 'permissions' in session:
|
||||||
|
session.pop('permissions')
|
||||||
|
|
||||||
|
return success_api(msg="注销成功")
|
||||||
@@ -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="批量删除成功")
|
||||||
@@ -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')
|
||||||
@@ -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="角色删除成功")
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
from flask import Blueprint, render_template, request
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from sqlalchemy import desc
|
||||||
|
|
||||||
|
from applications.common import curd
|
||||||
|
from applications.common.curd import enable_status, disable_status
|
||||||
|
from applications.common.utils.http import table_api, fail_api, success_api
|
||||||
|
from applications.common.utils.rights import authorize
|
||||||
|
from applications.common.utils.validate import str_escape
|
||||||
|
from applications.extensions import db
|
||||||
|
from applications.extensions.init_limit import limiter
|
||||||
|
from applications.models import Role, Dept
|
||||||
|
from applications.models import User, AdminLog
|
||||||
|
|
||||||
|
bp = Blueprint('user', __name__, url_prefix='/user')
|
||||||
|
|
||||||
|
|
||||||
|
# 用户管理
|
||||||
|
@bp.get('/')
|
||||||
|
@authorize("system:user:main")
|
||||||
|
def main():
|
||||||
|
return render_template('system/user/main.html')
|
||||||
|
|
||||||
|
|
||||||
|
# 用户分页查询
|
||||||
|
@bp.get('/data')
|
||||||
|
@limiter.limit("60 per minute")
|
||||||
|
@authorize("system:user:main")
|
||||||
|
def data():
|
||||||
|
# 获取请求参数
|
||||||
|
real_name = str_escape(request.args.get('realname', type=str))
|
||||||
|
|
||||||
|
username = str_escape(request.args.get('username', type=str))
|
||||||
|
dept_id = request.args.get('deptId', type=int)
|
||||||
|
|
||||||
|
filters = []
|
||||||
|
if real_name:
|
||||||
|
filters.append(User.realname.contains(real_name))
|
||||||
|
if username:
|
||||||
|
filters.append(User.username.contains(username))
|
||||||
|
if dept_id:
|
||||||
|
filters.append(User.dept_id == dept_id)
|
||||||
|
|
||||||
|
# print(*filters)
|
||||||
|
query = db.session.query(
|
||||||
|
User,
|
||||||
|
Dept
|
||||||
|
).filter(*filters).outerjoin(Dept, User.dept_id == Dept.id).layui_paginate()
|
||||||
|
|
||||||
|
return table_api(
|
||||||
|
data=[{
|
||||||
|
'id': user.id,
|
||||||
|
'username': user.username,
|
||||||
|
'realname': user.realname,
|
||||||
|
'enable': user.enable,
|
||||||
|
'create_at': user.create_at,
|
||||||
|
'update_at': user.update_at,
|
||||||
|
'dept_name': dept.dept_name if dept else None
|
||||||
|
} for user, dept in query.items],
|
||||||
|
count=query.total)
|
||||||
|
|
||||||
|
# 用户增加
|
||||||
|
@bp.get('/add')
|
||||||
|
@authorize("system:user:add", log=True)
|
||||||
|
def add():
|
||||||
|
roles = Role.query.all()
|
||||||
|
return render_template('system/user/add.html', roles=roles)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post('/save')
|
||||||
|
@authorize("system:user:add", log=True)
|
||||||
|
def save():
|
||||||
|
req_json = request.get_json(force=True)
|
||||||
|
a = req_json.get("roleIds")
|
||||||
|
username = str_escape(req_json.get('username'))
|
||||||
|
real_name = str_escape(req_json.get('realName'))
|
||||||
|
password = str_escape(req_json.get('password'))
|
||||||
|
dept_id = str_escape(req_json.get('deptId'))
|
||||||
|
role_ids = a.split(',')
|
||||||
|
|
||||||
|
if not username or not real_name or not password:
|
||||||
|
return fail_api(msg="账号姓名密码不得为空")
|
||||||
|
|
||||||
|
if bool(User.query.filter_by(username=username).count()):
|
||||||
|
return fail_api(msg="用户已经存在")
|
||||||
|
|
||||||
|
user = User(username=username, realname=real_name, enable=1, dept_id=dept_id)
|
||||||
|
user.set_password(password)
|
||||||
|
db.session.add(user)
|
||||||
|
roles = Role.query.filter(Role.id.in_(role_ids)).all()
|
||||||
|
for r in roles:
|
||||||
|
user.role.append(r)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return success_api(msg="增加成功")
|
||||||
|
|
||||||
|
|
||||||
|
# 删除用户
|
||||||
|
@bp.delete('/remove/<int:id>')
|
||||||
|
@authorize("system:user:remove", log=True)
|
||||||
|
def delete(id):
|
||||||
|
user = User.query.filter_by(id=id).first()
|
||||||
|
user.role = []
|
||||||
|
|
||||||
|
res = User.query.filter_by(id=id).delete()
|
||||||
|
db.session.commit()
|
||||||
|
if not res:
|
||||||
|
return fail_api(msg="删除失败")
|
||||||
|
return success_api(msg="删除成功")
|
||||||
|
|
||||||
|
|
||||||
|
# 编辑用户
|
||||||
|
@bp.get('/edit/<int:id>')
|
||||||
|
@authorize("system:user:edit", log=True)
|
||||||
|
def edit(id):
|
||||||
|
user = curd.get_one_by_id(User, id)
|
||||||
|
roles = Role.query.all()
|
||||||
|
checked_roles = []
|
||||||
|
for r in user.role:
|
||||||
|
checked_roles.append(r.id)
|
||||||
|
return render_template('system/user/edit.html', user=user, roles=roles, checked_roles=checked_roles)
|
||||||
|
|
||||||
|
|
||||||
|
# 编辑用户
|
||||||
|
@bp.put('/update')
|
||||||
|
@authorize("system:user:edit", log=True)
|
||||||
|
def update():
|
||||||
|
req_json = request.get_json(force=True)
|
||||||
|
a = str_escape(req_json.get("roleIds"))
|
||||||
|
id = str_escape(req_json.get("userId"))
|
||||||
|
username = str_escape(req_json.get('username'))
|
||||||
|
real_name = str_escape(req_json.get('realName'))
|
||||||
|
dept_id = str_escape(req_json.get('deptId'))
|
||||||
|
role_ids = a.split(',')
|
||||||
|
User.query.filter_by(id=id).update({'username': username, 'realname': real_name, 'dept_id': dept_id})
|
||||||
|
u = User.query.filter_by(id=id).first()
|
||||||
|
|
||||||
|
roles = Role.query.filter(Role.id.in_(role_ids)).all()
|
||||||
|
u.role = roles
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return success_api(msg="更新成功")
|
||||||
|
|
||||||
|
|
||||||
|
# 个人中心
|
||||||
|
@bp.get('/center')
|
||||||
|
@login_required
|
||||||
|
def center():
|
||||||
|
user_info = current_user
|
||||||
|
user_logs = AdminLog.query.filter_by(url='/passport/login').filter_by(uid=current_user.id).order_by(
|
||||||
|
desc(AdminLog.create_time)).limit(10)
|
||||||
|
return render_template('system/user/center.html', user_info=user_info, user_logs=user_logs)
|
||||||
|
|
||||||
|
|
||||||
|
# 修改头像
|
||||||
|
@bp.get('/profile')
|
||||||
|
@login_required
|
||||||
|
def profile():
|
||||||
|
return render_template('system/user/profile.html')
|
||||||
|
|
||||||
|
|
||||||
|
# 修改头像
|
||||||
|
@bp.put('/updateAvatar')
|
||||||
|
@login_required
|
||||||
|
def update_avatar():
|
||||||
|
url = request.get_json(force=True).get("avatar").get("src")
|
||||||
|
r = User.query.filter_by(id=current_user.id).update({"avatar": url})
|
||||||
|
db.session.commit()
|
||||||
|
if not r:
|
||||||
|
return fail_api(msg="出错啦")
|
||||||
|
return success_api(msg="修改成功")
|
||||||
|
|
||||||
|
|
||||||
|
# 修改当前用户信息
|
||||||
|
@bp.put('/updateInfo')
|
||||||
|
@login_required
|
||||||
|
def update_info():
|
||||||
|
req_json = request.get_json(force=True)
|
||||||
|
r = User.query.filter_by(id=current_user.id).update(
|
||||||
|
{"realname": req_json.get("realName"), "remark": req_json.get("details")})
|
||||||
|
db.session.commit()
|
||||||
|
if not r:
|
||||||
|
return fail_api(msg="出错啦")
|
||||||
|
return success_api(msg="更新成功")
|
||||||
|
|
||||||
|
|
||||||
|
# 修改当前用户密码
|
||||||
|
@bp.get('/editPassword')
|
||||||
|
@login_required
|
||||||
|
def edit_password():
|
||||||
|
return render_template('system/user/edit_password.html')
|
||||||
|
|
||||||
|
|
||||||
|
# 修改当前用户密码
|
||||||
|
@bp.put('/editPassword')
|
||||||
|
@login_required
|
||||||
|
def edit_password_put():
|
||||||
|
res_json = request.get_json(force=True)
|
||||||
|
if res_json.get("newPassword") == '':
|
||||||
|
return fail_api("新密码不得为空")
|
||||||
|
if res_json.get("newPassword") != res_json.get("confirmPassword"):
|
||||||
|
return fail_api("两次密码不一样")
|
||||||
|
user = current_user
|
||||||
|
is_right = user.validate_password(res_json.get("oldPassword"))
|
||||||
|
if not is_right:
|
||||||
|
return fail_api("旧密码错误")
|
||||||
|
user.set_password(res_json.get("newPassword"))
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
return success_api("更改成功")
|
||||||
|
|
||||||
|
|
||||||
|
# 启用用户
|
||||||
|
@bp.put('/enable')
|
||||||
|
@authorize("system:user:edit", log=True)
|
||||||
|
def enable():
|
||||||
|
_id = request.get_json(force=True).get('userId')
|
||||||
|
if _id:
|
||||||
|
res = enable_status(model=User, id=_id)
|
||||||
|
if not res:
|
||||||
|
return fail_api(msg="出错啦")
|
||||||
|
return success_api(msg="启动成功")
|
||||||
|
return fail_api(msg="数据错误")
|
||||||
|
|
||||||
|
|
||||||
|
# 禁用用户
|
||||||
|
@bp.put('/disable')
|
||||||
|
@authorize("system:user:edit", log=True)
|
||||||
|
def dis_enable():
|
||||||
|
_id = request.get_json(force=True).get('userId')
|
||||||
|
if _id:
|
||||||
|
res = disable_status(model=User, id=_id)
|
||||||
|
if not res:
|
||||||
|
return fail_api(msg="出错啦")
|
||||||
|
return success_api(msg="禁用成功")
|
||||||
|
return fail_api(msg="数据错误")
|
||||||
-36
@@ -1,36 +0,0 @@
|
|||||||
import os
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
|
|
||||||
class BaseConfig:
|
|
||||||
SECRET_KEY = os.getenv("SECRET_KEY", "pear-admin-flask")
|
|
||||||
|
|
||||||
SQLALCHEMY_DATABASE_URI = ""
|
|
||||||
|
|
||||||
ROOT_PATH = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
|
|
||||||
JWT_TOKEN_LOCATION = ["headers"]
|
|
||||||
JWT_ACCESS_TOKEN_EXPIRES = timedelta(days=7)
|
|
||||||
|
|
||||||
|
|
||||||
class DevelopmentConfig(BaseConfig):
|
|
||||||
"""开发配置"""
|
|
||||||
|
|
||||||
SQLALCHEMY_DATABASE_URI = "sqlite:///pear_admin.db"
|
|
||||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
|
||||||
|
|
||||||
|
|
||||||
class TestingConfig(BaseConfig):
|
|
||||||
"""测试配置"""
|
|
||||||
|
|
||||||
SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:" # 内存数据库
|
|
||||||
|
|
||||||
|
|
||||||
class ProductionConfig(BaseConfig):
|
|
||||||
"""生成环境配置"""
|
|
||||||
|
|
||||||
SQLALCHEMY_DATABASE_URI = "mysql://root:root@127.0.0.1:3306/pear_admin"
|
|
||||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
|
||||||
|
|
||||||
|
|
||||||
config = {"dev": DevelopmentConfig, "test": TestingConfig, "prod": ProductionConfig}
|
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
flask:
|
||||||
|
build:
|
||||||
|
context: ../.. # 指向项目根目录 (pear-admin-flask)
|
||||||
|
dockerfile: deploy/dev/Dockerfile
|
||||||
|
container_name: flask_dev
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
|
||||||
|
# [优化] 挂载卷:将本地代码映射到容器内部
|
||||||
|
# 效果:你在 PyCharm 修改代码,容器里立马生效,不需要重启容器
|
||||||
|
volumes:
|
||||||
|
- ../..:/app
|
||||||
|
|
||||||
|
# [优化] 环境变量
|
||||||
|
environment:
|
||||||
|
- FLASK_DEBUG=0 # 是否开启 Flask 调试模式
|
||||||
|
- FLASK_APP=app.py
|
||||||
|
|
||||||
|
# 网络设置
|
||||||
|
networks:
|
||||||
|
- dev_network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
dev_network:
|
||||||
|
driver: bridge
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# 确保进入工作目录
|
||||||
|
cd /app
|
||||||
|
|
||||||
|
echo "=== [Step 1] 检查并初始化数据库 ==="
|
||||||
|
# 使用 || true 确保即使命令报错(例如migrations文件夹已存在)也不会中断脚本
|
||||||
|
flask db init || true
|
||||||
|
flask db migrate || true
|
||||||
|
flask db upgrade || true
|
||||||
|
|
||||||
|
# (可选) 每次重启尝试初始化数据,如果已存在则会被代码逻辑跳过
|
||||||
|
flask admin init || true
|
||||||
|
|
||||||
|
echo "=== [Step 2] 启动服务 (开发模式) ==="
|
||||||
|
|
||||||
|
# [优化] 增加 --reload 参数
|
||||||
|
# 配合 docker-compose 的 volumes,当检测到代码变化时,Gunicorn 会自动重启 worker
|
||||||
|
# access-logfile - 表示将日志输出到控制台
|
||||||
|
exec gunicorn --bind 0.0.0.0:5000 \
|
||||||
|
--workers 2 \
|
||||||
|
--reload \
|
||||||
|
--access-logfile - \
|
||||||
|
--error-logfile - \
|
||||||
|
app:app
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# =====================================================
|
||||||
|
# Pear Admin Flask - 个人 NAS / 单机部署 Dockerfile
|
||||||
|
# =====================================================
|
||||||
|
# 适用场景:内网访问 / 少量并发 / 数据量小
|
||||||
|
# 基础:python:3.11-slim(~150 MB),无 MySQL,纯 SQLite
|
||||||
|
#
|
||||||
|
# 构建: docker build -f deploy/nas/Dockerfile -t pear-admin-nas .
|
||||||
|
# 运行: docker compose -f deploy/nas/docker-compose.yaml up -d
|
||||||
|
# =====================================================
|
||||||
|
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# 容器内时区与 Python 输出
|
||||||
|
ENV TZ=Asia/Shanghai \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
PEAR_ENV=pro \
|
||||||
|
PEAR_DATA_DIR=/app/data \
|
||||||
|
FLASK_APP=app.py
|
||||||
|
|
||||||
|
# 安装时区数据 + 健康检查用的 curl
|
||||||
|
# (curl 在 slim 里已有,但保持显式声明便于以后切到 alpine)
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends curl tzdata sqlite3 \
|
||||||
|
&& ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \
|
||||||
|
&& echo $TZ > /etc/timezone \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 非 root 运行(生产最佳实践)
|
||||||
|
RUN useradd -m -u 1001 appuser
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 先只 copy 依赖文件 → 利用 Docker 缓存;requirements 不变则跳过 pip install
|
||||||
|
COPY requirements.txt ./
|
||||||
|
RUN pip install -r requirements.txt \
|
||||||
|
&& pip install gunicorn==21.2.0
|
||||||
|
|
||||||
|
# 再 copy 全部源码
|
||||||
|
COPY --chown=appuser:appuser . .
|
||||||
|
|
||||||
|
# 数据目录单独建好并 chown 给 appuser(uid 1001)。
|
||||||
|
# /app/data 由卷挂载覆盖,但容器刚启动时如果宿主目录是空的,
|
||||||
|
# 子目录(backup/ flask_session/ 等)需要 appuser 自己能写。
|
||||||
|
RUN mkdir -p /app/data/backup /app/data/flask_session /app/data/logs /app/data/upload \
|
||||||
|
&& chown -R appuser:appuser /app/data
|
||||||
|
|
||||||
|
# 启动脚本加执行权限
|
||||||
|
RUN chmod +x /app/deploy/nas/start.sh /app/deploy/nas/backup.sh
|
||||||
|
|
||||||
|
# 切换到非 root
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
# 容器健康检查:访问 /healthz 接口(applications/view/health.py 提供,DB 不可达返回 503)
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||||
|
CMD curl -fsS http://localhost:5000/healthz || exit 1
|
||||||
|
|
||||||
|
CMD ["/app/deploy/nas/start.sh"]
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Pear Admin Flask - SQLite 在线热备脚本
|
||||||
|
#
|
||||||
|
# 用法: docker exec pear-admin-nas /app/deploy/nas/backup.sh
|
||||||
|
# (也支持本地直接 ./backup.sh)
|
||||||
|
#
|
||||||
|
# 关键:不能用 cp 复制 pear.db —— WAL 模式下 .db 文件单独 cp 不含未刷盘的页。
|
||||||
|
# 改用 SQLite 自带的 `sqlite3 pear.db ".backup target"` 或
|
||||||
|
# `VACUUM INTO 'target'`,两者都在 SQLite 内部完成拷贝,含 WAL 的一致性快照。
|
||||||
|
#
|
||||||
|
# 产物:${DATA_DIR}/backup/pear-YYYYMMDD_HHMMSS.db
|
||||||
|
# 保留:最近 14 份(可通过 BACKUP_KEEP 覆盖)
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
DATA_DIR=${PEAR_DATA_DIR:-/app/data}
|
||||||
|
DB_PATH="${DATA_DIR}/pear.db"
|
||||||
|
BACKUP_DIR="${DATA_DIR}/backup"
|
||||||
|
KEEP=${BACKUP_KEEP:-14}
|
||||||
|
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||||
|
TARGET="${BACKUP_DIR}/pear-${TIMESTAMP}.db"
|
||||||
|
|
||||||
|
if [[ ! -f "${DB_PATH}" ]]; then
|
||||||
|
echo "FATAL: 数据库文件不存在: ${DB_PATH}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "${BACKUP_DIR}"
|
||||||
|
|
||||||
|
echo "[backup] db: ${DB_PATH}"
|
||||||
|
echo "[backup] -> : ${TARGET}"
|
||||||
|
# sqlite3 二进制在 Dockerfile 里 apt install 了;镜像外本机有的话也用得上
|
||||||
|
sqlite3 "${DB_PATH}" ".backup '${TARGET}'"
|
||||||
|
|
||||||
|
# 压缩一份小的;同时保留原文件方便快速恢复
|
||||||
|
gzip -f "${TARGET}"
|
||||||
|
|
||||||
|
# 清理旧备份:保留文件名最近 KEEP 个(含 .gz 后缀)
|
||||||
|
KEPT=0
|
||||||
|
for f in $(ls -1t "${BACKUP_DIR}"/pear-*.db.gz 2>/dev/null); do
|
||||||
|
KEPT=$((KEPT + 1))
|
||||||
|
if [[ "${KEPT}" -gt "${KEEP}" ]]; then
|
||||||
|
rm -f "${f}"
|
||||||
|
echo "[backup] removed old: ${f}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 健康检查:备份文件必须 > 0 字节且能正常打开
|
||||||
|
LATEST="${BACKUP_DIR}/pear-${TIMESTAMP}.db.gz"
|
||||||
|
if [[ ! -s "${LATEST}" ]]; then
|
||||||
|
echo "FATAL: 备份文件为空: ${LATEST}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "[backup] done. latest = ${LATEST}"
|
||||||
|
echo "[backup] current keep: $(ls -1 "${BACKUP_DIR}"/pear-*.db.gz 2>/dev/null | wc -l) of ${KEEP}"
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# =====================================================
|
||||||
|
# Pear Admin Flask - 个人 NAS 单机部署
|
||||||
|
# =====================================================
|
||||||
|
# 使用:
|
||||||
|
# 1) cp .env.example .env 并修改 SECRET_KEY
|
||||||
|
# 2) mkdir -p data && cp ../pear.db data/pear.db # 首次部署把已有库搬过来
|
||||||
|
# 3) docker compose -f deploy/nas/docker-compose.yaml up -d
|
||||||
|
# 4) 访问 http://NAS_IP:5000
|
||||||
|
#
|
||||||
|
# 数据持久化(一个目录搞定):
|
||||||
|
# ./data → 容器内 /app/data
|
||||||
|
# ├─ pear.db pear.db-wal pear.db-shm ← SQLite(WAL 模式)
|
||||||
|
# ├─ backup/ ← 在线热备(VACUUM INTO 产物)
|
||||||
|
# ├─ flask_session/ ← Flask-Session 文件
|
||||||
|
# ├─ logs/ ← gunicorn / Flask 日志
|
||||||
|
# └─ upload/ ← 上传图片
|
||||||
|
#
|
||||||
|
# 重建容器 / 换机器:整个 ./data 目录拷走就行,pear.db、备份、上传全在里面。
|
||||||
|
# =====================================================
|
||||||
|
|
||||||
|
services:
|
||||||
|
flask:
|
||||||
|
build:
|
||||||
|
context: ../.. # 项目根目录(含 requirements.txt)
|
||||||
|
dockerfile: deploy/nas/Dockerfile
|
||||||
|
image: pear-admin-nas:latest
|
||||||
|
container_name: pear-admin-nas
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "5000:5000" # 容器 5000 → NAS 5000;可改为 127.0.0.1:5000:5000 仅本地访问
|
||||||
|
|
||||||
|
# 环境变量注入(生产环境用 .env 自动读取)
|
||||||
|
env_file:
|
||||||
|
- ../../.env
|
||||||
|
environment:
|
||||||
|
# 把容器内的 /app/data 重定向到宿主 ./data;
|
||||||
|
# config.py 会自动把 SQLite 库、session、logs、backup 都收到这里
|
||||||
|
PEAR_DATA_DIR: /app/data
|
||||||
|
|
||||||
|
# 数据持久化:单一绑定挂载,NAS 共享文件夹里直接看得见
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
|
||||||
|
# 资源限制(NAS 推荐;按需调整)
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '2.0' # 单容器最多用 2 核
|
||||||
|
memory: 1024M # 1 GB 上限
|
||||||
|
reservations:
|
||||||
|
cpus: '0.5'
|
||||||
|
memory: 256M
|
||||||
|
|
||||||
|
# 健康检查:applications/view/health.py 提供 /healthz
|
||||||
|
# - 200:DB OK
|
||||||
|
# - 503:DB 不可达(让容器被标 unhealthy)
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://localhost:5000/healthz"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
start_period: 40s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
# 网络模式默认 bridge;如 NAS 上启用了 host 网络可获得更好的局域网性能:
|
||||||
|
# network_mode: host
|
||||||
|
networks:
|
||||||
|
- pear_net
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# 单数据卷已够用:
|
||||||
|
# - 重建容器:只会重建容器层,./data 完全不受影响
|
||||||
|
# - 备份:NAS 上定时把 ./data 整个 tar 走,或跑 deploy/nas/backup.sh 单独导出 pear.db
|
||||||
|
# - 迁移:停容器、把 ./data 拷到新机器、起新容器,pear.db 内容原样可用
|
||||||
|
# =====================================================
|
||||||
|
networks:
|
||||||
|
pear_net:
|
||||||
|
driver: bridge
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Pear Admin Flask - NAS 生产启动脚本
|
||||||
|
#
|
||||||
|
# 流程:
|
||||||
|
# 1. 数据目录检查:PEAR_DATA_DIR 必须存在且可写;不存在则用默认行为
|
||||||
|
# 2. alembic 迁移到最新版本(幂等;migrations 文件夹随镜像打包)
|
||||||
|
# 3. flask admin init 幂等初始化菜单/权限(已有则跳过)
|
||||||
|
# 4. exec 切换到 gunicorn,PID 1 由 gunicorn 接管
|
||||||
|
#
|
||||||
|
# 注意:必须 exec,否则容器启动后 PID 1 是 shell 而非 gunicorn,
|
||||||
|
# docker stop 发出的 SIGTERM 不会被正确转发,graceful shutdown 失效。
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
DATA_DIR=${PEAR_DATA_DIR:-/app/data}
|
||||||
|
echo "=== [1/4] 数据目录 ==="
|
||||||
|
echo "PEAR_DATA_DIR=${DATA_DIR}"
|
||||||
|
mkdir -p "${DATA_DIR}/backup" "${DATA_DIR}/flask_session" "${DATA_DIR}/logs" "${DATA_DIR}/upload"
|
||||||
|
# 确保子目录可写(WAL 模式下 SQLite 需要 pear.db 同目录的 -wal / -shm 也能创建)
|
||||||
|
touch "${DATA_DIR}/.write_test" && rm -f "${DATA_DIR}/.write_test" || {
|
||||||
|
echo "FATAL: data dir not writable: ${DATA_DIR}" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "=== [2/4] 等待数据库就绪 ==="
|
||||||
|
# 默认 SQLite 不需要等待;这里预留扩展位(如未来切 MySQL,可在此加 wait-for-it)
|
||||||
|
if [[ "${SQLALCHEMY_DATABASE_URI:-}" == mysql* ]]; then
|
||||||
|
echo "检测到 MySQL,等待 10s 让 db 服务启动..."
|
||||||
|
sleep 10
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== [3/4] 初始化数据库(幂等) ==="
|
||||||
|
# flask 命令依赖 FLASK_APP;镜像里已经设过;这里再 fallback 一次
|
||||||
|
export FLASK_APP=${FLASK_APP:-app.py}
|
||||||
|
|
||||||
|
# 数据库迁移:migrate 仅当模型有变才生成新版本;upgrade 永远是幂等的
|
||||||
|
flask db upgrade || echo "WARN: flask db upgrade failed (首次启动可能正常)"
|
||||||
|
flask admin init || true
|
||||||
|
|
||||||
|
echo "=== [4/4] 启动 Gunicorn ==="
|
||||||
|
# SQLite + WAL 模式下,多 worker 写文件本身没问题(同时间互不阻塞),
|
||||||
|
# 但多个事务并发提交仍可能撞锁。这里把 worker 降到 1(线程给够),
|
||||||
|
# 既能吃满并发量、又把"database is locked"概率压到最低。
|
||||||
|
WORKERS=${GUNICORN_WORKERS:-1}
|
||||||
|
THREADS=${GUNICORN_THREADS:-8}
|
||||||
|
TIMEOUT=60
|
||||||
|
|
||||||
|
exec gunicorn \
|
||||||
|
--bind 0.0.0.0:5000 \
|
||||||
|
--workers "$WORKERS" \
|
||||||
|
--threads "$THREADS" \
|
||||||
|
--timeout "$TIMEOUT" \
|
||||||
|
--graceful-timeout 30 \
|
||||||
|
--keep-alive 5 \
|
||||||
|
--access-logfile "${DATA_DIR}/logs/access.log" \
|
||||||
|
--error-logfile "${DATA_DIR}/logs/error.log" \
|
||||||
|
--log-level info \
|
||||||
|
app:app
|
||||||
+164
@@ -0,0 +1,164 @@
|
|||||||
|
# Pear Admin Flask - 部署指南
|
||||||
|
|
||||||
|
本文档面向把本项目部署到生产环境的运维 / 二次开发者。
|
||||||
|
请结合根目录的 `README.md` 与 `docs/SECURITY.md` 一起阅读。
|
||||||
|
|
||||||
|
## 0. 环境要求
|
||||||
|
|
||||||
|
| 项目 | 版本 |
|
||||||
|
| --- | --- |
|
||||||
|
| Python | 3.8(推荐 3.11) |
|
||||||
|
| SQLite | 3.x(仅开发用) |
|
||||||
|
| MySQL | 5.7+ / 8.0(生产推荐) |
|
||||||
|
| 操作系统 | Windows / Linux 均可 |
|
||||||
|
|
||||||
|
## 1. 克隆代码
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.bwhome.top/bwadmin/pear-admin-flask.git
|
||||||
|
cd pear-admin-flask
|
||||||
|
```
|
||||||
|
|
||||||
|
> 如仓库使用自签证书导致 `SEC_E_WRONG_PRINCIPAL` 报错,
|
||||||
|
> 可临时关闭 SSL 校验:`git -c http.sslVerify=false clone <url>`。
|
||||||
|
> 长期建议把根证书导入到系统信任库。
|
||||||
|
|
||||||
|
## 2. 创建虚拟环境 + 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux / macOS
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
# Windows (cmd)
|
||||||
|
python -m venv venv
|
||||||
|
venv\Scripts\activate.bat
|
||||||
|
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 配置 .env
|
||||||
|
|
||||||
|
复制模板:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # Linux
|
||||||
|
copy .env.example .env # Windows
|
||||||
|
```
|
||||||
|
|
||||||
|
按需修改(**生产模式 SECRET_KEY 必填**,参见下文):
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
PEAR_ENV=production
|
||||||
|
SECRET_KEY=<用 python -c "import secrets; print(secrets.token_urlsafe(48))" 生成>
|
||||||
|
|
||||||
|
# MySQL 示例
|
||||||
|
SQLALCHEMY_DATABASE_URI=mysql+pymysql://user:pass@127.0.0.1:3306/pear?charset=utf8mb4
|
||||||
|
|
||||||
|
# 邮件(可选)
|
||||||
|
MAIL_SERVER=smtp.qq.com
|
||||||
|
MAIL_USE_SSL=true
|
||||||
|
MAIL_PORT=465
|
||||||
|
MAIL_USERNAME=your_account@qq.com
|
||||||
|
MAIL_PASSWORD=your_authorization_code_here
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ `.env` 文件已在 `.gitignore` 中,**不会**被提交到 git。
|
||||||
|
|
||||||
|
## 4. 数据库初始化
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) 生成迁移脚本
|
||||||
|
flask db migrate
|
||||||
|
# 2) 升级到当前 head
|
||||||
|
flask db upgrade
|
||||||
|
# 3) 导入 admin 基础数据(用户 / 角色 / 菜单)
|
||||||
|
flask admin init
|
||||||
|
```
|
||||||
|
|
||||||
|
MySQL 用户需提前建库:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE DATABASE pear DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
CREATE USER 'pear'@'%' IDENTIFIED BY 'your_strong_password';
|
||||||
|
GRANT ALL PRIVILEGES ON pear.* TO 'pear'@'%';
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 启动
|
||||||
|
|
||||||
|
### 5.1 开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux
|
||||||
|
make run
|
||||||
|
# Windows
|
||||||
|
start.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 生产
|
||||||
|
|
||||||
|
推荐用 `gunicorn`(`pip install gunicorn`),或反向代理到 `waitress`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux
|
||||||
|
PEAR_ENV=production gunicorn -w 4 -b 0.0.0.0:5000 app:app
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
pip install waitress
|
||||||
|
PEAR_ENV=production waitress-serve --port=5000 app:app
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 健康检查
|
||||||
|
|
||||||
|
服务起来后:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:5000/healthz
|
||||||
|
# 200 OK → {"status":"ok","db_ok":true,...}
|
||||||
|
# 503 → {"status":"degraded","db_ok":false,...}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 反向代理(Nginx 示例)
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name pear.example.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:5000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
client_max_body_size 50m;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /healthz {
|
||||||
|
proxy_pass http://127.0.0.1:5000/healthz;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 升级流程
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
flask db migrate
|
||||||
|
flask db upgrade
|
||||||
|
# 重启进程
|
||||||
|
systemctl restart pear-admin-flask
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 常见问题
|
||||||
|
|
||||||
|
| 现象 | 处理 |
|
||||||
|
| --- | --- |
|
||||||
|
| `[ProConfig] 生产环境必须通过环境变量 SECRET_KEY 传入强随机密钥` | 在 `.env` 或系统环境变量中设置 `SECRET_KEY`。 |
|
||||||
|
| `SEC_E_WRONG_PRINCIPAL` 克隆失败 | `git -c http.sslVerify=false clone ...` 或导入根证书。 |
|
||||||
|
| 启动后 `/` 跳转到登录页 | 正常。后台默认账号 `admin` / 密码 `123456`,**请立即修改**。 |
|
||||||
|
| 数据库表缺失 | `flask db upgrade` 后 `flask admin init`。 |
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Minimal makefile for Sphinx documentation
|
||||||
|
#
|
||||||
|
|
||||||
|
# You can set these variables from the command line, and also
|
||||||
|
# from the environment for the first two.
|
||||||
|
SPHINXOPTS ?=
|
||||||
|
SPHINXBUILD ?= sphinx-build
|
||||||
|
SOURCEDIR = source
|
||||||
|
BUILDDIR = _build
|
||||||
|
|
||||||
|
# Put it first so that "make" without argument is like "make help".
|
||||||
|
help:
|
||||||
|
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||||
|
|
||||||
|
.PHONY: help Makefile
|
||||||
|
|
||||||
|
# Catch-all target: route all unknown targets to Sphinx using the new
|
||||||
|
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
||||||
|
%: Makefile
|
||||||
|
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Pear Admin Flask - 安全指南
|
||||||
|
|
||||||
|
## 1. 密钥(SECRET_KEY)
|
||||||
|
|
||||||
|
`SECRET_KEY` 用于 Flask `session` 签名、`csrf_token`、Flask-Session
|
||||||
|
等。**一旦泄露,攻击者可伪造任意管理员会话。**
|
||||||
|
|
||||||
|
要求:
|
||||||
|
- 长度 ≥ 32 字节(推荐 48+);
|
||||||
|
- 高熵随机(`secrets.token_urlsafe(48)` / `openssl rand -base64 48`);
|
||||||
|
- **禁止**使用默认值 `pear-system-flask`;
|
||||||
|
- **禁止**把 `.env` / 含密钥的部署脚本提交到 git。
|
||||||
|
|
||||||
|
开发模式自动生成的密钥仅供本地调试,**重启即失效**。
|
||||||
|
|
||||||
|
## 2. 凭据(Mail / DB)
|
||||||
|
|
||||||
|
所有凭据通过 `.env` 注入,代码里只放占位符。强烈建议:
|
||||||
|
- 生产数据库单独建用户,**只授必要的库权限**;
|
||||||
|
- Mail 使用**授权码**而非登录密码(QQ / 163 / Gmail 均提供);
|
||||||
|
- 定期轮换凭据,并在变更后重启服务。
|
||||||
|
|
||||||
|
## 3. 默认管理员账号
|
||||||
|
|
||||||
|
`flask admin init` 会写入默认账号 `admin / 123456`,**首次登录后必须立即修改密码**。
|
||||||
|
|
||||||
|
可执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) 登录后台 → 用户管理 → 修改 admin 密码;
|
||||||
|
# 2) 或直接 SQL:
|
||||||
|
sqlite3 pear.db "UPDATE rbac_user SET password='<pbkdf2:sha256:...>' WHERE username='admin';"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Cookie / Session
|
||||||
|
|
||||||
|
`SESSION_COOKIE_HTTPONLY = True`(默认)与 `SESSION_COOKIE_SAMESITE = 'Lax'`
|
||||||
|
已经在配置中体现。生产部署在 HTTPS 下请额外设置:
|
||||||
|
|
||||||
|
```python
|
||||||
|
SESSION_COOKIE_SECURE = True
|
||||||
|
```
|
||||||
|
|
||||||
|
> 若启用 Flask-Session(filesystem / redis),请把 `flask_session/`
|
||||||
|
> 目录排除在 Web 静态目录之外,并定期清理。
|
||||||
|
|
||||||
|
## 5. CSRF
|
||||||
|
|
||||||
|
本项目基于 `flask_wtf`,所有写操作均有 CSRF 校验。
|
||||||
|
前端表单务必带上 `{{ csrf_token() }}` 或请求头 `X-CSRFToken`。
|
||||||
|
|
||||||
|
## 6. 上传文件
|
||||||
|
|
||||||
|
`UPLOADED_PHOTOS_DEST = static/upload`,后缀白名单已限制为图片。
|
||||||
|
但**用户可上传 SVG / 伪装为图片的 JS** 等——建议:
|
||||||
|
|
||||||
|
- 上传目录关闭脚本执行(Nginx: `location ^~ /static/upload/ { ... }`);
|
||||||
|
- 后端校验真实文件类型(`python-magic`),而非仅看后缀。
|
||||||
|
|
||||||
|
## 7. 限流
|
||||||
|
|
||||||
|
`Flask-Limiter` 已挂载(`applications/extensions/init_limit.py`)。
|
||||||
|
生产模式请按业务需求调整默认速率,避免误伤。
|
||||||
|
|
||||||
|
## 8. 日志
|
||||||
|
|
||||||
|
`LOG_LEVEL` 控制根 logger 输出等级。生产推荐 `WARN` / `ERROR`。
|
||||||
|
**不要把 `SECRET_KEY`、用户密码、token 写入日志。**
|
||||||
|
|
||||||
|
## 9. 依赖漏洞
|
||||||
|
|
||||||
|
建议在 CI 中加入 `pip-audit`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install pip-audit
|
||||||
|
pip-audit -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. 上线前 Checklist
|
||||||
|
|
||||||
|
- [ ] `PEAR_ENV=production`
|
||||||
|
- [ ] `SECRET_KEY` 已替换为强随机值
|
||||||
|
- [ ] 数据库用户仅授必要权限
|
||||||
|
- [ ] 默认 admin 密码已修改
|
||||||
|
- [ ] HTTPS 已上线(`SESSION_COOKIE_SECURE` 同步开启)
|
||||||
|
- [ ] `flask_session/` 目录已加入备份策略
|
||||||
|
- [ ] `static/upload/` 已禁用脚本执行
|
||||||
|
- [ ] `pip-audit` 跑过
|
||||||
|
- [ ] `/healthz` 已接入负载均衡 / 容器探活
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
# Pear Admin Flask - 个人 NAS 部署指南
|
||||||
|
|
||||||
|
适用:内网 / 家庭 / 小团队(≤ 50 并发用户,无 K8s 需求)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 前置检查
|
||||||
|
|
||||||
|
NAS 上需安装:
|
||||||
|
|
||||||
|
| 软件 | 最低版本 | 验证命令 |
|
||||||
|
|---|---|---|
|
||||||
|
| Docker Engine | 20.10+ | `docker --version` |
|
||||||
|
| Docker Compose | v2.x | `docker compose version` |
|
||||||
|
|
||||||
|
> 如果 NAS 是群晖 / 威联通,请在套件中心安装 **Container Manager** 或自行 SSH 安装 docker。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 准备 .env
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /volume1/docker/pear-admin-flask
|
||||||
|
cp .env.example .env
|
||||||
|
# 编辑 .env:SECRET_KEY 必须改成强随机字符串
|
||||||
|
python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||||
|
# 把输出贴到 .env 的 SECRET_KEY=
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env` **绝不能提交到 git**(已在 `.gitignore` 中)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 首次构建 + 启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 进入项目目录
|
||||||
|
cd /volume1/docker/pear-admin-flask # 改成你自己的路径
|
||||||
|
|
||||||
|
# 如果你之前用过 pear-admin-flask,要把现有库搬过来:
|
||||||
|
mkdir -p data
|
||||||
|
cp pear.db data/pear.db # 把根目录的库搬到 data/ 下(容器会把整个 ./data 挂进 /app/data)
|
||||||
|
|
||||||
|
# 构建镜像(首次约 3-5 分钟)
|
||||||
|
docker compose -f deploy/nas/docker-compose.yaml build
|
||||||
|
|
||||||
|
# 后台启动
|
||||||
|
docker compose -f deploy/nas/docker-compose.yaml up -d
|
||||||
|
|
||||||
|
# 查看启动日志(应看到 "Listening at: http://0.0.0.0:5000")
|
||||||
|
docker compose -f deploy/nas/docker-compose.yaml logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
启动成功后访问:`http://NAS_IP:5000`
|
||||||
|
|
||||||
|
默认账号 `admin` / `123456`(首次登录后**强烈建议改密码**)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 健康检查
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 进程是否还活着(DB OK 返回 200;DB 不可达返回 503)
|
||||||
|
curl -i http://NAS_IP:5000/healthz
|
||||||
|
# 200 OK: {"status":"ok","db_ok":true,"env":"production","system":"Pear Admin","timestamp":"..."}
|
||||||
|
|
||||||
|
# 容器视角的健康状态(Docker 30s 探一次)
|
||||||
|
docker inspect --format='{{.State.Health.Status}}' pear-admin-nas
|
||||||
|
# healthy / unhealthy / starting
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 端口与局域网访问
|
||||||
|
|
||||||
|
`docker-compose.yaml` 中默认 `5000:5000`,局域网内任意设备 `http://NAS_IP:5000` 都能访问。
|
||||||
|
|
||||||
|
如要改端口(如 NAS 上 5000 被占用):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ports:
|
||||||
|
- "8080:5000" # 宿主 8080 → 容器 5000
|
||||||
|
```
|
||||||
|
|
||||||
|
手机访问:`http://NAS_IP:8080`(确保 NAS 防火墙放行)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 数据持久化
|
||||||
|
|
||||||
|
整个数据库 / 会话 / 备份 / 上传,**统一收进宿主 `./data` 目录**,容器内路径 `/app/data`。
|
||||||
|
|
||||||
|
```
|
||||||
|
./data # docker-compose 唯一绑定挂载
|
||||||
|
├── pear.db # SQLite 主库(WAL 模式)
|
||||||
|
├── pear.db-wal # WAL 文件(不要删)
|
||||||
|
├── pear.db-shm # 共享内存文件(不要删)
|
||||||
|
├── backup/ # 在线热备(自动保留最近 14 天)
|
||||||
|
│ └── pear-20260906_020000.db.gz
|
||||||
|
├── flask_session/ # Flask-Session 文件
|
||||||
|
├── logs/ # gunicorn / Flask 日志
|
||||||
|
│ ├── access.log
|
||||||
|
│ └── error.log
|
||||||
|
└── upload/ # 上传图片
|
||||||
|
```
|
||||||
|
|
||||||
|
在 NAS File Station 里直接看得到、可以直接 tar 走 —— 这是从"看不见的虚拟卷"改成"看得见的文件夹"的核心收益。
|
||||||
|
|
||||||
|
### 备份(在线热备)
|
||||||
|
|
||||||
|
容器镜像里带了 `sqlite3`,`deploy/nas/backup.sh` 走 `sqlite3 .backup`(含 WAL 一致性,比 `cp` 安全):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec pear-admin-nas /app/deploy/nas/backup.sh
|
||||||
|
# 输出:[backup] done. latest = /app/data/backup/pear-20260906_020000.db.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
### 自动备份(推荐,群晖 / 威联通 / Linux 都适用)
|
||||||
|
|
||||||
|
群晖 DSM:「控制面板 → 任务计划表 → 新增 → 计划的任务 → 用户定义的脚本」:
|
||||||
|
|
||||||
|
```
|
||||||
|
每天 02:00 跑:
|
||||||
|
docker exec pear-admin-nas /app/deploy/nas/backup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
或者宿主 crontab:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
0 2 * * * docker exec pear-admin-nas /app/deploy/nas/backup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
默认保留 14 天(`BACKUP_KEEP=14` 可改)。
|
||||||
|
|
||||||
|
### 恢复
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 停容器
|
||||||
|
docker compose -f deploy/nas/docker-compose.yaml down
|
||||||
|
|
||||||
|
# 把备份解开(注意:必须先停容器才能覆盖 pear.db,否则 SQLite 还在持有文件锁)
|
||||||
|
gunzip -c data/backup/pear-20260906_020000.db.gz > data/pear.db
|
||||||
|
# 顺带把 -wal / -shm 也清掉(WAL 会自动重建)
|
||||||
|
rm -f data/pear.db-wal data/pear.db-shm
|
||||||
|
|
||||||
|
# 起回去
|
||||||
|
docker compose -f deploy/nas/docker-compose.yaml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
整库迁移到新机器:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 旧机器:tar 整个 data 目录(不算大,库 100MB 级别)
|
||||||
|
cd /volume1/docker/pear-admin-flask
|
||||||
|
tar czf pear-data-$(date +%F).tgz data/
|
||||||
|
|
||||||
|
# 新机器:解开 → 启动即用
|
||||||
|
tar xzf pear-data-2026-09-06.tgz -C /volume1/docker/pear-admin-flask/
|
||||||
|
docker compose -f deploy/nas/docker-compose.yaml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 升级流程
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 拉取最新代码
|
||||||
|
cd /volume1/docker/pear-admin-flask
|
||||||
|
git pull
|
||||||
|
|
||||||
|
# 2. 重新构建镜像
|
||||||
|
docker compose -f deploy/nas/docker-compose.yaml build
|
||||||
|
|
||||||
|
# 3. 重启容器(migrations 自动跑)
|
||||||
|
docker compose -f deploy/nas/docker-compose.yaml up -d
|
||||||
|
|
||||||
|
# 4. 验证
|
||||||
|
curl -i http://NAS_IP:5000/healthz
|
||||||
|
```
|
||||||
|
|
||||||
|
数据库迁移由 `start.sh` 中的 `flask db upgrade` 自动执行(幂等;含 `site_nav_click` 等插件表的迁移)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 常见问题
|
||||||
|
|
||||||
|
### Q1:容器启动后立刻退出
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f deploy/nas/docker-compose.yaml logs --tail=50
|
||||||
|
```
|
||||||
|
|
||||||
|
最常见原因:
|
||||||
|
- `SECRET_KEY` 还是占位符 `PLEASE_REPLACE_WITH_RANDOM_STRING` → ProConfig 会拒绝启动
|
||||||
|
- 端口被占用 → 修改 `ports` 段
|
||||||
|
- `data/` 目录存在但权限不对(容器内 uid 1001 没写权限)→ `chown -R 1001:1001 data/`
|
||||||
|
|
||||||
|
### Q2:访问首页 502 / 拒绝连接
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 确认容器在跑
|
||||||
|
docker ps | grep pear-admin-nas
|
||||||
|
|
||||||
|
# 看健康状态
|
||||||
|
docker inspect --format='{{.State.Health.Status}}' pear-admin-nas
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q3:忘记 admin 密码
|
||||||
|
|
||||||
|
直接重置(删除整个 `data/pear.db`,再启动让 `flask admin init` 重新生成默认账号):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f deploy/nas/docker-compose.yaml down
|
||||||
|
rm -f data/pear.db data/pear.db-wal data/pear.db-shm
|
||||||
|
docker compose -f deploy/nas/docker-compose.yaml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **此操作会清空所有数据**,务必先 `docker exec … backup.sh` 一份。
|
||||||
|
|
||||||
|
### Q4:日志占满磁盘
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 看 data/logs 实际大小
|
||||||
|
du -sh data/logs
|
||||||
|
# 清空(保留文件)
|
||||||
|
: > data/logs/access.log
|
||||||
|
: > data/logs/error.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q5:想要外网访问
|
||||||
|
|
||||||
|
需要做两件事:
|
||||||
|
1. NAS 上做端口映射(5000 → 公网 IP) + DDNS
|
||||||
|
2. **必须**在前面套一层 Nginx/Caddy 加 HTTPS(避免密码明文)
|
||||||
|
|
||||||
|
可参考 `deploy/nas/README.md`(如果后续加入 nginx 反代方案)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 性能调优
|
||||||
|
|
||||||
|
NAS 通常内存有限(4-8 GB)。如遇卡顿:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yaml 中调小资源限制
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 768M # 从 1024M 降到 768M
|
||||||
|
```
|
||||||
|
|
||||||
|
或减少 worker:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# .env 里
|
||||||
|
GUNICORN_WORKERS=1
|
||||||
|
GUNICORN_THREADS=16 # SQLite + WAL 单进程跑,线程数给够
|
||||||
|
```
|
||||||
|
|
||||||
|
**为什么默认 workers=1 threads=8 而不是 2×4**:SQLite 写串行;多进程提交事务时仍可能撞锁,gunicorn worker 越多事故面越大。单进程写最稳,线程吃满网络并发。你的场景下 8 线程绰绰有余,要更高把线程拉到 16 就行。
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
del _build /s /q
|
||||||
|
sphinx-build .\source .\_build
|
||||||
@@ -0,0 +1,524 @@
|
|||||||
|
# Pear Admin Flask 插件开发操作手册
|
||||||
|
|
||||||
|
> 适用版本:Pear Admin Flask master 分支(fork 自 lab.lovepikachu.top 文档站点的最新文档)
|
||||||
|
>
|
||||||
|
> 文档维护:根据 plugins/<name>/ 下的实际插件实现同步更新
|
||||||
|
|
||||||
|
Pear Admin Flask 的插件机制是其核心竞争力——**不修改框架代码即可扩展新页面、新菜单、新数据埋点**。
|
||||||
|
|
||||||
|
本文以仓库中已落地的 `navManager` / `friendManager` / `aboutManager` / `siteStats` 四个插件为依据,整理出从设计到上线的完整操作步骤。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 插件是什么
|
||||||
|
|
||||||
|
| 概念 | framework(框架) | plugin(插件) |
|
||||||
|
|---|---|---|
|
||||||
|
| 代码位置 | `applications/`、`templates/system/` 之外的共用代码 | `plugins/<pluginName>/` 下,与 framework 解耦 |
|
||||||
|
| 启用方式 | 自动加载 | 在 `applications/config.py` 的 `PLUGIN_ENABLE_FOLDERS` 列表中显式声明 |
|
||||||
|
| 修改影响 | 改 framework 会影响所有升级 | 改 plugin 不影响 framework 与其他 plugin |
|
||||||
|
| 典型用途 | 用户/角色/权限/部门等所有项目共用的基础模块 | 站点特有业务(导航、友链、关于、统计等) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 目录结构最小模型
|
||||||
|
|
||||||
|
一个最简插件的目录是这样:
|
||||||
|
|
||||||
|
```
|
||||||
|
plugins/
|
||||||
|
└── helloworld/ # 插件文件夹名(与 PLUGIN_ENABLE_FOLDERS 中写的保持一致)
|
||||||
|
├── __init__.json # 【可选】插件元信息(名称/版本/描述),删掉也能加载
|
||||||
|
├── __init__.py # 必须。定义 event_init / event_finish 等事件函数
|
||||||
|
└── view/ # 建议。存放 view.py(Blueprint 定义和路由)
|
||||||
|
└── main.py # 自定义视图
|
||||||
|
|
||||||
|
# 复杂插件(仿 navManager/friendManager/aboutManager/siteStats):
|
||||||
|
plugins/
|
||||||
|
└── navManager/
|
||||||
|
├── __init__.json
|
||||||
|
├── __init__.py # 入口
|
||||||
|
├── view/
|
||||||
|
│ └── nav.py # Blueprint 'nav',url_prefix='/system/nav'
|
||||||
|
└── templates/
|
||||||
|
└── system/
|
||||||
|
└── nav/
|
||||||
|
├── main.html
|
||||||
|
├── add.html
|
||||||
|
└── edit.html
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 启用插件
|
||||||
|
|
||||||
|
打开 `applications/config.py`,找到 `BaseConfig` 类尾部的配置项:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ---- 插件 ----
|
||||||
|
# 站点业务模块(导航 / 友链 / 关于 / 访问统计)均通过 plugins 方式开发,启用即可挂载路由
|
||||||
|
PLUGIN_ENABLE_FOLDERS = ["navManager", "friendManager", "aboutManager", "siteStats"]
|
||||||
|
```
|
||||||
|
|
||||||
|
> 顺序就是加载顺序,越靠前越早被 import;按需新增或删除。
|
||||||
|
|
||||||
|
修改完成后,**重启 Flask** 即可生效,启动日志会列出:
|
||||||
|
|
||||||
|
```
|
||||||
|
* Plugin: Loaded plugin: 导航管理 .
|
||||||
|
* Plugin: Loaded plugin: 友情链接 .
|
||||||
|
* Plugin: Loaded plugin: 关于本站 .
|
||||||
|
* Plugin: Loaded plugin: 访问统计 .
|
||||||
|
* navManager: registered /system/nav/* blueprint
|
||||||
|
* friendManager: registered /system/friend/* blueprint
|
||||||
|
* aboutManager: registered /system/about/* blueprint
|
||||||
|
* siteStats: registered /system/stats/* blueprint
|
||||||
|
* siteStats: registered /site/* before_request hook
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 插件生命周期(4 个事件)
|
||||||
|
|
||||||
|
插件入口 `__init__.py` 中可以暴露 4 个事件函数(任意函数缺省将自动跳过):
|
||||||
|
|
||||||
|
| 函数 | 时机 | 典型用途 |
|
||||||
|
|---|---|---|
|
||||||
|
| `event_begin(app)` | 框架功能注册之前(数据库连上之前) | 极少数兼容场景;多数插件不需要 |
|
||||||
|
| `event_init(app)` | 所有 framework 蓝图注册之后 | `app.register_blueprint(bp)` |
|
||||||
|
| `event_finish(app)` | 数据库/CLI/脚本初始化完毕之后 | 注册 `@app.before_request` 钩子、`@app.cli.command` |
|
||||||
|
| `event_context(app)` | 第一个请求到来之前(自动 `app.app_context()`) | 预热缓存、检查/修正数据库初始数据 |
|
||||||
|
|
||||||
|
调用顺序:`event_begin` → ... framework 注册 ... → `event_init` → ... 启动余下逻辑 ... → `event_finish` → (自动)`event_context`。
|
||||||
|
|
||||||
|
**注意**:如果同时定义 `event_init` 和 `event_finish`,前者注册蓝图、后者挂 before_request / CLI,**不能**用一次事件全做——否则会与 framework 自身的广播节奏冲突。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 最小可运行示例:Hello World(来自仓库 plugins/helloworld/)
|
||||||
|
|
||||||
|
### 5.1 `plugins/helloworld/__init__.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
from flask import Flask
|
||||||
|
from .main import helloworld_blueprint
|
||||||
|
|
||||||
|
from applications.models import Dept
|
||||||
|
from applications.common import curd
|
||||||
|
from applications.schemas import DeptSchema
|
||||||
|
|
||||||
|
dir_path = os.path.dirname(__file__).replace("\\", "/")
|
||||||
|
|
||||||
|
|
||||||
|
def event_begin(app: Flask):
|
||||||
|
print("所有功能初始化之前加载")
|
||||||
|
|
||||||
|
|
||||||
|
def event_init(app: Flask):
|
||||||
|
"""初始化完成时会调用这里"""
|
||||||
|
print("初始插件初始化视图")
|
||||||
|
app.register_blueprint(helloworld_blueprint)
|
||||||
|
|
||||||
|
|
||||||
|
def event_finish(app: Flask):
|
||||||
|
print("所有初始化完毕")
|
||||||
|
|
||||||
|
|
||||||
|
def event_context(app: Flask):
|
||||||
|
"""Flask 初始化完成,等待第一个请求之前,等同于 with app.app_context():"""
|
||||||
|
dept = Dept.query.order_by(Dept.sort).all()
|
||||||
|
print(curd.model_to_dicts(schema=DeptSchema, data=dept))
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 `plugins/helloworld/main.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from flask import render_template, Blueprint
|
||||||
|
|
||||||
|
helloworld_blueprint = Blueprint('hello_world', __name__,
|
||||||
|
template_folder='templates',
|
||||||
|
static_folder='static',
|
||||||
|
url_prefix='/hello_world')
|
||||||
|
|
||||||
|
|
||||||
|
@helloworld_blueprint.route('/')
|
||||||
|
def index():
|
||||||
|
return render_template('helloworld_index.html')
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 启用
|
||||||
|
|
||||||
|
```python
|
||||||
|
PLUGIN_ENABLE_FOLDERS = ["helloworld"]
|
||||||
|
```
|
||||||
|
|
||||||
|
启动后访问 `http://127.0.0.1:5000/hello_world/`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 含数据库的完整插件(实操示例:navManager)
|
||||||
|
|
||||||
|
这一节以仓库实际存在的 `navManager` 为模板,演示创建"导航表 + 后台 CRUD + 模板 + 权限码绑定 + 菜单种子"完整业务插件的全流程。
|
||||||
|
|
||||||
|
### 6.1 设计
|
||||||
|
|
||||||
|
- 数据模型:单表 `site_nav`(继承自 `applications.models.admin_nav.Nav`)
|
||||||
|
- 路由:`/system/nav/`、`/system/nav/data`、`/system/nav/add`、`/system/nav/save`、`/system/nav/edit`、`/system/nav/update`、`/system/nav/remove/<int:nav_id>`、`/system/nav/enable`、`/system/nav/disable`
|
||||||
|
- 权限码:`system:nav:main / system:nav:add / system:nav:edit / system:nav:remove`
|
||||||
|
|
||||||
|
### 6.2 创建插件骨架
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p plugins/navManager/{view,templates/system/nav}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 写 `plugins/navManager/__init__.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"plugin_name": "导航管理",
|
||||||
|
"plugin_version": "1.0.0",
|
||||||
|
"plugin_description": "后台导航管理模块。对 nav 表的增删改查、分页查询、关键字 + 分类过滤、启用/禁用切换。"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 写 `plugins/navManager/__init__.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from flask import Flask
|
||||||
|
from .view.nav import bp
|
||||||
|
|
||||||
|
|
||||||
|
def event_init(app: Flask):
|
||||||
|
"""初始化完成时注册蓝图到 app。"""
|
||||||
|
app.register_blueprint(bp)
|
||||||
|
print(" * navManager: registered /system/nav/* blueprint")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5 写 `plugins/navManager/view/nav.py`
|
||||||
|
|
||||||
|
最关键的一步——要把 Blueprint 的 `url_prefix` 拼成 `/system/nav` 并指定独立 `template_folder`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
from flask import Blueprint, render_template, request
|
||||||
|
from flask_login import current_user
|
||||||
|
|
||||||
|
from applications.common import curd
|
||||||
|
from applications.common.utils.http import table_api, fail_api, success_api
|
||||||
|
from applications.common.utils.rights import authorize
|
||||||
|
from applications.common.utils.validate import str_escape
|
||||||
|
from applications.extensions import db
|
||||||
|
from applications.extensions.init_limit import limiter
|
||||||
|
from applications.models import Nav
|
||||||
|
from applications.schemas import NavManageSchema
|
||||||
|
|
||||||
|
dir_path = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
bp = Blueprint(
|
||||||
|
'nav', __name__,
|
||||||
|
url_prefix='/system/nav',
|
||||||
|
template_folder=os.path.join(dir_path, '..', 'templates'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get('/')
|
||||||
|
@authorize("system:nav:main")
|
||||||
|
def main():
|
||||||
|
return render_template('system/nav/main.html')
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get('/data')
|
||||||
|
@limiter.limit("60 per minute")
|
||||||
|
@authorize("system:nav:main")
|
||||||
|
def data():
|
||||||
|
keyword = str_escape(request.args.get('keyword', type=str))
|
||||||
|
category = str_escape(request.args.get('category', type=str))
|
||||||
|
query = Nav.query.filter()
|
||||||
|
if keyword:
|
||||||
|
query = query.filter(
|
||||||
|
db.or_(
|
||||||
|
Nav.title.contains(keyword),
|
||||||
|
Nav.url.contains(keyword),
|
||||||
|
Nav.description.contains(keyword),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if category:
|
||||||
|
query = query.filter(Nav.category == category)
|
||||||
|
items = query.order_by(Nav.category.asc(), Nav.sort.asc(), Nav.id.asc()).layui_paginate()
|
||||||
|
return table_api(
|
||||||
|
msg='请求成功',
|
||||||
|
data=curd.model_to_dicts(schema=NavManageSchema, data=items.items),
|
||||||
|
count=items.total,
|
||||||
|
)
|
||||||
|
# ... 其余 CRUD 路由基本按 user.py 风格展开
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ 与 framework 视图的关键区别**:
|
||||||
|
- `Blueprint` 的 `url_prefix` 直接是 `/system/nav`,**不再**注册到 framework 的 `system_bp`,避免对 framework 文件的依赖
|
||||||
|
- `template_folder` 显式指向 plugin 自带的 `templates/`,与根 `templates/` 解耦
|
||||||
|
|
||||||
|
### 6.6 数据库 Schema
|
||||||
|
|
||||||
|
模型**仍然需要放在 framework 的 `applications/models/admin_nav.py`**,因为 Flask 在 `init_databases(app)` 时只 import 这一目录,而且 Alembic 也只看这里做迁移:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# applications/models/admin_nav.py
|
||||||
|
class Nav(db.Model):
|
||||||
|
__tablename__ = 'site_nav'
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
> 这点是 Pear Admin Flask 的设计局限:模型层不能放到 plugin。Alembic migration 也需要落到 `migrations/versions/` 下。
|
||||||
|
|
||||||
|
### 6.7 模板文件
|
||||||
|
|
||||||
|
把模板放到 `plugins/navManager/templates/system/nav/main.html`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>导航管理</title>
|
||||||
|
{% include 'system/common/header.html' %}
|
||||||
|
</head>
|
||||||
|
<body class="pear-container">
|
||||||
|
<!-- 查询表单 + 表格 -->
|
||||||
|
</body>
|
||||||
|
</body>
|
||||||
|
{% include 'system/common/footer.html' %}
|
||||||
|
{% raw %}
|
||||||
|
<script type="text/html" id="col-status">...</script>
|
||||||
|
{% endraw %}
|
||||||
|
<script>
|
||||||
|
layui.use(['table', 'form', 'layer'], function () {
|
||||||
|
var table = layui.table;
|
||||||
|
table.render({
|
||||||
|
elem: '#nav-table',
|
||||||
|
url: '/system/nav/data',
|
||||||
|
cols: [[...]],
|
||||||
|
page: true, limit: 20,
|
||||||
|
skin: 'line',
|
||||||
|
text: {none: '暂无导航数据'},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意 footer include 必须放在所有业务 `<script>` 之前**(这是 Pear Admin 2.x 同步加载的特性,错误顺序会 `layui is not defined`)。
|
||||||
|
|
||||||
|
### 6.8 菜单种子(Power 表)
|
||||||
|
|
||||||
|
`site_nav` 表的菜单项需要 seed 到 RBAC 的 `Power` 表:
|
||||||
|
|
||||||
|
```python
|
||||||
|
Power(
|
||||||
|
id=60, name='导航管理', type='1',
|
||||||
|
code='system:nav:main',
|
||||||
|
url='/system/nav/', open_type='_iframe',
|
||||||
|
parent_id=64, icon='layui-icon layui-icon-link',
|
||||||
|
enable=1, sort=1,
|
||||||
|
),
|
||||||
|
Power(
|
||||||
|
name='导航新增', type='2', code='system:nav:add', ...
|
||||||
|
),
|
||||||
|
# 编辑 / 删除 同理
|
||||||
|
```
|
||||||
|
|
||||||
|
子权限的 `parent_id` 必须指向主菜单的 id;Python seed 时可先 commit 主菜单拿到 id。
|
||||||
|
|
||||||
|
### 6.9 启用
|
||||||
|
|
||||||
|
```python
|
||||||
|
PLUGIN_ENABLE_FOLDERS = ["navManager"]
|
||||||
|
```
|
||||||
|
|
||||||
|
启动 Flask 后访问 `http://127.0.0.1:5000/system/nav/`(仍需要登录),可见后台菜单「站点管理 / 导航管理」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 含 before_request 钩子的插件(实操示例:siteStats)
|
||||||
|
|
||||||
|
类似访问统计这种需要监听全局请求的插件,用 `event_finish` 钩子:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# plugins/siteStats/__init__.py
|
||||||
|
from flask import Flask
|
||||||
|
from .view.stat import bp, record_visit
|
||||||
|
|
||||||
|
|
||||||
|
def event_init(app: Flask):
|
||||||
|
app.register_blueprint(bp)
|
||||||
|
|
||||||
|
|
||||||
|
def event_finish(app: Flask):
|
||||||
|
@app.before_request
|
||||||
|
def _stat_record():
|
||||||
|
record_visit()
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# plugins/siteStats/view/stat.py
|
||||||
|
def record_visit():
|
||||||
|
"""埋点 /site/* GET 请求。"""
|
||||||
|
if not request.path.startswith('/site'):
|
||||||
|
return
|
||||||
|
if request.method != 'GET':
|
||||||
|
return
|
||||||
|
# ... 累计 PV/UV 到 PageStat
|
||||||
|
```
|
||||||
|
|
||||||
|
**优势**:未来增加新的"统计规则"或"埋点路径"只需要改 plugin 文件,不动 framework 的 `app.py`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 含自定义 CLI 子命令的插件(实操示例:giftManager)
|
||||||
|
|
||||||
|
若插件需要自定义 `flask <your-cmd>` 命令:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# plugins/giftManager/cli/__init__.py
|
||||||
|
from flask.cli import AppGroup
|
||||||
|
|
||||||
|
gift_cli = AppGroup('gift')
|
||||||
|
|
||||||
|
|
||||||
|
@gift_cli.command('init')
|
||||||
|
def init_db():
|
||||||
|
"""初始化兑换码管理插件的菜单和种子数据"""
|
||||||
|
# seed Power / seed Gift data
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# plugins/giftManager/__init__.py
|
||||||
|
from flask import Flask
|
||||||
|
from .cli import gift_cli
|
||||||
|
from .view.gift import bp
|
||||||
|
|
||||||
|
|
||||||
|
def event_init(app: Flask):
|
||||||
|
app.register_blueprint(bp)
|
||||||
|
|
||||||
|
|
||||||
|
def event_finish(app: Flask):
|
||||||
|
app.cli.add_command(gift_cli)
|
||||||
|
```
|
||||||
|
|
||||||
|
执行 `flask gift init` 即可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 插件开发检查清单
|
||||||
|
|
||||||
|
下面是一份自检表,开发完成后逐项确认:
|
||||||
|
|
||||||
|
- [ ] `plugins/<name>/__init__.json` 写好
|
||||||
|
- [ ] `plugins/<name>/__init__.py` 定义 `event_init`(注册蓝图)或/和 `event_finish`(before_request / CLI)
|
||||||
|
- [ ] 视图文件 `view/<name>.py` 中 Blueprint 的 `name` 全项目唯一;`url_prefix` 在 `/system/<xxx>` 或自定其它前缀
|
||||||
|
- [ ] 蓝图 `template_folder` 显式指向 plugin 内 templates,避免与 framework 模板同名冲突
|
||||||
|
- [ ] 模型文件保留在 `applications/models/admin_<name>.py`(Pear Admin 限制)
|
||||||
|
- [ ] Schema 保留在 `applications/schemas/admin_<name>.py`
|
||||||
|
- [ ] 权限码 seed 已写入 Power 表(type=1 主菜单 + type=2 子权限)
|
||||||
|
- [ ] 管理员 role 的 power 列表中已挂载本插件相关权限码
|
||||||
|
- [ ] `applications/config.py` 中 `PLUGIN_ENABLE_FOLDERS` 加上插件文件夹名
|
||||||
|
- [ ] framework 中**没有**与该插件同名的 Blueprint 注册(避免 endpoint 冲突)
|
||||||
|
- [ ] 模板放在 `{% include 'system/common/footer.html' %}` **之前** + `<script>layui.use(...)` **之后** 仍然运行正常
|
||||||
|
- [ ] 用 Playwright/Selenium 验证一次页面渲染(控制台无 `layui is not defined`)
|
||||||
|
- [ ] alembic migration 已生成(如果模型新增列):`flask db migrate -m "..."` + `flask db upgrade`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 常见问题(FAQ)
|
||||||
|
|
||||||
|
### Q1:出现 `ValueError: The name 'xxx' is already registered for this blueprint`
|
||||||
|
答:framework 里有个同名 Blueprint 已注册 + 当前 plugin 又注册了同名 Blueprint。检查:
|
||||||
|
1. `applications/view/system/__init__.py` 的 `register_system_bps` 是否仍包含迁移前的 bp 注册
|
||||||
|
2. `extensions/__init__.py:33-34` 早期版本同时调用了 `event_init` / `event_finish`,后又由 `init_bps` / `init_script` 重复触发——必须删除其中一处(建议只在 `view/__init__.py` 触发 `event_init`,只在 `script/__init__.py` 触发 `event_finish`)。
|
||||||
|
|
||||||
|
### Q2:`event_finish` 调用了两次,before_request 钩子被注册两次
|
||||||
|
答:同 Q1,问题来源 + 修复同。
|
||||||
|
|
||||||
|
### Q3:菜单在前台后台不显示 / 点菜单 403
|
||||||
|
答:检查 `applications/models/admin_power.py` 的 Power 表里菜单的 `code`、`parent_id` 是否正确;管理员 role 的 power 关联表里是否绑定了相关 code。
|
||||||
|
|
||||||
|
### Q4:模板渲染找不到文件 `TemplateNotFound`
|
||||||
|
答:检查 Blueprint 实例化时是否显式设置 `template_folder=os.path.join(dir_path, '..', 'templates')`,不要省略。
|
||||||
|
|
||||||
|
### Q5:插件导入顺序错误(a 插件 import 时触发 b 插件未加载)
|
||||||
|
答:把"a"放 `PLUGIN_ENABLE_FOLDERS` 更前面。Pear Admin 的 loader 通过 `importlib.import_module` 顺序装载。
|
||||||
|
|
||||||
|
### Q6:插件里如何获取当前请求
|
||||||
|
答:直接 `request` 对象或 `flask.session`;访问数据库用 `with app.app_context():` 或在 `event_context` 函数体内。
|
||||||
|
|
||||||
|
### Q7:插件改动后必须重启 Flask 吗?
|
||||||
|
答:是的。所有 `__init__.py`、`event_*` 函数、Blueprint 注册只发生在进程启动时。模板可用 `TEMPLATES_AUTO_RELOAD=True` 动态生效。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 插件模式 vs framework 内置
|
||||||
|
|
||||||
|
| 维度 | plugin 内置 | framework 内置 |
|
||||||
|
|---|---|---|
|
||||||
|
| 升级 pear-admin-framework 后是否受影响 | 无 | 有 |
|
||||||
|
| 删除即下线的便利性 | 注释 PLUGIN_ENABLE_FOLDERS 即可 | 改 framework 才能下 |
|
||||||
|
| 团队协作时移植到新项目 | 直接复制 plugins/ 整目录 | diff 框架代码 |
|
||||||
|
| 适合场景 | 大多数业务模块 | 不变的用户/角色/权限/部门等基础模块 |
|
||||||
|
|
||||||
|
**结论**:除非改动涉及 framework 自身的核心能力(如重写 `@authorize` 实现),否则一律走 plugin。这正是 Pear Admin Flask 推荐的最佳实践。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 当前仓库已实现的插件
|
||||||
|
|
||||||
|
| 插件 | 文件夹 | 用途 | 状态 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 导航管理 | `plugins/navManager/` | 后台 CRUD nav 表;前台 `/site/` 入口 + 类目聚合走 framework 的 `public_nav` | ✅ |
|
||||||
|
| 友情链接 | `plugins/friendManager/` | 后台 CRUD friend 表;前台 `/site/friend` | ✅ |
|
||||||
|
| 关于本站 | `plugins/aboutManager/` | 后台编辑表单 + 保存;前台 `/site/about` 渲染 | ✅ |
|
||||||
|
| 访问统计 | `plugins/siteStats/` | 后台 PV/UV 主页 + 3 个 JSON 接口 + 全站 `/site/*` 埋点 | ✅ |
|
||||||
|
| 兑换码示例 | `plugins/giftManager/` | 官方示例,需要 `flask gift init` 初始化 | ✅(未启用) |
|
||||||
|
| Hello World | `plugins/helloworld/` | 官方示例 | ✅(未启用) |
|
||||||
|
| Replace Page | `plugins/replacePage/` | framework 页面替换示例 | ✅(未启用) |
|
||||||
|
| Real IP | `plugins/realip/` | Flask 上下文修改示例 | ✅(未启用) |
|
||||||
|
|
||||||
|
启用列表见 `applications/config.py` 的 `PLUGIN_ENABLE_FOLDERS`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 首次启用 nav / friend / about / stat 时的手动操作
|
||||||
|
|
||||||
|
这些插件依赖数据库模型和权限码已经在 framework 中存在。**首次**部署到新环境:
|
||||||
|
|
||||||
|
1. `flask db migrate -m "add admin_nav / admin_friend / admin_about / admin_stat tables"`(如已存在表跳过)
|
||||||
|
2. `flask db upgrade`
|
||||||
|
3. 为 site_nav / site_about / site_friend 注册菜单权限(Power)记录:
|
||||||
|
```python
|
||||||
|
# 用 Flask shell 或在 admin.py 中 seed
|
||||||
|
Power(name='导航管理', type='1', code='system:nav:main', url='/system/nav/', open_type='_iframe', parent_id=1, ...)
|
||||||
|
# ... 类似地注册 site:friend:main / site:about:main / site:stats:main 与子权限
|
||||||
|
```
|
||||||
|
4. 把上述 `code` 加到 admin role 的 `power` 列表
|
||||||
|
5. 在 `PLUGIN_ENABLE_FOLDERS` 写好四项
|
||||||
|
6. 重启 Flask,用 admin / 123456 登录后台,可见新增菜单
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 进阶:把现有 framework 业务迁出到 plugin 的步骤
|
||||||
|
|
||||||
|
如果有一天希望把用户管理(`applications/view/system/user.py`)也从 framework 迁移到 plugin,按以下顺序:
|
||||||
|
|
||||||
|
1. 复制 `applications/view/system/user.py` 内容到 `plugins/userManager/view/user.py`
|
||||||
|
2. 修改 Blueprint:`url_prefix='/system/user'`、加 `template_folder=...`
|
||||||
|
3. 复制 `templates/system/user/` 到 `plugins/userManager/templates/system/user/`
|
||||||
|
4. 从 `applications/view/system/__init__.py` 删除 `user_bp` 的 import 和 `system_bp.register_blueprint(user_bp)` 一行
|
||||||
|
5. 删除 `applications/view/system/user.py` 与 `applications/templates/system/user/`(如果都已迁出)
|
||||||
|
6. `PLUGIN_ENABLE_FOLDERS = [..., "userManager"]`
|
||||||
|
7. 重启验证
|
||||||
|
|
||||||
|
> ⚠️ 注意保留模型和 Schema 不动。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
文档结束。
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user