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:
dekun
2026-07-12 11:44:12 +08:00
parent f7ce6f1058
commit 3149770887
15 changed files with 635 additions and 242 deletions
+8 -20
View File
@@ -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_orderGate 扫单,多在云服务器----------
# 本机 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
+17
View File
@@ -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」。
+98 -88
View File
@@ -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:
+34 -1
View File
@@ -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("一键恢复")
+42
View File
@@ -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()
+3
View File
@@ -0,0 +1,3 @@
$ErrorActionPreference = "Stop"
Set-Location (Split-Path -Parent $PSScriptRoot)
python scripts/deploy.py
+217
View File
@@ -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()
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
python3 scripts/deploy.py
-27
View File
@@ -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()
+15
View File
@@ -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;
}
+1
View File
@@ -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>
+1
View File
@@ -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>
+90
View File
@@ -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
View File
@@ -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
View File
@@ -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_orderGate 扫单)接入
### 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