first commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# 本地导航站
|
||||
|
||||
Flask + SQLite 的局域网导航聚合:左侧分组与服务列表,右侧 iframe 内嵌打开 `http://内网IP:端口`,统一入口管理宝塔、面板、本地服务等。
|
||||
|
||||
**完整中文说明与部署文档:** [部署与使用说明.md](./部署与使用说明.md)
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
Binary file not shown.
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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/<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 链接」「新窗口打开」「修改密码」等功能,建议在本文档对应章节补充说明并保持与代码一致。
|
||||
Reference in New Issue
Block a user