first commit

This commit is contained in:
dekun
2026-05-12 15:25:03 +08:00
commit 895e1bed0f
15 changed files with 1452 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# 本地导航站
Flask + SQLite 的局域网导航聚合:左侧分组与服务列表,右侧 iframe 内嵌打开 `http://内网IP:端口`,统一入口管理宝塔、面板、本地服务等。
**完整中文说明与部署文档:** [部署与使用说明.md](./部署与使用说明.md)
+264
View File
@@ -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/<int:gid>/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/<int:gid>/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/<int:sid>/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/<int:sid>/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")
+44
View File
@@ -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="端口范围 165535"),
],
)
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")
Binary file not shown.
+54
View File
@@ -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}"
+6
View File
@@ -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
+324
View File
@@ -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;
}
+44
View File
@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block title %}{{ title }} · 本地导航{% endblock %}
{% block body %}
<header class="topbar">
<h1>{{ title }}</h1>
<nav>
<a href="{{ url_for('admin_groups') }}">返回列表</a>
<a href="{{ url_for('index') }}">导航首页</a>
</nav>
</header>
<div class="page-wrap">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-wrap">
{% for cat, msg in messages %}
<div class="flash {{ cat }}">{{ msg }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<form method="post" novalidate>
{{ form.hidden_tag() }}
<div class="form-row">
{{ form.name.label }}
{{ form.name() }}
{% if form.name.errors %}
<div class="errors">{{ form.name.errors[0] }}</div>
{% endif %}
</div>
<div class="form-row">
{{ form.sort_order.label }}
{{ form.sort_order() }}
{% if form.sort_order.errors %}
<div class="errors">{{ form.sort_order.errors[0] }}</div>
{% endif %}
</div>
<div class="toolbar" style="margin-top: 1.25rem">
{{ form.submit(class="btn btn-primary", style="width: auto") }}
<a class="btn btn-secondary" href="{{ url_for('admin_groups') }}">取消</a>
</div>
</form>
</div>
{% endblock %}
+69
View File
@@ -0,0 +1,69 @@
{% extends "base.html" %}
{% block title %}分组管理 · 本地导航{% endblock %}
{% block body %}
<header class="topbar">
<h1>分组管理</h1>
<nav>
<a href="{{ url_for('index') }}">返回导航</a>
<a href="{{ url_for('admin_services') }}">服务管理</a>
<a href="{{ url_for('logout') }}">退出</a>
</nav>
</header>
<div class="page-wrap">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-wrap">
{% for cat, msg in messages %}
<div class="flash {{ cat }}">{{ msg }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<div class="toolbar">
<h1 style="margin: 0; flex: 1">自定义分组</h1>
<a class="btn btn-primary" href="{{ url_for('admin_group_new') }}" style="width: auto">新建分组</a>
</div>
<div class="table-wrap">
<table class="data">
<thead>
<tr>
<th>ID</th>
<th>名称</th>
<th>排序</th>
<th style="width: 200px">操作</th>
</tr>
</thead>
<tbody>
{% for g in groups %}
<tr>
<td>{{ g.id }}</td>
<td>{{ g.name }}</td>
<td>{{ g.sort_order }}</td>
<td>
<a href="{{ url_for('admin_group_edit', gid=g.id) }}">编辑</a>
·
<a href="{{ url_for('admin_service_new', group_id=g.id) }}">在此分组添加服务</a>
·
<form
class="inline-form"
method="post"
action="{{ url_for('admin_group_delete', gid=g.id) }}"
onsubmit="return confirm('确定删除该分组?其下所有服务也会被删除。');"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<button type="submit" class="btn btn-danger">删除</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="hint">暂无分组,点击「新建分组」</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+72
View File
@@ -0,0 +1,72 @@
{% extends "base.html" %}
{% block title %}{{ title }} · 本地导航{% endblock %}
{% block body %}
<header class="topbar">
<h1>{{ title }}</h1>
<nav>
<a href="{{ url_for('admin_services') }}">返回列表</a>
<a href="{{ url_for('index') }}">导航首页</a>
</nav>
</header>
<div class="page-wrap">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-wrap">
{% for cat, msg in messages %}
<div class="flash {{ cat }}">{{ msg }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<form method="post" novalidate>
{{ form.hidden_tag() }}
<div class="form-row">
{{ form.name.label }}
{{ form.name() }}
{% if form.name.errors %}
<div class="errors">{{ form.name.errors[0] }}</div>
{% endif %}
</div>
<div class="form-row">
{{ form.host.label }}
{{ form.host(placeholder="例如 192.168.1.10 或 主机名") }}
{% if form.host.errors %}
<div class="errors">{{ form.host.errors[0] }}</div>
{% endif %}
</div>
<div class="form-row">
{{ form.port.label }}
{{ form.port() }}
{% if form.port.errors %}
<div class="errors">{{ form.port.errors[0] }}</div>
{% endif %}
</div>
<div class="form-row">
{{ form.path.label }}
{{ form.path(placeholder="/ 或 /path") }}
{% if form.path.errors %}
<div class="errors">{{ form.path.errors[0] }}</div>
{% endif %}
</div>
<div class="form-row">
{{ form.group_id.label }}
{{ form.group_id() }}
{% if form.group_id.errors %}
<div class="errors">{{ form.group_id.errors[0] }}</div>
{% endif %}
</div>
<div class="form-row">
{{ form.sort_order.label }}
{{ form.sort_order() }}
{% if form.sort_order.errors %}
<div class="errors">{{ form.sort_order.errors[0] }}</div>
{% endif %}
</div>
<div class="toolbar" style="margin-top: 1.25rem">
{{ form.submit(class="btn btn-primary", style="width: auto") }}
<a class="btn btn-secondary" href="{{ url_for('admin_services') }}">取消</a>
</div>
</form>
</div>
{% endblock %}
+80
View File
@@ -0,0 +1,80 @@
{% extends "base.html" %}
{% block title %}服务管理 · 本地导航{% endblock %}
{% block body %}
<header class="topbar">
<h1>服务管理</h1>
<nav>
<a href="{{ url_for('index') }}">返回导航</a>
<a href="{{ url_for('admin_groups') }}">分组管理</a>
<a href="{{ url_for('logout') }}">退出</a>
</nav>
</header>
<div class="page-wrap">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-wrap">
{% for cat, msg in messages %}
<div class="flash {{ cat }}">{{ msg }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<div class="toolbar">
<h1 style="margin: 0; flex: 1">内网服务</h1>
<a class="btn btn-primary" href="{{ url_for('admin_service_new') }}" style="width: auto">新建服务</a>
</div>
<form class="toolbar" method="get" action="{{ url_for('admin_services') }}">
<label for="group_id" class="hint" style="margin: 0">按分组筛选</label>
<select name="group_id" id="group_id" onchange="this.form.submit()">
<option value="">全部分组</option>
{% for g in groups %}
<option value="{{ g.id }}" {% if filter_group_id == g.id %}selected{% endif %}>{{ g.name }}</option>
{% endfor %}
</select>
</form>
<div class="table-wrap">
<table class="data">
<thead>
<tr>
<th>名称</th>
<th>地址</th>
<th>分组</th>
<th>排序</th>
<th style="width: 140px">操作</th>
</tr>
</thead>
<tbody>
{% for s in services %}
<tr>
<td>{{ s.name }}</td>
<td><code style="font-size: 0.82rem">{{ s.build_url() }}</code></td>
<td>{{ s.group.name if s.group else '—' }}</td>
<td>{{ s.sort_order }}</td>
<td>
<a href="{{ url_for('admin_service_edit', sid=s.id) }}">编辑</a>
·
<form
class="inline-form"
method="post"
action="{{ url_for('admin_service_delete', sid=s.id) }}"
onsubmit="return confirm('确定删除该服务?');"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<button type="submit" class="btn btn-danger">删除</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="hint">暂无服务,点击「新建服务」</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="hint">说明:目标站点若设置禁止被嵌入(如部分面板),iframe 会空白或报错,属对方安全策略,与本站无关。</p>
</div>
{% endblock %}
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}本地导航{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" />
</head>
<body>
{% block body %}{% endblock %}
</body>
</html>
+79
View File
@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block title %}导航 · 本地导航{% endblock %}
{% block body %}
<div class="app-shell">
<header class="topbar">
<h1>本地导航</h1>
<nav>
<span class="user">{{ current_user.username }}</span>
<a href="{{ url_for('admin_groups') }}">分组管理</a>
<a href="{{ url_for('admin_services') }}">服务管理</a>
<a href="{{ url_for('logout') }}">退出</a>
</nav>
</header>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-wrap" style="padding-top: 0.5rem">
{% for cat, msg in messages %}
<div class="flash {{ cat }}">{{ msg }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<div class="layout-main">
<aside class="sidebar" id="sidebar">
{% for group, services in grouped %}
<div class="sidebar-section">
<h2>{{ group.name }}</h2>
{% for svc in services %}
<a
href="#"
class="nav-link"
role="button"
data-url="{{ svc.build_url() }}"
data-name="{{ svc.name | e }}"
>{{ svc.name }}</a
>
{% else %}
<div class="hint" style="padding: 0 1rem">该分组下暂无服务</div>
{% endfor %}
</div>
{% else %}
<div class="hint" style="padding: 1rem">
暂无分组与服务,请到「分组管理」「服务管理」添加。
</div>
{% endfor %}
</aside>
<div class="frame-wrap">
<div class="frame-placeholder" id="placeholder">在左侧点击服务,在此区域以内嵌方式打开(不跳转、不开新标签)</div>
<iframe id="svc-frame" title="内嵌服务" hidden></iframe>
</div>
</div>
</div>
<script>
(function () {
var frame = document.getElementById("svc-frame");
var placeholder = document.getElementById("placeholder");
var links = document.querySelectorAll(".nav-link[data-url]");
function setActive(el) {
links.forEach(function (a) {
a.classList.remove("active");
});
if (el) el.classList.add("active");
}
links.forEach(function (a) {
a.addEventListener("click", function (e) {
e.preventDefault();
var url = a.getAttribute("data-url");
if (!url) return;
placeholder.hidden = true;
frame.hidden = false;
frame.src = url;
setActive(a);
});
});
})();
</script>
{% endblock %}
+27
View File
@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}登录 · 本地导航{% endblock %}
{% block body %}
<div class="login-page">
<div class="login-card">
<h1>本地导航站</h1>
<form method="post" novalidate>
{{ form.hidden_tag() }}
<div class="form-row">
{{ form.username.label }}
{{ form.username(class="") }}
{% if form.username.errors %}
<div class="errors">{{ form.username.errors[0] }}</div>
{% endif %}
</div>
<div class="form-row">
{{ form.password.label }}
{{ form.password(class="") }}
{% if form.password.errors %}
<div class="errors">{{ form.password.errors[0] }}</div>
{% endif %}
</div>
<div class="form-row">{{ form.submit(class="btn btn-primary") }}</div>
</form>
</div>
</div>
{% endblock %}
+372
View File
@@ -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
```
**WindowsPowerShell):**
```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` 或可解析的主机名。
- **端口**165535。
- **路径**:可选,须以 `/` 开头;留空则按 `/` 处理。
- **分组**:必选。
- **排序**:同分组内数字越小越靠前。
### 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/<id>/edit` | 编辑分组 |
| `/admin/groups/<id>/delete` | 删除分组(POST |
| `/admin/services` | 服务列表(支持 `?group_id=` 筛选) |
| `/admin/services/new` | 新建服务 |
| `/admin/services/<id>/edit` | 编辑服务 |
| `/admin/services/<id>/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 链接」「新窗口打开」「修改密码」等功能,建议在本文档对应章节补充说明并保持与代码一致。