diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/DaFuWeng.iml b/.idea/DaFuWeng.iml new file mode 100644 index 0000000..d0876a7 --- /dev/null +++ b/.idea/DaFuWeng.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..2a2c1b9 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..a31e05c --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/__pycache__/draw.cpython-312.pyc b/__pycache__/draw.cpython-312.pyc new file mode 100644 index 0000000..f317f4a Binary files /dev/null and b/__pycache__/draw.cpython-312.pyc differ diff --git a/__pycache__/game.cpython-312.pyc b/__pycache__/game.cpython-312.pyc new file mode 100644 index 0000000..d6d1f27 Binary files /dev/null and b/__pycache__/game.cpython-312.pyc differ diff --git a/__pycache__/player.cpython-312.pyc b/__pycache__/player.cpython-312.pyc new file mode 100644 index 0000000..8509f18 Binary files /dev/null and b/__pycache__/player.cpython-312.pyc differ diff --git a/app.py b/app.py new file mode 100644 index 0000000..3ffd94f --- /dev/null +++ b/app.py @@ -0,0 +1,11 @@ +from backend import create_app, db +from backend.models import User, Log, Resource, LogEntry + +app = create_app() + +@app.shell_context_processor +def make_shell_context(): + return {'db': db, 'User': User, 'Log': Log, 'Resource': Resource, 'LogEntry': LogEntry} + +if __name__ == '__main__': + app.run(debug=True) diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..7c75ad5 --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1,79 @@ +import os +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from flask_login import LoginManager +from flask_mail import Mail +from flask_jwt_extended import JWTManager +from .config import Config +from logstash_async.handler import AsynchronousLogstashHandler +from logstash_async.formatter import FlaskLogstashFormatter +import logging +from logging.handlers import RotatingFileHandler + +# 创建扩展实例 +db = SQLAlchemy() +login_manager = LoginManager() +login_manager.login_view = 'auth.login' +login_manager.login_message_category = 'info' +mail = Mail() +jwt = JWTManager() + +def create_app(config_class=Config): + app = Flask(__name__) + app.config.from_object(config_class) + + # 确保上传文件夹存在 + if not os.path.exists(app.config['UPLOAD_FOLDER']): + os.makedirs(app.config['UPLOAD_FOLDER']) + + # 初始化扩展 + db.init_app(app) + login_manager.init_app(app) + mail.init_app(app) + jwt.init_app(app) + + # 配置日志 + configure_logging(app) + + # 注册蓝图 + from backend.main import bp as main_bp + app.register_blueprint(main_bp) + + from backend.auth import bp as auth_bp + app.register_blueprint(auth_bp, url_prefix='/auth') + + from backend.logs import bp as logs_bp + app.register_blueprint(logs_bp, url_prefix='/logs') + + from backend.resources import bp as resources_bp + app.register_blueprint(resources_bp, url_prefix='/resources') + + return app + +def configure_logging(app): + # 设置日志级别 + app.logger.setLevel(logging.INFO) + + # 创建文件日志处理器 + if not os.path.exists('logs'): + os.mkdir('logs') + file_handler = RotatingFileHandler('logs/dafuweng.log', maxBytes=10240, backupCount=10) + file_handler.setFormatter(logging.Formatter( + '%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]' + )) + file_handler.setLevel(logging.INFO) + app.logger.addHandler(file_handler) + + # 添加Logstash日志处理器 + if app.config['LOGSTASH_HOST'] and app.config['LOGSTASH_PORT']: + logstash_handler = AsynchronousLogstashHandler( + host=app.config['LOGSTASH_HOST'], + port=app.config['LOGSTASH_PORT'], + database_path=None, + ) + logstash_formatter = FlaskLogstashFormatter() + logstash_handler.setFormatter(logstash_formatter) + logstash_handler.setLevel(logging.INFO) + app.logger.addHandler(logstash_handler) + + app.logger.info('DaFuWeng application startup') diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..dc9af0c --- /dev/null +++ b/backend/auth.py @@ -0,0 +1,98 @@ +from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify +from flask_login import login_user, logout_user, current_user, login_required +from werkzeug.urls import url_parse +from flask_mail import Message +from flask_jwt_extended import create_access_token, create_refresh_token, jwt_required, get_jwt_identity +from backend import db, mail +from backend.models import User +from backend.forms import LoginForm, RegistrationForm, ResetPasswordRequestForm, ResetPasswordForm +import secrets +import string + +# 创建认证蓝图 +bp = Blueprint('auth', __name__) + +def generate_secure_token(length=32): + """生成安全令牌""" + alphabet = string.ascii_letters + string.digits + return ''.join(secrets.choice(alphabet) for i in range(length)) + +@bp.route('/login', methods=['GET', 'POST']) +def login(): + if current_user.is_authenticated: + return redirect(url_for('main.index')) + form = LoginForm() + if form.validate_on_submit(): + user = User.query.filter_by(email=form.email.data).first() + if user is None or not user.check_password(form.password.data): + flash('无效的邮箱或密码', 'danger') + return redirect(url_for('auth.login')) + login_user(user, remember=form.remember_me.data) + next_page = request.args.get('next') + if not next_page or url_parse(next_page).netloc != '': + next_page = url_for('main.index') + return redirect(next_page) + return render_template('auth/login.html', title='登录', form=form) + +@bp.route('/logout') +def logout(): + logout_user() + return redirect(url_for('main.index')) + +@bp.route('/register', methods=['GET', 'POST']) +def register(): + if current_user.is_authenticated: + return redirect(url_for('main.index')) + form = RegistrationForm() + if form.validate_on_submit(): + user = User(username=form.username.data, email=form.email.data) + user.set_password(form.password.data) + db.session.add(user) + db.session.commit() + flash('恭喜!您已成功注册', 'success') + return redirect(url_for('auth.login')) + return render_template('auth/register.html', title='注册', form=form) + +def send_reset_email(user): + """发送密码重置邮件""" + token = user.get_reset_token() + msg = Message('密码重置请求', + sender='noreply@example.com', + recipients=[user.email]) + msg.body = f'''要重置您的密码,请访问以下链接: + +{url_for('auth.reset_password', token=token, _external=True)} + +如果您没有请求此重置,请忽略此邮件,您的密码将保持不变。 +''' + mail.send(msg) + +@bp.route('/reset_password_request', methods=['GET', 'POST']) +def reset_password_request(): + if current_user.is_authenticated: + return redirect(url_for('main.index')) + form = ResetPasswordRequestForm() + if form.validate_on_submit(): + user = User.query.filter_by(email=form.email.data).first() + if user: + send_reset_email(user) + flash('检查您的邮箱以获取密码重置说明', 'info') + return redirect(url_for('auth.login')) + return render_template('auth/reset_password_request.html', + title='重置密码', form=form) + +@bp.route('/reset_password/', methods=['GET', 'POST']) +def reset_password(token): + if current_user.is_authenticated: + return redirect(url_for('main.index')) + user = User.verify_reset_token(token) + if not user: + flash('无效或过期的令牌', 'warning') + return redirect(url_for('auth.reset_password_request')) + form = ResetPasswordForm() + if form.validate_on_submit(): + user.set_password(form.password.data) + db.session.commit() + flash('您的密码已成功重置', 'success') + return redirect(url_for('auth.login')) + return render_template('auth/reset_password.html', form=form) diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..0c6dab7 --- /dev/null +++ b/backend/config.py @@ -0,0 +1,20 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + +class Config: + SECRET_KEY = os.environ.get('SECRET_KEY') or 'you-will-never-guess' + SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'sqlite:///' + os.path.join(os.path.abspath(os.path.dirname(__file__)), '../app.db') + SQLALCHEMY_TRACK_MODIFICATIONS = False + UPLOAD_FOLDER = os.path.join(os.path.abspath(os.path.dirname(__file__)), '../uploads') + ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'obj', 'fbx', 'mtl'} + MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB + MAIL_SERVER = os.environ.get('MAIL_SERVER') + MAIL_PORT = int(os.environ.get('MAIL_PORT') or 587) + MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS') is not None + MAIL_USERNAME = os.environ.get('MAIL_USERNAME') + MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') + ADMINS = ['your-email@example.com'] + LOGSTASH_HOST = os.environ.get('LOGSTASH_HOST') or 'localhost' + LOGSTASH_PORT = int(os.environ.get('LOGSTASH_PORT') or 5000) diff --git a/backend/forms.py b/backend/forms.py new file mode 100644 index 0000000..8004ef0 --- /dev/null +++ b/backend/forms.py @@ -0,0 +1,51 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField, BooleanField, SubmitField, TextAreaField, SelectField +from wtforms.validators import DataRequired, ValidationError, Email, EqualTo, Length +from backend.models import User + +class LoginForm(FlaskForm): + email = StringField('邮箱', validators=[DataRequired(), Email()]) + password = PasswordField('密码', validators=[DataRequired()]) + remember_me = BooleanField('记住我') + submit = SubmitField('登录') + +class RegistrationForm(FlaskForm): + username = StringField('用户名', validators=[DataRequired(), Length(min=2, max=64)]) + email = StringField('邮箱', validators=[DataRequired(), Email()]) + password = PasswordField('密码', validators=[DataRequired(), Length(min=6)]) + password2 = PasswordField( + '确认密码', validators=[DataRequired(), EqualTo('password')]) + submit = SubmitField('注册') + + def validate_username(self, username): + user = User.query.filter_by(username=username.data).first() + if user is not None: + raise ValidationError('请使用其他用户名') + + def validate_email(self, email): + user = User.query.filter_by(email=email.data).first() + if user is not None: + raise ValidationError('请使用其他邮箱地址') + +class ResetPasswordRequestForm(FlaskForm): + email = StringField('邮箱', validators=[DataRequired(), Email()]) + submit = SubmitField('请求重置密码') + +class ResetPasswordForm(FlaskForm): + password = PasswordField('新密码', validators=[DataRequired(), Length(min=6)]) + password2 = PasswordField( + '确认新密码', validators=[DataRequired(), EqualTo('password')]) + submit = SubmitField('重置密码') + +class LogForm(FlaskForm): + title = StringField('标题', validators=[DataRequired(), Length(max=120)]) + content = TextAreaField('内容', validators=[DataRequired()]) + category = StringField('分类', validators=[Length(max=64)]) + tags = StringField('标签', validators=[Length(max=128)]) + submit = SubmitField('保存') + +class ResourceForm(FlaskForm): + file = StringField('文件', validators=[DataRequired()]) + file_type = SelectField('类型', choices=[('skin', '皮肤'), ('model', '人物模型')], validators=[DataRequired()]) + description = StringField('描述', validators=[Length(max=256)]) + submit = SubmitField('上传') diff --git a/backend/logs.py b/backend/logs.py new file mode 100644 index 0000000..bc79aa1 --- /dev/null +++ b/backend/logs.py @@ -0,0 +1,124 @@ +from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify +from flask_login import current_user, login_required +from backend import db +from backend.models import Log +from backend.forms import LogForm +from datetime import datetime + +# 创建日志蓝图 +bp = Blueprint('logs', __name__) + +@bp.route('/') +@bp.route('/index') +@login_required +def index(): + page = request.args.get('page', 1, type=int) + logs = Log.query.filter_by(user_id=current_user.id).order_by(Log.updated_at.desc()).paginate( + page=page, per_page=10, error_out=False) + next_url = url_for('logs.index', page=logs.next_num) if logs.has_next else None + prev_url = url_for('logs.index', page=logs.prev_num) if logs.has_prev else None + return render_template('logs/index.html', title='我的日志', logs=logs.items, next_url=next_url, prev_url=prev_url) + +@bp.route('/create', methods=['GET', 'POST']) +@login_required +def create(): + form = LogForm() + if form.validate_on_submit(): + log = Log( + title=form.title.data, + content=form.content.data, + category=form.category.data, + tags=form.tags.data, + author=current_user + ) + db.session.add(log) + db.session.commit() + flash('日志已成功创建', 'success') + return redirect(url_for('logs.index')) + return render_template('logs/create.html', title='创建日志', form=form) + +@bp.route('/') +@login_required +def detail(log_id): + log = Log.query.get_or_404(log_id) + if log.author != current_user: + flash('您没有权限访问此日志', 'danger') + return redirect(url_for('logs.index')) + return render_template('logs/detail.html', title=log.title, log=log) + +@bp.route('//edit', methods=['GET', 'POST']) +@login_required +def edit(log_id): + log = Log.query.get_or_404(log_id) + if log.author != current_user: + flash('您没有权限编辑此日志', 'danger') + return redirect(url_for('logs.index')) + form = LogForm(obj=log) + if form.validate_on_submit(): + log.title = form.title.data + log.content = form.content.data + log.category = form.category.data + log.tags = form.tags.data + db.session.commit() + flash('日志已成功更新', 'success') + return redirect(url_for('logs.detail', log_id=log.id)) + return render_template('logs/edit.html', title='编辑日志', form=form, log=log) + +@bp.route('//delete', methods=['POST']) +@login_required +def delete(log_id): + log = Log.query.get_or_404(log_id) + if log.author != current_user: + flash('您没有权限删除此日志', 'danger') + return redirect(url_for('logs.index')) + db.session.delete(log) + db.session.commit() + flash('日志已成功删除', 'success') + return redirect(url_for('logs.index')) + +@bp.route('/search') +@login_required +def search(): + query = request.args.get('q', '') + category = request.args.get('category', '') + start_date = request.args.get('start_date', '') + end_date = request.args.get('end_date', '') + + logs_query = Log.query.filter_by(user_id=current_user.id) + + if query: + logs_query = logs_query.filter(Log.title.ilike(f'%{query}%') | Log.content.ilike(f'%{query}%')) + + if category: + logs_query = logs_query.filter(Log.category == category) + + if start_date: + try: + start = datetime.strptime(start_date, '%Y-%m-%d') + logs_query = logs_query.filter(Log.created_at >= start) + except ValueError: + flash('无效的开始日期格式', 'warning') + + if end_date: + try: + end = datetime.strptime(end_date, '%Y-%m-%d') + logs_query = logs_query.filter(Log.created_at <= end) + except ValueError: + flash('无效的结束日期格式', 'warning') + + page = request.args.get('page', 1, type=int) + logs = logs_query.order_by(Log.updated_at.desc()).paginate( + page=page, per_page=10, error_out=False) + + next_url = url_for('logs.search', q=query, category=category, start_date=start_date, end_date=end_date, page=logs.next_num) if logs.has_next else None + prev_url = url_for('logs.search', q=query, category=category, start_date=start_date, end_date=end_date, page=logs.prev_num) if logs.has_prev else None + + return render_template('logs/search.html', title='搜索日志', logs=logs.items, next_url=next_url, prev_url=prev_url, query=query, category=category, start_date=start_date, end_date=end_date) + +@bp.route('/categories') +@login_required +def categories(): + """获取所有分类""" + categories = Log.query.filter_by(user_id=current_user.id).with_entities(Log.category).distinct().all() + categories_list = [cat[0] for cat in categories if cat[0]] # 过滤掉空分类 + return jsonify(categories_list) diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..f2a799a --- /dev/null +++ b/backend/main.py @@ -0,0 +1,44 @@ +from flask import Blueprint, render_template, jsonify +from flask_login import current_user, login_required +from backend import db +from backend.models import User, Log, Resource + +# 创建主蓝图 +bp = Blueprint('main', __name__) + +@bp.route('/') +def index(): + if current_user.is_authenticated: + return render_template('main/dashboard.html', title='控制台') + return render_template('main/index.html', title='首页') + +@bp.route('/dashboard') +@login_required +def dashboard(): + # 获取用户统计信息 + logs_count = Log.query.filter_by(user_id=current_user.id).count() + resources_count = Resource.query.filter_by(user_id=current_user.id).count() + active_skin = Resource.query.filter_by(user_id=current_user.id, file_type='skin', is_active=True).first() + active_model = Resource.query.filter_by(user_id=current_user.id, file_type='model', is_active=True).first() + + # 获取最近的日志 + recent_logs = Log.query.filter_by(user_id=current_user.id).order_by(Log.created_at.desc()).limit(5).all() + + return render_template('main/dashboard.html', title='控制台', + logs_count=logs_count, + resources_count=resources_count, + active_skin=active_skin, + active_model=active_model, + recent_logs=recent_logs) + +@bp.route('/api/statistics') +@login_required +def api_statistics(): + """API端点:获取用户统计信息""" + logs_count = Log.query.filter_by(user_id=current_user.id).count() + resources_count = Resource.query.filter_by(user_id=current_user.id).count() + + return jsonify({ + 'logs_count': logs_count, + 'resources_count': resources_count + }) diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..bca2e87 --- /dev/null +++ b/backend/models.py @@ -0,0 +1,94 @@ +from datetime import datetime, timedelta +from werkzeug.security import generate_password_hash, check_password_hash +from flask_login import UserMixin +from backend import db, login_manager +import jwt +from time import time + +@login_manager.user_loader +def load_user(user_id): + return User.query.get(int(user_id)) + +class User(UserMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(64), index=True, unique=True) + email = db.Column(db.String(120), index=True, unique=True) + password_hash = db.Column(db.String(128)) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + # 关系定义 + logs = db.relationship('Log', backref='author', lazy='dynamic') + resources = db.relationship('Resource', backref='owner', lazy='dynamic') + + def __repr__(self): + return ''.format(self.username) + + def set_password(self, password): + self.password_hash = generate_password_hash(password) + + def check_password(self, password): + return check_password_hash(self.password_hash, password) + + def get_reset_token(self, expires_in=600): + return jwt.encode( + {'reset_password': self.id, 'exp': time() + expires_in}, + login_manager.app.config['SECRET_KEY'], algorithm='HS256') + + @staticmethod + def verify_reset_token(token): + try: + id = jwt.decode(token, login_manager.app.config['SECRET_KEY'], + algorithms=['HS256'])['reset_password'] + except: + return + return User.query.get(id) + +class Log(db.Model): + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(120)) + content = db.Column(db.Text) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + user_id = db.Column(db.Integer, db.ForeignKey('user.id')) + category = db.Column(db.String(64)) + tags = db.Column(db.String(128)) # 以逗号分隔的标签列表 + + def __repr__(self): + return ''.format(self.title) + +class Resource(db.Model): + id = db.Column(db.Integer, primary_key=True) + filename = db.Column(db.String(128)) + original_filename = db.Column(db.String(128)) + file_type = db.Column(db.String(64)) # 'skin' 或 'model' + description = db.Column(db.String(256)) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + user_id = db.Column(db.Integer, db.ForeignKey('user.id')) + is_active = db.Column(db.Boolean, default=True) + + def __repr__(self): + return ''.format(self.filename) + + def to_dict(self): + return { + 'id': self.id, + 'filename': self.filename, + 'original_filename': self.original_filename, + 'file_type': self.file_type, + 'description': self.description, + 'created_at': self.created_at.isoformat(), + 'is_active': self.is_active + } + +class LogEntry(db.Model): + id = db.Column(db.Integer, primary_key=True) + timestamp = db.Column(db.DateTime, default=datetime.utcnow) + level = db.Column(db.String(64)) + message = db.Column(db.Text) + module = db.Column(db.String(128)) + line_number = db.Column(db.Integer) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True) + ip_address = db.Column(db.String(45)) + + def __repr__(self): + return ''.format(self.message[:50]) diff --git a/backend/resources.py b/backend/resources.py new file mode 100644 index 0000000..913977f --- /dev/null +++ b/backend/resources.py @@ -0,0 +1,174 @@ +import os +import secrets +from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, send_from_directory +from flask_login import current_user, login_required +from werkzeug.utils import secure_filename +from backend import db +from backend.models import Resource +from backend.forms import ResourceForm +from backend.config import Config +from PIL import Image + +# 创建资源管理蓝图 +bp = Blueprint('resources', __name__) + +def allowed_file(filename): + """检查文件扩展名是否被允许""" + return '.' in filename and filename.rsplit('.', 1)[1].lower() in Config.ALLOWED_EXTENSIONS + +def generate_unique_filename(filename): + """生成唯一文件名""" + filename = secure_filename(filename) + name, ext = os.path.splitext(filename) + unique_filename = f"{name}_{secrets.token_hex(8)}{ext}" + return unique_filename + +def save_file(file): + """保存文件到服务器""" + if not file or not allowed_file(file.filename): + raise ValueError('不允许的文件类型') + + filename = generate_unique_filename(file.filename) + file_path = os.path.join(Config.UPLOAD_FOLDER, filename) + + # 保存文件 + file.save(file_path) + + return filename + +@bp.route('/') +@login_required +def index(): + page = request.args.get('page', 1, type=int) + resources = Resource.query.filter_by(user_id=current_user.id).order_by(Resource.created_at.desc()).paginate( + page=page, per_page=10, error_out=False) + next_url = url_for('resources.index', page=resources.next_num) if resources.has_next else None + prev_url = url_for('resources.index', page=resources.prev_num) if resources.has_prev else None + return render_template('resources/index.html', title='我的资源', resources=resources.items, next_url=next_url, prev_url=prev_url) + +@bp.route('/upload', methods=['GET', 'POST']) +@login_required +def upload(): + form = ResourceForm() + if request.method == 'POST': + if 'file' not in request.files: + flash('没有选择文件', 'danger') + return redirect(request.url) + + file = request.files['file'] + if file.filename == '': + flash('没有选择文件', 'danger') + return redirect(request.url) + + try: + filename = save_file(file) + + # 创建资源记录 + resource = Resource( + filename=filename, + original_filename=file.filename, + file_type=request.form['file_type'], + description=request.form.get('description', ''), + owner=current_user + ) + + db.session.add(resource) + db.session.commit() + + flash('文件上传成功', 'success') + return redirect(url_for('resources.index')) + except Exception as e: + flash(f'文件上传失败: {str(e)}', 'danger') + return redirect(request.url) + + return render_template('resources/upload.html', title='上传资源', form=form) + +@bp.route('/') +@login_required +def detail(resource_id): + resource = Resource.query.get_or_404(resource_id) + if resource.owner != current_user: + flash('您没有权限访问此资源', 'danger') + return redirect(url_for('resources.index')) + return render_template('resources/detail.html', title=resource.original_filename, resource=resource) + +@bp.route('//delete', methods=['POST']) +@login_required +def delete(resource_id): + resource = Resource.query.get_or_404(resource_id) + if resource.owner != current_user: + flash('您没有权限删除此资源', 'danger') + return redirect(url_for('resources.index')) + + try: + # 删除文件 + file_path = os.path.join(Config.UPLOAD_FOLDER, resource.filename) + if os.path.exists(file_path): + os.remove(file_path) + + # 删除数据库记录 + db.session.delete(resource) + db.session.commit() + + flash('资源已成功删除', 'success') + except Exception as e: + flash(f'删除资源失败: {str(e)}', 'danger') + + return redirect(url_for('resources.index')) + +@bp.route('//activate', methods=['POST']) +@login_required +def activate(resource_id): + resource = Resource.query.get_or_404(resource_id) + if resource.owner != current_user: + flash('您没有权限操作此资源', 'danger') + return redirect(url_for('resources.index')) + + try: + # 取消所有同类型资源的激活状态 + Resource.query.filter_by(user_id=current_user.id, file_type=resource.file_type, is_active=True).update({'is_active': False}) + + # 激活当前资源 + resource.is_active = True + db.session.commit() + + flash(f'{resource.original_filename}已设为当前使用的{"皮肤" if resource.file_type == "skin" else "人物模型"}', 'success') + except Exception as e: + flash(f'操作失败: {str(e)}', 'danger') + + return redirect(url_for('resources.index')) + +@bp.route('/uploads/') +@login_required +def uploaded_file(filename): + """提供文件下载或预览""" + # 检查文件是否属于当前用户 + resource = Resource.query.filter_by(filename=filename, user_id=current_user.id).first() + if not resource: + flash('您没有权限访问此文件', 'danger') + return redirect(url_for('resources.index')) + + return send_from_directory(Config.UPLOAD_FOLDER, filename) + +@bp.route('/active') +@login_required +def active_resources(): + """获取当前激活的资源""" + active_skin = Resource.query.filter_by(user_id=current_user.id, file_type='skin', is_active=True).first() + active_model = Resource.query.filter_by(user_id=current_user.id, file_type='model', is_active=True).first() + + return jsonify({ + 'skin': active_skin.to_dict() if active_skin else None, + 'model': active_model.to_dict() if active_model else None + }) + +@bp.route('/types/') +@login_required +def resources_by_type(file_type): + """按类型获取资源列表""" + if file_type not in ['skin', 'model']: + flash('无效的资源类型', 'danger') + return redirect(url_for('resources.index')) + + resources = Resource.query.filter_by(user_id=current_user.id, file_type=file_type).order_by(Resource.created_at.desc()).all() + return render_template('resources/type.html', title=f'{"皮肤" if file_type == "skin" else "人物模型"}列表', resources=resources, file_type=file_type) diff --git a/frontend/login_page.py b/frontend/login_page.py new file mode 100644 index 0000000..24cb154 --- /dev/null +++ b/frontend/login_page.py @@ -0,0 +1,260 @@ +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QLineEdit, QPushButton, QMessageBox, QFormLayout, + QSpacerItem, QSizePolicy) +from PyQt5.QtGui import QFont, QIcon +from PyQt5.QtCore import Qt, pyqtSignal +from frontend.main_window import APIClient + +class LoginPage(QWidget): + # 定义登录成功信号 + login_success_signal = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + self.api_client = APIClient() + self.initUI() + + def initUI(self): + # 创建布局 + main_layout = QVBoxLayout() + main_layout.setContentsMargins(50, 50, 50, 50) + main_layout.setSpacing(20) + + # 添加标题 + title_label = QLabel('登录') + title_label.setFont(QFont('Arial', 24, QFont.Bold)) + title_label.setAlignment(Qt.AlignCenter) + main_layout.addWidget(title_label) + + # 添加表单布局 + form_layout = QFormLayout() + form_layout.setSpacing(15) + + # 邮箱输入框 + self.email_edit = QLineEdit() + self.email_edit.setPlaceholderText('请输入邮箱地址') + self.email_edit.setFont(QFont('Arial', 12)) + self.email_edit.setFixedHeight(40) + form_layout.addRow('邮箱:', self.email_edit) + + # 密码输入框 + self.password_edit = QLineEdit() + self.password_edit.setPlaceholderText('请输入密码') + self.password_edit.setEchoMode(QLineEdit.Password) + self.password_edit.setFont(QFont('Arial', 12)) + self.password_edit.setFixedHeight(40) + form_layout.addRow('密码:', self.password_edit) + + main_layout.addLayout(form_layout) + + # 添加登录按钮 + self.login_button = QPushButton('登录') + self.login_button.setFont(QFont('Arial', 14, QFont.Bold)) + self.login_button.setFixedHeight(45) + self.login_button.setStyleSheet('background-color: #4CAF50; color: white; border: none; border-radius: 5px;') + self.login_button.clicked.connect(self.handle_login) + main_layout.addWidget(self.login_button) + + # 添加注册链接 + register_layout = QHBoxLayout() + register_layout.setAlignment(Qt.AlignCenter) + + register_label = QLabel('还没有账号?') + register_label.setFont(QFont('Arial', 12)) + + self.register_link = QPushButton('立即注册') + self.register_link.setFont(QFont('Arial', 12, QFont.Bold)) + self.register_link.setStyleSheet('background-color: transparent; color: #2196F3; border: none;') + self.register_link.clicked.connect(self.handle_register_click) + + register_layout.addWidget(register_label) + register_layout.addWidget(self.register_link) + + main_layout.addLayout(register_layout) + + # 添加忘记密码链接 + forgot_password_layout = QHBoxLayout() + forgot_password_layout.setAlignment(Qt.AlignRight) + + self.forgot_password_link = QPushButton('忘记密码?') + self.forgot_password_link.setFont(QFont('Arial', 12)) + self.forgot_password_link.setStyleSheet('background-color: transparent; color: #757575; border: none;') + self.forgot_password_link.clicked.connect(self.handle_forgot_password) + + forgot_password_layout.addWidget(self.forgot_password_link) + + main_layout.addLayout(forgot_password_layout) + + # 设置布局 + self.setLayout(main_layout) + + def handle_login(self): + email = self.email_edit.text().strip() + password = self.password_edit.text().strip() + + # 基本验证 + if not email or not password: + QMessageBox.warning(self, '输入错误', '请输入邮箱和密码') + return + + if '@' not in email: + QMessageBox.warning(self, '输入错误', '请输入有效的邮箱地址') + return + + # 尝试登录 + if self.api_client.login(email, password): + QMessageBox.information(self, '登录成功', f'欢迎回来,{self.api_client.username}') + # 发送登录成功信号 + self.login_success_signal.emit() + else: + QMessageBox.critical(self, '登录失败', '邮箱或密码错误') + self.password_edit.clear() + + def handle_register_click(self): + # 通知父窗口显示注册页面 + self.parent().show_register_page() + + def handle_forgot_password(self): + # 实现忘记密码功能 + email = self.email_edit.text().strip() + + if not email: + QMessageBox.warning(self, '输入错误', '请输入您的邮箱地址') + return + + if '@' not in email: + QMessageBox.warning(self, '输入错误', '请输入有效的邮箱地址') + return + + # 这里可以添加发送密码重置邮件的逻辑 + QMessageBox.information(self, '密码重置', f'密码重置邮件已发送到 {email}') + +class RegisterPage(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.api_client = APIClient() + self.initUI() + + def initUI(self): + # 创建布局 + main_layout = QVBoxLayout() + main_layout.setContentsMargins(50, 50, 50, 50) + main_layout.setSpacing(20) + + # 添加标题 + title_label = QLabel('注册') + title_label.setFont(QFont('Arial', 24, QFont.Bold)) + title_label.setAlignment(Qt.AlignCenter) + main_layout.addWidget(title_label) + + # 添加表单布局 + form_layout = QFormLayout() + form_layout.setSpacing(15) + + # 用户名输入框 + self.username_edit = QLineEdit() + self.username_edit.setPlaceholderText('请输入用户名') + self.username_edit.setFont(QFont('Arial', 12)) + self.username_edit.setFixedHeight(40) + form_layout.addRow('用户名:', self.username_edit) + + # 邮箱输入框 + self.email_edit = QLineEdit() + self.email_edit.setPlaceholderText('请输入邮箱地址') + self.email_edit.setFont(QFont('Arial', 12)) + self.email_edit.setFixedHeight(40) + form_layout.addRow('邮箱:', self.email_edit) + + # 密码输入框 + self.password_edit = QLineEdit() + self.password_edit.setPlaceholderText('请输入密码(至少6位)') + self.password_edit.setEchoMode(QLineEdit.Password) + self.password_edit.setFont(QFont('Arial', 12)) + self.password_edit.setFixedHeight(40) + form_layout.addRow('密码:', self.password_edit) + + # 确认密码输入框 + self.confirm_password_edit = QLineEdit() + self.confirm_password_edit.setPlaceholderText('请确认密码') + self.confirm_password_edit.setEchoMode(QLineEdit.Password) + self.confirm_password_edit.setFont(QFont('Arial', 12)) + self.confirm_password_edit.setFixedHeight(40) + form_layout.addRow('确认密码:', self.confirm_password_edit) + + main_layout.addLayout(form_layout) + + # 添加注册按钮 + self.register_button = QPushButton('注册') + self.register_button.setFont(QFont('Arial', 14, QFont.Bold)) + self.register_button.setFixedHeight(45) + self.register_button.setStyleSheet('background-color: #4CAF50; color: white; border: none; border-radius: 5px;') + self.register_button.clicked.connect(self.handle_register) + main_layout.addWidget(self.register_button) + + # 添加登录链接 + login_layout = QHBoxLayout() + login_layout.setAlignment(Qt.AlignCenter) + + login_label = QLabel('已有账号?') + login_label.setFont(QFont('Arial', 12)) + + self.login_link = QPushButton('立即登录') + self.login_link.setFont(QFont('Arial', 12, QFont.Bold)) + self.login_link.setStyleSheet('background-color: transparent; color: #2196F3; border: none;') + self.login_link.clicked.connect(self.handle_login_click) + + login_layout.addWidget(login_label) + login_layout.addWidget(self.login_link) + + main_layout.addLayout(login_layout) + + # 设置布局 + self.setLayout(main_layout) + + def handle_register(self): + username = self.username_edit.text().strip() + email = self.email_edit.text().strip() + password = self.password_edit.text().strip() + confirm_password = self.confirm_password_edit.text().strip() + + # 基本验证 + if not username or not email or not password or not confirm_password: + QMessageBox.warning(self, '输入错误', '请填写所有必填字段') + return + + if len(username) < 2 or len(username) > 64: + QMessageBox.warning(self, '输入错误', '用户名长度必须在2到64个字符之间') + return + + if '@' not in email: + QMessageBox.warning(self, '输入错误', '请输入有效的邮箱地址') + return + + if len(password) < 6: + QMessageBox.warning(self, '输入错误', '密码长度不能少于6位') + return + + if password != confirm_password: + QMessageBox.warning(self, '输入错误', '两次输入的密码不一致') + self.confirm_password_edit.clear() + return + + # 尝试注册 + if self.api_client.register(username, email, password): + QMessageBox.information(self, '注册成功', '您已成功注册,请登录') + # 通知父窗口注册成功,切换到登录页面 + self.parent().show_login_page() + # 清空表单 + self.clear_form() + else: + QMessageBox.critical(self, '注册失败', '注册失败,请检查您的输入或网络连接') + + def handle_login_click(self): + # 通知父窗口显示登录页面 + self.parent().show_login_page() + + def clear_form(self): + self.username_edit.clear() + self.email_edit.clear() + self.password_edit.clear() + self.confirm_password_edit.clear() diff --git a/frontend/logs_page.py b/frontend/logs_page.py new file mode 100644 index 0000000..1e0f959 --- /dev/null +++ b/frontend/logs_page.py @@ -0,0 +1,381 @@ +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QLineEdit, QPushButton, QMessageBox, QFormLayout, + QSpacerItem, QSizePolicy, QTableWidget, QTableWidgetItem, + QTextEdit, QDialog, QComboBox, QCalendarWidget, QSplitter, + QHeaderView) +from PyQt5.QtGui import QFont, QIcon +from PyQt5.QtCore import Qt, pyqtSignal, QDate +import requests + +class LogsPage(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.api_client = parent.login_page.api_client + self.initUI() + self.load_logs() + + def initUI(self): + # 创建主布局 + main_layout = QVBoxLayout() + main_layout.setContentsMargins(20, 20, 20, 20) + main_layout.setSpacing(20) + + # 创建顶部布局(标题和操作按钮) + top_layout = QHBoxLayout() + + # 标题 + title_label = QLabel('日志管理') + title_label.setFont(QFont('Arial', 20, QFont.Bold)) + top_layout.addWidget(title_label) + + # 搜索框 + self.search_edit = QLineEdit() + self.search_edit.setPlaceholderText('搜索日志...') + self.search_edit.setFont(QFont('Arial', 12)) + self.search_edit.setFixedWidth(300) + self.search_edit.textChanged.connect(self.load_logs) + top_layout.addWidget(self.search_edit) + + # 占位符 + top_layout.addStretch() + + # 创建日志按钮 + self.create_log_button = QPushButton('创建日志') + self.create_log_button.setFont(QFont('Arial', 12)) + self.create_log_button.setFixedHeight(35) + self.create_log_button.setStyleSheet('background-color: #4CAF50; color: white; border: none; border-radius: 5px;') + self.create_log_button.clicked.connect(self.show_create_log_dialog) + top_layout.addWidget(self.create_log_button) + + main_layout.addLayout(top_layout) + + # 创建日志列表 + self.logs_table = QTableWidget() + self.logs_table.setColumnCount(4) + self.logs_table.setHorizontalHeaderLabels(['标题', '分类', '创建时间', '更新时间']) + self.logs_table.horizontalHeader().setStretchLastSection(True) + self.logs_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + self.logs_table.setSelectionBehavior(QTableWidget.SelectRows) + self.logs_table.setEditTriggers(QTableWidget.NoEditTriggers) + self.logs_table.doubleClicked.connect(self.show_log_detail) + main_layout.addWidget(self.logs_table) + + # 创建底部布局(操作按钮) + bottom_layout = QHBoxLayout() + + # 编辑按钮 + self.edit_button = QPushButton('编辑') + self.edit_button.setFont(QFont('Arial', 12)) + self.edit_button.setFixedHeight(35) + self.edit_button.setStyleSheet('background-color: #2196F3; color: white; border: none; border-radius: 5px;') + self.edit_button.clicked.connect(self.show_edit_log_dialog) + bottom_layout.addWidget(self.edit_button) + + # 删除按钮 + self.delete_button = QPushButton('删除') + self.delete_button.setFont(QFont('Arial', 12)) + self.delete_button.setFixedHeight(35) + self.delete_button.setStyleSheet('background-color: #F44336; color: white; border: none; border-radius: 5px;') + self.delete_button.clicked.connect(self.handle_delete_log) + bottom_layout.addWidget(self.delete_button) + + # 刷新按钮 + self.refresh_button = QPushButton('刷新') + self.refresh_button.setFont(QFont('Arial', 12)) + self.refresh_button.setFixedHeight(35) + self.refresh_button.setStyleSheet('background-color: #9E9E9E; color: white; border: none; border-radius: 5px;') + self.refresh_button.clicked.connect(self.load_logs) + bottom_layout.addWidget(self.refresh_button) + + main_layout.addLayout(bottom_layout) + + # 设置布局 + self.setLayout(main_layout) + + def load_logs(self): + """加载日志列表""" + if not self.api_client.is_authenticated(): + return + + try: + search_query = self.search_edit.text().strip() + url = f'{self.api_client.base_url}/logs' + + if search_query: + url = f'{url}?q={search_query}' + + response = self.api_client.session.get(url) + response.raise_for_status() + + logs = response.json() + + # 更新表格 + self.logs_table.setRowCount(len(logs)) + + for row, log in enumerate(logs): + title_item = QTableWidgetItem(log['title']) + category_item = QTableWidgetItem(log['category'] if log['category'] else '') + created_at_item = QTableWidgetItem(log['created_at'].split('T')[0]) + updated_at_item = QTableWidgetItem(log['updated_at'].split('T')[0]) + + self.logs_table.setItem(row, 0, title_item) + self.logs_table.setItem(row, 1, category_item) + self.logs_table.setItem(row, 2, created_at_item) + self.logs_table.setItem(row, 3, updated_at_item) + + # 存储日志ID + self.logs_table.setVerticalHeaderItem(row, QTableWidgetItem(str(log['id']))) + + except requests.exceptions.RequestException as e: + QMessageBox.warning(self, '加载失败', f'日志加载失败: {e}') + + def show_log_detail(self, index): + """显示日志详情""" + log_id = int(self.logs_table.verticalHeaderItem(index.row()).text()) + + try: + response = self.api_client.session.get(f'{self.api_client.base_url}/logs/{log_id}') + response.raise_for_status() + + log = response.json() + + # 创建详情对话框 + detail_dialog = QDialog(self) + detail_dialog.setWindowTitle(log['title']) + detail_dialog.setGeometry(200, 200, 800, 600) + + layout = QVBoxLayout() + + # 标题 + title_label = QLabel(log['title']) + title_label.setFont(QFont('Arial', 18, QFont.Bold)) + layout.addWidget(title_label) + + # 元数据 + meta_layout = QHBoxLayout() + + category_label = QLabel(f'分类: {log["category"] if log["category"] else "未分类"}') + category_label.setFont(QFont('Arial', 12)) + meta_layout.addWidget(category_label) + + created_at_label = QLabel(f'创建时间: {log["created_at"].split("T")[0]}') + created_at_label.setFont(QFont('Arial', 12)) + meta_layout.addWidget(created_at_label) + + updated_at_label = QLabel(f'更新时间: {log["updated_at"].split("T")[0]}') + updated_at_label.setFont(QFont('Arial', 12)) + meta_layout.addWidget(updated_at_label) + + meta_layout.addStretch() + layout.addLayout(meta_layout) + + # 标签 + if log['tags']: + tags_label = QLabel(f'标签: {log["tags"]}') + tags_label.setFont(QFont('Arial', 12)) + layout.addWidget(tags_label) + + # 内容 + content_label = QLabel('内容:') + content_label.setFont(QFont('Arial', 14, QFont.Bold)) + layout.addWidget(content_label) + + content_text = QTextEdit() + content_text.setPlainText(log['content']) + content_text.setReadOnly(True) + content_text.setFont(QFont('Arial', 12)) + layout.addWidget(content_text) + + # 关闭按钮 + close_button = QPushButton('关闭') + close_button.setFont(QFont('Arial', 12)) + close_button.setFixedHeight(35) + close_button.clicked.connect(detail_dialog.close) + layout.addWidget(close_button) + + detail_dialog.setLayout(layout) + detail_dialog.exec_() + + except requests.exceptions.RequestException as e: + QMessageBox.warning(self, '加载失败', f'日志详情加载失败: {e}') + + def show_create_log_dialog(self): + """显示创建日志对话框""" + dialog = LogFormDialog(self, self.api_client, mode='create') + if dialog.exec_(): + self.load_logs() + + def show_edit_log_dialog(self): + """显示编辑日志对话框""" + selected_row = self.logs_table.currentRow() + if selected_row == -1: + QMessageBox.warning(self, '操作提示', '请先选择一条日志') + return + + log_id = int(self.logs_table.verticalHeaderItem(selected_row).text()) + + dialog = LogFormDialog(self, self.api_client, mode='edit', log_id=log_id) + if dialog.exec_(): + self.load_logs() + + def handle_delete_log(self): + """处理删除日志""" + selected_row = self.logs_table.currentRow() + if selected_row == -1: + QMessageBox.warning(self, '操作提示', '请先选择一条日志') + return + + log_id = int(self.logs_table.verticalHeaderItem(selected_row).text()) + log_title = self.logs_table.item(selected_row, 0).text() + + reply = QMessageBox.question(self, '确认删除', f'确定要删除日志"{log_title}"吗?', + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + + if reply == QMessageBox.Yes: + try: + response = self.api_client.session.delete(f'{self.api_client.base_url}/logs/{log_id}') + response.raise_for_status() + + QMessageBox.information(self, '删除成功', '日志已成功删除') + self.load_logs() + + except requests.exceptions.RequestException as e: + QMessageBox.warning(self, '删除失败', f'日志删除失败: {e}') + +class LogFormDialog(QDialog): + def __init__(self, parent=None, api_client=None, mode='create', log_id=None): + super().__init__(parent) + self.api_client = api_client + self.mode = mode + self.log_id = log_id + + if mode == 'create': + self.setWindowTitle('创建日志') + else: + self.setWindowTitle('编辑日志') + self.load_log_data() + + self.initUI() + + def initUI(self): + # 创建布局 + main_layout = QVBoxLayout() + main_layout.setContentsMargins(30, 30, 30, 30) + main_layout.setSpacing(20) + + # 创建表单布局 + form_layout = QFormLayout() + form_layout.setSpacing(15) + + # 标题输入框 + self.title_edit = QLineEdit() + self.title_edit.setPlaceholderText('请输入日志标题') + self.title_edit.setFont(QFont('Arial', 12)) + self.title_edit.setFixedHeight(40) + form_layout.addRow('标题:', self.title_edit) + + # 分类输入框 + self.category_edit = QLineEdit() + self.category_edit.setPlaceholderText('请输入日志分类') + self.category_edit.setFont(QFont('Arial', 12)) + self.category_edit.setFixedHeight(40) + form_layout.addRow('分类:', self.category_edit) + + # 标签输入框 + self.tags_edit = QLineEdit() + self.tags_edit.setPlaceholderText('请输入标签,以逗号分隔') + self.tags_edit.setFont(QFont('Arial', 12)) + self.tags_edit.setFixedHeight(40) + form_layout.addRow('标签:', self.tags_edit) + + # 内容输入框 + form_layout.addRow('内容:', self.create_content_editor()) + + main_layout.addLayout(form_layout) + + # 创建按钮布局 + button_layout = QHBoxLayout() + + # 保存按钮 + self.save_button = QPushButton('保存') + self.save_button.setFont(QFont('Arial', 12)) + self.save_button.setFixedHeight(35) + self.save_button.setStyleSheet('background-color: #4CAF50; color: white; border: none; border-radius: 5px;') + self.save_button.clicked.connect(self.handle_save) + button_layout.addWidget(self.save_button) + + # 取消按钮 + cancel_button = QPushButton('取消') + cancel_button.setFont(QFont('Arial', 12)) + cancel_button.setFixedHeight(35) + cancel_button.setStyleSheet('background-color: #9E9E9E; color: white; border: none; border-radius: 5px;') + cancel_button.clicked.connect(self.close) + button_layout.addWidget(cancel_button) + + main_layout.addLayout(button_layout) + + # 设置布局 + self.setLayout(main_layout) + + def create_content_editor(self): + """创建内容编辑器""" + self.content_edit = QTextEdit() + self.content_edit.setPlaceholderText('请输入日志内容') + self.content_edit.setFont(QFont('Arial', 12)) + self.content_edit.setMinimumHeight(300) + return self.content_edit + + def load_log_data(self): + """加载日志数据(编辑模式下)""" + try: + response = self.api_client.session.get(f'{self.api_client.base_url}/logs/{self.log_id}') + response.raise_for_status() + + log = response.json() + + self.title_edit.setText(log['title']) + self.category_edit.setText(log['category'] if log['category'] else '') + self.tags_edit.setText(log['tags'] if log['tags'] else '') + self.content_edit.setPlainText(log['content']) + + except requests.exceptions.RequestException as e: + QMessageBox.warning(self, '加载失败', f'日志数据加载失败: {e}') + self.reject() + + def handle_save(self): + """处理保存日志""" + title = self.title_edit.text().strip() + content = self.content_edit.toPlainText().strip() + category = self.category_edit.text().strip() + tags = self.tags_edit.text().strip() + + # 基本验证 + if not title or not content: + QMessageBox.warning(self, '输入错误', '请填写标题和内容') + return + + if len(title) > 120: + QMessageBox.warning(self, '输入错误', '标题长度不能超过120个字符') + return + + # 准备数据 + data = { + 'title': title, + 'content': content, + 'category': category, + 'tags': tags + } + + try: + if self.mode == 'create': + response = self.api_client.session.post(f'{self.api_client.base_url}/logs/create', json=data) + else: + response = self.api_client.session.put(f'{self.api_client.base_url}/logs/{self.log_id}/edit', json=data) + + response.raise_for_status() + + QMessageBox.information(self, '保存成功', '日志已成功保存') + self.accept() + + except requests.exceptions.RequestException as e: + QMessageBox.warning(self, '保存失败', f'日志保存失败: {e}') diff --git a/frontend/main_window.py b/frontend/main_window.py new file mode 100644 index 0000000..6213d23 --- /dev/null +++ b/frontend/main_window.py @@ -0,0 +1,417 @@ +import sys +from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, + QHBoxLayout, QStackedWidget, QMenuBar, QMenu, + QAction, QToolBar, QStatusBar, QLabel, QPushButton, + QMessageBox) +from PyQt5.QtGui import QIcon, QFont +from PyQt5.QtCore import Qt, QThread, pyqtSignal +import requests +from frontend.login_page import LoginPage, RegisterPage +from frontend.logs_page import LogsPage +from frontend.resources_page import ResourcesPage + +class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + self.initUI() + + # 初始化API客户端 + self.api_client = APIClient() + + # 检查登录状态 + self.check_login_status() + + def initUI(self): + # 设置窗口基本属性 + self.setWindowTitle('大富翁——清华之旅') + self.setGeometry(100, 100, 1080, 680) + self.setMinimumSize(800, 500) + + # 创建菜单栏 + self.create_menu_bar() + + # 创建工具栏 + self.create_tool_bar() + + # 创建状态栏 + self.create_status_bar() + + # 创建中央部件和布局 + self.central_widget = QStackedWidget() + self.setCentralWidget(self.central_widget) + + # 创建各种页面 + self.create_pages() + + # 连接登录成功信号 + self.login_page.login_success_signal.connect(self.on_login_success) + + # 显示初始页面 + self.show_login_page() + + def create_menu_bar(self): + menubar = self.menuBar() + + # 文件菜单 + file_menu = menubar.addMenu('文件') + + exit_action = QAction(QIcon('exit.png'), '退出', self) + exit_action.setShortcut('Ctrl+Q') + exit_action.setStatusTip('退出应用') + exit_action.triggered.connect(self.close) + file_menu.addAction(exit_action) + + # 编辑菜单 + edit_menu = menubar.addMenu('编辑') + + # 视图菜单 + view_menu = menubar.addMenu('视图') + + # 帮助菜单 + help_menu = menubar.addMenu('帮助') + + def create_tool_bar(self): + toolbar = self.addToolBar('工具') + + # 添加工具按钮 + login_action = QAction(QIcon('login.png'), '登录', self) + login_action.setStatusTip('登录') + login_action.triggered.connect(self.show_login_page) + toolbar.addAction(login_action) + + register_action = QAction(QIcon('register.png'), '注册', self) + register_action.setStatusTip('注册') + register_action.triggered.connect(self.show_register_page) + toolbar.addAction(register_action) + + dashboard_action = QAction(QIcon('dashboard.png'), '控制台', self) + dashboard_action.setStatusTip('控制台') + dashboard_action.triggered.connect(self.show_dashboard_page) + toolbar.addAction(dashboard_action) + + logs_action = QAction(QIcon('logs.png'), '日志', self) + logs_action.setStatusTip('日志管理') + logs_action.triggered.connect(self.show_logs_page) + toolbar.addAction(logs_action) + + resources_action = QAction(QIcon('resources.png'), '资源', self) + resources_action.setStatusTip('资源管理') + resources_action.triggered.connect(self.show_resources_page) + toolbar.addAction(resources_action) + + settings_action = QAction(QIcon('settings.png'), '设置', self) + settings_action.setStatusTip('设置') + settings_action.triggered.connect(self.show_settings_page) + toolbar.addAction(settings_action) + + def create_status_bar(self): + self.status_bar = QStatusBar() + self.setStatusBar(self.status_bar) + self.status_bar.showMessage('欢迎使用大富翁——清华之旅') + + def create_pages(self): + # 登录页面 + self.login_page = LoginPage(self) + self.central_widget.addWidget(self.login_page) + + # 注册页面 + self.register_page = RegisterPage(self) + self.central_widget.addWidget(self.register_page) + + # 控制台页面 + self.dashboard_page = QWidget() + self.central_widget.addWidget(self.dashboard_page) + + # 日志页面 + self.logs_page = LogsPage(self) + self.central_widget.addWidget(self.logs_page) + + # 资源页面 + self.resources_page = ResourcesPage(self) + self.central_widget.addWidget(self.resources_page) + + # 设置页面 + self.settings_page = QWidget() + self.central_widget.addWidget(self.settings_page) + + # 初始化页面内容 + self.init_dashboard_page() + + def show_login_page(self): + self.central_widget.setCurrentWidget(self.login_page) + self.status_bar.showMessage('登录') + + def show_register_page(self): + self.central_widget.setCurrentWidget(self.register_page) + self.status_bar.showMessage('注册') + + def on_login_success(self): + """处理登录成功事件""" + self.status_bar.showMessage(f'欢迎回来,{self.login_page.api_client.username}') + self.show_dashboard_page() + + def login_success(self): + """供LoginPage调用的登录成功方法""" + # 这里可以添加更多登录成功后的处理 + self.on_login_success() + + def init_dashboard_page(self): + """初始化控制台页面""" + layout = QVBoxLayout() + layout.setContentsMargins(20, 20, 20, 20) + + # 添加标题 + title_label = QLabel('控制台') + title_label.setFont(QFont('Arial', 20, QFont.Bold)) + title_label.setMargin(10) + layout.addWidget(title_label) + + # 添加一些示例信息 + info_layout = QHBoxLayout() + info_layout.setSpacing(20) + + # 日志统计 + logs_widget = QWidget() + logs_layout = QVBoxLayout(logs_widget) + logs_widget.setStyleSheet('background-color: #E3F2FD; border-radius: 10px; padding: 15px;') + + logs_title = QLabel('我的日志') + logs_title.setFont(QFont('Arial', 14, QFont.Bold)) + logs_layout.addWidget(logs_title) + + self.logs_count_label = QLabel('0') + self.logs_count_label.setFont(QFont('Arial', 24, QFont.Bold)) + self.logs_count_label.setAlignment(Qt.AlignCenter) + logs_layout.addWidget(self.logs_count_label) + + info_layout.addWidget(logs_widget) + + # 资源统计 + resources_widget = QWidget() + resources_layout = QVBoxLayout(resources_widget) + resources_widget.setStyleSheet('background-color: #C8E6C9; border-radius: 10px; padding: 15px;') + + resources_title = QLabel('我的资源') + resources_title.setFont(QFont('Arial', 14, QFont.Bold)) + resources_layout.addWidget(resources_title) + + self.resources_count_label = QLabel('0') + self.resources_count_label.setFont(QFont('Arial', 24, QFont.Bold)) + self.resources_count_label.setAlignment(Qt.AlignCenter) + resources_layout.addWidget(self.resources_count_label) + + info_layout.addWidget(resources_widget) + + layout.addLayout(info_layout) + + # 添加刷新按钮 + refresh_button = QPushButton('刷新数据') + refresh_button.setFont(QFont('Arial', 12)) + refresh_button.setFixedHeight(35) + refresh_button.clicked.connect(self.refresh_dashboard_data) + layout.addWidget(refresh_button) + + # 添加占位符 + layout.addStretch() + + self.dashboard_page.setLayout(layout) + + def refresh_dashboard_data(self): + """刷新控制台数据""" + if not self.login_page.api_client.is_authenticated(): + return + + try: + # 从API获取统计数据 + response = self.login_page.api_client.session.get(f'{self.login_page.api_client.base_url}/api/statistics') + response.raise_for_status() + + data = response.json() + self.logs_count_label.setText(str(data['logs_count'])) + self.resources_count_label.setText(str(data['resources_count'])) + + self.status_bar.showMessage('数据刷新成功') + except requests.exceptions.RequestException as e: + QMessageBox.warning(self, '刷新失败', f'数据刷新失败: {e}') + + def show_dashboard_page(self): + if not self.api_client.is_authenticated(): + self.status_bar.showMessage('请先登录') + self.show_login_page() + return + self.central_widget.setCurrentWidget(self.dashboard_page) + self.status_bar.showMessage('控制台') + + def show_logs_page(self): + if not self.api_client.is_authenticated(): + self.status_bar.showMessage('请先登录') + self.show_login_page() + return + self.central_widget.setCurrentWidget(self.logs_page) + self.status_bar.showMessage('日志管理') + + def show_resources_page(self): + if not self.api_client.is_authenticated(): + self.status_bar.showMessage('请先登录') + self.show_login_page() + return + self.central_widget.setCurrentWidget(self.resources_page) + self.status_bar.showMessage('资源管理') + + def show_settings_page(self): + self.central_widget.setCurrentWidget(self.settings_page) + self.status_bar.showMessage('设置') + + def check_login_status(self): + # 检查本地是否有保存的登录信息 + if self.api_client.load_session(): + self.status_bar.showMessage(f'欢迎回来,{self.api_client.username}') + self.show_dashboard_page() + else: + self.show_login_page() + +class APIClient: + def __init__(self): + self.base_url = 'http://localhost:5000' + self.session = requests.Session() + self.username = None + self.user_id = None + self.access_token = None + self.refresh_token = None + # 添加用户代理 + self.session.headers.update({ + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' + }) + + def login(self, email, password): + try: + response = self.session.post(f'{self.base_url}/auth/login', json={ + 'email': email, + 'password': password + }) + response.raise_for_status() + + # 登录成功,保存会话信息 + data = response.json() + self.username = data.get('username') + self.user_id = data.get('user_id') + self.access_token = data.get('access_token') + self.refresh_token = data.get('refresh_token') + + # 设置Authorization头 + self.session.headers.update({ + 'Authorization': f'Bearer {self.access_token}' + }) + + self.save_session() + return True + except requests.exceptions.RequestException as e: + print(f'登录失败: {e}') + return False + + def register(self, username, email, password): + try: + response = self.session.post(f'{self.base_url}/auth/register', json={ + 'username': username, + 'email': email, + 'password': password, + 'password2': password + }) + response.raise_for_status() + return True + except requests.exceptions.RequestException as e: + print(f'注册失败: {e}') + return False + + def logout(self): + try: + response = self.session.get(f'{self.base_url}/auth/logout') + response.raise_for_status() + except requests.exceptions.RequestException as e: + print(f'登出失败: {e}') + finally: + self.clear_session() + + def refresh_access_token(self): + """刷新访问令牌""" + if not self.refresh_token: + return False + + try: + response = self.session.post(f'{self.base_url}/auth/refresh', json={ + 'refresh_token': self.refresh_token + }) + response.raise_for_status() + + data = response.json() + self.access_token = data.get('access_token') + + # 更新Authorization头 + self.session.headers.update({ + 'Authorization': f'Bearer {self.access_token}' + }) + + self.save_session() + return True + except requests.exceptions.RequestException as e: + print(f'刷新令牌失败: {e}') + self.clear_session() + return False + + def is_authenticated(self): + return self.access_token is not None + + def save_session(self): + # 保存会话信息到本地文件 + with open('session.json', 'w') as f: + import json + session_data = { + 'username': self.username, + 'user_id': self.user_id, + 'access_token': self.access_token, + 'refresh_token': self.refresh_token + } + json.dump(session_data, f) + + def load_session(self): + try: + with open('session.json', 'r') as f: + import json + session_data = json.load(f) + self.username = session_data.get('username') + self.user_id = session_data.get('user_id') + self.access_token = session_data.get('access_token') + self.refresh_token = session_data.get('refresh_token') + + # 设置Authorization头 + if self.access_token: + self.session.headers.update({ + 'Authorization': f'Bearer {self.access_token}' + }) + + return self.is_authenticated() + except (FileNotFoundError, json.JSONDecodeError): + pass + return False + + def clear_session(self): + self.username = None + self.user_id = None + self.access_token = None + self.refresh_token = None + + # 移除Authorization头 + if 'Authorization' in self.session.headers: + del self.session.headers['Authorization'] + + try: + import os + os.remove('session.json') + except: + pass + +if __name__ == '__main__': + app = QApplication(sys.argv) + window = MainWindow() + window.show() + sys.exit(app.exec_()) diff --git a/frontend/resources_page.py b/frontend/resources_page.py new file mode 100644 index 0000000..91e03c2 --- /dev/null +++ b/frontend/resources_page.py @@ -0,0 +1,549 @@ +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QLineEdit, QPushButton, QMessageBox, QFormLayout, + QSpacerItem, QSizePolicy, QTableWidget, QTableWidgetItem, + QFileDialog, QDialog, QComboBox, QCalendarWidget, QSplitter, + QHeaderView, QTextEdit, QProgressBar, QFrame) +from PyQt5.QtGui import QFont, QIcon, QPixmap, QImageReader +from PyQt5.QtCore import Qt, pyqtSignal, QDate, QThread, pyqtSlot, QFileInfo +import requests +import os +import math + +class ResourcesPage(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.api_client = parent.login_page.api_client + self.initUI() + self.load_resources() + + def initUI(self): + # 创建主布局 + main_layout = QVBoxLayout() + main_layout.setContentsMargins(20, 20, 20, 20) + main_layout.setSpacing(20) + + # 创建顶部布局(标题和操作按钮) + top_layout = QHBoxLayout() + + # 标题 + title_label = QLabel('资源管理') + title_label.setFont(QFont('Arial', 20, QFont.Bold)) + top_layout.addWidget(title_label) + + # 搜索框 + self.search_edit = QLineEdit() + self.search_edit.setPlaceholderText('搜索资源...') + self.search_edit.setFont(QFont('Arial', 12)) + self.search_edit.setFixedWidth(300) + self.search_edit.textChanged.connect(self.load_resources) + top_layout.addWidget(self.search_edit) + + # 占位符 + top_layout.addStretch() + + # 上传资源按钮 + self.upload_resource_button = QPushButton('上传资源') + self.upload_resource_button.setFont(QFont('Arial', 12)) + self.upload_resource_button.setFixedHeight(35) + self.upload_resource_button.setStyleSheet('background-color: #4CAF50; color: white; border: none; border-radius: 5px;') + self.upload_resource_button.clicked.connect(self.show_upload_dialog) + top_layout.addWidget(self.upload_resource_button) + + main_layout.addLayout(top_layout) + + # 创建资源列表 + self.resources_table = QTableWidget() + self.resources_table.setColumnCount(5) + self.resources_table.setHorizontalHeaderLabels(['名称', '类型', '大小', '上传时间', '状态']) + self.resources_table.horizontalHeader().setStretchLastSection(True) + self.resources_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + self.resources_table.setSelectionBehavior(QTableWidget.SelectRows) + self.resources_table.setEditTriggers(QTableWidget.NoEditTriggers) + self.resources_table.doubleClicked.connect(self.show_resource_preview) + main_layout.addWidget(self.resources_table) + + # 创建底部布局(操作按钮) + bottom_layout = QHBoxLayout() + + # 激活按钮 + self.activate_button = QPushButton('激活') + self.activate_button.setFont(QFont('Arial', 12)) + self.activate_button.setFixedHeight(35) + self.activate_button.setStyleSheet('background-color: #2196F3; color: white; border: none; border-radius: 5px;') + self.activate_button.clicked.connect(self.handle_activate_resource) + bottom_layout.addWidget(self.activate_button) + + # 删除按钮 + self.delete_button = QPushButton('删除') + self.delete_button.setFont(QFont('Arial', 12)) + self.delete_button.setFixedHeight(35) + self.delete_button.setStyleSheet('background-color: #F44336; color: white; border: none; border-radius: 5px;') + self.delete_button.clicked.connect(self.handle_delete_resource) + bottom_layout.addWidget(self.delete_button) + + # 刷新按钮 + self.refresh_button = QPushButton('刷新') + self.refresh_button.setFont(QFont('Arial', 12)) + self.refresh_button.setFixedHeight(35) + self.refresh_button.setStyleSheet('background-color: #9E9E9E; color: white; border: none; border-radius: 5px;') + self.refresh_button.clicked.connect(self.load_resources) + bottom_layout.addWidget(self.refresh_button) + + main_layout.addLayout(bottom_layout) + + # 设置布局 + self.setLayout(main_layout) + + def load_resources(self): + """加载资源列表""" + if not self.api_client.is_authenticated(): + return + + try: + search_query = self.search_edit.text().strip() + url = f'{self.api_client.base_url}/resources' + + if search_query: + url = f'{url}?q={search_query}' + + response = self.api_client.session.get(url) + response.raise_for_status() + + resources = response.json() + + # 更新表格 + self.resources_table.setRowCount(len(resources)) + + for row, resource in enumerate(resources): + name_item = QTableWidgetItem(resource['name']) + type_item = QTableWidgetItem(resource['type']) + size_item = QTableWidgetItem(self.format_file_size(resource['size'])) + upload_time_item = QTableWidgetItem(resource['upload_time'].split('T')[0]) + + # 状态显示 + status_item = QTableWidgetItem() + if resource['is_active']: + status_item.setText('已激活') + status_item.setForeground(Qt.green) + else: + status_item.setText('未激活') + status_item.setForeground(Qt.gray) + + self.resources_table.setItem(row, 0, name_item) + self.resources_table.setItem(row, 1, type_item) + self.resources_table.setItem(row, 2, size_item) + self.resources_table.setItem(row, 3, upload_time_item) + self.resources_table.setItem(row, 4, status_item) + + # 存储资源ID + self.resources_table.setVerticalHeaderItem(row, QTableWidgetItem(str(resource['id']))) + + except requests.exceptions.RequestException as e: + QMessageBox.warning(self, '加载失败', f'资源加载失败: {e}') + + def format_file_size(self, size_bytes): + """格式化文件大小为人类可读格式""" + if size_bytes == 0: + return "0 Bytes" + + size_names = ["Bytes", "KB", "MB", "GB", "TB"] + i = int(math.floor(math.log(size_bytes, 1024))) + p = math.pow(1024, i) + s = round(size_bytes / p, 2) + return f"{s} {size_names[i]}" + + def show_resource_preview(self, index): + """显示资源预览""" + resource_id = int(self.resources_table.verticalHeaderItem(index.row()).text()) + resource_name = self.resources_table.item(index.row(), 0).text() + resource_type = self.resources_table.item(index.row(), 1).text() + + try: + # 获取资源内容 + response = self.api_client.session.get(f'{self.api_client.base_url}/resources/{resource_id}/download', stream=True) + response.raise_for_status() + + # 创建临时文件 + import tempfile + temp_dir = tempfile.gettempdir() + temp_file_path = os.path.join(temp_dir, resource_name) + + # 保存文件到临时目录 + with open(temp_file_path, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + + # 创建预览对话框 + preview_dialog = QDialog(self) + preview_dialog.setWindowTitle(f'预览: {resource_name}') + preview_dialog.setGeometry(200, 200, 800, 600) + + layout = QVBoxLayout() + + # 判断文件类型并显示相应的预览 + if resource_type.startswith('image/'): + # 图片预览 + pixmap = QPixmap(temp_file_path) + if not pixmap.isNull(): + image_label = QLabel() + image_label.setPixmap(pixmap.scaled(800, 600, Qt.KeepAspectRatio, Qt.SmoothTransformation)) + image_label.setAlignment(Qt.AlignCenter) + layout.addWidget(image_label) + else: + layout.addWidget(QLabel('无法预览该图片')) + elif resource_type in ['application/pdf']: + # PDF预览(需要PyQt5.QtWebEngineWidgets) + try: + from PyQt5.QtWebEngineWidgets import QWebEngineView + web_view = QWebEngineView() + web_view.load(QUrl.fromLocalFile(temp_file_path)) + layout.addWidget(web_view) + except ImportError: + layout.addWidget(QLabel('无法预览PDF文件,请安装PyQt5.QtWebEngineWidgets')) + else: + # 其他类型文件显示信息 + info_layout = QFormLayout() + info_layout.addRow('文件名:', QLabel(resource_name)) + info_layout.addRow('文件类型:', QLabel(resource_type)) + info_layout.addRow('文件大小:', self.resources_table.item(index.row(), 2)) + info_layout.addRow('上传时间:', self.resources_table.item(index.row(), 3)) + layout.addLayout(info_layout) + + # 关闭按钮 + close_button = QPushButton('关闭') + close_button.setFont(QFont('Arial', 12)) + close_button.setFixedHeight(35) + close_button.clicked.connect(lambda: self.handle_close_preview(preview_dialog, temp_file_path)) + layout.addWidget(close_button) + + preview_dialog.setLayout(layout) + preview_dialog.exec_() + + except requests.exceptions.RequestException as e: + QMessageBox.warning(self, '预览失败', f'资源预览失败: {e}') + + def handle_close_preview(self, dialog, temp_file_path): + """处理关闭预览并删除临时文件""" + dialog.close() + try: + os.remove(temp_file_path) + except OSError: + pass + + def show_upload_dialog(self): + """显示上传资源对话框""" + dialog = ResourceUploadDialog(self, self.api_client) + if dialog.exec_(): + self.load_resources() + + def handle_activate_resource(self): + """处理激活资源""" + selected_row = self.resources_table.currentRow() + if selected_row == -1: + QMessageBox.warning(self, '操作提示', '请先选择一个资源') + return + + resource_id = int(self.resources_table.verticalHeaderItem(selected_row).text()) + resource_name = self.resources_table.item(selected_row, 0).text() + current_status = self.resources_table.item(selected_row, 4).text() + + # 如果资源已经激活,则提示用户 + if current_status == '已激活': + QMessageBox.information(self, '操作提示', f'资源"{resource_name}"已经是激活状态') + return + + reply = QMessageBox.question(self, '确认激活', f'确定要激活资源"{resource_name}"吗?', + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + + if reply == QMessageBox.Yes: + try: + response = self.api_client.session.post(f'{self.api_client.base_url}/resources/{resource_id}/activate') + response.raise_for_status() + + QMessageBox.information(self, '激活成功', '资源已成功激活') + self.load_resources() + + except requests.exceptions.RequestException as e: + QMessageBox.warning(self, '激活失败', f'资源激活失败: {e}') + + def handle_delete_resource(self): + """处理删除资源""" + selected_row = self.resources_table.currentRow() + if selected_row == -1: + QMessageBox.warning(self, '操作提示', '请先选择一个资源') + return + + resource_id = int(self.resources_table.verticalHeaderItem(selected_row).text()) + resource_name = self.resources_table.item(selected_row, 0).text() + + reply = QMessageBox.question(self, '确认删除', f'确定要删除资源"{resource_name}"吗?', + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + + if reply == QMessageBox.Yes: + try: + response = self.api_client.session.delete(f'{self.api_client.base_url}/resources/{resource_id}') + response.raise_for_status() + + QMessageBox.information(self, '删除成功', '资源已成功删除') + self.load_resources() + + except requests.exceptions.RequestException as e: + QMessageBox.warning(self, '删除失败', f'资源删除失败: {e}') + +class ResourceUploadDialog(QDialog): + def __init__(self, parent=None, api_client=None): + super().__init__(parent) + self.api_client = api_client + self.file_path = None + self.upload_thread = None + self.setWindowTitle('上传资源') + self.initUI() + + def initUI(self): + # 创建布局 + main_layout = QVBoxLayout() + main_layout.setContentsMargins(30, 30, 30, 30) + main_layout.setSpacing(20) + + # 创建表单布局 + form_layout = QFormLayout() + form_layout.setSpacing(15) + + # 资源名称 + self.name_edit = QLineEdit() + self.name_edit.setPlaceholderText('请输入资源名称') + self.name_edit.setFont(QFont('Arial', 12)) + self.name_edit.setFixedHeight(40) + form_layout.addRow('资源名称:', self.name_edit) + + # 资源类型 + self.type_combo = QComboBox() + self.type_combo.setFont(QFont('Arial', 12)) + self.type_combo.setFixedHeight(40) + self.type_combo.addItems(['皮肤', '人物模型', '场景', '音效', '其他']) + form_layout.addRow('资源类型:', self.type_combo) + + # 描述 + self.description_edit = QTextEdit() + self.description_edit.setPlaceholderText('请输入资源描述') + self.description_edit.setFont(QFont('Arial', 12)) + self.description_edit.setFixedHeight(100) + form_layout.addRow('资源描述:', self.description_edit) + + # 文件选择 + file_layout = QHBoxLayout() + + self.file_path_edit = QLineEdit() + self.file_path_edit.setReadOnly(True) + self.file_path_edit.setFont(QFont('Arial', 12)) + self.file_path_edit.setFixedHeight(40) + file_layout.addWidget(self.file_path_edit) + + self.browse_button = QPushButton('浏览') + self.browse_button.setFont(QFont('Arial', 12)) + self.browse_button.setFixedHeight(40) + self.browse_button.setFixedWidth(100) + self.browse_button.setStyleSheet('background-color: #2196F3; color: white; border: none; border-radius: 5px;') + self.browse_button.clicked.connect(self.handle_browse_file) + file_layout.addWidget(self.browse_button) + + form_layout.addRow('选择文件:', file_layout) + + main_layout.addLayout(form_layout) + + # 进度条 + self.progress_bar = QProgressBar() + self.progress_bar.setVisible(False) + main_layout.addWidget(self.progress_bar) + + # 创建按钮布局 + button_layout = QHBoxLayout() + + # 上传按钮 + self.upload_button = QPushButton('上传') + self.upload_button.setFont(QFont('Arial', 12)) + self.upload_button.setFixedHeight(35) + self.upload_button.setStyleSheet('background-color: #4CAF50; color: white; border: none; border-radius: 5px;') + self.upload_button.clicked.connect(self.handle_upload) + button_layout.addWidget(self.upload_button) + + # 取消按钮 + cancel_button = QPushButton('取消') + cancel_button.setFont(QFont('Arial', 12)) + cancel_button.setFixedHeight(35) + cancel_button.setStyleSheet('background-color: #9E9E9E; color: white; border: none; border-radius: 5px;') + cancel_button.clicked.connect(self.close) + button_layout.addWidget(cancel_button) + + main_layout.addLayout(button_layout) + + # 设置布局 + self.setLayout(main_layout) + + def handle_browse_file(self): + """处理浏览文件""" + file_path, _ = QFileDialog.getOpenFileName( + self, '选择资源文件', '', '所有文件 (*);;图片文件 (*.png *.jpg *.jpeg);;模型文件 (*.obj *.fbx);;音频文件 (*.mp3 *.wav)') + + if file_path: + self.file_path = file_path + self.file_path_edit.setText(file_path) + + # 自动填充资源名称 + if not self.name_edit.text(): + file_info = QFileInfo(file_path) + self.name_edit.setText(file_info.baseName()) + + def handle_upload(self): + """处理上传资源""" + name = self.name_edit.text().strip() + resource_type = self.type_combo.currentText() + description = self.description_edit.toPlainText().strip() + + # 基本验证 + if not name: + QMessageBox.warning(self, '输入错误', '请填写资源名称') + return + + if not self.file_path: + QMessageBox.warning(self, '输入错误', '请选择资源文件') + return + + # 检查文件大小 + max_file_size = 10 * 1024 * 1024 # 10MB + file_size = os.path.getsize(self.file_path) + + if file_size > max_file_size: + QMessageBox.warning(self, '文件过大', f'文件大小不能超过10MB,当前文件大小为{self.format_file_size(file_size)}') + return + + # 检查文件类型 + allowed_types = [ + ('image/png', '.png'), + ('image/jpeg', '.jpg'), + ('image/jpeg', '.jpeg'), + ('model/obj', '.obj'), + ('model/fbx', '.fbx'), + ('audio/mpeg', '.mp3'), + ('audio/wav', '.wav') + ] + + file_ext = os.path.splitext(self.file_path)[1].lower() + file_type = None + + for mime_type, ext in allowed_types: + if file_ext == ext: + file_type = mime_type + break + + if not file_type: + QMessageBox.warning(self, '文件类型不支持', '不支持的文件类型,请选择正确的资源文件') + return + + # 开始上传 + self.progress_bar.setVisible(True) + self.upload_button.setEnabled(False) + self.browse_button.setEnabled(False) + + # 创建上传线程 + self.upload_thread = ResourceUploadThread(self.api_client, self.file_path, name, resource_type, description, file_type) + self.upload_thread.progress_updated.connect(self.update_progress) + self.upload_thread.upload_finished.connect(self.handle_upload_finished) + self.upload_thread.start() + + def format_file_size(self, size_bytes): + """格式化文件大小为人类可读格式""" + if size_bytes == 0: + return "0 Bytes" + + size_names = ["Bytes", "KB", "MB", "GB", "TB"] + i = int(math.floor(math.log(size_bytes, 1024))) + p = math.pow(1024, i) + s = round(size_bytes / p, 2) + return f"{s} {size_names[i]}" + + @pyqtSlot(int) + def update_progress(self, progress): + """更新上传进度""" + self.progress_bar.setValue(progress) + + @pyqtSlot(bool, str) + def handle_upload_finished(self, success, message): + """处理上传完成""" + self.progress_bar.setVisible(False) + self.upload_button.setEnabled(True) + self.browse_button.setEnabled(True) + + if success: + QMessageBox.information(self, '上传成功', message) + self.accept() + else: + QMessageBox.warning(self, '上传失败', message) + +class ResourceUploadThread(QThread): + progress_updated = pyqtSignal(int) + upload_finished = pyqtSignal(bool, str) + + def __init__(self, api_client, file_path, name, resource_type, description, file_type): + super().__init__() + self.api_client = api_client + self.file_path = file_path + self.name = name + self.resource_type = resource_type + self.description = description + self.file_type = file_type + + def run(self): + """线程运行函数""" + try: + # 读取文件内容 + with open(self.file_path, 'rb') as f: + file_content = f.read() + + # 准备数据 + files = { + 'file': (os.path.basename(self.file_path), file_content, self.file_type) + } + + data = { + 'name': self.name, + 'type': self.resource_type, + 'description': self.description + } + + # 发送上传请求 + response = self.api_client.session.post(f'{self.api_client.base_url}/resources/upload', + data=data, files=files, stream=True) + + # 检查响应状态 + response.raise_for_status() + + # 获取文件大小 + file_size = len(file_content) + + # 处理上传进度 + if response.headers.get('Content-Length'): + total_size = int(response.headers.get('Content-Length')) + bytes_uploaded = 0 + + for chunk in response.iter_content(chunk_size=8192): + if chunk: + bytes_uploaded += len(chunk) + progress = int((bytes_uploaded / total_size) * 100) + self.progress_updated.emit(progress) + else: + # 无法获取内容长度时,直接显示100% + self.progress_updated.emit(100) + + # 解析响应 + result = response.json() + + if result.get('success'): + self.upload_finished.emit(True, '资源上传成功') + else: + self.upload_finished.emit(False, f'资源上传失败: {result.get("message", "未知错误")}') + + except requests.exceptions.RequestException as e: + self.upload_finished.emit(False, f'资源上传失败: {e}') + except Exception as e: + self.upload_finished.emit(False, f'资源上传失败: {str(e)}') diff --git a/ke b/ke new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..751b521 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +Flask==2.3.3 +Flask-SQLAlchemy==3.0.5 +Flask-Login==0.6.3 +Flask-WTF==1.1.1 +WTForms==3.0.1 +passlib==1.7.4 +email-validator==2.0.0.post2 +Pillow==10.0.0 +python-dotenv==1.0.0 +python-logstash-async==2.5.0 +python-dotenv==1.0.0 \ No newline at end of file