feat: 完善登录认证系统
- 首次使用强制设置密码(至少6位) - 密码存数据库(SHA256哈希),前端可修改 - 登录失败5次锁定5分钟,防爆破 - 导航栏添加修改密码入口 - 移除硬编码默认密码
This commit is contained in:
@@ -19,7 +19,6 @@ VOLUME ["/app/data"]
|
|||||||
|
|
||||||
ENV FLASK_DEBUG=0
|
ENV FLASK_DEBUG=0
|
||||||
ENV TZ=Asia/Shanghai
|
ENV TZ=Asia/Shanghai
|
||||||
ENV ADMIN_PASSWORD=admin123
|
|
||||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||||
|
|
||||||
CMD ["python", "run.py"]
|
CMD ["python", "run.py"]
|
||||||
|
|||||||
@@ -6,6 +6,4 @@ services:
|
|||||||
- "9000:9000"
|
- "9000:9000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
environment:
|
|
||||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
197
server/app.py
197
server/app.py
@@ -1,61 +1,214 @@
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import time
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||||
|
|
||||||
from flask import Flask, redirect, url_for, request, session, render_template
|
from flask import (Flask, redirect, url_for, request, session,
|
||||||
|
render_template, jsonify)
|
||||||
from flask_socketio import SocketIO
|
from flask_socketio import SocketIO
|
||||||
from server.database import init_db
|
from server.database import init_db, get_db
|
||||||
from server.routers import accounts, tasks, orders
|
|
||||||
|
|
||||||
socketio = SocketIO(cors_allowed_origins="*")
|
socketio = SocketIO(cors_allowed_origins="*")
|
||||||
|
|
||||||
# 访问密码,可通过环境变量 ADMIN_PASSWORD 设置,默认 admin123
|
# 防爆破:最大失败次数 & 锁定时间(秒)
|
||||||
ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD', 'admin123')
|
MAX_FAIL = 5
|
||||||
|
LOCK_SECONDS = 300 # 5分钟
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_pwd(pwd):
|
||||||
|
return hashlib.sha256(pwd.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_admin():
|
||||||
|
db = get_db()
|
||||||
|
row = db.execute('SELECT * FROM admin WHERE id = 1').fetchone()
|
||||||
|
db.close()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _is_locked():
|
||||||
|
admin = _get_admin()
|
||||||
|
if not admin:
|
||||||
|
return False
|
||||||
|
if admin['fail_count'] >= MAX_FAIL and admin['locked_until']:
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
locked = datetime.strptime(admin['locked_until'],
|
||||||
|
'%Y-%m-%d %H:%M:%S')
|
||||||
|
if datetime.now() < locked:
|
||||||
|
return True
|
||||||
|
# 锁定已过期,重置
|
||||||
|
db = get_db()
|
||||||
|
db.execute("UPDATE admin SET fail_count=0, locked_until='' "
|
||||||
|
"WHERE id=1")
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _record_fail():
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
db = get_db()
|
||||||
|
admin = db.execute('SELECT fail_count FROM admin WHERE id=1').fetchone()
|
||||||
|
if admin:
|
||||||
|
new_count = (admin['fail_count'] or 0) + 1
|
||||||
|
locked_until = ''
|
||||||
|
if new_count >= MAX_FAIL:
|
||||||
|
locked_until = (datetime.now() + timedelta(
|
||||||
|
seconds=LOCK_SECONDS)).strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
db.execute("UPDATE admin SET fail_count=?, locked_until=?, "
|
||||||
|
"updated_at=datetime('now','localtime') WHERE id=1",
|
||||||
|
(new_count, locked_until))
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_fail():
|
||||||
|
db = get_db()
|
||||||
|
db.execute("UPDATE admin SET fail_count=0, locked_until='', "
|
||||||
|
"updated_at=datetime('now','localtime') WHERE id=1")
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
app = Flask(
|
app = Flask(
|
||||||
__name__,
|
__name__,
|
||||||
template_folder=os.path.join(os.path.dirname(__file__), '..', 'templates'),
|
template_folder=os.path.join(os.path.dirname(__file__), '..',
|
||||||
static_folder=os.path.join(os.path.dirname(__file__), '..', 'static'),
|
'templates'),
|
||||||
|
static_folder=os.path.join(os.path.dirname(__file__), '..',
|
||||||
|
'static'),
|
||||||
)
|
)
|
||||||
app.secret_key = os.environ.get(
|
app.secret_key = os.environ.get(
|
||||||
'SECRET_KEY', 'snatcher-secret-key-change-me')
|
'SECRET_KEY', 'snatcher-' + _hash_pwd('change-me')[:16])
|
||||||
|
|
||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
# ── 登录认证 ──
|
# ── 登录 & 初始设置密码 ──
|
||||||
@app.route('/login', methods=['GET', 'POST'])
|
@app.route('/login', methods=['GET', 'POST'])
|
||||||
def login():
|
def login():
|
||||||
|
admin = _get_admin()
|
||||||
|
# 首次使用:跳转设置密码
|
||||||
|
if not admin:
|
||||||
|
return redirect(url_for('setup'))
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
|
if _is_locked():
|
||||||
|
remain = ''
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
locked = datetime.strptime(
|
||||||
|
_get_admin()['locked_until'], '%Y-%m-%d %H:%M:%S')
|
||||||
|
remain = f'{int((locked - datetime.now()).total_seconds())}秒'
|
||||||
|
except Exception:
|
||||||
|
remain = f'{LOCK_SECONDS}秒'
|
||||||
|
return render_template(
|
||||||
|
'login.html',
|
||||||
|
error=f'登录失败次数过多,请{remain}后再试')
|
||||||
|
|
||||||
pwd = request.form.get('password', '')
|
pwd = request.form.get('password', '')
|
||||||
if pwd == ADMIN_PASSWORD:
|
if _hash_pwd(pwd) == admin['password_hash']:
|
||||||
session['authenticated'] = _hash(pwd)
|
_reset_fail()
|
||||||
|
session['authenticated'] = True
|
||||||
session.permanent = True
|
session.permanent = True
|
||||||
app.permanent_session_lifetime = __import__(
|
app.permanent_session_lifetime = __import__(
|
||||||
'datetime').timedelta(days=7)
|
'datetime').timedelta(days=7)
|
||||||
return redirect(request.args.get('next', '/'))
|
return redirect(request.args.get('next', '/'))
|
||||||
return render_template('login.html', error='密码错误')
|
else:
|
||||||
|
_record_fail()
|
||||||
|
admin = _get_admin()
|
||||||
|
remaining = MAX_FAIL - (admin['fail_count'] or 0)
|
||||||
|
if remaining <= 0:
|
||||||
|
return render_template(
|
||||||
|
'login.html',
|
||||||
|
error=f'账号已锁定{LOCK_SECONDS // 60}分钟')
|
||||||
|
return render_template(
|
||||||
|
'login.html',
|
||||||
|
error=f'密码错误,还剩{remaining}次机会')
|
||||||
|
|
||||||
return render_template('login.html')
|
return render_template('login.html')
|
||||||
|
|
||||||
|
@app.route('/setup', methods=['GET', 'POST'])
|
||||||
|
def setup():
|
||||||
|
admin = _get_admin()
|
||||||
|
if admin:
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
pwd = request.form.get('password', '')
|
||||||
|
pwd2 = request.form.get('password2', '')
|
||||||
|
if len(pwd) < 6:
|
||||||
|
return render_template('setup.html',
|
||||||
|
error='密码至少6位')
|
||||||
|
if pwd != pwd2:
|
||||||
|
return render_template('setup.html',
|
||||||
|
error='两次密码不一致')
|
||||||
|
db = get_db()
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO admin (id, password_hash) VALUES (1, ?)",
|
||||||
|
(_hash_pwd(pwd),))
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
session['authenticated'] = True
|
||||||
|
session.permanent = True
|
||||||
|
app.permanent_session_lifetime = __import__(
|
||||||
|
'datetime').timedelta(days=7)
|
||||||
|
return redirect('/')
|
||||||
|
|
||||||
|
return render_template('setup.html')
|
||||||
|
|
||||||
@app.route('/logout')
|
@app.route('/logout')
|
||||||
def logout():
|
def logout():
|
||||||
session.clear()
|
session.clear()
|
||||||
return redirect('/login')
|
return redirect('/login')
|
||||||
|
|
||||||
|
@app.route('/change_password', methods=['GET', 'POST'])
|
||||||
|
def change_password():
|
||||||
|
if not session.get('authenticated'):
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
if request.method == 'POST':
|
||||||
|
old = request.form.get('old_password', '')
|
||||||
|
new = request.form.get('new_password', '')
|
||||||
|
new2 = request.form.get('new_password2', '')
|
||||||
|
admin = _get_admin()
|
||||||
|
if _hash_pwd(old) != admin['password_hash']:
|
||||||
|
return render_template('change_password.html',
|
||||||
|
error='原密码错误')
|
||||||
|
if len(new) < 6:
|
||||||
|
return render_template('change_password.html',
|
||||||
|
error='新密码至少6位')
|
||||||
|
if new != new2:
|
||||||
|
return render_template('change_password.html',
|
||||||
|
error='两次密码不一致')
|
||||||
|
db = get_db()
|
||||||
|
db.execute(
|
||||||
|
"UPDATE admin SET password_hash=?, "
|
||||||
|
"updated_at=datetime('now','localtime') WHERE id=1",
|
||||||
|
(_hash_pwd(new),))
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
return render_template('change_password.html',
|
||||||
|
success='密码修改成功')
|
||||||
|
return render_template('change_password.html')
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def require_auth():
|
def require_auth():
|
||||||
# 放行登录页和静态资源
|
allowed = ('/login', '/setup')
|
||||||
if request.path in ('/login',) or request.path.startswith('/static'):
|
if request.path in allowed or request.path.startswith('/static'):
|
||||||
return
|
return
|
||||||
# 放行 SocketIO 的内部路径
|
|
||||||
if request.path.startswith('/socket.io'):
|
if request.path.startswith('/socket.io'):
|
||||||
return
|
return
|
||||||
if session.get('authenticated') != _hash(ADMIN_PASSWORD):
|
admin = _get_admin()
|
||||||
|
if not admin:
|
||||||
|
return redirect(url_for('setup'))
|
||||||
|
if not session.get('authenticated'):
|
||||||
return redirect(url_for('login', next=request.path))
|
return redirect(url_for('login', next=request.path))
|
||||||
|
|
||||||
|
from server.routers import accounts, tasks, orders
|
||||||
app.register_blueprint(accounts.bp)
|
app.register_blueprint(accounts.bp)
|
||||||
app.register_blueprint(tasks.bp)
|
app.register_blueprint(tasks.bp)
|
||||||
app.register_blueprint(orders.bp)
|
app.register_blueprint(orders.bp)
|
||||||
@@ -70,10 +223,6 @@ def create_app():
|
|||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
def _hash(s):
|
|
||||||
return hashlib.sha256(s.encode()).hexdigest()[:16]
|
|
||||||
|
|
||||||
|
|
||||||
def _register_socketio_events():
|
def _register_socketio_events():
|
||||||
import asyncio
|
import asyncio
|
||||||
from server.services.remote_browser import get_session
|
from server.services.remote_browser import get_session
|
||||||
@@ -92,19 +241,15 @@ def _register_socketio_events():
|
|||||||
def handle_keyboard(data):
|
def handle_keyboard(data):
|
||||||
s = get_session(data.get('session_id', ''))
|
s = get_session(data.get('session_id', ''))
|
||||||
if s and s.loop and s.loop.is_running():
|
if s and s.loop and s.loop.is_running():
|
||||||
text = data.get('text', '')
|
|
||||||
if text:
|
|
||||||
asyncio.run_coroutine_threadsafe(
|
asyncio.run_coroutine_threadsafe(
|
||||||
s.handle_keyboard(text), s.loop)
|
s.handle_keyboard(data.get('text', '')), s.loop)
|
||||||
|
|
||||||
@socketio.on('rb_key', namespace='/rb')
|
@socketio.on('rb_key', namespace='/rb')
|
||||||
def handle_key(data):
|
def handle_key(data):
|
||||||
s = get_session(data.get('session_id', ''))
|
s = get_session(data.get('session_id', ''))
|
||||||
if s and s.loop and s.loop.is_running():
|
if s and s.loop and s.loop.is_running():
|
||||||
key = data.get('key', '')
|
|
||||||
if key:
|
|
||||||
asyncio.run_coroutine_threadsafe(
|
asyncio.run_coroutine_threadsafe(
|
||||||
s.handle_key(key), s.loop)
|
s.handle_key(data.get('key', '')), s.loop)
|
||||||
|
|
||||||
@socketio.on('rb_close', namespace='/rb')
|
@socketio.on('rb_close', namespace='/rb')
|
||||||
def handle_close(data):
|
def handle_close(data):
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ def init_db():
|
|||||||
FOREIGN KEY (task_id) REFERENCES tasks(id),
|
FOREIGN KEY (task_id) REFERENCES tasks(id),
|
||||||
FOREIGN KEY (account_id) REFERENCES accounts(id)
|
FOREIGN KEY (account_id) REFERENCES accounts(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
fail_count INTEGER DEFAULT 0,
|
||||||
|
locked_until TEXT DEFAULT '',
|
||||||
|
created_at TEXT DEFAULT (datetime('now', 'localtime')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
|
||||||
|
);
|
||||||
''')
|
''')
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -35,7 +35,8 @@
|
|||||||
<a class="nav-link" href="/tasks/"><i class="bi bi-list-task"></i> 任务</a>
|
<a class="nav-link" href="/tasks/"><i class="bi bi-list-task"></i> 任务</a>
|
||||||
<a class="nav-link" href="/accounts/"><i class="bi bi-people"></i> 账号</a>
|
<a class="nav-link" href="/accounts/"><i class="bi bi-people"></i> 账号</a>
|
||||||
<a class="nav-link" href="/orders/"><i class="bi bi-receipt"></i> 订单</a>
|
<a class="nav-link" href="/orders/"><i class="bi bi-receipt"></i> 订单</a>
|
||||||
<a class="nav-link text-warning-emphasis" href="/logout"><i class="bi bi-box-arrow-right"></i></a>
|
<a class="nav-link text-warning-emphasis" href="/change_password" title="修改密码"><i class="bi bi-key"></i></a>
|
||||||
|
<a class="nav-link text-warning-emphasis" href="/logout" title="退出"><i class="bi bi-box-arrow-right"></i></a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
33
templates/change_password.html
Normal file
33
templates/change_password.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h5 class="mb-3"><i class="bi bi-key"></i> 修改密码</h5>
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger py-2" style="border-radius:10px;font-size:.85rem">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if success %}
|
||||||
|
<div class="alert alert-success py-2" style="border-radius:10px;font-size:.85rem">{{ success }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<form method="POST">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">原密码</label>
|
||||||
|
<input type="password" class="form-control" name="old_password" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">新密码(至少6位)</label>
|
||||||
|
<input type="password" class="form-control" name="new_password" required minlength="6">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">确认新密码</label>
|
||||||
|
<input type="password" class="form-control" name="new_password2" required minlength="6">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100">确认修改</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -10,27 +10,24 @@
|
|||||||
body { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh;
|
body { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh;
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
|
||||||
.login-card { width: 360px; border-radius: 18px; border: none;
|
.card { width: 360px; border-radius: 18px; border: none; box-shadow: 0 8px 40px rgba(0,0,0,.2); }
|
||||||
box-shadow: 0 8px 40px rgba(0,0,0,.2); }
|
.card-header { text-align: center; padding: 2rem 2rem 1rem; background: none; border: none; }
|
||||||
.login-header { text-align: center; padding: 2rem 2rem 1rem; }
|
.card-header i { font-size: 2.5rem; color: #667eea; }
|
||||||
.login-header i { font-size: 2.5rem; color: #667eea; }
|
.card-header h4 { margin-top: .8rem; font-weight: 600; color: #333; }
|
||||||
.login-header h4 { margin-top: .8rem; font-weight: 600; color: #333; }
|
.card-body { padding: 0 2rem 2rem; }
|
||||||
.login-body { padding: 0 2rem 2rem; }
|
|
||||||
.form-control { border-radius: 10px; padding: .6rem 1rem; }
|
.form-control { border-radius: 10px; padding: .6rem 1rem; }
|
||||||
.form-control:focus { border-color: #667eea; box-shadow: 0 0 0 .2rem rgba(102,126,234,.15); }
|
.btn-primary { background: linear-gradient(135deg, #667eea, #764ba2); border: none;
|
||||||
.btn-login { background: linear-gradient(135deg, #667eea, #764ba2); border: none;
|
|
||||||
border-radius: 10px; padding: .6rem; font-weight: 500; width: 100%; }
|
border-radius: 10px; padding: .6rem; font-weight: 500; width: 100%; }
|
||||||
.btn-login:hover { opacity: .9; }
|
|
||||||
.alert { border-radius: 10px; font-size: .85rem; }
|
.alert { border-radius: 10px; font-size: .85rem; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="card login-card">
|
<div class="card">
|
||||||
<div class="login-header">
|
<div class="card-header">
|
||||||
<i class="bi bi-lightning-charge-fill"></i>
|
<i class="bi bi-lightning-charge-fill"></i>
|
||||||
<h4>微店抢购管理</h4>
|
<h4>微店抢购管理</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="login-body">
|
<div class="card-body">
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<div class="alert alert-danger py-2">{{ error }}</div>
|
<div class="alert alert-danger py-2">{{ error }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -39,7 +36,7 @@
|
|||||||
<input type="password" class="form-control" name="password"
|
<input type="password" class="form-control" name="password"
|
||||||
placeholder="请输入访问密码" autofocus required>
|
placeholder="请输入访问密码" autofocus required>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary btn-login">登 录</button>
|
<button type="submit" class="btn btn-primary">登 录</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
50
templates/setup.html
Normal file
50
templates/setup.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 href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
|
||||||
|
.card { width: 380px; border-radius: 18px; border: none; box-shadow: 0 8px 40px rgba(0,0,0,.2); }
|
||||||
|
.card-header { text-align: center; padding: 2rem 2rem 1rem; background: none; border: none; }
|
||||||
|
.card-header i { font-size: 2.5rem; color: #667eea; }
|
||||||
|
.card-header h4 { margin-top: .8rem; font-weight: 600; color: #333; }
|
||||||
|
.card-header p { color: #888; font-size: .85rem; }
|
||||||
|
.card-body { padding: 0 2rem 2rem; }
|
||||||
|
.form-control { border-radius: 10px; padding: .6rem 1rem; }
|
||||||
|
.btn-primary { background: linear-gradient(135deg, #667eea, #764ba2); border: none;
|
||||||
|
border-radius: 10px; padding: .6rem; font-weight: 500; width: 100%; }
|
||||||
|
.alert { border-radius: 10px; font-size: .85rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="bi bi-shield-lock-fill"></i>
|
||||||
|
<h4>首次使用设置</h4>
|
||||||
|
<p>请设置管理密码(至少6位)</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger py-2">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<form method="POST">
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="password" class="form-control" name="password"
|
||||||
|
placeholder="设置密码" autofocus required minlength="6">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="password" class="form-control" name="password2"
|
||||||
|
placeholder="确认密码" required minlength="6">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">确认设置</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user