From 3149770887de306dbd7fde06e93c050c2a7291cc Mon Sep 17 00:00:00 2001 From: dekun Date: Sun, 12 Jul 2026 11:44:12 +0800 Subject: [PATCH] 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 --- .env.example | 28 ++--- README.md | 17 +++ app.py | 186 +++++++++++++++-------------- forms.py | 35 +++++- scripts/cleanup_gate_scout.py | 42 +++++++ scripts/deploy.ps1 | 3 + scripts/deploy.py | 217 ++++++++++++++++++++++++++++++++++ scripts/deploy.sh | 4 + scripts/seed_gate_scout.py | 27 ----- static/style.css | 15 +++ templates/admin_groups.html | 1 + templates/admin_services.html | 1 + templates/admin_settings.html | 90 ++++++++++++++ templates/index.html | 40 +------ 部署与使用说明.md | 171 ++++++++++++++++----------- 15 files changed, 635 insertions(+), 242 deletions(-) create mode 100644 scripts/cleanup_gate_scout.py create mode 100644 scripts/deploy.ps1 create mode 100644 scripts/deploy.py create mode 100644 scripts/deploy.sh delete mode 100644 scripts/seed_gate_scout.py create mode 100644 templates/admin_settings.html diff --git a/.env.example b/.env.example index 049a630..8b109f5 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 2607f1a..a302223 100644 --- a/README.md +++ b/README.md @@ -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」。 diff --git a/app.py b/app.py index 913bc74..803abf2 100644 --- a/app.py +++ b/app.py @@ -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: diff --git a/forms.py b/forms.py index df73492..5011645 100644 --- a/forms.py +++ b/forms.py @@ -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("一键恢复") diff --git a/scripts/cleanup_gate_scout.py b/scripts/cleanup_gate_scout.py new file mode 100644 index 0000000..fed5a30 --- /dev/null +++ b/scripts/cleanup_gate_scout.py @@ -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() diff --git a/scripts/deploy.ps1 b/scripts/deploy.ps1 new file mode 100644 index 0000000..72c2b75 --- /dev/null +++ b/scripts/deploy.ps1 @@ -0,0 +1,3 @@ +$ErrorActionPreference = "Stop" +Set-Location (Split-Path -Parent $PSScriptRoot) +python scripts/deploy.py diff --git a/scripts/deploy.py b/scripts/deploy.py new file mode 100644 index 0000000..5786105 --- /dev/null +++ b/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() diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 0000000..75be68c --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +python3 scripts/deploy.py diff --git a/scripts/seed_gate_scout.py b/scripts/seed_gate_scout.py deleted file mode 100644 index 2b5e686..0000000 --- a/scripts/seed_gate_scout.py +++ /dev/null @@ -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() diff --git a/static/style.css b/static/style.css index 9e1e9ec..0b780da 100644 --- a/static/style.css +++ b/static/style.css @@ -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; +} diff --git a/templates/admin_groups.html b/templates/admin_groups.html index 4947615..aa0462d 100644 --- a/templates/admin_groups.html +++ b/templates/admin_groups.html @@ -6,6 +6,7 @@ diff --git a/templates/admin_services.html b/templates/admin_services.html index 1b53f35..3bcc806 100644 --- a/templates/admin_services.html +++ b/templates/admin_services.html @@ -6,6 +6,7 @@ diff --git a/templates/admin_settings.html b/templates/admin_settings.html new file mode 100644 index 0000000..35b875d --- /dev/null +++ b/templates/admin_settings.html @@ -0,0 +1,90 @@ +{% extends "base.html" %} +{% block title %}系统设置 · 本地导航{% endblock %} +{% block body %} +
+

系统设置

+ +
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for cat, msg in messages %} +
{{ msg }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + +
+

账户安全

+

当前用户:{{ current_user.username }}

+
+ {{ pwd_form.hidden_tag() }} +
+ {{ pwd_form.current_password.label }} + {{ pwd_form.current_password() }} + {% if pwd_form.current_password.errors %} +
{{ pwd_form.current_password.errors[0] }}
+ {% endif %} +
+
+ {{ pwd_form.new_password.label }} + {{ pwd_form.new_password() }} + {% if pwd_form.new_password.errors %} +
{{ pwd_form.new_password.errors[0] }}
+ {% endif %} +
+
+ {{ pwd_form.confirm_password.label }} + {{ pwd_form.confirm_password() }} + {% if pwd_form.confirm_password.errors %} +
{{ pwd_form.confirm_password.errors[0] }}
+ {% endif %} +
+
+ {{ pwd_form.submit(class="btn btn-primary", style="width: auto") }} +
+
+
+ +
+

数据备份与恢复

+

+ 数据库文件:{{ db_path }} + {% if db_size %} + (约 {{ "%.1f"|format(db_size / 1024) }} KB) + {% else %} + (文件不存在或为空) + {% endif %} +

+ +
+ {{ restore_form.hidden_tag() }} +
+ {{ restore_form.backup_file.label }} + {{ restore_form.backup_file() }} + {% if restore_form.backup_file.errors %} +
{{ restore_form.backup_file.errors[0] }}
+ {% endif %} +
+

恢复完成后请重启 LocalNav 服务。

+
+ {{ restore_form.submit(class="btn btn-secondary", style="width: auto") }} +
+
+
+
+{% endblock %} diff --git a/templates/index.html b/templates/index.html index 1365968..6d40506 100644 --- a/templates/index.html +++ b/templates/index.html @@ -9,6 +9,7 @@ {{ current_user.username }} 分组管理 服务管理 + 系统设置 退出 @@ -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 登录 -