├── .github └── workflows │ ├── docker-publish.yml │ └── release.yml ├── .gitignore ├── README.md ├── backend ├── README.md ├── alembic.ini ├── bonita │ ├── __init__.py │ ├── alembic │ │ ├── README │ │ ├── env.py │ │ ├── script.py.mako │ │ └── versions │ │ │ ├── .gitkeep │ │ │ ├── 202503071003_f6497e8754c9_srcdeleted.py │ │ │ ├── 202503132240_b0305c88a31f_watch_history.py │ │ │ ├── 202503152249_eca431b007aa_update.py │ │ │ ├── 202503152315_07c8c5cb55d1_watched.py │ │ │ └── 202503171030_062f620c65b4_update.py │ ├── api │ │ ├── __init__.py │ │ ├── deps.py │ │ ├── main.py │ │ ├── routes │ │ │ ├── file_browser.py │ │ │ ├── login.py │ │ │ ├── mediaitem.py │ │ │ ├── metadata.py │ │ │ ├── records.py │ │ │ ├── resource.py │ │ │ ├── scraping_config.py │ │ │ ├── settings.py │ │ │ ├── status.py │ │ │ ├── task_config.py │ │ │ ├── tasks.py │ │ │ ├── tools.py │ │ │ └── users.py │ │ └── websockets │ │ │ └── logs.py │ ├── celery_tasks │ │ ├── __init__.py │ │ └── tasks.py │ ├── core │ │ ├── config.py │ │ ├── db.py │ │ ├── security.py │ │ └── service.py │ ├── db │ │ ├── __init__.py │ │ └── models │ │ │ ├── __init__.py │ │ │ ├── downloads.py │ │ │ ├── extrainfo.py │ │ │ ├── mediaitem.py │ │ │ ├── metadata.py │ │ │ ├── record.py │ │ │ ├── scraping.py │ │ │ ├── setting.py │ │ │ ├── task.py │ │ │ ├── user.py │ │ │ └── watch_history.py │ ├── main.py │ ├── modules │ │ ├── download_clients │ │ │ ├── base_client.py │ │ │ └── transmission.py │ │ ├── media_service │ │ │ ├── emby.py │ │ │ ├── jellyfin.py │ │ │ ├── sync.py │ │ │ └── trakt.py │ │ ├── monitor │ │ │ ├── handler.py │ │ │ └── monitor.py │ │ ├── scraping │ │ │ ├── number_parser.py │ │ │ ├── scraping.py │ │ │ └── watermark │ │ │ │ ├── CNSUB.png │ │ │ │ ├── HACK.png │ │ │ │ ├── LEAK.png │ │ │ │ └── UNCENSORED.png │ │ └── transfer │ │ │ └── transfer.py │ ├── schemas │ │ ├── __init__.py │ │ ├── extrainfo.py │ │ ├── file_browser.py │ │ ├── mediaitem.py │ │ ├── metadata.py │ │ ├── record.py │ │ ├── response.py │ │ ├── scraping.py │ │ ├── system.py │ │ ├── task.py │ │ ├── token.py │ │ └── user.py │ ├── services │ │ ├── record_service.py │ │ ├── setting_service.py │ │ └── tool_service.py │ ├── utils │ │ ├── downloader.py │ │ ├── filehelper.py │ │ ├── fileinfo.py │ │ ├── http.py │ │ ├── regex.py │ │ └── singleton.py │ └── worker.py ├── data │ └── .gitkeep └── requirements.txt ├── docker ├── Dockerfile ├── nginx.conf └── s6-rc.d │ ├── bonita │ ├── dependencies.d │ │ └── init-adduser │ ├── run │ └── type │ ├── init-adduser │ ├── run │ ├── type │ └── up │ └── user │ └── contents.d │ └── bonita └── frontend ├── .gitignore ├── README.md ├── biome.json ├── index.html ├── modify-openapi-operationids.js ├── openapi-ts.config.ts ├── package.json ├── public └── favicon.ico ├── src ├── @core │ ├── components │ │ ├── MoreBtn.vue │ │ ├── ThemeSwitcher.vue │ │ └── cards │ │ │ ├── CardStatisticsHorizontal.vue │ │ │ ├── CardStatisticsVertical.vue │ │ │ └── CardStatisticsWithImages.vue │ ├── scss │ │ ├── base │ │ │ ├── _components.scss │ │ │ ├── _dark.scss │ │ │ ├── _default-layout-w-vertical-nav.scss │ │ │ ├── _default-layout.scss │ │ │ ├── _index.scss │ │ │ ├── _layouts.scss │ │ │ ├── _misc.scss │ │ │ ├── _mixins.scss │ │ │ ├── _utilities.scss │ │ │ ├── _utils.scss │ │ │ ├── _variables.scss │ │ │ ├── _vertical-nav.scss │ │ │ ├── libs │ │ │ │ ├── _perfect-scrollbar.scss │ │ │ │ └── vuetify │ │ │ │ │ ├── _index.scss │ │ │ │ │ ├── _overrides.scss │ │ │ │ │ └── _variables.scss │ │ │ └── placeholders │ │ │ │ ├── _default-layout-vertical-nav.scss │ │ │ │ ├── _default-layout.scss │ │ │ │ ├── _index.scss │ │ │ │ ├── _misc.scss │ │ │ │ ├── _nav.scss │ │ │ │ └── _vertical-nav.scss │ │ └── template │ │ │ ├── _components.scss │ │ │ ├── _dark.scss │ │ │ ├── _default-layout-w-vertical-nav.scss │ │ │ ├── _mixins.scss │ │ │ ├── _utilities.scss │ │ │ ├── _utils.scss │ │ │ ├── _variables.scss │ │ │ ├── _vertical-nav.scss │ │ │ ├── index.scss │ │ │ ├── libs │ │ │ ├── apex-chart.scss │ │ │ └── vuetify │ │ │ │ ├── _variables.scss │ │ │ │ ├── components │ │ │ │ ├── _alert.scss │ │ │ │ ├── _avatar.scss │ │ │ │ ├── _badge.scss │ │ │ │ ├── _button.scss │ │ │ │ ├── _cards.scss │ │ │ │ ├── _checkbox.scss │ │ │ │ ├── _chip.scss │ │ │ │ ├── _dialog.scss │ │ │ │ ├── _expansion-panels.scss │ │ │ │ ├── _field.scss │ │ │ │ ├── _list.scss │ │ │ │ ├── _menu.scss │ │ │ │ ├── _otp-input.scss │ │ │ │ ├── _pagination.scss │ │ │ │ ├── _progress.scss │ │ │ │ ├── _radio.scss │ │ │ │ ├── _rating.scss │ │ │ │ ├── _slider.scss │ │ │ │ ├── _snackbar.scss │ │ │ │ ├── _switch.scss │ │ │ │ ├── _table.scss │ │ │ │ ├── _tabs.scss │ │ │ │ ├── _textarea.scss │ │ │ │ ├── _timeline.scss │ │ │ │ ├── _tooltip.scss │ │ │ │ └── index.scss │ │ │ │ ├── index.scss │ │ │ │ └── overrides.scss │ │ │ ├── pages │ │ │ ├── misc.scss │ │ │ └── page-auth.scss │ │ │ └── placeholders │ │ │ ├── _default-layout-vertical-nav.scss │ │ │ ├── _index.scss │ │ │ ├── _misc.scss │ │ │ ├── _nav.scss │ │ │ └── _vertical-nav.scss │ └── utils │ │ ├── colorConverter.ts │ │ ├── formatters.ts │ │ ├── helpers.ts │ │ └── plugins.ts ├── @layouts │ ├── components │ │ ├── TransitionExpand.vue │ │ ├── VerticalNav.vue │ │ ├── VerticalNavGroup.vue │ │ ├── VerticalNavLayout.vue │ │ ├── VerticalNavLink.vue │ │ └── VerticalNavSectionTitle.vue │ ├── styles │ │ ├── _classes.scss │ │ ├── _default-layout.scss │ │ ├── _global.scss │ │ ├── _mixins.scss │ │ ├── _placeholders.scss │ │ ├── _rtl.scss │ │ ├── _variables.scss │ │ └── index.scss │ ├── types.ts │ └── utils.ts ├── App.vue ├── assets │ ├── images │ │ ├── avatar1.png │ │ ├── logo.png │ │ ├── logo.svg │ │ ├── pages │ │ │ └── 404.png │ │ └── svg │ │ │ ├── checkbox-checked.svg │ │ │ ├── checkbox-indeterminate.svg │ │ │ ├── checkbox-unchecked.svg │ │ │ ├── radio-checked.svg │ │ │ └── radio-unchecked.svg │ └── styles │ │ ├── styles.scss │ │ └── variables │ │ ├── _template.scss │ │ └── _vuetify.scss ├── client │ ├── core │ │ ├── ApiError.ts │ │ ├── ApiRequestOptions.ts │ │ ├── ApiResult.ts │ │ ├── CancelablePromise.ts │ │ ├── OpenAPI.ts │ │ └── request.ts │ ├── index.ts │ ├── schemas.gen.ts │ ├── services.gen.ts │ └── types.gen.ts ├── components │ ├── FileBrowserDialog.vue │ ├── GlobalToast.vue │ ├── README.md │ ├── common │ │ └── ConfirmationDialog.vue │ ├── mediaitem │ │ ├── MediaItemDetailDialog.vue │ │ └── MediaItemDetailForm.vue │ ├── metadata │ │ ├── MetadataDetailDialog.vue │ │ └── MetadataDetailForm.vue │ ├── record │ │ ├── RecordDetailDialog.vue │ │ └── RecordDetailForm.vue │ ├── scraping │ │ ├── ScrapingConfigDialog.vue │ │ └── ScrapingConfigForm.vue │ └── task │ │ ├── TransferConfigDetailDialog.vue │ │ └── TransferConfigDetailForm.vue ├── hook │ └── authCheck.ts ├── layouts │ ├── README.md │ ├── blank.vue │ ├── components │ │ ├── DefaultLayoutWithVerticalNav.vue │ │ ├── LanguageSwitcher.vue │ │ ├── NavItems.vue │ │ ├── NavbarThemeSwitcher.vue │ │ ├── UserProfile.vue │ │ └── VerticalNavLayout.vue │ └── default.vue ├── main.ts ├── pages │ ├── Dashboard.vue │ ├── Login.vue │ ├── Logs.vue │ ├── Mediaitem.vue │ ├── Metadata.vue │ ├── Records.vue │ ├── ScrapingConfigs.vue │ ├── ServiceSettings.vue │ ├── Task.vue │ ├── Tools.vue │ ├── UserSettings.vue │ └── [...error].vue ├── plugins │ ├── 06.pinia.ts │ ├── i18n │ │ ├── index.ts │ │ └── locales │ │ │ ├── en.ts │ │ │ └── zh.ts │ ├── iconify │ │ ├── build-icons.ts │ │ ├── index.ts │ │ └── package.json │ ├── router │ │ ├── index.ts │ │ └── routes.ts │ └── vuetify │ │ ├── defaults.ts │ │ ├── icons.ts │ │ ├── index.ts │ │ └── theme.ts ├── stores │ ├── app.store.ts │ ├── auth.store.ts │ ├── confirmation.store.ts │ ├── log.store.ts │ ├── mediaitem.store.ts │ ├── metadata.store.ts │ ├── record.store.ts │ ├── scraping.store.ts │ ├── setting.store.ts │ ├── task.store.ts │ ├── toast.store.ts │ └── tool.store.ts ├── utils.ts └── views │ └── settings │ └── AccountSettingsSecurity.vue ├── tsconfig.json └── vite.config.mts /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Push Docker 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | publish_tag: 7 | description: 'Publish Tag' 8 | required: false 9 | default: 'latest' 10 | 11 | jobs: 12 | docker: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v3 17 | 18 | - name: Set up QEMU 19 | uses: docker/setup-qemu-action@v2 20 | 21 | - name: Set up Docker Buildx 22 | uses: docker/setup-buildx-action@v2 23 | 24 | - name: Login to Docker Hub 25 | uses: docker/login-action@v2 26 | with: 27 | username: ${{ secrets.DOCKERHUB_USERNAME }} 28 | password: ${{ secrets.DOCKERHUB_TOKEN }} 29 | 30 | - name: Build and push 31 | uses: docker/build-push-action@v3 32 | with: 33 | context: . 34 | file: ./docker/Dockerfile 35 | platforms: linux/amd64,linux/arm64 36 | push: true 37 | tags: suwmlee/bonita:${{ github.event.inputs.publish_tag }} 38 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Version 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - 'backend/bonita/__init__.py' 9 | workflow_dispatch: 10 | 11 | permissions: 12 | contents: write 13 | 14 | jobs: 15 | release: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v3 20 | 21 | - name: Extract version 22 | id: extract_version 23 | run: | 24 | VERSION=$(grep -oP '__version__ = "\K[^"]+' backend/bonita/__init__.py) 25 | echo "version=$VERSION" >> $GITHUB_OUTPUT 26 | echo "Found version: $VERSION" 27 | 28 | - name: Create Release 29 | uses: softprops/action-gh-release@v1 30 | with: 31 | tag_name: v${{ steps.extract_version.outputs.version }} 32 | name: Release v${{ steps.extract_version.outputs.version }} 33 | draft: false 34 | prerelease: false 35 | 36 | docker: 37 | needs: release 38 | runs-on: ubuntu-latest 39 | steps: 40 | - name: Checkout code 41 | uses: actions/checkout@v3 42 | 43 | - name: Extract version 44 | id: extract_version 45 | run: | 46 | VERSION=$(grep -oP '__version__ = "\K[^"]+' backend/bonita/__init__.py) 47 | echo "version=$VERSION" >> $GITHUB_OUTPUT 48 | 49 | - name: Set up QEMU 50 | uses: docker/setup-qemu-action@v2 51 | 52 | - name: Set up Docker Buildx 53 | uses: docker/setup-buildx-action@v2 54 | 55 | - name: Login to Docker Hub 56 | uses: docker/login-action@v2 57 | with: 58 | username: ${{ secrets.DOCKERHUB_USERNAME }} 59 | password: ${{ secrets.DOCKERHUB_TOKEN }} 60 | 61 | - name: Build and push 62 | uses: docker/build-push-action@v3 63 | with: 64 | context: . 65 | file: ./docker/Dockerfile 66 | platforms: linux/amd64,linux/arm64 67 | push: true 68 | tags: | 69 | suwmlee/bonita:latest 70 | suwmlee/bonita:${{ steps.extract_version.outputs.version }} 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | ### NodeJS 7 | node_modules 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | pip-wheel-metadata/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | *.db 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # vscode 87 | .vscode 88 | # pyenv 89 | .python-version 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # celery beat schedule file 98 | celerybeat-schedule 99 | 100 | # SageMath parsed files 101 | *.sage.py 102 | 103 | # Environments 104 | .env 105 | .venv 106 | env/ 107 | venv/ 108 | ENV/ 109 | env.bak/ 110 | venv.bak/ 111 | 112 | # Spyder project settings 113 | .spyderproject 114 | .spyproject 115 | 116 | # Rope project settings 117 | .ropeproject 118 | 119 | # mkdocs documentation 120 | /site 121 | 122 | # mypy 123 | .mypy_cache/ 124 | .dmypy.json 125 | dmypy.json 126 | 127 | # Pyre type checker 128 | .pyre/ 129 | 130 | # MacOS venv 131 | .DS_Store 132 | bin/ 133 | include/ 134 | pyvenv.cfg 135 | 136 | # Custom 137 | *.lock 138 | cache/ 139 | *.log.* 140 | .cursor/ 141 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ## Bonita 3 | 4 | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/suwmlee/bonita/release.yml?branch=main)](https://github.com/suwmlee/bonita/actions) [![Docker Pulls](https://img.shields.io/docker/pulls/suwmlee/bonita)](https://hub.docker.com/r/suwmlee/bonita) 5 | 6 | 安心享受影片 7 | 8 | 特性: 9 | - 自动检测影视文件、入库 10 | - 管理视频元数据 11 | - 自定义刮削 12 | - 整理、迁移视频文件 13 | - 管理观影记录、收藏的影片 14 | - 同步`Emby` 15 | - 关联`Transmission` 16 | 17 | 计划: 18 | - 同步各个媒体服务记录,无痛迁移 19 | - Ai影片推荐 20 | - 推送服务 21 | 22 | 23 | ### 部署 24 | 25 | 详细参考 [Bonita](https://bonita.starunits.net/zh/guide/) 26 | -------------------------------------------------------------------------------- /backend/README.md: -------------------------------------------------------------------------------- 1 | 2 | ## Bonita backend 3 | 4 | ### 部署 5 | 6 | ```sh 7 | 8 | # 安装依赖 9 | python -m venv .venv 10 | pip install -r requirements.txt 11 | 12 | # 启动 13 | uvicorn bonita.main:app --host 0.0.0.0 --port 8000 --reload 14 | 15 | # 启动 worker,采用 threads 适用简单I/O,设置并发数为 5,同时启用事件机制 16 | celery --app bonita.worker.celery worker --pool threads --concurrency 5 --events --loglevel DEBUG 17 | 18 | # 注册的任务列表 19 | celery --app bonita.worker.celery inspect registered 20 | ``` 21 | 22 | #### alembic迁移 23 | 24 | ``` 25 | alembic init bonita/alembic 26 | 27 | alembic revision --autogenerate -m "update" 28 | 29 | alembic upgrade head 30 | 31 | alembic downgrade -1 32 | ``` 33 | 34 | #### VSCode 35 | 36 | ```sh 37 | { 38 | "version": "0.2.0", 39 | "configurations": [ 40 | { 41 | "name": "FastAPI", 42 | "type": "debugpy", 43 | "request": "launch", 44 | "module": "uvicorn", 45 | "args": ["bonita.main:app", "--host=0.0.0.0", "--port=8000", "--reload"], 46 | "jinja": true 47 | }, 48 | { 49 | "name": "Celery", 50 | "type": "debugpy", 51 | "request": "launch", 52 | "module": "celery", 53 | "console": "integratedTerminal", 54 | "args": [ 55 | "--app", 56 | "bonita.worker.celery", 57 | "worker", 58 | "--loglevel", 59 | "DEBUG", 60 | "--pool", 61 | "threads", 62 | "--concurrency", 63 | "5", 64 | "--events" 65 | ] 66 | } 67 | ], 68 | "compounds": [ 69 | { 70 | "name": "Celery and FastAPI", 71 | "configurations": ["Celery", "FastAPI"] 72 | } 73 | ] 74 | } 75 | ``` -------------------------------------------------------------------------------- /backend/bonita/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.9.4" 2 | -------------------------------------------------------------------------------- /backend/bonita/alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /backend/bonita/alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | ${imports if imports else ""} 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = ${repr(up_revision)} 16 | down_revision: Union[str, None] = ${repr(down_revision)} 17 | branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} 18 | depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} 19 | 20 | 21 | def upgrade() -> None: 22 | ${upgrades if upgrades else "pass"} 23 | 24 | 25 | def downgrade() -> None: 26 | ${downgrades if downgrades else "pass"} 27 | -------------------------------------------------------------------------------- /backend/bonita/alembic/versions/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Suwmlee/bonita/05c7a84ba4b9d88bba7f374000d07b776484f865/backend/bonita/alembic/versions/.gitkeep -------------------------------------------------------------------------------- /backend/bonita/alembic/versions/202503071003_f6497e8754c9_srcdeleted.py: -------------------------------------------------------------------------------- 1 | """srcdeleted 2 | 3 | Revision ID: f6497e8754c9 4 | Revises: 5499b19d0194 5 | Create Date: 2025-03-07 10:03:36.631762 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = 'f6497e8754c9' 16 | down_revision: Union[str, None] = None 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | try: 24 | with op.batch_alter_table('transrecords', schema=None) as batch_op: 25 | batch_op.add_column(sa.Column('srcdeleted', sa.Boolean(), server_default='0', nullable=True, comment='实际源文件已经删除')) 26 | except: 27 | pass 28 | # ### end Alembic commands ### 29 | 30 | 31 | def downgrade() -> None: 32 | # ### commands auto generated by Alembic - please adjust! ### 33 | try: 34 | with op.batch_alter_table('transrecords', schema=None) as batch_op: 35 | batch_op.drop_column('srcdeleted') 36 | except: 37 | pass 38 | # ### end Alembic commands ### 39 | -------------------------------------------------------------------------------- /backend/bonita/alembic/versions/202503152315_07c8c5cb55d1_watched.py: -------------------------------------------------------------------------------- 1 | """watched 2 | 3 | Revision ID: 07c8c5cb55d1 4 | Revises: eca431b007aa 5 | Create Date: 2025-03-15 23:15:40.402811 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = '07c8c5cb55d1' 16 | down_revision: Union[str, None] = 'eca431b007aa' 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | with op.batch_alter_table('watchhistory', schema=None) as batch_op: 24 | batch_op.add_column(sa.Column('watched', sa.Boolean(), server_default='0', nullable=True, comment='是否观看')) 25 | batch_op.add_column(sa.Column('watch_count', sa.Integer(), nullable=True, comment='观看次数')) 26 | batch_op.drop_column('played_at') 27 | batch_op.drop_column('play_count') 28 | 29 | # ### end Alembic commands ### 30 | 31 | 32 | def downgrade() -> None: 33 | # ### commands auto generated by Alembic - please adjust! ### 34 | with op.batch_alter_table('watchhistory', schema=None) as batch_op: 35 | batch_op.add_column(sa.Column('play_count', sa.INTEGER(), nullable=True)) 36 | batch_op.add_column(sa.Column('played_at', sa.DATETIME(), nullable=True)) 37 | batch_op.drop_column('watch_count') 38 | batch_op.drop_column('watched') 39 | 40 | # ### end Alembic commands ### 41 | -------------------------------------------------------------------------------- /backend/bonita/alembic/versions/202503171030_062f620c65b4_update.py: -------------------------------------------------------------------------------- 1 | """update 2 | 3 | Revision ID: 062f620c65b4 4 | Revises: 07c8c5cb55d1 5 | Create Date: 2025-03-17 10:30:48.751219 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = '062f620c65b4' 16 | down_revision: Union[str, None] = '07c8c5cb55d1' 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | with op.batch_alter_table('mediaitem', schema=None) as batch_op: 24 | batch_op.drop_index('ix_mediaitem_douban_id') 25 | batch_op.drop_column('poster') 26 | batch_op.drop_column('year') 27 | batch_op.drop_column('douban_id') 28 | 29 | with op.batch_alter_table('watchhistory', schema=None) as batch_op: 30 | batch_op.add_column(sa.Column('favorite', sa.Boolean(), nullable=True, comment='是否收藏')) 31 | batch_op.drop_column('source') 32 | batch_op.drop_column('external_id') 33 | 34 | # ### end Alembic commands ### 35 | 36 | 37 | def downgrade() -> None: 38 | # ### commands auto generated by Alembic - please adjust! ### 39 | with op.batch_alter_table('watchhistory', schema=None) as batch_op: 40 | batch_op.add_column(sa.Column('external_id', sa.VARCHAR(), nullable=False)) 41 | batch_op.add_column(sa.Column('source', sa.VARCHAR(), nullable=False)) 42 | batch_op.drop_column('favorite') 43 | 44 | with op.batch_alter_table('mediaitem', schema=None) as batch_op: 45 | batch_op.add_column(sa.Column('douban_id', sa.VARCHAR(), nullable=True)) 46 | batch_op.add_column(sa.Column('year', sa.INTEGER(), nullable=True)) 47 | batch_op.add_column(sa.Column('poster', sa.VARCHAR(), nullable=True)) 48 | batch_op.create_index('ix_mediaitem_douban_id', ['douban_id'], unique=False) 49 | 50 | # ### end Alembic commands ### 51 | -------------------------------------------------------------------------------- /backend/bonita/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Suwmlee/bonita/05c7a84ba4b9d88bba7f374000d07b776484f865/backend/bonita/api/__init__.py -------------------------------------------------------------------------------- /backend/bonita/api/deps.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from fastapi import Depends, HTTPException, status 4 | from fastapi.security import OAuth2PasswordBearer 5 | from jose import JWTError, jwt 6 | from pydantic import ValidationError 7 | from sqlalchemy.orm import Session 8 | 9 | from bonita import schemas 10 | from bonita.core import security 11 | from bonita.core.config import settings 12 | from bonita.db import get_db 13 | from bonita.db.models.user import User 14 | 15 | reusable_oauth2 = OAuth2PasswordBearer( 16 | tokenUrl=f"{settings.API_V1_STR}/login/access-token" 17 | ) 18 | 19 | 20 | SessionDep = Annotated[Session, Depends(get_db)] 21 | 22 | 23 | def verify_token(token: str = Depends(reusable_oauth2)) -> schemas.TokenPayload: 24 | try: 25 | payload = jwt.decode( 26 | token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] 27 | ) 28 | return schemas.TokenPayload(**payload) 29 | except (JWTError, ValidationError): 30 | raise HTTPException( 31 | status_code=status.HTTP_401_UNAUTHORIZED, 32 | detail="Could not validate credentials", 33 | ) 34 | 35 | 36 | TokenDep = Annotated[schemas.TokenPayload, Depends(verify_token)] 37 | 38 | 39 | def get_current_user(session: SessionDep, token: TokenDep) -> User: 40 | user = session.get(User, token.sub) 41 | if not user: 42 | raise HTTPException(status_code=404, detail="User not found") 43 | if not user.is_active: 44 | raise HTTPException(status_code=400, detail="Inactive user") 45 | return user 46 | 47 | 48 | CurrentUser = Annotated[User, Depends(get_current_user)] 49 | 50 | 51 | def get_current_active_superuser(current_user: CurrentUser) -> User: 52 | if not current_user.is_superuser: 53 | raise HTTPException( 54 | status_code=403, detail="The user doesn't have enough privileges" 55 | ) 56 | return current_user 57 | -------------------------------------------------------------------------------- /backend/bonita/api/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends 2 | 3 | from bonita.api.routes import login, mediaitem, records, resource, scraping_config, task_config, tasks, users, metadata, tools, settings, file_browser, status 4 | from bonita.api.deps import verify_token 5 | from bonita.api.websockets import logs as ws_logs 6 | 7 | api_router = APIRouter() 8 | api_router.include_router(login.router, prefix="/login", tags=["login"]) 9 | api_router.include_router(users.router, prefix="/users", tags=["user"], dependencies=[Depends(verify_token)]) 10 | api_router.include_router(tasks.router, prefix="/tasks", tags=["task"], dependencies=[Depends(verify_token)]) 11 | api_router.include_router(task_config.router, prefix="/tasks/config", 12 | tags=["taskConfig"], dependencies=[Depends(verify_token)]) 13 | api_router.include_router(scraping_config.router, prefix="/scraping/config", 14 | tags=["scrapingConfig"], dependencies=[Depends(verify_token)]) 15 | api_router.include_router(records.router, prefix="/records", 16 | tags=["record"], dependencies=[Depends(verify_token)]) 17 | api_router.include_router(metadata.router, prefix="/metadata", 18 | tags=["metadata"], dependencies=[Depends(verify_token)]) 19 | api_router.include_router(mediaitem.router, prefix="/mediaitems", 20 | tags=["mediaitem"], dependencies=[Depends(verify_token)]) 21 | api_router.include_router(tools.router, prefix="/tools", 22 | tags=["tools"], dependencies=[Depends(verify_token)]) 23 | api_router.include_router(settings.router, prefix="/settings", 24 | tags=["settings"], dependencies=[Depends(verify_token)]) 25 | api_router.include_router(resource.router, prefix="/resource", 26 | tags=["resource"]) 27 | api_router.include_router(file_browser.router, prefix="/files", 28 | tags=["files"], dependencies=[Depends(verify_token)]) 29 | api_router.include_router(status.router, prefix="/status", tags=["status"]) 30 | api_router.include_router(ws_logs.router, prefix="/ws", tags=["websocket"]) 31 | -------------------------------------------------------------------------------- /backend/bonita/api/routes/login.py: -------------------------------------------------------------------------------- 1 | 2 | from datetime import timedelta 3 | from typing import Annotated 4 | 5 | from fastapi import APIRouter, Depends, HTTPException 6 | from fastapi.security import OAuth2PasswordRequestForm 7 | 8 | from bonita import schemas 9 | from bonita.api.deps import SessionDep 10 | from bonita.core import security 11 | from bonita.core.config import settings 12 | from bonita.db.models.user import User 13 | 14 | 15 | router = APIRouter() 16 | 17 | 18 | @router.post("/access-token", summary="获取token", response_model=schemas.Token) 19 | async def login_access_token( 20 | session: SessionDep, 21 | form_data: Annotated[OAuth2PasswordRequestForm, Depends()] 22 | ) -> schemas.Token: 23 | """ 24 | 获取认证Token 25 | """ 26 | # 检查数据库 27 | user = User.authenticate( 28 | session=session, 29 | email=form_data.username, 30 | password=form_data.password 31 | ) 32 | if not user: 33 | raise HTTPException(status_code=400, detail="Incorrect email or password") 34 | elif not user.is_active: 35 | raise HTTPException(status_code=400, detail="Inactive user") 36 | access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) 37 | return schemas.Token( 38 | access_token=security.create_access_token( 39 | user.id, expires_delta=access_token_expires 40 | ) 41 | ) 42 | -------------------------------------------------------------------------------- /backend/bonita/api/routes/scraping_config.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from fastapi import APIRouter, HTTPException 3 | 4 | from bonita import schemas 5 | from bonita.api.deps import CurrentUser, SessionDep 6 | from bonita.db.models.scraping import ScrapingConfig 7 | 8 | 9 | router = APIRouter() 10 | 11 | 12 | @router.get("/all", response_model=schemas.ScrapingConfigsPublic) 13 | def get_all_configs(session: SessionDep, skip: int = 0, limit: int = 100) -> Any: 14 | """ 15 | 获取所有配置. 16 | """ 17 | configs = session.query(ScrapingConfig).offset(skip).limit(limit).all() 18 | count = session.query(ScrapingConfig).count() 19 | 20 | config_list = [schemas.ScrapingConfigPublic.model_validate(config) for config in configs] 21 | return schemas.ScrapingConfigsPublic(data=config_list, count=count) 22 | 23 | 24 | @router.post("/", response_model=schemas.ScrapingConfigPublic) 25 | def create_config( 26 | session: SessionDep, current_user: CurrentUser, config_in: schemas.ScrapingConfigCreate 27 | ) -> Any: 28 | """ 29 | 创建新配置 30 | """ 31 | config_info = config_in.__dict__ 32 | config = ScrapingConfig(**config_info) 33 | config.create(session) 34 | return config 35 | 36 | 37 | @router.put("/{id}", response_model=schemas.ScrapingConfigPublic) 38 | def update_config( 39 | session: SessionDep, 40 | id: int, 41 | config_in: schemas.ScrapingConfigPublic, 42 | ) -> Any: 43 | """ 44 | 更新配置 45 | """ 46 | config = session.get(ScrapingConfig, id) 47 | if not config: 48 | raise HTTPException(status_code=404, detail="配置未找到") 49 | update_dict = config_in.model_dump(exclude_unset=True) 50 | config.update(session, update_dict) 51 | session.commit() 52 | session.refresh(config) 53 | return config 54 | 55 | 56 | @router.delete("/{id}", response_model=schemas.Response) 57 | def delete_config( 58 | session: SessionDep, 59 | id: int 60 | ) -> Any: 61 | """ 62 | 删除配置 63 | """ 64 | config = session.get(ScrapingConfig, id) 65 | session.delete(config) 66 | session.commit() 67 | return schemas.Response(success=True, message="配置删除成功") -------------------------------------------------------------------------------- /backend/bonita/api/routes/status.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from bonita import __version__, schemas 4 | 5 | router = APIRouter() 6 | 7 | 8 | @router.get("/health", response_model=schemas.StatusResponse) 9 | def health_check(): 10 | """ 11 | 健康检查端点,用于确认API服务运行状态 12 | """ 13 | return schemas.StatusResponse(status="ok", version=__version__) 14 | -------------------------------------------------------------------------------- /backend/bonita/api/routes/tools.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from fastapi import APIRouter 3 | 4 | from bonita import schemas 5 | from bonita.api.deps import SessionDep 6 | from bonita.services.tool_service import ToolService 7 | 8 | router = APIRouter() 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | @router.post("/importnfo", response_model=schemas.TaskStatus) 13 | async def run_import_nfo( 14 | session: SessionDep, 15 | folder_args: schemas.ToolArgsParam): 16 | """ 导入NFO信息 17 | """ 18 | tool_service = ToolService(session) 19 | if folder_args.arg1 and folder_args.arg2: 20 | return tool_service.import_nfo(folder_args.arg1, folder_args.arg2) 21 | else: 22 | return schemas.TaskStatus(id=None, 23 | name="import nfo", 24 | status='FAILED') 25 | 26 | 27 | @router.get("/embyscan", response_model=schemas.TaskStatus) 28 | async def run_emby_scan( 29 | session: SessionDep, 30 | folder_args: schemas.ToolArgsParam): 31 | """ 扫描emby 32 | """ 33 | tool_service = ToolService(session) 34 | return tool_service.emby_scan(folder_args) 35 | 36 | 37 | @router.post("/sync/emby", response_model=schemas.Response) 38 | async def sync_emby_watch_history( 39 | session: SessionDep): 40 | """ 同步emby watch history 41 | """ 42 | tool_service = ToolService(session) 43 | return tool_service.sync_emby_watch_history() 44 | 45 | 46 | @router.post("/cleanup", response_model=schemas.Response) 47 | async def cleanup_data( 48 | session: SessionDep, 49 | params: schemas.ToolArgsParam): 50 | """ 清理下载器、转移记录和实际文件 51 | 52 | Args: 53 | arg1: 强制删除 ("true"/"false") 54 | """ 55 | # 获取是否删除文件的参数,默认为false 56 | delete_files = params.arg1.lower() == "true" if params.arg1 else False 57 | 58 | # 使用ToolService处理逻辑 59 | tool_service = ToolService(session) 60 | return tool_service.cleanup_data(delete_files) 61 | -------------------------------------------------------------------------------- /backend/bonita/celery_tasks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Suwmlee/bonita/05c7a84ba4b9d88bba7f374000d07b776484f865/backend/bonita/celery_tasks/__init__.py -------------------------------------------------------------------------------- /backend/bonita/core/config.py: -------------------------------------------------------------------------------- 1 | 2 | import os 3 | import logging 4 | import secrets 5 | from pydantic_settings import BaseSettings 6 | 7 | 8 | class Settings(BaseSettings): 9 | PROJECT_NAME: str = "Bonita" 10 | API_V1_STR: str = "/api/v1" 11 | # 与 alembic.ini 同步 12 | ALEMBIC_LOCATION: str = "./bonita/alembic" 13 | # DATABASE_LOCATION 14 | DATABASE_LOCATION: str = "./data/db.sqlite3" 15 | SQLALCHEMY_DATABASE_URI: str = f"sqlite:///{DATABASE_LOCATION}" 16 | # CACHE_LOCATION 17 | CACHE_LOCATION: str = "./data/cache" 18 | # CELERY 19 | CELERY_BROKER_URL: str = os.environ.get("CELERY_BROKER_URL", f"sqla+sqlite:///{DATABASE_LOCATION}") 20 | CELERY_RESULT_BACKEND: str = os.environ.get("CELERY_RESULT_BACKEND", f"db+sqlite:///{DATABASE_LOCATION}") 21 | # 最大并发任务数, 受 worker 数量影响 22 | MAX_CONCURRENT_TASKS: int = os.environ.get("MAX_CONCURRENT_TASKS", 5) 23 | # 日志 24 | LOGGING_FORMAT: str = "[%(asctime)s] %(levelname)s in %(module)s: %(message)s" 25 | LOGGING_LOCATION: str = "./data/bonita.log" 26 | LOGGING_LEVEL: int = logging.INFO 27 | # SECRET_KEY: str = secrets.token_urlsafe(32) 28 | SECRET_KEY: str = "secret key" 29 | # 60 minutes * 24 hours * 8 days = 8 days 30 | ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 31 | # 跨域 32 | BACKEND_CORS_ORIGINS: list = ["*"] 33 | # 初始化管理员 34 | FIRST_SUPERUSER: str = os.getenv("FIRST_SUPERUSER", "admin") 35 | FIRST_SUPERUSER_EMAIL: str = os.getenv("FIRST_SUPERUSER_EMAIL", "admin@example.com") 36 | FIRST_SUPERUSER_PASSWORD: str = os.getenv("FIRST_SUPERUSER_PASSWORD", "changepwd") 37 | # 是否开放注册 38 | USERS_OPEN_REGISTRATION: bool = False 39 | 40 | 41 | settings = Settings() 42 | -------------------------------------------------------------------------------- /backend/bonita/core/db.py: -------------------------------------------------------------------------------- 1 | 2 | import logging 3 | import os 4 | from alembic.command import upgrade, stamp 5 | from alembic.config import Config 6 | 7 | from bonita.core.config import settings 8 | from bonita.core.security import get_password_hash 9 | from bonita.db import Base, engine, SessionFactory 10 | from bonita.db.models import * 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | def init_db(): 16 | """ 17 | 初始化数据库 18 | """ 19 | try: 20 | location = settings.DATABASE_LOCATION 21 | if not os.path.exists(location): 22 | Base.metadata.create_all(bind=engine) 23 | init_super_user() 24 | stamp_db() 25 | else: 26 | upgrade_db() 27 | except Exception as e: 28 | logger.error(f"初始化数据库失败: {e}") 29 | 30 | def init_super_user(): 31 | """ 32 | 初始化超级管理员 33 | """ 34 | with SessionFactory() as session: 35 | _user = User.get_user_by_email(session=session, email=settings.FIRST_SUPERUSER_EMAIL) 36 | if not _user: 37 | _user = User( 38 | name=settings.FIRST_SUPERUSER, 39 | email=settings.FIRST_SUPERUSER_EMAIL, 40 | hashed_password=get_password_hash(settings.FIRST_SUPERUSER_PASSWORD), 41 | is_active=True, 42 | is_superuser=True 43 | ) 44 | _user.create(session) 45 | 46 | 47 | def upgrade_db(): 48 | """ 49 | 更新数据库 50 | """ 51 | try: 52 | alembic_cfg = Config() 53 | alembic_cfg.set_main_option('script_location', settings.ALEMBIC_LOCATION) 54 | alembic_cfg.set_main_option('sqlalchemy.url', settings.SQLALCHEMY_DATABASE_URI) 55 | upgrade(alembic_cfg, 'head') 56 | except Exception as e: 57 | logger.error(f"升级数据库失败: {e}") 58 | 59 | 60 | def stamp_db(): 61 | """ 62 | 打标签 63 | """ 64 | alembic_cfg = Config() 65 | alembic_cfg.set_main_option('script_location', settings.ALEMBIC_LOCATION) 66 | alembic_cfg.set_main_option('sqlalchemy.url', settings.SQLALCHEMY_DATABASE_URI) 67 | stamp(alembic_cfg, 'head') 68 | -------------------------------------------------------------------------------- /backend/bonita/core/security.py: -------------------------------------------------------------------------------- 1 | 2 | import bcrypt 3 | from datetime import datetime, timedelta, timezone 4 | from typing import Any 5 | from jose import jwt 6 | 7 | from bonita.core.config import settings 8 | 9 | 10 | ALGORITHM = "HS256" 11 | 12 | 13 | def create_access_token(subject: str | Any, expires_delta: timedelta) -> str: 14 | expire = datetime.now(timezone.utc) + expires_delta 15 | to_encode = {"exp": expire, "sub": str(subject)} 16 | encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) 17 | return encoded_jwt 18 | 19 | 20 | def verify_password(plain_password: str, hashed_password: str) -> bool: 21 | """ 22 | Check if the provided password matches the stored password (hashed) 23 | """ 24 | password_byte_enc = plain_password.encode('utf-8') 25 | return bcrypt.checkpw(password=password_byte_enc, hashed_password=hashed_password) 26 | 27 | 28 | def get_password_hash(password: str) -> str: 29 | """ 30 | Hash a password using bcrypt 31 | """ 32 | pwd_bytes = password.encode('utf-8') 33 | salt = bcrypt.gensalt() 34 | hashed_password = bcrypt.hashpw(password=pwd_bytes, salt=salt) 35 | return hashed_password 36 | -------------------------------------------------------------------------------- /backend/bonita/core/service.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | import logging 4 | from bonita.db import SessionFactory 5 | from bonita.db.models.setting import SystemSetting 6 | from bonita.db.models.task import TransferConfig 7 | from bonita.modules.media_service.emby import EmbyService 8 | from bonita.modules.monitor.monitor import MonitorService 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | def init_monitor(): 14 | """ 15 | initial MonitorService 16 | """ 17 | try: 18 | logger.info("initial MonitorService") 19 | MonitorService().start() 20 | except Exception as e: 21 | logger.error(e) 22 | 23 | 24 | def stop_monitor(): 25 | """ 26 | stop MonitorService 27 | """ 28 | MonitorService().stop() 29 | 30 | 31 | def init_emby(): 32 | """ 33 | initial EmbyService 34 | """ 35 | with SessionFactory() as session: 36 | logger.info("initial EmbyService") 37 | emby_enabled = session.query(SystemSetting).filter(SystemSetting.key == "emby_enabled").first() 38 | if not emby_enabled or emby_enabled.value != "true": 39 | logger.info("Emby is not enabled") 40 | return 41 | emby_host = session.query(SystemSetting).filter(SystemSetting.key == "emby_host").first() 42 | emby_apikey = session.query(SystemSetting).filter(SystemSetting.key == "emby_apikey").first() 43 | emby_user = session.query(SystemSetting).filter(SystemSetting.key == "emby_user").first() 44 | if not emby_host or not emby_apikey or not emby_user: 45 | logger.info("Emby host or API key or user not configured") 46 | return 47 | EmbyService().initialize(emby_host.value, emby_apikey.value, emby_user.value) 48 | 49 | 50 | def init_service(): 51 | """ 52 | initial Service 53 | """ 54 | init_monitor() 55 | init_emby() 56 | -------------------------------------------------------------------------------- /backend/bonita/db/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | from typing import Generator 3 | from sqlalchemy import create_engine, inspect 4 | from sqlalchemy.orm import sessionmaker, Session, declared_attr, as_declarative 5 | 6 | from bonita.core.config import settings 7 | from bonita.utils.filehelper import OperationMethod 8 | 9 | engine = create_engine(settings.SQLALCHEMY_DATABASE_URI, 10 | connect_args={"check_same_thread": False}) 11 | 12 | SessionFactory = sessionmaker(bind=engine, autoflush=False) 13 | 14 | 15 | def get_db() -> Generator: 16 | """ 17 | 获取数据库会话, 用于WEB请求 18 | :return: Session 19 | """ 20 | db = None 21 | try: 22 | db = SessionFactory() 23 | yield db 24 | finally: 25 | if db: 26 | db.close() 27 | 28 | 29 | @as_declarative() 30 | class Base: 31 | __name__: str 32 | 33 | def create(self, session: Session): 34 | session.add(self) 35 | session.commit() 36 | 37 | @declared_attr 38 | def __tablename__(self) -> str: 39 | return self.__name__.lower() 40 | 41 | def update(self, session: Session, payload: dict): 42 | payload = {k: v for k, v in payload.items() if v is not None} 43 | for key, value in payload.items(): 44 | setattr(self, key, value) 45 | if inspect(self).detached: 46 | session.add(self) 47 | 48 | def to_dict(self): 49 | result = {} 50 | for c in self.__table__.columns: 51 | value = getattr(self, c.name, None) 52 | if isinstance(value, OperationMethod): 53 | value = value.value 54 | result[c.name] = value 55 | return result 56 | 57 | def filter_dict(self, source_dict): 58 | valid_columns = {column.name for column in self.__table__.columns} 59 | filtered_dict = {key: value for key, value in source_dict.items() if key in valid_columns} 60 | return filtered_dict 61 | -------------------------------------------------------------------------------- /backend/bonita/db/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .user import User 2 | from .task import TransferConfig 3 | from .scraping import ScrapingConfig 4 | from .metadata import Metadata 5 | from .extrainfo import ExtraInfo 6 | from .record import TransRecords 7 | from .downloads import Downloads 8 | from .setting import SystemSetting 9 | from .watch_history import WatchHistory 10 | from .mediaitem import MediaItem 11 | -------------------------------------------------------------------------------- /backend/bonita/db/models/downloads.py: -------------------------------------------------------------------------------- 1 | 2 | from datetime import datetime 3 | from sqlalchemy import Column, Integer, String, DateTime 4 | 5 | from bonita.db import Base 6 | 7 | 8 | class Downloads(Base): 9 | """ 下载的文件 10 | """ 11 | id = Column(Integer, primary_key=True, index=True) 12 | url = Column(String, nullable=False, comment="下载链接") 13 | filepath = Column(String, nullable=False, comment="文件路径") 14 | updatetime = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") 15 | -------------------------------------------------------------------------------- /backend/bonita/db/models/extrainfo.py: -------------------------------------------------------------------------------- 1 | 2 | from datetime import datetime 3 | from sqlalchemy import Column, Integer, String, Date 4 | 5 | from bonita.db import Base 6 | 7 | 8 | class ExtraInfo(Base): 9 | """ 自定义额外信息 10 | """ 11 | id = Column(Integer, primary_key=True, index=True) 12 | filepath = Column(String, default="", nullable=False, comment="文件路径") 13 | number = Column(String, default="", nullable=False, comment="编号") 14 | tag = Column(String, default="", comment="标签(用于分类)") 15 | partNumber = Column(Integer, default=0, comment="分集数") 16 | specifiedsource = Column(String, default="", comment="指定来源") 17 | specifiedurl = Column(String, default="", comment="指定链接") 18 | -------------------------------------------------------------------------------- /backend/bonita/db/models/mediaitem.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, func 3 | from sqlalchemy.orm import relationship 4 | 5 | from bonita.db import Base 6 | 7 | 8 | class MediaItem(Base): 9 | """媒体项目中央映射表 10 | 作为所有媒体内容的统一标识和桥接各种元数据来源 11 | """ 12 | id = Column(Integer, primary_key=True, index=True) 13 | 14 | # 内部标识 15 | media_type = Column(String, nullable=False, comment="媒体类型: movie, episode, special") 16 | 17 | # 外部平台标识映射 18 | imdb_id = Column(String, index=True, comment="IMDB ID") 19 | tmdb_id = Column(String, index=True, comment="TMDB ID") 20 | tvdb_id = Column(String, index=True, comment="TVDB ID") 21 | # 特殊内容标识 22 | number = Column(String, index=True, comment="番号") 23 | 24 | # 基础信息(用于快速显示,减少联表查询) 25 | title = Column(String, nullable=False, comment="标题") 26 | original_title = Column(String, comment="原始标题") 27 | # 对于剧集类型 28 | series_id = Column(Integer, ForeignKey("mediaitem.id"), comment="关联的剧集ID") 29 | season_number = Column(Integer, default=-1, comment="季数") 30 | episode_number = Column(Integer, default=-1, comment="集数") 31 | # 记录信息 32 | createtime = Column(DateTime, default=datetime.now, comment="创建时间") 33 | updatetime = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") 34 | # 关系 35 | series = relationship("MediaItem", remote_side=[id], backref="episodes") 36 | -------------------------------------------------------------------------------- /backend/bonita/db/models/metadata.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from sqlalchemy import Column, DateTime, Integer, String, Date, FLOAT, func 3 | 4 | from bonita.db import Base 5 | 6 | 7 | class Metadata(Base): 8 | """元数据 9 | 存储丰富的媒体详情,特别是特殊影片的详细信息 10 | """ 11 | id = Column(Integer, primary_key=True, index=True) 12 | number = Column(String, default="", index=True, comment="番号") 13 | title = Column(String, default="", nullable=False, comment="标题") 14 | studio = Column(String, default="", comment="制作公司") 15 | release = Column(Date, comment="发行日期") 16 | year = Column(Integer, default=datetime.now().year, comment="发行年份") 17 | runtime = Column(String, default="", comment="时长") 18 | genre = Column(String, default="", comment="类型") 19 | rating = Column(String, default="", comment="评级") 20 | language = Column(String, default="", comment="语言") 21 | country = Column(String, default="", comment="国家") 22 | outline = Column(String, default="", comment="简介") 23 | director = Column(String, default="", comment="导演") 24 | actor = Column(String, default="", comment="演员") 25 | actor_photo = Column(String, default="", comment="演员图片") 26 | cover = Column(String, default="", comment="封面海报") 27 | cover_small = Column(String, default="", comment="缩略图") 28 | extrafanart = Column(String, default="", comment="影片橱窗") 29 | trailer = Column(String, default="", comment="预告") 30 | tag = Column(String, default="", comment="标签(用于分类)") 31 | label = Column(String, default="", comment="标记(用于标记)") 32 | series = Column(String, default="", comment="系列") 33 | userrating = Column(FLOAT, default=0.0, comment="用户评分") 34 | uservotes = Column(Integer, default=0, comment="用户投票数") 35 | detailurl = Column(String, default="", comment="来源链接") 36 | site = Column(String, default="", comment="资源站点") 37 | 38 | createtime = Column(DateTime, default=datetime.now, comment="创建时间") 39 | updatetime = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") 40 | -------------------------------------------------------------------------------- /backend/bonita/db/models/record.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from sqlalchemy import Column, Integer, String, Boolean, DateTime, func 3 | 4 | from bonita.db import Base 5 | 6 | 7 | class TransRecords(Base): 8 | """ 转移记录 9 | ignored: 忽略 10 | locked: 锁定, 不再进行重命名等 11 | deleted: 实际内容已经删除 12 | """ 13 | id = Column(Integer, primary_key=True) 14 | srcname = Column(String, default='') 15 | srcpath = Column(String, default='') 16 | srcfolder = Column(String, default='') 17 | task_id = Column(Integer, default=0, server_default='0', comment='任务ID') 18 | 19 | ignored = Column(Boolean, default=False) 20 | locked = Column(Boolean, default=False) 21 | deleted = Column(Boolean, default=False, comment='实际目标路径文件已经删除') 22 | srcdeleted = Column(Boolean, default=False, server_default='0', comment='实际源文件已经删除') 23 | 24 | forced_name = Column(String, default='', comment='forced name') 25 | top_folder = Column(String, default='') 26 | # 电影类,次级目录;如果是剧集则以season为准 27 | second_folder = Column(String, default='') 28 | isepisode = Column(Boolean, default=False) 29 | season = Column(Integer, default=-1) 30 | episode = Column(Integer, default=-1) 31 | # 链接使用的地址,可能与docker内地址不同 32 | linkpath = Column(String, default='') 33 | destpath = Column(String, default='') 34 | # 完全删除时间,包括源文件和目标路径文件 35 | deadtime = Column(DateTime, default=None, comment='time to delete files') 36 | 37 | createtime = Column(DateTime, default=datetime.now, comment="创建时间") 38 | updatetime = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") 39 | -------------------------------------------------------------------------------- /backend/bonita/db/models/scraping.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Integer, String, Boolean 2 | 3 | from bonita.db import Base 4 | 5 | 6 | class ScrapingConfig(Base): 7 | """ 刮削配置 8 | """ 9 | id = Column(Integer, primary_key=True, index=True) 10 | name = Column(String, default='movie') 11 | description = Column(String, default='') 12 | save_metadata = Column(Boolean, default=True) 13 | 14 | scraping_sites = Column(String, default="") 15 | location_rule = Column(String, default="actor+'/'+number+' '+title") 16 | naming_rule = Column(String, default="number+' '+title") 17 | max_title_len = Column(Integer, default=50) 18 | 19 | morestoryline = Column(Boolean, default=True) 20 | extrafanart_enabled = Column(Boolean, default=False) 21 | extrafanart_folder = Column(String, default='extrafanart') 22 | watermark_enabled = Column(Boolean, default=True) 23 | watermark_size = Column(Integer, default=9) 24 | watermark_location = Column(Integer, default=2) 25 | transalte_enabled = Column(Boolean, default=False) 26 | transalte_to_sc = Column(Boolean, default=False) 27 | transalte_values = Column(String, default="title,outline") 28 | -------------------------------------------------------------------------------- /backend/bonita/db/models/setting.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Integer, String, DateTime 2 | from datetime import datetime 3 | 4 | from bonita.db import Base 5 | 6 | 7 | class SystemSetting(Base): 8 | """ 系统设置 9 | """ 10 | id = Column(Integer, primary_key=True, index=True) 11 | key = Column(String, unique=True, index=True, nullable=False) 12 | value = Column(String, nullable=True, default="") 13 | description = Column(String, default="") 14 | updatetime = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") 15 | -------------------------------------------------------------------------------- /backend/bonita/db/models/task.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Enum, Integer, String, Boolean 2 | 3 | from bonita.db import Base 4 | from bonita.utils.filehelper import OperationMethod 5 | 6 | 7 | class TransferConfig(Base): 8 | """ 9 | 转移任务配置 10 | """ 11 | id = Column(Integer, primary_key=True, index=True) 12 | name = Column(String, default='movie') 13 | description = Column(String, default='') 14 | enabled = Column(Boolean, default=True) 15 | deleted = Column(Boolean, default=True) 16 | 17 | operation = Column(Enum(OperationMethod), default=OperationMethod.HARD_LINK) 18 | auto_watch = Column(Boolean, default=False, comment="开启自动监测") 19 | clean_others = Column(Boolean, default=True, comment="清理其他文件") 20 | optimize_name = Column(Boolean, default=True, comment="优化名字") 21 | 22 | # 内容类型: 1. 电影 2. 电视节目 23 | content_type = Column(Integer, default=1, comment="内容类型") 24 | source_folder = Column(String, default='/media/source') 25 | output_folder = Column(String, default='/media/output') 26 | failed_folder = Column(String, default='/media/failed') 27 | escape_folder = Column(String, default='Sample,sample,@eaDir') 28 | escape_literals = Column(String, default="\\()/") 29 | escape_size = Column(Integer, default=0) 30 | threads_num = Column(Integer, default=5) 31 | 32 | # 仅在刮削模式下生效,刮削配置 33 | sc_enabled = Column(Boolean, default=False, comment="启用刮削") 34 | sc_id = Column(Integer, default=0, comment="使用的刮削配置") 35 | -------------------------------------------------------------------------------- /backend/bonita/db/models/user.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | from sqlalchemy import Column, Integer, String, Boolean 4 | from sqlalchemy.orm import Session 5 | 6 | from bonita.core.security import verify_password 7 | from bonita.db import Base 8 | 9 | 10 | class User(Base): 11 | """ 12 | 用户表 13 | """ 14 | id = Column(Integer, primary_key=True, index=True) 15 | name = Column(String, index=True, nullable=False) 16 | email = Column(String) 17 | hashed_password = Column(String) 18 | is_active = Column(Boolean, default=True) 19 | is_superuser = Column(Boolean, default=False) 20 | 21 | @staticmethod 22 | def authenticate(session: Session, email: str, password: str): 23 | db_user = session.query(User).filter(User.email == email).first() 24 | if not db_user: 25 | return None 26 | if not verify_password(password, db_user.hashed_password): 27 | return None 28 | return db_user 29 | 30 | @staticmethod 31 | def get_user_by_email(session: Session, email: str): 32 | return session.query(User).filter(User.email == email).first() 33 | -------------------------------------------------------------------------------- /backend/bonita/db/models/watch_history.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from sqlalchemy import Column, Integer, String, Boolean, DateTime, Float, ForeignKey, func 3 | from sqlalchemy.orm import relationship 4 | 5 | from bonita.db import Base 6 | 7 | 8 | class WatchHistory(Base): 9 | """观看历史 10 | 仅关注用户的观看行为,不再存储媒体元数据 11 | """ 12 | id = Column(Integer, primary_key=True, index=True) 13 | 14 | # 关联到中央媒体项 15 | media_item_id = Column(Integer, ForeignKey("mediaitem.id"), nullable=False, index=True) 16 | media_item = relationship("MediaItem", backref="watchhistory") 17 | 18 | # 观看信息 19 | watched = Column(Boolean, default=False, server_default="0", comment="是否观看") 20 | watch_count = Column(Integer, default=1, comment="观看次数") 21 | play_progress = Column(Float, default=100.0, comment="播放进度百分比") 22 | duration = Column(Integer, comment="时长(秒)") 23 | 24 | favorite = Column(Boolean, default=False, comment="是否收藏") 25 | has_rating = Column(Boolean, default=False, comment="是否有评分") 26 | rating = Column(Float, default=0.0, comment="用户评分(1-10)") 27 | 28 | createtime = Column(DateTime, default=datetime.now, comment="创建时间") 29 | updatetime = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") 30 | -------------------------------------------------------------------------------- /backend/bonita/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from logging.handlers import RotatingFileHandler 3 | from fastapi import FastAPI 4 | from fastapi.routing import APIRoute 5 | from starlette.middleware.cors import CORSMiddleware 6 | 7 | from bonita import __version__ 8 | from bonita.core.config import settings 9 | from bonita.core.db import init_db 10 | from bonita.core.service import init_service 11 | from bonita.api.main import api_router 12 | 13 | # celery client 14 | from bonita.worker import celery 15 | 16 | 17 | def custom_generate_unique_id(route: APIRoute) -> str: 18 | return f"{route.tags[0]}-{route.name}" 19 | 20 | 21 | def create_app() -> FastAPI: 22 | current_app = FastAPI( 23 | title=settings.PROJECT_NAME, 24 | openapi_url=f"{settings.API_V1_STR}/openapi.json", 25 | generate_unique_id_function=custom_generate_unique_id, 26 | version=__version__ 27 | ) 28 | 29 | # Set all CORS enabled origins 30 | current_app.add_middleware( 31 | CORSMiddleware, 32 | allow_origins=settings.BACKEND_CORS_ORIGINS, 33 | allow_credentials=True, 34 | allow_methods=["*"], 35 | allow_headers=["*"], 36 | ) 37 | 38 | # initial router 39 | current_app.include_router(api_router, prefix=settings.API_V1_STR) 40 | 41 | return current_app 42 | 43 | 44 | def log_config(): 45 | """ 46 | 日志配置 47 | """ 48 | max_log_size = 5 * 1024 * 1024 # 5 MB 49 | backup_count = 5 50 | formatter = logging.Formatter(settings.LOGGING_FORMAT) 51 | handler = RotatingFileHandler(settings.LOGGING_LOCATION, maxBytes=max_log_size, 52 | backupCount=backup_count, encoding='utf-8') 53 | handler.setFormatter(formatter) 54 | logging.basicConfig( 55 | level=settings.LOGGING_LEVEL, 56 | handlers=[handler] 57 | ) 58 | 59 | 60 | app = create_app() 61 | app.celery_app = celery 62 | 63 | log_config() 64 | logger = logging.getLogger(__name__) 65 | logger.info(f"Bonita version: {__version__}") 66 | init_db() 67 | init_service() 68 | -------------------------------------------------------------------------------- /backend/bonita/modules/download_clients/base_client.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import List, Any, Union, Optional, Dict, TypeVar, Generic 3 | 4 | 5 | class torrent_info(): 6 | id: int 7 | name: str 8 | hash: str 9 | downloadDir: str 10 | error: int 11 | errorString: str 12 | 13 | 14 | class BaseDownloadClient(ABC): 15 | """Base abstract class for download clients""" 16 | 17 | @abstractmethod 18 | def initialize(self, url: str, username: str, password: str) -> bool: 19 | """Initialize the client with connection parameters 20 | 21 | Args: 22 | url: Client server URL 23 | username: Client username 24 | password: Client password 25 | 26 | Returns: 27 | bool: True if initialization was successful 28 | """ 29 | pass 30 | 31 | @abstractmethod 32 | def login(self) -> Optional[Any]: 33 | """Attempt to login to the client 34 | 35 | Returns: 36 | Optional[Any]: Client session if successful, None if failed 37 | """ 38 | pass 39 | 40 | @abstractmethod 41 | def getTorrents(self, ids: List[int]) -> List[torrent_info]: 42 | """Get torrents from the client 43 | 44 | Args: 45 | ids: Torrent IDs to fetch (optional) 46 | 47 | Returns: 48 | List[torrent_file]: List of torrent objects 49 | """ 50 | pass 51 | 52 | @abstractmethod 53 | def searchByName(self, name: str) -> List[torrent_info]: 54 | """Search torrents by name 55 | 56 | Args: 57 | name: Torrent name to search for 58 | 59 | Returns: 60 | List[torrent_file]: List of matching torrent objects 61 | """ 62 | pass 63 | 64 | @abstractmethod 65 | def searchByPath(self, path: str) -> List[torrent_info]: 66 | """Search torrents by path 67 | 68 | Args: 69 | path: Path to search for 70 | 71 | Returns: 72 | List[torrent_file]: List of matching torrent objects 73 | """ 74 | pass 75 | 76 | @abstractmethod 77 | def deleteTorrent(self, torrent_id: int, delete: bool = False) -> None: 78 | """Remove a torrent 79 | 80 | Args: 81 | torrent_id: ID of the torrent to remove 82 | delete: Whether to delete the torrent data 83 | 84 | Returns: 85 | None 86 | """ 87 | pass 88 | -------------------------------------------------------------------------------- /backend/bonita/modules/monitor/handler.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Literal 2 | from watchdog.events import FileSystemEventHandler 3 | 4 | 5 | class MonitorHandler(FileSystemEventHandler): 6 | """File system event handler that monitors file changes and triggers corresponding tasks""" 7 | 8 | def __init__(self, callback_func: Callable, task_id: str, folder_type: Literal["source", "output"]): 9 | """ 10 | Initialize the file system monitor handler 11 | 12 | Args: 13 | callback_func: The callback function to execute when events occur 14 | task_id: The task identifier 15 | folder_type: Type of the monitored folder ("source" or "output") 16 | """ 17 | super().__init__() 18 | self.task_func = callback_func 19 | self.task_id = task_id 20 | self.folder_type = folder_type 21 | 22 | def on_created(self, event) -> None: 23 | """Handle file creation events""" 24 | self.task_func(event, self.task_id, event.src_path, self.folder_type) 25 | 26 | def on_moved(self, event) -> None: 27 | """Handle file move events""" 28 | self.task_func(event, self.task_id, event.dest_path, self.folder_type) 29 | 30 | def on_deleted(self, event) -> None: 31 | """Handle file deletion events""" 32 | self.task_func(event, self.task_id, event.src_path, self.folder_type) 33 | -------------------------------------------------------------------------------- /backend/bonita/modules/scraping/watermark/CNSUB.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Suwmlee/bonita/05c7a84ba4b9d88bba7f374000d07b776484f865/backend/bonita/modules/scraping/watermark/CNSUB.png -------------------------------------------------------------------------------- /backend/bonita/modules/scraping/watermark/HACK.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Suwmlee/bonita/05c7a84ba4b9d88bba7f374000d07b776484f865/backend/bonita/modules/scraping/watermark/HACK.png -------------------------------------------------------------------------------- /backend/bonita/modules/scraping/watermark/LEAK.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Suwmlee/bonita/05c7a84ba4b9d88bba7f374000d07b776484f865/backend/bonita/modules/scraping/watermark/LEAK.png -------------------------------------------------------------------------------- /backend/bonita/modules/scraping/watermark/UNCENSORED.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Suwmlee/bonita/05c7a84ba4b9d88bba7f374000d07b776484f865/backend/bonita/modules/scraping/watermark/UNCENSORED.png -------------------------------------------------------------------------------- /backend/bonita/schemas/__init__.py: -------------------------------------------------------------------------------- 1 | from .token import * 2 | from .response import * 3 | from .user import * 4 | from .task import * 5 | from .scraping import * 6 | from .record import * 7 | from .extrainfo import * 8 | from .metadata import * 9 | from .system import * 10 | from .mediaitem import * 11 | from .file_browser import * 12 | -------------------------------------------------------------------------------- /backend/bonita/schemas/extrainfo.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from pydantic import BaseModel 3 | 4 | 5 | class ExtraInfoBase(BaseModel): 6 | filepath: str 7 | number: str 8 | tag: Optional[str] = None 9 | partNumber: int = 0 10 | specifiedsource: Optional[str] = None 11 | specifiedurl: Optional[str] = None 12 | 13 | 14 | class ExtraInfoPublic(ExtraInfoBase): 15 | id: Optional[int] = None 16 | filepath: Optional[str] = None 17 | number: Optional[str] = None 18 | partNumber: Optional[int] = None 19 | 20 | class Config: 21 | from_attributes = True 22 | -------------------------------------------------------------------------------- /backend/bonita/schemas/file_browser.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | from pydantic import BaseModel 3 | from datetime import datetime 4 | 5 | 6 | class DirectoryBrowseRequest(BaseModel): 7 | """ 8 | 文件浏览请求参数 9 | """ 10 | directory_path: Optional[str] = None 11 | 12 | 13 | class FileInfo(BaseModel): 14 | """ 15 | 文件或目录信息 16 | """ 17 | name: str 18 | path: str 19 | is_dir: bool 20 | size: int = 0 21 | modified_time: Optional[datetime] = None 22 | 23 | 24 | class FileListResponse(BaseModel): 25 | """ 26 | 目录内文件列表响应 27 | """ 28 | data: List[FileInfo] 29 | current_path: str 30 | parent_path: Optional[str] = None -------------------------------------------------------------------------------- /backend/bonita/schemas/mediaitem.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | from pydantic import BaseModel 3 | from datetime import datetime 4 | 5 | 6 | class MediaItemBase(BaseModel): 7 | """ 8 | MediaItem的基础属性 9 | """ 10 | media_type: str 11 | title: str 12 | original_title: Optional[str] = None 13 | 14 | # 标识符 15 | imdb_id: Optional[str] = None 16 | tmdb_id: Optional[str] = None 17 | tvdb_id: Optional[str] = None 18 | number: Optional[str] = None 19 | 20 | # 剧集信息 21 | season_number: Optional[int] = None 22 | episode_number: Optional[int] = None 23 | 24 | 25 | class MediaItemCreate(MediaItemBase): 26 | """ 27 | 创建MediaItem时的属性 28 | """ 29 | series_id: Optional[int] = None 30 | 31 | 32 | class MediaItemUpdate(BaseModel): 33 | """ 34 | 更新MediaItem时的属性 35 | """ 36 | media_type: Optional[str] = None 37 | title: Optional[str] = None 38 | original_title: Optional[str] = None 39 | imdb_id: Optional[str] = None 40 | tmdb_id: Optional[str] = None 41 | tvdb_id: Optional[str] = None 42 | number: Optional[str] = None 43 | season_number: Optional[int] = None 44 | episode_number: Optional[int] = None 45 | series_id: Optional[int] = None 46 | 47 | 48 | class MediaItemInDB(MediaItemBase): 49 | """ 50 | 数据库中的MediaItem属性 51 | """ 52 | id: int 53 | series_id: Optional[int] = None 54 | createtime: datetime 55 | updatetime: datetime 56 | 57 | class Config: 58 | from_attributes = True 59 | 60 | 61 | class UserWatchData(BaseModel): 62 | """ 63 | 用户观看数据,用于嵌套在MediaItem中 64 | """ 65 | # 基本观看状态 66 | watched: Optional[bool] = False 67 | favorite: Optional[bool] = False 68 | 69 | # 详细观看信息 70 | total_plays: int = 0 71 | play_progress: Optional[float] = None 72 | duration: Optional[int] = None 73 | 74 | # 评分信息 75 | has_rating: Optional[bool] = False 76 | user_rating: Optional[float] = None 77 | 78 | # 时间信息 79 | last_played: Optional[datetime] = None 80 | watch_updatetime: Optional[datetime] = None 81 | 82 | class Config: 83 | from_attributes = True 84 | 85 | 86 | class MediaItemWithWatches(MediaItemInDB): 87 | """ 88 | 包含观看历史的MediaItem 89 | """ 90 | userdata: Optional[UserWatchData] = None 91 | 92 | class Config: 93 | from_attributes = True 94 | 95 | 96 | class MediaItemCollection(BaseModel): 97 | """ 98 | MediaItem集合,用于分页响应 99 | """ 100 | data: List[MediaItemWithWatches] 101 | count: int 102 | -------------------------------------------------------------------------------- /backend/bonita/schemas/metadata.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | from pydantic import BaseModel, model_validator 3 | from datetime import date, datetime 4 | 5 | 6 | class MetadataBase(BaseModel): 7 | number: str 8 | title: str 9 | studio: Optional[str] = None 10 | release: Optional[date] = None 11 | year: Optional[int] = None 12 | runtime: Optional[str] = None 13 | genre: Optional[str] = None 14 | rating: Optional[str] = None 15 | language: Optional[str] = None 16 | country: Optional[str] = None 17 | outline: Optional[str] = None 18 | director: Optional[str] = None 19 | actor: Optional[str] = None 20 | actor_photo: Optional[str] = None 21 | cover: str 22 | cover_small: Optional[str] = None 23 | extrafanart: Optional[str] = None 24 | trailer: Optional[str] = None 25 | tag: Optional[str] = None 26 | label: Optional[str] = None 27 | series: Optional[str] = None 28 | userrating: Optional[float] = None 29 | uservotes: Optional[int] = None 30 | detailurl: Optional[str] = None 31 | site: Optional[str] = None 32 | updatetime: Optional[datetime] = None 33 | 34 | @model_validator(mode='before') 35 | def process_fields(cls, values): 36 | if 'source' in values: 37 | values['site'] = values['source'] 38 | if 'website' in values: 39 | values['detailurl'] = values['website'] 40 | 41 | # 处理空字符串和可能导致异常的字段 42 | for field in ['release', 'year', 'userrating', 'uservotes']: 43 | if field in values and (values[field] == '' or values[field] == '0'): 44 | values[field] = None 45 | 46 | for field, value in values.items(): 47 | if value is None: 48 | continue 49 | 50 | if isinstance(value, dict): 51 | values[field] = str(value) 52 | elif isinstance(value, list): 53 | values[field] = ', '.join(map(str, value)) 54 | else: 55 | values[field] = value 56 | 57 | return values 58 | 59 | 60 | class MetadataCreate(MetadataBase): 61 | """用于创建元数据的模型""" 62 | pass 63 | 64 | 65 | class MetadataMixed(MetadataBase): 66 | """ 额外自定义信息,不止元数据内容 67 | """ 68 | extra_filename: Optional[str] = None 69 | extra_folder: Optional[str] = None 70 | extra_part: Optional[int] = None 71 | 72 | class Config: 73 | from_attributes = True 74 | 75 | 76 | class MetadataPublic(MetadataBase): 77 | id: int 78 | 79 | class Config: 80 | from_attributes = True 81 | 82 | 83 | class MetadataCollection(BaseModel): 84 | data: List[MetadataPublic] 85 | count: int 86 | -------------------------------------------------------------------------------- /backend/bonita/schemas/record.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | from pydantic import BaseModel 3 | from datetime import datetime 4 | 5 | from bonita.schemas.extrainfo import ExtraInfoPublic 6 | 7 | 8 | class TransferRecordBase(BaseModel): 9 | """ 10 | Shared properties 11 | """ 12 | srcname: str 13 | srcpath: str 14 | srcfolder: str 15 | task_id: int 16 | 17 | ignored: bool = False 18 | locked: bool = False 19 | deleted: bool = False 20 | srcdeleted: bool = False 21 | 22 | forced_name: Optional[str] = None 23 | top_folder: Optional[str] = None 24 | second_folder: Optional[str] = None 25 | isepisode: Optional[bool] = False 26 | season: Optional[int] = -1 27 | episode: Optional[int] = -1 28 | linkpath: Optional[str] = None 29 | destpath: Optional[str] = None 30 | 31 | updatetime: Optional[datetime] = None 32 | deadtime: Optional[datetime] = None 33 | 34 | class Config: 35 | from_attributes = True 36 | 37 | 38 | class TransferRecordPublic(TransferRecordBase): 39 | """ 40 | Properties to return via API, id is always required 41 | """ 42 | id: int 43 | 44 | class Config: 45 | from_attributes = True 46 | 47 | 48 | class TransferRecordsPublic(BaseModel): 49 | data: List[TransferRecordPublic] 50 | count: int 51 | 52 | 53 | class RecordPublic(BaseModel): 54 | transfer_record: TransferRecordPublic 55 | extra_info: Optional[ExtraInfoPublic] = None 56 | 57 | class Config: 58 | from_attributes = True 59 | 60 | class RecordsPublic(BaseModel): 61 | data: List[RecordPublic] 62 | count: int 63 | -------------------------------------------------------------------------------- /backend/bonita/schemas/response.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union 2 | from pydantic import BaseModel 3 | 4 | 5 | class Response(BaseModel): 6 | # 状态 7 | success: bool = True 8 | # 消息文本 9 | message: Optional[str] = None 10 | # 数据 11 | data: Optional[Union[dict, list]] = {} 12 | 13 | 14 | class StatusResponse(BaseModel): 15 | status: str 16 | version: str 17 | -------------------------------------------------------------------------------- /backend/bonita/schemas/scraping.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | from pydantic import BaseModel 3 | 4 | 5 | class ScrapingConfigBase(BaseModel): 6 | """ 7 | Shared properties 8 | """ 9 | name: str 10 | description: str 11 | save_metadata: Optional[bool] = False 12 | scraping_sites: Optional[str] = None 13 | location_rule: Optional[str] = None 14 | naming_rule: Optional[str] = None 15 | max_title_len: Optional[int] = None 16 | morestoryline: Optional[bool] = True 17 | extrafanart_enabled: Optional[bool] = False 18 | extrafanart_folder: Optional[str] = 'extrafanart' 19 | watermark_enabled: Optional[bool] = True 20 | watermark_size: Optional[int] = 9 21 | watermark_location: Optional[int] = 2 22 | transalte_enabled: Optional[bool] = False 23 | transalte_to_sc: Optional[bool] = False 24 | transalte_values: Optional[str] = "title,outline" 25 | 26 | 27 | class ScrapingConfigPublic(ScrapingConfigBase): 28 | """ 29 | Properties to return via API, id is always required 30 | """ 31 | id: int 32 | 33 | class Config: 34 | from_attributes = True 35 | 36 | 37 | class ScrapingConfigsPublic(BaseModel): 38 | data: List[ScrapingConfigPublic] 39 | count: int 40 | 41 | 42 | class ScrapingConfigCreate(ScrapingConfigBase): 43 | name: str 44 | -------------------------------------------------------------------------------- /backend/bonita/schemas/system.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List, Dict, Any 2 | from datetime import datetime 3 | from pydantic import BaseModel 4 | 5 | 6 | class SystemSettingBase(BaseModel): 7 | """ 8 | Shared properties 9 | """ 10 | key: str 11 | value: Optional[str] = None 12 | description: Optional[str] = None 13 | 14 | 15 | class SystemSettingCreate(SystemSettingBase): 16 | """ 17 | Properties to receive on item creation 18 | """ 19 | pass 20 | 21 | 22 | class SystemSettingUpdate(BaseModel): 23 | """ 24 | Properties to receive on item update 25 | """ 26 | value: Optional[str] = None 27 | description: Optional[str] = None 28 | 29 | 30 | class SystemSettingPublic(SystemSettingBase): 31 | """ 32 | Properties to return to client 33 | """ 34 | id: int 35 | updatetime: Optional[datetime] = None 36 | 37 | class Config: 38 | from_attributes = True 39 | 40 | 41 | class ProxySettings(BaseModel): 42 | """ 43 | Proxy settings schema 44 | """ 45 | http: Optional[str] = None 46 | https: Optional[str] = None 47 | enabled: Optional[bool] = False 48 | 49 | 50 | class EmbySettings(BaseModel): 51 | """ 52 | Emby settings schema 53 | """ 54 | emby_host: str 55 | emby_apikey: str 56 | emby_user: str 57 | enabled: Optional[bool] = False 58 | 59 | 60 | class JellyfinSettings(BaseModel): 61 | """ 62 | Jellyfin settings schema 63 | """ 64 | jellyfin_host: str 65 | jellyfin_apikey: str 66 | enabled: Optional[bool] = False 67 | 68 | 69 | class TransmissionSettings(BaseModel): 70 | """ 71 | Transmission downloader settings schema 72 | """ 73 | transmission_host: str 74 | transmission_username: str 75 | transmission_password: str 76 | transmission_source_path: Optional[str] = "" 77 | transmission_dest_path: Optional[str] = "" 78 | enabled: Optional[bool] = False 79 | 80 | 81 | class LogEntry(BaseModel): 82 | """单条日志信息""" 83 | timestamp: str 84 | level: str 85 | module: str 86 | message: str 87 | 88 | 89 | class LogResponse(BaseModel): 90 | """日志响应""" 91 | logs: list[LogEntry] 92 | total_lines: int 93 | current_page: int 94 | total_pages: int 95 | -------------------------------------------------------------------------------- /backend/bonita/schemas/task.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | from pydantic import BaseModel 3 | 4 | from bonita.utils.filehelper import OperationMethod 5 | 6 | 7 | class TaskBase(BaseModel): 8 | id: str 9 | name: Optional[str] = None 10 | transfer_config: Optional[int] = None 11 | scraping_config: Optional[int] = None 12 | 13 | 14 | class TaskStatus(TaskBase): 15 | status: Optional[str] = None 16 | detail: Optional[str] = None 17 | 18 | 19 | class TransferConfigBase(BaseModel): 20 | """ 21 | Shared properties 22 | """ 23 | name: str 24 | description: str 25 | enabled: bool = True 26 | content_type: int = 1 27 | operation: OperationMethod = OperationMethod.HARD_LINK 28 | auto_watch: bool = False 29 | clean_others: bool = False 30 | optimize_name: bool = False 31 | source_folder: str 32 | output_folder: str 33 | failed_folder: Optional[str] = None 34 | escape_folder: Optional[str] = None 35 | escape_literals: Optional[str] = None 36 | escape_size: Optional[int] = 1 37 | threads_num: Optional[int] = 1 38 | sc_enabled: bool = False 39 | sc_id: Optional[int] = None 40 | 41 | 42 | class TransferConfigPublic(TransferConfigBase): 43 | """ 44 | Properties to return via API, id is always required 45 | """ 46 | id: int 47 | 48 | class Config: 49 | from_attributes = True 50 | 51 | 52 | class TransferConfigsPublic(BaseModel): 53 | data: List[TransferConfigPublic] 54 | count: int 55 | 56 | 57 | class TransferConfigCreate(TransferConfigBase): 58 | operation: OperationMethod 59 | source_folder: str 60 | 61 | 62 | class TaskPathParam(BaseModel): 63 | path: Optional[str] = None 64 | 65 | 66 | class ToolArgsParam(BaseModel): 67 | """ 68 | 工具参数请求 69 | """ 70 | arg1: Optional[str] = None 71 | arg2: Optional[str] = None 72 | arg3: Optional[str] = None 73 | -------------------------------------------------------------------------------- /backend/bonita/schemas/token.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from pydantic import BaseModel 3 | 4 | 5 | # JSON payload containing access token 6 | class Token(BaseModel): 7 | access_token: str 8 | token_type: str = "bearer" 9 | 10 | 11 | class TokenPayload(BaseModel): 12 | # 用户ID 13 | sub: Optional[int] = None 14 | # 用户名 15 | username: Optional[str] = None 16 | # 超级用户 17 | super_user: Optional[bool] = None 18 | -------------------------------------------------------------------------------- /backend/bonita/schemas/user.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from pydantic import BaseModel, EmailStr, Field 3 | 4 | 5 | # Shared properties 6 | class UserBase(BaseModel): 7 | # 用户名 8 | name: str | None = Field(default=None, max_length=255) 9 | # 邮箱,未启用 10 | email: EmailStr = Field(unique=True, index=True, max_length=255) 11 | # 状态 12 | is_active: bool = True 13 | # 超级管理员 14 | is_superuser: bool = False 15 | 16 | 17 | # Properties to receive via API on creation 18 | class UserCreate(UserBase): 19 | password: str = Field(min_length=8, max_length=40) 20 | 21 | 22 | class UserRegister(BaseModel): 23 | email: EmailStr = Field(max_length=255) 24 | password: str = Field(min_length=8, max_length=40) 25 | name: str | None = Field(default=None, max_length=255) 26 | 27 | 28 | # Properties to receive via API on update, all are optional 29 | class UserUpdate(UserBase): 30 | email: EmailStr | None = Field(default=None, max_length=255) # type: ignore 31 | password: str | None = Field(default=None, min_length=8, max_length=40) 32 | 33 | 34 | class UserUpdateMe(BaseModel): 35 | name: str | None = Field(default=None, max_length=255) 36 | email: EmailStr | None = Field(default=None, max_length=255) 37 | 38 | 39 | class UpdatePassword(BaseModel): 40 | current_password: str = Field(min_length=8, max_length=40) 41 | new_password: str = Field(min_length=8, max_length=40) 42 | 43 | 44 | # Properties to return via API, id is always required 45 | class UserPublic(UserBase): 46 | id: int 47 | 48 | class Config: 49 | from_attributes = True 50 | 51 | 52 | class UsersPublic(BaseModel): 53 | data: List[UserPublic] 54 | count: int 55 | -------------------------------------------------------------------------------- /backend/bonita/utils/http.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Session 2 | 3 | from bonita.db.models.setting import SystemSetting 4 | 5 | 6 | def get_active_proxy(session: Session) -> dict: 7 | """获取系统代理设置 8 | 9 | 检索系统代理设置并返回适合 requests 库使用的代理配置 10 | 11 | Args: 12 | session: 数据库会话 13 | 14 | Returns: 15 | dict: 代理设置字典,格式为 {"http": "http://proxy.com:8080", "https": "http://proxy.com:8080"} 16 | 如果代理未启用,则返回 None 17 | """ 18 | # 一次性获取所有代理相关设置以减少数据库查询 19 | proxy_settings = session.query(SystemSetting).filter( 20 | SystemSetting.key.in_(["proxy_enabled", "proxy_http", "proxy_https"]) 21 | ).all() 22 | 23 | # 将查询结果转换为字典 24 | proxy_dict = {setting.key: setting.value for setting in proxy_settings} 25 | 26 | # 检查代理是否启用 27 | proxy_enabled = proxy_dict.get("proxy_enabled", "false").lower() == "true" 28 | 29 | if not proxy_enabled: 30 | return None 31 | 32 | # 构建代理配置 33 | proxy = {} 34 | proxy_http = proxy_dict.get("proxy_http") 35 | proxy_https = proxy_dict.get("proxy_https") 36 | 37 | if proxy_http: 38 | proxy["http"] = proxy_http 39 | if proxy_https: 40 | proxy["https"] = proxy_https 41 | 42 | # 如果没有配置任何代理服务器,也返回None 43 | return proxy if proxy else None 44 | -------------------------------------------------------------------------------- /backend/bonita/utils/singleton.py: -------------------------------------------------------------------------------- 1 | """ 2 | 单例模式 3 | """ 4 | from abc import ABCMeta 5 | 6 | 7 | class Singleton(ABCMeta): 8 | def __call__(cls, *args, **kwargs): 9 | if not hasattr(cls, '_instance'): 10 | cls._instance = super(Singleton, cls).__call__(*args, **kwargs) 11 | return cls._instance 12 | -------------------------------------------------------------------------------- /backend/bonita/worker.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from celery import Celery 4 | # load tasks 5 | from bonita.celery_tasks import tasks 6 | from bonita.core.config import settings 7 | 8 | def create_celery(): 9 | """ 10 | 配置 https://docs.celeryq.dev/en/stable/userguide/configuration.html#general-settings 11 | """ 12 | celery = Celery("bonita") 13 | celery.conf.broker_url = os.environ.get("CELERY_BROKER_URL", settings.CELERY_BROKER_URL) 14 | celery.conf.result_backend = os.environ.get("CELERY_RESULT_BACKEND", settings.CELERY_RESULT_BACKEND) 15 | 16 | celery.conf.update(timezone="Asia/Shanghai") # 时区 17 | celery.conf.update(enable_utc=False) # 关闭UTC时区。默认启动 18 | celery.conf.update(task_track_started=True) # 启动任务跟踪 19 | celery.conf.update(result_expires=200) # 结果过期时间,200s 20 | celery.conf.update(result_persistent=True) 21 | celery.conf.update(worker_send_task_events=False) 22 | celery.conf.update(worker_prefetch_multiplier=1) 23 | celery.conf.update(broker_connection_retry_on_startup=True) # 启动时重试代理连接 24 | celery.conf.update(worker_log_format=settings.LOGGING_FORMAT) # 日志格式 25 | celery.conf.update(worker_task_log_format=settings.LOGGING_FORMAT) # 任务日志格式 26 | celery.conf.update(worker_logfile=settings.LOGGING_LOCATION) # 日志文件路径 27 | 28 | # Set up scheduled tasks 29 | celery.conf.beat_schedule = { 30 | # Sync watch history from all sources daily 31 | 'sync-watch-history-daily': { 32 | 'task': 'watch_history:sync', 33 | 'schedule': 86400.0, # 24 hours in seconds 34 | 'args': (None, 30, 100), # sources=None, days=30, limit=100 35 | }, 36 | } 37 | 38 | return celery 39 | 40 | 41 | celery = create_celery() 42 | -------------------------------------------------------------------------------- /backend/data/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Suwmlee/bonita/05c7a84ba4b9d88bba7f374000d07b776484f865/backend/data/.gitkeep -------------------------------------------------------------------------------- /backend/requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi==0.112.2 2 | uvicorn[standard]==0.30.6 3 | pydantic-settings==2.4.0 4 | pydantic[email]==2.8.2 5 | python-jose==3.4.0 6 | sqlalchemy==2.0.32 7 | bcrypt==4.2.0 8 | python-multipart==0.0.20 9 | alembic==1.13.2 10 | celery==5.4.0 11 | redis==5.0.8 12 | watchdog==6.0.0 13 | scrapinglib 14 | pillow==11.1.0 15 | transmission_rpc==7.0.11 16 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | # 构建前端 2 | FROM node:20-slim AS frontend-build 3 | WORKDIR /app/frontend 4 | COPY ../frontend/package*.json ./ 5 | RUN npm install 6 | COPY ../frontend/ . 7 | RUN npm run build:icons 8 | RUN npm run build 9 | 10 | # 构建 Bonita with s6-overlay 11 | FROM python:3.12-slim-bullseye AS backend-build 12 | 13 | # s6-overlay 版本 14 | ARG S6_OVERLAY_VERSION=3.1.6.2 15 | 16 | ENV TZ=Asia/Shanghai 17 | ENV MAX_CONCURRENCY=5 18 | # 添加用户配置环境变量 19 | ENV PUID=0 20 | ENV PGID=0 21 | # s6-overlay 环境变量 22 | ENV S6_KEEP_ENV=1 23 | 24 | RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && \ 25 | echo $TZ > /etc/timezone 26 | 27 | # 安装 s6-overlay 28 | ADD https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz /tmp 29 | ADD https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-x86_64.tar.xz /tmp 30 | RUN apt-get update && \ 31 | apt-get install -y --no-install-recommends xz-utils && \ 32 | tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz && \ 33 | tar -C / -Jxpf /tmp/s6-overlay-x86_64.tar.xz && \ 34 | rm -rf /tmp/* && \ 35 | apt-get clean && \ 36 | rm -rf /var/lib/apt/lists/* 37 | 38 | WORKDIR /app/backend 39 | 40 | # 只复制依赖文件,利用缓存层 41 | COPY ../backend/requirements.txt . 42 | RUN pip install --no-cache-dir -r requirements.txt 43 | 44 | COPY ../backend/ . 45 | 46 | # 从前端构建阶段复制静态文件 47 | COPY --from=frontend-build /app/frontend/dist /app/frontend/dist 48 | 49 | # 安装精简版nginx 50 | RUN apt-get update && \ 51 | apt-get install -y --no-install-recommends nginx curl && \ 52 | apt-get clean && \ 53 | rm -rf /var/lib/apt/lists/* 54 | 55 | # 创建应用用户和相关目录 56 | RUN echo "**** create tomoki user and make folders ****" && \ 57 | groupmod -g 1000 users && \ 58 | useradd -u 911 -U -d /config -s /bin/false tomoki && \ 59 | usermod -G users tomoki && \ 60 | mkdir /config 61 | 62 | # 复制nginx配置 63 | COPY ../docker/nginx.conf /etc/nginx/nginx.conf 64 | 65 | # 复制 s6-overlay 配置文件 66 | COPY ../docker/s6-rc.d /etc/s6-overlay/s6-rc.d/ 67 | 68 | EXPOSE 12346 69 | 70 | ENTRYPOINT ["/init"] 71 | -------------------------------------------------------------------------------- /docker/nginx.conf: -------------------------------------------------------------------------------- 1 | worker_processes 1; 2 | 3 | events { 4 | worker_connections 1024; 5 | } 6 | 7 | http { 8 | include mime.types; 9 | default_type application/octet-stream; 10 | 11 | server { 12 | listen 12346; 13 | 14 | # 访问静态文件 15 | location / { 16 | root /app/frontend/dist; 17 | try_files $uri $uri/ /index.html; 18 | } 19 | 20 | # WebSocket 代理配置 21 | location /api/v1/ws { 22 | proxy_pass http://localhost:8000; 23 | proxy_http_version 1.1; 24 | proxy_set_header Upgrade $http_upgrade; 25 | proxy_set_header Connection "upgrade"; 26 | proxy_read_timeout 86400; 27 | } 28 | 29 | # 代理 API 请求到 bonita 30 | location /api { 31 | proxy_pass http://localhost:8000; 32 | proxy_set_header Host $host; 33 | proxy_set_header X-Real-IP $remote_addr; 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /docker/s6-rc.d/bonita/dependencies.d/init-adduser: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Suwmlee/bonita/05c7a84ba4b9d88bba7f374000d07b776484f865/docker/s6-rc.d/bonita/dependencies.d/init-adduser -------------------------------------------------------------------------------- /docker/s6-rc.d/bonita/run: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 启动nginx 4 | nginx 5 | 6 | # 启动FastAPI,确保数据库初始化完成 7 | cd /app/backend 8 | 9 | echo "启动FastAPI服务..." 10 | exec s6-setuidgid tomoki uvicorn bonita.main:app --host 0.0.0.0 --port 8000 & 11 | UVICORN_PID=$! 12 | 13 | # 等待FastAPI启动完成 14 | echo "等待FastAPI服务启动和数据库初始化..." 15 | until $(curl -X GET --output /dev/null --silent --fail http://localhost:8000/api/v1/status/health); do 16 | printf "." 17 | sleep 2 18 | done 19 | echo "FastAPI服务已启动,数据库初始化完成" 20 | 21 | # 启动Celery 22 | echo "启动Celery worker..." 23 | exec s6-setuidgid tomoki celery -A bonita.worker.celery worker --pool threads --concurrency $MAX_CONCURRENCY --events --loglevel DEBUG 24 | -------------------------------------------------------------------------------- /docker/s6-rc.d/bonita/type: -------------------------------------------------------------------------------- 1 | longrun 2 | -------------------------------------------------------------------------------- /docker/s6-rc.d/init-adduser/run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/with-contenv bash 2 | # shellcheck shell=bash 3 | 4 | PUID=${PUID:-911} 5 | PGID=${PGID:-911} 6 | 7 | groupmod -o -g "$PGID" tomoki 8 | usermod -o -u "$PUID" tomoki 9 | 10 | echo " 11 | ------------------------------------- 12 | /$$ /$$ /$$ 13 | | $$ |__/ | $$ 14 | | $$$$$$$ /$$$$$$ /$$$$$$$ /$$ /$$$$$$ /$$$$$$ 15 | | $$__ $$ /$$__ $$| $$__ $$| $$|_ $$_/ |____ $$ 16 | | $$ \ $$| $$ \ $$| $$ \ $$| $$ | $$ /$$$$$$$ 17 | | $$ | $$| $$ | $$| $$ | $$| $$ | $$ /$$ /$$__ $$ 18 | | $$$$$$$/| $$$$$$/| $$ | $$| $$ | $$$$/| $$$$$$$ 19 | |_______/ \______/ |__/ |__/|__/ \___/ \_______/ 20 | 21 | Starting with 22 | User uid: $(id -u tomoki) 23 | User gid: $(id -g tomoki) 24 | ------------------------------------- 25 | " 26 | 27 | chown tomoki:tomoki /config 28 | chown tomoki:tomoki /app 29 | 30 | chown -R tomoki /app/backend/data 31 | chmod -R u+rwx /app/backend/data 32 | -------------------------------------------------------------------------------- /docker/s6-rc.d/init-adduser/type: -------------------------------------------------------------------------------- 1 | oneshot 2 | -------------------------------------------------------------------------------- /docker/s6-rc.d/init-adduser/up: -------------------------------------------------------------------------------- 1 | sh /etc/s6-overlay/s6-rc.d/init-adduser/run 2 | -------------------------------------------------------------------------------- /docker/s6-rc.d/user/contents.d/bonita: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Suwmlee/bonita/05c7a84ba4b9d88bba7f374000d07b776484f865/docker/s6-rc.d/user/contents.d/bonita -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Log files 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .DS_Store 12 | dist 13 | dist-ssr 14 | *.local 15 | 16 | /cypress/videos/ 17 | /cypress/screenshots/ 18 | 19 | # Editor directories and files 20 | .vscode/* 21 | !.vscode/extensions.json 22 | !.vscode/settings.json 23 | !.vscode/*.code-snippets 24 | !.vscode/tours 25 | .idea 26 | *.suo 27 | *.ntvs* 28 | *.njsproj 29 | *.sln 30 | *.sw? 31 | .yarn 32 | 33 | # iconify dist files 34 | src/plugins/iconify/build-icons.js 35 | src/plugins/iconify/icons.css 36 | 37 | # Custom ignores 38 | *.d.ts 39 | *.d.json 40 | package-lock.json 41 | openapi.json 42 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | 2 | ## Bonita frontend 3 | 4 | ### 安装 5 | 6 | ```sh 7 | # 使用 nvm 管理 node 8 | nvm ls 9 | 10 | # 安装并使用 11 | nvm install 20 12 | nvm use 20 13 | 14 | # 安装依赖 15 | npm install 16 | 17 | npm run dev 18 | ``` 19 | 20 | ### 更新 .env 21 | 22 | ```sh 23 | # dev 24 | VITE_API_URL="http://localhost:8000" 25 | ``` 26 | 27 | ### icon 28 | 29 | https://boxicons.com/ 30 | 31 | ### 更新 client API 32 | 33 | http://localhost:8000/api/v1/openapi.json 34 | 35 | ```sh 36 | node modify-openapi-operationids.js 37 | 38 | npm run generate-client 39 | ``` 40 | -------------------------------------------------------------------------------- /frontend/biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.6.1/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "files": { 7 | "ignore": [ 8 | ".vscode", 9 | "dist", 10 | "node_modules", 11 | "src/client", 12 | "src/@core", 13 | "src/@layouts", 14 | "src/plugins/iconify", 15 | "src/*.d.ts" 16 | ] 17 | }, 18 | "linter": { 19 | "enabled": true, 20 | "rules": { 21 | "recommended": true, 22 | "suspicious": { 23 | "noExplicitAny": "off", 24 | "noArrayIndexKey": "off" 25 | }, 26 | "style": { 27 | "noNonNullAssertion": "off" 28 | } 29 | } 30 | }, 31 | "formatter": { 32 | "indentStyle": "space" 33 | }, 34 | "javascript": { 35 | "formatter": { 36 | "quoteStyle": "double", 37 | "semicolons": "asNeeded" 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Bonita 9 | 10 | 11 | 12 |
13 | 70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 | 79 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /frontend/modify-openapi-operationids.js: -------------------------------------------------------------------------------- 1 | import * as fs from "node:fs" 2 | 3 | async function modifyOpenAPIFile(filePath) { 4 | try { 5 | const data = await fs.promises.readFile(filePath) 6 | const openapiContent = JSON.parse(data) 7 | 8 | const paths = openapiContent.paths 9 | for (const pathKey of Object.keys(paths)) { 10 | const pathData = paths[pathKey] 11 | for (const method of Object.keys(pathData)) { 12 | const operation = pathData[method] 13 | if (operation.tags && operation.tags.length > 0) { 14 | const tag = operation.tags[0] 15 | const operationId = operation.operationId 16 | const toRemove = `${tag}-` 17 | if (operationId.startsWith(toRemove)) { 18 | const newOperationId = operationId.substring(toRemove.length) 19 | operation.operationId = newOperationId 20 | } 21 | } 22 | } 23 | } 24 | 25 | await fs.promises.writeFile( 26 | filePath, 27 | JSON.stringify(openapiContent, null, 2), 28 | ) 29 | console.log("File successfully modified") 30 | } catch (err) { 31 | console.error("Error:", err) 32 | } 33 | } 34 | 35 | const filePath = "./openapi.json" 36 | modifyOpenAPIFile(filePath) 37 | -------------------------------------------------------------------------------- /frontend/openapi-ts.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "@hey-api/openapi-ts" 2 | 3 | export default defineConfig({ 4 | input: "./openapi.json", 5 | output: { 6 | format: "prettier", 7 | path: "./src/client", 8 | }, 9 | client: "axios", 10 | services: { 11 | asClass: true, 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bonita", 3 | "private": true, 4 | "version": "0.1.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "lint": "biome check --write --unsafe --no-errors-on-unmatched --files-ignore-unknown=true ./", 10 | "build:icons": "tsx src/plugins/iconify/build-icons.ts", 11 | "generate-client": "openapi-ts" 12 | }, 13 | "dependencies": { 14 | "@mdi/font": "6.2.95", 15 | "@vueuse/core": "^10.11.0", 16 | "axios": "^1.7.2", 17 | "core-js": "^3.34.0", 18 | "roboto-fontface": "*", 19 | "vue": "^3.4.21", 20 | "vue-i18n": "^9.14.3", 21 | "vue3-perfect-scrollbar": "^1.6.1", 22 | "vuetify": "^3.5.8" 23 | }, 24 | "devDependencies": { 25 | "@babel/types": "^7.24.0", 26 | "@biomejs/biome": "1.8.3", 27 | "@hey-api/openapi-ts": "^0.48.0", 28 | "@iconify-json/bx": "^1.1.10", 29 | "@iconify-json/bxl": "^1.1.10", 30 | "@iconify-json/bxs": "^1.1.10", 31 | "@iconify-json/fa": "^1.1.9", 32 | "@iconify-json/mdi": "^1.1.67", 33 | "@iconify/tools": "^4.0.5", 34 | "@iconify/utils": "^2.1.32", 35 | "@iconify/vue": "^4.1.2", 36 | "@types/node": "^20.11.25", 37 | "@vitejs/plugin-vue": "^5.0.4", 38 | "pinia": "^2.1.7", 39 | "sass": "1.77.6", 40 | "tsx": "^4.18.0", 41 | "typescript": "^5.4.2", 42 | "unplugin-auto-import": "^0.17.5", 43 | "unplugin-vue-components": "^0.26.0", 44 | "vite": "^5.1.5", 45 | "vite-plugin-vue-layouts": "^0.11.0", 46 | "vite-plugin-vuetify": "^2.0.3", 47 | "vite-svg-loader": "^5.1.0", 48 | "vue-router": "^4.3.0", 49 | "vue-tsc": "^2.0.6" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Suwmlee/bonita/05c7a84ba4b9d88bba7f374000d07b776484f865/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/src/@core/components/MoreBtn.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 29 | -------------------------------------------------------------------------------- /frontend/src/@core/components/ThemeSwitcher.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 34 | -------------------------------------------------------------------------------- /frontend/src/@core/components/cards/CardStatisticsHorizontal.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 49 | -------------------------------------------------------------------------------- /frontend/src/@core/components/cards/CardStatisticsVertical.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 57 | -------------------------------------------------------------------------------- /frontend/src/@core/components/cards/CardStatisticsWithImages.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 58 | 59 | 66 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/base/_dark.scss: -------------------------------------------------------------------------------- 1 | @use "@configured-variables" as variables; 2 | 3 | // ———————————————————————————————————— 4 | // * ——— Perfect Scrollbar 5 | // ———————————————————————————————————— 6 | 7 | body.v-theme--dark { 8 | .ps__rail-y, 9 | .ps__rail-x { 10 | background-color: transparent !important; 11 | } 12 | 13 | .ps__thumb-y { 14 | background-color: variables.$plugin-ps-thumb-y-dark; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/base/_default-layout.scss: -------------------------------------------------------------------------------- 1 | @use "@core/scss/base/placeholders"; 2 | @use "@core/scss/base/variables"; 3 | 4 | .layout-vertical-nav, 5 | .layout-horizontal-nav { 6 | ol, 7 | ul { 8 | list-style: none; 9 | } 10 | } 11 | 12 | .layout-navbar { 13 | @if variables.$navbar-high-emphasis-text { 14 | @extend %layout-navbar; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/base/_index.scss: -------------------------------------------------------------------------------- 1 | @use "sass:map"; 2 | 3 | // Layout 4 | @use "vertical-nav"; 5 | @use "default-layout"; 6 | @use "default-layout-w-vertical-nav"; 7 | 8 | // Layouts package 9 | @use "layouts"; 10 | 11 | // Components 12 | @use "components"; 13 | 14 | // Utilities 15 | @use "utilities"; 16 | 17 | // Misc 18 | @use "misc"; 19 | 20 | // Dark 21 | @use "dark"; 22 | 23 | // libs 24 | @use "libs/perfect-scrollbar"; 25 | 26 | a { 27 | color: rgb(var(--v-theme-primary)); 28 | text-decoration: none; 29 | } 30 | 31 | // Vuetify 3 don't provide margin bottom style like vuetify 2 32 | p { 33 | margin-block-end: 1rem; 34 | } 35 | 36 | // Iconify icon size 37 | svg.iconify { 38 | block-size: 1em; 39 | inline-size: 1em; 40 | } 41 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/base/_layouts.scss: -------------------------------------------------------------------------------- 1 | @use "@configured-variables" as variables; 2 | 3 | /* ℹ️ This styles extends the existing layout package's styles for handling cases that aren't related to layouts package */ 4 | 5 | /* 6 | ℹ️ When we use v-layout as immediate first child of `.page-content-container`, it adds display:flex and page doesn't get contained height 7 | */ 8 | // .layout-wrapper.layout-nav-type-vertical { 9 | // &.layout-content-height-fixed { 10 | // .page-content-container { 11 | // > .v-layout:first-child > :not(.v-navigation-drawer):first-child { 12 | // flex-grow: 1; 13 | // block-size: 100%; 14 | // } 15 | // } 16 | // } 17 | // } 18 | .layout-wrapper.layout-nav-type-vertical { 19 | &.layout-content-height-fixed { 20 | .page-content-container { 21 | > .v-layout:first-child { 22 | overflow: hidden; 23 | min-block-size: 100%; 24 | 25 | > .v-main { 26 | // overflow-y: auto; 27 | 28 | .v-main__wrap > :first-child { 29 | block-size: 100%; 30 | overflow-y: auto; 31 | } 32 | } 33 | } 34 | } 35 | } 36 | } 37 | 38 | // ℹ️ Let div/v-layout take full height. E.g. Email App 39 | .layout-wrapper.layout-nav-type-horizontal { 40 | &.layout-content-height-fixed { 41 | > .layout-page-content { 42 | display: flex; 43 | } 44 | } 45 | } 46 | 47 | // 👉 Floating navbar styles 48 | @if variables.$vertical-nav-navbar-style == "floating" { 49 | // ℹ️ Add spacing above navbar if navbar is floating (was in %layout-navbar-sticky placeholder) 50 | body .layout-wrapper.layout-nav-type-vertical.layout-navbar-sticky { 51 | .layout-navbar { 52 | inset-block-start: variables.$vertical-nav-floating-navbar-top; 53 | } 54 | 55 | /* 56 | ℹ️ If it's floating navbar 57 | Add `vertical-nav-floating-navbar-top` as margin top to .layout-page-content 58 | */ 59 | .layout-page-content { 60 | margin-block-start: variables.$vertical-nav-floating-navbar-top; 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/base/_misc.scss: -------------------------------------------------------------------------------- 1 | // ℹ️ scrollable-content allows creating fixed header and scrollable content for VNavigationDrawer (Used when perfect scrollbar is used) 2 | .scrollable-content { 3 | &.v-navigation-drawer { 4 | .v-navigation-drawer__content { 5 | display: flex; 6 | overflow: hidden; 7 | flex-direction: column; 8 | } 9 | } 10 | } 11 | 12 | // ℹ️ adding styling for code tag 13 | code { 14 | border-radius: 3px; 15 | color: rgb(var(--v-code-color)); 16 | font-size: 90%; 17 | font-weight: 400; 18 | padding-block: 0.2em; 19 | padding-inline: 0.4em; 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/base/_mixins.scss: -------------------------------------------------------------------------------- 1 | @use "sass:map"; 2 | @use "@styles/variables/vuetify.scss"; 3 | 4 | @mixin elevation($z, $important: false) { 5 | box-shadow: map.get(vuetify.$shadow-key-umbra, $z), map.get(vuetify.$shadow-key-penumbra, $z), map.get(vuetify.$shadow-key-ambient, $z) if($important, !important, null); 6 | } 7 | 8 | // #region before-pseudo 9 | // ℹ️ This mixin is inspired from vuetify for adding hover styles via before pseudo element 10 | @mixin before-pseudo() { 11 | position: relative; 12 | 13 | &::before { 14 | position: absolute; 15 | border-radius: inherit; 16 | background: currentcolor; 17 | block-size: 100%; 18 | content: ""; 19 | inline-size: 100%; 20 | inset: 0; 21 | opacity: 0; 22 | pointer-events: none; 23 | } 24 | } 25 | 26 | // #endregion before-pseudo 27 | 28 | @mixin bordered-skin($component, $border-property: "border", $important: false) { 29 | #{$component} { 30 | box-shadow: none !important; 31 | // stylelint-disable-next-line annotation-no-unknown 32 | #{$border-property}: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)) if($important, !important, null); 33 | } 34 | } 35 | 36 | // #region selected-states 37 | // ℹ️ Inspired from vuetify's active-states mixin 38 | // focus => 0.12 & selected => 0.08 39 | @mixin selected-states($selector) { 40 | #{$selector} { 41 | opacity: calc(var(--v-selected-opacity) * var(--v-theme-overlay-multiplier)); 42 | } 43 | 44 | &:hover 45 | #{$selector} { 46 | opacity: calc(var(--v-selected-opacity) + var(--v-hover-opacity) * var(--v-theme-overlay-multiplier)); 47 | } 48 | 49 | &:focus-visible 50 | #{$selector} { 51 | opacity: calc(var(--v-selected-opacity) + var(--v-focus-opacity) * var(--v-theme-overlay-multiplier)); 52 | } 53 | 54 | @supports not selector(:focus-visible) { 55 | &:focus { 56 | #{$selector} { 57 | opacity: calc(var(--v-selected-opacity) + var(--v-focus-opacity) * var(--v-theme-overlay-multiplier)); 58 | } 59 | } 60 | } 61 | } 62 | 63 | // #endregion selected-states 64 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/base/_utils.scss: -------------------------------------------------------------------------------- 1 | @use "sass:map"; 2 | @use "sass:list"; 3 | @use "@configured-variables" as variables; 4 | 5 | // Thanks: https://css-tricks.com/snippets/sass/deep-getset-maps/ 6 | @function map-deep-get($map, $keys...) { 7 | @each $key in $keys { 8 | $map: map.get($map, $key); 9 | } 10 | 11 | @return $map; 12 | } 13 | 14 | @function map-deep-set($map, $keys, $value) { 15 | $maps: ($map,); 16 | $result: null; 17 | 18 | // If the last key is a map already 19 | // Warn the user we will be overriding it with $value 20 | @if type-of(nth($keys, -1)) == "map" { 21 | @warn "The last key you specified is a map; it will be overrided with `#{$value}`."; 22 | } 23 | 24 | // If $keys is a single key 25 | // Just merge and return 26 | @if length($keys) == 1 { 27 | @return map-merge($map, ($keys: $value)); 28 | } 29 | 30 | // Loop from the first to the second to last key from $keys 31 | // Store the associated map to this key in the $maps list 32 | // If the key doesn't exist, throw an error 33 | @for $i from 1 through length($keys) - 1 { 34 | $current-key: list.nth($keys, $i); 35 | $current-map: list.nth($maps, -1); 36 | $current-get: map.get($current-map, $current-key); 37 | 38 | @if not $current-get { 39 | @error "Key `#{$key}` doesn't exist at current level in map."; 40 | } 41 | 42 | $maps: list.append($maps, $current-get); 43 | } 44 | 45 | // Loop from the last map to the first one 46 | // Merge it with the previous one 47 | @for $i from length($maps) through 1 { 48 | $current-map: list.nth($maps, $i); 49 | $current-key: list.nth($keys, $i); 50 | $current-val: if($i == list.length($maps), $value, $result); 51 | $result: map.map-merge($current-map, ($current-key: $current-val)); 52 | } 53 | 54 | // Return result 55 | @return $result; 56 | } 57 | 58 | // font size utility classes 59 | @each $name, $size in variables.$font-sizes { 60 | .text-#{$name} { 61 | font-size: $size; 62 | line-height: map.get(variables.$font-line-height, $name); 63 | } 64 | } 65 | 66 | // truncate utility class 67 | .truncate { 68 | overflow: hidden; 69 | text-overflow: ellipsis; 70 | white-space: nowrap; 71 | } 72 | 73 | // gap utility class 74 | @each $name, $size in variables.$gap { 75 | .gap-#{$name} { 76 | gap: $size; 77 | } 78 | 79 | .gap-x-#{$name} { 80 | column-gap: $size; 81 | } 82 | 83 | .gap-y-#{$name} { 84 | row-gap: $size; 85 | } 86 | } 87 | 88 | .list-none { 89 | list-style-type: none; 90 | } 91 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/base/libs/_perfect-scrollbar.scss: -------------------------------------------------------------------------------- 1 | $ps-size: 0.25rem; 2 | $ps-hover-size: 0.375rem; 3 | $ps-track-size: 0.5rem; 4 | 5 | .ps__thumb-y { 6 | inline-size: $ps-size !important; 7 | inset-inline-end: 0.0625rem; 8 | } 9 | 10 | .ps__thumb-y, 11 | .ps__thumb-x { 12 | background-color: rgb(var(--v-theme-perfect-scrollbar-thumb)) !important; 13 | } 14 | 15 | .ps__thumb-x { 16 | block-size: $ps-size !important; 17 | } 18 | 19 | .ps__rail-x { 20 | background: transparent !important; 21 | block-size: $ps-track-size; 22 | } 23 | 24 | .ps__rail-y { 25 | background: transparent !important; 26 | inline-size: $ps-track-size !important; 27 | inset-inline-end: 0.125rem !important; 28 | inset-inline-start: unset !important; 29 | } 30 | 31 | .ps__rail-y.ps--clicking .ps__thumb-y, 32 | .ps__rail-y:focus > .ps__thumb-y, 33 | .ps__rail-y:hover > .ps__thumb-y { 34 | inline-size: $ps-hover-size !important; 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/base/libs/vuetify/_index.scss: -------------------------------------------------------------------------------- 1 | @use "overrides"; 2 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/base/libs/vuetify/_variables.scss: -------------------------------------------------------------------------------- 1 | @use "sass:map"; 2 | 3 | /* 👉 Shadow opacities */ 4 | $shadow-key-umbra-opacity-custom: var(--v-shadow-key-umbra-opacity); 5 | $shadow-key-penumbra-opacity-custom: var(--v-shadow-key-penumbra-opacity); 6 | $shadow-key-ambient-opacity-custom: var(--v-shadow-key-ambient-opacity); 7 | 8 | /* 👉 Card transition properties */ 9 | $card-transition-property-custom: box-shadow, opacity; 10 | 11 | @forward "vuetify/settings" with ( 12 | // 👉 General settings 13 | $color-pack: false !default, 14 | 15 | // 👉 Shadow opacity 16 | $shadow-key-umbra-opacity: $shadow-key-umbra-opacity-custom !default, 17 | $shadow-key-penumbra-opacity: $shadow-key-penumbra-opacity-custom !default, 18 | $shadow-key-ambient-opacity: $shadow-key-ambient-opacity-custom !default, 19 | 20 | // 👉 Card 21 | $card-color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)) !default, 22 | $card-elevation: 6 !default, 23 | $card-title-line-height: 1.6 !default, 24 | $card-actions-min-height: unset !default, 25 | $card-text-padding: 1.25rem !default, 26 | $card-item-padding: 1.25rem !default, 27 | $card-actions-padding: 0 12px 12px !default, 28 | $card-transition-property: $card-transition-property-custom !default, 29 | $card-subtitle-opacity: 1 !default, 30 | 31 | // 👉 Expansion Panel 32 | $expansion-panel-active-title-min-height: 48px !default, 33 | 34 | // 👉 List 35 | $list-item-icon-margin-end: 16px !default, 36 | $list-item-icon-margin-start: 16px !default, 37 | $list-item-subtitle-opacity: 1 !default, 38 | 39 | // 👉 Navigation Drawer 40 | $navigation-drawer-content-overflow-y: hidden !default, 41 | 42 | // 👉 Tooltip 43 | $tooltip-background-color: rgba(59, 55, 68, 0.9) !default, 44 | $tooltip-text-color: rgb(var(--v-theme-on-primary)) !default, 45 | $tooltip-font-size: 0.75rem !default, 46 | 47 | $button-icon-density: ("default": 2, "comfortable": 0, "compact": -1 ) !default, 48 | 49 | // 👉 VTimeline 50 | $timeline-dot-size: 34px !default, 51 | 52 | // 👉 table 53 | $table-transition-property: height !default, 54 | 55 | // 👉 VOverlay 56 | $overlay-opacity: 1 !default, 57 | 58 | // 👉 VContainer 59 | $container-max-widths: ( 60 | "xl": 1440px, 61 | "xxl": 1440px 62 | ) !default, 63 | 64 | ); 65 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/base/placeholders/_default-layout-vertical-nav.scss: -------------------------------------------------------------------------------- 1 | @use "@configured-variables" as variables; 2 | @use "misc"; 3 | @use "@core/scss/base/mixins"; 4 | 5 | %default-layout-vertical-nav-scrolled-sticky-elevated-nav { 6 | background-color: rgb(var(--v-theme-surface)); 7 | } 8 | 9 | %default-layout-vertical-nav-floating-navbar-and-sticky-elevated-navbar-scrolled { 10 | @include mixins.elevation(variables.$vertical-nav-navbar-elevation); 11 | 12 | // If navbar is contained => Squeeze navbar content on scroll 13 | @if variables.$layout-vertical-nav-navbar-is-contained { 14 | padding-inline: 1.2rem; 15 | } 16 | } 17 | 18 | %default-layout-vertical-nav-floating-navbar-overlay { 19 | isolation: isolate; 20 | 21 | &::after { 22 | position: absolute; 23 | z-index: -1; 24 | /* stylelint-disable property-no-vendor-prefix */ 25 | -webkit-backdrop-filter: blur(10px); 26 | backdrop-filter: blur(10px); 27 | /* stylelint-enable */ 28 | background: 29 | linear-gradient( 30 | 180deg, 31 | rgba(var(--v-theme-background), 70%) 44%, 32 | rgba(var(--v-theme-background), 43%) 73%, 33 | rgba(var(--v-theme-background), 0%) 34 | ); 35 | background-repeat: repeat; 36 | block-size: calc(variables.$layout-vertical-nav-navbar-height + variables.$vertical-nav-floating-navbar-top + 0.5rem); 37 | content: ""; 38 | inset-block-start: -(variables.$vertical-nav-floating-navbar-top); 39 | inset-inline: 0 0; 40 | /* stylelint-disable property-no-vendor-prefix */ 41 | -webkit-mask: linear-gradient(black, black 18%, transparent 100%); 42 | mask: linear-gradient(black, black 18%, transparent 100%); 43 | /* stylelint-enable */ 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/base/placeholders/_default-layout.scss: -------------------------------------------------------------------------------- 1 | %layout-navbar { 2 | color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)); 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/base/placeholders/_index.scss: -------------------------------------------------------------------------------- 1 | @forward "vertical-nav"; 2 | @forward "nav"; 3 | @forward "default-layout"; 4 | @forward "default-layout-vertical-nav"; 5 | @forward "misc"; 6 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/base/placeholders/_misc.scss: -------------------------------------------------------------------------------- 1 | %blurry-bg { 2 | /* stylelint-disable property-no-vendor-prefix */ 3 | -webkit-backdrop-filter: blur(6px); 4 | backdrop-filter: blur(6px); 5 | /* stylelint-enable */ 6 | background-color: rgb(var(--v-theme-surface), 0.9); 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/base/placeholders/_nav.scss: -------------------------------------------------------------------------------- 1 | @use "@core/scss/base/mixins"; 2 | 3 | // ℹ️ This is common style that needs to be applied to both navs 4 | %nav { 5 | color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)); 6 | 7 | .nav-item-title { 8 | letter-spacing: 0.15px; 9 | } 10 | 11 | .nav-section-title { 12 | letter-spacing: 0.4px; 13 | } 14 | } 15 | 16 | /* 17 | Active nav link styles for horizontal & vertical nav 18 | 19 | For horizontal nav it will be only applied to top level nav items 20 | For vertical nav it will be only applied to nav links (not nav groups) 21 | */ 22 | %nav-link-active { 23 | background-color: rgb(var(--v-theme-primary)); 24 | color: rgb(var(--v-theme-on-primary)); 25 | 26 | @include mixins.elevation(3); 27 | } 28 | 29 | %nav-link { 30 | a { 31 | color: inherit; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/_components.scss: -------------------------------------------------------------------------------- 1 | @use "@configured-variables" as variables; 2 | @use "vuetify/lib/styles/tools/_elevation" as mixins_elevation; 3 | 4 | // 👉 VExpansionPanel 5 | .v-expansion-panel-title, 6 | .v-expansion-panel-title--active, 7 | .v-expansion-panel-title:hover, 8 | .v-expansion-panel-title:focus, 9 | .v-expansion-panel-title:focus-visible, 10 | .v-expansion-panel-title--active:focus, 11 | .v-expansion-panel-title--active:hover { 12 | .v-expansion-panel-title__overlay { 13 | opacity: 0 !important; 14 | } 15 | } 16 | 17 | // 👉 Set Elevation 18 | .v-expansion-panels { 19 | .v-expansion-panel { 20 | .v-expansion-panel__shadow { 21 | @include mixins_elevation.elevation(3); 22 | } 23 | } 24 | 25 | .v-expansion-panel-text__wrapper { 26 | color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)) !important; 27 | font-size: 1rem; 28 | } 29 | } 30 | 31 | // 👉 Timeline outlined variant 32 | .v-timeline-item { 33 | .v-timeline-divider__dot { 34 | .v-timeline-divider__inner-dot { 35 | box-shadow: 0 0 0 0.1875rem rgb(var(--v-theme-on-surface-variant)); 36 | 37 | @each $color-name in variables.$theme-colors-name { 38 | 39 | &.bg-#{$color-name} { 40 | box-shadow: 0 0 0 0.1875rem rgba(var(--v-theme-#{$color-name}), 0.12); 41 | } 42 | } 43 | } 44 | } 45 | } 46 | 47 | // 👉 Timeline Outlined style 48 | .v-timeline-variant-outlined.v-timeline { 49 | .v-timeline-divider__dot { 50 | .v-timeline-divider__inner-dot { 51 | box-shadow: inset 0 0 0 0.125rem rgb(var(--v-theme-on-surface-variant)); 52 | 53 | @each $color-name in variables.$theme-colors-name { 54 | background-color: rgb(var(--v-theme-surface)) !important; 55 | 56 | &.bg-#{$color-name} { 57 | box-shadow: inset 0 0 0 0.125rem rgb(var(--v-theme-#{$color-name})); 58 | } 59 | } 60 | } 61 | } 62 | } 63 | 64 | // 👉 v-tab with pill support 65 | .v-tabs.v-tabs-pill { 66 | .v-tab.v-btn { 67 | border-radius: 0.375rem !important; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/_dark.scss: -------------------------------------------------------------------------------- 1 | .v-application { 2 | // vertical nav 3 | &.v-theme--dark .layout-nav-type-vertical, 4 | .v-theme-provider.v-theme--dark { 5 | .layout-vertical-nav { 6 | // nav-link and nav-group style for dark 7 | // vertical navitem 激活状态样式 8 | .nav-link .router-link-exact-active, 9 | .nav-group.active:not(.nav-group .nav-group) > :first-child { 10 | color: rgb(var(--v-theme-on-primary)) !important; 11 | 12 | &::before { 13 | z-index: -1; 14 | color: rgb(var(--v-theme-primary)); 15 | opacity: 1 !important; 16 | } 17 | } 18 | 19 | .nav-group { 20 | .nav-link { 21 | .router-link-exact-active { 22 | color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)) !important; 23 | 24 | &::before { 25 | color: transparent; 26 | } 27 | 28 | &:hover::before { 29 | color: inherit; 30 | opacity: var(--v-hover-opacity) !important; 31 | } 32 | } 33 | } 34 | } 35 | } 36 | } 37 | 38 | // horizontal nav 39 | &.v-theme--dark { 40 | .layout-wrapper.layout-nav-type-horizontal { 41 | .layout-horizontal-nav { 42 | .nav-items { 43 | .nav-group.active:not(.sub-item) { 44 | > :first-child { 45 | .nav-group-label { 46 | &::before { 47 | z-index: -1; 48 | opacity: 1; 49 | } 50 | 51 | .v-icon, 52 | .nav-item-title { 53 | color: rgb(var(--v-theme-on-primary)); 54 | } 55 | } 56 | } 57 | } 58 | } 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/_default-layout-w-vertical-nav.scss: -------------------------------------------------------------------------------- 1 | @use "vuetify/lib/styles/tools/elevation" as elevation; 2 | 3 | .layout-wrapper.layout-nav-type-vertical { 4 | // 👉 Layout footer 5 | .layout-footer { 6 | $ele-layout-footer: &; 7 | 8 | .footer-content-container { 9 | // Sticky footer 10 | @at-root { 11 | // ℹ️ .layout-footer-sticky#{$ele-layout-footer} => .layout-footer-sticky.layout-wrapper.layout-nav-type-vertical .layout-footer 12 | .layout-footer-sticky#{$ele-layout-footer} { 13 | .footer-content-container { 14 | @include elevation.elevation(8); 15 | } 16 | } 17 | } 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/_mixins.scss: -------------------------------------------------------------------------------- 1 | @use "sass:map"; 2 | @use "@configured-variables" as variables; 3 | 4 | @mixin custom-elevation($color, $size) { 5 | box-shadow: (map.get(variables.$shadow-params, $size) rgba($color, map.get(variables.$shadow-opacity, $size))); 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/_utilities.scss: -------------------------------------------------------------------------------- 1 | .v-timeline-item { 2 | .app-timeline-title { 3 | color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)); 4 | font-size: 15px; 5 | font-weight: 500; 6 | line-height: 1.3125rem; 7 | } 8 | 9 | .app-timeline-meta { 10 | color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity)); 11 | font-size: 11px; 12 | line-height: 0.875rem; 13 | } 14 | 15 | .app-timeline-text { 16 | color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)); 17 | font-size: 13px; 18 | line-height: 1.25rem; 19 | } 20 | } 21 | 22 | // ℹ️ Temporary solution as v-spacer style is not getting applied in build version. will remove this after release. 23 | // VSpacer 24 | .v-spacer { 25 | flex-grow: 1; 26 | } 27 | 28 | // app-logo & app-logo-title 29 | .app-logo { 30 | display: flex; 31 | align-items: center !important; 32 | column-gap: 0.5rem !important; 33 | 34 | .app-logo-title { 35 | font-size: 1.75rem !important; 36 | font-weight: 700 !important; 37 | letter-spacing: 0.15px !important; 38 | line-height: 1.75rem !important; 39 | text-transform: lowercase !important; 40 | } 41 | } 42 | 43 | .text-white-variant { 44 | color: rgba(255, 255, 255, 78%) !important; 45 | } 46 | 47 | .bg-custom-background { 48 | background-color: rgb(var(--v-table-header-color)); 49 | } 50 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/_utils.scss: -------------------------------------------------------------------------------- 1 | @use "sass:string"; 2 | 3 | /* 4 | ℹ️ This function is helpful when we have multi dimensional value 5 | 6 | Assume we have padding variable `$nav-padding-horizontal: 10px;` 7 | With above variable let's say we use it in some style: 8 | ```scss 9 | .selector { 10 | margin-left: $nav-padding-horizontal; 11 | } 12 | ``` 13 | 14 | Now, problem is we can also have value as `$nav-padding-horizontal: 10px 15px;` 15 | In this case above style will be invalid. 16 | 17 | This function will extract the left most value from the variable value. 18 | 19 | $nav-padding-horizontal: 10px; => 10px; 20 | $nav-padding-horizontal: 10px 15px; => 10px; 21 | 22 | This is safe: 23 | ```scss 24 | .selector { 25 | margin-left: get-first-value($nav-padding-horizontal); 26 | } 27 | ``` 28 | */ 29 | @function get-first-value($var) { 30 | $start-at: string.index(#{$var}, " "); 31 | 32 | @if $start-at { 33 | @return string.slice( 34 | #{$var}, 35 | 0, 36 | $start-at 37 | ); 38 | } 39 | /* stylelint-disable-next-line @stylistic/indentation */ 40 | @else { 41 | @return $var; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/index.scss: -------------------------------------------------------------------------------- 1 | @use "sass:map"; 2 | @use "@core/scss/base"; 3 | 4 | // Layout 5 | @use "vertical-nav"; 6 | @use "default-layout-w-vertical-nav"; 7 | 8 | // Utilities 9 | @use "utilities"; 10 | 11 | // Components 12 | @use "components"; 13 | 14 | // Mixins 15 | @use "mixins"; 16 | 17 | // Dark 18 | @use "dark"; 19 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/libs/vuetify/components/_alert.scss: -------------------------------------------------------------------------------- 1 | @use "@core/scss/base/mixins"; 2 | @use "@configured-variables" as variables; 3 | @use "@core/scss/template/mixins" as templateMixins; 4 | 5 | /* 👉 Alert 6 | / ℹ️ custom icon styling */ 7 | 8 | $alert-prepend-icon-font-size: 1.125rem !important; 9 | 10 | .v-alert:not(.v-alert--prominent) { 11 | .v-alert__prepend { 12 | padding: 0.125rem; 13 | border-radius: 1rem; 14 | background-color: #fff; 15 | 16 | .v-icon { 17 | block-size: $alert-prepend-icon-font-size; 18 | font-size: $alert-prepend-icon-font-size; 19 | inline-size: $alert-prepend-icon-font-size; 20 | } 21 | } 22 | 23 | .v-alert-title { 24 | margin-block-end: 0.25rem; 25 | } 26 | 27 | .v-alert__close { 28 | .v-btn--icon { 29 | .v-icon { 30 | block-size: 1.25rem; 31 | font-size: 1.25rem; 32 | inline-size: 1.25rem; 33 | } 34 | 35 | .v-btn__overlay, 36 | .v-ripple__container { 37 | opacity: 0; 38 | } 39 | } 40 | } 41 | } 42 | 43 | @each $color-name in variables.$theme-colors-name { 44 | .v-alert { 45 | 46 | &:not(.v-alert--prominent).text-#{$color-name}, 47 | &:not(.v-alert--prominent).bg-#{$color-name} { 48 | .v-alert__prepend { 49 | border: 2px solid rgb(var(--v-theme-#{$color-name}-light)); 50 | color: rgba(var(--v-theme-#{$color-name})) !important; 51 | 52 | @include mixins.elevation(2); 53 | } 54 | } 55 | 56 | &--variant-outlined:not(.v-alert--prominent), 57 | &--variant-tonal:not(.v-alert--prominent), 58 | &--variant-plain:not(.v-alert--prominent) { 59 | &.bg-#{$color-name}, 60 | &.text-#{$color-name} { 61 | .v-alert__prepend { 62 | border: none; 63 | background-color: rgb(var(--v-theme-#{$color-name})); 64 | box-shadow: 0 0 0 2px rgba(var(--v-theme-#{$color-name}), 0.16); 65 | color: #fff !important; 66 | } 67 | } 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/libs/vuetify/components/_avatar.scss: -------------------------------------------------------------------------------- 1 | @use "@core/scss/base/mixins"; 2 | 3 | // 👉 Avatar 4 | body { 5 | .v-avatar { 6 | .v-icon { 7 | block-size: 1.5rem; 8 | inline-size: 1.5rem; 9 | } 10 | 11 | &.v-avatar--variant-tonal:not([class*="text-"]) { 12 | .v-avatar__underlay { 13 | --v-activated-opacity: 0.08; 14 | } 15 | } 16 | } 17 | 18 | .v-avatar-group { 19 | > * { 20 | &:hover { 21 | transform: translateY(-5px) scale(1); 22 | 23 | @include mixins.elevation(6); 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/libs/vuetify/components/_badge.scss: -------------------------------------------------------------------------------- 1 | @use "@configured-variables" as variables; 2 | 3 | // 👉 Badge 4 | .v-badge { 5 | .v-badge__badge .v-icon { 6 | font-size: 0.9375rem; 7 | } 8 | 9 | &.v-badge--bordered:not(.v-badge--dot) { 10 | .v-badge__badge { 11 | &::after { 12 | transform: scale(1.05); 13 | } 14 | } 15 | } 16 | 17 | &.v-badge--tonal { 18 | @each $color-name in variables.$theme-colors-name { 19 | .v-badge__badge.bg-#{$color-name} { 20 | background-color: rgba(var(--v-theme-#{$color-name}), 0.16) !important; 21 | color: rgb(var(--v-theme-#{$color-name})) !important; 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/libs/vuetify/components/_cards.scss: -------------------------------------------------------------------------------- 1 | .v-card-subtitle { 2 | color: rgba(var(--v-theme-on-background), 0.55); 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/libs/vuetify/components/_checkbox.scss: -------------------------------------------------------------------------------- 1 | @use "sass:list"; 2 | @use "sass:map"; 3 | @use "@styles/variables/vuetify"; 4 | @use "@configured-variables" as variables; 5 | 6 | // 👉 Checkbox 7 | .v-checkbox { 8 | // We adjusted it to vertically align the label 9 | 10 | .v-selection-control--disabled { 11 | --v-disabled-opacity: 0.45; 12 | } 13 | 14 | // Remove extra space below the label 15 | .v-input__details { 16 | min-block-size: unset !important; 17 | padding-block-start: 0 !important; 18 | } 19 | } 20 | 21 | // 👉 checkbox size and box shadow 22 | .v-checkbox-btn { 23 | // 👉 Checkbox icon opacity 24 | .v-selection-control__input { 25 | > .v-icon { 26 | opacity: 1; 27 | } 28 | 29 | > .custom-checkbox-indeterminate { 30 | color: rgb(var(--v-theme-primary)); 31 | } 32 | } 33 | 34 | &.v-selection-control--dirty { 35 | @each $color-name in variables.$theme-colors-name { 36 | .v-selection-control__wrapper.text-#{$color-name} { 37 | .v-selection-control__input { 38 | /* ℹ️ Using filter: drop-shadow() instead of box-shadow because box-shadow creates white background for SVG; */ 39 | .v-icon { 40 | filter: drop-shadow(0 2px 4px rgba(var(--v-theme-#{$color-name}), 0.4)); 41 | } 42 | } 43 | } 44 | } 45 | } 46 | } 47 | 48 | // checkbox icon size 49 | .v-checkbox, 50 | .v-checkbox-btn { 51 | &.v-selection-control { 52 | .v-selection-control__input { 53 | svg { 54 | font-size: 1.5rem; 55 | } 56 | } 57 | 58 | .v-label { 59 | color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity)); 60 | line-height: 1.375rem; 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/libs/vuetify/components/_dialog.scss: -------------------------------------------------------------------------------- 1 | @use "@layouts/styles/mixins" as layoutsMixins; 2 | 3 | // 👉 Dialog 4 | body .v-dialog { 5 | // dialog custom close btn 6 | .v-dialog-close-btn { 7 | border-radius: 0.25rem; 8 | inset-block-start: 0; 9 | inset-inline-end: 0; 10 | transform: translate(0.5rem, -0.5rem); 11 | 12 | @include layoutsMixins.rtl { 13 | transform: translate(-0.5rem, -0.5rem); 14 | } 15 | 16 | &:hover { 17 | transform: translate(0.3125rem, -0.3125rem); 18 | 19 | @include layoutsMixins.rtl { 20 | transform: translate(-0.3125rem, -0.3125rem); 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/libs/vuetify/components/_expansion-panels.scss: -------------------------------------------------------------------------------- 1 | @use "@core/scss/base/mixins"; 2 | @use "@layouts/styles/mixins" as layoutsMixins; 3 | 4 | // 👉 Expansion panels 5 | body .v-layout .v-application__wrap .v-expansion-panels { 6 | .v-expansion-panel { 7 | margin-block-start: 0 !important; 8 | 9 | // expansion panel arrow font size 10 | .v-expansion-panel-title { 11 | color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)); 12 | font-weight: 500; 13 | 14 | .v-expansion-panel-title__icon { 15 | transition: transform 0.2s ease-in-out; 16 | 17 | .v-icon { 18 | block-size: 1.25rem !important; 19 | font-size: 1.25rem !important; 20 | inline-size: 1.25rem !important; 21 | } 22 | } 23 | } 24 | 25 | .v-expansion-panel-title, 26 | .v-expansion-panel-title--active, 27 | .v-expansion-panel-title:hover, 28 | .v-expansion-panel-title:focus, 29 | .v-expansion-panel-title:focus-visible, 30 | .v-expansion-panel-title--active:focus, 31 | .v-expansion-panel-title--active:hover { 32 | .v-expansion-panel-title__overlay { 33 | opacity: 0 !important; 34 | } 35 | } 36 | 37 | // Set Elevation when panel open 38 | &:not(.v-expansion-panels--variant-accordion) { 39 | &.v-expansion-panel--active { 40 | .v-expansion-panel__shadow { 41 | @include mixins.elevation(6); 42 | } 43 | } 44 | } 45 | } 46 | 47 | // custom style for expansion panels 48 | &.expansion-panels-width-border { 49 | border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)); 50 | border-radius: 0.375rem; 51 | 52 | .v-expansion-panel-title { 53 | background-color: rgb(var(--v-theme-grey-light)); 54 | border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)); 55 | margin-block-end: -1px; 56 | } 57 | 58 | .v-expansion-panel-text { 59 | .v-expansion-panel-text__wrapper { 60 | padding: 1.25rem; 61 | } 62 | } 63 | } 64 | 65 | &:not(.expansion-panels-width-border) { 66 | .v-expansion-panel { 67 | &:not(:last-child) { 68 | margin-block-end: 0.5rem; 69 | } 70 | 71 | &:not(:first-child)::after { 72 | content: none; 73 | } 74 | 75 | // ℹ️ we have to use below style of increase the specificity and override the default style 76 | /* stylelint-disable-next-line no-descending-specificity */ 77 | &:first-child:not(:last-child), 78 | &:not(:first-child, :last-child), 79 | &:not(:first-child) { 80 | border-radius: 0.375rem !important; 81 | } 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/libs/vuetify/components/_list.scss: -------------------------------------------------------------------------------- 1 | // 👉 List 2 | .v-list-item { 3 | --v-hover-opacity: 0.06 !important; 4 | 5 | .v-checkbox-btn.v-selection-control--density-compact { 6 | margin-inline-end: 0.5rem; 7 | } 8 | 9 | .v-list-item__overlay { 10 | transition: none; 11 | } 12 | 13 | .v-list-item__prepend { 14 | .v-icon { 15 | font-size: 1.25rem; 16 | } 17 | } 18 | 19 | &.v-list-item--active { 20 | &.v-list-group__header { 21 | color: rgb(var(--v-theme-primary)); 22 | } 23 | 24 | &:not(.v-list-group__header) { 25 | .v-list-item-subtitle { 26 | color: rgb(var(--v-theme-primary)); 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/libs/vuetify/components/_menu.scss: -------------------------------------------------------------------------------- 1 | // Style list differently when it's used in a components like select, menu etc 2 | .v-menu { 3 | // Adjust padding of list item inside menu 4 | .v-list-item { 5 | padding-block: 8px !important; 6 | padding-inline: 20px !important; 7 | } 8 | } 9 | 10 | // 👉 Menu 11 | // Menu custom style 12 | .v-menu.v-overlay { 13 | .v-overlay__content { 14 | .v-list { 15 | .v-list-item { 16 | margin-block-end: 0.125rem; 17 | min-block-size: 2.375rem; 18 | 19 | &:first-child { 20 | margin-block-start: 0; 21 | } 22 | 23 | &:last-child { 24 | margin-block-end: 0; 25 | } 26 | } 27 | 28 | .v-list-item--density-default:not(.v-list-item--nav).v-list-item--one-line { 29 | padding-block: 0.5rem; 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/libs/vuetify/components/_otp-input.scss: -------------------------------------------------------------------------------- 1 | // otp input 2 | .v-otp-input { 3 | justify-content: unset !important; 4 | 5 | .v-otp-input__content { 6 | max-inline-size: 100%; 7 | 8 | .v-field.v-field--focused { 9 | .v-field__outline { 10 | .v-field__outline__start, 11 | .v-field__outline__end { 12 | border-color: rgb(var(--v-theme-primary)) !important; 13 | } 14 | } 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/libs/vuetify/components/_progress.scss: -------------------------------------------------------------------------------- 1 | // @use "@core/scss/template/mixins" as templateMixins; 2 | @use "@configured-variables" as variables; 3 | 4 | // 👉 Progress 5 | // .v-progress-linear { 6 | // .v-progress-linear__determinate { 7 | // @each $color-name in variables.$theme-colors-name { 8 | // &.bg-#{$color-name} { 9 | // // @include templateMixins.custom-elevation(var(--v-theme-#{$color-name}), "sm"); 10 | // } 11 | // } 12 | // } 13 | // } 14 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/libs/vuetify/components/_radio.scss: -------------------------------------------------------------------------------- 1 | @use "@core/scss/base/mixins"; 2 | @use "@configured-variables" as variables; 3 | 4 | // 👉 Radio 5 | .v-radio, 6 | .v-radio-btn { 7 | // 👉 radio icon opacity 8 | .v-selection-control__input > .v-icon { 9 | opacity: 1; 10 | } 11 | 12 | &.v-selection-control--disabled { 13 | --v-disabled-opacity: 0.45; 14 | } 15 | 16 | &.v-selection-control--dirty { 17 | @each $color-name in variables.$theme-colors-name { 18 | .v-selection-control__wrapper.text-#{$color-name} { 19 | .v-selection-control__input { 20 | /* ℹ️ Using filter: drop-shadow() instead of box-shadow because box-shadow creates white background for SVG; */ 21 | .v-icon { 22 | filter: drop-shadow(0 2px 4px rgba(var(--v-theme-#{$color-name}), 0.4)); 23 | } 24 | } 25 | } 26 | } 27 | } 28 | 29 | &.v-selection-control { 30 | .v-selection-control__input { 31 | svg { 32 | font-size: 1.5rem; 33 | } 34 | } 35 | 36 | .v-label { 37 | color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity)); 38 | } 39 | } 40 | } 41 | 42 | // 👉 Radio, Checkbox 43 | 44 | .v-input.v-radio-group > .v-input__control > .v-label { 45 | margin-inline-start: 0; 46 | } 47 | 48 | .v-radio-group { 49 | .v-selection-control-group { 50 | .v-radio:not(:last-child) { 51 | margin-inline-end: 0; 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/libs/vuetify/components/_rating.scss: -------------------------------------------------------------------------------- 1 | // 👉 Rating 2 | .v-rating { 3 | .v-rating__wrapper { 4 | .v-btn { 5 | &:hover { 6 | transform: none; 7 | } 8 | 9 | .v-icon { 10 | --v-icon-size-multiplier: 1; 11 | } 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/libs/vuetify/components/_slider.scss: -------------------------------------------------------------------------------- 1 | // 👉 Slider 2 | .v-slider { 3 | .v-slider-track__background--opacity { 4 | opacity: 0.16; 5 | } 6 | } 7 | 8 | .v-slider-thumb { 9 | .v-slider-thumb__surface::after { 10 | border-radius: 50%; 11 | background-color: #fff; 12 | block-size: calc(var(--v-slider-thumb-size) - 9px); 13 | inline-size: calc(var(--v-slider-thumb-size) - 9px); 14 | } 15 | 16 | .v-slider-thumb__label { 17 | background-color: rgb(var(--v-tooltip-background)); 18 | color: rgb(var(--v-theme-surface)); 19 | font-weight: 500; 20 | line-height: 1.25rem; 21 | 22 | &::before { 23 | content: none; 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/libs/vuetify/components/_snackbar.scss: -------------------------------------------------------------------------------- 1 | // 👉 snackbar 2 | .v-snackbar { 3 | .v-snackbar__actions { 4 | .v-btn { 5 | font-size: 13px; 6 | line-height: 18px; 7 | text-transform: capitalize; 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/libs/vuetify/components/_switch.scss: -------------------------------------------------------------------------------- 1 | @use "@configured-variables" as variables; 2 | @use "@core/scss/template/mixins" as templateMixins; 3 | @use "@core/scss/base/mixins"; 4 | 5 | // 👉 switch 6 | .v-switch { 7 | &.v-switch--inset { 8 | .v-selection-control { 9 | .v-switch__track { 10 | transition: all 0.1s; 11 | } 12 | 13 | &.v-selection-control--disabled { 14 | --v-disabled-opacity: 0.45; 15 | } 16 | 17 | &.v-selection-control--dirty { 18 | @each $color-name in variables.$theme-colors-name { 19 | .v-switch__track.bg-#{$color-name} { 20 | @include templateMixins.custom-elevation(var(--v-theme-#{$color-name}), "sm"); 21 | } 22 | } 23 | } 24 | 25 | &:not(.v-selection-control--dirty) { 26 | .v-switch__track { 27 | box-shadow: 0 0 4px 0 rgba(0, 0, 0, 16%) inset; 28 | } 29 | } 30 | } 31 | 32 | .v-selection-control__wrapper { 33 | block-size: 36px; 34 | } 35 | 36 | .v-selection-control__input { 37 | transform: translateX(-6px) !important; 38 | 39 | --v-selection-control-size: 0.875rem; 40 | 41 | .v-switch__thumb { 42 | @include mixins.elevation(2); 43 | 44 | transform: scale(1); 45 | } 46 | } 47 | 48 | .v-selection-control--dirty { 49 | .v-selection-control__input { 50 | transform: translateX(6px) !important; 51 | } 52 | } 53 | } 54 | 55 | .v-label { 56 | color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)); 57 | line-height: 1.375rem !important; 58 | } 59 | } 60 | 61 | .v-switch.v-input, 62 | .v-checkbox-btn, 63 | .v-radio-btn, 64 | .v-radio { 65 | --v-input-control-height: auto; 66 | 67 | flex: unset; 68 | } 69 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/libs/vuetify/components/_table.scss: -------------------------------------------------------------------------------- 1 | @use "@layouts/styles/mixins" as layoutMixins; 2 | 3 | // 👉 Table 4 | .v-table { 5 | th { 6 | color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)) !important; 7 | font-size: 0.8125rem; 8 | letter-spacing: 0.2px; 9 | line-height: 24px; 10 | text-transform: uppercase; 11 | 12 | .v-data-table-header__content { 13 | display: flex; 14 | justify-content: space-between; 15 | } 16 | } 17 | 18 | .v-data-table-footer { 19 | row-gap: 8px !important; 20 | } 21 | } 22 | 23 | // 👉 Datatable 24 | .v-data-table, 25 | .v-table { 26 | table { 27 | thead, 28 | tbody { 29 | tr { 30 | th, 31 | td { 32 | &:first-child:has(.v-checkbox-btn) { 33 | padding-inline: 15px 0 !important; 34 | } 35 | 36 | @include layoutMixins.rtl { 37 | padding-inline: 20px 16px !important; 38 | } 39 | } 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/libs/vuetify/components/_textarea.scss: -------------------------------------------------------------------------------- 1 | .v-textarea { 2 | textarea { 3 | opacity: 0 !important; 4 | } 5 | 6 | & .v-field--active textarea { 7 | opacity: 1 !important; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/libs/vuetify/components/_tooltip.scss: -------------------------------------------------------------------------------- 1 | // 👉 Tooltip 2 | .v-tooltip { 3 | .v-overlay__content { 4 | font-weight: 500; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/libs/vuetify/components/index.scss: -------------------------------------------------------------------------------- 1 | @use "alert"; 2 | @use "avatar"; 3 | @use "button"; 4 | @use "badge"; 5 | @use "cards"; 6 | @use "chip"; 7 | @use "dialog"; 8 | @use "expansion-panels"; 9 | @use "list"; 10 | @use "menu"; 11 | @use "pagination"; 12 | @use "progress"; 13 | @use "rating"; 14 | @use "snackbar"; 15 | @use "slider"; 16 | @use "table"; 17 | @use "tabs"; 18 | @use "timeline"; 19 | @use "tooltip"; 20 | @use "otp-input"; 21 | @use "field"; 22 | @use "checkbox"; 23 | @use "textarea"; 24 | @use "radio"; 25 | @use "switch"; 26 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/libs/vuetify/index.scss: -------------------------------------------------------------------------------- 1 | @use "@core/scss/base/libs/vuetify"; 2 | @use "./overrides.scss"; 3 | @use "components/index.scss"; 4 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/libs/vuetify/overrides.scss: -------------------------------------------------------------------------------- 1 | @use "@core/scss/base/utils"; 2 | @use "@configured-variables" as variables; 3 | 4 | // 👉 Body 5 | 6 | // set body font size 15px 7 | body { 8 | font-size: 15px !important; 9 | 10 | // 👉 Button outline with default color border color 11 | .v-alert--variant-outlined, 12 | .v-avatar--variant-outlined, 13 | .v-btn.v-btn--variant-outlined, 14 | .v-card--variant-outlined, 15 | .v-chip--variant-outlined, 16 | .v-list-item--variant-outlined { 17 | &:not([class*="text-"]) { 18 | border-color: rgba(var(--v-border-color), 0.22); 19 | } 20 | 21 | &.text-default { 22 | border-color: rgba(var(--v-border-color), 0.22); 23 | } 24 | } 25 | 26 | // We reduced this margin to get 40px input height 27 | .v-input--density-compact { 28 | --v-input-chips-margin-bottom: 1px; 29 | } 30 | } 31 | 32 | .text-caption { 33 | color: rgba(var(--v-theme-on-background), var(--v-disabled-opacity)); 34 | } 35 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/pages/misc.scss: -------------------------------------------------------------------------------- 1 | .layout-blank { 2 | .misc-wrapper { 3 | display: flex; 4 | flex-direction: column; 5 | align-items: center; 6 | justify-content: center; 7 | padding: 1.25rem; 8 | min-block-size: calc(var(--vh, 1vh) * 100); 9 | } 10 | 11 | .misc-avatar { 12 | z-index: 1; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/pages/page-auth.scss: -------------------------------------------------------------------------------- 1 | .layout-blank { 2 | .auth-wrapper { 3 | min-block-size: 100dvh; 4 | } 5 | 6 | .auth-card { 7 | z-index: 1 !important; 8 | } 9 | } 10 | 11 | .auth-v1-top-shape, 12 | .auth-v1-bottom-shape { 13 | position: absolute; 14 | } 15 | 16 | .auth-v1-top-shape { 17 | block-size: 148px; 18 | inline-size: 148px; 19 | inset-block-start: -3.5rem; 20 | inset-inline-end: -2.5rem; 21 | } 22 | 23 | .auth-v1-bottom-shape { 24 | block-size: 240px; 25 | inline-size: 240px; 26 | inset-block-end: -4.5rem; 27 | inset-inline-start: -3rem; 28 | } 29 | 30 | .auth-illustration { 31 | z-index: 1; 32 | } 33 | 34 | @media (min-width: 960px) { 35 | .skin--bordered { 36 | .auth-card-v2 { 37 | border-inline-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)) !important; 38 | } 39 | } 40 | } 41 | 42 | .auth-logo { 43 | position: absolute; 44 | z-index: 2; 45 | inset-block-start: 2.5rem; 46 | inset-inline-start: 2.5rem; 47 | } 48 | 49 | .auth-title { 50 | font-size: 1.75rem; 51 | font-weight: 700; 52 | letter-spacing: 0.15px; 53 | line-height: 1.75rem; 54 | } 55 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/placeholders/_default-layout-vertical-nav.scss: -------------------------------------------------------------------------------- 1 | @use "vuetify/lib/styles/tools/elevation" as elevation; 2 | @use "@configured-variables" as variables; 3 | 4 | %default-layout-vertical-nav-floating-navbar-and-sticky-elevated-navbar-scrolled { 5 | // If navbar is contained => Squeeze navbar content on scroll 6 | @if variables.$layout-vertical-nav-navbar-is-contained { 7 | padding-inline: 1.5rem; 8 | 9 | @include elevation.elevation(4); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/placeholders/_index.scss: -------------------------------------------------------------------------------- 1 | @forward "vertical-nav"; 2 | @forward "nav"; 3 | @forward "default-layout-vertical-nav"; 4 | @forward "misc"; 5 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/placeholders/_misc.scss: -------------------------------------------------------------------------------- 1 | %blurry-bg { 2 | /* stylelint-disable property-no-vendor-prefix */ 3 | -webkit-backdrop-filter: blur(3px); 4 | backdrop-filter: blur(3px); 5 | /* stylelint-enable */ 6 | background-color: rgb(var(--v-theme-surface), 0.88); 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/placeholders/_nav.scss: -------------------------------------------------------------------------------- 1 | // ℹ️ This is common style that needs to be applied to both navs 2 | %nav { 3 | .nav-item-title { 4 | letter-spacing: normal; 5 | line-height: 1.375rem; 6 | } 7 | } 8 | 9 | /* 10 | Active nav link styles for horizontal & vertical nav 11 | 12 | For horizontal nav it will be only applied to top level nav items 13 | For vertical nav it will be only applied to nav links (not nav groups) 14 | */ 15 | %nav-link-active { 16 | --v-activated-opacity: 0.16; 17 | 18 | background-color: rgba(var(--v-theme-primary), var(--v-activated-opacity)); 19 | box-shadow: none; 20 | color: rgb(var(--v-theme-primary)); 21 | } 22 | 23 | // style for vertical nav nested icon 24 | %nav-link-nested-active { 25 | background-color: transparent !important; 26 | box-shadow: none; 27 | color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)) !important; 28 | 29 | // style for nested dot icon 30 | .nav-item-icon { 31 | color: rgb(var(--v-theme-primary), var(--v-activated-opacity)) !important; 32 | transform: scale(2.6662); 33 | 34 | &::before { 35 | position: absolute; 36 | border-radius: 6px; 37 | background-color: rgb(var(--v-theme-primary)); 38 | block-size: 100%; 39 | content: ""; 40 | inline-size: 100%; 41 | inset: 0; 42 | transform: scale(-0.5); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /frontend/src/@core/scss/template/placeholders/_vertical-nav.scss: -------------------------------------------------------------------------------- 1 | // Open & Active nav group styles 2 | %vertical-nav-group-active { 3 | --v-theme-overlay-multiplier: 2; 4 | 5 | color: rgb(var(--v-theme-primary)); 6 | } 7 | 8 | %nav-header-action { 9 | display: flex; 10 | align-items: center; 11 | justify-content: center; 12 | background-color: rgb(var(--v-theme-primary)); 13 | block-size: 1.375rem; 14 | inline-size: 1.375rem; 15 | } 16 | 17 | // nav-group and nav-link border radius 18 | %vertical-nav-item-interactive { 19 | border-radius: 0.375rem; 20 | block-size: 2.625rem; 21 | margin-block-end: 0.25rem; 22 | } 23 | 24 | // ℹ️ Icon styling for icon nested inside another nav item (2nd level) 25 | %vertical-nav-items-nested-icon { 26 | margin-inline: 6px 20px; 27 | transition: transform 0.25s ease-in-out 0s; 28 | } 29 | 30 | %vertical-nav-items-icon-after-2nd-level { 31 | margin-inline-start: 14px; 32 | visibility: visible; 33 | } 34 | 35 | %vertical-nav-section-title { 36 | block-size: 1.125rem; 37 | color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity)); 38 | font-size: 0.8125rem; 39 | line-height: 1.125rem; 40 | text-transform: uppercase; 41 | } 42 | 43 | // Vertical nav item badge styles 44 | %vertical-nav-item-badge { 45 | z-index: 1; 46 | display: flex; 47 | align-items: center; 48 | justify-content: center; 49 | border-radius: 500px; 50 | block-size: 1.5rem; 51 | font-size: 0.8125rem; 52 | font-weight: 500; 53 | line-height: 1.25rem; 54 | margin-inline-end: 0.5rem; 55 | padding-block: 0; 56 | padding-inline: 0.625rem; 57 | } 58 | -------------------------------------------------------------------------------- /frontend/src/@core/utils/colorConverter.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Convert Hex color to rgb 3 | * @param hex 4 | */ 5 | 6 | export const hexToRgb = (hex: string) => { 7 | // Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF") 8 | const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i 9 | 10 | hex = hex.replace(shorthandRegex, (m: string, r: string, g: string, b: string) => { 11 | return r + r + g + g + b + b 12 | }) 13 | 14 | const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) 15 | 16 | return result ? `${Number.parseInt(result[1], 16)},${Number.parseInt(result[2], 16)},${Number.parseInt(result[3], 16)}` : null 17 | } 18 | 19 | /** 20 | *RGBA color to Hex color with / without opacity 21 | */ 22 | export const rgbaToHex = (rgba: string, forceRemoveAlpha = false) => { 23 | return ( 24 | `#${ 25 | rgba 26 | .replace(/^rgba?\(|\s+|\)$/g, '') // Get's rgba / rgb string values 27 | .split(',') // splits them at "," 28 | .filter((string, index) => !forceRemoveAlpha || index !== 3) 29 | .map(string => Number.parseFloat(string)) // Converts them to numbers 30 | .map((number, index) => (index === 3 ? Math.round(number * 255) : number)) // Converts alpha to 255 number 31 | .map(number => number.toString(16)) // Converts numbers to hex 32 | .map(string => (string.length === 1 ? `0${string}` : string)) // Adds 0 when length of one number is 1 33 | .join('')}` 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/@core/utils/formatters.ts: -------------------------------------------------------------------------------- 1 | // TODO: Try to implement this: https://twitter.com/fireship_dev/status/1565424801216311297 2 | export const kFormatter = (num: number) => { 3 | const regex = /\B(?=(\d{3})+(?!\d))/g 4 | 5 | return Math.abs(num) > 9999 ? `${Math.sign(num) * +((Math.abs(num) / 1000).toFixed(1))}k` : Math.abs(num).toFixed(0).replace(regex, ',') 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/@core/utils/helpers.ts: -------------------------------------------------------------------------------- 1 | // 👉 IsEmpty 2 | export const isEmpty = (value: unknown): boolean => { 3 | if (value === null || value === undefined || value === '') 4 | return true 5 | 6 | return !!(Array.isArray(value) && value.length === 0) 7 | } 8 | 9 | // 👉 IsNullOrUndefined 10 | export const isNullOrUndefined = (value: unknown): value is undefined | null => { 11 | return value === null || value === undefined 12 | } 13 | 14 | // 👉 IsEmptyArray 15 | export const isEmptyArray = (arr: unknown): boolean => { 16 | return Array.isArray(arr) && arr.length === 0 17 | } 18 | 19 | // 👉 IsObject 20 | export const isObject = (obj: unknown): obj is Record => 21 | obj !== null && !!obj && typeof obj === 'object' && !Array.isArray(obj) 22 | 23 | // 👉 IsToday 24 | export const isToday = (date: Date) => { 25 | const today = new Date() 26 | 27 | return ( 28 | date.getDate() === today.getDate() 29 | && date.getMonth() === today.getMonth() 30 | && date.getFullYear() === today.getFullYear() 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /frontend/src/@core/utils/plugins.ts: -------------------------------------------------------------------------------- 1 | import type { App } from 'vue' 2 | 3 | /** 4 | * This is helper function to register plugins like a nuxt 5 | * To register a plugin just export a const function `defineVuePlugin` that takes `app` as argument and call `app.use` 6 | * For Scanning plugins it will include all files in `src/plugins` and `src/plugins/**\/index.ts` 7 | * 8 | * 9 | * @param {App} app Vue app instance 10 | * @returns void 11 | * 12 | * @example 13 | * ```ts 14 | * // File: src/plugins/vuetify/index.ts 15 | * 16 | * import type { App } from 'vue' 17 | * import { createVuetify } from 'vuetify' 18 | * 19 | * const vuetify = createVuetify({ ... }) 20 | * 21 | * export default function (app: App) { 22 | * app.use(vuetify) 23 | * } 24 | * ``` 25 | * 26 | * All you have to do is use this helper function in `main.ts` file like below: 27 | * ```ts 28 | * // File: src/main.ts 29 | * import { registerPlugins } from '@core/utils/plugins' 30 | * import { createApp } from 'vue' 31 | * import App from '@/App.vue' 32 | * 33 | * // Create vue app 34 | * const app = createApp(App) 35 | * 36 | * // Register plugins 37 | * registerPlugins(app) // [!code focus] 38 | * 39 | * // Mount vue app 40 | * app.mount('#app') 41 | * ``` 42 | */ 43 | 44 | export const registerPlugins = (app: App) => { 45 | const imports = import.meta.glob<{ default: (app: App) => void }>(['../../plugins/*.{ts,js}', '../../plugins/*/index.{ts,js}'], { eager: true }) 46 | 47 | const importPaths = Object.keys(imports).sort() 48 | 49 | importPaths.forEach(path => { 50 | const pluginImportModule = imports[path] 51 | 52 | pluginImportModule.default?.(app) 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /frontend/src/@layouts/components/TransitionExpand.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 71 | 72 | 84 | 85 | 93 | -------------------------------------------------------------------------------- /frontend/src/@layouts/components/VerticalNavGroup.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 43 | 44 | 71 | -------------------------------------------------------------------------------- /frontend/src/@layouts/components/VerticalNavLink.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 37 | 38 | 47 | -------------------------------------------------------------------------------- /frontend/src/@layouts/components/VerticalNavSectionTitle.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 21 | -------------------------------------------------------------------------------- /frontend/src/@layouts/styles/_classes.scss: -------------------------------------------------------------------------------- 1 | .cursor-pointer { 2 | cursor: pointer; 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/@layouts/styles/_default-layout.scss: -------------------------------------------------------------------------------- 1 | // These are styles which are both common in layout w/ vertical nav & horizontal nav 2 | @use "@layouts/styles/rtl"; 3 | @use "@layouts/styles/placeholders"; 4 | @use "@layouts/styles/mixins"; 5 | @use "@configured-variables" as variables; 6 | 7 | html, 8 | body { 9 | min-block-size: 100%; 10 | } 11 | 12 | .layout-page-content { 13 | @include mixins.boxed-content(true); 14 | 15 | flex-grow: 1; 16 | 17 | // TODO: Use grid gutter variable here 18 | padding-block: 1.5rem; 19 | } 20 | 21 | .layout-footer { 22 | .footer-content-container { 23 | block-size: variables.$layout-vertical-nav-footer-height; 24 | } 25 | 26 | .layout-footer-sticky & { 27 | position: sticky; 28 | inset-block-end: 0; 29 | will-change: transform; 30 | } 31 | 32 | .layout-footer-hidden & { 33 | display: none; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/@layouts/styles/_global.scss: -------------------------------------------------------------------------------- 1 | *, 2 | ::before, 3 | ::after { 4 | box-sizing: inherit; 5 | background-repeat: no-repeat; 6 | } 7 | 8 | html { 9 | box-sizing: border-box; 10 | } 11 | -------------------------------------------------------------------------------- /frontend/src/@layouts/styles/_mixins.scss: -------------------------------------------------------------------------------- 1 | @use "placeholders"; 2 | @use "@configured-variables" as variables; 3 | 4 | @mixin rtl { 5 | @if variables.$enable-rtl-styles { 6 | [dir="rtl"] & { 7 | @content; 8 | } 9 | } 10 | } 11 | 12 | @mixin boxed-content($nest-selector: false) { 13 | & { 14 | @extend %boxed-content-spacing; 15 | 16 | @at-root { 17 | @if $nest-selector == false { 18 | .layout-content-width-boxed#{&} { 19 | @extend %boxed-content; 20 | } 21 | } 22 | // stylelint-disable-next-line @stylistic/indentation 23 | @else { 24 | .layout-content-width-boxed & { 25 | @extend %boxed-content; 26 | } 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /frontend/src/@layouts/styles/_placeholders.scss: -------------------------------------------------------------------------------- 1 | // placeholders 2 | @use "@configured-variables" as variables; 3 | 4 | %boxed-content { 5 | @at-root #{&}-spacing { 6 | // TODO: Use grid gutter variable here 7 | padding-inline: 1.5rem; 8 | } 9 | 10 | inline-size: 100%; 11 | margin-inline: auto; 12 | max-inline-size: variables.$layout-boxed-content-width; 13 | } 14 | 15 | %layout-navbar-hidden { 16 | display: none; 17 | } 18 | 19 | // ℹ️ We created this placeholder even it is being used in just layout w/ vertical nav because in future we might apply style to both navbar & horizontal nav separately 20 | %layout-navbar-sticky { 21 | position: sticky; 22 | inset-block-start: 0; 23 | 24 | // will-change: transform; 25 | // inline-size: 100%; 26 | } 27 | 28 | %style-scroll-bar { 29 | /* width */ 30 | 31 | &::-webkit-scrollbar { 32 | background: rgb(var(--v-theme-surface)); 33 | block-size: 8px; 34 | border-end-end-radius: 14px; 35 | border-start-end-radius: 14px; 36 | inline-size: 4px; 37 | } 38 | 39 | /* Track */ 40 | &::-webkit-scrollbar-track { 41 | background: transparent; 42 | } 43 | 44 | /* Handle */ 45 | &::-webkit-scrollbar-thumb { 46 | border-radius: 0.5rem; 47 | background: rgb(var(--v-theme-perfect-scrollbar-thumb)); 48 | } 49 | 50 | &::-webkit-scrollbar-corner { 51 | display: none; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /frontend/src/@layouts/styles/_rtl.scss: -------------------------------------------------------------------------------- 1 | @use "./mixins"; 2 | 3 | .layout-vertical-nav .nav-group-arrow { 4 | @include mixins.rtl { 5 | transform: rotate(180deg); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/@layouts/styles/_variables.scss: -------------------------------------------------------------------------------- 1 | // @use "@styles/style.scss"; 2 | 3 | // 👉 Vertical nav 4 | $layout-vertical-nav-z-index: 12 !default; 5 | $layout-vertical-nav-width: 260px !default; 6 | $layout-vertical-nav-collapsed-width: 80px !default; 7 | $selector-vertical-nav-mini: ".layout-vertical-nav-collapsed .layout-vertical-nav:not(:hover)"; 8 | 9 | // 👉 Horizontal nav 10 | $layout-horizontal-nav-z-index: 11 !default; 11 | $layout-horizontal-nav-navbar-height: 64px !default; 12 | 13 | // 👉 Navbar 14 | $layout-vertical-nav-navbar-height: 64px !default; 15 | $layout-vertical-nav-navbar-is-contained: true !default; 16 | $layout-vertical-nav-layout-navbar-z-index: 11 !default; 17 | $layout-horizontal-nav-layout-navbar-z-index: 11 !default; 18 | 19 | // 👉 Main content 20 | $layout-boxed-content-width: 1440px !default; 21 | 22 | // 👉Footer 23 | $layout-vertical-nav-footer-height: 56px !default; 24 | 25 | // 👉 Layout overlay 26 | $layout-overlay-z-index: 11 !default; 27 | 28 | // 👉 RTL 29 | $enable-rtl-styles: true !default; 30 | -------------------------------------------------------------------------------- /frontend/src/@layouts/styles/index.scss: -------------------------------------------------------------------------------- 1 | @use "global"; 2 | @use "vue3-perfect-scrollbar/dist/vue3-perfect-scrollbar.min.css"; 3 | @use "classes"; 4 | -------------------------------------------------------------------------------- /frontend/src/@layouts/types.ts: -------------------------------------------------------------------------------- 1 | import type { RouteLocationRaw } from 'vue-router' 2 | 3 | export interface AclProperties { 4 | action: string 5 | subject: string 6 | } 7 | 8 | // 👉 Vertical nav section title 9 | export interface NavSectionTitle extends Partial { 10 | heading: string 11 | } 12 | 13 | // 👉 Vertical nav link 14 | declare type ATagTargetAttrValues = '_blank' | '_self' | '_parent' | '_top' | 'framename' 15 | declare type ATagRelAttrValues = 16 | | 'alternate' 17 | | 'author' 18 | | 'bookmark' 19 | | 'external' 20 | | 'help' 21 | | 'license' 22 | | 'next' 23 | | 'nofollow' 24 | | 'noopener' 25 | | 'noreferrer' 26 | | 'prev' 27 | | 'search' 28 | | 'tag' 29 | 30 | export interface NavLinkProps { 31 | to?: RouteLocationRaw | string | null 32 | href?: string 33 | target?: ATagTargetAttrValues 34 | rel?: ATagRelAttrValues 35 | } 36 | 37 | export interface NavLink extends NavLinkProps, Partial { 38 | title: string 39 | icon?: unknown 40 | badgeContent?: string 41 | badgeClass?: string 42 | disable?: boolean 43 | } 44 | 45 | // 👉 Vertical nav group 46 | export interface NavGroup extends Partial { 47 | title: string 48 | icon?: unknown 49 | badgeContent?: string 50 | badgeClass?: string 51 | children: (NavLink | NavGroup)[] 52 | disable?: boolean 53 | } 54 | 55 | // 👉 Components ======================== 56 | export interface ThemeSwitcherTheme { 57 | name: string 58 | icon: string 59 | } 60 | -------------------------------------------------------------------------------- /frontend/src/@layouts/utils.ts: -------------------------------------------------------------------------------- 1 | export const hexToRgb = (hex: string) => { 2 | // Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF") 3 | const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i 4 | 5 | hex = hex.replace(shorthandRegex, (m: string, r: string, g: string, b: string) => { 6 | return r + r + g + g + b + b 7 | }) 8 | 9 | const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) 10 | 11 | return result ? `${Number.parseInt(result[1], 16)},${Number.parseInt(result[2], 16)},${Number.parseInt(result[3], 16)}` : null 12 | } 13 | -------------------------------------------------------------------------------- /frontend/src/App.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /frontend/src/assets/images/avatar1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Suwmlee/bonita/05c7a84ba4b9d88bba7f374000d07b776484f865/frontend/src/assets/images/avatar1.png -------------------------------------------------------------------------------- /frontend/src/assets/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Suwmlee/bonita/05c7a84ba4b9d88bba7f374000d07b776484f865/frontend/src/assets/images/logo.png -------------------------------------------------------------------------------- /frontend/src/assets/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /frontend/src/assets/images/pages/404.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Suwmlee/bonita/05c7a84ba4b9d88bba7f374000d07b776484f865/frontend/src/assets/images/pages/404.png -------------------------------------------------------------------------------- /frontend/src/assets/images/svg/checkbox-checked.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/src/assets/images/svg/checkbox-indeterminate.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/src/assets/images/svg/checkbox-unchecked.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/images/svg/radio-checked.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/images/svg/radio-unchecked.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/styles/styles.scss: -------------------------------------------------------------------------------- 1 | // Write your overrides 2 | 3 | .row-label { 4 | display: flex; 5 | align-items: center; 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/assets/styles/variables/_template.scss: -------------------------------------------------------------------------------- 1 | @forward "@core/scss/template/variables"; 2 | 3 | // ℹ️ Remove above import and uncomment below to override core variables. 4 | // @forward "@core/scss/template/variables" with ( 5 | // $: 6 | // ) 7 | -------------------------------------------------------------------------------- /frontend/src/assets/styles/variables/_vuetify.scss: -------------------------------------------------------------------------------- 1 | // ❗ Path must be relative 2 | @forward "../../../@core/scss/template/libs/vuetify/variables"; 3 | 4 | // ℹ️ Remove above import and uncomment below to override core variables. 5 | // @forward "../../../@core/scss/template/libs/vuetify/variables" with ( 6 | // $: 7 | // ) 8 | -------------------------------------------------------------------------------- /frontend/src/client/core/ApiError.ts: -------------------------------------------------------------------------------- 1 | import type { ApiRequestOptions } from './ApiRequestOptions'; 2 | import type { ApiResult } from './ApiResult'; 3 | 4 | export class ApiError extends Error { 5 | public readonly url: string; 6 | public readonly status: number; 7 | public readonly statusText: string; 8 | public readonly body: unknown; 9 | public readonly request: ApiRequestOptions; 10 | 11 | constructor(request: ApiRequestOptions, response: ApiResult, message: string) { 12 | super(message); 13 | 14 | this.name = 'ApiError'; 15 | this.url = response.url; 16 | this.status = response.status; 17 | this.statusText = response.statusText; 18 | this.body = response.body; 19 | this.request = request; 20 | } 21 | } -------------------------------------------------------------------------------- /frontend/src/client/core/ApiRequestOptions.ts: -------------------------------------------------------------------------------- 1 | export type ApiRequestOptions = { 2 | readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH'; 3 | readonly url: string; 4 | readonly path?: Record; 5 | readonly cookies?: Record; 6 | readonly headers?: Record; 7 | readonly query?: Record; 8 | readonly formData?: Record; 9 | readonly body?: any; 10 | readonly mediaType?: string; 11 | readonly responseHeader?: string; 12 | readonly responseTransformer?: (data: unknown) => Promise; 13 | readonly errors?: Record; 14 | }; -------------------------------------------------------------------------------- /frontend/src/client/core/ApiResult.ts: -------------------------------------------------------------------------------- 1 | export type ApiResult = { 2 | readonly body: TData; 3 | readonly ok: boolean; 4 | readonly status: number; 5 | readonly statusText: string; 6 | readonly url: string; 7 | }; -------------------------------------------------------------------------------- /frontend/src/client/core/OpenAPI.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosRequestConfig, AxiosResponse } from 'axios'; 2 | import type { ApiRequestOptions } from './ApiRequestOptions'; 3 | 4 | type Headers = Record; 5 | type Middleware = (value: T) => T | Promise; 6 | type Resolver = (options: ApiRequestOptions) => Promise; 7 | 8 | export class Interceptors { 9 | _fns: Middleware[]; 10 | 11 | constructor() { 12 | this._fns = []; 13 | } 14 | 15 | eject(fn: Middleware): void { 16 | const index = this._fns.indexOf(fn); 17 | if (index !== -1) { 18 | this._fns = [...this._fns.slice(0, index), ...this._fns.slice(index + 1)]; 19 | } 20 | } 21 | 22 | use(fn: Middleware): void { 23 | this._fns = [...this._fns, fn]; 24 | } 25 | } 26 | 27 | export type OpenAPIConfig = { 28 | BASE: string; 29 | CREDENTIALS: 'include' | 'omit' | 'same-origin'; 30 | ENCODE_PATH?: ((path: string) => string) | undefined; 31 | HEADERS?: Headers | Resolver | undefined; 32 | PASSWORD?: string | Resolver | undefined; 33 | TOKEN?: string | Resolver | undefined; 34 | USERNAME?: string | Resolver | undefined; 35 | VERSION: string; 36 | WITH_CREDENTIALS: boolean; 37 | interceptors: { 38 | request: Interceptors; 39 | response: Interceptors; 40 | }; 41 | }; 42 | 43 | export const OpenAPI: OpenAPIConfig = { 44 | BASE: '', 45 | CREDENTIALS: 'include', 46 | ENCODE_PATH: undefined, 47 | HEADERS: undefined, 48 | PASSWORD: undefined, 49 | TOKEN: undefined, 50 | USERNAME: undefined, 51 | VERSION: '0.8.0', 52 | WITH_CREDENTIALS: false, 53 | interceptors: { 54 | request: new Interceptors(), 55 | response: new Interceptors(), 56 | }, 57 | }; -------------------------------------------------------------------------------- /frontend/src/client/index.ts: -------------------------------------------------------------------------------- 1 | // This file is auto-generated by @hey-api/openapi-ts 2 | export { ApiError } from './core/ApiError'; 3 | export { CancelablePromise, CancelError } from './core/CancelablePromise'; 4 | export { OpenAPI, type OpenAPIConfig } from './core/OpenAPI'; 5 | export * from './schemas.gen'; 6 | export * from './services.gen'; 7 | export * from './types.gen'; -------------------------------------------------------------------------------- /frontend/src/components/GlobalToast.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 26 | 27 | 30 | -------------------------------------------------------------------------------- /frontend/src/components/README.md: -------------------------------------------------------------------------------- 1 | # Components 2 | 3 | Vue template files in this folder are automatically imported. 4 | 5 | ## 🚀 Usage 6 | 7 | Importing is handled by [unplugin-vue-components](https://github.com/unplugin/unplugin-vue-components). This plugin automatically imports `.vue` files created in the `src/components` directory, and registers them as global components. This means that you can use any component in your application without having to manually import it. 8 | 9 | The following example assumes a component located at `src/components/MyComponent.vue`: 10 | 11 | ```vue 12 | 17 | 18 | 21 | ``` 22 | 23 | When your template is rendered, the component's import will automatically be inlined, which renders to this: 24 | 25 | ```vue 26 | 31 | 32 | 35 | ``` 36 | -------------------------------------------------------------------------------- /frontend/src/components/common/ConfirmationDialog.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | -------------------------------------------------------------------------------- /frontend/src/components/mediaitem/MediaItemDetailDialog.vue: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 23 | 24 | -------------------------------------------------------------------------------- /frontend/src/components/metadata/MetadataDetailDialog.vue: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 23 | 24 | 29 | -------------------------------------------------------------------------------- /frontend/src/components/record/RecordDetailDialog.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 21 | 22 | 27 | -------------------------------------------------------------------------------- /frontend/src/components/scraping/ScrapingConfigDialog.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 22 | 23 | 28 | -------------------------------------------------------------------------------- /frontend/src/components/task/TransferConfigDetailDialog.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 22 | 23 | 28 | -------------------------------------------------------------------------------- /frontend/src/hook/authCheck.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPI } from "@/client/core/OpenAPI" 2 | import { useAuthStore } from "@/stores/auth.store" 3 | 4 | // hook auth check 5 | const authCheck = () => { 6 | OpenAPI.interceptors.response.use(async (response) => { 7 | // Determine if it is an authentication error 8 | if (response.status === 401) { 9 | const errDetail = (response as any)?.data?.detail 10 | if (errDetail === "Could not validate credentials") { 11 | const authStore = useAuthStore() 12 | authStore.logout() 13 | } 14 | } 15 | 16 | return response // <-- must return response 17 | }) 18 | } 19 | 20 | export default authCheck 21 | -------------------------------------------------------------------------------- /frontend/src/layouts/README.md: -------------------------------------------------------------------------------- 1 | # Layouts 2 | 3 | Layouts are reusable components that wrap around pages. They are used to provide a consistent look and feel across multiple pages. 4 | 5 | Full documentation for this feature can be found in the Official [vite-plugin-vue-layouts](https://github.com/JohnCampionJr/vite-plugin-vue-layouts) repository. 6 | -------------------------------------------------------------------------------- /frontend/src/layouts/blank.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /frontend/src/layouts/components/LanguageSwitcher.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | -------------------------------------------------------------------------------- /frontend/src/layouts/components/NavItems.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 60 | -------------------------------------------------------------------------------- /frontend/src/layouts/components/NavbarThemeSwitcher.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 33 | -------------------------------------------------------------------------------- /frontend/src/layouts/components/UserProfile.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 89 | -------------------------------------------------------------------------------- /frontend/src/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | 11 | 15 | -------------------------------------------------------------------------------- /frontend/src/main.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * main.ts 3 | * 4 | * Bootstraps Vuetify and other plugins then mounts the App` 5 | */ 6 | 7 | import { OpenAPI } from "./client" 8 | 9 | // Components 10 | import App from "./App.vue" 11 | 12 | // Composables 13 | import { createApp } from "vue" 14 | 15 | // Plugins 16 | import { registerPlugins } from "@core/utils/plugins" 17 | import authCheck from "./hook/authCheck" 18 | 19 | // Styles 20 | import "@core/scss/template/index.scss" 21 | import "@layouts/styles/index.scss" 22 | import "@styles/styles.scss" 23 | 24 | OpenAPI.BASE = import.meta.env.VITE_API_URL || window.location.origin 25 | OpenAPI.TOKEN = async () => { 26 | return localStorage.getItem("access_token") || "" 27 | } 28 | 29 | const app = createApp(App) 30 | 31 | registerPlugins(app) 32 | app.use(authCheck) 33 | 34 | app.mount("#app") 35 | -------------------------------------------------------------------------------- /frontend/src/pages/Login.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 80 | 81 | 84 | -------------------------------------------------------------------------------- /frontend/src/pages/ScrapingConfigs.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 76 | -------------------------------------------------------------------------------- /frontend/src/pages/UserSettings.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 41 | -------------------------------------------------------------------------------- /frontend/src/pages/[...error].vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 23 | -------------------------------------------------------------------------------- /frontend/src/plugins/06.pinia.ts: -------------------------------------------------------------------------------- 1 | import { createPinia } from "pinia" 2 | import type { App } from "vue" 3 | 4 | export const store = createPinia() 5 | 6 | export default function (app: App) { 7 | app.use(store) 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/plugins/i18n/index.ts: -------------------------------------------------------------------------------- 1 | import type { App } from "vue" 2 | import { createI18n } from "vue-i18n" 3 | 4 | import en from "./locales/en" 5 | // 导入语言包 6 | import zh from "./locales/zh" 7 | 8 | // 获取浏览器语言 9 | const getBrowserLanguage = () => { 10 | const browserLang = navigator.language.toLowerCase() 11 | return browserLang.startsWith("zh") ? "zh" : "en" 12 | } 13 | 14 | // 从本地存储中获取保存的语言设置,如果没有则使用浏览器语言 15 | const getStoredLanguage = () => { 16 | return localStorage.getItem("language") || getBrowserLanguage() 17 | } 18 | 19 | // 创建 i18n 实例 20 | const i18n = createI18n({ 21 | legacy: false, // 使用 Composition API 模式 22 | locale: getStoredLanguage(), 23 | fallbackLocale: "zh", // 默认语言为中文 24 | messages: { 25 | zh, 26 | en, 27 | }, 28 | globalInjection: true, // 全局注入 $t 函数 29 | allowComposition: true, // 允许组合式 API 30 | }) 31 | 32 | // 导出 i18n 实例供组件内使用 33 | export { i18n } 34 | 35 | // 默认导出插件安装函数 36 | export default function (app: App) { 37 | app.use(i18n) 38 | return i18n 39 | } 40 | -------------------------------------------------------------------------------- /frontend/src/plugins/iconify/index.ts: -------------------------------------------------------------------------------- 1 | import './icons.css' 2 | 3 | export default function () { 4 | // This plugin just requires icons import 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/plugins/iconify/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "commonjs" 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/plugins/router/index.ts: -------------------------------------------------------------------------------- 1 | import { useAuthStore } from "@/stores/auth.store" 2 | import type { App } from "vue" 3 | // Composables 4 | import { createRouter, createWebHistory } from "vue-router" 5 | import { routes } from "./routes" 6 | 7 | const router = createRouter({ 8 | history: createWebHistory(import.meta.env.BASE_URL), 9 | routes, 10 | }) 11 | 12 | router.beforeEach((to, from, next) => { 13 | const authStore = useAuthStore() 14 | if (to.matched.some((record) => record.meta.requiresAuth)) { 15 | if (!authStore.isLoggedIn()) { 16 | authStore.returnUrl = to.fullPath 17 | next({ path: "/login" }) 18 | } else { 19 | next() 20 | } 21 | } else { 22 | next() 23 | } 24 | }) 25 | 26 | export default function (app: App) { 27 | app.use(router) 28 | } 29 | 30 | export { router } 31 | -------------------------------------------------------------------------------- /frontend/src/plugins/vuetify/icons.ts: -------------------------------------------------------------------------------- 1 | import type { IconAliases, IconProps } from "vuetify" 2 | 3 | import checkboxChecked from "@images/svg/checkbox-checked.svg" 4 | import checkboxIndeterminate from "@images/svg/checkbox-indeterminate.svg" 5 | import checkboxUnchecked from "@images/svg/checkbox-unchecked.svg" 6 | import radioChecked from "@images/svg/radio-checked.svg" 7 | import radioUnchecked from "@images/svg/radio-unchecked.svg" 8 | 9 | const customIcons: Record = { 10 | "mdi-checkbox-blank-outline": checkboxUnchecked, 11 | "mdi-checkbox-marked": checkboxChecked, 12 | "mdi-minus-box": checkboxIndeterminate, 13 | "mdi-radiobox-marked": radioChecked, 14 | "mdi-radiobox-blank": radioUnchecked, 15 | } 16 | 17 | const aliases: Partial = { 18 | calendar: "bx-calendar", 19 | collapse: "bx-chevron-up", 20 | complete: "bx-check", 21 | cancel: "bx-x", 22 | close: "bx-x", 23 | delete: "bx-bxs-x-circle", 24 | clear: "bx-x-circle", 25 | success: "bx-check-circle", 26 | info: "bx-info-circle", 27 | warning: "bx-error", 28 | error: "bx-error-circle", 29 | prev: "bx-chevron-left", 30 | ratingEmpty: "bx-star", 31 | ratingFull: "bx-bxs-star", 32 | ratingHalf: "bx-bxs-star-half", 33 | next: "bx-chevron-right", 34 | delimiter: "bx-circle", 35 | sort: "bx-up-arrow-alt", 36 | expand: "bx-chevron-down", 37 | menu: "bx-menu", 38 | subgroup: "bx-caret-down", 39 | dropdown: "bx-chevron-down", 40 | edit: "bx-pencil", 41 | loading: "bx-refresh", 42 | first: "bx-skip-previous", 43 | last: "bx-skip-next", 44 | unfold: "bx-move-vertical", 45 | file: "bx-paperclip", 46 | plus: "bx-plus", 47 | minus: "bx-minus", 48 | sortAsc: "bx-up-arrow-alt", 49 | sortDesc: "bx-down-arrow-alt", 50 | } 51 | 52 | export const iconify = { 53 | component: (props: IconProps) => { 54 | // Load custom SVG directly instead of going through icon component 55 | if (typeof props.icon === "string") { 56 | const iconComponent = customIcons[props.icon] 57 | 58 | if (iconComponent) return h(iconComponent) 59 | } 60 | 61 | return h(props.tag, { 62 | ...props, 63 | 64 | // As we are using class based icons 65 | class: [props.icon], 66 | 67 | // Remove used props from DOM rendering 68 | tag: undefined, 69 | icon: undefined, 70 | }) 71 | }, 72 | } 73 | 74 | export const icons = { 75 | defaultSet: "iconify", 76 | aliases, 77 | sets: { 78 | iconify, 79 | }, 80 | } 81 | -------------------------------------------------------------------------------- /frontend/src/plugins/vuetify/index.ts: -------------------------------------------------------------------------------- 1 | import type { App } from "vue" 2 | 3 | import { createVuetify } from "vuetify" 4 | import { VBtn } from "vuetify/components/VBtn" 5 | import defaults from "./defaults" 6 | import { icons } from "./icons" 7 | import { themes } from "./theme" 8 | 9 | // Styles 10 | 11 | import "@core/scss/template/libs/vuetify/index.scss" 12 | import "vuetify/styles" 13 | 14 | export default function (app: App) { 15 | const vuetify = createVuetify({ 16 | aliases: { 17 | IconBtn: VBtn, 18 | }, 19 | defaults, 20 | icons, 21 | theme: { 22 | defaultTheme: "light", 23 | themes, 24 | }, 25 | }) 26 | 27 | app.use(vuetify) 28 | } 29 | -------------------------------------------------------------------------------- /frontend/src/stores/app.store.ts: -------------------------------------------------------------------------------- 1 | import { StatusService } from "@/client/services.gen" 2 | import { defineStore } from "pinia" 3 | 4 | export const useAppStore = defineStore("app-store", { 5 | state: () => ({ 6 | currentTheme: "light", 7 | version: "", 8 | }), 9 | actions: { 10 | getTheme() { 11 | this.currentTheme = localStorage.getItem("theme") ?? "light" 12 | return this.currentTheme 13 | }, 14 | updateTheme(theme: string) { 15 | this.currentTheme = theme 16 | localStorage.setItem("theme", theme) 17 | }, 18 | async fetchVersion() { 19 | try { 20 | const response = await StatusService.healthCheck() 21 | if (response?.version) { 22 | this.version = response.version 23 | } 24 | } catch (error) { 25 | console.error("获取版本信息失败", error) 26 | } 27 | }, 28 | }, 29 | }) 30 | -------------------------------------------------------------------------------- /frontend/src/stores/auth.store.ts: -------------------------------------------------------------------------------- 1 | // Utilities 2 | import { LoginService } from "@/client" 3 | import type { 4 | Body_login_login_access_token as AccessToken, 5 | Token, 6 | } from "@/client" 7 | import { router } from "@/plugins/router" 8 | import { handleError } from "@/utils" 9 | import { defineStore } from "pinia" 10 | import { useToastStore } from "./toast.store" 11 | 12 | export const useAuthStore = defineStore("auth-store", { 13 | state: () => ({ 14 | returnUrl: "", 15 | }), 16 | actions: { 17 | isLoggedIn() { 18 | return localStorage.getItem("access_token") !== null 19 | }, 20 | async login(email: string, pwd: string) { 21 | const showToast = useToastStore() 22 | const data: AccessToken = { 23 | username: email, 24 | password: pwd, 25 | } 26 | await LoginService.loginAccessToken({ formData: data }) 27 | .then((response: Token) => { 28 | localStorage.setItem("access_token", response.access_token) 29 | // redirect to previous url or default to home page 30 | router.push(this.returnUrl || "/dashboard") 31 | }) 32 | .catch((error) => { 33 | handleError(error, showToast) 34 | }) 35 | }, 36 | logout() { 37 | localStorage.removeItem("access_token") 38 | router.push("/login") 39 | }, 40 | }, 41 | }) 42 | -------------------------------------------------------------------------------- /frontend/src/stores/confirmation.store.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia" 2 | 3 | export type ConfirmationOptions = { 4 | title: string 5 | message: string 6 | confirmText?: string 7 | cancelText?: string 8 | confirmColor?: string 9 | type?: "delete" | "warning" | "info" 10 | data?: any 11 | } 12 | 13 | export const useConfirmationStore = defineStore("confirmation-store", { 14 | state: () => ({ 15 | show: false, 16 | options: { 17 | title: "Confirm Action", 18 | message: "Are you sure you want to proceed?", 19 | confirmText: "Confirm", 20 | cancelText: "Cancel", 21 | confirmColor: "primary", 22 | type: "warning", 23 | } as ConfirmationOptions, 24 | resolvePromise: null as ((value: boolean) => void) | null, 25 | }), 26 | 27 | actions: { 28 | openConfirmation(options: ConfirmationOptions) { 29 | this.show = true 30 | this.options = { ...this.options, ...options } 31 | 32 | // Set default styles based on type 33 | if (options.type === "delete" && !options.confirmColor) { 34 | this.options.confirmColor = "error" 35 | this.options.confirmText = options.confirmText || "Delete" 36 | } 37 | 38 | // Return a promise that will be resolved when the user confirms or cancels 39 | return new Promise((resolve) => { 40 | this.resolvePromise = resolve 41 | }) 42 | }, 43 | 44 | confirm() { 45 | if (this.resolvePromise) { 46 | this.resolvePromise(true) 47 | this.resolvePromise = null 48 | } 49 | this.show = false 50 | }, 51 | 52 | cancel() { 53 | if (this.resolvePromise) { 54 | this.resolvePromise(false) 55 | this.resolvePromise = null 56 | } 57 | this.show = false 58 | }, 59 | 60 | // Specialized confirmation helpers 61 | async confirmDelete(title: string, message: string, data?: any) { 62 | return await this.openConfirmation({ 63 | title, 64 | message, 65 | type: "delete", 66 | confirmText: "Delete", 67 | data, 68 | }) 69 | }, 70 | }, 71 | }) 72 | -------------------------------------------------------------------------------- /frontend/src/stores/log.store.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia" 2 | 3 | // 定义日志条目类型,不再从客户端导入 4 | interface LogEntry { 5 | timestamp: string 6 | level: string 7 | module: string 8 | message: string 9 | } 10 | 11 | export const useLogStore = defineStore("log-store", { 12 | state: () => ({ 13 | logs: [] as LogEntry[], 14 | }), 15 | actions: { 16 | clearLogs() { 17 | // 直接清空日志,不再调用API 18 | this.logs = [] 19 | return { success: true } 20 | }, 21 | 22 | // 处理WebSocket接收到的日志 23 | handleWebSocketLogs(data: any) { 24 | // 检查是否为批量日志格式 (服务端返回 {logs: [...]} 格式) 25 | if (data.logs && Array.isArray(data.logs)) { 26 | // 批量添加日志 27 | this.logs = [...this.logs, ...data.logs] 28 | console.log(`批量添加了 ${data.logs.length} 条日志`) 29 | } 30 | // 单条日志处理 31 | else { 32 | this.logs = [...this.logs, data] 33 | } 34 | 35 | // 限制日志数量,避免过多导致性能问题 36 | if (this.logs.length > 1000) { 37 | this.logs = this.logs.slice(-1000) 38 | } 39 | }, 40 | }, 41 | }) 42 | -------------------------------------------------------------------------------- /frontend/src/stores/toast.store.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia" 2 | 3 | interface ToastState { 4 | /** 通知内容 */ 5 | msg: string 6 | /** 通知颜色 */ 7 | color: string 8 | /** 显示 */ 9 | visible?: boolean 10 | /** 是否显示关闭按钮 */ 11 | showClose?: boolean 12 | /** 超时时间 */ 13 | timeout?: number 14 | /** 防抖计时器 */ 15 | debounceTimer?: NodeJS.Timeout | null 16 | } 17 | 18 | export const useToastStore = defineStore("toast-store", { 19 | state: (): ToastState => { 20 | return { 21 | msg: "", 22 | color: "info", 23 | visible: false, 24 | showClose: true, 25 | timeout: 3000, 26 | debounceTimer: null, 27 | } 28 | }, 29 | actions: { 30 | /** 31 | * 显示通知 32 | * @param options 参数 33 | */ 34 | open(options: ToastState) { 35 | this.msg = options.msg 36 | if (options?.color) { 37 | this.color = options.color 38 | } 39 | if (options?.timeout) { 40 | this.timeout = options.timeout 41 | } 42 | this.visible = true 43 | // 防抖 44 | if (this.debounceTimer) { 45 | clearTimeout(this.debounceTimer) 46 | } 47 | this.debounceTimer = setTimeout(() => { 48 | this.visible = false 49 | }, this.timeout) 50 | }, 51 | /** 52 | * 成功通知 53 | * @param msg 消息 54 | */ 55 | success(msg: string) { 56 | this.open({ 57 | msg, 58 | color: "success", 59 | }) 60 | }, 61 | /** 62 | * 错误通知 63 | * @param msg 消息 64 | */ 65 | error(msg: string) { 66 | this.open({ 67 | msg, 68 | color: "error", 69 | }) 70 | }, 71 | /** 72 | * info通知 73 | * @param msg 消息 74 | */ 75 | info(msg: string) { 76 | this.open({ 77 | msg, 78 | color: "info", 79 | }) 80 | }, 81 | /** 82 | * 警告通知 83 | * @param msg 消息 84 | */ 85 | warning(msg: string) { 86 | this.open({ 87 | msg, 88 | color: "warning", 89 | }) 90 | }, 91 | }, 92 | }) 93 | -------------------------------------------------------------------------------- /frontend/src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { ApiError } from "./client" 2 | 3 | export const handleError = (err: ApiError, showToast: any) => { 4 | const errDetail = (err.body as any)?.detail 5 | let errorMessage = errDetail || "Something went wrong." 6 | if (Array.isArray(errDetail) && errDetail.length > 0) { 7 | errorMessage = errDetail[0].msg 8 | } 9 | showToast.error(errorMessage) 10 | } 11 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "useDefineForClassFields": true, 5 | "module": "esnext", 6 | "moduleResolution": "Bundler", 7 | "isolatedModules": true, 8 | "strict": true, 9 | "jsx": "preserve", 10 | "jsxFactory": "h", 11 | "jsxFragmentFactory": "Fragment", 12 | "sourceMap": true, 13 | "resolveJsonModule": true, 14 | "esModuleInterop": true, 15 | "paths": { 16 | "@/*": ["./src/*"], 17 | "@layouts/*": ["./src/@layouts/*"], 18 | "@layouts": ["./src/@layouts"], 19 | "@core/*": ["./src/@core/*"], 20 | "@core": ["./src/@core"], 21 | "@images/*": ["./src/assets/images/*"], 22 | "@styles/*": ["./src/assets/styles/*"] 23 | }, 24 | "lib": ["esnext", "dom", "dom.iterable", "scripthost"], 25 | "skipLibCheck": true, 26 | "types": ["vite/client", "vite-plugin-vue-layouts/client"] 27 | }, 28 | "include": [ 29 | "./vite.config.*", 30 | "./src/**/*", 31 | "./src/**/*.vue", 32 | "./src/*.d.ts" 33 | ], 34 | "exclude": ["./dist", "./node_modules"] 35 | } 36 | -------------------------------------------------------------------------------- /frontend/vite.config.mts: -------------------------------------------------------------------------------- 1 | import Vue from "@vitejs/plugin-vue" 2 | // Plugins 3 | import AutoImport from "unplugin-auto-import/vite" 4 | import Components from "unplugin-vue-components/vite" 5 | import Layouts from "vite-plugin-vue-layouts" 6 | import Vuetify, { transformAssetUrls } from "vite-plugin-vuetify" 7 | import svgLoader from "vite-svg-loader" 8 | 9 | import { URL, fileURLToPath } from "node:url" 10 | // Utilities 11 | import { defineConfig } from "vite" 12 | 13 | // https://vitejs.dev/config/ 14 | export default defineConfig({ 15 | plugins: [ 16 | Layouts(), 17 | Vue({ 18 | template: { transformAssetUrls }, 19 | }), 20 | // https://github.com/vuetifyjs/vuetify-loader/tree/master/packages/vite-plugin#readme 21 | Vuetify({ 22 | autoImport: true, 23 | styles: { 24 | configFile: "src/assets/styles/variables/_vuetify.scss", 25 | }, 26 | }), 27 | Components({ 28 | dirs: ["src/@core/components", "src/components"], 29 | dts: "src/components.d.ts", 30 | }), 31 | // Docs: https://github.com/antfu/unplugin-auto-import#unplugin-auto-import 32 | AutoImport({ 33 | imports: ["vue", "vue-router", "@vueuse/core", "pinia"], 34 | dts: "src/auto-imports.d.ts", 35 | vueTemplate: true, 36 | // ℹ️ Disabled to avoid confusion & accidental usage 37 | ignore: ["useCookies", "useStorage"], 38 | }), 39 | svgLoader(), 40 | ], 41 | define: { "process.env": {} }, 42 | resolve: { 43 | alias: { 44 | "@": fileURLToPath(new URL("./src", import.meta.url)), 45 | "@core": fileURLToPath(new URL("./src/@core", import.meta.url)), 46 | "@layouts": fileURLToPath(new URL("./src/@layouts", import.meta.url)), 47 | "@images": fileURLToPath( 48 | new URL("./src/assets/images/", import.meta.url), 49 | ), 50 | "@styles": fileURLToPath( 51 | new URL("./src/assets/styles/", import.meta.url), 52 | ), 53 | "@configured-variables": fileURLToPath( 54 | new URL( 55 | "./src/assets/styles/variables/_template.scss", 56 | import.meta.url, 57 | ), 58 | ), 59 | }, 60 | }, 61 | build: { 62 | chunkSizeWarningLimit: 5000, 63 | }, 64 | server: { 65 | port: 3000, 66 | }, 67 | }) 68 | --------------------------------------------------------------------------------