Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fa50f8167 | ||
|
|
c74ee1a34b | ||
|
|
e741b7ab39 | ||
|
|
8250fb0c68 | ||
|
|
e4adb4d00a | ||
|
|
382e321a98 | ||
|
|
2b2727d28d | ||
|
|
abd964d89a | ||
|
|
15c01c5d92 | ||
|
|
1fd7423176 | ||
|
|
7d59795c30 | ||
|
|
65d526c5d1 |
@@ -90,9 +90,9 @@ jenkins
|
||||
# ------------------------------------------------------------
|
||||
# 前端 UI 目录(按需排除,如不需要可删)
|
||||
/ui
|
||||
/public
|
||||
/uploads
|
||||
|
||||
# 文档与说明(按需保留)
|
||||
# README.md
|
||||
# docs
|
||||
# *.md
|
||||
# *.md
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
# ============================================================
|
||||
# 基础配置
|
||||
# ============================================================
|
||||
|
||||
# 应用名称 - 用于界面显示和日志标识
|
||||
FLASK_NAME=Flask+EleVue旗舰版
|
||||
# 应用版本号 - 用于版本追踪和API兼容性判断
|
||||
FLASK_VERSION=v3.0.0
|
||||
# 应用秘钥 - 用于Session加密、CSRF保护和签名
|
||||
# 生产环境务必更换为强随机字符串
|
||||
FLASK_SECRET_KEY='d67beaf46fbf4f048d2eeb26fd62ea49'
|
||||
# 应用运行地址 - 127.0.0.1仅本地访问,0.0.0.0允许外部访问
|
||||
FLASK_HOST=127.0.0.1
|
||||
# 应用运行端口 - 确保端口未被占用
|
||||
FLASK_PORT=8021
|
||||
# 应用启动文件 - Flask入口文件名称
|
||||
FLASK_APP=app.py
|
||||
# 应用环境变量 - development开发环境 / production生产环境
|
||||
FLASK_ENV=development
|
||||
# 是否DEBUG模式 - True开启调试 / False关闭调试
|
||||
# 生产环境必须设为False,避免暴露调试器和代码信息
|
||||
FLASK_DEBUG=True
|
||||
# 是否禁用自动重启 - True禁用 / False启用
|
||||
# 仅在FLASK_DEBUG=True时生效,生产环境无关(Gunicorn不使用reloader)
|
||||
FLASK_USE_RELOADER=False
|
||||
# 可信代理层数 - 反向代理环境下获取真实客户端IP
|
||||
# 0-无代理(不信任任何转发头),1-1层代理(如Nginx),2-2层代理
|
||||
# 生产环境部署在Nginx后时应设为1
|
||||
PROXY_NUM=0
|
||||
# CORS允许的源 - 逗号分隔,如 http://localhost:8001,https://admin.example.com
|
||||
# 配置后支持携带凭证(Cookie等),未配置则允许所有源但不携带凭证
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:8001
|
||||
# 是否DEMO模式 - True演示模式(限制操作权限)/ False正常模式
|
||||
FLASK_DEMO=False
|
||||
# 万能验证码 - 仅在FLASK_DEMO=True时生效,生产环境必须为空
|
||||
# 设置后可用此验证码绕过正常验证码校验,用于开发调试
|
||||
CAPTCHA_BYPASS_CODE=618618
|
||||
# 文件上传路径 - 用户上传文件的存储目录
|
||||
FLASK_UPLOAD_DIR=/data/apps/uploads
|
||||
# 图片域名 - 用于生成图片完整URL,方便前端展示
|
||||
FLASK_FILE_URL='http://images.flask.elevue.djangoadmin.cn'
|
||||
# 允许上传的文件扩展名 - 逗号分隔,带点号
|
||||
# 生产环境务必配置,防止上传.py、.exe、.sh等危险文件
|
||||
UPLOAD_ALLOWED_EXTS=.jpg,.jpeg,.png,.gif,.bmp,.webp,.svg,.ico,.txt,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.mp3,.mp4,.zip,.rar,.7z
|
||||
# 上传文件大小上限(MB)- 超过此大小将被拒绝上传
|
||||
UPLOAD_MAX_SIZE_MB=10
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 数据库配置
|
||||
# ============================================================
|
||||
|
||||
# 数据库驱动 - 可选 mysql/postgresql/mssql/sqlite/oracle
|
||||
# (别名:postgres=postgresql、sqlserver=mssql、sqlite3=sqlite)
|
||||
# 示例:
|
||||
# MySQL: DB_DRIVER=mysql DB_HOST=127.0.0.1 DB_PORT=3306 DB_USERNAME=root DB_PASSWORD=***
|
||||
# PostgreSQL: DB_DRIVER=postgresql DB_HOST=127.0.0.1 DB_PORT=5432 DB_USERNAME=postgres DB_PASSWORD=***
|
||||
# SQL Server: DB_DRIVER=mssql DB_HOST=127.0.0.1 DB_PORT=1433 DB_USERNAME=sa DB_PASSWORD=***
|
||||
# SQLite: DB_DRIVER=sqlite DB_DATABASE=dev.db(此时 DB_DATABASE 即数据库文件完整路径,
|
||||
# 可填相对/绝对路径如 C:/apps/dev.db,忽略 host/port/user/password)
|
||||
# Oracle: DB_DRIVER=oracle DB_HOST=127.0.0.1 DB_PORT=1521 DB_USERNAME=system DB_PASSWORD=***
|
||||
# (DB_DATABASE 填 SERVICE_NAME,如 XEPDB1)
|
||||
DB_DRIVER='mysql'
|
||||
DB_HOST='127.0.0.1'
|
||||
DB_PORT=3306
|
||||
DB_DATABASE='djangoadmin.flask.elevue'
|
||||
DB_USERNAME='root'
|
||||
DB_PASSWORD='root'
|
||||
# 数据表前缀 - 用于区分同一数据库中不同应用的数据表
|
||||
DB_PREFIX='flask_'
|
||||
# 是否开启调试模式 - True打印SQL日志 / False不打印
|
||||
DB_DEBUG=True
|
||||
# 是否启用数据库SSL加密连接 - 生产环境建议开启,防止数据明文传输
|
||||
DB_SSL=False
|
||||
# SSL CA证书路径 - 用于验证服务端证书,留空则信任系统CA
|
||||
DB_SSL_CA=
|
||||
# SSL客户端证书路径 - 双向认证时使用
|
||||
DB_SSL_CERT=
|
||||
# SSL客户端私钥路径 - 双向认证时使用
|
||||
DB_SSL_KEY=
|
||||
# 连接池常驻连接数 - 默认10,生产环境建议根据并发量调整
|
||||
# DB_POOL_SIZE=10
|
||||
# 超出pool_size后允许的最大额外连接 - 默认10,总连接数=pool_size+max_overflow
|
||||
# DB_MAX_OVERFLOW=10
|
||||
# 连接自动回收时间(秒)- 默认1800,应小于MySQL的wait_timeout
|
||||
# 防止"MySQL server has gone away"错误
|
||||
# DB_POOL_RECYCLE=1800
|
||||
# 获取连接的超时时间(秒)- 默认30,超时抛出TimeoutError
|
||||
# DB_POOL_TIMEOUT=30
|
||||
# 使用前是否检测连接存活 - 默认True,防止使用已被MySQL关闭的连接
|
||||
# DB_POOL_PRE_PING=True
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 缓存配置(Redis)
|
||||
# ============================================================
|
||||
|
||||
# 缓存服务地址 - Redis服务器域名或IP
|
||||
REDIS_HOST='127.0.0.1'
|
||||
# 缓存服务端口 - Redis默认6379
|
||||
REDIS_PORT=6379
|
||||
# 缓存服务密码 - 务必使用强密码,不能为空
|
||||
REDIS_PASSWORD="123456"
|
||||
# 缓存库索引 - 0-15,不同索引用于不同业务(如会话、缓存、队列等)
|
||||
REDIS_INDEX=0
|
||||
# 是否需要进行用户认证 - True开启 / False关闭,生产环境必须开启
|
||||
REDIS_AUTH=True
|
||||
# 是否对查询结果进行编码处理 - True自动解码 / False返回字节数据
|
||||
REDIS_DECODE_RESPONSES=True
|
||||
|
||||
# ============================================================
|
||||
# 验证码配置
|
||||
# ============================================================
|
||||
|
||||
# 验证码字符长度(4-6位,推荐4位)
|
||||
CAPTCHA_LENGTH=6
|
||||
# 验证码过期时间(秒),默认5分钟
|
||||
CAPTCHA_EXPIRE=300
|
||||
# 验证码防刷窗口期(秒)- 在此时间窗口内,同一IP获取次数受CAPTCHA_RATE_LIMIT限制
|
||||
CAPTCHA_RATE_WINDOW=60
|
||||
# 验证码防刷上限(窗口期内最大获取次数)- 超过需等待窗口期结束
|
||||
CAPTCHA_RATE_LIMIT=10
|
||||
# 验证码字符集(去除易混淆字符:0/O/1/I/l/5/S/2/Z)
|
||||
CAPTCHA_CHARS=ABCDEFGHJKLMNPQRSTUVWXYZ2346789
|
||||
# 验证码字体路径
|
||||
CAPTCHA_FONT_PATH=static/fonts/Vera.ttf
|
||||
# 验证码图片宽度(像素)
|
||||
CAPTCHA_IMAGE_WIDTH=160
|
||||
# 验证码图片高度(像素)
|
||||
CAPTCHA_IMAGE_HEIGHT=50
|
||||
# 验证码字体大小
|
||||
CAPTCHA_FONT_SIZE=28
|
||||
# 干扰线数量
|
||||
CAPTCHA_NOISE_LINES=6
|
||||
# 噪点数量
|
||||
CAPTCHA_NOISE_DOTS=30
|
||||
|
||||
# ============================================================
|
||||
# 邮件配置
|
||||
# ============================================================
|
||||
|
||||
# 邮件发送方式 - smtp(SMTP协议)/ sendmail(本地sendmail)
|
||||
MAIL_MAILER='smtp'
|
||||
# 邮件服务器地址 - 163邮箱:smtp.163.com,QQ邮箱:smtp.qq.com
|
||||
MAIL_SERVER='smtp.163.com'
|
||||
# 邮件服务器端口 - 465(SSL加密)/ 587(TLS加密)
|
||||
MAIL_PORT=465
|
||||
# 是否启用SSL加密 - True启用 / False不启用
|
||||
MAIL_USE_SSL=True
|
||||
# 授权邮箱用户名 - 发送邮件的邮箱地址
|
||||
MAIL_USERNAME=''
|
||||
# 授权邮箱授权码 - 不是登录密码,需在邮箱设置中获取
|
||||
MAIL_PASSWORD=''
|
||||
# 邮件加密方式 - ssl / tls,与端口对应
|
||||
MAIL_ENCRYPTION=''
|
||||
# 邮件发件人名称 - 显示在收件人处的名称
|
||||
MAIL_FROM_NAME=''
|
||||
# 邮件发件人地址 - 与MAIL_USERNAME保持一致
|
||||
MAIL_FROM_ADDRESS=''
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 短信配置(阿里云)
|
||||
# ============================================================
|
||||
|
||||
# 短信服务商 - 当前仅支持aliyun,预留扩展
|
||||
SMS_PROVIDER=aliyun
|
||||
# 阿里云AccessKey ID - 在阿里云控制台 > RAM访问控制中获取
|
||||
SMS_ALIYUN_ACCESS_KEY_ID=''
|
||||
# 阿里云AccessKey Secret - 请妥善保管,不要提交到代码仓库
|
||||
SMS_ALIYUN_ACCESS_KEY_SECRET=''
|
||||
# 短信签名 - 需在阿里云控制台 > 短信服务中审核通过
|
||||
SMS_ALIYUN_SIGN_NAME=''
|
||||
# 短信服务接入点 - 默认杭州区域(cn-hangzhou),一般无需修改
|
||||
SMS_ALIYUN_ENDPOINT=dysmsapi.aliyuncs.com
|
||||
# 连接超时(毫秒)- 建立连接的最大等待时间
|
||||
SMS_ALIYUN_CONNECT_TIMEOUT=10000
|
||||
# 读取超时(毫秒)- 等待服务端响应的最大时间
|
||||
SMS_ALIYUN_READ_TIMEOUT=10000
|
||||
|
||||
|
||||
# ============================================================
|
||||
# JWT令牌配置
|
||||
# ============================================================
|
||||
|
||||
# JWT密钥 - 用于令牌签名和验证,至少32字节,务必使用强随机密钥
|
||||
# 生成方式:python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
# 切勿使用默认值,否则存在安全风险!
|
||||
JWT_SALT='VHMlRyV000Tx8xbGtV7k0JG7RhRPE90_oPmUU3c2IzG_3B1ApWIJt1LGLWe83vOG'
|
||||
# 滑动会话空闲超时(分钟)- 用户此时间内无请求则过期需重新登录,活跃则自动续期
|
||||
JWT_EXPIRE_MINUTES=20
|
||||
# 令牌绝对有效期(分钟)- 即使一直活跃最长有效时间(防止无限续期),需大于 JWT_EXPIRE_MINUTES
|
||||
JWT_ABSOLUTE_TIMEOUT_MINUTES=1440
|
||||
# 登录失败次数上限 - 同一IP+用户名连续失败超过此次数后锁定
|
||||
LOGIN_FAIL_MAX=5
|
||||
# 登录锁定时间(秒)- 锁定期间禁止该IP+用户名登录
|
||||
LOGIN_LOCK_SECONDS=300
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 登录白名单配置(支持通配符)
|
||||
# ============================================================
|
||||
|
||||
# 精确匹配白名单 - 逗号分隔,完全匹配路径才放行
|
||||
# 常见路径:/login、/captcha、/favicon.ico
|
||||
LOGIN_WHITELIST_EXACT=/api/v1/login,/api/v1/captcha,/favicon.ico,/health
|
||||
# 前缀匹配白名单 - 逗号分隔,路径以此开头即放行
|
||||
# 常见路径:/job/sendSms、/public/*、/static/*、/api/webhook/*
|
||||
LOGIN_WHITELIST_PREFIX=/api/v1/job/sendSms,/public,/static,/api/webhook
|
||||
# 通配符匹配白名单 - 逗号分隔,支持*和?通配符
|
||||
# * 匹配任意多个字符,? 匹配单个字符
|
||||
# 示例:/job/*/send* 匹配 /job/1/sendSms、/job/2/sendEmail
|
||||
# /api/v1/*/public* 匹配 /api/v1/user/publicInfo
|
||||
LOGIN_WHITELIST_WILDCARD=/api/v1/job/*/send*,/api/v1/*/public*
|
||||
# 是否启用白名单调试日志 - True打印匹配日志 / False不打印
|
||||
LOGIN_WHITELIST_DEBUG=True
|
||||
# 是否在白名单模式下记录访问日志 - True记录 / False不记录
|
||||
LOGIN_WHITELIST_LOG_ACCESS=True
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 权限验证配置
|
||||
# ============================================================
|
||||
|
||||
# 是否启用权限验证 - True启用(需要登录验证)/ False禁用(所有请求放行)
|
||||
# 注意:定时任务等无用户场景建议设置为False或使用内部API密钥
|
||||
PERMISSION_ENABLED=True
|
||||
# 系统内部调用的API密钥 - 用于定时任务等无用户场景
|
||||
# 调用方需在请求头中携带 X-Internal-API-Key 或 X-Scheduler-Key
|
||||
INTERNAL_API_KEY=4sgEM_G_VSv3s3fQWIDrMrH6lpJReDbeDotogr7fyxsJH9Y5IUrn1nVCIBRZbHQk
|
||||
# 内部调用白名单IP - 逗号分隔,支持通配符如127.0.0.*
|
||||
# 在此列表中的IP发起的请求将跳过权限验证
|
||||
# 本地开发默认放行127.0.0.1,生产环境应配置为定时任务服务器的IP
|
||||
INTERNAL_ALLOWED_IPS=127.0.0.1
|
||||
@@ -9,7 +9,6 @@
|
||||
.vscode/
|
||||
vender/
|
||||
# 业务模块 log 目录(src/modules/log)须入库,特例排除
|
||||
log/
|
||||
!src/modules/log/
|
||||
composer.lock
|
||||
gitpush.sh
|
||||
@@ -23,7 +22,6 @@ cbuild
|
||||
evui/
|
||||
document/
|
||||
node_modules/
|
||||
uploads/
|
||||
|
||||
# ============================================================
|
||||
# Python
|
||||
@@ -43,9 +41,9 @@ ENV/
|
||||
# 环境变量(含密钥/凭据,严禁提交)
|
||||
# 根锚定:仅忽略根目录后端 .env,前端 ui/.env* 正常入库
|
||||
# ============================================================
|
||||
/.env
|
||||
/.env.local
|
||||
/.env.*.local
|
||||
#/.env
|
||||
#/.env.local
|
||||
#/.env.*.local
|
||||
|
||||
# ============================================================
|
||||
# 日志 / 本地实例配置
|
||||
@@ -74,4 +72,3 @@ coverage.xml
|
||||
# ============================================================
|
||||
*.pid
|
||||
*.sqlite3
|
||||
captcha.png
|
||||
|
||||
@@ -139,4 +139,4 @@ EXPOSE ${PORT}
|
||||
# CMD ["sh","-c","uwsgi --http 0.0.0.0:${PORT} \
|
||||
# --module 你的项目名.wsgi:application \
|
||||
# --processes 4 --threads 2"]
|
||||
CMD ["sh", "-c", "python3 app.py runserver 0.0.0.0:${PORT} --noreload"]
|
||||
CMD ["sh", "-c", "gunicorn -c gunicorn.conf.py src.wsgi:app -b 0.0.0.0:${PORT}"]
|
||||
|
||||
@@ -19,5 +19,5 @@ gevent==24.2.1
|
||||
py-ip2region==3.0.4
|
||||
oracledb==2.5.1
|
||||
psycopg2-binary==2.9.10
|
||||
pymssql==2.3.3
|
||||
pymssql==2.4.1
|
||||
pypinyin==0.55.0
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
IP地址工具模块
|
||||
=============================================================
|
||||
|
||||
提供客户端IP获取和地理位置查询:
|
||||
- get_client_ip: 获取客户端真实IP(需配合Flask ProxyFix使用)
|
||||
- get_ip_location: 调用第三方API查询IP地理位置
|
||||
提供客户端IP获取功能:
|
||||
- get_client_ip: 获取客户端真实IP(支持反向代理场景)
|
||||
|
||||
地理位置查询请使用 ip2region 模块(离线数据库,高性能)。
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import urllib.request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -18,38 +17,28 @@ def get_client_ip():
|
||||
"""
|
||||
获取客户端真实IP地址
|
||||
|
||||
通过Flask ProxyFix中间件由PROXY_NUM配置自动设置
|
||||
request.remote_addr为真实客户端IP。未配置ProxyFix时,
|
||||
request.remote_addr即为直连IP,避免客户端伪造。
|
||||
优先级:
|
||||
1. X-Forwarded-For 头(反向代理场景,取第一个非内网IP)
|
||||
2. X-Real-IP 头(Nginx 常用配置)
|
||||
3. remote_addr(直连场景)
|
||||
|
||||
Returns:
|
||||
str: 客户端IP地址
|
||||
"""
|
||||
from flask import request
|
||||
|
||||
# 优先从 X-Forwarded-For 获取(反向代理场景)
|
||||
forwarded_for = request.headers.get("X-Forwarded-For", "")
|
||||
if forwarded_for:
|
||||
# 格式: client, proxy1, proxy2 → 取第一个
|
||||
first_ip = forwarded_for.split(",")[0].strip()
|
||||
if first_ip and first_ip not in ("127.0.0.1", "localhost", "0.0.0.0", "::1"):
|
||||
return first_ip
|
||||
|
||||
# 回退到 X-Real-IP(Nginx proxy_set_header X-Real-IP $remote_addr)
|
||||
real_ip = request.headers.get("X-Real-IP", "").strip()
|
||||
if real_ip and real_ip not in ("127.0.0.1", "localhost", "0.0.0.0", "::1"):
|
||||
return real_ip
|
||||
|
||||
# 最后使用 remote_addr
|
||||
return request.remote_addr
|
||||
|
||||
|
||||
def get_ip_location(ip: str) -> str:
|
||||
"""
|
||||
根据IP获取地理位置(使用第三方API)
|
||||
|
||||
Args:
|
||||
ip: IP地址
|
||||
|
||||
Returns:
|
||||
地理位置信息,格式:"国家 省份 城市"
|
||||
"""
|
||||
if ip in ['127.0.0.1', 'localhost'] or ip.startswith('192.168.') or ip.startswith('10.'):
|
||||
return "内网IP"
|
||||
|
||||
try:
|
||||
url = f"http://ip-api.com/json/{ip}?lang=zh-CN"
|
||||
with urllib.request.urlopen(url, timeout=3) as response:
|
||||
data = json.loads(response.read().decode('utf-8'))
|
||||
if data.get('status') == 'success':
|
||||
return f"{data.get('country', '')} {data.get('regionName', '')} {data.get('city', '')}"
|
||||
except Exception as e:
|
||||
logger.warning(f"获取IP地理位置失败: {e}")
|
||||
|
||||
return "未知"
|
||||
|
||||
@@ -68,7 +68,7 @@ from flask_redis import FlaskRedis
|
||||
# FlaskRedis 扩展实例
|
||||
# ============================================================
|
||||
# 在应用工厂中通过 init_redis(app) 完成绑定
|
||||
redis_client = FlaskRedis(decode_responses=True, protocol=2)
|
||||
redis_client = FlaskRedis(decode_responses=True)
|
||||
|
||||
|
||||
def init_redis(app: Flask):
|
||||
|
||||
@@ -135,8 +135,11 @@ def register_operation_log(app):
|
||||
1. 请求方法为 POST/PUT/DELETE/PATCH
|
||||
2. 路径不在排除列表中
|
||||
3. HTTP 状态码为 2xx
|
||||
4. 业务 code 为 0(成功)
|
||||
5. 非内部调用
|
||||
4. 非内部调用
|
||||
|
||||
记录范围:
|
||||
- 成功操作(code=0):status=0(正常)
|
||||
- 失败操作(code!=0,如演示环境拦截):status=1(异常),记录错误信息
|
||||
"""
|
||||
# 只记录写操作
|
||||
if request.method not in ("POST", "PUT", "DELETE", "PATCH"):
|
||||
@@ -147,24 +150,28 @@ def register_operation_log(app):
|
||||
if _is_excluded_path(path):
|
||||
return response
|
||||
|
||||
# 只记录成功操作(HTTP 2xx 且业务 code=0)
|
||||
# HTTP 非 2xx 不记录(如 404、500 等)
|
||||
if not (200 <= response.status_code < 300):
|
||||
return response
|
||||
|
||||
try:
|
||||
data = response.get_json(silent=True)
|
||||
if data and data.get("code", 0) != 0:
|
||||
return response
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 内部调用不记录
|
||||
if getattr(g, "_internal_call", False):
|
||||
return response
|
||||
|
||||
# 异步记录,不阻塞响应
|
||||
# 判断业务状态:code=0 为成功,其他为失败
|
||||
status = 0 # 默认成功
|
||||
error_msg = ""
|
||||
try:
|
||||
_write_log(response)
|
||||
data = response.get_json(silent=True)
|
||||
if data and data.get("code", 0) != 0:
|
||||
status = 1 # 业务失败
|
||||
error_msg = data.get("msg", "操作失败")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 记录日志(成功和失败都记录)
|
||||
try:
|
||||
_write_log(response, status=status, error_msg=error_msg)
|
||||
except Exception as e:
|
||||
logger.warning(f"记录操作日志失败: {str(e)}")
|
||||
|
||||
@@ -173,7 +180,7 @@ def register_operation_log(app):
|
||||
logger.info("操作日志中间件注册成功")
|
||||
|
||||
|
||||
def _write_log(response):
|
||||
def _write_log(response, status=0, error_msg=""):
|
||||
"""
|
||||
写入操作日志
|
||||
|
||||
@@ -181,6 +188,8 @@ def _write_log(response):
|
||||
|
||||
Args:
|
||||
response: Flask 响应对象
|
||||
status: 操作状态(0-正常,1-异常)
|
||||
error_msg: 错误信息(失败时)
|
||||
"""
|
||||
from src.core.security import get_username
|
||||
from src.common.utils.ip import get_client_ip
|
||||
@@ -217,7 +226,7 @@ def _write_log(response):
|
||||
title=title,
|
||||
log_type=log_type,
|
||||
source=0, # OperationLog.Source.ADMIN
|
||||
status=0, # OperationLog.Status.NORMAL
|
||||
status=status,
|
||||
method=request.endpoint or "",
|
||||
request_method=request.method,
|
||||
url=request.path,
|
||||
@@ -228,7 +237,7 @@ def _write_log(response):
|
||||
os=ua_info.get("os", "未知"),
|
||||
browser=ua_info.get("browser", "未知"),
|
||||
consume_time=consume_time,
|
||||
error="",
|
||||
error=error_msg,
|
||||
username=username,
|
||||
)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ VITE_PORT = 8001
|
||||
VITE_GLOB_APP_TITLE = Flask+ElementPlus版
|
||||
|
||||
# spa shortname
|
||||
VITE_GLOB_APP_SHORT_NAME = Flask+ElementPlus版
|
||||
VITE_GLOB_APP_SHORT_NAME = DjangoAdmin
|
||||
|
||||
# 生产环境 开启mock
|
||||
VITE_GLOB_PROD_MOCK = false
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
var _hmt = _hmt || [];
|
||||
(function() {
|
||||
var hm = document.createElement("script");
|
||||
hm.src = "https://hm.baidu.com/hm.js?3025f35e9071c58643123b5f795c1e5e";
|
||||
hm.src = "https://hm.baidu.com/hm.js?434245e350daaaf1649bc75d96834842";
|
||||
var s = document.getElementsByTagName("script")[0];
|
||||
s.parentNode.insertBefore(hm, s);
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { http } from '@/utils/http/axios';
|
||||
|
||||
/**
|
||||
* @description: 列表
|
||||
*/
|
||||
export function getDataSourceList(params?) {
|
||||
return http.request({
|
||||
url: '/data/source/page',
|
||||
method: 'GET',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: 根据ID获取详情
|
||||
*/
|
||||
export function getDataSourceDetail(id) {
|
||||
return http.request({
|
||||
url: '/data/source/detail/' + id,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: 添加
|
||||
*/
|
||||
export function dataSourceAdd(data: any) {
|
||||
return http.request({
|
||||
url: '/data/source/add',
|
||||
method: 'POST',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: 更新
|
||||
*/
|
||||
export function dataSourceUpdate(data: any) {
|
||||
return http.request({
|
||||
url: '/data/source/update',
|
||||
method: 'PUT',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: 删除
|
||||
*/
|
||||
export function dataSourceDelete(id) {
|
||||
return http.request({
|
||||
url: '/data/source/delete/' + id,
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: 批量删除
|
||||
*/
|
||||
export function dataSourceBatchDelete(data: any) {
|
||||
return http.request({
|
||||
url: '/data/source/batchDelete',
|
||||
method: 'DELETE',
|
||||
data,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { h } from 'vue';
|
||||
import { ElTag } from 'element-plus';
|
||||
|
||||
export const columns = [
|
||||
{
|
||||
type: 'selection',
|
||||
},
|
||||
{
|
||||
label: 'ID',
|
||||
prop: 'id',
|
||||
fixed: 'left',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
label: '任务名称',
|
||||
prop: 'jobName',
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
label: '任务组名',
|
||||
prop: 'jobGroup',
|
||||
minWidth: 100,
|
||||
},
|
||||
{
|
||||
label: '任务触发器',
|
||||
prop: 'jobTrigger',
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
label: '任务日志信息',
|
||||
prop: 'jobMessage',
|
||||
minWidth: 300,
|
||||
},
|
||||
{
|
||||
label: '执行表达式',
|
||||
prop: 'cronExpression',
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
label: '执行状态',
|
||||
prop: 'status',
|
||||
minWidth: 100,
|
||||
render(record) {
|
||||
let typeText = '';
|
||||
switch (record.row.status) {
|
||||
case 0:
|
||||
typeText = '未发布';
|
||||
break;
|
||||
case 1:
|
||||
typeText = '运行中';
|
||||
break;
|
||||
case 2:
|
||||
typeText = '暂停';
|
||||
break;
|
||||
case 3:
|
||||
typeText = '删除';
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return h('span', typeText || '-');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '任务开始时间',
|
||||
prop: 'startTime',
|
||||
minWidth: 180,
|
||||
},
|
||||
{
|
||||
label: '任务结束时间',
|
||||
prop: 'endTime',
|
||||
minWidth: 180,
|
||||
},
|
||||
{
|
||||
label: '任务执行时间',
|
||||
prop: 'createTime',
|
||||
minWidth: 180,
|
||||
},
|
||||
{
|
||||
label: '任务执行耗时',
|
||||
prop: 'consumeTime',
|
||||
minWidth: 120,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="props.visible"
|
||||
title="日志详情"
|
||||
width="750"
|
||||
:close-on-click-modal="false"
|
||||
:before-close="dialogClose"
|
||||
>
|
||||
<el-descriptions class="margin-top" :column="2" border>
|
||||
<el-descriptions-item label="任务名称" label-class-name="des-width">
|
||||
{{ formData.jobName }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="任务组名" label-class-name="des-width">
|
||||
{{ formData.jobGroup }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="任务触发器">
|
||||
{{ formData.jobTrigger }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="任务日志信息">
|
||||
{{ formData.jobMessage }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="执行表达式">
|
||||
{{ formData.cronExpression }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="执行状态">
|
||||
<el-tag :type="formData.status ? 'danger' : 'success'">{{
|
||||
formData.status ? '异常' : '正常'
|
||||
}}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="任务开始时间">
|
||||
{{ formData.startTime }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="任务结束时间">
|
||||
{{ formData.endTime }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="任务执行时间">
|
||||
{{ formData.createTime }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="执行耗时"> {{ formData.consumeTime }}ms </el-descriptions-item>
|
||||
<el-descriptions-item label="执行异常信息" :span="3">
|
||||
{{ formData.exceptionInfo }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="dialogClose">取消</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { getJobLogDetail } from '@/api/monitor/job';
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
const emit = defineEmits(['success', 'update:visible']);
|
||||
|
||||
/**
|
||||
* 定义表单参数
|
||||
*/
|
||||
const formData = ref({
|
||||
id: '',
|
||||
jobName: '',
|
||||
jobGroup: '',
|
||||
jobTrigger: '',
|
||||
jobMessage: '',
|
||||
status: '',
|
||||
cronExpression: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
createTime: '',
|
||||
consumeTime: '',
|
||||
exceptionInfo: '',
|
||||
});
|
||||
|
||||
/**
|
||||
* 定义接收的参数
|
||||
*/
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false,
|
||||
},
|
||||
logId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 关闭窗体
|
||||
*/
|
||||
const dialogClose = () => {
|
||||
emit('update:visible', false);
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置表单数据
|
||||
*/
|
||||
const setFormData = async () => {
|
||||
const data = await getJobLogDetail(props.logId);
|
||||
formData.value = data;
|
||||
};
|
||||
|
||||
/**
|
||||
* 钩子函数
|
||||
*/
|
||||
onMounted(() => {
|
||||
if (props.logId) {
|
||||
setFormData();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
:deep(.des-width) {
|
||||
width: 120px;
|
||||
}
|
||||
:deep(.el-descriptions__body .el-descriptions__table .el-descriptions__cell) {
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="props.visible"
|
||||
title="日志详情"
|
||||
width="750"
|
||||
:close-on-click-modal="false"
|
||||
:before-close="dialogClose"
|
||||
>
|
||||
<el-descriptions column="2">
|
||||
<el-descriptions-item label="任务名称:">{{ formData.jobName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="任务组名:">{{ formData.jobGroup }}</el-descriptions-item>
|
||||
<el-descriptions-item label="任务触发器:">{{ formData.jobTrigger }}</el-descriptions-item>
|
||||
<el-descriptions-item label="任务信息:">{{ formData.jobMessage }}</el-descriptions-item>
|
||||
<el-descriptions-item label="cron执行表达式:">{{
|
||||
formData.cronExpression
|
||||
}}</el-descriptions-item>
|
||||
<el-descriptions-item label="任务开始时间:">{{ formData.startTime }}</el-descriptions-item>
|
||||
<el-descriptions-item label="任务结束时间:">{{ formData.endTime }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="dialogClose">关闭</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { getJobLogDetail } from '@/api/monitor/job';
|
||||
import { onMounted, reactive, shallowRef } from 'vue';
|
||||
|
||||
const formData = reactive({
|
||||
id: '',
|
||||
jobName: '',
|
||||
jobGroup: '',
|
||||
jobTrigger: '',
|
||||
jobMessage: '',
|
||||
cronExpression: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:visible']);
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false,
|
||||
},
|
||||
logId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const dialogClose = () => {
|
||||
emit('update:visible', false);
|
||||
};
|
||||
|
||||
const setFormData = async () => {
|
||||
const data = await getJobLogDetail(props.logId);
|
||||
for (const key in formData) {
|
||||
if (data[key] != null && data[key] != undefined) {
|
||||
//@ts-ignore
|
||||
formData[key] = data[key];
|
||||
}
|
||||
}
|
||||
};
|
||||
const getReviceType = (type) => {
|
||||
let typeText = '';
|
||||
switch (type) {
|
||||
case 1:
|
||||
typeText = '系统用户';
|
||||
break;
|
||||
case 2:
|
||||
typeText = '会员用户';
|
||||
break;
|
||||
case 3:
|
||||
typeText = '其他';
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return typeText;
|
||||
};
|
||||
const getTyepText = (type) => {
|
||||
let typeText = '';
|
||||
switch (type) {
|
||||
case 1:
|
||||
typeText = '登录';
|
||||
break;
|
||||
case 2:
|
||||
typeText = '注册';
|
||||
break;
|
||||
case 3:
|
||||
typeText = '找回密码';
|
||||
break;
|
||||
case 4:
|
||||
typeText = '业务';
|
||||
break;
|
||||
case 5:
|
||||
typeText = '其他';
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return typeText;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (props.logId) {
|
||||
setFormData();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,215 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
v-model="props.visible"
|
||||
title="调度日志"
|
||||
direction="rtl"
|
||||
:size="size"
|
||||
:close-on-click-modal="false"
|
||||
:before-close="dialogClose"
|
||||
>
|
||||
<el-card :bordered="false" class="pt-3 mb-3 proCard">
|
||||
<BasicForm @register="register" @submit="handleSubmit" @reset="handleReset" />
|
||||
</el-card>
|
||||
<el-card :bordered="false" class="proCard">
|
||||
<BasicTable
|
||||
:columns="columns"
|
||||
:request="loadDataTable"
|
||||
:row-key="(row) => row.id"
|
||||
ref="tableRef"
|
||||
:actionColumn="actionColumn"
|
||||
@selection-change="onSelectionChange"
|
||||
>
|
||||
<template #tableTitle>
|
||||
<el-button type="danger" @click="handleDelete()" :disabled="!selectionData.length">
|
||||
<template #icon>
|
||||
<el-icon class="el-input__icon">
|
||||
<Delete />
|
||||
</el-icon>
|
||||
</template>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</BasicTable>
|
||||
</el-card>
|
||||
<editDialog v-if="editVisible" :logId="logId" v-model:visible="editVisible" />
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref, h, nextTick, defineAsyncComponent } from 'vue';
|
||||
import { ColProps } from 'element-plus';
|
||||
import { schemas } from './querySchemas';
|
||||
import { useForm } from '@/components/Form/index';
|
||||
import { TableAction } from '@/components/Table';
|
||||
import { getJobLogList, jobLogDelete, jobLogBatchDelete } from '@/api/monitor/job';
|
||||
import { columns } from './columns';
|
||||
import { message, confirm } from '@/utils/auth';
|
||||
|
||||
/**
|
||||
* 导入组件
|
||||
*/
|
||||
const editDialog = defineAsyncComponent(() => import('./edit.vue'));
|
||||
const emit = defineEmits(['update:visible']);
|
||||
|
||||
/**
|
||||
* 定义接收的参数
|
||||
*/
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false,
|
||||
},
|
||||
jobId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 定义参数变量
|
||||
*/
|
||||
const editVisible = ref(false);
|
||||
const logId = ref(0);
|
||||
const size = document.body.clientWidth - 500;
|
||||
const selectionData = ref([]);
|
||||
const tableRef = ref();
|
||||
|
||||
/**
|
||||
* 定义查询参数
|
||||
*/
|
||||
const formParams = reactive({
|
||||
jobName: '',
|
||||
status: '',
|
||||
});
|
||||
|
||||
/**
|
||||
* 定义操作栏
|
||||
*/
|
||||
const actionColumn = reactive({
|
||||
width: 200,
|
||||
label: '操作',
|
||||
prop: 'action',
|
||||
fixed: 'right',
|
||||
render(record) {
|
||||
return h(TableAction, {
|
||||
style: 'button',
|
||||
actions: [
|
||||
{
|
||||
label: '详情',
|
||||
icon: 'View',
|
||||
type: 'warning',
|
||||
onClick: handleDetail.bind(null, record),
|
||||
ifShow: () => {
|
||||
return true;
|
||||
},
|
||||
// auth: ['sys:smsLog:detail'],
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
icon: 'Delete',
|
||||
type: 'danger',
|
||||
onClick: handleDelete.bind(null, record),
|
||||
// 根据业务控制是否显示 isShow 和 auth 是并且关系
|
||||
ifShow: () => {
|
||||
return true;
|
||||
},
|
||||
// 根据权限控制是否显示: 有权限,会显示,支持多个
|
||||
// auth: ['basic_list'],
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
*加载数据列表
|
||||
* @param res 参数
|
||||
*/
|
||||
const loadDataTable = async (res: any) => {
|
||||
const result = await getJobLogList({ ...formParams, jobId: props.jobId, ...res });
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* 刷新数据列表
|
||||
* @param noRefresh 参数
|
||||
*/
|
||||
function reloadTable(noRefresh = '') {
|
||||
tableRef.value.reload(noRefresh ? {} : { pageNo: 1 });
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册
|
||||
*/
|
||||
const [register, {}] = useForm({
|
||||
labelWidth: 100,
|
||||
layout: 'horizontal',
|
||||
colProps: { span: 6 } as ColProps,
|
||||
submitOnReset: true,
|
||||
schemas,
|
||||
});
|
||||
|
||||
/**
|
||||
* 执行提交表单
|
||||
* @param values 参数
|
||||
*/
|
||||
function handleSubmit(values: Recordable) {
|
||||
handleReset();
|
||||
for (const key in values) {
|
||||
formParams[key] = values[key];
|
||||
}
|
||||
reloadTable();
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行重置
|
||||
*/
|
||||
function handleReset() {
|
||||
for (const key in formParams) {
|
||||
formParams[key] = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭窗体
|
||||
*/
|
||||
const dialogClose = () => {
|
||||
emit('update:visible', false);
|
||||
};
|
||||
|
||||
/**
|
||||
* 执行查询详情
|
||||
*/
|
||||
const handleDetail = async (record: Recordable) => {
|
||||
logId.value = record.row.id;
|
||||
await nextTick();
|
||||
editVisible.value = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 执行删除
|
||||
* @param record 参数
|
||||
*/
|
||||
async function handleDelete(record: Recordable) {
|
||||
let ids = [];
|
||||
if (!record) {
|
||||
ids = selectionData.value.map(({ id }) => id);
|
||||
}
|
||||
await confirm('确定要删除?');
|
||||
record ? await jobLogDelete(record.row.id) : await jobLogBatchDelete(ids);
|
||||
message('删除成功');
|
||||
reloadTable();
|
||||
}
|
||||
|
||||
/**
|
||||
* 选项发生变化
|
||||
* @param value 参数
|
||||
*/
|
||||
function onSelectionChange(value) {
|
||||
selectionData.value = value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -0,0 +1,30 @@
|
||||
import { FormSchema } from '@/components/Form/index';
|
||||
export const schemas: FormSchema[] = [
|
||||
{
|
||||
field: 'jobName',
|
||||
component: 'Input',
|
||||
label: '任务名称',
|
||||
componentProps: {
|
||||
placeholder: '请输入任务名称',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
component: 'Select',
|
||||
label: '任务状态',
|
||||
componentProps: {
|
||||
placeholder: '请选择任务状态',
|
||||
clearable: true,
|
||||
options: [
|
||||
{
|
||||
label: '正常',
|
||||
value: 0,
|
||||
},
|
||||
{
|
||||
label: '失败',
|
||||
value: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
After Width: | Height: | Size: 381 KiB |
|
After Width: | Height: | Size: 381 KiB |
|
After Width: | Height: | Size: 381 KiB |
|
After Width: | Height: | Size: 381 KiB |
|
After Width: | Height: | Size: 381 KiB |
|
After Width: | Height: | Size: 381 KiB |
|
After Width: | Height: | Size: 381 KiB |
|
After Width: | Height: | Size: 381 KiB |
|
After Width: | Height: | Size: 381 KiB |
|
After Width: | Height: | Size: 381 KiB |
|
After Width: | Height: | Size: 381 KiB |
|
After Width: | Height: | Size: 605 KiB |
|
After Width: | Height: | Size: 567 KiB |
|
After Width: | Height: | Size: 618 KiB |
|
After Width: | Height: | Size: 754 KiB |
|
After Width: | Height: | Size: 788 KiB |
|
After Width: | Height: | Size: 816 KiB |
|
After Width: | Height: | Size: 660 KiB |
|
After Width: | Height: | Size: 398 KiB |
|
After Width: | Height: | Size: 626 KiB |
|
After Width: | Height: | Size: 851 KiB |
|
After Width: | Height: | Size: 390 KiB |
|
After Width: | Height: | Size: 535 KiB |
|
After Width: | Height: | Size: 570 KiB |
|
After Width: | Height: | Size: 573 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 382 KiB |
|
After Width: | Height: | Size: 654 KiB |
|
After Width: | Height: | Size: 383 KiB |
|
After Width: | Height: | Size: 830 KiB |
|
After Width: | Height: | Size: 660 KiB |
|
After Width: | Height: | Size: 774 KiB |
|
After Width: | Height: | Size: 823 KiB |
|
After Width: | Height: | Size: 846 KiB |
|
After Width: | Height: | Size: 831 KiB |
|
After Width: | Height: | Size: 879 KiB |
|
After Width: | Height: | Size: 719 KiB |
|
After Width: | Height: | Size: 843 KiB |
|
After Width: | Height: | Size: 781 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 381 KiB |
|
After Width: | Height: | Size: 381 KiB |
|
After Width: | Height: | Size: 381 KiB |
|
After Width: | Height: | Size: 2.3 MiB |