diff --git a/applications/common/script/admin.py b/applications/common/script/admin.py index 00be923..a13d65a 100644 --- a/applications/common/script/admin.py +++ b/applications/common/script/admin.py @@ -75,6 +75,9 @@ def import_logic(file_path, is_force): db.session.query(Role).delete() db.session.query(Power).delete() db.session.query(Dept).delete() + # 新增:清空 Nav 表 + from applications.models import Nav + db.session.query(Nav).delete() db.session.commit() except Exception as e: db.session.rollback() @@ -134,7 +137,17 @@ def import_logic(file_path, is_force): user.role = roles db.session.commit() - click.echo(f"导入成功! 新增: 部门{count_dept}, 权限{count_power}, 角色{count_role}, 用户{count_user}") + # 5. 导入公开导航(Nav 表) + from applications.models import Nav + count_nav = 0 + for item in raw_data.get("navs", []): + item = parse_time(item) + if not Nav.query.get(item.get('id')) if item.get('id') else True: + db.session.add(Nav(**item)) + count_nav += 1 + db.session.commit() + + click.echo(f"导入成功! 新增: 部门{count_dept}, 权限{count_power}, 角色{count_role}, 用户{count_user}, 导航{count_nav}") return True except Exception as e: @@ -184,7 +197,8 @@ def export_data(name): "depts": [], "powers": [], "roles": [], - "users": [] + "users": [], + "navs": [] } # 1. 导出部门 @@ -204,6 +218,10 @@ def export_data(name): u_dict['role_ids'] = [r.id for r in u.role] data["users"].append(u_dict) + # 5. 导出公开导航 + from applications.models import Nav + data["navs"] = [model_to_dict(n) for n in Nav.query.order_by(Nav.category, Nav.sort, Nav.id).all()] + try: with open(file_path, 'w', encoding='utf-8') as f: json.dump(data, f, cls=DateEncoder, ensure_ascii=False, indent=4) diff --git a/applications/common/script/app_data/init.json b/applications/common/script/app_data/init.json index 4666974..c8935a9 100644 --- a/applications/common/script/app_data/init.json +++ b/applications/common/script/app_data/init.json @@ -464,6 +464,58 @@ "sort": 2, "create_time": "2023-01-01 10:00:00", "enable": 1 + }, + { + "id": 60, + "name": "导航管理", + "type": "1", + "code": "system:nav:main", + "url": "/system/nav/", + "open_type": "_iframe", + "parent_id": "1", + "icon": "layui-icon layui-icon-link", + "sort": 9, + "create_time": "2026-09-05 14:30:00", + "enable": 1 + }, + { + "id": 61, + "name": "导航新增", + "type": "2", + "code": "system:nav:add", + "url": "", + "open_type": "", + "parent_id": "60", + "icon": "", + "sort": 1, + "create_time": "2026-09-05 14:30:00", + "enable": 1 + }, + { + "id": 62, + "name": "导航编辑", + "type": "2", + "code": "system:nav:edit", + "url": "", + "open_type": "", + "parent_id": "60", + "icon": "", + "sort": 2, + "create_time": "2026-09-05 14:30:00", + "enable": 1 + }, + { + "id": 63, + "name": "导航删除", + "type": "2", + "code": "system:nav:remove", + "url": "", + "open_type": "", + "parent_id": "60", + "icon": "", + "sort": 3, + "create_time": "2026-09-05 14:30:00", + "enable": 1 } ], "roles": [ @@ -485,7 +537,18 @@ "details": "只有查看,没有增删改权限", "sort": 2, "create_time": "2023-01-01 10:00:00", - "power_ids": [1, 3, 4, 9, 12, 13, 17, 18, 44, 48] + "power_ids": [ + 1, + 3, + 4, + 9, + 12, + 13, + 17, + 18, + 44, + 48 + ] } ], "users": [ @@ -499,7 +562,9 @@ "remark": "要是不能把握时机,就要终身蹭蹬,一事无成!", "avatar": "/static/system/admin/images/avatar.jpg", "dept_id": 1, - "role_ids": [1] + "role_ids": [ + 1 + ] }, { "id": 2, @@ -511,7 +576,9 @@ "remark": "要是不能把握时机,就要终身蹭蹬,一事无成!", "avatar": "/static/system/admin/images/avatar.jpg", "dept_id": 1, - "role_ids": [2] + "role_ids": [ + 2 + ] }, { "id": "3", @@ -525,5 +592,127 @@ "dept_id": 7, "role_ids": [] } + ], + "navs": [ + { + "category": "开发工具", + "title": "GitHub", + "url": "https://github.com", + "description": "全球最大开源代码托管平台", + "icon": "layui-icon layui-icon-website", + "sort": 1, + "status": 1, + "is_external": 1, + "create_by": "admin", + "create_at": "2026-09-05 14:30:00" + }, + { + "category": "开发工具", + "title": "Stack Overflow", + "url": "https://stackoverflow.com", + "description": "全球程序员问答社区", + "icon": "layui-icon layui-icon-help", + "sort": 2, + "status": 1, + "is_external": 1, + "create_by": "admin", + "create_at": "2026-09-05 14:30:00" + }, + { + "category": "开发工具", + "title": "MDN Web Docs", + "url": "https://developer.mozilla.org", + "description": "Web 开发权威文档", + "icon": "layui-icon layui-icon-read", + "sort": 3, + "status": 1, + "is_external": 1, + "create_by": "admin", + "create_at": "2026-09-05 14:30:00" + }, + { + "category": "开发工具", + "title": "Gitee", + "url": "https://gitee.com", + "description": "国内代码托管平台", + "icon": "layui-icon layui-icon-website", + "sort": 4, + "status": 1, + "is_external": 1, + "create_by": "admin", + "create_at": "2026-09-05 14:30:00" + }, + { + "category": "AI 工具", + "title": "ChatGPT", + "url": "https://chat.openai.com", + "description": "OpenAI 出品的对话式 AI", + "icon": "layui-icon layui-icon-console", + "sort": 1, + "status": 1, + "is_external": 1, + "create_by": "admin", + "create_at": "2026-09-05 14:30:00" + }, + { + "category": "AI 工具", + "title": "Claude", + "url": "https://claude.ai", + "description": "Anthropic 出品的 AI 助手", + "icon": "layui-icon layui-icon-console", + "sort": 2, + "status": 1, + "is_external": 1, + "create_by": "admin", + "create_at": "2026-09-05 14:30:00" + }, + { + "category": "AI 工具", + "title": "通义千问", + "url": "https://tongyi.aliyun.com", + "description": "阿里云出品的国产大模型", + "icon": "layui-icon layui-icon-console", + "sort": 3, + "status": 1, + "is_external": 1, + "create_by": "admin", + "create_at": "2026-09-05 14:30:00" + }, + { + "category": "设计资源", + "title": "Dribbble", + "url": "https://dribbble.com", + "description": "全球设计灵感社区", + "icon": "layui-icon layui-icon-picture", + "sort": 1, + "status": 1, + "is_external": 1, + "create_by": "admin", + "create_at": "2026-09-05 14:30:00" + }, + { + "category": "设计资源", + "title": "Behance", + "url": "https://www.behance.net", + "description": "Adobe 旗下的设计师作品集平台", + "icon": "layui-icon layui-icon-picture", + "sort": 2, + "status": 1, + "is_external": 1, + "create_by": "admin", + "create_at": "2026-09-05 14:30:00" + }, + { + "category": "设计资源", + "title": "优设", + "url": "https://www.uisdc.com", + "description": "国内优秀设计师导航", + "icon": "layui-icon layui-icon-picture", + "sort": 3, + "status": 1, + "is_external": 1, + "create_by": "admin", + "create_at": "2026-09-05 14:30:00" + } ] } \ No newline at end of file diff --git a/applications/models/__init__.py b/applications/models/__init__.py index d94b265..bbaa0d5 100644 --- a/applications/models/__init__.py +++ b/applications/models/__init__.py @@ -8,3 +8,4 @@ from .admin_role_power import role_power from .admin_user import User from .admin_user_role import user_role from .admin_mail import Mail +from .admin_nav import Nav diff --git a/applications/models/admin_nav.py b/applications/models/admin_nav.py new file mode 100644 index 0000000..5234329 --- /dev/null +++ b/applications/models/admin_nav.py @@ -0,0 +1,69 @@ +""" +前台公开导航模型。 + +Nav 用于「公开访问的导航聚合页」,与 admin_power 表完全解耦: +- admin_power 控制后台管理菜单,需要登录 +- public_nav 控制前台公开导航,无需登录 + +后续若要做前台用户(收藏、提交链接),只需加独立 User 模型, +不需要碰 RBAC。 +""" +import datetime +from applications.extensions import db + + +class Nav(db.Model): + __tablename__ = 'public_nav' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='导航ID') + + # 分类名(如"开发工具"/"AI"/"设计资源"),用于首页聚合展示 + category = db.Column(db.String(50), nullable=False, comment='分类', index=True) + + # 单条导航的标题 + title = db.Column(db.String(100), nullable=False, comment='标题') + + # 链接 URL(站内或外链) + url = db.Column(db.String(255), nullable=False, comment='链接') + + # 简短描述(鼠标悬停时可见) + description = db.Column(db.String(255), default='', comment='简介') + + # layui-icon 的 class 名,例如 "layui-icon-website" + icon = db.Column(db.String(100), default='layui-icon-link', comment='图标') + + # 同分类下的显示顺序 + sort = db.Column(db.Integer, default=0, comment='排序') + + # 1=启用,0=禁用(禁用后前台不再展示,后台列表仍可见) + status = db.Column(db.Integer, default=1, comment='状态(1启用,0关闭)') + + # 1=外链(新窗口打开),0=站内(当前窗口) + is_external = db.Column(db.Integer, default=1, comment='是否外链') + + # 创建者(后台管理员用户名,便于追溯),非空约束放宽以便匿名维护 + create_by = db.Column(db.String(50), default='admin', comment='创建者') + + create_at = db.Column(db.DateTime, default=datetime.datetime.now, comment='创建时间') + update_at = db.Column( + db.DateTime, + default=datetime.datetime.now, + onupdate=datetime.datetime.now, + comment='更新时间', + ) + + def to_dict(self): + """方便模板直接读取""" + return { + 'id': self.id, + 'category': self.category, + 'title': self.title, + 'url': self.url, + 'description': self.description or '', + 'icon': self.icon or 'layui-icon-link', + 'sort': self.sort or 0, + 'status': self.status, + 'is_external': self.is_external, + 'create_by': self.create_by, + 'create_at': self.create_at.strftime('%Y-%m-%d %H:%M:%S') if self.create_at else '', + } \ No newline at end of file diff --git a/applications/schemas/__init__.py b/applications/schemas/__init__.py index 56aa9d0..5f91d86 100644 --- a/applications/schemas/__init__.py +++ b/applications/schemas/__init__.py @@ -6,3 +6,4 @@ from .admin_dept import DeptSchema from .admin_log import LogOutSchema from .admin_photo import PhotoOutSchema from .admin_mail import MailOutSchema +from .admin_nav import NavOutSchema, NavManageSchema diff --git a/applications/schemas/admin_nav.py b/applications/schemas/admin_nav.py new file mode 100644 index 0000000..68fb5a4 --- /dev/null +++ b/applications/schemas/admin_nav.py @@ -0,0 +1,17 @@ +from flask_marshmallow.sqla import SQLAlchemyAutoSchema + +from applications.models import Nav + + +class NavOutSchema(SQLAlchemyAutoSchema): + """前台展示用:序列化 Nav 对象为 JSON""" + class Meta: + model = Nav + include_fk = True + + +class NavManageSchema(SQLAlchemyAutoSchema): + """后台管理用:同上,前缀分开便于将来按需差异化""" + class Meta: + model = Nav + include_fk = True \ No newline at end of file diff --git a/applications/view/__init__.py b/applications/view/__init__.py index 3e71f71..1b9a305 100644 --- a/applications/view/__init__.py +++ b/applications/view/__init__.py @@ -1,8 +1,13 @@ from applications.view.system import register_system_bps +from applications.view.public import bp as public_bp # public 包的入口就是 nav_bp from applications.extensions.init_plugins import broadcast_execute def init_bps(app): + # 前台公开蓝图(无需登录) + app.register_blueprint(public_bp) + + # 后台系统蓝图(需要登录 + 权限码) register_system_bps(app) # 插件初始化函数 diff --git a/applications/view/public/__init__.py b/applications/view/public/__init__.py new file mode 100644 index 0000000..a6513b2 --- /dev/null +++ b/applications/view/public/__init__.py @@ -0,0 +1,13 @@ +""" +前台公开蓝图:导航聚合页 / 分类详情页 + +URL 前缀:/site(避免与后台 / 路由冲突) +鉴权要求:无(任何用户都可访问,含未登录匿名用户) + +注意:不要给这些视图加 @authorize 装饰器,否则会强制登录_required。 +""" +from applications.view.public.nav import bp as nav_bp + + +# 把 nav 蓝图作为 public 包的对外入口 +bp = nav_bp \ No newline at end of file diff --git a/applications/view/public/nav.py b/applications/view/public/nav.py new file mode 100644 index 0000000..0b23e0c --- /dev/null +++ b/applications/view/public/nav.py @@ -0,0 +1,80 @@ +""" +前台导航页视图(无需登录)。 + +URL 路径(挂在根路径下,便于匿名访问): +- GET /site 公开导航聚合首页(按分类展示所有启用的导航) +- GET /site/category/ 分类详情:展示指定分类下所有启用的导航 +- GET /site/api/navs JSON 接口(供前端动态加载) + +注意: +- 这些视图**不加** @authorize,避免 login_required 强制登录。 +- 使用 url_prefix='/site' 避免与后台 / 路由冲突(后台 / 是登录后的工作台)。 +""" +from collections import OrderedDict + +from flask import Blueprint, render_template, request, jsonify + +from applications.models import Nav + +bp = Blueprint('public_nav', __name__, url_prefix='/site') + + +@bp.get('/') +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 · 导航', + ) + + +@bp.get('/category/') +def category_detail(name: str): + """ + 分类详情页:列出指定分类下所有启用的导航 + """ + items = ( + Nav.query + .filter(Nav.category == name, Nav.status == 1) + .order_by(Nav.sort.asc(), Nav.id.asc()) + .all() + ) + return render_template( + 'public/category.html', + category=name, + items=items, + site_name='BWStudio · 导航', + ) + + +@bp.get('/api/navs') +def api_navs(): + """ + JSON 接口:返回按分类聚合的导航数据 + 可用于首页卡片动态加载、第三方嵌入等 + """ + return jsonify({ + 'success': True, + 'msg': '请求成功', + 'data': _grouped_navs(), + }) + + +def _grouped_navs() -> "OrderedDict[str, list]": + """按 category 分组,组内按 sort asc 排序""" + items = ( + Nav.query + .filter(Nav.status == 1) + .order_by(Nav.category.asc(), Nav.sort.asc(), Nav.id.asc()) + .all() + ) + groups = OrderedDict() + for item in items: + groups.setdefault(item.category, []).append(item.to_dict()) + return groups \ No newline at end of file diff --git a/applications/view/system/__init__.py b/applications/view/system/__init__.py index 162c0fc..e0ef97e 100644 --- a/applications/view/system/__init__.py +++ b/applications/view/system/__init__.py @@ -6,6 +6,7 @@ from applications.view.system.index import bp as index_bp from applications.view.system.log import bp as log_bp from applications.view.system.mail import bp as mail_bp from applications.view.system.monitor import bp as monitor_bp +from applications.view.system.nav import bp as nav_bp from applications.view.system.passport import bp as passport_bp from applications.view.system.power import bp as power_bp from applications.view.system.rights import bp as right_bp @@ -30,5 +31,6 @@ def register_system_bps(app: Flask): system_bp.register_blueprint(passport_bp) system_bp.register_blueprint(right_bp) system_bp.register_blueprint(dept_bp) + system_bp.register_blueprint(nav_bp) app.register_blueprint(index_bp) app.register_blueprint(system_bp) diff --git a/applications/view/system/nav.py b/applications/view/system/nav.py new file mode 100644 index 0000000..a30cfb2 --- /dev/null +++ b/applications/view/system/nav.py @@ -0,0 +1,142 @@ +""" +后台导航管理视图(需要登录 + 权限码 system:nav:*) + +提供 Nav 模型的 CRUD 接口,供后台管理员维护前台公开导航。 +""" +from flask import Blueprint, render_template, request +from flask_login import current_user + +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 + +bp = Blueprint('nav', __name__, url_prefix='/nav') + + +@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 + 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()).all() + data = NavManageSchema(many=True).dump(items) + return table_api(msg="请求成功", data=data, count=len(data)) + + +@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 = request.get_json(force=True) + try: + nav = Nav( + category=str_escape(req.get('category')), + title=str_escape(req.get('title')), + url=str_escape(req.get('url')), + description=str_escape(req.get('description')), + icon=str_escape(req.get('icon')) or 'layui-icon-link', + sort=int(req.get('sort') or 0), + status=int(req.get('status') or 1), + is_external=int(req.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 = Nav.query.get(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 = request.get_json(force=True) + nav_id = req.get('id') + nav = Nav.query.get(nav_id) + if not nav: + return fail_api(msg="导航不存在") + try: + nav.category = str_escape(req.get('category')) or nav.category + nav.title = str_escape(req.get('title')) or nav.title + nav.url = str_escape(req.get('url')) or nav.url + nav.description = str_escape(req.get('description')) + nav.icon = str_escape(req.get('icon')) or 'layui-icon-link' + nav.sort = int(req.get('sort') or 0) + nav.status = int(req.get('status') or 1) + nav.is_external = int(req.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(_id): + nav = Nav.query.get(_id) + if not nav: + return fail_api(msg="导航不存在") + db.session.delete(nav) + db.session.commit() + return success_api(msg="删除成功") + + +@bp.put('/enable') +@authorize("system:nav:edit", log=True) +def enable(): + nav_id = request.get_json(force=True).get('navId') + Nav.query.filter_by(id=nav_id).update({"status": 1}) + db.session.commit() + return success_api(msg="已启用") + + +@bp.put('/disable') +@authorize("system:nav:edit", log=True) +def disable(): + nav_id = request.get_json(force=True).get('navId') + Nav.query.filter_by(id=nav_id).update({"status": 0}) + db.session.commit() + return success_api(msg="已禁用") \ No newline at end of file diff --git a/templates/public/base.html b/templates/public/base.html new file mode 100644 index 0000000..7599f72 --- /dev/null +++ b/templates/public/base.html @@ -0,0 +1,153 @@ + + + + + + {% block title %}{{ site_name|default('BWStudio · 导航') }}{% endblock %} + + + + {% block extra_head %}{% endblock %} + + +
+
+

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

+
{% block subtitle %}精选优质站点,按分类聚合展示 · 共 {{ total|default(0) }} 个链接{% endblock %}
+ +
+
+ +
+ {% block content %}{% endblock %} +
+ + + + {% block extra_js %}{% endblock %} + + \ No newline at end of file diff --git a/templates/public/category.html b/templates/public/category.html new file mode 100644 index 0000000..226b31c --- /dev/null +++ b/templates/public/category.html @@ -0,0 +1,36 @@ +{% extends 'public/base.html' %} + +{% block title %}{{ category }} - {{ site_name }}{% endblock %} + +{% block content %} +
+
+

{{ category }}

+
+ 共 {{ items|length }} 个 · + ← 返回首页 +
+
+ + {% if not items %} +
该分类下暂无导航。
+ {% else %} + + {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/templates/public/index.html b/templates/public/index.html new file mode 100644 index 0000000..4d554bc --- /dev/null +++ b/templates/public/index.html @@ -0,0 +1,38 @@ +{% extends 'public/base.html' %} + +{% block content %} + {% if not groups %} +
+ +

暂无导航数据,请先在后台【系统管理 → 导航管理】添加。

+
+ {% else %} + {% for category, items in groups.items() %} +
+
+

{{ category }}

+
+ 共 {{ items|length }} 个 · + 查看全部 → +
+
+ +
+ {% endfor %} + {% endif %} +{% endblock %} \ No newline at end of file diff --git a/templates/system/nav/add.html b/templates/system/nav/add.html new file mode 100644 index 0000000..04c45f3 --- /dev/null +++ b/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/templates/system/nav/edit.html b/templates/system/nav/edit.html new file mode 100644 index 0000000..b436f08 --- /dev/null +++ b/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/templates/system/nav/main.html b/templates/system/nav/main.html new file mode 100644 index 0000000..ef934c6 --- /dev/null +++ b/templates/system/nav/main.html @@ -0,0 +1,177 @@ + + + + + 导航管理 + {% include 'system/common/header.html' %} + + +
+ {% include 'system/common/head.html' %} + {% include 'system/common/side.html' %} +
+
+
+ 导航管理 + +
+
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ + +
+
+
+ +
+
+
+ {% include 'system/common/footer.html' %} +
+ +{% raw %} + + + + + +{% endraw %} + + + + \ No newline at end of file