diff --git a/applications/view/public/__init__.py b/applications/view/public/__init__.py index a6513b2..8bcf779 100644 --- a/applications/view/public/__init__.py +++ b/applications/view/public/__init__.py @@ -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 \ No newline at end of file +bp = nav_bp + + +def register_public_bp(app): + """注册 public 子蓝图""" + app.register_blueprint(about_bp) + app.register_blueprint(friend_bp) \ No newline at end of file diff --git a/applications/view/public/about.py b/applications/view/public/about.py new file mode 100644 index 0000000..413f00b --- /dev/null +++ b/applications/view/public/about.py @@ -0,0 +1,49 @@ +""" +前台公开"关于本站"页面。 + +不需要登录,匿名可访问。Markdown 在模板里渲染。 +""" +import re + +import markdown as _markdown +from flask import Blueprint, render_template + +from applications.models import About + +bp = Blueprint('public_about', __name__, url_prefix='/site') + + +def _get_about() -> About: + """复用 model 的种子逻辑,避免空表时炸""" + a = About.query.get(1) + if a is None: + from applications.extensions import db + a = About(id=1, site_name='旺珂 · 导航') + db.session.add(a) + db.session.commit() + return a + + +@bp.get('/about') +def about(): + a = _get_about() + html = _render_md(a.content_md) + return render_template('public/about.html', about=a, content_html=html) + + +_URL_RE = re.compile(r'(https?://[^\s)<>"\\]+|[\w.+-]+@[\w-]+\.[\w.-]+)') + + +def _render_md(text: str) -> str: + """Markdown → HTML;自动把裸链接/邮箱变可点;同时禁掉 script/iframe。""" + if not text or not text.strip(): + return '' + html = _markdown.markdown( + text, + extensions=['fenced_code', 'tables', 'nl2br', 'sane_lists'], + output_format='html5', + ) + # 防御:清掉 +{% endraw %} + +``` + +**注意 footer include 必须放在所有业务 ` + + diff --git a/plugins/aboutManager/view/about.py b/plugins/aboutManager/view/about.py new file mode 100644 index 0000000..599d948 --- /dev/null +++ b/plugins/aboutManager/view/about.py @@ -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='保存成功') diff --git a/plugins/friendManager/__init__.json b/plugins/friendManager/__init__.json new file mode 100644 index 0000000..a7442f8 --- /dev/null +++ b/plugins/friendManager/__init__.json @@ -0,0 +1,5 @@ +{ + "plugin_name": "友情链接", + "plugin_version": "1.0.0", + "plugin_description": "后台友情链接模块。后台菜单位于「站点管理 / 友情链接」。对 site_friend 表的增删改查与启用/禁用。" +} diff --git a/plugins/friendManager/__init__.py b/plugins/friendManager/__init__.py new file mode 100644 index 0000000..b7e9155 --- /dev/null +++ b/plugins/friendManager/__init__.py @@ -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") diff --git a/plugins/friendManager/templates/system/friend/add.html b/plugins/friendManager/templates/system/friend/add.html new file mode 100644 index 0000000..1a0393a --- /dev/null +++ b/plugins/friendManager/templates/system/friend/add.html @@ -0,0 +1,114 @@ + + + + + 新增友情链接 + {% include 'system/common/header.html' %} + + +
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+ + +
+
+
+ + + + diff --git a/plugins/friendManager/templates/system/friend/edit.html b/plugins/friendManager/templates/system/friend/edit.html new file mode 100644 index 0000000..7ca7cd7 --- /dev/null +++ b/plugins/friendManager/templates/system/friend/edit.html @@ -0,0 +1,117 @@ + + + + + 编辑友情链接 + {% include 'system/common/header.html' %} + + +
+ +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+ + +
+
+
+ + + + diff --git a/plugins/friendManager/templates/system/friend/main.html b/plugins/friendManager/templates/system/friend/main.html new file mode 100644 index 0000000..a5d5227 --- /dev/null +++ b/plugins/friendManager/templates/system/friend/main.html @@ -0,0 +1,171 @@ + + + + + 友情链接 + {% include 'system/common/header.html' %} + + +
+
+
+
+ +
+ +
+ +
+ +
+ + + +
+
+
+
+ +
+
+
+
+
+ +{% include 'system/common/footer.html' %} +{% raw %} + + + + + + + +{% endraw %} + + + + diff --git a/plugins/friendManager/view/friend.py b/plugins/friendManager/view/friend.py new file mode 100644 index 0000000..1fc8b57 --- /dev/null +++ b/plugins/friendManager/view/friend.py @@ -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/') +@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="已禁用") diff --git a/plugins/navManager/__init__.json b/plugins/navManager/__init__.json new file mode 100644 index 0000000..79143c8 --- /dev/null +++ b/plugins/navManager/__init__.json @@ -0,0 +1,5 @@ +{ + "plugin_name": "导航管理", + "plugin_version": "1.0.0", + "plugin_description": "后台导航管理模块。后台菜单位于「站点管理 / 导航管理」。提供对 nav 表的增删改查、分页查询、关键字 + 分类过滤、启用/禁用切换。" +} diff --git a/plugins/navManager/__init__.py b/plugins/navManager/__init__.py new file mode 100644 index 0000000..76340c1 --- /dev/null +++ b/plugins/navManager/__init__.py @@ -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") diff --git a/plugins/navManager/templates/system/nav/add.html b/plugins/navManager/templates/system/nav/add.html new file mode 100644 index 0000000..b7622ba --- /dev/null +++ b/plugins/navManager/templates/system/nav/add.html @@ -0,0 +1,101 @@ + + + + + 新增导航 + {% include 'system/common/header.html' %} + + +
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ + +
+
+
+ +
+ +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+ + +
+
+
+ +{% include 'system/common/footer.html' %} + + + \ No newline at end of file diff --git a/plugins/navManager/templates/system/nav/category_add.html b/plugins/navManager/templates/system/nav/category_add.html new file mode 100644 index 0000000..b062936 --- /dev/null +++ b/plugins/navManager/templates/system/nav/category_add.html @@ -0,0 +1,84 @@ + + + + + 新增导航分类 + {% include 'system/common/header.html' %} + + + +
+
+ +
+ +
+
+
+ +
+ +
填写 layui 图标 class,例如 layui-icon-list / layui-icon-star
+
+
+
+ +
+ +
+
+
+ +
+ +
数字越大,越靠前
+
+
+
+ +
+ + +
+
+
+
+ + +
+
+
+ +{% include 'system/common/footer.html' %} + + + \ No newline at end of file diff --git a/plugins/navManager/templates/system/nav/category_edit.html b/plugins/navManager/templates/system/nav/category_edit.html new file mode 100644 index 0000000..f03c1ce --- /dev/null +++ b/plugins/navManager/templates/system/nav/category_edit.html @@ -0,0 +1,95 @@ + + + + + 编辑导航分类 + {% include 'system/common/header.html' %} + + + +
+ +
+ +
+ +
+
+
+ +
+ +
填写 layui 图标 class
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ {% if cat.enable == 1 %} + + + {% else %} + + + {% endif %} +
+
+
+
+ + +
+
+
+ +{% include 'system/common/footer.html' %} + + + \ No newline at end of file diff --git a/plugins/navManager/templates/system/nav/category_main.html b/plugins/navManager/templates/system/nav/category_main.html new file mode 100644 index 0000000..4d2267f --- /dev/null +++ b/plugins/navManager/templates/system/nav/category_main.html @@ -0,0 +1,146 @@ + + + + + 导航分类管理 + {% include 'system/common/header.html' %} + + +
+
+
+
+ +
+ +
+ + + +
+
+
+
+ +
+
+
+
+
+ +{% include 'system/common/footer.html' %} +{% raw %} + + + +{% endraw %} + + + + \ No newline at end of file diff --git a/plugins/navManager/templates/system/nav/edit.html b/plugins/navManager/templates/system/nav/edit.html new file mode 100644 index 0000000..27cb4b9 --- /dev/null +++ b/plugins/navManager/templates/system/nav/edit.html @@ -0,0 +1,100 @@ + + + + + 编辑导航 + {% include 'system/common/header.html' %} + + +
+ +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+ + +
+
+
+ +{% include 'system/common/footer.html' %} + + + \ No newline at end of file diff --git a/plugins/navManager/templates/system/nav/main.html b/plugins/navManager/templates/system/nav/main.html new file mode 100644 index 0000000..693428f --- /dev/null +++ b/plugins/navManager/templates/system/nav/main.html @@ -0,0 +1,162 @@ + + + + + 导航管理 + {% include 'system/common/header.html' %} + + +
+
+
+
+ +
+ +
+ +
+ +
+ + + +
+
+
+
+ +
+
+ +
+
+ +{% include 'system/common/footer.html' %} +{% raw %} + + + + + +{% endraw %} + + + + diff --git a/plugins/navManager/view/nav.py b/plugins/navManager/view/nav.py new file mode 100644 index 0000000..bee41a0 --- /dev/null +++ b/plugins/navManager/view/nav.py @@ -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/') +@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="已禁用") diff --git a/plugins/navManager/view/nav_category.py b/plugins/navManager/view/nav_category.py new file mode 100644 index 0000000..b99acf5 --- /dev/null +++ b/plugins/navManager/view/nav_category.py @@ -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/') +@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='已禁用') diff --git a/plugins/siteStats/__init__.json b/plugins/siteStats/__init__.json new file mode 100644 index 0000000..e910d1b --- /dev/null +++ b/plugins/siteStats/__init__.json @@ -0,0 +1,5 @@ +{ + "plugin_name": "访问统计", + "plugin_version": "1.0.0", + "plugin_description": "后台访问统计主页 + 汇总/按天/路径接口 + before_request 全站埋点。前台 /site/* GET 自动累计 PV/UV。" +} diff --git a/plugins/siteStats/__init__.py b/plugins/siteStats/__init__.py new file mode 100644 index 0000000..7d3b6c2 --- /dev/null +++ b/plugins/siteStats/__init__.py @@ -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") diff --git a/plugins/siteStats/templates/system/stat/main.html b/plugins/siteStats/templates/system/stat/main.html new file mode 100644 index 0000000..f83ee56 --- /dev/null +++ b/plugins/siteStats/templates/system/stat/main.html @@ -0,0 +1,169 @@ + + + + + 访问统计 + {% include 'system/common/header.html' %} + + + +
+
+
+
+
--
+
累计 PV(页面浏览)
+
+
+
+
+
+
+
--
+
累计 UV(独立访客)
+
+
+
+
+
+
+
--
+
今日 PV
+
+
+
+
+
+
+
--
+
今日 UV
+
+
+
+
+ +
+
+ 最近 7 天趋势 + +
+
+
+
+
+ +
+
+
+
路径 TOP10
+
+ + + + + + + +
路径PV最近访问
加载中…
+
+
+
+
+
+
最近访问
+
+ + + + + + + +
路径IP时间
加载中…
+
+
+
+
+ + +{% include 'system/common/footer.html' %} + + + + + diff --git a/plugins/siteStats/view/stat.py b/plugins/siteStats/view/stat.py new file mode 100644 index 0000000..ecc0386 --- /dev/null +++ b/plugins/siteStats/view/stat.py @@ -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//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//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) diff --git a/scripts/compare_category.py b/scripts/compare_category.py new file mode 100644 index 0000000..7e0dd65 --- /dev/null +++ b/scripts/compare_category.py @@ -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() \ No newline at end of file diff --git a/scripts/compare_home.py b/scripts/compare_home.py new file mode 100644 index 0000000..ae98857 --- /dev/null +++ b/scripts/compare_home.py @@ -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() \ No newline at end of file diff --git a/scripts/debug_drawer_icon.py b/scripts/debug_drawer_icon.py new file mode 100644 index 0000000..0ffacc1 --- /dev/null +++ b/scripts/debug_drawer_icon.py @@ -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() \ No newline at end of file diff --git a/scripts/debug_theme_click.py b/scripts/debug_theme_click.py new file mode 100644 index 0000000..e958122 --- /dev/null +++ b/scripts/debug_theme_click.py @@ -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() \ No newline at end of file diff --git a/scripts/ensure_nav_click_table.py b/scripts/ensure_nav_click_table.py new file mode 100644 index 0000000..d97902d --- /dev/null +++ b/scripts/ensure_nav_click_table.py @@ -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") \ No newline at end of file diff --git a/scripts/probe_drawer_open.py b/scripts/probe_drawer_open.py new file mode 100644 index 0000000..babb16b --- /dev/null +++ b/scripts/probe_drawer_open.py @@ -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() \ No newline at end of file diff --git a/scripts/probe_nav_category.py b/scripts/probe_nav_category.py new file mode 100644 index 0000000..107ebc4 --- /dev/null +++ b/scripts/probe_nav_category.py @@ -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/ (删除) + 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) \ No newline at end of file diff --git a/scripts/probe_pages.py b/scripts/probe_pages.py new file mode 100644 index 0000000..7da81a3 --- /dev/null +++ b/scripts/probe_pages.py @@ -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() diff --git a/scripts/probe_ref_drawer.py b/scripts/probe_ref_drawer.py new file mode 100644 index 0000000..23f0f99 --- /dev/null +++ b/scripts/probe_ref_drawer.py @@ -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() \ No newline at end of file diff --git a/scripts/test_auto_screenshot.py b/scripts/test_auto_screenshot.py new file mode 100644 index 0000000..c5580ba --- /dev/null +++ b/scripts/test_auto_screenshot.py @@ -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() \ No newline at end of file diff --git a/scripts/test_drawer_click.py b/scripts/test_drawer_click.py new file mode 100644 index 0000000..ef351f4 --- /dev/null +++ b/scripts/test_drawer_click.py @@ -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() \ No newline at end of file diff --git a/scripts/test_mobile_interact.py b/scripts/test_mobile_interact.py new file mode 100644 index 0000000..e5162f2 --- /dev/null +++ b/scripts/test_mobile_interact.py @@ -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() \ No newline at end of file diff --git a/scripts/test_themes.py b/scripts/test_themes.py new file mode 100644 index 0000000..7109e36 --- /dev/null +++ b/scripts/test_themes.py @@ -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() \ No newline at end of file diff --git a/scripts/verify_adaptive_theme.py b/scripts/verify_adaptive_theme.py new file mode 100644 index 0000000..e9d2e56 --- /dev/null +++ b/scripts/verify_adaptive_theme.py @@ -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() \ No newline at end of file diff --git a/scripts/verify_beacon_click.py b/scripts/verify_beacon_click.py new file mode 100644 index 0000000..52c0ec6 --- /dev/null +++ b/scripts/verify_beacon_click.py @@ -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') diff --git a/scripts/verify_theme_switch.py b/scripts/verify_theme_switch.py new file mode 100644 index 0000000..c8b4adf --- /dev/null +++ b/scripts/verify_theme_switch.py @@ -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() \ No newline at end of file diff --git a/scripts/verify_visit_count.py b/scripts/verify_visit_count.py new file mode 100644 index 0000000..539c870 --- /dev/null +++ b/scripts/verify_visit_count.py @@ -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") \ No newline at end of file diff --git a/templates/public/about.html b/templates/public/about.html new file mode 100644 index 0000000..2e22fe1 --- /dev/null +++ b/templates/public/about.html @@ -0,0 +1,87 @@ +{% extends 'public/base.html' %} + +{% block subtitle %}关于本站 · 备案信息 · 联系方式{% endblock %} + +{% block content %} +
+
+

{{ about.site_name or '关于本站' }}

+ {% if about.icp %} + + {% endif %} +
+ + {% if content_html %} +
+ {{ content_html | safe }} +
+ {% else %} +
+

+ 📝
+ 暂无「关于本站」内容。请到 + 后台 → 网站管理 → 关于本站 编辑。 +

+
+ {% 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 %} +
+

联系方式

+
    + {% if about.contact_email %} +
  • 邮箱: + {{ about.contact_email }} +
  • + {% endif %} + {% if about.contact_qq %} +
  • QQ:{{ about.contact_qq }}
  • + {% endif %} + {% if about.contact_wechat %} +
  • 微信:{{ about.contact_wechat }}
  • + {% endif %} + {% if about.contact_telegram %} +
  • Telegram: + {% if about.contact_telegram.startswith('http') %} + {{ about.contact_telegram }} + {% else %} + {{ about.contact_telegram }} + {% endif %} +
  • + {% endif %} + {% if about.contact_github %} +
  • GitHub: + {{ about.contact_github }} +
  • + {% endif %} +
+
+ {% endif %} + + {% if about.donate_url %} + + {% endif %} + + +
+{% endblock %} diff --git a/templates/public/base.html b/templates/public/base.html index a7b3ffa..e205724 100644 --- a/templates/public/base.html +++ b/templates/public/base.html @@ -5,13 +5,38 @@ - {% block title %}{{ site_name|default('BWStudio · 导航') }}{% endblock %} - - + {% block title %}{{ site_name|default('旺珂 · 导航') }}{% endblock %} + + + {# 首屏防闪烁:根据 localStorage / 系统偏好立即设置 data-theme #} + {% block extra_head %}{% endblock %}
-
-

{{ site_name|default('BWStudio · 导航') }}

-
{% block subtitle %}精选优质站点,按分类聚合展示 · 共 {{ total|default(0) }} 个链接{% endblock %}
+
+ {# 手机端分类入口按钮(OneNav 风格) #} + + +
+

{{ site_name|default('旺珂 · 导航') }}

+
{% block subtitle %}精选优质站点,按分类聚合展示 · 共 {{ total|default(0) }} 个链接{% endblock %}
+
+ + {# 全局搜索框(OneNav 风格) #} + +
🏠 首页 - 🔐 登录后台 - + 📖 关于 + 🔗 友链 + 🔐 登录后台 + {# 主题切换下拉 #} +
+ +
    +
  • 蓝绿(默认)
  • +
  • 暮色橙
  • +
  • 森林绿
  • +
  • 暗色
  • +
  • 🔄 跟随系统
  • +
+
@@ -283,30 +753,165 @@
- © 2026 BWStudio · 基于 Pear Admin Flask 构建 +
© 2026 旺珂 · 基于 Pear Admin Flask 构建
+
+ {# 移动端分类 FAB + 抽屉(桌面被 CSS 隐藏,无需各页面写) #} + + + {% block extra_js %}{% endblock %} \ No newline at end of file diff --git a/templates/public/category.html b/templates/public/category.html index 7673730..864db77 100644 --- a/templates/public/category.html +++ b/templates/public/category.html @@ -26,15 +26,28 @@ {% for item in items %} - diff --git a/templates/public/friend.html b/templates/public/friend.html new file mode 100644 index 0000000..f6f41c2 --- /dev/null +++ b/templates/public/friend.html @@ -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() %} +
+
+

{{ category }}

+ 共 {{ items|length }} 个 +
+ +
+ {% endfor %} +{% else %} +
+
🔗
+
暂无友情链接
+
如需交换友链,请到 后台 添加
+
+{% endif %} +{% endblock %} diff --git a/templates/public/index.html b/templates/public/index.html index 2afd8e6..e9c29b3 100644 --- a/templates/public/index.html +++ b/templates/public/index.html @@ -34,15 +34,29 @@ {% for item in items %} - diff --git a/templates/system/common/head.html b/templates/system/common/head.html new file mode 100644 index 0000000..2922e1d --- /dev/null +++ b/templates/system/common/head.html @@ -0,0 +1,26 @@ + + +
+ +
    +
  • +
  • +
+
+ +
diff --git a/templates/system/common/side.html b/templates/system/common/side.html new file mode 100644 index 0000000..481fd88 --- /dev/null +++ b/templates/system/common/side.html @@ -0,0 +1,74 @@ + + +
+ +
+
+
+
+ + + + +