├── .flake8 ├── .gitignore ├── .style.yapf ├── .vscode ├── README.md ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── backend ├── .env ├── .gitignore ├── README.md ├── app │ ├── alembic.ini │ ├── alembic │ │ ├── README.md │ │ ├── env.py │ │ ├── script.py.mako │ │ └── versions │ │ │ └── 11fa3bdfe542_init.py │ ├── app │ │ ├── api │ │ │ ├── __init__.py │ │ │ ├── api_v1 │ │ │ │ ├── __init__.py │ │ │ │ ├── api.py │ │ │ │ └── endpoints │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── login.py │ │ │ │ │ ├── permissions.py │ │ │ │ │ ├── roles.py │ │ │ │ │ └── users.py │ │ │ └── deps.py │ │ ├── backend_pre_start.py │ │ ├── core │ │ │ ├── __init__.py │ │ │ ├── config.py │ │ │ └── security.py │ │ ├── crud │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── crud_permission.py │ │ │ ├── crud_role.py │ │ │ └── crud_user.py │ │ ├── db │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── base_class.py │ │ │ ├── init_db.py │ │ │ └── session.py │ │ ├── initial_data.py │ │ ├── main.py │ │ ├── models │ │ │ ├── __init__.py │ │ │ ├── permission.py │ │ │ ├── role.py │ │ │ ├── role_permission_rel.py │ │ │ ├── user.py │ │ │ └── user_role_rel.py │ │ ├── schemas │ │ │ ├── __init__.py │ │ │ ├── msg.py │ │ │ ├── permission.py │ │ │ ├── role.py │ │ │ ├── token.py │ │ │ └── user.py │ │ └── utils.py │ └── prestart.sh ├── backend.dockerfile ├── requirement.txt └── start_uvicorn.sh ├── docker-compose.dev.yml ├── docker-compose.yml ├── frontend ├── .browserslistrc ├── .dockerignore ├── .env ├── .env.development ├── .env.production ├── .eslintrc.js ├── .gitignore ├── Dockerfile ├── README.md ├── babel.config.js ├── config │ ├── plugin.config.js │ └── theme-plugin.config.js ├── jsconfig.json ├── nginx.conf ├── package.json ├── public │ ├── index.html │ ├── logo.png │ └── robots.txt ├── src │ ├── App.vue │ ├── api │ │ ├── index.js │ │ ├── login.api.js │ │ ├── permissions.api.js │ │ ├── roles.api.js │ │ └── users.api.js │ ├── assets │ │ ├── background.svg │ │ ├── logo.png │ │ └── logo.svg │ ├── components │ │ └── FormBuilder │ │ │ ├── AntFormAdaptor.vue │ │ │ └── FormBuilder.vue │ ├── config │ │ ├── default-settings.js │ │ └── router.config.js │ ├── directives │ │ └── auth.js │ ├── global.less │ ├── layouts │ │ ├── BasicLayout.less │ │ ├── BasicLayout.vue │ │ ├── RouteView.vue │ │ ├── UserLayout.vue │ │ ├── components │ │ │ ├── AvatarDropdown.vue │ │ │ ├── RightContent.vue │ │ │ └── SelectLang.vue │ │ └── index.js │ ├── locales │ │ ├── account.i18n.js │ │ ├── common.i18n.js │ │ ├── index.js │ │ ├── layouts.i18n.js │ │ ├── login.i18n.js │ │ ├── menu.i18n.js │ │ ├── modules │ │ │ ├── admin │ │ │ │ ├── index.js │ │ │ │ ├── role.i18n.js │ │ │ │ ├── security-log.i18n.js │ │ │ │ └── user.i18n.js │ │ │ ├── apps │ │ │ │ └── index.js │ │ │ └── dash │ │ │ │ └── index.js │ │ ├── permissions.i18n.js │ │ └── pro-layout-setting.i18n.js │ ├── main.js │ ├── mixins │ │ ├── app.mixin.js │ │ ├── data-table.mixin.js │ │ └── normal-crud.mixin.js │ ├── permissions │ │ ├── README.md │ │ └── admin │ │ │ ├── role.json │ │ │ ├── security-log.json │ │ │ └── user.json │ ├── plugins │ │ ├── antd.js │ │ ├── antdpro.js │ │ ├── auth.js │ │ ├── composition-api.js │ │ ├── fext.js │ │ ├── i18n.js │ │ ├── iconfont.js │ │ └── vee-validate.js │ ├── registerServiceWorker.js │ ├── router │ │ ├── index.js │ │ └── router-hook.js │ ├── store │ │ ├── getters.js │ │ ├── index.js │ │ ├── modules │ │ │ ├── app.js │ │ │ ├── permission.js │ │ │ └── user.js │ │ └── mutation-types.js │ ├── use │ │ └── form │ │ │ ├── element.js │ │ │ └── form.js │ ├── utils │ │ ├── request.js │ │ └── string.js │ └── views │ │ ├── account │ │ └── settings │ │ │ ├── AccountSettings.vue │ │ │ ├── Basic.vue │ │ │ ├── Security.vue │ │ │ ├── basic.form.js │ │ │ └── password-change.form.js │ │ ├── admin │ │ ├── role │ │ │ ├── Role.vue │ │ │ ├── detail.form.js │ │ │ └── module.config.js │ │ ├── security-log │ │ │ └── SecurityLog.vue │ │ └── user │ │ │ ├── User.vue │ │ │ ├── detail.form.js │ │ │ └── module.config.js │ │ ├── dash │ │ └── Dashboard.vue │ │ ├── exception │ │ ├── 403.vue │ │ ├── 404.vue │ │ └── 500.vue │ │ └── login │ │ └── Login.vue ├── vue.config.js └── yarn.lock ├── misc └── demo.png ├── scripts ├── frontend-api-code-generator │ ├── README.md │ ├── gen.py │ └── openapi.example.json └── permissions-uploader │ ├── README.md │ └── uploader.py └── vetur.config.js /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 140 3 | exclude = .git,__pycache__,__init__.py,.mypy_cache,.pytest_cache 4 | ignore = 5 | # E111: indentation is not a multiple of four 6 | E111, 7 | # E114: indentation is not a multiple of four (comment) 8 | E114, 9 | # E121: continuation line under-indented for hanging indent 10 | E121, -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l2m2/fastapi-vue-admin/5e38112dc87c77c9a5bd7d446113622327ff6a67/.gitignore -------------------------------------------------------------------------------- /.style.yapf: -------------------------------------------------------------------------------- 1 | [style] 2 | based_on_style = pep8 3 | INDENT_WIDTH = 2 4 | CONTINUATION_INDENT_WIDTH = 2 5 | COLUMN_LIMIT = 140 -------------------------------------------------------------------------------- /.vscode/README.md: -------------------------------------------------------------------------------- 1 | ## extensions.json 2 | 定制设置该项目的VS Code插件配置。 3 | - recommendations 设置推荐安装的插件。若没有安装,打开此项目VS Code会自动提示。 4 | 5 | ## settings.json 6 | 定制设置该项目的VS Code配置。 7 | 8 | - python.autoComplete.extraPaths 9 | 10 | 自动补全添加当前工作目录,参考[这里](https://github.com/microsoft/python-language-server/blob/master/TROUBLESHOOTING.md#unresolved-import-warnings) -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "octref.vetur" 5 | ] 6 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.autoComplete.extraPaths": [ 3 | "./backend/app" 4 | ], 5 | "python.envFile": "${workspaceFolder}/.env", 6 | "terminal.integrated.env.osx": { 7 | "PYTHONPATH": "${env:PYTHONPATH}:${workspaceFolder}/backend/app", 8 | }, 9 | "terminal.integrated.env.linux": { 10 | "PYTHONPATH": "${env:PYTHONPATH}:${workspaceFolder}/backend/app", 11 | }, 12 | "terminal.integrated.env.windows": { 13 | "PYTHONPATH": "${env:PYTHONPATH}:${workspaceFolder}/backend/app", 14 | }, 15 | "editor.codeActionsOnSave": { 16 | "source.fixAll.eslint": true 17 | }, 18 | "eslint.workingDirectories": [ 19 | { 20 | "mode": "auto" 21 | } 22 | ], 23 | "[python]": { 24 | "editor.formatOnSave": true 25 | }, 26 | "python.formatting.provider": "yapf", 27 | "python.linting.enabled": true, 28 | "python.linting.flake8Enabled": true, 29 | "svg.preview.background": "transparent" 30 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 l2m2 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fastapi-vue-admin 2 | 3 | [![License](https://img.shields.io/npm/l/package.json.svg?style=flat)](https://github.com/vueComponent/ant-design-vue-pro/blob/master/LICENSE) 4 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://github.com/l2m2/fastapi-vue-admin) 5 | 6 | 7 | [在线预览](http://8.142.66.90:18080/) 8 | ![](misc/demo.png) 9 | 10 | ## 技术选型 11 | 12 | - 数据库:[PostgreSQL](https://www.postgresql.org/) 13 | - 后端:[FastAPI](https://fastapi.tiangolo.com/zh/) 14 | - 前端:[Vue.js](https://cn.vuejs.org/) 15 | - 前端UI框架:[Ant Design of Vue](https://antdv.com/docs/vue/introduce-cn/) 16 | - 部署:[Docker](https://www.docker.com/) 17 | ## 主要功能 18 | - 用户管理 19 | - 角色管理 20 | - 权限管理 21 | 22 | ## TODO 23 | - 智能看板 24 | - 动态表单 25 | 26 | ## 联系方式 27 | 28 | - 直接提Issue 29 | -------------------------------------------------------------------------------- /backend/.env: -------------------------------------------------------------------------------- 1 | DOMAIN=localhost 2 | 3 | # Python 3 4 | PYTHONPATH=${PYTHONPATH}:./backend/app 5 | 6 | # Postgres 7 | POSTGRES_SERVER=db 8 | POSTGRES_PORT=5432 9 | POSTGRES_USER=postgres 10 | POSTGRES_PASSWORD=postgres 11 | POSTGRES_DB=fastapi-vue-admin 12 | 13 | # Backend 14 | PROJECT_NAME=FastAPI Vue Admin 15 | FIRST_SUPERUSER=admin 16 | FIRST_FULLNAME=Admin 17 | FIRST_SUPERUSER_PASSWORD=admin -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 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 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | # .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ -------------------------------------------------------------------------------- /backend/README.md: -------------------------------------------------------------------------------- 1 | 开发时启动后端服务 2 | ```shell 3 | $ cd backend/app/app 4 | $ uvicorn main:app --reload 5 | ``` 6 | 查看交互式API文档: http://127.0.0.1:8000/docs#/ -------------------------------------------------------------------------------- /backend/app/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = alembic 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # sys.path path, will be prepended to sys.path if present. 11 | # defaults to the current working directory. 12 | prepend_sys_path = . 13 | 14 | # timezone to use when rendering the date 15 | # within the migration file as well as the filename. 16 | # string value is passed to dateutil.tz.gettz() 17 | # leave blank for localtime 18 | # timezone = 19 | 20 | # max length of characters to apply to the 21 | # "slug" field 22 | # truncate_slug_length = 40 23 | 24 | # set to 'true' to run the environment during 25 | # the 'revision' command, regardless of autogenerate 26 | # revision_environment = false 27 | 28 | # set to 'true' to allow .pyc and .pyo files without 29 | # a source .py file to be detected as revisions in the 30 | # versions/ directory 31 | # sourceless = false 32 | 33 | # version location specification; this defaults 34 | # to alembic/versions. When using multiple version 35 | # directories, initial revisions must be specified with --version-path 36 | # version_locations = %(here)s/bar %(here)s/bat alembic/versions 37 | 38 | # the output encoding used when revision files 39 | # are written from script.py.mako 40 | # output_encoding = utf-8 41 | 42 | sqlalchemy.url = driver://user:pass@localhost/dbname 43 | 44 | 45 | [post_write_hooks] 46 | # post_write_hooks defines scripts or Python functions that are run 47 | # on newly generated revision scripts. See the documentation for further 48 | # detail and examples 49 | 50 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 51 | # hooks=black 52 | # black.type=console_scripts 53 | # black.entrypoint=black 54 | # black.options=-l 79 55 | 56 | # Logging configuration 57 | [loggers] 58 | keys = root,sqlalchemy,alembic 59 | 60 | [handlers] 61 | keys = console 62 | 63 | [formatters] 64 | keys = generic 65 | 66 | [logger_root] 67 | level = WARN 68 | handlers = console 69 | qualname = 70 | 71 | [logger_sqlalchemy] 72 | level = WARN 73 | handlers = 74 | qualname = sqlalchemy.engine 75 | 76 | [logger_alembic] 77 | level = INFO 78 | handlers = 79 | qualname = alembic 80 | 81 | [handler_console] 82 | class = StreamHandler 83 | args = (sys.stderr,) 84 | level = NOTSET 85 | formatter = generic 86 | 87 | [formatter_generic] 88 | format = %(levelname)-5.5s [%(name)s] %(message)s 89 | datefmt = %H:%M:%S 90 | -------------------------------------------------------------------------------- /backend/app/alembic/README.md: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. 2 | 3 | 修改Model后自动生成迁移脚本 4 | ```shell 5 | $ alembic revision --autogenerate -m "xxx" 6 | ``` 7 | 8 | 根据迁移脚本更新数据库 9 | ```shell 10 | $ alembic upgrade head 11 | ``` -------------------------------------------------------------------------------- /backend/app/alembic/env.py: -------------------------------------------------------------------------------- 1 | from logging.config import fileConfig 2 | import os 3 | from sqlalchemy import engine_from_config 4 | from sqlalchemy import pool 5 | from alembic import context 6 | from app.db.base import Base 7 | 8 | # this is the Alembic Config object, which provides 9 | # access to the values within the .ini file in use. 10 | config = context.config 11 | 12 | # Interpret the config file for Python logging. 13 | # This line sets up loggers basically. 14 | fileConfig(config.config_file_name) 15 | 16 | # add your model's MetaData object here 17 | # for 'autogenerate' support 18 | target_metadata = Base.metadata 19 | # target_metadata = None 20 | 21 | 22 | def get_url(): 23 | user = os.getenv("POSTGRES_USER", "postgres") 24 | password = os.getenv("POSTGRES_PASSWORD", "") 25 | server = os.getenv("POSTGRES_SERVER", "db") 26 | port = os.getenv("POSTGRES_PORT", "5432") 27 | db = os.getenv("POSTGRES_DB", "app") 28 | return f"postgresql://{user}:{password}@{server}:{port}/{db}" 29 | 30 | 31 | # other values from the config, defined by the needs of env.py, 32 | # can be acquired: 33 | # my_important_option = config.get_main_option("my_important_option") 34 | # ... etc. 35 | 36 | 37 | def run_migrations_offline(): 38 | """Run migrations in 'offline' mode. 39 | 40 | This configures the context with just a URL 41 | and not an Engine, though an Engine is acceptable 42 | here as well. By skipping the Engine creation 43 | we don't even need a DBAPI to be available. 44 | 45 | Calls to context.execute() here emit the given string to the 46 | script output. 47 | 48 | """ 49 | # url = config.get_main_option("sqlalchemy.url") 50 | url = get_url() 51 | context.configure( 52 | url=url, 53 | target_metadata=target_metadata, 54 | literal_binds=True, 55 | dialect_opts={"paramstyle": "named"}, 56 | ) 57 | 58 | with context.begin_transaction(): 59 | context.run_migrations() 60 | 61 | 62 | def run_migrations_online(): 63 | """Run migrations in 'online' mode. 64 | 65 | In this scenario we need to create an Engine 66 | and associate a connection with the context. 67 | 68 | """ 69 | configuration = config.get_section(config.config_ini_section) 70 | configuration["sqlalchemy.url"] = get_url() 71 | connectable = engine_from_config( 72 | configuration, 73 | prefix="sqlalchemy.", 74 | poolclass=pool.NullPool, 75 | ) 76 | 77 | with connectable.connect() as connection: 78 | context.configure(connection=connection, target_metadata=target_metadata) 79 | 80 | with context.begin_transaction(): 81 | context.run_migrations() 82 | 83 | 84 | if context.is_offline_mode(): 85 | run_migrations_offline() 86 | else: 87 | run_migrations_online() 88 | -------------------------------------------------------------------------------- /backend/app/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 alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /backend/app/alembic/versions/11fa3bdfe542_init.py: -------------------------------------------------------------------------------- 1 | """init 2 | 3 | Revision ID: 11fa3bdfe542 4 | Revises: 5 | Create Date: 2021-03-22 15:45:48.367137 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects import postgresql 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '11fa3bdfe542' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('sys_permission', sa.Column('code', sa.String(length=255), nullable=False), 22 | sa.Column('conf', postgresql.JSONB(astext_type=sa.Text()), nullable=True), sa.PrimaryKeyConstraint('code')) 23 | op.create_index(op.f('ix_sys_permission_code'), 'sys_permission', ['code'], unique=False) 24 | op.create_table('sys_role', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=20), nullable=False), 25 | sa.Column('description', sa.String(), nullable=True), sa.PrimaryKeyConstraint('id')) 26 | op.create_index(op.f('ix_sys_role_id'), 'sys_role', ['id'], unique=False) 27 | op.create_index(op.f('ix_sys_role_name'), 'sys_role', ['name'], unique=True) 28 | op.create_table('sys_role_permission_rel', sa.Column('role_id', sa.Integer(), nullable=False), 29 | sa.Column('permission_code', sa.String(length=255), nullable=False), 30 | sa.PrimaryKeyConstraint('role_id', 'permission_code')) 31 | op.create_index(op.f('ix_sys_role_permission_rel_role_id'), 'sys_role_permission_rel', ['role_id'], unique=False) 32 | op.create_table('sys_user', sa.Column('id', sa.Integer(), nullable=False), sa.Column('username', sa.String(length=20), nullable=False), 33 | sa.Column('fullname', sa.String(length=20), nullable=True), sa.Column('email', sa.String(), nullable=True), 34 | sa.Column('password', sa.String(), nullable=False), sa.Column('is_active', sa.Boolean(), nullable=True), 35 | sa.Column('is_superuser', sa.Boolean(), nullable=True), sa.PrimaryKeyConstraint('id')) 36 | op.create_index(op.f('ix_sys_user_id'), 'sys_user', ['id'], unique=False) 37 | op.create_index(op.f('ix_sys_user_username'), 'sys_user', ['username'], unique=True) 38 | op.create_table('sys_user_role_rel', sa.Column('user_id', sa.Integer(), nullable=False), sa.Column('role_id', 39 | sa.Integer(), 40 | nullable=False), 41 | sa.PrimaryKeyConstraint('user_id', 'role_id')) 42 | op.create_index(op.f('ix_sys_user_role_rel_user_id'), 'sys_user_role_rel', ['user_id'], unique=False) 43 | # ### end Alembic commands ### 44 | 45 | 46 | def downgrade(): 47 | # ### commands auto generated by Alembic - please adjust! ### 48 | op.drop_index(op.f('ix_sys_user_role_rel_user_id'), table_name='sys_user_role_rel') 49 | op.drop_table('sys_user_role_rel') 50 | op.drop_index(op.f('ix_sys_user_username'), table_name='sys_user') 51 | op.drop_index(op.f('ix_sys_user_id'), table_name='sys_user') 52 | op.drop_table('sys_user') 53 | op.drop_index(op.f('ix_sys_role_permission_rel_role_id'), table_name='sys_role_permission_rel') 54 | op.drop_table('sys_role_permission_rel') 55 | op.drop_index(op.f('ix_sys_role_name'), table_name='sys_role') 56 | op.drop_index(op.f('ix_sys_role_id'), table_name='sys_role') 57 | op.drop_table('sys_role') 58 | op.drop_index(op.f('ix_sys_permission_code'), table_name='sys_permission') 59 | op.drop_table('sys_permission') 60 | # ### end Alembic commands ### 61 | -------------------------------------------------------------------------------- /backend/app/app/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l2m2/fastapi-vue-admin/5e38112dc87c77c9a5bd7d446113622327ff6a67/backend/app/app/api/__init__.py -------------------------------------------------------------------------------- /backend/app/app/api/api_v1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l2m2/fastapi-vue-admin/5e38112dc87c77c9a5bd7d446113622327ff6a67/backend/app/app/api/api_v1/__init__.py -------------------------------------------------------------------------------- /backend/app/app/api/api_v1/api.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | from app.api.api_v1.endpoints import login, users, roles, permissions 3 | 4 | api_router = APIRouter() 5 | 6 | api_router.include_router(login.router, tags=["login"]) 7 | api_router.include_router(users.router, prefix="/users", tags=["users"]) 8 | api_router.include_router(roles.router, prefix="/roles", tags=["roles"]) 9 | api_router.include_router(permissions.router, prefix="/permissions", tags=["permissions"]) 10 | -------------------------------------------------------------------------------- /backend/app/app/api/api_v1/endpoints/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l2m2/fastapi-vue-admin/5e38112dc87c77c9a5bd7d446113622327ff6a67/backend/app/app/api/api_v1/endpoints/__init__.py -------------------------------------------------------------------------------- /backend/app/app/api/api_v1/endpoints/login.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from typing import Any 3 | 4 | from fastapi import APIRouter, Depends, HTTPException, Body 5 | from fastapi.security import OAuth2PasswordRequestForm 6 | from sqlalchemy.orm import Session 7 | 8 | from app import crud, models, schemas 9 | from app.api import deps 10 | from app.core import security 11 | from app.core.config import settings 12 | from app.core.security import get_password_hash, verify_password 13 | 14 | router = APIRouter() 15 | 16 | 17 | @router.post("/login/access-token", response_model=schemas.Token) 18 | def login_access_token(db: Session = Depends(deps.get_db), form_data: OAuth2PasswordRequestForm = Depends()) -> Any: 19 | """ 20 | OAuth2 compatible token login, get an access token for future requests 21 | """ 22 | user = crud.user.authenticate(db, username=form_data.username, password=form_data.password) 23 | if not user: 24 | raise HTTPException(status_code=400, detail="Incorrect username or password") 25 | elif not crud.user.is_active(user): 26 | raise HTTPException(status_code=400, detail="Inactive user") 27 | access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) 28 | return { 29 | "access_token": security.create_access_token(user.id, expires_delta=access_token_expires), 30 | "token_type": "bearer", 31 | } 32 | 33 | 34 | @router.post("/login/test-token", response_model=schemas.User) 35 | def test_token(current_user: models.User = Depends(deps.get_current_user)) -> Any: 36 | """ 37 | 测试Token 38 | """ 39 | return current_user 40 | 41 | 42 | @router.post("/reset-password/", response_model=schemas.Msg) 43 | def reset_password(current_password: str = Body(...), 44 | new_password: str = Body(...), 45 | db: Session = Depends(deps.get_db), 46 | current_user: models.User = Depends(deps.get_current_active_user)) -> Any: 47 | """ 48 | 重置密码 49 | """ 50 | if not verify_password(current_password, current_user.password): 51 | raise HTTPException(status_code=400, detail="输入的原始密码错误") 52 | hashed_password = get_password_hash(new_password) 53 | current_user.password = hashed_password 54 | db.add(current_user) 55 | db.commit() 56 | return {"msg": "密码修改成功"} 57 | -------------------------------------------------------------------------------- /backend/app/app/api/api_v1/endpoints/permissions.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, HTTPException 2 | from typing import Any 3 | from sqlalchemy.orm import Session 4 | 5 | from app import crud, models, schemas 6 | from app.api import deps 7 | 8 | router = APIRouter() 9 | 10 | 11 | @router.get("/", response_model=schemas.PermissionList) 12 | def read_permissions(db: Session = Depends(deps.get_db), current_user: models.User = Depends(deps.get_current_active_user)) -> Any: 13 | """ 14 | 获取权限列表 15 | """ 16 | permissions = crud.permission.get_multi(db, skip=0, limit=99999) 17 | return permissions 18 | 19 | 20 | @router.post("/", response_model=schemas.Permission) 21 | def create_permission(*, 22 | db: Session = Depends(deps.get_db), 23 | obj_in: schemas.PermissionCreate, 24 | current_user: models.User = Depends(deps.get_current_active_superuser)) -> Any: 25 | """ 26 | 创建权限 27 | """ 28 | item = crud.permission.create(db, obj_in=obj_in) 29 | return item 30 | 31 | 32 | @router.get("/{code}", response_model=schemas.Permission) 33 | def read_permission_by_code( 34 | code: str, current_user: models.User = Depends(deps.get_current_active_user), db: Session = Depends(deps.get_db)) -> Any: 35 | """ 36 | 根据Code获取权限信息 37 | """ 38 | item = crud.permission.get_by_code(db, code=code) 39 | if not item: 40 | raise HTTPException(status_code=404, detail="权限不存在") 41 | return item 42 | 43 | 44 | @router.put("/{code}", response_model=schemas.Permission) 45 | def update_permission(*, 46 | db: Session = Depends(deps.get_db), 47 | code: str, 48 | obj_in: schemas.PermissionUpdate, 49 | current_user: models.User = Depends(deps.get_current_active_user)) -> Any: 50 | """ 51 | 更新权限信息 52 | """ 53 | item = crud.permission.get_by_code(db, code=code) 54 | if not item: 55 | raise HTTPException(status_code=404, detail="权限不存在") 56 | item = crud.permission.update(db, db_obj=item, obj_in=obj_in) 57 | return item 58 | 59 | 60 | @router.delete("/{code}", response_model=schemas.Permission) 61 | def delete_permission(*, 62 | db: Session = Depends(deps.get_db), 63 | code: str, 64 | current_user: models.User = Depends(deps.get_current_active_superuser)) -> Any: 65 | """ 66 | 删除权限 67 | """ 68 | item = crud.permission.get_by_code(db, code=code) 69 | if not item: 70 | raise HTTPException(status_code=404, detail="权限不存在") 71 | db.delete(item) 72 | db.commit() 73 | return item 74 | -------------------------------------------------------------------------------- /backend/app/app/api/api_v1/endpoints/roles.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, HTTPException 2 | from typing import Any, List 3 | from sqlalchemy.orm import Session 4 | 5 | from app import crud, models, schemas 6 | from app.api import deps 7 | 8 | router = APIRouter() 9 | 10 | 11 | @router.get("/", response_model=schemas.RoleList) 12 | def read_roles(db: Session = Depends(deps.get_db), 13 | skip: int = 0, 14 | limit: int = 100, 15 | filter: str = '', 16 | order: str = '', 17 | current_user: models.User = Depends(deps.get_current_active_superuser)) -> Any: 18 | """ 19 | 获取角色列表 20 | """ 21 | roles = crud.role.get_multi(db, skip=skip, limit=limit, filter=filter, order=order) 22 | return roles 23 | 24 | 25 | @router.post("/", response_model=schemas.Role) 26 | def create_role(*, 27 | db: Session = Depends(deps.get_db), 28 | role_in: schemas.RoleCreate, 29 | current_user: models.User = Depends(deps.get_current_active_superuser)) -> Any: 30 | """ 31 | 创建角色 32 | """ 33 | role = crud.role.create(db, obj_in=role_in) 34 | return role 35 | 36 | 37 | @router.get("/{role_id}", response_model=schemas.Role) 38 | def read_role_by_id( 39 | role_id: int, current_user: models.User = Depends(deps.get_current_active_user), db: Session = Depends(deps.get_db)) -> Any: 40 | """ 41 | 根据ID获取角色信息 42 | """ 43 | role = crud.role.get(db, id=role_id) 44 | if not role: 45 | raise HTTPException(status_code=404, detail="角色不存在") 46 | return role 47 | 48 | 49 | @router.put("/{role_id}", response_model=schemas.Role) 50 | def update_role(*, 51 | db: Session = Depends(deps.get_db), 52 | role_id: int, 53 | role_in: schemas.RoleUpdate, 54 | current_user: models.User = Depends(deps.get_current_active_user)) -> Any: 55 | """ 56 | 更新角色信息 57 | """ 58 | role = crud.role.get(db, id=role_id) 59 | if not role: 60 | raise HTTPException(status_code=404, detail="角色不存在") 61 | role = crud.role.update(db, db_obj=role, obj_in=role_in) 62 | return role 63 | 64 | 65 | @router.delete("/{role_id}", response_model=schemas.Role) 66 | def delete_role(*, db: Session = Depends(deps.get_db), role_id: int, 67 | current_user: models.User = Depends(deps.get_current_active_superuser)) -> Any: 68 | """ 69 | 删除角色 70 | """ 71 | role = crud.role.get(db, id=role_id) 72 | if not role: 73 | raise HTTPException(status_code=404, detail="角色不存在") 74 | role = crud.role.remove(db, id=role_id) 75 | return role 76 | 77 | 78 | @router.get("/{role_id}/permissions", response_model=List[str]) 79 | def read_role_permissions_by_id(*, 80 | db: Session = Depends(deps.get_db), 81 | role_id: int, 82 | current_user: models.User = Depends(deps.get_current_active_superuser)): 83 | """ 84 | 读取角色的权限信息 85 | """ 86 | role = crud.role.get(db, id=role_id) 87 | if not role: 88 | raise HTTPException(status_code=404, detail="角色不存在") 89 | return crud.role.get_permissions_by_role_id(db, id=role_id) 90 | 91 | 92 | @router.put("/{role_id}/permissions") 93 | def update_role_permissions_by_id(*, 94 | db: Session = Depends(deps.get_db), 95 | role_id: int, 96 | permissions_in: List[str], 97 | current_user: models.User = Depends(deps.get_current_active_superuser)): 98 | """ 99 | 更新角色的权限信息 100 | """ 101 | role = crud.role.get(db, id=role_id) 102 | if not role: 103 | raise HTTPException(status_code=404, detail="角色不存在") 104 | crud.role.update_permissions_by_role_id(db, id=role_id, permissions=permissions_in) 105 | return True 106 | -------------------------------------------------------------------------------- /backend/app/app/api/api_v1/endpoints/users.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from fastapi import APIRouter, Body, Depends, HTTPException 3 | from fastapi.encoders import jsonable_encoder 4 | from pydantic.networks import EmailStr 5 | from sqlalchemy.orm import Session 6 | 7 | from app import crud, models, schemas 8 | from app.api import deps 9 | 10 | router = APIRouter() 11 | 12 | 13 | @router.get("/", response_model=schemas.UserList) 14 | def read_users( 15 | db: Session = Depends(deps.get_db), 16 | skip: int = 0, 17 | limit: int = 100, 18 | filter: str = '', 19 | order: str = '', 20 | current_user: models.User = Depends(deps.get_current_active_superuser), 21 | ) -> Any: 22 | """ 23 | 获取用户列表 24 | """ 25 | users = crud.user.get_users(db, skip=skip, limit=limit, filter=filter, order=order) 26 | return users 27 | 28 | 29 | @router.post("/", response_model=schemas.User) 30 | def create_user( 31 | *, 32 | db: Session = Depends(deps.get_db), 33 | user_in: schemas.UserCreate, 34 | current_user: models.User = Depends(deps.get_current_active_superuser), 35 | ) -> Any: 36 | """ 37 | 创建用户 38 | """ 39 | user = crud.user.get_by_username(db, username=user_in.username) 40 | if user: 41 | raise HTTPException( 42 | status_code=400, 43 | detail="系统中已存在相同的用户名.", 44 | ) 45 | user = crud.user.create(db, obj_in=user_in) 46 | return user 47 | 48 | 49 | @router.put("/me", response_model=schemas.User) 50 | def update_user_me( 51 | *, 52 | db: Session = Depends(deps.get_db), 53 | password: str = Body(None), 54 | fullname: str = Body(None), 55 | email: EmailStr = Body(None), 56 | current_user: models.User = Depends(deps.get_current_active_user), 57 | ) -> Any: 58 | """ 59 | 更新当前用户信息 60 | """ 61 | current_user_data = jsonable_encoder(current_user) 62 | user_in = schemas.UserUpdate(**current_user_data) 63 | if password is not None: 64 | user_in.password = password 65 | if fullname is not None: 66 | user_in.fullname = fullname 67 | if email is not None: 68 | user_in.email = email 69 | user = crud.user.update(db, db_obj=current_user, obj_in=user_in) 70 | return user 71 | 72 | 73 | @router.get("/me", response_model=schemas.UserMe) 74 | def read_user_me( 75 | db: Session = Depends(deps.get_db), 76 | current_user: models.User = Depends(deps.get_current_active_user), 77 | ) -> Any: 78 | """ 79 | 获取当前用户信息 80 | """ 81 | permissions = crud.user.get_permissions_by_userid(db, user_id=current_user.id) 82 | ret = jsonable_encoder(current_user) 83 | ret["permissions"] = permissions 84 | return ret 85 | 86 | 87 | @router.get("/{user_id}", response_model=schemas.User) 88 | def read_user_by_id( 89 | user_id: int, 90 | current_user: models.User = Depends(deps.get_current_active_user), 91 | db: Session = Depends(deps.get_db), 92 | ) -> Any: 93 | """ 94 | 根据ID获取用户信息 95 | """ 96 | user = crud.user.get(db, id=user_id) 97 | if user == current_user: 98 | return user 99 | if not crud.user.is_superuser(current_user): 100 | raise HTTPException(status_code=400, detail="The user doesn't have enough privileges") 101 | return user 102 | 103 | 104 | @router.put("/{user_id}", response_model=schemas.User) 105 | def update_user( 106 | *, 107 | db: Session = Depends(deps.get_db), 108 | user_id: int, 109 | user_in: schemas.UserUpdate, 110 | current_user: models.User = Depends(deps.get_current_active_superuser), 111 | ) -> Any: 112 | """ 113 | 更新用户信息 114 | """ 115 | user = crud.user.get(db, id=user_id) 116 | if not user: 117 | raise HTTPException( 118 | status_code=404, 119 | detail="用户不存在", 120 | ) 121 | user = crud.user.update(db, db_obj=user, obj_in=user_in) 122 | return user 123 | 124 | 125 | @router.delete("/{user_id}", response_model=schemas.User) 126 | def delete_user(*, db: Session = Depends(deps.get_db), user_id: int, 127 | current_user: models.User = Depends(deps.get_current_active_superuser)): 128 | """ 129 | 删除用户 130 | """ 131 | user = crud.user.get(db, id=user_id) 132 | if not user: 133 | raise HTTPException(status_code=404, detail="用户不存在") 134 | user = crud.user.remove(db, id=user_id) 135 | return user 136 | -------------------------------------------------------------------------------- /backend/app/app/api/deps.py: -------------------------------------------------------------------------------- 1 | from typing import Generator 2 | 3 | from fastapi import Depends, HTTPException, status 4 | from fastapi.security import OAuth2PasswordBearer 5 | from jose import jwt 6 | from pydantic import ValidationError 7 | from sqlalchemy.orm import Session 8 | 9 | from app import crud, models, schemas 10 | from app.core import security 11 | from app.core.config import settings 12 | from app.db.session import SessionLocal 13 | 14 | reusable_oauth2 = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/login/access-token") 15 | 16 | 17 | def get_db() -> Generator: 18 | try: 19 | db = SessionLocal() 20 | yield db 21 | finally: 22 | db.close() 23 | 24 | 25 | def get_current_user(db: Session = Depends(get_db), token: str = Depends(reusable_oauth2)) -> models.User: 26 | try: 27 | payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]) 28 | token_data = schemas.TokenPayload(**payload) 29 | except (jwt.JWTError, ValidationError): 30 | raise HTTPException( 31 | status_code=status.HTTP_403_FORBIDDEN, 32 | detail="Could not validate credentials", 33 | ) 34 | user = crud.user.get(db, id=token_data.sub) 35 | if not user: 36 | raise HTTPException(status_code=404, detail="User not found") 37 | return user 38 | 39 | 40 | def get_current_active_user(current_user: models.User = Depends(get_current_user), ) -> models.User: 41 | if not crud.user.is_active(current_user): 42 | raise HTTPException(status_code=400, detail="Inactive user") 43 | return current_user 44 | 45 | 46 | def get_current_active_superuser(current_user: models.User = Depends(get_current_user), ) -> models.User: 47 | if not crud.user.is_superuser(current_user): 48 | raise HTTPException(status_code=400, detail="The user doesn't have enough privileges") 49 | return current_user 50 | -------------------------------------------------------------------------------- /backend/app/app/backend_pre_start.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed 4 | 5 | from app.db.session import SessionLocal 6 | 7 | logging.basicConfig(level=logging.INFO) 8 | logger = logging.getLogger(__name__) 9 | 10 | max_tries = 60 * 5 # 5 minutes 11 | wait_seconds = 1 12 | 13 | 14 | @retry( 15 | stop=stop_after_attempt(max_tries), 16 | wait=wait_fixed(wait_seconds), 17 | before=before_log(logger, logging.INFO), 18 | after=after_log(logger, logging.WARN), 19 | ) 20 | def init() -> None: 21 | try: 22 | db = SessionLocal() 23 | # Try to create session to check if DB is awake 24 | db.execute("SELECT 1") 25 | except Exception as e: 26 | logger.error(e) 27 | raise e 28 | 29 | 30 | def main() -> None: 31 | logger.info("Initializing service") 32 | init() 33 | logger.info("Service finished initializing") 34 | 35 | 36 | if __name__ == "__main__": 37 | main() 38 | -------------------------------------------------------------------------------- /backend/app/app/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l2m2/fastapi-vue-admin/5e38112dc87c77c9a5bd7d446113622327ff6a67/backend/app/app/core/__init__.py -------------------------------------------------------------------------------- /backend/app/app/core/config.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseSettings, AnyHttpUrl, PostgresDsn, validator 2 | from typing import List, Optional, Dict, Any 3 | 4 | 5 | class Settings(BaseSettings): 6 | PROJECT_NAME: str 7 | 8 | API_V1_STR: str = "/api/v1" 9 | SECRET_KEY: str = "f5826809b8460dffbed75f18ee165de587fe2a1b02383194ab43f34163e86dbe" 10 | # 60 minutes * 24 hours * 8 days = 8 days 11 | ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 12 | BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = [] 13 | 14 | POSTGRES_SERVER: str 15 | POSTGRES_PORT: int 16 | POSTGRES_USER: str 17 | POSTGRES_PASSWORD: str 18 | POSTGRES_DB: str 19 | SQLALCHEMY_DATABASE_URI: Optional[PostgresDsn] = None 20 | 21 | @validator("SQLALCHEMY_DATABASE_URI", pre=True) 22 | def assemble_db_connection(cls, v: Optional[str], values: Dict[str, Any]) -> Any: # noqa 23 | if isinstance(v, str): 24 | return v 25 | return PostgresDsn.build( 26 | scheme="postgresql", 27 | user=values.get("POSTGRES_USER"), 28 | password=values.get("POSTGRES_PASSWORD"), 29 | host=f"{values.get('POSTGRES_SERVER')}:{values.get('POSTGRES_PORT')}", 30 | path=f"/{values.get('POSTGRES_DB') or ''}", 31 | ) 32 | 33 | FIRST_SUPERUSER: str 34 | FIRST_FULLNAME: str 35 | FIRST_SUPERUSER_PASSWORD: str 36 | 37 | 38 | settings = Settings() 39 | -------------------------------------------------------------------------------- /backend/app/app/core/security.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from typing import Any, Union 3 | from jose import jwt 4 | from passlib.context import CryptContext 5 | from app.core.config import settings 6 | 7 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 8 | 9 | ALGORITHM = "HS256" 10 | 11 | 12 | def create_access_token(subject: Union[str, Any], expires_delta: timedelta = None) -> str: 13 | if expires_delta: 14 | expire = datetime.utcnow() + expires_delta 15 | else: 16 | expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) 17 | to_encode = {"exp": expire, "sub": str(subject)} 18 | encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) 19 | return encoded_jwt 20 | 21 | 22 | def verify_password(plain_password: str, hashed_password: str) -> bool: 23 | return pwd_context.verify(plain_password, hashed_password) 24 | 25 | 26 | def get_password_hash(password: str) -> str: 27 | return pwd_context.hash(password) 28 | -------------------------------------------------------------------------------- /backend/app/app/crud/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import CRUDBase 2 | from .crud_user import user 3 | from .crud_role import role 4 | from .crud_permission import permission -------------------------------------------------------------------------------- /backend/app/app/crud/base.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Generic, Optional, Type, TypeVar, Union 2 | 3 | from fastapi.encoders import jsonable_encoder 4 | from pydantic import BaseModel 5 | from sqlalchemy.orm import Session 6 | from sqlalchemy import text 7 | from app.db.base_class import Base 8 | 9 | ModelType = TypeVar("ModelType", bound=Base) 10 | CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel) 11 | UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel) 12 | ModelListSchemaType = TypeVar("ModelListSchemaType", bound=BaseModel) 13 | 14 | 15 | class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType, ModelListSchemaType]): 16 | def __init__(self, model: Type[ModelType]): 17 | """ 18 | CRUD object with default methods to Create, Read, Update, Delete (CRUD). 19 | 20 | **Parameters** 21 | 22 | * `model`: A SQLAlchemy model class 23 | * `schema`: A Pydantic model (schema) class 24 | """ 25 | self.model = model 26 | 27 | def get(self, db: Session, id: Any) -> Optional[ModelType]: 28 | return db.query(self.model).filter(self.model.id == id).first() 29 | 30 | def get_multi(self, db: Session, *, skip: int = 0, limit: int = 100, filter: str = '', order: str = '') -> ModelListSchemaType: 31 | q = db.query(self.model).filter(text(filter)) 32 | total = q.count() 33 | items = q.order_by(text(order)).offset(skip).limit(limit).all() 34 | return {'total': total, 'items': items} 35 | 36 | def create(self, db: Session, *, obj_in: CreateSchemaType) -> ModelType: 37 | obj_in_data = jsonable_encoder(obj_in) 38 | db_obj = self.model(**obj_in_data) # type: ignore 39 | db.add(db_obj) 40 | db.commit() 41 | db.refresh(db_obj) 42 | return db_obj 43 | 44 | def update(self, db: Session, *, db_obj: ModelType, obj_in: Union[UpdateSchemaType, Dict[str, Any]]) -> ModelType: 45 | obj_data = jsonable_encoder(db_obj) 46 | if isinstance(obj_in, dict): 47 | update_data = obj_in 48 | else: 49 | update_data = obj_in.dict(exclude_unset=True) 50 | for field in obj_data: 51 | if field in update_data: 52 | setattr(db_obj, field, update_data[field]) 53 | db.add(db_obj) 54 | db.commit() 55 | db.refresh(db_obj) 56 | return db_obj 57 | 58 | def remove(self, db: Session, *, id: int) -> ModelType: 59 | obj = db.query(self.model).get(id) 60 | db.delete(obj) 61 | db.commit() 62 | return obj 63 | -------------------------------------------------------------------------------- /backend/app/app/crud/crud_permission.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from sqlalchemy.orm import Session 3 | 4 | from app.crud.base import CRUDBase 5 | from app.models.permission import Permission 6 | from app.schemas.permission import PermissionCreate, PermissionUpdate, PermissionList 7 | 8 | 9 | class CRUDPermission(CRUDBase[Permission, PermissionCreate, PermissionUpdate, PermissionList]): 10 | def get_by_code(self, db: Session, *, code: str) -> Optional[Permission]: 11 | return db.query(Permission).filter(Permission.code == code).first() 12 | 13 | 14 | permission = CRUDPermission(Permission) 15 | -------------------------------------------------------------------------------- /backend/app/app/crud/crud_role.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from sqlalchemy.orm import Session 3 | 4 | from app import models, schemas, crud 5 | 6 | 7 | class CRUDRole(crud.CRUDBase[models.Role, schemas.RoleCreate, schemas.RoleUpdate, schemas.RoleList]): 8 | def get_permissions_by_role_id(self, db: Session, *, id: int) -> List[str]: 9 | codes = db.query(models.RolePermissionRel.permission_code).filter(models.RolePermissionRel.role_id == id).all() 10 | codes = [x[0] for x in codes] 11 | return codes 12 | 13 | def update_permissions_by_role_id(self, db: Session, *, id: int, permissions: List[str]): 14 | delete_q = models.RolePermissionRel.__table__.delete().where(models.RolePermissionRel.role_id == id) 15 | db.execute(delete_q) 16 | db.add_all([models.RolePermissionRel(role_id=id, permission_code=x) for x in permissions]) 17 | db.commit() 18 | 19 | 20 | role = CRUDRole(models.Role) 21 | -------------------------------------------------------------------------------- /backend/app/app/crud/crud_user.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional, Union, List 2 | 3 | from sqlalchemy.orm import Session 4 | from sqlalchemy import text, func, true 5 | 6 | from app.core.security import get_password_hash, verify_password 7 | from app.crud.base import CRUDBase 8 | from app.models.user import User 9 | from app.models.user_role_rel import UserRoleRel 10 | from app.models.role_permission_rel import RolePermissionRel 11 | from app.schemas.user import UserCreate, UserUpdate, UserList 12 | 13 | 14 | class CRUDUser(CRUDBase[User, UserCreate, UserUpdate, UserList]): 15 | def get_by_username(self, db: Session, *, username: str) -> Optional[User]: 16 | return db.query(User).filter(User.username == username).first() 17 | 18 | def get_users(self, db: Session, *, skip: int = 0, limit: int = 100, filter: str = '', order: str = '') -> UserList: 19 | total = db.query(self.model).filter(text(filter)).count() 20 | subq = db.query(func.array_agg(UserRoleRel.role_id).label("roles")).filter(User.id == UserRoleRel.user_id).subquery().lateral() 21 | items = db.query(*[c for c in User.__table__.c], 22 | subq).join(subq, true(), isouter=True).filter(text(filter)).order_by(text(order)).offset(skip).limit(limit).all() 23 | return {'total': total, 'items': items} 24 | 25 | def create(self, db: Session, *, obj_in: UserCreate) -> User: 26 | db_obj = User( 27 | username=obj_in.username, 28 | email=obj_in.email, 29 | password=get_password_hash(obj_in.password), 30 | fullname=obj_in.fullname, 31 | is_superuser=obj_in.is_superuser, 32 | ) 33 | db.add(db_obj) 34 | db.flush() 35 | if obj_in.roles: 36 | db.add_all([UserRoleRel(user_id=db_obj.id, role_id=x) for x in obj_in.roles]) 37 | db.commit() 38 | db.refresh(db_obj) 39 | return db_obj 40 | 41 | def update(self, db: Session, *, db_obj: User, obj_in: Union[UserUpdate, Dict[str, Any]]) -> User: 42 | if isinstance(obj_in, dict): 43 | update_data = obj_in 44 | else: 45 | update_data = obj_in.dict(exclude_unset=True) 46 | if "password" in update_data: 47 | hashed_password = get_password_hash(update_data["password"]) 48 | del update_data["password"] 49 | update_data["password"] = hashed_password 50 | if "roles" in update_data: 51 | delete_q = UserRoleRel.__table__.delete().where(UserRoleRel.user_id == db_obj.id) 52 | db.execute(delete_q) 53 | db.add_all([UserRoleRel(role_id=x, user_id=db_obj.id) for x in update_data["roles"]]) 54 | return super().update(db, db_obj=db_obj, obj_in=update_data) 55 | 56 | def authenticate(self, db: Session, *, username: str, password: str) -> Optional[User]: 57 | user = self.get_by_username(db, username=username) 58 | if not user: 59 | return None 60 | if not verify_password(password, user.password): 61 | return None 62 | return user 63 | 64 | def is_active(self, user: User) -> bool: 65 | return user.is_active 66 | 67 | def is_superuser(self, user: User) -> bool: 68 | return user.is_superuser 69 | 70 | def get_permissions_by_userid(self, db: Session, *, user_id: int) -> Optional[List[str]]: 71 | subq = db.query(User).filter(User.id == user_id).subquery() 72 | permission_codes = db.query(RolePermissionRel.permission_code).select_from(subq).join( 73 | UserRoleRel, UserRoleRel.user_id == subq.c.id).join(RolePermissionRel, RolePermissionRel.role_id == UserRoleRel.role_id).all() 74 | permission_codes = [x[0] for x in permission_codes] 75 | return permission_codes 76 | 77 | 78 | user = CRUDUser(User) 79 | -------------------------------------------------------------------------------- /backend/app/app/db/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l2m2/fastapi-vue-admin/5e38112dc87c77c9a5bd7d446113622327ff6a67/backend/app/app/db/__init__.py -------------------------------------------------------------------------------- /backend/app/app/db/base.py: -------------------------------------------------------------------------------- 1 | # Import all the models, so that Base has them before being 2 | # imported by Alembic 3 | from app.db.base_class import Base # noqa: F401 4 | from app.models.user import User # noqa: F401 5 | -------------------------------------------------------------------------------- /backend/app/app/db/base_class.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.declarative import declarative_base 2 | 3 | Base = declarative_base() 4 | 5 | # @as_declarative() 6 | # class Base: 7 | # id: Any 8 | # __name__: str 9 | 10 | # # Generate __tablename__ automatically 11 | # @declared_attr 12 | # def __tablename__(cls) -> str: 13 | # return cls.__name__.lower() 14 | -------------------------------------------------------------------------------- /backend/app/app/db/init_db.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Session 2 | 3 | from app import crud, schemas 4 | from app.core.config import settings 5 | from app.db import base # noqa: F401 6 | 7 | # make sure all SQL Alchemy models are imported (app.db.base) before initializing DB 8 | # otherwise, SQL Alchemy might fail to initialize relationships properly 9 | # for more details: https://github.com/tiangolo/full-stack-fastapi-postgresql/issues/28 10 | 11 | 12 | def init_db(db: Session) -> None: 13 | # Tables should be created with Alembic migrations 14 | # But if you don't want to use migrations, create 15 | # the tables un-commenting the next line 16 | # Base.metadata.create_all(bind=engine) 17 | 18 | user = crud.user.get_by_username(db, username=settings.FIRST_SUPERUSER) 19 | if not user: 20 | user_in = schemas.UserCreate( 21 | username=settings.FIRST_SUPERUSER, 22 | fullname=settings.FIRST_FULLNAME, 23 | password=settings.FIRST_SUPERUSER_PASSWORD, 24 | is_superuser=True, 25 | ) 26 | user = crud.user.create(db, obj_in=user_in) 27 | -------------------------------------------------------------------------------- /backend/app/app/db/session.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import create_engine 2 | from sqlalchemy.orm import sessionmaker 3 | 4 | from app.core.config import settings 5 | 6 | engine = create_engine(settings.SQLALCHEMY_DATABASE_URI, pool_pre_ping=True, echo=True) 7 | SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) 8 | -------------------------------------------------------------------------------- /backend/app/app/initial_data.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from app.db.init_db import init_db 4 | from app.db.session import SessionLocal 5 | 6 | logging.basicConfig(level=logging.INFO) 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | def init() -> None: 11 | db = SessionLocal() 12 | init_db(db) 13 | 14 | 15 | def main() -> None: 16 | logger.info("Creating initial data") 17 | init() 18 | logger.info("Initial data created") 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /backend/app/app/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from starlette.middleware.cors import CORSMiddleware 3 | 4 | from app.core.config import settings 5 | from app.api.api_v1.api import api_router 6 | 7 | app = FastAPI(title=settings.PROJECT_NAME, openapi_url=f"{settings.API_V1_STR}/openapi.json") 8 | 9 | # Set all CORS enabled origins 10 | if settings.BACKEND_CORS_ORIGINS: 11 | app.add_middleware( 12 | CORSMiddleware, 13 | allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS], 14 | allow_credentials=True, 15 | allow_methods=["*"], 16 | allow_headers=["*"], 17 | ) 18 | 19 | app.include_router(api_router, prefix=settings.API_V1_STR) 20 | -------------------------------------------------------------------------------- /backend/app/app/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .user import User 2 | from .role import Role 3 | from .user_role_rel import UserRoleRel 4 | from .permission import Permission 5 | from .role_permission_rel import RolePermissionRel 6 | -------------------------------------------------------------------------------- /backend/app/app/models/permission.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, String 2 | from sqlalchemy.dialects import postgresql 3 | from app.db.base import Base 4 | 5 | 6 | class Permission(Base): 7 | __tablename__ = "sys_permission" 8 | 9 | code = Column(String(255), primary_key=True, index=True) 10 | conf = Column(postgresql.JSONB) 11 | 12 | def __repr__(self): 13 | return f"" 14 | -------------------------------------------------------------------------------- /backend/app/app/models/role.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import String, Integer, Column 2 | from app.db.base_class import Base 3 | 4 | 5 | class Role(Base): 6 | __tablename__ = "sys_role" 7 | 8 | id = Column(Integer, primary_key=True, index=True) 9 | name = Column(String(20), unique=True, index=True, nullable=False) 10 | description = Column(String) 11 | 12 | def __repr__(self): 13 | return f"" 14 | -------------------------------------------------------------------------------- /backend/app/app/models/role_permission_rel.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, String, Integer 2 | from app.db.base import Base 3 | 4 | 5 | class RolePermissionRel(Base): 6 | __tablename__ = "sys_role_permission_rel" 7 | 8 | role_id = Column(Integer, primary_key=True, index=True) 9 | permission_code = Column(String(255), primary_key=True) 10 | 11 | def __repr__(self): 12 | return f"" 13 | -------------------------------------------------------------------------------- /backend/app/app/models/user.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Boolean, Column, Integer, String 2 | from app.db.base_class import Base 3 | 4 | 5 | class User(Base): 6 | __tablename__ = "sys_user" 7 | 8 | id = Column(Integer, primary_key=True, index=True) 9 | username = Column(String(20), unique=True, index=True, nullable=False) 10 | fullname = Column(String(20)) 11 | email = Column(String) 12 | password = Column(String, nullable=False) 13 | is_active = Column(Boolean(), default=True) 14 | is_superuser = Column(Boolean(), default=False) 15 | 16 | def __repr__(self): 17 | return f"" 18 | -------------------------------------------------------------------------------- /backend/app/app/models/user_role_rel.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Integer 2 | from app.db.base import Base 3 | 4 | 5 | class UserRoleRel(Base): 6 | __tablename__ = "sys_user_role_rel" 7 | 8 | user_id = Column(Integer, primary_key=True, index=True) 9 | role_id = Column(Integer, primary_key=True) 10 | 11 | def __repr__(self): 12 | return f"" 13 | -------------------------------------------------------------------------------- /backend/app/app/schemas/__init__.py: -------------------------------------------------------------------------------- 1 | from .token import Token, TokenPayload 2 | from .msg import Msg 3 | from .user import UserCreate, UserUpdate, User, UserList, UserMe 4 | from .role import RoleCreate, RoleUpdate, Role, RoleList 5 | from .permission import PermissionCreate, PermissionUpdate, Permission, PermissionList 6 | -------------------------------------------------------------------------------- /backend/app/app/schemas/msg.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class Msg(BaseModel): 5 | msg: str 6 | -------------------------------------------------------------------------------- /backend/app/app/schemas/permission.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List, Dict 2 | from pydantic import BaseModel 3 | 4 | permission_conf_example = { 5 | "en": { 6 | "root": "System config", 7 | "group": "User management", 8 | "name": "Create" 9 | }, 10 | "zhcn": { 11 | "root": "系统设置", 12 | "group": "用户管理", 13 | "name": "新建" 14 | } 15 | } 16 | 17 | 18 | class PermissionBase(BaseModel): 19 | conf: Optional[Dict[str, Dict[str, str]]] = None 20 | 21 | class Config: 22 | schema_extra = {"example": {"conf": permission_conf_example}} 23 | 24 | 25 | class PermissionCreate(PermissionBase): 26 | code: str 27 | 28 | class Config: 29 | schema_extra = {"example": {"code": "sys-user-create", "conf": permission_conf_example}} 30 | 31 | 32 | class PermissionUpdate(PermissionBase): 33 | ... 34 | 35 | 36 | class Permission(PermissionBase): 37 | code: str 38 | 39 | class Config: 40 | orm_mode = True 41 | schema_extra = {"example": {"code": "sys-user-create", "conf": permission_conf_example}} 42 | 43 | 44 | class PermissionList(BaseModel): 45 | total: int 46 | items: List[Permission] 47 | -------------------------------------------------------------------------------- /backend/app/app/schemas/role.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List 2 | from pydantic import BaseModel 3 | 4 | 5 | class RoleBase(BaseModel): 6 | name: Optional[str] = None 7 | description: Optional[str] = None 8 | 9 | 10 | class RoleCreate(RoleBase): 11 | name: str 12 | 13 | 14 | class RoleUpdate(RoleBase): 15 | ... 16 | 17 | 18 | class Role(RoleBase): 19 | id: Optional[int] = None 20 | 21 | class Config: 22 | orm_mode = True 23 | 24 | 25 | class RoleList(BaseModel): 26 | total: int 27 | items: List[Role] 28 | -------------------------------------------------------------------------------- /backend/app/app/schemas/token.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class Token(BaseModel): 7 | access_token: str 8 | token_type: str 9 | 10 | 11 | class TokenPayload(BaseModel): 12 | sub: Optional[int] = None 13 | -------------------------------------------------------------------------------- /backend/app/app/schemas/user.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List 2 | 3 | from pydantic import BaseModel, EmailStr 4 | 5 | 6 | # Shared properties 7 | class UserBase(BaseModel): 8 | username: Optional[str] = None 9 | fullname: Optional[str] = None 10 | email: Optional[EmailStr] = None 11 | is_active: Optional[bool] = True 12 | is_superuser: bool = False 13 | roles: Optional[List[int]] = None 14 | 15 | 16 | # Properties to receive via API on creation 17 | class UserCreate(UserBase): 18 | username: str 19 | password: str 20 | 21 | 22 | # Properties to receive via API on update 23 | class UserUpdate(UserBase): 24 | ... 25 | 26 | 27 | class User(UserBase): 28 | id: Optional[int] = None 29 | 30 | class Config: 31 | # Pydantic's orm_mode will tell the Pydantic model to read the data even if it is not a dict, 32 | # but an ORM model (or any other arbitrary object with attributes). 33 | orm_mode = True 34 | 35 | 36 | class UserMe(User): 37 | permissions: Optional[List[str]] = None 38 | 39 | 40 | class UserList(BaseModel): 41 | total: int 42 | items: List[User] 43 | -------------------------------------------------------------------------------- /backend/app/app/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from jose import jwt 4 | 5 | from app.core.config import settings 6 | 7 | 8 | def verify_password_reset_token(token: str) -> Optional[str]: 9 | try: 10 | decoded_token = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) 11 | return decoded_token["sub"] 12 | except jwt.JWTError: 13 | return None 14 | -------------------------------------------------------------------------------- /backend/app/prestart.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | # Let the DB start 4 | python /app/app/backend_pre_start.py 5 | 6 | # Run migrations 7 | alembic upgrade head 8 | 9 | # Create initial data in DB 10 | python /app/app/initial_data.py 11 | -------------------------------------------------------------------------------- /backend/backend.dockerfile: -------------------------------------------------------------------------------- 1 | FROM tiangolo/uvicorn-gunicorn-fastapi:python3.8 2 | WORKDIR /app/ 3 | 4 | COPY requirement.txt /app/ 5 | RUN pip install -r requirement.txt 6 | 7 | COPY ./app /app 8 | ENV PYTHONPATH=/app -------------------------------------------------------------------------------- /backend/requirement.txt: -------------------------------------------------------------------------------- 1 | alembic==1.5.5 2 | astroid==2.5 3 | bcrypt==3.2.0 4 | certifi==2020.12.5 5 | cffi==1.14.5 6 | chardet==4.0.0 7 | click==7.1.2 8 | cryptography==3.4.6 9 | dnspython==2.1.0 10 | ecdsa==0.14.1 11 | email-validator==1.1.2 12 | fastapi==0.63.0 13 | flake8==3.8.4 14 | h11==0.12.0 15 | idna==2.10 16 | isort==5.7.0 17 | jwt==1.2.0 18 | lazy-object-proxy==1.5.2 19 | Mako==1.1.4 20 | MarkupSafe==1.1.1 21 | mccabe==0.6.1 22 | passlib==1.7.4 23 | pathlib==1.0.1 24 | psycopg2-binary==2.8.6 25 | pyasn1==0.4.8 26 | pycodestyle==2.6.0 27 | pycparser==2.20 28 | pydantic==1.7.3 29 | pyflakes==2.2.0 30 | python-dateutil==2.8.1 31 | python-editor==1.0.4 32 | python-jose==3.2.0 33 | python-multipart==0.0.5 34 | requests==2.25.1 35 | rsa==4.7.2 36 | six==1.15.0 37 | SQLAlchemy==1.3.23 38 | starlette==0.13.6 39 | tenacity==6.3.1 40 | toml==0.10.2 41 | urllib3==1.26.4 42 | uvicorn==0.13.4 43 | wrapt==1.12.1 44 | yapf==0.30.0 45 | -------------------------------------------------------------------------------- /backend/start_uvicorn.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | cd app/app 3 | uvicorn main:app --reload -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | db: 5 | image: postgres:12 6 | volumes: 7 | - app-db-data:/var/lib/postgresql/data/pgdata 8 | env_file: 9 | - backend/.env 10 | environment: 11 | - PGDATA=/var/lib/postgresql/data/pgdata 12 | networks: 13 | - app-network 14 | ports: 15 | - "5432:5432" 16 | 17 | backend: 18 | image: backend 19 | depends_on: 20 | - db 21 | env_file: 22 | - backend/.env 23 | build: 24 | context: ./backend 25 | dockerfile: backend.dockerfile 26 | networks: 27 | - app-network 28 | ports: 29 | - "8000:80" 30 | 31 | frontend: 32 | image: frontend 33 | depends_on: 34 | - backend 35 | build: 36 | context: ./frontend 37 | args: 38 | FRONTEND_ENV: development 39 | ports: 40 | - "80:80" 41 | networks: 42 | - app-network 43 | 44 | volumes: 45 | app-db-data: 46 | 47 | networks: 48 | app-network: 49 | driver: bridge -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | db: 5 | image: postgres:12 6 | volumes: 7 | - app-db-data:/var/lib/postgresql/data/pgdata 8 | env_file: 9 | - backend/.env 10 | environment: 11 | - PGDATA=/var/lib/postgresql/data/pgdata 12 | networks: 13 | - app-network 14 | ports: 15 | - "5432:5432" 16 | 17 | backend: 18 | image: backend 19 | depends_on: 20 | - db 21 | env_file: 22 | - backend/.env 23 | build: 24 | context: ./backend 25 | dockerfile: backend.dockerfile 26 | networks: 27 | - app-network 28 | ports: 29 | - "8000:80" 30 | 31 | frontend: 32 | image: frontend 33 | depends_on: 34 | - backend 35 | build: 36 | context: ./frontend 37 | args: 38 | FRONTEND_ENV: production 39 | ports: 40 | - "80:80" 41 | networks: 42 | - app-network 43 | 44 | volumes: 45 | app-db-data: 46 | 47 | networks: 48 | app-network: 49 | driver: bridge -------------------------------------------------------------------------------- /frontend/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /frontend/.env: -------------------------------------------------------------------------------- 1 | NODE_ENV=production 2 | VUE_APP_LANGS=en,zhcn 3 | VUE_APP_I18N_LOCALE=zhcn 4 | VUE_APP_I18N_FALLBACK_LOCALE=zhcn 5 | VUE_APP_API_BASE_URL=/api/v1 -------------------------------------------------------------------------------- /frontend/.env.development: -------------------------------------------------------------------------------- 1 | NODE_ENV=development -------------------------------------------------------------------------------- /frontend/.env.production: -------------------------------------------------------------------------------- 1 | NODE_ENV=production -------------------------------------------------------------------------------- /frontend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | extends: ["plugin:vue/essential", "eslint:recommended", "@vue/prettier"], 7 | parserOptions: { 8 | parser: "babel-eslint" 9 | }, 10 | rules: { 11 | "no-console": process.env.NODE_ENV === "production" ? "warn" : "off", 12 | "no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off", 13 | "max-len": ["error", { "code": 140 }], 14 | "prettier/prettier": [ "error", { printWidth: 140} ] 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine3.13 as build-stage 2 | WORKDIR /app 3 | COPY package.json /app/ 4 | COPY yarn.lock /app/ 5 | RUN yarn install 6 | COPY ./ /app/ 7 | ARG FRONTEND_ENV=production 8 | ENV NODE_ENV=${FRONTEND_ENV} 9 | RUN yarn build --mode=$NODE_ENV 10 | 11 | FROM nginx:1.15 12 | COPY --from=build-stage /app/dist/ /usr/share/nginx/html 13 | COPY ./nginx.conf /etc/nginx/conf.d/default.conf -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # frontend 2 | 3 | ## Project setup 4 | ``` 5 | yarn install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | yarn serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | yarn build 16 | ``` 17 | 18 | ### Lints and fixes files 19 | ``` 20 | yarn lint 21 | ``` 22 | 23 | ### Customize configuration 24 | See [Configuration Reference](https://cli.vuejs.org/config/). 25 | -------------------------------------------------------------------------------- /frontend/babel.config.js: -------------------------------------------------------------------------------- 1 | const IS_PROD = ["production", "prod"].includes(process.env.NODE_ENV); 2 | 3 | const plugins = []; 4 | if (IS_PROD) { 5 | plugins.push("transform-remove-console"); 6 | } 7 | 8 | // lazy load ant-design-vue 9 | // if your use import on Demand, Use this code 10 | plugins.push([ 11 | "import", 12 | { 13 | libraryName: "ant-design-vue", 14 | libraryDirectory: "es", 15 | style: true // `style: true` 会加载 less 文件 16 | } 17 | ]); 18 | 19 | module.exports = { 20 | presets: [ 21 | "@vue/cli-plugin-babel/preset", 22 | [ 23 | "@babel/preset-env", 24 | { 25 | useBuiltIns: "entry", 26 | corejs: 3 27 | } 28 | ] 29 | ], 30 | plugins 31 | }; 32 | -------------------------------------------------------------------------------- /frontend/config/plugin.config.js: -------------------------------------------------------------------------------- 1 | const ThemeColorReplacer = require("webpack-theme-color-replacer"); 2 | const generate = require("@ant-design/colors/lib/generate").default; 3 | 4 | const getAntdSerials = color => { 5 | // 淡化(即less的tint) 6 | const lightens = new Array(9).fill().map((t, i) => { 7 | return ThemeColorReplacer.varyColor.lighten(color, i / 10); 8 | }); 9 | const colorPalettes = generate(color); 10 | const rgb = ThemeColorReplacer.varyColor.toNum3(color.replace("#", "")).join(","); 11 | return lightens.concat(colorPalettes).concat(rgb); 12 | }; 13 | 14 | const themePluginOption = { 15 | fileName: "css/theme-colors-[contenthash:8].css", 16 | matchColors: getAntdSerials("#1890ff"), // 主色系列 17 | // 改变样式选择器,解决样式覆盖问题 18 | changeSelector(selector) { 19 | switch (selector) { 20 | case ".ant-calendar-today .ant-calendar-date": 21 | return ":not(.ant-calendar-selected-date):not(.ant-calendar-selected-day)" + selector; 22 | case ".ant-btn:focus,.ant-btn:hover": 23 | return ".ant-btn:focus:not(.ant-btn-primary):not(.ant-btn-danger),.ant-btn:hover:not(.ant-btn-primary):not(.ant-btn-danger)"; 24 | case ".ant-btn.active,.ant-btn:active": 25 | return ".ant-btn.active:not(.ant-btn-primary):not(.ant-btn-danger),.ant-btn:active:not(.ant-btn-primary):not(.ant-btn-danger)"; 26 | case ".ant-steps-item-process .ant-steps-item-icon > .ant-steps-icon": 27 | case ".ant-steps-item-process .ant-steps-item-icon>.ant-steps-icon": 28 | return ":not(.ant-steps-item-process)" + selector; 29 | // fixed https://github.com/vueComponent/ant-design-vue-pro/issues/876 30 | case ".ant-steps-item-process .ant-steps-item-icon": 31 | return ":not(.ant-steps-item-custom)" + selector; 32 | // eslint-disable-next-line 33 | case ".ant-menu-horizontal>.ant-menu-item-active,.ant-menu-horizontal>.ant-menu-item-open,.ant-menu-horizontal>.ant-menu-item-selected,.ant-menu-horizontal>.ant-menu-item:hover,.ant-menu-horizontal>.ant-menu-submenu-active,.ant-menu-horizontal>.ant-menu-submenu-open,.ant-menu-horizontal>.ant-menu-submenu-selected,.ant-menu-horizontal>.ant-menu-submenu:hover": 34 | // eslint-disable-next-line 35 | case ".ant-menu-horizontal > .ant-menu-item-active,.ant-menu-horizontal > .ant-menu-item-open,.ant-menu-horizontal > .ant-menu-item-selected,.ant-menu-horizontal > .ant-menu-item:hover,.ant-menu-horizontal > .ant-menu-submenu-active,.ant-menu-horizontal > .ant-menu-submenu-open,.ant-menu-horizontal > .ant-menu-submenu-selected,.ant-menu-horizontal > .ant-menu-submenu:hover": 36 | // eslint-disable-next-line 37 | return ".ant-menu-horizontal > .ant-menu-item-active,.ant-menu-horizontal > .ant-menu-item-open,.ant-menu-horizontal > .ant-menu-item-selected,.ant-menu-horizontal:not(.ant-menu-dark) > .ant-menu-item:hover,.ant-menu-horizontal > .ant-menu-submenu-active,.ant-menu-horizontal > .ant-menu-submenu-open,.ant-menu-horizontal:not(.ant-menu-dark) > .ant-menu-submenu-selected,.ant-menu-horizontal:not(.ant-menu-dark) > .ant-menu-submenu:hover"; 38 | case ".ant-menu-horizontal > .ant-menu-item-selected > a": 39 | case ".ant-menu-horizontal>.ant-menu-item-selected>a": 40 | return ".ant-menu-horizontal:not(ant-menu-light):not(.ant-menu-dark) > .ant-menu-item-selected > a"; 41 | case ".ant-menu-horizontal > .ant-menu-item > a:hover": 42 | case ".ant-menu-horizontal>.ant-menu-item>a:hover": 43 | return ".ant-menu-horizontal:not(ant-menu-light):not(.ant-menu-dark) > .ant-menu-item > a:hover"; 44 | default: 45 | return selector; 46 | } 47 | } 48 | }; 49 | 50 | const createThemeColorReplacerPlugin = () => new ThemeColorReplacer(themePluginOption); 51 | 52 | module.exports = createThemeColorReplacerPlugin; 53 | -------------------------------------------------------------------------------- /frontend/config/theme-plugin.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | theme: [ 3 | { 4 | key: "dark", 5 | fileName: "dark.css", 6 | theme: "dark" 7 | }, 8 | { 9 | key: "#F5222D", 10 | fileName: "#F5222D.css", 11 | modifyVars: { 12 | "@primary-color": "#F5222D" 13 | } 14 | }, 15 | { 16 | key: "#FA541C", 17 | fileName: "#FA541C.css", 18 | modifyVars: { 19 | "@primary-color": "#FA541C" 20 | } 21 | }, 22 | { 23 | key: "#FAAD14", 24 | fileName: "#FAAD14.css", 25 | modifyVars: { 26 | "@primary-color": "#FAAD14" 27 | } 28 | }, 29 | { 30 | key: "#13C2C2", 31 | fileName: "#13C2C2.css", 32 | modifyVars: { 33 | "@primary-color": "#13C2C2" 34 | } 35 | }, 36 | { 37 | key: "#52C41A", 38 | fileName: "#52C41A.css", 39 | modifyVars: { 40 | "@primary-color": "#52C41A" 41 | } 42 | }, 43 | { 44 | key: "#2F54EB", 45 | fileName: "#2F54EB.css", 46 | modifyVars: { 47 | "@primary-color": "#2F54EB" 48 | } 49 | }, 50 | { 51 | key: "#722ED1", 52 | fileName: "#722ED1.css", 53 | modifyVars: { 54 | "@primary-color": "#722ED1" 55 | } 56 | }, 57 | 58 | { 59 | key: "#F5222D", 60 | theme: "dark", 61 | fileName: "dark-#F5222D.css", 62 | modifyVars: { 63 | "@primary-color": "#F5222D" 64 | } 65 | }, 66 | { 67 | key: "#FA541C", 68 | theme: "dark", 69 | fileName: "dark-#FA541C.css", 70 | modifyVars: { 71 | "@primary-color": "#FA541C" 72 | } 73 | }, 74 | { 75 | key: "#FAAD14", 76 | theme: "dark", 77 | fileName: "dark-#FAAD14.css", 78 | modifyVars: { 79 | "@primary-color": "#FAAD14" 80 | } 81 | }, 82 | { 83 | key: "#13C2C2", 84 | theme: "dark", 85 | fileName: "dark-#13C2C2.css", 86 | modifyVars: { 87 | "@primary-color": "#13C2C2" 88 | } 89 | }, 90 | { 91 | key: "#52C41A", 92 | theme: "dark", 93 | fileName: "dark-#52C41A.css", 94 | modifyVars: { 95 | "@primary-color": "#52C41A" 96 | } 97 | }, 98 | { 99 | key: "#2F54EB", 100 | theme: "dark", 101 | fileName: "dark-#2F54EB.css", 102 | modifyVars: { 103 | "@primary-color": "#2F54EB" 104 | } 105 | }, 106 | { 107 | key: "#722ED1", 108 | theme: "dark", 109 | fileName: "dark-#722ED1.css", 110 | modifyVars: { 111 | "@primary-color": "#722ED1" 112 | } 113 | } 114 | ] 115 | }; 116 | -------------------------------------------------------------------------------- /frontend/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "baseUrl": ".", 5 | "paths": { 6 | "@/*": [ 7 | "src/*" 8 | ] 9 | } 10 | }, 11 | "exclude": [ 12 | "node_modules", 13 | "dist" 14 | ], 15 | "include": [ 16 | "src/**/*" 17 | ] 18 | } -------------------------------------------------------------------------------- /frontend/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | 4 | location / { 5 | root /usr/share/nginx/html; 6 | index index.html index.htm; 7 | try_files $uri $uri/ /index.html =404; 8 | } 9 | location /api/ { 10 | proxy_pass http://backend/api/; 11 | } 12 | } -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint", 9 | "i18n:report": "vue-cli-service i18n:report --src \"./src/**/*.?(js|vue)\" --locales \"./src/locales/**/*.json\"" 10 | }, 11 | "dependencies": { 12 | "@ant-design-vue/pro-layout": "^1.0.8", 13 | "@vue/composition-api": "^1.0.0-rc.5", 14 | "ant-design-vue": "^1.7.4", 15 | "axios": "^0.21.1", 16 | "core-js": "^3.6.5", 17 | "deepmerge": "^4.2.2", 18 | "lodash-es": "^4.17.21", 19 | "nprogress": "^0.2.0", 20 | "qs": "^6.9.6", 21 | "register-service-worker": "^1.7.1", 22 | "vee-validate": "^3.4.5", 23 | "vue": "^2.6.11", 24 | "vue-i18n": "^8.22.3", 25 | "vue-router": "^3.2.0", 26 | "vue-svg-component-runtime": "^1.0.1", 27 | "vuex": "^3.4.0", 28 | "vuex-persist": "^3.1.3" 29 | }, 30 | "devDependencies": { 31 | "@vue/cli-plugin-babel": "~4.5.0", 32 | "@vue/cli-plugin-eslint": "~4.5.0", 33 | "@vue/cli-plugin-router": "~4.5.0", 34 | "@vue/cli-plugin-vuex": "~4.5.0", 35 | "@vue/cli-service": "~4.5.0", 36 | "@vue/eslint-config-prettier": "^6.0.0", 37 | "babel-eslint": "^10.1.0", 38 | "babel-plugin-import": "^1.13.3", 39 | "babel-plugin-transform-remove-console": "^6.9.4", 40 | "eslint": "^6.7.2", 41 | "eslint-plugin-prettier": "^3.1.3", 42 | "eslint-plugin-vue": "^6.2.2", 43 | "git-revision-webpack-plugin": "^3.0.6", 44 | "less": "^3.0.4", 45 | "less-loader": "^5.0.0", 46 | "lint-staged": "^9.5.0", 47 | "prettier": "^1.19.1", 48 | "vue-cli-plugin-i18n": "~2.0.0", 49 | "vue-svg-icon-loader": "^2.1.1", 50 | "vue-template-compiler": "^2.6.11", 51 | "webpack-theme-color-replacer": "^1.3.18" 52 | }, 53 | "gitHooks": { 54 | "pre-commit": "lint-staged" 55 | }, 56 | "lint-staged": { 57 | "*.{js,jsx,vue}": [ 58 | "vue-cli-service lint", 59 | "git add" 60 | ] 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /frontend/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l2m2/fastapi-vue-admin/5e38112dc87c77c9a5bd7d446113622327ff6a67/frontend/public/logo.png -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /frontend/src/App.vue: -------------------------------------------------------------------------------- 1 | 8 | 23 | -------------------------------------------------------------------------------- /frontend/src/api/index.js: -------------------------------------------------------------------------------- 1 | import login from "./login.api"; 2 | import users from "./users.api"; 3 | import roles from "./roles.api"; 4 | import permissions from "./permissions.api"; 5 | 6 | const apis = { 7 | login, 8 | users, 9 | roles, 10 | permissions 11 | }; 12 | export default apis; 13 | -------------------------------------------------------------------------------- /frontend/src/api/login.api.js: -------------------------------------------------------------------------------- 1 | /** 2 | * AUTO-GENERATED BY scripts/frontend-api-code-generator/gen.py 3 | */ 4 | import qs from "qs"; 5 | import request from "@/utils/request"; 6 | 7 | const api = { 8 | loginAccessToken: data => { 9 | return request({ 10 | url: "/login/access-token", 11 | headers: { 12 | "Content-Type": "application/x-www-form-urlencoded" 13 | }, 14 | data: qs.stringify(data), 15 | method: "post" 16 | }); 17 | }, 18 | testToken: data => { 19 | return request({ 20 | url: "/login/test-token", 21 | data: data, 22 | method: "post" 23 | }); 24 | }, 25 | resetPassword: data => { 26 | return request({ 27 | url: "/reset-password/", 28 | headers: { 29 | "Content-Type": "application/json" 30 | }, 31 | data: data, 32 | method: "post" 33 | }); 34 | } 35 | }; 36 | export default api; 37 | -------------------------------------------------------------------------------- /frontend/src/api/permissions.api.js: -------------------------------------------------------------------------------- 1 | /** 2 | * AUTO-GENERATED BY scripts/frontend-api-code-generator/gen.py 3 | */ 4 | import request from "@/utils/request"; 5 | 6 | const api = { 7 | readPermissions: params => { 8 | return request({ 9 | url: "/permissions/", 10 | params: params, 11 | method: "get" 12 | }); 13 | }, 14 | createPermission: data => { 15 | return request({ 16 | url: "/permissions/", 17 | headers: { 18 | "Content-Type": "application/json" 19 | }, 20 | data: data, 21 | method: "post" 22 | }); 23 | }, 24 | readPermissionByCode: (code, params) => { 25 | return request({ 26 | url: "/permissions/" + code, 27 | params: params, 28 | method: "get" 29 | }); 30 | }, 31 | updatePermission: (code, data) => { 32 | return request({ 33 | url: "/permissions/" + code, 34 | headers: { 35 | "Content-Type": "application/json" 36 | }, 37 | data: data, 38 | method: "put" 39 | }); 40 | }, 41 | deletePermission: code => { 42 | return request({ 43 | url: "/permissions/" + code, 44 | method: "delete" 45 | }); 46 | } 47 | }; 48 | export default api; 49 | -------------------------------------------------------------------------------- /frontend/src/api/roles.api.js: -------------------------------------------------------------------------------- 1 | /** 2 | * AUTO-GENERATED BY scripts/frontend-api-code-generator/gen.py 3 | */ 4 | import request from "@/utils/request"; 5 | 6 | const api = { 7 | readRoles: params => { 8 | return request({ 9 | url: "/roles/", 10 | params: params, 11 | method: "get" 12 | }); 13 | }, 14 | createRole: data => { 15 | return request({ 16 | url: "/roles/", 17 | headers: { 18 | "Content-Type": "application/json" 19 | }, 20 | data: data, 21 | method: "post" 22 | }); 23 | }, 24 | readRoleById: (role_id, params) => { 25 | return request({ 26 | url: "/roles/" + role_id, 27 | params: params, 28 | method: "get" 29 | }); 30 | }, 31 | updateRole: (role_id, data) => { 32 | return request({ 33 | url: "/roles/" + role_id, 34 | headers: { 35 | "Content-Type": "application/json" 36 | }, 37 | data: data, 38 | method: "put" 39 | }); 40 | }, 41 | deleteRole: role_id => { 42 | return request({ 43 | url: "/roles/" + role_id, 44 | method: "delete" 45 | }); 46 | }, 47 | readRolePermissionsById: (role_id, params) => { 48 | return request({ 49 | url: "/roles/" + role_id + "/permissions", 50 | params: params, 51 | method: "get" 52 | }); 53 | }, 54 | updateRolePermissionsById: (role_id, data) => { 55 | return request({ 56 | url: "/roles/" + role_id + "/permissions", 57 | headers: { 58 | "Content-Type": "application/json" 59 | }, 60 | data: data, 61 | method: "put" 62 | }); 63 | } 64 | }; 65 | export default api; 66 | -------------------------------------------------------------------------------- /frontend/src/api/users.api.js: -------------------------------------------------------------------------------- 1 | /** 2 | * AUTO-GENERATED BY scripts/frontend-api-code-generator/gen.py 3 | */ 4 | import request from "@/utils/request"; 5 | 6 | const api = { 7 | readUsers: params => { 8 | return request({ 9 | url: "/users/", 10 | params: params, 11 | method: "get" 12 | }); 13 | }, 14 | createUser: data => { 15 | return request({ 16 | url: "/users/", 17 | headers: { 18 | "Content-Type": "application/json" 19 | }, 20 | data: data, 21 | method: "post" 22 | }); 23 | }, 24 | readUserMe: params => { 25 | return request({ 26 | url: "/users/me", 27 | params: params, 28 | method: "get" 29 | }); 30 | }, 31 | updateUserMe: data => { 32 | return request({ 33 | url: "/users/me", 34 | headers: { 35 | "Content-Type": "application/json" 36 | }, 37 | data: data, 38 | method: "put" 39 | }); 40 | }, 41 | readUserById: (user_id, params) => { 42 | return request({ 43 | url: "/users/" + user_id, 44 | params: params, 45 | method: "get" 46 | }); 47 | }, 48 | updateUser: (user_id, data) => { 49 | return request({ 50 | url: "/users/" + user_id, 51 | headers: { 52 | "Content-Type": "application/json" 53 | }, 54 | data: data, 55 | method: "put" 56 | }); 57 | }, 58 | deleteUser: user_id => { 59 | return request({ 60 | url: "/users/" + user_id, 61 | method: "delete" 62 | }); 63 | } 64 | }; 65 | export default api; 66 | -------------------------------------------------------------------------------- /frontend/src/assets/background.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Group 21 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /frontend/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l2m2/fastapi-vue-admin/5e38112dc87c77c9a5bd7d446113622327ff6a67/frontend/src/assets/logo.png -------------------------------------------------------------------------------- /frontend/src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Vue 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /frontend/src/components/FormBuilder/AntFormAdaptor.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 163 | -------------------------------------------------------------------------------- /frontend/src/components/FormBuilder/FormBuilder.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 118 | -------------------------------------------------------------------------------- /frontend/src/config/default-settings.js: -------------------------------------------------------------------------------- 1 | export default { 2 | navTheme: "dark", // theme for nav menu: ['dark', 'light'] 3 | primaryColor: "#F5222D", // primary color of ant design 4 | layout: "sidemenu", // nav menu position: `sidemenu` or `topmenu` 5 | contentWidth: "Fluid", // layout of content: `Fluid` or `Fixed`, only works when layout is topmenu 6 | fixedHeader: false, // sticky header 7 | fixSiderbar: false, // sticky siderbar 8 | colorWeak: false, // 色弱模式 9 | menu: { 10 | locale: true 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /frontend/src/config/router.config.js: -------------------------------------------------------------------------------- 1 | import { UserLayout, BasicLayout } from "@/layouts"; 2 | 3 | const RouteView = { 4 | name: "RouteView", 5 | render: h => h("router-view") 6 | }; 7 | 8 | /** 9 | * 基础路由 10 | */ 11 | export const basicRouters = [ 12 | { 13 | path: "/user", 14 | component: UserLayout, 15 | redirect: "/user/login", 16 | children: [ 17 | { 18 | path: "login", 19 | name: "login", 20 | component: () => import("@/views/login/Login") 21 | } 22 | ] 23 | }, 24 | { 25 | path: "/404", 26 | component: () => import("@/views/exception/404") 27 | } 28 | ]; 29 | 30 | /** 31 | * 动态路由 32 | */ 33 | export const dynamicRouters = [ 34 | { 35 | path: "/", 36 | name: "index", 37 | component: BasicLayout, 38 | meta: { title: "menu.home" }, 39 | redirect: "/dashboard", 40 | children: [ 41 | { 42 | path: "/dashboard", 43 | name: "dashboard", 44 | component: () => import("@/views/dash/Dashboard"), 45 | meta: { title: "menu.dashboard", icon: "dashboard" } 46 | }, 47 | { 48 | path: "/admin", 49 | component: RouteView, 50 | meta: { title: "menu.admin.default", icon: "setting" }, 51 | children: [ 52 | { 53 | path: "/admin/user", 54 | name: "user", 55 | component: () => import("@/views/admin/user/User"), 56 | meta: { title: "menu.admin.user", permission: ["admin.user.read"] } 57 | }, 58 | { 59 | path: "/admin/role", 60 | name: "role", 61 | component: () => import("@/views/admin/role/Role"), 62 | meta: { title: "menu.admin.role", permission: ["admin.role.read"] } 63 | }, 64 | { 65 | path: "/admin/security-log", 66 | name: "security-log", 67 | component: () => import("@/views/admin/security-log/SecurityLog"), 68 | meta: { title: "menu.admin.security-log", permission: ["admin.security-log.read"] } 69 | } 70 | ] 71 | } 72 | ] 73 | }, 74 | { 75 | path: "/account", 76 | name: "account", 77 | component: BasicLayout, 78 | children: [ 79 | { 80 | path: "/account/settings", 81 | name: "account-settings", 82 | component: () => import("@/views/account/settings/AccountSettings"), 83 | redirect: "/account/settings/basic", 84 | children: [ 85 | { 86 | path: "/account/settings/basic", 87 | name: "account-basic-settings", 88 | component: () => import("@/views/account/settings/Basic"), 89 | meta: { title: "account.settings.basic.default" } 90 | }, 91 | { 92 | path: "/account/settings/security", 93 | name: "account-security-settings", 94 | component: () => import("@/views/account/settings/Security"), 95 | meta: { title: "account.settings.security.default" } 96 | } 97 | ] 98 | } 99 | ] 100 | }, 101 | { 102 | path: "*", 103 | redirect: "/404" 104 | } 105 | ]; 106 | -------------------------------------------------------------------------------- /frontend/src/directives/auth.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import store from "@/store"; 3 | 4 | /** 5 | * 权限指令 6 | * 用法: 7 | * 1. 单个权限 8 | * {{ $t("_.action.new") }} 9 | * 2. 多个权限 10 | * {{ $t("_.action.new") }} 11 | */ 12 | const auth = Vue.directive("auth", { 13 | inserted: function(el, binding) { 14 | if (store.getters.username === "admin") { 15 | return; 16 | } 17 | let permissions = binding.value; 18 | if (typeof permissions === "string") { 19 | permissions = [permissions]; 20 | } 21 | if (permissions.some(val => store.getters.permissions.indexOf(val) === -1)) { 22 | if (el.parentNode) { 23 | el.parentNode.removeChild(el); 24 | } else { 25 | el.style.display = "none"; 26 | } 27 | } 28 | } 29 | }); 30 | 31 | export default auth; 32 | -------------------------------------------------------------------------------- /frontend/src/global.less: -------------------------------------------------------------------------------- 1 | @import '~ant-design-vue/es/style/themes/default.less'; 2 | 3 | html, 4 | body, 5 | #app, #root { 6 | height: 100%; 7 | } 8 | 9 | .colorWeak { 10 | filter: invert(80%); 11 | } 12 | 13 | canvas { 14 | display: block; 15 | } 16 | 17 | body { 18 | text-rendering: optimizeLegibility; 19 | -webkit-font-smoothing: antialiased; 20 | -moz-osx-font-smoothing: grayscale; 21 | } 22 | 23 | ul, 24 | ol { 25 | list-style: none; 26 | } -------------------------------------------------------------------------------- /frontend/src/layouts/BasicLayout.less: -------------------------------------------------------------------------------- 1 | @import "~ant-design-vue/es/style/themes/default.less"; 2 | 3 | .ant-pro-global-header-index-right { 4 | margin-right: 8px; 5 | 6 | &.ant-pro-global-header-index-dark { 7 | .ant-pro-global-header-index-action { 8 | color: hsla(0, 0%, 100%, .85); 9 | 10 | &:hover { 11 | background: #1890ff; 12 | } 13 | } 14 | } 15 | 16 | .ant-pro-account-avatar { 17 | .antd-pro-global-header-index-avatar { 18 | margin: ~'calc((@{layout-header-height} - 24px) / 2)' 0; 19 | margin-right: 8px; 20 | color: @primary-color; 21 | vertical-align: top; 22 | background: rgba(255, 255, 255, 0.85); 23 | } 24 | } 25 | 26 | .menu { 27 | .anticon { 28 | margin-right: 8px; 29 | } 30 | 31 | .ant-dropdown-menu-item { 32 | min-width: 100px; 33 | } 34 | } 35 | } 36 | 37 | -------------------------------------------------------------------------------- /frontend/src/layouts/BasicLayout.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 112 | 115 | -------------------------------------------------------------------------------- /frontend/src/layouts/RouteView.vue: -------------------------------------------------------------------------------- 1 | 31 | -------------------------------------------------------------------------------- /frontend/src/layouts/UserLayout.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 52 | 53 | 173 | -------------------------------------------------------------------------------- /frontend/src/layouts/components/AvatarDropdown.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 43 | -------------------------------------------------------------------------------- /frontend/src/layouts/components/RightContent.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 44 | -------------------------------------------------------------------------------- /frontend/src/layouts/components/SelectLang.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 37 | -------------------------------------------------------------------------------- /frontend/src/layouts/index.js: -------------------------------------------------------------------------------- 1 | import UserLayout from "./UserLayout"; 2 | import BasicLayout from "./BasicLayout"; 3 | import RouteView from "./RouteView"; 4 | 5 | export { UserLayout, BasicLayout, RouteView }; 6 | -------------------------------------------------------------------------------- /frontend/src/locales/account.i18n.js: -------------------------------------------------------------------------------- 1 | export default { 2 | settings: { 3 | basic: { 4 | default: { en: "Basic", zhcn: "基本设置" }, 5 | save: { en: "Save", zhcn: "更新基本信息" } 6 | }, 7 | security: { 8 | default: { en: "Security", zhcn: "安全设置" }, 9 | password: { en: "Password", zhcn: "账户密码" }, 10 | modify: { en: "Modify", zhcn: "修改" }, 11 | "password-description": { 12 | en: "New password only contain alpha-numeric characters", 13 | zhcn: "新密码位数必须是6-16之间,且只能包含字母数字字符、破折号和下划线" 14 | }, 15 | "change-password": { en: "Change Password", zhcn: "修改密码" }, 16 | "current-password": { en: "Current Password", zhcn: "当前密码" }, 17 | "new-password": { en: "New Password", zhcn: "新密码" }, 18 | "new-password-again": { en: "New Passowrd (again)", zhcn: "请重复新密码" } 19 | } 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /frontend/src/locales/common.i18n.js: -------------------------------------------------------------------------------- 1 | export default { 2 | app: { 3 | name: { en: "Cute sheep", zhcn: "乖乖吃草" } 4 | }, 5 | action: { 6 | default: { en: "Action", zhcn: "操作" }, 7 | new: { en: "New item", zhcn: "新建" }, 8 | delete: { en: "Delete", zhcn: "删除" }, 9 | edit: { en: "Edit", zhcn: "编辑" }, 10 | reload: { en: "Reload", zhcn: "刷新" }, 11 | "columns-setting": { en: "Columns setting", zhcn: "列设置" }, 12 | save: { en: "Save", zhcn: "保存" } 13 | }, 14 | "data-footer": { 15 | "page-text": { en: "{0}-{1} of {2}", zhcn: "{0}-{1} 共 {2}" } 16 | }, 17 | dialog: { 18 | delete: { 19 | title: { en: "Delete", zhcn: "删除" }, 20 | content: { 21 | en: "Deleted items cannot be retrieved, do you want to continue?", 22 | zhcn: "删除的条目将无法找回,是否继续?" 23 | }, 24 | ok: { en: "Delete", zhcn: "删除" }, 25 | cancel: { en: "Cancel", zhcn: "取消" } 26 | } 27 | }, 28 | message: { 29 | save: { 30 | success: { en: "Data saved", zhcn: "数据已保存" } 31 | } 32 | }, 33 | validations: { 34 | alpha: { en: "The field may only contain alphabetic characters", zhcn: "该字段只能包含字母字符" }, 35 | alpha_num: { en: "The field may only contain alpha-numeric characters", zhcn: "该字段能够包含字母数字字符、破折号和下划线" }, 36 | alpha_dash: { 37 | en: "The field may contain alpha-numeric characters as well as dashes and underscores", 38 | zhcn: "该字段只能包含字母数字字符" 39 | }, 40 | alpha_spaces: { 41 | en: "The field may only contain alphabetic characters as well as spaces", 42 | zhcn: "该字段只能包含字母字符和空格" 43 | }, 44 | between: { en: "The field must be between {min} and {max}", zhcn: "该字段必须在{min}与{max}之间" }, 45 | confirmed: { en: "The field confirmation does not match", zhcn: "该字段不能和{target}匹配" }, 46 | digits: { 47 | en: "The field must be numeric and exactly contain {length} digits", 48 | zhcn: "该字段必须是数字,且精确到{length}位数" 49 | }, 50 | dimensions: { 51 | en: "The field must be {width} pixels by {height} pixels", 52 | zhcn: "该字段必须在{width}像素与{height}像素之间" 53 | }, 54 | email: { en: "The field must be a valid email", zhcn: "该字段不是一个有效的邮箱" }, 55 | excluded: { en: "The field is not a valid value", zhcn: "该字段不是一个有效值" }, 56 | ext: { en: "The field is not a valid file", zhcn: "该字段不是一个有效的文件" }, 57 | image: { en: "The field must be an image", zhcn: "该字段不是一张有效的图片" }, 58 | integer: { en: "The field must be an integer", zhcn: "该字段必须是整数" }, 59 | length: { en: "The field must be {length} long", zhcn: "该字段长度必须为{length}" }, 60 | max_value: { en: "The field must be {max} or less", zhcn: "该字段必须小于或等于{max}" }, 61 | max: { en: "The field may not be greater than {length} characters", zhcn: "该字段不能超过{length}个字符" }, 62 | mimes: { en: "The field must have a valid file type", zhcn: "该字段不是一个有效的文件类型" }, 63 | min_value: { en: "The field must be {min} or more", zhcn: "该字段必须大于或等于{min}" }, 64 | min: { en: "The field must be at least {length} characters", zhcn: "该字段必须至少有{length}个字符" }, 65 | numeric: { en: "The field may only contain numeric characters", zhcn: "该字段只能包含数字字符" }, 66 | oneOf: { en: "The field is not a valid value", zhcn: "该字段不是一个有效值" }, 67 | regex: { en: "The field format is invalid", zhcn: "该字段格式无效" }, 68 | required_if: { en: "The field is required", zhcn: "该字段是必填项" }, 69 | required: { en: "The field is required", zhcn: "该字段是必填项" }, 70 | size: { en: "The field size must be less than {size}KB", zhcn: "该字段必须小于{size}KB" }, 71 | double: { en: "The field must be a valid decimal", zhcn: "该字段字段必须为有效的小数" } 72 | } 73 | }; 74 | -------------------------------------------------------------------------------- /frontend/src/locales/index.js: -------------------------------------------------------------------------------- 1 | import common from "./common.i18n"; 2 | import proLayoutSetting from "./pro-layout-setting.i18n"; 3 | import layouts from "./layouts.i18n"; 4 | import menu from "./menu.i18n"; 5 | import permissions from "./permissions.i18n"; 6 | import login from "./login.i18n"; 7 | import account from "./account.i18n"; 8 | import admin from "./modules/admin"; 9 | import apps from "./modules/apps"; 10 | import dash from "./modules/dash"; 11 | 12 | export default { 13 | _: common, 14 | layouts, 15 | menu, 16 | permissions, 17 | ...proLayoutSetting, 18 | login, 19 | account, 20 | ...admin, 21 | ...apps, 22 | ...dash 23 | }; 24 | -------------------------------------------------------------------------------- /frontend/src/locales/layouts.i18n.js: -------------------------------------------------------------------------------- 1 | export default { 2 | "user-layout": { 3 | title: { 4 | en: "Based on FastAPI/Vue.js/Ant Design", 5 | zhcn: "基于FastAPI/Vue.js/Ant Design实现的后台管理系统" 6 | }, 7 | help: { en: "Help", zhcn: "帮助" }, 8 | privacy: { en: "Privacy", zhcn: "隐私" }, 9 | terms: { en: "Terms", zhcn: "条款" } 10 | }, 11 | "basic-layout": { 12 | settings: { en: "Settings", zhcn: "个人设置" }, 13 | logout: { en: "Sign out", zhcn: "退出登录" }, 14 | "exit-dialog": { 15 | title: { en: "Sure to sign out?", zhcn: "你确定要退出登录吗?" }, 16 | ok: { en: "Sign out", zhcn: "继续退出" } 17 | } 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /frontend/src/locales/login.i18n.js: -------------------------------------------------------------------------------- 1 | export default { 2 | username: { 3 | placeholder: { en: "Username", zhcn: "用户名" }, 4 | required: { en: "Please enter your account", zhcn: "请输入用户名" } 5 | }, 6 | password: { 7 | placeholder: { en: "Password", zhcn: "密码" }, 8 | required: { en: "Please enter your password", zhcn: "请输入密码" } 9 | }, 10 | "remember-me": { en: "Remember me", zhcn: "自动登录" }, 11 | login: { en: "Sign In", zhcn: "登录" } 12 | }; 13 | -------------------------------------------------------------------------------- /frontend/src/locales/menu.i18n.js: -------------------------------------------------------------------------------- 1 | export default { 2 | home: { en: "Home", zhcn: "首页" }, 3 | dashboard: { en: "Dashboard", zhcn: "仪表盘" }, 4 | admin: { 5 | default: { en: "System Config", zhcn: "系统设置" }, 6 | user: { en: "User Management", zhcn: "用户管理" }, 7 | role: { en: "Role Management", zhcn: "角色管理" }, 8 | "security-log": { en: "Security Log", zhcn: "安全日志" } 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /frontend/src/locales/modules/admin/index.js: -------------------------------------------------------------------------------- 1 | import user from "./user.i18n"; 2 | import role from "./role.i18n"; 3 | 4 | export default { 5 | user, 6 | role 7 | }; 8 | -------------------------------------------------------------------------------- /frontend/src/locales/modules/admin/role.i18n.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: { en: "Role name", zhcn: "角色名称" }, 3 | description: { en: "Description", zhcn: "描述" }, 4 | "list-card-title": { en: "Role List", zhcn: "角色列表" }, 5 | "set-permissions": { en: "Set permissions", zhcn: "设置权限" } 6 | }; 7 | -------------------------------------------------------------------------------- /frontend/src/locales/modules/admin/security-log.i18n.js: -------------------------------------------------------------------------------- 1 | export default {}; 2 | -------------------------------------------------------------------------------- /frontend/src/locales/modules/admin/user.i18n.js: -------------------------------------------------------------------------------- 1 | export default { 2 | username: { en: "Username", zhcn: "用户名" }, 3 | fullname: { en: "Fullname", zhcn: "全名" }, 4 | email: { en: "Email", zhcn: "邮箱" }, 5 | superuser: { en: "Superuser", zhcn: "超级用户" }, 6 | status: { en: "Status", zhcn: "状态" }, 7 | roles: { en: "Roles", zhcn: "角色" }, 8 | enum: { 9 | status: { 10 | active: { en: "Active", zhcn: "激活" }, 11 | inactive: { en: "Inactive", zhcn: "失效" } 12 | }, 13 | is_superuser: { 14 | yes: { en: "Yes", zhcn: "是" }, 15 | no: { en: "No", zhcn: "否" } 16 | } 17 | }, 18 | "list-card-title": { en: "User List", zhcn: "用户列表" } 19 | }; 20 | -------------------------------------------------------------------------------- /frontend/src/locales/modules/apps/index.js: -------------------------------------------------------------------------------- 1 | export default {}; 2 | -------------------------------------------------------------------------------- /frontend/src/locales/modules/dash/index.js: -------------------------------------------------------------------------------- 1 | export default {}; 2 | -------------------------------------------------------------------------------- /frontend/src/locales/permissions.i18n.js: -------------------------------------------------------------------------------- 1 | export default { 2 | action: { 3 | create: { en: "Create", zhcn: "新建" }, 4 | read: { en: "Read", zhcn: "读取" }, 5 | update: { en: "Update", zhcn: "更新" }, 6 | delete: { en: "Delete", zhcn: "删除" } 7 | }, 8 | admin: { 9 | default: { en: "System Config", zhcn: "系统设置" }, 10 | user: { 11 | default: { en: "User Management", zhcn: "用户管理" } 12 | }, 13 | role: { 14 | default: { en: "Role Management", zhcn: "角色管理" } 15 | }, 16 | "security-log": { 17 | default: { en: "Security Log", zhcn: "安全日志" } 18 | } 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /frontend/src/locales/pro-layout-setting.i18n.js: -------------------------------------------------------------------------------- 1 | export default { 2 | "app.setting.pagestyle": { en: "Page style setting", zhcn: "整体风格设置" }, 3 | "app.setting.pagestyle.light": { en: "Light style", zhcn: "亮色菜单风格" }, 4 | "app.setting.pagestyle.dark": { en: "Dark style", zhcn: "暗色菜单风格" }, 5 | "app.setting.pagestyle.realdark": { en: "RealDark style", zhcn: "暗黑模式" }, 6 | "app.setting.themecolor": { en: "Theme Color", zhcn: "主题色" }, 7 | "app.setting.navigationmode": { en: "Navigation Mode", zhcn: "导航模式" }, 8 | "app.setting.content-width": { en: "Content Width", zhcn: "内容区域宽度" }, 9 | "app.setting.fixedheader": { en: "Fixed Header", zhcn: "固定 Header" }, 10 | "app.setting.fixedsidebar": { en: "Fixed Sidebar", zhcn: "固定侧边栏" }, 11 | "app.setting.fixedsidebar.hint": { en: "Fixed Sidebar", zhcn: "固定侧边栏" }, 12 | "app.setting.sidemenu": { en: "Side Menu Layout", zhcn: "侧边菜单布局" }, 13 | "app.setting.topmenu": { en: "Top Menu Layout", zhcn: "顶部菜单布局" }, 14 | "app.setting.content-width.fixed": { en: "Fixed", zhcn: "Fixed" }, 15 | "app.setting.content-width.fluid": { en: "Fluid", zhcn: "Fluid" }, 16 | "app.setting.othersettings": { en: "Other Settings", zhcn: "其他设置" }, 17 | "app.setting.weakmode": { en: "Weak Mode", zhcn: "色弱模式" }, 18 | "app.setting.copy": { en: "Copy Setting", zhcn: "拷贝设置" }, 19 | "app.setting.loading": { en: "Loading theme", zhcn: "加载主题中" }, 20 | "app.setting.copyinfo": { 21 | en: "copy success,please replace defaultSettings in src/config/defaultSettings.js", 22 | zhcn: "拷贝设置成功 src/config/defaultSettings.js" 23 | }, 24 | "app.setting.production.hint": { 25 | en: "Setting panel shows in development environment only, please manually modify", 26 | zhcn: "配置栏只在开发环境用于预览,生产环境不会展现,请拷贝后手动修改配置文件" 27 | }, 28 | "app.setting.themecolor.daybreak": { en: "Daybreak Blue", zhcn: "拂晓蓝" }, 29 | "app.setting.themecolor.dust": { en: "Dust Red", zhcn: "薄暮" }, 30 | "app.setting.themecolor.volcano": { en: "Volcano", zhcn: "火山" }, 31 | "app.setting.themecolor.sunset": { en: "Sunset Orange", zhcn: "日暮" }, 32 | "app.setting.themecolor.cyan": { en: "Cyan", zhcn: "明青" }, 33 | "app.setting.themecolor.green": { en: "Polar Green", zhcn: "极光绿" }, 34 | "app.setting.themecolor.geekblue": { en: "Geek Blue", zhcn: "极客蓝" }, 35 | "app.setting.themecolor.purple": { en: "Golden Purple", zhcn: "酱紫" } 36 | }; 37 | -------------------------------------------------------------------------------- /frontend/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import App from "./App.vue"; 3 | import "./registerServiceWorker"; 4 | import router from "./router"; 5 | import store from "./store"; 6 | 7 | import "./plugins/composition-api"; 8 | import "./plugins/vee-validate"; 9 | import "./plugins/antd"; 10 | import "./plugins/antdpro"; 11 | import "./plugins/fext"; 12 | import "./plugins/iconfont"; 13 | import i18n from "./plugins/i18n"; 14 | import authPlugin from "./plugins/auth"; 15 | Vue.use(authPlugin, { store }); 16 | 17 | import "./directives/auth"; 18 | 19 | import themePluginConfig from "../config/theme-plugin.config"; 20 | import "./utils/string"; 21 | 22 | import "./router/router-hook"; 23 | import "./global.less"; 24 | 25 | Vue.config.productionTip = false; 26 | 27 | window.umi_plugin_ant_themeVar = themePluginConfig.theme; 28 | 29 | new Vue({ 30 | router, 31 | store, 32 | i18n, 33 | render: h => h(App) 34 | }).$mount("#app"); 35 | -------------------------------------------------------------------------------- /frontend/src/mixins/app.mixin.js: -------------------------------------------------------------------------------- 1 | import { mapState } from "vuex"; 2 | 3 | const baseMixin = { 4 | computed: { 5 | ...mapState({ 6 | layout: state => state.app.layout, 7 | navTheme: state => state.app.theme, 8 | primaryColor: state => state.app.color, 9 | colorWeak: state => state.app.weak, 10 | fixedHeader: state => state.app.fixedHeader, 11 | fixedSidebar: state => state.app.fixedSidebar, 12 | contentWidth: state => state.app.contentWidth, 13 | autoHideHeader: state => state.app.autoHideHeader, 14 | 15 | isMobile: state => state.app.isMobile, 16 | sideCollapsed: state => state.app.sideCollapsed, 17 | multiTab: state => state.app.multiTab 18 | }), 19 | isTopMenu() { 20 | return this.layout === "topmenu"; 21 | } 22 | }, 23 | methods: { 24 | isSideMenu() { 25 | return !this.isTopMenu; 26 | } 27 | } 28 | }; 29 | 30 | export { baseMixin }; 31 | -------------------------------------------------------------------------------- /frontend/src/mixins/data-table.mixin.js: -------------------------------------------------------------------------------- 1 | export default { 2 | data() { 3 | return { 4 | loading: false, 5 | pagination: { 6 | pageSize: 10, 7 | current: 1, 8 | total: 0, 9 | showSizeChanger: true, 10 | showTotal: (total, range) => this.$t("_.data-footer.page-text").format(range[0], range[1], total) 11 | }, 12 | filters: null, 13 | sorter: {}, 14 | items: [] 15 | }; 16 | }, 17 | methods: { 18 | handleTableChange(pagination, filters, sorter) { 19 | const pager = { ...this.pagination }; 20 | pager.current = pagination.current; 21 | this.pagination = pager; 22 | this.filters = filters; 23 | this.sorter = sorter; 24 | this.getDataFromApi(); 25 | }, 26 | async getDataFromApi() { 27 | this.loading = true; 28 | let params = { 29 | skip: (this.pagination.current - 1) * this.pagination.pageSize, 30 | limit: this.pagination.pageSize 31 | }; 32 | if (this.sorter && this.sorter.field) { 33 | params.order = `${this.sorter.field} ${this.sorter.order === "descend" ? "DESC" : "ASC"}`; 34 | } 35 | try { 36 | const data = await this.listApi(params); 37 | this.pagination.total = data.total; 38 | if (typeof this.getDataFromApiHook === "function") { 39 | this.items = this.getDataFromApiHook(data.items); 40 | } else { 41 | this.items = data.items.map(item => { 42 | Object.keys(item).forEach(k => item[k] == null && delete item[k]); 43 | return item; 44 | }); 45 | } 46 | this.loading = false; 47 | } catch (e) { 48 | this.loading = false; 49 | } 50 | } 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /frontend/src/mixins/normal-crud.mixin.js: -------------------------------------------------------------------------------- 1 | export default { 2 | data() { 3 | return { 4 | modalVisible: false, 5 | editedItem: {} 6 | }; 7 | }, 8 | computed: { 9 | columns() { 10 | return this.config.columns(this); 11 | }, 12 | formConfig() { 13 | return this.uiloader(this); 14 | } 15 | }, 16 | watch: { 17 | modalVisible(val) { 18 | val || this.closeModal(); 19 | } 20 | }, 21 | async mounted() { 22 | this.sorter = Object.assign({}, this.config.defaultSorter); 23 | await this.getDataFromApi(); 24 | }, 25 | methods: { 26 | editItem(item = this.config.defaultItem) { 27 | this.editedItem = Object.assign({}, item); 28 | this.modalVisible = true; 29 | this.$nextTick(() => { 30 | this.updateFormValues(this.editedItem); 31 | }); 32 | }, 33 | deleteItem(item) { 34 | let self = this; 35 | this.editedItem = Object.assign({}, item); 36 | this.$confirm({ 37 | title: this.$t("_.dialog.delete.title"), 38 | content: this.$t("_.dialog.delete.content"), 39 | okType: "danger", 40 | okText: this.$t("_.dialog.delete.ok"), 41 | cancelText: this.$t("_.dialog.delete.cancel"), 42 | async onOk() { 43 | await self.deleteApi(self.editedItem.id); 44 | await self.getDataFromApi(); 45 | } 46 | }); 47 | }, 48 | closeModal() { 49 | this.modalVisible = false; 50 | this.$nextTick(() => { 51 | this.editedItem = Object.assign({}, this.config.defaultItem); 52 | }); 53 | }, 54 | async save() { 55 | const valid = await this.$refs.observer.validate(); 56 | if (!valid) { 57 | return; 58 | } 59 | if (this.editedItem.id) { 60 | await this.updateApi(this.editedItem.id, this.editedItem); 61 | } else { 62 | await this.createApi(this.editedItem); 63 | } 64 | await this.getDataFromApi(); 65 | this.closeModal(); 66 | } 67 | } 68 | }; 69 | -------------------------------------------------------------------------------- /frontend/src/permissions/README.md: -------------------------------------------------------------------------------- 1 | ## 权限配置 2 | - 层级:固定为3级 3 | 4 | 第一级为大分类,例如系统设置、功能配置等 5 | 6 | 第二级为模块名称,例如用户管理、角色管理等 7 | 8 | 第三级为具体的 Action,例如新增、修改、删除、读取等 9 | 10 | - 编写配置 11 | 12 | 固定在permissions目录下编写 13 | 14 | ``` 15 | |--permissions/ 16 | | |--admin/ 17 | | | |--user.json 18 | | | |--role.json 19 | ``` 20 | 21 | 权限的第一级是permissions下的子目录(例如admin) 22 | 23 | 权限的第二级是配置文件的文件名 24 | 25 | 权限的第三级在JSON中书写,例如`["read", "create", "edit", "delete"]`,由于通常增删查改是模块的基础权限,支持`"CRUD"`表示增删查改来简化配置, 也支持用CRUD的子集来表示部分权限,比如`"R"`表示仅有读取的权限。 26 | 27 | - 权限配置的翻译 28 | 29 | 在`locales/permissions.i18n.js`中进行翻译配置。 30 | 31 | CRUD四种基础权限不用翻译。 32 | 33 | -------------------------------------------------------------------------------- /frontend/src/permissions/admin/role.json: -------------------------------------------------------------------------------- 1 | ["CRUD"] -------------------------------------------------------------------------------- /frontend/src/permissions/admin/security-log.json: -------------------------------------------------------------------------------- 1 | ["R"] -------------------------------------------------------------------------------- /frontend/src/permissions/admin/user.json: -------------------------------------------------------------------------------- 1 | ["CRUD"] -------------------------------------------------------------------------------- /frontend/src/plugins/antd.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | 3 | import { 4 | Alert, 5 | Anchor, 6 | AutoComplete, 7 | Avatar, 8 | BackTop, 9 | Badge, 10 | Breadcrumb, 11 | Button, 12 | Calendar, 13 | Card, 14 | Carousel, 15 | Cascader, 16 | Checkbox, 17 | Col, 18 | Collapse, 19 | Comment, 20 | ConfigProvider, 21 | DatePicker, 22 | Descriptions, 23 | Divider, 24 | Drawer, 25 | Dropdown, 26 | Empty, 27 | Form, 28 | FormModel, 29 | Icon, 30 | List, 31 | Input, 32 | InputNumber, 33 | LocaleProvider, 34 | Menu, 35 | message, 36 | Modal, 37 | notification, 38 | PageHeader, 39 | Pagination, 40 | Popconfirm, 41 | Popover, 42 | Progress, 43 | Radio, 44 | Rate, 45 | Result, 46 | Row, 47 | Select, 48 | Slider, 49 | Space, 50 | Spin, 51 | Statistic, 52 | Steps, 53 | Switch, 54 | Table, 55 | Tabs, 56 | Tag, 57 | Timeline, 58 | TimePicker, 59 | Tooltip, 60 | Transfer, 61 | Tree, 62 | Upload 63 | } from "ant-design-vue"; 64 | 65 | Vue.use(Alert); 66 | Vue.use(Anchor); 67 | Vue.use(AutoComplete); 68 | Vue.use(Avatar); 69 | Vue.use(BackTop); 70 | Vue.use(Badge); 71 | Vue.use(Breadcrumb); 72 | Vue.use(Button); 73 | Vue.use(Calendar); 74 | Vue.use(Card); 75 | Vue.use(Carousel); 76 | Vue.use(Cascader); 77 | Vue.use(Checkbox); 78 | Vue.use(Col); 79 | Vue.use(Collapse); 80 | Vue.use(Comment); 81 | Vue.use(ConfigProvider); 82 | Vue.use(DatePicker); 83 | Vue.use(Descriptions); 84 | Vue.use(Divider); 85 | Vue.use(Drawer); 86 | Vue.use(Dropdown); 87 | Vue.use(Empty); 88 | Vue.use(Form); 89 | Vue.use(FormModel); 90 | Vue.use(Icon); 91 | Vue.use(List); 92 | Vue.use(Input); 93 | Vue.use(InputNumber); 94 | Vue.use(LocaleProvider); 95 | Vue.use(Menu); 96 | Vue.use(message); 97 | Vue.use(Modal); 98 | Vue.use(notification); 99 | Vue.use(PageHeader); 100 | Vue.use(Pagination); 101 | Vue.use(Popconfirm); 102 | Vue.use(Popover); 103 | Vue.use(Progress); 104 | Vue.use(Radio); 105 | Vue.use(Rate); 106 | Vue.use(Result); 107 | Vue.use(Row); 108 | Vue.use(Select); 109 | Vue.use(Slider); 110 | Vue.use(Space); 111 | Vue.use(Spin); 112 | Vue.use(Statistic); 113 | Vue.use(Steps); 114 | Vue.use(Switch); 115 | Vue.use(Table); 116 | Vue.use(Tabs); 117 | Vue.use(Tag); 118 | Vue.use(Timeline); 119 | Vue.use(TimePicker); 120 | Vue.use(Tooltip); 121 | Vue.use(Transfer); 122 | Vue.use(Tree); 123 | Vue.use(Upload); 124 | 125 | Vue.prototype.$confirm = Modal.confirm; 126 | Vue.prototype.$message = message; 127 | Vue.prototype.$notification = notification; 128 | Vue.prototype.$info = Modal.info; 129 | Vue.prototype.$success = Modal.success; 130 | Vue.prototype.$error = Modal.error; 131 | Vue.prototype.$warning = Modal.warning; 132 | -------------------------------------------------------------------------------- /frontend/src/plugins/antdpro.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import ProLayout, { PageHeaderWrapper } from "@ant-design-vue/pro-layout"; 3 | 4 | Vue.component("pro-layout", ProLayout); 5 | Vue.component("page-container", PageHeaderWrapper); 6 | Vue.component("page-header-wrapper", PageHeaderWrapper); 7 | -------------------------------------------------------------------------------- /frontend/src/plugins/auth.js: -------------------------------------------------------------------------------- 1 | const plugin = { 2 | install: (Vue, { store }) => { 3 | if (!store) { 4 | throw new Error("Please provide vuex store."); 5 | } 6 | Vue.prototype.$auth = function(permissions) { 7 | if (store.getters.username === "admin") { 8 | return true; 9 | } 10 | return !permissions.some(val => store.getters.permissions.indexOf(val) === -1); 11 | }; 12 | } 13 | }; 14 | 15 | export default plugin; 16 | -------------------------------------------------------------------------------- /frontend/src/plugins/composition-api.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import VueCompositionApi from "@vue/composition-api"; 3 | 4 | Vue.use(VueCompositionApi); 5 | -------------------------------------------------------------------------------- /frontend/src/plugins/fext.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import FormBuilder from "@/components/FormBuilder/FormBuilder.vue"; 3 | import AntFormAdaptor from "@/components/FormBuilder/AntFormAdaptor.vue"; 4 | 5 | Vue.component("form-builder", FormBuilder); 6 | Vue.component("ant-form-adaptor", AntFormAdaptor); 7 | -------------------------------------------------------------------------------- /frontend/src/plugins/i18n.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import VueI18n from "vue-i18n"; 3 | import i18nConf from "@/locales"; 4 | import store from "@/store"; 5 | 6 | Vue.use(VueI18n); 7 | 8 | const langs = process.env.VUE_APP_LANGS.split(","); 9 | 10 | function load(conf, lang, result) { 11 | for (let k in conf) { 12 | if (langs.every(item => Object.prototype.hasOwnProperty.call(conf[k], item))) { 13 | result[k] = conf[k][lang]; 14 | } else { 15 | result[k] = {}; 16 | load(conf[k], lang, result[k]); 17 | } 18 | } 19 | } 20 | 21 | function loadLocaleMessages() { 22 | const messages = {}; 23 | langs.forEach(lang => { 24 | messages[lang] = {}; 25 | load(i18nConf, lang, messages[lang]); 26 | }); 27 | return messages; 28 | } 29 | 30 | export default new VueI18n({ 31 | locale: store.getters.lang || process.env.VUE_APP_I18N_LOCALE || "en", 32 | fallbackLocale: store.getters.lang || process.env.VUE_APP_I18N_FALLBACK_LOCALE || "en", 33 | messages: loadLocaleMessages(), 34 | missing: (lang, key) => key.split(".").slice(-1)[0] 35 | }); 36 | -------------------------------------------------------------------------------- /frontend/src/plugins/iconfont.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import { Icon } from "ant-design-vue"; 3 | 4 | const IconFont = Icon.createFromIconfontCN({ 5 | scriptUrl: "//at.alicdn.com/t/font_2456846_rs0079tzph.js" 6 | }); 7 | 8 | Vue.component("icon-font", IconFont); 9 | -------------------------------------------------------------------------------- /frontend/src/plugins/vee-validate.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import { ValidationProvider, ValidationObserver, configure } from "vee-validate/dist/vee-validate.full"; 3 | import i18n from "./i18n"; 4 | 5 | Vue.component("ValidationProvider", ValidationProvider); 6 | Vue.component("ValidationObserver", ValidationObserver); 7 | 8 | configure({ 9 | // this will be used to generate messages. 10 | defaultMessage: (field, values) => { 11 | return i18n.t(`_.validations.${values._rule_}`, values); 12 | } 13 | }); 14 | -------------------------------------------------------------------------------- /frontend/src/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | import { register } from "register-service-worker"; 4 | 5 | if (process.env.NODE_ENV === "production") { 6 | register(`${process.env.BASE_URL}service-worker.js`, { 7 | ready() { 8 | console.log("App is being served from cache by a service worker.\n" + "For more details, visit https://goo.gl/AFskqB"); 9 | }, 10 | registered() { 11 | console.log("Service worker has been registered."); 12 | }, 13 | cached() { 14 | console.log("Content has been cached for offline use."); 15 | }, 16 | updatefound() { 17 | console.log("New content is downloading."); 18 | }, 19 | updated() { 20 | console.log("New content is available; please refresh."); 21 | }, 22 | offline() { 23 | console.log("No internet connection found. App is running in offline mode."); 24 | }, 25 | error(error) { 26 | console.error("Error during service worker registration:", error); 27 | } 28 | }); 29 | } 30 | -------------------------------------------------------------------------------- /frontend/src/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import VueRouter from "vue-router"; 3 | import { basicRouters } from "@/config/router.config"; 4 | 5 | // hack router push callback 6 | const originalPush = VueRouter.prototype.push; 7 | VueRouter.prototype.push = function push(location, onResolve, onReject) { 8 | if (onResolve || onReject) return originalPush.call(this, location, onResolve, onReject); 9 | return originalPush.call(this, location).catch(err => err); 10 | }; 11 | 12 | Vue.use(VueRouter); 13 | 14 | const router = new VueRouter({ 15 | mode: "history", 16 | base: process.env.BASE_URL, 17 | routes: basicRouters 18 | }); 19 | 20 | export default router; 21 | -------------------------------------------------------------------------------- /frontend/src/router/router-hook.js: -------------------------------------------------------------------------------- 1 | import router from "@/router"; 2 | import store from "@/store"; 3 | import NProgress from "nprogress"; 4 | 5 | NProgress.configure({ showSpinner: false }); 6 | 7 | const allowList = ["login"]; 8 | const loginRoutePath = "/user/login"; 9 | const defaultRoutePath = "/dashboard"; 10 | 11 | router.beforeEach((to, from, next) => { 12 | NProgress.start(); 13 | if (store.getters.token) { 14 | if (to.path === loginRoutePath) { 15 | next({ path: defaultRoutePath }); 16 | NProgress.done(); 17 | } else { 18 | if (store.getters.additionalRouters.length === 0) { 19 | store 20 | .dispatch("user/getUserInfo") 21 | .then(res => { 22 | const permissions = res.permissions; 23 | store.dispatch("permission/generateRouters", { permissions }).then(() => { 24 | store.getters.additionalRouters.forEach(item => { 25 | router.addRoute(item); 26 | }); 27 | const redirect = decodeURIComponent(from.query.redirect || to.path); 28 | if (to.path === redirect) { 29 | next({ ...to, replace: true }); 30 | } else { 31 | next({ path: redirect }); 32 | } 33 | }); 34 | }) 35 | .catch(() => { 36 | store.dispatch("user/logout").then(() => { 37 | next({ path: loginRoutePath, query: { redirect: to.fullPath } }); 38 | }); 39 | }); 40 | } else { 41 | next(); 42 | } 43 | } 44 | } else { 45 | if (allowList.includes(to.name)) { 46 | next(); 47 | } else { 48 | next({ path: loginRoutePath, query: { redirect: to.fullPath } }); 49 | // if current page is login will not trigger afterEach hook, so manually handle it 50 | NProgress.done(); 51 | } 52 | } 53 | }); 54 | 55 | router.afterEach(() => { 56 | NProgress.done(); 57 | }); 58 | -------------------------------------------------------------------------------- /frontend/src/store/getters.js: -------------------------------------------------------------------------------- 1 | const getters = { 2 | isMobile: state => state.app.isMobile, 3 | lang: state => state.app.lang, 4 | theme: state => state.app.theme, 5 | color: state => state.app.color, 6 | multiTab: state => state.app.multiTab, 7 | token: state => state.user.token, 8 | avatar: state => state.user.avatar, 9 | username: state => state.user.username, 10 | fullname: state => state.user.fullname, 11 | userInfo: state => state.user.info, 12 | permissions: state => state.user.permissions, 13 | additionalRouters: state => state.permission.additionalRouters 14 | }; 15 | 16 | export default getters; 17 | -------------------------------------------------------------------------------- /frontend/src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import Vuex from "vuex"; 3 | import VuexPersistence from "vuex-persist"; 4 | import getters from "./getters"; 5 | 6 | Vue.use(Vuex); 7 | 8 | // https://webpack.js.org/guides/dependency-management/#requirecontext 9 | const modulesFiles = require.context("./modules", true, /\.js$/); 10 | 11 | // you do not need `import app from './modules/app'` 12 | // it will auto require all vuex module from modules file 13 | const modules = modulesFiles.keys().reduce((modules, modulePath) => { 14 | // set './app.js' => 'app' 15 | const moduleName = modulePath.replace(/^\.\/(.*)\.\w+$/, "$1"); 16 | const value = modulesFiles(modulePath); 17 | modules[moduleName] = value.default; 18 | return modules; 19 | }, {}); 20 | 21 | const vuexLocal = new VuexPersistence({ 22 | storage: window.localStorage, 23 | modules: ["app", "user"] 24 | }); 25 | 26 | export default new Vuex.Store({ 27 | state: {}, 28 | mutations: {}, 29 | actions: {}, 30 | modules, 31 | getters, 32 | plugins: [vuexLocal.plugin] 33 | }); 34 | -------------------------------------------------------------------------------- /frontend/src/store/modules/app.js: -------------------------------------------------------------------------------- 1 | import { 2 | SIDEBAR_TYPE, 3 | TOGGLE_MOBILE_TYPE, 4 | TOGGLE_NAV_THEME, 5 | TOGGLE_LAYOUT, 6 | TOGGLE_FIXED_HEADER, 7 | TOGGLE_FIXED_SIDEBAR, 8 | TOGGLE_CONTENT_WIDTH, 9 | TOGGLE_HIDE_HEADER, 10 | TOGGLE_COLOR, 11 | TOGGLE_WEAK, 12 | TOGGLE_MULTI_TAB, 13 | APP_LANGUAGE 14 | } from "@/store/mutation-types"; 15 | 16 | const state = { 17 | sideCollapsed: false, 18 | isMobile: false, 19 | theme: "dark", 20 | layout: "", 21 | contentWidth: "", 22 | fixedHeader: false, 23 | fixedSidebar: false, 24 | autoHideHeader: false, 25 | color: "", 26 | weak: false, 27 | multiTab: true, 28 | lang: "zhcn" 29 | }; 30 | 31 | const mutations = { 32 | [SIDEBAR_TYPE]: (state, type) => { 33 | state.sideCollapsed = type; 34 | }, 35 | [TOGGLE_MOBILE_TYPE]: (state, isMobile) => { 36 | state.isMobile = isMobile; 37 | }, 38 | [TOGGLE_NAV_THEME]: (state, theme) => { 39 | state.theme = theme; 40 | }, 41 | [TOGGLE_LAYOUT]: (state, mode) => { 42 | state.layout = mode; 43 | }, 44 | [TOGGLE_FIXED_HEADER]: (state, mode) => { 45 | state.fixedHeader = mode; 46 | }, 47 | [TOGGLE_FIXED_SIDEBAR]: (state, mode) => { 48 | state.fixedSidebar = mode; 49 | }, 50 | [TOGGLE_CONTENT_WIDTH]: (state, type) => { 51 | state.contentWidth = type; 52 | }, 53 | [TOGGLE_HIDE_HEADER]: (state, type) => { 54 | state.autoHideHeader = type; 55 | }, 56 | [TOGGLE_COLOR]: (state, color) => { 57 | state.color = color; 58 | }, 59 | [TOGGLE_WEAK]: (state, mode) => { 60 | state.weak = mode; 61 | }, 62 | [APP_LANGUAGE]: (state, lang) => { 63 | state.lang = lang; 64 | }, 65 | [TOGGLE_MULTI_TAB]: (state, bool) => { 66 | state.multiTab = bool; 67 | } 68 | }; 69 | 70 | export default { 71 | state, 72 | mutations 73 | }; 74 | -------------------------------------------------------------------------------- /frontend/src/store/modules/permission.js: -------------------------------------------------------------------------------- 1 | import { cloneDeep as _cloneDeep } from "lodash-es"; 2 | import store from "@/store"; 3 | import { basicRouters, dynamicRouters } from "@/config/router.config"; 4 | 5 | function hasPermission(router, permissions) { 6 | if (router.meta && router.meta.permission) { 7 | return !router.meta.permission.some(val => permissions.indexOf(val) === -1); 8 | } 9 | return true; 10 | } 11 | 12 | function filterRouters(routers, permissions) { 13 | const accessedRouters = routers.filter(router => { 14 | if (hasPermission(router, permissions)) { 15 | if (router.children && router.children.length) { 16 | router.children = filterRouters(router.children, permissions); 17 | } 18 | return true; 19 | } 20 | return false; 21 | }); 22 | return accessedRouters; 23 | } 24 | 25 | const state = { 26 | routers: basicRouters, 27 | additionalRouters: [] 28 | }; 29 | 30 | const mutations = { 31 | SET_ROUTERS: (state, routers) => { 32 | state.additionalRouters = routers; 33 | state.routers = basicRouters.concat(routers); 34 | } 35 | }; 36 | 37 | const actions = { 38 | generateRouters({ commit }, data) { 39 | return new Promise(resolve => { 40 | const { permissions } = data; 41 | const accessedRouters = store.getters.username === "admin" ? dynamicRouters : filterRouters(_cloneDeep(dynamicRouters), permissions); 42 | commit("SET_ROUTERS", accessedRouters); 43 | resolve(); 44 | }); 45 | }, 46 | clear({ commit }) { 47 | commit("SET_ROUTERS", []); 48 | } 49 | }; 50 | 51 | export default { 52 | namespaced: true, 53 | state, 54 | mutations, 55 | actions 56 | }; 57 | -------------------------------------------------------------------------------- /frontend/src/store/modules/user.js: -------------------------------------------------------------------------------- 1 | import API from "@/api/index"; 2 | 3 | const state = { 4 | token: null, 5 | username: "", 6 | fullname: "", 7 | permissions: [], 8 | info: {} 9 | }; 10 | 11 | const mutations = { 12 | SET_TOKEN: (state, token) => { 13 | state.token = token; 14 | }, 15 | SET_USERNAME: (state, username) => { 16 | state.username = username; 17 | }, 18 | SET_FULLNAME: (state, fullname) => { 19 | state.fullname = fullname; 20 | }, 21 | SET_PERMISSIONS: (state, permissions) => { 22 | state.permissions = permissions; 23 | }, 24 | SET_INFO: (state, info) => { 25 | state.info = info; 26 | } 27 | }; 28 | 29 | const actions = { 30 | login({ commit }, data) { 31 | return new Promise((resolve, reject) => { 32 | API.login 33 | .loginAccessToken(data) 34 | .then(response => { 35 | commit("SET_TOKEN", response.access_token); 36 | resolve(); 37 | }) 38 | .catch(error => { 39 | reject(error); 40 | }); 41 | }); 42 | }, 43 | getUserInfo({ commit }) { 44 | return new Promise((resolve, reject) => { 45 | API.users 46 | .readUserMe() 47 | .then(response => { 48 | commit("SET_FULLNAME", response.fullname); 49 | commit("SET_USERNAME", response.username); 50 | commit("SET_PERMISSIONS", response.permissions); 51 | commit("SET_INFO", response); 52 | resolve(response); 53 | }) 54 | .catch(error => { 55 | reject(error); 56 | }); 57 | }); 58 | }, 59 | logout({ commit }) { 60 | commit("SET_USERNAME", ""); 61 | commit("SET_FULLNAME", ""); 62 | commit("SET_PERMISSIONS", []); 63 | commit("SET_INFO", {}); 64 | commit("SET_TOKEN", null); 65 | } 66 | }; 67 | 68 | export default { 69 | namespaced: true, 70 | state, 71 | mutations, 72 | actions 73 | }; 74 | -------------------------------------------------------------------------------- /frontend/src/store/mutation-types.js: -------------------------------------------------------------------------------- 1 | export const ACCESS_TOKEN = "Access-Token"; 2 | 3 | export const SIDEBAR_TYPE = "sidebar_type"; 4 | export const TOGGLE_MOBILE_TYPE = "is_mobile"; 5 | export const TOGGLE_NAV_THEME = "nav_theme"; 6 | export const TOGGLE_LAYOUT = "layout"; 7 | export const TOGGLE_FIXED_HEADER = "fixed_header"; 8 | export const TOGGLE_FIXED_SIDEBAR = "fixed_sidebar"; 9 | export const TOGGLE_CONTENT_WIDTH = "content_width"; 10 | export const TOGGLE_HIDE_HEADER = "auto_hide_header"; 11 | export const TOGGLE_COLOR = "color"; 12 | export const TOGGLE_WEAK = "weak"; 13 | export const TOGGLE_MULTI_TAB = "multi_tab"; 14 | export const APP_LANGUAGE = "app_language"; 15 | -------------------------------------------------------------------------------- /frontend/src/use/form/element.js: -------------------------------------------------------------------------------- 1 | import { ref, watch } from "@vue/composition-api"; 2 | import { isEqual as _isEqual, cloneDeep as _cloneDeep } from "lodash-es"; 3 | 4 | function useFormElement(props, context) { 5 | const rules = ref({}); 6 | const dirty = ref(false); 7 | const localValue = ref(null); 8 | const initialValue = ref(null); 9 | 10 | const setInitialValue = function(value) { 11 | initialValue.value = _cloneDeep(value); 12 | localValue.value = _cloneDeep(value); 13 | context.emit("input", localValue.value); 14 | }; 15 | 16 | const resetLocalValue = function() { 17 | localValue.value = _cloneDeep(initialValue.value); 18 | context.emit("input", localValue.value); 19 | }; 20 | 21 | const updateLocalValue = function(value) { 22 | if (!_isEqual(value, localValue.value)) { 23 | dirty.value = true; 24 | localValue.value = _cloneDeep(value); 25 | context.emit("input", localValue.value); 26 | } 27 | }; 28 | 29 | const watchPropValue = function(callback) { 30 | watch( 31 | () => props.value, 32 | value => { 33 | if (_isEqual(value, localValue.value)) { 34 | return; 35 | } 36 | callback(value); 37 | }, 38 | { 39 | deep: true 40 | } 41 | ); 42 | }; 43 | 44 | watchPropValue(value => { 45 | if (!dirty.value) { 46 | initialValue.value = _cloneDeep(value); 47 | } 48 | localValue.value = _cloneDeep(value); 49 | }); 50 | 51 | watch( 52 | () => props.rules, 53 | value => { 54 | rules.value = value; 55 | } 56 | ); 57 | 58 | return { 59 | dirty, 60 | localValue, 61 | watchPropValue, 62 | setInitialValue, 63 | resetLocalValue, 64 | updateLocalValue 65 | }; 66 | } 67 | 68 | export { useFormElement }; 69 | -------------------------------------------------------------------------------- /frontend/src/use/form/form.js: -------------------------------------------------------------------------------- 1 | import { reactive, toRefs } from "@vue/composition-api"; 2 | import { cloneDeep as _cloneDeep } from "lodash-es"; 3 | 4 | function useForm() { 5 | const state = reactive({ 6 | initialFormValues: {}, 7 | formValues: {} 8 | }); 9 | 10 | const setInitialFormValues = function(formValues) { 11 | state.initialFormValues = _cloneDeep(formValues); 12 | state.formValues = _cloneDeep(formValues); 13 | }; 14 | 15 | const updateFormValues = function(formValues) { 16 | state.formValues = formValues; 17 | }; 18 | 19 | const resetFormValues = function() { 20 | state.formValues = _cloneDeep(state.initialFormValues); 21 | }; 22 | 23 | return { 24 | ...toRefs(state), 25 | 26 | setInitialFormValues, 27 | updateFormValues, 28 | resetFormValues 29 | }; 30 | } 31 | 32 | export { useForm }; 33 | -------------------------------------------------------------------------------- /frontend/src/utils/request.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import store from "@/store"; 3 | import notification from "ant-design-vue/es/notification"; 4 | 5 | const service = axios.create({ 6 | baseURL: process.env.VUE_APP_API_BASE_URL, 7 | timeout: 6000 8 | }); 9 | 10 | service.interceptors.request.use( 11 | config => { 12 | config.headers["Authorization"] = "Bearer " + store.getters.token; 13 | return config; 14 | }, 15 | error => { 16 | return Promise.reject(error); 17 | } 18 | ); 19 | 20 | service.interceptors.response.use( 21 | response => { 22 | const res = response.data; 23 | return res; 24 | }, 25 | async error => { 26 | const { data, statusText } = error.response; 27 | let m = { message: statusText }; 28 | if (data && data.detail) { 29 | if (typeof data.detail === "string") { 30 | m.description = data.detail; 31 | } else { 32 | m.description = JSON.stringify(data.detail).replace(/"([^"]+)":/g, "$1:"); 33 | } 34 | } 35 | notification.error(m); 36 | return Promise.reject(error); 37 | } 38 | ); 39 | 40 | export default service; 41 | -------------------------------------------------------------------------------- /frontend/src/utils/string.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Usage: "Hello {0}, welcome to {1}".format("leon", "Chengdu") 3 | */ 4 | if (!String.prototype.format) { 5 | String.prototype.format = function() { 6 | var args = arguments; 7 | return this.replace(/{(\d+)}/g, function(match, number) { 8 | return typeof args[number] != "undefined" ? args[number] : match; 9 | }); 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/views/account/settings/AccountSettings.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 57 | 66 | -------------------------------------------------------------------------------- /frontend/src/views/account/settings/Basic.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 56 | -------------------------------------------------------------------------------- /frontend/src/views/account/settings/Security.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 82 | -------------------------------------------------------------------------------- /frontend/src/views/account/settings/basic.form.js: -------------------------------------------------------------------------------- 1 | export default function(self) { 2 | return [ 3 | { 4 | fields: [ 5 | { 6 | name: "fullname", 7 | component: "ant-form-adaptor", 8 | label: self.$t("user.fullname"), 9 | rules: "required" 10 | }, 11 | { 12 | name: "email", 13 | component: "ant-form-adaptor", 14 | label: self.$t("user.email"), 15 | rules: "required|email" 16 | } 17 | ] 18 | } 19 | ]; 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/views/account/settings/password-change.form.js: -------------------------------------------------------------------------------- 1 | export default function(self) { 2 | return [ 3 | { 4 | fields: [ 5 | { 6 | name: "current_password", 7 | component: "ant-form-adaptor", 8 | label: self.$t("account.settings.security.current-password"), 9 | extend: { 10 | component: "a-input-password" 11 | }, 12 | rules: "required" 13 | }, 14 | { 15 | name: "new_password", 16 | component: "ant-form-adaptor", 17 | label: self.$t("account.settings.security.new-password"), 18 | extend: { 19 | component: "a-input-password" 20 | }, 21 | rules: "alpha_num|min:6|max:16" 22 | }, 23 | { 24 | name: "new_password_2", 25 | component: "ant-form-adaptor", 26 | label: self.$t("account.settings.security.new-password-again"), 27 | extend: { 28 | component: "a-input-password" 29 | }, 30 | rules: "confirmed:new_password" 31 | } 32 | ] 33 | } 34 | ]; 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/views/admin/role/Role.vue: -------------------------------------------------------------------------------- 1 | 60 | 61 | 153 | 160 | -------------------------------------------------------------------------------- /frontend/src/views/admin/role/detail.form.js: -------------------------------------------------------------------------------- 1 | export default function(self) { 2 | return [ 3 | { 4 | fields: [ 5 | { 6 | name: "name", 7 | component: "ant-form-adaptor", 8 | label: self.$t("role.name"), 9 | rules: "required" 10 | }, 11 | { 12 | name: "description", 13 | component: "ant-form-adaptor", 14 | label: self.$t("role.description"), 15 | extend: { 16 | component: "a-textarea" 17 | }, 18 | props: { 19 | rows: 4 20 | } 21 | } 22 | ] 23 | } 24 | ]; 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/views/admin/role/module.config.js: -------------------------------------------------------------------------------- 1 | const columns = function(self) { 2 | return [ 3 | { 4 | title: self.$t("role.name"), 5 | dataIndex: "name", 6 | sorter: true 7 | }, 8 | { 9 | title: self.$t("role.description"), 10 | dataIndex: "description" 11 | }, 12 | { 13 | title: self.$t("_.action.default"), 14 | dataIndex: "action", 15 | scopedSlots: { customRender: "action" } 16 | } 17 | ]; 18 | }; 19 | 20 | const defaultItem = { 21 | name: "", 22 | description: "" 23 | }; 24 | 25 | const defaultSorter = { 26 | field: "name", 27 | order: "ascend" 28 | }; 29 | 30 | export default { 31 | columns, 32 | defaultItem, 33 | defaultSorter 34 | }; 35 | -------------------------------------------------------------------------------- /frontend/src/views/admin/security-log/SecurityLog.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /frontend/src/views/admin/user/User.vue: -------------------------------------------------------------------------------- 1 | 62 | 63 | 133 | -------------------------------------------------------------------------------- /frontend/src/views/admin/user/detail.form.js: -------------------------------------------------------------------------------- 1 | export default function(self) { 2 | return [ 3 | { 4 | fields: [ 5 | { 6 | name: "username", 7 | component: "ant-form-adaptor", 8 | label: self.$t("user.username"), 9 | rules: "required" 10 | }, 11 | { 12 | name: "fullname", 13 | component: "ant-form-adaptor", 14 | label: self.$t("user.fullname"), 15 | rules: "required" 16 | }, 17 | { 18 | name: "email", 19 | component: "ant-form-adaptor", 20 | label: self.$t("user.email"), 21 | rules: "required|email" 22 | }, 23 | { 24 | name: "is_active", 25 | component: "ant-form-adaptor", 26 | label: self.$t("user.status"), 27 | extend: { 28 | component: "a-switch" 29 | }, 30 | props: { 31 | "checked-children": self.$t("user.enum.status.active"), 32 | "un-checked-children": self.$t("user.enum.status.inactive") 33 | } 34 | }, 35 | { 36 | name: "is_superuser", 37 | component: "ant-form-adaptor", 38 | label: self.$t("user.superuser"), 39 | extend: { 40 | component: "a-switch" 41 | }, 42 | props: { 43 | "checked-children": self.$t("user.enum.is_superuser.yes"), 44 | "un-checked-children": self.$t("user.enum.is_superuser.no") 45 | } 46 | }, 47 | { 48 | name: "roles", 49 | component: "ant-form-adaptor", 50 | label: self.$t("user.roles"), 51 | extend: { 52 | component: "a-select" 53 | }, 54 | props: { 55 | mode: "multiple", 56 | "option-filter-prop": "children" 57 | }, 58 | items: self.roleOptions 59 | } 60 | ] 61 | } 62 | ]; 63 | } 64 | -------------------------------------------------------------------------------- /frontend/src/views/admin/user/module.config.js: -------------------------------------------------------------------------------- 1 | const columns = function(self) { 2 | return [ 3 | { 4 | title: self.$t("user.username"), 5 | dataIndex: "username", 6 | sorter: true 7 | }, 8 | { 9 | title: self.$t("user.fullname"), 10 | dataIndex: "fullname", 11 | sorter: true 12 | }, 13 | { 14 | title: self.$t("user.email"), 15 | dataIndex: "email", 16 | sorter: true 17 | }, 18 | { 19 | title: self.$t("user.status"), 20 | dataIndex: "is_active", 21 | scopedSlots: { customRender: "is_active" }, 22 | sorter: true 23 | }, 24 | { 25 | title: self.$t("user.superuser"), 26 | dataIndex: "is_superuser", 27 | scopedSlots: { customRender: "is_superuser" }, 28 | sorter: true 29 | }, 30 | { 31 | title: self.$t("user.roles"), 32 | dataIndex: "roles", 33 | scopedSlots: { customRender: "roles" } 34 | }, 35 | { 36 | title: self.$t("_.action.default"), 37 | dataIndex: "action", 38 | scopedSlots: { customRender: "action" } 39 | } 40 | ]; 41 | }; 42 | 43 | const defaultItem = { 44 | username: "", 45 | fullname: "", 46 | email: "", 47 | is_active: true, 48 | is_superuser: false 49 | }; 50 | 51 | const defaultSorter = { 52 | field: "username", 53 | order: "ascend" 54 | }; 55 | 56 | export default { 57 | columns, 58 | defaultItem, 59 | defaultSorter 60 | }; 61 | -------------------------------------------------------------------------------- /frontend/src/views/dash/Dashboard.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /frontend/src/views/exception/403.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 21 | -------------------------------------------------------------------------------- /frontend/src/views/exception/404.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 21 | -------------------------------------------------------------------------------- /frontend/src/views/exception/500.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 21 | -------------------------------------------------------------------------------- /frontend/src/views/login/Login.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /frontend/vue.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const webpack = require("webpack"); 3 | const GitRevisionPlugin = require("git-revision-webpack-plugin"); 4 | const GitRevision = new GitRevisionPlugin(); 5 | const buildDate = JSON.stringify(new Date().toLocaleString()); 6 | const createThemeColorReplacerPlugin = require("./config/plugin.config"); 7 | 8 | function resolve(dir) { 9 | return path.join(__dirname, dir); 10 | } 11 | 12 | // check Git 13 | function getGitHash() { 14 | try { 15 | return GitRevision.version(); 16 | } catch (e) { 17 | console.log(e); 18 | } 19 | return "unknown"; 20 | } 21 | 22 | // vue.config.js 23 | const vueConfig = { 24 | configureWebpack: { 25 | // webpack plugins 26 | plugins: [ 27 | // Ignore all locale files of moment.js 28 | new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/), 29 | new webpack.DefinePlugin({ 30 | APP_VERSION: `"${require("./package.json").version}"`, 31 | GIT_HASH: JSON.stringify(getGitHash()), 32 | BUILD_DATE: buildDate 33 | }) 34 | ] 35 | }, 36 | 37 | chainWebpack: config => { 38 | config.resolve.alias.set("@$", resolve("src")); 39 | 40 | const svgRule = config.module.rule("svg"); 41 | svgRule.uses.clear(); 42 | svgRule 43 | .oneOf("inline") 44 | .resourceQuery(/inline/) 45 | .use("vue-svg-icon-loader") 46 | .loader("vue-svg-icon-loader") 47 | .end() 48 | .end() 49 | .oneOf("external") 50 | .use("file-loader") 51 | .loader("file-loader") 52 | .options({ 53 | name: "assets/[name].[hash:8].[ext]" 54 | }); 55 | }, 56 | 57 | css: { 58 | loaderOptions: { 59 | less: { 60 | // DO NOT REMOVE THIS LINE 61 | javascriptEnabled: true 62 | } 63 | } 64 | }, 65 | 66 | devServer: { 67 | proxy: "http://127.0.0.1:8000/" 68 | }, 69 | 70 | // disable source map in production 71 | productionSourceMap: false, 72 | 73 | lintOnSave: undefined, 74 | 75 | // babel-loader no-ignore node_modules/* 76 | transpileDependencies: [], 77 | 78 | pluginOptions: { 79 | i18n: { 80 | locale: "en", 81 | fallbackLocale: "en", 82 | localeDir: "locales", 83 | enableInSFC: false 84 | } 85 | } 86 | }; 87 | 88 | // preview.pro.loacg.com only do not use in your production; 89 | if (process.env.NODE_ENV !== "production") { 90 | // add `ThemeColorReplacer` plugin to webpack plugins 91 | vueConfig.configureWebpack.plugins.push(createThemeColorReplacerPlugin()); 92 | } 93 | 94 | module.exports = vueConfig; 95 | -------------------------------------------------------------------------------- /misc/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l2m2/fastapi-vue-admin/5e38112dc87c77c9a5bd7d446113622327ff6a67/misc/demo.png -------------------------------------------------------------------------------- /scripts/frontend-api-code-generator/README.md: -------------------------------------------------------------------------------- 1 | # 前端API代码生成器 2 | 3 | ```shell 4 | python gen.py 5 | ``` 6 | 7 | 步骤 8 | 1. 获取FastAPI 接口数据:http://127.0.0.1:8000/api/v1/openapi.json 9 | 2. 解析JSON按前端要求生成JS文件 10 | 11 | ## 注意 12 | - 运行此脚本会直接覆盖 `frontend/src/api` 下的所有文件 -------------------------------------------------------------------------------- /scripts/frontend-api-code-generator/gen.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import shutil 4 | import requests 5 | 6 | SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__)) 7 | FRONTEND_API_DIR = "../../frontend/src/api/" 8 | 9 | BASE_API = "/api/v1" 10 | OPENAPI_JSON_URL = "http://127.0.0.1:8000/api/v1/openapi.json" 11 | 12 | API_FILE_TEMPLATE1 = """/** 13 | * AUTO-GENERATED BY scripts/frontend-api-code-generator/gen.py 14 | */ 15 | import request from "@/utils/request"; 16 | 17 | const api = { 18 | %APIS% 19 | }; 20 | export default api; 21 | """ 22 | API_FILE_TEMPLATE2 = """/** 23 | * AUTO-GENERATED BY scripts/frontend-api-code-generator/gen.py 24 | */ 25 | import qs from "qs"; 26 | import request from "@/utils/request"; 27 | 28 | const api = { 29 | %APIS% 30 | }; 31 | export default api; 32 | """ 33 | INDEX_FILE_TEMPLATE = """/** 34 | * AUTO-GENERATED BY scripts/frontend-api-code-generator/gen.py 35 | */ 36 | 37 | """ 38 | 39 | 40 | def _get_openapi_json(): 41 | os.environ["NO_PROXY"] = '127.0.0.1' 42 | r: requests.Response = requests.get(OPENAPI_JSON_URL, timeout=5) 43 | return r.json() 44 | 45 | 46 | def _uncapitalize(s): 47 | return s[:1].lower() + s[1:] 48 | 49 | 50 | def _get_path_dict(paths): 51 | """ 52 | Returns 53 | { "tag": [ { "name": "", "method": "", "url": "", "content_type": "" } ] } 54 | """ 55 | path_dict = {} 56 | for fullpath, methods in paths.items(): 57 | 58 | for method, detail in methods.items(): 59 | 60 | summary = detail["summary"] 61 | name = _uncapitalize(re.sub(r'\s+', '', summary)) 62 | 63 | tags = detail["tags"] 64 | if not tags: 65 | continue 66 | tag = tags[0] 67 | 68 | content_type: str = "" 69 | if "requestBody" in detail: 70 | content_type = next(iter(detail["requestBody"]["content"])) 71 | 72 | tag_obj = {"name": name, "method": method, "url": re.sub(BASE_API, '', fullpath), "content_type": content_type} 73 | if tag in path_dict: 74 | path_dict[tag].append(tag_obj) 75 | else: 76 | path_dict[tag] = [tag_obj] 77 | return path_dict 78 | 79 | 80 | def _get_index_js_content(tags): 81 | import_lines = [] 82 | content_lines = [] 83 | for tag in tags: 84 | import_lines.append(f"import {tag} from \"./{tag}.api\";") 85 | content_lines.append(f" {tag}") 86 | 87 | index_lines = import_lines 88 | index_lines.append("") 89 | index_lines.append("const apis = {") 90 | index_lines.append(',\n'.join(content_lines)) 91 | index_lines.append("};") 92 | index_lines.append("export default apis;") 93 | index_lines.append("") 94 | return "\n".join(index_lines) 95 | 96 | 97 | def gen(): 98 | paths = _get_openapi_json()["paths"] 99 | path_dict = _get_path_dict(paths) 100 | if not path_dict: 101 | raise "no path_dict" 102 | 103 | api_dir = os.path.join(SCRIPT_DIR, FRONTEND_API_DIR) 104 | if os.path.isdir(api_dir): 105 | shutil.rmtree(api_dir, ignore_errors=True) 106 | os.makedirs(api_dir) 107 | 108 | # *.api.js 109 | for tag, apis in path_dict.items(): 110 | api_strs = [] 111 | qs_flag: bool = False 112 | for api in apis: 113 | lines = [] 114 | 115 | params = re.findall(r'\{(.*?)\}', api["url"]) 116 | if api["method"] == "get": 117 | params.append("params") 118 | elif api["method"] in ["post", "put", "patch"]: 119 | params.append("data") 120 | params_str: str = "" 121 | if len(params) > 1: 122 | params_str = "({})".format(', '.join(params)) 123 | else: 124 | params_str = "{}".format(', '.join(params)) 125 | 126 | lines.append(f"{api['name']}: {params_str} => {{") 127 | lines.append(" return request({") 128 | 129 | # url 130 | places = re.findall(r"\{(.*?)\}", api["url"]) 131 | url_value = api["url"] 132 | for index, place in enumerate(places): 133 | if (index == (len(places) - 1)) and api["url"][-1] == '}': 134 | url_value = re.sub(f"{{{place}}}", f'" + {place}', url_value) 135 | else: 136 | url_value = re.sub(f"{{{place}}}", f'" + {place} + "', url_value) 137 | if api["url"][-1] != '}': 138 | url_value = f"{url_value}\"" 139 | lines.append(" url: \"{},".format(url_value)) 140 | 141 | # headers 142 | if "content_type" in api and api["content_type"]: 143 | if api["content_type"] == "application/x-www-form-urlencoded": 144 | qs_flag = True 145 | lines.append(" headers: {") 146 | lines.append(" \"Content-Type\": \"{}\"".format(api["content_type"])) 147 | lines.append(" },") 148 | 149 | # data 150 | if api["method"] == "get": 151 | lines.append(" params: params,") 152 | elif api["method"] in ["post", "put", "patch"]: 153 | if api["content_type"] == "application/x-www-form-urlencoded": 154 | lines.append(" data: qs.stringify(data),") 155 | else: 156 | lines.append(" data: data,") 157 | 158 | # method 159 | lines.append(" method: \"{}\"".format(api["method"])) 160 | 161 | lines.append(" });") 162 | lines.append(" }") 163 | 164 | api_strs.append('\n'.join(lines)) 165 | 166 | content = re.sub("%APIS%", ',\n '.join(api_strs), API_FILE_TEMPLATE1 if not qs_flag else API_FILE_TEMPLATE2) 167 | with open(os.path.join(api_dir, f"{tag}.api.js"), 'w', encoding='utf-8') as f: 168 | f.write(content) 169 | 170 | # index.js 171 | with open(os.path.join(api_dir, "index.js"), 'w', encoding='utf-8') as f: 172 | f.write(_get_index_js_content(path_dict.keys())) 173 | 174 | 175 | if __name__ == "__main__": 176 | print("Start...") 177 | gen() 178 | print("Done!") 179 | -------------------------------------------------------------------------------- /scripts/permissions-uploader/README.md: -------------------------------------------------------------------------------- 1 | # 权限配置上传工具 2 | 3 | 负责将权限配置上传到数据库 4 | 5 | ```shell 6 | python uploader.py 7 | ``` -------------------------------------------------------------------------------- /scripts/permissions-uploader/uploader.py: -------------------------------------------------------------------------------- 1 | import os 2 | import glob 3 | import re 4 | import json 5 | import argparse 6 | import psycopg2 7 | import psycopg2.extras 8 | from app.core.config import settings 9 | 10 | SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__)) 11 | PERMISSION_CONF_DIR = os.path.join(SCRIPT_DIR, "../../frontend/src/permissions") 12 | 13 | 14 | def _get_all_permissions_code(): 15 | files = [y for x in os.walk(PERMISSION_CONF_DIR) for y in glob.glob(os.path.join(x[0], '*.json'))] 16 | total = [] 17 | for file in files: 18 | match = re.search(re.compile(PERMISSION_CONF_DIR + "/(.*?).json"), file) 19 | if match: 20 | prefix = match.group(1) 21 | items = [] 22 | with open(file, 'r') as f: 23 | for item in json.load(f): 24 | crud_match = re.search(r'^[CRUD]+$', item) 25 | if crud_match: 26 | for crud_item in crud_match.group(0): 27 | if crud_item == "C": 28 | items.append("create") 29 | elif crud_item == "R": 30 | items.append("read") 31 | elif crud_item == "U": 32 | items.append("update") 33 | elif crud_item == "D": 34 | items.append("delete") 35 | total = total + ['.'.join(f"{prefix}/{x}".split('/')) for x in items] 36 | return total 37 | 38 | 39 | def upload(options): 40 | codes = _get_all_permissions_code() 41 | if not codes: 42 | print("no permission conf found.") 43 | return 44 | codes = [(x, ) for x in codes] 45 | conn = psycopg2.connect(**options) 46 | cursor = conn.cursor() 47 | try: 48 | cursor.execute("DELETE FROM sys_permission") 49 | psycopg2.extras.execute_values(cursor, "INSERT INTO sys_permission(code) VALUES %s", codes) 50 | except Exception as e: # noqa: E722 51 | print(e) 52 | conn.rollback() 53 | else: 54 | conn.commit() 55 | finally: 56 | conn.close() 57 | 58 | 59 | if __name__ == "__main__": 60 | parser = argparse.ArgumentParser() 61 | parser.add_argument('--host', type=str, required=False) 62 | parser.add_argument('--port', type=int, required=False) 63 | parser.add_argument('--user', type=str, required=False) 64 | parser.add_argument('--password', type=str, required=False) 65 | parser.add_argument('--database', type=str, required=False) 66 | 67 | options = vars(parser.parse_args()) 68 | # 若命令行参数没有指定,从.env读取 69 | if not options['host']: 70 | options['host'] = settings.POSTGRES_SERVER 71 | if not options['port']: 72 | options['port'] = settings.POSTGRES_PORT 73 | if not options['user']: 74 | options['user'] = settings.POSTGRES_USER 75 | if not options['password']: 76 | options['password'] = settings.POSTGRES_PASSWORD 77 | if not options['database']: 78 | options['database'] = settings.POSTGRES_DB 79 | 80 | print(f"options: {options}") 81 | upload(options) 82 | -------------------------------------------------------------------------------- /vetur.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | projects: [ 3 | "./frontend" 4 | ] 5 | } --------------------------------------------------------------------------------