├── .gitignore ├── LICENSE ├── README.md ├── alembic.ini ├── config ├── development.yaml └── production.yaml ├── images ├── dictonary.png ├── dictonary_items.png ├── job_manage.png ├── job_playbook.png ├── login.png ├── menu.png ├── menu_add.png ├── role.png ├── role_add.png ├── user.png └── user_add.png ├── myapp.py ├── requirements.txt ├── rpyc_scheduler ├── __init__.py ├── config.py ├── job.py ├── jobstore.py ├── models.py ├── scheduler.py ├── server.py ├── tasks.py └── utils.py ├── server ├── .idea │ ├── .gitignore │ ├── deployment.xml │ ├── inspectionProfiles │ │ └── profiles_settings.xml │ ├── misc.xml │ ├── modules.xml │ ├── server.iml │ └── vcs.xml ├── README.md ├── __init__.py ├── alembic.ini ├── alembic │ ├── README │ ├── __pycache__ │ │ └── env.cpython-39.pyc │ ├── env.py │ ├── script.py.mako │ └── versions │ │ ├── 001da528b756_添加数据字典.py │ │ └── 9edc223ae20a_update数据字典.py ├── common │ ├── __init__.py │ ├── __pycache__ │ │ ├── __init__.cpython-39.pyc │ │ ├── security.cpython-39.pyc │ │ └── utils.cpython-39.pyc │ ├── auth_casbin.py │ ├── database.py │ ├── dep.py │ ├── log.py │ ├── response_code.py │ ├── security.py │ └── utils.py ├── crud │ ├── __init__.py │ ├── base.py │ └── internal │ │ ├── __init__.py │ │ ├── dictonary.py │ │ ├── host.py │ │ ├── job.py │ │ ├── menu.py │ │ ├── playbook.py │ │ ├── roles.py │ │ └── user.py ├── data.py ├── db_init.py ├── main.py ├── model.conf ├── models │ ├── __init__.py │ └── internal │ │ ├── __init__.py │ │ ├── dictonary.py │ │ ├── host.py │ │ ├── job.py │ │ ├── menu.py │ │ ├── playbook.py │ │ ├── relationships.py │ │ ├── role.py │ │ └── user.py ├── requirements.txt ├── routers │ ├── __init__.py │ ├── __pycache__ │ │ ├── __init__.cpython-39.pyc │ │ ├── menu.cpython-39.pyc │ │ ├── roles.cpython-39.pyc │ │ └── user.cpython-39.pyc │ └── internal │ │ ├── __init__.py │ │ ├── dictonary.py │ │ ├── host.py │ │ ├── job.py │ │ ├── login.py │ │ ├── menu.py │ │ ├── playbook.py │ │ ├── roles.py │ │ └── user.py ├── settings.py ├── sql │ └── __pycache__ │ │ ├── __init__.cpython-39.pyc │ │ ├── crud.cpython-39.pyc │ │ ├── database.cpython-39.pyc │ │ ├── models.cpython-39.pyc │ │ └── schemas.cpython-39.pyc └── static │ └── template │ └── system.xlsx ├── service.sh ├── todo-list.md └── www ├── .env.development ├── .env.developmentv ├── .env.production ├── .gitignore ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── public └── favicon.ico ├── src ├── App.vue ├── api │ ├── dictonary.js │ ├── file.js │ ├── host.js │ ├── jobs.js │ ├── menus.js │ ├── playbook.js │ ├── roles.js │ └── users.js ├── assets │ └── logo.png ├── autoImport.d.ts ├── components.d.ts ├── components │ ├── AutoDict.vue │ ├── AutoFormDialog.vue │ ├── HeaderContent.vue │ ├── InputPlus.vue │ ├── MenuList.vue │ ├── ResetButton.vue │ └── roles.vue ├── composables │ ├── useMenu.js │ ├── usePagination.js │ └── useTerm.js ├── main.js ├── permission.js ├── router │ └── index.js ├── stores │ ├── collapse.js │ ├── dict.js │ ├── index.js │ └── tabs.js ├── utils │ ├── auth.js │ ├── common.js │ ├── request.js │ └── router.js └── views │ ├── DashBoard.vue │ ├── Home │ └── index.vue │ ├── Layout.vue │ ├── Login │ └── index.vue │ ├── PersonalCenter.vue │ ├── errorPage │ ├── 401.vue │ ├── 404.vue │ └── NotFound.vue │ ├── host │ ├── AddGroup.vue │ ├── HostDialog.vue │ └── index.vue │ ├── jobs │ ├── Execute │ │ ├── LogDrawer.vue │ │ └── index.vue │ ├── JobManage │ │ ├── AddTargetsDialog.vue │ │ ├── JobLogs.vue │ │ ├── LogDetail.vue │ │ └── index.vue │ ├── components │ │ ├── AddJob.vue │ │ └── AddTargetsDialog.vue │ └── playbook │ │ ├── AddPlaybook.vue │ │ └── index.vue │ └── system │ ├── dictonary │ ├── AddDict.vue │ ├── AddItem.vue │ ├── DictItem.vue │ └── index.vue │ ├── menus │ ├── ButtonForm.vue │ ├── MenuDrawer.vue │ ├── MenuForm.vue │ └── index.vue │ ├── roles │ ├── RoleDialog.vue │ └── index.vue │ ├── setting │ └── index.vue │ └── user │ ├── ResetPasswdDialog.vue │ ├── UserDrawer.vue │ └── index.vue └── vite.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | server/**/__pycache__/*.pyc 2 | server/**/.idea 3 | *.pyc 4 | *.pyc 5 | *.log 6 | *.pyc 7 | -------------------------------------------------------------------------------- /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 within the migration file 15 | # as well as the filename. 16 | # If specified, requires the python-dateutil library that can be 17 | # installed by adding `alembic[tz]` to the pip requirements 18 | # string value is passed to dateutil.tz.gettz() 19 | # leave blank for localtime 20 | # timezone = 21 | 22 | # max length of characters to apply to the 23 | # "slug" field 24 | # truncate_slug_length = 40 25 | 26 | # set to 'true' to run the environment during 27 | # the 'revision' command, regardless of autogenerate 28 | # revision_environment = false 29 | 30 | # set to 'true' to allow .pyc and .pyo files without 31 | # a source .py file to be detected as revisions in the 32 | # versions/ directory 33 | # sourceless = false 34 | 35 | # version location specification; This defaults 36 | # to alembic/versions. When using multiple version 37 | # directories, initial revisions must be specified with --version-path. 38 | # The path separator used here should be the separator specified by "version_path_separator" 39 | # version_locations = %(here)s/bar:%(here)s/bat:alembic/versions 40 | 41 | # version path separator; As mentioned above, this is the character used to split 42 | # version_locations. Valid values are: 43 | # 44 | # version_path_separator = : 45 | # version_path_separator = ; 46 | # version_path_separator = space 47 | version_path_separator = os # default: use os.pathsep 48 | 49 | # the output encoding used when revision files 50 | # are written from script.py.mako 51 | # output_encoding = utf-8 52 | 53 | sqlalchemy.url = driver://user:pass@localhost/dbname 54 | 55 | 56 | [post_write_hooks] 57 | # post_write_hooks defines scripts or Python functions that are run 58 | # on newly generated revision scripts. See the documentation for further 59 | # detail and examples 60 | 61 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 62 | # hooks = black 63 | # black.type = console_scripts 64 | # black.entrypoint = black 65 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 66 | 67 | # Logging configuration 68 | [loggers] 69 | keys = root,sqlalchemy,alembic 70 | 71 | [handlers] 72 | keys = console 73 | 74 | [formatters] 75 | keys = generic 76 | 77 | [logger_root] 78 | level = WARN 79 | handlers = console 80 | qualname = 81 | 82 | [logger_sqlalchemy] 83 | level = WARN 84 | handlers = 85 | qualname = sqlalchemy.engine 86 | 87 | [logger_alembic] 88 | level = INFO 89 | handlers = 90 | qualname = alembic 91 | 92 | [handler_console] 93 | class = StreamHandler 94 | args = (sys.stderr,) 95 | level = NOTSET 96 | formatter = generic 97 | 98 | [formatter_generic] 99 | format = %(levelname)-5.5s [%(name)s] %(message)s 100 | datefmt = %H:%M:%S 101 | -------------------------------------------------------------------------------- /config/development.yaml: -------------------------------------------------------------------------------- 1 | # 通用配置 2 | common: 3 | env: development 4 | log_level: DEBUG 5 | 6 | # FastAPI 服务器配置 7 | server: 8 | host: 0.0.0.0 9 | port: 8000 10 | debug: true 11 | # token信息 12 | secret_key: "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7" 13 | algorithm: "HS256" 14 | access_token_expire_minutes: 30 15 | # 连接数据库 16 | database_uri: "mysql+pymysql://root:123456@localhost/devops" 17 | casbin_model_path: "server/model.conf" 18 | # 白名单 19 | no_verify_url: 20 | - "/" 21 | - "/api/login" 22 | redis: 23 | host: "192.168.137.129" 24 | password: "seraphim" 25 | port: 6379 26 | health_check_interval: 30 27 | # 配置连接rpyc信息 28 | rpyc_config: 29 | host: localhost 30 | port: 18861 31 | config: 32 | allow_public_attrs: true 33 | allow_pickle: true 34 | keepalive: true 35 | 36 | # RPyC Scheduler 配置 37 | scheduler: 38 | rpc_port: 18861 39 | apscheduler_job_store: 'mysql+pymysql://root:123456@192.168.137.129/devops' 40 | redis: 41 | host: "192.168.137.129" 42 | password: "seraphim" 43 | port: 6379 44 | health_check_interval: 30 -------------------------------------------------------------------------------- /config/production.yaml: -------------------------------------------------------------------------------- 1 | # 通用配置 2 | common: 3 | env: production 4 | log_level: DEBUG 5 | 6 | # FastAPI 服务器配置 7 | server: 8 | host: 0.0.0.0 9 | port: 8000 10 | debug: true 11 | # token信息 12 | secret_key: "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7" 13 | algorithm: "HS256" 14 | access_token_expire_minutes: 30 15 | # 连接数据库 16 | database_uri: "mysql+pymysql://root:123456@localhost/devops" 17 | casbin_model_path: "server/model.conf" 18 | # 白名单 19 | no_verify_url: 20 | - "/" 21 | - "/api/login" 22 | redis: 23 | host: "192.168.137.129" 24 | password: "seraphim" 25 | port: 6379 26 | health_check_interval: 30 27 | # 配置连接rpyc信息 28 | rpyc_config: 29 | host: localhost 30 | port: 18861 31 | config: 32 | allow_public_attrs: true 33 | allow_pickle: true 34 | keepalive: true 35 | 36 | # RPyC Scheduler 配置 37 | scheduler: 38 | rpc_port: 18861 39 | apscheduler_job_store: 'mysql+pymysql://root:123456@192.168.137.129/devops' 40 | redis: 41 | host: "192.168.137.129" 42 | password: "seraphim" 43 | port: 6379 44 | health_check_interval: 30 -------------------------------------------------------------------------------- /images/dictonary.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4linuxfun/Fastapi-Admin/5c057ea38a5c8a1c9189ee7090c306979a1dfaf6/images/dictonary.png -------------------------------------------------------------------------------- /images/dictonary_items.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4linuxfun/Fastapi-Admin/5c057ea38a5c8a1c9189ee7090c306979a1dfaf6/images/dictonary_items.png -------------------------------------------------------------------------------- /images/job_manage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4linuxfun/Fastapi-Admin/5c057ea38a5c8a1c9189ee7090c306979a1dfaf6/images/job_manage.png -------------------------------------------------------------------------------- /images/job_playbook.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4linuxfun/Fastapi-Admin/5c057ea38a5c8a1c9189ee7090c306979a1dfaf6/images/job_playbook.png -------------------------------------------------------------------------------- /images/login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4linuxfun/Fastapi-Admin/5c057ea38a5c8a1c9189ee7090c306979a1dfaf6/images/login.png -------------------------------------------------------------------------------- /images/menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4linuxfun/Fastapi-Admin/5c057ea38a5c8a1c9189ee7090c306979a1dfaf6/images/menu.png -------------------------------------------------------------------------------- /images/menu_add.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4linuxfun/Fastapi-Admin/5c057ea38a5c8a1c9189ee7090c306979a1dfaf6/images/menu_add.png -------------------------------------------------------------------------------- /images/role.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4linuxfun/Fastapi-Admin/5c057ea38a5c8a1c9189ee7090c306979a1dfaf6/images/role.png -------------------------------------------------------------------------------- /images/role_add.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4linuxfun/Fastapi-Admin/5c057ea38a5c8a1c9189ee7090c306979a1dfaf6/images/role_add.png -------------------------------------------------------------------------------- /images/user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4linuxfun/Fastapi-Admin/5c057ea38a5c8a1c9189ee7090c306979a1dfaf6/images/user.png -------------------------------------------------------------------------------- /images/user_add.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4linuxfun/Fastapi-Admin/5c057ea38a5c8a1c9189ee7090c306979a1dfaf6/images/user_add.png -------------------------------------------------------------------------------- /myapp.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | from server.main import app 3 | 4 | if __name__ == '__main__': 5 | uvicorn.run(app, host="0.0.0.0", port=8000) 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4linuxfun/Fastapi-Admin/5c057ea38a5c8a1c9189ee7090c306979a1dfaf6/requirements.txt -------------------------------------------------------------------------------- /rpyc_scheduler/__init__.py: -------------------------------------------------------------------------------- 1 | from .server import main -------------------------------------------------------------------------------- /rpyc_scheduler/config.py: -------------------------------------------------------------------------------- 1 | import yaml 2 | import os 3 | from pathlib import Path 4 | 5 | # 获取环境变量,默认为 development 6 | env = os.getenv('ENV', 'development') 7 | config_path = Path(__file__).parent.parent / 'config' / f'{env}.yaml' 8 | 9 | # 初始化配置变量 10 | rpc_config = {} 11 | 12 | # 读取配置文件 13 | def load_config(): 14 | global rpc_config 15 | with open(config_path, 'r', encoding='utf-8') as f: 16 | config = yaml.safe_load(f) 17 | rpc_config.update(config['scheduler']) 18 | 19 | # 加载配置 20 | load_config() 21 | -------------------------------------------------------------------------------- /rpyc_scheduler/job.py: -------------------------------------------------------------------------------- 1 | from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError 2 | from apscheduler.util import maybe_ref, datetime_to_utc_timestamp, utc_timestamp_to_datetime 3 | from apscheduler.job import Job 4 | try: 5 | import cPickle as pickle 6 | except ImportError: # pragma: nocover 7 | import pickle 8 | 9 | try: 10 | from sqlalchemy import ( 11 | create_engine, Table, Column, MetaData, Unicode, Float, LargeBinary, select, and_) 12 | from sqlalchemy.exc import IntegrityError 13 | from sqlalchemy.sql.expression import null 14 | except ImportError: # pragma: nocover 15 | raise ImportError('SQLAlchemyJobStore requires SQLAlchemy installed') 16 | 17 | 18 | class MyJobStore(BaseJobStore): 19 | """ 20 | 参照apscheduler自带的SQLAlchemyJobStore,重写JobStore 21 | 1. 添加额外的字段 22 | 2. 重写部分方法,实现Job的保持 23 | """ 24 | 25 | def __init__(self, url=None, engine=None, tablename='apscheduler_jobs', metadata=None, 26 | pickle_protocol=pickle.HIGHEST_PROTOCOL, tableschema=None, engine_options=None): 27 | super().__init__(url=None, engine=None, tablename='apscheduler_jobs', metadata=None, 28 | pickle_protocol=pickle.HIGHEST_PROTOCOL, tableschema=None, engine_options=None) 29 | self.jobs_t = Table( 30 | tablename, metadata, 31 | Column('id', Unicode(191), primary_key=True), 32 | Column('next_run_time', Float(25), index=True), 33 | Column('job_state', LargeBinary, nullable=False), 34 | schema=tableschema 35 | ) 36 | 37 | def start(self, scheduler, alias): 38 | super().start(scheduler, alias) 39 | -------------------------------------------------------------------------------- /rpyc_scheduler/jobstore.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from loguru import logger 3 | from apscheduler.util import maybe_ref 4 | from apscheduler.job import Job 5 | from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore 6 | from sqlalchemy import ( 7 | create_engine, Table, Column, MetaData, Unicode, Float, LargeBinary, select, and_, text, Integer) 8 | 9 | try: 10 | import cPickle as pickle 11 | except ImportError: # pragma: nocover 12 | import pickle 13 | 14 | 15 | class CustomJobStore(SQLAlchemyJobStore): 16 | 17 | def get_multi_jobs(self, uid=None): 18 | """ 19 | 通过job_id查询多个job 20 | """ 21 | jobs = [] 22 | selectable = select(self.jobs_t.c.id, self.jobs_t.c.job_state). \ 23 | order_by(self.jobs_t.c.next_run_time) 24 | selectable = selectable.where(self.jobs_t.c.id == uid) if uid else selectable 25 | logger.debug(selectable) 26 | failed_job_ids = set() 27 | with self.engine.begin() as connection: 28 | for row in connection.execute(selectable): 29 | try: 30 | jobs.append(self._reconstitute_job(row.job_state)) 31 | except BaseException: 32 | self._logger.exception('Unable to restore job "%s" -- removing it', row.id) 33 | failed_job_ids.add(row.id) 34 | 35 | # Remove all the jobs we failed to restore 36 | if failed_job_ids: 37 | delete = self.jobs_t.delete().where(self.jobs_t.c.id.in_(failed_job_ids)) 38 | connection.execute(delete) 39 | logger.debug(jobs) 40 | return jobs 41 | 42 | def get_user_jobs(self, uid, job_name, jobstore=None) -> list[Job]: 43 | """ 44 | 通过uid和job_name,获取用户的jobs列表 45 | :param uid:用户ID 46 | :param job_name:指定匹配job name 47 | :param jobstore: None 48 | """ 49 | user_jobs: List[Job] = [] 50 | jobs = self.get_multi_jobs(uid) 51 | logger.debug(jobs) 52 | for job in jobs: 53 | if job_name is None: 54 | user_jobs.append(job) 55 | elif (job.name.find(job_name)) >= 0: 56 | user_jobs.append(job) 57 | logger.debug(user_jobs) 58 | return user_jobs 59 | -------------------------------------------------------------------------------- /rpyc_scheduler/models.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from datetime import datetime 3 | from sqlmodel import SQLModel, Field, Column, Integer, create_engine, Session 4 | from sqlalchemy.dialects import mysql 5 | from .config import rpc_config 6 | from sqlmodel import SQLModel 7 | from typing import Union 8 | 9 | engine = create_engine(str(rpc_config['apscheduler_job_store']), pool_size=5, max_overflow=10, pool_timeout=30, 10 | pool_pre_ping=True) 11 | 12 | 13 | # SQLModel.metadata.create_all(engine) 14 | 15 | class InventoryHost(SQLModel): 16 | """ 17 | 主机资产涉及的参数 18 | """ 19 | name: str 20 | ansible_host: str 21 | ansible_port: int = 22 22 | ansible_user: str 23 | ansible_password: Union[str, None] = None 24 | ansible_ssh_private_key: Union[str, None] = None 25 | 26 | 27 | class AnsibleInventory(SQLModel): 28 | pass 29 | -------------------------------------------------------------------------------- /rpyc_scheduler/scheduler.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | from loguru import logger 3 | from datetime import datetime, timedelta 4 | import six 5 | import warnings 6 | from apscheduler.schedulers.background import BackgroundScheduler 7 | 8 | try: 9 | from collections.abc import MutableMapping 10 | except ImportError: 11 | from collections import MutableMapping 12 | 13 | STATE_STOPPED = 0 14 | #: constant indicating a scheduler's running state (started and processing jobs) 15 | STATE_RUNNING = 1 16 | #: constant indicating a scheduler's paused state (started but not processing jobs) 17 | STATE_PAUSED = 2 18 | 19 | 20 | class CustomBackgroundScheduler(BackgroundScheduler): 21 | 22 | def get_user_jobs(self, uid, job_name, jobstore=None, pending=None): 23 | """ 24 | 分页搜索jobs 25 | 26 | :param str|unicode jobstore: alias of the job store 27 | :param bool pending: **DEPRECATED** 28 | :rtype: list[Job] 29 | 30 | """ 31 | if pending is not None: 32 | warnings.warn('The "pending" option is deprecated -- get_jobs() always returns ' 33 | 'scheduled jobs if the scheduler has been started and pending jobs ' 34 | 'otherwise', DeprecationWarning) 35 | 36 | with self._jobstores_lock: 37 | jobs = [] 38 | if self.state == STATE_STOPPED: 39 | for job, alias, replace_existing in self._pending_jobs: 40 | if jobstore is None or alias == jobstore: 41 | jobs.append(job) 42 | else: 43 | for alias, store in six.iteritems(self._jobstores): 44 | if jobstore is None or alias == jobstore: 45 | jobs.extend(store.get_user_jobs(uid, job_name, jobstore)) 46 | logger.debug(jobs) 47 | return jobs 48 | 49 | def get_jobs(self, jobstore=None, pending=None): 50 | """ 51 | 分页搜索jobs 52 | 53 | :param str|unicode jobstore: alias of the job store 54 | :param bool pending: **DEPRECATED** 55 | :rtype: list[Job] 56 | 57 | """ 58 | if pending is not None: 59 | warnings.warn('The "pending" option is deprecated -- get_jobs() always returns ' 60 | 'scheduled jobs if the scheduler has been started and pending jobs ' 61 | 'otherwise', DeprecationWarning) 62 | 63 | with self._jobstores_lock: 64 | jobs = [] 65 | if self.state == STATE_STOPPED: 66 | for job, alias, replace_existing in self._pending_jobs: 67 | if jobstore is None or alias == jobstore: 68 | jobs.append(job) 69 | else: 70 | for alias, store in six.iteritems(self._jobstores): 71 | if jobstore is None or alias == jobstore: 72 | jobs.extend(store.get_all_jobs()) 73 | return jobs 74 | -------------------------------------------------------------------------------- /rpyc_scheduler/utils.py: -------------------------------------------------------------------------------- 1 | import redis 2 | import os 3 | from loguru import logger 4 | from pathlib import Path 5 | from typing import Dict, Any, List 6 | from .models import InventoryHost 7 | 8 | 9 | class Channel: 10 | def __init__(self, redis_config, job_id): 11 | self.conn = redis.Redis(**redis_config, decode_responses=True) 12 | self.job_id = job_id 13 | self.task_key = f"tasks:{self.job_id}" 14 | self._expire = 15 15 | 16 | @property 17 | def msg(self, ): 18 | return self.conn.xrange(self.task_key, '-', '+') 19 | 20 | @property 21 | def expire(self, ) -> int: 22 | return self._expire 23 | 24 | @expire.setter 25 | def expire(self, value: int): 26 | self._expire = value 27 | 28 | def send(self, msg: Dict[Any, Any]): 29 | self.conn.xadd(self.task_key, msg) 30 | 31 | def delete(self, ): 32 | self.conn.delete(self.task_key) 33 | 34 | def __enter__(self): 35 | return self 36 | 37 | def __exit__(self, exc_type, exc_val, exc_tb): 38 | self.conn.expire(self.task_key, self._expire) 39 | self.close() 40 | 41 | def close(self, ): 42 | self.conn.close() 43 | 44 | 45 | def hosts_to_inventory(hosts: List[InventoryHost], private_data_dir: Path) -> dict: 46 | """ 47 | 转换hosts为inventory格式的数据 48 | :params hosts: 49 | :params private_data_dir:ansible-runner的环境目录,其中保存runner执行过程的所有数据 50 | """ 51 | inventory = {} 52 | logger.debug(hosts) 53 | for host in hosts: 54 | inventory[host.name] = { 55 | "ansible_host": host.ansible_host, 56 | "ansible_port": host.ansible_port, 57 | "ansible_user": host.ansible_user, 58 | } 59 | if host.ansible_password: 60 | inventory[host.name]["ansible_password"] = host.ansible_password 61 | if host.ansible_ssh_private_key: 62 | # 私钥保存到本地文件,并指向对应路径 63 | private_key_file = private_data_dir / f"{host.ansible_host}" 64 | private_key_file.write_text(host.ansible_ssh_private_key) 65 | os.chmod(str(private_key_file), 0o600) 66 | inventory[host.name]["ansible_ssh_private_key_file"] = str(private_key_file) 67 | return {'all': {'hosts': inventory}} 68 | -------------------------------------------------------------------------------- /server/.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Datasource local storage ignored files 5 | /dataSources/ 6 | /dataSources.local.xml 7 | # 基于编辑器的 HTTP 客户端请求 8 | /httpRequests/ 9 | -------------------------------------------------------------------------------- /server/.idea/deployment.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /server/.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /server/.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /server/.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /server/.idea/server.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /server/.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | 服务启动 2 | 3 | uvicorn server.main:app --reload 4 | 5 | 6 | ## 知识点介绍 7 | ### 带参数分页查询 8 | #### Pagination定义 9 | ```python 10 | from typing import Optional, Generic, TypeVar 11 | from pydantic import BaseModel 12 | 13 | T = TypeVar('T') 14 | 15 | 16 | class Pagination(BaseModel, Generic[T]): 17 | search: T 18 | page: Optional[int] = 1 19 | page_size: Optional[int] = 10 20 | model: Optional[str] = 'asc' 21 | ``` 22 | #### ApiSearch定义 23 | ```python 24 | from typing import Dict 25 | from ...models.internal.api import ApiBase 26 | 27 | 28 | class ApiSearch(ApiBase): 29 | type: Dict[str, str] 30 | ``` 31 | #### ApiBase为数据库字段的定义 32 | ```python 33 | class ApiBase(SQLModel): 34 | tags: str 35 | path: str 36 | method: str 37 | summary: str 38 | deprecated: int = Field(default=0) 39 | ``` 40 | #### 解析分页查询请求 41 | ```python 42 | #引入Pagination 43 | from ...schemas.internal.pagination import Pagination 44 | 45 | #定义POST请求,并解析,其中ApiSearch为定义的Pagination.search字段解析 46 | @router.post('/sysapis', summary='获取API列表') 47 | async def get_sysapis(search: Pagination[ApiSearch], 48 | session: Session = Depends(get_session)): 49 | print(search) 50 | total = crud.internal.api.search_total(session, search.search) 51 | print(total) 52 | sys_apis = crud.internal.api.search(session, search) 53 | return { 54 | 'total': total, 55 | 'data': sys_apis 56 | } 57 | ``` 58 | -------------------------------------------------------------------------------- /server/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /server/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 within the migration file 15 | # as well as the filename. 16 | # If specified, requires the python-dateutil library that can be 17 | # installed by adding `alembic[tz]` to the pip requirements 18 | # string value is passed to dateutil.tz.gettz() 19 | # leave blank for localtime 20 | # timezone = 21 | 22 | # max length of characters to apply to the 23 | # "slug" field 24 | # truncate_slug_length = 40 25 | 26 | # set to 'true' to run the environment during 27 | # the 'revision' command, regardless of autogenerate 28 | # revision_environment = false 29 | 30 | # set to 'true' to allow .pyc and .pyo files without 31 | # a source .py file to be detected as revisions in the 32 | # versions/ directory 33 | # sourceless = false 34 | 35 | # version location specification; This defaults 36 | # to alembic/versions. When using multiple version 37 | # directories, initial revisions must be specified with --version-path. 38 | # The path separator used here should be the separator specified by "version_path_separator" 39 | # version_locations = %(here)s/bar:%(here)s/bat:alembic/versions 40 | 41 | # version path separator; As mentioned above, this is the character used to split 42 | # version_locations. Valid values are: 43 | # 44 | # version_path_separator = : 45 | # version_path_separator = ; 46 | # version_path_separator = space 47 | version_path_separator = os # default: use os.pathsep 48 | 49 | # the output encoding used when revision files 50 | # are written from script.py.mako 51 | # output_encoding = utf-8 52 | 53 | sqlalchemy.url = mysql+pymysql://root:123456@192.168.137.129/devops 54 | 55 | 56 | [post_write_hooks] 57 | # post_write_hooks defines scripts or Python functions that are run 58 | # on newly generated revision scripts. See the documentation for further 59 | # detail and examples 60 | 61 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 62 | # hooks = black 63 | # black.type = console_scripts 64 | # black.entrypoint = black 65 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 66 | 67 | # Logging configuration 68 | [loggers] 69 | keys = root,sqlalchemy,alembic 70 | 71 | [handlers] 72 | keys = console 73 | 74 | [formatters] 75 | keys = generic 76 | 77 | [logger_root] 78 | level = WARN 79 | handlers = console 80 | qualname = 81 | 82 | [logger_sqlalchemy] 83 | level = WARN 84 | handlers = 85 | qualname = sqlalchemy.engine 86 | 87 | [logger_alembic] 88 | level = INFO 89 | handlers = 90 | qualname = alembic 91 | 92 | [handler_console] 93 | class = StreamHandler 94 | args = (sys.stderr,) 95 | level = NOTSET 96 | formatter = generic 97 | 98 | [formatter_generic] 99 | format = %(levelname)-5.5s [%(name)s] %(message)s 100 | datefmt = %H:%M:%S 101 | -------------------------------------------------------------------------------- /server/alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /server/alembic/__pycache__/env.cpython-39.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4linuxfun/Fastapi-Admin/5c057ea38a5c8a1c9189ee7090c306979a1dfaf6/server/alembic/__pycache__/env.cpython-39.pyc -------------------------------------------------------------------------------- /server/alembic/env.py: -------------------------------------------------------------------------------- 1 | from logging.config import fileConfig 2 | 3 | from sqlalchemy import engine_from_config 4 | from sqlalchemy import pool 5 | from sqlmodel import SQLModel 6 | 7 | from alembic import context 8 | 9 | # this is the Alembic Config object, which provides 10 | # access to the values within the .ini file in use. 11 | config = context.config 12 | 13 | # Interpret the config file for Python logging. 14 | # This line sets up loggers basically. 15 | if config.config_file_name is not None: 16 | fileConfig(config.config_file_name) 17 | 18 | # add your model's MetaData object here 19 | # for 'autogenerate' support 20 | # from myapp import mymodel 21 | # target_metadata = mymodel.Base.metadata 22 | 23 | from models import * 24 | from models.internal import * 25 | 26 | target_metadata = SQLModel.metadata 27 | 28 | 29 | def include_name(name, type_, parent_names): 30 | """ 31 | 排除某些表 32 | """ 33 | exclude_tables = ['apscheduler_jobs', 'casbin_rule'] 34 | if name in exclude_tables and type_ == 'table': 35 | return False 36 | else: 37 | return True 38 | 39 | 40 | # other values from the config, defined by the needs of env.py, 41 | # can be acquired: 42 | # my_important_option = config.get_main_option("my_important_option") 43 | # ... etc. 44 | 45 | 46 | def run_migrations_offline() -> None: 47 | """Run migrations in 'offline' mode. 48 | 49 | This configures the context with just a URL 50 | and not an Engine, though an Engine is acceptable 51 | here as well. By skipping the Engine creation 52 | we don't even need a DBAPI to be available. 53 | 54 | Calls to context.execute() here emit the given string to the 55 | script output. 56 | 57 | """ 58 | url = config.get_main_option("sqlalchemy.url") 59 | context.configure( 60 | url=url, 61 | target_metadata=target_metadata, 62 | literal_binds=True, 63 | dialect_opts={"paramstyle": "named"}, 64 | include_name=include_name 65 | ) 66 | 67 | with context.begin_transaction(): 68 | context.run_migrations() 69 | 70 | 71 | def run_migrations_online() -> None: 72 | """Run migrations in 'online' mode. 73 | 74 | In this scenario we need to create an Engine 75 | and associate a connection with the context. 76 | 77 | """ 78 | connectable = engine_from_config( 79 | config.get_section(config.config_ini_section), 80 | prefix="sqlalchemy.", 81 | poolclass=pool.NullPool, 82 | ) 83 | 84 | with connectable.connect() as connection: 85 | context.configure( 86 | connection=connection, target_metadata=target_metadata, compare_type=True, include_name=include_name 87 | ) 88 | 89 | with context.begin_transaction(): 90 | context.run_migrations() 91 | 92 | 93 | if context.is_offline_mode(): 94 | run_migrations_offline() 95 | else: 96 | run_migrations_online() 97 | -------------------------------------------------------------------------------- /server/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 | import sqlmodel 11 | ${imports if imports else ""} 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = ${repr(up_revision)} 15 | down_revision = ${repr(down_revision)} 16 | branch_labels = ${repr(branch_labels)} 17 | depends_on = ${repr(depends_on)} 18 | 19 | 20 | def upgrade() -> None: 21 | ${upgrades if upgrades else "pass"} 22 | 23 | 24 | def downgrade() -> None: 25 | ${downgrades if downgrades else "pass"} 26 | -------------------------------------------------------------------------------- /server/alembic/versions/001da528b756_添加数据字典.py: -------------------------------------------------------------------------------- 1 | """添加数据字典 2 | 3 | Revision ID: 001da528b756 4 | Revises: 5 | Create Date: 2022-11-08 10:11:55.957440 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | import sqlmodel 11 | from sqlalchemy.dialects import mysql 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = '001da528b756' 15 | down_revision = None 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade() -> None: 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.create_table('data_dict', 23 | sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), 24 | sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False, comment='字典名称'), 25 | sa.Column('code', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False, comment='字典编号'), 26 | sa.Column('desc', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=True, comment='描述'), 27 | sa.PrimaryKeyConstraint('id') 28 | ) 29 | op.create_table('dict_item', 30 | sa.Column('enable', sa.Boolean(), nullable=True, comment='是否启用'), 31 | sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), 32 | sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False, comment='名称'), 33 | sa.Column('data', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False, comment='数据值'), 34 | sa.Column('desc', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=True, comment='描述'), 35 | sa.Column('sort', sa.Integer(), nullable=True, comment='排序值,越小越靠前'), 36 | sa.Column('dict_id', sa.Integer(), nullable=True), 37 | sa.ForeignKeyConstraint(['dict_id'], ['data_dict.id'], ), 38 | sa.PrimaryKeyConstraint('id') 39 | ) 40 | # ### end Alembic commands ### 41 | 42 | 43 | def downgrade() -> None: 44 | # ### commands auto generated by Alembic - please adjust! ### 45 | op.drop_table('dict_item') 46 | op.drop_table('data_dict') 47 | # ### end Alembic commands ### 48 | -------------------------------------------------------------------------------- /server/alembic/versions/9edc223ae20a_update数据字典.py: -------------------------------------------------------------------------------- 1 | """update数据字典 2 | 3 | Revision ID: 9edc223ae20a 4 | Revises: 001da528b756 5 | Create Date: 2022-11-11 10:57:04.750421 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | import sqlmodel 11 | from sqlalchemy.dialects import mysql 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = '9edc223ae20a' 15 | down_revision = '001da528b756' 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade() -> None: 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.add_column('dict_item', sa.Column('label', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False, comment='名称')) 23 | op.add_column('dict_item', sa.Column('value', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False, comment='数据值')) 24 | op.drop_column('dict_item', 'data') 25 | op.drop_column('dict_item', 'name') 26 | op.alter_column('menu', 'icon', 27 | existing_type=mysql.VARCHAR(collation='utf8mb4_general_ci', length=50), 28 | nullable=True, 29 | existing_comment='Icon图标') 30 | # ### end Alembic commands ### 31 | 32 | 33 | def downgrade() -> None: 34 | # ### commands auto generated by Alembic - please adjust! ### 35 | op.alter_column('menu', 'icon', 36 | existing_type=mysql.VARCHAR(collation='utf8mb4_general_ci', length=50), 37 | nullable=False, 38 | existing_comment='Icon图标') 39 | op.add_column('dict_item', sa.Column('name', mysql.VARCHAR(collation='utf8mb4_general_ci', length=50), nullable=False, comment='名称')) 40 | op.add_column('dict_item', sa.Column('data', mysql.VARCHAR(collation='utf8mb4_general_ci', length=100), nullable=False, comment='数据值')) 41 | op.drop_column('dict_item', 'value') 42 | op.drop_column('dict_item', 'label') 43 | # ### end Alembic commands ### 44 | -------------------------------------------------------------------------------- /server/common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4linuxfun/Fastapi-Admin/5c057ea38a5c8a1c9189ee7090c306979a1dfaf6/server/common/__init__.py -------------------------------------------------------------------------------- /server/common/__pycache__/__init__.cpython-39.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4linuxfun/Fastapi-Admin/5c057ea38a5c8a1c9189ee7090c306979a1dfaf6/server/common/__pycache__/__init__.cpython-39.pyc -------------------------------------------------------------------------------- /server/common/__pycache__/security.cpython-39.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4linuxfun/Fastapi-Admin/5c057ea38a5c8a1c9189ee7090c306979a1dfaf6/server/common/__pycache__/security.cpython-39.pyc -------------------------------------------------------------------------------- /server/common/__pycache__/utils.cpython-39.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4linuxfun/Fastapi-Admin/5c057ea38a5c8a1c9189ee7090c306979a1dfaf6/server/common/__pycache__/utils.cpython-39.pyc -------------------------------------------------------------------------------- /server/common/auth_casbin.py: -------------------------------------------------------------------------------- 1 | from loguru import logger 2 | from fastapi import Request, HTTPException 3 | from ..settings import casbin_enforcer 4 | 5 | 6 | class Authority: 7 | def __init__(self, policy: str): 8 | self.policy = policy 9 | 10 | def __call__(self, request: Request): 11 | if request.state.uid == 1: 12 | # uid为1的都跳过,为admin用户 13 | return True 14 | model, act = self.policy.split(':') 15 | if not casbin_enforcer.enforce(f'uid_{request.state.uid}', model, act): 16 | logger.warning(f'uid_{request.state.uid} {model} {act} 没有权限') 17 | raise HTTPException(status_code=403, detail="没有权限") 18 | -------------------------------------------------------------------------------- /server/common/database.py: -------------------------------------------------------------------------------- 1 | import redis.asyncio as redis 2 | import rpyc 3 | from sqlmodel import create_engine, SQLModel, Session, select 4 | from server.settings import settings, engine 5 | 6 | 7 | def init_db(): 8 | """ 9 | 数据库初始化 10 | :return: 11 | """ 12 | SQLModel.metadata.create_all(engine) 13 | 14 | 15 | # 数据库的dependency,用于每次请求都需要创建db连接时使用 16 | def get_session(): 17 | with Session(engine) as session: 18 | yield session 19 | 20 | 21 | def get_rpyc(): 22 | with rpyc.connect(**settings['rpyc_config']) as conn: 23 | yield conn 24 | 25 | 26 | def get_or_create(session: Session, model, **kwargs): 27 | """ 28 | 检查表中是否存在对象,如果不存在就创建 29 | :param session: 30 | :param model: 表模型 31 | :param kwargs: 表模型参数 32 | :return: 33 | """ 34 | instance = session.query(model).filter_by(**kwargs).first() 35 | if instance: 36 | return instance 37 | else: 38 | instance = model(**kwargs) 39 | session.add(instance) 40 | session.commit() 41 | return instance 42 | 43 | 44 | async def get_redis(): 45 | redis_conn = redis.Redis(**settings['redis']) 46 | try: 47 | yield redis_conn 48 | finally: 49 | await redis_conn.close() 50 | -------------------------------------------------------------------------------- /server/common/dep.py: -------------------------------------------------------------------------------- 1 | from fastapi import Request 2 | 3 | 4 | async def get_uid(request: Request) -> int: 5 | """ 6 | 从request头部中获取uid信息 7 | """ 8 | return request.state.uid 9 | -------------------------------------------------------------------------------- /server/common/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from loguru import logger 3 | 4 | 5 | # 参考自:https://gist.github.com/nkhitrov/a3e31cfcc1b19cba8e1b626276148c49 6 | 7 | 8 | class InterceptHandler(logging.Handler): 9 | def emit(self, record): 10 | # Get corresponding Loguru level if it exists 11 | try: 12 | level = logger.level(record.levelname).name 13 | except ValueError: 14 | level = record.levelno 15 | 16 | # Find caller from where originated the logged message 17 | frame, depth = logging.currentframe(), 2 18 | while frame.f_code.co_filename == logging.__file__: 19 | frame = frame.f_back 20 | depth += 1 21 | 22 | logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) 23 | 24 | 25 | def init_logging(): 26 | # 获取所有uvicorn的日志设定,并重置 27 | loggers = ( 28 | logging.getLogger(name) 29 | for name in logging.root.manager.loggerDict 30 | if name.startswith("uvicorn.") 31 | ) 32 | 33 | for uvicorn_logger in loggers: 34 | # 为了防止日志重复输出 35 | uvicorn_logger.propagate = False 36 | uvicorn_logger.handlers = [InterceptHandler()] 37 | -------------------------------------------------------------------------------- /server/common/response_code.py: -------------------------------------------------------------------------------- 1 | from typing import Generic, TypeVar, List, Union 2 | from pydantic import Field 3 | from pydantic import BaseModel 4 | 5 | T = TypeVar("T") 6 | DATA = TypeVar("DATA") 7 | 8 | 9 | class ApiResponse(BaseModel, Generic[T]): 10 | """ 11 | 自定义返回模型 12 | """ 13 | code: int = Field(default=200, description="返回码") 14 | message: str = Field(default="success", description="消息内容") 15 | data: Union[T, None] = None 16 | 17 | 18 | class SearchResponse(BaseModel, Generic[DATA]): 19 | total: int 20 | data: List[DATA] = [] 21 | -------------------------------------------------------------------------------- /server/common/security.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from loguru import logger 4 | from datetime import datetime, timedelta 5 | from fastapi import Request, WebSocket, HTTPException, status 6 | from fastapi.security import OAuth2PasswordBearer 7 | from fastapi.security.utils import get_authorization_scheme_param 8 | from jose import JWTError, jwt 9 | from passlib.context import CryptContext 10 | from starlette.websockets import WebSocket 11 | 12 | from ..settings import settings 13 | 14 | # to get a string like this run: 15 | # openssl rand -hex 32 16 | 17 | 18 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 19 | 20 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") 21 | 22 | 23 | def auth_check(request: Request = None, ws: WebSocket = None): 24 | """ 25 | 检查是否有token信息,并在request.state中添加uid值 26 | :param request: 27 | :param ws: 28 | :return: 29 | """ 30 | # websocket不需要验证 31 | if ws: 32 | return None 33 | logger.info(f'request url:{request.url} method:{request.method}') 34 | for url in settings['no_verify_url']: 35 | if url == request.url.path.lower(): 36 | logger.debug(f"{request.url.path} 在白名单中,不需要权限验证") 37 | return True 38 | authorization: str = request.headers.get("Authorization") 39 | schema, param = get_authorization_scheme_param(authorization) 40 | if not authorization or schema.lower() != "bearer": 41 | raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authenticated") 42 | 43 | try: 44 | playload = jwt.decode(param, settings['secret_key'], settings['algorithm']) 45 | except jwt.ExpiredSignatureError as e: 46 | raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e)) 47 | except JWTError as e: 48 | raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e)) 49 | 50 | uid = playload.get('uid') 51 | # 在Request对象中设置用户对象,这样在其他地方就能通过request.state.uid获取当前用户id了 52 | request.state.uid = uid 53 | 54 | 55 | def create_access_token(data): 56 | """ 57 | 生成token 58 | :param data: 59 | :return: 60 | """ 61 | expires_delta = timedelta(minutes=settings['access_token_expire_minutes']) 62 | to_encode = data.copy() 63 | if expires_delta: 64 | expire = datetime.utcnow() + expires_delta 65 | else: 66 | expire = datetime.utcnow() + timedelta(minutes=15) 67 | to_encode.update({"exp": expire}) 68 | encoded_jwt = jwt.encode(to_encode, settings['secret_key'], algorithm=settings['algorithm']) 69 | return encoded_jwt 70 | -------------------------------------------------------------------------------- /server/crud/__init__.py: -------------------------------------------------------------------------------- 1 | from .internal.menu import menu 2 | -------------------------------------------------------------------------------- /server/crud/internal/__init__.py: -------------------------------------------------------------------------------- 1 | from .user import user 2 | from .roles import role 3 | from .menu import menu 4 | from .dictonary import data_dict, dict_item 5 | from .host import host, group 6 | from .job import job_logs 7 | from .playbook import playbook 8 | -------------------------------------------------------------------------------- /server/crud/internal/dictonary.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List, Dict 2 | from loguru import logger 3 | from sqlmodel import select, Session, desc, delete 4 | from ...models.internal.dictonary import DataDict, DictItem, DictItemSearch 5 | from ..base import CRUDBase 6 | from ...models.internal import Pagination 7 | 8 | 9 | class CRUDDict(CRUDBase[DataDict]): 10 | pass 11 | 12 | 13 | class CRUDItem(CRUDBase[DictItem]): 14 | def get_items_by_code(self, db: Session, code: str): 15 | dict_id = select(DataDict.id).where(DataDict.code == code).scalar_subquery() 16 | sql = select(self.model).where(self.model.dict_id == dict_id).where(self.model.enable == 1).order_by( 17 | self.model.sort) 18 | return db.exec(sql).all() 19 | 20 | def delete_by_dict_id(self, session: Session, dict_id: int): 21 | """ 22 | 通过dict_id删除对应关联的item 23 | """ 24 | sql = delete(self.model).where(self.model.dict_id == dict_id) 25 | session.exec(sql) 26 | 27 | def search(self, session: Session, search: Pagination[DictItemSearch], filter_type: Optional[Dict[str, str]] = None, 28 | columns: Optional[List] = None, order_col: Optional[str] = 'id'): 29 | """ 30 | 重写search函数,数据字典通过sort进行排序 31 | """ 32 | sql = select(self.model).where(self.model.dict_id == search.search.dict_id) 33 | sql = self._make_search(sql, search.search, filter_type) 34 | if search.model == 'desc': 35 | sql = sql.order_by(desc(self.model.sort)) 36 | else: 37 | sql = sql.order_by(self.model.sort) 38 | sql = sql.limit(search.page_size).offset((search.page - 1) * search.page_size) 39 | logger.debug(sql) 40 | return session.exec(sql).all() 41 | 42 | 43 | data_dict = CRUDDict(DataDict) 44 | dict_item = CRUDItem(DictItem) 45 | -------------------------------------------------------------------------------- /server/crud/internal/host.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, Dict, Union, Type 2 | 3 | import sqlalchemy.orm.exc 4 | from loguru import logger 5 | from pydantic import BaseModel 6 | from sqlmodel import select, Session, func 7 | 8 | from ...models.internal import Pagination 9 | from ...models.internal.host import Host, Group, HostGroup 10 | from ..base import CRUDBase 11 | 12 | 13 | class CRUDHost(CRUDBase[Host]): 14 | def _host_search(self, sql, q: BaseModel): 15 | """ 16 | 构建主机查询语句,返回sql 17 | """ 18 | logger.debug(q) 19 | sql = sql.join(HostGroup, self.model.id == HostGroup.host_id) 20 | if q.name is not None: 21 | sql = sql.where(self.model.name.like('%' + q.name + '%')) 22 | if q.ansible_host is not None: 23 | sql = sql.where(self.model.ansible_host.like('%' + q.ansible_host + '%')) 24 | if q.group_id is not None: 25 | sub_query1 = select(Group.id).where(Group.id == q.group_id) 26 | if q.ancestors is None: 27 | sub_query2 = select(Group.id).where(Group.ancestors.like(q.ancestors + ',' + str(q.group_id) + ',%')) 28 | else: 29 | sub_query2 = select(Group.id).where(Group.ancestors.like(str(q.group_id))) 30 | sql = sql.where(HostGroup.group_id.in_(sub_query1.union_all(sub_query2))) 31 | 32 | sql = sql.group_by(self.model.id).order_by(self.model.id) 33 | return sql 34 | 35 | def search_total(self, session: Session, q: BaseModel, filter_type: Optional[Dict[str, str]] = None): 36 | sql = select(self.model) 37 | sql = self._host_search(sql, q) 38 | sql = sql.subquery() 39 | count_sql = select(func.count(sql.c.id)).select_from(sql) 40 | logger.debug(count_sql) 41 | try: 42 | result = session.exec(count_sql).one() 43 | except sqlalchemy.orm.exc.NoResultFound: 44 | result = 0 45 | logger.debug(result) 46 | return result 47 | 48 | def search(self, session: Session, search: Pagination, filter_type: Optional[Dict[str, str]] = None, 49 | columns: Optional[List] = None, order_col: Optional[str] = 'id'): 50 | """ 51 | 实现主机管理界面的分页查询 52 | """ 53 | sql = select(self.model) 54 | sql = self._host_search(sql, search.search) 55 | sql = sql.limit(search.page_size).offset((search.page - 1) * search.page_size) 56 | logger.debug(sql) 57 | results = session.exec(sql).all() 58 | logger.debug(results) 59 | return results 60 | 61 | 62 | class CRUDGroup(CRUDBase[Group]): 63 | def search_groups(self, session: Session) -> List[Group]: 64 | sql = select(self.model) 65 | return session.exec(sql).all() 66 | 67 | 68 | host = CRUDHost(Host) 69 | group = CRUDGroup(Group) 70 | -------------------------------------------------------------------------------- /server/crud/internal/job.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | from loguru import logger 3 | from sqlmodel import select, Session, delete 4 | from ...models.internal.job import JobLogs 5 | from ..base import CRUDBase 6 | 7 | 8 | class CRUDJobLogs(CRUDBase[JobLogs]): 9 | def get_by_job_id(self, db: Session, job_id: str): 10 | return db.exec(select(self.model).where(self.model.job_id == job_id)).one() 11 | 12 | def delete_by_jobid(self, db: Session, job_id: str): 13 | db.exec(delete(self.model).where(self.model.job_id == job_id)) 14 | db.commit() 15 | 16 | 17 | job_logs = CRUDJobLogs(JobLogs) 18 | -------------------------------------------------------------------------------- /server/crud/internal/menu.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List 2 | from loguru import logger 3 | from sqlmodel import Session, select 4 | from ...models.internal.menu import Menu 5 | from ..base import CRUDBase 6 | 7 | 8 | class CRUDMenu(CRUDBase[Menu]): 9 | def search_menus(self, session: Session) -> List[Menu]: 10 | sql = select(self.model) 11 | sql = sql.order_by(self.model.sort) 12 | return session.exec(sql).all() 13 | 14 | def update(self, session: Session, db_obj, obj_in: Menu): 15 | """ 16 | 菜单的更新,1. 更新基础内容,2. 更新apis. 17 | roles的更新,应该在角色管理里,菜单里不涉及关联角色的更新 18 | :param session: 19 | :param db_obj: 20 | :param obj_in: 21 | :return: 22 | """ 23 | logger.debug(db_obj) 24 | return super(CRUDMenu, self).update(session, db_obj, obj_in) 25 | 26 | def delete(self, session: Session, id: int): 27 | db_obj = self.get(session, id) 28 | session.delete(db_obj) 29 | session.commit() 30 | 31 | def check_has_child(self, session: Session, pid: int) -> bool: 32 | """ 33 | 判断是否有子菜单 34 | :param session: 35 | :param pid: 36 | :return: 37 | """ 38 | sql = select(self.model).where(self.model.parent_id == pid) 39 | return session.exec(sql).first() is not None 40 | 41 | 42 | menu = CRUDMenu(Menu) 43 | -------------------------------------------------------------------------------- /server/crud/internal/playbook.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | from loguru import logger 3 | from sqlmodel import select, Session 4 | from ...models.internal.playbook import Playbook 5 | from ..base import CRUDBase 6 | from ...models.internal.user import UserInfo, UserLogin 7 | from .roles import role 8 | 9 | 10 | class CRUDPlaybook(CRUDBase[Playbook]): 11 | 12 | def query_playbooks(self, session: Session, query: Union[str, None] = None): 13 | """ 14 | 通过名称查询playbook 15 | """ 16 | sql = select(Playbook) 17 | if query: 18 | sql = sql.where(Playbook.name.like(f'%{query}%')) 19 | return session.exec(sql).all() 20 | 21 | 22 | playbook = CRUDPlaybook(Playbook) 23 | -------------------------------------------------------------------------------- /server/crud/internal/roles.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | from loguru import logger 3 | from sqlmodel import select, Session 4 | from ...models.internal import Role, Menu, RoleMenu 5 | from ..base import CRUDBase 6 | from ...settings import casbin_enforcer 7 | 8 | 9 | class CRUDRole(CRUDBase[Role]): 10 | def get_roles_by_id(self, session: Session, roles: List[str]): 11 | return session.exec(select(self.model).where(self.model.id.in_(roles))).all() 12 | 13 | def check_exist(self, session: Session, name: str): 14 | sql = select(self.model).where(self.model.name == name) 15 | return session.exec(sql).one() 16 | 17 | def check_admin(self, session: Session, uid: int) -> bool: 18 | """ 19 | 通过uid,判断此用户是否在admin组中 20 | :param session: 21 | :param uid: 22 | :return: 23 | """ 24 | admin = session.exec(select(self.model).where(self.model.name == 'admin')).one() 25 | admin_users = [user.id for user in admin.users] 26 | if uid in admin_users: 27 | return True 28 | else: 29 | return False 30 | 31 | def get_all_menus(self, session: Session): 32 | return session.exec(select(Menu).where(Menu.enable == 1)).all() 33 | 34 | def get_enable_menus(self, session: Session, id: int) -> List[id]: 35 | if id is not None: 36 | sql = select(self.model).where(self.model.id == id) 37 | role: Role = session.exec(sql).one() 38 | role_menus = [menu.id for menu in role.menus] 39 | else: 40 | role_menus = [] 41 | return role_menus 42 | 43 | def update_menus(self, session: Session, db_obj: Role, menus: List[int]): 44 | """ 45 | 更新角色信息,还涉及到角色关联的menus 46 | :param session: 47 | :param db_obj: 48 | :param menus: 49 | :return: 50 | """ 51 | logger.debug(db_obj.menus) 52 | db_menus = session.exec(select(Menu).where(Menu.id.in_(menus))).all() 53 | db_obj.menus = db_menus 54 | casbin_enforcer.delete_permissions_for_user(f'role_{db_obj.id}') 55 | logger.debug(db_menus) 56 | for menu in db_menus: 57 | if (menu.auth is not None) and menu.auth: 58 | model, act = menu.auth.split(':') 59 | logger.debug(f'增加权限:role_{db_obj.id},{model},{act}') 60 | casbin_enforcer.add_permission_for_user(f'role_{db_obj.id}', model, act) 61 | session.add(db_obj) 62 | session.commit() 63 | session.refresh(db_obj) 64 | 65 | 66 | role = CRUDRole(Role) 67 | -------------------------------------------------------------------------------- /server/crud/internal/user.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | from loguru import logger 3 | from sqlmodel import select, Session 4 | from ...models.internal.user import User 5 | from ..base import CRUDBase 6 | from ...models.internal.user import UserInfo, UserLogin 7 | from .roles import role 8 | 9 | 10 | class CRUDUser(CRUDBase[User]): 11 | def login(self, session: Session, login_form: UserLogin) -> User: 12 | sql = select(self.model).where(self.model.name == login_form.username, 13 | self.model.password == login_form.password, 14 | self.model.enable == 1) 15 | return session.exec(sql).one() 16 | 17 | def check_name(self, session: Session, name: str): 18 | sql = select(self.model).where(self.model.name == name) 19 | return session.exec(sql).one() 20 | 21 | def insert(self, session: Session, user_info: UserInfo) -> User: 22 | updated_user = User(**user_info.user.model_dump()) 23 | user_roles = role.get_roles_by_id(session, user_info.roles) 24 | updated_user.roles = user_roles 25 | return super(CRUDUser, self).insert(session, updated_user) 26 | 27 | def update(self, session: Session, uid: int, user_info: UserInfo): 28 | db_obj = self.get(session, uid) 29 | updated_user = user_info.user 30 | db_obj = super(CRUDUser, self).update(session, db_obj, updated_user) 31 | user_roles = role.get_roles_by_id(session, user_info.roles) 32 | db_obj.roles = user_roles 33 | logger.debug('update:') 34 | logger.debug(db_obj) 35 | session.add(db_obj) 36 | session.commit() 37 | session.refresh(db_obj) 38 | return db_obj 39 | 40 | def update_passwd(self, session: Session, uid: int, passwd: str): 41 | db_obj = self.get(session, uid) 42 | db_obj.password = passwd 43 | session.add(db_obj) 44 | session.commit() 45 | 46 | 47 | user = CRUDUser(User) 48 | -------------------------------------------------------------------------------- /server/data.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | from typing import List, Optional, Any 3 | 4 | 5 | class Common(BaseModel): 6 | code: int 7 | message: str 8 | data: Any 9 | 10 | 11 | class UserName(BaseModel): 12 | username: str 13 | disabled: bool 14 | 15 | 16 | class UserRoles(UserName): 17 | roles: List[str] 18 | 19 | 20 | class UserInfo(UserRoles): 21 | email: Optional[str] 22 | avater: Optional[str] 23 | -------------------------------------------------------------------------------- /server/db_init.py: -------------------------------------------------------------------------------- 1 | from sqlmodel import Session 2 | from .db import init_db, engine 3 | from .models.role import Role 4 | from .models.user import User 5 | 6 | 7 | def main(): 8 | init_db() 9 | with Session(engine) as session: 10 | admin_role = Role(name='admin', description='Admin管理员组', enable=1) 11 | admin = User(name='admin', enable=1, password='1111111') 12 | admin.roles.append(admin_role) 13 | session.add(admin) 14 | session.commit() 15 | 16 | 17 | if __name__ == '__main__': 18 | main() 19 | -------------------------------------------------------------------------------- /server/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, Depends 2 | from loguru import logger 3 | from .common.log import init_logging 4 | from .routers.internal import login, user, menu, roles, dictonary, job, host, playbook 5 | from .common.security import auth_check 6 | 7 | init_logging() 8 | 9 | app = FastAPI(dependencies=[Depends(auth_check)]) 10 | 11 | app.include_router(login.router, tags=['用户登录']) 12 | app.include_router(user.router, tags=['用户管理']) 13 | app.include_router(menu.router, tags=['菜单管理']) 14 | app.include_router(roles.router, tags=['角色管理']) 15 | app.include_router(dictonary.router, tags=['数据字典']) 16 | app.include_router(job.router, tags=['任务管理']) 17 | app.include_router(host.router, tags=['主机管理']) 18 | app.include_router(playbook.router, tags=['playbook管理']) 19 | 20 | 21 | @app.on_event("startup") 22 | async def startup(): 23 | """ 24 | 在此时添加openapi数据的获取,导入api表,判断有没有新接口信息需要添加进去 25 | :return: 26 | """ 27 | logger.debug('服务启动后执行服务') 28 | 29 | 30 | @app.on_event("shutdown") 31 | def shutdown(): 32 | logger.debug('关闭服务') 33 | -------------------------------------------------------------------------------- /server/model.conf: -------------------------------------------------------------------------------- 1 | [request_definition] 2 | r = sub, obj, act 3 | 4 | [policy_definition] 5 | p = sub, obj, act 6 | 7 | [role_definition] 8 | g = _, _ 9 | 10 | [policy_effect] 11 | e = some(where (p.eft == allow)) 12 | 13 | [matchers] 14 | # 指定admin组为1,则直接可以访问全部 15 | m = g(r.sub, p.sub) && keyMatch3(r.obj,p.obj) && regexMatch(r.act, p.act) -------------------------------------------------------------------------------- /server/models/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /server/models/internal/__init__.py: -------------------------------------------------------------------------------- 1 | from .user import User 2 | from .menu import Menu 3 | from .role import Role, RoleMenu 4 | from .dictonary import DataDict, DictItem 5 | from .host import Host, Group 6 | from .job import JobLogs 7 | from .playbook import Playbook 8 | from pydantic import BaseModel 9 | from typing import TypeVar, Generic, Optional, Union 10 | 11 | T = TypeVar('T') 12 | 13 | 14 | class Pagination(BaseModel, Generic[T]): 15 | search: Union[T, None] 16 | page: Optional[int] = 1 17 | page_size: Optional[int] = 10 18 | model: Optional[str] = 'asc' 19 | -------------------------------------------------------------------------------- /server/models/internal/dictonary.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List, Union 2 | from sqlmodel import SQLModel, Field, Relationship, Column, Boolean, Integer, String 3 | 4 | 5 | class DataDictBase(SQLModel): 6 | name: str = Field(sa_column=Column(String(50), nullable=False, comment='字典名称')) 7 | code: str = Field(sa_column=Column(String(100), nullable=False, comment='字典编号')) 8 | desc: Optional[str] = Field(sa_column=Column(String(100), default=None, comment='描述')) 9 | 10 | 11 | class DataDict(DataDictBase, table=True): 12 | __tablename__ = 'data_dict' 13 | id: Optional[int] = Field(sa_column=Column('id', Integer, primary_key=True, autoincrement=True)) 14 | dict_items: List["DictItem"] = Relationship(back_populates="dict") 15 | 16 | 17 | class DataDictSearch(SQLModel): 18 | name: Optional[str] 19 | code: Optional[str] 20 | 21 | 22 | class DictBase(SQLModel): 23 | label: str = Field(sa_column=Column(String(50), nullable=False, comment='名称')) 24 | value: str = Field(sa_column=Column(String(100), nullable=False, comment='数据值')) 25 | desc: Optional[str] = Field(sa_column=Column(String(100), default=None, comment='描述')) 26 | sort: Optional[int] = Field(sa_column=Column(Integer, default=1, comment='排序值,越小越靠前')) 27 | enable: bool = Field(sa_column=Column(Boolean, default=True, comment='是否启用')) 28 | dict_id: Optional[int] = Field(foreign_key="data_dict.id") 29 | 30 | 31 | class DictItem(DictBase, table=True): 32 | __tablename__ = 'dict_item' 33 | 34 | id: Optional[int] = Field(sa_column=Column('id', Integer, primary_key=True, autoincrement=True)) 35 | dict: Optional[DataDict] = Relationship(back_populates='dict_items') 36 | 37 | 38 | class DictRead(DictBase): 39 | id: int 40 | 41 | 42 | class DictUpdate(DictBase): 43 | id: Optional[int] 44 | 45 | 46 | class DictItemSearch(SQLModel): 47 | dict_id: int 48 | name: Union[str, None] = None 49 | data: Union[str, None] = None 50 | enable: Union[bool, None] = None 51 | 52 | 53 | class DictItemSearchFilter(SQLModel): 54 | dict_id: str 55 | label: str 56 | value: str 57 | enable: str 58 | -------------------------------------------------------------------------------- /server/models/internal/host.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union 2 | from sqlmodel import SQLModel, Field, Column, Text, Integer, String, Relationship 3 | 4 | 5 | class HostGroup(SQLModel, table=True): 6 | """ 7 | 通过中间表实现:主机-组的对应关系 8 | """ 9 | __tablename__ = 'host_group' 10 | host_id: int = Field(foreign_key="host.id", primary_key=True, nullable=False) 11 | group_id: int = Field(foreign_key="group.id", primary_key=True, nullable=False) 12 | 13 | 14 | class Host(SQLModel, table=True): 15 | __tablename__ = 'host' 16 | id: int = Field(sa_column=Column('id', Integer, primary_key=True, autoincrement=True)) 17 | name: str = Field(sa_column=Column(String(50), unique=True, nullable=False, comment='主机名')) 18 | ansible_host: str = Field(sa_column=Column(String(50), nullable=False, comment='主机地址')) 19 | ansible_port: int = Field(sa_column=Column(Integer, default=22, nullable=False, comment='ssh端口')) 20 | ansible_user: str = Field(sa_column=Column(String(50), nullable=True, default=None, comment='ssh用户名')) 21 | ansible_password: str = Field(sa_column=Column(String(50), default=None, comment='ssh密码')) 22 | ansible_ssh_private_key: str = Field( 23 | sa_column=Column('ansible_ssh_private_key', Text, default=None, nullable=True, comment='私钥')) 24 | desc: str = Field(sa_column=Column(String(100), default=None, nullable=True, comment='描述')) 25 | groups: List['Group'] = Relationship(back_populates='hosts', link_model=HostGroup) 26 | 27 | 28 | class Group(SQLModel, table=True): 29 | __tablename__ = 'group' 30 | id: int = Field(sa_column=Column('id', Integer, primary_key=True, autoincrement=True)) 31 | name: str = Field(sa_column=Column(String(50), nullable=False, comment='组名')) 32 | parent_id: int = Field(sa_column=Column(Integer, default=None, nullable=True, comment='父ID')) 33 | ancestors: Union[str, None] = Field( 34 | sa_column=Column(String(100), default=None, nullable=True, comment='祖先ID列表')) 35 | hosts: List['Host'] = Relationship(back_populates='groups', link_model=HostGroup) 36 | 37 | 38 | class GroupWithChild(SQLModel): 39 | id: int 40 | name: str 41 | parent_id: Union[int, None] 42 | ancestors: Union[str, None] 43 | children: List['GroupWithChild'] = [] 44 | 45 | 46 | class CreateHost(SQLModel): 47 | id: Union[int, None] = None 48 | name: str 49 | groups: List[int] 50 | ansible_host: str 51 | ansible_port: int 52 | ansible_user: str 53 | ansible_password: Union[str, None] = None 54 | ansible_ssh_private_key: Union[str, None] 55 | desc: Union[str, None] 56 | 57 | 58 | class HostWithIp(SQLModel): 59 | name: Union[str, None] = None 60 | ansible_host: Union[str, None] = None 61 | group_id: Union[int, None] = None 62 | ancestors: Union[str, None] 63 | -------------------------------------------------------------------------------- /server/models/internal/job.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Union, List, Tuple, Dict, Any, Optional 3 | from sqlmodel import SQLModel, Field, Column, Relationship, Integer, String, LargeBinary, JSON, Unicode 4 | from sqlalchemy.dialects import mysql 5 | from datetime import datetime 6 | from pydantic import BaseModel 7 | 8 | 9 | class CronJobArgs(BaseModel): 10 | cron: str 11 | start_date: Optional[str] 12 | end_date: Optional[str] 13 | 14 | 15 | class TriggerEnum(str, Enum): 16 | date = 'date' 17 | cron = 'cron' 18 | 19 | 20 | class TriggerArgs(BaseModel): 21 | run_date: Optional[str] = Field(default=None, description="date类型触发器设定执行时间,None为立即执行") 22 | cron: Optional[str] = Field(default=None, description="cron类型触发器参数") 23 | start_date: Optional[str] = Field(default=None, description="cron类型触发器开始时间") 24 | end_date: Optional[str] = Field(default=None, description="cron类型触发器结束时间") 25 | 26 | 27 | class AnsibleArgs(BaseModel): 28 | module: Union[str, None] = Field(default=None, description="ansible模块") 29 | module_args: Union[str, None] = Field(default=None, description="ansible模块参数") 30 | playbook: Union[int, None] = Field(default=None, description="ansible playbook") 31 | 32 | 33 | class JobAdd(BaseModel): 34 | id: Optional[str] = Field(default=None, description="任务ID") 35 | name: Union[str, None] = Field(description="任务名称") 36 | trigger: TriggerEnum = Field(description="触发器类型") 37 | trigger_args: TriggerArgs = Field(description="触发器") 38 | targets: List[int] = Field(description="执行任务的主机") 39 | ansible_args: AnsibleArgs = Field(description="ansible任务参数") 40 | 41 | 42 | # class Job(SQLModel, table=True): 43 | # """ 44 | # 此表同步apschedule中的建表语句,如果没有,则apscheduler会自动创建对应表 45 | # """ 46 | # __tablename__ = 'jobs' 47 | # id: str = Field(sa_column=Column('id', autoincrement=True, primary_key=True)) 48 | # name: str = Field(sa_column=Column('name', String(50), nullable=False, unique=True)) 49 | # create_time: float = Field(sa_column=Column('create_time', mysql.DOUBLE, index=True)) 50 | # update_time: float = Field(sa_column=Column('update_time', mysql.DOUBLE, index=True)) 51 | # job_id: str = Field(sa_column=Column('job_id', Unicode(255))) 52 | # job_logs: List["JobLog"] = Relationship(back_populates="job") 53 | # 54 | # 55 | class JobLogs(SQLModel, table=True): 56 | """ 57 | 任务执行日志相关表 58 | """ 59 | __tablename__ = 'job_logs' 60 | id: Optional[int] = Field(sa_column=Column('id', Integer, primary_key=True, autoincrement=True)) 61 | job_id: Optional[str] = Field(sa_column=Column('job_id', String(191), index=True)) 62 | start_time: datetime = Field(default_factory=datetime.now, sa_column_kwargs={'comment': '任务开始时间'}) 63 | end_time: datetime = Field(default=datetime.now, sa_column_kwargs={'comment': '任务结束时间'}) 64 | log: str = Field(sa_column=Column(mysql.TEXT, comment='执行日志')) 65 | stats: str = Field(sa_column=Column(mysql.TEXT, comment='任务返回状态')) 66 | type: int = Field(sa_column=Column('type', mysql.TINYINT, comment='任务类型,0:cron,1:date')) 67 | 68 | 69 | class JobSearch(SQLModel): 70 | job_name: Optional[str] = None 71 | job_trigger: Optional[str] = None 72 | 73 | 74 | class JobLogSearch(BaseModel): 75 | job_id: Union[str, None] = None 76 | type: Union[int, None] = None 77 | -------------------------------------------------------------------------------- /server/models/internal/menu.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List 2 | from sqlmodel import SQLModel, Field, Relationship, Column, Boolean, Integer, String, Float 3 | from .relationships import RoleMenu 4 | 5 | 6 | class MenuBase(SQLModel): 7 | id: Optional[int] = Field(sa_column=Column('id', Integer, primary_key=True, autoincrement=True)) 8 | name: str = Field(sa_column=Column(String(20), nullable=False, comment='菜单名')) 9 | icon: Optional[str] = Field(default=None, sa_column=Column(String(50), default=None, comment='Icon图标')) 10 | path: Optional[str] = Field(sa_column=Column(String(100), default=None, comment='路径')) 11 | component: Optional[str] = Field(sa_column=Column(String(50), default=None, comment='组件')) 12 | auth: Optional[str] = Field(sa_column=Column(String(50), default=None, comment='授权标识')) 13 | type: str = Field(sa_column=Column(String(10), nullable=False, comment='类型')) 14 | parent_id: Optional[int] = Field(sa_column=Column(Integer, default=None, comment='父级ID')) 15 | sort: Optional[float] = Field(default=None, sa_column=Column(Float, default=None, comment='菜单排序')) 16 | enable: bool = Field(sa_column=Column(Boolean, default=True, comment='启用')) 17 | 18 | 19 | class Menu(MenuBase, table=True): 20 | roles: List["Role"] = Relationship(back_populates="menus", link_model=RoleMenu) 21 | # apis: List['Api'] = Relationship(back_populates="menus", link_model=MenuApi) 22 | 23 | 24 | class MenusWithChild(MenuBase): 25 | children: List['MenusWithChild'] = [] 26 | 27 | 28 | # 底部导入,且延迟注释 29 | from .role import Role 30 | 31 | MenusWithChild.model_rebuild() 32 | -------------------------------------------------------------------------------- /server/models/internal/playbook.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union 2 | from sqlmodel import SQLModel, Field, Column, Integer, String, TEXT 3 | 4 | 5 | class Playbook(SQLModel, table=True): 6 | id: Union[int, None] = Field(default=None, sa_column=Column('id', Integer, primary_key=True, autoincrement=True)) 7 | name: str = Field(sa_column=Column(String(50), nullable=False, comment='playbook名称')) 8 | playbook: str = Field(sa_column=Column(TEXT, nullable=False, comment='playbook文件')) 9 | desc: Union[str, None] = Field(default=None, 10 | sa_column=Column(String(255), default=None, nullable=True, comment='描述')) 11 | 12 | 13 | class PlaybookSearch(SQLModel): 14 | name: Union[str, None] = None 15 | -------------------------------------------------------------------------------- /server/models/internal/relationships.py: -------------------------------------------------------------------------------- 1 | from sqlmodel import SQLModel, Field, Unicode 2 | 3 | 4 | # 这些是权限验证的基础表,单独放置 5 | class RoleMenu(SQLModel, table=True): 6 | __tablename__ = "role_menu" 7 | role_id: int = Field(foreign_key="roles.id", primary_key=True) 8 | menu_id: int = Field(foreign_key="menu.id", primary_key=True) 9 | 10 | 11 | class UserRole(SQLModel, table=True): 12 | __tablename__ = 'user_roles' 13 | 14 | user_id: int = Field(foreign_key="user.id", primary_key=True) 15 | role_id: int = Field(foreign_key="roles.id", primary_key=True) 16 | 17 | 18 | # class UserJob(SQLModel, table=True): 19 | # """ 20 | # 通过中间表实现:用户-任务的对应关系 21 | # """ 22 | # __tablename__ = 'user_job' 23 | # user_id: int = Field(foreign_key="user.id", primary_key=True, nullable=False) 24 | # job_id: str = Field(Unicode(191), foreign_key="apscheduler_jobs.id", primary_key=True, nullable=False) 25 | -------------------------------------------------------------------------------- /server/models/internal/role.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List, TYPE_CHECKING, Union 2 | from sqlmodel import SQLModel, Field, Relationship, Column, Integer, Boolean, String 3 | from .relationships import RoleMenu, UserRole 4 | 5 | if TYPE_CHECKING: 6 | from .user import User 7 | from .menu import Menu 8 | 9 | 10 | class Role(SQLModel, table=True): 11 | __tablename__ = "roles" 12 | id: Optional[int] = Field(sa_column=Column('id', Integer, primary_key=True, autoincrement=True)) 13 | name: Union[str, None] = Field(sa_column=Column(String(20), nullable=False, unique=True, comment='角色名')) 14 | description: Union[str, None] = Field(sa_column=Column(String(100), default=None, comment='描述')) 15 | enable: Union[bool, None] = Field(sa_column=Column(Boolean, default=True, comment='启用')) 16 | menus: List["Menu"] = Relationship(back_populates="roles", link_model=RoleMenu) 17 | users: List["User"] = Relationship(back_populates="roles", link_model=UserRole) 18 | 19 | 20 | class RoleBase(SQLModel): 21 | name: Union[str, None] = Field(max_length=20, nullable=False) 22 | description: Union[str, None] = Field(max_length=100, default=None) 23 | enable: Union[bool, None] = Field(default=True) 24 | 25 | 26 | class RoleInsert(RoleBase): 27 | menus: List[int] 28 | 29 | 30 | class RoleUpdate(RoleInsert): 31 | id: int 32 | 33 | 34 | class RoleWithMenus(RoleBase): 35 | id: int 36 | menus: List['Menu'] = [] 37 | 38 | 39 | from .menu import Menu 40 | 41 | RoleWithMenus.model_rebuild() 42 | -------------------------------------------------------------------------------- /server/models/internal/user.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List, Union, Literal, TYPE_CHECKING 2 | from pydantic import BaseModel 3 | from sqlmodel import SQLModel, Field, Relationship, Column, Integer, Boolean, String 4 | from .relationships import UserRole 5 | from .role import Role 6 | 7 | if TYPE_CHECKING: 8 | from .role import Role 9 | from .job import Job 10 | 11 | 12 | class User(SQLModel, table=True): 13 | __tablename__ = 'user' 14 | id: Optional[int] = Field(sa_column=Column('id', Integer, primary_key=True, autoincrement=True)) 15 | name: Union[str, None] = Field(sa_column=Column(String(20), nullable=False, unique=True, comment='用户名')) 16 | enable: Union[bool, None] = Field(sa_column=Column(Boolean, default=True, comment='可用')) 17 | avatar: Union[str, None] = Field(sa_column=Column(String(100), default=None, comment='头像')) 18 | email: Union[str, None] = Field(sa_column=Column(String(20), default=None, comment='邮箱')) 19 | password: Optional[str] = Field(sa_column=Column(String(50), comment='密码')) 20 | roles: List['Role'] = Relationship(back_populates="users", link_model=UserRole) 21 | 22 | 23 | class UserWithOutPasswd(SQLModel): 24 | name: Union[str, None] = Field(max_length=20, nullable=False) 25 | enable: Union[bool, None] = Field(default=True) 26 | avatar: Union[str, None] = Field(max_length=100, default=None) 27 | email: Union[str, None] = Field(max_length=20, default=None) 28 | 29 | 30 | class UserBase(UserWithOutPasswd): 31 | password: Optional[str] = Field(sa_column=Column(String(50), comment='密码')) 32 | # age: Optional[int] = Field(..., title='年龄', lt=120) 33 | 34 | 35 | class UserRead(UserWithOutPasswd): 36 | # get请求时返回的数据模型,response_model使用模型 37 | id: int 38 | 39 | 40 | class UserReadWithRoles(UserRead): 41 | # 包含relationship的数据模型 42 | roles: List['Role'] = [] 43 | 44 | 45 | class UserRoles(SQLModel): 46 | roles: List['Role'] = [] 47 | enable: List[str] = [] 48 | 49 | 50 | class UserCreateWithRoles(SQLModel): 51 | # POST请求时,传递过来的模型 52 | user: UserBase 53 | roles: List[int] 54 | 55 | class Config: 56 | title = '新建用户' 57 | 58 | 59 | class UserUpdatePassword(SQLModel): 60 | id: int 61 | password: str 62 | 63 | 64 | class UserUpdateWithRoles(SQLModel): 65 | # PUT请求时,传递过来的数据模型 66 | user: UserWithOutPasswd 67 | roles: List[int] 68 | 69 | 70 | class UserLogin(SQLModel): 71 | username: str 72 | password: str 73 | 74 | 75 | class LoginResponse(SQLModel): 76 | uid: int 77 | token: str 78 | 79 | 80 | class UserInfo(BaseModel): 81 | user: User 82 | roles: List[str] 83 | -------------------------------------------------------------------------------- /server/requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4linuxfun/Fastapi-Admin/5c057ea38a5c8a1c9189ee7090c306979a1dfaf6/server/requirements.txt -------------------------------------------------------------------------------- /server/routers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4linuxfun/Fastapi-Admin/5c057ea38a5c8a1c9189ee7090c306979a1dfaf6/server/routers/__init__.py -------------------------------------------------------------------------------- /server/routers/__pycache__/__init__.cpython-39.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4linuxfun/Fastapi-Admin/5c057ea38a5c8a1c9189ee7090c306979a1dfaf6/server/routers/__pycache__/__init__.cpython-39.pyc -------------------------------------------------------------------------------- /server/routers/__pycache__/menu.cpython-39.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4linuxfun/Fastapi-Admin/5c057ea38a5c8a1c9189ee7090c306979a1dfaf6/server/routers/__pycache__/menu.cpython-39.pyc -------------------------------------------------------------------------------- /server/routers/__pycache__/roles.cpython-39.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4linuxfun/Fastapi-Admin/5c057ea38a5c8a1c9189ee7090c306979a1dfaf6/server/routers/__pycache__/roles.cpython-39.pyc -------------------------------------------------------------------------------- /server/routers/__pycache__/user.cpython-39.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4linuxfun/Fastapi-Admin/5c057ea38a5c8a1c9189ee7090c306979a1dfaf6/server/routers/__pycache__/user.cpython-39.pyc -------------------------------------------------------------------------------- /server/routers/internal/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4linuxfun/Fastapi-Admin/5c057ea38a5c8a1c9189ee7090c306979a1dfaf6/server/routers/internal/__init__.py -------------------------------------------------------------------------------- /server/routers/internal/dictonary.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List 2 | from fastapi import APIRouter, Depends, status, HTTPException 3 | from sqlmodel import Session 4 | from ...models.internal import Pagination 5 | from ...models.internal.dictonary import DataDict, DictRead, DictUpdate, DictItem, DataDictSearch, \ 6 | DictItemSearch, DictItemSearchFilter 7 | from ...common.response_code import ApiResponse, SearchResponse 8 | from ...common.database import get_session 9 | from ... import crud 10 | 11 | router = APIRouter(prefix='/api') 12 | 13 | 14 | @router.post('/dict/item/search', summary="字典列表查询", response_model=ApiResponse[SearchResponse[DictRead]]) 15 | async def search_items(search: Pagination[DictItemSearch], session: Session = Depends(get_session)): 16 | filter_type = DictItemSearchFilter(dict_id='eq', label='like', enable='eq', value='like') 17 | total = crud.internal.dict_item.search_total(session, search.search, filter_type.model_dump()) 18 | items: List[DictRead] = crud.internal.dict_item.search(session, search, filter_type.model_dump()) 19 | item_list = [DictRead.from_orm(item) for item in items] 20 | return ApiResponse( 21 | data={ 22 | 'total': total, 23 | 'data': item_list 24 | } 25 | 26 | ) 27 | 28 | 29 | @router.post('/dict/item', summary="添加字典字段", response_model=ApiResponse[DictRead]) 30 | async def add_dict_item(dict_item: DictUpdate, session: Session = Depends(get_session)): 31 | new_item = crud.internal.dict_item.insert(session, DictItem(**dict_item.model_dump())) 32 | return ApiResponse( 33 | data=DictRead.from_orm(new_item) 34 | ) 35 | 36 | 37 | @router.put('/dict/item', summary="更新字典元素", response_model=ApiResponse) 38 | async def update_dict_item(dict_item: DictUpdate, session: Session = Depends(get_session)): 39 | db_obj = crud.internal.dict_item.get(session, dict_item.id) 40 | crud.internal.dict_item.update(session, db_obj, dict_item) 41 | return ApiResponse() 42 | 43 | 44 | @router.delete('/dict/item/{item_id}', summary="删除字典元素", ) 45 | async def del_dict_item(item_id: int, session: Session = Depends(get_session)): 46 | crud.internal.dict_item.delete(session, item_id) 47 | return ApiResponse() 48 | 49 | 50 | @router.get("/dict/{dict_code}", summary="获取数据字典", response_model=ApiResponse[List[DictRead]], 51 | response_model_exclude={'data': {'__all__': {'desc', 'sort', 'enable'}}}) 52 | async def get_dict(dict_code: str, session: Session = Depends(get_session)): 53 | dict_items: List[DictItem] = crud.internal.dict_item.get_items_by_code(session, dict_code) 54 | if dict_items: 55 | return ApiResponse( 56 | data=[DictRead.from_orm(item) for item in dict_items] 57 | ) 58 | else: 59 | return ApiResponse( 60 | code=404, 61 | message=f"无效的数据字典:{dict_code}" 62 | ) 63 | 64 | 65 | @router.delete("/dict/{dict_id}", summary="删除数据字典") 66 | async def del_dict(dict_id: int, session: Session = Depends(get_session)): 67 | try: 68 | crud.internal.dict_item.delete_by_dict_id(session, dict_id) 69 | crud.internal.data_dict.delete(session, dict_id) 70 | return ApiResponse() 71 | except Exception as e: 72 | return ApiResponse( 73 | code=500, 74 | message=f"删除数据字典失败:{e}" 75 | ) 76 | 77 | 78 | @router.post("/dict", summary="新建数据字典", response_model=ApiResponse[DataDict]) 79 | async def add_dict(data_dict: DataDict, session: Session = Depends(get_session)): 80 | obj = crud.internal.data_dict.insert(session, data_dict) 81 | return ApiResponse( 82 | data=obj 83 | ) 84 | 85 | 86 | @router.put("/dict", summary="更新数据字典", response_model=ApiResponse[DataDict]) 87 | async def add_dict(data_dict: DataDict, session: Session = Depends(get_session)): 88 | db_obj = crud.internal.data_dict.get(session, data_dict.id) 89 | obj = crud.internal.data_dict.update(session, db_obj, data_dict) 90 | return ApiResponse( 91 | data=obj 92 | ) 93 | 94 | 95 | @router.post('/dict/search', 96 | summary="查询数据字典") 97 | async def get_dicts(search: Pagination[DataDictSearch], session: Session = Depends(get_session)): 98 | filter_type = DataDictSearch(name='like', code='like') 99 | total = crud.internal.data_dict.search_total(session, search.search, filter_type.model_dump()) 100 | dicts: List[DataDict] = crud.internal.data_dict.search(session, search, filter_type.model_dump()) 101 | return ApiResponse( 102 | data={ 103 | 'total': total, 104 | 'data': dicts 105 | } 106 | ) 107 | -------------------------------------------------------------------------------- /server/routers/internal/login.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from loguru import logger 3 | from fastapi import APIRouter, Depends, Request, status 4 | from ...common.database import get_session 5 | from sqlmodel import Session, select 6 | from sqlalchemy.exc import NoResultFound 7 | from ...common.response_code import ApiResponse 8 | from ...common.security import create_access_token 9 | from ...models.internal import User, Menu 10 | from ...common.dep import get_uid 11 | from ...models.internal.user import UserLogin, LoginResponse 12 | from ... import crud 13 | from ...common.utils import menu_convert 14 | 15 | router = APIRouter(prefix='/api') 16 | 17 | 18 | @router.post('/login', summary="登录验证", response_model=ApiResponse[LoginResponse]) 19 | async def login(login_form: UserLogin, session: Session = Depends(get_session)): 20 | """ 21 | 处理登录请求,返回{token:xxxxx},判断用户密码是否正确 22 | :param login_form: 23 | :param session 24 | :return: 25 | """ 26 | try: 27 | user = crud.internal.user.login(session, login_form) 28 | except NoResultFound: 29 | return ApiResponse( 30 | code=status.HTTP_400_BAD_REQUEST, 31 | message='用户名或密码错误', 32 | ) 33 | user_roles = [] 34 | for role in user.roles: 35 | if role.enable == 1: 36 | user_roles.append(role.id) 37 | # 把roles封装再token里,每次只需要depends检查对应的roles是否有权限即可 38 | access_token = create_access_token( 39 | data={"uid": user.id} 40 | ) 41 | return ApiResponse( 42 | data={"uid": user.id, 43 | "token": access_token} 44 | ) 45 | 46 | 47 | @router.get('/permission', summary='获取权限') 48 | async def get_permission(uid: int = Depends(get_uid), session: Session = Depends(get_session)): 49 | """ 50 | 用户权限请求,返回拥有权限的菜单列表,前端根据返回的菜单列表信息,合成菜单项 51 | :param request: 52 | :param session: 53 | :param token: 54 | :return: 55 | """ 56 | logger.debug(f"uid is:{uid}") 57 | user: User = crud.internal.user.get(session, uid) 58 | logger.debug(user.roles) 59 | user_menus = [] 60 | # admin组用户获取所有菜单列表 61 | if uid == 1 or crud.internal.role.check_admin(session, uid): 62 | menu_list = session.exec(select(Menu).where(Menu.type != 'btn', Menu.enable == 1).order_by(Menu.sort)).all() 63 | btn_list = session.exec( 64 | select(Menu.auth).where(Menu.type == 'btn', Menu.enable == 1).where(Menu.auth.is_not(None))).all() 65 | else: 66 | for role in user.roles: 67 | user_menus.extend([menu.id for menu in role.menus]) 68 | menu_list = session.exec( 69 | select(Menu).where(Menu.id.in_(set(user_menus))).where(Menu.type != 'btn').where(Menu.enable == 1).order_by( 70 | Menu.sort)).all() 71 | btn_list = session.exec(select(Menu.auth).where(Menu.id.in_(set(user_menus))).where(Menu.type == 'btn').where( 72 | Menu.enable == 1)).all() 73 | user_menus = menu_convert(menu_list) 74 | 75 | logger.debug(f"user menus:{user_menus}") 76 | return ApiResponse( 77 | data={ 78 | 'menus': user_menus, 79 | 'btns': btn_list 80 | } 81 | ) 82 | -------------------------------------------------------------------------------- /server/routers/internal/menu.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List 2 | from fastapi import APIRouter, Depends, status, HTTPException 3 | from sqlmodel import Session 4 | from ...common.response_code import ApiResponse 5 | from ...common.database import get_session 6 | from ...common.auth_casbin import Authority 7 | from ...models.internal.menu import MenuBase, Menu, MenusWithChild 8 | from ...common import utils 9 | from ... import crud 10 | from ...settings import casbin_enforcer 11 | 12 | router = APIRouter(prefix='/api') 13 | 14 | 15 | @router.get('/menus', summary="列出菜单", response_model=ApiResponse[List[MenusWithChild]]) 16 | async def get_all_menu(session: Session = Depends(get_session)): 17 | # 复用crud.get_menu_list,默认role为admin就是返回所有的菜单列表 18 | menu_list: List[Menu] = crud.menu.search_menus(session) 19 | user_menus = utils.menu_convert(menu_list) 20 | return ApiResponse( 21 | data=user_menus 22 | ) 23 | 24 | 25 | @router.post('/menus', summary="新建菜单", response_model=ApiResponse[Menu], 26 | dependencies=[Depends(Authority("menu:add"))]) 27 | async def add_menu(menu: MenuBase, session: Session = Depends(get_session)): 28 | """ 29 | # 新建的菜单,还是没有授权给角色的,所以直接新增就行了 30 | :param menu: 31 | :param session: 32 | :return: 33 | """ 34 | db_obj = crud.menu.insert(session, Menu(**menu.model_dump())) 35 | session.add(db_obj) 36 | session.commit() 37 | session.refresh(db_obj) 38 | return ApiResponse( 39 | data=db_obj 40 | ) 41 | 42 | 43 | @router.put('/menus', summary="更新菜单", response_model=ApiResponse[Menu], 44 | dependencies=[Depends(Authority("menu:update"))]) 45 | async def update_menu(menu: MenuBase, session: Session = Depends(get_session)): 46 | """ 47 | 更新菜单,涉及到原菜单对应api的更新,则需要更新对应信息 48 | :param menu: 49 | :param session: 50 | :return: 51 | """ 52 | db_obj: Menu = crud.menu.get(session, menu.id) 53 | new_obj: Menu = crud.menu.update(session, db_obj, menu) 54 | session.add(new_obj) 55 | session.commit() 56 | session.refresh(new_obj) 57 | return ApiResponse( 58 | data=new_obj 59 | ) 60 | 61 | 62 | @router.delete('/menus/{menu_id}', summary='删除菜单', 63 | dependencies=[Depends(Authority("menu:del"))]) 64 | async def del_menu(menu_id: int, session: Session = Depends(get_session)): 65 | db_obj = crud.menu.get(session, menu_id) 66 | if len(db_obj.roles) > 0: 67 | roles = [role.name for role in db_obj.roles] 68 | raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{roles} 角色关联菜单,请先取消关联") 69 | if crud.menu.check_has_child(session, menu_id): 70 | raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"菜单下存在子菜单,请先删除子菜单") 71 | crud.menu.delete(session, menu_id) 72 | return ApiResponse() 73 | -------------------------------------------------------------------------------- /server/routers/internal/playbook.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union 2 | from loguru import logger 3 | from fastapi import APIRouter, Depends 4 | from sqlmodel import Session 5 | from server.common.response_code import ApiResponse, SearchResponse 6 | from server.common.database import get_session 7 | from server.models.internal.playbook import Playbook, PlaybookSearch 8 | from server.models.internal import Pagination 9 | from server import crud 10 | 11 | router = APIRouter(prefix='/api') 12 | 13 | 14 | @router.get('/playbook/{playbook_id}', summary='获取playbook详情', response_model=ApiResponse[Playbook]) 15 | async def get_playbook_by_id(playbook_id: int, session: Session = Depends(get_session)): 16 | playbook = crud.internal.playbook.get(session, playbook_id) 17 | return ApiResponse(data=playbook.model_dump()) 18 | 19 | 20 | @router.post('/playbook/search', summary="获取playbook列表", response_model=ApiResponse[SearchResponse[Playbook]]) 21 | async def get_all_user(search: Pagination[PlaybookSearch], 22 | session: Session = Depends(get_session)): 23 | """ 24 | :param search: Pagination实例,包含搜索的所有参数 偏移页面 25 | :param session: 26 | :return: 27 | """ 28 | total = crud.internal.playbook.search_total(session, search.search, {'name': 'like'}) 29 | logger.debug(total) 30 | playbooks: List[Playbook] = crud.internal.playbook.search(session, search, {'name': 'like'}) 31 | playbook_list = [playbook.model_dump(exclude={'playbook'}) for playbook 32 | in playbooks] 33 | logger.debug(playbook_list) 34 | return ApiResponse( 35 | data={ 36 | 'total': total, 37 | 'data': playbook_list 38 | } 39 | ) 40 | 41 | 42 | @router.get('/playbook', summary='获取playbooks列表', response_model=ApiResponse[List[Playbook]]) 43 | async def query_playbooks(query: Union[str, None] = None, session: Session = Depends(get_session)): 44 | playbooks: List[Playbook] = crud.internal.playbook.query_playbooks(session, query) 45 | playbook_list = [playbook.model_dump(exclude={'playbook'}) for playbook 46 | in playbooks] 47 | return ApiResponse(data=playbook_list) 48 | 49 | 50 | @router.post('/playbook', summary='创建playbook') 51 | async def create_playbook(playbook: Playbook, session: Session = Depends(get_session)): 52 | logger.debug(playbook) 53 | crud.internal.playbook.insert(session, Playbook(**playbook.model_dump(exclude_unset=True))) 54 | return ApiResponse() 55 | 56 | 57 | @router.put('/playbook', summary='更新playbook') 58 | async def update_playbook(playbook: Playbook, session: Session = Depends(get_session)): 59 | crud.internal.playbook.update(session, crud.internal.playbook.get(session, playbook.id), 60 | Playbook(**playbook.model_dump(exclude_unset=True))) 61 | return ApiResponse() 62 | 63 | 64 | @router.delete('/playbook/{book_id}', summary='删除playbook') 65 | async def delete_playbook(book_id: int, session: Session = Depends(get_session)): 66 | crud.internal.playbook.delete(session, book_id) 67 | return ApiResponse() 68 | -------------------------------------------------------------------------------- /server/routers/internal/roles.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List 2 | from loguru import logger 3 | from fastapi import APIRouter, Depends, status 4 | from fastapi.exceptions import HTTPException 5 | from sqlmodel import Session 6 | from sqlalchemy.exc import NoResultFound 7 | from ...common.response_code import ApiResponse 8 | from ...common.auth_casbin import Authority 9 | from ...common.database import get_session 10 | from ... import crud 11 | from ...models.internal import Role, Menu 12 | from ...models.internal.role import RoleBase, RoleWithMenus, RoleInsert, RoleUpdate 13 | from server.models.internal import Pagination 14 | from ...common.utils import menu_convert 15 | 16 | router = APIRouter(prefix='/api') 17 | 18 | 19 | @router.get('/roles/enable-menus', summary='获取角色菜单') 20 | async def get_role_menus(id: Optional[int] = None, session: Session = Depends(get_session)): 21 | """ 22 | 返回菜单,和已分配权限菜单 23 | :param id: 24 | :param session: 25 | :return: 26 | """ 27 | # 所有角色,进行权限分配的时候,都是返回所有菜单列表,enable=True:只查询启用的菜单 28 | menu_list: List[Menu] = crud.internal.role.get_all_menus(session) 29 | user_menus = menu_convert(menu_list) 30 | role_menus = crud.internal.role.get_enable_menus(session, id) 31 | return ApiResponse( 32 | data={ 33 | "menus": user_menus, 34 | "enable": role_menus 35 | } 36 | ) 37 | 38 | 39 | @router.post('/roles/search', 40 | summary="查询角色") 41 | async def get_roles(search: Pagination[RoleBase], session: Session = Depends(get_session)): 42 | total = crud.internal.role.search_total(session, search.search, {'name': 'like', 'enable': 'eq'}) 43 | logger.debug(total) 44 | roles: List[Role] = crud.internal.role.search(session, search, {'name': 'like', 'enable': 'eq'}) 45 | role_with_menus: List[RoleWithMenus] = [] 46 | for role in roles: 47 | new_role = RoleWithMenus(**role.model_dump(), menus=role.menus) 48 | role_with_menus.append(new_role) 49 | return ApiResponse( 50 | data={ 51 | 'total': total, 52 | 'data': role_with_menus 53 | } 54 | ) 55 | 56 | 57 | @router.get('/roles/exist', summary='角色是否存在') 58 | async def check_uname_exist(name: str, session: Session = Depends(get_session)): 59 | try: 60 | crud.internal.role.check_exist(session, name) 61 | except NoResultFound: 62 | return ApiResponse() 63 | else: 64 | return ApiResponse( 65 | message='error', 66 | data='error' 67 | ) 68 | 69 | 70 | @router.post('/roles', summary="新建角色", response_model=ApiResponse[Role], 71 | dependencies=[Depends(Authority('role:add'))]) 72 | async def add_roles(role_info: RoleInsert, session: Session = Depends(get_session)): 73 | logger.debug(role_info) 74 | enable_menus = role_info.menus 75 | delattr(role_info, 'menus') 76 | try: 77 | db_obj = crud.internal.role.insert(session, Role(**role_info.model_dump())) 78 | crud.internal.role.update_menus(session, db_obj, enable_menus) 79 | return ApiResponse( 80 | data=db_obj 81 | ) 82 | except Exception as e: 83 | logger.error(f"add role error:{str(e)}") 84 | return ApiResponse( 85 | code=500, 86 | message=f"新建角色错误" 87 | ) 88 | 89 | 90 | @router.put('/roles', summary="更新角色", response_model=ApiResponse[Role], 91 | dependencies=[Depends(Authority('role:update'))]) 92 | async def update_roles(role_info: RoleUpdate, session: Session = Depends(get_session)): 93 | logger.debug(role_info) 94 | if role_info.name == 'admin': 95 | ApiResponse(code=status.HTTP_400_BAD_REQUEST, message='admin权限组无法更新信息') 96 | db_obj = crud.internal.role.get(session, role_info.id) 97 | enable_menus = role_info.menus 98 | delattr(role_info, 'menus') 99 | db_obj = crud.internal.role.update(session, db_obj, role_info) 100 | crud.internal.role.update_menus(session, db_obj, enable_menus) 101 | return ApiResponse( 102 | data=db_obj 103 | ) 104 | 105 | 106 | @router.delete('/roles/{id}', summary='删除角色', dependencies=[Depends(Authority('role:del'))], 107 | status_code=status.HTTP_204_NO_CONTENT) 108 | async def del_role(id: int, session: Session = Depends(get_session)): 109 | db_obj = crud.internal.role.get(session, id) 110 | if db_obj.name == 'admin': 111 | raise HTTPException(status_code=400, detail='admin用户组无法删除') 112 | if len(db_obj.users) > 0: 113 | raise HTTPException(status_code=400, detail='有用户关联此角色') 114 | crud.internal.role.delete(session, id) 115 | -------------------------------------------------------------------------------- /server/settings.py: -------------------------------------------------------------------------------- 1 | import yaml 2 | import os 3 | from pathlib import Path 4 | import casbin_sqlalchemy_adapter 5 | import casbin 6 | from sqlmodel import create_engine 7 | 8 | # 获取环境变量,默认为 development 9 | env = os.getenv('ENV', 'development') 10 | config_path = Path(__file__).parent.parent / 'config' / f'{env}.yaml' 11 | 12 | # 读取配置文件 13 | with open(config_path, 'r', encoding='utf-8') as f: 14 | config = yaml.safe_load(f) 15 | settings = config['server'] 16 | 17 | engine = create_engine( 18 | str(settings['database_uri']), 19 | pool_size=5, 20 | max_overflow=10, 21 | pool_timeout=30, 22 | pool_pre_ping=True 23 | ) 24 | adapter = casbin_sqlalchemy_adapter.Adapter(engine) 25 | casbin_enforcer = casbin.Enforcer(settings['casbin_model_path'], adapter) 26 | -------------------------------------------------------------------------------- /server/sql/__pycache__/__init__.cpython-39.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4linuxfun/Fastapi-Admin/5c057ea38a5c8a1c9189ee7090c306979a1dfaf6/server/sql/__pycache__/__init__.cpython-39.pyc -------------------------------------------------------------------------------- /server/sql/__pycache__/crud.cpython-39.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4linuxfun/Fastapi-Admin/5c057ea38a5c8a1c9189ee7090c306979a1dfaf6/server/sql/__pycache__/crud.cpython-39.pyc -------------------------------------------------------------------------------- /server/sql/__pycache__/database.cpython-39.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4linuxfun/Fastapi-Admin/5c057ea38a5c8a1c9189ee7090c306979a1dfaf6/server/sql/__pycache__/database.cpython-39.pyc -------------------------------------------------------------------------------- /server/sql/__pycache__/models.cpython-39.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4linuxfun/Fastapi-Admin/5c057ea38a5c8a1c9189ee7090c306979a1dfaf6/server/sql/__pycache__/models.cpython-39.pyc -------------------------------------------------------------------------------- /server/sql/__pycache__/schemas.cpython-39.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4linuxfun/Fastapi-Admin/5c057ea38a5c8a1c9189ee7090c306979a1dfaf6/server/sql/__pycache__/schemas.cpython-39.pyc -------------------------------------------------------------------------------- /server/static/template/system.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4linuxfun/Fastapi-Admin/5c057ea38a5c8a1c9189ee7090c306979a1dfaf6/server/static/template/system.xlsx -------------------------------------------------------------------------------- /service.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 生产、开发环境服务控制 3 | # service.sh dev|pro start|stop|restart 4 | 5 | # 获取脚本所在目录的绝对路径 6 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 7 | 8 | start_fastapi() { 9 | env=$1 10 | if pgrep -f "gunicorn server.main:app" > /dev/null || pgrep -f "uvicorn server.main:app" > /dev/null; then 11 | echo "FastAPI service is already running." 12 | return 1 13 | fi 14 | 15 | echo "Starting FastAPI service..." 16 | cd "$SCRIPT_DIR" 17 | 18 | if [ "$env" = "pro" ]; then 19 | echo "Running in production mode..." 20 | export ENV=production 21 | nohup gunicorn server.main:app \ 22 | -b 127.0.0.1:8000 \ 23 | -w 4 \ 24 | -k uvicorn.workers.UvicornWorker \ 25 | > gunicorn.log 2>&1 & 26 | else 27 | echo "Running in development mode..." 28 | export ENV=development 29 | nohup python -m uvicorn server.main:app \ 30 | --host 0.0.0.0 --port 8000 \ 31 | --reload \ 32 | --reload-exclude www \ 33 | > fastapi_dev.log 2>&1 & 34 | fi 35 | } 36 | 37 | stop_fastapi() { 38 | if ! pgrep -f "gunicorn server.main:app" > /dev/null && ! pgrep -f "uvicorn server.main:app" > /dev/null; then 39 | echo "FastAPI service is not running." 40 | return 1 41 | fi 42 | echo "Stopping FastAPI service..." 43 | pkill -f "gunicorn.*server.main:app" 44 | pkill -9 -f "python.*-m.*uvicorn.*server.main:app" 45 | # 关闭所有相关的 Python 子进程 46 | pkill -9 -f "multiprocessing-fork" 47 | # 确保端口被释放 48 | fuser -k 8000/tcp 49 | } 50 | 51 | start_scheduler() { 52 | echo "Starting RPyC scheduler..." 53 | cd "$SCRIPT_DIR" 54 | if [ "$1" = "pro" ]; then 55 | export ENV=production 56 | else 57 | export ENV=development 58 | fi 59 | export PYTHONPATH="$SCRIPT_DIR:$PYTHONPATH" 60 | nohup python rpyc_scheduler/server.py > rpyc_scheduler.log 2>&1 & 61 | } 62 | 63 | stop_scheduler() { 64 | echo "Stopping RPyC scheduler..." 65 | pkill -f "python.*server.py" 66 | } 67 | 68 | case "$2" in 69 | start) 70 | start_fastapi "$1" 71 | start_scheduler "$1" 72 | ;; 73 | stop) 74 | stop_fastapi 75 | stop_scheduler 76 | ;; 77 | restart) 78 | stop_fastapi 79 | stop_scheduler 80 | sleep 2 81 | start_fastapi "$1" 82 | start_scheduler "$1" 83 | ;; 84 | *) 85 | echo "Usage: $0 {dev|pro} {start|stop|restart}" 86 | echo "Options:" 87 | echo " dev Development environment" 88 | echo " pro Production environment" 89 | echo "Commands:" 90 | echo " start Start services" 91 | echo " stop Stop services" 92 | echo " restart Restart services" 93 | exit 1 94 | ;; 95 | esac 96 | -------------------------------------------------------------------------------- /todo-list.md: -------------------------------------------------------------------------------- 1 | # Bug列表 2 | * 登录页面,不存在用户登录能继续执行后续的user_info请求 3 | * 用户退出没有清理asyncRouter信息,导致下个用户登录不需要请求获取列表信息---logout增加清理asyncRouter 4 | * 前端页面刷新导致vuex store信息丢失 5 | 6 | # 功能需求 7 | * 用户登录获取权限列表 -------------------------------------------------------------------------------- /www/.env.development: -------------------------------------------------------------------------------- 1 | NODE_ENV='development' 2 | VITE_APP_BASEURL='http://localhost' 3 | VITE_APP_WS='ws://localhost' -------------------------------------------------------------------------------- /www/.env.developmentv: -------------------------------------------------------------------------------- 1 | NODE_ENV='development' 2 | VITE_APP_BASEURL='http://localhost' -------------------------------------------------------------------------------- /www/.env.production: -------------------------------------------------------------------------------- 1 | NODE_ENV=production 2 | VITE_APP_BASEURL='http://172.16.8.201:8000' -------------------------------------------------------------------------------- /www/.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 | -------------------------------------------------------------------------------- /www/README.md: -------------------------------------------------------------------------------- 1 | # 自封组件介绍 2 | ## 分页查询 3 | ```javascript 4 | // 定义查询字段,type字段定义各个查询字段的方式 5 | /* 6 | * type可用参数: 7 | * 'r_like':右like模糊查询 8 | * 'like':全模糊查询 9 | * 'l_like':左like模糊查询 10 | * 'eq':等于 11 | * 'ne':不等于 12 | * 'lt':小于 13 | * 'le':小于等于 14 | * 'gt':大于 15 | * 'ge':大于等于 16 | * */ 17 | const searchForm = { 18 | tags: null, 19 | path: null, 20 | method: null, 21 | summary: null, 22 | type: { 23 | tags: 'r_like', 24 | path: 'like', 25 | method: 'eq', 26 | summary: 'like', 27 | } 28 | } 29 | 30 | /*usePagination(url,searchForm,orderModel) 31 | * 32 | * */ 33 | const { 34 | search, 35 | tableData, 36 | currentPage, 37 | pageSize, 38 | orderModel, 39 | total, 40 | freshCurrentPage, 41 | handleSearch 42 | } = usePagination('/api/sysapis', searchForm,'desc') 43 | ``` 44 | 45 | ## AutoFormDialog 46 | ### 调用 47 | ``` 48 | 50 | 51 | ``` 52 | ### 传递属性: 53 | 54 | * 动态表单组件 55 | * @description 动态表单组件 56 | * @tutorial 无 57 | * @property {Boolean} visible 是否显示formdialog 58 | * @property {Number} col = [1|2] 显示列数 59 | * @property {String} labelWidth = "100px" formItem label的宽度 60 | * @property {Array} formItemInfo 表单各字段的属性 61 | * @event {Function} update 提交事件,返回新值对象:{prop1:value,prop2:value,prop3:value} 62 | * @example 63 | 64 | 65 | ### formItemInfo信息 66 | ``` 67 | const formItemInfo = [{ 68 | "type": "text", #用于判断类型 69 | "prop": "name", #对应绑定form的prop属性值名称,后期返回数据也是此字段名 70 | "value": null, #传递的默认值 71 | "label": "菜单名", #formItem的label 72 | # 对输入字段设置对应的rules验证规则 73 | "rules": [{ 74 | "required": true, 75 | "message": "请输入菜单名", 76 | "trigger": "blur" 77 | }], 78 | # formItem中对应表单组件的属性值,直接传递给相应组件 79 | "properties": { 80 | "placeholder": "请输入菜单名", 81 | }, 82 | }, 83 | { 84 | "type": "number", 85 | "prop": "age", 86 | "value": 11, 87 | "label": "年龄", 88 | "rules": [{ 89 | "required": true, 90 | "message": "请输入年龄", 91 | "trigger": "blur" 92 | }], 93 | "properties": { 94 | "placeholder": "请输入年龄", 95 | }, 96 | }, 97 | { 98 | "type": "switch", 99 | "prop": "value", 100 | "value": true, 101 | "label": "值", 102 | "rules": [{ 103 | "required": true, 104 | "message": "请输入值", 105 | "trigger": "blur" 106 | }], 107 | 108 | }, 109 | { 110 | "type": "date", 111 | "prop": "date", 112 | "value": "2021-11-12", 113 | "label": "创建时间", 114 | "rules": [{ 115 | "required": true, 116 | "message": "参考前端组件填写", 117 | "trigger": "blur" 118 | }], 119 | "properties": { 120 | "type": 'date', 121 | "placeholder": '请输入日期', 122 | "value-format": "YYYY-MM-DD" 123 | }, 124 | }, 125 | { 126 | "type": "datetime", 127 | "prop": "datetime", 128 | "value": "2021-12-12 12:12:12", 129 | "label": "申报时间", 130 | "rules": [{ 131 | "required": true, 132 | "message": "参考前端组件填写", 133 | "trigger": "blur" 134 | }], 135 | "properties": { 136 | "type": 'datetime', 137 | "placeholder": '请输入时间', 138 | "value-format": "YYYY-MM-DD HH:mm:ss", 139 | "style": "width: 100%;" 140 | }, 141 | } 142 | ] 143 | ``` 144 | -------------------------------------------------------------------------------- /www/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | fastapi-admin 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /www/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "www", 3 | "version": "0.1.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@element-plus/icons-vue": "^2.3.1", 13 | "@vueuse/core": "^10.7.0", 14 | "ace-builds": "^1.35.4", 15 | "axios": "^1.7.9", 16 | "core-js": "^3.40.0", 17 | "echarts": "^5.4.2", 18 | "element-plus": "^2.9.3", 19 | "js-base64": "^3.7.5", 20 | "js-cookie": "^3.0.5", 21 | "jsonref": "^8.0.8", 22 | "pinia": "2.3.0", 23 | "v-contextmenu": "^3.2.0", 24 | "vue": "^3.5.13", 25 | "vue-draggable-plus": "^0.3.5", 26 | "vue-echarts": "^6.6.5", 27 | "vue-router": "^4.5.0", 28 | "vue3-ace-editor": "^2.2.4", 29 | "xterm": "^5.3.0", 30 | "xterm-addon-attach": "^0.9.0", 31 | "xterm-addon-fit": "^0.8.0" 32 | }, 33 | "devDependencies": { 34 | "@rollup/plugin-dynamic-import-vars": "^2.1.2", 35 | "@vitejs/plugin-vue": "^5.2.1", 36 | "js-md5": "^0.8.3", 37 | "sass": "^1.79.3", 38 | "unocss": "^65.4.0", 39 | "unplugin-auto-import": "^0.17.2", 40 | "unplugin-vue-components": "^0.26.0", 41 | "vite": "^6.0.7", 42 | "vite-plugin-dynamic-import": "^1.6.0" 43 | }, 44 | "eslintConfig": { 45 | "root": true, 46 | "env": { 47 | "node": true 48 | }, 49 | "extends": [ 50 | "plugin:vue/vue3-essential" 51 | ], 52 | "parserOptions": { 53 | "parser": "@babel/eslint-parser" 54 | }, 55 | "rules": { 56 | "vue/multi-word-component-names": 0 57 | } 58 | }, 59 | "browserslist": [ 60 | "> 1%", 61 | "last 2 versions", 62 | "not dead" 63 | ] 64 | } 65 | -------------------------------------------------------------------------------- /www/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4linuxfun/Fastapi-Admin/5c057ea38a5c8a1c9189ee7090c306979a1dfaf6/www/public/favicon.ico -------------------------------------------------------------------------------- /www/src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 12 | 13 | 23 | -------------------------------------------------------------------------------- /www/src/api/dictonary.js: -------------------------------------------------------------------------------- 1 | import {GET, POST, PUT, DELETE} from '@/utils/request' 2 | 3 | export const PostNewDict = (dict) => POST('/api/dict', dict) 4 | export const PutDict = (dict) => PUT('/api/dict', dict) 5 | export const DelDict = (id) => DELETE('/api/dict/' + id) 6 | export const PostNewDictItem = (item) => POST('/api/dict/item', item) 7 | export const PutDictItem = (item) => PUT('/api/dict/item', item) 8 | export const DelDictItem = (id) => DELETE('/api/dict/item/' + id) 9 | export const GetDictItems = (code) => GET('/api/dict/' + code) 10 | -------------------------------------------------------------------------------- /www/src/api/file.js: -------------------------------------------------------------------------------- 1 | import request from '@/utils/request' 2 | 3 | export function downloadFile(url,method,data=null){ 4 | let config = { 5 | url:url, 6 | method:method, 7 | responseType:"blob" 8 | } 9 | if(method === "post"){ 10 | config['data']= data 11 | } 12 | request(config).then((res)=>{ 13 | let url = window.URL.createObjectURL(new Blob([res.data])) 14 | let a = document.createElement('a') 15 | a.style.display = 'none' 16 | a.href = url 17 | a.setAttribute('download',res.filename) 18 | document.body.appendChild(a) 19 | a.click() 20 | document.body.removeChild(a) 21 | }) 22 | } -------------------------------------------------------------------------------- /www/src/api/host.js: -------------------------------------------------------------------------------- 1 | import {GET, POST, PUT, DELETE} from '@/utils/request' 2 | 3 | // 用户相关接口 4 | export const PostNewGroup = (group) => POST('/api/host/group', group) 5 | export const PutGroup = (group) => PUT('/api/host/group', group) 6 | export const GetAllGroup = () => GET('/api/host/group') 7 | export const DelGroup = (groupId) => DELETE('/api/host/group/' + groupId) 8 | export const PostNewHost = (host) => POST('/api/host', host) 9 | export const PutHost = (host) => PUT('/api/host', host) 10 | export const DelHost = (hostId) => DELETE('/api/host/' + hostId) 11 | export const PingHost = (hostId) => GET('/api/host/ping/' + hostId) 12 | export const GetHostById = (hostId) => GET('/api/host/' + hostId) 13 | export const GetHostsByIds = (ids) => GET('/api/host/targets/?'+ ids) -------------------------------------------------------------------------------- /www/src/api/jobs.js: -------------------------------------------------------------------------------- 1 | import {GET, POST, PUT, DELETE} from '@/utils/request' 2 | 3 | export const PostNewCronJob = (dict) => POST('/api/jobs/', dict) 4 | export const GetJobList = () => GET('/api/jobs/') 5 | export const DelJob = (jobId) => DELETE('/api/jobs/' + jobId) 6 | export const PutCronJob = (job) => PUT('/api/jobs/', job) 7 | export const SwitchJob = (jobId, status) => GET('/api/jobs/switch/' + jobId, {status: status}) 8 | export const GetLogs = (jobId) => GET('/api/jobs/logs') -------------------------------------------------------------------------------- /www/src/api/menus.js: -------------------------------------------------------------------------------- 1 | import { GET, POST, PUT, DELETE } from '@/utils/request' 2 | 3 | // 菜单接口 4 | export const GetAllMenus = () => GET('/api/menus') 5 | export const PostNewMenu = (menu) => POST('/api/menus', menu ) 6 | export const PutMenu = (menu) => PUT('/api/menus', menu) 7 | export const DeleteMenu = (id) => DELETE('/api/menus/' + id) -------------------------------------------------------------------------------- /www/src/api/playbook.js: -------------------------------------------------------------------------------- 1 | import {GET, POST, PUT, DELETE} from '@/utils/request' 2 | 3 | //playbook相关接口 4 | export const GetPlaybook = (playbookId) => GET('/api/playbook/' + playbookId) 5 | export const PostNewPlaybook = (playbook) => POST('/api/playbook', playbook) 6 | export const PutPlaybook = (playbook) => PUT('/api/playbook', playbook) 7 | export const DelPlaybook = (playbookId) => DELETE('/api/playbook/' + playbookId) 8 | export const GetPlaybooksByQuery = (query) => GET('/api/playbook', {query}) -------------------------------------------------------------------------------- /www/src/api/roles.js: -------------------------------------------------------------------------------- 1 | import {GET, POST, PUT, DELETE} from '@/utils/request' 2 | 3 | // 角色接口 4 | export const GetRoles = (q) => GET('/api/roles', {q}) 5 | export const GetRoleInfo = (roleId) => GET('/api/roles' + roleId) 6 | export const GetRoleEnableMenus = (roleId) => GET('/api/roles/enable-menus', {id: roleId}) 7 | export const PostNewRoles = (role) => POST('/api/roles', role) 8 | export const PutRoles = (role) => PUT('/api/roles', role) 9 | export const DeleteRole = (roleId) => DELETE('/api/roles/' + roleId) 10 | export const GetRoleExist = (name) => GET('/api/roles/exist', {name}) -------------------------------------------------------------------------------- /www/src/api/users.js: -------------------------------------------------------------------------------- 1 | import {GET, POST, PUT, DELETE} from '@/utils/request' 2 | 3 | // 用户相关接口 4 | export const requestLogin = (username, password) => POST('/api/login', {username, password}) 5 | export const GetUserInfo = (uid) => GET('/api/users/' + uid) 6 | export const GetUserPermission = () => GET('/api/permission') 7 | export const GetUserExist = (name) => GET('/api/users/exist', {name}) 8 | export const GetUserRoles = (userId) => GET('/api/users/roles', {id: userId}) 9 | export const PutNewUser = (user, roles) => PUT('/api/users/' + user.id, {user, roles}) 10 | export const PostAddUser = (user, roles) => POST('/api/users', {user, roles}) 11 | export const DeleteUser = (userId) => DELETE('/api/users/' + userId) 12 | export const ResetPasswd = (userId, password) => PUT('/api/users/password', {id: userId, password}) -------------------------------------------------------------------------------- /www/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/4linuxfun/Fastapi-Admin/5c057ea38a5c8a1c9189ee7090c306979a1dfaf6/www/src/assets/logo.png -------------------------------------------------------------------------------- /www/src/autoImport.d.ts: -------------------------------------------------------------------------------- 1 | // Generated by 'unplugin-auto-import' 2 | export {} 3 | declare global { 4 | const EffectScope: typeof import('vue')['EffectScope'] 5 | const computed: typeof import('vue')['computed'] 6 | const createApp: typeof import('vue')['createApp'] 7 | const customRef: typeof import('vue')['customRef'] 8 | const defineAsyncComponent: typeof import('vue')['defineAsyncComponent'] 9 | const defineComponent: typeof import('vue')['defineComponent'] 10 | const effectScope: typeof import('vue')['effectScope'] 11 | const getCurrentInstance: typeof import('vue')['getCurrentInstance'] 12 | const getCurrentScope: typeof import('vue')['getCurrentScope'] 13 | const h: typeof import('vue')['h'] 14 | const inject: typeof import('vue')['inject'] 15 | const isProxy: typeof import('vue')['isProxy'] 16 | const isReactive: typeof import('vue')['isReactive'] 17 | const isReadonly: typeof import('vue')['isReadonly'] 18 | const isRef: typeof import('vue')['isRef'] 19 | const markRaw: typeof import('vue')['markRaw'] 20 | const nextTick: typeof import('vue')['nextTick'] 21 | const onActivated: typeof import('vue')['onActivated'] 22 | const onBeforeMount: typeof import('vue')['onBeforeMount'] 23 | const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave'] 24 | const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate'] 25 | const onBeforeUnmount: typeof import('vue')['onBeforeUnmount'] 26 | const onBeforeUpdate: typeof import('vue')['onBeforeUpdate'] 27 | const onDeactivated: typeof import('vue')['onDeactivated'] 28 | const onErrorCaptured: typeof import('vue')['onErrorCaptured'] 29 | const onMounted: typeof import('vue')['onMounted'] 30 | const onRenderTracked: typeof import('vue')['onRenderTracked'] 31 | const onRenderTriggered: typeof import('vue')['onRenderTriggered'] 32 | const onScopeDispose: typeof import('vue')['onScopeDispose'] 33 | const onServerPrefetch: typeof import('vue')['onServerPrefetch'] 34 | const onUnmounted: typeof import('vue')['onUnmounted'] 35 | const onUpdated: typeof import('vue')['onUpdated'] 36 | const provide: typeof import('vue')['provide'] 37 | const reactive: typeof import('vue')['reactive'] 38 | const readonly: typeof import('vue')['readonly'] 39 | const ref: typeof import('vue')['ref'] 40 | const resolveComponent: typeof import('vue')['resolveComponent'] 41 | const resolveDirective: typeof import('vue')['resolveDirective'] 42 | const shallowReactive: typeof import('vue')['shallowReactive'] 43 | const shallowReadonly: typeof import('vue')['shallowReadonly'] 44 | const shallowRef: typeof import('vue')['shallowRef'] 45 | const toRaw: typeof import('vue')['toRaw'] 46 | const toRef: typeof import('vue')['toRef'] 47 | const toRefs: typeof import('vue')['toRefs'] 48 | const triggerRef: typeof import('vue')['triggerRef'] 49 | const unref: typeof import('vue')['unref'] 50 | const useAttrs: typeof import('vue')['useAttrs'] 51 | const useCssModule: typeof import('vue')['useCssModule'] 52 | const useCssVars: typeof import('vue')['useCssVars'] 53 | const useLink: typeof import('vue-router')['useLink'] 54 | const useRoute: typeof import('vue-router')['useRoute'] 55 | const useRouter: typeof import('vue-router')['useRouter'] 56 | const useSlots: typeof import('vue')['useSlots'] 57 | const watch: typeof import('vue')['watch'] 58 | const watchEffect: typeof import('vue')['watchEffect'] 59 | const watchPostEffect: typeof import('vue')['watchPostEffect'] 60 | const watchSyncEffect: typeof import('vue')['watchSyncEffect'] 61 | } 62 | -------------------------------------------------------------------------------- /www/src/components.d.ts: -------------------------------------------------------------------------------- 1 | // generated by unplugin-vue-components 2 | // We suggest you to commit this file into source control 3 | // Read more: https://github.com/vuejs/core/pull/3399 4 | import '@vue/runtime-core' 5 | 6 | export {} 7 | 8 | declare module '@vue/runtime-core' { 9 | export interface GlobalComponents { 10 | AutoDict: typeof import('./components/AutoDict.vue')['default'] 11 | AutoFormDialog: typeof import('./components/AutoFormDialog.vue')['default'] 12 | ElAside: typeof import('element-plus/es')['ElAside'] 13 | ElAvatar: typeof import('element-plus/es')['ElAvatar'] 14 | ElCol: typeof import('element-plus/es')['ElCol'] 15 | ElContainer: typeof import('element-plus/es')['ElContainer'] 16 | ElDropdown: typeof import('element-plus/es')['ElDropdown'] 17 | ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem'] 18 | ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu'] 19 | ElHeader: typeof import('element-plus/es')['ElHeader'] 20 | ElIcon: typeof import('element-plus/es')['ElIcon'] 21 | ElMain: typeof import('element-plus/es')['ElMain'] 22 | ElMenu: typeof import('element-plus/es')['ElMenu'] 23 | ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] 24 | ElRow: typeof import('element-plus/es')['ElRow'] 25 | ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] 26 | ElTabPane: typeof import('element-plus/es')['ElTabPane'] 27 | ElTabs: typeof import('element-plus/es')['ElTabs'] 28 | HeaderContent: typeof import('./components/HeaderContent.vue')['default'] 29 | InputPlus: typeof import('./components/InputPlus.vue')['default'] 30 | MenuList: typeof import('./components/MenuList.vue')['default'] 31 | ResetButton: typeof import('./components/ResetButton.vue')['default'] 32 | Roles: typeof import('./components/roles.vue')['default'] 33 | RouterLink: typeof import('vue-router')['RouterLink'] 34 | RouterView: typeof import('vue-router')['RouterView'] 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /www/src/components/AutoDict.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 100 | 101 | -------------------------------------------------------------------------------- /www/src/components/AutoFormDialog.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 121 | 122 | 124 | -------------------------------------------------------------------------------- /www/src/components/HeaderContent.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 75 | 78 | -------------------------------------------------------------------------------- /www/src/components/InputPlus.vue: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 25 | 26 | 28 | -------------------------------------------------------------------------------- /www/src/components/MenuList.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 67 | 68 | -------------------------------------------------------------------------------- /www/src/components/ResetButton.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 18 | 19 | -------------------------------------------------------------------------------- /www/src/components/roles.vue: -------------------------------------------------------------------------------- 1 | 20 | 43 | -------------------------------------------------------------------------------- /www/src/composables/useMenu.js: -------------------------------------------------------------------------------- 1 | import {reactive, ref, watch, computed} from 'vue' 2 | 3 | export default function (form, menuData, emit) { 4 | const selectData = reactive(form) 5 | 6 | watch(selectData, (newData) => { 7 | console.log('watch selectData change:' + selectData) 8 | emit('update:form', selectData) 9 | }) 10 | 11 | 12 | const menuMap = (arr) => { 13 | const menu = arr.filter(item => item.type !== 'btn') 14 | for (let item of menu) { 15 | if (item.children && item.children.length > 0) { 16 | item.children = menuMap(item.children) 17 | } 18 | } 19 | return menu 20 | } 21 | //cascaderMenu变量不包含menuData变量中type为btn的值,如果children属性值包含内容,则再进行遍历判断 22 | const cascaderMenu = computed(() => { 23 | let menu = menuMap(menuData) 24 | console.log(menu) 25 | return menu 26 | }) 27 | 28 | return { 29 | selectData, 30 | cascaderMenu 31 | } 32 | } -------------------------------------------------------------------------------- /www/src/composables/usePagination.js: -------------------------------------------------------------------------------- 1 | import {reactive, ref, watch} from 'vue' 2 | import {POST} from '@/utils/request' 3 | 4 | /** 5 | * 分页逻辑复用函数 6 | * @param {string} url - 请求的API地址 7 | * @param {Object} searchForm - 搜索表单数据,默认为空对象 8 | * @param {string} orderType - 排序方式,默认为 'asc' 9 | * @param {number} initialPageSize - 每页显示条数,默认为 10 10 | * @returns {Object} - 返回分页相关的状态和方法 11 | */ 12 | export default function usePagination(url, searchForm = {}, orderType = 'asc', initialPageSize = 10) { 13 | // 响应式搜索表单数据 14 | const search = reactive(searchForm) 15 | 16 | // 表格数据 17 | const tableData = ref([]) 18 | 19 | // 每页显示条数 20 | const pageSize = ref(initialPageSize) 21 | 22 | // 当前页码 23 | const currentPage = ref(1) 24 | 25 | // 排序方式 26 | const orderModel = ref(orderType) 27 | 28 | // 数据总数 29 | const total = ref(0) 30 | 31 | /** 32 | * 获取分页数据 33 | * @returns {Promise} 34 | */ 35 | const fetchData = async () => { 36 | const response = await POST(url, { 37 | search, 38 | page: currentPage.value, 39 | page_size: pageSize.value, 40 | model: orderModel.value, 41 | }) 42 | tableData.value = response.data 43 | total.value = response.total 44 | } 45 | 46 | /** 47 | * 刷新当前页数据 48 | */ 49 | const freshCurrentPage = async () => { 50 | await fetchData() 51 | } 52 | 53 | /** 54 | * 处理搜索操作,重置页码并获取数据 55 | */ 56 | const handleSearch = async () => { 57 | currentPage.value = 1 58 | await fetchData() 59 | } 60 | 61 | // 监听 currentPage 和 pageSize 的变化,自动获取数据 62 | watch([currentPage, pageSize], async () => { 63 | await fetchData() 64 | }) 65 | 66 | // 返回分页相关的状态和方法 67 | return { 68 | search, 69 | tableData, 70 | currentPage, 71 | pageSize, 72 | orderModel, 73 | total, 74 | freshCurrentPage, 75 | handleSearch, 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /www/src/composables/useTerm.js: -------------------------------------------------------------------------------- 1 | import {onMounted, ref, watch, onBeforeUnmount, nextTick} from 'vue' 2 | import {FitAddon} from 'xterm-addon-fit' 3 | import {Terminal} from 'xterm' 4 | import 'xterm/css/xterm.css' 5 | 6 | /** 7 | * @name: useTerm,用于统一使用terminal 8 | * @description: 终端 9 | * @return {Promise} 10 | * 11 | * 示例用法: 12 | * 创建终端 13 | *
14 | * const terminalRef = ref(null) 15 | * const {term, initTerm} = useTerm(terminalRef) 16 | * 手动初始化 17 | * await initTerm() 18 | * 写入内容 19 | * term.value.write('写入terminal 内容\n') 20 | */ 21 | export default function useTerm(terminalRef) { 22 | const term = ref(null) 23 | const fitAddon = new FitAddon() 24 | 25 | async function initTerm() { 26 | if(!terminalRef.value){ 27 | console.error('Terminal container not found') 28 | return 29 | } 30 | console.log('init terminal') 31 | term.value = new Terminal({ 32 | rendererType: 'canvas', 33 | disableStdin: false, 34 | convertEol: true, 35 | cursorStyle: 'block', 36 | scrollback: 9999999, 37 | }) 38 | term.value.open(terminalRef.value) 39 | term.value.loadAddon(fitAddon) 40 | 41 | await nextTick(async () => { 42 | fitAddon.fit() 43 | console.log('fit ok') 44 | }) 45 | 46 | term.value.focus() 47 | console.log('init terminal ok') 48 | } 49 | 50 | const handleResize = () => { 51 | fitAddon.fit() 52 | } 53 | 54 | window.addEventListener('resize', handleResize) 55 | 56 | onBeforeUnmount(() => { 57 | window.removeEventListener('resize', handleResize) 58 | }) 59 | 60 | return { 61 | term, 62 | initTerm 63 | } 64 | } -------------------------------------------------------------------------------- /www/src/main.js: -------------------------------------------------------------------------------- 1 | import {createApp} from 'vue' 2 | import App from './App.vue' 3 | import ElementPlus from 'element-plus' 4 | import * as ElementPlusIconsVue from '@element-plus/icons-vue' 5 | import zhCn from 'element-plus/dist/locale/zh-cn' 6 | import 'element-plus/dist/index.css' 7 | import router from './router' 8 | import {createPinia} from 'pinia' 9 | import {useStore} from './stores' 10 | import './permission' 11 | import 'uno.css' 12 | 13 | 14 | const app = createApp(App) 15 | for (const [key, component] of Object.entries(ElementPlusIconsVue)) { 16 | app.component(key, component) 17 | } 18 | 19 | app.use(ElementPlus, {locale: zhCn}) 20 | app.use(router) 21 | app.use(createPinia()) 22 | 23 | app.directive('permission', { 24 | mounted(el, binding) { 25 | console.log('permission run') 26 | console.log(el, binding) 27 | const store = useStore() 28 | let permission = binding.value 29 | console.log('check permission:' + permission) 30 | let btnPermissions = store.buttons 31 | console.log(btnPermissions) 32 | if (!btnPermissions.includes(permission)) { 33 | el.parentNode.removeChild(el) 34 | } 35 | } 36 | }) 37 | app.mount('#app') -------------------------------------------------------------------------------- /www/src/permission.js: -------------------------------------------------------------------------------- 1 | import router from './router' 2 | import {useStore} from './stores' 3 | import {getToken} from '@/utils/auth' 4 | import {makeRouter} from '@/utils/router' 5 | import {useTabsStore} from '@/stores/tabs' 6 | 7 | const whiteList = ['/login'] // no redirect whitelist 8 | 9 | router.beforeEach(async (to) => { 10 | const store = useStore() 11 | const {tabAdd} = useTabsStore() 12 | console.log('start before each', to) 13 | 14 | if (getToken()) { 15 | console.log('已经有token') 16 | // 已登录且要跳转的页面是登录页 17 | if (to.path === '/login') { 18 | return '/' 19 | } else { 20 | console.log('已经登录成功') 21 | //登录成功,需要判断router是不是已经按照权限要求构建好,并且菜单是否按照权限要求生成,如没有,则生成 22 | // router.push('/') 23 | if (store.asyncRoutes.length === 0) { 24 | console.log('asyncroutes is not set') 25 | try { 26 | await store.getInfo() 27 | await store.getPermission() 28 | let asyncRoutes = await makeRouter(store.asyncRoutes) 29 | console.log(asyncRoutes) 30 | for (let route of asyncRoutes) { 31 | console.log('add route:') 32 | console.log(route) 33 | // 判断route是否有children,如果没有,则增加到'/'的children中 34 | if (!route.children || route.children.length === 0) { 35 | console.log('no children') 36 | router.addRoute('home', route) 37 | } else { 38 | router.addRoute(route) 39 | } 40 | } 41 | return {path: to.fullPath, replace: true} 42 | } catch (e) { 43 | console.log('用户权限拉取失败' + e) 44 | await store.logOut() 45 | location.reload() 46 | } 47 | } 48 | console.log('当前生效路由表') 49 | console.log(router.getRoutes()) 50 | tabAdd(to) 51 | return true 52 | } 53 | } else { 54 | // 无token信息,表示未登录 55 | console.log('无token信息') 56 | if (whiteList.indexOf(to.path) !== -1) { 57 | // 在免登录白名单,直接进入 58 | return true 59 | } else { 60 | // return true 61 | router.replace(`/login?redirect=${to.path}`) // 否则全部重定向到登录页 62 | } 63 | } 64 | }) 65 | -------------------------------------------------------------------------------- /www/src/router/index.js: -------------------------------------------------------------------------------- 1 | import {createRouter, createWebHistory} from 'vue-router' 2 | 3 | 4 | export const constantRouterMap = [ 5 | { 6 | path: '/', 7 | name: 'home', 8 | component: () => import('@/views/Layout'), 9 | children: [ 10 | { 11 | path: '', 12 | name: '首页', 13 | component: () => import('@/views/DashBoard') 14 | }, 15 | { 16 | path: '404', 17 | name: '404', 18 | component: () => import('@/views/errorPage/NotFound'), 19 | }, 20 | { 21 | path: ':pathMatch(.*)*', 22 | name: '404', 23 | component: () => import('@/views/errorPage/NotFound'), 24 | }, 25 | ] 26 | }, 27 | { 28 | path: '/login', 29 | component: () => import('@/views/Login/index'), 30 | // hidden: true 31 | }, 32 | ] 33 | 34 | export default createRouter({ 35 | history: createWebHistory(), 36 | // scrollBehavior: () => ({ y: 0 }), 37 | routes: constantRouterMap 38 | }) -------------------------------------------------------------------------------- /www/src/stores/collapse.js: -------------------------------------------------------------------------------- 1 | import {defineStore} from 'pinia' 2 | import {ref} from 'vue' 3 | 4 | export const useCollapseStore = defineStore('collapse', () => { 5 | const collapse = ref(false) 6 | return {collapse} 7 | }) -------------------------------------------------------------------------------- /www/src/stores/dict.js: -------------------------------------------------------------------------------- 1 | import {defineStore} from "pinia"; 2 | import {computed, ref} from 'vue' 3 | import {GetDictItems} from '@/api/dictonary' 4 | 5 | export const useDictStore = defineStore('dict', () => { 6 | const dictObject = ref({}) 7 | // 通过dict,返回对应的值.构建一个带参数的getter 8 | const dictItems = computed(() => { 9 | return (dictName) => { 10 | if (!dictObject.value.hasOwnProperty(dictName)) { 11 | GetDictItems(dictName).then(response => { 12 | dictObject.value[dictName] = response 13 | }) 14 | } 15 | return dictObject.value[dictName] 16 | 17 | } 18 | }) 19 | 20 | async function getDictItems(dictName) { 21 | if (!dictObject.value.hasOwnProperty(dictName)) { 22 | console.log('dictObject has no dict:'+dictName) 23 | dictObject.value[dictName] = await GetDictItems(dictName) 24 | } 25 | return dictObject.value[dictName] 26 | } 27 | 28 | return {dictObject, dictItems, getDictItems} 29 | }) -------------------------------------------------------------------------------- /www/src/stores/index.js: -------------------------------------------------------------------------------- 1 | import {defineStore} from 'pinia' 2 | import {ref} from 'vue' 3 | import { 4 | requestLogin, 5 | GetUserInfo, 6 | GetUserPermission 7 | } from '@/api/users' 8 | import { 9 | setToken, 10 | removeToken 11 | } from '@/utils/auth' 12 | 13 | export const useStore = defineStore('user', () => { 14 | 15 | const uid = ref('') 16 | const token = ref('') 17 | const name = ref(null) 18 | const avatar = ref(null) 19 | const asyncRoutes = ref([]) 20 | const buttons = ref([]) 21 | 22 | //执行登录请求,获取token 23 | function logIn(userInfo) { 24 | const username = userInfo.username 25 | const password = userInfo.password 26 | const rememberMe = userInfo.rememberMe 27 | console.log('user login actions') 28 | return new Promise((resolve, reject) => { 29 | requestLogin(username, password).then((response) => { 30 | console.log(response) 31 | setToken(response.token, rememberMe) 32 | token.value = response.token 33 | uid.value = response.uid 34 | resolve() 35 | }).catch((error) => { 36 | reject(error) 37 | }) 38 | }) 39 | } 40 | 41 | // 获取用户状态信息 42 | function getInfo() { 43 | return new Promise((resolve, reject) => { 44 | console.log('get user info') 45 | GetUserInfo(this.uid).then(response => { 46 | name.value = response.name 47 | avatar.value = response.avatar 48 | resolve(response) 49 | }).catch(error => { 50 | reject(error) 51 | }) 52 | }) 53 | } 54 | 55 | function logOut() { 56 | return new Promise((resolve) => { 57 | // this.$reset() 58 | removeToken() 59 | resolve() 60 | }) 61 | } 62 | 63 | // 获取用户权限列表 64 | function getPermission() { 65 | return new Promise((resolve, reject) => { 66 | GetUserPermission().then(response => { 67 | console.log('permission response is:') 68 | console.log(response) 69 | asyncRoutes.value = (response.menus) 70 | buttons.value = (response.btns) 71 | resolve(response) 72 | }).catch(error => { 73 | reject(error) 74 | }) 75 | }) 76 | } 77 | 78 | return {uid, token, name, avatar, asyncRoutes, buttons, getInfo, logIn, logOut, getPermission} 79 | } 80 | ) -------------------------------------------------------------------------------- /www/src/stores/tabs.js: -------------------------------------------------------------------------------- 1 | import {defineStore} from 'pinia' 2 | import {computed, nextTick, ref} from 'vue' 3 | import router from '@/router' 4 | 5 | export const useTabsStore = defineStore('tabs', () => { 6 | const allTabs = ref([{name: '首页', path: '/'},]) 7 | const currentTab = ref(null) 8 | const cacheTabs = ref([]) 9 | 10 | const tabsList = computed(() => { 11 | return allTabs 12 | }) 13 | 14 | 15 | // 打开新tab时执行 16 | function tabAdd(to) { 17 | const isExist = allTabs.value.some(item => { 18 | return item.path === to.fullPath 19 | }) 20 | if (!isExist) { 21 | allTabs.value.push({ 22 | name: to.name, 23 | path: to.fullPath 24 | }) 25 | cacheTabs.value.push(to.name) 26 | } 27 | currentTab.value = to.name 28 | } 29 | 30 | function tabRemove(removeTab) { 31 | console.log('tab remove:', removeTab) 32 | for (let i = 0; i < allTabs.value.length; i++) { 33 | if (allTabs.value[i].name === removeTab) { 34 | allTabs.value.splice(i, 1) 35 | cacheTabs.value.splice(i, 1) 36 | const nextId = i === allTabs.value.length ? i - 1 : i 37 | currentTab.value = allTabs.value[nextId].name 38 | console.log(currentTab.value) 39 | router.push(allTabs.value[nextId].path) 40 | } 41 | } 42 | } 43 | 44 | function tabClick(selectTab) { 45 | console.log('tab click', selectTab) 46 | currentTab.value = selectTab.paneName 47 | console.log('current tab value:', currentTab.value) 48 | console.log('all tabs:', allTabs) 49 | allTabs.value.forEach(tab => { 50 | console.log(tab.name) 51 | if (tab.name === currentTab.value) { 52 | console.log('router to:', tab.path) 53 | router.push(tab.path) 54 | } 55 | }) 56 | } 57 | 58 | return {currentTab, allTabs, cacheTabs, tabsList, tabAdd, tabRemove, tabClick} 59 | }) -------------------------------------------------------------------------------- /www/src/utils/auth.js: -------------------------------------------------------------------------------- 1 | import Cookies from 'js-cookie' 2 | 3 | const TokenKey = 'Token' 4 | 5 | export function getToken() { 6 | return Cookies.get(TokenKey) 7 | } 8 | 9 | export function setToken(token, rememberMe) { 10 | if (rememberMe) { 11 | return Cookies.set(TokenKey, token, { expires: 1 }) 12 | } else { 13 | return Cookies.set(TokenKey, token) 14 | } 15 | } 16 | 17 | export function removeToken() { 18 | return Cookies.remove(TokenKey) 19 | } -------------------------------------------------------------------------------- /www/src/utils/common.js: -------------------------------------------------------------------------------- 1 | // 一些通用的js 2 | /** 3 | * 将树形数据扁平化为 { id: node } 的形式 4 | * @param {Array} nodes - 树形数据 5 | * @param {Object} props - 属性配置,例如:{ id: 'id', children: 'children' } 6 | * @returns {Map} - 返回一个 Map,key 是节点的 id,value 是节点本身 7 | */ 8 | function flattenTree(nodes, props = {}) { 9 | const {id = 'id', children = 'children'} = props 10 | const nodeMap = new Map() 11 | 12 | function flatten(nodes) { 13 | nodes.forEach(node => { 14 | nodeMap.set(node[id], node) // 将节点存入 Map 15 | if (node[children] && node[children].length > 0) { 16 | flatten(node[children]) // 递归处理子节点 17 | } 18 | }) 19 | } 20 | 21 | flatten(nodes) 22 | return nodeMap 23 | } 24 | 25 | /** 26 | * 通过 id 查找节点 27 | * @param {Map} nodeMap - 扁平化的节点 Map 28 | * @param {number|string} targetId - 目标节点的 id 29 | * @returns {Object|null} - 返回找到的节点,如果未找到则返回 null 30 | */ 31 | export function findNodeById(nodeMap, targetId) { 32 | return nodeMap.get(targetId) || null 33 | } 34 | 35 | 36 | /** 37 | * 通过id生成对应的层级label,例如:根节点/子节点/孙节点 38 | * @param {Array} data - 传入的树形数据, 例如:[{id: 1, name: '根节点', children: [{id: 2, name: '子节点', children: [{id: 3, name: '孙节点'}]}]}] 39 | * @param {number} findId - 传入的唯一值 40 | * @param {Object} props - 传入的属性,例如:{id: 'id', children: 'children', label: 'label',parent_id: 'parent_id'} 41 | * @returns {string} - 返回层级label,例如:根节点/子节点/孙节点 42 | */ 43 | export function getHierarchyLabel(data, findId, props) { 44 | const defaultProps = { 45 | id: 'id', 46 | children: 'children', 47 | label: 'label', 48 | parent_id: 'parent_id' 49 | } 50 | const id = props && props.hasOwnProperty('id') ? props.id : defaultProps.id 51 | const children = props && props.hasOwnProperty('children') ? props.children : defaultProps.children 52 | const label = props && props.hasOwnProperty('label') ? props.label : defaultProps.label 53 | const parent_id = props && props.hasOwnProperty('parent_id') ? props.parent_id : defaultProps.parent_id 54 | 55 | let nodeMap = flattenTree(data, {id: 'id', children: 'children'}) 56 | let hierarchyLabel = '' 57 | while (true) { 58 | let selectNode = findNodeById(nodeMap, findId) 59 | hierarchyLabel = hierarchyLabel ? selectNode[label] + '/' + hierarchyLabel : selectNode[label] 60 | if (selectNode[parent_id] === null) { 61 | hierarchyLabel = '/' + hierarchyLabel 62 | break 63 | } 64 | findId = selectNode[parent_id] 65 | } 66 | console.log(hierarchyLabel) 67 | return hierarchyLabel 68 | } 69 | 70 | 71 | /** 72 | * 给[{},{}]类型的数据递归增加自定义属性值,并返回新数组 73 | * @param {Array} data - 传入的树形数据, 例如:[{id: 1, name: '根节点', children: [{id: 2, name: '子节点', children: [{id: 3, name: '孙节点'}]}]}] 74 | * @param {string} customProperty - 传入的自定义属性名称, 例如:'isExpanded' 75 | * @param value - 自定义的值 76 | * @returns {Array} - 返回新数组 77 | */ 78 | export function addCustomProperty(data, customProperty, value) { 79 | return data.map(item => { 80 | item[customProperty] = value 81 | if (item.children) { 82 | item.children = addCustomProperty(item.children, customProperty, value) 83 | } 84 | return item 85 | }) 86 | } 87 | 88 | /** 89 | * 给[{},{}]类型的数据递归查找某个属性,修改值,并返回新数组 90 | * @param {Array} data - 传入的树形数据, 例如:[{id: 1, name: '根节点', children: [{id: 2, name: '子节点', children: [{id: 3, name: '孙节点'}]}]}] 91 | * @param {string} customProperty - 传入的自定义属性名称, 例如:'isExpanded' 92 | * @param value - 自定义的值 93 | * @param newValue - 新的值 94 | * @returns {Array} - 返回新数组 95 | */ 96 | export function updateCustomProperty(data, customProperty, value, newValue) { 97 | return data.map(item => { 98 | if (item[customProperty] === value) { 99 | item[customProperty] = newValue 100 | } 101 | if (item.children) { 102 | item.children = updateCustomProperty(item.children, customProperty, value, newValue) 103 | } 104 | return item 105 | }) 106 | } -------------------------------------------------------------------------------- /www/src/utils/router.js: -------------------------------------------------------------------------------- 1 | import Layout from '@/views/Layout' 2 | import {shallowRef} from 'vue' 3 | 4 | // 通过服务端传回来的权限菜单列表,添加部分字段 5 | export function makeRouter(routers) { // 遍历后台传来的路由字符串,转换为组件对象 6 | console.log(routers) 7 | let asyncRouter = routers.map(route => { 8 | console.log('name:' + route.name + ' path:' + route.path) 9 | if (route.component) { 10 | route.component = loadView(route.component) 11 | } else { 12 | route.component = null 13 | } 14 | 15 | if (route.children && route.children.length) { 16 | route.children = makeRouter(route.children) 17 | } 18 | return route 19 | }) 20 | console.log('asyncrouter is:', asyncRouter) // 修改: 更改为对象输出 21 | return asyncRouter 22 | } 23 | 24 | export const loadView = (view) => { // 路由懒加载 25 | return () => import(`@/views/${view}`) 26 | } 27 | -------------------------------------------------------------------------------- /www/src/views/DashBoard.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 76 | 77 | -------------------------------------------------------------------------------- /www/src/views/Home/index.vue: -------------------------------------------------------------------------------- 1 | 6 | 18 | -------------------------------------------------------------------------------- /www/src/views/Layout.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 52 | 53 | -------------------------------------------------------------------------------- /www/src/views/PersonalCenter.vue: -------------------------------------------------------------------------------- 1 | 6 | 11 | -------------------------------------------------------------------------------- /www/src/views/errorPage/401.vue: -------------------------------------------------------------------------------- 1 | 6 | 11 | -------------------------------------------------------------------------------- /www/src/views/errorPage/404.vue: -------------------------------------------------------------------------------- 1 | 6 | 11 | -------------------------------------------------------------------------------- /www/src/views/errorPage/NotFound.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 12 | 13 | 35 | -------------------------------------------------------------------------------- /www/src/views/host/AddGroup.vue: -------------------------------------------------------------------------------- 1 | 4 | 29 | 30 | 112 | 113 | 114 | -------------------------------------------------------------------------------- /www/src/views/host/HostDialog.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 137 | 138 | -------------------------------------------------------------------------------- /www/src/views/jobs/Execute/LogDrawer.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /www/src/views/jobs/Execute/index.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 36 | 37 | 76 | -------------------------------------------------------------------------------- /www/src/views/jobs/JobManage/JobLogs.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 98 | 99 | -------------------------------------------------------------------------------- /www/src/views/jobs/JobManage/LogDetail.vue: -------------------------------------------------------------------------------- 1 | 4 | 40 | 41 | 80 | 81 | -------------------------------------------------------------------------------- /www/src/views/jobs/playbook/AddPlaybook.vue: -------------------------------------------------------------------------------- 1 | 2 | 23 | 24 | 101 | -------------------------------------------------------------------------------- /www/src/views/jobs/playbook/index.vue: -------------------------------------------------------------------------------- 1 | 43 | 48 | 49 | 88 | 89 | -------------------------------------------------------------------------------- /www/src/views/system/dictonary/AddDict.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /www/src/views/system/dictonary/AddItem.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 120 | 121 | -------------------------------------------------------------------------------- /www/src/views/system/dictonary/index.vue: -------------------------------------------------------------------------------- 1 | 60 | 61 | 157 | 158 | -------------------------------------------------------------------------------- /www/src/views/system/menus/ButtonForm.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 33 | 34 | -------------------------------------------------------------------------------- /www/src/views/system/menus/MenuDrawer.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 104 | 105 | 107 | -------------------------------------------------------------------------------- /www/src/views/system/roles/RoleDialog.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 150 | 151 | 157 | -------------------------------------------------------------------------------- /www/src/views/system/roles/index.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 56 | 136 | -------------------------------------------------------------------------------- /www/src/views/system/setting/index.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 16 | 19 | 20 | -------------------------------------------------------------------------------- /www/src/views/system/user/ResetPasswdDialog.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 86 | 87 | -------------------------------------------------------------------------------- /www/src/views/system/user/UserDrawer.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 128 | 129 | 131 | -------------------------------------------------------------------------------- /www/vite.config.js: -------------------------------------------------------------------------------- 1 | import {defineConfig} from 'vite' 2 | import AutoImport from 'unplugin-auto-import/vite' 3 | import Components from 'unplugin-vue-components/vite' 4 | import {ElementPlusResolver} from 'unplugin-vue-components/resolvers' 5 | import {resolve} from 'path' 6 | import vue from '@vitejs/plugin-vue' 7 | import dynamicImport from 'vite-plugin-dynamic-import' 8 | import Unocss from 'unocss/vite' 9 | import { 10 | presetAttributify, 11 | presetIcons, 12 | presetUno, 13 | transformerDirectives, 14 | transformerVariantGroup, 15 | } from 'unocss' 16 | 17 | // https://vitejs.dev/config/ 18 | export default defineConfig({ 19 | publicDir: 'public', 20 | base: '/', 21 | plugins: [ 22 | vue(), 23 | dynamicImport(), 24 | AutoImport({resolvers: [ElementPlusResolver()]}), 25 | Components({ 26 | resolvers: [ 27 | ElementPlusResolver({ 28 | importStyle: 'sass', 29 | }), 30 | ], 31 | }), 32 | Unocss({ 33 | presets: [ 34 | presetUno(), 35 | presetAttributify(), 36 | presetIcons({ 37 | scale: 1.2, 38 | warn: true, 39 | }), 40 | ], 41 | transformers: [ 42 | transformerDirectives(), 43 | transformerVariantGroup(), 44 | ] 45 | }) 46 | ], 47 | server: { 48 | port: 8080, 49 | host: true, 50 | open: '/', 51 | proxy: {}, 52 | }, 53 | resolve: { 54 | extensions: ['.vue', '.mjs', '.js', '.ts', '.jsx', '.tsx', '.json'], 55 | alias: [ 56 | {find: '@', replacement: resolve(__dirname, 'src')}, 57 | {find: '~/', replacement: resolve(__dirname, 'src/')} 58 | ], 59 | }, 60 | build: { 61 | outDir: 'dist', 62 | minify: 'terser', 63 | terserOptions: { 64 | compress: { 65 | //发布关闭调试模式 66 | drop_console: true, 67 | drop_debugger: true, 68 | }, 69 | }, 70 | }, 71 | css: {} 72 | 73 | }) --------------------------------------------------------------------------------