commit 20a2f846ff7fcab78a5b3524fcf2ea32920e64b5 Author: 小蚂蚁云 Date: Wed Sep 16 12:00:58 2026 +0800 签入版本 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7f45417 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,31 @@ +# 版本控制与 IDE +.git +.gitignore +.idea +.vscode + +# Python 缓存与虚拟环境 +__pycache__/ +*.py[cod] +*.egg-info/ +.venv +venv +env + +# 测试与日志 +tests +.pytest_cache +*.log + +# 运行时数据(不进入镜像,compose 通过 ./uploads 挂载提供) +uploads + +# 敏感配置不进入镜像:环境变量由运行时注入(docker compose env_file / docker run --env-file) +# .env 含数据库/Redis/JWT/短信等明文凭据,打进镜像即随分发泄露;仅保留占位模板 .env.example +#.env +.env.* +!.env.example + +# 前端 UI 目录(按需排除,如不需要可删) +/ui +/uploads \ No newline at end of file diff --git a/.env b/.env new file mode 100644 index 0000000..308c2e3 --- /dev/null +++ b/.env @@ -0,0 +1,153 @@ +# +====================================================================== +# | 模块: 环境配置文件模板 +# | 说明: 提供完整的 .env 配置示例,包含应用、数据库、缓存、JWT、验证码等 +# +====================================================================== + +# ============================================================ +# 基础配置 +# ============================================================ + +# 应用名称 +TORNADO_NAME = Tornado+AntdVue旗舰版 +# 应用版本 +TORNADO_VERSION = v3.0.0 +# 允许的跨域来源白名单(逗号分隔;留空则允许所有来源且不携带凭据) +TORNADO_CORS_ORIGINS = +# 应用运行地址 +TORNADO_HOST = 127.0.0.1 +# 应用运行端口 +TORNADO_PORT = 8041 +# 应用启动文件 +TORNADO_APP = app.py +# 应用环境变量 +TORNADO_ENV = development +# 是否DEBUG模式:是-True,否-False +TORNADO_DEBUG = True +# 是否DEMO模式:是-True,否-False +TORNADO_DEMO = False +# 应用文件上传路径(留空使用项目内 uploads 目录) +TORNADO_UPLOAD_DIR = +# 应用文件域名 +TORNADO_FILE_URL = http://images.tornado.antdvue.djangoadmin.cn + +# ============================================================ +# 上传配置 +# ============================================================ + +# 允许上传的文件扩展名 - 逗号分隔,带点号 +# 生产环境务必配置,防止上传.py、.exe、.sh等危险文件 +UPLOAD_ALLOWED_EXTS = .jpg,.jpeg,.png,.gif,.bmp,.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 +# 数据库地址 - 数据库服务器IP或域名(sqlite 时忽略) +DB_HOST = 127.0.0.1 +# 数据库端口 - MySQL:3306 / PostgreSQL:5432 / SQL Server:1433 / Oracle:1521 / sqlite 时忽略 +DB_PORT = 3306 +# 数据库名称 - 要连接的数据库名;DB_DRIVER=sqlite 时即数据库文件完整路径; +# DB_DRIVER=oracle 时填 SERVICE_NAME +DB_DATABASE = djangoadmin.tornado.antdvue +# 数据库账号 - 连接数据库的用户名(sqlite 时忽略) +DB_USERNAME = root +# 数据库密码 - 连接数据库的密码(sqlite 时无需配置) +DB_PASSWORD = root +# 数据表前缀 - 所有数据表统一前缀,用于多应用或模块隔离 +DB_PREFIX = tornado_ +# 是否开启调试模式:是-True,否-False +DB_DEBUG = True + +# ============================================================ +# 缓存配置 +# ============================================================ + +# 缓存服务地址 +REDIS_HOST = 127.0.0.1 +# 缓存服务端口 +REDIS_PORT = 6379 +# 缓存服务密码 +REDIS_PASSWORD = 123456 +# 缓存库索引 +REDIS_INDEX = 0 +# AUTH 为 True 时需要进行 用户认证 +REDIS_AUTH = True +# 是否对查询结果进行编码处理 +REDIS_DECODE_RESPONSES = True + +# ============================================================ +# JWT 配置 +# ============================================================ + +# JWT 密钥 - 用于令牌签名和验证,至少 32 字节,务必使用强随机密钥 +# 生成方式:python -c "import secrets; print(secrets.token_urlsafe(48))" +# 切勿使用默认值,否则存在安全风险(启动 fail-close)! +JWT_SECRET = p3rMHeXiB7o0vQQO_K-Z2bJj0IBu6frYSEKcS8aovUxQtHBjD2SC53O7Sv30UVf7 +# JWT 过期时间(分钟) +JWT_EXPIRE_MINUTES = 1440 +# 登录失败次数上限 - 同一IP+用户名连续失败超过此次数后锁定 +LOGIN_FAIL_MAX = 5 +# 登录锁定时间(秒)- 锁定期间禁止该IP+用户名登录 +LOGIN_LOCK_SECONDS = 300 + +# ============================================================ +# 验证码配置 +# ============================================================ + +# 验证码长度 +CAPTCHA_LENGTH = 6 +# 验证码有效期(秒) +CAPTCHA_EXPIRE = 300 +# 防刷:同一 IP 在窗口期内最多获取验证码次数 +CAPTCHA_RATE_LIMIT = 20 +# 防刷滑动窗口(秒) +CAPTCHA_RATE_WINDOW = 60 +# 字符集(去除易混淆字符,全大写便于大小写不敏感比较) +CAPTCHA_CHARS = 346789ABCDEFGHJKMNPQRTUVWXY +# 验证码图片尺寸与字号 +CAPTCHA_IMAGE_WIDTH = 200 +CAPTCHA_IMAGE_HEIGHT = 60 +CAPTCHA_FONT_SIZE = 30 +# 干扰线 / 噪点数量 +CAPTCHA_NOISE_LINES = 10 +CAPTCHA_NOISE_DOTS = 60 +# 万能验证码(仅 TORNADO_DEMO=True 时生效,方便演示环境登录) +CAPTCHA_BYPASS_CODE = 618618 + +# ============================================================ +# 邮件配置 +# ============================================================ + +# 邮寄者 +MAIL_MAILER = smtp +# 邮件服务 +MAIL_SERVER = smtp.163.com +# 邮件端口 +MAIL_PORT = 465 +# 邮件SSL证书 +MAIL_USE_SSL = True +# 授权邮箱用户名 +MAIL_USERNAME = +# 授权邮箱授权码 +MAIL_PASSWORD = +# 邮件加密串 +MAIL_ENCRYPTION = +# 邮件发件人名称 +MAIL_FROM_NAME = +# 邮件发件人地址 +MAIL_FROM_ADDRESS = \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4e8d98b --- /dev/null +++ b/.env.example @@ -0,0 +1,151 @@ +# +====================================================================== +# | 模块: 环境配置文件模板 +# | 说明: 提供完整的 .env 配置示例,包含应用、数据库、缓存、JWT、验证码等 +# +====================================================================== + +# ============================================================ +# 基础配置 +# ============================================================ + +# 应用名称 +TORNADO_NAME=Tornado+AntdVue旗舰版 +# 应用版本 +TORNADO_VERSION=v3.0.0 +# 允许的跨域来源白名单(逗号分隔;留空则允许所有来源且不携带凭据) +TORNADO_CORS_ORIGINS= +# 应用运行地址 +TORNADO_HOST=127.0.0.1 +# 应用运行端口 +TORNADO_PORT=8041 +# 应用环境变量 +TORNADO_ENV=development +# 是否DEBUG模式:是-True,否-False +TORNADO_DEBUG=True +# 是否DEMO模式:是-True,否-False(为True时所有写操作被拒绝) +TORNADO_DEMO=False +# 应用文件上传路径(留空使用项目内 uploads 目录) +TORNADO_UPLOAD_DIR= +# 应用文件域名(文件回显地址前缀) +TORNADO_FILE_URL= + +# ============================================================ +# 数据库配置 +# ============================================================ + +# 数据库驱动 +DB_DRIVER=mysql +# 数据库地址 +DB_HOST=127.0.0.1 +# 数据库端口 +DB_PORT=3306 +# 数据库名称 +DB_DATABASE=djangoadmin.tornado.antdvue +# 数据库账号 +DB_USERNAME=root +# 数据库密码 +DB_PASSWORD= +# 数据表前缀 +DB_PREFIX=tornado_ +# 是否开启SQL调试 +DB_DEBUG=True +# 读库地址(读写分离,可选;留空则读写同库) +READ_DATABASE_URL= + +# ============================================================ +# 缓存配置 +# ============================================================ + +# 缓存服务地址 +REDIS_HOST=127.0.0.1 +# 缓存服务端口 +REDIS_PORT=6379 +# 缓存服务密码 +REDIS_PASSWORD= +# 缓存库索引 +REDIS_INDEX=0 +# AUTH 为 True 时需要进行 用户认证 +REDIS_AUTH=True +# 是否对查询结果进行编码处理 +REDIS_DECODE_RESPONSES=True + +# ============================================================ +# 邮件配置 +# ============================================================ + +# 邮寄者 +MAIL_MAILER=smtp +# 邮件服务 +MAIL_SERVER=smtp.163.com +# 邮件端口 +MAIL_PORT=465 +# 邮件SSL证书 +MAIL_USE_SSL=True +# 授权邮箱用户名 +MAIL_USERNAME= +# 授权邮箱授权码 +MAIL_PASSWORD= +# 邮件加密串 +MAIL_ENCRYPTION= +# 邮件发件人名称 +MAIL_FROM_NAME= +# 邮件发件人地址 +MAIL_FROM_ADDRESS= + +# ============================================================ +# JWT 配置 +# ============================================================ + +# JWT 密钥 - 用于令牌签名和验证,至少 32 字节,务必使用强随机密钥 +# 生成方式:python -c "import secrets; print(secrets.token_urlsafe(48))" +# 切勿使用默认值或留空,否则启动将中止(fail-close) +JWT_SECRET= +# JWT 过期时间(分钟) +JWT_EXPIRE_MINUTES=20 +# 登录失败次数上限 - 同一IP+用户名连续失败超过此次数后锁定 +LOGIN_FAIL_MAX=5 +# 登录锁定时间(秒)- 锁定期间禁止该IP+用户名登录 +LOGIN_LOCK_SECONDS=300 + +# ============================================================ +# 验证码配置 +# ============================================================ + +# 验证码长度 +CAPTCHA_LENGTH=6 +# 验证码有效期(秒) +CAPTCHA_EXPIRE=300 +# 防刷:同一 IP 在窗口期内最多获取验证码次数 +CAPTCHA_RATE_LIMIT=20 +# 防刷滑动窗口(秒) +CAPTCHA_RATE_WINDOW=60 +# 字符集(去除易混淆字符,全大写便于大小写不敏感比较) +CAPTCHA_CHARS=346789ABCDEFGHJKMNPQRTUVWXY +# 验证码图片尺寸与字号 +CAPTCHA_IMAGE_WIDTH=200 +CAPTCHA_IMAGE_HEIGHT=60 +CAPTCHA_FONT_SIZE=30 +# 干扰线 / 噪点数量 +CAPTCHA_NOISE_LINES=10 +CAPTCHA_NOISE_DOTS=60 +# 万能验证码(仅 TORNADO_DEMO=True 时生效,方便演示环境登录) +CAPTCHA_BYPASS_CODE=618618 + +# ============================================================ +# 上传配置 +# ============================================================ + +# 允许上传的文件扩展名 - 逗号分隔,带点号 +# 生产环境务必配置,防止上传.py、.exe、.sh等危险文件 +UPLOAD_ALLOWED_EXTS=.jpg,.jpeg,.png,.gif,.bmp,.ico,.txt,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.mp3,.mp4,.zip,.rar,.7z + +# 上传文件大小上限(MB)- 超过此大小将被拒绝上传 +UPLOAD_MAX_SIZE_MB=10 + +# ============================================================ +# 日志配置 +# ============================================================ + +# 日志级别:DEBUG/INFO/WARNING/ERROR +LOG_LEVEL=INFO +# 日志目录 +LOG_DIR=logs \ No newline at end of file diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..22f13a2 --- /dev/null +++ b/.flake8 @@ -0,0 +1,4 @@ +[flake8] +max-line-length = 120 +exclude = .git,__pycache__,build,dist,.venv,alembic/versions +extend-ignore = E203,W503 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bbf4966 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +.DS_Store +.buildpath +.hgignore.swp +.project +.orig +.swp +.idea/ +.settings/ +.vscode/ +vender/ +composer.lock +gitpush.sh +pkg/ +bin/ +cbuild +*/.DS_Store +.vscode + +.svn +evui/ +document/* + +__pycache__/ +*.py[cod] +.pytest_cache/ +*.egg-info/ +dist/ +venv/ +.venv/ +logs/ +uploads/ \ No newline at end of file diff --git a/.jenkins/.env.yml b/.jenkins/.env.yml new file mode 100644 index 0000000..a17137b --- /dev/null +++ b/.jenkins/.env.yml @@ -0,0 +1,16 @@ +masterProfiles: ['','uat','prod'] +Profiles: ['','dev'] +jobConfigFile: "djangoadminConfig.yml" +npmCompileCMD: | + export PATH=/usr/local/node/bin:$PATH + set -e \ + && yarn config set registry https://registry.npmmirror.com \ + && yarn config get registry \ + && yarn config set strict-ssl false \ + && yarn config set sass_binary_site https://repo.huaweicloud.com/node-sass/node-sass \ + && rm -rf package-lock.json \ + && rm -rf node_modules/.cache 2>/dev/null || true \ + && yarn cache clean --force \ + && yarn global add node-gyp@10.3.1 \ + && yarn install --unsafe-perm --legacy-peer-deps --network-timeout 300000 \ + && yarn run build:${PROFILE} \ No newline at end of file diff --git a/.jenkins/Dockerfile b/.jenkins/Dockerfile new file mode 100644 index 0000000..9af6d5a --- /dev/null +++ b/.jenkins/Dockerfile @@ -0,0 +1,141 @@ +# ============================================================ +# Dockerfile:项目镜像(多阶段构建) +# 基础镜像:python-base:3.12.8(RockyLinux 8 + Python 3.12.8 + mariadb-connector-c) +# +# 设计要点 +# - builder 只编译依赖,装到 /opt/deps,不碰基础镜像的 Python 目录 +# - runtime 继承基础镜像的 Python,只把依赖的 site-packages COPY 过来 +# - MySQL 运行库(mariadb-connector-c)由基础镜像预置,runtime 无需重复安装 +# - 用 COPY --chown 指定属主,避免额外的 chown 层 +# - 源码 COPY 放在依赖之后,保护依赖层缓存 +# +# 构建参数 +# VAR_BASE_IMAGE 基础镜像 +# VAR_PYTHON_PREFIX Python 安装前缀(与基础镜像一致) +# VAR_PYTHON_SHORT_VERSION Python 主次版本号,用于 site-packages 路径 +# VAR_PYFILE_PATH 项目源码目录 +# VAR_PROFILES 应用 profile(dev / prod) +# VAR_PORT 应用监听端口 +# VAR_PYFILE 入口文件名(不含 .py) +# ============================================================ + + +# ------------------------------------------------------------ +# 全局构建参数 +# 在 FROM 之前声明,可供后续各阶段引用 +# ------------------------------------------------------------ +ARG VAR_BASE_IMAGE +ARG VAR_PYTHON_PREFIX=/usr/local/python3.12.8 +ARG VAR_PYTHON_SHORT_VERSION=3.12 + + +# ============================================================ +# 阶段一:builder —— 编译并安装项目依赖 +# ============================================================ +FROM ${VAR_BASE_IMAGE} AS builder + +# 阶段内重新声明,否则读不到全局 ARG +# Docker 规则:全局 ARG 只在 FROM 行可见,阶段内使用必须重新声明 +ARG VAR_PYTHON_PREFIX +ARG VAR_PYTHON_SHORT_VERSION +ARG VAR_PYFILE_PATH + +# 基础镜像末尾是 esxi 用户;dnf/pip 需要写系统目录和 /opt/deps, +# 所以这里仍需 root +USER root + +LABEL MAINTAINER="djangoadmin@126.com" + +# 编译阶段环境变量 +# PYTHONUNBUFFERED=1 日志实时输出 +# PYTHONDONTWRITEBYTECODE=1 不生成 .pyc +# PIP_NO_CACHE_DIR=1 不缓存 wheel +# PIP_DISABLE_PIP_VERSION_CHECK=1 关闭 pip 版本检查 +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +# 安装编译工具链 +# gcc / gcc-c++ / make 编译 C 扩展(mysqlclient、uwsgi 等) +# openssl-devel cryptography、_ssl 等 +# bzip2-devel / libffi-devel +# mariadb-devel 编译 mysqlclient(提供 .pc 文件) +# pkgconf-pkg-config 提供 pkg-config 命令(mysqlclient 2.2.x 必需) +RUN set -eux; \ + # 替换 dnf 源为阿里云镜像 + sed -e 's|^mirrorlist=|#mirrorlist=|g' \ + -e 's|^#baseurl=http://dl.rockylinux.org/$contentdir|baseurl=https://mirrors.aliyun.com/rockylinux|g' \ + -i.bak /etc/yum.repos.d/*.repo; \ + dnf install -y --setopt=install_weak_deps=False \ + gcc gcc-c++ make \ + openssl-devel bzip2-devel libffi-devel \ + mariadb-devel pkgconf-pkg-config; \ + dnf clean all; rm -rf /var/cache/dnf + +# 配置 pip 国内镜像源(阿里云 PyPI) +RUN pip3 config set global.index-url http://mirrors.aliyun.com/pypi/simple && \ + pip3 config set install.trusted-host mirrors.aliyun.com + +# 只拷 requirements.txt +# 源码变化但依赖未变时,可复用 pip install 层缓存 +WORKDIR /opt/apps +COPY ${VAR_PYFILE_PATH}/requirements.txt ./ + +# 依赖装到 /opt/deps,与基础镜像 Python 目录解耦 +# --prefix=/opt/deps 安装到独立目录,runtime 只 COPY 这个目录的产物 +# uwsgi 生产环境 WSGI 服务器,单独安装 +RUN python3 -m pip install --prefix=/opt/deps -r requirements.txt && \ + python3 -m pip install --prefix=/opt/deps uwsgi + + +# ============================================================ +# 阶段二:runtime —— 最终业务镜像 +# ============================================================ +FROM ${VAR_BASE_IMAGE} AS runtime + +# 阶段内重新声明 +ARG VAR_PYTHON_PREFIX +ARG VAR_PYTHON_SHORT_VERSION +ARG VAR_PROFILES +ARG VAR_PORT +ARG VAR_PYFILE +ARG VAR_PYFILE_PATH + +LABEL MAINTAINER="djangoadmin@126.com" + +# 运行期环境变量 +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +# ARG 转 ENV,运行期可读取 +# PORT 供 EXPOSE 与 CMD 使用 +# PYFILE 入口文件名 +# PROFILES 应用读取的 profile +ENV PORT=${VAR_PORT} \ + PYFILE=${VAR_PYFILE} \ + PROFILES=${VAR_PROFILES} + +# 把 builder 编译好的 site-packages 和可执行文件拷到基础镜像 Python 对应目录 +# 目标路径必须与基础镜像 Python 的实际 site-packages 一致 +# 用 COPY --chown 指定属主,避免额外的 chown 层 +COPY --chown=esxi:esxi --from=builder \ + /opt/deps/lib/python${VAR_PYTHON_SHORT_VERSION}/site-packages/ \ + ${VAR_PYTHON_PREFIX}/lib/python${VAR_PYTHON_SHORT_VERSION}/site-packages/ +COPY --chown=esxi:esxi --from=builder /opt/deps/bin/ ${VAR_PYTHON_PREFIX}/bin/ + +WORKDIR /opt/apps + +# 拷项目源码(放在依赖之后,保护 pip 层缓存) +# 建议配合 .dockerignore 排除 .git、__pycache__、本地虚拟环境等 +COPY --chown=esxi:esxi ${VAR_PYFILE_PATH}/ ./ + +USER esxi +EXPOSE ${PORT} + +# 启动命令 +# sh -c 以 shell 形式执行,可展开 ${PORT} 等环境变量 +# ${PYFILE} 入口文件名(不含 .py),由 ENV PYFILE 注入 +# ${PORT} 监听端口,由 ENV PORT 注入,运行期可 -e PORT=xxx 覆盖 +# poetry run 在 poetry 虚拟环境中运行,保证依赖版本一致 +CMD ["sh", "-c", "python app.py --port=${PORT}"] \ No newline at end of file diff --git a/.jenkins/Jenkinsfile b/.jenkins/Jenkinsfile new file mode 100644 index 0000000..8129435 --- /dev/null +++ b/.jenkins/Jenkinsfile @@ -0,0 +1,3 @@ +@Library('share-library@master')_ +orgName = 'djangoadmin' +pipelinePythonService(orgName) \ No newline at end of file diff --git a/.jenkins/Jenkinsfile2 b/.jenkins/Jenkinsfile2 new file mode 100644 index 0000000..2cf3edd --- /dev/null +++ b/.jenkins/Jenkinsfile2 @@ -0,0 +1,3 @@ +@Library('share-library@master')_ +orgName = 'djangoadmin' +pipelineNodeService(orgName) \ No newline at end of file diff --git a/.mypy.ini b/.mypy.ini new file mode 100644 index 0000000..c044e52 --- /dev/null +++ b/.mypy.ini @@ -0,0 +1,5 @@ +[mypy] +python_version = 3.10 +ignore_missing_imports = True +warn_unused_ignores = False +exclude = alembic/versions diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..0a69084 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,21 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-json + - id: check-merge-conflict + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.4.2 + hooks: + - id: ruff + args: [--fix] + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.10.0 + hooks: + - id: mypy + additional_dependencies: [pydantic, pydantic-settings] diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..78e29ce --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +# ====================================================================== +# 多阶段构建:Stage 1 构建依赖 -> Stage 2 运行 +# ====================================================================== +FROM python:3.12-slim AS builder + +WORKDIR /build + +# 复制依赖清单 +COPY pyproject.toml poetry.lock* ./ + +# 安装 poetry 并导出 requirements +RUN pip install --no-cache-dir poetry \ + && poetry export -f requirements.txt -o requirements.txt --without-hashes + +# ====================================================================== +FROM python:3.12-slim AS runtime + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + TZ=Asia/Shanghai + +WORKDIR /data/apps + +# 安装运行依赖 +COPY --from=builder /build/requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +# 复制项目文件 +COPY src ./src +COPY app.py ./ +COPY alembic ./alembic +COPY alembic.ini ./ +COPY .env.example ./.env +COPY scripts ./scripts +COPY templates ./templates +COPY static ./static + +# 创建运行时目录 +RUN mkdir -p uploads logs + +EXPOSE 8041 + +# 启动应用 +CMD ["python", "app.py"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9374504 --- /dev/null +++ b/Makefile @@ -0,0 +1,54 @@ +.PHONY: install dev test test-unit test-integration lint format migrate seed health build up down + +# 安装依赖 +install: + poetry install + +# 开发运行 +dev: + poetry run python app.py + +# 运行全部测试 +test: + poetry run pytest + +# 单元测试 +test-unit: + poetry run pytest tests/unit + +# 集成测试 +test-integration: + poetry run pytest tests/integration + +# 代码规范检查 +lint: + poetry run flake8 src tests + poetry run mypy src + +# 代码自动格式化 +format: + poetry run ruff check --fix src tests + +# 数据库迁移 +migrate: + poetry run alembic upgrade head + +# 填充测试数据 +seed: + poetry run python scripts/seed.py + +# 健康检查 +health: + poetry run python scripts/health_check.py + +# Docker 构建 +build: + docker compose build + +# Docker 启动 +up: + docker compose up + +# Docker 停止 +down: + docker compose down diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c316f2 --- /dev/null +++ b/README.md @@ -0,0 +1,285 @@ +

DjangoAdmin 数据中台解决方案(Tornado + AntDesign 版本)

+ +

DjangoAdmin Logo

+ +## 📚 项目介绍 + +DjangoAdmin 数据中台解决方案是一套面向企业级应用场景的一站式敏捷开发平台。后端以 Python + Tornado 为核心,前端采用 Vue3 + TypeScript + AntDesign + Vite 构建,并通过 Vue Router 与 Pinia 实现路由和状态管理。全栈技术体系成熟稳定,可快速支撑中后台管理系统、数据中台、业务运营平台等多类项目落地。 + +框架以“简化开发、提升效率”为设计初衷,自研了一套个性化、可插拔的组件式开发体系,涵盖单图上传、多图上传、下拉选择、开关按钮、单选按钮、多选按钮等常用交互组件。开发者只需按需组合,即可完成业务页面搭建,真正实现低代码开发。 + +在工程化层面,框架内置代码生成器,可根据数据库表结构自动生成完整的 CRUD 模块,包括后端代码、前端页面以及菜单权限记录,将重复性的增删改查工作一键自动化,让开发者能够将精力聚焦于核心业务逻辑与差异化功能。 + +在架构层面,框架采用前后端分离设计:后端提供标准 API,前端独立部署并支持多端适配。权限体系基于 RBAC 模型构建,覆盖菜单权限、按钮权限、数据权限三级控制;认证采用 JWT 无状态方案,使前后端协作更加高效顺畅。代码分层清晰、职责明确,配合完善的开发规范与工具链,为团队协作与长期维护提供坚实保障。 + +## 🍻 项目特点 + +- **高性能异步**:基于 Tornado 非阻塞 I/O 事件循环,单进程高并发,性能卓越 +- **模块化、松耦合**:每个业务模块独立封装,互不干扰,支持按需加载 +- **模块丰富、开箱即用**:内置用户、角色、菜单、部门、字典、日志等十余个基础模块 +- **前后端分离**:前后端独立开发与部署,接口规范统一,支持多端适配 +- **权限体系完善**:基于 RBAC 模型,支持菜单权限、按钮权限二级控制 +- **认证安全可靠**:采用无状态认证方案,支持令牌刷新与黑名单,适配分布式场景 +- **统一响应规范**:接口返回格式统一,前后端协作更高效 +- **跨库数据迁移**:内置数据迁移脚本,支持 MySQL 到其他数据库的结构 + 数据全量迁移 +- **完善的数据验证**:请求参数自动校验,错误信息自动格式化 +- **完善的本地中文化支持**:错误提示、表单验证、操作日志全面中文化 +- **设计为团队及企业使用**:规范统一,便于多人协作与长期维护 + +## 🍪 内置模块 + +- 用户管理:维护管理系统的用户,常规信息的维护与账号设置 +- 角色管理:角色菜单管理与权限分配,设置角色所拥有的菜单权限 +- 菜单管理:配置系统菜单、操作权限、按钮权限标识,支持动态路由生成 +- 职级管理:主要管理用户的职级信息 +- 岗位管理:主要管理用户担任的职务 +- 部门管理:配置系统组织机构,树结构展现支持数据权限 +- 字典管理:对系统中常用的较为固定的数据进行统一维护 +- 字典项管理:字典数据的明细项管理 +- 配置管理:对系统的常规配置信息进行维护,网站配置管理功能统一维护 +- 配置项管理:配置数据的明细项管理 +- 通知公告:系统通知公告信息发布维护 +- 文章管理:文章分类与内容管理 +- 城市管理:城市数据维护,支持树形层级结构 +- 友情链接:友情链接管理 +- 参数管理:系统参数配置管理 +- 文件模板:文件模板管理与导出 +- 操作日志:记录用户关键操作,支持日志查询与审计 +- 登录日志:记录用户登录信息,支持 IP 归属地解析 +- 定时任务:基于 APScheduler 的任务调度,支持任务管理与执行日志 +- 文件上传:通用文件上传接口 +- 代码生成器:根据数据库表结构自动生成前后端模块代码 + +## 🚀 快速开始 + +### 环境要求 + +- **Python** >= 3.12 +- **Node.js** >= 22(前端项目) +- **pnpm** >= 7(前端项目) +- **MySQL** >= 8.0(默认,可切换其他数据库) +- **Redis** >= 6.0 + +## 🗄️ 数据库支持 + +框架默认支持 **MySQL** 数据库,同时也支持多种数据库,可根据实际业务场景灵活切换。 + +| 数据库 | 驱动 | DB_DRIVER 值 | 支持情况 | +| --- | --- | --- | --- | +| MySQL | PyMySQL 1.2.0 | `mysql`(默认) | ✅ 默认支持 | +| PostgreSQL | psycopg[binary] 3.x | `postgresql` | ✅ 支持 | +| SQL Server | pymssql 2.3.x | `mssql` | ✅ 支持 | +| SQLite | Python 内置 | `sqlite` | ✅ 支持 | +| Oracle | oracledb 2.x | `oracle` | ✅ 支持 | + +## 📁 项目结构 + +``` +├── app.py # 应用启动入口(python app.py) +├── generator.py # 代码生成器 CLI 入口 +├── config.json # 代码生成器配置 +├── pyproject.toml # Poetry 项目配置(依赖来源) +├── poetry.lock # 依赖锁定文件 +├── requirements.txt # Python 依赖清单(纯文本备选) +├── alembic.ini # Alembic 迁移配置 +├── Makefile # 常用命令快捷入口 +├── Dockerfile # 镜像构建 +├── docker-compose.yml # Docker 编排 +├── .env # 环境变量配置 +├── .env.example # 环境变量示例 +├── src/ # 后端源码目录 +│ ├── app.py # 应用工厂(create_app) +│ ├── bootstrap.py # 启动引导(参数解析、信号处理) +│ ├── api/ # API 路由层 +│ │ └── v1/ +│ │ ├── router.py # 路由集中注册(build_routes) +│ │ ├── auth.py # 登录/登出/验证码/令牌刷新 +│ │ ├── user.py # 用户管理 +│ │ ├── user_role.py # 用户角色分配 +│ │ ├── role.py # 角色管理 +│ │ ├── role_menu.py # 角色菜单分配 +│ │ ├── menu.py # 菜单管理 +│ │ ├── dept.py # 部门管理 +│ │ ├── level.py # 职级管理 +│ │ ├── position.py # 岗位管理 +│ │ ├── dict.py # 字典管理 +│ │ ├── dict_item.py # 字典项管理 +│ │ ├── config.py # 配置管理 +│ │ ├── config_item.py # 配置项管理 +│ │ ├── notice.py # 通知公告 +│ │ ├── article.py # 文章管理 +│ │ ├── category.py # 分类管理 +│ │ ├── city.py # 城市管理 +│ │ ├── link.py # 友情链接 +│ │ ├── param.py # 参数管理 +│ │ ├── job.py # 定时任务 +│ │ ├── job_log.py # 任务日志 +│ │ ├── file_template.py # 文件模板 +│ │ ├── login_log.py # 登录日志 +│ │ ├── operation_log.py # 操作日志 +│ │ ├── upload.py # 文件上传 +│ │ ├── index.py # 首页菜单/用户信息 +│ │ └── generator.py # 代码生成器 API +│ ├── core/ # 核心基础设施 +│ │ ├── base_handler.py # 请求处理器基类(认证、异常、会话回收) +│ │ ├── base_model.py # 数据模型基类(审计字段、to_dict) +│ │ ├── base_repository.py # 仓储基类(CRUD、分页、软删除) +│ │ ├── base_schema.py # Pydantic Schema 基类 +│ │ ├── base_service.py # 服务基类(模板方法 CRUD) +│ │ ├── context.py # 请求级上下文(current_user_id 等 contextvars) +│ │ ├── database.py # 数据库引擎/会话管理(读写分离) +│ │ ├── redis.py # Redis 客户端封装 +│ │ ├── jwt.py # JWT 签发/解析/黑名单/刷新 +│ │ ├── password.py # 双盐 bcrypt 密码加密 +│ │ ├── response.py # 统一响应封装(R.ok/R.failed) +│ │ ├── exceptions.py # 自定义异常体系 +│ │ ├── logger.py # 结构化日志配置 +│ │ ├── metrics.py # 指标采集 +│ │ └── tracer.py # 链路追踪 +│ ├── config/ # 配置模块(Pydantic Settings) +│ │ ├── app.py # 应用配置(TORNADO_DEMO 等) +│ │ ├── database.py # 数据库配置 +│ │ ├── redis.py # Redis 配置 +│ │ ├── auth.py # 认证配置(JWT) +│ │ ├── captcha.py # 验证码配置 +│ │ ├── email.py # 邮件配置 +│ │ └── logging.py # 日志配置 +│ ├── constants/ # 常量定义 +│ │ └── __init__.py # 通用常量与提示文案 +│ ├── common/ # 公共组件 +│ │ ├── middleware/ # 中间件/装饰器 +│ │ │ ├── authentication.py # JWT 认证(login_required) +│ │ │ ├── cors.py # 跨域处理 +│ │ │ ├── access_decorators.py # 权限校验 + 演示模式拦截 +│ │ │ ├── operation_log.py # 操作日志装饰器 +│ │ │ ├── login_log.py # 登录日志装饰器 +│ │ │ ├── trace_id.py # 链路追踪 ID 注入 +│ │ │ └── metrics.py # 请求指标中间件 +│ │ ├── permission.py # 跨模块权限解析(Redis 缓存) +│ │ └── utils/ # 工具函数 +│ │ ├── crypto.py # 密码加密门面 +│ │ ├── captcha.py # 验证码生成与验证 +│ │ ├── file.py # 文件操作(mkdir、魔术字节校验) +│ │ ├── rich_text.py # 富文本处理(XSS 清洗、路径穿越防护) +│ │ ├── ip.py # 客户端 IP 获取(代理透传) +│ │ ├── ip2region.py # IP 归属地离线查询 +│ │ ├── dict_util.py # 字典 Redis 缓存读取 +│ │ ├── helpers.py # 通用辅助(UA 解析、参数脱敏) +│ │ └── validators.py # 通用校验器 +│ ├── modules/ # 业务模块(每个模块 5 文件) +│ │ ├── auth/ # 认证模块(登录/登出/验证码/令牌刷新) +│ │ ├── user/ # 用户管理 +│ │ ├── user_role/ # 用户角色关联 +│ │ ├── role/ # 角色管理 +│ │ ├── role_menu/ # 角色菜单关联 +│ │ ├── menu/ # 菜单管理(动态路由 + 权限节点) +│ │ ├── dept/ # 部门管理 +│ │ ├── level/ # 职级管理 +│ │ ├── position/ # 岗位管理 +│ │ ├── dictionary/ # 字典模块 +│ │ │ ├── dict/ # 字典管理 +│ │ │ └── dict_item/ # 字典项管理 +│ │ ├── configuration/ # 配置模块 +│ │ │ ├── config/ # 配置管理 +│ │ │ └── config_item/ # 配置项管理 +│ │ ├── notice/ # 通知公告 +│ │ ├── article/ # 文章管理 +│ │ ├── category/ # 分类管理 +│ │ ├── city/ # 城市管理(树形结构) +│ │ ├── link/ # 友情链接 +│ │ ├── param/ # 参数管理 +│ │ ├── file_template/ # 文件模板 +│ │ ├── job/ # 定时任务(APScheduler) +│ │ ├── job_log/ # 任务执行日志 +│ │ ├── login_log/ # 登录日志(IP 归属地解析) +│ │ ├── operation_log/ # 操作日志(审计追踪) +│ │ ├── upload/ # 文件上传 +│ │ ├── generator/ # 代码生成器 +│ │ └── example/ # 示例模块(开发参考) +│ └── extensions/ # 扩展(可选组件) +│ ├── sqlalchemy.py # SQLAlchemy 单例导出 +│ ├── redis.py # Redis 单例导出 +│ ├── swagger.py # Swagger(占位) +│ └── sentry.py # Sentry(占位) +├── scripts/ # 运维脚本 +│ ├── seed_dict.py # 业务字典初始化(幂等) +│ ├── seed.py # 通用数据填充 +│ ├── init_db.py # 数据库初始化 +│ ├── migrate_db.py # 跨库数据迁移工具(MySQL→PG/MSSQL/SQLite/Oracle) +│ ├── migrate.py # 迁移辅助 +│ ├── deploy.py # 部署脚本 +│ ├── health_check.py # 健康检查 +│ └── deploy.py # 部署脚本 +├── tests/ # 测试目录 +│ ├── unit/ # 单元测试 +│ │ ├── core/ # 核心模块测试(jwt、crypto、response、base_service、repository) +│ │ └── modules/ # 业务模块测试(schema、scheduler) +│ ├── integration/ # 集成测试(需 MySQL + Redis) +│ │ ├── test_api.py # API 接口测试 +│ │ ├── test_database.py # 数据库连接测试 +│ │ └── test_redis.py # Redis 连接测试 +│ └── fixtures/ # 测试夹具 +│ └── factories.py # 测试数据工厂 +├── alembic/ # Alembic 数据库迁移 +│ └── versions/ # 迁移版本文件 +└── document/ # 项目文档 + ├── mysql/ # MySQL 初始化 SQL + ├── postgresql/ # PostgreSQL 初始化 SQL + ├── sqlserver/ # SQL Server 初始化 SQL + ├── sqlite/ # SQLite 初始化 SQL + └── sql/ # 通用 SQL 脚本 +``` + +## 👷 软件信息 + +- 软件名称:DjangoAdmin 敏捷开发框架 Tornado + AntDesign 版本 +- 软件作者:@小蚂蚁云 团队荣誉出品 +- 软件出处:小蚂蚁云团队 +- 软件协议:Apache-2.0 +- 官网网址: +- 文档网址: +- 演示地址: + +## 📌 版本说明 + +| 版本名称 | 版本说明 | 版本地址 | +| --- | --- | --- | +| Django + Layui 混编版 | 采用 Django、Layui 等框架研发 | | +| Flask + Layui 混编版 | 采用 Flask、Layui 等框架研发 | | +| FastAPI + Layui 混编版 | 采用 FastAPI、Layui 等框架研发 | | +| Tornado + Layui 混编版 | 采用 Tornado、Layui 等框架研发 | | +| Django + EleVue 前后端分离版 | 采用 Django、Vue3、TypeScript、ElementPlus 等框架研发前后端分离版本 | | +| Flask + EleVue 前后端分离版 | 采用 Flask、Vue3、TypeScript、ElementPlus 等框架研发前后端分离版本 | | +| FastAPI + EleVue 前后端分离版 | 采用 FastAPI、Vue3、TypeScript、ElementPlus 等框架研发前后端分离版本 | | +| Tornado + EleVue 前后端分离版 | 采用 Tornado、Vue3、TypeScript、ElementPlus 等框架研发前后端分离版本 | | +| Django + AntdVue 前后端分离版 | 采用 Django、Vue3、AntDesign 等框架研发前后端分离版本 | | +| Flask + AntdVue 前后端分离版 | 采用 Flask、Vue3、AntDesign 等框架研发前后端分离版本 | | +| FastAPI + AntdVue 前后端分离版 | 采用 FastAPI、Vue3、AntDesign 等框架研发前后端分离版本 | | +| Tornado + AntdVue 前后端分离版 | 采用 Tornado、Vue3、AntDesign 等框架研发前后端分离版本 | | +| Django + NaiveUI 前后端分离版 | 采用 Django、Vue3、TypeScript、NaiveUI 等框架研发前后端分离版本 | | +| Flask + NaiveUI 前后端分离版 | 采用 Flask、Vue3、TypeScript、NaiveUI 等框架研发前后端分离版本 | | +| FastAPI + NaiveUI 前后端分离版 | 采用 FastAPI、Vue3、TypeScript、NaiveUI 等框架研发前后端分离版本 | | +| Tornado + NaiveUI 前后端分离版 | 采用 Tornado、Vue3、TypeScript、NaiveUI 等框架研发前后端分离版本 | | + +## 🔧 模块展示 + +| | | +| --- | --- | +| ![模块展示1](./uploads/demo/1.png) | ![模块展示2](./uploads/demo/2.png) | +| ![模块展示3](./uploads/demo/3.png) | ![模块展示4](./uploads/demo/4.png) | +| ![模块展示5](./uploads/demo/5.png) | ![模块展示6](./uploads/demo/6.png) | +| ![模块展示7](./uploads/demo/7.png) | ![模块展示8](./uploads/demo/8.png) | +| ![模块展示9](./uploads/demo/9.png) | ![模块展示10](./uploads/demo/10.png) | +| ![模块展示11](./uploads/demo/11.png) | ![模块展示12](./uploads/demo/12.png) | +| ![模块展示13](./uploads/demo/13.png) | ![模块展示14](./uploads/demo/14.png) | +| ![模块展示15](./uploads/demo/15.png) | ![模块展示16](./uploads/demo/16.png) | +| ![模块展示17](./uploads/demo/17.png) | ![模块展示18](./uploads/demo/18.png) | +| ![模块展示19](./uploads/demo/19.png) | ![模块展示20](./uploads/demo/20.png) | +| ![模块展示21](./uploads/demo/21.png) | ![模块展示22](./uploads/demo/22.png) | +| ![模块展示23](./uploads/demo/23.png) | ![模块展示24](./uploads/demo/24.png) | +| ![模块展示25](./uploads/demo/25.png) | ![模块展示26](./uploads/demo/26.png) | +| ![模块展示27](./uploads/demo/27.png) | ![模块展示28](./uploads/demo/28.png) | + +## ✨ 特别鸣谢 + +感谢 [Tornado](https://www.tornadoweb.org/)、[SQLAlchemy](https://www.sqlalchemy.org/)、[Vue](https://cn.vuejs.org/)、[AntDesign](https://www.antdv.com/docs/vue/introduce-cn)、[Pydantic](https://docs.pydantic.dev/) 等优秀开源项目。 \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..4369353 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,111 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +;sqlalchemy.url = driver://user:pass@localhost/dbname +; DATABASE_URL is set dynamically in alembic/env.py from config/database.py +;sqlalchemy.url = mysql+pymysql://user:pass@host:port/dbname + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..33ca84e --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,117 @@ +# +====================================================================== +# | 模块: Alembic 迁移环境 +# | 说明: 配置数据库连接与模型 metadata,支持离线/在线两种迁移模式 +# +====================================================================== +"""Alembic 迁移环境。 + +- 数据库 URL 取自 src.config.database.DATABASE_URL +- 自动聚合 src.modules.*.models 中注册的模型 metadata,支持 autogenerate +""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:模块搜索路径、日志配置、路径处理、SQLAlchemy 引擎与连接池、 +# Alembic 上下文 +# ---------------------------------------------------------------------- +import sys +from logging.config import fileConfig +from pathlib import Path + +from sqlalchemy import create_engine, pool + +from alembic import context + +# ============================================================ +# 将 src 目录加入 sys.path +# ============================================================ +# 将 src 目录加入 sys.path,确保可直接 import 项目代码 +ROOT = Path(__file__).resolve().parent.parent +SRC = ROOT / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +from config.database import DATABASE_URL + +# ============================================================ +# 聚合全部模型 metadata +# ============================================================ +# 聚合全部模型 metadata(导入各模块 models 即完成注册) +from extensions.sqlalchemy import Base +import modules.auth.models +import modules.user.models +import modules.user_role.models +import modules.role.models +import modules.role_menu.models +import modules.menu.models +import modules.dept.models +import modules.category.models +import modules.article.models +import modules.param.models +import modules.file_template.models +import modules.job.models +import modules.dictionary.dict.models +import modules.dictionary.dict_item.models +import modules.configuration.config.models +import modules.configuration.config_item.models +import modules.level.models +import modules.position.models +import modules.city.models +import modules.notice.models +import modules.link.models +import modules.operation_log.models +import modules.login_log.models +import modules.job_log.models +import modules.example.models + +# ============================================================ +# 读取 Alembic 配置 +# ============================================================ +# 读取 alembic.ini 中的配置 +config = context.config + +# 存在配置文件时加载其日志配置 +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# 目标 metadata(autogenerate 依据) +target_metadata = Base.metadata + +# 数据库 URL +database_url = DATABASE_URL + + +# ====================================================================== +# 离线模式:仅生成 SQL 脚本 +# ====================================================================== +def run_migrations_offline() -> None: + """离线模式:仅生成 SQL 脚本。""" + context.configure( + url=database_url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +# ====================================================================== +# 在线模式:直接执行迁移 +# ====================================================================== +def run_migrations_online() -> None: + """在线模式:直接执行迁移。""" + # NullPool:迁移结束后即释放连接,无需连接池 + connectable = create_engine(database_url, poolclass=pool.NullPool) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +# ============================================================ +# 入口:按模式执行 +# ============================================================ +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/d6e1fac26659_初始化.py b/alembic/versions/d6e1fac26659_初始化.py new file mode 100644 index 0000000..e3039bb --- /dev/null +++ b/alembic/versions/d6e1fac26659_初始化.py @@ -0,0 +1,635 @@ +"""初始化 + +Revision ID: d6e1fac26659 +Revises: +Create Date: 2026-09-15 17:51:25.310996 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd6e1fac26659' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('tornado_article', + sa.Column('category_id', sa.Integer(), nullable=True, comment='分类ID'), + sa.Column('title', sa.String(length=150), nullable=True, comment='文章标题'), + sa.Column('cover', sa.String(length=255), nullable=True, comment='文章封面'), + sa.Column('images', sa.Text(), nullable=True, comment='文章图集'), + sa.Column('intro', sa.String(length=255), nullable=True, comment='文章导读'), + sa.Column('content', sa.Text(), nullable=True, comment='文章内容'), + sa.Column('author', sa.String(length=100), nullable=True, comment='文章作者'), + sa.Column('status', sa.Integer(), server_default=sa.text('0'), nullable=False, comment='文章状态:0-正常 1-下架'), + sa.Column('click', sa.Integer(), server_default=sa.text('0'), nullable=False, comment='文章点击率'), + sa.Column('sort', sa.Integer(), server_default=sa.text('0'), nullable=False, comment='文章排序'), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), + sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'), + sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'), + sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'), + sa.PrimaryKeyConstraint('id'), + comment='文章表' + ) + op.create_index(op.f('ix_tornado_article_category_id'), 'tornado_article', ['category_id'], unique=False) + op.create_index(op.f('ix_tornado_article_is_delete'), 'tornado_article', ['is_delete'], unique=False) + op.create_index(op.f('ix_tornado_article_status'), 'tornado_article', ['status'], unique=False) + op.create_index(op.f('ix_tornado_article_title'), 'tornado_article', ['title'], unique=False) + op.create_table('tornado_category', + sa.Column('category_name', sa.String(length=100), nullable=False, comment='分类名称'), + sa.Column('category_code', sa.String(length=100), nullable=True, comment='分类编码'), + sa.Column('parent_id', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='上级ID'), + sa.Column('sort', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='排序'), + sa.Column('note', sa.String(length=100), nullable=True, comment='分类备注'), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), + sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'), + sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'), + sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'), + sa.PrimaryKeyConstraint('id'), + comment='文章分类表' + ) + op.create_index(op.f('ix_tornado_category_category_name'), 'tornado_category', ['category_name'], unique=False) + op.create_index(op.f('ix_tornado_category_is_delete'), 'tornado_category', ['is_delete'], unique=False) + op.create_index(op.f('ix_tornado_category_parent_id'), 'tornado_category', ['parent_id'], unique=False) + op.create_table('tornado_city', + sa.Column('city_code', sa.String(length=6), nullable=False, comment='城市区号'), + sa.Column('area_code', sa.String(length=20), nullable=False, comment='行政编码'), + sa.Column('parent_code', sa.String(length=20), nullable=True, comment='上级行政编码'), + sa.Column('zip_code', sa.String(length=6), nullable=False, comment='邮政编码'), + sa.Column('level', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='城市级别:1-省份 2-城市 3-县区 4-街道'), + sa.Column('pid', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='上级城市ID'), + sa.Column('name', sa.String(length=150), nullable=False, comment='城市名称'), + sa.Column('short_name', sa.String(length=150), nullable=False, comment='城市简称'), + sa.Column('full_name', sa.String(length=150), nullable=True, comment='城市全称'), + sa.Column('pinyin', sa.String(length=150), nullable=True, comment='城市拼音'), + sa.Column('lng', sa.String(length=150), nullable=True, comment='城市经度'), + sa.Column('lat', sa.String(length=150), nullable=True, comment='城市纬度'), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), + sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'), + sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'), + sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'), + sa.PrimaryKeyConstraint('id'), + comment='城市行政区划表' + ) + op.create_index(op.f('ix_tornado_city_area_code'), 'tornado_city', ['area_code'], unique=False) + op.create_index(op.f('ix_tornado_city_city_code'), 'tornado_city', ['city_code'], unique=False) + op.create_index(op.f('ix_tornado_city_is_delete'), 'tornado_city', ['is_delete'], unique=False) + op.create_index(op.f('ix_tornado_city_level'), 'tornado_city', ['level'], unique=False) + op.create_index(op.f('ix_tornado_city_name'), 'tornado_city', ['name'], unique=False) + op.create_index(op.f('ix_tornado_city_parent_code'), 'tornado_city', ['parent_code'], unique=False) + op.create_index(op.f('ix_tornado_city_pid'), 'tornado_city', ['pid'], unique=False) + op.create_table('tornado_config', + sa.Column('name', sa.String(length=150), nullable=False, comment='配置名称'), + sa.Column('code', sa.String(length=150), nullable=False, comment='配置编码'), + sa.Column('sort', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='配置排序'), + sa.Column('note', sa.String(length=255), nullable=True, comment='配置备注'), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), + sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'), + sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'), + sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'), + sa.PrimaryKeyConstraint('id'), + comment='系统配置表' + ) + op.create_index(op.f('ix_tornado_config_code'), 'tornado_config', ['code'], unique=False) + op.create_index(op.f('ix_tornado_config_is_delete'), 'tornado_config', ['is_delete'], unique=False) + op.create_index(op.f('ix_tornado_config_name'), 'tornado_config', ['name'], unique=False) + op.create_table('tornado_config_item', + sa.Column('name', sa.String(length=100), nullable=False, comment='配置项名称'), + sa.Column('code', sa.String(length=100), nullable=False, comment='配置项编码'), + sa.Column('value', sa.String(length=1000), nullable=False, comment='配置项值'), + sa.Column('options', sa.String(length=255), nullable=False, comment='配置选项'), + sa.Column('config_id', sa.Integer(), server_default=sa.text('0'), nullable=False, comment='配置ID'), + sa.Column('type', sa.String(length=100), nullable=False, comment='配置类型'), + sa.Column('status', sa.Integer(), server_default=sa.text('1'), nullable=False, comment='配置状态:1-正常 2-停用'), + sa.Column('sort', sa.Integer(), server_default=sa.text('0'), nullable=False, comment='配置项排序'), + sa.Column('note', sa.String(length=255), nullable=True, comment='配置项备注'), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), + sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'), + sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'), + sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'), + sa.PrimaryKeyConstraint('id'), + comment='配置项表' + ) + op.create_index(op.f('ix_tornado_config_item_config_id'), 'tornado_config_item', ['config_id'], unique=False) + op.create_index(op.f('ix_tornado_config_item_is_delete'), 'tornado_config_item', ['is_delete'], unique=False) + op.create_index(op.f('ix_tornado_config_item_name'), 'tornado_config_item', ['name'], unique=False) + op.create_index(op.f('ix_tornado_config_item_status'), 'tornado_config_item', ['status'], unique=False) + op.create_table('tornado_dept', + sa.Column('name', sa.String(length=150), nullable=False, comment='部门名称'), + sa.Column('type', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='部门类型:1-公司 2-子公司 3-部门 4-小组'), + sa.Column('parent_id', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='上级部门ID'), + sa.Column('sort', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='部门排序'), + sa.Column('note', sa.String(length=255), nullable=True, comment='备注'), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), + sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'), + sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'), + sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'), + sa.PrimaryKeyConstraint('id'), + comment='部门表' + ) + op.create_index(op.f('ix_tornado_dept_is_delete'), 'tornado_dept', ['is_delete'], unique=False) + op.create_index(op.f('ix_tornado_dept_name'), 'tornado_dept', ['name'], unique=False) + op.create_index(op.f('ix_tornado_dept_parent_id'), 'tornado_dept', ['parent_id'], unique=False) + op.create_table('tornado_dict', + sa.Column('name', sa.String(length=150), nullable=False, comment='字典名称'), + sa.Column('code', sa.String(length=150), nullable=False, comment='字典编码'), + sa.Column('sort', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='字典排序'), + sa.Column('note', sa.String(length=255), nullable=True, comment='字典备注'), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), + sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'), + sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'), + sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'), + sa.PrimaryKeyConstraint('id'), + comment='数据字典表' + ) + op.create_index(op.f('ix_tornado_dict_code'), 'tornado_dict', ['code'], unique=False) + op.create_index(op.f('ix_tornado_dict_is_delete'), 'tornado_dict', ['is_delete'], unique=False) + op.create_index(op.f('ix_tornado_dict_name'), 'tornado_dict', ['name'], unique=False) + op.create_table('tornado_dict_item', + sa.Column('name', sa.String(length=150), nullable=False, comment='字典项名称'), + sa.Column('value', sa.String(length=150), nullable=False, comment='字典项值'), + sa.Column('dict_id', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='字典ID'), + sa.Column('sort', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='字典项顺序'), + sa.Column('note', sa.String(length=255), nullable=True, comment='字典项备注'), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), + sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'), + sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'), + sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'), + sa.PrimaryKeyConstraint('id'), + comment='字典项表' + ) + op.create_index(op.f('ix_tornado_dict_item_dict_id'), 'tornado_dict_item', ['dict_id'], unique=False) + op.create_index(op.f('ix_tornado_dict_item_is_delete'), 'tornado_dict_item', ['is_delete'], unique=False) + op.create_index(op.f('ix_tornado_dict_item_name'), 'tornado_dict_item', ['name'], unique=False) + op.create_table('tornado_file_template', + sa.Column('name', sa.String(length=100), nullable=True, comment='模板名称'), + sa.Column('code', sa.String(length=50), nullable=True, comment='模板编码'), + sa.Column('file_name', sa.String(length=255), nullable=True, comment='文件名称'), + sa.Column('file_path', sa.String(length=255), nullable=True, comment='文件路径'), + sa.Column('note', sa.String(length=255), nullable=True, comment='文件模板备注'), + sa.Column('sort', sa.Integer(), server_default=sa.text('0'), nullable=False, comment='文件模板顺序'), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), + sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'), + sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'), + sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'), + sa.PrimaryKeyConstraint('id'), + comment='文件模板表' + ) + op.create_index(op.f('ix_tornado_file_template_code'), 'tornado_file_template', ['code'], unique=False) + op.create_index(op.f('ix_tornado_file_template_is_delete'), 'tornado_file_template', ['is_delete'], unique=False) + op.create_index(op.f('ix_tornado_file_template_name'), 'tornado_file_template', ['name'], unique=False) + op.create_table('tornado_job', + sa.Column('job_name', sa.String(length=255), nullable=False, comment='任务名称'), + sa.Column('job_alias', sa.String(length=255), nullable=True, comment='任务别名'), + sa.Column('job_group', sa.String(length=255), nullable=True, comment='任务分组'), + sa.Column('job_trigger', sa.String(length=255), nullable=True, comment='任务触发器'), + sa.Column('status', sa.SmallInteger(), server_default=sa.text('0'), nullable=False, comment='任务状态:0-未发布 1-运行中 2-暂停 3-删除'), + sa.Column('cron_expression', sa.String(length=255), nullable=True, comment='Cron表达式'), + sa.Column('execute_policy', sa.SmallInteger(), server_default=sa.text('1'), nullable=False, comment='执行策略:1-立即执行 2-执行一次 3-放弃执行'), + sa.Column('is_sync', sa.SmallInteger(), server_default=sa.text('0'), nullable=False, comment='是否同步任务:0-否 1-是'), + sa.Column('url', sa.String(length=255), nullable=True, comment='任务URL'), + sa.Column('params', sa.Text(), nullable=True, comment='任务参数'), + sa.Column('note', sa.String(length=255), nullable=True, comment='任务备注'), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), + sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'), + sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'), + sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'), + sa.PrimaryKeyConstraint('id'), + comment='定时任务表' + ) + op.create_index(op.f('ix_tornado_job_is_delete'), 'tornado_job', ['is_delete'], unique=False) + op.create_index(op.f('ix_tornado_job_job_name'), 'tornado_job', ['job_name'], unique=False) + op.create_index(op.f('ix_tornado_job_status'), 'tornado_job', ['status'], unique=False) + op.create_table('tornado_job_log', + sa.Column('job_id', sa.Integer(), server_default=sa.text('0'), nullable=False, comment='任务ID'), + sa.Column('job_name', sa.String(length=255), nullable=False, comment='任务名称'), + sa.Column('job_group', sa.String(length=255), nullable=True, comment='任务分组'), + sa.Column('job_trigger', sa.String(length=255), nullable=True, comment='任务触发器'), + sa.Column('job_message', sa.String(length=500), nullable=True, comment='任务日志信息'), + sa.Column('cron_expression', sa.String(length=255), nullable=True, comment='Cron表达式'), + sa.Column('status', sa.SmallInteger(), server_default=sa.text('0'), nullable=False, comment='执行状态:0-正常 1-失败'), + sa.Column('start_time', sa.DateTime(), nullable=True, comment='执行开始时间'), + sa.Column('end_time', sa.DateTime(), nullable=True, comment='执行结束时间'), + sa.Column('consume_time', sa.BigInteger(), server_default=sa.text('0'), nullable=False, comment='执行耗时(毫秒)'), + sa.Column('exception_info', sa.Text(), nullable=True, comment='异常信息'), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), + sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'), + sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'), + sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'), + sa.PrimaryKeyConstraint('id'), + comment='定时任务日志表' + ) + op.create_index(op.f('ix_tornado_job_log_is_delete'), 'tornado_job_log', ['is_delete'], unique=False) + op.create_index(op.f('ix_tornado_job_log_job_id'), 'tornado_job_log', ['job_id'], unique=False) + op.create_index(op.f('ix_tornado_job_log_status'), 'tornado_job_log', ['status'], unique=False) + op.create_table('tornado_level', + sa.Column('name', sa.String(length=255), nullable=False, comment='职级名称'), + sa.Column('status', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='职级状态:1-在用 2-停用'), + sa.Column('sort', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='职级排序'), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), + sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'), + sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'), + sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'), + sa.PrimaryKeyConstraint('id'), + comment='职级表' + ) + op.create_index(op.f('ix_tornado_level_is_delete'), 'tornado_level', ['is_delete'], unique=False) + op.create_index(op.f('ix_tornado_level_name'), 'tornado_level', ['name'], unique=False) + op.create_index(op.f('ix_tornado_level_status'), 'tornado_level', ['status'], unique=False) + op.create_table('tornado_link', + sa.Column('name', sa.String(length=255), nullable=False, comment='友链名称'), + sa.Column('type', sa.Integer(), server_default=sa.text('1'), nullable=True, comment='友链类型:1-友情链接 2-合作伙伴'), + sa.Column('url', sa.String(length=255), nullable=True, comment='友链URL'), + sa.Column('form', sa.Integer(), server_default=sa.text('1'), nullable=True, comment='友链形式:1-文字链接 2-图片链接'), + sa.Column('image', sa.String(length=255), nullable=True, comment='友链图片'), + sa.Column('status', sa.Integer(), server_default=sa.text('1'), nullable=True, comment='友链状态:1-正常 2-停用'), + sa.Column('sort', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='友链顺序'), + sa.Column('note', sa.String(length=255), nullable=True, comment='友链备注'), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), + sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'), + sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'), + sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'), + sa.PrimaryKeyConstraint('id'), + comment='友链表' + ) + op.create_index(op.f('ix_tornado_link_is_delete'), 'tornado_link', ['is_delete'], unique=False) + op.create_index(op.f('ix_tornado_link_name'), 'tornado_link', ['name'], unique=False) + op.create_index(op.f('ix_tornado_link_status'), 'tornado_link', ['status'], unique=False) + op.create_table('tornado_login_log', + sa.Column('username', sa.String(length=50), nullable=False, comment='登录账号'), + sa.Column('log_type', sa.Integer(), server_default=sa.text('1'), nullable=False, comment='日志类型:1-登录 2-退出'), + sa.Column('source', sa.Integer(), server_default=sa.text('0'), nullable=False, comment='登录来源:0-后台 1-前台'), + sa.Column('method', sa.String(length=10), nullable=False, comment='请求方法'), + sa.Column('request_method', sa.String(length=10), nullable=False, comment='请求方式'), + sa.Column('url', sa.String(length=500), nullable=False, comment='请求URL'), + sa.Column('param', sa.Text(), nullable=True, comment='请求参数'), + sa.Column('result', sa.Text(), nullable=False, comment='返回结果'), + sa.Column('ip', sa.String(length=50), nullable=False, comment='IP地址'), + sa.Column('location', sa.String(length=100), nullable=False, comment='IP归属地'), + sa.Column('os', sa.String(length=100), nullable=False, comment='操作系统'), + sa.Column('browser', sa.String(length=100), nullable=False, comment='浏览器'), + sa.Column('consume_time', sa.Integer(), server_default=sa.text('0'), nullable=False, comment='请求耗时(毫秒)'), + sa.Column('status', sa.Integer(), server_default=sa.text('0'), nullable=False, comment='操作状态:0-正常 1-异常'), + sa.Column('error', sa.Text(), nullable=True, comment='错误信息'), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), + sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'), + sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'), + sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'), + sa.PrimaryKeyConstraint('id'), + comment='登录日志表' + ) + op.create_index(op.f('ix_tornado_login_log_is_delete'), 'tornado_login_log', ['is_delete'], unique=False) + op.create_index(op.f('ix_tornado_login_log_log_type'), 'tornado_login_log', ['log_type'], unique=False) + op.create_index(op.f('ix_tornado_login_log_username'), 'tornado_login_log', ['username'], unique=False) + op.create_table('tornado_menu', + sa.Column('name', sa.String(length=150), nullable=False, comment='菜单名称'), + sa.Column('icon', sa.String(length=50), nullable=True, comment='菜单图标'), + sa.Column('icon2', sa.String(length=50), nullable=True, comment='菜单图标'), + sa.Column('parent_id', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='上级ID'), + sa.Column('path', sa.String(length=255), nullable=True, comment='菜单路径'), + sa.Column('component', sa.String(length=255), nullable=True, comment='菜单组件'), + sa.Column('permission', sa.String(length=150), nullable=True, comment='权限节点'), + sa.Column('target', sa.String(length=30), nullable=True, comment='打开方式:1-内部打开 2-外部打开'), + sa.Column('type', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='菜单类型:0-菜单 1-节点'), + sa.Column('status', sa.Integer(), server_default=sa.text('1'), nullable=True, comment='菜单状态:1-正常 2-停用'), + sa.Column('hide', sa.Integer(), server_default=sa.text('1'), nullable=True, comment='是否可见:0-可见 1-不可见'), + sa.Column('sort', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='排序'), + sa.Column('note', sa.String(length=255), nullable=True, comment='备注'), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), + sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'), + sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'), + sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'), + sa.PrimaryKeyConstraint('id'), + comment='菜单表' + ) + op.create_index(op.f('ix_tornado_menu_is_delete'), 'tornado_menu', ['is_delete'], unique=False) + op.create_index(op.f('ix_tornado_menu_name'), 'tornado_menu', ['name'], unique=False) + op.create_index(op.f('ix_tornado_menu_parent_id'), 'tornado_menu', ['parent_id'], unique=False) + op.create_index(op.f('ix_tornado_menu_permission'), 'tornado_menu', ['permission'], unique=False) + op.create_index(op.f('ix_tornado_menu_status'), 'tornado_menu', ['status'], unique=False) + op.create_index(op.f('ix_tornado_menu_type'), 'tornado_menu', ['type'], unique=False) + op.create_table('tornado_notice', + sa.Column('title', sa.String(length=255), nullable=False, comment='通知标题'), + sa.Column('cover', sa.String(length=255), nullable=True, comment='通知封面'), + sa.Column('type', sa.Integer(), server_default=sa.text('1'), nullable=True, comment='通知类型:1-通知 2-公告'), + sa.Column('click_num', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='点击率'), + sa.Column('status', sa.Integer(), server_default=sa.text('1'), nullable=True, comment='通知状态:1-正常 2-停用'), + sa.Column('content', sa.Text(), nullable=True, comment='通知公告内容'), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), + sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'), + sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'), + sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'), + sa.PrimaryKeyConstraint('id'), + comment='通知公告表' + ) + op.create_index(op.f('ix_tornado_notice_is_delete'), 'tornado_notice', ['is_delete'], unique=False) + op.create_index(op.f('ix_tornado_notice_status'), 'tornado_notice', ['status'], unique=False) + op.create_index(op.f('ix_tornado_notice_title'), 'tornado_notice', ['title'], unique=False) + op.create_index(op.f('ix_tornado_notice_type'), 'tornado_notice', ['type'], unique=False) + op.create_table('tornado_operation_log', + sa.Column('title', sa.String(length=150), nullable=False, comment='请求标题'), + sa.Column('type', sa.Integer(), server_default=sa.text('1'), nullable=False, comment='请求类型:1-新增 2-修改 3-删除'), + sa.Column('source', sa.SmallInteger(), server_default=sa.text('1'), nullable=False, comment='请求来源:1-PC端 2-移动端'), + sa.Column('method', sa.String(length=100), nullable=False, comment='请求方法(操作)'), + sa.Column('request_method', sa.String(length=100), nullable=False, comment='请求方式(HTTP)'), + sa.Column('url', sa.String(length=1000), nullable=False, comment='请求URL'), + sa.Column('param', sa.Text(), nullable=True, comment='请求参数'), + sa.Column('result', sa.Text(), nullable=True, comment='请求结果'), + sa.Column('ip', sa.String(length=100), nullable=False, comment='请求IP'), + sa.Column('location', sa.String(length=255), nullable=False, comment='请求所在区域'), + sa.Column('os', sa.String(length=100), nullable=False, comment='操作系统'), + sa.Column('browser', sa.String(length=100), nullable=False, comment='浏览器'), + sa.Column('consume_time', sa.BigInteger(), server_default=sa.text('0'), nullable=False, comment='请求耗时(毫秒)'), + sa.Column('status', sa.SmallInteger(), server_default=sa.text('0'), nullable=False, comment='请求状态:0-成功 1-失败'), + sa.Column('error', sa.Text(), nullable=True, comment='错误描述'), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), + sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'), + sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'), + sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'), + sa.PrimaryKeyConstraint('id'), + comment='操作日志表' + ) + op.create_index(op.f('ix_tornado_operation_log_is_delete'), 'tornado_operation_log', ['is_delete'], unique=False) + op.create_index(op.f('ix_tornado_operation_log_title'), 'tornado_operation_log', ['title'], unique=False) + op.create_index(op.f('ix_tornado_operation_log_type'), 'tornado_operation_log', ['type'], unique=False) + op.create_table('tornado_param', + sa.Column('name', sa.String(length=100), nullable=False, comment='参数名称'), + sa.Column('code', sa.String(length=100), nullable=False, comment='参数编码'), + sa.Column('value', sa.String(length=255), nullable=False, comment='参数值'), + sa.Column('type', sa.Integer(), server_default=sa.text('0'), nullable=False, comment='参数类型:0-系统 1-业务'), + sa.Column('status', sa.Integer(), server_default=sa.text('1'), nullable=False, comment='参数状态:1-正常 2-禁用'), + sa.Column('sort', sa.Integer(), server_default=sa.text('0'), nullable=False, comment='参数排序'), + sa.Column('note', sa.String(length=255), nullable=True, comment='参数备注'), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), + sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'), + sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'), + sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'), + sa.PrimaryKeyConstraint('id'), + comment='系统参数表' + ) + op.create_index(op.f('ix_tornado_param_code'), 'tornado_param', ['code'], unique=False) + op.create_index(op.f('ix_tornado_param_is_delete'), 'tornado_param', ['is_delete'], unique=False) + op.create_index(op.f('ix_tornado_param_name'), 'tornado_param', ['name'], unique=False) + op.create_index(op.f('ix_tornado_param_status'), 'tornado_param', ['status'], unique=False) + op.create_table('tornado_position', + sa.Column('name', sa.String(length=255), nullable=False, comment='岗位名称'), + sa.Column('status', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='岗位状态:1-在用 2-停用'), + sa.Column('sort', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='岗位排序'), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), + sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'), + sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'), + sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'), + sa.PrimaryKeyConstraint('id'), + comment='岗位表' + ) + op.create_index(op.f('ix_tornado_position_is_delete'), 'tornado_position', ['is_delete'], unique=False) + op.create_index(op.f('ix_tornado_position_name'), 'tornado_position', ['name'], unique=False) + op.create_index(op.f('ix_tornado_position_status'), 'tornado_position', ['status'], unique=False) + op.create_table('tornado_role', + sa.Column('name', sa.String(length=150), nullable=False, comment='角色名称'), + sa.Column('code', sa.String(length=30), nullable=False, comment='角色编码'), + sa.Column('sort', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='角色排序'), + sa.Column('note', sa.String(length=255), nullable=True, comment='角色备注'), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), + sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'), + sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'), + sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'), + sa.PrimaryKeyConstraint('id'), + comment='角色表' + ) + op.create_index(op.f('ix_tornado_role_code'), 'tornado_role', ['code'], unique=False) + op.create_index(op.f('ix_tornado_role_is_delete'), 'tornado_role', ['is_delete'], unique=False) + op.create_index(op.f('ix_tornado_role_name'), 'tornado_role', ['name'], unique=False) + op.create_table('tornado_role_menu', + sa.Column('role_id', sa.Integer(), server_default=sa.text('0'), nullable=False, comment='角色ID'), + sa.Column('menu_id', sa.Integer(), server_default=sa.text('0'), nullable=False, comment='菜单ID'), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), + sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'), + sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'), + sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'), + sa.PrimaryKeyConstraint('id'), + comment='角色菜单关联表' + ) + op.create_index(op.f('ix_tornado_role_menu_is_delete'), 'tornado_role_menu', ['is_delete'], unique=False) + op.create_index(op.f('ix_tornado_role_menu_menu_id'), 'tornado_role_menu', ['menu_id'], unique=False) + op.create_index(op.f('ix_tornado_role_menu_role_id'), 'tornado_role_menu', ['role_id'], unique=False) + op.create_table('tornado_user', + sa.Column('code', sa.String(length=100), server_default=sa.text("''"), nullable=False, comment='用户编码'), + sa.Column('realname', sa.String(length=150), nullable=False, comment='用户姓名'), + sa.Column('gender', sa.Integer(), server_default=sa.text('1'), nullable=True, comment='性别:1-男 2-女 3-保密'), + sa.Column('avatar', sa.String(length=255), nullable=False, comment='用户头像'), + sa.Column('mobile', sa.String(length=30), nullable=False, comment='手机号'), + sa.Column('email', sa.String(length=30), nullable=False, comment='邮箱'), + sa.Column('dept_id', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='部门ID'), + sa.Column('level_id', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='职级ID'), + sa.Column('position_id', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='岗位ID'), + sa.Column('province_code', sa.String(length=30), nullable=False, comment='省份编码'), + sa.Column('city_code', sa.String(length=30), nullable=False, comment='城市编码'), + sa.Column('district_code', sa.String(length=30), nullable=False, comment='县区编码'), + sa.Column('street_code', sa.String(length=30), nullable=False, comment='街道编码'), + sa.Column('city_info', sa.String(length=255), nullable=True, comment='省市区信息'), + sa.Column('address', sa.String(length=255), nullable=False, comment='详细地址'), + sa.Column('username', sa.String(length=30), nullable=True, comment='用户名'), + sa.Column('password', sa.String(length=255), nullable=True, comment='密码'), + sa.Column('salt', sa.String(length=30), nullable=True, comment='加密盐'), + sa.Column('intro', sa.String(length=255), nullable=True, comment='个人简介'), + sa.Column('status', sa.Integer(), server_default=sa.text('1'), nullable=True, comment='状态:1-正常 2-禁用'), + sa.Column('sort', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='排序'), + sa.Column('note', sa.String(length=255), nullable=True, comment='个人备注'), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), + sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'), + sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'), + sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'), + sa.PrimaryKeyConstraint('id'), + comment='用户表' + ) + op.create_index(op.f('ix_tornado_user_code'), 'tornado_user', ['code'], unique=False) + op.create_index(op.f('ix_tornado_user_dept_id'), 'tornado_user', ['dept_id'], unique=False) + op.create_index(op.f('ix_tornado_user_email'), 'tornado_user', ['email'], unique=False) + op.create_index(op.f('ix_tornado_user_is_delete'), 'tornado_user', ['is_delete'], unique=False) + op.create_index(op.f('ix_tornado_user_mobile'), 'tornado_user', ['mobile'], unique=False) + op.create_index(op.f('ix_tornado_user_realname'), 'tornado_user', ['realname'], unique=False) + op.create_index(op.f('ix_tornado_user_status'), 'tornado_user', ['status'], unique=False) + op.create_index(op.f('ix_tornado_user_username'), 'tornado_user', ['username'], unique=False) + op.create_table('tornado_user_role', + sa.Column('user_id', sa.Integer(), server_default=sa.text('0'), nullable=False, comment='用户ID'), + sa.Column('role_id', sa.Integer(), server_default=sa.text('0'), nullable=False, comment='角色ID'), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), + sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'), + sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'), + sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'), + sa.PrimaryKeyConstraint('id'), + comment='用户角色关联表' + ) + op.create_index(op.f('ix_tornado_user_role_is_delete'), 'tornado_user_role', ['is_delete'], unique=False) + op.create_index(op.f('ix_tornado_user_role_role_id'), 'tornado_user_role', ['role_id'], unique=False) + op.create_index(op.f('ix_tornado_user_role_user_id'), 'tornado_user_role', ['user_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_tornado_user_role_user_id'), table_name='tornado_user_role') + op.drop_index(op.f('ix_tornado_user_role_role_id'), table_name='tornado_user_role') + op.drop_index(op.f('ix_tornado_user_role_is_delete'), table_name='tornado_user_role') + op.drop_table('tornado_user_role') + op.drop_index(op.f('ix_tornado_user_username'), table_name='tornado_user') + op.drop_index(op.f('ix_tornado_user_status'), table_name='tornado_user') + op.drop_index(op.f('ix_tornado_user_realname'), table_name='tornado_user') + op.drop_index(op.f('ix_tornado_user_mobile'), table_name='tornado_user') + op.drop_index(op.f('ix_tornado_user_is_delete'), table_name='tornado_user') + op.drop_index(op.f('ix_tornado_user_email'), table_name='tornado_user') + op.drop_index(op.f('ix_tornado_user_dept_id'), table_name='tornado_user') + op.drop_index(op.f('ix_tornado_user_code'), table_name='tornado_user') + op.drop_table('tornado_user') + op.drop_index(op.f('ix_tornado_role_menu_role_id'), table_name='tornado_role_menu') + op.drop_index(op.f('ix_tornado_role_menu_menu_id'), table_name='tornado_role_menu') + op.drop_index(op.f('ix_tornado_role_menu_is_delete'), table_name='tornado_role_menu') + op.drop_table('tornado_role_menu') + op.drop_index(op.f('ix_tornado_role_name'), table_name='tornado_role') + op.drop_index(op.f('ix_tornado_role_is_delete'), table_name='tornado_role') + op.drop_index(op.f('ix_tornado_role_code'), table_name='tornado_role') + op.drop_table('tornado_role') + op.drop_index(op.f('ix_tornado_position_status'), table_name='tornado_position') + op.drop_index(op.f('ix_tornado_position_name'), table_name='tornado_position') + op.drop_index(op.f('ix_tornado_position_is_delete'), table_name='tornado_position') + op.drop_table('tornado_position') + op.drop_index(op.f('ix_tornado_param_status'), table_name='tornado_param') + op.drop_index(op.f('ix_tornado_param_name'), table_name='tornado_param') + op.drop_index(op.f('ix_tornado_param_is_delete'), table_name='tornado_param') + op.drop_index(op.f('ix_tornado_param_code'), table_name='tornado_param') + op.drop_table('tornado_param') + op.drop_index(op.f('ix_tornado_operation_log_type'), table_name='tornado_operation_log') + op.drop_index(op.f('ix_tornado_operation_log_title'), table_name='tornado_operation_log') + op.drop_index(op.f('ix_tornado_operation_log_is_delete'), table_name='tornado_operation_log') + op.drop_table('tornado_operation_log') + op.drop_index(op.f('ix_tornado_notice_type'), table_name='tornado_notice') + op.drop_index(op.f('ix_tornado_notice_title'), table_name='tornado_notice') + op.drop_index(op.f('ix_tornado_notice_status'), table_name='tornado_notice') + op.drop_index(op.f('ix_tornado_notice_is_delete'), table_name='tornado_notice') + op.drop_table('tornado_notice') + op.drop_index(op.f('ix_tornado_menu_type'), table_name='tornado_menu') + op.drop_index(op.f('ix_tornado_menu_status'), table_name='tornado_menu') + op.drop_index(op.f('ix_tornado_menu_permission'), table_name='tornado_menu') + op.drop_index(op.f('ix_tornado_menu_parent_id'), table_name='tornado_menu') + op.drop_index(op.f('ix_tornado_menu_name'), table_name='tornado_menu') + op.drop_index(op.f('ix_tornado_menu_is_delete'), table_name='tornado_menu') + op.drop_table('tornado_menu') + op.drop_index(op.f('ix_tornado_login_log_username'), table_name='tornado_login_log') + op.drop_index(op.f('ix_tornado_login_log_log_type'), table_name='tornado_login_log') + op.drop_index(op.f('ix_tornado_login_log_is_delete'), table_name='tornado_login_log') + op.drop_table('tornado_login_log') + op.drop_index(op.f('ix_tornado_link_status'), table_name='tornado_link') + op.drop_index(op.f('ix_tornado_link_name'), table_name='tornado_link') + op.drop_index(op.f('ix_tornado_link_is_delete'), table_name='tornado_link') + op.drop_table('tornado_link') + op.drop_index(op.f('ix_tornado_level_status'), table_name='tornado_level') + op.drop_index(op.f('ix_tornado_level_name'), table_name='tornado_level') + op.drop_index(op.f('ix_tornado_level_is_delete'), table_name='tornado_level') + op.drop_table('tornado_level') + op.drop_index(op.f('ix_tornado_job_log_status'), table_name='tornado_job_log') + op.drop_index(op.f('ix_tornado_job_log_job_id'), table_name='tornado_job_log') + op.drop_index(op.f('ix_tornado_job_log_is_delete'), table_name='tornado_job_log') + op.drop_table('tornado_job_log') + op.drop_index(op.f('ix_tornado_job_status'), table_name='tornado_job') + op.drop_index(op.f('ix_tornado_job_job_name'), table_name='tornado_job') + op.drop_index(op.f('ix_tornado_job_is_delete'), table_name='tornado_job') + op.drop_table('tornado_job') + op.drop_index(op.f('ix_tornado_file_template_name'), table_name='tornado_file_template') + op.drop_index(op.f('ix_tornado_file_template_is_delete'), table_name='tornado_file_template') + op.drop_index(op.f('ix_tornado_file_template_code'), table_name='tornado_file_template') + op.drop_table('tornado_file_template') + op.drop_index(op.f('ix_tornado_dict_item_name'), table_name='tornado_dict_item') + op.drop_index(op.f('ix_tornado_dict_item_is_delete'), table_name='tornado_dict_item') + op.drop_index(op.f('ix_tornado_dict_item_dict_id'), table_name='tornado_dict_item') + op.drop_table('tornado_dict_item') + op.drop_index(op.f('ix_tornado_dict_name'), table_name='tornado_dict') + op.drop_index(op.f('ix_tornado_dict_is_delete'), table_name='tornado_dict') + op.drop_index(op.f('ix_tornado_dict_code'), table_name='tornado_dict') + op.drop_table('tornado_dict') + op.drop_index(op.f('ix_tornado_dept_parent_id'), table_name='tornado_dept') + op.drop_index(op.f('ix_tornado_dept_name'), table_name='tornado_dept') + op.drop_index(op.f('ix_tornado_dept_is_delete'), table_name='tornado_dept') + op.drop_table('tornado_dept') + op.drop_index(op.f('ix_tornado_config_item_status'), table_name='tornado_config_item') + op.drop_index(op.f('ix_tornado_config_item_name'), table_name='tornado_config_item') + op.drop_index(op.f('ix_tornado_config_item_is_delete'), table_name='tornado_config_item') + op.drop_index(op.f('ix_tornado_config_item_config_id'), table_name='tornado_config_item') + op.drop_table('tornado_config_item') + op.drop_index(op.f('ix_tornado_config_name'), table_name='tornado_config') + op.drop_index(op.f('ix_tornado_config_is_delete'), table_name='tornado_config') + op.drop_index(op.f('ix_tornado_config_code'), table_name='tornado_config') + op.drop_table('tornado_config') + op.drop_index(op.f('ix_tornado_city_pid'), table_name='tornado_city') + op.drop_index(op.f('ix_tornado_city_parent_code'), table_name='tornado_city') + op.drop_index(op.f('ix_tornado_city_name'), table_name='tornado_city') + op.drop_index(op.f('ix_tornado_city_level'), table_name='tornado_city') + op.drop_index(op.f('ix_tornado_city_is_delete'), table_name='tornado_city') + op.drop_index(op.f('ix_tornado_city_city_code'), table_name='tornado_city') + op.drop_index(op.f('ix_tornado_city_area_code'), table_name='tornado_city') + op.drop_table('tornado_city') + op.drop_index(op.f('ix_tornado_category_parent_id'), table_name='tornado_category') + op.drop_index(op.f('ix_tornado_category_is_delete'), table_name='tornado_category') + op.drop_index(op.f('ix_tornado_category_category_name'), table_name='tornado_category') + op.drop_table('tornado_category') + op.drop_index(op.f('ix_tornado_article_title'), table_name='tornado_article') + op.drop_index(op.f('ix_tornado_article_status'), table_name='tornado_article') + op.drop_index(op.f('ix_tornado_article_is_delete'), table_name='tornado_article') + op.drop_index(op.f('ix_tornado_article_category_id'), table_name='tornado_article') + op.drop_table('tornado_article') + # ### end Alembic commands ### diff --git a/app.py b/app.py new file mode 100644 index 0000000..0d9d8c0 --- /dev/null +++ b/app.py @@ -0,0 +1,27 @@ +# +====================================================================== +# | 模块: 应用入口层 +# | 说明: 极简入口,将 src 加入模块搜索路径后交由 bootstrap 启动服务 +# +====================================================================== +"""应用入口(极简版)。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:操作系统接口、模块搜索路径管理 +# ---------------------------------------------------------------------- +import os +import sys + +# ============================================================ +# 将 src 目录加入 sys.path +# ============================================================ +# 将 src 目录加入 sys.path(未安装 poetry 时也可直接运行) +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "src")) + +from bootstrap import main + +# ============================================================ +# 入口 +# ============================================================ +if __name__ == '__main__': + # 启动应用 + main() diff --git a/config.json b/config.json new file mode 100644 index 0000000..0976e61 --- /dev/null +++ b/config.json @@ -0,0 +1,212 @@ +{ + "app_name": "example", + "module_comment": "案例演示", + "model_name": "example", + "model_class_name": "Example", + "route_prefix": "example", + "business_description": "标识系统中的案例演示数据", + "business_description_detail": "包含案例名称, 案例图片, 案例类型:1-类型1 2-类型2 3-类型3 4-类型4等属性", + "permission_prefix": "sys:example", + "primary_key": "id", + "display_field": "name", + "display_field_camel": "name", + "table_name": "example", + "table_prefix": [ + "tornado_" + ], + "remove_prefix": true, + "menu_group": "tool", + "parent_menu_id": 0, + "menu_sort": 5, + "has_sort": true, + "has_status": true, + "has_unique_code": false, + "has_export": false, + "has_image_field": true, + "has_rich_text_field": false, + "fields": [ + { + "name": "name", + "camel_name": "name", + "comment": "案例名称", + "py_comment": "案例名称", + "purpose": "存储案例名称信息", + "db_type": "String", + "db_args": "255", + "form_type": "str", + "type_display": "字符串", + "max_length": 255, + "nullable": false, + "required": true, + "is_unique": false, + "db_index": true, + "default": null, + "default_repr": null, + "in_form": true, + "in_list": true, + "filterable": true, + "filter_type": "模糊查询", + "editable": true, + "searchable": true, + "is_image": false, + "is_rich_text": false, + "is_status": false, + "is_sort": false, + "choices": null, + "min_value": null, + "max_value": null + }, + { + "name": "avatar", + "camel_name": "avatar", + "comment": "案例图片", + "py_comment": "案例图片", + "purpose": "存储案例图片信息", + "db_type": "String", + "db_args": "255", + "form_type": "str", + "type_display": "字符串", + "max_length": 255, + "nullable": false, + "required": true, + "is_unique": false, + "db_index": false, + "default": null, + "default_repr": null, + "in_form": true, + "in_list": true, + "filterable": false, + "filter_type": "", + "editable": true, + "searchable": false, + "is_image": true, + "is_rich_text": false, + "is_status": false, + "is_sort": false, + "choices": null, + "min_value": null, + "max_value": null + }, + { + "name": "type", + "camel_name": "type", + "comment": "案例类型:1-类型1 2-类型2 3-类型3 4-类型4", + "py_comment": "案例类型:1-类型1 2-类型2 3-类型3 4-类型4", + "purpose": "存储案例类型:1-类型1 2-类型2 3-类型3 4-类型4信息", + "db_type": "Integer", + "db_args": null, + "form_type": "int", + "type_display": "整数", + "max_length": null, + "nullable": true, + "required": false, + "is_unique": false, + "db_index": false, + "default": 0, + "default_repr": "0", + "in_form": true, + "in_list": true, + "filterable": true, + "filter_type": "精确匹配", + "editable": true, + "searchable": false, + "is_image": false, + "is_rich_text": false, + "is_status": false, + "is_sort": false, + "choices": [ + [ + 1, + "类型1" + ], + [ + 2, + "类型2" + ], + [ + 3, + "类型3" + ], + [ + 4, + "类型4" + ] + ], + "min_value": 1, + "max_value": 4, + "dict_code": "example_type" + }, + { + "name": "status", + "camel_name": "status", + "comment": "案例状态:1-在用 2-停用", + "py_comment": "案例状态:1-在用 2-停用", + "purpose": "存储案例状态:1-在用 2-停用信息", + "db_type": "Integer", + "db_args": null, + "form_type": "int", + "type_display": "整数", + "max_length": null, + "nullable": true, + "required": false, + "is_unique": false, + "db_index": true, + "default": 0, + "default_repr": "0", + "in_form": true, + "in_list": true, + "filterable": true, + "filter_type": "精确匹配", + "editable": true, + "searchable": false, + "is_image": false, + "is_rich_text": false, + "is_status": true, + "is_sort": false, + "choices": [ + [ + 1, + "在用" + ], + [ + 2, + "停用" + ] + ], + "min_value": 1, + "max_value": 2, + "dict_code": "example_status" + }, + { + "name": "sort", + "camel_name": "sort", + "comment": "案例排序", + "py_comment": "案例排序", + "purpose": "存储案例排序信息", + "db_type": "Integer", + "db_args": null, + "form_type": "int", + "type_display": "整数", + "max_length": null, + "nullable": true, + "required": false, + "is_unique": false, + "db_index": false, + "default": 0, + "default_repr": "0", + "in_form": true, + "in_list": true, + "filterable": false, + "filter_type": "", + "editable": true, + "searchable": false, + "is_image": false, + "is_rich_text": false, + "is_status": false, + "is_sort": true, + "choices": null, + "min_value": null, + "max_value": null + } + ] +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4a25603 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +# +====================================================================== +# | 模块: Docker Compose 配置 +# | 说明: 应用服务容器编排 +# +====================================================================== + +version: '3.7' + +services: + # 后端服务 + tornado_antdvue: + image: tornado_antdvue # 镜像名称 + container_name: tornado_antdvue # 容器名称 + build: # 构建镜像 + context: ./ + dockerfile: Dockerfile + restart: always # 始终自动重启 + network_mode: host # host 模式;ports 映射会被忽略,改 bridge 时需将 DB_HOST/REDIS_HOST 指向宿主机并启用端口映射 + ports: + - 8041:8041 # 端口映射(仅 bridge 模式生效) + volumes: + - ./uploads:/data/apps/uploads # 上传目录持久化 + env_file: + - .env # 从 .env 注入环境变量 + command: python app.py # 容器启动命令 + logging: + driver: "json-file" + options: + max-size: "500m" # 单个日志文件最大 500MB + max-file: "10" # 最多保留 10 个日志文件 \ No newline at end of file diff --git a/document/.gitignore b/document/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/document/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/generator.py b/generator.py new file mode 100644 index 0000000..d4dfd4f --- /dev/null +++ b/generator.py @@ -0,0 +1,204 @@ +# +====================================================================== +# | 模块: 代码生成器命令行工具 +# | 说明: 数据库表结构解析与代码生成命令行入口 +# +====================================================================== + +""" +数据库表结构解析与代码生成命令行工具 +============================================================= + +本项目代码生成器的命令行入口,内部复用 src/modules/generator 下的引擎: + - parser.generate_config_from_table : 从数据库表解析生成配置 + - code_generator.CodeGenerator : 根据配置生成后端三层模块代码与前端 Vue 页面 + +支持 --dry-run / --config / --output 参数: + - 默认从数据库表解析生成配置 + - --config 加载已有的配置文件(兼容 config.json 参考配置) + - --dry-run 仅生成配置不写代码,--output 导出配置为 JSON + +命令行示例: + # 方式1:解析表结构并生成模块(核心用法) + python generator.py tornado_example + + # 方式2:仅生成配置,不执行代码生成(预览模式) + python generator.py tornado_example --dry-run + + # 方式2.1:预览并导出配置为 JSON 文件 + python generator.py tornado_example --dry-run --output config.json + + # 方式3:使用已有配置文件生成(跳过表解析,兼容 config.json 参考配置) + python generator.py --config config.json + + # 方式4:生成代码并保存配置(解析 -> 保存 -> 生成) + python generator.py tornado_example --output config.json +""" + +import argparse +import json +import os +import sys + +# 将项目 src 目录加入 sys.path,保证从任意目录运行都能导入 +ROOT = os.path.dirname(os.path.abspath(__file__)) +SRC_DIR = os.path.join(ROOT, 'src') +if SRC_DIR not in sys.path: + sys.path.insert(0, SRC_DIR) + + +# ============================================================ +# 辅助函数 +# ============================================================ +def _init_database(): + """ + 初始化数据库连接 + + 代码生成引擎通过 SQLAlchemy 的 Session 访问数据库,因此 CLI 需要 + 先初始化数据库连接。复用 src/core/database.py 的 engine 和 SessionLocal。 + """ + from core.database import engine, SessionLocal + return engine, SessionLocal + + +def _resolve_config(args): + """ + 根据命令行参数解析生成配置 + + Args: + args: argparse 解析结果 + + Returns: + dict: 生成配置 + + Raises: + SystemExit: 缺少表名且未指定配置文件时退出 + """ + if args.config: + # 方式3:从配置文件加载(兼容 config.json 参考配置) + print(f"从配置文件加载: {args.config}") + with open(args.config, 'r', encoding='utf-8') as f: + config = json.load(f) + print("配置文件加载成功") + return config + + if args.table_name: + # 方式1/2/4:从数据库表解析 + from modules.generator.parser import generate_config_from_table + print(f"正在解析表: {args.table_name}") + config = generate_config_from_table(args.table_name) + print("表结构解析成功") + return config + + # 缺少必要参数:打印帮助并退出 + args_parser.print_help() + sys.exit(1) + + +def _print_config_summary(config): + """ + 打印配置摘要,便于人工核对解析结果 + + Args: + config: 生成配置字典 + """ + fields = config.get('fields', []) + print("\n" + "-" * 60) + print("生成配置摘要:") + print("-" * 60) + print(f" 应用名称: {config.get('app_name')}") + print(f" 模块名称: {config.get('module_comment')}") + print(f" 模型类名: {config.get('model_class_name')}") + print(f" 字段数量: {len(fields)}") + print(f" 是否有排序: {config.get('has_sort')}") + print(f" 是否有状态: {config.get('has_status')}") + + # 字段列表(仅展示前 5 个,避免刷屏) + for field in fields[:5]: + print(f" - {field['name']}: {field.get('comment', '')} " + f"({field.get('db_type', field.get('type', ''))})") + if field.get('choices'): + print(f" 选项: {field['choices']}") + if len(fields) > 5: + print(f" ... 还有 {len(fields) - 5} 个字段") + + +def _run_code_generator(config): + """ + 执行代码生成(写入模块文件 + 自动注册路由) + + Args: + config: 生成配置字典 + """ + from modules.generator.code_generator import CodeGenerator + + generator = CodeGenerator(config) + generator.generate(auto=True) + return True + + +# ============================================================ +# CLI 主入口 +# ============================================================ +def main(): + """ + CLI 主入口 + + 执行流程: + 1. 初始化数据库连接 + 2. 解析生成配置(表解析 / 配置文件加载) + 3. 按需导出配置(--output) + 4. 非预览模式下执行代码生成 + """ + global args_parser + args_parser = argparse.ArgumentParser( + description='数据库表结构解析和代码生成工具', + ) + args_parser.add_argument('table_name', nargs='?', help='数据库表名(如 tornado_example)') + args_parser.add_argument('--config', '-c', help='使用已有的配置文件(JSON格式,兼容 config.json 参考配置)') + args_parser.add_argument('--dry-run', '-d', action='store_true', help='仅生成配置,不执行代码生成') + args_parser.add_argument('--output', '-o', help='输出配置文件路径') + + args = args_parser.parse_args() + + print("=" * 60) + print("DjangoAdmin Tornado 代码生成工具") + print("=" * 60) + + # 初始化数据库连接 + _init_database() + + try: + # 步骤1:解析生成配置 + config = _resolve_config(args) + + # 步骤2:导出配置(--output,仅表解析路径生效) + if args.output: + with open(args.output, 'w', encoding='utf-8') as f: + json.dump(config, f, ensure_ascii=False, indent=2) + print(f"配置已保存到: {args.output}") + + # 步骤3:打印配置摘要 + _print_config_summary(config) + + # 步骤4:执行代码生成(预览模式跳过) + if args.dry_run: + print("\n" + "=" * 60) + print("预览模式 - 不执行代码生成(去掉 --dry-run 参数即可生成)") + print("=" * 60) + else: + print("\n" + "=" * 60) + print("开始执行代码生成") + print("=" * 60) + _run_code_generator(config) + print("代码生成完成!") + + print("\n操作完成!") + + except Exception as e: + print(f"\n操作失败: {str(e)}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..02d601a --- /dev/null +++ b/poetry.lock @@ -0,0 +1,1722 @@ +# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. + +[[package]] +name = "annotated-types" +version = "0.8.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0"}, + {file = "annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7"}, +] + +[[package]] +name = "apscheduler" +version = "3.11.3" +description = "In-process task scheduler with Cron-like capabilities" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "apscheduler-3.11.3-py3-none-any.whl", hash = "sha256:bbeb2ec02d23d3c06a6c07ed7f0f3939ada6680eb121fae809a69bb42c537a30"}, + {file = "apscheduler-3.11.3.tar.gz", hash = "sha256:cd2fcc9330039a81a5893472ad49facf23a6d5604cbe1d918c835c6de7834d5a"}, +] + +[package.dependencies] +tzlocal = ">=3.0" + +[package.extras] +doc = ["packaging", "sphinx", "sphinx-rtd-theme (>=1.3.0)"] +etcd = ["etcd3", "protobuf (<=3.21.0)"] +gevent = ["gevent"] +mongodb = ["pymongo (>=3.0)"] +redis = ["redis (>=3.0)"] +rethinkdb = ["rethinkdb (>=2.4.0)"] +sqlalchemy = ["sqlalchemy (>=1.4)"] +test = ["APScheduler[etcd,mongodb,redis,rethinkdb,sqlalchemy,tornado,zookeeper]", "PySide6 ; platform_python_implementation == \"CPython\"", "anyio (>=4.5.2)", "gevent ; python_version < \"3.14\"", "pytest", "pytest-timeout", "pytz", "twisted ; python_version < \"3.14\""] +tornado = ["tornado (>=4.3)"] +twisted = ["twisted"] +zookeeper = ["kazoo"] + +[[package]] +name = "async-timeout" +version = "5.0.1" +description = "Timeout context manager for asyncio programs" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "python_full_version <= \"3.11.2\"" +files = [ + {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, + {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, +] + +[[package]] +name = "bcrypt" +version = "4.3.0" +description = "Modern password hashing for your software and your servers" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "bcrypt-4.3.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f01e060f14b6b57bbb72fc5b4a83ac21c443c9a2ee708e04a10e9192f90a6281"}, + {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5eeac541cefd0bb887a371ef73c62c3cd78535e4887b310626036a7c0a817bb"}, + {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59e1aa0e2cd871b08ca146ed08445038f42ff75968c7ae50d2fdd7860ade2180"}, + {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:0042b2e342e9ae3d2ed22727c1262f76cc4f345683b5c1715f0250cf4277294f"}, + {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74a8d21a09f5e025a9a23e7c0fd2c7fe8e7503e4d356c0a2c1486ba010619f09"}, + {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:0142b2cb84a009f8452c8c5a33ace5e3dfec4159e7735f5afe9a4d50a8ea722d"}, + {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:12fa6ce40cde3f0b899729dbd7d5e8811cb892d31b6f7d0334a1f37748b789fd"}, + {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:5bd3cca1f2aa5dbcf39e2aa13dd094ea181f48959e1071265de49cc2b82525af"}, + {file = "bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:335a420cfd63fc5bc27308e929bee231c15c85cc4c496610ffb17923abf7f231"}, + {file = "bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:0e30e5e67aed0187a1764911af023043b4542e70a7461ad20e837e94d23e1d6c"}, + {file = "bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b8d62290ebefd49ee0b3ce7500f5dbdcf13b81402c05f6dafab9a1e1b27212f"}, + {file = "bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef6630e0ec01376f59a006dc72918b1bf436c3b571b80fa1968d775fa02fe7d"}, + {file = "bcrypt-4.3.0-cp313-cp313t-win32.whl", hash = "sha256:7a4be4cbf241afee43f1c3969b9103a41b40bcb3a3f467ab19f891d9bc4642e4"}, + {file = "bcrypt-4.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c1949bf259a388863ced887c7861da1df681cb2388645766c89fdfd9004c669"}, + {file = "bcrypt-4.3.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:f81b0ed2639568bf14749112298f9e4e2b28853dab50a8b357e31798686a036d"}, + {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:864f8f19adbe13b7de11ba15d85d4a428c7e2f344bac110f667676a0ff84924b"}, + {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e36506d001e93bffe59754397572f21bb5dc7c83f54454c990c74a468cd589e"}, + {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:842d08d75d9fe9fb94b18b071090220697f9f184d4547179b60734846461ed59"}, + {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7c03296b85cb87db865d91da79bf63d5609284fc0cab9472fdd8367bbd830753"}, + {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:62f26585e8b219cdc909b6a0069efc5e4267e25d4a3770a364ac58024f62a761"}, + {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:beeefe437218a65322fbd0069eb437e7c98137e08f22c4660ac2dc795c31f8bb"}, + {file = "bcrypt-4.3.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:97eea7408db3a5bcce4a55d13245ab3fa566e23b4c67cd227062bb49e26c585d"}, + {file = "bcrypt-4.3.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:191354ebfe305e84f344c5964c7cd5f924a3bfc5d405c75ad07f232b6dffb49f"}, + {file = "bcrypt-4.3.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:41261d64150858eeb5ff43c753c4b216991e0ae16614a308a15d909503617732"}, + {file = "bcrypt-4.3.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:33752b1ba962ee793fa2b6321404bf20011fe45b9afd2a842139de3011898fef"}, + {file = "bcrypt-4.3.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:50e6e80a4bfd23a25f5c05b90167c19030cf9f87930f7cb2eacb99f45d1c3304"}, + {file = "bcrypt-4.3.0-cp38-abi3-win32.whl", hash = "sha256:67a561c4d9fb9465ec866177e7aebcad08fe23aaf6fbd692a6fab69088abfc51"}, + {file = "bcrypt-4.3.0-cp38-abi3-win_amd64.whl", hash = "sha256:584027857bc2843772114717a7490a37f68da563b3620f78a849bcb54dc11e62"}, + {file = "bcrypt-4.3.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0d3efb1157edebfd9128e4e46e2ac1a64e0c1fe46fb023158a407c7892b0f8c3"}, + {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08bacc884fd302b611226c01014eca277d48f0a05187666bca23aac0dad6fe24"}, + {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6746e6fec103fcd509b96bacdfdaa2fbde9a553245dbada284435173a6f1aef"}, + {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:afe327968aaf13fc143a56a3360cb27d4ad0345e34da12c7290f1b00b8fe9a8b"}, + {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d9af79d322e735b1fc33404b5765108ae0ff232d4b54666d46730f8ac1a43676"}, + {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f1e3ffa1365e8702dc48c8b360fef8d7afeca482809c5e45e653af82ccd088c1"}, + {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3004df1b323d10021fda07a813fd33e0fd57bef0e9a480bb143877f6cba996fe"}, + {file = "bcrypt-4.3.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:531457e5c839d8caea9b589a1bcfe3756b0547d7814e9ce3d437f17da75c32b0"}, + {file = "bcrypt-4.3.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:17a854d9a7a476a89dcef6c8bd119ad23e0f82557afbd2c442777a16408e614f"}, + {file = "bcrypt-4.3.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6fb1fd3ab08c0cbc6826a2e0447610c6f09e983a281b919ed721ad32236b8b23"}, + {file = "bcrypt-4.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e965a9c1e9a393b8005031ff52583cedc15b7884fce7deb8b0346388837d6cfe"}, + {file = "bcrypt-4.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:79e70b8342a33b52b55d93b3a59223a844962bef479f6a0ea318ebbcadf71505"}, + {file = "bcrypt-4.3.0-cp39-abi3-win32.whl", hash = "sha256:b4d4e57f0a63fd0b358eb765063ff661328f69a04494427265950c71b992a39a"}, + {file = "bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b"}, + {file = "bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c950d682f0952bafcceaf709761da0a32a942272fad381081b51096ffa46cea1"}, + {file = "bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:107d53b5c67e0bbc3f03ebf5b030e0403d24dda980f8e244795335ba7b4a027d"}, + {file = "bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b693dbb82b3c27a1604a3dff5bfc5418a7e6a781bb795288141e5f80cf3a3492"}, + {file = "bcrypt-4.3.0-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:b6354d3760fcd31994a14c89659dee887f1351a06e5dac3c1142307172a79f90"}, + {file = "bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a839320bf27d474e52ef8cb16449bb2ce0ba03ca9f44daba6d93fa1d8828e48a"}, + {file = "bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:bdc6a24e754a555d7316fa4774e64c6c3997d27ed2d1964d55920c7c227bc4ce"}, + {file = "bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:55a935b8e9a1d2def0626c4269db3fcd26728cbff1e84f0341465c31c4ee56d8"}, + {file = "bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:57967b7a28d855313a963aaea51bf6df89f833db4320da458e5b3c5ab6d4c938"}, + {file = "bcrypt-4.3.0.tar.gz", hash = "sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18"}, +] + +[package.extras] +tests = ["pytest (>=3.2.1,!=3.3.0)"] +typecheck = ["mypy"] + +[[package]] +name = "bleach" +version = "6.4.0" +description = "An easy safelist-based HTML-sanitizing tool." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "bleach-6.4.0-py3-none-any.whl", hash = "sha256:4b6b6a54fff2e69a3dde9d21cc6301220bee3c3cb792187d11403fd795031081"}, + {file = "bleach-6.4.0.tar.gz", hash = "sha256:4202482733d85cedd04e59fcb2f89f4e4c7c385a78d3c3c23c30446843a37452"}, +] + +[package.dependencies] +webencodings = "*" + +[package.extras] +css = ["tinycss2 (>=1.1.0)"] + +[[package]] +name = "cfgv" +version = "3.5.0" +description = "Validate configuration and produce human readable error messages." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0"}, + {file = "cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132"}, +] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "coverage" +version = "7.15.4" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "coverage-7.15.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d0be6daac4cce6b8c8dc65886bae1b082ddbca4da8e5cbb5e15166acf253e264"}, + {file = "coverage-7.15.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b24e078eabcd6a9caa8b0713f9bc1eeb310bcc960a29d45a3b4fcd4b16d5b11d"}, + {file = "coverage-7.15.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cfe20cc8cf8821d4fe54f89106cbf06aa27f37b5bbe3535568065a81539b4150"}, + {file = "coverage-7.15.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:83cf06cdd687677742caff1a9134833b7a8b75f111519d2cb0e0ba1b9a851e15"}, + {file = "coverage-7.15.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8fa4de68e2a752468ff14b4e15db7def689a71be759e826a31ccecbef69c5fd0"}, + {file = "coverage-7.15.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4dff9daa47d83120c3ec38ce921214242944a832aa04e903e50b5b7ebac8972d"}, + {file = "coverage-7.15.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a093fd37229918976f602aa07aa59e0973cde82186f220c8e197f721f5be0ce4"}, + {file = "coverage-7.15.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:317db01a2cb02552fd67e2b1cca77a4b528a2a277176c5e0bf2cecbb639d3f54"}, + {file = "coverage-7.15.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:8ee3838dcb656602c3b51e16aed9bfb0822f8d8d6d1c5966d32ec8c104be8e20"}, + {file = "coverage-7.15.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:425920379052ff1fe465268f3361d35804a241bbdd5a1b592c8cb60df4c52325"}, + {file = "coverage-7.15.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:69bb2400abef928e365ea7d4d9925169ada78ed2295546780002d4b65de3df88"}, + {file = "coverage-7.15.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:81661f82d302484e3119e7c80c519c02fa9bcc2a6b339baf67d67bc89c580f04"}, + {file = "coverage-7.15.4-cp310-cp310-win32.whl", hash = "sha256:cb476b2e828ecb71cb6b6a928d23fd20a7ddb501188022dae1c37499149cc338"}, + {file = "coverage-7.15.4-cp310-cp310-win_amd64.whl", hash = "sha256:3fc2130bf37df31852a8384f12601563a45a0024bccc6624f38355cba7a8d360"}, + {file = "coverage-7.15.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bbac5abad70df71019988f83f26ac7092ff2642975def4429e98dc7585ef3490"}, + {file = "coverage-7.15.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:357a173465c7ce028d07a95cc2b63b5bf59f50ecdd5ad75c5cbb78ada984048e"}, + {file = "coverage-7.15.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:21b803935e2efc3acebe9697197a294fccf5dc4e5382bd6369542ff7a7d2a1d7"}, + {file = "coverage-7.15.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a2b580774a4786c1053157c0165e04476e03ff293993d7c148eee784a94bae6"}, + {file = "coverage-7.15.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9464451c4efffe8d47ace5a540b10b0dc10e879066290f8600872b7f54a419d"}, + {file = "coverage-7.15.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de602f34123c2f4af1c1869c6dbbbd60da6d5983bf01937367295d135cccbfce"}, + {file = "coverage-7.15.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6879ded16a27f3eeca19b900c147e81616e7054db451471a611b2755ee5249f7"}, + {file = "coverage-7.15.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:986be58c3ab54aae8d3496a6225eea74f760fdbe739b38bd442c7e8d133aa53b"}, + {file = "coverage-7.15.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c6103639613fe6c1e989082948419bc77a2d26b6c825c99d7fad25f7d3d87afc"}, + {file = "coverage-7.15.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d3af93dddb5659276c63bc16ac6466ac2033a70ca816097bbc06345b8ccdf571"}, + {file = "coverage-7.15.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b10075e5421d04265766a6d1dac809bbeb8a946fbb23c8f82c227409b2190719"}, + {file = "coverage-7.15.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a67a9f78b2942d87ba8ce3059c642164d2aedd65337377fb52fe9803656bc5c7"}, + {file = "coverage-7.15.4-cp311-cp311-win32.whl", hash = "sha256:69484d1aca26e322e1c3ce03f09341e84524ababad2d7202161738d83cc9f82e"}, + {file = "coverage-7.15.4-cp311-cp311-win_amd64.whl", hash = "sha256:63fd6fcd1dd6e158f7eb78606e72933b3f6d01e7b747f99c6c12d764307a0fdc"}, + {file = "coverage-7.15.4-cp311-cp311-win_arm64.whl", hash = "sha256:ea82116c9893fa89e929b7f197ee5a1950a76e91cc5c85ba503fc02379d04890"}, + {file = "coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22"}, + {file = "coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97"}, + {file = "coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d"}, + {file = "coverage-7.15.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:899b9da30f3c6c336566e3707495bb23e8302d39d862f01fa78c48b99b9437e2"}, + {file = "coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931"}, + {file = "coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8"}, + {file = "coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9"}, + {file = "coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839"}, + {file = "coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72"}, + {file = "coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52"}, + {file = "coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c"}, + {file = "coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4"}, + {file = "coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b"}, + {file = "coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd"}, + {file = "coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f"}, + {file = "coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921"}, + {file = "coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e"}, + {file = "coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36"}, + {file = "coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4"}, + {file = "coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c"}, + {file = "coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7"}, + {file = "coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25"}, + {file = "coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b"}, + {file = "coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78"}, + {file = "coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f"}, + {file = "coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d"}, + {file = "coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff"}, + {file = "coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c"}, + {file = "coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4"}, + {file = "coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf"}, + {file = "coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f"}, + {file = "coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c"}, + {file = "coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082"}, + {file = "coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac"}, + {file = "coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734"}, + {file = "coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d"}, + {file = "coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf"}, + {file = "coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b"}, + {file = "coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25"}, + {file = "coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303"}, + {file = "coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f"}, + {file = "coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5"}, + {file = "coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7"}, + {file = "coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425"}, + {file = "coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8"}, + {file = "coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8"}, + {file = "coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a"}, + {file = "coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b"}, + {file = "coverage-7.15.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d6f4955b73b5445271379a59e3792b0d978f42d4a01e0cf7a67d9c33a3bb0a5"}, + {file = "coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba"}, + {file = "coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982"}, + {file = "coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c"}, + {file = "coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57"}, + {file = "coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26"}, + {file = "coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429"}, + {file = "coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017"}, + {file = "coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839"}, + {file = "coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85"}, + {file = "coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e"}, + {file = "coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e"}, + {file = "coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9"}, + {file = "coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a"}, + {file = "coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54"}, + {file = "coverage-7.15.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d28a4a899354d0ea6214cc59b4fa19eefbce1b9ff1688ab579acf49e894bd3fb"}, + {file = "coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed"}, + {file = "coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce"}, + {file = "coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c"}, + {file = "coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced"}, + {file = "coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800"}, + {file = "coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3"}, + {file = "coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768"}, + {file = "coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753"}, + {file = "coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2"}, + {file = "coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809"}, + {file = "coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d"}, + {file = "coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d"}, + {file = "coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26"}, + {file = "coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645"}, + {file = "coverage-7.15.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8df457da2249d3c75ca2e5e835d59c725abfe92d27fdff6cd99eed85b51d5e9a"}, + {file = "coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624"}, + {file = "coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa"}, + {file = "coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f"}, + {file = "coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f"}, + {file = "coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67"}, + {file = "coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc"}, + {file = "coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88"}, + {file = "coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc"}, + {file = "coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a"}, + {file = "coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b"}, + {file = "coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278"}, + {file = "coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84"}, + {file = "coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00"}, +] + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] + +[[package]] +name = "distlib" +version = "0.4.3" +description = "Distribution utilities" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b"}, + {file = "distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed"}, +] + +[[package]] +name = "et-xmlfile" +version = "2.0.0" +description = "An implementation of lxml.xmlfile for the standard library" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa"}, + {file = "et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, + {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "filelock" +version = "3.32.3" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "filelock-3.32.3-py3-none-any.whl", hash = "sha256:7f0ca4bcc0e181c60dbbd8aa9ab5b120ebb99e4e064e83636340056f833a1f09"}, + {file = "filelock-3.32.3.tar.gz", hash = "sha256:0ffa185a3540854c95caa7fa76b76cb219d907415e2c5dc9af25fd970563487f"}, +] + +[[package]] +name = "flake8" +version = "7.3.0" +description = "the modular source code checker: pep8 pyflakes and co" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e"}, + {file = "flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872"}, +] + +[package.dependencies] +mccabe = ">=0.7.0,<0.8.0" +pycodestyle = ">=2.14.0,<2.15.0" +pyflakes = ">=3.4.0,<3.5.0" + +[[package]] +name = "greenlet" +version = "3.5.5" +description = "Lightweight in-process concurrent programming" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\"" +files = [ + {file = "greenlet-3.5.5-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:816230f469381ad0a43abc9fa8dda5a699e32fb78958dde32ded93213b70a667"}, + {file = "greenlet-3.5.5-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5433cf291e0ef9114bd14d0d824db6e5e4a43033234bca48181a9597acca07b"}, + {file = "greenlet-3.5.5-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:19d59f068887d8c5907fc177f27683413ace3011b6ed646c0b309266e74a6502"}, + {file = "greenlet-3.5.5-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:86c5113d698cb8d927b2750bb1f1d59eefe3a37e0e0217491aee29a7f84ef52c"}, + {file = "greenlet-3.5.5-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff00e12102358292087274dfb1669132387ff6e7920ebf9d85f4826ce0d3a56"}, + {file = "greenlet-3.5.5-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:c69bed34470abfcd456984fdadaa18e62169af4480335c45f3c32d1d9c12e638"}, + {file = "greenlet-3.5.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:523bb8e27614d77101ea7a8cf59f8d91219b72d5c29f6a038c92b50828bfa8d0"}, + {file = "greenlet-3.5.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f1e2db190db51c17433eee424803818cf0670bf049d9cfe0dd07be111d1aa7c4"}, + {file = "greenlet-3.5.5-cp310-cp310-win_amd64.whl", hash = "sha256:740e544169527b82695ce76af2f7ad6f030904658f2f3921a1d245771fb88cfc"}, + {file = "greenlet-3.5.5-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:be63afcbbccfad3dd95a1ba12ada84dab2ef32031973d80b5b92df67fa763a61"}, + {file = "greenlet-3.5.5-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a268024ce2d7d2b04694bf1594058981a9fa663d1df4b762dee499211ed7c1c"}, + {file = "greenlet-3.5.5-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35cbb8bf55ace57fbccb4fb8622c4521713acd8691e77f4696d416ea7ca527da"}, + {file = "greenlet-3.5.5-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:abc8bc8d9f935cd685457545b6a53863a877fdc12c2c0f5ee9beee18d9db139c"}, + {file = "greenlet-3.5.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cc6df89ec5302337adc9cf096221cbed2510fd444b0e0f1586cf0470740864"}, + {file = "greenlet-3.5.5-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:3134291427bb0f3526e9d90311988caf336eb43730e95244997a4fb15f45144f"}, + {file = "greenlet-3.5.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d9b454c5fc48aeaa7c4337813dbf513a6870468e426438a04d922c6d0fe63db"}, + {file = "greenlet-3.5.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03551ed792cb1b4fc0277a0c60dfd8c343894a0ba06fe60dcd22f568b433da39"}, + {file = "greenlet-3.5.5-cp311-cp311-win_amd64.whl", hash = "sha256:ab3df3dffb58bf70564e93a5cec7941e4d9faa5a36cc4234a10d3131afe04f53"}, + {file = "greenlet-3.5.5-cp311-cp311-win_arm64.whl", hash = "sha256:2b70a766135540c472ac1393d57c2e1b4a2eb85bf526a1e41e6d096173a8cee5"}, + {file = "greenlet-3.5.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:49520f0c95a48b42cf55414b8e8479beb274ea70431afc33e3f79903c71f4380"}, + {file = "greenlet-3.5.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55272212cbc5f43d1d723725ab931f1939969b7e9523882ca58b55061769d053"}, + {file = "greenlet-3.5.5-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655bca754a2ef4efcb0eb48a94d3f4593536d0f3d48f8ed44343c01d16a92f95"}, + {file = "greenlet-3.5.5-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ca5d6ae0739e5764f2cfcfaa562ac5a990cbdaedca93251c5e3cf07c362371f"}, + {file = "greenlet-3.5.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:147b25a42e5ca5be3d42356e8f608b37af715a1c196e9bf9d1627f3341adfe1d"}, + {file = "greenlet-3.5.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:1b5ed9162c0c098e0bbc2cf88a94f433c1b8926f831745252e099e5d83e17759"}, + {file = "greenlet-3.5.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:27493374cff1d1b7919dc8126547f2aea582737e3046147b434b1e12de56389b"}, + {file = "greenlet-3.5.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:12e2ee66c2aba86133f10fd99d6a8856c6d351ffb7be0e4d52ef2cc5fbb705b2"}, + {file = "greenlet-3.5.5-cp312-cp312-win_amd64.whl", hash = "sha256:49ddacd36af37735fab103846f4ee4d18a492dde72730d1699c0c8ebe30d9f18"}, + {file = "greenlet-3.5.5-cp312-cp312-win_arm64.whl", hash = "sha256:5f1b1ff4828cdc1aba4266aff814085d04a1d07959287219af021b838b265d52"}, + {file = "greenlet-3.5.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b79fd2a5bc099b5e744f34c4c9a58954a5f4cb7529fb4b6e8446057d61b6edaa"}, + {file = "greenlet-3.5.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:634cf15a233a949136879dd388e25d3296e16f3f1e217d2456797b8579ebc6ed"}, + {file = "greenlet-3.5.5-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:499adea519f748407fc6806d20eedabac2884fd73b9f38d81236e190ba20dfef"}, + {file = "greenlet-3.5.5-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7278591501941bb2456af102bb9cd59aab48c6cfd6e2dd68fa1290bb0c49a42"}, + {file = "greenlet-3.5.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2eabb980975cba5b93a95f6f69287d05fc05ac955bfd6a320a7c083eeb52c0b0"}, + {file = "greenlet-3.5.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:4dfc7c4470354e7b09184d1a3a985761053a2fd694ddb5b5c80242afc2c8c90b"}, + {file = "greenlet-3.5.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537"}, + {file = "greenlet-3.5.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4441153ffba21b90d3ca89fe3d31f5c093ae6c0bf0cfdfc98f54cde22f95b62e"}, + {file = "greenlet-3.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:95c5b1f4b3a193f8a0c2de4bfdcb48d119f7f1063941f1de1f2168051b3e52dd"}, + {file = "greenlet-3.5.5-cp313-cp313-win_arm64.whl", hash = "sha256:1af90aa4bc129883b340cdd6957a3bc74f60528a4993bbd1f53aaebe1d9981cc"}, + {file = "greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d"}, + {file = "greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328"}, + {file = "greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926"}, + {file = "greenlet-3.5.5-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d98ef6f92e67c6dbf299dbfd8facc1b0d2d9cedf91e325e73b3d0373fe4309d8"}, + {file = "greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e"}, + {file = "greenlet-3.5.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:159df1942d88e8f784cbb38d6f18bdb365cd11319cfbb3e89623de2b97892d53"}, + {file = "greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc"}, + {file = "greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9"}, + {file = "greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1"}, + {file = "greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07"}, + {file = "greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277"}, + {file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b"}, + {file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272"}, + {file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e9ec2e7c98e895fcea0c5cc57b2606cf86ece6d0a56578f3eb225e2af4f0387"}, + {file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476"}, + {file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:102817506f6090b5176c746a82603341a549b40e5c3d5b72a4c672228a918c41"}, + {file = "greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874"}, + {file = "greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71"}, + {file = "greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0"}, + {file = "greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206"}, + {file = "greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad"}, + {file = "greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0"}, + {file = "greenlet-3.5.5-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37faa97daccb6d9f4c2141ce3118d023c3c5506864a7d8bdf726f665018c1f76"}, + {file = "greenlet-3.5.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552"}, + {file = "greenlet-3.5.5-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:b18007dc2473a7942fd157366b55f01da6fed7ce85318591005b419e0a439474"}, + {file = "greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007"}, + {file = "greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773"}, + {file = "greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e"}, + {file = "greenlet-3.5.5-cp315-cp315-win_arm64.whl", hash = "sha256:2d57406c3efd32d7a81e17a674314e8bd00792cdab49ea3228a49aa1bfb2e769"}, + {file = "greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c"}, + {file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6"}, + {file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae"}, + {file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2888a3a38bc5ee5bb6c438372197152e815837e4fab7ed7a1f86ef18ffd58ad1"}, + {file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3"}, + {file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:1e8d9391fe77f15649589a907cef972dbbd6352ef7ff7dc0492f658c0c26495f"}, + {file = "greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0"}, + {file = "greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5"}, + {file = "greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8"}, + {file = "greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b"}, + {file = "greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c"}, +] + +[package.extras] +docs = ["Sphinx", "furo"] +test = ["objgraph", "psutil", "setuptools"] + +[[package]] +name = "identify" +version = "2.6.19" +description = "File identification library for Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a"}, + {file = "identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842"}, +] + +[package.extras] +license = ["ukkonen"] + +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + +[[package]] +name = "librt" +version = "0.15.0" +description = "Mypyc runtime library" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "librt-0.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1a49adf16a7c9d9646816c2946135527197b6fcf4347c7b8b761cf1bfbf4489"}, + {file = "librt-0.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81a398f45b45a59200e13cd5ad1ae1d3f44334de98b148331afe2cdfee701c52"}, + {file = "librt-0.15.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4eafbaff06b9563f8b1c850621ce51605de05208e09d4d71ce490bc972b7b9e8"}, + {file = "librt-0.15.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b0411b4066db926b80258c60dcb0e6db4c9cee312eab45b7e8866b17ddf9ada1"}, + {file = "librt-0.15.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:febb1ce6cac545a54e6b769982824e955a700fdd9fbf3a08a3d82c990968b57d"}, + {file = "librt-0.15.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b230acc1c3bfe2d6f2627ba2b95dc92e58aa494600e9722d0e6ccbc931e59702"}, + {file = "librt-0.15.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6da110e5f314c19ab8478464d02ae18808ae73d522c15260fa4918acdcd64da9"}, + {file = "librt-0.15.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:eab9208b00ca55bf75983ec99f7bf13acc746a36102e98953addaad7f7ea1e1b"}, + {file = "librt-0.15.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6c013cd3a1721e69e14380ada97eaa4b7b0cdf1c6b96fa765d4ea47c875088db"}, + {file = "librt-0.15.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:567b1c430f8bd560e689421468278ac5941bab4a05303b5d95b6ae10db03f451"}, + {file = "librt-0.15.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:29c4cab9df457b19672c39be7f384ebb2bc925c4e2684b8780c222b43eb36389"}, + {file = "librt-0.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bccbd8e5b0bffb7106cf18eb1baa3d7194b1cebb3b4b1cdbd4bdb19382a6ee6c"}, + {file = "librt-0.15.0-cp310-cp310-win32.whl", hash = "sha256:8ae493ed5f659a7761c43d42f183db514536073ded9bcf671d2d1df47e29a07e"}, + {file = "librt-0.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:bc25fb356d0c7810bb49ff3df908ad1fda6995d660ab099ded69244ed7ab6053"}, + {file = "librt-0.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:823b92cf3c18ecd08afc70c42473888b41b6e8ef5046f3b82c05c154a2fa3d22"}, + {file = "librt-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c70bc1b602cf59917e8f0c7a2cbc8bcc6fbc14d5486136b00707a79619121d63"}, + {file = "librt-0.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:814ff83a25b5fce8b9c80c4dd803153fb5c5599fc74db9e022466938368957ef"}, + {file = "librt-0.15.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:57f5eeb6ad4c180de583b1038e61fe5fbd9796bb69a8a1c1a0c7ddbec4c8c60f"}, + {file = "librt-0.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82909c8f7eb9952656b65d3147afde4cf8e6d5a991eebc86418b5e65843b0ab8"}, + {file = "librt-0.15.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f779070399f991400fc451719e0ea388eb7de313388bada2c127a35de05f798a"}, + {file = "librt-0.15.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bac89069bc496ebdf4f79ebb57bbd10d0b214c8454225deb672d91002bd17e18"}, + {file = "librt-0.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e0d00c708fb2f5822b152429b1ac80a58dbbbc3f6c232c4d13a3f7fcf2ea5b4c"}, + {file = "librt-0.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6c6624fe268625869485553dd7cc1daf30d22558215bb2a4ff16f67a9801a31a"}, + {file = "librt-0.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f56b397858a23dacf35ede366ed2212fdc03a6a57a1ad36468ad6e9dc5fac091"}, + {file = "librt-0.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4388184646efe2054911c5b00a1077d6d1ee86a95b7e8ba96dc7850a809f3f40"}, + {file = "librt-0.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:97335f59082f9fe2ce6c2a9cc6433a0114bbb6cd4d5c09dd76c95c68b9f9a8b0"}, + {file = "librt-0.15.0-cp311-cp311-win32.whl", hash = "sha256:83380ffde38062a2e9bb55d83e74474f6614665528b98a6928720fc006dfffbb"}, + {file = "librt-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:f75720477ee05d509a310e856cacc8d909adc182f7b91193c207bcc26d7ee6db"}, + {file = "librt-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:256237037a3ab001ae8d9803b2d43562a4c3aa38739843694349e4d5ebb0fd56"}, + {file = "librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e"}, + {file = "librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d"}, + {file = "librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1"}, + {file = "librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa"}, + {file = "librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd"}, + {file = "librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa"}, + {file = "librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d"}, + {file = "librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656"}, + {file = "librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81"}, + {file = "librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d"}, + {file = "librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0"}, + {file = "librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2"}, + {file = "librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1"}, + {file = "librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022"}, + {file = "librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570"}, + {file = "librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26"}, + {file = "librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801"}, + {file = "librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc"}, + {file = "librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95"}, + {file = "librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b"}, + {file = "librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2"}, + {file = "librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3"}, + {file = "librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785"}, + {file = "librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6"}, + {file = "librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101"}, + {file = "librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218"}, + {file = "librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b"}, + {file = "librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab"}, + {file = "librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890"}, + {file = "librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8"}, + {file = "librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad"}, + {file = "librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993"}, + {file = "librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8"}, + {file = "librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21"}, + {file = "librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953"}, + {file = "librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa"}, + {file = "librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879"}, + {file = "librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae"}, + {file = "librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd"}, + {file = "librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285"}, + {file = "librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239"}, + {file = "librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60"}, + {file = "librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65"}, + {file = "librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622"}, + {file = "librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15"}, + {file = "librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28"}, + {file = "librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95"}, + {file = "librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714"}, + {file = "librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3"}, + {file = "librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d"}, + {file = "librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38"}, + {file = "librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19"}, + {file = "librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab"}, + {file = "librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2"}, + {file = "librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108"}, + {file = "librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08"}, + {file = "librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47"}, + {file = "librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81"}, + {file = "librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc"}, + {file = "librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf"}, + {file = "librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915"}, + {file = "librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605"}, + {file = "librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca"}, + {file = "librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965"}, + {file = "librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad"}, + {file = "librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9"}, + {file = "librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328"}, + {file = "librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0"}, + {file = "librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9"}, + {file = "librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659"}, + {file = "librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0"}, + {file = "librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d"}, + {file = "librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c"}, + {file = "librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d"}, + {file = "librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374"}, + {file = "librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9"}, + {file = "librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8"}, + {file = "librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b"}, + {file = "librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54"}, + {file = "librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6"}, + {file = "librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988"}, + {file = "librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13"}, + {file = "librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416"}, + {file = "librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a"}, + {file = "librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c"}, + {file = "librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302"}, + {file = "librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d"}, + {file = "librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9"}, + {file = "librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab"}, + {file = "librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b"}, + {file = "librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162"}, + {file = "librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1"}, + {file = "librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc"}, + {file = "librt-0.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0e2d0c0acf5b0ada7d045912b7cf787c21315c95b38b1fa939ef72d45d366b3d"}, + {file = "librt-0.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f9ca190fe9edc0eb08eec558a509a16d28d91c35667b8f043cba40ed5e77a959"}, + {file = "librt-0.15.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80811e1c42386ea95c6fb30571d3250ad43d7863f883f787f70517f441150e59"}, + {file = "librt-0.15.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:88c2a17815c266e6d8180204ff62cb739ab869ada4a746d4c505331526ac58f1"}, + {file = "librt-0.15.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a5fa8f1f916988d0bf1afea005bda37f56ac41a18016e813ccf0097a8d460ca4"}, + {file = "librt-0.15.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:355e3a4c725225a14262004fc1872a552b9d3634b4f791a0dfc80804aafbfd55"}, + {file = "librt-0.15.0-cp39-cp39-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc1ed11c4ad0b91af24def2050f2840ea4567828e3dd058fbe608d982f6e5465"}, + {file = "librt-0.15.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1f4ef2e71db33df4309167ed7f1520c4fae5e611226e159fa9cf33f93e6ddb3d"}, + {file = "librt-0.15.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1a1a8cd430c7dd0c083f455cb1b328d7fc682b05c31b940906f7845bdff80881"}, + {file = "librt-0.15.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:04d5387b908676c0b8d5d2f5fb58373b4ea382d81f7a6f0fab8ea2a462bb4738"}, + {file = "librt-0.15.0-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:1172c6ad2a88b646e7fe3b480e3fac4ab4418b3443fd8a4061fdd531e0622fc7"}, + {file = "librt-0.15.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:52e8db01f603f5da0ca30987479acff98769382efc8e142fa3962395dcf3ffdb"}, + {file = "librt-0.15.0-cp39-cp39-win32.whl", hash = "sha256:e4c911f15a1652ca94ae9f1abd92e74cbb1b3597d2d92fdd556202f94e8cd455"}, + {file = "librt-0.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:68242379c9b65a582b6e97318a1e9fbd6d445e58954f2d437991c4804ab11578"}, + {file = "librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162"}, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +description = "McCabe checker, plugin for flake8" +optional = false +python-versions = ">=3.6" +groups = ["dev"] +files = [ + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, +] + +[[package]] +name = "mypy" +version = "1.20.2" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "mypy-1.20.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cf5a4db6dca263010e2c7bff081c89383c72d187ba2cf4c44759aac970e2f0c4"}, + {file = "mypy-1.20.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7b0e817b518bff7facd7f85ea05b643ad8bdcce684cf29784987b0a7c8e1f997"}, + {file = "mypy-1.20.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97d7b9a485b40f8ca425460e89bf1da2814625b2da627c0dcc6aa46c92631d14"}, + {file = "mypy-1.20.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e1c12f6d2db3d78b909b5f77513c11eb7f2dd2782b96a3ab6dffc7d44575c99"}, + {file = "mypy-1.20.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:89dce27e142d25ffbc154c1819383b69f2e9234dc4ed4766f42e0e8cb264ab5c"}, + {file = "mypy-1.20.2-cp310-cp310-win_amd64.whl", hash = "sha256:f376e37f9bf2a946872fc5fd1199c99310748e3c26c7a26683f13f8bdb756cbd"}, + {file = "mypy-1.20.2-cp310-cp310-win_arm64.whl", hash = "sha256:6e2b469efd811707bc530fd1effef0f5d6eebcb7fe376affae69025da4b979a2"}, + {file = "mypy-1.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c"}, + {file = "mypy-1.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3"}, + {file = "mypy-1.20.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254"}, + {file = "mypy-1.20.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98"}, + {file = "mypy-1.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac"}, + {file = "mypy-1.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:baf593f2765fa3a6b1ef95807dbaa3d25b594f6a52adcc506a6b9cb115e1be67"}, + {file = "mypy-1.20.2-cp311-cp311-win_arm64.whl", hash = "sha256:20175a1c0f49863946ec20b7f63255768058ac4f07d2b9ded6a6b46cfb5a9100"}, + {file = "mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b"}, + {file = "mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4"}, + {file = "mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6"}, + {file = "mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066"}, + {file = "mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102"}, + {file = "mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9"}, + {file = "mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58"}, + {file = "mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026"}, + {file = "mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943"}, + {file = "mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517"}, + {file = "mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15"}, + {file = "mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee"}, + {file = "mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f"}, + {file = "mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330"}, + {file = "mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30"}, + {file = "mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924"}, + {file = "mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb"}, + {file = "mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc"}, + {file = "mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558"}, + {file = "mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8"}, + {file = "mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3"}, + {file = "mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609"}, + {file = "mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2"}, + {file = "mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c"}, + {file = "mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744"}, + {file = "mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6"}, + {file = "mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec"}, + {file = "mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382"}, + {file = "mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563"}, + {file = "mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665"}, +] + +[package.dependencies] +librt = {version = ">=0.8.0", markers = "platform_python_implementation != \"PyPy\""} +mypy_extensions = ">=1.0.0" +pathspec = ">=1.0.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing_extensions = [ + {version = ">=4.6.0", markers = "python_version < \"3.15\""}, + {version = ">=4.14.0", markers = "python_version >= \"3.15\""}, +] + +[package.extras] +dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +native-parser = ["ast-serialize (>=0.1.1,<1.0.0)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +description = "Node.js virtual environment builder" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +files = [ + {file = "nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827"}, + {file = "nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb"}, +] + +[[package]] +name = "openpyxl" +version = "3.1.5" +description = "A Python library to read/write Excel 2010 xlsx/xlsm files" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2"}, + {file = "openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050"}, +] + +[package.dependencies] +et-xmlfile = "*" + +[[package]] +name = "packaging" +version = "26.3" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c"}, + {file = "packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79"}, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189"}, + {file = "pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a"}, +] + +[package.extras] +hyperscan = ["hyperscan (>=0.7)"] +optional = ["typing-extensions (>=4)"] +re2 = ["google-re2 (>=1.1)"] + +[[package]] +name = "pillow" +version = "10.4.0" +description = "Python Imaging Library (Fork)" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pillow-10.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e"}, + {file = "pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d"}, + {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7928ecbf1ece13956b95d9cbcfc77137652b02763ba384d9ab508099a2eca856"}, + {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4d49b85c4348ea0b31ea63bc75a9f3857869174e2bf17e7aba02945cd218e6f"}, + {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6c762a5b0997f5659a5ef2266abc1d8851ad7749ad9a6a5506eb23d314e4f46b"}, + {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a985e028fc183bf12a77a8bbf36318db4238a3ded7fa9df1b9a133f1cb79f8fc"}, + {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:812f7342b0eee081eaec84d91423d1b4650bb9828eb53d8511bcef8ce5aecf1e"}, + {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ac1452d2fbe4978c2eec89fb5a23b8387aba707ac72810d9490118817d9c0b46"}, + {file = "pillow-10.4.0-cp310-cp310-win32.whl", hash = "sha256:bcd5e41a859bf2e84fdc42f4edb7d9aba0a13d29a2abadccafad99de3feff984"}, + {file = "pillow-10.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:ecd85a8d3e79cd7158dec1c9e5808e821feea088e2f69a974db5edf84dc53141"}, + {file = "pillow-10.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:ff337c552345e95702c5fde3158acb0625111017d0e5f24bf3acdb9cc16b90d1"}, + {file = "pillow-10.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c"}, + {file = "pillow-10.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be"}, + {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3"}, + {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6"}, + {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe"}, + {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319"}, + {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d"}, + {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696"}, + {file = "pillow-10.4.0-cp311-cp311-win32.whl", hash = "sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496"}, + {file = "pillow-10.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91"}, + {file = "pillow-10.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22"}, + {file = "pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94"}, + {file = "pillow-10.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597"}, + {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80"}, + {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca"}, + {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef"}, + {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a"}, + {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b"}, + {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9"}, + {file = "pillow-10.4.0-cp312-cp312-win32.whl", hash = "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42"}, + {file = "pillow-10.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a"}, + {file = "pillow-10.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9"}, + {file = "pillow-10.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3"}, + {file = "pillow-10.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb"}, + {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70"}, + {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be"}, + {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0"}, + {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc"}, + {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a"}, + {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309"}, + {file = "pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060"}, + {file = "pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea"}, + {file = "pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d"}, + {file = "pillow-10.4.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:8d4d5063501b6dd4024b8ac2f04962d661222d120381272deea52e3fc52d3736"}, + {file = "pillow-10.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7c1ee6f42250df403c5f103cbd2768a28fe1a0ea1f0f03fe151c8741e1469c8b"}, + {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15e02e9bb4c21e39876698abf233c8c579127986f8207200bc8a8f6bb27acf2"}, + {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a8d4bade9952ea9a77d0c3e49cbd8b2890a399422258a77f357b9cc9be8d680"}, + {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:43efea75eb06b95d1631cb784aa40156177bf9dd5b4b03ff38979e048258bc6b"}, + {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:950be4d8ba92aca4b2bb0741285a46bfae3ca699ef913ec8416c1b78eadd64cd"}, + {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d7480af14364494365e89d6fddc510a13e5a2c3584cb19ef65415ca57252fb84"}, + {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:73664fe514b34c8f02452ffb73b7a92c6774e39a647087f83d67f010eb9a0cf0"}, + {file = "pillow-10.4.0-cp38-cp38-win32.whl", hash = "sha256:e88d5e6ad0d026fba7bdab8c3f225a69f063f116462c49892b0149e21b6c0a0e"}, + {file = "pillow-10.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:5161eef006d335e46895297f642341111945e2c1c899eb406882a6c61a4357ab"}, + {file = "pillow-10.4.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:0ae24a547e8b711ccaaf99c9ae3cd975470e1a30caa80a6aaee9a2f19c05701d"}, + {file = "pillow-10.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:298478fe4f77a4408895605f3482b6cc6222c018b2ce565c2b6b9c354ac3229b"}, + {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:134ace6dc392116566980ee7436477d844520a26a4b1bd4053f6f47d096997fd"}, + {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:930044bb7679ab003b14023138b50181899da3f25de50e9dbee23b61b4de2126"}, + {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:c76e5786951e72ed3686e122d14c5d7012f16c8303a674d18cdcd6d89557fc5b"}, + {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:b2724fdb354a868ddf9a880cb84d102da914e99119211ef7ecbdc613b8c96b3c"}, + {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dbc6ae66518ab3c5847659e9988c3b60dc94ffb48ef9168656e0019a93dbf8a1"}, + {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:06b2f7898047ae93fad74467ec3d28fe84f7831370e3c258afa533f81ef7f3df"}, + {file = "pillow-10.4.0-cp39-cp39-win32.whl", hash = "sha256:7970285ab628a3779aecc35823296a7869f889b8329c16ad5a71e4901a3dc4ef"}, + {file = "pillow-10.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:961a7293b2457b405967af9c77dcaa43cc1a8cd50d23c532e62d48ab6cdd56f5"}, + {file = "pillow-10.4.0-cp39-cp39-win_arm64.whl", hash = "sha256:32cda9e3d601a52baccb2856b8ea1fc213c90b340c542dcef77140dfa3278a9e"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5b4815f2e65b30f5fbae9dfffa8636d992d49705723fe86a3661806e069352d4"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8f0aef4ef59694b12cadee839e2ba6afeab89c0f39a3adc02ed51d109117b8da"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f4727572e2918acaa9077c919cbbeb73bd2b3ebcfe033b72f858fc9fbef0026"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff25afb18123cea58a591ea0244b92eb1e61a1fd497bf6d6384f09bc3262ec3e"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dc3e2db6ba09ffd7d02ae9141cfa0ae23393ee7687248d46a7507b75d610f4f5"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02a2be69f9c9b8c1e97cf2713e789d4e398c751ecfd9967c18d0ce304efbf885"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0755ffd4a0c6f267cccbae2e9903d95477ca2f77c4fcf3a3a09570001856c8a5"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:a02364621fe369e06200d4a16558e056fe2805d3468350df3aef21e00d26214b"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:1b5dea9831a90e9d0721ec417a80d4cbd7022093ac38a568db2dd78363b00908"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b885f89040bb8c4a1573566bbb2f44f5c505ef6e74cec7ab9068c900047f04b"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87dd88ded2e6d74d31e1e0a99a726a6765cda32d00ba72dc37f0651f306daaa8"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:2db98790afc70118bd0255c2eeb465e9767ecf1f3c25f9a1abb8ffc8cfd1fe0a"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f7baece4ce06bade126fb84b8af1c33439a76d8a6fd818970215e0560ca28c27"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:cfdd747216947628af7b259d274771d84db2268ca062dd5faf373639d00113a3"}, + {file = "pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=7.3)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +fpx = ["olefile"] +mic = ["olefile"] +tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] +typing = ["typing-extensions ; python_version < \"3.10\""] +xmp = ["defusedxml"] + +[[package]] +name = "platformdirs" +version = "4.11.3" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "platformdirs-4.11.3-py3-none-any.whl", hash = "sha256:5ed065d443751de711da036041a7a214122efc4a4de393b3f4137ba5576540e7"}, + {file = "platformdirs-4.11.3.tar.gz", hash = "sha256:66a73d38a849810252df809a3d8bcbda8e26f6c189920e7535ad608a48dbb5ab"}, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pre-commit" +version = "3.8.0" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"}, + {file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + +[[package]] +name = "py-ip2region" +version = "3.0.4" +description = "ip2region official python binding with both IPv4 and IPv6 supported" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "py_ip2region-3.0.4-py3-none-any.whl", hash = "sha256:7668e3756bc941045cf317a2116fe8359b0a70d88cee326decb836caa14bd77d"}, + {file = "py_ip2region-3.0.4.tar.gz", hash = "sha256:a8b818ed502a17feb998bdcf7c7748466aff368bcc75592c443aedf4eb6987f7"}, +] + +[[package]] +name = "pycodestyle" +version = "2.14.0" +description = "Python style guide checker" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d"}, + {file = "pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783"}, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba"}, + {file = "pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.46.4" +typing-extensions = ">=4.14.1" +typing-inspection = ">=0.4.2" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4"}, + {file = "pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d"}, + {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4"}, + {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f"}, + {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39"}, + {file = "pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d"}, + {file = "pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf"}, + {file = "pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594"}, + {file = "pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3"}, + {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848"}, + {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3"}, + {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109"}, + {file = "pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda"}, + {file = "pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33"}, + {file = "pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d"}, + {file = "pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2"}, + {file = "pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b"}, + {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458"}, + {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b"}, + {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c"}, + {file = "pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894"}, + {file = "pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89"}, + {file = "pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a"}, + {file = "pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008"}, + {file = "pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e"}, + {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd"}, + {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be"}, + {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d"}, + {file = "pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb"}, + {file = "pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292"}, + {file = "pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d"}, + {file = "pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb"}, + {file = "pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb"}, + {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898"}, + {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e"}, + {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519"}, + {file = "pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4"}, + {file = "pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac"}, + {file = "pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596"}, + {file = "pydantic_core-2.46.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae"}, + {file = "pydantic_core-2.46.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6"}, + {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9"}, + {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29"}, + {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9"}, + {file = "pydantic_core-2.46.4-cp39-cp39-win32.whl", hash = "sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1"}, + {file = "pydantic_core-2.46.4-cp39-cp39-win_amd64.whl", hash = "sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983"}, + {file = "pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1"}, +] + +[package.dependencies] +typing-extensions = ">=4.14.1" + +[[package]] +name = "pydantic-settings" +version = "2.15.0" +description = "Settings management using Pydantic" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42"}, + {file = "pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117"}, +] + +[package.dependencies] +pydantic = ">=2.7.0" +python-dotenv = ">=0.21.0" +typing-inspection = ">=0.4.0" + +[package.extras] +aws-secrets-manager = ["boto3 (>=1.35.0)"] +azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] +gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"] +toml = ["tomli (>=2.0.1)"] +yaml = ["pyyaml (>=6.0.1)"] + +[[package]] +name = "pyflakes" +version = "3.4.0" +description = "passive checker of Python programs" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f"}, + {file = "pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58"}, +] + +[[package]] +name = "pygments" +version = "2.20.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, + {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pyjwt" +version = "2.13.0" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728"}, + {file = "pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423"}, +] + +[package.dependencies] +typing_extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] + +[[package]] +name = "pymysql" +version = "1.2.0" +description = "Pure Python MySQL Driver" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pymysql-1.2.0-py3-none-any.whl", hash = "sha256:62169ce6d5510f08e140c5e7990ee884a9764024e4a9a27b2cc11f1099322ae0"}, + {file = "pymysql-1.2.0.tar.gz", hash = "sha256:6c7b17ca686988104d7426c27895b455cdeea3e9d3ceb1270f0c3704fead8c33"}, +] + +[package.extras] +ed25519 = ["PyNaCl (>=1.6.2)"] +rsa = ["cryptography (>=46.0.7)"] + +[[package]] +name = "pytest" +version = "8.4.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, + {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1" +packaging = ">=20" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "0.23.8" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytest_asyncio-0.23.8-py3-none-any.whl", hash = "sha256:50265d892689a5faefb84df80819d1ecef566eb3549cf915dfb33569359d1ce2"}, + {file = "pytest_asyncio-0.23.8.tar.gz", hash = "sha256:759b10b33a6dc61cce40a8bd5205e302978bbbcc00e279a8b61d9a6a3c82e4d3"}, +] + +[package.dependencies] +pytest = ">=7.0.0,<9" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + +[[package]] +name = "pytest-cov" +version = "5.0.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, + {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, +] + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] + +[[package]] +name = "python-discovery" +version = "1.5.2" +description = "Python interpreter discovery" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "python_discovery-1.5.2-py3-none-any.whl", hash = "sha256:3e338c2d0f15dfaeea57493f4c2c6caebe0e998ea815c30ae8bf8ee21f1112d3"}, + {file = "python_discovery-1.5.2.tar.gz", hash = "sha256:45fd4f20a4e3f9b7bf2e0817870bc8e3b320a19658da177af800768c82dbf354"}, +] + +[package.dependencies] +filelock = ">=3.15.4" + +[[package]] +name = "python-dotenv" +version = "1.2.2" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"}, + {file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "pyyaml" +version = "6.0.3" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, + {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, + {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, + {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, + {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, + {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, + {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, + {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, + {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, + {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, + {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, + {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, + {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, +] + +[[package]] +name = "redis" +version = "4.6.0" +description = "Python client for Redis database and key-value store" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "redis-4.6.0-py3-none-any.whl", hash = "sha256:e2b03db868160ee4591de3cb90d40ebb50a90dd302138775937f6a42b7ed183c"}, + {file = "redis-4.6.0.tar.gz", hash = "sha256:585dc516b9eb042a619ef0a39c3d7d55fe81bdb4df09a52c9cdde0d07bf1aa7d"}, +] + +[package.dependencies] +async-timeout = {version = ">=4.0.2", markers = "python_full_version <= \"3.11.2\""} + +[package.extras] +hiredis = ["hiredis (>=1.0.0)"] +ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"] + +[[package]] +name = "ruff" +version = "0.4.10" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ruff-0.4.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5c2c4d0859305ac5a16310eec40e4e9a9dec5dcdfbe92697acd99624e8638dac"}, + {file = "ruff-0.4.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a79489607d1495685cdd911a323a35871abfb7a95d4f98fc6f85e799227ac46e"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1dd1681dfa90a41b8376a61af05cc4dc5ff32c8f14f5fe20dba9ff5deb80cd6"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c75c53bb79d71310dc79fb69eb4902fba804a81f374bc86a9b117a8d077a1784"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18238c80ee3d9100d3535d8eb15a59c4a0753b45cc55f8bf38f38d6a597b9739"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d8f71885bce242da344989cae08e263de29752f094233f932d4f5cfb4ef36a81"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:330421543bd3222cdfec481e8ff3460e8702ed1e58b494cf9d9e4bf90db52b9d"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e9b6fb3a37b772628415b00c4fc892f97954275394ed611056a4b8a2631365e"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f54c481b39a762d48f64d97351048e842861c6662d63ec599f67d515cb417f6"}, + {file = "ruff-0.4.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:67fe086b433b965c22de0b4259ddfe6fa541c95bf418499bedb9ad5fb8d1c631"}, + {file = "ruff-0.4.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:acfaaab59543382085f9eb51f8e87bac26bf96b164839955f244d07125a982ef"}, + {file = "ruff-0.4.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3cea07079962b2941244191569cf3a05541477286f5cafea638cd3aa94b56815"}, + {file = "ruff-0.4.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:338a64ef0748f8c3a80d7f05785930f7965d71ca260904a9321d13be24b79695"}, + {file = "ruff-0.4.10-py3-none-win32.whl", hash = "sha256:ffe3cd2f89cb54561c62e5fa20e8f182c0a444934bf430515a4b422f1ab7b7ca"}, + {file = "ruff-0.4.10-py3-none-win_amd64.whl", hash = "sha256:67f67cef43c55ffc8cc59e8e0b97e9e60b4837c8f21e8ab5ffd5d66e196e25f7"}, + {file = "ruff-0.4.10-py3-none-win_arm64.whl", hash = "sha256:dd1fcee327c20addac7916ca4e2653fbbf2e8388d8a6477ce5b4e986b68ae6c0"}, + {file = "ruff-0.4.10.tar.gz", hash = "sha256:3aa4f2bc388a30d346c56524f7cacca85945ba124945fe489952aadb6b5cd804"}, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.52" +description = "Database Abstraction Library" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "sqlalchemy-2.0.52-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a7438774e1091192fc50a2bd8ceff5c596912d00ecd46587e88effdea7826101"}, + {file = "sqlalchemy-2.0.52-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c1b7ed45bf87b214e0a9def9c2313949067efe6269db5ef18d542ee13250af7"}, + {file = "sqlalchemy-2.0.52-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:309cc8ba50fc5d2174189dfcd49cdf7aa711f8346afcff19f2642ae4fc449c14"}, + {file = "sqlalchemy-2.0.52-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2f9eccf8793c8c3f8dd2dfd11b9e400cb27d1d19370ef732b66017e212107822"}, + {file = "sqlalchemy-2.0.52-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9255ceb65a80c1b001129060b63ee776a2e9c288be3b662be36dfbb888fffdcd"}, + {file = "sqlalchemy-2.0.52-cp310-cp310-win32.whl", hash = "sha256:2e15b1d1116a64fc399b8c2694a83f3e792fdc58df28514a81e1dc4f8cf22729"}, + {file = "sqlalchemy-2.0.52-cp310-cp310-win_amd64.whl", hash = "sha256:11560064cc4696e772298b6221ede59e646386d9f2a85d549365473b972f7850"}, + {file = "sqlalchemy-2.0.52-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0c3ce43907374889f3352bdcc6195c970148a2cb71574cd0237a5071a37fb6c"}, + {file = "sqlalchemy-2.0.52-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a0d48c4b80717c61385b4e966e087c839a66cfd7b780641dcb428f4dba65608"}, + {file = "sqlalchemy-2.0.52-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:938325a5373267afc53bfbe72983b20fbd64ca47842aac62433c3da1137ecff1"}, + {file = "sqlalchemy-2.0.52-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5f8438a98d49424acf69d0d53c0a522951dfe49a6f2d86417fbb37ad3066ab43"}, + {file = "sqlalchemy-2.0.52-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4699dbb8d396d199e7e78fd4d525e3ad3d6008a9c8c0160b87e74c606c2c3736"}, + {file = "sqlalchemy-2.0.52-cp311-cp311-win32.whl", hash = "sha256:cef328349452ae152637df4d11ce5a0919ecdf0a363e16c830c3518ee33bde72"}, + {file = "sqlalchemy-2.0.52-cp311-cp311-win_amd64.whl", hash = "sha256:f1c850792a3b25a3ad74dade3f05e4f402cdebfea27438bcadafaa1617f77bcc"}, + {file = "sqlalchemy-2.0.52-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:be8c49131665dfe2cc74c498aa1240ffb548d0fd901325dd11c2c7a18956f727"}, + {file = "sqlalchemy-2.0.52-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b2d9e507a458832adcfbd8af6e2036ddf069b7710b799448542ebccae2dceee"}, + {file = "sqlalchemy-2.0.52-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8738008376d22f30f411ea3efecf39b51110b6996d80bb73786f30bcfdd5fd3b"}, + {file = "sqlalchemy-2.0.52-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37a4d548327b6cab9c7d8cdb4e0e82feabee0110c4d150059068e2d1cfbd99ee"}, + {file = "sqlalchemy-2.0.52-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e49f51a5d59857a7a0dcaf9469febf7197d9394bd88f00d69c2c4e848112cdbf"}, + {file = "sqlalchemy-2.0.52-cp312-cp312-win32.whl", hash = "sha256:afda3ec521d0517d0de783fc70030775841900896d832de5bbd066549290470e"}, + {file = "sqlalchemy-2.0.52-cp312-cp312-win_amd64.whl", hash = "sha256:2d5e53e36e37129fe0be8b9d08b6e4052c10a963ee6cda56c8c10dcc194b99ca"}, + {file = "sqlalchemy-2.0.52-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2eb3c6a64b1bfe6704777cfd504e7b8ad093a5f3e03ce67663a5e6742f294e43"}, + {file = "sqlalchemy-2.0.52-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:923bb183c1dc64fdf7b717965e3d59938ec4f8b8710b419a21ce403e5da9a9e1"}, + {file = "sqlalchemy-2.0.52-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:651d6d8782e80679e6151707c7b490834d46ada526328895abf567f25e63d29c"}, + {file = "sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b08cddb8989775e3c88799d86704bdfc3ee6e9846118201aa5997f16f27e3a15"}, + {file = "sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ab66fa9618269390d4dfa222f2f2f88f7bc4bf5da13905131b818217db7e8057"}, + {file = "sqlalchemy-2.0.52-cp313-cp313-win32.whl", hash = "sha256:c63bda077685c85ca513286547a531ba57e7a68cf0a7ed3bafcc2bbd18896f4d"}, + {file = "sqlalchemy-2.0.52-cp313-cp313-win_amd64.whl", hash = "sha256:9876b09b9f1ce7398b0ffece585c0a911244c53191187341f6bcae640e133751"}, + {file = "sqlalchemy-2.0.52-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:410d52be41d17f1a236d19520fbe776257dc16516ed06bd16d433311842aefd9"}, + {file = "sqlalchemy-2.0.52-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfe9ce533dbe4d0a2ae1486546619bd30b76bcd670539a44d910361376175f5e"}, + {file = "sqlalchemy-2.0.52-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:812bae5138bfc0aa46fb0686da0fc7f581f68e2bbb05bc24c3713bebaedd1437"}, + {file = "sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:50bff43b632a56fbf5ed9afdd76307e1512b62051bcd5afb341ae67205bbb6c8"}, + {file = "sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:49565daf5af554f538e23aef1fc81a95a4e49658f152285e45c02f5fc44f04cd"}, + {file = "sqlalchemy-2.0.52-cp314-cp314-win32.whl", hash = "sha256:ab9da41e61b9979b910499d633b241df20c51ee5037e5405b11c2faac3cbe1a2"}, + {file = "sqlalchemy-2.0.52-cp314-cp314-win_amd64.whl", hash = "sha256:a593db51b3bae75db17a5738ad5f992244b3a03863f83c28117ee482c6a3f76d"}, + {file = "sqlalchemy-2.0.52-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1e61d08bdf4ee2f41024569e3400de7d6734ba498144766b11260936ccfa582"}, + {file = "sqlalchemy-2.0.52-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1b92a1e23ed40022081217b40d2d1feba4f77064e69ef4f39f68bcbbd148452a"}, + {file = "sqlalchemy-2.0.52-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77a247d3fd179f6583171e7e0e98f40dc6642ed4f655557515a5a7e25923e9a4"}, + {file = "sqlalchemy-2.0.52-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd9206024b8602e7518bbaf44016c29e0045722f09328d8e654941023920d0b3"}, + {file = "sqlalchemy-2.0.52-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:46f0c46f0d360d727b84660b26c62b295d82306ec2c82b701e97747d2c6dcbe1"}, + {file = "sqlalchemy-2.0.52-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:df8f213ceb485d8227b74935eb87ba0d80169a8401eba7835da6e30d6727dac4"}, + {file = "sqlalchemy-2.0.52-cp38-cp38-win32.whl", hash = "sha256:f2b09029ef6f260409eefa5dc2b8276f6c3d7b892bfb50d50e8f852257d4a6b4"}, + {file = "sqlalchemy-2.0.52-cp38-cp38-win_amd64.whl", hash = "sha256:765f439da5bc8696973bc0c8a31fae0912ac3ff1cb9d66246a6b2728ee4fbbc8"}, + {file = "sqlalchemy-2.0.52-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4b89e93bb89eabdbea9d5d3fa2d6cc6544e733c33064339f91e5292480cf130e"}, + {file = "sqlalchemy-2.0.52-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf993f065bc04caa5000b339e8d9d6f3d9d00251511f850147c516c9e07115f"}, + {file = "sqlalchemy-2.0.52-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cce4922535db73f9dbb91e3db2b3e851ac629467fd1ebd8e354a60e369521c63"}, + {file = "sqlalchemy-2.0.52-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2f5fa2b2aca75d2c7f36db3a8dd04717b6fbfd1a964fb32bdeae16698e475ab3"}, + {file = "sqlalchemy-2.0.52-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:f4d4f7afc682961dc567db70e00a7b5bd81ccd3743c46199b0257f0744902dde"}, + {file = "sqlalchemy-2.0.52-cp39-cp39-win32.whl", hash = "sha256:de89de5b5798cafdd7ef7b7b804acec246d6152922128fd9d156cd1701271aff"}, + {file = "sqlalchemy-2.0.52-cp39-cp39-win_amd64.whl", hash = "sha256:3c95c3044edddb65e4a2f7194ec52ca5a9736f72d33ca3a6fa4196aedcc689fd"}, + {file = "sqlalchemy-2.0.52-py3-none-any.whl", hash = "sha256:3b81b8363a919ce53453591cdb93702e6bd54ade6c4fa2f468fc053baee5ed89"}, + {file = "sqlalchemy-2.0.52.tar.gz", hash = "sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97"}, +] + +[package.dependencies] +greenlet = {version = ">=1", markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\""} +typing-extensions = ">=4.6.0" + +[package.extras] +aiomysql = ["aiomysql (>=0.2.0)", "greenlet (>=1)"] +aioodbc = ["aioodbc", "greenlet (>=1)"] +aiosqlite = ["aiosqlite", "greenlet (>=1)", "typing_extensions (!=3.10.0.1)"] +asyncio = ["greenlet (>=1)"] +asyncmy = ["asyncmy (>=0.2.12)", "greenlet (>=1)"] +mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10)"] +mssql = ["pyodbc"] +mssql-pymssql = ["pymssql"] +mssql-pyodbc = ["pyodbc"] +mypy = ["mypy (>=0.910)"] +mysql = ["mysqlclient (>=1.4.0)"] +mysql-connector = ["mysql-connector-python"] +oracle = ["cx_oracle (>=8)"] +oracle-oracledb = ["oracledb (>=1.0.1)"] +postgresql = ["psycopg2 (>=2.7)"] +postgresql-asyncpg = ["asyncpg", "greenlet (>=1)"] +postgresql-pg8000 = ["pg8000 (>=1.29.1)"] +postgresql-psycopg = ["psycopg (>=3.0.7)"] +postgresql-psycopg2binary = ["psycopg2-binary"] +postgresql-psycopg2cffi = ["psycopg2cffi"] +postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] +pymysql = ["pymysql"] +sqlcipher = ["sqlcipher3_binary"] + +[[package]] +name = "tinycss2" +version = "1.5.1" +description = "A tiny CSS parser" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661"}, + {file = "tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957"}, +] + +[package.dependencies] +webencodings = ">=0.4" + +[package.extras] +doc = ["furo", "sphinx"] +test = ["pytest", "ruff"] + +[[package]] +name = "tomli" +version = "2.4.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +markers = "python_full_version <= \"3.11.0a6\"" +files = [ + {file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"}, + {file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"}, + {file = "tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076"}, + {file = "tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9"}, + {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c"}, + {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc"}, + {file = "tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049"}, + {file = "tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e"}, + {file = "tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece"}, + {file = "tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a"}, + {file = "tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085"}, + {file = "tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9"}, + {file = "tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5"}, + {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585"}, + {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1"}, + {file = "tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917"}, + {file = "tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9"}, + {file = "tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257"}, + {file = "tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54"}, + {file = "tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a"}, + {file = "tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897"}, + {file = "tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f"}, + {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d"}, + {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5"}, + {file = "tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd"}, + {file = "tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36"}, + {file = "tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd"}, + {file = "tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf"}, + {file = "tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac"}, + {file = "tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662"}, + {file = "tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853"}, + {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15"}, + {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba"}, + {file = "tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6"}, + {file = "tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7"}, + {file = "tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232"}, + {file = "tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4"}, + {file = "tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c"}, + {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d"}, + {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41"}, + {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c"}, + {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f"}, + {file = "tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8"}, + {file = "tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26"}, + {file = "tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396"}, + {file = "tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe"}, + {file = "tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"}, +] + +[[package]] +name = "tornado" +version = "6.5.8" +description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tornado-6.5.8-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:cc6aa787d7cfab7c3d35189dc7a56fbd2399a569624c730c6b55b3d6531d0403"}, + {file = "tornado-6.5.8-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9715b5eb79735b2bcd454ce216a9275b7c0470e64ea1bf5742f78b2f72b26eeb"}, + {file = "tornado-6.5.8-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:547d63f450d570c14fe0e8db2cfb14c9bbd1c2503b4a6612586267955aa47b58"}, + {file = "tornado-6.5.8-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e2360a0ffbe145eca8af0b19cb7203d79b1a98dd4cccdd6b368f6f49c2e3808"}, + {file = "tornado-6.5.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5d242290bdf7ab3151bc1065fdd75c0dcc21cbc7b49f22a4c56329c2d6566d22"}, + {file = "tornado-6.5.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7b94ff0e128fe0542f3bd331fb44d06260fc4ac16881545159f34ef08aad4195"}, + {file = "tornado-6.5.8-cp39-abi3-win32.whl", hash = "sha256:67832909c4779c64942380cb5f044a5c6163d00831472d80e25e115de9917836"}, + {file = "tornado-6.5.8-cp39-abi3-win_amd64.whl", hash = "sha256:11881db6b7c168494be2c2d12e65931451bdf7ee718535418ae1d8855dd5a0ee"}, + {file = "tornado-6.5.8-cp39-abi3-win_arm64.whl", hash = "sha256:68a7468c7e289f8514d7d664101753903217eff1bb6822c6b5994a0b5f5bcb26"}, + {file = "tornado-6.5.8.tar.gz", hash = "sha256:9452e1b208a8bd771e2cb1f2ff564985b9b214bdebbe622793e1799e0a6bd23f"}, +] + +[[package]] +name = "types-pymysql" +version = "1.2.0.20260807" +description = "Typing stubs for PyMySQL" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "types_pymysql-1.2.0.20260807-py3-none-any.whl", hash = "sha256:2a722284451649e1c2564655cba4963c0661851d04cc10195b7c002fab8e55f6"}, + {file = "types_pymysql-1.2.0.20260807.tar.gz", hash = "sha256:7fd4a7767925dbb0fd3e73fd488184fcd98b7612f8066cda280453b17864264f"}, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"}, + {file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"}, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147"}, + {file = "typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47"}, +] + +[package.dependencies] +typing-extensions = ">=4.15.0" + +[[package]] +name = "tzdata" +version = "2026.3" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +groups = ["main"] +markers = "platform_system == \"Windows\"" +files = [ + {file = "tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931"}, + {file = "tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415"}, +] + +[[package]] +name = "tzlocal" +version = "5.4.4" +description = "tzinfo object for the local timezone" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "tzlocal-5.4.4-py3-none-any.whl", hash = "sha256:aae09f0126a8a86fa736be266eb4a471380d26a0de3bc14844e7821fee3e2a15"}, + {file = "tzlocal-5.4.4.tar.gz", hash = "sha256:8dbb8660838688a7b6ba4fed31d18dedf842afb4d47ca050d6d891c2c15f3be4"}, +] + +[package.dependencies] +tzdata = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +devenv = ["zest.releaser"] +testing = ["check_manifest", "pyroma", "pytest (>=4.3)", "pytest-cov", "pytest-mock (>=3.3)", "ruff"] + +[[package]] +name = "virtualenv" +version = "21.7.4" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "virtualenv-21.7.4-py3-none-any.whl", hash = "sha256:376ec93cd6aab3044fa395d7db226db38043b7b5748948044b2a87168525e843"}, + {file = "virtualenv-21.7.4.tar.gz", hash = "sha256:c9d960c95fa458171e58222a5ccab7465298e4b6559977865e627c4719f1e825"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = {version = ">=3.24.2,<4", markers = "python_version >= \"3.10\""} +platformdirs = ">=3.9.1,<5" +python-discovery = ">=1.4.2" +typing-extensions = {version = ">=4.13.2", markers = "python_version < \"3.11\""} + +[[package]] +name = "webencodings" +version = "0.5.1" +description = "Character encoding aliases for legacy web content" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"}, + {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"}, +] + +[extras] +mssql = [] +oracle = [] +postgresql = [] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.10" +content-hash = "7f340d1e9c0eb3922cae4233c8e4d54ccd3b078b859c9779556671c17173dee4" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1e44509 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,62 @@ +[build-system] +requires = ["poetry-core>=2.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry] +name = "djangoadmin-tornado-antdvue" +version = "2.8.0" +description = "DjangoAdmin 敏捷开发框架 Tornado + AntdVue 版本(模块化、高性能、企业级)" +authors = ["小蚂蚁云团队 "] +license = "Apache-2.0" +readme = "README.md" +homepage = "https://www.djangoadmin.cn" +packages = [{ include = "*", from = "src" }] + +[tool.poetry.dependencies] +python = ">=3.10" +tornado = "^6.3.2" +sqlalchemy = "^2.0.18" +pymysql = "^1.1.0" +redis = "^4.6.0" +PyJWT = "^2.7.0" +pydantic = "^2.0.2" +pydantic-settings = "^2.0.0" +bcrypt = "^4.0.0" +python-dotenv = "^1.0.0" +Pillow = "^10.0.0" +openpyxl = "^3.1.2" +APScheduler = "^3.11.2" + +# 可选数据库驱动(默认 MySQL 使用 pymysql): +# poetry install -E postgresql / -E mssql / -E oracle +# SQLite 走内置 sqlite3,无需额外安装 +bleach = "^6.4.0" +tinycss2 = "^1.5.1" +py-ip2region = "3.0.4" +[tool.poetry.extras] +postgresql = ["psycopg2-binary"] +mssql = ["pymssql"] +oracle = ["oracledb"] + +[tool.poetry.group.dev.dependencies] +pytest = "^8.0.0" +pytest-asyncio = ">=0.21,<0.24" +pytest-cov = "^5.0.0" +mypy = "^1.8.0" +flake8 = "^7.0.0" +pre-commit = "^3.6.0" +ruff = "^0.4.0" +types-PyMySQL = "^1.1.0.4" + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] +asyncio_mode = "auto" + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[tool.mypy] +python_version = "3.10" +ignore_missing_imports = true diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4d17f19 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,30 @@ +# ============================================================ +# Web 框架 +# ============================================================ +tornado == 6.5.8 + +# ============================================================ +# 数据库驱动 +# ============================================================ +psycopg2-binary >= 2.9 +pymssql >= 2.3.0 +oracledb == 4.0.2 +PyMySQL == 1.2.0 +# SQLite: 内置支持,无需额外驱动 + +# ============================================================ +# 核心依赖(按字母顺序) +# ============================================================ +APScheduler == 3.11.3 +bcrypt == 5.0.0 +openpyxl == 3.1.5 +Pillow == 12.3.0 +pydantic == 2.13.4 +pydantic-settings == 2.15.0 +py-ip2region == 3.0.4 +PyJWT == 2.13.0 +python-dotenv == 1.2.2 +redis == 8.0.1 +SQLAlchemy == 2.0.51 +bleach == 6.4.0 +tinycss2 == 1.5.1 \ No newline at end of file diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..1995fe6 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""运维脚本集合。""" diff --git a/scripts/deploy.py b/scripts/deploy.py new file mode 100644 index 0000000..e0fd60c --- /dev/null +++ b/scripts/deploy.py @@ -0,0 +1,65 @@ +"""部署脚本。 + +用法: + python scripts/deploy.py --check # 预检(依赖/配置/数据库连通性) + python scripts/deploy.py --health # 服务健康检查 + python scripts/deploy.py --docker # docker compose 构建并启动 +""" +import argparse +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + + +def pre_check() -> None: + print("== 依赖检查 ==") + try: + import tornado + import sqlalchemy + import pydantic + print(" 核心依赖已安装") + except ImportError as e: + print(f" 缺少依赖: {e}") + sys.exit(1) + + print("== 配置文件检查 ==") + if (ROOT / ".env").exists(): + print(" .env 存在") + else: + print(" 警告: .env 不存在,将从 .env.example 复制") + (ROOT / ".env.example").read_text() + (ROOT / ".env").write_text((ROOT / ".env.example").read_text()) + print(" 已复制 .env.example -> .env") + + print("== 预检完成 ==") + + +def health() -> None: + subprocess.run([sys.executable, str(ROOT / "scripts" / "health_check.py")]) + + +def docker_deploy() -> None: + subprocess.run(["docker", "compose", "up", "-d", "--build"], cwd=str(ROOT), check=True) + + +def main() -> None: + parser = argparse.ArgumentParser(description="部署脚本") + parser.add_argument("--check", action="store_true", help="部署预检") + parser.add_argument("--health", action="store_true", help="健康检查") + parser.add_argument("--docker", action="store_true", help="Docker 部署") + args = parser.parse_args() + + if args.check: + pre_check() + elif args.health: + health() + elif args.docker: + docker_deploy() + else: + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/scripts/health_check.py b/scripts/health_check.py new file mode 100644 index 0000000..97aa250 --- /dev/null +++ b/scripts/health_check.py @@ -0,0 +1,40 @@ +"""健康检查脚本。 + +用法:python scripts/health_check.py [--host HOST] [--port PORT] +""" +import argparse +import sys +import urllib.request +from pathlib import Path + +SRC = Path(__file__).resolve().parent.parent / "src" +sys.path.insert(0, str(SRC)) + +from config.app import TORNADO_HOST, TORNADO_PORT + + +def check(host: str, port: int) -> None: + url = f"http://{host}:{port}/api/v1/health" + try: + with urllib.request.urlopen(url, timeout=10) as resp: + body = resp.read().decode("utf-8") + if resp.status == 200: + print(f"[OK] {url} -> {resp.status} {body}") + else: + print(f"[WARN] {url} -> {resp.status} {body}") + sys.exit(1) + except Exception as e: + print(f"[FAIL] {url} 不可达: {e}") + sys.exit(1) + + +def main() -> None: + parser = argparse.ArgumentParser(description="健康检查") + parser.add_argument("--host", default=TORNADO_HOST) + parser.add_argument("--port", type=int, default=TORNADO_PORT) + args = parser.parse_args() + check(args.host, args.port) + + +if __name__ == "__main__": + main() diff --git a/scripts/init_db.py b/scripts/init_db.py new file mode 100644 index 0000000..46aec92 --- /dev/null +++ b/scripts/init_db.py @@ -0,0 +1,80 @@ +"""数据库初始化脚本。 + +用法: + python scripts/init_db.py # 根据模型创建表 + python scripts/init_db.py --sql <文件> # 导入 SQL 结构文件 +""" +import argparse +import sys +from pathlib import Path + +# 将 src 目录加入 sys.path +SRC = Path(__file__).resolve().parent.parent / "src" +sys.path.insert(0, str(SRC)) + +from config.database import DATABASE_URL +from extensions.sqlalchemy import Base, engine + + +def import_sql(sql_file: Path) -> None: + """导入 SQL 文件(按分号+换行拆分逐条执行,避免字符串内分号误切)。""" + from sqlalchemy import text + + if not sql_file.exists(): + print(f"SQL 文件不存在: {sql_file}") + sys.exit(1) + + content = sql_file.read_text(encoding="utf-8", errors="replace") + statements = [s.strip() for s in content.split(";\n") if s.strip()] + + with engine.begin() as conn: + for statement in statements: + conn.execute(text(statement)) + print(f"SQL 导入完成: {sql_file}(共 {len(statements)} 条语句)") + + +def create_tables() -> None: + """根据模型定义创建全部数据表。""" + # 导入各模块 models 触发注册 + import modules.auth.models + import modules.user.models + import modules.user_role.models + import modules.role.models + import modules.role_menu.models + import modules.menu.models + import modules.dept.models + import modules.category.models + import modules.article.models + import modules.param.models + import modules.file_template.models + import modules.job.models + import modules.dictionary.dict.models + import modules.dictionary.dict_item.models + import modules.configuration.config.models + import modules.configuration.config_item.models + import modules.level.models + import modules.position.models + import modules.city.models + import modules.notice.models + import modules.link.models + import modules.operation_log.models + import modules.login_log.models + import modules.job_log.models + import modules.example.models + + Base.metadata.create_all(bind=engine) + print(f"数据表创建完成,目标数据库: {DATABASE_URL.rsplit('/', 1)[-1]}") + + +def main() -> None: + parser = argparse.ArgumentParser(description="数据库初始化") + parser.add_argument("--sql", type=Path, default=None, help="SQL 结构文件路径") + args = parser.parse_args() + if args.sql: + import_sql(args.sql) + else: + create_tables() + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate.py b/scripts/migrate.py new file mode 100644 index 0000000..3991255 --- /dev/null +++ b/scripts/migrate.py @@ -0,0 +1,32 @@ +"""数据库迁移脚本(Alembic 封装)。 + +用法: + python scripts/migrate.py # 升级到最新 + python scripts/migrate.py --revision abc123 # 升级到指定版本 +""" +import argparse +import subprocess +import sys + + +def run_alembic(args_list: list[str]) -> None: + cmd = [sys.executable, "-m", "alembic", *args_list] + print("执行:", " ".join(cmd)) + result = subprocess.run(cmd) + sys.exit(result.returncode) + + +def main() -> None: + parser = argparse.ArgumentParser(description="数据库迁移") + parser.add_argument("--revision", type=str, default="head", help="迁移目标版本") + parser.add_argument("--downgrade", action="store_true", help="回滚迁移") + args = parser.parse_args() + + if args.downgrade: + run_alembic(["downgrade", args.revision]) + else: + run_alembic(["upgrade", args.revision]) + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_db.py b/scripts/migrate_db.py new file mode 100644 index 0000000..768727e --- /dev/null +++ b/scripts/migrate_db.py @@ -0,0 +1,615 @@ +""" +跨库数据迁移脚本(MySQL → PostgreSQL / SQL Server / SQLite 等) +============================================================= + +把「源库」的数据整体搬到「目标库」: +- 目标库结构用模型 metadata 生成(Base.metadata.create_all,方言可移植, + 与 init_db.py / alembic 0001_baseline 同源,模型即 head,含 + 0002 的 city.parent_code nullable); +- 数据按拓扑序逐表 SELECT ... ORDER BY id,显式带 id 批量插入,保住 + 无外键裸列引用关系(user.dept_id、role_menu.role_id/menu_id 等); +- 最后按方言重置自增序列(PG setval / MSSQL IDENTITY_INSERT + DBCC + CHECKIDENT / Oracle 为每表重建 sequence + BEFORE INSERT 触发器; + MySQL / SQLite 显式插 id 后自动跟随,无需处理)。 + +与 init_db.py 的分工: +- init_db.py 单库初始化(建库 + 导 MySQL dump + 补表 + alembic stamp) +- migrate_db.py 双库迁移(源库 → 目标库,CLI 显式参数,不依赖 .env 连接串) + +用法: + python scripts/migrate_db.py \ + --src-driver=mysql --src-host=127.0.0.1 --src-port=3306 \ + --src-db=djangoadmin.fastapi.antdvue --src-user=root --src-pass=root \ + --dst-driver=postgresql --dst-host=127.0.0.1 --dst-port=5432 \ + --dst-db=djangoadmin.fastapi.antdvue --dst-user=root --dst-pass=root \ + --drop-target-first + +先干跑核对计划:加 --dry-run(只连源、逐表数行数、打印计划,不碰目标库)。 +""" + +import argparse +import os +import sys +import time +from pathlib import Path +from urllib.parse import quote_plus + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src")) + +# Windows 控制台默认 GBK(cp936):✓ 等字符无法编码会崩溃、中文按终端编码显示成乱码。 +# 统一把 stdout/stderr 切到 UTF-8,Git Bash / 重定向均正常;无 reconfigure 的环境跳过。 +if sys.stdout and hasattr(sys.stdout, 'reconfigure'): + sys.stdout.reconfigure(encoding='utf-8') + sys.stderr.reconfigure(encoding='utf-8') + +from sqlalchemy import create_engine, inspect, select, text +from sqlalchemy.pool import NullPool + +from config.database import DB_PREFIX +from core.database import Base + + +def _normalize_driver(driver: str) -> str: + """方言别名 → 规范名(postgres=postgresql、sqlserver=mssql、sqlite3=sqlite)。""" + aliases = {"postgres": "postgresql", "sqlserver": "mssql", "sqlite3": "sqlite"} + return aliases.get(driver.strip().lower(), driver.strip().lower()) + + +def _sqlite_url(database: str) -> str: + """SQLite 连接串(database 即数据库文件路径,反斜杠转正斜杠)。""" + if not database: + return "sqlite:///:memory:" + return f"sqlite:///{database.replace(chr(92), '/')}" + + +def _load_models() -> None: + """导入各模块 models 填充 Base.metadata(与 init_db.py / alembic env.py 同源,模型即 head)。""" + import modules.auth.models + import modules.user.models + import modules.user_role.models + import modules.role.models + import modules.role_menu.models + import modules.menu.models + import modules.dept.models + import modules.category.models + import modules.article.models + import modules.param.models + import modules.file_template.models + import modules.job.models + import modules.dictionary.dict.models + import modules.dictionary.dict_item.models + import modules.configuration.config.models + import modules.configuration.config_item.models + import modules.level.models + import modules.position.models + import modules.city.models + import modules.notice.models + import modules.link.models + import modules.operation_log.models + import modules.login_log.models + import modules.job_log.models + import modules.example.models + + +# 各驱动默认端口(sqlite 无端口概念) +_DEFAULT_PORTS = {"mysql": 3306, "postgresql": 5432, "mssql": 1433, "oracle": 1521, "sqlite": None} + +# 迁移顺序:基础/被引用表在前,依赖表在后。项目无外键,表间关联是裸 Integer 列 +# 由应用层维护,显式带 id 插入即保住引用关系;保序只是锦上添花(父先子后)。 +_BASE_TABLES = [ + "dept", "menu", "role", "level", "position", "city", "category", + "dict", "config", "file_template", "param", "job", "link", +] +_DEP_TABLES = [ + "role_menu", "dict_item", "config_item", "job_log", + "user", "user_role", "article", "notice", "operation_log", "login_log", +] + + +def build_parser() -> argparse.ArgumentParser: + """CLI 参数:源/目标两端连接显式给出,不依赖 .env。""" + p = argparse.ArgumentParser(description="跨库数据迁移:源库 → 目标库") + + def add_side(side: str, default_user: str): + p.add_argument( + f"--{side}-driver", + choices=("mysql", "postgresql", "mssql", "oracle", "sqlite"), + default="mysql", + help="驱动(oracle 时 --{side}-db 即 service_name;sqlite 时即数据库文件路径)", + ) + p.add_argument(f"--{side}-host", default="127.0.0.1") + p.add_argument(f"--{side}-port", type=int, default=None, + help="端口,缺省按驱动默认(mysql 3306 / pg 5432 / mssql 1433 / oracle 1521)") + p.add_argument(f"--{side}-db", required=True, help="数据库名(sqlite 时为文件路径)") + p.add_argument(f"--{side}-user", default=default_user) + p.add_argument(f"--{side}-pass", default="", + help=f"口令,留空回落环境变量 {side.upper()}_DB_PASS(避免进 shell 历史)") + + add_side("src", "root") + add_side("dst", "postgres") + + p.add_argument("--drop-target-first", action="store_true", help="先删目标库再重建(默认关)") + p.add_argument("--dry-run", action="store_true", help="只连源、逐表数行数、打印计划,不碰目标库") + p.add_argument("--chunk-size", type=int, default=1000, help="每批插入行数(默认 1000)") + p.add_argument("--only-tables", default="", help="逗号分隔的表名子集(全名或去掉前缀的短名),续传用") + p.add_argument("--skip-existing", action="store_true", help="目标已等于源行数的表跳过") + p.add_argument("--skip-verify", action="store_true", help="跳过迁移后的逐表行数校验") + p.add_argument("--single-transaction", action="store_true", help="全部表包进一个大事务(默认每表一个事务)") + return p + + +# ============================================================ +# URL / 引擎 +# ============================================================ + +def make_url(driver: str, host: str, port: int, database: str, username: str, password: str) -> str: + """带显式参数的连接串构建器,URL 形态与 config.database.DATABASE_URL 的多方言拼接规则一致 + (config.database 读 .env 全局,脚本端需要显式参数版)。""" + drv = _normalize_driver(driver) + if drv == 'sqlite': + return _sqlite_url(database) # 此时 database 即文件路径,忽略 host/port/user/pass + base = quote_plus(username) + ':' + quote_plus(password) + '@' + host + ':' + str(port) + if drv == 'oracle': + # Oracle 的"库"即连接的服务名(service_name),schema 是连接用户 + return 'oracle+oracledb://' + base + '/?service_name=' + quote_plus(database) + base = base + '/' + database + if drv == 'mysql': + return 'mysql+pymysql://' + base + '?charset=utf8mb4' + if drv == 'postgresql': + return 'postgresql+psycopg://' + base + return 'mssql+pymssql://' + base + '?charset=utf8' + + +def make_engine(driver: str, host: str, port: int, database: str, username: str, password: str): + """迁移专用引擎:NullPool,避免连接池把目标/源的会话状态串起来。""" + return create_engine(make_url(driver, host, port, database, username, password), poolclass=NullPool) + + +def _resolve_port(driver: str, port: int | None) -> int | None: + return port if port is not None else _DEFAULT_PORTS[driver] + + +def _fmt_duration(sec: float) -> str: + """耗时格式化:<1s 显示毫秒,<60s 显示秒,否则分+秒。""" + if sec < 1: + return f"{sec * 1000:.0f}ms" + if sec < 60: + return f"{sec:.1f}s" + return f"{int(sec // 60)}m{int(sec % 60)}s" + + +_BUSY = "[·]" # 阶段进行中 +_DONE = "[✓]" # 阶段完成 + + +def _phase_start(n: int, total: int, desc: str, hint: bool = True): + """阶段开始横幅:`[·] [1/5] 建目标库...(可能耗时较长,请勿中断)`""" + tail = "(可能耗时较长,请勿中断)" if hint else "" + print(f"{_BUSY} [{n}/{total}] {desc}...{tail}") + + +def _phase_done(n: int, total: int, desc: str, t0: float, extra: str = ""): + """阶段完成横幅:`[✓] [1/5] 建目标库 完成(耗时 947ms)`""" + print(f"{_DONE} [{n}/{total}] {desc} 完成(耗时 {_fmt_duration(time.perf_counter() - t0)}){extra}") + + +# ============================================================ +# 建目标库(镜像 init_db.py 的分支) +# ============================================================ + +def create_database_if_missing(driver: str, host: str, port: int, database: str, + username: str, password: str, drop_first: bool = False, + tables: tuple = ()): + """按方言连维护库建目标库(不存在才建)。sqlite 无建库概念,只确保父目录并可选删旧文件。 + + - mysql: 连无库连接 CREATE DATABASE ... utf8mb4 + - postgresql: 连 postgres 维护库(autocommit),库名含点号必须双引号 + - mssql: 连 master 维护库,标识符用方括号 + - oracle: 服务由 DBA 管理,schema 即连接用户,无需/无法"建库";仅校验连通性, + drop_first 时尽力 DROP TABLE PURGE 清掉 tables 里已存在的项目表 + """ + if driver == 'sqlite': + file = Path(database).expanduser() + print(f" · 检查/准备 sqlite 文件 {file}...") + if file.parent.as_posix() != '.': + file.parent.mkdir(parents=True, exist_ok=True) + if drop_first and file.exists(): + print(f" · 删除旧 sqlite 文件 {file}...") + file.unlink() + return + + if driver == 'mysql': + import pymysql + print(" · 正在连接 MySQL(无库连接)...") + conn = pymysql.connect(host=host, port=int(port), user=username, password=password, autocommit=True) + cur = conn.cursor() + if drop_first: + print(f" · 正在删除旧目标库 `{database}`...") + cur.execute(f"DROP DATABASE IF EXISTS `{database}`") + print(f" · 正在创建目标库 `{database}`(utf8mb4)...") + cur.execute( + f"CREATE DATABASE IF NOT EXISTS `{database}` " + f"DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci" + ) + cur.close() + conn.close() + return + + if driver == 'postgresql': + import psycopg + # 连 postgres 维护库创建目标库;DB_DATABASE 含点号,标识符必须双引号 + print(" · 正在连接 postgres 维护库...") + conn = psycopg.connect( + host=host, port=int(port), user=username, password=password, + dbname="postgres", autocommit=True, + ) + with conn.cursor() as cur: + cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (database,)) + exists = cur.fetchone() is not None + if drop_first and exists: + print(f" · 正在删除旧目标库 \"{database}\"(WITH FORCE)...") + cur.execute(f'DROP DATABASE "{database}" WITH (FORCE)') + exists = False + if not exists: + # 强制 UTF8 编码(TEMPLATE template0 避免继承模板库非 UTF8 编码导致 CJK 乱码) + print(f" · 正在创建目标库 \"{database}\"(UTF8)...") + cur.execute(f'CREATE DATABASE "{database}" ENCODING \'UTF8\' TEMPLATE template0') + conn.close() + return + + if driver == 'oracle': + # 服务由 DBA 管理,schema 就是连接用户;连上即视为"目标库就绪" + import oracledb + print(f" · 正在连接 Oracle 服务 {database}(schema={username})...") + conn = oracledb.connect(user=username, password=password, dsn=f"{host}:{port}/{database}") + cur = conn.cursor() + cur.execute("SELECT 1 FROM dual") + cur.fetchone() + if drop_first: + print(f" · drop-first:清理 {len(tables)} 张旧项目表(DROP TABLE ... PURGE)...") + for t in tables: + try: + cur.execute(f"DROP TABLE {t} PURGE") + except Exception: + pass # 表不存在则跳过 + conn.close() + return + + # mssql:连 master 维护库创建目标库 + import pymssql + print(" · 正在连接 SQL Server(master 维护库)...") + conn = pymssql.connect( + server=host, user=username, password=password, + port=int(port), database="master", autocommit=True, + ) + cur = conn.cursor() + if drop_first: + print(f" · 正在删除旧目标库 [{database}](SINGLE_USER + DROP)...") + cur.execute( + f"IF DB_ID(N'{database}') IS NOT NULL " + f"ALTER DATABASE [{database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE" + ) + cur.execute(f"IF DB_ID(N'{database}') IS NOT NULL DROP DATABASE [{database}]") + print(f" · 正在创建目标库 [{database}]...") + cur.execute(f"IF DB_ID(N'{database}') IS NULL CREATE DATABASE [{database}]") + cur.close() + conn.close() + + +# ============================================================ +# 迁移顺序 +# ============================================================ + +def ordered_tables(): + """按拓扑序返回要迁移的 Table 对象(base 先、dep 后;metadata 中未列出的追加末尾)。""" + out, seen = [], set() + for name in _BASE_TABLES + _DEP_TABLES: + tbl = Base.metadata.tables.get(DB_PREFIX + name) + if tbl is not None: + out.append(tbl) + seen.add(tbl.name) + for tbl in Base.metadata.tables.values(): + if tbl.name not in seen: + out.append(tbl) + return out + + +# ============================================================ +# 泵数据 +# ============================================================ + +def sanitize(payload: list[dict], table) -> list[dict]: + """把驱动读回的零日期/非法日期字符串统一置 None(防御性兜底)。 + + PyMySQL 默认已把 MySQL 的 '0000-00-00 00:00:00' 转成 None;此处兜底处理 + 个别驱动返回原始字符串的情况,避免 PostgreSQL/MSSQL 拒绝非法日期。 + 本项目时间列全部 nullable,置 None 安全。 + """ + for row in payload: + for col in table.columns: + v = row.get(col.name) + if isinstance(v, str) and v.startswith('0000-00-00'): + row[col.name] = None + return payload + + +def pump_table(src, dst, table, chunk_size: int, driver: str) -> int: + """逐 chunk 批量插入一张表,显式带 id 以保住裸列引用关系。 + + src/dst 为已就绪的 Connection;dst 处于调用方开启的事务内 + (默认每表一个事务;--single-transaction 时全库一个大事务)。 + mssql 必须 SET IDENTITY_INSERT ON 才能显式插自增 id。 + """ + pk = next(iter(table.primary_key.columns)) + sel = select(*table.columns).order_by(pk) # ORDER BY id:自引用父先子后 + use_identity = (driver == 'mssql') + total = 0 + t0 = time.perf_counter() + if use_identity: + dst.execute(text(f"SET IDENTITY_INSERT [{table.name}] ON")) + try: + result = src.execute(sel) + while True: + rows = result.fetchmany(chunk_size) + if not rows: + break + payload = sanitize([dict(r._mapping) for r in rows], table) + dst.execute(table.insert(), payload) + total += len(payload) + print(f" [{table.name}] +{len(payload)}(累计 {total})") + finally: + if use_identity: + dst.execute(text(f"SET IDENTITY_INSERT [{table.name}] OFF")) + print(f" [✓ {table.name}] 共 {total} 行(耗时 {_fmt_duration(time.perf_counter() - t0)})") + return total + + +# ============================================================ +# 自增序列重置 +# ============================================================ + +def pg_reset_sql(table_name: str) -> str: + """PostgreSQL:把 SERIAL/IDENTITY 序列推进到 MAX(id),否则显式插 id 后新插入会主键冲突。 + + - 非空表:setval(seq, MAX(id)),is_called=true → 下一 id = MAX(id)+1 + - 空表: setval(seq, 1, false),is_called=false → 下一 id = 1 + (不能 setval(seq, 0):PG 序列最小值是 1,会抛 NumericValueOutOfRange) + """ + return ( + f"SELECT setval(pg_get_serial_sequence('{table_name}', 'id'), " + f"COALESCE((SELECT MAX(id) FROM \"{table_name}\"), 1), " + f"(SELECT MAX(id) FROM \"{table_name}\") IS NOT NULL)" + ) + + +def mssql_reset_sql(table_name: str, max_id) -> str: + """SQL Server:RESEED 必须是字面量,先查 MAX(id) 再拼。空表 RESEED 0 → 下一 id=1。""" + return f"DBCC CHECKIDENT ('{table_name}', RESEED, {max_id or 0})" + + +def oracle_sequence_sql(table_name: str, start_with: int) -> str: + """Oracle:为无自增机制的 INTEGER 主键建 sequence,START WITH 决定下一 id。""" + return f"CREATE SEQUENCE {table_name}_id_seq START WITH {start_with} INCREMENT BY 1 NOCACHE" + + +def oracle_trigger_sql(table_name: str) -> str: + """Oracle:BEFORE INSERT 触发器,id 为 NULL 时从 sequence 取 NEXTVAL。""" + seq = f"{table_name}_id_seq" + trg = f"{table_name}_id_trg" + return ( + f"CREATE OR REPLACE TRIGGER {trg}\n" + f"BEFORE INSERT ON {table_name} FOR EACH ROW\n" + f"WHEN (NEW.id IS NULL)\n" + f"BEGIN SELECT {seq}.NEXTVAL INTO :NEW.id FROM dual; END;" + ) + + +def ensure_oracle_autoincrement(conn, table_name: str): + """Oracle 迁移后补自增机制:重建 sequence(START WITH MAX(id)+1)+ BEFORE INSERT 触发器。 + + Oracle 的 create_all 对 Integer 自增主键只生成 INTEGER NOT NULL(无 identity/sequence), + 不补的话应用 save() 拿不到新 id。空表 MAX(id) 为 NULL → COALESCE 0 → START WITH 1。 + Oracle 无 DROP SEQUENCE IF EXISTS,先查 user_sequences 再删。 + """ + max_id = conn.execute(text(f"SELECT COALESCE(MAX(id), 0) FROM {table_name}")).scalar() + seq = f"{table_name}_id_seq" + exists = conn.execute(text( + "SELECT COUNT(*) FROM user_sequences WHERE sequence_name = UPPER(:n)" + ), {"n": seq}).scalar() + if exists: + conn.execute(text(f"DROP SEQUENCE {seq}")) + conn.execute(text(oracle_sequence_sql(table_name, max_id + 1))) + conn.execute(text(oracle_trigger_sql(table_name))) + + +def reset_sequence(driver: str, conn, table): + """迁移后重置自增序列。MySQL / SQLite 显式插 id 后 auto_increment/rowid 自动 + 跟随 MAX(id),无需处理;PostgreSQL / SQL Server / Oracle 需要。""" + if driver == 'postgresql': + conn.execute(text(pg_reset_sql(table.name))) + elif driver == 'mssql': + max_id = conn.execute(text(f"SELECT MAX(id) FROM [{table.name}]")).scalar() + conn.execute(text(mssql_reset_sql(table.name, max_id))) + elif driver == 'oracle': + ensure_oracle_autoincrement(conn, table.name) + + +# ============================================================ +# 校验 +# ============================================================ + +def verify_counts(src, dst, tables) -> list[tuple[str, int, int]]: + """逐表 count 对比,返回不一致的表列表((表名, 源行数, 目标行数))。""" + failed = [] + for t in tables: + s = src.execute(text(f"SELECT COUNT(*) FROM {t.name}")).scalar() + d = dst.execute(text(f"SELECT COUNT(*) FROM {t.name}")).scalar() + mark = "PASS" if s == d else "FAIL" + print(f" [{t.name}] source={s} target={d} {mark}") + if s != d: + failed.append((t.name, s, d)) + return failed + + +def _existing_equal(src, dst, table) -> bool: + s = src.execute(text(f"SELECT COUNT(*) FROM {table.name}")).scalar() + d = dst.execute(text(f"SELECT COUNT(*) FROM {table.name}")).scalar() + return s == d + + +# ============================================================ +# 主流程 +# ============================================================ + +def _dry_run(src_engine, tables, dst_driver: str, dst_db: str, t_start: float) -> int: + print("=" * 62) + print(f"[DRY-RUN 开始] 计划迁移 {len(tables)} 张表 → {dst_driver}:{dst_db}") + print("=" * 62) + with src_engine.connect() as src: + for t in tables: + n = src.execute(text(f"SELECT COUNT(*) FROM {t.name}")).scalar() + print(f" - {t.name}: {n} 行") + print(f"[DRY-RUN 完成] {len(tables)} 张表核对完毕(耗时 {_fmt_duration(time.perf_counter() - t_start)}),未触碰目标库") + return 0 + + +def run(args) -> int: + """执行一次迁移。返回进程退出码(0 成功 / 1 校验失败)。""" + src_driver = _normalize_driver(args.src_driver) + dst_driver = _normalize_driver(args.dst_driver) + + src_engine = make_engine( + src_driver, args.src_host, _resolve_port(src_driver, args.src_port), + args.src_db, args.src_user, args.src_pass or os.environ.get("SRC_DB_PASS", ""), + ) + dst_engine = make_engine( + dst_driver, args.dst_host, _resolve_port(dst_driver, args.dst_port), + args.dst_db, args.dst_user, args.dst_pass or os.environ.get("DST_DB_PASS", ""), + ) + + # 加载全部模型填充 Base.metadata(与 init_db.py / alembic env.py 同源) + _load_models() + + tables = ordered_tables() + if args.only_tables: + wanted = {x.strip() for x in args.only_tables.split(",") if x.strip()} + tables = [t for t in tables if t.name in wanted or t.name[len(DB_PREFIX):] in wanted] + + try: + # 源端缺表容错(老库可能缺整表) + src_insp = inspect(src_engine) + present, missing = [], [] + for t in tables: + if src_insp.has_table(t.name): + present.append(t) + else: + missing.append(t) + for t in missing: + print(f"[SKIP] 源库不存在表 {t.name},跳过") + + t_start = time.perf_counter() + if args.dry_run: + return _dry_run(src_engine, present, dst_driver, args.dst_db, t_start) + + print("=" * 62) + print(f"[迁移开始] 源: {src_driver}:{args.src_db} → 目标: {dst_driver}:{args.dst_db}") + note = "" if not missing else f",源端缺失 {len(missing)} 张已跳过" + print(f"[迁移开始] 待迁移 {len(present)} 张表{note}") + print("=" * 62) + + # 1) 建目标库 + t0 = time.perf_counter() + _phase_start(1, 5, "建目标库") + create_database_if_missing( + dst_driver, args.dst_host, _resolve_port(dst_driver, args.dst_port), + args.dst_db, args.dst_user, args.dst_pass or os.environ.get("DST_DB_PASS", ""), + drop_first=args.drop_target_first, + tables=tuple(t.name for t in present), # Oracle drop_first 时清表用 + ) + _phase_done(1, 5, "建目标库", t0) + + # 2) 建结构(模型即 head,方言可移植) + t0 = time.perf_counter() + _phase_start(2, 5, f"建表结构({len(present)} 张表 create_all)") + Base.metadata.create_all(dst_engine) + _phase_done(2, 5, f"建表结构({len(present)} 张表 create_all)", t0, extra=f",{len(present)} 张表") + + # 3) 逐表迁移数据 + t0 = time.perf_counter() + _phase_start(3, 5, "逐表迁移数据") + total_rows = 0 + n_present = len(present) + if args.single_transaction: + with dst_engine.begin() as dst, src_engine.connect() as src: + for idx, t in enumerate(present, 1): + if args.skip_existing and _existing_equal(src, dst, t): + print(f" · [{idx}/{n_present}] 跳过 {t.name}(源=目标)") + continue + print(f" · [{idx}/{n_present}] 正在迁移 {t.name}...") + total_rows += pump_table(src, dst, t, args.chunk_size, dst_driver) + else: + with src_engine.connect() as src: + for idx, t in enumerate(present, 1): + with dst_engine.begin() as dst: + if args.skip_existing and _existing_equal(src, dst, t): + print(f" · [{idx}/{n_present}] 跳过 {t.name}(源=目标)") + continue + print(f" · [{idx}/{n_present}] 正在迁移 {t.name}...") + total_rows += pump_table(src, dst, t, args.chunk_size, dst_driver) + _phase_done(3, 5, "逐表迁移数据", t0, extra=f",共 {total_rows} 行") + + # 4) 重置自增序列(PG/MSSQL/Oracle;MySQL/SQLite 自动跟随) + # 单表失败不中断其余表,避免一张空表序列异常连累后续全部序列 + reset_errors = [] + t0 = time.perf_counter() + _phase_start(4, 5, "重置自增序列") + if dst_driver in ("postgresql", "mssql", "oracle"): + with dst_engine.begin() as dst: + for t in present: + try: + reset_sequence(dst_driver, dst, t) + except Exception as e: + reset_errors.append((t.name, str(e))) + print(f" · [WARN] 序列重置失败 {t.name}: {e}") + tail = f",{len(reset_errors)} 张失败" if reset_errors else "" + _phase_done(4, 5, "重置自增序列", t0, extra=tail) + else: + print(" · MySQL/SQLite 自动跟随,无需处理") + _phase_done(4, 5, "重置自增序列", t0, extra="(无需处理)") + + # 5) 校验 + t0 = time.perf_counter() + _phase_start(5, 5, "逐表行数校验") + if not args.skip_verify: + with src_engine.connect() as src, dst_engine.connect() as dst: + failed = verify_counts(src, dst, present) + if failed: + _phase_done(5, 5, "逐表行数校验", t0, extra=f",FAIL {len(failed)} 张") + for name, s, d in failed: + print(f" - {name}: source={s} target={d}") + return 1 + _phase_done(5, 5, "逐表行数校验", t0, extra=f",{len(present)} 张表全部 PASS") + else: + print(" · 已跳过(--skip-verify)") + _phase_done(5, 5, "逐表行数校验", t0, extra="(已跳过)") + + # 汇总 + print("=" * 62) + print(f"[迁移完成] 成功:{total_rows} 行 / {len(present)} 张表 / 总耗时 {_fmt_duration(time.perf_counter() - t_start)}") + print("=" * 62) + return 0 + finally: + src_engine.dispose() + dst_engine.dispose() + + +def main(): + args = build_parser().parse_args() + try: + sys.exit(run(args)) + except KeyboardInterrupt: + sys.exit(130) + + +if __name__ == "__main__": + main() diff --git a/scripts/seed.py b/scripts/seed.py new file mode 100644 index 0000000..fc7bcfd --- /dev/null +++ b/scripts/seed.py @@ -0,0 +1,196 @@ +"""测试数据填充脚本。 + +用法:python scripts/seed.py +- 创建默认管理员账号(admin / 123456) +- 填充任务调度示例数据:任务菜单/权限节点、示例任务、示例任务日志 +""" +import sys +from datetime import datetime, timedelta +from pathlib import Path + +from sqlalchemy import inspect + +SRC = Path(__file__).resolve().parent.parent / "src" +sys.path.insert(0, str(SRC)) + +from common.utils.crypto import set_password +from config.app import TORNADO_PORT +from extensions.sqlalchemy import SessionLocal +from modules.user.models import User + + +def seed_admin() -> None: + """创建默认管理员账号(幂等)。""" + db = SessionLocal() + try: + exists = db.query(User).filter(User.username == "admin").first() + if exists: + print("管理员账号已存在,跳过。") + return + salt, hashed = set_password("123456") + admin = User( + realname="超级管理员", + nickname="管理员", + username="admin", + password=hashed, + salt=salt, + gender=1, + status=1, + sort=0, + ) + db.add(admin) + db.commit() + print("管理员账号创建成功:admin / 123456") + except Exception as e: + db.rollback() + print(f"填充失败: {e}") + sys.exit(1) + finally: + db.close() + + +def _ensure_table(db, required: tuple) -> bool: + """校验所需数据表是否存在,不存在则提示并返回 False。""" + existing = set(inspect(db.bind).get_table_names()) + missing = [t for t in required if t not in existing] + if missing: + print(f"表不存在,跳过任务数据填充:{', '.join(missing)}(请先执行 alembic upgrade head)") + return False + return True + + +def seed_job_data() -> None: + """填充任务调度示例数据(幂等):菜单/权限节点 + 示例任务 + 示例任务日志。""" + db = SessionLocal() + try: + from modules.job.models import Job + from modules.job_log.models import JobLog + from modules.menu.models import Menu + + if not _ensure_table(db, ("tornado_menu", "tornado_job", "tornado_job_log")): + return + + # ---------- 1. 菜单与权限节点(挂到"系统管理"下) ---------- + system = db.query(Menu).filter(Menu.title == "系统管理", Menu.parent_id == 0).first() + parent_id = system.id if system else 0 + + # 任务调度菜单 + job_menu = db.query(Menu).filter(Menu.permission == "sys:job:view").first() + if not job_menu: + job_menu = Menu(title="任务调度", icon="el-icon-alarm-clock", parent_id=parent_id, + path="/system/job", component="/system/job", target="_self", + permission="sys:job:view", type=0, status=1, hide=0, sort=40) + db.add(job_menu) + db.flush() + print("创建菜单:任务调度") + else: + print("菜单已存在:任务调度") + + # 任务日志菜单 + joblog_menu = db.query(Menu).filter(Menu.permission == "sys:joblog:view").first() + if not joblog_menu: + joblog_menu = Menu(title="任务日志", icon="el-icon-document", parent_id=parent_id, + path="/system/joblog", component="/system/joblog", target="_self", + permission="sys:joblog:view", type=0, status=1, hide=0, sort=45) + db.add(joblog_menu) + db.flush() + print("创建菜单:任务日志") + else: + print("菜单已存在:任务日志") + + # 任务调度权限节点(type=1,按钮级) + job_nodes = [ + ("查询任务", "sys:job:list", "/job/list", 1), + ("任务详情", "sys:job:detail", "/job/detail", 5), + ("添加任务", "sys:job:add", "/job/add", 10), + ("修改任务", "sys:job:update", "/job/update", 15), + ("删除任务", "sys:job:delete", "/job/delete", 20), + ("设置状态", "sys:job:status", "/job/status", 25), + ("立即执行", "sys:job:run", "/job/run", 30), + ] + for title, perm, path, sort in job_nodes: + if not db.query(Menu).filter(Menu.permission == perm).first(): + db.add(Menu(title=title, icon="", parent_id=job_menu.id, path=path, + component="", target="_self", permission=perm, type=1, + status=1, hide=0, sort=sort)) + print(f"创建权限节点:{title}({perm})") + + # 任务日志权限节点 + joblog_nodes = [ + ("查询日志", "sys:joblog:list", "/joblog/list", 1), + ("日志详情", "sys:joblog:detail", "/joblog/detail", 5), + ("删除日志", "sys:joblog:delete", "/joblog/delete", 10), + ] + for title, perm, path, sort in joblog_nodes: + if not db.query(Menu).filter(Menu.permission == perm).first(): + db.add(Menu(title=title, icon="", parent_id=joblog_menu.id, path=path, + component="", target="_self", permission=perm, type=1, + status=1, hide=0, sort=sort)) + print(f"创建权限节点:{title}({perm})") + + # ---------- 2. 示例任务(回调指向本服务健康检查接口,可真实触发并产生日志) ---------- + base_url = f"http://127.0.0.1:{TORNADO_PORT}/api/v1/health" + sample_jobs = [ + dict(job_name="应用健康检查", job_alias="health", job_group="系统任务", + job_trigger="cron", status=1, cron_expression="*/5 * * * *", + execute_policy=1, is_sync=0, url=base_url, + note="每5分钟检测应用健康状态(示例任务)"), + dict(job_name="夜间数据备份", job_alias="backup", job_group="系统任务", + job_trigger="cron", status=2, cron_expression="0 0 2 * * ?", + execute_policy=1, is_sync=0, url=base_url, + note="每日凌晨2点执行数据备份(示例任务,已暂停)"), + dict(job_name="订单数据同步", job_alias="sync", job_group="业务任务", + job_trigger="cron", status=0, cron_expression="0 */30 * * * *", + execute_policy=2, is_sync=0, url=base_url, + note="每30分钟同步订单数据(示例任务,未发布)"), + ] + for item in sample_jobs: + if db.query(Job).filter(Job.job_name == item["job_name"]).first(): + print(f"任务已存在:{item['job_name']}") + continue + db.add(Job(**item)) + print(f"创建示例任务:{item['job_name']}") + # flush 让新增任务可见,供下方示例日志回填真实 job_id + db.flush() + + # ---------- 3. 示例任务日志(仅表为空时填充,按任务名回查真实 job_id) ---------- + def _job_id(name: str): + job = db.query(Job).filter(Job.job_name == name).first() + return job.id if job else None + + if db.query(JobLog).count() == 0: + now = datetime.now() + refs = [ + ("应用健康检查", 0, 156, None), + ("应用健康检查", 0, 203, None), + ("应用健康检查", 1, 10001, "urlopen error "), + ] + sample_logs = [] + for idx, (name, status, consume, exc) in enumerate(refs): + jid = _job_id(name) + if jid is None: + continue + start = now - timedelta(minutes=(idx + 1) * 5) + sample_logs.append(JobLog( + job_id=jid, job_name=name, job_group="系统任务", job_trigger="cron", + job_message="任务触发成功" if status == 0 else "任务触发失败", + cron_expression="*/5 * * * *", status=status, + start_time=start, end_time=start + timedelta(milliseconds=consume), + consume_time=consume, exception_info=exc, + )) + db.add_all(sample_logs) + print(f"创建示例任务日志 {len(sample_logs)} 条") + + db.commit() + print("任务调度示例数据填充完成。") + except Exception as e: + db.rollback() + print(f"任务数据填充失败: {e}") + sys.exit(1) + finally: + db.close() + + +if __name__ == "__main__": + seed_admin() + seed_job_data() diff --git a/scripts/seed_dict.py b/scripts/seed_dict.py new file mode 100644 index 0000000..0ad27e1 --- /dev/null +++ b/scripts/seed_dict.py @@ -0,0 +1,282 @@ +""" +业务字典数据初始化脚本 +============================================================= + +幂等写入业务字典(dict + dict_item):按字典编码(code)判重, +已存在则跳过、不覆盖现有数据;适合全新安装或现有库增量补齐。 + +字典格式(code/name/note/items),字典排序与字典项排序由列表顺序自动生成: + DICT_SEED = [ + { + "code": "gender", "name": "性别", "note": "用户性别", + "items": [ + {"value": "1", "name": "男"}, + {"value": "2", "name": "女"}, + ], + }, + ] + +注意:code 必须与业务代码中 get_dict_map / get_dict_label / serialize_maps +引用的编码一致(见 src/common/utils/dict_util.py),改 code 需同步改业务引用。 + +用法: + python scripts/seed_dict.py # 使用 .env 中的数据库配置 + python scripts/seed_dict.py --dry-run # 只打印将写入的字典,不落库 +""" + +import argparse +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src")) + +# Windows 控制台默认 GBK(cp936):把 stdout/stderr 切到 UTF-8,避免中文打印乱码/崩溃 +if sys.stdout and hasattr(sys.stdout, 'reconfigure'): + sys.stdout.reconfigure(encoding='utf-8') + sys.stderr.reconfigure(encoding='utf-8') + +# 业务字典种子数据 +DICT_SEED = [ + { + "code": "gender", + "name": "性别", + "note": "用户性别", + "items": [ + {"value": "1", "name": "男"}, + {"value": "2", "name": "女"}, + {"value": "3", "name": "保密"}, + ], + }, + { + "code": "dept_type", + "name": "部门类型", + "note": "部门层级类型", + "items": [ + {"value": "1", "name": "公司"}, + {"value": "2", "name": "子公司"}, + {"value": "3", "name": "部门"}, + {"value": "4", "name": "小组"}, + ], + }, + { + "code": "article_status", + "name": "文章状态", + "note": "文章上下架状态", + "items": [ + {"value": "0", "name": "正常"}, + {"value": "1", "name": "下架"}, + ], + }, + { + "code": "notice_source", + "name": "通知来源", + "note": "通知公告来源", + "items": [ + {"value": "1", "name": "官方平台"}, + {"value": "2", "name": "开源中国"}, + {"value": "3", "name": "CSDN官方"}, + {"value": "4", "name": "新浪微博"}, + ], + }, + { + "code": "log_type", + "name": "日志类型", + "note": "登录日志类型", + "items": [ + {"value": "1", "name": "登录"}, + {"value": "2", "name": "退出"}, + ], + }, + { + "code": "login_source", + "name": "登录来源", + "note": "登录入口来源", + "items": [ + {"value": "0", "name": "后台"}, + {"value": "1", "name": "前台"}, + ], + }, + { + "code": "login_status", + "name": "登录状态", + "note": "登录日志状态", + "items": [ + {"value": "0", "name": "正常"}, + {"value": "1", "name": "异常"}, + ], + }, + { + "code": "operation_source", + "name": "操作日志来源", + "note": "操作日志来源", + "items": [ + {"value": "0", "name": "后台"}, + {"value": "1", "name": "前台"}, + ], + }, + { + "code": "operation_status", + "name": "操作日志状态", + "note": "操作日志状态", + "items": [ + {"value": "0", "name": "正常"}, + {"value": "1", "name": "异常"}, + ], + }, + { + "code": "param_type", + "name": "参数类型", + "note": "参数分类", + "items": [ + {"value": "0", "name": "系统"}, + {"value": "1", "name": "业务"}, + ], + }, + { + "code": "param_status", + "name": "参数状态", + "note": "参数启用状态", + "items": [ + {"value": "1", "name": "正常"}, + {"value": "2", "name": "禁用"}, + ], + }, + { + "code": "job_status", + "name": "任务状态", + "note": "定时任务状态", + "items": [ + {"value": "0", "name": "未发布"}, + {"value": "1", "name": "运行中"}, + {"value": "2", "name": "暂停"}, + {"value": "3", "name": "删除"}, + ], + }, + { + "code": "job_execute_policy", + "name": "执行策略", + "note": "任务失败执行策略", + "items": [ + {"value": "1", "name": "立即执行"}, + {"value": "2", "name": "执行一次"}, + {"value": "3", "name": "放弃执行"}, + ], + }, + { + "code": "job_log_status", + "name": "任务日志状态", + "note": "任务执行结果状态", + "items": [ + {"value": "0", "name": "正常"}, + {"value": "1", "name": "失败"}, + ], + }, + { + "code": "link_type", + "name": "友链类型", + "note": "友链分类", + "items": [ + {"value": "1", "name": "友情链接"}, + {"value": "2", "name": "合作伙伴"}, + ], + }, + { + "code": "link_platform", + "name": "友链投放平台", + "note": "友链投放平台", + "items": [ + {"value": "1", "name": "PC站"}, + {"value": "2", "name": "WAP站"}, + {"value": "3", "name": "微信小程序"}, + {"value": "4", "name": "APP应用"}, + ], + }, + { + "code": "link_form", + "name": "友链形式", + "note": "友链展示形式", + "items": [ + {"value": "1", "name": "文字链接"}, + {"value": "2", "name": "图片链接"}, + ], + }, + { + "code": "city_level", + "name": "城市级别", + "note": "行政区划级别", + "items": [ + {"value": "1", "name": "省份"}, + {"value": "2", "name": "城市"}, + {"value": "3", "name": "县区"}, + {"value": "4", "name": "街道"}, + ], + }, + { + "code": "config_item_type", + "name": "配置项类型", + "note": "配置项输入控件类型", + "items": [ + {"value": "text", "name": "单行文本"}, + {"value": "textarea", "name": "多行文本"}, + {"value": "ueditor", "name": "富文本编辑器"}, + {"value": "date", "name": "日期"}, + {"value": "datetime", "name": "时间"}, + {"value": "number", "name": "数字"}, + {"value": "select", "name": "下拉框"}, + {"value": "radio", "name": "单选框"}, + {"value": "checkbox", "name": "复选框"}, + {"value": "image", "name": "单张图片"}, + {"value": "images", "name": "多张图片"}, + {"value": "password", "name": "密码"}, + {"value": "icon", "name": "字体图标"}, + {"value": "file", "name": "单个文件"}, + {"value": "files", "name": "多个文件"}, + {"value": "hidden", "name": "隐藏"}, + {"value": "readonly", "name": "只读文本"}, + ], + }, +] + + +def main(): + parser = argparse.ArgumentParser(description="初始化业务字典数据") + parser.add_argument("--dry-run", action="store_true", help="仅打印计划,不写库") + args = parser.parse_args() + + from core.database import SessionLocal + from modules.dictionary.dict.models import Dict + from modules.dictionary.dict_item.models import DictItem + + db = SessionLocal() + added = skipped = 0 + try: + for idx, d in enumerate(DICT_SEED): + code = d["code"] + exists = db.query(Dict).filter(Dict.code == code, Dict.is_delete == 0).first() + if exists: + skipped += 1 + continue + if args.dry_run: + print(f"[DRY-RUN] 将新增字典: {code}({d['name']},{len(d['items'])} 项)") + continue + dict_obj = Dict(name=d["name"], code=code, sort=(idx + 1) * 5, note=d.get("note", "")) + db.add(dict_obj) + db.flush() # 获取自增 id 供字典项外键引用 + for i, item in enumerate(d["items"]): + db.add(DictItem( + name=item["name"], value=item["value"], + dict_id=dict_obj.id, sort=(i + 1) * 5, note='', + )) + added += 1 + db.commit() + if not args.dry_run: + print(f"done: added={added} dicts, skipped={skipped}(已存在)") + except Exception: + db.rollback() + raise + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..649d596 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,13 @@ +# +====================================================================== +# | 模块: 项目源码根包 +# | 说明: 声明 DjangoAdmin 后端源码包,并概述各顶层目录的职责 +# +====================================================================== +"""DjangoAdmin Tornado+AntdVue 后端源码包。 + +- config: 配置层(Pydantic Settings / 常量 / 消息 / 日志) +- core: 核心基础设施层 +- modules: 业务模块 +- common: 公共模块(中间件 / 工具 / 装饰器) +- extensions: 扩展 +- api: API 版本管理 +""" diff --git a/src/api/__init__.py b/src/api/__init__.py new file mode 100644 index 0000000..8386fab --- /dev/null +++ b/src/api/__init__.py @@ -0,0 +1,5 @@ +# +====================================================================== +# | 模块: API 版本管理包 +# | 说明: 声明 api 为包,用于组织各版本的 API 路由模块 +# +====================================================================== +"""API 版本管理。""" \ No newline at end of file diff --git a/src/api/v1/__init__.py b/src/api/v1/__init__.py new file mode 100644 index 0000000..d58116b --- /dev/null +++ b/src/api/v1/__init__.py @@ -0,0 +1,9 @@ +# +====================================================================== +# | 模块: API v1 版本路由包 +# | 说明: 声明 api.v1 为包,并说明各资源模块的路由导出约定 +# +====================================================================== +"""API v1 版本路由模块。 + +每个资源模块导出 `routes`((正则, Handler) 列表), +由 router.build_routes() 统一添加 /api/v1 前缀。 +""" diff --git a/src/api/v1/article.py b/src/api/v1/article.py new file mode 100644 index 0000000..803b9b1 --- /dev/null +++ b/src/api/v1/article.py @@ -0,0 +1,33 @@ +# +====================================================================== +# | 模块: 文章路由层 +# | 说明: 提供文章信息的增删改查、批量删除等 API 接口 +# +====================================================================== +"""文章相关路由。""" + +# ---------------------------------------------------------------------- +# 导入文章模块的各类处理器(Handler) +# 涵盖:分页、详情、新增、更新、删除、批量删除 +# ---------------------------------------------------------------------- +from modules.article.handlers import (ArticleAddHandler, ArticleDeleteHandler, ArticleDetailHandler, + ArticlePageHandler, ArticleStatusHandler, ArticleUpdateHandler, + ArticleBatchDeleteHandler) + +# ---------------------------------------------------------------------- +# 路由表:每一项为 (URL 正则, 处理器类) +# ---------------------------------------------------------------------- +routes = [ + # 文章分页查询 + (r"/article/page", ArticlePageHandler), + # 查询文章详情,路径参数为文章 ID + (r"/article/detail/(\d+)", ArticleDetailHandler), + # 新增文章 + (r"/article/add", ArticleAddHandler), + # 更新文章信息 + (r"/article/update", ArticleUpdateHandler), + # 删除单篇文章,路径参数为文章 ID + (r"/article/delete/(\d+)", ArticleDeleteHandler), + # 批量删除文章 + (r"/article/batchDelete", ArticleBatchDeleteHandler), + # 修改文章状态(如上架 / 下架) + (r"/article/status", ArticleStatusHandler), +] \ No newline at end of file diff --git a/src/api/v1/auth.py b/src/api/v1/auth.py new file mode 100644 index 0000000..d01f978 --- /dev/null +++ b/src/api/v1/auth.py @@ -0,0 +1,30 @@ +# +====================================================================== +# | 模块: 认证路由层 +# | 说明: 提供登录、验证码获取、Token 刷新等认证相关 API 接口 +# +====================================================================== +"""认证相关路由。""" + +# ---------------------------------------------------------------------- +# 导入认证模块的各类处理器(Handler) +# 涵盖:登录、验证码、Token 刷新 +# ---------------------------------------------------------------------- +from modules.auth.handlers import ( + CaptchaHandler, + LoginHandler, + LogoutHandler, + TokenRefreshHandler, +) + +# ---------------------------------------------------------------------- +# 路由表:每一项为 (URL 正则, 处理器类) +# ---------------------------------------------------------------------- +routes = [ + # 用户登录 + (r"/login", LoginHandler), + # 退出登录 + (r"/logout", LogoutHandler), + # 获取图形验证码 + (r"/captcha", CaptchaHandler), + # 刷新 Token + (r"/token/refresh", TokenRefreshHandler), +] diff --git a/src/api/v1/category.py b/src/api/v1/category.py new file mode 100644 index 0000000..871bceb --- /dev/null +++ b/src/api/v1/category.py @@ -0,0 +1,33 @@ +# +====================================================================== +# | 模块: 文章分类路由层 +# | 说明: 提供文章分类的增删改查、列表获取等 API 接口 +# +====================================================================== +"""文章分类相关路由。""" + +# ---------------------------------------------------------------------- +# 导入文章分类模块的各类处理器(Handler) +# 涵盖:列表、详情、新增、更新、删除、列表获取 +# ---------------------------------------------------------------------- +from modules.category.handlers import (CategoryAddHandler, CategoryDeleteHandler, CategoryDetailHandler, + CategoryGetDataHandler, CategoryGetListHandler, CategoryListHandler, + CategoryUpdateHandler) + +# ---------------------------------------------------------------------- +# 路由表:每一项为 (URL 正则, 处理器类) +# ---------------------------------------------------------------------- +routes = [ + # 文章分类列表(分页 / 条件查询) + (r"/category/list", CategoryListHandler), + # 查询文章分类详情,路径参数为分类 ID + (r"/category/detail/(\d+)", CategoryDetailHandler), + # 新增文章分类 + (r"/category/add", CategoryAddHandler), + # 更新文章分类信息 + (r"/category/update", CategoryUpdateHandler), + # 删除单个文章分类,路径参数为分类 ID + (r"/category/delete/(\d+)", CategoryDeleteHandler), + # 获取文章分类列表(常用于下拉选择等场景) + (r"/category/getCategoryList", CategoryGetListHandler), + # 获取文章分类数据(树形结构) + (r"/category/getCategoryData", CategoryGetDataHandler), +] \ No newline at end of file diff --git a/src/api/v1/city.py b/src/api/v1/city.py new file mode 100644 index 0000000..de0f8f3 --- /dev/null +++ b/src/api/v1/city.py @@ -0,0 +1,33 @@ +# +====================================================================== +# | 模块: 城市路由层 +# | 说明: 提供城市信息的增删改查、批量删除等 API 接口 +# +====================================================================== +"""城市相关路由。""" + +# ---------------------------------------------------------------------- +# 导入城市模块的各类处理器(Handler) +# 涵盖:分页、详情、新增、更新、删除、批量删除 +# ---------------------------------------------------------------------- +from modules.city.handlers import (CityAddHandler, CityBatchDeleteHandler, CityDeleteHandler, + CityDetailHandler, CityGetListHandler, CityPageHandler, + CityUpdateHandler) + +# ---------------------------------------------------------------------- +# 路由表:每一项为 (URL 正则, 处理器类) +# ---------------------------------------------------------------------- +routes = [ + # 城市分页查询 + (r"/city/page", CityPageHandler), + # 查询城市详情,路径参数为城市 ID + (r"/city/detail/(\d+)", CityDetailHandler), + # 新增城市 + (r"/city/add", CityAddHandler), + # 更新城市信息 + (r"/city/update", CityUpdateHandler), + # 删除单个城市,路径参数为城市 ID + (r"/city/delete/(\d+)", CityDeleteHandler), + # 批量删除城市 + (r"/city/batchDelete", CityBatchDeleteHandler), + # 根据城市编码查询子级城市 + (r"/city/getCityList/(\w+)", CityGetListHandler), +] diff --git a/src/api/v1/config.py b/src/api/v1/config.py new file mode 100644 index 0000000..0f2b340 --- /dev/null +++ b/src/api/v1/config.py @@ -0,0 +1,36 @@ +# +====================================================================== +# | 模块: 配置路由层 +# | 说明: 提供系统配置的增删改查、批量删除、网站配置读取与保存等 API 接口 +# +====================================================================== +"""配置相关路由。""" + +# ---------------------------------------------------------------------- +# 导入配置模块的各类处理器(Handler) +# 涵盖:分页、详情、新增、更新、删除、批量删除、网站配置读取与保存 +# ---------------------------------------------------------------------- +from modules.configuration.config.handlers import (ConfigAddHandler, ConfigDeleteHandler, + ConfigDetailHandler, ConfigPageHandler, + ConfigUpdateHandler, ConfigBatchDeleteHandler, + ConfigWebIndexHandler, ConfigWebSaveHandler) + +# ---------------------------------------------------------------------- +# 路由表:每一项为 (URL 正则, 处理器类) +# ---------------------------------------------------------------------- +routes = [ + # 配置分页查询 + (r"/config/page", ConfigPageHandler), + # 查询配置详情,路径参数为配置 ID + (r"/config/detail/(\d+)", ConfigDetailHandler), + # 新增配置 + (r"/config/add", ConfigAddHandler), + # 更新配置信息 + (r"/config/update", ConfigUpdateHandler), + # 删除单个配置,路径参数为配置 ID + (r"/config/delete/(\d+)", ConfigDeleteHandler), + # 批量删除配置 + (r"/config/batchDelete", ConfigBatchDeleteHandler), + # 读取网站配置(首页展示) + (r"/config/web/index", ConfigWebIndexHandler), + # 保存网站配置 + (r"/config/web/save", ConfigWebSaveHandler), +] diff --git a/src/api/v1/config_item.py b/src/api/v1/config_item.py new file mode 100644 index 0000000..cb635a6 --- /dev/null +++ b/src/api/v1/config_item.py @@ -0,0 +1,34 @@ +# +====================================================================== +# | 模块: 配置项路由层 +# | 说明: 提供配置项的增删改查、批量删除、状态管理等 API 接口 +# +====================================================================== +"""配置项相关路由。""" + +# ---------------------------------------------------------------------- +# 导入配置项模块的各类处理器(Handler) +# 涵盖:分页、详情、新增、更新、删除、批量删除、状态变更 +# ---------------------------------------------------------------------- +from modules.configuration.config_item.handlers import (ConfigItemAddHandler, ConfigItemDeleteHandler, + ConfigItemDetailHandler, ConfigItemPageHandler, + ConfigItemStatusHandler, ConfigItemUpdateHandler, + ConfigItemBatchDeleteHandler) + +# ---------------------------------------------------------------------- +# 路由表:每一项为 (URL 正则, 处理器类) +# ---------------------------------------------------------------------- +routes = [ + # 配置项分页查询 + (r"/config/item/page", ConfigItemPageHandler), + # 查询配置项详情,路径参数为配置项 ID + (r"/config/item/detail/(\d+)", ConfigItemDetailHandler), + # 新增配置项 + (r"/config/item/add", ConfigItemAddHandler), + # 更新配置项信息 + (r"/config/item/update", ConfigItemUpdateHandler), + # 删除单个配置项,路径参数为配置项 ID + (r"/config/item/delete/(\d+)", ConfigItemDeleteHandler), + # 批量删除配置项 + (r"/config/item/batchDelete", ConfigItemBatchDeleteHandler), + # 修改配置项状态(如启用 / 禁用) + (r"/config/item/status", ConfigItemStatusHandler), +] diff --git a/src/api/v1/dept.py b/src/api/v1/dept.py new file mode 100644 index 0000000..eb20bc2 --- /dev/null +++ b/src/api/v1/dept.py @@ -0,0 +1,30 @@ +# +====================================================================== +# | 模块: 部门路由层 +# | 说明: 提供部门信息的增删改查、列表获取等 API 接口 +# +====================================================================== +"""部门相关路由。""" + +# ---------------------------------------------------------------------- +# 导入部门模块的各类处理器(Handler) +# 涵盖:列表、详情、新增、更新、删除、列表获取 +# ---------------------------------------------------------------------- +from modules.dept.handlers import (DeptAddHandler, DeptDeleteHandler, DeptDetailHandler, + DeptGetListHandler, DeptListHandler, DeptUpdateHandler) + +# ---------------------------------------------------------------------- +# 路由表:每一项为 (URL 正则, 处理器类) +# ---------------------------------------------------------------------- +routes = [ + # 部门列表(分页 / 条件查询) + (r"/dept/list", DeptListHandler), + # 查询部门详情,路径参数为部门 ID + (r"/dept/detail/(\d+)", DeptDetailHandler), + # 新增部门 + (r"/dept/add", DeptAddHandler), + # 更新部门信息 + (r"/dept/update", DeptUpdateHandler), + # 删除单个部门,路径参数为部门 ID + (r"/dept/delete/(\d+)", DeptDeleteHandler), + # 获取部门列表(常用于下拉选择等场景) + (r"/dept/getDeptList", DeptGetListHandler), +] diff --git a/src/api/v1/dict.py b/src/api/v1/dict.py new file mode 100644 index 0000000..85f632c --- /dev/null +++ b/src/api/v1/dict.py @@ -0,0 +1,33 @@ +# +====================================================================== +# | 模块: 字典路由层 +# | 说明: 提供数据字典的增删改查、批量删除、字典数据获取等 API 接口 +# +====================================================================== +"""字典相关路由。""" + +# ---------------------------------------------------------------------- +# 导入字典模块的各类处理器(Handler) +# 涵盖:分页、详情、新增、更新、删除、批量删除、字典数据获取 +# ---------------------------------------------------------------------- +from modules.dictionary.dict.handlers import (DictAddHandler, DictDataHandler, DictDeleteHandler, + DictDetailHandler, DictPageHandler, DictUpdateHandler, + DictBatchDeleteHandler) + +# ---------------------------------------------------------------------- +# 路由表:每一项为 (URL 正则, 处理器类) +# ---------------------------------------------------------------------- +routes = [ + # 字典分页查询 + (r"/dict/page", DictPageHandler), + # 查询字典详情,路径参数为字典 ID + (r"/dict/detail/(\d+)", DictDetailHandler), + # 新增字典 + (r"/dict/add", DictAddHandler), + # 更新字典信息 + (r"/dict/update", DictUpdateHandler), + # 删除单个字典,路径参数为字典 ID + (r"/dict/delete/(\d+)", DictDeleteHandler), + # 批量删除字典 + (r"/dict/batchDelete", DictBatchDeleteHandler), + # 获取字典数据,路径参数为字典类型编码(如性别、状态等) + (r"/dict/data/(\w+)", DictDataHandler), +] diff --git a/src/api/v1/dict_item.py b/src/api/v1/dict_item.py new file mode 100644 index 0000000..44a075e --- /dev/null +++ b/src/api/v1/dict_item.py @@ -0,0 +1,31 @@ +# +====================================================================== +# | 模块: 字典项路由层 +# | 说明: 提供字典项的增删改查、批量删除等 API 接口 +# +====================================================================== +"""字典项相关路由。""" + +# ---------------------------------------------------------------------- +# 导入字典项模块的各类处理器(Handler) +# 涵盖:分页、详情、新增、更新、删除、批量删除 +# ---------------------------------------------------------------------- +from modules.dictionary.dict_item.handlers import (DictItemAddHandler, DictItemDeleteHandler, + DictItemDetailHandler, DictItemPageHandler, + DictItemUpdateHandler, DictItemBatchDeleteHandler) + +# ---------------------------------------------------------------------- +# 路由表:每一项为 (URL 正则, 处理器类) +# ---------------------------------------------------------------------- +routes = [ + # 字典项分页查询 + (r"/dict/item/page", DictItemPageHandler), + # 查询字典项详情,路径参数为字典项 ID + (r"/dict/item/detail/(\d+)", DictItemDetailHandler), + # 新增字典项 + (r"/dict/item/add", DictItemAddHandler), + # 更新字典项信息 + (r"/dict/item/update", DictItemUpdateHandler), + # 删除单个字典项,路径参数为字典项 ID + (r"/dict/item/delete/(\d+)", DictItemDeleteHandler), + # 批量删除字典项 + (r"/dict/item/batchDelete", DictItemBatchDeleteHandler), +] diff --git a/src/api/v1/example.py b/src/api/v1/example.py new file mode 100644 index 0000000..a7e1671 --- /dev/null +++ b/src/api/v1/example.py @@ -0,0 +1,33 @@ +# +====================================================================== +# | 模块: 案例演示路由层 +# | 说明: 提供案例演示的增删改查、批量删除、状态管理等 API 接口 +# +====================================================================== +"""案例演示相关路由。""" + +# ---------------------------------------------------------------------- +# 导入案例演示模块的各类处理器(Handler) +# 涵盖:分页、详情、新增、更新、删除、批量删除、状态变更# ---------------------------------------------------------------------- +from modules.example.handlers import (ExampleAddHandler, ExampleBatchDeleteHandler, + ExampleDeleteHandler, ExampleDetailHandler, + ExamplePageHandler, ExampleUpdateHandler, + ExampleStatusHandler) + +# ---------------------------------------------------------------------- +# 路由表:每一项为 (URL 正则, 处理器类) +# ---------------------------------------------------------------------- +routes = [ + # 案例演示分页查询 + (r"/example/page", ExamplePageHandler), + # 查询案例演示详情,路径参数为 ID + (r"/example/detail/(\d+)", ExampleDetailHandler), + # 新增案例演示 + (r"/example/add", ExampleAddHandler), + # 更新案例演示信息 + (r"/example/update", ExampleUpdateHandler), + # 删除单个案例演示,路径参数为 ID + (r"/example/delete/(\d+)", ExampleDeleteHandler), + # 批量删除案例演示 + (r"/example/batchDelete", ExampleBatchDeleteHandler), + # 修改案例演示状态(如启用 / 禁用) + (r"/example/status", ExampleStatusHandler), +] diff --git a/src/api/v1/file_template.py b/src/api/v1/file_template.py new file mode 100644 index 0000000..af70560 --- /dev/null +++ b/src/api/v1/file_template.py @@ -0,0 +1,34 @@ +# +====================================================================== +# | 模块: 文件模板路由层 +# | 说明: 提供文件模板的增删改查、批量删除等 API 接口 +# +====================================================================== +"""文件模板相关路由。""" + +# ---------------------------------------------------------------------- +# 导入文件模板模块的各类处理器(Handler) +# 涵盖:分页、详情、新增、更新、删除、批量删除 +# ---------------------------------------------------------------------- +from modules.file_template.handlers import (FileTemplateAddHandler, FileTemplateBatchDeleteHandler, + FileTemplateDeleteHandler, FileTemplateDetailHandler, + FileTemplateGetByCodeHandler, FileTemplatePageHandler, + FileTemplateUpdateHandler) + +# ---------------------------------------------------------------------- +# 路由表:每一项为 (URL 正则, 处理器类) +# ---------------------------------------------------------------------- +routes = [ + # 文件模板分页查询 + (r"/file/template/page", FileTemplatePageHandler), + # 查询文件模板详情,路径参数为文件模板 ID + (r"/file/template/detail/(\d+)", FileTemplateDetailHandler), + # 新增文件模板 + (r"/file/template/add", FileTemplateAddHandler), + # 更新文件模板信息 + (r"/file/template/update", FileTemplateUpdateHandler), + # 删除单个文件模板,路径参数为文件模板 ID + (r"/file/template/delete/(\d+)", FileTemplateDeleteHandler), + # 批量删除文件模板 + (r"/file/template/batchDelete", FileTemplateBatchDeleteHandler), + # 根据编码获取文件模板 + (r"/file/template/getTemplateByCode/(\w+)", FileTemplateGetByCodeHandler), +] diff --git a/src/api/v1/generator.py b/src/api/v1/generator.py new file mode 100644 index 0000000..3f00186 --- /dev/null +++ b/src/api/v1/generator.py @@ -0,0 +1,29 @@ +# +====================================================================== +# | 模块: 代码生成器路由层 +# | 说明: 提供数据库表查询、代码生成等 API 接口 +# +====================================================================== +"""代码生成器相关路由。""" + +# ---------------------------------------------------------------------- +# 导入代码生成器模块的各类处理器(Handler) +# 涵盖:表分页查询、表详情、字段查询、代码生成、批量生成 +# ---------------------------------------------------------------------- +from modules.generator.handlers import (GeneratorBatchGenerateHandler, GeneratorColumnsHandler, + GeneratorDetailHandler, GeneratorGenerateHandler, + GeneratorPageHandler) + +# ---------------------------------------------------------------------- +# 路由表:每一项为 (URL 正则, 处理器类) +# ---------------------------------------------------------------------- +routes = [ + # 查询数据库表分页数据 + (r"/generator/page", GeneratorPageHandler), + # 查询数据库表详情,路径参数为表名 + (r"/generator/detail/(\w+)", GeneratorDetailHandler), + # 查询数据库表字段信息,路径参数为表名 + (r"/generator/columns/(\w+)", GeneratorColumnsHandler), + # 代码生成 + (r"/generator/generate", GeneratorGenerateHandler), + # 批量代码生成 + (r"/generator/batchGenerate", GeneratorBatchGenerateHandler), +] diff --git a/src/api/v1/index.py b/src/api/v1/index.py new file mode 100644 index 0000000..7c22ad2 --- /dev/null +++ b/src/api/v1/index.py @@ -0,0 +1,26 @@ +# +====================================================================== +# | 模块: 个人中心路由层 +# | 说明: 提供主页菜单、个人信息查询与修改、密码修改等 API 接口 +# +====================================================================== +"""个人中心/主页路由。""" + +# ---------------------------------------------------------------------- +# 导入用户模块的各类处理器(Handler) +# 涵盖:菜单获取、密码修改、个人信息查询与修改 +# ---------------------------------------------------------------------- +from modules.user.handlers import (IndexMenuHandler, PasswordUpdateHandler, + ProfileHandler, ProfileUpdateHandler) + +# ---------------------------------------------------------------------- +# 路由表:每一项为 (URL 正则, 处理器类) +# ---------------------------------------------------------------------- +routes = [ + # 获取主页菜单 + (r"/index/getMenus", IndexMenuHandler), + # 获取当前登录用户信息 + (r"/index/getUser", ProfileHandler), + # 更新个人信息 + (r"/index/updateProfile", ProfileUpdateHandler), + # 修改登录密码 + (r"/index/updatePassword", PasswordUpdateHandler), +] diff --git a/src/api/v1/job.py b/src/api/v1/job.py new file mode 100644 index 0000000..983da57 --- /dev/null +++ b/src/api/v1/job.py @@ -0,0 +1,42 @@ +# +====================================================================== +# | 模块: 任务调度路由层 +# | 说明: 提供定时任务的增删改查、批量删除、状态管理、立即执行等 API 接口 +# +====================================================================== +"""任务调度相关路由。""" + +# ---------------------------------------------------------------------- +# 导入任务调度模块的各类处理器(Handler) +# 涵盖:分页、详情、新增、更新、删除、批量删除、状态变更、立即执行 +# ---------------------------------------------------------------------- +from modules.job.handlers import (JobAddHandler, JobBatchDeleteHandler, JobDeleteHandler, + JobDetailHandler, JobPageHandler, JobPauseHandler, + JobReloadHandler, JobResumeHandler, JobRunOnceHandler, + JobStatusHandler, JobUpdateHandler) + +# ---------------------------------------------------------------------- +# 路由表:每一项为 (URL 正则, 处理器类) +# ---------------------------------------------------------------------- +routes = [ + # 任务分页查询 + (r"/job/page", JobPageHandler), + # 查询任务详情,路径参数为任务 ID + (r"/job/detail/(\d+)", JobDetailHandler), + # 新增任务 + (r"/job/add", JobAddHandler), + # 更新任务信息 + (r"/job/update", JobUpdateHandler), + # 删除单个任务,路径参数为任务 ID + (r"/job/delete/(\d+)", JobDeleteHandler), + # 批量删除任务 + (r"/job/batchDelete", JobBatchDeleteHandler), + # 修改任务状态(如启用 / 暂停) + (r"/job/status", JobStatusHandler), + # 立即执行任务,路径参数为任务 ID + (r"/job/runOnce/(\d+)", JobRunOnceHandler), + # 暂停任务,路径参数为任务 ID + (r"/job/pause/(\d+)", JobPauseHandler), + # 恢复任务,路径参数为任务 ID + (r"/job/resume/(\d+)", JobResumeHandler), + # 重新加载所有任务 + (r"/job/reload", JobReloadHandler), +] diff --git a/src/api/v1/job_log.py b/src/api/v1/job_log.py new file mode 100644 index 0000000..c592802 --- /dev/null +++ b/src/api/v1/job_log.py @@ -0,0 +1,26 @@ +# +====================================================================== +# | 模块: 任务日志路由层 +# | 说明: 提供任务执行日志的查询、详情、删除、批量删除等 API 接口 +# +====================================================================== +"""任务日志相关路由。""" + +# ---------------------------------------------------------------------- +# 导入任务日志模块的各类处理器(Handler) +# 涵盖:分页、详情、删除、批量删除 +# ---------------------------------------------------------------------- +from modules.job_log.handlers import (JobLogDeleteHandler, JobLogDetailHandler, + JobLogPageHandler, JobLogBatchDeleteHandler) + +# ---------------------------------------------------------------------- +# 路由表:每一项为 (URL 正则, 处理器类) +# ---------------------------------------------------------------------- +routes = [ + # 任务日志分页查询 + (r"/job/log/page", JobLogPageHandler), + # 查询任务日志详情,路径参数为日志 ID + (r"/job/log/detail/(\d+)", JobLogDetailHandler), + # 删除单条任务日志,路径参数为日志 ID + (r"/job/log/delete/(\d+)", JobLogDeleteHandler), + # 批量删除任务日志 + (r"/job/log/batchDelete", JobLogBatchDeleteHandler), +] diff --git a/src/api/v1/level.py b/src/api/v1/level.py new file mode 100644 index 0000000..3e2f287 --- /dev/null +++ b/src/api/v1/level.py @@ -0,0 +1,39 @@ +# +====================================================================== +# | 模块: 职级路由层 +# | 说明: 提供职级信息的增删改查、批量删除、状态管理、导入导出、列表获取等 API 接口 +# +====================================================================== +"""职级相关路由。""" + +# ---------------------------------------------------------------------- +# 导入职级模块的各类处理器(Handler) +# 涵盖:分页、详情、新增、更新、删除、批量删除、状态变更、导入、导出、列表获取 +# ---------------------------------------------------------------------- +from modules.level.handlers import (LevelAddHandler, LevelDeleteHandler, LevelDetailHandler, + LevelExportHandler, LevelGetListHandler, LevelImportHandler, + LevelPageHandler, LevelStatusHandler, LevelUpdateHandler, LevelBatchDeleteHandler) + +# ---------------------------------------------------------------------- +# 路由表:每一项为 (URL 正则, 处理器类) +# ---------------------------------------------------------------------- +routes = [ + # 职级分页查询 + (r"/level/page", LevelPageHandler), + # 查询职级详情,路径参数为职级 ID + (r"/level/detail/(\d+)", LevelDetailHandler), + # 新增职级 + (r"/level/add", LevelAddHandler), + # 更新职级信息 + (r"/level/update", LevelUpdateHandler), + # 删除单个职级,路径参数为职级 ID + (r"/level/delete/(\d+)", LevelDeleteHandler), + # 批量删除职级 + (r"/level/batchDelete", LevelBatchDeleteHandler), + # 修改职级状态(如启用 / 禁用) + (r"/level/status", LevelStatusHandler), + # 导入职级数据 + (r"/level/import", LevelImportHandler), + # 导出职级数据 + (r"/level/export", LevelExportHandler), + # 获取职级列表(常用于下拉选择等场景) + (r"/level/list", LevelGetListHandler), +] \ No newline at end of file diff --git a/src/api/v1/link.py b/src/api/v1/link.py new file mode 100644 index 0000000..b6bfcd4 --- /dev/null +++ b/src/api/v1/link.py @@ -0,0 +1,32 @@ +# +====================================================================== +# | 模块: 友链路由层 +# | 说明: 提供友情链接的增删改查、批量删除、状态管理等 API 接口 +# +====================================================================== +"""友链相关路由。""" + +# ---------------------------------------------------------------------- +# 导入友链模块的各类处理器(Handler) +# 涵盖:分页、详情、新增、更新、删除、批量删除、状态变更 +# ---------------------------------------------------------------------- +from modules.link.handlers import (LinkAddHandler, LinkDeleteHandler, LinkDetailHandler, LinkPageHandler, + LinkStatusHandler, LinkUpdateHandler, LinkBatchDeleteHandler) + +# ---------------------------------------------------------------------- +# 路由表:每一项为 (URL 正则, 处理器类) +# ---------------------------------------------------------------------- +routes = [ + # 友链分页查询 + (r"/link/page", LinkPageHandler), + # 查询友链详情,路径参数为友链 ID + (r"/link/detail/(\d+)", LinkDetailHandler), + # 新增友链 + (r"/link/add", LinkAddHandler), + # 更新友链信息 + (r"/link/update", LinkUpdateHandler), + # 删除单个友链,路径参数为友链 ID + (r"/link/delete/(\d+)", LinkDeleteHandler), + # 批量删除友链 + (r"/link/batchDelete", LinkBatchDeleteHandler), + # 修改友链状态(如显示 / 隐藏) + (r"/link/status", LinkStatusHandler), +] diff --git a/src/api/v1/login_log.py b/src/api/v1/login_log.py new file mode 100644 index 0000000..8e14204 --- /dev/null +++ b/src/api/v1/login_log.py @@ -0,0 +1,26 @@ +# +====================================================================== +# | 模块: 登录日志路由层 +# | 说明: 提供登录日志的查询、详情、删除、批量删除等 API 接口 +# +====================================================================== +"""登录日志相关路由。""" + +# ---------------------------------------------------------------------- +# 导入登录日志模块的各类处理器(Handler) +# 涵盖:分页、详情、删除、批量删除 +# ---------------------------------------------------------------------- +from modules.login_log.handlers import (LoginLogDeleteHandler, LoginLogDetailHandler, + LoginLogPageHandler, LoginLogBatchDeleteHandler) + +# ---------------------------------------------------------------------- +# 路由表:每一项为 (URL 正则, 处理器类) +# ---------------------------------------------------------------------- +routes = [ + # 登录日志分页查询 + (r"/login/log/page", LoginLogPageHandler), + # 查询登录日志详情,路径参数为日志 ID + (r"/login/log/detail/(\d+)", LoginLogDetailHandler), + # 删除单条登录日志,路径参数为日志 ID + (r"/login/log/delete/(\d+)", LoginLogDeleteHandler), + # 批量删除登录日志 + (r"/login/log/batchDelete", LoginLogBatchDeleteHandler), +] diff --git a/src/api/v1/menu.py b/src/api/v1/menu.py new file mode 100644 index 0000000..8162060 --- /dev/null +++ b/src/api/v1/menu.py @@ -0,0 +1,28 @@ +# +====================================================================== +# | 模块: 菜单路由层 +# | 说明: 提供菜单信息的增删改查等 API 接口 +# +====================================================================== +"""菜单相关路由。""" + +# ---------------------------------------------------------------------- +# 导入菜单模块的各类处理器(Handler) +# 涵盖:列表、详情、新增、更新、删除 +# ---------------------------------------------------------------------- +from modules.menu.handlers import (MenuAddHandler, MenuDeleteHandler, MenuDetailHandler, + MenuListHandler, MenuUpdateHandler) + +# ---------------------------------------------------------------------- +# 路由表:每一项为 (URL 正则, 处理器类) +# ---------------------------------------------------------------------- +routes = [ + # 菜单列表(通常返回树形结构) + (r"/menu/list", MenuListHandler), + # 查询菜单详情,路径参数为菜单 ID + (r"/menu/detail/(\d+)", MenuDetailHandler), + # 新增菜单 + (r"/menu/add", MenuAddHandler), + # 更新菜单信息 + (r"/menu/update", MenuUpdateHandler), + # 删除单个菜单,路径参数为菜单 ID + (r"/menu/delete/(\d+)", MenuDeleteHandler), +] diff --git a/src/api/v1/notice.py b/src/api/v1/notice.py new file mode 100644 index 0000000..a61440c --- /dev/null +++ b/src/api/v1/notice.py @@ -0,0 +1,30 @@ +# +====================================================================== +# | 模块: 通知公告路由层 +# | 说明: 提供通知公告的增删改查、批量删除等 API 接口 +# +====================================================================== +"""通知公告相关路由。""" + +# ---------------------------------------------------------------------- +# 导入通知公告模块的各类处理器(Handler) +# 涵盖:分页、详情、新增、更新、删除、批量删除 +# ---------------------------------------------------------------------- +from modules.notice.handlers import (NoticeAddHandler, NoticeDeleteHandler, NoticeDetailHandler, + NoticePageHandler, NoticeUpdateHandler, NoticeBatchDeleteHandler) + +# ---------------------------------------------------------------------- +# 路由表:每一项为 (URL 正则, 处理器类) +# ---------------------------------------------------------------------- +routes = [ + # 通知公告分页查询 + (r"/notice/page", NoticePageHandler), + # 查询通知公告详情,路径参数为公告 ID + (r"/notice/detail/(\d+)", NoticeDetailHandler), + # 新增通知公告 + (r"/notice/add", NoticeAddHandler), + # 更新通知公告信息 + (r"/notice/update", NoticeUpdateHandler), + # 删除单条通知公告,路径参数为公告 ID + (r"/notice/delete/(\d+)", NoticeDeleteHandler), + # 批量删除通知公告 + (r"/notice/batchDelete", NoticeBatchDeleteHandler), +] diff --git a/src/api/v1/operation_log.py b/src/api/v1/operation_log.py new file mode 100644 index 0000000..e5cabb2 --- /dev/null +++ b/src/api/v1/operation_log.py @@ -0,0 +1,26 @@ +# +====================================================================== +# | 模块: 操作日志路由层 +# | 说明: 提供操作日志的查询、详情、删除、批量删除等 API 接口 +# +====================================================================== +"""操作日志相关路由。""" + +# ---------------------------------------------------------------------- +# 导入操作日志模块的各类处理器(Handler) +# 涵盖:分页、详情、删除、批量删除 +# ---------------------------------------------------------------------- +from modules.operation_log.handlers import (OperationLogDeleteHandler, OperationLogDetailHandler, + OperationLogPageHandler, OperationLogBatchDeleteHandler) + +# ---------------------------------------------------------------------- +# 路由表:每一项为 (URL 正则, 处理器类) +# ---------------------------------------------------------------------- +routes = [ + # 操作日志分页查询 + (r"/oper/log/page", OperationLogPageHandler), + # 查询操作日志详情,路径参数为日志 ID + (r"/oper/log/detail/(\d+)", OperationLogDetailHandler), + # 删除单条操作日志,路径参数为日志 ID + (r"/oper/log/delete/(\d+)", OperationLogDeleteHandler), + # 批量删除操作日志 + (r"/oper/log/batchDelete", OperationLogBatchDeleteHandler), +] diff --git a/src/api/v1/param.py b/src/api/v1/param.py new file mode 100644 index 0000000..c3a642e --- /dev/null +++ b/src/api/v1/param.py @@ -0,0 +1,32 @@ +# +====================================================================== +# | 模块: 参数路由层 +# | 说明: 提供系统参数的增删改查、批量删除、状态管理等 API 接口 +# +====================================================================== +"""参数相关路由。""" + +# ---------------------------------------------------------------------- +# 导入参数模块的各类处理器(Handler) +# 涵盖:分页、详情、新增、更新、删除、批量删除、状态变更 +# ---------------------------------------------------------------------- +from modules.param.handlers import (ParamAddHandler, ParamDeleteHandler, ParamDetailHandler, + ParamPageHandler, ParamStatusHandler, ParamUpdateHandler, ParamBatchDeleteHandler) + +# ---------------------------------------------------------------------- +# 路由表:每一项为 (URL 正则, 处理器类) +# ---------------------------------------------------------------------- +routes = [ + # 参数分页查询 + (r"/param/page", ParamPageHandler), + # 查询参数详情,路径参数为参数 ID + (r"/param/detail/(\d+)", ParamDetailHandler), + # 新增参数 + (r"/param/add", ParamAddHandler), + # 更新参数信息 + (r"/param/update", ParamUpdateHandler), + # 删除单个参数,路径参数为参数 ID + (r"/param/delete/(\d+)", ParamDeleteHandler), + # 批量删除参数 + (r"/param/batchDelete", ParamBatchDeleteHandler), + # 修改参数状态(如启用 / 禁用) + (r"/param/status", ParamStatusHandler), +] diff --git a/src/api/v1/position.py b/src/api/v1/position.py new file mode 100644 index 0000000..6de8eb1 --- /dev/null +++ b/src/api/v1/position.py @@ -0,0 +1,35 @@ +# +====================================================================== +# | 模块: 岗位路由层 +# | 说明: 提供岗位信息的增删改查、状态管理、列表获取等 API 接口 +# +====================================================================== +"""岗位相关路由。""" + +# ---------------------------------------------------------------------- +# 导入岗位模块的各类处理器(Handler) +# 涵盖:分页、详情、新增、更新、删除、批量删除、状态变更、列表获取 +# ---------------------------------------------------------------------- +from modules.position.handlers import (PositionAddHandler, PositionDeleteHandler, PositionDetailHandler, + PositionGetListHandler, PositionPageHandler, PositionStatusHandler, + PositionUpdateHandler, PositionBatchDeleteHandler) + +# ---------------------------------------------------------------------- +# 路由表:每一项为 (URL 正则, 处理器类) +# ---------------------------------------------------------------------- +routes = [ + # 岗位分页查询 + (r"/position/page", PositionPageHandler), + # 查询岗位详情,路径参数为岗位 ID + (r"/position/detail/(\d+)", PositionDetailHandler), + # 新增岗位 + (r"/position/add", PositionAddHandler), + # 更新岗位信息 + (r"/position/update", PositionUpdateHandler), + # 删除单个岗位,路径参数为岗位 ID + (r"/position/delete/(\d+)", PositionDeleteHandler), + # 批量删除岗位 + (r"/position/batchDelete", PositionBatchDeleteHandler), + # 修改岗位状态(如启用 / 禁用) + (r"/position/status", PositionStatusHandler), + # 获取岗位列表(常用于下拉选择等场景) + (r"/position/list", PositionGetListHandler), +] \ No newline at end of file diff --git a/src/api/v1/role.py b/src/api/v1/role.py new file mode 100644 index 0000000..c75e4db --- /dev/null +++ b/src/api/v1/role.py @@ -0,0 +1,33 @@ +# +====================================================================== +# | 模块: 角色路由层 +# | 说明: 提供角色信息的增删改查、批量删除、列表获取等 API 接口 +# +====================================================================== +"""角色相关路由。""" + +# ---------------------------------------------------------------------- +# 导入角色模块的各类处理器(Handler) +# 涵盖:分页、详情、新增、更新、删除、批量删除、列表获取 +# ---------------------------------------------------------------------- +from modules.role.handlers import (RoleAddHandler, RoleBatchDeleteHandler, RoleDeleteHandler, + RoleDetailHandler, RoleGetListHandler, RolePageHandler, + RoleUpdateHandler) + +# ---------------------------------------------------------------------- +# 路由表:每一项为 (URL 正则, 处理器类) +# ---------------------------------------------------------------------- +routes = [ + # 角色分页查询 + (r"/role/page", RolePageHandler), + # 查询角色详情,路径参数为角色 ID + (r"/role/detail/(\d+)", RoleDetailHandler), + # 新增角色 + (r"/role/add", RoleAddHandler), + # 更新角色信息 + (r"/role/update", RoleUpdateHandler), + # 删除单个角色,路径参数为角色 ID + (r"/role/delete/(\d+)", RoleDeleteHandler), + # 批量删除角色 + (r"/role/batchDelete", RoleBatchDeleteHandler), + # 获取角色列表(常用于下拉选择等场景) + (r"/role/list", RoleGetListHandler), +] diff --git a/src/api/v1/role_menu.py b/src/api/v1/role_menu.py new file mode 100644 index 0000000..a636a58 --- /dev/null +++ b/src/api/v1/role_menu.py @@ -0,0 +1,21 @@ +# +====================================================================== +# | 模块: 角色菜单路由层 +# | 说明: 提供角色菜单分配的查询与保存等 API 接口 +# +====================================================================== +"""角色菜单相关路由。""" + +# ---------------------------------------------------------------------- +# 导入角色菜单模块的各类处理器(Handler) +# 涵盖:查询角色菜单、保存角色菜单分配 +# ---------------------------------------------------------------------- +from modules.role_menu.handlers import (RoleMenuListHandler, RoleMenuSaveHandler) + +# ---------------------------------------------------------------------- +# 路由表:每一项为 (URL 正则, 处理器类) +# ---------------------------------------------------------------------- +routes = [ + # 查询角色已分配的菜单,路径参数为角色 ID + (r"/role/menu/list/(\d+)", RoleMenuListHandler), + # 保存角色菜单分配 + (r"/role/menu/save", RoleMenuSaveHandler), +] diff --git a/src/api/v1/router.py b/src/api/v1/router.py new file mode 100644 index 0000000..ee73a20 --- /dev/null +++ b/src/api/v1/router.py @@ -0,0 +1,57 @@ +# +====================================================================== +# | 模块: API v1 路由聚合层 +# | 说明: 汇总各资源路由,统一添加 /api/v1 前缀,并注册健康检查端点 +# +====================================================================== +"""API v1 路由聚合。 + +汇总各资源路由,统一添加 /api/v1 前缀,并注册健康检查端点。 +""" + +# ---------------------------------------------------------------------- +# 导入各资源的路由模块(每个模块内含 routes 列表) +# ---------------------------------------------------------------------- +from api.v1 import (article, auth, category, city, + config, config_item, dept, dict, + dict_item, file_template, generator, index, + job, job_log, level, link, + login_log, menu, notice, operation_log, + param, position, role, role_menu, + upload, user, user_role, example) +# 导入基础处理器中的健康检查处理器 +from core.base_handler import HealthHandler + +# ---------------------------------------------------------------------- +# API v1 统一前缀 +# ---------------------------------------------------------------------- +V1_PREFIX = "/api/v1" + +# ---------------------------------------------------------------------- +# 参与路由聚合的资源模块列表 +# 新增资源模块时,只需在此追加对应模块即可自动注册其路由 +# ---------------------------------------------------------------------- +_RESOURCE_MODULES = [ + auth, index, upload, user, + user_role, role, role_menu, menu, + dept, dict, dict_item, config, + config_item, level, position, city, + notice, link, category, article, + param, file_template, job, generator, + operation_log, login_log, job_log, example, +] + + +def build_routes() -> list: + """构建全部路由(含 /api/v1 前缀与健康检查)。 + + 遍历 _RESOURCE_MODULES,为每个资源模块的每条路由拼接统一前缀, + 最后追加健康检查端点。 + + :return: 完整的路由列表,元素为 (URL 路径, 处理器类) + """ + routes = [] + # 遍历所有资源模块,为其路由统一添加 /api/v1 前缀 + for module in _RESOURCE_MODULES: + routes.extend([(V1_PREFIX + path, handler) for path, handler in module.routes]) + # 追加健康检查端点 + routes.append((V1_PREFIX + "/health", HealthHandler)) + return routes diff --git a/src/api/v1/upload.py b/src/api/v1/upload.py new file mode 100644 index 0000000..24c1aa3 --- /dev/null +++ b/src/api/v1/upload.py @@ -0,0 +1,18 @@ +# +====================================================================== +# | 模块: 上传路由层 +# | 说明: 提供文件上传 API 接口(统一入口,支持图片和各类文件) +# +====================================================================== +"""上传相关路由。""" + +# ---------------------------------------------------------------------- +# 导入上传模块的处理器 +# ---------------------------------------------------------------------- +from modules.upload.handlers import UploadFileHandler + +# ---------------------------------------------------------------------- +# 路由表:每一项为 (URL 正则, 处理器类) +# ---------------------------------------------------------------------- +routes = [ + # 上传文件(统一入口,支持图片和各类文件) + (r"/upload/uploadFile", UploadFileHandler), +] diff --git a/src/api/v1/user.py b/src/api/v1/user.py new file mode 100644 index 0000000..8894f38 --- /dev/null +++ b/src/api/v1/user.py @@ -0,0 +1,40 @@ +# +====================================================================== +# | 模块: 用户路由层 +# | 说明: 提供用户信息的增删改查、批量删除、状态管理、密码重置、导入导出等 API 接口 +# +====================================================================== +"""用户相关路由。""" + +# ---------------------------------------------------------------------- +# 导入用户模块的各类处理器(Handler) +# 涵盖:分页、详情、新增、更新、删除、批量删除、状态变更、密码重置、导入导出 +# ---------------------------------------------------------------------- +from modules.user.handlers import (UserAddHandler, UserBatchDeleteHandler, UserDeleteHandler, + UserDetailHandler, UserExportHandler, UserImportHandler, + UserPageHandler, UserResetPwdHandler, + UserStatusHandler, UserUpdateHandler) + +# ---------------------------------------------------------------------- +# 路由表:每一项为 (URL 正则, 处理器类) +# ---------------------------------------------------------------------- +routes = [ + # 用户分页查询 + (r"/user/page", UserPageHandler), + # 查询用户详情,路径参数为用户 ID + (r"/user/detail/(\d+)", UserDetailHandler), + # 新增用户 + (r"/user/add", UserAddHandler), + # 更新用户信息 + (r"/user/update", UserUpdateHandler), + # 删除单个用户,路径参数为用户 ID + (r"/user/delete/(\d+)", UserDeleteHandler), + # 批量删除用户 + (r"/user/batchDelete", UserBatchDeleteHandler), + # 修改用户状态(如启用 / 禁用) + (r"/user/status", UserStatusHandler), + # 重置用户密码 + (r"/user/resetPwd", UserResetPwdHandler), + # 导入 Excel + (r"/user/import", UserImportHandler), + # 导出 Excel + (r"/user/export", UserExportHandler), +] diff --git a/src/api/v1/user_role.py b/src/api/v1/user_role.py new file mode 100644 index 0000000..16f6f31 --- /dev/null +++ b/src/api/v1/user_role.py @@ -0,0 +1,26 @@ +# +====================================================================== +# | 模块: 用户角色路由层 +# | 说明: 提供用户角色关联关系的查询、设置、删除等 API 接口 +# +====================================================================== +"""用户角色相关路由。""" + +# ---------------------------------------------------------------------- +# 导入用户角色模块的各类处理器(Handler) +# 涵盖:查询用户角色、设置用户角色、删除用户角色、清空用户角色 +# ---------------------------------------------------------------------- +from modules.user_role.handlers import (UserRoleClearHandler, UserRoleDeleteHandler, + UserRoleListHandler, UserRoleSaveHandler) + +# ---------------------------------------------------------------------- +# 路由表:每一项为 (URL 正则, 处理器类) +# ---------------------------------------------------------------------- +routes = [ + # 查询用户角色列表,路径参数为用户 ID + (r"/user/role/list/(\d+)", UserRoleListHandler), + # 设置用户角色(全量替换) + (r"/user/role/save", UserRoleSaveHandler), + # 删除用户角色关联,路径参数为用户 ID 和角色 ID + (r"/user/role/delete/(\d+)/(\d+)", UserRoleDeleteHandler), + # 清空用户所有角色,路径参数为用户 ID + (r"/user/role/clear/(\d+)", UserRoleClearHandler), +] diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..6c8e80f --- /dev/null +++ b/src/app.py @@ -0,0 +1,53 @@ +# +====================================================================== +# | 模块: 应用工厂层 +# | 说明: 创建并配置 Tornado 应用实例(路由、模板/静态目录、调试与安全选项), +# | 并完成扩展初始化 +# +====================================================================== +"""应用工厂。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:应用配置(根路径/调试开关)、认证配置(JWT 秘钥)、 +# 扩展注册入口、路由聚合、Tornado 框架 +# ---------------------------------------------------------------------- +from config.app import ROOT_PATH, TORNADO_DEBUG +from config.auth import JWT_SECRET +from extensions import register_extensions +from api.v1.router import build_routes +import tornado.web + + +# ====================================================================== +# 创建应用实例 +# ====================================================================== +def create_app(): + """创建 Tornado 应用实例。 + + :return: 配置完成的 Tornado Application 实例 + """ + app = tornado.web.Application( + # 初始化路由 + build_routes(), + # 模板文件目录 + template_path=str(ROOT_PATH / "templates"), + # 静态文件路径 + static_path=str(ROOT_PATH / "static"), + # 静态文件路由前缀 + static_url_prefix="/static/", + # 调试模式 + debug=TORNADO_DEBUG, + # 自动重载 + autoreload=False, + # 支持 gzip 压缩 + gzip=True, + # 禁用自动转义 + autoescape=None, + # 防跨站请求攻击 + xsrf_cookies=None, + # COOKIE 秘钥 + cookie_secret=JWT_SECRET, + ) + + # 初始化扩展 + register_extensions(app) + return app diff --git a/src/bootstrap.py b/src/bootstrap.py new file mode 100644 index 0000000..863e5cc --- /dev/null +++ b/src/bootstrap.py @@ -0,0 +1,117 @@ +# +====================================================================== +# | 模块: 启动引导层 +# | 说明: 解析命令行参数、创建应用并启动 HTTP 服务, +# | 注册信号处理实现优雅关闭 +# +====================================================================== +"""启动引导程序。 + +- 解析命令行参数(--port) +- 创建应用并启动 HTTP 服务 +- 注册信号处理实现优雅关闭 +""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:信号处理、Tornado HTTP 服务与事件循环、命令行参数工具、 +# 应用/数据库/邮件/Redis 配置、日志器 +# ---------------------------------------------------------------------- +import signal + +import tornado.httpserver +import tornado.ioloop +from tornado.options import define, options, parse_command_line + +from config.app import TORNADO_ENV, TORNADO_HOST, TORNADO_NAME, TORNADO_PORT +from config.database import DB_PASSWORD +from config.email import MAIL_PASSWORD +from config.redis import REDIS_PASSWORD +from core.logger import logger + +# ============================================================ +# 命令行参数定义 +# ============================================================ +define('port', default=TORNADO_PORT, help='run on the given port', type=int) + + +# ====================================================================== +# 启动 Banner +# ====================================================================== +_BANNER = r""" + ____ _ _ + | _ \ _ _ __ _ _ __ ___ (_) _ __ __ _ _ __ __| | + | | | || | | | / _` || '__| / __|| || '_ \ / _` || '__| / _` | + | |_| || |_| || (_| || | | (__ | || |_) || (_| || | | (_| | + |____/ \__,_| \__,_||_| \___||_|| .__/ \__,_||_| \__,_| + |_| + Tornado + AntdVue 企业级管理框架 +""" + + +# ====================================================================== +# 启动服务 +# ====================================================================== +def main(): + """启动服务。""" + # 延迟导入应用工厂,避免与根目录入口模块同名冲突 + from app import create_app + + # 打印启动 Banner + print(_BANNER) + + parse_command_line() + app = create_app() + + # 预热 ip2region 离线库,避免首个日志落库时在事件循环上加载阻塞 + try: + from common.utils.ip2region import preload + preload() + except Exception: + logger.warning("ip2region 预热失败(将延迟到首次查询时加载)") + + # 生产环境安全提醒(签名密钥已在 config.auth 启动校验中 fail-close;此处对其它 + # 敏感凭据做软告警,提示确认来源,不阻断启动) + if TORNADO_ENV.strip().lower() == "production": + for name, value in (("DB_PASSWORD", DB_PASSWORD), + ("REDIS_PASSWORD", REDIS_PASSWORD), + ("MAIL_PASSWORD", MAIL_PASSWORD)): + if not value: + logger.warning("生产环境检测到 %s 为空,请确认凭据来源(环境变量 / 密钥管理服务)", name) + + http_server = tornado.httpserver.HTTPServer(app) + http_server.listen(options.port) + + # 启动任务调度器并加载运行中任务 + from modules.job.scheduler import init_scheduler + init_scheduler() + + logger.info("服务启动成功 → http://%s:%s", TORNADO_HOST, options.port) + + ioloop = tornado.ioloop.IOLoop.current() + + # ============================================================ + # 优雅关闭回调 + # ============================================================ + def _shutdown(): + """收到退出信号时关闭调度器并停止事件循环。""" + logger.info("收到退出信号,正在优雅关闭...") + from modules.job.scheduler import shutdown_scheduler + shutdown_scheduler() + ioloop.stop() + + # ============================================================ + # 注册信号处理(优雅关闭) + # ============================================================ + for sig in (signal.SIGINT, signal.SIGTERM): + try: + signal.signal(sig, lambda s, f: ioloop.add_callback_from_signal(_shutdown)) + except (ValueError, AttributeError, OSError): + pass + + ioloop.start() + + +# ====================================================================== +# 入口 +# ====================================================================== +if __name__ == '__main__': + main() diff --git a/src/common/__init__.py b/src/common/__init__.py new file mode 100644 index 0000000..8b68290 --- /dev/null +++ b/src/common/__init__.py @@ -0,0 +1,8 @@ +# +====================================================================== +# | 模块: 公共模块包 +# | 说明: 声明 common 为包,用于组织中间件、工具与装饰器等公共代码 +# +====================================================================== +"""公共模块。 + +提示:开发者可根据项目需要,自行在此包内添加所需内容。 +""" diff --git a/src/common/middleware/__init__.py b/src/common/middleware/__init__.py new file mode 100644 index 0000000..a2e8107 --- /dev/null +++ b/src/common/middleware/__init__.py @@ -0,0 +1,6 @@ +# +====================================================================== +# | 模块: 中间件包 +# | 说明: 声明 middleware 为包,用于组织认证、跨域、日志、链路追踪等 +# | 中间件(装饰器实现) +# +====================================================================== +"""中间件(装饰器实现)。""" \ No newline at end of file diff --git a/src/common/middleware/access_decorators.py b/src/common/middleware/access_decorators.py new file mode 100644 index 0000000..a0a4189 --- /dev/null +++ b/src/common/middleware/access_decorators.py @@ -0,0 +1,98 @@ +# +====================================================================== +# | 模块: 访问控制装饰器层 +# | 说明: 提供演示模式拦截(check_demo)与权限节点校验(permission_required) +# | 两个装饰器 +# +====================================================================== +"""访问控制装饰器:演示模式拦截 + 权限校验。 + +- check_demo: 演示模式(TORNADO_DEMO=True)下拦截写操作,放在 @permission_required 之后、路由函数之前 +- permission_required: 权限节点校验(用户 ID 为 1 的超级管理员放行全部权限) +""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:异步判断、装饰器工具、演示模式配置、演示提示文案、 +# 统一响应对象、当前用户上下文、日志器 +# ---------------------------------------------------------------------- +import asyncio +from functools import wraps + +from config.app import TORNADO_DEMO +from constants import MESSAGE_DEMO +from core import response as R +from core.context import current_user_id +from core.logger import logger + + +# ====================================================================== +# 演示模式拦截装饰器 +# ====================================================================== +def check_demo(func): + """演示模式拦截装饰器:放在 @permission_required 之后、路由函数之前。 + + :param func: 被装饰的同步 / 异步处理函数 + :return: 包装后的函数 + """ + # 异步函数分支 + if asyncio.iscoroutinefunction(func): + @wraps(func) + async def async_wrapper(self, *args, **kwargs): + """异步包装:演示模式下直接返回失败响应。""" + if TORNADO_DEMO: + return R.failed(self, MESSAGE_DEMO) + return await func(self, *args, **kwargs) + + return async_wrapper + + # 同步函数分支 + @wraps(func) + def sync_wrapper(self, *args, **kwargs): + """同步包装:演示模式下直接返回失败响应。""" + if TORNADO_DEMO: + return R.failed(self, MESSAGE_DEMO) + return func(self, *args, **kwargs) + + return sync_wrapper + + +# ====================================================================== +# 权限节点鉴权装饰器 +# ====================================================================== +def permission_required(permission: str, log: bool = False): + """权限节点鉴权装饰器。 + + :param permission: 权限节点标识,如 "sys:user:list" + :param log: 是否记录无权限访问日志 + """ + + def decorator(func): + """接收被装饰函数,返回带鉴权的包装函数。""" + + @wraps(func) + async def wrapper(self, *args, **kwargs): + """鉴权包装:校验当前用户是否具备指定权限。""" + user_id = current_user_id.get() + # 超级管理员直接放行 + if user_id != 1: + # 延迟导入避免循环依赖 + from common.permission import get_permissions_list + + permission_list = get_permissions_list(user_id) + if permission not in permission_list: + # 可选记录无权限访问日志 + if log: + logger.warning("无权限访问 path=%s permission=%s user=%s", + self.request.path, permission, user_id) + # 无权限:HTTP 状态码与业务码统一为 403 + self.set_status(403) + return R.failed(self, "权限不足", code=R.Codes.FORBIDDEN) + + # 兼容同步 / 异步 handler(同步 handler 的返回不再被 await) + result = func(self, *args, **kwargs) + if asyncio.iscoroutine(result): + return await result + return result + + return wrapper + + return decorator diff --git a/src/common/middleware/authentication.py b/src/common/middleware/authentication.py new file mode 100644 index 0000000..f52a7ad --- /dev/null +++ b/src/common/middleware/authentication.py @@ -0,0 +1,60 @@ +# +====================================================================== +# | 模块: 认证中间件层 +# | 说明: 提供登录检测装饰器,校验 JWT(含黑名单)并将 userId 写入请求上下文 +# +====================================================================== +"""认证中间件(装饰器)。 + +所有请求(除 /api/v1/login、/api/v1/captcha 与 OPTIONS 预检)需携带 +有效 JWT(Authorization: Bearer )。认证通过后将 userId 写入 +current_user_id 上下文,供仓储/服务层审计使用。 +""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:统一响应对象、当前用户上下文、JWT 工具(解析令牌/黑名单/取令牌) +# ---------------------------------------------------------------------- +from core import response as R +from core.context import current_realname, current_user_id, current_username +from core.jwt import get_access_token, is_token_blacklisted, parse_payload + +# ============================================================ +# 无需认证的 URL +# ============================================================ +IGNORE_URL = ["/api/v1/login", "/api/v1/captcha"] + + +# ====================================================================== +# 登录检测装饰器 +# ====================================================================== +def login_required(func): + """登录检测装饰器。 + + :param func: 被装饰的处理函数 + :return: 包装后的处理函数 + """ + + def wrapper(self, *args, **kwargs): + """鉴权包装:非白名单请求需携带有效 JWT。""" + request_url = self.request.path + # 白名单与 OPTIONS 预检请求直接放行 + if request_url not in IGNORE_URL and self.request.method != "OPTIONS": + # 获取请求头中的 token(统一走 get_access_token,大小写不敏感解析 Bearer) + success, access_token, _ = get_access_token(self.request) + if not success or not access_token: + return R.failed(self, code=401, msg="登录过期") + # JWT 解密 + result = parse_payload(access_token) + if result["code"] != 0: + return R.failed(self, code=401, msg="登录过期") + # 黑名单校验:已注销(登出)的令牌拒绝访问(Redis 不可用时 fail-open 放行) + if is_token_blacklisted(access_token): + return R.failed(self, code=401, msg="登录过期") + # 写入当前用户上下文 + data = result["data"] + current_user_id.set(int(data["userId"])) + current_username.set(data.get("username", "")) + current_realname.set(data.get("realname", "")) + # 放行 + return func(self, *args, **kwargs) + + return wrapper diff --git a/src/common/middleware/cors.py b/src/common/middleware/cors.py new file mode 100644 index 0000000..f3d352c --- /dev/null +++ b/src/common/middleware/cors.py @@ -0,0 +1,53 @@ +# +====================================================================== +# | 模块: 跨域请求中间件层 +# | 说明: 依据配置的来源白名单为响应设置 CORS 与安全相关响应头 +# +====================================================================== +"""跨域请求中间件。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:跨域来源白名单配置 +# ---------------------------------------------------------------------- +from config.app import TORNADO_CORS_ORIGINS + +# ============================================================ +# 允许来源白名单 +# ============================================================ +# 配置的允许来源白名单(TORNADO_CORS_ORIGINS 逗号分隔);为空表示允许所有来源(但不携带凭据) +_ALLOWED_ORIGINS = {o.strip() for o in TORNADO_CORS_ORIGINS.split(",") if o.strip()} + + +# ====================================================================== +# 设置跨域响应头 +# ====================================================================== +def cros_required(self): + """为响应设置跨域相关头。 + + 未配置白名单时允许所有来源(Access-Control-Allow-Origin: *),但按浏览器规范 + 不能同时携带凭据;配置白名单后仅对匹配来源反射该来源并允许携带凭据,其余来源 + 不返回 CORS 头(浏览器将阻止跨域读取)。 + + :param self: Tornado 请求处理器 + """ + origin = self.request.headers.get("Origin", "") + # 配置了白名单:仅对匹配来源反射来源并允许携带凭据 + if _ALLOWED_ORIGINS: + if origin in _ALLOWED_ORIGINS: + self.set_header("Access-Control-Allow-Origin", origin) + self.set_header("Access-Control-Allow-Credentials", "true") + self.set_header("Vary", "Origin") + # 不在白名单:不设置 Access-Control-Allow-Origin,浏览器拦截跨域 + else: + # 未配置白名单:允许所有来源(不携带凭据) + self.set_header("Access-Control-Allow-Origin", "*") + # 安全相关响应头 + self.set_header("X-XSS-Protection", "1; mode=block") + self.set_header("Content-Security-Policy", "default-src 'self'") + # 允许的响应头(* 表示允许所有响应头) + self.set_header("Access-Control-Allow-Headers", "*") + self.set_header("Access-Control-Expose-Headers", "*") + # 允许的请求方法 + self.set_header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS") + self.set_header("Content-Type", "application/json; charset=UTF-8") + # 预检请求最大缓存时间 + self.set_header("Access-Control-Max-Age", "3600") diff --git a/src/common/middleware/login_log.py b/src/common/middleware/login_log.py new file mode 100644 index 0000000..f1054ac --- /dev/null +++ b/src/common/middleware/login_log.py @@ -0,0 +1,157 @@ +# +====================================================================== +# | 模块: 登录日志中间件层 +# | 说明: 提供 @login_log 装饰器,记录登录(成功/失败)与退出操作日志, +# | 并异步落库、脱敏敏感参数 +# +====================================================================== +"""登录日志中间件:记录登录(成功/失败)与退出操作。 + +用法: + @login_log(LOGIN) + async def post(self): ... + +说明: +- log_type:1-登录(从请求体取账号),2-退出(从当前用户查询账号)。 +- 依赖 core.response 的 R.ok/R.failed 返回结果 dict,解析业务状态(code/msg)。 +- 登录参数中的密码等敏感字段会脱敏。 +- 落库通过 IOLoop.spawn_callback 延迟到响应写出后异步执行,不阻塞主流程。 +""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:JSON 解析、计时、装饰器工具、浏览器/系统识别与参数脱敏工具、 +# IP 归属地查询、当前用户上下文、日志器 +# ---------------------------------------------------------------------- +import json +import time +from functools import wraps + +from common.utils.helpers import detect_browser, detect_os, redact_params +from common.utils.ip import get_client_ip +from common.utils.ip2region import get_ip_location +from core.context import current_username +from core.logger import logger + +# ============================================================ +# 常量定义 +# ============================================================ +# 日志类型:1-登录 2-退出 +LOGIN = 1 +LOGOUT = 2 +# 字段长度上限 +_PARAM_MAX_LEN = 2000 +_RESULT_MAX_LEN = 2000 +_ERROR_MAX_LEN = 1000 + + +# ====================================================================== +# 提取登录账号 +# ====================================================================== +def _extract_username(self, log_type: int) -> str: + """提取登录账号:登录从请求体取;退出从上下文读取。 + + :param self: Tornado 请求处理器 + :param log_type: 日志类型(LOGIN / LOGOUT) + :return: 登录账号,取不到返回空串 + """ + # 登录:从请求体 JSON 中取 username + if log_type == LOGIN: + try: + data = json.loads(self.request.body) if self.request.body else {} + return str(data.get("username", "")) + except Exception: + return "" + # 退出:从上下文获取(用户已认证) + return current_username.get() + + +# ====================================================================== +# 记录登录日志(异步落库) +# ====================================================================== +def _record(self, username: str, log_type: int, status: int, + error: str, result: str, consume_time: int) -> None: + """记录登录日志:延迟到响应写出后异步落库。 + + :param self: Tornado 请求处理器 + :param username: 登录账号 + :param log_type: 日志类型(LOGIN / LOGOUT) + :param status: 操作状态(0-正常 1-异常) + :param error: 错误信息 + :param result: 返回结果(JSON 字符串) + :param consume_time: 请求耗时(毫秒) + """ + try: + import tornado.ioloop + from modules.login_log.service import record_login_log + + ua = self.request.headers.get("User-Agent", "") + body = self.request.body or b"" + client_ip = get_client_ip(self) + # 延迟到响应写出后异步落库,不阻塞主流程 + tornado.ioloop.IOLoop.current().spawn_callback( + record_login_log, + username=username, + log_type=log_type, + source=0, # 后台登录 + method=self.request.path, + request_method=self.request.method, + url=self.request.uri, + param=redact_params(body.decode("utf-8", errors="replace"), _PARAM_MAX_LEN), + result=result[:_RESULT_MAX_LEN], + ip=client_ip, + location=get_ip_location(client_ip), + os=detect_os(ua), + browser=detect_browser(ua), + consume_time=consume_time, + status=status, + error=error, + ) + except Exception: + # 日志记录异常不影响主流程 + logger.exception("登录日志记录异常") + + +# ====================================================================== +# 登录/退出日志装饰器 +# ====================================================================== +def login_log(log_type: int): + """登录/退出日志装饰器:@login_log(LOGIN) / @login_log(LOGOUT)。 + + :param log_type: 日志类型(LOGIN / LOGOUT) + """ + + def decorator(func): + """接收被装饰函数,返回带日志记录的包装函数。""" + + @wraps(func) + async def wrapper(self, *args, **kwargs): + """日志包装:调用原函数并根据返回结果记录登录日志。""" + start = time.time() + status = 0 + error_msg = "" + result_msg = "" + # 预先提取账号(登录取请求体,退出取当前用户) + username = _extract_username(self, log_type) + try: + await func(self, *args, **kwargs) + # 解析业务状态:R.ok/R.failed 已将结果 dict 写入 self._api_result + result = getattr(self, "_api_result", None) + if isinstance(result, dict): + if result.get("code", 0) != 0: + status = 1 + error_msg = result.get("msg", "") or "" + result_msg = json.dumps(result, ensure_ascii=False)[:_RESULT_MAX_LEN] + # 返回 None:避免 Tornado 对 handler 返回值执行 await 触发 TypeError + return None + except Exception as e: + status = 1 + error_msg = str(e) + raise + finally: + # 无论成功失败均记录日志 + consume_time = int((time.time() - start) * 1000) + _record(self, username, log_type, status, + error_msg[:_ERROR_MAX_LEN], result_msg, consume_time) + + return wrapper + + return decorator diff --git a/src/common/middleware/metrics.py b/src/common/middleware/metrics.py new file mode 100644 index 0000000..ba53b0b --- /dev/null +++ b/src/common/middleware/metrics.py @@ -0,0 +1,14 @@ +# +====================================================================== +# | 模块: 性能监控中间件层 +# | 说明: 预留请求计数与耗时统计能力(占位),供后续接入 core.metrics +# +====================================================================== +"""性能监控中间件(占位)。 + +TODO: 接入 core.metrics,统计请求计数与耗时。 + +预留扩展: + - 请求计数:按路径 / 方法 / 状态码维度累计 QPS + - 耗时直方图:记录请求处理耗时分布 + - 慢请求标记:超过阈值的请求单独计数或告警 + - 指标导出:通过 /api/v1/metrics 端点暴露给 Prometheus 抓取 +""" \ No newline at end of file diff --git a/src/common/middleware/operation_log.py b/src/common/middleware/operation_log.py new file mode 100644 index 0000000..c5f1b79 --- /dev/null +++ b/src/common/middleware/operation_log.py @@ -0,0 +1,173 @@ +# +====================================================================== +# | 模块: 操作日志中间件层 +# | 说明: 提供 @operation_log 装饰器,拦截写请求并记录操作日志 +# | (适配 Tornado 的装饰器形式),异步落库、敏感参数脱敏 +# +====================================================================== +"""操作日志中间件:拦截写请求并记录操作日志(适配 Tornado 的装饰器形式)。 + +用法: + @operation_log("职级管理", "详情") + @permission_required("sys:level:list") + async def get(self): + ... + +说明: +- 仅记录写方法(POST/PUT/DELETE),并按路径前缀排除登录/验证码/上传/主页等接口。 +- 依赖 core.response 的 R.ok/R.failed 返回结果 dict,解析业务状态(code/msg)。 +- 捕获请求参数(敏感字段脱敏)、URL、IP、UA(操作系统/浏览器/来源)。 +- 落库通过 IOLoop.spawn_callback 延迟到响应写出后异步执行,不阻塞主流程。 +""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:JSON 解析、计时、装饰器工具、类型标注、 +# 浏览器/系统/来源识别与参数脱敏工具、IP 归属地查询、 +# 当前用户上下文、日志器 +# ---------------------------------------------------------------------- +import json +import time +from functools import wraps +from typing import Any + +from common.utils.helpers import detect_browser, detect_os, detect_source, redact_params +from common.utils.ip import get_client_ip +from common.utils.ip2region import get_ip_location +from core.context import current_realname +from core.logger import logger + +# ============================================================ +# 常量定义 +# ============================================================ +# 不记录操作日志的路径前缀(个人中心改资料/改密码不属后台管理操作,避免日志噪音) +_EXCLUDE_PATHS = ( + "/api/v1/login", + "/api/v1/captcha", + "/api/v1/upload", + "/api/v1/index/menu", + "/api/v1/index/user", + "/api/v1/index/userInfo", + "/api/v1/index/updatePwd", + "/api/v1/index/logout", + "/api/v1/operationlog", +) +# 写方法才记录 +_MUTATE_METHODS = ("POST", "PUT", "DELETE") +# 请求类型:HTTP 方法 -> 业务类型 +_REQUEST_TYPE = {"POST": 1, "PUT": 2, "DELETE": 3} +# 字段长度上限 +_PARAM_MAX_LEN = 10000 +_RESULT_MAX_LEN = 10000 +_ERROR_MAX_LEN = 1000 + + +# ====================================================================== +# 判断是否需要记录 +# ====================================================================== +def _should_log(self) -> bool: + """判断当前请求是否需要记录操作日志。 + + :param self: Tornado 请求处理器 + :return: 需要记录返回 True,否则 False + """ + if self.request.method not in _MUTATE_METHODS: + return False + path = self.request.path + # 按路径段边界匹配排除前缀(path == prefix 或 prefix + '/'),避免子串误伤 + return not any(path == prefix or path.startswith(prefix + "/") for prefix in _EXCLUDE_PATHS) + + +# ====================================================================== +# 记录操作日志(异步落库) +# ====================================================================== +def _record(self: Any, module: str, action: str, status: int, + error: str, result: str, consume_time: int) -> None: + """记录操作日志:延迟到响应写出后异步落库。 + + :param self: Tornado 请求处理器 + :param module: 业务模块名(如"职级管理") + :param action: 操作动作(如"添加") + :param status: 操作状态(0-成功 1-失败) + :param error: 错误信息 + :param result: 返回结果(JSON 字符串) + :param consume_time: 请求耗时(毫秒) + """ + try: + import tornado.ioloop + from modules.operation_log.service import record_operation_log + + ua = self.request.headers.get("User-Agent", "") + body = self.request.body or b"" + client_ip = get_client_ip(self) + # 延迟到响应写出后异步落库,不阻塞主流程 + tornado.ioloop.IOLoop.current().spawn_callback( + record_operation_log, + realname=current_realname.get(), + title=f"{module}-{action}", + type=_REQUEST_TYPE.get(self.request.method, 1), + source=detect_source(ua), + method=self.request.path, + request_method=self.request.method, + url=self.request.uri, + param=redact_params(body.decode("utf-8", errors="replace"), _PARAM_MAX_LEN), + result=result[:_RESULT_MAX_LEN], + ip=client_ip, + location=get_ip_location(client_ip), + os=detect_os(ua), + browser=detect_browser(ua), + consume_time=consume_time, + status=status, + error=error, + ) + except Exception: + # 日志记录异常不影响主流程 + logger.exception("操作日志记录异常") + + +# ====================================================================== +# 操作日志装饰器 +# ====================================================================== +def operation_log(module: str = "", action: str = ""): + """操作日志装饰器:放在 @permission_required / @check_demo 之后、路由函数之前。 + + :param module: 业务模块名(如"职级管理") + :param action: 操作动作(如"添加") + """ + + def decorator(func): + """接收被装饰函数,返回带日志记录的包装函数。""" + + @wraps(func) + async def wrapper(self, *args, **kwargs): + """日志包装:按需调用原函数并记录操作日志。""" + # 非写请求或排除路径:直接放行,不记录 + if not _should_log(self): + return await func(self, *args, **kwargs) + + start = time.time() + status = 0 + error_msg = "" + result_msg = "" + try: + await func(self, *args, **kwargs) + # 解析业务状态:R.ok/R.failed 已将结果 dict 写入 self._api_result + result = getattr(self, "_api_result", None) + if isinstance(result, dict): + if result.get("code", 0) != 0: + status = 1 + error_msg = result.get("msg", "") or "" + result_msg = json.dumps(result, ensure_ascii=False)[:_RESULT_MAX_LEN] + # 返回 None:避免 Tornado 对 handler 返回值执行 await 触发 TypeError + return None + except Exception as e: + status = 1 + error_msg = str(e) + raise + finally: + # 无论成功失败均记录日志 + consume_time = int((time.time() - start) * 1000) + _record(self, module, action, status, + error_msg[:_ERROR_MAX_LEN], result_msg, consume_time) + + return wrapper + + return decorator diff --git a/src/common/middleware/trace_id.py b/src/common/middleware/trace_id.py new file mode 100644 index 0000000..1af0337 --- /dev/null +++ b/src/common/middleware/trace_id.py @@ -0,0 +1,39 @@ +# +====================================================================== +# | 模块: 链路追踪 ID 中间件层 +# | 说明: 透传或生成请求 trace_id,写入响应头与请求上下文 +# +====================================================================== +"""链路追踪 ID 中间件。 + +透传请求头 X-Trace-Id;无则生成新 ID,写入响应头与请求上下文。 +""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:UUID 生成、当前请求 trace_id 上下文 +# ---------------------------------------------------------------------- +import uuid + +from core.context import current_trace_id + +# ============================================================ +# 常量定义 +# ============================================================ +# 链路追踪 ID 请求头名称 +TRACE_ID_HEADER = "X-Trace-Id" + + +# ====================================================================== +# 生成 / 透传 trace_id +# ====================================================================== +def apply_trace_id(self): + """为当前请求生成/透传 trace_id。 + + :param self: Tornado 请求处理器 + :return: 本次请求的 trace_id + """ + # 优先透传请求头,缺省时生成新的 UUID + trace_id = self.request.headers.get(TRACE_ID_HEADER) or uuid.uuid4().hex + # 写入响应头与请求上下文 + self.set_header(TRACE_ID_HEADER, trace_id) + current_trace_id.set(trace_id) + return trace_id diff --git a/src/common/permission.py b/src/common/permission.py new file mode 100644 index 0000000..3d5eee7 --- /dev/null +++ b/src/common/permission.py @@ -0,0 +1,125 @@ +# +====================================================================== +# | 模块: 权限节点解析层 +# | 说明: 跨模块共享的权限解析,供访问控制中间件与个人中心使用, +# | 带 Redis 缓存与版本号即时失效 +# +====================================================================== +"""权限节点解析(跨模块共享,供访问控制中间件与个人中心使用)。 + +权限节点存储于 menu 表(type=1),非独立业务模块: +- get_permissions_list:按用户解析权限节点列表(带 Redis 缓存) +- invalidate_permission_cache:角色/菜单/用户角色变更时使权限缓存失效 +""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:JSON 序列化、SQLAlchemy 条件表达式、数据库会话、日志器、 +# Redis 客户端、菜单/角色/用户模型 +# ---------------------------------------------------------------------- +import json + +from sqlalchemy import and_, or_ + +from core.database import db +from core.logger import logger +from core.redis import redis +from modules.menu.models import Menu +from modules.role.models import Role +from modules.role_menu.models import RoleMenu +from modules.user.models import User +from modules.user_role.models import UserRole + +# ============================================================ +# 缓存配置 +# ============================================================ +# 权限缓存 TTL(秒):角色/菜单变更走版本号即时失效,TTL 仅兜底 +_PERM_CACHE_TTL = 300 +# 权限缓存版本号 key:自增即表示"所有用户的权限缓存失效" +_PERM_VERSION_KEY = "perm:version" + + +# ====================================================================== +# 读取权限缓存版本号 +# ====================================================================== +def _get_perm_version() -> int: + """读取权限缓存版本号(Redis 异常时回退 1,仅按 TTL 失效)。 + + :return: 当前权限缓存版本号 + """ + try: + version = redis.get(_PERM_VERSION_KEY) + return int(version) if version else 1 + except Exception: + return 1 + + +# ====================================================================== +# 失效权限缓存 +# ====================================================================== +def invalidate_permission_cache() -> None: + """角色/菜单/用户角色变更后调用:自增版本号使所有权限缓存立即失效。""" + try: + redis.incr(_PERM_VERSION_KEY) + except Exception: + logger.warning("权限缓存失效失败(将按 TTL 自动过期)") + + +# ====================================================================== +# 获取用户权限节点列表 +# ====================================================================== +def get_permissions_list(user_id) -> list: + """获取用户权限节点列表(带 Redis 缓存)。超级管理员(id=1)拥有全部节点。 + + :param user_id: 用户 ID + :return: 权限节点标识列表 + """ + user_id = int(user_id) + version = _get_perm_version() + cache_key = f"perm:list:{version}:{user_id}" + + # 读缓存 + try: + cached = redis.get(cache_key) + if cached is not None: + return json.loads(cached) + except Exception: + pass # 缓存读取异常降级为实时查询 + + # 实时查询 + try: + if user_id == 1: + # 超级管理员:全部权限节点 + nodes = db.query(Menu).filter(and_(Menu.is_delete == 0, Menu.type == 1)).all() + else: + # 其他用户:通过角色关联查询权限节点(过滤软删的用户/角色/菜单关联, + # 且仅取状态正常的用户,停用的用户不再授权) + nodes = ( + db.query(Menu) + .join(RoleMenu, RoleMenu.menu_id == Menu.id) + .join(UserRole, UserRole.role_id == RoleMenu.role_id) + .join(Role, Role.id == UserRole.role_id) + .join(User, User.id == UserRole.user_id) + .filter( + UserRole.user_id == user_id, + UserRole.is_delete == 0, + RoleMenu.is_delete == 0, + Role.is_delete == 0, + User.is_delete == 0, + User.status == 1, + or_(Menu.type == 1, and_(Menu.type == 0, Menu.permission != "")), + Menu.status == 0, + Menu.is_delete == 0, + ) + .distinct() + .all() + ) + # 提取权限标识,过滤空值 + result = [item.permission for item in nodes if item.permission] + finally: + db.close() + + # 写缓存(Redis 异常时静默,仅损失缓存收益) + try: + redis.set(cache_key, json.dumps(result), ex=_PERM_CACHE_TTL) + except Exception: + pass + return result diff --git a/src/common/utils/__init__.py b/src/common/utils/__init__.py new file mode 100644 index 0000000..05d34fe --- /dev/null +++ b/src/common/utils/__init__.py @@ -0,0 +1 @@ +"""公共工具函数。""" diff --git a/src/common/utils/captcha.py b/src/common/utils/captcha.py new file mode 100644 index 0000000..b5356d0 --- /dev/null +++ b/src/common/utils/captcha.py @@ -0,0 +1,251 @@ +# +====================================================================== +# | 模块: 验证码生成与验证工具层 +# | 说明: 生成带干扰的验证码图片(Base64)、校验用户输入、按 IP 防刷 +# +====================================================================== +""" +验证码生成与验证模块 +============================================================= + +功能说明: + 1. 生成带干扰的验证码图片,返回 Base64 格式数据 + 2. 验证用户输入的验证码是否正确 + 3. 防刷机制:限制同一 IP 获取验证码的频率 + +技术特点: + - 使用 secrets 模块生成安全随机数,替代 random 模块 + - 字符集去除 0/O/1/I/l/5/S/2/Z 等易混淆字符,提升用户体验 + - 验证码图片包含干扰线和噪点,防止 OCR 识别 + - 验证码存储于 Redis,支持过期自动失效 + - 验证成功后自动删除,确保一次性使用,防止重放攻击 + - 大小写不敏感比较,提升用户体验 + +配置来源: + 参数从 config.captcha 读取(可在 .env 中覆盖,代码保留默认值兜底), + 包括:验证码长度、过期时间、防刷间隔、字符集、图片尺寸等。 + +依赖: + - core.redis: 项目统一的同步 Redis 客户端,用于存储验证码与防刷计数 + - config.captcha: 验证码配置参数(.env 可覆盖) + - config.app: TORNADO_TRUST_PROXY 等应用配置 + - PIL: 图片生成与处理 + +接口说明: + generate_captcha(handler) -> dict + 生成验证码,返回 {"idKey": "唯一标识", "data": "base64图片数据"} + handler 为 Tornado 请求处理器(用于读取客户端 IP 做防刷) + 调用频率受 CAPTCHA_RATE_LIMIT 限制,超限抛出 BusinessError + + verify_captcha(id_key, user_input) -> bool + 验证用户输入的验证码是否正确 + 验证成功后自动删除 Redis 中的记录 +""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:Base64 编码、安全随机数、字节流、PIL 图像处理、 +# 应用配置(根路径 / 反向代理信任)、验证码配置、 +# 业务异常、Redis 客户端 +# ---------------------------------------------------------------------- +import base64 +import secrets +from io import BytesIO + +from PIL import Image, ImageDraw, ImageFilter, ImageFont + +from config.app import ROOT_PATH, TORNADO_TRUST_PROXY +from config.captcha import (CAPTCHA_CHARS, CAPTCHA_EXPIRE, CAPTCHA_FONT_SIZE, + CAPTCHA_IMAGE_HEIGHT, CAPTCHA_IMAGE_WIDTH, + CAPTCHA_LENGTH, CAPTCHA_NOISE_DOTS, + CAPTCHA_NOISE_LINES, CAPTCHA_RATE_LIMIT, + CAPTCHA_RATE_WINDOW) +from core.exceptions import BusinessError +from core.redis import redis + +# 项目内置字体(缺失时回退 PIL 默认字体) +_CAPTCHA_FONT_PATH = str(ROOT_PATH / "src" / "common" / "utils" / "fonts" / "Vera.ttf") + + +# ====================================================================== +# 内部函数 +# ====================================================================== + +# ============================================================ +# 获取随机颜色 +# ============================================================ +def get_random_color(): + """获取随机颜色RGB(使用secrets更安全)""" + return ( + secrets.randbelow(256), + secrets.randbelow(256), + secrets.randbelow(256) + ) + + +# ============================================================ +# 生成验证码图片 +# ============================================================ +def generate_image(length): + """生成验证码图片 + + Args: + length: 验证码长度 + + Returns: + tuple: (PIL.Image对象, 验证码字符串) + """ + # 从字符集随机抽取 + code = ''.join(secrets.choice(CAPTCHA_CHARS) for _ in range(length)) + + # 创建画布(浅灰色背景,减少对比度干扰) + image = Image.new('RGB', (CAPTCHA_IMAGE_WIDTH, CAPTCHA_IMAGE_HEIGHT), color=(240, 240, 240)) + + # 加载字体(文件缺失时回退默认字体,保证服务可用) + try: + font = ImageFont.truetype(_CAPTCHA_FONT_PATH, size=CAPTCHA_FONT_SIZE) + except Exception: + font = ImageFont.load_default() + + draw = ImageDraw.Draw(image) + + # 绘制验证码字符(随机偏移,防OCR识别) + char_width = (CAPTCHA_IMAGE_WIDTH - 20) // length + for i, c in enumerate(code): + x = 10 + i * char_width + secrets.randbelow(8) + y = secrets.randbelow(10) + 2 + draw.text( + (x, y), + text=c, + fill=get_random_color(), + font=font + ) + + # 绘制干扰线 + for _ in range(CAPTCHA_NOISE_LINES): + x1 = secrets.randbelow(CAPTCHA_IMAGE_WIDTH) + y1 = secrets.randbelow(CAPTCHA_IMAGE_HEIGHT // 2) + x2 = secrets.randbelow(CAPTCHA_IMAGE_WIDTH) + y2 = secrets.randbelow(CAPTCHA_IMAGE_HEIGHT // 2) + CAPTCHA_IMAGE_HEIGHT // 2 + draw.line(((x1, y1), (x2, y2)), fill=get_random_color(), width=1) + + # 添加噪点 + for _ in range(CAPTCHA_NOISE_DOTS): + x = secrets.randbelow(CAPTCHA_IMAGE_WIDTH) + y = secrets.randbelow(CAPTCHA_IMAGE_HEIGHT) + draw.point((x, y), fill=get_random_color()) + + # 滤镜增强边缘 + image = image.filter(ImageFilter.EDGE_ENHANCE) + + return image, code + + +# ============================================================ +# 获取客户端真实 IP +# ============================================================ +def get_client_ip(handler): + """获取客户端真实IP。 + + 仅在配置了可信代理(TORNADO_TRUST_PROXY=True)时才采用 X-Forwarded-For 透传; + 否则取直连 IP(remote_ip),防止攻击者伪造 XFF 头绕过验证码限流与登录防爆破。 + """ + # 可信代理:取 XFF 首个地址 + if TORNADO_TRUST_PROXY: + forwarded = handler.request.headers.get('X-Forwarded-For', '') + if forwarded: + return forwarded.split(',')[0].strip() + # 默认:取直连 IP + return handler.request.remote_ip + + +# ====================================================================== +# 对外接口 +# ====================================================================== + +# ============================================================ +# 生成验证码 +# ============================================================ +def generate_captcha(handler): + """生成验证码实例 + + Args: + handler: Tornado 请求处理器(用于读取客户端 IP 做防刷) + + Returns: + dict: { + "idKey": str, # 验证码唯一标识 + "data": str # base64图片数据 + } + + Raises: + BusinessError: 同一 IP 请求过于频繁时抛出(由 BaseHandler 转统一响应) + """ + # ============================================================ + # 防刷:原子 incr + expire,消除 get/check/incr 竞态窗口 + # ============================================================ + client_ip = get_client_ip(handler) + rate_key = f"captcha:rate:{client_ip}" + count = redis.incr(rate_key) + # 首次计数时设置窗口 TTL + if count == 1: + redis.expire(rate_key, CAPTCHA_RATE_WINDOW) + # 超出限制:返回剩余等待时间 + if count > CAPTCHA_RATE_LIMIT: + ttl = redis.ttl(rate_key) + remaining = ttl if ttl > 0 else CAPTCHA_RATE_WINDOW + raise BusinessError(f"请求过于频繁,请等待 {remaining} 秒后再试") + + # ============================================================ + # 生成验证码 + # ============================================================ + image, code = generate_image(CAPTCHA_LENGTH) + + # 转换为base64 + buffer = BytesIO() + image.save(buffer, format='PNG') + buf_bytes = buffer.getvalue() + data = base64.b64encode(buf_bytes).decode('ascii') + + # 生成唯一ID(使用secrets更安全) + id_key = secrets.token_urlsafe(24) + + # 存储验证码到Redis(带过期时间,ex 为秒) + redis.set(id_key, code, ex=CAPTCHA_EXPIRE) + + return { + "idKey": id_key, + "data": data + } + + +# ============================================================ +# 校验验证码 +# ============================================================ +def verify_captcha(id_key, user_input): + """验证验证码(验证成功后自动删除,一次性使用) + + Args: + id_key: 验证码ID + user_input: 用户输入的验证码 + + Returns: + bool: 验证是否通过 + """ + if not id_key or not user_input: + return False + + # 从Redis获取验证码 + stored_code = redis.get(id_key) + + if not stored_code: + return False + + # 转为大写比较(大小写不敏感) + stored_code = str(stored_code).upper().strip() + user_input = str(user_input).upper().strip() + + # 验证通过后删除(一次性使用,防止重放攻击) + if stored_code == user_input: + redis.delete(id_key) + return True + + return False diff --git a/src/common/utils/crypto.py b/src/common/utils/crypto.py new file mode 100644 index 0000000..34d157f --- /dev/null +++ b/src/common/utils/crypto.py @@ -0,0 +1,135 @@ +# +====================================================================== +# | 模块: 密码加密工具层 +# | 说明: 基于 core.password 的 bcrypt 双重加盐方案,兼容旧无盐 bcrypt 数据 +# +====================================================================== +"""密码加密工具:基于 core.password 的 bcrypt 双重加盐方案,兼容旧的无盐 bcrypt 数据。 + +推荐使用盐方案(密码必须加盐): + salt, hashed = set_password("123456") # 生成盐 + 加密 + check_password("123456", salt, hashed) # 校验 + +旧数据(无盐 bcrypt)校验与自动升级见 verify_user_password。 +""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:bcrypt 加密库、核心密码工具的轮数/加密/盐生成与盐方案校验 +# ---------------------------------------------------------------------- +import bcrypt + +from core.password import BCRYPT_ROUNDS, encrypt_password, generate_salt +from core.password import verify_password as verify_salted + + +# ====================================================================== +# 盐方案(推荐) +# ====================================================================== + +# ============================================================ +# 设置密码 +# ============================================================ +def set_password(password: str) -> tuple: + """设置密码:生成随机盐并双重加盐加密,返回 (salt, hashed)。 + + :param password: 明文密码 + :return: 二元组 (盐, 哈希密文) + """ + salt = generate_salt() + return salt, encrypt_password(password, salt) + + +# ============================================================ +# 校验密码(盐方案) +# ============================================================ +def check_password(password: str, salt: str, hashed: str) -> bool: + """盐方案密码校验。 + + :param password: 明文密码 + :param salt: 加密时使用的盐 + :param hashed: 已存储的密码哈希 + :return: 校验通过返回 True,否则 False + """ + # 盐或密文缺失视为校验失败 + if not salt or not hashed: + return False + return verify_salted(password, salt, hashed) + + +# ====================================================================== +# 兼容旧数据(无盐 bcrypt) +# ====================================================================== + +# ============================================================ +# 无盐加密(兼容旧调用) +# ============================================================ +def hash_password(password: str) -> str: + """无盐 bcrypt 加密(兼容旧调用;新数据请使用 set_password)。 + + :param password: 明文密码 + :return: bcrypt 哈希字符串 + """ + return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt(rounds=BCRYPT_ROUNDS)).decode("utf-8") + + +# ============================================================ +# 无盐校验(兼容旧数据) +# ============================================================ +def verify_password(password: str, hashed: str) -> bool: + """无盐 bcrypt 校验(兼容旧数据)。 + + :param password: 明文密码 + :param hashed: 已存储的密码哈希 + :return: 校验通过返回 True,否则 False + """ + try: + return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8")) + except (ValueError, TypeError): + return False + + +# ============================================================ +# 判断是否为 bcrypt 密文 +# ============================================================ +def is_bcrypt(hashed: str) -> bool: + """判断密文是否为 bcrypt 格式。 + + :param hashed: 待判断的密文 + :return: 是 bcrypt 格式返回 True,否则 False + """ + return isinstance(hashed, str) and hashed.startswith("$2") + + +# ====================================================================== +# 统一校验 +# ====================================================================== + +# ============================================================ +# 统一密码校验 +# ============================================================ +def verify_user_password(password: str, salt: str, hashed: str) -> bool: + """统一密码校验:优先盐方案,回落无盐 bcrypt。 + + :param password: 明文密码 + :param salt: 盐(旧数据可能为空) + :param hashed: 已存储的密码哈希 + :return: 校验通过返回 True,否则 False + """ + # 有盐走盐方案 + if salt: + return check_password(password, salt, hashed) + # 无盐但为 bcrypt 密文,走兼容校验 + if is_bcrypt(hashed): + return verify_password(password, hashed) + return False + + +# ============================================================ +# 判断是否为旧无盐数据 +# ============================================================ +def is_legacy_hash(salt: str) -> bool: + """判断是否为旧的无盐数据(需要登录成功后自动升级)。 + + :param salt: 盐值 + :return: 无盐返回 True,否则 False + """ + return not salt diff --git a/src/common/utils/dict_util.py b/src/common/utils/dict_util.py new file mode 100644 index 0000000..158567a --- /dev/null +++ b/src/common/utils/dict_util.py @@ -0,0 +1,165 @@ +# +====================================================================== +# | 模块: 数据字典读取工具层 +# | 说明: 按字典编码读取字典项,供前端下拉与后端展示翻译复用, +# | 带 Redis 缓存与主动失效机制 +# +====================================================================== +"""数据字典读取工具:按字典编码读取字典项,供前端下拉与后端展示翻译复用。 + +取值链路: + 前端下拉 GET /dict/data/{code} → get_dict_items(code) → [{label, value, sort}] + 后端翻译 user service 等模块 → get_dict_map(code) → {value: label} + 单值翻译 → get_dict_label(code, value) + +缓存策略:Redis 缓存(多 worker 部署下各进程即时一致)。字典/字典项增删改时由 +modules/dict/service.py 主动失效,TTL 仅作兜底;Redis 异常时降级为实时查库。 +""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:JSON 序列化、日志、Redis 客户端、 +# 字典模型/仓储、字典项仓储 +# ---------------------------------------------------------------------- +import json +import logging + +from core.redis import redis +from modules.dictionary.dict.models import Dict +from modules.dictionary.dict.repository import DictRepository +from modules.dictionary.dict_item.repository import DictItemRepository + +# ============================================================ +# 仓储实例 +# ============================================================ +# 字典/字典项仓储实例(供缓存失效与读取复用) +dict_repo = DictRepository() +dictitem_repo = DictItemRepository() + +# 模块日志器 +logger = logging.getLogger(__name__) + +# ============================================================ +# 缓存配置 +# ============================================================ +# 字典缓存 TTL(秒):兜底过期时间,实际变更靠主动失效即时生效 +_DICT_CACHE_TTL = 60 +# 字典缓存 Redis key 前缀 +_DICT_CACHE_PREFIX = "dict:items:" + + +# ====================================================================== +# 构造缓存 key +# ====================================================================== +def _cache_key(code: str) -> str: + """构造字典缓存的 Redis key。 + + :param code: 字典编码 + :return: 缓存 key + """ + return f"{_DICT_CACHE_PREFIX}{code}" + + +# ====================================================================== +# 读取字典项列表 +# ====================================================================== +def get_dict_items(code): + """按字典编码读取字典项列表 [{label, value, sort}, ...],字典不存在返回空列表。 + + :param code: 字典编码 + :return: 字典项列表 + """ + # 优先读缓存 + try: + cached = redis.get(_cache_key(code)) + if cached is not None: + return json.loads(cached) + except Exception: + pass # 缓存读取异常降级为实时查库 + + # 缓存未命中:查库并回写缓存 + items = _load_dict_items(code) + + try: + redis.set(_cache_key(code), json.dumps(items), ex=_DICT_CACHE_TTL) + except Exception: + pass # 缓存写入失败仅损失缓存收益 + return items + + +# ====================================================================== +# 读取字典映射 +# ====================================================================== +def get_dict_map(code): + """按字典编码读取 {value: label} 映射(value 为字典项存储值,字符串类型)。 + + :param code: 字典编码 + :return: {value: label} 字典 + """ + return {item['value']: item['label'] for item in get_dict_items(code)} + + +# ====================================================================== +# 单值翻译 +# ====================================================================== +def get_dict_label(code, value, default=None): + """取单个字典项名称:value 自动转字符串匹配存储值,查不到返回 default。 + + :param code: 字典编码 + :param value: 字典项存储值 + :param default: 查不到时的返回值 + :return: 字典项名称或 default + """ + return get_dict_map(code).get(str(value), default) + + +# ====================================================================== +# 失效指定字典缓存 +# ====================================================================== +def invalidate_dict(code): + """清除指定字典编码的缓存(None/未缓存时安全无操作)。 + + :param code: 字典编码 + """ + if code: + try: + redis.delete(_cache_key(code)) + except Exception: + logger.warning("字典缓存失效失败(将按 TTL 自动过期) code=%s", code) + + +# ====================================================================== +# 按字典ID批量失效缓存 +# ====================================================================== +def invalidate_dicts_by_ids(dict_ids): + """按字典ID批量清除缓存:字典项变更后据 dict_id 反查 code 再失效。 + + 失效失败仅降级为按 TTL 过期(不阻断业务),避免无请求上下文/DB 异常时抛错。 + + :param dict_ids: 字典 ID 集合 + """ + if not dict_ids: + return + try: + # 据 ID 反查字典编码,逐个失效 + rows = dict_repo.filter(Dict.id.in_(dict_ids), Dict.is_delete == 0).all() + for row in rows: + invalidate_dict(row.code) + except Exception as e: + logger.warning("字典缓存失效失败(将按 TTL 自动过期): %s", e) + + +# ====================================================================== +# 从数据库加载字典项 +# ====================================================================== +def _load_dict_items(code): + """从数据库读取字典项(未删除、按 sort+id 升序),字典不存在返回空列表。 + + :param code: 字典编码 + :return: 字典项列表 + """ + dict_obj = dict_repo.get_one(code=code) + if not dict_obj: + return [] + rows = dictitem_repo.filter_by(dict_id=dict_obj.id).all() + # 按 sort、id 升序排列 + rows.sort(key=lambda item: (item.sort, item.id)) + return [{'label': item.name, 'value': item.value, 'sort': item.sort} for item in rows] diff --git a/src/common/utils/file.py b/src/common/utils/file.py new file mode 100644 index 0000000..7e9bd5d --- /dev/null +++ b/src/common/utils/file.py @@ -0,0 +1,452 @@ +# +====================================================================== +# | 模块: 文件目录操作工具层 +# | 说明: 提供文件魔术字节校验、目录创建、常用文件操作工具类与便捷函数 +# +====================================================================== +""" +文件目录操作工具模块 +============================================================= + +该模块提供文件和目录操作的常用工具函数。 +主要用于处理文件上传、目录创建、路径管理等场景,确保文件系统操作的 +安全性和可靠性。包含目录创建、文件操作等基础功能。 + +主要功能: +1. 递归创建目录(mkdir)- 确保目标目录存在 +2. 文件路径处理 +3. 目录存在性检查 + +所属包: common.utils +所在文件: common/utils/file.py +依赖模块: os - 操作系统接口模块 +""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:通配符匹配、日志、操作系统接口 +# ---------------------------------------------------------------------- +import glob +import logging +import os + +# 模块日志器 +logger = logging.getLogger(__name__) + +# ============================================================ +# 文件魔术字节签名表 +# ============================================================ +# 常见文件类型的魔术字节签名 +# 格式:扩展名 -> [签名列表],签名为 (offset, bytes) 元组 +MAGIC_SIGNATURES = { + # 图片 + '.jpg': [(0, b'\xff\xd8\xff')], + '.jpeg': [(0, b'\xff\xd8\xff')], + '.png': [(0, b'\x89PNG\r\n\x1a\n')], + '.gif': [(0, b'GIF87a'), (0, b'GIF89a')], + '.bmp': [(0, b'BM')], + '.ico': [(0, b'\x00\x00\x01\x00'), (0, b'\x00\x00\x02\x00')], + # 文档 + '.pdf': [(0, b'%PDF')], + # Office 2007+ (.docx/.xlsx/.pptx) 为 zip 容器,头部均为 PK\x03\x04 + '.docx': [(0, b'PK\x03\x04')], + '.xlsx': [(0, b'PK\x03\x04')], + '.pptx': [(0, b'PK\x03\x04')], + # Office 97-2003 二进制 (.doc/.xls/.ppt) 为 OLE2 复合文档,共享同一签名 + '.doc': [(0, b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1')], + '.xls': [(0, b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1')], + '.ppt': [(0, b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1')], + '.zip': [(0, b'PK\x03\x04')], + '.rar': [(0, b'Rar!\x1a\x07')], + '.7z': [(0, b'7z\xbc\xaf\x27\x1c')], + '.mp3': [(0, b'\xff\xfb'), (0, b'\xff\xf3'), (0, b'\xff\xf2'), (0, b'ID3')], + '.mp4': [(4, b'ftyp')], + # SVG 是文本格式,无固定二进制签名 +} + + +# ====================================================================== +# 校验文本内容 +# ====================================================================== +def _verify_text_content(file_stream): + """校验 .txt 内容为可读文本:文本格式无固定二进制签名,改为检测明显二进制内容。 + + 含 NUL 字节即视为二进制伪装(UTF-16 等宽文本也含 NUL,属有意收紧),拒绝上传。 + + :param file_stream: 文件流对象(支持 seek/read) + :return: (是否通过, 提示信息) + """ + try: + header = file_stream.read(64) + file_stream.seek(0) + except Exception as e: + logger.error(f'文本内容校验异常: {e}') + # fail-closed:读取异常时拒绝上传,避免伪装文件绕过校验 + return False, f'文件内容校验异常: {str(e)}' + # 含 NUL 字节视为二进制伪装 + if b'\x00' in header: + return False, '文件内容与扩展名 .txt 不匹配(疑似二进制文件)' + return True, '' + + +# ====================================================================== +# 校验文件魔术字节 +# ====================================================================== +def verify_file_magic(file_stream, ext_with_dot): + """ + 校验文件魔术字节,验证文件实际内容与扩展名是否一致 + + Args: + file_stream: 文件流对象,需支持seek/read + ext_with_dot: 带点的扩展名,如 '.jpg' + + Returns: + tuple: (is_valid, message) + - is_valid (bool): 是否通过校验 + - message (str): 提示信息 + """ + # .txt 无固定二进制签名,单独做内容可读性校验 + if ext_with_dot == '.txt': + return _verify_text_content(file_stream) + + signatures = MAGIC_SIGNATURES.get(ext_with_dot) + if not signatures: + # 无签名规则的文件类型,跳过魔术字节校验 + return True, '' + + try: + # 读取文件头部字节用于校验 + header = file_stream.read(32) + file_stream.seek(0) + + if not header: + return False, '文件内容为空' + + # 任一签名匹配即通过 + for sig in signatures: + offset, magic = sig + if header[offset:offset + len(magic)] == magic: + return True, '' + + return False, f'文件内容与扩展名 {ext_with_dot} 不匹配' + + except Exception as e: + logger.error(f'魔术字节校验异常: {e}') + # fail-closed:读取/校验异常时拒绝上传,避免伪装文件绕过魔数校验 + return False, f'文件内容校验异常: {str(e)}' + + +# ====================================================================== +# 递归创建目录 +# ====================================================================== +def mkdir(path): + """ + 递归创建目录 + ============================================================= + + 检查指定路径的目录是否存在,如果不存在则递归创建所有必需的目录。 + 用于确保文件上传等操作前目标目录已存在。 + + 参数: + path (str): 需要创建/检查的目录路径 + + 返回: + bool: True - 目录创建成功或已存在 + False - 目录创建失败(保留扩展用) + + 使用示例: + >>> # 确保上传目录存在 + >>> upload_dir = '/data/uploads/2024/01' + >>> mkdir(upload_dir) + /data/uploads/2024/01 创建成功 + True + + 注意事项: + 1. 函数会自动创建多级不存在的目录 + 2. 如果目录已存在,不会重复创建 + 3. 需要有相应的文件系统权限 + """ + + # 去除首位空格 + # 说明:消除用户输入中可能包含的前后空格,避免路径错误 + path = path.strip() + + # 去除尾部 \ 符号 + # 说明:Windows系统路径可能以\结尾,去除后保持路径格式统一 + path = path.rstrip("\\") + + # 判断路径是否存在 + # 函数:os.path.exists(path) + # 说明:检查指定路径的文件或目录是否存在 + isExists = os.path.exists(path) + + if not isExists: + # 如果不存在则创建目录 + # 函数:os.makedirs(path) + # 说明:递归创建目录,如果中间目录不存在也会一并创建 + # 例如:创建 /a/b/c 时,如果 /a 和 /a/b 都不存在,会一并创建 + os.makedirs(path) + logger.debug(f'{path} 创建成功') + return True + else: + logger.debug(f'{path} 目录已存在') + return False + + +# ====================================================================== +# 文件操作工具类 +# ====================================================================== +class FileUtils: + """ + 文件操作工具类 + ============================================================= + + 提供更多常用的文件和目录操作功能,封装为类方法便于调用。 + 包含文件读写、路径处理、文件信息获取等实用功能。 + + 使用示例: + >>> # 获取文件大小 + >>> size = FileUtils.get_file_size('/path/file.txt') + >>> print(f"{size} bytes") + + >>> # 安全删除文件 + >>> if FileUtils.safe_delete('/path/file.txt'): + ... print("文件删除成功") + """ + + # ============================================================ + # 获取文件大小 + # ============================================================ + @staticmethod + def get_file_size(file_path): + """ + 获取文件大小 + ============================================================= + + 参数: + file_path (str): 文件路径 + + 返回: + int: 文件大小(字节),如果文件不存在返回0 + """ + if os.path.exists(file_path) and os.path.isfile(file_path): + return os.path.getsize(file_path) + return 0 + + # ============================================================ + # 获取文件扩展名 + # ============================================================ + @staticmethod + def get_file_extension(filename): + """ + 获取文件扩展名 + ============================================================= + + 参数: + filename (str): 文件名或文件路径 + + 返回: + str: 文件扩展名(小写),例如:'.jpg', '.txt' + """ + return os.path.splitext(filename)[1].lower() + + # ============================================================ + # 获取不带扩展名的文件名 + # ============================================================ + @staticmethod + def get_file_name_without_ext(filename): + """ + 获取不带扩展名的文件名 + ============================================================= + + 参数: + filename (str): 文件名或文件路径 + + 返回: + str: 不带扩展名的文件名 + """ + return os.path.splitext(os.path.basename(filename))[0] + + # ============================================================ + # 安全删除文件 + # ============================================================ + @staticmethod + def safe_delete(file_path): + """ + 安全删除文件 + ============================================================= + + 参数: + file_path (str): 文件路径 + + 返回: + bool: True - 删除成功或文件不存在 + False - 删除失败(如权限不足) + """ + try: + if os.path.exists(file_path) and os.path.isfile(file_path): + os.remove(file_path) + return True + except Exception as e: + logger.warning(f"文件删除失败: {e}") + return False + + # ============================================================ + # 读取文件内容 + # ============================================================ + @staticmethod + def read_file(file_path, encoding='utf-8'): + """ + 读取文件内容 + ============================================================= + + 参数: + file_path (str): 文件路径 + encoding (str): 文件编码,默认utf-8 + + 返回: + str/None: 文件内容字符串,读取失败返回None + """ + try: + with open(file_path, 'r', encoding=encoding) as f: + return f.read() + except Exception as e: + logger.warning(f"文件读取失败: {e}") + return None + + # ============================================================ + # 写入文件内容 + # ============================================================ + @staticmethod + def write_file(file_path, content, encoding='utf-8', mode='w'): + """ + 写入文件内容 + ============================================================= + + 参数: + file_path (str): 文件路径 + content (str): 要写入的内容 + encoding (str): 文件编码,默认utf-8 + mode (str): 写入模式,'w'覆盖写入,'a'追加写入 + + 返回: + bool: True - 写入成功,False - 写入失败 + """ + try: + # 确保目录存在 + directory = os.path.dirname(file_path) + if directory and not os.path.exists(directory): + os.makedirs(directory) + + with open(file_path, mode, encoding=encoding) as f: + f.write(content) + return True + except Exception as e: + logger.warning(f"文件写入失败: {e}") + return False + + # ============================================================ + # 列出目录下的所有文件 + # ============================================================ + @staticmethod + def list_files(directory, pattern=None): + """ + 列出目录下的所有文件 + ============================================================= + + 参数: + directory (str): 目录路径 + pattern (str): 文件匹配模式,如 '*.txt',None表示所有文件 + + 返回: + list: 文件名列表 + """ + if not os.path.exists(directory): + return [] + + if pattern: + search_path = os.path.join(directory, pattern) + return [os.path.basename(f) for f in glob.glob(search_path)] + else: + return [f for f in os.listdir(directory) + if os.path.isfile(os.path.join(directory, f))] + + # ============================================================ + # 获取文件详细信息 + # ============================================================ + @staticmethod + def get_file_info(file_path): + """ + 获取文件详细信息 + ============================================================= + + 参数: + file_path (str): 文件路径 + + 返回: + dict: 包含文件信息的字典,文件不存在返回None + """ + if not os.path.exists(file_path) or not os.path.isfile(file_path): + return None + + stat = os.stat(file_path) + return { + 'name': os.path.basename(file_path), + 'path': os.path.abspath(file_path), + 'size': stat.st_size, + 'size_format': FileUtils._format_size(stat.st_size), + 'extension': FileUtils.get_file_extension(file_path), + 'created_time': stat.st_ctime, + 'modified_time': stat.st_mtime, + 'is_hidden': os.path.basename(file_path).startswith('.') + } + + # ============================================================ + # 格式化文件大小 + # ============================================================ + @staticmethod + def _format_size(size_bytes): + """ + 格式化文件大小显示 + ============================================================= + + 参数: + size_bytes (int): 文件大小(字节) + + 返回: + str: 格式化后的大小字符串,如 '1.5 MB' + """ + if size_bytes < 1024: + return f"{size_bytes} B" + elif size_bytes < 1024 * 1024: + return f"{size_bytes / 1024:.2f} KB" + elif size_bytes < 1024 * 1024 * 1024: + return f"{size_bytes / (1024 * 1024):.2f} MB" + else: + return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB" + + +# ====================================================================== +# 便捷函数封装 +# ====================================================================== + +# ============================================================ +# 获取文件大小 +# ============================================================ +def get_file_size(file_path): + """获取文件大小(字节)的便捷函数""" + return FileUtils.get_file_size(file_path) + + +# ============================================================ +# 安全删除文件 +# ============================================================ +def safe_delete(file_path): + """安全删除文件的便捷函数""" + return FileUtils.safe_delete(file_path) + + +# ============================================================ +# 获取文件扩展名 +# ============================================================ +def get_file_extension(filename): + """获取文件扩展名的便捷函数""" + return FileUtils.get_file_extension(filename) diff --git a/src/common/utils/fonts/Vera.ttf b/src/common/utils/fonts/Vera.ttf new file mode 100644 index 0000000..58cd6b5 Binary files /dev/null and b/src/common/utils/fonts/Vera.ttf differ diff --git a/src/common/utils/helpers.py b/src/common/utils/helpers.py new file mode 100644 index 0000000..db2517b --- /dev/null +++ b/src/common/utils/helpers.py @@ -0,0 +1,134 @@ +# +====================================================================== +# | 模块: 辅助函数层 +# | 说明: 提供命名转换、ID 解析、User-Agent 解析、参数脱敏等通用工具 +# +====================================================================== +"""辅助函数。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:JSON 序列化、类型标注 +# ---------------------------------------------------------------------- +import json +from typing import List + + +# ====================================================================== +# snake_case → camelCase +# ====================================================================== +def snake_to_camel(name: str) -> str: + """snake_case → camelCase,如 create_user → createUser。 + + :param name: 下划线命名的字段名 + :return: 驼峰命名的字段名 + """ + parts = name.split('_') + return parts[0] + ''.join(word.capitalize() for word in parts[1:]) + + +# ====================================================================== +# 解析逗号分隔的 ID 字符串 +# ====================================================================== +def parse_id_list(ids_str) -> List[int]: + """解析逗号分隔的 ID 字符串为合法 ID 列表(忽略空值与非数字)。 + + :param ids_str: 逗号分隔的 ID 字符串 + :return: 合法(正整数)ID 列表 + """ + result = [] + for part in str(ids_str).split(","): + try: + i = int(part) + except (TypeError, ValueError): + continue + if i > 0: + result.append(i) + return result + + +# ====================================================================== +# User-Agent 解析(操作系统 / 浏览器 / 来源) +# ====================================================================== + +# ============================================================ +# 识别操作系统 +# ============================================================ +def detect_os(ua: str) -> str: + """从 User-Agent 识别操作系统。 + + :param ua: User-Agent 字符串 + :return: 操作系统名称,识别不出返回"未知" + """ + if not ua: + return "未知" + # 依次匹配常见系统标识 + for key in ("Windows", "Mac OS", "Android", "iPhone", "iPad", "Linux"): + if key in ua: + return "iOS" if key in ("iPhone", "iPad") else key + return "未知" + + +# ============================================================ +# 识别浏览器 +# ============================================================ +def detect_browser(ua: str) -> str: + """从 User-Agent 识别浏览器。 + + :param ua: User-Agent 字符串 + :return: 浏览器名称,识别不出返回"未知" + """ + if not ua: + return "未知" + # 依次匹配常见浏览器标识 + for name, key in (("Edge", "Edg/"), ("Firefox", "Firefox"), ("Chrome", "Chrome"), + ("Safari", "Safari"), ("IE", "MSIE")): + if key in ua: + return name + return "未知" + + +# ============================================================ +# 识别请求来源 +# ============================================================ +def detect_source(ua: str) -> int: + """请求来源:1-PC端 2-移动端。 + + :param ua: User-Agent 字符串 + :return: 1-PC端 / 2-移动端 + """ + return 2 if any(k in ua for k in ("Android", "iPhone", "iPad", "Mobile")) else 1 + + +# ====================================================================== +# 参数脱敏 +# ====================================================================== + +# ============================================================ +# 敏感字段 +# ============================================================ +# 敏感字段(参数脱敏) +_SENSITIVE_KEYS = ("password", "pwd", "salt", "oldPassword", "newPassword", "rePassword", "token") + + +# ====================================================================== +# 脱敏请求参数 +# ====================================================================== +def redact_params(raw: str, max_len: int = 10000) -> str: + """对请求参数中的敏感字段做脱敏(密码等置为 ***)。空请求体返回空串。 + + :param raw: 原始请求体字符串 + :param max_len: 返回结果最大长度 + :return: 脱敏后的 JSON 字符串(非 JSON 时截断返回原文) + """ + if not raw: + return "" + try: + data = json.loads(raw) if raw else {} + # 遍历字段,命中敏感字段则替换为 *** + if isinstance(data, dict): + for key in list(data.keys()): + if any(s in key.lower() for s in _SENSITIVE_KEYS): + data[key] = "***" + return json.dumps(data, ensure_ascii=False)[:max_len] + except Exception: + # 非 JSON 内容:直接截断返回,避免解析异常中断日志记录 + return raw[:max_len] diff --git a/src/common/utils/ip.py b/src/common/utils/ip.py new file mode 100644 index 0000000..2469ff9 --- /dev/null +++ b/src/common/utils/ip.py @@ -0,0 +1,91 @@ +# +====================================================================== +# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] +# +---------------------------------------------------------------------- +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 +# +---------------------------------------------------------------------- +# | 开源协议: Apache-2.0 +# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 +# +---------------------------------------------------------------------- +# | 官方网站: https://www.djangoadmin.cn +# +---------------------------------------------------------------------- +# | 研发团队: 小蚂蚁云团队 荣誉出品 +# +====================================================================== +# | 版权和免责声明: +# | 一、知识产权声明 +# | 本团队对该软件框架产品(以下简称"本产品")拥有完整知识产权,包括但不限于商标权、专利权、 +# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, +# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 +# | 二、使用限制与禁止条款 +# | 您理解并同意,禁止将本产品用于以下任何行为: +# | 1. 任何违反中华人民共和国法律法规的用途; +# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; +# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 +# | 三、责任豁免 +# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 +# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 +# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 +# | 四、违约与法律责任 +# | 如发现任何违反本声明的行为,本团队有权: +# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; +# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; +# | 3. 保留进一步采取刑事控告等法律措施的权利。 +# | 五、许可范围说明 +# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 +# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 +# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 +# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 +# | 成的一切经济损失及商誉损害。 +# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 +# | 许可协议》附件为准。 +# +====================================================================== + +# +====================================================================== +# | 模块: IP地址工具 +# | 说明: 从 Tornado RequestHandler 获取客户端真实IP,支持代理透传 +# +====================================================================== + +""" +IP地址工具模块 +============================================================= + +提供客户端IP获取: +- get_client_ip: 从 Tornado RequestHandler 获取客户端真实IP + +(IP地理位置查询已迁移至 utils/ip2region 离线库) +""" + +from typing import Optional + +import tornado.web + + +# ============================================================ +# 获取客户端IP +# ============================================================ +def get_client_ip(handler: tornado.web.RequestHandler) -> str: + """ + 获取客户端真实IP地址(Tornado 版本) + + 优先级: + 1. X-Forwarded-For 最左侧IP(CDN/反代追加的真实客户端IP) + 2. X-Real-IP(Nginx proxy_set_header 常用方案) + 3. Tornado remote_ip(直连时的 socket 对端IP) + + Args: + handler: Tornado 请求处理器 + + Returns: + 客户端IP地址 + """ + # 优先从 X-Forwarded-For 取最左侧IP(由CDN/反代追加的真实客户端IP,右侧为代理节点IP) + forwarded = handler.request.headers.get("X-Forwarded-For", "") + if forwarded: + return forwarded.split(",")[0].strip() + + # 其次从 X-Real-IP 获取(Nginx proxy_set_header X-Real-IP $remote_addr) + real_ip = handler.request.headers.get("X-Real-IP", "") + if real_ip: + return real_ip.strip() + + # 最后回退到 Tornado remote_ip(直连场景) + return handler.request.remote_ip or "127.0.0.1" diff --git a/src/common/utils/ip2region.py b/src/common/utils/ip2region.py new file mode 100644 index 0000000..3a5e48f --- /dev/null +++ b/src/common/utils/ip2region.py @@ -0,0 +1,246 @@ +# +====================================================================== +# | 模块: IP 地理位置查询工具层 +# | 说明: 基于 ip2region 离线数据库提供高性能 IP 地理位置查询, +# | 支持完整区域信息、地理文本及各层级字段获取 +# +====================================================================== +""" +IP地理位置查询模块(基于ip2region离线数据库) +============================================================= + +基于 ip2region.xdb 离线数据库提供高性能IP地理位置查询,无需网络请求。 + +核心功能: +- get_ip_region: 根据IP获取完整区域信息字典 +- get_ip_location: 根据IP获取地理位置文本(兼容现有调用) +- get_ip_country / get_ip_province / get_ip_city / get_ip_isp: 按层级获取 + +xdb文件位于 src/common/utils/ip2region.xdb,启动时全量加载到内存,查询零IO延迟。 +""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:日志、操作系统接口、ip2region 搜索器与工具 +# ---------------------------------------------------------------------- +import logging +import os + +from ip2region import searcher, util + +# 模块日志器 +logger = logging.getLogger(__name__) + +# ============================================================ +# 数据库路径与单例 +# ============================================================ +# xdb数据库文件路径(与本模块同目录) +_XDB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ip2region.xdb") + +# 模块级单例:启动时加载xdb到内存,后续查询零IO +_searcher = None + + +# ====================================================================== +# 获取搜索器单例 +# ====================================================================== +def _get_searcher(): + """获取搜索器单例(懒加载,首次调用时初始化)""" + global _searcher + # 已初始化则直接复用 + if _searcher is not None: + return _searcher + + # 数据库文件缺失:记录错误并返回 None + if not os.path.exists(_XDB_PATH): + logger.error(f"ip2region数据库文件不存在: {_XDB_PATH}") + return None + + try: + # 全量加载 xdb 到内存,实现零 IO 查询 + c_buff = util.load_content_from_file(_XDB_PATH) + _searcher = searcher.new_with_buffer(util.IPv4, c_buff) + logger.debug(f"ip2region数据库加载成功: {_XDB_PATH}") + except Exception as e: + logger.error(f"ip2region数据库加载失败: {e}") + return None + + return _searcher + + +# ====================================================================== +# 解析原始区域字符串 +# ====================================================================== +def _parse_region(region_str): + """ + 解析ip2region原始结果字符串 + + 原始格式: "国家|省份|城市|ISP|国家代码" + 示例: "中国|浙江省|杭州市|阿里|CN", "United States|California|0|Google LLC|US" + + Args: + region_str: ip2region返回的原始字符串 + + Returns: + dict: 各层级区域信息 + """ + parts = region_str.split("|") if region_str else [] + # 补齐到5段 + while len(parts) < 5: + parts.append("0") + + country = parts[0] + province = parts[1] + city = parts[2] + isp = parts[3] + country_code = parts[4] + + # 清理占位符 "0" → 空字符串 + province = "" if province == "0" else province + city = "" if city == "0" else city + isp = "" if isp == "0" else isp + country_code = "" if country_code == "0" else country_code + + return { + "country": country, + "province": province, + "city": city, + "isp": isp, + "country_code": country_code, + } + + +# ====================================================================== +# 判断是否为内网 / 保留 IP +# ====================================================================== +def _is_private_ip(ip): + """判断是否为内网/保留IP""" + # 空 IP 视为内网处理 + if not ip: + return True + ip = ip.strip() + # 回环与本机地址 + if ip in ("127.0.0.1", "localhost", "0.0.0.0"): + return True + # 10.x 与 192.168.x 私网段 + if ip.startswith("10.") or ip.startswith("192.168."): + return True + # 172.16.x ~ 172.31.x 私网段 + if ip.startswith("172."): + parts = ip.split(".") + if len(parts) >= 2: + second = int(parts[1]) + if 16 <= second <= 31: + return True + return False + + +# ====================================================================== +# 启动预热 +# ====================================================================== +def preload() -> bool: + """启动时预热:加载 xdb 到内存,避免首个日志落库时在事件循环上阻塞。 + + :return: 加载成功返回 True,否则 False + """ + return _get_searcher() is not None + + +# ====================================================================== +# 获取完整区域信息 +# ====================================================================== +def get_ip_region(ip): + """ + 根据IP获取完整区域信息字典 + + Args: + ip: IP地址字符串 + + Returns: + dict: 区域信息,包含 country/province/city/isp/country_code 字段。 + 内网IP返回 {"country": "内网IP", ...},查询失败返回 {"country": "未知", ...} + """ + # 内网 IP 直接返回标记 + if _is_private_ip(ip): + return {"country": "内网IP", "province": "", "city": "", "isp": "", "country_code": ""} + + s = _get_searcher() + # 搜索器不可用(库缺失/加载失败) + if s is None: + return {"country": "未知", "province": "", "city": "", "isp": "", "country_code": ""} + + try: + region_str = s.search(ip) + if not region_str: + return {"country": "未知", "province": "", "city": "", "isp": "", "country_code": ""} + return _parse_region(region_str) + except Exception as e: + logger.warning(f"IP地理位置查询失败 [{ip}]: {e}") + return {"country": "未知", "province": "", "city": "", "isp": "", "country_code": ""} + + +# ====================================================================== +# 获取地理位置文本 +# ====================================================================== +def get_ip_location(ip): + """ + 根据IP获取地理位置文本描述 + + 格式: "国家 省份 城市"(与原 utils/ip.py 的 get_ip_location 返回格式兼容) + 内网IP返回 "内网IP",查询失败返回 "未知" + + Args: + ip: IP地址字符串 + + Returns: + str: 地理位置文本 + """ + region = get_ip_region(ip) + + # 内网 IP 或未知:直接返回对应文案 + if region["country"] in ("内网IP", "未知"): + return region["country"] + + parts = [region["country"]] + # 追加省份 + if region["province"]: + parts.append(region["province"]) + # 追加城市(避免与省份重复展示) + if region["city"] and region["city"] != region["province"]: + parts.append(region["city"]) + + return " ".join(parts) + + +# ====================================================================== +# 按层级获取字段 +# ====================================================================== + +# ============================================================ +# 获取国家 +# ============================================================ +def get_ip_country(ip): + """根据IP获取国家""" + return get_ip_region(ip)["country"] + + +# ============================================================ +# 获取省份 +# ============================================================ +def get_ip_province(ip): + """根据IP获取省份""" + return get_ip_region(ip)["province"] + + +# ============================================================ +# 获取城市 +# ============================================================ +def get_ip_city(ip): + """根据IP获取城市""" + return get_ip_region(ip)["city"] + + +# ============================================================ +# 获取运营商 +# ============================================================ +def get_ip_isp(ip): + """根据IP获取运营商""" + return get_ip_region(ip)["isp"] diff --git a/src/common/utils/ip2region.xdb b/src/common/utils/ip2region.xdb new file mode 100644 index 0000000..6b53b1f Binary files /dev/null and b/src/common/utils/ip2region.xdb differ diff --git a/src/common/utils/rich_text.py b/src/common/utils/rich_text.py new file mode 100644 index 0000000..6225d4e --- /dev/null +++ b/src/common/utils/rich_text.py @@ -0,0 +1,221 @@ +# +====================================================================== +# | 模块: 富文本与文件处理工具层 +# | 说明: 提供文件 URL 处理、临时文件迁移、富文本内容清洗与图片本地化 +# +====================================================================== +""" +富文本与文件处理模块 +============================================================= + +提供文件URL处理、临时文件迁移和富文本编辑器内容处理: +- get_file_url: 拼接文件域名生成完整URL +- save_file: 将临时目录文件迁移到正式存储目录(统一文件保存入口) +- save_content: 处理富文本HTML内容,提取并保存其中的图片文件(落盘前做 XSS 白名单清洗) + +配置来源:目录/域名均取自 config.app(.env 管理)。 +""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:HTML 转义、日志、路径/正则/文件操作、时间、 +# bleach 清洗器与 CSS 净化器、目录创建工具、应用配置 +# ---------------------------------------------------------------------- +import html +import logging +import os +import re +import shutil +import time + +from bleach.css_sanitizer import CSSSanitizer +from bleach.sanitizer import Cleaner + +from common.utils.file import mkdir +from config.app import TEMP_PATH, TORNADO_FILE_URL, UPLOAD_DIR + +# 模块日志器 +logger = logging.getLogger(__name__) + +# 文件域名配置(取自 config.app,.env 管理) +FILE_URL = TORNADO_FILE_URL + +# ============================================================ +# 富文本清洗白名单与清洗器 +# ============================================================ +# 富文本 HTML 白名单清洗:允许常见排版标签与安全内联样式,剥离 script 等危险标签、 +# on* 事件属性、javascript: 等危险协议与表达式类 CSS,防存储型 XSS(落盘前统一清洗) +_RICH_TEXT_TAGS = { + 'p', 'br', 'hr', 'strong', 'em', 'b', 'i', 'u', 's', 'small', 'sub', 'sup', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'ul', 'ol', 'li', 'blockquote', 'pre', 'code', + 'span', 'div', 'a', 'img', + 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', +} +_RICH_TEXT_ATTRS = { + '*': ['class', 'style'], + 'a': ['href', 'title', 'target', 'rel', 'name'], + 'img': ['src', 'alt', 'title', 'width', 'height'], + 'td': ['colspan', 'rowspan', 'width', 'height', 'align'], + 'th': ['colspan', 'rowspan', 'width', 'height', 'align'], + 'table': ['width', 'border', 'cellspacing', 'cellpadding', 'align'], + 'col': ['span', 'width'], +} +_RICH_TEXT_PROTOCOLS = {'http', 'https', 'mailto', 'tel'} +# 全局清洗器实例(白名单标签/属性/协议 + CSS 净化,strip=True 剥离未知标签) +_RICH_TEXT_CLEANER = Cleaner( + tags=_RICH_TEXT_TAGS, + attributes=_RICH_TEXT_ATTRS, + protocols=_RICH_TEXT_PROTOCOLS, + css_sanitizer=CSSSanitizer(), + strip=True, +) +# 兜底:style 值中残留的 IE 时代 CSS 表达式等向量(bleach 仅按属性白名单过滤) +_DANGEROUS_CSS_RE = re.compile(r'expression\s*\(|javascript\s*:', re.IGNORECASE) +_STYLE_ATTR_RE = re.compile(r'\sstyle="[^"]*"', re.IGNORECASE) + + +# ====================================================================== +# 剥离危险 style +# ====================================================================== +def _strip_dangerous_style(content): + """丢弃含危险 CSS 表达式的 style 属性(含则整体删除)。""" + + def _drop(match): + """命中危险 CSS 时整体删除该 style 属性,否则保留原样。""" + return '' if _DANGEROUS_CSS_RE.search(match.group(0)) else match.group(0) + + return _STYLE_ATTR_RE.sub(_drop, content) + + +# ====================================================================== +# 拼接完整文件访问 URL +# ====================================================================== +def get_file_url(path): + """ + 获取完整的文件访问URL + + Args: + path: 文件的相对路径 + + Returns: + 完整的文件访问URL,path 为空返回空字符串 + """ + if not path: + return "" + # 已是完整地址:原样返回 + if FILE_URL in path: + return path + return FILE_URL + path + + +# ====================================================================== +# URL/路径 还原为磁盘路径 +# ====================================================================== +def _url_to_disk_path(url: str) -> str: + """将文件访问URL/相对路径还原为磁盘绝对路径,并统一分隔符。 + + 仅支持本站文件(以 FILE_URL 开头,或 / 开头的相对路径); + 远程地址返回空字符串。 + """ + # 剥离本站域名前缀或取相对路径 + if FILE_URL and url.startswith(FILE_URL): + rel = url[len(FILE_URL):] + elif url.startswith('/'): + rel = url + else: + return "" + # 上传接口存储的路径以 / 分隔,Windows 下需转为 os.sep 才能与磁盘路径匹配 + disk = os.path.join(UPLOAD_DIR, rel.lstrip('/')).replace('/', os.sep) + # 防路径穿越:解析真实路径后必须仍位于 UPLOAD_DIR 内,否则拒绝 + base = os.path.realpath(UPLOAD_DIR) + if os.path.commonpath([os.path.realpath(disk), base]) != base: + logger.warning(f"非法文件路径,已拒绝: {url}") + return "" + return disk + + +# ====================================================================== +# 磁盘路径转为相对 URL 路径 +# ====================================================================== +def _disk_to_url_path(disk_path: str) -> str: + """将磁盘绝对路径转为相对URL路径(以 / 开头、/ 分隔)。""" + return '/' + disk_path[len(UPLOAD_DIR):].replace(os.sep, '/').lstrip('/') + + +# ====================================================================== +# 保存文件(统一入口) +# ====================================================================== +def save_file(url, directory): + """ + 保存文件到指定目录(统一文件保存入口) + + 处理本站临时文件迁移:将临时目录中的文件移动到正式存储目录。 + + Args: + url: 文件的完整URL或相对路径 + directory: 目标子目录名称,用于分类存储 + + Returns: + 文件的相对URL路径,失败时返回空字符串 + """ + if not url: + logger.warning("文件地址不能为空") + return "" + + disk_path = _url_to_disk_path(url) + # 非本站文件(远程地址)不支持迁移 + if not disk_path: + logger.warning(f"远程文件暂不支持保存: {url}") + return "" + + # 非临时目录文件(已在正式存储目录):直接返回相对URL路径 + # 注意:TEMP_PATH 用 "/" 拼接,需 normpath 统一分隔符后再比较,避免 Windows 下误判 + if not disk_path.startswith(os.path.normpath(TEMP_PATH) + os.sep): + return _disk_to_url_path(disk_path) + + # 临时文件迁移到正式存储目录:{temp}/{日期}/{文件名} -> {UPLOAD_DIR}/{分类}/{日期}/{文件名} + target_dir = os.path.join(UPLOAD_DIR, directory, time.strftime('%Y%m%d')) + mkdir(target_dir) + new_path = os.path.join(target_dir, os.path.basename(disk_path)) + shutil.move(disk_path, new_path) + return _disk_to_url_path(new_path) + + +# ====================================================================== +# 保存富文本内容 +# ====================================================================== +def save_content(content, title, directory): + """ + 保存富文本编辑器内容 + + 先对HTML做白名单清洗(防存储型XSS),再提取其中图片文件并保存, + 同时将文件alt属性设置为文章标题(已做HTML转义)。 + + Args: + content: 富文本HTML内容 + title: 文章标题,用于设置文件alt属性 + directory: 文件存储的子目录名称 + + Returns: + 处理后的富文本内容,文件地址已被替换 + """ + if not content: + return content + + # 白名单清洗:剥离 script/事件属性/javascript: 协议等 XSS 向量 + content = _RICH_TEXT_CLEANER.clean(content) + # 兜底:剥离 style 中遗留的 CSS 表达式等危险向量 + content = _strip_dangerous_style(content) + + # 提取并迁移内容中的本地图片 + image_urls = re.findall('img src="(.*?)"', content, re.S) + for url in image_urls: + image = save_file(url, directory) + if image: + content = content.replace(url, "[IMG_URL]" + image) + + # 设置图片 alt 属性为文章标题(已 HTML 转义) + title = html.escape(str(title) if title else "", quote=True) + if "alt=\"\"" in content and title: + content = content.replace("alt=\"\"", "alt=\"" + title + "\"") + + return content diff --git a/src/common/utils/validators.py b/src/common/utils/validators.py new file mode 100644 index 0000000..6f7d034 --- /dev/null +++ b/src/common/utils/validators.py @@ -0,0 +1,28 @@ +# +====================================================================== +# | 模块: 数据校验辅助层 +# | 说明: 提供 Pydantic 校验错误到可读文案的转换辅助 +# +====================================================================== +"""数据校验辅助。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:类型标注 +# ---------------------------------------------------------------------- +from typing import Any + + +# ====================================================================== +# 转换校验错误为可读文案 +# ====================================================================== +def get_err(exc: Any) -> str: + """将 Pydantic ValidationError 转换为可读的错误描述。 + + :param exc: Pydantic 校验异常对象 + :return: 拼接后的错误文案,无有效信息时返回"参数校验失败" + """ + # 提取全部错误项的消息 + errors = getattr(exc, "errors", lambda: [])() + error_list = [err.get("msg", "") for err in errors] + # 过滤空值后以 / 连接 + err_str = "/".join(filter(None, error_list)) + return err_str or "参数校验失败" diff --git a/src/config/__init__.py b/src/config/__init__.py new file mode 100644 index 0000000..a780bfa --- /dev/null +++ b/src/config/__init__.py @@ -0,0 +1,32 @@ +# +====================================================================== +# | 模块: 配置层包 +# | 说明: 声明 config 为包,统一加载 .env 并组织各配置子模块 +# +====================================================================== +"""配置层。 + +子模块: +- app.py — 应用基础信息、路径、CORS、上传、日志 +- auth.py — JWT 与登录安全 +- captcha.py — 验证码配置 +- database.py — 数据库配置 +- email.py — 邮件配置 +- redis.py — Redis 缓存配置 +- logging.py — 日志配置 + +提示:开发者可根据项目需要,自行在此包内添加所需内容。 +""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:路径处理、.env 文件加载 +# ---------------------------------------------------------------------- +from pathlib import Path + +from dotenv import load_dotenv + +# ============================================================ +# 加载 .env 文件 +# ============================================================ +# 在包初始化时加载 .env 文件,确保所有子模块的 os.getenv() 能读到 .env 中的值 +_ROOT = Path(__file__).resolve().parent.parent.parent +load_dotenv(_ROOT / ".env", encoding="utf-8") diff --git a/src/config/app.py b/src/config/app.py new file mode 100644 index 0000000..a0e0579 --- /dev/null +++ b/src/config/app.py @@ -0,0 +1,84 @@ +# +====================================================================== +# | 模块: 应用基础配置层 +# | 说明: 定义应用名称、版本、运行地址、端口、环境、调试/演示模式、 +# | 上传目录、图片域名、CORS 白名单、反向代理信任等配置项 +# +====================================================================== +"""应用基础配置。 + +包含应用名称、版本、运行地址、端口、环境、调试模式、演示模式、 +上传目录、图片域名、CORS 白名单、反向代理信任等。 +""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:环境变量读取、路径处理 +# ---------------------------------------------------------------------- +import os +from pathlib import Path + +# ============================================================ +# 应用基本信息 +# ============================================================ +# 应用名称 +TORNADO_NAME = os.getenv('TORNADO_NAME', 'Tornado+AntdVue旗舰版') +# 应用版本 +TORNADO_VERSION = os.getenv('TORNADO_VERSION', 'v3.0.0') +# 应用运行地址 +TORNADO_HOST = os.getenv('TORNADO_HOST', '127.0.0.1') +# 应用运行端口 +TORNADO_PORT = int(os.getenv('TORNADO_PORT', '8041')) +# 应用启动文件 +TORNADO_APP = os.getenv('TORNADO_APP', 'app.py') +# 应用环境变量 +TORNADO_ENV = os.getenv('TORNADO_ENV', 'development') +# 是否调试模式 +TORNADO_DEBUG = os.getenv('TORNADO_DEBUG', 'True').lower() == 'true' +# 是否演示模式 +TORNADO_DEMO = os.getenv('TORNADO_DEMO', 'False').lower() == 'true' + +# ============================================================ +# 应用路径 +# ============================================================ +# 应用根目录(src 的上上级) +ROOT_PATH = Path(__file__).resolve().parent.parent.parent +# 应用文件上传路径(留空使用项目内 uploads 目录) +TORNADO_UPLOAD_DIR = os.getenv('TORNADO_UPLOAD_DIR', '') +# 应用文件域名(文件回显地址前缀) +TORNADO_FILE_URL = os.getenv('TORNADO_FILE_URL', '') + +# ============================================================ +# CORS 与代理配置 +# ============================================================ +# 允许的跨域来源白名单(逗号分隔;留空则允许所有来源且不携带凭据) +TORNADO_CORS_ORIGINS = os.getenv('TORNADO_CORS_ORIGINS', '') +# 是否部署于可信反向代理之后:为 True 时信任 X-Forwarded-For 取客户端真实 IP; +# 为 False(默认)时忽略 XFF,防止攻击者伪造 XFF 绕过验证码限流/登录防爆破 +TORNADO_TRUST_PROXY = os.getenv('TORNADO_TRUST_PROXY', 'False').lower() == 'true' + +# ============================================================ +# 上传配置 +# ============================================================ +# 允许上传的文件扩展名 - 逗号分隔,带点号 +# 生产环境务必配置,防止上传 .py/.exe/.sh 等危险文件 +UPLOAD_ALLOWED_EXTS = os.getenv( + 'UPLOAD_ALLOWED_EXTS', + '.jpg,.jpeg,.png,.gif,.bmp,.ico,.txt,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.mp3,.mp4,.zip,.rar,.7z' +) +# 上传文件大小上限(MB)- 超过此大小将被拒绝上传 +UPLOAD_MAX_SIZE_MB = int(os.getenv('UPLOAD_MAX_SIZE_MB', '10')) + +# ============================================================ +# 日志配置 +# ============================================================ +# 日志级别 +LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO') +# 日志目录 +LOG_DIR = os.getenv('LOG_DIR', 'logs') + +# ============================================================ +# 路径计算 +# ============================================================ +# 上传根目录 +UPLOAD_DIR = TORNADO_UPLOAD_DIR or str(ROOT_PATH / 'uploads') +# 临时文件目录 +TEMP_PATH = f"{UPLOAD_DIR}/temp" \ No newline at end of file diff --git a/src/config/auth.py b/src/config/auth.py new file mode 100644 index 0000000..c07c72b --- /dev/null +++ b/src/config/auth.py @@ -0,0 +1,44 @@ +# +====================================================================== +# | 模块: JWT 与登录安全配置层 +# | 说明: 定义 JWT 密钥、过期时间与登录防爆破参数,并提供密钥安全校验常量 +# +====================================================================== +"""JWT 与登录安全配置。 + +包含 JWT 密钥、过期时间、登录防爆破参数。 +""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:环境变量读取 +# ---------------------------------------------------------------------- +import os + +# ============================================================ +# JWT 配置 +# ============================================================ +# JWT 密钥 - 用于令牌签名和验证,至少 32 字节,务必使用强随机密钥 +# 生成方式:python -c "import secrets; print(secrets.token_urlsafe(48))" +# 切勿使用默认值,否则存在安全风险(启动 fail-close)! +JWT_SECRET = os.getenv('JWT_SECRET', '') +# JWT 过期时间(分钟) +JWT_EXPIRE_MINUTES = int(os.getenv('JWT_EXPIRE_MINUTES', '20')) + +# ============================================================ +# 登录防爆破配置 +# ============================================================ +# 登录失败次数上限 - 同一IP+用户名连续失败超过此次数后锁定 +LOGIN_FAIL_MAX = int(os.getenv('LOGIN_FAIL_MAX', '5')) +# 登录锁定时间(秒)- 锁定期间禁止该IP+用户名登录 +LOGIN_LOCK_SECONDS = int(os.getenv('LOGIN_LOCK_SECONDS', '300')) + +# ============================================================ +# 安全校验常量 +# ============================================================ +# 安全密钥最小长度(字节):令牌签名密钥必须足够长 +MIN_SECRET_LENGTH = 32 +# 已知不安全/默认密钥,命中即启动失败(fail-close) +INSECURE_SECRETS = { + "", + "d67beaf46fbf4f048d2eeb26fd62ea49", # 历史公开默认值,严禁使用 + "change-me", "changeme", "secret", "your-secret-key", "jwt-secret", +} \ No newline at end of file diff --git a/src/config/captcha.py b/src/config/captcha.py new file mode 100644 index 0000000..3318f47 --- /dev/null +++ b/src/config/captcha.py @@ -0,0 +1,38 @@ +# +====================================================================== +# | 模块: 验证码配置层 +# | 说明: 定义验证码长度、有效期、防刷限流、字符集、图片尺寸、 +# | 干扰参数与万能验证码等配置项 +# +====================================================================== +"""验证码配置。 + +包含验证码长度、有效期、防刷限流、字符集、图片尺寸、干扰参数、万能验证码等。 +""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:环境变量读取 +# ---------------------------------------------------------------------- +import os + +# ============================================================ +# 验证码配置 +# ============================================================ +# 验证码长度 +CAPTCHA_LENGTH = int(os.getenv('CAPTCHA_LENGTH', '6')) +# 验证码有效期(秒) +CAPTCHA_EXPIRE = int(os.getenv('CAPTCHA_EXPIRE', '300')) +# 防刷:同一 IP 在窗口期内最多获取验证码次数 +CAPTCHA_RATE_LIMIT = int(os.getenv('CAPTCHA_RATE_LIMIT', '20')) +# 防刷滑动窗口(秒) +CAPTCHA_RATE_WINDOW = int(os.getenv('CAPTCHA_RATE_WINDOW', '60')) +# 字符集(去除 0/O/1/I/l/2/Z/5/S 等易混淆字符,全大写便于大小写不敏感比较) +CAPTCHA_CHARS = os.getenv('CAPTCHA_CHARS', '346789ABCDEFGHJKMNPQRTUVWXY') +# 验证码图片尺寸与字号 +CAPTCHA_IMAGE_WIDTH = int(os.getenv('CAPTCHA_IMAGE_WIDTH', '200')) +CAPTCHA_IMAGE_HEIGHT = int(os.getenv('CAPTCHA_IMAGE_HEIGHT', '60')) +CAPTCHA_FONT_SIZE = int(os.getenv('CAPTCHA_FONT_SIZE', '30')) +# 干扰线 / 噪点数量 +CAPTCHA_NOISE_LINES = int(os.getenv('CAPTCHA_NOISE_LINES', '10')) +CAPTCHA_NOISE_DOTS = int(os.getenv('CAPTCHA_NOISE_DOTS', '60')) +# 万能验证码(始终生效,方便开发与演示环境登录) +CAPTCHA_BYPASS_CODE = os.getenv('CAPTCHA_BYPASS_CODE', '618618') \ No newline at end of file diff --git a/src/config/database.py b/src/config/database.py new file mode 100644 index 0000000..cc6e78a --- /dev/null +++ b/src/config/database.py @@ -0,0 +1,94 @@ +# +====================================================================== +# | 模块: 数据库配置层 +# | 说明: 定义数据库驱动、连接参数、连接池、读写分离、表前缀等配置项, +# | 并拼接主库连接串 +# +====================================================================== +"""数据库配置。 + +包含数据库驱动、连接参数、连接池、读写分离、表前缀等。 +""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:环境变量读取 +# ---------------------------------------------------------------------- +import os + +# ============================================================ +# 数据库方言别名 → SQLAlchemy 方言 +# ============================================================ +DB_ALIASES = { + "mysql": "mysql+pymysql", + "postgres": "postgresql+psycopg2", + "postgresql": "postgresql+psycopg2", + "sqlserver": "mssql+pymssql", + "mssql": "mssql+pymssql", + "sqlite": "sqlite", + "sqlite3": "sqlite", + "oracle": "oracle+oracledb", +} + + +# ====================================================================== +# 解析驱动别名 +# ====================================================================== +def _resolve_driver(driver: str) -> str: + """解析驱动别名,返回 SQLAlchemy 方言名(未识别时原样返回)。 + + :param driver: 驱动别名(如 mysql / postgres / sqlite) + :return: SQLAlchemy 方言名 + """ + return DB_ALIASES.get(driver.strip().lower(), driver.strip().lower()) + + +# ============================================================ +# 数据库配置 +# ============================================================ +# 数据库驱动 - 可选 mysql / postgresql(别名 postgres) / mssql(别名 sqlserver) +# / sqlite(别名 sqlite3) / oracle +DB_DRIVER = os.getenv('DB_DRIVER', 'mysql') +# 数据库地址 - 数据库服务器IP或域名(sqlite 时忽略) +DB_HOST = os.getenv('DB_HOST', '127.0.0.1') +# 数据库端口 - MySQL:3306 / PostgreSQL:5432 / SQL Server:1433 / Oracle:1521 / sqlite 时忽略 +DB_PORT = int(os.getenv('DB_PORT', '3306')) +# 数据库名称 - 要连接的库名;DB_DRIVER=sqlite 时即数据库文件完整路径 +# (相对/绝对路径如 C:/apps/dev.db,忽略 host/port/user/password); +# DB_DRIVER=oracle 时填 SERVICE_NAME(如 XEPDB1) +DB_DATABASE = os.getenv('DB_DATABASE', 'djangoadmin.tornado.antdvue') +# 数据库账号 - 连接数据库的用户名(sqlite 时忽略) +DB_USERNAME = os.getenv('DB_USERNAME', 'root') +# 数据库密码 - 连接数据库的密码(sqlite 时无需配置) +DB_PASSWORD = os.getenv('DB_PASSWORD', '') +# 数据表前缀 - 所有数据表统一前缀,用于多应用或模块隔离 +DB_PREFIX = os.getenv('DB_PREFIX', 'tornado_') +# 是否开启调试模式 - True:输出SQL执行日志,False:不输出(生产环境务必关闭) +DB_DEBUG = os.getenv('DB_DEBUG', 'False').lower() == 'true' +# 读库地址(读写分离,可选;留空则读写同库) +READ_DATABASE_URL = os.getenv('READ_DATABASE_URL', '') + + +# ====================================================================== +# 数据库连接串(模块级变量,供直接导入) +# ====================================================================== +def _build_database_url() -> str: + """主库连接串(按方言拼接,见 DB_ALIASES)。 + + :return: 完整的主库连接串 + """ + driver = _resolve_driver(DB_DRIVER) + # SQLite:直接使用文件路径(空则退回内存库) + if driver.startswith("sqlite"): + if not DB_DATABASE: + return "sqlite:///:memory:" + return f"sqlite:///{DB_DATABASE.replace(chr(92), '/')}" + auth = f"{DB_USERNAME}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}" + # Oracle:使用 service_name 参数 + if driver.startswith("oracle"): + suffix = f"?service_name={DB_DATABASE}" if DB_DATABASE else "" + return f"{driver}://{auth}/{suffix}" + url = f"{driver}://{auth}/{DB_DATABASE}" + # MySQL:附加 utf8mb4 字符集 + return url + "?charset=utf8mb4" if driver.startswith("mysql") else url + + +DATABASE_URL = _build_database_url() diff --git a/src/config/email.py b/src/config/email.py new file mode 100644 index 0000000..858d39f --- /dev/null +++ b/src/config/email.py @@ -0,0 +1,36 @@ +# +====================================================================== +# | 模块: 邮件配置层 +# | 说明: 定义邮件服务器、端口、SSL、账号密码、发件人信息等配置项 +# +====================================================================== +"""邮件配置。 + +包含邮件服务器、端口、SSL、账号密码、发件人信息等。 +""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:环境变量读取 +# ---------------------------------------------------------------------- +import os + +# ============================================================ +# 邮件配置 +# ============================================================ +# 邮寄者 +MAIL_MAILER = os.getenv('MAIL_MAILER', 'smtp') +# 邮件服务 +MAIL_SERVER = os.getenv('MAIL_SERVER', 'smtp.163.com') +# 邮件端口 +MAIL_PORT = int(os.getenv('MAIL_PORT', '465')) +# 邮件SSL证书 +MAIL_USE_SSL = os.getenv('MAIL_USE_SSL', 'True').lower() == 'true' +# 授权邮箱用户名 +MAIL_USERNAME = os.getenv('MAIL_USERNAME', '') +# 授权邮箱授权码 +MAIL_PASSWORD = os.getenv('MAIL_PASSWORD', '') +# 加密方式 +MAIL_ENCRYPTION = os.getenv('MAIL_ENCRYPTION', '') +# 发件人名称 +MAIL_FROM_NAME = os.getenv('MAIL_FROM_NAME', '') +# 发件人邮箱 +MAIL_FROM_ADDRESS = os.getenv('MAIL_FROM_ADDRESS', '') \ No newline at end of file diff --git a/src/config/logging.py b/src/config/logging.py new file mode 100644 index 0000000..3530acd --- /dev/null +++ b/src/config/logging.py @@ -0,0 +1,102 @@ +# +====================================================================== +# | 模块: 日志配置层 +# | 说明: 配置控制台 + JSON 文件的日志输出(结构化),并提供 JSON 格式化器 +# +====================================================================== +"""日志配置:控制台 + JSON 文件,结构化输出。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:JSON 序列化、日志模块与配置、日期时间、路径处理、 +# 应用配置(日志目录 / 日志级别) +# ---------------------------------------------------------------------- +import json +import logging +import logging.config +from datetime import datetime +from pathlib import Path + +from config.app import LOG_DIR, LOG_LEVEL + +# 控制台日志格式(当前统一使用 JSON 格式化器,此常量保留备用) +_FORMAT = "%(asctime)s %(levelname)s %(name)s %(message)s" + + +# ====================================================================== +# JSON 结构化日志格式化器 +# ====================================================================== +class JsonFormatter(logging.Formatter): + """JSON 结构化日志格式化器。""" + + # ============================================================ + # 格式化日志记录 + # ============================================================ + def format(self, record: logging.LogRecord) -> str: + """将日志记录格式化为 JSON 字符串。 + + :param record: 日志记录 + :return: JSON 字符串 + """ + payload = { + "timestamp": datetime.fromtimestamp(record.created).isoformat(), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + } + # 异常信息:附上完整堆栈 + if record.exc_info: + payload["exc_info"] = self.formatException(record.exc_info) + return json.dumps(payload, ensure_ascii=False) + + +# ====================================================================== +# 初始化全局日志 +# ====================================================================== +def setup_logging() -> None: + """初始化全局日志(幂等)。""" + # 解析日志级别(非法值回退 INFO) + level = getattr(logging, LOG_LEVEL.upper(), logging.INFO) + # 确保日志目录存在 + log_dir = Path(LOG_DIR) + log_dir.mkdir(parents=True, exist_ok=True) + + # 处理器:控制台 + 滚动文件 + handlers = [ + { + "class": "logging.StreamHandler", + "formatter": "json", + "level": level, + }, + { + "class": "logging.handlers.RotatingFileHandler", + "filename": str(log_dir / "app.log"), + "maxBytes": 10 * 1024 * 1024, + "backupCount": 10, + "encoding": "utf-8", + "formatter": "json", + "level": level, + }, + ] + + logging.config.dictConfig({ + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "json": {"()": JsonFormatter}, + }, + "handlers": { + "console": handlers[0], + "file": handlers[1], + }, + "root": {"level": level, "handlers": ["console", "file"]}, + "loggers": { + # SQLAlchemy 日志仅记录 WARNING 以上,避免 SQL 噪声 + "sqlalchemy": {"level": logging.WARNING, "propagate": False}, + "sqlalchemy.engine": {"level": logging.WARNING, "propagate": False}, + "sqlalchemy.orm": {"level": logging.WARNING, "propagate": False}, + "sqlalchemy.pool": {"level": logging.WARNING, "propagate": False}, + # APScheduler 日志仅记录 WARNING 以上 + "apscheduler": {"level": logging.WARNING, "propagate": False}, + # 操作日志仅写入文件,不向控制台传播 + "operation": {"level": level, "handlers": ["file"], "propagate": False}, + }, + }) diff --git a/src/config/redis.py b/src/config/redis.py new file mode 100644 index 0000000..46bccd0 --- /dev/null +++ b/src/config/redis.py @@ -0,0 +1,30 @@ +# +====================================================================== +# | 模块: Redis 缓存配置层 +# | 说明: 定义 Redis 连接地址、端口、密码、库索引、认证、解码等配置项 +# +====================================================================== +"""Redis 缓存配置。 + +包含 Redis 连接地址、端口、密码、库索引、认证、解码等。 +""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:环境变量读取 +# ---------------------------------------------------------------------- +import os + +# ============================================================ +# 缓存配置 +# ============================================================ +# 缓存服务地址 +REDIS_HOST = os.getenv('REDIS_HOST', '127.0.0.1') +# 缓存服务端口 +REDIS_PORT = int(os.getenv('REDIS_PORT', '6379')) +# 缓存服务密码 +REDIS_PASSWORD = os.getenv('REDIS_PASSWORD', '') +# 缓存库索引 +REDIS_INDEX = int(os.getenv('REDIS_INDEX', '0')) +# AUTH 为 True 时需要进行用户认证 +REDIS_AUTH = os.getenv('REDIS_AUTH', 'True').lower() == 'true' +# 是否对查询结果进行编码处理 +REDIS_DECODE_RESPONSES = os.getenv('REDIS_DECODE_RESPONSES', 'True').lower() == 'true' diff --git a/src/constants/__init__.py b/src/constants/__init__.py new file mode 100644 index 0000000..6aac77d --- /dev/null +++ b/src/constants/__init__.py @@ -0,0 +1,40 @@ +# +====================================================================== +# | 模块: 全局常量 +# | 说明: 分页、默认密码、消息文本、序列化排除字段等配置 +# +====================================================================== + +"""全局常量:分页、默认密码、消息文本、序列化排除字段。""" + + +# ============================================================ +# 分页配置 +# ============================================================ +# 默认每页显示记录数 +PAGE_SIZE = 10 +# 单页最大条数(限制超大 limit,防止拖垮数据库) +MAX_PAGE_SIZE = 100 + + +# ============================================================ +# 用户配置 +# ============================================================ +# 默认密码(新创建用户或重置密码时的默认密码,建议首次登录后强制修改) +DEFAULT_PASSWORD = "123456" + + +# ============================================================ +# 消息提示配置 +# ============================================================ +# 操作成功提示信息 +MESSAGE_OK = "操作成功" +# 操作失败提示信息 +MESSAGE_FAIL = "操作失败" +# 演示环境提示 +MESSAGE_DEMO = "演示环境,暂无操作权限" + + +# ============================================================ +# 序列化安全配置 +# ============================================================ +# 模型序列化时统一排除的敏感字段,防止密码、盐值等泄露给前端 +SERIALIZE_EXCLUDE_FIELDS = frozenset({'is_delete', 'password', 'salt'}) diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..b9ec200 --- /dev/null +++ b/src/core/__init__.py @@ -0,0 +1 @@ +"""核心基础设施层。""" diff --git a/src/core/base_handler.py b/src/core/base_handler.py new file mode 100644 index 0000000..137784c --- /dev/null +++ b/src/core/base_handler.py @@ -0,0 +1,89 @@ +# +====================================================================== +# | 模块: 请求处理基类层 +# | 说明: 定义所有业务处理器的公共基类(跨域、trace_id、登录校验、 +# | 异常统一响应、数据库会话回收),以及健康检查端点 +# +====================================================================== +"""请求处理基类。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:Tornado 框架、中间件(认证/跨域/链路追踪)、数据库会话、 +# 自定义异常体系、日志器 +# ---------------------------------------------------------------------- +import tornado.web + +from common.middleware.authentication import login_required +from common.middleware.cors import cros_required +from common.middleware.trace_id import apply_trace_id +from core.database import db +from core.exceptions import BaseAppError, BusinessError, NotFoundError, ServerError, ValidationError +from core.logger import logger + + +# ====================================================================== +# 业务处理器基类 +# ====================================================================== +class BaseHandler(tornado.web.RequestHandler): + """所有业务请求处理器的基类。 + + - 统一跨域头与 trace_id + - prepare 阶段统一登录校验(除 /login、/captcha、OPTIONS 外均需有效 JWT) + - write_error 将异常统一转换为 {code, data, msg} 响应 + """ + + def set_default_headers(self): + """设置默认响应头:注入跨域头与链路追踪 ID。""" + super().set_default_headers() + cros_required(self) + apply_trace_id(self) + + @login_required + def prepare(self): + """请求预处理:统一执行登录校验(由 login_required 装饰器实现)。""" + pass + + def options(self, *args, **kwargs): + """处理 CORS 预检请求,直接返回 204 无内容响应。""" + self.set_status(204) + self.finish() + + def on_finish(self): + """请求结束后关闭数据库会话:兜底释放未走 service finally 关闭的会话, + 避免长连接与 identity map 跨请求累积(服务层 finally 已关闭时此处为幂等 no-op)。""" + try: + db.close() + except Exception: + pass + + def write_error(self, status_code, **kwargs): + """统一异常响应,并记录异常日志(业务失败记 warning,未预期异常记 error 含堆栈)。""" + exc_info = kwargs.get("exc_info", (None, None, None)) + exc = exc_info[1] + # 业务自定义异常:按异常类型分级记录日志 + if isinstance(exc, BaseAppError): + if isinstance(exc, (BusinessError, ValidationError, NotFoundError)): + logger.warning("业务异常 path=%s msg=%s", self.request.path, exc.msg) + else: + logger.error("请求异常 path=%s msg=%s", self.request.path, exc.msg) + self.set_status(exc.status_code) + self.finish({"code": exc.code, "data": None, "msg": exc.msg, "ok": False}) + else: + # 未预期异常:记录完整堆栈 + logger.error("未捕获异常 path=%s", self.request.path, exc_info=exc_info) + self.set_status(ServerError.status_code) + self.finish({"code": ServerError.code, "data": None, "msg": ServerError.msg, "ok": False}) + + +# ====================================================================== +# 健康检查端点 +# ====================================================================== +class HealthHandler(tornado.web.RequestHandler): + """健康检查端点(无需认证)。""" + + def set_default_headers(self): + """设置响应头为 JSON 格式。""" + self.set_header("Content-Type", "application/json; charset=UTF-8") + + def get(self): + """返回服务健康状态。""" + self.finish({"code": 0, "data": {"status": "ok"}, "msg": "操作成功", "ok": True}) diff --git a/src/core/base_model.py b/src/core/base_model.py new file mode 100644 index 0000000..3a0a631 --- /dev/null +++ b/src/core/base_model.py @@ -0,0 +1,110 @@ +# +====================================================================== +# | 模块: 数据库模型基类层 +# | 说明: 定义所有业务模型的公共基类,提供通用字段、序列化方法与子类钩子 +# +====================================================================== +"""数据库模型基类:通用字段与序列化方法(数据访问统一走 Repository)。 + +所有业务模型继承本类,自动获得: +1. 通用字段:id、创建人、创建时间、更新人、更新时间、软删除标识 +2. 序列化:to_dict()(自动排除敏感字段、UTC 转本地时间、驼峰命名) +3. 子类钩子:__table_comment__ 自动写入 __table_args__['comment'] +""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:日期时间类型、SQLAlchemy 列类型与函数、命名转换工具、 +# 序列化排除字段常量、声明式基类 +# ---------------------------------------------------------------------- +from datetime import date, datetime, timezone + +from sqlalchemy import Column, DateTime, Integer, String, func, text + +from common.utils.helpers import snake_to_camel +from constants import SERIALIZE_EXCLUDE_FIELDS +from core.database import Base + + +def _utc_now(): + """UTC 时间工厂(供 Column default 使用)。""" + return datetime.now(tz=timezone.utc) + + +# ====================================================================== +# 模型基类 +# ====================================================================== +class BaseModel(Base): + """数据库模型基类(抽象)。 + + 子类声明 __table_comment__ 后自动通过 __init_subclass__ 写入 + __table_args__['comment'],建表时生成表注释。 + """ + + __abstract__ = True + + # ============================================================ + # 子类钩子:自动将 __table_comment__ 写入 __table_args__ + # ============================================================ + def __init_subclass__(cls, **kwargs): + """子类定义时自动执行:将 __table_comment__ 合并进 __table_args__ 的 comment 项。""" + super().__init_subclass__(**kwargs) + comment = getattr(cls, '__table_comment__', None) + if not comment: + return + # 仅对有 __tablename__ 的具体模型操作(跳过纯抽象中间层) + if not getattr(cls, '__tablename__', None): + return + existing = getattr(cls, '__table_args__', {}) + # 兼容 __table_args__ 的三种形态:dict / tuple / 未定义 + if isinstance(existing, dict): + merged = {**existing} + elif isinstance(existing, tuple) and existing: + merged = dict(existing[-1]) if isinstance(existing[-1], dict) else {} + else: + merged = {} + # 不覆盖子类已显式声明的 comment + if 'comment' not in merged: + merged['comment'] = comment + cls.__table_args__ = merged + + # ============================================================ + # 通用字段定义 + # ============================================================ + id = Column(Integer, primary_key=True, autoincrement=True, comment="主键ID") + create_user = Column(String(50), nullable=True, comment="创建人") + create_time = Column(DateTime, default=_utc_now, server_default=func.now(), comment="创建时间") + update_user = Column(String(50), nullable=True, comment="更新人") + update_time = Column(DateTime, default=_utc_now, onupdate=_utc_now, server_default=func.now(), comment="更新时间") + is_delete = Column(Integer, default=0, server_default=text('0'), index=True, comment="删除标识:0-正常 1-已删除") + + # ============================================================ + # 序列化方法 + # ============================================================ + def to_dict(self): + """对象转字典,DateTime/Date 做格式化,排除敏感字段,字段名转驼峰。 + + 时区处理(C1 防御): + - aware datetime:统一转本地时间再格式化 + - naive datetime(MySQL DATETIME 无时区,读回为 naive): + 先补 UTC 时区再转本地,避免新对象(aware UTC)与读回对象(naive UTC)展示相差 8 小时 + """ + result = {} + # 遍历表字段,过滤敏感字段并做类型转换 + for col in self.__table__.columns: + if col.name in SERIALIZE_EXCLUDE_FIELDS: + continue + value = getattr(self, col.name) + if isinstance(value, datetime): + if value.tzinfo is None: + # MySQL DATETIME 无时区信息,读回为 naive UTC,统一补 UTC 后转本地 + value = value.replace(tzinfo=timezone.utc) + value = value.astimezone().strftime('%Y-%m-%d %H:%M:%S') + elif isinstance(value, date): + # 纯日期类型(如生日)无时分秒,单独格式化 + value = value.strftime('%Y-%m-%d') + result[snake_to_camel(col.name)] = value + return result + + def to_json(self): + """将实例对象转化为 JSON(移除 SQLAlchemy 内部状态与敏感字段)。""" + return {k: v for k, v in self.__dict__.items() + if k != "_sa_instance_state" and k not in SERIALIZE_EXCLUDE_FIELDS} diff --git a/src/core/base_repository.py b/src/core/base_repository.py new file mode 100644 index 0000000..923a348 --- /dev/null +++ b/src/core/base_repository.py @@ -0,0 +1,410 @@ +# +====================================================================== +# | 模块: 通用仓储基类层 +# | 说明: 提供统一 CRUD、软删除与审计字段写入,业务模块仓储继承本类 +# +====================================================================== +"""通用仓储基类。 + +提供统一 CRUD、软删除与审计字段写入;业务模块仓储继承本类。 + +软删除约定(统一以 is_delete=0 表示未删除): + - 便捷查询方法(get_by_id / get_by_ids / get_one / all / count / filter_by / exists*) + 自动追加"未删除"过滤。 + - filter(*criterion) 为原始出口,不做软删过滤,由调用方自行追加 + (如 Model.is_delete == 0),用于 in_ / != / 区间等复杂条件。 +""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:日期时间、正则表达式、类型标注、SQLAlchemy 会话与查询对象、 +# 当前用户上下文、默认数据库会话 +# ---------------------------------------------------------------------- +import re +from datetime import datetime +from typing import Any, Generic, List, Optional, Tuple, Type, TypeVar + +from sqlalchemy.orm import Query, Session + +from core.context import current_realname +from core.database import db as _default_db + +# 泛型类型变量,代表具体模型 +T = TypeVar("T") + + +# ====================================================================== +# 泛型仓储基类 +# ====================================================================== +class BaseRepository(Generic[T]): + """泛型仓储。子类设置 model 即可获得通用能力。""" + + # 子类需指定的模型类 + model: Type[T] + + # ============================================================ + # 初始化 + # ============================================================ + def __init__(self, db: Optional[Session] = None) -> None: + """初始化仓储:未传入会话时使用默认数据库会话。 + + :param db: 可选的 SQLAlchemy 会话;为 None 时使用全局默认会话 + """ + self.db = db or _default_db + + # ============================================================ + # 获取当前用户姓名 + # ============================================================ + def _uid(self) -> str: + """获取当前登录用户姓名(从上下文变量读取)。 + + :return: 当前请求上下文中的用户姓名 + """ + return current_realname.get() + + # ============================================================ + # 获取软删除字段 + # ============================================================ + def _soft_col(self): + """获取模型的软删除字段(is_delete)。 + + :return: 模型对应的 is_delete Column 对象 + """ + return getattr(self.model, "is_delete") + + # ============================================================ + # 应用软删除过滤 + # ============================================================ + def _apply_soft_filter(self, query: Query) -> Query: + """应用软删除过滤(is_delete=0)。 + + :param query: 原始查询对象 + :return: 追加了 is_delete == 0 条件的查询对象(模型无该字段时原样返回) + """ + if hasattr(self.model, "is_delete"): + return query.filter(self._soft_col() == 0) + return query + + # ============================================================ + # 数据清洗:camelCase → snake_case + 过滤非模型字段 + # ============================================================ + @staticmethod + def _camel_to_snake(name: str) -> str: + """驼峰命名转下划线命名。""" + s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name) + return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower() + + def _clean_model_data(self, data: dict) -> dict: + """清洗入库数据:camelCase 键转 snake_case 并仅保留模型真实列字段。 + + Pydantic schema 用 camelCase(如 deptId)而模型列是 snake_case(dept_id), + 直传 model(**fields) 会触发 TypeError。统一在数据访问层转换,业务模块无需各自处理。 + + :param data: 原始数据字典 + :return: 清洗后的数据字典 + """ + if not data: + return {} + columns = {c.name for c in self.model.__table__.columns} + return { + self._camel_to_snake(k): v + for k, v in data.items() + if self._camel_to_snake(k) in columns + } + + # ============================================================ + # 构造基础查询 + # ============================================================ + def _query(self, soft_delete: bool = True): + """构造基础查询:可选是否附加软删除过滤。 + + :param soft_delete: 是否附加软删除过滤,默认 True + :return: 查询对象 + """ + query = self.db.query(self.model) + return self._apply_soft_filter(query) if soft_delete else query + + # ============================================================ + # 按 ID 查询单条 + # ============================================================ + def get_by_id(self, pk, soft_delete: bool = True) -> Optional[T]: + """根据 ID 获取未删除记录(容错 None/空串/非数字/≤0)。 + + :param pk: 主键值,支持字符串或数字 + :param soft_delete: 是否过滤软删除记录,默认 True + :return: 匹配的记录,未找到返回 None + """ + # 容错空值:None 或空串直接返回 None + if pk is None or pk == "": + return None + # 容错非数字:转换失败返回 None + try: + pk = int(pk) + except (TypeError, ValueError): + return None + # 容错非正数:≤0 视为非法 ID + if pk <= 0: + return None + return self._query(soft_delete).filter(self.model.id == pk).first() + + # ============================================================ + # 按 ID 列表批量查询 + # ============================================================ + def get_by_ids(self, ids, soft_delete: bool = True) -> List[T]: + """根据 ID 列表批量获取未删除记录。 + + :param ids: 可迭代的 ID 集合 + :param soft_delete: 是否过滤软删除记录,默认 True + :return: 匹配的记录列表,无有效 ID 时返回空列表 + """ + valid = [] + # 过滤非法 ID,仅保留正整数 + for i in ids: + try: + i = int(i) + except (TypeError, ValueError): + continue + if i > 0: + valid.append(i) + # 无有效 ID,直接返回空列表 + if not valid: + return [] + return self._query(soft_delete).filter(self.model.id.in_(valid)).all() + + # ============================================================ + # 按等值条件查询单条 + # ============================================================ + def get_one(self, soft_delete: bool = True, **filters) -> Optional[T]: + """按等值条件获取单条未删除记录。 + + 示例: + user = repo.get_one(username="admin") + user = repo.get_one(False, username="admin") # 包含已删除记录 + + :param soft_delete: 是否过滤软删除记录,默认 True(仅查未删除) + :param filters: 等值过滤条件(字段名=值),如 username="admin"、status=1 + :return: 匹配的第一条记录,未找到返回 None + """ + return self._query(soft_delete).filter_by(**filters).first() + + # ============================================================ + # 分页查询 + # ============================================================ + def list_page(self, page: int = 1, limit: int = 10, soft_delete: bool = True) -> Tuple[List[T], int]: + """分页查询,返回 (数据列表, 总数)。 + + :param page: 页码,从 1 开始 + :param limit: 每页条数 + :param soft_delete: 是否过滤软删除记录,默认 True + :return: 二元组 (记录列表, 总记录数) + """ + query = self._query(soft_delete) + count = query.count() + # 按主键升序分页 + items = query.order_by(self.model.id.asc()).limit(limit).offset((page - 1) * limit).all() + return list(items), count + + # ============================================================ + # 对已有查询分页 + # ============================================================ + def paginate(self, query: Query, page: int = 1, limit: int = 10) -> Tuple[List[T], int]: + """对传入的 query 分页(自动追加软删过滤),返回 (数据列表, 总数)。 + + :param query: 已构造的查询对象 + :param page: 页码,从 1 开始 + :param limit: 每页条数 + :return: 二元组 (记录列表, 总记录数) + """ + # 统一追加软删除过滤,避免调用方遗漏 + query = self._apply_soft_filter(query) + total = query.count() + items = query.limit(limit).offset((page - 1) * limit).all() + return list(items), total + + # ============================================================ + # 查询全部记录 + # ============================================================ + def all(self, soft_delete: bool = True) -> List[T]: + """获取全部记录(默认过滤已删除)。 + + :param soft_delete: 是否过滤软删除记录,默认 True + :return: 记录列表 + """ + return list(self._query(soft_delete).all()) + + # ============================================================ + # 统计记录数 + # ============================================================ + def count(self, soft_delete: bool = True) -> int: + """统计记录数(默认过滤已删除)。 + + :param soft_delete: 是否过滤软删除记录,默认 True + :return: 记录总数 + """ + return self._query(soft_delete).count() + + # ============================================================ + # 判断记录是否存在 + # ============================================================ + def exists(self, obj_id=None, soft_delete: bool = True, **filters) -> bool: + """检查记录是否存在:按 ID 或按等值条件。 + + :param obj_id: 主键值,传入时按 ID 判断 + :param soft_delete: 是否过滤软删除记录,默认 True + :param filters: 等值过滤条件(未传 obj_id 时使用) + :return: 存在返回 True,否则 False + """ + # 优先按 ID 判断 + if obj_id is not None: + return self.get_by_id(obj_id, soft_delete) is not None + # 否则按等值条件判断 + return self._query(soft_delete).filter_by(**filters).first() is not None + + # ============================================================ + # 按字段判断是否存在(唯一性校验) + # ============================================================ + def exists_by_field(self, field: str, value: Any, exclude_id: Optional[int] = None, + soft_delete: bool = True) -> bool: + """检查某字段值是否已存在(唯一性校验,仅未删除记录,可排除自身)。 + + :param field: 字段名 + :param value: 待校验的字段值 + :param exclude_id: 需排除的记录 ID(更新时排除自身),可选 + :param soft_delete: 是否过滤软删除记录,默认 True + :return: 已存在返回 True,否则 False + """ + query = self._query(soft_delete).filter(getattr(self.model, field) == value) + # 更新场景:排除自身记录 + if exclude_id: + query = query.filter(self.model.id != exclude_id) + return query.first() is not None + + # ============================================================ + # 原始查询出口(不自动软删过滤) + # ============================================================ + def filter(self, *criterion): + """原始查询出口:返回按 criterion 过滤的 query,**不自动追加软删过滤**。 + + 用于 in_ / != / and_ / 区间等复杂条件,调用方需自行追加 Model.is_delete == 0。 + + :param criterion: SQLAlchemy 过滤条件表达式 + :return: 未附加软删过滤的查询对象 + """ + return self.db.query(self.model).filter(*criterion) + + # ============================================================ + # 安全查询出口(自动软删过滤) + # ============================================================ + def filter_by(self, **kwargs) -> Query: + """安全查询出口:等值条件 + 自动软删过滤,None 值跳过。 + + :param kwargs: 等值过滤条件(字段名=值),值为 None 时忽略 + :return: 查询对象 + """ + query = self._apply_soft_filter(self.db.query(self.model)) + # 逐项追加等值条件,跳过 None 值 + for key, value in kwargs.items(): + if value is not None: + query = query.filter(getattr(self.model, key) == value) + return query + + # ============================================================ + # 新增单条记录 + # ============================================================ + def create(self, obj: T) -> T: + """新增单条记录,自动写入创建人。 + + :param obj: 模型实例 + :return: 新增后的模型实例 + """ + # 自动写入创建人(如模型含 create_user 字段) + if hasattr(obj, "create_user"): + obj.create_user = str(self._uid()) + self.db.add(obj) + self.db.commit() + return obj + + # ============================================================ + # 批量新增记录 + # ============================================================ + def create_all(self, objs: List[T]) -> None: + """批量新增记录,自动写入创建人。 + + :param objs: 模型实例列表 + """ + # 逐个写入创建人 + for obj in objs: + if hasattr(obj, "create_user"): + obj.create_user = str(self._uid()) + self.db.add_all(objs) + self.db.commit() + + # ============================================================ + # 按主键更新 + # ============================================================ + def update(self, pk: int, data: dict) -> bool: + """按主键更新,自动补审计字段。 + + :param pk: 主键值 + :param data: 待更新的字段字典 + :return: 是否更新成功 + """ + # 自动补充更新人与更新时间 + data["update_user"] = str(self._uid()) + data["update_time"] = datetime.now() + result = self.db.query(self.model).filter_by(id=pk).update(data) + self.db.commit() + return bool(result) + + # ============================================================ + # 按条件批量更新 + # ============================================================ + def update_by(self, filters: dict, data: dict) -> bool: + """按条件批量更新,自动补审计字段。 + + :param filters: 过滤条件字典 + :param data: 待更新的字段字典 + :return: 是否更新成功 + """ + # 自动补充更新人与更新时间 + data["update_user"] = str(self._uid()) + data["update_time"] = datetime.now() + result = self.db.query(self.model).filter_by(**filters).update(data) + self.db.commit() + return bool(result) + + # ============================================================ + # 批量软删除 + # ============================================================ + def batch_delete(self, ids_str) -> int: + """按逗号分隔 ID 字符串软删除(is_delete=1),返回实际删除条数。 + + 无有效 ID 或记录不存在时返回 0。 + + :param ids_str: 逗号分隔的 ID 字符串,如 "1,2,3" + :return: 实际软删除的记录条数 + """ + id_list = [] + # 解析逗号分隔的 ID 字符串,过滤非法值 + for part in str(ids_str).split(","): + try: + i = int(part) + except (TypeError, ValueError): + continue + if i > 0: + id_list.append(i) + # 无有效 ID,直接返回 0 + if not id_list: + return 0 + # 仅查询未删除记录,逐个标记为已删除 + records = self.db.query(self.model).filter( + self.model.id.in_(id_list), self._soft_col() == 0).all() + for record in records: + setattr(record, "is_delete", 1) + self.db.commit() + return len(records) + + # ============================================================ + # 关闭会话 + # ============================================================ + def close(self) -> None: + """关闭会话(服务层 finally 中调用)。""" + self.db.close() diff --git a/src/core/base_schema.py b/src/core/base_schema.py new file mode 100644 index 0000000..30fddab --- /dev/null +++ b/src/core/base_schema.py @@ -0,0 +1,55 @@ +# +====================================================================== +# | 模块: Schema 基类层 +# | 说明: 定义所有创建/编辑 Schema 的公共基类,统一处理字段校验与配置 +# +====================================================================== +"""Schema 基类:统一处理前端传入的空字符串 id → None 等通用校验。 + +所有创建/编辑 Schema 应继承此类,自动获得: + 1. id 字段:Optional[int],前端传空字符串时自动转为 None + 2. extra="ignore":忽略前端多余的字段 +""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:类型标注、Pydantic 基类与校验器 +# ---------------------------------------------------------------------- +from typing import Optional + +from pydantic import BaseModel, Field, field_validator + + +# ====================================================================== +# Schema 基类 +# ====================================================================== +class BaseSchema(BaseModel): + """Schema 基类。 + + 所有业务 Schema 继承本类,统一获得 id 空值容错与多余字段忽略能力。 + """ + + # ============================================================ + # 通用字段定义 + # ============================================================ + id: Optional[int] = Field(None, description="主键ID") + + # ============================================================ + # 字段校验器 + # ============================================================ + @field_validator('id', mode='before') + @classmethod + def empty_str_to_none(cls, v): + """前端 POST 空字符串 '' 时转为 None,避免 Pydantic 整数解析报错。 + + :param v: 原始输入值 + :return: 空字符串类值转为 None,其余原样返回 + """ + # 兼容前端常见的空值表示形式 + if v == '' or v == 'null' or v == 'undefined': + return None + return v + + # ============================================================ + # 模型配置 + # ============================================================ + # 忽略前端多余的字段,避免校验报错 + model_config = {"extra": "ignore"} diff --git a/src/core/base_service.py b/src/core/base_service.py new file mode 100644 index 0000000..6a93552 --- /dev/null +++ b/src/core/base_service.py @@ -0,0 +1,599 @@ +# +====================================================================== +# | 模块: 业务服务基类层 +# | 说明: 封装模块通用 CRUD 流程(模板方法模式),子类声明差异点即可复用 +# +====================================================================== +"""业务服务基类:封装模块通用 CRUD 流程(模板方法模式)。 + +子类声明差异点(model / schema / 分页字段 / 排序 / 唯一性 / 序列化钩子), +即可获得分页、详情、添加、编辑、删除、状态切换、下拉列表等标准接口。 +复杂业务(图片、富文本、联表名称、树等)通过覆写钩子或自定义方法承接。 + +返回约定: + get_page / add / update / delete / update_status / get_options 返回 R 对象; + get_detail 返回序列化后的字典或 None(由端点包一层 R.ok)。 +""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:JSON 解析、类型标注、Pydantic 校验、通用工具(ID 解析、命名转换、 +# 校验错误提取)、常量、统一响应对象、仓储基类、当前用户上下文 +# ---------------------------------------------------------------------- +import json +from typing import Any, Callable, Dict, Generic, Optional, Tuple, Type, TypeVar + +import pydantic + +from common.utils.helpers import parse_id_list, snake_to_camel +from common.utils.validators import get_err +from constants import MAX_PAGE_SIZE, PAGE_SIZE +from core import response as R +from core.base_repository import BaseRepository +from core.context import current_realname + +# 泛型类型变量,代表具体模型 +M = TypeVar("M") + + +# ====================================================================== +# 业务服务基类 +# ====================================================================== +class BaseService(Generic[M]): + """业务服务基类,子类通过声明差异点定制通用 CRUD 流程。""" + + # ============================================================ + # 基础声明(必须由子类覆盖) + # ============================================================ + repo_cls: Type[BaseRepository] = BaseRepository + model: Type[M] + + # ============================================================ + # 差异点声明(子类按需覆盖) + # ============================================================ + # Pydantic 校验模型 + create_schema: Optional[Type[pydantic.BaseModel]] = None + update_schema: Optional[Type[pydantic.BaseModel]] = None + status_schema: Optional[Type[pydantic.BaseModel]] = None + # 分页模糊查询字段(取同名字段查询参数,如 name → LIKE %值%) + page_like_fields: Tuple[str, ...] = () + # 分页等值查询字段(取同名字段查询参数,如 status → =值) + page_eq_fields: Tuple[str, ...] = () + # 分页排序:每个元素为 ('字段名', 'asc'|'desc'),默认 id asc + page_order_by: Tuple[Tuple[str, str], ...] = (("id", "asc"),) + # 唯一性校验:{'字段名': '重复提示文案'}(新增全查,编辑排除自身) + unique_fields: Dict[str, str] = {} + # 枚举显示名映射:{'字段名': {值: 名称} 或 constants 中的常量名} + serialize_maps: Dict[str, Any] = {} + # 列表/详情共用加工钩子:返回需合并进字典的额外字段(如文件地址加前缀) + serialize_extra: Optional[Callable[[M], Optional[Dict[str, Any]]]] = None + # 详情专用加工钩子:仅在 get_detail 时合并(默认同列表行) + detail_serialize: Optional[Callable[[M], Optional[Dict[str, Any]]]] = None + # 下拉列表等值过滤(自动加软删),如 {'status': 1} + options_filter: Optional[Dict[str, Any]] = None + # 分页列表序列化时从返回数据剔除的字段(如完整富文本 content,列表无需返回、详情才返回) + page_exclude_fields: Tuple[str, ...] = () + # 文件字段:需要 save_file 处理的字段名(add/update 时自动迁移临时文件,serialize 时自动补全 URL) + file_fields: Tuple[str, ...] = () + # 富文本字段:需要 save_content 处理的字段名(含嵌入图片迁移 + XSS 清洗) + rich_text_fields: Tuple[str, ...] = () + # 文件存储子目录名(默认从表名去掉前缀推导,如 tornado_user -> user) + file_dir: str = '' + + # ============================================================ + # 初始化 + # ============================================================ + def __init__(self, db=None) -> None: + """初始化服务:实例化仓储。 + + :param db: 可选的数据库会话;为 None 时由仓储使用默认会话 + """ + # 经类属性非绑定访问,兼容类(RoleRepository)与可调用对象(lambda/工厂) + self.repo = type(self).repo_cls(db) + + # ============================================================ + # 属性 + # ============================================================ + @property + def db(self): + """当前仓储持有的数据库会话。""" + return self.repo.db + + @property + def uid(self) -> str: + """当前请求用户姓名。""" + return current_realname.get() + + # ============================================================ + # 请求解析与校验(静态工具) + # ============================================================ + @staticmethod + def parse_json(handler) -> dict: + """解析请求体 JSON,非 JSON 内容抛出 ValidationError(转统一校验失败响应而非 500)。 + + 结果缓存在 handler._parsed_json 上,同一次请求内重复调用不重复解析。 + + :param handler: Tornado 请求处理器 + :return: 解析后的字典;空请求体返回 {} + """ + # 优先读取缓存,避免同请求内重复解析 + cached = getattr(handler, '_parsed_json', None) + if cached is not None: + return cached + # 空请求体直接返回空字典 + if not handler.request.body: + handler._parsed_json = {} + return {} + try: + result = json.loads(handler.request.body) + handler._parsed_json = result + return result + except (ValueError, TypeError): + # 非 JSON 内容按校验失败处理,避免抛出 500 + from core.exceptions import ValidationError + raise ValidationError("请求体不是有效的 JSON") + + @staticmethod + def validate(schema_cls: Type[pydantic.BaseModel], data: dict) -> pydantic.BaseModel: + """Pydantic 校验,失败抛出 ValidationError。 + + :param schema_cls: 校验模型类 + :param data: 待校验的字典数据 + :return: 校验通过的模型实例 + """ + from core.exceptions import ValidationError + + try: + return schema_cls.model_validate(data) + except pydantic.ValidationError as e: + raise ValidationError(get_err(e)) + + def _validate(self, handler, schema: Type[pydantic.BaseModel]) -> pydantic.BaseModel: + """从请求体解析并校验,返回表单对象。 + + :param handler: Tornado 请求处理器 + :param schema: 校验模型类 + :return: 校验通过的模型实例 + """ + return self.validate(schema, self.parse_json(handler)) + + # ============================================================ + # 查询 + # ============================================================ + async def get_page(self, handler) -> R: + """分页查询,返回 R.page(data=列表, total=总数, current=页码, size=每页大小),含超页自动回退第 1 页。 + + :param handler: Tornado 请求处理器 + :return: 统一响应对象 + """ + try: + # 解析分页参数并组装查询 + pageNo, pageSize = self._parse_pagination(handler) + query = self._build_page_query(handler) + items, total = self.repo.paginate(query, pageNo, pageSize) + # 超页自动回退第 1 页 + if pageNo > 1 and not items: + items, total = self.repo.paginate(query, 1, pageSize) + # 序列化并剔除列表无需返回的字段 + result = [self._serialize(item) for item in items] + if self.page_exclude_fields: + for row in result: + for field in self.page_exclude_fields: + row.pop(snake_to_camel(field), None) + return R.page(handler, data=result, total=total, current=pageNo, size=pageSize) + finally: + self.repo.close() + + # ============================================================ + # 分页参数解析 + # ============================================================ + @staticmethod + def _parse_pagination(handler) -> Tuple[int, int]: + """解析分页参数:pageNo>=1,pageSize 限制在 [1, MAX_PAGE_SIZE],非数字回退默认值。 + + :param handler: Tornado 请求处理器 + :return: 二元组 (pageNo, pageSize) + """ + + def _safe(value, default): + """安全转换整数,失败时回退默认值。""" + try: + return int(value) + except (ValueError, TypeError): + return default + + # pageNo 至少为 1;pageSize 限制在 [1, MAX_PAGE_SIZE] + pageNo = max(_safe(handler.get_query_argument("pageNo", 1), 1), 1) + pageSize = min(max(_safe(handler.get_query_argument("pageSize", PAGE_SIZE), PAGE_SIZE), 1), MAX_PAGE_SIZE) + return pageNo, pageSize + + # ============================================================ + # 查询详情 + # ============================================================ + def get_detail(self, obj_id) -> Optional[Dict[str, Any]]: + """根据 ID 查询详情:get_by_id + 序列化加工,未找到返回 None。 + + :param obj_id: 主键值 + :return: 序列化后的详情字典,未找到返回 None + """ + try: + obj = self.repo.get_by_id(obj_id) + if not obj: + return None + return self._serialize_detail(obj) + finally: + self.repo.close() + + # ============================================================ + # 下拉列表查询 + # ============================================================ + async def get_options(self, handler) -> R: + """下拉列表:按 options_filter 等值过滤(自动软删)+ 排序。 + + :param handler: Tornado 请求处理器 + :return: 统一响应对象 + """ + try: + # 等值过滤(自动追加软删过滤) + query = self.repo.filter_by(**(self.options_filter or {})) + # 按声明的排序字段排序 + for field_name, direction in self.page_order_by: + column = getattr(self.model, field_name) + query = query.order_by(column.asc() if direction == 'asc' else column.desc()) + items = query.all() + return R.ok(handler, data=[self._serialize(item) for item in items]) + finally: + self.repo.close() + + # ============================================================ + # 新增 + # ============================================================ + async def add(self, handler) -> R: + """新增记录:唯一性校验 + 组装创建字段。 + + :param handler: Tornado 请求处理器 + :return: 统一响应对象 + """ + try: + form = self._validate(handler, self.create_schema) + data = form.model_dump() + # 唯一性校验,失败直接返回提示 + err = self._check_unique(form) + if err: + return R.failed(handler, err) + # 新增前扩展 + 组装入库字段 + self._before_add(handler, data) + fields = self._build_create_fields(data) + self.repo.create(self.model(**fields)) + return R.ok(handler, msg="添加成功") + finally: + self.repo.close() + + # ============================================================ + # 编辑 + # ============================================================ + async def update(self, handler) -> R: + """编辑记录:ID/存在性/唯一性(排除自身)校验后更新。 + + :param handler: Tornado 请求处理器 + :return: 统一响应对象 + """ + try: + form = self._validate(handler, self.update_schema) + data = form.model_dump() + # 校验 ID 合法 + obj_id = int(data.pop('id', 0) or 0) + if obj_id <= 0: + return R.failed(handler, "记录ID不能为空") + # 校验记录存在 + if not self.repo.get_by_id(obj_id): + return R.failed(handler, "记录不存在") + # 唯一性校验(排除自身) + err = self._check_unique(form, exclude_id=obj_id) + if err: + return R.failed(handler, err) + # 编辑前扩展 + 组装更新字段 + self._before_update(handler, data) + fields = self._build_update_fields(data) + if not self.repo.update(obj_id, fields): + return R.failed(handler, "更新失败") + return R.ok(handler, msg="更新成功") + finally: + self.repo.close() + + # ============================================================ + # 删除(支持逗号分隔 ID) + # ============================================================ + async def delete(self, handler, ids) -> R: + """删除记录(软删除):id 支持逗号分隔。 + + 先校验全部记录存在再删除,避免"部分软删已提交却整体报失败"导致的数据不一致。 + + :param handler: Tornado 请求处理器 + :param ids: 逗号分隔的 ID 字符串 + :return: 统一响应对象 + """ + try: + # 去重并解析有效 ID 列表 + id_list = list(dict.fromkeys(parse_id_list(str(ids)))) + if not id_list: + return R.failed(handler, "记录ID不存在") + # 校验全部记录存在,避免部分删除 + if len(self.repo.get_by_ids(id_list)) != len(id_list): + return R.failed(handler, "记录不存在") + # 删除前扩展校验 + err = self._before_delete(ids) + if err: + return R.failed(handler, err) + count = self.repo.batch_delete(ids) + return R.ok(handler, msg="本次共删除{0}条数据".format(count)) + finally: + self.repo.close() + + # ============================================================ + # 批量删除(请求体为 ID 数组) + # ============================================================ + async def batch_delete(self, handler) -> R: + """批量删除(软删除):请求体为 ID 数组 [1,2,3]。 + + :param handler: Tornado 请求处理器 + :return: 统一响应对象 + """ + try: + body = self.parse_json(handler) + # 兼容请求体直接为数组或 {ids: [...]} + ids = body if isinstance(body, list) else body.get('ids', []) + if not ids: + return R.failed(handler, "记录ID不存在") + id_str = ','.join(str(i) for i in ids) + id_list = list(dict.fromkeys(parse_id_list(id_str))) + if not id_list: + return R.failed(handler, "记录ID不存在") + # 校验全部记录存在,避免部分删除 + if len(self.repo.get_by_ids(id_list)) != len(id_list): + return R.failed(handler, "记录不存在") + # 删除前扩展校验 + err = self._before_delete(id_str) + if err: + return R.failed(handler, err) + count = self.repo.batch_delete(id_str) + return R.ok(handler, msg="本次共删除{0}条数据".format(count)) + finally: + self.repo.close() + + # ============================================================ + # 状态切换 + # ============================================================ + async def update_status(self, handler) -> R: + """设置记录状态(表单须含 id 与 status 字段)。 + + :param handler: Tornado 请求处理器 + :return: 统一响应对象 + """ + try: + form = self._validate(handler, self.status_schema) + # 校验记录存在 + if not self.repo.exists(form.id): + return R.failed(handler, "记录不存在") + if not self.repo.update(form.id, {"status": int(form.status)}): + return R.failed(handler, "设置失败") + return R.ok(handler, msg="设置成功") + finally: + self.repo.close() + + # ============================================================ + # 差异点钩子:组装分页查询 + # ============================================================ + def _build_page_query(self, handler): + """组装分页查询:声明字段过滤 + 排序。 + + :param handler: Tornado 请求处理器 + :return: 查询对象 + """ + query = self.repo.filter() + # 模糊查询字段 + for field in self.page_like_fields: + value = handler.get_query_argument(field, None) + if value: + query = query.filter(getattr(self.model, field).like('%' + value + '%')) + # 等值查询字段 + for field in self.page_eq_fields: + value = handler.get_query_argument(field, None) + if value: + query = query.filter(getattr(self.model, field) == value) + # 排序 + for field_name, direction in self.page_order_by: + column = getattr(self.model, field_name) + query = query.order_by(column.asc() if direction == 'asc' else column.desc()) + return query + + # ============================================================ + # 差异点钩子:列表行序列化 + # ============================================================ + def _serialize(self, item) -> Dict[str, Any]: + """列表行序列化:to_dict()(自动排除敏感字段)+ serialize_maps 枚举名 + serialize_extra 加工 + 文件 URL 补全。 + + serialize_maps 值支持三种形态: + - 字典编码(str):从 dict_util 取 {value: label}(value 为字符串) + - 常量名(str):从 constants 取 {value: label} 字典 + - 字面字典(dict):直接按值取名称 + + :param item: 模型实例 + :return: 序列化后的字典 + """ + data = item.to_dict() + # 追加枚举显示名字段(field 转驼峰 + Text 后缀:status -> statusText) + for field, spec in self.serialize_maps.items(): + value = getattr(item, field) + mapping = self._resolve_map(spec) + # dict_util 映射 key 为字符串,literal/常量映射 key 可能为 int,两者兼容 + label = mapping.get(value) + if label is None and not isinstance(value, str): + label = mapping.get(str(value)) + data[snake_to_camel(field) + 'Text'] = label + # 钩子为普通可调用对象,需通过类属性非绑定访问,避免 self 被绑定为 model + hook = type(self).serialize_extra + if hook: + extra = hook(item) + if extra: + data.update(extra) + # 文件字段补全完整 URL + self._enrich_file_urls(data) + return data + + # ============================================================ + # 差异点钩子:详情序列化 + # ============================================================ + def _serialize_detail(self, obj) -> Dict[str, Any]: + """详情序列化:默认同列表行,可附加 detail_serialize 加工字段。 + + :param obj: 模型实例 + :return: 序列化后的字典 + """ + data = self._serialize(obj) + hook = type(self).detail_serialize + if hook: + extra = hook(obj) + if extra: + data.update(extra) + return data + + # ============================================================ + # 差异点钩子:解析枚举映射 + # ============================================================ + @staticmethod + def _resolve_map(spec): + """解析枚举映射:dict 直接返回;str 作为 dict_util 字典编码查询。 + + :param spec: 映射声明,可为 dict / dict_util 编码 + :return: {值: 名称} 字典;无法解析时返回 {} + """ + if isinstance(spec, dict): + return spec + if isinstance(spec, str): + from common.utils import dict_util + return dict_util.get_dict_map(spec) + return {} + + # ============================================================ + # 差异点钩子:唯一性校验 + # ============================================================ + def _check_unique(self, data, exclude_id: Optional[int] = None) -> Optional[str]: + """按 unique_fields 查重,返回重复提示文案或 None。 + + :param data: 表单对象 + :param exclude_id: 需排除的记录 ID(编辑时排除自身),可选 + :return: 重复时返回提示文案,否则 None + """ + for field, msg in self.unique_fields.items(): + value = getattr(data, field, None) + if value is None: + continue + if self.repo.exists_by_field(field, value, exclude_id): + return msg + return None + + # ============================================================ + # 文件字段处理 + # ============================================================ + def _get_file_dir(self) -> str: + """获取文件存储子目录名:优先使用 file_dir,否则从表名去掉前缀推导。""" + if self.file_dir: + return self.file_dir + from config.database import DB_PREFIX + table_name = self.model.__tablename__ + if table_name.startswith(DB_PREFIX): + return table_name[len(DB_PREFIX):] + return table_name + + def _process_file_fields(self, fields: Dict[str, Any], data) -> None: + """处理文件字段:将临时 URL 迁移到正式存储目录,就地修改 fields。 + + :param fields: 待入库的字段字典(原地修改) + :param data: 表单数据对象(Pydantic model 或 dict) + """ + if not self.file_fields and not self.rich_text_fields: + return + from common.utils.rich_text import save_content, save_file + directory = self._get_file_dir() + for field_name in self.file_fields: + value = getattr(data, field_name, None) if hasattr(data, field_name) else (data.get(field_name) if isinstance(data, dict) else None) + if value: + fields[field_name] = save_file(value, directory) + for field_name in self.rich_text_fields: + value = getattr(data, field_name, None) if hasattr(data, field_name) else (data.get(field_name) if isinstance(data, dict) else None) + if value: + title = getattr(data, 'title', '') or getattr(data, 'name', '') or '' + fields[field_name] = save_content(value, title, directory) + + def _enrich_file_urls(self, data: Dict[str, Any]) -> None: + """列表/详情序列化时,为文件字段补全完整 URL 前缀,就地修改 data。 + + :param data: 序列化后的字典(原地修改) + """ + if not self.file_fields: + return + from common.utils.rich_text import get_file_url + for field_name in self.file_fields: + camel_name = snake_to_camel(field_name) + value = data.get(camel_name) + if value: + data[camel_name] = get_file_url(value) + + # ============================================================ + # 差异点钩子:组装新增字段 + # ============================================================ + def _build_create_fields(self, data: Dict[str, Any]) -> Dict[str, Any]: + """组装新增字段:camelCase → snake_case + 过滤非模型字段 + 排除 id + 处理文件字段。 + + :param data: 表单数据字典 + :return: 入库字段字典 + """ + data.pop('id', None) + fields = self.repo._clean_model_data(data) + self._process_file_fields(fields, data) + return fields + + # ============================================================ + # 差异点钩子:组装更新字段 + # ============================================================ + def _build_update_fields(self, data: Dict[str, Any]) -> Dict[str, Any]: + """组装更新字段:camelCase → snake_case + 过滤非模型字段 + 排除 id 与 None 值 + 处理文件字段。 + + :param data: 表单数据字典 + :return: 入库字段字典 + """ + data.pop('id', None) + cleaned = self.repo._clean_model_data(data) + fields = {k: v for k, v in cleaned.items() if v is not None} + self._process_file_fields(fields, data) + return fields + + # ============================================================ + # 业务扩展点:新增前 + # ============================================================ + def _before_add(self, handler, data: Dict[str, Any]) -> None: + """新增前扩展(如图片保存、密码盐化、关联数据处理)。 + + :param handler: Tornado 请求处理器 + :param data: 待入库的表单数据字典 + """ + + # ============================================================ + # 业务扩展点:编辑前 + # ============================================================ + def _before_update(self, handler, data: Dict[str, Any]) -> None: + """编辑前扩展(如关联数据处理)。 + + :param handler: Tornado 请求处理器 + :param data: 待入库的表单数据字典 + """ + + # ============================================================ + # 业务扩展点:删除前 + # ============================================================ + def _before_delete(self, ids) -> Optional[str]: + """删除前校验:返回 None 放行,返回文案则拦截(如子级存在性检查)。 + + :param ids: 逗号分隔的 ID 字符串 + :return: 拦截文案或 None + """ + return None diff --git a/src/core/context.py b/src/core/context.py new file mode 100644 index 0000000..4358a11 --- /dev/null +++ b/src/core/context.py @@ -0,0 +1,29 @@ +# +====================================================================== +# | 模块: 请求上下文层 +# | 说明: 基于 contextvars 保存当前请求的上下文信息(用户 ID、trace_id), +# | 供仓储/服务层读取用于审计字段与链路追踪 +# +====================================================================== +"""请求上下文(contextvars)。 + +在认证中间件中写入当前请求的用户 ID,仓储/服务层读取用于审计字段。 +""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:contextvars 上下文变量 +# ---------------------------------------------------------------------- +from contextvars import ContextVar + +# ============================================================ +# 当前请求用户信息 +# ============================================================ +# 未认证时为默认值,认证中间件在请求开始时写入真实值 +current_user_id: ContextVar[int] = ContextVar("current_user_id", default=0) +current_username: ContextVar[str] = ContextVar("current_username", default="") +current_realname: ContextVar[str] = ContextVar("current_realname", default="") + +# ============================================================ +# 当前请求 trace_id +# ============================================================ +# 用于链路追踪,默认空字符串,由 trace 中间件写入 +current_trace_id: ContextVar[str] = ContextVar("current_trace_id", default="") diff --git a/src/core/database.py b/src/core/database.py new file mode 100644 index 0000000..ae47b8d --- /dev/null +++ b/src/core/database.py @@ -0,0 +1,121 @@ +# +====================================================================== +# | 模块: 数据库连接管理层 +# | 说明: 管理写/读引擎(读写分离)、会话工厂与声明式模型基类 +# +====================================================================== +"""数据库连接管理。 + +- 写引擎:config.database.DATABASE_URL +- 读引擎:config.database.READ_DATABASE_URL(读写分离,未配置则回落写库) +- Base:声明式模型基类 +""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:SQLAlchemy 引擎创建、声明式基类与会话工具、连接池类型、 +# 数据库配置(连接串 / 调试开关 / 读库连接串) +# ---------------------------------------------------------------------- +from sqlalchemy import create_engine +from sqlalchemy.orm import declarative_base, scoped_session, sessionmaker +from sqlalchemy.pool import NullPool + +from config.database import DATABASE_URL, DB_DEBUG, READ_DATABASE_URL + +# ============================================================ +# 连接池参数 +# ============================================================ +POOL_SIZE = 20 +POOL_RECYCLE = 3600 +POOL_TIMEOUT = 15 +MAX_OVERFLOW = 2 +CONNECT_TIMEOUT = 60 + + +# ============================================================ +# 提取连接串方言名 +# ============================================================ +def _dialect(url: str) -> str: + """从连接串提取方言名(如 sqlite:///... -> sqlite)。 + + :param url: 数据库连接串 + :return: 方言名(如 mysql / postgresql / sqlite) + """ + return url.split("://", 1)[0] + + +# ============================================================ +# 构造各驱动的连接参数 +# ============================================================ +def _connect_args(driver: str) -> dict: + """各 DBAPI 的连接参数(connect_timeout 等)。 + + :param driver: 方言名 + :return: 传给 DBAPI 的连接参数字典 + """ + # MySQL / PostgreSQL:仅需连接超时 + if driver.startswith("mysql") or driver.startswith("postgresql"): + return {"connect_timeout": CONNECT_TIMEOUT} + # MSSQL(pymssql):默认编码非 UTF-8,需显式指定,同时设置登录超时 + if driver.startswith("mssql"): + # pymssql 默认编码非 UTF-8,需显式指定 + return {"charset": "utf8", "login_timeout": CONNECT_TIMEOUT} + return {} # oracle(oracledb) / sqlite 使用驱动默认即可 + + +# ============================================================ +# 按方言创建引擎 +# ============================================================ +def _create_engine(url: str): + """按方言构建引擎:SQLite 无连接池并允许跨线程,其余走标准连接池。 + + :param url: 数据库连接串 + :return: SQLAlchemy Engine 实例 + """ + driver = _dialect(url) + # SQLite:禁用连接池并允许跨线程访问 + if driver.startswith("sqlite"): + return create_engine( + url, + connect_args={"check_same_thread": False}, + poolclass=NullPool, + echo=DB_DEBUG, + ) + # 其他方言:标准连接池配置 + return create_engine( + url, + pool_size=POOL_SIZE, + pool_recycle=POOL_RECYCLE, + pool_timeout=POOL_TIMEOUT, + max_overflow=MAX_OVERFLOW, + connect_args=_connect_args(driver), + pool_pre_ping=True, + pool_use_lifo=True, + echo_pool=False, + echo=DB_DEBUG, + ) + + +# ============================================================ +# 引擎实例 +# ============================================================ +# 写引擎 +engine = _create_engine(DATABASE_URL) +# 读引擎(读写分离,未配置回落写库) +reader_engine = _create_engine(READ_DATABASE_URL) if READ_DATABASE_URL else engine + +# ============================================================ +# 会话工厂 +# ============================================================ +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +ReaderSession = sessionmaker(autocommit=False, autoflush=False, bind=reader_engine) + +# ============================================================ +# 默认会话(thread-local 作用域) +# ============================================================ +# 服务层/仓储统一使用 db,finally 中 close 后下次使用自动重建, +# 避免调度线程/事件循环线程共享同一 Session 引发并发问题。 +db = scoped_session(SessionLocal) + +# ============================================================ +# 声明式模型基类 +# ============================================================ +Base = declarative_base() diff --git a/src/core/exceptions.py b/src/core/exceptions.py new file mode 100644 index 0000000..1e1272f --- /dev/null +++ b/src/core/exceptions.py @@ -0,0 +1,119 @@ +# +====================================================================== +# | 模块: 统一异常体系层 +# | 说明: 定义应用异常基类与各业务异常子类,由 BaseHandler.write_error +# | 统一转换为 {code, data, msg} 响应 +# +====================================================================== +"""统一异常体系。 + +所有业务异常继承 BaseAppError;BaseHandler.write_error 会将其转换为 +统一响应 {code, data, msg}。 +""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:类型标注 +# ---------------------------------------------------------------------- +from typing import Optional + + +# ====================================================================== +# 应用异常基类 +# ====================================================================== +class BaseAppError(Exception): + """应用异常基类。 + + 子类通过覆盖 code / status_code / msg 定义具体异常语义。 + """ + + # ============================================================ + # 默认属性 + # ============================================================ + code: int = 1 # 业务状态码 + status_code: int = 200 # HTTP 状态码 + msg: str = "操作失败" # 默认错误提示 + + # ============================================================ + # 初始化 + # ============================================================ + def __init__(self, msg: Optional[str] = None, code: Optional[int] = None): + """初始化异常:可覆盖默认提示文案与业务状态码。 + + :param msg: 自定义错误提示,为 None 时使用类默认 msg + :param code: 自定义业务状态码,为 None 时使用类默认 code + """ + if msg is not None: + self.msg = msg + if code is not None: + self.code = code + super().__init__(self.msg) + + +# ====================================================================== +# 业务逻辑异常 +# ====================================================================== +class BusinessError(BaseAppError): + """业务逻辑异常。""" + + msg = "操作失败" + + +# ====================================================================== +# 参数校验异常 +# ====================================================================== +class ValidationError(BaseAppError): + """参数校验异常。""" + + msg = "参数校验失败" + + +# ====================================================================== +# 记录不存在异常 +# ====================================================================== +class NotFoundError(BaseAppError): + """记录不存在。""" + + code = 404 + status_code = 404 + msg = "记录不存在" + + +# ====================================================================== +# 认证异常 +# ====================================================================== +class AuthError(BaseAppError): + """认证异常(登录过期 / 未登录)。""" + + code = 401 + status_code = 401 + msg = "登录过期" + + +# ====================================================================== +# 权限不足异常 +# ====================================================================== +class PermissionDeniedError(BaseAppError): + """权限不足。""" + + code = 403 + status_code = 403 + msg = "权限不足" + + +# ====================================================================== +# 演示模式异常 +# ====================================================================== +class DemoModeError(BaseAppError): + """演示环境,写操作被禁止。""" + + msg = "演示环境,暂无操作权限" + + +# ====================================================================== +# 服务器内部错误异常 +# ====================================================================== +class ServerError(BaseAppError): + """服务器内部错误。""" + + code = 500 + status_code = 500 + msg = "服务器内部错误" diff --git a/src/core/jwt.py b/src/core/jwt.py new file mode 100644 index 0000000..8052cae --- /dev/null +++ b/src/core/jwt.py @@ -0,0 +1,612 @@ +# +====================================================================== +# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] +# +---------------------------------------------------------------------- +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 +# +---------------------------------------------------------------------- +# | 开源协议: Apache-2.0 +# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 +# +---------------------------------------------------------------------- +# | 官方网站: https://www.djangoadmin.cn +# +---------------------------------------------------------------------- +# | 研发团队: 小蚂蚁云团队 荣誉出品 +# +====================================================================== +# | 版权和免责声明: +# | 一、知识产权声明 +# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 +# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, +# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 +# | 二、使用限制与禁止条款 +# | 您理解并同意,禁止将本产品用于以下任何行为: +# | 1. 任何违反中华人民共和国法律法规的用途; +# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; +# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 +# | 三、责任豁免 +# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 +# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 +# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 +# | 四、违约与法律责任 +# | 如发现任何违反本声明的行为,本团队有权: +# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; +# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; +# | 3. 保留进一步采取刑事控告等法律措施的权利。 +# | 五、许可范围说明 +# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 +# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 +# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 +# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 +# | 成的一切经济损失及商誉损害。 +# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 +# | 许可协议》附件为准。 +# +====================================================================== + +""" +JWT令牌处理模块 +============================================================= + +该模块提供基于JWT(JSON Web Token)的令牌生成和验证功能。 +主要用于用户登录认证,生成访问令牌,并在后续请求中验证令牌的有效性。 +支持令牌过期机制,确保系统的安全性。 + +主要功能: +1. JWT令牌生成(create_token)- 用于用户登录成功后生成访问令牌 +2. JWT令牌解析验证(parse_payload)- 验证请求中的令牌有效性 +3. 统一的异常处理,返回标准格式的验证结果 +4. 可配置的令牌过期时间 + +所属包: core +所在文件: core/jwt.py +依赖模块: jwt - JWT处理第三方库 + datetime - 时间处理模块 + hashlib - 摘要算法模块 + config.auth - JWT 密钥/过期时间(.env 管理,启动 fail-close 校验) + redis - 令牌黑名单存储(同步客户端) + logging - 日志记录模块 +""" + +import datetime +import hashlib +import logging +import time + +import jwt +from jwt import exceptions + +from config.auth import JWT_EXPIRE_MINUTES, JWT_SECRET + +# 配置日志记录器 +logger = logging.getLogger(__name__) + + +# ============================================================= +# JWT密钥配置 +# ============================================================= + +def _validate_key_strength(key: str) -> None: + """ + 启动时校验 JWT 密钥强度 + + 校验规则: + 1. 密钥不能为空 + 2. 密钥长度至少 32 字节(256 位) + + 校验失败直接抛出 RuntimeError,拒绝启动应用。 + """ + if not key: + raise RuntimeError( + "JWT_SECRET 未配置!请在 .env 中设置至少32字节的强随机密钥。\n" + "生成方式:python -c \"import secrets; print(secrets.token_urlsafe(48))\"" + ) + + key_bytes = len(key.encode('utf-8')) + if key_bytes < 32: + raise RuntimeError( + f"JWT_SECRET 长度不足:{key_bytes} 字节,至少需要 32 字节(256位)。\n" + "生成方式:python -c \"import secrets; print(secrets.token_urlsafe(48))\"" + ) + + logger.debug(f"JWT密钥校验通过,长度:{key_bytes}字节") + + +# 签名密钥与过期时间取自 config.auth(启动时已 fail-close 强度校验) +JWT_SALT = JWT_SECRET +JWT_ALGORITHM = 'HS256' +DEFAULT_TIMEOUT_MINUTES = JWT_EXPIRE_MINUTES + +# 启动时校验密钥强度(第二道独立防护) +_validate_key_strength(JWT_SALT) + + +# ============================================================= +# JWT核心功能函数 +# ============================================================= + +def create_token(payload, timeout=None): + """ + 生成JWT令牌 + ============================================================= + + 根据传入的载荷数据生成JWT访问令牌,自动添加过期时间。 + 使用强密钥确保令牌安全性,符合RFC 7518安全规范。 + + 参数: + payload (dict): JWT载荷数据,通常包含用户ID、用户名等信息 + timeout (int): 令牌过期时间(分钟),默认使用DEFAULT_TIMEOUT_MINUTES + + 返回: + str: 生成的JWT令牌字符串 + + 使用示例: + >>> payload = {'userId': 1001, 'username': 'admin'} + >>> token = create_token(payload, timeout=30) + >>> print(token) + eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9... + + 注意事项: + 1. 密钥长度必须至少32字节,否则会发出警告 + 2. 生产环境建议通过环境变量设置JWT_SALT + 3. 令牌过期后需要重新登录获取新令牌 + """ + try: + # 设置过期时间 + # 说明:在载荷中添加exp字段,指定令牌的过期时间点 + # 计算方式:当前UTC时间 + 指定的分钟数 + if timeout is None: + timeout = DEFAULT_TIMEOUT_MINUTES + + # 创建payload副本,避免修改原始数据 + payload_copy = payload.copy() + + # 添加过期时间(exp)和签发时间(iat) + # exp: 过期时间,超过此时间令牌无效 + # iat: 签发时间,用于令牌生命周期计算 + current_time = datetime.datetime.now(tz=datetime.timezone.utc) + payload_copy['exp'] = current_time + datetime.timedelta(minutes=timeout) + payload_copy['iat'] = current_time + + # 声明类型和加密算法 + # 说明:定义JWT头部信息,包含令牌类型和加密算法 + headers = { + "typ": "JWT", # 令牌类型 + "alg": JWT_ALGORITHM # 加密算法 + } + + # 生成JWT令牌 + # 函数:jwt.encode() + # 参数说明: + # payload: JWT载荷数据 + # key: 加密密钥(盐) + # algorithm: 加密算法(HS256) + # headers: JWT头部信息 + token = jwt.encode( + payload=payload_copy, + key=JWT_SALT, + algorithm=JWT_ALGORITHM, + headers=headers + ) + + # 记录日志(可选,生产环境建议使用DEBUG级别) + logger.debug(f"生成JWT令牌成功,过期时间:{timeout}分钟") + + return token + + except Exception as e: + # 捕获并记录生成令牌时的异常 + logger.error(f"生成JWT令牌失败: {str(e)}") + raise + + +def parse_payload(token): + """ + 解析验证JWT令牌 + ============================================================= + + 验证传入的JWT令牌,检查签名有效性和过期状态。 + 统一处理各种异常情况,返回标准格式的结果对象。 + + 参数: + token (str): 需要验证的JWT令牌字符串 + + 返回: + dict: 验证结果,包含以下字段: + code (int): 状态码,0-成功,1-失败 + data (dict/None): 验证成功时返回载荷数据,失败时为None + msg (str): 提示信息 + + 使用示例: + >>> result = parse_payload(token) + >>> if result['code'] == 0: + ... user_id = result['data']['userId'] + ... username = result['data']['username'] + ... else: + ... error_msg = result['msg'] + ... # 处理令牌失效,跳转登录页 + + 异常处理说明: + - ExpiredSignatureError: 令牌已过期,需要重新登录 + - DecodeError: 解码失败,令牌格式错误或签名无效 + - InvalidTokenError: 其他JWT相关异常 + """ + # 初始化返回结果 + # 说明:定义统一返回格式,默认成功状态 + result = { + "code": 0, + "data": None, + "msg": "操作成功" + } + + # 参数校验 + if not token: + result['code'] = 1 + result['msg'] = "token不能为空" + return result + + try: + # 解密验证JWT令牌 + # 函数:jwt.decode() + # 参数说明: + # jwt: 令牌字符串 + # key: 解密密钥(必须与加密时使用的密钥一致) + # algorithms: 允许的算法列表 + # + # 说明:jwt.decode会自动完成以下验证: + # 1. 签名验证 - 确保令牌未被篡改 + # 2. 过期时间验证 - 检查exp字段是否大于当前时间 + # 3. 算法验证 - 确保使用正确的加密算法 + verified_payload = jwt.decode( + token, + JWT_SALT, + algorithms=[JWT_ALGORITHM] + ) + + # 验证成功,将载荷数据存入返回结果 + # 注意:返回的数据中不包含敏感信息(如密码等) + result['data'] = verified_payload + + # 可选:记录验证成功的日志(生产环境建议使用DEBUG级别) + logger.debug(f"JWT令牌验证成功,用户: {verified_payload.get('username', 'unknown')}") + + except exceptions.ExpiredSignatureError as e: + # 令牌已过期异常处理 + # 说明:令牌超过设置的exp时间,需要重新登录获取新令牌 + result['code'] = 1 + result['msg'] = "token已失效,请重新登录" + logger.info(f"JWT令牌已过期: {str(e)}") + + except exceptions.DecodeError as e: + # 令牌解码失败异常处理 + # 说明:令牌格式错误或签名验证失败,可能被篡改 + result['code'] = 1 + result['msg'] = "token认证失败,无效的令牌格式" + logger.warning(f"JWT令牌解码失败: {str(e)}") + + except exceptions.InvalidTokenError as e: + # 无效令牌异常处理 + # 说明:捕获其他所有JWT相关异常,如算法不匹配等 + result['code'] = 1 + result['msg'] = "非法的token,请检查令牌有效性" + logger.warning(f"无效的JWT令牌: {str(e)}") + + except Exception as e: + # 其他未预期的异常处理 + result['code'] = 1 + result['msg'] = "token验证失败,系统异常" + logger.error(f"JWT验证系统异常: {str(e)}") + + return result + + +async def refresh_token(token, extend_minutes=None): + """ + 刷新JWT令牌 + ============================================================= + + 验证现有令牌有效性,如果有效则生成新的令牌,延长有效期。 + 令牌轮换:刷新后会将旧令牌立即加入黑名单,防止其在有效期内继续使用。 + + 参数: + token (str): 需要刷新的JWT令牌字符串 + extend_minutes (int): 延长的分钟数,默认使用DEFAULT_TIMEOUT_MINUTES + + 返回: + dict: 刷新结果,包含以下字段: + code (int): 状态码,0-成功,1-失败 + data (dict/None): 成功时包含新令牌,失败时为None + msg (str): 提示信息 + + 使用示例: + >>> result = await refresh_token(old_token, extend_minutes=30) + >>> if result['code'] == 0: + ... new_token = result['data']['access_token'] + ... else: + ... # 令牌无效,需要重新登录 + ... error_msg = result['msg'] + + 注意事项: + 1. 令牌只有在有效期内才能刷新 + 2. 令牌轮换:刷新后签发全新令牌,并将旧令牌立即加入黑名单失效(依赖 Redis) + 3. 建议在令牌剩余有效期少于一半时进行刷新 + """ + if extend_minutes is None: + extend_minutes = DEFAULT_TIMEOUT_MINUTES + + # 先验证原令牌 + result = parse_payload(token) + + # 如果验证失败,返回错误信息 + if result['code'] != 0: + return { + "code": 1, + "data": None, + "msg": f"令牌无效,无法刷新: {result['msg']}" + } + + # 已注销(拉黑)的令牌禁止刷新:refresh 轮换会签发新 token 并拉黑旧 token, + # 若不检查入参黑名单,攻击者可用已注销 token 无限轮换出新 token,绕过登出/吊销 + if is_token_blacklisted(token): + return { + "code": 1, + "data": None, + "msg": "令牌已注销,无法刷新,请重新登录" + } + + try: + # 获取原载荷数据(不包含exp、iat等JWT标准字段) + payload = result['data'] + + # 移除JWT标准字段,避免重复添加 + # 注意:保留原始业务数据,移除系统字段 + clean_payload = {k: v for k, v in payload.items() + if k not in ['exp', 'iat', 'nbf']} + + # 生成新令牌 + new_token = create_token(clean_payload, timeout=extend_minutes) + + # 令牌轮换:将旧令牌加入黑名单立即失效,防止旧令牌在有效期内继续使用 + blacklisted = add_token_to_blacklist(token) + if not blacklisted: + logger.warning("旧令牌加入黑名单失败,刷新后旧令牌仍可能在有效期内可用") + + logger.info(f"JWT令牌刷新成功,延长{extend_minutes}分钟,旧令牌已注销") + + return { + "code": 0, + "data": { + "access_token": new_token, + "expires_in": extend_minutes * 60 # 转换为秒 + }, + "msg": "令牌刷新成功" + } + + except Exception as e: + logger.error(f"刷新JWT令牌失败: {str(e)}") + return { + "code": 1, + "data": None, + "msg": "令牌刷新失败,请重新登录" + } + + +def get_token_expiration(token): + """ + 获取JWT令牌的过期时间 + + 验证签名后读取过期时间,确保token未被篡改。 + 不过验证过期状态(verify_exp=False),以便调用方 + 读取已过期token的过期时间用于剩余时间计算等场景。 + + Args: + token (str): JWT令牌字符串 + + Returns: + datetime/None: 令牌过期时间(UTC),解析失败返回None + """ + try: + # 验证签名确保token未被篡改,但不验证过期状态 + payload = jwt.decode( + token, + JWT_SALT, + algorithms=[JWT_ALGORITHM], + options={"verify_exp": False} + ) + + exp_timestamp = payload.get('exp') + if exp_timestamp: + return datetime.datetime.fromtimestamp(exp_timestamp, tz=datetime.timezone.utc) + return None + + except Exception as e: + logger.warning(f"获取JWT过期时间失败: {str(e)}") + return None + + +def get_access_token(request): + """ + 从请求头中提取 access_token + + Args: + request: 任意含 .headers 的请求对象(Tornado 的 HTTPServerRequest / Starlette 请求均可) + + Returns: + tuple: (success, token, message) + """ + try: + auth_header = request.headers.get('Authorization', '') + if not auth_header: + return False, None, '缺少 Authorization 头' + + # Bearer 认证方案前缀大小写不敏感(RFC 7235),兼容 'Bearer'/'bearer'/'BEARER' + stripped = auth_header.strip() + if stripped.lower().startswith('bearer '): + access_token = stripped[7:].strip() + else: + access_token = stripped + if not access_token: + return False, None, 'token 为空' + + return True, access_token, 'success' + + except Exception as e: + logger.error(f"获取 access_token 异常: {str(e)}") + return False, None, f'token 提取异常: {str(e)}' + + +# token解析工具类 +def parse_token(request): + """ + 解析请求头中的token,返回解析结果 + + Args: + request: 任意含 .headers 的请求对象(Tornado 的 HTTPServerRequest / Starlette 请求均可) + + Returns: + dict: 包含解析结果和用户信息的字典 + """ + result = { + 'code': 1, + 'msg': 'token无效', + 'data': { + 'userId': 0, + 'username': None + } + } + + try: + success, access_token, msg = get_access_token(request) + if not success: + result['msg'] = msg + return result + + parse_result = parse_payload(access_token) + + if not isinstance(parse_result, dict): + result['msg'] = '解析结果格式错误' + return result + + code = parse_result.get('code', 1) + if code != 0: + result['msg'] = parse_result.get('msg', 'token验证失败') + return result + + data = parse_result.get('data', {}) + if not isinstance(data, dict): + result['msg'] = '数据格式错误' + return result + + result['code'] = 0 + result['msg'] = 'success' + result['data']['userId'] = data.get('userId', 0) + result['data']['username'] = data.get('username', '') + result['data']['realname'] = data.get('realname', '') + + except Exception as e: + result['msg'] = f'token解析异常: {str(e)}' + + return result + + +# ============================================================= +# Token黑名单功能 +# ============================================================= + +# 黑名单Key前缀,用于Redis中存储被注销的Token +_TOKEN_BLACKLIST_PREFIX = 'token:blacklist:' + +# 黑名单检查告警节流:首次失败记 ERROR,随后每 _BLACKLIST_ALERT_INTERVAL 秒至多一条 ERROR,期间重复失败降为 DEBUG +_BLACKLIST_ALERT_INTERVAL = 60.0 +_blacklist_check_last_alert = 0.0 + + +def _log_blacklist_check_alert(error: Exception) -> None: + """节流记录黑名单检查失败告警,避免 Redis 故障期间每个请求刷一条 ERROR。""" + global _blacklist_check_last_alert + now = time.monotonic() + if now - _blacklist_check_last_alert >= _BLACKLIST_ALERT_INTERVAL: + _blacklist_check_last_alert = now + logger.error( + "Token黑名单检查异常,已按 fail-open 放行(已注销的令牌可能被误放行): %s", error) + else: + logger.debug("Token黑名单检查异常(节流中,跳过 ERROR): %s", error) + + +def _token_fingerprint(token: str) -> str: + """ + 生成Token指纹(SHA256摘要) + + 避免将完整Token存入Redis,仅存储摘要值,减少泄露风险。 + + Args: + token: JWT令牌字符串 + + Returns: + str: SHA256十六进制摘要 + """ + return hashlib.sha256(token.encode('utf-8')).hexdigest() + + +def add_token_to_blacklist(token: str) -> bool: + """ + 将Token加入黑名单 + + 退出登录时调用,使Token立即失效。黑名单条目的TTL与Token剩余有效期一致, + Token自然过期后黑名单条目自动清除,避免Redis内存无限增长。 + + 使用项目统一的同步 Redis 客户端(core.redis),与全站同步 DB/Redis 调用风格一致。 + + Args: + token: JWT令牌字符串 + + Returns: + bool: 是否成功加入黑名单 + """ + try: + from core.redis import redis + + # 获取Token剩余有效期 + expiration = get_token_expiration(token) + if expiration is None: + # 无法解析过期时间,使用默认过期时间作为TTL + ttl_seconds = DEFAULT_TIMEOUT_MINUTES * 60 + else: + ttl_seconds = int((expiration - datetime.datetime.now(tz=datetime.timezone.utc)).total_seconds()) + if ttl_seconds <= 0: + # Token已自然过期,无需加入黑名单 + return True + + fingerprint = _token_fingerprint(token) + key = f'{_TOKEN_BLACKLIST_PREFIX}{fingerprint}' + redis.set(key, '1', ex=ttl_seconds) + + logger.info(f"Token已加入黑名单,TTL={ttl_seconds}秒") + return True + + except Exception as e: + logger.error(f"Token加入黑名单失败: {str(e)}") + return False + + +def is_token_blacklisted(token: str) -> bool: + """ + 检查Token是否在黑名单中 + + 在登录验证中间件中调用,拒绝已被注销的Token。 + + 使用项目统一的同步 Redis 客户端(core.redis),与全站同步 DB/Redis 调用风格一致。 + + Args: + token: JWT令牌字符串 + + Returns: + bool: True-已被注销(在黑名单中),False-有效 + """ + try: + from core.redis import redis + + fingerprint = _token_fingerprint(token) + key = f'{_TOKEN_BLACKLIST_PREFIX}{fingerprint}' + return bool(redis.exists(key)) + + except Exception as e: + # fail-open 权衡:Redis 不可用时放行,避免全部请求被拒;代价是已注销的令牌可能被放行。 + # 属安全降级,需告警;节流记录避免 Redis 故障期间日志刷屏。 + _log_blacklist_check_alert(e) + return False diff --git a/src/core/logger.py b/src/core/logger.py new file mode 100644 index 0000000..80fb89b --- /dev/null +++ b/src/core/logger.py @@ -0,0 +1,34 @@ +# +====================================================================== +# | 模块: 应用日志入口层 +# | 说明: 初始化全局日志配置,并对外提供日志器获取方法 +# +====================================================================== +"""应用日志入口。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:标准日志模块、日志配置初始化函数 +# ---------------------------------------------------------------------- +import logging + +from config.logging import setup_logging + +# ============================================================ +# 初始化日志配置 +# ============================================================ +# 模块导入时即完成日志配置初始化,保证后续日志输出生效 +setup_logging() + +# 全局默认日志器(应用内通用) +logger = logging.getLogger("djangoadmin") + + +# ============================================================ +# 获取子日志器 +# ============================================================ +def get_logger(name: str) -> logging.Logger: + """获取指定名称的子日志器。 + + :param name: 日志器名称(通常传入模块名 __name__) + :return: 对应名称的 Logger 实例 + """ + return logging.getLogger(name) diff --git a/src/core/metrics.py b/src/core/metrics.py new file mode 100644 index 0000000..53b790a --- /dev/null +++ b/src/core/metrics.py @@ -0,0 +1,9 @@ +# +====================================================================== +# | 模块: 指标收集层 +# | 说明: 预留应用指标收集能力,后续接入 prometheus-client 实现 +# +====================================================================== +"""指标收集(占位)。 + +TODO: 接入 prometheus-client 后在此实现请求计数、耗时直方图等指标, +并通过 /api/v1/metrics 端点暴露。 +""" diff --git a/src/core/password.py b/src/core/password.py new file mode 100644 index 0000000..1074d17 --- /dev/null +++ b/src/core/password.py @@ -0,0 +1,63 @@ +# +====================================================================== +# | 模块: 密码加密工具层 +# | 说明: 基于 bcrypt 的双重加盐方案,提供盐值生成、密码加密与校验 +# +====================================================================== +"""密码加密工具:基于 bcrypt 的双重加盐方案。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:安全随机数生成、bcrypt 加密库 +# ---------------------------------------------------------------------- +import secrets + +import bcrypt + +# ============================================================ +# 加密参数 +# ============================================================ +# bcrypt 工作因子,值越大计算越慢越安全,默认12(约250ms) +BCRYPT_ROUNDS = 12 + + +# ============================================================ +# 生成随机盐值 +# ============================================================ +def generate_salt(length: int = 10) -> str: + """生成随机盐值。 + + :param length: 盐值长度,默认 10 + :return: URL 安全的随机字符串 + """ + return secrets.token_urlsafe(length)[:length] + + +# ============================================================ +# 加密密码 +# ============================================================ +def encrypt_password(password: str, salt: str) -> str: + """使用 bcrypt 加密密码(外部 salt 双重加盐)。 + + :param password: 明文密码 + :param salt: 外部盐值(由 generate_salt 生成) + :return: 加密后的密码哈希字符串 + """ + # 密码与外部盐拼接后再交给 bcrypt 内部加盐 + combined = (password + salt).encode('utf-8') + hashed = bcrypt.hashpw(combined, bcrypt.gensalt(rounds=BCRYPT_ROUNDS)) + return hashed.decode('utf-8') + + +# ============================================================ +# 校验密码 +# ============================================================ +def verify_password(password: str, salt: str, hashed: str) -> bool: + """验证密码是否正确。 + + :param password: 待校验的明文密码 + :param salt: 加密时使用的外部盐值 + :param hashed: 已存储的密码哈希 + :return: 校验通过返回 True,否则 False + """ + # 按加密时相同的拼接方式重组,再交由 bcrypt 校验 + combined = (password + salt).encode('utf-8') + return bcrypt.checkpw(combined, hashed.encode('utf-8')) \ No newline at end of file diff --git a/src/core/redis.py b/src/core/redis.py new file mode 100644 index 0000000..59b512e --- /dev/null +++ b/src/core/redis.py @@ -0,0 +1,27 @@ +# +====================================================================== +# | 模块: Redis 客户端层 +# | 说明: 基于配置创建全局单例 Redis 客户端,供缓存/会话等使用 +# +====================================================================== +"""Redis 客户端(全局单例)。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:redis 客户端库、Redis 连接配置 +# ---------------------------------------------------------------------- +import redis as redis_lib + +from config.redis import (REDIS_DECODE_RESPONSES, REDIS_HOST, REDIS_INDEX, + REDIS_PASSWORD, REDIS_PORT) + +# ============================================================ +# 全局 Redis 客户端实例 +# ============================================================ +redis = redis_lib.Redis( + host=REDIS_HOST, + port=REDIS_PORT, + # 密码为空时传 None,避免空字符串被当作有效密码 + password=REDIS_PASSWORD or None, + db=REDIS_INDEX, + # 是否自动解码响应(True 时返回 str,False 时返回 bytes) + decode_responses=REDIS_DECODE_RESPONSES, +) diff --git a/src/core/response.py b/src/core/response.py new file mode 100644 index 0000000..c1aedf3 --- /dev/null +++ b/src/core/response.py @@ -0,0 +1,140 @@ +# +====================================================================== +# | 模块: 统一响应结果层 +# | 说明: 定义所有 API 的标准返回格式 {code, data, msg, ok}, +# | 并提供成功、失败与通用响应生成函数(Tornado 适配) +# +====================================================================== +"""统一响应结果:所有 API 返回 {code, data, msg, ok} 标准格式。 + +Tornado 适配:写完响应(self.finish)并把结果 dict 保存到 self._api_result, +供操作日志/登录日志等装饰器解析业务状态。函数统一返回 None —— 避免 Tornado +对 handler 方法返回值执行 await,触发 "object dict can't be used in 'await' expression"。 +""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:类型标注、统一提示文案常量 +# ---------------------------------------------------------------------- +from typing import Any, Dict, Optional + +from constants import MESSAGE_FAIL, MESSAGE_OK + + +# ====================================================================== +# 常用状态码常量 +# ====================================================================== +class Codes: + """常用状态码定义。""" + + SUCCESS = 0 # 成功 + FAILED = 1 # 通用失败 + UNAUTHORIZED = 401 # 未认证 + FORBIDDEN = 403 # 无权限 + NOT_FOUND = 404 # 资源不存在 + VALIDATE_ERROR = 422 # 验证错误 + SERVER_ERROR = 500 # 服务器错误 + + +# ====================================================================== +# 内部响应写出 +# ====================================================================== +def _write(self, body: Dict[str, Any]) -> None: + """写出响应并保存结果 dict 供日志装饰器解析(返回 None,勿改)。 + + :param self: Tornado 请求处理器 + :param body: 待写出的响应体字典 + """ + # 保存结果供日志装饰器解析业务状态 + self._api_result = body + self.finish(body) + + +# ====================================================================== +# 成功响应 +# ====================================================================== +def ok(self, data: Optional[Any] = None, msg: str = MESSAGE_OK, code: int = Codes.SUCCESS, + **kwargs: Any) -> None: + """生成成功响应,支持通过 kwargs 添加额外字段(如 count)。 + + :param self: Tornado 请求处理器 + :param data: 返回数据 + :param msg: 提示文案,默认成功提示 + :param code: 业务状态码,默认 0 + :param kwargs: 额外字段(如 count=总数) + """ + body: Dict[str, Any] = {"code": code, "data": data, "msg": msg, "ok": True} + # 合并额外字段 + if kwargs: + body.update(kwargs) + _write(self, body) + + +# ====================================================================== +# 失败响应 +# ====================================================================== +def failed(self, msg: str = MESSAGE_FAIL, code: int = Codes.FAILED, + data: Optional[Any] = None, **kwargs: Any) -> None: + """生成失败响应。 + + :param self: Tornado 请求处理器 + :param msg: 错误提示文案,默认失败提示 + :param code: 业务状态码,默认 1 + :param data: 返回数据,默认 None + :param kwargs: 额外字段 + """ + body: Dict[str, Any] = {"code": code, "data": data, "msg": msg, "ok": False} + # 合并额外字段 + if kwargs: + body.update(kwargs) + _write(self, body) + + +# ====================================================================== +# 分页响应 +# ====================================================================== +def page(self, data: list, total: int, current: int, size: int, + msg: str = MESSAGE_OK, code: int = Codes.SUCCESS) -> None: + """分页响应:封装 {records, total, size, current, pages} 标准分页结构。 + + :param self: Tornado 请求处理器 + :param data: 当前页数据列表 + :param total: 总记录数 + :param current: 当前页码 + :param size: 每页大小 + :param msg: 提示文案 + :param code: 业务状态码 + """ + pages = (total + size - 1) // size if size > 0 else 0 + body: Dict[str, Any] = { + "code": code, + "data": { + "records": data, + "total": total, + "size": size, + "current": current, + "pages": pages, + }, + "msg": msg, + "ok": True, + } + _write(self, body) + + +# ====================================================================== +# 通用响应 +# ====================================================================== +def response(self, data: Optional[Any] = None, msg: str = MESSAGE_OK, code: int = Codes.SUCCESS, + success: bool = True, **kwargs: Any) -> None: + """通用响应生成器(高级用法),允许完全自定义字段。 + + :param self: Tornado 请求处理器 + :param data: 返回数据 + :param msg: 提示文案 + :param code: 业务状态码 + :param success: 业务是否成功,决定 ok 字段 + :param kwargs: 额外字段 + """ + body: Dict[str, Any] = {"code": code, "data": data, "msg": msg, "ok": success} + # 合并额外字段 + if kwargs: + body.update(kwargs) + _write(self, body) diff --git a/src/core/tracer.py b/src/core/tracer.py new file mode 100644 index 0000000..a8addb0 --- /dev/null +++ b/src/core/tracer.py @@ -0,0 +1,9 @@ +# +====================================================================== +# | 模块: 链路追踪层 +# | 说明: 预留分布式链路追踪能力,后续接入 OpenTelemetry 实现 +# +====================================================================== +"""链路追踪(占位)。 + +TODO: 接入 OpenTelemetry 后在此实现 span 创建与上下文传播, +与 common/middleware/trace_id.py 联动的分布式追踪。 +""" diff --git a/src/extensions/__init__.py b/src/extensions/__init__.py new file mode 100644 index 0000000..4580fd6 --- /dev/null +++ b/src/extensions/__init__.py @@ -0,0 +1,23 @@ +# +====================================================================== +# | 模块: 扩展包初始化层 +# | 说明: 集中初始化各扩展单例(数据库、Redis 等),并对外提供注册入口 +# +====================================================================== +"""扩展包:集中初始化各扩展单例。""" + + +# ====================================================================== +# 注册扩展 +# ====================================================================== +def register_extensions(app): + """应用扩展初始化(幂等,可扩展)。 + + 导入扩展模块即触发单例初始化(数据库会话、Redis 连接等)。 + + :param app: 应用实例(当前仅占位,返回原实例) + :return: 传入的应用实例 + """ + # 导入即触发单例初始化(数据库会话) + from extensions import sqlalchemy + # 导入即触发单例初始化(Redis 连接) + from extensions import redis + return app diff --git a/src/extensions/redis.py b/src/extensions/redis.py new file mode 100644 index 0000000..9e4b3e8 --- /dev/null +++ b/src/extensions/redis.py @@ -0,0 +1,16 @@ +# +====================================================================== +# | 模块: Redis 扩展层 +# | 说明: 对外导出全局 Redis 客户端实例,供扩展包统一初始化 +# +====================================================================== +"""Redis 扩展:导出全局客户端实例。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:核心层已创建的全局 Redis 客户端实例 +# ---------------------------------------------------------------------- +from core.redis import redis + +# ============================================================ +# 对外导出 +# ============================================================ +__all__ = ["redis"] diff --git a/src/extensions/sentry.py b/src/extensions/sentry.py new file mode 100644 index 0000000..bdfd942 --- /dev/null +++ b/src/extensions/sentry.py @@ -0,0 +1,24 @@ +# +====================================================================== +# | 模块: Sentry 错误监控扩展层 +# | 说明: 预留 Sentry 错误监控接入能力,当前为占位实现 +# +====================================================================== +"""Sentry 错误监控扩展(占位)。 + +TODO: 配置 SENTRY_DSN 后接入: + import sentry_sdk + sentry_sdk.init(dsn=..., traces_sample_rate=1.0) +""" + + +# ====================================================================== +# 初始化扩展 +# ====================================================================== +def init(app): + """初始化 Sentry 扩展(占位)。 + + TODO: 接入 Sentry 后在此调用 sentry_sdk.init(...) 完成初始化。 + + :param app: 应用实例(当前仅占位,返回原实例) + :return: 传入的应用实例 + """ + return app diff --git a/src/extensions/sqlalchemy.py b/src/extensions/sqlalchemy.py new file mode 100644 index 0000000..b25d625 --- /dev/null +++ b/src/extensions/sqlalchemy.py @@ -0,0 +1,26 @@ +# +====================================================================== +# | 模块: SQLAlchemy 扩展层 +# | 说明: 对外导出数据库会话、引擎与模型基类(模型基类已移至 core.base_model) +# +====================================================================== +"""SQLAlchemy 扩展:会话与模型基类导出。 + +模型基类已移至 core.base_model。 +""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:核心层的声明式基类、会话工厂、默认会话与读/写引擎 +# ---------------------------------------------------------------------- +from core.database import Base, SessionLocal, db, engine, reader_engine, ReaderSession + +# ============================================================ +# 对外导出 +# ============================================================ +__all__ = [ + "Base", + "db", + "SessionLocal", + "engine", + "reader_engine", + "ReaderSession", +] diff --git a/src/extensions/swagger.py b/src/extensions/swagger.py new file mode 100644 index 0000000..d864053 --- /dev/null +++ b/src/extensions/swagger.py @@ -0,0 +1,23 @@ +# +====================================================================== +# | 模块: Swagger 文档扩展层 +# | 说明: 预留 Swagger/OpenAPI 文档渲染能力,当前为占位实现 +# +====================================================================== +"""Swagger 文档扩展(占位)。 + +TODO: 接入 openapi-spec 生成,将 document/api/swagger.yaml 渲染到 +/api/v1/docs 页面。 +""" + + +# ====================================================================== +# 初始化扩展 +# ====================================================================== +def init(app): + """初始化 Swagger 文档扩展(占位)。 + + TODO: 接入后在此渲染 document/api/swagger.yaml 到 /api/v1/docs 页面。 + + :param app: 应用实例(当前仅占位,返回原实例) + :return: 传入的应用实例 + """ + return app diff --git a/src/modules/__init__.py b/src/modules/__init__.py new file mode 100644 index 0000000..26435b9 --- /dev/null +++ b/src/modules/__init__.py @@ -0,0 +1,13 @@ +# +====================================================================== +# | 模块: 业务模块包 +# | 说明: 声明 modules 为包,并约定各业务子模块的固定结构 +# +====================================================================== +"""业务模块。 + +每个模块固定结构: +- models.py SQLAlchemy 数据模型 +- schemas.py Pydantic 校验模型 +- repository.py 仓储(继承 core.base_repository.BaseRepository) +- service.py 业务逻辑 +- handlers.py 请求处理器(继承 core.base_handler.BaseHandler) +""" \ No newline at end of file diff --git a/src/modules/article/__init__.py b/src/modules/article/__init__.py new file mode 100644 index 0000000..4dc1d4d --- /dev/null +++ b/src/modules/article/__init__.py @@ -0,0 +1,5 @@ +# +====================================================================== +# | 模块: 文章管理包 +# | 说明: 声明 article 为包,用于组织文章模块的路由、处理器、服务与数据访问 +# +====================================================================== +"""文章管理模块。""" \ No newline at end of file diff --git a/src/modules/article/handlers.py b/src/modules/article/handlers.py new file mode 100644 index 0000000..bc80b59 --- /dev/null +++ b/src/modules/article/handlers.py @@ -0,0 +1,117 @@ +# +====================================================================== +# | 模块: 文章请求处理器层 +# | 说明: 承接文章相关的 HTTP 请求,校验权限/演示模式并记录操作日志后, +# | 转交文章服务层处理(分页、详情、增删改、批量删除等) +# +====================================================================== +"""文章请求处理器。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:权限/演示模式装饰器、操作日志装饰器、统一响应对象、 +# 请求处理基类、文章服务单例 +# ---------------------------------------------------------------------- +from common.middleware.access_decorators import check_demo, permission_required +from common.middleware.operation_log import operation_log +from core import response as R +from core.base_handler import BaseHandler +from modules.article.service import article_service + + +# ====================================================================== +# 查询文章分页数据 +# ====================================================================== +class ArticlePageHandler(BaseHandler): + """查询文章分页数据。""" + + @permission_required("sys:article:list") + async def get(self): + """GET 请求:返回文章分页列表。""" + return await article_service.get_page(self) + + +# ====================================================================== +# 查询文章详情 +# ====================================================================== +class ArticleDetailHandler(BaseHandler): + """查询文章详情。""" + + @permission_required("sys:article:detail") + async def get(self, id): + """GET 请求:按 ID 返回文章详情。 + + :param id: 文章 ID(路径参数) + """ + return R.ok(self, data=article_service.get_detail(id)) + + +# ====================================================================== +# 添加文章 +# ====================================================================== +class ArticleAddHandler(BaseHandler): + """添加文章。""" + + @permission_required("sys:article:add") + @operation_log("文章管理", "添加") + @check_demo + async def post(self): + """POST 请求:新增文章。""" + return await article_service.add(self) + + +# ====================================================================== +# 更新文章 +# ====================================================================== +class ArticleUpdateHandler(BaseHandler): + """更新文章。""" + + @permission_required("sys:article:update") + @operation_log("文章管理", "更新") + @check_demo + async def put(self): + """PUT 请求:更新文章信息。""" + return await article_service.update(self) + + +# ====================================================================== +# 删除文章 +# ====================================================================== +class ArticleDeleteHandler(BaseHandler): + """删除文章。""" + + @permission_required("sys:article:delete") + @operation_log("文章管理", "删除") + @check_demo + async def delete(self, id): + """DELETE 请求:按 ID 删除文章(软删除)。 + + :param id: 文章 ID(路径参数) + """ + return await article_service.delete(self, id) + + +# ====================================================================== +# 批量删除文章 +# ====================================================================== +class ArticleBatchDeleteHandler(BaseHandler): + """批量删除文章。""" + + @permission_required("sys:article:delete") + @operation_log("文章管理", "批量删除") + @check_demo + async def post(self): + """POST 请求:批量删除文章(软删除)。""" + return await article_service.batch_delete(self) + + +# ====================================================================== +# 设置文章状态 +# ====================================================================== +class ArticleStatusHandler(BaseHandler): + """设置文章状态。""" + + @permission_required("sys:article:status") + @operation_log("文章管理", "设置状态") + @check_demo + async def put(self): + """PUT 请求:修改文章状态(如上架 / 下架)。""" + return await article_service.update_status(self) diff --git a/src/modules/article/models.py b/src/modules/article/models.py new file mode 100644 index 0000000..048136d --- /dev/null +++ b/src/modules/article/models.py @@ -0,0 +1,49 @@ +# +====================================================================== +# | 模块: 文章数据模型层 +# | 说明: 定义文章表结构,继承 BaseModel 获得通用字段与序列化能力 +# +====================================================================== +"""文章数据模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:SQLAlchemy 列类型与函数、表名前缀配置、模型基类 +# ---------------------------------------------------------------------- +from sqlalchemy import Column, Integer, String, Text, text + +from config.database import DB_PREFIX +from core.base_model import BaseModel + + +# ====================================================================== +# 文章模型 +# ====================================================================== +class Article(BaseModel): + """文章模型。""" + + # ============================================================ + # 表定义 + # ============================================================ + __tablename__ = DB_PREFIX + "article" + __table_comment__ = "文章表" + + # ============================================================ + # 业务字段 + # ============================================================ + category_id = Column(Integer, nullable=True, index=True, comment="分类ID") + title = Column(String(150), nullable=True, index=True, comment="文章标题") + cover = Column(String(255), nullable=True, comment="文章封面") + images = Column(Text, nullable=True, comment="文章图集") + intro = Column(String(255), nullable=True, comment="文章导读") + content = Column(Text, nullable=True, comment="文章内容") + author = Column(String(100), nullable=True, comment="文章作者") + status = Column(Integer, nullable=False, default=0, server_default=text('0'), index=True, + comment="文章状态:0-正常 1-下架") + click = Column(Integer, nullable=False, default=0, server_default=text('0'), comment="文章点击率") + sort = Column(Integer, nullable=False, default=0, server_default=text('0'), comment="文章排序") + + # ============================================================ + # 调试输出 + # ============================================================ + def __str__(self): + """调试用字符串表示:返回文章标识。""" + return f"文章{self.id}" diff --git a/src/modules/article/repository.py b/src/modules/article/repository.py new file mode 100644 index 0000000..a49d4fc --- /dev/null +++ b/src/modules/article/repository.py @@ -0,0 +1,24 @@ +# +====================================================================== +# | 模块: 文章数据访问层 +# | 说明: 定义文章仓储,继承 BaseRepository 获得通用 CRUD 与软删除能力 +# +====================================================================== +"""文章数据访问。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:通用仓储基类、文章模型 +# ---------------------------------------------------------------------- +from core.base_repository import BaseRepository +from modules.article.models import Article + + +# ====================================================================== +# 文章仓储 +# ====================================================================== +class ArticleRepository(BaseRepository): + """文章仓储。""" + + # ============================================================ + # 绑定的模型类 + # ============================================================ + model = Article diff --git a/src/modules/article/schemas.py b/src/modules/article/schemas.py new file mode 100644 index 0000000..0e7abd5 --- /dev/null +++ b/src/modules/article/schemas.py @@ -0,0 +1,28 @@ +"""文章请求校验模型。""" +from typing import Optional + +from pydantic import BaseModel, Field + +from core.base_schema import BaseSchema + + +class ArticleSchema(BaseSchema): + """文章创建/编辑表单。""" + + categoryId: Optional[int] = Field(None, ge=0, description="分类ID") + title: str = Field(..., min_length=1, max_length=150, description="文章标题") + cover: Optional[str] = Field(None, max_length=255, description="文章封面") + images: Optional[str] = Field(None, description="文章图集") + intro: Optional[str] = Field(None, max_length=255, description="文章导读") + content: Optional[str] = Field(None, description="文章内容") + author: Optional[str] = Field(None, max_length=100, description="文章作者") + status: int = Field(default=0, ge=0, le=1, description="文章状态:0-正常 1-下架") + click: int = Field(0, ge=0, le=2147483647, description="文章点击率") + sort: int = Field(0, ge=0, le=99999, description="文章排序") + + +class ArticleStatusSchema(BaseModel): + """文章状态更新表单。""" + + id: int = Field(..., gt=0, description="文章ID") + status: int = Field(..., ge=0, le=1, description="文章状态:0-正常 1-下架") diff --git a/src/modules/article/service.py b/src/modules/article/service.py new file mode 100644 index 0000000..e9f625c --- /dev/null +++ b/src/modules/article/service.py @@ -0,0 +1,239 @@ +# +====================================================================== +# | 模块: 文章业务逻辑层 +# | 说明: 定义文章服务,覆盖分页序列化与媒体处理钩子, +# | 处理封面/图集/富文本,并导出服务单例(参考通知公告模块) +# +====================================================================== +"""文章业务逻辑(参考通知公告模块)。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:SQLAlchemy 条件表达式、富文本与文件工具、文件访问前缀配置、 +# 统一响应对象、业务服务基类、数据库会话、文章模型/仓储/校验模型、 +# 分类模型 +# ---------------------------------------------------------------------- +from sqlalchemy import and_ + +from common.utils.rich_text import get_file_url, save_content, save_file +from config.app import TORNADO_FILE_URL +from core import response as R +from core.base_service import BaseService +from core.database import db +from modules.article.models import Article +from modules.article.repository import ArticleRepository +from modules.article.schemas import ArticleSchema, ArticleStatusSchema +from modules.category.models import Category + + +# ====================================================================== +# 列表行加工:封面/图集完整地址 + 分类名 +# ====================================================================== +def _article_serialize_extra(item): + """文章行加工:封面/图集完整地址 + 分类名。 + + :param item: 文章模型实例 + :return: 需合并进序列化字典的额外字段 + """ + extra = {} + # 封面补全完整访问地址 + if item.cover: + extra['cover'] = get_file_url(item.cover) + # 图集(逗号分隔)逐项补全完整地址 + if item.images: + extra['images'] = [get_file_url(img) for img in item.images.split(',') if img] + # 关联分类名(过滤软删) + if item.category_id: + cate = db.query(Category).filter( + and_(Category.id == item.category_id, Category.is_delete == 0)).first() + if cate: + extra['categoryName'] = cate.category_name + return extra + + +# ====================================================================== +# 详情加工:富文本占位符还原 + 图集完整地址 +# ====================================================================== +def _article_detail_serialize(item): + """详情加工:富文本占位符还原为图片地址 + 图集完整地址。 + + :param item: 文章模型实例 + :return: 需合并进详情字典的额外字段 + """ + extra = {} + if item.content: + extra["content"] = item.content.replace("[IMG_URL]", TORNADO_FILE_URL) + # 图集:images 保留原始路径,imagesList 存完整 URL 数组 + if item.images: + extra["imagesList"] = [get_file_url(img) for img in item.images.split(',') if img] + else: + extra["imagesList"] = [] + return extra + + +# ====================================================================== +# 批量预取分类映射 +# ====================================================================== +def _batch_category_map(category_ids) -> dict: + """批量查询分类(过滤软删),返回 {id: Category},避免列表逐行 N+1 查询。 + + :param category_ids: 分类 ID 集合 + :return: {分类ID: Category 实例} 字典 + """ + # 去重并过滤空值 + ids = list({i for i in category_ids if i}) + if not ids: + return {} + rows = db.query(Category).filter( + and_(Category.id.in_(ids), Category.is_delete == 0)).all() + return {c.id: c for c in rows} + + +# ====================================================================== +# 文章服务 +# ====================================================================== +class ArticleService(BaseService): + """文章服务。""" + + # ============================================================ + # 基础声明 + # ============================================================ + model = Article + repo_cls = ArticleRepository + + # ============================================================ + # 差异点声明 + # ============================================================ + create_schema = ArticleSchema + update_schema = ArticleSchema + status_schema = ArticleStatusSchema + # 分页查询字段 + page_like_fields = ("title",) + page_eq_fields = ("status", "category_id") + page_order_by = (("id", "desc"),) + # 枚举显示名与序列化钩子 + serialize_maps = {"status": {0: "正常", 1: "下架"}} + serialize_extra = _article_serialize_extra + detail_serialize = _article_detail_serialize + + # ============================================================ + # 分页查询(覆写) + # ============================================================ + async def get_page(self, handler): + """分页查询:批量预取分类映射(消除逐行 N+1),列表剔除完整富文本 content。 + + :param handler: Tornado 请求处理器 + :return: 统一响应对象 + """ + try: + pageNo, pageSize = self._parse_pagination(handler) + query = self._build_page_query(handler) + items, total = self.repo.paginate(query, pageNo, pageSize) + # 超页自动回退第 1 页 + if pageNo > 1 and not items: + items, total = self.repo.paginate(query, 1, pageSize) + return R.page(handler, data=self._serialize_page(items), total=total, current=pageNo, size=pageSize) + finally: + self.repo.close() + + # ============================================================ + # 批量序列化列表 + # ============================================================ + def _serialize_page(self, items): + """批量序列化文章列表:单次查询预取分类,避免逐行查库。 + + :param items: 文章模型实例列表 + :return: 序列化后的字典列表 + """ + if not items: + return [] + # 单次预取分类映射,避免 N+1 + cate_map = _batch_category_map({i.category_id for i in items}) + status_map = self.serialize_maps.get('status') or {} + result = [] + for item in items: + data = item.to_dict() + # 列表不返回完整富文本,减小负载(详情才返回 content) + data.pop('content', None) + # 封面/图集补全完整地址 + if item.cover: + data['cover'] = get_file_url(item.cover) + if item.images: + data['images'] = [get_file_url(img) for img in item.images.split(',') if img] + # 关联分类名 + cate = cate_map.get(item.category_id) + if cate: + data['categoryName'] = cate.category_name + # 枚举显示名 + data['statusText'] = status_map.get(item.status) + result.append(data) + return result + + # ============================================================ + # 新增前钩子 + # ============================================================ + def _before_add(self, handler, data): + """新增前:处理封面/图集/富文本媒体。 + + :param handler: Tornado 请求处理器 + :param data: 待入库的表单数据字典 + """ + # 新增:表单字段全部视为显式提交 + self._handle_media(data, set(data.keys())) + + # ============================================================ + # 编辑前钩子 + # ============================================================ + def _before_update(self, handler, data): + """编辑前:按"显式提交"字段处理媒体,兼容"未传=保持原值 / 显式空=清空"。 + + :param handler: Tornado 请求处理器 + :param data: 待入库的表单数据字典 + """ + # 编辑:区分"未传=保持原值"与"显式空值=清空"(避免已设置的封面/图集无法清除) + # parse_json 有缓存,同一次请求内不重复解析 + from core.base_service import BaseService + raw = BaseService.parse_json(handler) + explicit = set(raw.keys()) if isinstance(raw, dict) else set() + self._handle_media(data, explicit) + + # ============================================================ + # 媒体处理(封面/图集/富文本) + # ============================================================ + @staticmethod + def _handle_media(data, explicit: set): + """按显式字段处理封面、图集与富文本内容。 + + :param data: 待入库的表单数据字典(原地修改) + :param explicit: 本次请求显式提交的字段名集合 + """ + # 封面:临时图片移动;显式传空串则清空,未传则保持原值 + # save_file 对外部(非本站)URL 返回空,此时保留原值而非清空 + if 'cover' in explicit: + cover = data.get('cover') + if cover: + data['cover'] = save_file(cover, "article") or cover + else: + data['cover'] = "" + # 图集:逗号分隔字符串,逐项迁移临时文件后重新拼接;显式传空串则清空,未传则保持原值 + if 'images' in explicit: + images = data.get('images') + if images: + saved = [] + for url in images.split(','): + url = url.strip() + if not url: + continue + img = save_file(url, "article") + saved.append(img or url) + data['images'] = ','.join(saved) + else: + data['images'] = "" + # 富文本内容:图片本地化 + content = data.get('content') + if content: + data['content'] = save_content(content, data.get('title', ''), "article") + + +# ====================================================================== +# 服务单例 +# ====================================================================== +article_service = ArticleService() diff --git a/src/modules/auth/__init__.py b/src/modules/auth/__init__.py new file mode 100644 index 0000000..14abd12 --- /dev/null +++ b/src/modules/auth/__init__.py @@ -0,0 +1,5 @@ +# +====================================================================== +# | 模块: 认证授权包 +# | 说明: 声明 auth 为包,用于组织认证模块的路由、处理器与服务 +# +====================================================================== +"""认证授权模块。""" diff --git a/src/modules/auth/handlers.py b/src/modules/auth/handlers.py new file mode 100644 index 0000000..3376342 --- /dev/null +++ b/src/modules/auth/handlers.py @@ -0,0 +1,71 @@ +# +====================================================================== +# | 模块: 认证请求处理器层 +# | 说明: 承接认证相关的 HTTP 请求(登录、验证码、令牌刷新), +# | 记录登录日志并转交认证服务/JWT 工具处理 +# +====================================================================== +"""认证请求处理器。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:登录日志装饰器与类型常量、统一响应对象、请求处理基类、 +# JWT 令牌解析与刷新工具、认证服务 +# ---------------------------------------------------------------------- +from common.middleware.login_log import LOGIN, LOGOUT, login_log +from core import response as R +from core.base_handler import BaseHandler +from core.jwt import get_access_token, refresh_token +from modules.auth import service + + +# ====================================================================== +# 系统登录 +# ====================================================================== +class LoginHandler(BaseHandler): + """系统登录。""" + + @login_log(LOGIN) + async def post(self): + """POST 请求:执行登录并记录登录日志。""" + return await service.login(self) + + +# ====================================================================== +# 获取图形验证码 +# ====================================================================== +class CaptchaHandler(BaseHandler): + """获取图形验证码。""" + + async def get(self): + """GET 请求:返回图形验证码。""" + return service.captcha(self) + + +# ====================================================================== +# 刷新令牌 +# ====================================================================== +class TokenRefreshHandler(BaseHandler): + """刷新令牌:校验当前令牌并签发新令牌(旧令牌立即加入黑名单失效)。""" + + async def post(self): + """POST 请求:校验当前令牌并签发新令牌。""" + # 从请求头解析访问令牌 + success, access_token, _ = get_access_token(self.request) + if not success or not access_token: + return R.failed(self, "缺少访问令牌") + # 刷新令牌(内部将旧令牌加入黑名单) + result = await refresh_token(access_token) + if result["code"] != 0: + return R.failed(self, result["msg"]) + return R.ok(self, data=result["data"], msg="令牌刷新成功") + + +# ====================================================================== +# 退出登录 +# ====================================================================== +class LogoutHandler(BaseHandler): + """退出登录:将当前令牌加入黑名单使其立即失效。""" + + @login_log(LOGOUT) + async def get(self): + """POST 请求:退出登录并记录登录日志。""" + return service.logout(self) diff --git a/src/modules/auth/models.py b/src/modules/auth/models.py new file mode 100644 index 0000000..c6988c6 --- /dev/null +++ b/src/modules/auth/models.py @@ -0,0 +1,8 @@ +# +====================================================================== +# | 模块: 认证模块数据模型层 +# | 说明: 认证复用用户模块的 User 模型,本文件仅为模块完整性保留 +# +====================================================================== +"""认证模块数据模型。 + +认证复用用户模块的 User 模型,本文件仅为模块完整性保留。 +""" diff --git a/src/modules/auth/repository.py b/src/modules/auth/repository.py new file mode 100644 index 0000000..0f4c778 --- /dev/null +++ b/src/modules/auth/repository.py @@ -0,0 +1,19 @@ +# +====================================================================== +# | 模块: 认证数据访问层 +# | 说明: 认证查询复用用户仓储,本模块仅做导出 +# +====================================================================== +"""认证数据访问。 + +认证查询复用用户仓储。 +""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:用户仓储(认证查询复用) +# ---------------------------------------------------------------------- +from modules.user.repository import UserRepository + +# ============================================================ +# 对外导出 +# ============================================================ +__all__ = ["UserRepository"] diff --git a/src/modules/auth/schemas.py b/src/modules/auth/schemas.py new file mode 100644 index 0000000..abcfc8e --- /dev/null +++ b/src/modules/auth/schemas.py @@ -0,0 +1,34 @@ +# +====================================================================== +# | 模块: 认证请求校验模型层 +# | 说明: 定义认证相关的 Pydantic 校验模型(登录请求等) +# +====================================================================== +"""认证请求校验模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:Pydantic 基类与字段定义、验证码长度配置 +# ---------------------------------------------------------------------- +from pydantic import BaseModel, Field + +from config.captcha import CAPTCHA_LENGTH + + +# ====================================================================== +# 登录请求 +# ====================================================================== +class LoginSchema(BaseModel): + """登录请求。""" + + # ============================================================ + # 表单字段 + # ============================================================ + username: str = Field(..., min_length=1, max_length=20, description="登录账号") + password: str = Field(..., min_length=6, max_length=128, description="登录密码") + code: str = Field(..., min_length=CAPTCHA_LENGTH, max_length=CAPTCHA_LENGTH, description="验证码") + key: str = Field(..., min_length=1, description="KEY值") + + # ============================================================ + # 模型配置 + # ============================================================ + # 忽略前端多余的字段,避免校验报错 + model_config = {"extra": "ignore"} diff --git a/src/modules/auth/service.py b/src/modules/auth/service.py new file mode 100644 index 0000000..2b4ebbe --- /dev/null +++ b/src/modules/auth/service.py @@ -0,0 +1,220 @@ +# +====================================================================== +# | 模块: 认证业务逻辑层 +# | 说明: 提供登录(含防爆破锁定、验证码校验、密码校验与 JWT 签发)、 +# | 验证码生成、退出登录等认证能力 +# +====================================================================== +"""认证业务逻辑。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:验证码与客户端 IP 工具、密码加密工具、演示模式与认证/验证码配置、 +# 统一响应对象、业务服务基类、JWT 令牌工具、日志器、Redis 客户端、 +# 登录校验模型、用户仓储 +# ---------------------------------------------------------------------- +from common.utils.captcha import generate_captcha, get_client_ip, verify_captcha +from common.utils.crypto import is_legacy_hash, set_password, verify_user_password +from config.auth import LOGIN_FAIL_MAX, LOGIN_LOCK_SECONDS +from config.captcha import CAPTCHA_BYPASS_CODE +from core import response as R +from core.base_service import BaseService +from core.jwt import add_token_to_blacklist, create_token, get_access_token +from core.logger import logger +from core.redis import redis +from modules.auth.schemas import LoginSchema +from modules.user.repository import UserRepository + + +# ====================================================================== +# 用户登录 +# ====================================================================== +async def login(self): + """用户登录:防爆破锁定校验 -> 校验验证码 -> 校验账号密码 -> 签发 JWT。 + + :param self: Tornado 请求处理器 + :return: 统一响应对象 + """ + json_data = BaseService.parse_json(self) + form = BaseService.validate(LoginSchema, json_data) + + # 获取客户端 IP(用于防爆破维度) + ip = get_client_ip(self) + + # 防爆破:同一 IP+用户名 连续失败达上限后锁定 + if _is_login_locked(ip, form.username): + remaining = _login_lock_remaining(ip, form.username) + return R.failed(self, f"登录失败次数过多,请{remaining}秒后再试") + + # 校验验证码(一次性使用,校验成功自动删除,防止重放) + # 验证码失败不计入登录锁定:验证码本身已有按 IP 限流,避免正常用户因误输被锁 + # 万能验证码跳过验证码校验 + if form.code == CAPTCHA_BYPASS_CODE: + pass + elif not verify_captcha(form.key, form.code): + return R.failed(self, "验证码不正确") + + # 查询用户 + user = UserRepository().get_by_username(form.username) + if not user: + _record_login_fail(ip, form.username) + # 统一文案,避免通过"用户不存在/密码不正确"枚举有效账号 + return R.failed(self, "用户名或密码错误") + + # 禁用账号拒绝登录(不累计失败次数,禁用账号无需再锁) + if user.status != 1: + return R.failed(self, "账号已被禁用,请联系管理员") + + # 密码校验:盐方案优先,兼容旧无盐 bcrypt(成功登录后自动升级为盐方案) + if not _verify_login_password(form.password, user): + _record_login_fail(ip, form.username) + return R.failed(self, "用户名或密码错误") + + # 登录成功:清零失败计数 + _clear_login_fail(ip, form.username) + + # 签发令牌 + access_token = create_token({ + "userId": user.id, + "username": user.username or "", + "realname": user.realname or "", + }) + return R.ok(self, msg="登录成功", data={"access_token": access_token}) + + +# ====================================================================== +# 登录防爆破:同一 IP+用户名 连续失败达上限后锁定一段时间 +# ====================================================================== + +# ============================================================ +# 构造登录失败计数 key +# ============================================================ +def _login_fail_key(ip: str, username: str) -> str: + """登录失败计数 Redis key(按 IP+用户名 维度)。 + + :param ip: 客户端 IP + :param username: 登录用户名 + :return: Redis key + """ + return f"login:fail:{ip}:{username}" + + +# ============================================================ +# 检查是否已锁定 +# ============================================================ +def _is_login_locked(ip: str, username: str) -> bool: + """检查该 IP+用户名 是否已被锁定(失败次数 >= 上限)。Redis 异常时降级为未锁定。 + + :param ip: 客户端 IP + :param username: 登录用户名 + :return: 已锁定返回 True,否则 False + """ + try: + count = redis.get(_login_fail_key(ip, username)) + return bool(count) and int(count) >= LOGIN_FAIL_MAX + except Exception: + # Redis 异常时降级放行,避免影响正常登录 + logger.warning("登录锁定检查异常,已降级放行 ip=%s", ip) + return False + + +# ============================================================ +# 查询剩余锁定秒数 +# ============================================================ +def _login_lock_remaining(ip: str, username: str) -> int: + """返回剩余锁定秒数(Redis 异常时回退为配置锁定时长)。 + + :param ip: 客户端 IP + :param username: 登录用户名 + :return: 剩余锁定秒数 + """ + try: + ttl = redis.ttl(_login_fail_key(ip, username)) + return ttl if ttl and ttl > 0 else LOGIN_LOCK_SECONDS + except Exception: + return LOGIN_LOCK_SECONDS + + +# ============================================================ +# 记录一次登录失败 +# ============================================================ +def _record_login_fail(ip: str, username: str) -> None: + """记录一次登录失败;首次失败时设置锁定窗口 TTL,窗口内连续失败达上限即锁定。 + + :param ip: 客户端 IP + :param username: 登录用户名 + """ + try: + key = _login_fail_key(ip, username) + count = redis.incr(key) + # 首次失败设置锁定窗口 TTL + if count == 1: + redis.expire(key, LOGIN_LOCK_SECONDS) + except Exception: + logger.warning("登录失败计数写入失败(防爆破降级为仅记录日志) ip=%s", ip) + + +# ============================================================ +# 清零登录失败计数 +# ============================================================ +def _clear_login_fail(ip: str, username: str) -> None: + """登录成功时清零失败计数(Redis 异常时静默忽略)。 + + :param ip: 客户端 IP + :param username: 登录用户名 + """ + try: + redis.delete(_login_fail_key(ip, username)) + except Exception: + pass + + +# ============================================================ +# 校验登录密码 +# ============================================================ +def _verify_login_password(password: str, user) -> bool: + """校验登录密码:盐方案优先,兼容旧无盐 bcrypt,成功登录自动升级为盐方案。 + + :param password: 明文密码 + :param user: 用户模型实例 + :return: 校验通过返回 True,否则 False + """ + if not verify_user_password(password, user.salt, user.password): + return False + # 旧无盐数据校验通过,升级为盐方案(生成 salt + 双重加盐)。 + # 用 update 而非 create:create 会用当前登录者 ID 覆盖原 create_user 审计字段。 + if is_legacy_hash(user.salt): + salt, hashed = set_password(password) + UserRepository().update(user.id, {"salt": salt, "password": hashed}) + return True + + +# ====================================================================== +# 生成图形验证码 +# ====================================================================== +def captcha(self): + """生成图形验证码并写入 Redis。 + + :param self: Tornado 请求处理器 + :return: 统一响应对象(含 base64 图片与 key) + """ + result = generate_captcha(self) + id_key = result['idKey'] + img_str = "data:image/png;base64," + result["data"] + return R.ok(self, data={"captcha": img_str, "key": id_key}) + + +# ====================================================================== +# 退出登录 +# ====================================================================== +def logout(self): + """退出登录:将当前令牌加入黑名单使其立即失效(Redis 不可用时降级为无状态登出)。 + + :param self: Tornado 请求处理器 + :return: 统一响应对象 + """ + try: + success, access_token, _ = get_access_token(self.request) + if success and access_token: + add_token_to_blacklist(access_token) + except Exception: + logger.exception("退出登录时令牌加入黑名单失败") + return R.ok(self, msg="注销成功") diff --git a/src/modules/category/__init__.py b/src/modules/category/__init__.py new file mode 100644 index 0000000..d9a7cbc --- /dev/null +++ b/src/modules/category/__init__.py @@ -0,0 +1,5 @@ +# +====================================================================== +# | 模块: 文章分类包 +# | 说明: 声明 category 为包,用于组织文章分类模块的路由、处理器、服务与数据访问 +# +====================================================================== +"""文章分类模块。""" diff --git a/src/modules/category/handlers.py b/src/modules/category/handlers.py new file mode 100644 index 0000000..cc83e51 --- /dev/null +++ b/src/modules/category/handlers.py @@ -0,0 +1,111 @@ +# +====================================================================== +# | 模块: 文章分类请求处理器层 +# | 说明: 承接文章分类相关的 HTTP 请求,校验权限/演示模式并记录操作日志后, +# | 转交分类服务层处理(列表、详情、增删改、下拉列表等) +# +====================================================================== +"""文章分类请求处理器。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:权限/演示模式装饰器、操作日志装饰器、统一响应对象、 +# 请求处理基类、文章分类服务单例 +# ---------------------------------------------------------------------- +from common.middleware.access_decorators import check_demo, permission_required +from common.middleware.operation_log import operation_log +from core import response as R +from core.base_handler import BaseHandler +from modules.category.service import category_service + + +# ====================================================================== +# 查询文章分类列表数据 +# ====================================================================== +class CategoryListHandler(BaseHandler): + """查询文章分类列表数据。""" + + @permission_required("sys:category:list") + async def get(self): + """GET 请求:返回文章分类列表。""" + return await category_service.get_list(self) + + +# ====================================================================== +# 查询文章分类详情 +# ====================================================================== +class CategoryDetailHandler(BaseHandler): + """查询文章分类详情。""" + + @permission_required("sys:category:detail") + async def get(self, id): + """GET 请求:按 ID 返回文章分类详情。 + + :param id: 分类 ID(路径参数) + """ + return R.ok(self, data=category_service.get_detail(id)) + + +# ====================================================================== +# 添加文章分类 +# ====================================================================== +class CategoryAddHandler(BaseHandler): + """添加文章分类。""" + + @permission_required("sys:category:add") + @operation_log("文章分类", "添加") + @check_demo + async def post(self): + """POST 请求:新增文章分类。""" + return await category_service.add(self) + + +# ====================================================================== +# 更新文章分类 +# ====================================================================== +class CategoryUpdateHandler(BaseHandler): + """更新文章分类。""" + + @permission_required("sys:category:update") + @operation_log("文章分类", "更新") + @check_demo + async def put(self): + """PUT 请求:更新文章分类信息。""" + return await category_service.update(self) + + +# ====================================================================== +# 删除文章分类 +# ====================================================================== +class CategoryDeleteHandler(BaseHandler): + """删除文章分类。""" + + @permission_required("sys:category:delete") + @operation_log("文章分类", "删除") + @check_demo + async def delete(self, id): + """DELETE 请求:按 ID 删除文章分类(软删除)。 + + :param id: 分类 ID(路径参数) + """ + return await category_service.delete(self, id) + + +# ====================================================================== +# 获取文章分类下拉列表 +# ====================================================================== +class CategoryGetListHandler(BaseHandler): + """获取文章分类下拉列表。""" + + async def get(self): + """GET 请求:返回文章分类下拉列表(无需权限校验)。""" + return await category_service.get_options(self) + + +# ====================================================================== +# 获取文章分类数据(树形结构) +# ====================================================================== +class CategoryGetDataHandler(BaseHandler): + """获取文章分类数据(树形结构)。""" + + async def get(self): + """GET 请求:返回文章分类树形数据(无需权限校验)。""" + return await category_service.get_tree_data(self) diff --git a/src/modules/category/models.py b/src/modules/category/models.py new file mode 100644 index 0000000..9e79be8 --- /dev/null +++ b/src/modules/category/models.py @@ -0,0 +1,43 @@ +# +====================================================================== +# | 模块: 文章分类数据模型层 +# | 说明: 定义文章分类表结构,继承 BaseModel 获得通用字段与序列化能力 +# +====================================================================== +"""文章分类数据模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:SQLAlchemy 列类型与函数、表名前缀配置、模型基类 +# ---------------------------------------------------------------------- +from sqlalchemy import Column, Integer, String, text + +from config.database import DB_PREFIX +from core.base_model import BaseModel + + +# ====================================================================== +# 文章分类模型 +# ====================================================================== +class Category(BaseModel): + """文章分类模型。""" + + # ============================================================ + # 表定义 + # ============================================================ + __tablename__ = DB_PREFIX + "category" + __table_comment__ = "文章分类表" + + # ============================================================ + # 业务字段 + # ============================================================ + category_name = Column(String(100), nullable=False, index=True, comment="分类名称") + category_code = Column(String(100), nullable=True, comment="分类编码") + parent_id = Column(Integer, default=0, server_default=text('0'), index=True, comment="上级ID") + sort = Column(Integer, default=0, server_default=text('0'), comment="排序") + note = Column(String(100), nullable=True, comment="分类备注") + + # ============================================================ + # 调试输出 + # ============================================================ + def __str__(self): + """调试用字符串表示:返回文章分类标识。""" + return f"文章分类{self.id}" diff --git a/src/modules/category/repository.py b/src/modules/category/repository.py new file mode 100644 index 0000000..c38b018 --- /dev/null +++ b/src/modules/category/repository.py @@ -0,0 +1,24 @@ +# +====================================================================== +# | 模块: 文章分类数据访问层 +# | 说明: 定义文章分类仓储,继承 BaseRepository 获得通用 CRUD 与软删除能力 +# +====================================================================== +"""文章分类数据访问。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:通用仓储基类、文章分类模型 +# ---------------------------------------------------------------------- +from core.base_repository import BaseRepository +from modules.category.models import Category + + +# ====================================================================== +# 文章分类仓储 +# ====================================================================== +class CategoryRepository(BaseRepository): + """文章分类仓储。""" + + # ============================================================ + # 绑定的模型类 + # ============================================================ + model = Category diff --git a/src/modules/category/schemas.py b/src/modules/category/schemas.py new file mode 100644 index 0000000..1adae0b --- /dev/null +++ b/src/modules/category/schemas.py @@ -0,0 +1,31 @@ +# +====================================================================== +# | 模块: 文章分类请求校验模型层 +# | 说明: 定义文章分类创建/编辑的 Pydantic 校验模型 +# +====================================================================== +"""文章分类请求校验模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:类型标注、Pydantic 字段定义、Schema 基类 +# ---------------------------------------------------------------------- +from typing import Optional + +from pydantic import Field + +from core.base_schema import BaseSchema + + +# ====================================================================== +# 文章分类创建/编辑表单 +# ====================================================================== +class CategorySchema(BaseSchema): + """文章分类创建/编辑表单。""" + + # ============================================================ + # 表单字段 + # ============================================================ + categoryName: str = Field(..., min_length=1, max_length=100, description="分类名称") + categoryCode: Optional[str] = Field(None, max_length=100, description="分类编码") + parentId: int = Field(0, ge=0, description="上级ID") + sort: int = Field(..., ge=0, le=99999, description="排序") + note: Optional[str] = Field(None, max_length=100, description="分类备注") diff --git a/src/modules/category/service.py b/src/modules/category/service.py new file mode 100644 index 0000000..950de02 --- /dev/null +++ b/src/modules/category/service.py @@ -0,0 +1,125 @@ +# +====================================================================== +# | 模块: 文章分类业务逻辑层 +# | 说明: 定义文章分类服务,声明差异点并覆写删除前校验(子分类存在性检查), +# | 最后导出服务单例(参考部门模块) +# +====================================================================== +"""文章分类业务逻辑。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:SQLAlchemy 条件表达式、ID 列表解析工具、统一响应对象、 +# 业务服务基类、文章分类模型/仓储/校验模型 +# ---------------------------------------------------------------------- +from sqlalchemy import and_ + +from common.utils.helpers import parse_id_list +from core import response as R +from core.base_service import BaseService +from modules.category.models import Category +from modules.category.repository import CategoryRepository +from modules.category.schemas import CategorySchema + + +# ====================================================================== +# 文章分类服务 +# ====================================================================== +class CategoryService(BaseService): + """文章分类服务(参考部门模块)。""" + + # ============================================================ + # 基础声明 + # ============================================================ + model = Category + repo_cls = CategoryRepository + + # ============================================================ + # 差异点声明 + # ============================================================ + create_schema = CategorySchema + update_schema = CategorySchema + # 分页查询字段与排序 + page_like_fields = ("category_name",) + page_order_by = (("sort", "asc"),) + + # ============================================================ + # 列表查询(不分页) + # ============================================================ + async def get_list(self, handler) -> R: + """列表查询:返回全部数据(不分页)。 + + :param handler: Tornado 请求处理器 + :return: 统一响应对象 + """ + try: + query = self.repo.filter() + category_name = handler.get_query_argument('category_name', None) + if category_name: + query = query.filter(Category.category_name.like('%' + category_name + '%')) + query = query.order_by(Category.sort.asc()) + items = query.all() + result = [self._serialize(item) for item in items] + return R.ok(handler, data=result) + finally: + self.repo.close() + + # ============================================================ + # 获取树形数据 + # ============================================================ + def get_tree_data(self, handler) -> list: + """获取分类树形数据。 + + :param handler: Tornado 请求处理器 + :return: 树形结构数据列表 + """ + try: + items = self.repo.get_all() + # 构建树形结构 + result = [] + item_map = {} + for item in items: + node = { + 'id': item.id, + 'categoryName': item.category_name, + 'categoryCode': item.category_code, + 'parentId': item.parent_id, + 'sort': item.sort, + 'type': item.type, + 'children': [] + } + item_map[item.id] = node + for item in items: + node = item_map[item.id] + if item.parent_id == 0: + result.append(node) + else: + parent = item_map.get(item.parent_id) + if parent: + parent['children'].append(node) + return result + finally: + self.repo.close() + + # ============================================================ + # 删除前校验(覆写) + # ============================================================ + def _before_delete(self, ids): + """删除前校验:存在子分类时拦截,避免产生指向已删除父级的孤儿节点。 + + :param ids: 逗号分隔的 ID 字符串 + :return: 拦截文案或 None(放行) + """ + id_list = parse_id_list(str(ids)) + if not id_list: + return None + # 检查这些分类下是否存在未删除的子分类 + child_count = self.repo.db.query(Category.id).filter( + and_(Category.parent_id.in_(id_list), Category.is_delete == 0)).count() + if child_count: + return "存在子分类,请先删除子分类" + return None + + +# ====================================================================== +# 服务单例 +# ====================================================================== +category_service = CategoryService() diff --git a/src/modules/city/__init__.py b/src/modules/city/__init__.py new file mode 100644 index 0000000..529ba94 --- /dev/null +++ b/src/modules/city/__init__.py @@ -0,0 +1,5 @@ +# +====================================================================== +# | 模块: 城市管理包 +# | 说明: 声明 city 为包,用于组织城市模块的路由、处理器、服务与数据访问 +# +====================================================================== +"""城市管理模块。""" diff --git a/src/modules/city/handlers.py b/src/modules/city/handlers.py new file mode 100644 index 0000000..e85a14e --- /dev/null +++ b/src/modules/city/handlers.py @@ -0,0 +1,137 @@ +# +====================================================================== +# | 模块: 城市请求处理器层 +# | 说明: 承接城市相关的 HTTP 请求,校验权限/演示模式并记录操作日志后, +# | 转交城市服务层处理(分页、详情、增删改、批量删除等) +# +====================================================================== +"""城市请求处理器。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:权限/演示模式装饰器、操作日志装饰器、统一响应对象、 +# 请求处理基类、城市服务单例 +# ---------------------------------------------------------------------- +from common.middleware.access_decorators import check_demo, permission_required +from common.middleware.operation_log import operation_log +from core import response as R +from core.base_handler import BaseHandler +from core.database import db +from modules.city.models import City +from modules.city.service import city_service + + +# ====================================================================== +# 查询城市数据 +# ====================================================================== +class CityPageHandler(BaseHandler): + """查询城市数据。""" + + @permission_required("sys:city:list") + async def get(self): + """GET 请求:返回城市分页列表。""" + return await city_service.get_page(self) + + +# ====================================================================== +# 查询城市详情 +# ====================================================================== +class CityDetailHandler(BaseHandler): + """查询城市详情。""" + + @permission_required("sys:city:detail") + async def get(self, id): + """GET 请求:按 ID 返回城市详情。 + + :param id: 城市 ID(路径参数) + """ + return R.ok(self, data=city_service.get_detail(id)) + + +# ====================================================================== +# 添加城市 +# ====================================================================== +class CityAddHandler(BaseHandler): + """添加城市。""" + + @permission_required("sys:city:add") + @operation_log("城市管理", "添加") + @check_demo + async def post(self): + """POST 请求:新增城市。""" + return await city_service.add(self) + + +# ====================================================================== +# 更新城市 +# ====================================================================== +class CityUpdateHandler(BaseHandler): + """更新城市。""" + + @permission_required("sys:city:update") + @operation_log("城市管理", "更新") + @check_demo + async def put(self): + """PUT 请求:更新城市信息。""" + return await city_service.update(self) + + +# ====================================================================== +# 删除城市 +# ====================================================================== +class CityDeleteHandler(BaseHandler): + """删除城市。""" + + @permission_required("sys:city:delete") + @operation_log("城市管理", "删除") + @check_demo + async def delete(self, id): + """DELETE 请求:按 ID 删除城市(软删除)。 + + :param id: 城市 ID(路径参数) + """ + return await city_service.delete(self, id) + + +# ====================================================================== +# 批量删除城市 +# ====================================================================== +class CityBatchDeleteHandler(BaseHandler): + """批量删除城市。""" + + @permission_required("sys:city:delete") + @operation_log("城市管理", "批量删除") + @check_demo + async def post(self): + """POST 请求:批量删除城市(软删除)。""" + return await city_service.batch_delete(self) + + +# ====================================================================== +# 根据城市编码查询子级城市 +# ====================================================================== +class CityGetListHandler(BaseHandler): + """根据城市编码查询子级城市。""" + + async def get(self, city_code): + """GET 请求:根据城市编码返回子级城市列表。 + + :param city_code: 城市编码(路径参数) + """ + try: + from sqlalchemy import or_ + city_code = (city_code or '').strip() + # 顶级(省级)查询:兼容旧数据 '0' 哨兵值与新数据 NULL + if city_code in ('0', ''): + items = db.query(City).filter( + or_(City.parent_code == '0', City.parent_code.is_(None)), + City.is_delete == 0, + ).all() + else: + # 查询子级城市 + items = db.query(City).filter( + City.parent_code == city_code, City.is_delete == 0 + ).all() + # 返回完整结构 + result = [item.to_dict() for item in items] + return R.ok(self, data=result) + finally: + db.close() diff --git a/src/modules/city/models.py b/src/modules/city/models.py new file mode 100644 index 0000000..d45aef9 --- /dev/null +++ b/src/modules/city/models.py @@ -0,0 +1,51 @@ +# +====================================================================== +# | 模块: 城市数据模型层 +# | 说明: 定义城市行政区划表结构,继承 BaseModel 获得通用字段与序列化能力 +# +====================================================================== +"""城市数据模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:SQLAlchemy 列类型与函数、表名前缀配置、模型基类 +# ---------------------------------------------------------------------- +from sqlalchemy import Column, Integer, String, text + +from config.database import DB_PREFIX +from core.base_model import BaseModel + + +# ====================================================================== +# 城市模型 +# ====================================================================== +class City(BaseModel): + """城市模型。""" + + # ============================================================ + # 表定义 + # ============================================================ + __tablename__ = DB_PREFIX + "city" + __table_comment__ = "城市行政区划表" + + # ============================================================ + # 业务字段 + # ============================================================ + city_code = Column(String(6), nullable=False, index=True, comment="城市区号") + area_code = Column(String(20), nullable=False, index=True, comment="行政编码") + parent_code = Column(String(20), nullable=True, index=True, comment="上级行政编码") + zip_code = Column(String(6), nullable=False, comment="邮政编码") + level = Column(Integer, default=0, server_default=text('0'), index=True, + comment="城市级别:1-省份 2-城市 3-县区 4-街道") + pid = Column(Integer, default=0, server_default=text('0'), index=True, comment="上级城市ID") + name = Column(String(150), nullable=False, index=True, comment="城市名称") + short_name = Column(String(150), nullable=False, comment="城市简称") + full_name = Column(String(150), nullable=True, comment="城市全称") + pinyin = Column(String(150), nullable=True, comment="城市拼音") + lng = Column(String(150), nullable=True, comment="城市经度") + lat = Column(String(150), nullable=True, comment="城市纬度") + + # ============================================================ + # 调试输出 + # ============================================================ + def __str__(self): + """调试用字符串表示:返回城市标识。""" + return f'城市{self.id}' diff --git a/src/modules/city/repository.py b/src/modules/city/repository.py new file mode 100644 index 0000000..56f5251 --- /dev/null +++ b/src/modules/city/repository.py @@ -0,0 +1,24 @@ +# +====================================================================== +# | 模块: 城市数据访问层 +# | 说明: 定义城市仓储,继承 BaseRepository 获得通用 CRUD 与软删除能力 +# +====================================================================== +"""城市数据访问。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:通用仓储基类、城市模型 +# ---------------------------------------------------------------------- +from core.base_repository import BaseRepository +from modules.city.models import City + + +# ====================================================================== +# 城市仓储 +# ====================================================================== +class CityRepository(BaseRepository): + """城市仓储。""" + + # ============================================================ + # 绑定的模型类 + # ============================================================ + model = City diff --git a/src/modules/city/schemas.py b/src/modules/city/schemas.py new file mode 100644 index 0000000..6032964 --- /dev/null +++ b/src/modules/city/schemas.py @@ -0,0 +1,35 @@ +# +====================================================================== +# | 模块: 城市请求校验模型层 +# | 说明: 定义城市创建/编辑的 Pydantic 校验模型 +# +====================================================================== +"""城市请求校验模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:类型标注、Pydantic 字段定义、Schema 基类 +# ---------------------------------------------------------------------- +from typing import Optional + +from pydantic import Field + +from core.base_schema import BaseSchema + + +# ====================================================================== +# 城市创建/编辑表单 +# ====================================================================== +class CitySchema(BaseSchema): + """城市创建/编辑表单。""" + + # ============================================================ + # 表单字段 + # ============================================================ + name: str = Field(..., min_length=1, max_length=150, description="城市名称") + shortName: str = Field(..., min_length=1, max_length=150, description="城市简称") + cityCode: str = Field(..., min_length=1, max_length=6, description="城市区号") + areaCode: str = Field(..., min_length=1, max_length=20, description="行政编码") + zipCode: str = Field(..., min_length=1, max_length=6, description="城市邮编") + level: int = Field(..., ge=1, le=4, description="城市级别") + lng: str = Field(..., min_length=1, max_length=150, description="城市经度") + lat: str = Field(..., min_length=1, max_length=150, description="城市纬度") + parentCode: Optional[str] = Field(None, max_length=20, description="上级行政编码") diff --git a/src/modules/city/service.py b/src/modules/city/service.py new file mode 100644 index 0000000..e73f565 --- /dev/null +++ b/src/modules/city/service.py @@ -0,0 +1,84 @@ +# +====================================================================== +# | 模块: 城市业务逻辑层 +# | 说明: 定义城市服务,声明差异点并提供子级判断加工钩子,最后导出服务单例 +# +====================================================================== +"""城市业务逻辑。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:SQLAlchemy 条件表达式、业务服务基类、数据库会话、 +# 城市模型/仓储/校验模型 +# ---------------------------------------------------------------------- +from sqlalchemy import and_ + +from core.base_service import BaseService +from core.database import db +from modules.city.models import City +from modules.city.repository import CityRepository +from modules.city.schemas import CitySchema + + +# ====================================================================== +# 列表行加工:是否有子级 +# ====================================================================== +def _city_serialize_extra(item): + """城市行加工:是否有子级(按 pid 查询真实子级,避免按 level 硬编码导致树节点误判)。 + + :param item: 城市模型实例 + :return: 需合并进序列化字典的额外字段 + """ + # 按 pid 查询真实子级,存在即标记 haveChild + has_child = db.query(City.id).filter( + and_(City.pid == item.id, City.is_delete == 0)).first() + return {"haveChild": has_child is not None} + + +# ====================================================================== +# 详情加工:上级城市名称 +# ====================================================================== +def _city_detail_serialize(obj): + """城市详情加工:上级城市名称。 + + :param obj: 城市模型实例 + :return: 需合并进详情字典的额外字段 + """ + parent_name = '' + if obj.parent_code: + parent = db.query(City).filter( + and_(City.area_code == obj.parent_code, City.is_delete == 0)).first() + if parent: + parent_name = parent.name + return {"parentName": parent_name} + + +# ====================================================================== +# 城市服务 +# ====================================================================== +class CityService(BaseService): + """城市服务。""" + + # ============================================================ + # 基础声明 + # ============================================================ + model = City + repo_cls = CityRepository + + # ============================================================ + # 差异点声明 + # ============================================================ + create_schema = CitySchema + update_schema = CitySchema + # 分页查询字段与排序 + page_like_fields = ("name",) + page_eq_fields = ("pid",) + page_order_by = (("id", "asc"),) + # 枚举显示名映射与序列化钩子 + serialize_maps = {"level": "city_level"} + serialize_extra = _city_serialize_extra + detail_serialize = _city_detail_serialize + + +# ====================================================================== +# 服务单例 +# ====================================================================== +city_service = CityService() diff --git a/src/modules/configuration/__init__.py b/src/modules/configuration/__init__.py new file mode 100644 index 0000000..954f283 --- /dev/null +++ b/src/modules/configuration/__init__.py @@ -0,0 +1,5 @@ +# +====================================================================== +# | 模块: 配置管理包 +# | 说明: 声明 configuration 为包,用于组织系统配置与配置项等子模块 +# +====================================================================== +"""配置管理模块。""" diff --git a/src/modules/configuration/config/__init__.py b/src/modules/configuration/config/__init__.py new file mode 100644 index 0000000..0ea9cc2 --- /dev/null +++ b/src/modules/configuration/config/__init__.py @@ -0,0 +1,5 @@ +# +====================================================================== +# | 模块: 配置管理包 +# | 说明: 声明 configuration 为包,用于组织系统配置与配置项等子模块 +# +====================================================================== +"""配置子模块。""" diff --git a/src/modules/configuration/config/handlers.py b/src/modules/configuration/config/handlers.py new file mode 100644 index 0000000..aef7360 --- /dev/null +++ b/src/modules/configuration/config/handlers.py @@ -0,0 +1,128 @@ +# +====================================================================== +# | 模块: 配置请求处理器层 +# | 说明: 承接配置相关的 HTTP 请求,校验权限/演示模式并记录操作日志后, +# | 转交配置服务层处理(分页、详情、增删改、批量删除等) +# +====================================================================== +"""配置请求处理器。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:权限/演示模式装饰器、操作日志装饰器、统一响应对象、 +# 请求处理基类、配置服务单例 +# ---------------------------------------------------------------------- +from common.middleware.access_decorators import check_demo, permission_required +from common.middleware.operation_log import operation_log +from core import response as R +from core.base_handler import BaseHandler +from modules.configuration.config.service import config_service + + +# ====================================================================== +# 查询配置分页数据 +# ====================================================================== +class ConfigPageHandler(BaseHandler): + """查询配置分页数据。""" + + @permission_required("sys:config:list") + async def get(self): + """GET 请求:返回配置分页列表。""" + return await config_service.get_page(self) + + +# ====================================================================== +# 查询配置详情 +# ====================================================================== +class ConfigDetailHandler(BaseHandler): + """查询配置详情。""" + + @permission_required("sys:config:detail") + async def get(self, id): + """GET 请求:按 ID 返回配置详情。 + + :param id: 配置 ID(路径参数) + """ + return R.ok(self, data=config_service.get_detail(id)) + + +# ====================================================================== +# 添加配置 +# ====================================================================== +class ConfigAddHandler(BaseHandler): + """添加配置。""" + + @permission_required("sys:config:add") + @operation_log("配置管理", "添加") + @check_demo + async def post(self): + """POST 请求:新增配置。""" + return await config_service.add(self) + + +# ====================================================================== +# 更新配置 +# ====================================================================== +class ConfigUpdateHandler(BaseHandler): + """更新配置。""" + + @permission_required("sys:config:update") + @operation_log("配置管理", "更新") + @check_demo + async def put(self): + """PUT 请求:更新配置信息。""" + return await config_service.update(self) + + +# ====================================================================== +# 删除配置 +# ====================================================================== +class ConfigDeleteHandler(BaseHandler): + """删除配置。""" + + @permission_required("sys:config:delete") + @operation_log("配置管理", "删除") + @check_demo + async def delete(self, id): + """DELETE 请求:按 ID 删除配置(软删除)。 + + :param id: 配置 ID(路径参数) + """ + return await config_service.delete(self, id) + + +# ====================================================================== +# 批量删除配置 +# ====================================================================== +class ConfigBatchDeleteHandler(BaseHandler): + """批量删除配置。""" + + @permission_required("sys:config:delete") + @operation_log("配置管理", "批量删除") + @check_demo + async def post(self): + """POST 请求:批量删除配置(软删除)。""" + return await config_service.batch_delete(self) + + +# ====================================================================== +# 获取网站配置 +# ====================================================================== +class ConfigWebIndexHandler(BaseHandler): + """获取网站配置。""" + + @permission_required("sys:configweb:index") + async def get(self): + """GET 请求:返回网站配置信息。""" + return R.ok(self, data=config_service.get_config_info(self)) + + +# ====================================================================== +# 保存网站配置 +# ====================================================================== +class ConfigWebSaveHandler(BaseHandler): + """保存网站配置。""" + + @permission_required("sys:configweb:save") + @check_demo + async def post(self): + """PUT 请求:保存网站配置信息。""" + return await config_service.save_config_info(self) diff --git a/src/modules/configuration/config/models.py b/src/modules/configuration/config/models.py new file mode 100644 index 0000000..f380c7c --- /dev/null +++ b/src/modules/configuration/config/models.py @@ -0,0 +1,42 @@ +# +====================================================================== +# | 模块: 配置数据模型层 +# | 说明: 定义系统配置表结构,继承 BaseModel 获得通用字段与序列化能力 +# +====================================================================== +"""配置数据模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:SQLAlchemy 列类型与函数、表名前缀配置、模型基类 +# ---------------------------------------------------------------------- +from sqlalchemy import Column, Integer, String, text + +from config.database import DB_PREFIX +from core.base_model import BaseModel + + +# ====================================================================== +# 配置模型 +# ====================================================================== +class Config(BaseModel): + """配置模型。""" + + # ============================================================ + # 表定义 + # ============================================================ + __tablename__ = DB_PREFIX + "config" + __table_comment__ = "系统配置表" + + # ============================================================ + # 业务字段 + # ============================================================ + name = Column(String(150), nullable=False, index=True, comment="配置名称") + code = Column(String(150), nullable=False, index=True, comment="配置编码") + sort = Column(Integer, default=0, server_default=text('0'), comment="配置排序") + note = Column(String(255), nullable=True, comment="配置备注") + + # ============================================================ + # 调试输出 + # ============================================================ + def __str__(self): + """调试用字符串表示:返回配置标识。""" + return f"配置{self.id}" diff --git a/src/modules/configuration/config/repository.py b/src/modules/configuration/config/repository.py new file mode 100644 index 0000000..46d0ab0 --- /dev/null +++ b/src/modules/configuration/config/repository.py @@ -0,0 +1,24 @@ +# +====================================================================== +# | 模块: 配置数据访问层 +# | 说明: 定义配置仓储,继承 BaseRepository 获得通用 CRUD 与软删除能力 +# +====================================================================== +"""配置数据访问。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:通用仓储基类、配置模型 +# ---------------------------------------------------------------------- +from core.base_repository import BaseRepository +from modules.configuration.config.models import Config + + +# ====================================================================== +# 配置仓储 +# ====================================================================== +class ConfigRepository(BaseRepository): + """配置仓储。""" + + # ============================================================ + # 绑定的模型类 + # ============================================================ + model = Config diff --git a/src/modules/configuration/config/schemas.py b/src/modules/configuration/config/schemas.py new file mode 100644 index 0000000..6116f5b --- /dev/null +++ b/src/modules/configuration/config/schemas.py @@ -0,0 +1,30 @@ +# +====================================================================== +# | 模块: 配置请求校验模型层 +# | 说明: 定义配置创建/编辑的 Pydantic 校验模型 +# +====================================================================== +"""配置请求校验模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:类型标注、Pydantic 字段定义、Schema 基类 +# ---------------------------------------------------------------------- +from typing import Optional + +from pydantic import Field + +from core.base_schema import BaseSchema + + +# ====================================================================== +# 配置创建/编辑表单 +# ====================================================================== +class ConfigSchema(BaseSchema): + """配置创建/编辑表单。""" + + # ============================================================ + # 表单字段 + # ============================================================ + name: str = Field(..., min_length=1, max_length=150, description="配置名称") + code: str = Field(..., min_length=1, max_length=150, description="配置编码") + sort: int = Field(..., ge=0, le=99999, description="配置排序") + note: Optional[str] = Field(None, max_length=255, description="配置备注") diff --git a/src/modules/configuration/config/service.py b/src/modules/configuration/config/service.py new file mode 100644 index 0000000..6e043b6 --- /dev/null +++ b/src/modules/configuration/config/service.py @@ -0,0 +1,240 @@ +# +====================================================================== +# | 模块: 配置业务逻辑层 +# | 说明: 定义配置服务,声明差异点以复用通用 CRUD 流程,并提供网站配置 +# | 信息的获取与保存能力,导出服务单例 +# +====================================================================== +"""配置业务逻辑。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:业务服务基类、统一响应对象、当前用户上下文、数据库会话、 +# 配置模型/仓储/校验模型、配置项模型、富文本文件工具 +# ---------------------------------------------------------------------- +from datetime import datetime + +from sqlalchemy import and_ + +from common.utils.rich_text import get_file_url, save_file +from core import response as R +from core.base_service import BaseService +from core.context import current_realname +from core.database import db +from modules.configuration.config.models import Config +from modules.configuration.config.repository import ConfigRepository +from modules.configuration.config.schemas import ConfigSchema +from modules.configuration.config_item.models import ConfigItem + + +# ====================================================================== +# 配置服务 +# ====================================================================== +class ConfigService(BaseService): + """配置服务。""" + + # ============================================================ + # 基础声明 + # ============================================================ + model = Config + repo_cls = ConfigRepository + + # ============================================================ + # 差异点声明 + # ============================================================ + create_schema = ConfigSchema + update_schema = ConfigSchema + # 分页查询字段与排序 + page_like_fields = ("name",) + page_order_by = (("sort", "asc"),) + + # ============================================================ + # 获取网站配置信息 + # ============================================================ + @staticmethod + def get_config_info(handler): + """获取网站配置信息(按配置组分组的配置项列表)。 + + 返回结构参考 FastAPI 版本,每个配置项包含完整字段与按类型 + 计算的 valueList / param 等展示属性。 + + :param handler: Tornado 请求处理器 + :return: 按配置分组的配置项列表 + """ + try: + # 一次性查询所有配置组,按排序升序 + configs = db.query(Config).filter( + Config.is_delete == 0 + ).order_by(Config.sort.asc()).all() + if not configs: + return [] + + # 一次性查询所有活跃配置项,按 config_id 分组 + config_ids = [c.id for c in configs] + all_items = db.query(ConfigItem).filter( + and_( + ConfigItem.is_delete == 0, + ConfigItem.status == 1, + ConfigItem.config_id.in_(config_ids) + ) + ).order_by(ConfigItem.sort.asc()).all() + + items_map: dict = {} + for item in all_items: + items_map.setdefault(item.config_id, []).append(item) + + config_list = [] + for val in configs: + config = { + 'configId': val.id, + 'configName': val.name, + } + + data_list = [] + for v in items_map.get(val.id, []): + data = { + "id": v.id, + "configId": v.config_id, + "name": v.name, + "code": v.code, + "value": v.value, + "valueList": None, + "options": v.options or "", + "type": v.type, + "status": v.status, + "sort": v.sort, + "note": v.note or "", + "param": None, + } + + item_type = v.type + if item_type == "checkbox": + # 多选:返回选项参数与已选值数组 + data["param"] = _parse_options(v.options) + data["valueList"] = v.value.split(',') if v.value else [] + elif item_type in ("radio", "select"): + # 单选/下拉:返回选项参数 + data["param"] = _parse_options(v.options) + elif item_type == "image": + # 单图:补全完整访问地址 + if v.value: + data["value"] = get_file_url(v.value) + elif item_type == "images": + # 多图:逐项补全完整访问地址 + if v.value: + data["valueList"] = [get_file_url(img) for img in v.value.split(',')] + elif item_type in ("file", "files"): + # 文件:名称|URL 格式,补全 URL 部分 + if v.value: + if '|' in v.value: + name_part, url_part = v.value.split('|', 1) + data["valueList"] = [f"{name_part}|{get_file_url(url_part)}"] + else: + data["valueList"] = [get_file_url(v.value)] + + data_list.append(data) + + config['dataList'] = data_list + config_list.append(config) + + return config_list + finally: + db.close() + + # ============================================================ + # 保存网站配置信息 + # ============================================================ + @staticmethod + async def save_config_info(handler): + """保存网站配置信息。 + + 按 code 匹配配置项并更新值,图片/文件类自动本地化存储。 + 密码项留空表示不修改。 + + :param handler: Tornado 请求处理器 + :return: 统一响应对象 + """ + body = BaseService.parse_json(handler) + if not body or not isinstance(body, dict): + return R.failed(handler, "请求参数异常") + try: + # 预加载所有活跃配置项,按 code 建索引 + valid_items = db.query(ConfigItem).filter( + and_(ConfigItem.is_delete == 0, ConfigItem.status == 1) + ).all() + item_map = {item.code: item for item in valid_items} + + for key, val in body.items(): + # 仅允许修改已存在的活跃配置项,忽略非法 key + config_item = item_map.get(key) + if not config_item: + continue + + # 数据处理:图片/文件/普通值 + is_media = config_item.type in ("image", "images", "file", "files") + if isinstance(val, list): + item = [] + for v in val: + if isinstance(v, str) and '|' in v: + # 文件类型:名称|URL,只处理 URL 部分 + parts = v.split('|', 1) + saved = save_file(parts[1], "config") + item.append(f"{parts[0]}|{saved}" if saved else v) + elif is_media and isinstance(v, str) and ( + v.startswith("http://") or v.startswith("https://") + ): + saved = save_file(v, "config") + item.append(saved if saved else v) + else: + item.append(v) + val = ','.join(str(x) for x in item) + elif isinstance(val, str) and '|' in val: + # 文件类型:名称|URL + parts = val.split('|', 1) + saved = save_file(parts[1], "config") + val = f"{parts[0]}|{saved}" if saved else val + elif is_media and isinstance(val, str) and ( + val.startswith("http://") or val.startswith("https://") + ): + saved = save_file(val, "config") + val = saved if saved else val + + # 密码项留空表示不修改 + if config_item.type == "password" and val in ("", None): + continue + + # 写入新值与审计字段 + config_item.value = val + config_item.update_user = current_realname.get() + config_item.update_time = datetime.now() + + db.commit() + return R.ok(handler) + except Exception: + db.rollback() + raise + finally: + db.close() + + +# ====================================================================== +# 辅助函数 +# ====================================================================== +def _parse_options(options_str) -> dict: + """将 'k1=v1,k2=v2' 格式的选项字符串解析为字典。 + + :param options_str: 配置选项字符串 + :return: {key: value} 字典 + """ + if not options_str: + return {} + result: dict = {} + for part in options_str.split(','): + if '=' in part: + key, value = part.split('=', 1) + result[key] = value + return result + + +# ====================================================================== +# 服务单例 +# ====================================================================== +config_service = ConfigService() diff --git a/src/modules/configuration/config_item/__init__.py b/src/modules/configuration/config_item/__init__.py new file mode 100644 index 0000000..c4979ca --- /dev/null +++ b/src/modules/configuration/config_item/__init__.py @@ -0,0 +1,6 @@ +# +====================================================================== +# | 模块: 配置项管理包 +# | 说明: 声明 config_item 为包,用于组织配置项模块的路由、处理器、 +# | 服务与数据访问 +# +====================================================================== +"""配置项子模块。""" diff --git a/src/modules/configuration/config_item/handlers.py b/src/modules/configuration/config_item/handlers.py new file mode 100644 index 0000000..d9c19be --- /dev/null +++ b/src/modules/configuration/config_item/handlers.py @@ -0,0 +1,117 @@ +# +====================================================================== +# | 模块: 配置项请求处理器层 +# | 说明: 承接配置项相关的 HTTP 请求,校验权限/演示模式并记录操作日志后, +# | 转交配置项服务处理(分页、详情、增删改、状态、批量删除) +# +====================================================================== +"""配置项请求处理器。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:权限/演示模式装饰器、操作日志装饰器、统一响应对象、 +# 请求处理基类、配置项服务单例 +# ---------------------------------------------------------------------- +from common.middleware.access_decorators import check_demo, permission_required +from common.middleware.operation_log import operation_log +from core import response as R +from core.base_handler import BaseHandler +from modules.configuration.config_item.service import config_item_service + + +# ====================================================================== +# 查询配置项分页数据 +# ====================================================================== +class ConfigItemPageHandler(BaseHandler): + """查询配置项分页数据。""" + + @permission_required("sys:config:list") + async def get(self): + """GET 请求:返回配置项分页列表。""" + return await config_item_service.get_page(self) + + +# ====================================================================== +# 查询配置项详情 +# ====================================================================== +class ConfigItemDetailHandler(BaseHandler): + """查询配置项详情。""" + + @permission_required("sys:config:detail") + async def get(self, id): + """GET 请求:按 ID 返回配置项详情。 + + :param id: 配置项 ID(路径参数) + """ + return R.ok(self, data=config_item_service.get_detail(id)) + + +# ====================================================================== +# 添加配置项 +# ====================================================================== +class ConfigItemAddHandler(BaseHandler): + """添加配置项。""" + + @permission_required("sys:config:add") + @operation_log("配置管理", "添加配置项") + @check_demo + async def post(self): + """POST 请求:新增配置项。""" + return await config_item_service.add(self) + + +# ====================================================================== +# 更新配置项 +# ====================================================================== +class ConfigItemUpdateHandler(BaseHandler): + """更新配置项。""" + + @permission_required("sys:config:update") + @operation_log("配置管理", "更新配置项") + @check_demo + async def put(self): + """PUT 请求:更新配置项信息。""" + return await config_item_service.update(self) + + +# ====================================================================== +# 删除配置项 +# ====================================================================== +class ConfigItemDeleteHandler(BaseHandler): + """删除配置项。""" + + @permission_required("sys:config:delete") + @operation_log("配置管理", "删除配置项") + @check_demo + async def delete(self, id): + """DELETE 请求:按 ID 删除配置项(软删除)。 + + :param id: 配置项 ID(路径参数) + """ + return await config_item_service.delete(self, id) + + +# ====================================================================== +# 设置配置项状态 +# ====================================================================== +class ConfigItemStatusHandler(BaseHandler): + """设置配置项状态。""" + + @permission_required("sys:config:status") + @operation_log("配置管理", "设置配置项状态") + @check_demo + async def put(self): + """PUT 请求:修改配置项状态(如启用 / 禁用)。""" + return await config_item_service.update_status(self) + + +# ====================================================================== +# 批量删除配置项 +# ====================================================================== +class ConfigItemBatchDeleteHandler(BaseHandler): + """批量删除配置项。""" + + @permission_required("sys:config:delete") + @operation_log("配置项管理", "批量删除") + @check_demo + async def post(self): + """POST 请求:批量删除配置项(软删除)。""" + return await config_item_service.batch_delete(self) diff --git a/src/modules/configuration/config_item/models.py b/src/modules/configuration/config_item/models.py new file mode 100644 index 0000000..335ebf4 --- /dev/null +++ b/src/modules/configuration/config_item/models.py @@ -0,0 +1,48 @@ +# +====================================================================== +# | 模块: 配置项数据模型层 +# | 说明: 定义配置项表结构,继承 BaseModel 获得通用字段与序列化能力 +# +====================================================================== +"""配置项数据模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:SQLAlchemy 列类型与函数、表名前缀配置、模型基类 +# ---------------------------------------------------------------------- +from sqlalchemy import Column, Integer, String, text + +from config.database import DB_PREFIX +from core.base_model import BaseModel + + +# ====================================================================== +# 配置项模型 +# ====================================================================== +class ConfigItem(BaseModel): + """配置项模型。""" + + # ============================================================ + # 表定义 + # ============================================================ + __tablename__ = DB_PREFIX + "config_item" + __table_comment__ = "配置项表" + + # ============================================================ + # 业务字段 + # ============================================================ + name = Column(String(100), nullable=False, default="", index=True, comment="配置项名称") + code = Column(String(100), nullable=False, default="", comment="配置项编码") + value = Column(String(1000), nullable=False, default="", comment="配置项值") + options = Column(String(255), nullable=False, default="", comment="配置选项") + config_id = Column(Integer, nullable=False, default=0, server_default=text('0'), index=True, comment="配置ID") + type = Column(String(100), nullable=False, default="text", comment="配置类型") + status = Column(Integer, nullable=False, default=1, server_default=text('1'), index=True, + comment="配置状态:1-正常 2-停用") + sort = Column(Integer, nullable=False, default=0, server_default=text('0'), comment="配置项排序") + note = Column(String(255), nullable=True, comment="配置项备注") + + # ============================================================ + # 调试输出 + # ============================================================ + def __str__(self): + """调试用字符串表示:返回配置项标识。""" + return f"配置项{self.id}" diff --git a/src/modules/configuration/config_item/repository.py b/src/modules/configuration/config_item/repository.py new file mode 100644 index 0000000..506e1e8 --- /dev/null +++ b/src/modules/configuration/config_item/repository.py @@ -0,0 +1,24 @@ +# +====================================================================== +# | 模块: 配置项数据访问层 +# | 说明: 定义配置项仓储,继承 BaseRepository 获得通用 CRUD 与软删除能力 +# +====================================================================== +"""配置项数据访问。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:通用仓储基类、配置项模型 +# ---------------------------------------------------------------------- +from core.base_repository import BaseRepository +from modules.configuration.config_item.models import ConfigItem + + +# ====================================================================== +# 配置项仓储 +# ====================================================================== +class ConfigItemRepository(BaseRepository): + """配置项仓储。""" + + # ============================================================ + # 绑定的模型类 + # ============================================================ + model = ConfigItem diff --git a/src/modules/configuration/config_item/schemas.py b/src/modules/configuration/config_item/schemas.py new file mode 100644 index 0000000..5de740c --- /dev/null +++ b/src/modules/configuration/config_item/schemas.py @@ -0,0 +1,48 @@ +# +====================================================================== +# | 模块: 配置项请求校验模型层 +# | 说明: 定义配置项创建/编辑与状态更新的 Pydantic 校验模型 +# +====================================================================== +"""配置项请求校验模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:类型标注、Pydantic 基类与字段定义、Schema 基类 +# ---------------------------------------------------------------------- +from typing import Optional + +from pydantic import BaseModel, Field + +from core.base_schema import BaseSchema + + +# ====================================================================== +# 配置项创建/编辑表单 +# ====================================================================== +class ConfigItemSchema(BaseSchema): + """配置项创建/编辑表单。""" + + # ============================================================ + # 表单字段 + # ============================================================ + configId: int = Field(..., ge=0, description="配置ID") + name: str = Field(..., min_length=1, max_length=150, description="配置项名称") + code: str = Field(..., min_length=1, max_length=150, description="配置项编码") + value: Optional[str] = Field(..., min_length=1, max_length=1000, description="配置项值") + options: Optional[str] = Field(None, max_length=1000, description="配置选项") + type: str = Field(..., min_length=1, max_length=150, description="配置类型") + status: int = Field(default=1, ge=1, le=2, description="配置状态") + sort: int = Field(..., ge=0, le=99999, description="配置项排序") + note: Optional[str] = Field(None, max_length=255, description="配置项备注") + + +# ====================================================================== +# 配置项状态更新表单 +# ====================================================================== +class ConfigItemStatusSchema(BaseModel): + """配置项状态更新表单。""" + + # ============================================================ + # 表单字段 + # ============================================================ + id: int = Field(..., gt=0, description="配置项ID") + status: int = Field(..., ge=1, le=2, description="配置项状态:1-正常 2-停用") diff --git a/src/modules/configuration/config_item/service.py b/src/modules/configuration/config_item/service.py new file mode 100644 index 0000000..bda3c9d --- /dev/null +++ b/src/modules/configuration/config_item/service.py @@ -0,0 +1,153 @@ +# +====================================================================== +# | 模块: 配置项业务逻辑层 +# | 说明: 定义配置项服务,声明差异点以复用通用 CRUD 流程,并导出服务单例 +# +====================================================================== +"""配置项业务逻辑。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:SQLAlchemy 条件表达式、富文本文件工具、 +# 业务服务基类、数据库会话、配置项模型/仓储/校验模型 +# ---------------------------------------------------------------------- +from sqlalchemy import and_ + +from common.utils.rich_text import get_file_url, save_file +from core.base_service import BaseService +from core.database import db +from modules.configuration.config_item.models import ConfigItem +from modules.configuration.config_item.repository import ConfigItemRepository +from modules.configuration.config_item.schemas import ConfigItemSchema, ConfigItemStatusSchema + + +# ====================================================================== +# 详情加工:配置编码 + 按类型计算 valueList +# ====================================================================== +def _config_item_detail_serialize(item): + """配置项详情加工:补充 configCode、valueList。 + + :param item: 配置项模型实例 + :return: 需合并进详情字典的额外字段 + """ + from modules.configuration.config.models import Config + extra = {} + # 关联配置组编码 + config = db.query(Config).filter( + and_(Config.id == item.config_id, Config.is_delete == 0)).first() + extra['configCode'] = config.code if config else '' + # 单图:value 拼接完整 URL + if item.type == "image" and item.value: + extra['value'] = get_file_url(item.value) + # 按类型计算 valueList + extra['valueList'] = _build_value_list(item) + return extra + + +def _build_value_list(item): + """按配置项类型计算 valueList。""" + if not item.value: + return None + if item.type == "images": + return [get_file_url(img) for img in item.value.split(',')] + if item.type == "checkbox": + return item.value.split(',') + if item.type in ("file", "files"): + result = [] + for part in item.value.split(','): + if '|' in part: + name, url = part.split('|', 1) + result.append(f"{name}|{get_file_url(url)}") + else: + result.append(get_file_url(part)) + return result + return None + + +# ====================================================================== +# 媒体类型 value 处理:迁移临时文件到正式目录 +# ====================================================================== +def _process_media_value(data): + """图片/文件类型的配置项 value 做 save_file 处理。 + + :param data: 待入库的表单数据字典(原地修改) + """ + item_type = data.get('type', '') + value = data.get('value') + if not value or item_type not in ('image', 'images', 'file', 'files'): + return + if item_type == 'image': + data['value'] = save_file(value, "config") or value + elif item_type == 'images': + saved = [] + for url in value.split(','): + url = url.strip() + if url: + saved.append(save_file(url, "config") or url) + data['value'] = ','.join(saved) + elif item_type in ('file', 'files'): + saved = [] + for part in value.split(','): + part = part.strip() + if not part: + continue + if '|' in part: + name, url = part.split('|', 1) + saved.append(f"{name}|{save_file(url, 'config') or url}") + else: + saved.append(save_file(part, "config") or part) + data['value'] = ','.join(saved) + + +# ====================================================================== +# 配置项服务 +# ====================================================================== +class ConfigItemService(BaseService): + """配置项服务。""" + + # ============================================================ + # 基础声明 + # ============================================================ + model = ConfigItem + repo_cls = ConfigItemRepository + + # ============================================================ + # 差异点声明 + # ============================================================ + create_schema = ConfigItemSchema + update_schema = ConfigItemSchema + status_schema = ConfigItemStatusSchema + # 分页查询字段与排序 + page_like_fields = ("name", "code") + page_eq_fields = ("config_id",) + page_order_by = (("sort", "asc"),) + # 枚举显示名映射 + serialize_maps = {"type": "config_item_type"} + # 详情加工钩子 + detail_serialize = _config_item_detail_serialize + + # ============================================================ + # 新增前钩子(媒体类型 value 做 save_file) + # ============================================================ + def _before_add(self, handler, data): + """新增前:图片/文件类型的 value 迁移临时文件到正式目录。 + + :param handler: Tornado 请求处理器 + :param data: 待入库的表单数据字典 + """ + _process_media_value(data) + + # ============================================================ + # 编辑前钩子(媒体类型 value 做 save_file) + # ============================================================ + def _before_update(self, handler, data): + """编辑前:图片/文件类型的 value 迁移临时文件到正式目录。 + + :param handler: Tornado 请求处理器 + :param data: 待入库的表单数据字典 + """ + _process_media_value(data) + + +# ====================================================================== +# 服务单例 +# ====================================================================== +config_item_service = ConfigItemService() diff --git a/src/modules/dept/__init__.py b/src/modules/dept/__init__.py new file mode 100644 index 0000000..1bcc0d2 --- /dev/null +++ b/src/modules/dept/__init__.py @@ -0,0 +1,5 @@ +# +====================================================================== +# | 模块: 部门管理包 +# | 说明: 声明 dept 为包,用于组织部门模块的路由、处理器、服务与数据访问 +# +====================================================================== +"""部门管理模块。""" \ No newline at end of file diff --git a/src/modules/dept/handlers.py b/src/modules/dept/handlers.py new file mode 100644 index 0000000..861c295 --- /dev/null +++ b/src/modules/dept/handlers.py @@ -0,0 +1,100 @@ +# +====================================================================== +# | 模块: 部门请求处理器层 +# | 说明: 承接部门相关的 HTTP 请求,校验权限/演示模式并记录操作日志后, +# | 转交部门服务层处理(列表、详情、增删改、下拉列表等) +# +====================================================================== +"""部门请求处理器。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:权限/演示模式装饰器、操作日志装饰器、统一响应对象、 +# 请求处理基类、部门服务单例 +# ---------------------------------------------------------------------- +from common.middleware.access_decorators import check_demo, permission_required +from common.middleware.operation_log import operation_log +from core import response as R +from core.base_handler import BaseHandler +from modules.dept.service import dept_service + + +# ====================================================================== +# 查询部门列表数据 +# ====================================================================== +class DeptListHandler(BaseHandler): + """查询部门列表数据。""" + + @permission_required("sys:dept:list") + async def get(self): + """GET 请求:返回部门列表。""" + return await dept_service.get_list(self) + + +# ====================================================================== +# 查询部门详情 +# ====================================================================== +class DeptDetailHandler(BaseHandler): + """查询部门详情。""" + + @permission_required("sys:dept:detail") + async def get(self, id): + """GET 请求:按 ID 返回部门详情。 + + :param id: 部门 ID(路径参数) + """ + return R.ok(self, data=dept_service.get_detail(id)) + + +# ====================================================================== +# 添加部门 +# ====================================================================== +class DeptAddHandler(BaseHandler): + """添加部门。""" + + @permission_required("sys:dept:add") + @operation_log("部门管理", "添加") + @check_demo + async def post(self): + """POST 请求:新增部门。""" + return await dept_service.add(self) + + +# ====================================================================== +# 更新部门 +# ====================================================================== +class DeptUpdateHandler(BaseHandler): + """更新部门。""" + + @permission_required("sys:dept:update") + @operation_log("部门管理", "更新") + @check_demo + async def put(self): + """PUT 请求:更新部门信息。""" + return await dept_service.update(self) + + +# ====================================================================== +# 删除部门 +# ====================================================================== +class DeptDeleteHandler(BaseHandler): + """删除部门。""" + + @permission_required("sys:dept:delete") + @operation_log("部门管理", "删除") + @check_demo + async def delete(self, id): + """DELETE 请求:按 ID 删除部门(软删除)。 + + :param id: 部门 ID(路径参数) + """ + return await dept_service.delete(self, id) + + +# ====================================================================== +# 获取部门下拉列表 +# ====================================================================== +class DeptGetListHandler(BaseHandler): + """获取部门下拉列表。""" + + async def get(self): + """GET 请求:返回部门下拉列表(无需权限校验)。""" + return await dept_service.get_options(self) \ No newline at end of file diff --git a/src/modules/dept/models.py b/src/modules/dept/models.py new file mode 100644 index 0000000..30c10ca --- /dev/null +++ b/src/modules/dept/models.py @@ -0,0 +1,43 @@ +# +====================================================================== +# | 模块: 部门数据模型层 +# | 说明: 定义部门表结构,继承 BaseModel 获得通用字段与序列化能力 +# +====================================================================== +"""部门数据模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:SQLAlchemy 列类型与函数、表名前缀配置、模型基类 +# ---------------------------------------------------------------------- +from sqlalchemy import Column, Integer, String, text + +from config.database import DB_PREFIX +from core.base_model import BaseModel + + +# ====================================================================== +# 部门模型 +# ====================================================================== +class Dept(BaseModel): + """部门模型。""" + + # ============================================================ + # 表定义 + # ============================================================ + __tablename__ = DB_PREFIX + "dept" + __table_comment__ = "部门表" + + # ============================================================ + # 业务字段 + # ============================================================ + name = Column(String(150), nullable=False, index=True, comment="部门名称") + type = Column(Integer, default=0, server_default=text('0'), comment="部门类型:1-公司 2-子公司 3-部门 4-小组") + parent_id = Column(Integer, default=0, server_default=text('0'), index=True, comment="上级部门ID") + sort = Column(Integer, default=0, server_default=text('0'), comment="部门排序") + note = Column(String(255), nullable=True, comment="备注") + + # ============================================================ + # 调试输出 + # ============================================================ + def __str__(self): + """调试用字符串表示:返回部门名称。""" + return f'部门{self.name}' \ No newline at end of file diff --git a/src/modules/dept/repository.py b/src/modules/dept/repository.py new file mode 100644 index 0000000..5057b0e --- /dev/null +++ b/src/modules/dept/repository.py @@ -0,0 +1,24 @@ +# +====================================================================== +# | 模块: 部门数据访问层 +# | 说明: 定义部门仓储,继承 BaseRepository 获得通用 CRUD 与软删除能力 +# +====================================================================== +"""部门数据访问。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:通用仓储基类、部门模型 +# ---------------------------------------------------------------------- +from core.base_repository import BaseRepository +from modules.dept.models import Dept + + +# ====================================================================== +# 部门仓储 +# ====================================================================== +class DeptRepository(BaseRepository): + """部门仓储。""" + + # ============================================================ + # 绑定的模型类 + # ============================================================ + model = Dept \ No newline at end of file diff --git a/src/modules/dept/schemas.py b/src/modules/dept/schemas.py new file mode 100644 index 0000000..53e59c7 --- /dev/null +++ b/src/modules/dept/schemas.py @@ -0,0 +1,31 @@ +# +====================================================================== +# | 模块: 部门请求校验模型层 +# | 说明: 定义部门创建/编辑的 Pydantic 校验模型 +# +====================================================================== +"""部门请求校验模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:类型标注、Pydantic 字段定义、Schema 基类 +# ---------------------------------------------------------------------- +from typing import Optional + +from pydantic import Field + +from core.base_schema import BaseSchema + + +# ====================================================================== +# 部门创建/编辑表单 +# ====================================================================== +class DeptSchema(BaseSchema): + """部门创建/编辑表单。""" + + # ============================================================ + # 表单字段 + # ============================================================ + name: str = Field(..., min_length=1, max_length=150, description="部门名称") + type: int = Field(..., ge=1, le=4, description="部门类型:1-公司 2-子公司 3-部门 4-小组") + parentId: int = Field(None, description="上级ID") + sort: int = Field(..., ge=0, le=99999, description="部门排序") + note: Optional[str] = Field(None, max_length=255, description="部门备注") diff --git a/src/modules/dept/service.py b/src/modules/dept/service.py new file mode 100644 index 0000000..9f13845 --- /dev/null +++ b/src/modules/dept/service.py @@ -0,0 +1,89 @@ +# +====================================================================== +# | 模块: 部门业务逻辑层 +# | 说明: 定义部门服务,声明差异点并覆写删除前校验(子部门存在性检查), +# | 最后导出服务单例 +# +====================================================================== +"""部门业务逻辑。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:SQLAlchemy 条件表达式、ID 列表解析工具、统一响应对象、 +# 业务服务基类、部门模型/仓储/校验模型 +# ---------------------------------------------------------------------- +from sqlalchemy import and_ + +from common.utils.helpers import parse_id_list +from core import response as R +from core.base_service import BaseService +from modules.dept.models import Dept +from modules.dept.repository import DeptRepository +from modules.dept.schemas import DeptSchema + + +# ====================================================================== +# 部门服务 +# ====================================================================== +class DeptService(BaseService): + """部门服务。""" + + # ============================================================ + # 基础声明 + # ============================================================ + model = Dept + repo_cls = DeptRepository + + # ============================================================ + # 差异点声明 + # ============================================================ + create_schema = DeptSchema + update_schema = DeptSchema + # 分页查询字段、排序与枚举映射 + page_like_fields = ("name",) + page_order_by = (("sort", "asc"),) + serialize_maps = {"type": "dept_type"} + + # ============================================================ + # 列表查询(不分页) + # ============================================================ + async def get_list(self, handler) -> R: + """列表查询:返回全部数据(不分页)。 + + :param handler: Tornado 请求处理器 + :return: 统一响应对象 + """ + try: + query = self.repo.filter() + name = handler.get_query_argument('name', None) + if name: + query = query.filter(Dept.name.like('%' + name + '%')) + query = query.order_by(Dept.sort.asc()) + items = query.all() + result = [self._serialize(item) for item in items] + return R.ok(handler, data=result) + finally: + self.repo.close() + + # ============================================================ + # 删除前校验(覆写) + # ============================================================ + def _before_delete(self, ids): + """删除前校验:存在子部门时拦截,避免产生指向已删除父级的孤儿节点。 + + :param ids: 逗号分隔的 ID 字符串 + :return: 拦截文案或 None(放行) + """ + id_list = parse_id_list(str(ids)) + if not id_list: + return None + # 检查这些部门下是否存在未删除的子部门 + child_count = self.repo.db.query(Dept.id).filter( + and_(Dept.parent_id.in_(id_list), Dept.is_delete == 0)).count() + if child_count: + return "存在子部门,请先删除子部门" + return None + + +# ====================================================================== +# 服务单例 +# ====================================================================== +dept_service = DeptService() diff --git a/src/modules/dictionary/__init__.py b/src/modules/dictionary/__init__.py new file mode 100644 index 0000000..6f963bb --- /dev/null +++ b/src/modules/dictionary/__init__.py @@ -0,0 +1 @@ +"""字典管理模块。""" diff --git a/src/modules/dictionary/dict/__init__.py b/src/modules/dictionary/dict/__init__.py new file mode 100644 index 0000000..11ae4b4 --- /dev/null +++ b/src/modules/dictionary/dict/__init__.py @@ -0,0 +1,5 @@ +# +====================================================================== +# | 模块: 字典管理包 +# | 说明: 声明 dictionary 为包,用于组织数据字典与字典项等子模块 +# +====================================================================== +"""字典子模块。""" diff --git a/src/modules/dictionary/dict/handlers.py b/src/modules/dictionary/dict/handlers.py new file mode 100644 index 0000000..5ba00d7 --- /dev/null +++ b/src/modules/dictionary/dict/handlers.py @@ -0,0 +1,120 @@ +# +====================================================================== +# | 模块: 字典请求处理器层 +# | 说明: 承接数据字典相关的 HTTP 请求,校验权限/演示模式并记录操作日志后, +# | 转交字典服务层处理(分页、详情、增删改、批量删除), +# | 并提供按编码获取字典项的接口 +# +====================================================================== +"""字典请求处理器。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:权限/演示模式装饰器、操作日志装饰器、统一响应对象、 +# 请求处理基类、字典服务单例 +# ---------------------------------------------------------------------- +from common.middleware.access_decorators import check_demo, permission_required +from common.middleware.operation_log import operation_log +from core import response as R +from core.base_handler import BaseHandler +from modules.dictionary.dict.service import dict_service + + +# ====================================================================== +# 查询字典分页数据 +# ====================================================================== +class DictPageHandler(BaseHandler): + """查询字典分页数据。""" + + @permission_required("sys:dict:list") + async def get(self): + """GET 请求:返回字典分页列表。""" + return await dict_service.get_page(self) + + +# ====================================================================== +# 查询字典详情 +# ====================================================================== +class DictDetailHandler(BaseHandler): + """查询字典详情。""" + + @permission_required("sys:dict:detail") + async def get(self, id): + """GET 请求:按 ID 返回字典详情。 + + :param id: 字典 ID(路径参数) + """ + return R.ok(self, data=dict_service.get_detail(id)) + + +# ====================================================================== +# 添加字典 +# ====================================================================== +class DictAddHandler(BaseHandler): + """添加字典。""" + + @permission_required("sys:dict:add") + @operation_log("字典管理", "添加") + @check_demo + async def post(self): + """POST 请求:新增字典。""" + return await dict_service.add(self) + + +# ====================================================================== +# 更新字典 +# ====================================================================== +class DictUpdateHandler(BaseHandler): + """更新字典。""" + + @permission_required("sys:dict:update") + @operation_log("字典管理", "更新") + @check_demo + async def put(self): + """PUT 请求:更新字典信息。""" + return await dict_service.update(self) + + +# ====================================================================== +# 删除字典 +# ====================================================================== +class DictDeleteHandler(BaseHandler): + """删除字典。""" + + @permission_required("sys:dict:delete") + @operation_log("字典管理", "删除") + @check_demo + async def delete(self, id): + """DELETE 请求:按 ID 删除字典(软删除)。 + + :param id: 字典 ID(路径参数) + """ + return await dict_service.delete(self, id) + + +# ====================================================================== +# 按字典编码获取字典项下拉数据 +# ====================================================================== +class DictDataHandler(BaseHandler): + """按字典编码获取字典项下拉数据。""" + + async def get(self, code): + """GET 请求:按字典编码返回字典项列表(无需权限校验)。 + + :param code: 字典类型编码(路径参数) + """ + # 局部导入,避免模块级循环依赖 + from common.utils.dict_util import get_dict_items + return R.ok(self, data=get_dict_items(code)) + + +# ====================================================================== +# 批量删除字典 +# ====================================================================== +class DictBatchDeleteHandler(BaseHandler): + """批量删除字典。""" + + @permission_required("sys:dict:delete") + @operation_log("字典管理", "批量删除") + @check_demo + async def post(self): + """POST 请求:批量删除字典(软删除)。""" + return await dict_service.batch_delete(self) diff --git a/src/modules/dictionary/dict/models.py b/src/modules/dictionary/dict/models.py new file mode 100644 index 0000000..7d6948d --- /dev/null +++ b/src/modules/dictionary/dict/models.py @@ -0,0 +1,42 @@ +# +====================================================================== +# | 模块: 字典数据模型层 +# | 说明: 定义数据字典表结构,继承 BaseModel 获得通用字段与序列化能力 +# +====================================================================== +"""字典数据模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:SQLAlchemy 列类型与函数、表名前缀配置、模型基类 +# ---------------------------------------------------------------------- +from sqlalchemy import Column, Integer, String, text + +from config.database import DB_PREFIX +from core.base_model import BaseModel + + +# ====================================================================== +# 字典模型 +# ====================================================================== +class Dict(BaseModel): + """字典模型。""" + + # ============================================================ + # 表定义 + # ============================================================ + __tablename__ = DB_PREFIX + "dict" + __table_comment__ = "数据字典表" + + # ============================================================ + # 业务字段 + # ============================================================ + name = Column(String(150), nullable=False, index=True, comment="字典名称") + code = Column(String(150), nullable=False, index=True, comment="字典编码") + sort = Column(Integer, default=0, server_default=text('0'), comment="字典排序") + note = Column(String(255), nullable=True, comment="字典备注") + + # ============================================================ + # 调试输出 + # ============================================================ + def __str__(self): + """调试用字符串表示:返回字典标识。""" + return f"字典{self.id}" diff --git a/src/modules/dictionary/dict/repository.py b/src/modules/dictionary/dict/repository.py new file mode 100644 index 0000000..e514b99 --- /dev/null +++ b/src/modules/dictionary/dict/repository.py @@ -0,0 +1,24 @@ +# +====================================================================== +# | 模块: 字典数据访问层 +# | 说明: 定义字典仓储,继承 BaseRepository 获得通用 CRUD 与软删除能力 +# +====================================================================== +"""字典数据访问。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:通用仓储基类、字典模型 +# ---------------------------------------------------------------------- +from core.base_repository import BaseRepository +from modules.dictionary.dict.models import Dict + + +# ====================================================================== +# 字典仓储 +# ====================================================================== +class DictRepository(BaseRepository): + """字典仓储。""" + + # ============================================================ + # 绑定的模型类 + # ============================================================ + model = Dict diff --git a/src/modules/dictionary/dict/schemas.py b/src/modules/dictionary/dict/schemas.py new file mode 100644 index 0000000..34c718e --- /dev/null +++ b/src/modules/dictionary/dict/schemas.py @@ -0,0 +1,30 @@ +# +====================================================================== +# | 模块: 字典请求校验模型层 +# | 说明: 定义字典创建/编辑的 Pydantic 校验模型 +# +====================================================================== +"""字典请求校验模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:类型标注、Pydantic 字段定义、Schema 基类 +# ---------------------------------------------------------------------- +from typing import Optional + +from pydantic import Field + +from core.base_schema import BaseSchema + + +# ====================================================================== +# 字典创建/编辑表单 +# ====================================================================== +class DictSchema(BaseSchema): + """字典创建/编辑表单。""" + + # ============================================================ + # 表单字段 + # ============================================================ + name: str = Field(..., max_length=150, description="字典名称") + code: str = Field(..., max_length=150, description="字典编码") + sort: int = Field(..., ge=0, le=99999, description="字典排序") + note: Optional[str] = Field(None, max_length=255, description="字典备注") diff --git a/src/modules/dictionary/dict/service.py b/src/modules/dictionary/dict/service.py new file mode 100644 index 0000000..bcfa716 --- /dev/null +++ b/src/modules/dictionary/dict/service.py @@ -0,0 +1,82 @@ +# +====================================================================== +# | 模块: 字典业务逻辑层 +# | 说明: 定义字典服务,声明差异点并覆写增删改前钩子以失效字典缓存, +# | 最后导出服务单例 +# +====================================================================== +"""字典业务逻辑。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:字典缓存工具、ID 列表解析工具、业务服务基类、 +# 字典模型/仓储/校验模型 +# ---------------------------------------------------------------------- +from common.utils import dict_util +from common.utils.helpers import parse_id_list +from core.base_service import BaseService +from modules.dictionary.dict.models import Dict +from modules.dictionary.dict.repository import DictRepository +from modules.dictionary.dict.schemas import DictSchema + + +# ====================================================================== +# 字典服务 +# ====================================================================== +class DictService(BaseService): + """字典服务。""" + + # ============================================================ + # 基础声明 + # ============================================================ + model = Dict + repo_cls = DictRepository + + # ============================================================ + # 差异点声明 + # ============================================================ + create_schema = DictSchema + update_schema = DictSchema + # 分页查询字段与排序 + page_like_fields = ("name",) + page_order_by = (("sort", "asc"),) + # 唯一性校验 + unique_fields = {"code": "字典编码不能重复"} + + # ============================================================ + # 新增前钩子(失效缓存) + # ============================================================ + def _before_add(self, handler, data): + """新增字典后失效该编码缓存。 + + :param handler: Tornado 请求处理器 + :param data: 待入库的表单数据字典 + """ + dict_util.invalidate_dict(data.get('code')) + + # ============================================================ + # 编辑前钩子(失效缓存) + # ============================================================ + def _before_update(self, handler, data): + """更新字典后失效编码缓存。 + + :param handler: Tornado 请求处理器 + :param data: 待入库的表单数据字典 + """ + dict_util.invalidate_dict(data.get('code')) + + # ============================================================ + # 删除前钩子(失效缓存) + # ============================================================ + def _before_delete(self, ids): + """删除字典前失效对应缓存。 + + :param ids: 逗号分隔的 ID 字符串 + :return: 始终返回 None(放行) + """ + dict_util.invalidate_dicts_by_ids(parse_id_list(str(ids))) + return None + + +# ====================================================================== +# 服务单例 +# ====================================================================== +dict_service = DictService() diff --git a/src/modules/dictionary/dict_item/__init__.py b/src/modules/dictionary/dict_item/__init__.py new file mode 100644 index 0000000..1d3531b --- /dev/null +++ b/src/modules/dictionary/dict_item/__init__.py @@ -0,0 +1,6 @@ +# +====================================================================== +# | 模块: 字典项管理包 +# | 说明: 声明 dict_item 为包,用于组织字典项模块的路由、处理器、 +# | 服务与数据访问 +# +====================================================================== +"""字典项子模块。""" diff --git a/src/modules/dictionary/dict_item/handlers.py b/src/modules/dictionary/dict_item/handlers.py new file mode 100644 index 0000000..aefcc52 --- /dev/null +++ b/src/modules/dictionary/dict_item/handlers.py @@ -0,0 +1,103 @@ +# +====================================================================== +# | 模块: 字典项请求处理器层 +# | 说明: 承接字典项相关的 HTTP 请求,校验权限/演示模式并记录操作日志后, +# | 转交字典项服务层处理(分页、详情、增删改、批量删除等) +# +====================================================================== +"""字典项请求处理器。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:权限/演示模式装饰器、操作日志装饰器、统一响应对象、 +# 请求处理基类、字典项服务单例 +# ---------------------------------------------------------------------- +from common.middleware.access_decorators import check_demo, permission_required +from common.middleware.operation_log import operation_log +from core import response as R +from core.base_handler import BaseHandler +from modules.dictionary.dict_item.service import dict_item_service + + +# ====================================================================== +# 查询字典项分页数据 +# ====================================================================== +class DictItemPageHandler(BaseHandler): + """查询字典项分页数据。""" + + @permission_required("sys:dict:list") + async def get(self): + """GET 请求:返回字典项分页列表。""" + return await dict_item_service.get_page(self) + + +# ====================================================================== +# 查询字典项详情 +# ====================================================================== +class DictItemDetailHandler(BaseHandler): + """查询字典项详情。""" + + @permission_required("sys:dict:detail") + async def get(self, id): + """GET 请求:按 ID 返回字典项详情。 + + :param id: 字典项 ID(路径参数) + """ + return R.ok(self, data=dict_item_service.get_detail(id)) + + +# ====================================================================== +# 添加字典项 +# ====================================================================== +class DictItemAddHandler(BaseHandler): + """添加字典项。""" + + @permission_required("sys:dict:add") + @operation_log("字典管理", "添加字典项") + @check_demo + async def post(self): + """POST 请求:新增字典项。""" + return await dict_item_service.add(self) + + +# ====================================================================== +# 更新字典项 +# ====================================================================== +class DictItemUpdateHandler(BaseHandler): + """更新字典项。""" + + @permission_required("sys:dict:update") + @operation_log("字典管理", "更新字典项") + @check_demo + async def put(self): + """PUT 请求:更新字典项信息。""" + return await dict_item_service.update(self) + + +# ====================================================================== +# 删除字典项 +# ====================================================================== +class DictItemDeleteHandler(BaseHandler): + """删除字典项。""" + + @permission_required("sys:dict:delete") + @operation_log("字典管理", "删除字典项") + @check_demo + async def delete(self, id): + """DELETE 请求:按 ID 删除字典项(软删除)。 + + :param id: 字典项 ID(路径参数) + """ + return await dict_item_service.delete(self, id) + + +# ====================================================================== +# 批量删除字典项 +# ====================================================================== +class DictItemBatchDeleteHandler(BaseHandler): + """批量删除字典项。""" + + @permission_required("sys:dict:delete") + @operation_log("字典项管理", "批量删除") + @check_demo + async def post(self): + """POST 请求:批量删除字典项(软删除)。""" + return await dict_item_service.batch_delete(self) diff --git a/src/modules/dictionary/dict_item/models.py b/src/modules/dictionary/dict_item/models.py new file mode 100644 index 0000000..7e6e535 --- /dev/null +++ b/src/modules/dictionary/dict_item/models.py @@ -0,0 +1,43 @@ +# +====================================================================== +# | 模块: 字典项数据模型层 +# | 说明: 定义字典项表结构,继承 BaseModel 获得通用字段与序列化能力 +# +====================================================================== +"""字典项数据模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:SQLAlchemy 列类型与函数、表名前缀配置、模型基类 +# ---------------------------------------------------------------------- +from sqlalchemy import Column, Integer, String, text + +from config.database import DB_PREFIX +from core.base_model import BaseModel + + +# ====================================================================== +# 字典项模型 +# ====================================================================== +class DictItem(BaseModel): + """字典项模型。""" + + # ============================================================ + # 表定义 + # ============================================================ + __tablename__ = DB_PREFIX + "dict_item" + __table_comment__ = "字典项表" + + # ============================================================ + # 业务字段 + # ============================================================ + name = Column(String(150), nullable=False, index=True, comment="字典项名称") + value = Column(String(150), nullable=False, comment="字典项值") + dict_id = Column(Integer, default=0, server_default=text('0'), index=True, comment="字典ID") + sort = Column(Integer, default=0, server_default=text('0'), comment="字典项顺序") + note = Column(String(255), nullable=True, comment="字典项备注") + + # ============================================================ + # 调试输出 + # ============================================================ + def __str__(self): + """调试用字符串表示:返回字典项标识。""" + return f"字典项{self.id}" \ No newline at end of file diff --git a/src/modules/dictionary/dict_item/repository.py b/src/modules/dictionary/dict_item/repository.py new file mode 100644 index 0000000..0e313bd --- /dev/null +++ b/src/modules/dictionary/dict_item/repository.py @@ -0,0 +1,24 @@ +# +====================================================================== +# | 模块: 字典项数据访问层 +# | 说明: 定义字典项仓储,继承 BaseRepository 获得通用 CRUD 与软删除能力 +# +====================================================================== +"""字典项数据访问。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:通用仓储基类、字典项模型 +# ---------------------------------------------------------------------- +from core.base_repository import BaseRepository +from modules.dictionary.dict_item.models import DictItem + + +# ====================================================================== +# 字典项仓储 +# ====================================================================== +class DictItemRepository(BaseRepository): + """字典项仓储。""" + + # ============================================================ + # 绑定的模型类 + # ============================================================ + model = DictItem \ No newline at end of file diff --git a/src/modules/dictionary/dict_item/schemas.py b/src/modules/dictionary/dict_item/schemas.py new file mode 100644 index 0000000..c397a4e --- /dev/null +++ b/src/modules/dictionary/dict_item/schemas.py @@ -0,0 +1,31 @@ +# +====================================================================== +# | 模块: 字典项请求校验模型层 +# | 说明: 定义字典项创建/编辑的 Pydantic 校验模型 +# +====================================================================== +"""字典项请求校验模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:类型标注、Pydantic 字段定义、Schema 基类 +# ---------------------------------------------------------------------- +from typing import Optional + +from pydantic import Field + +from core.base_schema import BaseSchema + + +# ====================================================================== +# 字典项创建/编辑表单 +# ====================================================================== +class DictItemSchema(BaseSchema): + """字典项创建/编辑表单。""" + + # ============================================================ + # 表单字段 + # ============================================================ + name: str = Field(..., max_length=150, description="字典项名称") + value: str = Field(..., max_length=150, description="字典项值") + dictId: int = Field(default=0, ge=0, description="字典ID") + sort: int = Field(..., ge=0, le=99999, description="字典项排序") + note: Optional[str] = Field(None, max_length=255, description="字典项备注") diff --git a/src/modules/dictionary/dict_item/service.py b/src/modules/dictionary/dict_item/service.py new file mode 100644 index 0000000..2f78b47 --- /dev/null +++ b/src/modules/dictionary/dict_item/service.py @@ -0,0 +1,85 @@ +# +====================================================================== +# | 模块: 字典项业务逻辑层 +# | 说明: 定义字典项服务,声明差异点并覆写增删改前钩子以失效父字典缓存, +# | 最后导出服务单例 +# +====================================================================== +"""字典项业务逻辑。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:字典缓存工具、ID 列表解析工具、业务服务基类、 +# 字典项模型/仓储/校验模型 +# ---------------------------------------------------------------------- +from common.utils import dict_util +from common.utils.helpers import parse_id_list +from core.base_service import BaseService +from modules.dictionary.dict_item.models import DictItem +from modules.dictionary.dict_item.repository import DictItemRepository +from modules.dictionary.dict_item.schemas import DictItemSchema + + +# ====================================================================== +# 字典项服务 +# ====================================================================== +class DictItemService(BaseService): + """字典项服务。""" + + # ============================================================ + # 基础声明 + # ============================================================ + model = DictItem + repo_cls = DictItemRepository + + # ============================================================ + # 差异点声明 + # ============================================================ + create_schema = DictItemSchema + update_schema = DictItemSchema + # 分页查询字段与排序 + page_like_fields = ("name",) + page_eq_fields = ("dict_id",) + page_order_by = (("sort", "asc"),) + + # ============================================================ + # 新增前钩子(失效缓存) + # ============================================================ + def _before_add(self, handler, data): + """新增字典项后失效父字典缓存。 + + :param handler: Tornado 请求处理器 + :param data: 待入库的表单数据字典 + """ + dict_util.invalidate_dicts_by_ids([data.get('dict_id')]) + + # ============================================================ + # 编辑前钩子(失效缓存) + # ============================================================ + def _before_update(self, handler, data): + """更新字典项后失效父字典缓存。 + + :param handler: Tornado 请求处理器 + :param data: 待入库的表单数据字典 + """ + dict_util.invalidate_dicts_by_ids([data.get('dict_id')]) + + # ============================================================ + # 删除前钩子(失效缓存) + # ============================================================ + def _before_delete(self, ids): + """删除字典项前失效父字典缓存。 + + :param ids: 逗号分隔的 ID 字符串 + :return: 始终返回 None(放行) + """ + id_list = parse_id_list(str(ids)) + if id_list: + # 反查待删记录,收集其所属字典 ID 后统一失效缓存 + rows = self.repo.get_by_ids(id_list) + dict_util.invalidate_dicts_by_ids({r.dict_id for r in rows}) + return None + + +# ====================================================================== +# 服务单例 +# ====================================================================== +dict_item_service = DictItemService() diff --git a/src/modules/example/__init__.py b/src/modules/example/__init__.py new file mode 100644 index 0000000..2064132 --- /dev/null +++ b/src/modules/example/__init__.py @@ -0,0 +1,5 @@ +# +====================================================================== +# | 模块: 案例演示 +# | 说明: 管理案例演示信息 +# +====================================================================== +"""案例演示模块。""" diff --git a/src/modules/example/handlers.py b/src/modules/example/handlers.py new file mode 100644 index 0000000..74c8892 --- /dev/null +++ b/src/modules/example/handlers.py @@ -0,0 +1,118 @@ +# +====================================================================== +# | 模块: 案例演示请求处理器 +# | 说明: 承接案例演示相关的 HTTP 请求,校验权限/演示模式并记录操作日志后, +# | 转交案例演示服务层处理 +# | 涵盖:分页查询、详情查询、新增、更新、删除、批量删除、状态设置等接口 +# +====================================================================== +"""案例演示请求处理器。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:权限/演示模式装饰器、操作日志装饰器、统一响应对象、 +# 请求处理基类、案例演示服务单例 +# ---------------------------------------------------------------------- +from common.middleware.access_decorators import check_demo, permission_required +from common.middleware.operation_log import operation_log +from core import response as R +from core.base_handler import BaseHandler +from modules.example.service import example_service + + +# ====================================================================== +# 查询案例演示分页数据 +# ====================================================================== +class ExamplePageHandler(BaseHandler): + """查询案例演示分页数据。""" + + @permission_required("sys:example:page") + async def get(self): + """GET 请求:返回案例演示分页列表。""" + return await example_service.get_page(self) + + +# ====================================================================== +# 查询案例演示详情 +# ====================================================================== +class ExampleDetailHandler(BaseHandler): + """查询案例演示详情。""" + + @permission_required("sys:example:detail") + async def get(self, id): + """GET 请求:按 ID 返回案例演示详情。 + + :param id: 案例演示 ID(路径参数) + """ + return R.ok(self, data=example_service.get_detail(id)) + + +# ====================================================================== +# 添加案例演示 +# ====================================================================== +class ExampleAddHandler(BaseHandler): + """添加案例演示。""" + + @permission_required("sys:example:add") + @operation_log("案例演示", "添加") + @check_demo + async def post(self): + """POST 请求:新增案例演示。""" + return await example_service.add(self) + + +# ====================================================================== +# 更新案例演示 +# ====================================================================== +class ExampleUpdateHandler(BaseHandler): + """更新案例演示。""" + + @permission_required("sys:example:update") + @operation_log("案例演示", "更新") + @check_demo + async def put(self): + """PUT 请求:更新案例演示信息。""" + return await example_service.update(self) + + +# ====================================================================== +# 删除案例演示 +# ====================================================================== +class ExampleDeleteHandler(BaseHandler): + """删除案例演示。""" + + @permission_required("sys:example:delete") + @operation_log("案例演示", "删除") + @check_demo + async def delete(self, id): + """DELETE 请求:按 ID 删除案例演示(软删除)。 + + :param id: 案例演示 ID(路径参数) + """ + return await example_service.delete(self, id) + + +# ====================================================================== +# 批量删除案例演示 +# ====================================================================== +class ExampleBatchDeleteHandler(BaseHandler): + """批量删除案例演示。""" + + @permission_required("sys:example:delete") + @operation_log("案例演示", "批量删除") + @check_demo + async def post(self): + """POST 请求:批量删除案例演示(软删除)。""" + return await example_service.batch_delete(self) + + +# ====================================================================== +# 设置案例演示状态 +# ====================================================================== +class ExampleStatusHandler(BaseHandler): + """设置案例演示状态。""" + + @permission_required("sys:example:status") + @operation_log("案例演示", "设置状态") + @check_demo + async def put(self): + """PUT 请求:修改案例演示状态(如启用 / 禁用)。""" + return await example_service.update_status(self) diff --git a/src/modules/example/models.py b/src/modules/example/models.py new file mode 100644 index 0000000..a67356d --- /dev/null +++ b/src/modules/example/models.py @@ -0,0 +1,48 @@ +# +====================================================================== +# | 模块: 案例演示数据模型层 +# | 说明: 定义案例演示表结构,继承 BaseModel 获得通用字段与序列化能力 +# +====================================================================== +"""案例演示数据模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:SQLAlchemy 列类型与函数、表名前缀配置、模型基类 +# ---------------------------------------------------------------------- +from sqlalchemy import Column, String, Integer, Text, BigInteger, DateTime, Float, Numeric, Boolean, text + +from config.database import DB_PREFIX +from core.base_model import BaseModel + + +# ====================================================================== +# 案例演示模型 +# ====================================================================== +class Example(BaseModel): + """案例演示模型。""" + + # ============================================================ + # 表定义 + # ============================================================ + __tablename__ = DB_PREFIX + "example" + __table_comment__ = "案例演示表" + + # ============================================================ + # 业务字段 + # ============================================================ + # 案例名称 + name = Column(String(255), nullable=False, default="", server_default=text("''"), comment="案例名称") + # 案例图片 + avatar = Column(String(255), nullable=False, default="", server_default=text("''"), comment="案例图片") + # 案例类型:1-类型1 2-类型2 3-类型3 4-类型4 + type = Column(Integer, default=0, server_default=text('0'), comment="案例类型:1-类型1 2-类型2 3-类型3 4-类型4") + # 案例状态:1-在用 2-停用 + status = Column(Integer, default=0, server_default=text('0'), comment="案例状态:1-在用 2-停用") + # 案例排序 + sort = Column(Integer, default=0, server_default=text('0'), comment="案例排序") + + # ============================================================ + # 调试输出 + # ============================================================ + def __str__(self): + """调试用字符串表示:返回案例演示标识。""" + return f"案例演示{self.name}" diff --git a/src/modules/example/repository.py b/src/modules/example/repository.py new file mode 100644 index 0000000..1f69614 --- /dev/null +++ b/src/modules/example/repository.py @@ -0,0 +1,24 @@ +# +====================================================================== +# | 模块: 案例演示数据访问层 +# | 说明: 定义案例演示仓储,继承 BaseRepository 获得通用 CRUD 与软删除能力 +# +====================================================================== +"""案例演示数据访问。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:通用仓储基类、案例演示模型 +# ---------------------------------------------------------------------- +from core.base_repository import BaseRepository +from modules.example.models import Example + + +# ====================================================================== +# 案例演示仓储 +# ====================================================================== +class ExampleRepository(BaseRepository): + """案例演示仓储。""" + + # ============================================================ + # 绑定的模型类 + # ============================================================ + model = Example diff --git a/src/modules/example/schemas.py b/src/modules/example/schemas.py new file mode 100644 index 0000000..ee41b48 --- /dev/null +++ b/src/modules/example/schemas.py @@ -0,0 +1,44 @@ +# +====================================================================== +# | 模块: 案例演示请求校验模型层 +# | 说明: 定义案例演示创建/编辑与状态更新的 Pydantic 校验模型 +# +====================================================================== +"""案例演示请求校验模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:类型标注、Pydantic 基类与字段定义、Schema 基类 +# ---------------------------------------------------------------------- +from typing import Optional + +from pydantic import BaseModel, Field + +from core.base_schema import BaseSchema + + +# ====================================================================== +# 案例演示创建/编辑表单 +# ====================================================================== +class ExampleSchema(BaseSchema): + """案例演示创建/编辑表单。""" + + # ============================================================ + # 表单字段 + # ============================================================ + name: str = Field(..., max_length=255, description="案例名称") + avatar: str = Field(..., max_length=255, description="案例图片") + type: Optional[int] = Field(default=None, description="案例类型:1-类型1 2-类型2 3-类型3 4-类型4") + status: Optional[int] = Field(default=None, description="案例状态:1-在用 2-停用") + sort: Optional[int] = Field(default=None, description="案例排序") + + +# ====================================================================== +# 案例演示状态更新表单 +# ====================================================================== +class ExampleStatusSchema(BaseModel): + """案例演示状态更新表单。""" + + # ============================================================ + # 表单字段 + # ============================================================ + id: int = Field(..., gt=0, description="案例演示ID") + status: int = Field(..., description="案例演示状态") diff --git a/src/modules/example/service.py b/src/modules/example/service.py new file mode 100644 index 0000000..8f4c852 --- /dev/null +++ b/src/modules/example/service.py @@ -0,0 +1,47 @@ +# +====================================================================== +# | 模块: 案例演示业务逻辑层 +# | 说明: 定义案例演示服务,声明差异点以复用通用 CRUD 流程,并导出服务单例 +# +====================================================================== +"""案例演示业务逻辑。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:业务服务基类、案例演示模型/仓储/校验模型 +# ---------------------------------------------------------------------- +from core.base_service import BaseService +from modules.example.models import Example +from modules.example.repository import ExampleRepository +from modules.example.schemas import ExampleSchema, ExampleStatusSchema + +# ====================================================================== +# 案例演示服务 +# ====================================================================== +class ExampleService(BaseService): + """案例演示服务。""" + + # ============================================================ + # 基础声明 + # ============================================================ + model = Example + repo_cls = ExampleRepository + + # ============================================================ + # 差异点声明 + # ============================================================ + create_schema = ExampleSchema + update_schema = ExampleSchema + status_schema = ExampleStatusSchema + # 分页查询字段与排序 + page_like_fields = ("name", ) + page_eq_fields = ("type", "status", ) + page_order_by = (("sort", "asc"),) + # 文件字段:add/update 时自动 save_file,serialize 时自动补全 URL + file_fields = ('avatar',) + # 枚举显示名映射:统一走 dict 字典表,避免维护双份字面量 + serialize_maps = {'type': 'example_type', 'status': 'example_status'} + + +# ====================================================================== +# 服务单例 +# ====================================================================== +example_service = ExampleService() diff --git a/src/modules/file_template/__init__.py b/src/modules/file_template/__init__.py new file mode 100644 index 0000000..1245cba --- /dev/null +++ b/src/modules/file_template/__init__.py @@ -0,0 +1,6 @@ +# +====================================================================== +# | 模块: 文件模板管理包 +# | 说明: 声明 file_template 为包,用于组织文件模板模块的路由、处理器、 +# | 服务与数据访问 +# +====================================================================== +"""文件模板模块。""" \ No newline at end of file diff --git a/src/modules/file_template/handlers.py b/src/modules/file_template/handlers.py new file mode 100644 index 0000000..41c7d51 --- /dev/null +++ b/src/modules/file_template/handlers.py @@ -0,0 +1,131 @@ +# +====================================================================== +# | 模块: 文件模板请求处理器层 +# | 说明: 承接文件模板相关的 HTTP 请求,校验权限/演示模式并记录操作日志后, +# | 转交文件模板服务层处理(分页、详情、增删改、批量删除等) +# +====================================================================== +"""文件模板请求处理器。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:权限/演示模式装饰器、操作日志装饰器、统一响应对象、 +# 请求处理基类、文件模板服务单例 +# ---------------------------------------------------------------------- +from common.middleware.access_decorators import check_demo, permission_required +from common.middleware.operation_log import operation_log +from common.utils.rich_text import get_file_url +from core import response as R +from core.base_handler import BaseHandler +from core.database import db +from modules.file_template.models import FileTemplate +from modules.file_template.service import file_template_service + + +# ====================================================================== +# 查询文件模板分页数据 +# ====================================================================== +class FileTemplatePageHandler(BaseHandler): + """查询文件模板分页数据。""" + + @permission_required("sys:filetemplate:list") + async def get(self): + """GET 请求:返回文件模板分页列表。""" + return await file_template_service.get_page(self) + + +# ====================================================================== +# 查询文件模板详情 +# ====================================================================== +class FileTemplateDetailHandler(BaseHandler): + """查询文件模板详情。""" + + @permission_required("sys:filetemplate:detail") + async def get(self, id): + """GET 请求:按 ID 返回文件模板详情。 + + :param id: 文件模板 ID(路径参数) + """ + return R.ok(self, data=file_template_service.get_detail(id)) + + +# ====================================================================== +# 添加文件模板 +# ====================================================================== +class FileTemplateAddHandler(BaseHandler): + """添加文件模板。""" + + @permission_required("sys:filetemplate:add") + @operation_log("文件模板", "添加") + @check_demo + async def post(self): + """POST 请求:新增文件模板。""" + return await file_template_service.add(self) + + +# ====================================================================== +# 更新文件模板 +# ====================================================================== +class FileTemplateUpdateHandler(BaseHandler): + """更新文件模板。""" + + @permission_required("sys:filetemplate:update") + @operation_log("文件模板", "更新") + @check_demo + async def put(self): + """PUT 请求:更新文件模板信息。""" + return await file_template_service.update(self) + + +# ====================================================================== +# 删除文件模板 +# ====================================================================== +class FileTemplateDeleteHandler(BaseHandler): + """删除文件模板。""" + + @permission_required("sys:filetemplate:delete") + @operation_log("文件模板", "删除") + @check_demo + async def delete(self, id): + """DELETE 请求:按 ID 删除文件模板(软删除)。 + + :param id: 文件模板 ID(路径参数) + """ + return await file_template_service.delete(self, id) + + +# ====================================================================== +# 批量删除文件模板 +# ====================================================================== +class FileTemplateBatchDeleteHandler(BaseHandler): + """批量删除文件模板。""" + + @permission_required("sys:filetemplate:delete") + @operation_log("文件模板管理", "批量删除") + @check_demo + async def post(self): + """POST 请求:批量删除文件模板(软删除)。""" + return await file_template_service.batch_delete(self) + + +# ====================================================================== +# 根据编码获取文件模板 +# ====================================================================== +class FileTemplateGetByCodeHandler(BaseHandler): + """根据编码获取文件模板。""" + + async def get(self, code): + """GET 请求:根据模板编码返回文件模板信息。 + + :param code: 模板编码(路径参数) + """ + try: + item = db.query(FileTemplate).filter( + FileTemplate.code == code, FileTemplate.is_delete == 0 + ).first() + if not item: + return R.failed(self, "模板不存在") + data = item.to_dict() + if data.get('filePath'): + data['filePath'] = get_file_url(data['filePath']) + return R.ok(self, data=data) + finally: + db.close() diff --git a/src/modules/file_template/models.py b/src/modules/file_template/models.py new file mode 100644 index 0000000..18cc44f --- /dev/null +++ b/src/modules/file_template/models.py @@ -0,0 +1,44 @@ +# +====================================================================== +# | 模块: 文件模板数据模型层 +# | 说明: 定义文件模板表结构,继承 BaseModel 获得通用字段与序列化能力 +# +====================================================================== +"""文件模板数据模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:SQLAlchemy 列类型与函数、表名前缀配置、模型基类 +# ---------------------------------------------------------------------- +from sqlalchemy import Column, Integer, String, text + +from config.database import DB_PREFIX +from core.base_model import BaseModel + + +# ====================================================================== +# 文件模板模型 +# ====================================================================== +class FileTemplate(BaseModel): + """文件模板模型。""" + + # ============================================================ + # 表定义 + # ============================================================ + __tablename__ = DB_PREFIX + "file_template" + __table_comment__ = "文件模板表" + + # ============================================================ + # 业务字段 + # ============================================================ + name = Column(String(100), nullable=True, index=True, comment="模板名称") + code = Column(String(50), nullable=True, index=True, comment="模板编码") + file_name = Column(String(255), nullable=True, comment="文件名称") + file_path = Column(String(255), nullable=True, comment="文件路径") + note = Column(String(255), nullable=True, comment="文件模板备注") + sort = Column(Integer, nullable=False, default=0, server_default=text('0'), comment="文件模板顺序") + + # ============================================================ + # 调试输出 + # ============================================================ + def __str__(self): + """调试用字符串表示:返回文件模板标识。""" + return f"文件模板{self.id}" diff --git a/src/modules/file_template/repository.py b/src/modules/file_template/repository.py new file mode 100644 index 0000000..a2e573f --- /dev/null +++ b/src/modules/file_template/repository.py @@ -0,0 +1,24 @@ +# +====================================================================== +# | 模块: 文件模板数据访问层 +# | 说明: 定义文件模板仓储,继承 BaseRepository 获得通用 CRUD 与软删除能力 +# +====================================================================== +"""文件模板数据访问。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:通用仓储基类、文件模板模型 +# ---------------------------------------------------------------------- +from core.base_repository import BaseRepository +from modules.file_template.models import FileTemplate + + +# ====================================================================== +# 文件模板仓储 +# ====================================================================== +class FileTemplateRepository(BaseRepository): + """文件模板仓储。""" + + # ============================================================ + # 绑定的模型类 + # ============================================================ + model = FileTemplate diff --git a/src/modules/file_template/schemas.py b/src/modules/file_template/schemas.py new file mode 100644 index 0000000..882e929 --- /dev/null +++ b/src/modules/file_template/schemas.py @@ -0,0 +1,32 @@ +# +====================================================================== +# | 模块: 文件模板请求校验模型层 +# | 说明: 定义文件模板创建/编辑的 Pydantic 校验模型 +# +====================================================================== +"""文件模板请求校验模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:类型标注、Pydantic 字段定义、Schema 基类 +# ---------------------------------------------------------------------- +from typing import Optional + +from pydantic import Field + +from core.base_schema import BaseSchema + + +# ====================================================================== +# 文件模板创建/编辑表单 +# ====================================================================== +class FileTemplateSchema(BaseSchema): + """文件模板创建/编辑表单。""" + + # ============================================================ + # 表单字段 + # ============================================================ + name: str = Field(None, max_length=100, description="模板名称") + code: Optional[str] = Field(default=None, max_length=50, description="模板编码") + fileName: Optional[str] = Field(default=None, max_length=255, description="文件名称") + filePath: Optional[str] = Field(default=None, max_length=255, description="文件路径") + note: Optional[str] = Field(default=None, max_length=255, description="文件模板备注") + sort: int = Field(default=0, ge=0, le=99999, description="文件模板顺序") diff --git a/src/modules/file_template/service.py b/src/modules/file_template/service.py new file mode 100644 index 0000000..7250a06 --- /dev/null +++ b/src/modules/file_template/service.py @@ -0,0 +1,45 @@ +# +====================================================================== +# | 模块: 文件模板业务逻辑层 +# | 说明: 定义文件模板服务,声明差异点(file_fields 自动处理文件路径), +# | 并导出服务单例 +# +====================================================================== +"""文件模板业务逻辑。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:业务服务基类、文件模板模型/仓储/校验模型 +# ---------------------------------------------------------------------- +from core.base_service import BaseService +from modules.file_template.models import FileTemplate +from modules.file_template.repository import FileTemplateRepository +from modules.file_template.schemas import FileTemplateSchema + + +# ====================================================================== +# 文件模板服务 +# ====================================================================== +class FileTemplateService(BaseService): + """文件模板服务。""" + + # ============================================================ + # 基础声明 + # ============================================================ + model = FileTemplate + repo_cls = FileTemplateRepository + + # ============================================================ + # 差异点声明 + # ============================================================ + create_schema = FileTemplateSchema + update_schema = FileTemplateSchema + # 分页查询字段与排序 + page_like_fields = ("name", "code") + page_order_by = (("sort", "asc"),) + # 文件字段:add/update 时自动 save_file,serialize 时自动补全 URL + file_fields = ("file_path",) + + +# ====================================================================== +# 服务单例 +# ====================================================================== +file_template_service = FileTemplateService() diff --git a/src/modules/generator/__init__.py b/src/modules/generator/__init__.py new file mode 100644 index 0000000..03ef56c --- /dev/null +++ b/src/modules/generator/__init__.py @@ -0,0 +1,5 @@ +# +====================================================================== +# | 模块: 代码生成器 +# | 说明: 根据数据库表结构自动生成后端模块代码与前端 Vue 页面 +# +====================================================================== +"""代码生成器模块。""" diff --git a/src/modules/generator/code_generator.py b/src/modules/generator/code_generator.py new file mode 100644 index 0000000..1608199 --- /dev/null +++ b/src/modules/generator/code_generator.py @@ -0,0 +1,724 @@ +# +====================================================================== +# | 模块: 代码生成器核心类 +# | 说明: 根据配置信息生成完整的后端三层模块代码与前端 Vue 页面代码 +# +====================================================================== + +""" +代码生成器类 +============================================================= + +根据配置信息生成完整的后端三层模块代码与前端 Vue 页面代码。 + +后端模块(src/modules/{app_name}/): + models.py / schemas.py / repository.py / service.py / handlers.py + +前端页面(ui/src/views/tool/{module_name}/ 与 ui/src/api/tool/): + 普通分页列表(ui 模板)或树状列表(ui2 模板,含 parent_id/pid 字段时自动切换) + +生成完成后自动注册到项目: + - src/api/v1/router.py(追加路由注册) +""" + +import logging +import os +import re + +from jinja2 import Environment, FileSystemLoader + +logger = logging.getLogger(__name__) + +# 本模块目录:src/modules/generator +GENERATOR_DIR = os.path.dirname(os.path.abspath(__file__)) +# 业务模块目录:src/modules +MODULES_DIR = os.path.dirname(GENERATOR_DIR) +# 源码目录:src +SRC_DIR = os.path.dirname(MODULES_DIR) +# 项目根目录 +PROJECT_ROOT = os.path.dirname(SRC_DIR) + +# 模板目录 +TEMPLATE_DIR = os.path.join(GENERATOR_DIR, 'templates') + +# 后端代码模板清单(生成文件名 = 模板名去掉 .tpl) +BACKEND_TEMPLATES = [ + '__init__.py.tpl', + 'models.py.tpl', + 'schemas.py.tpl', + 'repository.py.tpl', + 'service.py.tpl', + 'handlers.py.tpl', +] + +# 配置字段类型(config.json 参考配置使用的命名)-> SQLAlchemy 字段类型映射 +_CONFIG_TYPE_TO_DB = { + 'CharField': 'String', + 'TextField': 'Text', + 'IntegerField': 'Integer', + 'BigIntegerField': 'BigInteger', + 'DateTimeField': 'DateTime', + 'DateField': 'Date', + 'TimeField': 'Time', + 'FloatField': 'Float', + 'DecimalField': 'Numeric', + 'BooleanField': 'Boolean', + 'JSONField': 'JSON', +} + +# SQLAlchemy 字段类型 -> 配置字段类型名映射(前端模板按该命名判断字段类型) +_DB_TO_CONFIG_TYPE = { + 'String': 'CharField', + 'Text': 'TextField', + 'Integer': 'IntegerField', + 'BigInteger': 'BigIntegerField', + 'DateTime': 'DateTimeField', + 'Date': 'DateField', + 'Time': 'TimeField', + 'Float': 'FloatField', + 'Numeric': 'DecimalField', + 'Boolean': 'BooleanField', + 'JSON': 'JSONField', +} + + +def _py_string(value): + """ + 将字符串转义为可安全嵌入 Python 单引号字符串字面量 + + Args: + value: 原始字符串 + + Returns: + str: 转义后的字符串(不含引号) + """ + if not value: + return '' + return str(value).replace('\\', '\\\\').replace("'", "\\'").replace('"', '\\"') + + +def normalize_config(config): + """ + 标准化生成配置(兼容 config.json 参考配置) + + Args: + config: 生成配置字典 + + Returns: + dict: 标准化后的配置 + """ + fields = config.get('fields', []) + # 无字段或已含 db_type 的字段视为已标准化配置,直接返回(幂等) + if not fields or any('db_type' in f for f in fields): + return config + + normalized = [] + for field in fields: + # 1) 将配置字段类型映射为 SQLAlchemy 字段类型与参数 + config_type = field.get('type', 'CharField') + db_type = _CONFIG_TYPE_TO_DB.get(config_type, 'String') + max_length = field.get('max_length') + db_args = str(max_length) if db_type == 'String' and max_length else None + + # 2) 派生 Pydantic 字段类型 + form_type = 'str' if db_type in ('String', 'Text') else 'int' if db_type in ( + 'Integer', 'BigInteger') else 'float' if db_type in ( + 'Float', 'Numeric') else 'bool' if db_type == 'Boolean' else 'str' + + # 3) 数值字段的字符串默认值转为数值类型 + default = field.get('default') + if default is not None and isinstance(default, str) and db_type in ('Integer', 'BigInteger'): + try: + default = int(default) + except ValueError: + pass + + # 4) 派生命名与状态/排序标识 + name = field['name'] + normalized.append({ + **field, + 'default': default, + 'camel_name': _to_camel_case(name), + 'py_comment': field.get('comment') or name, + 'db_type': db_type, + 'db_args': db_args, + 'form_type': form_type, + 'default_repr': repr(default) if default is not None else None, + 'is_status': name in ('status', 'state', 'is_active'), + 'is_sort': name in ('sort', 'order', 'seq', 'sequence', 'priority'), + }) + + return {**config, 'fields': normalized} + + +def _to_camel_case(snake_str): + """下划线命名 -> 驼峰命名""" + if not snake_str: + return snake_str + components = str(snake_str).split('_') + return components[0] + ''.join(x.title() for x in components[1:]) + + +def _choice_tag_type(value): + """ + 根据选项值返回标签颜色类型 + + Args: + value: 选项值 + + Returns: + str: Element Plus 标签颜色类型 + """ + return {1: 'success', 2: 'danger', 3: 'warning'}.get(value, 'info') + + +# ============================================================ +# 代码生成器类 +# ============================================================ +class CodeGenerator: + """ + 代码生成器类 + ============================================================= + + 根据配置信息生成完整的业务模块代码(后端三层 + 前端 Vue 页面)。 + 支持自动注册到路由。 + + 使用示例: + config = {...} # 由 parser.generate_config_from_table 生成 + generator = CodeGenerator(config) + generator.generate(auto=True) # 生成所有代码并自动注册 + """ + + def __init__(self, config): + """ + 初始化代码生成器 + + Args: + config: 生成配置字典 + """ + # 标准化配置:兼容 config.json 参考配置(幂等,parser 产出的配置原样返回) + self.config = normalize_config(config) + # 输出目录:src/modules/{app_name} + self.output_dir = os.path.join(MODULES_DIR, self.config['app_name']) + + # 初始化 Jinja2 模板环境 + self.env = Environment( + loader=FileSystemLoader(TEMPLATE_DIR), + trim_blocks=True, + lstrip_blocks=True, + ) + + # ============================================================ + # 主生成方法 + # ============================================================ + + def generate(self, auto=False, handler=None): + """ + 执行代码生成主方法 + ============================================================= + + 生成完整的后端三层模块代码与前端 Vue 页面代码并自动注册。 + + 执行流程: + - 校验输出目录不存在(避免覆盖已有模块) + - 创建输出目录 + - 准备模板上下文数据 + - 生成后端代码文件(models/schemas/repository/service/handlers)到 modules 目录 + - 生成路由文件到 api/v1 目录 + - 生成前端 Vue 页面代码(普通列表 / 树状列表按字段自动切换) + - 自动注册到 api/v1/router.py + + Args: + auto: 是否自动模式(跳过交互确认,当前无交互差异) + handler: Tornado 请求处理器 + """ + app_name = self.config['app_name'] + + if os.path.exists(self.output_dir): + raise Exception(f"模块 {app_name} 已存在,请先删除 {self.output_dir}") + + os.makedirs(self.output_dir, exist_ok=True) + logger.info("创建输出目录: %s", self.output_dir) + + # 准备模板上下文 + context = self._prepare_context() + + # 生成后端代码文件(modules 目录) + for template_name in BACKEND_TEMPLATES: + output_path = os.path.join(self.output_dir, template_name.replace('.tpl', '')) + self._render_template(template_name, output_path, context) + + # 生成路由文件(api/v1 目录) + api_dir = os.path.join(SRC_DIR, 'api', 'v1') + route_path = os.path.join(api_dir, f"{app_name}.py") + self._render_template('route.py.tpl', route_path, context) + + # 生成前端 Vue 页面代码 + self._generate_vue_files(context) + + # 自动注册到项目 + self._register_module() + + # 自动创建菜单 + 权限节点 + self._create_menus(handler) + + logger.info("代码生成完成!输出目录: %s", self.output_dir) + + # ============================================================ + # 模板上下文 + # ============================================================ + + def _prepare_context(self): + """ + 准备模板上下文数据 + + Returns: + dict: 模板渲染上下文 + """ + config = self.config + fields = config.get('fields', []) + + # 排序字段 + sort_field = 'id' + for f in fields: + if f.get('is_sort'): + sort_field = f['name'] + break + + # 筛选字段:精确匹配进 filters,模糊匹配进 like_filters + filter_fields = [f for f in fields if f.get('filterable') and f.get('filter_type') == '精确匹配'] + like_fields = [f for f in fields if f.get('filterable') and f.get('filter_type') == '模糊查询'] + + # 状态路由 + has_status_route = any(f['name'] == 'status' for f in fields) + # 图片/富文本字段标识 + has_image_field = any(f.get('is_image') for f in fields) + has_rich_text_field = any(f.get('is_rich_text') for f in fields) + # file_fields:图片/文件类型的字段名列表(用于自动 save_file + get_file_url) + file_fields = tuple(f['name'] for f in fields if f.get('is_image')) + # 枚举字段 -> serialize_maps(字段名: 数据字典编码) + serialize_maps = {f['name']: f['dict_code'] for f in fields if f.get('dict_code')} + + # 模块中文名转义 + module_comment = config.get('module_comment') or config.get('app_name', '') + module_comment_py = _py_string(module_comment) + + return { + # 基础信息 + 'app_name': config.get('app_name', ''), + 'module_comment': module_comment, + 'module_comment_py': module_comment_py, + 'model_class_name': config.get('model_class_name', ''), + 'permission_prefix': config.get('permission_prefix', ''), + 'display_field': config.get('display_field', 'id'), + + # 功能标识 + 'has_sort': config.get('has_sort', False), + 'has_status': config.get('has_status', False), + 'has_status_route': has_status_route, + 'has_image_field': has_image_field, + 'has_rich_text_field': has_rich_text_field, + 'file_fields': file_fields, + 'serialize_maps': serialize_maps, + + # 字段列表 + 'sort_field': sort_field, + 'fields': fields, + 'form_fields': [f for f in fields if f.get('in_form', True)], + 'filter_fields': filter_fields, + 'like_fields': like_fields, + } + + # ============================================================ + # 前端模板上下文 + # ============================================================ + + def _prepare_vue_context(self, base_context): + """ + 准备前端 Vue 模板上下文 + + Args: + base_context: 后端模板上下文 + + Returns: + dict: 前端模板渲染上下文 + """ + config = self.config + context = dict(base_context) + + # 模块名 + app_name = config.get('app_name', '') + context['module_name'] = self._to_camel_case(app_name) + + # 模型类名驼峰 + model_class_name = config.get('model_class_name', '') + context['model_class_name_camel'] = ( + model_class_name[0].lower() + model_class_name[1:] if model_class_name else '' + ) + + # 字段重塑 + fields = self._build_vue_fields(config.get('fields', [])) + context['fields'] = fields + context['form_fields'] = [f for f in fields if f.get('in_form', True)] + context['list_fields'] = [f for f in fields if f.get('in_list', True)] + context['filter_fields'] = [f for f in fields if f.get('filterable')] + context['editable_fields'] = [f for f in fields if f.get('editable')] + context['searchable_fields'] = [f for f in fields if f.get('searchable')] + + # 功能标识 + context['has_export'] = config.get('has_export', False) + context['has_status'] = config.get('has_status', False) + context['has_unique_code'] = config.get('has_unique_code', False) + context['has_image_field'] = any(f.get('is_image') for f in fields) + context['has_rich_text_field'] = any(f.get('is_rich_text') for f in fields) + + # 路由前缀 + context['route_prefix'] = app_name.replace('_', '/') + context['api_path'] = f"tool/{context['module_name']}" + context['permission_prefix'] = config.get('permission_prefix', f'sys:{app_name}') + context['primary_key'] = config.get('primary_key', 'id') + context['display_field_camel'] = ( + config.get('display_field_camel') + or self._to_camel_case(config.get('display_field', 'id')) + ) + + # 树形结构判定 + tree_fields = [f for f in config.get('fields', []) if f['name'] in ('parent_id', 'pid')] + is_tree_structure = bool(tree_fields) + context['is_tree_structure'] = is_tree_structure + context['parent_id_field'] = self._to_camel_case(tree_fields[0]['name']) if tree_fields else '' + + # 普通分页列表与树状列表的展示开关 + if is_tree_structure: + context['has_search_form'] = config.get('has_tree_search', False) + context['show_selection'] = config.get('show_selection', False) + context['action_column_width'] = config.get('action_column_width', 200) + else: + context['has_search_form'] = bool(context['filter_fields']) + context['show_selection'] = True + context['action_column_width'] = 150 + + return context + + def _build_vue_fields(self, fields): + """ + 将字段配置重塑为前端模板所需的形状 + + Args: + fields: 字段配置列表 + + Returns: + list: 前端模板字段列表 + """ + vue_fields = [] + for field in fields: + raw_comment = field.get('comment', '') + vue_fields.append({ + 'name': field['name'], + 'camel_name': field.get('camel_name') or self._to_camel_case(field['name']), + 'comment': raw_comment, + 'label': self._clean_field_comment(raw_comment), + 'type': _DB_TO_CONFIG_TYPE.get(field.get('db_type', 'String'), 'CharField'), + 'type_display': field.get('type_display', '字符串'), + 'purpose': field.get('purpose', ''), + 'nullable': field.get('nullable', False), + 'max_length': field.get('max_length'), + 'is_unique': field.get('is_unique', False), + 'db_index': field.get('db_index', False), + 'default': field.get('default'), + 'choices': self._build_choices(field.get('choices')), + 'min_value': field.get('min_value'), + 'max_value': field.get('max_value'), + 'is_image': field.get('is_image', False), + 'is_rich_text': field.get('is_rich_text', False), + 'required': not field.get('nullable', False) and field.get('required', True), + 'in_form': field.get('in_form', True), + 'in_list': field.get('in_list', True), + 'filterable': field.get('filterable', False), + 'filter_type': field.get('filter_type', '模糊查询'), + 'editable': field.get('editable', True), + 'searchable': field.get('searchable', False), + }) + return vue_fields + + def _clean_field_comment(self, comment): + """ + 清洗字段注释,移除选项说明部分 + + Args: + comment: 原始字段注释 + + Returns: + str: 清洗后的注释 + """ + if not comment: + return '' + + if re.search(r'\d+[\-\:]\S+', comment): + if ':' in comment: + return comment.split(':')[0].strip() + if ':' in comment: + return comment.split(':')[0].strip() + + return comment + + def _build_choices(self, choices): + """ + 将选项元组列表转换为前端模板所需字典列表 + + Args: + choices: 选项列表 + + Returns: + list/None: [{value, label, type}] 列表 + """ + if not choices: + return None + result = [] + for value, label in choices: + result.append({ + 'value': value, + 'label': label, + 'type': _choice_tag_type(value), + }) + return result + + def _generate_vue_files(self, base_context): + """ + 生成前端 Vue 页面代码 + + Args: + base_context: 后端模板上下文 + """ + vue_context = self._prepare_vue_context(base_context) + module_name = vue_context['module_name'] + is_tree = vue_context.get('is_tree_structure', False) + + # 组件目录:ui/src/views/tool/{module_name}/ + vue_views_dir = os.path.join(PROJECT_ROOT, 'ui', 'src', 'views', 'tool', module_name) + os.makedirs(vue_views_dir, exist_ok=True) + + # 按列表类型选择模板集 + if is_tree: + component_templates = [ + ('ui2/index.vue.tpl', 'index.vue'), + ('ui2/edit.vue.tpl', 'edit.vue'), + ('ui2/detail.vue.tpl', 'detail.vue'), + ] + else: + component_templates = [ + ('ui/index.vue.tpl', 'index.vue'), + ('ui/edit.vue.tpl', 'edit.vue'), + ('ui/detail.vue.tpl', 'detail.vue'), + ('ui/querySchemas.ts.tpl', 'querySchemas.ts'), + ('ui/columns.ts.tpl', 'columns.ts'), + ] + + for template_name, output_name in component_templates: + self._render_template(template_name, os.path.join(vue_views_dir, output_name), vue_context) + + # API 接口文件 + vue_api_dir = os.path.join(PROJECT_ROOT, 'ui', 'src', 'api', 'tool') + os.makedirs(vue_api_dir, exist_ok=True) + self._render_template('ui/api.ts.tpl', os.path.join(vue_api_dir, f'{module_name}.ts'), vue_context) + + # ============================================================ + # 渲染与文件读写 + # ============================================================ + + def _render_template(self, template_name, output_path, context=None): + """ + 渲染模板并写入目标文件 + + Args: + template_name: 模板文件名 + output_path: 输出文件路径 + context: 渲染上下文 + """ + template = self.env.get_template(template_name) + rendered = template.render(context or {}) + self._write_file(output_path, rendered) + logger.info("生成文件: %s", output_path) + + @staticmethod + def _read_file(path): + """读取文本文件""" + with open(path, 'r', encoding='utf-8') as f: + return f.read() + + @staticmethod + def _write_file(path, content): + """写入文本文件""" + if not content.endswith('\n'): + content += '\n' + with open(path, 'w', encoding='utf-8') as f: + f.write(content) + + # ============================================================ + # 自动注册 + # ============================================================ + + def _register_module(self): + """ + 自动注册新模块到项目: + - src/api/v1/router.py:导入并追加新路由 + + 均为幂等操作(已存在则跳过)。 + """ + app_name = self.config['app_name'] + + # 更新 router.py + router_path = os.path.join(SRC_DIR, 'api', 'v1', 'router.py') + if os.path.exists(router_path): + content = self._read_file(router_path) + updated = self._update_router(content, app_name) + if updated != content: + self._write_file(router_path, updated) + logger.info("已更新路由注册: %s", router_path) + + @staticmethod + def _update_router(content, app_name): + """ + 更新 router.py,添加新模块的导入和注册(幂等) + + Args: + content: 原文件内容 + app_name: 模块名称 + + Returns: + str: 更新后的内容 + """ + # 检查是否已存在 + if "from api.v1 import" in content and app_name in content: + return content + + import re + + # 1. 处理 from api.v1 import (...) 多行导入 + # 找到该语句的起止位置,在闭合括号前插入新模块 + import_pattern = re.compile( + r'(from api\.v1 import\s+\()(.*?)(\))', + re.DOTALL + ) + match = import_pattern.search(content) + if match: + # 提取已有模块列表,去掉空白和换行,重新排列 + modules_str = match.group(2) + modules = [m.strip() for m in modules_str.replace('\n', '').split(',') if m.strip()] + modules.append(app_name) + # 按原格式每行4个模块重新排版 + prefix = 'from api.v1 import (' + indent = ' ' * len(prefix) + lines = [] + for idx in range(0, len(modules), 4): + chunk = ', '.join(modules[idx:idx + 4]) + if idx == 0: + lines.append(f"{prefix}{chunk}") + else: + lines.append(f"{indent}{chunk}") + new_import = ',\n'.join(lines) + ')' + content = content[:match.start()] + new_import + content[match.end():] + + # 2. 在 _RESOURCE_MODULES 列表的 ] 前插入新模块 + resource_pattern = re.compile( + r'(_RESOURCE_MODULES\s*=\s*\[)(.*?)(\])', + re.DOTALL + ) + match = resource_pattern.search(content) + if match: + modules_str = match.group(2) + modules = [m.strip() for m in modules_str.replace('\n', '').split(',') if m.strip()] + modules.append(app_name) + # 按原格式每行4个模块重新排版 + prefix = '_RESOURCE_MODULES = [' + indent = ' ' + # 保持与原文件一致的缩进格式 + new_list = f"{prefix}\n" + for idx in range(0, len(modules), 4): + chunk = ', '.join(modules[idx:idx + 4]) + new_list += f"{indent}{chunk},\n" + new_list += ']' + content = content[:match.start()] + new_list + content[match.end():] + + return content + + # ============================================================ + # 自动创建菜单 + 权限节点 + # ============================================================ + + def _create_menus(self, handler=None): + """生成代码后自动在数据库创建菜单和权限节点(幂等:同路径已存在则跳过)""" + from core.database import db + from core.context import current_realname + from modules.menu.models import Menu + + operator = current_realname.get() or '系统' + + app_name = self.config['app_name'] + module_comment = self.config.get('module_comment', app_name) + permission_prefix = self.config.get('permission_prefix', f'sys:{app_name}') + module_camel = self._to_camel_case(app_name) + + # 统一放到一级菜单"开发工具"下面 + parent_id = 189 + + try: + # 幂等检查:path 已存在则跳过 + path = f"/tool/{module_camel}" + if db.query(Menu).filter(Menu.path == path, Menu.is_delete == 0).first(): + logger.info("菜单 %s 已存在,跳过创建", path) + return + + # 二级菜单(页面,type=0) + menu = Menu( + name=module_comment, + icon='el-icon-setting', + icon2='GlobalOutlined', + parent_id=parent_id, + path=path, + component=f'{path}/index', + target=0, + permission=f'{permission_prefix}:index', + type=0, status=0, hide=0, sort=10, + create_user=operator, update_user=operator, + ) + db.add(menu) + db.flush() + + # 三级权限节点(type=1),sort 跨度 5 + actions = [ + ('page', '分页查询', 1), ('list', '列表查询', 5), ('detail', '详情查询', 10), + ('add', '新增', 15), ('update', '编辑', 20), ('delete', '删除', 25), + ('batchDelete', '批量删除', 30), ('status', '状态管理', 35), + ('import', '导入', 40), ('export', '导出', 45), + ] + for action, label, sort in actions: + db.add(Menu( + name=f'{module_comment}-{label}', + parent_id=menu.id, + permission=f'{permission_prefix}:{action}', + type=1, status=0, target=0, hide=0, sort=sort, + create_user=operator, update_user=operator, + )) + + db.commit() + logger.info("已自动创建菜单: %s(%d 个权限节点)", module_comment, len(actions)) + + except Exception: + db.rollback() + logger.warning("自动创建菜单失败(可手动创建)", exc_info=True) + + # ============================================================ + # 命名转换 + # ============================================================ + + @staticmethod + def _to_camel_case(snake_str): + """下划线命名 -> 驼峰命名""" + if not snake_str: + return snake_str + components = str(snake_str).split('_') + return components[0] + ''.join(x.title() for x in components[1:]) + + @staticmethod + def _to_pascal_case(snake_str): + """下划线命名 -> 帕斯卡命名""" + return ''.join(x.title() for x in snake_str.split('_')) diff --git a/src/modules/generator/handlers.py b/src/modules/generator/handlers.py new file mode 100644 index 0000000..8ac6858 --- /dev/null +++ b/src/modules/generator/handlers.py @@ -0,0 +1,111 @@ +# +====================================================================== +# | 模块: 代码生成器请求处理器层 +# | 说明: 承接代码生成相关的 HTTP 请求,校验权限/演示模式并记录操作日志后, +# | 转交代码生成器服务层处理(表查询、代码生成等) +# +====================================================================== +"""代码生成器请求处理器。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:权限/演示模式装饰器、操作日志装饰器、统一响应对象、 +# 请求处理基类、代码生成器服务 +# ---------------------------------------------------------------------- +from common.middleware.access_decorators import check_demo, permission_required +from common.middleware.operation_log import operation_log +from core import response as R +from core.base_handler import BaseHandler +from modules.generator import service as generator_service + + +# ====================================================================== +# 查询数据库表分页数据 +# ====================================================================== +class GeneratorPageHandler(BaseHandler): + """查询数据库表分页数据。""" + + @permission_required("sys:generator:page") + async def get(self): + """GET 请求:返回数据库表分页列表。""" + params = { + "page": self.get_query_argument("page", "1"), + "limit": self.get_query_argument("limit", "10"), + "tableName": self.get_query_argument("tableName", ""), + "tableComment": self.get_query_argument("tableComment", ""), + } + return R.ok(self, data=generator_service.get_table_page(params)) + + +# ====================================================================== +# 查询数据库表详情 +# ====================================================================== +class GeneratorDetailHandler(BaseHandler): + """查询数据库表详情。""" + + @permission_required("sys:generator:detail") + async def get(self, table_name): + """GET 请求:按表名返回数据库表详情。 + + :param table_name: 数据库表名(路径参数) + """ + data = generator_service.get_table_detail(table_name) + return R.ok(self, data=data) + + +# ====================================================================== +# 查询数据库表字段信息 +# ====================================================================== +class GeneratorColumnsHandler(BaseHandler): + """查询数据库表字段信息。""" + + @permission_required("sys:generator:detail") + async def get(self, table_name): + """GET 请求:按表名返回字段信息列表。 + + :param table_name: 数据库表名(路径参数) + """ + data = generator_service.get_table_columns(table_name) + return R.ok(self, data=data) + + +# ====================================================================== +# 代码生成 +# ====================================================================== +class GeneratorGenerateHandler(BaseHandler): + """代码生成。""" + + @permission_required("sys:generator:generate") + @operation_log("代码生成器", "生成代码") + @check_demo + async def post(self): + """POST 请求:单表/批量代码生成。 + + 请求体示例:{"tableNames": ["tornado_article|文章管理表", "tornado_example|案例表"]} + """ + body = self.parse_json(self) + try: + count = generator_service.generate_tables(body, handler=self) + return R.ok(self, msg=f"本次共计生成{count}个模块") + except Exception as e: + return R.failed(self, str(e)) + + +# ====================================================================== +# 批量代码生成 +# ====================================================================== +class GeneratorBatchGenerateHandler(BaseHandler): + """批量代码生成。""" + + @permission_required("sys:generator:batchGenerate") + @operation_log("代码生成器", "批量生成代码") + @check_demo + async def post(self): + """POST 请求:批量代码生成。 + + 请求体示例:{"tableNames": ["tornado_article|文章管理表", "tornado_example|案例表"]} + """ + body = self.parse_json(self) + try: + count = generator_service.generate_tables(body, handler=self) + return R.ok(self, msg=f"本次共计生成{count}个模块") + except Exception as e: + return R.failed(self, str(e)) diff --git a/src/modules/generator/parser.py b/src/modules/generator/parser.py new file mode 100644 index 0000000..be707b5 --- /dev/null +++ b/src/modules/generator/parser.py @@ -0,0 +1,894 @@ +# +====================================================================== +# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] +# +---------------------------------------------------------------------- +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 +# +---------------------------------------------------------------------- +# | 开源协议: Apache-2.0 +# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 +# +---------------------------------------------------------------------- +# | 官方网站: https://www.djangoadmin.cn +# +---------------------------------------------------------------------- +# | 研发团队: 小蚂蚁云团队 荣誉出品 +# +====================================================================== +# | 版权和免责声明: +# | 一、知识产权声明 +# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 +# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, +# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 +# | 二、使用限制与禁止条款 +# | 您理解并同意,禁止将本产品用于以下任何行为: +# | 1. 任何违反中华人民共和国法律法规的用途; +# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; +# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 +# | 三、责任豁免 +# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 +# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 +# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 +# | 四、违约与法律责任 +# | 如发现任何违反本声明的行为,本团队有权: +# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; +# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; +# | 3. 保留进一步采取刑事控告等法律措施的权利。 +# | 五、许可范围说明 +# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 +# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 +# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 +# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 +# | 成的一切经济损失及商誉损害。 +# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 +# | 许可协议》附件为准。 +# +====================================================================== + +# +====================================================================== +# | 模块: 数据库表结构解析模块 +# | 说明: 从 information_schema 读取表结构,生成代码生成器配置 +# +====================================================================== + +""" +数据库表结构解析模块 +============================================================= + +该模块用于从数据库中读取表结构信息,自动生成代码生成器所需的配置。 +支持解析字段类型、注释、长度、是否为空、默认值等信息,并智能识别字段类型。 + +输出配置: + - db_type: SQLAlchemy 字段类型名(String/Integer/Text/...) + - db_args: SQLAlchemy 字段类型参数(长度/精度),如 "100"、"10, 2" + - form_type: Pydantic 字段类型(str/int/...) +""" + +import logging +import re + +from sqlalchemy import text + +from config.database import DB_PREFIX +from core.database import db + +logger = logging.getLogger(__name__) + + +# ============================================================ +# 数据库表结构解析器类 +# ============================================================ +class TableSchemaParser: + """ + 数据库表结构解析器 + ============================================================= + + 从数据库中读取表结构,自动生成代码生成器配置。 + + 功能特性: + - 自动识别字段类型(String、Integer、DateTime、Text等) + - 自动识别图片字段(avatar、cover、logo、image等) + - 自动识别富文本字段(Text且注释包含关键词) + - 自动识别状态字段(status、state、is_active等) + - 自动识别排序字段(sort、order、seq等) + - 自动识别唯一编码字段(code、sn、no等) + - 自动为常用字段设置查询条件(name、title、code、mobile、email等) + - 自动为选项字段设置查询条件(type、category、status等) + - 自动识别字段是否有数据库索引,并设置为查询条件 + - 自动生成字段注释和用途说明 + """ + + def __init__(self, table_name): + """ + 初始化表结构解析器 + + Args: + table_name: 数据库表名(可以带前缀或不带前缀) + """ + # 保存原始表名(用于数据库查询,可能带前缀) + self.raw_table_name = table_name + + # 处理后的表名(用于生成代码,不带前缀) + if table_name.startswith(DB_PREFIX): + self.table_name = table_name[len(DB_PREFIX):] + else: + self.table_name = table_name + + # 排除的系统字段(这些字段在代码生成时会被跳过,对应 base_model 公共列) + self.exclude_fields = ['id', 'create_user', 'create_time', 'update_user', 'update_time', 'is_delete'] + + # 图片字段关键词 + self.image_keywords = ['avatar', 'cover', 'logo', 'image', 'photo', 'picture', 'img', 'icon', 'thumbnail', + 'banner'] + + # 富Text字段关键词(Text 字段且注释包含这些关键词时识别为富文本) + self.rich_text_keywords = ['内容', '介绍', '说明', '描述', '详情', '正文'] + + # 状态字段关键词 + self.status_keywords = ['status', 'state', 'is_active', 'is_enabled', 'is_deleted'] + + # 排序字段关键词 + self.sort_keywords = ['sort', 'order', 'seq', 'sequence', 'priority'] + + # 唯一编码字段关键词 + self.code_keywords = ['code', 'sn', 'no', 'serial', 'number', 'unique_code'] + + # 需要设置为查询条件的常用字段关键词 + self.filter_keywords = [ + 'name', 'title', 'code', 'sn', 'no', # 名称/编码类 + 'mobile', 'phone', 'tel', 'email', # 联系方式类 + 'username', 'nickname', 'realname', # 用户信息类 + 'type', 'category', 'kind', 'class', # 类型分类类 + 'status', 'state', 'level', 'grade', # 状态等级类 + 'dept_id', 'department_id', # 部门类 + 'role_id', 'position_id', 'level_id', # 关联ID类 + 'province_code', 'city_code', 'district_code', # 地区代码类 + 'gender', 'sex', # 性别类 + ] + + # 数据库类型 -> SQLAlchemy 字段类型映射 + self.db_type_mapping = { + 'varchar': 'String', + 'char': 'String', + 'text': 'Text', + 'longtext': 'Text', + 'mediumtext': 'Text', + 'tinytext': 'Text', + 'int': 'Integer', + 'tinyint': 'Integer', + 'smallint': 'Integer', + 'mediumint': 'Integer', + 'bigint': 'BigInteger', + 'datetime': 'DateTime', + 'timestamp': 'DateTime', + 'date': 'Date', + 'time': 'Time', + 'float': 'Float', + 'double': 'Float', + 'real': 'Float', + 'decimal': 'Numeric', + 'boolean': 'Boolean', + 'bit': 'Boolean', + 'json': 'JSON', + } + + # SQLAlchemy 字段类型 -> Pydantic 字段类型映射 + self.form_type_mapping = { + 'String': 'str', + 'Text': 'str', + 'Integer': 'int', + 'BigInteger': 'int', + 'Float': 'float', + 'Numeric': 'float', + 'Boolean': 'bool', + 'DateTime': 'str', + 'Date': 'str', + 'Time': 'str', + 'JSON': 'str', + } + + # SQLAlchemy 字段类型 -> 中文显示名映射 + self.type_display_mapping = { + 'String': '字符串', + 'Text': '长文本', + 'Integer': '整数', + 'BigInteger': '大整数', + 'DateTime': '日期时间', + 'Date': '日期', + 'Time': '时间', + 'Float': '浮点数', + 'Numeric': '小数', + 'Boolean': '布尔值', + 'JSON': 'JSON', + } + + # ============================================================ + # 主解析方法 + # ============================================================ + + def parse(self): + """ + 解析表结构,生成配置字典 + + Returns: + dict: 代码生成器配置字典 + """ + # 步骤1:获取表信息(注释、引擎等) + table_info = self._get_table_info(db) + + # 步骤2:获取字段列表(排除系统字段) + columns = self._get_columns(db) + logger.info(f"解析表 {self.raw_table_name}:获取到 %s 个业务字段", len(columns)) + + # 步骤3:获取字段索引信息 + indexes = self._get_indexes(db) + + # 步骤4:处理表名,移除前缀(用于生成模块名) + table_name_cleaned = self._clean_table_name() + + # 步骤5:构建应用名称和模块名称 + app_name = table_name_cleaned.lower() + module_comment = self._get_module_comment(table_info) + model_class_name = self._to_pascal_case(table_name_cleaned) + route_prefix = app_name.replace('_', '/') + + # 步骤6:构建字段列表 + fields = self._build_fields(columns, indexes) + if not fields: + logger.warning("表 %s 没有找到任何业务字段", self.raw_table_name) + + # 步骤7:判断功能标识 + has_sort = self._has_field_by_keywords(columns, self.sort_keywords) + has_status = self._has_field_by_keywords(columns, self.status_keywords) + has_unique_code = self._has_field_by_keywords(columns, self.code_keywords) + + has_image_field = any(f.get('is_image', False) for f in fields) + has_rich_text_field = any(f.get('is_rich_text', False) for f in fields) + + # 步骤8:获取显示字段 + display_field = self._get_display_field(columns) + + # 步骤9:构建完整配置 + config = { + # 基础信息 + 'app_name': app_name, + 'module_comment': module_comment, + 'model_name': app_name, + 'model_class_name': model_class_name, + 'route_prefix': route_prefix, + + # 业务描述 + 'business_description': f'标识系统中的{module_comment}数据', + 'business_description_detail': ( + f'包含{", ".join([f["comment"] for f in fields[:3]])}等属性' if fields + else f'包含{module_comment}相关属性' + ), + + # 权限和标识 + 'permission_prefix': f'sys:{self._to_camel_case(app_name)}', + 'primary_key': 'id', + 'display_field': display_field, + 'display_field_camel': self._to_camel_case(display_field), + + # 表信息 + 'table_name': self.table_name, + 'table_prefix': self._get_table_prefixes(), + 'remove_prefix': True, + + # 菜单配置 + 'menu_group': 'tool', + 'parent_menu_id': 0, + 'menu_sort': 5, + + # 功能标识 + 'has_sort': has_sort, + 'has_status': has_status, + 'has_unique_code': has_unique_code, + 'has_export': False, + 'has_image_field': has_image_field, + 'has_rich_text_field': has_rich_text_field, + + # 字段列表 + 'fields': fields, + } + + return config + + # ============================================================ + # 数据库查询 + # ============================================================ + + def _get_table_info(self, db): + """ + 获取表的元数据信息(注释、引擎、行数等) + + Returns: + dict: 表信息字典 + """ + sql = """ + SELECT + TABLE_NAME, + TABLE_COMMENT, + ENGINE, + TABLE_ROWS, + CREATE_TIME, + UPDATE_TIME + FROM + information_schema.TABLES + WHERE + TABLE_SCHEMA = (SELECT DATABASE()) + AND TABLE_NAME = :table_name + """ + rows = db.execute(text(sql), {"table_name": self.raw_table_name}).fetchall() + if rows: + return dict(rows[0]._mapping) + return {} + + def _get_columns(self, db): + """ + 获取表的字段列表信息 + + Returns: + list: 字段信息列表 + """ + sql = """ + SELECT + COLUMN_NAME, + COLUMN_COMMENT, + DATA_TYPE, + COLUMN_TYPE, + IS_NULLABLE, + COLUMN_DEFAULT, + EXTRA, + CHARACTER_MAXIMUM_LENGTH, + NUMERIC_PRECISION, + NUMERIC_SCALE + FROM + information_schema.COLUMNS + WHERE + TABLE_SCHEMA = (SELECT DATABASE()) + AND TABLE_NAME = :table_name + ORDER BY + ORDINAL_POSITION + """ + columns = [] + rows = db.execute(text(sql), {"table_name": self.raw_table_name}).fetchall() + for row in rows: + column = dict(row._mapping) + # 排除系统字段 + if column['COLUMN_NAME'] not in self.exclude_fields: + columns.append(column) + return columns + + def _get_indexes(self, db): + """ + 获取表的索引信息 + + Returns: + set: 有索引的字段名集合 + """ + sql = """ + SELECT DISTINCT + COLUMN_NAME + FROM + information_schema.STATISTICS + WHERE + TABLE_SCHEMA = (SELECT DATABASE()) + AND TABLE_NAME = :table_name + """ + rows = db.execute(text(sql), {"table_name": self.raw_table_name}).fetchall() + return {row[0] for row in rows} + + # ============================================================ + # 表名处理 + # ============================================================ + + def _clean_table_name(self): + """ + 清理表名,移除常见前缀 + + Returns: + str: 清理后的表名 + """ + table_name = self.table_name + prefixes = [DB_PREFIX, 'sys_', 'tb_', 'fastapi_', 'auth_', 'captcha_'] + + for prefix in prefixes: + if prefix and table_name.startswith(prefix): + table_name = table_name[len(prefix):] + break + + return table_name + + def _get_table_prefixes(self): + """ + 获取可能的数据表前缀列表 + + Returns: + list: 前缀列表 + """ + return [DB_PREFIX] + + def _get_module_comment(self, table_info): + """ + 获取模块中文注释 + + Args: + table_info: 表信息字典 + + Returns: + str: 模块中文注释 + """ + if table_info.get('TABLE_COMMENT'): + comment = table_info['TABLE_COMMENT'] + if comment.endswith('表'): + comment = comment[:-1] + comment = comment.strip() + if comment: + return comment + + # 从表名生成 + table_name_cleaned = self._clean_table_name() + name_parts = table_name_cleaned.split('_') + return ''.join(part.title() for part in name_parts) + + # ============================================================ + # 字段类型映射 + # ============================================================ + + def _get_field_type(self, column): + """ + 根据数据库字段类型映射到 SQLAlchemy 字段类型 + + Args: + column: 字段信息字典 + + Returns: + str: SQLAlchemy 字段类型名 + """ + data_type = column['DATA_TYPE'].lower() + return self.db_type_mapping.get(data_type, 'String') + + def _get_db_args(self, column): + """ + 获取字段类型的构造参数(长度/精度) + + Args: + column: 字段信息字典 + + Returns: + str/None: 预渲染的参数串 + """ + db_type = self._get_field_type(column) + if db_type == 'String': + max_length = column.get('CHARACTER_MAXIMUM_LENGTH') + return str(max_length) if max_length else None + if db_type == 'Numeric': + precision = column.get('NUMERIC_PRECISION') + if precision: + scale = column.get('NUMERIC_SCALE') or 0 + return f"{precision}, {scale}" + return None + return None + + def _get_form_type(self, column): + """ + 获取 Pydantic 字段类型 + + Args: + column: 字段信息字典 + + Returns: + str: Pydantic 字段类型名 + """ + db_type = self._get_field_type(column) + return self.form_type_mapping.get(db_type, 'str') + + def _get_field_type_display(self, db_type): + """ + 获取字段类型的中文显示名称 + + Args: + db_type: SQLAlchemy 字段类型名 + + Returns: + str: 类型显示名称(中文) + """ + return self.type_display_mapping.get(db_type, '字符串') + + def _get_max_length(self, column): + """ + 获取字段最大长度 + + Args: + column: 字段信息字典 + + Returns: + int/None: 最大长度 + """ + if column['DATA_TYPE'].lower() in ('varchar', 'char'): + return column.get('CHARACTER_MAXIMUM_LENGTH') + return None + + def _is_nullable(self, column): + """ + 判断字段是否可为空 + + Args: + column: 字段信息字典 + + Returns: + bool: 是否可为空 + """ + return column['IS_NULLABLE'] == 'YES' + + def _get_default_value(self, column): + """ + 获取字段默认值 + + Args: + column: 字段信息字典 + + Returns: + any: 默认值 + """ + default = column.get('COLUMN_DEFAULT') + if default is None: + return None + + if isinstance(default, str) and default.startswith("'") and default.endswith("'"): + default = default[1:-1] + + return default + + # ============================================================ + # 字段智能识别 + # ============================================================ + + def _is_image_field(self, column_name): + """ + 判断是否为图片字段 + + Args: + column_name: 字段名 + + Returns: + bool: 是否为图片字段 + """ + return any(img in column_name.lower() for img in self.image_keywords) + + def _is_status_field(self, column_name): + """ + 判断是否为状态字段 + + Args: + column_name: 字段名 + + Returns: + bool: 是否为状态字段 + """ + return any(keyword in column_name.lower() for keyword in self.status_keywords) + + def _is_sort_field(self, column_name): + """ + 判断是否为排序字段 + + Args: + column_name: 字段名 + + Returns: + bool: 是否为排序字段 + """ + return any(keyword in column_name.lower() for keyword in self.sort_keywords) + + def _get_choices(self, column): + """ + 获取字段的选项列表(从字段注释中解析) + + Args: + column: 字段信息字典 + + Returns: + tuple: (choices列表, min_value, max_value) + """ + comment = column.get('COLUMN_COMMENT', '') + pattern = r'(\d+)[:\-](\S+)' + matches = re.findall(pattern, comment) + + if matches: + choices = [] + values = [] + for match in matches: + try: + value = int(match[0]) + except ValueError: + value = match[0] + values.append(value) + choices.append((value, match[1])) + + if values: + return choices, min(values), max(values) + return choices, None, None + + return None, None, None + + def _should_filterable(self, column, indexed_columns): + """ + 判断字段是否应该作为查询条件 + + Args: + column: 字段信息字典 + indexed_columns: 有索引的字段名集合 + + Returns: + bool: 是否作为查询条件 + """ + column_name = column['COLUMN_NAME'].lower() + field_type = self._get_field_type(column) + choices, _, _ = self._get_choices(column) + has_choices = choices is not None + + if column_name in indexed_columns: + return True + + for keyword in self.filter_keywords: + if keyword in column_name: + return True + + if field_type == 'Integer' and has_choices: + return True + + for keyword in self.status_keywords: + if keyword in column_name: + return True + + for keyword in self.code_keywords: + if keyword in column_name: + return True + + return False + + def _get_filter_type(self, column): + """ + 获取查询条件的筛选类型 + + Args: + column: 字段信息字典 + + Returns: + str: 筛选类型(模糊查询/精确匹配) + """ + field_type = self._get_field_type(column) + column_name = column['COLUMN_NAME'].lower() + choices, _, _ = self._get_choices(column) + has_choices = choices is not None + + # 字符串类型且不是编码字段,使用模糊查询 + if field_type == 'String': + for keyword in self.code_keywords: + if keyword in column_name: + return '精确匹配' + return '模糊查询' + + # 整数字段,有选项或状态字段,使用精确匹配 + if field_type == 'Integer': + if has_choices: + return '精确匹配' + for keyword in self.status_keywords: + if keyword in column_name: + return '精确匹配' + + return '精确匹配' + + def _has_field_by_keywords(self, columns, keywords): + """ + 判断表中是否存在包含关键词的字段 + + Args: + columns: 字段列表 + keywords: 关键词列表 + + Returns: + bool: 是否存在 + """ + for col in columns: + column_name = col['COLUMN_NAME'].lower() + for keyword in keywords: + if keyword in column_name: + return True + return False + + def _get_display_field(self, columns): + """ + 获取显示字段(优先使用 name,否则使用第一个字符串字段) + + Args: + columns: 字段列表 + + Returns: + str: 显示字段名 + """ + for col in columns: + if col['COLUMN_NAME'].lower() == 'name': + return 'name' + for col in columns: + if col['COLUMN_NAME'].lower() == 'title': + return 'title' + for col in columns: + if self._get_field_type(col) == 'String': + return col['COLUMN_NAME'] + if columns: + return columns[0]['COLUMN_NAME'] + return 'id' + + # ============================================================ + # 构建字段配置 + # ============================================================ + + def _build_fields(self, columns, indexed_columns): + """ + 构建字段列表配置 + + Args: + columns: 字段列表 + indexed_columns: 有索引的字段名集合 + + Returns: + list: 字段配置列表 + """ + fields = [] + + for col in columns: + column_name = col['COLUMN_NAME'] + db_type = self._get_field_type(col) + is_image = self._is_image_field(column_name) + is_status = self._is_status_field(column_name) + is_sort = self._is_sort_field(column_name) + + # 判断是否为富文本字段 + is_rich_text = False + if db_type == 'Text': + comment_text = col.get('COLUMN_COMMENT', '') + for keyword in self.rich_text_keywords: + if keyword in comment_text: + is_rich_text = True + break + + # 判断是否作为查询条件 + is_filterable = self._should_filterable(col, indexed_columns) + + # 获取字段注释 + comment = col.get('COLUMN_COMMENT') or column_name.title() + if is_image and not comment: + comment = f'{column_name}图片' + elif is_status and not comment: + comment = f'{column_name}状态' + elif is_sort and not comment: + comment = f'{column_name}排序' + + # 获取选项和取值范围 + choices, min_value, max_value = self._get_choices(col) + + default = self._get_default_value(col) + # 数值字段的字符串默认值转为数值类型 + if default is not None and isinstance(default, str): + if db_type in ('Integer', 'BigInteger'): + try: + default = int(default) + except ValueError: + pass + elif db_type in ('Float', 'Numeric'): + try: + default = float(default) + except ValueError: + pass + elif db_type == 'Boolean': + default = str(default).lower() in ('1', 'true') + default_repr = repr(default) if default is not None else None + + field_config = { + 'name': column_name, + 'camel_name': self._to_camel_case(column_name), + 'comment': comment, + 'py_comment': self._py_string(comment), + 'purpose': f'存储{comment}信息', + 'db_type': db_type, + 'db_args': self._get_db_args(col), + 'form_type': self._get_form_type(col), + 'type_display': self._get_field_type_display(db_type), + 'max_length': self._get_max_length(col), + 'nullable': self._is_nullable(col), + 'required': not self._is_nullable(col), + 'is_unique': col.get('EXTRA') == 'unique' or False, + 'db_index': column_name in indexed_columns, + 'default': default, + 'default_repr': default_repr, + 'in_form': True, + 'in_list': True, + 'filterable': is_filterable, + 'filter_type': self._get_filter_type(col) if is_filterable else '', + 'editable': True, + 'searchable': db_type == 'String' and not is_image and is_filterable, + 'is_image': is_image, + 'is_rich_text': is_rich_text, + 'is_status': is_status, + 'is_sort': is_sort, + 'choices': choices, + 'min_value': min_value, + 'max_value': max_value, + } + + # 有选项时,筛选类型为精确匹配,并生成数据字典编码 + if choices: + field_config['dict_code'] = f'{self.table_name}_{column_name}' + field_config['filter_type'] = '精确匹配' + + # 图片字段特殊处理 + if is_image: + field_config['filterable'] = False + field_config['searchable'] = False + + # 排序字段特殊处理 + if is_sort: + field_config['filterable'] = False + field_config['searchable'] = False + + fields.append(field_config) + + return fields + + # ============================================================ + # 命名转换 + # ============================================================ + + def _py_string(self, value): + """ + 将字符串转义为可安全嵌入 Python 单引号字符串字面量 + + Args: + value: 原始字符串 + + Returns: + str: 转义后的字符串(不含引号) + """ + if not value: + return '' + return str(value).replace('\\', '\\\\').replace("'", "\\'").replace('"', '\\"') + + def _to_camel_case(self, snake_str): + """ + 将下划线命名转换为驼峰命名 + + Args: + snake_str: 下划线命名字符串 + + Returns: + str: 驼峰命名字符串 + """ + if not snake_str: + return snake_str + + components = str(snake_str).split('_') + return components[0] + ''.join(x.title() for x in components[1:]) + + def _to_pascal_case(self, snake_str): + """ + 将下划线命名转换为帕斯卡命名(首字母大写) + + Args: + snake_str: 下划线命名字符串 + + Returns: + str: 帕斯卡命名字符串 + """ + return ''.join(x.title() for x in snake_str.split('_')) + + +# ============================================================ +# 代码生成器集成 +# ============================================================ +def generate_config_from_table(table_name): + """ + 从数据库表生成代码生成器配置 + + Args: + table_name: 数据库表名 + + Returns: + dict: 代码生成器配置 + """ + parser = TableSchemaParser(table_name) + return parser.parse() diff --git a/src/modules/generator/service.py b/src/modules/generator/service.py new file mode 100644 index 0000000..684813d --- /dev/null +++ b/src/modules/generator/service.py @@ -0,0 +1,391 @@ +# +====================================================================== +# | 模块: 代码生成器业务逻辑层 +# | 说明: 查询数据库表元数据,生成 Tornado 三层模块代码 +# +====================================================================== + +""" +代码生成器服务模块 +============================================================= + +该模块实现了数据库表结构查询与代码生成的业务逻辑。 +包括表的分页查询、详情查询、字段查询等功能, +通过查询 information_schema 获取表元数据,并调用代码生成引擎生成 Tornado 三层模块代码。 +""" + +import logging + +from sqlalchemy import text + +from constants import PAGE_SIZE +from core.database import db + +logger = logging.getLogger(__name__) + +# 批量生成的最大表数量 +MAX_BATCH_SIZE = 50 + +# 需要排除的非业务表前缀 +_EXCLUDE_TABLE_PREFIXES = ("auth_", "captcha_") + +# 需要排除的非业务表后缀模式 +_EXCLUDE_TABLE_PATTERNS = ("%_migrations", "%_session", "%_content_type") + + +# ============================================================ +# 公开接口函数 +# ============================================================ +def get_table_page(params: dict) -> dict: + """ + 查询数据库表分页数据 + + Args: + params: 查询参数 + + Returns: + dict: 分页数据 {"records", "total", "size", "current", "pages"} + """ + try: + page_no = int(params.get("page") or 1) + page_size = int(params.get("limit") or PAGE_SIZE) + tables = _query_tables(params.get("tableName") or "", params.get("tableComment") or "") + return _paginate_tables(tables, page_no, page_size) + finally: + db.close() + + +def get_table_detail(table_name: str) -> dict: + """ + 查询数据库表详情 + + Args: + table_name: 表名 + + Returns: + dict: 表详情信息 + """ + if not table_name: + raise Exception("表名不能为空") + try: + result = _query_table_detail(table_name) + if not result: + raise Exception("表不存在") + return result + finally: + db.close() + + +def get_table_columns(table_name: str) -> list: + """ + 查询数据库表字段信息 + + Args: + table_name: 表名 + + Returns: + list: 字段信息列表 + """ + if not table_name: + raise Exception("表名不能为空") + try: + return _query_table_columns(table_name) + finally: + db.close() + + +def generate_tables(data: dict, handler=None) -> int: + """ + 统一的代码生成服务方法 + + Args: + data: 请求数据 + - tableNames: 表名列表 + handler: Tornado 请求处理器 + + Returns: + int: 成功生成的模块数量 + """ + table_names = data.get("tableNames", []) if isinstance(data, dict) else [] + if not table_names: + raise Exception("表名不能为空") + if not isinstance(table_names, list): + raise Exception("tableNames 必须是数组") + if len(table_names) > MAX_BATCH_SIZE: + raise Exception(f"批量生成最多支持 {MAX_BATCH_SIZE} 个表") + + success_count = 0 + failed_details = [] + for item in table_names: + table_name, table_comment = _parse_table_item(item) + try: + _generate_one(table_name, table_comment, handler) + success_count += 1 + except Exception as e: + logger.error(f"生成表 {table_name} 代码失败: {str(e)}") + failed_details.append(f"{table_name}: {str(e)}") + + if success_count == 0 and failed_details: + raise Exception("所有表生成失败:" + ";".join(failed_details[:3])) + + return success_count + + +# ============================================================ +# 私有辅助函数 +# ============================================================ +def _execute_rows(sql: str, params: dict): + """ + 执行只读 SQL 并返回全部行 + + Args: + sql: 原生 SQL 语句 + params: 绑定参数字典 + + Returns: + list: SQLAlchemy Row 列表 + """ + result = db.execute(text(sql), params) + return result.fetchall() + + +def _query_tables(table_name: str = "", table_comment: str = "") -> list: + """ + 查询数据库表信息 + + Args: + table_name: 表名模糊查询条件 + table_comment: 表注释模糊查询条件 + + Returns: + list: 表信息字典列表 + """ + sql = """ + SELECT + TABLE_NAME, + TABLE_COMMENT, + ENGINE, + TABLE_ROWS, + DATA_LENGTH, + AUTO_INCREMENT, + TABLE_COLLATION, + CREATE_TIME, + UPDATE_TIME + FROM + information_schema.TABLES + WHERE + TABLE_SCHEMA = (SELECT DATABASE()) + """ + + for prefix in _EXCLUDE_TABLE_PREFIXES: + sql += f" AND TABLE_NAME NOT LIKE '{prefix}%'" + for pattern in _EXCLUDE_TABLE_PATTERNS: + sql += f" AND TABLE_NAME NOT LIKE '{pattern}'" + + params = {} + if table_name: + sql += " AND TABLE_NAME LIKE :table_name" + params["table_name"] = f"%{table_name}%" + if table_comment: + sql += " AND TABLE_COMMENT LIKE :table_comment" + params["table_comment"] = f"%{table_comment}%" + + sql += " ORDER BY TABLE_NAME" + + tables = [] + for row in _execute_rows(sql, params): + row_dict = dict(row._mapping) + + auto_increment = row_dict.get("AUTO_INCREMENT") + if auto_increment is None and row_dict.get("TABLE_ROWS"): + auto_increment = row_dict["TABLE_ROWS"] + 1 + + tables.append({ + "tableName": row_dict.get("TABLE_NAME", ""), + "tableComment": row_dict.get("TABLE_COMMENT", ""), + "engine": row_dict.get("ENGINE", ""), + "tableRows": row_dict.get("TABLE_ROWS", 0), + "dataLength": row_dict.get("DATA_LENGTH", 0), + "autoIncrement": auto_increment, + "tableCollation": row_dict.get("TABLE_COLLATION", ""), + "createTime": str(row_dict.get("CREATE_TIME", "")), + "updateTime": str(row_dict.get("UPDATE_TIME", "")), + }) + + return tables + + +def _paginate_tables(tables: list, page_no: int, page_size: int) -> dict: + """ + 对表列表进行内存分页 + + Args: + tables: 表信息列表 + page_no: 当前页码 + page_size: 每页大小 + + Returns: + dict: 分页数据 {"records", "total", "size", "current", "pages"} + """ + total = len(tables) + pages = (total + page_size - 1) // page_size if page_size > 0 else 0 + if page_no < 1: + page_no = 1 + if pages > 0 and page_no > pages: + page_no = 1 + + start = (page_no - 1) * page_size + records = tables[start:start + page_size] + + return { + "records": records, + "total": total, + "size": page_size, + "current": page_no, + "pages": pages, + } + + +def _query_table_detail(table_name: str): + """ + 根据表名查询表详情 + + Args: + table_name: 表名 + + Returns: + dict/None: 表详情或 None + """ + sql = """ + SELECT + TABLE_NAME, + TABLE_COMMENT, + ENGINE, + TABLE_ROWS, + DATA_LENGTH, + AUTO_INCREMENT, + TABLE_COLLATION, + CREATE_TIME, + UPDATE_TIME + FROM + information_schema.TABLES + WHERE + TABLE_SCHEMA = (SELECT DATABASE()) + AND TABLE_NAME = :table_name + """ + rows = _execute_rows(sql, {"table_name": table_name}) + if not rows: + return None + + row_dict = dict(rows[0]._mapping) + if row_dict.get("AUTO_INCREMENT") is None and row_dict.get("TABLE_ROWS"): + row_dict["AUTO_INCREMENT"] = row_dict["TABLE_ROWS"] + 1 + + return { + "tableName": row_dict.get("TABLE_NAME", ""), + "tableComment": row_dict.get("TABLE_COMMENT", ""), + "engine": row_dict.get("ENGINE", ""), + "tableRows": row_dict.get("TABLE_ROWS", 0), + "dataLength": row_dict.get("DATA_LENGTH", 0), + "autoIncrement": row_dict.get("AUTO_INCREMENT"), + "tableCollation": row_dict.get("TABLE_COLLATION", ""), + "createTime": str(row_dict.get("CREATE_TIME", "")), + "updateTime": str(row_dict.get("UPDATE_TIME", "")), + } + + +def _query_table_columns(table_name: str) -> list: + """ + 获取表的字段信息 + + Args: + table_name: 表名 + + Returns: + list: 字段信息列表 + """ + sql = """ + SELECT + COLUMN_NAME, + COLUMN_COMMENT, + DATA_TYPE, + COLUMN_TYPE, + IS_NULLABLE, + COLUMN_DEFAULT, + EXTRA + FROM + information_schema.COLUMNS + WHERE + TABLE_SCHEMA = (SELECT DATABASE()) + AND TABLE_NAME = :table_name + ORDER BY + ORDINAL_POSITION + """ + columns = [] + for row in _execute_rows(sql, {"table_name": table_name}): + row_dict = dict(row._mapping) + columns.append({ + "columnName": row_dict.get("COLUMN_NAME", ""), + "columnComment": row_dict.get("COLUMN_COMMENT", ""), + "dataType": row_dict.get("DATA_TYPE", ""), + "columnType": row_dict.get("COLUMN_TYPE", ""), + "isNullable": row_dict.get("IS_NULLABLE", ""), + "columnDefault": row_dict.get("COLUMN_DEFAULT"), + "extra": row_dict.get("EXTRA", ""), + }) + + return columns + + +def _parse_table_item(item): + """ + 解析表项,支持三种格式: + 格式1(管道分隔): "tornado_article|文章管理表" + 格式2(纯表名): "tornado_article" + 格式3(字典): {"name": "tornado_article", "comment": "文章管理表"} + + Args: + item: 单个表项 + + Returns: + tuple: (table_name, table_comment) + """ + if isinstance(item, dict): + table_name = str(item.get("name", "")).strip() + table_comment = str(item.get("comment", "")).strip() + return table_name, table_comment + + if isinstance(item, str): + item = item.strip() + if "|" in item: + parts = item.split("|", 1) + return parts[0].strip(), parts[1].strip() if len(parts) > 1 else "" + return item, "" + + return str(item or "").strip(), "" + + +def _generate_one(table_name: str, table_comment: str, handler=None): + """ + 生成单个表的后端模块代码 + + Args: + table_name: 数据库表名 + table_comment: 表注释(可为空) + handler: Tornado 请求处理器 + """ + from modules.generator.parser import generate_config_from_table + from modules.generator.code_generator import CodeGenerator + + # 步骤1:解析表结构 + config = generate_config_from_table(table_name) + if not config or not config.get("fields"): + raise Exception(f"表 {table_name} 不存在或无业务字段") + + # 步骤2:用请求中的表注释覆盖模块中文名 + if table_comment: + module_comment = table_comment.replace("表", "").strip() + config["module_comment"] = module_comment + config["business_description"] = f"标识系统中的{module_comment}数据" + config["business_description_detail"] = f"每个{module_comment}包含相关属性" + + # 步骤3:调用代码生成器 + CodeGenerator(config).generate(auto=True, handler=handler) diff --git a/src/modules/generator/templates/__init__.py.tpl b/src/modules/generator/templates/__init__.py.tpl new file mode 100644 index 0000000..13c68a2 --- /dev/null +++ b/src/modules/generator/templates/__init__.py.tpl @@ -0,0 +1,5 @@ +# +====================================================================== +# | 模块: {{ module_comment }} +# | 说明: 管理{{ module_comment }}信息 +# +====================================================================== +"""{{ module_comment }}模块。""" diff --git a/src/modules/generator/templates/handlers.py.tpl b/src/modules/generator/templates/handlers.py.tpl new file mode 100644 index 0000000..5ece281 --- /dev/null +++ b/src/modules/generator/templates/handlers.py.tpl @@ -0,0 +1,120 @@ +# +====================================================================== +# | 模块: {{ module_comment }}请求处理器 +# | 说明: 承接{{ module_comment }}相关的 HTTP 请求,校验权限/演示模式并记录操作日志后, +# | 转交{{ module_comment }}服务层处理 +# | 涵盖:分页查询、详情查询、新增、更新、删除、批量删除{% if has_status %}、状态设置{% endif %}等接口 +# +====================================================================== +"""{{ module_comment }}请求处理器。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:权限/演示模式装饰器、操作日志装饰器、统一响应对象、 +# 请求处理基类、{{ module_comment }}服务单例 +# ---------------------------------------------------------------------- +from common.middleware.access_decorators import check_demo, permission_required +from common.middleware.operation_log import operation_log +from core import response as R +from core.base_handler import BaseHandler +from modules.{{ app_name }}.service import {{ app_name }}_service + + +# ====================================================================== +# 查询{{ module_comment }}分页数据 +# ====================================================================== +class {{ model_class_name }}PageHandler(BaseHandler): + """查询{{ module_comment }}分页数据。""" + + @permission_required("{{ permission_prefix }}:page") + async def get(self): + """GET 请求:返回{{ module_comment }}分页列表。""" + return await {{ app_name }}_service.get_page(self) + + +# ====================================================================== +# 查询{{ module_comment }}详情 +# ====================================================================== +class {{ model_class_name }}DetailHandler(BaseHandler): + """查询{{ module_comment }}详情。""" + + @permission_required("{{ permission_prefix }}:detail") + async def get(self, id): + """GET 请求:按 ID 返回{{ module_comment }}详情。 + + :param id: {{ module_comment }} ID(路径参数) + """ + return R.ok(self, data={{ app_name }}_service.get_detail(id)) + + +# ====================================================================== +# 添加{{ module_comment }} +# ====================================================================== +class {{ model_class_name }}AddHandler(BaseHandler): + """添加{{ module_comment }}。""" + + @permission_required("{{ permission_prefix }}:add") + @operation_log("{{ module_comment }}", "添加") + @check_demo + async def post(self): + """POST 请求:新增{{ module_comment }}。""" + return await {{ app_name }}_service.add(self) + + +# ====================================================================== +# 更新{{ module_comment }} +# ====================================================================== +class {{ model_class_name }}UpdateHandler(BaseHandler): + """更新{{ module_comment }}。""" + + @permission_required("{{ permission_prefix }}:update") + @operation_log("{{ module_comment }}", "更新") + @check_demo + async def put(self): + """PUT 请求:更新{{ module_comment }}信息。""" + return await {{ app_name }}_service.update(self) + + +# ====================================================================== +# 删除{{ module_comment }} +# ====================================================================== +class {{ model_class_name }}DeleteHandler(BaseHandler): + """删除{{ module_comment }}。""" + + @permission_required("{{ permission_prefix }}:delete") + @operation_log("{{ module_comment }}", "删除") + @check_demo + async def delete(self, id): + """DELETE 请求:按 ID 删除{{ module_comment }}(软删除)。 + + :param id: {{ module_comment }} ID(路径参数) + """ + return await {{ app_name }}_service.delete(self, id) + + +# ====================================================================== +# 批量删除{{ module_comment }} +# ====================================================================== +class {{ model_class_name }}BatchDeleteHandler(BaseHandler): + """批量删除{{ module_comment }}。""" + + @permission_required("{{ permission_prefix }}:delete") + @operation_log("{{ module_comment }}", "批量删除") + @check_demo + async def post(self): + """POST 请求:批量删除{{ module_comment }}(软删除)。""" + return await {{ app_name }}_service.batch_delete(self) +{% if has_status %} + + +# ====================================================================== +# 设置{{ module_comment }}状态 +# ====================================================================== +class {{ model_class_name }}StatusHandler(BaseHandler): + """设置{{ module_comment }}状态。""" + + @permission_required("{{ permission_prefix }}:status") + @operation_log("{{ module_comment }}", "设置状态") + @check_demo + async def put(self): + """PUT 请求:修改{{ module_comment }}状态(如启用 / 禁用)。""" + return await {{ app_name }}_service.update_status(self) +{% endif %} \ No newline at end of file diff --git a/src/modules/generator/templates/models.py.tpl b/src/modules/generator/templates/models.py.tpl new file mode 100644 index 0000000..ee336b5 --- /dev/null +++ b/src/modules/generator/templates/models.py.tpl @@ -0,0 +1,58 @@ +# +====================================================================== +# | 模块: {{ module_comment }}数据模型层 +# | 说明: 定义{{ module_comment }}表结构,继承 BaseModel 获得通用字段与序列化能力 +# +====================================================================== +"""{{ module_comment }}数据模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:SQLAlchemy 列类型与函数、表名前缀配置、模型基类 +# ---------------------------------------------------------------------- +from sqlalchemy import Column, String, Integer, Text, BigInteger, DateTime, Float, Numeric, Boolean, text + +from config.database import DB_PREFIX +from core.base_model import BaseModel + + +# ====================================================================== +# {{ module_comment }}模型 +# ====================================================================== +class {{ model_class_name }}(BaseModel): + """{{ module_comment }}模型。""" + + # ============================================================ + # 表定义 + # ============================================================ + __tablename__ = DB_PREFIX + "{{ app_name }}" + __table_comment__ = "{{ module_comment }}表" + + # ============================================================ + # 业务字段 + # ============================================================ +{% for f in fields %} + # {{ f.comment }} +{% if f.db_type == 'String' %} + {{ f.name }} = Column(String({{ f.db_args or 255 }}), nullable={{ 'True' if f.nullable else 'False' }}, default="", server_default=text("''"), comment="{{ f.comment }}") +{% elif f.db_type == 'Text' %} + {{ f.name }} = Column(Text, nullable={{ 'True' if f.nullable else 'False' }}, comment="{{ f.comment }}") +{% elif f.db_type == 'Integer' %} + {{ f.name }} = Column(Integer, default={{ f.default or 0 }}, server_default=text('{{ f.default or 0 }}'), comment="{{ f.comment }}") +{% elif f.db_type == 'BigInteger' %} + {{ f.name }} = Column(BigInteger, default={{ f.default or 0 }}, server_default=text('{{ f.default or 0 }}'), comment="{{ f.comment }}") +{% elif f.db_type == 'DateTime' %} + {{ f.name }} = Column(DateTime, nullable=True, comment="{{ f.comment }}") +{% elif f.db_type == 'Float' %} + {{ f.name }} = Column(Float, default=0, server_default=text('0'), comment="{{ f.comment }}") +{% elif f.db_type == 'Numeric' %} + {{ f.name }} = Column(Numeric(10, 2), default=0, server_default=text('0'), comment="{{ f.comment }}") +{% elif f.db_type == 'Boolean' %} + {{ f.name }} = Column(Boolean, default=False, server_default=text('0'), comment="{{ f.comment }}") +{% endif %} +{% endfor %} + + # ============================================================ + # 调试输出 + # ============================================================ + def __str__(self): + """调试用字符串表示:返回{{ module_comment }}标识。""" + return f"{{ module_comment }}{self.{{ display_field }}}" \ No newline at end of file diff --git a/src/modules/generator/templates/repository.py.tpl b/src/modules/generator/templates/repository.py.tpl new file mode 100644 index 0000000..2dda1e2 --- /dev/null +++ b/src/modules/generator/templates/repository.py.tpl @@ -0,0 +1,24 @@ +# +====================================================================== +# | 模块: {{ module_comment }}数据访问层 +# | 说明: 定义{{ module_comment }}仓储,继承 BaseRepository 获得通用 CRUD 与软删除能力 +# +====================================================================== +"""{{ module_comment }}数据访问。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:通用仓储基类、{{ module_comment }}模型 +# ---------------------------------------------------------------------- +from core.base_repository import BaseRepository +from modules.{{ app_name }}.models import {{ model_class_name }} + + +# ====================================================================== +# {{ module_comment }}仓储 +# ====================================================================== +class {{ model_class_name }}Repository(BaseRepository): + """{{ module_comment }}仓储。""" + + # ============================================================ + # 绑定的模型类 + # ============================================================ + model = {{ model_class_name }} \ No newline at end of file diff --git a/src/modules/generator/templates/route.py.tpl b/src/modules/generator/templates/route.py.tpl new file mode 100644 index 0000000..44daa34 --- /dev/null +++ b/src/modules/generator/templates/route.py.tpl @@ -0,0 +1,36 @@ +# +====================================================================== +# | 模块: {{ module_comment }}路由层 +# | 说明: 提供{{ module_comment }}的增删改查、批量删除{% if has_status %}、状态管理{% endif %}等 API 接口 +# +====================================================================== +"""{{ module_comment }}相关路由。""" + +# ---------------------------------------------------------------------- +# 导入{{ module_comment }}模块的各类处理器(Handler) +# 涵盖:分页、详情、新增、更新、删除、批量删除{% if has_status %}、状态变更{% endif %} +# ---------------------------------------------------------------------- +from modules.{{ app_name }}.handlers import ({{ model_class_name }}AddHandler, {{ model_class_name }}BatchDeleteHandler, + {{ model_class_name }}DeleteHandler, {{ model_class_name }}DetailHandler, + {{ model_class_name }}PageHandler, {{ model_class_name }}UpdateHandler{% if has_status %}, + {{ model_class_name }}StatusHandler{% endif %}) + +# ---------------------------------------------------------------------- +# 路由表:每一项为 (URL 正则, 处理器类) +# ---------------------------------------------------------------------- +routes = [ + # {{ module_comment }}分页查询 + (r"/{{ app_name }}/page", {{ model_class_name }}PageHandler), + # 查询{{ module_comment }}详情,路径参数为 ID + (r"/{{ app_name }}/detail/(\d+)", {{ model_class_name }}DetailHandler), + # 新增{{ module_comment }} + (r"/{{ app_name }}/add", {{ model_class_name }}AddHandler), + # 更新{{ module_comment }}信息 + (r"/{{ app_name }}/update", {{ model_class_name }}UpdateHandler), + # 删除单个{{ module_comment }},路径参数为 ID + (r"/{{ app_name }}/delete/(\d+)", {{ model_class_name }}DeleteHandler), + # 批量删除{{ module_comment }} + (r"/{{ app_name }}/batchDelete", {{ model_class_name }}BatchDeleteHandler), +{% if has_status %} + # 修改{{ module_comment }}状态(如启用 / 禁用) + (r"/{{ app_name }}/status", {{ model_class_name }}StatusHandler), +{% endif %} +] \ No newline at end of file diff --git a/src/modules/generator/templates/schemas.py.tpl b/src/modules/generator/templates/schemas.py.tpl new file mode 100644 index 0000000..4f954f9 --- /dev/null +++ b/src/modules/generator/templates/schemas.py.tpl @@ -0,0 +1,50 @@ +# +====================================================================== +# | 模块: {{ module_comment }}请求校验模型层 +# | 说明: 定义{{ module_comment }}创建/编辑与状态更新的 Pydantic 校验模型 +# +====================================================================== +"""{{ module_comment }}请求校验模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:类型标注、Pydantic 基类与字段定义、Schema 基类 +# ---------------------------------------------------------------------- +from typing import Optional + +from pydantic import BaseModel, Field + +from core.base_schema import BaseSchema + + +# ====================================================================== +# {{ module_comment }}创建/编辑表单 +# ====================================================================== +class {{ model_class_name }}Schema(BaseSchema): + """{{ module_comment }}创建/编辑表单。""" + + # ============================================================ + # 表单字段 + # ============================================================ +{% for f in form_fields %} +{% if f.form_type == 'str' %} + {{ f.camel_name }}: {{ 'Optional[str]' if f.nullable else 'str' }} = Field({{ "default=None" if f.nullable else "..." }}, max_length={{ f.max_length or 255 }}, description="{{ f.comment }}") +{% elif f.form_type == 'int' %} + {{ f.camel_name }}: {{ 'Optional[int]' if f.nullable else 'int' }} = Field({{ "default=None" if f.nullable else "..." }}, description="{{ f.comment }}") +{% elif f.form_type == 'float' %} + {{ f.camel_name }}: {{ 'Optional[float]' if f.nullable else 'float' }} = Field({{ "default=None" if f.nullable else "..." }}, description="{{ f.comment }}") +{% endif %} +{% endfor %} + + +{% if has_status %} +# ====================================================================== +# {{ module_comment }}状态更新表单 +# ====================================================================== +class {{ model_class_name }}StatusSchema(BaseModel): + """{{ module_comment }}状态更新表单。""" + + # ============================================================ + # 表单字段 + # ============================================================ + id: int = Field(..., gt=0, description="{{ module_comment }}ID") + status: int = Field(..., description="{{ module_comment }}状态") +{% endif %} \ No newline at end of file diff --git a/src/modules/generator/templates/service.py.tpl b/src/modules/generator/templates/service.py.tpl new file mode 100644 index 0000000..5a53d47 --- /dev/null +++ b/src/modules/generator/templates/service.py.tpl @@ -0,0 +1,54 @@ +# +====================================================================== +# | 模块: {{ module_comment }}业务逻辑层 +# | 说明: 定义{{ module_comment }}服务,声明差异点以复用通用 CRUD 流程,并导出服务单例 +# +====================================================================== +"""{{ module_comment }}业务逻辑。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:业务服务基类、{{ module_comment }}模型/仓储/校验模型 +# ---------------------------------------------------------------------- +from core.base_service import BaseService +from modules.{{ app_name }}.models import {{ model_class_name }} +from modules.{{ app_name }}.repository import {{ model_class_name }}Repository +from modules.{{ app_name }}.schemas import {{ model_class_name }}Schema{% if has_status %}, {{ model_class_name }}StatusSchema{% endif %} + + +# ====================================================================== +# {{ module_comment }}服务 +# ====================================================================== +class {{ model_class_name }}Service(BaseService): + """{{ module_comment }}服务。""" + + # ============================================================ + # 基础声明 + # ============================================================ + model = {{ model_class_name }} + repo_cls = {{ model_class_name }}Repository + + # ============================================================ + # 差异点声明 + # ============================================================ + create_schema = {{ model_class_name }}Schema + update_schema = {{ model_class_name }}Schema +{% if has_status %} + status_schema = {{ model_class_name }}StatusSchema +{% endif %} + # 分页查询字段与排序 + page_like_fields = ({% for f in like_fields %}"{{ f.name }}", {% endfor %}) + page_eq_fields = ({% for f in filter_fields %}"{{ f.name }}", {% endfor %}) + page_order_by = (("{{ sort_field }}", "asc"),) +{% if file_fields %} + # 文件字段:add/update 时自动 save_file,serialize 时自动补全 URL + file_fields = {{ file_fields }} +{% endif %} +{% if serialize_maps %} + # 枚举显示名映射:统一走 dict 字典表,避免维护双份字面量 + serialize_maps = {{ serialize_maps }} +{% endif %} + + +# ====================================================================== +# 服务单例 +# ====================================================================== +{{ app_name }}_service = {{ model_class_name }}Service() \ No newline at end of file diff --git a/src/modules/generator/templates/ui/api.ts.tpl b/src/modules/generator/templates/ui/api.ts.tpl new file mode 100644 index 0000000..69e39dc --- /dev/null +++ b/src/modules/generator/templates/ui/api.ts.tpl @@ -0,0 +1,83 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: {{ module_comment }}分页列表 + */ +export function get{{ model_class_name }}Page(params?) { + return http.request({ + url: '/{{ route_prefix }}/page', + method: 'GET', + params, + }); +} + +/** + * 获取全部{{ module_comment }}列表 + * @param params 参数 + * @returns 返回结果 + */ +export function get{{ model_class_name }}List(params?) { + return http.request({ + url: '/{{ route_prefix }}/list', + method: 'GET', + params, + }); +} + +/** + * @description: 根据ID获取{{ module_comment }}详情 + * @param {{ primary_key }} {{ module_comment }}ID + */ +export function get{{ model_class_name }}Detail({{ primary_key }}) { + return http.request({ + url: '/{{ route_prefix }}/detail/' + {{ primary_key }}, + method: 'GET', + }); +} + +/** + * @description: 添加{{ module_comment }} + * @param data {{ module_comment }}数据 + */ +export function {{ model_class_name_camel }}Add(data: any) { + return http.request({ + url: '/{{ route_prefix }}/add', + method: 'POST', + data, + }); +} + +/** + * @description: 更新{{ module_comment }} + * @param data {{ module_comment }}数据 + */ +export function {{ model_class_name_camel }}Update(data: any) { + return http.request({ + url: '/{{ route_prefix }}/update', + method: 'PUT', + data, + }); +} + +/** + * @description: 删除{{ module_comment }} + * @param {{ primary_key }} {{ module_comment }}ID + */ +export function {{ model_class_name_camel }}Delete({{ primary_key }}) { + return http.request({ + url: '/{{ route_prefix }}/delete/' + {{ primary_key }}, + method: 'DELETE', + }); +} + +/** + * @description: 批量删除{{ module_comment }} + * @param data {{ module_comment }}ID数组 + */ +export function {{ model_class_name_camel }}BatchDelete(data: any) { + return http.request({ + url: '/{{ route_prefix }}/batchDelete', + method: 'DELETE', + data, + }); +} \ No newline at end of file diff --git a/src/modules/generator/templates/ui/columns.ts.tpl b/src/modules/generator/templates/ui/columns.ts.tpl new file mode 100644 index 0000000..81f3a91 --- /dev/null +++ b/src/modules/generator/templates/ui/columns.ts.tpl @@ -0,0 +1,103 @@ +import { h } from 'vue'; +import { Tag, Avatar, Image } from 'ant-design-vue'; + +/** + * 表格列配置 + * 定义表格显示的列 + */ +export const columns = [ + { + title: 'ID', + dataIndex: 'id', + fixed: 'left', + width: 50, + }, +{% for field in list_fields %} + { + title: '{{ field.label }}', + dataIndex: '{{ field.camel_name }}', + width: {% if field.type == 'CharField' %}150{% elif field.type == 'IntegerField' %}100{% elif field.type == 'DateTimeField' %}180{% else %}120{% endif %}, +{% if field.is_image %} + // 图片渲染 + customRender({ record }) { + return record.{{ field.camel_name }} + ? h(Image, { + src: record.{{ field.camel_name }}, + width: 48, + height: 48, + fit: 'cover', + preview: true, + }) + : h('span', '-'); + }, +{% elif field.choices %} + // 选项标签渲染 + customRender({ record }) { + const statusMap = { + {% for choice in field.choices %} + {{ choice.value }}: { text: '{{ choice.label }}', color: {% if choice.value == 1 %}'success'{% elif choice.value == 2 %}'error'{% elif choice.value == 3 %}'warning'{% else %}'default'{% endif %} }, + {% endfor %} + }; + const status = statusMap[record.{{ field.camel_name }}]; + return h( + Tag, + { + color: status?.color || 'default', + }, + { + default: () => status?.text || record.{{ field.camel_name }}, + }, + ); + }, +{% elif field.camel_name == 'status' %} + // 状态标签渲染 + customRender({ record }) { + return h( + Tag, + { + color: record.status == 1 ? 'success' : 'error', + }, + { + default: () => (record.status == 1 ? '正常' : '停用'), + }, + ); + }, +{% elif field.type == 'CharField' and field.camel_name|lower in ['website', 'site', 'url', 'link'] %} + // 链接渲染 + customRender({ record }) { + const url = record.{{ field.camel_name }}; + return h( + 'a', + { + href: url, + target: '_blank', + rel: 'noopener noreferrer', + }, + { + default: () => url || '点击查看', + }, + ); + }, +{% else %} + // 普通文本渲染 +{% endif %} + }, +{% endfor %} + { + title: '创建人', + dataIndex: 'createUser', + width: 100, + }, + { + title: '创建时间', + dataIndex: 'createTime', + width: 180, + }, + { + title: '操作', + fixed: 'right', + dataIndex: 'action', + key: 'action', + width: 200, + }, +]; \ No newline at end of file diff --git a/src/modules/generator/templates/ui/detail.vue.tpl b/src/modules/generator/templates/ui/detail.vue.tpl new file mode 100644 index 0000000..3c7198f --- /dev/null +++ b/src/modules/generator/templates/ui/detail.vue.tpl @@ -0,0 +1,171 @@ + + + + + \ No newline at end of file diff --git a/src/modules/generator/templates/ui/edit.vue.tpl b/src/modules/generator/templates/ui/edit.vue.tpl new file mode 100644 index 0000000..cbbbdb3 --- /dev/null +++ b/src/modules/generator/templates/ui/edit.vue.tpl @@ -0,0 +1,423 @@ + + + + + \ No newline at end of file diff --git a/src/modules/generator/templates/ui/index.vue.tpl b/src/modules/generator/templates/ui/index.vue.tpl new file mode 100644 index 0000000..367a453 --- /dev/null +++ b/src/modules/generator/templates/ui/index.vue.tpl @@ -0,0 +1,278 @@ + + + + + \ No newline at end of file diff --git a/src/modules/generator/templates/ui/querySchemas.ts.tpl b/src/modules/generator/templates/ui/querySchemas.ts.tpl new file mode 100644 index 0000000..ed51acb --- /dev/null +++ b/src/modules/generator/templates/ui/querySchemas.ts.tpl @@ -0,0 +1,30 @@ +import { FormSchema } from '@/components/Form/index'; + +/** + * 查询表单配置 + * 定义搜索区域的表单项 + */ +export const schemas: FormSchema[] = [ +{% for field in filter_fields %} + { + name: '{{ field.name }}', + component: '{% if field.type == 'CharField' %}Input{% elif field.choices %}Select{% elif field.type == 'IntegerField' and not field.choices %}InputNumber{% elif field.type == 'DateField' %}DatePicker{% else %}Input{% endif %}', + label: '{{ field.label }}', + componentProps: { + placeholder: '请输入{{ field.label }}', + clearable: true, + allowClear: true, +{% if field.choices %} + options: [ +{% for choice in field.choices %} + { + label: '{{ choice.label }}', + value: {{ choice.value }}, + }, +{% endfor %} + ], +{% endif %} + }, + }, +{% endfor %} +]; \ No newline at end of file diff --git a/src/modules/generator/templates/ui2/columns.ts.tpl b/src/modules/generator/templates/ui2/columns.ts.tpl new file mode 100644 index 0000000..addb9e3 --- /dev/null +++ b/src/modules/generator/templates/ui2/columns.ts.tpl @@ -0,0 +1,97 @@ +import { h } from 'vue'; +import { Tag, Avatar, Image } from 'ant-design-vue'; + +/** + * 表格列配置 + * 定义表格显示的列 + */ +export const columns = [ +{% for field in list_fields %} + { + title: '{{ field.label }}', + dataIndex: '{{ field.camel_name }}', + width: {% if field.type == 'CharField' %}150{% elif field.type == 'IntegerField' %}100{% elif field.type == 'DateTimeField' %}180{% else %}120{% endif %}, +{% if field.is_image %} + // 图片渲染 + customRender({ record }) { + return record.{{ field.camel_name }} + ? h(Image, { + src: record.{{ field.camel_name }}, + width: 48, + height: 48, + fit: 'cover', + preview: true, + }) + : h('span', '-'); + }, +{% elif field.choices %} + // 选项标签渲染 + customRender({ record }) { + const statusMap = { + {% for choice in field.choices %} + {{ choice.value }}: { text: '{{ choice.label }}', color: {% if choice.value == 1 %}'success'{% elif choice.value == 2 %}'error'{% elif choice.value == 3 %}'warning'{% else %}'default'{% endif %} }, + {% endfor %} + }; + const status = statusMap[record.{{ field.camel_name }}]; + return h( + Tag, + { + color: status?.color || 'default', + }, + { + default: () => status?.text || record.{{ field.camel_name }}, + }, + ); + }, +{% elif field.camel_name == 'status' %} + // 状态标签渲染 + customRender({ record }) { + return h( + Tag, + { + color: record.status == 1 ? 'success' : 'error', + }, + { + default: () => (record.status == 1 ? '正常' : '停用'), + }, + ); + }, +{% elif field.type == 'CharField' and field.camel_name|lower in ['website', 'site', 'url', 'link'] %} + // 链接渲染 + customRender({ record }) { + const url = record.{{ field.camel_name }}; + return h( + 'a', + { + href: url, + target: '_blank', + rel: 'noopener noreferrer', + }, + { + default: () => url || '点击查看', + }, + ); + }, +{% else %} + // 普通文本渲染 +{% endif %} + }, +{% endfor %} + { + title: '创建人', + dataIndex: 'createUser', + width: 100, + }, + { + title: '创建时间', + dataIndex: 'createTime', + width: 180, + }, + { + title: '操作', + fixed: 'right', + dataIndex: 'action', + key: 'action', + width: 200, + }, +]; \ No newline at end of file diff --git a/src/modules/generator/templates/ui2/detail.vue.tpl b/src/modules/generator/templates/ui2/detail.vue.tpl new file mode 100644 index 0000000..3c7198f --- /dev/null +++ b/src/modules/generator/templates/ui2/detail.vue.tpl @@ -0,0 +1,171 @@ + + + + + \ No newline at end of file diff --git a/src/modules/generator/templates/ui2/edit.vue.tpl b/src/modules/generator/templates/ui2/edit.vue.tpl new file mode 100644 index 0000000..104bd22 --- /dev/null +++ b/src/modules/generator/templates/ui2/edit.vue.tpl @@ -0,0 +1,402 @@ + + + + + \ No newline at end of file diff --git a/src/modules/generator/templates/ui2/index.vue.tpl b/src/modules/generator/templates/ui2/index.vue.tpl new file mode 100644 index 0000000..b744ea9 --- /dev/null +++ b/src/modules/generator/templates/ui2/index.vue.tpl @@ -0,0 +1,260 @@ + + + + + \ No newline at end of file diff --git a/src/modules/job/__init__.py b/src/modules/job/__init__.py new file mode 100644 index 0000000..fdc3913 --- /dev/null +++ b/src/modules/job/__init__.py @@ -0,0 +1,5 @@ +# +====================================================================== +# | 模块: 任务调度包 +# | 说明: 声明 job 为包,用于组织定时任务模块的路由、处理器、服务与数据访问 +# +====================================================================== +"""任务调度模块。""" \ No newline at end of file diff --git a/src/modules/job/handlers.py b/src/modules/job/handlers.py new file mode 100644 index 0000000..a0d9bef --- /dev/null +++ b/src/modules/job/handlers.py @@ -0,0 +1,188 @@ +# +====================================================================== +# | 模块: 任务调度请求处理器层 +# | 说明: 承接定时任务相关的 HTTP 请求,校验权限/演示模式并记录操作日志后, +# | 转交任务服务层处理(分页、详情、增删改、状态、立即执行、批量删除) +# +====================================================================== +"""任务调度请求处理器。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:权限/演示模式装饰器、操作日志装饰器、统一响应对象、 +# 请求处理基类、通用异常、任务服务单例 +# ---------------------------------------------------------------------- +from common.middleware.access_decorators import check_demo, permission_required +from common.middleware.operation_log import operation_log +from core import response as R +from core.base_handler import BaseHandler +from core.exceptions import NotFoundError +from modules.job.service import job_service + + +# ====================================================================== +# 查询任务分页数据 +# ====================================================================== +class JobPageHandler(BaseHandler): + """查询任务分页数据。""" + + @permission_required("sys:job:list") + async def get(self): + """GET 请求:返回任务分页列表。""" + return await job_service.get_page(self) + + +# ====================================================================== +# 查询任务详情 +# ====================================================================== +class JobDetailHandler(BaseHandler): + """查询任务详情。""" + + @permission_required("sys:job:detail") + async def get(self, id): + """GET 请求:按 ID 返回任务详情,未找到抛 404。 + + :param id: 任务 ID(路径参数) + :raises NotFoundError: 任务不存在时抛出 + """ + data = job_service.get_detail(id) + # 记录不存在时抛统一异常,由 BaseHandler 转为标准响应 + if not data: + raise NotFoundError() + return R.ok(self, data=data) + + +# ====================================================================== +# 添加任务 +# ====================================================================== +class JobAddHandler(BaseHandler): + """添加任务。""" + + @permission_required("sys:job:add") + @operation_log("任务调度", "添加") + @check_demo + async def post(self): + """POST 请求:新增任务。""" + return await job_service.add(self) + + +# ====================================================================== +# 更新任务 +# ====================================================================== +class JobUpdateHandler(BaseHandler): + """更新任务。""" + + @permission_required("sys:job:update") + @operation_log("任务调度", "更新") + @check_demo + async def put(self): + """PUT 请求:更新任务信息。""" + return await job_service.update(self) + + +# ====================================================================== +# 删除任务 +# ====================================================================== +class JobDeleteHandler(BaseHandler): + """删除任务。""" + + @permission_required("sys:job:delete") + @operation_log("任务调度", "删除") + @check_demo + async def delete(self, id): + """DELETE 请求:按 ID 删除任务(软删除)。 + + :param id: 任务 ID(路径参数) + """ + return await job_service.delete(self, id) + + +# ====================================================================== +# 设置任务状态 +# ====================================================================== +class JobStatusHandler(BaseHandler): + """设置任务状态。""" + + @permission_required("sys:job:status") + @operation_log("任务调度", "设置状态") + @check_demo + async def post(self): + """PUT 请求:修改任务状态(如启用 / 暂停)。""" + return await job_service.update_status(self) + + +# ====================================================================== +# 立即执行任务 +# ====================================================================== +class JobRunOnceHandler(BaseHandler): + """立即执行任务。""" + + @permission_required("sys:job:runOnce") + @operation_log("任务调度", "立即执行") + @check_demo + async def post(self, id): + """POST 请求:立即执行指定任务。 + + :param id: 任务 ID(路径参数) + """ + return await job_service.runOnce(self, id) + + +# ====================================================================== +# 批量删除任务 +# ====================================================================== +class JobBatchDeleteHandler(BaseHandler): + """批量删除任务。""" + + @permission_required("sys:job:delete") + @operation_log("任务管理", "批量删除") + @check_demo + async def post(self): + """POST 请求:批量删除任务(软删除)。""" + return await job_service.batch_delete(self) + + +# ====================================================================== +# 暂停任务 +# ====================================================================== +class JobPauseHandler(BaseHandler): + """暂停任务。""" + + @permission_required("sys:job:status") + @operation_log("任务调度", "暂停任务") + @check_demo + async def put(self, id): + """PUT 请求:暂停指定任务。 + + :param id: 任务 ID(路径参数) + """ + return await job_service.pause(self, id) + + +# ====================================================================== +# 恢复任务 +# ====================================================================== +class JobResumeHandler(BaseHandler): + """恢复任务。""" + + @permission_required("sys:job:status") + @operation_log("任务调度", "恢复任务") + @check_demo + async def put(self, id): + """PUT 请求:恢复指定任务。 + + :param id: 任务 ID(路径参数) + """ + return await job_service.resume(self, id) + + +# ====================================================================== +# 重新加载任务 +# ====================================================================== +class JobReloadHandler(BaseHandler): + """重新加载任务。""" + + @permission_required("sys:job:reload") + @operation_log("任务调度", "重新加载任务") + @check_demo + async def post(self): + """POST 请求:重新加载所有任务。""" + return await job_service.reload(self) diff --git a/src/modules/job/models.py b/src/modules/job/models.py new file mode 100644 index 0000000..efa53b6 --- /dev/null +++ b/src/modules/job/models.py @@ -0,0 +1,52 @@ +# +====================================================================== +# | 模块: 任务调度数据模型层 +# | 说明: 定义定时任务表结构,继承 BaseModel 获得通用字段与序列化能力 +# +====================================================================== +"""任务调度数据模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:SQLAlchemy 列类型与函数、表名前缀配置、模型基类 +# ---------------------------------------------------------------------- +from sqlalchemy import Column, SmallInteger, String, Text, text + +from config.database import DB_PREFIX +from core.base_model import BaseModel + + +# ====================================================================== +# 任务调度模型 +# ====================================================================== +class Job(BaseModel): + """任务调度模型。""" + + # ============================================================ + # 表定义 + # ============================================================ + __tablename__ = DB_PREFIX + "job" + __table_comment__ = "定时任务表" + + # ============================================================ + # 业务字段 + # ============================================================ + job_name = Column(String(255), nullable=False, index=True, comment="任务名称") + job_alias = Column(String(255), nullable=True, comment="任务别名") + job_group = Column(String(255), nullable=True, comment="任务分组") + job_trigger = Column(String(255), nullable=True, comment="任务触发器") + status = Column(SmallInteger, nullable=False, default=0, server_default=text('0'), index=True, + comment="任务状态:0-未发布 1-运行中 2-暂停 3-删除") + cron_expression = Column(String(255), nullable=True, comment="Cron表达式") + execute_policy = Column(SmallInteger, nullable=False, default=1, server_default=text('1'), + comment="执行策略:1-立即执行 2-执行一次 3-放弃执行") + is_sync = Column(SmallInteger, nullable=False, default=0, server_default=text('0'), + comment="是否同步任务:0-否 1-是") + url = Column(String(255), nullable=True, comment="任务URL") + params = Column(Text, nullable=True, comment="任务参数") + note = Column(String(255), nullable=True, comment="任务备注") + + # ============================================================ + # 调试输出 + # ============================================================ + def __str__(self): + """调试用字符串表示:返回任务标识。""" + return f"任务{self.id}" diff --git a/src/modules/job/repository.py b/src/modules/job/repository.py new file mode 100644 index 0000000..511cfba --- /dev/null +++ b/src/modules/job/repository.py @@ -0,0 +1,24 @@ +# +====================================================================== +# | 模块: 任务调度数据访问层 +# | 说明: 定义任务调度仓储,继承 BaseRepository 获得通用 CRUD 与软删除能力 +# +====================================================================== +"""任务调度数据访问。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:通用仓储基类、任务调度模型 +# ---------------------------------------------------------------------- +from core.base_repository import BaseRepository +from modules.job.models import Job + + +# ====================================================================== +# 任务调度仓储 +# ====================================================================== +class JobRepository(BaseRepository): + """任务调度仓储。""" + + # ============================================================ + # 绑定的模型类 + # ============================================================ + model = Job diff --git a/src/modules/job/scheduler.py b/src/modules/job/scheduler.py new file mode 100644 index 0000000..643ce26 --- /dev/null +++ b/src/modules/job/scheduler.py @@ -0,0 +1,397 @@ +# +====================================================================== +# | 模块: 任务调度器层 +# | 说明: 基于 APScheduler 封装全局后台调度器,负责任务加载、触发执行、 +# | 状态联动、线程安全快照与 SSRF 防护 +# +====================================================================== +"""任务调度器(APScheduler 封装)。 + +- 全局后台调度器,启动时加载运行中的任务 +- 任务触发执行:向任务 URL 发起 HTTP 请求 +- 状态联动:运行中->调度/更新;暂停->暂停;删除/未发布->移除 +- 线程安全: + - 后台线程只消费 Job 字段快照(JobSnapshot),绝不接触请求线程加载的 SQLAlchemy 实例, + 避免 detached 实例在后台线程访问属性时抛 DetachedInstanceError 或读到过期快照; + - 防重叠集合 _running 用锁保护,避免并发触发时重复执行。 +- 防 SSRF:任务 URL 仅允许 http/https 且目标必须是公网地址,禁止 file:// 等本地文件读取 + 与内网/回环/云元数据地址探测。 +""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:IP/套接字/线程/HTTP 请求/线程池/日期时间/类型标注/URL 解析/时区、 +# APScheduler 后台调度器与 Cron 触发器、日志器 +# ---------------------------------------------------------------------- +import ipaddress +import socket +import threading +import urllib.request +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime +from typing import Optional +from urllib.parse import urlparse +from zoneinfo import ZoneInfo + +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +from core.logger import logger + +# ============================================================ +# 全局对象与运行状态 +# ============================================================ +# 时区 +TZ = ZoneInfo("Asia/Shanghai") + +# 全局后台调度器 +scheduler = BackgroundScheduler(timezone=TZ) + +# 立即执行用线程池,避免阻塞 Tornado 事件循环 +_executor = ThreadPoolExecutor(max_workers=4) + +# 正在执行的任务 ID 集合(用于执行策略防重叠) +_running: set = set() +# _running 的并发访问锁 +_running_lock = threading.Lock() + + +# ====================================================================== +# 禁重定向处理器 +# ====================================================================== +class _NoRedirectHandler(urllib.request.HTTPRedirectHandler): + """禁止跟随 HTTP 重定向。 + + urlopen 默认跟随 3xx,任务 URL 指向公网服务器但 302 跳转到内网/云元数据地址时, + 会绕过 validate_job_url 的公网校验形成 SSRF 通道,故对重定向一律拒绝。 + """ + + def redirect_request(self, req, fp, code, msg, headers, newurl): + """拒绝重定向:返回 None 使 urlopen 抛出 HTTPError。""" + return None + + +# 禁重定向的 opener(redirect_request 返回 None 时 urlopen 抛 HTTPError,视作触发失败) +_no_redirect_opener = urllib.request.build_opener(_NoRedirectHandler()) + + +# ====================================================================== +# 任务字段快照 +# ====================================================================== +class JobSnapshot: + """任务字段快照:把请求线程加载的 SQLAlchemy 实例转成纯 Python 对象再交给后台线程。 + + 在请求线程内(对象仍 attached)同步读取所需字段,后台线程只读快照, + 避免跨线程访问 detached ORM 实例。 + """ + + __slots__ = ('id', 'job_name', 'job_group', 'job_trigger', + 'cron_expression', 'execute_policy', 'url', 'status') + + def __init__(self, job) -> None: + """在请求线程内读取并固化任务字段。 + + :param job: 任务 ORM 实例 + """ + self.id = job.id + self.job_name = job.job_name or "" + self.job_group = job.job_group + self.job_trigger = job.job_trigger + self.cron_expression = job.cron_expression + self.execute_policy = int(job.execute_policy or 1) + self.url = job.url or "" + self.status = int(job.status or 0) + + +# ============================================================ +# 生成任务快照 +# ============================================================ +def _snapshot_job(job) -> JobSnapshot: + """生成任务快照(兼容 ORM 实例与已生成的 JobSnapshot,重复快照仅复制字段)。 + + :param job: 任务 ORM 实例或 JobSnapshot + :return: JobSnapshot 实例 + """ + return job if isinstance(job, JobSnapshot) else JobSnapshot(job) + + +# ====================================================================== +# URL 与 Cron 校验 +# ====================================================================== + +# ============================================================ +# 校验任务 URL(防 SSRF) +# ============================================================ +def validate_job_url(url: str) -> bool: + """校验任务 URL:仅允许 http/https,且解析后的目标 IP 必须是公网地址(防 SSRF)。 + + 对全部解析出的地址逐一判断,任一为非公网(私网/回环/链路本地/组播/保留等)即拒绝。 + 注意:getaddrinfo 只做一次解析,对 DNS 重绑定攻击的防护有限,属基础防线。 + + :param url: 待校验的任务 URL + :return: 合法返回 True,否则 False + """ + try: + parsed = urlparse(url) + # 仅允许 http/https 协议 + if parsed.scheme not in ("http", "https"): + return False + host = parsed.hostname + if not host: + return False + # 解析全部地址并逐一判断是否为公网 + infos = socket.getaddrinfo(host, None) + if not infos: + return False + for info in infos: + ip = ipaddress.ip_address(info[4][0]) + if not ip.is_global: + return False + return True + except Exception: + return False + + +# ============================================================ +# 校验 Cron 表达式 +# ============================================================ +def validate_cron_expression(cron: str) -> Optional[str]: + """校验 Cron 表达式,合法返回 None,非法返回错误文案。 + + :param cron: Cron 表达式 + :return: 错误文案或 None + """ + if not cron: + return "Cron表达式不能为空" + try: + CronTrigger.from_crontab(cron, timezone=TZ) + except ValueError as e: + return f"Cron表达式格式不正确:{e}" + return None + + +# ====================================================================== +# 记录任务执行日志 +# ====================================================================== +def _record_job(snapshot: JobSnapshot, start_time, end_time, status, message, exception_info) -> None: + """记录一条任务执行日志(惰性导入,避免调度器顶层耦合业务模块)。 + + 调度线程执行任务,日志落库失败不影响任务本身。 + + :param snapshot: 任务快照 + :param start_time: 开始时间 + :param end_time: 结束时间 + :param status: 执行状态(0-成功 1-失败) + :param message: 执行信息 + :param exception_info: 异常信息 + """ + # 惰性导入,避免顶层循环依赖 + from modules.job_log.service import record_job_log + + record_job_log( + job_id=snapshot.id, + job_name=snapshot.job_name or "", + job_group=snapshot.job_group, + job_trigger=snapshot.job_trigger, + job_message=message, + cron_expression=snapshot.cron_expression, + status=status, + start_time=start_time, + end_time=end_time, + consume_time=int((end_time - start_time).total_seconds() * 1000), + exception_info=exception_info, + ) + + +# ====================================================================== +# 立即执行任务 +# ====================================================================== +def run_job_now(job) -> None: + """在线程池中立即执行任务(不阻塞事件循环)。 + + :param job: 任务 ORM 实例或 JobSnapshot + """ + # 提交线程池异步执行 + _executor.submit(execute_job, _snapshot_job(job)) + logger.info("任务已提交立即执行 id=%s name=%s", job.id, job.job_name) + + +# ====================================================================== +# 触发执行任务 +# ====================================================================== +def execute_job(snapshot: JobSnapshot) -> None: + """任务触发执行:向任务 URL 发起请求并记录执行日志。 + + 执行策略防重叠:1-立即执行(并行)2-执行一次 3-放弃执行(任务执行中则跳过本次触发)。 + + :param snapshot: 任务快照 + """ + url = snapshot.url + # 防重叠:执行中且策略为 2/3 时放弃本次触发(记录日志但不调用任务) + with _running_lock: + if snapshot.id in _running and snapshot.execute_policy in (2, 3): + _record_job(snapshot, datetime.now(), datetime.now(), 0, "任务正在执行中,本次触发放弃执行", None) + return + _running.add(snapshot.id) + + start_time = datetime.now() + status, message, exception_info = 0, "", None + try: + if not url: + # URL 为空:跳过执行 + message = "任务URL为空,跳过执行" + logger.warning("任务URL为空,跳过执行 id=%s", snapshot.id) + elif not validate_job_url(url): + # URL 非法(非公网等):拒绝执行 + status, message = 1, "任务URL非法或指向非公网地址,已拒绝执行(防SSRF)" + logger.error("任务URL校验失败已拒绝 id=%s url=%s", snapshot.id, url) + else: + # 使用禁重定向 opener:防止公网 URL 302 跳转内网绕过公网校验(SSRF) + with _no_redirect_opener.open(url, timeout=10) as resp: + message = "任务触发成功" + logger.info("任务触发成功 id=%s url=%s status=%s", snapshot.id, url, resp.status) + except Exception as e: + status, message, exception_info = 1, "任务触发失败", str(e) + logger.error("任务触发失败 id=%s url=%s err=%s", snapshot.id, url, e) + finally: + # 无论成功失败均记录日志并释放防重叠标记 + end_time = datetime.now() + _record_job(snapshot, start_time, end_time, status, message, exception_info) + _running.discard(snapshot.id) + + +# ============================================================ +# 构造调度任务 ID +# ============================================================ +def _job_id(snapshot: JobSnapshot) -> str: + """构造 APScheduler 任务 ID。 + + :param snapshot: 任务快照 + :return: 调度任务 ID + """ + return f"job_{snapshot.id}" + + +# ============================================================ +# 执行策略 -> misfire_grace_time +# ============================================================ +def _misfire_grace_time(snapshot: JobSnapshot) -> int: + """执行策略 -> misfire_grace_time(秒)。1-立即执行 2-执行一次 3-放弃执行。 + + :param snapshot: 任务快照 + :return: 容忍的错过触发时间(秒) + """ + policy = snapshot.execute_policy + return {1: 30, 2: 5, 3: 0}.get(policy, 30) + + +# ====================================================================== +# 调度任务(按状态) +# ====================================================================== +def schedule_job(job) -> None: + """按任务状态调度/更新/暂停/移除(先快照,再按快照操作,后台线程不接触 ORM 实例)。 + + :param job: 任务 ORM 实例或 JobSnapshot + """ + snapshot = _snapshot_job(job) + job_id = _job_id(snapshot) + try: + if snapshot.status == 1: # 运行中 + cron = snapshot.cron_expression + if not cron: + logger.warning("任务运行中但缺少Cron表达式 id=%s", snapshot.id) + return + trigger = CronTrigger.from_crontab(cron, timezone=TZ) + # 已存在则更新触发器,否则新增 + if scheduler.get_job(job_id): + scheduler.reschedule_job(job_id, trigger=trigger) + else: + scheduler.add_job( + execute_job, trigger, args=[snapshot], id=job_id, + misfire_grace_time=_misfire_grace_time(snapshot), replace_existing=True) + logger.info("任务已调度 id=%s name=%s cron=%s", snapshot.id, snapshot.job_name, cron) + elif snapshot.status == 2: # 暂停 + if scheduler.get_job(job_id): + scheduler.pause_job(job_id) + logger.info("任务已暂停 id=%s", snapshot.id) + else: # 未发布/删除 + remove_job(snapshot.id) + except Exception as e: + logger.error("任务调度失败 id=%s err=%s", snapshot.id, e) + + +# ====================================================================== +# 移除任务调度 +# ====================================================================== +def remove_job(job_id: int) -> None: + """移除任务调度(按任务主键 ID,不再依赖 ORM 实例)。 + + :param job_id: 任务主键 ID + """ + key = f"job_{job_id}" + try: + if scheduler.get_job(key): + scheduler.remove_job(key) + logger.info("任务已移除 id=%s", job_id) + except Exception as e: + logger.error("任务移除失败 id=%s err=%s", job_id, e) + + +# ====================================================================== +# 启动调度器 +# ====================================================================== +def init_scheduler() -> None: + """启动调度器并加载运行中的任务。""" + if scheduler.running: + return + try: + # 惰性导入,避免调度器顶层耦合业务模块 + from modules.job.repository import JobRepository + + repo = JobRepository() + try: + # 快照在对象仍 attached 时完成,随后关闭会话 + running = [_snapshot_job(j) for j in repo.all() if j.status == 1] + finally: + repo.close() + scheduler.start() + logger.info("任务调度器已启动,加载运行中任务 %d 个", len(running)) + # 逐个调度运行中的任务 + for snap in running: + schedule_job(snap) + except Exception as e: + logger.error("任务调度器初始化失败 err=%s", e) + + +# ====================================================================== +# 关闭调度器 +# ====================================================================== +def shutdown_scheduler() -> None: + """关闭调度器。""" + if scheduler.running: + scheduler.shutdown(wait=False) + logger.info("任务调度器已关闭") + + +def reload_all_jobs() -> None: + """重新加载所有任务。 + + 移除所有现有任务,然后重新加载运行中的任务。 + """ + try: + # 移除所有现有任务 + for job in scheduler.get_jobs(): + scheduler.remove_job(job.id) + logger.info("已移除所有现有任务") + + # 重新加载运行中的任务 + from modules.job.repository import JobRepository + repo = JobRepository() + try: + running = [_snapshot_job(j) for j in repo.all() if j.status == 1] + finally: + repo.close() + + for snap in running: + schedule_job(snap) + logger.info("重新加载任务完成,加载运行中任务 %d 个", len(running)) + except Exception as e: + logger.error("重新加载任务失败 err=%s", e) diff --git a/src/modules/job/schemas.py b/src/modules/job/schemas.py new file mode 100644 index 0000000..e965c61 --- /dev/null +++ b/src/modules/job/schemas.py @@ -0,0 +1,50 @@ +# +====================================================================== +# | 模块: 任务调度请求校验模型层 +# | 说明: 定义任务创建/编辑与状态更新的 Pydantic 校验模型 +# +====================================================================== +"""任务调度请求校验模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:类型标注、Pydantic 基类与字段定义、Schema 基类 +# ---------------------------------------------------------------------- +from typing import Optional + +from pydantic import BaseModel, Field + +from core.base_schema import BaseSchema + + +# ====================================================================== +# 任务创建/编辑表单 +# ====================================================================== +class JobSchema(BaseSchema): + """任务创建/编辑表单。""" + + # ============================================================ + # 表单字段 + # ============================================================ + jobName: str = Field(..., min_length=1, max_length=255, description="任务名称") + jobAlias: Optional[str] = Field(default=None, max_length=255, description="任务别名") + jobGroup: Optional[str] = Field(default=None, max_length=255, description="任务分组") + jobTrigger: Optional[str] = Field(default=None, max_length=255, description="任务触发器") + status: int = Field(default=0, ge=0, le=3, description="任务状态:0-未发布 1-运行中 2-暂停 3-删除") + cronExpression: Optional[str] = Field(None, max_length=255, description="Cron表达式(支持5或6字段,6字段首字段为秒)") + executePolicy: int = Field(..., ge=1, le=3, description="执行策略:1-立即执行 2-执行一次 3-放弃执行") + isSync: int = Field(default=0, ge=0, le=1, description="是否同步任务:0-否 1-是") + url: Optional[str] = Field(default=None, max_length=255, description="任务URL") + params: Optional[str] = Field(default=None, max_length=2000, description="任务参数") + note: Optional[str] = Field(default=None, max_length=255, description="任务备注") + + +# ====================================================================== +# 任务状态更新表单 +# ====================================================================== +class JobStatusSchema(BaseModel): + """任务状态更新表单。""" + + # ============================================================ + # 表单字段 + # ============================================================ + id: int = Field(..., gt=0, description="任务ID") + status: int = Field(..., ge=0, le=3, description="任务状态:0-未发布 1-运行中 2-暂停 3-删除") diff --git a/src/modules/job/service.py b/src/modules/job/service.py new file mode 100644 index 0000000..1f4c3eb --- /dev/null +++ b/src/modules/job/service.py @@ -0,0 +1,313 @@ +# +====================================================================== +# | 模块: 任务调度业务逻辑层 +# | 说明: 定义任务服务,覆写新增/编辑/状态切换/删除/立即执行等方法, +# | 在保存数据的同时联动调度器(调度、重调度、移除、立即执行) +# +====================================================================== +"""任务调度业务逻辑(带调度器联动)。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:类型标注、ID 列表解析工具、统一响应对象、业务服务基类、 +# 任务模型/仓储/校验模型、调度器工具 +# ---------------------------------------------------------------------- +from typing import Optional + +from common.utils.helpers import parse_id_list +from core import response as R +from core.base_service import BaseService +from modules.job.models import Job +from modules.job.repository import JobRepository +from modules.job.schemas import JobSchema, JobStatusSchema +from modules.job.scheduler import (remove_job, run_job_now, schedule_job, + validate_cron_expression) + + +# ====================================================================== +# 列表行加工:获取任务下次执行时间 +# ====================================================================== +def _job_serialize_extra(item): + """任务行加工:从调度器获取下次执行时间。 + + :param item: 任务模型实例 + :return: 需合并进序列化字典的额外字段 + """ + try: + from modules.job.scheduler import get_scheduler + scheduler = get_scheduler() + scheduler_job = scheduler.get_job(f"job_{item.id}") + if scheduler_job and scheduler_job.next_run_time: + return {"nextRunTime": scheduler_job.next_run_time.strftime('%Y-%m-%d %H:%M:%S')} + except Exception: + pass + return {"nextRunTime": None} + + +# ====================================================================== +# 任务调度服务 +# ====================================================================== +class JobService(BaseService): + """任务调度服务。""" + + # ============================================================ + # 基础声明 + # ============================================================ + model = Job + repo_cls = JobRepository + + # ============================================================ + # 差异点声明 + # ============================================================ + create_schema = JobSchema + update_schema = JobSchema + status_schema = JobStatusSchema + # 分页查询字段与排序 + page_like_fields = ("job_name", "job_group") + page_eq_fields = ("status",) + page_order_by = (("id", "desc"),) + # 枚举显示名映射 + serialize_maps = { + # 枚举统一走 dict 字典表(seed_dict.py 已种 job_status/job_execute_policy) + "status": "job_status", + "execute_policy": "job_execute_policy", + "is_sync": {0: "否", 1: "是"}, + } + # 序列化钩子 + serialize_extra = _job_serialize_extra + + # ============================================================ + # 新增任务(覆写) + # ============================================================ + async def add(self, handler): + """新增任务:保存后按状态调度。 + + :param handler: Tornado 请求处理器 + :return: 统一响应对象 + """ + try: + form = self._validate(handler, self.create_schema) + data = form.model_dump() + # 唯一性校验 + err = self._check_unique(form) + if err: + return R.failed(handler, err) + # Cron 表达式校验 + err = self._check_cron(data) + if err: + return R.failed(handler, err) + self._before_add(handler, data) + fields = self._build_create_fields(data) + job = self.repo.create(self.model(**fields)) + # 新增后立即按状态调度 + schedule_job(job) + return R.ok(handler, msg="添加成功") + finally: + self.repo.close() + + # ============================================================ + # 编辑任务(覆写) + # ============================================================ + async def update(self, handler): + """编辑任务:保存后重新调度。 + + :param handler: Tornado 请求处理器 + :return: 统一响应对象 + """ + try: + form = self._validate(handler, self.update_schema) + data = form.model_dump() + # 校验 ID 合法 + job_id = int(data.pop('id', 0) or 0) + if job_id <= 0: + return R.failed(handler, "记录ID不能为空") + # 校验记录存在 + if not self.repo.get_by_id(job_id): + return R.failed(handler, "记录不存在") + # 唯一性与 Cron 校验 + err = self._check_unique(form, exclude_id=job_id) + if err: + return R.failed(handler, err) + err = self._check_cron(data) + if err: + return R.failed(handler, err) + self._before_update(handler, data) + fields = self._build_update_fields(data) + if not self.repo.update(job_id, fields): + return R.failed(handler, "更新失败") + # 更新后重新取回并按状态重新调度 + updated = self.repo.get_by_id(job_id) + if updated: + schedule_job(updated) + return R.ok(handler, msg="更新成功") + finally: + self.repo.close() + + # ============================================================ + # 状态切换(覆写) + # ============================================================ + async def update_status(self, handler): + """设置任务状态:运行/暂停/未发布 联动调度器。 + + :param handler: Tornado 请求处理器 + :return: 统一响应对象 + """ + try: + form = self._validate(handler, self.status_schema) + job = self.repo.get_by_id(form.id) + if not job: + return R.failed(handler, "记录不存在") + # 运行中必须已配置 Cron 表达式 + if int(form.status) == 1 and not job.cron_expression: + return R.failed(handler, "任务未配置Cron表达式,无法设为运行中") + if not self.repo.update(form.id, {"status": int(form.status)}): + return R.failed(handler, "设置失败") + # 状态变更后联动调度器 + updated = self.repo.get_by_id(form.id) + if updated: + schedule_job(updated) + return R.ok(handler, msg="设置成功") + finally: + self.repo.close() + + # ============================================================ + # Cron 表达式校验 + # ============================================================ + @staticmethod + def _check_cron(data) -> Optional[str]: + """校验 Cron 表达式:运行中的任务必须配置合法表达式,提前拦截错误配置。 + + :param data: 表单数据字典 + :return: 错误文案或 None + """ + status = int(data.get("status", 0) or 0) + cron = data.get("cron_expression") + # 运行中必须配置表达式 + if status == 1 and not cron: + return "运行中的任务必须配置Cron表达式" + # 已配置则校验格式 + if cron: + return validate_cron_expression(cron) + return None + + # ============================================================ + # 删除任务(覆写) + # ============================================================ + async def delete(self, handler, ids): + """删除任务:先校验全部存在,再移除调度并软删,杜绝已软删任务继续运行的"幽灵任务"。 + + :param handler: Tornado 请求处理器 + :param ids: 逗号分隔的 ID 字符串 + :return: 统一响应对象 + """ + try: + id_list = list(dict.fromkeys(parse_id_list(str(ids)))) + if not id_list: + return R.failed(handler, "记录ID不存在") + jobs = self.repo.get_by_ids(id_list) + # 校验全部记录存在 + if len(jobs) != len(id_list): + return R.failed(handler, "记录不存在") + count = self.repo.batch_delete(ids) + # 逐个移除调度,避免"幽灵任务"继续运行 + for job in jobs: + remove_job(job.id) + return R.ok(handler, msg="本次共删除{0}条数据".format(count)) + finally: + self.repo.close() + + # ============================================================ + # 暂停任务 + # ============================================================ + async def pause(self, handler, job_id): + """暂停任务。 + + :param handler: Tornado 请求处理器 + :param job_id: 任务 ID + :return: 统一响应对象 + """ + try: + job = self.repo.get_by_id(job_id) + if not job: + return R.failed(handler, "记录不存在") + if job.status != 1: + return R.failed(handler, "只有运行中的任务才能暂停") + if not self.repo.update(job_id, {"status": 2}): + return R.failed(handler, "暂停失败") + # 状态变更后联动调度器 + updated = self.repo.get_by_id(job_id) + if updated: + schedule_job(updated) + return R.ok(handler, msg="暂停成功") + finally: + self.repo.close() + + # ============================================================ + # 恢复任务 + # ============================================================ + async def resume(self, handler, job_id): + """恢复任务。 + + :param handler: Tornado 请求处理器 + :param job_id: 任务 ID + :return: 统一响应对象 + """ + try: + job = self.repo.get_by_id(job_id) + if not job: + return R.failed(handler, "记录不存在") + if job.status != 2: + return R.failed(handler, "只有暂停的任务才能恢复") + if not job.cron_expression: + return R.failed(handler, "任务未配置Cron表达式,无法恢复") + if not self.repo.update(job_id, {"status": 1}): + return R.failed(handler, "恢复失败") + # 状态变更后联动调度器 + updated = self.repo.get_by_id(job_id) + if updated: + schedule_job(updated) + return R.ok(handler, msg="恢复成功") + finally: + self.repo.close() + + # ============================================================ + # 重新加载任务 + # ============================================================ + async def reload(self, handler): + """重新加载所有任务。 + + :param handler: Tornado 请求处理器 + :return: 统一响应对象 + """ + try: + from modules.job.scheduler import reload_all_jobs + reload_all_jobs() + return R.ok(handler, msg="重新加载成功") + finally: + self.repo.close() + + # ============================================================ + # 立即执行任务 + # ============================================================ + async def runOnce(self, handler, job_id): + """立即执行任务(线程池触发,不阻塞事件循环)。 + + :param handler: Tornado 请求处理器 + :param job_id: 任务 ID + :return: 统一响应对象 + """ + try: + job = self.repo.get_by_id(job_id) + if not job: + return R.failed(handler, "记录不存在") + # 已删除任务不允许执行 + if job.status == 3: + return R.failed(handler, "任务已删除,无法执行") + run_job_now(job) + return R.ok(handler, msg="任务已触发执行") + finally: + self.repo.close() + + +# ====================================================================== +# 服务单例 +# ====================================================================== +job_service = JobService() diff --git a/src/modules/job_log/__init__.py b/src/modules/job_log/__init__.py new file mode 100644 index 0000000..94ab42c --- /dev/null +++ b/src/modules/job_log/__init__.py @@ -0,0 +1,6 @@ +# +====================================================================== +# | 模块: 任务执行日志包 +# | 说明: 声明 job_log 为包,用于组织任务执行日志模块的路由、处理器、 +# | 服务与数据访问 +# +====================================================================== +"""任务执行日志模块。""" diff --git a/src/modules/job_log/handlers.py b/src/modules/job_log/handlers.py new file mode 100644 index 0000000..9051e42 --- /dev/null +++ b/src/modules/job_log/handlers.py @@ -0,0 +1,75 @@ +# +====================================================================== +# | 模块: 任务执行日志请求处理器层 +# | 说明: 承接任务执行日志相关的 HTTP 请求,校验权限/演示模式并记录操作日志后, +# | 转交日志服务层处理(分页、详情、删除、批量删除) +# +====================================================================== +"""任务执行日志请求处理器。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:权限/演示模式装饰器、操作日志装饰器、统一响应对象、 +# 请求处理基类、任务日志服务单例 +# ---------------------------------------------------------------------- +from common.middleware.access_decorators import check_demo, permission_required +from core import response as R +from core.base_handler import BaseHandler +from modules.job_log.service import job_log_service + +from common.middleware.operation_log import operation_log + + +# ====================================================================== +# 查询任务执行日志分页数据 +# ====================================================================== +class JobLogPageHandler(BaseHandler): + """查询任务执行日志分页数据。""" + + @permission_required("sys:joblog:list") + async def get(self): + """GET 请求:返回任务执行日志分页列表。""" + return await job_log_service.get_page(self) + + +# ====================================================================== +# 查询任务执行日志详情 +# ====================================================================== +class JobLogDetailHandler(BaseHandler): + """查询任务执行日志详情。""" + + @permission_required("sys:joblog:detail") + async def get(self, id): + """GET 请求:按 ID 返回任务执行日志详情。 + + :param id: 日志 ID(路径参数) + """ + return R.ok(self, data=job_log_service.get_detail(id)) + + +# ====================================================================== +# 删除任务执行日志 +# ====================================================================== +class JobLogDeleteHandler(BaseHandler): + """删除任务执行日志。""" + + @permission_required("sys:joblog:delete") + @check_demo + async def delete(self, id): + """DELETE 请求:按 ID 删除任务执行日志(软删除)。 + + :param id: 日志 ID(路径参数) + """ + return await job_log_service.delete(self, id) + + +# ====================================================================== +# 批量删除任务日志 +# ====================================================================== +class JobLogBatchDeleteHandler(BaseHandler): + """批量删除任务日志。""" + + @permission_required("sys:joblog:delete") + @operation_log("任务日志管理", "批量删除") + @check_demo + async def post(self): + """POST 请求:批量删除任务日志(软删除)。""" + return await job_log_service.batch_delete(self) diff --git a/src/modules/job_log/models.py b/src/modules/job_log/models.py new file mode 100644 index 0000000..43a6ef3 --- /dev/null +++ b/src/modules/job_log/models.py @@ -0,0 +1,58 @@ +# +====================================================================== +# | 模块: 任务执行日志数据模型层 +# | 说明: 定义定时任务日志表结构,继承 BaseModel 获得通用字段与序列化能力, +# | 并声明组合索引以优化查询 +# +====================================================================== +"""任务执行日志数据模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:SQLAlchemy 列类型、索引与函数、表名前缀配置、模型基类 +# ---------------------------------------------------------------------- +from sqlalchemy import BigInteger, Column, DateTime, Index, Integer, SmallInteger, String, Text, text + +from config.database import DB_PREFIX +from core.base_model import BaseModel + + +# ====================================================================== +# 任务执行日志模型 +# ====================================================================== +class JobLog(BaseModel): + """任务执行日志模型。""" + + # ============================================================ + # 表定义 + # ============================================================ + __tablename__ = DB_PREFIX + "job_log" + __table_comment__ = "定时任务日志表" + + # 组合索引:优化"按任务 + 状态"与"按开始时间"的查询 + __table_args__ = ( + Index("idx_job_log_job_id_status", "job_id", "status"), + Index("idx_job_log_start_time", "start_time"), + {"extend_existing": True}, + ) + + # ============================================================ + # 业务字段 + # ============================================================ + job_id = Column(Integer, nullable=False, default=0, server_default=text('0'), index=True, comment="任务ID") + job_name = Column(String(255), nullable=False, default="", comment="任务名称") + job_group = Column(String(255), nullable=True, comment="任务分组") + job_trigger = Column(String(255), nullable=True, comment="任务触发器") + job_message = Column(String(500), nullable=True, comment="任务日志信息") + cron_expression = Column(String(255), nullable=True, comment="Cron表达式") + status = Column(SmallInteger, nullable=False, default=0, server_default=text('0'), index=True, + comment="执行状态:0-正常 1-失败") + start_time = Column(DateTime, nullable=True, comment="执行开始时间") + end_time = Column(DateTime, nullable=True, comment="执行结束时间") + consume_time = Column(BigInteger, nullable=False, default=0, server_default=text('0'), comment="执行耗时(毫秒)") + exception_info = Column(Text, nullable=True, comment="异常信息") + + # ============================================================ + # 调试输出 + # ============================================================ + def __str__(self): + """调试用字符串表示:返回任务日志标识。""" + return f"任务日志{self.id}" diff --git a/src/modules/job_log/repository.py b/src/modules/job_log/repository.py new file mode 100644 index 0000000..b14df03 --- /dev/null +++ b/src/modules/job_log/repository.py @@ -0,0 +1,24 @@ +# +====================================================================== +# | 模块: 任务执行日志数据访问层 +# | 说明: 定义任务执行日志仓储,继承 BaseRepository 获得通用 CRUD 与软删除能力 +# +====================================================================== +"""任务执行日志数据访问。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:通用仓储基类、任务执行日志模型 +# ---------------------------------------------------------------------- +from core.base_repository import BaseRepository +from modules.job_log.models import JobLog + + +# ====================================================================== +# 任务执行日志仓储 +# ====================================================================== +class JobLogRepository(BaseRepository): + """任务执行日志仓储。""" + + # ============================================================ + # 绑定的模型类 + # ============================================================ + model = JobLog diff --git a/src/modules/job_log/schemas.py b/src/modules/job_log/schemas.py new file mode 100644 index 0000000..6ced9c4 --- /dev/null +++ b/src/modules/job_log/schemas.py @@ -0,0 +1,8 @@ +# +====================================================================== +# | 模块: 任务执行日志请求校验模型层 +# | 说明: 说明任务执行日志为追加型数据,仅提供查询与删除,无需校验模型 +# +====================================================================== +"""任务执行日志请求校验模型。 + +任务执行日志为追加型数据(由调度器写入),仅提供查询与删除,无需新增/编辑校验模型。 +""" diff --git a/src/modules/job_log/service.py b/src/modules/job_log/service.py new file mode 100644 index 0000000..b3de080 --- /dev/null +++ b/src/modules/job_log/service.py @@ -0,0 +1,112 @@ +# +====================================================================== +# | 模块: 任务执行日志业务逻辑层 +# | 说明: 提供任务日志落库函数(独立会话,供调度器后台线程调用), +# | 以及任务日志查询/删除服务(追加型数据,无新增/编辑) +# +====================================================================== +"""任务执行日志业务逻辑。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:业务服务基类、独立会话工厂、日志器、任务日志模型/仓储 +# ---------------------------------------------------------------------- +from core.base_service import BaseService +from core.database import SessionLocal +from core.logger import get_logger +from modules.job_log.models import JobLog +from modules.job_log.repository import JobLogRepository + +# 任务日志专用日志器 +logger = get_logger("job") + + +# ====================================================================== +# 记录任务执行日志 +# ====================================================================== +def record_job_log(job_id, job_name, job_group, job_trigger, job_message, + cron_expression, status, start_time, end_time, consume_time, + exception_info) -> None: + """记录一条任务执行日志(落库失败不影响任务执行)。 + + 任务由调度器在后台线程执行,不能复用事件循环的共享会话 db, + 故使用独立会话 SessionLocal(),避免跨线程并发访问同一 Session。 + + :param job_id: 任务 ID + :param job_name: 任务名称 + :param job_group: 任务分组 + :param job_trigger: 任务触发器 + :param job_message: 执行信息 + :param cron_expression: Cron 表达式 + :param status: 执行状态(0-正常 1-失败) + :param start_time: 执行开始时间 + :param end_time: 执行结束时间 + :param consume_time: 执行耗时(毫秒) + :param exception_info: 异常信息 + """ + # 后台线程使用独立会话,避免与事件循环共享 Session + session = SessionLocal() + try: + session.add(JobLog( + job_id=job_id, job_name=job_name, job_group=job_group, + job_trigger=job_trigger, job_message=job_message, + cron_expression=cron_expression, status=status, + start_time=start_time, end_time=end_time, + consume_time=consume_time, exception_info=exception_info, + )) + session.commit() + except Exception: + # 落库失败不影响任务本身,仅记录异常 + session.rollback() + logger.exception("记录任务日志失败") + finally: + session.close() + + +# ====================================================================== +# 任务执行日志服务 +# ====================================================================== +class JobLogService(BaseService): + """任务执行日志查询/删除服务(追加型数据,无新增/编辑)。""" + + # ============================================================ + # 基础声明 + # ============================================================ + model = JobLog + repo_cls = JobLogRepository + + # ============================================================ + # 差异点声明 + # ============================================================ + # 分页查询字段与排序 + page_like_fields = ("job_name", "job_group") + page_eq_fields = ("job_id", "status", "job_trigger") + page_order_by = (("id", "desc"),) + # 枚举显示名映射 + serialize_maps = { + "status": "job_log_status", + } + + # ============================================================ + # 组装分页查询(覆写) + # ============================================================ + def _build_page_query(self, handler): + """分页查询:支持 startTime / endTime 按执行开始时间区间过滤。 + + :param handler: Tornado 请求处理器 + :return: 查询对象 + """ + # 先复用基类的字段过滤与排序 + query = super()._build_page_query(handler) + # 追加执行开始时间区间过滤 + start_time = handler.get_query_argument("startTime", None) + end_time = handler.get_query_argument("endTime", None) + if start_time: + query = query.filter(JobLog.start_time >= start_time) + if end_time: + query = query.filter(JobLog.start_time <= end_time) + return query + + +# ====================================================================== +# 服务单例 +# ====================================================================== +job_log_service = JobLogService() diff --git a/src/modules/level/__init__.py b/src/modules/level/__init__.py new file mode 100644 index 0000000..452e680 --- /dev/null +++ b/src/modules/level/__init__.py @@ -0,0 +1,5 @@ +# +====================================================================== +# | 模块: 职级管理包 +# | 说明: 声明 level 为包,用于组织职级模块的路由、处理器、服务与数据访问 +# +====================================================================== +"""职级管理模块。""" diff --git a/src/modules/level/handlers.py b/src/modules/level/handlers.py new file mode 100644 index 0000000..22ce8a3 --- /dev/null +++ b/src/modules/level/handlers.py @@ -0,0 +1,155 @@ +# +====================================================================== +# | 模块: 职级请求处理器层 +# | 说明: 承接职级相关的 HTTP 请求,校验权限/演示模式并记录操作日志后, +# | 转交职级服务层处理(分页、详情、增删改、状态、导入导出、下拉列表等) +# +====================================================================== +"""职级请求处理器。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:权限/演示模式装饰器、操作日志装饰器、统一响应对象、 +# 请求处理基类、职级服务单例 +# ---------------------------------------------------------------------- +from common.middleware.access_decorators import check_demo, permission_required +from common.middleware.operation_log import operation_log +from core import response as R +from core.base_handler import BaseHandler +from modules.level.service import level_service + + +# ====================================================================== +# 查询职级分页数据 +# ====================================================================== +class LevelPageHandler(BaseHandler): + """查询职级分页数据。""" + + @permission_required("sys:level:list") + async def get(self): + """GET 请求:返回职级分页列表。""" + return await level_service.get_page(self) + + +# ====================================================================== +# 查询职级详情 +# ====================================================================== +class LevelDetailHandler(BaseHandler): + """查询职级详情。""" + + @permission_required("sys:level:detail") + async def get(self, id): + """GET 请求:按 ID 返回职级详情。 + + :param id: 职级 ID(路径参数) + """ + return R.ok(self, data=level_service.get_detail(id)) + + +# ====================================================================== +# 添加职级 +# ====================================================================== +class LevelAddHandler(BaseHandler): + """添加职级。""" + + @permission_required("sys:level:add") + @operation_log("职级管理", "添加") + @check_demo + async def post(self): + """POST 请求:新增职级。""" + return await level_service.add(self) + + +# ====================================================================== +# 更新职级 +# ====================================================================== +class LevelUpdateHandler(BaseHandler): + """更新职级。""" + + @permission_required("sys:level:update") + @operation_log("职级管理", "更新") + @check_demo + async def put(self): + """PUT 请求:更新职级信息。""" + return await level_service.update(self) + + +# ====================================================================== +# 删除职级 +# ====================================================================== +class LevelDeleteHandler(BaseHandler): + """删除职级。""" + + @permission_required("sys:level:delete") + @operation_log("职级管理", "删除") + @check_demo + async def delete(self, id): + """DELETE 请求:按 ID 删除职级(软删除)。 + + :param id: 职级 ID(路径参数) + """ + return await level_service.delete(self, id) + + +# ====================================================================== +# 设置职级状态 +# ====================================================================== +class LevelStatusHandler(BaseHandler): + """设置职级状态。""" + + @permission_required("sys:level:status") + @operation_log("职级管理", "设置状态") + @check_demo + async def put(self): + """PUT 请求:修改职级状态(如启用 / 禁用)。""" + return await level_service.update_status(self) + + +# ====================================================================== +# 导入职级 Excel +# ====================================================================== +class LevelImportHandler(BaseHandler): + """导入职级 Excel。""" + + @permission_required("sys:level:import") + @operation_log("职级管理", "导入") + @check_demo + async def post(self): + """POST 请求:从 Excel 导入职级数据。""" + return level_service.level_import(self) + + +# ====================================================================== +# 导出职级 Excel +# ====================================================================== +class LevelExportHandler(BaseHandler): + """导出职级 Excel。""" + + @permission_required("sys:level:export") + @check_demo + async def get(self): + """GET 请求:导出职级数据为 Excel。""" + return level_service.level_export(self) + + +# ====================================================================== +# 获取职级下拉列表 +# ====================================================================== +class LevelGetListHandler(BaseHandler): + """获取职级下拉列表。""" + + async def get(self): + """GET 请求:返回职级下拉列表(无需权限校验)。""" + return await level_service.get_options(self) + + +# ====================================================================== +# 批量删除职级 +# ====================================================================== +class LevelBatchDeleteHandler(BaseHandler): + """批量删除职级。""" + + @permission_required("sys:level:delete") + @operation_log("职级管理", "批量删除") + @check_demo + async def post(self): + """POST 请求:批量删除职级(软删除)。""" + return await level_service.batch_delete(self) diff --git a/src/modules/level/models.py b/src/modules/level/models.py new file mode 100644 index 0000000..6f54684 --- /dev/null +++ b/src/modules/level/models.py @@ -0,0 +1,41 @@ +# +====================================================================== +# | 模块: 职级数据模型层 +# | 说明: 定义职级表结构,继承 BaseModel 获得通用字段与序列化能力 +# +====================================================================== +"""职级数据模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:SQLAlchemy 列类型与函数、表名前缀配置、模型基类 +# ---------------------------------------------------------------------- +from sqlalchemy import Column, Integer, String, text + +from config.database import DB_PREFIX +from core.base_model import BaseModel + + +# ====================================================================== +# 职级模型 +# ====================================================================== +class Level(BaseModel): + """职级模型。""" + + # ============================================================ + # 表定义 + # ============================================================ + __tablename__ = DB_PREFIX + "level" + __table_comment__ = "职级表" + + # ============================================================ + # 业务字段 + # ============================================================ + name = Column(String(255), nullable=False, index=True, comment="职级名称") + status = Column(Integer, default=0, server_default=text('0'), index=True, comment="职级状态:1-在用 2-停用") + sort = Column(Integer, default=0, server_default=text('0'), comment="职级排序") + + # ============================================================ + # 调试输出 + # ============================================================ + def __repr__(self): + """调试用字符串表示:返回职级名称。""" + return f'职级:{self.name}' diff --git a/src/modules/level/repository.py b/src/modules/level/repository.py new file mode 100644 index 0000000..ec04e52 --- /dev/null +++ b/src/modules/level/repository.py @@ -0,0 +1,24 @@ +# +====================================================================== +# | 模块: 职级数据访问层 +# | 说明: 定义职级仓储,继承 BaseRepository 获得通用 CRUD 与软删除能力 +# +====================================================================== +"""职级数据访问。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:通用仓储基类、职级模型 +# ---------------------------------------------------------------------- +from core.base_repository import BaseRepository +from modules.level.models import Level + + +# ====================================================================== +# 职级仓储 +# ====================================================================== +class LevelRepository(BaseRepository): + """职级仓储。""" + + # ============================================================ + # 绑定的模型类 + # ============================================================ + model = Level diff --git a/src/modules/level/schemas.py b/src/modules/level/schemas.py new file mode 100644 index 0000000..9d56ba5 --- /dev/null +++ b/src/modules/level/schemas.py @@ -0,0 +1,40 @@ +# +====================================================================== +# | 模块: 职级请求校验模型层 +# | 说明: 定义职级创建/编辑与状态更新的 Pydantic 校验模型 +# +====================================================================== +"""职级请求校验模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:Pydantic 基类与字段定义、Schema 基类 +# ---------------------------------------------------------------------- +from pydantic import BaseModel, Field + +from core.base_schema import BaseSchema + + +# ====================================================================== +# 职级创建/编辑表单 +# ====================================================================== +class LevelSchema(BaseSchema): + """职级创建/编辑表单。""" + + # ============================================================ + # 表单字段 + # ============================================================ + name: str = Field(..., min_length=1, max_length=150, description="职级名称") + status: int = Field(default=1, ge=1, le=2, description="职级状态:1-在用 2-停用") + sort: int = Field(..., ge=0, le=99999, description="职级排序") + + +# ====================================================================== +# 职级状态更新表单 +# ====================================================================== +class LevelStatusSchema(BaseModel): + """职级状态更新表单。""" + + # ============================================================ + # 表单字段 + # ============================================================ + id: int = Field(..., gt=0, description="职级ID") + status: int = Field(..., ge=1, le=2, description="职级状态:1-在用 2-停用") diff --git a/src/modules/level/service.py b/src/modules/level/service.py new file mode 100644 index 0000000..0beab9a --- /dev/null +++ b/src/modules/level/service.py @@ -0,0 +1,192 @@ +# +====================================================================== +# | 模块: 职级业务逻辑层 +# | 说明: 定义职级服务,声明差异点复用通用 CRUD,并实现 Excel 导入导出 +# +====================================================================== +"""职级业务逻辑。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:日志、文件路径/随机数/时间、openpyxl 读写、文件工具、 +# 应用配置(临时目录/文件访问前缀/上传目录)、统一响应对象、 +# 业务服务基类、职级模型/仓储/校验模型 +# ---------------------------------------------------------------------- +import logging +import os +import random +import time + +from openpyxl.reader.excel import load_workbook +from openpyxl.workbook import Workbook + +from common.utils.file import mkdir +from config.app import TEMP_PATH, TORNADO_FILE_URL, UPLOAD_DIR +from core import response as R +from core.base_service import BaseService +from modules.level.models import Level +from modules.level.repository import LevelRepository +from modules.level.schemas import LevelSchema, LevelStatusSchema + +logger = logging.getLogger(__name__) + + +# ====================================================================== +# 职级服务 +# ====================================================================== +class LevelService(BaseService): + """职级服务。""" + + # ============================================================ + # 基础声明 + # ============================================================ + model = Level + repo_cls = LevelRepository + + # ============================================================ + # 差异点声明 + # ============================================================ + create_schema = LevelSchema + update_schema = LevelSchema + status_schema = LevelStatusSchema + # 分页查询字段与排序 + page_like_fields = ("name",) + page_eq_fields = ("status",) + page_order_by = (("sort", "asc"),) + # 下拉列表过滤 + options_filter = {"status": 1} + + # ============================================================ + # 导入职级 Excel + # ============================================================ + def level_import(self, handler): + """导入职级 Excel:逐行校验、批量提交、返回成功/失败明细。 + + :param handler: Tornado 请求处理器 + :return: 统一响应对象 + """ + # 获取上传文件 + file_metas = handler.request.files.get('file', None) + if not file_metas: + return R.failed(handler, "请选择要上传的文件") + meta = file_metas[0] + original_name = meta.get('filename', '') + body = meta.get('body') or b"" + file_ext = os.path.splitext(original_name)[1].lower() + if file_ext not in ('.xls', '.xlsx'): + return R.failed(handler, "仅支持 .xls / .xlsx 格式的 Excel 文件") + + # 保存到临时目录 + date_dir = time.strftime('%Y%m%d') + save_dir = os.path.join(TEMP_PATH, date_dir) + mkdir(save_dir) + file_name = time.strftime('%Y%m%d%H%M%S') + '%05d' % random.randint(0, 99999) + file_ext + abs_path = os.path.join(save_dir, file_name) + with open(abs_path, 'wb') as f: + f.write(body) + + errors = [] + count = 0 + try: + wb = load_workbook(abs_path) + ws = wb.worksheets[0] + max_row = ws.max_row + # 第 1 行为表头,从第 2 行开始遍历 + for row_idx in range(2, max_row + 1): + try: + name = ws.cell(row_idx, 1).value + sort = ws.cell(row_idx, 2).value + status = ws.cell(row_idx, 3).value + # 必填校验 + name_str = str(name).strip() if name else '' + if not name_str: + raise ValueError("职级名称不能为空") + if len(name_str) > 255: + raise ValueError("职级名称长度不能超过255") + # 唯一性校验 + repo = LevelRepository() + if repo.get_one(name=name_str): + raise ValueError(f"职级名称'{name_str}'已存在") + # 排序校验 + try: + sort_val = int(sort) if sort is not None else 0 + except (ValueError, TypeError): + raise ValueError("职级排序必须为数字") + # 状态解析 + status_str = str(status).strip() if status else "正常" + if status_str in ("正常", "1"): + status_val = 1 + elif status_str in ("禁用", "2"): + status_val = 2 + else: + raise ValueError("职级状态仅支持【正常/禁用】") + # 创建数据 + self.repo.create(Level( + name=name_str, + status=status_val, + sort=sort_val, + )) + count += 1 + except ValueError as e: + errors.append(f"第{row_idx}行:{str(e)}") + except Exception as e: + logger.error(f"导入职级第{row_idx}行异常: {str(e)}") + errors.append(f"第{row_idx}行:导入异常") + wb.close() + except Exception: + raise + finally: + if os.path.exists(abs_path): + os.remove(abs_path) + + # 返回结果 + if errors: + detail = ";".join(errors) + if len(detail) > 200: + detail = detail[:200] + "…" + return R.ok(handler, data={"success": count, "fail": len(errors), "errors": errors}, + msg="导入成功 {} 条,失败 {} 条:{}".format(count, len(errors), detail)) + return R.ok(handler, msg="本次共导入【{}】条数据".format(count)) + + # ============================================================ + # 导出职级 Excel + # ============================================================ + def level_export(self, handler): + """导出职级 Excel。 + + :param handler: Tornado 请求处理器 + :return: 统一响应对象(含导出文件访问地址) + """ + # 构建查询(复用分页筛选条件) + query = self._build_page_query(handler) + level_list = query.all() + if not level_list: + return R.failed(handler, "暂无数据可导出") + + # 写入工作簿:首行为表头 + wb = Workbook() + ws = wb.worksheets[0] + ws.append(['职级ID', '职级名称', '职级排序', '职级状态', '创建时间', '更新时间']) + for item in level_list: + ws.append([ + item.id, item.name, item.sort, + "正常" if item.status == 1 else "禁用", + item.create_time.strftime('%Y-%m-%d %H:%M:%S') if item.create_time else '', + item.update_time.strftime('%Y-%m-%d %H:%M:%S') if item.update_time else '', + ]) + + # 保存文件 + save_path = os.path.join(TEMP_PATH, time.strftime('%Y%m%d')) + mkdir(save_path) + file_name = time.strftime('%Y%m%d%H%M%S') + '%05d' % random.randint(0, 99999) + ".xlsx" + path = os.path.join(save_path, file_name) + wb.save(path) + + # 文件访问地址 + relative_path = path.replace(UPLOAD_DIR, "").replace("\\", "/").lstrip("/") + file_url = TORNADO_FILE_URL + "/" + relative_path + return R.ok(handler, data=file_url) + + +# ====================================================================== +# 服务单例 +# ====================================================================== +level_service = LevelService() diff --git a/src/modules/link/__init__.py b/src/modules/link/__init__.py new file mode 100644 index 0000000..8c837c4 --- /dev/null +++ b/src/modules/link/__init__.py @@ -0,0 +1,5 @@ +# +====================================================================== +# | 模块: 友链管理包 +# | 说明: 声明 link 为包,用于组织友情链接模块的路由、处理器、服务与数据访问 +# +====================================================================== +"""友链管理模块。""" diff --git a/src/modules/link/handlers.py b/src/modules/link/handlers.py new file mode 100644 index 0000000..7836e71 --- /dev/null +++ b/src/modules/link/handlers.py @@ -0,0 +1,117 @@ +# +====================================================================== +# | 模块: 友链请求处理器层 +# | 说明: 承接友情链接相关的 HTTP 请求,校验权限/演示模式并记录操作日志后, +# | 转交友链服务层处理(分页、详情、增删改、状态、批量删除等) +# +====================================================================== +"""友链请求处理器。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:权限/演示模式装饰器、操作日志装饰器、统一响应对象、 +# 请求处理基类、友链服务单例 +# ---------------------------------------------------------------------- +from common.middleware.access_decorators import check_demo, permission_required +from common.middleware.operation_log import operation_log +from core import response as R +from core.base_handler import BaseHandler +from modules.link.service import link_service + + +# ====================================================================== +# 查询友链分页数据 +# ====================================================================== +class LinkPageHandler(BaseHandler): + """查询友链分页数据。""" + + @permission_required("sys:link:list") + async def get(self): + """GET 请求:返回友链分页列表。""" + return await link_service.get_page(self) + + +# ====================================================================== +# 查询友链详情 +# ====================================================================== +class LinkDetailHandler(BaseHandler): + """查询友链详情。""" + + @permission_required("sys:link:detail") + async def get(self, id): + """GET 请求:按 ID 返回友链详情。 + + :param id: 友链 ID(路径参数) + """ + return R.ok(self, data=link_service.get_detail(id)) + + +# ====================================================================== +# 添加友链 +# ====================================================================== +class LinkAddHandler(BaseHandler): + """添加友链。""" + + @permission_required("sys:link:add") + @operation_log("友链管理", "添加") + @check_demo + async def post(self): + """POST 请求:新增友链。""" + return await link_service.add(self) + + +# ====================================================================== +# 更新友链 +# ====================================================================== +class LinkUpdateHandler(BaseHandler): + """更新友链。""" + + @permission_required("sys:link:update") + @operation_log("友链管理", "更新") + @check_demo + async def put(self): + """PUT 请求:更新友链信息。""" + return await link_service.update(self) + + +# ====================================================================== +# 删除友链 +# ====================================================================== +class LinkDeleteHandler(BaseHandler): + """删除友链。""" + + @permission_required("sys:link:delete") + @operation_log("友链管理", "删除") + @check_demo + async def delete(self, id): + """DELETE 请求:按 ID 删除友链(软删除)。 + + :param id: 友链 ID(路径参数) + """ + return await link_service.delete(self, id) + + +# ====================================================================== +# 设置友链状态 +# ====================================================================== +class LinkStatusHandler(BaseHandler): + """设置友链状态。""" + + @permission_required("sys:link:status") + @operation_log("友链管理", "设置状态") + @check_demo + async def put(self): + """PUT 请求:修改友链状态(如显示 / 隐藏)。""" + return await link_service.update_status(self) + + +# ====================================================================== +# 批量删除友链 +# ====================================================================== +class LinkBatchDeleteHandler(BaseHandler): + """批量删除友链。""" + + @permission_required("sys:link:delete") + @operation_log("友链管理", "批量删除") + @check_demo + async def post(self): + """POST 请求:批量删除友链(软删除)。""" + return await link_service.batch_delete(self) diff --git a/src/modules/link/models.py b/src/modules/link/models.py new file mode 100644 index 0000000..99bd307 --- /dev/null +++ b/src/modules/link/models.py @@ -0,0 +1,46 @@ +# +====================================================================== +# | 模块: 友链数据模型层 +# | 说明: 定义友链表结构,继承 BaseModel 获得通用字段与序列化能力 +# +====================================================================== +"""友链数据模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:SQLAlchemy 列类型与函数、表名前缀配置、模型基类 +# ---------------------------------------------------------------------- +from sqlalchemy import Column, Integer, String, text + +from config.database import DB_PREFIX +from core.base_model import BaseModel + + +# ====================================================================== +# 友链模型 +# ====================================================================== +class Link(BaseModel): + """友链模型。""" + + # ============================================================ + # 表定义 + # ============================================================ + __tablename__ = DB_PREFIX + "link" + __table_comment__ = "友链表" + + # ============================================================ + # 业务字段 + # ============================================================ + name = Column(String(255), nullable=False, index=True, comment="友链名称") + type = Column(Integer, default=1, server_default=text('1'), comment="友链类型:1-友情链接 2-合作伙伴") + url = Column(String(255), nullable=True, comment="友链URL") + form = Column(Integer, default=1, server_default=text('1'), comment="友链形式:1-文字链接 2-图片链接") + image = Column(String(255), nullable=True, comment="友链图片") + status = Column(Integer, default=1, server_default=text('1'), index=True, comment="友链状态:1-正常 2-停用") + sort = Column(Integer, default=0, server_default=text('0'), comment="友链顺序") + note = Column(String(255), nullable=True, comment="友链备注") + + # ============================================================ + # 调试输出 + # ============================================================ + def __str__(self): + """调试用字符串表示:返回友链名称。""" + return f"友链{self.name}" diff --git a/src/modules/link/repository.py b/src/modules/link/repository.py new file mode 100644 index 0000000..01d0c2c --- /dev/null +++ b/src/modules/link/repository.py @@ -0,0 +1,24 @@ +# +====================================================================== +# | 模块: 友链数据访问层 +# | 说明: 定义友链仓储,继承 BaseRepository 获得通用 CRUD 与软删除能力 +# +====================================================================== +"""友链数据访问。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:通用仓储基类、友链模型 +# ---------------------------------------------------------------------- +from core.base_repository import BaseRepository +from modules.link.models import Link + + +# ====================================================================== +# 友链仓储 +# ====================================================================== +class LinkRepository(BaseRepository): + """友链仓储。""" + + # ============================================================ + # 绑定的模型类 + # ============================================================ + model = Link diff --git a/src/modules/link/schemas.py b/src/modules/link/schemas.py new file mode 100644 index 0000000..ff75b1b --- /dev/null +++ b/src/modules/link/schemas.py @@ -0,0 +1,47 @@ +# +====================================================================== +# | 模块: 友链请求校验模型层 +# | 说明: 定义友链创建/编辑与状态更新的 Pydantic 校验模型 +# +====================================================================== +"""友链请求校验模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:类型标注、Pydantic 基类与字段定义、Schema 基类 +# ---------------------------------------------------------------------- +from typing import Optional + +from pydantic import BaseModel, Field + +from core.base_schema import BaseSchema + + +# ====================================================================== +# 友链创建/编辑表单 +# ====================================================================== +class LinkSchema(BaseSchema): + """友链创建/编辑表单。""" + + # ============================================================ + # 表单字段 + # ============================================================ + name: str = Field(..., min_length=1, max_length=255, description="友链名称") + type: int = Field(..., ge=1, le=2, description="友链类型:1-友情链接 2-合作伙伴") + url: Optional[str] = Field(..., min_length=1, max_length=255, description="友链URL") + form: int = Field(..., ge=1, le=2, alias="form", description="友链形式:1-文字链接 2-图片链接") + image: Optional[str] = Field(..., min_length=1, max_length=255, description="友链图片") + status: int = Field(default=1, ge=1, le=2, description="友链状态:1-正常 2-停用") + sort: int = Field(..., ge=0, le=99999, description="友链排序") + note: Optional[str] = Field(None, max_length=255, description="友链备注") + + +# ====================================================================== +# 友链状态更新表单 +# ====================================================================== +class LinkStatusSchema(BaseModel): + """友链状态更新表单。""" + + # ============================================================ + # 表单字段 + # ============================================================ + id: int = Field(..., gt=0, description="友链ID") + status: int = Field(..., ge=1, le=2, description="友链状态:1-正常 2-停用") diff --git a/src/modules/link/service.py b/src/modules/link/service.py new file mode 100644 index 0000000..95c811d --- /dev/null +++ b/src/modules/link/service.py @@ -0,0 +1,52 @@ +# +====================================================================== +# | 模块: 友链业务逻辑层 +# | 说明: 定义友链服务,声明差异点(file_fields 自动处理图片), +# | 最后导出服务单例 +# +====================================================================== +"""友链业务逻辑。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:业务服务基类、友链模型/仓储/校验模型 +# ---------------------------------------------------------------------- +from core.base_service import BaseService +from modules.link.models import Link +from modules.link.repository import LinkRepository +from modules.link.schemas import LinkSchema, LinkStatusSchema + + +# ====================================================================== +# 友链服务 +# ====================================================================== +class LinkService(BaseService): + """友链服务。""" + + # ============================================================ + # 基础声明 + # ============================================================ + model = Link + repo_cls = LinkRepository + + # ============================================================ + # 差异点声明 + # ============================================================ + create_schema = LinkSchema + update_schema = LinkSchema + status_schema = LinkStatusSchema + # 分页查询字段与排序 + page_like_fields = ("name",) + page_eq_fields = ("type", "form", "status") + page_order_by = (("sort", "asc"),) + # 枚举显示名映射 + serialize_maps = { + "type": "link_type", + "form": "link_form", + } + # 文件字段:add/update 时自动 save_file,serialize 时自动补全 URL + file_fields = ("image",) + + +# ====================================================================== +# 服务单例 +# ====================================================================== +link_service = LinkService() diff --git a/src/modules/login_log/__init__.py b/src/modules/login_log/__init__.py new file mode 100644 index 0000000..cc2d1af --- /dev/null +++ b/src/modules/login_log/__init__.py @@ -0,0 +1,6 @@ +# +====================================================================== +# | 模块: 登录日志包 +# | 说明: 声明 login_log 为包,用于组织登录日志模块的路由、处理器、 +# | 服务与数据访问 +# +====================================================================== +"""登录日志模块。""" diff --git a/src/modules/login_log/handlers.py b/src/modules/login_log/handlers.py new file mode 100644 index 0000000..ddb2eae --- /dev/null +++ b/src/modules/login_log/handlers.py @@ -0,0 +1,82 @@ +# +====================================================================== +# | 模块: 登录日志请求处理器层 +# | 说明: 承接登录日志相关的 HTTP 请求,校验权限/演示模式并记录操作日志后, +# | 转交登录日志服务层处理(分页、详情、删除、批量删除) +# +====================================================================== +"""登录日志请求处理器。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:权限/演示模式装饰器、操作日志装饰器、统一响应对象、 +# 请求处理基类、通用异常、登录日志服务单例 +# ---------------------------------------------------------------------- +from common.middleware.access_decorators import check_demo, permission_required +from core import response as R +from core.base_handler import BaseHandler +from core.exceptions import NotFoundError +from modules.login_log.service import login_log_service + +from common.middleware.operation_log import operation_log + + +# ====================================================================== +# 查询登录日志分页数据 +# ====================================================================== +class LoginLogPageHandler(BaseHandler): + """查询登录日志分页数据。""" + + @permission_required("sys:loginLog:list") + async def get(self): + """GET 请求:返回登录日志分页列表。""" + return await login_log_service.get_page(self) + + +# ====================================================================== +# 查询登录日志详情 +# ====================================================================== +class LoginLogDetailHandler(BaseHandler): + """查询登录日志详情。""" + + @permission_required("sys:loginLog:detail") + async def get(self, id): + """GET 请求:按 ID 返回登录日志详情,未找到抛 404。 + + :param id: 日志 ID(路径参数) + :raises NotFoundError: 日志不存在时抛出 + """ + data = login_log_service.get_detail(id) + # 记录不存在时抛统一异常,由 BaseHandler 转为标准响应 + if not data: + raise NotFoundError() + return R.ok(self, data=data) + + +# ====================================================================== +# 删除登录日志 +# ====================================================================== +class LoginLogDeleteHandler(BaseHandler): + """删除登录日志。""" + + @permission_required("sys:loginLog:delete") + @operation_log("登录日志管理", "删除") + @check_demo + async def delete(self, id): + """DELETE 请求:按 ID 删除登录日志(软删除)。 + + :param id: 日志 ID(路径参数) + """ + return await login_log_service.delete(self, id) + + +# ====================================================================== +# 批量删除登录日志 +# ====================================================================== +class LoginLogBatchDeleteHandler(BaseHandler): + """批量删除登录日志。""" + + @permission_required("sys:loginLog:delete") + @operation_log("登录日志管理", "批量删除") + @check_demo + async def post(self): + """POST 请求:批量删除登录日志(软删除)。""" + return await login_log_service.batch_delete(self) diff --git a/src/modules/login_log/models.py b/src/modules/login_log/models.py new file mode 100644 index 0000000..0019d80 --- /dev/null +++ b/src/modules/login_log/models.py @@ -0,0 +1,54 @@ +# +====================================================================== +# | 模块: 登录日志数据模型层 +# | 说明: 定义登录日志表结构,继承 BaseModel 获得通用字段与序列化能力 +# +====================================================================== +"""登录日志数据模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:SQLAlchemy 列类型与函数、表名前缀配置、模型基类 +# ---------------------------------------------------------------------- +from sqlalchemy import Column, Integer, String, Text, text + +from config.database import DB_PREFIX +from core.base_model import BaseModel + + +# ====================================================================== +# 登录日志模型 +# ====================================================================== +class LoginLog(BaseModel): + """登录日志模型。""" + + # ============================================================ + # 表定义 + # ============================================================ + __tablename__ = DB_PREFIX + "login_log" + __table_comment__ = "登录日志表" + + # ============================================================ + # 业务字段 + # ============================================================ + username = Column(String(50), nullable=False, default="", index=True, comment="登录账号") + log_type = Column(Integer, nullable=False, default=1, server_default=text('1'), index=True, + comment="日志类型:1-登录 2-退出") + source = Column(Integer, nullable=False, default=0, server_default=text('0'), comment="登录来源:0-后台 1-前台") + method = Column(String(100), nullable=False, default="", comment="请求方法") + request_method = Column(String(100), nullable=False, default="", comment="请求方式") + url = Column(String(500), nullable=False, default="", comment="请求URL") + param = Column(Text, nullable=True, comment="请求参数") + result = Column(Text, nullable=False, default="", comment="返回结果") + ip = Column(String(50), nullable=False, default="", comment="IP地址") + location = Column(String(100), nullable=False, default="", comment="IP归属地") + os = Column(String(100), nullable=False, default="", comment="操作系统") + browser = Column(String(100), nullable=False, default="", comment="浏览器") + consume_time = Column(Integer, nullable=False, default=0, server_default=text('0'), comment="请求耗时(毫秒)") + status = Column(Integer, nullable=False, default=0, server_default=text('0'), comment="操作状态:0-正常 1-异常") + error = Column(Text, nullable=True, comment="错误信息") + + # ============================================================ + # 调试输出 + # ============================================================ + def __str__(self): + """调试用字符串表示:返回登录日志标识。""" + return f"登录日志{self.id}" diff --git a/src/modules/login_log/repository.py b/src/modules/login_log/repository.py new file mode 100644 index 0000000..14e33d5 --- /dev/null +++ b/src/modules/login_log/repository.py @@ -0,0 +1,24 @@ +# +====================================================================== +# | 模块: 登录日志数据访问层 +# | 说明: 定义登录日志仓储,继承 BaseRepository 获得通用 CRUD 与软删除能力 +# +====================================================================== +"""登录日志数据访问。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:通用仓储基类、登录日志模型 +# ---------------------------------------------------------------------- +from core.base_repository import BaseRepository +from modules.login_log.models import LoginLog + + +# ====================================================================== +# 登录日志仓储 +# ====================================================================== +class LoginLogRepository(BaseRepository): + """登录日志仓储。""" + + # ============================================================ + # 绑定的模型类 + # ============================================================ + model = LoginLog diff --git a/src/modules/login_log/schemas.py b/src/modules/login_log/schemas.py new file mode 100644 index 0000000..043989a --- /dev/null +++ b/src/modules/login_log/schemas.py @@ -0,0 +1,8 @@ +# +====================================================================== +# | 模块: 登录日志请求校验模型层 +# | 说明: 说明登录日志为追加型数据,仅提供查询与删除,无需校验模型 +# +====================================================================== +"""登录日志请求校验模型。 + +登录日志为追加型数据,仅提供查询与删除,无需新增/编辑校验模型。 +""" diff --git a/src/modules/login_log/service.py b/src/modules/login_log/service.py new file mode 100644 index 0000000..3b8fe5f --- /dev/null +++ b/src/modules/login_log/service.py @@ -0,0 +1,97 @@ +# +====================================================================== +# | 模块: 登录日志业务逻辑层 +# | 说明: 提供登录日志落库函数(独立会话,供中间件调用), +# | 以及登录日志查询/删除服务(追加型数据,无新增/编辑) +# +====================================================================== +"""登录日志业务逻辑。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:业务服务基类、独立会话工厂、日志器、登录日志模型/仓储 +# ---------------------------------------------------------------------- +from core.base_service import BaseService +from core.database import SessionLocal +from core.logger import get_logger +from modules.login_log.models import LoginLog +from modules.login_log.repository import LoginLogRepository + +# 登录日志专用日志器 +logger = get_logger("operation") + + +# ====================================================================== +# 记录登录日志 +# ====================================================================== +def record_login_log(username, log_type, source, method, request_method, url, param, + result, ip, location, os, browser, consume_time, status, error) -> None: + """记录一条登录日志(登录成功/失败、退出均记录;落库失败不影响主流程)。 + + 使用独立会话 SessionLocal() 落库,避免与请求线程 scoped_session db 并发冲突。 + + :param username: 登录账号(同时写入 create_user) + :param log_type: 日志类型(1-登录 2-退出) + :param source: 登录来源(0-后台 1-前台) + :param method: 请求方法 + :param request_method: 请求方式 + :param url: 请求 URL + :param param: 请求参数 + :param result: 返回结果 + :param ip: IP 地址 + :param location: IP 归属地 + :param os: 操作系统 + :param browser: 浏览器 + :param consume_time: 请求耗时(毫秒) + :param status: 操作状态(0-正常 1-异常) + :param error: 错误信息 + """ + # 使用独立会话落库,避免与请求线程共享 Session + session = SessionLocal() + try: + session.add(LoginLog( + create_user=username, + username=username, log_type=log_type, source=source, method=method, + request_method=request_method, url=url, param=param, result=result, + ip=ip, location=location, os=os, browser=browser, + consume_time=consume_time, status=status, error=error, + )) + session.commit() + except Exception: + # 落库失败不影响主流程,仅记录异常 + session.rollback() + logger.exception("记录登录日志失败") + finally: + session.close() + + +# ====================================================================== +# 登录日志服务 +# ====================================================================== +class LoginLogService(BaseService): + """登录日志查询/删除服务(追加型数据,无新增/编辑)。""" + + # ============================================================ + # 基础声明 + # ============================================================ + model = LoginLog + repo_cls = LoginLogRepository + + # ============================================================ + # 差异点声明 + # ============================================================ + # 分页查询字段与排序 + page_like_fields = ("username",) + page_eq_fields = ("log_type", "source", "status") + page_order_by = (("id", "desc"),) + # 枚举显示名映射 + serialize_maps = { + # 枚举统一走 dict 字典表(seed_dict.py 已种 log_type/login_source/login_status) + "log_type": "log_type", + "source": "login_source", + "status": "login_status", + } + + +# ====================================================================== +# 服务单例 +# ====================================================================== +login_log_service = LoginLogService() diff --git a/src/modules/menu/__init__.py b/src/modules/menu/__init__.py new file mode 100644 index 0000000..7211328 --- /dev/null +++ b/src/modules/menu/__init__.py @@ -0,0 +1,5 @@ +# +====================================================================== +# | 模块: 菜单管理包 +# | 说明: 声明 menu 为包,用于组织菜单模块的路由、处理器、服务与数据访问 +# +====================================================================== +"""菜单管理模块。""" \ No newline at end of file diff --git a/src/modules/menu/handlers.py b/src/modules/menu/handlers.py new file mode 100644 index 0000000..cf25821 --- /dev/null +++ b/src/modules/menu/handlers.py @@ -0,0 +1,95 @@ +# +====================================================================== +# | 模块: 菜单请求处理器层 +# | 说明: 承接菜单相关的 HTTP 请求,校验权限/演示模式并记录操作日志后, +# | 转交菜单服务层处理(列表、详情、增删改等) +# +====================================================================== +"""菜单请求处理器。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:权限/演示模式装饰器、操作日志装饰器、统一响应对象、 +# 请求处理基类、通用异常、菜单服务单例 +# ---------------------------------------------------------------------- +from common.middleware.access_decorators import check_demo, permission_required +from common.middleware.operation_log import operation_log +from core import response as R +from core.base_handler import BaseHandler +from core.exceptions import NotFoundError +from modules.menu.service import menu_service + + +# ====================================================================== +# 查询菜单列表数据 +# ====================================================================== +class MenuListHandler(BaseHandler): + """查询菜单列表数据。""" + + @permission_required("sys:menu:list") + async def get(self): + """GET 请求:返回菜单列表(通常为树形结构)。""" + return await menu_service.get_list(self) + + +# ====================================================================== +# 查询菜单详情 +# ====================================================================== +class MenuDetailHandler(BaseHandler): + """查询菜单详情。""" + + @permission_required("sys:menu:detail") + async def get(self, id): + """GET 请求:按 ID 返回菜单详情,未找到抛 404。 + + :param id: 菜单 ID(路径参数) + :raises NotFoundError: 菜单不存在时抛出 + """ + data = menu_service.get_detail(id) + # 记录不存在时抛统一异常,由 BaseHandler 转为标准响应 + if not data: + raise NotFoundError() + return R.ok(self, data=data) + + +# ====================================================================== +# 添加菜单 +# ====================================================================== +class MenuAddHandler(BaseHandler): + """添加菜单。""" + + @permission_required("sys:menu:add") + @operation_log("菜单管理", "添加") + @check_demo + async def post(self): + """POST 请求:新增菜单。""" + return await menu_service.add(self) + + +# ====================================================================== +# 更新菜单 +# ====================================================================== +class MenuUpdateHandler(BaseHandler): + """更新菜单。""" + + @permission_required("sys:menu:update") + @operation_log("菜单管理", "更新") + @check_demo + async def put(self): + """PUT 请求:更新菜单信息。""" + return await menu_service.update(self) + + +# ====================================================================== +# 删除菜单 +# ====================================================================== +class MenuDeleteHandler(BaseHandler): + """删除菜单。""" + + @permission_required("sys:menu:delete") + @operation_log("菜单管理", "删除") + @check_demo + async def delete(self, id): + """DELETE 请求:按 ID 删除菜单(软删除)。 + + :param id: 菜单 ID(路径参数) + """ + return await menu_service.delete(self, id) diff --git a/src/modules/menu/models.py b/src/modules/menu/models.py new file mode 100644 index 0000000..b21dd6d --- /dev/null +++ b/src/modules/menu/models.py @@ -0,0 +1,51 @@ +# +====================================================================== +# | 模块: 菜单数据模型层 +# | 说明: 定义菜单表结构,继承 BaseModel 获得通用字段与序列化能力 +# +====================================================================== +"""菜单数据模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:SQLAlchemy 列类型与函数、表名前缀配置、模型基类 +# ---------------------------------------------------------------------- +from sqlalchemy import Column, Integer, String, text + +from config.database import DB_PREFIX +from core.base_model import BaseModel + + +# ====================================================================== +# 菜单模型 +# ====================================================================== +class Menu(BaseModel): + """菜单模型。""" + + # ============================================================ + # 表定义 + # ============================================================ + __tablename__ = DB_PREFIX + "menu" + __table_comment__ = "菜单表" + + # ============================================================ + # 业务字段 + # ============================================================ + name = Column(String(150), nullable=False, index=True, comment="菜单名称") + icon = Column(String(50), nullable=True, comment="菜单图标") + icon2 = Column(String(50), nullable=True, comment="菜单图标") + parent_id = Column(Integer, default=0, server_default=text('0'), index=True, comment="上级ID") + path = Column(String(255), nullable=True, comment="菜单路径") + component = Column(String(255), nullable=True, comment="菜单组件") + permission = Column(String(150), nullable=True, index=True, comment="权限节点") + target = Column(String(30), default='_self', comment="打开方式:1-内部打开 2-外部打开") + type = Column(Integer, default=0, server_default=text('0'), index=True, comment="菜单类型:0-菜单 1-节点") + status = Column(Integer, default=1, server_default=text('1'), index=True, comment="菜单状态:1-正常 2-停用") + hide = Column(Integer, default=1, server_default=text('1'), comment="是否可见:0-可见 1-不可见") + sort = Column(Integer, default=0, server_default=text('0'), comment="排序") + note = Column(String(255), nullable=True, comment="备注") + + # ============================================================ + # 调试输出 + # ============================================================ + def __str__(self): + """调试用字符串表示:返回菜单标识。""" + return f"菜单{self.id}" diff --git a/src/modules/menu/repository.py b/src/modules/menu/repository.py new file mode 100644 index 0000000..16e3985 --- /dev/null +++ b/src/modules/menu/repository.py @@ -0,0 +1,56 @@ +# +====================================================================== +# | 模块: 菜单数据访问层 +# | 说明: 定义菜单仓储,继承 BaseRepository 获得通用 CRUD 能力, +# | 并扩展菜单/权限节点的专用查询 +# +====================================================================== +"""菜单数据访问。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:类型标注、通用仓储基类、菜单模型 +# ---------------------------------------------------------------------- +from typing import List + +from core.base_repository import BaseRepository +from modules.menu.models import Menu + + +# ====================================================================== +# 菜单仓储 +# ====================================================================== +class MenuRepository(BaseRepository): + """菜单仓储。""" + + # ============================================================ + # 绑定的模型类 + # ============================================================ + model = Menu + + # ============================================================ + # 查询指定类型的菜单列表 + # ============================================================ + def get_menus(self, type: int = 0) -> List[Menu]: + """查询指定类型的菜单列表。 + + :param type: 菜单类型(0-菜单 1-节点),默认 0 + :return: 未删除且指定类型的菜单列表(按排序升序) + """ + return (self.db.query(Menu) + .filter(Menu.is_delete == 0, Menu.type == type) + .order_by(Menu.sort.asc()) + .all()) + + # ============================================================ + # 查询菜单下的权限节点 + # ============================================================ + def get_permission_nodes(self, parent_id: int) -> List[Menu]: + """查询菜单下的权限节点。 + + :param parent_id: 上级菜单 ID + :return: 该菜单下未删除的权限节点列表 + """ + # 局部导入,避免模块顶层导入未使用的依赖 + from sqlalchemy import and_ + return (self.db.query(Menu) + .filter(and_(Menu.parent_id == parent_id, Menu.type == 1, Menu.is_delete == 0)) + .all()) diff --git a/src/modules/menu/schemas.py b/src/modules/menu/schemas.py new file mode 100644 index 0000000..b0d6a01 --- /dev/null +++ b/src/modules/menu/schemas.py @@ -0,0 +1,40 @@ +# +====================================================================== +# | 模块: 菜单请求校验模型层 +# | 说明: 定义菜单创建/编辑的 Pydantic 校验模型 +# +====================================================================== +"""菜单请求校验模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:类型标注、Pydantic 字段定义、Schema 基类 +# ---------------------------------------------------------------------- +from typing import List, Optional + +from pydantic import Field + +from core.base_schema import BaseSchema + + +# ====================================================================== +# 菜单创建/编辑表单 +# ====================================================================== +class MenuSchema(BaseSchema): + """菜单创建/编辑表单。""" + + # ============================================================ + # 表单字段 + # ============================================================ + name: str = Field(..., min_length=1, max_length=150, description="菜单名称") + icon: Optional[str] = Field(None, max_length=50, description="菜单图标") + icon2: Optional[str] = Field(None, max_length=50, description="菜单图标") + path: Optional[str] = Field(None, max_length=255, description="路由地址") + component: Optional[str] = Field(None, max_length=255, description="组件路径") + parentId: int = Field(..., ge=0, description="上级ID") + type: Optional[int] = Field(None, ge=0, le=1, description="菜单类型:0-菜单 1-节点") + target: Optional[int] = Field(None, ge=0, le=2, description="打开方式:0-组件 1-内链 2-外链") + permission: Optional[str] = Field(None, max_length=150, description="权限节点") + hide: Optional[int] = Field(None, ge=0, le=1, description="是否可见:0-可见 1-不可见") + status: int = Field(..., ge=0, le=1, description="菜单状态:0-正常 1-停用") + sort: int = Field(..., ge=0, le=99999, description="菜单排序") + note: Optional[str] = Field(None, max_length=255, description="菜单备注") + checkedList: Optional[List[int]] = Field(default=[], description="权限节点") diff --git a/src/modules/menu/service.py b/src/modules/menu/service.py new file mode 100644 index 0000000..ffa343c --- /dev/null +++ b/src/modules/menu/service.py @@ -0,0 +1,429 @@ +# +====================================================================== +# | 模块: 菜单业务逻辑层 +# | 说明: 定义菜单服务,覆写列表/详情/增删改方法并处理权限节点联动; +# | 另提供权限节点写入、菜单树生成、下拉结构等定制逻辑 +# +====================================================================== +"""菜单业务逻辑。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:日期时间、SQLAlchemy 条件表达式、ID 列表解析工具、 +# 统一响应对象、业务服务基类、当前用户上下文、数据库会话、 +# 菜单模型/仓储/校验模型、权限缓存失效工具、角色/用户关联模型 +# ---------------------------------------------------------------------- +from datetime import datetime + +from sqlalchemy import and_ + +from common.utils.helpers import parse_id_list +from core import response as R +from core.base_service import BaseService +from core.context import current_realname +from core.database import db +from modules.menu.models import Menu +from modules.menu.repository import MenuRepository +from modules.menu.schemas import MenuSchema +from common.permission import invalidate_permission_cache +from modules.role.models import Role +from modules.role_menu.models import RoleMenu +from modules.user.models import User +from modules.user_role.models import UserRole + + +# ====================================================================== +# 菜单服务 +# ====================================================================== +class MenuService(BaseService): + """菜单服务。""" + + # ============================================================ + # 基础声明 + # ============================================================ + model = Menu + repo_cls = MenuRepository + + # ============================================================ + # 差异点声明 + # ============================================================ + create_schema = MenuSchema + update_schema = MenuSchema + page_order_by = (("sort", "asc"),) + + # ============================================================ + # 组装查询条件 + # ============================================================ + def _build_query(self, handler): + """列表查询:name 参数匹配菜单名称。 + + :param handler: Tornado 请求处理器 + :return: 查询对象 + """ + query = self.repo.filter() + # 按菜单名称模糊过滤 + name = handler.get_query_argument('name', None) + if name: + query = query.filter(Menu.name.like('%' + name + '%')) + return query.order_by(Menu.sort.asc()) + + # ============================================================ + # 列表查询(不分页) + # ============================================================ + async def get_list(self, handler) -> R: + """列表查询:返回全部数据(不分页)。 + + :param handler: Tornado 请求处理器 + :return: 统一响应对象 + """ + try: + query = self._build_query(handler) + items = query.all() + result = [self._serialize(item) for item in items] + return R.ok(handler, data=result) + finally: + self.repo.close() + + # ============================================================ + # 详情序列化(覆写) + # ============================================================ + def _serialize_detail(self, obj): + """详情:附加权限节点选中状态。 + + :param obj: 菜单模型实例 + :return: 序列化后的详情字典 + """ + data = self._serialize(obj) + # 查询该菜单下的权限节点,返回其 sort 作为选中标记 + permission_list = MenuRepository().get_permission_nodes(obj.id) + data['checkedList'] = [v.sort for v in permission_list] + return data + + # ============================================================ + # 新增菜单(覆写) + # ============================================================ + async def add(self, handler): + """添加菜单:保存后写入权限节点。 + + :param handler: Tornado 请求处理器 + :return: 统一响应对象 + """ + try: + form = self._validate(handler, self.create_schema) + data = form.model_dump() + # 取出权限节点列表,不写入主表 + checked_list = data.pop('checked_list', None) + err = self._check_unique(form) + if err: + return R.failed(handler, err) + fields = self._build_create_fields(data) + menu = self.repo.create(self.model(**fields)) + # 写入权限节点 + set_permission(form.type, checked_list, form.name, form.path, menu.id, current_realname.get()) + return R.ok(handler, msg="添加成功") + finally: + self.repo.close() + + # ============================================================ + # 更新菜单(覆写) + # ============================================================ + async def update(self, handler): + """更新菜单:保存后重写权限节点。 + + :param handler: Tornado 请求处理器 + :return: 统一响应对象 + """ + try: + form = self._validate(handler, self.update_schema) + data = form.model_dump() + # 校验 ID 合法 + menu_id = int(data.pop('id', 0) or 0) + if menu_id <= 0: + return R.failed(handler, "记录ID不能为空") + if not self.repo.get_by_id(menu_id): + return R.failed(handler, "记录不存在") + err = self._check_unique(form, exclude_id=menu_id) + if err: + return R.failed(handler, err) + # 取出权限节点列表后重写节点 + checked_list = data.pop('checked_list', None) + fields = self._build_update_fields(data) + if not self.repo.update(menu_id, fields): + return R.failed(handler, "更新失败") + set_permission(form.type, checked_list, form.name, form.path, menu_id, current_realname.get()) + return R.ok(handler, msg="更新成功") + finally: + self.repo.close() + + # ============================================================ + # 删除菜单(覆写) + # ============================================================ + async def delete(self, handler, ids) -> R: + """删除菜单:单事务软删自身、全部子级(含权限节点)与 role_menu 关联,并失效权限缓存。 + + 防止父菜单删除后子级成为孤儿、或子级权限节点仍经 role_menu 继续授权。 + + :param handler: Tornado 请求处理器 + :param ids: 逗号分隔的 ID 字符串 + :return: 统一响应对象 + """ + try: + id_list = list(dict.fromkeys(parse_id_list(str(ids)))) + if not id_list: + return R.failed(handler, "记录ID不存在") + if len(self.repo.get_by_ids(id_list)) != len(id_list): + return R.failed(handler, "记录不存在") + # 收集全部后代菜单 ID(含 type=1 权限节点) + to_delete = set(id_list) + frontier = list(id_list) + while frontier: + rows = db.query(Menu.id).filter( + and_(Menu.parent_id.in_(frontier), Menu.is_delete == 0)).all() + children = [r[0] for r in rows if r[0] not in to_delete] + if not children: + break + to_delete.update(children) + frontier = children + try: + # 软删菜单(含后代)与角色菜单关联 + db.query(Menu).filter( + and_(Menu.id.in_(to_delete), Menu.is_delete == 0)).update( + {Menu.is_delete: 1}, synchronize_session=False) + db.query(RoleMenu).filter( + and_(RoleMenu.menu_id.in_(to_delete), RoleMenu.is_delete == 0)).update( + {RoleMenu.is_delete: 1}, synchronize_session=False) + db.commit() + # 失效权限缓存 + invalidate_permission_cache() + except Exception: + db.rollback() + raise + return R.ok(handler, msg="本次共删除{0}条数据".format(len(id_list))) + finally: + db.close() + + +# ====================================================================== +# 服务单例 +# ====================================================================== +menu_service = MenuService() + + +# ====================================================================== +# 权限节点写入 / 菜单树(定制逻辑) +# ====================================================================== + +# ============================================================ +# 保存菜单权限节点 +# ============================================================ +def set_permission(menu_type, checked_list, name, path, parent_id, realname=""): + """保存菜单权限节点(type=0 菜单时生成 sys:xxx:action 节点)。 + + 安全修复: + - 仅增删 type=1 权限节点,绝不触碰 type=0 子菜单(原实现 delete 无 type 过滤会删光子菜单); + - 已存在节点原地更新(保持 ID 不变),避免 role_menu 关联失效导致角色权限被静默撤销; + - 取消勾选的节点软删并清理其 role_menu 关联。 + + :param menu_type: 菜单类型(0-菜单 1-节点) + :param checked_list: 勾选的权限节点 sort 列表,None 表示不修改 + :param name: 菜单名称 + :param path: 菜单路径 + :param parent_id: 父级菜单 ID + :param realname: 操作者姓名 + """ + if menu_type != 0 or not path or checked_list is None: + # checked_list 未传表示"不修改权限节点"(保持现有节点);显式传 [] 才清空 + return + module_title = name.replace("管理", "") + path_arr = path.split('/') + if len(path_arr) < 3: + return + module_name = path_arr[-1] + + # sort -> 权限节点定义(名称、路径、权限编码) + node_map = { + 1: ("查询" + module_title, "/" + module_name + "/list", "sys:" + module_name + ":list"), + 5: ("添加" + module_title, "/" + module_name + "/add", "sys:" + module_name + ":add"), + 10: ("修改" + module_title, "/" + module_name + "/update", "sys:" + module_name + ":update"), + 15: ("删除" + module_title, "/" + module_name + "/delete", "sys:" + module_name + ":delete"), + 20: (module_title + "详情", "/" + module_name + "/detail", "sys:" + module_name + ":detail"), + 25: ("设置状态", "/" + module_name + "/status", "sys:" + module_name + ":status"), + 30: ("批量删除", "/" + module_name + "/dall", "sys:" + module_name + ":dall"), + 35: ("添加子级", "/" + module_name + "/addz", "sys:" + module_name + ":addz"), + 40: ("全部展开", "/" + module_name + "/expand", "sys:" + module_name + ":expand"), + 45: ("全部折叠", "/" + module_name + "/collapse", "sys:" + module_name + ":collapse"), + 50: ("导出" + module_title, "/" + module_name + "/export", "sys:" + module_name + ":export"), + 55: ("导入" + module_title, "/" + module_name + "/import", "sys:" + module_name + ":import"), + 60: ("分配权限", "/" + module_name + "/permission", "sys:" + module_name + ":permission"), + 65: ("重置密码", "/" + module_name + "/resetPwd", "sys:" + module_name + ":resetPwd"), + } + + try: + # 当前已存在的权限节点(type=1),按 sort 索引 + existing = db.query(Menu).filter( + and_(Menu.parent_id == parent_id, Menu.type == 1, Menu.is_delete == 0)).all() + existing_by_sort = {m.sort: m for m in existing} + checked_sorts = set() + + # 逐个处理勾选的节点:存在则原地更新,否则新增 + for v in checked_list or []: + try: + node = node_map.get(int(v)) + except (ValueError, TypeError): + continue + if not node: + continue + sort = int(v) + checked_sorts.add(sort) + m = existing_by_sort.get(sort) + if m: + # 复用原节点 ID,保持 role_menu 关联有效 + m.name = node[0] + m.path = node[1] + m.permission = node[2] + m.status = 1 + m.update_user = realname + m.update_time = datetime.now() + else: + db.add(Menu( + parent_id=parent_id, + type=1, + status=1, + target="_self", + sort=sort, + create_user=realname, + name=node[0], + path=node[1], + permission=node[2], + )) + + # 取消勾选的节点:软删并清理角色关联 + for m in existing: + if m.sort not in checked_sorts: + db.query(RoleMenu).filter(RoleMenu.menu_id == m.id).update( + {RoleMenu.is_delete: 1}, synchronize_session=False) + m.is_delete = 1 + db.commit() + # 失效权限缓存 + invalidate_permission_cache() + except Exception: + db.rollback() + raise + finally: + db.close() + + +# ============================================================ +# 查询用户菜单树 +# ============================================================ +async def get_permission_menu_list(user_id): + """根据用户 ID 查询菜单树列表。 + + :param user_id: 用户 ID + :return: 树状菜单列表 + """ + user_id = int(user_id) + # 侧边栏菜单仅返回可见项(hide=0),隐藏菜单不参与前端渲染 + if user_id == 1: + # 超级管理员:全部可见菜单 + menu_list = db.query(Menu).filter( + and_(Menu.is_delete == 0, Menu.status == 0, Menu.type == 0, Menu.hide == 0)).order_by(Menu.sort.asc()).all() + return get_tree_list(menu_list) + # 其他用户:关联角色菜单(过滤软删的用户/角色/菜单关联,且仅取状态正常的用户) + menu_list = ( + db.query(Menu) + .join(RoleMenu, RoleMenu.menu_id == Menu.id) + .join(UserRole, UserRole.role_id == RoleMenu.role_id) + .join(Role, Role.id == UserRole.role_id) + .join(User, User.id == UserRole.user_id) + .filter( + UserRole.user_id == user_id, + UserRole.is_delete == 0, + RoleMenu.is_delete == 0, + Role.is_delete == 0, + User.is_delete == 0, + User.status == 1, + Menu.type == 0, + Menu.status == 0, + Menu.hide == 0, + Menu.is_delete == 0, + ) + # distinct:多角色共享同一菜单时 join 会产生重复行,去重避免侧边栏重复菜单 + .distinct() + .order_by(Menu.sort.asc()) + .all() + ) + return get_tree_list(menu_list) + + +# ============================================================ +# 生成树状结构 +# ============================================================ +def get_tree_list(menu_list): + """根据数据源生成树状结构。 + + :param menu_list: 菜单模型实例列表 + :return: 树状结构列表 + """ + # 先转成字典列表,再统一构建树 + data = [{ + 'id': item.id, + 'name': item.name, + 'icon': item.icon, + 'path': item.path, + 'parentId': item.parent_id, + 'type': item.type, + 'component': item.component, + 'permission': item.permission, + 'target': item.target, + 'hide': item.hide, + } for item in menu_list] + return get_tree(data, 0) + + +# ============================================================ +# 构建树(单次遍历) +# ============================================================ +def get_tree(data, parent_id): + """根据数据源生成树状结构(单次遍历按 parentId 索引,避免 O(n²) 递归)。 + + :param data: 字典列表(每项含 id / parentId) + :param parent_id: 顶层父级 ID(通常为 0) + :return: 树状结构列表 + """ + nodes = {item["id"]: item for item in data} + result = [] + for item in data: + if item["parentId"] == parent_id: + result.append(item) + else: + parent = nodes.get(item["parentId"]) + if parent is not None: + parent.setdefault("children", []).append(item) + # 孤立节点(父级不在数据集中)与原始递归行为一致:丢弃 + # 统一补齐 children 字段,保证前端渲染一致 + for item in data: + item.setdefault("children", []) + return result + + +# ============================================================ +# 菜单树下拉结构 +# ============================================================ +def make_list(): + """获取菜单树状下拉结构。 + + :return: {菜单ID: 缩进名称} 映射字典 + """ + try: + menu_list = db.query(Menu).filter(and_(Menu.type == 0, Menu.is_delete == 0)).order_by(Menu.sort.asc()).all() + tree = get_tree_list(menu_list) + # 按层级拼接缩进前缀(最多三级) + menu_map = {} + for val in tree: + menu_map[val['id']] = val['name'] + for v in val['children']: + menu_map[v['id']] = "|--" + v['name'] + for vt in v['children']: + menu_map[vt['id']] = "|--|--" + vt['name'] + return menu_map + finally: + db.close() diff --git a/src/modules/notice/__init__.py b/src/modules/notice/__init__.py new file mode 100644 index 0000000..46ca2ed --- /dev/null +++ b/src/modules/notice/__init__.py @@ -0,0 +1,5 @@ +# +====================================================================== +# | 模块: 通知公告包 +# | 说明: 声明 notice 为包,用于组织通知公告模块的路由、处理器、服务与数据访问 +# +====================================================================== +"""通知公告模块。""" \ No newline at end of file diff --git a/src/modules/notice/handlers.py b/src/modules/notice/handlers.py new file mode 100644 index 0000000..22c5def --- /dev/null +++ b/src/modules/notice/handlers.py @@ -0,0 +1,103 @@ +# +====================================================================== +# | 模块: 通知公告请求处理器层 +# | 说明: 承接通知公告相关的 HTTP 请求,校验权限/演示模式并记录操作日志后, +# | 转交通知公告服务层处理(分页、详情、增删改、批量删除等) +# +====================================================================== +"""通知公告请求处理器。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:权限/演示模式装饰器、操作日志装饰器、统一响应对象、 +# 请求处理基类、通知公告服务单例 +# ---------------------------------------------------------------------- +from common.middleware.access_decorators import check_demo, permission_required +from common.middleware.operation_log import operation_log +from core import response as R +from core.base_handler import BaseHandler +from modules.notice.service import notice_service + + +# ====================================================================== +# 查询通知公告分页数据 +# ====================================================================== +class NoticePageHandler(BaseHandler): + """查询通知公告分页数据。""" + + @permission_required("sys:notice:list") + async def get(self): + """GET 请求:返回通知公告分页列表。""" + return await notice_service.get_page(self) + + +# ====================================================================== +# 查询通知公告详情 +# ====================================================================== +class NoticeDetailHandler(BaseHandler): + """查询通知公告详情。""" + + @permission_required("sys:notice:detail") + async def get(self, id): + """GET 请求:按 ID 返回通知公告详情。 + + :param id: 公告 ID(路径参数) + """ + return R.ok(self, data=notice_service.get_detail(id)) + + +# ====================================================================== +# 添加通知公告 +# ====================================================================== +class NoticeAddHandler(BaseHandler): + """添加通知公告。""" + + @permission_required("sys:notice:add") + @operation_log("通知公告", "添加") + @check_demo + async def post(self): + """POST 请求:新增通知公告。""" + return await notice_service.add(self) + + +# ====================================================================== +# 更新通知公告 +# ====================================================================== +class NoticeUpdateHandler(BaseHandler): + """更新通知公告。""" + + @permission_required("sys:notice:update") + @operation_log("通知公告", "更新") + @check_demo + async def put(self): + """PUT 请求:更新通知公告信息。""" + return await notice_service.update(self) + + +# ====================================================================== +# 删除通知公告 +# ====================================================================== +class NoticeDeleteHandler(BaseHandler): + """删除通知公告。""" + + @permission_required("sys:notice:delete") + @operation_log("通知公告", "删除") + @check_demo + async def delete(self, id): + """DELETE 请求:按 ID 删除通知公告(软删除)。 + + :param id: 公告 ID(路径参数) + """ + return await notice_service.delete(self, id) + + +# ====================================================================== +# 批量删除通知公告 +# ====================================================================== +class NoticeBatchDeleteHandler(BaseHandler): + """批量删除通知公告。""" + + @permission_required("sys:notice:delete") + @operation_log("通知公告管理", "批量删除") + @check_demo + async def post(self): + """POST 请求:批量删除通知公告(软删除)。""" + return await notice_service.batch_delete(self) diff --git a/src/modules/notice/models.py b/src/modules/notice/models.py new file mode 100644 index 0000000..2bf65d5 --- /dev/null +++ b/src/modules/notice/models.py @@ -0,0 +1,44 @@ +# +====================================================================== +# | 模块: 通知公告数据模型层 +# | 说明: 定义通知公告表结构,继承 BaseModel 获得通用字段与序列化能力 +# +====================================================================== +"""通知公告数据模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:SQLAlchemy 列类型与函数、表名前缀配置、模型基类 +# ---------------------------------------------------------------------- +from sqlalchemy import Column, Integer, String, Text, text + +from config.database import DB_PREFIX +from core.base_model import BaseModel + + +# ====================================================================== +# 通知公告模型 +# ====================================================================== +class Notice(BaseModel): + """通知公告模型。""" + + # ============================================================ + # 表定义 + # ============================================================ + __tablename__ = DB_PREFIX + "notice" + __table_comment__ = "通知公告表" + + # ============================================================ + # 业务字段 + # ============================================================ + title = Column(String(255), nullable=False, index=True, comment="通知标题") + cover = Column(String(255), nullable=True, comment="通知封面") + type = Column(Integer, default=1, server_default=text('1'), index=True, comment="通知类型:1-通知 2-公告") + click_num = Column(Integer, default=0, server_default=text('0'), comment="点击率") + status = Column(Integer, default=1, server_default=text('1'), index=True, comment="通知状态:1-正常 2-停用") + content = Column(Text, nullable=True, comment="通知公告内容") + + # ============================================================ + # 调试输出 + # ============================================================ + def __str__(self): + """调试用字符串表示:返回通知公告标题。""" + return f"通知公告{self.title}" diff --git a/src/modules/notice/repository.py b/src/modules/notice/repository.py new file mode 100644 index 0000000..6fb6993 --- /dev/null +++ b/src/modules/notice/repository.py @@ -0,0 +1,24 @@ +# +====================================================================== +# | 模块: 通知公告数据访问层 +# | 说明: 定义通知公告仓储,继承 BaseRepository 获得通用 CRUD 与软删除能力 +# +====================================================================== +"""通知公告数据访问。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:通用仓储基类、通知公告模型 +# ---------------------------------------------------------------------- +from core.base_repository import BaseRepository +from modules.notice.models import Notice + + +# ====================================================================== +# 通知公告仓储 +# ====================================================================== +class NoticeRepository(BaseRepository): + """通知公告仓储。""" + + # ============================================================ + # 绑定的模型类 + # ============================================================ + model = Notice diff --git a/src/modules/notice/schemas.py b/src/modules/notice/schemas.py new file mode 100644 index 0000000..f62fe30 --- /dev/null +++ b/src/modules/notice/schemas.py @@ -0,0 +1,31 @@ +# +====================================================================== +# | 模块: 通知公告请求校验模型层 +# | 说明: 定义通知公告创建/编辑的 Pydantic 校验模型 +# +====================================================================== +"""通知公告请求校验模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:类型标注、Pydantic 字段定义、Schema 基类 +# ---------------------------------------------------------------------- +from typing import Optional + +from pydantic import Field + +from core.base_schema import BaseSchema + + +# ====================================================================== +# 通知公告创建/编辑表单 +# ====================================================================== +class NoticeSchema(BaseSchema): + """通知公告创建/编辑表单。""" + + # ============================================================ + # 表单字段 + # ============================================================ + title: str = Field(..., min_length=1, max_length=255, description="通知标题") + cover: str = Field(..., min_length=1, max_length=255, description="通知封面") + type: int = Field(..., ge=1, le=2, description="通知类型:1-通知 2-公告") + status: int = Field(..., ge=1, le=2, description="通知状态:1-正常 2-停用") + content: Optional[str] = Field(None, description="通知内容") diff --git a/src/modules/notice/service.py b/src/modules/notice/service.py new file mode 100644 index 0000000..1258510 --- /dev/null +++ b/src/modules/notice/service.py @@ -0,0 +1,93 @@ +# +====================================================================== +# | 模块: 通知公告业务逻辑层 +# | 说明: 定义通知公告服务,提供富文本详情加工与保存钩子, +# | 列表剔除完整富文本内容,最后导出服务单例 +# +====================================================================== +"""通知公告业务逻辑。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:富文本保存工具、文件访问前缀配置、业务服务基类、 +# 通知公告模型/仓储/校验模型 +# ---------------------------------------------------------------------- +from common.utils.rich_text import save_content +from config.app import TORNADO_FILE_URL +from core.base_service import BaseService +from modules.notice.models import Notice +from modules.notice.repository import NoticeRepository +from modules.notice.schemas import NoticeSchema + + +# ====================================================================== +# 详情加工:富文本占位符还原 +# ====================================================================== +def _notice_detail_serialize(obj): + """详情加工:富文本占位符还原为图片地址。 + + :param obj: 通知公告模型实例 + :return: 需合并进详情字典的额外字段,无内容时返回 None + """ + if obj.content: + return {"content": obj.content.replace("[IMG_URL]", TORNADO_FILE_URL)} + return None + + +# ====================================================================== +# 通知公告服务 +# ====================================================================== +class NoticeService(BaseService): + """通知公告服务。""" + + # ============================================================ + # 基础声明 + # ============================================================ + model = Notice + repo_cls = NoticeRepository + + # ============================================================ + # 差异点声明 + # ============================================================ + create_schema = NoticeSchema + update_schema = NoticeSchema + # 分页查询字段与排序 + page_like_fields = ("title",) + page_eq_fields = ("type", "status") + page_order_by = (("id", "desc"),) + # 枚举显示名、详情加工钩子与列表剔除字段 + serialize_maps = {"type": {1: "通知", 2: "公告"}} + file_fields = ("cover",) + detail_serialize = _notice_detail_serialize + # 列表不返回完整富文本 content(含未还原的 [IMG_URL] 占位符),详情才返回 + page_exclude_fields = ("content",) + + # ============================================================ + # 新增前钩子(富文本本地化) + # ============================================================ + def _before_add(self, handler, data): + """新增前:保存富文本内容(图片本地化)。 + + :param handler: Tornado 请求处理器 + :param data: 待入库的表单数据字典 + """ + content = data.get('content') + if content: + data['content'] = save_content(content, data.get('title', ''), "notice") + + # ============================================================ + # 编辑前钩子(富文本本地化) + # ============================================================ + def _before_update(self, handler, data): + """编辑前:保存富文本内容(图片本地化)。 + + :param handler: Tornado 请求处理器 + :param data: 待入库的表单数据字典 + """ + content = data.get('content') + if content: + data['content'] = save_content(content, data.get('title', ''), "notice") + + +# ====================================================================== +# 服务单例 +# ====================================================================== +notice_service = NoticeService() diff --git a/src/modules/operation_log/__init__.py b/src/modules/operation_log/__init__.py new file mode 100644 index 0000000..6269801 --- /dev/null +++ b/src/modules/operation_log/__init__.py @@ -0,0 +1,6 @@ +# +====================================================================== +# | 模块: 操作日志包 +# | 说明: 声明 operation_log 为包,用于组织操作日志模块的路由、处理器、 +# | 服务与数据访问 +# +====================================================================== +"""操作日志模块。""" diff --git a/src/modules/operation_log/handlers.py b/src/modules/operation_log/handlers.py new file mode 100644 index 0000000..502f1c3 --- /dev/null +++ b/src/modules/operation_log/handlers.py @@ -0,0 +1,82 @@ +# +====================================================================== +# | 模块: 操作日志请求处理器层 +# | 说明: 承接操作日志相关的 HTTP 请求,校验权限/演示模式并记录操作日志后, +# | 转交操作日志服务层处理(分页、详情、删除、批量删除) +# +====================================================================== +"""操作日志请求处理器。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:权限/演示模式装饰器、操作日志装饰器、统一响应对象、 +# 请求处理基类、通用异常、操作日志服务单例 +# ---------------------------------------------------------------------- +from common.middleware.access_decorators import check_demo, permission_required +from core import response as R +from core.base_handler import BaseHandler +from core.exceptions import NotFoundError +from modules.operation_log.service import operation_log_service + +from common.middleware.operation_log import operation_log + + +# ====================================================================== +# 查询操作日志分页数据 +# ====================================================================== +class OperationLogPageHandler(BaseHandler): + """查询操作日志分页数据。""" + + @permission_required("sys:operationLog:list") + async def get(self): + """GET 请求:返回操作日志分页列表。""" + return await operation_log_service.get_page(self) + + +# ====================================================================== +# 查询操作日志详情 +# ====================================================================== +class OperationLogDetailHandler(BaseHandler): + """查询操作日志详情。""" + + @permission_required("sys:operationLog:detail") + async def get(self, id): + """GET 请求:按 ID 返回操作日志详情,未找到抛 404。 + + :param id: 日志 ID(路径参数) + :raises NotFoundError: 日志不存在时抛出 + """ + data = operation_log_service.get_detail(id) + # 记录不存在时抛统一异常,由 BaseHandler 转为标准响应 + if not data: + raise NotFoundError() + return R.ok(self, data=data) + + +# ====================================================================== +# 删除操作日志 +# ====================================================================== +class OperationLogDeleteHandler(BaseHandler): + """删除操作日志。""" + + @permission_required("sys:operationLog:delete") + @operation_log("操作日志管理", "删除") + @check_demo + async def delete(self, id): + """DELETE 请求:按 ID 删除操作日志(软删除)。 + + :param id: 日志 ID(路径参数) + """ + return await operation_log_service.delete(self, id) + + +# ====================================================================== +# 批量删除操作日志 +# ====================================================================== +class OperationLogBatchDeleteHandler(BaseHandler): + """批量删除操作日志。""" + + @permission_required("sys:operationLog:delete") + @operation_log("操作日志管理", "批量删除") + @check_demo + async def post(self): + """POST 请求:批量删除操作日志(软删除)。""" + return await operation_log_service.batch_delete(self) diff --git a/src/modules/operation_log/models.py b/src/modules/operation_log/models.py new file mode 100644 index 0000000..4b00ea9 --- /dev/null +++ b/src/modules/operation_log/models.py @@ -0,0 +1,55 @@ +# +====================================================================== +# | 模块: 操作日志数据模型层 +# | 说明: 定义操作日志表结构,继承 BaseModel 获得通用字段与序列化能力 +# +====================================================================== +"""操作日志数据模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:SQLAlchemy 列类型与函数、表名前缀配置、模型基类 +# ---------------------------------------------------------------------- +from sqlalchemy import BigInteger, Column, Integer, SmallInteger, String, Text, text + +from config.database import DB_PREFIX +from core.base_model import BaseModel + + +# ====================================================================== +# 操作日志模型 +# ====================================================================== +class OperationLog(BaseModel): + """操作日志模型。""" + + # ============================================================ + # 表定义 + # ============================================================ + __tablename__ = DB_PREFIX + "operation_log" + __table_comment__ = "操作日志表" + + # ============================================================ + # 业务字段 + # ============================================================ + title = Column(String(150), nullable=False, default="", index=True, comment="请求标题") + type = Column(Integer, nullable=False, default=1, server_default=text('1'), index=True, + comment="请求类型:1-新增 2-修改 3-删除") + source = Column(SmallInteger, nullable=False, default=1, server_default=text('1'), + comment="请求来源:1-PC端 2-移动端") + method = Column(String(100), nullable=False, default="", comment="请求方法(操作)") + request_method = Column(String(100), nullable=False, default="", comment="请求方式(HTTP)") + url = Column(String(1000), nullable=False, default="", comment="请求URL") + param = Column(Text, nullable=True, comment="请求参数") + result = Column(Text, nullable=True, comment="请求结果") + ip = Column(String(100), nullable=False, default="", comment="请求IP") + location = Column(String(255), nullable=False, default="", comment="请求所在区域") + os = Column(String(100), nullable=False, default="", comment="操作系统") + browser = Column(String(100), nullable=False, default="", comment="浏览器") + consume_time = Column(BigInteger, nullable=False, default=0, server_default=text('0'), comment="请求耗时(毫秒)") + status = Column(SmallInteger, nullable=False, default=0, server_default=text('0'), comment="请求状态:0-成功 1-失败") + error = Column(Text, nullable=True, comment="错误描述") + + # ============================================================ + # 调试输出 + # ============================================================ + def __str__(self): + """调试用字符串表示:返回操作日志标识。""" + return f"操作日志{self.id}" diff --git a/src/modules/operation_log/repository.py b/src/modules/operation_log/repository.py new file mode 100644 index 0000000..3a4709b --- /dev/null +++ b/src/modules/operation_log/repository.py @@ -0,0 +1,24 @@ +# +====================================================================== +# | 模块: 操作日志数据访问层 +# | 说明: 定义操作日志仓储,继承 BaseRepository 获得通用 CRUD 与软删除能力 +# +====================================================================== +"""操作日志数据访问。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:通用仓储基类、操作日志模型 +# ---------------------------------------------------------------------- +from core.base_repository import BaseRepository +from modules.operation_log.models import OperationLog + + +# ====================================================================== +# 操作日志仓储 +# ====================================================================== +class OperationLogRepository(BaseRepository): + """操作日志仓储。""" + + # ============================================================ + # 绑定的模型类 + # ============================================================ + model = OperationLog diff --git a/src/modules/operation_log/schemas.py b/src/modules/operation_log/schemas.py new file mode 100644 index 0000000..18f9088 --- /dev/null +++ b/src/modules/operation_log/schemas.py @@ -0,0 +1,8 @@ +# +====================================================================== +# | 模块: 操作日志请求校验模型层 +# | 说明: 说明操作日志为追加型数据,仅提供查询与删除,无需校验模型 +# +====================================================================== +"""操作日志请求校验模型。 + +操作日志为追加型数据,仅提供查询与删除,无需新增/编辑校验模型。 +""" diff --git a/src/modules/operation_log/service.py b/src/modules/operation_log/service.py new file mode 100644 index 0000000..73c002b --- /dev/null +++ b/src/modules/operation_log/service.py @@ -0,0 +1,101 @@ +# +====================================================================== +# | 模块: 操作日志业务逻辑层 +# | 说明: 提供操作日志落库函数(独立会话,供中间件异步调用), +# | 以及操作日志查询/删除服务(追加型数据,无新增/编辑) +# +====================================================================== +"""操作日志业务逻辑。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:业务服务基类、独立会话工厂、日志器、操作日志模型/仓储 +# ---------------------------------------------------------------------- +from core.base_service import BaseService +from core.database import SessionLocal +from core.logger import get_logger +from modules.operation_log.models import OperationLog +from modules.operation_log.repository import OperationLogRepository + +# 操作日志专用日志器 +logger = get_logger("operation") + + +# ====================================================================== +# 记录操作日志 +# ====================================================================== +def record_operation_log(realname, title, type, source, method, request_method, url, + param, result, ip, location, os, browser, consume_time, + status, error) -> None: + """记录一条操作日志(落库失败不影响主流程)。 + + create_user 显式写入操作用户姓名(异步落库时上下文用户可能已失效,不能依赖审计自动填充)。 + 使用独立会话 SessionLocal() 落库:日志经 IOLoop.spawn_callback 在事件循环线程异步写入, + 复用请求线程的 scoped_session db 可能与并发请求会话冲突,独立会话更安全。 + + :param realname: 操作用户姓名 + :param title: 请求标题 + :param type: 请求类型(1-新增 2-修改 3-删除) + :param source: 请求来源(1-PC端 2-移动端) + :param method: 请求方法(操作) + :param request_method: 请求方式(HTTP) + :param url: 请求 URL + :param param: 请求参数 + :param result: 请求结果 + :param ip: 请求 IP + :param location: 请求所在区域 + :param os: 操作系统 + :param browser: 浏览器 + :param consume_time: 请求耗时(毫秒) + :param status: 请求状态(0-成功 1-失败) + :param error: 错误描述 + """ + # 使用独立会话落库,避免与请求线程共享 Session 冲突 + session = SessionLocal() + try: + session.add(OperationLog( + # 异步落库时上下文用户可能已失效,显式写入 create_user + create_user=realname or "", + title=title, type=type, source=source, method=method, + request_method=request_method, url=url, param=param, + result=result, ip=ip, location=location, os=os, + browser=browser, consume_time=consume_time, status=status, error=error, + )) + session.commit() + except Exception: + # 落库失败不影响主流程,仅记录异常 + session.rollback() + logger.exception("记录操作日志失败") + finally: + session.close() + + +# ====================================================================== +# 操作日志服务 +# ====================================================================== +class OperationLogService(BaseService): + """操作日志查询/删除服务(日志为追加型,无新增/编辑)。""" + + # ============================================================ + # 基础声明 + # ============================================================ + model = OperationLog + repo_cls = OperationLogRepository + + # ============================================================ + # 差异点声明 + # ============================================================ + # 分页查询字段与排序 + page_like_fields = ("title",) + page_eq_fields = ("type", "source", "request_method", "status") + page_order_by = (("id", "desc"),) + # 枚举显示名映射 + serialize_maps = { + "type": {1: "新增", 2: "修改", 3: "删除"}, + "source": {1: "PC端", 2: "移动端"}, + "status": {0: "成功", 1: "失败"}, + } + + +# ====================================================================== +# 服务单例 +# ====================================================================== +operation_log_service = OperationLogService() diff --git a/src/modules/param/__init__.py b/src/modules/param/__init__.py new file mode 100644 index 0000000..c201039 --- /dev/null +++ b/src/modules/param/__init__.py @@ -0,0 +1,5 @@ +# +====================================================================== +# | 模块: 参数管理包 +# | 说明: 声明 param 为包,用于组织系统参数模块的路由、处理器、服务与数据访问 +# +====================================================================== +"""参数管理模块。""" \ No newline at end of file diff --git a/src/modules/param/handlers.py b/src/modules/param/handlers.py new file mode 100644 index 0000000..b7cf2d5 --- /dev/null +++ b/src/modules/param/handlers.py @@ -0,0 +1,117 @@ +# +====================================================================== +# | 模块: 参数请求处理器层 +# | 说明: 承接系统参数相关的 HTTP 请求,校验权限/演示模式并记录操作日志后, +# | 转交参数服务层处理(分页、详情、增删改、状态、批量删除等) +# +====================================================================== +"""参数请求处理器。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:权限/演示模式装饰器、操作日志装饰器、统一响应对象、 +# 请求处理基类、参数服务单例 +# ---------------------------------------------------------------------- +from common.middleware.access_decorators import check_demo, permission_required +from common.middleware.operation_log import operation_log +from core import response as R +from core.base_handler import BaseHandler +from modules.param.service import param_service + + +# ====================================================================== +# 查询参数分页数据 +# ====================================================================== +class ParamPageHandler(BaseHandler): + """查询参数分页数据。""" + + @permission_required("sys:param:list") + async def get(self): + """GET 请求:返回参数分页列表。""" + return await param_service.get_page(self) + + +# ====================================================================== +# 查询参数详情 +# ====================================================================== +class ParamDetailHandler(BaseHandler): + """查询参数详情。""" + + @permission_required("sys:param:detail") + async def get(self, id): + """GET 请求:按 ID 返回参数详情。 + + :param id: 参数 ID(路径参数) + """ + return R.ok(self, data=param_service.get_detail(id)) + + +# ====================================================================== +# 添加参数 +# ====================================================================== +class ParamAddHandler(BaseHandler): + """添加参数。""" + + @permission_required("sys:param:add") + @operation_log("参数管理", "添加") + @check_demo + async def post(self): + """POST 请求:新增参数。""" + return await param_service.add(self) + + +# ====================================================================== +# 更新参数 +# ====================================================================== +class ParamUpdateHandler(BaseHandler): + """更新参数。""" + + @permission_required("sys:param:update") + @operation_log("参数管理", "更新") + @check_demo + async def put(self): + """PUT 请求:更新参数信息。""" + return await param_service.update(self) + + +# ====================================================================== +# 删除参数 +# ====================================================================== +class ParamDeleteHandler(BaseHandler): + """删除参数。""" + + @permission_required("sys:param:delete") + @operation_log("参数管理", "删除") + @check_demo + async def delete(self, id): + """DELETE 请求:按 ID 删除参数(软删除)。 + + :param id: 参数 ID(路径参数) + """ + return await param_service.delete(self, id) + + +# ====================================================================== +# 设置参数状态 +# ====================================================================== +class ParamStatusHandler(BaseHandler): + """设置参数状态。""" + + @permission_required("sys:param:status") + @operation_log("参数管理", "设置状态") + @check_demo + async def put(self): + """PUT 请求:修改参数状态(如启用 / 禁用)。""" + return await param_service.update_status(self) + + +# ====================================================================== +# 批量删除参数 +# ====================================================================== +class ParamBatchDeleteHandler(BaseHandler): + """批量删除参数。""" + + @permission_required("sys:param:delete") + @operation_log("参数管理", "批量删除") + @check_demo + async def post(self): + """POST 请求:批量删除参数(软删除)。""" + return await param_service.batch_delete(self) diff --git a/src/modules/param/models.py b/src/modules/param/models.py new file mode 100644 index 0000000..e61c4f5 --- /dev/null +++ b/src/modules/param/models.py @@ -0,0 +1,46 @@ +# +====================================================================== +# | 模块: 参数数据模型层 +# | 说明: 定义系统参数表结构,继承 BaseModel 获得通用字段与序列化能力 +# +====================================================================== +"""参数数据模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:SQLAlchemy 列类型与函数、表名前缀配置、模型基类 +# ---------------------------------------------------------------------- +from sqlalchemy import Column, Integer, String, text + +from config.database import DB_PREFIX +from core.base_model import BaseModel + + +# ====================================================================== +# 参数模型 +# ====================================================================== +class Param(BaseModel): + """参数模型。""" + + # ============================================================ + # 表定义 + # ============================================================ + __tablename__ = DB_PREFIX + "param" + __table_comment__ = "系统参数表" + + # ============================================================ + # 业务字段 + # ============================================================ + name = Column(String(100), nullable=False, index=True, comment="参数名称") + code = Column(String(100), nullable=False, index=True, comment="参数编码") + value = Column(String(255), nullable=False, default="", comment="参数值") + type = Column(Integer, nullable=False, default=0, server_default=text('0'), comment="参数类型:0-系统 1-业务") + status = Column(Integer, nullable=False, default=1, server_default=text('1'), index=True, + comment="参数状态:1-正常 2-禁用") + sort = Column(Integer, nullable=False, default=0, server_default=text('0'), comment="参数排序") + note = Column(String(255), nullable=True, comment="参数备注") + + # ============================================================ + # 调试输出 + # ============================================================ + def __str__(self): + """调试用字符串表示:返回参数标识。""" + return f"参数{self.id}" diff --git a/src/modules/param/repository.py b/src/modules/param/repository.py new file mode 100644 index 0000000..a49fa6a --- /dev/null +++ b/src/modules/param/repository.py @@ -0,0 +1,24 @@ +# +====================================================================== +# | 模块: 参数数据访问层 +# | 说明: 定义参数仓储,继承 BaseRepository 获得通用 CRUD 与软删除能力 +# +====================================================================== +"""参数数据访问。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:通用仓储基类、参数模型 +# ---------------------------------------------------------------------- +from core.base_repository import BaseRepository +from modules.param.models import Param + + +# ====================================================================== +# 参数仓储 +# ====================================================================== +class ParamRepository(BaseRepository): + """参数仓储。""" + + # ============================================================ + # 绑定的模型类 + # ============================================================ + model = Param diff --git a/src/modules/param/schemas.py b/src/modules/param/schemas.py new file mode 100644 index 0000000..a799361 --- /dev/null +++ b/src/modules/param/schemas.py @@ -0,0 +1,46 @@ +# +====================================================================== +# | 模块: 参数请求校验模型层 +# | 说明: 定义参数创建/编辑与状态更新的 Pydantic 校验模型 +# +====================================================================== +"""参数请求校验模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:类型标注、Pydantic 基类与字段定义、Schema 基类 +# ---------------------------------------------------------------------- +from typing import Optional + +from pydantic import BaseModel, Field + +from core.base_schema import BaseSchema + + +# ====================================================================== +# 参数创建/编辑表单 +# ====================================================================== +class ParamSchema(BaseSchema): + """参数创建/编辑表单。""" + + # ============================================================ + # 表单字段 + # ============================================================ + name: str = Field(..., min_length=1, max_length=100, description="参数名称") + code: str = Field(..., min_length=1, max_length=100, description="参数编码") + value: str = Field(..., min_length=1, max_length=255, description="参数值") + type: int = Field(default=0, ge=0, le=1, description="参数类型:0-系统 1-业务") + status: int = Field(default=1, ge=1, le=2, description="参数状态:1-正常 2-禁用") + sort: int = Field(..., ge=0, le=99999, description="参数排序") + note: Optional[str] = Field(None, max_length=255, description="参数备注") + + +# ====================================================================== +# 参数状态更新表单 +# ====================================================================== +class ParamStatusSchema(BaseModel): + """参数状态更新表单。""" + + # ============================================================ + # 表单字段 + # ============================================================ + id: int = Field(..., gt=0, description="参数ID") + status: int = Field(..., ge=1, le=2, description="参数状态:1-正常 2-禁用") diff --git a/src/modules/param/service.py b/src/modules/param/service.py new file mode 100644 index 0000000..f4d33f5 --- /dev/null +++ b/src/modules/param/service.py @@ -0,0 +1,49 @@ +# +====================================================================== +# | 模块: 参数业务逻辑层 +# | 说明: 定义参数服务,声明差异点以复用通用 CRUD 流程,并导出服务单例 +# +====================================================================== +"""参数业务逻辑。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:业务服务基类、参数模型/仓储/校验模型 +# ---------------------------------------------------------------------- +from core.base_service import BaseService +from modules.param.models import Param +from modules.param.repository import ParamRepository +from modules.param.schemas import ParamSchema, ParamStatusSchema + + +# ====================================================================== +# 参数服务 +# ====================================================================== +class ParamService(BaseService): + """参数服务。""" + + # ============================================================ + # 基础声明 + # ============================================================ + model = Param + repo_cls = ParamRepository + + # ============================================================ + # 差异点声明 + # ============================================================ + create_schema = ParamSchema + update_schema = ParamSchema + status_schema = ParamStatusSchema + # 分页查询字段与排序 + page_like_fields = ("name", "code") + page_eq_fields = ("type", "status") + page_order_by = (("sort", "asc"),) + # 枚举显示名映射:统一走 dict 字典表(seed_dict.py 已种 param_type/param_status),避免维护双份字面量 + serialize_maps = { + "type": "param_type", + "status": "param_status", + } + + +# ====================================================================== +# 服务单例 +# ====================================================================== +param_service = ParamService() diff --git a/src/modules/position/__init__.py b/src/modules/position/__init__.py new file mode 100644 index 0000000..869e6e2 --- /dev/null +++ b/src/modules/position/__init__.py @@ -0,0 +1,5 @@ +# +====================================================================== +# | 模块: 岗位管理包 +# | 说明: 声明 position 为包,用于组织岗位模块的路由、处理器、服务与数据访问 +# +====================================================================== +"""岗位管理模块。""" diff --git a/src/modules/position/handlers.py b/src/modules/position/handlers.py new file mode 100644 index 0000000..9c96555 --- /dev/null +++ b/src/modules/position/handlers.py @@ -0,0 +1,128 @@ +# +====================================================================== +# | 模块: 岗位请求处理器层 +# | 说明: 承接岗位相关的 HTTP 请求,校验权限/演示模式并记录操作日志后, +# | 转交岗位服务层处理(分页、详情、增删改、状态、下拉列表等) +# +====================================================================== +"""岗位请求处理器。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:权限/演示模式装饰器、操作日志装饰器、统一响应对象、 +# 请求处理基类、岗位服务单例 +# ---------------------------------------------------------------------- +from common.middleware.access_decorators import check_demo, permission_required +from common.middleware.operation_log import operation_log +from core import response as R +from core.base_handler import BaseHandler +from modules.position.service import position_service + + +# ====================================================================== +# 查询岗位分页数据 +# ====================================================================== +class PositionPageHandler(BaseHandler): + """查询岗位分页数据。""" + + @permission_required("sys:position:list") + async def get(self): + """GET 请求:返回岗位分页列表。""" + return await position_service.get_page(self) + + +# ====================================================================== +# 查询岗位详情 +# ====================================================================== +class PositionDetailHandler(BaseHandler): + """查询岗位详情。""" + + @permission_required("sys:position:detail") + async def get(self, id): + """GET 请求:按 ID 返回岗位详情。 + + :param id: 岗位 ID(路径参数) + """ + return R.ok(self, data=position_service.get_detail(id)) + + +# ====================================================================== +# 添加岗位 +# ====================================================================== +class PositionAddHandler(BaseHandler): + """添加岗位。""" + + @permission_required("sys:position:add") + @operation_log("岗位管理", "添加") + @check_demo + async def post(self): + """POST 请求:新增岗位。""" + return await position_service.add(self) + + +# ====================================================================== +# 更新岗位 +# ====================================================================== +class PositionUpdateHandler(BaseHandler): + """更新岗位。""" + + @permission_required("sys:position:update") + @operation_log("岗位管理", "更新") + @check_demo + async def put(self): + """PUT 请求:更新岗位信息。""" + return await position_service.update(self) + + +# ====================================================================== +# 删除岗位 +# ====================================================================== +class PositionDeleteHandler(BaseHandler): + """删除岗位。""" + + @permission_required("sys:position:delete") + @operation_log("岗位管理", "删除") + @check_demo + async def delete(self, id): + """DELETE 请求:按 ID 删除岗位(软删除)。 + + :param id: 岗位 ID(路径参数) + """ + return await position_service.delete(self, id) + + +# ====================================================================== +# 设置岗位状态 +# ====================================================================== +class PositionStatusHandler(BaseHandler): + """设置岗位状态。""" + + @permission_required("sys:position:status") + @operation_log("岗位管理", "设置状态") + @check_demo + async def put(self): + """PUT 请求:修改岗位状态(如启用 / 禁用)。""" + return await position_service.update_status(self) + + +# ====================================================================== +# 获取岗位下拉列表 +# ====================================================================== +class PositionGetListHandler(BaseHandler): + """获取岗位下拉列表。""" + + async def get(self): + """GET 请求:返回岗位下拉列表(无需权限校验)。""" + return await position_service.get_options(self) + + +# ====================================================================== +# 批量删除岗位 +# ====================================================================== +class PositionBatchDeleteHandler(BaseHandler): + """批量删除岗位。""" + + @permission_required("sys:position:delete") + @operation_log("岗位管理", "批量删除") + @check_demo + async def post(self): + """POST 请求:批量删除岗位(软删除)。""" + return await position_service.batch_delete(self) diff --git a/src/modules/position/models.py b/src/modules/position/models.py new file mode 100644 index 0000000..6cd34de --- /dev/null +++ b/src/modules/position/models.py @@ -0,0 +1,41 @@ +# +====================================================================== +# | 模块: 岗位数据模型层 +# | 说明: 定义岗位表结构,继承 BaseModel 获得通用字段与序列化能力 +# +====================================================================== +"""岗位数据模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:SQLAlchemy 列类型与函数、表名前缀配置、模型基类 +# ---------------------------------------------------------------------- +from sqlalchemy import Column, Integer, String, text + +from config.database import DB_PREFIX +from core.base_model import BaseModel + + +# ====================================================================== +# 岗位模型 +# ====================================================================== +class Position(BaseModel): + """岗位模型。""" + + # ============================================================ + # 表定义 + # ============================================================ + __tablename__ = DB_PREFIX + "position" + __table_comment__ = "岗位表" + + # ============================================================ + # 业务字段 + # ============================================================ + name = Column(String(255), nullable=False, index=True, comment="岗位名称") + status = Column(Integer, default=0, server_default=text('0'), index=True, comment="岗位状态:1-在用 2-停用") + sort = Column(Integer, default=0, server_default=text('0'), comment="岗位排序") + + # ============================================================ + # 调试输出 + # ============================================================ + def __repr__(self): + """调试用字符串表示:返回岗位名称。""" + return f'岗位:{self.name}' diff --git a/src/modules/position/repository.py b/src/modules/position/repository.py new file mode 100644 index 0000000..40a80cb --- /dev/null +++ b/src/modules/position/repository.py @@ -0,0 +1,24 @@ +# +====================================================================== +# | 模块: 岗位数据访问层 +# | 说明: 定义岗位仓储,继承 BaseRepository 获得通用 CRUD 与软删除能力 +# +====================================================================== +"""岗位数据访问。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:通用仓储基类、岗位模型 +# ---------------------------------------------------------------------- +from core.base_repository import BaseRepository +from modules.position.models import Position + + +# ====================================================================== +# 岗位仓储 +# ====================================================================== +class PositionRepository(BaseRepository): + """岗位仓储。""" + + # ============================================================ + # 绑定的模型类 + # ============================================================ + model = Position diff --git a/src/modules/position/schemas.py b/src/modules/position/schemas.py new file mode 100644 index 0000000..bab7c9e --- /dev/null +++ b/src/modules/position/schemas.py @@ -0,0 +1,40 @@ +# +====================================================================== +# | 模块: 岗位请求校验模型层 +# | 说明: 定义岗位创建/编辑与状态更新的 Pydantic 校验模型 +# +====================================================================== +"""岗位请求校验模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:Pydantic 基类与字段定义、Schema 基类 +# ---------------------------------------------------------------------- +from pydantic import BaseModel, Field + +from core.base_schema import BaseSchema + + +# ====================================================================== +# 岗位创建/编辑表单 +# ====================================================================== +class PositionSchema(BaseSchema): + """岗位创建/编辑表单。""" + + # ============================================================ + # 表单字段 + # ============================================================ + name: str = Field(..., min_length=1, max_length=150, description="岗位名称") + status: int = Field(default=1, ge=1, le=2, description="岗位状态:1-在用 2-停用") + sort: int = Field(..., ge=0, le=99999, description="岗位排序") + + +# ====================================================================== +# 岗位状态更新表单 +# ====================================================================== +class PositionStatusSchema(BaseModel): + """岗位状态更新表单。""" + + # ============================================================ + # 表单字段 + # ============================================================ + id: int = Field(..., gt=0, description="岗位ID") + status: int = Field(..., ge=1, le=2, description="岗位状态:1-在用 2-停用") diff --git a/src/modules/position/service.py b/src/modules/position/service.py new file mode 100644 index 0000000..4a38f7d --- /dev/null +++ b/src/modules/position/service.py @@ -0,0 +1,47 @@ +# +====================================================================== +# | 模块: 岗位业务逻辑层 +# | 说明: 定义岗位服务,声明差异点以复用通用 CRUD 流程,并导出服务单例 +# +====================================================================== +"""岗位业务逻辑。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:业务服务基类、岗位模型、岗位仓储、岗位校验模型 +# ---------------------------------------------------------------------- +from core.base_service import BaseService +from modules.position.models import Position +from modules.position.repository import PositionRepository +from modules.position.schemas import PositionSchema, PositionStatusSchema + + +# ====================================================================== +# 岗位服务 +# ====================================================================== +class PositionService(BaseService): + """岗位服务。""" + + # ============================================================ + # 基础声明 + # ============================================================ + model = Position + repo_cls = PositionRepository + + # ============================================================ + # 差异点声明 + # ============================================================ + # 校验模型 + create_schema = PositionSchema + update_schema = PositionSchema + status_schema = PositionStatusSchema + # 分页查询字段 + page_like_fields = ("name",) + page_eq_fields = ("status",) + # 分页排序与下拉过滤 + page_order_by = (("sort", "asc"),) + options_filter = {"status": 1} + + +# ====================================================================== +# 服务单例 +# ====================================================================== +position_service = PositionService() diff --git a/src/modules/role/__init__.py b/src/modules/role/__init__.py new file mode 100644 index 0000000..3b60559 --- /dev/null +++ b/src/modules/role/__init__.py @@ -0,0 +1,5 @@ +# +====================================================================== +# | 模块: 角色管理包 +# | 说明: 声明 role 为包,用于组织角色模块的路由、处理器、服务与数据访问 +# +====================================================================== +"""角色管理模块。""" diff --git a/src/modules/role/handlers.py b/src/modules/role/handlers.py new file mode 100644 index 0000000..b7780df --- /dev/null +++ b/src/modules/role/handlers.py @@ -0,0 +1,120 @@ +# +====================================================================== +# | 模块: 角色请求处理器层 +# | 说明: 承接角色相关的 HTTP 请求,校验权限/演示模式并记录操作日志后, +# | 转交角色服务层处理(分页、详情、增删改、下拉列表、菜单分配等) +# +====================================================================== +"""角色请求处理器。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:权限/演示模式装饰器、操作日志装饰器、统一响应对象、 +# 请求处理基类、通用异常、角色服务单例 +# ---------------------------------------------------------------------- +from common.middleware.access_decorators import check_demo, permission_required +from common.middleware.operation_log import operation_log +from core import response as R +from core.base_handler import BaseHandler +from core.exceptions import NotFoundError +from modules.role.service import role_service + + +# ====================================================================== +# 查询角色分页数据 +# ====================================================================== +class RolePageHandler(BaseHandler): + """查询角色分页数据。""" + + @permission_required("sys:role:list") + async def get(self): + """GET 请求:返回角色分页列表。""" + return await role_service.get_page(self) + + +# ====================================================================== +# 查询角色详情 +# ====================================================================== +class RoleDetailHandler(BaseHandler): + """查询角色详情。""" + + @permission_required("sys:role:detail") + async def get(self, id): + """GET 请求:按 ID 返回角色详情,未找到抛 404。 + + :param id: 角色 ID(路径参数) + :raises NotFoundError: 角色不存在时抛出 + """ + data = role_service.get_detail(id) + # 记录不存在时抛统一异常,由 BaseHandler 转为标准响应 + if not data: + raise NotFoundError() + return R.ok(self, data=data) + + +# ====================================================================== +# 添加角色 +# ====================================================================== +class RoleAddHandler(BaseHandler): + """添加角色。""" + + @permission_required("sys:role:add") + @operation_log("角色管理", "添加") + @check_demo + async def post(self): + """POST 请求:新增角色。""" + return await role_service.add(self) + + +# ====================================================================== +# 更新角色 +# ====================================================================== +class RoleUpdateHandler(BaseHandler): + """更新角色。""" + + @permission_required("sys:role:update") + @operation_log("角色管理", "更新") + @check_demo + async def put(self): + """PUT 请求:更新角色信息。""" + return await role_service.update(self) + + +# ====================================================================== +# 删除角色 +# ====================================================================== +class RoleDeleteHandler(BaseHandler): + """删除角色。""" + + @permission_required("sys:role:delete") + @operation_log("角色管理", "删除") + @check_demo + async def delete(self, id): + """DELETE 请求:按 ID 删除角色(软删除)。 + + :param id: 角色 ID(路径参数) + """ + return await role_service.delete(self, id) + + +# ====================================================================== +# 获取角色下拉列表 +# ====================================================================== +class RoleGetListHandler(BaseHandler): + """获取角色下拉列表。""" + + async def get(self): + """GET 请求:返回角色下拉列表(无需权限校验)。""" + return await role_service.get_options(self) + + +# ====================================================================== +# 批量删除角色 +# ====================================================================== +class RoleBatchDeleteHandler(BaseHandler): + """批量删除角色。""" + + @permission_required("sys:role:delete") + @operation_log("角色管理", "批量删除") + @check_demo + async def post(self): + """POST 请求:批量删除角色(软删除)。""" + return await role_service.batch_delete(self) diff --git a/src/modules/role/models.py b/src/modules/role/models.py new file mode 100644 index 0000000..63661c7 --- /dev/null +++ b/src/modules/role/models.py @@ -0,0 +1,42 @@ +# +====================================================================== +# | 模块: 角色数据模型层 +# | 说明: 定义角色表结构,继承 BaseModel 获得通用字段与序列化能力 +# +====================================================================== +"""角色数据模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:SQLAlchemy 列类型与函数、表名前缀配置、模型基类 +# ---------------------------------------------------------------------- +from sqlalchemy import Column, Integer, String, text + +from config.database import DB_PREFIX +from core.base_model import BaseModel + + +# ====================================================================== +# 角色模型 +# ====================================================================== +class Role(BaseModel): + """角色模型。""" + + # ============================================================ + # 表定义 + # ============================================================ + __tablename__ = DB_PREFIX + "role" + __table_comment__ = "角色表" + + # ============================================================ + # 业务字段 + # ============================================================ + name = Column(String(150), nullable=False, index=True, comment="角色名称") + code = Column(String(30), nullable=False, index=True, comment="角色编码") + sort = Column(Integer, default=0, server_default=text('0'), comment="角色排序") + note = Column(String(255), nullable=True, comment="角色备注") + + # ============================================================ + # 调试输出 + # ============================================================ + def __str__(self): + """调试用字符串表示:返回角色名称。""" + return f'角色{self.name}' diff --git a/src/modules/role/repository.py b/src/modules/role/repository.py new file mode 100644 index 0000000..63317b4 --- /dev/null +++ b/src/modules/role/repository.py @@ -0,0 +1,41 @@ +# +====================================================================== +# | 模块: 角色数据访问层 +# | 说明: 定义角色仓储,继承 BaseRepository 获得通用能力, +# | 并扩展角色菜单查询 +# +====================================================================== +"""角色数据访问。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:类型标注、通用仓储基类、角色模型、角色菜单模型 +# ---------------------------------------------------------------------- +from typing import List + +from core.base_repository import BaseRepository +from modules.role.models import Role +from modules.role_menu.models import RoleMenu + + +# ====================================================================== +# 角色仓储 +# ====================================================================== +class RoleRepository(BaseRepository): + """角色仓储。""" + + # ============================================================ + # 绑定的模型类 + # ============================================================ + model = Role + + # ============================================================ + # 查询角色关联的菜单ID + # ============================================================ + def get_menus(self, role_id: int) -> List[int]: + """查询角色关联的菜单ID(过滤软删关联)。 + + :param role_id: 角色 ID + :return: 该角色关联的菜单 ID 列表 + """ + records = self.db.query(RoleMenu).filter( + RoleMenu.role_id == role_id, RoleMenu.is_delete == 0).all() + return [r.menu_id for r in records] diff --git a/src/modules/role/schemas.py b/src/modules/role/schemas.py new file mode 100644 index 0000000..ec26600 --- /dev/null +++ b/src/modules/role/schemas.py @@ -0,0 +1,43 @@ +# +====================================================================== +# | 模块: 角色请求校验模型层 +# | 说明: 定义角色创建/编辑与角色菜单分配的 Pydantic 校验模型 +# +====================================================================== +"""角色请求校验模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:类型标注、Pydantic 基类与字段定义、Schema 基类 +# ---------------------------------------------------------------------- +from typing import List, Optional + +from pydantic import BaseModel, Field + +from core.base_schema import BaseSchema + + +# ====================================================================== +# 角色创建/编辑表单 +# ====================================================================== +class RoleSchema(BaseSchema): + """角色创建/编辑表单。""" + + # ============================================================ + # 表单字段 + # ============================================================ + name: str = Field(..., min_length=1, max_length=150, description="角色名称") + code: str = Field(..., min_length=1, max_length=30, description="角色编码") + sort: int = Field(..., ge=0, le=99999, description="角色排序") + note: Optional[str] = Field(None, max_length=255, description="角色备注") + + +# ====================================================================== +# 保存角色菜单分配表单 +# ====================================================================== +class RoleMenuSaveSchema(BaseModel): + """保存角色菜单分配表单。""" + + # ============================================================ + # 表单字段 + # ============================================================ + roleId: int = Field(..., gt=0, description="角色ID") + menuIds: Optional[List[int]] = Field(default=[], description="菜单ID列表") diff --git a/src/modules/role/service.py b/src/modules/role/service.py new file mode 100644 index 0000000..01a5c0a --- /dev/null +++ b/src/modules/role/service.py @@ -0,0 +1,121 @@ +# +====================================================================== +# | 模块: 角色业务逻辑层 +# | 说明: 定义角色服务,覆写删除方法(模板方法定制:分页模糊 name) +# +====================================================================== +"""角色业务逻辑。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:SQLAlchemy 条件表达式、ID 列表解析工具、统一响应对象、 +# 业务服务基类、数据库会话、权限缓存失效工具、 +# 角色/角色菜单/用户角色模型、角色仓储、角色校验模型 +# ---------------------------------------------------------------------- +from sqlalchemy import and_ + +from common.permission import invalidate_permission_cache +from common.utils.helpers import parse_id_list +from core import response as R +from core.base_service import BaseService +from core.database import db +from modules.role.models import Role +from modules.role.repository import RoleRepository +from modules.role.schemas import RoleSchema +from modules.role_menu.models import RoleMenu +from modules.user_role.models import UserRole + + +# ====================================================================== +# 角色服务 +# ====================================================================== +class RoleService(BaseService): + """角色服务(模板方法定制:分页模糊 name、角色菜单分配)。""" + + # ============================================================ + # 基础声明 + # ============================================================ + model = Role + repo_cls = RoleRepository + + # ============================================================ + # 差异点声明 + # ============================================================ + create_schema = RoleSchema + update_schema = RoleSchema + # 分页查询字段与排序 + page_like_fields = ("name",) + page_order_by = (("sort", "asc"),) + + # ============================================================ + # 查询角色菜单列表(含选中标记) + # ============================================================ + async def get_role_menu_list(self, role_id): + """根据角色 ID 查询菜单列表(含选中标记)。 + + :param role_id: 角色 ID + :return: 带 checked 标记的菜单列表,无菜单时返回 None + """ + try: + menu_list = db.query(Menu).filter( + and_(Menu.is_delete == 0, Menu.status == 0)).order_by(Menu.sort.asc()).all() + if not menu_list: + return None + # 查询该角色已分配的菜单 ID 集合 + id_list = {r.menu_id for r in db.query(RoleMenu).filter( + RoleMenu.role_id == role_id, RoleMenu.is_delete == 0).all()} + + result = [] + for menu in menu_list: + data = {'id': menu.id, 'name': menu.name, 'open': True, 'parentId': menu.parent_id} + # 已分配的菜单标记为选中 + if menu.id in id_list: + data['checked'] = True + result.append(data) + return result + finally: + db.close() + + # ============================================================ + # 删除角色(覆写) + # ============================================================ + async def delete(self, handler, ids) -> R: + """删除角色:单事务软删角色与其菜单/用户关联,避免主体与关联跨事务不一致。 + + :param handler: Tornado 请求处理器 + :param ids: 逗号分隔的 ID 字符串 + :return: 统一响应对象 + """ + try: + id_list = list(dict.fromkeys(parse_id_list(str(ids)))) + if not id_list: + return R.failed(handler, "记录ID不存在") + if len(self.repo.get_by_ids(id_list)) != len(id_list): + return R.failed(handler, "记录不存在") + try: + # 软删角色菜单关联 + db.query(RoleMenu).filter( + and_(RoleMenu.role_id.in_(id_list), RoleMenu.is_delete == 0)).update( + {RoleMenu.is_delete: 1}, synchronize_session=False) + # 软删用户角色关联 + db.query(UserRole).filter( + and_(UserRole.role_id.in_(id_list), UserRole.is_delete == 0)).update( + {UserRole.is_delete: 1}, synchronize_session=False) + # 软删角色本身 + db.query(Role).filter( + and_(Role.id.in_(id_list), Role.is_delete == 0)).update( + {Role.is_delete: 1}, synchronize_session=False) + db.commit() + # 失效权限缓存 + invalidate_permission_cache() + except Exception: + db.rollback() + raise + return R.ok(handler, msg="本次共删除{0}条数据".format(len(id_list))) + finally: + db.close() + + +# ====================================================================== +# 服务单例 +# ====================================================================== +# 模块级服务实例(handlers 复用) +role_service = RoleService() diff --git a/src/modules/role_menu/__init__.py b/src/modules/role_menu/__init__.py new file mode 100644 index 0000000..4cf4962 --- /dev/null +++ b/src/modules/role_menu/__init__.py @@ -0,0 +1,5 @@ +# +====================================================================== +# | 模块: 角色菜单 +# | 说明: 管理角色与菜单的关联关系 +# +====================================================================== +"""角色菜单模块。""" diff --git a/src/modules/role_menu/handlers.py b/src/modules/role_menu/handlers.py new file mode 100644 index 0000000..e7765b2 --- /dev/null +++ b/src/modules/role_menu/handlers.py @@ -0,0 +1,51 @@ +# +====================================================================== +# | 模块: 角色菜单请求处理器层 +# | 说明: 承接角色菜单分配的查询与保存请求,校验权限/演示模式并记录 +# | 操作日志后,转交角色菜单服务处理 +# +====================================================================== +"""角色菜单请求处理器。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:权限/演示模式装饰器、操作日志装饰器、统一响应对象、 +# 请求处理基类、角色菜单服务与校验模型 +# ---------------------------------------------------------------------- +from common.middleware.access_decorators import check_demo, permission_required +from common.middleware.operation_log import operation_log +from core import response as R +from core.base_handler import BaseHandler +from modules.role_menu import service as role_menu_service +from modules.role_menu.schemas import RoleMenuSaveSchema + + +# ====================================================================== +# 查询角色菜单列表 +# ====================================================================== +class RoleMenuListHandler(BaseHandler): + """查询角色菜单列表。""" + + @permission_required("sys:role:getPermission") + async def get(self, role_id): + """GET 请求:返回角色已分配的菜单 ID 列表。 + + :param role_id: 角色 ID(路径参数) + """ + return R.ok(self, data=role_menu_service.get_role_menu_list(role_id)) + + +# ====================================================================== +# 保存角色菜单数据 +# ====================================================================== +class RoleMenuSaveHandler(BaseHandler): + """保存角色菜单数据。""" + + @permission_required("sys:role:savePermission") + @operation_log("角色管理", "保存角色菜单") + @check_demo + async def post(self): + """POST 请求:保存角色菜单分配。""" + # 局部导入,避免顶层依赖 + from core.base_service import BaseService + # 解析并校验请求体 + data = BaseService.validate(RoleMenuSaveSchema, BaseService.parse_json(self)) + return await role_menu_service.save_role_menu(self, data) diff --git a/src/modules/role_menu/models.py b/src/modules/role_menu/models.py new file mode 100644 index 0000000..bb3f13f --- /dev/null +++ b/src/modules/role_menu/models.py @@ -0,0 +1,40 @@ +# +====================================================================== +# | 模块: 角色菜单模型层 +# | 说明: 定义角色菜单关联表结构,继承 BaseModel 获得通用字段与序列化能力 +# +====================================================================== +"""角色菜单数据模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:SQLAlchemy 列类型与函数、表名前缀配置、模型基类 +# ---------------------------------------------------------------------- +from sqlalchemy import Column, Integer, text + +from config.database import DB_PREFIX +from core.base_model import BaseModel + + +# ====================================================================== +# 角色菜单模型 +# ====================================================================== +class RoleMenu(BaseModel): + """角色菜单模型。""" + + # ============================================================ + # 表定义 + # ============================================================ + __tablename__ = DB_PREFIX + "role_menu" + __table_comment__ = "角色菜单关联表" + + # ============================================================ + # 业务字段 + # ============================================================ + role_id = Column(Integer, nullable=False, default=0, server_default=text('0'), index=True, comment="角色ID") + menu_id = Column(Integer, nullable=False, default=0, server_default=text('0'), index=True, comment="菜单ID") + + # ============================================================ + # 调试输出 + # ============================================================ + def __str__(self): + """调试用字符串表示:返回角色菜单标识。""" + return f"角色菜单{self.id}" diff --git a/src/modules/role_menu/repository.py b/src/modules/role_menu/repository.py new file mode 100644 index 0000000..6171aad --- /dev/null +++ b/src/modules/role_menu/repository.py @@ -0,0 +1,38 @@ +# +====================================================================== +# | 模块: 角色菜单数据访问层 +# | 说明: 定义角色菜单仓储,继承 BaseRepository 获得通用 CRUD 能力, +# | 并扩展按角色删除菜单关联 +# +====================================================================== +"""角色菜单数据访问。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:通用仓储基类、角色菜单模型 +# ---------------------------------------------------------------------- +from core.base_repository import BaseRepository +from modules.role_menu.models import RoleMenu + + +# ====================================================================== +# 角色菜单仓储 +# ====================================================================== +class RoleMenuRepository(BaseRepository): + """角色菜单仓储。""" + + # ============================================================ + # 绑定的模型类 + # ============================================================ + model = RoleMenu + + # ============================================================ + # 按角色删除菜单关联 + # ============================================================ + def delete_by_role(self, role_id: int) -> int: + """删除指定角色的所有菜单关联。 + + :param role_id: 角色 ID + :return: 受影响行数 + """ + return self.db.query(self.model).filter( + self.model.role_id == role_id + ).delete() diff --git a/src/modules/role_menu/schemas.py b/src/modules/role_menu/schemas.py new file mode 100644 index 0000000..53ec9f9 --- /dev/null +++ b/src/modules/role_menu/schemas.py @@ -0,0 +1,26 @@ +# +====================================================================== +# | 模块: 角色菜单请求校验模型层 +# | 说明: 定义角色菜单分配的 Pydantic 校验模型 +# +====================================================================== +"""角色菜单请求校验模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:类型标注、Pydantic 基类与字段定义 +# ---------------------------------------------------------------------- +from typing import List, Optional + +from pydantic import BaseModel, Field + + +# ====================================================================== +# 保存角色菜单分配表单 +# ====================================================================== +class RoleMenuSaveSchema(BaseModel): + """保存角色菜单分配表单。""" + + # ============================================================ + # 表单字段 + # ============================================================ + roleId: int = Field(..., gt=0, description="角色ID") + menuIds: Optional[List[int]] = Field(default=[], description="菜单ID列表") diff --git a/src/modules/role_menu/service.py b/src/modules/role_menu/service.py new file mode 100644 index 0000000..7005b15 --- /dev/null +++ b/src/modules/role_menu/service.py @@ -0,0 +1,95 @@ +# +====================================================================== +# | 模块: 角色菜单业务逻辑层 +# | 说明: 提供角色菜单分配的查询(含选中标记)与保存(含存在性校验、 +# | 重建关联、权限缓存失效) +# +====================================================================== +"""角色菜单业务逻辑。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:SQLAlchemy 条件表达式、统一响应对象、数据库会话、 +# 菜单模型、角色菜单模型/仓储/校验模型 +# ---------------------------------------------------------------------- +from sqlalchemy import and_ + +from core import response as R +from core.database import db +from modules.menu.models import Menu +from modules.role_menu.models import RoleMenu +from modules.role_menu.repository import RoleMenuRepository +from modules.role_menu.schemas import RoleMenuSaveSchema + + +# ====================================================================== +# 查询角色菜单列表 +# ====================================================================== +def get_role_menu_list(role_id): + """根据角色 ID 查询菜单列表(含选中标记)。 + + :param role_id: 角色 ID + :return: 带 checked 标记的菜单列表,无菜单时返回空列表 + """ + try: + from modules.menu.models import Menu + menu_list = db.query(Menu).filter( + and_(Menu.is_delete == 0, Menu.status == 0)).order_by(Menu.sort.asc()).all() + if not menu_list: + return [] + # 查询该角色已分配的菜单 ID 集合 + id_list = {r.menu_id for r in db.query(RoleMenu).filter( + RoleMenu.role_id == role_id, RoleMenu.is_delete == 0).all()} + + result = [] + for menu in menu_list: + data = {'id': menu.id, 'name': menu.name, 'open': True, 'parentId': menu.parent_id} + # 已分配的菜单标记为选中 + if menu.id in id_list: + data['checked'] = True + result.append(data) + return result + finally: + db.close() + + +# ====================================================================== +# 保存角色菜单分配 +# ====================================================================== +async def save_role_menu(handler, data): + """保存角色菜单分配。 + + :param handler: Tornado 请求处理器 + :param data: RoleMenuSaveSchema 实例 + :return: 统一响应对象 + """ + role_menu_repo = RoleMenuRepository() + try: + # 校验角色存在 + from modules.role.repository import RoleRepository + role_repo = RoleRepository() + if not role_repo.get_by_id(data.roleId): + return R.failed(handler, "角色不存在") + + menu_id_list = [int(m) for m in (data.menuIds or []) if m] + valid_menu_ids = [] + # 过滤出真实存在的菜单 ID + if menu_id_list: + rows = db.query(Menu.id).filter( + and_(Menu.id.in_(menu_id_list), Menu.is_delete == 0)).all() + valid_menu_ids = [r[0] for r in rows] + + # 先清空旧关联,再写入新关联 + role_menu_repo.delete_by_role(data.roleId) + for menu_id in valid_menu_ids: + role_menu_repo.db.add(RoleMenu(role_id=data.roleId, menu_id=menu_id)) + role_menu_repo.db.commit() + + # 失效权限缓存 + from common.permission import invalidate_permission_cache + invalidate_permission_cache() + return R.ok(handler) + except Exception: + # 异常回滚,保证主体与关联一致 + role_menu_repo.db.rollback() + raise + finally: + role_menu_repo.close() diff --git a/src/modules/upload/__init__.py b/src/modules/upload/__init__.py new file mode 100644 index 0000000..8d58101 --- /dev/null +++ b/src/modules/upload/__init__.py @@ -0,0 +1,5 @@ +# +====================================================================== +# | 模块: 上传管理包 +# | 说明: 声明 upload 为包,用于组织文件上传模块的路由、处理器与服务 +# +====================================================================== +"""上传模块。""" diff --git a/src/modules/upload/handlers.py b/src/modules/upload/handlers.py new file mode 100644 index 0000000..9778fed --- /dev/null +++ b/src/modules/upload/handlers.py @@ -0,0 +1,26 @@ +# +====================================================================== +# | 模块: 上传请求处理器层 +# | 说明: 承接文件上传的 HTTP 请求,校验权限与演示模式后转交上传服务处理 +# +====================================================================== +"""上传请求处理器。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:权限/演示模式装饰器、请求处理基类、上传服务 +# ---------------------------------------------------------------------- +from common.middleware.access_decorators import check_demo, permission_required +from core.base_handler import BaseHandler +from modules.upload import service + + +# ====================================================================== +# 上传文件(统一入口,支持图片和各类文件) +# ====================================================================== +class UploadFileHandler(BaseHandler): + """上传文件(统一入口)。""" + + @permission_required("sys:upload:file") + @check_demo + async def post(self): + """POST 请求:上传文件并返回文件信息。""" + return await service.upload_file(self) diff --git a/src/modules/upload/models.py b/src/modules/upload/models.py new file mode 100644 index 0000000..1a289ea --- /dev/null +++ b/src/modules/upload/models.py @@ -0,0 +1,6 @@ +# +====================================================================== +# | 模块: 上传模块数据模型层 +# | 说明: 上传不涉及持久化表,本文件仅为模块完整性保留 +# +====================================================================== +"""上传模块数据模型。""" +# 上传不涉及持久化表,本文件仅为模块完整性保留 diff --git a/src/modules/upload/repository.py b/src/modules/upload/repository.py new file mode 100644 index 0000000..4ebe98b --- /dev/null +++ b/src/modules/upload/repository.py @@ -0,0 +1,6 @@ +# +====================================================================== +# | 模块: 上传模块数据访问层 +# | 说明: 上传不涉及数据访问,本文件仅为模块完整性保留 +# +====================================================================== +"""上传模块数据访问。""" +# 上传不涉及数据访问,本文件仅为模块完整性保留 diff --git a/src/modules/upload/schemas.py b/src/modules/upload/schemas.py new file mode 100644 index 0000000..30f82cd --- /dev/null +++ b/src/modules/upload/schemas.py @@ -0,0 +1,6 @@ +# +====================================================================== +# | 模块: 上传请求校验模型层 +# | 说明: 上传使用 multipart 表单,无需 Pydantic 校验模型 +# +====================================================================== +"""上传请求校验模型。""" +# 上传使用 multipart 表单,无需 Pydantic 校验模型 diff --git a/src/modules/upload/service.py b/src/modules/upload/service.py new file mode 100644 index 0000000..3fbb33b --- /dev/null +++ b/src/modules/upload/service.py @@ -0,0 +1,175 @@ +# +====================================================================== +# | 模块: 上传业务逻辑层 +# | 说明: 文件上传(统一入口)、安全性校验(路径遍历防护、魔术字节校验)、 +# | 大小限制、扩展名白名单等业务处理 +# +====================================================================== +"""上传业务逻辑。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:日志、文件路径/随机数/时间、MIME 推断、 +# 文件工具、应用配置(临时目录/文件访问前缀/允许扩展名/上传目录/大小上限)、 +# 统一响应对象 +# ---------------------------------------------------------------------- +import logging +import mimetypes +import os +import random +import time +from io import BytesIO + +from common.utils.file import mkdir, verify_file_magic +from config.app import (TEMP_PATH, TORNADO_FILE_URL, UPLOAD_ALLOWED_EXTS, + UPLOAD_DIR, UPLOAD_MAX_SIZE_MB) +from core import response as R + +logger = logging.getLogger(__name__) + +# ============================================================ +# 上传限制配置 +# ============================================================ +# 允许上传的文件扩展名白名单(来自 .env UPLOAD_ALLOWED_EXTS,带点号、逗号分隔) +ALLOWED_EXTS = {ext.strip().lower() for ext in UPLOAD_ALLOWED_EXTS.split(",") if ext.strip()} +# 单文件大小上限(来自 .env UPLOAD_MAX_SIZE_MB,单位 MB) +MAX_FILE_SIZE = UPLOAD_MAX_SIZE_MB * 1024 * 1024 + + +# ====================================================================== +# 辅助函数:安全校验 +# ====================================================================== +def _is_safe_filename(filename: str) -> bool: + """校验文件名安全性,防止路径遍历攻击。 + + 拒绝包含路径分隔符或 .. 的文件名,确保不会跳出目标目录。 + + :param filename: 原始文件名 + :return: True 表示安全 + """ + if not filename: + return False + normalized = os.path.normpath(filename) + if '..' in normalized.split(os.sep): + return False + basename = os.path.basename(normalized) + return basename == normalized or basename == filename + + +# ====================================================================== +# 辅助函数:MIME 类型推断 +# ====================================================================== +_MIME_MAP = { + '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', + '.gif': 'image/gif', '.bmp': 'image/bmp', '.ico': 'image/x-icon', + '.txt': 'text/plain', '.pdf': 'application/pdf', + '.doc': 'application/msword', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.xls': 'application/vnd.ms-excel', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.ppt': 'application/vnd.ms-powerpoint', + '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + '.mp3': 'audio/mpeg', '.mp4': 'video/mp4', + '.zip': 'application/zip', '.rar': 'application/x-rar-compressed', + '.7z': 'application/x-7z-compressed', +} + + +def _get_mime_type(filename: str, ext_with_dot: str) -> str: + """获取文件的 MIME 类型。 + + :param filename: 原始文件名 + :param ext_with_dot: 带点号的扩展名 + :return: MIME 类型字符串 + """ + mime_type, _ = mimetypes.guess_type(filename) + if not mime_type: + mime_type = _MIME_MAP.get(ext_with_dot, 'application/octet-stream') + return mime_type + + +# ====================================================================== +# 辅助函数:文件名生成 +# ====================================================================== +def _generate_filename(ext_with_dot: str) -> str: + """生成唯一文件名:年月日时分秒 + 5 位随机数 + 扩展名。 + + :param ext_with_dot: 带点号的扩展名 + :return: 生成的文件名 + """ + timestamp = time.strftime('%Y%m%d%H%M%S') + random_num = '%05d' % random.randint(0, 99999) + return f"{timestamp}{random_num}{ext_with_dot}" + + +# ====================================================================== +# 上传文件(统一入口,支持图片和各类文件) +# ====================================================================== +async def upload_file(handler): + """上传文件(统一入口,支持图片和各类文件)。 + + 返回结构参考 FastAPI 版本,包含 originalName / fileExtension / + fileType / fileSize / fileName / filePath / fileUrl。 + + :param handler: Tornado 请求处理器 + :return: R 统一响应对象 + """ + try: + # 获取上传文件 + file_metas = handler.request.files.get('file', None) + if not file_metas: + return R.failed(handler, msg="请选择要上传的文件") + + meta = file_metas[0] + original_name = meta.get('filename', '') + body = meta.get('body') or b"" + + # 校验文件名安全性,防止路径遍历 + if not _is_safe_filename(original_name): + return R.failed(handler, msg="文件名不合法") + + file_ext_with_dot = os.path.splitext(original_name)[1].lower() + file_ext = file_ext_with_dot[1:] if file_ext_with_dot.startswith('.') else '' + + # 校验扩展名白名单 + if not file_ext_with_dot or file_ext_with_dot not in ALLOWED_EXTS: + return R.failed(handler, msg=f"文件格式不支持,仅支持 {', '.join(sorted(ALLOWED_EXTS))} 格式") + + # 校验文件大小 + if len(body) > MAX_FILE_SIZE: + return R.failed(handler, msg=f"文件大小超过限制,最大允许 {UPLOAD_MAX_SIZE_MB}MB") + + # 魔术字节校验:验证文件实际内容与扩展名一致,防止伪造扩展名上传 + is_valid, magic_msg = verify_file_magic(BytesIO(body), file_ext_with_dot) + if not is_valid: + return R.failed(handler, msg=magic_msg or "文件内容与扩展名不匹配") + + # 生成文件名和路径 + generated_name = _generate_filename(file_ext_with_dot) + date_dir = time.strftime('%Y%m%d') + save_dir = os.path.join(TEMP_PATH, date_dir) + mkdir(save_dir) + file_path = os.path.join(save_dir, generated_name) + + # 保存文件 + with open(file_path, 'wb') as f: + f.write(body) + + # 生成访问信息 + relative_path = file_path.replace(UPLOAD_DIR, "").replace("\\", "/") + file_url = TORNADO_FILE_URL + relative_path + mime_type = _get_mime_type(original_name, file_ext_with_dot) + + result = { + 'originalName': original_name, + 'fileExtension': file_ext, + 'fileType': mime_type, + 'fileSize': len(body), + 'fileName': generated_name, + 'filePath': relative_path, + 'fileUrl': file_url, + } + + return R.ok(handler, msg="上传成功", data=result) + + except Exception as e: + logger.error(f"文件上传异常: {str(e)}") + return R.failed(handler, msg=f"上传失败: {str(e)}") diff --git a/src/modules/user/__init__.py b/src/modules/user/__init__.py new file mode 100644 index 0000000..08f16b6 --- /dev/null +++ b/src/modules/user/__init__.py @@ -0,0 +1,5 @@ +# +====================================================================== +# | 模块: 用户管理包 +# | 说明: 声明 user 为包,用于组织用户模块的路由、处理器、服务与数据访问 +# +====================================================================== +"""用户管理模块。""" diff --git a/src/modules/user/handlers.py b/src/modules/user/handlers.py new file mode 100644 index 0000000..8f1556c --- /dev/null +++ b/src/modules/user/handlers.py @@ -0,0 +1,254 @@ +# +====================================================================== +# | 模块: 用户请求处理器层 +# | 说明: 承接用户管理与个人中心相关的 HTTP 请求,校验权限/演示模式并 +# | 记录操作/登录日志后,转交用户或认证服务处理 +# +====================================================================== +"""用户请求处理器(含个人中心/主页接口)。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:权限/演示模式装饰器、操作日志装饰器、统一响应对象、 +# 请求处理基类、当前用户上下文、通用异常、用户服务与仓储 +# ---------------------------------------------------------------------- +from common.middleware.access_decorators import check_demo, permission_required +from common.middleware.operation_log import operation_log +from core import response as R +from core.base_handler import BaseHandler +from core.context import current_user_id +from core.exceptions import NotFoundError +from modules.user.service import UserRepository, export_users, import_users, user_service + + +# ====================================================================== +# 用户管理 +# ====================================================================== + +# ============================================================ +# 查询用户分页数据 +# ============================================================ +class UserPageHandler(BaseHandler): + """查询用户分页数据。""" + + @permission_required("sys:user:list") + async def get(self): + """GET 请求:返回用户分页列表。""" + return await user_service.get_page(self) + + +# ============================================================ +# 查询用户详情 +# ============================================================ +class UserDetailHandler(BaseHandler): + """查询用户详情。""" + + @permission_required("sys:user:detail") + async def get(self, id): + """GET 请求:按 ID 返回用户详情,未找到抛 404。 + + :param id: 用户 ID(路径参数) + :raises NotFoundError: 用户不存在时抛出 + """ + data = user_service.get_detail(id) + # 记录不存在时抛统一异常,由 BaseHandler 转为标准响应 + if not data: + raise NotFoundError() + return R.ok(self, data=data) + + +# ============================================================ +# 添加用户 +# ============================================================ +class UserAddHandler(BaseHandler): + """添加用户。""" + + @permission_required("sys:user:add") + @operation_log("用户管理", "添加") + @check_demo + async def post(self): + """POST 请求:新增用户。""" + return await user_service.add(self) + + +# ============================================================ +# 更新用户 +# ============================================================ +class UserUpdateHandler(BaseHandler): + """更新用户。""" + + @permission_required("sys:user:update") + @operation_log("用户管理", "更新") + @check_demo + async def put(self): + """PUT 请求:更新用户信息。""" + return await user_service.update(self) + + +# ============================================================ +# 删除用户 +# ============================================================ +class UserDeleteHandler(BaseHandler): + """删除用户。""" + + @permission_required("sys:user:delete") + @operation_log("用户管理", "删除") + @check_demo + async def delete(self, id): + """DELETE 请求:按 ID 删除用户(软删除)。 + + :param id: 用户 ID(路径参数) + """ + return await user_service.delete(self, id) + + +# ============================================================ +# 设置用户状态 +# ============================================================ +class UserStatusHandler(BaseHandler): + """设置用户状态。""" + + @permission_required("sys:user:status") + @operation_log("用户管理", "设置状态") + @check_demo + async def put(self): + """PUT 请求:修改用户状态(如启用 / 禁用)。""" + return await user_service.update_status(self) + + +# ============================================================ +# 重置用户密码 +# ============================================================ +class UserResetPwdHandler(BaseHandler): + """重置用户密码。""" + + @permission_required("sys:user:resetPwd") + @operation_log("用户管理", "重置密码") + @check_demo + async def put(self): + """PUT 请求:重置指定用户的密码。""" + return await user_service.user_reset_pwd(self) + + +# ====================================================================== +# 个人中心 +# ====================================================================== + +# ============================================================ +# 获取当前用户菜单 +# ============================================================ +class IndexMenuHandler(BaseHandler): + """获取当前用户菜单。""" + + async def get(self): + """GET 请求:返回当前登录用户的菜单树。""" + # 局部导入,避免顶层循环依赖 + from modules.menu.service import get_permission_menu_list + menu_list = await get_permission_menu_list(current_user_id.get()) + return R.ok(self, data=menu_list) + + +# ============================================================ +# 获取个人信息 +# ============================================================ +class ProfileHandler(BaseHandler): + """获取个人信息。""" + + async def get(self): + """GET 请求:返回当前登录用户的基本信息与权限。""" + # 局部导入,避免顶层循环依赖 + from common.utils.rich_text import get_file_url + from common.permission import get_permissions_list + user_id = current_user_id.get() + repo = UserRepository() + try: + user = repo.get_by_id(user_id) + if not user: + return R.failed(self, "用户不存在") + # 组装个人资料(头像补全完整地址、附加角色与权限) + info = { + 'realname': user.realname, + 'avatar': get_file_url(user.avatar) if user.avatar else None, + 'gender': user.gender, + 'mobile': user.mobile, + 'email': user.email, + 'intro': user.intro, + 'address': user.address, + 'city': [ + user.province_code, + user.city_code, + user.district_code, + user.street_code, + ], + 'note': user.note, + 'roles': repo.get_role_ids(user_id), + 'permissions': get_permissions_list(user_id), + } + return R.ok(self, data=info) + finally: + repo.close() + + +# ============================================================ +# 修改个人信息 +# ============================================================ +class ProfileUpdateHandler(BaseHandler): + """修改个人信息。""" + + @operation_log("用户管理", "修改个人信息") + @check_demo + async def put(self): + """PUT 请求:更新当前登录用户的个人资料。""" + return await user_service.update_profile(self) + + +# ============================================================ +# 修改密码 +# ============================================================ +class PasswordUpdateHandler(BaseHandler): + """修改密码。""" + + @operation_log("用户管理", "修改密码") + @check_demo + async def put(self): + """PUT 请求:修改当前登录用户的密码。""" + return await user_service.update_password(self) + + +# ============================================================ +# 批量删除用户 +# ============================================================ +class UserBatchDeleteHandler(BaseHandler): + """批量删除用户。""" + + @permission_required("sys:user:delete") + @operation_log("用户管理", "批量删除") + @check_demo + async def post(self): + """POST 请求:批量删除用户(软删除)。""" + return await user_service.batch_delete(self) + + +# ============================================================ +# 导入 Excel +# ============================================================ +class UserImportHandler(BaseHandler): + """导入 Excel。""" + + @permission_required("sys:user:import") + @operation_log("用户管理", "导入") + @check_demo + async def post(self): + """POST 请求:通过 Excel 文件批量导入用户数据。""" + return import_users(self) + + +# ============================================================ +# 导出 Excel +# ============================================================ +class UserExportHandler(BaseHandler): + """导出 Excel。""" + + @permission_required("sys:user:export") + @check_demo + async def get(self): + """GET 请求:导出用户数据为 Excel 文件并返回下载 URL。""" + return export_users(self) diff --git a/src/modules/user/models.py b/src/modules/user/models.py new file mode 100644 index 0000000..e425f08 --- /dev/null +++ b/src/modules/user/models.py @@ -0,0 +1,76 @@ +# +====================================================================== +# | 模块: 用户数据模型层 +# | 说明: 定义用户表与用户角色关联表结构,继承 BaseModel 获得通用字段与序列化能力 +# +====================================================================== +"""用户数据模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:SQLAlchemy 列类型与函数、表名前缀配置、模型基类 +# ---------------------------------------------------------------------- +from sqlalchemy import Column, Integer, String, text + +from config.database import DB_PREFIX +from core.base_model import BaseModel + + +# ====================================================================== +# 用户模型 +# ====================================================================== +class User(BaseModel): + """用户模型。""" + + # ============================================================ + # 表定义 + # ============================================================ + __tablename__ = DB_PREFIX + "user" + __table_comment__ = "用户表" + + # ============================================================ + # 基础信息 + # ============================================================ + code = Column(String(100), nullable=False, default="", server_default=text("''"), index=True, comment="用户编码") + realname = Column(String(150), nullable=False, index=True, comment="用户姓名") + gender = Column(Integer, default=1, server_default=text('1'), comment="性别:1-男 2-女 3-保密") + avatar = Column(String(255), nullable=False, comment="用户头像") + mobile = Column(String(30), nullable=False, index=True, comment="手机号") + email = Column(String(30), nullable=False, index=True, comment="邮箱") + + # ============================================================ + # 组织与职级关联 + # ============================================================ + dept_id = Column(Integer, default=0, server_default=text('0'), index=True, comment="部门ID") + level_id = Column(Integer, default=0, server_default=text('0'), comment="职级ID") + position_id = Column(Integer, default=0, server_default=text('0'), comment="岗位ID") + + # ============================================================ + # 地址信息 + # ============================================================ + province_code = Column(String(30), nullable=False, comment="省份编码") + city_code = Column(String(30), nullable=False, comment="城市编码") + district_code = Column(String(30), nullable=False, comment="县区编码") + street_code = Column(String(30), nullable=False, comment="街道编码") + city_info = Column(String(255), nullable=True, comment="省市区信息") + address = Column(String(255), nullable=False, comment="详细地址") + + # ============================================================ + # 账号与认证 + # ============================================================ + username = Column(String(30), nullable=True, index=True, comment="用户名") + password = Column(String(255), nullable=True, comment="密码") + salt = Column(String(30), nullable=True, comment="加密盐") + + # ============================================================ + # 其他信息 + # ============================================================ + intro = Column(String(255), nullable=True, comment="个人简介") + status = Column(Integer, default=1, server_default=text('1'), index=True, comment="状态:1-正常 2-禁用") + sort = Column(Integer, default=0, server_default=text('0'), comment="排序") + note = Column(String(255), nullable=True, comment="个人备注") + + # ============================================================ + # 调试输出 + # ============================================================ + def __str__(self): + """调试用字符串表示:返回用户标识。""" + return f"用户{self.id}" diff --git a/src/modules/user/repository.py b/src/modules/user/repository.py new file mode 100644 index 0000000..e19bd52 --- /dev/null +++ b/src/modules/user/repository.py @@ -0,0 +1,52 @@ +# +====================================================================== +# | 模块: 用户数据访问层 +# | 说明: 定义用户仓储,继承 BaseRepository 获得通用能力, +# | 并扩展按用户名查询、角色查询 +# +====================================================================== +"""用户数据访问。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:类型标注、通用仓储基类、用户模型、用户角色模型 +# ---------------------------------------------------------------------- +from typing import List, Optional + +from core.base_repository import BaseRepository +from modules.user.models import User +from modules.user_role.models import UserRole + + +# ====================================================================== +# 用户仓储 +# ====================================================================== +class UserRepository(BaseRepository): + """用户仓储。""" + + # ============================================================ + # 绑定的模型类 + # ============================================================ + model = User + + # ============================================================ + # 按用户名查询用户 + # ============================================================ + def get_by_username(self, username: str) -> Optional[User]: + """按用户名查询用户(自动过滤软删)。 + + :param username: 用户名 + :return: 匹配的用户,未找到返回 None + """ + return self.get_one(username=username) + + # ============================================================ + # 查询用户的有效角色ID列表 + # ============================================================ + def get_role_ids(self, user_id: int) -> List[int]: + """查询用户的有效角色ID列表(过滤软删关联)。 + + :param user_id: 用户 ID + :return: 该用户的有效角色 ID 列表 + """ + records = self.db.query(UserRole).filter( + UserRole.user_id == user_id, UserRole.is_delete == 0).all() + return [r.role_id for r in records] diff --git a/src/modules/user/schemas.py b/src/modules/user/schemas.py new file mode 100644 index 0000000..05c00ed --- /dev/null +++ b/src/modules/user/schemas.py @@ -0,0 +1,113 @@ +# +====================================================================== +# | 模块: 用户请求校验模型层 +# | 说明: 定义用户创建/编辑、状态更新、个人信息修改、密码修改/重置等 +# | Pydantic 校验模型 +# +====================================================================== +"""用户请求校验模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:类型标注、Pydantic 基类与字段定义、Schema 基类 +# ---------------------------------------------------------------------- +from typing import List, Optional + +from pydantic import BaseModel, Field + +from core.base_schema import BaseSchema + + +# ====================================================================== +# 用户创建/编辑表单 +# ====================================================================== +class UserSchema(BaseSchema): + """用户创建/编辑表单。""" + + # ============================================================ + # 表单字段 + # ============================================================ + realname: str = Field(..., min_length=1, max_length=150, description="用户姓名") + gender: int = Field(..., ge=1, le=3, description="性别:1-男 2-女 3-保密") + avatar: str = Field(..., min_length=1, max_length=255, description="头像") + mobile: str = Field(..., min_length=1, max_length=30, description="手机号") + email: str = Field(..., min_length=1, max_length=30, description="邮箱") + deptId: int = Field(..., ge=0, description="部门ID") + levelId: int = Field(..., ge=0, description="职级ID") + positionId: int = Field(..., ge=0, description="岗位ID") + city: Optional[List[int]] = Field(None, description="行政区划") + address: str = Field(..., min_length=1, max_length=255, description="详细地址") + username: str = Field(..., min_length=1, max_length=30, description="登录账号") + password: Optional[str] = Field(None, max_length=128, description="登录密码") + intro: Optional[str] = Field(None, max_length=255, description="个人简介") + status: int = Field(..., ge=1, le=2, description="用户状态:1-正常 2-禁用") + note: Optional[str] = Field(None, max_length=255, description="备注") + sort: int = Field(..., ge=0, le=99999, description="用户排序") + roles: Optional[List[int]] = Field(None, description="用户角色") + + +# ====================================================================== +# 用户更新表单(ID 必填) +# ====================================================================== +class UserUpdateSchema(UserSchema): + """用户更新表单(ID 必填)。""" + + # ============================================================ + # 表单字段 + # ============================================================ + id: int = Field(..., gt=0, description="用户ID") + + +# ====================================================================== +# 用户状态更新表单 +# ====================================================================== +class UserStatusSchema(BaseModel): + """用户状态更新表单。""" + + # ============================================================ + # 表单字段 + # ============================================================ + id: int = Field(..., gt=0, description="用户ID") + status: int = Field(..., ge=1, le=2, description="用户状态:1-正常 2-禁用") + + +# ====================================================================== +# 修改个人信息表单 +# ====================================================================== +class UserInfoSchema(BaseModel): + """修改个人信息表单。""" + + # ============================================================ + # 表单字段 + # ============================================================ + realname: str = Field(..., min_length=1, max_length=150, description="用户姓名") + gender: int = Field(..., ge=1, le=3, description="性别:1-男 2-女 3-保密") + mobile: str = Field(..., min_length=1, max_length=30, description="手机号") + email: str = Field(..., min_length=1, max_length=30, description="邮箱") + address: str = Field(..., min_length=1, max_length=255, description="详细地址") + intro: Optional[str] = Field(None, max_length=255, description="个人简介") + city: Optional[List[int]] = Field(None, description="行政区划") + + +# ====================================================================== +# 修改密码表单 +# ====================================================================== +class UpdatePwdSchema(BaseModel): + """修改密码表单。""" + + # ============================================================ + # 表单字段 + # ============================================================ + oldPassword: str = Field(..., min_length=6, max_length=128, description="原始密码") + newPassword: str = Field(..., min_length=6, max_length=128, description="新密码") + confirmPassword: str = Field(..., min_length=6, max_length=128, description="确认密码") + + +# ====================================================================== +# 重置密码表单 +# ====================================================================== +class ResetPwdSchema(BaseModel): + """重置密码表单。""" + + # ============================================================ + # 表单字段 + # ============================================================ + userId: int = Field(..., gt=0, description="用户ID") diff --git a/src/modules/user/service.py b/src/modules/user/service.py new file mode 100644 index 0000000..8488ab0 --- /dev/null +++ b/src/modules/user/service.py @@ -0,0 +1,942 @@ +# +====================================================================== +# | 模块: 用户业务逻辑层 +# | 说明: 定义用户服务,覆写分页/详情/增删改/状态等方法以处理头像、密码盐、 +# | 行政区划与角色关联;另提供个人中心与密码修改等定制方法 +# +====================================================================== +"""用户业务逻辑。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:日志、文件路径/随机数/时间、SQLAlchemy 条件表达式、 +# 密码/字典/ID 解析/富文本/文件工具、 +# 统一响应对象、业务服务基类、当前用户上下文、数据库会话、 +# 应用配置(临时目录/上传目录/文件访问前缀)、 +# 部门/职级/岗位/角色/城市/用户模型与仓储、用户校验模型、权限缓存工具 +# ---------------------------------------------------------------------- +import logging +import os +import random +import time + +from openpyxl.reader.excel import load_workbook +from openpyxl.workbook import Workbook +from sqlalchemy import and_ + +from common.utils.crypto import set_password, verify_user_password +from common.utils.dict_util import get_dict_label, get_dict_map +from common.utils.file import mkdir +from common.utils.helpers import parse_id_list +from common.utils.rich_text import get_file_url, save_file +from config.app import TEMP_PATH, TORNADO_FILE_URL, UPLOAD_DIR +from constants import DEFAULT_PASSWORD +from core import response as R +from core.base_service import BaseService +from core.context import current_user_id +from core.database import db +from modules.city.models import City +from modules.dept.models import Dept +from modules.level.models import Level +from common.permission import invalidate_permission_cache +from modules.position.models import Position +from modules.role.models import Role +from modules.user.models import User +from modules.user.repository import UserRepository +from modules.user_role.models import UserRole +from modules.user_role.repository import UserRoleRepository +from modules.user.schemas import (ResetPwdSchema, UpdatePwdSchema, UserInfoSchema, + UserSchema, UserStatusSchema, UserUpdateSchema) + +logger = logging.getLogger(__name__) + + +# ====================================================================== +# 当前请求用户 ID +# ====================================================================== +def _uid() -> int: + """当前请求用户 ID。 + + :return: 当前请求上下文中的用户 ID + """ + return current_user_id.get() + + +# ====================================================================== +# 根据行政编码拼接省市区信息 +# ====================================================================== +def _build_city_info(province_code, city_code, district_code, street_code): + """根据省/市/区/街道行政编码查询城市名称,按 level 升序逗号拼接。 + + :param province_code: 省份编码 + :param city_code: 城市编码 + :param district_code: 县区编码 + :param street_code: 街道编码 + :return: 省市区名称字符串(如"广东省,深圳市,南山区") + """ + codes = [c for c in [province_code, city_code, district_code, street_code] if c] + if not codes: + return '' + cities = db.query(City).filter(City.area_code.in_(codes)).order_by(City.level.asc()).all() + return ','.join(c.name for c in cities) + + +# ====================================================================== +# 用户行加工(列表) +# ====================================================================== +def _user_serialize_extra(item): + """用户行加工(列表):性别/头像/部门/职级/岗位/角色/城市。 + + :param item: 用户模型实例 + :return: 需合并进序列化字典的额外字段 + """ + extra = { + 'genderName': get_dict_label("gender", item.gender), + 'avatar': get_file_url(item.avatar) if item.avatar else "", + 'city': [item.province_code, item.city_code, item.district_code, item.street_code], + } + # 关联部门/职级/岗位名称 + dept = db.query(Dept).filter(and_(Dept.id == item.dept_id, Dept.is_delete == 0)).first() + if dept: + extra['deptName'] = dept.name + level = db.query(Level).filter(and_(Level.id == item.level_id, Level.is_delete == 0)).first() + if level: + extra['levelName'] = level.name + pos = db.query(Position).filter(and_(Position.id == item.position_id, Position.is_delete == 0)).first() + if pos: + extra['positionName'] = pos.name + extra['roles'] = _get_user_role_list(item.id) + return extra + + +# ====================================================================== +# 查询用户角色列表 +# ====================================================================== +def _get_user_role_list(user_id): + """同步查询用户角色列表({id, name},过滤软删关联)。 + + :param user_id: 用户 ID + :return: [{id, name}] 角色列表 + """ + records = ( + db.query(Role) + .join(UserRole, UserRole.role_id == Role.id) + .filter(UserRole.user_id == user_id, UserRole.is_delete == 0, Role.is_delete == 0) + .all() + ) + return [{'id': v.id, 'name': v.name} for v in records] + + +# ====================================================================== +# 按 ID 批量查询映射 +# ====================================================================== +def _batch_map_by_ids(model, ids): + """批量查询 model(过滤软删),返回 {id: obj}。 + + :param model: 模型类 + :param ids: ID 集合 + :return: {id: 模型实例} 字典 + """ + if not ids: + return {} + rows = db.query(model).filter(model.id.in_(list(ids)), model.is_delete == 0).all() + return {r.id: r for r in rows} + + +# ====================================================================== +# 批量查询用户角色映射 +# ====================================================================== +def _batch_user_roles(user_ids): + """批量查询用户角色映射,返回 {user_id: [{id, name}]}(过滤软删关联)。 + + :param user_ids: 用户 ID 列表 + :return: {user_id: [{id, name}]} 字典 + """ + if not user_ids: + return {} + rows = ( + db.query(Role, UserRole) + .join(UserRole, UserRole.role_id == Role.id) + .filter(UserRole.user_id.in_(user_ids), UserRole.is_delete == 0, Role.is_delete == 0) + .all() + ) + result = {} + for role, ur in rows: + result.setdefault(ur.user_id, []).append({'id': role.id, 'name': role.name}) + return result + + +# ====================================================================== +# 用户服务 +# ====================================================================== +class UserService(BaseService): + """用户服务。""" + + # ============================================================ + # 基础声明 + # ============================================================ + model = User + repo_cls = UserRepository + + # ============================================================ + # 差异点声明 + # ============================================================ + create_schema = UserSchema + update_schema = UserUpdateSchema + status_schema = UserStatusSchema + # 分页查询字段与排序 + page_like_fields = ("realname",) + page_eq_fields = ("gender", "status") + page_order_by = (("id", "asc"),) + # 唯一性校验与序列化钩子 + unique_fields = {"username": "登录账号不能重复"} + serialize_extra = _user_serialize_extra + + # ============================================================ + # 详情序列化(覆写) + # ============================================================ + def _serialize_detail(self, obj): + """详情:批量预取关联数据,避免 N+1 查询;列表返回 roleList,详情返回 roles(角色ID列表)。 + + :param obj: 用户模型实例 + :return: 序列化后的详情字典 + """ + dept_map = _batch_map_by_ids(Dept, {obj.dept_id} if obj.dept_id else set()) + level_map = _batch_map_by_ids(Level, {obj.level_id} if obj.level_id else set()) + position_map = _batch_map_by_ids(Position, {obj.position_id} if obj.position_id else set()) + gender_map = get_dict_map('gender') + data = obj.to_dict() + data.update({ + 'genderName': gender_map.get(str(obj.gender)), + 'avatar': get_file_url(obj.avatar) if obj.avatar else "", + 'city': [obj.province_code, obj.city_code, obj.district_code, obj.street_code], + 'deptName': dept_map.get(obj.dept_id).name if dept_map.get(obj.dept_id) else None, + 'levelName': level_map.get(obj.level_id).name if level_map.get(obj.level_id) else None, + 'positionName': position_map.get(obj.position_id).name if position_map.get(obj.position_id) else None, + 'roles': UserRepository().get_role_ids(obj.id), + }) + return data + + # ============================================================ + # 分页查询(覆写) + # ============================================================ + async def get_page(self, handler): + """分页查询:批量预取部门/职级/岗位/角色,避免逐行 N+1 查询。 + + :param handler: Tornado 请求处理器 + :return: 统一响应对象 + """ + try: + pageNo, pageSize = self._parse_pagination(handler) + query = self._build_page_query(handler) + items, total = self.repo.paginate(query, pageNo, pageSize) + # 超页自动回退第 1 页 + if pageNo > 1 and not items: + items, total = self.repo.paginate(query, 1, pageSize) + return R.page(handler, data=self._serialize_page(items), total=total, current=pageNo, size=pageSize) + finally: + self.repo.close() + + # ============================================================ + # 批量序列化列表 + # ============================================================ + def _serialize_page(self, items): + """批量序列化用户列表:单次查询预取部门/职级/岗位/角色映射。 + + :param items: 用户模型实例列表 + :return: 序列化后的字典列表 + """ + if not items: + return [] + # 批量预取关联映射,避免 N+1 + dept_map = _batch_map_by_ids(Dept, {i.dept_id for i in items if i.dept_id}) + level_map = _batch_map_by_ids(Level, {i.level_id for i in items if i.level_id}) + pos_map = _batch_map_by_ids(Position, {i.position_id for i in items if i.position_id}) + roles_map = _batch_user_roles([i.id for i in items]) + result = [] + for item in items: + data = item.to_dict() + data.update({ + 'genderName': get_dict_label("gender", item.gender), + 'avatar': get_file_url(item.avatar) if item.avatar else "", + 'city': [item.province_code, item.city_code, item.district_code, item.street_code], + 'deptName': dept_map.get(item.dept_id).name if dept_map.get(item.dept_id) else None, + 'levelName': level_map.get(item.level_id).name if level_map.get(item.level_id) else None, + 'positionName': pos_map.get(item.position_id).name if pos_map.get(item.position_id) else None, + 'roles': roles_map.get(item.id, []), + }) + result.append(data) + return result + + # ============================================================ + # 新增用户(覆写) + # ============================================================ + async def add(self, handler): + """添加用户:头像/密码盐/行政区划/角色关联。 + + :param handler: Tornado 请求处理器 + :return: 统一响应对象 + """ + try: + form = self._validate(handler, self.create_schema) + data = form.model_dump() + # 取出角色列表,不写入主表 + roles = data.pop('roles', None) + err = self._check_unique(form) + if err: + return R.failed(handler, err) + # 新增用户必须设置初始密码,否则将创建无法登录的账号 + if not data.get('password'): + return R.failed(handler, "登录密码不能为空") + self._before_add(handler, data) + fields = self._build_create_fields(data) + user = self.repo.create(self.model(**fields)) + # 根据自增 ID 生成 6 位用户编码 + user.code = f"{user.id:06d}" + self.repo.update(user.id, {"code": user.code}) + # 重建角色关联 + _add_user_role(user.id, roles or []) + return R.ok(handler, msg="添加成功") + finally: + self.repo.close() + + # ============================================================ + # 更新用户(覆写) + # ============================================================ + async def update(self, handler): + """更新用户:头像/密码盐/行政区划/角色关联。 + + 超级管理员(id=1)保护:编辑接口不允许禁用或改密码 + (delete/update_status/user_reset_pwd 已拦截,此处补齐 update 路径)。 + + :param handler: Tornado 请求处理器 + :return: 统一响应对象 + """ + try: + form = self._validate(handler, self.update_schema) + data = form.model_dump() + # 校验 ID 合法 + user_id = int(data.pop('id', 0) or 0) + if user_id <= 0: + return R.failed(handler, "记录ID不能为空") + if not self.repo.get_by_id(user_id): + return R.failed(handler, "记录不存在") + # 超级管理员保护:不允许禁用或改密码 + if user_id == 1: + if data.get('status', 1) != 1: + return R.failed(handler, "超级管理员不能被禁用") + if data.get('password'): + return R.failed(handler, "超级管理员密码不能通过编辑接口修改") + err = self._check_unique(form, exclude_id=user_id) + if err: + return R.failed(handler, err) + # 取出角色列表 + roles = data.pop('roles', None) + self._before_update(handler, data) + fields = self._build_update_fields(data) + if not self.repo.update(user_id, fields): + return R.failed(handler, "更新失败") + # 仅当请求显式携带 roles 时才重建角色关联,避免未传 roles 的编辑静默清空用户角色 + if roles is not None: + _add_user_role(user_id, roles) + return R.ok(handler, msg="更新成功") + finally: + self.repo.close() + + # ============================================================ + # 状态切换(覆写) + # ============================================================ + async def update_status(self, handler): + """设置用户状态:超级管理员(id=1)不可禁用;状态变更后即时失效权限缓存。 + + :param handler: Tornado 请求处理器 + :return: 统一响应对象 + """ + try: + form = self._validate(handler, self.status_schema) + # 超级管理员不可禁用 + if form.id == 1 and int(form.status) != 1: + return R.failed(handler, "超级管理员不能被禁用") + if not self.repo.exists(form.id): + return R.failed(handler, "记录不存在") + if not self.repo.update(form.id, {"status": int(form.status)}): + return R.failed(handler, "设置失败") + # 权限查询已过滤 User.status==1,状态变更后需即时刷新权限缓存 + invalidate_permission_cache() + return R.ok(handler, msg="设置成功") + finally: + self.repo.close() + + # ============================================================ + # 新增前钩子(头像/密码/行政区划) + # ============================================================ + def _before_add(self, handler, data): + """新增前:处理头像、密码盐与行政区划。 + + :param handler: Tornado 请求处理器 + :param data: 待入库的表单数据字典 + """ + avatar = data.pop('avatar', None) + data['avatar'] = save_file(avatar, "user") if avatar else "" + self._handle_password_and_city(data) + + # ============================================================ + # 编辑前钩子(头像/密码/行政区划) + # ============================================================ + def _before_update(self, handler, data): + """编辑前:处理头像、密码盐与行政区划(区分未传与显式空值)。 + + :param handler: Tornado 请求处理器 + :param data: 待入库的表单数据字典 + """ + # 区分"未传=保持原值"与"显式空值=清空头像" + raw = BaseService.parse_json(handler) + explicit = set(raw.keys()) if isinstance(raw, dict) else set() + avatar = data.pop('avatar', None) + if 'avatar' in explicit: + data['avatar'] = save_file(avatar, "user") if avatar else "" + elif avatar: + data['avatar'] = save_file(avatar, "user") + self._handle_password_and_city(data) + + # ============================================================ + # 密码与行政区划处理 + # ============================================================ + @staticmethod + def _handle_password_and_city(data): + """处理密码盐化与行政区划编码。 + + :param data: 待入库的表单数据字典(原地修改) + """ + # 密码:设置时生成盐;未设置则不写入(保持原值) + password = data.pop('password', None) + if password: + salt, hashed = set_password(password) + data['salt'] = salt + data['password'] = hashed + else: + data.pop('password', None) + data.pop('salt', None) + # 行政区划:未传或长度不对时编码列写空串,避免违反 nullable=False 触发 500 + city = data.pop('city', None) + if city and len(city) >= 3: + data['province_code'] = city[0] or '' + data['city_code'] = city[1] or '' + data['district_code'] = city[2] or '' + data['street_code'] = city[3] if len(city) > 3 else '' + else: + data['province_code'] = data.get('province_code', '') or '' + data['city_code'] = data.get('city_code', '') or '' + data['district_code'] = data.get('district_code', '') or '' + data['street_code'] = data.get('street_code', '') or '' + # 根据行政编码自动拼接省市区信息 + data['city_info'] = _build_city_info( + data['province_code'], data['city_code'], + data['district_code'], data['street_code'] + ) + + # ============================================================ + # 删除用户(覆写) + # ============================================================ + async def delete(self, handler, ids) -> R: + """删除用户:单事务软删用户与其角色关联,避免主体与关联跨事务不一致。 + + 超级管理员(id=1)不可删除,防止系统无人可管理。 + + :param handler: Tornado 请求处理器 + :param ids: 逗号分隔的 ID 字符串 + :return: 统一响应对象 + """ + try: + id_list = list(dict.fromkeys(parse_id_list(str(ids)))) + if not id_list: + return R.failed(handler, "记录ID不存在") + # 超级管理员不可删除 + if 1 in id_list: + return R.failed(handler, "超级管理员不能被删除") + if len(self.repo.get_by_ids(id_list)) != len(id_list): + return R.failed(handler, "记录不存在") + try: + # 软删用户角色关联 + db.query(UserRole).filter( + and_(UserRole.user_id.in_(id_list), UserRole.is_delete == 0)).update( + {UserRole.is_delete: 1}, synchronize_session=False) + # 软删用户本身 + db.query(User).filter( + and_(User.id.in_(id_list), User.is_delete == 0)).update( + {User.is_delete: 1}, synchronize_session=False) + db.commit() + # 失效权限缓存 + invalidate_permission_cache() + except Exception: + db.rollback() + raise + return R.ok(handler, msg="本次共删除{0}条数据".format(len(id_list))) + finally: + db.close() + + # ============================================================ + # 个人中心 / 密码(定制方法) + # ============================================================ + + # ============================================================ + # 修改个人信息 + # ============================================================ + async def update_profile(self, handler): + """修改个人信息。 + + :param handler: Tornado 请求处理器 + :return: 统一响应对象 + """ + form = self._validate(handler, UserInfoSchema) + user = self.repo.get_by_id(_uid()) + if not user: + return R.failed(handler, "记录不存在") + data = form.model_dump() + # 行政区划:city 数组拆分为省/市/区/街道编码 + city = data.pop('city', None) + if city and len(city) >= 3: + data['province_code'] = city[0] or '' + data['city_code'] = city[1] or '' + data['district_code'] = city[2] or '' + data['street_code'] = city[3] if len(city) > 3 else '' + data['city_info'] = _build_city_info( + data['province_code'], data['city_code'], + data['district_code'], data['street_code'] + ) + if not self.repo.update(user.id, data): + return R.failed(handler, "更新失败") + return R.ok(handler, msg="更新成功") + + # ============================================================ + # 修改密码 + # ============================================================ + async def update_password(self, handler): + """修改密码。 + + :param handler: Tornado 请求处理器 + :return: 统一响应对象 + """ + form = self._validate(handler, UpdatePwdSchema) + # 两次新密码一致性校验 + if form.newPassword != form.confirmPassword: + return R.failed(handler, "两次输入密码不一致") + user = self.repo.get_by_id(_uid()) + if not user: + return R.failed(handler, "用户不存在") + # 校验原始密码 + if not verify_user_password(form.oldPassword, user.salt, user.password): + return R.failed(handler, "原始密码不正确") + salt, hashed = set_password(form.newPassword) + if not self.repo.update(user.id, {"salt": salt, "password": hashed}): + return R.failed(handler, "更新失败") + return R.ok(handler, msg="更新成功") + + # ============================================================ + # 重置密码 + # ============================================================ + async def user_reset_pwd(self, handler): + """重置密码为 123456。 + + :param handler: Tornado 请求处理器 + :return: 统一响应对象 + """ + form = self._validate(handler, ResetPwdSchema) + # 超级管理员不可通过此接口重置 + if form.id == 1: + return R.failed(handler, "超级管理员密码不能通过此接口重置") + user = self.repo.get_by_id(form.id) + if not user: + return R.failed(handler, "用户不存在") + salt, hashed = set_password("123456") + if not self.repo.update(user.id, {"salt": salt, "password": hashed}): + return R.failed(handler, "重置密码失败") + return R.ok(handler, msg="重置密码成功") + + +# ====================================================================== +# Excel 导入 / 导出 辅助函数 +# ====================================================================== +def _clean_text(value): + """清洗 Excel 单元格值:仅去除头尾 Unicode 空白字符(全角空格、零宽等),保留中间空格。""" + import unicodedata + text = str(value) + while text and unicodedata.category(text[0]).startswith('Z'): + text = text[1:] + while text and unicodedata.category(text[-1]).startswith('Z'): + text = text[:-1] + return text + + +def _parse_gender(value): + """性别解析:'男'/'女'/'保密' -> 1/2/3(兼容数字)。""" + text = _clean_text(value) + if text in ("男", "1"): + return 1 + if text in ("女", "2"): + return 2 + if text in ("保密", "3"): + return 3 + raise ValueError("性别必须为'男'/'女'/'保密'") + + +def _parse_dept(value): + """部门名称 -> 部门 ID(按名称匹配,不存在则行级报错)。""" + text = _clean_text(value) + if not text: + return 0 + obj = db.query(Dept).filter(Dept.name == text, Dept.is_delete == 0).first() + if not obj: + raise ValueError(f"部门'{text}'不存在") + return obj.id + + +def _parse_level(value): + """职级名称 -> 职级 ID(按名称匹配,不存在则行级报错)。""" + text = _clean_text(value) + if not text: + return 0 + obj = db.query(Level).filter(Level.name == text, Level.is_delete == 0).first() + if not obj: + raise ValueError(f"职级'{text}'不存在") + return obj.id + + +def _parse_position(value): + """岗位名称 -> 岗位 ID(按名称匹配,不存在则行级报错)。""" + text = _clean_text(value) + if not text: + return 0 + obj = db.query(Position).filter(Position.name == text, Position.is_delete == 0).first() + if not obj: + raise ValueError(f"岗位'{text}'不存在") + return obj.id + + +def _parse_roles(value): + """角色名称(逗号分隔)-> 角色 ID 列表(按名称匹配,不存在则行级报错)。""" + if value is None: + return [] + text = _clean_text(value) + if not text: + return [] + names = [n.strip() for n in text.replace(",", ",").split(",") if n.strip()] + role_ids = [] + for name in names: + role = db.query(Role).filter(Role.name == name, Role.is_delete == 0).first() + if not role: + raise ValueError(f"角色'{name}'不存在") + role_ids.append(role.id) + return role_ids + + +def _parse_status(value): + """状态解析:'正常'/'禁用' -> 1/2(兼容数字)。""" + text = _clean_text(value) + if text in ("正常", "1"): + return 1 + if text in ("禁用", "2"): + return 2 + raise ValueError("状态必须为'正常'/'禁用'") + + +def _parse_city_info(value): + """城市信息(逗号/顿号分隔的城市名称)-> (province_code, city_code, district_code, street_code)。""" + text = _clean_text(value) + if not text: + return ("", "", "", "") + names = [n.strip() for n in text.replace(",", ",").replace("、", ",").split(",") if n.strip()] + if not names: + return ("", "", "", "") + codes = ["", "", "", ""] + for name in names: + city = db.query(City).filter(City.name == name).first() + if not city: + raise ValueError(f"城市'{name}'不存在") + level = city.level # 0=省 1=市 2=区 3=街道 + if 0 <= level <= 3: + codes[level] = city.area_code + return tuple(codes) + + +def _format_roles(value): + """导出角色列表展示:角色字典列表 -> 名称顿号拼接。""" + if not value: + return "" + return "、".join(role["name"] for role in value if role.get("name")) + + +# 导入列定义(顺序即 Excel 列序,从 A 列开始) +USER_IMPORT_COLUMNS = [ + {"title": "登录账号", "key": "username", "required": True}, + {"title": "用户姓名", "key": "realname", "required": True}, + {"title": "性别", "key": "gender", "parser": _parse_gender}, + {"title": "手机号", "key": "mobile", "required": True}, + {"title": "邮箱", "key": "email", "required": True}, + {"title": "部门", "key": "dept_id", "parser": _parse_dept}, + {"title": "职级", "key": "level_id", "parser": _parse_level}, + {"title": "岗位", "key": "position_id", "parser": _parse_position}, + {"title": "用户角色", "key": "roles", "parser": _parse_roles}, + {"title": "状态", "key": "status", "parser": _parse_status}, + {"title": "城市信息", "key": "city_codes", "parser": _parse_city_info}, + {"title": "详细地址", "key": "address", "required": True}, + {"title": "个人简介", "key": "intro"}, + {"title": "备注", "key": "note"}, +] + +# 导出列定义 +USER_EXPORT_COLUMNS = [ + {"title": "序号", "key": "_row_number"}, + {"title": "用户编码", "key": "code"}, + {"title": "登录账号", "key": "username"}, + {"title": "用户姓名", "key": "realname"}, + {"title": "性别", "key": "gender_name"}, + {"title": "手机号", "key": "mobile"}, + {"title": "邮箱", "key": "email"}, + {"title": "部门", "key": "dept_name"}, + {"title": "职级", "key": "level_name"}, + {"title": "岗位", "key": "position_name"}, + {"title": "用户角色", "key": "roleList", "formatter": _format_roles}, + {"title": "状态", "key": "status", "formatter": lambda v: "正常" if v == 1 else "禁用"}, + {"title": "城市信息", "key": "city_info"}, + {"title": "详细地址", "key": "address"}, + {"title": "个人简介", "key": "intro"}, + {"title": "备注", "key": "note"}, + {"title": "创建时间", "key": "create_time"}, +] + + +def import_users(handler): + """导入 Excel 文件批量创建用户。 + + :param handler: Tornado 请求处理器 + :return: R 统一响应对象 + """ + # 获取上传文件 + file_metas = handler.request.files.get('file', None) + if not file_metas: + return R.failed(handler, "请选择要上传的文件") + meta = file_metas[0] + original_name = meta.get('filename', '') + body = meta.get('body') or b"" + file_ext = os.path.splitext(original_name)[1].lower() + if file_ext not in ('.xls', '.xlsx'): + return R.failed(handler, "仅支持 .xls / .xlsx 格式的 Excel 文件") + + # 保存到临时目录 + date_dir = time.strftime('%Y%m%d') + save_dir = os.path.join(TEMP_PATH, date_dir) + mkdir(save_dir) + file_name = time.strftime('%Y%m%d%H%M%S') + '%05d' % random.randint(0, 99999) + file_ext + abs_path = os.path.join(save_dir, file_name) + with open(abs_path, 'wb') as f: + f.write(body) + + errors = [] + count = 0 + try: + wb = load_workbook(abs_path) + ws = wb.worksheets[0] + max_row = ws.max_row + # 第 1 行为表头,从第 2 行开始遍历 + for row_idx in range(2, max_row + 1): + try: + row_data = {} + # 按列定义解析每个单元格 + for col_idx, col in enumerate(USER_IMPORT_COLUMNS, start=1): + cell_value = ws.cell(row_idx, col_idx).value + # 必填校验 + if col.get("required") and (cell_value is None or str(cell_value).strip() == ""): + raise ValueError(f"{col['title']}不能为空") + # 有解析器则转换,否则直接取值 + if "parser" in col and cell_value is not None: + row_data[col["key"]] = col["parser"](cell_value) + elif cell_value is not None: + row_data[col["key"]] = str(cell_value).strip() if isinstance(cell_value, str) else cell_value + # 唯一性校验 + username = row_data.get("username") + if username and UserRepository().get_one(username=username): + raise ValueError(f"登录账号'{username}'已存在") + # 密码加密 + salt, encrypted_pwd = set_password(row_data.get("password") or DEFAULT_PASSWORD) + # 行政区划解析 + city_codes = row_data.get("city_codes") or ("", "", "", "") + province_code, city_code, district_code, street_code = city_codes + city_info = _build_city_info(province_code or "", city_code or "", + district_code or "", street_code or "") + # 创建用户 + user = User( + username=username, + realname=row_data["realname"], + gender=row_data.get("gender") or 1, + avatar="", + mobile=row_data["mobile"], + email=row_data["email"], + dept_id=row_data.get("dept_id") or 0, + level_id=row_data.get("level_id") or 0, + position_id=row_data.get("position_id") or 0, + province_code=province_code or "", + city_code=city_code or "", + district_code=district_code or "", + street_code=street_code or "", + city_info=city_info, + address=row_data["address"], + password=encrypted_pwd, + salt=salt, + intro=row_data.get("intro"), + status=row_data.get("status") or 1, + sort=row_data.get("sort") or 0, + note=row_data.get("note"), + create_user=username, + update_user=username, + ) + db.add(user) + db.flush() + # 根据自增 ID 生成 6 位用户编码 + user.code = f"{user.id:06d}" + # 关联角色 + role_ids = row_data.get("roles") + if role_ids: + db.query(UserRole).filter(UserRole.user_id == user.id).delete() + new_roles = [UserRole(user_id=user.id, role_id=rid) for rid in role_ids] + db.add_all(new_roles) + count += 1 + except ValueError as e: + errors.append(f"第{row_idx}行:{str(e)}") + except Exception as e: + logger.error(f"导入用户第{row_idx}行异常: {str(e)}") + errors.append(f"第{row_idx}行:导入异常") + db.commit() + wb.close() + except Exception: + db.rollback() + raise + finally: + db.close() + if os.path.exists(abs_path): + os.remove(abs_path) + + # 返回结果 + if errors: + detail = ";".join(errors) + if len(detail) > 200: + detail = detail[:200] + "…" + return R.ok(handler, data={"success": count, "fail": len(errors), "errors": errors}, + msg="导入成功 {} 条,失败 {} 条:{}".format(count, len(errors), detail)) + return R.ok(handler, msg="本次共导入【{}】条数据".format(count)) + + +def export_users(handler): + """导出用户数据到 Excel 文件。 + + :param handler: Tornado 请求处理器 + :return: R 统一响应对象(包含文件访问 URL) + """ + try: + # 构建查询(与 get_page 一致的筛选条件) + query = db.query(User).filter(User.is_delete == 0) + username = handler.get_query_argument("username", "") + if username: + query = query.filter(User.username.like(f"%{username}%")) + gender = handler.get_query_argument("gender", "") + if gender: + query = query.filter(User.gender == int(gender)) + status = handler.get_query_argument("status", "") + if status: + query = query.filter(User.status == int(status)) + query = query.order_by(User.id.asc()) + + # 导出数量限制 + EXPORT_MAX_ROWS = 10000 + user_list = query.limit(EXPORT_MAX_ROWS).all() + if not user_list: + return R.failed(handler, "暂无数据可导出") + + # 批量加载关联数据 + dept_map = {d.id: d.name for d in db.query(Dept).filter(Dept.is_delete == 0).all()} + level_map = {lv.id: lv.name for lv in db.query(Level).filter(Level.is_delete == 0).all()} + position_map = {p.id: p.name for p in db.query(Position).filter(Position.is_delete == 0).all()} + gender_map = get_dict_map('gender') + user_ids = [u.id for u in user_list] + roles_map = _batch_user_roles(user_ids) + + # 构建工作簿 + wb = Workbook() + ws = wb.worksheets[0] + headers = [col["title"] for col in USER_EXPORT_COLUMNS] + ws.append(headers) + + for idx, u in enumerate(user_list, start=1): + role_list = roles_map.get(u.id, []) + row = [ + idx, + u.code or '', + u.username or '', + u.realname or '', + gender_map.get(str(u.gender), ''), + u.mobile or '', + u.email or '', + dept_map.get(u.dept_id, ''), + level_map.get(u.level_id, ''), + position_map.get(u.position_id, ''), + _format_roles(role_list), + "正常" if u.status == 1 else "禁用", + u.city_info or '', + u.address or '', + u.intro or '', + u.note or '', + u.create_time.strftime('%Y-%m-%d %H:%M:%S') if u.create_time else '', + ] + ws.append(row) + + # 保存文件 + save_path = os.path.join(TEMP_PATH, time.strftime('%Y%m%d')) + mkdir(save_path) + file_name = time.strftime('%Y%m%d%H%M%S') + '%05d' % random.randint(0, 99999) + ".xlsx" + path = os.path.join(save_path, file_name) + wb.save(path) + + # 文件访问地址 + relative_path = path.replace(UPLOAD_DIR, "").replace("\\", "/").lstrip("/") + file_url = TORNADO_FILE_URL + "/" + relative_path + return R.ok(handler, data=file_url) + finally: + db.close() + + +# ====================================================================== +# 重建用户角色关系 +# ====================================================================== +def _add_user_role(user_id: int, role_id_list): + """重建用户角色关系(单事务,避免中途失败留下残缺关联)。 + + 先过滤指向不存在角色的 ID,避免写入孤儿关联(与 role.save_role_menu 的校验保持一致)。 + + :param user_id: 用户 ID + :param role_id_list: 角色 ID 列表 + """ + repo = UserRoleRepository() + try: + valid_role_ids = [] + raw_ids = [int(r) for r in role_id_list if r] + # 过滤出真实存在的角色 ID + if raw_ids: + rows = repo.db.query(Role.id).filter( + Role.id.in_(raw_ids), Role.is_delete == 0).all() + valid_role_ids = [r[0] for r in rows] + # 先清空旧关联,再写入新关联 + repo.delete_by_user(user_id) + for role_id in valid_role_ids: + repo.db.add(UserRole(user_id=user_id, role_id=role_id)) + repo.db.commit() + # 失效权限缓存 + invalidate_permission_cache() + except Exception: + repo.db.rollback() + raise + finally: + repo.db.close() + + +# ====================================================================== +# 服务单例 +# ====================================================================== +user_service = UserService() diff --git a/src/modules/user_role/__init__.py b/src/modules/user_role/__init__.py new file mode 100644 index 0000000..ddc20fa --- /dev/null +++ b/src/modules/user_role/__init__.py @@ -0,0 +1,5 @@ +# +====================================================================== +# | 模块: 用户角色 +# | 说明: 管理用户与角色的关联关系 +# +====================================================================== +"""用户角色模块。""" diff --git a/src/modules/user_role/handlers.py b/src/modules/user_role/handlers.py new file mode 100644 index 0000000..f42731f --- /dev/null +++ b/src/modules/user_role/handlers.py @@ -0,0 +1,89 @@ +# +====================================================================== +# | 模块: 用户角色请求处理器层 +# | 说明: 承接用户角色关联关系的查询、设置、删除、清空请求, +# | 校验权限/演示模式并记录操作日志后,转交用户角色服务处理 +# +====================================================================== +"""用户角色请求处理器。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:权限/演示模式装饰器、操作日志装饰器、统一响应对象、 +# 请求处理基类、用户角色服务与校验模型 +# ---------------------------------------------------------------------- +from common.middleware.access_decorators import check_demo, permission_required +from common.middleware.operation_log import operation_log +from core import response as R +from core.base_handler import BaseHandler +from modules.user_role import service as user_role_service +from modules.user_role.schemas import UserRoleSaveSchema + + +# ====================================================================== +# 查询用户角色列表 +# ====================================================================== +class UserRoleListHandler(BaseHandler): + """查询用户角色列表。""" + + @permission_required("sys:user:role:list") + async def get(self, userId): + """GET 请求:返回指定用户的角色 ID 列表。 + + :param userId: 用户 ID(路径参数) + """ + return R.ok(self, data=user_role_service.get_user_role_list(userId)) + + +# ====================================================================== +# 设置用户角色 +# ====================================================================== +class UserRoleSaveHandler(BaseHandler): + """设置用户角色。""" + + @permission_required("sys:user:role:save") + @operation_log("用户管理", "设置用户角色") + @check_demo + async def post(self): + """POST 请求:设置用户角色(全量替换)。 + + 请求体:{ "userId": 1, "roleIds": [1, 2, 3] } + """ + # 局部导入,避免顶层依赖 + from core.base_service import BaseService + # 解析并校验请求体 + data = BaseService.validate(UserRoleSaveSchema, BaseService.parse_json(self)) + return await user_role_service.save_user_role(self, data) + + +# ====================================================================== +# 删除用户角色 +# ====================================================================== +class UserRoleDeleteHandler(BaseHandler): + """删除用户角色。""" + + @permission_required("sys:user:role:delete") + @operation_log("用户管理", "删除用户角色") + @check_demo + async def delete(self, userId, roleId): + """DELETE 请求:删除指定用户的角色关联。 + + :param userId: 用户 ID(路径参数) + :param roleId: 角色 ID(路径参数) + """ + return await user_role_service.delete_user_role(self, userId, roleId) + + +# ====================================================================== +# 清空用户角色 +# ====================================================================== +class UserRoleClearHandler(BaseHandler): + """清空用户角色。""" + + @permission_required("sys:user:role:delete") + @operation_log("用户管理", "清空用户角色") + @check_demo + async def delete(self, userId): + """DELETE 请求:清空指定用户的所有角色。 + + :param userId: 用户 ID(路径参数) + """ + return await user_role_service.clear_user_roles(self, userId) diff --git a/src/modules/user_role/models.py b/src/modules/user_role/models.py new file mode 100644 index 0000000..5c7a556 --- /dev/null +++ b/src/modules/user_role/models.py @@ -0,0 +1,40 @@ +# +====================================================================== +# | 模块: 用户角色模型层 +# | 说明: 定义用户角色关联表结构,继承 BaseModel 获得通用字段与序列化能力 +# +====================================================================== +"""用户角色数据模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:SQLAlchemy 列类型与函数、表名前缀配置、模型基类 +# ---------------------------------------------------------------------- +from sqlalchemy import Column, Integer, text + +from config.database import DB_PREFIX +from core.base_model import BaseModel + + +# ====================================================================== +# 用户角色模型 +# ====================================================================== +class UserRole(BaseModel): + """用户角色模型。""" + + # ============================================================ + # 表定义 + # ============================================================ + __tablename__ = DB_PREFIX + "user_role" + __table_comment__ = "用户角色关联表" + + # ============================================================ + # 业务字段 + # ============================================================ + user_id = Column(Integer, nullable=False, default=0, server_default=text('0'), index=True, comment="用户ID") + role_id = Column(Integer, nullable=False, default=0, server_default=text('0'), index=True, comment="角色ID") + + # ============================================================ + # 调试输出 + # ============================================================ + def __str__(self): + """调试用字符串表示:返回用户角色标识。""" + return f"用户角色{self.id}" diff --git a/src/modules/user_role/repository.py b/src/modules/user_role/repository.py new file mode 100644 index 0000000..8e5c9e3 --- /dev/null +++ b/src/modules/user_role/repository.py @@ -0,0 +1,52 @@ +# +====================================================================== +# | 模块: 用户角色数据访问层 +# | 说明: 定义用户角色仓储,继承 BaseRepository 获得通用 CRUD 能力, +# | 并扩展按用户删除关联与查询角色 ID +# +====================================================================== +"""用户角色数据访问。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:通用仓储基类、用户角色模型 +# ---------------------------------------------------------------------- +from core.base_repository import BaseRepository +from modules.user_role.models import UserRole + + +# ====================================================================== +# 用户角色仓储 +# ====================================================================== +class UserRoleRepository(BaseRepository): + """用户角色仓储。""" + + # ============================================================ + # 绑定的模型类 + # ============================================================ + model = UserRole + + # ============================================================ + # 按用户删除角色关联 + # ============================================================ + def delete_by_user(self, user_id: int) -> int: + """删除指定用户的所有角色关联。 + + :param user_id: 用户 ID + :return: 受影响行数 + """ + return self.db.query(self.model).filter( + self.model.user_id == user_id + ).delete() + + # ============================================================ + # 查询用户角色 ID 列表 + # ============================================================ + def get_role_ids(self, user_id: int) -> list: + """获取用户的角色 ID 列表。 + + :param user_id: 用户 ID + :return: 角色 ID 列表 + """ + rows = self.db.query(self.model.role_id).filter( + self.model.user_id == user_id, self.model.is_delete == 0 + ).all() + return [r[0] for r in rows] diff --git a/src/modules/user_role/schemas.py b/src/modules/user_role/schemas.py new file mode 100644 index 0000000..03dca82 --- /dev/null +++ b/src/modules/user_role/schemas.py @@ -0,0 +1,26 @@ +# +====================================================================== +# | 模块: 用户角色请求校验模型层 +# | 说明: 定义用户角色分配的 Pydantic 校验模型 +# +====================================================================== +"""用户角色请求校验模型。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:类型标注、Pydantic 基类与字段定义 +# ---------------------------------------------------------------------- +from typing import List, Optional + +from pydantic import BaseModel, Field + + +# ====================================================================== +# 保存用户角色分配表单 +# ====================================================================== +class UserRoleSaveSchema(BaseModel): + """保存用户角色分配表单。""" + + # ============================================================ + # 表单字段 + # ============================================================ + userId: int = Field(..., gt=0, description="用户ID") + roleIds: Optional[List[int]] = Field(default=[], description="角色ID列表") diff --git a/src/modules/user_role/service.py b/src/modules/user_role/service.py new file mode 100644 index 0000000..9805d9b --- /dev/null +++ b/src/modules/user_role/service.py @@ -0,0 +1,109 @@ +# +====================================================================== +# | 模块: 用户角色业务逻辑层 +# | 说明: 提供用户角色分配的查询、设置(全量替换)、删除、清空功能 +# +====================================================================== +"""用户角色业务逻辑。""" + +# ---------------------------------------------------------------------- +# 导入依赖 +# 涵盖:统一响应对象、数据库会话、用户角色模型/仓储 +# ---------------------------------------------------------------------- +from core import response as R +from core.database import db +from modules.user_role.models import UserRole +from modules.user_role.repository import UserRoleRepository + + +# ====================================================================== +# 获取用户角色 ID 列表 +# ====================================================================== +def get_user_role_list(user_id): + """获取用户已分配的角色 ID 列表。 + + :param user_id: 用户 ID + :return: 角色 ID 列表 + """ + try: + repo = UserRoleRepository() + return repo.get_role_ids(user_id) + finally: + db.close() + + +# ====================================================================== +# 设置用户角色(全量替换) +# ====================================================================== +async def save_user_role(handler, data): + """设置用户角色(全量替换)。 + + :param handler: Tornado 请求处理器 + :param data: UserRoleSaveSchema 实例 + :return: 统一响应对象 + """ + try: + # 删除原有角色 + db.query(UserRole).filter(UserRole.user_id == data.userId).delete() + # 添加新角色 + for role_id in (data.roleIds or []): + db.add(UserRole(user_id=data.userId, role_id=role_id)) + db.commit() + return R.ok(handler, msg="设置成功") + except Exception: + # 异常回滚,保证全量替换的原子性 + db.rollback() + raise + finally: + db.close() + + +# ====================================================================== +# 删除用户角色关联 +# ====================================================================== +async def delete_user_role(handler, user_id, role_id): + """删除用户角色关联。 + + :param handler: Tornado 请求处理器 + :param user_id: 用户 ID + :param role_id: 角色 ID + :return: 统一响应对象 + """ + try: + rows = db.query(UserRole).filter( + UserRole.user_id == user_id, + UserRole.role_id == role_id, + UserRole.is_delete == 0 + ).all() + # 关联不存在:返回失败 + if not rows: + return R.failed(handler, "记录不存在") + # 软删除匹配到的关联记录 + for row in rows: + row.is_delete = 1 + db.commit() + return R.ok(handler, msg="删除成功") + except Exception: + db.rollback() + raise + finally: + db.close() + + +# ====================================================================== +# 清空用户所有角色 +# ====================================================================== +async def clear_user_roles(handler, user_id): + """清空用户所有角色。 + + :param handler: Tornado 请求处理器 + :param user_id: 用户 ID + :return: 统一响应对象 + """ + try: + db.query(UserRole).filter(UserRole.user_id == user_id).delete() + db.commit() + return R.ok(handler, msg="清空成功") + except Exception: + db.rollback() + raise + finally: + db.close() diff --git a/static/.gitkeep b/static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/templates/errors/404.html b/templates/errors/404.html new file mode 100644 index 0000000..b492eed --- /dev/null +++ b/templates/errors/404.html @@ -0,0 +1,19 @@ + + + + + + 404 - 页面不存在 + + + +

404

+
抱歉,您访问的页面不存在
+ 返回首页 + + diff --git a/templates/errors/500.html b/templates/errors/500.html new file mode 100644 index 0000000..110cedb --- /dev/null +++ b/templates/errors/500.html @@ -0,0 +1,19 @@ + + + + + + 500 - 服务器错误 + + + +

500

+
抱歉,服务器内部错误,请稍后重试
+ 返回首页 + + diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..9796b1f --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""测试包。""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d6673b8 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,14 @@ +"""pytest 全局配置。""" +import pytest + + +def pytest_configure(config): + config.addinivalue_line("markers", "integration: 需要 MySQL/Redis 服务的集成测试") + config.addinivalue_line("markers", "e2e: 端到端测试") + + +@pytest.fixture +def fake_handler(): + """构造一个模拟 Tornado handler,供服务层单测使用。""" + from tests.fixtures.factories import FakeHandler + return FakeHandler diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 0000000..ee04e1e --- /dev/null +++ b/tests/fixtures/__init__.py @@ -0,0 +1 @@ +"""测试夹具。""" diff --git a/tests/fixtures/data.sql b/tests/fixtures/data.sql new file mode 100644 index 0000000..2edd8ad --- /dev/null +++ b/tests/fixtures/data.sql @@ -0,0 +1,2 @@ +-- 集成测试基础数据 +-- 完整结构请使用 document/djangoadmin.tornado.antdvue.sql diff --git a/tests/fixtures/factories.py b/tests/fixtures/factories.py new file mode 100644 index 0000000..bfbb79d --- /dev/null +++ b/tests/fixtures/factories.py @@ -0,0 +1,126 @@ +"""工厂模式:构造测试用模拟对象。""" +import json + +from common.utils.helpers import parse_id_list + + +class DictItem(dict): + """带 to_dict 的伪模型对象。""" + + def to_dict(self): + return dict(self) + + +class FakeHandler: + """模拟 Tornado handler(支持 get_query_argument / request.body / finish)。""" + + def __init__(self, query=None, body=None): + self._query = query or {} + self.request = type("Request", (), { + "body": json.dumps(body).encode("utf-8") if body is not None else b"{}", + "path": "/api/v1/test", + "method": "POST", + })() + self.result = None + + def get_query_argument(self, name, default=None): + return self._query.get(name, default) + + def finish(self, result): + self.result = result + return self.result + + +class FakeQuery: + """内存查询对象:支持 filter/order_by/count/all(不做真实过滤)。""" + + def __init__(self, items): + self.items = list(items) + + def filter(self, *criterion): + return self + + def order_by(self, *cols): + return self + + def all(self): + return self.items + + def count(self): + return len(self.items) + + +class FakeRepository: + """内存版仓储,支持 BaseService 模板方法所需原语。""" + + def __init__(self, items=None): + self.items = items if items is not None else [] + self._deleted = [] + + # ---------- 数据清洗 ---------- + def _clean_model_data(self, data: dict) -> dict: + """模拟 BaseRepository._clean_model_data:透传数据。""" + return data if data else {} + + # ---------- 查询 ---------- + def filter(self, *criterion): + return FakeQuery(self.items) + + def filter_by(self, **kwargs): + return FakeQuery(self.items) + + def get_by_id(self, pk, soft_delete=True): + try: + pk = int(pk) + except (TypeError, ValueError): + return None + for item in self.items: + if getattr(item, "id", None) == pk: + return item + return None + + def get_by_ids(self, ids, soft_delete=True): + valid = [i for i in ids if isinstance(i, int) and i > 0] + return [item for item in self.items if getattr(item, "id", None) in valid] + + def exists(self, obj_id=None, soft_delete=True, **filters): + if obj_id is not None: + return self.get_by_id(obj_id) is not None + return any(self.items) + + def exists_by_field(self, field, value, exclude_id=None, soft_delete=True): + for item in self.items: + if getattr(item, field, None) == value and getattr(item, "id", None) != exclude_id: + return True + return False + + def paginate(self, query, page=1, limit=10): + items = list(query.items) + total = len(items) + start = (page - 1) * limit + return items[start:start + limit], total + + def all(self, soft_delete=True): + return list(self.items) + + # ---------- 写操作 ---------- + def create(self, obj): + self.items.append(obj) + return obj + + def update(self, pk, data): + return True + + def soft_delete(self, ids): + self._deleted.extend(ids) + return len(ids) + + def batch_delete(self, ids_str): + id_list = parse_id_list(str(ids_str)) + if not id_list: + return 0 + self._deleted.extend(id_list) + return len(id_list) + + def close(self): + pass diff --git a/tests/fixtures/users.json b/tests/fixtures/users.json new file mode 100644 index 0000000..d545fcd --- /dev/null +++ b/tests/fixtures/users.json @@ -0,0 +1,10 @@ +[ + { + "username": "admin", + "password": "123456", + "realname": "超级管理员", + "nickname": "管理员", + "gender": 1, + "status": 1 + } +] diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..de02c1a --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1 @@ +"""集成测试。""" diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..96a7bf4 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,28 @@ +"""集成测试配置:MySQL/Redis 不可用或认证失败时自动跳过。""" +import pytest + + +def _services_available() -> bool: + """尝试真实连接 MySQL 与 Redis,任一不可用则跳过集成测试。""" + try: + # 数据库连通性 + 认证(select(1) 跨方言,Oracle 下自动渲染 SELECT 1 FROM DUAL) + from sqlalchemy import select + from extensions.sqlalchemy import engine + with engine.connect() as conn: + conn.execute(select(1)) + # Redis 连通性 + from core.redis import redis + redis.ping() + return True + except Exception: + return False + + +SERVICES_AVAILABLE = _services_available() + + +@pytest.fixture(autouse=True) +def _skip_without_services(request): + """集成测试依赖 MySQL/Redis,服务不可用或认证失败时跳过。""" + if "integration" in request.keywords and not SERVICES_AVAILABLE: + pytest.skip("MySQL/Redis 服务不可用或认证失败,跳过集成测试") diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py new file mode 100644 index 0000000..a437b12 --- /dev/null +++ b/tests/integration/test_api.py @@ -0,0 +1,34 @@ +"""API 集成测试。""" +import json + +import pytest +from tornado.testing import AsyncHTTPTestCase + +# 使用 src 包路径导入工厂,避免与根目录入口 app.py 同名冲突 +from src.app import create_app + +pytestmark = pytest.mark.integration + + +class ApiSmokeTest(AsyncHTTPTestCase): + def get_app(self): + return create_app() + + def test_health(self): + resp = self.fetch("/api/v1/health") + assert resp.code == 200 + body = json.loads(resp.body) + assert body["code"] == 0 + assert body["data"]["status"] == "ok" + + def test_captcha(self): + resp = self.fetch("/api/v1/captcha") + assert resp.code == 200 + body = json.loads(resp.body) + assert body["code"] == 0 + assert body["data"].startswith("data:image/png;base64,") + + def test_protected_requires_token(self): + resp = self.fetch("/api/v1/user/list") + body = json.loads(resp.body) + assert body["code"] == 401 diff --git a/tests/integration/test_database.py b/tests/integration/test_database.py new file mode 100644 index 0000000..ae05cb1 --- /dev/null +++ b/tests/integration/test_database.py @@ -0,0 +1,64 @@ +"""数据库连接与 Repository 集成测试。""" +import pytest +from sqlalchemy import select + +from extensions.sqlalchemy import engine + +pytestmark = pytest.mark.integration + + +def test_db_connect(): + with engine.connect() as conn: + result = conn.execute(select(1)) + assert result.scalar() == 1 + + +def test_repo_get_by_id_robust(): + """get_by_id 对非法 ID 容错,对已存在 ID 正常返回。""" + from modules.role.repository import RoleRepository + repo = RoleRepository() + try: + assert repo.get_by_id(None) is None + assert repo.get_by_id("") is None + assert repo.get_by_id("abc") is None + assert repo.get_by_id(0) is None + assert repo.get_by_id(-1) is None + first = repo.filter_by().order_by(RoleRepository.model.id.asc()).first() + assert first is not None + assert repo.get_by_id(first.id) is not None + finally: + repo.close() + + +def test_repo_exists_by_field(): + """exists_by_field 唯一性校验(仅未删除,可排除自身)。""" + from modules.role.models import Role + from modules.role.repository import RoleRepository + repo = RoleRepository() + try: + role = repo.create(Role(name="唯一名XYZ", code="uniq_xyz", status=1, sort=0)) + assert repo.exists_by_field("code", "uniq_xyz") # 自身命中 + assert not repo.exists_by_field("code", "uniq_xyz", exclude_id=role.id) # 排除自身后无重复 + assert not repo.exists_by_field("code", "__不存在的编码__") + repo.db.query(Role).filter(Role.id == role.id).delete() + repo.db.commit() + finally: + repo.close() + + +def test_repo_batch_delete(): + """batch_delete 逗号分隔软删,返回实际删除条数。""" + from modules.role.models import Role + from modules.role.repository import RoleRepository + repo = RoleRepository() + try: + role = repo.create(Role(name="测试删除角色", code="test_del", status=1, sort=0)) + assert repo.batch_delete(f"{role.id}") == 1 + assert repo.get_by_id(role.id) is None # 软删后不可见 + assert repo.get_by_id(role.id, soft_delete=False) is not None # 物理仍在 + assert repo.batch_delete("abc,,0") == 0 + # 清理 + repo.db.query(Role).filter(Role.id == role.id).delete() + repo.db.commit() + finally: + repo.close() diff --git a/tests/integration/test_redis.py b/tests/integration/test_redis.py new file mode 100644 index 0000000..d1393e9 --- /dev/null +++ b/tests/integration/test_redis.py @@ -0,0 +1,15 @@ +"""Redis 集成测试。""" +import pytest + +from core.redis import redis + +pytestmark = pytest.mark.integration + +TEST_KEY = "test:djangoadmin:ping" + + +def test_redis_set_get(): + redis.set(TEST_KEY, "pong") + assert redis.get(TEST_KEY) == "pong" + redis.delete(TEST_KEY) + assert redis.get(TEST_KEY) is None diff --git a/tests/reports/.keep b/tests/reports/.keep new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..6d39ee9 --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +"""单元测试。""" diff --git a/tests/unit/core/__init__.py b/tests/unit/core/__init__.py new file mode 100644 index 0000000..899a9f5 --- /dev/null +++ b/tests/unit/core/__init__.py @@ -0,0 +1 @@ +"""核心层单元测试。""" diff --git a/tests/unit/core/test_base_service.py b/tests/unit/core/test_base_service.py new file mode 100644 index 0000000..4602ab1 --- /dev/null +++ b/tests/unit/core/test_base_service.py @@ -0,0 +1,138 @@ +"""BaseService 模板方法测试。""" +import pytest + +from core.base_service import BaseService +from tests.fixtures.factories import FakeHandler, FakeRepository + + +class FakeColumn: + """伪列对象:模拟 SQLAlchemy 列的 asc/desc。""" + + def __init__(self, name): + self.name = name + + def asc(self): + return self + + def desc(self): + return self + + +class DummyItem: + """伪模型:类属性模拟列(支持 .asc()/.desc()),实例属性存值。""" + + id = FakeColumn("id") + name = FakeColumn("name") + status = FakeColumn("status") + sort = FakeColumn("sort") + + def __init__(self, id=None, name=None, status=1, sort=0): + self.id = id + self.name = name + self.status = status + self.sort = sort + + def to_dict(self): + # 与 BaseModel.to_dict() 保持一致:返回 camelCase key + return {"id": self.id, "name": self.name, "status": self.status, "sort": self.sort} + + +class DummySchema: + """伪 Pydantic 表单。""" + + def __init__(self, data): + for k, v in data.items(): + setattr(self, k, v) + self._data = data + + @classmethod + def model_validate(cls, data): + return cls(data) + + def model_dump(self): + return dict(self._data) + + +class DummyService(BaseService): + model = DummyItem + repo_cls = FakeRepository + create_schema = DummySchema + update_schema = DummySchema + status_schema = DummySchema + page_like_fields = ("name",) + page_eq_fields = ("status",) + page_order_by = (("sort", "asc"),) + serialize_maps = {"status": {1: "正常", 2: "停用"}} + + +@pytest.fixture +def service(): + repo = FakeRepository([DummyItem(id=1, name="A", status=1), DummyItem(id=2, name="B", status=1)]) + DummyService.repo_cls = lambda db=None: repo + return DummyService() + + +async def test_get_page(service): + handler = FakeHandler(query={"page": 1, "limit": 10}) + await service.get_page(handler) + assert handler.result["code"] == 0 + assert handler.result["data"]["total"] == 2 + assert len(handler.result["data"]["records"]) == 2 + assert handler.result["data"]["current"] == 1 + assert handler.result["data"]["size"] == 10 + # serialize_maps 枚举名(camelCase + Text 后缀) + assert handler.result["data"]["records"][0]["statusText"] == "正常" + + +async def test_get_detail(service): + assert service.get_detail(1)["name"] == "A" + assert service.get_detail(999) is None + + +async def test_add(service): + handler = FakeHandler(body={"name": "C", "status": 1, "sort": 3}) + await service.add(handler) + assert handler.result["code"] == 0 + assert any(item.name == "C" for item in service.repo.items) + + +async def test_update(service): + handler = FakeHandler(body={"id": 1, "name": "A2", "status": 1, "sort": 5}) + await service.update(handler) + assert handler.result["code"] == 0 + assert handler.result["msg"] == "更新成功" + + +async def test_update_requires_id(service): + handler = FakeHandler(body={"name": "A2", "status": 1}) + await service.update(handler) + assert handler.result["code"] == 1 + assert "记录ID" in handler.result["msg"] + + +async def test_delete(service): + handler = FakeHandler() + await service.delete(handler, "1,2") + assert handler.result["code"] == 0 + assert service.repo._deleted == [1, 2] + + +async def test_delete_invalid_ids(service): + handler = FakeHandler() + await service.delete(handler, "abc,,0") + assert handler.result["code"] == 1 + assert "记录ID" in handler.result["msg"] + + +async def test_update_status(service): + handler = FakeHandler(body={"id": 1, "status": 2}) + await service.update_status(handler) + assert handler.result["code"] == 0 + assert handler.result["msg"] == "设置成功" + + +async def test_update_status_missing_record(service): + handler = FakeHandler(body={"id": 999, "status": 2}) + await service.update_status(handler) + assert handler.result["code"] == 1 + assert handler.result["msg"] == "记录不存在" diff --git a/tests/unit/core/test_crypto.py b/tests/unit/core/test_crypto.py new file mode 100644 index 0000000..ae930c3 --- /dev/null +++ b/tests/unit/core/test_crypto.py @@ -0,0 +1,47 @@ +"""加密工具测试。""" +from common.utils.crypto import (check_password, hash_password, is_bcrypt, + set_password, verify_password, verify_user_password) + + +def test_hash_and_verify_legacy(): + """无盐 bcrypt(兼容旧数据)。""" + hashed = hash_password("123456") + assert is_bcrypt(hashed) + assert verify_password("123456", hashed) + assert not verify_password("wrong", hashed) + + +def test_set_and_check_password(): + """盐方案:生成盐 + 双重加盐加密。""" + salt, hashed = set_password("123456") + assert salt + assert is_bcrypt(hashed) + assert check_password("123456", salt, hashed) + assert not check_password("wrong", salt, hashed) + assert not check_password("123456", "", hashed) + + +def test_salt_differences(): + """同一明文两次 set_password 盐不同,但均可校验通过。""" + salt1, hashed1 = set_password("123456") + salt2, hashed2 = set_password("123456") + assert salt1 != salt2 + assert hashed1 != hashed2 + assert check_password("123456", salt1, hashed1) + assert check_password("123456", salt2, hashed2) + + +def test_verify_user_password_prefers_salt(): + """统一校验:优先盐方案。""" + salt, hashed = set_password("123456") + assert verify_user_password("123456", salt, hashed) + assert not verify_user_password("wrong", salt, hashed) + + +def test_verify_user_password_falls_back_to_bcrypt(): + """统一校验:兼容旧无盐 bcrypt。""" + bcrypt_hash = hash_password("123456") + assert verify_user_password("123456", None, bcrypt_hash) + assert not verify_user_password("wrong", None, bcrypt_hash) + # 非 bcrypt 密文无法通过校验 + assert not verify_user_password("123456", None, "not-a-hash") diff --git a/tests/unit/core/test_jwt.py b/tests/unit/core/test_jwt.py new file mode 100644 index 0000000..a75148b --- /dev/null +++ b/tests/unit/core/test_jwt.py @@ -0,0 +1,43 @@ +"""JWT 加解密测试。""" +import asyncio + +from core.jwt import create_token, parse_payload, refresh_token + + +def test_create_and_parse(): + token = create_token({"userId": 1}, timeout=10) + assert isinstance(token, str) and token + result = parse_payload(token) + assert result["code"] == 0 + assert result["data"]["userId"] == 1 + + +def test_parse_invalid_token(): + result = parse_payload("invalid.token.here") + # 新实现统一约定:code 0-成功 1-失败(旧实现返回 -1) + assert result["code"] == 1 + assert result["data"] is None + + +def test_parse_empty_token(): + result = parse_payload("") + assert result["code"] == 1 + assert "不能为空" in result["msg"] + + +def test_refresh_token_rotation(): + token = create_token({"userId": 7}, timeout=30) + result = asyncio.run(refresh_token(token, extend_minutes=60)) + assert result["code"] == 0 + assert result["data"]["expires_in"] == 60 * 60 + new_token = result["data"]["access_token"] + parsed = parse_payload(new_token) + assert parsed["code"] == 0 + assert parsed["data"]["userId"] == 7 + # 轮换后新令牌不等于旧令牌 + assert new_token != token + + +def test_refresh_invalid_token(): + result = asyncio.run(refresh_token("invalid.token.here")) + assert result["code"] == 1 diff --git a/tests/unit/core/test_repository.py b/tests/unit/core/test_repository.py new file mode 100644 index 0000000..e311c9d --- /dev/null +++ b/tests/unit/core/test_repository.py @@ -0,0 +1,30 @@ +"""Repository 相关纯逻辑与序列化安全测试。""" +from common.utils.helpers import parse_id_list + + +def test_parse_id_list(): + assert parse_id_list("1,2,3") == [1, 2, 3] + assert parse_id_list("1,,3,abc,-2") == [1, 3] + assert parse_id_list("") == [] + assert parse_id_list(None) == [] + + +def test_to_dict_excludes_sensitive_fields(): + """BaseModel.to_dict() 不得泄露 password/salt/is_delete。""" + from modules.user.models import User + user = User(username="admin", password="secret-hash", salt="s1", realname="管理员") + data = user.to_dict() + assert "password" not in data + assert "salt" not in data + assert "is_delete" not in data + assert data["username"] == "admin" + assert data["realname"] == "管理员" + + +def test_to_json_excludes_sensitive_fields(): + from modules.user.models import User + user = User(username="admin", password="secret-hash", salt="s1") + data = user.to_json() + assert "password" not in data + assert "salt" not in data + assert "is_delete" not in data diff --git a/tests/unit/core/test_response.py b/tests/unit/core/test_response.py new file mode 100644 index 0000000..7608ece --- /dev/null +++ b/tests/unit/core/test_response.py @@ -0,0 +1,66 @@ +"""统一响应测试。""" +from core import response as R + + +class FakeHandler: + """模拟 Tornado handler 的 finish 方法。""" + + def __init__(self): + self.result = None + + def finish(self, result): + self.result = result + return self.result + + +def test_ok_basic(): + handler = FakeHandler() + R.ok(handler, data={"a": 1}, msg="成功") + assert handler.result["code"] == 0 + assert handler.result["data"] == {"a": 1} + assert handler.result["msg"] == "成功" + assert handler.result["ok"] is True + + +def test_ok_with_kwargs(): + handler = FakeHandler() + R.ok(handler, data=[], count=10) + assert handler.result["count"] == 10 + assert handler.result["ok"] is True + + +def test_failed_default_code(): + """failed 默认码为 1,并含 ok: False。""" + handler = FakeHandler() + R.failed(handler, msg="失败") + assert handler.result["code"] == 1 + assert handler.result["data"] is None + assert handler.result["msg"] == "失败" + assert handler.result["ok"] is False + + +def test_failed_with_code_and_data(): + handler = FakeHandler() + R.failed(handler, msg="未认证", code=R.Codes.UNAUTHORIZED, data={"detail": "x"}) + assert handler.result["code"] == 401 + assert handler.result["data"] == {"detail": "x"} + assert handler.result["ok"] is False + + +def test_response_generic(): + """通用响应生成器:允许自定义 ok 字段。""" + handler = FakeHandler() + R.response(handler, data=[1], msg="自定义", code=200, success=True, extra=1) + assert handler.result["code"] == 200 + assert handler.result["ok"] is True + assert handler.result["extra"] == 1 + + +def test_codes_constants(): + assert R.Codes.SUCCESS == 0 + assert R.Codes.FAILED == 1 + assert R.Codes.UNAUTHORIZED == 401 + assert R.Codes.FORBIDDEN == 403 + assert R.Codes.NOT_FOUND == 404 + assert R.Codes.VALIDATE_ERROR == 422 + assert R.Codes.SERVER_ERROR == 500 diff --git a/tests/unit/modules/__init__.py b/tests/unit/modules/__init__.py new file mode 100644 index 0000000..1d620c6 --- /dev/null +++ b/tests/unit/modules/__init__.py @@ -0,0 +1 @@ +"""业务模块单元测试。""" diff --git a/tests/unit/modules/test_auth.py b/tests/unit/modules/test_auth.py new file mode 100644 index 0000000..f364cce --- /dev/null +++ b/tests/unit/modules/test_auth.py @@ -0,0 +1,21 @@ +"""认证模块测试。""" +import pytest + +from modules.auth.schemas import LoginSchema + + +def test_login_schema_valid(): + form = LoginSchema(username="admin", password="123456", code="123456", key="uuid-1") + assert form.username == "admin" + assert form.key == "uuid-1" + assert form.code == "123456" + + +def test_login_schema_missing_field(): + with pytest.raises(Exception): + LoginSchema(username="admin") + + +def test_login_schema_extra_ignored(): + form = LoginSchema(username="admin", password="123456", code="123456", key="x", extra_field=1) + assert not hasattr(form, "extra_field") diff --git a/tests/unit/modules/test_job_log_service.py b/tests/unit/modules/test_job_log_service.py new file mode 100644 index 0000000..86a1263 --- /dev/null +++ b/tests/unit/modules/test_job_log_service.py @@ -0,0 +1,66 @@ +"""JobLogService 分页时间区间过滤测试。""" +from modules.job_log.service import JobLogService +from tests.fixtures.factories import FakeHandler + + +class TimeFilterQuery: + """捕获 filter 条件的伪查询对象。""" + + def __init__(self, items=None): + self.items = items or [] + self.criteria = [] + + def filter(self, *criterion): + self.criteria.extend(criterion) + return self + + def order_by(self, *cols): + return self + + def all(self): + return self.items + + +class TimeFilterRepo: + """伪仓储:仅返回捕获查询。""" + + def __init__(self): + self.query = TimeFilterQuery() + + def filter(self, *criterion): + return self.query + + def close(self): + pass + + +def _service(): + service = JobLogService(db=None) + service.repo = TimeFilterRepo() # 注入伪仓储 + return service + + +def _range_names(query): + """提取 filter 条件中的列名(用于断言区间过滤生效)。""" + return [c.left.name for c in query.criteria if hasattr(c, "left")] + + +def test_time_range_filter_applied(): + service = _service() + handler = FakeHandler(query={ + "startTime": "2026-08-01 00:00:00", + "endTime": "2026-08-15 23:59:59", + }) + + service._build_page_query(handler) + + assert _range_names(service.repo.query).count("start_time") == 2 + + +def test_time_range_filter_without_args_no_crash(): + service = _service() + handler = FakeHandler(query={}) + + service._build_page_query(handler) + + assert _range_names(service.repo.query) == [] diff --git a/tests/unit/modules/test_job_scheduler.py b/tests/unit/modules/test_job_scheduler.py new file mode 100644 index 0000000..8e8b46c --- /dev/null +++ b/tests/unit/modules/test_job_scheduler.py @@ -0,0 +1,139 @@ +"""任务调度器测试:执行日志记录、Cron 校验与执行策略防重叠。""" +import types + +import pytest + +from modules.job import scheduler +from modules.job_log import service as job_log_service + + +def make_job(**overrides): + """构造一个伪任务快照(无 DB),与调度器要求的 JobSnapshot 对齐。 + + 默认 URL 用公网 IP 字面量(8.8.8.8):无需 DNS 即能通过 getaddrinfo, + 且 is_global=True,符合调度器防 SSRF 的 URL 校验(不再使用 127.0.0.1)。 + """ + defaults = dict( + id=1, job_name="测试任务", job_group="系统任务", job_trigger="cron", + cron_expression="*/5 * * * *", url="http://8.8.8.8/api/v1/health", + execute_policy=1, status=1, + ) + defaults.update(overrides) + return scheduler._snapshot_job(types.SimpleNamespace(**defaults)) + + +class FakeResponse: + """支持 with 语法的伪 HTTP 响应。""" + + status = 200 + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +@pytest.fixture(autouse=True) +def _clean_running(): + """每个用例前后清空运行中集合,避免相互污染。""" + scheduler._running.clear() + yield + scheduler._running.clear() + + +def _capture(monkeypatch): + """打桩 record_job_log,返回记录列表。""" + records = [] + monkeypatch.setattr(job_log_service, "record_job_log", lambda **kw: records.append(kw)) + return records + + +def test_execute_job_success_records_log(monkeypatch): + records = _capture(monkeypatch) + # execute_job 经 _no_redirect_opener.open 发起请求(禁重定向,防 SSRF 302 跳内网) + monkeypatch.setattr(scheduler._no_redirect_opener, "open", lambda url, timeout=None: FakeResponse()) + + scheduler.execute_job(make_job()) + + assert len(records) == 1 + assert records[0]["status"] == 0 + assert records[0]["job_name"] == "测试任务" + assert records[0]["job_message"] == "任务触发成功" + assert records[0]["consume_time"] >= 0 + + +def test_execute_job_failure_records_exception(monkeypatch): + records = _capture(monkeypatch) + + def _boom(url, timeout=None): + raise ConnectionError("connect timeout") + + monkeypatch.setattr(scheduler._no_redirect_opener, "open", _boom) + + scheduler.execute_job(make_job()) + + assert len(records) == 1 + assert records[0]["status"] == 1 + assert "connect timeout" in records[0]["exception_info"] + assert records[0]["job_message"] == "任务触发失败" + + +def test_overlap_policy_skip_while_running(monkeypatch): + """策略 3-放弃执行:任务执行中时跳过本次触发,不调用任务 URL。""" + records = _capture(monkeypatch) + calls = {"urlopen": 0} + + def _urlopen(url, timeout=None): + calls["urlopen"] += 1 + return FakeResponse() + + monkeypatch.setattr(scheduler._no_redirect_opener, "open", _urlopen) + + job = make_job(execute_policy=3) + scheduler._running.add(job.id) + scheduler.execute_job(job) + + assert calls["urlopen"] == 0 + assert len(records) == 1 + assert records[0]["status"] == 0 + assert "放弃执行" in records[0]["job_message"] + # 跳过不等于释放运行标记:原执行仍占用,任务应保留在运行集合 + assert job.id in scheduler._running + + +def test_overlap_policy_immediate_runs_in_parallel(monkeypatch): + """策略 1-立即执行:即使任务执行中仍再次执行(并行)。""" + records = _capture(monkeypatch) + monkeypatch.setattr(scheduler._no_redirect_opener, "open", lambda url, timeout=None: FakeResponse()) + + job = make_job(execute_policy=1) + scheduler._running.add(job.id) + scheduler.execute_job(job) + + assert len(records) == 1 + assert records[0]["status"] == 0 + assert job.id not in scheduler._running # 执行结束从运行集合移除 + + +def test_validate_cron_expression(): + assert scheduler.validate_cron_expression("*/5 * * * *") is None + assert scheduler.validate_cron_expression("") is not None + assert scheduler.validate_cron_expression("not-a-cron") is not None + + +def test_validate_job_url_blocks_ssrf(): + """防 SSRF:拒绝非 http/https 协议与内网/回环/云元数据地址。""" + assert scheduler.validate_job_url("http://8.8.8.8/ping") is True + assert scheduler.validate_job_url("http://127.0.0.1:8041/x") is False + assert scheduler.validate_job_url("http://192.168.1.1/x") is False + assert scheduler.validate_job_url("http://169.254.169.254/latest/meta-data/") is False + assert scheduler.validate_job_url("file:///etc/passwd") is False + assert scheduler.validate_job_url("ftp://example.com/x") is False + + +def test_no_redirect_handler_blocks_redirect(): + """禁重定向:公网 URL 302 跳内网的 SSRF 通道被切断。""" + handler = scheduler._NoRedirectHandler() + # redirect_request 返回 None -> urlopen 抛 HTTPError,不会继续请求跳转目标 + assert handler.redirect_request(None, None, 302, "", None, "http://169.254.169.254/") is None diff --git a/tests/unit/modules/test_user_schema.py b/tests/unit/modules/test_user_schema.py new file mode 100644 index 0000000..16206c9 --- /dev/null +++ b/tests/unit/modules/test_user_schema.py @@ -0,0 +1,42 @@ +"""用户校验模型测试。""" +import pytest + +from modules.user.schemas import UserSchema, UserStatusSchema + + +def test_user_create_minimal(): + form = UserSchema( + realname="测试", gender=1, avatar="default.png", + mobile="13800138000", email="test@example.com", + deptId=0, levelId=0, positionId=0, + address="测试地址", username="tester", + status=1, sort=0, + ) + assert form.gender == 1 + assert form.status == 1 + assert form.city is None + + +def test_user_create_with_city_and_roles(): + form = UserSchema( + realname="测试", gender=1, avatar="default.png", + mobile="13800138000", email="test@example.com", + deptId=0, levelId=0, positionId=0, + address="测试地址", username="tester", + status=1, sort=0, + city=[110000, 110100, 110101], + roles=[1, 2], + ) + assert form.city == [110000, 110100, 110101] + assert form.roles == [1, 2] + + +def test_user_status_invalid(): + with pytest.raises(Exception): + UserStatusSchema(id=0, status=1) + + +def test_user_status_valid(): + form = UserStatusSchema(id=1, status=2) + assert form.id == 1 + assert form.status == 2 diff --git a/ui/.editorconfig b/ui/.editorconfig new file mode 100644 index 0000000..65d433a --- /dev/null +++ b/ui/.editorconfig @@ -0,0 +1,19 @@ +root = true + +[*] +charset=utf-8 +end_of_line=lf +insert_final_newline=true +indent_style=space +indent_size=2 +max_line_length = 100 + +[*.{yml,yaml,json}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab \ No newline at end of file diff --git a/ui/.env b/ui/.env new file mode 100644 index 0000000..37565cc --- /dev/null +++ b/ui/.env @@ -0,0 +1,11 @@ +# port +VITE_PORT = 8002 + +# spa-title +VITE_GLOB_APP_TITLE = FastAPI+AntDesign版 + +# spa shortname +VITE_GLOB_APP_SHORT_NAME = DjangoAdmin + +# 生产环境 开启mock +VITE_GLOB_PROD_MOCK = false diff --git a/ui/.env.development b/ui/.env.development new file mode 100644 index 0000000..f9db60b --- /dev/null +++ b/ui/.env.development @@ -0,0 +1,29 @@ +# 只在开发模式中被载入 +VITE_PORT = 8002 + +# 网站根目录 +VITE_PUBLIC_PATH = / + +# 是否开启mock +VITE_USE_MOCK = false + +# 网站前缀 +VITE_BASE_URL = / + +# 是否删除console +VITE_DROP_CONSOLE = true + +# 跨域代理,可以配置多个,请注意不要换行 +VITE_PROXY=[["/api", "http://127.0.0.1:8031/api/v1"]] + +# API 接口地址 +VITE_GLOB_API_URL = + +# 图片上传地址 +VITE_GLOB_UPLOAD_URL= + +# 图片前缀地址 +VITE_GLOB_IMG_URL= + +# 接口前缀 +VITE_GLOB_API_URL_PREFIX = /api diff --git a/ui/.env.production b/ui/.env.production new file mode 100644 index 0000000..0ee697f --- /dev/null +++ b/ui/.env.production @@ -0,0 +1,31 @@ +# 是否开启mock +VITE_USE_MOCK = true + +# 网站根目录 +VITE_PUBLIC_PATH = / + +# 网站前缀 +VITE_BASE_URL = / + +# 是否删除console +VITE_DROP_CONSOLE = true + +# API +VITE_GLOB_API_URL = + +# 图片上传地址 +VITE_GLOB_UPLOAD_URL= + +# 图片前缀地址 +VITE_GLOB_IMG_URL= + +# 接口前缀 +VITE_GLOB_API_URL_PREFIX = /api + +# 是否启用gzip压缩或brotli压缩 +# 可选: gzip | brotli | none +# 如果你需要多种形式,你可以用','来分隔 +VITE_BUILD_COMPRESS = 'none' + +# 使用压缩时是否删除原始文件,默认为false +VITE_BUILD_COMPRESS_DELETE_ORIGIN_FILE = false diff --git a/ui/.env.test b/ui/.env.test new file mode 100644 index 0000000..bb8df49 --- /dev/null +++ b/ui/.env.test @@ -0,0 +1,23 @@ +NODE_ENV = production + +# 是否开启mock +VITE_USE_MOCK = true + +# 网站根目录 +VITE_PUBLIC_PATH = / + +# 是否删除console +VITE_DROP_CONSOLE = true + +# API 接口地址 +VITE_GLOB_API_URL = + +# 接口前缀 +VITE_GLOB_API_URL_PREFIX = + +# 图片上传地址 +VITE_GLOB_UPLOAD_URL = /upload + +# 图片前缀地址 +VITE_GLOB_IMG_URL = + diff --git a/ui/.eslintignore b/ui/.eslintignore new file mode 100644 index 0000000..348631b --- /dev/null +++ b/ui/.eslintignore @@ -0,0 +1,15 @@ + +*.sh +node_modules +*.md +*.woff +*.ttf +.vscode +.idea +dist +/public +/docs +.husky +.local +/bin +Dockerfile diff --git a/ui/.eslintrc.js b/ui/.eslintrc.js new file mode 100644 index 0000000..123cd04 --- /dev/null +++ b/ui/.eslintrc.js @@ -0,0 +1,74 @@ +module.exports = { + root: true, + env: { + browser: true, + node: true, + es6: true, + }, + parser: 'vue-eslint-parser', + parserOptions: { + parser: '@typescript-eslint/parser', + ecmaVersion: 2020, + sourceType: 'module', + jsxPragma: 'React', + ecmaFeatures: { + jsx: true, + }, + }, + extends: [ + 'plugin:vue/vue3-recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:prettier/recommended', + ], + rules: { + 'vue/script-setup-uses-vars': 'error', + '@typescript-eslint/ban-ts-ignore': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-var-requires': 'off', + '@typescript-eslint/no-empty-function': 'off', + 'vue/custom-event-name-casing': 'off', + 'no-use-before-define': 'off', + '@typescript-eslint/no-use-before-define': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + '@typescript-eslint/ban-types': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + }, + ], + 'no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + }, + ], + 'space-before-function-paren': 'off', + + 'vue/attributes-order': 'off', + 'vue/one-component-per-file': 'off', + 'vue/html-closing-bracket-newline': 'off', + 'vue/max-attributes-per-line': 'off', + 'vue/multiline-html-element-content-newline': 'off', + 'vue/singleline-html-element-content-newline': 'off', + 'vue/attribute-hyphenation': 'off', + 'vue/require-default-prop': 'off', + 'vue/html-self-closing': [ + 'error', + { + html: { + void: 'always', + normal: 'never', + component: 'always', + }, + svg: 'always', + math: 'always', + }, + ], + }, +}; diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 0000000..94234c1 --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,26 @@ +.DS_Store +node_modules +/screenshots +/dist +dist.zip +dist_electron + +# local env files +.env.local +.env.*.local +.history + +# Log files +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Editor directories and files +.idea +.vscode +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/ui/.prettierignore b/ui/.prettierignore new file mode 100644 index 0000000..f7e39e6 --- /dev/null +++ b/ui/.prettierignore @@ -0,0 +1,9 @@ +/dist/* +.local +.output.js +/node_modules/** + +**/*.svg +**/*.sh + +/public/* diff --git a/ui/.stylelintignore b/ui/.stylelintignore new file mode 100644 index 0000000..0517076 --- /dev/null +++ b/ui/.stylelintignore @@ -0,0 +1,3 @@ +/dist/* +/public/* +public/* diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md new file mode 100644 index 0000000..9a4d1fe --- /dev/null +++ b/ui/CHANGELOG.md @@ -0,0 +1 @@ +### FastAPI+AntDesign版 diff --git a/ui/build/config/themeConfig.ts b/ui/build/config/themeConfig.ts new file mode 100644 index 0000000..c9a3e8b --- /dev/null +++ b/ui/build/config/themeConfig.ts @@ -0,0 +1,79 @@ +import { generate } from '@ant-design/colors'; + +export const primaryColor = '#1e6fff'; + +export const darkMode = 'light'; + +type Fn = (...arg: any) => any; + +type GenerateTheme = 'default' | 'dark'; + +export interface GenerateColorsParams { + mixLighten: Fn; + mixDarken: Fn; + tinycolor: any; + color?: string; +} + +export function generateAntColors(color: string, theme: GenerateTheme = 'default') { + return generate(color, { + theme, + }); +} + +export function getThemeColors(color?: string) { + const tc = color || primaryColor; + const lightColors = generateAntColors(tc); + const primary = lightColors[5]; + const modeColors = generateAntColors(primary, 'dark'); + + return [...lightColors, ...modeColors]; +} + +export function generateColors({ + color = primaryColor, + mixLighten, + mixDarken, + tinycolor, +}: GenerateColorsParams) { + const arr = new Array(19).fill(0); + const lightens = arr.map((_t, i) => { + return mixLighten(color, i / 5); + }); + + const darkens = arr.map((_t, i) => { + return mixDarken(color, i / 5); + }); + + const alphaColors = arr.map((_t, i) => { + return tinycolor(color) + .setAlpha(i / 20) + .toRgbString(); + }); + + const shortAlphaColors = alphaColors.map((item) => item.replace(/\s/g, '').replace(/0\./g, '.')); + + const tinycolorLightens = arr + .map((_t, i) => { + return tinycolor(color) + .lighten(i * 5) + .toHexString(); + }) + .filter((item) => item !== '#ffffff'); + + const tinycolorDarkens = arr + .map((_t, i) => { + return tinycolor(color) + .darken(i * 5) + .toHexString(); + }) + .filter((item) => item !== '#000000'); + return [ + ...lightens, + ...darkens, + ...alphaColors, + ...shortAlphaColors, + ...tinycolorDarkens, + ...tinycolorLightens, + ].filter((item) => !item.includes('-')); +} diff --git a/ui/build/constant.ts b/ui/build/constant.ts new file mode 100644 index 0000000..cbcc5f3 --- /dev/null +++ b/ui/build/constant.ts @@ -0,0 +1,6 @@ +/** + * The name of the configuration file entered in the production environment + */ +export const GLOB_CONFIG_FILE_NAME = 'app.config.js'; + +export const OUTPUT_DIR = 'dist'; diff --git a/ui/build/getConfigFileName.ts b/ui/build/getConfigFileName.ts new file mode 100644 index 0000000..d61cd41 --- /dev/null +++ b/ui/build/getConfigFileName.ts @@ -0,0 +1,9 @@ +/** + * Get the configuration file variable name + * @param env + */ +export const getConfigFileName = (env: Record) => { + return `__PRODUCTION__${env.VITE_GLOB_APP_SHORT_NAME || '__APP'}__CONF__` + .toUpperCase() + .replace(/\s/g, ''); +}; diff --git a/ui/build/script/buildConf.ts b/ui/build/script/buildConf.ts new file mode 100644 index 0000000..342c154 --- /dev/null +++ b/ui/build/script/buildConf.ts @@ -0,0 +1,45 @@ +/** + * Generate additional configuration files when used for packaging. The file can be configured with some global variables, so that it can be changed directly externally without repackaging + */ +import { GLOB_CONFIG_FILE_NAME, OUTPUT_DIR } from '../constant'; +import fs, { writeFileSync } from 'fs-extra'; +import chalk from 'chalk'; + +import { getEnvConfig, getRootPath } from '../utils'; +import { getConfigFileName } from '../getConfigFileName'; + +import pkg from '../../package.json'; + +interface CreateConfigParams { + configName: string; + config: any; + configFileName?: string; +} + +function createConfig(params: CreateConfigParams) { + const { configName, config, configFileName } = params; + try { + const windowConf = `window.${configName}`; + // Ensure that the variable will not be modified + const configStr = `${windowConf}=${JSON.stringify(config)}; + Object.freeze(${windowConf}); + Object.defineProperty(window, "${configName}", { + configurable: false, + writable: false, + }); + `.replace(/\s/g, ''); + fs.mkdirp(getRootPath(OUTPUT_DIR)); + writeFileSync(getRootPath(`${OUTPUT_DIR}/${configFileName}`), configStr); + + console.log(chalk.cyan(`✨ [${pkg.name}]`) + ` - configuration file is build successfully:`); + console.log(chalk.gray(OUTPUT_DIR + '/' + chalk.green(configFileName)) + '\n'); + } catch (error) { + console.log(chalk.red('configuration file configuration file failed to package:\n' + error)); + } +} + +export function runBuildConfig() { + const config = getEnvConfig(); + const configFileName = getConfigFileName(config); + createConfig({ config, configName: configFileName, configFileName: GLOB_CONFIG_FILE_NAME }); +} diff --git a/ui/build/script/postBuild.ts b/ui/build/script/postBuild.ts new file mode 100644 index 0000000..e1554bf --- /dev/null +++ b/ui/build/script/postBuild.ts @@ -0,0 +1,23 @@ +// #!/usr/bin/env node + +import { runBuildConfig } from './buildConf'; +import chalk from 'chalk'; + +import pkg from '../../package.json'; + +export const runBuild = async () => { + try { + const argvList = process.argv.splice(2); + + // Generate configuration file + if (!argvList.includes('disabled-config')) { + runBuildConfig(); + } + + console.log(`✨ ${chalk.cyan(`[${pkg.name}]`)}` + ' - build successfully!'); + } catch (error) { + console.log(chalk.red('vite build error:\n' + error)); + process.exit(1); + } +}; +runBuild(); diff --git a/ui/build/utils.ts b/ui/build/utils.ts new file mode 100644 index 0000000..16a3fdf --- /dev/null +++ b/ui/build/utils.ts @@ -0,0 +1,77 @@ +import fs from 'fs'; +import path from 'path'; +import dotenv from 'dotenv'; + +export function isDevFn(mode: string): boolean { + return mode === 'development'; +} + +export function isProdFn(mode: string): boolean { + return mode === 'production'; +} + +/** + * Whether to generate package preview + */ +export function isReportMode(): boolean { + return process.env.REPORT === 'true'; +} + +// Read all environment variable configuration files to process.env +export function wrapperEnv(envConf: Recordable): ViteEnv { + const ret: any = {}; + + for (const envName of Object.keys(envConf)) { + let realName = envConf[envName].replace(/\\n/g, '\n'); + realName = realName === 'true' ? true : realName === 'false' ? false : realName; + + if (envName === 'VITE_PORT') { + realName = Number(realName); + } + if (envName === 'VITE_PROXY') { + try { + realName = JSON.parse(realName.replace(/'/g, '"')); + } catch (error) { + realName = ''; + } + } + ret[envName] = realName; + if (typeof realName === 'string') { + process.env[envName] = realName; + } else if (typeof realName === 'object') { + process.env[envName] = JSON.stringify(realName); + } + } + return ret; +} + +/** + * Get the environment variables starting with the specified prefix + * @param match prefix + * @param confFiles ext + */ +export function getEnvConfig(match = 'VITE_GLOB_', confFiles = ['.env', '.env.production']) { + let envConfig = {}; + confFiles.forEach((item) => { + try { + const env = dotenv.parse(fs.readFileSync(path.resolve(process.cwd(), item))); + envConfig = { ...envConfig, ...env }; + } catch (error) {} + }); + + Object.keys(envConfig).forEach((key) => { + const reg = new RegExp(`^(${match})`); + if (!reg.test(key)) { + Reflect.deleteProperty(envConfig, key); + } + }); + return envConfig; +} + +/** + * Get user root directory + * @param dir file path + */ +export function getRootPath(...dir: string[]) { + return path.resolve(process.cwd(), ...dir); +} diff --git a/ui/build/vite/plugin/compress.ts b/ui/build/vite/plugin/compress.ts new file mode 100644 index 0000000..9563679 --- /dev/null +++ b/ui/build/vite/plugin/compress.ts @@ -0,0 +1,35 @@ +/** + * Used to package and output gzip. Note that this does not work properly in Vite, the specific reason is still being investigated + * https://github.com/anncwb/vite-plugin-compression + */ +import type { Plugin } from 'vite'; + +import compressPlugin from 'vite-plugin-compression'; + +export function configCompressPlugin( + compress: 'gzip' | 'brotli' | 'none', + deleteOriginFile = false +): Plugin | Plugin[] { + const compressList = compress.split(','); + + const plugins: Plugin[] = []; + + if (compressList.includes('gzip')) { + plugins.push( + compressPlugin({ + ext: '.gz', + deleteOriginFile, + }) + ); + } + if (compressList.includes('brotli')) { + plugins.push( + compressPlugin({ + ext: '.br', + algorithm: 'brotliCompress', + deleteOriginFile, + }) + ); + } + return plugins; +} diff --git a/ui/build/vite/plugin/html.ts b/ui/build/vite/plugin/html.ts new file mode 100644 index 0000000..6af034a --- /dev/null +++ b/ui/build/vite/plugin/html.ts @@ -0,0 +1,40 @@ +/** + * Plugin to minimize and use ejs template syntax in index.html. + * https://github.com/anncwb/vite-plugin-html + */ +import type { PluginOption } from 'vite'; +import { createHtmlPlugin } from 'vite-plugin-html'; +import pkg from '../../../package.json'; +import { GLOB_CONFIG_FILE_NAME } from '../../constant'; + +export function configHtmlPlugin(env: ViteEnv, isBuild: boolean) { + const { VITE_GLOB_APP_TITLE, VITE_PUBLIC_PATH } = env; + + const path = VITE_PUBLIC_PATH.endsWith('/') ? VITE_PUBLIC_PATH : `${VITE_PUBLIC_PATH}/`; + + const getAppConfigSrc = () => { + return `${path || '/'}${GLOB_CONFIG_FILE_NAME}?v=${pkg.version}-${new Date().getTime()}`; + }; + + const htmlPlugin: PluginOption[] = createHtmlPlugin({ + minify: isBuild, + inject: { + // Inject data into ejs template + data: { + title: VITE_GLOB_APP_TITLE, + }, + // Embed the generated app.config.js file + tags: isBuild + ? [ + { + tag: 'script', + attrs: { + src: getAppConfigSrc(), + }, + }, + ] + : [], + }, + }); + return htmlPlugin; +} diff --git a/ui/build/vite/plugin/index.ts b/ui/build/vite/plugin/index.ts new file mode 100644 index 0000000..5e1515f --- /dev/null +++ b/ui/build/vite/plugin/index.ts @@ -0,0 +1,51 @@ +import type { PluginOption } from 'vite'; + +import vue from '@vitejs/plugin-vue'; +import vueJsx from '@vitejs/plugin-vue-jsx'; + +import windiCSS from 'vite-plugin-windicss'; + +import { configHtmlPlugin } from './html'; +import { configMockPlugin } from './mock'; +import { configCompressPlugin } from './compress'; +import { configThemePlugin } from './theme'; +import { configVisualizerPlugin } from './visualizer'; +import { configStyleImportPlugin } from './styleImport'; + +export function createVitePlugins(viteEnv: ViteEnv, isBuild: boolean, prodMock) { + const { VITE_USE_MOCK, VITE_BUILD_COMPRESS, VITE_BUILD_COMPRESS_DELETE_ORIGIN_FILE } = viteEnv; + + const vitePlugins: (PluginOption | PluginOption[])[] = [ + // have to + vue(), + // have to + vueJsx(), + ]; + + // vite-plugin-windicss + vitePlugins.push(windiCSS()); + + // vite-plugin-html + vitePlugins.push(configHtmlPlugin(viteEnv, isBuild)); + + // vite-plugin-mock + VITE_USE_MOCK && vitePlugins.push(configMockPlugin(isBuild, prodMock)); + + // vite-plugin-style-import + vitePlugins.push(configStyleImportPlugin(isBuild)); + + //vite-plugin-theme + vitePlugins.push(configThemePlugin()); + + // rollup-plugin-visualizer + vitePlugins.push(configVisualizerPlugin()); + + if (isBuild) { + // rollup-plugin-gzip + vitePlugins.push( + configCompressPlugin(VITE_BUILD_COMPRESS, VITE_BUILD_COMPRESS_DELETE_ORIGIN_FILE), + ); + } + + return vitePlugins; +} diff --git a/ui/build/vite/plugin/mock.ts b/ui/build/vite/plugin/mock.ts new file mode 100644 index 0000000..84bf144 --- /dev/null +++ b/ui/build/vite/plugin/mock.ts @@ -0,0 +1,19 @@ +/** + * Mock plugin for development and production. + * https://github.com/anncwb/vite-plugin-mock + */ +import { viteMockServe } from 'vite-plugin-mock'; + +export function configMockPlugin(isBuild: boolean, prodMock: boolean) { + return viteMockServe({ + ignore: /^\_/, + mockPath: 'mock', + localEnabled: !isBuild, + prodEnabled: isBuild && prodMock, + injectCode: ` + import { setupProdMockServer } from '../mock/_createProductionServer'; + + setupProdMockServer(); + `, + }); +} diff --git a/ui/build/vite/plugin/styleImport.ts b/ui/build/vite/plugin/styleImport.ts new file mode 100644 index 0000000..5217716 --- /dev/null +++ b/ui/build/vite/plugin/styleImport.ts @@ -0,0 +1,16 @@ +/** + * Introduces component library styles on demand. + * https://github.com/antfu/unplugin-vue-components + */ +import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'; + +import Components from 'unplugin-vue-components/vite'; + +export function configStyleImportPlugin(isBuild: boolean) { + if (!isBuild) { + return []; + } + return Components({ + resolvers: [AntDesignVueResolver()], + }); +} diff --git a/ui/build/vite/plugin/theme.ts b/ui/build/vite/plugin/theme.ts new file mode 100644 index 0000000..e7fc020 --- /dev/null +++ b/ui/build/vite/plugin/theme.ts @@ -0,0 +1,45 @@ +import type { Plugin } from 'vite'; +import path from 'path'; +import themePreprocessorPlugin from '@zougt/vite-plugin-theme-preprocessor'; + +export function configThemePlugin(): Plugin[] { + const includeStyles = { + '.ant-button-primary:hover, .ant-button-primary:focus': { + color: '#FFFFFF', + }, + }; + const plugin = [ + themePreprocessorPlugin({ + less: { + // 是否启用任意主题色模式,这里不启用 + arbitraryMode: false, + multipleScopeVars: [ + { + scopeName: 'theme-default', + path: path.resolve('src/styles/theme/theme-default.less'), + includeStyles, + }, + { + scopeName: 'theme-dark', + path: path.resolve('src/styles/theme/theme-dark.less'), + includeStyles, + }, + ], + includeStyleWithColors: [ + { + // color也可以是array,如 ["#ffffff","#000"] + color: '#ffffff', + }, + { + color: ['transparent', 'none'], + excludeSelectors: ['.ant-btn-link:hover, .ant-btn-link:focus, .ant-btn-link:active'], + }, + ], + // 在生产模式是否抽取独立的主题css文件,extract为true以下属性有效 + extract: false, + }, + }), + ]; + + return plugin as unknown as Plugin[]; +} diff --git a/ui/build/vite/plugin/visualizer.ts b/ui/build/vite/plugin/visualizer.ts new file mode 100644 index 0000000..e57e36b --- /dev/null +++ b/ui/build/vite/plugin/visualizer.ts @@ -0,0 +1,15 @@ +import { PluginOption } from 'vite'; +import visualizer from 'rollup-plugin-visualizer'; +import { isReportMode } from '../../utils'; + +export function configVisualizerPlugin() { + if (isReportMode()) { + return visualizer({ + filename: './node_modules/.cache/visualizer/stats.html', + open: true, + gzipSize: true, + brotliSize: true, + }) as PluginOption; + } + return []; +} diff --git a/ui/build/vite/proxy.ts b/ui/build/vite/proxy.ts new file mode 100644 index 0000000..dc23646 --- /dev/null +++ b/ui/build/vite/proxy.ts @@ -0,0 +1,34 @@ +/** + * Used to parse the .env.development proxy configuration + */ +import type { ProxyOptions } from 'vite'; + +type ProxyItem = [string, string]; + +type ProxyList = ProxyItem[]; + +type ProxyTargetList = Record string }>; + +const httpsRE = /^https:\/\//; + +/** + * Generate proxy + * @param list + */ +export function createProxy(list: ProxyList = []) { + const ret: ProxyTargetList = {}; + for (const [prefix, target] of list) { + const isHttps = httpsRE.test(target); + + // https://github.com/http-party/node-http-proxy#options + ret[prefix] = { + target: target, + changeOrigin: true, + ws: true, + rewrite: (path) => path.replace(new RegExp(`^${prefix}`), ''), + // https is require secure=false + ...(isHttps ? { secure: false } : {}), + }; + } + return ret; +} diff --git a/ui/commitlint.config.js b/ui/commitlint.config.js new file mode 100644 index 0000000..7189e8f --- /dev/null +++ b/ui/commitlint.config.js @@ -0,0 +1,57 @@ +module.exports = { + ignores: [(commit) => commit.includes('init')], + extends: ['@commitlint/config-conventional'], + parserPreset: { + parserOpts: { + headerPattern: /^(\w*|[\u4e00-\u9fa5]*)(?:[\(\(](.*)[\)\)])?[\:\:] (.*)/, + headerCorrespondence: ['type', 'scope', 'subject'], + referenceActions: [ + 'close', + 'closes', + 'closed', + 'fix', + 'fixes', + 'fixed', + 'resolve', + 'resolves', + 'resolved', + ], + issuePrefixes: ['#'], + noteKeywords: ['BREAKING CHANGE'], + fieldPattern: /^-(.*?)-$/, + revertPattern: /^Revert\s"([\s\S]*)"\s*This reverts commit (\w*)\./, + revertCorrespondence: ['header', 'hash'], + warn() {}, + mergePattern: null, + mergeCorrespondence: null, + }, + }, + rules: { + 'body-leading-blank': [2, 'always'], + 'footer-leading-blank': [1, 'always'], + 'header-max-length': [2, 'always', 108], + 'subject-empty': [2, 'never'], + 'type-empty': [2, 'never'], + 'type-enum': [ + 2, + 'always', + [ + 'feat', + 'fix', + 'perf', + 'style', + 'docs', + 'test', + 'refactor', + 'build', + 'ci', + 'chore', + 'revert', + 'wip', + 'workflow', + 'types', + 'release', + ], + ], + }, +}; diff --git a/ui/components.d.ts b/ui/components.d.ts new file mode 100644 index 0000000..e57f8f3 --- /dev/null +++ b/ui/components.d.ts @@ -0,0 +1,92 @@ +// generated by unplugin-vue-components +// We suggest you to commit this file into source control +// Read more: https://github.com/vuejs/core/pull/3399 +import '@vue/runtime-core' + +declare module '@vue/runtime-core' { + export interface GlobalComponents { + AAlert: typeof import('ant-design-vue/es')['Alert'] + AAvatar: typeof import('ant-design-vue/es')['Avatar'] + ABadge: typeof import('ant-design-vue/es')['Badge'] + ABreadcrumb: typeof import('ant-design-vue/es')['Breadcrumb'] + ABreadcrumbItem: typeof import('ant-design-vue/es')['BreadcrumbItem'] + AButton: typeof import('ant-design-vue/es')['Button'] + ACard: typeof import('ant-design-vue/es')['Card'] + ACheckbox: typeof import('ant-design-vue/es')['Checkbox'] + ACheckboxGroup: typeof import('ant-design-vue/es')['CheckboxGroup'] + ACol: typeof import('ant-design-vue/es')['Col'] + ADatePicker: typeof import('ant-design-vue/es')['DatePicker'] + ADescriptions: typeof import('ant-design-vue/es')['Descriptions'] + ADescriptionsItem: typeof import('ant-design-vue/es')['DescriptionsItem'] + ADivider: typeof import('ant-design-vue/es')['Divider'] + ADrawer: typeof import('ant-design-vue/es')['Drawer'] + ADropdown: typeof import('ant-design-vue/es')['Dropdown'] + AEmpty: typeof import('ant-design-vue/es')['Empty'] + AForm: typeof import('ant-design-vue/es')['Form'] + AFormItem: typeof import('ant-design-vue/es')['FormItem'] + AImage: typeof import('ant-design-vue/es')['Image'] + AInput: typeof import('ant-design-vue/es')['Input'] + AInputNumber: typeof import('ant-design-vue/es')['InputNumber'] + AInputPassword: typeof import('ant-design-vue/es')['InputPassword'] + AList: typeof import('ant-design-vue/es')['List'] + AListItem: typeof import('ant-design-vue/es')['ListItem'] + AListItemMeta: typeof import('ant-design-vue/es')['ListItemMeta'] + AMenu: typeof import('ant-design-vue/es')['Menu'] + AMenuItem: typeof import('ant-design-vue/es')['MenuItem'] + AModal: typeof import('ant-design-vue/es')['Modal'] + APopconfirm: typeof import('ant-design-vue/es')['Popconfirm'] + APopover: typeof import('ant-design-vue/es')['Popover'] + AppSearch: typeof import('./src/components/Application/search/AppSearch.vue')['default'] + AProgress: typeof import('ant-design-vue/es')['Progress'] + ARadio: typeof import('ant-design-vue/es')['Radio'] + ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup'] + AResult: typeof import('ant-design-vue/es')['Result'] + ARow: typeof import('ant-design-vue/es')['Row'] + ASelect: typeof import('ant-design-vue/es')['Select'] + ASkeleton: typeof import('ant-design-vue/es')['Skeleton'] + ASpace: typeof import('ant-design-vue/es')['Space'] + ASpin: typeof import('ant-design-vue/es')['Spin'] + AStep: typeof import('ant-design-vue/es')['Step'] + ASteps: typeof import('ant-design-vue/es')['Steps'] + ASwitch: typeof import('ant-design-vue/es')['Switch'] + ATable: typeof import('ant-design-vue/es')['Table'] + ATabPane: typeof import('ant-design-vue/es')['TabPane'] + ATabs: typeof import('ant-design-vue/es')['Tabs'] + ATag: typeof import('ant-design-vue/es')['Tag'] + ATextarea: typeof import('ant-design-vue/es')['Textarea'] + ATooltip: typeof import('ant-design-vue/es')['Tooltip'] + ATree: typeof import('ant-design-vue/es')['Tree'] + ATypographyText: typeof import('ant-design-vue/es')['TypographyText'] + ATypographyTitle: typeof import('ant-design-vue/es')['TypographyTitle'] + AUpload: typeof import('ant-design-vue/es')['Upload'] + Authority: typeof import('./src/components/Authority/src/Authority.vue')['default'] + BasicForm: typeof import('./src/components/Form/src/BasicForm.vue')['default'] + BasicModal: typeof import('./src/components/Modal/src/basicModal.vue')['default'] + BasicSelect: typeof import('./src/components/Select/src/BasicSelect.vue')['default'] + BasicUpload: typeof import('./src/components/Upload/src/BasicUpload.vue')['default'] + ColumnSetting: typeof import('./src/components/Table/src/components/settings/ColumnSetting.vue')['default'] + CountTo: typeof import('./src/components/CountTo/CountTo.vue')['default'] + Cropper: typeof import('./src/components/Cropper/src/Cropper.vue')['default'] + CropperImage: typeof import('./src/components/Cropper/src/CropperImage.vue')['default'] + CropperModal: typeof import('./src/components/Cropper/src/CropperModal.vue')['default'] + EditableCell: typeof import('./src/components/Table/src/components/editable/EditableCell.vue')['default'] + ImportExcel: typeof import('./src/components/Excel/src/ImportExcel.vue')['default'] + Intensity: typeof import('./src/components/Password/src/components/Intensity.vue')['default'] + Lockscreen: typeof import('./src/components/Lockscreen/Lockscreen.vue')['default'] + ModalClose: typeof import('./src/components/Modal/src/components/ModalClose.vue')['default'] + PageFooter: typeof import('./src/components/Page/src/PageFooter.vue')['default'] + PageWrapper: typeof import('./src/components/Page/src/PageWrapper.vue')['default'] + Password: typeof import('./src/components/Password/src/Password.vue')['default'] + PreviewModal: typeof import('./src/components/Upload/src/PreviewModal.vue')['default'] + Qrcode: typeof import('./src/components/Qrcode/src/Qrcode.vue')['default'] + Recharge: typeof import('./src/components/Lockscreen/Recharge.vue')['default'] + Region: typeof import('./src/components/Region/src/Region.vue')['default'] + Render: typeof import('./src/components/Render/src/Render.vue')['default'] + RouterLink: typeof import('vue-router')['RouterLink'] + RouterView: typeof import('vue-router')['RouterView'] + Table: typeof import('./src/components/Table/src/Table.vue')['default'] + TableAction: typeof import('./src/components/Table/src/components/TableAction.vue')['default'] + } +} + +export {} diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..717a689 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,129 @@ + + + + + + + + + <%= title %> + + + + +
+ +
+
+ +
+
+
+ + + + diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..74d6f27 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,149 @@ +{ + "name": "djangoadmin", + "version": "3.0.0", + "description": "FastAPI+AntDesign版", + "license": "Apache-2.0", + "author": { + "name": "FastAPI+AntDesign版", + "email": "djangoadmin@126.com", + "url": "https://www.djangoadmin.cn" + }, + "private": true, + "scripts": { + "bootstrap": "pnpm install", + "serve": "pnpm run dev", + "dev": "vite", + "build": "cross-env NODE_ENV=production vite build && esno ./build/script/postBuild.ts", + "build:test": "cross-env vite build --mode test && esno ./build/script/postBuild.ts", + "build:prod": "cross-env NODE_ENV=production vite build && esno ./build/script/postBuild.ts", + "build:no-cache": "pnpm clean:cache && pnpm run build", + "report": "cross-env REPORT=true pnpm run build", + "type:check": "vue-tsc --noEmit --skipLibCheck", + "preview": "pnpm run build && vite preview", + "preview:dist": "vite preview", + "clean:cache": "rimraf node_modules/.cache/ && rimraf node_modules/.vite", + "clean:lib": "rimraf node_modules", + "lint:eslint": "eslint \"{src,mock}/**/*.{vue,ts,tsx}\" --fix", + "lint:prettier": "prettier --write --loglevel warn \"src/**/*.{js,json,tsx,css,less,scss,vue,html,md}\"", + "lint:stylelint": "stylelint --fix \"**/*.{vue,less,postcss,css,scss}\" --cache --cache-location node_modules/.cache/stylelint/", + "lint:lint-staged": "lint-staged -c ./.husky/lintstagedrc.js", + "lint:pretty": "pretty-quick --staged", + "test prod gzip": "http-server dist --cors --gzip -c-1", + "reinstall": "rimraf pnpm-lock.yaml && rimraf package.lock.json && rimraf node_modules && pnpm run bootstrap", + "build typecheck": "vuedx-typecheck . && vite build", + "deploy": "gh-pages -d dist" + }, + "dependencies": { + "@ant-design-vue/pro-layout": "^3.2.5", + "@ant-design/icons-vue": "^6.1.0", + "@tinymce/tinymce-vue": "^5.1.1", + "@vueup/vue-quill": "1.0.0-beta.8", + "@vueuse/core": "^8.9.4", + "ali-oss": "^6.22.0", + "ant-design-vue": "3.2.20", + "axios": "^0.27.2", + "cropperjs": "^1.6.2", + "dayjs": "^1.11.11", + "echarts": "^5.5.1", + "lodash-es": "^4.17.21", + "mockjs": "^1.1.0", + "moment": "^2.30.1", + "nprogress": "^0.2.0", + "perfect-scrollbar": "^1.5.5", + "pinia": "^2.1.7", + "print-js": "^1.6.0", + "qrcode": "^1.5.3", + "qs": "^6.12.2", + "tinymce": "5.10.3", + "vue": "3.3.4", + "vue-cropper": "0.5.8", + "vue-router": "^4.4.0", + "vue-types": "^4.2.1", + "vuedraggable": "^4.1.0", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "@commitlint/cli": "^17.8.1", + "@commitlint/config-conventional": "^17.8.1", + "@types/element-resize-detector": "^1.1.6", + "@types/lodash-es": "^4.17.12", + "@types/node": "^17.0.45", + "@types/qrcode": "^1.5.5", + "@typescript-eslint/eslint-plugin": "^5.62.0", + "@typescript-eslint/parser": "^5.62.0", + "@vitejs/plugin-vue": "^2.3.4", + "@vitejs/plugin-vue-jsx": "^1.3.10", + "@vue/compiler-sfc": "^3.4.31", + "@zougt/some-loader-utils": "^1.4.3", + "@zougt/vite-plugin-theme-preprocessor": "^1.4.8", + "autoprefixer": "^10.4.19", + "color": "^4.2.3", + "colors": "^1.4.0", + "commitizen": "^4.3.0", + "core-js": "^3.37.1", + "cross-env": "^7.0.3", + "dotenv": "^16.4.5", + "eslint": "^8.57.0", + "eslint-config-prettier": "^8.10.0", + "eslint-define-config": "^1.24.1", + "eslint-plugin-prettier": "^4.2.1", + "eslint-plugin-vue": "^9.27.0", + "esno": "^0.16.3", + "fs-extra": "^10.1.0", + "gh-pages": "^4.0.0", + "husky": "^8.0.3", + "install": "^0.13.0", + "less": "^4.2.0", + "less-loader": "^11.1.4", + "lint-staged": "^12.5.0", + "pnpm": "^7.33.7", + "postcss": "^8.4.39", + "prettier": "^2.8.8", + "pretty-quick": "^3.3.1", + "rimraf": "^5.0.7", + "rollup-plugin-visualizer": "^5.12.0", + "stylelint": "^14.16.1", + "stylelint-config-prettier": "^9.0.5", + "stylelint-config-standard": "^25.0.0", + "stylelint-order": "^5.0.0", + "stylelint-scss": "^4.7.0", + "ts-node": "^10.9.2", + "typescript": "^4.9.5", + "unplugin-vue-components": "^0.19.9", + "vite": "^5.3.3", + "vite-plugin-compression": "^0.5.1", + "vite-plugin-html": "^3.2.2", + "vite-plugin-mock": "^2.9.8", + "vite-plugin-windicss": "^1.9.3", + "vue-eslint-parser": "^9.4.3", + "vue-tsc": "^0.34.17" + }, + "lint-staged": { + "*.{vue,js,ts,tsx}": "eslint --fix" + }, + "config": { + "commitizen": { + "path": "./node_modules/cz-customizable" + } + }, + "keywords": [ + "vue", + "antdesign", + "vue3", + "ts", + "tsx", + "admin", + "typescript" + ], + "engines": { + "node": ">= 16.9.0" + }, + "pnpm": { + "peerDependencyRules": { + "ignoreMissing": [ + "rollup", + "webpack" + ] + } + } +} diff --git a/ui/postcss.config.js b/ui/postcss.config.js new file mode 100644 index 0000000..a47ef4f --- /dev/null +++ b/ui/postcss.config.js @@ -0,0 +1,5 @@ +module.exports = { + plugins: { + autoprefixer: {}, + }, +}; diff --git a/ui/prettier.config.js b/ui/prettier.config.js new file mode 100644 index 0000000..a6f192e --- /dev/null +++ b/ui/prettier.config.js @@ -0,0 +1,10 @@ +module.exports = { + printWidth: 100, + semi: true, + vueIndentScriptAndStyle: true, + singleQuote: true, + trailingComma: 'all', + proseWrap: 'never', + htmlWhitespaceSensitivity: 'strict', + endOfLine: 'auto', +}; \ No newline at end of file diff --git a/ui/public/favicon.ico b/ui/public/favicon.ico new file mode 100644 index 0000000..5f4e189 Binary files /dev/null and b/ui/public/favicon.ico differ diff --git a/ui/public/tinymce/jquery.tinymce.js b/ui/public/tinymce/jquery.tinymce.js new file mode 100644 index 0000000..46e24d2 --- /dev/null +++ b/ui/public/tinymce/jquery.tinymce.js @@ -0,0 +1,91 @@ +/** + * Copyright (c) Tiny Technologies, Inc. All rights reserved. + * Licensed under the LGPL or a commercial license. + * For LGPL see License.txt in the project root for license information. + * For commercial licenses see https://www.tiny.cloud/ + */ +/** + * Jquery integration plugin. + * + * @class tinymce.core.JqueryIntegration + * @private + */ +!function(){function f(){ +// Reference to tinymce needs to be lazily evaluated since tinymce +// might be loaded through the compressor or other means +return d.tinymce}var p,c,u,s=[],d="undefined"!=typeof global?global:window,m=d.jQuery;m.fn.tinymce=function(o){var e,t,i,n,l=this,r=""; +// No match then just ignore the call +return l.length? +// Get editor instance +o?(l.css("visibility","hidden"), +// Load TinyMCE on demand, if we need to +d.tinymce||c||!(e=o.script_url)? +// Delay the init call until tinymce is loaded +1===c?s.push(a):a():(c=1,t=e.substring(0,e.lastIndexOf("/")), +// Check if it's a dev/src version they want to load then +// make sure that all plugins, themes etc are loaded in source mode as well +-1!=e.indexOf(".min")&&(r=".min"), +// Setup tinyMCEPreInit object this will later be used by the TinyMCE +// core script to locate other resources like CSS files, dialogs etc +// You can also predefined a tinyMCEPreInit object and then it will use that instead +d.tinymce=d.tinyMCEPreInit||{base:t,suffix:r}, +// url contains gzip then we assume it's a compressor +-1!=e.indexOf("gzip")&&(i=o.language||"en",e=e+(/\?/.test(e)?"&":"?")+"js=true&core=true&suffix="+escape(r)+"&themes="+escape(o.theme||"modern")+"&plugins="+escape(o.plugins||"")+"&languages="+(i||""), +// Check if compressor script is already loaded otherwise setup a basic one +d.tinyMCE_GZ||(d.tinyMCE_GZ={start:function(){function n(e){f().ScriptLoader.markDone(f().baseURI.toAbsolute(e))} +// Add core languages +n("langs/"+i+".js"), +// Add themes with languages +n("themes/"+o.theme+"/theme"+r+".js"),n("themes/"+o.theme+"/langs/"+i+".js"), +// Add plugins with languages +m.each(o.plugins.split(","),function(e,t){t&&(n("plugins/"+t+"/plugin"+r+".js"),n("plugins/"+t+"/langs/"+i+".js"))})},end:function(){}})),(n=document.createElement("script")).type="text/javascript",n.onload=n.onreadystatechange=function(e){e=e||window.event,2===c||"load"!=e.type&&!/complete|loaded/.test(n.readyState)||(f().dom.Event.domLoaded=1,c=2, +// Execute callback after mainscript has been loaded and before the initialization occurs +o.script_loaded&&o.script_loaded(),a(),m.each(s,function(e,t){t()}))},n.src=e,document.body.appendChild(n)),l):f()?f().get(l[0].id):null:l;function a(){var a=[],c=0; +// Apply patches to the jQuery object, only once +u||(v(),u=!0), +// Create an editor instance for each matched node +l.each(function(e,t){var n,i=t.id,r=o.oninit; +// Generate unique id for target element if needed +i||(t.id=i=f().DOM.uniqueId()), +// Only init the editor once +f().get(i)||( +// Create editor instance and render it +n=f().createEditor(i,o),a.push(n),n.on("init",function(){var e,t=r;l.css("visibility",""), +// Run this if the oninit setting is defined +// this logic will fire the oninit callback ones each +// matched editor instance is initialized +r&&++c==a.length&&("string"==typeof t&&(e=-1===t.indexOf(".")?null:f().resolve(t.replace(/\.\w+$/,"")),t=f().resolve(t)), +// Call the oninit function with the object +t.apply(e||f(),a))}))}), +// Render the editor instances in a separate loop since we +// need to have the full editors array used in the onInit calls +m.each(a,function(e,t){t.render()})}}, +// Add :tinymce pseudo selector this will select elements that has been converted into editor instances +// it's now possible to use things like $('*:tinymce') to get all TinyMCE bound elements. +m.extend(m.expr[":"],{tinymce:function(e){var t;return!!(e.id&&"tinymce"in d&&(t=f().get(e.id))&&t.editorManager===f())}}); +// This function patches internal jQuery functions so that if +// you for example remove an div element containing an editor it's +// automatically destroyed by the TinyMCE API +var v=function(){function r(e){ +// If the function is remove +"remove"===e&&this.each(function(e,t){var n=u(t);n&&n.remove()}),this.find("span.mceEditor,div.mceEditor").each(function(e,t){var n=f().get(t.id.replace(/_parent$/,""));n&&n.remove()})}function o(i){var e,t=this; +// Handle set value +/*jshint eqnull:true */if(null!=i)r.call(t), +// Saves the contents before get/set value of textarea/div +t.each(function(e,t){var n;(n=f().get(t.id))&&n.setContent(i)});else if(0])*>/g,""):n.getContent({save:!0}):a.apply(m(t),r)}),i}}), +// Makes it possible to use $('#id').append("content"); to append contents to the TinyMCE editor iframe +m.each(["append","prepend"],function(e,t){var n=s[t]=m.fn[t],r="prepend"===t;m.fn[t]=function(i){var e=this;return l(e)?i!==p?("string"==typeof i&&e.filter(":tinymce").each(function(e,t){var n=u(t);n&&n.setContent(r?i+n.getContent():n.getContent()+i)}),n.apply(e.not(":tinymce"),arguments),e):void 0:n.apply(e,arguments)}}), +// Makes sure that the editor instance gets properly destroyed when the parent element is removed +m.each(["remove","replaceWith","replaceAll","empty"],function(e,t){var n=s[t]=m.fn[t];m.fn[t]=function(){return r.call(this,t),n.apply(this,arguments)}}),s.attr=m.fn.attr, +// Makes sure that $('#tinymce_id').attr('value') gets the editors current HTML contents +m.fn.attr=function(e,t){var n=this,i=arguments;if(!e||"value"!==e||!l(n))return s.attr.apply(n,i);if(t!==p)return o.call(n.filter(":tinymce"),t),s.attr.apply(n.not(":tinymce"),i),n;// return original set for chaining +var r=n[0],a=u(r);return a?a.getContent({save:!0}):s.attr.apply(m(r),i)}}}(); \ No newline at end of file diff --git a/ui/public/tinymce/jquery.tinymce.min.js b/ui/public/tinymce/jquery.tinymce.min.js new file mode 100644 index 0000000..46e24d2 --- /dev/null +++ b/ui/public/tinymce/jquery.tinymce.min.js @@ -0,0 +1,91 @@ +/** + * Copyright (c) Tiny Technologies, Inc. All rights reserved. + * Licensed under the LGPL or a commercial license. + * For LGPL see License.txt in the project root for license information. + * For commercial licenses see https://www.tiny.cloud/ + */ +/** + * Jquery integration plugin. + * + * @class tinymce.core.JqueryIntegration + * @private + */ +!function(){function f(){ +// Reference to tinymce needs to be lazily evaluated since tinymce +// might be loaded through the compressor or other means +return d.tinymce}var p,c,u,s=[],d="undefined"!=typeof global?global:window,m=d.jQuery;m.fn.tinymce=function(o){var e,t,i,n,l=this,r=""; +// No match then just ignore the call +return l.length? +// Get editor instance +o?(l.css("visibility","hidden"), +// Load TinyMCE on demand, if we need to +d.tinymce||c||!(e=o.script_url)? +// Delay the init call until tinymce is loaded +1===c?s.push(a):a():(c=1,t=e.substring(0,e.lastIndexOf("/")), +// Check if it's a dev/src version they want to load then +// make sure that all plugins, themes etc are loaded in source mode as well +-1!=e.indexOf(".min")&&(r=".min"), +// Setup tinyMCEPreInit object this will later be used by the TinyMCE +// core script to locate other resources like CSS files, dialogs etc +// You can also predefined a tinyMCEPreInit object and then it will use that instead +d.tinymce=d.tinyMCEPreInit||{base:t,suffix:r}, +// url contains gzip then we assume it's a compressor +-1!=e.indexOf("gzip")&&(i=o.language||"en",e=e+(/\?/.test(e)?"&":"?")+"js=true&core=true&suffix="+escape(r)+"&themes="+escape(o.theme||"modern")+"&plugins="+escape(o.plugins||"")+"&languages="+(i||""), +// Check if compressor script is already loaded otherwise setup a basic one +d.tinyMCE_GZ||(d.tinyMCE_GZ={start:function(){function n(e){f().ScriptLoader.markDone(f().baseURI.toAbsolute(e))} +// Add core languages +n("langs/"+i+".js"), +// Add themes with languages +n("themes/"+o.theme+"/theme"+r+".js"),n("themes/"+o.theme+"/langs/"+i+".js"), +// Add plugins with languages +m.each(o.plugins.split(","),function(e,t){t&&(n("plugins/"+t+"/plugin"+r+".js"),n("plugins/"+t+"/langs/"+i+".js"))})},end:function(){}})),(n=document.createElement("script")).type="text/javascript",n.onload=n.onreadystatechange=function(e){e=e||window.event,2===c||"load"!=e.type&&!/complete|loaded/.test(n.readyState)||(f().dom.Event.domLoaded=1,c=2, +// Execute callback after mainscript has been loaded and before the initialization occurs +o.script_loaded&&o.script_loaded(),a(),m.each(s,function(e,t){t()}))},n.src=e,document.body.appendChild(n)),l):f()?f().get(l[0].id):null:l;function a(){var a=[],c=0; +// Apply patches to the jQuery object, only once +u||(v(),u=!0), +// Create an editor instance for each matched node +l.each(function(e,t){var n,i=t.id,r=o.oninit; +// Generate unique id for target element if needed +i||(t.id=i=f().DOM.uniqueId()), +// Only init the editor once +f().get(i)||( +// Create editor instance and render it +n=f().createEditor(i,o),a.push(n),n.on("init",function(){var e,t=r;l.css("visibility",""), +// Run this if the oninit setting is defined +// this logic will fire the oninit callback ones each +// matched editor instance is initialized +r&&++c==a.length&&("string"==typeof t&&(e=-1===t.indexOf(".")?null:f().resolve(t.replace(/\.\w+$/,"")),t=f().resolve(t)), +// Call the oninit function with the object +t.apply(e||f(),a))}))}), +// Render the editor instances in a separate loop since we +// need to have the full editors array used in the onInit calls +m.each(a,function(e,t){t.render()})}}, +// Add :tinymce pseudo selector this will select elements that has been converted into editor instances +// it's now possible to use things like $('*:tinymce') to get all TinyMCE bound elements. +m.extend(m.expr[":"],{tinymce:function(e){var t;return!!(e.id&&"tinymce"in d&&(t=f().get(e.id))&&t.editorManager===f())}}); +// This function patches internal jQuery functions so that if +// you for example remove an div element containing an editor it's +// automatically destroyed by the TinyMCE API +var v=function(){function r(e){ +// If the function is remove +"remove"===e&&this.each(function(e,t){var n=u(t);n&&n.remove()}),this.find("span.mceEditor,div.mceEditor").each(function(e,t){var n=f().get(t.id.replace(/_parent$/,""));n&&n.remove()})}function o(i){var e,t=this; +// Handle set value +/*jshint eqnull:true */if(null!=i)r.call(t), +// Saves the contents before get/set value of textarea/div +t.each(function(e,t){var n;(n=f().get(t.id))&&n.setContent(i)});else if(0])*>/g,""):n.getContent({save:!0}):a.apply(m(t),r)}),i}}), +// Makes it possible to use $('#id').append("content"); to append contents to the TinyMCE editor iframe +m.each(["append","prepend"],function(e,t){var n=s[t]=m.fn[t],r="prepend"===t;m.fn[t]=function(i){var e=this;return l(e)?i!==p?("string"==typeof i&&e.filter(":tinymce").each(function(e,t){var n=u(t);n&&n.setContent(r?i+n.getContent():n.getContent()+i)}),n.apply(e.not(":tinymce"),arguments),e):void 0:n.apply(e,arguments)}}), +// Makes sure that the editor instance gets properly destroyed when the parent element is removed +m.each(["remove","replaceWith","replaceAll","empty"],function(e,t){var n=s[t]=m.fn[t];m.fn[t]=function(){return r.call(this,t),n.apply(this,arguments)}}),s.attr=m.fn.attr, +// Makes sure that $('#tinymce_id').attr('value') gets the editors current HTML contents +m.fn.attr=function(e,t){var n=this,i=arguments;if(!e||"value"!==e||!l(n))return s.attr.apply(n,i);if(t!==p)return o.call(n.filter(":tinymce"),t),s.attr.apply(n.not(":tinymce"),i),n;// return original set for chaining +var r=n[0],a=u(r);return a?a.getContent({save:!0}):s.attr.apply(m(r),i)}}}(); \ No newline at end of file diff --git a/ui/public/tinymce/skins/content/dark/content.css b/ui/public/tinymce/skins/content/dark/content.css new file mode 100644 index 0000000..bae7923 --- /dev/null +++ b/ui/public/tinymce/skins/content/dark/content.css @@ -0,0 +1,72 @@ +/** + * Copyright (c) Tiny Technologies, Inc. All rights reserved. + * Licensed under the LGPL or a commercial license. + * For LGPL see License.txt in the project root for license information. + * For commercial licenses see https://www.tiny.cloud/ + */ +body { + background-color: #2f3742; + color: #dfe0e4; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + line-height: 1.4; + margin: 1rem; +} +a { + color: #4099ff; +} +table { + border-collapse: collapse; +} +/* Apply a default padding if legacy cellpadding attribute is missing */ +table:not([cellpadding]) th, +table:not([cellpadding]) td { + padding: 0.4rem; +} +/* Set default table styles if a table has a positive border attribute + and no inline css */ +table[border]:not([border="0"]):not([style*="border-width"]) th, +table[border]:not([border="0"]):not([style*="border-width"]) td { + border-width: 1px; +} +/* Set default table styles if a table has a positive border attribute + and no inline css */ +table[border]:not([border="0"]):not([style*="border-style"]) th, +table[border]:not([border="0"]):not([style*="border-style"]) td { + border-style: solid; +} +/* Set default table styles if a table has a positive border attribute + and no inline css */ +table[border]:not([border="0"]):not([style*="border-color"]) th, +table[border]:not([border="0"]):not([style*="border-color"]) td { + border-color: #6d737b; +} +figure { + display: table; + margin: 1rem auto; +} +figure figcaption { + color: #8a8f97; + display: block; + margin-top: 0.25rem; + text-align: center; +} +hr { + border-color: #6d737b; + border-style: solid; + border-width: 1px 0 0 0; +} +code { + background-color: #6d737b; + border-radius: 3px; + padding: 0.1rem 0.2rem; +} +.mce-content-body:not([dir=rtl]) blockquote { + border-left: 2px solid #6d737b; + margin-left: 1.5rem; + padding-left: 1rem; +} +.mce-content-body[dir=rtl] blockquote { + border-right: 2px solid #6d737b; + margin-right: 1.5rem; + padding-right: 1rem; +} diff --git a/ui/public/tinymce/skins/content/dark/content.min.css b/ui/public/tinymce/skins/content/dark/content.min.css new file mode 100644 index 0000000..07d40c2 --- /dev/null +++ b/ui/public/tinymce/skins/content/dark/content.min.css @@ -0,0 +1,7 @@ +/** + * Copyright (c) Tiny Technologies, Inc. All rights reserved. + * Licensed under the LGPL or a commercial license. + * For LGPL see License.txt in the project root for license information. + * For commercial licenses see https://www.tiny.cloud/ + */ +body{background-color:#2f3742;color:#dfe0e4;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif;line-height:1.4;margin:1rem}a{color:#4099ff}table{border-collapse:collapse}table:not([cellpadding]) td,table:not([cellpadding]) th{padding:.4rem}table[border]:not([border="0"]):not([style*=border-width]) td,table[border]:not([border="0"]):not([style*=border-width]) th{border-width:1px}table[border]:not([border="0"]):not([style*=border-style]) td,table[border]:not([border="0"]):not([style*=border-style]) th{border-style:solid}table[border]:not([border="0"]):not([style*=border-color]) td,table[border]:not([border="0"]):not([style*=border-color]) th{border-color:#6d737b}figure{display:table;margin:1rem auto}figure figcaption{color:#8a8f97;display:block;margin-top:.25rem;text-align:center}hr{border-color:#6d737b;border-style:solid;border-width:1px 0 0 0}code{background-color:#6d737b;border-radius:3px;padding:.1rem .2rem}.mce-content-body:not([dir=rtl]) blockquote{border-left:2px solid #6d737b;margin-left:1.5rem;padding-left:1rem}.mce-content-body[dir=rtl] blockquote{border-right:2px solid #6d737b;margin-right:1.5rem;padding-right:1rem} diff --git a/ui/public/tinymce/skins/content/default/content.css b/ui/public/tinymce/skins/content/default/content.css new file mode 100644 index 0000000..1dad697 --- /dev/null +++ b/ui/public/tinymce/skins/content/default/content.css @@ -0,0 +1,68 @@ +/** + * Copyright (c) Tiny Technologies, Inc. All rights reserved. + * Licensed under the LGPL or a commercial license. + * For LGPL see License.txt in the project root for license information. + * For commercial licenses see https://www.tiny.cloud/ + */ +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + line-height: 1.4; + margin: 1rem; + font-size:14px +} +table { + border-collapse: collapse; +} +/* Apply a default padding if legacy cellpadding attribute is missing */ +table:not([cellpadding]) th, +table:not([cellpadding]) td { + padding: 0.4rem; +} +/* Set default table styles if a table has a positive border attribute + and no inline css */ +table[border]:not([border="0"]):not([style*="border-width"]) th, +table[border]:not([border="0"]):not([style*="border-width"]) td { + border-width: 1px; +} +/* Set default table styles if a table has a positive border attribute + and no inline css */ +table[border]:not([border="0"]):not([style*="border-style"]) th, +table[border]:not([border="0"]):not([style*="border-style"]) td { + border-style: solid; +} +/* Set default table styles if a table has a positive border attribute + and no inline css */ +table[border]:not([border="0"]):not([style*="border-color"]) th, +table[border]:not([border="0"]):not([style*="border-color"]) td { + border-color: #ccc; +} +figure { + display: table; + margin: 1rem auto; +} +figure figcaption { + color: #999; + display: block; + margin-top: 0.25rem; + text-align: center; +} +hr { + border-color: #ccc; + border-style: solid; + border-width: 1px 0 0 0; +} +code { + background-color: #e8e8e8; + border-radius: 3px; + padding: 0.1rem 0.2rem; +} +.mce-content-body:not([dir=rtl]) blockquote { + border-left: 2px solid #ccc; + margin-left: 1.5rem; + padding-left: 1rem; +} +.mce-content-body[dir=rtl] blockquote { + border-right: 2px solid #ccc; + margin-right: 1.5rem; + padding-right: 1rem; +} diff --git a/ui/public/tinymce/skins/content/default/content.min.css b/ui/public/tinymce/skins/content/default/content.min.css new file mode 100644 index 0000000..29cd987 --- /dev/null +++ b/ui/public/tinymce/skins/content/default/content.min.css @@ -0,0 +1,7 @@ +/** + * Copyright (c) Tiny Technologies, Inc. All rights reserved. + * Licensed under the LGPL or a commercial license. + * For LGPL see License.txt in the project root for license information. + * For commercial licenses see https://www.tiny.cloud/ + */ +body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif;line-height:1.4;margin:1rem}table{border-collapse:collapse}table:not([cellpadding]) td,table:not([cellpadding]) th{padding:.4rem}table[border]:not([border="0"]):not([style*=border-width]) td,table[border]:not([border="0"]):not([style*=border-width]) th{border-width:1px}table[border]:not([border="0"]):not([style*=border-style]) td,table[border]:not([border="0"]):not([style*=border-style]) th{border-style:solid}table[border]:not([border="0"]):not([style*=border-color]) td,table[border]:not([border="0"]):not([style*=border-color]) th{border-color:#ccc}figure{display:table;margin:1rem auto}figure figcaption{color:#999;display:block;margin-top:.25rem;text-align:center}hr{border-color:#ccc;border-style:solid;border-width:1px 0 0 0}code{background-color:#e8e8e8;border-radius:3px;padding:.1rem .2rem}.mce-content-body:not([dir=rtl]) blockquote{border-left:2px solid #ccc;margin-left:1.5rem;padding-left:1rem}.mce-content-body[dir=rtl] blockquote{border-right:2px solid #ccc;margin-right:1.5rem;padding-right:1rem} diff --git a/ui/public/tinymce/skins/content/document/content.css b/ui/public/tinymce/skins/content/document/content.css new file mode 100644 index 0000000..75f637a --- /dev/null +++ b/ui/public/tinymce/skins/content/document/content.css @@ -0,0 +1,72 @@ +/** + * Copyright (c) Tiny Technologies, Inc. All rights reserved. + * Licensed under the LGPL or a commercial license. + * For LGPL see License.txt in the project root for license information. + * For commercial licenses see https://www.tiny.cloud/ + */ +@media screen { + html { + background: #f4f4f4; + min-height: 100%; + } +} +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; +} +@media screen { + body { + background-color: #fff; + box-shadow: 0 0 4px rgba(0, 0, 0, 0.15); + box-sizing: border-box; + margin: 1rem auto 0; + max-width: 820px; + min-height: calc(100vh - 1rem); + padding: 4rem 6rem 6rem 6rem; + } +} +table { + border-collapse: collapse; +} +/* Apply a default padding if legacy cellpadding attribute is missing */ +table:not([cellpadding]) th, +table:not([cellpadding]) td { + padding: 0.4rem; +} +/* Set default table styles if a table has a positive border attribute + and no inline css */ +table[border]:not([border="0"]):not([style*="border-width"]) th, +table[border]:not([border="0"]):not([style*="border-width"]) td { + border-width: 1px; +} +/* Set default table styles if a table has a positive border attribute + and no inline css */ +table[border]:not([border="0"]):not([style*="border-style"]) th, +table[border]:not([border="0"]):not([style*="border-style"]) td { + border-style: solid; +} +/* Set default table styles if a table has a positive border attribute + and no inline css */ +table[border]:not([border="0"]):not([style*="border-color"]) th, +table[border]:not([border="0"]):not([style*="border-color"]) td { + border-color: #ccc; +} +figure figcaption { + color: #999; + margin-top: 0.25rem; + text-align: center; +} +hr { + border-color: #ccc; + border-style: solid; + border-width: 1px 0 0 0; +} +.mce-content-body:not([dir=rtl]) blockquote { + border-left: 2px solid #ccc; + margin-left: 1.5rem; + padding-left: 1rem; +} +.mce-content-body[dir=rtl] blockquote { + border-right: 2px solid #ccc; + margin-right: 1.5rem; + padding-right: 1rem; +} diff --git a/ui/public/tinymce/skins/content/document/content.min.css b/ui/public/tinymce/skins/content/document/content.min.css new file mode 100644 index 0000000..a1feef4 --- /dev/null +++ b/ui/public/tinymce/skins/content/document/content.min.css @@ -0,0 +1,7 @@ +/** + * Copyright (c) Tiny Technologies, Inc. All rights reserved. + * Licensed under the LGPL or a commercial license. + * For LGPL see License.txt in the project root for license information. + * For commercial licenses see https://www.tiny.cloud/ + */ +@media screen{html{background:#f4f4f4;min-height:100%}}body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif}@media screen{body{background-color:#fff;box-shadow:0 0 4px rgba(0,0,0,.15);box-sizing:border-box;margin:1rem auto 0;max-width:820px;min-height:calc(100vh - 1rem);padding:4rem 6rem 6rem 6rem}}table{border-collapse:collapse}table:not([cellpadding]) td,table:not([cellpadding]) th{padding:.4rem}table[border]:not([border="0"]):not([style*=border-width]) td,table[border]:not([border="0"]):not([style*=border-width]) th{border-width:1px}table[border]:not([border="0"]):not([style*=border-style]) td,table[border]:not([border="0"]):not([style*=border-style]) th{border-style:solid}table[border]:not([border="0"]):not([style*=border-color]) td,table[border]:not([border="0"]):not([style*=border-color]) th{border-color:#ccc}figure figcaption{color:#999;margin-top:.25rem;text-align:center}hr{border-color:#ccc;border-style:solid;border-width:1px 0 0 0}.mce-content-body:not([dir=rtl]) blockquote{border-left:2px solid #ccc;margin-left:1.5rem;padding-left:1rem}.mce-content-body[dir=rtl] blockquote{border-right:2px solid #ccc;margin-right:1.5rem;padding-right:1rem} diff --git a/ui/public/tinymce/skins/content/writer/content.css b/ui/public/tinymce/skins/content/writer/content.css new file mode 100644 index 0000000..ceee359 --- /dev/null +++ b/ui/public/tinymce/skins/content/writer/content.css @@ -0,0 +1,68 @@ +/** + * Copyright (c) Tiny Technologies, Inc. All rights reserved. + * Licensed under the LGPL or a commercial license. + * For LGPL see License.txt in the project root for license information. + * For commercial licenses see https://www.tiny.cloud/ + */ +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + line-height: 1.4; + margin: 1rem auto; + max-width: 900px; +} +table { + border-collapse: collapse; +} +/* Apply a default padding if legacy cellpadding attribute is missing */ +table:not([cellpadding]) th, +table:not([cellpadding]) td { + padding: 0.4rem; +} +/* Set default table styles if a table has a positive border attribute + and no inline css */ +table[border]:not([border="0"]):not([style*="border-width"]) th, +table[border]:not([border="0"]):not([style*="border-width"]) td { + border-width: 1px; +} +/* Set default table styles if a table has a positive border attribute + and no inline css */ +table[border]:not([border="0"]):not([style*="border-style"]) th, +table[border]:not([border="0"]):not([style*="border-style"]) td { + border-style: solid; +} +/* Set default table styles if a table has a positive border attribute + and no inline css */ +table[border]:not([border="0"]):not([style*="border-color"]) th, +table[border]:not([border="0"]):not([style*="border-color"]) td { + border-color: #ccc; +} +figure { + display: table; + margin: 1rem auto; +} +figure figcaption { + color: #999; + display: block; + margin-top: 0.25rem; + text-align: center; +} +hr { + border-color: #ccc; + border-style: solid; + border-width: 1px 0 0 0; +} +code { + background-color: #e8e8e8; + border-radius: 3px; + padding: 0.1rem 0.2rem; +} +.mce-content-body:not([dir=rtl]) blockquote { + border-left: 2px solid #ccc; + margin-left: 1.5rem; + padding-left: 1rem; +} +.mce-content-body[dir=rtl] blockquote { + border-right: 2px solid #ccc; + margin-right: 1.5rem; + padding-right: 1rem; +} diff --git a/ui/public/tinymce/skins/content/writer/content.min.css b/ui/public/tinymce/skins/content/writer/content.min.css new file mode 100644 index 0000000..0d8f5d3 --- /dev/null +++ b/ui/public/tinymce/skins/content/writer/content.min.css @@ -0,0 +1,7 @@ +/** + * Copyright (c) Tiny Technologies, Inc. All rights reserved. + * Licensed under the LGPL or a commercial license. + * For LGPL see License.txt in the project root for license information. + * For commercial licenses see https://www.tiny.cloud/ + */ +body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif;line-height:1.4;margin:1rem auto;max-width:900px}table{border-collapse:collapse}table:not([cellpadding]) td,table:not([cellpadding]) th{padding:.4rem}table[border]:not([border="0"]):not([style*=border-width]) td,table[border]:not([border="0"]):not([style*=border-width]) th{border-width:1px}table[border]:not([border="0"]):not([style*=border-style]) td,table[border]:not([border="0"]):not([style*=border-style]) th{border-style:solid}table[border]:not([border="0"]):not([style*=border-color]) td,table[border]:not([border="0"]):not([style*=border-color]) th{border-color:#ccc}figure{display:table;margin:1rem auto}figure figcaption{color:#999;display:block;margin-top:.25rem;text-align:center}hr{border-color:#ccc;border-style:solid;border-width:1px 0 0 0}code{background-color:#e8e8e8;border-radius:3px;padding:.1rem .2rem}.mce-content-body:not([dir=rtl]) blockquote{border-left:2px solid #ccc;margin-left:1.5rem;padding-left:1rem}.mce-content-body[dir=rtl] blockquote{border-right:2px solid #ccc;margin-right:1.5rem;padding-right:1rem} diff --git a/ui/public/tinymce/skins/ui/oxide-dark/content.css b/ui/public/tinymce/skins/ui/oxide-dark/content.css new file mode 100644 index 0000000..9c0e3a8 --- /dev/null +++ b/ui/public/tinymce/skins/ui/oxide-dark/content.css @@ -0,0 +1,714 @@ +/** + * Copyright (c) Tiny Technologies, Inc. All rights reserved. + * Licensed under the LGPL or a commercial license. + * For LGPL see License.txt in the project root for license information. + * For commercial licenses see https://www.tiny.cloud/ + */ +.mce-content-body .mce-item-anchor { + background: transparent url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'8'%20height%3D'12'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20d%3D'M0%200L8%200%208%2012%204.09117821%209%200%2012z'%20fill%3D%22%23cccccc%22%2F%3E%3C%2Fsvg%3E%0A") no-repeat center; + cursor: default; + display: inline-block; + height: 12px !important; + padding: 0 2px; + -webkit-user-modify: read-only; + -moz-user-modify: read-only; + -webkit-user-select: all; + -moz-user-select: all; + -ms-user-select: all; + user-select: all; + width: 8px !important; +} +.mce-content-body .mce-item-anchor[data-mce-selected] { + outline-offset: 1px; +} +.tox-comments-visible .tox-comment { + background-color: #fff0b7; +} +.tox-comments-visible .tox-comment--active { + background-color: #ffe168; +} +.tox-checklist > li:not(.tox-checklist--hidden) { + list-style: none; + margin: 0.25em 0; +} +.tox-checklist > li:not(.tox-checklist--hidden)::before { + content: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-unchecked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2215%22%20height%3D%2215%22%20x%3D%22.5%22%20y%3D%22.5%22%20fill-rule%3D%22nonzero%22%20stroke%3D%22%236d737b%22%20rx%3D%222%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A"); + cursor: pointer; + height: 1em; + margin-left: -1.5em; + margin-top: 0.125em; + position: absolute; + width: 1em; +} +.tox-checklist li:not(.tox-checklist--hidden).tox-checklist--checked::before { + content: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-checked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2216%22%20height%3D%2216%22%20fill%3D%22%234099FF%22%20fill-rule%3D%22nonzero%22%20rx%3D%222%22%2F%3E%3Cpath%20id%3D%22Path%22%20fill%3D%22%23FFF%22%20fill-rule%3D%22nonzero%22%20d%3D%22M11.5703186%2C3.14417309%20C11.8516238%2C2.73724603%2012.4164781%2C2.62829933%2012.83558%2C2.89774797%20C13.260121%2C3.17069355%2013.3759736%2C3.72932262%2013.0909105%2C4.14168582%20L7.7580587%2C11.8560195%20C7.43776896%2C12.3193404%206.76483983%2C12.3852142%206.35607322%2C11.9948725%20L3.02491697%2C8.8138662%20C2.66090143%2C8.46625845%202.65798871%2C7.89594698%203.01850234%2C7.54483354%20C3.373942%2C7.19866177%203.94940006%2C7.19592841%204.30829608%2C7.5386474%20L6.85276923%2C9.9684299%20L11.5703186%2C3.14417309%20Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A"); +} +[dir=rtl] .tox-checklist > li:not(.tox-checklist--hidden)::before { + margin-left: 0; + margin-right: -1.5em; +} +/* stylelint-disable */ +/* http://prismjs.com/ */ +/** + * Dracula Theme originally by Zeno Rocha [@zenorocha] + * https://draculatheme.com/ + * + * Ported for PrismJS by Albert Vallverdu [@byverdu] + */ +code[class*="language-"], +pre[class*="language-"] { + color: #f8f8f2; + background: none; + text-shadow: 0 1px rgba(0, 0, 0, 0.3); + font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + word-wrap: normal; + line-height: 1.5; + -moz-tab-size: 4; + tab-size: 4; + -webkit-hyphens: none; + -ms-hyphens: none; + hyphens: none; +} +/* Code blocks */ +pre[class*="language-"] { + padding: 1em; + margin: 0.5em 0; + overflow: auto; + border-radius: 0.3em; +} +:not(pre) > code[class*="language-"], +pre[class*="language-"] { + background: #282a36; +} +/* Inline code */ +:not(pre) > code[class*="language-"] { + padding: 0.1em; + border-radius: 0.3em; + white-space: normal; +} +.token.comment, +.token.prolog, +.token.doctype, +.token.cdata { + color: #6272a4; +} +.token.punctuation { + color: #f8f8f2; +} +.namespace { + opacity: 0.7; +} +.token.property, +.token.tag, +.token.constant, +.token.symbol, +.token.deleted { + color: #ff79c6; +} +.token.boolean, +.token.number { + color: #bd93f9; +} +.token.selector, +.token.attr-name, +.token.string, +.token.char, +.token.builtin, +.token.inserted { + color: #50fa7b; +} +.token.operator, +.token.entity, +.token.url, +.language-css .token.string, +.style .token.string, +.token.variable { + color: #f8f8f2; +} +.token.atrule, +.token.attr-value, +.token.function, +.token.class-name { + color: #f1fa8c; +} +.token.keyword { + color: #8be9fd; +} +.token.regex, +.token.important { + color: #ffb86c; +} +.token.important, +.token.bold { + font-weight: bold; +} +.token.italic { + font-style: italic; +} +.token.entity { + cursor: help; +} +/* stylelint-enable */ +.mce-content-body { + overflow-wrap: break-word; + word-wrap: break-word; +} +.mce-content-body .mce-visual-caret { + background-color: black; + background-color: currentColor; + position: absolute; +} +.mce-content-body .mce-visual-caret-hidden { + display: none; +} +.mce-content-body *[data-mce-caret] { + left: -1000px; + margin: 0; + padding: 0; + position: absolute; + right: auto; + top: 0; +} +.mce-content-body .mce-offscreen-selection { + left: -2000000px; + max-width: 1000000px; + position: absolute; +} +.mce-content-body *[contentEditable=false] { + cursor: default; +} +.mce-content-body *[contentEditable=true] { + cursor: text; +} +.tox-cursor-format-painter { + cursor: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%3E%0A%20%20%3Cg%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%0A%20%20%20%20%3Cpath%20fill%3D%22%23000%22%20fill-rule%3D%22nonzero%22%20d%3D%22M15%2C6%20C15%2C5.45%2014.55%2C5%2014%2C5%20L6%2C5%20C5.45%2C5%205%2C5.45%205%2C6%20L5%2C10%20C5%2C10.55%205.45%2C11%206%2C11%20L14%2C11%20C14.55%2C11%2015%2C10.55%2015%2C10%20L15%2C9%20L16%2C9%20L16%2C12%20L9%2C12%20L9%2C19%20C9%2C19.55%209.45%2C20%2010%2C20%20L11%2C20%20C11.55%2C20%2012%2C19.55%2012%2C19%20L12%2C14%20L18%2C14%20L18%2C7%20L15%2C7%20L15%2C6%20Z%22%2F%3E%0A%20%20%20%20%3Cpath%20fill%3D%22%23000%22%20fill-rule%3D%22nonzero%22%20d%3D%22M1%2C1%20L8.25%2C1%20C8.66421356%2C1%209%2C1.33578644%209%2C1.75%20L9%2C1.75%20C9%2C2.16421356%208.66421356%2C2.5%208.25%2C2.5%20L2.5%2C2.5%20L2.5%2C8.25%20C2.5%2C8.66421356%202.16421356%2C9%201.75%2C9%20L1.75%2C9%20C1.33578644%2C9%201%2C8.66421356%201%2C8.25%20L1%2C1%20Z%22%2F%3E%0A%20%20%3C%2Fg%3E%0A%3C%2Fsvg%3E%0A"), default; +} +.mce-content-body figure.align-left { + float: left; +} +.mce-content-body figure.align-right { + float: right; +} +.mce-content-body figure.image.align-center { + display: table; + margin-left: auto; + margin-right: auto; +} +.mce-preview-object { + border: 1px solid gray; + display: inline-block; + line-height: 0; + margin: 0 2px 0 2px; + position: relative; +} +.mce-preview-object .mce-shim { + background: url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7); + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; +} +.mce-preview-object[data-mce-selected="2"] .mce-shim { + display: none; +} +.mce-object { + background: transparent url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M4%203h16a1%201%200%200%201%201%201v16a1%201%200%200%201-1%201H4a1%201%200%200%201-1-1V4a1%201%200%200%201%201-1zm1%202v14h14V5H5zm4.79%202.565l5.64%204.028a.5.5%200%200%201%200%20.814l-5.64%204.028a.5.5%200%200%201-.79-.407V7.972a.5.5%200%200%201%20.79-.407z%22%20fill%3D%22%23cccccc%22%2F%3E%3C%2Fsvg%3E%0A") no-repeat center; + border: 1px dashed #aaa; +} +.mce-pagebreak { + border: 1px dashed #aaa; + cursor: default; + display: block; + height: 5px; + margin-top: 15px; + page-break-before: always; + width: 100%; +} +@media print { + .mce-pagebreak { + border: 0; + } +} +.tiny-pageembed .mce-shim { + background: url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7); + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; +} +.tiny-pageembed[data-mce-selected="2"] .mce-shim { + display: none; +} +.tiny-pageembed { + display: inline-block; + position: relative; +} +.tiny-pageembed--21by9, +.tiny-pageembed--16by9, +.tiny-pageembed--4by3, +.tiny-pageembed--1by1 { + display: block; + overflow: hidden; + padding: 0; + position: relative; + width: 100%; +} +.tiny-pageembed--21by9 { + padding-top: 42.857143%; +} +.tiny-pageembed--16by9 { + padding-top: 56.25%; +} +.tiny-pageembed--4by3 { + padding-top: 75%; +} +.tiny-pageembed--1by1 { + padding-top: 100%; +} +.tiny-pageembed--21by9 iframe, +.tiny-pageembed--16by9 iframe, +.tiny-pageembed--4by3 iframe, +.tiny-pageembed--1by1 iframe { + border: 0; + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; +} +.mce-content-body[data-mce-placeholder] { + position: relative; +} +.mce-content-body[data-mce-placeholder]:not(.mce-visualblocks)::before { + color: rgba(34, 47, 62, 0.7); + content: attr(data-mce-placeholder); + position: absolute; +} +.mce-content-body:not([dir=rtl])[data-mce-placeholder]:not(.mce-visualblocks)::before { + left: 1px; +} +.mce-content-body[dir=rtl][data-mce-placeholder]:not(.mce-visualblocks)::before { + right: 1px; +} +.mce-content-body div.mce-resizehandle { + background-color: #4099ff; + border-color: #4099ff; + border-style: solid; + border-width: 1px; + box-sizing: border-box; + height: 10px; + position: absolute; + width: 10px; + z-index: 1298; +} +.mce-content-body div.mce-resizehandle:hover { + background-color: #4099ff; +} +.mce-content-body div.mce-resizehandle:nth-of-type(1) { + cursor: nwse-resize; +} +.mce-content-body div.mce-resizehandle:nth-of-type(2) { + cursor: nesw-resize; +} +.mce-content-body div.mce-resizehandle:nth-of-type(3) { + cursor: nwse-resize; +} +.mce-content-body div.mce-resizehandle:nth-of-type(4) { + cursor: nesw-resize; +} +.mce-content-body .mce-resize-backdrop { + z-index: 10000; +} +.mce-content-body .mce-clonedresizable { + cursor: default; + opacity: 0.5; + outline: 1px dashed black; + position: absolute; + z-index: 10001; +} +.mce-content-body .mce-clonedresizable.mce-resizetable-columns th, +.mce-content-body .mce-clonedresizable.mce-resizetable-columns td { + border: 0; +} +.mce-content-body .mce-resize-helper { + background: #555; + background: rgba(0, 0, 0, 0.75); + border: 1px; + border-radius: 3px; + color: white; + display: none; + font-family: sans-serif; + font-size: 12px; + line-height: 14px; + margin: 5px 10px; + padding: 5px; + position: absolute; + white-space: nowrap; + z-index: 10002; +} +.tox-rtc-user-selection { + position: relative; +} +.tox-rtc-user-cursor { + bottom: 0; + cursor: default; + position: absolute; + top: 0; + width: 2px; +} +.tox-rtc-user-cursor::before { + background-color: inherit; + border-radius: 50%; + content: ''; + display: block; + height: 8px; + position: absolute; + right: -3px; + top: -3px; + width: 8px; +} +.tox-rtc-user-cursor:hover::after { + background-color: inherit; + border-radius: 100px; + box-sizing: border-box; + color: #fff; + content: attr(data-user); + display: block; + font-size: 12px; + font-weight: bold; + left: -5px; + min-height: 8px; + min-width: 8px; + padding: 0 12px; + position: absolute; + top: -11px; + white-space: nowrap; + z-index: 1000; +} +.tox-rtc-user-selection--1 .tox-rtc-user-cursor { + background-color: #2dc26b; +} +.tox-rtc-user-selection--2 .tox-rtc-user-cursor { + background-color: #e03e2d; +} +.tox-rtc-user-selection--3 .tox-rtc-user-cursor { + background-color: #f1c40f; +} +.tox-rtc-user-selection--4 .tox-rtc-user-cursor { + background-color: #3598db; +} +.tox-rtc-user-selection--5 .tox-rtc-user-cursor { + background-color: #b96ad9; +} +.tox-rtc-user-selection--6 .tox-rtc-user-cursor { + background-color: #e67e23; +} +.tox-rtc-user-selection--7 .tox-rtc-user-cursor { + background-color: #aaa69d; +} +.tox-rtc-user-selection--8 .tox-rtc-user-cursor { + background-color: #f368e0; +} +.tox-rtc-remote-image { + background: #eaeaea url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2236%22%20height%3D%2212%22%20viewBox%3D%220%200%2036%2012%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Ccircle%20cx%3D%226%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%20%20%3Ccircle%20cx%3D%2218%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20begin%3D%22.33s%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%20%20%3Ccircle%20cx%3D%2230%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20begin%3D%22.66s%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%3C%2Fsvg%3E%0A") no-repeat center center; + border: 1px solid #ccc; + min-height: 240px; + min-width: 320px; +} +.mce-match-marker { + background: #aaa; + color: #fff; +} +.mce-match-marker-selected { + background: #39f; + color: #fff; +} +.mce-match-marker-selected::-moz-selection { + background: #39f; + color: #fff; +} +.mce-match-marker-selected::selection { + background: #39f; + color: #fff; +} +.mce-content-body img[data-mce-selected], +.mce-content-body video[data-mce-selected], +.mce-content-body audio[data-mce-selected], +.mce-content-body object[data-mce-selected], +.mce-content-body embed[data-mce-selected], +.mce-content-body table[data-mce-selected] { + outline: 3px solid #4099ff; +} +.mce-content-body hr[data-mce-selected] { + outline: 3px solid #4099ff; + outline-offset: 1px; +} +.mce-content-body *[contentEditable=false] *[contentEditable=true]:focus { + outline: 3px solid #4099ff; +} +.mce-content-body *[contentEditable=false] *[contentEditable=true]:hover { + outline: 3px solid #4099ff; +} +.mce-content-body *[contentEditable=false][data-mce-selected] { + cursor: not-allowed; + outline: 3px solid #4099ff; +} +.mce-content-body.mce-content-readonly *[contentEditable=true]:focus, +.mce-content-body.mce-content-readonly *[contentEditable=true]:hover { + outline: none; +} +.mce-content-body *[data-mce-selected="inline-boundary"] { + background-color: #4099ff; +} +.mce-content-body .mce-edit-focus { + outline: 3px solid #4099ff; +} +.mce-content-body td[data-mce-selected], +.mce-content-body th[data-mce-selected] { + position: relative; +} +.mce-content-body td[data-mce-selected]::-moz-selection, +.mce-content-body th[data-mce-selected]::-moz-selection { + background: none; +} +.mce-content-body td[data-mce-selected]::selection, +.mce-content-body th[data-mce-selected]::selection { + background: none; +} +.mce-content-body td[data-mce-selected] *, +.mce-content-body th[data-mce-selected] * { + outline: none; + -webkit-touch-callout: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} +.mce-content-body td[data-mce-selected]::after, +.mce-content-body th[data-mce-selected]::after { + background-color: rgba(180, 215, 255, 0.7); + border: 1px solid transparent; + bottom: -1px; + content: ''; + left: -1px; + mix-blend-mode: lighten; + position: absolute; + right: -1px; + top: -1px; +} +@media screen and (-ms-high-contrast: active), (-ms-high-contrast: none) { + .mce-content-body td[data-mce-selected]::after, + .mce-content-body th[data-mce-selected]::after { + border-color: rgba(0, 84, 180, 0.7); + } +} +.mce-content-body img::-moz-selection { + background: none; +} +.mce-content-body img::selection { + background: none; +} +.ephox-snooker-resizer-bar { + background-color: #4099ff; + opacity: 0; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} +.ephox-snooker-resizer-cols { + cursor: col-resize; +} +.ephox-snooker-resizer-rows { + cursor: row-resize; +} +.ephox-snooker-resizer-bar.ephox-snooker-resizer-bar-dragging { + opacity: 1; +} +.mce-spellchecker-word { + background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'4'%20height%3D'4'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20stroke%3D'%23ff0000'%20fill%3D'none'%20stroke-linecap%3D'round'%20stroke-opacity%3D'.75'%20d%3D'M0%203L2%201%204%203'%2F%3E%3C%2Fsvg%3E%0A"); + background-position: 0 calc(100% + 1px); + background-repeat: repeat-x; + background-size: auto 6px; + cursor: default; + height: 2rem; +} +.mce-spellchecker-grammar { + background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'4'%20height%3D'4'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20stroke%3D'%2300A835'%20fill%3D'none'%20stroke-linecap%3D'round'%20d%3D'M0%203L2%201%204%203'%2F%3E%3C%2Fsvg%3E%0A"); + background-position: 0 calc(100% + 1px); + background-repeat: repeat-x; + background-size: auto 6px; + cursor: default; +} +.mce-toc { + border: 1px solid gray; +} +.mce-toc h2 { + margin: 4px; +} +.mce-toc li { + list-style-type: none; +} +table[style*="border-width: 0px"], +.mce-item-table:not([border]), +.mce-item-table[border="0"], +table[style*="border-width: 0px"] td, +.mce-item-table:not([border]) td, +.mce-item-table[border="0"] td, +table[style*="border-width: 0px"] th, +.mce-item-table:not([border]) th, +.mce-item-table[border="0"] th, +table[style*="border-width: 0px"] caption, +.mce-item-table:not([border]) caption, +.mce-item-table[border="0"] caption { + border: 1px dashed #bbb; +} +.mce-visualblocks p, +.mce-visualblocks h1, +.mce-visualblocks h2, +.mce-visualblocks h3, +.mce-visualblocks h4, +.mce-visualblocks h5, +.mce-visualblocks h6, +.mce-visualblocks div:not([data-mce-bogus]), +.mce-visualblocks section, +.mce-visualblocks article, +.mce-visualblocks blockquote, +.mce-visualblocks address, +.mce-visualblocks pre, +.mce-visualblocks figure, +.mce-visualblocks figcaption, +.mce-visualblocks hgroup, +.mce-visualblocks aside, +.mce-visualblocks ul, +.mce-visualblocks ol, +.mce-visualblocks dl { + background-repeat: no-repeat; + border: 1px dashed #bbb; + margin-left: 3px; + padding-top: 10px; +} +.mce-visualblocks p { + background-image: url(data:image/gif;base64,R0lGODlhCQAJAJEAAAAAAP///7u7u////yH5BAEAAAMALAAAAAAJAAkAAAIQnG+CqCN/mlyvsRUpThG6AgA7); +} +.mce-visualblocks h1 { + background-image: url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybGu1JuxHoAfRNRW3TWXyF2YiRUAOw==); +} +.mce-visualblocks h2 { + background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8Hybbx4oOuqgTynJd6bGlWg3DkJzoaUAAAOw==); +} +.mce-visualblocks h3 { + background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIZjI8Hybbx4oOuqgTynJf2Ln2NOHpQpmhAAQA7); +} +.mce-visualblocks h4 { + background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxInR0zqeAdhtJlXwV1oCll2HaWgAAOw==); +} +.mce-visualblocks h5 { + background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxIoiuwjane4iq5GlW05GgIkIZUAAAOw==); +} +.mce-visualblocks h6 { + background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxIoiuwjan04jep1iZ1XRlAo5bVgAAOw==); +} +.mce-visualblocks div:not([data-mce-bogus]) { + background-image: url(data:image/gif;base64,R0lGODlhEgAKAIABALu7u////yH5BAEAAAEALAAAAAASAAoAAAIfjI9poI0cgDywrhuxfbrzDEbQM2Ei5aRjmoySW4pAAQA7); +} +.mce-visualblocks section { + background-image: url(data:image/gif;base64,R0lGODlhKAAKAIABALu7u////yH5BAEAAAEALAAAAAAoAAoAAAI5jI+pywcNY3sBWHdNrplytD2ellDeSVbp+GmWqaDqDMepc8t17Y4vBsK5hDyJMcI6KkuYU+jpjLoKADs=); +} +.mce-visualblocks article { + background-image: url(data:image/gif;base64,R0lGODlhKgAKAIABALu7u////yH5BAEAAAEALAAAAAAqAAoAAAI6jI+pywkNY3wG0GBvrsd2tXGYSGnfiF7ikpXemTpOiJScasYoDJJrjsG9gkCJ0ag6KhmaIe3pjDYBBQA7); +} +.mce-visualblocks blockquote { + background-image: url(data:image/gif;base64,R0lGODlhPgAKAIABALu7u////yH5BAEAAAEALAAAAAA+AAoAAAJPjI+py+0Knpz0xQDyuUhvfoGgIX5iSKZYgq5uNL5q69asZ8s5rrf0yZmpNkJZzFesBTu8TOlDVAabUyatguVhWduud3EyiUk45xhTTgMBBQA7); +} +.mce-visualblocks address { + background-image: url(data:image/gif;base64,R0lGODlhLQAKAIABALu7u////yH5BAEAAAEALAAAAAAtAAoAAAI/jI+pywwNozSP1gDyyZcjb3UaRpXkWaXmZW4OqKLhBmLs+K263DkJK7OJeifh7FicKD9A1/IpGdKkyFpNmCkAADs=); +} +.mce-visualblocks pre { + background-image: url(data:image/gif;base64,R0lGODlhFQAKAIABALu7uwAAACH5BAEAAAEALAAAAAAVAAoAAAIjjI+ZoN0cgDwSmnpz1NCueYERhnibZVKLNnbOq8IvKpJtVQAAOw==); +} +.mce-visualblocks figure { + background-image: url(data:image/gif;base64,R0lGODlhJAAKAIAAALu7u////yH5BAEAAAEALAAAAAAkAAoAAAI0jI+py+2fwAHUSFvD3RlvG4HIp4nX5JFSpnZUJ6LlrM52OE7uSWosBHScgkSZj7dDKnWAAgA7); +} +.mce-visualblocks figcaption { + border: 1px dashed #bbb; +} +.mce-visualblocks hgroup { + background-image: url(data:image/gif;base64,R0lGODlhJwAKAIABALu7uwAAACH5BAEAAAEALAAAAAAnAAoAAAI3jI+pywYNI3uB0gpsRtt5fFnfNZaVSYJil4Wo03Hv6Z62uOCgiXH1kZIIJ8NiIxRrAZNMZAtQAAA7); +} +.mce-visualblocks aside { + background-image: url(data:image/gif;base64,R0lGODlhHgAKAIABAKqqqv///yH5BAEAAAEALAAAAAAeAAoAAAItjI+pG8APjZOTzgtqy7I3f1yehmQcFY4WKZbqByutmW4aHUd6vfcVbgudgpYCADs=); +} +.mce-visualblocks ul { + background-image: url(data:image/gif;base64,R0lGODlhDQAKAIAAALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybGuYnqUVSjvw26DzzXiqIDlVwAAOw==); +} +.mce-visualblocks ol { + background-image: url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybH6HHt0qourxC6CvzXieHyeWQAAOw==); +} +.mce-visualblocks dl { + background-image: url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybEOnmOvUoWznTqeuEjNSCqeGRUAOw==); +} +.mce-visualblocks:not([dir=rtl]) p, +.mce-visualblocks:not([dir=rtl]) h1, +.mce-visualblocks:not([dir=rtl]) h2, +.mce-visualblocks:not([dir=rtl]) h3, +.mce-visualblocks:not([dir=rtl]) h4, +.mce-visualblocks:not([dir=rtl]) h5, +.mce-visualblocks:not([dir=rtl]) h6, +.mce-visualblocks:not([dir=rtl]) div:not([data-mce-bogus]), +.mce-visualblocks:not([dir=rtl]) section, +.mce-visualblocks:not([dir=rtl]) article, +.mce-visualblocks:not([dir=rtl]) blockquote, +.mce-visualblocks:not([dir=rtl]) address, +.mce-visualblocks:not([dir=rtl]) pre, +.mce-visualblocks:not([dir=rtl]) figure, +.mce-visualblocks:not([dir=rtl]) figcaption, +.mce-visualblocks:not([dir=rtl]) hgroup, +.mce-visualblocks:not([dir=rtl]) aside, +.mce-visualblocks:not([dir=rtl]) ul, +.mce-visualblocks:not([dir=rtl]) ol, +.mce-visualblocks:not([dir=rtl]) dl { + margin-left: 3px; +} +.mce-visualblocks[dir=rtl] p, +.mce-visualblocks[dir=rtl] h1, +.mce-visualblocks[dir=rtl] h2, +.mce-visualblocks[dir=rtl] h3, +.mce-visualblocks[dir=rtl] h4, +.mce-visualblocks[dir=rtl] h5, +.mce-visualblocks[dir=rtl] h6, +.mce-visualblocks[dir=rtl] div:not([data-mce-bogus]), +.mce-visualblocks[dir=rtl] section, +.mce-visualblocks[dir=rtl] article, +.mce-visualblocks[dir=rtl] blockquote, +.mce-visualblocks[dir=rtl] address, +.mce-visualblocks[dir=rtl] pre, +.mce-visualblocks[dir=rtl] figure, +.mce-visualblocks[dir=rtl] figcaption, +.mce-visualblocks[dir=rtl] hgroup, +.mce-visualblocks[dir=rtl] aside, +.mce-visualblocks[dir=rtl] ul, +.mce-visualblocks[dir=rtl] ol, +.mce-visualblocks[dir=rtl] dl { + background-position-x: right; + margin-right: 3px; +} +.mce-nbsp, +.mce-shy { + background: #aaa; +} +.mce-shy::after { + content: '-'; +} +body { + font-family: sans-serif; +} +table { + border-collapse: collapse; +} diff --git a/ui/public/tinymce/skins/ui/oxide-dark/content.inline.css b/ui/public/tinymce/skins/ui/oxide-dark/content.inline.css new file mode 100644 index 0000000..8e7521d --- /dev/null +++ b/ui/public/tinymce/skins/ui/oxide-dark/content.inline.css @@ -0,0 +1,726 @@ +/** + * Copyright (c) Tiny Technologies, Inc. All rights reserved. + * Licensed under the LGPL or a commercial license. + * For LGPL see License.txt in the project root for license information. + * For commercial licenses see https://www.tiny.cloud/ + */ +.mce-content-body .mce-item-anchor { + background: transparent url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'8'%20height%3D'12'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20d%3D'M0%200L8%200%208%2012%204.09117821%209%200%2012z'%2F%3E%3C%2Fsvg%3E%0A") no-repeat center; + cursor: default; + display: inline-block; + height: 12px !important; + padding: 0 2px; + -webkit-user-modify: read-only; + -moz-user-modify: read-only; + -webkit-user-select: all; + -moz-user-select: all; + -ms-user-select: all; + user-select: all; + width: 8px !important; +} +.mce-content-body .mce-item-anchor[data-mce-selected] { + outline-offset: 1px; +} +.tox-comments-visible .tox-comment { + background-color: #fff0b7; +} +.tox-comments-visible .tox-comment--active { + background-color: #ffe168; +} +.tox-checklist > li:not(.tox-checklist--hidden) { + list-style: none; + margin: 0.25em 0; +} +.tox-checklist > li:not(.tox-checklist--hidden)::before { + content: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-unchecked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2215%22%20height%3D%2215%22%20x%3D%22.5%22%20y%3D%22.5%22%20fill-rule%3D%22nonzero%22%20stroke%3D%22%234C4C4C%22%20rx%3D%222%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A"); + cursor: pointer; + height: 1em; + margin-left: -1.5em; + margin-top: 0.125em; + position: absolute; + width: 1em; +} +.tox-checklist li:not(.tox-checklist--hidden).tox-checklist--checked::before { + content: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-checked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2216%22%20height%3D%2216%22%20fill%3D%22%234099FF%22%20fill-rule%3D%22nonzero%22%20rx%3D%222%22%2F%3E%3Cpath%20id%3D%22Path%22%20fill%3D%22%23FFF%22%20fill-rule%3D%22nonzero%22%20d%3D%22M11.5703186%2C3.14417309%20C11.8516238%2C2.73724603%2012.4164781%2C2.62829933%2012.83558%2C2.89774797%20C13.260121%2C3.17069355%2013.3759736%2C3.72932262%2013.0909105%2C4.14168582%20L7.7580587%2C11.8560195%20C7.43776896%2C12.3193404%206.76483983%2C12.3852142%206.35607322%2C11.9948725%20L3.02491697%2C8.8138662%20C2.66090143%2C8.46625845%202.65798871%2C7.89594698%203.01850234%2C7.54483354%20C3.373942%2C7.19866177%203.94940006%2C7.19592841%204.30829608%2C7.5386474%20L6.85276923%2C9.9684299%20L11.5703186%2C3.14417309%20Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A"); +} +[dir=rtl] .tox-checklist > li:not(.tox-checklist--hidden)::before { + margin-left: 0; + margin-right: -1.5em; +} +/* stylelint-disable */ +/* http://prismjs.com/ */ +/** + * prism.js default theme for JavaScript, CSS and HTML + * Based on dabblet (http://dabblet.com) + * @author Lea Verou + */ +code[class*="language-"], +pre[class*="language-"] { + color: black; + background: none; + text-shadow: 0 1px white; + font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; + font-size: 1em; + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + word-wrap: normal; + line-height: 1.5; + -moz-tab-size: 4; + tab-size: 4; + -webkit-hyphens: none; + -ms-hyphens: none; + hyphens: none; +} +pre[class*="language-"]::-moz-selection, +pre[class*="language-"] ::-moz-selection, +code[class*="language-"]::-moz-selection, +code[class*="language-"] ::-moz-selection { + text-shadow: none; + background: #b3d4fc; +} +pre[class*="language-"]::selection, +pre[class*="language-"] ::selection, +code[class*="language-"]::selection, +code[class*="language-"] ::selection { + text-shadow: none; + background: #b3d4fc; +} +@media print { + code[class*="language-"], + pre[class*="language-"] { + text-shadow: none; + } +} +/* Code blocks */ +pre[class*="language-"] { + padding: 1em; + margin: 0.5em 0; + overflow: auto; +} +:not(pre) > code[class*="language-"], +pre[class*="language-"] { + background: #f5f2f0; +} +/* Inline code */ +:not(pre) > code[class*="language-"] { + padding: 0.1em; + border-radius: 0.3em; + white-space: normal; +} +.token.comment, +.token.prolog, +.token.doctype, +.token.cdata { + color: slategray; +} +.token.punctuation { + color: #999; +} +.namespace { + opacity: 0.7; +} +.token.property, +.token.tag, +.token.boolean, +.token.number, +.token.constant, +.token.symbol, +.token.deleted { + color: #905; +} +.token.selector, +.token.attr-name, +.token.string, +.token.char, +.token.builtin, +.token.inserted { + color: #690; +} +.token.operator, +.token.entity, +.token.url, +.language-css .token.string, +.style .token.string { + color: #9a6e3a; + background: hsla(0, 0%, 100%, 0.5); +} +.token.atrule, +.token.attr-value, +.token.keyword { + color: #07a; +} +.token.function, +.token.class-name { + color: #DD4A68; +} +.token.regex, +.token.important, +.token.variable { + color: #e90; +} +.token.important, +.token.bold { + font-weight: bold; +} +.token.italic { + font-style: italic; +} +.token.entity { + cursor: help; +} +/* stylelint-enable */ +.mce-content-body { + overflow-wrap: break-word; + word-wrap: break-word; +} +.mce-content-body .mce-visual-caret { + background-color: black; + background-color: currentColor; + position: absolute; +} +.mce-content-body .mce-visual-caret-hidden { + display: none; +} +.mce-content-body *[data-mce-caret] { + left: -1000px; + margin: 0; + padding: 0; + position: absolute; + right: auto; + top: 0; +} +.mce-content-body .mce-offscreen-selection { + left: -2000000px; + max-width: 1000000px; + position: absolute; +} +.mce-content-body *[contentEditable=false] { + cursor: default; +} +.mce-content-body *[contentEditable=true] { + cursor: text; +} +.tox-cursor-format-painter { + cursor: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%3E%0A%20%20%3Cg%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%0A%20%20%20%20%3Cpath%20fill%3D%22%23000%22%20fill-rule%3D%22nonzero%22%20d%3D%22M15%2C6%20C15%2C5.45%2014.55%2C5%2014%2C5%20L6%2C5%20C5.45%2C5%205%2C5.45%205%2C6%20L5%2C10%20C5%2C10.55%205.45%2C11%206%2C11%20L14%2C11%20C14.55%2C11%2015%2C10.55%2015%2C10%20L15%2C9%20L16%2C9%20L16%2C12%20L9%2C12%20L9%2C19%20C9%2C19.55%209.45%2C20%2010%2C20%20L11%2C20%20C11.55%2C20%2012%2C19.55%2012%2C19%20L12%2C14%20L18%2C14%20L18%2C7%20L15%2C7%20L15%2C6%20Z%22%2F%3E%0A%20%20%20%20%3Cpath%20fill%3D%22%23000%22%20fill-rule%3D%22nonzero%22%20d%3D%22M1%2C1%20L8.25%2C1%20C8.66421356%2C1%209%2C1.33578644%209%2C1.75%20L9%2C1.75%20C9%2C2.16421356%208.66421356%2C2.5%208.25%2C2.5%20L2.5%2C2.5%20L2.5%2C8.25%20C2.5%2C8.66421356%202.16421356%2C9%201.75%2C9%20L1.75%2C9%20C1.33578644%2C9%201%2C8.66421356%201%2C8.25%20L1%2C1%20Z%22%2F%3E%0A%20%20%3C%2Fg%3E%0A%3C%2Fsvg%3E%0A"), default; +} +.mce-content-body figure.align-left { + float: left; +} +.mce-content-body figure.align-right { + float: right; +} +.mce-content-body figure.image.align-center { + display: table; + margin-left: auto; + margin-right: auto; +} +.mce-preview-object { + border: 1px solid gray; + display: inline-block; + line-height: 0; + margin: 0 2px 0 2px; + position: relative; +} +.mce-preview-object .mce-shim { + background: url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7); + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; +} +.mce-preview-object[data-mce-selected="2"] .mce-shim { + display: none; +} +.mce-object { + background: transparent url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M4%203h16a1%201%200%200%201%201%201v16a1%201%200%200%201-1%201H4a1%201%200%200%201-1-1V4a1%201%200%200%201%201-1zm1%202v14h14V5H5zm4.79%202.565l5.64%204.028a.5.5%200%200%201%200%20.814l-5.64%204.028a.5.5%200%200%201-.79-.407V7.972a.5.5%200%200%201%20.79-.407z%22%2F%3E%3C%2Fsvg%3E%0A") no-repeat center; + border: 1px dashed #aaa; +} +.mce-pagebreak { + border: 1px dashed #aaa; + cursor: default; + display: block; + height: 5px; + margin-top: 15px; + page-break-before: always; + width: 100%; +} +@media print { + .mce-pagebreak { + border: 0; + } +} +.tiny-pageembed .mce-shim { + background: url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7); + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; +} +.tiny-pageembed[data-mce-selected="2"] .mce-shim { + display: none; +} +.tiny-pageembed { + display: inline-block; + position: relative; +} +.tiny-pageembed--21by9, +.tiny-pageembed--16by9, +.tiny-pageembed--4by3, +.tiny-pageembed--1by1 { + display: block; + overflow: hidden; + padding: 0; + position: relative; + width: 100%; +} +.tiny-pageembed--21by9 { + padding-top: 42.857143%; +} +.tiny-pageembed--16by9 { + padding-top: 56.25%; +} +.tiny-pageembed--4by3 { + padding-top: 75%; +} +.tiny-pageembed--1by1 { + padding-top: 100%; +} +.tiny-pageembed--21by9 iframe, +.tiny-pageembed--16by9 iframe, +.tiny-pageembed--4by3 iframe, +.tiny-pageembed--1by1 iframe { + border: 0; + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; +} +.mce-content-body[data-mce-placeholder] { + position: relative; +} +.mce-content-body[data-mce-placeholder]:not(.mce-visualblocks)::before { + color: rgba(34, 47, 62, 0.7); + content: attr(data-mce-placeholder); + position: absolute; +} +.mce-content-body:not([dir=rtl])[data-mce-placeholder]:not(.mce-visualblocks)::before { + left: 1px; +} +.mce-content-body[dir=rtl][data-mce-placeholder]:not(.mce-visualblocks)::before { + right: 1px; +} +.mce-content-body div.mce-resizehandle { + background-color: #4099ff; + border-color: #4099ff; + border-style: solid; + border-width: 1px; + box-sizing: border-box; + height: 10px; + position: absolute; + width: 10px; + z-index: 1298; +} +.mce-content-body div.mce-resizehandle:hover { + background-color: #4099ff; +} +.mce-content-body div.mce-resizehandle:nth-of-type(1) { + cursor: nwse-resize; +} +.mce-content-body div.mce-resizehandle:nth-of-type(2) { + cursor: nesw-resize; +} +.mce-content-body div.mce-resizehandle:nth-of-type(3) { + cursor: nwse-resize; +} +.mce-content-body div.mce-resizehandle:nth-of-type(4) { + cursor: nesw-resize; +} +.mce-content-body .mce-resize-backdrop { + z-index: 10000; +} +.mce-content-body .mce-clonedresizable { + cursor: default; + opacity: 0.5; + outline: 1px dashed black; + position: absolute; + z-index: 10001; +} +.mce-content-body .mce-clonedresizable.mce-resizetable-columns th, +.mce-content-body .mce-clonedresizable.mce-resizetable-columns td { + border: 0; +} +.mce-content-body .mce-resize-helper { + background: #555; + background: rgba(0, 0, 0, 0.75); + border: 1px; + border-radius: 3px; + color: white; + display: none; + font-family: sans-serif; + font-size: 12px; + line-height: 14px; + margin: 5px 10px; + padding: 5px; + position: absolute; + white-space: nowrap; + z-index: 10002; +} +.tox-rtc-user-selection { + position: relative; +} +.tox-rtc-user-cursor { + bottom: 0; + cursor: default; + position: absolute; + top: 0; + width: 2px; +} +.tox-rtc-user-cursor::before { + background-color: inherit; + border-radius: 50%; + content: ''; + display: block; + height: 8px; + position: absolute; + right: -3px; + top: -3px; + width: 8px; +} +.tox-rtc-user-cursor:hover::after { + background-color: inherit; + border-radius: 100px; + box-sizing: border-box; + color: #fff; + content: attr(data-user); + display: block; + font-size: 12px; + font-weight: bold; + left: -5px; + min-height: 8px; + min-width: 8px; + padding: 0 12px; + position: absolute; + top: -11px; + white-space: nowrap; + z-index: 1000; +} +.tox-rtc-user-selection--1 .tox-rtc-user-cursor { + background-color: #2dc26b; +} +.tox-rtc-user-selection--2 .tox-rtc-user-cursor { + background-color: #e03e2d; +} +.tox-rtc-user-selection--3 .tox-rtc-user-cursor { + background-color: #f1c40f; +} +.tox-rtc-user-selection--4 .tox-rtc-user-cursor { + background-color: #3598db; +} +.tox-rtc-user-selection--5 .tox-rtc-user-cursor { + background-color: #b96ad9; +} +.tox-rtc-user-selection--6 .tox-rtc-user-cursor { + background-color: #e67e23; +} +.tox-rtc-user-selection--7 .tox-rtc-user-cursor { + background-color: #aaa69d; +} +.tox-rtc-user-selection--8 .tox-rtc-user-cursor { + background-color: #f368e0; +} +.tox-rtc-remote-image { + background: #eaeaea url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2236%22%20height%3D%2212%22%20viewBox%3D%220%200%2036%2012%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Ccircle%20cx%3D%226%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%20%20%3Ccircle%20cx%3D%2218%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20begin%3D%22.33s%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%20%20%3Ccircle%20cx%3D%2230%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20begin%3D%22.66s%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%3C%2Fsvg%3E%0A") no-repeat center center; + border: 1px solid #ccc; + min-height: 240px; + min-width: 320px; +} +.mce-match-marker { + background: #aaa; + color: #fff; +} +.mce-match-marker-selected { + background: #39f; + color: #fff; +} +.mce-match-marker-selected::-moz-selection { + background: #39f; + color: #fff; +} +.mce-match-marker-selected::selection { + background: #39f; + color: #fff; +} +.mce-content-body img[data-mce-selected], +.mce-content-body video[data-mce-selected], +.mce-content-body audio[data-mce-selected], +.mce-content-body object[data-mce-selected], +.mce-content-body embed[data-mce-selected], +.mce-content-body table[data-mce-selected] { + outline: 3px solid #b4d7ff; +} +.mce-content-body hr[data-mce-selected] { + outline: 3px solid #b4d7ff; + outline-offset: 1px; +} +.mce-content-body *[contentEditable=false] *[contentEditable=true]:focus { + outline: 3px solid #b4d7ff; +} +.mce-content-body *[contentEditable=false] *[contentEditable=true]:hover { + outline: 3px solid #b4d7ff; +} +.mce-content-body *[contentEditable=false][data-mce-selected] { + cursor: not-allowed; + outline: 3px solid #b4d7ff; +} +.mce-content-body.mce-content-readonly *[contentEditable=true]:focus, +.mce-content-body.mce-content-readonly *[contentEditable=true]:hover { + outline: none; +} +.mce-content-body *[data-mce-selected="inline-boundary"] { + background-color: #b4d7ff; +} +.mce-content-body .mce-edit-focus { + outline: 3px solid #b4d7ff; +} +.mce-content-body td[data-mce-selected], +.mce-content-body th[data-mce-selected] { + position: relative; +} +.mce-content-body td[data-mce-selected]::-moz-selection, +.mce-content-body th[data-mce-selected]::-moz-selection { + background: none; +} +.mce-content-body td[data-mce-selected]::selection, +.mce-content-body th[data-mce-selected]::selection { + background: none; +} +.mce-content-body td[data-mce-selected] *, +.mce-content-body th[data-mce-selected] * { + outline: none; + -webkit-touch-callout: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} +.mce-content-body td[data-mce-selected]::after, +.mce-content-body th[data-mce-selected]::after { + background-color: rgba(180, 215, 255, 0.7); + border: 1px solid rgba(180, 215, 255, 0.7); + bottom: -1px; + content: ''; + left: -1px; + mix-blend-mode: multiply; + position: absolute; + right: -1px; + top: -1px; +} +@media screen and (-ms-high-contrast: active), (-ms-high-contrast: none) { + .mce-content-body td[data-mce-selected]::after, + .mce-content-body th[data-mce-selected]::after { + border-color: rgba(0, 84, 180, 0.7); + } +} +.mce-content-body img::-moz-selection { + background: none; +} +.mce-content-body img::selection { + background: none; +} +.ephox-snooker-resizer-bar { + background-color: #b4d7ff; + opacity: 0; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} +.ephox-snooker-resizer-cols { + cursor: col-resize; +} +.ephox-snooker-resizer-rows { + cursor: row-resize; +} +.ephox-snooker-resizer-bar.ephox-snooker-resizer-bar-dragging { + opacity: 1; +} +.mce-spellchecker-word { + background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'4'%20height%3D'4'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20stroke%3D'%23ff0000'%20fill%3D'none'%20stroke-linecap%3D'round'%20stroke-opacity%3D'.75'%20d%3D'M0%203L2%201%204%203'%2F%3E%3C%2Fsvg%3E%0A"); + background-position: 0 calc(100% + 1px); + background-repeat: repeat-x; + background-size: auto 6px; + cursor: default; + height: 2rem; +} +.mce-spellchecker-grammar { + background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'4'%20height%3D'4'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20stroke%3D'%2300A835'%20fill%3D'none'%20stroke-linecap%3D'round'%20d%3D'M0%203L2%201%204%203'%2F%3E%3C%2Fsvg%3E%0A"); + background-position: 0 calc(100% + 1px); + background-repeat: repeat-x; + background-size: auto 6px; + cursor: default; +} +.mce-toc { + border: 1px solid gray; +} +.mce-toc h2 { + margin: 4px; +} +.mce-toc li { + list-style-type: none; +} +table[style*="border-width: 0px"], +.mce-item-table:not([border]), +.mce-item-table[border="0"], +table[style*="border-width: 0px"] td, +.mce-item-table:not([border]) td, +.mce-item-table[border="0"] td, +table[style*="border-width: 0px"] th, +.mce-item-table:not([border]) th, +.mce-item-table[border="0"] th, +table[style*="border-width: 0px"] caption, +.mce-item-table:not([border]) caption, +.mce-item-table[border="0"] caption { + border: 1px dashed #bbb; +} +.mce-visualblocks p, +.mce-visualblocks h1, +.mce-visualblocks h2, +.mce-visualblocks h3, +.mce-visualblocks h4, +.mce-visualblocks h5, +.mce-visualblocks h6, +.mce-visualblocks div:not([data-mce-bogus]), +.mce-visualblocks section, +.mce-visualblocks article, +.mce-visualblocks blockquote, +.mce-visualblocks address, +.mce-visualblocks pre, +.mce-visualblocks figure, +.mce-visualblocks figcaption, +.mce-visualblocks hgroup, +.mce-visualblocks aside, +.mce-visualblocks ul, +.mce-visualblocks ol, +.mce-visualblocks dl { + background-repeat: no-repeat; + border: 1px dashed #bbb; + margin-left: 3px; + padding-top: 10px; +} +.mce-visualblocks p { + background-image: url(data:image/gif;base64,R0lGODlhCQAJAJEAAAAAAP///7u7u////yH5BAEAAAMALAAAAAAJAAkAAAIQnG+CqCN/mlyvsRUpThG6AgA7); +} +.mce-visualblocks h1 { + background-image: url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybGu1JuxHoAfRNRW3TWXyF2YiRUAOw==); +} +.mce-visualblocks h2 { + background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8Hybbx4oOuqgTynJd6bGlWg3DkJzoaUAAAOw==); +} +.mce-visualblocks h3 { + background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIZjI8Hybbx4oOuqgTynJf2Ln2NOHpQpmhAAQA7); +} +.mce-visualblocks h4 { + background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxInR0zqeAdhtJlXwV1oCll2HaWgAAOw==); +} +.mce-visualblocks h5 { + background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxIoiuwjane4iq5GlW05GgIkIZUAAAOw==); +} +.mce-visualblocks h6 { + background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxIoiuwjan04jep1iZ1XRlAo5bVgAAOw==); +} +.mce-visualblocks div:not([data-mce-bogus]) { + background-image: url(data:image/gif;base64,R0lGODlhEgAKAIABALu7u////yH5BAEAAAEALAAAAAASAAoAAAIfjI9poI0cgDywrhuxfbrzDEbQM2Ei5aRjmoySW4pAAQA7); +} +.mce-visualblocks section { + background-image: url(data:image/gif;base64,R0lGODlhKAAKAIABALu7u////yH5BAEAAAEALAAAAAAoAAoAAAI5jI+pywcNY3sBWHdNrplytD2ellDeSVbp+GmWqaDqDMepc8t17Y4vBsK5hDyJMcI6KkuYU+jpjLoKADs=); +} +.mce-visualblocks article { + background-image: url(data:image/gif;base64,R0lGODlhKgAKAIABALu7u////yH5BAEAAAEALAAAAAAqAAoAAAI6jI+pywkNY3wG0GBvrsd2tXGYSGnfiF7ikpXemTpOiJScasYoDJJrjsG9gkCJ0ag6KhmaIe3pjDYBBQA7); +} +.mce-visualblocks blockquote { + background-image: url(data:image/gif;base64,R0lGODlhPgAKAIABALu7u////yH5BAEAAAEALAAAAAA+AAoAAAJPjI+py+0Knpz0xQDyuUhvfoGgIX5iSKZYgq5uNL5q69asZ8s5rrf0yZmpNkJZzFesBTu8TOlDVAabUyatguVhWduud3EyiUk45xhTTgMBBQA7); +} +.mce-visualblocks address { + background-image: url(data:image/gif;base64,R0lGODlhLQAKAIABALu7u////yH5BAEAAAEALAAAAAAtAAoAAAI/jI+pywwNozSP1gDyyZcjb3UaRpXkWaXmZW4OqKLhBmLs+K263DkJK7OJeifh7FicKD9A1/IpGdKkyFpNmCkAADs=); +} +.mce-visualblocks pre { + background-image: url(data:image/gif;base64,R0lGODlhFQAKAIABALu7uwAAACH5BAEAAAEALAAAAAAVAAoAAAIjjI+ZoN0cgDwSmnpz1NCueYERhnibZVKLNnbOq8IvKpJtVQAAOw==); +} +.mce-visualblocks figure { + background-image: url(data:image/gif;base64,R0lGODlhJAAKAIAAALu7u////yH5BAEAAAEALAAAAAAkAAoAAAI0jI+py+2fwAHUSFvD3RlvG4HIp4nX5JFSpnZUJ6LlrM52OE7uSWosBHScgkSZj7dDKnWAAgA7); +} +.mce-visualblocks figcaption { + border: 1px dashed #bbb; +} +.mce-visualblocks hgroup { + background-image: url(data:image/gif;base64,R0lGODlhJwAKAIABALu7uwAAACH5BAEAAAEALAAAAAAnAAoAAAI3jI+pywYNI3uB0gpsRtt5fFnfNZaVSYJil4Wo03Hv6Z62uOCgiXH1kZIIJ8NiIxRrAZNMZAtQAAA7); +} +.mce-visualblocks aside { + background-image: url(data:image/gif;base64,R0lGODlhHgAKAIABAKqqqv///yH5BAEAAAEALAAAAAAeAAoAAAItjI+pG8APjZOTzgtqy7I3f1yehmQcFY4WKZbqByutmW4aHUd6vfcVbgudgpYCADs=); +} +.mce-visualblocks ul { + background-image: url(data:image/gif;base64,R0lGODlhDQAKAIAAALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybGuYnqUVSjvw26DzzXiqIDlVwAAOw==); +} +.mce-visualblocks ol { + background-image: url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybH6HHt0qourxC6CvzXieHyeWQAAOw==); +} +.mce-visualblocks dl { + background-image: url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybEOnmOvUoWznTqeuEjNSCqeGRUAOw==); +} +.mce-visualblocks:not([dir=rtl]) p, +.mce-visualblocks:not([dir=rtl]) h1, +.mce-visualblocks:not([dir=rtl]) h2, +.mce-visualblocks:not([dir=rtl]) h3, +.mce-visualblocks:not([dir=rtl]) h4, +.mce-visualblocks:not([dir=rtl]) h5, +.mce-visualblocks:not([dir=rtl]) h6, +.mce-visualblocks:not([dir=rtl]) div:not([data-mce-bogus]), +.mce-visualblocks:not([dir=rtl]) section, +.mce-visualblocks:not([dir=rtl]) article, +.mce-visualblocks:not([dir=rtl]) blockquote, +.mce-visualblocks:not([dir=rtl]) address, +.mce-visualblocks:not([dir=rtl]) pre, +.mce-visualblocks:not([dir=rtl]) figure, +.mce-visualblocks:not([dir=rtl]) figcaption, +.mce-visualblocks:not([dir=rtl]) hgroup, +.mce-visualblocks:not([dir=rtl]) aside, +.mce-visualblocks:not([dir=rtl]) ul, +.mce-visualblocks:not([dir=rtl]) ol, +.mce-visualblocks:not([dir=rtl]) dl { + margin-left: 3px; +} +.mce-visualblocks[dir=rtl] p, +.mce-visualblocks[dir=rtl] h1, +.mce-visualblocks[dir=rtl] h2, +.mce-visualblocks[dir=rtl] h3, +.mce-visualblocks[dir=rtl] h4, +.mce-visualblocks[dir=rtl] h5, +.mce-visualblocks[dir=rtl] h6, +.mce-visualblocks[dir=rtl] div:not([data-mce-bogus]), +.mce-visualblocks[dir=rtl] section, +.mce-visualblocks[dir=rtl] article, +.mce-visualblocks[dir=rtl] blockquote, +.mce-visualblocks[dir=rtl] address, +.mce-visualblocks[dir=rtl] pre, +.mce-visualblocks[dir=rtl] figure, +.mce-visualblocks[dir=rtl] figcaption, +.mce-visualblocks[dir=rtl] hgroup, +.mce-visualblocks[dir=rtl] aside, +.mce-visualblocks[dir=rtl] ul, +.mce-visualblocks[dir=rtl] ol, +.mce-visualblocks[dir=rtl] dl { + background-position-x: right; + margin-right: 3px; +} +.mce-nbsp, +.mce-shy { + background: #aaa; +} +.mce-shy::after { + content: '-'; +} diff --git a/ui/public/tinymce/skins/ui/oxide-dark/content.inline.min.css b/ui/public/tinymce/skins/ui/oxide-dark/content.inline.min.css new file mode 100644 index 0000000..b4ab9a3 --- /dev/null +++ b/ui/public/tinymce/skins/ui/oxide-dark/content.inline.min.css @@ -0,0 +1,7 @@ +/** + * Copyright (c) Tiny Technologies, Inc. All rights reserved. + * Licensed under the LGPL or a commercial license. + * For LGPL see License.txt in the project root for license information. + * For commercial licenses see https://www.tiny.cloud/ + */ +.mce-content-body .mce-item-anchor{background:transparent url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'8'%20height%3D'12'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20d%3D'M0%200L8%200%208%2012%204.09117821%209%200%2012z'%2F%3E%3C%2Fsvg%3E%0A") no-repeat center;cursor:default;display:inline-block;height:12px!important;padding:0 2px;-webkit-user-modify:read-only;-moz-user-modify:read-only;-webkit-user-select:all;-moz-user-select:all;-ms-user-select:all;user-select:all;width:8px!important}.mce-content-body .mce-item-anchor[data-mce-selected]{outline-offset:1px}.tox-comments-visible .tox-comment{background-color:#fff0b7}.tox-comments-visible .tox-comment--active{background-color:#ffe168}.tox-checklist>li:not(.tox-checklist--hidden){list-style:none;margin:.25em 0}.tox-checklist>li:not(.tox-checklist--hidden)::before{content:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-unchecked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2215%22%20height%3D%2215%22%20x%3D%22.5%22%20y%3D%22.5%22%20fill-rule%3D%22nonzero%22%20stroke%3D%22%234C4C4C%22%20rx%3D%222%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A");cursor:pointer;height:1em;margin-left:-1.5em;margin-top:.125em;position:absolute;width:1em}.tox-checklist li:not(.tox-checklist--hidden).tox-checklist--checked::before{content:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-checked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2216%22%20height%3D%2216%22%20fill%3D%22%234099FF%22%20fill-rule%3D%22nonzero%22%20rx%3D%222%22%2F%3E%3Cpath%20id%3D%22Path%22%20fill%3D%22%23FFF%22%20fill-rule%3D%22nonzero%22%20d%3D%22M11.5703186%2C3.14417309%20C11.8516238%2C2.73724603%2012.4164781%2C2.62829933%2012.83558%2C2.89774797%20C13.260121%2C3.17069355%2013.3759736%2C3.72932262%2013.0909105%2C4.14168582%20L7.7580587%2C11.8560195%20C7.43776896%2C12.3193404%206.76483983%2C12.3852142%206.35607322%2C11.9948725%20L3.02491697%2C8.8138662%20C2.66090143%2C8.46625845%202.65798871%2C7.89594698%203.01850234%2C7.54483354%20C3.373942%2C7.19866177%203.94940006%2C7.19592841%204.30829608%2C7.5386474%20L6.85276923%2C9.9684299%20L11.5703186%2C3.14417309%20Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A")}[dir=rtl] .tox-checklist>li:not(.tox-checklist--hidden)::before{margin-left:0;margin-right:-1.5em}code[class*=language-],pre[class*=language-]{color:#000;background:0 0;text-shadow:0 1px #fff;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;tab-size:4;-webkit-hyphens:none;-ms-hyphens:none;hyphens:none}code[class*=language-] ::-moz-selection,code[class*=language-]::-moz-selection,pre[class*=language-] ::-moz-selection,pre[class*=language-]::-moz-selection{text-shadow:none;background:#b3d4fc}code[class*=language-] ::selection,code[class*=language-]::selection,pre[class*=language-] ::selection,pre[class*=language-]::selection{text-shadow:none;background:#b3d4fc}@media print{code[class*=language-],pre[class*=language-]{text-shadow:none}}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#f5f2f0}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#708090}.token.punctuation{color:#999}.namespace{opacity:.7}.token.boolean,.token.constant,.token.deleted,.token.number,.token.property,.token.symbol,.token.tag{color:#905}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#690}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url{color:#9a6e3a;background:hsla(0,0%,100%,.5)}.token.atrule,.token.attr-value,.token.keyword{color:#07a}.token.class-name,.token.function{color:#dd4a68}.token.important,.token.regex,.token.variable{color:#e90}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.mce-content-body{overflow-wrap:break-word;word-wrap:break-word}.mce-content-body .mce-visual-caret{background-color:#000;background-color:currentColor;position:absolute}.mce-content-body .mce-visual-caret-hidden{display:none}.mce-content-body [data-mce-caret]{left:-1000px;margin:0;padding:0;position:absolute;right:auto;top:0}.mce-content-body .mce-offscreen-selection{left:-2000000px;max-width:1000000px;position:absolute}.mce-content-body [contentEditable=false]{cursor:default}.mce-content-body [contentEditable=true]{cursor:text}.tox-cursor-format-painter{cursor:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%3E%0A%20%20%3Cg%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%0A%20%20%20%20%3Cpath%20fill%3D%22%23000%22%20fill-rule%3D%22nonzero%22%20d%3D%22M15%2C6%20C15%2C5.45%2014.55%2C5%2014%2C5%20L6%2C5%20C5.45%2C5%205%2C5.45%205%2C6%20L5%2C10%20C5%2C10.55%205.45%2C11%206%2C11%20L14%2C11%20C14.55%2C11%2015%2C10.55%2015%2C10%20L15%2C9%20L16%2C9%20L16%2C12%20L9%2C12%20L9%2C19%20C9%2C19.55%209.45%2C20%2010%2C20%20L11%2C20%20C11.55%2C20%2012%2C19.55%2012%2C19%20L12%2C14%20L18%2C14%20L18%2C7%20L15%2C7%20L15%2C6%20Z%22%2F%3E%0A%20%20%20%20%3Cpath%20fill%3D%22%23000%22%20fill-rule%3D%22nonzero%22%20d%3D%22M1%2C1%20L8.25%2C1%20C8.66421356%2C1%209%2C1.33578644%209%2C1.75%20L9%2C1.75%20C9%2C2.16421356%208.66421356%2C2.5%208.25%2C2.5%20L2.5%2C2.5%20L2.5%2C8.25%20C2.5%2C8.66421356%202.16421356%2C9%201.75%2C9%20L1.75%2C9%20C1.33578644%2C9%201%2C8.66421356%201%2C8.25%20L1%2C1%20Z%22%2F%3E%0A%20%20%3C%2Fg%3E%0A%3C%2Fsvg%3E%0A"),default}.mce-content-body figure.align-left{float:left}.mce-content-body figure.align-right{float:right}.mce-content-body figure.image.align-center{display:table;margin-left:auto;margin-right:auto}.mce-preview-object{border:1px solid gray;display:inline-block;line-height:0;margin:0 2px 0 2px;position:relative}.mce-preview-object .mce-shim{background:url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7);height:100%;left:0;position:absolute;top:0;width:100%}.mce-preview-object[data-mce-selected="2"] .mce-shim{display:none}.mce-object{background:transparent url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M4%203h16a1%201%200%200%201%201%201v16a1%201%200%200%201-1%201H4a1%201%200%200%201-1-1V4a1%201%200%200%201%201-1zm1%202v14h14V5H5zm4.79%202.565l5.64%204.028a.5.5%200%200%201%200%20.814l-5.64%204.028a.5.5%200%200%201-.79-.407V7.972a.5.5%200%200%201%20.79-.407z%22%2F%3E%3C%2Fsvg%3E%0A") no-repeat center;border:1px dashed #aaa}.mce-pagebreak{border:1px dashed #aaa;cursor:default;display:block;height:5px;margin-top:15px;page-break-before:always;width:100%}@media print{.mce-pagebreak{border:0}}.tiny-pageembed .mce-shim{background:url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7);height:100%;left:0;position:absolute;top:0;width:100%}.tiny-pageembed[data-mce-selected="2"] .mce-shim{display:none}.tiny-pageembed{display:inline-block;position:relative}.tiny-pageembed--16by9,.tiny-pageembed--1by1,.tiny-pageembed--21by9,.tiny-pageembed--4by3{display:block;overflow:hidden;padding:0;position:relative;width:100%}.tiny-pageembed--21by9{padding-top:42.857143%}.tiny-pageembed--16by9{padding-top:56.25%}.tiny-pageembed--4by3{padding-top:75%}.tiny-pageembed--1by1{padding-top:100%}.tiny-pageembed--16by9 iframe,.tiny-pageembed--1by1 iframe,.tiny-pageembed--21by9 iframe,.tiny-pageembed--4by3 iframe{border:0;height:100%;left:0;position:absolute;top:0;width:100%}.mce-content-body[data-mce-placeholder]{position:relative}.mce-content-body[data-mce-placeholder]:not(.mce-visualblocks)::before{color:rgba(34,47,62,.7);content:attr(data-mce-placeholder);position:absolute}.mce-content-body:not([dir=rtl])[data-mce-placeholder]:not(.mce-visualblocks)::before{left:1px}.mce-content-body[dir=rtl][data-mce-placeholder]:not(.mce-visualblocks)::before{right:1px}.mce-content-body div.mce-resizehandle{background-color:#4099ff;border-color:#4099ff;border-style:solid;border-width:1px;box-sizing:border-box;height:10px;position:absolute;width:10px;z-index:1298}.mce-content-body div.mce-resizehandle:hover{background-color:#4099ff}.mce-content-body div.mce-resizehandle:nth-of-type(1){cursor:nwse-resize}.mce-content-body div.mce-resizehandle:nth-of-type(2){cursor:nesw-resize}.mce-content-body div.mce-resizehandle:nth-of-type(3){cursor:nwse-resize}.mce-content-body div.mce-resizehandle:nth-of-type(4){cursor:nesw-resize}.mce-content-body .mce-resize-backdrop{z-index:10000}.mce-content-body .mce-clonedresizable{cursor:default;opacity:.5;outline:1px dashed #000;position:absolute;z-index:10001}.mce-content-body .mce-clonedresizable.mce-resizetable-columns td,.mce-content-body .mce-clonedresizable.mce-resizetable-columns th{border:0}.mce-content-body .mce-resize-helper{background:#555;background:rgba(0,0,0,.75);border:1px;border-radius:3px;color:#fff;display:none;font-family:sans-serif;font-size:12px;line-height:14px;margin:5px 10px;padding:5px;position:absolute;white-space:nowrap;z-index:10002}.tox-rtc-user-selection{position:relative}.tox-rtc-user-cursor{bottom:0;cursor:default;position:absolute;top:0;width:2px}.tox-rtc-user-cursor::before{background-color:inherit;border-radius:50%;content:'';display:block;height:8px;position:absolute;right:-3px;top:-3px;width:8px}.tox-rtc-user-cursor:hover::after{background-color:inherit;border-radius:100px;box-sizing:border-box;color:#fff;content:attr(data-user);display:block;font-size:12px;font-weight:700;left:-5px;min-height:8px;min-width:8px;padding:0 12px;position:absolute;top:-11px;white-space:nowrap;z-index:1000}.tox-rtc-user-selection--1 .tox-rtc-user-cursor{background-color:#2dc26b}.tox-rtc-user-selection--2 .tox-rtc-user-cursor{background-color:#e03e2d}.tox-rtc-user-selection--3 .tox-rtc-user-cursor{background-color:#f1c40f}.tox-rtc-user-selection--4 .tox-rtc-user-cursor{background-color:#3598db}.tox-rtc-user-selection--5 .tox-rtc-user-cursor{background-color:#b96ad9}.tox-rtc-user-selection--6 .tox-rtc-user-cursor{background-color:#e67e23}.tox-rtc-user-selection--7 .tox-rtc-user-cursor{background-color:#aaa69d}.tox-rtc-user-selection--8 .tox-rtc-user-cursor{background-color:#f368e0}.tox-rtc-remote-image{background:#eaeaea url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2236%22%20height%3D%2212%22%20viewBox%3D%220%200%2036%2012%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Ccircle%20cx%3D%226%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%20%20%3Ccircle%20cx%3D%2218%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20begin%3D%22.33s%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%20%20%3Ccircle%20cx%3D%2230%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20begin%3D%22.66s%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%3C%2Fsvg%3E%0A") no-repeat center center;border:1px solid #ccc;min-height:240px;min-width:320px}.mce-match-marker{background:#aaa;color:#fff}.mce-match-marker-selected{background:#39f;color:#fff}.mce-match-marker-selected::-moz-selection{background:#39f;color:#fff}.mce-match-marker-selected::selection{background:#39f;color:#fff}.mce-content-body audio[data-mce-selected],.mce-content-body embed[data-mce-selected],.mce-content-body img[data-mce-selected],.mce-content-body object[data-mce-selected],.mce-content-body table[data-mce-selected],.mce-content-body video[data-mce-selected]{outline:3px solid #b4d7ff}.mce-content-body hr[data-mce-selected]{outline:3px solid #b4d7ff;outline-offset:1px}.mce-content-body [contentEditable=false] [contentEditable=true]:focus{outline:3px solid #b4d7ff}.mce-content-body [contentEditable=false] [contentEditable=true]:hover{outline:3px solid #b4d7ff}.mce-content-body [contentEditable=false][data-mce-selected]{cursor:not-allowed;outline:3px solid #b4d7ff}.mce-content-body.mce-content-readonly [contentEditable=true]:focus,.mce-content-body.mce-content-readonly [contentEditable=true]:hover{outline:0}.mce-content-body [data-mce-selected=inline-boundary]{background-color:#b4d7ff}.mce-content-body .mce-edit-focus{outline:3px solid #b4d7ff}.mce-content-body td[data-mce-selected],.mce-content-body th[data-mce-selected]{position:relative}.mce-content-body td[data-mce-selected]::-moz-selection,.mce-content-body th[data-mce-selected]::-moz-selection{background:0 0}.mce-content-body td[data-mce-selected]::selection,.mce-content-body th[data-mce-selected]::selection{background:0 0}.mce-content-body td[data-mce-selected] *,.mce-content-body th[data-mce-selected] *{outline:0;-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.mce-content-body td[data-mce-selected]::after,.mce-content-body th[data-mce-selected]::after{background-color:rgba(180,215,255,.7);border:1px solid rgba(180,215,255,.7);bottom:-1px;content:'';left:-1px;mix-blend-mode:multiply;position:absolute;right:-1px;top:-1px}@media screen and (-ms-high-contrast:active),(-ms-high-contrast:none){.mce-content-body td[data-mce-selected]::after,.mce-content-body th[data-mce-selected]::after{border-color:rgba(0,84,180,.7)}}.mce-content-body img::-moz-selection{background:0 0}.mce-content-body img::selection{background:0 0}.ephox-snooker-resizer-bar{background-color:#b4d7ff;opacity:0;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.ephox-snooker-resizer-cols{cursor:col-resize}.ephox-snooker-resizer-rows{cursor:row-resize}.ephox-snooker-resizer-bar.ephox-snooker-resizer-bar-dragging{opacity:1}.mce-spellchecker-word{background-image:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'4'%20height%3D'4'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20stroke%3D'%23ff0000'%20fill%3D'none'%20stroke-linecap%3D'round'%20stroke-opacity%3D'.75'%20d%3D'M0%203L2%201%204%203'%2F%3E%3C%2Fsvg%3E%0A");background-position:0 calc(100% + 1px);background-repeat:repeat-x;background-size:auto 6px;cursor:default;height:2rem}.mce-spellchecker-grammar{background-image:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'4'%20height%3D'4'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20stroke%3D'%2300A835'%20fill%3D'none'%20stroke-linecap%3D'round'%20d%3D'M0%203L2%201%204%203'%2F%3E%3C%2Fsvg%3E%0A");background-position:0 calc(100% + 1px);background-repeat:repeat-x;background-size:auto 6px;cursor:default}.mce-toc{border:1px solid gray}.mce-toc h2{margin:4px}.mce-toc li{list-style-type:none}.mce-item-table:not([border]),.mce-item-table:not([border]) caption,.mce-item-table:not([border]) td,.mce-item-table:not([border]) th,.mce-item-table[border="0"],.mce-item-table[border="0"] caption,.mce-item-table[border="0"] td,.mce-item-table[border="0"] th,table[style*="border-width: 0px"],table[style*="border-width: 0px"] caption,table[style*="border-width: 0px"] td,table[style*="border-width: 0px"] th{border:1px dashed #bbb}.mce-visualblocks address,.mce-visualblocks article,.mce-visualblocks aside,.mce-visualblocks blockquote,.mce-visualblocks div:not([data-mce-bogus]),.mce-visualblocks dl,.mce-visualblocks figcaption,.mce-visualblocks figure,.mce-visualblocks h1,.mce-visualblocks h2,.mce-visualblocks h3,.mce-visualblocks h4,.mce-visualblocks h5,.mce-visualblocks h6,.mce-visualblocks hgroup,.mce-visualblocks ol,.mce-visualblocks p,.mce-visualblocks pre,.mce-visualblocks section,.mce-visualblocks ul{background-repeat:no-repeat;border:1px dashed #bbb;margin-left:3px;padding-top:10px}.mce-visualblocks p{background-image:url(data:image/gif;base64,R0lGODlhCQAJAJEAAAAAAP///7u7u////yH5BAEAAAMALAAAAAAJAAkAAAIQnG+CqCN/mlyvsRUpThG6AgA7)}.mce-visualblocks h1{background-image:url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybGu1JuxHoAfRNRW3TWXyF2YiRUAOw==)}.mce-visualblocks h2{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8Hybbx4oOuqgTynJd6bGlWg3DkJzoaUAAAOw==)}.mce-visualblocks h3{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIZjI8Hybbx4oOuqgTynJf2Ln2NOHpQpmhAAQA7)}.mce-visualblocks h4{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxInR0zqeAdhtJlXwV1oCll2HaWgAAOw==)}.mce-visualblocks h5{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxIoiuwjane4iq5GlW05GgIkIZUAAAOw==)}.mce-visualblocks h6{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxIoiuwjan04jep1iZ1XRlAo5bVgAAOw==)}.mce-visualblocks div:not([data-mce-bogus]){background-image:url(data:image/gif;base64,R0lGODlhEgAKAIABALu7u////yH5BAEAAAEALAAAAAASAAoAAAIfjI9poI0cgDywrhuxfbrzDEbQM2Ei5aRjmoySW4pAAQA7)}.mce-visualblocks section{background-image:url(data:image/gif;base64,R0lGODlhKAAKAIABALu7u////yH5BAEAAAEALAAAAAAoAAoAAAI5jI+pywcNY3sBWHdNrplytD2ellDeSVbp+GmWqaDqDMepc8t17Y4vBsK5hDyJMcI6KkuYU+jpjLoKADs=)}.mce-visualblocks article{background-image:url(data:image/gif;base64,R0lGODlhKgAKAIABALu7u////yH5BAEAAAEALAAAAAAqAAoAAAI6jI+pywkNY3wG0GBvrsd2tXGYSGnfiF7ikpXemTpOiJScasYoDJJrjsG9gkCJ0ag6KhmaIe3pjDYBBQA7)}.mce-visualblocks blockquote{background-image:url(data:image/gif;base64,R0lGODlhPgAKAIABALu7u////yH5BAEAAAEALAAAAAA+AAoAAAJPjI+py+0Knpz0xQDyuUhvfoGgIX5iSKZYgq5uNL5q69asZ8s5rrf0yZmpNkJZzFesBTu8TOlDVAabUyatguVhWduud3EyiUk45xhTTgMBBQA7)}.mce-visualblocks address{background-image:url(data:image/gif;base64,R0lGODlhLQAKAIABALu7u////yH5BAEAAAEALAAAAAAtAAoAAAI/jI+pywwNozSP1gDyyZcjb3UaRpXkWaXmZW4OqKLhBmLs+K263DkJK7OJeifh7FicKD9A1/IpGdKkyFpNmCkAADs=)}.mce-visualblocks pre{background-image:url(data:image/gif;base64,R0lGODlhFQAKAIABALu7uwAAACH5BAEAAAEALAAAAAAVAAoAAAIjjI+ZoN0cgDwSmnpz1NCueYERhnibZVKLNnbOq8IvKpJtVQAAOw==)}.mce-visualblocks figure{background-image:url(data:image/gif;base64,R0lGODlhJAAKAIAAALu7u////yH5BAEAAAEALAAAAAAkAAoAAAI0jI+py+2fwAHUSFvD3RlvG4HIp4nX5JFSpnZUJ6LlrM52OE7uSWosBHScgkSZj7dDKnWAAgA7)}.mce-visualblocks figcaption{border:1px dashed #bbb}.mce-visualblocks hgroup{background-image:url(data:image/gif;base64,R0lGODlhJwAKAIABALu7uwAAACH5BAEAAAEALAAAAAAnAAoAAAI3jI+pywYNI3uB0gpsRtt5fFnfNZaVSYJil4Wo03Hv6Z62uOCgiXH1kZIIJ8NiIxRrAZNMZAtQAAA7)}.mce-visualblocks aside{background-image:url(data:image/gif;base64,R0lGODlhHgAKAIABAKqqqv///yH5BAEAAAEALAAAAAAeAAoAAAItjI+pG8APjZOTzgtqy7I3f1yehmQcFY4WKZbqByutmW4aHUd6vfcVbgudgpYCADs=)}.mce-visualblocks ul{background-image:url(data:image/gif;base64,R0lGODlhDQAKAIAAALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybGuYnqUVSjvw26DzzXiqIDlVwAAOw==)}.mce-visualblocks ol{background-image:url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybH6HHt0qourxC6CvzXieHyeWQAAOw==)}.mce-visualblocks dl{background-image:url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybEOnmOvUoWznTqeuEjNSCqeGRUAOw==)}.mce-visualblocks:not([dir=rtl]) address,.mce-visualblocks:not([dir=rtl]) article,.mce-visualblocks:not([dir=rtl]) aside,.mce-visualblocks:not([dir=rtl]) blockquote,.mce-visualblocks:not([dir=rtl]) div:not([data-mce-bogus]),.mce-visualblocks:not([dir=rtl]) dl,.mce-visualblocks:not([dir=rtl]) figcaption,.mce-visualblocks:not([dir=rtl]) figure,.mce-visualblocks:not([dir=rtl]) h1,.mce-visualblocks:not([dir=rtl]) h2,.mce-visualblocks:not([dir=rtl]) h3,.mce-visualblocks:not([dir=rtl]) h4,.mce-visualblocks:not([dir=rtl]) h5,.mce-visualblocks:not([dir=rtl]) h6,.mce-visualblocks:not([dir=rtl]) hgroup,.mce-visualblocks:not([dir=rtl]) ol,.mce-visualblocks:not([dir=rtl]) p,.mce-visualblocks:not([dir=rtl]) pre,.mce-visualblocks:not([dir=rtl]) section,.mce-visualblocks:not([dir=rtl]) ul{margin-left:3px}.mce-visualblocks[dir=rtl] address,.mce-visualblocks[dir=rtl] article,.mce-visualblocks[dir=rtl] aside,.mce-visualblocks[dir=rtl] blockquote,.mce-visualblocks[dir=rtl] div:not([data-mce-bogus]),.mce-visualblocks[dir=rtl] dl,.mce-visualblocks[dir=rtl] figcaption,.mce-visualblocks[dir=rtl] figure,.mce-visualblocks[dir=rtl] h1,.mce-visualblocks[dir=rtl] h2,.mce-visualblocks[dir=rtl] h3,.mce-visualblocks[dir=rtl] h4,.mce-visualblocks[dir=rtl] h5,.mce-visualblocks[dir=rtl] h6,.mce-visualblocks[dir=rtl] hgroup,.mce-visualblocks[dir=rtl] ol,.mce-visualblocks[dir=rtl] p,.mce-visualblocks[dir=rtl] pre,.mce-visualblocks[dir=rtl] section,.mce-visualblocks[dir=rtl] ul{background-position-x:right;margin-right:3px}.mce-nbsp,.mce-shy{background:#aaa}.mce-shy::after{content:'-'} diff --git a/ui/public/tinymce/skins/ui/oxide-dark/content.min.css b/ui/public/tinymce/skins/ui/oxide-dark/content.min.css new file mode 100644 index 0000000..e27b8a0 --- /dev/null +++ b/ui/public/tinymce/skins/ui/oxide-dark/content.min.css @@ -0,0 +1,7 @@ +/** + * Copyright (c) Tiny Technologies, Inc. All rights reserved. + * Licensed under the LGPL or a commercial license. + * For LGPL see License.txt in the project root for license information. + * For commercial licenses see https://www.tiny.cloud/ + */ +.mce-content-body .mce-item-anchor{background:transparent url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'8'%20height%3D'12'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20d%3D'M0%200L8%200%208%2012%204.09117821%209%200%2012z'%20fill%3D%22%23cccccc%22%2F%3E%3C%2Fsvg%3E%0A") no-repeat center;cursor:default;display:inline-block;height:12px!important;padding:0 2px;-webkit-user-modify:read-only;-moz-user-modify:read-only;-webkit-user-select:all;-moz-user-select:all;-ms-user-select:all;user-select:all;width:8px!important}.mce-content-body .mce-item-anchor[data-mce-selected]{outline-offset:1px}.tox-comments-visible .tox-comment{background-color:#fff0b7}.tox-comments-visible .tox-comment--active{background-color:#ffe168}.tox-checklist>li:not(.tox-checklist--hidden){list-style:none;margin:.25em 0}.tox-checklist>li:not(.tox-checklist--hidden)::before{content:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-unchecked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2215%22%20height%3D%2215%22%20x%3D%22.5%22%20y%3D%22.5%22%20fill-rule%3D%22nonzero%22%20stroke%3D%22%236d737b%22%20rx%3D%222%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A");cursor:pointer;height:1em;margin-left:-1.5em;margin-top:.125em;position:absolute;width:1em}.tox-checklist li:not(.tox-checklist--hidden).tox-checklist--checked::before{content:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-checked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2216%22%20height%3D%2216%22%20fill%3D%22%234099FF%22%20fill-rule%3D%22nonzero%22%20rx%3D%222%22%2F%3E%3Cpath%20id%3D%22Path%22%20fill%3D%22%23FFF%22%20fill-rule%3D%22nonzero%22%20d%3D%22M11.5703186%2C3.14417309%20C11.8516238%2C2.73724603%2012.4164781%2C2.62829933%2012.83558%2C2.89774797%20C13.260121%2C3.17069355%2013.3759736%2C3.72932262%2013.0909105%2C4.14168582%20L7.7580587%2C11.8560195%20C7.43776896%2C12.3193404%206.76483983%2C12.3852142%206.35607322%2C11.9948725%20L3.02491697%2C8.8138662%20C2.66090143%2C8.46625845%202.65798871%2C7.89594698%203.01850234%2C7.54483354%20C3.373942%2C7.19866177%203.94940006%2C7.19592841%204.30829608%2C7.5386474%20L6.85276923%2C9.9684299%20L11.5703186%2C3.14417309%20Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A")}[dir=rtl] .tox-checklist>li:not(.tox-checklist--hidden)::before{margin-left:0;margin-right:-1.5em}code[class*=language-],pre[class*=language-]{color:#f8f8f2;background:0 0;text-shadow:0 1px rgba(0,0,0,.3);font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;tab-size:4;-webkit-hyphens:none;-ms-hyphens:none;hyphens:none}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto;border-radius:.3em}:not(pre)>code[class*=language-],pre[class*=language-]{background:#282a36}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#6272a4}.token.punctuation{color:#f8f8f2}.namespace{opacity:.7}.token.constant,.token.deleted,.token.property,.token.symbol,.token.tag{color:#ff79c6}.token.boolean,.token.number{color:#bd93f9}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#50fa7b}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url,.token.variable{color:#f8f8f2}.token.atrule,.token.attr-value,.token.class-name,.token.function{color:#f1fa8c}.token.keyword{color:#8be9fd}.token.important,.token.regex{color:#ffb86c}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.mce-content-body{overflow-wrap:break-word;word-wrap:break-word}.mce-content-body .mce-visual-caret{background-color:#000;background-color:currentColor;position:absolute}.mce-content-body .mce-visual-caret-hidden{display:none}.mce-content-body [data-mce-caret]{left:-1000px;margin:0;padding:0;position:absolute;right:auto;top:0}.mce-content-body .mce-offscreen-selection{left:-2000000px;max-width:1000000px;position:absolute}.mce-content-body [contentEditable=false]{cursor:default}.mce-content-body [contentEditable=true]{cursor:text}.tox-cursor-format-painter{cursor:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%3E%0A%20%20%3Cg%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%0A%20%20%20%20%3Cpath%20fill%3D%22%23000%22%20fill-rule%3D%22nonzero%22%20d%3D%22M15%2C6%20C15%2C5.45%2014.55%2C5%2014%2C5%20L6%2C5%20C5.45%2C5%205%2C5.45%205%2C6%20L5%2C10%20C5%2C10.55%205.45%2C11%206%2C11%20L14%2C11%20C14.55%2C11%2015%2C10.55%2015%2C10%20L15%2C9%20L16%2C9%20L16%2C12%20L9%2C12%20L9%2C19%20C9%2C19.55%209.45%2C20%2010%2C20%20L11%2C20%20C11.55%2C20%2012%2C19.55%2012%2C19%20L12%2C14%20L18%2C14%20L18%2C7%20L15%2C7%20L15%2C6%20Z%22%2F%3E%0A%20%20%20%20%3Cpath%20fill%3D%22%23000%22%20fill-rule%3D%22nonzero%22%20d%3D%22M1%2C1%20L8.25%2C1%20C8.66421356%2C1%209%2C1.33578644%209%2C1.75%20L9%2C1.75%20C9%2C2.16421356%208.66421356%2C2.5%208.25%2C2.5%20L2.5%2C2.5%20L2.5%2C8.25%20C2.5%2C8.66421356%202.16421356%2C9%201.75%2C9%20L1.75%2C9%20C1.33578644%2C9%201%2C8.66421356%201%2C8.25%20L1%2C1%20Z%22%2F%3E%0A%20%20%3C%2Fg%3E%0A%3C%2Fsvg%3E%0A"),default}.mce-content-body figure.align-left{float:left}.mce-content-body figure.align-right{float:right}.mce-content-body figure.image.align-center{display:table;margin-left:auto;margin-right:auto}.mce-preview-object{border:1px solid gray;display:inline-block;line-height:0;margin:0 2px 0 2px;position:relative}.mce-preview-object .mce-shim{background:url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7);height:100%;left:0;position:absolute;top:0;width:100%}.mce-preview-object[data-mce-selected="2"] .mce-shim{display:none}.mce-object{background:transparent url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M4%203h16a1%201%200%200%201%201%201v16a1%201%200%200%201-1%201H4a1%201%200%200%201-1-1V4a1%201%200%200%201%201-1zm1%202v14h14V5H5zm4.79%202.565l5.64%204.028a.5.5%200%200%201%200%20.814l-5.64%204.028a.5.5%200%200%201-.79-.407V7.972a.5.5%200%200%201%20.79-.407z%22%20fill%3D%22%23cccccc%22%2F%3E%3C%2Fsvg%3E%0A") no-repeat center;border:1px dashed #aaa}.mce-pagebreak{border:1px dashed #aaa;cursor:default;display:block;height:5px;margin-top:15px;page-break-before:always;width:100%}@media print{.mce-pagebreak{border:0}}.tiny-pageembed .mce-shim{background:url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7);height:100%;left:0;position:absolute;top:0;width:100%}.tiny-pageembed[data-mce-selected="2"] .mce-shim{display:none}.tiny-pageembed{display:inline-block;position:relative}.tiny-pageembed--16by9,.tiny-pageembed--1by1,.tiny-pageembed--21by9,.tiny-pageembed--4by3{display:block;overflow:hidden;padding:0;position:relative;width:100%}.tiny-pageembed--21by9{padding-top:42.857143%}.tiny-pageembed--16by9{padding-top:56.25%}.tiny-pageembed--4by3{padding-top:75%}.tiny-pageembed--1by1{padding-top:100%}.tiny-pageembed--16by9 iframe,.tiny-pageembed--1by1 iframe,.tiny-pageembed--21by9 iframe,.tiny-pageembed--4by3 iframe{border:0;height:100%;left:0;position:absolute;top:0;width:100%}.mce-content-body[data-mce-placeholder]{position:relative}.mce-content-body[data-mce-placeholder]:not(.mce-visualblocks)::before{color:rgba(34,47,62,.7);content:attr(data-mce-placeholder);position:absolute}.mce-content-body:not([dir=rtl])[data-mce-placeholder]:not(.mce-visualblocks)::before{left:1px}.mce-content-body[dir=rtl][data-mce-placeholder]:not(.mce-visualblocks)::before{right:1px}.mce-content-body div.mce-resizehandle{background-color:#4099ff;border-color:#4099ff;border-style:solid;border-width:1px;box-sizing:border-box;height:10px;position:absolute;width:10px;z-index:1298}.mce-content-body div.mce-resizehandle:hover{background-color:#4099ff}.mce-content-body div.mce-resizehandle:nth-of-type(1){cursor:nwse-resize}.mce-content-body div.mce-resizehandle:nth-of-type(2){cursor:nesw-resize}.mce-content-body div.mce-resizehandle:nth-of-type(3){cursor:nwse-resize}.mce-content-body div.mce-resizehandle:nth-of-type(4){cursor:nesw-resize}.mce-content-body .mce-resize-backdrop{z-index:10000}.mce-content-body .mce-clonedresizable{cursor:default;opacity:.5;outline:1px dashed #000;position:absolute;z-index:10001}.mce-content-body .mce-clonedresizable.mce-resizetable-columns td,.mce-content-body .mce-clonedresizable.mce-resizetable-columns th{border:0}.mce-content-body .mce-resize-helper{background:#555;background:rgba(0,0,0,.75);border:1px;border-radius:3px;color:#fff;display:none;font-family:sans-serif;font-size:12px;line-height:14px;margin:5px 10px;padding:5px;position:absolute;white-space:nowrap;z-index:10002}.tox-rtc-user-selection{position:relative}.tox-rtc-user-cursor{bottom:0;cursor:default;position:absolute;top:0;width:2px}.tox-rtc-user-cursor::before{background-color:inherit;border-radius:50%;content:'';display:block;height:8px;position:absolute;right:-3px;top:-3px;width:8px}.tox-rtc-user-cursor:hover::after{background-color:inherit;border-radius:100px;box-sizing:border-box;color:#fff;content:attr(data-user);display:block;font-size:12px;font-weight:700;left:-5px;min-height:8px;min-width:8px;padding:0 12px;position:absolute;top:-11px;white-space:nowrap;z-index:1000}.tox-rtc-user-selection--1 .tox-rtc-user-cursor{background-color:#2dc26b}.tox-rtc-user-selection--2 .tox-rtc-user-cursor{background-color:#e03e2d}.tox-rtc-user-selection--3 .tox-rtc-user-cursor{background-color:#f1c40f}.tox-rtc-user-selection--4 .tox-rtc-user-cursor{background-color:#3598db}.tox-rtc-user-selection--5 .tox-rtc-user-cursor{background-color:#b96ad9}.tox-rtc-user-selection--6 .tox-rtc-user-cursor{background-color:#e67e23}.tox-rtc-user-selection--7 .tox-rtc-user-cursor{background-color:#aaa69d}.tox-rtc-user-selection--8 .tox-rtc-user-cursor{background-color:#f368e0}.tox-rtc-remote-image{background:#eaeaea url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2236%22%20height%3D%2212%22%20viewBox%3D%220%200%2036%2012%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Ccircle%20cx%3D%226%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%20%20%3Ccircle%20cx%3D%2218%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20begin%3D%22.33s%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%20%20%3Ccircle%20cx%3D%2230%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20begin%3D%22.66s%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%3C%2Fsvg%3E%0A") no-repeat center center;border:1px solid #ccc;min-height:240px;min-width:320px}.mce-match-marker{background:#aaa;color:#fff}.mce-match-marker-selected{background:#39f;color:#fff}.mce-match-marker-selected::-moz-selection{background:#39f;color:#fff}.mce-match-marker-selected::selection{background:#39f;color:#fff}.mce-content-body audio[data-mce-selected],.mce-content-body embed[data-mce-selected],.mce-content-body img[data-mce-selected],.mce-content-body object[data-mce-selected],.mce-content-body table[data-mce-selected],.mce-content-body video[data-mce-selected]{outline:3px solid #4099ff}.mce-content-body hr[data-mce-selected]{outline:3px solid #4099ff;outline-offset:1px}.mce-content-body [contentEditable=false] [contentEditable=true]:focus{outline:3px solid #4099ff}.mce-content-body [contentEditable=false] [contentEditable=true]:hover{outline:3px solid #4099ff}.mce-content-body [contentEditable=false][data-mce-selected]{cursor:not-allowed;outline:3px solid #4099ff}.mce-content-body.mce-content-readonly [contentEditable=true]:focus,.mce-content-body.mce-content-readonly [contentEditable=true]:hover{outline:0}.mce-content-body [data-mce-selected=inline-boundary]{background-color:#4099ff}.mce-content-body .mce-edit-focus{outline:3px solid #4099ff}.mce-content-body td[data-mce-selected],.mce-content-body th[data-mce-selected]{position:relative}.mce-content-body td[data-mce-selected]::-moz-selection,.mce-content-body th[data-mce-selected]::-moz-selection{background:0 0}.mce-content-body td[data-mce-selected]::selection,.mce-content-body th[data-mce-selected]::selection{background:0 0}.mce-content-body td[data-mce-selected] *,.mce-content-body th[data-mce-selected] *{outline:0;-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.mce-content-body td[data-mce-selected]::after,.mce-content-body th[data-mce-selected]::after{background-color:rgba(180,215,255,.7);border:1px solid transparent;bottom:-1px;content:'';left:-1px;mix-blend-mode:lighten;position:absolute;right:-1px;top:-1px}@media screen and (-ms-high-contrast:active),(-ms-high-contrast:none){.mce-content-body td[data-mce-selected]::after,.mce-content-body th[data-mce-selected]::after{border-color:rgba(0,84,180,.7)}}.mce-content-body img::-moz-selection{background:0 0}.mce-content-body img::selection{background:0 0}.ephox-snooker-resizer-bar{background-color:#4099ff;opacity:0;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.ephox-snooker-resizer-cols{cursor:col-resize}.ephox-snooker-resizer-rows{cursor:row-resize}.ephox-snooker-resizer-bar.ephox-snooker-resizer-bar-dragging{opacity:1}.mce-spellchecker-word{background-image:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'4'%20height%3D'4'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20stroke%3D'%23ff0000'%20fill%3D'none'%20stroke-linecap%3D'round'%20stroke-opacity%3D'.75'%20d%3D'M0%203L2%201%204%203'%2F%3E%3C%2Fsvg%3E%0A");background-position:0 calc(100% + 1px);background-repeat:repeat-x;background-size:auto 6px;cursor:default;height:2rem}.mce-spellchecker-grammar{background-image:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'4'%20height%3D'4'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20stroke%3D'%2300A835'%20fill%3D'none'%20stroke-linecap%3D'round'%20d%3D'M0%203L2%201%204%203'%2F%3E%3C%2Fsvg%3E%0A");background-position:0 calc(100% + 1px);background-repeat:repeat-x;background-size:auto 6px;cursor:default}.mce-toc{border:1px solid gray}.mce-toc h2{margin:4px}.mce-toc li{list-style-type:none}.mce-item-table:not([border]),.mce-item-table:not([border]) caption,.mce-item-table:not([border]) td,.mce-item-table:not([border]) th,.mce-item-table[border="0"],.mce-item-table[border="0"] caption,.mce-item-table[border="0"] td,.mce-item-table[border="0"] th,table[style*="border-width: 0px"],table[style*="border-width: 0px"] caption,table[style*="border-width: 0px"] td,table[style*="border-width: 0px"] th{border:1px dashed #bbb}.mce-visualblocks address,.mce-visualblocks article,.mce-visualblocks aside,.mce-visualblocks blockquote,.mce-visualblocks div:not([data-mce-bogus]),.mce-visualblocks dl,.mce-visualblocks figcaption,.mce-visualblocks figure,.mce-visualblocks h1,.mce-visualblocks h2,.mce-visualblocks h3,.mce-visualblocks h4,.mce-visualblocks h5,.mce-visualblocks h6,.mce-visualblocks hgroup,.mce-visualblocks ol,.mce-visualblocks p,.mce-visualblocks pre,.mce-visualblocks section,.mce-visualblocks ul{background-repeat:no-repeat;border:1px dashed #bbb;margin-left:3px;padding-top:10px}.mce-visualblocks p{background-image:url(data:image/gif;base64,R0lGODlhCQAJAJEAAAAAAP///7u7u////yH5BAEAAAMALAAAAAAJAAkAAAIQnG+CqCN/mlyvsRUpThG6AgA7)}.mce-visualblocks h1{background-image:url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybGu1JuxHoAfRNRW3TWXyF2YiRUAOw==)}.mce-visualblocks h2{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8Hybbx4oOuqgTynJd6bGlWg3DkJzoaUAAAOw==)}.mce-visualblocks h3{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIZjI8Hybbx4oOuqgTynJf2Ln2NOHpQpmhAAQA7)}.mce-visualblocks h4{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxInR0zqeAdhtJlXwV1oCll2HaWgAAOw==)}.mce-visualblocks h5{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxIoiuwjane4iq5GlW05GgIkIZUAAAOw==)}.mce-visualblocks h6{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxIoiuwjan04jep1iZ1XRlAo5bVgAAOw==)}.mce-visualblocks div:not([data-mce-bogus]){background-image:url(data:image/gif;base64,R0lGODlhEgAKAIABALu7u////yH5BAEAAAEALAAAAAASAAoAAAIfjI9poI0cgDywrhuxfbrzDEbQM2Ei5aRjmoySW4pAAQA7)}.mce-visualblocks section{background-image:url(data:image/gif;base64,R0lGODlhKAAKAIABALu7u////yH5BAEAAAEALAAAAAAoAAoAAAI5jI+pywcNY3sBWHdNrplytD2ellDeSVbp+GmWqaDqDMepc8t17Y4vBsK5hDyJMcI6KkuYU+jpjLoKADs=)}.mce-visualblocks article{background-image:url(data:image/gif;base64,R0lGODlhKgAKAIABALu7u////yH5BAEAAAEALAAAAAAqAAoAAAI6jI+pywkNY3wG0GBvrsd2tXGYSGnfiF7ikpXemTpOiJScasYoDJJrjsG9gkCJ0ag6KhmaIe3pjDYBBQA7)}.mce-visualblocks blockquote{background-image:url(data:image/gif;base64,R0lGODlhPgAKAIABALu7u////yH5BAEAAAEALAAAAAA+AAoAAAJPjI+py+0Knpz0xQDyuUhvfoGgIX5iSKZYgq5uNL5q69asZ8s5rrf0yZmpNkJZzFesBTu8TOlDVAabUyatguVhWduud3EyiUk45xhTTgMBBQA7)}.mce-visualblocks address{background-image:url(data:image/gif;base64,R0lGODlhLQAKAIABALu7u////yH5BAEAAAEALAAAAAAtAAoAAAI/jI+pywwNozSP1gDyyZcjb3UaRpXkWaXmZW4OqKLhBmLs+K263DkJK7OJeifh7FicKD9A1/IpGdKkyFpNmCkAADs=)}.mce-visualblocks pre{background-image:url(data:image/gif;base64,R0lGODlhFQAKAIABALu7uwAAACH5BAEAAAEALAAAAAAVAAoAAAIjjI+ZoN0cgDwSmnpz1NCueYERhnibZVKLNnbOq8IvKpJtVQAAOw==)}.mce-visualblocks figure{background-image:url(data:image/gif;base64,R0lGODlhJAAKAIAAALu7u////yH5BAEAAAEALAAAAAAkAAoAAAI0jI+py+2fwAHUSFvD3RlvG4HIp4nX5JFSpnZUJ6LlrM52OE7uSWosBHScgkSZj7dDKnWAAgA7)}.mce-visualblocks figcaption{border:1px dashed #bbb}.mce-visualblocks hgroup{background-image:url(data:image/gif;base64,R0lGODlhJwAKAIABALu7uwAAACH5BAEAAAEALAAAAAAnAAoAAAI3jI+pywYNI3uB0gpsRtt5fFnfNZaVSYJil4Wo03Hv6Z62uOCgiXH1kZIIJ8NiIxRrAZNMZAtQAAA7)}.mce-visualblocks aside{background-image:url(data:image/gif;base64,R0lGODlhHgAKAIABAKqqqv///yH5BAEAAAEALAAAAAAeAAoAAAItjI+pG8APjZOTzgtqy7I3f1yehmQcFY4WKZbqByutmW4aHUd6vfcVbgudgpYCADs=)}.mce-visualblocks ul{background-image:url(data:image/gif;base64,R0lGODlhDQAKAIAAALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybGuYnqUVSjvw26DzzXiqIDlVwAAOw==)}.mce-visualblocks ol{background-image:url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybH6HHt0qourxC6CvzXieHyeWQAAOw==)}.mce-visualblocks dl{background-image:url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybEOnmOvUoWznTqeuEjNSCqeGRUAOw==)}.mce-visualblocks:not([dir=rtl]) address,.mce-visualblocks:not([dir=rtl]) article,.mce-visualblocks:not([dir=rtl]) aside,.mce-visualblocks:not([dir=rtl]) blockquote,.mce-visualblocks:not([dir=rtl]) div:not([data-mce-bogus]),.mce-visualblocks:not([dir=rtl]) dl,.mce-visualblocks:not([dir=rtl]) figcaption,.mce-visualblocks:not([dir=rtl]) figure,.mce-visualblocks:not([dir=rtl]) h1,.mce-visualblocks:not([dir=rtl]) h2,.mce-visualblocks:not([dir=rtl]) h3,.mce-visualblocks:not([dir=rtl]) h4,.mce-visualblocks:not([dir=rtl]) h5,.mce-visualblocks:not([dir=rtl]) h6,.mce-visualblocks:not([dir=rtl]) hgroup,.mce-visualblocks:not([dir=rtl]) ol,.mce-visualblocks:not([dir=rtl]) p,.mce-visualblocks:not([dir=rtl]) pre,.mce-visualblocks:not([dir=rtl]) section,.mce-visualblocks:not([dir=rtl]) ul{margin-left:3px}.mce-visualblocks[dir=rtl] address,.mce-visualblocks[dir=rtl] article,.mce-visualblocks[dir=rtl] aside,.mce-visualblocks[dir=rtl] blockquote,.mce-visualblocks[dir=rtl] div:not([data-mce-bogus]),.mce-visualblocks[dir=rtl] dl,.mce-visualblocks[dir=rtl] figcaption,.mce-visualblocks[dir=rtl] figure,.mce-visualblocks[dir=rtl] h1,.mce-visualblocks[dir=rtl] h2,.mce-visualblocks[dir=rtl] h3,.mce-visualblocks[dir=rtl] h4,.mce-visualblocks[dir=rtl] h5,.mce-visualblocks[dir=rtl] h6,.mce-visualblocks[dir=rtl] hgroup,.mce-visualblocks[dir=rtl] ol,.mce-visualblocks[dir=rtl] p,.mce-visualblocks[dir=rtl] pre,.mce-visualblocks[dir=rtl] section,.mce-visualblocks[dir=rtl] ul{background-position-x:right;margin-right:3px}.mce-nbsp,.mce-shy{background:#aaa}.mce-shy::after{content:'-'}body{font-family:sans-serif}table{border-collapse:collapse} diff --git a/ui/public/tinymce/skins/ui/oxide-dark/content.mobile.css b/ui/public/tinymce/skins/ui/oxide-dark/content.mobile.css new file mode 100644 index 0000000..4bdb8ba --- /dev/null +++ b/ui/public/tinymce/skins/ui/oxide-dark/content.mobile.css @@ -0,0 +1,29 @@ +/** + * Copyright (c) Tiny Technologies, Inc. All rights reserved. + * Licensed under the LGPL or a commercial license. + * For LGPL see License.txt in the project root for license information. + * For commercial licenses see https://www.tiny.cloud/ + */ +.tinymce-mobile-unfocused-selections .tinymce-mobile-unfocused-selection { + /* Note: this file is used inside the content, so isn't part of theming */ + background-color: green; + display: inline-block; + opacity: 0.5; + position: absolute; +} +body { + -webkit-text-size-adjust: none; +} +body img { + /* this is related to the content margin */ + max-width: 96vw; +} +body table img { + max-width: 95%; +} +body { + font-family: sans-serif; +} +table { + border-collapse: collapse; +} diff --git a/ui/public/tinymce/skins/ui/oxide-dark/content.mobile.min.css b/ui/public/tinymce/skins/ui/oxide-dark/content.mobile.min.css new file mode 100644 index 0000000..35f7dc0 --- /dev/null +++ b/ui/public/tinymce/skins/ui/oxide-dark/content.mobile.min.css @@ -0,0 +1,7 @@ +/** + * Copyright (c) Tiny Technologies, Inc. All rights reserved. + * Licensed under the LGPL or a commercial license. + * For LGPL see License.txt in the project root for license information. + * For commercial licenses see https://www.tiny.cloud/ + */ +.tinymce-mobile-unfocused-selections .tinymce-mobile-unfocused-selection{background-color:green;display:inline-block;opacity:.5;position:absolute}body{-webkit-text-size-adjust:none}body img{max-width:96vw}body table img{max-width:95%}body{font-family:sans-serif}table{border-collapse:collapse} diff --git a/ui/public/tinymce/skins/ui/oxide-dark/fonts/tinymce-mobile.woff b/ui/public/tinymce/skins/ui/oxide-dark/fonts/tinymce-mobile.woff new file mode 100644 index 0000000..1e3be03 Binary files /dev/null and b/ui/public/tinymce/skins/ui/oxide-dark/fonts/tinymce-mobile.woff differ diff --git a/ui/public/tinymce/skins/ui/oxide-dark/skin.css b/ui/public/tinymce/skins/ui/oxide-dark/skin.css new file mode 100644 index 0000000..d34b9c1 --- /dev/null +++ b/ui/public/tinymce/skins/ui/oxide-dark/skin.css @@ -0,0 +1,3047 @@ +/** + * Copyright (c) Tiny Technologies, Inc. All rights reserved. + * Licensed under the LGPL or a commercial license. + * For LGPL see License.txt in the project root for license information. + * For commercial licenses see https://www.tiny.cloud/ + */ +.tox { + box-shadow: none; + box-sizing: content-box; + color: #2A3746; + cursor: auto; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + font-size: 16px; + font-style: normal; + font-weight: normal; + line-height: normal; + -webkit-tap-highlight-color: transparent; + text-decoration: none; + text-shadow: none; + text-transform: none; + vertical-align: initial; + white-space: normal; +} +.tox *:not(svg):not(rect) { + box-sizing: inherit; + color: inherit; + cursor: inherit; + direction: inherit; + font-family: inherit; + font-size: inherit; + font-style: inherit; + font-weight: inherit; + line-height: inherit; + -webkit-tap-highlight-color: inherit; + text-align: inherit; + text-decoration: inherit; + text-shadow: inherit; + text-transform: inherit; + vertical-align: inherit; + white-space: inherit; +} +.tox *:not(svg):not(rect) { + /* stylelint-disable-line no-duplicate-selectors */ + background: transparent; + border: 0; + box-shadow: none; + float: none; + height: auto; + margin: 0; + max-width: none; + outline: 0; + padding: 0; + position: static; + width: auto; +} +.tox:not([dir=rtl]) { + direction: ltr; + text-align: left; +} +.tox[dir=rtl] { + direction: rtl; + text-align: right; +} +.tox-tinymce { + border: 1px solid #000000; + border-radius: 0; + box-shadow: none; + box-sizing: border-box; + display: flex; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + overflow: hidden; + position: relative; + visibility: inherit !important; +} +.tox-tinymce-inline { + border: none; + box-shadow: none; +} +.tox-tinymce-inline .tox-editor-header { + background-color: transparent; + border: 1px solid #000000; + border-radius: 0; + box-shadow: none; +} +.tox-tinymce-aux { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + z-index: 1300; +} +.tox-tinymce *:focus, +.tox-tinymce-aux *:focus { + outline: none; +} +button::-moz-focus-inner { + border: 0; +} +.tox[dir=rtl] .tox-icon--flip svg { + transform: rotateY(180deg); +} +.tox .accessibility-issue__header { + align-items: center; + display: flex; + margin-bottom: 4px; +} +.tox .accessibility-issue__description { + align-items: stretch; + border: 1px solid #000000; + border-radius: 3px; + display: flex; + justify-content: space-between; +} +.tox .accessibility-issue__description > div { + padding-bottom: 4px; +} +.tox .accessibility-issue__description > div > div { + align-items: center; + display: flex; + margin-bottom: 4px; +} +.tox .accessibility-issue__description > *:last-child:not(:only-child) { + border-color: #000000; + border-style: solid; +} +.tox .accessibility-issue__repair { + margin-top: 16px; +} +.tox .tox-dialog__body-content .accessibility-issue--info .accessibility-issue__description { + background-color: rgba(32, 122, 183, 0.5); + border-color: #207ab7; + color: #fff; +} +.tox .tox-dialog__body-content .accessibility-issue--info .accessibility-issue__description > *:last-child { + border-color: #207ab7; +} +.tox .tox-dialog__body-content .accessibility-issue--info .tox-form__group h2 { + color: #fff; +} +.tox .tox-dialog__body-content .accessibility-issue--info .tox-icon svg { + fill: #fff; +} +.tox .tox-dialog__body-content .accessibility-issue--info a .tox-icon { + color: #fff; +} +.tox .tox-dialog__body-content .accessibility-issue--warn .accessibility-issue__description { + background-color: rgba(255, 165, 0, 0.5); + border-color: rgba(255, 165, 0, 0.8); + color: #fff; +} +.tox .tox-dialog__body-content .accessibility-issue--warn .accessibility-issue__description > *:last-child { + border-color: rgba(255, 165, 0, 0.8); +} +.tox .tox-dialog__body-content .accessibility-issue--warn .tox-form__group h2 { + color: #fff; +} +.tox .tox-dialog__body-content .accessibility-issue--warn .tox-icon svg { + fill: #fff; +} +.tox .tox-dialog__body-content .accessibility-issue--warn a .tox-icon { + color: #fff; +} +.tox .tox-dialog__body-content .accessibility-issue--error .accessibility-issue__description { + background-color: rgba(204, 0, 0, 0.5); + border-color: rgba(204, 0, 0, 0.8); + color: #fff; +} +.tox .tox-dialog__body-content .accessibility-issue--error .accessibility-issue__description > *:last-child { + border-color: rgba(204, 0, 0, 0.8); +} +.tox .tox-dialog__body-content .accessibility-issue--error .tox-form__group h2 { + color: #fff; +} +.tox .tox-dialog__body-content .accessibility-issue--error .tox-icon svg { + fill: #fff; +} +.tox .tox-dialog__body-content .accessibility-issue--error a .tox-icon { + color: #fff; +} +.tox .tox-dialog__body-content .accessibility-issue--success .accessibility-issue__description { + background-color: rgba(120, 171, 70, 0.5); + border-color: rgba(120, 171, 70, 0.8); + color: #fff; +} +.tox .tox-dialog__body-content .accessibility-issue--success .accessibility-issue__description > *:last-child { + border-color: rgba(120, 171, 70, 0.8); +} +.tox .tox-dialog__body-content .accessibility-issue--success .tox-form__group h2 { + color: #fff; +} +.tox .tox-dialog__body-content .accessibility-issue--success .tox-icon svg { + fill: #fff; +} +.tox .tox-dialog__body-content .accessibility-issue--success a .tox-icon { + color: #fff; +} +.tox .tox-dialog__body-content .accessibility-issue__header h1, +.tox .tox-dialog__body-content .tox-form__group .accessibility-issue__description h2 { + margin-top: 0; +} +.tox:not([dir=rtl]) .tox-dialog__body-content .accessibility-issue__header .tox-button { + margin-left: 4px; +} +.tox:not([dir=rtl]) .tox-dialog__body-content .accessibility-issue__header > *:nth-last-child(2) { + margin-left: auto; +} +.tox:not([dir=rtl]) .tox-dialog__body-content .accessibility-issue__description { + padding: 4px 4px 4px 8px; +} +.tox:not([dir=rtl]) .tox-dialog__body-content .accessibility-issue__description > *:last-child { + border-left-width: 1px; + padding-left: 4px; +} +.tox[dir=rtl] .tox-dialog__body-content .accessibility-issue__header .tox-button { + margin-right: 4px; +} +.tox[dir=rtl] .tox-dialog__body-content .accessibility-issue__header > *:nth-last-child(2) { + margin-right: auto; +} +.tox[dir=rtl] .tox-dialog__body-content .accessibility-issue__description { + padding: 4px 8px 4px 4px; +} +.tox[dir=rtl] .tox-dialog__body-content .accessibility-issue__description > *:last-child { + border-right-width: 1px; + padding-right: 4px; +} +.tox .tox-anchorbar { + display: flex; + flex: 0 0 auto; +} +.tox .tox-bar { + display: flex; + flex: 0 0 auto; +} +.tox .tox-button { + background-color: #207ab7; + background-image: none; + background-position: 0 0; + background-repeat: repeat; + border-color: #207ab7; + border-radius: 3px; + border-style: solid; + border-width: 1px; + box-shadow: none; + box-sizing: border-box; + color: #fff; + cursor: pointer; + display: inline-block; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + font-size: 14px; + font-style: normal; + font-weight: bold; + letter-spacing: normal; + line-height: 24px; + margin: 0; + outline: none; + padding: 4px 16px; + text-align: center; + text-decoration: none; + text-transform: none; + white-space: nowrap; +} +.tox .tox-button[disabled] { + background-color: #207ab7; + background-image: none; + border-color: #207ab7; + box-shadow: none; + color: rgba(255, 255, 255, 0.5); + cursor: not-allowed; +} +.tox .tox-button:focus:not(:disabled) { + background-color: #1c6ca1; + background-image: none; + border-color: #1c6ca1; + box-shadow: none; + color: #fff; +} +.tox .tox-button:hover:not(:disabled) { + background-color: #1c6ca1; + background-image: none; + border-color: #1c6ca1; + box-shadow: none; + color: #fff; +} +.tox .tox-button:active:not(:disabled) { + background-color: #185d8c; + background-image: none; + border-color: #185d8c; + box-shadow: none; + color: #fff; +} +.tox .tox-button--secondary { + background-color: #3d546f; + background-image: none; + background-position: 0 0; + background-repeat: repeat; + border-color: #3d546f; + border-radius: 3px; + border-style: solid; + border-width: 1px; + box-shadow: none; + color: #fff; + font-size: 14px; + font-style: normal; + font-weight: bold; + letter-spacing: normal; + outline: none; + padding: 4px 16px; + text-decoration: none; + text-transform: none; +} +.tox .tox-button--secondary[disabled] { + background-color: #3d546f; + background-image: none; + border-color: #3d546f; + box-shadow: none; + color: rgba(255, 255, 255, 0.5); +} +.tox .tox-button--secondary:focus:not(:disabled) { + background-color: #34485f; + background-image: none; + border-color: #34485f; + box-shadow: none; + color: #fff; +} +.tox .tox-button--secondary:hover:not(:disabled) { + background-color: #34485f; + background-image: none; + border-color: #34485f; + box-shadow: none; + color: #fff; +} +.tox .tox-button--secondary:active:not(:disabled) { + background-color: #2b3b4e; + background-image: none; + border-color: #2b3b4e; + box-shadow: none; + color: #fff; +} +.tox .tox-button--icon, +.tox .tox-button.tox-button--icon, +.tox .tox-button.tox-button--secondary.tox-button--icon { + padding: 4px; +} +.tox .tox-button--icon .tox-icon svg, +.tox .tox-button.tox-button--icon .tox-icon svg, +.tox .tox-button.tox-button--secondary.tox-button--icon .tox-icon svg { + display: block; + fill: currentColor; +} +.tox .tox-button-link { + background: 0; + border: none; + box-sizing: border-box; + cursor: pointer; + display: inline-block; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + font-size: 16px; + font-weight: normal; + line-height: 1.3; + margin: 0; + padding: 0; + white-space: nowrap; +} +.tox .tox-button-link--sm { + font-size: 14px; +} +.tox .tox-button--naked { + background-color: transparent; + border-color: transparent; + box-shadow: unset; + color: #fff; +} +.tox .tox-button--naked[disabled] { + background-color: #3d546f; + border-color: #3d546f; + box-shadow: none; + color: rgba(255, 255, 255, 0.5); +} +.tox .tox-button--naked:hover:not(:disabled) { + background-color: #34485f; + border-color: #34485f; + box-shadow: none; + color: #fff; +} +.tox .tox-button--naked:focus:not(:disabled) { + background-color: #34485f; + border-color: #34485f; + box-shadow: none; + color: #fff; +} +.tox .tox-button--naked:active:not(:disabled) { + background-color: #2b3b4e; + border-color: #2b3b4e; + box-shadow: none; + color: #fff; +} +.tox .tox-button--naked .tox-icon svg { + fill: currentColor; +} +.tox .tox-button--naked.tox-button--icon:hover:not(:disabled) { + color: #fff; +} +.tox .tox-checkbox { + align-items: center; + border-radius: 3px; + cursor: pointer; + display: flex; + height: 36px; + min-width: 36px; +} +.tox .tox-checkbox__input { + /* Hide from view but visible to screen readers */ + height: 1px; + overflow: hidden; + position: absolute; + top: auto; + width: 1px; +} +.tox .tox-checkbox__icons { + align-items: center; + border-radius: 3px; + box-shadow: 0 0 0 2px transparent; + box-sizing: content-box; + display: flex; + height: 24px; + justify-content: center; + padding: calc(4px - 1px); + width: 24px; +} +.tox .tox-checkbox__icons .tox-checkbox-icon__unchecked svg { + display: block; + fill: rgba(255, 255, 255, 0.2); +} +.tox .tox-checkbox__icons .tox-checkbox-icon__indeterminate svg { + display: none; + fill: #207ab7; +} +.tox .tox-checkbox__icons .tox-checkbox-icon__checked svg { + display: none; + fill: #207ab7; +} +.tox .tox-checkbox--disabled { + color: rgba(255, 255, 255, 0.5); + cursor: not-allowed; +} +.tox .tox-checkbox--disabled .tox-checkbox__icons .tox-checkbox-icon__checked svg { + fill: rgba(255, 255, 255, 0.5); +} +.tox .tox-checkbox--disabled .tox-checkbox__icons .tox-checkbox-icon__unchecked svg { + fill: rgba(255, 255, 255, 0.5); +} +.tox .tox-checkbox--disabled .tox-checkbox__icons .tox-checkbox-icon__indeterminate svg { + fill: rgba(255, 255, 255, 0.5); +} +.tox input.tox-checkbox__input:checked + .tox-checkbox__icons .tox-checkbox-icon__unchecked svg { + display: none; +} +.tox input.tox-checkbox__input:checked + .tox-checkbox__icons .tox-checkbox-icon__checked svg { + display: block; +} +.tox input.tox-checkbox__input:indeterminate + .tox-checkbox__icons .tox-checkbox-icon__unchecked svg { + display: none; +} +.tox input.tox-checkbox__input:indeterminate + .tox-checkbox__icons .tox-checkbox-icon__indeterminate svg { + display: block; +} +.tox input.tox-checkbox__input:focus + .tox-checkbox__icons { + border-radius: 3px; + box-shadow: inset 0 0 0 1px #207ab7; + padding: calc(4px - 1px); +} +.tox:not([dir=rtl]) .tox-checkbox__label { + margin-left: 4px; +} +.tox:not([dir=rtl]) .tox-checkbox__input { + left: -10000px; +} +.tox:not([dir=rtl]) .tox-bar .tox-checkbox { + margin-left: 4px; +} +.tox[dir=rtl] .tox-checkbox__label { + margin-right: 4px; +} +.tox[dir=rtl] .tox-checkbox__input { + right: -10000px; +} +.tox[dir=rtl] .tox-bar .tox-checkbox { + margin-right: 4px; +} +.tox { + /* stylelint-disable-next-line no-descending-specificity */ +} +.tox .tox-collection--toolbar .tox-collection__group { + display: flex; + padding: 0; +} +.tox .tox-collection--grid .tox-collection__group { + display: flex; + flex-wrap: wrap; + max-height: 208px; + overflow-x: hidden; + overflow-y: auto; + padding: 0; +} +.tox .tox-collection--list .tox-collection__group { + border-bottom-width: 0; + border-color: #1a1a1a; + border-left-width: 0; + border-right-width: 0; + border-style: solid; + border-top-width: 1px; + padding: 4px 0; +} +.tox .tox-collection--list .tox-collection__group:first-child { + border-top-width: 0; +} +.tox .tox-collection__group-heading { + background-color: #333333; + color: #fff; + cursor: default; + font-size: 12px; + font-style: normal; + font-weight: normal; + margin-bottom: 4px; + margin-top: -4px; + padding: 4px 8px; + text-transform: none; + -webkit-touch-callout: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} +.tox .tox-collection__item { + align-items: center; + color: #fff; + cursor: pointer; + display: flex; + -webkit-touch-callout: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} +.tox .tox-collection--list .tox-collection__item { + padding: 4px 8px; +} +.tox .tox-collection--toolbar .tox-collection__item { + border-radius: 3px; + padding: 4px; +} +.tox .tox-collection--grid .tox-collection__item { + border-radius: 3px; + padding: 4px; +} +.tox .tox-collection--list .tox-collection__item--enabled { + background-color: #2b3b4e; + color: #fff; +} +.tox .tox-collection--list .tox-collection__item--active { + background-color: #4a5562; +} +.tox .tox-collection--toolbar .tox-collection__item--enabled { + background-color: #757d87; + color: #fff; +} +.tox .tox-collection--toolbar .tox-collection__item--active { + background-color: #4a5562; +} +.tox .tox-collection--grid .tox-collection__item--enabled { + background-color: #757d87; + color: #fff; +} +.tox .tox-collection--grid .tox-collection__item--active:not(.tox-collection__item--state-disabled) { + background-color: #4a5562; + color: #fff; +} +.tox .tox-collection--list .tox-collection__item--active:not(.tox-collection__item--state-disabled) { + color: #fff; +} +.tox .tox-collection--toolbar .tox-collection__item--active:not(.tox-collection__item--state-disabled) { + color: #fff; +} +.tox .tox-collection__item-icon, +.tox .tox-collection__item-checkmark { + align-items: center; + display: flex; + height: 24px; + justify-content: center; + width: 24px; +} +.tox .tox-collection__item-icon svg, +.tox .tox-collection__item-checkmark svg { + fill: currentColor; +} +.tox .tox-collection--toolbar-lg .tox-collection__item-icon { + height: 48px; + width: 48px; +} +.tox .tox-collection__item-label { + color: currentColor; + display: inline-block; + flex: 1; + -ms-flex-preferred-size: auto; + font-size: 14px; + font-style: normal; + font-weight: normal; + line-height: 24px; + text-transform: none; + word-break: break-all; +} +.tox .tox-collection__item-accessory { + color: rgba(255, 255, 255, 0.5); + display: inline-block; + font-size: 14px; + height: 24px; + line-height: 24px; + text-transform: none; +} +.tox .tox-collection__item-caret { + align-items: center; + display: flex; + min-height: 24px; +} +.tox .tox-collection__item-caret::after { + content: ''; + font-size: 0; + min-height: inherit; +} +.tox .tox-collection__item-caret svg { + fill: #fff; +} +.tox .tox-collection__item--state-disabled { + background-color: transparent; + color: rgba(255, 255, 255, 0.5); + cursor: not-allowed; +} +.tox .tox-collection__item--state-disabled .tox-collection__item-caret svg { + fill: rgba(255, 255, 255, 0.5); +} +.tox .tox-collection--list .tox-collection__item:not(.tox-collection__item--enabled) .tox-collection__item-checkmark svg { + display: none; +} +.tox .tox-collection--list .tox-collection__item:not(.tox-collection__item--enabled) .tox-collection__item-accessory + .tox-collection__item-checkmark { + display: none; +} +.tox .tox-collection--horizontal { + background-color: #2b3b4e; + border: 1px solid #1a1a1a; + border-radius: 3px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); + display: flex; + flex: 0 0 auto; + flex-shrink: 0; + flex-wrap: nowrap; + margin-bottom: 0; + overflow-x: auto; + padding: 0; +} +.tox .tox-collection--horizontal .tox-collection__group { + align-items: center; + display: flex; + flex-wrap: nowrap; + margin: 0; + padding: 0 4px; +} +.tox .tox-collection--horizontal .tox-collection__item { + height: 34px; + margin: 2px 0 3px 0; + padding: 0 4px; +} +.tox .tox-collection--horizontal .tox-collection__item-label { + white-space: nowrap; +} +.tox .tox-collection--horizontal .tox-collection__item-caret { + margin-left: 4px; +} +.tox .tox-collection__item-container { + display: flex; +} +.tox .tox-collection__item-container--row { + align-items: center; + flex: 1 1 auto; + flex-direction: row; +} +.tox .tox-collection__item-container--row.tox-collection__item-container--align-left { + margin-right: auto; +} +.tox .tox-collection__item-container--row.tox-collection__item-container--align-right { + justify-content: flex-end; + margin-left: auto; +} +.tox .tox-collection__item-container--row.tox-collection__item-container--valign-top { + align-items: flex-start; + margin-bottom: auto; +} +.tox .tox-collection__item-container--row.tox-collection__item-container--valign-middle { + align-items: center; +} +.tox .tox-collection__item-container--row.tox-collection__item-container--valign-bottom { + align-items: flex-end; + margin-top: auto; +} +.tox .tox-collection__item-container--column { + -ms-grid-row-align: center; + align-self: center; + flex: 1 1 auto; + flex-direction: column; +} +.tox .tox-collection__item-container--column.tox-collection__item-container--align-left { + align-items: flex-start; +} +.tox .tox-collection__item-container--column.tox-collection__item-container--align-right { + align-items: flex-end; +} +.tox .tox-collection__item-container--column.tox-collection__item-container--valign-top { + align-self: flex-start; +} +.tox .tox-collection__item-container--column.tox-collection__item-container--valign-middle { + -ms-grid-row-align: center; + align-self: center; +} +.tox .tox-collection__item-container--column.tox-collection__item-container--valign-bottom { + align-self: flex-end; +} +.tox:not([dir=rtl]) .tox-collection--horizontal .tox-collection__group:not(:last-of-type) { + border-right: 1px solid #000000; +} +.tox:not([dir=rtl]) .tox-collection--list .tox-collection__item > *:not(:first-child) { + margin-left: 8px; +} +.tox:not([dir=rtl]) .tox-collection--list .tox-collection__item > .tox-collection__item-label:first-child { + margin-left: 4px; +} +.tox:not([dir=rtl]) .tox-collection__item-accessory { + margin-left: 16px; + text-align: right; +} +.tox:not([dir=rtl]) .tox-collection .tox-collection__item-caret { + margin-left: 16px; +} +.tox[dir=rtl] .tox-collection--horizontal .tox-collection__group:not(:last-of-type) { + border-left: 1px solid #000000; +} +.tox[dir=rtl] .tox-collection--list .tox-collection__item > *:not(:first-child) { + margin-right: 8px; +} +.tox[dir=rtl] .tox-collection--list .tox-collection__item > .tox-collection__item-label:first-child { + margin-right: 4px; +} +.tox[dir=rtl] .tox-collection__item-accessory { + margin-right: 16px; + text-align: left; +} +.tox[dir=rtl] .tox-collection .tox-collection__item-caret { + margin-right: 16px; + transform: rotateY(180deg); +} +.tox[dir=rtl] .tox-collection--horizontal .tox-collection__item-caret { + margin-right: 4px; +} +.tox .tox-color-picker-container { + display: flex; + flex-direction: row; + height: 225px; + margin: 0; +} +.tox .tox-sv-palette { + box-sizing: border-box; + display: flex; + height: 100%; +} +.tox .tox-sv-palette-spectrum { + height: 100%; +} +.tox .tox-sv-palette, +.tox .tox-sv-palette-spectrum { + width: 225px; +} +.tox .tox-sv-palette-thumb { + background: none; + border: 1px solid black; + border-radius: 50%; + box-sizing: content-box; + height: 12px; + position: absolute; + width: 12px; +} +.tox .tox-sv-palette-inner-thumb { + border: 1px solid white; + border-radius: 50%; + height: 10px; + position: absolute; + width: 10px; +} +.tox .tox-hue-slider { + box-sizing: border-box; + height: 100%; + width: 25px; +} +.tox .tox-hue-slider-spectrum { + background: linear-gradient(to bottom, #f00, #ff0080, #f0f, #8000ff, #00f, #0080ff, #0ff, #00ff80, #0f0, #80ff00, #ff0, #ff8000, #f00); + height: 100%; + width: 100%; +} +.tox .tox-hue-slider, +.tox .tox-hue-slider-spectrum { + width: 20px; +} +.tox .tox-hue-slider-thumb { + background: white; + border: 1px solid black; + box-sizing: content-box; + height: 4px; + width: 100%; +} +.tox .tox-rgb-form { + display: flex; + flex-direction: column; + justify-content: space-between; +} +.tox .tox-rgb-form div { + align-items: center; + display: flex; + justify-content: space-between; + margin-bottom: 5px; + width: inherit; +} +.tox .tox-rgb-form input { + width: 6em; +} +.tox .tox-rgb-form input.tox-invalid { + /* Need !important to override Chrome's focus styling unfortunately */ + border: 1px solid red !important; +} +.tox .tox-rgb-form .tox-rgba-preview { + border: 1px solid black; + flex-grow: 2; + margin-bottom: 0; +} +.tox:not([dir=rtl]) .tox-sv-palette { + margin-right: 15px; +} +.tox:not([dir=rtl]) .tox-hue-slider { + margin-right: 15px; +} +.tox:not([dir=rtl]) .tox-hue-slider-thumb { + margin-left: -1px; +} +.tox:not([dir=rtl]) .tox-rgb-form label { + margin-right: 0.5em; +} +.tox[dir=rtl] .tox-sv-palette { + margin-left: 15px; +} +.tox[dir=rtl] .tox-hue-slider { + margin-left: 15px; +} +.tox[dir=rtl] .tox-hue-slider-thumb { + margin-right: -1px; +} +.tox[dir=rtl] .tox-rgb-form label { + margin-left: 0.5em; +} +.tox .tox-toolbar .tox-swatches, +.tox .tox-toolbar__primary .tox-swatches, +.tox .tox-toolbar__overflow .tox-swatches { + margin: 2px 0 3px 4px; +} +.tox .tox-collection--list .tox-collection__group .tox-swatches-menu { + border: 0; + margin: -4px 0; +} +.tox .tox-swatches__row { + display: flex; +} +.tox .tox-swatch { + height: 30px; + transition: transform 0.15s, box-shadow 0.15s; + width: 30px; +} +.tox .tox-swatch:hover, +.tox .tox-swatch:focus { + box-shadow: 0 0 0 1px rgba(127, 127, 127, 0.3) inset; + transform: scale(0.8); +} +.tox .tox-swatch--remove { + align-items: center; + display: flex; + justify-content: center; +} +.tox .tox-swatch--remove svg path { + stroke: #e74c3c; +} +.tox .tox-swatches__picker-btn { + align-items: center; + background-color: transparent; + border: 0; + cursor: pointer; + display: flex; + height: 30px; + justify-content: center; + outline: none; + padding: 0; + width: 30px; +} +.tox .tox-swatches__picker-btn svg { + height: 24px; + width: 24px; +} +.tox .tox-swatches__picker-btn:hover { + background: #4a5562; +} +.tox:not([dir=rtl]) .tox-swatches__picker-btn { + margin-left: auto; +} +.tox[dir=rtl] .tox-swatches__picker-btn { + margin-right: auto; +} +.tox .tox-comment-thread { + background: #2b3b4e; + position: relative; +} +.tox .tox-comment-thread > *:not(:first-child) { + margin-top: 8px; +} +.tox .tox-comment { + background: #2b3b4e; + border: 1px solid #000000; + border-radius: 3px; + box-shadow: 0 4px 8px 0 rgba(42, 55, 70, 0.1); + padding: 8px 8px 16px 8px; + position: relative; +} +.tox .tox-comment__header { + align-items: center; + color: #fff; + display: flex; + justify-content: space-between; +} +.tox .tox-comment__date { + color: rgba(255, 255, 255, 0.5); + font-size: 12px; +} +.tox .tox-comment__body { + color: #fff; + font-size: 14px; + font-style: normal; + font-weight: normal; + line-height: 1.3; + margin-top: 8px; + position: relative; + text-transform: initial; +} +.tox .tox-comment__body textarea { + resize: none; + white-space: normal; + width: 100%; +} +.tox .tox-comment__expander { + padding-top: 8px; +} +.tox .tox-comment__expander p { + color: rgba(255, 255, 255, 0.5); + font-size: 14px; + font-style: normal; +} +.tox .tox-comment__body p { + margin: 0; +} +.tox .tox-comment__buttonspacing { + padding-top: 16px; + text-align: center; +} +.tox .tox-comment-thread__overlay::after { + background: #2b3b4e; + bottom: 0; + content: ""; + display: flex; + left: 0; + opacity: 0.9; + position: absolute; + right: 0; + top: 0; + z-index: 5; +} +.tox .tox-comment__reply { + display: flex; + flex-shrink: 0; + flex-wrap: wrap; + justify-content: flex-end; + margin-top: 8px; +} +.tox .tox-comment__reply > *:first-child { + margin-bottom: 8px; + width: 100%; +} +.tox .tox-comment__edit { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + margin-top: 16px; +} +.tox .tox-comment__gradient::after { + background: linear-gradient(rgba(43, 59, 78, 0), #2b3b4e); + bottom: 0; + content: ""; + display: block; + height: 5em; + margin-top: -40px; + position: absolute; + width: 100%; +} +.tox .tox-comment__overlay { + background: #2b3b4e; + bottom: 0; + display: flex; + flex-direction: column; + flex-grow: 1; + left: 0; + opacity: 0.9; + position: absolute; + right: 0; + text-align: center; + top: 0; + z-index: 5; +} +.tox .tox-comment__loading-text { + align-items: center; + color: #fff; + display: flex; + flex-direction: column; + position: relative; +} +.tox .tox-comment__loading-text > div { + padding-bottom: 16px; +} +.tox .tox-comment__overlaytext { + bottom: 0; + flex-direction: column; + font-size: 14px; + left: 0; + padding: 1em; + position: absolute; + right: 0; + top: 0; + z-index: 10; +} +.tox .tox-comment__overlaytext p { + background-color: #2b3b4e; + box-shadow: 0 0 8px 8px #2b3b4e; + color: #fff; + text-align: center; +} +.tox .tox-comment__overlaytext div:nth-of-type(2) { + font-size: 0.8em; +} +.tox .tox-comment__busy-spinner { + align-items: center; + background-color: #2b3b4e; + bottom: 0; + display: flex; + justify-content: center; + left: 0; + position: absolute; + right: 0; + top: 0; + z-index: 20; +} +.tox .tox-comment__scroll { + display: flex; + flex-direction: column; + flex-shrink: 1; + overflow: auto; +} +.tox .tox-conversations { + margin: 8px; +} +.tox:not([dir=rtl]) .tox-comment__edit { + margin-left: 8px; +} +.tox:not([dir=rtl]) .tox-comment__buttonspacing > *:last-child, +.tox:not([dir=rtl]) .tox-comment__edit > *:last-child, +.tox:not([dir=rtl]) .tox-comment__reply > *:last-child { + margin-left: 8px; +} +.tox[dir=rtl] .tox-comment__edit { + margin-right: 8px; +} +.tox[dir=rtl] .tox-comment__buttonspacing > *:last-child, +.tox[dir=rtl] .tox-comment__edit > *:last-child, +.tox[dir=rtl] .tox-comment__reply > *:last-child { + margin-right: 8px; +} +.tox .tox-user { + align-items: center; + display: flex; +} +.tox .tox-user__avatar svg { + fill: rgba(255, 255, 255, 0.5); +} +.tox .tox-user__name { + color: rgba(255, 255, 255, 0.5); + font-size: 12px; + font-style: normal; + font-weight: bold; + text-transform: uppercase; +} +.tox:not([dir=rtl]) .tox-user__avatar svg { + margin-right: 8px; +} +.tox:not([dir=rtl]) .tox-user__avatar + .tox-user__name { + margin-left: 8px; +} +.tox[dir=rtl] .tox-user__avatar svg { + margin-left: 8px; +} +.tox[dir=rtl] .tox-user__avatar + .tox-user__name { + margin-right: 8px; +} +.tox .tox-dialog-wrap { + align-items: center; + bottom: 0; + display: flex; + justify-content: center; + left: 0; + position: fixed; + right: 0; + top: 0; + z-index: 1100; +} +.tox .tox-dialog-wrap__backdrop { + background-color: rgba(34, 47, 62, 0.75); + bottom: 0; + left: 0; + position: absolute; + right: 0; + top: 0; + z-index: 1; +} +.tox .tox-dialog-wrap__backdrop--opaque { + background-color: #222f3e; +} +.tox .tox-dialog { + background-color: #2b3b4e; + border-color: #000000; + border-radius: 3px; + border-style: solid; + border-width: 1px; + box-shadow: 0 16px 16px -10px rgba(42, 55, 70, 0.15), 0 0 40px 1px rgba(42, 55, 70, 0.15); + display: flex; + flex-direction: column; + max-height: 100%; + max-width: 480px; + overflow: hidden; + position: relative; + width: 95vw; + z-index: 2; +} +@media only screen and (max-width:767px) { + body:not(.tox-force-desktop) .tox .tox-dialog { + align-self: flex-start; + margin: 8px auto; + width: calc(100vw - 16px); + } +} +.tox .tox-dialog-inline { + z-index: 1100; +} +.tox .tox-dialog__header { + align-items: center; + background-color: #2b3b4e; + border-bottom: none; + color: #fff; + display: flex; + font-size: 16px; + justify-content: space-between; + padding: 8px 16px 0 16px; + position: relative; +} +.tox .tox-dialog__header .tox-button { + z-index: 1; +} +.tox .tox-dialog__draghandle { + cursor: grab; + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; +} +.tox .tox-dialog__draghandle:active { + cursor: grabbing; +} +.tox .tox-dialog__dismiss { + margin-left: auto; +} +.tox .tox-dialog__title { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + font-size: 20px; + font-style: normal; + font-weight: normal; + line-height: 1.3; + margin: 0; + text-transform: none; +} +.tox .tox-dialog__body { + color: #fff; + display: flex; + flex: 1; + -ms-flex-preferred-size: auto; + font-size: 16px; + font-style: normal; + font-weight: normal; + line-height: 1.3; + min-width: 0; + text-align: left; + text-transform: none; +} +@media only screen and (max-width:767px) { + body:not(.tox-force-desktop) .tox .tox-dialog__body { + flex-direction: column; + } +} +.tox .tox-dialog__body-nav { + align-items: flex-start; + display: flex; + flex-direction: column; + padding: 16px 16px; +} +@media only screen and (max-width:767px) { + body:not(.tox-force-desktop) .tox .tox-dialog__body-nav { + flex-direction: row; + -webkit-overflow-scrolling: touch; + overflow-x: auto; + padding-bottom: 0; + } +} +.tox .tox-dialog__body-nav-item { + border-bottom: 2px solid transparent; + color: rgba(255, 255, 255, 0.5); + display: inline-block; + font-size: 14px; + line-height: 1.3; + margin-bottom: 8px; + text-decoration: none; + white-space: nowrap; +} +.tox .tox-dialog__body-nav-item:focus { + background-color: rgba(32, 122, 183, 0.1); +} +.tox .tox-dialog__body-nav-item--active { + border-bottom: 2px solid #207ab7; + color: #207ab7; +} +.tox .tox-dialog__body-content { + box-sizing: border-box; + display: flex; + flex: 1; + flex-direction: column; + -ms-flex-preferred-size: auto; + max-height: 650px; + overflow: auto; + -webkit-overflow-scrolling: touch; + padding: 16px 16px; +} +.tox .tox-dialog__body-content > * { + margin-bottom: 0; + margin-top: 16px; +} +.tox .tox-dialog__body-content > *:first-child { + margin-top: 0; +} +.tox .tox-dialog__body-content > *:last-child { + margin-bottom: 0; +} +.tox .tox-dialog__body-content > *:only-child { + margin-bottom: 0; + margin-top: 0; +} +.tox .tox-dialog__body-content a { + color: #207ab7; + cursor: pointer; + text-decoration: none; +} +.tox .tox-dialog__body-content a:hover, +.tox .tox-dialog__body-content a:focus { + color: #185d8c; + text-decoration: none; +} +.tox .tox-dialog__body-content a:active { + color: #185d8c; + text-decoration: none; +} +.tox .tox-dialog__body-content svg { + fill: #fff; +} +.tox .tox-dialog__body-content ul { + display: block; + list-style-type: disc; + margin-bottom: 16px; + -webkit-margin-end: 0; + margin-inline-end: 0; + -webkit-margin-start: 0; + margin-inline-start: 0; + -webkit-padding-start: 2.5rem; + padding-inline-start: 2.5rem; +} +.tox .tox-dialog__body-content .tox-form__group h1 { + color: #fff; + font-size: 20px; + font-style: normal; + font-weight: bold; + letter-spacing: normal; + margin-bottom: 16px; + margin-top: 2rem; + text-transform: none; +} +.tox .tox-dialog__body-content .tox-form__group h2 { + color: #fff; + font-size: 16px; + font-style: normal; + font-weight: bold; + letter-spacing: normal; + margin-bottom: 16px; + margin-top: 2rem; + text-transform: none; +} +.tox .tox-dialog__body-content .tox-form__group p { + margin-bottom: 16px; +} +.tox .tox-dialog__body-content .tox-form__group h1:first-child, +.tox .tox-dialog__body-content .tox-form__group h2:first-child, +.tox .tox-dialog__body-content .tox-form__group p:first-child { + margin-top: 0; +} +.tox .tox-dialog__body-content .tox-form__group h1:last-child, +.tox .tox-dialog__body-content .tox-form__group h2:last-child, +.tox .tox-dialog__body-content .tox-form__group p:last-child { + margin-bottom: 0; +} +.tox .tox-dialog__body-content .tox-form__group h1:only-child, +.tox .tox-dialog__body-content .tox-form__group h2:only-child, +.tox .tox-dialog__body-content .tox-form__group p:only-child { + margin-bottom: 0; + margin-top: 0; +} +.tox .tox-dialog--width-lg { + height: 650px; + max-width: 1200px; +} +.tox .tox-dialog--width-md { + max-width: 800px; +} +.tox .tox-dialog--width-md .tox-dialog__body-content { + overflow: auto; +} +.tox .tox-dialog__body-content--centered { + text-align: center; +} +.tox .tox-dialog__footer { + align-items: center; + background-color: #2b3b4e; + border-top: 1px solid #000000; + display: flex; + justify-content: space-between; + padding: 8px 16px; +} +.tox .tox-dialog__footer-start, +.tox .tox-dialog__footer-end { + display: flex; +} +.tox .tox-dialog__busy-spinner { + align-items: center; + background-color: rgba(34, 47, 62, 0.75); + bottom: 0; + display: flex; + justify-content: center; + left: 0; + position: absolute; + right: 0; + top: 0; + z-index: 3; +} +.tox .tox-dialog__table { + border-collapse: collapse; + width: 100%; +} +.tox .tox-dialog__table thead th { + font-weight: bold; + padding-bottom: 8px; +} +.tox .tox-dialog__table tbody tr { + border-bottom: 1px solid #000000; +} +.tox .tox-dialog__table tbody tr:last-child { + border-bottom: none; +} +.tox .tox-dialog__table td { + padding-bottom: 8px; + padding-top: 8px; +} +.tox .tox-dialog__popups { + position: absolute; + width: 100%; + z-index: 1100; +} +.tox .tox-dialog__body-iframe { + display: flex; + flex: 1; + flex-direction: column; + -ms-flex-preferred-size: auto; +} +.tox .tox-dialog__body-iframe .tox-navobj { + display: flex; + flex: 1; + -ms-flex-preferred-size: auto; +} +.tox .tox-dialog__body-iframe .tox-navobj :nth-child(2) { + flex: 1; + -ms-flex-preferred-size: auto; + height: 100%; +} +.tox .tox-dialog-dock-fadeout { + opacity: 0; + visibility: hidden; +} +.tox .tox-dialog-dock-fadein { + opacity: 1; + visibility: visible; +} +.tox .tox-dialog-dock-transition { + transition: visibility 0s linear 0.3s, opacity 0.3s ease; +} +.tox .tox-dialog-dock-transition.tox-dialog-dock-fadein { + transition-delay: 0s; +} +.tox.tox-platform-ie { + /* IE11 CSS styles go here */ +} +.tox.tox-platform-ie .tox-dialog-wrap { + position: -ms-device-fixed; +} +@media only screen and (max-width:767px) { + body:not(.tox-force-desktop) .tox:not([dir=rtl]) .tox-dialog__body-nav { + margin-right: 0; + } +} +@media only screen and (max-width:767px) { + body:not(.tox-force-desktop) .tox:not([dir=rtl]) .tox-dialog__body-nav-item:not(:first-child) { + margin-left: 8px; + } +} +.tox:not([dir=rtl]) .tox-dialog__footer .tox-dialog__footer-start > *, +.tox:not([dir=rtl]) .tox-dialog__footer .tox-dialog__footer-end > * { + margin-left: 8px; +} +.tox[dir=rtl] .tox-dialog__body { + text-align: right; +} +@media only screen and (max-width:767px) { + body:not(.tox-force-desktop) .tox[dir=rtl] .tox-dialog__body-nav { + margin-left: 0; + } +} +@media only screen and (max-width:767px) { + body:not(.tox-force-desktop) .tox[dir=rtl] .tox-dialog__body-nav-item:not(:first-child) { + margin-right: 8px; + } +} +.tox[dir=rtl] .tox-dialog__footer .tox-dialog__footer-start > *, +.tox[dir=rtl] .tox-dialog__footer .tox-dialog__footer-end > * { + margin-right: 8px; +} +body.tox-dialog__disable-scroll { + overflow: hidden; +} +.tox .tox-dropzone-container { + display: flex; + flex: 1; + -ms-flex-preferred-size: auto; +} +.tox .tox-dropzone { + align-items: center; + background: #fff; + border: 2px dashed #000000; + box-sizing: border-box; + display: flex; + flex-direction: column; + flex-grow: 1; + justify-content: center; + min-height: 100px; + padding: 10px; +} +.tox .tox-dropzone p { + color: rgba(255, 255, 255, 0.5); + margin: 0 0 16px 0; +} +.tox .tox-edit-area { + display: flex; + flex: 1; + -ms-flex-preferred-size: auto; + overflow: hidden; + position: relative; +} +.tox .tox-edit-area__iframe { + background-color: #fff; + border: 0; + box-sizing: border-box; + flex: 1; + -ms-flex-preferred-size: auto; + height: 100%; + position: absolute; + width: 100%; +} +.tox.tox-inline-edit-area { + border: 1px dotted #000000; +} +.tox .tox-editor-container { + display: flex; + flex: 1 1 auto; + flex-direction: column; + overflow: hidden; +} +.tox .tox-editor-header { + z-index: 1; +} +.tox:not(.tox-tinymce-inline) .tox-editor-header { + box-shadow: none; + transition: box-shadow 0.5s; +} +.tox.tox-tinymce--toolbar-bottom .tox-editor-header, +.tox.tox-tinymce-inline .tox-editor-header { + margin-bottom: -1px; +} +.tox.tox-tinymce--toolbar-sticky-on .tox-editor-header { + background-color: transparent; + box-shadow: 0 4px 4px -3px rgba(0, 0, 0, 0.25); +} +.tox-editor-dock-fadeout { + opacity: 0; + visibility: hidden; +} +.tox-editor-dock-fadein { + opacity: 1; + visibility: visible; +} +.tox-editor-dock-transition { + transition: visibility 0s linear 0.25s, opacity 0.25s ease; +} +.tox-editor-dock-transition.tox-editor-dock-fadein { + transition-delay: 0s; +} +.tox .tox-control-wrap { + flex: 1; + position: relative; +} +.tox .tox-control-wrap:not(.tox-control-wrap--status-invalid) .tox-control-wrap__status-icon-invalid, +.tox .tox-control-wrap:not(.tox-control-wrap--status-unknown) .tox-control-wrap__status-icon-unknown, +.tox .tox-control-wrap:not(.tox-control-wrap--status-valid) .tox-control-wrap__status-icon-valid { + display: none; +} +.tox .tox-control-wrap svg { + display: block; +} +.tox .tox-control-wrap__status-icon-wrap { + position: absolute; + top: 50%; + transform: translateY(-50%); +} +.tox .tox-control-wrap__status-icon-invalid svg { + fill: #c00; +} +.tox .tox-control-wrap__status-icon-unknown svg { + fill: orange; +} +.tox .tox-control-wrap__status-icon-valid svg { + fill: green; +} +.tox:not([dir=rtl]) .tox-control-wrap--status-invalid .tox-textfield, +.tox:not([dir=rtl]) .tox-control-wrap--status-unknown .tox-textfield, +.tox:not([dir=rtl]) .tox-control-wrap--status-valid .tox-textfield { + padding-right: 32px; +} +.tox:not([dir=rtl]) .tox-control-wrap__status-icon-wrap { + right: 4px; +} +.tox[dir=rtl] .tox-control-wrap--status-invalid .tox-textfield, +.tox[dir=rtl] .tox-control-wrap--status-unknown .tox-textfield, +.tox[dir=rtl] .tox-control-wrap--status-valid .tox-textfield { + padding-left: 32px; +} +.tox[dir=rtl] .tox-control-wrap__status-icon-wrap { + left: 4px; +} +.tox .tox-autocompleter { + max-width: 25em; +} +.tox .tox-autocompleter .tox-menu { + max-width: 25em; +} +.tox .tox-autocompleter .tox-autocompleter-highlight { + font-weight: bold; +} +.tox .tox-color-input { + display: flex; + position: relative; + z-index: 1; +} +.tox .tox-color-input .tox-textfield { + z-index: -1; +} +.tox .tox-color-input span { + border-color: rgba(42, 55, 70, 0.2); + border-radius: 3px; + border-style: solid; + border-width: 1px; + box-shadow: none; + box-sizing: border-box; + height: 24px; + position: absolute; + top: 6px; + width: 24px; +} +.tox .tox-color-input span:hover:not([aria-disabled=true]), +.tox .tox-color-input span:focus:not([aria-disabled=true]) { + border-color: #207ab7; + cursor: pointer; +} +.tox .tox-color-input span::before { + background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.25) 25%, transparent 25%), linear-gradient(-45deg, rgba(255, 255, 255, 0.25) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, rgba(255, 255, 255, 0.25) 75%), linear-gradient(-45deg, transparent 75%, rgba(255, 255, 255, 0.25) 75%); + background-position: 0 0, 0 6px, 6px -6px, -6px 0; + background-size: 12px 12px; + border: 1px solid #2b3b4e; + border-radius: 3px; + box-sizing: border-box; + content: ''; + height: 24px; + left: -1px; + position: absolute; + top: -1px; + width: 24px; + z-index: -1; +} +.tox .tox-color-input span[aria-disabled=true] { + cursor: not-allowed; +} +.tox:not([dir=rtl]) .tox-color-input { + /* stylelint-disable-next-line no-descending-specificity */ +} +.tox:not([dir=rtl]) .tox-color-input .tox-textfield { + padding-left: 36px; +} +.tox:not([dir=rtl]) .tox-color-input span { + left: 6px; +} +.tox[dir="rtl"] .tox-color-input { + /* stylelint-disable-next-line no-descending-specificity */ +} +.tox[dir="rtl"] .tox-color-input .tox-textfield { + padding-right: 36px; +} +.tox[dir="rtl"] .tox-color-input span { + right: 6px; +} +.tox .tox-label, +.tox .tox-toolbar-label { + color: rgba(255, 255, 255, 0.5); + display: block; + font-size: 14px; + font-style: normal; + font-weight: normal; + line-height: 1.3; + padding: 0 8px 0 0; + text-transform: none; + white-space: nowrap; +} +.tox .tox-toolbar-label { + padding: 0 8px; +} +.tox[dir=rtl] .tox-label { + padding: 0 0 0 8px; +} +.tox .tox-form { + display: flex; + flex: 1; + flex-direction: column; + -ms-flex-preferred-size: auto; +} +.tox .tox-form__group { + box-sizing: border-box; + margin-bottom: 4px; +} +.tox .tox-form-group--maximize { + flex: 1; +} +.tox .tox-form__group--error { + color: #c00; +} +.tox .tox-form__group--collection { + display: flex; +} +.tox .tox-form__grid { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-between; +} +.tox .tox-form__grid--2col > .tox-form__group { + width: calc(50% - (8px / 2)); +} +.tox .tox-form__grid--3col > .tox-form__group { + width: calc(100% / 3 - (8px / 2)); +} +.tox .tox-form__grid--4col > .tox-form__group { + width: calc(25% - (8px / 2)); +} +.tox .tox-form__controls-h-stack { + align-items: center; + display: flex; +} +.tox .tox-form__group--inline { + align-items: center; + display: flex; +} +.tox .tox-form__group--stretched { + display: flex; + flex: 1; + flex-direction: column; + -ms-flex-preferred-size: auto; +} +.tox .tox-form__group--stretched .tox-textarea { + flex: 1; + -ms-flex-preferred-size: auto; +} +.tox .tox-form__group--stretched .tox-navobj { + display: flex; + flex: 1; + -ms-flex-preferred-size: auto; +} +.tox .tox-form__group--stretched .tox-navobj :nth-child(2) { + flex: 1; + -ms-flex-preferred-size: auto; + height: 100%; +} +.tox:not([dir=rtl]) .tox-form__controls-h-stack > *:not(:first-child) { + margin-left: 4px; +} +.tox[dir=rtl] .tox-form__controls-h-stack > *:not(:first-child) { + margin-right: 4px; +} +.tox .tox-lock.tox-locked .tox-lock-icon__unlock, +.tox .tox-lock:not(.tox-locked) .tox-lock-icon__lock { + display: none; +} +.tox .tox-textfield, +.tox .tox-toolbar-textfield, +.tox .tox-listboxfield .tox-listbox--select, +.tox .tox-textarea { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background-color: #2b3b4e; + border-color: #000000; + border-radius: 3px; + border-style: solid; + border-width: 1px; + box-shadow: none; + box-sizing: border-box; + color: #fff; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + font-size: 16px; + line-height: 24px; + margin: 0; + min-height: 34px; + outline: none; + padding: 5px 4.75px; + resize: none; + width: 100%; +} +.tox .tox-textfield[disabled], +.tox .tox-textarea[disabled] { + background-color: #222f3e; + color: rgba(255, 255, 255, 0.85); + cursor: not-allowed; +} +.tox .tox-textfield:focus, +.tox .tox-listboxfield .tox-listbox--select:focus, +.tox .tox-textarea:focus { + background-color: #2b3b4e; + border-color: #207ab7; + box-shadow: none; + outline: none; +} +.tox .tox-toolbar-textfield { + border-width: 0; + margin-bottom: 3px; + margin-top: 2px; + max-width: 250px; +} +.tox .tox-naked-btn { + background-color: transparent; + border: 0; + border-color: transparent; + box-shadow: unset; + color: #207ab7; + cursor: pointer; + display: block; + margin: 0; + padding: 0; +} +.tox .tox-naked-btn svg { + display: block; + fill: #fff; +} +.tox:not([dir=rtl]) .tox-toolbar-textfield + * { + margin-left: 4px; +} +.tox[dir=rtl] .tox-toolbar-textfield + * { + margin-right: 4px; +} +.tox .tox-listboxfield { + cursor: pointer; + position: relative; +} +.tox .tox-listboxfield .tox-listbox--select[disabled] { + background-color: #19232e; + color: rgba(255, 255, 255, 0.85); + cursor: not-allowed; +} +.tox .tox-listbox__select-label { + cursor: default; + flex: 1; + margin: 0 4px; +} +.tox .tox-listbox__select-chevron { + align-items: center; + display: flex; + justify-content: center; + width: 16px; +} +.tox .tox-listbox__select-chevron svg { + fill: #fff; +} +.tox .tox-listboxfield .tox-listbox--select { + align-items: center; + display: flex; +} +.tox:not([dir=rtl]) .tox-listboxfield svg { + right: 8px; +} +.tox[dir=rtl] .tox-listboxfield svg { + left: 8px; +} +.tox .tox-selectfield { + cursor: pointer; + position: relative; +} +.tox .tox-selectfield select { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background-color: #2b3b4e; + border-color: #000000; + border-radius: 3px; + border-style: solid; + border-width: 1px; + box-shadow: none; + box-sizing: border-box; + color: #fff; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + font-size: 16px; + line-height: 24px; + margin: 0; + min-height: 34px; + outline: none; + padding: 5px 4.75px; + resize: none; + width: 100%; +} +.tox .tox-selectfield select[disabled] { + background-color: #19232e; + color: rgba(255, 255, 255, 0.85); + cursor: not-allowed; +} +.tox .tox-selectfield select::-ms-expand { + display: none; +} +.tox .tox-selectfield select:focus { + background-color: #2b3b4e; + border-color: #207ab7; + box-shadow: none; + outline: none; +} +.tox .tox-selectfield svg { + pointer-events: none; + position: absolute; + top: 50%; + transform: translateY(-50%); +} +.tox:not([dir=rtl]) .tox-selectfield select[size="0"], +.tox:not([dir=rtl]) .tox-selectfield select[size="1"] { + padding-right: 24px; +} +.tox:not([dir=rtl]) .tox-selectfield svg { + right: 8px; +} +.tox[dir=rtl] .tox-selectfield select[size="0"], +.tox[dir=rtl] .tox-selectfield select[size="1"] { + padding-left: 24px; +} +.tox[dir=rtl] .tox-selectfield svg { + left: 8px; +} +.tox .tox-textarea { + -webkit-appearance: textarea; + -moz-appearance: textarea; + appearance: textarea; + white-space: pre-wrap; +} +.tox-fullscreen { + border: 0; + height: 100%; + margin: 0; + overflow: hidden; + -ms-scroll-chaining: none; + overscroll-behavior: none; + padding: 0; + touch-action: pinch-zoom; + width: 100%; +} +.tox.tox-tinymce.tox-fullscreen .tox-statusbar__resize-handle { + display: none; +} +.tox.tox-tinymce.tox-fullscreen, +.tox-shadowhost.tox-fullscreen { + left: 0; + position: fixed; + top: 0; + z-index: 1200; +} +.tox.tox-tinymce.tox-fullscreen { + background-color: transparent; +} +.tox-fullscreen .tox.tox-tinymce-aux, +.tox-fullscreen ~ .tox.tox-tinymce-aux { + z-index: 1201; +} +.tox .tox-help__more-link { + list-style: none; + margin-top: 1em; +} +.tox .tox-image-tools { + width: 100%; +} +.tox .tox-image-tools__toolbar { + align-items: center; + display: flex; + justify-content: center; +} +.tox .tox-image-tools__image { + background-color: #666; + height: 380px; + overflow: auto; + position: relative; + width: 100%; +} +.tox .tox-image-tools__image, +.tox .tox-image-tools__image + .tox-image-tools__toolbar { + margin-top: 8px; +} +.tox .tox-image-tools__image-bg { + background: url(data:image/gif;base64,R0lGODdhDAAMAIABAMzMzP///ywAAAAADAAMAAACFoQfqYeabNyDMkBQb81Uat85nxguUAEAOw==); +} +.tox .tox-image-tools__toolbar > .tox-spacer { + flex: 1; + -ms-flex-preferred-size: auto; +} +.tox .tox-croprect-block { + background: black; + filter: alpha(opacity=50); + opacity: 0.5; + position: absolute; + zoom: 1; +} +.tox .tox-croprect-handle { + border: 2px solid white; + height: 20px; + left: 0; + position: absolute; + top: 0; + width: 20px; +} +.tox .tox-croprect-handle-move { + border: 0; + cursor: move; + position: absolute; +} +.tox .tox-croprect-handle-nw { + border-width: 2px 0 0 2px; + cursor: nw-resize; + left: 100px; + margin: -2px 0 0 -2px; + top: 100px; +} +.tox .tox-croprect-handle-ne { + border-width: 2px 2px 0 0; + cursor: ne-resize; + left: 200px; + margin: -2px 0 0 -20px; + top: 100px; +} +.tox .tox-croprect-handle-sw { + border-width: 0 0 2px 2px; + cursor: sw-resize; + left: 100px; + margin: -20px 2px 0 -2px; + top: 200px; +} +.tox .tox-croprect-handle-se { + border-width: 0 2px 2px 0; + cursor: se-resize; + left: 200px; + margin: -20px 0 0 -20px; + top: 200px; +} +.tox:not([dir=rtl]) .tox-image-tools__toolbar > .tox-slider:not(:first-of-type) { + margin-left: 8px; +} +.tox:not([dir=rtl]) .tox-image-tools__toolbar > .tox-button + .tox-slider { + margin-left: 32px; +} +.tox:not([dir=rtl]) .tox-image-tools__toolbar > .tox-slider + .tox-button { + margin-left: 32px; +} +.tox[dir=rtl] .tox-image-tools__toolbar > .tox-slider:not(:first-of-type) { + margin-right: 8px; +} +.tox[dir=rtl] .tox-image-tools__toolbar > .tox-button + .tox-slider { + margin-right: 32px; +} +.tox[dir=rtl] .tox-image-tools__toolbar > .tox-slider + .tox-button { + margin-right: 32px; +} +.tox .tox-insert-table-picker { + display: flex; + flex-wrap: wrap; + width: 170px; +} +.tox .tox-insert-table-picker > div { + border-color: #000000; + border-style: solid; + border-width: 0 1px 1px 0; + box-sizing: border-box; + height: 17px; + width: 17px; +} +.tox .tox-collection--list .tox-collection__group .tox-insert-table-picker { + margin: -4px 0; +} +.tox .tox-insert-table-picker .tox-insert-table-picker__selected { + background-color: rgba(32, 122, 183, 0.5); + border-color: rgba(32, 122, 183, 0.5); +} +.tox .tox-insert-table-picker__label { + color: #fff; + display: block; + font-size: 14px; + padding: 4px; + text-align: center; + width: 100%; +} +.tox:not([dir=rtl]) { + /* stylelint-disable-next-line no-descending-specificity */ +} +.tox:not([dir=rtl]) .tox-insert-table-picker > div:nth-child(10n) { + border-right: 0; +} +.tox[dir=rtl] { + /* stylelint-disable-next-line no-descending-specificity */ +} +.tox[dir=rtl] .tox-insert-table-picker > div:nth-child(10n+1) { + border-right: 0; +} +.tox { + /* stylelint-disable */ + /* stylelint-enable */ +} +.tox .tox-menu { + background-color: #2b3b4e; + border: 1px solid #000000; + border-radius: 3px; + box-shadow: 0 4px 8px 0 rgba(42, 55, 70, 0.1); + display: inline-block; + overflow: hidden; + vertical-align: top; + z-index: 1150; +} +.tox .tox-menu.tox-collection.tox-collection--list { + padding: 0; +} +.tox .tox-menu.tox-collection.tox-collection--toolbar { + padding: 4px; +} +.tox .tox-menu.tox-collection.tox-collection--grid { + padding: 4px; +} +.tox .tox-menu__label h1, +.tox .tox-menu__label h2, +.tox .tox-menu__label h3, +.tox .tox-menu__label h4, +.tox .tox-menu__label h5, +.tox .tox-menu__label h6, +.tox .tox-menu__label p, +.tox .tox-menu__label blockquote, +.tox .tox-menu__label code { + margin: 0; +} +.tox .tox-menubar { + background: url("data:image/svg+xml;charset=utf8,%3Csvg height='39px' viewBox='0 0 40 39px' width='40' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='0' y='38px' width='100' height='1' fill='%23000000'/%3E%3C/svg%3E") left 0 top 0 #222f3e; + background-color: #222f3e; + display: flex; + flex: 0 0 auto; + flex-shrink: 0; + flex-wrap: wrap; + padding: 0 4px 0 4px; +} +.tox.tox-tinymce:not(.tox-tinymce-inline) .tox-editor-header:not(:first-child) .tox-menubar { + border-top: 1px solid #000000; +} +/* Deprecated. Remove in next major release */ +.tox .tox-mbtn { + align-items: center; + background: transparent; + border: 0; + border-radius: 3px; + box-shadow: none; + color: #fff; + display: flex; + flex: 0 0 auto; + font-size: 14px; + font-style: normal; + font-weight: normal; + height: 34px; + justify-content: center; + margin: 2px 0 3px 0; + outline: none; + overflow: hidden; + padding: 0 4px; + text-transform: none; + width: auto; +} +.tox .tox-mbtn[disabled] { + background-color: transparent; + border: 0; + box-shadow: none; + color: rgba(255, 255, 255, 0.5); + cursor: not-allowed; +} +.tox .tox-mbtn:focus:not(:disabled) { + background: #4a5562; + border: 0; + box-shadow: none; + color: #fff; +} +.tox .tox-mbtn--active { + background: #757d87; + border: 0; + box-shadow: none; + color: #fff; +} +.tox .tox-mbtn:hover:not(:disabled):not(.tox-mbtn--active) { + background: #4a5562; + border: 0; + box-shadow: none; + color: #fff; +} +.tox .tox-mbtn__select-label { + cursor: default; + font-weight: normal; + margin: 0 4px; +} +.tox .tox-mbtn[disabled] .tox-mbtn__select-label { + cursor: not-allowed; +} +.tox .tox-mbtn__select-chevron { + align-items: center; + display: flex; + justify-content: center; + width: 16px; + display: none; +} +.tox .tox-notification { + border-radius: 3px; + border-style: solid; + border-width: 1px; + box-shadow: none; + box-sizing: border-box; + display: -ms-grid; + display: grid; + font-size: 14px; + font-weight: normal; + -ms-grid-columns: minmax(40px, 1fr) auto minmax(40px, 1fr); + grid-template-columns: minmax(40px, 1fr) auto minmax(40px, 1fr); + margin-top: 4px; + opacity: 0; + padding: 4px; + transition: transform 100ms ease-in, opacity 150ms ease-in; +} +.tox .tox-notification p { + font-size: 14px; + font-weight: normal; +} +.tox .tox-notification a { + cursor: pointer; + text-decoration: underline; +} +.tox .tox-notification--in { + opacity: 1; +} +.tox .tox-notification--success { + background-color: #e4eeda; + border-color: #d7e6c8; + color: #fff; +} +.tox .tox-notification--success p { + color: #fff; +} +.tox .tox-notification--success a { + color: #547831; +} +.tox .tox-notification--success svg { + fill: #fff; +} +.tox .tox-notification--error { + background-color: #f8dede; + border-color: #f2bfbf; + color: #fff; +} +.tox .tox-notification--error p { + color: #fff; +} +.tox .tox-notification--error a { + color: #c00; +} +.tox .tox-notification--error svg { + fill: #fff; +} +.tox .tox-notification--warn, +.tox .tox-notification--warning { + background-color: #fffaea; + border-color: #ffe89d; + color: #fff; +} +.tox .tox-notification--warn p, +.tox .tox-notification--warning p { + color: #fff; +} +.tox .tox-notification--warn a, +.tox .tox-notification--warning a { + color: #fff; +} +.tox .tox-notification--warn svg, +.tox .tox-notification--warning svg { + fill: #fff; +} +.tox .tox-notification--info { + background-color: #d9edf7; + border-color: #779ecb; + color: #fff; +} +.tox .tox-notification--info p { + color: #fff; +} +.tox .tox-notification--info a { + color: #fff; +} +.tox .tox-notification--info svg { + fill: #fff; +} +.tox .tox-notification__body { + -ms-grid-row-align: center; + align-self: center; + color: #fff; + font-size: 14px; + -ms-grid-column-span: 1; + grid-column-end: 3; + -ms-grid-column: 2; + grid-column-start: 2; + -ms-grid-row-span: 1; + grid-row-end: 2; + -ms-grid-row: 1; + grid-row-start: 1; + text-align: center; + white-space: normal; + word-break: break-all; + word-break: break-word; +} +.tox .tox-notification__body > * { + margin: 0; +} +.tox .tox-notification__body > * + * { + margin-top: 1rem; +} +.tox .tox-notification__icon { + -ms-grid-row-align: center; + align-self: center; + -ms-grid-column-span: 1; + grid-column-end: 2; + -ms-grid-column: 1; + grid-column-start: 1; + -ms-grid-row-span: 1; + grid-row-end: 2; + -ms-grid-row: 1; + grid-row-start: 1; + -ms-grid-column-align: end; + justify-self: end; +} +.tox .tox-notification__icon svg { + display: block; +} +.tox .tox-notification__dismiss { + -ms-grid-row-align: start; + align-self: start; + -ms-grid-column-span: 1; + grid-column-end: 4; + -ms-grid-column: 3; + grid-column-start: 3; + -ms-grid-row-span: 1; + grid-row-end: 2; + -ms-grid-row: 1; + grid-row-start: 1; + -ms-grid-column-align: end; + justify-self: end; +} +.tox .tox-notification .tox-progress-bar { + -ms-grid-column-span: 3; + grid-column-end: 4; + -ms-grid-column: 1; + grid-column-start: 1; + -ms-grid-row-span: 1; + grid-row-end: 3; + -ms-grid-row: 2; + grid-row-start: 2; + -ms-grid-column-align: center; + justify-self: center; +} +.tox .tox-pop { + display: inline-block; + position: relative; +} +.tox .tox-pop--resizing { + transition: width 0.1s ease; +} +.tox .tox-pop--resizing .tox-toolbar, +.tox .tox-pop--resizing .tox-toolbar__group { + flex-wrap: nowrap; +} +.tox .tox-pop--transition { + transition: 0.15s ease; + transition-property: left, right, top, bottom; +} +.tox .tox-pop--transition::before, +.tox .tox-pop--transition::after { + transition: all 0.15s, visibility 0s, opacity 0.075s ease 0.075s; +} +.tox .tox-pop__dialog { + background-color: #222f3e; + border: 1px solid #000000; + border-radius: 3px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); + min-width: 0; + overflow: hidden; +} +.tox .tox-pop__dialog > *:not(.tox-toolbar) { + margin: 4px 4px 4px 8px; +} +.tox .tox-pop__dialog .tox-toolbar { + background-color: transparent; + margin-bottom: -1px; +} +.tox .tox-pop::before, +.tox .tox-pop::after { + border-style: solid; + content: ''; + display: block; + height: 0; + opacity: 1; + position: absolute; + width: 0; +} +.tox .tox-pop.tox-pop--inset::before, +.tox .tox-pop.tox-pop--inset::after { + opacity: 0; + transition: all 0s 0.15s, visibility 0s, opacity 0.075s ease; +} +.tox .tox-pop.tox-pop--bottom::before, +.tox .tox-pop.tox-pop--bottom::after { + left: 50%; + top: 100%; +} +.tox .tox-pop.tox-pop--bottom::after { + border-color: #222f3e transparent transparent transparent; + border-width: 8px; + margin-left: -8px; + margin-top: -1px; +} +.tox .tox-pop.tox-pop--bottom::before { + border-color: #000000 transparent transparent transparent; + border-width: 9px; + margin-left: -9px; +} +.tox .tox-pop.tox-pop--top::before, +.tox .tox-pop.tox-pop--top::after { + left: 50%; + top: 0; + transform: translateY(-100%); +} +.tox .tox-pop.tox-pop--top::after { + border-color: transparent transparent #222f3e transparent; + border-width: 8px; + margin-left: -8px; + margin-top: 1px; +} +.tox .tox-pop.tox-pop--top::before { + border-color: transparent transparent #000000 transparent; + border-width: 9px; + margin-left: -9px; +} +.tox .tox-pop.tox-pop--left::before, +.tox .tox-pop.tox-pop--left::after { + left: 0; + top: calc(50% - 1px); + transform: translateY(-50%); +} +.tox .tox-pop.tox-pop--left::after { + border-color: transparent #222f3e transparent transparent; + border-width: 8px; + margin-left: -15px; +} +.tox .tox-pop.tox-pop--left::before { + border-color: transparent #000000 transparent transparent; + border-width: 10px; + margin-left: -19px; +} +.tox .tox-pop.tox-pop--right::before, +.tox .tox-pop.tox-pop--right::after { + left: 100%; + top: calc(50% + 1px); + transform: translateY(-50%); +} +.tox .tox-pop.tox-pop--right::after { + border-color: transparent transparent transparent #222f3e; + border-width: 8px; + margin-left: -1px; +} +.tox .tox-pop.tox-pop--right::before { + border-color: transparent transparent transparent #000000; + border-width: 10px; + margin-left: -1px; +} +.tox .tox-pop.tox-pop--align-left::before, +.tox .tox-pop.tox-pop--align-left::after { + left: 20px; +} +.tox .tox-pop.tox-pop--align-right::before, +.tox .tox-pop.tox-pop--align-right::after { + left: calc(100% - 20px); +} +.tox .tox-sidebar-wrap { + display: flex; + flex-direction: row; + flex-grow: 1; + -ms-flex-preferred-size: 0; + min-height: 0; +} +.tox .tox-sidebar { + background-color: #222f3e; + display: flex; + flex-direction: row; + justify-content: flex-end; +} +.tox .tox-sidebar__slider { + display: flex; + overflow: hidden; +} +.tox .tox-sidebar__pane-container { + display: flex; +} +.tox .tox-sidebar__pane { + display: flex; +} +.tox .tox-sidebar--sliding-closed { + opacity: 0; +} +.tox .tox-sidebar--sliding-open { + opacity: 1; +} +.tox .tox-sidebar--sliding-growing, +.tox .tox-sidebar--sliding-shrinking { + transition: width 0.5s ease, opacity 0.5s ease; +} +.tox .tox-selector { + background-color: #4099ff; + border-color: #4099ff; + border-style: solid; + border-width: 1px; + box-sizing: border-box; + display: inline-block; + height: 10px; + position: absolute; + width: 10px; +} +.tox.tox-platform-touch .tox-selector { + height: 12px; + width: 12px; +} +.tox .tox-slider { + align-items: center; + display: flex; + flex: 1; + -ms-flex-preferred-size: auto; + height: 24px; + justify-content: center; + position: relative; +} +.tox .tox-slider__rail { + background-color: transparent; + border: 1px solid #000000; + border-radius: 3px; + height: 10px; + min-width: 120px; + width: 100%; +} +.tox .tox-slider__handle { + background-color: #207ab7; + border: 2px solid #185d8c; + border-radius: 3px; + box-shadow: none; + height: 24px; + left: 50%; + position: absolute; + top: 50%; + transform: translateX(-50%) translateY(-50%); + width: 14px; +} +.tox .tox-source-code { + overflow: auto; +} +.tox .tox-spinner { + display: flex; +} +.tox .tox-spinner > div { + animation: tam-bouncing-dots 1.5s ease-in-out 0s infinite both; + background-color: rgba(255, 255, 255, 0.5); + border-radius: 100%; + height: 8px; + width: 8px; +} +.tox .tox-spinner > div:nth-child(1) { + animation-delay: -0.32s; +} +.tox .tox-spinner > div:nth-child(2) { + animation-delay: -0.16s; +} +@keyframes tam-bouncing-dots { + 0%, + 80%, + 100% { + transform: scale(0); + } + 40% { + transform: scale(1); + } +} +.tox:not([dir=rtl]) .tox-spinner > div:not(:first-child) { + margin-left: 4px; +} +.tox[dir=rtl] .tox-spinner > div:not(:first-child) { + margin-right: 4px; +} +.tox .tox-statusbar { + align-items: center; + background-color: #222f3e; + border-top: 1px solid #000000; + color: #fff; + display: flex; + flex: 0 0 auto; + font-size: 12px; + font-weight: normal; + height: 18px; + overflow: hidden; + padding: 0 8px; + position: relative; + text-transform: uppercase; +} +.tox .tox-statusbar__text-container { + display: flex; + flex: 1 1 auto; + justify-content: flex-end; + overflow: hidden; +} +.tox .tox-statusbar__path { + display: flex; + flex: 1 1 auto; + margin-right: auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.tox .tox-statusbar__path > * { + display: inline; + white-space: nowrap; +} +.tox .tox-statusbar__wordcount { + flex: 0 0 auto; + margin-left: 1ch; +} +.tox .tox-statusbar a, +.tox .tox-statusbar__path-item, +.tox .tox-statusbar__wordcount { + color: #fff; + text-decoration: none; +} +.tox .tox-statusbar a:hover:not(:disabled):not([aria-disabled=true]), +.tox .tox-statusbar__path-item:hover:not(:disabled):not([aria-disabled=true]), +.tox .tox-statusbar__wordcount:hover:not(:disabled):not([aria-disabled=true]), +.tox .tox-statusbar a:focus:not(:disabled):not([aria-disabled=true]), +.tox .tox-statusbar__path-item:focus:not(:disabled):not([aria-disabled=true]), +.tox .tox-statusbar__wordcount:focus:not(:disabled):not([aria-disabled=true]) { + cursor: pointer; + text-decoration: underline; +} +.tox .tox-statusbar__resize-handle { + align-items: flex-end; + align-self: stretch; + cursor: nwse-resize; + display: flex; + flex: 0 0 auto; + justify-content: flex-end; + margin-left: auto; + margin-right: -8px; + padding-left: 1ch; +} +.tox .tox-statusbar__resize-handle svg { + display: block; + fill: #fff; +} +.tox .tox-statusbar__resize-handle:focus svg { + background-color: #4a5562; + border-radius: 1px; + box-shadow: 0 0 0 2px #4a5562; +} +.tox:not([dir=rtl]) .tox-statusbar__path > * { + margin-right: 4px; +} +.tox:not([dir=rtl]) .tox-statusbar__branding { + margin-left: 1ch; +} +.tox[dir=rtl] .tox-statusbar { + flex-direction: row-reverse; +} +.tox[dir=rtl] .tox-statusbar__path > * { + margin-left: 4px; +} +.tox .tox-throbber { + z-index: 1299; +} +.tox .tox-throbber__busy-spinner { + align-items: center; + background-color: rgba(34, 47, 62, 0.6); + bottom: 0; + display: flex; + justify-content: center; + left: 0; + position: absolute; + right: 0; + top: 0; +} +.tox .tox-tbtn { + align-items: center; + background: transparent; + border: 0; + border-radius: 3px; + box-shadow: none; + color: #fff; + display: flex; + flex: 0 0 auto; + font-size: 14px; + font-style: normal; + font-weight: normal; + height: 34px; + justify-content: center; + margin: 2px 0 3px 0; + outline: none; + overflow: hidden; + padding: 0; + text-transform: none; + width: 34px; +} +.tox .tox-tbtn svg { + display: block; + fill: #fff; +} +.tox .tox-tbtn.tox-tbtn-more { + padding-left: 5px; + padding-right: 5px; + width: inherit; +} +.tox .tox-tbtn:focus { + background: #4a5562; + border: 0; + box-shadow: none; +} +.tox .tox-tbtn:hover { + background: #4a5562; + border: 0; + box-shadow: none; + color: #fff; +} +.tox .tox-tbtn:hover svg { + fill: #fff; +} +.tox .tox-tbtn:active { + background: #757d87; + border: 0; + box-shadow: none; + color: #fff; +} +.tox .tox-tbtn:active svg { + fill: #fff; +} +.tox .tox-tbtn--disabled, +.tox .tox-tbtn--disabled:hover, +.tox .tox-tbtn:disabled, +.tox .tox-tbtn:disabled:hover { + background: transparent; + border: 0; + box-shadow: none; + color: rgba(255, 255, 255, 0.5); + cursor: not-allowed; +} +.tox .tox-tbtn--disabled svg, +.tox .tox-tbtn--disabled:hover svg, +.tox .tox-tbtn:disabled svg, +.tox .tox-tbtn:disabled:hover svg { + /* stylelint-disable-line no-descending-specificity */ + fill: rgba(255, 255, 255, 0.5); +} +.tox .tox-tbtn--enabled, +.tox .tox-tbtn--enabled:hover { + background: #757d87; + border: 0; + box-shadow: none; + color: #fff; +} +.tox .tox-tbtn--enabled > *, +.tox .tox-tbtn--enabled:hover > * { + transform: none; +} +.tox .tox-tbtn--enabled svg, +.tox .tox-tbtn--enabled:hover svg { + /* stylelint-disable-line no-descending-specificity */ + fill: #fff; +} +.tox .tox-tbtn:focus:not(.tox-tbtn--disabled) { + color: #fff; +} +.tox .tox-tbtn:focus:not(.tox-tbtn--disabled) svg { + fill: #fff; +} +.tox .tox-tbtn:active > * { + transform: none; +} +.tox .tox-tbtn--md { + height: 51px; + width: 51px; +} +.tox .tox-tbtn--lg { + flex-direction: column; + height: 68px; + width: 68px; +} +.tox .tox-tbtn--return { + -ms-grid-row-align: stretch; + align-self: stretch; + height: unset; + width: 16px; +} +.tox .tox-tbtn--labeled { + padding: 0 4px; + width: unset; +} +.tox .tox-tbtn__vlabel { + display: block; + font-size: 10px; + font-weight: normal; + letter-spacing: -0.025em; + margin-bottom: 4px; + white-space: nowrap; +} +.tox .tox-tbtn--select { + margin: 2px 0 3px 0; + padding: 0 4px; + width: auto; +} +.tox .tox-tbtn__select-label { + cursor: default; + font-weight: normal; + margin: 0 4px; +} +.tox .tox-tbtn__select-chevron { + align-items: center; + display: flex; + justify-content: center; + width: 16px; +} +.tox .tox-tbtn__select-chevron svg { + fill: rgba(255, 255, 255, 0.5); +} +.tox .tox-tbtn--bespoke .tox-tbtn__select-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: 7em; +} +.tox .tox-split-button { + border: 0; + border-radius: 3px; + box-sizing: border-box; + display: flex; + margin: 2px 0 3px 0; + overflow: hidden; +} +.tox .tox-split-button:hover { + box-shadow: 0 0 0 1px #4a5562 inset; +} +.tox .tox-split-button:focus { + background: #4a5562; + box-shadow: none; + color: #fff; +} +.tox .tox-split-button > * { + border-radius: 0; +} +.tox .tox-split-button__chevron { + width: 16px; +} +.tox .tox-split-button__chevron svg { + fill: rgba(255, 255, 255, 0.5); +} +.tox .tox-split-button .tox-tbtn { + margin: 0; +} +.tox.tox-platform-touch .tox-split-button .tox-tbtn:first-child { + width: 30px; +} +.tox.tox-platform-touch .tox-split-button__chevron { + width: 20px; +} +.tox .tox-split-button.tox-tbtn--disabled:hover, +.tox .tox-split-button.tox-tbtn--disabled:focus, +.tox .tox-split-button.tox-tbtn--disabled .tox-tbtn:hover, +.tox .tox-split-button.tox-tbtn--disabled .tox-tbtn:focus { + background: transparent; + box-shadow: none; + color: rgba(255, 255, 255, 0.5); +} +.tox .tox-toolbar-overlord { + background-color: #222f3e; +} +.tox .tox-toolbar, +.tox .tox-toolbar__primary, +.tox .tox-toolbar__overflow { + background: url("data:image/svg+xml;charset=utf8,%3Csvg height='39px' viewBox='0 0 40 39px' width='40' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='0' y='38px' width='100' height='1' fill='%23000000'/%3E%3C/svg%3E") left 0 top 0 #222f3e; + background-color: #222f3e; + display: flex; + flex: 0 0 auto; + flex-shrink: 0; + flex-wrap: wrap; + padding: 0 0; +} +.tox .tox-toolbar__overflow.tox-toolbar__overflow--closed { + height: 0; + opacity: 0; + padding-bottom: 0; + padding-top: 0; + visibility: hidden; +} +.tox .tox-toolbar__overflow--growing { + transition: height 0.3s ease, opacity 0.2s linear 0.1s; +} +.tox .tox-toolbar__overflow--shrinking { + transition: opacity 0.3s ease, height 0.2s linear 0.1s, visibility 0s linear 0.3s; +} +.tox .tox-menubar + .tox-toolbar, +.tox .tox-menubar + .tox-toolbar-overlord .tox-toolbar__primary { + border-top: 1px solid #000000; + margin-top: -1px; +} +.tox .tox-toolbar--scrolling { + flex-wrap: nowrap; + overflow-x: auto; +} +.tox .tox-pop .tox-toolbar { + border-width: 0; +} +.tox .tox-toolbar--no-divider { + background-image: none; +} +.tox-tinymce:not(.tox-tinymce-inline) .tox-editor-header:not(:first-child) .tox-toolbar:first-child, +.tox-tinymce:not(.tox-tinymce-inline) .tox-editor-header:not(:first-child) .tox-toolbar-overlord:first-child .tox-toolbar__primary { + border-top: 1px solid #000000; +} +.tox.tox-tinymce-aux .tox-toolbar__overflow { + background-color: #222f3e; + border: 1px solid #000000; + border-radius: 3px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); +} +.tox .tox-toolbar__group { + align-items: center; + display: flex; + flex-wrap: wrap; + margin: 0 0; + padding: 0 4px 0 4px; +} +.tox .tox-toolbar__group--pull-right { + margin-left: auto; +} +.tox .tox-toolbar--scrolling .tox-toolbar__group { + flex-shrink: 0; + flex-wrap: nowrap; +} +.tox:not([dir=rtl]) .tox-toolbar__group:not(:last-of-type) { + border-right: 1px solid #000000; +} +.tox[dir=rtl] .tox-toolbar__group:not(:last-of-type) { + border-left: 1px solid #000000; +} +.tox .tox-tooltip { + display: inline-block; + padding: 8px; + position: relative; +} +.tox .tox-tooltip__body { + background-color: #3d546f; + border-radius: 3px; + box-shadow: 0 2px 4px rgba(42, 55, 70, 0.3); + color: rgba(255, 255, 255, 0.75); + font-size: 14px; + font-style: normal; + font-weight: normal; + padding: 4px 8px; + text-transform: none; +} +.tox .tox-tooltip__arrow { + position: absolute; +} +.tox .tox-tooltip--down .tox-tooltip__arrow { + border-left: 8px solid transparent; + border-right: 8px solid transparent; + border-top: 8px solid #3d546f; + bottom: 0; + left: 50%; + position: absolute; + transform: translateX(-50%); +} +.tox .tox-tooltip--up .tox-tooltip__arrow { + border-bottom: 8px solid #3d546f; + border-left: 8px solid transparent; + border-right: 8px solid transparent; + left: 50%; + position: absolute; + top: 0; + transform: translateX(-50%); +} +.tox .tox-tooltip--right .tox-tooltip__arrow { + border-bottom: 8px solid transparent; + border-left: 8px solid #3d546f; + border-top: 8px solid transparent; + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); +} +.tox .tox-tooltip--left .tox-tooltip__arrow { + border-bottom: 8px solid transparent; + border-right: 8px solid #3d546f; + border-top: 8px solid transparent; + left: 0; + position: absolute; + top: 50%; + transform: translateY(-50%); +} +.tox .tox-well { + border: 1px solid #000000; + border-radius: 3px; + padding: 8px; + width: 100%; +} +.tox .tox-well > *:first-child { + margin-top: 0; +} +.tox .tox-well > *:last-child { + margin-bottom: 0; +} +.tox .tox-well > *:only-child { + margin: 0; +} +.tox .tox-custom-editor { + border: 1px solid #000000; + border-radius: 3px; + display: flex; + flex: 1; + position: relative; +} +/* stylelint-disable */ +.tox { + /* stylelint-enable */ +} +.tox .tox-dialog-loading::before { + background-color: rgba(0, 0, 0, 0.5); + content: ""; + height: 100%; + position: absolute; + width: 100%; + z-index: 1000; +} +.tox .tox-tab { + cursor: pointer; +} +.tox .tox-dialog__content-js { + display: flex; + flex: 1; + -ms-flex-preferred-size: auto; +} +.tox .tox-dialog__body-content .tox-collection { + display: flex; + flex: 1; + -ms-flex-preferred-size: auto; +} +.tox .tox-image-tools-edit-panel { + height: 60px; +} +.tox .tox-image-tools__sidebar { + height: 60px; +} diff --git a/ui/public/tinymce/skins/ui/oxide-dark/skin.min.css b/ui/public/tinymce/skins/ui/oxide-dark/skin.min.css new file mode 100644 index 0000000..e71f6f0 --- /dev/null +++ b/ui/public/tinymce/skins/ui/oxide-dark/skin.min.css @@ -0,0 +1,7 @@ +/** + * Copyright (c) Tiny Technologies, Inc. All rights reserved. + * Licensed under the LGPL or a commercial license. + * For LGPL see License.txt in the project root for license information. + * For commercial licenses see https://www.tiny.cloud/ + */ +.tox{box-shadow:none;box-sizing:content-box;color:#2a3746;cursor:auto;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;font-size:16px;font-style:normal;font-weight:400;line-height:normal;-webkit-tap-highlight-color:transparent;text-decoration:none;text-shadow:none;text-transform:none;vertical-align:initial;white-space:normal}.tox :not(svg):not(rect){box-sizing:inherit;color:inherit;cursor:inherit;direction:inherit;font-family:inherit;font-size:inherit;font-style:inherit;font-weight:inherit;line-height:inherit;-webkit-tap-highlight-color:inherit;text-align:inherit;text-decoration:inherit;text-shadow:inherit;text-transform:inherit;vertical-align:inherit;white-space:inherit}.tox :not(svg):not(rect){background:0 0;border:0;box-shadow:none;float:none;height:auto;margin:0;max-width:none;outline:0;padding:0;position:static;width:auto}.tox:not([dir=rtl]){direction:ltr;text-align:left}.tox[dir=rtl]{direction:rtl;text-align:right}.tox-tinymce{border:1px solid #000;border-radius:0;box-shadow:none;box-sizing:border-box;display:flex;flex-direction:column;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;overflow:hidden;position:relative;visibility:inherit!important}.tox-tinymce-inline{border:none;box-shadow:none}.tox-tinymce-inline .tox-editor-header{background-color:transparent;border:1px solid #000;border-radius:0;box-shadow:none}.tox-tinymce-aux{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;z-index:1300}.tox-tinymce :focus,.tox-tinymce-aux :focus{outline:0}button::-moz-focus-inner{border:0}.tox[dir=rtl] .tox-icon--flip svg{transform:rotateY(180deg)}.tox .accessibility-issue__header{align-items:center;display:flex;margin-bottom:4px}.tox .accessibility-issue__description{align-items:stretch;border:1px solid #000;border-radius:3px;display:flex;justify-content:space-between}.tox .accessibility-issue__description>div{padding-bottom:4px}.tox .accessibility-issue__description>div>div{align-items:center;display:flex;margin-bottom:4px}.tox .accessibility-issue__description>:last-child:not(:only-child){border-color:#000;border-style:solid}.tox .accessibility-issue__repair{margin-top:16px}.tox .tox-dialog__body-content .accessibility-issue--info .accessibility-issue__description{background-color:rgba(32,122,183,.5);border-color:#207ab7;color:#fff}.tox .tox-dialog__body-content .accessibility-issue--info .accessibility-issue__description>:last-child{border-color:#207ab7}.tox .tox-dialog__body-content .accessibility-issue--info .tox-form__group h2{color:#fff}.tox .tox-dialog__body-content .accessibility-issue--info .tox-icon svg{fill:#fff}.tox .tox-dialog__body-content .accessibility-issue--info a .tox-icon{color:#fff}.tox .tox-dialog__body-content .accessibility-issue--warn .accessibility-issue__description{background-color:rgba(255,165,0,.5);border-color:rgba(255,165,0,.8);color:#fff}.tox .tox-dialog__body-content .accessibility-issue--warn .accessibility-issue__description>:last-child{border-color:rgba(255,165,0,.8)}.tox .tox-dialog__body-content .accessibility-issue--warn .tox-form__group h2{color:#fff}.tox .tox-dialog__body-content .accessibility-issue--warn .tox-icon svg{fill:#fff}.tox .tox-dialog__body-content .accessibility-issue--warn a .tox-icon{color:#fff}.tox .tox-dialog__body-content .accessibility-issue--error .accessibility-issue__description{background-color:rgba(204,0,0,.5);border-color:rgba(204,0,0,.8);color:#fff}.tox .tox-dialog__body-content .accessibility-issue--error .accessibility-issue__description>:last-child{border-color:rgba(204,0,0,.8)}.tox .tox-dialog__body-content .accessibility-issue--error .tox-form__group h2{color:#fff}.tox .tox-dialog__body-content .accessibility-issue--error .tox-icon svg{fill:#fff}.tox .tox-dialog__body-content .accessibility-issue--error a .tox-icon{color:#fff}.tox .tox-dialog__body-content .accessibility-issue--success .accessibility-issue__description{background-color:rgba(120,171,70,.5);border-color:rgba(120,171,70,.8);color:#fff}.tox .tox-dialog__body-content .accessibility-issue--success .accessibility-issue__description>:last-child{border-color:rgba(120,171,70,.8)}.tox .tox-dialog__body-content .accessibility-issue--success .tox-form__group h2{color:#fff}.tox .tox-dialog__body-content .accessibility-issue--success .tox-icon svg{fill:#fff}.tox .tox-dialog__body-content .accessibility-issue--success a .tox-icon{color:#fff}.tox .tox-dialog__body-content .accessibility-issue__header h1,.tox .tox-dialog__body-content .tox-form__group .accessibility-issue__description h2{margin-top:0}.tox:not([dir=rtl]) .tox-dialog__body-content .accessibility-issue__header .tox-button{margin-left:4px}.tox:not([dir=rtl]) .tox-dialog__body-content .accessibility-issue__header>:nth-last-child(2){margin-left:auto}.tox:not([dir=rtl]) .tox-dialog__body-content .accessibility-issue__description{padding:4px 4px 4px 8px}.tox:not([dir=rtl]) .tox-dialog__body-content .accessibility-issue__description>:last-child{border-left-width:1px;padding-left:4px}.tox[dir=rtl] .tox-dialog__body-content .accessibility-issue__header .tox-button{margin-right:4px}.tox[dir=rtl] .tox-dialog__body-content .accessibility-issue__header>:nth-last-child(2){margin-right:auto}.tox[dir=rtl] .tox-dialog__body-content .accessibility-issue__description{padding:4px 8px 4px 4px}.tox[dir=rtl] .tox-dialog__body-content .accessibility-issue__description>:last-child{border-right-width:1px;padding-right:4px}.tox .tox-anchorbar{display:flex;flex:0 0 auto}.tox .tox-bar{display:flex;flex:0 0 auto}.tox .tox-button{background-color:#207ab7;background-image:none;background-position:0 0;background-repeat:repeat;border-color:#207ab7;border-radius:3px;border-style:solid;border-width:1px;box-shadow:none;box-sizing:border-box;color:#fff;cursor:pointer;display:inline-block;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;font-size:14px;font-style:normal;font-weight:700;letter-spacing:normal;line-height:24px;margin:0;outline:0;padding:4px 16px;text-align:center;text-decoration:none;text-transform:none;white-space:nowrap}.tox .tox-button[disabled]{background-color:#207ab7;background-image:none;border-color:#207ab7;box-shadow:none;color:rgba(255,255,255,.5);cursor:not-allowed}.tox .tox-button:focus:not(:disabled){background-color:#1c6ca1;background-image:none;border-color:#1c6ca1;box-shadow:none;color:#fff}.tox .tox-button:hover:not(:disabled){background-color:#1c6ca1;background-image:none;border-color:#1c6ca1;box-shadow:none;color:#fff}.tox .tox-button:active:not(:disabled){background-color:#185d8c;background-image:none;border-color:#185d8c;box-shadow:none;color:#fff}.tox .tox-button--secondary{background-color:#3d546f;background-image:none;background-position:0 0;background-repeat:repeat;border-color:#3d546f;border-radius:3px;border-style:solid;border-width:1px;box-shadow:none;color:#fff;font-size:14px;font-style:normal;font-weight:700;letter-spacing:normal;outline:0;padding:4px 16px;text-decoration:none;text-transform:none}.tox .tox-button--secondary[disabled]{background-color:#3d546f;background-image:none;border-color:#3d546f;box-shadow:none;color:rgba(255,255,255,.5)}.tox .tox-button--secondary:focus:not(:disabled){background-color:#34485f;background-image:none;border-color:#34485f;box-shadow:none;color:#fff}.tox .tox-button--secondary:hover:not(:disabled){background-color:#34485f;background-image:none;border-color:#34485f;box-shadow:none;color:#fff}.tox .tox-button--secondary:active:not(:disabled){background-color:#2b3b4e;background-image:none;border-color:#2b3b4e;box-shadow:none;color:#fff}.tox .tox-button--icon,.tox .tox-button.tox-button--icon,.tox .tox-button.tox-button--secondary.tox-button--icon{padding:4px}.tox .tox-button--icon .tox-icon svg,.tox .tox-button.tox-button--icon .tox-icon svg,.tox .tox-button.tox-button--secondary.tox-button--icon .tox-icon svg{display:block;fill:currentColor}.tox .tox-button-link{background:0;border:none;box-sizing:border-box;cursor:pointer;display:inline-block;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;font-size:16px;font-weight:400;line-height:1.3;margin:0;padding:0;white-space:nowrap}.tox .tox-button-link--sm{font-size:14px}.tox .tox-button--naked{background-color:transparent;border-color:transparent;box-shadow:unset;color:#fff}.tox .tox-button--naked[disabled]{background-color:#3d546f;border-color:#3d546f;box-shadow:none;color:rgba(255,255,255,.5)}.tox .tox-button--naked:hover:not(:disabled){background-color:#34485f;border-color:#34485f;box-shadow:none;color:#fff}.tox .tox-button--naked:focus:not(:disabled){background-color:#34485f;border-color:#34485f;box-shadow:none;color:#fff}.tox .tox-button--naked:active:not(:disabled){background-color:#2b3b4e;border-color:#2b3b4e;box-shadow:none;color:#fff}.tox .tox-button--naked .tox-icon svg{fill:currentColor}.tox .tox-button--naked.tox-button--icon:hover:not(:disabled){color:#fff}.tox .tox-checkbox{align-items:center;border-radius:3px;cursor:pointer;display:flex;height:36px;min-width:36px}.tox .tox-checkbox__input{height:1px;overflow:hidden;position:absolute;top:auto;width:1px}.tox .tox-checkbox__icons{align-items:center;border-radius:3px;box-shadow:0 0 0 2px transparent;box-sizing:content-box;display:flex;height:24px;justify-content:center;padding:calc(4px - 1px);width:24px}.tox .tox-checkbox__icons .tox-checkbox-icon__unchecked svg{display:block;fill:rgba(255,255,255,.2)}.tox .tox-checkbox__icons .tox-checkbox-icon__indeterminate svg{display:none;fill:#207ab7}.tox .tox-checkbox__icons .tox-checkbox-icon__checked svg{display:none;fill:#207ab7}.tox .tox-checkbox--disabled{color:rgba(255,255,255,.5);cursor:not-allowed}.tox .tox-checkbox--disabled .tox-checkbox__icons .tox-checkbox-icon__checked svg{fill:rgba(255,255,255,.5)}.tox .tox-checkbox--disabled .tox-checkbox__icons .tox-checkbox-icon__unchecked svg{fill:rgba(255,255,255,.5)}.tox .tox-checkbox--disabled .tox-checkbox__icons .tox-checkbox-icon__indeterminate svg{fill:rgba(255,255,255,.5)}.tox input.tox-checkbox__input:checked+.tox-checkbox__icons .tox-checkbox-icon__unchecked svg{display:none}.tox input.tox-checkbox__input:checked+.tox-checkbox__icons .tox-checkbox-icon__checked svg{display:block}.tox input.tox-checkbox__input:indeterminate+.tox-checkbox__icons .tox-checkbox-icon__unchecked svg{display:none}.tox input.tox-checkbox__input:indeterminate+.tox-checkbox__icons .tox-checkbox-icon__indeterminate svg{display:block}.tox input.tox-checkbox__input:focus+.tox-checkbox__icons{border-radius:3px;box-shadow:inset 0 0 0 1px #207ab7;padding:calc(4px - 1px)}.tox:not([dir=rtl]) .tox-checkbox__label{margin-left:4px}.tox:not([dir=rtl]) .tox-checkbox__input{left:-10000px}.tox:not([dir=rtl]) .tox-bar .tox-checkbox{margin-left:4px}.tox[dir=rtl] .tox-checkbox__label{margin-right:4px}.tox[dir=rtl] .tox-checkbox__input{right:-10000px}.tox[dir=rtl] .tox-bar .tox-checkbox{margin-right:4px}.tox .tox-collection--toolbar .tox-collection__group{display:flex;padding:0}.tox .tox-collection--grid .tox-collection__group{display:flex;flex-wrap:wrap;max-height:208px;overflow-x:hidden;overflow-y:auto;padding:0}.tox .tox-collection--list .tox-collection__group{border-bottom-width:0;border-color:#1a1a1a;border-left-width:0;border-right-width:0;border-style:solid;border-top-width:1px;padding:4px 0}.tox .tox-collection--list .tox-collection__group:first-child{border-top-width:0}.tox .tox-collection__group-heading{background-color:#333;color:#fff;cursor:default;font-size:12px;font-style:normal;font-weight:400;margin-bottom:4px;margin-top:-4px;padding:4px 8px;text-transform:none;-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.tox .tox-collection__item{align-items:center;color:#fff;cursor:pointer;display:flex;-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.tox .tox-collection--list .tox-collection__item{padding:4px 8px}.tox .tox-collection--toolbar .tox-collection__item{border-radius:3px;padding:4px}.tox .tox-collection--grid .tox-collection__item{border-radius:3px;padding:4px}.tox .tox-collection--list .tox-collection__item--enabled{background-color:#2b3b4e;color:#fff}.tox .tox-collection--list .tox-collection__item--active{background-color:#4a5562}.tox .tox-collection--toolbar .tox-collection__item--enabled{background-color:#757d87;color:#fff}.tox .tox-collection--toolbar .tox-collection__item--active{background-color:#4a5562}.tox .tox-collection--grid .tox-collection__item--enabled{background-color:#757d87;color:#fff}.tox .tox-collection--grid .tox-collection__item--active:not(.tox-collection__item--state-disabled){background-color:#4a5562;color:#fff}.tox .tox-collection--list .tox-collection__item--active:not(.tox-collection__item--state-disabled){color:#fff}.tox .tox-collection--toolbar .tox-collection__item--active:not(.tox-collection__item--state-disabled){color:#fff}.tox .tox-collection__item-checkmark,.tox .tox-collection__item-icon{align-items:center;display:flex;height:24px;justify-content:center;width:24px}.tox .tox-collection__item-checkmark svg,.tox .tox-collection__item-icon svg{fill:currentColor}.tox .tox-collection--toolbar-lg .tox-collection__item-icon{height:48px;width:48px}.tox .tox-collection__item-label{color:currentColor;display:inline-block;flex:1;-ms-flex-preferred-size:auto;font-size:14px;font-style:normal;font-weight:400;line-height:24px;text-transform:none;word-break:break-all}.tox .tox-collection__item-accessory{color:rgba(255,255,255,.5);display:inline-block;font-size:14px;height:24px;line-height:24px;text-transform:none}.tox .tox-collection__item-caret{align-items:center;display:flex;min-height:24px}.tox .tox-collection__item-caret::after{content:'';font-size:0;min-height:inherit}.tox .tox-collection__item-caret svg{fill:#fff}.tox .tox-collection__item--state-disabled{background-color:transparent;color:rgba(255,255,255,.5);cursor:not-allowed}.tox .tox-collection__item--state-disabled .tox-collection__item-caret svg{fill:rgba(255,255,255,.5)}.tox .tox-collection--list .tox-collection__item:not(.tox-collection__item--enabled) .tox-collection__item-checkmark svg{display:none}.tox .tox-collection--list .tox-collection__item:not(.tox-collection__item--enabled) .tox-collection__item-accessory+.tox-collection__item-checkmark{display:none}.tox .tox-collection--horizontal{background-color:#2b3b4e;border:1px solid #1a1a1a;border-radius:3px;box-shadow:0 1px 3px rgba(0,0,0,.15);display:flex;flex:0 0 auto;flex-shrink:0;flex-wrap:nowrap;margin-bottom:0;overflow-x:auto;padding:0}.tox .tox-collection--horizontal .tox-collection__group{align-items:center;display:flex;flex-wrap:nowrap;margin:0;padding:0 4px}.tox .tox-collection--horizontal .tox-collection__item{height:34px;margin:2px 0 3px 0;padding:0 4px}.tox .tox-collection--horizontal .tox-collection__item-label{white-space:nowrap}.tox .tox-collection--horizontal .tox-collection__item-caret{margin-left:4px}.tox .tox-collection__item-container{display:flex}.tox .tox-collection__item-container--row{align-items:center;flex:1 1 auto;flex-direction:row}.tox .tox-collection__item-container--row.tox-collection__item-container--align-left{margin-right:auto}.tox .tox-collection__item-container--row.tox-collection__item-container--align-right{justify-content:flex-end;margin-left:auto}.tox .tox-collection__item-container--row.tox-collection__item-container--valign-top{align-items:flex-start;margin-bottom:auto}.tox .tox-collection__item-container--row.tox-collection__item-container--valign-middle{align-items:center}.tox .tox-collection__item-container--row.tox-collection__item-container--valign-bottom{align-items:flex-end;margin-top:auto}.tox .tox-collection__item-container--column{-ms-grid-row-align:center;align-self:center;flex:1 1 auto;flex-direction:column}.tox .tox-collection__item-container--column.tox-collection__item-container--align-left{align-items:flex-start}.tox .tox-collection__item-container--column.tox-collection__item-container--align-right{align-items:flex-end}.tox .tox-collection__item-container--column.tox-collection__item-container--valign-top{align-self:flex-start}.tox .tox-collection__item-container--column.tox-collection__item-container--valign-middle{-ms-grid-row-align:center;align-self:center}.tox .tox-collection__item-container--column.tox-collection__item-container--valign-bottom{align-self:flex-end}.tox:not([dir=rtl]) .tox-collection--horizontal .tox-collection__group:not(:last-of-type){border-right:1px solid #000}.tox:not([dir=rtl]) .tox-collection--list .tox-collection__item>:not(:first-child){margin-left:8px}.tox:not([dir=rtl]) .tox-collection--list .tox-collection__item>.tox-collection__item-label:first-child{margin-left:4px}.tox:not([dir=rtl]) .tox-collection__item-accessory{margin-left:16px;text-align:right}.tox:not([dir=rtl]) .tox-collection .tox-collection__item-caret{margin-left:16px}.tox[dir=rtl] .tox-collection--horizontal .tox-collection__group:not(:last-of-type){border-left:1px solid #000}.tox[dir=rtl] .tox-collection--list .tox-collection__item>:not(:first-child){margin-right:8px}.tox[dir=rtl] .tox-collection--list .tox-collection__item>.tox-collection__item-label:first-child{margin-right:4px}.tox[dir=rtl] .tox-collection__item-accessory{margin-right:16px;text-align:left}.tox[dir=rtl] .tox-collection .tox-collection__item-caret{margin-right:16px;transform:rotateY(180deg)}.tox[dir=rtl] .tox-collection--horizontal .tox-collection__item-caret{margin-right:4px}.tox .tox-color-picker-container{display:flex;flex-direction:row;height:225px;margin:0}.tox .tox-sv-palette{box-sizing:border-box;display:flex;height:100%}.tox .tox-sv-palette-spectrum{height:100%}.tox .tox-sv-palette,.tox .tox-sv-palette-spectrum{width:225px}.tox .tox-sv-palette-thumb{background:0 0;border:1px solid #000;border-radius:50%;box-sizing:content-box;height:12px;position:absolute;width:12px}.tox .tox-sv-palette-inner-thumb{border:1px solid #fff;border-radius:50%;height:10px;position:absolute;width:10px}.tox .tox-hue-slider{box-sizing:border-box;height:100%;width:25px}.tox .tox-hue-slider-spectrum{background:linear-gradient(to bottom,red,#ff0080,#f0f,#8000ff,#00f,#0080ff,#0ff,#00ff80,#0f0,#80ff00,#ff0,#ff8000,red);height:100%;width:100%}.tox .tox-hue-slider,.tox .tox-hue-slider-spectrum{width:20px}.tox .tox-hue-slider-thumb{background:#fff;border:1px solid #000;box-sizing:content-box;height:4px;width:100%}.tox .tox-rgb-form{display:flex;flex-direction:column;justify-content:space-between}.tox .tox-rgb-form div{align-items:center;display:flex;justify-content:space-between;margin-bottom:5px;width:inherit}.tox .tox-rgb-form input{width:6em}.tox .tox-rgb-form input.tox-invalid{border:1px solid red!important}.tox .tox-rgb-form .tox-rgba-preview{border:1px solid #000;flex-grow:2;margin-bottom:0}.tox:not([dir=rtl]) .tox-sv-palette{margin-right:15px}.tox:not([dir=rtl]) .tox-hue-slider{margin-right:15px}.tox:not([dir=rtl]) .tox-hue-slider-thumb{margin-left:-1px}.tox:not([dir=rtl]) .tox-rgb-form label{margin-right:.5em}.tox[dir=rtl] .tox-sv-palette{margin-left:15px}.tox[dir=rtl] .tox-hue-slider{margin-left:15px}.tox[dir=rtl] .tox-hue-slider-thumb{margin-right:-1px}.tox[dir=rtl] .tox-rgb-form label{margin-left:.5em}.tox .tox-toolbar .tox-swatches,.tox .tox-toolbar__overflow .tox-swatches,.tox .tox-toolbar__primary .tox-swatches{margin:2px 0 3px 4px}.tox .tox-collection--list .tox-collection__group .tox-swatches-menu{border:0;margin:-4px 0}.tox .tox-swatches__row{display:flex}.tox .tox-swatch{height:30px;transition:transform .15s,box-shadow .15s;width:30px}.tox .tox-swatch:focus,.tox .tox-swatch:hover{box-shadow:0 0 0 1px rgba(127,127,127,.3) inset;transform:scale(.8)}.tox .tox-swatch--remove{align-items:center;display:flex;justify-content:center}.tox .tox-swatch--remove svg path{stroke:#e74c3c}.tox .tox-swatches__picker-btn{align-items:center;background-color:transparent;border:0;cursor:pointer;display:flex;height:30px;justify-content:center;outline:0;padding:0;width:30px}.tox .tox-swatches__picker-btn svg{height:24px;width:24px}.tox .tox-swatches__picker-btn:hover{background:#4a5562}.tox:not([dir=rtl]) .tox-swatches__picker-btn{margin-left:auto}.tox[dir=rtl] .tox-swatches__picker-btn{margin-right:auto}.tox .tox-comment-thread{background:#2b3b4e;position:relative}.tox .tox-comment-thread>:not(:first-child){margin-top:8px}.tox .tox-comment{background:#2b3b4e;border:1px solid #000;border-radius:3px;box-shadow:0 4px 8px 0 rgba(42,55,70,.1);padding:8px 8px 16px 8px;position:relative}.tox .tox-comment__header{align-items:center;color:#fff;display:flex;justify-content:space-between}.tox .tox-comment__date{color:rgba(255,255,255,.5);font-size:12px}.tox .tox-comment__body{color:#fff;font-size:14px;font-style:normal;font-weight:400;line-height:1.3;margin-top:8px;position:relative;text-transform:initial}.tox .tox-comment__body textarea{resize:none;white-space:normal;width:100%}.tox .tox-comment__expander{padding-top:8px}.tox .tox-comment__expander p{color:rgba(255,255,255,.5);font-size:14px;font-style:normal}.tox .tox-comment__body p{margin:0}.tox .tox-comment__buttonspacing{padding-top:16px;text-align:center}.tox .tox-comment-thread__overlay::after{background:#2b3b4e;bottom:0;content:"";display:flex;left:0;opacity:.9;position:absolute;right:0;top:0;z-index:5}.tox .tox-comment__reply{display:flex;flex-shrink:0;flex-wrap:wrap;justify-content:flex-end;margin-top:8px}.tox .tox-comment__reply>:first-child{margin-bottom:8px;width:100%}.tox .tox-comment__edit{display:flex;flex-wrap:wrap;justify-content:flex-end;margin-top:16px}.tox .tox-comment__gradient::after{background:linear-gradient(rgba(43,59,78,0),#2b3b4e);bottom:0;content:"";display:block;height:5em;margin-top:-40px;position:absolute;width:100%}.tox .tox-comment__overlay{background:#2b3b4e;bottom:0;display:flex;flex-direction:column;flex-grow:1;left:0;opacity:.9;position:absolute;right:0;text-align:center;top:0;z-index:5}.tox .tox-comment__loading-text{align-items:center;color:#fff;display:flex;flex-direction:column;position:relative}.tox .tox-comment__loading-text>div{padding-bottom:16px}.tox .tox-comment__overlaytext{bottom:0;flex-direction:column;font-size:14px;left:0;padding:1em;position:absolute;right:0;top:0;z-index:10}.tox .tox-comment__overlaytext p{background-color:#2b3b4e;box-shadow:0 0 8px 8px #2b3b4e;color:#fff;text-align:center}.tox .tox-comment__overlaytext div:nth-of-type(2){font-size:.8em}.tox .tox-comment__busy-spinner{align-items:center;background-color:#2b3b4e;bottom:0;display:flex;justify-content:center;left:0;position:absolute;right:0;top:0;z-index:20}.tox .tox-comment__scroll{display:flex;flex-direction:column;flex-shrink:1;overflow:auto}.tox .tox-conversations{margin:8px}.tox:not([dir=rtl]) .tox-comment__edit{margin-left:8px}.tox:not([dir=rtl]) .tox-comment__buttonspacing>:last-child,.tox:not([dir=rtl]) .tox-comment__edit>:last-child,.tox:not([dir=rtl]) .tox-comment__reply>:last-child{margin-left:8px}.tox[dir=rtl] .tox-comment__edit{margin-right:8px}.tox[dir=rtl] .tox-comment__buttonspacing>:last-child,.tox[dir=rtl] .tox-comment__edit>:last-child,.tox[dir=rtl] .tox-comment__reply>:last-child{margin-right:8px}.tox .tox-user{align-items:center;display:flex}.tox .tox-user__avatar svg{fill:rgba(255,255,255,.5)}.tox .tox-user__name{color:rgba(255,255,255,.5);font-size:12px;font-style:normal;font-weight:700;text-transform:uppercase}.tox:not([dir=rtl]) .tox-user__avatar svg{margin-right:8px}.tox:not([dir=rtl]) .tox-user__avatar+.tox-user__name{margin-left:8px}.tox[dir=rtl] .tox-user__avatar svg{margin-left:8px}.tox[dir=rtl] .tox-user__avatar+.tox-user__name{margin-right:8px}.tox .tox-dialog-wrap{align-items:center;bottom:0;display:flex;justify-content:center;left:0;position:fixed;right:0;top:0;z-index:1100}.tox .tox-dialog-wrap__backdrop{background-color:rgba(34,47,62,.75);bottom:0;left:0;position:absolute;right:0;top:0;z-index:1}.tox .tox-dialog-wrap__backdrop--opaque{background-color:#222f3e}.tox .tox-dialog{background-color:#2b3b4e;border-color:#000;border-radius:3px;border-style:solid;border-width:1px;box-shadow:0 16px 16px -10px rgba(42,55,70,.15),0 0 40px 1px rgba(42,55,70,.15);display:flex;flex-direction:column;max-height:100%;max-width:480px;overflow:hidden;position:relative;width:95vw;z-index:2}@media only screen and (max-width:767px){body:not(.tox-force-desktop) .tox .tox-dialog{align-self:flex-start;margin:8px auto;width:calc(100vw - 16px)}}.tox .tox-dialog-inline{z-index:1100}.tox .tox-dialog__header{align-items:center;background-color:#2b3b4e;border-bottom:none;color:#fff;display:flex;font-size:16px;justify-content:space-between;padding:8px 16px 0 16px;position:relative}.tox .tox-dialog__header .tox-button{z-index:1}.tox .tox-dialog__draghandle{cursor:grab;height:100%;left:0;position:absolute;top:0;width:100%}.tox .tox-dialog__draghandle:active{cursor:grabbing}.tox .tox-dialog__dismiss{margin-left:auto}.tox .tox-dialog__title{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;font-size:20px;font-style:normal;font-weight:400;line-height:1.3;margin:0;text-transform:none}.tox .tox-dialog__body{color:#fff;display:flex;flex:1;-ms-flex-preferred-size:auto;font-size:16px;font-style:normal;font-weight:400;line-height:1.3;min-width:0;text-align:left;text-transform:none}@media only screen and (max-width:767px){body:not(.tox-force-desktop) .tox .tox-dialog__body{flex-direction:column}}.tox .tox-dialog__body-nav{align-items:flex-start;display:flex;flex-direction:column;padding:16px 16px}@media only screen and (max-width:767px){body:not(.tox-force-desktop) .tox .tox-dialog__body-nav{flex-direction:row;-webkit-overflow-scrolling:touch;overflow-x:auto;padding-bottom:0}}.tox .tox-dialog__body-nav-item{border-bottom:2px solid transparent;color:rgba(255,255,255,.5);display:inline-block;font-size:14px;line-height:1.3;margin-bottom:8px;text-decoration:none;white-space:nowrap}.tox .tox-dialog__body-nav-item:focus{background-color:rgba(32,122,183,.1)}.tox .tox-dialog__body-nav-item--active{border-bottom:2px solid #207ab7;color:#207ab7}.tox .tox-dialog__body-content{box-sizing:border-box;display:flex;flex:1;flex-direction:column;-ms-flex-preferred-size:auto;max-height:650px;overflow:auto;-webkit-overflow-scrolling:touch;padding:16px 16px}.tox .tox-dialog__body-content>*{margin-bottom:0;margin-top:16px}.tox .tox-dialog__body-content>:first-child{margin-top:0}.tox .tox-dialog__body-content>:last-child{margin-bottom:0}.tox .tox-dialog__body-content>:only-child{margin-bottom:0;margin-top:0}.tox .tox-dialog__body-content a{color:#207ab7;cursor:pointer;text-decoration:none}.tox .tox-dialog__body-content a:focus,.tox .tox-dialog__body-content a:hover{color:#185d8c;text-decoration:none}.tox .tox-dialog__body-content a:active{color:#185d8c;text-decoration:none}.tox .tox-dialog__body-content svg{fill:#fff}.tox .tox-dialog__body-content ul{display:block;list-style-type:disc;margin-bottom:16px;-webkit-margin-end:0;margin-inline-end:0;-webkit-margin-start:0;margin-inline-start:0;-webkit-padding-start:2.5rem;padding-inline-start:2.5rem}.tox .tox-dialog__body-content .tox-form__group h1{color:#fff;font-size:20px;font-style:normal;font-weight:700;letter-spacing:normal;margin-bottom:16px;margin-top:2rem;text-transform:none}.tox .tox-dialog__body-content .tox-form__group h2{color:#fff;font-size:16px;font-style:normal;font-weight:700;letter-spacing:normal;margin-bottom:16px;margin-top:2rem;text-transform:none}.tox .tox-dialog__body-content .tox-form__group p{margin-bottom:16px}.tox .tox-dialog__body-content .tox-form__group h1:first-child,.tox .tox-dialog__body-content .tox-form__group h2:first-child,.tox .tox-dialog__body-content .tox-form__group p:first-child{margin-top:0}.tox .tox-dialog__body-content .tox-form__group h1:last-child,.tox .tox-dialog__body-content .tox-form__group h2:last-child,.tox .tox-dialog__body-content .tox-form__group p:last-child{margin-bottom:0}.tox .tox-dialog__body-content .tox-form__group h1:only-child,.tox .tox-dialog__body-content .tox-form__group h2:only-child,.tox .tox-dialog__body-content .tox-form__group p:only-child{margin-bottom:0;margin-top:0}.tox .tox-dialog--width-lg{height:650px;max-width:1200px}.tox .tox-dialog--width-md{max-width:800px}.tox .tox-dialog--width-md .tox-dialog__body-content{overflow:auto}.tox .tox-dialog__body-content--centered{text-align:center}.tox .tox-dialog__footer{align-items:center;background-color:#2b3b4e;border-top:1px solid #000;display:flex;justify-content:space-between;padding:8px 16px}.tox .tox-dialog__footer-end,.tox .tox-dialog__footer-start{display:flex}.tox .tox-dialog__busy-spinner{align-items:center;background-color:rgba(34,47,62,.75);bottom:0;display:flex;justify-content:center;left:0;position:absolute;right:0;top:0;z-index:3}.tox .tox-dialog__table{border-collapse:collapse;width:100%}.tox .tox-dialog__table thead th{font-weight:700;padding-bottom:8px}.tox .tox-dialog__table tbody tr{border-bottom:1px solid #000}.tox .tox-dialog__table tbody tr:last-child{border-bottom:none}.tox .tox-dialog__table td{padding-bottom:8px;padding-top:8px}.tox .tox-dialog__popups{position:absolute;width:100%;z-index:1100}.tox .tox-dialog__body-iframe{display:flex;flex:1;flex-direction:column;-ms-flex-preferred-size:auto}.tox .tox-dialog__body-iframe .tox-navobj{display:flex;flex:1;-ms-flex-preferred-size:auto}.tox .tox-dialog__body-iframe .tox-navobj :nth-child(2){flex:1;-ms-flex-preferred-size:auto;height:100%}.tox .tox-dialog-dock-fadeout{opacity:0;visibility:hidden}.tox .tox-dialog-dock-fadein{opacity:1;visibility:visible}.tox .tox-dialog-dock-transition{transition:visibility 0s linear .3s,opacity .3s ease}.tox .tox-dialog-dock-transition.tox-dialog-dock-fadein{transition-delay:0s}.tox.tox-platform-ie .tox-dialog-wrap{position:-ms-device-fixed}@media only screen and (max-width:767px){body:not(.tox-force-desktop) .tox:not([dir=rtl]) .tox-dialog__body-nav{margin-right:0}}@media only screen and (max-width:767px){body:not(.tox-force-desktop) .tox:not([dir=rtl]) .tox-dialog__body-nav-item:not(:first-child){margin-left:8px}}.tox:not([dir=rtl]) .tox-dialog__footer .tox-dialog__footer-end>*,.tox:not([dir=rtl]) .tox-dialog__footer .tox-dialog__footer-start>*{margin-left:8px}.tox[dir=rtl] .tox-dialog__body{text-align:right}@media only screen and (max-width:767px){body:not(.tox-force-desktop) .tox[dir=rtl] .tox-dialog__body-nav{margin-left:0}}@media only screen and (max-width:767px){body:not(.tox-force-desktop) .tox[dir=rtl] .tox-dialog__body-nav-item:not(:first-child){margin-right:8px}}.tox[dir=rtl] .tox-dialog__footer .tox-dialog__footer-end>*,.tox[dir=rtl] .tox-dialog__footer .tox-dialog__footer-start>*{margin-right:8px}body.tox-dialog__disable-scroll{overflow:hidden}.tox .tox-dropzone-container{display:flex;flex:1;-ms-flex-preferred-size:auto}.tox .tox-dropzone{align-items:center;background:#fff;border:2px dashed #000;box-sizing:border-box;display:flex;flex-direction:column;flex-grow:1;justify-content:center;min-height:100px;padding:10px}.tox .tox-dropzone p{color:rgba(255,255,255,.5);margin:0 0 16px 0}.tox .tox-edit-area{display:flex;flex:1;-ms-flex-preferred-size:auto;overflow:hidden;position:relative}.tox .tox-edit-area__iframe{background-color:#fff;border:0;box-sizing:border-box;flex:1;-ms-flex-preferred-size:auto;height:100%;position:absolute;width:100%}.tox.tox-inline-edit-area{border:1px dotted #000}.tox .tox-editor-container{display:flex;flex:1 1 auto;flex-direction:column;overflow:hidden}.tox .tox-editor-header{z-index:1}.tox:not(.tox-tinymce-inline) .tox-editor-header{box-shadow:none;transition:box-shadow .5s}.tox.tox-tinymce--toolbar-bottom .tox-editor-header,.tox.tox-tinymce-inline .tox-editor-header{margin-bottom:-1px}.tox.tox-tinymce--toolbar-sticky-on .tox-editor-header{background-color:transparent;box-shadow:0 4px 4px -3px rgba(0,0,0,.25)}.tox-editor-dock-fadeout{opacity:0;visibility:hidden}.tox-editor-dock-fadein{opacity:1;visibility:visible}.tox-editor-dock-transition{transition:visibility 0s linear .25s,opacity .25s ease}.tox-editor-dock-transition.tox-editor-dock-fadein{transition-delay:0s}.tox .tox-control-wrap{flex:1;position:relative}.tox .tox-control-wrap:not(.tox-control-wrap--status-invalid) .tox-control-wrap__status-icon-invalid,.tox .tox-control-wrap:not(.tox-control-wrap--status-unknown) .tox-control-wrap__status-icon-unknown,.tox .tox-control-wrap:not(.tox-control-wrap--status-valid) .tox-control-wrap__status-icon-valid{display:none}.tox .tox-control-wrap svg{display:block}.tox .tox-control-wrap__status-icon-wrap{position:absolute;top:50%;transform:translateY(-50%)}.tox .tox-control-wrap__status-icon-invalid svg{fill:#c00}.tox .tox-control-wrap__status-icon-unknown svg{fill:orange}.tox .tox-control-wrap__status-icon-valid svg{fill:green}.tox:not([dir=rtl]) .tox-control-wrap--status-invalid .tox-textfield,.tox:not([dir=rtl]) .tox-control-wrap--status-unknown .tox-textfield,.tox:not([dir=rtl]) .tox-control-wrap--status-valid .tox-textfield{padding-right:32px}.tox:not([dir=rtl]) .tox-control-wrap__status-icon-wrap{right:4px}.tox[dir=rtl] .tox-control-wrap--status-invalid .tox-textfield,.tox[dir=rtl] .tox-control-wrap--status-unknown .tox-textfield,.tox[dir=rtl] .tox-control-wrap--status-valid .tox-textfield{padding-left:32px}.tox[dir=rtl] .tox-control-wrap__status-icon-wrap{left:4px}.tox .tox-autocompleter{max-width:25em}.tox .tox-autocompleter .tox-menu{max-width:25em}.tox .tox-autocompleter .tox-autocompleter-highlight{font-weight:700}.tox .tox-color-input{display:flex;position:relative;z-index:1}.tox .tox-color-input .tox-textfield{z-index:-1}.tox .tox-color-input span{border-color:rgba(42,55,70,.2);border-radius:3px;border-style:solid;border-width:1px;box-shadow:none;box-sizing:border-box;height:24px;position:absolute;top:6px;width:24px}.tox .tox-color-input span:focus:not([aria-disabled=true]),.tox .tox-color-input span:hover:not([aria-disabled=true]){border-color:#207ab7;cursor:pointer}.tox .tox-color-input span::before{background-image:linear-gradient(45deg,rgba(255,255,255,.25) 25%,transparent 25%),linear-gradient(-45deg,rgba(255,255,255,.25) 25%,transparent 25%),linear-gradient(45deg,transparent 75%,rgba(255,255,255,.25) 75%),linear-gradient(-45deg,transparent 75%,rgba(255,255,255,.25) 75%);background-position:0 0,0 6px,6px -6px,-6px 0;background-size:12px 12px;border:1px solid #2b3b4e;border-radius:3px;box-sizing:border-box;content:'';height:24px;left:-1px;position:absolute;top:-1px;width:24px;z-index:-1}.tox .tox-color-input span[aria-disabled=true]{cursor:not-allowed}.tox:not([dir=rtl]) .tox-color-input .tox-textfield{padding-left:36px}.tox:not([dir=rtl]) .tox-color-input span{left:6px}.tox[dir=rtl] .tox-color-input .tox-textfield{padding-right:36px}.tox[dir=rtl] .tox-color-input span{right:6px}.tox .tox-label,.tox .tox-toolbar-label{color:rgba(255,255,255,.5);display:block;font-size:14px;font-style:normal;font-weight:400;line-height:1.3;padding:0 8px 0 0;text-transform:none;white-space:nowrap}.tox .tox-toolbar-label{padding:0 8px}.tox[dir=rtl] .tox-label{padding:0 0 0 8px}.tox .tox-form{display:flex;flex:1;flex-direction:column;-ms-flex-preferred-size:auto}.tox .tox-form__group{box-sizing:border-box;margin-bottom:4px}.tox .tox-form-group--maximize{flex:1}.tox .tox-form__group--error{color:#c00}.tox .tox-form__group--collection{display:flex}.tox .tox-form__grid{display:flex;flex-direction:row;flex-wrap:wrap;justify-content:space-between}.tox .tox-form__grid--2col>.tox-form__group{width:calc(50% - (8px / 2))}.tox .tox-form__grid--3col>.tox-form__group{width:calc(100% / 3 - (8px / 2))}.tox .tox-form__grid--4col>.tox-form__group{width:calc(25% - (8px / 2))}.tox .tox-form__controls-h-stack{align-items:center;display:flex}.tox .tox-form__group--inline{align-items:center;display:flex}.tox .tox-form__group--stretched{display:flex;flex:1;flex-direction:column;-ms-flex-preferred-size:auto}.tox .tox-form__group--stretched .tox-textarea{flex:1;-ms-flex-preferred-size:auto}.tox .tox-form__group--stretched .tox-navobj{display:flex;flex:1;-ms-flex-preferred-size:auto}.tox .tox-form__group--stretched .tox-navobj :nth-child(2){flex:1;-ms-flex-preferred-size:auto;height:100%}.tox:not([dir=rtl]) .tox-form__controls-h-stack>:not(:first-child){margin-left:4px}.tox[dir=rtl] .tox-form__controls-h-stack>:not(:first-child){margin-right:4px}.tox .tox-lock.tox-locked .tox-lock-icon__unlock,.tox .tox-lock:not(.tox-locked) .tox-lock-icon__lock{display:none}.tox .tox-listboxfield .tox-listbox--select,.tox .tox-textarea,.tox .tox-textfield,.tox .tox-toolbar-textfield{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#2b3b4e;border-color:#000;border-radius:3px;border-style:solid;border-width:1px;box-shadow:none;box-sizing:border-box;color:#fff;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;font-size:16px;line-height:24px;margin:0;min-height:34px;outline:0;padding:5px 4.75px;resize:none;width:100%}.tox .tox-textarea[disabled],.tox .tox-textfield[disabled]{background-color:#222f3e;color:rgba(255,255,255,.85);cursor:not-allowed}.tox .tox-listboxfield .tox-listbox--select:focus,.tox .tox-textarea:focus,.tox .tox-textfield:focus{background-color:#2b3b4e;border-color:#207ab7;box-shadow:none;outline:0}.tox .tox-toolbar-textfield{border-width:0;margin-bottom:3px;margin-top:2px;max-width:250px}.tox .tox-naked-btn{background-color:transparent;border:0;border-color:transparent;box-shadow:unset;color:#207ab7;cursor:pointer;display:block;margin:0;padding:0}.tox .tox-naked-btn svg{display:block;fill:#fff}.tox:not([dir=rtl]) .tox-toolbar-textfield+*{margin-left:4px}.tox[dir=rtl] .tox-toolbar-textfield+*{margin-right:4px}.tox .tox-listboxfield{cursor:pointer;position:relative}.tox .tox-listboxfield .tox-listbox--select[disabled]{background-color:#19232e;color:rgba(255,255,255,.85);cursor:not-allowed}.tox .tox-listbox__select-label{cursor:default;flex:1;margin:0 4px}.tox .tox-listbox__select-chevron{align-items:center;display:flex;justify-content:center;width:16px}.tox .tox-listbox__select-chevron svg{fill:#fff}.tox .tox-listboxfield .tox-listbox--select{align-items:center;display:flex}.tox:not([dir=rtl]) .tox-listboxfield svg{right:8px}.tox[dir=rtl] .tox-listboxfield svg{left:8px}.tox .tox-selectfield{cursor:pointer;position:relative}.tox .tox-selectfield select{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#2b3b4e;border-color:#000;border-radius:3px;border-style:solid;border-width:1px;box-shadow:none;box-sizing:border-box;color:#fff;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;font-size:16px;line-height:24px;margin:0;min-height:34px;outline:0;padding:5px 4.75px;resize:none;width:100%}.tox .tox-selectfield select[disabled]{background-color:#19232e;color:rgba(255,255,255,.85);cursor:not-allowed}.tox .tox-selectfield select::-ms-expand{display:none}.tox .tox-selectfield select:focus{background-color:#2b3b4e;border-color:#207ab7;box-shadow:none;outline:0}.tox .tox-selectfield svg{pointer-events:none;position:absolute;top:50%;transform:translateY(-50%)}.tox:not([dir=rtl]) .tox-selectfield select[size="0"],.tox:not([dir=rtl]) .tox-selectfield select[size="1"]{padding-right:24px}.tox:not([dir=rtl]) .tox-selectfield svg{right:8px}.tox[dir=rtl] .tox-selectfield select[size="0"],.tox[dir=rtl] .tox-selectfield select[size="1"]{padding-left:24px}.tox[dir=rtl] .tox-selectfield svg{left:8px}.tox .tox-textarea{-webkit-appearance:textarea;-moz-appearance:textarea;appearance:textarea;white-space:pre-wrap}.tox-fullscreen{border:0;height:100%;margin:0;overflow:hidden;-ms-scroll-chaining:none;overscroll-behavior:none;padding:0;touch-action:pinch-zoom;width:100%}.tox.tox-tinymce.tox-fullscreen .tox-statusbar__resize-handle{display:none}.tox-shadowhost.tox-fullscreen,.tox.tox-tinymce.tox-fullscreen{left:0;position:fixed;top:0;z-index:1200}.tox.tox-tinymce.tox-fullscreen{background-color:transparent}.tox-fullscreen .tox.tox-tinymce-aux,.tox-fullscreen~.tox.tox-tinymce-aux{z-index:1201}.tox .tox-help__more-link{list-style:none;margin-top:1em}.tox .tox-image-tools{width:100%}.tox .tox-image-tools__toolbar{align-items:center;display:flex;justify-content:center}.tox .tox-image-tools__image{background-color:#666;height:380px;overflow:auto;position:relative;width:100%}.tox .tox-image-tools__image,.tox .tox-image-tools__image+.tox-image-tools__toolbar{margin-top:8px}.tox .tox-image-tools__image-bg{background:url(data:image/gif;base64,R0lGODdhDAAMAIABAMzMzP///ywAAAAADAAMAAACFoQfqYeabNyDMkBQb81Uat85nxguUAEAOw==)}.tox .tox-image-tools__toolbar>.tox-spacer{flex:1;-ms-flex-preferred-size:auto}.tox .tox-croprect-block{background:#000;opacity:.5;position:absolute;zoom:1}.tox .tox-croprect-handle{border:2px solid #fff;height:20px;left:0;position:absolute;top:0;width:20px}.tox .tox-croprect-handle-move{border:0;cursor:move;position:absolute}.tox .tox-croprect-handle-nw{border-width:2px 0 0 2px;cursor:nw-resize;left:100px;margin:-2px 0 0 -2px;top:100px}.tox .tox-croprect-handle-ne{border-width:2px 2px 0 0;cursor:ne-resize;left:200px;margin:-2px 0 0 -20px;top:100px}.tox .tox-croprect-handle-sw{border-width:0 0 2px 2px;cursor:sw-resize;left:100px;margin:-20px 2px 0 -2px;top:200px}.tox .tox-croprect-handle-se{border-width:0 2px 2px 0;cursor:se-resize;left:200px;margin:-20px 0 0 -20px;top:200px}.tox:not([dir=rtl]) .tox-image-tools__toolbar>.tox-slider:not(:first-of-type){margin-left:8px}.tox:not([dir=rtl]) .tox-image-tools__toolbar>.tox-button+.tox-slider{margin-left:32px}.tox:not([dir=rtl]) .tox-image-tools__toolbar>.tox-slider+.tox-button{margin-left:32px}.tox[dir=rtl] .tox-image-tools__toolbar>.tox-slider:not(:first-of-type){margin-right:8px}.tox[dir=rtl] .tox-image-tools__toolbar>.tox-button+.tox-slider{margin-right:32px}.tox[dir=rtl] .tox-image-tools__toolbar>.tox-slider+.tox-button{margin-right:32px}.tox .tox-insert-table-picker{display:flex;flex-wrap:wrap;width:170px}.tox .tox-insert-table-picker>div{border-color:#000;border-style:solid;border-width:0 1px 1px 0;box-sizing:border-box;height:17px;width:17px}.tox .tox-collection--list .tox-collection__group .tox-insert-table-picker{margin:-4px 0}.tox .tox-insert-table-picker .tox-insert-table-picker__selected{background-color:rgba(32,122,183,.5);border-color:rgba(32,122,183,.5)}.tox .tox-insert-table-picker__label{color:#fff;display:block;font-size:14px;padding:4px;text-align:center;width:100%}.tox:not([dir=rtl]) .tox-insert-table-picker>div:nth-child(10n){border-right:0}.tox[dir=rtl] .tox-insert-table-picker>div:nth-child(10n+1){border-right:0}.tox .tox-menu{background-color:#2b3b4e;border:1px solid #000;border-radius:3px;box-shadow:0 4px 8px 0 rgba(42,55,70,.1);display:inline-block;overflow:hidden;vertical-align:top;z-index:1150}.tox .tox-menu.tox-collection.tox-collection--list{padding:0}.tox .tox-menu.tox-collection.tox-collection--toolbar{padding:4px}.tox .tox-menu.tox-collection.tox-collection--grid{padding:4px}.tox .tox-menu__label blockquote,.tox .tox-menu__label code,.tox .tox-menu__label h1,.tox .tox-menu__label h2,.tox .tox-menu__label h3,.tox .tox-menu__label h4,.tox .tox-menu__label h5,.tox .tox-menu__label h6,.tox .tox-menu__label p{margin:0}.tox .tox-menubar{background:url("data:image/svg+xml;charset=utf8,%3Csvg height='39px' viewBox='0 0 40 39px' width='40' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='0' y='38px' width='100' height='1' fill='%23000000'/%3E%3C/svg%3E") left 0 top 0 #222f3e;background-color:#222f3e;display:flex;flex:0 0 auto;flex-shrink:0;flex-wrap:wrap;padding:0 4px 0 4px}.tox.tox-tinymce:not(.tox-tinymce-inline) .tox-editor-header:not(:first-child) .tox-menubar{border-top:1px solid #000}.tox .tox-mbtn{align-items:center;background:0 0;border:0;border-radius:3px;box-shadow:none;color:#fff;display:flex;flex:0 0 auto;font-size:14px;font-style:normal;font-weight:400;height:34px;justify-content:center;margin:2px 0 3px 0;outline:0;overflow:hidden;padding:0 4px;text-transform:none;width:auto}.tox .tox-mbtn[disabled]{background-color:transparent;border:0;box-shadow:none;color:rgba(255,255,255,.5);cursor:not-allowed}.tox .tox-mbtn:focus:not(:disabled){background:#4a5562;border:0;box-shadow:none;color:#fff}.tox .tox-mbtn--active{background:#757d87;border:0;box-shadow:none;color:#fff}.tox .tox-mbtn:hover:not(:disabled):not(.tox-mbtn--active){background:#4a5562;border:0;box-shadow:none;color:#fff}.tox .tox-mbtn__select-label{cursor:default;font-weight:400;margin:0 4px}.tox .tox-mbtn[disabled] .tox-mbtn__select-label{cursor:not-allowed}.tox .tox-mbtn__select-chevron{align-items:center;display:flex;justify-content:center;width:16px;display:none}.tox .tox-notification{border-radius:3px;border-style:solid;border-width:1px;box-shadow:none;box-sizing:border-box;display:-ms-grid;display:grid;font-size:14px;font-weight:400;-ms-grid-columns:minmax(40px,1fr) auto minmax(40px,1fr);grid-template-columns:minmax(40px,1fr) auto minmax(40px,1fr);margin-top:4px;opacity:0;padding:4px;transition:transform .1s ease-in,opacity 150ms ease-in}.tox .tox-notification p{font-size:14px;font-weight:400}.tox .tox-notification a{cursor:pointer;text-decoration:underline}.tox .tox-notification--in{opacity:1}.tox .tox-notification--success{background-color:#e4eeda;border-color:#d7e6c8;color:#fff}.tox .tox-notification--success p{color:#fff}.tox .tox-notification--success a{color:#547831}.tox .tox-notification--success svg{fill:#fff}.tox .tox-notification--error{background-color:#f8dede;border-color:#f2bfbf;color:#fff}.tox .tox-notification--error p{color:#fff}.tox .tox-notification--error a{color:#c00}.tox .tox-notification--error svg{fill:#fff}.tox .tox-notification--warn,.tox .tox-notification--warning{background-color:#fffaea;border-color:#ffe89d;color:#fff}.tox .tox-notification--warn p,.tox .tox-notification--warning p{color:#fff}.tox .tox-notification--warn a,.tox .tox-notification--warning a{color:#fff}.tox .tox-notification--warn svg,.tox .tox-notification--warning svg{fill:#fff}.tox .tox-notification--info{background-color:#d9edf7;border-color:#779ecb;color:#fff}.tox .tox-notification--info p{color:#fff}.tox .tox-notification--info a{color:#fff}.tox .tox-notification--info svg{fill:#fff}.tox .tox-notification__body{-ms-grid-row-align:center;align-self:center;color:#fff;font-size:14px;-ms-grid-column-span:1;grid-column-end:3;-ms-grid-column:2;grid-column-start:2;-ms-grid-row-span:1;grid-row-end:2;-ms-grid-row:1;grid-row-start:1;text-align:center;white-space:normal;word-break:break-all;word-break:break-word}.tox .tox-notification__body>*{margin:0}.tox .tox-notification__body>*+*{margin-top:1rem}.tox .tox-notification__icon{-ms-grid-row-align:center;align-self:center;-ms-grid-column-span:1;grid-column-end:2;-ms-grid-column:1;grid-column-start:1;-ms-grid-row-span:1;grid-row-end:2;-ms-grid-row:1;grid-row-start:1;-ms-grid-column-align:end;justify-self:end}.tox .tox-notification__icon svg{display:block}.tox .tox-notification__dismiss{-ms-grid-row-align:start;align-self:start;-ms-grid-column-span:1;grid-column-end:4;-ms-grid-column:3;grid-column-start:3;-ms-grid-row-span:1;grid-row-end:2;-ms-grid-row:1;grid-row-start:1;-ms-grid-column-align:end;justify-self:end}.tox .tox-notification .tox-progress-bar{-ms-grid-column-span:3;grid-column-end:4;-ms-grid-column:1;grid-column-start:1;-ms-grid-row-span:1;grid-row-end:3;-ms-grid-row:2;grid-row-start:2;-ms-grid-column-align:center;justify-self:center}.tox .tox-pop{display:inline-block;position:relative}.tox .tox-pop--resizing{transition:width .1s ease}.tox .tox-pop--resizing .tox-toolbar,.tox .tox-pop--resizing .tox-toolbar__group{flex-wrap:nowrap}.tox .tox-pop--transition{transition:.15s ease;transition-property:left,right,top,bottom}.tox .tox-pop--transition::after,.tox .tox-pop--transition::before{transition:all .15s,visibility 0s,opacity 75ms ease 75ms}.tox .tox-pop__dialog{background-color:#222f3e;border:1px solid #000;border-radius:3px;box-shadow:0 1px 3px rgba(0,0,0,.15);min-width:0;overflow:hidden}.tox .tox-pop__dialog>:not(.tox-toolbar){margin:4px 4px 4px 8px}.tox .tox-pop__dialog .tox-toolbar{background-color:transparent;margin-bottom:-1px}.tox .tox-pop::after,.tox .tox-pop::before{border-style:solid;content:'';display:block;height:0;opacity:1;position:absolute;width:0}.tox .tox-pop.tox-pop--inset::after,.tox .tox-pop.tox-pop--inset::before{opacity:0;transition:all 0s .15s,visibility 0s,opacity 75ms ease}.tox .tox-pop.tox-pop--bottom::after,.tox .tox-pop.tox-pop--bottom::before{left:50%;top:100%}.tox .tox-pop.tox-pop--bottom::after{border-color:#222f3e transparent transparent transparent;border-width:8px;margin-left:-8px;margin-top:-1px}.tox .tox-pop.tox-pop--bottom::before{border-color:#000 transparent transparent transparent;border-width:9px;margin-left:-9px}.tox .tox-pop.tox-pop--top::after,.tox .tox-pop.tox-pop--top::before{left:50%;top:0;transform:translateY(-100%)}.tox .tox-pop.tox-pop--top::after{border-color:transparent transparent #222f3e transparent;border-width:8px;margin-left:-8px;margin-top:1px}.tox .tox-pop.tox-pop--top::before{border-color:transparent transparent #000 transparent;border-width:9px;margin-left:-9px}.tox .tox-pop.tox-pop--left::after,.tox .tox-pop.tox-pop--left::before{left:0;top:calc(50% - 1px);transform:translateY(-50%)}.tox .tox-pop.tox-pop--left::after{border-color:transparent #222f3e transparent transparent;border-width:8px;margin-left:-15px}.tox .tox-pop.tox-pop--left::before{border-color:transparent #000 transparent transparent;border-width:10px;margin-left:-19px}.tox .tox-pop.tox-pop--right::after,.tox .tox-pop.tox-pop--right::before{left:100%;top:calc(50% + 1px);transform:translateY(-50%)}.tox .tox-pop.tox-pop--right::after{border-color:transparent transparent transparent #222f3e;border-width:8px;margin-left:-1px}.tox .tox-pop.tox-pop--right::before{border-color:transparent transparent transparent #000;border-width:10px;margin-left:-1px}.tox .tox-pop.tox-pop--align-left::after,.tox .tox-pop.tox-pop--align-left::before{left:20px}.tox .tox-pop.tox-pop--align-right::after,.tox .tox-pop.tox-pop--align-right::before{left:calc(100% - 20px)}.tox .tox-sidebar-wrap{display:flex;flex-direction:row;flex-grow:1;-ms-flex-preferred-size:0;min-height:0}.tox .tox-sidebar{background-color:#222f3e;display:flex;flex-direction:row;justify-content:flex-end}.tox .tox-sidebar__slider{display:flex;overflow:hidden}.tox .tox-sidebar__pane-container{display:flex}.tox .tox-sidebar__pane{display:flex}.tox .tox-sidebar--sliding-closed{opacity:0}.tox .tox-sidebar--sliding-open{opacity:1}.tox .tox-sidebar--sliding-growing,.tox .tox-sidebar--sliding-shrinking{transition:width .5s ease,opacity .5s ease}.tox .tox-selector{background-color:#4099ff;border-color:#4099ff;border-style:solid;border-width:1px;box-sizing:border-box;display:inline-block;height:10px;position:absolute;width:10px}.tox.tox-platform-touch .tox-selector{height:12px;width:12px}.tox .tox-slider{align-items:center;display:flex;flex:1;-ms-flex-preferred-size:auto;height:24px;justify-content:center;position:relative}.tox .tox-slider__rail{background-color:transparent;border:1px solid #000;border-radius:3px;height:10px;min-width:120px;width:100%}.tox .tox-slider__handle{background-color:#207ab7;border:2px solid #185d8c;border-radius:3px;box-shadow:none;height:24px;left:50%;position:absolute;top:50%;transform:translateX(-50%) translateY(-50%);width:14px}.tox .tox-source-code{overflow:auto}.tox .tox-spinner{display:flex}.tox .tox-spinner>div{animation:tam-bouncing-dots 1.5s ease-in-out 0s infinite both;background-color:rgba(255,255,255,.5);border-radius:100%;height:8px;width:8px}.tox .tox-spinner>div:nth-child(1){animation-delay:-.32s}.tox .tox-spinner>div:nth-child(2){animation-delay:-.16s}@keyframes tam-bouncing-dots{0%,100%,80%{transform:scale(0)}40%{transform:scale(1)}}.tox:not([dir=rtl]) .tox-spinner>div:not(:first-child){margin-left:4px}.tox[dir=rtl] .tox-spinner>div:not(:first-child){margin-right:4px}.tox .tox-statusbar{align-items:center;background-color:#222f3e;border-top:1px solid #000;color:#fff;display:flex;flex:0 0 auto;font-size:12px;font-weight:400;height:18px;overflow:hidden;padding:0 8px;position:relative;text-transform:uppercase}.tox .tox-statusbar__text-container{display:flex;flex:1 1 auto;justify-content:flex-end;overflow:hidden}.tox .tox-statusbar__path{display:flex;flex:1 1 auto;margin-right:auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tox .tox-statusbar__path>*{display:inline;white-space:nowrap}.tox .tox-statusbar__wordcount{flex:0 0 auto;margin-left:1ch}.tox .tox-statusbar a,.tox .tox-statusbar__path-item,.tox .tox-statusbar__wordcount{color:#fff;text-decoration:none}.tox .tox-statusbar a:focus:not(:disabled):not([aria-disabled=true]),.tox .tox-statusbar a:hover:not(:disabled):not([aria-disabled=true]),.tox .tox-statusbar__path-item:focus:not(:disabled):not([aria-disabled=true]),.tox .tox-statusbar__path-item:hover:not(:disabled):not([aria-disabled=true]),.tox .tox-statusbar__wordcount:focus:not(:disabled):not([aria-disabled=true]),.tox .tox-statusbar__wordcount:hover:not(:disabled):not([aria-disabled=true]){cursor:pointer;text-decoration:underline}.tox .tox-statusbar__resize-handle{align-items:flex-end;align-self:stretch;cursor:nwse-resize;display:flex;flex:0 0 auto;justify-content:flex-end;margin-left:auto;margin-right:-8px;padding-left:1ch}.tox .tox-statusbar__resize-handle svg{display:block;fill:#fff}.tox .tox-statusbar__resize-handle:focus svg{background-color:#4a5562;border-radius:1px;box-shadow:0 0 0 2px #4a5562}.tox:not([dir=rtl]) .tox-statusbar__path>*{margin-right:4px}.tox:not([dir=rtl]) .tox-statusbar__branding{margin-left:1ch}.tox[dir=rtl] .tox-statusbar{flex-direction:row-reverse}.tox[dir=rtl] .tox-statusbar__path>*{margin-left:4px}.tox .tox-throbber{z-index:1299}.tox .tox-throbber__busy-spinner{align-items:center;background-color:rgba(34,47,62,.6);bottom:0;display:flex;justify-content:center;left:0;position:absolute;right:0;top:0}.tox .tox-tbtn{align-items:center;background:0 0;border:0;border-radius:3px;box-shadow:none;color:#fff;display:flex;flex:0 0 auto;font-size:14px;font-style:normal;font-weight:400;height:34px;justify-content:center;margin:2px 0 3px 0;outline:0;overflow:hidden;padding:0;text-transform:none;width:34px}.tox .tox-tbtn svg{display:block;fill:#fff}.tox .tox-tbtn.tox-tbtn-more{padding-left:5px;padding-right:5px;width:inherit}.tox .tox-tbtn:focus{background:#4a5562;border:0;box-shadow:none}.tox .tox-tbtn:hover{background:#4a5562;border:0;box-shadow:none;color:#fff}.tox .tox-tbtn:hover svg{fill:#fff}.tox .tox-tbtn:active{background:#757d87;border:0;box-shadow:none;color:#fff}.tox .tox-tbtn:active svg{fill:#fff}.tox .tox-tbtn--disabled,.tox .tox-tbtn--disabled:hover,.tox .tox-tbtn:disabled,.tox .tox-tbtn:disabled:hover{background:0 0;border:0;box-shadow:none;color:rgba(255,255,255,.5);cursor:not-allowed}.tox .tox-tbtn--disabled svg,.tox .tox-tbtn--disabled:hover svg,.tox .tox-tbtn:disabled svg,.tox .tox-tbtn:disabled:hover svg{fill:rgba(255,255,255,.5)}.tox .tox-tbtn--enabled,.tox .tox-tbtn--enabled:hover{background:#757d87;border:0;box-shadow:none;color:#fff}.tox .tox-tbtn--enabled:hover>*,.tox .tox-tbtn--enabled>*{transform:none}.tox .tox-tbtn--enabled svg,.tox .tox-tbtn--enabled:hover svg{fill:#fff}.tox .tox-tbtn:focus:not(.tox-tbtn--disabled){color:#fff}.tox .tox-tbtn:focus:not(.tox-tbtn--disabled) svg{fill:#fff}.tox .tox-tbtn:active>*{transform:none}.tox .tox-tbtn--md{height:51px;width:51px}.tox .tox-tbtn--lg{flex-direction:column;height:68px;width:68px}.tox .tox-tbtn--return{-ms-grid-row-align:stretch;align-self:stretch;height:unset;width:16px}.tox .tox-tbtn--labeled{padding:0 4px;width:unset}.tox .tox-tbtn__vlabel{display:block;font-size:10px;font-weight:400;letter-spacing:-.025em;margin-bottom:4px;white-space:nowrap}.tox .tox-tbtn--select{margin:2px 0 3px 0;padding:0 4px;width:auto}.tox .tox-tbtn__select-label{cursor:default;font-weight:400;margin:0 4px}.tox .tox-tbtn__select-chevron{align-items:center;display:flex;justify-content:center;width:16px}.tox .tox-tbtn__select-chevron svg{fill:rgba(255,255,255,.5)}.tox .tox-tbtn--bespoke .tox-tbtn__select-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;width:7em}.tox .tox-split-button{border:0;border-radius:3px;box-sizing:border-box;display:flex;margin:2px 0 3px 0;overflow:hidden}.tox .tox-split-button:hover{box-shadow:0 0 0 1px #4a5562 inset}.tox .tox-split-button:focus{background:#4a5562;box-shadow:none;color:#fff}.tox .tox-split-button>*{border-radius:0}.tox .tox-split-button__chevron{width:16px}.tox .tox-split-button__chevron svg{fill:rgba(255,255,255,.5)}.tox .tox-split-button .tox-tbtn{margin:0}.tox.tox-platform-touch .tox-split-button .tox-tbtn:first-child{width:30px}.tox.tox-platform-touch .tox-split-button__chevron{width:20px}.tox .tox-split-button.tox-tbtn--disabled .tox-tbtn:focus,.tox .tox-split-button.tox-tbtn--disabled .tox-tbtn:hover,.tox .tox-split-button.tox-tbtn--disabled:focus,.tox .tox-split-button.tox-tbtn--disabled:hover{background:0 0;box-shadow:none;color:rgba(255,255,255,.5)}.tox .tox-toolbar-overlord{background-color:#222f3e}.tox .tox-toolbar,.tox .tox-toolbar__overflow,.tox .tox-toolbar__primary{background:url("data:image/svg+xml;charset=utf8,%3Csvg height='39px' viewBox='0 0 40 39px' width='40' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='0' y='38px' width='100' height='1' fill='%23000000'/%3E%3C/svg%3E") left 0 top 0 #222f3e;background-color:#222f3e;display:flex;flex:0 0 auto;flex-shrink:0;flex-wrap:wrap;padding:0 0}.tox .tox-toolbar__overflow.tox-toolbar__overflow--closed{height:0;opacity:0;padding-bottom:0;padding-top:0;visibility:hidden}.tox .tox-toolbar__overflow--growing{transition:height .3s ease,opacity .2s linear .1s}.tox .tox-toolbar__overflow--shrinking{transition:opacity .3s ease,height .2s linear .1s,visibility 0s linear .3s}.tox .tox-menubar+.tox-toolbar,.tox .tox-menubar+.tox-toolbar-overlord .tox-toolbar__primary{border-top:1px solid #000;margin-top:-1px}.tox .tox-toolbar--scrolling{flex-wrap:nowrap;overflow-x:auto}.tox .tox-pop .tox-toolbar{border-width:0}.tox .tox-toolbar--no-divider{background-image:none}.tox-tinymce:not(.tox-tinymce-inline) .tox-editor-header:not(:first-child) .tox-toolbar-overlord:first-child .tox-toolbar__primary,.tox-tinymce:not(.tox-tinymce-inline) .tox-editor-header:not(:first-child) .tox-toolbar:first-child{border-top:1px solid #000}.tox.tox-tinymce-aux .tox-toolbar__overflow{background-color:#222f3e;border:1px solid #000;border-radius:3px;box-shadow:0 1px 3px rgba(0,0,0,.15)}.tox .tox-toolbar__group{align-items:center;display:flex;flex-wrap:wrap;margin:0 0;padding:0 4px 0 4px}.tox .tox-toolbar__group--pull-right{margin-left:auto}.tox .tox-toolbar--scrolling .tox-toolbar__group{flex-shrink:0;flex-wrap:nowrap}.tox:not([dir=rtl]) .tox-toolbar__group:not(:last-of-type){border-right:1px solid #000}.tox[dir=rtl] .tox-toolbar__group:not(:last-of-type){border-left:1px solid #000}.tox .tox-tooltip{display:inline-block;padding:8px;position:relative}.tox .tox-tooltip__body{background-color:#3d546f;border-radius:3px;box-shadow:0 2px 4px rgba(42,55,70,.3);color:rgba(255,255,255,.75);font-size:14px;font-style:normal;font-weight:400;padding:4px 8px;text-transform:none}.tox .tox-tooltip__arrow{position:absolute}.tox .tox-tooltip--down .tox-tooltip__arrow{border-left:8px solid transparent;border-right:8px solid transparent;border-top:8px solid #3d546f;bottom:0;left:50%;position:absolute;transform:translateX(-50%)}.tox .tox-tooltip--up .tox-tooltip__arrow{border-bottom:8px solid #3d546f;border-left:8px solid transparent;border-right:8px solid transparent;left:50%;position:absolute;top:0;transform:translateX(-50%)}.tox .tox-tooltip--right .tox-tooltip__arrow{border-bottom:8px solid transparent;border-left:8px solid #3d546f;border-top:8px solid transparent;position:absolute;right:0;top:50%;transform:translateY(-50%)}.tox .tox-tooltip--left .tox-tooltip__arrow{border-bottom:8px solid transparent;border-right:8px solid #3d546f;border-top:8px solid transparent;left:0;position:absolute;top:50%;transform:translateY(-50%)}.tox .tox-well{border:1px solid #000;border-radius:3px;padding:8px;width:100%}.tox .tox-well>:first-child{margin-top:0}.tox .tox-well>:last-child{margin-bottom:0}.tox .tox-well>:only-child{margin:0}.tox .tox-custom-editor{border:1px solid #000;border-radius:3px;display:flex;flex:1;position:relative}.tox .tox-dialog-loading::before{background-color:rgba(0,0,0,.5);content:"";height:100%;position:absolute;width:100%;z-index:1000}.tox .tox-tab{cursor:pointer}.tox .tox-dialog__content-js{display:flex;flex:1;-ms-flex-preferred-size:auto}.tox .tox-dialog__body-content .tox-collection{display:flex;flex:1;-ms-flex-preferred-size:auto}.tox .tox-image-tools-edit-panel{height:60px}.tox .tox-image-tools__sidebar{height:60px} diff --git a/ui/public/tinymce/skins/ui/oxide-dark/skin.mobile.css b/ui/public/tinymce/skins/ui/oxide-dark/skin.mobile.css new file mode 100644 index 0000000..875721a --- /dev/null +++ b/ui/public/tinymce/skins/ui/oxide-dark/skin.mobile.css @@ -0,0 +1,673 @@ +/** + * Copyright (c) Tiny Technologies, Inc. All rights reserved. + * Licensed under the LGPL or a commercial license. + * For LGPL see License.txt in the project root for license information. + * For commercial licenses see https://www.tiny.cloud/ + */ +/* RESET all the things! */ +.tinymce-mobile-outer-container { + all: initial; + display: block; +} +.tinymce-mobile-outer-container * { + border: 0; + box-sizing: initial; + cursor: inherit; + float: none; + line-height: 1; + margin: 0; + outline: 0; + padding: 0; + -webkit-tap-highlight-color: transparent; + /* TBIO-3691, stop the gray flicker on touch. */ + text-shadow: none; + white-space: nowrap; +} +.tinymce-mobile-icon-arrow-back::before { + content: "\e5cd"; +} +.tinymce-mobile-icon-image::before { + content: "\e412"; +} +.tinymce-mobile-icon-cancel-circle::before { + content: "\e5c9"; +} +.tinymce-mobile-icon-full-dot::before { + content: "\e061"; +} +.tinymce-mobile-icon-align-center::before { + content: "\e234"; +} +.tinymce-mobile-icon-align-left::before { + content: "\e236"; +} +.tinymce-mobile-icon-align-right::before { + content: "\e237"; +} +.tinymce-mobile-icon-bold::before { + content: "\e238"; +} +.tinymce-mobile-icon-italic::before { + content: "\e23f"; +} +.tinymce-mobile-icon-unordered-list::before { + content: "\e241"; +} +.tinymce-mobile-icon-ordered-list::before { + content: "\e242"; +} +.tinymce-mobile-icon-font-size::before { + content: "\e245"; +} +.tinymce-mobile-icon-underline::before { + content: "\e249"; +} +.tinymce-mobile-icon-link::before { + content: "\e157"; +} +.tinymce-mobile-icon-unlink::before { + content: "\eca2"; +} +.tinymce-mobile-icon-color::before { + content: "\e891"; +} +.tinymce-mobile-icon-previous::before { + content: "\e314"; +} +.tinymce-mobile-icon-next::before { + content: "\e315"; +} +.tinymce-mobile-icon-large-font::before, +.tinymce-mobile-icon-style-formats::before { + content: "\e264"; +} +.tinymce-mobile-icon-undo::before { + content: "\e166"; +} +.tinymce-mobile-icon-redo::before { + content: "\e15a"; +} +.tinymce-mobile-icon-removeformat::before { + content: "\e239"; +} +.tinymce-mobile-icon-small-font::before { + content: "\e906"; +} +.tinymce-mobile-icon-readonly-back::before, +.tinymce-mobile-format-matches::after { + content: "\e5ca"; +} +.tinymce-mobile-icon-small-heading::before { + content: "small"; +} +.tinymce-mobile-icon-large-heading::before { + content: "large"; +} +.tinymce-mobile-icon-small-heading::before, +.tinymce-mobile-icon-large-heading::before { + font-family: sans-serif; + font-size: 80%; +} +.tinymce-mobile-mask-edit-icon::before { + content: "\e254"; +} +.tinymce-mobile-icon-back::before { + content: "\e5c4"; +} +.tinymce-mobile-icon-heading::before { + /* TODO: Translate */ + content: "Headings"; + font-family: sans-serif; + font-size: 80%; + font-weight: bold; +} +.tinymce-mobile-icon-h1::before { + content: "H1"; + font-weight: bold; +} +.tinymce-mobile-icon-h2::before { + content: "H2"; + font-weight: bold; +} +.tinymce-mobile-icon-h3::before { + content: "H3"; + font-weight: bold; +} +.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask { + align-items: center; + display: flex; + justify-content: center; + background: rgba(51, 51, 51, 0.5); + height: 100%; + position: absolute; + top: 0; + width: 100%; +} +.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container { + align-items: center; + border-radius: 50%; + display: flex; + flex-direction: column; + font-family: sans-serif; + font-size: 1em; + justify-content: space-between; +} +.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .mixin-menu-item { + align-items: center; + display: flex; + justify-content: center; + border-radius: 50%; + height: 2.1em; + width: 2.1em; +} +.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section { + align-items: center; + display: flex; + justify-content: center; + flex-direction: column; + font-size: 1em; +} +@media only screen and (min-device-width:700px) { + .tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section { + font-size: 1.2em; + } +} +.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section .tinymce-mobile-mask-tap-icon { + align-items: center; + display: flex; + justify-content: center; + border-radius: 50%; + height: 2.1em; + width: 2.1em; + background-color: white; + color: #207ab7; +} +.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section .tinymce-mobile-mask-tap-icon::before { + content: "\e900"; + font-family: 'tinymce-mobile', sans-serif; +} +.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section:not(.tinymce-mobile-mask-tap-icon-selected) .tinymce-mobile-mask-tap-icon { + z-index: 2; +} +.tinymce-mobile-android-container.tinymce-mobile-android-maximized { + background: #ffffff; + border: none; + bottom: 0; + display: flex; + flex-direction: column; + left: 0; + position: fixed; + right: 0; + top: 0; +} +.tinymce-mobile-android-container:not(.tinymce-mobile-android-maximized) { + position: relative; +} +.tinymce-mobile-android-container .tinymce-mobile-editor-socket { + display: flex; + flex-grow: 1; +} +.tinymce-mobile-android-container .tinymce-mobile-editor-socket iframe { + display: flex !important; + flex-grow: 1; + height: auto !important; +} +.tinymce-mobile-android-scroll-reload { + overflow: hidden; +} +:not(.tinymce-mobile-readonly-mode) > .tinymce-mobile-android-selection-context-toolbar { + margin-top: 23px; +} +.tinymce-mobile-toolstrip { + background: #fff; + display: flex; + flex: 0 0 auto; + z-index: 1; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar { + align-items: center; + background-color: #fff; + border-bottom: 1px solid #cccccc; + display: flex; + flex: 1; + height: 2.5em; + width: 100%; + /* Make it no larger than the toolstrip, so that it needs to scroll */ +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group { + align-items: center; + display: flex; + height: 100%; + flex-shrink: 1; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group > div { + align-items: center; + display: flex; + height: 100%; + flex: 1; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group.tinymce-mobile-exit-container { + background: #f44336; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group.tinymce-mobile-toolbar-scrollable-group { + flex-grow: 1; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item { + padding-left: 0.5em; + padding-right: 0.5em; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item.tinymce-mobile-toolbar-button { + align-items: center; + display: flex; + height: 80%; + margin-left: 2px; + margin-right: 2px; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item.tinymce-mobile-toolbar-button.tinymce-mobile-toolbar-button-selected { + background: #c8cbcf; + color: #cccccc; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group:first-of-type, +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group:last-of-type { + background: #207ab7; + color: #eceff1; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar { + /* Note, this file is imported inside .tinymce-mobile-context-toolbar, so that prefix is on everything here. */ +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group { + align-items: center; + display: flex; + height: 100%; + flex: 1; + padding-bottom: 0.4em; + padding-top: 0.4em; + /* Make any buttons appearing on the left and right display in the centre (e.g. color edges) */ + /* For widgets like the colour picker, use the whole height */ +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog { + display: flex; + min-height: 1.5em; + overflow: hidden; + padding-left: 0; + padding-right: 0; + position: relative; + width: 100%; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain { + display: flex; + height: 100%; + transition: left cubic-bezier(0.4, 0, 1, 1) 0.15s; + width: 100%; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen { + display: flex; + flex: 0 0 auto; + justify-content: space-between; + width: 100%; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen input { + font-family: Sans-serif; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-input-container { + display: flex; + flex-grow: 1; + position: relative; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-input-container .tinymce-mobile-input-container-x { + -ms-grid-row-align: center; + align-self: center; + background: inherit; + border: none; + border-radius: 50%; + color: #888; + font-size: 0.6em; + font-weight: bold; + height: 100%; + padding-right: 2px; + position: absolute; + right: 0; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-input-container.tinymce-mobile-input-container-empty .tinymce-mobile-input-container-x { + display: none; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-previous, +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-next { + align-items: center; + display: flex; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-previous::before, +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-next::before { + align-items: center; + display: flex; + font-weight: bold; + height: 100%; + padding-left: 0.5em; + padding-right: 0.5em; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-previous.tinymce-mobile-toolbar-navigation-disabled::before, +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-next.tinymce-mobile-toolbar-navigation-disabled::before { + visibility: hidden; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-dot-item { + color: #cccccc; + font-size: 10px; + line-height: 10px; + margin: 0 2px; + padding-top: 3px; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-dot-item.tinymce-mobile-dot-active { + color: #c8cbcf; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-icon-large-font::before, +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-icon-large-heading::before { + margin-left: 0.5em; + margin-right: 0.9em; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-icon-small-font::before, +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-icon-small-heading::before { + margin-left: 0.9em; + margin-right: 0.5em; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider { + display: flex; + flex: 1; + margin-left: 0; + margin-right: 0; + padding: 0.28em 0; + position: relative; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider .tinymce-mobile-slider-size-container { + align-items: center; + display: flex; + flex-grow: 1; + height: 100%; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider .tinymce-mobile-slider-size-container .tinymce-mobile-slider-size-line { + background: #cccccc; + display: flex; + flex: 1; + height: 0.2em; + margin-bottom: 0.3em; + margin-top: 0.3em; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container { + padding-left: 2em; + padding-right: 2em; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container .tinymce-mobile-slider-gradient-container { + align-items: center; + display: flex; + flex-grow: 1; + height: 100%; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container .tinymce-mobile-slider-gradient-container .tinymce-mobile-slider-gradient { + background: linear-gradient(to right, hsl(0, 100%, 50%) 0%, hsl(60, 100%, 50%) 17%, hsl(120, 100%, 50%) 33%, hsl(180, 100%, 50%) 50%, hsl(240, 100%, 50%) 67%, hsl(300, 100%, 50%) 83%, hsl(0, 100%, 50%) 100%); + display: flex; + flex: 1; + height: 0.2em; + margin-bottom: 0.3em; + margin-top: 0.3em; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container .tinymce-mobile-hue-slider-black { + /* Not part of theming */ + background: black; + height: 0.2em; + margin-bottom: 0.3em; + margin-top: 0.3em; + width: 1.2em; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container .tinymce-mobile-hue-slider-white { + /* Not part of theming */ + background: white; + height: 0.2em; + margin-bottom: 0.3em; + margin-top: 0.3em; + width: 1.2em; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider .tinymce-mobile-slider-thumb { + /* vertically centering trick (margin: auto, top: 0, bottom: 0). On iOS and Safari, if you leave + * out these values, then it shows the thumb at the top of the spectrum. This is probably because it is + * absolutely positioned with only a left value, and not a top. Note, on Chrome it seems to be fine without + * this approach. + */ + align-items: center; + background-clip: padding-box; + background-color: #455a64; + border: 0.5em solid rgba(136, 136, 136, 0); + border-radius: 3em; + bottom: 0; + color: #fff; + display: flex; + height: 0.5em; + justify-content: center; + left: -10px; + margin: auto; + position: absolute; + top: 0; + transition: border 120ms cubic-bezier(0.39, 0.58, 0.57, 1); + width: 0.5em; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider .tinymce-mobile-slider-thumb.tinymce-mobile-thumb-active { + border: 0.5em solid rgba(136, 136, 136, 0.39); +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serializer-wrapper, +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group > div { + align-items: center; + display: flex; + height: 100%; + flex: 1; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serializer-wrapper { + flex-direction: column; + justify-content: center; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item { + align-items: center; + display: flex; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item:not(.tinymce-mobile-serialised-dialog) { + height: 100%; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-dot-container { + display: flex; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group input { + background: #ffffff; + border: none; + border-radius: 0; + color: #455a64; + flex-grow: 1; + font-size: 0.85em; + padding-bottom: 0.1em; + padding-left: 5px; + padding-top: 0.1em; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group input::-webkit-input-placeholder { + /* WebKit, Blink, Edge */ + color: #888; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group input::placeholder { + /* WebKit, Blink, Edge */ + color: #888; +} +/* dropup */ +.tinymce-mobile-dropup { + background: white; + display: flex; + overflow: hidden; + width: 100%; +} +.tinymce-mobile-dropup.tinymce-mobile-dropup-shrinking { + transition: height 0.3s ease-out; +} +.tinymce-mobile-dropup.tinymce-mobile-dropup-growing { + transition: height 0.3s ease-in; +} +.tinymce-mobile-dropup.tinymce-mobile-dropup-closed { + flex-grow: 0; +} +.tinymce-mobile-dropup.tinymce-mobile-dropup-open:not(.tinymce-mobile-dropup-growing) { + flex-grow: 1; +} +/* TODO min-height for device size and orientation */ +.tinymce-mobile-ios-container .tinymce-mobile-dropup:not(.tinymce-mobile-dropup-closed) { + min-height: 200px; +} +@media only screen and (orientation: landscape) { + .tinymce-mobile-dropup:not(.tinymce-mobile-dropup-closed) { + min-height: 200px; + } +} +@media only screen and (min-device-width : 320px) and (max-device-width : 568px) and (orientation : landscape) { + .tinymce-mobile-ios-container .tinymce-mobile-dropup:not(.tinymce-mobile-dropup-closed) { + min-height: 150px; + } +} +/* styles menu */ +.tinymce-mobile-styles-menu { + font-family: sans-serif; + outline: 4px solid black; + overflow: hidden; + position: relative; + width: 100%; +} +.tinymce-mobile-styles-menu [role="menu"] { + display: flex; + flex-direction: column; + height: 100%; + position: absolute; + width: 100%; +} +.tinymce-mobile-styles-menu [role="menu"].transitioning { + transition: transform 0.5s ease-in-out; +} +.tinymce-mobile-styles-menu .tinymce-mobile-styles-item { + border-bottom: 1px solid #ddd; + color: #455a64; + cursor: pointer; + display: flex; + padding: 1em 1em; + position: relative; +} +.tinymce-mobile-styles-menu .tinymce-mobile-styles-collapser .tinymce-mobile-styles-collapse-icon::before { + color: #455a64; + content: "\e314"; + font-family: 'tinymce-mobile', sans-serif; +} +.tinymce-mobile-styles-menu .tinymce-mobile-styles-item.tinymce-mobile-styles-item-is-menu::after { + color: #455a64; + content: "\e315"; + font-family: 'tinymce-mobile', sans-serif; + padding-left: 1em; + padding-right: 1em; + position: absolute; + right: 0; +} +.tinymce-mobile-styles-menu .tinymce-mobile-styles-item.tinymce-mobile-format-matches::after { + font-family: 'tinymce-mobile', sans-serif; + padding-left: 1em; + padding-right: 1em; + position: absolute; + right: 0; +} +.tinymce-mobile-styles-menu .tinymce-mobile-styles-separator, +.tinymce-mobile-styles-menu .tinymce-mobile-styles-collapser { + align-items: center; + background: #fff; + border-top: #455a64; + color: #455a64; + display: flex; + min-height: 2.5em; + padding-left: 1em; + padding-right: 1em; +} +.tinymce-mobile-styles-menu [data-transitioning-destination="before"][data-transitioning-state], +.tinymce-mobile-styles-menu [data-transitioning-state="before"] { + transform: translate(-100%); +} +.tinymce-mobile-styles-menu [data-transitioning-destination="current"][data-transitioning-state], +.tinymce-mobile-styles-menu [data-transitioning-state="current"] { + transform: translate(0%); +} +.tinymce-mobile-styles-menu [data-transitioning-destination="after"][data-transitioning-state], +.tinymce-mobile-styles-menu [data-transitioning-state="after"] { + transform: translate(100%); +} +@font-face { + font-family: 'tinymce-mobile'; + font-style: normal; + font-weight: normal; + src: url('fonts/tinymce-mobile.woff?8x92w3') format('woff'); +} +@media (min-device-width: 700px) { + .tinymce-mobile-outer-container, + .tinymce-mobile-outer-container input { + font-size: 25px; + } +} +@media (max-device-width: 700px) { + .tinymce-mobile-outer-container, + .tinymce-mobile-outer-container input { + font-size: 18px; + } +} +.tinymce-mobile-icon { + font-family: 'tinymce-mobile', sans-serif; +} +.mixin-flex-and-centre { + align-items: center; + display: flex; + justify-content: center; +} +.mixin-flex-bar { + align-items: center; + display: flex; + height: 100%; +} +.tinymce-mobile-outer-container .tinymce-mobile-editor-socket iframe { + background-color: #fff; + width: 100%; +} +.tinymce-mobile-editor-socket .tinymce-mobile-mask-edit-icon { + /* Note, on the iPod touch in landscape, this isn't visible when the navbar appears */ + background-color: #207ab7; + border-radius: 50%; + bottom: 1em; + color: white; + font-size: 1em; + height: 2.1em; + position: fixed; + right: 2em; + width: 2.1em; + align-items: center; + display: flex; + justify-content: center; +} +@media only screen and (min-device-width:700px) { + .tinymce-mobile-editor-socket .tinymce-mobile-mask-edit-icon { + font-size: 1.2em; + } +} +.tinymce-mobile-outer-container:not(.tinymce-mobile-fullscreen-maximized) .tinymce-mobile-editor-socket { + height: 300px; + overflow: hidden; +} +.tinymce-mobile-outer-container:not(.tinymce-mobile-fullscreen-maximized) .tinymce-mobile-editor-socket iframe { + height: 100%; +} +.tinymce-mobile-outer-container:not(.tinymce-mobile-fullscreen-maximized) .tinymce-mobile-toolstrip { + display: none; +} +/* + Note, that if you don't include this (::-webkit-file-upload-button), the toolbar width gets + increased and the whole body becomes scrollable. It's important! + */ +input[type="file"]::-webkit-file-upload-button { + display: none; +} +@media only screen and (min-device-width : 320px) and (max-device-width : 568px) and (orientation : landscape) { + .tinymce-mobile-ios-container .tinymce-mobile-editor-socket .tinymce-mobile-mask-edit-icon { + bottom: 50%; + } +} diff --git a/ui/public/tinymce/skins/ui/oxide-dark/skin.mobile.min.css b/ui/public/tinymce/skins/ui/oxide-dark/skin.mobile.min.css new file mode 100644 index 0000000..3a45cac --- /dev/null +++ b/ui/public/tinymce/skins/ui/oxide-dark/skin.mobile.min.css @@ -0,0 +1,7 @@ +/** + * Copyright (c) Tiny Technologies, Inc. All rights reserved. + * Licensed under the LGPL or a commercial license. + * For LGPL see License.txt in the project root for license information. + * For commercial licenses see https://www.tiny.cloud/ + */ +.tinymce-mobile-outer-container{all:initial;display:block}.tinymce-mobile-outer-container *{border:0;box-sizing:initial;cursor:inherit;float:none;line-height:1;margin:0;outline:0;padding:0;-webkit-tap-highlight-color:transparent;text-shadow:none;white-space:nowrap}.tinymce-mobile-icon-arrow-back::before{content:"\e5cd"}.tinymce-mobile-icon-image::before{content:"\e412"}.tinymce-mobile-icon-cancel-circle::before{content:"\e5c9"}.tinymce-mobile-icon-full-dot::before{content:"\e061"}.tinymce-mobile-icon-align-center::before{content:"\e234"}.tinymce-mobile-icon-align-left::before{content:"\e236"}.tinymce-mobile-icon-align-right::before{content:"\e237"}.tinymce-mobile-icon-bold::before{content:"\e238"}.tinymce-mobile-icon-italic::before{content:"\e23f"}.tinymce-mobile-icon-unordered-list::before{content:"\e241"}.tinymce-mobile-icon-ordered-list::before{content:"\e242"}.tinymce-mobile-icon-font-size::before{content:"\e245"}.tinymce-mobile-icon-underline::before{content:"\e249"}.tinymce-mobile-icon-link::before{content:"\e157"}.tinymce-mobile-icon-unlink::before{content:"\eca2"}.tinymce-mobile-icon-color::before{content:"\e891"}.tinymce-mobile-icon-previous::before{content:"\e314"}.tinymce-mobile-icon-next::before{content:"\e315"}.tinymce-mobile-icon-large-font::before,.tinymce-mobile-icon-style-formats::before{content:"\e264"}.tinymce-mobile-icon-undo::before{content:"\e166"}.tinymce-mobile-icon-redo::before{content:"\e15a"}.tinymce-mobile-icon-removeformat::before{content:"\e239"}.tinymce-mobile-icon-small-font::before{content:"\e906"}.tinymce-mobile-format-matches::after,.tinymce-mobile-icon-readonly-back::before{content:"\e5ca"}.tinymce-mobile-icon-small-heading::before{content:"small"}.tinymce-mobile-icon-large-heading::before{content:"large"}.tinymce-mobile-icon-large-heading::before,.tinymce-mobile-icon-small-heading::before{font-family:sans-serif;font-size:80%}.tinymce-mobile-mask-edit-icon::before{content:"\e254"}.tinymce-mobile-icon-back::before{content:"\e5c4"}.tinymce-mobile-icon-heading::before{content:"Headings";font-family:sans-serif;font-size:80%;font-weight:700}.tinymce-mobile-icon-h1::before{content:"H1";font-weight:700}.tinymce-mobile-icon-h2::before{content:"H2";font-weight:700}.tinymce-mobile-icon-h3::before{content:"H3";font-weight:700}.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask{align-items:center;display:flex;justify-content:center;background:rgba(51,51,51,.5);height:100%;position:absolute;top:0;width:100%}.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container{align-items:center;border-radius:50%;display:flex;flex-direction:column;font-family:sans-serif;font-size:1em;justify-content:space-between}.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .mixin-menu-item{align-items:center;display:flex;justify-content:center;border-radius:50%;height:2.1em;width:2.1em}.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section{align-items:center;display:flex;justify-content:center;flex-direction:column;font-size:1em}@media only screen and (min-device-width:700px){.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section{font-size:1.2em}}.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section .tinymce-mobile-mask-tap-icon{align-items:center;display:flex;justify-content:center;border-radius:50%;height:2.1em;width:2.1em;background-color:#fff;color:#207ab7}.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section .tinymce-mobile-mask-tap-icon::before{content:"\e900";font-family:tinymce-mobile,sans-serif}.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section:not(.tinymce-mobile-mask-tap-icon-selected) .tinymce-mobile-mask-tap-icon{z-index:2}.tinymce-mobile-android-container.tinymce-mobile-android-maximized{background:#fff;border:none;bottom:0;display:flex;flex-direction:column;left:0;position:fixed;right:0;top:0}.tinymce-mobile-android-container:not(.tinymce-mobile-android-maximized){position:relative}.tinymce-mobile-android-container .tinymce-mobile-editor-socket{display:flex;flex-grow:1}.tinymce-mobile-android-container .tinymce-mobile-editor-socket iframe{display:flex!important;flex-grow:1;height:auto!important}.tinymce-mobile-android-scroll-reload{overflow:hidden}:not(.tinymce-mobile-readonly-mode)>.tinymce-mobile-android-selection-context-toolbar{margin-top:23px}.tinymce-mobile-toolstrip{background:#fff;display:flex;flex:0 0 auto;z-index:1}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar{align-items:center;background-color:#fff;border-bottom:1px solid #ccc;display:flex;flex:1;height:2.5em;width:100%}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group{align-items:center;display:flex;height:100%;flex-shrink:1}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group>div{align-items:center;display:flex;height:100%;flex:1}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group.tinymce-mobile-exit-container{background:#f44336}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group.tinymce-mobile-toolbar-scrollable-group{flex-grow:1}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item{padding-left:.5em;padding-right:.5em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item.tinymce-mobile-toolbar-button{align-items:center;display:flex;height:80%;margin-left:2px;margin-right:2px}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item.tinymce-mobile-toolbar-button.tinymce-mobile-toolbar-button-selected{background:#c8cbcf;color:#ccc}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group:first-of-type,.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group:last-of-type{background:#207ab7;color:#eceff1}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group{align-items:center;display:flex;height:100%;flex:1;padding-bottom:.4em;padding-top:.4em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog{display:flex;min-height:1.5em;overflow:hidden;padding-left:0;padding-right:0;position:relative;width:100%}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain{display:flex;height:100%;transition:left cubic-bezier(.4,0,1,1) .15s;width:100%}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen{display:flex;flex:0 0 auto;justify-content:space-between;width:100%}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen input{font-family:Sans-serif}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-input-container{display:flex;flex-grow:1;position:relative}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-input-container .tinymce-mobile-input-container-x{-ms-grid-row-align:center;align-self:center;background:inherit;border:none;border-radius:50%;color:#888;font-size:.6em;font-weight:700;height:100%;padding-right:2px;position:absolute;right:0}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-input-container.tinymce-mobile-input-container-empty .tinymce-mobile-input-container-x{display:none}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-next,.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-previous{align-items:center;display:flex}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-next::before,.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-previous::before{align-items:center;display:flex;font-weight:700;height:100%;padding-left:.5em;padding-right:.5em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-next.tinymce-mobile-toolbar-navigation-disabled::before,.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-previous.tinymce-mobile-toolbar-navigation-disabled::before{visibility:hidden}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-dot-item{color:#ccc;font-size:10px;line-height:10px;margin:0 2px;padding-top:3px}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-dot-item.tinymce-mobile-dot-active{color:#c8cbcf}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-icon-large-font::before,.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-icon-large-heading::before{margin-left:.5em;margin-right:.9em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-icon-small-font::before,.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-icon-small-heading::before{margin-left:.9em;margin-right:.5em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider{display:flex;flex:1;margin-left:0;margin-right:0;padding:.28em 0;position:relative}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider .tinymce-mobile-slider-size-container{align-items:center;display:flex;flex-grow:1;height:100%}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider .tinymce-mobile-slider-size-container .tinymce-mobile-slider-size-line{background:#ccc;display:flex;flex:1;height:.2em;margin-bottom:.3em;margin-top:.3em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container{padding-left:2em;padding-right:2em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container .tinymce-mobile-slider-gradient-container{align-items:center;display:flex;flex-grow:1;height:100%}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container .tinymce-mobile-slider-gradient-container .tinymce-mobile-slider-gradient{background:linear-gradient(to right,red 0,#feff00 17%,#0f0 33%,#00feff 50%,#00f 67%,#ff00fe 83%,red 100%);display:flex;flex:1;height:.2em;margin-bottom:.3em;margin-top:.3em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container .tinymce-mobile-hue-slider-black{background:#000;height:.2em;margin-bottom:.3em;margin-top:.3em;width:1.2em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container .tinymce-mobile-hue-slider-white{background:#fff;height:.2em;margin-bottom:.3em;margin-top:.3em;width:1.2em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider .tinymce-mobile-slider-thumb{align-items:center;background-clip:padding-box;background-color:#455a64;border:.5em solid rgba(136,136,136,0);border-radius:3em;bottom:0;color:#fff;display:flex;height:.5em;justify-content:center;left:-10px;margin:auto;position:absolute;top:0;transition:border 120ms cubic-bezier(.39,.58,.57,1);width:.5em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider .tinymce-mobile-slider-thumb.tinymce-mobile-thumb-active{border:.5em solid rgba(136,136,136,.39)}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serializer-wrapper,.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group>div{align-items:center;display:flex;height:100%;flex:1}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serializer-wrapper{flex-direction:column;justify-content:center}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item{align-items:center;display:flex}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item:not(.tinymce-mobile-serialised-dialog){height:100%}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-dot-container{display:flex}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group input{background:#fff;border:none;border-radius:0;color:#455a64;flex-grow:1;font-size:.85em;padding-bottom:.1em;padding-left:5px;padding-top:.1em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group input::-webkit-input-placeholder{color:#888}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group input::placeholder{color:#888}.tinymce-mobile-dropup{background:#fff;display:flex;overflow:hidden;width:100%}.tinymce-mobile-dropup.tinymce-mobile-dropup-shrinking{transition:height .3s ease-out}.tinymce-mobile-dropup.tinymce-mobile-dropup-growing{transition:height .3s ease-in}.tinymce-mobile-dropup.tinymce-mobile-dropup-closed{flex-grow:0}.tinymce-mobile-dropup.tinymce-mobile-dropup-open:not(.tinymce-mobile-dropup-growing){flex-grow:1}.tinymce-mobile-ios-container .tinymce-mobile-dropup:not(.tinymce-mobile-dropup-closed){min-height:200px}@media only screen and (orientation:landscape){.tinymce-mobile-dropup:not(.tinymce-mobile-dropup-closed){min-height:200px}}@media only screen and (min-device-width :320px) and (max-device-width :568px) and (orientation :landscape){.tinymce-mobile-ios-container .tinymce-mobile-dropup:not(.tinymce-mobile-dropup-closed){min-height:150px}}.tinymce-mobile-styles-menu{font-family:sans-serif;outline:4px solid #000;overflow:hidden;position:relative;width:100%}.tinymce-mobile-styles-menu [role=menu]{display:flex;flex-direction:column;height:100%;position:absolute;width:100%}.tinymce-mobile-styles-menu [role=menu].transitioning{transition:transform .5s ease-in-out}.tinymce-mobile-styles-menu .tinymce-mobile-styles-item{border-bottom:1px solid #ddd;color:#455a64;cursor:pointer;display:flex;padding:1em 1em;position:relative}.tinymce-mobile-styles-menu .tinymce-mobile-styles-collapser .tinymce-mobile-styles-collapse-icon::before{color:#455a64;content:"\e314";font-family:tinymce-mobile,sans-serif}.tinymce-mobile-styles-menu .tinymce-mobile-styles-item.tinymce-mobile-styles-item-is-menu::after{color:#455a64;content:"\e315";font-family:tinymce-mobile,sans-serif;padding-left:1em;padding-right:1em;position:absolute;right:0}.tinymce-mobile-styles-menu .tinymce-mobile-styles-item.tinymce-mobile-format-matches::after{font-family:tinymce-mobile,sans-serif;padding-left:1em;padding-right:1em;position:absolute;right:0}.tinymce-mobile-styles-menu .tinymce-mobile-styles-collapser,.tinymce-mobile-styles-menu .tinymce-mobile-styles-separator{align-items:center;background:#fff;border-top:#455a64;color:#455a64;display:flex;min-height:2.5em;padding-left:1em;padding-right:1em}.tinymce-mobile-styles-menu [data-transitioning-destination=before][data-transitioning-state],.tinymce-mobile-styles-menu [data-transitioning-state=before]{transform:translate(-100%)}.tinymce-mobile-styles-menu [data-transitioning-destination=current][data-transitioning-state],.tinymce-mobile-styles-menu [data-transitioning-state=current]{transform:translate(0)}.tinymce-mobile-styles-menu [data-transitioning-destination=after][data-transitioning-state],.tinymce-mobile-styles-menu [data-transitioning-state=after]{transform:translate(100%)}@font-face{font-family:tinymce-mobile;font-style:normal;font-weight:400;src:url(fonts/tinymce-mobile.woff?8x92w3) format('woff')}@media (min-device-width:700px){.tinymce-mobile-outer-container,.tinymce-mobile-outer-container input{font-size:25px}}@media (max-device-width:700px){.tinymce-mobile-outer-container,.tinymce-mobile-outer-container input{font-size:18px}}.tinymce-mobile-icon{font-family:tinymce-mobile,sans-serif}.mixin-flex-and-centre{align-items:center;display:flex;justify-content:center}.mixin-flex-bar{align-items:center;display:flex;height:100%}.tinymce-mobile-outer-container .tinymce-mobile-editor-socket iframe{background-color:#fff;width:100%}.tinymce-mobile-editor-socket .tinymce-mobile-mask-edit-icon{background-color:#207ab7;border-radius:50%;bottom:1em;color:#fff;font-size:1em;height:2.1em;position:fixed;right:2em;width:2.1em;align-items:center;display:flex;justify-content:center}@media only screen and (min-device-width:700px){.tinymce-mobile-editor-socket .tinymce-mobile-mask-edit-icon{font-size:1.2em}}.tinymce-mobile-outer-container:not(.tinymce-mobile-fullscreen-maximized) .tinymce-mobile-editor-socket{height:300px;overflow:hidden}.tinymce-mobile-outer-container:not(.tinymce-mobile-fullscreen-maximized) .tinymce-mobile-editor-socket iframe{height:100%}.tinymce-mobile-outer-container:not(.tinymce-mobile-fullscreen-maximized) .tinymce-mobile-toolstrip{display:none}input[type=file]::-webkit-file-upload-button{display:none}@media only screen and (min-device-width :320px) and (max-device-width :568px) and (orientation :landscape){.tinymce-mobile-ios-container .tinymce-mobile-editor-socket .tinymce-mobile-mask-edit-icon{bottom:50%}} diff --git a/ui/public/tinymce/skins/ui/oxide-dark/skin.shadowdom.css b/ui/public/tinymce/skins/ui/oxide-dark/skin.shadowdom.css new file mode 100644 index 0000000..d2adc4d --- /dev/null +++ b/ui/public/tinymce/skins/ui/oxide-dark/skin.shadowdom.css @@ -0,0 +1,37 @@ +/** + * Copyright (c) Tiny Technologies, Inc. All rights reserved. + * Licensed under the LGPL or a commercial license. + * For LGPL see License.txt in the project root for license information. + * For commercial licenses see https://www.tiny.cloud/ + */ +body.tox-dialog__disable-scroll { + overflow: hidden; +} +.tox-fullscreen { + border: 0; + height: 100%; + margin: 0; + overflow: hidden; + -ms-scroll-chaining: none; + overscroll-behavior: none; + padding: 0; + touch-action: pinch-zoom; + width: 100%; +} +.tox.tox-tinymce.tox-fullscreen .tox-statusbar__resize-handle { + display: none; +} +.tox.tox-tinymce.tox-fullscreen, +.tox-shadowhost.tox-fullscreen { + left: 0; + position: fixed; + top: 0; + z-index: 1200; +} +.tox.tox-tinymce.tox-fullscreen { + background-color: transparent; +} +.tox-fullscreen .tox.tox-tinymce-aux, +.tox-fullscreen ~ .tox.tox-tinymce-aux { + z-index: 1201; +} diff --git a/ui/public/tinymce/skins/ui/oxide-dark/skin.shadowdom.min.css b/ui/public/tinymce/skins/ui/oxide-dark/skin.shadowdom.min.css new file mode 100644 index 0000000..a0893b9 --- /dev/null +++ b/ui/public/tinymce/skins/ui/oxide-dark/skin.shadowdom.min.css @@ -0,0 +1,7 @@ +/** + * Copyright (c) Tiny Technologies, Inc. All rights reserved. + * Licensed under the LGPL or a commercial license. + * For LGPL see License.txt in the project root for license information. + * For commercial licenses see https://www.tiny.cloud/ + */ +body.tox-dialog__disable-scroll{overflow:hidden}.tox-fullscreen{border:0;height:100%;margin:0;overflow:hidden;-ms-scroll-chaining:none;overscroll-behavior:none;padding:0;touch-action:pinch-zoom;width:100%}.tox.tox-tinymce.tox-fullscreen .tox-statusbar__resize-handle{display:none}.tox-shadowhost.tox-fullscreen,.tox.tox-tinymce.tox-fullscreen{left:0;position:fixed;top:0;z-index:1200}.tox.tox-tinymce.tox-fullscreen{background-color:transparent}.tox-fullscreen .tox.tox-tinymce-aux,.tox-fullscreen~.tox.tox-tinymce-aux{z-index:1201} diff --git a/ui/public/tinymce/skins/ui/oxide/content.css b/ui/public/tinymce/skins/ui/oxide/content.css new file mode 100644 index 0000000..2ac0cca --- /dev/null +++ b/ui/public/tinymce/skins/ui/oxide/content.css @@ -0,0 +1,732 @@ +/** + * Copyright (c) Tiny Technologies, Inc. All rights reserved. + * Licensed under the LGPL or a commercial license. + * For LGPL see License.txt in the project root for license information. + * For commercial licenses see https://www.tiny.cloud/ + */ +.mce-content-body .mce-item-anchor { + background: transparent url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'8'%20height%3D'12'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20d%3D'M0%200L8%200%208%2012%204.09117821%209%200%2012z'%2F%3E%3C%2Fsvg%3E%0A") no-repeat center; + cursor: default; + display: inline-block; + height: 12px !important; + padding: 0 2px; + -webkit-user-modify: read-only; + -moz-user-modify: read-only; + -webkit-user-select: all; + -moz-user-select: all; + -ms-user-select: all; + user-select: all; + width: 8px !important; +} +.mce-content-body .mce-item-anchor[data-mce-selected] { + outline-offset: 1px; +} +.tox-comments-visible .tox-comment { + background-color: #fff0b7; +} +.tox-comments-visible .tox-comment--active { + background-color: #ffe168; +} +.tox-checklist > li:not(.tox-checklist--hidden) { + list-style: none; + margin: 0.25em 0; +} +.tox-checklist > li:not(.tox-checklist--hidden)::before { + content: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-unchecked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2215%22%20height%3D%2215%22%20x%3D%22.5%22%20y%3D%22.5%22%20fill-rule%3D%22nonzero%22%20stroke%3D%22%234C4C4C%22%20rx%3D%222%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A"); + cursor: pointer; + height: 1em; + margin-left: -1.5em; + margin-top: 0.125em; + position: absolute; + width: 1em; +} +.tox-checklist li:not(.tox-checklist--hidden).tox-checklist--checked::before { + content: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-checked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2216%22%20height%3D%2216%22%20fill%3D%22%234099FF%22%20fill-rule%3D%22nonzero%22%20rx%3D%222%22%2F%3E%3Cpath%20id%3D%22Path%22%20fill%3D%22%23FFF%22%20fill-rule%3D%22nonzero%22%20d%3D%22M11.5703186%2C3.14417309%20C11.8516238%2C2.73724603%2012.4164781%2C2.62829933%2012.83558%2C2.89774797%20C13.260121%2C3.17069355%2013.3759736%2C3.72932262%2013.0909105%2C4.14168582%20L7.7580587%2C11.8560195%20C7.43776896%2C12.3193404%206.76483983%2C12.3852142%206.35607322%2C11.9948725%20L3.02491697%2C8.8138662%20C2.66090143%2C8.46625845%202.65798871%2C7.89594698%203.01850234%2C7.54483354%20C3.373942%2C7.19866177%203.94940006%2C7.19592841%204.30829608%2C7.5386474%20L6.85276923%2C9.9684299%20L11.5703186%2C3.14417309%20Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A"); +} +[dir=rtl] .tox-checklist > li:not(.tox-checklist--hidden)::before { + margin-left: 0; + margin-right: -1.5em; +} +/* stylelint-disable */ +/* http://prismjs.com/ */ +/** + * prism.js default theme for JavaScript, CSS and HTML + * Based on dabblet (http://dabblet.com) + * @author Lea Verou + */ +code[class*="language-"], +pre[class*="language-"] { + color: black; + background: none; + text-shadow: 0 1px white; + font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; + font-size: 1em; + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + word-wrap: normal; + line-height: 1.5; + -moz-tab-size: 4; + tab-size: 4; + -webkit-hyphens: none; + -ms-hyphens: none; + hyphens: none; +} +pre[class*="language-"]::-moz-selection, +pre[class*="language-"] ::-moz-selection, +code[class*="language-"]::-moz-selection, +code[class*="language-"] ::-moz-selection { + text-shadow: none; + background: #b3d4fc; +} +pre[class*="language-"]::selection, +pre[class*="language-"] ::selection, +code[class*="language-"]::selection, +code[class*="language-"] ::selection { + text-shadow: none; + background: #b3d4fc; +} +@media print { + code[class*="language-"], + pre[class*="language-"] { + text-shadow: none; + } +} +/* Code blocks */ +pre[class*="language-"] { + padding: 1em; + margin: 0.5em 0; + overflow: auto; +} +:not(pre) > code[class*="language-"], +pre[class*="language-"] { + background: #f5f2f0; +} +/* Inline code */ +:not(pre) > code[class*="language-"] { + padding: 0.1em; + border-radius: 0.3em; + white-space: normal; +} +.token.comment, +.token.prolog, +.token.doctype, +.token.cdata { + color: slategray; +} +.token.punctuation { + color: #999; +} +.namespace { + opacity: 0.7; +} +.token.property, +.token.tag, +.token.boolean, +.token.number, +.token.constant, +.token.symbol, +.token.deleted { + color: #905; +} +.token.selector, +.token.attr-name, +.token.string, +.token.char, +.token.builtin, +.token.inserted { + color: #690; +} +.token.operator, +.token.entity, +.token.url, +.language-css .token.string, +.style .token.string { + color: #9a6e3a; + background: hsla(0, 0%, 100%, 0.5); +} +.token.atrule, +.token.attr-value, +.token.keyword { + color: #07a; +} +.token.function, +.token.class-name { + color: #DD4A68; +} +.token.regex, +.token.important, +.token.variable { + color: #e90; +} +.token.important, +.token.bold { + font-weight: bold; +} +.token.italic { + font-style: italic; +} +.token.entity { + cursor: help; +} +/* stylelint-enable */ +.mce-content-body { + overflow-wrap: break-word; + word-wrap: break-word; +} +.mce-content-body .mce-visual-caret { + background-color: black; + background-color: currentColor; + position: absolute; +} +.mce-content-body .mce-visual-caret-hidden { + display: none; +} +.mce-content-body *[data-mce-caret] { + left: -1000px; + margin: 0; + padding: 0; + position: absolute; + right: auto; + top: 0; +} +.mce-content-body .mce-offscreen-selection { + left: -2000000px; + max-width: 1000000px; + position: absolute; +} +.mce-content-body *[contentEditable=false] { + cursor: default; +} +.mce-content-body *[contentEditable=true] { + cursor: text; +} +.tox-cursor-format-painter { + cursor: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%3E%0A%20%20%3Cg%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%0A%20%20%20%20%3Cpath%20fill%3D%22%23000%22%20fill-rule%3D%22nonzero%22%20d%3D%22M15%2C6%20C15%2C5.45%2014.55%2C5%2014%2C5%20L6%2C5%20C5.45%2C5%205%2C5.45%205%2C6%20L5%2C10%20C5%2C10.55%205.45%2C11%206%2C11%20L14%2C11%20C14.55%2C11%2015%2C10.55%2015%2C10%20L15%2C9%20L16%2C9%20L16%2C12%20L9%2C12%20L9%2C19%20C9%2C19.55%209.45%2C20%2010%2C20%20L11%2C20%20C11.55%2C20%2012%2C19.55%2012%2C19%20L12%2C14%20L18%2C14%20L18%2C7%20L15%2C7%20L15%2C6%20Z%22%2F%3E%0A%20%20%20%20%3Cpath%20fill%3D%22%23000%22%20fill-rule%3D%22nonzero%22%20d%3D%22M1%2C1%20L8.25%2C1%20C8.66421356%2C1%209%2C1.33578644%209%2C1.75%20L9%2C1.75%20C9%2C2.16421356%208.66421356%2C2.5%208.25%2C2.5%20L2.5%2C2.5%20L2.5%2C8.25%20C2.5%2C8.66421356%202.16421356%2C9%201.75%2C9%20L1.75%2C9%20C1.33578644%2C9%201%2C8.66421356%201%2C8.25%20L1%2C1%20Z%22%2F%3E%0A%20%20%3C%2Fg%3E%0A%3C%2Fsvg%3E%0A"), default; +} +.mce-content-body figure.align-left { + float: left; +} +.mce-content-body figure.align-right { + float: right; +} +.mce-content-body figure.image.align-center { + display: table; + margin-left: auto; + margin-right: auto; +} +.mce-preview-object { + border: 1px solid gray; + display: inline-block; + line-height: 0; + margin: 0 2px 0 2px; + position: relative; +} +.mce-preview-object .mce-shim { + background: url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7); + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; +} +.mce-preview-object[data-mce-selected="2"] .mce-shim { + display: none; +} +.mce-object { + background: transparent url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M4%203h16a1%201%200%200%201%201%201v16a1%201%200%200%201-1%201H4a1%201%200%200%201-1-1V4a1%201%200%200%201%201-1zm1%202v14h14V5H5zm4.79%202.565l5.64%204.028a.5.5%200%200%201%200%20.814l-5.64%204.028a.5.5%200%200%201-.79-.407V7.972a.5.5%200%200%201%20.79-.407z%22%2F%3E%3C%2Fsvg%3E%0A") no-repeat center; + border: 1px dashed #aaa; +} +.mce-pagebreak { + border: 1px dashed #aaa; + cursor: default; + display: block; + height: 5px; + margin-top: 15px; + page-break-before: always; + width: 100%; +} +@media print { + .mce-pagebreak { + border: 0; + } +} +.tiny-pageembed .mce-shim { + background: url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7); + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; +} +.tiny-pageembed[data-mce-selected="2"] .mce-shim { + display: none; +} +.tiny-pageembed { + display: inline-block; + position: relative; +} +.tiny-pageembed--21by9, +.tiny-pageembed--16by9, +.tiny-pageembed--4by3, +.tiny-pageembed--1by1 { + display: block; + overflow: hidden; + padding: 0; + position: relative; + width: 100%; +} +.tiny-pageembed--21by9 { + padding-top: 42.857143%; +} +.tiny-pageembed--16by9 { + padding-top: 56.25%; +} +.tiny-pageembed--4by3 { + padding-top: 75%; +} +.tiny-pageembed--1by1 { + padding-top: 100%; +} +.tiny-pageembed--21by9 iframe, +.tiny-pageembed--16by9 iframe, +.tiny-pageembed--4by3 iframe, +.tiny-pageembed--1by1 iframe { + border: 0; + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; +} +.mce-content-body[data-mce-placeholder] { + position: relative; +} +.mce-content-body[data-mce-placeholder]:not(.mce-visualblocks)::before { + color: rgba(34, 47, 62, 0.7); + content: attr(data-mce-placeholder); + position: absolute; +} +.mce-content-body:not([dir=rtl])[data-mce-placeholder]:not(.mce-visualblocks)::before { + left: 1px; +} +.mce-content-body[dir=rtl][data-mce-placeholder]:not(.mce-visualblocks)::before { + right: 1px; +} +.mce-content-body div.mce-resizehandle { + background-color: #4099ff; + border-color: #4099ff; + border-style: solid; + border-width: 1px; + box-sizing: border-box; + height: 10px; + position: absolute; + width: 10px; + z-index: 1298; +} +.mce-content-body div.mce-resizehandle:hover { + background-color: #4099ff; +} +.mce-content-body div.mce-resizehandle:nth-of-type(1) { + cursor: nwse-resize; +} +.mce-content-body div.mce-resizehandle:nth-of-type(2) { + cursor: nesw-resize; +} +.mce-content-body div.mce-resizehandle:nth-of-type(3) { + cursor: nwse-resize; +} +.mce-content-body div.mce-resizehandle:nth-of-type(4) { + cursor: nesw-resize; +} +.mce-content-body .mce-resize-backdrop { + z-index: 10000; +} +.mce-content-body .mce-clonedresizable { + cursor: default; + opacity: 0.5; + outline: 1px dashed black; + position: absolute; + z-index: 10001; +} +.mce-content-body .mce-clonedresizable.mce-resizetable-columns th, +.mce-content-body .mce-clonedresizable.mce-resizetable-columns td { + border: 0; +} +.mce-content-body .mce-resize-helper { + background: #555; + background: rgba(0, 0, 0, 0.75); + border: 1px; + border-radius: 3px; + color: white; + display: none; + font-family: sans-serif; + font-size: 12px; + line-height: 14px; + margin: 5px 10px; + padding: 5px; + position: absolute; + white-space: nowrap; + z-index: 10002; +} +.tox-rtc-user-selection { + position: relative; +} +.tox-rtc-user-cursor { + bottom: 0; + cursor: default; + position: absolute; + top: 0; + width: 2px; +} +.tox-rtc-user-cursor::before { + background-color: inherit; + border-radius: 50%; + content: ''; + display: block; + height: 8px; + position: absolute; + right: -3px; + top: -3px; + width: 8px; +} +.tox-rtc-user-cursor:hover::after { + background-color: inherit; + border-radius: 100px; + box-sizing: border-box; + color: #fff; + content: attr(data-user); + display: block; + font-size: 12px; + font-weight: bold; + left: -5px; + min-height: 8px; + min-width: 8px; + padding: 0 12px; + position: absolute; + top: -11px; + white-space: nowrap; + z-index: 1000; +} +.tox-rtc-user-selection--1 .tox-rtc-user-cursor { + background-color: #2dc26b; +} +.tox-rtc-user-selection--2 .tox-rtc-user-cursor { + background-color: #e03e2d; +} +.tox-rtc-user-selection--3 .tox-rtc-user-cursor { + background-color: #f1c40f; +} +.tox-rtc-user-selection--4 .tox-rtc-user-cursor { + background-color: #3598db; +} +.tox-rtc-user-selection--5 .tox-rtc-user-cursor { + background-color: #b96ad9; +} +.tox-rtc-user-selection--6 .tox-rtc-user-cursor { + background-color: #e67e23; +} +.tox-rtc-user-selection--7 .tox-rtc-user-cursor { + background-color: #aaa69d; +} +.tox-rtc-user-selection--8 .tox-rtc-user-cursor { + background-color: #f368e0; +} +.tox-rtc-remote-image { + background: #eaeaea url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2236%22%20height%3D%2212%22%20viewBox%3D%220%200%2036%2012%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Ccircle%20cx%3D%226%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%20%20%3Ccircle%20cx%3D%2218%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20begin%3D%22.33s%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%20%20%3Ccircle%20cx%3D%2230%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20begin%3D%22.66s%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%3C%2Fsvg%3E%0A") no-repeat center center; + border: 1px solid #ccc; + min-height: 240px; + min-width: 320px; +} +.mce-match-marker { + background: #aaa; + color: #fff; +} +.mce-match-marker-selected { + background: #39f; + color: #fff; +} +.mce-match-marker-selected::-moz-selection { + background: #39f; + color: #fff; +} +.mce-match-marker-selected::selection { + background: #39f; + color: #fff; +} +.mce-content-body img[data-mce-selected], +.mce-content-body video[data-mce-selected], +.mce-content-body audio[data-mce-selected], +.mce-content-body object[data-mce-selected], +.mce-content-body embed[data-mce-selected], +.mce-content-body table[data-mce-selected] { + outline: 3px solid #b4d7ff; +} +.mce-content-body hr[data-mce-selected] { + outline: 3px solid #b4d7ff; + outline-offset: 1px; +} +.mce-content-body *[contentEditable=false] *[contentEditable=true]:focus { + outline: 3px solid #b4d7ff; +} +.mce-content-body *[contentEditable=false] *[contentEditable=true]:hover { + outline: 3px solid #b4d7ff; +} +.mce-content-body *[contentEditable=false][data-mce-selected] { + cursor: not-allowed; + outline: 3px solid #b4d7ff; +} +.mce-content-body.mce-content-readonly *[contentEditable=true]:focus, +.mce-content-body.mce-content-readonly *[contentEditable=true]:hover { + outline: none; +} +.mce-content-body *[data-mce-selected="inline-boundary"] { + background-color: #b4d7ff; +} +.mce-content-body .mce-edit-focus { + outline: 3px solid #b4d7ff; +} +.mce-content-body td[data-mce-selected], +.mce-content-body th[data-mce-selected] { + position: relative; +} +.mce-content-body td[data-mce-selected]::-moz-selection, +.mce-content-body th[data-mce-selected]::-moz-selection { + background: none; +} +.mce-content-body td[data-mce-selected]::selection, +.mce-content-body th[data-mce-selected]::selection { + background: none; +} +.mce-content-body td[data-mce-selected] *, +.mce-content-body th[data-mce-selected] * { + outline: none; + -webkit-touch-callout: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} +.mce-content-body td[data-mce-selected]::after, +.mce-content-body th[data-mce-selected]::after { + background-color: rgba(180, 215, 255, 0.7); + border: 1px solid rgba(180, 215, 255, 0.7); + bottom: -1px; + content: ''; + left: -1px; + mix-blend-mode: multiply; + position: absolute; + right: -1px; + top: -1px; +} +@media screen and (-ms-high-contrast: active), (-ms-high-contrast: none) { + .mce-content-body td[data-mce-selected]::after, + .mce-content-body th[data-mce-selected]::after { + border-color: rgba(0, 84, 180, 0.7); + } +} +.mce-content-body img::-moz-selection { + background: none; +} +.mce-content-body img::selection { + background: none; +} +.ephox-snooker-resizer-bar { + background-color: #b4d7ff; + opacity: 0; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} +.ephox-snooker-resizer-cols { + cursor: col-resize; +} +.ephox-snooker-resizer-rows { + cursor: row-resize; +} +.ephox-snooker-resizer-bar.ephox-snooker-resizer-bar-dragging { + opacity: 1; +} +.mce-spellchecker-word { + background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'4'%20height%3D'4'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20stroke%3D'%23ff0000'%20fill%3D'none'%20stroke-linecap%3D'round'%20stroke-opacity%3D'.75'%20d%3D'M0%203L2%201%204%203'%2F%3E%3C%2Fsvg%3E%0A"); + background-position: 0 calc(100% + 1px); + background-repeat: repeat-x; + background-size: auto 6px; + cursor: default; + height: 2rem; +} +.mce-spellchecker-grammar { + background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'4'%20height%3D'4'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20stroke%3D'%2300A835'%20fill%3D'none'%20stroke-linecap%3D'round'%20d%3D'M0%203L2%201%204%203'%2F%3E%3C%2Fsvg%3E%0A"); + background-position: 0 calc(100% + 1px); + background-repeat: repeat-x; + background-size: auto 6px; + cursor: default; +} +.mce-toc { + border: 1px solid gray; +} +.mce-toc h2 { + margin: 4px; +} +.mce-toc li { + list-style-type: none; +} +table[style*="border-width: 0px"], +.mce-item-table:not([border]), +.mce-item-table[border="0"], +table[style*="border-width: 0px"] td, +.mce-item-table:not([border]) td, +.mce-item-table[border="0"] td, +table[style*="border-width: 0px"] th, +.mce-item-table:not([border]) th, +.mce-item-table[border="0"] th, +table[style*="border-width: 0px"] caption, +.mce-item-table:not([border]) caption, +.mce-item-table[border="0"] caption { + border: 1px dashed #bbb; +} +.mce-visualblocks p, +.mce-visualblocks h1, +.mce-visualblocks h2, +.mce-visualblocks h3, +.mce-visualblocks h4, +.mce-visualblocks h5, +.mce-visualblocks h6, +.mce-visualblocks div:not([data-mce-bogus]), +.mce-visualblocks section, +.mce-visualblocks article, +.mce-visualblocks blockquote, +.mce-visualblocks address, +.mce-visualblocks pre, +.mce-visualblocks figure, +.mce-visualblocks figcaption, +.mce-visualblocks hgroup, +.mce-visualblocks aside, +.mce-visualblocks ul, +.mce-visualblocks ol, +.mce-visualblocks dl { + background-repeat: no-repeat; + border: 1px dashed #bbb; + margin-left: 3px; + padding-top: 10px; +} +.mce-visualblocks p { + background-image: url(data:image/gif;base64,R0lGODlhCQAJAJEAAAAAAP///7u7u////yH5BAEAAAMALAAAAAAJAAkAAAIQnG+CqCN/mlyvsRUpThG6AgA7); +} +.mce-visualblocks h1 { + background-image: url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybGu1JuxHoAfRNRW3TWXyF2YiRUAOw==); +} +.mce-visualblocks h2 { + background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8Hybbx4oOuqgTynJd6bGlWg3DkJzoaUAAAOw==); +} +.mce-visualblocks h3 { + background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIZjI8Hybbx4oOuqgTynJf2Ln2NOHpQpmhAAQA7); +} +.mce-visualblocks h4 { + background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxInR0zqeAdhtJlXwV1oCll2HaWgAAOw==); +} +.mce-visualblocks h5 { + background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxIoiuwjane4iq5GlW05GgIkIZUAAAOw==); +} +.mce-visualblocks h6 { + background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxIoiuwjan04jep1iZ1XRlAo5bVgAAOw==); +} +.mce-visualblocks div:not([data-mce-bogus]) { + background-image: url(data:image/gif;base64,R0lGODlhEgAKAIABALu7u////yH5BAEAAAEALAAAAAASAAoAAAIfjI9poI0cgDywrhuxfbrzDEbQM2Ei5aRjmoySW4pAAQA7); +} +.mce-visualblocks section { + background-image: url(data:image/gif;base64,R0lGODlhKAAKAIABALu7u////yH5BAEAAAEALAAAAAAoAAoAAAI5jI+pywcNY3sBWHdNrplytD2ellDeSVbp+GmWqaDqDMepc8t17Y4vBsK5hDyJMcI6KkuYU+jpjLoKADs=); +} +.mce-visualblocks article { + background-image: url(data:image/gif;base64,R0lGODlhKgAKAIABALu7u////yH5BAEAAAEALAAAAAAqAAoAAAI6jI+pywkNY3wG0GBvrsd2tXGYSGnfiF7ikpXemTpOiJScasYoDJJrjsG9gkCJ0ag6KhmaIe3pjDYBBQA7); +} +.mce-visualblocks blockquote { + background-image: url(data:image/gif;base64,R0lGODlhPgAKAIABALu7u////yH5BAEAAAEALAAAAAA+AAoAAAJPjI+py+0Knpz0xQDyuUhvfoGgIX5iSKZYgq5uNL5q69asZ8s5rrf0yZmpNkJZzFesBTu8TOlDVAabUyatguVhWduud3EyiUk45xhTTgMBBQA7); +} +.mce-visualblocks address { + background-image: url(data:image/gif;base64,R0lGODlhLQAKAIABALu7u////yH5BAEAAAEALAAAAAAtAAoAAAI/jI+pywwNozSP1gDyyZcjb3UaRpXkWaXmZW4OqKLhBmLs+K263DkJK7OJeifh7FicKD9A1/IpGdKkyFpNmCkAADs=); +} +.mce-visualblocks pre { + background-image: url(data:image/gif;base64,R0lGODlhFQAKAIABALu7uwAAACH5BAEAAAEALAAAAAAVAAoAAAIjjI+ZoN0cgDwSmnpz1NCueYERhnibZVKLNnbOq8IvKpJtVQAAOw==); +} +.mce-visualblocks figure { + background-image: url(data:image/gif;base64,R0lGODlhJAAKAIAAALu7u////yH5BAEAAAEALAAAAAAkAAoAAAI0jI+py+2fwAHUSFvD3RlvG4HIp4nX5JFSpnZUJ6LlrM52OE7uSWosBHScgkSZj7dDKnWAAgA7); +} +.mce-visualblocks figcaption { + border: 1px dashed #bbb; +} +.mce-visualblocks hgroup { + background-image: url(data:image/gif;base64,R0lGODlhJwAKAIABALu7uwAAACH5BAEAAAEALAAAAAAnAAoAAAI3jI+pywYNI3uB0gpsRtt5fFnfNZaVSYJil4Wo03Hv6Z62uOCgiXH1kZIIJ8NiIxRrAZNMZAtQAAA7); +} +.mce-visualblocks aside { + background-image: url(data:image/gif;base64,R0lGODlhHgAKAIABAKqqqv///yH5BAEAAAEALAAAAAAeAAoAAAItjI+pG8APjZOTzgtqy7I3f1yehmQcFY4WKZbqByutmW4aHUd6vfcVbgudgpYCADs=); +} +.mce-visualblocks ul { + background-image: url(data:image/gif;base64,R0lGODlhDQAKAIAAALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybGuYnqUVSjvw26DzzXiqIDlVwAAOw==); +} +.mce-visualblocks ol { + background-image: url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybH6HHt0qourxC6CvzXieHyeWQAAOw==); +} +.mce-visualblocks dl { + background-image: url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybEOnmOvUoWznTqeuEjNSCqeGRUAOw==); +} +.mce-visualblocks:not([dir=rtl]) p, +.mce-visualblocks:not([dir=rtl]) h1, +.mce-visualblocks:not([dir=rtl]) h2, +.mce-visualblocks:not([dir=rtl]) h3, +.mce-visualblocks:not([dir=rtl]) h4, +.mce-visualblocks:not([dir=rtl]) h5, +.mce-visualblocks:not([dir=rtl]) h6, +.mce-visualblocks:not([dir=rtl]) div:not([data-mce-bogus]), +.mce-visualblocks:not([dir=rtl]) section, +.mce-visualblocks:not([dir=rtl]) article, +.mce-visualblocks:not([dir=rtl]) blockquote, +.mce-visualblocks:not([dir=rtl]) address, +.mce-visualblocks:not([dir=rtl]) pre, +.mce-visualblocks:not([dir=rtl]) figure, +.mce-visualblocks:not([dir=rtl]) figcaption, +.mce-visualblocks:not([dir=rtl]) hgroup, +.mce-visualblocks:not([dir=rtl]) aside, +.mce-visualblocks:not([dir=rtl]) ul, +.mce-visualblocks:not([dir=rtl]) ol, +.mce-visualblocks:not([dir=rtl]) dl { + margin-left: 3px; +} +.mce-visualblocks[dir=rtl] p, +.mce-visualblocks[dir=rtl] h1, +.mce-visualblocks[dir=rtl] h2, +.mce-visualblocks[dir=rtl] h3, +.mce-visualblocks[dir=rtl] h4, +.mce-visualblocks[dir=rtl] h5, +.mce-visualblocks[dir=rtl] h6, +.mce-visualblocks[dir=rtl] div:not([data-mce-bogus]), +.mce-visualblocks[dir=rtl] section, +.mce-visualblocks[dir=rtl] article, +.mce-visualblocks[dir=rtl] blockquote, +.mce-visualblocks[dir=rtl] address, +.mce-visualblocks[dir=rtl] pre, +.mce-visualblocks[dir=rtl] figure, +.mce-visualblocks[dir=rtl] figcaption, +.mce-visualblocks[dir=rtl] hgroup, +.mce-visualblocks[dir=rtl] aside, +.mce-visualblocks[dir=rtl] ul, +.mce-visualblocks[dir=rtl] ol, +.mce-visualblocks[dir=rtl] dl { + background-position-x: right; + margin-right: 3px; +} +.mce-nbsp, +.mce-shy { + background: #aaa; +} +.mce-shy::after { + content: '-'; +} +body { + font-family: sans-serif; +} +table { + border-collapse: collapse; +} diff --git a/ui/public/tinymce/skins/ui/oxide/content.inline.css b/ui/public/tinymce/skins/ui/oxide/content.inline.css new file mode 100644 index 0000000..8e7521d --- /dev/null +++ b/ui/public/tinymce/skins/ui/oxide/content.inline.css @@ -0,0 +1,726 @@ +/** + * Copyright (c) Tiny Technologies, Inc. All rights reserved. + * Licensed under the LGPL or a commercial license. + * For LGPL see License.txt in the project root for license information. + * For commercial licenses see https://www.tiny.cloud/ + */ +.mce-content-body .mce-item-anchor { + background: transparent url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'8'%20height%3D'12'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20d%3D'M0%200L8%200%208%2012%204.09117821%209%200%2012z'%2F%3E%3C%2Fsvg%3E%0A") no-repeat center; + cursor: default; + display: inline-block; + height: 12px !important; + padding: 0 2px; + -webkit-user-modify: read-only; + -moz-user-modify: read-only; + -webkit-user-select: all; + -moz-user-select: all; + -ms-user-select: all; + user-select: all; + width: 8px !important; +} +.mce-content-body .mce-item-anchor[data-mce-selected] { + outline-offset: 1px; +} +.tox-comments-visible .tox-comment { + background-color: #fff0b7; +} +.tox-comments-visible .tox-comment--active { + background-color: #ffe168; +} +.tox-checklist > li:not(.tox-checklist--hidden) { + list-style: none; + margin: 0.25em 0; +} +.tox-checklist > li:not(.tox-checklist--hidden)::before { + content: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-unchecked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2215%22%20height%3D%2215%22%20x%3D%22.5%22%20y%3D%22.5%22%20fill-rule%3D%22nonzero%22%20stroke%3D%22%234C4C4C%22%20rx%3D%222%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A"); + cursor: pointer; + height: 1em; + margin-left: -1.5em; + margin-top: 0.125em; + position: absolute; + width: 1em; +} +.tox-checklist li:not(.tox-checklist--hidden).tox-checklist--checked::before { + content: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-checked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2216%22%20height%3D%2216%22%20fill%3D%22%234099FF%22%20fill-rule%3D%22nonzero%22%20rx%3D%222%22%2F%3E%3Cpath%20id%3D%22Path%22%20fill%3D%22%23FFF%22%20fill-rule%3D%22nonzero%22%20d%3D%22M11.5703186%2C3.14417309%20C11.8516238%2C2.73724603%2012.4164781%2C2.62829933%2012.83558%2C2.89774797%20C13.260121%2C3.17069355%2013.3759736%2C3.72932262%2013.0909105%2C4.14168582%20L7.7580587%2C11.8560195%20C7.43776896%2C12.3193404%206.76483983%2C12.3852142%206.35607322%2C11.9948725%20L3.02491697%2C8.8138662%20C2.66090143%2C8.46625845%202.65798871%2C7.89594698%203.01850234%2C7.54483354%20C3.373942%2C7.19866177%203.94940006%2C7.19592841%204.30829608%2C7.5386474%20L6.85276923%2C9.9684299%20L11.5703186%2C3.14417309%20Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A"); +} +[dir=rtl] .tox-checklist > li:not(.tox-checklist--hidden)::before { + margin-left: 0; + margin-right: -1.5em; +} +/* stylelint-disable */ +/* http://prismjs.com/ */ +/** + * prism.js default theme for JavaScript, CSS and HTML + * Based on dabblet (http://dabblet.com) + * @author Lea Verou + */ +code[class*="language-"], +pre[class*="language-"] { + color: black; + background: none; + text-shadow: 0 1px white; + font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; + font-size: 1em; + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + word-wrap: normal; + line-height: 1.5; + -moz-tab-size: 4; + tab-size: 4; + -webkit-hyphens: none; + -ms-hyphens: none; + hyphens: none; +} +pre[class*="language-"]::-moz-selection, +pre[class*="language-"] ::-moz-selection, +code[class*="language-"]::-moz-selection, +code[class*="language-"] ::-moz-selection { + text-shadow: none; + background: #b3d4fc; +} +pre[class*="language-"]::selection, +pre[class*="language-"] ::selection, +code[class*="language-"]::selection, +code[class*="language-"] ::selection { + text-shadow: none; + background: #b3d4fc; +} +@media print { + code[class*="language-"], + pre[class*="language-"] { + text-shadow: none; + } +} +/* Code blocks */ +pre[class*="language-"] { + padding: 1em; + margin: 0.5em 0; + overflow: auto; +} +:not(pre) > code[class*="language-"], +pre[class*="language-"] { + background: #f5f2f0; +} +/* Inline code */ +:not(pre) > code[class*="language-"] { + padding: 0.1em; + border-radius: 0.3em; + white-space: normal; +} +.token.comment, +.token.prolog, +.token.doctype, +.token.cdata { + color: slategray; +} +.token.punctuation { + color: #999; +} +.namespace { + opacity: 0.7; +} +.token.property, +.token.tag, +.token.boolean, +.token.number, +.token.constant, +.token.symbol, +.token.deleted { + color: #905; +} +.token.selector, +.token.attr-name, +.token.string, +.token.char, +.token.builtin, +.token.inserted { + color: #690; +} +.token.operator, +.token.entity, +.token.url, +.language-css .token.string, +.style .token.string { + color: #9a6e3a; + background: hsla(0, 0%, 100%, 0.5); +} +.token.atrule, +.token.attr-value, +.token.keyword { + color: #07a; +} +.token.function, +.token.class-name { + color: #DD4A68; +} +.token.regex, +.token.important, +.token.variable { + color: #e90; +} +.token.important, +.token.bold { + font-weight: bold; +} +.token.italic { + font-style: italic; +} +.token.entity { + cursor: help; +} +/* stylelint-enable */ +.mce-content-body { + overflow-wrap: break-word; + word-wrap: break-word; +} +.mce-content-body .mce-visual-caret { + background-color: black; + background-color: currentColor; + position: absolute; +} +.mce-content-body .mce-visual-caret-hidden { + display: none; +} +.mce-content-body *[data-mce-caret] { + left: -1000px; + margin: 0; + padding: 0; + position: absolute; + right: auto; + top: 0; +} +.mce-content-body .mce-offscreen-selection { + left: -2000000px; + max-width: 1000000px; + position: absolute; +} +.mce-content-body *[contentEditable=false] { + cursor: default; +} +.mce-content-body *[contentEditable=true] { + cursor: text; +} +.tox-cursor-format-painter { + cursor: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%3E%0A%20%20%3Cg%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%0A%20%20%20%20%3Cpath%20fill%3D%22%23000%22%20fill-rule%3D%22nonzero%22%20d%3D%22M15%2C6%20C15%2C5.45%2014.55%2C5%2014%2C5%20L6%2C5%20C5.45%2C5%205%2C5.45%205%2C6%20L5%2C10%20C5%2C10.55%205.45%2C11%206%2C11%20L14%2C11%20C14.55%2C11%2015%2C10.55%2015%2C10%20L15%2C9%20L16%2C9%20L16%2C12%20L9%2C12%20L9%2C19%20C9%2C19.55%209.45%2C20%2010%2C20%20L11%2C20%20C11.55%2C20%2012%2C19.55%2012%2C19%20L12%2C14%20L18%2C14%20L18%2C7%20L15%2C7%20L15%2C6%20Z%22%2F%3E%0A%20%20%20%20%3Cpath%20fill%3D%22%23000%22%20fill-rule%3D%22nonzero%22%20d%3D%22M1%2C1%20L8.25%2C1%20C8.66421356%2C1%209%2C1.33578644%209%2C1.75%20L9%2C1.75%20C9%2C2.16421356%208.66421356%2C2.5%208.25%2C2.5%20L2.5%2C2.5%20L2.5%2C8.25%20C2.5%2C8.66421356%202.16421356%2C9%201.75%2C9%20L1.75%2C9%20C1.33578644%2C9%201%2C8.66421356%201%2C8.25%20L1%2C1%20Z%22%2F%3E%0A%20%20%3C%2Fg%3E%0A%3C%2Fsvg%3E%0A"), default; +} +.mce-content-body figure.align-left { + float: left; +} +.mce-content-body figure.align-right { + float: right; +} +.mce-content-body figure.image.align-center { + display: table; + margin-left: auto; + margin-right: auto; +} +.mce-preview-object { + border: 1px solid gray; + display: inline-block; + line-height: 0; + margin: 0 2px 0 2px; + position: relative; +} +.mce-preview-object .mce-shim { + background: url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7); + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; +} +.mce-preview-object[data-mce-selected="2"] .mce-shim { + display: none; +} +.mce-object { + background: transparent url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M4%203h16a1%201%200%200%201%201%201v16a1%201%200%200%201-1%201H4a1%201%200%200%201-1-1V4a1%201%200%200%201%201-1zm1%202v14h14V5H5zm4.79%202.565l5.64%204.028a.5.5%200%200%201%200%20.814l-5.64%204.028a.5.5%200%200%201-.79-.407V7.972a.5.5%200%200%201%20.79-.407z%22%2F%3E%3C%2Fsvg%3E%0A") no-repeat center; + border: 1px dashed #aaa; +} +.mce-pagebreak { + border: 1px dashed #aaa; + cursor: default; + display: block; + height: 5px; + margin-top: 15px; + page-break-before: always; + width: 100%; +} +@media print { + .mce-pagebreak { + border: 0; + } +} +.tiny-pageembed .mce-shim { + background: url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7); + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; +} +.tiny-pageembed[data-mce-selected="2"] .mce-shim { + display: none; +} +.tiny-pageembed { + display: inline-block; + position: relative; +} +.tiny-pageembed--21by9, +.tiny-pageembed--16by9, +.tiny-pageembed--4by3, +.tiny-pageembed--1by1 { + display: block; + overflow: hidden; + padding: 0; + position: relative; + width: 100%; +} +.tiny-pageembed--21by9 { + padding-top: 42.857143%; +} +.tiny-pageembed--16by9 { + padding-top: 56.25%; +} +.tiny-pageembed--4by3 { + padding-top: 75%; +} +.tiny-pageembed--1by1 { + padding-top: 100%; +} +.tiny-pageembed--21by9 iframe, +.tiny-pageembed--16by9 iframe, +.tiny-pageembed--4by3 iframe, +.tiny-pageembed--1by1 iframe { + border: 0; + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; +} +.mce-content-body[data-mce-placeholder] { + position: relative; +} +.mce-content-body[data-mce-placeholder]:not(.mce-visualblocks)::before { + color: rgba(34, 47, 62, 0.7); + content: attr(data-mce-placeholder); + position: absolute; +} +.mce-content-body:not([dir=rtl])[data-mce-placeholder]:not(.mce-visualblocks)::before { + left: 1px; +} +.mce-content-body[dir=rtl][data-mce-placeholder]:not(.mce-visualblocks)::before { + right: 1px; +} +.mce-content-body div.mce-resizehandle { + background-color: #4099ff; + border-color: #4099ff; + border-style: solid; + border-width: 1px; + box-sizing: border-box; + height: 10px; + position: absolute; + width: 10px; + z-index: 1298; +} +.mce-content-body div.mce-resizehandle:hover { + background-color: #4099ff; +} +.mce-content-body div.mce-resizehandle:nth-of-type(1) { + cursor: nwse-resize; +} +.mce-content-body div.mce-resizehandle:nth-of-type(2) { + cursor: nesw-resize; +} +.mce-content-body div.mce-resizehandle:nth-of-type(3) { + cursor: nwse-resize; +} +.mce-content-body div.mce-resizehandle:nth-of-type(4) { + cursor: nesw-resize; +} +.mce-content-body .mce-resize-backdrop { + z-index: 10000; +} +.mce-content-body .mce-clonedresizable { + cursor: default; + opacity: 0.5; + outline: 1px dashed black; + position: absolute; + z-index: 10001; +} +.mce-content-body .mce-clonedresizable.mce-resizetable-columns th, +.mce-content-body .mce-clonedresizable.mce-resizetable-columns td { + border: 0; +} +.mce-content-body .mce-resize-helper { + background: #555; + background: rgba(0, 0, 0, 0.75); + border: 1px; + border-radius: 3px; + color: white; + display: none; + font-family: sans-serif; + font-size: 12px; + line-height: 14px; + margin: 5px 10px; + padding: 5px; + position: absolute; + white-space: nowrap; + z-index: 10002; +} +.tox-rtc-user-selection { + position: relative; +} +.tox-rtc-user-cursor { + bottom: 0; + cursor: default; + position: absolute; + top: 0; + width: 2px; +} +.tox-rtc-user-cursor::before { + background-color: inherit; + border-radius: 50%; + content: ''; + display: block; + height: 8px; + position: absolute; + right: -3px; + top: -3px; + width: 8px; +} +.tox-rtc-user-cursor:hover::after { + background-color: inherit; + border-radius: 100px; + box-sizing: border-box; + color: #fff; + content: attr(data-user); + display: block; + font-size: 12px; + font-weight: bold; + left: -5px; + min-height: 8px; + min-width: 8px; + padding: 0 12px; + position: absolute; + top: -11px; + white-space: nowrap; + z-index: 1000; +} +.tox-rtc-user-selection--1 .tox-rtc-user-cursor { + background-color: #2dc26b; +} +.tox-rtc-user-selection--2 .tox-rtc-user-cursor { + background-color: #e03e2d; +} +.tox-rtc-user-selection--3 .tox-rtc-user-cursor { + background-color: #f1c40f; +} +.tox-rtc-user-selection--4 .tox-rtc-user-cursor { + background-color: #3598db; +} +.tox-rtc-user-selection--5 .tox-rtc-user-cursor { + background-color: #b96ad9; +} +.tox-rtc-user-selection--6 .tox-rtc-user-cursor { + background-color: #e67e23; +} +.tox-rtc-user-selection--7 .tox-rtc-user-cursor { + background-color: #aaa69d; +} +.tox-rtc-user-selection--8 .tox-rtc-user-cursor { + background-color: #f368e0; +} +.tox-rtc-remote-image { + background: #eaeaea url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2236%22%20height%3D%2212%22%20viewBox%3D%220%200%2036%2012%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Ccircle%20cx%3D%226%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%20%20%3Ccircle%20cx%3D%2218%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20begin%3D%22.33s%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%20%20%3Ccircle%20cx%3D%2230%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20begin%3D%22.66s%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%3C%2Fsvg%3E%0A") no-repeat center center; + border: 1px solid #ccc; + min-height: 240px; + min-width: 320px; +} +.mce-match-marker { + background: #aaa; + color: #fff; +} +.mce-match-marker-selected { + background: #39f; + color: #fff; +} +.mce-match-marker-selected::-moz-selection { + background: #39f; + color: #fff; +} +.mce-match-marker-selected::selection { + background: #39f; + color: #fff; +} +.mce-content-body img[data-mce-selected], +.mce-content-body video[data-mce-selected], +.mce-content-body audio[data-mce-selected], +.mce-content-body object[data-mce-selected], +.mce-content-body embed[data-mce-selected], +.mce-content-body table[data-mce-selected] { + outline: 3px solid #b4d7ff; +} +.mce-content-body hr[data-mce-selected] { + outline: 3px solid #b4d7ff; + outline-offset: 1px; +} +.mce-content-body *[contentEditable=false] *[contentEditable=true]:focus { + outline: 3px solid #b4d7ff; +} +.mce-content-body *[contentEditable=false] *[contentEditable=true]:hover { + outline: 3px solid #b4d7ff; +} +.mce-content-body *[contentEditable=false][data-mce-selected] { + cursor: not-allowed; + outline: 3px solid #b4d7ff; +} +.mce-content-body.mce-content-readonly *[contentEditable=true]:focus, +.mce-content-body.mce-content-readonly *[contentEditable=true]:hover { + outline: none; +} +.mce-content-body *[data-mce-selected="inline-boundary"] { + background-color: #b4d7ff; +} +.mce-content-body .mce-edit-focus { + outline: 3px solid #b4d7ff; +} +.mce-content-body td[data-mce-selected], +.mce-content-body th[data-mce-selected] { + position: relative; +} +.mce-content-body td[data-mce-selected]::-moz-selection, +.mce-content-body th[data-mce-selected]::-moz-selection { + background: none; +} +.mce-content-body td[data-mce-selected]::selection, +.mce-content-body th[data-mce-selected]::selection { + background: none; +} +.mce-content-body td[data-mce-selected] *, +.mce-content-body th[data-mce-selected] * { + outline: none; + -webkit-touch-callout: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} +.mce-content-body td[data-mce-selected]::after, +.mce-content-body th[data-mce-selected]::after { + background-color: rgba(180, 215, 255, 0.7); + border: 1px solid rgba(180, 215, 255, 0.7); + bottom: -1px; + content: ''; + left: -1px; + mix-blend-mode: multiply; + position: absolute; + right: -1px; + top: -1px; +} +@media screen and (-ms-high-contrast: active), (-ms-high-contrast: none) { + .mce-content-body td[data-mce-selected]::after, + .mce-content-body th[data-mce-selected]::after { + border-color: rgba(0, 84, 180, 0.7); + } +} +.mce-content-body img::-moz-selection { + background: none; +} +.mce-content-body img::selection { + background: none; +} +.ephox-snooker-resizer-bar { + background-color: #b4d7ff; + opacity: 0; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} +.ephox-snooker-resizer-cols { + cursor: col-resize; +} +.ephox-snooker-resizer-rows { + cursor: row-resize; +} +.ephox-snooker-resizer-bar.ephox-snooker-resizer-bar-dragging { + opacity: 1; +} +.mce-spellchecker-word { + background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'4'%20height%3D'4'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20stroke%3D'%23ff0000'%20fill%3D'none'%20stroke-linecap%3D'round'%20stroke-opacity%3D'.75'%20d%3D'M0%203L2%201%204%203'%2F%3E%3C%2Fsvg%3E%0A"); + background-position: 0 calc(100% + 1px); + background-repeat: repeat-x; + background-size: auto 6px; + cursor: default; + height: 2rem; +} +.mce-spellchecker-grammar { + background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'4'%20height%3D'4'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20stroke%3D'%2300A835'%20fill%3D'none'%20stroke-linecap%3D'round'%20d%3D'M0%203L2%201%204%203'%2F%3E%3C%2Fsvg%3E%0A"); + background-position: 0 calc(100% + 1px); + background-repeat: repeat-x; + background-size: auto 6px; + cursor: default; +} +.mce-toc { + border: 1px solid gray; +} +.mce-toc h2 { + margin: 4px; +} +.mce-toc li { + list-style-type: none; +} +table[style*="border-width: 0px"], +.mce-item-table:not([border]), +.mce-item-table[border="0"], +table[style*="border-width: 0px"] td, +.mce-item-table:not([border]) td, +.mce-item-table[border="0"] td, +table[style*="border-width: 0px"] th, +.mce-item-table:not([border]) th, +.mce-item-table[border="0"] th, +table[style*="border-width: 0px"] caption, +.mce-item-table:not([border]) caption, +.mce-item-table[border="0"] caption { + border: 1px dashed #bbb; +} +.mce-visualblocks p, +.mce-visualblocks h1, +.mce-visualblocks h2, +.mce-visualblocks h3, +.mce-visualblocks h4, +.mce-visualblocks h5, +.mce-visualblocks h6, +.mce-visualblocks div:not([data-mce-bogus]), +.mce-visualblocks section, +.mce-visualblocks article, +.mce-visualblocks blockquote, +.mce-visualblocks address, +.mce-visualblocks pre, +.mce-visualblocks figure, +.mce-visualblocks figcaption, +.mce-visualblocks hgroup, +.mce-visualblocks aside, +.mce-visualblocks ul, +.mce-visualblocks ol, +.mce-visualblocks dl { + background-repeat: no-repeat; + border: 1px dashed #bbb; + margin-left: 3px; + padding-top: 10px; +} +.mce-visualblocks p { + background-image: url(data:image/gif;base64,R0lGODlhCQAJAJEAAAAAAP///7u7u////yH5BAEAAAMALAAAAAAJAAkAAAIQnG+CqCN/mlyvsRUpThG6AgA7); +} +.mce-visualblocks h1 { + background-image: url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybGu1JuxHoAfRNRW3TWXyF2YiRUAOw==); +} +.mce-visualblocks h2 { + background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8Hybbx4oOuqgTynJd6bGlWg3DkJzoaUAAAOw==); +} +.mce-visualblocks h3 { + background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIZjI8Hybbx4oOuqgTynJf2Ln2NOHpQpmhAAQA7); +} +.mce-visualblocks h4 { + background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxInR0zqeAdhtJlXwV1oCll2HaWgAAOw==); +} +.mce-visualblocks h5 { + background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxIoiuwjane4iq5GlW05GgIkIZUAAAOw==); +} +.mce-visualblocks h6 { + background-image: url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxIoiuwjan04jep1iZ1XRlAo5bVgAAOw==); +} +.mce-visualblocks div:not([data-mce-bogus]) { + background-image: url(data:image/gif;base64,R0lGODlhEgAKAIABALu7u////yH5BAEAAAEALAAAAAASAAoAAAIfjI9poI0cgDywrhuxfbrzDEbQM2Ei5aRjmoySW4pAAQA7); +} +.mce-visualblocks section { + background-image: url(data:image/gif;base64,R0lGODlhKAAKAIABALu7u////yH5BAEAAAEALAAAAAAoAAoAAAI5jI+pywcNY3sBWHdNrplytD2ellDeSVbp+GmWqaDqDMepc8t17Y4vBsK5hDyJMcI6KkuYU+jpjLoKADs=); +} +.mce-visualblocks article { + background-image: url(data:image/gif;base64,R0lGODlhKgAKAIABALu7u////yH5BAEAAAEALAAAAAAqAAoAAAI6jI+pywkNY3wG0GBvrsd2tXGYSGnfiF7ikpXemTpOiJScasYoDJJrjsG9gkCJ0ag6KhmaIe3pjDYBBQA7); +} +.mce-visualblocks blockquote { + background-image: url(data:image/gif;base64,R0lGODlhPgAKAIABALu7u////yH5BAEAAAEALAAAAAA+AAoAAAJPjI+py+0Knpz0xQDyuUhvfoGgIX5iSKZYgq5uNL5q69asZ8s5rrf0yZmpNkJZzFesBTu8TOlDVAabUyatguVhWduud3EyiUk45xhTTgMBBQA7); +} +.mce-visualblocks address { + background-image: url(data:image/gif;base64,R0lGODlhLQAKAIABALu7u////yH5BAEAAAEALAAAAAAtAAoAAAI/jI+pywwNozSP1gDyyZcjb3UaRpXkWaXmZW4OqKLhBmLs+K263DkJK7OJeifh7FicKD9A1/IpGdKkyFpNmCkAADs=); +} +.mce-visualblocks pre { + background-image: url(data:image/gif;base64,R0lGODlhFQAKAIABALu7uwAAACH5BAEAAAEALAAAAAAVAAoAAAIjjI+ZoN0cgDwSmnpz1NCueYERhnibZVKLNnbOq8IvKpJtVQAAOw==); +} +.mce-visualblocks figure { + background-image: url(data:image/gif;base64,R0lGODlhJAAKAIAAALu7u////yH5BAEAAAEALAAAAAAkAAoAAAI0jI+py+2fwAHUSFvD3RlvG4HIp4nX5JFSpnZUJ6LlrM52OE7uSWosBHScgkSZj7dDKnWAAgA7); +} +.mce-visualblocks figcaption { + border: 1px dashed #bbb; +} +.mce-visualblocks hgroup { + background-image: url(data:image/gif;base64,R0lGODlhJwAKAIABALu7uwAAACH5BAEAAAEALAAAAAAnAAoAAAI3jI+pywYNI3uB0gpsRtt5fFnfNZaVSYJil4Wo03Hv6Z62uOCgiXH1kZIIJ8NiIxRrAZNMZAtQAAA7); +} +.mce-visualblocks aside { + background-image: url(data:image/gif;base64,R0lGODlhHgAKAIABAKqqqv///yH5BAEAAAEALAAAAAAeAAoAAAItjI+pG8APjZOTzgtqy7I3f1yehmQcFY4WKZbqByutmW4aHUd6vfcVbgudgpYCADs=); +} +.mce-visualblocks ul { + background-image: url(data:image/gif;base64,R0lGODlhDQAKAIAAALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybGuYnqUVSjvw26DzzXiqIDlVwAAOw==); +} +.mce-visualblocks ol { + background-image: url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybH6HHt0qourxC6CvzXieHyeWQAAOw==); +} +.mce-visualblocks dl { + background-image: url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybEOnmOvUoWznTqeuEjNSCqeGRUAOw==); +} +.mce-visualblocks:not([dir=rtl]) p, +.mce-visualblocks:not([dir=rtl]) h1, +.mce-visualblocks:not([dir=rtl]) h2, +.mce-visualblocks:not([dir=rtl]) h3, +.mce-visualblocks:not([dir=rtl]) h4, +.mce-visualblocks:not([dir=rtl]) h5, +.mce-visualblocks:not([dir=rtl]) h6, +.mce-visualblocks:not([dir=rtl]) div:not([data-mce-bogus]), +.mce-visualblocks:not([dir=rtl]) section, +.mce-visualblocks:not([dir=rtl]) article, +.mce-visualblocks:not([dir=rtl]) blockquote, +.mce-visualblocks:not([dir=rtl]) address, +.mce-visualblocks:not([dir=rtl]) pre, +.mce-visualblocks:not([dir=rtl]) figure, +.mce-visualblocks:not([dir=rtl]) figcaption, +.mce-visualblocks:not([dir=rtl]) hgroup, +.mce-visualblocks:not([dir=rtl]) aside, +.mce-visualblocks:not([dir=rtl]) ul, +.mce-visualblocks:not([dir=rtl]) ol, +.mce-visualblocks:not([dir=rtl]) dl { + margin-left: 3px; +} +.mce-visualblocks[dir=rtl] p, +.mce-visualblocks[dir=rtl] h1, +.mce-visualblocks[dir=rtl] h2, +.mce-visualblocks[dir=rtl] h3, +.mce-visualblocks[dir=rtl] h4, +.mce-visualblocks[dir=rtl] h5, +.mce-visualblocks[dir=rtl] h6, +.mce-visualblocks[dir=rtl] div:not([data-mce-bogus]), +.mce-visualblocks[dir=rtl] section, +.mce-visualblocks[dir=rtl] article, +.mce-visualblocks[dir=rtl] blockquote, +.mce-visualblocks[dir=rtl] address, +.mce-visualblocks[dir=rtl] pre, +.mce-visualblocks[dir=rtl] figure, +.mce-visualblocks[dir=rtl] figcaption, +.mce-visualblocks[dir=rtl] hgroup, +.mce-visualblocks[dir=rtl] aside, +.mce-visualblocks[dir=rtl] ul, +.mce-visualblocks[dir=rtl] ol, +.mce-visualblocks[dir=rtl] dl { + background-position-x: right; + margin-right: 3px; +} +.mce-nbsp, +.mce-shy { + background: #aaa; +} +.mce-shy::after { + content: '-'; +} diff --git a/ui/public/tinymce/skins/ui/oxide/content.inline.min.css b/ui/public/tinymce/skins/ui/oxide/content.inline.min.css new file mode 100644 index 0000000..b4ab9a3 --- /dev/null +++ b/ui/public/tinymce/skins/ui/oxide/content.inline.min.css @@ -0,0 +1,7 @@ +/** + * Copyright (c) Tiny Technologies, Inc. All rights reserved. + * Licensed under the LGPL or a commercial license. + * For LGPL see License.txt in the project root for license information. + * For commercial licenses see https://www.tiny.cloud/ + */ +.mce-content-body .mce-item-anchor{background:transparent url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'8'%20height%3D'12'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20d%3D'M0%200L8%200%208%2012%204.09117821%209%200%2012z'%2F%3E%3C%2Fsvg%3E%0A") no-repeat center;cursor:default;display:inline-block;height:12px!important;padding:0 2px;-webkit-user-modify:read-only;-moz-user-modify:read-only;-webkit-user-select:all;-moz-user-select:all;-ms-user-select:all;user-select:all;width:8px!important}.mce-content-body .mce-item-anchor[data-mce-selected]{outline-offset:1px}.tox-comments-visible .tox-comment{background-color:#fff0b7}.tox-comments-visible .tox-comment--active{background-color:#ffe168}.tox-checklist>li:not(.tox-checklist--hidden){list-style:none;margin:.25em 0}.tox-checklist>li:not(.tox-checklist--hidden)::before{content:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-unchecked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2215%22%20height%3D%2215%22%20x%3D%22.5%22%20y%3D%22.5%22%20fill-rule%3D%22nonzero%22%20stroke%3D%22%234C4C4C%22%20rx%3D%222%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A");cursor:pointer;height:1em;margin-left:-1.5em;margin-top:.125em;position:absolute;width:1em}.tox-checklist li:not(.tox-checklist--hidden).tox-checklist--checked::before{content:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-checked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2216%22%20height%3D%2216%22%20fill%3D%22%234099FF%22%20fill-rule%3D%22nonzero%22%20rx%3D%222%22%2F%3E%3Cpath%20id%3D%22Path%22%20fill%3D%22%23FFF%22%20fill-rule%3D%22nonzero%22%20d%3D%22M11.5703186%2C3.14417309%20C11.8516238%2C2.73724603%2012.4164781%2C2.62829933%2012.83558%2C2.89774797%20C13.260121%2C3.17069355%2013.3759736%2C3.72932262%2013.0909105%2C4.14168582%20L7.7580587%2C11.8560195%20C7.43776896%2C12.3193404%206.76483983%2C12.3852142%206.35607322%2C11.9948725%20L3.02491697%2C8.8138662%20C2.66090143%2C8.46625845%202.65798871%2C7.89594698%203.01850234%2C7.54483354%20C3.373942%2C7.19866177%203.94940006%2C7.19592841%204.30829608%2C7.5386474%20L6.85276923%2C9.9684299%20L11.5703186%2C3.14417309%20Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A")}[dir=rtl] .tox-checklist>li:not(.tox-checklist--hidden)::before{margin-left:0;margin-right:-1.5em}code[class*=language-],pre[class*=language-]{color:#000;background:0 0;text-shadow:0 1px #fff;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;tab-size:4;-webkit-hyphens:none;-ms-hyphens:none;hyphens:none}code[class*=language-] ::-moz-selection,code[class*=language-]::-moz-selection,pre[class*=language-] ::-moz-selection,pre[class*=language-]::-moz-selection{text-shadow:none;background:#b3d4fc}code[class*=language-] ::selection,code[class*=language-]::selection,pre[class*=language-] ::selection,pre[class*=language-]::selection{text-shadow:none;background:#b3d4fc}@media print{code[class*=language-],pre[class*=language-]{text-shadow:none}}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#f5f2f0}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#708090}.token.punctuation{color:#999}.namespace{opacity:.7}.token.boolean,.token.constant,.token.deleted,.token.number,.token.property,.token.symbol,.token.tag{color:#905}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#690}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url{color:#9a6e3a;background:hsla(0,0%,100%,.5)}.token.atrule,.token.attr-value,.token.keyword{color:#07a}.token.class-name,.token.function{color:#dd4a68}.token.important,.token.regex,.token.variable{color:#e90}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.mce-content-body{overflow-wrap:break-word;word-wrap:break-word}.mce-content-body .mce-visual-caret{background-color:#000;background-color:currentColor;position:absolute}.mce-content-body .mce-visual-caret-hidden{display:none}.mce-content-body [data-mce-caret]{left:-1000px;margin:0;padding:0;position:absolute;right:auto;top:0}.mce-content-body .mce-offscreen-selection{left:-2000000px;max-width:1000000px;position:absolute}.mce-content-body [contentEditable=false]{cursor:default}.mce-content-body [contentEditable=true]{cursor:text}.tox-cursor-format-painter{cursor:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%3E%0A%20%20%3Cg%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%0A%20%20%20%20%3Cpath%20fill%3D%22%23000%22%20fill-rule%3D%22nonzero%22%20d%3D%22M15%2C6%20C15%2C5.45%2014.55%2C5%2014%2C5%20L6%2C5%20C5.45%2C5%205%2C5.45%205%2C6%20L5%2C10%20C5%2C10.55%205.45%2C11%206%2C11%20L14%2C11%20C14.55%2C11%2015%2C10.55%2015%2C10%20L15%2C9%20L16%2C9%20L16%2C12%20L9%2C12%20L9%2C19%20C9%2C19.55%209.45%2C20%2010%2C20%20L11%2C20%20C11.55%2C20%2012%2C19.55%2012%2C19%20L12%2C14%20L18%2C14%20L18%2C7%20L15%2C7%20L15%2C6%20Z%22%2F%3E%0A%20%20%20%20%3Cpath%20fill%3D%22%23000%22%20fill-rule%3D%22nonzero%22%20d%3D%22M1%2C1%20L8.25%2C1%20C8.66421356%2C1%209%2C1.33578644%209%2C1.75%20L9%2C1.75%20C9%2C2.16421356%208.66421356%2C2.5%208.25%2C2.5%20L2.5%2C2.5%20L2.5%2C8.25%20C2.5%2C8.66421356%202.16421356%2C9%201.75%2C9%20L1.75%2C9%20C1.33578644%2C9%201%2C8.66421356%201%2C8.25%20L1%2C1%20Z%22%2F%3E%0A%20%20%3C%2Fg%3E%0A%3C%2Fsvg%3E%0A"),default}.mce-content-body figure.align-left{float:left}.mce-content-body figure.align-right{float:right}.mce-content-body figure.image.align-center{display:table;margin-left:auto;margin-right:auto}.mce-preview-object{border:1px solid gray;display:inline-block;line-height:0;margin:0 2px 0 2px;position:relative}.mce-preview-object .mce-shim{background:url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7);height:100%;left:0;position:absolute;top:0;width:100%}.mce-preview-object[data-mce-selected="2"] .mce-shim{display:none}.mce-object{background:transparent url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M4%203h16a1%201%200%200%201%201%201v16a1%201%200%200%201-1%201H4a1%201%200%200%201-1-1V4a1%201%200%200%201%201-1zm1%202v14h14V5H5zm4.79%202.565l5.64%204.028a.5.5%200%200%201%200%20.814l-5.64%204.028a.5.5%200%200%201-.79-.407V7.972a.5.5%200%200%201%20.79-.407z%22%2F%3E%3C%2Fsvg%3E%0A") no-repeat center;border:1px dashed #aaa}.mce-pagebreak{border:1px dashed #aaa;cursor:default;display:block;height:5px;margin-top:15px;page-break-before:always;width:100%}@media print{.mce-pagebreak{border:0}}.tiny-pageembed .mce-shim{background:url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7);height:100%;left:0;position:absolute;top:0;width:100%}.tiny-pageembed[data-mce-selected="2"] .mce-shim{display:none}.tiny-pageembed{display:inline-block;position:relative}.tiny-pageembed--16by9,.tiny-pageembed--1by1,.tiny-pageembed--21by9,.tiny-pageembed--4by3{display:block;overflow:hidden;padding:0;position:relative;width:100%}.tiny-pageembed--21by9{padding-top:42.857143%}.tiny-pageembed--16by9{padding-top:56.25%}.tiny-pageembed--4by3{padding-top:75%}.tiny-pageembed--1by1{padding-top:100%}.tiny-pageembed--16by9 iframe,.tiny-pageembed--1by1 iframe,.tiny-pageembed--21by9 iframe,.tiny-pageembed--4by3 iframe{border:0;height:100%;left:0;position:absolute;top:0;width:100%}.mce-content-body[data-mce-placeholder]{position:relative}.mce-content-body[data-mce-placeholder]:not(.mce-visualblocks)::before{color:rgba(34,47,62,.7);content:attr(data-mce-placeholder);position:absolute}.mce-content-body:not([dir=rtl])[data-mce-placeholder]:not(.mce-visualblocks)::before{left:1px}.mce-content-body[dir=rtl][data-mce-placeholder]:not(.mce-visualblocks)::before{right:1px}.mce-content-body div.mce-resizehandle{background-color:#4099ff;border-color:#4099ff;border-style:solid;border-width:1px;box-sizing:border-box;height:10px;position:absolute;width:10px;z-index:1298}.mce-content-body div.mce-resizehandle:hover{background-color:#4099ff}.mce-content-body div.mce-resizehandle:nth-of-type(1){cursor:nwse-resize}.mce-content-body div.mce-resizehandle:nth-of-type(2){cursor:nesw-resize}.mce-content-body div.mce-resizehandle:nth-of-type(3){cursor:nwse-resize}.mce-content-body div.mce-resizehandle:nth-of-type(4){cursor:nesw-resize}.mce-content-body .mce-resize-backdrop{z-index:10000}.mce-content-body .mce-clonedresizable{cursor:default;opacity:.5;outline:1px dashed #000;position:absolute;z-index:10001}.mce-content-body .mce-clonedresizable.mce-resizetable-columns td,.mce-content-body .mce-clonedresizable.mce-resizetable-columns th{border:0}.mce-content-body .mce-resize-helper{background:#555;background:rgba(0,0,0,.75);border:1px;border-radius:3px;color:#fff;display:none;font-family:sans-serif;font-size:12px;line-height:14px;margin:5px 10px;padding:5px;position:absolute;white-space:nowrap;z-index:10002}.tox-rtc-user-selection{position:relative}.tox-rtc-user-cursor{bottom:0;cursor:default;position:absolute;top:0;width:2px}.tox-rtc-user-cursor::before{background-color:inherit;border-radius:50%;content:'';display:block;height:8px;position:absolute;right:-3px;top:-3px;width:8px}.tox-rtc-user-cursor:hover::after{background-color:inherit;border-radius:100px;box-sizing:border-box;color:#fff;content:attr(data-user);display:block;font-size:12px;font-weight:700;left:-5px;min-height:8px;min-width:8px;padding:0 12px;position:absolute;top:-11px;white-space:nowrap;z-index:1000}.tox-rtc-user-selection--1 .tox-rtc-user-cursor{background-color:#2dc26b}.tox-rtc-user-selection--2 .tox-rtc-user-cursor{background-color:#e03e2d}.tox-rtc-user-selection--3 .tox-rtc-user-cursor{background-color:#f1c40f}.tox-rtc-user-selection--4 .tox-rtc-user-cursor{background-color:#3598db}.tox-rtc-user-selection--5 .tox-rtc-user-cursor{background-color:#b96ad9}.tox-rtc-user-selection--6 .tox-rtc-user-cursor{background-color:#e67e23}.tox-rtc-user-selection--7 .tox-rtc-user-cursor{background-color:#aaa69d}.tox-rtc-user-selection--8 .tox-rtc-user-cursor{background-color:#f368e0}.tox-rtc-remote-image{background:#eaeaea url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2236%22%20height%3D%2212%22%20viewBox%3D%220%200%2036%2012%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Ccircle%20cx%3D%226%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%20%20%3Ccircle%20cx%3D%2218%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20begin%3D%22.33s%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%20%20%3Ccircle%20cx%3D%2230%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20begin%3D%22.66s%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%3C%2Fsvg%3E%0A") no-repeat center center;border:1px solid #ccc;min-height:240px;min-width:320px}.mce-match-marker{background:#aaa;color:#fff}.mce-match-marker-selected{background:#39f;color:#fff}.mce-match-marker-selected::-moz-selection{background:#39f;color:#fff}.mce-match-marker-selected::selection{background:#39f;color:#fff}.mce-content-body audio[data-mce-selected],.mce-content-body embed[data-mce-selected],.mce-content-body img[data-mce-selected],.mce-content-body object[data-mce-selected],.mce-content-body table[data-mce-selected],.mce-content-body video[data-mce-selected]{outline:3px solid #b4d7ff}.mce-content-body hr[data-mce-selected]{outline:3px solid #b4d7ff;outline-offset:1px}.mce-content-body [contentEditable=false] [contentEditable=true]:focus{outline:3px solid #b4d7ff}.mce-content-body [contentEditable=false] [contentEditable=true]:hover{outline:3px solid #b4d7ff}.mce-content-body [contentEditable=false][data-mce-selected]{cursor:not-allowed;outline:3px solid #b4d7ff}.mce-content-body.mce-content-readonly [contentEditable=true]:focus,.mce-content-body.mce-content-readonly [contentEditable=true]:hover{outline:0}.mce-content-body [data-mce-selected=inline-boundary]{background-color:#b4d7ff}.mce-content-body .mce-edit-focus{outline:3px solid #b4d7ff}.mce-content-body td[data-mce-selected],.mce-content-body th[data-mce-selected]{position:relative}.mce-content-body td[data-mce-selected]::-moz-selection,.mce-content-body th[data-mce-selected]::-moz-selection{background:0 0}.mce-content-body td[data-mce-selected]::selection,.mce-content-body th[data-mce-selected]::selection{background:0 0}.mce-content-body td[data-mce-selected] *,.mce-content-body th[data-mce-selected] *{outline:0;-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.mce-content-body td[data-mce-selected]::after,.mce-content-body th[data-mce-selected]::after{background-color:rgba(180,215,255,.7);border:1px solid rgba(180,215,255,.7);bottom:-1px;content:'';left:-1px;mix-blend-mode:multiply;position:absolute;right:-1px;top:-1px}@media screen and (-ms-high-contrast:active),(-ms-high-contrast:none){.mce-content-body td[data-mce-selected]::after,.mce-content-body th[data-mce-selected]::after{border-color:rgba(0,84,180,.7)}}.mce-content-body img::-moz-selection{background:0 0}.mce-content-body img::selection{background:0 0}.ephox-snooker-resizer-bar{background-color:#b4d7ff;opacity:0;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.ephox-snooker-resizer-cols{cursor:col-resize}.ephox-snooker-resizer-rows{cursor:row-resize}.ephox-snooker-resizer-bar.ephox-snooker-resizer-bar-dragging{opacity:1}.mce-spellchecker-word{background-image:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'4'%20height%3D'4'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20stroke%3D'%23ff0000'%20fill%3D'none'%20stroke-linecap%3D'round'%20stroke-opacity%3D'.75'%20d%3D'M0%203L2%201%204%203'%2F%3E%3C%2Fsvg%3E%0A");background-position:0 calc(100% + 1px);background-repeat:repeat-x;background-size:auto 6px;cursor:default;height:2rem}.mce-spellchecker-grammar{background-image:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'4'%20height%3D'4'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20stroke%3D'%2300A835'%20fill%3D'none'%20stroke-linecap%3D'round'%20d%3D'M0%203L2%201%204%203'%2F%3E%3C%2Fsvg%3E%0A");background-position:0 calc(100% + 1px);background-repeat:repeat-x;background-size:auto 6px;cursor:default}.mce-toc{border:1px solid gray}.mce-toc h2{margin:4px}.mce-toc li{list-style-type:none}.mce-item-table:not([border]),.mce-item-table:not([border]) caption,.mce-item-table:not([border]) td,.mce-item-table:not([border]) th,.mce-item-table[border="0"],.mce-item-table[border="0"] caption,.mce-item-table[border="0"] td,.mce-item-table[border="0"] th,table[style*="border-width: 0px"],table[style*="border-width: 0px"] caption,table[style*="border-width: 0px"] td,table[style*="border-width: 0px"] th{border:1px dashed #bbb}.mce-visualblocks address,.mce-visualblocks article,.mce-visualblocks aside,.mce-visualblocks blockquote,.mce-visualblocks div:not([data-mce-bogus]),.mce-visualblocks dl,.mce-visualblocks figcaption,.mce-visualblocks figure,.mce-visualblocks h1,.mce-visualblocks h2,.mce-visualblocks h3,.mce-visualblocks h4,.mce-visualblocks h5,.mce-visualblocks h6,.mce-visualblocks hgroup,.mce-visualblocks ol,.mce-visualblocks p,.mce-visualblocks pre,.mce-visualblocks section,.mce-visualblocks ul{background-repeat:no-repeat;border:1px dashed #bbb;margin-left:3px;padding-top:10px}.mce-visualblocks p{background-image:url(data:image/gif;base64,R0lGODlhCQAJAJEAAAAAAP///7u7u////yH5BAEAAAMALAAAAAAJAAkAAAIQnG+CqCN/mlyvsRUpThG6AgA7)}.mce-visualblocks h1{background-image:url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybGu1JuxHoAfRNRW3TWXyF2YiRUAOw==)}.mce-visualblocks h2{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8Hybbx4oOuqgTynJd6bGlWg3DkJzoaUAAAOw==)}.mce-visualblocks h3{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIZjI8Hybbx4oOuqgTynJf2Ln2NOHpQpmhAAQA7)}.mce-visualblocks h4{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxInR0zqeAdhtJlXwV1oCll2HaWgAAOw==)}.mce-visualblocks h5{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxIoiuwjane4iq5GlW05GgIkIZUAAAOw==)}.mce-visualblocks h6{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxIoiuwjan04jep1iZ1XRlAo5bVgAAOw==)}.mce-visualblocks div:not([data-mce-bogus]){background-image:url(data:image/gif;base64,R0lGODlhEgAKAIABALu7u////yH5BAEAAAEALAAAAAASAAoAAAIfjI9poI0cgDywrhuxfbrzDEbQM2Ei5aRjmoySW4pAAQA7)}.mce-visualblocks section{background-image:url(data:image/gif;base64,R0lGODlhKAAKAIABALu7u////yH5BAEAAAEALAAAAAAoAAoAAAI5jI+pywcNY3sBWHdNrplytD2ellDeSVbp+GmWqaDqDMepc8t17Y4vBsK5hDyJMcI6KkuYU+jpjLoKADs=)}.mce-visualblocks article{background-image:url(data:image/gif;base64,R0lGODlhKgAKAIABALu7u////yH5BAEAAAEALAAAAAAqAAoAAAI6jI+pywkNY3wG0GBvrsd2tXGYSGnfiF7ikpXemTpOiJScasYoDJJrjsG9gkCJ0ag6KhmaIe3pjDYBBQA7)}.mce-visualblocks blockquote{background-image:url(data:image/gif;base64,R0lGODlhPgAKAIABALu7u////yH5BAEAAAEALAAAAAA+AAoAAAJPjI+py+0Knpz0xQDyuUhvfoGgIX5iSKZYgq5uNL5q69asZ8s5rrf0yZmpNkJZzFesBTu8TOlDVAabUyatguVhWduud3EyiUk45xhTTgMBBQA7)}.mce-visualblocks address{background-image:url(data:image/gif;base64,R0lGODlhLQAKAIABALu7u////yH5BAEAAAEALAAAAAAtAAoAAAI/jI+pywwNozSP1gDyyZcjb3UaRpXkWaXmZW4OqKLhBmLs+K263DkJK7OJeifh7FicKD9A1/IpGdKkyFpNmCkAADs=)}.mce-visualblocks pre{background-image:url(data:image/gif;base64,R0lGODlhFQAKAIABALu7uwAAACH5BAEAAAEALAAAAAAVAAoAAAIjjI+ZoN0cgDwSmnpz1NCueYERhnibZVKLNnbOq8IvKpJtVQAAOw==)}.mce-visualblocks figure{background-image:url(data:image/gif;base64,R0lGODlhJAAKAIAAALu7u////yH5BAEAAAEALAAAAAAkAAoAAAI0jI+py+2fwAHUSFvD3RlvG4HIp4nX5JFSpnZUJ6LlrM52OE7uSWosBHScgkSZj7dDKnWAAgA7)}.mce-visualblocks figcaption{border:1px dashed #bbb}.mce-visualblocks hgroup{background-image:url(data:image/gif;base64,R0lGODlhJwAKAIABALu7uwAAACH5BAEAAAEALAAAAAAnAAoAAAI3jI+pywYNI3uB0gpsRtt5fFnfNZaVSYJil4Wo03Hv6Z62uOCgiXH1kZIIJ8NiIxRrAZNMZAtQAAA7)}.mce-visualblocks aside{background-image:url(data:image/gif;base64,R0lGODlhHgAKAIABAKqqqv///yH5BAEAAAEALAAAAAAeAAoAAAItjI+pG8APjZOTzgtqy7I3f1yehmQcFY4WKZbqByutmW4aHUd6vfcVbgudgpYCADs=)}.mce-visualblocks ul{background-image:url(data:image/gif;base64,R0lGODlhDQAKAIAAALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybGuYnqUVSjvw26DzzXiqIDlVwAAOw==)}.mce-visualblocks ol{background-image:url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybH6HHt0qourxC6CvzXieHyeWQAAOw==)}.mce-visualblocks dl{background-image:url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybEOnmOvUoWznTqeuEjNSCqeGRUAOw==)}.mce-visualblocks:not([dir=rtl]) address,.mce-visualblocks:not([dir=rtl]) article,.mce-visualblocks:not([dir=rtl]) aside,.mce-visualblocks:not([dir=rtl]) blockquote,.mce-visualblocks:not([dir=rtl]) div:not([data-mce-bogus]),.mce-visualblocks:not([dir=rtl]) dl,.mce-visualblocks:not([dir=rtl]) figcaption,.mce-visualblocks:not([dir=rtl]) figure,.mce-visualblocks:not([dir=rtl]) h1,.mce-visualblocks:not([dir=rtl]) h2,.mce-visualblocks:not([dir=rtl]) h3,.mce-visualblocks:not([dir=rtl]) h4,.mce-visualblocks:not([dir=rtl]) h5,.mce-visualblocks:not([dir=rtl]) h6,.mce-visualblocks:not([dir=rtl]) hgroup,.mce-visualblocks:not([dir=rtl]) ol,.mce-visualblocks:not([dir=rtl]) p,.mce-visualblocks:not([dir=rtl]) pre,.mce-visualblocks:not([dir=rtl]) section,.mce-visualblocks:not([dir=rtl]) ul{margin-left:3px}.mce-visualblocks[dir=rtl] address,.mce-visualblocks[dir=rtl] article,.mce-visualblocks[dir=rtl] aside,.mce-visualblocks[dir=rtl] blockquote,.mce-visualblocks[dir=rtl] div:not([data-mce-bogus]),.mce-visualblocks[dir=rtl] dl,.mce-visualblocks[dir=rtl] figcaption,.mce-visualblocks[dir=rtl] figure,.mce-visualblocks[dir=rtl] h1,.mce-visualblocks[dir=rtl] h2,.mce-visualblocks[dir=rtl] h3,.mce-visualblocks[dir=rtl] h4,.mce-visualblocks[dir=rtl] h5,.mce-visualblocks[dir=rtl] h6,.mce-visualblocks[dir=rtl] hgroup,.mce-visualblocks[dir=rtl] ol,.mce-visualblocks[dir=rtl] p,.mce-visualblocks[dir=rtl] pre,.mce-visualblocks[dir=rtl] section,.mce-visualblocks[dir=rtl] ul{background-position-x:right;margin-right:3px}.mce-nbsp,.mce-shy{background:#aaa}.mce-shy::after{content:'-'} diff --git a/ui/public/tinymce/skins/ui/oxide/content.min.css b/ui/public/tinymce/skins/ui/oxide/content.min.css new file mode 100644 index 0000000..844858d --- /dev/null +++ b/ui/public/tinymce/skins/ui/oxide/content.min.css @@ -0,0 +1,7 @@ +/** + * Copyright (c) Tiny Technologies, Inc. All rights reserved. + * Licensed under the LGPL or a commercial license. + * For LGPL see License.txt in the project root for license information. + * For commercial licenses see https://www.tiny.cloud/ + */ +.mce-content-body .mce-item-anchor{background:transparent url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'8'%20height%3D'12'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20d%3D'M0%200L8%200%208%2012%204.09117821%209%200%2012z'%2F%3E%3C%2Fsvg%3E%0A") no-repeat center;cursor:default;display:inline-block;height:12px!important;padding:0 2px;-webkit-user-modify:read-only;-moz-user-modify:read-only;-webkit-user-select:all;-moz-user-select:all;-ms-user-select:all;user-select:all;width:8px!important}.mce-content-body .mce-item-anchor[data-mce-selected]{outline-offset:1px}.tox-comments-visible .tox-comment{background-color:#fff0b7}.tox-comments-visible .tox-comment--active{background-color:#ffe168}.tox-checklist>li:not(.tox-checklist--hidden){list-style:none;margin:.25em 0}.tox-checklist>li:not(.tox-checklist--hidden)::before{content:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-unchecked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2215%22%20height%3D%2215%22%20x%3D%22.5%22%20y%3D%22.5%22%20fill-rule%3D%22nonzero%22%20stroke%3D%22%234C4C4C%22%20rx%3D%222%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A");cursor:pointer;height:1em;margin-left:-1.5em;margin-top:.125em;position:absolute;width:1em}.tox-checklist li:not(.tox-checklist--hidden).tox-checklist--checked::before{content:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-checked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2216%22%20height%3D%2216%22%20fill%3D%22%234099FF%22%20fill-rule%3D%22nonzero%22%20rx%3D%222%22%2F%3E%3Cpath%20id%3D%22Path%22%20fill%3D%22%23FFF%22%20fill-rule%3D%22nonzero%22%20d%3D%22M11.5703186%2C3.14417309%20C11.8516238%2C2.73724603%2012.4164781%2C2.62829933%2012.83558%2C2.89774797%20C13.260121%2C3.17069355%2013.3759736%2C3.72932262%2013.0909105%2C4.14168582%20L7.7580587%2C11.8560195%20C7.43776896%2C12.3193404%206.76483983%2C12.3852142%206.35607322%2C11.9948725%20L3.02491697%2C8.8138662%20C2.66090143%2C8.46625845%202.65798871%2C7.89594698%203.01850234%2C7.54483354%20C3.373942%2C7.19866177%203.94940006%2C7.19592841%204.30829608%2C7.5386474%20L6.85276923%2C9.9684299%20L11.5703186%2C3.14417309%20Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A")}[dir=rtl] .tox-checklist>li:not(.tox-checklist--hidden)::before{margin-left:0;margin-right:-1.5em}code[class*=language-],pre[class*=language-]{color:#000;background:0 0;text-shadow:0 1px #fff;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;tab-size:4;-webkit-hyphens:none;-ms-hyphens:none;hyphens:none}code[class*=language-] ::-moz-selection,code[class*=language-]::-moz-selection,pre[class*=language-] ::-moz-selection,pre[class*=language-]::-moz-selection{text-shadow:none;background:#b3d4fc}code[class*=language-] ::selection,code[class*=language-]::selection,pre[class*=language-] ::selection,pre[class*=language-]::selection{text-shadow:none;background:#b3d4fc}@media print{code[class*=language-],pre[class*=language-]{text-shadow:none}}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#f5f2f0}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#708090}.token.punctuation{color:#999}.namespace{opacity:.7}.token.boolean,.token.constant,.token.deleted,.token.number,.token.property,.token.symbol,.token.tag{color:#905}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#690}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url{color:#9a6e3a;background:hsla(0,0%,100%,.5)}.token.atrule,.token.attr-value,.token.keyword{color:#07a}.token.class-name,.token.function{color:#dd4a68}.token.important,.token.regex,.token.variable{color:#e90}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.mce-content-body{overflow-wrap:break-word;word-wrap:break-word}.mce-content-body .mce-visual-caret{background-color:#000;background-color:currentColor;position:absolute}.mce-content-body .mce-visual-caret-hidden{display:none}.mce-content-body [data-mce-caret]{left:-1000px;margin:0;padding:0;position:absolute;right:auto;top:0}.mce-content-body .mce-offscreen-selection{left:-2000000px;max-width:1000000px;position:absolute}.mce-content-body [contentEditable=false]{cursor:default}.mce-content-body [contentEditable=true]{cursor:text}.tox-cursor-format-painter{cursor:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%3E%0A%20%20%3Cg%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%0A%20%20%20%20%3Cpath%20fill%3D%22%23000%22%20fill-rule%3D%22nonzero%22%20d%3D%22M15%2C6%20C15%2C5.45%2014.55%2C5%2014%2C5%20L6%2C5%20C5.45%2C5%205%2C5.45%205%2C6%20L5%2C10%20C5%2C10.55%205.45%2C11%206%2C11%20L14%2C11%20C14.55%2C11%2015%2C10.55%2015%2C10%20L15%2C9%20L16%2C9%20L16%2C12%20L9%2C12%20L9%2C19%20C9%2C19.55%209.45%2C20%2010%2C20%20L11%2C20%20C11.55%2C20%2012%2C19.55%2012%2C19%20L12%2C14%20L18%2C14%20L18%2C7%20L15%2C7%20L15%2C6%20Z%22%2F%3E%0A%20%20%20%20%3Cpath%20fill%3D%22%23000%22%20fill-rule%3D%22nonzero%22%20d%3D%22M1%2C1%20L8.25%2C1%20C8.66421356%2C1%209%2C1.33578644%209%2C1.75%20L9%2C1.75%20C9%2C2.16421356%208.66421356%2C2.5%208.25%2C2.5%20L2.5%2C2.5%20L2.5%2C8.25%20C2.5%2C8.66421356%202.16421356%2C9%201.75%2C9%20L1.75%2C9%20C1.33578644%2C9%201%2C8.66421356%201%2C8.25%20L1%2C1%20Z%22%2F%3E%0A%20%20%3C%2Fg%3E%0A%3C%2Fsvg%3E%0A"),default}.mce-content-body figure.align-left{float:left}.mce-content-body figure.align-right{float:right}.mce-content-body figure.image.align-center{display:table;margin-left:auto;margin-right:auto}.mce-preview-object{border:1px solid gray;display:inline-block;line-height:0;margin:0 2px 0 2px;position:relative}.mce-preview-object .mce-shim{background:url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7);height:100%;left:0;position:absolute;top:0;width:100%}.mce-preview-object[data-mce-selected="2"] .mce-shim{display:none}.mce-object{background:transparent url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%3E%3Cpath%20d%3D%22M4%203h16a1%201%200%200%201%201%201v16a1%201%200%200%201-1%201H4a1%201%200%200%201-1-1V4a1%201%200%200%201%201-1zm1%202v14h14V5H5zm4.79%202.565l5.64%204.028a.5.5%200%200%201%200%20.814l-5.64%204.028a.5.5%200%200%201-.79-.407V7.972a.5.5%200%200%201%20.79-.407z%22%2F%3E%3C%2Fsvg%3E%0A") no-repeat center;border:1px dashed #aaa}.mce-pagebreak{border:1px dashed #aaa;cursor:default;display:block;height:5px;margin-top:15px;page-break-before:always;width:100%}@media print{.mce-pagebreak{border:0}}.tiny-pageembed .mce-shim{background:url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7);height:100%;left:0;position:absolute;top:0;width:100%}.tiny-pageembed[data-mce-selected="2"] .mce-shim{display:none}.tiny-pageembed{display:inline-block;position:relative}.tiny-pageembed--16by9,.tiny-pageembed--1by1,.tiny-pageembed--21by9,.tiny-pageembed--4by3{display:block;overflow:hidden;padding:0;position:relative;width:100%}.tiny-pageembed--21by9{padding-top:42.857143%}.tiny-pageembed--16by9{padding-top:56.25%}.tiny-pageembed--4by3{padding-top:75%}.tiny-pageembed--1by1{padding-top:100%}.tiny-pageembed--16by9 iframe,.tiny-pageembed--1by1 iframe,.tiny-pageembed--21by9 iframe,.tiny-pageembed--4by3 iframe{border:0;height:100%;left:0;position:absolute;top:0;width:100%}.mce-content-body[data-mce-placeholder]{position:relative}.mce-content-body[data-mce-placeholder]:not(.mce-visualblocks)::before{color:rgba(34,47,62,.7);content:attr(data-mce-placeholder);position:absolute}.mce-content-body:not([dir=rtl])[data-mce-placeholder]:not(.mce-visualblocks)::before{left:1px}.mce-content-body[dir=rtl][data-mce-placeholder]:not(.mce-visualblocks)::before{right:1px}.mce-content-body div.mce-resizehandle{background-color:#4099ff;border-color:#4099ff;border-style:solid;border-width:1px;box-sizing:border-box;height:10px;position:absolute;width:10px;z-index:1298}.mce-content-body div.mce-resizehandle:hover{background-color:#4099ff}.mce-content-body div.mce-resizehandle:nth-of-type(1){cursor:nwse-resize}.mce-content-body div.mce-resizehandle:nth-of-type(2){cursor:nesw-resize}.mce-content-body div.mce-resizehandle:nth-of-type(3){cursor:nwse-resize}.mce-content-body div.mce-resizehandle:nth-of-type(4){cursor:nesw-resize}.mce-content-body .mce-resize-backdrop{z-index:10000}.mce-content-body .mce-clonedresizable{cursor:default;opacity:.5;outline:1px dashed #000;position:absolute;z-index:10001}.mce-content-body .mce-clonedresizable.mce-resizetable-columns td,.mce-content-body .mce-clonedresizable.mce-resizetable-columns th{border:0}.mce-content-body .mce-resize-helper{background:#555;background:rgba(0,0,0,.75);border:1px;border-radius:3px;color:#fff;display:none;font-family:sans-serif;font-size:12px;line-height:14px;margin:5px 10px;padding:5px;position:absolute;white-space:nowrap;z-index:10002}.tox-rtc-user-selection{position:relative}.tox-rtc-user-cursor{bottom:0;cursor:default;position:absolute;top:0;width:2px}.tox-rtc-user-cursor::before{background-color:inherit;border-radius:50%;content:'';display:block;height:8px;position:absolute;right:-3px;top:-3px;width:8px}.tox-rtc-user-cursor:hover::after{background-color:inherit;border-radius:100px;box-sizing:border-box;color:#fff;content:attr(data-user);display:block;font-size:12px;font-weight:700;left:-5px;min-height:8px;min-width:8px;padding:0 12px;position:absolute;top:-11px;white-space:nowrap;z-index:1000}.tox-rtc-user-selection--1 .tox-rtc-user-cursor{background-color:#2dc26b}.tox-rtc-user-selection--2 .tox-rtc-user-cursor{background-color:#e03e2d}.tox-rtc-user-selection--3 .tox-rtc-user-cursor{background-color:#f1c40f}.tox-rtc-user-selection--4 .tox-rtc-user-cursor{background-color:#3598db}.tox-rtc-user-selection--5 .tox-rtc-user-cursor{background-color:#b96ad9}.tox-rtc-user-selection--6 .tox-rtc-user-cursor{background-color:#e67e23}.tox-rtc-user-selection--7 .tox-rtc-user-cursor{background-color:#aaa69d}.tox-rtc-user-selection--8 .tox-rtc-user-cursor{background-color:#f368e0}.tox-rtc-remote-image{background:#eaeaea url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2236%22%20height%3D%2212%22%20viewBox%3D%220%200%2036%2012%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Ccircle%20cx%3D%226%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%20%20%3Ccircle%20cx%3D%2218%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20begin%3D%22.33s%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%20%20%3Ccircle%20cx%3D%2230%22%20cy%3D%226%22%20r%3D%223%22%20fill%3D%22rgba(0%2C%200%2C%200%2C%20.2)%22%3E%0A%20%20%20%20%3Canimate%20attributeName%3D%22r%22%20values%3D%223%3B5%3B3%22%20calcMode%3D%22linear%22%20begin%3D%22.66s%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%20%2F%3E%0A%20%20%3C%2Fcircle%3E%0A%3C%2Fsvg%3E%0A") no-repeat center center;border:1px solid #ccc;min-height:240px;min-width:320px}.mce-match-marker{background:#aaa;color:#fff}.mce-match-marker-selected{background:#39f;color:#fff}.mce-match-marker-selected::-moz-selection{background:#39f;color:#fff}.mce-match-marker-selected::selection{background:#39f;color:#fff}.mce-content-body audio[data-mce-selected],.mce-content-body embed[data-mce-selected],.mce-content-body img[data-mce-selected],.mce-content-body object[data-mce-selected],.mce-content-body table[data-mce-selected],.mce-content-body video[data-mce-selected]{outline:3px solid #b4d7ff}.mce-content-body hr[data-mce-selected]{outline:3px solid #b4d7ff;outline-offset:1px}.mce-content-body [contentEditable=false] [contentEditable=true]:focus{outline:3px solid #b4d7ff}.mce-content-body [contentEditable=false] [contentEditable=true]:hover{outline:3px solid #b4d7ff}.mce-content-body [contentEditable=false][data-mce-selected]{cursor:not-allowed;outline:3px solid #b4d7ff}.mce-content-body.mce-content-readonly [contentEditable=true]:focus,.mce-content-body.mce-content-readonly [contentEditable=true]:hover{outline:0}.mce-content-body [data-mce-selected=inline-boundary]{background-color:#b4d7ff}.mce-content-body .mce-edit-focus{outline:3px solid #b4d7ff}.mce-content-body td[data-mce-selected],.mce-content-body th[data-mce-selected]{position:relative}.mce-content-body td[data-mce-selected]::-moz-selection,.mce-content-body th[data-mce-selected]::-moz-selection{background:0 0}.mce-content-body td[data-mce-selected]::selection,.mce-content-body th[data-mce-selected]::selection{background:0 0}.mce-content-body td[data-mce-selected] *,.mce-content-body th[data-mce-selected] *{outline:0;-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.mce-content-body td[data-mce-selected]::after,.mce-content-body th[data-mce-selected]::after{background-color:rgba(180,215,255,.7);border:1px solid rgba(180,215,255,.7);bottom:-1px;content:'';left:-1px;mix-blend-mode:multiply;position:absolute;right:-1px;top:-1px}@media screen and (-ms-high-contrast:active),(-ms-high-contrast:none){.mce-content-body td[data-mce-selected]::after,.mce-content-body th[data-mce-selected]::after{border-color:rgba(0,84,180,.7)}}.mce-content-body img::-moz-selection{background:0 0}.mce-content-body img::selection{background:0 0}.ephox-snooker-resizer-bar{background-color:#b4d7ff;opacity:0;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.ephox-snooker-resizer-cols{cursor:col-resize}.ephox-snooker-resizer-rows{cursor:row-resize}.ephox-snooker-resizer-bar.ephox-snooker-resizer-bar-dragging{opacity:1}.mce-spellchecker-word{background-image:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'4'%20height%3D'4'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20stroke%3D'%23ff0000'%20fill%3D'none'%20stroke-linecap%3D'round'%20stroke-opacity%3D'.75'%20d%3D'M0%203L2%201%204%203'%2F%3E%3C%2Fsvg%3E%0A");background-position:0 calc(100% + 1px);background-repeat:repeat-x;background-size:auto 6px;cursor:default;height:2rem}.mce-spellchecker-grammar{background-image:url("data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D'4'%20height%3D'4'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%3E%3Cpath%20stroke%3D'%2300A835'%20fill%3D'none'%20stroke-linecap%3D'round'%20d%3D'M0%203L2%201%204%203'%2F%3E%3C%2Fsvg%3E%0A");background-position:0 calc(100% + 1px);background-repeat:repeat-x;background-size:auto 6px;cursor:default}.mce-toc{border:1px solid gray}.mce-toc h2{margin:4px}.mce-toc li{list-style-type:none}.mce-item-table:not([border]),.mce-item-table:not([border]) caption,.mce-item-table:not([border]) td,.mce-item-table:not([border]) th,.mce-item-table[border="0"],.mce-item-table[border="0"] caption,.mce-item-table[border="0"] td,.mce-item-table[border="0"] th,table[style*="border-width: 0px"],table[style*="border-width: 0px"] caption,table[style*="border-width: 0px"] td,table[style*="border-width: 0px"] th{border:1px dashed #bbb}.mce-visualblocks address,.mce-visualblocks article,.mce-visualblocks aside,.mce-visualblocks blockquote,.mce-visualblocks div:not([data-mce-bogus]),.mce-visualblocks dl,.mce-visualblocks figcaption,.mce-visualblocks figure,.mce-visualblocks h1,.mce-visualblocks h2,.mce-visualblocks h3,.mce-visualblocks h4,.mce-visualblocks h5,.mce-visualblocks h6,.mce-visualblocks hgroup,.mce-visualblocks ol,.mce-visualblocks p,.mce-visualblocks pre,.mce-visualblocks section,.mce-visualblocks ul{background-repeat:no-repeat;border:1px dashed #bbb;margin-left:3px;padding-top:10px}.mce-visualblocks p{background-image:url(data:image/gif;base64,R0lGODlhCQAJAJEAAAAAAP///7u7u////yH5BAEAAAMALAAAAAAJAAkAAAIQnG+CqCN/mlyvsRUpThG6AgA7)}.mce-visualblocks h1{background-image:url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybGu1JuxHoAfRNRW3TWXyF2YiRUAOw==)}.mce-visualblocks h2{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8Hybbx4oOuqgTynJd6bGlWg3DkJzoaUAAAOw==)}.mce-visualblocks h3{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIZjI8Hybbx4oOuqgTynJf2Ln2NOHpQpmhAAQA7)}.mce-visualblocks h4{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxInR0zqeAdhtJlXwV1oCll2HaWgAAOw==)}.mce-visualblocks h5{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxIoiuwjane4iq5GlW05GgIkIZUAAAOw==)}.mce-visualblocks h6{background-image:url(data:image/gif;base64,R0lGODlhDgAKAIABALu7u////yH5BAEAAAEALAAAAAAOAAoAAAIajI8HybbxIoiuwjan04jep1iZ1XRlAo5bVgAAOw==)}.mce-visualblocks div:not([data-mce-bogus]){background-image:url(data:image/gif;base64,R0lGODlhEgAKAIABALu7u////yH5BAEAAAEALAAAAAASAAoAAAIfjI9poI0cgDywrhuxfbrzDEbQM2Ei5aRjmoySW4pAAQA7)}.mce-visualblocks section{background-image:url(data:image/gif;base64,R0lGODlhKAAKAIABALu7u////yH5BAEAAAEALAAAAAAoAAoAAAI5jI+pywcNY3sBWHdNrplytD2ellDeSVbp+GmWqaDqDMepc8t17Y4vBsK5hDyJMcI6KkuYU+jpjLoKADs=)}.mce-visualblocks article{background-image:url(data:image/gif;base64,R0lGODlhKgAKAIABALu7u////yH5BAEAAAEALAAAAAAqAAoAAAI6jI+pywkNY3wG0GBvrsd2tXGYSGnfiF7ikpXemTpOiJScasYoDJJrjsG9gkCJ0ag6KhmaIe3pjDYBBQA7)}.mce-visualblocks blockquote{background-image:url(data:image/gif;base64,R0lGODlhPgAKAIABALu7u////yH5BAEAAAEALAAAAAA+AAoAAAJPjI+py+0Knpz0xQDyuUhvfoGgIX5iSKZYgq5uNL5q69asZ8s5rrf0yZmpNkJZzFesBTu8TOlDVAabUyatguVhWduud3EyiUk45xhTTgMBBQA7)}.mce-visualblocks address{background-image:url(data:image/gif;base64,R0lGODlhLQAKAIABALu7u////yH5BAEAAAEALAAAAAAtAAoAAAI/jI+pywwNozSP1gDyyZcjb3UaRpXkWaXmZW4OqKLhBmLs+K263DkJK7OJeifh7FicKD9A1/IpGdKkyFpNmCkAADs=)}.mce-visualblocks pre{background-image:url(data:image/gif;base64,R0lGODlhFQAKAIABALu7uwAAACH5BAEAAAEALAAAAAAVAAoAAAIjjI+ZoN0cgDwSmnpz1NCueYERhnibZVKLNnbOq8IvKpJtVQAAOw==)}.mce-visualblocks figure{background-image:url(data:image/gif;base64,R0lGODlhJAAKAIAAALu7u////yH5BAEAAAEALAAAAAAkAAoAAAI0jI+py+2fwAHUSFvD3RlvG4HIp4nX5JFSpnZUJ6LlrM52OE7uSWosBHScgkSZj7dDKnWAAgA7)}.mce-visualblocks figcaption{border:1px dashed #bbb}.mce-visualblocks hgroup{background-image:url(data:image/gif;base64,R0lGODlhJwAKAIABALu7uwAAACH5BAEAAAEALAAAAAAnAAoAAAI3jI+pywYNI3uB0gpsRtt5fFnfNZaVSYJil4Wo03Hv6Z62uOCgiXH1kZIIJ8NiIxRrAZNMZAtQAAA7)}.mce-visualblocks aside{background-image:url(data:image/gif;base64,R0lGODlhHgAKAIABAKqqqv///yH5BAEAAAEALAAAAAAeAAoAAAItjI+pG8APjZOTzgtqy7I3f1yehmQcFY4WKZbqByutmW4aHUd6vfcVbgudgpYCADs=)}.mce-visualblocks ul{background-image:url(data:image/gif;base64,R0lGODlhDQAKAIAAALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybGuYnqUVSjvw26DzzXiqIDlVwAAOw==)}.mce-visualblocks ol{background-image:url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybH6HHt0qourxC6CvzXieHyeWQAAOw==)}.mce-visualblocks dl{background-image:url(data:image/gif;base64,R0lGODlhDQAKAIABALu7u////yH5BAEAAAEALAAAAAANAAoAAAIXjI8GybEOnmOvUoWznTqeuEjNSCqeGRUAOw==)}.mce-visualblocks:not([dir=rtl]) address,.mce-visualblocks:not([dir=rtl]) article,.mce-visualblocks:not([dir=rtl]) aside,.mce-visualblocks:not([dir=rtl]) blockquote,.mce-visualblocks:not([dir=rtl]) div:not([data-mce-bogus]),.mce-visualblocks:not([dir=rtl]) dl,.mce-visualblocks:not([dir=rtl]) figcaption,.mce-visualblocks:not([dir=rtl]) figure,.mce-visualblocks:not([dir=rtl]) h1,.mce-visualblocks:not([dir=rtl]) h2,.mce-visualblocks:not([dir=rtl]) h3,.mce-visualblocks:not([dir=rtl]) h4,.mce-visualblocks:not([dir=rtl]) h5,.mce-visualblocks:not([dir=rtl]) h6,.mce-visualblocks:not([dir=rtl]) hgroup,.mce-visualblocks:not([dir=rtl]) ol,.mce-visualblocks:not([dir=rtl]) p,.mce-visualblocks:not([dir=rtl]) pre,.mce-visualblocks:not([dir=rtl]) section,.mce-visualblocks:not([dir=rtl]) ul{margin-left:3px}.mce-visualblocks[dir=rtl] address,.mce-visualblocks[dir=rtl] article,.mce-visualblocks[dir=rtl] aside,.mce-visualblocks[dir=rtl] blockquote,.mce-visualblocks[dir=rtl] div:not([data-mce-bogus]),.mce-visualblocks[dir=rtl] dl,.mce-visualblocks[dir=rtl] figcaption,.mce-visualblocks[dir=rtl] figure,.mce-visualblocks[dir=rtl] h1,.mce-visualblocks[dir=rtl] h2,.mce-visualblocks[dir=rtl] h3,.mce-visualblocks[dir=rtl] h4,.mce-visualblocks[dir=rtl] h5,.mce-visualblocks[dir=rtl] h6,.mce-visualblocks[dir=rtl] hgroup,.mce-visualblocks[dir=rtl] ol,.mce-visualblocks[dir=rtl] p,.mce-visualblocks[dir=rtl] pre,.mce-visualblocks[dir=rtl] section,.mce-visualblocks[dir=rtl] ul{background-position-x:right;margin-right:3px}.mce-nbsp,.mce-shy{background:#aaa}.mce-shy::after{content:'-'}body{font-family:sans-serif}table{border-collapse:collapse} diff --git a/ui/public/tinymce/skins/ui/oxide/content.mobile.css b/ui/public/tinymce/skins/ui/oxide/content.mobile.css new file mode 100644 index 0000000..4bdb8ba --- /dev/null +++ b/ui/public/tinymce/skins/ui/oxide/content.mobile.css @@ -0,0 +1,29 @@ +/** + * Copyright (c) Tiny Technologies, Inc. All rights reserved. + * Licensed under the LGPL or a commercial license. + * For LGPL see License.txt in the project root for license information. + * For commercial licenses see https://www.tiny.cloud/ + */ +.tinymce-mobile-unfocused-selections .tinymce-mobile-unfocused-selection { + /* Note: this file is used inside the content, so isn't part of theming */ + background-color: green; + display: inline-block; + opacity: 0.5; + position: absolute; +} +body { + -webkit-text-size-adjust: none; +} +body img { + /* this is related to the content margin */ + max-width: 96vw; +} +body table img { + max-width: 95%; +} +body { + font-family: sans-serif; +} +table { + border-collapse: collapse; +} diff --git a/ui/public/tinymce/skins/ui/oxide/content.mobile.min.css b/ui/public/tinymce/skins/ui/oxide/content.mobile.min.css new file mode 100644 index 0000000..35f7dc0 --- /dev/null +++ b/ui/public/tinymce/skins/ui/oxide/content.mobile.min.css @@ -0,0 +1,7 @@ +/** + * Copyright (c) Tiny Technologies, Inc. All rights reserved. + * Licensed under the LGPL or a commercial license. + * For LGPL see License.txt in the project root for license information. + * For commercial licenses see https://www.tiny.cloud/ + */ +.tinymce-mobile-unfocused-selections .tinymce-mobile-unfocused-selection{background-color:green;display:inline-block;opacity:.5;position:absolute}body{-webkit-text-size-adjust:none}body img{max-width:96vw}body table img{max-width:95%}body{font-family:sans-serif}table{border-collapse:collapse} diff --git a/ui/public/tinymce/skins/ui/oxide/fonts/tinymce-mobile.woff b/ui/public/tinymce/skins/ui/oxide/fonts/tinymce-mobile.woff new file mode 100644 index 0000000..1e3be03 Binary files /dev/null and b/ui/public/tinymce/skins/ui/oxide/fonts/tinymce-mobile.woff differ diff --git a/ui/public/tinymce/skins/ui/oxide/skin.css b/ui/public/tinymce/skins/ui/oxide/skin.css new file mode 100644 index 0000000..49a82fa --- /dev/null +++ b/ui/public/tinymce/skins/ui/oxide/skin.css @@ -0,0 +1,3047 @@ +/** + * Copyright (c) Tiny Technologies, Inc. All rights reserved. + * Licensed under the LGPL or a commercial license. + * For LGPL see License.txt in the project root for license information. + * For commercial licenses see https://www.tiny.cloud/ + */ +.tox { + box-shadow: none; + box-sizing: content-box; + color: #222f3e; + cursor: auto; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + font-size: 16px; + font-style: normal; + font-weight: normal; + line-height: normal; + -webkit-tap-highlight-color: transparent; + text-decoration: none; + text-shadow: none; + text-transform: none; + vertical-align: initial; + white-space: normal; +} +.tox *:not(svg):not(rect) { + box-sizing: inherit; + color: inherit; + cursor: inherit; + direction: inherit; + font-family: inherit; + font-size: inherit; + font-style: inherit; + font-weight: inherit; + line-height: inherit; + -webkit-tap-highlight-color: inherit; + text-align: inherit; + text-decoration: inherit; + text-shadow: inherit; + text-transform: inherit; + vertical-align: inherit; + white-space: inherit; +} +.tox *:not(svg):not(rect) { + /* stylelint-disable-line no-duplicate-selectors */ + background: transparent; + border: 0; + box-shadow: none; + float: none; + height: auto; + margin: 0; + max-width: none; + outline: 0; + padding: 0; + position: static; + width: auto; +} +.tox:not([dir=rtl]) { + direction: ltr; + text-align: left; +} +.tox[dir=rtl] { + direction: rtl; + text-align: right; +} +.tox-tinymce { + border: 1px solid #cccccc; + border-radius: 0; + box-shadow: none; + box-sizing: border-box; + display: flex; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + overflow: hidden; + position: relative; + visibility: inherit !important; +} +.tox-tinymce-inline { + border: none; + box-shadow: none; +} +.tox-tinymce-inline .tox-editor-header { + background-color: transparent; + border: 1px solid #cccccc; + border-radius: 0; + box-shadow: none; +} +.tox-tinymce-aux { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + z-index: 1300; +} +.tox-tinymce *:focus, +.tox-tinymce-aux *:focus { + outline: none; +} +button::-moz-focus-inner { + border: 0; +} +.tox[dir=rtl] .tox-icon--flip svg { + transform: rotateY(180deg); +} +.tox .accessibility-issue__header { + align-items: center; + display: flex; + margin-bottom: 4px; +} +.tox .accessibility-issue__description { + align-items: stretch; + border: 1px solid #cccccc; + border-radius: 3px; + display: flex; + justify-content: space-between; +} +.tox .accessibility-issue__description > div { + padding-bottom: 4px; +} +.tox .accessibility-issue__description > div > div { + align-items: center; + display: flex; + margin-bottom: 4px; +} +.tox .accessibility-issue__description > *:last-child:not(:only-child) { + border-color: #cccccc; + border-style: solid; +} +.tox .accessibility-issue__repair { + margin-top: 16px; +} +.tox .tox-dialog__body-content .accessibility-issue--info .accessibility-issue__description { + background-color: rgba(32, 122, 183, 0.1); + border-color: rgba(32, 122, 183, 0.4); + color: #222f3e; +} +.tox .tox-dialog__body-content .accessibility-issue--info .accessibility-issue__description > *:last-child { + border-color: rgba(32, 122, 183, 0.4); +} +.tox .tox-dialog__body-content .accessibility-issue--info .tox-form__group h2 { + color: #207ab7; +} +.tox .tox-dialog__body-content .accessibility-issue--info .tox-icon svg { + fill: #207ab7; +} +.tox .tox-dialog__body-content .accessibility-issue--info a .tox-icon { + color: #207ab7; +} +.tox .tox-dialog__body-content .accessibility-issue--warn .accessibility-issue__description { + background-color: rgba(255, 165, 0, 0.1); + border-color: rgba(255, 165, 0, 0.5); + color: #222f3e; +} +.tox .tox-dialog__body-content .accessibility-issue--warn .accessibility-issue__description > *:last-child { + border-color: rgba(255, 165, 0, 0.5); +} +.tox .tox-dialog__body-content .accessibility-issue--warn .tox-form__group h2 { + color: #cc8500; +} +.tox .tox-dialog__body-content .accessibility-issue--warn .tox-icon svg { + fill: #cc8500; +} +.tox .tox-dialog__body-content .accessibility-issue--warn a .tox-icon { + color: #cc8500; +} +.tox .tox-dialog__body-content .accessibility-issue--error .accessibility-issue__description { + background-color: rgba(204, 0, 0, 0.1); + border-color: rgba(204, 0, 0, 0.4); + color: #222f3e; +} +.tox .tox-dialog__body-content .accessibility-issue--error .accessibility-issue__description > *:last-child { + border-color: rgba(204, 0, 0, 0.4); +} +.tox .tox-dialog__body-content .accessibility-issue--error .tox-form__group h2 { + color: #c00; +} +.tox .tox-dialog__body-content .accessibility-issue--error .tox-icon svg { + fill: #c00; +} +.tox .tox-dialog__body-content .accessibility-issue--error a .tox-icon { + color: #c00; +} +.tox .tox-dialog__body-content .accessibility-issue--success .accessibility-issue__description { + background-color: rgba(120, 171, 70, 0.1); + border-color: rgba(120, 171, 70, 0.4); + color: #222f3e; +} +.tox .tox-dialog__body-content .accessibility-issue--success .accessibility-issue__description > *:last-child { + border-color: rgba(120, 171, 70, 0.4); +} +.tox .tox-dialog__body-content .accessibility-issue--success .tox-form__group h2 { + color: #78AB46; +} +.tox .tox-dialog__body-content .accessibility-issue--success .tox-icon svg { + fill: #78AB46; +} +.tox .tox-dialog__body-content .accessibility-issue--success a .tox-icon { + color: #78AB46; +} +.tox .tox-dialog__body-content .accessibility-issue__header h1, +.tox .tox-dialog__body-content .tox-form__group .accessibility-issue__description h2 { + margin-top: 0; +} +.tox:not([dir=rtl]) .tox-dialog__body-content .accessibility-issue__header .tox-button { + margin-left: 4px; +} +.tox:not([dir=rtl]) .tox-dialog__body-content .accessibility-issue__header > *:nth-last-child(2) { + margin-left: auto; +} +.tox:not([dir=rtl]) .tox-dialog__body-content .accessibility-issue__description { + padding: 4px 4px 4px 8px; +} +.tox:not([dir=rtl]) .tox-dialog__body-content .accessibility-issue__description > *:last-child { + border-left-width: 1px; + padding-left: 4px; +} +.tox[dir=rtl] .tox-dialog__body-content .accessibility-issue__header .tox-button { + margin-right: 4px; +} +.tox[dir=rtl] .tox-dialog__body-content .accessibility-issue__header > *:nth-last-child(2) { + margin-right: auto; +} +.tox[dir=rtl] .tox-dialog__body-content .accessibility-issue__description { + padding: 4px 8px 4px 4px; +} +.tox[dir=rtl] .tox-dialog__body-content .accessibility-issue__description > *:last-child { + border-right-width: 1px; + padding-right: 4px; +} +.tox .tox-anchorbar { + display: flex; + flex: 0 0 auto; +} +.tox .tox-bar { + display: flex; + flex: 0 0 auto; +} +.tox .tox-button { + background-color: #207ab7; + background-image: none; + background-position: 0 0; + background-repeat: repeat; + border-color: #207ab7; + border-radius: 3px; + border-style: solid; + border-width: 1px; + box-shadow: none; + box-sizing: border-box; + color: #fff; + cursor: pointer; + display: inline-block; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + font-size: 14px; + font-style: normal; + font-weight: bold; + letter-spacing: normal; + line-height: 24px; + margin: 0; + outline: none; + padding: 4px 16px; + text-align: center; + text-decoration: none; + text-transform: none; + white-space: nowrap; +} +.tox .tox-button[disabled] { + background-color: #207ab7; + background-image: none; + border-color: #207ab7; + box-shadow: none; + color: rgba(255, 255, 255, 0.5); + cursor: not-allowed; +} +.tox .tox-button:focus:not(:disabled) { + background-color: #1c6ca1; + background-image: none; + border-color: #1c6ca1; + box-shadow: none; + color: #fff; +} +.tox .tox-button:hover:not(:disabled) { + background-color: #1c6ca1; + background-image: none; + border-color: #1c6ca1; + box-shadow: none; + color: #fff; +} +.tox .tox-button:active:not(:disabled) { + background-color: #185d8c; + background-image: none; + border-color: #185d8c; + box-shadow: none; + color: #fff; +} +.tox .tox-button--secondary { + background-color: #f0f0f0; + background-image: none; + background-position: 0 0; + background-repeat: repeat; + border-color: #f0f0f0; + border-radius: 3px; + border-style: solid; + border-width: 1px; + box-shadow: none; + color: #222f3e; + font-size: 14px; + font-style: normal; + font-weight: bold; + letter-spacing: normal; + outline: none; + padding: 4px 16px; + text-decoration: none; + text-transform: none; +} +.tox .tox-button--secondary[disabled] { + background-color: #f0f0f0; + background-image: none; + border-color: #f0f0f0; + box-shadow: none; + color: rgba(34, 47, 62, 0.5); +} +.tox .tox-button--secondary:focus:not(:disabled) { + background-color: #e3e3e3; + background-image: none; + border-color: #e3e3e3; + box-shadow: none; + color: #222f3e; +} +.tox .tox-button--secondary:hover:not(:disabled) { + background-color: #e3e3e3; + background-image: none; + border-color: #e3e3e3; + box-shadow: none; + color: #222f3e; +} +.tox .tox-button--secondary:active:not(:disabled) { + background-color: #d6d6d6; + background-image: none; + border-color: #d6d6d6; + box-shadow: none; + color: #222f3e; +} +.tox .tox-button--icon, +.tox .tox-button.tox-button--icon, +.tox .tox-button.tox-button--secondary.tox-button--icon { + padding: 4px; +} +.tox .tox-button--icon .tox-icon svg, +.tox .tox-button.tox-button--icon .tox-icon svg, +.tox .tox-button.tox-button--secondary.tox-button--icon .tox-icon svg { + display: block; + fill: currentColor; +} +.tox .tox-button-link { + background: 0; + border: none; + box-sizing: border-box; + cursor: pointer; + display: inline-block; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + font-size: 16px; + font-weight: normal; + line-height: 1.3; + margin: 0; + padding: 0; + white-space: nowrap; +} +.tox .tox-button-link--sm { + font-size: 14px; +} +.tox .tox-button--naked { + background-color: transparent; + border-color: transparent; + box-shadow: unset; + color: #222f3e; +} +.tox .tox-button--naked[disabled] { + background-color: #f0f0f0; + border-color: #f0f0f0; + box-shadow: none; + color: rgba(34, 47, 62, 0.5); +} +.tox .tox-button--naked:hover:not(:disabled) { + background-color: #e3e3e3; + border-color: #e3e3e3; + box-shadow: none; + color: #222f3e; +} +.tox .tox-button--naked:focus:not(:disabled) { + background-color: #e3e3e3; + border-color: #e3e3e3; + box-shadow: none; + color: #222f3e; +} +.tox .tox-button--naked:active:not(:disabled) { + background-color: #d6d6d6; + border-color: #d6d6d6; + box-shadow: none; + color: #222f3e; +} +.tox .tox-button--naked .tox-icon svg { + fill: currentColor; +} +.tox .tox-button--naked.tox-button--icon:hover:not(:disabled) { + color: #222f3e; +} +.tox .tox-checkbox { + align-items: center; + border-radius: 3px; + cursor: pointer; + display: flex; + height: 36px; + min-width: 36px; +} +.tox .tox-checkbox__input { + /* Hide from view but visible to screen readers */ + height: 1px; + overflow: hidden; + position: absolute; + top: auto; + width: 1px; +} +.tox .tox-checkbox__icons { + align-items: center; + border-radius: 3px; + box-shadow: 0 0 0 2px transparent; + box-sizing: content-box; + display: flex; + height: 24px; + justify-content: center; + padding: calc(4px - 1px); + width: 24px; +} +.tox .tox-checkbox__icons .tox-checkbox-icon__unchecked svg { + display: block; + fill: rgba(34, 47, 62, 0.3); +} +.tox .tox-checkbox__icons .tox-checkbox-icon__indeterminate svg { + display: none; + fill: #207ab7; +} +.tox .tox-checkbox__icons .tox-checkbox-icon__checked svg { + display: none; + fill: #207ab7; +} +.tox .tox-checkbox--disabled { + color: rgba(34, 47, 62, 0.5); + cursor: not-allowed; +} +.tox .tox-checkbox--disabled .tox-checkbox__icons .tox-checkbox-icon__checked svg { + fill: rgba(34, 47, 62, 0.5); +} +.tox .tox-checkbox--disabled .tox-checkbox__icons .tox-checkbox-icon__unchecked svg { + fill: rgba(34, 47, 62, 0.5); +} +.tox .tox-checkbox--disabled .tox-checkbox__icons .tox-checkbox-icon__indeterminate svg { + fill: rgba(34, 47, 62, 0.5); +} +.tox input.tox-checkbox__input:checked + .tox-checkbox__icons .tox-checkbox-icon__unchecked svg { + display: none; +} +.tox input.tox-checkbox__input:checked + .tox-checkbox__icons .tox-checkbox-icon__checked svg { + display: block; +} +.tox input.tox-checkbox__input:indeterminate + .tox-checkbox__icons .tox-checkbox-icon__unchecked svg { + display: none; +} +.tox input.tox-checkbox__input:indeterminate + .tox-checkbox__icons .tox-checkbox-icon__indeterminate svg { + display: block; +} +.tox input.tox-checkbox__input:focus + .tox-checkbox__icons { + border-radius: 3px; + box-shadow: inset 0 0 0 1px #207ab7; + padding: calc(4px - 1px); +} +.tox:not([dir=rtl]) .tox-checkbox__label { + margin-left: 4px; +} +.tox:not([dir=rtl]) .tox-checkbox__input { + left: -10000px; +} +.tox:not([dir=rtl]) .tox-bar .tox-checkbox { + margin-left: 4px; +} +.tox[dir=rtl] .tox-checkbox__label { + margin-right: 4px; +} +.tox[dir=rtl] .tox-checkbox__input { + right: -10000px; +} +.tox[dir=rtl] .tox-bar .tox-checkbox { + margin-right: 4px; +} +.tox { + /* stylelint-disable-next-line no-descending-specificity */ +} +.tox .tox-collection--toolbar .tox-collection__group { + display: flex; + padding: 0; +} +.tox .tox-collection--grid .tox-collection__group { + display: flex; + flex-wrap: wrap; + max-height: 208px; + overflow-x: hidden; + overflow-y: auto; + padding: 0; +} +.tox .tox-collection--list .tox-collection__group { + border-bottom-width: 0; + border-color: #cccccc; + border-left-width: 0; + border-right-width: 0; + border-style: solid; + border-top-width: 1px; + padding: 4px 0; +} +.tox .tox-collection--list .tox-collection__group:first-child { + border-top-width: 0; +} +.tox .tox-collection__group-heading { + background-color: #e6e6e6; + color: rgba(34, 47, 62, 0.7); + cursor: default; + font-size: 12px; + font-style: normal; + font-weight: normal; + margin-bottom: 4px; + margin-top: -4px; + padding: 4px 8px; + text-transform: none; + -webkit-touch-callout: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} +.tox .tox-collection__item { + align-items: center; + color: #222f3e; + cursor: pointer; + display: flex; + -webkit-touch-callout: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} +.tox .tox-collection--list .tox-collection__item { + padding: 4px 8px; +} +.tox .tox-collection--toolbar .tox-collection__item { + border-radius: 3px; + padding: 4px; +} +.tox .tox-collection--grid .tox-collection__item { + border-radius: 3px; + padding: 4px; +} +.tox .tox-collection--list .tox-collection__item--enabled { + background-color: #fff; + color: #222f3e; +} +.tox .tox-collection--list .tox-collection__item--active { + background-color: #dee0e2; +} +.tox .tox-collection--toolbar .tox-collection__item--enabled { + background-color: #c8cbcf; + color: #222f3e; +} +.tox .tox-collection--toolbar .tox-collection__item--active { + background-color: #dee0e2; +} +.tox .tox-collection--grid .tox-collection__item--enabled { + background-color: #c8cbcf; + color: #222f3e; +} +.tox .tox-collection--grid .tox-collection__item--active:not(.tox-collection__item--state-disabled) { + background-color: #dee0e2; + color: #222f3e; +} +.tox .tox-collection--list .tox-collection__item--active:not(.tox-collection__item--state-disabled) { + color: #222f3e; +} +.tox .tox-collection--toolbar .tox-collection__item--active:not(.tox-collection__item--state-disabled) { + color: #222f3e; +} +.tox .tox-collection__item-icon, +.tox .tox-collection__item-checkmark { + align-items: center; + display: flex; + height: 24px; + justify-content: center; + width: 24px; +} +.tox .tox-collection__item-icon svg, +.tox .tox-collection__item-checkmark svg { + fill: currentColor; +} +.tox .tox-collection--toolbar-lg .tox-collection__item-icon { + height: 48px; + width: 48px; +} +.tox .tox-collection__item-label { + color: currentColor; + display: inline-block; + flex: 1; + -ms-flex-preferred-size: auto; + font-size: 14px; + font-style: normal; + font-weight: normal; + line-height: 24px; + text-transform: none; + word-break: break-all; +} +.tox .tox-collection__item-accessory { + color: rgba(34, 47, 62, 0.7); + display: inline-block; + font-size: 14px; + height: 24px; + line-height: 24px; + text-transform: none; +} +.tox .tox-collection__item-caret { + align-items: center; + display: flex; + min-height: 24px; +} +.tox .tox-collection__item-caret::after { + content: ''; + font-size: 0; + min-height: inherit; +} +.tox .tox-collection__item-caret svg { + fill: #222f3e; +} +.tox .tox-collection__item--state-disabled { + background-color: transparent; + color: rgba(34, 47, 62, 0.5); + cursor: not-allowed; +} +.tox .tox-collection__item--state-disabled .tox-collection__item-caret svg { + fill: rgba(34, 47, 62, 0.5); +} +.tox .tox-collection--list .tox-collection__item:not(.tox-collection__item--enabled) .tox-collection__item-checkmark svg { + display: none; +} +.tox .tox-collection--list .tox-collection__item:not(.tox-collection__item--enabled) .tox-collection__item-accessory + .tox-collection__item-checkmark { + display: none; +} +.tox .tox-collection--horizontal { + background-color: #fff; + border: 1px solid #cccccc; + border-radius: 3px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); + display: flex; + flex: 0 0 auto; + flex-shrink: 0; + flex-wrap: nowrap; + margin-bottom: 0; + overflow-x: auto; + padding: 0; +} +.tox .tox-collection--horizontal .tox-collection__group { + align-items: center; + display: flex; + flex-wrap: nowrap; + margin: 0; + padding: 0 4px; +} +.tox .tox-collection--horizontal .tox-collection__item { + height: 34px; + margin: 2px 0 3px 0; + padding: 0 4px; +} +.tox .tox-collection--horizontal .tox-collection__item-label { + white-space: nowrap; +} +.tox .tox-collection--horizontal .tox-collection__item-caret { + margin-left: 4px; +} +.tox .tox-collection__item-container { + display: flex; +} +.tox .tox-collection__item-container--row { + align-items: center; + flex: 1 1 auto; + flex-direction: row; +} +.tox .tox-collection__item-container--row.tox-collection__item-container--align-left { + margin-right: auto; +} +.tox .tox-collection__item-container--row.tox-collection__item-container--align-right { + justify-content: flex-end; + margin-left: auto; +} +.tox .tox-collection__item-container--row.tox-collection__item-container--valign-top { + align-items: flex-start; + margin-bottom: auto; +} +.tox .tox-collection__item-container--row.tox-collection__item-container--valign-middle { + align-items: center; +} +.tox .tox-collection__item-container--row.tox-collection__item-container--valign-bottom { + align-items: flex-end; + margin-top: auto; +} +.tox .tox-collection__item-container--column { + -ms-grid-row-align: center; + align-self: center; + flex: 1 1 auto; + flex-direction: column; +} +.tox .tox-collection__item-container--column.tox-collection__item-container--align-left { + align-items: flex-start; +} +.tox .tox-collection__item-container--column.tox-collection__item-container--align-right { + align-items: flex-end; +} +.tox .tox-collection__item-container--column.tox-collection__item-container--valign-top { + align-self: flex-start; +} +.tox .tox-collection__item-container--column.tox-collection__item-container--valign-middle { + -ms-grid-row-align: center; + align-self: center; +} +.tox .tox-collection__item-container--column.tox-collection__item-container--valign-bottom { + align-self: flex-end; +} +.tox:not([dir=rtl]) .tox-collection--horizontal .tox-collection__group:not(:last-of-type) { + border-right: 1px solid #cccccc; +} +.tox:not([dir=rtl]) .tox-collection--list .tox-collection__item > *:not(:first-child) { + margin-left: 8px; +} +.tox:not([dir=rtl]) .tox-collection--list .tox-collection__item > .tox-collection__item-label:first-child { + margin-left: 4px; +} +.tox:not([dir=rtl]) .tox-collection__item-accessory { + margin-left: 16px; + text-align: right; +} +.tox:not([dir=rtl]) .tox-collection .tox-collection__item-caret { + margin-left: 16px; +} +.tox[dir=rtl] .tox-collection--horizontal .tox-collection__group:not(:last-of-type) { + border-left: 1px solid #cccccc; +} +.tox[dir=rtl] .tox-collection--list .tox-collection__item > *:not(:first-child) { + margin-right: 8px; +} +.tox[dir=rtl] .tox-collection--list .tox-collection__item > .tox-collection__item-label:first-child { + margin-right: 4px; +} +.tox[dir=rtl] .tox-collection__item-accessory { + margin-right: 16px; + text-align: left; +} +.tox[dir=rtl] .tox-collection .tox-collection__item-caret { + margin-right: 16px; + transform: rotateY(180deg); +} +.tox[dir=rtl] .tox-collection--horizontal .tox-collection__item-caret { + margin-right: 4px; +} +.tox .tox-color-picker-container { + display: flex; + flex-direction: row; + height: 225px; + margin: 0; +} +.tox .tox-sv-palette { + box-sizing: border-box; + display: flex; + height: 100%; +} +.tox .tox-sv-palette-spectrum { + height: 100%; +} +.tox .tox-sv-palette, +.tox .tox-sv-palette-spectrum { + width: 225px; +} +.tox .tox-sv-palette-thumb { + background: none; + border: 1px solid black; + border-radius: 50%; + box-sizing: content-box; + height: 12px; + position: absolute; + width: 12px; +} +.tox .tox-sv-palette-inner-thumb { + border: 1px solid white; + border-radius: 50%; + height: 10px; + position: absolute; + width: 10px; +} +.tox .tox-hue-slider { + box-sizing: border-box; + height: 100%; + width: 25px; +} +.tox .tox-hue-slider-spectrum { + background: linear-gradient(to bottom, #f00, #ff0080, #f0f, #8000ff, #00f, #0080ff, #0ff, #00ff80, #0f0, #80ff00, #ff0, #ff8000, #f00); + height: 100%; + width: 100%; +} +.tox .tox-hue-slider, +.tox .tox-hue-slider-spectrum { + width: 20px; +} +.tox .tox-hue-slider-thumb { + background: white; + border: 1px solid black; + box-sizing: content-box; + height: 4px; + width: 100%; +} +.tox .tox-rgb-form { + display: flex; + flex-direction: column; + justify-content: space-between; +} +.tox .tox-rgb-form div { + align-items: center; + display: flex; + justify-content: space-between; + margin-bottom: 5px; + width: inherit; +} +.tox .tox-rgb-form input { + width: 6em; +} +.tox .tox-rgb-form input.tox-invalid { + /* Need !important to override Chrome's focus styling unfortunately */ + border: 1px solid red !important; +} +.tox .tox-rgb-form .tox-rgba-preview { + border: 1px solid black; + flex-grow: 2; + margin-bottom: 0; +} +.tox:not([dir=rtl]) .tox-sv-palette { + margin-right: 15px; +} +.tox:not([dir=rtl]) .tox-hue-slider { + margin-right: 15px; +} +.tox:not([dir=rtl]) .tox-hue-slider-thumb { + margin-left: -1px; +} +.tox:not([dir=rtl]) .tox-rgb-form label { + margin-right: 0.5em; +} +.tox[dir=rtl] .tox-sv-palette { + margin-left: 15px; +} +.tox[dir=rtl] .tox-hue-slider { + margin-left: 15px; +} +.tox[dir=rtl] .tox-hue-slider-thumb { + margin-right: -1px; +} +.tox[dir=rtl] .tox-rgb-form label { + margin-left: 0.5em; +} +.tox .tox-toolbar .tox-swatches, +.tox .tox-toolbar__primary .tox-swatches, +.tox .tox-toolbar__overflow .tox-swatches { + margin: 2px 0 3px 4px; +} +.tox .tox-collection--list .tox-collection__group .tox-swatches-menu { + border: 0; + margin: -4px 0; +} +.tox .tox-swatches__row { + display: flex; +} +.tox .tox-swatch { + height: 30px; + transition: transform 0.15s, box-shadow 0.15s; + width: 30px; +} +.tox .tox-swatch:hover, +.tox .tox-swatch:focus { + box-shadow: 0 0 0 1px rgba(127, 127, 127, 0.3) inset; + transform: scale(0.8); +} +.tox .tox-swatch--remove { + align-items: center; + display: flex; + justify-content: center; +} +.tox .tox-swatch--remove svg path { + stroke: #e74c3c; +} +.tox .tox-swatches__picker-btn { + align-items: center; + background-color: transparent; + border: 0; + cursor: pointer; + display: flex; + height: 30px; + justify-content: center; + outline: none; + padding: 0; + width: 30px; +} +.tox .tox-swatches__picker-btn svg { + height: 24px; + width: 24px; +} +.tox .tox-swatches__picker-btn:hover { + background: #dee0e2; +} +.tox:not([dir=rtl]) .tox-swatches__picker-btn { + margin-left: auto; +} +.tox[dir=rtl] .tox-swatches__picker-btn { + margin-right: auto; +} +.tox .tox-comment-thread { + background: #fff; + position: relative; +} +.tox .tox-comment-thread > *:not(:first-child) { + margin-top: 8px; +} +.tox .tox-comment { + background: #fff; + border: 1px solid #cccccc; + border-radius: 3px; + box-shadow: 0 4px 8px 0 rgba(34, 47, 62, 0.1); + padding: 8px 8px 16px 8px; + position: relative; +} +.tox .tox-comment__header { + align-items: center; + color: #222f3e; + display: flex; + justify-content: space-between; +} +.tox .tox-comment__date { + color: rgba(34, 47, 62, 0.7); + font-size: 12px; +} +.tox .tox-comment__body { + color: #222f3e; + font-size: 14px; + font-style: normal; + font-weight: normal; + line-height: 1.3; + margin-top: 8px; + position: relative; + text-transform: initial; +} +.tox .tox-comment__body textarea { + resize: none; + white-space: normal; + width: 100%; +} +.tox .tox-comment__expander { + padding-top: 8px; +} +.tox .tox-comment__expander p { + color: rgba(34, 47, 62, 0.7); + font-size: 14px; + font-style: normal; +} +.tox .tox-comment__body p { + margin: 0; +} +.tox .tox-comment__buttonspacing { + padding-top: 16px; + text-align: center; +} +.tox .tox-comment-thread__overlay::after { + background: #fff; + bottom: 0; + content: ""; + display: flex; + left: 0; + opacity: 0.9; + position: absolute; + right: 0; + top: 0; + z-index: 5; +} +.tox .tox-comment__reply { + display: flex; + flex-shrink: 0; + flex-wrap: wrap; + justify-content: flex-end; + margin-top: 8px; +} +.tox .tox-comment__reply > *:first-child { + margin-bottom: 8px; + width: 100%; +} +.tox .tox-comment__edit { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + margin-top: 16px; +} +.tox .tox-comment__gradient::after { + background: linear-gradient(rgba(255, 255, 255, 0), #fff); + bottom: 0; + content: ""; + display: block; + height: 5em; + margin-top: -40px; + position: absolute; + width: 100%; +} +.tox .tox-comment__overlay { + background: #fff; + bottom: 0; + display: flex; + flex-direction: column; + flex-grow: 1; + left: 0; + opacity: 0.9; + position: absolute; + right: 0; + text-align: center; + top: 0; + z-index: 5; +} +.tox .tox-comment__loading-text { + align-items: center; + color: #222f3e; + display: flex; + flex-direction: column; + position: relative; +} +.tox .tox-comment__loading-text > div { + padding-bottom: 16px; +} +.tox .tox-comment__overlaytext { + bottom: 0; + flex-direction: column; + font-size: 14px; + left: 0; + padding: 1em; + position: absolute; + right: 0; + top: 0; + z-index: 10; +} +.tox .tox-comment__overlaytext p { + background-color: #fff; + box-shadow: 0 0 8px 8px #fff; + color: #222f3e; + text-align: center; +} +.tox .tox-comment__overlaytext div:nth-of-type(2) { + font-size: 0.8em; +} +.tox .tox-comment__busy-spinner { + align-items: center; + background-color: #fff; + bottom: 0; + display: flex; + justify-content: center; + left: 0; + position: absolute; + right: 0; + top: 0; + z-index: 20; +} +.tox .tox-comment__scroll { + display: flex; + flex-direction: column; + flex-shrink: 1; + overflow: auto; +} +.tox .tox-conversations { + margin: 8px; +} +.tox:not([dir=rtl]) .tox-comment__edit { + margin-left: 8px; +} +.tox:not([dir=rtl]) .tox-comment__buttonspacing > *:last-child, +.tox:not([dir=rtl]) .tox-comment__edit > *:last-child, +.tox:not([dir=rtl]) .tox-comment__reply > *:last-child { + margin-left: 8px; +} +.tox[dir=rtl] .tox-comment__edit { + margin-right: 8px; +} +.tox[dir=rtl] .tox-comment__buttonspacing > *:last-child, +.tox[dir=rtl] .tox-comment__edit > *:last-child, +.tox[dir=rtl] .tox-comment__reply > *:last-child { + margin-right: 8px; +} +.tox .tox-user { + align-items: center; + display: flex; +} +.tox .tox-user__avatar svg { + fill: rgba(34, 47, 62, 0.7); +} +.tox .tox-user__name { + color: rgba(34, 47, 62, 0.7); + font-size: 12px; + font-style: normal; + font-weight: bold; + text-transform: uppercase; +} +.tox:not([dir=rtl]) .tox-user__avatar svg { + margin-right: 8px; +} +.tox:not([dir=rtl]) .tox-user__avatar + .tox-user__name { + margin-left: 8px; +} +.tox[dir=rtl] .tox-user__avatar svg { + margin-left: 8px; +} +.tox[dir=rtl] .tox-user__avatar + .tox-user__name { + margin-right: 8px; +} +.tox .tox-dialog-wrap { + align-items: center; + bottom: 0; + display: flex; + justify-content: center; + left: 0; + position: fixed; + right: 0; + top: 0; + z-index: 1100; +} +.tox .tox-dialog-wrap__backdrop { + background-color: rgba(255, 255, 255, 0.75); + bottom: 0; + left: 0; + position: absolute; + right: 0; + top: 0; + z-index: 1; +} +.tox .tox-dialog-wrap__backdrop--opaque { + background-color: #fff; +} +.tox .tox-dialog { + background-color: #fff; + border-color: #cccccc; + border-radius: 3px; + border-style: solid; + border-width: 1px; + box-shadow: 0 16px 16px -10px rgba(34, 47, 62, 0.15), 0 0 40px 1px rgba(34, 47, 62, 0.15); + display: flex; + flex-direction: column; + max-height: 100%; + max-width: 480px; + overflow: hidden; + position: relative; + width: 95vw; + z-index: 2; +} +@media only screen and (max-width:767px) { + body:not(.tox-force-desktop) .tox .tox-dialog { + align-self: flex-start; + margin: 8px auto; + width: calc(100vw - 16px); + } +} +.tox .tox-dialog-inline { + z-index: 1100; +} +.tox .tox-dialog__header { + align-items: center; + background-color: #fff; + border-bottom: none; + color: #222f3e; + display: flex; + font-size: 16px; + justify-content: space-between; + padding: 8px 16px 0 16px; + position: relative; +} +.tox .tox-dialog__header .tox-button { + z-index: 1; +} +.tox .tox-dialog__draghandle { + cursor: grab; + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; +} +.tox .tox-dialog__draghandle:active { + cursor: grabbing; +} +.tox .tox-dialog__dismiss { + margin-left: auto; +} +.tox .tox-dialog__title { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + font-size: 20px; + font-style: normal; + font-weight: normal; + line-height: 1.3; + margin: 0; + text-transform: none; +} +.tox .tox-dialog__body { + color: #222f3e; + display: flex; + flex: 1; + -ms-flex-preferred-size: auto; + font-size: 16px; + font-style: normal; + font-weight: normal; + line-height: 1.3; + min-width: 0; + text-align: left; + text-transform: none; +} +@media only screen and (max-width:767px) { + body:not(.tox-force-desktop) .tox .tox-dialog__body { + flex-direction: column; + } +} +.tox .tox-dialog__body-nav { + align-items: flex-start; + display: flex; + flex-direction: column; + padding: 16px 16px; +} +@media only screen and (max-width:767px) { + body:not(.tox-force-desktop) .tox .tox-dialog__body-nav { + flex-direction: row; + -webkit-overflow-scrolling: touch; + overflow-x: auto; + padding-bottom: 0; + } +} +.tox .tox-dialog__body-nav-item { + border-bottom: 2px solid transparent; + color: rgba(34, 47, 62, 0.7); + display: inline-block; + font-size: 14px; + line-height: 1.3; + margin-bottom: 8px; + text-decoration: none; + white-space: nowrap; +} +.tox .tox-dialog__body-nav-item:focus { + background-color: rgba(32, 122, 183, 0.1); +} +.tox .tox-dialog__body-nav-item--active { + border-bottom: 2px solid #207ab7; + color: #207ab7; +} +.tox .tox-dialog__body-content { + box-sizing: border-box; + display: flex; + flex: 1; + flex-direction: column; + -ms-flex-preferred-size: auto; + max-height: 650px; + overflow: auto; + -webkit-overflow-scrolling: touch; + padding: 16px 16px; +} +.tox .tox-dialog__body-content > * { + margin-bottom: 0; + margin-top: 16px; +} +.tox .tox-dialog__body-content > *:first-child { + margin-top: 0; +} +.tox .tox-dialog__body-content > *:last-child { + margin-bottom: 0; +} +.tox .tox-dialog__body-content > *:only-child { + margin-bottom: 0; + margin-top: 0; +} +.tox .tox-dialog__body-content a { + color: #207ab7; + cursor: pointer; + text-decoration: none; +} +.tox .tox-dialog__body-content a:hover, +.tox .tox-dialog__body-content a:focus { + color: #185d8c; + text-decoration: none; +} +.tox .tox-dialog__body-content a:active { + color: #185d8c; + text-decoration: none; +} +.tox .tox-dialog__body-content svg { + fill: #222f3e; +} +.tox .tox-dialog__body-content ul { + display: block; + list-style-type: disc; + margin-bottom: 16px; + -webkit-margin-end: 0; + margin-inline-end: 0; + -webkit-margin-start: 0; + margin-inline-start: 0; + -webkit-padding-start: 2.5rem; + padding-inline-start: 2.5rem; +} +.tox .tox-dialog__body-content .tox-form__group h1 { + color: #222f3e; + font-size: 20px; + font-style: normal; + font-weight: bold; + letter-spacing: normal; + margin-bottom: 16px; + margin-top: 2rem; + text-transform: none; +} +.tox .tox-dialog__body-content .tox-form__group h2 { + color: #222f3e; + font-size: 16px; + font-style: normal; + font-weight: bold; + letter-spacing: normal; + margin-bottom: 16px; + margin-top: 2rem; + text-transform: none; +} +.tox .tox-dialog__body-content .tox-form__group p { + margin-bottom: 16px; +} +.tox .tox-dialog__body-content .tox-form__group h1:first-child, +.tox .tox-dialog__body-content .tox-form__group h2:first-child, +.tox .tox-dialog__body-content .tox-form__group p:first-child { + margin-top: 0; +} +.tox .tox-dialog__body-content .tox-form__group h1:last-child, +.tox .tox-dialog__body-content .tox-form__group h2:last-child, +.tox .tox-dialog__body-content .tox-form__group p:last-child { + margin-bottom: 0; +} +.tox .tox-dialog__body-content .tox-form__group h1:only-child, +.tox .tox-dialog__body-content .tox-form__group h2:only-child, +.tox .tox-dialog__body-content .tox-form__group p:only-child { + margin-bottom: 0; + margin-top: 0; +} +.tox .tox-dialog--width-lg { + height: 650px; + max-width: 1200px; +} +.tox .tox-dialog--width-md { + max-width: 800px; +} +.tox .tox-dialog--width-md .tox-dialog__body-content { + overflow: auto; +} +.tox .tox-dialog__body-content--centered { + text-align: center; +} +.tox .tox-dialog__footer { + align-items: center; + background-color: #fff; + border-top: 1px solid #cccccc; + display: flex; + justify-content: space-between; + padding: 8px 16px; +} +.tox .tox-dialog__footer-start, +.tox .tox-dialog__footer-end { + display: flex; +} +.tox .tox-dialog__busy-spinner { + align-items: center; + background-color: rgba(255, 255, 255, 0.75); + bottom: 0; + display: flex; + justify-content: center; + left: 0; + position: absolute; + right: 0; + top: 0; + z-index: 3; +} +.tox .tox-dialog__table { + border-collapse: collapse; + width: 100%; +} +.tox .tox-dialog__table thead th { + font-weight: bold; + padding-bottom: 8px; +} +.tox .tox-dialog__table tbody tr { + border-bottom: 1px solid #cccccc; +} +.tox .tox-dialog__table tbody tr:last-child { + border-bottom: none; +} +.tox .tox-dialog__table td { + padding-bottom: 8px; + padding-top: 8px; +} +.tox .tox-dialog__popups { + position: absolute; + width: 100%; + z-index: 1100; +} +.tox .tox-dialog__body-iframe { + display: flex; + flex: 1; + flex-direction: column; + -ms-flex-preferred-size: auto; +} +.tox .tox-dialog__body-iframe .tox-navobj { + display: flex; + flex: 1; + -ms-flex-preferred-size: auto; +} +.tox .tox-dialog__body-iframe .tox-navobj :nth-child(2) { + flex: 1; + -ms-flex-preferred-size: auto; + height: 100%; +} +.tox .tox-dialog-dock-fadeout { + opacity: 0; + visibility: hidden; +} +.tox .tox-dialog-dock-fadein { + opacity: 1; + visibility: visible; +} +.tox .tox-dialog-dock-transition { + transition: visibility 0s linear 0.3s, opacity 0.3s ease; +} +.tox .tox-dialog-dock-transition.tox-dialog-dock-fadein { + transition-delay: 0s; +} +.tox.tox-platform-ie { + /* IE11 CSS styles go here */ +} +.tox.tox-platform-ie .tox-dialog-wrap { + position: -ms-device-fixed; +} +@media only screen and (max-width:767px) { + body:not(.tox-force-desktop) .tox:not([dir=rtl]) .tox-dialog__body-nav { + margin-right: 0; + } +} +@media only screen and (max-width:767px) { + body:not(.tox-force-desktop) .tox:not([dir=rtl]) .tox-dialog__body-nav-item:not(:first-child) { + margin-left: 8px; + } +} +.tox:not([dir=rtl]) .tox-dialog__footer .tox-dialog__footer-start > *, +.tox:not([dir=rtl]) .tox-dialog__footer .tox-dialog__footer-end > * { + margin-left: 8px; +} +.tox[dir=rtl] .tox-dialog__body { + text-align: right; +} +@media only screen and (max-width:767px) { + body:not(.tox-force-desktop) .tox[dir=rtl] .tox-dialog__body-nav { + margin-left: 0; + } +} +@media only screen and (max-width:767px) { + body:not(.tox-force-desktop) .tox[dir=rtl] .tox-dialog__body-nav-item:not(:first-child) { + margin-right: 8px; + } +} +.tox[dir=rtl] .tox-dialog__footer .tox-dialog__footer-start > *, +.tox[dir=rtl] .tox-dialog__footer .tox-dialog__footer-end > * { + margin-right: 8px; +} +body.tox-dialog__disable-scroll { + overflow: hidden; +} +.tox .tox-dropzone-container { + display: flex; + flex: 1; + -ms-flex-preferred-size: auto; +} +.tox .tox-dropzone { + align-items: center; + background: #fff; + border: 2px dashed #cccccc; + box-sizing: border-box; + display: flex; + flex-direction: column; + flex-grow: 1; + justify-content: center; + min-height: 100px; + padding: 10px; +} +.tox .tox-dropzone p { + color: rgba(34, 47, 62, 0.7); + margin: 0 0 16px 0; +} +.tox .tox-edit-area { + display: flex; + flex: 1; + -ms-flex-preferred-size: auto; + overflow: hidden; + position: relative; +} +.tox .tox-edit-area__iframe { + background-color: #fff; + border: 0; + box-sizing: border-box; + flex: 1; + -ms-flex-preferred-size: auto; + height: 100%; + position: absolute; + width: 100%; +} +.tox.tox-inline-edit-area { + border: 1px dotted #cccccc; +} +.tox .tox-editor-container { + display: flex; + flex: 1 1 auto; + flex-direction: column; + overflow: hidden; +} +.tox .tox-editor-header { + z-index: 1; +} +.tox:not(.tox-tinymce-inline) .tox-editor-header { + box-shadow: none; + transition: box-shadow 0.5s; +} +.tox.tox-tinymce--toolbar-bottom .tox-editor-header, +.tox.tox-tinymce-inline .tox-editor-header { + margin-bottom: -1px; +} +.tox.tox-tinymce--toolbar-sticky-on .tox-editor-header { + background-color: transparent; + box-shadow: 0 4px 4px -3px rgba(0, 0, 0, 0.25); +} +.tox-editor-dock-fadeout { + opacity: 0; + visibility: hidden; +} +.tox-editor-dock-fadein { + opacity: 1; + visibility: visible; +} +.tox-editor-dock-transition { + transition: visibility 0s linear 0.25s, opacity 0.25s ease; +} +.tox-editor-dock-transition.tox-editor-dock-fadein { + transition-delay: 0s; +} +.tox .tox-control-wrap { + flex: 1; + position: relative; +} +.tox .tox-control-wrap:not(.tox-control-wrap--status-invalid) .tox-control-wrap__status-icon-invalid, +.tox .tox-control-wrap:not(.tox-control-wrap--status-unknown) .tox-control-wrap__status-icon-unknown, +.tox .tox-control-wrap:not(.tox-control-wrap--status-valid) .tox-control-wrap__status-icon-valid { + display: none; +} +.tox .tox-control-wrap svg { + display: block; +} +.tox .tox-control-wrap__status-icon-wrap { + position: absolute; + top: 50%; + transform: translateY(-50%); +} +.tox .tox-control-wrap__status-icon-invalid svg { + fill: #c00; +} +.tox .tox-control-wrap__status-icon-unknown svg { + fill: orange; +} +.tox .tox-control-wrap__status-icon-valid svg { + fill: green; +} +.tox:not([dir=rtl]) .tox-control-wrap--status-invalid .tox-textfield, +.tox:not([dir=rtl]) .tox-control-wrap--status-unknown .tox-textfield, +.tox:not([dir=rtl]) .tox-control-wrap--status-valid .tox-textfield { + padding-right: 32px; +} +.tox:not([dir=rtl]) .tox-control-wrap__status-icon-wrap { + right: 4px; +} +.tox[dir=rtl] .tox-control-wrap--status-invalid .tox-textfield, +.tox[dir=rtl] .tox-control-wrap--status-unknown .tox-textfield, +.tox[dir=rtl] .tox-control-wrap--status-valid .tox-textfield { + padding-left: 32px; +} +.tox[dir=rtl] .tox-control-wrap__status-icon-wrap { + left: 4px; +} +.tox .tox-autocompleter { + max-width: 25em; +} +.tox .tox-autocompleter .tox-menu { + max-width: 25em; +} +.tox .tox-autocompleter .tox-autocompleter-highlight { + font-weight: bold; +} +.tox .tox-color-input { + display: flex; + position: relative; + z-index: 1; +} +.tox .tox-color-input .tox-textfield { + z-index: -1; +} +.tox .tox-color-input span { + border-color: rgba(34, 47, 62, 0.2); + border-radius: 3px; + border-style: solid; + border-width: 1px; + box-shadow: none; + box-sizing: border-box; + height: 24px; + position: absolute; + top: 6px; + width: 24px; +} +.tox .tox-color-input span:hover:not([aria-disabled=true]), +.tox .tox-color-input span:focus:not([aria-disabled=true]) { + border-color: #207ab7; + cursor: pointer; +} +.tox .tox-color-input span::before { + background-image: linear-gradient(45deg, rgba(0, 0, 0, 0.25) 25%, transparent 25%), linear-gradient(-45deg, rgba(0, 0, 0, 0.25) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, rgba(0, 0, 0, 0.25) 75%), linear-gradient(-45deg, transparent 75%, rgba(0, 0, 0, 0.25) 75%); + background-position: 0 0, 0 6px, 6px -6px, -6px 0; + background-size: 12px 12px; + border: 1px solid #fff; + border-radius: 3px; + box-sizing: border-box; + content: ''; + height: 24px; + left: -1px; + position: absolute; + top: -1px; + width: 24px; + z-index: -1; +} +.tox .tox-color-input span[aria-disabled=true] { + cursor: not-allowed; +} +.tox:not([dir=rtl]) .tox-color-input { + /* stylelint-disable-next-line no-descending-specificity */ +} +.tox:not([dir=rtl]) .tox-color-input .tox-textfield { + padding-left: 36px; +} +.tox:not([dir=rtl]) .tox-color-input span { + left: 6px; +} +.tox[dir="rtl"] .tox-color-input { + /* stylelint-disable-next-line no-descending-specificity */ +} +.tox[dir="rtl"] .tox-color-input .tox-textfield { + padding-right: 36px; +} +.tox[dir="rtl"] .tox-color-input span { + right: 6px; +} +.tox .tox-label, +.tox .tox-toolbar-label { + color: rgba(34, 47, 62, 0.7); + display: block; + font-size: 14px; + font-style: normal; + font-weight: normal; + line-height: 1.3; + padding: 0 8px 0 0; + text-transform: none; + white-space: nowrap; +} +.tox .tox-toolbar-label { + padding: 0 8px; +} +.tox[dir=rtl] .tox-label { + padding: 0 0 0 8px; +} +.tox .tox-form { + display: flex; + flex: 1; + flex-direction: column; + -ms-flex-preferred-size: auto; +} +.tox .tox-form__group { + box-sizing: border-box; + margin-bottom: 4px; +} +.tox .tox-form-group--maximize { + flex: 1; +} +.tox .tox-form__group--error { + color: #c00; +} +.tox .tox-form__group--collection { + display: flex; +} +.tox .tox-form__grid { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-between; +} +.tox .tox-form__grid--2col > .tox-form__group { + width: calc(50% - (8px / 2)); +} +.tox .tox-form__grid--3col > .tox-form__group { + width: calc(100% / 3 - (8px / 2)); +} +.tox .tox-form__grid--4col > .tox-form__group { + width: calc(25% - (8px / 2)); +} +.tox .tox-form__controls-h-stack { + align-items: center; + display: flex; +} +.tox .tox-form__group--inline { + align-items: center; + display: flex; +} +.tox .tox-form__group--stretched { + display: flex; + flex: 1; + flex-direction: column; + -ms-flex-preferred-size: auto; +} +.tox .tox-form__group--stretched .tox-textarea { + flex: 1; + -ms-flex-preferred-size: auto; +} +.tox .tox-form__group--stretched .tox-navobj { + display: flex; + flex: 1; + -ms-flex-preferred-size: auto; +} +.tox .tox-form__group--stretched .tox-navobj :nth-child(2) { + flex: 1; + -ms-flex-preferred-size: auto; + height: 100%; +} +.tox:not([dir=rtl]) .tox-form__controls-h-stack > *:not(:first-child) { + margin-left: 4px; +} +.tox[dir=rtl] .tox-form__controls-h-stack > *:not(:first-child) { + margin-right: 4px; +} +.tox .tox-lock.tox-locked .tox-lock-icon__unlock, +.tox .tox-lock:not(.tox-locked) .tox-lock-icon__lock { + display: none; +} +.tox .tox-textfield, +.tox .tox-toolbar-textfield, +.tox .tox-listboxfield .tox-listbox--select, +.tox .tox-textarea { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background-color: #fff; + border-color: #cccccc; + border-radius: 3px; + border-style: solid; + border-width: 1px; + box-shadow: none; + box-sizing: border-box; + color: #222f3e; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + font-size: 16px; + line-height: 24px; + margin: 0; + min-height: 34px; + outline: none; + padding: 5px 4.75px; + resize: none; + width: 100%; +} +.tox .tox-textfield[disabled], +.tox .tox-textarea[disabled] { + background-color: #f2f2f2; + color: rgba(34, 47, 62, 0.85); + cursor: not-allowed; +} +.tox .tox-textfield:focus, +.tox .tox-listboxfield .tox-listbox--select:focus, +.tox .tox-textarea:focus { + background-color: #fff; + border-color: #207ab7; + box-shadow: none; + outline: none; +} +.tox .tox-toolbar-textfield { + border-width: 0; + margin-bottom: 3px; + margin-top: 2px; + max-width: 250px; +} +.tox .tox-naked-btn { + background-color: transparent; + border: 0; + border-color: transparent; + box-shadow: unset; + color: #207ab7; + cursor: pointer; + display: block; + margin: 0; + padding: 0; +} +.tox .tox-naked-btn svg { + display: block; + fill: #222f3e; +} +.tox:not([dir=rtl]) .tox-toolbar-textfield + * { + margin-left: 4px; +} +.tox[dir=rtl] .tox-toolbar-textfield + * { + margin-right: 4px; +} +.tox .tox-listboxfield { + cursor: pointer; + position: relative; +} +.tox .tox-listboxfield .tox-listbox--select[disabled] { + background-color: #f2f2f2; + color: rgba(34, 47, 62, 0.85); + cursor: not-allowed; +} +.tox .tox-listbox__select-label { + cursor: default; + flex: 1; + margin: 0 4px; +} +.tox .tox-listbox__select-chevron { + align-items: center; + display: flex; + justify-content: center; + width: 16px; +} +.tox .tox-listbox__select-chevron svg { + fill: #222f3e; +} +.tox .tox-listboxfield .tox-listbox--select { + align-items: center; + display: flex; +} +.tox:not([dir=rtl]) .tox-listboxfield svg { + right: 8px; +} +.tox[dir=rtl] .tox-listboxfield svg { + left: 8px; +} +.tox .tox-selectfield { + cursor: pointer; + position: relative; +} +.tox .tox-selectfield select { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background-color: #fff; + border-color: #cccccc; + border-radius: 3px; + border-style: solid; + border-width: 1px; + box-shadow: none; + box-sizing: border-box; + color: #222f3e; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + font-size: 16px; + line-height: 24px; + margin: 0; + min-height: 34px; + outline: none; + padding: 5px 4.75px; + resize: none; + width: 100%; +} +.tox .tox-selectfield select[disabled] { + background-color: #f2f2f2; + color: rgba(34, 47, 62, 0.85); + cursor: not-allowed; +} +.tox .tox-selectfield select::-ms-expand { + display: none; +} +.tox .tox-selectfield select:focus { + background-color: #fff; + border-color: #207ab7; + box-shadow: none; + outline: none; +} +.tox .tox-selectfield svg { + pointer-events: none; + position: absolute; + top: 50%; + transform: translateY(-50%); +} +.tox:not([dir=rtl]) .tox-selectfield select[size="0"], +.tox:not([dir=rtl]) .tox-selectfield select[size="1"] { + padding-right: 24px; +} +.tox:not([dir=rtl]) .tox-selectfield svg { + right: 8px; +} +.tox[dir=rtl] .tox-selectfield select[size="0"], +.tox[dir=rtl] .tox-selectfield select[size="1"] { + padding-left: 24px; +} +.tox[dir=rtl] .tox-selectfield svg { + left: 8px; +} +.tox .tox-textarea { + -webkit-appearance: textarea; + -moz-appearance: textarea; + appearance: textarea; + white-space: pre-wrap; +} +.tox-fullscreen { + border: 0; + height: 100%; + margin: 0; + overflow: hidden; + -ms-scroll-chaining: none; + overscroll-behavior: none; + padding: 0; + touch-action: pinch-zoom; + width: 100%; +} +.tox.tox-tinymce.tox-fullscreen .tox-statusbar__resize-handle { + display: none; +} +.tox.tox-tinymce.tox-fullscreen, +.tox-shadowhost.tox-fullscreen { + left: 0; + position: fixed; + top: 0; + z-index: 1200; +} +.tox.tox-tinymce.tox-fullscreen { + background-color: transparent; +} +.tox-fullscreen .tox.tox-tinymce-aux, +.tox-fullscreen ~ .tox.tox-tinymce-aux { + z-index: 1201; +} +.tox .tox-help__more-link { + list-style: none; + margin-top: 1em; +} +.tox .tox-image-tools { + width: 100%; +} +.tox .tox-image-tools__toolbar { + align-items: center; + display: flex; + justify-content: center; +} +.tox .tox-image-tools__image { + background-color: #666; + height: 380px; + overflow: auto; + position: relative; + width: 100%; +} +.tox .tox-image-tools__image, +.tox .tox-image-tools__image + .tox-image-tools__toolbar { + margin-top: 8px; +} +.tox .tox-image-tools__image-bg { + background: url(data:image/gif;base64,R0lGODdhDAAMAIABAMzMzP///ywAAAAADAAMAAACFoQfqYeabNyDMkBQb81Uat85nxguUAEAOw==); +} +.tox .tox-image-tools__toolbar > .tox-spacer { + flex: 1; + -ms-flex-preferred-size: auto; +} +.tox .tox-croprect-block { + background: black; + filter: alpha(opacity=50); + opacity: 0.5; + position: absolute; + zoom: 1; +} +.tox .tox-croprect-handle { + border: 2px solid white; + height: 20px; + left: 0; + position: absolute; + top: 0; + width: 20px; +} +.tox .tox-croprect-handle-move { + border: 0; + cursor: move; + position: absolute; +} +.tox .tox-croprect-handle-nw { + border-width: 2px 0 0 2px; + cursor: nw-resize; + left: 100px; + margin: -2px 0 0 -2px; + top: 100px; +} +.tox .tox-croprect-handle-ne { + border-width: 2px 2px 0 0; + cursor: ne-resize; + left: 200px; + margin: -2px 0 0 -20px; + top: 100px; +} +.tox .tox-croprect-handle-sw { + border-width: 0 0 2px 2px; + cursor: sw-resize; + left: 100px; + margin: -20px 2px 0 -2px; + top: 200px; +} +.tox .tox-croprect-handle-se { + border-width: 0 2px 2px 0; + cursor: se-resize; + left: 200px; + margin: -20px 0 0 -20px; + top: 200px; +} +.tox:not([dir=rtl]) .tox-image-tools__toolbar > .tox-slider:not(:first-of-type) { + margin-left: 8px; +} +.tox:not([dir=rtl]) .tox-image-tools__toolbar > .tox-button + .tox-slider { + margin-left: 32px; +} +.tox:not([dir=rtl]) .tox-image-tools__toolbar > .tox-slider + .tox-button { + margin-left: 32px; +} +.tox[dir=rtl] .tox-image-tools__toolbar > .tox-slider:not(:first-of-type) { + margin-right: 8px; +} +.tox[dir=rtl] .tox-image-tools__toolbar > .tox-button + .tox-slider { + margin-right: 32px; +} +.tox[dir=rtl] .tox-image-tools__toolbar > .tox-slider + .tox-button { + margin-right: 32px; +} +.tox .tox-insert-table-picker { + display: flex; + flex-wrap: wrap; + width: 170px; +} +.tox .tox-insert-table-picker > div { + border-color: #cccccc; + border-style: solid; + border-width: 0 1px 1px 0; + box-sizing: border-box; + height: 17px; + width: 17px; +} +.tox .tox-collection--list .tox-collection__group .tox-insert-table-picker { + margin: -4px 0; +} +.tox .tox-insert-table-picker .tox-insert-table-picker__selected { + background-color: rgba(32, 122, 183, 0.5); + border-color: rgba(32, 122, 183, 0.5); +} +.tox .tox-insert-table-picker__label { + color: rgba(34, 47, 62, 0.7); + display: block; + font-size: 14px; + padding: 4px; + text-align: center; + width: 100%; +} +.tox:not([dir=rtl]) { + /* stylelint-disable-next-line no-descending-specificity */ +} +.tox:not([dir=rtl]) .tox-insert-table-picker > div:nth-child(10n) { + border-right: 0; +} +.tox[dir=rtl] { + /* stylelint-disable-next-line no-descending-specificity */ +} +.tox[dir=rtl] .tox-insert-table-picker > div:nth-child(10n+1) { + border-right: 0; +} +.tox { + /* stylelint-disable */ + /* stylelint-enable */ +} +.tox .tox-menu { + background-color: #fff; + border: 1px solid #cccccc; + border-radius: 3px; + box-shadow: 0 4px 8px 0 rgba(34, 47, 62, 0.1); + display: inline-block; + overflow: hidden; + vertical-align: top; + z-index: 1150; +} +.tox .tox-menu.tox-collection.tox-collection--list { + padding: 0; +} +.tox .tox-menu.tox-collection.tox-collection--toolbar { + padding: 4px; +} +.tox .tox-menu.tox-collection.tox-collection--grid { + padding: 4px; +} +.tox .tox-menu__label h1, +.tox .tox-menu__label h2, +.tox .tox-menu__label h3, +.tox .tox-menu__label h4, +.tox .tox-menu__label h5, +.tox .tox-menu__label h6, +.tox .tox-menu__label p, +.tox .tox-menu__label blockquote, +.tox .tox-menu__label code { + margin: 0; +} +.tox .tox-menubar { + background: url("data:image/svg+xml;charset=utf8,%3Csvg height='39px' viewBox='0 0 40 39px' width='40' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='0' y='38px' width='100' height='1' fill='%23cccccc'/%3E%3C/svg%3E") left 0 top 0 #fff; + background-color: #fff; + display: flex; + flex: 0 0 auto; + flex-shrink: 0; + flex-wrap: wrap; + padding: 0 4px 0 4px; +} +.tox.tox-tinymce:not(.tox-tinymce-inline) .tox-editor-header:not(:first-child) .tox-menubar { + border-top: 1px solid #cccccc; +} +/* Deprecated. Remove in next major release */ +.tox .tox-mbtn { + align-items: center; + background: transparent; + border: 0; + border-radius: 3px; + box-shadow: none; + color: #222f3e; + display: flex; + flex: 0 0 auto; + font-size: 14px; + font-style: normal; + font-weight: normal; + height: 34px; + justify-content: center; + margin: 2px 0 3px 0; + outline: none; + overflow: hidden; + padding: 0 4px; + text-transform: none; + width: auto; +} +.tox .tox-mbtn[disabled] { + background-color: transparent; + border: 0; + box-shadow: none; + color: rgba(34, 47, 62, 0.5); + cursor: not-allowed; +} +.tox .tox-mbtn:focus:not(:disabled) { + background: #dee0e2; + border: 0; + box-shadow: none; + color: #222f3e; +} +.tox .tox-mbtn--active { + background: #c8cbcf; + border: 0; + box-shadow: none; + color: #222f3e; +} +.tox .tox-mbtn:hover:not(:disabled):not(.tox-mbtn--active) { + background: #dee0e2; + border: 0; + box-shadow: none; + color: #222f3e; +} +.tox .tox-mbtn__select-label { + cursor: default; + font-weight: normal; + margin: 0 4px; +} +.tox .tox-mbtn[disabled] .tox-mbtn__select-label { + cursor: not-allowed; +} +.tox .tox-mbtn__select-chevron { + align-items: center; + display: flex; + justify-content: center; + width: 16px; + display: none; +} +.tox .tox-notification { + border-radius: 3px; + border-style: solid; + border-width: 1px; + box-shadow: none; + box-sizing: border-box; + display: -ms-grid; + display: grid; + font-size: 14px; + font-weight: normal; + -ms-grid-columns: minmax(40px, 1fr) auto minmax(40px, 1fr); + grid-template-columns: minmax(40px, 1fr) auto minmax(40px, 1fr); + margin-top: 4px; + opacity: 0; + padding: 4px; + transition: transform 100ms ease-in, opacity 150ms ease-in; +} +.tox .tox-notification p { + font-size: 14px; + font-weight: normal; +} +.tox .tox-notification a { + cursor: pointer; + text-decoration: underline; +} +.tox .tox-notification--in { + opacity: 1; +} +.tox .tox-notification--success { + background-color: #e4eeda; + border-color: #d7e6c8; + color: #222f3e; +} +.tox .tox-notification--success p { + color: #222f3e; +} +.tox .tox-notification--success a { + color: #547831; +} +.tox .tox-notification--success svg { + fill: #222f3e; +} +.tox .tox-notification--error { + background-color: #f8dede; + border-color: #f2bfbf; + color: #222f3e; +} +.tox .tox-notification--error p { + color: #222f3e; +} +.tox .tox-notification--error a { + color: #c00; +} +.tox .tox-notification--error svg { + fill: #222f3e; +} +.tox .tox-notification--warn, +.tox .tox-notification--warning { + background-color: #fffaea; + border-color: #ffe89d; + color: #222f3e; +} +.tox .tox-notification--warn p, +.tox .tox-notification--warning p { + color: #222f3e; +} +.tox .tox-notification--warn a, +.tox .tox-notification--warning a { + color: #222f3e; +} +.tox .tox-notification--warn svg, +.tox .tox-notification--warning svg { + fill: #222f3e; +} +.tox .tox-notification--info { + background-color: #d9edf7; + border-color: #779ecb; + color: #222f3e; +} +.tox .tox-notification--info p { + color: #222f3e; +} +.tox .tox-notification--info a { + color: #222f3e; +} +.tox .tox-notification--info svg { + fill: #222f3e; +} +.tox .tox-notification__body { + -ms-grid-row-align: center; + align-self: center; + color: #222f3e; + font-size: 14px; + -ms-grid-column-span: 1; + grid-column-end: 3; + -ms-grid-column: 2; + grid-column-start: 2; + -ms-grid-row-span: 1; + grid-row-end: 2; + -ms-grid-row: 1; + grid-row-start: 1; + text-align: center; + white-space: normal; + word-break: break-all; + word-break: break-word; +} +.tox .tox-notification__body > * { + margin: 0; +} +.tox .tox-notification__body > * + * { + margin-top: 1rem; +} +.tox .tox-notification__icon { + -ms-grid-row-align: center; + align-self: center; + -ms-grid-column-span: 1; + grid-column-end: 2; + -ms-grid-column: 1; + grid-column-start: 1; + -ms-grid-row-span: 1; + grid-row-end: 2; + -ms-grid-row: 1; + grid-row-start: 1; + -ms-grid-column-align: end; + justify-self: end; +} +.tox .tox-notification__icon svg { + display: block; +} +.tox .tox-notification__dismiss { + -ms-grid-row-align: start; + align-self: start; + -ms-grid-column-span: 1; + grid-column-end: 4; + -ms-grid-column: 3; + grid-column-start: 3; + -ms-grid-row-span: 1; + grid-row-end: 2; + -ms-grid-row: 1; + grid-row-start: 1; + -ms-grid-column-align: end; + justify-self: end; +} +.tox .tox-notification .tox-progress-bar { + -ms-grid-column-span: 3; + grid-column-end: 4; + -ms-grid-column: 1; + grid-column-start: 1; + -ms-grid-row-span: 1; + grid-row-end: 3; + -ms-grid-row: 2; + grid-row-start: 2; + -ms-grid-column-align: center; + justify-self: center; +} +.tox .tox-pop { + display: inline-block; + position: relative; +} +.tox .tox-pop--resizing { + transition: width 0.1s ease; +} +.tox .tox-pop--resizing .tox-toolbar, +.tox .tox-pop--resizing .tox-toolbar__group { + flex-wrap: nowrap; +} +.tox .tox-pop--transition { + transition: 0.15s ease; + transition-property: left, right, top, bottom; +} +.tox .tox-pop--transition::before, +.tox .tox-pop--transition::after { + transition: all 0.15s, visibility 0s, opacity 0.075s ease 0.075s; +} +.tox .tox-pop__dialog { + background-color: #fff; + border: 1px solid #cccccc; + border-radius: 3px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); + min-width: 0; + overflow: hidden; +} +.tox .tox-pop__dialog > *:not(.tox-toolbar) { + margin: 4px 4px 4px 8px; +} +.tox .tox-pop__dialog .tox-toolbar { + background-color: transparent; + margin-bottom: -1px; +} +.tox .tox-pop::before, +.tox .tox-pop::after { + border-style: solid; + content: ''; + display: block; + height: 0; + opacity: 1; + position: absolute; + width: 0; +} +.tox .tox-pop.tox-pop--inset::before, +.tox .tox-pop.tox-pop--inset::after { + opacity: 0; + transition: all 0s 0.15s, visibility 0s, opacity 0.075s ease; +} +.tox .tox-pop.tox-pop--bottom::before, +.tox .tox-pop.tox-pop--bottom::after { + left: 50%; + top: 100%; +} +.tox .tox-pop.tox-pop--bottom::after { + border-color: #fff transparent transparent transparent; + border-width: 8px; + margin-left: -8px; + margin-top: -1px; +} +.tox .tox-pop.tox-pop--bottom::before { + border-color: #cccccc transparent transparent transparent; + border-width: 9px; + margin-left: -9px; +} +.tox .tox-pop.tox-pop--top::before, +.tox .tox-pop.tox-pop--top::after { + left: 50%; + top: 0; + transform: translateY(-100%); +} +.tox .tox-pop.tox-pop--top::after { + border-color: transparent transparent #fff transparent; + border-width: 8px; + margin-left: -8px; + margin-top: 1px; +} +.tox .tox-pop.tox-pop--top::before { + border-color: transparent transparent #cccccc transparent; + border-width: 9px; + margin-left: -9px; +} +.tox .tox-pop.tox-pop--left::before, +.tox .tox-pop.tox-pop--left::after { + left: 0; + top: calc(50% - 1px); + transform: translateY(-50%); +} +.tox .tox-pop.tox-pop--left::after { + border-color: transparent #fff transparent transparent; + border-width: 8px; + margin-left: -15px; +} +.tox .tox-pop.tox-pop--left::before { + border-color: transparent #cccccc transparent transparent; + border-width: 10px; + margin-left: -19px; +} +.tox .tox-pop.tox-pop--right::before, +.tox .tox-pop.tox-pop--right::after { + left: 100%; + top: calc(50% + 1px); + transform: translateY(-50%); +} +.tox .tox-pop.tox-pop--right::after { + border-color: transparent transparent transparent #fff; + border-width: 8px; + margin-left: -1px; +} +.tox .tox-pop.tox-pop--right::before { + border-color: transparent transparent transparent #cccccc; + border-width: 10px; + margin-left: -1px; +} +.tox .tox-pop.tox-pop--align-left::before, +.tox .tox-pop.tox-pop--align-left::after { + left: 20px; +} +.tox .tox-pop.tox-pop--align-right::before, +.tox .tox-pop.tox-pop--align-right::after { + left: calc(100% - 20px); +} +.tox .tox-sidebar-wrap { + display: flex; + flex-direction: row; + flex-grow: 1; + -ms-flex-preferred-size: 0; + min-height: 0; +} +.tox .tox-sidebar { + background-color: #fff; + display: flex; + flex-direction: row; + justify-content: flex-end; +} +.tox .tox-sidebar__slider { + display: flex; + overflow: hidden; +} +.tox .tox-sidebar__pane-container { + display: flex; +} +.tox .tox-sidebar__pane { + display: flex; +} +.tox .tox-sidebar--sliding-closed { + opacity: 0; +} +.tox .tox-sidebar--sliding-open { + opacity: 1; +} +.tox .tox-sidebar--sliding-growing, +.tox .tox-sidebar--sliding-shrinking { + transition: width 0.5s ease, opacity 0.5s ease; +} +.tox .tox-selector { + background-color: #4099ff; + border-color: #4099ff; + border-style: solid; + border-width: 1px; + box-sizing: border-box; + display: inline-block; + height: 10px; + position: absolute; + width: 10px; +} +.tox.tox-platform-touch .tox-selector { + height: 12px; + width: 12px; +} +.tox .tox-slider { + align-items: center; + display: flex; + flex: 1; + -ms-flex-preferred-size: auto; + height: 24px; + justify-content: center; + position: relative; +} +.tox .tox-slider__rail { + background-color: transparent; + border: 1px solid #cccccc; + border-radius: 3px; + height: 10px; + min-width: 120px; + width: 100%; +} +.tox .tox-slider__handle { + background-color: #207ab7; + border: 2px solid #185d8c; + border-radius: 3px; + box-shadow: none; + height: 24px; + left: 50%; + position: absolute; + top: 50%; + transform: translateX(-50%) translateY(-50%); + width: 14px; +} +.tox .tox-source-code { + overflow: auto; +} +.tox .tox-spinner { + display: flex; +} +.tox .tox-spinner > div { + animation: tam-bouncing-dots 1.5s ease-in-out 0s infinite both; + background-color: rgba(34, 47, 62, 0.7); + border-radius: 100%; + height: 8px; + width: 8px; +} +.tox .tox-spinner > div:nth-child(1) { + animation-delay: -0.32s; +} +.tox .tox-spinner > div:nth-child(2) { + animation-delay: -0.16s; +} +@keyframes tam-bouncing-dots { + 0%, + 80%, + 100% { + transform: scale(0); + } + 40% { + transform: scale(1); + } +} +.tox:not([dir=rtl]) .tox-spinner > div:not(:first-child) { + margin-left: 4px; +} +.tox[dir=rtl] .tox-spinner > div:not(:first-child) { + margin-right: 4px; +} +.tox .tox-statusbar { + align-items: center; + background-color: #fff; + border-top: 1px solid #cccccc; + color: rgba(34, 47, 62, 0.7); + display: flex; + flex: 0 0 auto; + font-size: 12px; + font-weight: normal; + height: 18px; + overflow: hidden; + padding: 0 8px; + position: relative; + text-transform: uppercase; +} +.tox .tox-statusbar__text-container { + display: flex; + flex: 1 1 auto; + justify-content: flex-end; + overflow: hidden; +} +.tox .tox-statusbar__path { + display: flex; + flex: 1 1 auto; + margin-right: auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.tox .tox-statusbar__path > * { + display: inline; + white-space: nowrap; +} +.tox .tox-statusbar__wordcount { + flex: 0 0 auto; + margin-left: 1ch; +} +.tox .tox-statusbar a, +.tox .tox-statusbar__path-item, +.tox .tox-statusbar__wordcount { + color: rgba(34, 47, 62, 0.7); + text-decoration: none; +} +.tox .tox-statusbar a:hover:not(:disabled):not([aria-disabled=true]), +.tox .tox-statusbar__path-item:hover:not(:disabled):not([aria-disabled=true]), +.tox .tox-statusbar__wordcount:hover:not(:disabled):not([aria-disabled=true]), +.tox .tox-statusbar a:focus:not(:disabled):not([aria-disabled=true]), +.tox .tox-statusbar__path-item:focus:not(:disabled):not([aria-disabled=true]), +.tox .tox-statusbar__wordcount:focus:not(:disabled):not([aria-disabled=true]) { + cursor: pointer; + text-decoration: underline; +} +.tox .tox-statusbar__resize-handle { + align-items: flex-end; + align-self: stretch; + cursor: nwse-resize; + display: flex; + flex: 0 0 auto; + justify-content: flex-end; + margin-left: auto; + margin-right: -8px; + padding-left: 1ch; +} +.tox .tox-statusbar__resize-handle svg { + display: block; + fill: rgba(34, 47, 62, 0.7); +} +.tox .tox-statusbar__resize-handle:focus svg { + background-color: #dee0e2; + border-radius: 1px; + box-shadow: 0 0 0 2px #dee0e2; +} +.tox:not([dir=rtl]) .tox-statusbar__path > * { + margin-right: 4px; +} +.tox:not([dir=rtl]) .tox-statusbar__branding { + margin-left: 1ch; +} +.tox[dir=rtl] .tox-statusbar { + flex-direction: row-reverse; +} +.tox[dir=rtl] .tox-statusbar__path > * { + margin-left: 4px; +} +.tox .tox-throbber { + z-index: 1299; +} +.tox .tox-throbber__busy-spinner { + align-items: center; + background-color: rgba(255, 255, 255, 0.6); + bottom: 0; + display: flex; + justify-content: center; + left: 0; + position: absolute; + right: 0; + top: 0; +} +.tox .tox-tbtn { + align-items: center; + background: transparent; + border: 0; + border-radius: 3px; + box-shadow: none; + color: #222f3e; + display: flex; + flex: 0 0 auto; + font-size: 14px; + font-style: normal; + font-weight: normal; + height: 34px; + justify-content: center; + margin: 2px 0 3px 0; + outline: none; + overflow: hidden; + padding: 0; + text-transform: none; + width: 34px; +} +.tox .tox-tbtn svg { + display: block; + fill: #222f3e; +} +.tox .tox-tbtn.tox-tbtn-more { + padding-left: 5px; + padding-right: 5px; + width: inherit; +} +.tox .tox-tbtn:focus { + background: #dee0e2; + border: 0; + box-shadow: none; +} +.tox .tox-tbtn:hover { + background: #dee0e2; + border: 0; + box-shadow: none; + color: #222f3e; +} +.tox .tox-tbtn:hover svg { + fill: #222f3e; +} +.tox .tox-tbtn:active { + background: #c8cbcf; + border: 0; + box-shadow: none; + color: #222f3e; +} +.tox .tox-tbtn:active svg { + fill: #222f3e; +} +.tox .tox-tbtn--disabled, +.tox .tox-tbtn--disabled:hover, +.tox .tox-tbtn:disabled, +.tox .tox-tbtn:disabled:hover { + background: transparent; + border: 0; + box-shadow: none; + color: rgba(34, 47, 62, 0.5); + cursor: not-allowed; +} +.tox .tox-tbtn--disabled svg, +.tox .tox-tbtn--disabled:hover svg, +.tox .tox-tbtn:disabled svg, +.tox .tox-tbtn:disabled:hover svg { + /* stylelint-disable-line no-descending-specificity */ + fill: rgba(34, 47, 62, 0.5); +} +.tox .tox-tbtn--enabled, +.tox .tox-tbtn--enabled:hover { + background: #c8cbcf; + border: 0; + box-shadow: none; + color: #222f3e; +} +.tox .tox-tbtn--enabled > *, +.tox .tox-tbtn--enabled:hover > * { + transform: none; +} +.tox .tox-tbtn--enabled svg, +.tox .tox-tbtn--enabled:hover svg { + /* stylelint-disable-line no-descending-specificity */ + fill: #222f3e; +} +.tox .tox-tbtn:focus:not(.tox-tbtn--disabled) { + color: #222f3e; +} +.tox .tox-tbtn:focus:not(.tox-tbtn--disabled) svg { + fill: #222f3e; +} +.tox .tox-tbtn:active > * { + transform: none; +} +.tox .tox-tbtn--md { + height: 51px; + width: 51px; +} +.tox .tox-tbtn--lg { + flex-direction: column; + height: 68px; + width: 68px; +} +.tox .tox-tbtn--return { + -ms-grid-row-align: stretch; + align-self: stretch; + height: unset; + width: 16px; +} +.tox .tox-tbtn--labeled { + padding: 0 4px; + width: unset; +} +.tox .tox-tbtn__vlabel { + display: block; + font-size: 10px; + font-weight: normal; + letter-spacing: -0.025em; + margin-bottom: 4px; + white-space: nowrap; +} +.tox .tox-tbtn--select { + margin: 2px 0 3px 0; + padding: 0 4px; + width: auto; +} +.tox .tox-tbtn__select-label { + cursor: default; + font-weight: normal; + margin: 0 4px; +} +.tox .tox-tbtn__select-chevron { + align-items: center; + display: flex; + justify-content: center; + width: 16px; +} +.tox .tox-tbtn__select-chevron svg { + fill: rgba(34, 47, 62, 0.5); +} +.tox .tox-tbtn--bespoke .tox-tbtn__select-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: 7em; +} +.tox .tox-split-button { + border: 0; + border-radius: 3px; + box-sizing: border-box; + display: flex; + margin: 2px 0 3px 0; + overflow: hidden; +} +.tox .tox-split-button:hover { + box-shadow: 0 0 0 1px #dee0e2 inset; +} +.tox .tox-split-button:focus { + background: #dee0e2; + box-shadow: none; + color: #222f3e; +} +.tox .tox-split-button > * { + border-radius: 0; +} +.tox .tox-split-button__chevron { + width: 16px; +} +.tox .tox-split-button__chevron svg { + fill: rgba(34, 47, 62, 0.5); +} +.tox .tox-split-button .tox-tbtn { + margin: 0; +} +.tox.tox-platform-touch .tox-split-button .tox-tbtn:first-child { + width: 30px; +} +.tox.tox-platform-touch .tox-split-button__chevron { + width: 20px; +} +.tox .tox-split-button.tox-tbtn--disabled:hover, +.tox .tox-split-button.tox-tbtn--disabled:focus, +.tox .tox-split-button.tox-tbtn--disabled .tox-tbtn:hover, +.tox .tox-split-button.tox-tbtn--disabled .tox-tbtn:focus { + background: transparent; + box-shadow: none; + color: rgba(34, 47, 62, 0.5); +} +.tox .tox-toolbar-overlord { + background-color: #fff; +} +.tox .tox-toolbar, +.tox .tox-toolbar__primary, +.tox .tox-toolbar__overflow { + background: url("data:image/svg+xml;charset=utf8,%3Csvg height='39px' viewBox='0 0 40 39px' width='40' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='0' y='38px' width='100' height='1' fill='%23cccccc'/%3E%3C/svg%3E") left 0 top 0 #fff; + background-color: #fff; + display: flex; + flex: 0 0 auto; + flex-shrink: 0; + flex-wrap: wrap; + padding: 0 0; +} +.tox .tox-toolbar__overflow.tox-toolbar__overflow--closed { + height: 0; + opacity: 0; + padding-bottom: 0; + padding-top: 0; + visibility: hidden; +} +.tox .tox-toolbar__overflow--growing { + transition: height 0.3s ease, opacity 0.2s linear 0.1s; +} +.tox .tox-toolbar__overflow--shrinking { + transition: opacity 0.3s ease, height 0.2s linear 0.1s, visibility 0s linear 0.3s; +} +.tox .tox-menubar + .tox-toolbar, +.tox .tox-menubar + .tox-toolbar-overlord .tox-toolbar__primary { + border-top: 1px solid #cccccc; + margin-top: -1px; +} +.tox .tox-toolbar--scrolling { + flex-wrap: nowrap; + overflow-x: auto; +} +.tox .tox-pop .tox-toolbar { + border-width: 0; +} +.tox .tox-toolbar--no-divider { + background-image: none; +} +.tox-tinymce:not(.tox-tinymce-inline) .tox-editor-header:not(:first-child) .tox-toolbar:first-child, +.tox-tinymce:not(.tox-tinymce-inline) .tox-editor-header:not(:first-child) .tox-toolbar-overlord:first-child .tox-toolbar__primary { + border-top: 1px solid #cccccc; +} +.tox.tox-tinymce-aux .tox-toolbar__overflow { + background-color: #fff; + border: 1px solid #cccccc; + border-radius: 3px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); +} +.tox .tox-toolbar__group { + align-items: center; + display: flex; + flex-wrap: wrap; + margin: 0 0; + padding: 0 4px 0 4px; +} +.tox .tox-toolbar__group--pull-right { + margin-left: auto; +} +.tox .tox-toolbar--scrolling .tox-toolbar__group { + flex-shrink: 0; + flex-wrap: nowrap; +} +.tox:not([dir=rtl]) .tox-toolbar__group:not(:last-of-type) { + border-right: 1px solid #cccccc; +} +.tox[dir=rtl] .tox-toolbar__group:not(:last-of-type) { + border-left: 1px solid #cccccc; +} +.tox .tox-tooltip { + display: inline-block; + padding: 8px; + position: relative; +} +.tox .tox-tooltip__body { + background-color: #222f3e; + border-radius: 3px; + box-shadow: 0 2px 4px rgba(34, 47, 62, 0.3); + color: rgba(255, 255, 255, 0.75); + font-size: 14px; + font-style: normal; + font-weight: normal; + padding: 4px 8px; + text-transform: none; +} +.tox .tox-tooltip__arrow { + position: absolute; +} +.tox .tox-tooltip--down .tox-tooltip__arrow { + border-left: 8px solid transparent; + border-right: 8px solid transparent; + border-top: 8px solid #222f3e; + bottom: 0; + left: 50%; + position: absolute; + transform: translateX(-50%); +} +.tox .tox-tooltip--up .tox-tooltip__arrow { + border-bottom: 8px solid #222f3e; + border-left: 8px solid transparent; + border-right: 8px solid transparent; + left: 50%; + position: absolute; + top: 0; + transform: translateX(-50%); +} +.tox .tox-tooltip--right .tox-tooltip__arrow { + border-bottom: 8px solid transparent; + border-left: 8px solid #222f3e; + border-top: 8px solid transparent; + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); +} +.tox .tox-tooltip--left .tox-tooltip__arrow { + border-bottom: 8px solid transparent; + border-right: 8px solid #222f3e; + border-top: 8px solid transparent; + left: 0; + position: absolute; + top: 50%; + transform: translateY(-50%); +} +.tox .tox-well { + border: 1px solid #cccccc; + border-radius: 3px; + padding: 8px; + width: 100%; +} +.tox .tox-well > *:first-child { + margin-top: 0; +} +.tox .tox-well > *:last-child { + margin-bottom: 0; +} +.tox .tox-well > *:only-child { + margin: 0; +} +.tox .tox-custom-editor { + border: 1px solid #cccccc; + border-radius: 3px; + display: flex; + flex: 1; + position: relative; +} +/* stylelint-disable */ +.tox { + /* stylelint-enable */ +} +.tox .tox-dialog-loading::before { + background-color: rgba(0, 0, 0, 0.5); + content: ""; + height: 100%; + position: absolute; + width: 100%; + z-index: 1000; +} +.tox .tox-tab { + cursor: pointer; +} +.tox .tox-dialog__content-js { + display: flex; + flex: 1; + -ms-flex-preferred-size: auto; +} +.tox .tox-dialog__body-content .tox-collection { + display: flex; + flex: 1; + -ms-flex-preferred-size: auto; +} +.tox .tox-image-tools-edit-panel { + height: 60px; +} +.tox .tox-image-tools__sidebar { + height: 60px; +} diff --git a/ui/public/tinymce/skins/ui/oxide/skin.min.css b/ui/public/tinymce/skins/ui/oxide/skin.min.css new file mode 100644 index 0000000..09735a1 --- /dev/null +++ b/ui/public/tinymce/skins/ui/oxide/skin.min.css @@ -0,0 +1,7 @@ +/** + * Copyright (c) Tiny Technologies, Inc. All rights reserved. + * Licensed under the LGPL or a commercial license. + * For LGPL see License.txt in the project root for license information. + * For commercial licenses see https://www.tiny.cloud/ + */ +.tox{box-shadow:none;box-sizing:content-box;color:#222f3e;cursor:auto;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;font-size:16px;font-style:normal;font-weight:400;line-height:normal;-webkit-tap-highlight-color:transparent;text-decoration:none;text-shadow:none;text-transform:none;vertical-align:initial;white-space:normal}.tox :not(svg):not(rect){box-sizing:inherit;color:inherit;cursor:inherit;direction:inherit;font-family:inherit;font-size:inherit;font-style:inherit;font-weight:inherit;line-height:inherit;-webkit-tap-highlight-color:inherit;text-align:inherit;text-decoration:inherit;text-shadow:inherit;text-transform:inherit;vertical-align:inherit;white-space:inherit}.tox :not(svg):not(rect){background:0 0;border:0;box-shadow:none;float:none;height:auto;margin:0;max-width:none;outline:0;padding:0;position:static;width:auto}.tox:not([dir=rtl]){direction:ltr;text-align:left}.tox[dir=rtl]{direction:rtl;text-align:right}.tox-tinymce{border:1px solid #ccc;border-radius:0;box-shadow:none;box-sizing:border-box;display:flex;flex-direction:column;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;overflow:hidden;position:relative;visibility:inherit!important}.tox-tinymce-inline{border:none;box-shadow:none}.tox-tinymce-inline .tox-editor-header{background-color:transparent;border:1px solid #ccc;border-radius:0;box-shadow:none}.tox-tinymce-aux{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;z-index:13000000}.tox-tinymce :focus,.tox-tinymce-aux :focus{outline:0}button::-moz-focus-inner{border:0}.tox[dir=rtl] .tox-icon--flip svg{transform:rotateY(180deg)}.tox .accessibility-issue__header{align-items:center;display:flex;margin-bottom:4px}.tox .accessibility-issue__description{align-items:stretch;border:1px solid #ccc;border-radius:3px;display:flex;justify-content:space-between}.tox .accessibility-issue__description>div{padding-bottom:4px}.tox .accessibility-issue__description>div>div{align-items:center;display:flex;margin-bottom:4px}.tox .accessibility-issue__description>:last-child:not(:only-child){border-color:#ccc;border-style:solid}.tox .accessibility-issue__repair{margin-top:16px}.tox .tox-dialog__body-content .accessibility-issue--info .accessibility-issue__description{background-color:rgba(32,122,183,.1);border-color:rgba(32,122,183,.4);color:#222f3e}.tox .tox-dialog__body-content .accessibility-issue--info .accessibility-issue__description>:last-child{border-color:rgba(32,122,183,.4)}.tox .tox-dialog__body-content .accessibility-issue--info .tox-form__group h2{color:#207ab7}.tox .tox-dialog__body-content .accessibility-issue--info .tox-icon svg{fill:#207ab7}.tox .tox-dialog__body-content .accessibility-issue--info a .tox-icon{color:#207ab7}.tox .tox-dialog__body-content .accessibility-issue--warn .accessibility-issue__description{background-color:rgba(255,165,0,.1);border-color:rgba(255,165,0,.5);color:#222f3e}.tox .tox-dialog__body-content .accessibility-issue--warn .accessibility-issue__description>:last-child{border-color:rgba(255,165,0,.5)}.tox .tox-dialog__body-content .accessibility-issue--warn .tox-form__group h2{color:#cc8500}.tox .tox-dialog__body-content .accessibility-issue--warn .tox-icon svg{fill:#cc8500}.tox .tox-dialog__body-content .accessibility-issue--warn a .tox-icon{color:#cc8500}.tox .tox-dialog__body-content .accessibility-issue--error .accessibility-issue__description{background-color:rgba(204,0,0,.1);border-color:rgba(204,0,0,.4);color:#222f3e}.tox .tox-dialog__body-content .accessibility-issue--error .accessibility-issue__description>:last-child{border-color:rgba(204,0,0,.4)}.tox .tox-dialog__body-content .accessibility-issue--error .tox-form__group h2{color:#c00}.tox .tox-dialog__body-content .accessibility-issue--error .tox-icon svg{fill:#c00}.tox .tox-dialog__body-content .accessibility-issue--error a .tox-icon{color:#c00}.tox .tox-dialog__body-content .accessibility-issue--success .accessibility-issue__description{background-color:rgba(120,171,70,.1);border-color:rgba(120,171,70,.4);color:#222f3e}.tox .tox-dialog__body-content .accessibility-issue--success .accessibility-issue__description>:last-child{border-color:rgba(120,171,70,.4)}.tox .tox-dialog__body-content .accessibility-issue--success .tox-form__group h2{color:#78ab46}.tox .tox-dialog__body-content .accessibility-issue--success .tox-icon svg{fill:#78ab46}.tox .tox-dialog__body-content .accessibility-issue--success a .tox-icon{color:#78ab46}.tox .tox-dialog__body-content .accessibility-issue__header h1,.tox .tox-dialog__body-content .tox-form__group .accessibility-issue__description h2{margin-top:0}.tox:not([dir=rtl]) .tox-dialog__body-content .accessibility-issue__header .tox-button{margin-left:4px}.tox:not([dir=rtl]) .tox-dialog__body-content .accessibility-issue__header>:nth-last-child(2){margin-left:auto}.tox:not([dir=rtl]) .tox-dialog__body-content .accessibility-issue__description{padding:4px 4px 4px 8px}.tox:not([dir=rtl]) .tox-dialog__body-content .accessibility-issue__description>:last-child{border-left-width:1px;padding-left:4px}.tox[dir=rtl] .tox-dialog__body-content .accessibility-issue__header .tox-button{margin-right:4px}.tox[dir=rtl] .tox-dialog__body-content .accessibility-issue__header>:nth-last-child(2){margin-right:auto}.tox[dir=rtl] .tox-dialog__body-content .accessibility-issue__description{padding:4px 8px 4px 4px}.tox[dir=rtl] .tox-dialog__body-content .accessibility-issue__description>:last-child{border-right-width:1px;padding-right:4px}.tox .tox-anchorbar{display:flex;flex:0 0 auto}.tox .tox-bar{display:flex;flex:0 0 auto}.tox .tox-button{background-color:#207ab7;background-image:none;background-position:0 0;background-repeat:repeat;border-color:#207ab7;border-radius:3px;border-style:solid;border-width:1px;box-shadow:none;box-sizing:border-box;color:#fff;cursor:pointer;display:inline-block;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;font-size:14px;font-style:normal;font-weight:700;letter-spacing:normal;line-height:24px;margin:0;outline:0;padding:4px 16px;text-align:center;text-decoration:none;text-transform:none;white-space:nowrap}.tox .tox-button[disabled]{background-color:#207ab7;background-image:none;border-color:#207ab7;box-shadow:none;color:rgba(255,255,255,.5);cursor:not-allowed}.tox .tox-button:focus:not(:disabled){background-color:#1c6ca1;background-image:none;border-color:#1c6ca1;box-shadow:none;color:#fff}.tox .tox-button:hover:not(:disabled){background-color:#1c6ca1;background-image:none;border-color:#1c6ca1;box-shadow:none;color:#fff}.tox .tox-button:active:not(:disabled){background-color:#185d8c;background-image:none;border-color:#185d8c;box-shadow:none;color:#fff}.tox .tox-button--secondary{background-color:#f0f0f0;background-image:none;background-position:0 0;background-repeat:repeat;border-color:#f0f0f0;border-radius:3px;border-style:solid;border-width:1px;box-shadow:none;color:#222f3e;font-size:14px;font-style:normal;font-weight:700;letter-spacing:normal;outline:0;padding:4px 16px;text-decoration:none;text-transform:none}.tox .tox-button--secondary[disabled]{background-color:#f0f0f0;background-image:none;border-color:#f0f0f0;box-shadow:none;color:rgba(34,47,62,.5)}.tox .tox-button--secondary:focus:not(:disabled){background-color:#e3e3e3;background-image:none;border-color:#e3e3e3;box-shadow:none;color:#222f3e}.tox .tox-button--secondary:hover:not(:disabled){background-color:#e3e3e3;background-image:none;border-color:#e3e3e3;box-shadow:none;color:#222f3e}.tox .tox-button--secondary:active:not(:disabled){background-color:#d6d6d6;background-image:none;border-color:#d6d6d6;box-shadow:none;color:#222f3e}.tox .tox-button--icon,.tox .tox-button.tox-button--icon,.tox .tox-button.tox-button--secondary.tox-button--icon{padding:4px}.tox .tox-button--icon .tox-icon svg,.tox .tox-button.tox-button--icon .tox-icon svg,.tox .tox-button.tox-button--secondary.tox-button--icon .tox-icon svg{display:block;fill:currentColor}.tox .tox-button-link{background:0;border:none;box-sizing:border-box;cursor:pointer;display:inline-block;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;font-size:16px;font-weight:400;line-height:1.3;margin:0;padding:0;white-space:nowrap}.tox .tox-button-link--sm{font-size:14px}.tox .tox-button--naked{background-color:transparent;border-color:transparent;box-shadow:unset;color:#222f3e}.tox .tox-button--naked[disabled]{background-color:#f0f0f0;border-color:#f0f0f0;box-shadow:none;color:rgba(34,47,62,.5)}.tox .tox-button--naked:hover:not(:disabled){background-color:#e3e3e3;border-color:#e3e3e3;box-shadow:none;color:#222f3e}.tox .tox-button--naked:focus:not(:disabled){background-color:#e3e3e3;border-color:#e3e3e3;box-shadow:none;color:#222f3e}.tox .tox-button--naked:active:not(:disabled){background-color:#d6d6d6;border-color:#d6d6d6;box-shadow:none;color:#222f3e}.tox .tox-button--naked .tox-icon svg{fill:currentColor}.tox .tox-button--naked.tox-button--icon:hover:not(:disabled){color:#222f3e}.tox .tox-checkbox{align-items:center;border-radius:3px;cursor:pointer;display:flex;height:36px;min-width:36px}.tox .tox-checkbox__input{height:1px;overflow:hidden;position:absolute;top:auto;width:1px}.tox .tox-checkbox__icons{align-items:center;border-radius:3px;box-shadow:0 0 0 2px transparent;box-sizing:content-box;display:flex;height:24px;justify-content:center;padding:calc(4px - 1px);width:24px}.tox .tox-checkbox__icons .tox-checkbox-icon__unchecked svg{display:block;fill:rgba(34,47,62,.3)}.tox .tox-checkbox__icons .tox-checkbox-icon__indeterminate svg{display:none;fill:#207ab7}.tox .tox-checkbox__icons .tox-checkbox-icon__checked svg{display:none;fill:#207ab7}.tox .tox-checkbox--disabled{color:rgba(34,47,62,.5);cursor:not-allowed}.tox .tox-checkbox--disabled .tox-checkbox__icons .tox-checkbox-icon__checked svg{fill:rgba(34,47,62,.5)}.tox .tox-checkbox--disabled .tox-checkbox__icons .tox-checkbox-icon__unchecked svg{fill:rgba(34,47,62,.5)}.tox .tox-checkbox--disabled .tox-checkbox__icons .tox-checkbox-icon__indeterminate svg{fill:rgba(34,47,62,.5)}.tox input.tox-checkbox__input:checked+.tox-checkbox__icons .tox-checkbox-icon__unchecked svg{display:none}.tox input.tox-checkbox__input:checked+.tox-checkbox__icons .tox-checkbox-icon__checked svg{display:block}.tox input.tox-checkbox__input:indeterminate+.tox-checkbox__icons .tox-checkbox-icon__unchecked svg{display:none}.tox input.tox-checkbox__input:indeterminate+.tox-checkbox__icons .tox-checkbox-icon__indeterminate svg{display:block}.tox input.tox-checkbox__input:focus+.tox-checkbox__icons{border-radius:3px;box-shadow:inset 0 0 0 1px #207ab7;padding:calc(4px - 1px)}.tox:not([dir=rtl]) .tox-checkbox__label{margin-left:4px}.tox:not([dir=rtl]) .tox-checkbox__input{left:-10000px}.tox:not([dir=rtl]) .tox-bar .tox-checkbox{margin-left:4px}.tox[dir=rtl] .tox-checkbox__label{margin-right:4px}.tox[dir=rtl] .tox-checkbox__input{right:-10000px}.tox[dir=rtl] .tox-bar .tox-checkbox{margin-right:4px}.tox .tox-collection--toolbar .tox-collection__group{display:flex;padding:0}.tox .tox-collection--grid .tox-collection__group{display:flex;flex-wrap:wrap;max-height:208px;overflow-x:hidden;overflow-y:auto;padding:0}.tox .tox-collection--list .tox-collection__group{border-bottom-width:0;border-color:#ccc;border-left-width:0;border-right-width:0;border-style:solid;border-top-width:1px;padding:4px 0}.tox .tox-collection--list .tox-collection__group:first-child{border-top-width:0}.tox .tox-collection__group-heading{background-color:#e6e6e6;color:rgba(34,47,62,.7);cursor:default;font-size:12px;font-style:normal;font-weight:400;margin-bottom:4px;margin-top:-4px;padding:4px 8px;text-transform:none;-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.tox .tox-collection__item{align-items:center;color:#222f3e;cursor:pointer;display:flex;-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.tox .tox-collection--list .tox-collection__item{padding:4px 8px}.tox .tox-collection--toolbar .tox-collection__item{border-radius:3px;padding:4px}.tox .tox-collection--grid .tox-collection__item{border-radius:3px;padding:4px}.tox .tox-collection--list .tox-collection__item--enabled{background-color:#fff;color:#222f3e}.tox .tox-collection--list .tox-collection__item--active{background-color:#dee0e2}.tox .tox-collection--toolbar .tox-collection__item--enabled{background-color:#c8cbcf;color:#222f3e}.tox .tox-collection--toolbar .tox-collection__item--active{background-color:#dee0e2}.tox .tox-collection--grid .tox-collection__item--enabled{background-color:#c8cbcf;color:#222f3e}.tox .tox-collection--grid .tox-collection__item--active:not(.tox-collection__item--state-disabled){background-color:#dee0e2;color:#222f3e}.tox .tox-collection--list .tox-collection__item--active:not(.tox-collection__item--state-disabled){color:#222f3e}.tox .tox-collection--toolbar .tox-collection__item--active:not(.tox-collection__item--state-disabled){color:#222f3e}.tox .tox-collection__item-checkmark,.tox .tox-collection__item-icon{align-items:center;display:flex;height:24px;justify-content:center;width:24px}.tox .tox-collection__item-checkmark svg,.tox .tox-collection__item-icon svg{fill:currentColor}.tox .tox-collection--toolbar-lg .tox-collection__item-icon{height:48px;width:48px}.tox .tox-collection__item-label{color:currentColor;display:inline-block;flex:1;-ms-flex-preferred-size:auto;font-size:14px;font-style:normal;font-weight:400;line-height:24px;text-transform:none;word-break:break-all}.tox .tox-collection__item-accessory{color:rgba(34,47,62,.7);display:inline-block;font-size:14px;height:24px;line-height:24px;text-transform:none}.tox .tox-collection__item-caret{align-items:center;display:flex;min-height:24px}.tox .tox-collection__item-caret::after{content:'';font-size:0;min-height:inherit}.tox .tox-collection__item-caret svg{fill:#222f3e}.tox .tox-collection__item--state-disabled{background-color:transparent;color:rgba(34,47,62,.5);cursor:not-allowed}.tox .tox-collection__item--state-disabled .tox-collection__item-caret svg{fill:rgba(34,47,62,.5)}.tox .tox-collection--list .tox-collection__item:not(.tox-collection__item--enabled) .tox-collection__item-checkmark svg{display:none}.tox .tox-collection--list .tox-collection__item:not(.tox-collection__item--enabled) .tox-collection__item-accessory+.tox-collection__item-checkmark{display:none}.tox .tox-collection--horizontal{background-color:#fff;border:1px solid #ccc;border-radius:3px;box-shadow:0 1px 3px rgba(0,0,0,.15);display:flex;flex:0 0 auto;flex-shrink:0;flex-wrap:nowrap;margin-bottom:0;overflow-x:auto;padding:0}.tox .tox-collection--horizontal .tox-collection__group{align-items:center;display:flex;flex-wrap:nowrap;margin:0;padding:0 4px}.tox .tox-collection--horizontal .tox-collection__item{height:34px;margin:2px 0 3px 0;padding:0 4px}.tox .tox-collection--horizontal .tox-collection__item-label{white-space:nowrap}.tox .tox-collection--horizontal .tox-collection__item-caret{margin-left:4px}.tox .tox-collection__item-container{display:flex}.tox .tox-collection__item-container--row{align-items:center;flex:1 1 auto;flex-direction:row}.tox .tox-collection__item-container--row.tox-collection__item-container--align-left{margin-right:auto}.tox .tox-collection__item-container--row.tox-collection__item-container--align-right{justify-content:flex-end;margin-left:auto}.tox .tox-collection__item-container--row.tox-collection__item-container--valign-top{align-items:flex-start;margin-bottom:auto}.tox .tox-collection__item-container--row.tox-collection__item-container--valign-middle{align-items:center}.tox .tox-collection__item-container--row.tox-collection__item-container--valign-bottom{align-items:flex-end;margin-top:auto}.tox .tox-collection__item-container--column{-ms-grid-row-align:center;align-self:center;flex:1 1 auto;flex-direction:column}.tox .tox-collection__item-container--column.tox-collection__item-container--align-left{align-items:flex-start}.tox .tox-collection__item-container--column.tox-collection__item-container--align-right{align-items:flex-end}.tox .tox-collection__item-container--column.tox-collection__item-container--valign-top{align-self:flex-start}.tox .tox-collection__item-container--column.tox-collection__item-container--valign-middle{-ms-grid-row-align:center;align-self:center}.tox .tox-collection__item-container--column.tox-collection__item-container--valign-bottom{align-self:flex-end}.tox:not([dir=rtl]) .tox-collection--horizontal .tox-collection__group:not(:last-of-type){border-right:1px solid #ccc}.tox:not([dir=rtl]) .tox-collection--list .tox-collection__item>:not(:first-child){margin-left:8px}.tox:not([dir=rtl]) .tox-collection--list .tox-collection__item>.tox-collection__item-label:first-child{margin-left:4px}.tox:not([dir=rtl]) .tox-collection__item-accessory{margin-left:16px;text-align:right}.tox:not([dir=rtl]) .tox-collection .tox-collection__item-caret{margin-left:16px}.tox[dir=rtl] .tox-collection--horizontal .tox-collection__group:not(:last-of-type){border-left:1px solid #ccc}.tox[dir=rtl] .tox-collection--list .tox-collection__item>:not(:first-child){margin-right:8px}.tox[dir=rtl] .tox-collection--list .tox-collection__item>.tox-collection__item-label:first-child{margin-right:4px}.tox[dir=rtl] .tox-collection__item-accessory{margin-right:16px;text-align:left}.tox[dir=rtl] .tox-collection .tox-collection__item-caret{margin-right:16px;transform:rotateY(180deg)}.tox[dir=rtl] .tox-collection--horizontal .tox-collection__item-caret{margin-right:4px}.tox .tox-color-picker-container{display:flex;flex-direction:row;height:225px;margin:0}.tox .tox-sv-palette{box-sizing:border-box;display:flex;height:100%}.tox .tox-sv-palette-spectrum{height:100%}.tox .tox-sv-palette,.tox .tox-sv-palette-spectrum{width:225px}.tox .tox-sv-palette-thumb{background:0 0;border:1px solid #000;border-radius:50%;box-sizing:content-box;height:12px;position:absolute;width:12px}.tox .tox-sv-palette-inner-thumb{border:1px solid #fff;border-radius:50%;height:10px;position:absolute;width:10px}.tox .tox-hue-slider{box-sizing:border-box;height:100%;width:25px}.tox .tox-hue-slider-spectrum{background:linear-gradient(to bottom,red,#ff0080,#f0f,#8000ff,#00f,#0080ff,#0ff,#00ff80,#0f0,#80ff00,#ff0,#ff8000,red);height:100%;width:100%}.tox .tox-hue-slider,.tox .tox-hue-slider-spectrum{width:20px}.tox .tox-hue-slider-thumb{background:#fff;border:1px solid #000;box-sizing:content-box;height:4px;width:100%}.tox .tox-rgb-form{display:flex;flex-direction:column;justify-content:space-between}.tox .tox-rgb-form div{align-items:center;display:flex;justify-content:space-between;margin-bottom:5px;width:inherit}.tox .tox-rgb-form input{width:6em}.tox .tox-rgb-form input.tox-invalid{border:1px solid red!important}.tox .tox-rgb-form .tox-rgba-preview{border:1px solid #000;flex-grow:2;margin-bottom:0}.tox:not([dir=rtl]) .tox-sv-palette{margin-right:15px}.tox:not([dir=rtl]) .tox-hue-slider{margin-right:15px}.tox:not([dir=rtl]) .tox-hue-slider-thumb{margin-left:-1px}.tox:not([dir=rtl]) .tox-rgb-form label{margin-right:.5em}.tox[dir=rtl] .tox-sv-palette{margin-left:15px}.tox[dir=rtl] .tox-hue-slider{margin-left:15px}.tox[dir=rtl] .tox-hue-slider-thumb{margin-right:-1px}.tox[dir=rtl] .tox-rgb-form label{margin-left:.5em}.tox .tox-toolbar .tox-swatches,.tox .tox-toolbar__overflow .tox-swatches,.tox .tox-toolbar__primary .tox-swatches{margin:2px 0 3px 4px}.tox .tox-collection--list .tox-collection__group .tox-swatches-menu{border:0;margin:-4px 0}.tox .tox-swatches__row{display:flex}.tox .tox-swatch{height:30px;transition:transform .15s,box-shadow .15s;width:30px}.tox .tox-swatch:focus,.tox .tox-swatch:hover{box-shadow:0 0 0 1px rgba(127,127,127,.3) inset;transform:scale(.8)}.tox .tox-swatch--remove{align-items:center;display:flex;justify-content:center}.tox .tox-swatch--remove svg path{stroke:#e74c3c}.tox .tox-swatches__picker-btn{align-items:center;background-color:transparent;border:0;cursor:pointer;display:flex;height:30px;justify-content:center;outline:0;padding:0;width:30px}.tox .tox-swatches__picker-btn svg{height:24px;width:24px}.tox .tox-swatches__picker-btn:hover{background:#dee0e2}.tox:not([dir=rtl]) .tox-swatches__picker-btn{margin-left:auto}.tox[dir=rtl] .tox-swatches__picker-btn{margin-right:auto}.tox .tox-comment-thread{background:#fff;position:relative}.tox .tox-comment-thread>:not(:first-child){margin-top:8px}.tox .tox-comment{background:#fff;border:1px solid #ccc;border-radius:3px;box-shadow:0 4px 8px 0 rgba(34,47,62,.1);padding:8px 8px 16px 8px;position:relative}.tox .tox-comment__header{align-items:center;color:#222f3e;display:flex;justify-content:space-between}.tox .tox-comment__date{color:rgba(34,47,62,.7);font-size:12px}.tox .tox-comment__body{color:#222f3e;font-size:14px;font-style:normal;font-weight:400;line-height:1.3;margin-top:8px;position:relative;text-transform:initial}.tox .tox-comment__body textarea{resize:none;white-space:normal;width:100%}.tox .tox-comment__expander{padding-top:8px}.tox .tox-comment__expander p{color:rgba(34,47,62,.7);font-size:14px;font-style:normal}.tox .tox-comment__body p{margin:0}.tox .tox-comment__buttonspacing{padding-top:16px;text-align:center}.tox .tox-comment-thread__overlay::after{background:#fff;bottom:0;content:"";display:flex;left:0;opacity:.9;position:absolute;right:0;top:0;z-index:5}.tox .tox-comment__reply{display:flex;flex-shrink:0;flex-wrap:wrap;justify-content:flex-end;margin-top:8px}.tox .tox-comment__reply>:first-child{margin-bottom:8px;width:100%}.tox .tox-comment__edit{display:flex;flex-wrap:wrap;justify-content:flex-end;margin-top:16px}.tox .tox-comment__gradient::after{background:linear-gradient(rgba(255,255,255,0),#fff);bottom:0;content:"";display:block;height:5em;margin-top:-40px;position:absolute;width:100%}.tox .tox-comment__overlay{background:#fff;bottom:0;display:flex;flex-direction:column;flex-grow:1;left:0;opacity:.9;position:absolute;right:0;text-align:center;top:0;z-index:5}.tox .tox-comment__loading-text{align-items:center;color:#222f3e;display:flex;flex-direction:column;position:relative}.tox .tox-comment__loading-text>div{padding-bottom:16px}.tox .tox-comment__overlaytext{bottom:0;flex-direction:column;font-size:14px;left:0;padding:1em;position:absolute;right:0;top:0;z-index:10}.tox .tox-comment__overlaytext p{background-color:#fff;box-shadow:0 0 8px 8px #fff;color:#222f3e;text-align:center}.tox .tox-comment__overlaytext div:nth-of-type(2){font-size:.8em}.tox .tox-comment__busy-spinner{align-items:center;background-color:#fff;bottom:0;display:flex;justify-content:center;left:0;position:absolute;right:0;top:0;z-index:20}.tox .tox-comment__scroll{display:flex;flex-direction:column;flex-shrink:1;overflow:auto}.tox .tox-conversations{margin:8px}.tox:not([dir=rtl]) .tox-comment__edit{margin-left:8px}.tox:not([dir=rtl]) .tox-comment__buttonspacing>:last-child,.tox:not([dir=rtl]) .tox-comment__edit>:last-child,.tox:not([dir=rtl]) .tox-comment__reply>:last-child{margin-left:8px}.tox[dir=rtl] .tox-comment__edit{margin-right:8px}.tox[dir=rtl] .tox-comment__buttonspacing>:last-child,.tox[dir=rtl] .tox-comment__edit>:last-child,.tox[dir=rtl] .tox-comment__reply>:last-child{margin-right:8px}.tox .tox-user{align-items:center;display:flex}.tox .tox-user__avatar svg{fill:rgba(34,47,62,.7)}.tox .tox-user__name{color:rgba(34,47,62,.7);font-size:12px;font-style:normal;font-weight:700;text-transform:uppercase}.tox:not([dir=rtl]) .tox-user__avatar svg{margin-right:8px}.tox:not([dir=rtl]) .tox-user__avatar+.tox-user__name{margin-left:8px}.tox[dir=rtl] .tox-user__avatar svg{margin-left:8px}.tox[dir=rtl] .tox-user__avatar+.tox-user__name{margin-right:8px}.tox .tox-dialog-wrap{align-items:center;bottom:0;display:flex;justify-content:center;left:0;position:fixed;right:0;top:0;z-index:1100}.tox .tox-dialog-wrap__backdrop{background-color:rgba(255,255,255,.75);bottom:0;left:0;position:absolute;right:0;top:0;z-index:1}.tox .tox-dialog-wrap__backdrop--opaque{background-color:#fff}.tox .tox-dialog{background-color:#fff;border-color:#ccc;border-radius:3px;border-style:solid;border-width:1px;box-shadow:0 16px 16px -10px rgba(34,47,62,.15),0 0 40px 1px rgba(34,47,62,.15);display:flex;flex-direction:column;max-height:100%;max-width:480px;overflow:hidden;position:relative;width:95vw;z-index:2}@media only screen and (max-width:767px){body:not(.tox-force-desktop) .tox .tox-dialog{align-self:flex-start;margin:8px auto;width:calc(100vw - 16px)}}.tox .tox-dialog-inline{z-index:1100}.tox .tox-dialog__header{align-items:center;background-color:#fff;border-bottom:none;color:#222f3e;display:flex;font-size:16px;justify-content:space-between;padding:8px 16px 0 16px;position:relative}.tox .tox-dialog__header .tox-button{z-index:1}.tox .tox-dialog__draghandle{cursor:grab;height:100%;left:0;position:absolute;top:0;width:100%}.tox .tox-dialog__draghandle:active{cursor:grabbing}.tox .tox-dialog__dismiss{margin-left:auto}.tox .tox-dialog__title{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;font-size:20px;font-style:normal;font-weight:400;line-height:1.3;margin:0;text-transform:none}.tox .tox-dialog__body{color:#222f3e;display:flex;flex:1;-ms-flex-preferred-size:auto;font-size:16px;font-style:normal;font-weight:400;line-height:1.3;min-width:0;text-align:left;text-transform:none}@media only screen and (max-width:767px){body:not(.tox-force-desktop) .tox .tox-dialog__body{flex-direction:column}}.tox .tox-dialog__body-nav{align-items:flex-start;display:flex;flex-direction:column;padding:16px 16px}@media only screen and (max-width:767px){body:not(.tox-force-desktop) .tox .tox-dialog__body-nav{flex-direction:row;-webkit-overflow-scrolling:touch;overflow-x:auto;padding-bottom:0}}.tox .tox-dialog__body-nav-item{border-bottom:2px solid transparent;color:rgba(34,47,62,.7);display:inline-block;font-size:14px;line-height:1.3;margin-bottom:8px;text-decoration:none;white-space:nowrap}.tox .tox-dialog__body-nav-item:focus{background-color:rgba(32,122,183,.1)}.tox .tox-dialog__body-nav-item--active{border-bottom:2px solid #207ab7;color:#207ab7}.tox .tox-dialog__body-content{box-sizing:border-box;display:flex;flex:1;flex-direction:column;-ms-flex-preferred-size:auto;max-height:650px;overflow:auto;-webkit-overflow-scrolling:touch;padding:16px 16px}.tox .tox-dialog__body-content>*{margin-bottom:0;margin-top:16px}.tox .tox-dialog__body-content>:first-child{margin-top:0}.tox .tox-dialog__body-content>:last-child{margin-bottom:0}.tox .tox-dialog__body-content>:only-child{margin-bottom:0;margin-top:0}.tox .tox-dialog__body-content a{color:#207ab7;cursor:pointer;text-decoration:none}.tox .tox-dialog__body-content a:focus,.tox .tox-dialog__body-content a:hover{color:#185d8c;text-decoration:none}.tox .tox-dialog__body-content a:active{color:#185d8c;text-decoration:none}.tox .tox-dialog__body-content svg{fill:#222f3e}.tox .tox-dialog__body-content ul{display:block;list-style-type:disc;margin-bottom:16px;-webkit-margin-end:0;margin-inline-end:0;-webkit-margin-start:0;margin-inline-start:0;-webkit-padding-start:2.5rem;padding-inline-start:2.5rem}.tox .tox-dialog__body-content .tox-form__group h1{color:#222f3e;font-size:20px;font-style:normal;font-weight:700;letter-spacing:normal;margin-bottom:16px;margin-top:2rem;text-transform:none}.tox .tox-dialog__body-content .tox-form__group h2{color:#222f3e;font-size:16px;font-style:normal;font-weight:700;letter-spacing:normal;margin-bottom:16px;margin-top:2rem;text-transform:none}.tox .tox-dialog__body-content .tox-form__group p{margin-bottom:16px}.tox .tox-dialog__body-content .tox-form__group h1:first-child,.tox .tox-dialog__body-content .tox-form__group h2:first-child,.tox .tox-dialog__body-content .tox-form__group p:first-child{margin-top:0}.tox .tox-dialog__body-content .tox-form__group h1:last-child,.tox .tox-dialog__body-content .tox-form__group h2:last-child,.tox .tox-dialog__body-content .tox-form__group p:last-child{margin-bottom:0}.tox .tox-dialog__body-content .tox-form__group h1:only-child,.tox .tox-dialog__body-content .tox-form__group h2:only-child,.tox .tox-dialog__body-content .tox-form__group p:only-child{margin-bottom:0;margin-top:0}.tox .tox-dialog--width-lg{height:650px;max-width:1200px}.tox .tox-dialog--width-md{max-width:800px}.tox .tox-dialog--width-md .tox-dialog__body-content{overflow:auto}.tox .tox-dialog__body-content--centered{text-align:center}.tox .tox-dialog__footer{align-items:center;background-color:#fff;border-top:1px solid #ccc;display:flex;justify-content:space-between;padding:8px 16px}.tox .tox-dialog__footer-end,.tox .tox-dialog__footer-start{display:flex}.tox .tox-dialog__busy-spinner{align-items:center;background-color:rgba(255,255,255,.75);bottom:0;display:flex;justify-content:center;left:0;position:absolute;right:0;top:0;z-index:3}.tox .tox-dialog__table{border-collapse:collapse;width:100%}.tox .tox-dialog__table thead th{font-weight:700;padding-bottom:8px}.tox .tox-dialog__table tbody tr{border-bottom:1px solid #ccc}.tox .tox-dialog__table tbody tr:last-child{border-bottom:none}.tox .tox-dialog__table td{padding-bottom:8px;padding-top:8px}.tox .tox-dialog__popups{position:absolute;width:100%;z-index:1100}.tox .tox-dialog__body-iframe{display:flex;flex:1;flex-direction:column;-ms-flex-preferred-size:auto}.tox .tox-dialog__body-iframe .tox-navobj{display:flex;flex:1;-ms-flex-preferred-size:auto}.tox .tox-dialog__body-iframe .tox-navobj :nth-child(2){flex:1;-ms-flex-preferred-size:auto;height:100%}.tox .tox-dialog-dock-fadeout{opacity:0;visibility:hidden}.tox .tox-dialog-dock-fadein{opacity:1;visibility:visible}.tox .tox-dialog-dock-transition{transition:visibility 0s linear .3s,opacity .3s ease}.tox .tox-dialog-dock-transition.tox-dialog-dock-fadein{transition-delay:0s}.tox.tox-platform-ie .tox-dialog-wrap{position:-ms-device-fixed}@media only screen and (max-width:767px){body:not(.tox-force-desktop) .tox:not([dir=rtl]) .tox-dialog__body-nav{margin-right:0}}@media only screen and (max-width:767px){body:not(.tox-force-desktop) .tox:not([dir=rtl]) .tox-dialog__body-nav-item:not(:first-child){margin-left:8px}}.tox:not([dir=rtl]) .tox-dialog__footer .tox-dialog__footer-end>*,.tox:not([dir=rtl]) .tox-dialog__footer .tox-dialog__footer-start>*{margin-left:8px}.tox[dir=rtl] .tox-dialog__body{text-align:right}@media only screen and (max-width:767px){body:not(.tox-force-desktop) .tox[dir=rtl] .tox-dialog__body-nav{margin-left:0}}@media only screen and (max-width:767px){body:not(.tox-force-desktop) .tox[dir=rtl] .tox-dialog__body-nav-item:not(:first-child){margin-right:8px}}.tox[dir=rtl] .tox-dialog__footer .tox-dialog__footer-end>*,.tox[dir=rtl] .tox-dialog__footer .tox-dialog__footer-start>*{margin-right:8px}body.tox-dialog__disable-scroll{overflow:hidden}.tox .tox-dropzone-container{display:flex;flex:1;-ms-flex-preferred-size:auto}.tox .tox-dropzone{align-items:center;background:#fff;border:2px dashed #ccc;box-sizing:border-box;display:flex;flex-direction:column;flex-grow:1;justify-content:center;min-height:100px;padding:10px}.tox .tox-dropzone p{color:rgba(34,47,62,.7);margin:0 0 16px 0}.tox .tox-edit-area{display:flex;flex:1;-ms-flex-preferred-size:auto;overflow:hidden;position:relative}.tox .tox-edit-area__iframe{background-color:#fff;border:0;box-sizing:border-box;flex:1;-ms-flex-preferred-size:auto;height:100%;position:absolute;width:100%}.tox.tox-inline-edit-area{border:1px dotted #ccc}.tox .tox-editor-container{display:flex;flex:1 1 auto;flex-direction:column;overflow:hidden}.tox .tox-editor-header{z-index:1}.tox:not(.tox-tinymce-inline) .tox-editor-header{box-shadow:none;transition:box-shadow .5s}.tox.tox-tinymce--toolbar-bottom .tox-editor-header,.tox.tox-tinymce-inline .tox-editor-header{margin-bottom:-1px}.tox.tox-tinymce--toolbar-sticky-on .tox-editor-header{background-color:transparent;box-shadow:0 4px 4px -3px rgba(0,0,0,.25)}.tox-editor-dock-fadeout{opacity:0;visibility:hidden}.tox-editor-dock-fadein{opacity:1;visibility:visible}.tox-editor-dock-transition{transition:visibility 0s linear .25s,opacity .25s ease}.tox-editor-dock-transition.tox-editor-dock-fadein{transition-delay:0s}.tox .tox-control-wrap{flex:1;position:relative}.tox .tox-control-wrap:not(.tox-control-wrap--status-invalid) .tox-control-wrap__status-icon-invalid,.tox .tox-control-wrap:not(.tox-control-wrap--status-unknown) .tox-control-wrap__status-icon-unknown,.tox .tox-control-wrap:not(.tox-control-wrap--status-valid) .tox-control-wrap__status-icon-valid{display:none}.tox .tox-control-wrap svg{display:block}.tox .tox-control-wrap__status-icon-wrap{position:absolute;top:50%;transform:translateY(-50%)}.tox .tox-control-wrap__status-icon-invalid svg{fill:#c00}.tox .tox-control-wrap__status-icon-unknown svg{fill:orange}.tox .tox-control-wrap__status-icon-valid svg{fill:green}.tox:not([dir=rtl]) .tox-control-wrap--status-invalid .tox-textfield,.tox:not([dir=rtl]) .tox-control-wrap--status-unknown .tox-textfield,.tox:not([dir=rtl]) .tox-control-wrap--status-valid .tox-textfield{padding-right:32px}.tox:not([dir=rtl]) .tox-control-wrap__status-icon-wrap{right:4px}.tox[dir=rtl] .tox-control-wrap--status-invalid .tox-textfield,.tox[dir=rtl] .tox-control-wrap--status-unknown .tox-textfield,.tox[dir=rtl] .tox-control-wrap--status-valid .tox-textfield{padding-left:32px}.tox[dir=rtl] .tox-control-wrap__status-icon-wrap{left:4px}.tox .tox-autocompleter{max-width:25em}.tox .tox-autocompleter .tox-menu{max-width:25em}.tox .tox-autocompleter .tox-autocompleter-highlight{font-weight:700}.tox .tox-color-input{display:flex;position:relative;z-index:1}.tox .tox-color-input .tox-textfield{z-index:-1}.tox .tox-color-input span{border-color:rgba(34,47,62,.2);border-radius:3px;border-style:solid;border-width:1px;box-shadow:none;box-sizing:border-box;height:24px;position:absolute;top:6px;width:24px}.tox .tox-color-input span:focus:not([aria-disabled=true]),.tox .tox-color-input span:hover:not([aria-disabled=true]){border-color:#207ab7;cursor:pointer}.tox .tox-color-input span::before{background-image:linear-gradient(45deg,rgba(0,0,0,.25) 25%,transparent 25%),linear-gradient(-45deg,rgba(0,0,0,.25) 25%,transparent 25%),linear-gradient(45deg,transparent 75%,rgba(0,0,0,.25) 75%),linear-gradient(-45deg,transparent 75%,rgba(0,0,0,.25) 75%);background-position:0 0,0 6px,6px -6px,-6px 0;background-size:12px 12px;border:1px solid #fff;border-radius:3px;box-sizing:border-box;content:'';height:24px;left:-1px;position:absolute;top:-1px;width:24px;z-index:-1}.tox .tox-color-input span[aria-disabled=true]{cursor:not-allowed}.tox:not([dir=rtl]) .tox-color-input .tox-textfield{padding-left:36px}.tox:not([dir=rtl]) .tox-color-input span{left:6px}.tox[dir=rtl] .tox-color-input .tox-textfield{padding-right:36px}.tox[dir=rtl] .tox-color-input span{right:6px}.tox .tox-label,.tox .tox-toolbar-label{color:rgba(34,47,62,.7);display:block;font-size:14px;font-style:normal;font-weight:400;line-height:1.3;padding:0 8px 0 0;text-transform:none;white-space:nowrap}.tox .tox-toolbar-label{padding:0 8px}.tox[dir=rtl] .tox-label{padding:0 0 0 8px}.tox .tox-form{display:flex;flex:1;flex-direction:column;-ms-flex-preferred-size:auto}.tox .tox-form__group{box-sizing:border-box;margin-bottom:4px}.tox .tox-form-group--maximize{flex:1}.tox .tox-form__group--error{color:#c00}.tox .tox-form__group--collection{display:flex}.tox .tox-form__grid{display:flex;flex-direction:row;flex-wrap:wrap;justify-content:space-between}.tox .tox-form__grid--2col>.tox-form__group{width:calc(50% - (8px / 2))}.tox .tox-form__grid--3col>.tox-form__group{width:calc(100% / 3 - (8px / 2))}.tox .tox-form__grid--4col>.tox-form__group{width:calc(25% - (8px / 2))}.tox .tox-form__controls-h-stack{align-items:center;display:flex}.tox .tox-form__group--inline{align-items:center;display:flex}.tox .tox-form__group--stretched{display:flex;flex:1;flex-direction:column;-ms-flex-preferred-size:auto}.tox .tox-form__group--stretched .tox-textarea{flex:1;-ms-flex-preferred-size:auto}.tox .tox-form__group--stretched .tox-navobj{display:flex;flex:1;-ms-flex-preferred-size:auto}.tox .tox-form__group--stretched .tox-navobj :nth-child(2){flex:1;-ms-flex-preferred-size:auto;height:100%}.tox:not([dir=rtl]) .tox-form__controls-h-stack>:not(:first-child){margin-left:4px}.tox[dir=rtl] .tox-form__controls-h-stack>:not(:first-child){margin-right:4px}.tox .tox-lock.tox-locked .tox-lock-icon__unlock,.tox .tox-lock:not(.tox-locked) .tox-lock-icon__lock{display:none}.tox .tox-listboxfield .tox-listbox--select,.tox .tox-textarea,.tox .tox-textfield,.tox .tox-toolbar-textfield{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#ccc;border-radius:3px;border-style:solid;border-width:1px;box-shadow:none;box-sizing:border-box;color:#222f3e;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;font-size:16px;line-height:24px;margin:0;min-height:34px;outline:0;padding:5px 4.75px;resize:none;width:100%}.tox .tox-textarea[disabled],.tox .tox-textfield[disabled]{background-color:#f2f2f2;color:rgba(34,47,62,.85);cursor:not-allowed}.tox .tox-listboxfield .tox-listbox--select:focus,.tox .tox-textarea:focus,.tox .tox-textfield:focus{background-color:#fff;border-color:#207ab7;box-shadow:none;outline:0}.tox .tox-toolbar-textfield{border-width:0;margin-bottom:3px;margin-top:2px;max-width:250px}.tox .tox-naked-btn{background-color:transparent;border:0;border-color:transparent;box-shadow:unset;color:#207ab7;cursor:pointer;display:block;margin:0;padding:0}.tox .tox-naked-btn svg{display:block;fill:#222f3e}.tox:not([dir=rtl]) .tox-toolbar-textfield+*{margin-left:4px}.tox[dir=rtl] .tox-toolbar-textfield+*{margin-right:4px}.tox .tox-listboxfield{cursor:pointer;position:relative}.tox .tox-listboxfield .tox-listbox--select[disabled]{background-color:#f2f2f2;color:rgba(34,47,62,.85);cursor:not-allowed}.tox .tox-listbox__select-label{cursor:default;flex:1;margin:0 4px}.tox .tox-listbox__select-chevron{align-items:center;display:flex;justify-content:center;width:16px}.tox .tox-listbox__select-chevron svg{fill:#222f3e}.tox .tox-listboxfield .tox-listbox--select{align-items:center;display:flex}.tox:not([dir=rtl]) .tox-listboxfield svg{right:8px}.tox[dir=rtl] .tox-listboxfield svg{left:8px}.tox .tox-selectfield{cursor:pointer;position:relative}.tox .tox-selectfield select{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#ccc;border-radius:3px;border-style:solid;border-width:1px;box-shadow:none;box-sizing:border-box;color:#222f3e;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;font-size:16px;line-height:24px;margin:0;min-height:34px;outline:0;padding:5px 4.75px;resize:none;width:100%}.tox .tox-selectfield select[disabled]{background-color:#f2f2f2;color:rgba(34,47,62,.85);cursor:not-allowed}.tox .tox-selectfield select::-ms-expand{display:none}.tox .tox-selectfield select:focus{background-color:#fff;border-color:#207ab7;box-shadow:none;outline:0}.tox .tox-selectfield svg{pointer-events:none;position:absolute;top:50%;transform:translateY(-50%)}.tox:not([dir=rtl]) .tox-selectfield select[size="0"],.tox:not([dir=rtl]) .tox-selectfield select[size="1"]{padding-right:24px}.tox:not([dir=rtl]) .tox-selectfield svg{right:8px}.tox[dir=rtl] .tox-selectfield select[size="0"],.tox[dir=rtl] .tox-selectfield select[size="1"]{padding-left:24px}.tox[dir=rtl] .tox-selectfield svg{left:8px}.tox .tox-textarea{-webkit-appearance:textarea;-moz-appearance:textarea;appearance:textarea;white-space:pre-wrap}.tox-fullscreen{border:0;height:100%;margin:0;overflow:hidden;-ms-scroll-chaining:none;overscroll-behavior:none;padding:0;touch-action:pinch-zoom;width:100%}.tox.tox-tinymce.tox-fullscreen .tox-statusbar__resize-handle{display:none}.tox-shadowhost.tox-fullscreen,.tox.tox-tinymce.tox-fullscreen{left:0;position:fixed;top:0;z-index:1200}.tox.tox-tinymce.tox-fullscreen{background-color:transparent}.tox-fullscreen .tox.tox-tinymce-aux,.tox-fullscreen~.tox.tox-tinymce-aux{z-index:1201}.tox .tox-help__more-link{list-style:none;margin-top:1em}.tox .tox-image-tools{width:100%}.tox .tox-image-tools__toolbar{align-items:center;display:flex;justify-content:center}.tox .tox-image-tools__image{background-color:#666;height:380px;overflow:auto;position:relative;width:100%}.tox .tox-image-tools__image,.tox .tox-image-tools__image+.tox-image-tools__toolbar{margin-top:8px}.tox .tox-image-tools__image-bg{background:url(data:image/gif;base64,R0lGODdhDAAMAIABAMzMzP///ywAAAAADAAMAAACFoQfqYeabNyDMkBQb81Uat85nxguUAEAOw==)}.tox .tox-image-tools__toolbar>.tox-spacer{flex:1;-ms-flex-preferred-size:auto}.tox .tox-croprect-block{background:#000;opacity:.5;position:absolute;zoom:1}.tox .tox-croprect-handle{border:2px solid #fff;height:20px;left:0;position:absolute;top:0;width:20px}.tox .tox-croprect-handle-move{border:0;cursor:move;position:absolute}.tox .tox-croprect-handle-nw{border-width:2px 0 0 2px;cursor:nw-resize;left:100px;margin:-2px 0 0 -2px;top:100px}.tox .tox-croprect-handle-ne{border-width:2px 2px 0 0;cursor:ne-resize;left:200px;margin:-2px 0 0 -20px;top:100px}.tox .tox-croprect-handle-sw{border-width:0 0 2px 2px;cursor:sw-resize;left:100px;margin:-20px 2px 0 -2px;top:200px}.tox .tox-croprect-handle-se{border-width:0 2px 2px 0;cursor:se-resize;left:200px;margin:-20px 0 0 -20px;top:200px}.tox:not([dir=rtl]) .tox-image-tools__toolbar>.tox-slider:not(:first-of-type){margin-left:8px}.tox:not([dir=rtl]) .tox-image-tools__toolbar>.tox-button+.tox-slider{margin-left:32px}.tox:not([dir=rtl]) .tox-image-tools__toolbar>.tox-slider+.tox-button{margin-left:32px}.tox[dir=rtl] .tox-image-tools__toolbar>.tox-slider:not(:first-of-type){margin-right:8px}.tox[dir=rtl] .tox-image-tools__toolbar>.tox-button+.tox-slider{margin-right:32px}.tox[dir=rtl] .tox-image-tools__toolbar>.tox-slider+.tox-button{margin-right:32px}.tox .tox-insert-table-picker{display:flex;flex-wrap:wrap;width:170px}.tox .tox-insert-table-picker>div{border-color:#ccc;border-style:solid;border-width:0 1px 1px 0;box-sizing:border-box;height:17px;width:17px}.tox .tox-collection--list .tox-collection__group .tox-insert-table-picker{margin:-4px 0}.tox .tox-insert-table-picker .tox-insert-table-picker__selected{background-color:rgba(32,122,183,.5);border-color:rgba(32,122,183,.5)}.tox .tox-insert-table-picker__label{color:rgba(34,47,62,.7);display:block;font-size:14px;padding:4px;text-align:center;width:100%}.tox:not([dir=rtl]) .tox-insert-table-picker>div:nth-child(10n){border-right:0}.tox[dir=rtl] .tox-insert-table-picker>div:nth-child(10n+1){border-right:0}.tox .tox-menu{background-color:#fff;border:1px solid #ccc;border-radius:3px;box-shadow:0 4px 8px 0 rgba(34,47,62,.1);display:inline-block;overflow:hidden;vertical-align:top;z-index:1150}.tox .tox-menu.tox-collection.tox-collection--list{padding:0}.tox .tox-menu.tox-collection.tox-collection--toolbar{padding:4px}.tox .tox-menu.tox-collection.tox-collection--grid{padding:4px}.tox .tox-menu__label blockquote,.tox .tox-menu__label code,.tox .tox-menu__label h1,.tox .tox-menu__label h2,.tox .tox-menu__label h3,.tox .tox-menu__label h4,.tox .tox-menu__label h5,.tox .tox-menu__label h6,.tox .tox-menu__label p{margin:0}.tox .tox-menubar{background:url("data:image/svg+xml;charset=utf8,%3Csvg height='39px' viewBox='0 0 40 39px' width='40' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='0' y='38px' width='100' height='1' fill='%23cccccc'/%3E%3C/svg%3E") left 0 top 0 #fff;background-color:#fff;display:flex;flex:0 0 auto;flex-shrink:0;flex-wrap:wrap;padding:0 4px 0 4px}.tox.tox-tinymce:not(.tox-tinymce-inline) .tox-editor-header:not(:first-child) .tox-menubar{border-top:1px solid #ccc}.tox .tox-mbtn{align-items:center;background:0 0;border:0;border-radius:3px;box-shadow:none;color:#222f3e;display:flex;flex:0 0 auto;font-size:14px;font-style:normal;font-weight:400;height:34px;justify-content:center;margin:2px 0 3px 0;outline:0;overflow:hidden;padding:0 4px;text-transform:none;width:auto}.tox .tox-mbtn[disabled]{background-color:transparent;border:0;box-shadow:none;color:rgba(34,47,62,.5);cursor:not-allowed}.tox .tox-mbtn:focus:not(:disabled){background:#dee0e2;border:0;box-shadow:none;color:#222f3e}.tox .tox-mbtn--active{background:#c8cbcf;border:0;box-shadow:none;color:#222f3e}.tox .tox-mbtn:hover:not(:disabled):not(.tox-mbtn--active){background:#dee0e2;border:0;box-shadow:none;color:#222f3e}.tox .tox-mbtn__select-label{cursor:default;font-weight:400;margin:0 4px}.tox .tox-mbtn[disabled] .tox-mbtn__select-label{cursor:not-allowed}.tox .tox-mbtn__select-chevron{align-items:center;display:flex;justify-content:center;width:16px;display:none}.tox .tox-notification{border-radius:3px;border-style:solid;border-width:1px;box-shadow:none;box-sizing:border-box;display:-ms-grid;display:grid;font-size:14px;font-weight:400;-ms-grid-columns:minmax(40px,1fr) auto minmax(40px,1fr);grid-template-columns:minmax(40px,1fr) auto minmax(40px,1fr);margin-top:4px;opacity:0;padding:4px;transition:transform .1s ease-in,opacity 150ms ease-in}.tox .tox-notification p{font-size:14px;font-weight:400}.tox .tox-notification a{cursor:pointer;text-decoration:underline}.tox .tox-notification--in{opacity:1}.tox .tox-notification--success{background-color:#e4eeda;border-color:#d7e6c8;color:#222f3e}.tox .tox-notification--success p{color:#222f3e}.tox .tox-notification--success a{color:#547831}.tox .tox-notification--success svg{fill:#222f3e}.tox .tox-notification--error{background-color:#f8dede;border-color:#f2bfbf;color:#222f3e}.tox .tox-notification--error p{color:#222f3e}.tox .tox-notification--error a{color:#c00}.tox .tox-notification--error svg{fill:#222f3e}.tox .tox-notification--warn,.tox .tox-notification--warning{background-color:#fffaea;border-color:#ffe89d;color:#222f3e}.tox .tox-notification--warn p,.tox .tox-notification--warning p{color:#222f3e}.tox .tox-notification--warn a,.tox .tox-notification--warning a{color:#222f3e}.tox .tox-notification--warn svg,.tox .tox-notification--warning svg{fill:#222f3e}.tox .tox-notification--info{background-color:#d9edf7;border-color:#779ecb;color:#222f3e}.tox .tox-notification--info p{color:#222f3e}.tox .tox-notification--info a{color:#222f3e}.tox .tox-notification--info svg{fill:#222f3e}.tox .tox-notification__body{-ms-grid-row-align:center;align-self:center;color:#222f3e;font-size:14px;-ms-grid-column-span:1;grid-column-end:3;-ms-grid-column:2;grid-column-start:2;-ms-grid-row-span:1;grid-row-end:2;-ms-grid-row:1;grid-row-start:1;text-align:center;white-space:normal;word-break:break-all;word-break:break-word}.tox .tox-notification__body>*{margin:0}.tox .tox-notification__body>*+*{margin-top:1rem}.tox .tox-notification__icon{-ms-grid-row-align:center;align-self:center;-ms-grid-column-span:1;grid-column-end:2;-ms-grid-column:1;grid-column-start:1;-ms-grid-row-span:1;grid-row-end:2;-ms-grid-row:1;grid-row-start:1;-ms-grid-column-align:end;justify-self:end}.tox .tox-notification__icon svg{display:block}.tox .tox-notification__dismiss{-ms-grid-row-align:start;align-self:start;-ms-grid-column-span:1;grid-column-end:4;-ms-grid-column:3;grid-column-start:3;-ms-grid-row-span:1;grid-row-end:2;-ms-grid-row:1;grid-row-start:1;-ms-grid-column-align:end;justify-self:end}.tox .tox-notification .tox-progress-bar{-ms-grid-column-span:3;grid-column-end:4;-ms-grid-column:1;grid-column-start:1;-ms-grid-row-span:1;grid-row-end:3;-ms-grid-row:2;grid-row-start:2;-ms-grid-column-align:center;justify-self:center}.tox .tox-pop{display:inline-block;position:relative}.tox .tox-pop--resizing{transition:width .1s ease}.tox .tox-pop--resizing .tox-toolbar,.tox .tox-pop--resizing .tox-toolbar__group{flex-wrap:nowrap}.tox .tox-pop--transition{transition:.15s ease;transition-property:left,right,top,bottom}.tox .tox-pop--transition::after,.tox .tox-pop--transition::before{transition:all .15s,visibility 0s,opacity 75ms ease 75ms}.tox .tox-pop__dialog{background-color:#fff;border:1px solid #ccc;border-radius:3px;box-shadow:0 1px 3px rgba(0,0,0,.15);min-width:0;overflow:hidden}.tox .tox-pop__dialog>:not(.tox-toolbar){margin:4px 4px 4px 8px}.tox .tox-pop__dialog .tox-toolbar{background-color:transparent;margin-bottom:-1px}.tox .tox-pop::after,.tox .tox-pop::before{border-style:solid;content:'';display:block;height:0;opacity:1;position:absolute;width:0}.tox .tox-pop.tox-pop--inset::after,.tox .tox-pop.tox-pop--inset::before{opacity:0;transition:all 0s .15s,visibility 0s,opacity 75ms ease}.tox .tox-pop.tox-pop--bottom::after,.tox .tox-pop.tox-pop--bottom::before{left:50%;top:100%}.tox .tox-pop.tox-pop--bottom::after{border-color:#fff transparent transparent transparent;border-width:8px;margin-left:-8px;margin-top:-1px}.tox .tox-pop.tox-pop--bottom::before{border-color:#ccc transparent transparent transparent;border-width:9px;margin-left:-9px}.tox .tox-pop.tox-pop--top::after,.tox .tox-pop.tox-pop--top::before{left:50%;top:0;transform:translateY(-100%)}.tox .tox-pop.tox-pop--top::after{border-color:transparent transparent #fff transparent;border-width:8px;margin-left:-8px;margin-top:1px}.tox .tox-pop.tox-pop--top::before{border-color:transparent transparent #ccc transparent;border-width:9px;margin-left:-9px}.tox .tox-pop.tox-pop--left::after,.tox .tox-pop.tox-pop--left::before{left:0;top:calc(50% - 1px);transform:translateY(-50%)}.tox .tox-pop.tox-pop--left::after{border-color:transparent #fff transparent transparent;border-width:8px;margin-left:-15px}.tox .tox-pop.tox-pop--left::before{border-color:transparent #ccc transparent transparent;border-width:10px;margin-left:-19px}.tox .tox-pop.tox-pop--right::after,.tox .tox-pop.tox-pop--right::before{left:100%;top:calc(50% + 1px);transform:translateY(-50%)}.tox .tox-pop.tox-pop--right::after{border-color:transparent transparent transparent #fff;border-width:8px;margin-left:-1px}.tox .tox-pop.tox-pop--right::before{border-color:transparent transparent transparent #ccc;border-width:10px;margin-left:-1px}.tox .tox-pop.tox-pop--align-left::after,.tox .tox-pop.tox-pop--align-left::before{left:20px}.tox .tox-pop.tox-pop--align-right::after,.tox .tox-pop.tox-pop--align-right::before{left:calc(100% - 20px)}.tox .tox-sidebar-wrap{display:flex;flex-direction:row;flex-grow:1;-ms-flex-preferred-size:0;min-height:0}.tox .tox-sidebar{background-color:#fff;display:flex;flex-direction:row;justify-content:flex-end}.tox .tox-sidebar__slider{display:flex;overflow:hidden}.tox .tox-sidebar__pane-container{display:flex}.tox .tox-sidebar__pane{display:flex}.tox .tox-sidebar--sliding-closed{opacity:0}.tox .tox-sidebar--sliding-open{opacity:1}.tox .tox-sidebar--sliding-growing,.tox .tox-sidebar--sliding-shrinking{transition:width .5s ease,opacity .5s ease}.tox .tox-selector{background-color:#4099ff;border-color:#4099ff;border-style:solid;border-width:1px;box-sizing:border-box;display:inline-block;height:10px;position:absolute;width:10px}.tox.tox-platform-touch .tox-selector{height:12px;width:12px}.tox .tox-slider{align-items:center;display:flex;flex:1;-ms-flex-preferred-size:auto;height:24px;justify-content:center;position:relative}.tox .tox-slider__rail{background-color:transparent;border:1px solid #ccc;border-radius:3px;height:10px;min-width:120px;width:100%}.tox .tox-slider__handle{background-color:#207ab7;border:2px solid #185d8c;border-radius:3px;box-shadow:none;height:24px;left:50%;position:absolute;top:50%;transform:translateX(-50%) translateY(-50%);width:14px}.tox .tox-source-code{overflow:auto}.tox .tox-spinner{display:flex}.tox .tox-spinner>div{animation:tam-bouncing-dots 1.5s ease-in-out 0s infinite both;background-color:rgba(34,47,62,.7);border-radius:100%;height:8px;width:8px}.tox .tox-spinner>div:nth-child(1){animation-delay:-.32s}.tox .tox-spinner>div:nth-child(2){animation-delay:-.16s}@keyframes tam-bouncing-dots{0%,100%,80%{transform:scale(0)}40%{transform:scale(1)}}.tox:not([dir=rtl]) .tox-spinner>div:not(:first-child){margin-left:4px}.tox[dir=rtl] .tox-spinner>div:not(:first-child){margin-right:4px}.tox .tox-statusbar{align-items:center;background-color:#fff;border-top:1px solid #ccc;color:rgba(34,47,62,.7);display:flex;flex:0 0 auto;font-size:12px;font-weight:400;height:18px;overflow:hidden;padding:0 8px;position:relative;text-transform:uppercase}.tox .tox-statusbar__text-container{display:flex;flex:1 1 auto;justify-content:flex-end;overflow:hidden}.tox .tox-statusbar__path{display:flex;flex:1 1 auto;margin-right:auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tox .tox-statusbar__path>*{display:inline;white-space:nowrap}.tox .tox-statusbar__wordcount{flex:0 0 auto;margin-left:1ch}.tox .tox-statusbar a,.tox .tox-statusbar__path-item,.tox .tox-statusbar__wordcount{color:rgba(34,47,62,.7);text-decoration:none}.tox .tox-statusbar a:focus:not(:disabled):not([aria-disabled=true]),.tox .tox-statusbar a:hover:not(:disabled):not([aria-disabled=true]),.tox .tox-statusbar__path-item:focus:not(:disabled):not([aria-disabled=true]),.tox .tox-statusbar__path-item:hover:not(:disabled):not([aria-disabled=true]),.tox .tox-statusbar__wordcount:focus:not(:disabled):not([aria-disabled=true]),.tox .tox-statusbar__wordcount:hover:not(:disabled):not([aria-disabled=true]){cursor:pointer;text-decoration:underline}.tox .tox-statusbar__resize-handle{align-items:flex-end;align-self:stretch;cursor:nwse-resize;display:flex;flex:0 0 auto;justify-content:flex-end;margin-left:auto;margin-right:-8px;padding-left:1ch}.tox .tox-statusbar__resize-handle svg{display:block;fill:rgba(34,47,62,.7)}.tox .tox-statusbar__resize-handle:focus svg{background-color:#dee0e2;border-radius:1px;box-shadow:0 0 0 2px #dee0e2}.tox:not([dir=rtl]) .tox-statusbar__path>*{margin-right:4px}.tox:not([dir=rtl]) .tox-statusbar__branding{margin-left:1ch}.tox[dir=rtl] .tox-statusbar{flex-direction:row-reverse}.tox[dir=rtl] .tox-statusbar__path>*{margin-left:4px}.tox .tox-throbber{z-index:1299}.tox .tox-throbber__busy-spinner{align-items:center;background-color:rgba(255,255,255,.6);bottom:0;display:flex;justify-content:center;left:0;position:absolute;right:0;top:0}.tox .tox-tbtn{align-items:center;background:0 0;border:0;border-radius:3px;box-shadow:none;color:#222f3e;display:flex;flex:0 0 auto;font-size:14px;font-style:normal;font-weight:400;height:34px;justify-content:center;margin:2px 0 3px 0;outline:0;overflow:hidden;padding:0;text-transform:none;width:34px}.tox .tox-tbtn svg{display:block;fill:#222f3e}.tox .tox-tbtn.tox-tbtn-more{padding-left:5px;padding-right:5px;width:inherit}.tox .tox-tbtn:focus{background:#dee0e2;border:0;box-shadow:none}.tox .tox-tbtn:hover{background:#dee0e2;border:0;box-shadow:none;color:#222f3e}.tox .tox-tbtn:hover svg{fill:#222f3e}.tox .tox-tbtn:active{background:#c8cbcf;border:0;box-shadow:none;color:#222f3e}.tox .tox-tbtn:active svg{fill:#222f3e}.tox .tox-tbtn--disabled,.tox .tox-tbtn--disabled:hover,.tox .tox-tbtn:disabled,.tox .tox-tbtn:disabled:hover{background:0 0;border:0;box-shadow:none;color:rgba(34,47,62,.5);cursor:not-allowed}.tox .tox-tbtn--disabled svg,.tox .tox-tbtn--disabled:hover svg,.tox .tox-tbtn:disabled svg,.tox .tox-tbtn:disabled:hover svg{fill:rgba(34,47,62,.5)}.tox .tox-tbtn--enabled,.tox .tox-tbtn--enabled:hover{background:#c8cbcf;border:0;box-shadow:none;color:#222f3e}.tox .tox-tbtn--enabled:hover>*,.tox .tox-tbtn--enabled>*{transform:none}.tox .tox-tbtn--enabled svg,.tox .tox-tbtn--enabled:hover svg{fill:#222f3e}.tox .tox-tbtn:focus:not(.tox-tbtn--disabled){color:#222f3e}.tox .tox-tbtn:focus:not(.tox-tbtn--disabled) svg{fill:#222f3e}.tox .tox-tbtn:active>*{transform:none}.tox .tox-tbtn--md{height:51px;width:51px}.tox .tox-tbtn--lg{flex-direction:column;height:68px;width:68px}.tox .tox-tbtn--return{-ms-grid-row-align:stretch;align-self:stretch;height:unset;width:16px}.tox .tox-tbtn--labeled{padding:0 4px;width:unset}.tox .tox-tbtn__vlabel{display:block;font-size:10px;font-weight:400;letter-spacing:-.025em;margin-bottom:4px;white-space:nowrap}.tox .tox-tbtn--select{margin:2px 0 3px 0;padding:0 4px;width:auto}.tox .tox-tbtn__select-label{cursor:default;font-weight:400;margin:0 4px}.tox .tox-tbtn__select-chevron{align-items:center;display:flex;justify-content:center;width:16px}.tox .tox-tbtn__select-chevron svg{fill:rgba(34,47,62,.5)}.tox .tox-tbtn--bespoke .tox-tbtn__select-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;width:7em}.tox .tox-split-button{border:0;border-radius:3px;box-sizing:border-box;display:flex;margin:2px 0 3px 0;overflow:hidden}.tox .tox-split-button:hover{box-shadow:0 0 0 1px #dee0e2 inset}.tox .tox-split-button:focus{background:#dee0e2;box-shadow:none;color:#222f3e}.tox .tox-split-button>*{border-radius:0}.tox .tox-split-button__chevron{width:16px}.tox .tox-split-button__chevron svg{fill:rgba(34,47,62,.5)}.tox .tox-split-button .tox-tbtn{margin:0}.tox.tox-platform-touch .tox-split-button .tox-tbtn:first-child{width:30px}.tox.tox-platform-touch .tox-split-button__chevron{width:20px}.tox .tox-split-button.tox-tbtn--disabled .tox-tbtn:focus,.tox .tox-split-button.tox-tbtn--disabled .tox-tbtn:hover,.tox .tox-split-button.tox-tbtn--disabled:focus,.tox .tox-split-button.tox-tbtn--disabled:hover{background:0 0;box-shadow:none;color:rgba(34,47,62,.5)}.tox .tox-toolbar-overlord{background-color:#fff}.tox .tox-toolbar,.tox .tox-toolbar__overflow,.tox .tox-toolbar__primary{background:url("data:image/svg+xml;charset=utf8,%3Csvg height='39px' viewBox='0 0 40 39px' width='40' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='0' y='38px' width='100' height='1' fill='%23cccccc'/%3E%3C/svg%3E") left 0 top 0 #fff;background-color:#fff;display:flex;flex:0 0 auto;flex-shrink:0;flex-wrap:wrap;padding:0 0}.tox .tox-toolbar__overflow.tox-toolbar__overflow--closed{height:0;opacity:0;padding-bottom:0;padding-top:0;visibility:hidden}.tox .tox-toolbar__overflow--growing{transition:height .3s ease,opacity .2s linear .1s}.tox .tox-toolbar__overflow--shrinking{transition:opacity .3s ease,height .2s linear .1s,visibility 0s linear .3s}.tox .tox-menubar+.tox-toolbar,.tox .tox-menubar+.tox-toolbar-overlord .tox-toolbar__primary{border-top:1px solid #ccc;margin-top:-1px}.tox .tox-toolbar--scrolling{flex-wrap:nowrap;overflow-x:auto}.tox .tox-pop .tox-toolbar{border-width:0}.tox .tox-toolbar--no-divider{background-image:none}.tox-tinymce:not(.tox-tinymce-inline) .tox-editor-header:not(:first-child) .tox-toolbar-overlord:first-child .tox-toolbar__primary,.tox-tinymce:not(.tox-tinymce-inline) .tox-editor-header:not(:first-child) .tox-toolbar:first-child{border-top:1px solid #ccc}.tox.tox-tinymce-aux .tox-toolbar__overflow{background-color:#fff;border:1px solid #ccc;border-radius:3px;box-shadow:0 1px 3px rgba(0,0,0,.15)}.tox .tox-toolbar__group{align-items:center;display:flex;flex-wrap:wrap;margin:0 0;padding:0 4px 0 4px}.tox .tox-toolbar__group--pull-right{margin-left:auto}.tox .tox-toolbar--scrolling .tox-toolbar__group{flex-shrink:0;flex-wrap:nowrap}.tox:not([dir=rtl]) .tox-toolbar__group:not(:last-of-type){border-right:1px solid #ccc}.tox[dir=rtl] .tox-toolbar__group:not(:last-of-type){border-left:1px solid #ccc}.tox .tox-tooltip{display:inline-block;padding:8px;position:relative}.tox .tox-tooltip__body{background-color:#222f3e;border-radius:3px;box-shadow:0 2px 4px rgba(34,47,62,.3);color:rgba(255,255,255,.75);font-size:14px;font-style:normal;font-weight:400;padding:4px 8px;text-transform:none}.tox .tox-tooltip__arrow{position:absolute}.tox .tox-tooltip--down .tox-tooltip__arrow{border-left:8px solid transparent;border-right:8px solid transparent;border-top:8px solid #222f3e;bottom:0;left:50%;position:absolute;transform:translateX(-50%)}.tox .tox-tooltip--up .tox-tooltip__arrow{border-bottom:8px solid #222f3e;border-left:8px solid transparent;border-right:8px solid transparent;left:50%;position:absolute;top:0;transform:translateX(-50%)}.tox .tox-tooltip--right .tox-tooltip__arrow{border-bottom:8px solid transparent;border-left:8px solid #222f3e;border-top:8px solid transparent;position:absolute;right:0;top:50%;transform:translateY(-50%)}.tox .tox-tooltip--left .tox-tooltip__arrow{border-bottom:8px solid transparent;border-right:8px solid #222f3e;border-top:8px solid transparent;left:0;position:absolute;top:50%;transform:translateY(-50%)}.tox .tox-well{border:1px solid #ccc;border-radius:3px;padding:8px;width:100%}.tox .tox-well>:first-child{margin-top:0}.tox .tox-well>:last-child{margin-bottom:0}.tox .tox-well>:only-child{margin:0}.tox .tox-custom-editor{border:1px solid #ccc;border-radius:3px;display:flex;flex:1;position:relative}.tox .tox-dialog-loading::before{background-color:rgba(0,0,0,.5);content:"";height:100%;position:absolute;width:100%;z-index:1000}.tox .tox-tab{cursor:pointer}.tox .tox-dialog__content-js{display:flex;flex:1;-ms-flex-preferred-size:auto}.tox .tox-dialog__body-content .tox-collection{display:flex;flex:1;-ms-flex-preferred-size:auto}.tox .tox-image-tools-edit-panel{height:60px}.tox .tox-image-tools__sidebar{height:60px} diff --git a/ui/public/tinymce/skins/ui/oxide/skin.mobile.css b/ui/public/tinymce/skins/ui/oxide/skin.mobile.css new file mode 100644 index 0000000..875721a --- /dev/null +++ b/ui/public/tinymce/skins/ui/oxide/skin.mobile.css @@ -0,0 +1,673 @@ +/** + * Copyright (c) Tiny Technologies, Inc. All rights reserved. + * Licensed under the LGPL or a commercial license. + * For LGPL see License.txt in the project root for license information. + * For commercial licenses see https://www.tiny.cloud/ + */ +/* RESET all the things! */ +.tinymce-mobile-outer-container { + all: initial; + display: block; +} +.tinymce-mobile-outer-container * { + border: 0; + box-sizing: initial; + cursor: inherit; + float: none; + line-height: 1; + margin: 0; + outline: 0; + padding: 0; + -webkit-tap-highlight-color: transparent; + /* TBIO-3691, stop the gray flicker on touch. */ + text-shadow: none; + white-space: nowrap; +} +.tinymce-mobile-icon-arrow-back::before { + content: "\e5cd"; +} +.tinymce-mobile-icon-image::before { + content: "\e412"; +} +.tinymce-mobile-icon-cancel-circle::before { + content: "\e5c9"; +} +.tinymce-mobile-icon-full-dot::before { + content: "\e061"; +} +.tinymce-mobile-icon-align-center::before { + content: "\e234"; +} +.tinymce-mobile-icon-align-left::before { + content: "\e236"; +} +.tinymce-mobile-icon-align-right::before { + content: "\e237"; +} +.tinymce-mobile-icon-bold::before { + content: "\e238"; +} +.tinymce-mobile-icon-italic::before { + content: "\e23f"; +} +.tinymce-mobile-icon-unordered-list::before { + content: "\e241"; +} +.tinymce-mobile-icon-ordered-list::before { + content: "\e242"; +} +.tinymce-mobile-icon-font-size::before { + content: "\e245"; +} +.tinymce-mobile-icon-underline::before { + content: "\e249"; +} +.tinymce-mobile-icon-link::before { + content: "\e157"; +} +.tinymce-mobile-icon-unlink::before { + content: "\eca2"; +} +.tinymce-mobile-icon-color::before { + content: "\e891"; +} +.tinymce-mobile-icon-previous::before { + content: "\e314"; +} +.tinymce-mobile-icon-next::before { + content: "\e315"; +} +.tinymce-mobile-icon-large-font::before, +.tinymce-mobile-icon-style-formats::before { + content: "\e264"; +} +.tinymce-mobile-icon-undo::before { + content: "\e166"; +} +.tinymce-mobile-icon-redo::before { + content: "\e15a"; +} +.tinymce-mobile-icon-removeformat::before { + content: "\e239"; +} +.tinymce-mobile-icon-small-font::before { + content: "\e906"; +} +.tinymce-mobile-icon-readonly-back::before, +.tinymce-mobile-format-matches::after { + content: "\e5ca"; +} +.tinymce-mobile-icon-small-heading::before { + content: "small"; +} +.tinymce-mobile-icon-large-heading::before { + content: "large"; +} +.tinymce-mobile-icon-small-heading::before, +.tinymce-mobile-icon-large-heading::before { + font-family: sans-serif; + font-size: 80%; +} +.tinymce-mobile-mask-edit-icon::before { + content: "\e254"; +} +.tinymce-mobile-icon-back::before { + content: "\e5c4"; +} +.tinymce-mobile-icon-heading::before { + /* TODO: Translate */ + content: "Headings"; + font-family: sans-serif; + font-size: 80%; + font-weight: bold; +} +.tinymce-mobile-icon-h1::before { + content: "H1"; + font-weight: bold; +} +.tinymce-mobile-icon-h2::before { + content: "H2"; + font-weight: bold; +} +.tinymce-mobile-icon-h3::before { + content: "H3"; + font-weight: bold; +} +.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask { + align-items: center; + display: flex; + justify-content: center; + background: rgba(51, 51, 51, 0.5); + height: 100%; + position: absolute; + top: 0; + width: 100%; +} +.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container { + align-items: center; + border-radius: 50%; + display: flex; + flex-direction: column; + font-family: sans-serif; + font-size: 1em; + justify-content: space-between; +} +.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .mixin-menu-item { + align-items: center; + display: flex; + justify-content: center; + border-radius: 50%; + height: 2.1em; + width: 2.1em; +} +.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section { + align-items: center; + display: flex; + justify-content: center; + flex-direction: column; + font-size: 1em; +} +@media only screen and (min-device-width:700px) { + .tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section { + font-size: 1.2em; + } +} +.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section .tinymce-mobile-mask-tap-icon { + align-items: center; + display: flex; + justify-content: center; + border-radius: 50%; + height: 2.1em; + width: 2.1em; + background-color: white; + color: #207ab7; +} +.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section .tinymce-mobile-mask-tap-icon::before { + content: "\e900"; + font-family: 'tinymce-mobile', sans-serif; +} +.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section:not(.tinymce-mobile-mask-tap-icon-selected) .tinymce-mobile-mask-tap-icon { + z-index: 2; +} +.tinymce-mobile-android-container.tinymce-mobile-android-maximized { + background: #ffffff; + border: none; + bottom: 0; + display: flex; + flex-direction: column; + left: 0; + position: fixed; + right: 0; + top: 0; +} +.tinymce-mobile-android-container:not(.tinymce-mobile-android-maximized) { + position: relative; +} +.tinymce-mobile-android-container .tinymce-mobile-editor-socket { + display: flex; + flex-grow: 1; +} +.tinymce-mobile-android-container .tinymce-mobile-editor-socket iframe { + display: flex !important; + flex-grow: 1; + height: auto !important; +} +.tinymce-mobile-android-scroll-reload { + overflow: hidden; +} +:not(.tinymce-mobile-readonly-mode) > .tinymce-mobile-android-selection-context-toolbar { + margin-top: 23px; +} +.tinymce-mobile-toolstrip { + background: #fff; + display: flex; + flex: 0 0 auto; + z-index: 1; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar { + align-items: center; + background-color: #fff; + border-bottom: 1px solid #cccccc; + display: flex; + flex: 1; + height: 2.5em; + width: 100%; + /* Make it no larger than the toolstrip, so that it needs to scroll */ +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group { + align-items: center; + display: flex; + height: 100%; + flex-shrink: 1; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group > div { + align-items: center; + display: flex; + height: 100%; + flex: 1; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group.tinymce-mobile-exit-container { + background: #f44336; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group.tinymce-mobile-toolbar-scrollable-group { + flex-grow: 1; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item { + padding-left: 0.5em; + padding-right: 0.5em; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item.tinymce-mobile-toolbar-button { + align-items: center; + display: flex; + height: 80%; + margin-left: 2px; + margin-right: 2px; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item.tinymce-mobile-toolbar-button.tinymce-mobile-toolbar-button-selected { + background: #c8cbcf; + color: #cccccc; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group:first-of-type, +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group:last-of-type { + background: #207ab7; + color: #eceff1; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar { + /* Note, this file is imported inside .tinymce-mobile-context-toolbar, so that prefix is on everything here. */ +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group { + align-items: center; + display: flex; + height: 100%; + flex: 1; + padding-bottom: 0.4em; + padding-top: 0.4em; + /* Make any buttons appearing on the left and right display in the centre (e.g. color edges) */ + /* For widgets like the colour picker, use the whole height */ +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog { + display: flex; + min-height: 1.5em; + overflow: hidden; + padding-left: 0; + padding-right: 0; + position: relative; + width: 100%; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain { + display: flex; + height: 100%; + transition: left cubic-bezier(0.4, 0, 1, 1) 0.15s; + width: 100%; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen { + display: flex; + flex: 0 0 auto; + justify-content: space-between; + width: 100%; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen input { + font-family: Sans-serif; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-input-container { + display: flex; + flex-grow: 1; + position: relative; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-input-container .tinymce-mobile-input-container-x { + -ms-grid-row-align: center; + align-self: center; + background: inherit; + border: none; + border-radius: 50%; + color: #888; + font-size: 0.6em; + font-weight: bold; + height: 100%; + padding-right: 2px; + position: absolute; + right: 0; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-input-container.tinymce-mobile-input-container-empty .tinymce-mobile-input-container-x { + display: none; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-previous, +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-next { + align-items: center; + display: flex; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-previous::before, +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-next::before { + align-items: center; + display: flex; + font-weight: bold; + height: 100%; + padding-left: 0.5em; + padding-right: 0.5em; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-previous.tinymce-mobile-toolbar-navigation-disabled::before, +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-next.tinymce-mobile-toolbar-navigation-disabled::before { + visibility: hidden; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-dot-item { + color: #cccccc; + font-size: 10px; + line-height: 10px; + margin: 0 2px; + padding-top: 3px; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-dot-item.tinymce-mobile-dot-active { + color: #c8cbcf; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-icon-large-font::before, +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-icon-large-heading::before { + margin-left: 0.5em; + margin-right: 0.9em; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-icon-small-font::before, +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-icon-small-heading::before { + margin-left: 0.9em; + margin-right: 0.5em; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider { + display: flex; + flex: 1; + margin-left: 0; + margin-right: 0; + padding: 0.28em 0; + position: relative; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider .tinymce-mobile-slider-size-container { + align-items: center; + display: flex; + flex-grow: 1; + height: 100%; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider .tinymce-mobile-slider-size-container .tinymce-mobile-slider-size-line { + background: #cccccc; + display: flex; + flex: 1; + height: 0.2em; + margin-bottom: 0.3em; + margin-top: 0.3em; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container { + padding-left: 2em; + padding-right: 2em; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container .tinymce-mobile-slider-gradient-container { + align-items: center; + display: flex; + flex-grow: 1; + height: 100%; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container .tinymce-mobile-slider-gradient-container .tinymce-mobile-slider-gradient { + background: linear-gradient(to right, hsl(0, 100%, 50%) 0%, hsl(60, 100%, 50%) 17%, hsl(120, 100%, 50%) 33%, hsl(180, 100%, 50%) 50%, hsl(240, 100%, 50%) 67%, hsl(300, 100%, 50%) 83%, hsl(0, 100%, 50%) 100%); + display: flex; + flex: 1; + height: 0.2em; + margin-bottom: 0.3em; + margin-top: 0.3em; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container .tinymce-mobile-hue-slider-black { + /* Not part of theming */ + background: black; + height: 0.2em; + margin-bottom: 0.3em; + margin-top: 0.3em; + width: 1.2em; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container .tinymce-mobile-hue-slider-white { + /* Not part of theming */ + background: white; + height: 0.2em; + margin-bottom: 0.3em; + margin-top: 0.3em; + width: 1.2em; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider .tinymce-mobile-slider-thumb { + /* vertically centering trick (margin: auto, top: 0, bottom: 0). On iOS and Safari, if you leave + * out these values, then it shows the thumb at the top of the spectrum. This is probably because it is + * absolutely positioned with only a left value, and not a top. Note, on Chrome it seems to be fine without + * this approach. + */ + align-items: center; + background-clip: padding-box; + background-color: #455a64; + border: 0.5em solid rgba(136, 136, 136, 0); + border-radius: 3em; + bottom: 0; + color: #fff; + display: flex; + height: 0.5em; + justify-content: center; + left: -10px; + margin: auto; + position: absolute; + top: 0; + transition: border 120ms cubic-bezier(0.39, 0.58, 0.57, 1); + width: 0.5em; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider .tinymce-mobile-slider-thumb.tinymce-mobile-thumb-active { + border: 0.5em solid rgba(136, 136, 136, 0.39); +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serializer-wrapper, +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group > div { + align-items: center; + display: flex; + height: 100%; + flex: 1; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serializer-wrapper { + flex-direction: column; + justify-content: center; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item { + align-items: center; + display: flex; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item:not(.tinymce-mobile-serialised-dialog) { + height: 100%; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-dot-container { + display: flex; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group input { + background: #ffffff; + border: none; + border-radius: 0; + color: #455a64; + flex-grow: 1; + font-size: 0.85em; + padding-bottom: 0.1em; + padding-left: 5px; + padding-top: 0.1em; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group input::-webkit-input-placeholder { + /* WebKit, Blink, Edge */ + color: #888; +} +.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group input::placeholder { + /* WebKit, Blink, Edge */ + color: #888; +} +/* dropup */ +.tinymce-mobile-dropup { + background: white; + display: flex; + overflow: hidden; + width: 100%; +} +.tinymce-mobile-dropup.tinymce-mobile-dropup-shrinking { + transition: height 0.3s ease-out; +} +.tinymce-mobile-dropup.tinymce-mobile-dropup-growing { + transition: height 0.3s ease-in; +} +.tinymce-mobile-dropup.tinymce-mobile-dropup-closed { + flex-grow: 0; +} +.tinymce-mobile-dropup.tinymce-mobile-dropup-open:not(.tinymce-mobile-dropup-growing) { + flex-grow: 1; +} +/* TODO min-height for device size and orientation */ +.tinymce-mobile-ios-container .tinymce-mobile-dropup:not(.tinymce-mobile-dropup-closed) { + min-height: 200px; +} +@media only screen and (orientation: landscape) { + .tinymce-mobile-dropup:not(.tinymce-mobile-dropup-closed) { + min-height: 200px; + } +} +@media only screen and (min-device-width : 320px) and (max-device-width : 568px) and (orientation : landscape) { + .tinymce-mobile-ios-container .tinymce-mobile-dropup:not(.tinymce-mobile-dropup-closed) { + min-height: 150px; + } +} +/* styles menu */ +.tinymce-mobile-styles-menu { + font-family: sans-serif; + outline: 4px solid black; + overflow: hidden; + position: relative; + width: 100%; +} +.tinymce-mobile-styles-menu [role="menu"] { + display: flex; + flex-direction: column; + height: 100%; + position: absolute; + width: 100%; +} +.tinymce-mobile-styles-menu [role="menu"].transitioning { + transition: transform 0.5s ease-in-out; +} +.tinymce-mobile-styles-menu .tinymce-mobile-styles-item { + border-bottom: 1px solid #ddd; + color: #455a64; + cursor: pointer; + display: flex; + padding: 1em 1em; + position: relative; +} +.tinymce-mobile-styles-menu .tinymce-mobile-styles-collapser .tinymce-mobile-styles-collapse-icon::before { + color: #455a64; + content: "\e314"; + font-family: 'tinymce-mobile', sans-serif; +} +.tinymce-mobile-styles-menu .tinymce-mobile-styles-item.tinymce-mobile-styles-item-is-menu::after { + color: #455a64; + content: "\e315"; + font-family: 'tinymce-mobile', sans-serif; + padding-left: 1em; + padding-right: 1em; + position: absolute; + right: 0; +} +.tinymce-mobile-styles-menu .tinymce-mobile-styles-item.tinymce-mobile-format-matches::after { + font-family: 'tinymce-mobile', sans-serif; + padding-left: 1em; + padding-right: 1em; + position: absolute; + right: 0; +} +.tinymce-mobile-styles-menu .tinymce-mobile-styles-separator, +.tinymce-mobile-styles-menu .tinymce-mobile-styles-collapser { + align-items: center; + background: #fff; + border-top: #455a64; + color: #455a64; + display: flex; + min-height: 2.5em; + padding-left: 1em; + padding-right: 1em; +} +.tinymce-mobile-styles-menu [data-transitioning-destination="before"][data-transitioning-state], +.tinymce-mobile-styles-menu [data-transitioning-state="before"] { + transform: translate(-100%); +} +.tinymce-mobile-styles-menu [data-transitioning-destination="current"][data-transitioning-state], +.tinymce-mobile-styles-menu [data-transitioning-state="current"] { + transform: translate(0%); +} +.tinymce-mobile-styles-menu [data-transitioning-destination="after"][data-transitioning-state], +.tinymce-mobile-styles-menu [data-transitioning-state="after"] { + transform: translate(100%); +} +@font-face { + font-family: 'tinymce-mobile'; + font-style: normal; + font-weight: normal; + src: url('fonts/tinymce-mobile.woff?8x92w3') format('woff'); +} +@media (min-device-width: 700px) { + .tinymce-mobile-outer-container, + .tinymce-mobile-outer-container input { + font-size: 25px; + } +} +@media (max-device-width: 700px) { + .tinymce-mobile-outer-container, + .tinymce-mobile-outer-container input { + font-size: 18px; + } +} +.tinymce-mobile-icon { + font-family: 'tinymce-mobile', sans-serif; +} +.mixin-flex-and-centre { + align-items: center; + display: flex; + justify-content: center; +} +.mixin-flex-bar { + align-items: center; + display: flex; + height: 100%; +} +.tinymce-mobile-outer-container .tinymce-mobile-editor-socket iframe { + background-color: #fff; + width: 100%; +} +.tinymce-mobile-editor-socket .tinymce-mobile-mask-edit-icon { + /* Note, on the iPod touch in landscape, this isn't visible when the navbar appears */ + background-color: #207ab7; + border-radius: 50%; + bottom: 1em; + color: white; + font-size: 1em; + height: 2.1em; + position: fixed; + right: 2em; + width: 2.1em; + align-items: center; + display: flex; + justify-content: center; +} +@media only screen and (min-device-width:700px) { + .tinymce-mobile-editor-socket .tinymce-mobile-mask-edit-icon { + font-size: 1.2em; + } +} +.tinymce-mobile-outer-container:not(.tinymce-mobile-fullscreen-maximized) .tinymce-mobile-editor-socket { + height: 300px; + overflow: hidden; +} +.tinymce-mobile-outer-container:not(.tinymce-mobile-fullscreen-maximized) .tinymce-mobile-editor-socket iframe { + height: 100%; +} +.tinymce-mobile-outer-container:not(.tinymce-mobile-fullscreen-maximized) .tinymce-mobile-toolstrip { + display: none; +} +/* + Note, that if you don't include this (::-webkit-file-upload-button), the toolbar width gets + increased and the whole body becomes scrollable. It's important! + */ +input[type="file"]::-webkit-file-upload-button { + display: none; +} +@media only screen and (min-device-width : 320px) and (max-device-width : 568px) and (orientation : landscape) { + .tinymce-mobile-ios-container .tinymce-mobile-editor-socket .tinymce-mobile-mask-edit-icon { + bottom: 50%; + } +} diff --git a/ui/public/tinymce/skins/ui/oxide/skin.mobile.min.css b/ui/public/tinymce/skins/ui/oxide/skin.mobile.min.css new file mode 100644 index 0000000..3a45cac --- /dev/null +++ b/ui/public/tinymce/skins/ui/oxide/skin.mobile.min.css @@ -0,0 +1,7 @@ +/** + * Copyright (c) Tiny Technologies, Inc. All rights reserved. + * Licensed under the LGPL or a commercial license. + * For LGPL see License.txt in the project root for license information. + * For commercial licenses see https://www.tiny.cloud/ + */ +.tinymce-mobile-outer-container{all:initial;display:block}.tinymce-mobile-outer-container *{border:0;box-sizing:initial;cursor:inherit;float:none;line-height:1;margin:0;outline:0;padding:0;-webkit-tap-highlight-color:transparent;text-shadow:none;white-space:nowrap}.tinymce-mobile-icon-arrow-back::before{content:"\e5cd"}.tinymce-mobile-icon-image::before{content:"\e412"}.tinymce-mobile-icon-cancel-circle::before{content:"\e5c9"}.tinymce-mobile-icon-full-dot::before{content:"\e061"}.tinymce-mobile-icon-align-center::before{content:"\e234"}.tinymce-mobile-icon-align-left::before{content:"\e236"}.tinymce-mobile-icon-align-right::before{content:"\e237"}.tinymce-mobile-icon-bold::before{content:"\e238"}.tinymce-mobile-icon-italic::before{content:"\e23f"}.tinymce-mobile-icon-unordered-list::before{content:"\e241"}.tinymce-mobile-icon-ordered-list::before{content:"\e242"}.tinymce-mobile-icon-font-size::before{content:"\e245"}.tinymce-mobile-icon-underline::before{content:"\e249"}.tinymce-mobile-icon-link::before{content:"\e157"}.tinymce-mobile-icon-unlink::before{content:"\eca2"}.tinymce-mobile-icon-color::before{content:"\e891"}.tinymce-mobile-icon-previous::before{content:"\e314"}.tinymce-mobile-icon-next::before{content:"\e315"}.tinymce-mobile-icon-large-font::before,.tinymce-mobile-icon-style-formats::before{content:"\e264"}.tinymce-mobile-icon-undo::before{content:"\e166"}.tinymce-mobile-icon-redo::before{content:"\e15a"}.tinymce-mobile-icon-removeformat::before{content:"\e239"}.tinymce-mobile-icon-small-font::before{content:"\e906"}.tinymce-mobile-format-matches::after,.tinymce-mobile-icon-readonly-back::before{content:"\e5ca"}.tinymce-mobile-icon-small-heading::before{content:"small"}.tinymce-mobile-icon-large-heading::before{content:"large"}.tinymce-mobile-icon-large-heading::before,.tinymce-mobile-icon-small-heading::before{font-family:sans-serif;font-size:80%}.tinymce-mobile-mask-edit-icon::before{content:"\e254"}.tinymce-mobile-icon-back::before{content:"\e5c4"}.tinymce-mobile-icon-heading::before{content:"Headings";font-family:sans-serif;font-size:80%;font-weight:700}.tinymce-mobile-icon-h1::before{content:"H1";font-weight:700}.tinymce-mobile-icon-h2::before{content:"H2";font-weight:700}.tinymce-mobile-icon-h3::before{content:"H3";font-weight:700}.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask{align-items:center;display:flex;justify-content:center;background:rgba(51,51,51,.5);height:100%;position:absolute;top:0;width:100%}.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container{align-items:center;border-radius:50%;display:flex;flex-direction:column;font-family:sans-serif;font-size:1em;justify-content:space-between}.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .mixin-menu-item{align-items:center;display:flex;justify-content:center;border-radius:50%;height:2.1em;width:2.1em}.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section{align-items:center;display:flex;justify-content:center;flex-direction:column;font-size:1em}@media only screen and (min-device-width:700px){.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section{font-size:1.2em}}.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section .tinymce-mobile-mask-tap-icon{align-items:center;display:flex;justify-content:center;border-radius:50%;height:2.1em;width:2.1em;background-color:#fff;color:#207ab7}.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section .tinymce-mobile-mask-tap-icon::before{content:"\e900";font-family:tinymce-mobile,sans-serif}.tinymce-mobile-outer-container .tinymce-mobile-disabled-mask .tinymce-mobile-content-container .tinymce-mobile-content-tap-section:not(.tinymce-mobile-mask-tap-icon-selected) .tinymce-mobile-mask-tap-icon{z-index:2}.tinymce-mobile-android-container.tinymce-mobile-android-maximized{background:#fff;border:none;bottom:0;display:flex;flex-direction:column;left:0;position:fixed;right:0;top:0}.tinymce-mobile-android-container:not(.tinymce-mobile-android-maximized){position:relative}.tinymce-mobile-android-container .tinymce-mobile-editor-socket{display:flex;flex-grow:1}.tinymce-mobile-android-container .tinymce-mobile-editor-socket iframe{display:flex!important;flex-grow:1;height:auto!important}.tinymce-mobile-android-scroll-reload{overflow:hidden}:not(.tinymce-mobile-readonly-mode)>.tinymce-mobile-android-selection-context-toolbar{margin-top:23px}.tinymce-mobile-toolstrip{background:#fff;display:flex;flex:0 0 auto;z-index:1}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar{align-items:center;background-color:#fff;border-bottom:1px solid #ccc;display:flex;flex:1;height:2.5em;width:100%}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group{align-items:center;display:flex;height:100%;flex-shrink:1}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group>div{align-items:center;display:flex;height:100%;flex:1}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group.tinymce-mobile-exit-container{background:#f44336}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group.tinymce-mobile-toolbar-scrollable-group{flex-grow:1}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item{padding-left:.5em;padding-right:.5em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item.tinymce-mobile-toolbar-button{align-items:center;display:flex;height:80%;margin-left:2px;margin-right:2px}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item.tinymce-mobile-toolbar-button.tinymce-mobile-toolbar-button-selected{background:#c8cbcf;color:#ccc}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group:first-of-type,.tinymce-mobile-toolstrip .tinymce-mobile-toolbar:not(.tinymce-mobile-context-toolbar) .tinymce-mobile-toolbar-group:last-of-type{background:#207ab7;color:#eceff1}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group{align-items:center;display:flex;height:100%;flex:1;padding-bottom:.4em;padding-top:.4em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog{display:flex;min-height:1.5em;overflow:hidden;padding-left:0;padding-right:0;position:relative;width:100%}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain{display:flex;height:100%;transition:left cubic-bezier(.4,0,1,1) .15s;width:100%}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen{display:flex;flex:0 0 auto;justify-content:space-between;width:100%}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen input{font-family:Sans-serif}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-input-container{display:flex;flex-grow:1;position:relative}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-input-container .tinymce-mobile-input-container-x{-ms-grid-row-align:center;align-self:center;background:inherit;border:none;border-radius:50%;color:#888;font-size:.6em;font-weight:700;height:100%;padding-right:2px;position:absolute;right:0}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-input-container.tinymce-mobile-input-container-empty .tinymce-mobile-input-container-x{display:none}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-next,.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-previous{align-items:center;display:flex}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-next::before,.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-previous::before{align-items:center;display:flex;font-weight:700;height:100%;padding-left:.5em;padding-right:.5em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-next.tinymce-mobile-toolbar-navigation-disabled::before,.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serialised-dialog .tinymce-mobile-serialised-dialog-chain .tinymce-mobile-serialised-dialog-screen .tinymce-mobile-icon-previous.tinymce-mobile-toolbar-navigation-disabled::before{visibility:hidden}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-dot-item{color:#ccc;font-size:10px;line-height:10px;margin:0 2px;padding-top:3px}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-dot-item.tinymce-mobile-dot-active{color:#c8cbcf}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-icon-large-font::before,.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-icon-large-heading::before{margin-left:.5em;margin-right:.9em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-icon-small-font::before,.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-icon-small-heading::before{margin-left:.9em;margin-right:.5em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider{display:flex;flex:1;margin-left:0;margin-right:0;padding:.28em 0;position:relative}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider .tinymce-mobile-slider-size-container{align-items:center;display:flex;flex-grow:1;height:100%}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider .tinymce-mobile-slider-size-container .tinymce-mobile-slider-size-line{background:#ccc;display:flex;flex:1;height:.2em;margin-bottom:.3em;margin-top:.3em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container{padding-left:2em;padding-right:2em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container .tinymce-mobile-slider-gradient-container{align-items:center;display:flex;flex-grow:1;height:100%}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container .tinymce-mobile-slider-gradient-container .tinymce-mobile-slider-gradient{background:linear-gradient(to right,red 0,#feff00 17%,#0f0 33%,#00feff 50%,#00f 67%,#ff00fe 83%,red 100%);display:flex;flex:1;height:.2em;margin-bottom:.3em;margin-top:.3em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container .tinymce-mobile-hue-slider-black{background:#000;height:.2em;margin-bottom:.3em;margin-top:.3em;width:1.2em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider.tinymce-mobile-hue-slider-container .tinymce-mobile-hue-slider-white{background:#fff;height:.2em;margin-bottom:.3em;margin-top:.3em;width:1.2em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider .tinymce-mobile-slider-thumb{align-items:center;background-clip:padding-box;background-color:#455a64;border:.5em solid rgba(136,136,136,0);border-radius:3em;bottom:0;color:#fff;display:flex;height:.5em;justify-content:center;left:-10px;margin:auto;position:absolute;top:0;transition:border 120ms cubic-bezier(.39,.58,.57,1);width:.5em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-slider .tinymce-mobile-slider-thumb.tinymce-mobile-thumb-active{border:.5em solid rgba(136,136,136,.39)}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serializer-wrapper,.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group>div{align-items:center;display:flex;height:100%;flex:1}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-serializer-wrapper{flex-direction:column;justify-content:center}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item{align-items:center;display:flex}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-toolbar-group-item:not(.tinymce-mobile-serialised-dialog){height:100%}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group .tinymce-mobile-dot-container{display:flex}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group input{background:#fff;border:none;border-radius:0;color:#455a64;flex-grow:1;font-size:.85em;padding-bottom:.1em;padding-left:5px;padding-top:.1em}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group input::-webkit-input-placeholder{color:#888}.tinymce-mobile-toolstrip .tinymce-mobile-toolbar.tinymce-mobile-context-toolbar .tinymce-mobile-toolbar-group input::placeholder{color:#888}.tinymce-mobile-dropup{background:#fff;display:flex;overflow:hidden;width:100%}.tinymce-mobile-dropup.tinymce-mobile-dropup-shrinking{transition:height .3s ease-out}.tinymce-mobile-dropup.tinymce-mobile-dropup-growing{transition:height .3s ease-in}.tinymce-mobile-dropup.tinymce-mobile-dropup-closed{flex-grow:0}.tinymce-mobile-dropup.tinymce-mobile-dropup-open:not(.tinymce-mobile-dropup-growing){flex-grow:1}.tinymce-mobile-ios-container .tinymce-mobile-dropup:not(.tinymce-mobile-dropup-closed){min-height:200px}@media only screen and (orientation:landscape){.tinymce-mobile-dropup:not(.tinymce-mobile-dropup-closed){min-height:200px}}@media only screen and (min-device-width :320px) and (max-device-width :568px) and (orientation :landscape){.tinymce-mobile-ios-container .tinymce-mobile-dropup:not(.tinymce-mobile-dropup-closed){min-height:150px}}.tinymce-mobile-styles-menu{font-family:sans-serif;outline:4px solid #000;overflow:hidden;position:relative;width:100%}.tinymce-mobile-styles-menu [role=menu]{display:flex;flex-direction:column;height:100%;position:absolute;width:100%}.tinymce-mobile-styles-menu [role=menu].transitioning{transition:transform .5s ease-in-out}.tinymce-mobile-styles-menu .tinymce-mobile-styles-item{border-bottom:1px solid #ddd;color:#455a64;cursor:pointer;display:flex;padding:1em 1em;position:relative}.tinymce-mobile-styles-menu .tinymce-mobile-styles-collapser .tinymce-mobile-styles-collapse-icon::before{color:#455a64;content:"\e314";font-family:tinymce-mobile,sans-serif}.tinymce-mobile-styles-menu .tinymce-mobile-styles-item.tinymce-mobile-styles-item-is-menu::after{color:#455a64;content:"\e315";font-family:tinymce-mobile,sans-serif;padding-left:1em;padding-right:1em;position:absolute;right:0}.tinymce-mobile-styles-menu .tinymce-mobile-styles-item.tinymce-mobile-format-matches::after{font-family:tinymce-mobile,sans-serif;padding-left:1em;padding-right:1em;position:absolute;right:0}.tinymce-mobile-styles-menu .tinymce-mobile-styles-collapser,.tinymce-mobile-styles-menu .tinymce-mobile-styles-separator{align-items:center;background:#fff;border-top:#455a64;color:#455a64;display:flex;min-height:2.5em;padding-left:1em;padding-right:1em}.tinymce-mobile-styles-menu [data-transitioning-destination=before][data-transitioning-state],.tinymce-mobile-styles-menu [data-transitioning-state=before]{transform:translate(-100%)}.tinymce-mobile-styles-menu [data-transitioning-destination=current][data-transitioning-state],.tinymce-mobile-styles-menu [data-transitioning-state=current]{transform:translate(0)}.tinymce-mobile-styles-menu [data-transitioning-destination=after][data-transitioning-state],.tinymce-mobile-styles-menu [data-transitioning-state=after]{transform:translate(100%)}@font-face{font-family:tinymce-mobile;font-style:normal;font-weight:400;src:url(fonts/tinymce-mobile.woff?8x92w3) format('woff')}@media (min-device-width:700px){.tinymce-mobile-outer-container,.tinymce-mobile-outer-container input{font-size:25px}}@media (max-device-width:700px){.tinymce-mobile-outer-container,.tinymce-mobile-outer-container input{font-size:18px}}.tinymce-mobile-icon{font-family:tinymce-mobile,sans-serif}.mixin-flex-and-centre{align-items:center;display:flex;justify-content:center}.mixin-flex-bar{align-items:center;display:flex;height:100%}.tinymce-mobile-outer-container .tinymce-mobile-editor-socket iframe{background-color:#fff;width:100%}.tinymce-mobile-editor-socket .tinymce-mobile-mask-edit-icon{background-color:#207ab7;border-radius:50%;bottom:1em;color:#fff;font-size:1em;height:2.1em;position:fixed;right:2em;width:2.1em;align-items:center;display:flex;justify-content:center}@media only screen and (min-device-width:700px){.tinymce-mobile-editor-socket .tinymce-mobile-mask-edit-icon{font-size:1.2em}}.tinymce-mobile-outer-container:not(.tinymce-mobile-fullscreen-maximized) .tinymce-mobile-editor-socket{height:300px;overflow:hidden}.tinymce-mobile-outer-container:not(.tinymce-mobile-fullscreen-maximized) .tinymce-mobile-editor-socket iframe{height:100%}.tinymce-mobile-outer-container:not(.tinymce-mobile-fullscreen-maximized) .tinymce-mobile-toolstrip{display:none}input[type=file]::-webkit-file-upload-button{display:none}@media only screen and (min-device-width :320px) and (max-device-width :568px) and (orientation :landscape){.tinymce-mobile-ios-container .tinymce-mobile-editor-socket .tinymce-mobile-mask-edit-icon{bottom:50%}} diff --git a/ui/public/tinymce/skins/ui/oxide/skin.shadowdom.css b/ui/public/tinymce/skins/ui/oxide/skin.shadowdom.css new file mode 100644 index 0000000..d2adc4d --- /dev/null +++ b/ui/public/tinymce/skins/ui/oxide/skin.shadowdom.css @@ -0,0 +1,37 @@ +/** + * Copyright (c) Tiny Technologies, Inc. All rights reserved. + * Licensed under the LGPL or a commercial license. + * For LGPL see License.txt in the project root for license information. + * For commercial licenses see https://www.tiny.cloud/ + */ +body.tox-dialog__disable-scroll { + overflow: hidden; +} +.tox-fullscreen { + border: 0; + height: 100%; + margin: 0; + overflow: hidden; + -ms-scroll-chaining: none; + overscroll-behavior: none; + padding: 0; + touch-action: pinch-zoom; + width: 100%; +} +.tox.tox-tinymce.tox-fullscreen .tox-statusbar__resize-handle { + display: none; +} +.tox.tox-tinymce.tox-fullscreen, +.tox-shadowhost.tox-fullscreen { + left: 0; + position: fixed; + top: 0; + z-index: 1200; +} +.tox.tox-tinymce.tox-fullscreen { + background-color: transparent; +} +.tox-fullscreen .tox.tox-tinymce-aux, +.tox-fullscreen ~ .tox.tox-tinymce-aux { + z-index: 1201; +} diff --git a/ui/public/tinymce/skins/ui/oxide/skin.shadowdom.min.css b/ui/public/tinymce/skins/ui/oxide/skin.shadowdom.min.css new file mode 100644 index 0000000..a0893b9 --- /dev/null +++ b/ui/public/tinymce/skins/ui/oxide/skin.shadowdom.min.css @@ -0,0 +1,7 @@ +/** + * Copyright (c) Tiny Technologies, Inc. All rights reserved. + * Licensed under the LGPL or a commercial license. + * For LGPL see License.txt in the project root for license information. + * For commercial licenses see https://www.tiny.cloud/ + */ +body.tox-dialog__disable-scroll{overflow:hidden}.tox-fullscreen{border:0;height:100%;margin:0;overflow:hidden;-ms-scroll-chaining:none;overscroll-behavior:none;padding:0;touch-action:pinch-zoom;width:100%}.tox.tox-tinymce.tox-fullscreen .tox-statusbar__resize-handle{display:none}.tox-shadowhost.tox-fullscreen,.tox.tox-tinymce.tox-fullscreen{left:0;position:fixed;top:0;z-index:1200}.tox.tox-tinymce.tox-fullscreen{background-color:transparent}.tox-fullscreen .tox.tox-tinymce-aux,.tox-fullscreen~.tox.tox-tinymce-aux{z-index:1201} diff --git a/ui/public/zh-Hans.js b/ui/public/zh-Hans.js new file mode 100644 index 0000000..da18c86 --- /dev/null +++ b/ui/public/zh-Hans.js @@ -0,0 +1,462 @@ +tinymce.addI18n('zh_CN',{ + "Redo": "\u91cd\u505a", + "Undo": "\u64a4\u9500", + "Cut": "\u526a\u5207", + "Copy": "\u590d\u5236", + "Paste": "\u7c98\u8d34", + "Select all": "\u5168\u9009", + "New document": "\u65b0\u6587\u4ef6", + "Ok": "\u786e\u5b9a", + "Cancel": "\u53d6\u6d88", + "Visual aids": "\u7f51\u683c\u7ebf", + "Bold": "\u7c97\u4f53", + "Italic": "\u659c\u4f53", + "Underline": "\u4e0b\u5212\u7ebf", + "Strikethrough": "\u5220\u9664\u7ebf", + "Superscript": "\u4e0a\u6807", + "Subscript": "\u4e0b\u6807", + "Clear formatting": "\u6e05\u9664\u683c\u5f0f", + "Align left": "\u5de6\u8fb9\u5bf9\u9f50", + "Align center": "\u4e2d\u95f4\u5bf9\u9f50", + "Align right": "\u53f3\u8fb9\u5bf9\u9f50", + "Justify": "\u4e24\u7aef\u5bf9\u9f50", + "Bullet list": "\u9879\u76ee\u7b26\u53f7", + "Numbered list": "\u7f16\u53f7\u5217\u8868", + "Decrease indent": "\u51cf\u5c11\u7f29\u8fdb", + "Increase indent": "\u589e\u52a0\u7f29\u8fdb", + "Close": "\u5173\u95ed", + "Formats": "\u683c\u5f0f", + "Your browser doesn't support direct access to the clipboard. Please use the Ctrl+X\/C\/V keyboard shortcuts instead.": "\u4f60\u7684\u6d4f\u89c8\u5668\u4e0d\u652f\u6301\u6253\u5f00\u526a\u8d34\u677f\uff0c\u8bf7\u4f7f\u7528Ctrl+X\/C\/V\u7b49\u5feb\u6377\u952e\u3002", + "Headers": "\u6807\u9898", + "Header 1": "\u6807\u98981", + "Header 2": "\u6807\u98982", + "Header 3": "\u6807\u98983", + "Header 4": "\u6807\u98984", + "Header 5": "\u6807\u98985", + "Header 6": "\u6807\u98986", + "Headings": "\u6807\u9898", + "Heading 1": "\u6807\u98981", + "Heading 2": "\u6807\u98982", + "Heading 3": "\u6807\u98983", + "Heading 4": "\u6807\u98984", + "Heading 5": "\u6807\u98985", + "Heading 6": "\u6807\u98986", + "Preformatted": "\u9884\u5148\u683c\u5f0f\u5316\u7684", + "Div": "Div", + "Pre": "Pre", + "Code": "\u4ee3\u7801", + "Paragraph": "\u6bb5\u843d", + "Blockquote": "\u5f15\u6587\u533a\u5757", + "Inline": "\u6587\u672c", + "Blocks": "\u57fa\u5757", + "Paste is now in plain text mode. Contents will now be pasted as plain text until you toggle this option off.": "\u5f53\u524d\u4e3a\u7eaf\u6587\u672c\u7c98\u8d34\u6a21\u5f0f\uff0c\u518d\u6b21\u70b9\u51fb\u53ef\u4ee5\u56de\u5230\u666e\u901a\u7c98\u8d34\u6a21\u5f0f\u3002", + "Fonts": "\u5b57\u4f53", + "Font Sizes": "\u5b57\u53f7", + "Class": "\u7c7b\u578b", + "Browse for an image": "\u6d4f\u89c8\u56fe\u50cf", + "OR": "\u6216", + "Drop an image here": "\u62d6\u653e\u4e00\u5f20\u56fe\u50cf\u81f3\u6b64", + "Upload": "\u4e0a\u4f20", + "Block": "\u5757", + "Align": "\u5bf9\u9f50", + "Default": "\u9ed8\u8ba4", + "Circle": "\u7a7a\u5fc3\u5706", + "Disc": "\u5b9e\u5fc3\u5706", + "Square": "\u65b9\u5757", + "Lower Alpha": "\u5c0f\u5199\u82f1\u6587\u5b57\u6bcd", + "Lower Greek": "\u5c0f\u5199\u5e0c\u814a\u5b57\u6bcd", + "Lower Roman": "\u5c0f\u5199\u7f57\u9a6c\u5b57\u6bcd", + "Upper Alpha": "\u5927\u5199\u82f1\u6587\u5b57\u6bcd", + "Upper Roman": "\u5927\u5199\u7f57\u9a6c\u5b57\u6bcd", + "Anchor...": "\u951a\u70b9...", + "Name": "\u540d\u79f0", + "Id": "\u6807\u8bc6\u7b26", + "Id should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores.": "\u6807\u8bc6\u7b26\u5e94\u8be5\u4ee5\u5b57\u6bcd\u5f00\u5934\uff0c\u540e\u8ddf\u5b57\u6bcd\u3001\u6570\u5b57\u3001\u7834\u6298\u53f7\u3001\u70b9\u3001\u5192\u53f7\u6216\u4e0b\u5212\u7ebf\u3002", + "You have unsaved changes are you sure you want to navigate away?": "\u4f60\u8fd8\u6709\u6587\u6863\u5c1a\u672a\u4fdd\u5b58\uff0c\u786e\u5b9a\u8981\u79bb\u5f00\uff1f", + "Restore last draft": "\u6062\u590d\u4e0a\u6b21\u7684\u8349\u7a3f", + "Special character...": "\u7279\u6b8a\u5b57\u7b26...", + "Source code": "\u6e90\u4ee3\u7801", + "Insert\/Edit code sample": "\u63d2\u5165\/\u7f16\u8f91\u4ee3\u7801\u793a\u4f8b", + "Language": "\u8bed\u8a00", + "Code sample...": "\u793a\u4f8b\u4ee3\u7801...", + "Color Picker": "\u9009\u8272\u5668", + "R": "R", + "G": "G", + "B": "B", + "Left to right": "\u4ece\u5de6\u5230\u53f3", + "Right to left": "\u4ece\u53f3\u5230\u5de6", + "Emoticons": "\u8868\u60c5", + "Emoticons...": "\u8868\u60c5\u7b26\u53f7...", + "Metadata and Document Properties": "\u5143\u6570\u636e\u548c\u6587\u6863\u5c5e\u6027", + "Title": "\u6807\u9898", + "Keywords": "\u5173\u952e\u8bcd", + "Description": "\u63cf\u8ff0", + "Robots": "\u673a\u5668\u4eba", + "Author": "\u4f5c\u8005", + "Encoding": "\u7f16\u7801", + "Fullscreen": "\u5168\u5c4f", + "Action": "\u64cd\u4f5c", + "Shortcut": "\u5feb\u6377\u952e", + "Help": "\u5e2e\u52a9", + "Address": "\u5730\u5740", + "Focus to menubar": "\u79fb\u52a8\u7126\u70b9\u5230\u83dc\u5355\u680f", + "Focus to toolbar": "\u79fb\u52a8\u7126\u70b9\u5230\u5de5\u5177\u680f", + "Focus to element path": "\u79fb\u52a8\u7126\u70b9\u5230\u5143\u7d20\u8def\u5f84", + "Focus to contextual toolbar": "\u79fb\u52a8\u7126\u70b9\u5230\u4e0a\u4e0b\u6587\u83dc\u5355", + "Insert link (if link plugin activated)": "\u63d2\u5165\u94fe\u63a5 (\u5982\u679c\u94fe\u63a5\u63d2\u4ef6\u5df2\u6fc0\u6d3b)", + "Save (if save plugin activated)": "\u4fdd\u5b58(\u5982\u679c\u4fdd\u5b58\u63d2\u4ef6\u5df2\u6fc0\u6d3b)", + "Find (if searchreplace plugin activated)": "\u67e5\u627e(\u5982\u679c\u67e5\u627e\u66ff\u6362\u63d2\u4ef6\u5df2\u6fc0\u6d3b)", + "Plugins installed ({0}):": "\u5df2\u5b89\u88c5\u63d2\u4ef6 ({0}):", + "Premium plugins:": "\u4f18\u79c0\u63d2\u4ef6\uff1a", + "Learn more...": "\u4e86\u89e3\u66f4\u591a...", + "You are using {0}": "\u4f60\u6b63\u5728\u4f7f\u7528 {0}", + "Plugins": "\u63d2\u4ef6", + "Handy Shortcuts": "\u5feb\u6377\u952e", + "Horizontal line": "\u6c34\u5e73\u5206\u5272\u7ebf", + "Insert\/edit image": "\u63d2\u5165\/\u7f16\u8f91\u56fe\u7247", + "Alternative description": "\u66ff\u4ee3\u63cf\u8ff0", + "Accessibility": "\u8f85\u52a9\u529f\u80fd", + "Image is decorative": "\u56fe\u50cf\u662f\u88c5\u9970\u6027\u7684", + "Source": "\u5730\u5740", + "Dimensions": "\u5927\u5c0f", + "Constrain proportions": "\u4fdd\u6301\u7eb5\u6a2a\u6bd4", + "General": "\u666e\u901a", + "Advanced": "\u9ad8\u7ea7", + "Style": "\u6837\u5f0f", + "Vertical space": "\u5782\u76f4\u8fb9\u8ddd", + "Horizontal space": "\u6c34\u5e73\u8fb9\u8ddd", + "Border": "\u8fb9\u6846", + "Insert image": "\u63d2\u5165\u56fe\u7247", + "Image...": "\u56fe\u7247...", + "Image list": "\u56fe\u7247\u5217\u8868", + "Rotate counterclockwise": "\u9006\u65f6\u9488\u65cb\u8f6c", + "Rotate clockwise": "\u987a\u65f6\u9488\u65cb\u8f6c", + "Flip vertically": "\u5782\u76f4\u7ffb\u8f6c", + "Flip horizontally": "\u6c34\u5e73\u7ffb\u8f6c", + "Edit image": "\u7f16\u8f91\u56fe\u7247", + "Image options": "\u56fe\u7247\u9009\u9879", + "Zoom in": "\u653e\u5927", + "Zoom out": "\u7f29\u5c0f", + "Crop": "\u88c1\u526a", + "Resize": "\u8c03\u6574\u5927\u5c0f", + "Orientation": "\u65b9\u5411", + "Brightness": "\u4eae\u5ea6", + "Sharpen": "\u9510\u5316", + "Contrast": "\u5bf9\u6bd4\u5ea6", + "Color levels": "\u989c\u8272\u5c42\u6b21", + "Gamma": "\u4f3d\u9a6c\u503c", + "Invert": "\u53cd\u8f6c", + "Apply": "\u5e94\u7528", + "Back": "\u540e\u9000", + "Insert date\/time": "\u63d2\u5165\u65e5\u671f\/\u65f6\u95f4", + "Date\/time": "\u65e5\u671f\/\u65f6\u95f4", + "Insert\/edit link": "\u63d2\u5165\/\u7f16\u8f91\u94fe\u63a5", + "Text to display": "\u663e\u793a\u6587\u5b57", + "Url": "\u5730\u5740", + "Open link in...": "\u94fe\u63a5\u6253\u5f00\u4f4d\u7f6e...", + "Current window": "\u5f53\u524d\u7a97\u53e3", + "None": "\u65e0", + "New window": "\u5728\u65b0\u7a97\u53e3\u6253\u5f00", + "Open link": "\u6253\u5f00\u94fe\u63a5", + "Remove link": "\u5220\u9664\u94fe\u63a5", + "Anchors": "\u951a\u70b9", + "Link...": "\u94fe\u63a5...", + "Paste or type a link": "\u7c98\u8d34\u6216\u8f93\u5165\u94fe\u63a5", + "The URL you entered seems to be an email address. Do you want to add the required mailto: prefix?": "\u4f60\u6240\u586b\u5199\u7684URL\u5730\u5740\u4e3a\u90ae\u4ef6\u5730\u5740\uff0c\u9700\u8981\u52a0\u4e0amailto:\u524d\u7f00\u5417\uff1f", + "The URL you entered seems to be an external link. Do you want to add the required http:\/\/ prefix?": "\u4f60\u6240\u586b\u5199\u7684URL\u5730\u5740\u5c5e\u4e8e\u5916\u90e8\u94fe\u63a5\uff0c\u9700\u8981\u52a0\u4e0ahttp:\/\/:\u524d\u7f00\u5417\uff1f", + "The URL you entered seems to be an external link. Do you want to add the required https:\/\/ prefix?": "\u60a8\u8f93\u5165\u7684 URL \u4f3c\u4e4e\u662f\u4e00\u4e2a\u5916\u90e8\u94fe\u63a5\u3002\u60a8\u60f3\u6dfb\u52a0\u6240\u9700\u7684 https:\/\/ \u524d\u7f00\u5417\uff1f", + "Link list": "\u94fe\u63a5\u5217\u8868", + "Insert video": "\u63d2\u5165\u89c6\u9891", + "Insert\/edit video": "\u63d2\u5165\/\u7f16\u8f91\u89c6\u9891", + "Insert\/edit media": "\u63d2\u5165\/\u7f16\u8f91\u5a92\u4f53", + "Alternative source": "\u955c\u50cf", + "Alternative source URL": "\u66ff\u4ee3\u6765\u6e90\u7f51\u5740", + "Media poster (Image URL)": "\u5c01\u9762(\u56fe\u7247\u5730\u5740)", + "Paste your embed code below:": "\u5c06\u5185\u5d4c\u4ee3\u7801\u7c98\u8d34\u5728\u4e0b\u9762:", + "Embed": "\u5185\u5d4c", + "Media...": "\u591a\u5a92\u4f53...", + "Nonbreaking space": "\u4e0d\u95f4\u65ad\u7a7a\u683c", + "Page break": "\u5206\u9875\u7b26", + "Paste as text": "\u7c98\u8d34\u4e3a\u6587\u672c", + "Preview": "\u9884\u89c8", + "Print...": "\u6253\u5370...", + "Save": "\u4fdd\u5b58", + "Find": "\u67e5\u627e", + "Replace with": "\u66ff\u6362\u4e3a", + "Replace": "\u66ff\u6362", + "Replace all": "\u5168\u90e8\u66ff\u6362", + "Previous": "\u4e0a\u4e00\u4e2a", + "Next": "\u4e0b\u4e00\u4e2a", + "Find and Replace": "\u67e5\u627e\u548c\u66ff\u6362", + "Find and replace...": "\u67e5\u627e\u5e76\u66ff\u6362...", + "Could not find the specified string.": "\u672a\u627e\u5230\u641c\u7d22\u5185\u5bb9.", + "Match case": "\u533a\u5206\u5927\u5c0f\u5199", + "Find whole words only": "\u5168\u5b57\u5339\u914d", + "Find in selection": "\u5728\u9009\u533a\u4e2d\u67e5\u627e", + "Spellcheck": "\u62fc\u5199\u68c0\u67e5", + "Spellcheck Language": "\u62fc\u5199\u68c0\u67e5\u8bed\u8a00", + "No misspellings found.": "\u6ca1\u6709\u53d1\u73b0\u62fc\u5199\u9519\u8bef", + "Ignore": "\u5ffd\u7565", + "Ignore all": "\u5168\u90e8\u5ffd\u7565", + "Finish": "\u5b8c\u6210", + "Add to Dictionary": "\u6dfb\u52a0\u5230\u5b57\u5178", + "Insert table": "\u63d2\u5165\u8868\u683c", + "Table properties": "\u8868\u683c\u5c5e\u6027", + "Delete table": "\u5220\u9664\u8868\u683c", + "Cell": "\u5355\u5143\u683c", + "Row": "\u884c", + "Column": "\u5217", + "Cell properties": "\u5355\u5143\u683c\u5c5e\u6027", + "Merge cells": "\u5408\u5e76\u5355\u5143\u683c", + "Split cell": "\u62c6\u5206\u5355\u5143\u683c", + "Insert row before": "\u5728\u4e0a\u65b9\u63d2\u5165", + "Insert row after": "\u5728\u4e0b\u65b9\u63d2\u5165", + "Delete row": "\u5220\u9664\u884c", + "Row properties": "\u884c\u5c5e\u6027", + "Cut row": "\u526a\u5207\u884c", + "Copy row": "\u590d\u5236\u884c", + "Paste row before": "\u7c98\u8d34\u5230\u4e0a\u65b9", + "Paste row after": "\u7c98\u8d34\u5230\u4e0b\u65b9", + "Insert column before": "\u5728\u5de6\u4fa7\u63d2\u5165", + "Insert column after": "\u5728\u53f3\u4fa7\u63d2\u5165", + "Delete column": "\u5220\u9664\u5217", + "Cols": "\u5217", + "Rows": "\u884c", + "Width": "\u5bbd", + "Height": "\u9ad8", + "Cell spacing": "\u5355\u5143\u683c\u5916\u95f4\u8ddd", + "Cell padding": "\u5355\u5143\u683c\u5185\u8fb9\u8ddd", + "Caption": "\u6807\u9898", + "Show caption": "\u663e\u793a\u6807\u9898", + "Left": "\u5de6\u5bf9\u9f50", + "Center": "\u5c45\u4e2d", + "Right": "\u53f3\u5bf9\u9f50", + "Cell type": "\u5355\u5143\u683c\u7c7b\u578b", + "Scope": "\u8303\u56f4", + "Alignment": "\u5bf9\u9f50\u65b9\u5f0f", + "H Align": "\u6c34\u5e73\u5bf9\u9f50", + "V Align": "\u5782\u76f4\u5bf9\u9f50", + "Top": "\u9876\u90e8\u5bf9\u9f50", + "Middle": "\u5782\u76f4\u5c45\u4e2d", + "Bottom": "\u5e95\u90e8\u5bf9\u9f50", + "Header cell": "\u8868\u5934\u5355\u5143\u683c", + "Row group": "\u884c\u7ec4", + "Column group": "\u5217\u7ec4", + "Row type": "\u884c\u7c7b\u578b", + "Header": "\u8868\u5934", + "Body": "\u8868\u4f53", + "Footer": "\u8868\u5c3e", + "Border color": "\u8fb9\u6846\u989c\u8272", + "Insert template...": "\u63d2\u5165\u6a21\u677f...", + "Templates": "\u6a21\u677f", + "Template": "\u6a21\u677f", + "Text color": "\u6587\u5b57\u989c\u8272", + "Background color": "\u80cc\u666f\u8272", + "Custom...": "\u81ea\u5b9a\u4e49...", + "Custom color": "\u81ea\u5b9a\u4e49\u989c\u8272", + "No color": "\u65e0", + "Remove color": "\u79fb\u9664\u989c\u8272", + "Table of Contents": "\u5185\u5bb9\u5217\u8868", + "Show blocks": "\u663e\u793a\u533a\u5757\u8fb9\u6846", + "Show invisible characters": "\u663e\u793a\u4e0d\u53ef\u89c1\u5b57\u7b26", + "Word count": "\u5b57\u6570", + "Count": "\u8ba1\u6570", + "Document": "\u6587\u6863", + "Selection": "\u9009\u62e9", + "Words": "\u5355\u8bcd", + "Words: {0}": "\u5b57\u6570\uff1a{0}", + "{0} words": "{0} \u5b57", + "File": "\u6587\u4ef6", + "Edit": "\u7f16\u8f91", + "Insert": "\u63d2\u5165", + "View": "\u89c6\u56fe", + "Format": "\u683c\u5f0f", + "Table": "\u8868\u683c", + "Tools": "\u5de5\u5177", + "Powered by {0}": "\u7531{0}\u9a71\u52a8", + "Rich Text Area. Press ALT-F9 for menu. Press ALT-F10 for toolbar. Press ALT-0 for help": "\u5728\u7f16\u8f91\u533a\u6309ALT-F9\u6253\u5f00\u83dc\u5355\uff0c\u6309ALT-F10\u6253\u5f00\u5de5\u5177\u680f\uff0c\u6309ALT-0\u67e5\u770b\u5e2e\u52a9", + "Image title": "\u56fe\u7247\u6807\u9898", + "Border width": "\u8fb9\u6846\u5bbd\u5ea6", + "Border style": "\u8fb9\u6846\u6837\u5f0f", + "Error": "\u9519\u8bef", + "Warn": "\u8b66\u544a", + "Valid": "\u6709\u6548", + "To open the popup, press Shift+Enter": "\u6309Shitf+Enter\u952e\u6253\u5f00\u5bf9\u8bdd\u6846", + "Rich Text Area. Press ALT-0 for help.": "\u7f16\u8f91\u533a\u3002\u6309Alt+0\u952e\u6253\u5f00\u5e2e\u52a9\u3002", + "System Font": "\u7cfb\u7edf\u5b57\u4f53", + "Failed to upload image: {0}": "\u56fe\u7247\u4e0a\u4f20\u5931\u8d25: {0}", + "Failed to load plugin: {0} from url {1}": "\u63d2\u4ef6\u52a0\u8f7d\u5931\u8d25: {0} \u6765\u81ea\u94fe\u63a5 {1}", + "Failed to load plugin url: {0}": "\u63d2\u4ef6\u52a0\u8f7d\u5931\u8d25 \u94fe\u63a5: {0}", + "Failed to initialize plugin: {0}": "\u63d2\u4ef6\u521d\u59cb\u5316\u5931\u8d25: {0}", + "example": "\u793a\u4f8b", + "Search": "\u641c\u7d22", + "All": "\u5168\u90e8", + "Currency": "\u8d27\u5e01", + "Text": "\u6587\u5b57", + "Quotations": "\u5f15\u7528", + "Mathematical": "\u6570\u5b66", + "Extended Latin": "\u62c9\u4e01\u8bed\u6269\u5145", + "Symbols": "\u7b26\u53f7", + "Arrows": "\u7bad\u5934", + "User Defined": "\u81ea\u5b9a\u4e49", + "dollar sign": "\u7f8e\u5143\u7b26\u53f7", + "currency sign": "\u8d27\u5e01\u7b26\u53f7", + "euro-currency sign": "\u6b27\u5143\u7b26\u53f7", + "colon sign": "\u5192\u53f7", + "cruzeiro sign": "\u514b\u9c81\u8d5b\u7f57\u5e01\u7b26\u53f7", + "french franc sign": "\u6cd5\u90ce\u7b26\u53f7", + "lira sign": "\u91cc\u62c9\u7b26\u53f7", + "mill sign": "\u5bc6\u5c14\u7b26\u53f7", + "naira sign": "\u5948\u62c9\u7b26\u53f7", + "peseta sign": "\u6bd4\u585e\u5854\u7b26\u53f7", + "rupee sign": "\u5362\u6bd4\u7b26\u53f7", + "won sign": "\u97e9\u5143\u7b26\u53f7", + "new sheqel sign": "\u65b0\u8c22\u514b\u5c14\u7b26\u53f7", + "dong sign": "\u8d8a\u5357\u76fe\u7b26\u53f7", + "kip sign": "\u8001\u631d\u57fa\u666e\u7b26\u53f7", + "tugrik sign": "\u56fe\u683c\u91cc\u514b\u7b26\u53f7", + "drachma sign": "\u5fb7\u62c9\u514b\u9a6c\u7b26\u53f7", + "german penny symbol": "\u5fb7\u56fd\u4fbf\u58eb\u7b26\u53f7", + "peso sign": "\u6bd4\u7d22\u7b26\u53f7", + "guarani sign": "\u74dc\u62c9\u5c3c\u7b26\u53f7", + "austral sign": "\u6fb3\u5143\u7b26\u53f7", + "hryvnia sign": "\u683c\u91cc\u592b\u5c3c\u4e9a\u7b26\u53f7", + "cedi sign": "\u585e\u5730\u7b26\u53f7", + "livre tournois sign": "\u91cc\u5f17\u5f17\u5c14\u7b26\u53f7", + "spesmilo sign": "spesmilo\u7b26\u53f7", + "tenge sign": "\u575a\u6208\u7b26\u53f7", + "indian rupee sign": "\u5370\u5ea6\u5362\u6bd4", + "turkish lira sign": "\u571f\u8033\u5176\u91cc\u62c9", + "nordic mark sign": "\u5317\u6b27\u9a6c\u514b", + "manat sign": "\u9a6c\u7eb3\u7279\u7b26\u53f7", + "ruble sign": "\u5362\u5e03\u7b26\u53f7", + "yen character": "\u65e5\u5143\u5b57\u6837", + "yuan character": "\u4eba\u6c11\u5e01\u5143\u5b57\u6837", + "yuan character, in hong kong and taiwan": "\u5143\u5b57\u6837\uff08\u6e2f\u53f0\u5730\u533a\uff09", + "yen\/yuan character variant one": "\u5143\u5b57\u6837\uff08\u5927\u5199\uff09", + "Loading emoticons...": "\u52a0\u8f7d\u8868\u60c5\u7b26\u53f7...", + "Could not load emoticons": "\u4e0d\u80fd\u52a0\u8f7d\u8868\u60c5\u7b26\u53f7", + "People": "\u4eba\u7c7b", + "Animals and Nature": "\u52a8\u7269\u548c\u81ea\u7136", + "Food and Drink": "\u98df\u7269\u548c\u996e\u54c1", + "Activity": "\u6d3b\u52a8", + "Travel and Places": "\u65c5\u6e38\u548c\u5730\u70b9", + "Objects": "\u7269\u4ef6", + "Flags": "\u65d7\u5e1c", + "Characters": "\u5b57\u7b26", + "Characters (no spaces)": "\u5b57\u7b26(\u65e0\u7a7a\u683c)", + "{0} characters": "{0} \u4e2a\u5b57\u7b26", + "Error: Form submit field collision.": "\u9519\u8bef: \u8868\u5355\u63d0\u4ea4\u5b57\u6bb5\u51b2\u7a81\u3002", + "Error: No form element found.": "\u9519\u8bef: \u6ca1\u6709\u8868\u5355\u63a7\u4ef6\u3002", + "Update": "\u66f4\u65b0", + "Color swatch": "\u989c\u8272\u6837\u672c", + "Turquoise": "\u9752\u7eff\u8272", + "Green": "\u7eff\u8272", + "Blue": "\u84dd\u8272", + "Purple": "\u7d2b\u8272", + "Navy Blue": "\u6d77\u519b\u84dd", + "Dark Turquoise": "\u6df1\u84dd\u7eff\u8272", + "Dark Green": "\u6df1\u7eff\u8272", + "Medium Blue": "\u4e2d\u84dd\u8272", + "Medium Purple": "\u4e2d\u7d2b\u8272", + "Midnight Blue": "\u6df1\u84dd\u8272", + "Yellow": "\u9ec4\u8272", + "Orange": "\u6a59\u8272", + "Red": "\u7ea2\u8272", + "Light Gray": "\u6d45\u7070\u8272", + "Gray": "\u7070\u8272", + "Dark Yellow": "\u6697\u9ec4\u8272", + "Dark Orange": "\u6df1\u6a59\u8272", + "Dark Red": "\u6df1\u7ea2\u8272", + "Medium Gray": "\u4e2d\u7070\u8272", + "Dark Gray": "\u6df1\u7070\u8272", + "Light Green": "\u6d45\u7eff\u8272", + "Light Yellow": "\u6d45\u9ec4\u8272", + "Light Red": "\u6d45\u7ea2\u8272", + "Light Purple": "\u6d45\u7d2b\u8272", + "Light Blue": "\u6d45\u84dd\u8272", + "Dark Purple": "\u6df1\u7d2b\u8272", + "Dark Blue": "\u6df1\u84dd\u8272", + "Black": "\u9ed1\u8272", + "White": "\u767d\u8272", + "Switch to or from fullscreen mode": "\u5207\u6362\u5168\u5c4f\u6a21\u5f0f", + "Open help dialog": "\u6253\u5f00\u5e2e\u52a9\u5bf9\u8bdd\u6846", + "history": "\u5386\u53f2", + "styles": "\u6837\u5f0f", + "formatting": "\u683c\u5f0f\u5316", + "alignment": "\u5bf9\u9f50", + "indentation": "\u7f29\u8fdb", + "Font": "\u5b57\u4f53", + "Size": "\u5b57\u53f7", + "More...": "\u66f4\u591a...", + "Select...": "\u9009\u62e9...", + "Preferences": "\u9996\u9009\u9879", + "Yes": "\u662f", + "No": "\u5426", + "Keyboard Navigation": "\u952e\u76d8\u6307\u5f15", + "Version": "\u7248\u672c", + "Code view": "\u4ee3\u7801\u89c6\u56fe", + "Open popup menu for split buttons": "\u6253\u5f00\u5f39\u51fa\u5f0f\u83dc\u5355\uff0c\u7528\u4e8e\u62c6\u5206\u6309\u94ae", + "List Properties": "\u5217\u8868\u5c5e\u6027", + "List properties...": "\u6807\u9898\u5b57\u4f53\u5c5e\u6027", + "Start list at number": "\u4ee5\u6570\u5b57\u5f00\u59cb\u5217\u8868", + "Line height": "\u884c\u9ad8", + "comments": "\u5907\u6ce8", + "Format Painter": "\u683c\u5f0f\u5237", + "Insert\/edit iframe": "\u63d2\u5165\/\u7f16\u8f91\u6846\u67b6", + "Capitalization": "\u5927\u5199", + "lowercase": "\u5c0f\u5199", + "UPPERCASE": "\u5927\u5199", + "Title Case": "\u9996\u5b57\u6bcd\u5927\u5199", + "permanent pen": "\u8bb0\u53f7\u7b14", + "Permanent Pen Properties": "\u6c38\u4e45\u7b14\u5c5e\u6027", + "Permanent pen properties...": "\u6c38\u4e45\u7b14\u5c5e\u6027...", + "case change": "\u6848\u4f8b\u66f4\u6539", + "page embed": "\u9875\u9762\u5d4c\u5165", + "Advanced sort...": "\u9ad8\u7ea7\u6392\u5e8f...", + "Advanced Sort": "\u9ad8\u7ea7\u6392\u5e8f", + "Sort table by column ascending": "\u6309\u5217\u5347\u5e8f\u8868", + "Sort table by column descending": "\u6309\u5217\u964d\u5e8f\u8868", + "Sort": "\u6392\u5e8f", + "Order": "\u6392\u5e8f", + "Sort by": "\u6392\u5e8f\u65b9\u5f0f", + "Ascending": "\u5347\u5e8f", + "Descending": "\u964d\u5e8f", + "Column {0}": "\u5217{0}", + "Row {0}": "\u884c{0}", + "Spellcheck...": "\u62fc\u5199\u68c0\u67e5...", + "Misspelled word": "\u62fc\u5199\u9519\u8bef\u7684\u5355\u8bcd", + "Suggestions": "\u5efa\u8bae", + "Change": "\u66f4\u6539", + "Finding word suggestions": "\u67e5\u627e\u5355\u8bcd\u5efa\u8bae", + "Success": "\u6210\u529f", + "Repair": "\u4fee\u590d", + "Issue {0} of {1}": "\u5171\u8ba1{1}\u95ee\u9898{0}", + "Images must be marked as decorative or have an alternative text description": "\u56fe\u50cf\u5fc5\u987b\u6807\u8bb0\u4e3a\u88c5\u9970\u6027\u6216\u5177\u6709\u66ff\u4ee3\u6587\u672c\u63cf\u8ff0", + "Images must have an alternative text description. Decorative images are not allowed.": "\u56fe\u50cf\u5fc5\u987b\u5177\u6709\u66ff\u4ee3\u6587\u672c\u63cf\u8ff0\u3002\u4e0d\u5141\u8bb8\u4f7f\u7528\u88c5\u9970\u56fe\u50cf\u3002", + "Or provide alternative text:": "\u6216\u63d0\u4f9b\u5907\u9009\u6587\u672c\uff1a", + "Make image decorative:": "\u4f7f\u56fe\u50cf\u88c5\u9970\uff1a", + "ID attribute must be unique": "ID \u5c5e\u6027\u5fc5\u987b\u662f\u552f\u4e00\u7684", + "Make ID unique": "\u4f7f ID \u72ec\u4e00\u65e0\u4e8c", + "Keep this ID and remove all others": "\u4fdd\u7559\u6b64 ID \u5e76\u5220\u9664\u6240\u6709\u5176\u4ed6", + "Remove this ID": "\u5220\u9664\u6b64 ID", + "Remove all IDs": "\u6e05\u9664\u5168\u90e8IDs", + "Checklist": "\u6e05\u5355", + "Anchor": "\u951a\u70b9", + "Special character": "\u7279\u6b8a\u7b26\u53f7", + "Code sample": "\u4ee3\u7801\u793a\u4f8b", + "Color": "\u989c\u8272", + "Document properties": "\u6587\u6863\u5c5e\u6027", + "Image description": "\u56fe\u7247\u63cf\u8ff0", + "Image": "\u56fe\u7247", + "Insert link": "\u63d2\u5165\u94fe\u63a5", + "Target": "\u6253\u5f00\u65b9\u5f0f", + "Link": "\u94fe\u63a5", + "Poster": "\u5c01\u9762", + "Media": "\u5a92\u4f53", + "Print": "\u6253\u5370", + "Prev": "\u4e0a\u4e00\u4e2a", + "Find and replace": "\u67e5\u627e\u548c\u66ff\u6362", + "Whole words": "\u5168\u5b57\u5339\u914d", + "Insert template": "\u63d2\u5165\u6a21\u677f" +}); diff --git a/ui/src/App.vue b/ui/src/App.vue new file mode 100644 index 0000000..e4467e9 --- /dev/null +++ b/ui/src/App.vue @@ -0,0 +1,71 @@ + + + diff --git a/ui/src/api/common/city.ts b/ui/src/api/common/city.ts new file mode 100644 index 0000000..f35183c --- /dev/null +++ b/ui/src/api/common/city.ts @@ -0,0 +1,11 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: 城市列表 + */ +export function getCityByList(pid: any) { + return http.request({ + url: '/city/getCityList/' + pid, + method: 'get', + }); +} diff --git a/ui/src/api/common/index.ts b/ui/src/api/common/index.ts new file mode 100644 index 0000000..ea9b786 --- /dev/null +++ b/ui/src/api/common/index.ts @@ -0,0 +1,73 @@ +import { http } from '@/utils/http/axios'; + +/** + * 本地文件上传 + * @param data 参数 + * @returns 返回结果 + */ +export function upload(data) { + return http.request({ + url: '/upload/uploadFile', + method: 'post', + data, + }); +} + +/** + * 返回OSS配置 + * @param params 参数 + * @returns 返回结果 + */ +export function getOssConfig(params?) { + return http.request({ + url: '/config/item/getItemList/config_oss', + method: 'GET', + params, + }); +} + +/** + * 检测Token令牌有效性 + * @param params 参数 + * @returns 返回结果 + */ +export function checkToken(params?) { + return http.request({ + url: '/check/token', + method: 'GET', + params, + }); +} + +/** + * 根据字典编码查询字典项数据 + * @param code 字典编码 + * @returns 返回结果 + */ +export function getDictItemByCode(code) { + return http.request({ + url: '/dict/item/getDictItemList/' + code, + method: 'GET', + }); +} + +/** + * @description: 下载模板 + */ +export function getTemplateByCode(code: any) { + return http.request({ + url: '/file/template/getTemplateByCode/' + code, + method: 'GET', + }); +} + +/** + * @description: 发送消息 + */ +export function sendMsg(data) { + return http.request({ + url: '/websocket/sendMsg', + method: 'post', + data, + }); +} diff --git a/ui/src/api/common/menu.ts b/ui/src/api/common/menu.ts new file mode 100644 index 0000000..16f3f70 --- /dev/null +++ b/ui/src/api/common/menu.ts @@ -0,0 +1,11 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: 根据用户id获取用户菜单 + */ +export function adminMenus() { + return http.request({ + url: '/index/getMenus', + method: 'GET', + }); +} diff --git a/ui/src/api/common/user.ts b/ui/src/api/common/user.ts new file mode 100644 index 0000000..6afe285 --- /dev/null +++ b/ui/src/api/common/user.ts @@ -0,0 +1,136 @@ +import { http } from '@/utils/http/axios'; + +export interface BasicResponseModel { + code: number; + msg: string; + data: T; +} + +export interface BasicPageParams { + pageNo: number; + pageSize: number; + total: number; +} + +/** + * @description: 获取用户信息 + */ +export function getUserInfo() { + return http.request({ + url: '/index/getUser', + method: 'get', + }); +} + +/** + * @description: 用户注册 + */ +export function register(params) { + return http.request( + { + url: `/register`, + method: 'POST', + params, + }, + { + isTransformResponse: false, + }, + ); +} + +/** + * @description: 用户登录 + */ +export function login(params) { + return http.request( + { + url: '/login', + method: 'POST', + params, + }, + { + isTransformResponse: false, + }, + ); +} + +/** + * 登录2 + * @param data 参数 + * @returns 返回结果 + */ +export function login2(data) { + const formData = new FormData(); + formData.append('username', data.username); + formData.append('password', data.password); + formData.append('code', data.code); + formData.append('key', data.key); + formData.append('grant_type', data.grant_type); + return http.request( + { + url: '/token', + method: 'POST', + data: formData, + headers: { + 'Content-Type': 'multipart/form-data', + Authorization: 'Basic YWRtaW46MTIzNDU2', + }, + }, + { + isTransformResponse: false, + }, + ); +} + +/** + * @description: 用户登录 + */ +export function getInfoCaptcha() { + return http.request({ + url: '/captcha', + method: 'GET', + }); +} + +/** + * @description: 用户修改密码 + */ +export function changePassword(data) { + return http.request({ + url: `/index/updatePassword`, + method: 'PUT', + data, + }); +} + +/** + * @description: 获取短信验证码 + */ +export function sendSms(data) { + return http.request({ + url: `/sms/sendSms`, + method: 'POST', + data, + }); +} + +/** + * @description: 用户登出 + */ +export function logout(params) { + return http.request({ + url: '/logout', + method: 'GET', + params, + }); +} + +/** + * @description: 撤销令牌 + */ +export function removeToken(token) { + return http.request({ + url: '/remove/' + token, + method: 'DELETE', + }); +} diff --git a/ui/src/api/content/article.ts b/ui/src/api/content/article.ts new file mode 100644 index 0000000..5ab7f3e --- /dev/null +++ b/ui/src/api/content/article.ts @@ -0,0 +1,65 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: 列表 + */ +export function getArticleList(params?) { + return http.request({ + url: '/article/page', + method: 'GET', + params, + }); +} + +/** + * @description: 根据ID获取详情 + */ +export function getArticleDetail(articleId) { + return http.request({ + url: '/article/detail/' + articleId, + method: 'get', + }); +} + +/** + * @description: 添加 + */ +export function articleAdd(data: any) { + return http.request({ + url: '/article/add', + method: 'POST', + data, + }); +} + +/** + * @description: 更新 + */ +export function articleUpdate(data: any) { + return http.request({ + url: '/article/update', + method: 'PUT', + data, + }); +} + +/** + * @description: 删除 + */ +export function articleDelete(articleId) { + return http.request({ + url: '/article/delete/' + articleId, + method: 'DELETE', + }); +} + +/** + * @description: 批量删除 + */ +export function articleBatchDelete(data: any) { + return http.request({ + url: '/article/batchDelete', + method: 'DELETE', + data, + }); +} diff --git a/ui/src/api/content/category.ts b/ui/src/api/content/category.ts new file mode 100644 index 0000000..42bb481 --- /dev/null +++ b/ui/src/api/content/category.ts @@ -0,0 +1,54 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: 列表 + */ +export function getCategoryList(params?) { + return http.request({ + url: '/category/list', + method: 'GET', + params, + }); +} + +/** + * @description: 根据ID获取详情 + */ +export function getCategoryDetail(categoryId) { + return http.request({ + url: '/category/detail/' + categoryId, + method: 'get', + }); +} + +/** + * @description: 添加 + */ +export function categoryAdd(data: any) { + return http.request({ + url: '/category/add', + method: 'POST', + data, + }); +} + +/** + * @description: 更新 + */ +export function categoryUpdate(data: any) { + return http.request({ + url: '/category/update', + method: 'PUT', + data, + }); +} + +/** + * @description: 删除部门 + */ +export function categoryDelete(categoryId) { + return http.request({ + url: '/category/delete/' + categoryId, + method: 'DELETE', + }); +} diff --git a/ui/src/api/content/link.ts b/ui/src/api/content/link.ts new file mode 100644 index 0000000..de7c669 --- /dev/null +++ b/ui/src/api/content/link.ts @@ -0,0 +1,65 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: 列表 + */ +export function getLinkList(params?) { + return http.request({ + url: '/link/page', + method: 'GET', + params, + }); +} + +/** + * @description: 根据ID获取详情 + */ +export function getLinkDetail(linkId) { + return http.request({ + url: '/link/detail/' + linkId, + method: 'get', + }); +} + +/** + * @description: 添加 + */ +export function linkAdd(data: any) { + return http.request({ + url: '/link/add', + method: 'POST', + data, + }); +} + +/** + * @description: 更新 + */ +export function linkUpdate(data: any) { + return http.request({ + url: '/link/update', + method: 'PUT', + data, + }); +} + +/** + * @description: 删除 + */ +export function linkDelete(linkId) { + return http.request({ + url: '/link/delete/' + linkId, + method: 'DELETE', + }); +} + +/** + * @description: 批量删除 + */ +export function linkBatchDelete(data: any) { + return http.request({ + url: '/link/batchDelete', + method: 'DELETE', + data, + }); +} diff --git a/ui/src/api/dashboard/console.ts b/ui/src/api/dashboard/console.ts new file mode 100644 index 0000000..13011a9 --- /dev/null +++ b/ui/src/api/dashboard/console.ts @@ -0,0 +1,68 @@ +import { http } from '@/utils/http/axios'; + +export interface TypeVisits { + dayVisits: number; + rise: number; + decline: number; + amount: number; +} +export interface TypeSaleroom { + weekSaleroom: number; + amount: number; + degree: number; +} + +export interface TypeOrderLarge { + weekLarge: number; + rise: number; + decline: number; + amount: number; +} + +export interface TypeConsole { + visits: TypeVisits; + //销售额 + saleroom: TypeSaleroom; + //订单量 + orderLarge: TypeOrderLarge; + //成交额度 + volume: TypeOrderLarge; +} + +//获取主控台信息 +export function getConsoleInfo() { + return consoleInfo; +} + +/** + * 模拟控制台数据 + */ +const consoleInfo = { + //访问量 + visits: { + dayVisits: 32335, + rise: 48, + decline: 85, + amount: 948113, + }, + //销售额 + saleroom: { + weekSaleroom: 90758, + amount: 411553, + degree: 38.245882, + }, + //订单量 + orderLarge: { + weekLarge: 35704, + rise: 28, + decline: 28, + amount: 480578, + }, + //成交额度 + volume: { + weekLarge: 67800, + rise: 96, + decline: 81, + amount: 826814, + }, +}; diff --git a/ui/src/api/dashboard/message.ts b/ui/src/api/dashboard/message.ts new file mode 100644 index 0000000..9490a54 --- /dev/null +++ b/ui/src/api/dashboard/message.ts @@ -0,0 +1,50 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: 消息列表 + */ +export function getMessageProfile(params?) { + return http.request({ + url: '/message/profile', + method: 'GET', + params, + }); +} +/** + * @description: 设置消息已读 + */ +export function setRead(data?) { + return http.request({ + url: '/message/setRead', + method: 'POST', + data, + }); +} +/** + * @description: 根据ID获取详情 + */ +export function getMessageDetail(id) { + return http.request({ + url: '/message/detail/' + id, + method: 'get', + }); +} +/** + * @description: 删除消息 + */ +export function messageDelete(id) { + return http.request({ + url: '/message/delete/' + id, + method: 'DELETE', + }); +} +/** + * @description: 批量删除消息 + */ +export function messageBatchDelete(data: any) { + return http.request({ + url: '/message/batchDelete', + method: 'DELETE', + data, + }); +} diff --git a/ui/src/api/data/city.ts b/ui/src/api/data/city.ts new file mode 100644 index 0000000..9a6c345 --- /dev/null +++ b/ui/src/api/data/city.ts @@ -0,0 +1,65 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: 行政区划列表 + */ +export function getCityList(params?) { + return http.request({ + url: '/city/list', + method: 'GET', + params, + }); +} + +/** + * @description: 根据ID获取详情 + */ +export function getCityDetail(id) { + return http.request({ + url: '/city/detail/' + id, + method: 'get', + }); +} + +/** + * @description: 添加行政区划 + */ +export function cityAdd(data: any) { + return http.request({ + url: '/city/add', + method: 'POST', + data, + }); +} + +/** + * @description: 更新行政区划 + */ +export function cityUpdate(data: any) { + return http.request({ + url: '/city/update', + method: 'PUT', + data, + }); +} + +/** + * @description: 删除行政区划 + */ +export function cityDelete(id) { + return http.request({ + url: '/city/delete/' + id, + method: 'DELETE', + }); +} + +/** + * @description: 批量删除行政区划 + */ +export function cityBatchDelete(data: any) { + return http.request({ + url: '/city/batchDelete', + method: 'DELETE', + data, + }); +} diff --git a/ui/src/api/data/config.ts b/ui/src/api/data/config.ts new file mode 100644 index 0000000..b85e8fd --- /dev/null +++ b/ui/src/api/data/config.ts @@ -0,0 +1,155 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: 配置列表 + */ +export function getConfigList(params?) { + return http.request({ + url: '/config/page', + method: 'GET', + params, + }); +} + +/** + * 获取全部配置列表 + * @param params 参数 + * @returns 返回结果 + */ +export function getConfigAllList(params?) { + return http.request({ + url: '/config/list', + method: 'GET', + params, + }); +} + +/** + * @description: 根据ID获取详情 + */ +export function getConfigDetail(id) { + return http.request({ + url: '/config/detail/' + id, + method: 'get', + }); +} + +/** + * @description: 添加配置 + */ +export function configAdd(data: any) { + return http.request({ + url: '/config/add', + method: 'POST', + data, + }); +} + +/** + * @description: 更新配置 + */ +export function configUpdate(data: any) { + return http.request({ + url: '/config/update', + method: 'PUT', + data, + }); +} + +/** + * @description: 删除配置 + */ +export function configDelete(id) { + return http.request({ + url: '/config/delete/' + id, + method: 'DELETE', + }); +} + +/** + * @description: 批量删除配置 + */ +export function configBatchDelete(data: any) { + return http.request({ + url: '/config/batchDelete', + method: 'DELETE', + data, + }); +} + +/** + * @description: 配置项列表 + */ +export function getConfigItemList(params?) { + return http.request({ + url: '/config/item/page', + method: 'GET', + params, + }); +} + +/** + * 获取全部配置项列表 + * @param params 参数 + * @returns 返回结果 + */ +export function getConfigItemAllList(params?) { + return http.request({ + url: '/config/item/list', + method: 'GET', + params, + }); +} + +/** + * @description: 根据ID获取详情 + */ +export function getConfigItemDetail(id) { + return http.request({ + url: '/config/item/detail/' + id, + method: 'get', + }); +} + +/** + * @description: 添加配置项 + */ +export function configItemAdd(data: any) { + return http.request({ + url: '/config/item/add', + method: 'POST', + data, + }); +} + +/** + * @description: 更新配置项 + */ +export function configItemUpdate(data: any) { + return http.request({ + url: '/config/item/update', + method: 'PUT', + data, + }); +} + +/** + * @description: 删除配置项 + */ +export function configItemDelete(id) { + return http.request({ + url: '/config/item/delete/' + id, + method: 'DELETE', + }); +} + +/** + * @description: 批量删除配置项 + */ +export function configItemBatchDelete(data: any) { + return http.request({ + url: '/config/item/batchDelete', + method: 'DELETE', + data, + }); +} diff --git a/ui/src/api/data/dictionary.ts b/ui/src/api/data/dictionary.ts new file mode 100644 index 0000000..ee2ccc2 --- /dev/null +++ b/ui/src/api/data/dictionary.ts @@ -0,0 +1,139 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: 字典列表 + */ +export function getDictList(params?) { + return http.request({ + url: '/dict/page', + method: 'GET', + params, + }); +} + +/** + * @description: 根据ID获取详情 + */ +export function getDictDetail(id) { + return http.request({ + url: '/dict/detail/' + id, + method: 'get', + }); +} + +/** + * @description: 刷新缓存 + */ +export function refreshCache() { + return http.request({ + url: '/dict/refreshCache', + method: 'get', + }); +} + +/** + * @description: 添加字典 + */ +export function dictAdd(data: any) { + return http.request({ + url: '/dict/add', + method: 'POST', + data, + }); +} + +/** + * @description: 更新字典 + */ +export function dictUpdate(data: any) { + return http.request({ + url: '/dict/update', + method: 'PUT', + data, + }); +} + +/** + * @description: 删除字典 + */ +export function dictDelete(id) { + return http.request({ + url: '/dict/delete/' + id, + method: 'DELETE', + }); +} + +/** + * @description: 批量删除字典 + */ +export function dictBatchDelete(data: any) { + return http.request({ + url: '/dict/batchDelete', + method: 'DELETE', + data, + }); +} + +/** + * @description: 字典项列表 + */ +export function getDictItemList(params?) { + return http.request({ + url: '/dict/item/page', + method: 'GET', + params, + }); +} + +/** + * @description: 根据ID获取详情 + */ +export function getDictItemDetail(id) { + return http.request({ + url: '/dict/item/detail/' + id, + method: 'get', + }); +} + +/** + * @description: 添加字典项 + */ +export function dictItemAdd(data: any) { + return http.request({ + url: '/dict/item/add', + method: 'POST', + data, + }); +} + +/** + * @description: 更新字典项 + */ +export function dictItemUpdate(data: any) { + return http.request({ + url: '/dict/item/update', + method: 'PUT', + data, + }); +} + +/** + * @description: 删除字典项 + */ +export function dictItemDelete(id) { + return http.request({ + url: '/dict/item/delete/' + id, + method: 'DELETE', + }); +} + +/** + * @description: 批量删除字典项 + */ +export function dictItemBatchDelete(data: any) { + return http.request({ + url: '/dict/item/batchDelete', + method: 'DELETE', + data, + }); +} diff --git a/ui/src/api/data/message.ts b/ui/src/api/data/message.ts new file mode 100644 index 0000000..c779448 --- /dev/null +++ b/ui/src/api/data/message.ts @@ -0,0 +1,53 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: 消息列表 + */ +export function getMessageList(params?) { + return http.request({ + url: '/message/page', + method: 'GET', + params, + }); +} + +/** + * @description: 已读消息 + */ +export function messageRead(id) { + return http.request({ + url: '/message/read/' + id, + method: 'get', + }); +} + +/** + * @description: 根据ID获取详情 + */ +export function getMessageDetail(id) { + return http.request({ + url: '/message/detail/' + id, + method: 'get', + }); +} + +/** + * @description: 删除消息 + */ +export function messageDelete(id) { + return http.request({ + url: '/message/delete/' + id, + method: 'DELETE', + }); +} + +/** + * @description: 批量删除消息 + */ +export function messageBatchDelete(data: any) { + return http.request({ + url: '/message/batchDelete', + method: 'DELETE', + data, + }); +} diff --git a/ui/src/api/data/notice.ts b/ui/src/api/data/notice.ts new file mode 100644 index 0000000..97c9a39 --- /dev/null +++ b/ui/src/api/data/notice.ts @@ -0,0 +1,78 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: 公告列表 + */ +export function getNoticeList(params?) { + return http.request({ + url: '/notice/page', + method: 'GET', + params, + }); +} + +/** + * 获取全部通知公告列表 + * @param params 参数 + * @returns 返回结果 + */ +export function getNoticeAllList(params?) { + return http.request({ + url: '/notice/list', + method: 'GET', + params, + }); +} + +/** + * @description: 根据ID获取详情 + */ +export function getNoticeDetail(id) { + return http.request({ + url: '/notice/detail/' + id, + method: 'get', + }); +} + +/** + * @description: 添加公告 + */ +export function noticeAdd(data: any) { + return http.request({ + url: '/notice/add', + method: 'POST', + data, + }); +} + +/** + * @description: 更新公告 + */ +export function noticeUpdate(data: any) { + return http.request({ + url: '/notice/update', + method: 'PUT', + data, + }); +} + +/** + * @description: 删除公告 + */ +export function noticeDelete(id) { + return http.request({ + url: '/notice/delete/' + id, + method: 'DELETE', + }); +} + +/** + * @description: 批量删除公告 + */ +export function noticeBatchDelete(data: any) { + return http.request({ + url: '/notice/batchDelete', + method: 'DELETE', + data, + }); +} diff --git a/ui/src/api/data/param.ts b/ui/src/api/data/param.ts new file mode 100644 index 0000000..d04acce --- /dev/null +++ b/ui/src/api/data/param.ts @@ -0,0 +1,78 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: 参数列表 + */ +export function getParamList(params?) { + return http.request({ + url: '/param/page', + method: 'GET', + params, + }); +} + +/** + * 获取全部参数列表 + * @param params 参数 + * @returns 返回结果 + */ +export function getParamAllList(params?) { + return http.request({ + url: '/param/list', + method: 'GET', + params, + }); +} + +/** + * @description: 根据ID获取详情 + */ +export function getParamDetail(paramId) { + return http.request({ + url: '/param/detail/' + paramId, + method: 'get', + }); +} + +/** + * @description: 添加参数 + */ +export function paramAdd(data: any) { + return http.request({ + url: '/param/add', + method: 'POST', + data, + }); +} + +/** + * @description: 更新参数 + */ +export function paramUpdate(data: any) { + return http.request({ + url: '/param/update', + method: 'PUT', + data, + }); +} + +/** + * @description: 删除参数 + */ +export function paramDelete(paramId) { + return http.request({ + url: '/param/delete/' + paramId, + method: 'DELETE', + }); +} + +/** + * @description: 批量删除参数 + */ +export function paramBatchDelete(data: any) { + return http.request({ + url: '/param/batchDelete', + method: 'DELETE', + data, + }); +} diff --git a/ui/src/api/file/emailTemplate.ts b/ui/src/api/file/emailTemplate.ts new file mode 100644 index 0000000..cc882f8 --- /dev/null +++ b/ui/src/api/file/emailTemplate.ts @@ -0,0 +1,65 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: 列表 + */ +export function getEmailTemplateList(params?) { + return http.request({ + url: '/email/template/page', + method: 'GET', + params, + }); +} + +/** + * @description: 根据ID获取详情 + */ +export function getEmailTemplateDetail(id) { + return http.request({ + url: '/email/template/detail/' + id, + method: 'get', + }); +} + +/** + * @description: 添加 + */ +export function emailTemplateAdd(data: any) { + return http.request({ + url: '/email/template/add', + method: 'POST', + data, + }); +} + +/** + * @description: 更新 + */ +export function emailTemplateUpdate(data: any) { + return http.request({ + url: '/email/template/update', + method: 'PUT', + data, + }); +} + +/** + * @description: 删除 + */ +export function emailTemplateDelete(id) { + return http.request({ + url: '/email/template/delete/' + id, + method: 'DELETE', + }); +} + +/** + * @description: 批量删除 + */ +export function emailTemplateBatchDelete(data: any) { + return http.request({ + url: '/email/template/batchDelete', + method: 'DELETE', + data, + }); +} diff --git a/ui/src/api/file/fileTemplate.ts b/ui/src/api/file/fileTemplate.ts new file mode 100644 index 0000000..841f94b --- /dev/null +++ b/ui/src/api/file/fileTemplate.ts @@ -0,0 +1,65 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: 列表 + */ +export function getFileTemplateList(params?) { + return http.request({ + url: '/file/template/page', + method: 'GET', + params, + }); +} + +/** + * @description: 根据ID获取详情 + */ +export function getFileTemplateDetail(id) { + return http.request({ + url: '/file/template/detail/' + id, + method: 'get', + }); +} + +/** + * @description: 添加 + */ +export function fileTemplateAdd(data: any) { + return http.request({ + url: '/file/template/add', + method: 'POST', + data, + }); +} + +/** + * @description: 更新 + */ +export function fileTemplateUpdate(data: any) { + return http.request({ + url: '/file/template/update', + method: 'PUT', + data, + }); +} + +/** + * @description: 删除 + */ +export function fileTemplateDelete(id) { + return http.request({ + url: '/file/template/delete/' + id, + method: 'DELETE', + }); +} + +/** + * @description: 批量删除 + */ +export function fileTemplateBatchDelete(data: any) { + return http.request({ + url: '/file/template/batchDelete', + method: 'DELETE', + data, + }); +} diff --git a/ui/src/api/file/messageTemplate.ts b/ui/src/api/file/messageTemplate.ts new file mode 100644 index 0000000..9041293 --- /dev/null +++ b/ui/src/api/file/messageTemplate.ts @@ -0,0 +1,65 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: 列表 + */ +export function getMessageTemplateList(params?) { + return http.request({ + url: '/message/template/page', + method: 'GET', + params, + }); +} + +/** + * @description: 根据ID获取详情 + */ +export function getMessageTemplateDetail(id) { + return http.request({ + url: '/message/template/detail/' + id, + method: 'get', + }); +} + +/** + * @description: 添加 + */ +export function messageTemplateAdd(data: any) { + return http.request({ + url: '/message/template/add', + method: 'POST', + data, + }); +} + +/** + * @description: 更新 + */ +export function messageTemplateUpdate(data: any) { + return http.request({ + url: '/message/template/update', + method: 'PUT', + data, + }); +} + +/** + * @description: 删除 + */ +export function messageTemplateDelete(id) { + return http.request({ + url: '/message/template/delete/' + id, + method: 'DELETE', + }); +} + +/** + * @description: 批量删除 + */ +export function messageTemplateBatchDelete(data: any) { + return http.request({ + url: '/message/template/batchDelete', + method: 'DELETE', + data, + }); +} diff --git a/ui/src/api/file/smsTemplate.ts b/ui/src/api/file/smsTemplate.ts new file mode 100644 index 0000000..582a476 --- /dev/null +++ b/ui/src/api/file/smsTemplate.ts @@ -0,0 +1,65 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: 列表 + */ +export function getSmsTemplateList(params?) { + return http.request({ + url: '/sms/template/page', + method: 'GET', + params, + }); +} + +/** + * @description: 根据ID获取详情 + */ +export function getSmsTemplateDetail(id) { + return http.request({ + url: '/sms/template/detail/' + id, + method: 'get', + }); +} + +/** + * @description: 添加 + */ +export function smsTemplateAdd(data: any) { + return http.request({ + url: '/sms/template/add', + method: 'POST', + data, + }); +} + +/** + * @description: 更新 + */ +export function smsTemplateUpdate(data: any) { + return http.request({ + url: '/sms/template/update', + method: 'PUT', + data, + }); +} + +/** + * @description: 删除 + */ +export function smsTemplateDelete(id) { + return http.request({ + url: '/sms/template/delete/' + id, + method: 'DELETE', + }); +} + +/** + * @description: 批量删除 + */ +export function smsTemplateBatchDelete(data: any) { + return http.request({ + url: '/sms/template/batchDelete', + method: 'DELETE', + data, + }); +} diff --git a/ui/src/api/logger/emailLog.ts b/ui/src/api/logger/emailLog.ts new file mode 100644 index 0000000..b8a15e9 --- /dev/null +++ b/ui/src/api/logger/emailLog.ts @@ -0,0 +1,43 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: 邮件日志列表 + */ +export function getEmailLogList(params?) { + return http.request({ + url: '/email/log/page', + method: 'GET', + params, + }); +} + +/** + * @description: 根据ID获取详情 + */ +export function getEmailLogDetail(id) { + return http.request({ + url: '/email/log/detail/' + id, + method: 'get', + }); +} + +/** + * @description: 删除邮件日志 + */ +export function emailLogDelete(id) { + return http.request({ + url: '/email/log/delete/' + id, + method: 'DELETE', + }); +} + +/** + * @description: 批量删除邮件日志 + */ +export function emailLogBatchDelete(data: any) { + return http.request({ + url: '/email/log/batchDelete', + method: 'DELETE', + data, + }); +} diff --git a/ui/src/api/logger/fileLog.ts b/ui/src/api/logger/fileLog.ts new file mode 100644 index 0000000..a5b9f4b --- /dev/null +++ b/ui/src/api/logger/fileLog.ts @@ -0,0 +1,43 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: 文件日志列表 + */ +export function getFileLogList(params?) { + return http.request({ + url: '/file/log/page', + method: 'GET', + params, + }); +} + +/** + * @description: 根据ID获取详情 + */ +export function getFileLogDetail(id) { + return http.request({ + url: '/file/log/detail/' + id, + method: 'get', + }); +} + +/** + * @description: 删除文件日志 + */ +export function fileLogDelete(id) { + return http.request({ + url: '/file/log/delete/' + id, + method: 'DELETE', + }); +} + +/** + * @description: 批量删除文件日志 + */ +export function fileLogBatchDelete(data: any) { + return http.request({ + url: '/file/log/batchDelete', + method: 'DELETE', + data, + }); +} diff --git a/ui/src/api/logger/smsLog.ts b/ui/src/api/logger/smsLog.ts new file mode 100644 index 0000000..683e755 --- /dev/null +++ b/ui/src/api/logger/smsLog.ts @@ -0,0 +1,43 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: 短信日志列表 + */ +export function getSmsLogList(params?) { + return http.request({ + url: '/sms/log/page', + method: 'GET', + params, + }); +} + +/** + * @description: 根据ID获取详情 + */ +export function getSmsLogDetail(id) { + return http.request({ + url: '/sms/log/detail/' + id, + method: 'get', + }); +} + +/** + * @description: 删除登录日志 + */ +export function smsLogDelete(id) { + return http.request({ + url: '/sms/log/delete/' + id, + method: 'DELETE', + }); +} + +/** + * @description: 批量删除登录日志 + */ +export function smsLogBatchDelete(data: any) { + return http.request({ + url: '/sms/log/batchDelete', + method: 'DELETE', + data, + }); +} diff --git a/ui/src/api/monitor/job.ts b/ui/src/api/monitor/job.ts new file mode 100644 index 0000000..e602e03 --- /dev/null +++ b/ui/src/api/monitor/job.ts @@ -0,0 +1,161 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: 定时任务列表 + */ +export function getJobList(params?) { + return http.request({ + url: '/job/page', + method: 'GET', + params, + }); +} + +/** + * 获取全部定时任务列表 + * @param params 参数 + * @returns 返回结果 + */ +export function getJobAllList(params?) { + return http.request({ + url: '/job/list', + method: 'GET', + params, + }); +} + +/** + * @description: 根据ID获取详情 + */ +export function getJobDetail(id) { + return http.request({ + url: '/job/detail/' + id, + method: 'get', + }); +} + +/** + * @description: 添加定时任务 + */ +export function jobAdd(data: any) { + return http.request({ + url: '/job/add', + method: 'POST', + data, + }); +} + +/** + * @description: 更新定时任务 + */ +export function jobUpdate(data: any) { + return http.request({ + url: '/job/update', + method: 'PUT', + data, + }); +} + +/** + * @description: 删除定时任务 + */ +export function jobDelete(id) { + return http.request({ + url: '/job/delete/' + id, + method: 'DELETE', + }); +} + +/** + * @description: 批量删除定时任务 + */ +export function jobBatchDelete(data: any) { + return http.request({ + url: '/job/batchDelete', + method: 'DELETE', + data, + }); +} + +/** + * @description: 执行一次 + */ +export function getJobRunOnce(id) { + return http.request({ + url: '/job/runOnce/' + id, + method: 'post', + }); +} + +/** + * @description: 设置状态 + */ +export function setJobStatus(data: any) { + return http.request({ + url: '/job/status', + method: 'post', + data, + }); +} + +/** + * @description: 暂停任务 + */ +export function getJobPause(id) { + return http.request({ + url: '/job/pause/' + id, + method: 'put', + }); +} + +/** + * @description: 恢复任务 + */ +export function getJobResume(id) { + return http.request({ + url: '/job/resume/' + id, + method: 'put', + }); +} + +/** + * @description: 定时任务日志列表 + */ +export function getJobLogList(params?) { + return http.request({ + url: '/job/log/page', + method: 'GET', + params, + }); +} + +/** + * @description: 根据ID获取详情 + */ +export function getJobLogDetail(id) { + return http.request({ + url: '/job/log/detail/' + id, + method: 'get', + }); +} + +/** + * @description: 删除定时任务日志 + */ +export function jobLogDelete(id) { + return http.request({ + url: '/job/log/delete/' + id, + method: 'DELETE', + }); +} + +/** + * @description: 批量删除定时任务日志 + */ +export function jobLogBatchDelete(data: any) { + return http.request({ + url: '/job/log/batchDelete', + method: 'DELETE', + data, + }); +} diff --git a/ui/src/api/oauth2/authorization.ts b/ui/src/api/oauth2/authorization.ts new file mode 100644 index 0000000..bc2af2c --- /dev/null +++ b/ui/src/api/oauth2/authorization.ts @@ -0,0 +1,78 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: OAuth2 授权记录,存储授权流程中的各种令牌和状态信息,支持授权码流程、客户端凭证流程、密码流程、设备流程等多种授权方式列表 + */ +export function getOauth2AuthorizationList(params?) { + return http.request({ + url: '/auth/oauth2/authorization/page', + method: 'GET', + params, + }); +} + +/** + * 获取全部OAuth2 授权记录,存储授权流程中的各种令牌和状态信息,支持授权码流程、客户端凭证流程、密码流程、设备流程等多种授权方式列表 + * @param params 参数 + * @returns 返回结果 + */ +export function getOauth2AuthorizationAllList(params?) { + return http.request({ + url: '/auth/oauth2/authorization/list', + method: 'GET', + params, + }); +} + +/** + * @description: 根据ID获取详情 + */ +export function getOauth2AuthorizationDetail(oauth2authorizationId) { + return http.request({ + url: '/auth/oauth2/authorization/detail/' + oauth2authorizationId, + method: 'get', + }); +} + +/** + * @description: 添加OAuth2 授权记录,存储授权流程中的各种令牌和状态信息,支持授权码流程、客户端凭证流程、密码流程、设备流程等多种授权方式 + */ +export function oauth2AuthorizationAdd(data: any) { + return http.request({ + url: '/auth/oauth2/authorization/add', + method: 'POST', + data, + }); +} + +/** + * @description: 更新OAuth2 授权记录,存储授权流程中的各种令牌和状态信息,支持授权码流程、客户端凭证流程、密码流程、设备流程等多种授权方式 + */ +export function oauth2AuthorizationUpdate(data: any) { + return http.request({ + url: '/auth/oauth2/authorization/update', + method: 'PUT', + data, + }); +} + +/** + * @description: 删除OAuth2 授权记录,存储授权流程中的各种令牌和状态信息,支持授权码流程、客户端凭证流程、密码流程、设备流程等多种授权方式 + */ +export function oauth2AuthorizationRevoke(oauth2authorizationId) { + return http.request({ + url: '/auth/oauth2/authorization/revoke/' + oauth2authorizationId, + method: 'DELETE', + }); +} + +/** + * @description: 批量删除OAuth2 授权记录,存储授权流程中的各种令牌和状态信息,支持授权码流程、客户端凭证流程、密码流程、设备流程等多种授权方式 + */ +export function oauth2AuthorizationBatchRevoke(data: any) { + return http.request({ + url: '/auth/oauth2/authorization/batchRevoke', + method: 'DELETE', + data, + }); +} diff --git a/ui/src/api/oauth2/authorizationconsent.ts b/ui/src/api/oauth2/authorizationconsent.ts new file mode 100644 index 0000000..b302cf4 --- /dev/null +++ b/ui/src/api/oauth2/authorizationconsent.ts @@ -0,0 +1,78 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: OAuth2 授权同意记录,存储用户对客户端的长期授权同意信息,用于实现"记住同意"功能列表 + */ +export function getOauth2AuthorizationConsentList(params?) { + return http.request({ + url: '/auth/oauth2/authorization/consent/page', + method: 'GET', + params, + }); +} + +/** + * 获取全部OAuth2 授权同意记录,存储用户对客户端的长期授权同意信息,用于实现"记住同意"功能列表 + * @param params 参数 + * @returns 返回结果 + */ +export function getOauth2AuthorizationConsentAllList(params?) { + return http.request({ + url: '/auth/oauth2/authorization/consent/list', + method: 'GET', + params, + }); +} + +/** + * @description: 根据ID获取详情 + */ +export function getOauth2AuthorizationConsentDetail(oauth2authorizationconsentId) { + return http.request({ + url: '/auth/oauth2/authorization/consent/detail/' + oauth2authorizationconsentId, + method: 'get', + }); +} + +/** + * @description: 添加OAuth2 授权同意记录,存储用户对客户端的长期授权同意信息,用于实现"记住同意"功能 + */ +export function oauth2authorizationconsentAdd(data: any) { + return http.request({ + url: '/auth/oauth2/authorization/consent/add', + method: 'POST', + data, + }); +} + +/** + * @description: 更新OAuth2 授权同意记录,存储用户对客户端的长期授权同意信息,用于实现"记住同意"功能 + */ +export function oauth2authorizationconsentUpdate(data: any) { + return http.request({ + url: '/auth/oauth2/authorization/consent/update', + method: 'PUT', + data, + }); +} + +/** + * @description: 删除OAuth2 授权同意记录,存储用户对客户端的长期授权同意信息,用于实现"记住同意"功能 + */ +export function oauth2authorizationconsentDelete(oauth2authorizationconsentId) { + return http.request({ + url: '/auth/oauth2/authorization/consent/delete/' + oauth2authorizationconsentId, + method: 'DELETE', + }); +} + +/** + * @description: 批量删除OAuth2 授权同意记录,存储用户对客户端的长期授权同意信息,用于实现"记住同意"功能 + */ +export function oauth2authorizationconsentBatchDelete(data: any) { + return http.request({ + url: '/auth/oauth2/authorization/consent/batchDelete', + method: 'DELETE', + data, + }); +} diff --git a/ui/src/api/oauth2/registeredclient.ts b/ui/src/api/oauth2/registeredclient.ts new file mode 100644 index 0000000..feaf1b0 --- /dev/null +++ b/ui/src/api/oauth2/registeredclient.ts @@ -0,0 +1,88 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: OAuth2 已注册客户端,存储OAuth2和OpenID Connect的客户端配置信息列表 + */ +export function getOauth2RegisteredClientList(params?) { + return http.request({ + url: '/auth/oauth2/registered/client/page', + method: 'GET', + params, + }); +} + +/** + * 获取全部OAuth2 已注册客户端,存储OAuth2和OpenID Connect的客户端配置信息列表 + * @param params 参数 + * @returns 返回结果 + */ +export function getOauth2RegisteredClientAllList(params?) { + return http.request({ + url: '/auth/oauth2/registered/client/list', + method: 'GET', + params, + }); +} + +/** + * @description: 根据ID获取详情 + */ +export function getOauth2RegisteredClientDetail(oauth2registeredclientId) { + return http.request({ + url: '/auth/oauth2/registered/client/detail/' + oauth2registeredclientId, + method: 'get', + }); +} + +/** + * @description: 添加OAuth2 已注册客户端,存储OAuth2和OpenID Connect的客户端配置信息 + */ +export function oauth2RegisteredClientAdd(data: any) { + return http.request({ + url: '/auth/oauth2/registered/client/add', + method: 'POST', + data, + }); +} + +/** + * @description: 更新OAuth2 已注册客户端,存储OAuth2和OpenID Connect的客户端配置信息 + */ +export function oauth2RegisteredClientUpdate(data: any) { + return http.request({ + url: '/auth/oauth2/registered/client/update', + method: 'PUT', + data, + }); +} + +/** + * @description: 删除OAuth2 已注册客户端,存储OAuth2和OpenID Connect的客户端配置信息 + */ +export function oauth2RegisteredClientDelete(oauth2registeredclientId) { + return http.request({ + url: '/auth/oauth2/registered/client/delete/' + oauth2registeredclientId, + method: 'DELETE', + }); +} + +/** + * @description: 批量删除OAuth2 已注册客户端,存储OAuth2和OpenID Connect的客户端配置信息 + */ +export function oauth2RegisteredClientBatchDelete(data: any) { + return http.request({ + url: '/auth/oauth2/registered/client/batchDelete', + method: 'DELETE', + data, + }); +} + +/** + * @description: 刷新缓存 + */ +export function refreshCache() { + return http.request({ + url: '/auth/oauth2/registered/client/refreshCache', + method: 'get', + }); +} diff --git a/ui/src/api/setting/profile.ts b/ui/src/api/setting/profile.ts new file mode 100644 index 0000000..90a9082 --- /dev/null +++ b/ui/src/api/setting/profile.ts @@ -0,0 +1,12 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: 更新用户信息 + */ +export function updateProfile(data: any) { + return http.request({ + url: '/index/updateProfile', + method: 'PUT', + data, + }); +} diff --git a/ui/src/api/setting/web.ts b/ui/src/api/setting/web.ts new file mode 100644 index 0000000..9e3f7b5 --- /dev/null +++ b/ui/src/api/setting/web.ts @@ -0,0 +1,20 @@ +import { http } from '@/utils/http/axios'; + +export function getWebInfo(params?) { + return http.request({ + url: '/config/web/index', + method: 'GET', + params, + }); +} + +/** + * @description: 更新 + */ +export function updateWeb(data: any) { + return http.request({ + url: '/config/web/save', + method: 'POST', + data, + }); +} diff --git a/ui/src/api/system/dept.ts b/ui/src/api/system/dept.ts new file mode 100644 index 0000000..1d99985 --- /dev/null +++ b/ui/src/api/system/dept.ts @@ -0,0 +1,54 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: 部门列表 + */ +export function getDeptList(params?) { + return http.request({ + url: '/dept/list', + method: 'GET', + params, + }); +} + +/** + * @description: 根据ID获取详情 + */ +export function getDeptDetail(deptId) { + return http.request({ + url: '/dept/detail/' + deptId, + method: 'get', + }); +} + +/** + * @description: 添加部门 + */ +export function deptAdd(data: any) { + return http.request({ + url: '/dept/add', + method: 'POST', + data, + }); +} + +/** + * @description: 更新部门 + */ +export function deptUpdate(data: any) { + return http.request({ + url: '/dept/update', + method: 'PUT', + data, + }); +} + +/** + * @description: 删除部门 + */ +export function deptDelete(deptId) { + return http.request({ + url: '/dept/delete/' + deptId, + method: 'DELETE', + }); +} diff --git a/ui/src/api/system/level.ts b/ui/src/api/system/level.ts new file mode 100644 index 0000000..70dcf8f --- /dev/null +++ b/ui/src/api/system/level.ts @@ -0,0 +1,88 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: 职级列表 + */ +export function getLevelList(params?) { + return http.request({ + url: '/level/page', + method: 'GET', + params, + }); +} + +/** + * 获取全部职级列表 + * @param params 参数 + * @returns 返回结果 + */ +export function getLevelAllList(params?) { + return http.request({ + url: '/level/list', + method: 'GET', + params, + }); +} + +/** + * @description: 根据ID获取详情 + */ +export function getLevelDetail(levelId) { + return http.request({ + url: '/level/detail/' + levelId, + method: 'get', + }); +} + +/** + * @description: 添加职级 + */ +export function levelAdd(data: any) { + return http.request({ + url: '/level/add', + method: 'POST', + data, + }); +} + +/** + * @description: 更新职级 + */ +export function levelUpdate(data: any) { + return http.request({ + url: '/level/update', + method: 'PUT', + data, + }); +} + +/** + * @description: 删除职级 + */ +export function levelDelete(levelId) { + return http.request({ + url: '/level/delete/' + levelId, + method: 'DELETE', + }); +} + +/** + * @description: 批量删除职级 + */ +export function levelBatchDelete(data: any) { + return http.request({ + url: '/level/batchDelete', + method: 'DELETE', + data, + }); +} + +/** + * @description: 导出职级 + */ +export function levelExport() { + return http.request({ + url: '/level/export', + method: 'GET', + }); +} diff --git a/ui/src/api/system/loginLog.ts b/ui/src/api/system/loginLog.ts new file mode 100644 index 0000000..510530e --- /dev/null +++ b/ui/src/api/system/loginLog.ts @@ -0,0 +1,43 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: 登录日志列表 + */ +export function getLoginLogList(params?) { + return http.request({ + url: '/login/log/page', + method: 'GET', + params, + }); +} + +/** + * @description: 根据ID获取详情 + */ +export function getLoginLogDetail(id) { + return http.request({ + url: '/login/log/detail/' + id, + method: 'get', + }); +} + +/** + * @description: 删除登录日志 + */ +export function loginLogDelete(id) { + return http.request({ + url: '/login/log/delete/' + id, + method: 'DELETE', + }); +} + +/** + * @description: 批量删除登录日志 + */ +export function loginLogBatchDelete(data: any) { + return http.request({ + url: '/login/log/batchDelete', + method: 'DELETE', + data, + }); +} diff --git a/ui/src/api/system/menu.ts b/ui/src/api/system/menu.ts new file mode 100644 index 0000000..55aaa72 --- /dev/null +++ b/ui/src/api/system/menu.ts @@ -0,0 +1,56 @@ +import { http } from '@/utils/http/axios'; + +/** + * 获取tree菜单列表 + * @param params + */ +export function getMenuList(params?) { + return http.request({ + url: '/menu/list', + method: 'GET', + params, + }); +} + +/** + * 获取菜单详情 + * @param params + */ +export function getMenuDetail(id) { + return http.request({ + url: '/menu/detail/' + id, + method: 'GET', + }); +} + +/** + * @description: 添加菜单 + */ +export function menuAdd(data: any) { + return http.request({ + url: '/menu/add', + method: 'POST', + data, + }); +} + +/** + * @description: 更新菜单 + */ +export function menuUpdate(data: any) { + return http.request({ + url: '/menu/update', + method: 'PUT', + data, + }); +} + +/** + * @description: 删除菜单 + */ +export function menuDelete(id) { + return http.request({ + url: '/menu/delete/' + id, + method: 'DELETE', + }); +} diff --git a/ui/src/api/system/operLog.ts b/ui/src/api/system/operLog.ts new file mode 100644 index 0000000..d8312ef --- /dev/null +++ b/ui/src/api/system/operLog.ts @@ -0,0 +1,43 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: 操作日志列表 + */ +export function getOperLogList(params?) { + return http.request({ + url: '/oper/log/page', + method: 'GET', + params, + }); +} + +/** + * @description: 根据ID获取详情 + */ +export function getOperLogDetail(id) { + return http.request({ + url: '/oper/log/detail/' + id, + method: 'get', + }); +} + +/** + * @description: 删除操作日志 + */ +export function operLogDelete(id) { + return http.request({ + url: '/oper/log/delete/' + id, + method: 'DELETE', + }); +} + +/** + * @description: 批量删除操作日志 + */ +export function operLogBatchDelete(data: any) { + return http.request({ + url: '/oper/log/batchDelete', + method: 'DELETE', + data, + }); +} diff --git a/ui/src/api/system/position.ts b/ui/src/api/system/position.ts new file mode 100644 index 0000000..ad44ce2 --- /dev/null +++ b/ui/src/api/system/position.ts @@ -0,0 +1,78 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: 岗位列表 + */ +export function getPositionList(params?) { + return http.request({ + url: '/position/page', + method: 'GET', + params, + }); +} + +/** + * 获取全部岗位列表 + * @param params 参数 + * @returns 返回结果 + */ +export function getPositionAllList(params?) { + return http.request({ + url: '/position/list', + method: 'GET', + params, + }); +} + +/** + * @description: 根据ID获取详情 + */ +export function getPositionDetail(positionId) { + return http.request({ + url: '/position/detail/' + positionId, + method: 'get', + }); +} + +/** + * @description: 添加岗位 + */ +export function positionAdd(data: any) { + return http.request({ + url: '/position/add', + method: 'POST', + data, + }); +} + +/** + * @description: 更新岗位 + */ +export function positionUpdate(data: any) { + return http.request({ + url: '/position/update', + method: 'PUT', + data, + }); +} + +/** + * @description: 删除岗位 + */ +export function positionDelete(positionId) { + return http.request({ + url: '/position/delete/' + positionId, + method: 'DELETE', + }); +} + +/** + * @description: 批量删除岗位 + */ +export function positionBatchDelete(data: any) { + return http.request({ + url: '/position/batchDelete', + method: 'DELETE', + data, + }); +} diff --git a/ui/src/api/system/role.ts b/ui/src/api/system/role.ts new file mode 100644 index 0000000..cb490af --- /dev/null +++ b/ui/src/api/system/role.ts @@ -0,0 +1,99 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: 角色列表 + */ +export function getRoleList(params?) { + return http.request({ + url: '/role/page', + method: 'GET', + params, + }); +} + +/** + * 获取全部角色列表 + * @param params 参数 + * @returns 返回结果 + */ +export function getRoleAllList(params?) { + return http.request({ + url: '/role/list', + method: 'GET', + params, + }); +} + +/** + * @description: 根据ID获取详情 + */ +export function getRoleDetail(roleId) { + return http.request({ + url: '/role/detail/' + roleId, + method: 'get', + }); +} + +/** + * @description: 添加角色 + */ +export function roleAdd(data: any) { + return http.request({ + url: '/role/add', + method: 'POST', + data, + }); +} + +/** + * @description: 更新角色 + */ +export function roleUpdate(data: any) { + return http.request({ + url: '/role/update', + method: 'PUT', + data, + }); +} + +/** + * @description: 删除角色 + */ +export function roleDelete(roleId) { + return http.request({ + url: '/role/delete/' + roleId, + method: 'DELETE', + }); +} + +/** + * @description: 批量删除角色 + */ +export function roleBatchDelete(data) { + return http.request({ + url: '/role/batchDelete', + method: 'DELETE', + data, + }); +} + +/** + * @description: 获取角色菜单 + */ +export function getRoleMenuList(roleId) { + return http.request({ + url: '/role/menu/list/' + roleId, + method: 'get', + }); +} + +/** + * @description: 保存角色菜单 + */ +export function roleMenuSave(data: any) { + return http.request({ + url: '/role/menu/save', + method: 'POST', + data, + }); +} diff --git a/ui/src/api/system/tenant.ts b/ui/src/api/system/tenant.ts new file mode 100644 index 0000000..d3af278 --- /dev/null +++ b/ui/src/api/system/tenant.ts @@ -0,0 +1,76 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: 获取租户列表 + */ +export function getTenantList(params) { + return http.request({ + url: '/tenant/page', + method: 'get', + params, + }); +} + +/** + * @description: 根据ID获取详情 + */ +export function getTenantDetail(tenantId) { + return http.request({ + url: '/tenant/detail/' + tenantId, + method: 'get', + }); +} + +/** + * @description: 添加租户 + */ +export function tenantAdd(data: any) { + return http.request({ + url: '/tenant/add', + method: 'POST', + data, + }); +} + +/** + * @description: 更新租户 + */ +export function tenantUpdate(data: any) { + return http.request({ + url: '/tenant/update', + method: 'PUT', + data, + }); +} + +/** + * @description: 删除租户 + */ +export function tenantDelete(tenantId) { + return http.request({ + url: '/tenant/delete/' + tenantId, + method: 'DELETE', + }); +} + +/** + * @description: 批量删除租户 + */ +export function tenantBatchDelete(data: any) { + return http.request({ + url: '/tenant/batchDelete', + method: 'DELETE', + data, + }); +} + +/** + * @description: 创建租户账号 + */ +export function tenantAccount(data: any) { + return http.request({ + url: '/tenant/account', + method: 'POST', + data, + }); +} diff --git a/ui/src/api/system/user.ts b/ui/src/api/system/user.ts new file mode 100644 index 0000000..c278085 --- /dev/null +++ b/ui/src/api/system/user.ts @@ -0,0 +1,126 @@ +import { http } from '@/utils/http/axios'; + +export interface BasicResponseModel { + code: number; + msg: string; + data: T; +} + +export interface BasicPageParams { + pageNo: number; + pageSize: number; + total: number; +} + +/** + * @description: 获取用户列表 + */ +export function getUserList(params) { + return http.request({ + url: '/user/page', + method: 'get', + params, + }); +} + +/** + * @description: 根据ID获取详情 + */ +export function getUserDetail(userId) { + return http.request({ + url: '/user/detail/' + userId, + method: 'get', + }); +} + +/** + * @description: 添加用户 + */ +export function userAdd(data: any) { + return http.request({ + url: '/user/add', + method: 'POST', + data, + }); +} + +/** + * @description: 更新用户 + */ +export function userUpdate(data: any) { + return http.request({ + url: '/user/update', + method: 'PUT', + data, + }); +} + +/** + * @description: 重置密码 + */ +export function resetPwd(data: any) { + return http.request({ + url: '/user/resetPwd', + method: 'PUT', + data, + }); +} + +/** + * @description: 删除用户 + */ +export function userDelete(userId) { + return http.request({ + url: '/user/delete/' + userId, + method: 'DELETE', + }); +} + +/** + * @description: 批量删除用户 + */ +export function userBatchDelete(data: any) { + return http.request({ + url: '/user/batchDelete', + method: 'DELETE', + data, + }); +} + +/** + * @description: 导入用户 + */ +export function userImport(data) { + return http.request({ + url: '/user/import', + method: 'POST', + data, + }); +} + +/** + * @description: 导出用户 + */ +export function userExport(params?) { + return http.request( + { + url: '/user/export', + method: 'GET', + params, + responseType: 'blob', + }, + { + isReturnNativeResponse: true, + }, + ); +} + +/** + * @description: 获取打印地址 + */ +export function getUserDocument(userId: any) { + return http.request({ + url: '/user/document/' + userId, + method: 'GET', + }); +} diff --git a/ui/src/api/tool/category.ts b/ui/src/api/tool/category.ts new file mode 100644 index 0000000..23b4eb2 --- /dev/null +++ b/ui/src/api/tool/category.ts @@ -0,0 +1,83 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: 文章分类分页列表 + */ +export function getCategoryPage(params?) { + return http.request({ + url: '/category/page', + method: 'GET', + params, + }); +} + +/** + * 获取全部文章分类列表 + * @param params 参数 + * @returns 返回结果 + */ +export function getCategoryList(params?) { + return http.request({ + url: '/category/list', + method: 'GET', + params, + }); +} + +/** + * @description: 根据ID获取文章分类详情 + * @param id 文章分类ID + */ +export function getCategoryDetail(id) { + return http.request({ + url: '/category/detail/' + id, + method: 'GET', + }); +} + +/** + * @description: 添加文章分类 + * @param data 文章分类数据 + */ +export function categoryAdd(data: any) { + return http.request({ + url: '/category/add', + method: 'POST', + data, + }); +} + +/** + * @description: 更新文章分类 + * @param data 文章分类数据 + */ +export function categoryUpdate(data: any) { + return http.request({ + url: '/category/update', + method: 'PUT', + data, + }); +} + +/** + * @description: 删除文章分类 + * @param id 文章分类ID + */ +export function categoryDelete(id) { + return http.request({ + url: '/category/delete/' + id, + method: 'DELETE', + }); +} + +/** + * @description: 批量删除文章分类 + * @param data 文章分类ID数组 + */ +export function categoryBatchDelete(data: any) { + return http.request({ + url: '/category/batchDelete', + method: 'DELETE', + data, + }); +} diff --git a/ui/src/api/tool/example.ts b/ui/src/api/tool/example.ts new file mode 100644 index 0000000..8cb0a73 --- /dev/null +++ b/ui/src/api/tool/example.ts @@ -0,0 +1,83 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: 案例分页列表 + */ +export function getExamplePage(params?) { + return http.request({ + url: '/example/page', + method: 'GET', + params, + }); +} + +/** + * 获取全部案例列表 + * @param params 参数 + * @returns 返回结果 + */ +export function getExampleList(params?) { + return http.request({ + url: '/example/list', + method: 'GET', + params, + }); +} + +/** + * @description: 根据ID获取案例详情 + * @param id 案例ID + */ +export function getExampleDetail(id) { + return http.request({ + url: '/example/detail/' + id, + method: 'GET', + }); +} + +/** + * @description: 添加案例 + * @param data 案例数据 + */ +export function exampleAdd(data: any) { + return http.request({ + url: '/example/add', + method: 'POST', + data, + }); +} + +/** + * @description: 更新案例 + * @param data 案例数据 + */ +export function exampleUpdate(data: any) { + return http.request({ + url: '/example/update', + method: 'PUT', + data, + }); +} + +/** + * @description: 删除案例 + * @param id 案例ID + */ +export function exampleDelete(id) { + return http.request({ + url: '/example/delete/' + id, + method: 'DELETE', + }); +} + +/** + * @description: 批量删除案例 + * @param data 案例ID数组 + */ +export function exampleBatchDelete(data: any) { + return http.request({ + url: '/example/batchDelete', + method: 'DELETE', + data, + }); +} diff --git a/ui/src/api/tool/generator.ts b/ui/src/api/tool/generator.ts new file mode 100644 index 0000000..aa693bb --- /dev/null +++ b/ui/src/api/tool/generator.ts @@ -0,0 +1,34 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: 列表 + */ +export function getTableList(params?) { + return http.request({ + url: '/generator/page', + method: 'GET', + params, + }); +} + +/** + * @description: 一键生成 + */ +export function generator(data: any) { + return http.request({ + url: '/generator/generate', + method: 'POST', + data, + }); +} + +/** + * @description: 批量删除 + */ +export function batchGenerator(data: any) { + return http.request({ + url: '/generator/batchGenerate', + method: 'POST', + data, + }); +} diff --git a/ui/src/api/tool/orderitem.ts b/ui/src/api/tool/orderitem.ts new file mode 100644 index 0000000..1a9f2d3 --- /dev/null +++ b/ui/src/api/tool/orderitem.ts @@ -0,0 +1,83 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: 订单项分页列表 + */ +export function getOrderItemPage(params?) { + return http.request({ + url: '/order_item/page', + method: 'GET', + params, + }); +} + +/** + * 获取全部订单项列表 + * @param params 参数 + * @returns 返回结果 + */ +export function getOrderItemList(params?) { + return http.request({ + url: '/order_item/list', + method: 'GET', + params, + }); +} + +/** + * @description: 根据ID获取订单项详情 + * @param id 订单项ID + */ +export function getOrderItemDetail(id) { + return http.request({ + url: '/order_item/detail/' + id, + method: 'GET', + }); +} + +/** + * @description: 添加订单项 + * @param data 订单项数据 + */ +export function orderItemAdd(data: any) { + return http.request({ + url: '/order_item/add', + method: 'POST', + data, + }); +} + +/** + * @description: 更新订单项 + * @param data 订单项数据 + */ +export function orderItemUpdate(data: any) { + return http.request({ + url: '/order_item/update', + method: 'PUT', + data, + }); +} + +/** + * @description: 删除订单项 + * @param id 订单项ID + */ +export function orderItemDelete(id) { + return http.request({ + url: '/order_item/delete/' + id, + method: 'DELETE', + }); +} + +/** + * @description: 批量删除订单项 + * @param data 订单项ID数组 + */ +export function orderItemBatchDelete(data: any) { + return http.request({ + url: '/order_item/batchDelete', + method: 'DELETE', + data, + }); +} diff --git a/ui/src/assets/icons/login.svg b/ui/src/assets/icons/login.svg new file mode 100644 index 0000000..2490aa7 --- /dev/null +++ b/ui/src/assets/icons/login.svg @@ -0,0 +1,127 @@ + + + Group 21 + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +