feat(plugins): 4 个业务插件代码 + 前台公开页面改造
plugins/navManager:导航 CRUD + 导航分类 CRUD(独立 blueprint) plugins/friendManager:友情链接 CRUD plugins/aboutManager:关于本站 CRUD plugins/siteStats:访问统计主页 + JSON 接口 + /site/* GET 埋点 + nav 卡片点击埋点 applications/view/public/nav.py:导航聚合页 / 分类详情 / JSON 接口,支持 visit_count 注入 applications/view/public/about.py:关于本站公开页 applications/view/public/friend.py:友情链接公开页 templates/public/base.html:响应式首页(搜索框 / 分类抽屉 / 5 主题切换 / footer 隐私小字) templates/public/index.html / category.html:卡片右下角访问次数 templates/public/about.html / friend.html:关于 + 友链前台页 docs/plugins-development.md:插件开发完整指南(生命周期 + 4 个示例 + FAQ + framework 迁移) scripts/:探针与端到端验证脚本(probe_*/verify_*/test_*)
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
前台公开蓝图:导航聚合页 / 分类详情页
|
||||
前台公开蓝图:导航聚合页 / 分类详情页 / 关于本站
|
||||
|
||||
URL 前缀:/site(避免与后台 / 路由冲突)
|
||||
鉴权要求:无(任何用户都可访问,含未登录匿名用户)
|
||||
@@ -7,7 +7,15 @@ 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
|
||||
bp = nav_bp
|
||||
|
||||
|
||||
def register_public_bp(app):
|
||||
"""注册 public 子蓝图"""
|
||||
app.register_blueprint(about_bp)
|
||||
app.register_blueprint(friend_bp)
|
||||
@@ -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='旺珂 · 导航',
|
||||
)
|
||||
@@ -14,7 +14,8 @@ from collections import OrderedDict
|
||||
|
||||
from flask import Blueprint, render_template, request, jsonify
|
||||
|
||||
from applications.models import Nav
|
||||
from applications.models import Nav, NavCategory
|
||||
from plugins.siteStats.view.stat import get_nav_visit_counts
|
||||
|
||||
bp = Blueprint('public_nav', __name__, url_prefix='/site')
|
||||
|
||||
@@ -24,14 +25,7 @@ def index():
|
||||
"""
|
||||
公开首页:按 category 分组聚合,渲染模板
|
||||
"""
|
||||
groups = _grouped_navs()
|
||||
total = sum(len(items) for items in groups.values())
|
||||
return render_template(
|
||||
'public/index.html',
|
||||
groups=groups,
|
||||
total=total,
|
||||
site_name='BWStudio · 导航',
|
||||
)
|
||||
return _render_index()
|
||||
|
||||
|
||||
@bp.get('/category/<string:name>')
|
||||
@@ -41,15 +35,21 @@ def category_detail(name: str):
|
||||
"""
|
||||
items = (
|
||||
Nav.query
|
||||
.filter(Nav.category == name, Nav.status == 1)
|
||||
.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=items,
|
||||
site_name='BWStudio · 导航',
|
||||
items=dict_items,
|
||||
site_name='旺珂 · 导航',
|
||||
)
|
||||
|
||||
|
||||
@@ -66,15 +66,65 @@ def api_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())
|
||||
return render_template(
|
||||
'public/index.html',
|
||||
groups=groups,
|
||||
total=total,
|
||||
site_name='旺珂 · 导航',
|
||||
cat_meta=_category_meta(),
|
||||
)
|
||||
|
||||
|
||||
def _grouped_navs() -> "OrderedDict[str, list]":
|
||||
"""按 category 分组,组内按 sort asc 排序"""
|
||||
"""按 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.status == 1)
|
||||
.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:
|
||||
groups.setdefault(item.category, []).append(item.to_dict())
|
||||
return groups
|
||||
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,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 不动。
|
||||
|
||||
---
|
||||
|
||||
文档结束。
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"plugin_name": "关于本站",
|
||||
"plugin_version": "1.0.0",
|
||||
"plugin_description": "关于本站后台编辑页与保存接口。表 site_about 单条记录。"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
"""关于本站插件入口"""
|
||||
from flask import Flask
|
||||
|
||||
from .view.about import bp
|
||||
|
||||
|
||||
def event_init(app: Flask):
|
||||
app.register_blueprint(bp)
|
||||
print(" * aboutManager: registered /system/about/* blueprint")
|
||||
@@ -0,0 +1,150 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>关于本站</title>
|
||||
{% include 'system/common/header.html' %}
|
||||
</head>
|
||||
<body class="pear-container">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">
|
||||
<span class="layui-icon layui-icon-about"></span> 关于本站
|
||||
<span style="float:right;font-size:12px;color:#999;margin-top:5px;">
|
||||
共 1 条记录(站点全局唯一,修改即时生效)
|
||||
</span>
|
||||
</div>
|
||||
<div class="layui-card-body">
|
||||
<form class="layui-form" lay-filter="about-form" style="padding-right: 20px;">
|
||||
|
||||
{{ csrf_input()|safe }}
|
||||
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">站点名</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="siteName" value="{{ about.site_name or '' }}"
|
||||
placeholder="公开页显示的标题" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">自我介绍</label>
|
||||
<div class="layui-input-block">
|
||||
<textarea name="contentMd" placeholder="支持 Markdown 语法(# 标题、- 列表、** 加粗**、` 代码、> 引用……)"
|
||||
class="layui-textarea" rows="10"
|
||||
style="font-family: Consolas, 'Microsoft YaHei', monospace;">{{ about.content_md or '' }}</textarea>
|
||||
</div>
|
||||
<div class="layui-form-mid layui-word-aux">
|
||||
前台 /site/about 会用 Markdown 渲染,提交空内容则显示默认占位文字
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">ICP 备案</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="icp" value="{{ about.icp or '' }}"
|
||||
placeholder="如 京ICP备12345678号" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset class="layui-elem-field" style="margin: 20px 0; padding: 0 20px;">
|
||||
<legend>联系方式</legend>
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">邮箱</label>
|
||||
<div class="layui-input-inline">
|
||||
<input type="email" name="contactEmail" value="{{ about.contact_email or '' }}"
|
||||
placeholder="hi@example.com" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">QQ</label>
|
||||
<div class="layui-input-inline">
|
||||
<input type="text" name="contactQq" value="{{ about.contact_qq or '' }}"
|
||||
placeholder="12345678" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">微信</label>
|
||||
<div class="layui-input-inline">
|
||||
<input type="text" name="contactWechat" value="{{ about.contact_wechat or '' }}"
|
||||
autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">Telegram</label>
|
||||
<div class="layui-input-inline">
|
||||
<input type="text" name="contactTelegram" value="{{ about.contact_telegram or '' }}"
|
||||
placeholder="@your_id 或 https://t.me/xxx" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">GitHub</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="url" name="contactGithub" value="{{ about.contact_github or '' }}"
|
||||
placeholder="https://github.com/yourname" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">打赏/捐赠</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="donateUrl" value="{{ about.donate_url or '' }}"
|
||||
placeholder="可填二维码图片地址 或 PayPal/支付宝收款链接" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
<div class="layui-form-mid layui-word-aux">
|
||||
前台 /site/about 会在底部显示
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-input-block">
|
||||
<button type="button" class="layui-btn" lay-submit lay-filter="about-submit">
|
||||
<i class="layui-icon layui-icon-ok"></i> 保存
|
||||
</button>
|
||||
<button type="reset" class="layui-btn layui-btn-primary">重置</button>
|
||||
<a href="/site/about" target="_blank" class="layui-btn layui-btn-warm" style="margin-left: 16px;">
|
||||
<i class="layui-icon layui-icon-link"></i> 查看前台效果
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
{% include 'system/common/footer.html' %}
|
||||
<script>
|
||||
layui.use(['form', 'layer'], function(){
|
||||
var form = layui.form;
|
||||
var layer = layui.layer;
|
||||
var $ = layui.$;
|
||||
|
||||
// 隐藏字段随 Layui form 一起序列化
|
||||
form.on('submit(about-submit)', function(data){
|
||||
// data.field 已经包含 csrf_token(隐藏域)+ 所有可见字段
|
||||
$.ajax({
|
||||
url: '/system/about/save',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(data.field),
|
||||
success: function(res){
|
||||
if (res.success) {
|
||||
layer.msg(res.msg, {icon: 1});
|
||||
} else {
|
||||
layer.msg(res.msg, {icon: 2});
|
||||
}
|
||||
},
|
||||
error: function(){
|
||||
layer.msg('保存失败,请稍后再试', {icon: 2});
|
||||
}
|
||||
});
|
||||
return false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
「关于本站」后台管理:编辑单条记录 (id=1)。
|
||||
插件版。
|
||||
"""
|
||||
import os
|
||||
|
||||
from flask import Blueprint, render_template, request
|
||||
from flask_wtf.csrf import validate_csrf
|
||||
from wtforms.validators import ValidationError
|
||||
|
||||
from applications.common.utils.http import 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.models import About
|
||||
|
||||
dir_path = os.path.dirname(os.path.abspath(__file__))
|
||||
bp = Blueprint(
|
||||
'about', __name__,
|
||||
url_prefix='/system/about',
|
||||
template_folder=os.path.join(dir_path, '..', 'templates'),
|
||||
)
|
||||
|
||||
|
||||
def _get_about() -> About:
|
||||
a = About.query.get(1)
|
||||
if a is None:
|
||||
a = About(id=1, site_name='旺珂 · 导航')
|
||||
db.session.add(a)
|
||||
db.session.commit()
|
||||
return a
|
||||
|
||||
|
||||
@bp.get('/')
|
||||
@authorize("site:about:main")
|
||||
def main():
|
||||
about = _get_about()
|
||||
return render_template('system/about/main.html', about=about)
|
||||
|
||||
|
||||
@bp.post('/save')
|
||||
@authorize("site:about:main", log=True)
|
||||
def save():
|
||||
req_json = request.get_json(force=True, silent=True) or {}
|
||||
try:
|
||||
validate_csrf(req_json.get("csrf_token"))
|
||||
except ValidationError:
|
||||
return fail_api(msg='非法请求')
|
||||
|
||||
a = _get_about()
|
||||
fields = {
|
||||
'site_name': (req_json.get('siteName') or '').strip()[:120],
|
||||
'content_md': (req_json.get('contentMd') or '').strip(),
|
||||
'icp': (req_json.get('icp') or '').strip()[:120],
|
||||
'contact_email': (req_json.get('contactEmail') or '').strip()[:120],
|
||||
'contact_qq': (req_json.get('contactQq') or '').strip()[:32],
|
||||
'contact_wechat': (req_json.get('contactWechat') or '').strip()[:120],
|
||||
'contact_telegram': (req_json.get('contactTelegram') or '').strip()[:120],
|
||||
'contact_github': (req_json.get('contactGithub') or '').strip()[:255],
|
||||
'donate_url': (req_json.get('donateUrl') or '').strip()[:255],
|
||||
}
|
||||
for k, v in fields.items():
|
||||
setattr(a, k, v)
|
||||
db.session.commit()
|
||||
return success_api(msg='保存成功')
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"plugin_name": "友情链接",
|
||||
"plugin_version": "1.0.0",
|
||||
"plugin_description": "后台友情链接模块。后台菜单位于「站点管理 / 友情链接」。对 site_friend 表的增删改查与启用/禁用。"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
"""友情链接插件入口"""
|
||||
from flask import Flask
|
||||
|
||||
from .view.friend import bp
|
||||
|
||||
|
||||
def event_init(app: Flask):
|
||||
app.register_blueprint(bp)
|
||||
print(" * friendManager: registered /system/friend/* blueprint")
|
||||
@@ -0,0 +1,114 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>新增友情链接</title>
|
||||
{% include 'system/common/header.html' %}
|
||||
</head>
|
||||
<body>
|
||||
<div class="layui-form" lay-filter="friend-form" style="padding: 20px 30px 0 0;">
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">名称</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="title" required lay-verify="required" placeholder="显示在友链列表上的名字"
|
||||
autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">链接</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="url" required lay-verify="required|url" placeholder="https://example.com"
|
||||
autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">Logo</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="logo" placeholder="https://example.com/favicon.ico(可留空)"
|
||||
autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">简介</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="description" placeholder="一句话介绍对方站"
|
||||
autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">分组</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="category" placeholder="默认「推荐友链」"
|
||||
value="推荐友链" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">排序</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="number" name="sort" value="0" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">类型</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="radio" name="isExternal" value="1" title="外链(新窗口)" checked>
|
||||
<input type="radio" name="isExternal" value="0" title="站内(当前窗)">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">状态</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="radio" name="enable" value="1" title="启用" checked>
|
||||
<input type="radio" name="enable" value="0" title="禁用">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-input-block">
|
||||
<button type="button" class="layui-btn" lay-submit lay-filter="friend-form-submit">立即提交</button>
|
||||
<button type="reset" class="layui-btn layui-btn-primary">重置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
layui.use(['form', 'layer'], function(){
|
||||
var form = layui.form;
|
||||
var layer = layui.layer;
|
||||
var $ = layui.$;
|
||||
|
||||
form.render();
|
||||
form.on('submit(friend-form-submit)', function(){
|
||||
var data = form.val('friend-form');
|
||||
var submitBtn = $('button[lay-filter="friend-form-submit"]');
|
||||
submitBtn.prop('disabled', true).addClass('layui-btn-disabled');
|
||||
$.ajax({
|
||||
url: '/system/friend/save',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({
|
||||
title: data.title,
|
||||
url: data.url,
|
||||
logo: data.logo,
|
||||
description: data.description,
|
||||
category: data.category,
|
||||
sort: data.sort,
|
||||
isExternal: data.isExternal,
|
||||
enable: data.enable
|
||||
}),
|
||||
success: function(res){
|
||||
layer.msg(res.msg || (res.code === 0 ? '保存成功' : '保存失败'));
|
||||
if (res.code === 0 || res.success) {
|
||||
setTimeout(function(){ parent.layer.close(parent.layer.getFrameIndex(window.name)); }, 600);
|
||||
}
|
||||
},
|
||||
error: function(){ layer.msg('请求失败'); },
|
||||
complete: function(){
|
||||
submitBtn.prop('disabled', false).removeClass('layui-btn-disabled');
|
||||
}
|
||||
});
|
||||
return false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,117 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>编辑友情链接</title>
|
||||
{% include 'system/common/header.html' %}
|
||||
</head>
|
||||
<body>
|
||||
<div class="layui-form" lay-filter="friend-form" style="padding: 20px 30px 0 0;">
|
||||
<input type="hidden" name="id" value="{{ friend.id }}">
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">名称</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="title" required lay-verify="required" value="{{ friend.title }}"
|
||||
autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">链接</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="url" required lay-verify="required|url" value="{{ friend.url }}"
|
||||
autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">Logo</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="logo" value="{{ friend.logo or '' }}"
|
||||
placeholder="https://example.com/favicon.ico(可留空)"
|
||||
autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">简介</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="description" value="{{ friend.description or '' }}"
|
||||
autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">分组</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="category" value="{{ friend.category or '推荐友链' }}"
|
||||
autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">排序</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="number" name="sort" value="{{ friend.sort or 0 }}" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">类型</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="radio" name="isExternal" value="1" title="外链(新窗口)" {% if friend.is_external == 1 %}checked{% endif %}>
|
||||
<input type="radio" name="isExternal" value="0" title="站内(当前窗)" {% if friend.is_external == 0 %}checked{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">状态</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="radio" name="enable" value="1" title="启用" {% if friend.enable == 1 %}checked{% endif %}>
|
||||
<input type="radio" name="enable" value="0" title="禁用" {% if friend.enable == 0 %}checked{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-input-block">
|
||||
<button type="button" class="layui-btn" lay-submit lay-filter="friend-form-submit">保存</button>
|
||||
<button type="reset" class="layui-btn layui-btn-primary">重置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
layui.use(['form', 'layer'], function(){
|
||||
var form = layui.form;
|
||||
var layer = layui.layer;
|
||||
var $ = layui.$;
|
||||
|
||||
form.render();
|
||||
form.on('submit(friend-form-submit)', function(){
|
||||
var data = form.val('friend-form');
|
||||
var submitBtn = $('button[lay-filter="friend-form-submit"]');
|
||||
submitBtn.prop('disabled', true).addClass('layui-btn-disabled');
|
||||
$.ajax({
|
||||
url: '/system/friend/update',
|
||||
type: 'PUT',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({
|
||||
friendId: data.id,
|
||||
title: data.title,
|
||||
url: data.url,
|
||||
logo: data.logo,
|
||||
description: data.description,
|
||||
category: data.category,
|
||||
sort: data.sort,
|
||||
isExternal: data.isExternal,
|
||||
enable: data.enable
|
||||
}),
|
||||
success: function(res){
|
||||
layer.msg(res.msg || (res.code === 0 ? '保存成功' : '保存失败'));
|
||||
if (res.code === 0 || res.success) {
|
||||
setTimeout(function(){ parent.layer.close(parent.layer.getFrameIndex(window.name)); }, 600);
|
||||
}
|
||||
},
|
||||
error: function(){ layer.msg('请求失败'); },
|
||||
complete: function(){
|
||||
submitBtn.prop('disabled', false).removeClass('layui-btn-disabled');
|
||||
}
|
||||
});
|
||||
return false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,171 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>友情链接</title>
|
||||
{% include 'system/common/header.html' %}
|
||||
</head>
|
||||
<body class="pear-container">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-body">
|
||||
<form class="layui-form" action="" lay-filter="friend-query-form">
|
||||
<div class="layui-form-item" style="margin-bottom: unset;">
|
||||
<label class="layui-form-label">关键词</label>
|
||||
<div class="layui-input-inline">
|
||||
<input type="text" name="keyword" placeholder="名称/链接/简介" class="layui-input">
|
||||
</div>
|
||||
<label class="layui-form-label">分类</label>
|
||||
<div class="layui-input-inline">
|
||||
<input type="text" name="category" placeholder="按分组过滤" class="layui-input">
|
||||
</div>
|
||||
<button class="layui-btn layui-btn-md" lay-submit lay-filter="friend-query">
|
||||
<i class="layui-icon layui-icon-search"></i> 查询
|
||||
</button>
|
||||
<button type="reset" class="layui-btn layui-btn-primary layui-btn-md">
|
||||
<i class="layui-icon layui-icon-refresh"></i> 重置
|
||||
</button>
|
||||
<button type="button" class="layui-btn layui-btn-normal layui-btn-md" id="btn-add">
|
||||
<i class="layui-icon layui-icon-add-1"></i> 新增友链
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-body">
|
||||
<table id="friend-table" lay-filter="friend-table"></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'system/common/footer.html' %}
|
||||
{% raw %}
|
||||
<script type="text/html" id="col-friend-status">
|
||||
{{# if(d.enable === 1){ }}
|
||||
<span class="layui-badge layui-bg-green">启用</span>
|
||||
{{# } else { }}
|
||||
<span class="layui-badge layui-bg-gray">禁用</span>
|
||||
{{# } }}
|
||||
</script>
|
||||
|
||||
<script type="text/html" id="col-friend-external">
|
||||
{{# if(d.is_external === 1){ }}
|
||||
<span class="layui-badge layui-bg-blue">外链</span>
|
||||
{{# } else { }}
|
||||
<span class="layui-badge">站内</span>
|
||||
{{# } }}
|
||||
</script>
|
||||
|
||||
<script type="text/html" id="col-friend-logo">
|
||||
{{# if(d.logo){ }}
|
||||
<img src="{{ d.logo }}" style="height:24px;max-width:80px;" onerror="this.style.display='none'" />
|
||||
{{# } else { }}
|
||||
<span style="color:#999">—</span>
|
||||
{{# } }}
|
||||
</script>
|
||||
|
||||
<script type="text/html" id="col-friend-op">
|
||||
<a class="layui-btn layui-btn-xs" lay-event="edit">编辑</a>
|
||||
{{# if(d.enable === 1){ }}
|
||||
<a class="layui-btn layui-btn-xs layui-btn-warm" lay-event="disable">禁用</a>
|
||||
{{# } else { }}
|
||||
<a class="layui-btn layui-btn-xs layui-btn-normal" lay-event="enable">启用</a>
|
||||
{{# } }}
|
||||
<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="remove">删除</a>
|
||||
</script>
|
||||
{% endraw %}
|
||||
|
||||
<script>
|
||||
layui.use(['table', 'form', 'layer'], function(){
|
||||
var table = layui.table;
|
||||
var form = layui.form;
|
||||
var layer = layui.layer;
|
||||
var $ = layui.$;
|
||||
|
||||
var renderTable = function(){
|
||||
var keyword = $('input[name="keyword"]').val() || '';
|
||||
var category = $('input[name="category"]').val() || '';
|
||||
table.render({
|
||||
elem: '#friend-table',
|
||||
url: '/system/friend/data',
|
||||
where: { keyword: keyword, category: category },
|
||||
page: true,
|
||||
limit: 20,
|
||||
cols: [[
|
||||
{field: 'id', title: 'ID', width: 60},
|
||||
{field: 'title', title: '名称', width: 180},
|
||||
{field: 'logo', title: 'Logo', width: 100, templet: '#col-friend-logo'},
|
||||
{field: 'url', title: '链接', minWidth: 220},
|
||||
{field: 'description', title: '简介', minWidth: 200},
|
||||
{field: 'category', title: '分组', width: 120},
|
||||
{field: 'sort', title: '排序', width: 70},
|
||||
{field: 'is_external', title: '类型', width: 80, templet: '#col-friend-external'},
|
||||
{field: 'enable', title: '状态', width: 80, templet: '#col-friend-status'},
|
||||
{field: 'create_at', title: '创建时间', width: 170},
|
||||
{fixed: 'right', title: '操作', toolbar: '#col-friend-op', width: 220}
|
||||
]],
|
||||
skin: 'line',
|
||||
text: {none: '暂无友情链接'},
|
||||
// table_api 默认返回 {code:0, data, count, msg, limit} 与 Layui 默认 statusCode:0 兼容
|
||||
});
|
||||
};
|
||||
|
||||
renderTable();
|
||||
|
||||
form.on('submit(friend-query)', function(){
|
||||
renderTable();
|
||||
return false;
|
||||
});
|
||||
|
||||
$('#btn-add').on('click', function(){
|
||||
layer.open({
|
||||
type: 2,
|
||||
title: '新增友情链接',
|
||||
area: ['640px', '620px'],
|
||||
content: '/system/friend/add'
|
||||
});
|
||||
});
|
||||
|
||||
table.on('tool(friend-table)', function(obj){
|
||||
var data = obj.data;
|
||||
if (obj.event === 'edit'){
|
||||
layer.open({
|
||||
type: 2,
|
||||
title: '编辑友情链接',
|
||||
area: ['640px', '620px'],
|
||||
content: '/system/friend/edit?friendId=' + data.id
|
||||
});
|
||||
} else if (obj.event === 'remove'){
|
||||
layer.confirm('确定删除该友链吗?', function(idx){
|
||||
$.ajax({
|
||||
url: '/system/friend/remove/' + data.id,
|
||||
type: 'DELETE',
|
||||
success: function(res){
|
||||
if (res.code === 0 || res.success) { layer.msg(res.msg); obj.del(); }
|
||||
else layer.msg(res.msg);
|
||||
}
|
||||
});
|
||||
layer.close(idx);
|
||||
});
|
||||
} else if (obj.event === 'enable'){
|
||||
$.ajax({
|
||||
url: '/system/friend/enable',
|
||||
type: 'PUT',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({friendId: data.id}),
|
||||
success: function(res){ layer.msg(res.msg); renderTable(); }
|
||||
});
|
||||
} else if (obj.event === 'disable'){
|
||||
$.ajax({
|
||||
url: '/system/friend/disable',
|
||||
type: 'PUT',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({friendId: data.id}),
|
||||
success: function(res){ layer.msg(res.msg); renderTable(); }
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
后台友情链接视图 — 插件版(不依赖 framework 系统蓝图)。
|
||||
|
||||
URL 前缀变更:原 `'/friend'` 改成 `'/system/friend'`,独立挂载到 app。
|
||||
"""
|
||||
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 Friend
|
||||
from applications.schemas import FriendManageSchema
|
||||
|
||||
dir_path = os.path.dirname(os.path.abspath(__file__))
|
||||
bp = Blueprint(
|
||||
'friend', __name__,
|
||||
url_prefix='/system/friend',
|
||||
template_folder=os.path.join(dir_path, '..', 'templates'),
|
||||
)
|
||||
|
||||
|
||||
@bp.get('/')
|
||||
@authorize("system:friend:main")
|
||||
def main():
|
||||
return render_template('system/friend/main.html')
|
||||
|
||||
|
||||
@bp.get('/data')
|
||||
@limiter.limit("60 per minute")
|
||||
@authorize("system:friend:main")
|
||||
def data():
|
||||
keyword = str_escape(request.args.get('keyword', type=str))
|
||||
category = str_escape(request.args.get('category', type=str))
|
||||
|
||||
query = Friend.query.filter()
|
||||
if keyword:
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
Friend.title.contains(keyword),
|
||||
Friend.url.contains(keyword),
|
||||
Friend.description.contains(keyword),
|
||||
)
|
||||
)
|
||||
if category:
|
||||
query = query.filter(Friend.category == category)
|
||||
|
||||
items = query.order_by(Friend.category.asc(), Friend.sort.asc(), Friend.id.asc()).layui_paginate()
|
||||
return table_api(
|
||||
msg="请求成功",
|
||||
data=curd.model_to_dicts(schema=FriendManageSchema, data=items.items),
|
||||
count=items.total,
|
||||
)
|
||||
|
||||
|
||||
@bp.get('/add')
|
||||
@authorize("system:friend:add", log=True)
|
||||
def add():
|
||||
return render_template('system/friend/add.html')
|
||||
|
||||
|
||||
@bp.post('/save')
|
||||
@authorize("system:friend:add", log=True)
|
||||
def save():
|
||||
req_json = request.get_json(force=True, silent=True) or {}
|
||||
title = str_escape(req_json.get('title'))
|
||||
url = str_escape(req_json.get('url'))
|
||||
if not all([title, url]):
|
||||
return fail_api(msg="名称/链接不得为空")
|
||||
|
||||
try:
|
||||
friend = Friend(
|
||||
title=title,
|
||||
url=url,
|
||||
logo=str_escape(req_json.get('logo')),
|
||||
description=str_escape(req_json.get('description')),
|
||||
category=str_escape(req_json.get('category')) or '推荐友链',
|
||||
sort=int(req_json.get('sort') or 0),
|
||||
enable=int(req_json.get('enable') or 1),
|
||||
is_external=int(req_json.get('isExternal') or 1),
|
||||
create_by=current_user.username if current_user.is_authenticated else 'admin',
|
||||
)
|
||||
db.session.add(friend)
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return fail_api(msg=f"新增失败:{e}")
|
||||
return success_api(msg="新增成功")
|
||||
|
||||
|
||||
@bp.get('/edit')
|
||||
@authorize("site:friend:edit", log=True)
|
||||
def edit():
|
||||
friend_id = request.args.get('friendId', type=int)
|
||||
friend = curd.get_one_by_id(Friend, friend_id)
|
||||
if not friend:
|
||||
return fail_api(msg="友链不存在")
|
||||
return render_template('system/friend/edit.html', friend=friend)
|
||||
|
||||
|
||||
@bp.put('/update')
|
||||
@authorize("site:friend:edit", log=True)
|
||||
def update():
|
||||
req_json = request.get_json(force=True, silent=True) or {}
|
||||
friend_id = req_json.get('friendId')
|
||||
friend = curd.get_one_by_id(Friend, friend_id)
|
||||
if not friend:
|
||||
return fail_api(msg="友链不存在")
|
||||
|
||||
title = str_escape(req_json.get('title'))
|
||||
url = str_escape(req_json.get('url'))
|
||||
if not all([title, url]):
|
||||
return fail_api(msg="名称/链接不得为空")
|
||||
|
||||
try:
|
||||
friend.title = title
|
||||
friend.url = url
|
||||
friend.logo = str_escape(req_json.get('logo'))
|
||||
friend.description = str_escape(req_json.get('description'))
|
||||
friend.category = str_escape(req_json.get('category')) or '推荐友链'
|
||||
friend.sort = int(req_json.get('sort') or 0)
|
||||
friend.enable = int(req_json.get('enable') or 1)
|
||||
friend.is_external = int(req_json.get('isExternal') or 1)
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return fail_api(msg=f"更新失败:{e}")
|
||||
return success_api(msg="更新成功")
|
||||
|
||||
|
||||
@bp.delete('/remove/<int:friend_id>')
|
||||
@authorize("system:friend:remove", log=True)
|
||||
def remove(friend_id):
|
||||
res = curd.delete_one_by_id(Friend, friend_id)
|
||||
if not res:
|
||||
return fail_api(msg="删除失败")
|
||||
return success_api(msg="删除成功")
|
||||
|
||||
|
||||
@bp.put('/enable')
|
||||
@authorize("site:friend:edit", log=True)
|
||||
def enable():
|
||||
req_json = request.get_json(force=True, silent=True) or {}
|
||||
friend_id = req_json.get('friendId')
|
||||
if not friend_id:
|
||||
return fail_api(msg="数据错误")
|
||||
res = curd.enable_status(Friend, friend_id)
|
||||
if not res:
|
||||
return fail_api(msg="操作失败")
|
||||
return success_api(msg="已启用")
|
||||
|
||||
|
||||
@bp.put('/disable')
|
||||
@authorize("site:friend:edit", log=True)
|
||||
def dis_enable():
|
||||
req_json = request.get_json(force=True, silent=True) or {}
|
||||
friend_id = req_json.get('friendId')
|
||||
if not friend_id:
|
||||
return fail_api(msg="数据错误")
|
||||
res = curd.disable_status(Friend, friend_id)
|
||||
if not res:
|
||||
return fail_api(msg="操作失败")
|
||||
return success_api(msg="已禁用")
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"plugin_name": "导航管理",
|
||||
"plugin_version": "1.0.0",
|
||||
"plugin_description": "后台导航管理模块。后台菜单位于「站点管理 / 导航管理」。提供对 nav 表的增删改查、分页查询、关键字 + 分类过滤、启用/禁用切换。"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
导航管理插件入口
|
||||
|
||||
通过 Pear Admin Flask 插件系统注册到 /system/nav/* 与 /system/nav-category/* 路径。
|
||||
此插件不修改 framework 任何代码,仅挂在对应前缀下,注册蓝图即可。
|
||||
|
||||
数据层沿用 applications.models.{Nav, NavCategory}(SQLAlchemy 模型必须由 framework 加载以便 Alembic 跟踪)。
|
||||
"""
|
||||
from flask import Flask
|
||||
|
||||
from .view.nav import bp
|
||||
from .view.nav_category import bp as nav_category_bp
|
||||
|
||||
|
||||
def event_init(app: Flask):
|
||||
"""初始化完成时注册蓝图到 app(直接挂到 app,而不是 framework 的 system_bp)。"""
|
||||
app.register_blueprint(bp)
|
||||
app.register_blueprint(nav_category_bp)
|
||||
print(" * navManager: registered /system/nav/* and /system/nav-category/* blueprints")
|
||||
@@ -0,0 +1,101 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>新增导航</title>
|
||||
{% include 'system/common/header.html' %}
|
||||
</head>
|
||||
<body>
|
||||
<div class="layui-form" lay-filter="nav-form" style="padding: 20px 30px 0 0;">
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">分类</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="category" required lay-verify="required" placeholder="如:开发工具 / AI / 设计资源"
|
||||
autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">标题</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="title" required lay-verify="required" placeholder="显示在卡片上的名称"
|
||||
autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">链接 URL</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="url" required lay-verify="required|url"
|
||||
placeholder="https://example.com" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">简介</label>
|
||||
<div class="layui-input-block">
|
||||
<textarea name="description" placeholder="选填,鼠标悬停时显示" class="layui-textarea"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">图标</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="icon" placeholder="layui-icon-website / layui-icon-ai / 等"
|
||||
autocomplete="off" class="layui-input" value="layui-icon-link">
|
||||
<div class="layui-form-mid layui-word-aux">参考:<a href="https://layui.dev/web/doc/element/icon.html" target="_blank">layui 图标库</a></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">排序</label>
|
||||
<div class="layui-input-inline">
|
||||
<input type="number" name="sort" value="0" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">类型</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="radio" name="isExternal" value="1" title="外链(新窗口)" checked>
|
||||
<input type="radio" name="isExternal" value="0" title="站内(当前窗口)">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">状态</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="radio" name="enable" value="1" title="启用" checked>
|
||||
<input type="radio" name="enable" value="0" title="禁用">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-input-block">
|
||||
<button class="layui-btn" lay-submit lay-filter="nav-submit">保存</button>
|
||||
<button type="reset" class="layui-btn layui-btn-primary">重置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'system/common/footer.html' %}
|
||||
<script>
|
||||
layui.use(['form', 'layer'], function(){
|
||||
var form = layui.form;
|
||||
var layer = layui.layer;
|
||||
var $ = layui.$;
|
||||
|
||||
form.on('submit(nav-submit)', function(data){
|
||||
$.ajax({
|
||||
url: '/system/nav/save',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(data.field),
|
||||
success: function(res){
|
||||
if (res.success) {
|
||||
layer.msg(res.msg, {icon: 1});
|
||||
parent.layui.table.reload('nav-table');
|
||||
setTimeout(function(){ parent.layer.closeAll(); }, 800);
|
||||
} else {
|
||||
layer.msg(res.msg, {icon: 2});
|
||||
}
|
||||
}
|
||||
});
|
||||
return false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>新增导航分类</title>
|
||||
{% include 'system/common/header.html' %}
|
||||
<style>
|
||||
.layui-form-label { width: 90px; }
|
||||
.layui-input-block { margin-left: 120px; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="pear-container">
|
||||
<form class="layui-form" lay-filter="cat-add-form" style="margin-top: 20px;">
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">分类名</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="name" required lay-verify="required" placeholder="请输入分类名(唯一)" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">图标 class</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="icon" placeholder="layui-icon-list" value="layui-icon-list" class="layui-input">
|
||||
<div class="layui-form-mid layui-word-aux">填写 layui 图标 class,例如 layui-icon-list / layui-icon-star</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">简介</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="description" placeholder="前台首页卡片标题下方的描述" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">排序</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="number" name="sort" value="0" class="layui-input">
|
||||
<div class="layui-form-mid layui-word-aux">数字越大,越靠前</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">状态</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="radio" name="enable" value="1" title="启用" checked>
|
||||
<input type="radio" name="enable" value="0" title="禁用">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-input-block">
|
||||
<button type="submit" class="layui-btn" lay-submit lay-filter="cat-submit">立即提交</button>
|
||||
<button type="reset" class="layui-btn layui-btn-primary">重置</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% include 'system/common/footer.html' %}
|
||||
<script>
|
||||
layui.use(['form', 'layer'], function(){
|
||||
var form = layui.form;
|
||||
var layer = layui.layer;
|
||||
var $ = layui.$;
|
||||
|
||||
form.on('submit(cat-submit)', function(data){
|
||||
$.ajax({
|
||||
url: '/system/nav-category/save',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(data.field),
|
||||
success: function(res){
|
||||
if (res.code === 0 || res.success) {
|
||||
layer.msg(res.msg, {icon: 1}, function(){
|
||||
parent.layui.table.reload('cat-table');
|
||||
parent.layer.closeAll();
|
||||
});
|
||||
} else {
|
||||
layer.msg(res.msg, {icon: 2});
|
||||
}
|
||||
}
|
||||
});
|
||||
return false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,95 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>编辑导航分类</title>
|
||||
{% include 'system/common/header.html' %}
|
||||
<style>
|
||||
.layui-form-label { width: 90px; }
|
||||
.layui-input-block { margin-left: 120px; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="pear-container">
|
||||
<form class="layui-form" lay-filter="cat-edit-form" style="margin-top: 20px;">
|
||||
<input type="hidden" name="catId" value="{{ cat.id }}">
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">分类名</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="name" required lay-verify="required" value="{{ cat.name }}" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">图标 class</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="icon" value="{{ cat.icon or 'layui-icon-list' }}" class="layui-input">
|
||||
<div class="layui-form-mid layui-word-aux">填写 layui 图标 class</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">简介</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="description" value="{{ cat.description or '' }}" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">排序</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="number" name="sort" value="{{ cat.sort or 0 }}" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">状态</label>
|
||||
<div class="layui-input-block">
|
||||
{% if cat.enable == 1 %}
|
||||
<input type="radio" name="enable" value="1" title="启用" checked>
|
||||
<input type="radio" name="enable" value="0" title="禁用">
|
||||
{% else %}
|
||||
<input type="radio" name="enable" value="1" title="启用">
|
||||
<input type="radio" name="enable" value="0" title="禁用" checked>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-input-block">
|
||||
<button type="submit" class="layui-btn" lay-submit lay-filter="cat-submit">立即提交</button>
|
||||
<button type="button" class="layui-btn layui-btn-primary" id="btn-cancel">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% include 'system/common/footer.html' %}
|
||||
<script>
|
||||
layui.use(['form', 'layer'], function(){
|
||||
var form = layui.form;
|
||||
var layer = layui.layer;
|
||||
var $ = layui.$;
|
||||
|
||||
form.render();
|
||||
|
||||
form.on('submit(cat-submit)', function(data){
|
||||
$.ajax({
|
||||
url: '/system/nav-category/update',
|
||||
type: 'PUT',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(data.field),
|
||||
success: function(res){
|
||||
if (res.code === 0 || res.success) {
|
||||
layer.msg(res.msg, {icon: 1}, function(){
|
||||
parent.layui.table.reload('cat-table');
|
||||
parent.layer.closeAll();
|
||||
});
|
||||
} else {
|
||||
layer.msg(res.msg, {icon: 2});
|
||||
}
|
||||
}
|
||||
});
|
||||
return false;
|
||||
});
|
||||
|
||||
$('#btn-cancel').on('click', function(){
|
||||
parent.layer.closeAll();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,146 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>导航分类管理</title>
|
||||
{% include 'system/common/header.html' %}
|
||||
</head>
|
||||
<body class="pear-container">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-body">
|
||||
<form class="layui-form" action="" lay-filter="cat-query-form">
|
||||
<div class="layui-form-item" style="margin-bottom: unset;">
|
||||
<label class="layui-form-label">关键词</label>
|
||||
<div class="layui-input-inline">
|
||||
<input type="text" name="keyword" placeholder="分类名/简介" class="layui-input">
|
||||
</div>
|
||||
<button class="layui-btn layui-btn-md" lay-submit lay-filter="cat-query">
|
||||
<i class="layui-icon layui-icon-search"></i> 查询
|
||||
</button>
|
||||
<button type="reset" class="layui-btn layui-btn-primary layui-btn-md">
|
||||
<i class="layui-icon layui-icon-refresh"></i> 重置
|
||||
</button>
|
||||
<button type="button" class="layui-btn layui-btn-normal layui-btn-md" id="btn-add">
|
||||
<i class="layui-icon layui-icon-add-1"></i> 新增分类
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-body">
|
||||
<table id="cat-table" lay-filter="cat-table"></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'system/common/footer.html' %}
|
||||
{% raw %}
|
||||
<script type="text/html" id="col-cat-status">
|
||||
{{# if(d.enable === 1){ }}
|
||||
<span class="layui-badge layui-bg-green">启用</span>
|
||||
{{# } else { }}
|
||||
<span class="layui-badge layui-bg-gray">禁用</span>
|
||||
{{# } }}
|
||||
</script>
|
||||
|
||||
<script type="text/html" id="col-cat-op">
|
||||
<a class="layui-btn layui-btn-xs" lay-event="edit">编辑</a>
|
||||
{{# if(d.enable === 1){ }}
|
||||
<a class="layui-btn layui-btn-xs layui-btn-warm" lay-event="disable">禁用</a>
|
||||
{{# } else { }}
|
||||
<a class="layui-btn layui-btn-xs layui-btn-normal" lay-event="enable">启用</a>
|
||||
{{# } }}
|
||||
<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="remove">删除</a>
|
||||
</script>
|
||||
{% endraw %}
|
||||
|
||||
<script>
|
||||
layui.use(['table', 'form', 'layer'], function(){
|
||||
var table = layui.table;
|
||||
var form = layui.form;
|
||||
var layer = layui.layer;
|
||||
var $ = layui.$;
|
||||
|
||||
var renderTable = function(){
|
||||
var keyword = $('input[name="keyword"]').val() || '';
|
||||
table.render({
|
||||
elem: '#cat-table',
|
||||
url: '/system/nav-category/data',
|
||||
where: { keyword: keyword },
|
||||
page: true,
|
||||
limit: 20,
|
||||
cols: [[
|
||||
{field: 'id', title: 'ID', width: 60},
|
||||
{field: 'name', title: '分类名', width: 150},
|
||||
{field: 'icon', title: '图标', width: 150},
|
||||
{field: 'description', title: '简介', minWidth: 220},
|
||||
{field: 'sort', title: '排序', width: 70},
|
||||
{field: 'enable', title: '状态', width: 80, templet: '#col-cat-status'},
|
||||
{field: 'create_at', title: '创建时间', width: 170},
|
||||
{fixed: 'right', title: '操作', toolbar: '#col-cat-op', width: 220}
|
||||
]],
|
||||
skin: 'line',
|
||||
text: {none: '暂无分类数据'}
|
||||
});
|
||||
};
|
||||
|
||||
renderTable();
|
||||
|
||||
form.on('submit(cat-query)', function(){
|
||||
renderTable();
|
||||
return false;
|
||||
});
|
||||
|
||||
$('#btn-add').on('click', function(){
|
||||
layer.open({
|
||||
type: 2,
|
||||
title: '新增导航分类',
|
||||
area: ['520px', '520px'],
|
||||
content: '/system/nav-category/add'
|
||||
});
|
||||
});
|
||||
|
||||
table.on('tool(cat-table)', function(obj){
|
||||
var data = obj.data;
|
||||
if (obj.event === 'edit'){
|
||||
layer.open({
|
||||
type: 2,
|
||||
title: '编辑导航分类',
|
||||
area: ['520px', '520px'],
|
||||
content: '/system/nav-category/edit?catId=' + data.id
|
||||
});
|
||||
} else if (obj.event === 'remove'){
|
||||
layer.confirm('确定删除该分类吗?\n(该分类下的导航不会删除,但前台将归入「未分组」)', function(idx){
|
||||
$.ajax({
|
||||
url: '/system/nav-category/remove/' + data.id,
|
||||
type: 'DELETE',
|
||||
success: function(res){
|
||||
if (res.code === 0 || res.success) { layer.msg(res.msg); obj.del(); }
|
||||
else layer.msg(res.msg);
|
||||
}
|
||||
});
|
||||
layer.close(idx);
|
||||
});
|
||||
} else if (obj.event === 'enable'){
|
||||
$.ajax({
|
||||
url: '/system/nav-category/enable',
|
||||
type: 'PUT',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({catId: data.id}),
|
||||
success: function(res){ layer.msg(res.msg); renderTable(); }
|
||||
});
|
||||
} else if (obj.event === 'disable'){
|
||||
$.ajax({
|
||||
url: '/system/nav-category/disable',
|
||||
type: 'PUT',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({catId: data.id}),
|
||||
success: function(res){ layer.msg(res.msg); renderTable(); }
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,100 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>编辑导航</title>
|
||||
{% include 'system/common/header.html' %}
|
||||
</head>
|
||||
<body>
|
||||
<div class="layui-form" lay-filter="nav-form" style="padding: 20px 30px 0 0;">
|
||||
<input type="hidden" name="id" value="{{ nav.id }}">
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">分类</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="category" required lay-verify="required" value="{{ nav.category }}"
|
||||
autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">标题</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="title" required lay-verify="required" value="{{ nav.title }}"
|
||||
autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">链接 URL</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="url" required lay-verify="required|url" value="{{ nav.url }}"
|
||||
autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">简介</label>
|
||||
<div class="layui-input-block">
|
||||
<textarea name="description" class="layui-textarea">{{ nav.description or '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">图标</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="icon" value="{{ nav.icon }}" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">排序</label>
|
||||
<div class="layui-input-inline">
|
||||
<input type="number" name="sort" value="{{ nav.sort }}" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">类型</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="radio" name="isExternal" value="1" title="外链(新窗口)" {% if nav.is_external == 1 %}checked{% endif %}>
|
||||
<input type="radio" name="isExternal" value="0" title="站内(当前窗口)" {% if nav.is_external != 1 %}checked{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">状态</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="radio" name="enable" value="1" title="启用" {% if nav.enable == 1 %}checked{% endif %}>
|
||||
<input type="radio" name="enable" value="0" title="禁用" {% if nav.enable != 1 %}checked{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-input-block">
|
||||
<button class="layui-btn" lay-submit lay-filter="nav-submit">保存</button>
|
||||
<button type="button" class="layui-btn layui-btn-primary" onclick="parent.layer.closeAll();">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'system/common/footer.html' %}
|
||||
<script>
|
||||
layui.use(['form', 'layer'], function(){
|
||||
var form = layui.form;
|
||||
var layer = layui.layer;
|
||||
var $ = layui.$;
|
||||
|
||||
form.on('submit(nav-submit)', function(data){
|
||||
$.ajax({
|
||||
url: '/system/nav/update',
|
||||
type: 'PUT',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(data.field),
|
||||
success: function(res){
|
||||
if (res.success) {
|
||||
layer.msg(res.msg, {icon: 1});
|
||||
parent.layui.table.reload('nav-table');
|
||||
setTimeout(function(){ parent.layer.closeAll(); }, 800);
|
||||
} else {
|
||||
layer.msg(res.msg, {icon: 2});
|
||||
}
|
||||
}
|
||||
});
|
||||
return false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,162 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>导航管理</title>
|
||||
{% include 'system/common/header.html' %}
|
||||
</head>
|
||||
<body class="pear-container">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-body">
|
||||
<form class="layui-form" action="" lay-filter="nav-query-form">
|
||||
<div class="layui-form-item" style="margin-bottom: unset;">
|
||||
<label class="layui-form-label">关键词</label>
|
||||
<div class="layui-input-inline">
|
||||
<input type="text" name="keyword" placeholder="标题/链接/简介" class="layui-input">
|
||||
</div>
|
||||
<label class="layui-form-label">分类</label>
|
||||
<div class="layui-input-inline">
|
||||
<input type="text" name="category" placeholder="按分类过滤" class="layui-input">
|
||||
</div>
|
||||
<button class="layui-btn layui-btn-md" lay-submit lay-filter="nav-query">
|
||||
<i class="layui-icon layui-icon-search"></i> 查询
|
||||
</button>
|
||||
<button type="reset" class="layui-btn layui-btn-primary layui-btn-md">
|
||||
<i class="layui-icon layui-icon-refresh"></i> 重置
|
||||
</button>
|
||||
<button type="button" class="layui-btn layui-btn-normal layui-btn-md" id="btn-add">
|
||||
<i class="layui-icon layui-icon-add-1"></i> 新增导航
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-body">
|
||||
<table id="nav-table" lay-filter="nav-table"></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'system/common/footer.html' %}
|
||||
{% raw %}
|
||||
<script type="text/html" id="col-status">
|
||||
{{# if(d.enable === 1){ }}
|
||||
<span class="layui-badge layui-bg-green">启用</span>
|
||||
{{# } else { }}
|
||||
<span class="layui-badge layui-bg-gray">禁用</span>
|
||||
{{# } }}
|
||||
</script>
|
||||
|
||||
<script type="text/html" id="col-external">
|
||||
{{# if(d.is_external === 1){ }}
|
||||
<span class="layui-badge layui-bg-blue">外链</span>
|
||||
{{# } else { }}
|
||||
<span class="layui-badge">站内</span>
|
||||
{{# } }}
|
||||
</script>
|
||||
|
||||
<script type="text/html" id="col-op">
|
||||
<a class="layui-btn layui-btn-xs" lay-event="edit">编辑</a>
|
||||
{{# if(d.enable === 1){ }}
|
||||
<a class="layui-btn layui-btn-xs layui-btn-warm" lay-event="disable">禁用</a>
|
||||
{{# } else { }}
|
||||
<a class="layui-btn layui-btn-xs layui-btn-normal" lay-event="enable">启用</a>
|
||||
{{# } }}
|
||||
<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="remove">删除</a>
|
||||
</script>
|
||||
{% endraw %}
|
||||
|
||||
<script>
|
||||
layui.use(['table', 'form', 'layer'], function(){
|
||||
var table = layui.table;
|
||||
var form = layui.form;
|
||||
var layer = layui.layer;
|
||||
var $ = layui.$;
|
||||
|
||||
var renderTable = function(){
|
||||
var keyword = $('input[name="keyword"]').val() || '';
|
||||
var category = $('input[name="category"]').val() || '';
|
||||
table.render({
|
||||
elem: '#nav-table',
|
||||
url: '/system/nav/data',
|
||||
where: { keyword: keyword, category: category },
|
||||
page: true,
|
||||
limit: 20,
|
||||
cols: [[
|
||||
{field: 'id', title: 'ID', width: 60},
|
||||
{field: 'category', title: '分类', width: 120},
|
||||
{field: 'title', title: '标题', width: 180},
|
||||
{field: 'url', title: '链接', minWidth: 200},
|
||||
{field: 'icon', title: '图标', width: 100},
|
||||
{field: 'sort', title: '排序', width: 70},
|
||||
{field: 'is_external', title: '类型', width: 80, templet: '#col-external'},
|
||||
{field: 'enable', title: '状态', width: 80, templet: '#col-status'},
|
||||
{field: 'create_at', title: '创建时间', width: 170},
|
||||
{fixed: 'right', title: '操作', toolbar: '#col-op', width: 220}
|
||||
]],
|
||||
skin: 'line',
|
||||
text: {none: '暂无导航数据'},
|
||||
// table_api 默认返回 {code:0, data, count, msg, limit},与 Layui 默认 statusCode: 0 匹配,无需 parseData/response
|
||||
});
|
||||
};
|
||||
|
||||
renderTable();
|
||||
|
||||
form.on('submit(nav-query)', function(){
|
||||
renderTable();
|
||||
return false;
|
||||
});
|
||||
|
||||
$('#btn-add').on('click', function(){
|
||||
layer.open({
|
||||
type: 2,
|
||||
title: '新增导航',
|
||||
area: ['640px', '620px'],
|
||||
content: '/system/nav/add'
|
||||
});
|
||||
});
|
||||
|
||||
table.on('tool(nav-table)', function(obj){
|
||||
var data = obj.data;
|
||||
if (obj.event === 'edit'){
|
||||
layer.open({
|
||||
type: 2,
|
||||
title: '编辑导航',
|
||||
area: ['640px', '620px'],
|
||||
content: '/system/nav/edit?navId=' + data.id
|
||||
});
|
||||
} else if (obj.event === 'remove'){
|
||||
layer.confirm('确定删除该导航吗?', function(idx){
|
||||
$.ajax({
|
||||
url: '/system/nav/remove/' + data.id,
|
||||
type: 'DELETE',
|
||||
success: function(res){
|
||||
if (res.code === 0 || res.success) { layer.msg(res.msg); obj.del(); }
|
||||
else layer.msg(res.msg);
|
||||
}
|
||||
});
|
||||
layer.close(idx);
|
||||
});
|
||||
} else if (obj.event === 'enable'){
|
||||
$.ajax({
|
||||
url: '/system/nav/enable',
|
||||
type: 'PUT',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({navId: data.id}),
|
||||
success: function(res){ layer.msg(res.msg); renderTable(); }
|
||||
});
|
||||
} else if (obj.event === 'disable'){
|
||||
$.ajax({
|
||||
url: '/system/nav/disable',
|
||||
type: 'PUT',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({navId: data.id}),
|
||||
success: function(res){ layer.msg(res.msg); renderTable(); }
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
后台导航管理视图(通过插件方式开发)
|
||||
|
||||
URL 前缀变更:
|
||||
- 原蓝图 url_prefix='/nav'(注册到 system_bp → /system/nav/...)
|
||||
- 改为 url_prefix='/system/nav' 直接挂到 app,这样去掉了对 framework 内 system_bp 的依赖
|
||||
- 与用户原始菜单路径 /system/nav 完全兼容,权限码 system:nav:* 保持不变
|
||||
"""
|
||||
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
|
||||
|
||||
# 插件级 Blueprint:直接挂到 app,前缀 /system/nav,独立模板目录
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@bp.get('/add')
|
||||
@authorize("system:nav:add", log=True)
|
||||
def add():
|
||||
return render_template('system/nav/add.html')
|
||||
|
||||
|
||||
@bp.post('/save')
|
||||
@authorize("system:nav:add", log=True)
|
||||
def save():
|
||||
req_json = request.get_json(force=True, silent=True) or {}
|
||||
category = str_escape(req_json.get('category'))
|
||||
title = str_escape(req_json.get('title'))
|
||||
url = str_escape(req_json.get('url'))
|
||||
icon = str_escape(req_json.get('icon')) or 'layui-icon-link'
|
||||
|
||||
if not all([category, title, url]):
|
||||
return fail_api(msg="分类/标题/链接不得为空")
|
||||
|
||||
try:
|
||||
nav = Nav(
|
||||
category=category,
|
||||
title=title,
|
||||
url=url,
|
||||
description=str_escape(req_json.get('description')),
|
||||
icon=icon,
|
||||
sort=int(req_json.get('sort') or 0),
|
||||
enable=int(req_json.get('enable') or 1),
|
||||
is_external=int(req_json.get('isExternal') or 1),
|
||||
create_by=current_user.username if current_user.is_authenticated else 'admin',
|
||||
)
|
||||
db.session.add(nav)
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return fail_api(msg=f"新增失败:{e}")
|
||||
return success_api(msg="新增成功")
|
||||
|
||||
|
||||
@bp.get('/edit')
|
||||
@authorize("system:nav:edit", log=True)
|
||||
def edit():
|
||||
nav_id = request.args.get('navId', type=int)
|
||||
nav = curd.get_one_by_id(Nav, nav_id)
|
||||
if not nav:
|
||||
return fail_api(msg="导航不存在")
|
||||
return render_template('system/nav/edit.html', nav=nav)
|
||||
|
||||
|
||||
@bp.put('/update')
|
||||
@authorize("system:nav:edit", log=True)
|
||||
def update():
|
||||
req_json = request.get_json(force=True, silent=True) or {}
|
||||
nav_id = req_json.get('navId')
|
||||
nav = curd.get_one_by_id(Nav, nav_id)
|
||||
if not nav:
|
||||
return fail_api(msg="导航不存在")
|
||||
|
||||
category = str_escape(req_json.get('category'))
|
||||
title = str_escape(req_json.get('title'))
|
||||
url = str_escape(req_json.get('url'))
|
||||
if not all([category, title, url]):
|
||||
return fail_api(msg="分类/标题/链接不得为空")
|
||||
|
||||
try:
|
||||
nav.category = category
|
||||
nav.title = title
|
||||
nav.url = url
|
||||
nav.description = str_escape(req_json.get('description'))
|
||||
nav.icon = str_escape(req_json.get('icon')) or 'layui-icon-link'
|
||||
nav.sort = int(req_json.get('sort') or 0)
|
||||
nav.enable = int(req_json.get('enable') or 1)
|
||||
nav.is_external = int(req_json.get('isExternal') or 1)
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return fail_api(msg=f"更新失败:{e}")
|
||||
return success_api(msg="更新成功")
|
||||
|
||||
|
||||
@bp.delete('/remove/<int:nav_id>')
|
||||
@authorize("system:nav:remove", log=True)
|
||||
def remove(nav_id):
|
||||
res = curd.delete_one_by_id(Nav, nav_id)
|
||||
if not res:
|
||||
return fail_api(msg="删除失败")
|
||||
return success_api(msg="删除成功")
|
||||
|
||||
|
||||
@bp.put('/enable')
|
||||
@authorize("system:nav:edit", log=True)
|
||||
def enable():
|
||||
req_json = request.get_json(force=True, silent=True) or {}
|
||||
nav_id = req_json.get('navId')
|
||||
if not nav_id:
|
||||
return fail_api(msg="数据错误")
|
||||
res = curd.enable_status(Nav, nav_id)
|
||||
if not res:
|
||||
return fail_api(msg="操作失败")
|
||||
return success_api(msg="已启用")
|
||||
|
||||
|
||||
@bp.put('/disable')
|
||||
@authorize("system:nav:edit", log=True)
|
||||
def dis_enable():
|
||||
req_json = request.get_json(force=True, silent=True) or {}
|
||||
nav_id = req_json.get('navId')
|
||||
if not nav_id:
|
||||
return fail_api(msg="数据错误")
|
||||
res = curd.disable_status(Nav, nav_id)
|
||||
if not res:
|
||||
return fail_api(msg="操作失败")
|
||||
return success_api(msg="已禁用")
|
||||
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
导航分类管理后台视图 — navManager 插件内的子模块。
|
||||
|
||||
URL:/system/nav-category
|
||||
权限:system:nav-category:*
|
||||
"""
|
||||
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 NavCategory
|
||||
from applications.schemas import NavCategorySchema
|
||||
|
||||
dir_path = os.path.dirname(os.path.abspath(__file__))
|
||||
bp = Blueprint(
|
||||
'nav_category', __name__,
|
||||
url_prefix='/system/nav-category',
|
||||
template_folder=os.path.join(dir_path, '..', 'templates'),
|
||||
)
|
||||
|
||||
|
||||
@bp.get('/')
|
||||
@authorize("system:nav-category:main")
|
||||
def main():
|
||||
return render_template('system/nav/category_main.html')
|
||||
|
||||
|
||||
@bp.get('/data')
|
||||
@limiter.limit("60 per minute")
|
||||
@authorize("system:nav-category:main")
|
||||
def data():
|
||||
keyword = str_escape(request.args.get('keyword', type=str))
|
||||
query = NavCategory.query.filter()
|
||||
if keyword:
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
NavCategory.name.contains(keyword),
|
||||
NavCategory.description.contains(keyword),
|
||||
)
|
||||
)
|
||||
items = query.order_by(NavCategory.sort.desc(), NavCategory.id.asc()).layui_paginate()
|
||||
return table_api(
|
||||
msg='请求成功',
|
||||
data=curd.model_to_dicts(schema=NavCategorySchema, data=items.items),
|
||||
count=items.total,
|
||||
)
|
||||
|
||||
|
||||
@bp.get('/add')
|
||||
@authorize("system:nav-category:add", log=True)
|
||||
def add():
|
||||
return render_template('system/nav/category_add.html')
|
||||
|
||||
|
||||
@bp.post('/save')
|
||||
@authorize("system:nav-category:add", log=True)
|
||||
def save():
|
||||
req_json = request.get_json(force=True, silent=True) or {}
|
||||
name = str_escape(req_json.get('name'))
|
||||
if not name:
|
||||
return fail_api(msg='分类名不能为空')
|
||||
if NavCategory.query.filter_by(name=name).first():
|
||||
return fail_api(msg=f'分类"{name}"已存在')
|
||||
|
||||
try:
|
||||
cat = NavCategory(
|
||||
name=name,
|
||||
icon=str_escape(req_json.get('icon')) or 'layui-icon-list',
|
||||
description=str_escape(req_json.get('description')),
|
||||
sort=int(req_json.get('sort') or 0),
|
||||
enable=int(req_json.get('enable') or 1),
|
||||
)
|
||||
db.session.add(cat)
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return fail_api(msg=f'新增失败:{e}')
|
||||
return success_api(msg='新增成功')
|
||||
|
||||
|
||||
@bp.get('/edit')
|
||||
@authorize("system:nav-category:edit", log=True)
|
||||
def edit():
|
||||
cat_id = request.args.get('catId', type=int)
|
||||
cat = curd.get_one_by_id(NavCategory, cat_id)
|
||||
if not cat:
|
||||
return fail_api(msg='分类不存在')
|
||||
return render_template('system/nav/category_edit.html', cat=cat)
|
||||
|
||||
|
||||
@bp.put('/update')
|
||||
@authorize("system:nav-category:edit", log=True)
|
||||
def update():
|
||||
req_json = request.get_json(force=True, silent=True) or {}
|
||||
cat_id = req_json.get('catId')
|
||||
cat = curd.get_one_by_id(NavCategory, cat_id)
|
||||
if not cat:
|
||||
return fail_api(msg='分类不存在')
|
||||
name = str_escape(req_json.get('name'))
|
||||
if not name:
|
||||
return fail_api(msg='分类名不能为空')
|
||||
# name 是否被其他行占用
|
||||
other = NavCategory.query.filter(NavCategory.name == name, NavCategory.id != cat_id).first()
|
||||
if other:
|
||||
return fail_api(msg=f'分类"{name}"已被占用')
|
||||
|
||||
try:
|
||||
cat.name = name
|
||||
cat.icon = str_escape(req_json.get('icon')) or 'layui-icon-list'
|
||||
cat.description = str_escape(req_json.get('description'))
|
||||
cat.sort = int(req_json.get('sort') or 0)
|
||||
cat.enable = int(req_json.get('enable') or 1)
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return fail_api(msg=f'更新失败:{e}')
|
||||
return success_api(msg='更新成功')
|
||||
|
||||
|
||||
@bp.delete('/remove/<int:cat_id>')
|
||||
@authorize("system:nav-category:remove", log=True)
|
||||
def remove(cat_id):
|
||||
"""删除分类时不会动 Nav 记录,但 Nav.category 字段会孤立成 '未分组' 显示"""
|
||||
cat = curd.get_one_by_id(NavCategory, cat_id)
|
||||
if cat is None:
|
||||
return fail_api(msg='分类不存在')
|
||||
res = curd.delete_one_by_id(NavCategory, cat_id)
|
||||
if not res:
|
||||
return fail_api(msg='删除失败')
|
||||
return success_api(msg='删除成功(分类下导航已自动归入「未分组」)')
|
||||
|
||||
|
||||
@bp.put('/enable')
|
||||
@authorize("system:nav-category:edit", log=True)
|
||||
def enable():
|
||||
req_json = request.get_json(force=True, silent=True) or {}
|
||||
cat_id = req_json.get('catId')
|
||||
if not cat_id:
|
||||
return fail_api(msg='数据错误')
|
||||
res = curd.enable_status(NavCategory, cat_id)
|
||||
if not res:
|
||||
return fail_api(msg='操作失败')
|
||||
return success_api(msg='已启用')
|
||||
|
||||
|
||||
@bp.put('/disable')
|
||||
@authorize("system:nav-category:edit", log=True)
|
||||
def dis_enable():
|
||||
req_json = request.get_json(force=True, silent=True) or {}
|
||||
cat_id = req_json.get('catId')
|
||||
if not cat_id:
|
||||
return fail_api(msg='数据错误')
|
||||
res = curd.disable_status(NavCategory, cat_id)
|
||||
if not res:
|
||||
return fail_api(msg='操作失败')
|
||||
return success_api(msg='已禁用')
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"plugin_name": "访问统计",
|
||||
"plugin_version": "1.0.0",
|
||||
"plugin_description": "后台访问统计主页 + 汇总/按天/路径接口 + before_request 全站埋点。前台 /site/* GET 自动累计 PV/UV。"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"""
|
||||
访问统计插件入口
|
||||
|
||||
- event_init:在 app 上注册 /system/stats/* 蓝图
|
||||
- event_finish:挂 before_request 埋点
|
||||
"""
|
||||
from flask import Flask
|
||||
|
||||
from .view.stat import bp, site_bp, record_visit
|
||||
|
||||
|
||||
def event_init(app: Flask):
|
||||
app.register_blueprint(bp)
|
||||
app.register_blueprint(site_bp)
|
||||
print(" * siteStats: registered /system/stats/* and /site/nav/*click blueprints")
|
||||
|
||||
|
||||
def event_finish(app: Flask):
|
||||
@app.before_request
|
||||
def _stat_record():
|
||||
record_visit()
|
||||
print(" * siteStats: registered /site/* before_request hook")
|
||||
@@ -0,0 +1,169 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>访问统计</title>
|
||||
{% include 'system/common/header.html' %}
|
||||
</head>
|
||||
<body class="pear-container">
|
||||
|
||||
<div class="layui-row layui-col-space16" style="margin-top:12px;">
|
||||
<div class="layui-col-md3">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-body layui-text-center">
|
||||
<div style="font-size:32px;color:var(--brand);font-weight:600;" id="stat-total-pv">--</div>
|
||||
<div style="color:#888;margin-top:6px;">累计 PV(页面浏览)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-col-md3">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-body layui-text-center">
|
||||
<div style="font-size:32px;color:#5fb878;font-weight:600;" id="stat-total-uv">--</div>
|
||||
<div style="color:#888;margin-top:6px;">累计 UV(独立访客)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-col-md3">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-body layui-text-center">
|
||||
<div style="font-size:32px;color:#ffb800;font-weight:600;" id="stat-today-pv">--</div>
|
||||
<div style="color:#888;margin-top:6px;">今日 PV</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-col-md3">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-body layui-text-center">
|
||||
<div style="font-size:32px;color:#ff5722;font-weight:600;" id="stat-today-uv">--</div>
|
||||
<div style="color:#888;margin-top:6px;">今日 UV</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-card" style="margin-top:16px;">
|
||||
<div class="layui-card-header">
|
||||
<i class="layui-icon layui-icon-template-1"></i> 最近 7 天趋势
|
||||
<select id="days-select" style="float:right;font-size:13px;height:32px;border-radius:4px;padding:0 8px;border:1px solid #ddd;">
|
||||
<option value="7">近 7 天</option>
|
||||
<option value="14">近 14 天</option>
|
||||
<option value="30">近 30 天</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="layui-card-body">
|
||||
<div id="chart-days" style="height:280px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-row layui-col-space16" style="margin-top:16px;">
|
||||
<div class="layui-col-md7">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header"><i class="layui-icon layui-icon-link"></i> 路径 TOP10</div>
|
||||
<div class="layui-card-body">
|
||||
<table class="layui-table" lay-skin="line">
|
||||
<thead>
|
||||
<tr><th>路径</th><th width="100">PV</th><th width="200">最近访问</th></tr>
|
||||
</thead>
|
||||
<tbody id="paths-tbody">
|
||||
<tr><td colspan="3" class="layui-text-center" style="color:#999;">加载中…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-col-md5">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header"><i class="layui-icon layui-icon-time"></i> 最近访问</div>
|
||||
<div class="layui-card-body">
|
||||
<table class="layui-table" lay-skin="line">
|
||||
<thead>
|
||||
<tr><th>路径</th><th width="120">IP</th><th width="160">时间</th></tr>
|
||||
</thead>
|
||||
<tbody id="latest-tbody">
|
||||
<tr><td colspan="3" class="layui-text-center" style="color:#999;">加载中…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
{% include 'system/common/footer.html' %}
|
||||
<script src="{{ url_for('static', filename='system/component/pear/module/extends/echarts.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='system/component/pear/module/extends/echartsTheme.js') }}"></script>
|
||||
<script>
|
||||
layui.use(['element', 'jquery'], function(){
|
||||
var $ = layui.$;
|
||||
var daysChart = echarts.init(document.getElementById('chart-days'));
|
||||
|
||||
function loadSummary(){
|
||||
$.get('/system/stats/summary', function(res){
|
||||
if (res.code === 0){
|
||||
$('#stat-total-pv').text(res.data.pv_total);
|
||||
$('#stat-total-uv').text(res.data.uv_total);
|
||||
$('#stat-today-pv').text(res.data.today_pv);
|
||||
$('#stat-today-uv').text(res.data.today_uv);
|
||||
var rows = res.data.latest || [];
|
||||
var html = '';
|
||||
if (rows.length === 0){
|
||||
html = '<tr><td colspan="3" class="layui-text-center" style="color:#999;">暂无访问</td></tr>';
|
||||
} else {
|
||||
rows.forEach(function(r){
|
||||
html += '<tr><td>' + (r.path || '-') + '</td><td>' + (r.ip || '-') + '</td><td>' + (r.visit_at || '-') + '</td></tr>';
|
||||
});
|
||||
}
|
||||
$('#latest-tbody').html(html);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loadDays(n){
|
||||
$.get('/system/stats/days?days=' + n, function(res){
|
||||
if (res.code === 0){
|
||||
var data = res.data || [];
|
||||
daysChart.setOption({
|
||||
tooltip: {trigger: 'axis'},
|
||||
legend: {data: ['PV', 'UV']},
|
||||
xAxis: {type: 'category', data: data.map(function(d){return d.day.slice(5);})},
|
||||
yAxis: {type: 'value'},
|
||||
series: [
|
||||
{name: 'PV', type: 'line', smooth: true, data: data.map(function(d){return d.pv;})},
|
||||
{name: 'UV', type: 'line', smooth: true, data: data.map(function(d){return d.uv;})}
|
||||
]
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loadPaths(){
|
||||
$.get('/system/stats/paths', function(res){
|
||||
if (res.code === 0){
|
||||
var rows = res.data || [];
|
||||
var html = '';
|
||||
if (rows.length === 0){
|
||||
html = '<tr><td colspan="3" class="layui-text-center" style="color:#999;">暂无数据</td></tr>';
|
||||
} else {
|
||||
rows.forEach(function(r){
|
||||
html += '<tr><td>' + (r.path || '-') + '</td><td><b>' + (r.pv || 0) + '</b></td><td>' + (r.last_hit || '-') + '</td></tr>';
|
||||
});
|
||||
}
|
||||
$('#paths-tbody').html(html);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$('#days-select').on('change', function(){
|
||||
loadDays(parseInt($(this).val(), 10));
|
||||
});
|
||||
|
||||
loadSummary();
|
||||
loadDays(7);
|
||||
loadPaths();
|
||||
|
||||
window.addEventListener('resize', function(){ daysChart.resize(); });
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
访问统计插件:后台统计主页 + 3 个 JSON 接口 + 全站 before_request 埋点。
|
||||
|
||||
URL 前缀:/system/stats (与框架原路径一致);埋点用 before_request 挂在框架 app 上。
|
||||
"""
|
||||
import datetime
|
||||
import os
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
from flask import Blueprint, render_template, request, jsonify
|
||||
|
||||
from applications.common.utils.rights import authorize
|
||||
from applications.extensions import db
|
||||
from applications.models import VisitLog, PageStat, NavClick
|
||||
|
||||
dir_path = os.path.dirname(os.path.abspath(__file__))
|
||||
bp = Blueprint(
|
||||
'site_stats', __name__,
|
||||
url_prefix='/system/stats',
|
||||
template_folder=os.path.join(dir_path, '..', 'templates'),
|
||||
)
|
||||
|
||||
# 公开埋点 blueprint:无 url_prefix,路径就是 /site/nav/<id>/click
|
||||
site_bp = Blueprint(
|
||||
'site_stats_public', __name__,
|
||||
template_folder=os.path.join(dir_path, '..', 'templates'),
|
||||
)
|
||||
|
||||
|
||||
@bp.get('/')
|
||||
@authorize("site:stats:main")
|
||||
def main():
|
||||
return render_template('system/stat/main.html')
|
||||
|
||||
|
||||
@bp.get('/summary')
|
||||
@authorize("site:stats:main")
|
||||
def summary():
|
||||
today = datetime.date.today().strftime('%Y-%m-%d')
|
||||
pv_total = db.session.query(db.func.coalesce(db.func.sum(PageStat.pv), 0)).scalar() or 0
|
||||
today_pv = (db.session.query(db.func.coalesce(db.func.sum(PageStat.pv), 0))
|
||||
.filter(PageStat.day == today).scalar()) or 0
|
||||
|
||||
today_uv_set = set()
|
||||
for r in PageStat.query.filter(PageStat.day == today, PageStat.uv_ip_set != '').all():
|
||||
for ip in (r.uv_ip_set or '').split(','):
|
||||
ip = ip.strip()
|
||||
if ip:
|
||||
today_uv_set.add(ip)
|
||||
today_uv = len(today_uv_set)
|
||||
|
||||
all_uv_set = set()
|
||||
for r in PageStat.query.filter(PageStat.uv_ip_set != '').all():
|
||||
for ip in (r.uv_ip_set or '').split(','):
|
||||
ip = ip.strip()
|
||||
if ip:
|
||||
all_uv_set.add(ip)
|
||||
uv_all = len(all_uv_set)
|
||||
|
||||
latest = VisitLog.query.order_by(VisitLog.visit_at.desc()).limit(5).all()
|
||||
latest_rows = [{
|
||||
'path': r.path,
|
||||
'ip': r.ip or '-',
|
||||
'visit_at': r.visit_at.strftime('%Y-%m-%d %H:%M:%S') if r.visit_at else '',
|
||||
} for r in latest]
|
||||
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'msg': '请求成功',
|
||||
'data': {
|
||||
'pv_total': int(pv_total),
|
||||
'uv_total': uv_all,
|
||||
'today_pv': int(today_pv),
|
||||
'today_uv': today_uv,
|
||||
'latest': latest_rows,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@bp.get('/days')
|
||||
@authorize("site:stats:main")
|
||||
def days():
|
||||
try:
|
||||
days = max(1, min(30, int(request.args.get('days', 7))))
|
||||
except (TypeError, ValueError):
|
||||
days = 7
|
||||
cutoff = datetime.date.today() - datetime.timedelta(days=days - 1)
|
||||
cutoff_str = cutoff.strftime('%Y-%m-%d')
|
||||
|
||||
rows = PageStat.query.filter(PageStat.day >= cutoff_str).all()
|
||||
pv_by_day = defaultdict(int)
|
||||
uv_by_day = defaultdict(set)
|
||||
for r in rows:
|
||||
pv_by_day[r.day] += r.pv
|
||||
for ip in (r.uv_ip_set or '').split(','):
|
||||
ip = ip.strip()
|
||||
if ip:
|
||||
uv_by_day[r.day].add(ip)
|
||||
|
||||
out = []
|
||||
for i in range(days):
|
||||
d = (cutoff + datetime.timedelta(days=i)).strftime('%Y-%m-%d')
|
||||
out.append({
|
||||
'day': d,
|
||||
'pv': pv_by_day.get(d, 0),
|
||||
'uv': len(uv_by_day.get(d, set())),
|
||||
})
|
||||
return jsonify({'code': 0, 'msg': '请求成功', 'data': out})
|
||||
|
||||
|
||||
@bp.get('/paths')
|
||||
@authorize("site:stats:main")
|
||||
def paths():
|
||||
rows = PageStat.query.filter(PageStat.path != '').order_by(PageStat.pv.desc()).limit(10).all()
|
||||
out = []
|
||||
for r in rows:
|
||||
out.append({
|
||||
'path': r.path,
|
||||
'pv': r.pv or 0,
|
||||
'last_hit': r.last_hit.strftime('%Y-%m-%d %H:%M:%S') if r.last_hit else '',
|
||||
})
|
||||
return jsonify({'code': 0, 'msg': '请求成功', 'data': out})
|
||||
|
||||
|
||||
def record_visit():
|
||||
"""before_request 钩子调用:埋点 /site/* GET。"""
|
||||
if not request.path.startswith('/site'):
|
||||
return
|
||||
if request.method != 'GET':
|
||||
return
|
||||
path = request.path
|
||||
day = datetime.date.today().strftime('%Y-%m-%d')
|
||||
ip = (request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
|
||||
or request.remote_addr or '')
|
||||
ua_short = (request.user_agent.string or '')[:255]
|
||||
referer = request.headers.get('Referer', '')[:255]
|
||||
|
||||
try:
|
||||
log = VisitLog(path=path, referer=referer, ua=ua_short, ip=ip, day=day)
|
||||
db.session.add(log)
|
||||
stat = PageStat.query.filter_by(path=path, day=day).first()
|
||||
if stat is None:
|
||||
stat = PageStat(path=path, day=day, pv=1, uv=1 if ip else 0,
|
||||
uv_ip_set=(ip + ',') if ip else '',
|
||||
last_hit=datetime.datetime.now())
|
||||
db.session.add(stat)
|
||||
else:
|
||||
stat.pv = (stat.pv or 0) + 1
|
||||
stat.last_hit = datetime.datetime.now()
|
||||
existing = (stat.uv_ip_set or '').split(',')
|
||||
existing = [x.strip() for x in existing if x.strip()]
|
||||
if ip and ip not in existing:
|
||||
existing.append(ip)
|
||||
stat.uv_ip_set = ','.join(existing) + ','
|
||||
stat.uv = len(existing)
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
|
||||
|
||||
# ---------- 导航卡片点击埋点 ----------
|
||||
|
||||
# 进程级内存缓存,避免每个聚合页请求都 GROUP BY 一次
|
||||
# 结构:{nav_id: count}
|
||||
_visit_count_cache = {}
|
||||
_visit_count_cache_at = 0.0
|
||||
_VISIT_COUNT_TTL = 60.0 # 秒
|
||||
|
||||
|
||||
def get_nav_visit_counts():
|
||||
"""返回 {nav_id: total_clicks},60 秒内走缓存。"""
|
||||
global _visit_count_cache, _visit_count_cache_at
|
||||
now = time.time()
|
||||
if _visit_count_cache and (now - _visit_count_cache_at) < _VISIT_COUNT_TTL:
|
||||
return _visit_count_cache
|
||||
rows = db.session.query(
|
||||
NavClick.nav_id,
|
||||
db.func.count(NavClick.id),
|
||||
).group_by(NavClick.nav_id).all()
|
||||
counts = {int(nid): int(c) for nid, c in rows}
|
||||
_visit_count_cache = counts
|
||||
_visit_count_cache_at = now
|
||||
return counts
|
||||
|
||||
|
||||
@site_bp.post('/site/nav/<int:nav_id>/click')
|
||||
def nav_click(nav_id):
|
||||
"""导航卡片点击埋点:JS sendBeacon 调用,返回 204 无副作用。"""
|
||||
try:
|
||||
ip = (request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
|
||||
or request.remote_addr or '')
|
||||
day = datetime.date.today().strftime('%Y-%m-%d')
|
||||
db.session.add(NavClick(nav_id=nav_id, day=day, ip=ip[:64]))
|
||||
db.session.commit()
|
||||
# 让缓存尽快失效(最多延迟 60s 也无所谓,统计本来就不需要实时)
|
||||
global _visit_count_cache_at
|
||||
_visit_count_cache_at = 0.0
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
# 204 No Content:无响应体,beacon 友好
|
||||
return ('', 204)
|
||||
@@ -0,0 +1,20 @@
|
||||
"""截图对比分类详情页手机/桌面效果"""
|
||||
import os
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
OUT = r'D:\bwstudio\pear-admin-flask\scripts\screenshots\compare_home'
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(headless=True, args=['--no-sandbox', '--disable-gpu'])
|
||||
for vp_name, vp in [('mobile', {'width':390,'height':844}), ('desktop', {'width':1440,'height':900})]:
|
||||
ctx = browser.new_context(viewport=vp)
|
||||
page = ctx.new_page()
|
||||
url = 'http://192.168.1.10:5000/site/category/%E4%BC%91%E9%97%B2%E5%A8%B1%E4%B9%90'
|
||||
page.goto(url, wait_until='networkidle')
|
||||
page.wait_for_timeout(1500)
|
||||
path = os.path.join(OUT, f'{vp_name}_category_local.png')
|
||||
page.screenshot(path=path, full_page=True)
|
||||
print(f'-> {path}')
|
||||
ctx.close()
|
||||
browser.close()
|
||||
@@ -0,0 +1,36 @@
|
||||
"""对比截图:本地手机端首页 vs 参考站首页(桌面 + 手机两套视口)"""
|
||||
import os
|
||||
import time
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
OUT = r'D:\bwstudio\pear-admin-flask\scripts\screenshots\compare_home'
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
URLS = {
|
||||
'local': 'http://192.168.1.10:5000/',
|
||||
'ref': 'http://192.168.1.100/',
|
||||
}
|
||||
|
||||
VIEWPORTS = {
|
||||
'desktop': {'width': 1440, 'height': 900},
|
||||
'mobile': {'width': 390, 'height': 844}, # iPhone 13
|
||||
}
|
||||
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(headless=True, args=['--no-sandbox', '--disable-gpu'])
|
||||
for vp_name, vp in VIEWPORTS.items():
|
||||
ctx = browser.new_context(viewport=vp, device_scale_factor=2,
|
||||
user_agent='Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15' if vp_name=='mobile' else None)
|
||||
page = ctx.new_page()
|
||||
for label, url in URLS.items():
|
||||
print(f'>> [{vp_name}] {label}: {url}')
|
||||
try:
|
||||
page.goto(url, wait_until='networkidle', timeout=20000)
|
||||
time.sleep(2)
|
||||
path = os.path.join(OUT, f'{vp_name}_{label}.png')
|
||||
page.screenshot(path=path, full_page=True)
|
||||
print(f' -> {path}')
|
||||
except Exception as e:
|
||||
print(f' ERR: {e}')
|
||||
ctx.close()
|
||||
browser.close()
|
||||
@@ -0,0 +1,17 @@
|
||||
"""调试抽屉图标渲染"""
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(headless=True, args=['--no-sandbox', '--disable-gpu'])
|
||||
ctx = browser.new_context(viewport={'width':390,'height':844}, device_scale_factor=2)
|
||||
page = ctx.new_page()
|
||||
page.goto('http://192.168.1.10:5000/', wait_until='networkidle')
|
||||
page.wait_for_timeout(1000)
|
||||
page.click('#cat-trigger-mobile')
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
list_html = page.locator('#cat-drawer-list').evaluate('el => el.innerHTML')
|
||||
print('list innerHTML:', list_html[:800])
|
||||
print('cat-icon count:', page.locator('#cat-drawer-list .cat-icon').count())
|
||||
|
||||
browser.close()
|
||||
@@ -0,0 +1,56 @@
|
||||
"""调试主题按钮点击"""
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(headless=True, args=['--no-sandbox', '--disable-gpu'])
|
||||
ctx = browser.new_context(viewport={'width':1440,'height':900})
|
||||
page = ctx.new_page()
|
||||
|
||||
msgs = []
|
||||
page.on('console', lambda m: msgs.append(f'[{m.type}] {m.text}'))
|
||||
page.on('pageerror', lambda e: msgs.append(f'[PAGE-ERR] {e}'))
|
||||
|
||||
page.goto('http://192.168.1.10:5000/', wait_until='networkidle')
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
print('=== 1. 检查按钮存在 ===')
|
||||
btn_count = page.locator('#theme-toggle').count()
|
||||
print(f' #theme-toggle count: {btn_count}')
|
||||
btn_html = page.locator('#theme-toggle').evaluate('el => el.outerHTML') if btn_count else 'N/A'
|
||||
print(f' btn html: {btn_html[:200]}')
|
||||
|
||||
print('=== 2. 检查菜单存在 ===')
|
||||
menu_count = page.locator('#theme-menu').count()
|
||||
print(f' #theme-menu count: {menu_count}')
|
||||
if menu_count:
|
||||
menu_html = page.locator('#theme-menu').evaluate('el => el.outerHTML.substring(0, 300)')
|
||||
print(f' menu html: {menu_html}')
|
||||
|
||||
print('=== 3. 检查菜单项 ===')
|
||||
items = page.locator('.theme-menu li').count()
|
||||
print(f' li count: {items}')
|
||||
|
||||
print('=== 4. 检查 z-index / 遮挡 ===')
|
||||
btn_box = page.locator('#theme-toggle').bounding_box()
|
||||
print(f' btn box: {btn_box}')
|
||||
menu_box = page.locator('#theme-menu').bounding_box()
|
||||
print(f' menu box (closed): {menu_box}')
|
||||
|
||||
print('=== 5. 点击按钮 ===')
|
||||
page.click('#theme-toggle')
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
print(' menu open class:', page.locator('#theme-menu').evaluate('el => el.className'))
|
||||
print(' menu opacity:', page.locator('#theme-menu').evaluate('el => window.getComputedStyle(el).opacity'))
|
||||
print(' menu pointer-events:', page.locator('#theme-menu').evaluate('el => window.getComputedStyle(el).pointerEvents'))
|
||||
print(' menu display:', page.locator('#theme-menu').evaluate('el => window.getComputedStyle(el).display'))
|
||||
print(' menu visibility:', page.locator('#theme-menu').evaluate('el => window.getComputedStyle(el).visibility'))
|
||||
|
||||
box2 = page.locator('#theme-menu').bounding_box()
|
||||
print(f' menu box (after click): {box2}')
|
||||
|
||||
print('=== 6. 控制台 / 错误日志 ===')
|
||||
for m in msgs[-30:]:
|
||||
print(' ', m)
|
||||
|
||||
browser.close()
|
||||
@@ -0,0 +1,7 @@
|
||||
"""一次性脚本:建 site_nav_click 表(走 Flask app factory)。"""
|
||||
from app import app
|
||||
from applications.extensions import db
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
print("OK: site_nav_click ensured")
|
||||
@@ -0,0 +1,18 @@
|
||||
"""截图本地手机端抽屉展开状态"""
|
||||
import os
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
OUT = r'D:\bwstudio\pear-admin-flask\scripts\screenshots\compare_home'
|
||||
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(headless=True, args=['--no-sandbox', '--disable-gpu'])
|
||||
ctx = browser.new_context(viewport={'width':390,'height':844}, device_scale_factor=2)
|
||||
page = ctx.new_page()
|
||||
page.goto('http://192.168.1.10:5000/', wait_until='networkidle')
|
||||
page.wait_for_timeout(1000)
|
||||
page.click('#cat-trigger-mobile')
|
||||
page.wait_for_timeout(800)
|
||||
path = os.path.join(OUT, 'mobile_local_drawer_v2.png')
|
||||
page.screenshot(path=path, full_page=False)
|
||||
print(f'-> {path}')
|
||||
browser.close()
|
||||
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
导航分类功能端到端探针
|
||||
======================
|
||||
|
||||
不依赖浏览器 + ddddocr + 调试 header,直接用 Flask test_client 走完整流程:
|
||||
|
||||
1. 登录 admin
|
||||
2. GET /system/nav-category/ (主页面 HTML)
|
||||
3. GET /system/nav-category/data (table_api JSON)
|
||||
4. POST /system/nav-category/save (新增)
|
||||
5. PUT /system/nav-category/update (更新)
|
||||
6. PUT /system/nav-category/enable (启用/禁用)
|
||||
7. DELETE /system/nav-category/remove/<id> (删除)
|
||||
8. 验证每个 API 的 {code, message, data} 协议
|
||||
|
||||
退出码:全部通过 → 0;任一失败 → 1
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
sys.path.insert(0, r'D:\bwstudio\pear-admin-flask')
|
||||
|
||||
from applications import create_app
|
||||
|
||||
app = create_app()
|
||||
client = app.test_client()
|
||||
|
||||
|
||||
def _login():
|
||||
"""admin/123456 登录拿 cookie session。"""
|
||||
# 拿一张 captcha —— 通过 test_client 共享 session_transaction
|
||||
r = client.get('/system/passport/getCaptcha')
|
||||
assert r.status_code == 200, r.status_code
|
||||
|
||||
with client.session_transaction() as sess:
|
||||
code = sess.get('code') or ''
|
||||
code = code.lower()
|
||||
assert code, 'captcha code missing'
|
||||
|
||||
r = client.post('/system/passport/login', data={
|
||||
'username': 'admin',
|
||||
'password': '123456',
|
||||
'captcha': code,
|
||||
}, follow_redirects=False)
|
||||
assert r.status_code in (200, 302), r.status_code
|
||||
|
||||
|
||||
def _show(label, resp):
|
||||
body = resp.get_data(as_text=True)
|
||||
print(f' [{resp.status_code}] {label} -> {body[:200]}{"..." if len(body) > 200 else ""}')
|
||||
|
||||
|
||||
def main():
|
||||
_login()
|
||||
print('=== 已登录,开始 E2E 探针 ===\n')
|
||||
|
||||
# 1. 分类主页面 HTML
|
||||
r = client.get('/system/nav-category/')
|
||||
print(f'[HTML main] {r.status_code} content-type={r.content_type}')
|
||||
assert r.status_code == 200
|
||||
assert b'nav-category/data' in r.data or b'\u5bfc\u822a\u5206\u7c7b' in r.data, 'main 页面未包含预期内容'
|
||||
print(' [OK] 分类主页面返回 200,含预期关键字\n')
|
||||
|
||||
# 2. data 接口(table_api 协议:{code, data, count, msg, limit})
|
||||
r = client.get('/system/nav-category/data')
|
||||
assert r.status_code == 200
|
||||
payload = r.get_json()
|
||||
assert payload['code'] == 0, payload
|
||||
before_ids = {row['id'] for row in payload['data']}
|
||||
print(f' [OK] data 接口 code=0,count={payload["count"]},已有分类 {len(before_ids)} 条:{sorted(before_ids)}\n')
|
||||
|
||||
# 3. 新增(success_api 协议:{success, msg})
|
||||
unique_name = f'E2E临时分类{int(__import__("time").time())}'
|
||||
r = client.post('/system/nav-category/save',
|
||||
json={'name': unique_name, 'icon': 'layui-icon-test',
|
||||
'description': 'e2e created', 'sort': 1, 'enable': 1})
|
||||
payload = r.get_json()
|
||||
assert payload.get('success') is True, payload
|
||||
print(f' [OK] 新增成功:{unique_name}\n')
|
||||
|
||||
# 4. 拿到 id
|
||||
r = client.get('/system/nav-category/data')
|
||||
payload = r.get_json()
|
||||
new_id = next((row['id'] for row in payload['data'] if row['name'] == unique_name), None)
|
||||
assert new_id is not None, '新增的分类查不到'
|
||||
print(f' [OK] 查回新增分类 id={new_id}\n')
|
||||
|
||||
# 5. 更新
|
||||
r = client.put('/system/nav-category/update',
|
||||
json={'catId': new_id, 'name': unique_name + '_改',
|
||||
'icon': 'layui-icon-edit', 'description': 'e2e updated',
|
||||
'sort': 99, 'enable': 1})
|
||||
payload = r.get_json()
|
||||
assert payload.get('success') is True, payload
|
||||
print(f' [OK] 更新成功\n')
|
||||
|
||||
# 6. 禁用 → 启用
|
||||
r = client.put('/system/nav-category/disable', json={'catId': new_id})
|
||||
assert r.get_json().get('success') is True
|
||||
r = client.put('/system/nav-category/enable', json={'catId': new_id})
|
||||
assert r.get_json().get('success') is True
|
||||
print(f' [OK] 禁用→启用通过\n')
|
||||
|
||||
# 7. 删除
|
||||
r = client.delete(f'/system/nav-category/remove/{new_id}')
|
||||
assert r.get_json().get('success') is True, r.get_json()
|
||||
print(f' [OK] 删除成功\n')
|
||||
|
||||
# 8. 查回 count 必须等于开始前
|
||||
r = client.get('/system/nav-category/data')
|
||||
payload = r.get_json()
|
||||
after_ids = {row['id'] for row in payload['data']}
|
||||
assert before_ids == after_ids, f'分类集合变化:before={before_ids}, after={after_ids}'
|
||||
print(f' [OK] 删除后 count 还原,新增的 id 已不在集合中\n')
|
||||
|
||||
# 9. 重名检测
|
||||
existing_name = None
|
||||
if payload['data']:
|
||||
existing_name = payload['data'][0]['name']
|
||||
if existing_name:
|
||||
r = client.post('/system/nav-category/save',
|
||||
json={'name': existing_name, 'icon': 'x', 'description': '', 'sort': 0, 'enable': 1})
|
||||
body = r.get_json()
|
||||
assert body.get('success') is False, body
|
||||
print(f' [OK] 重名拦截正常:{body["msg"]}\n')
|
||||
|
||||
print('=== ALL PASS ===')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
sys.exit(0)
|
||||
except AssertionError as e:
|
||||
print(f'\n!!! ASSERT FAIL: {e}')
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f'\n!!! UNEXPECTED: {e}')
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,64 @@
|
||||
import sys, time
|
||||
sys.path.insert(0, r'D:\bwstudio\pear-admin-flask')
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = 'http://127.0.0.1:5000'
|
||||
|
||||
def login(page):
|
||||
page.goto(f'{BASE}/system/passport/login', wait_until='networkidle')
|
||||
page.wait_for_selector('input[name="captcha"]')
|
||||
# 用一个新的 context request 取同一个 session 的 captcha —— 因为 page.request 和 page 可能共享 cookie
|
||||
resp = page.request.get(f'{BASE}/system/passport/getCaptcha')
|
||||
code = resp.headers.get('x-captcha-code', '').lower()
|
||||
if not code:
|
||||
raise RuntimeError('no captcha code')
|
||||
# 再强制刷新一遍 login 页上的 captcha 图片,确保 server session code == 这张图绑定的那个
|
||||
# 因为 server 端 captcha() 会覆写 session['code'] 为最近一张图
|
||||
page.reload(wait_until='networkidle')
|
||||
page.wait_for_selector('input[name="captcha"]')
|
||||
resp = page.request.get(f'{BASE}/system/passport/getCaptcha')
|
||||
code = resp.headers.get('x-captcha-code', '').lower()
|
||||
print('captcha code =', code)
|
||||
page.fill('input[name="username"]', 'admin')
|
||||
page.fill('input[name="password"]', '123456')
|
||||
page.fill('input[name="captcha"]', code)
|
||||
page.click('button[lay-filter="login"]')
|
||||
# 等待跳转离开 login 页
|
||||
time.sleep(2)
|
||||
print('after login url =', page.url)
|
||||
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(headless=True, args=['--no-sandbox', '--disable-gpu'])
|
||||
ctx = browser.new_context()
|
||||
page = ctx.new_page()
|
||||
|
||||
msgs = []
|
||||
page.on('console', lambda m: msgs.append(f'[{m.type}] {m.text}'))
|
||||
page.on('pageerror', lambda e: msgs.append(f'[PAGE-ERR] {e}'))
|
||||
|
||||
login(page)
|
||||
|
||||
for menu_path in ['/system/nav/', '/system/friend/', '/system/about/', '/system/stats/']:
|
||||
msgs.clear()
|
||||
page.on('console', lambda m: msgs.append(f'[{m.type}] {m.text}'))
|
||||
page.on('pageerror', lambda e: msgs.append(f'[PAGE-ERR] {e}'))
|
||||
print(f'\n===== Visit {menu_path} =====')
|
||||
page.goto(BASE + menu_path, wait_until='networkidle')
|
||||
time.sleep(2.5)
|
||||
row_count = page.locator('.layui-table tbody tr').count()
|
||||
has_loading = page.locator('.layui-table-init').count()
|
||||
none_elem = page.locator('.layui-table-none').count()
|
||||
empty_text = ''
|
||||
if none_elem:
|
||||
empty_text = page.locator('.layui-table-none').first.inner_text() or ''
|
||||
print(f' rows={row_count}, init={has_loading}, none={none_elem}, none_text={empty_text!r}')
|
||||
out = menu_path.strip('/').replace('/', '_') + '.png'
|
||||
path = 'D:\\bwstudio\\pear-admin-flask\\scripts\\screenshots\\' + out
|
||||
import os
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
page.screenshot(path=path, full_page=True)
|
||||
print(f' screenshot -> {path}')
|
||||
for m in msgs[-15:]:
|
||||
print(' ', m)
|
||||
|
||||
browser.close()
|
||||
@@ -0,0 +1,24 @@
|
||||
"""抓参考站 mobile 抽屉展开后的截图"""
|
||||
import os
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
OUT = r'D:\bwstudio\pear-admin-flask\scripts\screenshots\compare_home'
|
||||
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(headless=True, args=['--no-sandbox', '--disable-gpu'])
|
||||
ctx = browser.new_context(viewport={'width':390,'height':844}, device_scale_factor=2,
|
||||
user_agent='Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15')
|
||||
page = ctx.new_page()
|
||||
page.goto('http://192.168.1.100/', wait_until='networkidle')
|
||||
page.wait_for_timeout(1500)
|
||||
|
||||
# 点击左上角汉堡按钮
|
||||
try:
|
||||
page.click('span.mdui-btn-icon', timeout=5000)
|
||||
page.wait_for_timeout(1500)
|
||||
except Exception as e:
|
||||
print(f'click err: {e}')
|
||||
path = os.path.join(OUT, 'mobile_ref_drawer.png')
|
||||
page.screenshot(path=path, full_page=False)
|
||||
print(f'-> {path}')
|
||||
browser.close()
|
||||
@@ -0,0 +1,23 @@
|
||||
"""截图:auto 项 + 5 主题展开"""
|
||||
import os
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
OUT = r'D:\bwstudio\pear-admin-flask\scripts\screenshots\themes'
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(headless=True, args=['--no-sandbox', '--disable-gpu'])
|
||||
ctx = browser.new_context(viewport={'width':1440,'height':900})
|
||||
page = ctx.new_page()
|
||||
page.goto('http://127.0.0.1:5000/', wait_until='networkidle')
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
# 展开菜单
|
||||
page.click('#theme-toggle')
|
||||
page.wait_for_timeout(300)
|
||||
page.evaluate('document.getElementById("theme-menu").classList.add("open")')
|
||||
page.wait_for_timeout(300)
|
||||
path = os.path.join(OUT, 'menu_open_v2.png')
|
||||
page.screenshot(path=path, full_page=False)
|
||||
print(f'-> {path}')
|
||||
browser.close()
|
||||
@@ -0,0 +1,19 @@
|
||||
"""测试点击抽屉分类跳转"""
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(headless=True, args=['--no-sandbox', '--disable-gpu'])
|
||||
ctx = browser.new_context(viewport={'width':390,'height':844}, device_scale_factor=2)
|
||||
page = ctx.new_page()
|
||||
page.goto('http://192.168.1.10:5000/', wait_until='networkidle')
|
||||
page.wait_for_timeout(1000)
|
||||
page.click('#cat-trigger-mobile')
|
||||
page.wait_for_timeout(500)
|
||||
# 点击第二个分类
|
||||
page.locator('#cat-drawer-list li a').nth(1).click()
|
||||
page.wait_for_timeout(800)
|
||||
print('URL after click:', page.url)
|
||||
# 检查当前滚动位置是否在目标 section 附近
|
||||
active_id = page.evaluate('() => { var s = document.querySelector("section.section"); return s ? s.id : null; }')
|
||||
print('first section id:', active_id)
|
||||
browser.close()
|
||||
@@ -0,0 +1,32 @@
|
||||
"""简单交互测试:手机端搜索过滤 + 分类 Tab 点击"""
|
||||
import time
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(headless=True, args=['--no-sandbox', '--disable-gpu'])
|
||||
ctx = browser.new_context(viewport={'width':390,'height':844}, device_scale_factor=2)
|
||||
page = ctx.new_page()
|
||||
page.goto('http://192.168.1.10:5000/', wait_until='networkidle')
|
||||
page.wait_for_timeout(1000)
|
||||
|
||||
# 1. 搜索过滤
|
||||
page.fill('#search-input', '百度')
|
||||
page.wait_for_timeout(800)
|
||||
visible = page.locator('.grid .card:not([style*="display: none"])').count()
|
||||
hidden = page.locator('.grid .card[style*="display: none"]').count()
|
||||
print(f'搜索"百度":可见 {visible} 条,隐藏 {hidden} 条')
|
||||
|
||||
# 2. 清空搜索
|
||||
page.fill('#search-input', '')
|
||||
page.wait_for_timeout(800)
|
||||
visible2 = page.locator('.grid .card:not([style*="display: none"])').count()
|
||||
print(f'清空后:可见 {visible2} 条')
|
||||
|
||||
# 3. 点击第二个分类 Tab
|
||||
tabs = page.locator('.cat-nav-inner a')
|
||||
if tabs.count() > 1:
|
||||
tabs.nth(1).click()
|
||||
page.wait_for_timeout(800)
|
||||
print('点击分类 Tab 后 URL:', page.url)
|
||||
|
||||
browser.close()
|
||||
@@ -0,0 +1,41 @@
|
||||
"""4 种主题切换 + 截图"""
|
||||
import os
|
||||
import time
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
OUT = r'D:\bwstudio\pear-admin-flask\scripts\screenshots\themes'
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
THEMES = ['default', 'sunset', 'forest', 'dark']
|
||||
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(headless=True, args=['--no-sandbox', '--disable-gpu'])
|
||||
ctx = browser.new_context(viewport={'width':1440,'height':900})
|
||||
page = ctx.new_page()
|
||||
page.goto('http://192.168.1.10:5000/', wait_until='networkidle')
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
for theme in THEMES:
|
||||
# 切换主题
|
||||
page.evaluate(f'document.documentElement.setAttribute("data-theme", "{theme}"); localStorage.setItem("bw-theme", "{theme}");')
|
||||
page.wait_for_timeout(500)
|
||||
attr = page.evaluate('document.documentElement.getAttribute("data-theme")')
|
||||
print(f' theme={theme} -> html data-theme={attr}')
|
||||
path = os.path.join(OUT, f'{theme}.png')
|
||||
page.screenshot(path=path, full_page=False)
|
||||
print(f' -> {path}')
|
||||
|
||||
# 验证下拉菜单
|
||||
page.evaluate('document.documentElement.setAttribute("data-theme", "default"); localStorage.removeItem("bw-theme");')
|
||||
page.reload(wait_until='networkidle')
|
||||
page.wait_for_timeout(800)
|
||||
page.click('#theme-toggle')
|
||||
page.wait_for_timeout(300)
|
||||
# JS 强开菜单,避免点击被 document 监听关闭
|
||||
page.evaluate('document.getElementById("theme-menu").classList.add("open")')
|
||||
page.wait_for_timeout(500)
|
||||
path = os.path.join(OUT, 'menu_open.png')
|
||||
page.screenshot(path=path, full_page=False)
|
||||
print(f' menu -> {path}')
|
||||
|
||||
browser.close()
|
||||
@@ -0,0 +1,75 @@
|
||||
"""A+E 主题自适应验证(每个场景前清 localStorage)"""
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(headless=True, args=['--no-sandbox', '--disable-gpu'])
|
||||
ctx = browser.new_context(viewport={'width':1440,'height':900})
|
||||
page = ctx.new_page()
|
||||
|
||||
msgs = []
|
||||
page.on('pageerror', lambda e: msgs.append(f'[ERR] {e}'))
|
||||
|
||||
# 场景 1:saved=auto + system=light → 应 default
|
||||
page.goto('http://127.0.0.1:5000/', wait_until='networkidle')
|
||||
page.evaluate('localStorage.setItem("bw-theme", "auto")')
|
||||
page.reload(wait_until='networkidle')
|
||||
page.wait_for_timeout(500)
|
||||
theme = page.evaluate('document.documentElement.getAttribute("data-theme")')
|
||||
label = page.locator('#theme-toggle-label').inner_text()
|
||||
sys_dark = page.evaluate('window.matchMedia("(prefers-color-scheme: dark)").matches')
|
||||
print(f'[1] saved=auto, system_dark={sys_dark} -> data-theme={theme!r}, label={label!r} (expect default, "跟随系统")')
|
||||
|
||||
# 场景 2:saved=dark + system=light → 应 dark
|
||||
page.evaluate('localStorage.setItem("bw-theme", "dark")')
|
||||
page.reload(wait_until='networkidle')
|
||||
page.wait_for_timeout(500)
|
||||
theme = page.evaluate('document.documentElement.getAttribute("data-theme")')
|
||||
label = page.locator('#theme-toggle-label').inner_text()
|
||||
print(f'[2] saved=dark, system_dark={sys_dark} -> data-theme={theme!r}, label={label!r} (expect dark, "暗色")')
|
||||
|
||||
# 场景 3:saved=default → 应 default
|
||||
page.evaluate('localStorage.setItem("bw-theme", "default")')
|
||||
page.reload(wait_until='networkidle')
|
||||
page.wait_for_timeout(500)
|
||||
theme = page.evaluate('document.documentElement.getAttribute("data-theme")')
|
||||
label = page.locator('#theme-toggle-label').inner_text()
|
||||
print(f'[3] saved=default -> data-theme={theme!r}, label={label!r} (expect default, "蓝绿")')
|
||||
|
||||
# 场景 4:无 localStorage + system=light → 应 default(不持久化为 auto)
|
||||
page.evaluate('localStorage.removeItem("bw-theme")')
|
||||
page.reload(wait_until='networkidle')
|
||||
page.wait_for_timeout(500)
|
||||
theme = page.evaluate('document.documentElement.getAttribute("data-theme")')
|
||||
label = page.locator('#theme-toggle-label').inner_text()
|
||||
saved_after = page.evaluate('localStorage.getItem("bw-theme")')
|
||||
print(f'[4] no saved, system_dark={sys_dark} -> data-theme={theme!r}, label={label!r}, after-save={saved_after!r}')
|
||||
print(f' (system=light 时应 default,不写 auto;只有 system=dark 才写 auto)')
|
||||
|
||||
# 场景 5:从 sunset 点 auto → 应 default + label="跟随系统" + saved=auto
|
||||
page.evaluate('localStorage.setItem("bw-theme", "sunset")')
|
||||
page.reload(wait_until='networkidle')
|
||||
page.wait_for_timeout(500)
|
||||
page.click('#theme-toggle')
|
||||
page.wait_for_timeout(300)
|
||||
page.click('.theme-menu li[data-theme="auto"]')
|
||||
page.wait_for_timeout(500)
|
||||
theme = page.evaluate('document.documentElement.getAttribute("data-theme")')
|
||||
label = page.locator('#theme-toggle-label').inner_text()
|
||||
saved = page.evaluate('localStorage.getItem("bw-theme")')
|
||||
print(f'[5] click auto -> data-theme={theme!r}, label={label!r}, saved={saved!r} (expect default, "跟随系统", auto)')
|
||||
|
||||
# 场景 6:早期脚本无闪烁(head 检查 data-theme 在脚本运行前已设)
|
||||
page.evaluate('localStorage.setItem("bw-theme", "dark")')
|
||||
early_attr = page.evaluate('''
|
||||
() => {
|
||||
return new Promise(resolve => {
|
||||
const iframe = document.createElement('iframe');
|
||||
// 没法直接测 early-script 在主页面执行前的状态,改用:再次模拟 reload 看 meta
|
||||
resolve(null);
|
||||
});
|
||||
}
|
||||
''')
|
||||
|
||||
print()
|
||||
print('JS errors:', msgs[-5:])
|
||||
browser.close()
|
||||
@@ -0,0 +1,30 @@
|
||||
"""验证点击卡片触发 sendBeacon,数据库 +1。"""
|
||||
from playwright.sync_api import sync_playwright
|
||||
from app import app
|
||||
from applications.extensions import db
|
||||
from applications.models import NavClick
|
||||
|
||||
with app.app_context():
|
||||
before = db.session.query(db.func.count(NavClick.id)).filter(NavClick.nav_id == 1).scalar()
|
||||
print('before click:', before)
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch()
|
||||
ctx = browser.new_context(viewport={'width': 1280, 'height': 800})
|
||||
page = ctx.new_page()
|
||||
page.goto('http://127.0.0.1:5000/site/', wait_until='networkidle')
|
||||
# 拦截请求,看是不是 POST
|
||||
reqs = []
|
||||
page.on('request', lambda r: reqs.append(r) if '/site/nav/1/click' in r.url else None)
|
||||
# 点击第一张卡片
|
||||
card = page.locator('.card[data-nav-id="1"]').first
|
||||
card.click(modifiers=['Meta']) # 按住 Meta 打开新标签,保持当前页
|
||||
page.wait_for_timeout(500)
|
||||
print('captured requests:', [(r.method, r.url) for r in reqs])
|
||||
browser.close()
|
||||
|
||||
with app.app_context():
|
||||
after = db.session.query(db.func.count(NavClick.id)).filter(NavClick.nav_id == 1).scalar()
|
||||
print('after click:', after)
|
||||
assert after == before + 1, f"Expected {before+1}, got {after}"
|
||||
print('PASS: beacon recorded click')
|
||||
@@ -0,0 +1,41 @@
|
||||
"""验证点击菜单项切换主题"""
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(headless=True, args=['--no-sandbox', '--disable-gpu'])
|
||||
ctx = browser.new_context(viewport={'width':1440,'height':900})
|
||||
page = ctx.new_page()
|
||||
page.goto('http://192.168.1.10:5000/', wait_until='networkidle')
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
# 初始状态
|
||||
print('初始主题:', page.evaluate('document.documentElement.getAttribute("data-theme")'))
|
||||
|
||||
# 点 sunset
|
||||
page.click('#theme-toggle')
|
||||
page.wait_for_timeout(300)
|
||||
page.click('.theme-menu li[data-theme="sunset"]')
|
||||
page.wait_for_timeout(300)
|
||||
print('点 sunset 后:', page.evaluate('document.documentElement.getAttribute("data-theme")'))
|
||||
print(' 按钮文字:', page.locator('#theme-toggle-label').inner_text())
|
||||
|
||||
# 点 forest
|
||||
page.click('#theme-toggle')
|
||||
page.wait_for_timeout(300)
|
||||
page.click('.theme-menu li[data-theme="forest"]')
|
||||
page.wait_for_timeout(300)
|
||||
print('点 forest 后:', page.evaluate('document.documentElement.getAttribute("data-theme")'))
|
||||
print(' 按钮文字:', page.locator('#theme-toggle-label').inner_text())
|
||||
|
||||
# 点 dark
|
||||
page.click('#theme-toggle')
|
||||
page.wait_for_timeout(300)
|
||||
page.click('.theme-menu li[data-theme="dark"]')
|
||||
page.wait_for_timeout(300)
|
||||
print('点 dark 后:', page.evaluate('document.documentElement.getAttribute("data-theme")'))
|
||||
print(' 按钮文字:', page.locator('#theme-toggle-label').inner_text())
|
||||
|
||||
# localStorage
|
||||
print('localStorage:', page.evaluate('localStorage.getItem("bw-theme")'))
|
||||
|
||||
browser.close()
|
||||
@@ -0,0 +1,24 @@
|
||||
"""验收:访问首页与移动端首页,截图检查 visit-count 样式与文字。"""
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
with sync_playwright() as p:
|
||||
# 桌面
|
||||
desk = p.chromium.launch().new_context(viewport={'width': 1280, 'height': 800})
|
||||
page = desk.new_page()
|
||||
page.goto('http://127.0.0.1:5000/site/', wait_until='networkidle')
|
||||
page.screenshot(path='D:/bwstudio/pear-admin-flask/scripts/_verify_visit_desktop.png', full_page=True)
|
||||
# 检查 nav_id=1 卡片右下角文字
|
||||
cnt1 = page.evaluate("() => { var c = document.querySelector('.card[data-nav-id=\"1\"] .visit-count'); return c ? c.textContent.trim() : null; }")
|
||||
print('nav_id=1 visit-count text:', cnt1)
|
||||
cnt2 = page.evaluate("() => { var c = document.querySelector('.card[data-nav-id=\"2\"] .visit-count'); return c ? c.textContent.trim() : null; }")
|
||||
print('nav_id=2 visit-count text:', cnt2)
|
||||
desk.close()
|
||||
|
||||
# 移动
|
||||
mob = p.chromium.launch().new_context(viewport={'width': 390, 'height': 844}, is_mobile=True)
|
||||
page2 = mob.new_page()
|
||||
page2.goto('http://127.0.0.1:5000/site/', wait_until='networkidle')
|
||||
page2.screenshot(path='D:/bwstudio/pear-admin-flask/scripts/_verify_visit_mobile.png', full_page=True)
|
||||
mob.close()
|
||||
|
||||
print("OK")
|
||||
@@ -0,0 +1,87 @@
|
||||
{% extends 'public/base.html' %}
|
||||
|
||||
{% block subtitle %}关于本站 · 备案信息 · 联系方式{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="card-section">
|
||||
<div class="about-header">
|
||||
<h1>{{ about.site_name or '关于本站' }}</h1>
|
||||
{% if about.icp %}
|
||||
<div class="icp-line">
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">
|
||||
<span class="beian-shield" aria-hidden="true">🛡</span>
|
||||
{{ about.icp }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if content_html %}
|
||||
<section class="about-content markdown-body">
|
||||
{{ content_html | safe }}
|
||||
</section>
|
||||
{% else %}
|
||||
<section class="about-content empty">
|
||||
<p style="color:#999;text-align:center;padding:60px 20px;">
|
||||
<span style="font-size:48px;">📝</span><br>
|
||||
暂无「关于本站」内容。请到
|
||||
<a href="/system/passport/login">后台</a> → 网站管理 → 关于本站 编辑。
|
||||
</p>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% set has_contact =
|
||||
about.contact_email or about.contact_qq or about.contact_wechat
|
||||
or about.contact_telegram or about.contact_github %}
|
||||
{% if has_contact %}
|
||||
<section class="contact-grid">
|
||||
<h2>联系方式</h2>
|
||||
<ul>
|
||||
{% if about.contact_email %}
|
||||
<li><b>邮箱:</b>
|
||||
<a href="mailto:{{ about.contact_email }}">{{ about.contact_email }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if about.contact_qq %}
|
||||
<li><b>QQ:</b>{{ about.contact_qq }}</li>
|
||||
{% endif %}
|
||||
{% if about.contact_wechat %}
|
||||
<li><b>微信:</b>{{ about.contact_wechat }}</li>
|
||||
{% endif %}
|
||||
{% if about.contact_telegram %}
|
||||
<li><b>Telegram:</b>
|
||||
{% if about.contact_telegram.startswith('http') %}
|
||||
<a href="{{ about.contact_telegram }}" target="_blank" rel="noopener noreferrer">{{ about.contact_telegram }}</a>
|
||||
{% else %}
|
||||
{{ about.contact_telegram }}
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if about.contact_github %}
|
||||
<li><b>GitHub:</b>
|
||||
<a href="{{ about.contact_github }}" target="_blank" rel="noopener noreferrer">{{ about.contact_github }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if about.donate_url %}
|
||||
<section class="donate-block">
|
||||
<h2>支持作者</h2>
|
||||
{% if about.donate_url.lower().endswith(('.png','.jpg','.jpeg','.gif','.webp')) %}
|
||||
<img class="donate-img" src="{{ about.donate_url }}" alt="扫码打赏">
|
||||
{% else %}
|
||||
<a class="layui-btn layui-btn-warm" href="{{ about.donate_url }}" target="_blank" rel="noopener noreferrer">
|
||||
❤️ 前往打赏
|
||||
</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<div class="about-footer-meta">
|
||||
最后更新:{{ about.update_at or '—' }}
|
||||
{% if about.update_at %} · {{ about.create_at }}{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% endblock %}
|
||||
+814
-57
File diff suppressed because it is too large
Load Diff
@@ -26,15 +26,28 @@
|
||||
{% for item in items %}
|
||||
<a class="card"
|
||||
href="{{ item.url }}"
|
||||
data-nav-id="{{ item.id }}"
|
||||
{% if item.is_external %}target="_blank" rel="noopener noreferrer"{% endif %}>
|
||||
<div class="title-row">
|
||||
<div class="icon-wrap">
|
||||
<i class="layui-icon {{ item.icon }}"></i>
|
||||
</div>
|
||||
<div class="title" title="{{ item.title }}">{{ item.title }}</div>
|
||||
<div class="icon-wrap">
|
||||
<i class="layui-icon {{ item.icon }}"></i>
|
||||
</div>
|
||||
<div class="desc">{{ item.description or '暂无描述' }}</div>
|
||||
<div class="url" title="{{ item.url }}">{{ item.url }}</div>
|
||||
<div class="card-body">
|
||||
<div class="title-row">
|
||||
<div class="title" title="{{ item.title }}">{{ item.title }}</div>
|
||||
{% if item.is_external %}
|
||||
<span class="ext-tag" title="外部链接"><i class="layui-icon layui-icon-link"></i></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="desc">{{ item.description or '暂无描述' }}</div>
|
||||
<div class="url" title="{{ item.url }}">{{ item.url }}</div>
|
||||
<div class="visit-count" data-nav-visit="{{ item.visit_count or 0 }}">
|
||||
{%- set vc = item.visit_count or 0 -%}
|
||||
{%- if vc >= 3 -%}访问 {{ '{:,}'.format(vc) }}
|
||||
{%- else -%}新
|
||||
{%- endif -%}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-arrow"><i class="layui-icon layui-icon-right"></i></div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{% extends 'public/base.html' %}
|
||||
{% block title %}{{ site_name|default('旺珂') }} · 友情链接{% endblock %}
|
||||
{% block subtitle %}合作伙伴、推荐站点与互链往来 · 共 {{ total|default(0) }} 个{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if groups and (groups|length) > 0 %}
|
||||
{% for category, items in groups.items() %}
|
||||
<section class="section" id="cat-{{ loop.index }}">
|
||||
<header class="section-header">
|
||||
<h2>{{ category }}</h2>
|
||||
<span class="meta">共 {{ items|length }} 个</span>
|
||||
</header>
|
||||
<div class="grid">
|
||||
{% for f in items %}
|
||||
<a class="card" href="{{ f.url }}" {% if f.is_external == 1 %}target="_blank" rel="noopener"{% endif %}>
|
||||
<div class="title-row">
|
||||
{% if f.logo %}
|
||||
<div class="icon-wrap"><img src="{{ f.logo }}" alt="{{ f.title }}" style="width:24px;height:24px;border-radius:4px;object-fit:cover;" onerror="this.replaceWith(document.createTextNode('🔗'))"></div>
|
||||
{% else %}
|
||||
<div class="icon-wrap">🔗</div>
|
||||
{% endif %}
|
||||
<div class="title">{{ f.title }}</div>
|
||||
</div>
|
||||
{% if f.description %}
|
||||
<div class="desc">{{ f.description }}</div>
|
||||
{% else %}
|
||||
<div class="desc">{{ f.url }}</div>
|
||||
{% endif %}
|
||||
<div class="url">{{ f.url }}</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty">
|
||||
<div style="font-size:48px;margin-bottom:12px;">🔗</div>
|
||||
<div>暂无友情链接</div>
|
||||
<div style="margin-top:14px;font-size:13px;">如需交换友链,请到 <a href="/system/friend/" style="color:var(--brand);">后台</a> 添加</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -34,15 +34,29 @@
|
||||
{% for item in items %}
|
||||
<a class="card"
|
||||
href="{{ item.url }}"
|
||||
data-nav-id="{{ item.id }}"
|
||||
{% if item.is_external %}target="_blank" rel="noopener noreferrer"{% endif %}>
|
||||
<div class="title-row">
|
||||
<div class="icon-wrap">
|
||||
<i class="layui-icon {{ item.icon }}"></i>
|
||||
</div>
|
||||
<div class="title" title="{{ item.title }}">{{ item.title }}</div>
|
||||
<div class="icon-wrap">
|
||||
<i class="layui-icon {{ item.icon }}"></i>
|
||||
</div>
|
||||
<div class="desc">{{ item.description or '暂无描述' }}</div>
|
||||
<div class="url" title="{{ item.url }}">{{ item.url }}</div>
|
||||
<div class="card-body">
|
||||
<div class="title-row">
|
||||
<div class="title" title="{{ item.title }}">{{ item.title }}</div>
|
||||
{% if item.is_external %}
|
||||
<span class="ext-tag" title="外部链接"><i class="layui-icon layui-icon-link"></i></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="desc">{{ item.description or '暂无描述' }}</div>
|
||||
<div class="url" title="{{ item.url }}">{{ item.url }}</div>
|
||||
<div class="visit-count" data-nav-visit="{{ item.visit_count or 0 }}">
|
||||
{%- set vc = item.visit_count or 0 -%}
|
||||
{%- if vc >= 3 -%}访问 {{ '{:,}'.format(vc) }}
|
||||
{%- elif vc > 0 -%}新
|
||||
{%- else -%}新
|
||||
{%- endif -%}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-arrow"><i class="layui-icon layui-icon-right"></i></div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<!-- 后台顶部导航条(顶栏) -->
|
||||
<!-- 与 templates/system/index.html 主页的 .layui-header 部分等价;供所有子模块引用 -->
|
||||
<div class="layui-header">
|
||||
<div class="layui-logo">
|
||||
<img class="logo">
|
||||
<span class="title"></span>
|
||||
</div>
|
||||
<ul class="layui-nav layui-layout-left">
|
||||
<li class="collapse layui-nav-item"><a href="#" class="layui-icon layui-icon-shrink-right"></a></li>
|
||||
<li class="refresh layui-nav-item"><a href="#" class="layui-icon layui-icon-refresh-1" loading=600></a></li>
|
||||
</ul>
|
||||
<div id="control" class="layui-layout-control"></div>
|
||||
<ul class="layui-nav layui-layout-right">
|
||||
<li class="layui-nav-item layui-hide-xs"><a href="#" class="menuSearch layui-icon layui-icon-search"></a></li>
|
||||
<li class="layui-nav-item layui-hide-xs message"></li>
|
||||
<li class="layui-nav-item layui-hide-xs"><a href="#" class="fullScreen layui-icon layui-icon-screen-full"></a></li>
|
||||
<li class="layui-nav-item user">
|
||||
<a class="layui-icon layui-icon-username" href="javascript:;"></a>
|
||||
<dl class="layui-nav-child">
|
||||
<dd><a href="javascript:void(0);" user-menu-url="/system/user/center" user-menu-id="5555" user-menu-title="基本资料">基本资料</a></dd>
|
||||
<dd><a href="javascript:void(0);" class="logout">注销登录</a></dd>
|
||||
</dl>
|
||||
</li>
|
||||
<li class="layui-nav-item setting"><a href="#" class="layui-icon layui-icon-more-vertical"></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -0,0 +1,74 @@
|
||||
<!-- 后台左侧菜单(侧栏) -->
|
||||
<!-- 与 templates/system/index.html 主页的 .layui-side 部分等价;供所有子模块引用 -->
|
||||
<div class="layui-side layui-bg-black">
|
||||
<div class="layui-logo">
|
||||
<img class="logo">
|
||||
<span class="title"></span>
|
||||
</div>
|
||||
<div class="layui-side-scroll">
|
||||
<div id="side"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 依赖脚本 + 框架初始化 -->
|
||||
<script src="{{ url_for('static', filename='system/component/layui/layui.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='system/component/pear/pear.js') }}"></script>
|
||||
<script>
|
||||
layui.use(['admin', 'jquery', 'popup'], function () {
|
||||
var admin = layui.admin;
|
||||
var popup = layui.popup;
|
||||
var $ = layui.jquery;
|
||||
|
||||
admin.setConfigurationPath("{{ url_for('system.rights.configs') }}");
|
||||
|
||||
admin._changeTheme = admin.changeTheme;
|
||||
admin.changeTheme = function () {
|
||||
admin._changeTheme();
|
||||
const variableKey = "--global-primary-color";
|
||||
const variableVal = localStorage.getItem("theme-color-color");
|
||||
const iframes = document.querySelectorAll('iframe');
|
||||
iframes.forEach(function (iframe) {
|
||||
try {
|
||||
const iframeDocument = iframe.contentDocument || iframe.contentWindow.document;
|
||||
iframeDocument.documentElement.style.setProperty(variableKey, variableVal);
|
||||
} catch (e) {}
|
||||
});
|
||||
};
|
||||
|
||||
admin._switchTheme = admin.switchTheme;
|
||||
admin.switchTheme = function (checked) {
|
||||
admin.isdrak = checked;
|
||||
admin._switchTheme(checked);
|
||||
const iframes = document.querySelectorAll('iframe');
|
||||
iframes.forEach(function (iframe) {
|
||||
try {
|
||||
const iframeDocument = iframe.contentDocument || iframe.contentWindow.document;
|
||||
if (checked === true || checked === "true") {
|
||||
iframeDocument.body.classList.add("pear-admin-dark");
|
||||
} else {
|
||||
iframeDocument.body.classList.remove("pear-admin-dark");
|
||||
}
|
||||
} catch (e) {}
|
||||
});
|
||||
};
|
||||
|
||||
admin.render();
|
||||
|
||||
admin.logout(function () {
|
||||
let loading = layer.load();
|
||||
$.ajax({
|
||||
url: '{{ url_for('system.passport.logout') }}',
|
||||
dataType: 'json',
|
||||
async: false,
|
||||
type: 'post',
|
||||
success: function (result) {
|
||||
layer.close(loading);
|
||||
if (result.success) {
|
||||
popup.success(result.msg, function () { location.href = '/'; });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user