diff --git a/Dockerfile b/Dockerfile index f1c0026ec..9b60633df 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,10 +5,16 @@ ARG DEBIANSRT=http://mirrors.aliyun.com/debian-security ARG PIP=https://mirrors.aliyun.com/pypi/simple WORKDIR /var/arkid +ENV PYTHONUSERBASE=/var/arkid/arkid_extensions PATH=$PATH:/var/arkid/arkid_extensions/bin + ADD . . RUN sed -i "s@https://mirrors.aliyun.com/pypi/simple@$PIP@g" requirements.txt; \ cat requirements.txt; \ - pip install --no-cache-dir -r requirements.txt; \ - chmod +x docker-entrypoint.sh + pip install --disable-pip-version-check -r requirements.txt; \ + chmod +x docker-entrypoint.sh; \ + for i in `tree -f -i extension_root|grep requirements.txt`; \ + do sed -i "s@https://mirrors.aliyun.com/pypi/simple@$PIP@g" $i; \ + pip install --disable-pip-version-check -r $i; done ; \ + mv pip.conf /etc/pip.conf ENTRYPOINT ["/var/arkid/docker-entrypoint.sh"] CMD ["tini", "--", "/usr/local/bin/python3.8", "manage.py", "runserver", "0.0.0.0:80"] diff --git a/api/v1/pages/developer_manage/api_docs.py b/api/v1/pages/developer_manage/api_docs.py index 5bcf209cf..098535ed1 100644 --- a/api/v1/pages/developer_manage/api_docs.py +++ b/api/v1/pages/developer_manage/api_docs.py @@ -8,5 +8,5 @@ router = FrontRouter( path=tag, name=name, - url='/api/v1/tenant/{tenant_}docs/redoc/' + url='/api/v1/tenant/{tenant_id}/docs/redoc/' ) \ No newline at end of file diff --git a/api/v1/pages/permission_manage/grant_manage/group_grant.py b/api/v1/pages/permission_manage/grant_manage/group_grant.py index 7eb0a8056..8a4bf1d62 100644 --- a/api/v1/pages/permission_manage/grant_manage/group_grant.py +++ b/api/v1/pages/permission_manage/grant_manage/group_grant.py @@ -8,10 +8,12 @@ page = pages.TreePage(tag=tag,name=name) group_permission_page = pages.TablePage(name=_("该分组权限")) update_group_permission_page = pages.TablePage(name=_("更新用户分组权限"),select=True) +show_group_permission_page = pages.TablePage(name=_("查看用户分组最终权限")) pages.register_front_pages(page) pages.register_front_pages(group_permission_page) pages.register_front_pages(update_group_permission_page) +pages.register_front_pages(show_group_permission_page) page.create_actions( init_action=actions.DirectAction( @@ -43,9 +45,20 @@ 'open': actions.OpenAction( name=("添加用户分组权限"), page=update_group_permission_page + ), + 'show': actions.OpenAction( + name=("查看最终权限"), + page=show_group_permission_page ) } ) +show_group_permission_page.create_actions( + init_action=actions.DirectAction( + path='/api/v1/tenant/{tenant_id}/user_group_last_permissions?usergroup_id={usergroup_id}', + method=actions.FrontActionMethod.GET, + ), +) + update_group_permission_page.create_actions( init_action=actions.DirectAction( diff --git a/api/v1/pages/permission_manage/grant_manage/user_grant.py b/api/v1/pages/permission_manage/grant_manage/user_grant.py index 4617362e3..5e21c5eb2 100644 --- a/api/v1/pages/permission_manage/grant_manage/user_grant.py +++ b/api/v1/pages/permission_manage/grant_manage/user_grant.py @@ -8,10 +8,14 @@ page = pages.TreePage(tag=tag,name=name) user_permission_page = pages.TablePage(name=_("该用户权限")) update_user_permission_page = pages.TablePage(name=_("更新用户权限"),select=True) +show_user_permission_page = pages.TreePage(name=_("查看用户最终权限")) +user_app_permission_page = pages.TablePage(name=_("该用户的应用权限")) pages.register_front_pages(page) pages.register_front_pages(user_permission_page) pages.register_front_pages(update_user_permission_page) +pages.register_front_pages(show_user_permission_page) +pages.register_front_pages(user_app_permission_page) page.create_actions( init_action=actions.DirectAction( @@ -39,10 +43,34 @@ 'open': actions.OpenAction( name=("添加用户权限"), page=update_user_permission_page + ), + 'show': actions.OpenAction( + name=("查看最终权限"), + page=show_user_permission_page ) } ) +# 查看最终权限 +show_user_permission_page.create_actions( + init_action=actions.DirectAction( + path='/api/v1/tenant/{tenant_id}/all_apps_in_arkid/', + method=actions.FrontActionMethod.GET, + ), + node_actions=[ + actions.CascadeAction( + page=user_app_permission_page + ) + ] +) +user_app_permission_page.create_actions( + init_action=actions.DirectAction( + path='/api/v1/tenant/{tenant_id}/user_app_last_permissions?user_id={user_id}&app_id={app_id}', + method=actions.FrontActionMethod.GET + ), +) + + update_user_permission_page.create_actions( init_action=actions.DirectAction( path='/api/v1/tenant/{tenant_id}/permissions', diff --git a/api/v1/pages/platform_admin/extension_admin.py b/api/v1/pages/platform_admin/extension_admin.py index fe4ea12b9..d5e512991 100644 --- a/api/v1/pages/platform_admin/extension_admin.py +++ b/api/v1/pages/platform_admin/extension_admin.py @@ -14,6 +14,7 @@ trial_page = pages.FormPage(name=_('Trial', '试用')) bind_agent_page = pages.FormPage(name=_('Bind Agent', '绑定代理商')) purchased_page = pages.CardsPage(name='已购买') +history_page = pages.TablePage(name='插件历史版本') markdown_page = pages.FormPage(name=_("文档")) profile_page = pages.FormPage(name='插件配置') price_page = pages.CardsPage(name='选择价格') @@ -29,6 +30,7 @@ pages.register_front_pages(trial_page) pages.register_front_pages(bind_agent_page) pages.register_front_pages(purchased_page) +pages.register_front_pages(history_page) pages.register_front_pages(markdown_page) pages.register_front_pages(profile_page) pages.register_front_pages(price_page) @@ -108,6 +110,10 @@ name='文档', page=arkstore_markdown_page ), + "history": actions.OpenAction( + name='历史版本', + page=history_page + ), "install": actions.DirectAction( name='安装', path='/api/v1/tenant/{tenant_id}/arkstore/install/{uuid}/', @@ -130,6 +136,20 @@ ) ) +history_page.create_actions( + init_action=actions.DirectAction( + path='/api/v1/tenant/{tenant_id}/arkstore/extensions/{package}/history/', + method=actions.FrontActionMethod.GET, + ), + local_actions={ + "install": actions.DirectAction( + name='安装', + path='/api/v1/tenant/{tenant_id}/arkstore/install/{uuid}/', + method=actions.FrontActionMethod.POST, + ), + } +) + order_page.add_pages([ price_page, copies_page, diff --git a/api/v1/pages/tenant_manage/front_theme.py b/api/v1/pages/tenant_manage/front_theme.py index ac05e9729..e9e4d6421 100644 --- a/api/v1/pages/tenant_manage/front_theme.py +++ b/api/v1/pages/tenant_manage/front_theme.py @@ -24,7 +24,7 @@ page.create_actions( init_action=actions.DirectAction( - path='/api/v1/tenant/{tenant_id}/front_theme/', + path='/api/v1/tenant/{tenant_id}/front_theme_list/', method=actions.FrontActionMethod.GET, ), global_actions={ diff --git a/api/v1/schema/approve_action.py b/api/v1/schema/approve_action.py index 382d5afc3..7b4744c24 100644 --- a/api/v1/schema/approve_action.py +++ b/api/v1/schema/approve_action.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -from typing import List +from typing import List, Optional from pydantic import Field from ninja import Schema, ModelSchema from arkid.core.translation import gettext_default as _ @@ -64,7 +64,7 @@ class ApproveActionExtensionIn(Schema): class ApproveActionSchema(Schema): name: str = Field(title=_('Name', '名称'), default='') - description: str = Field(title=_('Description', '备注'), default='') + description: Optional[str] = Field(title=_('Description', '备注'), default='') path: str = Field( title=_('Path', '请求路径'), type="string", diff --git a/api/v1/schema/mine.py b/api/v1/schema/mine.py index 52adcfb5b..af24dfbc6 100644 --- a/api/v1/schema/mine.py +++ b/api/v1/schema/mine.py @@ -16,14 +16,22 @@ class MineAppsOut(ResponseSchema): data:Optional[List[MineAppItem]] +class ProfileTenantOut(Schema): -class ProfileSchemaOut(ModelSchema): - class Config: - model = User - model_fields = ['id', 'username', 'avatar'] + id:UUID = Field(title='ID', hidden=True) + + slug:str = Field(title='slug', hidden=True) + + name:str = Field(title='name', hidden=True) + + is_platform_tenant:bool = Field(title=_("是否是平台租户"),hidden=True,default=False,readonly=True) + +class ProfileSchemaOut(Schema): id:UUID = Field(title='ID', hidden=True) username:str = Field(title='用户名',readonly=True) + avatar:Optional[str] = Field(title=_('头像')) + tenant:ProfileTenantOut = Field(title=_("租户"),hidden=True) class ProfileSchemaIn(ModelSchema): diff --git a/api/v1/schema/platform_config.py b/api/v1/schema/platform_config.py index bd5fc60ce..4ed583069 100644 --- a/api/v1/schema/platform_config.py +++ b/api/v1/schema/platform_config.py @@ -7,8 +7,16 @@ class PlatformConfigIn(ModelSchema): class Config: model = Platform - model_fields = ['is_saas', 'is_need_rent'] + model_fields = ['is_saas', 'is_need_rent', 'frontend_url'] class PlatformConfigOut(ResponseSchema): data: PlatformConfigIn + + +class FrontendUrlSchema(Schema): + url:str = Field(title=_("ArkId访问地址")) + + +class FrontendUrlOut(ResponseSchema): + data: FrontendUrlSchema \ No newline at end of file diff --git a/api/v1/views/app_protocol.py b/api/v1/views/app_protocol.py index 137ab9f7c..92b5e7148 100644 --- a/api/v1/views/app_protocol.py +++ b/api/v1/views/app_protocol.py @@ -9,7 +9,6 @@ from arkid.core.extension.app_protocol import AppProtocolExtension from arkid.config import get_app_config - @api.get("/tenant/{tenant_id}/app_protocols/",response=List[AppProtocolListItemOut],tags=["应用协议"]) @operation(AppProtocolListOut, roles=[TENANT_ADMIN, PLATFORM_ADMIN]) @paginate(CustomPagination) @@ -18,12 +17,27 @@ def get_app_protocols(request, tenant_id: str): """ rs = [] host = get_app_config().get_frontend_host() + # 拿到默认的系统插件 + qs = Extension.valid_objects.all() + packages = [] + for item in qs: + ext_dir = item.ext_dir + package = item.package + if 'extension_root' in ext_dir and package not in packages: + packages.append(package) + # 插件筛选 for k,v in AppProtocolExtension.composite_schema_map.items(): for p_k,p_v in v.items(): - rs.append({ - "name": k, - "doc_url": f"{host}/arkid/%20系统插件/{p_k.replace('.','_')}/", - "package": p_k - }) - + if p_k in packages: + rs.append({ + "name": k, + "doc_url": f"{host}/docs/%20%20系统插件/{p_k.replace('.','_')}/", + "package": p_k + }) + else: + rs.append({ + "name": k, + "doc_url": f"{host}/docs/%20其它插件/{p_k.replace('.','_')}/", + "package": p_k + }) return rs \ No newline at end of file diff --git a/api/v1/views/arkstore.py b/api/v1/views/arkstore.py index 9ae2108f3..7ef91d6e3 100644 --- a/api/v1/views/arkstore.py +++ b/api/v1/views/arkstore.py @@ -27,6 +27,7 @@ # change_arkstore_agent, # unbind_arkstore_agent, get_arkstore_extension_markdown, + get_arkstore_extension_history_by_package, ) from arkid.common.bind_saas import get_bind_info from arkid.core.api import api, operation @@ -497,10 +498,26 @@ class ExtensionMarkDownOut(ResponseSchema): @api.get("/tenant/{tenant_id}/arkstore/extensions/{uuid}/markdown/", tags=['方舟商店'], response=ExtensionMarkDownOut) -@operation(roles=[TENANT_ADMIN, PLATFORM_ADMIN]) +@operation(roles=[NORMAL_USER, TENANT_ADMIN, PLATFORM_ADMIN]) def get_markdown_arkstore_extension(request, tenant_id: str, uuid: str): token = request.user.auth_token tenant = Tenant.objects.get(id=tenant_id) access_token = get_arkstore_access_token(tenant, token) resp = get_arkstore_extension_markdown(access_token, uuid) return resp + + +class ArkstoreItemHisotryOut(Schema): + uuid: str = Field(hidden=True) + version: str = Field(readonly=True, title=_('Version', '版本')) + + +@api.get("/tenant/{tenant_id}/arkstore/extensions/{package}/history/", tags=['方舟商店'], response=List[ArkstoreItemHisotryOut]) +@operation(List[ArkstoreItemHisotryOut], roles=[TENANT_ADMIN, PLATFORM_ADMIN]) +@paginate(CustomPagination) +def get_arkstore_extension_history(request, tenant_id: str, package: str): + token = request.user.auth_token + tenant = Tenant.objects.get(id=tenant_id) + access_token = get_arkstore_access_token(tenant, token) + resp = get_arkstore_extension_history_by_package(access_token, package) + return resp['items'] diff --git a/api/v1/views/extension.py b/api/v1/views/extension.py index 7258957f1..ea217ddf7 100644 --- a/api/v1/views/extension.py +++ b/api/v1/views/extension.py @@ -7,13 +7,14 @@ from typing_extensions import Annotated from pydantic import Field from django.conf import settings -from arkid.core.constants import PLATFORM_ADMIN, TENANT_ADMIN +from arkid.core.constants import NORMAL_USER, PLATFORM_ADMIN, TENANT_ADMIN from arkid.core.extension import Extension from arkid.core.schema import ResponseSchema from arkid.extension.utils import import_extension from arkid.extension.models import TenantExtensionConfig, Extension as ExtensionModel from arkid.core.error import ErrorCode, ErrorDict from ninja.pagination import paginate +from oauth2_provider.models import Application from arkid.core.pagenation import CustomPagination from arkid.core.models import Tenant from arkid.core.translation import gettext_default as _ @@ -140,8 +141,16 @@ def list_extensions(request, status: str = None): if settings.IS_CENTRAL_ARKID: return qs - token = request.user.auth_token + # 如果未绑定中心arkid, 直接返回 tenant = Tenant.platform_tenant() + bind = Application.objects.filter( + uuid = tenant.id, + name = 'arkid_saas', + ).exists() + if not bind: + return qs + + token = request.user.auth_token access_token = get_arkstore_access_token(tenant, token) # resp = get_arkstore_extensions_purchased(access_token) resp = get_arkstore_list(request, True, 'extension', all=True) @@ -205,7 +214,7 @@ class ExtensionMarkDownOut(ResponseSchema): data:dict = Field(format='markdown',readonly=True) @api.get("/extensions/{id}/markdown/",tags=['平台插件'], response=ExtensionMarkDownOut) -@operation(roles=[TENANT_ADMIN, PLATFORM_ADMIN]) +@operation(roles=[NORMAL_USER, TENANT_ADMIN, PLATFORM_ADMIN]) def get_extension_markdown(request, id: str): """ 获取平台插件的markdown文档""" diff --git a/api/v1/views/front_theme.py b/api/v1/views/front_theme.py index b3dd33eb9..627658451 100644 --- a/api/v1/views/front_theme.py +++ b/api/v1/views/front_theme.py @@ -4,16 +4,18 @@ from django.db.models import F from ninja import Schema from pydantic import Field -from typing import List +from typing import List, Optional +from arkid.core.error import SuccessDict +from arkid.core.pagenation import CustomPagination from arkid.core.api import api, operation from arkid.core.constants import * -from arkid.core.schema import RootSchema +from arkid.core.schema import ResponseSchema, RootSchema from arkid.core.translation import gettext_default as _ from arkid.extension.models import TenantExtensionConfig, Extension from arkid.core.extension.front_theme import FrontThemeExtension from arkid.core.event import dispatch_event, Event, CREATE_FRONT_THEME_CONFIG from arkid.extension.utils import import_extension - +from ninja.pagination import paginate class FrontThemeListSchemaItem(Schema): id:str = Field() name:str = Field(title=_('配置名')) @@ -21,8 +23,13 @@ class FrontThemeListSchemaItem(Schema): type:str = Field(title=_('主题类型')) css_url:str = Field(title=_('CSS文件地址')) priority:int = Field(title=_('优先级')) + +class FrontThemeListOut(ResponseSchema): + data: List[FrontThemeListSchemaItem] -@api.get("/tenant/{tenant_id}/front_theme/", response=List[FrontThemeListSchemaItem], tags=["前端主题"], auth=None) +@api.get("/tenant/{tenant_id}/front_theme_list/", response=List[FrontThemeListSchemaItem], tags=["前端主题"], auth=None) +@operation(FrontThemeListOut, roles=[TENANT_ADMIN, PLATFORM_ADMIN]) +@paginate(CustomPagination) def get_front_theme_list(request, tenant_id: str): """ 前端主题配置列表 """ extensions = Extension.active_objects.filter(type=FrontThemeExtension.TYPE).all() @@ -40,14 +47,40 @@ def get_front_theme_list(request, tenant_id: str): datas.append(data) return datas -GetFrontThemeOut = FrontThemeExtension.create_composite_config_schema('GetFrontThemeOut') +class LoadFrontThemeListOut(ResponseSchema): + data:Optional[List[FrontThemeListSchemaItem]] + +@api.get("/tenant/{tenant_id}/front_theme/", response=LoadFrontThemeListOut, tags=["前端主题"], auth=None) +def load_front_theme_list(request, tenant_id: str): + """ 前端主题配置列表 """ + extensions = Extension.active_objects.filter(type=FrontThemeExtension.TYPE).all() + configs = TenantExtensionConfig.active_objects.filter(extension__in=extensions).annotate(package=F('extension__package')).values('package','id','name','type','config') + datas = [] + for config in configs: + data = { + 'id' : config['id'].hex, + 'package' : config['package'], + 'name' : config['name'], + 'type' : config['type'], + 'priority' : config['config']['priority'], + 'css_url' : config['config']['css_url'], + } + datas.append(data) + return SuccessDict( + data=datas + ) + +GetFrontThemeItemOut = FrontThemeExtension.create_composite_config_schema('GetFrontThemeItemOut') + +class GetFrontThemeOut(ResponseSchema): + data:Optional[GetFrontThemeItemOut] @api.get("/tenant/{tenant_id}/front_theme/{id}/", response=GetFrontThemeOut, tags=["前端主题"]) @operation(roles=[TENANT_ADMIN, PLATFORM_ADMIN]) def get_front_theme(request, tenant_id: str, id: str): """ 获取前端主题配置 """ config = TenantExtensionConfig.active_objects.filter(id=id).annotate(package=F('extension__package')).values('package','id','name','type','config').first() - return config + return SuccessDict(data=config) CreateFrontThemeIn = FrontThemeExtension.create_composite_config_schema( 'CreateFrontThemeIn', diff --git a/api/v1/views/mine.py b/api/v1/views/mine.py index eac546a39..afe17c435 100644 --- a/api/v1/views/mine.py +++ b/api/v1/views/mine.py @@ -29,8 +29,9 @@ def get_mine_apps(request, tenant_id: str): @operation(roles=[NORMAL_USER, TENANT_ADMIN, PLATFORM_ADMIN]) def get_mine_profile(request, tenant_id: str): """我的个人资料""" - user = request.user - user = User.expand_objects.filter(id=user.id).first() + real_user = request.user + user = User.expand_objects.filter(id=real_user.id).first() + user["tenant"] = real_user.tenant return user diff --git a/api/v1/views/permission.py b/api/v1/views/permission.py index a54f19bc8..20ea1871a 100644 --- a/api/v1/views/permission.py +++ b/api/v1/views/permission.py @@ -56,6 +56,17 @@ def list_permissions(request, tenant_id: str, app_id: str = None, select_user_i permissiondata = PermissionData() return permissiondata.get_permissions_by_search(tenant_id, app_id, select_user_id, group_id, login_user, app_name=app_name, category=category) +@api.get("/tenant/{tenant_id}/user_app_last_permissions", response=List[PermissionsListSchemaOut], tags=['权限']) +@operation(roles=[TENANT_ADMIN, PLATFORM_ADMIN]) +@paginate(CustomPagination) +def user_app_last_permissions(request, tenant_id: str, user_id: str = None, app_id: str = None): + ''' + 用户最终结果权限列表 + ''' + login_user = request.user + from arkid.core.perm.permission_data import PermissionData + permissiondata = PermissionData() + return permissiondata.get_user_app_last_permissions(tenant_id, app_id, user_id) @api.get("/tenant/{tenant_id}/childmanager_permissions", response=List[PermissionsListSchemaOut], tags=['权限']) @operation(roles=[TENANT_ADMIN, PLATFORM_ADMIN]) @@ -237,6 +248,17 @@ def list_group_permissions(request, tenant_id: str, select_usergroup_id: str = N permissiondata = PermissionData() return permissiondata.get_group_permissions_by_search(tenant_id, select_usergroup_id, app_name, category) +@api.get("/tenant/{tenant_id}/user_group_last_permissions", response=List[PermissionsListSchemaOut], tags=['权限']) +@operation(roles=[TENANT_ADMIN, PLATFORM_ADMIN]) +@paginate(CustomPagination) +def list_user_group_last_permissions(request, tenant_id: str, usergroup_id: str = None): + ''' + 分组权限最终列表 + ''' + from arkid.core.perm.permission_data import PermissionData + permissiondata = PermissionData() + return permissiondata.get_user_group_last_permissions(tenant_id, usergroup_id) + # @api.post("/tenant/{tenant_id}/permission/{permission_id}/set_open", tags=['权限']) # @operation(roles=[TENANT_ADMIN, PLATFORM_ADMIN]) # def permission_set_open(request, tenant_id: str, permission_id: str): @@ -419,9 +441,10 @@ def permission_toggle_open(request, tenant_id: str, permission_id: str): 切换权限是否打开的状态 ''' permission = SystemPermission.valid_objects.filter( - tenant_id=tenant_id, id=permission_id ).first() + if permission and permission.tenant is None: + return ErrorDict(ErrorCode.SYSTEM_PERMISSION_NOT_OPERATION) if permission is None: permission = Permission.valid_objects.filter(tenant_id=tenant_id, id=permission_id).first() if permission: diff --git a/api/v1/views/platform_config.py b/api/v1/views/platform_config.py index 268a64a0a..15e0c67c4 100644 --- a/api/v1/views/platform_config.py +++ b/api/v1/views/platform_config.py @@ -3,7 +3,7 @@ from arkid.core.translation import gettext_default as _ from api.v1.schema.platform_config import * from arkid.core.models import Platform -from arkid.core.error import ErrorCode, ErrorDict +from arkid.core.error import ErrorCode, ErrorDict, SuccessDict @api.get("/platform_config/",response=PlatformConfigOut, tags=["平台配置"]) @operation(roles=[PLATFORM_ADMIN]) @@ -22,4 +22,30 @@ def update_platform_config(request,data:PlatformConfigIn): setattr(config,key,value) config.save() - return {"error": ErrorCode.OK.value} \ No newline at end of file + return {"error": ErrorCode.OK.value} + +@api.get("/frontend_url/",response=FrontendUrlOut, tags=["平台配置"],auth=None) +def get_frontend_url(request): + """ 获取ArkId访问地址 + """ + config = Platform.get_config() + return SuccessDict( + data={ + "url": config.frontend_url + } + ) + + +@api.post("/frontend_url/",response=FrontendUrlOut, tags=["平台配置"],auth=None) +def set_frontend_url(request,data:FrontendUrlSchema): + """ 获取ArkId访问地址 + """ + config = Platform.get_config() + config.frontend_url = data.dict().get("url") + config.save() + + return SuccessDict( + data={ + "url": config.frontend_url + } + ) diff --git a/api/v1/views/tenant.py b/api/v1/views/tenant.py index 3bffb07c1..2b9e64655 100644 --- a/api/v1/views/tenant.py +++ b/api/v1/views/tenant.py @@ -127,4 +127,16 @@ def logout_tenant(request, tenant_id: str, data:TenantLogoutIn): "slug": platform_tenant.slug }, "refresh": True + } + +@api.get("/tenants/tenant_by_slug/{slug}/", response=TenantOut,tags=["租户管理"], auth=None) +def get_tenant_by_slug(request, slug: str): + tenant = get_object_or_404( + Tenant.expand_objects, + slug=slug, + is_active=True, + is_del=False + ) + return { + "data": tenant } \ No newline at end of file diff --git a/api/v1/views/user.py b/api/v1/views/user.py index cd1343935..529c61842 100644 --- a/api/v1/views/user.py +++ b/api/v1/views/user.py @@ -53,11 +53,17 @@ def user_list_no_super(request, tenant_id: str): def user_create(request, tenant_id: str,data:UserCreateIn): # user = User.expand_objects.create(tenant=request.tenant,**data.dict()) + if User.objects.filter(tenant=request.tenant, username=data.username).count(): + return ErrorDict( + ErrorCode.USERNAME_EXISTS_ERROR + ) + user = User.objects.create(tenant=request.tenant, username=data.username) for key,value in data.dict().items(): if key=='username': continue - setattr(user,key,value) + if value: + setattr(user,key,value) user.save() return {"data":{"user":user.id.hex}} diff --git a/arkid/common/arkstore.py b/arkid/common/arkstore.py index a09c60f14..2a3d446db 100644 --- a/arkid/common/arkstore.py +++ b/arkid/common/arkstore.py @@ -15,6 +15,7 @@ from arkid.extension.models import TenantExtension, Extension from arkid.extension.utils import import_extension, unload_extension, load_extension_apps from pathlib import Path +from arkid.common.logger import logger arkid_saas_token_cache = {} @@ -303,7 +304,12 @@ def download_arkstore_extension(tenant, token, extension_id, extension_detail): with zipfile.ZipFile(io.BytesIO(resp.content)) as zip_ref: zip_ref.extractall(extract_folder) - load_installed_extension(ext_dir) + try: + load_installed_extension(ext_dir) + logger.info(f'load download extension: {ext_package} scuess') + except Exception as e: + logger.exception(f'load download extension: {ext_package} failed: {str(e)}') + return {'success': 'failed'} return {'success': 'true'} @@ -349,8 +355,6 @@ def load_installed_extension(ext_dir): extension = extension, ) - # ext.start() - # 如果新安装的插件有models需重启django extension_models= Path(ext_dir) / 'models.py' if extension_models.exists(): @@ -361,6 +365,8 @@ def load_installed_extension(ext_dir): except Exception as e: print("未使用supervisor启动django server, 需手动重启django server!") + ext.start() + def get_bind_arkstore_agent(access_token): order_url = settings.ARKSTOER_URL + '/api/v1/bind_agent' @@ -614,3 +620,14 @@ def get_arkstore_extension_markdown(access_token, extension_id): raise Exception(f'Error get_arkstore_extension_markdown: {resp.status_code}') resp = resp.json() return resp + + +def get_arkstore_extension_history_by_package(access_token, package): + arkstore_extensions_url = settings.ARKSTOER_URL + f'/api/v1/extensions/package/{package}/history' + headers = {'Authorization': f'Token {access_token}'} + params = {} + resp = requests.get(arkstore_extensions_url, params=params, headers=headers) + if resp.status_code != 200: + raise Exception(f'Error get_arkstore_extension_history_by_package: {resp.status_code}') + resp = resp.json() + return resp diff --git a/arkid/core/api.py b/arkid/core/api.py index 0144fd8f4..032754a8a 100644 --- a/arkid/core/api.py +++ b/arkid/core/api.py @@ -4,6 +4,7 @@ from pydantic.fields import ModelField from arkid.core.translation import gettext_default as _ from ninja import NinjaAPI, Schema +from ninja.errors import HttpError from ninja.security import HttpBearer from ninja.openapi.schema import OpenAPISchema from arkid.common.logger import logger @@ -81,11 +82,11 @@ def authenticate(self, request, token): token = ExpiringToken.objects.get(token=token) if not token.user.is_active: - raise Exception(_('User inactive or deleted','用户无效或被删除')) + raise HttpError(401, _('User inactive or deleted','用户无效或被删除')) tenant = request.tenant or Tenant.platform_tenant() if token.expired(tenant): - raise Exception(_('Token has expired','秘钥已经过期')) + raise HttpError(401, _('Token has expired','秘钥已经过期')) operation_id = request.operation_id if operation_id: @@ -94,13 +95,13 @@ def authenticate(self, request, token): if token.user and tenant: result =permissiondata.api_system_permission_check(request.tenant, token.user, operation_id) if result == False: - raise Exception(_('You do not have api permission','你没有这个接口的权限')) + raise HttpError(403, _('You do not have api permission','你没有这个接口的权限')) except ExpiringToken.DoesNotExist: logger.error(_("Invalid token","无效的秘钥")) return - except Exception as err: - logger.error(err) - return + # except Exception as err: + # logger.error(err) + # return expand_user_dict = User.expand_objects.filter(id=token.user.id).first() request.user = token.user diff --git a/arkid/core/error.py b/arkid/core/error.py index 6b7210a49..89bfc33ae 100644 --- a/arkid/core/error.py +++ b/arkid/core/error.py @@ -11,7 +11,7 @@ class ErrorCode(Enum): # SMS_CODE_MISMATCH = '10002' # EMAIL_CODE_MISMATCH = '10021' - # USERNAME_EXISTS_ERROR = '10004' + USERNAME_EXISTS_ERROR = ('10004', _('username already exists', '用户名已存在')) # TENANT_NO_ACCESS = '10003' @@ -40,6 +40,7 @@ class ErrorCode(Enum): # PASSWORD_EXPIRED_ERROR = '10029' # USER_NOT_IN_TENANT_ERROR = '10030' PERMISSION_EXISTS_ERROR = ('10033', _('the permission not exists', '该权限不存在')) + # APP_EXISTS_ERROR = '10032' PERMISSION_NOT_EDIT = ('10033', _('the permission not edit', '该权限不允许编辑')) PERMISSION_NOT_CLOSE = ('10033', _('the permission not edit', '该权限不允许关闭')) @@ -48,6 +49,7 @@ class ErrorCode(Enum): BAN_REMOVE_GROUP_SCOPE = ('10036', _('ban remove group permission', '该分组范围不允许移除')) PERMISSION_GROUP_NOT_EDIT = ('10037', _('the permission group not edit', '该分组权限不允许编辑')) PERMISSION_GROUP_NOT_DELETE = ('10038', _('the permission group not delete', '该分组权限不允许删除')) + SYSTEM_PERMISSION_NOT_OPERATION = ('10033', _('system permission not operation', '系统权限不支持此操作')) # SMS_PROVIDER_IS_MISSING = '11001' # AUTHCODE_PROVIDER_IS_MISSING = '11002' diff --git a/arkid/core/extension/__init__.py b/arkid/core/extension/__init__.py index 7bc0c8506..92a6470c6 100644 --- a/arkid/core/extension/__init__.py +++ b/arkid/core/extension/__init__.py @@ -160,7 +160,7 @@ def create_config_schema_from_schema_list(schema_cls_name, schema_list, discrimi new_schema_list.append(schema) schema_list = new_schema_list - root_type, root_field = Union[tuple(schema_list)], Field(discriminator=discriminator, depth=depth) + root_type, root_field = Union[tuple(schema_list)], Field(discriminator=discriminator, readonly=True, depth=depth) schema = create_extension_schema_by_package( schema_cls_name, @@ -820,7 +820,8 @@ def register_composite_config_schema(self, schema, composite_value, exclude=[], custom_fields=[ (self.__class__.composite_key, Literal[composite_value], Field()), # type: ignore ("package", Literal[package], Field()), # type: ignore - ("config", schema, Field()), + ("config", schema, Field(title=_("配置内容"))), + ("id",Optional[UUID],Field(hidden=True)), ], ) new_schema.name = name diff --git a/arkid/core/extension/approve_system.py b/arkid/core/extension/approve_system.py index dd9e75359..ca3adea32 100644 --- a/arkid/core/extension/approve_system.py +++ b/arkid/core/extension/approve_system.py @@ -19,9 +19,14 @@ class ApproveRequestOut(ResponseSchema): class ApproveSystemBaseSchema(Schema): - change_status_url: str = Field( + pass_request_url: str = Field( default='', - title=_('Change Approve Request Status Url', '改变审批请求URL'), + title=_('Pass Approve Request Url', '通过审批请求URL'), + readonly=True, + ) + deny_request_url: str = Field( + default='', + title=_('Deny Approve Request Url', '拒绝审批请求URL'), readonly=True, ) diff --git a/arkid/core/extension/external_idp.py b/arkid/core/extension/external_idp.py index b2aa2bfe4..eefebac7d 100644 --- a/arkid/core/extension/external_idp.py +++ b/arkid/core/extension/external_idp.py @@ -230,7 +230,7 @@ def bind(self, request, config_id): # token = refresh_token(user) # data = {"token": token} data = {} - return JsonResponse(data) + return JsonResponse(self.success()) @abstractmethod def get_img_url(self): diff --git a/arkid/core/extension/scim_sync.py b/arkid/core/extension/scim_sync.py index cd53a6053..a2f8415bb 100644 --- a/arkid/core/extension/scim_sync.py +++ b/arkid/core/extension/scim_sync.py @@ -103,7 +103,7 @@ def get_groups_users(self, config): Args: config (arkid.extension.models.TenantExtensionConfig): Client模式创建的配置 """ - sync_server_id = config.config["sync_server_id"] + sync_server_id = config.config.get("sync_server", {}).get("id") server_config = TenantExtensionConfig.active_objects.filter( id=sync_server_id ).first() @@ -306,35 +306,28 @@ def query_groups(self, request, parameters, correlation_identifier): path='/api/v1/tenant/{tenant_id}/scim_server_list/', method=actions.FrontActionMethod.GET, ), - # node_actions=[ - # actions.DirectAction( - # path='/api/v1/tenant/{tenant_id}/app_groups/?parent_id={id}', - # method=actions.FrontActionMethod.GET, - # ) - # ], ) +class SelectServerIn(Schema): + id: str = Field(hidden=True) + name: str + + class BaseScimSyncClientSchema(Schema): - # name: str = Field(default='', title=_('Name', '配置名称')) crontab: str = Field(default='0 1 * * *', title=_('Crontab', '定时运行时间')) max_retries: int = Field(default=3, title=_('Max Retries', '重试次数')) retry_delay: int = Field(default=60, title=_('Retry Delay', '重试间隔(单位秒)')) - # sync_server_name: str = Field(default="", title=_('Sync Server Name', '同步服务的名字')) - sync_server_id: str = Field( + sync_server: SelectServerIn = Field( default="", - title=_('Sync Server ID', 'SCIM同步服务'), - field="id", + title=_('Sync Server', 'SCIM同步服务器'), page=select_scim_server_page.tag, - link="name", - type="string", ) # attr_map: dict = Field(default={}, title=_('Attribute Map', '同步映射关系')) mode: Literal["client"] class BaseScimSyncServerSchema(Schema): - # name: str = Field(title=_('配置名称')) mode: Literal["server"] user_url: str = Field(default="", title=_('User Url', '获取用户URL'), readonly=True) group_url: str = Field(default="", title=_('Group Url', '获取组URL'), readonly=True) diff --git a/arkid/core/migrations/0020_platform_frontend_url.py b/arkid/core/migrations/0020_platform_frontend_url.py new file mode 100644 index 000000000..80e41c982 --- /dev/null +++ b/arkid/core/migrations/0020_platform_frontend_url.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.13 on 2022-08-04 15:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0019_approverequest_request_get_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='platform', + name='frontend_url', + field=models.URLField(blank=True, default='', max_length=128, null=True, verbose_name='ArkId访问地址'), + ), + ] diff --git a/arkid/core/models.py b/arkid/core/models.py index 6059ab420..a84635040 100644 --- a/arkid/core/models.py +++ b/arkid/core/models.py @@ -15,6 +15,9 @@ class EmptyModel(models.Model): class Platform(BaseModel, ExpandModel): is_saas = models.BooleanField(default=False, verbose_name=_('SaaS Switch', '多租户开关')) is_need_rent = models.BooleanField(default=False, verbose_name=_('Is Tenant Need Rent Extension', '租户是否需要租赁插件')) + frontend_url = models.URLField( + verbose_name='ArkId访问地址', max_length=128, blank=True, null=True, default='' + ) @classmethod def get_config(cls): diff --git a/arkid/core/perm/permission_data.py b/arkid/core/perm/permission_data.py index c708668c6..687bb0e09 100644 --- a/arkid/core/perm/permission_data.py +++ b/arkid/core/perm/permission_data.py @@ -1268,7 +1268,7 @@ def get_permissions_by_search(self, tenant_id, app_id, user_id, group_id, login_ tenant_id=tenant_id, app__isnull=False ) - flag = True + permission_results = [] for userpermissionresult in userpermissionresults: permission_sort_ids = [] if userpermissionresult: @@ -1278,9 +1278,11 @@ def get_permissions_by_search(self, tenant_id, app_id, user_id, group_id, login_ if int(item) == 1: permission_sort_ids.append(index) if permission_sort_ids: - flag = False - permissions = permissions.filter(sort_id__in=permission_sort_ids) - if flag: + for item in permissions.filter(app=userpermissionresult.app, sort_id__in=permission_sort_ids): + permission_results.append(item.id) + if permission_results: + permissions = permissions.filter(id__in=permission_results) + else: permissions = permissions.filter(id__isnull=True) if group_id: usergroup = UserGroup.valid_objects.filter(id=group_id).first() @@ -1295,7 +1297,115 @@ def get_permissions_by_search(self, tenant_id, app_id, user_id, group_id, login_ else: systempermissions = systempermissions.filter(Q(tenant__isnull=True)|Q(tenant_id=tenant_id)) return list(systempermissions.all())+list(permissions.all()) - + + def get_user_app_last_permissions(self, tenant_id, app_id, user_id): + ''' + 获取用户指定应用的最终权限 + ''' + user = User.valid_objects.filter(id=user_id).first() + if user is None: + return [] + compress = Compress() + userpermissionresults = UserPermissionResult.valid_objects.filter( + user=user, + tenant_id=tenant_id, + ) + if app_id == 'arkid': + app_id = None + if app_id: + userpermissionresult = userpermissionresults.filter( + app_id=app_id, + ).first() + else: + userpermissionresult = userpermissionresults.filter( + app__isnull=True, + ).first() + if userpermissionresult: + permission_result = self.get_permission_str_process(userpermissionresult, tenant_id, False) + # 将结果字符串转化为权限列表 + permission_result_list = list(permission_result) + index_list = [] + for index,value in enumerate(permission_result_list): + if int(value) == 1: + index_list.append(index) + if len(index_list) == 0: + index_list.append(-1) + # 筛选出需要的显示的权限 + if app_id: + return Permission.valid_objects.filter( + app_id=app_id, + sort_id__in=index_list + ).order_by('sort_id') + else: + return SystemPermission.valid_objects.filter( + sort_id__in=index_list + ).order_by('sort_id') + else: + return [] + + def get_user_group_last_permissions(self, tenant_id, usergroup_id): + ''' + 获取用户分组的最终权限 + ''' + permissions = Permission.valid_objects.filter( + tenant_id=tenant_id, + app__is_del=False + ) + systempermissions = SystemPermission.valid_objects.filter( + Q(tenant__isnull=True)|Q(tenant_id=tenant_id) + ) + compress = Compress() + usergroup = UserGroup.valid_objects.filter( + id=usergroup_id + ).first() + all_groups = [] + # 取得当前分组的所有父分组 + all_groups.append(usergroup) + all_groups.extend(self.get_user_all_groups(usergroup, [])) + # 取得所有分组拥有的系统权限 + usergroup_permissionresults = GroupPermissionResult.valid_objects.filter( + user_group__in=all_groups, + tenant_id=tenant_id, + app=None + ) + permission_sort_ids = [] + for usergroup_permissionresult in usergroup_permissionresults: + permission_result = compress.decrypt(usergroup_permissionresult.result) + permission_result_arr = list(permission_result) + for index, item in enumerate(permission_result_arr): + if int(item) == 1 and index not in permission_sort_ids: + permission_sort_ids.append(index) + if len(permission_sort_ids) == 0: + systempermissions = systempermissions.filter(id__isnull=True) + else: + systempermissions = systempermissions.filter(sort_id__in=permission_sort_ids) + # 取得所有分组拥有的应用权限 + usergroup_permissionresults = GroupPermissionResult.valid_objects.filter( + user_group__in=all_groups, + tenant_id=tenant_id, + app__isnull=False + ) + permission_results = [] + for usergroup_permissionresult in usergroup_permissionresults: + permission_sort_ids = [] + if usergroup_permissionresult: + permission_result = compress.decrypt(usergroup_permissionresult.result) + permission_result_arr = list(permission_result) + for index, item in enumerate(permission_result_arr): + if int(item) == 1: + permission_sort_ids.append(index) + + if permission_sort_ids: + for item in permissions.filter(app=usergroup_permissionresult.app, sort_id__in=permission_sort_ids): + if item.id not in permission_results: + permission_results.append(item.id) + if permission_results: + permissions = permissions.filter(id__in=permission_results) + else: + permissions = permissions.filter(id__isnull=True) + return list(systempermissions)+list(permissions) + + def get_permissions_by_mine_search(self, tenant_id, app_id, user_id, group_id, login_user, parent_id=None, is_only_show_group=False, app_name=None, category=None): ''' 根据应用,用户,分组查权限(要根据用户身份显示正确的列表) @@ -1523,7 +1633,7 @@ def get_group_permissions_by_search(self, tenant_id, select_usergroup_id, app_na tenant_id=tenant_id, app__isnull=False ) - flag = True + permission_results = [] for usergroup_permissionresult in usergroup_permissionresults: permission_sort_ids = [] if usergroup_permissionresult: @@ -1532,14 +1642,15 @@ def get_group_permissions_by_search(self, tenant_id, select_usergroup_id, app_na for index, item in enumerate(permission_result_arr): if int(item) == 1: permission_sort_ids.append(index) + if permission_sort_ids: - flag = False - permissions = permissions.filter(app=usergroup_permissionresult.app, sort_id__in=permission_sort_ids) - if flag: + for item in permissions.filter(app=usergroup_permissionresult.app, sort_id__in=permission_sort_ids): + permission_results.append(item.id) + if permission_results: + permissions = permissions.filter(id__in=permission_results) + else: permissions = permissions.filter(id__isnull=True) return list(systempermissions)+list(permissions) - - def get_permission_str(self, user, tenant_id, app_id, is_64=False): ''' diff --git a/arkid/urls.py b/arkid/urls.py index 929ecc011..3581eeaad 100644 --- a/arkid/urls.py +++ b/arkid/urls.py @@ -22,6 +22,7 @@ from arkid.redoc import view as redoc_view from scim_server import urls as scim_urls from arkid.core.path import API_PATH_HEAD +from django.http import HttpResponse urlpatterns = [ @@ -32,6 +33,7 @@ path(f"{API_PATH_HEAD}/redoc", redoc_view.Redoc.as_view()), path(f"{API_PATH_HEAD}/openapi_redoc.json", redoc_view.RedocOpenAPI.as_view()), path(f"{API_PATH_HEAD}/", include('oauth2_provider.urls', namespace='oauth2_provider')), + path(f"{API_PATH_HEAD}/ping/", lambda _: HttpResponse('pong'), name='ping'), ] diff --git "a/docs/ \345\225\206\344\270\232\345\220\210\344\275\234\346\214\207\345\215\227/ \345\274\200\345\217\221\345\225\206.md" "b/docs/ \345\225\206\344\270\232\345\220\210\344\275\234\346\214\207\345\215\227/ \345\274\200\345\217\221\345\225\206.md" similarity index 100% rename from "docs/ \345\225\206\344\270\232\345\220\210\344\275\234\346\214\207\345\215\227/ \345\274\200\345\217\221\345\225\206.md" rename to "docs/ \345\225\206\344\270\232\345\220\210\344\275\234\346\214\207\345\215\227/ \345\274\200\345\217\221\345\225\206.md" diff --git "a/docs/ \345\225\206\344\270\232\345\220\210\344\275\234\346\214\207\345\215\227/ \344\273\243\347\220\206\345\225\206.md" "b/docs/ \345\225\206\344\270\232\345\220\210\344\275\234\346\214\207\345\215\227/ \344\273\243\347\220\206\345\225\206.md" similarity index 100% rename from "docs/ \345\225\206\344\270\232\345\220\210\344\275\234\346\214\207\345\215\227/ \344\273\243\347\220\206\345\225\206.md" rename to "docs/ \345\225\206\344\270\232\345\220\210\344\275\234\346\214\207\345\215\227/ \344\273\243\347\220\206\345\225\206.md" diff --git "a/docs/ \345\225\206\344\270\232\345\220\210\344\275\234\346\214\207\345\215\227/ \350\277\220\350\220\245\345\225\206.md" "b/docs/ \345\225\206\344\270\232\345\220\210\344\275\234\346\214\207\345\215\227/ \350\277\220\350\220\245\345\225\206.md" similarity index 100% rename from "docs/ \345\225\206\344\270\232\345\220\210\344\275\234\346\214\207\345\215\227/ \350\277\220\350\220\245\345\225\206.md" rename to "docs/ \345\225\206\344\270\232\345\220\210\344\275\234\346\214\207\345\215\227/ \350\277\220\350\220\245\345\225\206.md" diff --git "a/docs/ \345\225\206\344\270\232\345\220\210\344\275\234\346\214\207\345\215\227/index.md" "b/docs/ \345\225\206\344\270\232\345\220\210\344\275\234\346\214\207\345\215\227/index.md" similarity index 100% rename from "docs/ \345\225\206\344\270\232\345\220\210\344\275\234\346\214\207\345\215\227/index.md" rename to "docs/ \345\225\206\344\270\232\345\220\210\344\275\234\346\214\207\345\215\227/index.md" diff --git "a/docs/ \345\225\206\344\270\232\345\220\210\344\275\234\346\214\207\345\215\227/\346\224\266\347\233\212\350\256\241\347\256\227\345\231\250.md" "b/docs/ \345\225\206\344\270\232\345\220\210\344\275\234\346\214\207\345\215\227/\346\224\266\347\233\212\350\256\241\347\256\227\345\231\250.md" similarity index 100% rename from "docs/ \345\225\206\344\270\232\345\220\210\344\275\234\346\214\207\345\215\227/\346\224\266\347\233\212\350\256\241\347\256\227\345\231\250.md" rename to "docs/ \345\225\206\344\270\232\345\220\210\344\275\234\346\214\207\345\215\227/\346\224\266\347\233\212\350\256\241\347\256\227\345\231\250.md" diff --git "a/docs/ \345\274\200\345\217\221\350\200\205\346\214\207\345\215\227/ \346\217\222\344\273\266\346\214\207\345\215\227/\345\211\215\347\253\257.md" "b/docs/ \345\274\200\345\217\221\350\200\205\346\214\207\345\215\227/ \346\217\222\344\273\266\346\214\207\345\215\227/\345\211\215\347\253\257.md" new file mode 100644 index 000000000..b059c85db --- /dev/null +++ "b/docs/ \345\274\200\345\217\221\350\200\205\346\214\207\345\215\227/ \346\217\222\344\273\266\346\214\207\345\215\227/\345\211\215\347\253\257.md" @@ -0,0 +1,123 @@ +# 前端 + +根据上述配置信息结构说明,我们需要在后端接口(/api/v1/openapi.json)中对应生成页面配置数据以供前端解析,以下是基础的代码步骤: + +## 创建一个page + +``` python + +from arkid.core import pages + +user_list_tag = 'user_list' +user_list_name = '用户列表' + + +page = pages.FrontPage( + tag=user_list_tag, + name=user_list_name, + page_type=pages.FrontPageType.TABLE_PAGE, + init_action=pages.FrontAction( + path='/api/v1/tenant/{tenant_id}/users/', + method=pages.FrontActionMethod.GET + ) +) + +``` + +## 为page添加action + +``` python +... + +page.add_local_action( + [ + pages.FrontAction( + name=_("删除"), + method=pages.FrontActionMethod.DELETE, + path="/api/v1/tenant/{tenant_id}/users/{id}/", + icon="icon-delete", + action_type=pages.FrontActionType.DIRECT_ACTION + ) + ] +) + +... + +``` + +## 将page注册到全局 + +``` python + +pages.register_front_pages(page) + +``` + +## 写入路由信息 + +``` python +from arkid.core import routers + +user_list_router = routers.FrontRouter( + path="user_list", + name='用户管理', + icon='user', + page=page, +) + +router = routers.FrontRouter( + path='user', + name='用户管理', + icon='user', + children=[ + user_list_router, + ], +) + +``` + +## 最后在访问/api/v1/openapi.json接口时可得到数据为: + +``` json +{ + ... + "routers": [ + { + "path": "user", + "name": "用户管理", + "icon": "user", + "children": [ + { + "path": "user_list", + "name": "用户管理", + "icon": "user", + "page": "user_list" + } + ] + } + ], + "pages": [ + { + "tag": "user_list", + "name": "用户列表", + "type": "table", + "init": { + "tag": "16058e11df284ae1a58fd1220a85e501", + "path": "/api/v1/tenant/{tenant_id}/users/", + "method": "get" + }, + "local": [ + { + "tag": "9181f711ffcb4ac5b5a793e043468595", + "name": "删除", + "path": "/api/v1/tenant/{tenant_id}/users/{id}/", + "method": "delete", + "icon": "icon-delete", + "type": "direct" + } + ], + }, + ] +} +... +``` diff --git "a/docs/ \345\274\200\345\217\221\350\200\205\346\214\207\345\215\227/ \346\217\222\344\273\266\346\214\207\345\215\227/\345\211\215\347\253\257\347\225\214\351\235\242.md" "b/docs/ \345\274\200\345\217\221\350\200\205\346\214\207\345\215\227/ \346\217\222\344\273\266\346\214\207\345\215\227/\345\211\215\347\253\257\347\225\214\351\235\242.md" deleted file mode 100644 index e62f9f954..000000000 --- "a/docs/ \345\274\200\345\217\221\350\200\205\346\214\207\345\215\227/ \346\217\222\344\273\266\346\214\207\345\215\227/\345\211\215\347\253\257\347\225\214\351\235\242.md" +++ /dev/null @@ -1,597 +0,0 @@ -## 前端界面 - -- 介绍怎样通过 openapi.json 的接口读取,生成前端页面。 -- 说明 openapi.json 中与前端界面生成相关的配置规则。 - -!!! 提示 - 在前端登录成功之后,openapi.json 接口会很早被调用,以便获取其中的内容来生成前端界面。接口返回的数据中包含 components、info、openapi、pages、paths、permissions、routers、translation 等字段信息。 - -### 路由(routers) - -其主要声明前端路由信息,通过读取解析routers中内容,生成前端侧边栏等内容。其中page指向openapi.json接口返回数据pages中的某个页面配置。一共可以分为两种类别: - -* 有Children的路由 -* 无Children的路由 - -#### 字段声明 - -| 关键字 | 含义 | 类型 | -| --- | --- | --- | -| path | 路径,使用/字符进行叠加 | string | -| name | 侧边栏路由名称 | string | -| icon | 侧边栏路由图标,与前端配合 | string | -| hidden | 是否隐藏,如果为true,则不展示在前端侧边栏中 | boolean | -| page | 该路由需要渲染的页面,具体可以参考pages说明 | string | -| web | 是否为电脑端侧边栏,并含有顺序 | number | -| mobile | 是否为手机端底边栏,并含有顺序 | number | - -#### children router - -> 特别声明: 该类别的路由,最外层中的字段不包含page声明。 - -```json title='示例' -{ - "path": "mine", - "name": "我的", - "icon": "mine", - "hidden": true, - "children": [ - { - "path": "profile", - "name": "个人管理", - "page": "mine_profile", - "icon": "profile" - }, - { - "path": "logout", - "name": "退出登录", - "page": "mine_logout", - "icon": "logout", - }, - ] -} -``` - -#### no children router - -```json title='示例' -{ - "path": "mine", - "name": "我的", - "icon": "mine", - "hidden": true, - "page": "mine", -} -``` - -### 页面(pages) - -该模块的了解涉及到对前端界面的认知和从OpenAPI到前端界面的配置总体认知。 - -#### 初识页面 - -页面有哪些内容组成呢。下面的字段列表给出了我们所需的所有内容。 - -| 关键字 | 含义 | 功能 | 附加说明 | -| --- | --- | --- | --- | -| type | 页面类型 | 生成前端页面模板 | 具体参考下面的页面类型说明 | -| tag | 页面标识符 | 匹配唯一页面配置 | 不能重名,不使用.等特殊字符 | -| name | 页面名称 | - | - | -| init_action | 页面初始化操作 | 获取schema和数据 | 具体参考init_action详细说明 | -| init_data | 初始化数据 | 初始化后的赋值操作 | 默认从父级数据池开始查找 | -| global_action | 页面全局操作 | 生成全局按钮 | 具体参考global_action详细说明 | -| local_action | 页面局部操作 | 生成局部按钮 | 具体参考local_action详细说明 | -| node_action | 页面节点操作 | 生成树节点操作 | 仅存在于tree页面中,具体参考node_action详细说明 | -| select | 是否为可选页面 | 生成可选页面 | form页面中无需该字段 | -| pages | tabs/step多页面指向 | 生成tabs/step多页面 | 只存在于tabs/step页面中 | - -!!! attention "重要提示" - tag 不能重复使用,可以能使用 **.** 字符,不能使用 **[** 和 **]** 字符。 - -#### 页面类型 - -上述type字段目前只支持以下几种类型的页面。其中grid和list正在开发中,暂无效果。 - -| 类型 | 名称 | 附加说明 | -| --- | --- | :---: | -| table | 表格型页面 | ✔ | -| form | 表单型页面 | ✔ | -| tree | 树状性页面 | ✔ | -| tabs | 切换型页面 | ✔ | -| description | 描述型页面 | ✔ | -| cards | 卡片型页面 | ✔ | -| grid | 网格型页面 | ✘ | -| list | 列表型页面 | ✘ | -| step | 步骤型页面 | ✔ | - -#### 操作类型 - -| 关键字 | 名称 | 详情 | 附加说明 | -| --- | --- | --- | :---: | -| direct | 直接型 | 常见于删除或树节点点击获取Children等操作 | ✔ | -| open | 弹框型 | 打开对话框,展示新的一个类型页面,常见于创建或编辑等操作 | ✔ | -| cascade | 级联型 | 常见于树状型页面的级联页面使用,当点击某个节点时, 出现与之并列的数据展示页面 | ✔ | -| import | 导入型 | 导入文件或数据时使用 | ✘ | -| export | 导出型 | 导出文件或数据时使用,全导出或部分导出 | ✘ | -| next | 步骤型 | 点击继续下一步操作,根据情况自动添加上一步按钮 | ✔ | - -!!! 提示 - 具体操作类型的使用见下方。 - -#### 操作配置 - -操作的主要功能为匹配schema和增删改查数据。 - -| 关键字 | 含义 | 功能 | -| --- | --- | --- | -| tag | 操作标签 | 可用于前端操作名称 | -| path | API接口 | 用于匹配schema和获取数据 | -| method | API接口方法 | 同上 | -| type | 操作类型(见上方具体说明)| 方便识别操作 | -| page | 页面tag | 当且仅当类型为open时,有时需指向要打开页面的唯一标识 | -| name | 操作名称 | 前端页面按钮名,node_action无需包含该内容 | -| icon | 图标 | 可选,目前暂未支持 | -| close | 关闭条件 | 待更新优化 | -| open | 打开条件 | 待更新优化 | - - -!!! info "温馨提示" - 具体可以参考后端生成或django-ninja等框架说明 - -#### init_action - -init_action主要完成页面组成的初始化以及数据的获取。 - -在声明该部分的内容时,无需声明操作的类型。 - -```json title='示例' -{ - "init_action": { - "path": "xxx", - "method": "get", - } -} -``` - -#### global_action - -global_action主要完成页面全局按钮的生成,以及对应其操作的初始化。 - -在声明该部分的内容时,应说明每一个操作的类型。 - -```json title='示例' -{ - "global_action": { - "create": { - "name": "新建", - "path": "", - "method": "post", - "type": "open", - "page": "", - }, - "import": { - "name": "导入", - "path": "", - "method": "post", - "type": "import", - }, - "export": { - "name": "导出", - "path": "", - "method": "post", - "type": "direct", - }, - } -} -``` - -!!! 提示 - 由于创建时,只需要post方法的接口,所以无需再声明page字段内容。此时在前端的页面生成逻辑中,将会自动解析和处理。 - -#### local_action - -local_action主要完成页面全局按钮的生成,以及对应其操作的初始化。 - -在声明该部分的内容时,应说明每一个操作的类型。 - -```json title='示例' -{ - "local_action": { - "edit": { - "name": "编辑", - "type": "open", - "page": "edit_page", - }, - "delete": { - "name": "删除", - "type": "direct", - "path": "", - "method": "", - } - } -} -``` - -#### node_action - -node_action主要完成页面树节点的生成。功能主要为获取子节点和级联页面的数据。 - -在声明该部分的内容时,应说明操作的类型。 - -!!! 提示 - 树页面和级联页面(如果存在的话),在执行完该页面的init_action之后,如果有树节点数据,会默认点击第一个节点,自动执行一次该node_action中的所有操作。 - -```json title='示例' -{ - "node_action": [ - { - "path": "", - "method": "", - "type": "direct", - }, - { - "type": "cascade", - "page": "", - }, - ] -} -``` - -#### prop_action - -!!! attention "注意" - 该模块的操作并不是页面配置中的某个字段。item_action或suffix_action为某个页面元素的直接操作。该类型的操作均应当声明到schmea的描述之中,在生成页面的过程中直接赋予某个页面属性所独有。 - -| 关键字 | 含义 | 功能描述 | -| --- | --- | --- | -| item_action | 某个元素的直接操作 | switch(change)或input(blur)或select(options)时将会执行该操作 | -| suffix_action | 某个元素的后缀操作 | 比如发送验证码等操作 | -| option_action | 下拉选项操作 | 下拉动态数据的获取 | - -```json title='示例1 - item_action' -{ - "phone_number": { - "title": "主题", - "type": "string", - "item_action": { - "path": "", - "method": "", - } - } -} -``` - -```json title='示例2 - suffix_action' -{ - "phone_number": { - "title": "手机号码", - "type": "string", - "suffix_action": { - "path": "", - "method": "post", - "delay": 60, - } - } -} -``` - -#### 解析操作配置 - -读了上述的文档之后,对页面操作配置有了一定的了解,那么,前端如何解析操作呢? - -使用操作中的path和method在openapi.json接口返回的paths中找到对应的schema指向。如果method为get的话,则读取其中的responses信息中的schema指向;否则,读取requestBody信息中的schema指向。 - -通过schema的指向,再继续寻找components.schema中对应的描述信息。再继续通过每一项不同的属性声明,来生成前端不同的组件模板,继而组成一个完整的页面。 - -描述页面中的类型说明: - -| 关键字 | 类型 | 附加说明 | -| --- | --- | --- | -| string | 字符串 | input | -| boolean | 布尔值 | switch | -| integer | 整型 | input-number | -| array | 数组 | select | -| object | 对象 | form-object | -| format | 指定类型 | 根据不同format值生成,比如date/autocomplete/dynamic等 | - -### 页面配置 - -#### 表格型页面(table) - -!!! 提示 - 某些页面中存在filter过滤器,该部分内容是通过paths中声明的parameters的内容解析得来。不包含in=path的参数,也同时排除了page和page_size两个分页器参数。 - -```json title='示例' -{ - "type": "table", - "tag": "", - "name": "用户列表", - "modal": false, - "init_action": { - "path": "", - "method": "", - }, - "global_action": { - "create": { - "name": "新建", - "type": "open", - "path": "", - "method": "post", - }, - "import": { - "name": "导入", - "type": "import", - "path": "", - "method": "post", - } - }, - "local_action": { - "edit": { - "name": "编辑", - "type": "open", - "page": "", - }, - "set_password": { - "name": "密码设置", - "type": "open", - "page": "", - }, - "delete": { - "name": "删除", - "type": "direct", - "path": "", - "method": "delete" - } - } -} -``` - -#### 表单型页面(form) - -!!! 提示 - 该类型页面没有local_action声明。 - -```json title='示例' -{ - "type": "form", - "tag": "", - "name": "重置密码", - "modal": true, - "init_action": { - "path": "", - "method": "get", - }, - "global_action": { - "edit": { - "name": "确认", - "path": "", - "method": "post", - }, - }, -} -``` - -#### 树状页面(tree) - -```json title='示例' -{ - "type": "tree", - "tag": "", - "name": "用户分组", - "init_action": { - "path": "", - "method": "get", - }, - "node_action": [ - { - "path": "", - "method": "get", - "type": "direct" - }, - { - "page": "", - "type": "cascade", - } - ], - "global_action": { - "create": { - "name": "新建", - "type": "open", - "path": "", - "method": "post", - }, - "import": { - "name": "导入", - "type": "import", - "path": "", - "method": "post", - } - }, - "local_action": { - "edit": { - "name": "编辑", - "type": "open", - "page": "", - }, - "delete": { - "name": "删除", - "type": "direct", - "path": "", - "method": "delete" - } - }, -} -``` - -#### 切换型页面(tabs) - -```json title='示例' -{ - "type": "tabs", - "tag": "", - "name": "插件管理", - "pages": [ "", "", "" ], -} -``` - -#### 卡片型页面(cards) - -!!! 提示 - 该类型的页面的init_action的操作返回的数据可能具有自定义的特点,所以在某些时候需要与前端配合使用。 - -```json title='示例' -{ - "type": "cards", - "tag": "", - "name": "应用集", - "init_action": { - "path": "", - "method": "get", - }, -} -``` - -#### 描述型页面(description) - -```json title='示例' -{ - "type": "description", - "tag": "", - "name": "个人资料", - "init_action": { - "path": "", - "method": "get", - }, -} -``` - -#### 网格型页面(grid) - -!!! attention 注意 - 开发测试中,暂无效果。 - -#### 列表型页面(list) - -!!! attention 注意 - 开发测试中,暂无效果。 - - -## 后端配置说明 - -根据上述配置信息结构说明,我们需要在后端接口(/api/v1/openapi.json)中对应生成页面配置数据以供前端解析,以下是基础的代码步骤: - -1. 创建一个page - -``` python - -from arkid.core import pages - -user_list_tag = 'user_list' -user_list_name = '用户列表' - - -page = pages.FrontPage( - tag=user_list_tag, - name=user_list_name, - page_type=pages.FrontPageType.TABLE_PAGE, - init_action=pages.FrontAction( - path='/api/v1/tenant/{tenant_id}/users/', - method=pages.FrontActionMethod.GET - ) -) - -``` - -2. 为page添加action -``` python -... - -page.add_local_action( - [ - pages.FrontAction( - name=_("删除"), - method=pages.FrontActionMethod.DELETE, - path="/api/v1/tenant/{tenant_id}/users/{id}/", - icon="icon-delete", - action_type=pages.FrontActionType.DIRECT_ACTION - ) - ] -) - -... - -``` - -3. 将page注册到全局 -``` python - -pages.register_front_pages(page) - -``` - -4. 写入路由信息 -``` python -from arkid.core import routers - -user_list_router = routers.FrontRouter( - path="user_list", - name='用户管理', - icon='user', - page=page, -) - -router = routers.FrontRouter( - path='user', - name='用户管理', - icon='user', - children=[ - user_list_router, - ], -) - -``` - -5. 最后在访问/api/v1/openapi.json接口时可得到数据为: -``` json -{ - ... - "routers": [ - { - "path": "user", - "name": "用户管理", - "icon": "user", - "children": [ - { - "path": "user_list", - "name": "用户管理", - "icon": "user", - "page": "user_list" - } - ] - } - ], - "pages": [ - { - "tag": "user_list", - "name": "用户列表", - "type": "table", - "init": { - "tag": "16058e11df284ae1a58fd1220a85e501", - "path": "/api/v1/tenant/{tenant_id}/users/", - "method": "get" - }, - "local": [ - { - "tag": "9181f711ffcb4ac5b5a793e043468595", - "name": "删除", - "path": "/api/v1/tenant/{tenant_id}/users/{id}/", - "method": "delete", - "icon": "icon-delete", - "type": "direct" - } - ], - }, - ] -} -... -``` \ No newline at end of file diff --git "a/docs/ \345\274\200\345\217\221\350\200\205\346\214\207\345\215\227/\345\217\202\350\200\203\346\226\207\346\241\243/OpenAPI Plus.md" "b/docs/ \345\274\200\345\217\221\350\200\205\346\214\207\345\215\227/\345\217\202\350\200\203\346\226\207\346\241\243/OpenAPI Plus.md" index e69de29bb..c4563a693 100644 --- "a/docs/ \345\274\200\345\217\221\350\200\205\346\214\207\345\215\227/\345\217\202\350\200\203\346\226\207\346\241\243/OpenAPI Plus.md" +++ "b/docs/ \345\274\200\345\217\221\350\200\205\346\214\207\345\215\227/\345\217\202\350\200\203\346\226\207\346\241\243/OpenAPI Plus.md" @@ -0,0 +1,575 @@ +# OpenAPI Plus + +OpenAPI-Plus 使用[`django-ninja`](https://github.com/vitalik/django-ninja)和[`pydantic`](https://github.com/samuelcolvin/pydantic)进行功能扩展,以达到适配 ArkID 一账通项目前端生成的目的。 + +OpenAPI-Plus 主要讲述对其进行了哪些扩展,而这些扩展又有什么样的含义存在呢?请继续阅读文档。 + +## 扩展一: 路由(routers) + +其主要声明前端路由信息,通过读取解析 routers 中内容,生成前端侧边栏等内容。一共可以分为两种类别: + +- 有 Children 的路由 +- 无 Children 的路由 + +| 关键字 | 含义 | 类型 | +| ------ | ---------------------------------------------------------------------- | --------------- | +| path | 前端访问路径,使用/字符进行叠加 | string | +| name | 侧边栏路由名称 | string | +| icon | 侧边栏路由图标,需与前端配合声明 | string | +| hidden | 是否隐藏,如果为 true,则不展示在前端侧边栏中 | boolean | +| page | 该路由需要渲染的页面,具体信息指向 [pages](#pages) 扩展 | string | +| url | 直接访问该URL接口,默认为GET,接口返回新的url地址,放入iframe中显示 | string | +| web | 是否为电脑端侧边栏,可以含有顺序 | number, boolean | +| mobile | 是否为手机端底边栏,并含有顺序 | number, boolean | + +!!! attention "重要声明" + 01. path 不能使用 `.` 等特殊字符。 + 02. page 不能使用 `[ ] { }` 等特殊字符。 + +```json title='有Children路由示例' +{ + "path": "mine", + "name": "我的", + "icon": "mine", + "hidden": true, + "children": [ + { + "path": "profile", + "name": "个人管理", + "page": "mine_profile", + "icon": "profile" + }, + { + "path": "logout", + "name": "退出登录", + "page": "mine_logout", + "icon": "logout" + } + ] +} +``` + +```json title='无Children路由示例' +{ + "path": "mine", + "name": "我的", + "icon": "mine", + "hidden": true, + "page": "mine" +} +``` + +## 扩展二: 页面配置(pages) + +声明前端页面生成所需要的配置信息,前端将会根据OpenAPI返回的该项内容进行解析处理,从而生成前端各种类型的页面。下面将详细说明每一项配置的含义以及对应怎么表达和展示在前端。 + +!!! info "问题" + 01. 生成一个表格或表单页面,需要那些配置信息呢? + 02. 生成一个树结构页面呢? + 03. 页面之间有什么区别呢? + +### 页面类型 + +针对以上问题,先给出我们目前`已支持 ✔`或`待支持 ✘`的页面类型。 + +| 类型 | 名称 | 支持性 | +| --- | --- | --- | +| table | 表格型页面 | ✔ | +| form | 表单型页面 | ✔ | +| tree | 树状性页面 | ✔ | +| tabs | 切换型页面 | ✔ | +| description | 描述型页面 | ✔ | +| cards | 卡片型页面 | ✔ | +| grid | 网格型页面 | ✘ | +| list | 列表型页面 | ✘ | +| step | 步骤型页面 | ✔ | + +### 页面配置 + +对页面中所需的配置进行说明,以支持上述各类型页面的生成情形。 + +| 关键字 | 含义 | 功能 | 附加说明 | +| --- | --- | --- | --- | +| type | 页面类型 | 生成前端页面模板 | 具体参考下面的页面类型说明 | +| tag | 页面标识符 | 匹配唯一页面配置 | 页面唯一标签 | +| name | 页面名称 | 对应显示前端页面标题 | 需支持中英文 | +| init_action | [页面初始化操作](#_6) | 获取schema和数据 | 具体参考init_action详细说明 | +| init_data | 初始化数据 | 初始化后的赋值操作 | 默认从父级数据池开始查找 | +| global_action | [页面全局操作](#_7) | 生成全局按钮操作 | 具体参考global_action详细说明 | +| local_action | [页面局部操作](#_8) | 生成局部按钮操作 | 具体参考local_action详细说明 | +| node_action | [页面节点操作](#_9) | 生成节点点击操作 | 可能存在于tree/cards等页面中,具体参考node_action详细说明 | +| select | 是否为可选页面 | 生成可选页面 | form页面中无需该字段 | +| pages | tabs/step多页面指向 | 生成tabs/step多页面 | 只存在于tabs/step页面中 | + +!!! attention "重要提示" + 1. tag 不能只有`[ ] { }`等特殊字符 + 2. tag 同一个页面下不要重复声明一个页签 + + +### 页面操作 + + 页面主要配置就是有多个操作组成的。包含初始化数据获取操作,增删改查等点击操作。所以操作配置有着极为重要的含义和详细配置声明。 + +##### 操作类型 + +| 关键字 | 名称 | 详情说明 | 支持性 | +| --- | --- | --- | --- | +| direct | 直接型 | 常见于确认编辑或删除以及树节点点击获取Children等操作 | ✔ | +| open | 弹框型 | 打开对话框,展示新的一个类型页面,常见于创建或编辑等操作 | ✔ | +| cascade | 级联型 | 常见于树状型页面的级联页面使用,当点击某个节点时, 出现与之并列的数据展示页面 | ✔ | +| import | 导入型 | 导入文件或数据时使用 | ✘ | +| export | 导出型 | 导出文件或数据时使用,分为全导出或部分导出 | ✘ | +| next | 步骤型 | 点击继续下一步操作,根据情况自动添加上一步按钮 | ✔ | +| url | 地址型 | 直接更改当前浏览器地址栏地址 | ✔ | + + +##### 操作配置 + +| 关键字 | 含义 | 功能 | +| --- | --- | --- | +| tag | 操作标签 | 可用于前端操作名称 | +| path | API接口 | 用于匹配schema描述和获取数据 | +| method | API接口方法 | 同上 | +| type | [操作类型](#_4) | 方便识别操作,见上方具体说明 | +| page | 页面tag | 当操作类型为open时,指向打开页面的tag | +| name | 操作名称 | 前端页面按钮名,node_action无需包含该内容 | +| icon | 图标 | 可选,目前暂未支持 | +| close | 关闭条件 | 开关型按钮操作的关闭条件 | +| open | 打开条件 | 开关型按钮操作的打开条件 | + + +!!! attention "提示" + 01. 上述字段均为可选字段,需要根据具体情况进行声明 + 02. tag或page均不能包含特殊字符 + 03. close/open仅支持bool说明,其他条件在计划开发中 + close=True; (✔) + open=False; (✔) + close=is_system; (✘) + open=!is_admin; (✘) + close=is_system&!is_admin; (✘) + + +##### 初始化操作 + + init_action的目的是为了获取某个页面Schema结构和初始数据。也就是当你打开或看到某个页面时,该操作就会自动发起,然后将获取到的数据填入页面中。 + + 其主要包含`path`和`method`,且操作类型为`direct`类型,目前也没有其他类型的初始化操作出现。 + +```json +{ + "init_action": { + "path": "/api/v1/xxx", + "method": "get", + "tag": "", + "type": "direct", + } +} +``` + +##### 全局操作 + + global_action主要完成页面全局按钮的生成,并根据配置完成其对应操作的初始化工作。比如创建/导入等操作。 + + 其配置需要完全按照[操作配置](#_5)的说明来完成。 + +```json +{ + "global_action": { + "create": { + "name": "新建", + "path": "/api/v1/xxx", + "method": "post", + "type": "open", + "page": "user_create", + "tag": "", + }, + "import": { + "name": "导入", + "path": "/api/v1/xxx", + "method": "post", + "type": "import", + "tag": "", + }, + "export": { + "name": "导出", + "path": "/api/v1/xxx", + "method": "post", + "type": "export", + "tag": "", + }, + } +} +``` + +##### 局部操作 + + local_action主要完成页面局部按钮的生成,以及对应其操作的初始化。比如删除/编辑等操作。 + + 其配置需要完全按照[操作配置](#_5)的说明来完成。 + +```json title='示例' +{ + "local_action": { + "edit": { + "name": "编辑", + "type": "open", + "page": "user_edit", + "tag": "", + }, + "delete": { + "name": "删除", + "type": "direct", + "path": "/api/v1/xxx/{id}/", + "method": "delete", + "tag": "", + } + } +} +``` + +##### 节点操作 + + node_action主要完成页面节点的操作的生成,不会有对应按钮的显示。 + + 功能主要为获取子节点和级联页面的数据,在Cards类型的页面中可以作为点击Cards的操作声明。 + +!!! info "提示" + 1. node_action为数组类型配置 + 2. 若node_action中有什么`direct`类型的操作时,且当前为树页面,默认认为存在子节点 + 3. 若node_action中有`cascade`类型的操作时,将在该页面执行完init_action之后使用第一个节点数据触发级联页面的init_action + + +```json title='示例' +{ + "node_action": [ + { + "path": "/api/v1/xxx", + "method": "get", + "type": "direct", + "tag": "", + }, + { + "type": "cascade", + "page": "user_list", + "tag": "", + }, + ] +} +``` + +### 配置举例 + +##### 表格型页面 + +```json +{ + "name": "用户列表", + "type": "table", + "tag": "user_list", + "init_action": { + "path": "/api/v1/xxx", + "method": "get", + "type": "direct", + "tag": "", + }, + "local_action": { + "edit": { + "name": "编辑", + "page": "user_edit", + "tag": "", + "type": "open", + "icon": "edit", + }, + "delete": { + "name": "删除", + "tag": "", + "type": "direct", + "path": "/api/v1/xxx/{id}/", + "method": "delete", + } + }, + "global_action": { + "create": { + "name": "创建", + "path": "/api/v1/xxx", + "method": "post", + "type": "open", + "tag": "", + } + }, +} +``` + +!!! 提示 + 1. 配置中的全局和局部操作都包含type=open类型的操作,但描述却有所差异 + 2. 全局‘创建’操作页面只有一个接口描述,故不再需要page字段,前端将会自行处理 + 3. 局部‘编辑’操作页面将会有两个接口(get和post)描述,故需要page字段 + +##### 表单型页面 + + 表单页面一般情况没有local_action的声明。global_action声明一般为提交表单操作。 + +```json +{ + "name": "编辑用户", + "type": "form", + "tag": "user_edit", + "init_action": { + "path": "/api/v1/xxx/{id}/", + "method": "get", + "type": "direct", + "tag": "", + }, + "global_action": { + "confirm": { + "name": "确认", + "path": "/api/v1/xxx/{id}/", + "method": "post", + "type": "direct", + "tag": "", + } + } +} +``` + +##### 树状型页面 + + 树型页面一般需要配合select或cascade进行联合使用,单独使用的情况较少。 + +```json +{ + "name": "用户分组", + "type": "tree", + "tag": "user_group", + "init_action": { + + }, +} +``` + +##### 描述型页面 + + 描述型页面配置如下所示: + +```json +{ + "name": "个人资料", + "type": "description", + "tag": "", + "init_action": { + "path": "/api/v1/xxx", + "method": "get", + "tag": "", + "type": "direct", + }, + "global_action": { + "edit": { + "name": "编辑", + "type": "open", + "page": "edit_login_user", + "tag": "", + }, + }, +} +``` + +##### 卡片型页面 + + 卡片型页面配置如下所示: + +```json +{ + "name": "本地应用", + "type": "cards", + "tag": "", + "init_action": { + "path": "/api/v1/xxx", + "method": "get", + "type": "direct", + "tag": "", + }, + "global_action": { + "create": { + "name": "创建", + "path": "/api/v1/xxx", + "method": "post", + "tag": "", + "type": "open", + } + }, + "local_action": { + "eidt": { + "name": "编辑", + "page": "edit_this_app", + "type": "open", + "tag": "", + }, + "delete": { + "name": "删除", + "path": "/api/v1/xxx", + "method": "delete", + "type": "direct", + "tag": "", + } + } +} +``` + +##### 步骤型页面 + +```json +{ + "name": "订单", + "type": "step", + "tag": "", + "pages": [ + "first_step", + "second_step", + "third_step" + ], +} +``` + +##### 切换型页面 + +```json +{ + "name": "应用列表", + "type": "tabs", + "tag": "", + "pages": [ + "my_app_list", + "app_store_list", + "purchased_app_list" + ], +} +``` + +## 扩展三:Schema + + OpenAPI-Plus中使用`pydantic`中提供的`Field`方法进行Schema字段的扩展。下面将详细说明扩展了哪些字段并解释说明这些字段在前端界面中的使用情况。 + +### 类型/格式 type/format + +| type | 信息 | 页面展示 | +| --- | --- | --- | +| integer | 数字类型 | 数字输入框 | +| string | 字符串类型 | 字符串输入框 | +| boolean | 布尔类型 | 开关按钮 | +| array | 数组类型 | 下拉选框 | +| object | 对象类型 | 主要为多个表单项 | + +| format | 信息 | 页面展示 | +| --- | --- | --- | +| textarea | 长文本 | 可调节长文本输入框 | +| link | 链接类型 | 使用a标签展示 | +| date-time | 时间 | 时间选择器 | +| auto | 自动填充 | 下拉时触发选项,配合option_action使用 | +| dynamic | 动态表单 | 可以添加多个和删除以某个单元为基础的表单 | +| binary | 二进制文件 | 输入框加上传按钮 | +| qrcode | 二维码 | 展示二维码 | +| markdown | MD文档 | 展示MD文档 | +| badges | 多标签 | 展示多个标签内容 | + + +除此之外,还存在其他的一些Schema描述,也影响着前端页面的展示情况。比如: + +* 枚举(enum) - 使用下拉单选框 +* allOf - 使用FormObject +* oneOf - 使用FormObject +* $ref - 使用FormObject + +!!! info "附加说明" + 1. 当声明deprecated=True或hidden=True时,前端界面不展示 + 2. 当声明readonly=True时,前端界面禁止编辑 + +### 操作 *_action + + 该模块的操作主要对Schema描述中的部分属性进行单一化的操作。主要包含以下三种情形,该模块可以进行扩展,需要根据实际情况和需求而定。每一项内容比较类似于上面所描述的页面配置操作。 + +| 关键字 | 类型 | +| --- | --- | +| item_action | [元素项操作](#item_action) | +| suffix_action | [后缀项操作](#suffix_action) | +| option_action | [选择项操作](#option_action) | + +##### item_action + + 元素项操作主要用于开关按钮类型的元素中,也就是type=bool的元素。 + +```json title="示例" +{ + "path": "/api/v1/xxx", + "method": "post", + "close": false, +} +``` + +##### suffix_action + + 后缀项操作主要用于像`发送校验码`等类似的表单项操作。通过suffix_action的描述,前端将读取识别,并在input输入框后挂载上点击按钮操作,发起suffix_action。 + +```json title="示例" +{ + "path": "/api/v1/xxx", + "method": "post", + "name": "发送校验码", + "delay": 60, +} +``` + +##### option_action + + 选择项操作主要用于获取下拉选框数据。通过option_action的描述,当用户鼠标移入到对应的前端页面select元素上时,触发该操作。该操作需要注意返回数据的格式。 + +```json title="示例" +{ + "path": "/api/v1/xxx", + "method": "post", +} +``` + +### 对话框 page + +!!! 提示 + > 想一想: 为什么会需要弹出新的对话框呢? + + 1. 当需要选择某个页面中的某个或多个数据并进行统一添加时 + 2. 当需要选择某个页面中的某个或多个数据并进行统一回传时 + + 当某个元素描述上使用Field添加了page属性,则代表当点击该页面元素时,需要打开page属性指向的对话框页面。 + page属性指向的内容需要在[`pages`](#pages)中进行提供和声明。除此之外,还需要声明该数据是多选还是单选,单选使用`string`,多选使用`array`。 + + 当数据添加或回传只需要id值时,不要再进行其他的声明。否则需要声明回传的Schema内容。举例如下: + +```python +class UserGroupCreateParentIn(Schema): + """hidden=True意味着不需要在前端展示,但是需要在接口中回传""" + id:UUID = Field(hidden=True) + name:str + +class UserGroupCreateIn(ModalSchema): + + parent: Optional[UserGroupCreateParentIn] = Field( + title=_("上级用户分组"), + page="", + ) +``` + +## 扩展四:接口(paths) + + OpenAPI-Plus对Paths模块做了一些必要的扩展。比如operationId等内容。 + + 通过这些内容用于接口API权限的设置和匹配。 + +## 扩展五:权限(permissions) + + OpenAPI-Plus添加了权限相关模块。 + + 通过权限的匹配来实现API接口和页面的控制。 + +## 扩展六:国际化(translation) + + OpenAPI-Plus添加了国际化模块。 + + 主要包含了在OpenAPI描述中需要进行翻译的字段的中英文信息。 diff --git "a/docs/ \345\274\200\345\217\221\350\200\205\346\214\207\345\215\227/\345\217\202\350\200\203\346\226\207\346\241\243/\345\211\215\347\253\257\347\225\214\351\235\242.md" "b/docs/ \345\274\200\345\217\221\350\200\205\346\214\207\345\215\227/\345\217\202\350\200\203\346\226\207\346\241\243/\345\211\215\347\253\257\347\225\214\351\235\242.md" new file mode 100644 index 000000000..4c1530295 --- /dev/null +++ "b/docs/ \345\274\200\345\217\221\350\200\205\346\214\207\345\215\227/\345\217\202\350\200\203\346\226\207\346\241\243/\345\211\215\347\253\257\347\225\214\351\235\242.md" @@ -0,0 +1,319 @@ +# 前端界面 + +!!! info "温馨提示" + 1. 在阅读该文档之前,希望您已阅读过OpenAPI-Plus文档 + 2. 您已使用或浏览过ArkID前端界面 + + 前端界面主要包含两大模块。 + +* [登录界面](#_2) + + 登录界面主要通过`/api/v1/tenant/{tenant_id}/login_page/`接口返回的页面描述信息生成。 + +* [功能界面](#_6) + + 主要描述怎样通过OpenAPI-Plus的扩展接口`/api/v1/openapi.json`返回的信息而演化为前端显示的每一张界面。通过对前端界面的生成过程的演变描述,使得阅读ArkID前端项目的人员更加易于建议或使用。 + +## 登录界面 + + 登录界面配置接口返回内容包含两项,如下表所示。举例图片如下图所示。 + +| 关键字 | 名称 | 详情说明 | +| --- | --- | --- | +| data | [页面项配置](#_3) | 渲染登录/注册等不同页面表单 | +| tenant | [租户信息](#_4) | 渲染租户图标和名称等 | + +[![vVLQyt.png](https://s1.ax1x.com/2022/08/03/vVLQyt.png)](https://imgtu.com/i/vVLQyt) + +### 页面项配置 + + 页面项配置信息可以由`认证因素`模块动态添加所得。默认有用户名密码登录页面配置等信息。如上图所示,页面项配置生成登录模块中所有的不同表单项和按钮操作。主要包含的页面项为登录页面、注册页面和忘记密码页面。由于三种页面项的渲染过程一致,在此只介绍登录页面项的生成过程以及注意事项。 + + data关键字对应的值为页面项的各自页面描述信息,每一项中又包含`forms bottoms extend name`等字段信息。 + +| 关键字 | 名称 | +| --- | --- | +| name | 页面配置项名称 | +| forms | 不同方式的表单 | +| bottoms | 表单底部操作 | +| extend | 第三方登录 | + +**表单项 forms** + + forms用来渲染不同方式表单项。比如登录可以存在 ① 用户名密码登录方式;② 短信验证码登录方式 等。而其中又包含`label items submit`等字段信息。 + +| 关键字 | 详情 | +| --- | --- | +| label | 表单项标题名称 | +| items | 每一条表单项的详细表述 | +| submit | 表单项的提交操作表述 | + + + items表单项用于显示和输入用户名、密码、手机号等信息,并带有发送验证码等功能操作。 + +| 关键字 | 含义 | 详情 | +| --- | --- | --- | +| name | 字段key | 提交时回传的key | +| type | 表单项类型 | ① text ② password ③ hidden | +| placeholder | 表单项占位符 | - | +| append | 后缀操作 | 见submit操作说明即可, 一般常用于'发送验证码'和'图形校验码'等操作 | + + submit为提交用户输入的items信息。 + +| 关键字 | 详情 | +| --- | --- | +| http | 按钮操作内容,包含url/path/params等内容 | +| title | 按钮操作名称 | +| redirect | 点击之后的重定向地址 | +| agreement | 注册协议说明 | +| delay | 时间延迟, 用于发送验证码等操作 | +| gopage | 页面名称,用于前往data中某个页面 | +| img | 图片地址,用于第三方登录的图标显示 | +| long | 布尔值,长类型按钮, 用于按钮的长度的控制 | +| prepend | 按钮前缀文字,常用于表单底部按钮 | +| tooltip | 按钮移入提示信息描述,常用于第三方登录按钮 | + + +**底部操作 bottoms** + + bottoms用于不同页面配置项之间进行来回的切换操作。 + + 在前端页面展示中,常见于`还没有账号,立即注册`和`忘记密码`等按钮。该类型的操作属性一般包含上述描述中的`prepend gopage`等属性。 + +**第三方登录 extend** + + extend仅存在于登录页面配置项中,用于显示不同的第三方登录项。在上面图片中表示为下方的多个图标。 + + extend由`title buttons`两项组成。title为标题;buttons为各个第三方登录的按钮配置。 + + buttons中的操作属性一般包含`img redirect tooltip`等属性信息。 + +**配置举例** + + 接口中data返回的信息举例如下所述: + +```json +{ + "data": { + "login": { + "name": "login", + "forms": [ + { + "label": "用户名密码登录", + "items": [ + { + "value": "", + "type": "text", + "name": "username", + "placeholder": "用户名", + }, + { + "value": "", + "type": "password", + "name": "password", + "placeholder": "密码", + }, + ], + "submit": { + "http": { + "url": "/api/v1/xxx", + "method": "post", + "params": null, + }, + "title": "登录", + "long": true, + }, + } + ], + "bottoms": [ + { + "prepend": "还没有账号,", + "gopage": "register", + "title": "立即注册", + }, + { + "prepend": null, + "gopage": "password", + "title": "忘记密码", + }, + ], + "extend": { + "title": "第三方登录", + "buttons": [ + { + "img": "xxx.png", + "redirect": { + "url": "xxx", + "params": null, + }, + "tooltip": "Github", + }, + { + "img": "xxx.png", + "redirect": { + "url": "xxx", + "params": null, + }, + "tooltip": "Gitee", + } + ], + }, + }, + "register": {}, + "password": {}, + } +} +``` + +### 租户信息 + + 租户信息主要返回当前登录的租户信息。渲染上图中表单上方的租户图标和租户名称。 + +!!! 登录提示 + 1. 登录页面默认使用平台租户登录 + 2. 若想使用其他已存在的租户进行登录,请在地址栏填入后缀信息 + 例如:`/login?tenant_id=123` + +## 功能界面 + + 功能界面主要包含除登录页面之外的其他功能性页面,也就是可以阅读数据和增删改查数据信息的内容页面。具体页面的内容和运作需要有前端知识基础,比如TypeScript、Vue3和Bootstrap5等。此文档将会避免涉及到任何前端所需要的基础知识,而只是为您更好了贯穿前端和后端,从而理解其大体运作的方式。 + +### 问题概览 + + 这些页面是怎么来的呢?功能页面的生成大体经过的了下面的几个步骤。 + +1. 根据路由(routers)生成前端路由 +2. 根据路由提供的页面(page)寻找页面配置(pages)中对应的配置详情 +3. 根据页面配置生成类型页面,并寻找其中的弹框页、级联页和子页,以及各自对应的操作(paths) +4. 根据操作信息寻找对应的描述(components)内容,并根据描述生成页面属性 +5. 将操作挂载到页面或按钮上,从而完成页面的渲染和按钮的操作 + +!!! 提示 + 1. 弹框页:按钮类型为open的操作打开的对话框页面 + 2. 级联页:一般为树状页node_action描述中的cascade类型操作的指向页面 + 3. 子页:定义为tabs/step等类型页面中声明的pages指向页面 + +### OpenAPI-Plus + +OpenAPI-Plus通过接口`/api/v1/openapi.json`返回的信息和功能简介如下。更为详细的内容可以参考OpenAPI-Plus文档。 + +| 模块 | 名称 | 详情说明 | 前端是否使用 | +| --- | --- | --- | :---: | +| routers | 路由集 | 用于前端生成路由信息 | ✔ | +| pages | 页面集 | 用于前端生成不同的页面类型和页面操作 | ✔ | +| paths | 接口集 | 用于寻找Components和权限认证等 | ✔ | +| components | 描述集 | 用于前端生成页面元素信息 | ✔ | +| permissions | 权限集 | 用于权限管理 | ✔ | +| translation | 国际化 | 用于国际化语音切换 | ✔ | +| info | 信息 | OpenAPI描述 | ✘ | +| openapi | 版本 | 版本号 | ✘ | + +### 路由 routers + + 路由信息由OpenAPI-Plus接口提供。前端将直接读取此模块的内容,并根据路由描述来生成前端路由表。 + + 在生成前端路由表过程中需要对路由页面权限和路由一些信息进行处理和挂载。路由分为有Children和无Children。 + +!!! info "提示" + 1. 子路由Children数量大于等于2时,会产生父子侧边栏 + 2. 子路由Children为空或数量为1时,侧边栏直接展示页面 + +```json title='无Children路由示例' +{ + "path": "mine", + "name": "我的", + "icon": "mine", + "hidden": true, + "page": "mine", +} +``` + + +**路由权限** + + 如果某个OpenAPI-Plus路由表中提供`page`信息,则说明此路由表需要展示某个页面,也就需要先判断该页面是否拥有权限。权限集不会直接提供该页面的权限,而需要前端找到该页面的初始化接口等内容,通过接口来间接地判断该路由页面的权限。 + + 路由权限查找步骤(以上述示例为例): + +1. 上述示例中`page`指向mine页面,将在[页面配置](#pages)中查找到init_action。如果其为级联页面和子页面(tabs/step)类型,则查找级联页面的第一个子页面的[页面配置](#pages) +2. 通过init_action再查找[接口信息](#paths)中对应的接口operationId +3. 通过operationId再去[权限集](#permissions)中查找对应的sort_id值 +4. 根据sort_id值通过权限接口返回的字符串信息来决定其是否拥有权限 +5. 若拥有该路由权限,则显示;否则不显示 + + +**显示属性** + + 除路由权限需要前端注意之外,OpenAPI-Plus提供的`mobile web`两个显示属性也需要前端进行做出相对应的处理。 + + 这两个属性决定移动端和Web端显示哪些路由页面。如果指明mobile属性,则将会在移动端页面底部显示对应的路由信息。 + +[![vZArBF.png](https://s1.ax1x.com/2022/08/03/vZArBF.png)](https://imgtu.com/i/vZArBF) + + +### 页面配置 pages + + 页面配置项主要用来渲染页面的类型和操作。通过读取解析页面配置项中的内容,完成路由页面的完整渲染。页面配置项中包含哪些内容,已经在OpenAPI-Plus文档中详细阐述,在此不再赘述。 + +生成的某个主页面如下图所示: + +[![vZZlX6.png](https://s1.ax1x.com/2022/08/03/vZZlX6.png)](https://imgtu.com/i/vZZlX6) + +生成的某个弹出框页面如下图所示: + +[![vZZYAe.png](https://s1.ax1x.com/2022/08/03/vZZYAe.png)](https://imgtu.com/i/vZZYAe) + + +!!! info "读取提示" + 1. 若为路由声明页面和其级联页面均为主页面 + 2. 主页面和其他子页面(tabs/step)均使用Card不使用Modal + 3. 主页面和主页面从属第一个子页面均在打开路由时需要初始化数据 + +页面配置项读取步骤: + +1. 读取页面配置名称,挂载到前端页面中,若其为主页面且不是级联页面则隐藏该名称 +2. 读取init_action信息,根据init_action信息中的接口获取接口描述 +3. 读取其余action信息,并生成页面中对应的操作或按钮 +4. 再使用上述两个步骤中的action信息,完成页面操作的挂载 +5. 重复执行该读取步骤,完成所有指定页面的配置读取工作 + +### 接口信息 paths + + 接口信息对于前端来说,除operationId之外,主要用于接口responses或requestBody内容的读取以及操作内容的挂载。 + + 当[页面配置](#pages)中的action描述`method=get`时读取responses;否则读取requestBody。 + +**两条读取路线** + +1. responses => 200 => content => application/json => schema => $ref +2. requestBody => content => application/json => schema => $ref + + + 并进一步读取其中的$ref指向。例如`$ref=#/components/schemas/MineAppsOut`时,会根据其`MineAppsOut`去[属性描述](#components)中进一步操作描述。 + +[![vZQA2Q.png](https://s1.ax1x.com/2022/08/03/vZQA2Q.png)](https://imgtu.com/i/vZQA2Q) + + +### 属性描述 components + + 属性描述在`components => schema => MineAppsOut`的方式进行描述的详细信息的解析和处理。 + + 在根据详细信息中的`properties`等内容挂载到不同类型的页面中。 + + table表格型页面就挂载到表格列中;form表单型页面就挂载到表单项中。 + +[![vZQZKs.png](https://s1.ax1x.com/2022/08/03/vZQZKs.png)](https://imgtu.com/i/vZQZKs) + +### 权限集 permissions + + 权限集主要用于前端路由和API按钮的显示与否的控制。 + + 当没有路由或按钮权限信息时,页面不会显示所对应的内容。 + +### 国际化 translation + + 页面示例图片: + +[![vZQrxH.png](https://s1.ax1x.com/2022/08/03/vZQrxH.png)](https://imgtu.com/i/vZQrxH) + + 国际化主要用于用户在语言之间的选择切换,切换之后页面将会刷新后更新,使用OpenAPI-Plus提供的翻译文本进行对页面所使用位置的全局替换。 diff --git "a/docs/ \347\263\273\347\273\237\346\217\222\344\273\266" "b/docs/ \347\263\273\347\273\237\346\217\222\344\273\266" similarity index 100% rename from "docs/ \347\263\273\347\273\237\346\217\222\344\273\266" rename to "docs/ \347\263\273\347\273\237\346\217\222\344\273\266" diff --git "a/docs/ \345\205\266\345\256\203\346\217\222\344\273\266" "b/docs/ \345\205\266\345\256\203\346\217\222\344\273\266" new file mode 120000 index 000000000..03ea94656 --- /dev/null +++ "b/docs/ \345\205\266\345\256\203\346\217\222\344\273\266" @@ -0,0 +1 @@ +../arkid_extensions \ No newline at end of file diff --git a/docs/overrides/partials/header.html b/docs/overrides/partials/header.html index a5241dc5a..b6ac56376 100644 --- a/docs/overrides/partials/header.html +++ b/docs/overrides/partials/header.html @@ -6,6 +6,15 @@ {% set class = class ~ " md-header--lifted" %} {% endif %}
+