Add system settings, one-click deploy, and simplify embed UI.
Remove Gate scout auto-seeding and the manual hub login button while keeping Gate login and instance SSO. Add password change and database backup/restore, plus deploy scripts with env auto-generation. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+8
-20
@@ -1,12 +1,14 @@
|
||||
# 复制本文件为 .env 后按需修改(.env 勿提交到 Git)
|
||||
# 与 app.py 同目录;程序启动时自动加载。
|
||||
# 也可运行 python scripts/deploy.py 自动生成本文件并填入默认值。
|
||||
|
||||
# 必填(长期运行):随机字符串,用于会话与 CSRF。生成见下方「密钥」说明。
|
||||
# 必填(长期运行):随机字符串,用于会话与 CSRF。
|
||||
# 一键部署脚本会自动生成;手动生成:openssl rand -hex 32
|
||||
# NAV_SECRET_KEY=
|
||||
|
||||
# 首次启动且库中没有任何用户时:创建首个管理员(并建默认分组)
|
||||
# NAV_ADMIN_USERNAME=admin
|
||||
# NAV_ADMIN_PASSWORD=请改成强密码
|
||||
# NAV_ADMIN_PASSWORD=admin123
|
||||
|
||||
# 若库中已有用户(例如曾创建过 admin),仅改 .env 不会改旧账号密码。
|
||||
# 下面两项在「每次启动」时生效:
|
||||
@@ -40,25 +42,11 @@
|
||||
# 本地导航代登录中控(服务端请求云端 /api/auth/login,再打开 /embed-auth)
|
||||
# NAV_HUB_USERNAME=admin
|
||||
# NAV_HUB_PASSWORD=你的中控密码
|
||||
# 打开标记为「复盘中控」的服务时自动代登录(1=开启)
|
||||
# 打开标记为「复盘中控」的服务时自动代登录(1=开启,推荐开启)
|
||||
# NAV_HUB_AUTO_LOGIN=1
|
||||
|
||||
# ---------- gate_scout_order(Gate 扫单,多在云服务器)----------
|
||||
# 本机 LocalNav 内嵌打开云上扫描端 / 执行器(须 Nginx 反代 + 允许 iframe,见 gate_scout 部署说明 §13)
|
||||
# NAV_SEED_GATE_SCOUT=1
|
||||
# NAV_GATE_SCOUT_UPDATE=1
|
||||
# 方式 A:同一域名不同端口(防火墙已放行 8088/8090)
|
||||
# NAV_GATE_SCOUT_SCHEME=https
|
||||
# NAV_GATE_SCOUT_HOST=你的云服务器域名或IP
|
||||
# NAV_GATE_SCOUT_PORT=8088
|
||||
# NAV_GATE_EXECUTOR_PORT=8090
|
||||
# 方式 B:两个子域名反代到 8088 / 8090(推荐,端口填 443)
|
||||
# NAV_GATE_SCOUT_SCHEME=https
|
||||
# NAV_GATE_SCOUT_SCOUT_HOST=scout.你的域名
|
||||
# NAV_GATE_EXECUTOR_HOST=exec.你的域名
|
||||
# NAV_GATE_SCOUT_PORT=443
|
||||
# NAV_GATE_EXECUTOR_PORT=443
|
||||
# iframe 内自动代登录(须与云端 gate_scout PM2 的 NAV_EMBED_SESSION=1 配合)
|
||||
# ---------- Gate 服务 iframe 代登录(须在服务管理中手动添加并选择嵌入类型)----------
|
||||
# iframe 内自动代登录 Gate 扫描端/执行器
|
||||
# NAV_GATE_SCOUT_USERNAME=admin
|
||||
# NAV_GATE_SCOUT_PASSWORD=你的扫单密码
|
||||
# NAV_GATE_SCOUT_PASSWORD=你的 Gate 密码
|
||||
# NAV_GATE_SCOUT_AUTO_LOGIN=1
|
||||
|
||||
@@ -8,6 +8,23 @@ Flask + SQLite 的局域网导航聚合:左侧分组与服务列表,右侧 i
|
||||
|
||||
**默认端口:** `5070`(可通过环境变量 `NAV_PORT` 修改)
|
||||
|
||||
## 一键部署
|
||||
|
||||
```bash
|
||||
# Linux / macOS
|
||||
bash scripts/deploy.sh
|
||||
|
||||
# Windows PowerShell
|
||||
.\scripts\deploy.ps1
|
||||
|
||||
# 或直接
|
||||
python scripts/deploy.py
|
||||
```
|
||||
|
||||
脚本会自动:检测 Python 环境、创建虚拟环境、安装依赖、从 `.env.example` 生成 `.env`、写入 `NAV_SECRET_KEY` 与默认账号。
|
||||
|
||||
**默认登录:** `admin` / `admin123`(生产环境请尽快在「系统设置」中修改密码)
|
||||
|
||||
配置:将 `.env.example` 复制为 `.env` 并填写变量(与 `app.py` 同目录);详见说明文档「环境变量与 `.env`」一节。
|
||||
|
||||
进程守护(可选):`pm2 start ecosystem.config.cjs`(需已安装 PM2),详见 [部署与使用说明.md](./部署与使用说明.md) 中「9.7 使用 PM2」。
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from flask import Flask, flash, jsonify, redirect, render_template, request, url_for
|
||||
from flask import Flask, flash, jsonify, redirect, render_template, request, send_file, url_for
|
||||
from flask_login import LoginManager, current_user, login_required, login_user, logout_user
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
from forms import GroupForm, LoginForm, ServiceForm
|
||||
from forms import ChangePasswordForm, GroupForm, LoginForm, RestoreBackupForm, ServiceForm
|
||||
from models import Service, ServiceGroup, User, db
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent
|
||||
@@ -301,7 +303,6 @@ def create_app() -> Flask:
|
||||
_migrate_schema()
|
||||
_ensure_default_user()
|
||||
_ensure_admin_from_env()
|
||||
_ensure_gate_scout_services()
|
||||
|
||||
@app.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
@@ -624,6 +625,68 @@ def create_app() -> Flask:
|
||||
flash("服务已删除", "success")
|
||||
return redirect(url_for("admin_services"))
|
||||
|
||||
# ---------- 系统设置 ----------
|
||||
@app.route("/admin/settings", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def admin_settings():
|
||||
pwd_form = ChangePasswordForm()
|
||||
restore_form = RestoreBackupForm()
|
||||
db_path = _resolve_db_file()
|
||||
db_size = db_path.stat().st_size if db_path.is_file() else 0
|
||||
|
||||
if pwd_form.validate_on_submit():
|
||||
if not current_user.check_password(pwd_form.current_password.data):
|
||||
flash("原密码不正确", "error")
|
||||
else:
|
||||
current_user.set_password(pwd_form.new_password.data)
|
||||
db.session.commit()
|
||||
flash("密码已更新", "success")
|
||||
return redirect(url_for("admin_settings"))
|
||||
|
||||
if restore_form.validate_on_submit():
|
||||
upload = restore_form.backup_file.data
|
||||
if not upload or not upload.filename:
|
||||
flash("请选择要恢复的 .db 文件", "error")
|
||||
else:
|
||||
try:
|
||||
_restore_db_from_upload(upload)
|
||||
flash(
|
||||
"数据库已恢复。请重启 LocalNav 服务使变更完全生效。",
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for("admin_settings"))
|
||||
except ValueError as exc:
|
||||
flash(str(exc), "error")
|
||||
except OSError as exc:
|
||||
flash(f"恢复失败:{exc}", "error")
|
||||
|
||||
return render_template(
|
||||
"admin_settings.html",
|
||||
pwd_form=pwd_form,
|
||||
restore_form=restore_form,
|
||||
db_path=str(db_path),
|
||||
db_size=db_size,
|
||||
)
|
||||
|
||||
@app.route("/admin/settings/backup")
|
||||
@login_required
|
||||
def admin_settings_backup():
|
||||
try:
|
||||
db_path = _resolve_db_file()
|
||||
except ValueError as exc:
|
||||
flash(str(exc), "error")
|
||||
return redirect(url_for("admin_settings"))
|
||||
if not db_path.is_file():
|
||||
flash("数据库文件不存在", "error")
|
||||
return redirect(url_for("admin_settings"))
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
return send_file(
|
||||
db_path,
|
||||
as_attachment=True,
|
||||
download_name=f"nav_local_{ts}.db",
|
||||
mimetype="application/octet-stream",
|
||||
)
|
||||
|
||||
if os.environ.get("NAV_TRUST_PROXY") == "1":
|
||||
app.wsgi_app = ProxyFix(
|
||||
app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1, x_prefix=1
|
||||
@@ -712,95 +775,42 @@ def _ensure_default_user() -> None:
|
||||
)
|
||||
|
||||
|
||||
def _ensure_gate_scout_services() -> None:
|
||||
"""gate_scout_order:扫描端 + 执行器(NAV_SEED_GATE_SCOUT=1;云服务器用域名 + https)。"""
|
||||
flag = (os.environ.get("NAV_SEED_GATE_SCOUT") or "").strip().lower()
|
||||
if flag not in ("1", "true", "yes", "on"):
|
||||
return
|
||||
def _resolve_db_file() -> Path:
|
||||
"""从 NAV_DATABASE_URL 解析 SQLite 文件路径。"""
|
||||
uri = os.environ.get("NAV_DATABASE_URL", "sqlite:///nav_local.db")
|
||||
if not uri.startswith("sqlite:///"):
|
||||
raise ValueError("当前仅支持 SQLite 数据库的备份与恢复")
|
||||
path_part = uri[len("sqlite:///") :]
|
||||
if not path_part:
|
||||
raise ValueError("数据库路径配置无效")
|
||||
if path_part.startswith("/"):
|
||||
return Path(path_part)
|
||||
return _ROOT / path_part
|
||||
|
||||
scheme = (os.environ.get("NAV_GATE_SCOUT_SCHEME") or "http").strip().lower()
|
||||
if scheme not in ("http", "https"):
|
||||
scheme = "http"
|
||||
default_host = (os.environ.get("NAV_GATE_SCOUT_HOST") or "127.0.0.1").strip() or "127.0.0.1"
|
||||
scout_host = (os.environ.get("NAV_GATE_SCOUT_SCOUT_HOST") or default_host).strip() or default_host
|
||||
exec_host = (os.environ.get("NAV_GATE_EXECUTOR_HOST") or default_host).strip() or default_host
|
||||
default_port = "443" if scheme == "https" else "8088"
|
||||
default_exec_port = "443" if scheme == "https" else "8090"
|
||||
try:
|
||||
scout_port = int(os.environ.get("NAV_GATE_SCOUT_PORT") or default_port)
|
||||
exec_port = int(os.environ.get("NAV_GATE_EXECUTOR_PORT") or default_exec_port)
|
||||
except ValueError:
|
||||
scout_port = 443 if scheme == "https" else 8088
|
||||
exec_port = 443 if scheme == "https" else 8090
|
||||
|
||||
scout_path = (os.environ.get("NAV_GATE_SCOUT_PATH") or "/dashboard").strip() or "/dashboard"
|
||||
exec_path = (os.environ.get("NAV_GATE_EXECUTOR_PATH") or "/dashboard").strip() or "/dashboard"
|
||||
if not scout_path.startswith("/"):
|
||||
scout_path = "/" + scout_path
|
||||
if not exec_path.startswith("/"):
|
||||
exec_path = "/" + exec_path
|
||||
def _restore_db_from_upload(upload) -> None:
|
||||
filename = (upload.filename or "").lower()
|
||||
if not filename.endswith(".db"):
|
||||
raise ValueError("仅支持 .db 格式的 SQLite 备份文件")
|
||||
upload.seek(0, os.SEEK_END)
|
||||
size = upload.tell()
|
||||
upload.seek(0)
|
||||
if size > 50 * 1024 * 1024:
|
||||
raise ValueError("备份文件过大(上限 50MB)")
|
||||
if size < 512:
|
||||
raise ValueError("备份文件过小,可能已损坏")
|
||||
|
||||
update_existing = (os.environ.get("NAV_GATE_SCOUT_UPDATE") or "").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
)
|
||||
db_path = _resolve_db_file()
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if db_path.is_file():
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
bak = db_path.with_name(f"{db_path.name}.bak.{ts}")
|
||||
shutil.copy2(db_path, bak)
|
||||
|
||||
group_name = (os.environ.get("NAV_GATE_SCOUT_GROUP") or "Gate 扫单").strip() or "Gate 扫单"
|
||||
g = ServiceGroup.query.filter_by(name=group_name).first()
|
||||
if not g:
|
||||
g = ServiceGroup(name=group_name, sort_order=50)
|
||||
db.session.add(g)
|
||||
db.session.flush()
|
||||
|
||||
defs = (
|
||||
("Gate 扫描端", scout_host, scout_port, scout_path, 0, "gate_scout"),
|
||||
("Gate 下单执行器", exec_host, exec_port, exec_path, 10, "gate_exec"),
|
||||
)
|
||||
added = 0
|
||||
updated = 0
|
||||
for name, h, port, path, order, embed_k in defs:
|
||||
existing = Service.query.filter_by(group_id=g.id, name=name).first()
|
||||
if existing:
|
||||
if update_existing and (
|
||||
existing.scheme != scheme
|
||||
or existing.host != h
|
||||
or existing.port != port
|
||||
or existing.path != path
|
||||
or (existing.embed_kind or "") != embed_k
|
||||
):
|
||||
existing.scheme = scheme
|
||||
existing.host = h
|
||||
existing.port = port
|
||||
existing.path = path
|
||||
existing.embed_kind = embed_k
|
||||
updated += 1
|
||||
elif not (existing.embed_kind or "").strip():
|
||||
existing.embed_kind = embed_k
|
||||
updated += 1
|
||||
continue
|
||||
db.session.add(
|
||||
Service(
|
||||
name=name,
|
||||
scheme=scheme,
|
||||
host=h,
|
||||
port=port,
|
||||
path=path,
|
||||
sort_order=order,
|
||||
group_id=g.id,
|
||||
embed_kind=embed_k,
|
||||
)
|
||||
)
|
||||
added += 1
|
||||
if added or updated:
|
||||
db.session.commit()
|
||||
print(
|
||||
f"[nav] Gate 扫单:新增 {added}、更新 {updated}(分组「{group_name}」)。"
|
||||
f" 扫描 {scheme}://{scout_host}:{scout_port}{scout_path};"
|
||||
f"执行器 {scheme}://{exec_host}:{exec_port}{exec_path}",
|
||||
flush=True,
|
||||
)
|
||||
db.session.remove()
|
||||
db.engine.dispose()
|
||||
with open(db_path, "wb") as out:
|
||||
shutil.copyfileobj(upload.stream, out)
|
||||
|
||||
|
||||
def _ensure_admin_from_env() -> None:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from flask_wtf.file import FileAllowed, FileField
|
||||
from wtforms import IntegerField, PasswordField, SelectField, StringField, SubmitField
|
||||
from wtforms.validators import DataRequired, NumberRange, Optional, ValidationError
|
||||
from wtforms.validators import DataRequired, EqualTo, Length, NumberRange, Optional, ValidationError
|
||||
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
@@ -78,3 +79,35 @@ class ServiceForm(FlaskForm):
|
||||
v = (field.data or "").strip()
|
||||
if v and not v.startswith("/"):
|
||||
raise ValidationError("路径需以 / 开头,例如 /admin")
|
||||
|
||||
|
||||
class ChangePasswordForm(FlaskForm):
|
||||
current_password = PasswordField(
|
||||
"当前密码", validators=[DataRequired(message="请输入当前密码")]
|
||||
)
|
||||
new_password = PasswordField(
|
||||
"新密码",
|
||||
validators=[
|
||||
DataRequired(message="请输入新密码"),
|
||||
Length(min=6, message="新密码至少 6 位"),
|
||||
],
|
||||
)
|
||||
confirm_password = PasswordField(
|
||||
"确认新密码",
|
||||
validators=[
|
||||
DataRequired(message="请再次输入新密码"),
|
||||
EqualTo("new_password", message="两次输入的新密码不一致"),
|
||||
],
|
||||
)
|
||||
submit = SubmitField("保存密码")
|
||||
|
||||
|
||||
class RestoreBackupForm(FlaskForm):
|
||||
backup_file = FileField(
|
||||
"备份文件",
|
||||
validators=[
|
||||
DataRequired(message="请选择备份文件"),
|
||||
FileAllowed(["db"], message="仅支持 .db 文件"),
|
||||
],
|
||||
)
|
||||
submit = SubmitField("一键恢复")
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env python3
|
||||
"""可选:从数据库中删除「Gate 扫单」分组及其下属服务。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from app import create_app # noqa: E402
|
||||
from models import Service, ServiceGroup, db # noqa: E402
|
||||
|
||||
GROUP_NAMES = ("Gate 扫单",)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
removed_groups = 0
|
||||
removed_services = 0
|
||||
for name in GROUP_NAMES:
|
||||
group = ServiceGroup.query.filter_by(name=name).first()
|
||||
if not group:
|
||||
continue
|
||||
count = Service.query.filter_by(group_id=group.id).count()
|
||||
db.session.delete(group)
|
||||
removed_groups += 1
|
||||
removed_services += count
|
||||
if removed_groups:
|
||||
db.session.commit()
|
||||
print(
|
||||
f"已删除 {removed_groups} 个分组、{removed_services} 个服务。"
|
||||
" 请刷新导航首页。"
|
||||
)
|
||||
else:
|
||||
print("未找到「Gate 扫单」分组,无需清理。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,3 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-Location (Split-Path -Parent $PSScriptRoot)
|
||||
python scripts/deploy.py
|
||||
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env python3
|
||||
"""LocalNav 一键部署:环境检测、生成 .env、安装依赖。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import secrets
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ENV_EXAMPLE = ROOT / ".env.example"
|
||||
ENV_FILE = ROOT / ".env"
|
||||
VENV_DIR = ROOT / ".venv"
|
||||
REQUIREMENTS = ROOT / "requirements.txt"
|
||||
|
||||
MIN_PYTHON = (3, 10)
|
||||
|
||||
DEFAULTS = {
|
||||
"NAV_ADMIN_USERNAME": "admin",
|
||||
"NAV_ADMIN_PASSWORD": "admin123",
|
||||
"NAV_HUB_AUTO_LOGIN": "1",
|
||||
"NAV_COOKIES_INSECURE_HTTP": "1",
|
||||
}
|
||||
|
||||
|
||||
def _ok(msg: str) -> None:
|
||||
print(f"[OK] {msg}")
|
||||
|
||||
|
||||
def _warn(msg: str) -> None:
|
||||
print(f"[WARN] {msg}")
|
||||
|
||||
|
||||
def _fail(msg: str) -> None:
|
||||
print(f"[FAIL] {msg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def check_python() -> str:
|
||||
ver = sys.version_info
|
||||
if ver < MIN_PYTHON:
|
||||
_fail(f"需要 Python {MIN_PYTHON[0]}.{MIN_PYTHON[1]}+,当前 {ver.major}.{ver.minor}")
|
||||
_ok(f"Python {ver.major}.{ver.minor}.{ver.micro}")
|
||||
return sys.executable
|
||||
|
||||
|
||||
def check_pip(py_exe: str) -> None:
|
||||
try:
|
||||
subprocess.run(
|
||||
[py_exe, "-m", "pip", "--version"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
_ok("pip 可用")
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
_fail("pip 不可用,请先安装 pip")
|
||||
|
||||
|
||||
def optional_tools() -> None:
|
||||
for name in ("git", "pm2"):
|
||||
if shutil.which(name):
|
||||
_ok(f"可选工具 {name} 已安装")
|
||||
else:
|
||||
_warn(f"未检测到 {name}(可选)")
|
||||
|
||||
|
||||
def read_env_lines(path: Path) -> list[str]:
|
||||
if not path.is_file():
|
||||
return []
|
||||
return path.read_text(encoding="utf-8-sig").splitlines()
|
||||
|
||||
|
||||
def parse_env_keys(lines: list[str]) -> dict[str, str]:
|
||||
out: dict[str, str] = {}
|
||||
for line in lines:
|
||||
s = line.strip()
|
||||
if not s or s.startswith("#") or "=" not in s:
|
||||
continue
|
||||
key, _, val = s.partition("=")
|
||||
out[key.strip()] = val.strip()
|
||||
return out
|
||||
|
||||
|
||||
def write_env(lines: list[str], updates: dict[str, str]) -> None:
|
||||
existing = parse_env_keys(lines)
|
||||
merged = {**existing, **updates}
|
||||
out_lines: list[str] = []
|
||||
written: set[str] = set()
|
||||
|
||||
for line in lines:
|
||||
s = line.strip()
|
||||
if not s or s.startswith("#") or "=" not in s:
|
||||
out_lines.append(line)
|
||||
continue
|
||||
key, _, _ = s.partition("=")
|
||||
key = key.strip()
|
||||
if key in merged and key not in written:
|
||||
out_lines.append(f"{key}={merged[key]}")
|
||||
written.add(key)
|
||||
else:
|
||||
out_lines.append(line)
|
||||
|
||||
for key, val in merged.items():
|
||||
if key not in written:
|
||||
out_lines.append(f"{key}={val}")
|
||||
ENV_FILE.write_text("\n".join(out_lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def ensure_env() -> None:
|
||||
if not ENV_FILE.is_file():
|
||||
if ENV_EXAMPLE.is_file():
|
||||
shutil.copy2(ENV_EXAMPLE, ENV_FILE)
|
||||
_ok("已从 .env.example 创建 .env")
|
||||
else:
|
||||
ENV_FILE.write_text("", encoding="utf-8")
|
||||
_ok("已创建空 .env")
|
||||
|
||||
lines = read_env_lines(ENV_FILE)
|
||||
parsed = parse_env_keys(lines)
|
||||
updates: dict[str, str] = {}
|
||||
|
||||
secret = parsed.get("NAV_SECRET_KEY", "").strip()
|
||||
if not secret:
|
||||
updates["NAV_SECRET_KEY"] = secrets.token_hex(32)
|
||||
_ok("已自动生成 NAV_SECRET_KEY")
|
||||
|
||||
for key, val in DEFAULTS.items():
|
||||
if not parsed.get(key, "").strip():
|
||||
updates[key] = val
|
||||
|
||||
if updates:
|
||||
write_env(lines, updates)
|
||||
_ok(f"已写入 .env 配置项:{', '.join(updates.keys())}")
|
||||
else:
|
||||
_ok(".env 已完整,未覆盖现有配置")
|
||||
|
||||
|
||||
def venv_python() -> str:
|
||||
if platform.system() == "Windows":
|
||||
py = VENV_DIR / "Scripts" / "python.exe"
|
||||
else:
|
||||
py = VENV_DIR / "bin" / "python"
|
||||
return str(py)
|
||||
|
||||
|
||||
def ensure_venv(base_py: str) -> str:
|
||||
if not VENV_DIR.is_dir():
|
||||
subprocess.run([base_py, "-m", "venv", str(VENV_DIR)], check=True)
|
||||
_ok(f"已创建虚拟环境 {VENV_DIR}")
|
||||
else:
|
||||
_ok("虚拟环境已存在")
|
||||
return venv_python()
|
||||
|
||||
|
||||
def install_requirements(py_exe: str) -> None:
|
||||
cmd = [py_exe, "-m", "pip", "install", "-r", str(REQUIREMENTS)]
|
||||
try:
|
||||
subprocess.run(cmd, check=True, cwd=ROOT)
|
||||
except subprocess.CalledProcessError:
|
||||
_warn("默认 pip 源失败,尝试 https://pypi.org/simple")
|
||||
subprocess.run([*cmd, "-i", "https://pypi.org/simple"], check=True, cwd=ROOT)
|
||||
_ok("依赖安装完成")
|
||||
|
||||
|
||||
def smoke_test(py_exe: str) -> None:
|
||||
subprocess.run(
|
||||
[
|
||||
py_exe,
|
||||
"-c",
|
||||
"from app import create_app; create_app(); print('app import ok')",
|
||||
],
|
||||
check=True,
|
||||
cwd=ROOT,
|
||||
)
|
||||
_ok("应用启动检查通过")
|
||||
|
||||
|
||||
def print_summary() -> None:
|
||||
port = os.environ.get("NAV_PORT", "5070")
|
||||
print()
|
||||
print("=" * 50)
|
||||
print("部署完成")
|
||||
print(f" 访问地址: http://0.0.0.0:{port}")
|
||||
print(" 默认账号: admin / admin123")
|
||||
print(" 生产环境请尽快在「系统设置」中修改密码")
|
||||
print()
|
||||
print("启动方式(任选其一):")
|
||||
if platform.system() == "Windows":
|
||||
print(f" {venv_python()} app.py")
|
||||
else:
|
||||
print(f" {venv_python()} app.py")
|
||||
print(" pm2 start ecosystem.config.cjs")
|
||||
print("=" * 50)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print(f"LocalNav 一键部署 · {platform.system()} {platform.release()}")
|
||||
print(f"项目目录: {ROOT}")
|
||||
print()
|
||||
|
||||
base_py = check_python()
|
||||
check_pip(base_py)
|
||||
optional_tools()
|
||||
ensure_env()
|
||||
py_exe = ensure_venv(base_py)
|
||||
install_requirements(py_exe)
|
||||
smoke_test(py_exe)
|
||||
print_summary()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
python3 scripts/deploy.py
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""向 LocalNav SQLite 写入 gate_scout_order 两个服务(可重复执行,已存在则跳过)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
|
||||
os.environ.setdefault("NAV_SEED_GATE_SCOUT", "1")
|
||||
|
||||
from app import app, db # noqa: E402
|
||||
from app import _ensure_gate_scout_services # noqa: E402
|
||||
|
||||
|
||||
def main() -> None:
|
||||
with app.app_context():
|
||||
_ensure_gate_scout_services()
|
||||
db.session.commit()
|
||||
print("完成。请刷新本地导航首页查看「Gate 扫单」分组。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -687,3 +687,18 @@ table.data tr:hover td {
|
||||
font-size: 0.8rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.settings-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.settings-section h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<nav>
|
||||
<a href="{{ url_for('index') }}">返回导航</a>
|
||||
<a href="{{ url_for('admin_services') }}">服务管理</a>
|
||||
<a href="{{ url_for('admin_settings') }}">系统设置</a>
|
||||
<a href="{{ url_for('logout') }}">退出</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<nav>
|
||||
<a href="{{ url_for('index') }}">返回导航</a>
|
||||
<a href="{{ url_for('admin_groups') }}">分组管理</a>
|
||||
<a href="{{ url_for('admin_settings') }}">系统设置</a>
|
||||
<a href="{{ url_for('logout') }}">退出</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
{% 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('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 %}
|
||||
|
||||
<section class="settings-section">
|
||||
<h2>账户安全</h2>
|
||||
<p class="hint">当前用户:<strong>{{ current_user.username }}</strong></p>
|
||||
<form method="post" novalidate>
|
||||
{{ pwd_form.hidden_tag() }}
|
||||
<div class="form-row">
|
||||
{{ pwd_form.current_password.label }}
|
||||
{{ pwd_form.current_password() }}
|
||||
{% if pwd_form.current_password.errors %}
|
||||
<div class="errors">{{ pwd_form.current_password.errors[0] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="form-row">
|
||||
{{ pwd_form.new_password.label }}
|
||||
{{ pwd_form.new_password() }}
|
||||
{% if pwd_form.new_password.errors %}
|
||||
<div class="errors">{{ pwd_form.new_password.errors[0] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="form-row">
|
||||
{{ pwd_form.confirm_password.label }}
|
||||
{{ pwd_form.confirm_password() }}
|
||||
{% if pwd_form.confirm_password.errors %}
|
||||
<div class="errors">{{ pwd_form.confirm_password.errors[0] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="toolbar" style="margin-top: 1rem">
|
||||
{{ pwd_form.submit(class="btn btn-primary", style="width: auto") }}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<h2>数据备份与恢复</h2>
|
||||
<p class="hint">
|
||||
数据库文件:<code>{{ db_path }}</code>
|
||||
{% if db_size %}
|
||||
(约 {{ "%.1f"|format(db_size / 1024) }} KB)
|
||||
{% else %}
|
||||
(文件不存在或为空)
|
||||
{% endif %}
|
||||
</p>
|
||||
<div class="toolbar">
|
||||
<a class="btn btn-secondary" href="{{ url_for('admin_settings_backup') }}" style="width: auto">一键备份</a>
|
||||
</div>
|
||||
<form
|
||||
method="post"
|
||||
enctype="multipart/form-data"
|
||||
novalidate
|
||||
onsubmit="return confirm('恢复将覆盖当前数据库,是否继续?恢复前会自动生成 .bak 备份。');"
|
||||
>
|
||||
{{ restore_form.hidden_tag() }}
|
||||
<div class="form-row">
|
||||
{{ restore_form.backup_file.label }}
|
||||
{{ restore_form.backup_file() }}
|
||||
{% if restore_form.backup_file.errors %}
|
||||
<div class="errors">{{ restore_form.backup_file.errors[0] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="hint">恢复完成后请重启 LocalNav 服务。</p>
|
||||
<div class="toolbar" style="margin-top: 1rem">
|
||||
{{ restore_form.submit(class="btn btn-secondary", style="width: auto") }}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
+4
-36
@@ -9,6 +9,7 @@
|
||||
<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('admin_settings') }}">系统设置</a>
|
||||
<a href="{{ url_for('logout') }}">退出</a>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -131,20 +132,11 @@
|
||||
type="button"
|
||||
class="btn btn-secondary btn-toolbar-refresh"
|
||||
id="frame-gate-login"
|
||||
title="通过本地导航代登录 Gate 扫单(需配置 NAV_GATE_SCOUT_USERNAME / NAV_GATE_SCOUT_PASSWORD)"
|
||||
title="通过本地导航代登录 Gate 服务(需配置 NAV_GATE_SCOUT_USERNAME / NAV_GATE_SCOUT_PASSWORD)"
|
||||
hidden
|
||||
>
|
||||
Gate 登录
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-toolbar-refresh"
|
||||
id="frame-hub-login"
|
||||
title="通过本地导航代登录云端中控(需配置 NAV_HUB_USERNAME / NAV_HUB_PASSWORD)"
|
||||
hidden
|
||||
>
|
||||
中控登录
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-toolbar-refresh"
|
||||
@@ -228,7 +220,6 @@
|
||||
var btnOpenTab = document.getElementById("frame-open-tab");
|
||||
var btnBack = document.getElementById("frame-back-overview");
|
||||
var btnBackHub = document.getElementById("frame-back-hub");
|
||||
var btnHubLogin = document.getElementById("frame-hub-login");
|
||||
var btnGateLogin = document.getElementById("frame-gate-login");
|
||||
var btnInstanceSso = document.getElementById("frame-instance-sso");
|
||||
var currentBaseUrl = "";
|
||||
@@ -282,10 +273,6 @@
|
||||
if (btnInstanceSso) btnInstanceSso.hidden = !show;
|
||||
}
|
||||
|
||||
function toggleHubLoginBtn(show) {
|
||||
if (btnHubLogin) btnHubLogin.hidden = !show;
|
||||
}
|
||||
|
||||
function toggleGateLoginBtn(show) {
|
||||
if (btnGateLogin) btnGateLogin.hidden = !show;
|
||||
}
|
||||
@@ -324,12 +311,10 @@
|
||||
currentViewMode = "hub";
|
||||
toggleInstanceBackBtn(false);
|
||||
toggleInstanceSsoBtn(false);
|
||||
toggleHubLoginBtn(true);
|
||||
return;
|
||||
}
|
||||
currentViewMode = "hub-instance";
|
||||
toggleInstanceBackBtn(true);
|
||||
toggleHubLoginBtn(false);
|
||||
toggleInstanceSsoBtn(!!(instanceNavCtx && instanceNavCtx.exchangeId));
|
||||
}
|
||||
|
||||
@@ -415,7 +400,6 @@
|
||||
};
|
||||
currentViewMode = "hub-instance";
|
||||
if (data.title) nameEl.textContent = data.title;
|
||||
toggleHubLoginBtn(false);
|
||||
toggleInstanceBackBtn(true);
|
||||
toggleInstanceSsoBtn(!!instanceNavCtx.exchangeId);
|
||||
return;
|
||||
@@ -455,7 +439,6 @@
|
||||
};
|
||||
currentViewMode = "hub-instance";
|
||||
nameEl.textContent = instanceNavCtx.title;
|
||||
toggleHubLoginBtn(false);
|
||||
toggleInstanceBackBtn(true);
|
||||
applyIframeUrl(data.url);
|
||||
});
|
||||
@@ -508,7 +491,6 @@
|
||||
currentNextPath = st.nextPath || currentNextPath;
|
||||
nameEl.textContent = st.name || nameEl.textContent;
|
||||
}
|
||||
toggleHubLoginBtn(isHubEmbed(currentEmbedKind));
|
||||
if (isHubEmbed(currentEmbedKind) && hubAutoLogin) {
|
||||
hubLoginViaProxy(function (ok) {
|
||||
if (!ok) applyIframeUrl(currentOpenUrl || currentBaseUrl);
|
||||
@@ -553,19 +535,18 @@
|
||||
dashboard.hidden = true;
|
||||
frameStack.hidden = false;
|
||||
frame.hidden = false;
|
||||
toggleHubLoginBtn(isHubEmbed(currentEmbedKind) && !hubAutoLogin);
|
||||
toggleGateLoginBtn(isGateScoutEmbed(currentEmbedKind) && !gateScoutAutoLogin);
|
||||
if (isHubEmbed(currentEmbedKind) && hubAutoLogin) {
|
||||
hubLoginViaProxy(function (ok, err) {
|
||||
if (!ok) {
|
||||
applyIframeUrl(url);
|
||||
toggleHubLoginBtn(true);
|
||||
if (err) console.warn("[LocalNav] 中控代登录失败:", err);
|
||||
}
|
||||
});
|
||||
var nav = preferredNav || findNavLink(url);
|
||||
setActive(nav);
|
||||
return;
|
||||
}
|
||||
toggleGateLoginBtn(isGateScoutEmbed(currentEmbedKind) && !gateScoutAutoLogin);
|
||||
if (isGateScoutEmbed(currentEmbedKind) && gateScoutAutoLogin) {
|
||||
gateScoutLoginViaProxy(function (ok, err) {
|
||||
if (!ok) {
|
||||
@@ -682,7 +663,6 @@
|
||||
frame.hidden = true;
|
||||
frameStack.hidden = true;
|
||||
dashboard.hidden = false;
|
||||
toggleHubLoginBtn(false);
|
||||
toggleGateLoginBtn(false);
|
||||
toggleInstanceSsoBtn(false);
|
||||
setActive(null);
|
||||
@@ -731,18 +711,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
if (btnHubLogin) {
|
||||
btnHubLogin.addEventListener("click", function () {
|
||||
btnHubLogin.disabled = true;
|
||||
hubLoginViaProxy(function (ok, err) {
|
||||
btnHubLogin.disabled = false;
|
||||
if (!ok && err) {
|
||||
window.alert("中控登录失败:\n" + err + "\n\n请检查 LocalNav .env 的 NAV_HUB_USERNAME / NAV_HUB_PASSWORD 是否与云端 hub .env 一致。");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (btnInstanceSso) {
|
||||
btnInstanceSso.addEventListener("click", function () {
|
||||
btnInstanceSso.disabled = true;
|
||||
|
||||
+101
-70
@@ -26,6 +26,7 @@
|
||||
| 导航首页 | 左右分栏;左侧分组与服务;右侧 iframe 内嵌打开目标页。 |
|
||||
| 分组管理 | 新增 / 编辑 / 删除分组;支持排序字段(数字越小越靠前)。 |
|
||||
| 服务管理 | 新增 / 编辑 / 删除服务;字段:名称、内网主机、端口、路径、所属分组、排序。 |
|
||||
| 系统设置 | 修改登录密码;数据库一键备份与恢复。 |
|
||||
| 数据库 | SQLite,默认文件名为 `nav_local.db`(与运行当前工作目录有关)。 |
|
||||
| 网络监听 | 默认绑定 `0.0.0.0`,便于同局域网手机、电脑访问。 |
|
||||
|
||||
@@ -52,6 +53,11 @@
|
||||
├── .env.example # 环境变量模板(复制为 .env 后修改)
|
||||
├── .env # 本地配置(自建,勿提交 Git)
|
||||
├── ecosystem.config.cjs # PM2 守护进程配置
|
||||
├── scripts/
|
||||
│ ├── deploy.py # 一键部署(环境检测、生成 .env)
|
||||
│ ├── deploy.sh # Linux/macOS 封装
|
||||
│ ├── deploy.ps1 # Windows 封装
|
||||
│ └── cleanup_gate_scout.py # 可选:清理旧「Gate 扫单」分组
|
||||
├── nav_local.db # SQLite 数据库(首次成功运行后生成,勿手误提交到公开仓库)
|
||||
├── static/
|
||||
│ └── style.css # 样式
|
||||
@@ -63,11 +69,50 @@
|
||||
├── admin_group_form.html
|
||||
├── admin_services.html
|
||||
└── admin_service_form.html
|
||||
└── admin_settings.html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、环境变量与 `.env` 文件
|
||||
## 五、一键部署(推荐)
|
||||
|
||||
项目提供跨平台部署脚本,自动完成环境检测、虚拟环境创建、依赖安装、`.env` 生成与 `NAV_SECRET_KEY` 写入。
|
||||
|
||||
```bash
|
||||
# Linux / macOS
|
||||
bash scripts/deploy.sh
|
||||
|
||||
# Windows PowerShell
|
||||
.\scripts\deploy.ps1
|
||||
|
||||
# 或直接
|
||||
python scripts/deploy.py
|
||||
```
|
||||
|
||||
**脚本行为:**
|
||||
|
||||
| 步骤 | 说明 |
|
||||
|------|------|
|
||||
| 环境检测 | Python 3.10+、pip;可选检测 git、pm2 |
|
||||
| 生成 `.env` | 从 `.env.example` 复制(若不存在) |
|
||||
| `NAV_SECRET_KEY` | 若为空则自动生成 64 位 hex |
|
||||
| 默认账号 | `NAV_ADMIN_USERNAME=admin`、`NAV_ADMIN_PASSWORD=admin123` |
|
||||
| 中控自动登录 | 默认 `NAV_HUB_AUTO_LOGIN=1` |
|
||||
| 内网 Cookie | 默认 `NAV_COOKIES_INSECURE_HTTP=1`(便于 `http://IP:端口` 访问) |
|
||||
|
||||
**注意:** 脚本不会覆盖 `.env` 中已有的 `NAV_SECRET_KEY` 等配置。生产环境部署后请尽快登录并在「系统设置」中修改密码。
|
||||
|
||||
部署完成后启动:
|
||||
|
||||
```bash
|
||||
.venv/bin/python app.py # Linux
|
||||
# 或
|
||||
pm2 start ecosystem.config.cjs # 需已安装 PM2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、环境变量与 `.env` 文件
|
||||
|
||||
程序启动时会从 **与 `app.py` 同目录** 的 `.env` 文件加载变量(依赖 `python-dotenv`)。**若 `.env` 不存在则跳过,不影响启动。**
|
||||
|
||||
@@ -106,7 +151,7 @@ $env:NAV_SECRET_KEY = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count
|
||||
|
||||
---
|
||||
|
||||
## 六、安装与运行(通用)
|
||||
## 七、安装与运行(通用)
|
||||
|
||||
### 6.1 获取代码
|
||||
|
||||
@@ -174,43 +219,42 @@ http://<本机局域网IP>:5070
|
||||
|
||||
---
|
||||
|
||||
## 七、首次登录与默认账号
|
||||
## 八、首次登录与默认账号
|
||||
|
||||
1. 第一次成功启动且数据库中 **没有任何用户** 时,程序会自动创建:
|
||||
- 用户:**`admin`**
|
||||
- 密码:**`admin123`**
|
||||
- 一个名为 **「默认分组」** 的空分组。
|
||||
2. 控制台会打印一行提示(内容大意:默认账号仅内网使用,请尽快修改)。
|
||||
3. 一键部署脚本 `scripts/deploy.py` 会写入相同的默认账号;若 `.env` 中已有 `NAV_ADMIN_USERNAME` / `NAV_ADMIN_PASSWORD` 则以其为准。
|
||||
|
||||
**安全建议(强烈)**:
|
||||
|
||||
- 首次登录后,尽快通过可靠方式修改密码。当前版本未内置「改密页」,可自行选用其一:
|
||||
- 使用 [DB Browser for SQLite](https://sqlitebrowser.org/) 等工具打开 `nav_local.db`,删除 `users` 表中对应用户后,临时改代码跑一次初始化(不推荐反复操作);
|
||||
- 或自行增加「修改密码」路由(二次开发)。
|
||||
- 首次登录后,请进入顶部 **「系统设置」** 修改密码。
|
||||
- **不要将**带默认口令的数据库文件提交到公开 Git 仓库。
|
||||
- 本应用设计为 **内网聚合入口**;若需外网访问,请按 **9.8** 配置 HTTPS 与反向代理,勿将 `5070` 端口裸奔到公网。
|
||||
- 本应用设计为 **内网聚合入口**;若需外网访问,请按 **10.8** 配置 HTTPS 与反向代理,勿将 `5070` 端口裸奔到公网。
|
||||
|
||||
---
|
||||
|
||||
## 八、使用说明(操作层面)
|
||||
## 九、使用说明(操作层面)
|
||||
|
||||
### 8.1 登录
|
||||
### 9.1 登录
|
||||
|
||||
访问站点根路径,未登录会跳转至 **`/login`**,输入用户名与密码即可。
|
||||
|
||||
### 8.2 导航首页(`/`)
|
||||
### 9.2 导航首页(`/`)
|
||||
|
||||
- **左侧**:按分组展示服务名称;点击后在 **右侧 iframe** 打开对应地址。
|
||||
- **顶部**:可进入「分组管理」「服务管理」或退出登录。
|
||||
- **内嵌页工具栏**:「刷新」为普通刷新(追加时间戳参数);「强制刷新」等同 **Ctrl+F5**,先清空 iframe 再带随机参数重新加载,尽量跳过浏览器缓存(跨域 iframe 时效果取决于目标站点策略)。
|
||||
- **顶部**:可进入「分组管理」「服务管理」「系统设置」或退出登录。
|
||||
- **内嵌页工具栏**:「Gate 登录」「实例免密」「刷新」「强制刷新」「新标签页」。中控嵌入须配置 `NAV_HUB_AUTO_LOGIN=1` 自动代登录(无手动「中控登录」按钮)。
|
||||
|
||||
### 8.3 分组管理(`/admin/groups`)
|
||||
### 9.3 分组管理(`/admin/groups`)
|
||||
|
||||
- **新建 / 编辑**:填写分组名称、排序。
|
||||
- **删除**:会 **同时删除** 该分组下的 **所有服务**(级联删除),请谨慎操作。
|
||||
- 列表中可从某分组快捷 **「在此分组添加服务」**。
|
||||
|
||||
### 8.4 服务管理(`/admin/services`)
|
||||
### 9.4 服务管理(`/admin/services`)
|
||||
|
||||
- 字段含义简要说明:
|
||||
- **服务名称**:左侧显示名称。
|
||||
@@ -232,11 +276,17 @@ http://<本机局域网IP>:5070
|
||||
|
||||
生成地址:`https://panel.example.com:443/`
|
||||
|
||||
### 8.5 关于 iframe 打不开的说明
|
||||
### 9.5 系统设置(`/admin/settings`)
|
||||
|
||||
- **修改密码**:输入当前密码与新密码(至少 6 位),保存后立即生效。
|
||||
- **一键备份**:下载当前 `nav_local.db` 文件(文件名带时间戳)。
|
||||
- **一键恢复**:上传 `.db` 备份文件覆盖当前库;恢复前会自动在同目录生成 `nav_local.db.bak.时间戳`。**恢复后请重启 LocalNav 服务。**
|
||||
|
||||
### 9.6 关于 iframe 打不开的说明
|
||||
|
||||
部分网站(尤其银行、部分管理面板)通过 **`X-Frame-Options`** 或 **`Content-Security-Policy`** 禁止被嵌入 iframe,此时右侧区域可能为空白或浏览器控制台报错。这属于 **目标站点安全策略**,与本导航站实现无关。若必须统一入口,只能由目标服务侧放开嵌入策略,或改为新窗口打开(需改代码,非当前默认行为)。
|
||||
|
||||
### 8.6 云端「复盘中控」iframe 嵌入(LocalNav + manual_trading_hub)
|
||||
### 9.7 云端「复盘中控」iframe 嵌入(LocalNav + manual_trading_hub)
|
||||
|
||||
本地导航(如 `http://192.168.x.x:5070`)嵌入 **云端中控**(`https://你的域名:5100`)时,浏览器会把中控 Cookie 视为**跨站第三方**,直接在 iframe 里登录常会「成功但进不去」。
|
||||
|
||||
@@ -249,8 +299,7 @@ http://<本机局域网IP>:5070
|
||||
NAV_HUB_PASSWORD=你的中控密码
|
||||
NAV_HUB_AUTO_LOGIN=1
|
||||
```
|
||||
3. 重启 LocalNav。打开中控时会由**本地服务端**代登录,iframe 再打开 `/embed-auth?token=...` 写入会话。
|
||||
4. 也可在内嵌工具栏点 **「中控登录」** 手动触发。
|
||||
3. 重启 LocalNav。打开中控时会由**本地服务端**自动代登录(`NAV_HUB_AUTO_LOGIN=1`),iframe 再打开 `/embed-auth?token=...` 写入会话。
|
||||
|
||||
**云端中控侧(`crypto_monitor/manual_trading_hub`)**
|
||||
|
||||
@@ -264,7 +313,7 @@ HUB_EMBED_ORIGINS=http://192.168.8.6:5070
|
||||
|
||||
将 `192.168.8.6:5070` 换成你本机访问 LocalNav 的完整 Origin(含协议与端口)。多台电脑可逗号分隔。
|
||||
|
||||
**四实例(币安/Gate/OKX)**:从中控点「实例 / 策略交易 / 复盘」时,**最新版**会由中控 `postMessage` 通知本地导航,在**同一层 iframe** 打开实例 SSO 链接(避免「导航 → 中控 → 实例」三层嵌套导致 Cookie 失效、反复要密码)。工具栏会出现 **「← 中控」** 返回监控区;刷新会由本地导航服务端代签新的 SSO 链接(须已配置 `NAV_HUB_USERNAME` / `NAV_HUB_PASSWORD`)。
|
||||
**四实例(币安/Gate/OKX)**:从中控点「实例 / 策略交易 / 复盘」时,**最新版**会由中控 `postMessage` 通知本地导航,在**同一层 iframe** 打开实例 SSO 链接。工具栏会出现 **「← 中控」** 返回监控区与 **「实例免密」** 按钮;刷新会由本地导航服务端代签新的 SSO 链接(须已配置 `NAV_HUB_USERNAME` / `NAV_HUB_PASSWORD`)。
|
||||
|
||||
四实例 `.env` 建议:
|
||||
|
||||
@@ -276,57 +325,33 @@ HUB_EMBED_PARENT_ORIGINS=https://你的中控域名,http://192.168.x.x:5070
|
||||
|
||||
(`5070` 换成本地导航实际地址;与中控相同的 `HUB_BRIDGE_TOKEN` 必填。)
|
||||
|
||||
### 8.7 gate_scout_order(Gate 扫单)接入
|
||||
### 9.8 Gate 服务 iframe 代登录(手动配置)
|
||||
|
||||
**gate_scout 通常部署在云服务器**;本地导航在本机/局域网,通过 iframe 打开云上面板(不是 `127.0.0.1`)。
|
||||
本版本**不再自动创建**「Gate 扫单」分组。若需内嵌 Gate 扫描端或执行器,请在 **服务管理** 中手动添加服务,**嵌入类型** 选「Gate 扫描端」或「Gate 执行器」。
|
||||
|
||||
**1. 云上**(Nginx 反代示例):
|
||||
|
||||
| 服务 | 本机端口 | 建议对外 |
|
||||
|------|----------|----------|
|
||||
| 扫描端 | 8088 | `https://scout.你的域名` → `127.0.0.1:8088` |
|
||||
| 执行器 | 8090 | `https://exec.你的域名` → `127.0.0.1:8090` |
|
||||
|
||||
进程环境变量(允许被本地导航嵌入):
|
||||
`.env` 配置示例:
|
||||
|
||||
```env
|
||||
NAV_ALLOW_EMBED=true
|
||||
NAV_EMBED_ORIGINS=http://192.168.8.6:5070
|
||||
NAV_GATE_SCOUT_USERNAME=admin
|
||||
NAV_GATE_SCOUT_PASSWORD=你的 Gate 密码
|
||||
NAV_GATE_SCOUT_AUTO_LOGIN=1
|
||||
```
|
||||
|
||||
`5070` 换成本地访问 LocalNav 的地址(可逗号分隔多个)。
|
||||
打开对应服务时由本地服务端代登录;未开启自动登录时,可点工具栏 **「Gate 登录」** 手动触发。
|
||||
|
||||
**2. 本机 LocalNav `.env`:**
|
||||
|
||||
```env
|
||||
NAV_SEED_GATE_SCOUT=1
|
||||
NAV_GATE_SCOUT_UPDATE=1
|
||||
NAV_GATE_SCOUT_SCHEME=https
|
||||
NAV_GATE_SCOUT_SCOUT_HOST=scout.你的域名
|
||||
NAV_GATE_EXECUTOR_HOST=exec.你的域名
|
||||
NAV_GATE_SCOUT_PORT=443
|
||||
NAV_GATE_EXECUTOR_PORT=443
|
||||
```
|
||||
|
||||
若云上直接暴露端口、无子域名,可改用同一 `NAV_GATE_SCOUT_HOST=云IP或域名`,端口 `8088` / `8090`。
|
||||
|
||||
**3. 重启 LocalNav**,或在项目目录执行:
|
||||
**清理旧数据(可选)**:若升级前已有自动种子创建的「Gate 扫单」分组,可执行:
|
||||
|
||||
```bash
|
||||
NAV_SEED_GATE_SCOUT=1 NAV_GATE_SCOUT_UPDATE=1 python scripts/seed_gate_scout.py
|
||||
python scripts/cleanup_gate_scout.py
|
||||
```
|
||||
|
||||
也可在 **服务管理** 里手动改已有「Gate 扫描端」的主机与端口。
|
||||
|
||||
**4. 登录**:使用云上各服务 `config.yaml` 的 `auth` 账号密码。
|
||||
|
||||
---
|
||||
|
||||
## 九、部署指南(以 Ubuntu 为例)
|
||||
## 十、部署指南(以 Ubuntu 为例)
|
||||
|
||||
以下假设:系统为 **Ubuntu 20.04/22.04/24.04** 等,项目路径为 **`/opt/LocalNav`**,监听端口 **5070**,进程以 **root** 用户运行;可根据实际域名与端口修改。
|
||||
|
||||
### 9.1 系统准备
|
||||
### 10.1 系统准备
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
@@ -335,7 +360,7 @@ sudo apt install -y python3 python3-venv python3-pip git
|
||||
|
||||
(若已安装 Python 3、venv 与 git,可跳过。)
|
||||
|
||||
### 9.2 克隆项目并安装依赖
|
||||
### 10.2 克隆项目并安装依赖
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt
|
||||
@@ -358,7 +383,7 @@ pip install -r requirements.txt -i https://pypi.org/simple
|
||||
sudo systemctl restart nav-site
|
||||
```
|
||||
|
||||
### 9.3 配置密钥(必做)
|
||||
### 10.3 配置密钥(必做)
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /etc/nav-site
|
||||
@@ -370,7 +395,7 @@ sudo chmod 600 /etc/nav-site/secret_key
|
||||
|
||||
**替代做法**:也可在项目根目录放置 `.env`(建议 `chmod 600 .env`),`WorkingDirectory` 指向项目根时程序会自动加载。若 systemd 的 `Environment` / `EnvironmentFile` 里已设置同名变量,**以 systemd 为准**(`.env` 不会覆盖已有环境变量)。
|
||||
|
||||
### 9.4 使用 systemd 常驻运行(推荐)
|
||||
### 10.4 使用 systemd 常驻运行(推荐)
|
||||
|
||||
创建服务文件(仍使用内置 `python app.py` 时示例;若改用 Gunicorn,将 `ExecStart` 改为 gunicorn 命令即可):
|
||||
|
||||
@@ -428,7 +453,7 @@ sudo systemctl status nav-site
|
||||
journalctl -u nav-site -f
|
||||
```
|
||||
|
||||
### 9.5 防火墙(若启用了 ufw)
|
||||
### 10.5 防火墙(若启用了 ufw)
|
||||
|
||||
```bash
|
||||
sudo ufw allow 5070/tcp
|
||||
@@ -441,7 +466,7 @@ sudo ufw reload
|
||||
sudo ufw allow from 192.168.0.0/16 to any port 5070 proto tcp
|
||||
```
|
||||
|
||||
### 9.6 可选:使用 Gunicorn 提高稳定性
|
||||
### 10.6 可选:使用 Gunicorn 提高稳定性
|
||||
|
||||
安装:
|
||||
|
||||
@@ -458,7 +483,7 @@ ExecStart=/opt/LocalNav/.venv/bin/gunicorn -w 2 -b 0.0.0.0:5070 app:app
|
||||
|
||||
说明:`-w 2` 为 worker 数量,可按机器 CPU 调整;`app:app` 表示 `app.py` 中的全局变量 `app`。
|
||||
|
||||
### 9.7 使用 PM2 守护进程(可选)
|
||||
### 10.7 使用 PM2 守护进程(可选)
|
||||
|
||||
适合已安装 [PM2](https://pm2.keymetrics.io/) 的环境(常见于用 Node 的服务器上顺带托管 Python 进程)。项目根目录提供 **`ecosystem.config.cjs`**,用虚拟环境里的 Python 直接运行 **`app.py`**(与手动 `python app.py` 一致),**实例数固定为 1**(Flask 内置开发服务器不宜多进程监听同一端口)。
|
||||
|
||||
@@ -508,7 +533,7 @@ pm2 startup
|
||||
|
||||
**说明**:若 `interpreter` 指向的 `.venv/bin/python` 不存在,PM2 会启动失败;请确认虚拟环境路径与 `ecosystem.config.cjs` 中一致。Windows 下脚本会自动使用 `.venv\Scripts\python.exe`。
|
||||
|
||||
### 9.8 外网 HTTPS 访问(Nginx 反向代理)
|
||||
### 10.8 外网 HTTPS 访问(Nginx 反向代理)
|
||||
|
||||
若需从 **公网或外网** 通过 **HTTPS** 访问本导航站(浏览器地址栏为 `https://`),建议在 Ubuntu 上用 **Nginx** 终止 TLS,反代到本机 `127.0.0.1:5070`。Flask 仍监听内网端口,不直接暴露 5070 到公网。
|
||||
|
||||
@@ -597,15 +622,16 @@ sudo ufw reload
|
||||
|
||||
---
|
||||
|
||||
## 十、数据与备份
|
||||
## 十一、数据与备份
|
||||
|
||||
- 默认数据库文件:**`nav_local.db`**,位于 **启动进程时的当前工作目录**(与 `WorkingDirectory` 一致)。
|
||||
- 备份:定期复制该文件即可(建议在服务停止或负载极低时复制,避免损坏)。
|
||||
- 恢复:替换同名文件后重启服务。
|
||||
- **推荐**:登录后进入 **「系统设置」** → **一键备份** 下载数据库文件。
|
||||
- **恢复**:在「系统设置」上传 `.db` 文件一键恢复(恢复前会自动生成 `.bak` 备份);**恢复后请重启服务**。
|
||||
- 也可手动复制 `nav_local.db` 做备份;恢复时替换同名文件后重启服务。
|
||||
|
||||
---
|
||||
|
||||
## 十一、路由一览(便于排障与二次开发)
|
||||
## 十二、路由一览(便于排障与二次开发)
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
@@ -620,10 +646,15 @@ sudo ufw reload
|
||||
| `/admin/services/new` | 新建服务 |
|
||||
| `/admin/services/<id>/edit` | 编辑服务 |
|
||||
| `/admin/services/<id>/delete` | 删除服务(POST) |
|
||||
| `/admin/settings` | 系统设置(改密、备份恢复) |
|
||||
| `/admin/settings/backup` | 下载数据库备份 |
|
||||
| `/api/embed/hub-login` | 中控代登录(自动登录用) |
|
||||
| `/api/embed/gate-scout-login` | Gate 服务代登录 |
|
||||
| `/api/embed/hub-instance-url` | 实例 SSO 免密签发 |
|
||||
|
||||
---
|
||||
|
||||
## 十二、常见问题(FAQ)
|
||||
## 十三、常见问题(FAQ)
|
||||
|
||||
**Q:手机能打开吗?**
|
||||
能。只要手机与服务器在同一局域网,且防火墙放行端口,浏览器访问 `http://服务器IP:端口` 即可。
|
||||
@@ -635,20 +666,20 @@ sudo ufw reload
|
||||
设置环境变量 `NAV_PORT=8080`(示例)后重启进程;防火墙与 Nginx `proxy_pass` 端口需一并修改。默认端口为 **5070**。
|
||||
|
||||
**Q:忘记密码怎么办?**
|
||||
若有服务器文件权限,可用 SQLite 工具修改 `users` 表,或删除用户行后通过代码逻辑重新种子用户(需具备运维或开发能力)。
|
||||
登录后进入 **「系统设置」** 修改密码(须记得当前密码)。若完全无法登录,可用 SQLite 工具修改 `users` 表,或删除用户行后重启让程序按 `.env` 重新创建管理员。
|
||||
|
||||
**Q:能否从外网访问?**
|
||||
可以。推荐在 Ubuntu 上用 **Nginx + HTTPS** 反代到 `127.0.0.1:5070`,并配置 `NAV_TRUST_PROXY=1` 等变量,详见 **9.8 外网 HTTPS 访问**。请务必使用强密码并做好访问控制。
|
||||
可以。推荐在 Ubuntu 上用 **Nginx + HTTPS** 反代到 `127.0.0.1:5070`,并配置 `NAV_TRUST_PROXY=1` 等变量,详见 **10.8 外网 HTTPS 访问**。请务必使用强密码并做好访问控制。
|
||||
|
||||
---
|
||||
|
||||
## 十三、版本与维护
|
||||
## 十四、版本与维护
|
||||
|
||||
- 依赖版本见 `requirements.txt`;升级依赖前建议在测试环境验证。
|
||||
- 修改模板或静态文件后,重启进程即可生效;修改 Python 代码同样需要重启(`NAV_DEBUG=1` 时开发服务器可自动重载,但不建议在生产长期开启)。
|
||||
|
||||
---
|
||||
|
||||
**文档结束。** 若你后续增加「HTTPS 链接」「新窗口打开」「修改密码」等功能,建议在本文档对应章节补充说明并保持与代码一致。
|
||||
**文档结束。**
|
||||
|
||||
**仓库地址:** https://git.bz121.com/dekun/LocalNav.git
|
||||
|
||||
Reference in New Issue
Block a user