feat: 添加用户登录认证功能,确保系统安全性,并提供修改密码和退出登录选项
This commit is contained in:
@@ -44,7 +44,17 @@ gunicorn -w 2 -b 127.0.0.1:5000 app:app
|
||||
|
||||
说明:建议只监听 `127.0.0.1`,由 Nginx/宝塔反向代理。
|
||||
|
||||
## 4. 每次发布更新
|
||||
## 4. 生产环境变量(建议)
|
||||
|
||||
登录认证默认开启,建议在生产环境显式设置管理员账号和会话密钥:
|
||||
|
||||
```bash
|
||||
export INVENTORY_ADMIN_USERNAME="your_admin"
|
||||
export INVENTORY_ADMIN_PASSWORD="your_strong_password"
|
||||
export INVENTORY_SECRET_KEY="replace_with_random_long_secret"
|
||||
```
|
||||
|
||||
## 5. 每次发布更新
|
||||
|
||||
### 本地
|
||||
|
||||
@@ -66,13 +76,13 @@ pip install -r requirements.txt
|
||||
|
||||
然后在宝塔中重启 Python 项目(或重启 Gunicorn 进程)。
|
||||
|
||||
## 5. 数据库备份
|
||||
## 6. 数据库备份
|
||||
|
||||
```bash
|
||||
cp /www/wwwroot/inventory/data/inventory.db /www/backup/inventory_$(date +%F).db
|
||||
```
|
||||
|
||||
## 6. 快速排查
|
||||
## 7. 快速排查
|
||||
|
||||
查看服务端口是否监听:
|
||||
|
||||
|
||||
43
README.md
43
README.md
@@ -61,7 +61,48 @@ python app.py
|
||||
|
||||
默认访问:`http://127.0.0.1:5000`
|
||||
|
||||
### 2.4 可选:启用 AI 补货建议(硅基流动)
|
||||
### 2.4 登录认证(默认开启)
|
||||
|
||||
系统启动后会自动启用登录保护,未登录用户会被重定向到登录页。
|
||||
|
||||
- 默认管理员用户名:`admin`
|
||||
- 默认管理员密码:`admin123456`
|
||||
|
||||
可通过环境变量覆盖默认账号:
|
||||
|
||||
```powershell
|
||||
$env:INVENTORY_ADMIN_USERNAME="你的管理员用户名"
|
||||
$env:INVENTORY_ADMIN_PASSWORD="你的管理员密码"
|
||||
```
|
||||
|
||||
首次登录后,建议立即在页面右上角进入“修改密码”完成改密。
|
||||
|
||||
#### 2.4.1 忘记密码救砖
|
||||
|
||||
如果还能登录:
|
||||
|
||||
- 右上角 `账号` -> `修改密码`
|
||||
|
||||
如果已经无法登录,可在项目根目录执行下面命令重置 `admin` 密码(将示例密码替换为你自己的强密码):
|
||||
|
||||
```powershell
|
||||
c:/Users/BeihongWang/Desktop/inventory/.venv/Scripts/python.exe -c "from app import app,db,User,generate_password_hash; ctx=app.app_context(); ctx.push(); u=User.query.filter_by(username='admin').first(); u.password_hash=generate_password_hash('NewPass123!'); db.session.commit(); print('admin password reset ok'); ctx.pop();"
|
||||
```
|
||||
|
||||
执行完成后,用新密码重新登录。
|
||||
|
||||
如果要重置非 `admin` 账号,可用通用模板(把 `target_username` 和 `NewPass123!` 改成你的值):
|
||||
|
||||
```powershell
|
||||
c:/Users/BeihongWang/Desktop/inventory/.venv/Scripts/python.exe -c "from app import app,db,User,generate_password_hash; target_username='your_username'; new_password='NewPass123!'; ctx=app.app_context(); ctx.push(); u=User.query.filter_by(username=target_username).first(); print('user found:', bool(u)); (setattr(u, 'password_hash', generate_password_hash(new_password)), db.session.commit(), print('password reset ok')) if u else print('skip reset'); ctx.pop();"
|
||||
```
|
||||
|
||||
注意:
|
||||
|
||||
- `INVENTORY_ADMIN_PASSWORD` 仅在“系统中还没有任何用户”时用于创建默认管理员。
|
||||
- 如果数据库里已经有用户,修改该环境变量不会自动改已有账号密码。
|
||||
|
||||
### 2.5 可选:启用 AI 补货建议(硅基流动)
|
||||
|
||||
在启动前设置环境变量:
|
||||
|
||||
|
||||
209
app.py
209
app.py
@@ -24,8 +24,9 @@ from copy import deepcopy
|
||||
from io import StringIO
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from flask import Flask, Response, redirect, render_template, request, url_for
|
||||
from flask import Flask, Response, redirect, render_template, request, session, url_for
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -37,12 +38,18 @@ DB_PATH = os.path.join(DB_DIR, "inventory.db")
|
||||
app = Flask(__name__)
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{DB_PATH}"
|
||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||
app.config["SECRET_KEY"] = os.environ.get(
|
||||
"INVENTORY_SECRET_KEY",
|
||||
hashlib.sha256(f"inventory-local::{BASE_DIR}".encode("utf-8")).hexdigest(),
|
||||
)
|
||||
db = SQLAlchemy(app)
|
||||
|
||||
# 这里集中放全局常量,避免后面函数里散落硬编码。
|
||||
LOW_STOCK_THRESHOLD = 5
|
||||
BOX_TYPES_OVERRIDE_PATH = os.path.join(DB_DIR, "box_types.json")
|
||||
AI_SETTINGS_PATH = os.path.join(DB_DIR, "ai_settings.json")
|
||||
DEFAULT_ADMIN_USERNAME = os.environ.get("INVENTORY_ADMIN_USERNAME", "admin").strip() or "admin"
|
||||
DEFAULT_ADMIN_PASSWORD = os.environ.get("INVENTORY_ADMIN_PASSWORD", "admin123456")
|
||||
LCSC_BASE_URL = "https://open-api.jlc.com"
|
||||
LCSC_BASIC_PATH = "/lcsc/openapi/sku/product/basic"
|
||||
AI_SETTINGS_DEFAULT = {
|
||||
@@ -520,6 +527,15 @@ class InventoryEvent(db.Model):
|
||||
created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now())
|
||||
|
||||
|
||||
class User(db.Model):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(64), nullable=False, unique=True)
|
||||
password_hash = db.Column(db.String(255), nullable=False)
|
||||
created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now())
|
||||
|
||||
|
||||
def _add_column_if_missing(table_name: str, column_name: str, ddl: str) -> None:
|
||||
columns = {
|
||||
row[1]
|
||||
@@ -563,6 +579,116 @@ def ensure_schema() -> None:
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _get_session_user() -> User | None:
|
||||
user_id = session.get("user_id")
|
||||
if not user_id:
|
||||
return None
|
||||
try:
|
||||
return User.query.get(int(user_id))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _is_authenticated() -> bool:
|
||||
return _get_session_user() is not None
|
||||
|
||||
|
||||
def _login_user(user: User) -> None:
|
||||
now_iso = datetime.utcnow().isoformat(timespec="seconds")
|
||||
session["user_id"] = int(user.id)
|
||||
session["username"] = user.username
|
||||
session["login_at"] = now_iso
|
||||
session["last_active_at"] = now_iso
|
||||
|
||||
|
||||
def _logout_user() -> None:
|
||||
session.pop("user_id", None)
|
||||
session.pop("username", None)
|
||||
session.pop("login_at", None)
|
||||
session.pop("last_active_at", None)
|
||||
|
||||
|
||||
def _parse_iso_datetime(raw_value: str) -> datetime | None:
|
||||
value = (raw_value or "").strip()
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _build_session_status() -> tuple[str, str, str, str]:
|
||||
raw_login_at = (session.get("login_at") or "").strip()
|
||||
raw_last_active_at = (session.get("last_active_at") or "").strip()
|
||||
login_at = _parse_iso_datetime(raw_login_at)
|
||||
last_active_at = _parse_iso_datetime(raw_last_active_at)
|
||||
|
||||
if login_at is None:
|
||||
return "-", "-", "-", "-"
|
||||
if last_active_at is None:
|
||||
last_active_at = login_at
|
||||
|
||||
online_minutes = max(0, int((datetime.utcnow() - login_at).total_seconds() // 60))
|
||||
idle_minutes = max(0, int((datetime.utcnow() - last_active_at).total_seconds() // 60))
|
||||
login_label = login_at.strftime("%Y-%m-%d %H:%M:%S")
|
||||
last_active_label = last_active_at.strftime("%Y-%m-%d %H:%M:%S")
|
||||
online_label = "刚刚" if online_minutes <= 0 else f"{online_minutes} 分钟"
|
||||
idle_label = "刚刚" if idle_minutes <= 0 else f"{idle_minutes} 分钟"
|
||||
return login_label, online_label, last_active_label, idle_label
|
||||
|
||||
|
||||
def _ensure_default_admin_user() -> None:
|
||||
"""确保系统至少存在一个可登录用户。
|
||||
|
||||
中文说明:首次初始化时自动创建管理员账号,避免系统开启登录保护后无人可进。
|
||||
用户名和密码可通过环境变量 INVENTORY_ADMIN_USERNAME / INVENTORY_ADMIN_PASSWORD 覆盖。
|
||||
"""
|
||||
existing_user = User.query.order_by(User.id.asc()).first()
|
||||
if existing_user:
|
||||
return
|
||||
|
||||
admin = User(
|
||||
username=DEFAULT_ADMIN_USERNAME,
|
||||
password_hash=generate_password_hash(DEFAULT_ADMIN_PASSWORD),
|
||||
)
|
||||
db.session.add(admin)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@app.context_processor
|
||||
def inject_auth_context():
|
||||
current_user = _get_session_user()
|
||||
login_label, online_label, last_active_label, idle_label = _build_session_status()
|
||||
return {
|
||||
"auth_username": current_user.username if current_user else "",
|
||||
"auth_logged_in": current_user is not None,
|
||||
"auth_login_at": login_label,
|
||||
"auth_online_for": online_label,
|
||||
"auth_last_active_at": last_active_label,
|
||||
"auth_idle_for": idle_label,
|
||||
}
|
||||
|
||||
|
||||
@app.before_request
|
||||
def require_login_for_app_routes():
|
||||
open_endpoints = {
|
||||
"login_page",
|
||||
"logout_page",
|
||||
"static",
|
||||
}
|
||||
endpoint = request.endpoint or ""
|
||||
if endpoint in open_endpoints or endpoint.startswith("static"):
|
||||
return None
|
||||
|
||||
if _is_authenticated():
|
||||
session["last_active_at"] = datetime.utcnow().isoformat(timespec="seconds")
|
||||
return None
|
||||
|
||||
next_path = request.full_path if request.query_string else request.path
|
||||
return redirect(url_for("login_page", next=next_path.rstrip("?")))
|
||||
|
||||
|
||||
def slot_code_for_box(box: Box, slot_index: int) -> str:
|
||||
serial = box.start_number + slot_index - 1
|
||||
return f"{box.slot_prefix}{serial}"
|
||||
@@ -2689,6 +2815,86 @@ def _call_siliconflow_chat(
|
||||
raise RuntimeError("AI 返回格式无法解析") from exc
|
||||
|
||||
|
||||
def _is_safe_next_path(path: str) -> bool:
|
||||
candidate = (path or "").strip()
|
||||
if not candidate:
|
||||
return False
|
||||
return candidate.startswith("/") and not candidate.startswith("//")
|
||||
|
||||
|
||||
@app.route("/login", methods=["GET", "POST"])
|
||||
def login_page():
|
||||
if _is_authenticated():
|
||||
return redirect(url_for("types_page"))
|
||||
|
||||
error = ""
|
||||
notice = request.args.get("notice", "").strip()
|
||||
next_path = request.args.get("next", "").strip()
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username", "").strip()
|
||||
password = request.form.get("password", "")
|
||||
next_path = request.form.get("next", "").strip()
|
||||
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if not user or not check_password_hash(user.password_hash, password):
|
||||
error = "用户名或密码错误"
|
||||
else:
|
||||
_login_user(user)
|
||||
if _is_safe_next_path(next_path):
|
||||
return redirect(next_path)
|
||||
return redirect(url_for("types_page"))
|
||||
|
||||
if not _is_safe_next_path(next_path):
|
||||
next_path = ""
|
||||
|
||||
return render_template("login.html", error=error, notice=notice, next_path=next_path)
|
||||
|
||||
|
||||
@app.route("/logout")
|
||||
def logout_page():
|
||||
_logout_user()
|
||||
return redirect(url_for("login_page"))
|
||||
|
||||
|
||||
@app.route("/account/password", methods=["GET", "POST"])
|
||||
def change_password_page():
|
||||
"""登录后修改当前账号密码。
|
||||
|
||||
中文说明:为了避免长期使用默认密码,提供页面自助改密。
|
||||
改密成功后会强制重新登录,确保会话状态干净。
|
||||
"""
|
||||
current_user = _get_session_user()
|
||||
if current_user is None:
|
||||
return redirect(url_for("login_page"))
|
||||
|
||||
error = ""
|
||||
notice = ""
|
||||
|
||||
if request.method == "POST":
|
||||
current_password = request.form.get("current_password", "")
|
||||
new_password = request.form.get("new_password", "")
|
||||
confirm_password = request.form.get("confirm_password", "")
|
||||
|
||||
if not check_password_hash(current_user.password_hash, current_password):
|
||||
error = "当前密码不正确"
|
||||
elif len(new_password) < 8:
|
||||
error = "新密码至少需要 8 位"
|
||||
elif new_password != confirm_password:
|
||||
error = "两次输入的新密码不一致"
|
||||
elif check_password_hash(current_user.password_hash, new_password):
|
||||
error = "新密码不能与当前密码相同"
|
||||
else:
|
||||
current_user.password_hash = generate_password_hash(new_password)
|
||||
db.session.commit()
|
||||
_logout_user()
|
||||
return redirect(url_for("login_page", notice="密码修改成功,请使用新密码重新登录"))
|
||||
|
||||
if not error:
|
||||
notice = "建议使用强密码(字母+数字+符号),并定期更换。"
|
||||
|
||||
return render_template("change_password.html", error=error, notice=notice)
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return redirect(url_for("types_page"))
|
||||
@@ -4602,6 +4808,7 @@ def bootstrap() -> None:
|
||||
db.create_all()
|
||||
ensure_schema()
|
||||
normalize_legacy_data()
|
||||
_ensure_default_admin_user()
|
||||
|
||||
|
||||
bootstrap()
|
||||
|
||||
@@ -555,6 +555,64 @@ body {
|
||||
background: color-mix(in srgb, var(--danger) 85%, #000 15%);
|
||||
}
|
||||
|
||||
.account-menu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.account-menu > summary {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.account-menu > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.account-menu > summary::after {
|
||||
content: "▾";
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.account-menu[open] > summary::after {
|
||||
content: "▴";
|
||||
}
|
||||
|
||||
.account-menu-list {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 8px);
|
||||
min-width: 160px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 6px;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.account-menu-meta {
|
||||
margin: 0;
|
||||
padding: 4px 10px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.account-menu-item {
|
||||
display: block;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.account-menu-item:hover {
|
||||
background: color-mix(in srgb, var(--card-alt) 76%, var(--accent) 24%);
|
||||
}
|
||||
|
||||
.box-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
@@ -1420,4 +1478,9 @@ th {
|
||||
.chart-row {
|
||||
grid-template-columns: 100px 1fr 52px;
|
||||
}
|
||||
|
||||
.account-menu-list {
|
||||
right: auto;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
11
templates/_account_menu.html
Normal file
11
templates/_account_menu.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<details class="account-menu">
|
||||
<summary class="btn btn-light">账号:{{ auth_username or '未登录' }}</summary>
|
||||
<div class="account-menu-list" role="menu">
|
||||
<p class="account-menu-meta">登录时间:{{ auth_login_at }}</p>
|
||||
<p class="account-menu-meta">在线时长:{{ auth_online_for }}</p>
|
||||
<p class="account-menu-meta">最后活动:{{ auth_last_active_at }}</p>
|
||||
<p class="account-menu-meta">空闲时长:{{ auth_idle_for }}</p>
|
||||
<a class="account-menu-item" href="{{ url_for('change_password_page') }}" role="menuitem">修改密码</a>
|
||||
<a class="account-menu-item" href="{{ url_for('logout_page') }}" role="menuitem">退出登录</a>
|
||||
</div>
|
||||
</details>
|
||||
@@ -14,6 +14,7 @@
|
||||
</div>
|
||||
<div class="hero-actions">
|
||||
<a class="btn btn-light" href="{{ url_for('types_page') }}">返回仓库概览</a>
|
||||
{% include '_account_menu.html' %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<a class="btn btn-light" href="{{ url_for('search_page') }}">快速搜索</a>
|
||||
<a class="btn btn-light" href="{{ url_for('stats_page') }}">统计页</a>
|
||||
<a class="btn btn-light" href="{{ url_for('export_box_labels_csv', box_id=box.id) }}">导出打标CSV</a>
|
||||
{% include '_account_menu.html' %}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
|
||||
50
templates/change_password.html
Normal file
50
templates/change_password.html
Normal file
@@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>修改密码</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<header class="hero slim">
|
||||
<div>
|
||||
<h1>修改密码</h1>
|
||||
<p>当前用户:{{ auth_username }}</p>
|
||||
</div>
|
||||
<div class="hero-actions">
|
||||
<a class="btn btn-light" href="{{ url_for('types_page') }}">返回仓库概览</a>
|
||||
<a class="btn btn-light" href="{{ url_for('logout_page') }}">退出登录</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
<section class="panel" style="max-width: 560px; margin: 0 auto;">
|
||||
{% if error %}
|
||||
<p class="alert">{{ error }}</p>
|
||||
{% endif %}
|
||||
{% if notice %}
|
||||
<p class="notice">{{ notice }}</p>
|
||||
{% endif %}
|
||||
|
||||
<form class="form-grid" method="post" action="{{ url_for('change_password_page') }}">
|
||||
<label>
|
||||
当前密码
|
||||
<input type="password" name="current_password" required autocomplete="current-password" placeholder="请输入当前密码">
|
||||
</label>
|
||||
<label>
|
||||
新密码(至少8位)
|
||||
<input type="password" name="new_password" required minlength="8" autocomplete="new-password" placeholder="请输入新密码">
|
||||
</label>
|
||||
<label>
|
||||
确认新密码
|
||||
<input type="password" name="confirm_password" required minlength="8" autocomplete="new-password" placeholder="请再次输入新密码">
|
||||
</label>
|
||||
<div class="actions full">
|
||||
<button class="btn" type="submit">保存新密码</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -16,6 +16,7 @@
|
||||
<a class="btn btn-light" href="{{ url_for('search_page', q=search_query) if search_query else url_for('search_page') }}">返回快速搜索</a>
|
||||
<a class="btn btn-light" href="{{ url_for('stats_page') }}">统计页</a>
|
||||
<a class="btn btn-light" href="{{ url_for('view_box', box_id=box.id) }}">返回宫格</a>
|
||||
{% include '_account_menu.html' %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<a class="btn btn-light" href="{{ url_for('search_page') }}">快速搜索</a>
|
||||
<a class="btn btn-light" href="#quick-add">新增库存</a>
|
||||
<a class="btn btn-light" href="{{ url_for('stats_page') }}">统计页</a>
|
||||
{% include '_account_menu.html' %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
42
templates/login.html
Normal file
42
templates/login.html
Normal file
@@ -0,0 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>系统登录</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<header class="hero slim">
|
||||
<div>
|
||||
<h1>库存系统登录</h1>
|
||||
<p>请输入用户名和密码后继续使用系统</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
<section class="panel" style="max-width: 520px; margin: 0 auto;">
|
||||
{% if error %}
|
||||
<p class="alert">{{ error }}</p>
|
||||
{% endif %}
|
||||
{% if notice %}
|
||||
<p class="notice">{{ notice }}</p>
|
||||
{% endif %}
|
||||
<form class="form-grid" method="post" action="{{ url_for('login_page') }}">
|
||||
<input type="hidden" name="next" value="{{ next_path or '' }}">
|
||||
<label>
|
||||
用户名
|
||||
<input type="text" name="username" required autocomplete="username" placeholder="请输入用户名">
|
||||
</label>
|
||||
<label>
|
||||
密码
|
||||
<input type="password" name="password" required autocomplete="current-password" placeholder="请输入密码">
|
||||
</label>
|
||||
<div class="actions full">
|
||||
<button class="btn" type="submit">登录</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -15,6 +15,7 @@
|
||||
<nav class="hero-actions">
|
||||
<a class="btn btn-light" href="{{ url_for('index') }}">返回首页</a>
|
||||
<a class="btn btn-light" href="{{ url_for('stats_page') }}">统计页</a>
|
||||
{% include '_account_menu.html' %}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<nav class="hero-actions">
|
||||
<a class="btn btn-light" href="{{ url_for('index') }}">返回首页</a>
|
||||
<a class="btn btn-light" href="{{ url_for('search_page') }}">快速搜索</a>
|
||||
{% include '_account_menu.html' %}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
</div>
|
||||
<div class="hero-actions">
|
||||
<a class="btn btn-light" href="{{ url_for('types_page') }}">返回仓库概览</a>
|
||||
{% include '_account_menu.html' %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<a class="btn" href="{{ url_for('type_page', box_type='custom') }}#quick-add">添加容器</a>
|
||||
<a class="btn btn-light" href="{{ url_for('search_page') }}">快速搜索</a>
|
||||
<a class="btn btn-light" href="{{ url_for('stats_page') }}">统计页</a>
|
||||
{% include '_account_menu.html' %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user