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 %}
+系统设置
+
+
当前用户:{{ current_user.username }}
+ +
+ 数据库文件:{{ db_path }}
+ {% if db_size %}
+ (约 {{ "%.1f"|format(db_size / 1024) }} KB)
+ {% else %}
+ (文件不存在或为空)
+ {% endif %}
+