├── .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 | [](https://github.com/suwmlee/bonita/actions) [](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 |
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 |
12 |
13 |
17 |
18 |
22 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/frontend/src/@core/components/ThemeSwitcher.vue:
--------------------------------------------------------------------------------
1 |
21 |
22 |
23 |
24 |
25 |
30 | {{ currentThemeName }}
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/frontend/src/@core/components/cards/CardStatisticsHorizontal.vue:
--------------------------------------------------------------------------------
1 |
16 |
17 |
18 |
19 |
20 |
27 |
31 |
32 |
33 |
34 |
{{ props.title }}
35 |
36 |
{{ kFormatter(props.stats) }}
37 |
41 |
42 | {{ Math.abs(props.change) }}%
43 |
44 |
45 |
46 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/frontend/src/@core/components/cards/CardStatisticsVertical.vue:
--------------------------------------------------------------------------------
1 |
19 |
20 |
21 |
22 |
23 |
28 |
29 |
30 |
31 |
35 |
36 |
37 |
38 |
39 | {{ props.title }}
40 |
41 |
42 | {{ props.stats }}
43 |
44 |
48 |
52 | {{ isPositive ? Math.abs(props.change) : props.change }}%
53 |
54 |
55 |
56 |
57 |
--------------------------------------------------------------------------------
/frontend/src/@core/components/cards/CardStatisticsWithImages.vue:
--------------------------------------------------------------------------------
1 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | {{ props.title }}
24 |
25 |
26 |
27 | {{ props.stats }}
28 |
29 |
33 | {{ isPositive ? `+${props.change}` : props.change }}%
34 |
35 |
36 |
37 |
42 | {{ props.subtitle }}
43 |
44 |
45 |
46 |
47 |
48 |
49 |
54 |
55 |
56 |
57 |
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 |
12 |
16 |
20 |
24 | {{ item.title }}
25 |
29 | {{ item.badgeContent }}
30 |
31 |
35 |
36 |
41 |
42 |
43 |
44 |
71 |
--------------------------------------------------------------------------------
/frontend/src/@layouts/components/VerticalNavLink.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
14 |
20 |
24 |
25 |
26 | {{ item.title }}
27 |
28 |
32 | {{ item.badgeContent }}
33 |
34 |
35 |
36 |
37 |
38 |
47 |
--------------------------------------------------------------------------------
/frontend/src/@layouts/components/VerticalNavSectionTitle.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
12 |
13 |
17 |
18 |
19 |
20 |
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 |
2 |
3 |
4 |
5 |
6 |
7 |
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 |
8 |
16 | {{ toast.msg }}
17 |
18 |
23 |
24 |
25 |
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 |
13 |
14 |
15 |
16 |
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 |
27 |
28 |
29 |
30 |
31 |
32 |
35 | ```
36 |
--------------------------------------------------------------------------------
/frontend/src/components/common/ConfirmationDialog.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
15 |
16 |
17 | {{ confirmationStore.options.title || t('components.common.confirmation.title') }}
18 |
19 |
20 |
21 | {{ confirmationStore.options.message }}
22 |
23 |
24 |
25 |
26 |
30 | {{ confirmationStore.options.cancelText || t('components.common.confirmation.cancelText') }}
31 |
32 |
37 | {{ confirmationStore.options.confirmText || t('components.common.confirmation.confirmText') }}
38 |
39 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/frontend/src/components/mediaitem/MediaItemDetailDialog.vue:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
11 |
12 |
13 |
14 | {{ t('pages.mediaitem.editMediaItem') }}
15 | {{ t('pages.mediaitem.addMediaItem') }}
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/frontend/src/components/metadata/MetadataDetailDialog.vue:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
11 |
12 |
13 |
14 | {{ t('pages.metadata.editMetadata') }}
15 | {{ t('pages.metadata.addMetadata') }}
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
29 |
--------------------------------------------------------------------------------
/frontend/src/components/record/RecordDetailDialog.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
12 |
13 | {{ t('components.record.dialog.editTitle') }}
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
27 |
--------------------------------------------------------------------------------
/frontend/src/components/scraping/ScrapingConfigDialog.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
12 |
13 | {{ t('components.scraping.dialog.editTitle') }}
14 | {{ t('components.scraping.dialog.addTitle') }}
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
28 |
--------------------------------------------------------------------------------
/frontend/src/components/task/TransferConfigDetailDialog.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
12 |
13 | {{ t('components.task.dialog.editTitle') }}
14 | {{ t('components.task.dialog.addTitle') }}
15 |
16 |
17 |
18 |
19 |
20 |
21 |
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 |
2 |
3 |
4 |
5 |
6 |
7 |
12 |
--------------------------------------------------------------------------------
/frontend/src/layouts/components/LanguageSwitcher.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 | {{ t(`language.${currentLanguageCode}`) }}
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/frontend/src/layouts/components/NavItems.vue:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
14 |
19 |
24 |
29 |
34 |
39 |
44 |
49 |
54 |
59 |
60 |
--------------------------------------------------------------------------------
/frontend/src/layouts/components/NavbarThemeSwitcher.vue:
--------------------------------------------------------------------------------
1 |
29 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/frontend/src/layouts/components/UserProfile.vue:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
20 |
25 |
26 |
27 |
28 |
34 |
35 |
36 |
37 |
38 |
39 |
46 |
50 |
51 |
52 |
53 |
54 |
55 | Admin
56 |
57 |
58 |
59 |
60 |
61 |
62 |
67 |
68 |
69 | Settings
70 |
71 |
72 |
73 |
74 |
79 |
80 |
81 | Logout
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
--------------------------------------------------------------------------------
/frontend/src/layouts/default.vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
7 |
8 |
9 |
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 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | {{ t('app.title') }}
37 |
38 |
39 |
40 |
41 |
44 |
45 | {{ t('auth.welcomeMessage') }}
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 | {{ t('auth.login') }}
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
84 |
--------------------------------------------------------------------------------
/frontend/src/pages/ScrapingConfigs.vue:
--------------------------------------------------------------------------------
1 |
27 |
28 |
29 |
30 |
31 |
32 | Scraping Configs
33 |
34 |
35 |
36 |
38 |
39 |
40 |
41 | {{ data.name }}
42 |
43 |
44 |
45 |
46 |
47 | {{ data.description }}
48 |
49 |
50 |
51 |
52 |
53 | {{ data.location_rule }}
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
--------------------------------------------------------------------------------
/frontend/src/pages/UserSettings.vue:
--------------------------------------------------------------------------------
1 |
19 |
20 |
21 |
22 |
23 | {{ t('pages.userSettings.title') }}
24 |
25 |
26 |
27 |
28 |
29 | {{ item.title }}
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/frontend/src/pages/[...error].vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
8 |
9 |
10 |
14 | Back to Home
15 |
16 |
17 |
18 |
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 |
--------------------------------------------------------------------------------