Upload 10 files
Browse files- app.py +133 -8
- forms.py +46 -0
- models.py +43 -0
- requirements.txt +6 -0
- static/css/style.css +65 -17
- static/js/app.js +158 -46
- templates/index.html +83 -4
- templates/login.html +113 -0
- templates/register.html +125 -0
app.py
CHANGED
|
@@ -1,11 +1,31 @@
|
|
| 1 |
import os
|
| 2 |
import torch
|
| 3 |
from transformers import AutoTokenizer
|
| 4 |
-
from flask import Flask, request, jsonify, render_template
|
|
|
|
|
|
|
|
|
|
| 5 |
from PhoBERTMultiTask import PhoBERTMultiTask
|
| 6 |
|
| 7 |
app = Flask(__name__)
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 10 |
|
| 11 |
# === Load tokenizer & model ===
|
|
@@ -27,14 +47,93 @@ print("✅ Model đã sẵn sàng!")
|
|
| 27 |
|
| 28 |
# ====== ROUTES ======
|
| 29 |
@app.route("/", methods=["GET"])
|
|
|
|
| 30 |
def home():
|
| 31 |
return render_template("index.html")
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
@app.route("/api/health", methods=["GET"])
|
| 34 |
def health():
|
| 35 |
return jsonify({"status": "healthy", "message": "✅ PhoBERT MultiTask API is running!"})
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
@app.route("/predict", methods=["POST"])
|
|
|
|
| 38 |
def predict():
|
| 39 |
try:
|
| 40 |
data = request.get_json()
|
|
@@ -59,21 +158,47 @@ def predict():
|
|
| 59 |
topic = torch.argmax(logits_topic, dim=1).item()
|
| 60 |
|
| 61 |
# Mapping
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
return jsonify({
|
| 66 |
-
"sentiment":
|
| 67 |
-
"topic":
|
| 68 |
"confidence": {
|
| 69 |
-
"sentiment":
|
| 70 |
-
"topic":
|
| 71 |
}
|
| 72 |
})
|
| 73 |
except Exception as e:
|
| 74 |
return jsonify({"error": f"Có lỗi xảy ra khi xử lý: {str(e)}"}), 500
|
| 75 |
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
if __name__ == "__main__":
|
| 78 |
# Hugging Face luôn yêu cầu port = 7860
|
| 79 |
-
app.run(host="0.0.0.0", port=7860)
|
|
|
|
| 1 |
import os
|
| 2 |
import torch
|
| 3 |
from transformers import AutoTokenizer
|
| 4 |
+
from flask import Flask, request, jsonify, render_template, redirect, url_for, flash
|
| 5 |
+
from flask_login import LoginManager, login_user, logout_user, login_required, current_user
|
| 6 |
+
from models import db, User, Feedback
|
| 7 |
+
from forms import RegistrationForm, LoginForm
|
| 8 |
from PhoBERTMultiTask import PhoBERTMultiTask
|
| 9 |
|
| 10 |
app = Flask(__name__)
|
| 11 |
|
| 12 |
+
# Cấu hình
|
| 13 |
+
app.config['SECRET_KEY'] = 'your-secret-key-change-this-in-production'
|
| 14 |
+
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///feedback_analysis.db'
|
| 15 |
+
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
| 16 |
+
|
| 17 |
+
# Khởi tạo extensions
|
| 18 |
+
db.init_app(app)
|
| 19 |
+
login_manager = LoginManager()
|
| 20 |
+
login_manager.init_app(app)
|
| 21 |
+
login_manager.login_view = 'login'
|
| 22 |
+
login_manager.login_message = 'Vui lòng đăng nhập để sử dụng hệ thống phân tích feedback.'
|
| 23 |
+
login_manager.login_message_category = 'info'
|
| 24 |
+
|
| 25 |
+
@login_manager.user_loader
|
| 26 |
+
def load_user(user_id):
|
| 27 |
+
return User.query.get(int(user_id))
|
| 28 |
+
|
| 29 |
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 30 |
|
| 31 |
# === Load tokenizer & model ===
|
|
|
|
| 47 |
|
| 48 |
# ====== ROUTES ======
|
| 49 |
@app.route("/", methods=["GET"])
|
| 50 |
+
@login_required
|
| 51 |
def home():
|
| 52 |
return render_template("index.html")
|
| 53 |
|
| 54 |
+
@app.route("/register", methods=["GET", "POST"])
|
| 55 |
+
def register():
|
| 56 |
+
if current_user.is_authenticated:
|
| 57 |
+
return redirect(url_for('home'))
|
| 58 |
+
|
| 59 |
+
form = RegistrationForm()
|
| 60 |
+
if form.validate_on_submit():
|
| 61 |
+
user = User(username=form.username.data)
|
| 62 |
+
user.set_password(form.password.data)
|
| 63 |
+
db.session.add(user)
|
| 64 |
+
db.session.commit()
|
| 65 |
+
flash('Đăng ký thành công! Vui lòng đăng nhập.', 'success')
|
| 66 |
+
return redirect(url_for('login'))
|
| 67 |
+
|
| 68 |
+
return render_template('register.html', form=form)
|
| 69 |
+
|
| 70 |
+
@app.route("/login", methods=["GET", "POST"])
|
| 71 |
+
def login():
|
| 72 |
+
if current_user.is_authenticated:
|
| 73 |
+
return redirect(url_for('home'))
|
| 74 |
+
|
| 75 |
+
form = LoginForm()
|
| 76 |
+
if form.validate_on_submit():
|
| 77 |
+
user = User.query.filter_by(username=form.username.data).first()
|
| 78 |
+
if user and user.check_password(form.password.data):
|
| 79 |
+
login_user(user, remember=True)
|
| 80 |
+
flash('Đăng nhập thành công!', 'success')
|
| 81 |
+
next_page = request.args.get('next')
|
| 82 |
+
return redirect(next_page) if next_page else redirect(url_for('home'))
|
| 83 |
+
else:
|
| 84 |
+
flash('Tên đăng nhập hoặc mật khẩu không đúng.', 'danger')
|
| 85 |
+
|
| 86 |
+
return render_template('login.html', form=form)
|
| 87 |
+
|
| 88 |
+
@app.route("/logout")
|
| 89 |
+
@login_required
|
| 90 |
+
def logout():
|
| 91 |
+
logout_user()
|
| 92 |
+
flash('Đã đăng xuất thành công!', 'info')
|
| 93 |
+
return redirect(url_for('home'))
|
| 94 |
+
|
| 95 |
@app.route("/api/health", methods=["GET"])
|
| 96 |
def health():
|
| 97 |
return jsonify({"status": "healthy", "message": "✅ PhoBERT MultiTask API is running!"})
|
| 98 |
|
| 99 |
+
@app.route("/api/feedback-history", methods=["GET"])
|
| 100 |
+
@login_required
|
| 101 |
+
def get_feedback_history():
|
| 102 |
+
"""Lấy lịch sử feedback của user hiện tại"""
|
| 103 |
+
try:
|
| 104 |
+
page = request.args.get('page', 1, type=int)
|
| 105 |
+
per_page = request.args.get('per_page', 10, type=int)
|
| 106 |
+
|
| 107 |
+
# Lấy feedback của user hiện tại, sắp xếp theo thời gian mới nhất
|
| 108 |
+
feedbacks = Feedback.query.filter_by(user_id=current_user.id)\
|
| 109 |
+
.order_by(Feedback.created_at.desc())\
|
| 110 |
+
.paginate(page=page, per_page=per_page, error_out=False)
|
| 111 |
+
|
| 112 |
+
feedback_list = []
|
| 113 |
+
for feedback in feedbacks.items:
|
| 114 |
+
feedback_list.append({
|
| 115 |
+
'id': feedback.id,
|
| 116 |
+
'text': feedback.text,
|
| 117 |
+
'sentiment': feedback.sentiment,
|
| 118 |
+
'topic': feedback.topic,
|
| 119 |
+
'sentiment_confidence': feedback.sentiment_confidence,
|
| 120 |
+
'topic_confidence': feedback.topic_confidence,
|
| 121 |
+
'created_at': feedback.created_at.isoformat()
|
| 122 |
+
})
|
| 123 |
+
|
| 124 |
+
return jsonify({
|
| 125 |
+
'feedbacks': feedback_list,
|
| 126 |
+
'total': feedbacks.total,
|
| 127 |
+
'pages': feedbacks.pages,
|
| 128 |
+
'current_page': page,
|
| 129 |
+
'has_next': feedbacks.has_next,
|
| 130 |
+
'has_prev': feedbacks.has_prev
|
| 131 |
+
})
|
| 132 |
+
except Exception as e:
|
| 133 |
+
return jsonify({"error": f"Có lỗi xảy ra: {str(e)}"}), 500
|
| 134 |
+
|
| 135 |
@app.route("/predict", methods=["POST"])
|
| 136 |
+
@login_required
|
| 137 |
def predict():
|
| 138 |
try:
|
| 139 |
data = request.get_json()
|
|
|
|
| 158 |
topic = torch.argmax(logits_topic, dim=1).item()
|
| 159 |
|
| 160 |
# Mapping
|
| 161 |
+
SENTIMENT_MAP = {0: "negative", 1: "neutral", 2: "positive"}
|
| 162 |
+
TOPIC_MAP = {0: "lecturer", 1: "training_program", 2: "facility", 3: "others"}
|
| 163 |
+
|
| 164 |
+
sentiment = SENTIMENT_MAP[sent]
|
| 165 |
+
topic_result = TOPIC_MAP[topic]
|
| 166 |
+
sentiment_confidence = float(torch.softmax(logits_sent, dim=1).max().item())
|
| 167 |
+
topic_confidence = float(torch.softmax(logits_topic, dim=1).max().item())
|
| 168 |
+
|
| 169 |
+
# Lưu feedback vào database
|
| 170 |
+
try:
|
| 171 |
+
feedback = Feedback(
|
| 172 |
+
text=text,
|
| 173 |
+
sentiment=sentiment,
|
| 174 |
+
topic=topic_result,
|
| 175 |
+
sentiment_confidence=sentiment_confidence,
|
| 176 |
+
topic_confidence=topic_confidence,
|
| 177 |
+
user_id=current_user.id
|
| 178 |
+
)
|
| 179 |
+
db.session.add(feedback)
|
| 180 |
+
db.session.commit()
|
| 181 |
+
except Exception as db_error:
|
| 182 |
+
print(f"Database error: {db_error}")
|
| 183 |
+
# Không dừng quá trình nếu lưu DB thất bại
|
| 184 |
|
| 185 |
return jsonify({
|
| 186 |
+
"sentiment": sentiment,
|
| 187 |
+
"topic": topic_result,
|
| 188 |
"confidence": {
|
| 189 |
+
"sentiment": sentiment_confidence,
|
| 190 |
+
"topic": topic_confidence
|
| 191 |
}
|
| 192 |
})
|
| 193 |
except Exception as e:
|
| 194 |
return jsonify({"error": f"Có lỗi xảy ra khi xử lý: {str(e)}"}), 500
|
| 195 |
|
| 196 |
|
| 197 |
+
# Khởi tạo database
|
| 198 |
+
with app.app_context():
|
| 199 |
+
db.create_all()
|
| 200 |
+
print("✅ Database đã sẵn sàng!")
|
| 201 |
+
|
| 202 |
if __name__ == "__main__":
|
| 203 |
# Hugging Face luôn yêu cầu port = 7860
|
| 204 |
+
app.run(host="0.0.0.0", port=7860, debug=True)
|
forms.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask_wtf import FlaskForm
|
| 2 |
+
from wtforms import StringField, PasswordField, SubmitField, TextAreaField
|
| 3 |
+
from wtforms.validators import DataRequired, Length, EqualTo, ValidationError
|
| 4 |
+
from models import User
|
| 5 |
+
|
| 6 |
+
class RegistrationForm(FlaskForm):
|
| 7 |
+
username = StringField('Tên đăng nhập', validators=[
|
| 8 |
+
DataRequired(message='Vui lòng nhập tên đăng nhập'),
|
| 9 |
+
Length(min=3, max=20, message='Tên đăng nhập phải từ 3-20 ký tự')
|
| 10 |
+
])
|
| 11 |
+
|
| 12 |
+
password = PasswordField('Mật khẩu', validators=[
|
| 13 |
+
DataRequired(message='Vui lòng nhập mật khẩu'),
|
| 14 |
+
Length(min=6, message='Mật khẩu phải có ít nhất 6 ký tự')
|
| 15 |
+
])
|
| 16 |
+
|
| 17 |
+
confirm_password = PasswordField('Xác nhận mật khẩu', validators=[
|
| 18 |
+
DataRequired(message='Vui lòng xác nhận mật khẩu'),
|
| 19 |
+
EqualTo('password', message='Mật khẩu xác nhận không khớp')
|
| 20 |
+
])
|
| 21 |
+
|
| 22 |
+
submit = SubmitField('Đăng ký')
|
| 23 |
+
|
| 24 |
+
def validate_username(self, username):
|
| 25 |
+
user = User.query.filter_by(username=username.data).first()
|
| 26 |
+
if user:
|
| 27 |
+
raise ValidationError('Tên đăng nhập đã tồn tại. Vui lòng chọn tên khác.')
|
| 28 |
+
|
| 29 |
+
class LoginForm(FlaskForm):
|
| 30 |
+
username = StringField('Tên đăng nhập', validators=[
|
| 31 |
+
DataRequired(message='Vui lòng nhập tên đăng nhập')
|
| 32 |
+
])
|
| 33 |
+
|
| 34 |
+
password = PasswordField('Mật khẩu', validators=[
|
| 35 |
+
DataRequired(message='Vui lòng nhập mật khẩu')
|
| 36 |
+
])
|
| 37 |
+
|
| 38 |
+
submit = SubmitField('Đăng nhập')
|
| 39 |
+
|
| 40 |
+
class FeedbackForm(FlaskForm):
|
| 41 |
+
text = TextAreaField('Feedback của bạn', validators=[
|
| 42 |
+
DataRequired(message='Vui lòng nhập feedback'),
|
| 43 |
+
Length(min=10, max=1000, message='Feedback phải từ 10-1000 ký tự')
|
| 44 |
+
])
|
| 45 |
+
|
| 46 |
+
submit = SubmitField('Phân Tích Feedback')
|
models.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask_sqlalchemy import SQLAlchemy
|
| 2 |
+
from flask_login import UserMixin
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
import bcrypt
|
| 5 |
+
|
| 6 |
+
db = SQLAlchemy()
|
| 7 |
+
|
| 8 |
+
class User(UserMixin, db.Model):
|
| 9 |
+
__tablename__ = 'users'
|
| 10 |
+
|
| 11 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 12 |
+
username = db.Column(db.String(80), unique=True, nullable=False)
|
| 13 |
+
password_hash = db.Column(db.String(128), nullable=False)
|
| 14 |
+
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
| 15 |
+
|
| 16 |
+
# Relationship với feedbacks
|
| 17 |
+
feedbacks = db.relationship('Feedback', backref='user', lazy=True)
|
| 18 |
+
|
| 19 |
+
def set_password(self, password):
|
| 20 |
+
"""Hash và lưu password"""
|
| 21 |
+
self.password_hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
| 22 |
+
|
| 23 |
+
def check_password(self, password):
|
| 24 |
+
"""Kiểm tra password"""
|
| 25 |
+
return bcrypt.checkpw(password.encode('utf-8'), self.password_hash.encode('utf-8'))
|
| 26 |
+
|
| 27 |
+
def __repr__(self):
|
| 28 |
+
return f'<User {self.username}>'
|
| 29 |
+
|
| 30 |
+
class Feedback(db.Model):
|
| 31 |
+
__tablename__ = 'feedbacks'
|
| 32 |
+
|
| 33 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 34 |
+
text = db.Column(db.Text, nullable=False)
|
| 35 |
+
sentiment = db.Column(db.String(20), nullable=False) # positive, neutral, negative
|
| 36 |
+
topic = db.Column(db.String(50), nullable=False) # lecturer, training_program, facility, others
|
| 37 |
+
sentiment_confidence = db.Column(db.Float, nullable=False)
|
| 38 |
+
topic_confidence = db.Column(db.Float, nullable=False)
|
| 39 |
+
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) # Bắt buộc vì đã login_required
|
| 40 |
+
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
| 41 |
+
|
| 42 |
+
def __repr__(self):
|
| 43 |
+
return f'<Feedback {self.id}: {self.sentiment} - {self.topic}>'
|
requirements.txt
CHANGED
|
@@ -8,3 +8,9 @@ gunicorn
|
|
| 8 |
numpy
|
| 9 |
requests
|
| 10 |
safetensors
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
numpy
|
| 9 |
requests
|
| 10 |
safetensors
|
| 11 |
+
Flask-SQLAlchemy==3.1.1
|
| 12 |
+
Flask-Login==0.6.3
|
| 13 |
+
Flask-WTF==1.2.1
|
| 14 |
+
WTForms==3.1.2
|
| 15 |
+
Werkzeug==3.0.3
|
| 16 |
+
bcrypt==4.1.3
|
static/css/style.css
CHANGED
|
@@ -71,10 +71,9 @@ header {
|
|
| 71 |
color: #FFFFFF !important;
|
| 72 |
border-radius: 16px;
|
| 73 |
margin: 0 0 1.5rem 0;
|
| 74 |
-
padding: 1.5rem 2rem
|
| 75 |
position: relative;
|
| 76 |
overflow: hidden;
|
| 77 |
-
text-align: center;
|
| 78 |
}
|
| 79 |
|
| 80 |
/* Force header color consistency */
|
|
@@ -151,8 +150,8 @@ header p.lead {
|
|
| 151 |
}
|
| 152 |
|
| 153 |
.card:hover {
|
| 154 |
-
transform: translateY(-
|
| 155 |
-
box-shadow: 0
|
| 156 |
}
|
| 157 |
|
| 158 |
.card:hover::before {
|
|
@@ -176,16 +175,16 @@ header p.lead {
|
|
| 176 |
|
| 177 |
/* Forms */
|
| 178 |
.form-control {
|
| 179 |
-
border-radius:
|
| 180 |
-
border:
|
| 181 |
-
padding:
|
| 182 |
-
font-size:
|
| 183 |
-
transition: all 0.
|
| 184 |
background: var(--white);
|
| 185 |
color: var(--gray-900);
|
| 186 |
position: relative;
|
| 187 |
-
box-shadow: 0
|
| 188 |
-
min-height:
|
| 189 |
width: 100%;
|
| 190 |
}
|
| 191 |
|
|
@@ -200,16 +199,12 @@ header p.lead {
|
|
| 200 |
margin-bottom: 0.5rem;
|
| 201 |
}
|
| 202 |
|
| 203 |
-
#feedbackForm .mb-3 {
|
| 204 |
-
margin-bottom: 0.75rem !important;
|
| 205 |
-
}
|
| 206 |
-
|
| 207 |
.form-control:focus {
|
| 208 |
border-color: var(--primary-color);
|
| 209 |
-
box-shadow: 0 0 0
|
| 210 |
background: var(--white);
|
| 211 |
outline: none;
|
| 212 |
-
transform:
|
| 213 |
}
|
| 214 |
|
| 215 |
.form-control:focus + .form-label,
|
|
@@ -370,6 +365,53 @@ body .card-header.bg-light {
|
|
| 370 |
font-size: 0.9rem;
|
| 371 |
}
|
| 372 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 373 |
/* Components */
|
| 374 |
.spinner-border {
|
| 375 |
width: 3rem;
|
|
@@ -381,6 +423,12 @@ body .card-header.bg-light {
|
|
| 381 |
border: 1px solid var(--gray-200);
|
| 382 |
padding: 1rem 1.5rem;
|
| 383 |
background: var(--white);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 384 |
}
|
| 385 |
|
| 386 |
blockquote {
|
|
|
|
| 71 |
color: #FFFFFF !important;
|
| 72 |
border-radius: 16px;
|
| 73 |
margin: 0 0 1.5rem 0;
|
| 74 |
+
padding: 1.5rem 2rem;
|
| 75 |
position: relative;
|
| 76 |
overflow: hidden;
|
|
|
|
| 77 |
}
|
| 78 |
|
| 79 |
/* Force header color consistency */
|
|
|
|
| 150 |
}
|
| 151 |
|
| 152 |
.card:hover {
|
| 153 |
+
transform: translateY(-2px) scale(1.01);
|
| 154 |
+
box-shadow: 0 8px 25px rgba(168, 200, 236, 0.15), 0 4px 16px rgba(0, 0, 0, 0.05);
|
| 155 |
}
|
| 156 |
|
| 157 |
.card:hover::before {
|
|
|
|
| 175 |
|
| 176 |
/* Forms */
|
| 177 |
.form-control {
|
| 178 |
+
border-radius: 8px;
|
| 179 |
+
border: 1px solid var(--gray-300);
|
| 180 |
+
padding: 0.75rem 1rem;
|
| 181 |
+
font-size: 0.9rem;
|
| 182 |
+
transition: all 0.2s ease;
|
| 183 |
background: var(--white);
|
| 184 |
color: var(--gray-900);
|
| 185 |
position: relative;
|
| 186 |
+
box-shadow: 0 2px 8px rgba(168, 200, 236, 0.05);
|
| 187 |
+
min-height: 45px;
|
| 188 |
width: 100%;
|
| 189 |
}
|
| 190 |
|
|
|
|
| 199 |
margin-bottom: 0.5rem;
|
| 200 |
}
|
| 201 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
.form-control:focus {
|
| 203 |
border-color: var(--primary-color);
|
| 204 |
+
box-shadow: 0 0 0 2px rgba(168, 200, 236, 0.1), 0 2px 8px rgba(168, 200, 236, 0.1);
|
| 205 |
background: var(--white);
|
| 206 |
outline: none;
|
| 207 |
+
transform: none;
|
| 208 |
}
|
| 209 |
|
| 210 |
.form-control:focus + .form-label,
|
|
|
|
| 365 |
font-size: 0.9rem;
|
| 366 |
}
|
| 367 |
|
| 368 |
+
/* Header Buttons */
|
| 369 |
+
header .btn-outline-light {
|
| 370 |
+
border-color: rgba(255, 255, 255, 0.5);
|
| 371 |
+
color: #FFFFFF;
|
| 372 |
+
font-weight: 600;
|
| 373 |
+
transition: all 0.3s ease;
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
header .btn-outline-light:hover {
|
| 377 |
+
background-color: rgba(255, 255, 255, 0.1);
|
| 378 |
+
border-color: #FFFFFF;
|
| 379 |
+
color: #FFFFFF;
|
| 380 |
+
transform: translateY(-2px);
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
header .btn-light {
|
| 384 |
+
background-color: rgba(255, 255, 255, 0.9);
|
| 385 |
+
color: var(--gray-700);
|
| 386 |
+
font-weight: 600;
|
| 387 |
+
transition: all 0.3s ease;
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
header .btn-light:hover {
|
| 391 |
+
background-color: #FFFFFF;
|
| 392 |
+
color: var(--gray-800);
|
| 393 |
+
transform: translateY(-2px);
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
/* Dropdown Menu */
|
| 397 |
+
.dropdown-menu {
|
| 398 |
+
border-radius: 12px;
|
| 399 |
+
border: 1px solid var(--gray-200);
|
| 400 |
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
| 401 |
+
margin-top: 0.5rem;
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
.dropdown-item {
|
| 405 |
+
padding: 0.75rem 1rem;
|
| 406 |
+
font-weight: 500;
|
| 407 |
+
transition: all 0.2s ease;
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
.dropdown-item:hover {
|
| 411 |
+
background-color: var(--gray-100);
|
| 412 |
+
color: var(--gray-800);
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
/* Components */
|
| 416 |
.spinner-border {
|
| 417 |
width: 3rem;
|
|
|
|
| 423 |
border: 1px solid var(--gray-200);
|
| 424 |
padding: 1rem 1.5rem;
|
| 425 |
background: var(--white);
|
| 426 |
+
transition: opacity 0.5s ease-out, transform 0.5s ease-out;
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
.alert.fade-out {
|
| 430 |
+
opacity: 0;
|
| 431 |
+
transform: translateY(-10px);
|
| 432 |
}
|
| 433 |
|
| 434 |
blockquote {
|
static/js/app.js
CHANGED
|
@@ -71,37 +71,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
| 71 |
const examples = [
|
| 72 |
"Giảng viên giảng bài rất hay và dễ hiểu, sinh viên rất thích",
|
| 73 |
"Thầy cô rất nhiệt tình và hỗ trợ sinh viên học tập tốt",
|
| 74 |
-
"Giảng viên có kiến thức sâu rộng và phương pháp dạy hiệu quả",
|
| 75 |
-
"Thầy cô rất tận tâm và luôn sẵn sàng giải đáp thắc mắc",
|
| 76 |
-
"Phong cách giảng dạy của giảng viên rất thu hút và dễ hiểu",
|
| 77 |
"Chương trình học rất phù hợp và bổ ích cho sinh viên",
|
| 78 |
-
"Nội dung môn học rất thực tế và có tính ứng dụng cao",
|
| 79 |
-
"Cấu trúc chương trình học rất logic và dễ theo dõi",
|
| 80 |
-
"Môn học này giúp sinh viên phát triển kỹ năng tốt",
|
| 81 |
-
"Chương trình đào tạo rất toàn diện và chất lượng",
|
| 82 |
"Cơ sở vật chất rất hiện đại và đầy đủ tiện nghi",
|
| 83 |
-
"Phòng học rộng rãi, thoáng mát và có đầy đủ thiết bị",
|
| 84 |
-
"Thư viện rất đẹp và có nhiều tài liệu hữu ích",
|
| 85 |
-
"Khuôn viên trường rất đẹp và môi trường học tập tốt",
|
| 86 |
-
"Phòng thực hành được trang bị đầy đủ và hiện đại",
|
| 87 |
"Giảng viên giảng bài quá nhanh và khó hiểu",
|
| 88 |
-
"Thầy cô không nhiệt tình và ít quan tâm đến sinh viên",
|
| 89 |
-
"Phương pháp dạy của giảng viên rất nhàm chán",
|
| 90 |
-
"Giảng viên không sẵn sàng giải đáp thắc mắc của sinh viên",
|
| 91 |
-
"Thầy cô có thái độ không tốt với sinh viên",
|
| 92 |
"Chương trình học quá khó và không phù hợp với sinh viên",
|
| 93 |
-
"Nội dung môn học quá lý thuyết và thiếu thực hành",
|
| 94 |
-
"Cấu trúc chương trình rối rắm và khó theo dõi",
|
| 95 |
-
"Môn học này không có tính ứng dụng thực tế",
|
| 96 |
-
"Chương trình đào tạo quá nặng và áp lực",
|
| 97 |
"Phòng học quá nóng và không có điều hòa, rất khó chịu",
|
| 98 |
-
"
|
| 99 |
-
"Thư viện quá nhỏ và thiếu chỗ ngồi",
|
| 100 |
-
"Khuôn viên trường không được bảo trì tốt",
|
| 101 |
-
"Phòng thực hành thiếu thiết bị và không đảm bảo an toàn",
|
| 102 |
-
"Môn học này có nội dung bình thường, không có gì đặc biệt",
|
| 103 |
-
"Giảng viên dạy ổn, không có gì nổi bật",
|
| 104 |
-
"Chương trình học có thể chấp nhận được"
|
| 105 |
];
|
| 106 |
|
| 107 |
// Utility Functions
|
|
@@ -132,12 +107,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
| 132 |
elements.textarea.value = '';
|
| 133 |
elements.results.style.display = 'none';
|
| 134 |
elements.errorMessage.style.display = 'none';
|
| 135 |
-
|
| 136 |
-
const charCount = document.getElementById('charCount');
|
| 137 |
-
const charCounter = document.querySelector('.form-text');
|
| 138 |
-
|
| 139 |
-
if (charCount) charCount.textContent = '0';
|
| 140 |
-
if (charCounter) charCounter.style.color = '#6c757d';
|
| 141 |
},
|
| 142 |
|
| 143 |
updateResult(type, value) {
|
|
@@ -209,6 +179,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
| 209 |
elements.results.style.display = 'block';
|
| 210 |
elements.results.classList.add('fade-in');
|
| 211 |
utils.scrollToResults();
|
|
|
|
|
|
|
|
|
|
| 212 |
} else {
|
| 213 |
utils.showError(data.error || 'Có lỗi xảy ra khi phân tích feedback!');
|
| 214 |
}
|
|
@@ -239,23 +212,24 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
| 239 |
}
|
| 240 |
});
|
| 241 |
|
| 242 |
-
//
|
| 243 |
-
function createButton(text, icon, className, onClick) {
|
| 244 |
-
const btn = document.createElement('button');
|
| 245 |
-
btn.type = 'button';
|
| 246 |
-
btn.className = className;
|
| 247 |
-
btn.innerHTML = `<i class="fas ${icon} me-2"></i>${text}`;
|
| 248 |
-
btn.onclick = onClick;
|
| 249 |
-
return btn;
|
| 250 |
-
}
|
| 251 |
-
|
| 252 |
-
// Add buttons container
|
| 253 |
const buttonContainer = document.createElement('div');
|
| 254 |
buttonContainer.className = 'd-flex justify-content-center gap-2 mt-3';
|
| 255 |
|
| 256 |
-
|
| 257 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
|
|
|
|
|
|
|
| 259 |
elements.form.appendChild(buttonContainer);
|
| 260 |
|
| 261 |
// Add character counter
|
|
@@ -263,4 +237,142 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
| 263 |
charCounter.className = 'form-text text-muted';
|
| 264 |
charCounter.innerHTML = '<i class="fas fa-info-circle me-1"></i>Độ dài: <span id="charCount">0</span> ký tự';
|
| 265 |
elements.textarea.parentNode.appendChild(charCounter);
|
| 266 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
const examples = [
|
| 72 |
"Giảng viên giảng bài rất hay và dễ hiểu, sinh viên rất thích",
|
| 73 |
"Thầy cô rất nhiệt tình và hỗ trợ sinh viên học tập tốt",
|
|
|
|
|
|
|
|
|
|
| 74 |
"Chương trình học rất phù hợp và bổ ích cho sinh viên",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
"Cơ sở vật chất rất hiện đại và đầy đủ tiện nghi",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
"Giảng viên giảng bài quá nhanh và khó hiểu",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
"Chương trình học quá khó và không phù hợp với sinh viên",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
"Phòng học quá nóng và không có điều hòa, rất khó chịu",
|
| 79 |
+
"Môn học này có nội dung bình thường, không có gì đặc biệt"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
];
|
| 81 |
|
| 82 |
// Utility Functions
|
|
|
|
| 107 |
elements.textarea.value = '';
|
| 108 |
elements.results.style.display = 'none';
|
| 109 |
elements.errorMessage.style.display = 'none';
|
| 110 |
+
this.updateCharCounter();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
},
|
| 112 |
|
| 113 |
updateResult(type, value) {
|
|
|
|
| 179 |
elements.results.style.display = 'block';
|
| 180 |
elements.results.classList.add('fade-in');
|
| 181 |
utils.scrollToResults();
|
| 182 |
+
|
| 183 |
+
// Reload feedback history after successful analysis
|
| 184 |
+
loadFeedbackHistory(1);
|
| 185 |
} else {
|
| 186 |
utils.showError(data.error || 'Có lỗi xảy ra khi phân tích feedback!');
|
| 187 |
}
|
|
|
|
| 212 |
}
|
| 213 |
});
|
| 214 |
|
| 215 |
+
// Add utility buttons
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
const buttonContainer = document.createElement('div');
|
| 217 |
buttonContainer.className = 'd-flex justify-content-center gap-2 mt-3';
|
| 218 |
|
| 219 |
+
const clearBtn = document.createElement('button');
|
| 220 |
+
clearBtn.type = 'button';
|
| 221 |
+
clearBtn.className = 'btn btn-outline-secondary';
|
| 222 |
+
clearBtn.innerHTML = '<i class="fas fa-trash me-2"></i>Xóa Form';
|
| 223 |
+
clearBtn.onclick = () => utils.clearForm();
|
| 224 |
+
|
| 225 |
+
const exampleBtn = document.createElement('button');
|
| 226 |
+
exampleBtn.type = 'button';
|
| 227 |
+
exampleBtn.className = 'btn btn-outline-info';
|
| 228 |
+
exampleBtn.innerHTML = '<i class="fas fa-lightbulb me-2"></i>Ví Dụ';
|
| 229 |
+
exampleBtn.onclick = showExamples;
|
| 230 |
|
| 231 |
+
buttonContainer.appendChild(clearBtn);
|
| 232 |
+
buttonContainer.appendChild(exampleBtn);
|
| 233 |
elements.form.appendChild(buttonContainer);
|
| 234 |
|
| 235 |
// Add character counter
|
|
|
|
| 237 |
charCounter.className = 'form-text text-muted';
|
| 238 |
charCounter.innerHTML = '<i class="fas fa-info-circle me-1"></i>Độ dài: <span id="charCount">0</span> ký tự';
|
| 239 |
elements.textarea.parentNode.appendChild(charCounter);
|
| 240 |
+
|
| 241 |
+
// Load feedback history
|
| 242 |
+
loadFeedbackHistory();
|
| 243 |
+
});
|
| 244 |
+
|
| 245 |
+
// Feedback History Functions
|
| 246 |
+
let currentPage = 1;
|
| 247 |
+
const itemsPerPage = 5;
|
| 248 |
+
|
| 249 |
+
async function loadFeedbackHistory(page = 1) {
|
| 250 |
+
const historyLoading = document.getElementById('historyLoading');
|
| 251 |
+
const historyContent = document.getElementById('historyContent');
|
| 252 |
+
const historyPagination = document.getElementById('historyPagination');
|
| 253 |
+
|
| 254 |
+
historyLoading.style.display = 'block';
|
| 255 |
+
historyContent.innerHTML = '';
|
| 256 |
+
historyPagination.innerHTML = '';
|
| 257 |
+
|
| 258 |
+
try {
|
| 259 |
+
const response = await fetch(`/api/feedback-history?page=${page}&per_page=${itemsPerPage}`);
|
| 260 |
+
const data = await response.json();
|
| 261 |
+
|
| 262 |
+
if (response.ok) {
|
| 263 |
+
displayFeedbackHistory(data.feedbacks);
|
| 264 |
+
displayPagination(data, page);
|
| 265 |
+
} else {
|
| 266 |
+
historyContent.innerHTML = '<p class="text-muted text-center">Không thể tải lịch sử feedback.</p>';
|
| 267 |
+
}
|
| 268 |
+
} catch (error) {
|
| 269 |
+
console.error('Error loading feedback history:', error);
|
| 270 |
+
historyContent.innerHTML = '<p class="text-muted text-center">Có lỗi xảy ra khi tải lịch sử.</p>';
|
| 271 |
+
} finally {
|
| 272 |
+
historyLoading.style.display = 'none';
|
| 273 |
+
}
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
function displayFeedbackHistory(feedbacks) {
|
| 277 |
+
const historyContent = document.getElementById('historyContent');
|
| 278 |
+
|
| 279 |
+
if (feedbacks.length === 0) {
|
| 280 |
+
historyContent.innerHTML = '<p class="text-muted text-center">Chưa có feedback nào. Hãy phân tích feedback đầu tiên!</p>';
|
| 281 |
+
return;
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
let html = '';
|
| 285 |
+
feedbacks.forEach(feedback => {
|
| 286 |
+
const sentimentConfig = getSentimentConfig(feedback.sentiment);
|
| 287 |
+
const topicConfig = getTopicConfig(feedback.topic);
|
| 288 |
+
const date = new Date(feedback.created_at).toLocaleString('vi-VN');
|
| 289 |
+
|
| 290 |
+
html += `
|
| 291 |
+
<div class="card mb-3 border-start border-4 border-${getSentimentColor(feedback.sentiment)}">
|
| 292 |
+
<div class="card-body">
|
| 293 |
+
<div class="row">
|
| 294 |
+
<div class="col-md-8">
|
| 295 |
+
<p class="card-text">${feedback.text}</p>
|
| 296 |
+
<small class="text-muted">
|
| 297 |
+
<i class="fas fa-clock me-1"></i>${date}
|
| 298 |
+
</small>
|
| 299 |
+
</div>
|
| 300 |
+
<div class="col-md-4 text-end">
|
| 301 |
+
<div class="mb-2">
|
| 302 |
+
<span class="badge bg-${getSentimentColor(feedback.sentiment)} me-2">
|
| 303 |
+
<i class="fas ${sentimentConfig.icon} me-1"></i>
|
| 304 |
+
${sentimentConfig.label}
|
| 305 |
+
</span>
|
| 306 |
+
<span class="badge bg-secondary">
|
| 307 |
+
<i class="fas ${topicConfig.icon} me-1"></i>
|
| 308 |
+
${topicConfig.label}
|
| 309 |
+
</span>
|
| 310 |
+
</div>
|
| 311 |
+
<div class="small text-muted">
|
| 312 |
+
<div>Tin cậy: ${(feedback.sentiment_confidence * 100).toFixed(1)}%</div>
|
| 313 |
+
<div>Chủ đề: ${(feedback.topic_confidence * 100).toFixed(1)}%</div>
|
| 314 |
+
</div>
|
| 315 |
+
</div>
|
| 316 |
+
</div>
|
| 317 |
+
</div>
|
| 318 |
+
</div>
|
| 319 |
+
`;
|
| 320 |
+
});
|
| 321 |
+
|
| 322 |
+
historyContent.innerHTML = html;
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
function displayPagination(data, currentPage) {
|
| 326 |
+
const historyPagination = document.getElementById('historyPagination');
|
| 327 |
+
|
| 328 |
+
if (data.pages <= 1) return;
|
| 329 |
+
|
| 330 |
+
let html = '<nav><ul class="pagination pagination-sm">';
|
| 331 |
+
|
| 332 |
+
// Previous button
|
| 333 |
+
if (data.has_prev) {
|
| 334 |
+
html += `<li class="page-item"><a class="page-link" href="#" onclick="loadFeedbackHistory(${currentPage - 1})">Trước</a></li>`;
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
// Page numbers
|
| 338 |
+
for (let i = 1; i <= data.pages; i++) {
|
| 339 |
+
const active = i === currentPage ? 'active' : '';
|
| 340 |
+
html += `<li class="page-item ${active}"><a class="page-link" href="#" onclick="loadFeedbackHistory(${i})">${i}</a></li>`;
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
// Next button
|
| 344 |
+
if (data.has_next) {
|
| 345 |
+
html += `<li class="page-item"><a class="page-link" href="#" onclick="loadFeedbackHistory(${currentPage + 1})">Sau</a></li>`;
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
html += '</ul></nav>';
|
| 349 |
+
historyPagination.innerHTML = html;
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
function getSentimentConfig(sentiment) {
|
| 353 |
+
const configs = {
|
| 354 |
+
positive: { icon: 'fa-smile', label: 'Tích Cực' },
|
| 355 |
+
neutral: { icon: 'fa-meh', label: 'Trung Tính' },
|
| 356 |
+
negative: { icon: 'fa-frown', label: 'Tiêu Cực' }
|
| 357 |
+
};
|
| 358 |
+
return configs[sentiment] || configs.neutral;
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
function getTopicConfig(topic) {
|
| 362 |
+
const configs = {
|
| 363 |
+
lecturer: { icon: 'fa-user-tie', label: 'Giảng Viên' },
|
| 364 |
+
training_program: { icon: 'fa-graduation-cap', label: 'Chương Trình' },
|
| 365 |
+
facility: { icon: 'fa-building', label: 'Cơ Sở Vật Chất' },
|
| 366 |
+
others: { icon: 'fa-ellipsis-h', label: 'Khác' }
|
| 367 |
+
};
|
| 368 |
+
return configs[topic] || configs.others;
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
function getSentimentColor(sentiment) {
|
| 372 |
+
const colors = {
|
| 373 |
+
positive: 'success',
|
| 374 |
+
neutral: 'warning',
|
| 375 |
+
negative: 'danger'
|
| 376 |
+
};
|
| 377 |
+
return colors[sentiment] || 'secondary';
|
| 378 |
+
}
|
templates/index.html
CHANGED
|
@@ -12,15 +12,54 @@
|
|
| 12 |
<div class="container-fluid">
|
| 13 |
<!-- Header -->
|
| 14 |
<header>
|
| 15 |
-
<
|
| 16 |
-
<
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
</header>
|
| 20 |
|
| 21 |
<!-- Main Content -->
|
| 22 |
<div class="row justify-content-center">
|
| 23 |
<div class="col-lg-8">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
<!-- Input Form -->
|
| 25 |
<div class="card shadow-lg">
|
| 26 |
<div class="card-header bg-primary text-white">
|
|
@@ -135,6 +174,30 @@
|
|
| 135 |
<i class="fas fa-exclamation-triangle me-2"></i>
|
| 136 |
<span id="errorText"></span>
|
| 137 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
</div>
|
| 139 |
</div>
|
| 140 |
|
|
@@ -150,5 +213,21 @@
|
|
| 150 |
<!-- Scripts -->
|
| 151 |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
|
| 152 |
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
</body>
|
| 154 |
</html>
|
|
|
|
| 12 |
<div class="container-fluid">
|
| 13 |
<!-- Header -->
|
| 14 |
<header>
|
| 15 |
+
<div class="d-flex justify-content-between align-items-center">
|
| 16 |
+
<h1 class="display-4 fw-bold text-primary mb-0">
|
| 17 |
+
<i class="fas fa-graduation-cap me-1" style="margin-left: 10px !important;"></i>
|
| 18 |
+
Student Feedback Analysis
|
| 19 |
+
</h1>
|
| 20 |
+
<div class="d-flex align-items-center justify-content-end" style="margin-right: 20px; gap: 16px;">
|
| 21 |
+
<span class="text-white fw-semibold d-flex align-items-center">
|
| 22 |
+
<i class="fas fa-user me-2"></i>
|
| 23 |
+
{{ current_user.username }}
|
| 24 |
+
</span>
|
| 25 |
+
<a href="{{ url_for('logout') }}"
|
| 26 |
+
style="background-color: rgba(255,255,255,0.2);
|
| 27 |
+
border: 1px solid rgba(255,255,255,0.3);
|
| 28 |
+
color: white;
|
| 29 |
+
padding: 8px 8px;
|
| 30 |
+
border-radius: 6px;
|
| 31 |
+
text-decoration: none;
|
| 32 |
+
font-weight: 600;
|
| 33 |
+
display: inline-flex;
|
| 34 |
+
align-items: center;
|
| 35 |
+
justify-content: center;
|
| 36 |
+
font-size: 13px;
|
| 37 |
+
transition: all 0.3s ease;
|
| 38 |
+
backdrop-filter: blur(10px);
|
| 39 |
+
gap: 6px;">
|
| 40 |
+
<i class="fas fa-sign-out-alt"></i>
|
| 41 |
+
<span>Đăng xuất</span>
|
| 42 |
+
</a>
|
| 43 |
+
</div>
|
| 44 |
+
</div>
|
| 45 |
</header>
|
| 46 |
|
| 47 |
<!-- Main Content -->
|
| 48 |
<div class="row justify-content-center">
|
| 49 |
<div class="col-lg-8">
|
| 50 |
+
<!-- Flash Messages -->
|
| 51 |
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 52 |
+
{% if messages %}
|
| 53 |
+
{% for category, message in messages %}
|
| 54 |
+
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
|
| 55 |
+
<i class="fas fa-{{ 'exclamation-triangle' if category == 'error' else 'info-circle' }} me-2"></i>
|
| 56 |
+
{{ message }}
|
| 57 |
+
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
| 58 |
+
</div>
|
| 59 |
+
{% endfor %}
|
| 60 |
+
{% endif %}
|
| 61 |
+
{% endwith %}
|
| 62 |
+
|
| 63 |
<!-- Input Form -->
|
| 64 |
<div class="card shadow-lg">
|
| 65 |
<div class="card-header bg-primary text-white">
|
|
|
|
| 174 |
<i class="fas fa-exclamation-triangle me-2"></i>
|
| 175 |
<span id="errorText"></span>
|
| 176 |
</div>
|
| 177 |
+
|
| 178 |
+
<!-- Feedback History -->
|
| 179 |
+
<div class="card shadow-sm mt-4">
|
| 180 |
+
<div class="card-header bg-primary text-white">
|
| 181 |
+
<h4 class="card-title mb-0">
|
| 182 |
+
<i class="fas fa-history me-2"></i>
|
| 183 |
+
Lịch sử Feedback
|
| 184 |
+
</h4>
|
| 185 |
+
</div>
|
| 186 |
+
<div class="card-body">
|
| 187 |
+
<div id="historyLoading" class="text-center py-3" style="display: none;">
|
| 188 |
+
<div class="spinner-border text-primary" role="status">
|
| 189 |
+
<span class="visually-hidden">Loading...</span>
|
| 190 |
+
</div>
|
| 191 |
+
<p class="mt-2 text-muted">Đang tải lịch sử...</p>
|
| 192 |
+
</div>
|
| 193 |
+
<div id="historyContent">
|
| 194 |
+
<!-- History items will be loaded here -->
|
| 195 |
+
</div>
|
| 196 |
+
<div id="historyPagination" class="d-flex justify-content-center mt-3">
|
| 197 |
+
<!-- Pagination will be loaded here -->
|
| 198 |
+
</div>
|
| 199 |
+
</div>
|
| 200 |
+
</div>
|
| 201 |
</div>
|
| 202 |
</div>
|
| 203 |
|
|
|
|
| 213 |
<!-- Scripts -->
|
| 214 |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
|
| 215 |
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
| 216 |
+
<script>
|
| 217 |
+
// Auto-hide alerts after 3 seconds with smooth fade
|
| 218 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 219 |
+
const alerts = document.querySelectorAll('.alert');
|
| 220 |
+
alerts.forEach(function(alert) {
|
| 221 |
+
setTimeout(function() {
|
| 222 |
+
// Add fade-out class for smooth transition
|
| 223 |
+
alert.classList.add('fade-out');
|
| 224 |
+
// Remove from DOM after transition completes
|
| 225 |
+
setTimeout(function() {
|
| 226 |
+
alert.remove();
|
| 227 |
+
}, 500);
|
| 228 |
+
}, 3000);
|
| 229 |
+
});
|
| 230 |
+
});
|
| 231 |
+
</script>
|
| 232 |
</body>
|
| 233 |
</html>
|
templates/login.html
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="vi">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Đăng nhập - Student Feedback Analysis</title>
|
| 7 |
+
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 8 |
+
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
| 9 |
+
<link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
|
| 10 |
+
</head>
|
| 11 |
+
<body>
|
| 12 |
+
<div class="container-fluid">
|
| 13 |
+
<!-- Main Content -->
|
| 14 |
+
<div class="row justify-content-center" style="padding-top: 60px;">
|
| 15 |
+
<div class="col-lg-6 col-md-8">
|
| 16 |
+
<!-- Login Form -->
|
| 17 |
+
<div class="card shadow-sm">
|
| 18 |
+
<div class="card-header bg-primary text-white">
|
| 19 |
+
<h3 class="card-title mb-0">
|
| 20 |
+
<i class="fas fa-sign-in-alt me-2"></i>
|
| 21 |
+
Đăng nhập tài khoản
|
| 22 |
+
</h3>
|
| 23 |
+
</div>
|
| 24 |
+
<div class="card-body">
|
| 25 |
+
<!-- Flash Messages -->
|
| 26 |
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 27 |
+
{% if messages %}
|
| 28 |
+
{% for category, message in messages %}
|
| 29 |
+
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
|
| 30 |
+
<i class="fas fa-{{ 'exclamation-triangle' if category == 'error' else 'info-circle' }} me-2"></i>
|
| 31 |
+
{{ message }}
|
| 32 |
+
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
| 33 |
+
</div>
|
| 34 |
+
{% endfor %}
|
| 35 |
+
{% endif %}
|
| 36 |
+
{% endwith %}
|
| 37 |
+
|
| 38 |
+
<form method="POST">
|
| 39 |
+
{{ form.hidden_tag() }}
|
| 40 |
+
|
| 41 |
+
<div class="mb-3">
|
| 42 |
+
{{ form.username.label(class="form-label fw-bold") }}
|
| 43 |
+
{{ form.username(class="form-control" + (" is-invalid" if form.username.errors else "")) }}
|
| 44 |
+
{% if form.username.errors %}
|
| 45 |
+
<div class="invalid-feedback">
|
| 46 |
+
{% for error in form.username.errors %}
|
| 47 |
+
{{ error }}
|
| 48 |
+
{% endfor %}
|
| 49 |
+
</div>
|
| 50 |
+
{% endif %}
|
| 51 |
+
</div>
|
| 52 |
+
|
| 53 |
+
<div class="mb-4">
|
| 54 |
+
{{ form.password.label(class="form-label fw-bold") }}
|
| 55 |
+
{{ form.password(class="form-control" + (" is-invalid" if form.password.errors else "")) }}
|
| 56 |
+
{% if form.password.errors %}
|
| 57 |
+
<div class="invalid-feedback">
|
| 58 |
+
{% for error in form.password.errors %}
|
| 59 |
+
{{ error }}
|
| 60 |
+
{% endfor %}
|
| 61 |
+
</div>
|
| 62 |
+
{% endif %}
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
<div class="d-grid">
|
| 66 |
+
{{ form.submit(class="btn btn-primary btn-lg") }}
|
| 67 |
+
</div>
|
| 68 |
+
</form>
|
| 69 |
+
|
| 70 |
+
<hr class="my-4">
|
| 71 |
+
|
| 72 |
+
<div class="text-center">
|
| 73 |
+
<p class="text-muted mb-0">
|
| 74 |
+
Chưa có tài khoản?
|
| 75 |
+
<a href="{{ url_for('register') }}" class="text-decoration-none fw-bold text-secondary">
|
| 76 |
+
Đăng ký ngay
|
| 77 |
+
</a>
|
| 78 |
+
</p>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
|
| 83 |
+
<!-- Back to Home -->
|
| 84 |
+
<div class="text-center mt-3">
|
| 85 |
+
<a href="{{ url_for('home') }}" class="btn btn-outline-secondary">
|
| 86 |
+
<i class="fas fa-arrow-left me-2"></i>
|
| 87 |
+
Về trang chủ
|
| 88 |
+
</a>
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
</div>
|
| 93 |
+
|
| 94 |
+
<!-- Scripts -->
|
| 95 |
+
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
|
| 96 |
+
<script>
|
| 97 |
+
// Auto-hide alerts after 3 seconds with smooth fade
|
| 98 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 99 |
+
const alerts = document.querySelectorAll('.alert');
|
| 100 |
+
alerts.forEach(function(alert) {
|
| 101 |
+
setTimeout(function() {
|
| 102 |
+
// Add fade-out class for smooth transition
|
| 103 |
+
alert.classList.add('fade-out');
|
| 104 |
+
// Remove from DOM after transition completes
|
| 105 |
+
setTimeout(function() {
|
| 106 |
+
alert.remove();
|
| 107 |
+
}, 500);
|
| 108 |
+
}, 3000);
|
| 109 |
+
});
|
| 110 |
+
});
|
| 111 |
+
</script>
|
| 112 |
+
</body>
|
| 113 |
+
</html>
|
templates/register.html
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="vi">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Đăng ký - Student Feedback Analysis</title>
|
| 7 |
+
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 8 |
+
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
| 9 |
+
<link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
|
| 10 |
+
</head>
|
| 11 |
+
<body>
|
| 12 |
+
<div class="container-fluid">
|
| 13 |
+
<!-- Main Content -->
|
| 14 |
+
<div class="row justify-content-center" style="padding-top: 60px;">
|
| 15 |
+
<div class="col-lg-6 col-md-8">
|
| 16 |
+
<!-- Registration Form -->
|
| 17 |
+
<div class="card shadow-sm">
|
| 18 |
+
<div class="card-header bg-primary text-white">
|
| 19 |
+
<h3 class="card-title mb-0">
|
| 20 |
+
<i class="fas fa-user-plus me-2"></i>
|
| 21 |
+
Tạo tài khoản mới
|
| 22 |
+
</h3>
|
| 23 |
+
</div>
|
| 24 |
+
<div class="card-body">
|
| 25 |
+
<!-- Flash Messages -->
|
| 26 |
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 27 |
+
{% if messages %}
|
| 28 |
+
{% for category, message in messages %}
|
| 29 |
+
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
|
| 30 |
+
<i class="fas fa-{{ 'exclamation-triangle' if category == 'error' else 'info-circle' }} me-2"></i>
|
| 31 |
+
{{ message }}
|
| 32 |
+
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
| 33 |
+
</div>
|
| 34 |
+
{% endfor %}
|
| 35 |
+
{% endif %}
|
| 36 |
+
{% endwith %}
|
| 37 |
+
|
| 38 |
+
<form method="POST">
|
| 39 |
+
{{ form.hidden_tag() }}
|
| 40 |
+
|
| 41 |
+
<div class="mb-3">
|
| 42 |
+
{{ form.username.label(class="form-label fw-bold") }}
|
| 43 |
+
{{ form.username(class="form-control" + (" is-invalid" if form.username.errors else "")) }}
|
| 44 |
+
{% if form.username.errors %}
|
| 45 |
+
<div class="invalid-feedback">
|
| 46 |
+
{% for error in form.username.errors %}
|
| 47 |
+
{{ error }}
|
| 48 |
+
{% endfor %}
|
| 49 |
+
</div>
|
| 50 |
+
{% endif %}
|
| 51 |
+
</div>
|
| 52 |
+
|
| 53 |
+
<div class="mb-3">
|
| 54 |
+
{{ form.password.label(class="form-label fw-bold") }}
|
| 55 |
+
{{ form.password(class="form-control" + (" is-invalid" if form.password.errors else "")) }}
|
| 56 |
+
{% if form.password.errors %}
|
| 57 |
+
<div class="invalid-feedback">
|
| 58 |
+
{% for error in form.password.errors %}
|
| 59 |
+
{{ error }}
|
| 60 |
+
{% endfor %}
|
| 61 |
+
</div>
|
| 62 |
+
{% endif %}
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
<div class="mb-4">
|
| 66 |
+
{{ form.confirm_password.label(class="form-label fw-bold") }}
|
| 67 |
+
{{ form.confirm_password(class="form-control" + (" is-invalid" if form.confirm_password.errors else "")) }}
|
| 68 |
+
{% if form.confirm_password.errors %}
|
| 69 |
+
<div class="invalid-feedback">
|
| 70 |
+
{% for error in form.confirm_password.errors %}
|
| 71 |
+
{{ error }}
|
| 72 |
+
{% endfor %}
|
| 73 |
+
</div>
|
| 74 |
+
{% endif %}
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
<div class="d-grid">
|
| 78 |
+
{{ form.submit(class="btn btn-primary btn-lg") }}
|
| 79 |
+
</div>
|
| 80 |
+
</form>
|
| 81 |
+
|
| 82 |
+
<hr class="my-4">
|
| 83 |
+
|
| 84 |
+
<div class="text-center">
|
| 85 |
+
<p class="text-muted mb-0">
|
| 86 |
+
Đã có tài khoản?
|
| 87 |
+
<a href="{{ url_for('login') }}" class="text-decoration-none fw-bold text-secondary">
|
| 88 |
+
Đăng nhập ngay
|
| 89 |
+
</a>
|
| 90 |
+
</p>
|
| 91 |
+
</div>
|
| 92 |
+
</div>
|
| 93 |
+
</div>
|
| 94 |
+
|
| 95 |
+
<!-- Back to Home -->
|
| 96 |
+
<div class="text-center mt-3">
|
| 97 |
+
<a href="{{ url_for('home') }}" class="btn btn-outline-secondary">
|
| 98 |
+
<i class="fas fa-arrow-left me-2"></i>
|
| 99 |
+
Về trang chủ
|
| 100 |
+
</a>
|
| 101 |
+
</div>
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
<!-- Scripts -->
|
| 107 |
+
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
|
| 108 |
+
<script>
|
| 109 |
+
// Auto-hide alerts after 3 seconds with smooth fade
|
| 110 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 111 |
+
const alerts = document.querySelectorAll('.alert');
|
| 112 |
+
alerts.forEach(function(alert) {
|
| 113 |
+
setTimeout(function() {
|
| 114 |
+
// Add fade-out class for smooth transition
|
| 115 |
+
alert.classList.add('fade-out');
|
| 116 |
+
// Remove from DOM after transition completes
|
| 117 |
+
setTimeout(function() {
|
| 118 |
+
alert.remove();
|
| 119 |
+
}, 500);
|
| 120 |
+
}, 3000);
|
| 121 |
+
});
|
| 122 |
+
});
|
| 123 |
+
</script>
|
| 124 |
+
</body>
|
| 125 |
+
</html>
|