修改登录和基础功能样式,目前已经支持钉钉登录和自定义登录注册

This commit is contained in:
zsc
2026-05-16 14:21:32 +08:00
parent 7ba21d6413
commit de0d0cc6be
17 changed files with 588 additions and 50 deletions

View File

@@ -29,7 +29,7 @@ db.init_app(app)
login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = 'index'
login_manager.login_view = 'login'
@login_manager.user_loader
def load_user(user_id):

View File

@@ -1,6 +1,22 @@
import os
from datetime import timedelta
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY') or 'hard_to_guess_string_for_flask_app'
SQLALCHEMY_DATABASE_URI = 'sqlite:///question_collector.db'
SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_ENGINE_OPTIONS = {
'connect_args': {'check_same_thread': False}
}
# 设置 UTC+8 时区
import pytz
tz = pytz.timezone('Asia/Shanghai')
def naive_now():
from datetime import datetime
return datetime.now(tz)
def utc_now():
from datetime import datetime
return datetime.utcnow()

View File

@@ -1,7 +1,18 @@
from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, SelectField, BooleanField, SubmitField
from wtforms import StringField, TextAreaField, SelectField, BooleanField, SubmitField, PasswordField
from wtforms.validators import DataRequired, Length
class LoginForm(FlaskForm):
username = StringField('用户名', validators=[DataRequired(), Length(max=80)])
password = PasswordField('密码', validators=[DataRequired()])
submit = SubmitField('登录')
class RegisterForm(FlaskForm):
username = StringField('用户名', validators=[DataRequired(), Length(max=80)])
password = PasswordField('密码', validators=[DataRequired(), Length(min=6)])
confirm_password = PasswordField('确认密码', validators=[DataRequired()])
submit = SubmitField('注册')
class DemandForm(FlaskForm):
title = StringField('需求标题', validators=[DataRequired(), Length(max=200)])
content = TextAreaField('需求内容', validators=[DataRequired(), Length(max=1000)])
@@ -14,7 +25,7 @@ class DemandForm(FlaskForm):
('finance_review', '经费审查委员会'),
('women', '女职工委员会')
], validators=[DataRequired()])
contact = StringField('联系方式', validators=[DataRequired(), Length(max=100)])
contact = StringField('联系方式')
is_public = BooleanField('是否公开')
submit = SubmitField('提交')

View File

@@ -1,19 +1,42 @@
from __init__ import app, db
from models import User
from sqlalchemy import text
with app.app_context():
db.create_all()
if not User.query.filter_by(username='管理员').first():
# 检查并添加 password_hash 字段(如果不存在)
try:
db.session.execute(text("ALTER TABLE user ADD COLUMN password_hash VARCHAR(200)"))
db.session.commit()
print('已添加 password_hash 字段')
except Exception as e:
if 'duplicate column' in str(e).lower() or 'already exists' in str(e).lower():
print('password_hash 字段已存在')
else:
pass # 字段可能已存在,忽略错误
# 创建默认管理员账号
admin = User.query.filter_by(username='管理员').first()
if not admin:
admin = User(
username='管理员',
username='admin',
dingtalk_userid='admin',
dingtalk_name='管理员',
dingtalk_dept='',
role='admin'
)
admin.set_password('admin123') # 设置默认密码
db.session.add(admin)
db.session.commit()
print('数据库初始化完成,已创建管理员账户')
print('用户名: admin')
print('密码: admin123')
print('请及时修改默认密码!')
else:
# 确保管理员有密码
if not admin.password_hash:
admin.set_password('admin123')
db.session.commit()
print('已为管理员设置默认密码: admin123')
print('数据库已存在')

View File

@@ -1,18 +1,33 @@
from datetime import datetime
from flask_sqlalchemy import SQLAlchemy
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
import pytz
db = SQLAlchemy()
def now_shanghai():
"""返回上海时区UTC+8的当前时间"""
return datetime.now(pytz.timezone('Asia/Shanghai'))
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
password_hash = db.Column(db.String(200))
role = db.Column(db.String(20), nullable=False, default='user')
contact = db.Column(db.String(100))
dingtalk_userid = db.Column(db.String(100), unique=True, nullable=False)
dingtalk_userid = db.Column(db.String(100), unique=True)
dingtalk_name = db.Column(db.String(100))
dingtalk_dept = db.Column(db.String(200))
created_at = db.Column(db.DateTime, default=datetime.utcnow)
created_at = db.Column(db.DateTime, default=now_shanghai)
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
if not self.password_hash:
return False
return check_password_hash(self.password_hash, password)
def is_admin(self):
return self.role == 'admin'
@@ -27,8 +42,8 @@ class Demand(db.Model):
answer = db.Column(db.Text)
answered_at = db.Column(db.DateTime)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow)
created_at = db.Column(db.DateTime, default=now_shanghai)
updated_at = db.Column(db.DateTime, default=now_shanghai)
user = db.relationship('User', backref=db.backref('demands', lazy=True))

30
package.sh Executable file
View File

@@ -0,0 +1,30 @@
#!/bin/bash
# 项目名称
PROJECT_NAME="mashangban"
# 打包输出目录
OUTPUT_DIR="dist"
# 创建输出目录
mkdir -p $OUTPUT_DIR
# 创建打包文件
echo "开始打包..."
# 使用 tar 打包项目文件
tar -czvf "$OUTPUT_DIR/${PROJECT_NAME}_$(date +%Y%m%d_%H%M%S).tar.gz" \
--exclude='__pycache__' \
--exclude='*.pyc' \
--exclude='.git' \
--exclude='.codebuddy' \
--exclude='node_modules' \
--exclude='*.db' \
--exclude='venv' \
--exclude='env' \
--exclude='.env' \
-C /Users/justin/df_project \
$PROJECT_NAME
echo "打包完成!文件保存在 $OUTPUT_DIR 目录"
ls -lh $OUTPUT_DIR/

View File

@@ -4,3 +4,4 @@ Flask-WTF==1.2.1
Flask-Login==0.6.3
requests==2.31.0
python-dotenv==1.0.0
pytz==2024.1

101
routes.py
View File

@@ -1,9 +1,9 @@
from datetime import datetime
from flask import render_template, redirect, url_for, flash, request
from math import ceil
from flask_login import login_user, logout_user, login_required, current_user
from __init__ import app, db
from models import User, Demand
from forms import DemandForm, AnswerForm
from models import User, Demand, now_shanghai
from forms import DemandForm, AnswerForm, LoginForm, RegisterForm
BRANCH_NAMES = {
'comprehensive': '综合分会',
@@ -21,6 +21,53 @@ def utility_processor():
return BRANCH_NAMES.get(branch_key, branch_key)
return dict(get_branch_name=get_branch_name)
@app.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('index'))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(username=form.username.data).first()
if user and user.check_password(form.password.data):
login_user(user)
flash('登录成功', 'success')
next_page = request.args.get('next')
return redirect(next_page) if next_page else redirect(url_for('index'))
else:
flash('用户名或密码错误', 'error')
return render_template('login.html', form=form)
@app.route('/logout')
@login_required
def logout():
logout_user()
flash('已退出登录', 'info')
return redirect(url_for('index'))
@app.route('/register', methods=['GET', 'POST'])
def register():
if current_user.is_authenticated:
return redirect(url_for('index'))
form = RegisterForm()
if form.validate_on_submit():
if form.password.data != form.confirm_password.data:
flash('两次输入的密码不一致', 'warning')
return render_template('register.html', form=form)
existing_user = User.query.filter_by(username=form.username.data).first()
if existing_user:
flash('用户名已存在', 'warning')
else:
user = User(
username=form.username.data,
role='user'
)
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
flash('注册成功,请登录', 'success')
return redirect(url_for('login'))
return render_template('register.html', form=form)
@app.before_request
def before_request():
user_id = request.args.get('userId')
@@ -55,8 +102,12 @@ def before_request():
@app.route('/')
def index():
demands = Demand.query.filter_by(is_public=True).order_by(Demand.created_at.desc()).all()
return render_template('index.html', demands=demands)
page = request.args.get('page', 1, type=int)
per_page = 10
pagination = Demand.query.filter_by(is_public=True).order_by(Demand.created_at.desc()).paginate(
page=page, per_page=per_page, error_out=False
)
return render_template('index.html', demands=pagination.items, pagination=pagination)
@app.route('/demand/new', methods=['GET', 'POST'])
def new_demand():
@@ -74,7 +125,7 @@ def new_demand():
)
db.session.add(demand)
db.session.commit()
flash('需求提交成功')
flash('需求提交成功', 'success')
return redirect(url_for('index'))
return render_template('demand_form.html', form=form, title='提交新需求')
@@ -84,7 +135,7 @@ def edit_demand(id):
return render_template('not_logged_in.html')
demand = Demand.query.get_or_404(id)
if not demand.can_edit(current_user):
flash('无权限编辑此需求')
flash('无权限编辑此需求', 'error')
return redirect(url_for('index'))
form = DemandForm(obj=demand)
if form.validate_on_submit():
@@ -94,9 +145,9 @@ def edit_demand(id):
demand.contact = form.contact.data
if current_user.is_admin() or not demand.answer:
demand.is_public = form.is_public.data
demand.updated_at = datetime.utcnow()
demand.updated_at = now_shanghai()
db.session.commit()
flash('需求更新成功')
flash('需求更新成功', 'success')
return redirect(url_for('index'))
return render_template('demand_form.html', form=form, title='编辑需求', demand=demand)
@@ -106,14 +157,14 @@ def answer_demand(id):
return render_template('not_logged_in.html')
demand = Demand.query.get_or_404(id)
if not current_user.is_admin():
flash('只有管理员可以回答需求')
flash('只有管理员可以回答需求', 'error')
return redirect(url_for('index'))
form = AnswerForm(data={'answer': demand.answer or ''})
if form.validate_on_submit():
demand.answer = form.answer.data
demand.answered_at = datetime.utcnow()
demand.answered_at = now_shanghai()
db.session.commit()
flash('回答已保存')
flash('回答已保存', 'success')
return redirect(url_for('index'))
return render_template('answer_form.html', form=form, demand=demand)
@@ -123,26 +174,34 @@ def toggle_public(id):
return render_template('not_logged_in.html')
demand = Demand.query.get_or_404(id)
if not current_user.is_admin():
flash('只有管理员可以修改公开状态')
return redirect(url_for('index'))
flash('只有管理员可以修改公开状态', 'error')
return redirect(url_for(endpoint='admin_demands'))
demand.is_public = not demand.is_public
db.session.commit()
flash('公开状态已更新')
return redirect(url_for('index'))
flash('公开状态已更新', 'success')
return redirect(url_for('admin_demands'))
@app.route('/my_demands')
def my_demands():
if not current_user.is_authenticated:
return render_template('not_logged_in.html')
demands = Demand.query.filter_by(user_id=current_user.id).order_by(Demand.created_at.desc()).all()
return render_template('my_demands.html', demands=demands)
page = request.args.get('page', 1, type=int)
per_page = 10
pagination = Demand.query.filter_by(user_id=current_user.id).order_by(Demand.created_at.desc()).paginate(
page=page, per_page=per_page, error_out=False
)
return render_template('my_demands.html', demands=pagination.items, pagination=pagination)
@app.route('/admin/demands')
def admin_demands():
if not current_user.is_authenticated:
return render_template('not_logged_in.html')
if not current_user.is_admin():
flash('无权限访问此页面')
flash('无权限访问此页面', 'error')
return redirect(url_for('index'))
demands = Demand.query.order_by(Demand.created_at.desc()).all()
return render_template('admin_demands.html', demands=demands)
page = request.args.get('page', 1, type=int)
per_page = 10
pagination = Demand.query.order_by(Demand.created_at.desc()).paginate(
page=page, per_page=per_page, error_out=False
)
return render_template('admin_demands.html', demands=pagination.items, pagination=pagination)

View File

@@ -433,6 +433,217 @@ h2 {
color: #856404;
}
.login-container {
max-width: 400px;
margin: 40px auto;
padding: 30px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.login-container h2 {
text-align: center;
margin-bottom: 30px;
color: #4a90d9;
}
.login-container .form-group {
margin-bottom: 20px;
}
.login-container .form-control {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.login-container .form-control:focus {
outline: none;
border-color: #4a90d9;
}
.login-container .btn-primary {
width: 100%;
padding: 12px;
background-color: #4a90d9;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
}
.login-container .btn-primary:hover {
background-color: #3a80c9;
}
.login-link {
text-align: center;
margin-top: 20px;
color: #666;
}
.login-link a {
color: #4a90d9;
text-decoration: none;
}
.login-link a:hover {
text-decoration: underline;
}
.dingtalk-login {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eee;
text-align: center;
}
.dingtalk-login p {
color: #999;
margin-bottom: 15px;
font-size: 14px;
}
.btn-dingtalk {
background-color: #1677ff;
color: white;
padding: 10px 20px;
border-radius: 4px;
text-decoration: none;
display: inline-block;
}
.btn-dingtalk:hover {
background-color: #0958d9;
}
.login-options {
display: flex;
flex-direction: column;
gap: 15px;
align-items: center;
margin-top: 30px;
}
.login-options .btn {
min-width: 200px;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #5c636a;
}
.error {
color: #dc3545;
font-size: 14px;
margin-top: 5px;
}
.form-group .error {
color: #dc3545;
font-size: 14px;
margin-top: 5px;
}
/* Toast 弹出提示 - Element UI 风格 */
.toast-container {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
pointer-events: none;
}
.toast {
background: white;
padding: 12px 20px;
border-radius: 4px;
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
font-size: 14px;
min-width: 300px;
max-width: 500px;
text-align: center;
opacity: 0;
transform: translateY(-20px);
transition: all 0.3s ease;
pointer-events: auto;
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
.toast-success {
background: #f0f9eb;
border: 1px solid #e1f3d8;
color: #67c23a;
}
.toast-warning {
background: #fdf6ec;
border: 1px solid #faecd8;
color: #e6a23c;
}
.toast-error {
background: #fef0f0;
border: 1px solid #fde2e2;
color: #f56c6c;
}
.toast-info {
background: #edf2fc;
border: 1px solid #d3d4d6;
color: #909399;
}
.toast .toast-icon {
margin-right: 8px;
}
/* 分页样式 */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 15px;
margin-top: 30px;
padding: 20px 0;
}
.btn-page {
background-color: #4a90d9;
color: white;
padding: 8px 16px;
border-radius: 4px;
text-decoration: none;
font-size: 14px;
}
.btn-page:hover {
background-color: #3a80c9;
}
.page-info {
color: #666;
font-size: 14px;
}
.question-preview {
background: #f8f9fa;
padding: 20px;

View File

@@ -9,22 +9,26 @@
{% for demand in demands %}
<div class="question-item">
<div class="question-header">
<span class="branch">{{ get_branch_name(demand.branch) }}</span>
<span class="time">{{ demand.created_at.strftime('%Y-%m-%d %H:%M') }}</span>
<h3 class="demand-title">{{ demand.title }} <span class="branch">{{ get_branch_name(demand.branch) }}</span></h3>
<span class="status {% if demand.is_public %}public{% else %}private{% endif %}">
{% if demand.is_public %}公开{% else %}私有{% endif %}
</span>
<span class="user">提交者: {{ demand.user.username }}</span>
</div>
<h3 class="demand-title">{{ demand.title }}</h3>
<span class="time">提交者: {{ demand.user.username }} &nbsp;&nbsp;&nbsp;&nbsp;&nbsp; 提交时间: {{ demand.created_at.strftime('%Y-%m-%d %H:%M') }}</span>
<p class="content">{{ demand.content }}</p>
<div class="question-footer">
<span class="contact">联系方式: {{ demand.contact }}</span>
{% if demand.answer %}
<div class="answer">
<strong>回答:</strong>
<p>{{ demand.answer }}</p>
<span class="answer-time">回答时间: {{ demand.answered_at.strftime('%Y-%m-%d %H:%M') }}</span>
<div class="answer-toggle" onclick="toggleAnswer(this)">
<span class="toggle-icon"></span>
<span class="toggle-text">查看回答</span>
</div>
<div class="answer-content" style="display: none;">
<div class="answer">
<strong>回答:</strong>
<p>{{ demand.answer }}</p>
<span class="answer-time">回答时间: {{ demand.answered_at.strftime('%Y-%m-%d %H:%M') }}</span>
</div>
</div>
{% else %}
<span class="status waiting">待回答</span>
@@ -42,7 +46,41 @@
</div>
{% endfor %}
</div>
{% if pagination.pages > 1 %}
<div class="pagination">
{% if pagination.has_prev %}
<a href="{{ url_for('admin_demands', page=pagination.prev_num) }}" class="btn btn-page">上一页</a>
{% endif %}
<span class="page-info">
共 {{ pagination.total }} 条,第 {{ pagination.page }} / {{ pagination.pages }} 页
</span>
{% if pagination.has_next %}
<a href="{{ url_for('admin_demands', page=pagination.next_num) }}" class="btn btn-page">下一页</a>
{% endif %}
</div>
{% endif %}
{% else %}
<p class="empty">暂无需求</p>
{% endif %}
{% endblock %}
<script>
function toggleAnswer(element) {
var content = element.nextElementSibling;
var icon = element.querySelector('.toggle-icon');
var text = element.querySelector('.toggle-text');
if (content.style.display === 'none') {
content.style.display = 'block';
icon.textContent = '▼';
text.textContent = '收起回答';
} else {
content.style.display = 'none';
icon.textContent = '▶';
text.textContent = '查看回答';
}
}
</script>
{% endblock %}

View File

@@ -23,20 +23,48 @@
<li><a href="{{ url_for('admin_demands') }}">管理后台</a></li>
{% endif %}
<li class="user-name">{{ current_user.dingtalk_name or current_user.username }}</li>
<li><a href="{{ url_for('logout') }}">退出</a></li>
{% else %}
<li><a href="{{ url_for('login') }}">登录</a></li>
{% endif %}
</ul>
</nav>
</div>
</header>
<main class="container">
{% with messages = get_flashed_messages() %}
{% block content %}{% endblock %}
<div id="toast-container" class="toast-container"></div>
<script>
const toastIcons = {
success: '✓',
warning: '⚠',
error: '✕',
info: ''
};
function showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.innerHTML = `<span class="toast-icon">${toastIcons[type]}</span>${message}`;
container.appendChild(toast);
setTimeout(() => toast.classList.add('show'), 10);
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// 显示 Flask flash messages
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for message in messages %}
<div class="flash">{{ message }}</div>
{% for category, message in messages %}
showToast('{{ message }}', '{{ category }}');
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</script>
</main>
<footer>
<div class="container">

View File

@@ -19,10 +19,9 @@
{{ form.branch(class="form-control") }}
</div>
<div class="form-group">
<label>{{ form.contact.label.text }} *</label>
{{ form.contact(class="form-control") }}
<label>{{ form.contact.label.text }}</label>
{{ form.contact(class="form-control") }}
</div>
{{ form.is_public() }} <label for="is_public">{{ form.is_public.label.text }}</label>
<div class="form-group">
{{ form.submit(class="btn btn-primary") }}
<a href="{{ url_for('index') }}" class="btn btn-cancel">取消</a>

View File

@@ -27,6 +27,22 @@
</div>
{% endfor %}
</div>
{% if pagination.pages > 1 %}
<div class="pagination">
{% if pagination.has_prev %}
<a href="{{ url_for('index', page=pagination.prev_num) }}" class="btn btn-page">上一页</a>
{% endif %}
<span class="page-info">
共 {{ pagination.total }} 条,第 {{ pagination.page }} / {{ pagination.pages }} 页
</span>
{% if pagination.has_next %}
<a href="{{ url_for('index', page=pagination.next_num) }}" class="btn btn-page">下一页</a>
{% endif %}
</div>
{% endif %}
{% else %}
<p class="empty">暂无公开需求</p>
{% endif %}
@@ -48,4 +64,4 @@ function toggleAnswer(element) {
}
}
</script>
{% endblock %}
{% endblock %}

32
templates/login.html Normal file
View File

@@ -0,0 +1,32 @@
{% extends 'base.html' %}
{% block title %}登录 - 人事共享服务中心"码"上办{% endblock %}
{% block content %}
<div class="login-container">
<h2>登录</h2>
<form method="POST" action="{{ url_for('login') }}">
{{ form.hidden_tag() }}
<div class="form-group">
{{ form.username.label }}
{{ form.username(class="form-control") }}
{% if form.username.errors %}
<div class="error">{{ form.username.errors[0] }}</div>
{% endif %}
</div>
<div class="form-group">
{{ form.password.label }}
{{ form.password(class="form-control") }}
{% if form.password.errors %}
<div class="error">{{ form.password.errors[0] }}</div>
{% endif %}
</div>
<div class="form-group">
{{ form.submit(class="btn btn-primary") }}
</div>
</form>
<p class="login-link">
还没有账号?<a href="{{ url_for('register') }}">立即注册</a>
</p>
</div>
{% endblock %}

View File

@@ -36,6 +36,22 @@
<p class="empty">暂无需求</p>
{% endif %}
{% if pagination.pages > 1 %}
<div class="pagination">
{% if pagination.has_prev %}
<a href="{{ url_for('my_demands', page=pagination.prev_num) }}" class="btn btn-page">上一页</a>
{% endif %}
<span class="page-info">
共 {{ pagination.total }} 条,第 {{ pagination.page }} / {{ pagination.pages }} 页
</span>
{% if pagination.has_next %}
<a href="{{ url_for('my_demands', page=pagination.next_num) }}" class="btn btn-page">下一页</a>
{% endif %}
</div>
{% endif %}
<script>
function toggleAnswer(element) {
var content = element.nextElementSibling;

View File

@@ -5,7 +5,11 @@
{% block content %}
<div class="login-required">
<div class="login-icon">🔒</div>
<h2>请先通过钉钉登录</h2>
<p>本系统需要通过钉钉账号登录才能使用</p>
<h2>请先登录</h2>
<p>本系统需要登录才能使用</p>
<div class="login-options">
<a href="{{ url_for('login') }}" class="btn btn-primary">账号密码登录</a>
<a href="{{ url_for('register') }}" class="btn btn-secondary">注册账号</a>
</div>
</div>
{% endblock %}

39
templates/register.html Normal file
View File

@@ -0,0 +1,39 @@
{% extends 'base.html' %}
{% block title %}注册 - 人事共享服务中心"码"上办{% endblock %}
{% block content %}
<div class="login-container">
<h2>注册账号</h2>
<form method="POST" action="{{ url_for('register') }}">
{{ form.hidden_tag() }}
<div class="form-group">
{{ form.username.label }}
{{ form.username(class="form-control") }}
{% if form.username.errors %}
<div class="error">{{ form.username.errors[0] }}</div>
{% endif %}
</div>
<div class="form-group">
{{ form.password.label }}
{{ form.password(class="form-control") }}
{% if form.password.errors %}
<div class="error">{{ form.password.errors[0] }}</div>
{% endif %}
</div>
<div class="form-group">
{{ form.confirm_password.label }}
{{ form.confirm_password(class="form-control") }}
{% if form.confirm_password.errors %}
<div class="error">{{ form.confirm_password.errors[0] }}</div>
{% endif %}
</div>
<div class="form-group">
{{ form.submit(class="btn btn-primary") }}
</div>
</form>
<p class="login-link">
已有账号?<a href="{{ url_for('login') }}">立即登录</a>
</p>
</div>
{% endblock %}