From 895e1bed0fb88ef16a1e371d26f85dc6131831f5 Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 12 May 2026 15:25:03 +0800 Subject: [PATCH] first commit --- README.md | 5 + app.py | 264 +++++++++++++++++++++ forms.py | 44 ++++ instance/nav_local.db | Bin 0 -> 24576 bytes models.py | 54 +++++ requirements.txt | 6 + static/style.css | 324 ++++++++++++++++++++++++++ templates/admin_group_form.html | 44 ++++ templates/admin_groups.html | 69 ++++++ templates/admin_service_form.html | 72 ++++++ templates/admin_services.html | 80 +++++++ templates/base.html | 12 + templates/index.html | 79 +++++++ templates/login.html | 27 +++ 部署与使用说明.md | 372 ++++++++++++++++++++++++++++++ 15 files changed, 1452 insertions(+) create mode 100644 README.md create mode 100644 app.py create mode 100644 forms.py create mode 100644 instance/nav_local.db create mode 100644 models.py create mode 100644 requirements.txt create mode 100644 static/style.css create mode 100644 templates/admin_group_form.html create mode 100644 templates/admin_groups.html create mode 100644 templates/admin_service_form.html create mode 100644 templates/admin_services.html create mode 100644 templates/base.html create mode 100644 templates/index.html create mode 100644 templates/login.html create mode 100644 部署与使用说明.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..15059ca --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# 本地导航站 + +Flask + SQLite 的局域网导航聚合:左侧分组与服务列表,右侧 iframe 内嵌打开 `http://内网IP:端口`,统一入口管理宝塔、面板、本地服务等。 + +**完整中文说明与部署文档:** [部署与使用说明.md](./部署与使用说明.md) diff --git a/app.py b/app.py new file mode 100644 index 0000000..27dd64b --- /dev/null +++ b/app.py @@ -0,0 +1,264 @@ +import os +import secrets +from typing import Optional + +from flask import Flask, flash, redirect, render_template, request, url_for +from flask_login import LoginManager, current_user, login_required, login_user, logout_user +from flask_wtf.csrf import CSRFProtect + +from forms import GroupForm, LoginForm, ServiceForm +from models import Service, ServiceGroup, User, db + +login_manager = LoginManager() +csrf = CSRFProtect() + + +def create_app() -> Flask: + app = Flask(__name__) + app.config["SECRET_KEY"] = os.environ.get("NAV_SECRET_KEY") or secrets.token_hex(32) + app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get( + "NAV_DATABASE_URL", "sqlite:///nav_local.db" + ) + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["WTF_CSRF_TIME_LIMIT"] = None + + db.init_app(app) + login_manager.init_app(app) + login_manager.login_view = "login" + login_manager.login_message = "请先登录" + csrf.init_app(app) + + @login_manager.user_loader + def load_user(user_id: str): + return db.session.get(User, int(user_id)) + + with app.app_context(): + db.create_all() + _ensure_default_user() + + @app.route("/login", methods=["GET", "POST"]) + def login(): + if current_user.is_authenticated: + return redirect(url_for("index")) + form = LoginForm() + if form.validate_on_submit(): + user = User.query.filter_by(username=form.username.data.strip()).first() + if user and user.check_password(form.password.data): + login_user(user, remember=True) + next_url = request.args.get("next") + if next_url and next_url.startswith("/"): + return redirect(next_url) + return redirect(url_for("index")) + flash("用户名或密码错误", "error") + return render_template("login.html", form=form) + + @app.route("/logout") + @login_required + def logout(): + logout_user() + flash("已退出登录", "info") + return redirect(url_for("login")) + + @app.route("/") + @login_required + def index(): + groups = ( + ServiceGroup.query.order_by(ServiceGroup.sort_order, ServiceGroup.id).all() + ) + grouped = [] + for g in groups: + svcs = ( + Service.query.filter_by(group_id=g.id) + .order_by(Service.sort_order, Service.id) + .all() + ) + grouped.append((g, svcs)) + return render_template("index.html", grouped=grouped) + + # ---------- 分组管理 ---------- + @app.route("/admin/groups") + @login_required + def admin_groups(): + rows = ServiceGroup.query.order_by( + ServiceGroup.sort_order, ServiceGroup.id + ).all() + return render_template("admin_groups.html", groups=rows) + + @app.route("/admin/groups/new", methods=["GET", "POST"]) + @login_required + def admin_group_new(): + form = GroupForm() + if form.validate_on_submit(): + g = ServiceGroup( + name=form.name.data.strip(), + sort_order=form.sort_order.data or 0, + ) + db.session.add(g) + db.session.commit() + flash("分组已创建", "success") + return redirect(url_for("admin_groups")) + return render_template("admin_group_form.html", form=form, title="新建分组") + + @app.route("/admin/groups//edit", methods=["GET", "POST"]) + @login_required + def admin_group_edit(gid: int): + g = db.session.get(ServiceGroup, gid) + if not g: + flash("分组不存在", "error") + return redirect(url_for("admin_groups")) + form = GroupForm(obj=g) + if form.validate_on_submit(): + g.name = form.name.data.strip() + g.sort_order = form.sort_order.data or 0 + db.session.commit() + flash("分组已更新", "success") + return redirect(url_for("admin_groups")) + return render_template("admin_group_form.html", form=form, title="编辑分组") + + @app.route("/admin/groups//delete", methods=["POST"]) + @login_required + def admin_group_delete(gid: int): + g = db.session.get(ServiceGroup, gid) + if not g: + flash("分组不存在", "error") + return redirect(url_for("admin_groups")) + db.session.delete(g) + db.session.commit() + flash("分组及其下属服务已删除", "success") + return redirect(url_for("admin_groups")) + + # ---------- 服务管理 ---------- + @app.route("/admin/services") + @login_required + def admin_services(): + groups = ServiceGroup.query.order_by( + ServiceGroup.sort_order, ServiceGroup.id + ).all() + gid = request.args.get("group_id", type=int) + q = Service.query + if gid: + q = q.filter_by(group_id=gid) + services = q.order_by(Service.group_id, Service.sort_order, Service.id).all() + return render_template( + "admin_services.html", + services=services, + groups=groups, + filter_group_id=gid, + ) + + @app.route("/admin/services/new", methods=["GET", "POST"]) + @login_required + def admin_service_new(): + form = ServiceForm() + form.group_id.choices = _group_choices() + if not form.group_id.choices: + flash("请先创建至少一个分组", "error") + return redirect(url_for("admin_groups")) + groups = ServiceGroup.query.order_by( + ServiceGroup.sort_order, ServiceGroup.id + ).all() + if request.method == "GET": + gid = request.args.get("group_id", type=int) + if gid and any(gid == c[0] for c in form.group_id.choices): + form.group_id.data = gid + else: + fid = _first_group_id() + if fid is not None: + form.group_id.data = fid + if form.validate_on_submit(): + path = (form.path.data or "").strip() or "/" + s = Service( + name=form.name.data.strip(), + host=form.host.data.strip(), + port=form.port.data, + path=path, + sort_order=form.sort_order.data or 0, + group_id=form.group_id.data, + ) + db.session.add(s) + db.session.commit() + flash("服务已添加", "success") + return redirect(url_for("admin_services")) + return render_template( + "admin_service_form.html", + form=form, + groups=groups, + title="新建服务", + ) + + @app.route("/admin/services//edit", methods=["GET", "POST"]) + @login_required + def admin_service_edit(sid: int): + s = db.session.get(Service, sid) + if not s: + flash("服务不存在", "error") + return redirect(url_for("admin_services")) + form = ServiceForm(obj=s) + form.group_id.choices = _group_choices() + if not form.group_id.choices: + flash("请先创建至少一个分组", "error") + return redirect(url_for("admin_groups")) + groups = ServiceGroup.query.order_by( + ServiceGroup.sort_order, ServiceGroup.id + ).all() + if form.validate_on_submit(): + s.name = form.name.data.strip() + s.host = form.host.data.strip() + s.port = form.port.data + s.path = (form.path.data or "").strip() or "/" + s.sort_order = form.sort_order.data or 0 + s.group_id = form.group_id.data + db.session.commit() + flash("服务已更新", "success") + return redirect(url_for("admin_services")) + return render_template( + "admin_service_form.html", + form=form, + groups=groups, + title="编辑服务", + ) + + @app.route("/admin/services//delete", methods=["POST"]) + @login_required + def admin_service_delete(sid: int): + s = db.session.get(Service, sid) + if not s: + flash("服务不存在", "error") + return redirect(url_for("admin_services")) + db.session.delete(s) + db.session.commit() + flash("服务已删除", "success") + return redirect(url_for("admin_services")) + + return app + + +def _first_group_id() -> Optional[int]: + g = ServiceGroup.query.order_by(ServiceGroup.sort_order, ServiceGroup.id).first() + return g.id if g else None + + +def _group_choices(): + groups = ServiceGroup.query.order_by( + ServiceGroup.sort_order, ServiceGroup.id + ).all() + return [(g.id, g.name) for g in groups] + + +def _ensure_default_user() -> None: + if User.query.count() > 0: + return + u = User(username="admin") + u.set_password("admin123") + db.session.add(u) + db.session.add(ServiceGroup(name="默认分组", sort_order=0)) + db.session.commit() + print("[nav] 首次运行:默认账号 admin 密码 admin123(仅内网使用,建议自行改库或删用户后重建)") + + +app = create_app() + +if __name__ == "__main__": + host = os.environ.get("NAV_HOST", "0.0.0.0") + port = int(os.environ.get("NAV_PORT", "5000")) + app.run(host=host, port=port, debug=os.environ.get("NAV_DEBUG") == "1") diff --git a/forms.py b/forms.py new file mode 100644 index 0000000..d025548 --- /dev/null +++ b/forms.py @@ -0,0 +1,44 @@ +from flask_wtf import FlaskForm +from wtforms import IntegerField, PasswordField, SelectField, StringField, SubmitField +from wtforms.validators import DataRequired, NumberRange, Optional, ValidationError + + +class LoginForm(FlaskForm): + username = StringField("用户名", validators=[DataRequired(message="请输入用户名")]) + password = PasswordField("密码", validators=[DataRequired(message="请输入密码")]) + submit = SubmitField("登录") + + +class GroupForm(FlaskForm): + name = StringField("分组名称", validators=[DataRequired(message="请输入分组名称")]) + sort_order = IntegerField( + "排序(越小越靠前)", + validators=[Optional(), NumberRange(min=-10**6, max=10**6)], + default=0, + ) + submit = SubmitField("保存") + + +class ServiceForm(FlaskForm): + name = StringField("服务名称", validators=[DataRequired(message="请输入服务名称")]) + host = StringField("内网 IP 或主机名", validators=[DataRequired(message="请输入主机")]) + port = IntegerField( + "端口", + validators=[ + DataRequired(message="请输入端口"), + NumberRange(min=1, max=65535, message="端口范围 1–65535"), + ], + ) + path = StringField("路径(可选,默认 /)", validators=[Optional()]) + group_id = SelectField("分组", coerce=int, validators=[DataRequired(message="请选择分组")]) + sort_order = IntegerField( + "排序(越小越靠前)", + validators=[Optional(), NumberRange(min=-10**6, max=10**6)], + default=0, + ) + submit = SubmitField("保存") + + def validate_path(self, field): + v = (field.data or "").strip() + if v and not v.startswith("/"): + raise ValidationError("路径需以 / 开头,例如 /admin") diff --git a/instance/nav_local.db b/instance/nav_local.db new file mode 100644 index 0000000000000000000000000000000000000000..2a8792992af234a33b8e42819d68a70d6966d2ef GIT binary patch literal 24576 zcmeI(&vMd090%~E1==!JddtD7vxhoEW;&G(BqTjaF|AYtNvXE=fJ-*CI2MplTMv#? zrsMbsK7ixV7wGdi>ZMn&CX~=p>F9XG?@mZI+5Ii~eReP5&qgUMB^Z11xUNeXj< zC<^xo5dHT!x2fH7vI(!+&QWrRa~e2x^6peWw+ftXjSSwxDd%&W~OYCT;aaC zMMm``#R3U>lXws>cUMDG()_%5Qt7bjODE_N9K-!_%bAtDc_}_RQ|I2BmCZHNB86g^ z6gKnuMG~E9uziO-$ynJ387oeeggn%`*K|9BRz+2ZTU*U`XXKzQ*4Z0`sZ<#bbG@)i zv(26M$lxH=k&Tb7+@p-OO&*%tBwpvTpTcUP{GPr4Y%a!1g14bS00Izz00bZa0SG_<0uX=z1R(IY0>_JD zSdh9U#`o(D*KWUQb(WJ#TGN;HWxBAwlYPN9*zV!0;+DI!(pab&y3S~gacXN-tx6eB zX||qnlD1(a5_ZB?St_MbuGSKcZr7MW(@C8gTvJq|#+Z^yGMmw~$|>cX>$+aGjT+}# zHBA}gT3YAS8z+(QN2ckNs<9+hsh+HANrO?vhZi|IdWj*?1+0Y#;yu2tWV=5P$##AOHafKmY>&ufUub67Ed@ zIQ{tj%crj&-hVqi@t^;_KmDUX00Izz00bZa0SG_<0uX=z1RyZR0{-v+@%%r=6O7a# X009U<00Izz00bZa0SG_<0-nGx#qCS* literal 0 HcmV?d00001 diff --git a/models.py b/models.py new file mode 100644 index 0000000..19bd418 --- /dev/null +++ b/models.py @@ -0,0 +1,54 @@ +from flask_sqlalchemy import SQLAlchemy +from flask_login import UserMixin +from werkzeug.security import generate_password_hash, check_password_hash + +db = SQLAlchemy() + + +class User(UserMixin, db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False, index=True) + password_hash = db.Column(db.String(256), nullable=False) + + def set_password(self, password: str) -> None: + self.password_hash = generate_password_hash(password) + + def check_password(self, password: str) -> bool: + return check_password_hash(self.password_hash, password) + + +class ServiceGroup(db.Model): + __tablename__ = "service_groups" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(120), nullable=False) + sort_order = db.Column(db.Integer, nullable=False, default=0) + + services = db.relationship( + "Service", + backref="group", + lazy="dynamic", + cascade="all, delete-orphan", + ) + + +class Service(db.Model): + __tablename__ = "services" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(120), nullable=False) + host = db.Column(db.String(255), nullable=False) + port = db.Column(db.Integer, nullable=False) + path = db.Column(db.String(512), nullable=False, default="/") + sort_order = db.Column(db.Integer, nullable=False, default=0) + group_id = db.Column( + db.Integer, db.ForeignKey("service_groups.id"), nullable=False, index=True + ) + + def build_url(self) -> str: + p = (self.path or "/").strip() + if not p.startswith("/"): + p = "/" + p + return f"http://{self.host}:{self.port}{p}" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3088fda --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +Flask>=3.0.0 +Flask-SQLAlchemy>=3.1.0 +Flask-Login>=0.6.3 +Flask-WTF>=1.2.0 +WTForms>=3.1.0 +Werkzeug>=3.0.0 diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..99022b8 --- /dev/null +++ b/static/style.css @@ -0,0 +1,324 @@ +:root { + --bg: #0f1419; + --panel: #1a2332; + --border: #2d3a4d; + --text: #e8eef5; + --muted: #8b9cb3; + --accent: #3d8bfd; + --accent-dim: #2563c7; + --danger: #e05252; + --success: #3ecf8e; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; +} + +a { + color: var(--accent); + text-decoration: none; +} +a:hover { + text-decoration: underline; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.65rem 1.25rem; + background: var(--panel); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.topbar h1 { + margin: 0; + font-size: 1.05rem; + font-weight: 600; + letter-spacing: 0.02em; +} + +.topbar nav { + display: flex; + gap: 1rem; + align-items: center; + font-size: 0.9rem; +} + +.topbar .user { + color: var(--muted); + font-size: 0.85rem; +} + +.app-shell { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.layout-main { + display: flex; + flex: 1; + min-height: 0; +} + +.sidebar { + width: 280px; + min-width: 240px; + max-width: 40vw; + background: var(--panel); + border-right: 1px solid var(--border); + overflow-y: auto; + padding: 0.75rem 0; + flex-shrink: 0; +} + +.sidebar-section { + margin-bottom: 1rem; +} + +.sidebar-section h2 { + margin: 0 0 0.35rem; + padding: 0 1rem; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); + font-weight: 600; +} + +.nav-link { + display: block; + padding: 0.45rem 1rem; + color: var(--text); + cursor: pointer; + border-left: 3px solid transparent; + font-size: 0.9rem; + text-decoration: none; +} + +.nav-link:hover { + background: rgba(61, 139, 253, 0.08); + text-decoration: none; +} + +.nav-link.active { + background: rgba(61, 139, 253, 0.15); + border-left-color: var(--accent); + text-decoration: none; +} + +.frame-wrap { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + background: #0a0e12; +} + +.frame-placeholder { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: var(--muted); + font-size: 0.95rem; +} + +.frame-wrap iframe { + flex: 1; + width: 100%; + border: none; + background: #fff; +} + +/* 登录页 */ +.login-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; +} + +.login-card { + width: 100%; + max-width: 380px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 1.75rem 1.5rem; +} + +.login-card h1 { + margin: 0 0 1.25rem; + font-size: 1.25rem; + text-align: center; +} + +.form-row { + margin-bottom: 1rem; +} + +.form-row label { + display: block; + margin-bottom: 0.35rem; + font-size: 0.85rem; + color: var(--muted); +} + +.form-row input, +.form-row select { + width: 100%; + padding: 0.55rem 0.65rem; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); + font-size: 0.95rem; +} + +.btn { + display: inline-block; + padding: 0.5rem 1rem; + border-radius: 6px; + border: none; + font-size: 0.9rem; + cursor: pointer; + font-family: inherit; + text-align: center; + text-decoration: none; +} + +.btn-primary { + background: var(--accent); + color: #fff; + width: 100%; +} +.btn-primary:hover { + background: var(--accent-dim); +} + +.btn-secondary { + background: transparent; + color: var(--muted); + border: 1px solid var(--border); +} +.btn-secondary:hover { + color: var(--text); + border-color: var(--muted); +} + +.btn-danger { + background: rgba(224, 82, 82, 0.2); + color: var(--danger); + border: 1px solid var(--danger); + font-size: 0.8rem; + padding: 0.25rem 0.5rem; +} + +.flash-wrap { + max-width: 960px; + margin: 0 auto 1rem; + padding: 0 1rem; +} + +.flash { + padding: 0.5rem 0.75rem; + border-radius: 6px; + margin-bottom: 0.35rem; + font-size: 0.88rem; +} +.flash.error { + background: rgba(224, 82, 82, 0.15); + color: #f0a8a8; +} +.flash.success { + background: rgba(62, 207, 142, 0.12); + color: var(--success); +} +.flash.info { + background: rgba(61, 139, 253, 0.12); + color: #9ec5ff; +} + +/* 后台表格 */ +.page-wrap { + max-width: 960px; + margin: 0 auto; + padding: 1.25rem 1rem 2rem; +} + +.page-wrap h1 { + margin: 0 0 1rem; + font-size: 1.35rem; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + margin-bottom: 1rem; +} + +.table-wrap { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: 8px; +} + +table.data { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +table.data th, +table.data td { + padding: 0.55rem 0.75rem; + text-align: left; + border-bottom: 1px solid var(--border); +} + +table.data th { + background: var(--panel); + color: var(--muted); + font-weight: 600; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +table.data tr:last-child td { + border-bottom: none; +} + +table.data tr:hover td { + background: rgba(255, 255, 255, 0.02); +} + +.inline-form { + display: inline; +} + +.hint { + font-size: 0.82rem; + color: var(--muted); + margin-top: 0.35rem; +} + +.errors { + color: var(--danger); + font-size: 0.8rem; + margin-top: 0.25rem; +} diff --git a/templates/admin_group_form.html b/templates/admin_group_form.html new file mode 100644 index 0000000..34bea49 --- /dev/null +++ b/templates/admin_group_form.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}{{ title }} · 本地导航{% endblock %} +{% block body %} +
+

{{ title }}

+ +
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for cat, msg in messages %} +
{{ msg }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + +
+ {{ form.hidden_tag() }} +
+ {{ form.name.label }} + {{ form.name() }} + {% if form.name.errors %} +
{{ form.name.errors[0] }}
+ {% endif %} +
+
+ {{ form.sort_order.label }} + {{ form.sort_order() }} + {% if form.sort_order.errors %} +
{{ form.sort_order.errors[0] }}
+ {% endif %} +
+
+ {{ form.submit(class="btn btn-primary", style="width: auto") }} + 取消 +
+
+
+{% endblock %} diff --git a/templates/admin_groups.html b/templates/admin_groups.html new file mode 100644 index 0000000..48b93eb --- /dev/null +++ b/templates/admin_groups.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} +{% block title %}分组管理 · 本地导航{% endblock %} +{% block body %} +
+

分组管理

+ +
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for cat, msg in messages %} +
{{ msg }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + +
+

自定义分组

+ 新建分组 +
+ +
+ + + + + + + + + + + {% for g in groups %} + + + + + + + {% else %} + + + + {% endfor %} + +
ID名称排序操作
{{ g.id }}{{ g.name }}{{ g.sort_order }} + 编辑 + · + 在此分组添加服务 + · +
+ + +
+
暂无分组,点击「新建分组」
+
+
+{% endblock %} diff --git a/templates/admin_service_form.html b/templates/admin_service_form.html new file mode 100644 index 0000000..9f65507 --- /dev/null +++ b/templates/admin_service_form.html @@ -0,0 +1,72 @@ +{% extends "base.html" %} +{% block title %}{{ title }} · 本地导航{% endblock %} +{% block body %} +
+

{{ title }}

+ +
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for cat, msg in messages %} +
{{ msg }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + +
+ {{ form.hidden_tag() }} +
+ {{ form.name.label }} + {{ form.name() }} + {% if form.name.errors %} +
{{ form.name.errors[0] }}
+ {% endif %} +
+
+ {{ form.host.label }} + {{ form.host(placeholder="例如 192.168.1.10 或 主机名") }} + {% if form.host.errors %} +
{{ form.host.errors[0] }}
+ {% endif %} +
+
+ {{ form.port.label }} + {{ form.port() }} + {% if form.port.errors %} +
{{ form.port.errors[0] }}
+ {% endif %} +
+
+ {{ form.path.label }} + {{ form.path(placeholder="/ 或 /path") }} + {% if form.path.errors %} +
{{ form.path.errors[0] }}
+ {% endif %} +
+
+ {{ form.group_id.label }} + {{ form.group_id() }} + {% if form.group_id.errors %} +
{{ form.group_id.errors[0] }}
+ {% endif %} +
+
+ {{ form.sort_order.label }} + {{ form.sort_order() }} + {% if form.sort_order.errors %} +
{{ form.sort_order.errors[0] }}
+ {% endif %} +
+
+ {{ form.submit(class="btn btn-primary", style="width: auto") }} + 取消 +
+
+
+{% endblock %} diff --git a/templates/admin_services.html b/templates/admin_services.html new file mode 100644 index 0000000..87c2452 --- /dev/null +++ b/templates/admin_services.html @@ -0,0 +1,80 @@ +{% extends "base.html" %} +{% block title %}服务管理 · 本地导航{% endblock %} +{% block body %} +
+

服务管理

+ +
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for cat, msg in messages %} +
{{ msg }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + +
+

内网服务

+ 新建服务 +
+ +
+ + +
+ +
+ + + + + + + + + + + + {% for s in services %} + + + + + + + + {% else %} + + + + {% endfor %} + +
名称地址分组排序操作
{{ s.name }}{{ s.build_url() }}{{ s.group.name if s.group else '—' }}{{ s.sort_order }} + 编辑 + · +
+ + +
+
暂无服务,点击「新建服务」
+
+

说明:目标站点若设置禁止被嵌入(如部分面板),iframe 会空白或报错,属对方安全策略,与本站无关。

+
+{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..a59a9cf --- /dev/null +++ b/templates/base.html @@ -0,0 +1,12 @@ + + + + + + {% block title %}本地导航{% endblock %} + + + + {% block body %}{% endblock %} + + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..6802d41 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,79 @@ +{% extends "base.html" %} +{% block title %}导航 · 本地导航{% endblock %} +{% block body %} +
+
+

本地导航

+ +
+{% with messages = get_flashed_messages(with_categories=true) %} +{% if messages %} +
+ {% for cat, msg in messages %} +
{{ msg }}
+ {% endfor %} +
+{% endif %} +{% endwith %} +
+ +
+
在左侧点击服务,在此区域以内嵌方式打开(不跳转、不开新标签)
+ +
+
+
+ +{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..040de09 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %}登录 · 本地导航{% endblock %} +{% block body %} + +{% endblock %} diff --git a/部署与使用说明.md b/部署与使用说明.md new file mode 100644 index 0000000..5246af9 --- /dev/null +++ b/部署与使用说明.md @@ -0,0 +1,372 @@ +# 本地导航站 · 部署与使用说明 + +本文档为 **中文说明** 与 **部署操作** 合一版本,适用于在局域网(Ubuntu / Windows 等)上自建使用,不涉及公网穿透、云服务器 Nginx 或 frp。 + +--- + +## 一、项目概述 + +**本地导航站**是一个基于 **Flask** 的轻量 Web 应用,用于把多台内网机器、多个带端口的本地服务(宝塔、AI 面板、路由管理页、各类后端控制台等)**收纳到同一个入口地址**。 + +- 浏览器打开一个网址即可进入。 +- 左侧为 **自定义分组 + 服务链接列表**。 +- 右侧为 **大尺寸 iframe**:点击左侧链接时,在页面内嵌打开对应 `http://内网IP:端口/路径`,**不整页跳转、不新开标签**(体验类似统一后台)。 + +数据全部落在本机 **SQLite** 文件中,无需安装 MySQL,适合个人或小范围内网使用。 + +--- + +## 二、功能特性 + +| 模块 | 说明 | +|------|------| +| 账号登录 | 用户名 + 密码,会话由 Flask-Login 管理;表单带 CSRF 防护。 | +| 导航首页 | 左右分栏;左侧分组与服务;右侧 iframe 内嵌打开目标页。 | +| 分组管理 | 新增 / 编辑 / 删除分组;支持排序字段(数字越小越靠前)。 | +| 服务管理 | 新增 / 编辑 / 删除服务;字段:名称、内网主机、端口、路径、所属分组、排序。 | +| 数据库 | SQLite,默认文件名为 `nav_local.db`(与运行当前工作目录有关)。 | +| 网络监听 | 默认绑定 `0.0.0.0`,便于同局域网手机、电脑访问。 | + +**说明**:服务访问地址由程序拼接为 `http://{host}:{port}{path}`,当前版本固定为 **HTTP**(内网场景)。若目标服务为 HTTPS,需在后续版本中扩展字段;嵌入 iframe 时浏览器仍按 HTTPS 规则校验混合内容等。 + +--- + +## 三、技术栈与运行要求 + +- **Python**:建议 **3.10+**(3.9 一般也可,未在全部小版本上逐一验证)。 +- **主要依赖**:Flask、Flask-SQLAlchemy、Flask-Login、Flask-WTF、WTForms、Werkzeug。 +- **浏览器**:现代浏览器(Chrome / Edge / Firefox / Safari 等)。 + +--- + +## 四、目录结构(参考) + +``` +本地导航/ +├── app.py # 应用入口、路由、启动参数 +├── models.py # 数据模型(用户、分组、服务) +├── forms.py # 表单定义 +├── requirements.txt # Python 依赖列表 +├── nav_local.db # SQLite 数据库(首次成功运行后生成,勿手误提交到公开仓库) +├── static/ +│ └── style.css # 样式 +└── templates/ # Jinja2 模板(登录、首页、后台页) + ├── base.html + ├── login.html + ├── index.html + ├── admin_groups.html + ├── admin_group_form.html + ├── admin_services.html + └── admin_service_form.html +``` + +--- + +## 五、环境变量说明 + +| 变量名 | 含义 | 默认值 | +|--------|------|--------| +| `NAV_SECRET_KEY` | Flask 会话、CSRF 等加密签名密钥。**生产或长期运行务必设置固定值**,否则重启后会话失效且安全性下降。 | 未设置时每次进程启动随机生成 | +| `NAV_DATABASE_URL` | SQLAlchemy 数据库连接串 | `sqlite:///nav_local.db` | +| `NAV_HOST` | `python app.py` 时监听地址 | `0.0.0.0` | +| `NAV_PORT` | `python app.py` 时监听端口 | `5000` | +| `NAV_DEBUG` | 是否开启调试模式(**勿**在内网多人共用服务器上长期开启) | 未设置或不为 `1` 则为关闭;设为 `1` 开启 | + +**生成密钥示例(Linux / macOS):** + +```bash +export NAV_SECRET_KEY="$(openssl rand -hex 32)" +``` + +**Windows PowerShell 示例:** + +```powershell +$env:NAV_SECRET_KEY = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 48 | ForEach-Object {[char]$_}) +``` + +--- + +## 六、安装与运行(通用) + +### 6.1 获取代码 + +将项目目录放到目标机器上(拷贝、压缩包解压、或 Git 克隆均可)。下文以项目根目录为当前工作目录。 + +### 6.2 创建虚拟环境并安装依赖 + +**Linux / macOS:** + +```bash +cd /path/to/本地导航 +python3 -m venv .venv +source .venv/bin/activate +pip install -U pip +pip install -r requirements.txt +``` + +**Windows(PowerShell):** + +```powershell +cd C:\path\to\本地导航 +python -m venv .venv +.\.venv\Scripts\Activate.ps1 +python -m pip install -U pip +pip install -r requirements.txt +``` + +**若 `pip install` 报错 “No matching distribution” 或版本列表为空**:多为镜像源未同步或网络策略问题,可显式指定官方索引: + +```bash +pip install -r requirements.txt -i https://pypi.org/simple +``` + +### 6.3 直接启动(开发 / 小范围内网) + +```bash +export NAV_SECRET_KEY="$(openssl rand -hex 32)" # Linux,建议每次部署写进 systemd 环境文件 +python app.py +``` + +默认监听 **`http://0.0.0.0:5000`**。在同一局域网内的其他设备浏览器访问: + +```text +http://<本机局域网IP>:5000 +``` + +例如:`http://192.168.1.100:5000`。 + +**注意**:内置的 `app.run()` 为 Flask 开发用服务器,**并发能力与健壮性有限**,适合个人使用或低并发内网场景。需要长期开机、略高并发时,建议使用下文 **Gunicorn** 方式。 + +--- + +## 七、首次登录与默认账号 + +1. 第一次成功启动且数据库中 **没有任何用户** 时,程序会自动创建: + - 用户:**`admin`** + - 密码:**`admin123`** + - 一个名为 **「默认分组」** 的空分组。 +2. 控制台会打印一行提示(内容大意:默认账号仅内网使用,请尽快修改)。 + +**安全建议(强烈)**: + +- 首次登录后,尽快通过可靠方式修改密码。当前版本未内置「改密页」,可自行选用其一: + - 使用 [DB Browser for SQLite](https://sqlitebrowser.org/) 等工具打开 `nav_local.db`,删除 `users` 表中对应用户后,临时改代码跑一次初始化(不推荐反复操作); + - 或自行增加「修改密码」路由(二次开发)。 +- **不要将**带默认口令的数据库文件提交到公开 Git 仓库。 +- 本应用设计为 **纯内网**,请勿直接暴露到公网。 + +--- + +## 八、使用说明(操作层面) + +### 8.1 登录 + +访问站点根路径,未登录会跳转至 **`/login`**,输入用户名与密码即可。 + +### 8.2 导航首页(`/`) + +- **左侧**:按分组展示服务名称;点击后在 **右侧 iframe** 打开对应地址。 +- **顶部**:可进入「分组管理」「服务管理」或退出登录。 + +### 8.3 分组管理(`/admin/groups`) + +- **新建 / 编辑**:填写分组名称、排序。 +- **删除**:会 **同时删除** 该分组下的 **所有服务**(级联删除),请谨慎操作。 +- 列表中可从某分组快捷 **「在此分组添加服务」**。 + +### 8.4 服务管理(`/admin/services`) + +- 字段含义简要说明: + - **服务名称**:左侧显示名称。 + - **内网 IP 或主机名**:如 `192.168.1.10` 或可解析的主机名。 + - **端口**:1–65535。 + - **路径**:可选,须以 `/` 开头;留空则按 `/` 处理。 + - **分组**:必选。 + - **排序**:同分组内数字越小越靠前。 + +### 8.5 关于 iframe 打不开的说明 + +部分网站(尤其银行、部分管理面板)通过 **`X-Frame-Options`** 或 **`Content-Security-Policy`** 禁止被嵌入 iframe,此时右侧区域可能为空白或浏览器控制台报错。这属于 **目标站点安全策略**,与本导航站实现无关。若必须统一入口,只能由目标服务侧放开嵌入策略,或改为新窗口打开(需改代码,非当前默认行为)。 + +--- + +## 九、部署指南(以 Ubuntu 为例) + +以下假设:系统为 **Ubuntu 20.04/22.04/24.04** 等,项目路径为 `/opt/nav-site`,监听端口 **5000**;可根据实际路径与端口修改。 + +### 9.1 系统准备 + +```bash +sudo apt update +sudo apt install -y python3 python3-venv python3-pip +``` + +(若已安装 Python 3 与 venv,可跳过。) + +### 9.2 放置项目并安装依赖 + +```bash +sudo mkdir -p /opt/nav-site +# 将项目文件同步到 /opt/nav-site,保证 app.py、requirements.txt 等在根目录 + +cd /opt/nav-site +sudo python3 -m venv .venv +sudo chown -R $USER:$USER /opt/nav-site +source .venv/bin/activate +pip install -U pip +pip install -r requirements.txt -i https://pypi.org/simple +``` + +### 9.3 配置密钥(必做) + +```bash +sudo mkdir -p /etc/nav-site +sudo bash -c 'openssl rand -hex 32 > /etc/nav-site/secret_key' +sudo chmod 600 /etc/nav-site/secret_key +``` + +后续由 systemd 读取该文件并注入 `NAV_SECRET_KEY`(见下节)。 + +### 9.4 使用 systemd 常驻运行(推荐) + +创建服务文件(仍使用内置 `python app.py` 时示例;若改用 Gunicorn,将 `ExecStart` 改为 gunicorn 命令即可): + +```bash +sudo nano /etc/systemd/system/nav-site.service +``` + +示例内容: + +```ini +[Unit] +Description=Local Nav Flask Site +After=network.target + +[Service] +Type=simple +User=你的Linux用户名 +Group=你的Linux用户组 +WorkingDirectory=/opt/nav-site +EnvironmentFile=-/etc/nav-site/env +ExecStart=/opt/nav-site/.venv/bin/python /opt/nav-site/app.py +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +创建环境文件 `/etc/nav-site/env`(**不要**把密钥写进 Git): + +```bash +sudo nano /etc/nav-site/env +``` + +示例: + +```ini +NAV_SECRET_KEY=粘贴openssl_rand_hex32的输出 +NAV_HOST=0.0.0.0 +NAV_PORT=5000 +``` + +启用并启动: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable nav-site +sudo systemctl start nav-site +sudo systemctl status nav-site +``` + +查看日志: + +```bash +journalctl -u nav-site -f +``` + +### 9.5 防火墙(若启用了 ufw) + +```bash +sudo ufw allow 5000/tcp +sudo ufw reload +``` + +仅内网使用时,也可限制来源网段(示例,请按实际修改): + +```bash +sudo ufw allow from 192.168.0.0/16 to any port 5000 proto tcp +``` + +### 9.6 可选:使用 Gunicorn 提高稳定性 + +安装: + +```bash +source /opt/nav-site/.venv/bin/activate +pip install gunicorn +``` + +`WorkingDirectory` 仍为项目根目录,**保证 `nav_local.db` 路径与工作目录一致**。示例 `ExecStart`: + +```ini +ExecStart=/opt/nav-site/.venv/bin/gunicorn -w 2 -b 0.0.0.0:5000 app:app +``` + +说明:`-w 2` 为 worker 数量,可按机器 CPU 调整;`app:app` 表示 `app.py` 中的全局变量 `app`。 + +--- + +## 十、数据与备份 + +- 默认数据库文件:**`nav_local.db`**,位于 **启动进程时的当前工作目录**(与 `WorkingDirectory` 一致)。 +- 备份:定期复制该文件即可(建议在服务停止或负载极低时复制,避免损坏)。 +- 恢复:替换同名文件后重启服务。 + +--- + +## 十一、路由一览(便于排障与二次开发) + +| 路径 | 说明 | +|------|------| +| `/` | 导航首页(需登录) | +| `/login` | 登录页 | +| `/logout` | 退出登录 | +| `/admin/groups` | 分组列表 | +| `/admin/groups/new` | 新建分组 | +| `/admin/groups//edit` | 编辑分组 | +| `/admin/groups//delete` | 删除分组(POST) | +| `/admin/services` | 服务列表(支持 `?group_id=` 筛选) | +| `/admin/services/new` | 新建服务 | +| `/admin/services//edit` | 编辑服务 | +| `/admin/services//delete` | 删除服务(POST) | + +--- + +## 十二、常见问题(FAQ) + +**Q:手机能打开吗?** +能。只要手机与服务器在同一局域网,且防火墙放行端口,浏览器访问 `http://服务器IP:端口` 即可。 + +**Q:为什么右侧 iframe 是白的?** +常见原因:目标站禁止被嵌入;目标服务宕机或地址/端口填错;浏览器混合内容策略(本应用为 HTTP 打开链接,若目标强制 HTTPS 且策略严格,可能异常)。可在新标签直接打开同一 URL 对比排查。 + +**Q:端口想改成 8080?** +设置环境变量 `NAV_PORT=8080` 后重启进程;防火墙放行对应端口。 + +**Q:忘记密码怎么办?** +若有服务器文件权限,可用 SQLite 工具修改 `users` 表,或删除用户行后通过代码逻辑重新种子用户(需具备运维或开发能力)。 + +**Q:能否放到公网?** +本应用为内网场景设计,未内置 HTTPS、限流、审计等生产级能力。**不建议**直接暴露公网;若必须上公网,请自行叠加反向代理、TLS、访问控制与监控。 + +--- + +## 十三、版本与维护 + +- 依赖版本见 `requirements.txt`;升级依赖前建议在测试环境验证。 +- 修改模板或静态文件后,重启进程即可生效;修改 Python 代码同样需要重启(`NAV_DEBUG=1` 时开发服务器可自动重载,但不建议在生产长期开启)。 + +--- + +**文档结束。** 若你后续增加「HTTPS 链接」「新窗口打开」「修改密码」等功能,建议在本文档对应章节补充说明并保持与代码一致。