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