diff --git a/app_desc.yaml b/app_desc.yaml index 9d1e2a3..cdd3329 100644 --- a/app_desc.yaml +++ b/app_desc.yaml @@ -1,5 +1,5 @@ spec_version: 2 -app_version: "1.8.1" +app_version: "1.9.0" app: region: default bk_app_code: &APP_CODE bk_flow_engine diff --git a/bkflow/apigw/docs/apigw-docs.zip b/bkflow/apigw/docs/apigw-docs.zip index 1e14b43..3f4ce0c 100644 Binary files a/bkflow/apigw/docs/apigw-docs.zip and b/bkflow/apigw/docs/apigw-docs.zip differ diff --git a/bkflow/apigw/docs/zh/create_mock_task.md b/bkflow/apigw/docs/zh/create_mock_task.md index 8d3af7f..15012ab 100644 --- a/bkflow/apigw/docs/zh/create_mock_task.md +++ b/bkflow/apigw/docs/zh/create_mock_task.md @@ -11,14 +11,14 @@ #### 接口参数 -| 字段 | 类型 | 必选 | 描述 | -|-------------|--------|----|----------------------------------------------------------------------------| -| template_id | int | 是 | 模板id | -| name | string | 否 | 任务名 | -| creator | string | 是 | 创建者 | -| description | string | 否 | 描述 | -| constants | dict | 否 | 任务启动参数 | -| mock_data | dict | 否 | mock 数据,包含 nodes(mock 执行选中的节点) 和 outputs(mock 执行对应节点的 mock 数据,没有则表示不 mock) | +| 字段 | 类型 | 必选 | 描述 | +|-------------|--------|----|-----------------------------------------------------------------------------------------------------------------------------------------------------------------| +| template_id | int | 是 | 模板id | +| name | string | 否 | 任务名 | +| creator | string | 是 | 创建者 | +| description | string | 否 | 描述 | +| constants | dict | 否 | 任务启动参数 | +| mock_data | dict | 否 | mock 数据,包含 nodes(mock 任务使用 mock 执行的节点),outputs(可选参数,mock 执行对应节点的节点输出),mock_data_ids(mock 执行对应节点使用的 mock 数据 id,如果 outputs 没有传参,则会自动将创建任务时对应的 mock 数据 作为 outputs) | ### 请求参数示例 @@ -38,6 +38,9 @@ "nd7927122ef6310eb309c2c8d3f70c23": { "callback_data": "abc" } + }, + "mock_data_ids": { + "nd7927122ef6310eb309c2c8d3f70c23": 1 } } } diff --git a/bkflow/apigw/docs/zh/get_tasks_states.md b/bkflow/apigw/docs/zh/get_tasks_states.md new file mode 100644 index 0000000..b6efce2 --- /dev/null +++ b/bkflow/apigw/docs/zh/get_tasks_states.md @@ -0,0 +1,62 @@ +### 资源描述 + +批量获取任务状态 + +### 输入通用参数说明 +| 参数名称 | 参数类型 | 必须 | 参数说明 | +|---------------|--------|----|------------------------------------------------------------| +| bk_app_code | string | 是 | 应用ID(app id),可以通过 蓝鲸开发者中心 -> 应用基本设置 -> 基本信息 -> 鉴权信息 获取 | +| bk_app_secret | string | 是 | 安全秘钥(app secret),可以通过 蓝鲸开发者中心 -> 应用基本设置 -> 基本信息 -> 鉴权信息 获取 | + +#### 接口参数 +| 字段 | 类型 | 必选 | 描述 | +|----------|-----------|----|---------------| +| task_ids | list[int] | 是 | 需要查询的任务 id 列表 | + + +### 请求参数示例 +```json +{ + "bk_app_code": "xxxx", + "bk_app_secret": "xxxx", + "bk_username or bk_token": "xxxx", + "task_ids": [1, 2, 3, 4] +} +``` + + +### 返回结果示例 + +```json +{ + "result": true, + "data": { + "3": { + "state": "FINISHED" + }, + "2": { + "state": "FINISHED" + }, + "1": { + "state": "FINISHED" + } + }, + "code": "0", + "message": "" +} +``` +### 返回结果参数说明 + +| 字段 | 类型 | 描述 | +|---------|--------|-----------------------| +| result | bool | 返回结果,true为成功,false为失败 | +| code | int | 返回码,0表示成功,其他值表示失败 | +| message | string | 错误信息 | +| data | dict | 返回数据 | + +#### data[item] + +| 字段 | 类型 | 描述 | +|-----------------|--------|-----------| +| id | string | 任务实例ID | +| state | string | 任务状态 | diff --git a/bkflow/apigw/docs/zh/get_template_mock_data.md b/bkflow/apigw/docs/zh/get_template_mock_data.md new file mode 100644 index 0000000..dde2553 --- /dev/null +++ b/bkflow/apigw/docs/zh/get_template_mock_data.md @@ -0,0 +1,69 @@ +### 资源描述 + +获取流程 Mock 数据 + +### 输入通用参数说明 +| 参数名称 | 参数类型 | 必须 | 参数说明 | +|---------------|--------|----|------------------------------------------------------------| +| bk_app_code | string | 是 | 应用ID(app id),可以通过 蓝鲸开发者中心 -> 应用基本设置 -> 基本信息 -> 鉴权信息 获取 | +| bk_app_secret | string | 是 | 安全秘钥(app secret),可以通过 蓝鲸开发者中心 -> 应用基本设置 -> 基本信息 -> 鉴权信息 获取 | + +### 接口参数 + +| 字段 | 类型 | 必选 | 描述 | +|---------|--------|----|-------------------------| +| node_id | string | 否 | 基于节点 id 过滤对应节点的 Mock 数据 | + + +### 返回结果示例 + +```json +{ + "result": true, + "data": [ + { + "id": 1, + "name": "Mock 数据方案1", + "space_id": 1, + "template_id": 1, + "node_id": "nd64fbd5440932ee9658d47029751f46", + "data": { + "callback_data": { + "abc": "123" + } + }, + "is_default": true, + "extra_info": null, + "operator": "admin", + "create_at": "2024-09-14T15:24:39.075179+08:00", + "update_at": "2024-09-14T15:24:39.075289+08:00" + } + ], + "code": 0, + "message": "" +} +``` +### 返回结果参数说明 + +| 字段 | 类型 | 描述 | +|---------|--------|-----------------------| +| result | bool | 返回结果,true为成功,false为失败 | +| code | int | 返回码,0表示成功,其他值表示失败 | +| message | string | 错误信息 | +| data | dict | 返回数据 | + +### data[item] + +| 字段 | 类型 | 描述 | +|-------------|--------|------------------| +| id | int | Mock 数据 ID | +| name | string | Mock 数据名称 | +| space_id | int | Mock 数据所属空间 ID | +| template_id | int | Mock 数据所属流程模板 ID | +| node_id | string | Mock 数据所属节点 ID | +| data | dict | Mock 数据内容 | +| is_default | bool | 是否为默认 Mock 数据 | +| extra_info | dict | 额外信息 | +| operator | string | 操作人 | +| create_at | string | 创建时间 | +| update_at | string | 更新时间 | diff --git a/bkflow/apigw/docs/zh/validate_pipeline_tree.md b/bkflow/apigw/docs/zh/validate_pipeline_tree.md new file mode 100644 index 0000000..edbe61f --- /dev/null +++ b/bkflow/apigw/docs/zh/validate_pipeline_tree.md @@ -0,0 +1,260 @@ +### 资源描述 + +校验任务结构树是否合法 + +### 输入通用参数说明 +| 参数名称 | 参数类型 | 必须 | 参数说明 | +|---------------|--------|----|------------------------------------------------------------| +| bk_app_code | string | 是 | 应用ID(app id),可以通过 蓝鲸开发者中心 -> 应用基本设置 -> 基本信息 -> 鉴权信息 获取 | +| bk_app_secret | string | 是 | 安全秘钥(app secret),可以通过 蓝鲸开发者中心 -> 应用基本设置 -> 基本信息 -> 鉴权信息 获取 | + + +#### 接口参数 + +| 字段 | 类型 | 必选 | 描述 | +|---------------|--------|----|-------| +| pipeline_tree | json | 是 | 任务结构树 | + + + +### 请求参数示例 + +```json +{ + "bk_app_code": "xxxx", + "bk_app_secret": "xxxx", + "bk_username or bk_token": "xxxx", + "pipeline_tree": { + "name": "test", + "activities": { + "nf834705dbbb37c59ad114aa37314975": { + "component": { + "code": "bk_display", + "data": { + "bk_display_message": { + "hook": false, + "need_render": true, + "value": "" + } + }, + "version": "v1.0" + }, + "error_ignorable": false, + "id": "nf834705dbbb37c59ad114aa37314975", + "incoming": [ + "lee4ca362c673536958aa656cb36efda" + ], + "loop": null, + "name": "消息展示", + "optional": true, + "outgoing": "l2e819009a9a3714ab108ad5c594bb73", + "stage_name": "", + "type": "ServiceActivity", + "retryable": true, + "skippable": true, + "auto_retry": { + "enable": false, + "interval": 0, + "times": 1 + }, + "timeout_config": { + "enable": false, + "seconds": 10, + "action": "forced_fail" + }, + "labels": [] + } + }, + "end_event": { + "id": "n2dfd1233f633cdf864fc3681eb2b0b3", + "incoming": [ + "l2e819009a9a3714ab108ad5c594bb73" + ], + "name": "", + "outgoing": "", + "type": "EmptyEndEvent", + "labels": [] + }, + "flows": { + "lee4ca362c673536958aa656cb36efda": { + "id": "lee4ca362c673536958aa656cb36efda", + "is_default": false, + "source": "n1df1598dba137aba81094851379435a", + "target": "nf834705dbbb37c59ad114aa37314975" + }, + "l2e819009a9a3714ab108ad5c594bb73": { + "id": "l2e819009a9a3714ab108ad5c594bb73", + "is_default": false, + "source": "nf834705dbbb37c59ad114aa37314975", + "target": "n2dfd1233f633cdf864fc3681eb2b0b3" + } + }, + "gateways": {}, + "line": [ + { + "id": "lee4ca362c673536958aa656cb36efda", + "source": { + "arrow": "Right", + "id": "n1df1598dba137aba81094851379435a" + }, + "target": { + "arrow": "Left", + "id": "nf834705dbbb37c59ad114aa37314975" + } + }, + { + "id": "l2e819009a9a3714ab108ad5c594bb73", + "source": { + "arrow": "Right", + "id": "nf834705dbbb37c59ad114aa37314975" + }, + "target": { + "arrow": "Left", + "id": "n2dfd1233f633cdf864fc3681eb2b0b3" + } + } + ], + "location": [ + { + "id": "n1df1598dba137aba81094851379435a", + "type": "startpoint", + "x": 40, + "y": 150 + }, + { + "id": "nf834705dbbb37c59ad114aa37314975", + "type": "tasknode", + "name": "消息展示", + "stage_name": "", + "x": 240, + "y": 140, + "group": "蓝鲸服务(BK)", + "icon": "", + "optional": true, + "error_ignorable": false, + "retryable": true, + "skippable": true, + "auto_retry": { + "enable": false, + "interval": 0, + "times": 1 + }, + "timeout_config": { + "enable": false, + "seconds": 10, + "action": "forced_fail" + } + }, + { + "id": "n2dfd1233f633cdf864fc3681eb2b0b3", + "type": "endpoint", + "x": 540, + "y": 150 + } + ], + "outputs": [], + "start_event": { + "id": "n1df1598dba137aba81094851379435a", + "incoming": "", + "name": "", + "outgoing": "lee4ca362c673536958aa656cb36efda", + "type": "EmptyStartEvent", + "labels": [] + }, + "constants": {}, + "projectBaseInfo": {}, + "notify_receivers": { + "receiver_group": [], + "more_receiver": "" + }, + "notify_type": { + "success": [], + "fail": [] + }, + "template_labels": [], + "internalVariable": { + "${_system.task_name}": { + "key": "${_system.task_name}", + "name": "任务名称", + "index": -1, + "desc": "", + "show_type": "hide", + "source_type": "system", + "source_tag": "", + "source_info": {}, + "custom_type": "", + "value": "", + "hook": false, + "validation": "" + }, + "${_system.task_id}": { + "key": "${_system.task_id}", + "index": -2, + "name": "任务ID", + "desc": "", + "show_type": "hide", + "source_type": "system", + "source_tag": "", + "source_info": {}, + "custom_type": "", + "value": "", + "hook": false, + "validation": "" + }, + "${_system.task_start_time}": { + "key": "${_system.task_start_time}", + "name": "任务开始时间", + "index": -3, + "desc": "", + "show_type": "hide", + "source_type": "system", + "source_tag": "", + "source_info": {}, + "custom_type": "", + "value": "", + "hook": false, + "validation": "" + }, + "${_system.operator}": { + "key": "${_system.operator}", + "name": "任务的执行人(点击开始执行的人员)", + "index": -4, + "desc": "", + "show_type": "hide", + "source_type": "system", + "source_tag": "", + "source_info": {}, + "custom_type": "", + "value": "", + "hook": false, + "validation": "" + } + }, + "spaceId": 1, + "scopeInfo": { + "scope_type": null, + "scope_value": null + } + } +} +``` + +### 返回结果示例 + +```json +{ + "result": true, + "data": {}, + "code": "0", + "message": "" +} + +``` +### 返回结果参数说明 + +| 字段 | 类型 | 描述 | +|---------|--------|-----------------------| +| result | bool | 返回结果,true为成功,false为失败 | +| code | int | 返回码,0表示成功,其他值表示失败 | +| message | string | 错误信息 | +| data | dict | 返回数据 | diff --git a/bkflow/apigw/management/commands/data/api-resources.yml b/bkflow/apigw/management/commands/data/api-resources.yml index 26d9a83..630a81b 100644 --- a/bkflow/apigw/management/commands/data/api-resources.yml +++ b/bkflow/apigw/management/commands/data/api-resources.yml @@ -127,6 +127,30 @@ paths: userVerifiedRequired: false disabledStages: [ ] descriptionEn: Create a task without template + /space/{space_id}/validate_pipeline_tree/: + post: + operationId: validate_pipeline_tree + description: 校验 pipeline_tree 是否合法 + tags: [ ] + responses: + default: + description: '' + x-bk-apigateway-resource: + isPublic: true + allowApplyPermission: true + matchSubpath: false + backend: + type: HTTP + method: post + path: /{env.api_sub_path}apigw/space/{space_id}/validate_pipeline_tree/ + matchSubpath: false + timeout: 0 + upstreams: { } + transformHeaders: { } + authConfig: + userVerifiedRequired: false + disabledStages: [ ] + descriptionEn: Validate a pipeline tree /space/{space_id}/create_mock_task/: post: operationId: create_mock_task @@ -319,6 +343,30 @@ paths: userVerifiedRequired: false disabledStages: [] descriptionEn: Get states of a task + /space/{space_id}/get_tasks_states/: + post: + operationId: get_tasks_states + description: 批量获取任务状态 + tags: [ ] + responses: + default: + description: '' + x-bk-apigateway-resource: + isPublic: true + allowApplyPermission: true + matchSubpath: false + backend: + type: HTTP + method: post + path: /{env.api_sub_path}apigw/space/{space_id}/get_tasks_states/ + matchSubpath: false + timeout: 0 + upstreams: { } + transformHeaders: { } + authConfig: + userVerifiedRequired: false + disabledStages: [ ] + descriptionEn: Get states of a task with filter params /space/{space_id}/task/{task_id}/operate_task/{operation}/: post: operationId: operate_task @@ -391,6 +439,30 @@ paths: userVerifiedRequired: false disabledStages: [] descriptionEn: Get detail of a template + /space/{space_id}/template/{template_id}/get_template_mock_data/: + get: + operationId: get_template_mock_data + description: 获取模版 mock 数据 + tags: [] + responses: + default: + description: '' + x-bk-apigateway-resource: + isPublic: true + allowApplyPermission: true + matchSubpath: false + backend: + type: HTTP + method: get + path: /{env.api_sub_path}apigw/space/{space_id}/template/{template_id}/get_template_mock_data/ + matchSubpath: false + timeout: 0 + upstreams: {} + transformHeaders: {} + authConfig: + userVerifiedRequired: false + disabledStages: [] + descriptionEn: Get mock data of a template /space/{space_id}/get_template_list/: get: operationId: get_template_list diff --git a/bkflow/apigw/serializers/task.py b/bkflow/apigw/serializers/task.py index 5c17f1b..fd88c3d 100644 --- a/bkflow/apigw/serializers/task.py +++ b/bkflow/apigw/serializers/task.py @@ -18,9 +18,13 @@ to the current version of the project delivered to anyone in the future. """ from django.utils.translation import ugettext_lazy as _ +from pipeline.exceptions import PipelineException from rest_framework import serializers from bkflow.constants import MAX_LEN_OF_TASK_NAME, USER_NAME_MAX_LENGTH +from bkflow.pipeline_web.parser.validator import validate_web_pipeline_tree +from bkflow.template.models import TemplateMockData +from bkflow.utils.strings import standardize_pipeline_node_name class CreateTaskSerializer(serializers.Serializer): @@ -32,11 +36,10 @@ class CreateTaskSerializer(serializers.Serializer): class TaskMockDataSerializer(serializers.Serializer): - nodes = serializers.ListSerializer( - help_text=_("要 Mock 执行的节点 ID 列表"), child=serializers.CharField(), default=[] - ) - outputs = serializers.JSONField( - help_text=_('节点 Mock 输出, 形如{"node_id": {"output1": "output_value1"}}'), default={} + nodes = serializers.ListSerializer(help_text=_("要 Mock 执行的节点 ID 列表"), child=serializers.CharField(), default=[]) + outputs = serializers.JSONField(help_text=_('节点 Mock 输出, 形如{"node_id": {"output1": "output_value1"}}'), default={}) + mock_data_ids = serializers.JSONField( + help_text=_("节点 Mock 数据,当 outputs 为空时会提取对应 mock_data_ids 设置 outputs,否则仅记录作用"), default={} ) @@ -55,6 +58,24 @@ class CreateMockTaskWithPipelineTreeSerializer(CreateMockTaskBaseSerializer): class CreateMockTaskWithTemplateIdSerializer(CreateMockTaskBaseSerializer): template_id = serializers.IntegerField(help_text=_("模版ID")) + def validate(self, attrs): + if attrs["mock_data"]["mock_data_ids"] and not attrs["mock_data"]["outputs"]: + mock_data = TemplateMockData.objects.filter( + template_id=attrs["template_id"], id__in=list(attrs["mock_data"]["mock_data_ids"].values()) + ).values("id", "data") + mock_data = {item["id"]: item["data"] for item in mock_data} + outputs = {} + for node_id, mock_data_id in attrs["mock_data"]["mock_data_ids"].items(): + if node_id not in attrs["mock_data"].get("nodes"): + continue + if mock_data_id not in mock_data: + raise serializers.ValidationError( + f"mock data of node {node_id} with mock_data_id {mock_data_id} not found" + ) + outputs[node_id] = mock_data[mock_data_id] + attrs["mock_data"]["outputs"] = outputs + return attrs + class CreateTaskWithoutTemplateSerializer(serializers.Serializer): name = serializers.CharField(help_text=_("任务名"), max_length=MAX_LEN_OF_TASK_NAME, required=False) @@ -64,6 +85,18 @@ class CreateTaskWithoutTemplateSerializer(serializers.Serializer): description = serializers.CharField(help_text=_("任务描述"), required=False) constants = serializers.JSONField(help_text=_("任务启动参数"), required=False, default={}) pipeline_tree = serializers.JSONField(help_text=_("任务树"), required=True) + notify_config = serializers.JSONField(help_text=_("通知配置"), required=False, default={}) + + +class PipelineTreeSerializer(serializers.Serializer): + pipeline_tree = serializers.JSONField(help_text=_("任务树"), required=True) + + def validate_pipeline_tree(self, pipeline_tree): + try: + standardize_pipeline_node_name(pipeline_tree) + validate_web_pipeline_tree(pipeline_tree) + except PipelineException as e: + raise serializers.ValidationError(str(e)) class GetTaskListSerializer(serializers.Serializer): @@ -77,6 +110,10 @@ class GetTaskListSerializer(serializers.Serializer): name = serializers.CharField(help_text=_("任务名"), max_length=MAX_LEN_OF_TASK_NAME, required=False) +class GetTasksStatesSerializer(serializers.Serializer): + task_ids = serializers.ListField(required=True, child=serializers.IntegerField()) + + class OperateTaskSerializer(serializers.Serializer): operator = serializers.CharField(help_text=_("操作人"), max_length=USER_NAME_MAX_LENGTH, required=True) diff --git a/bkflow/apigw/urls.py b/bkflow/apigw/urls.py index 9931159..329fd75 100644 --- a/bkflow/apigw/urls.py +++ b/bkflow/apigw/urls.py @@ -41,8 +41,10 @@ from bkflow.apigw.views.get_task_list import get_task_list from bkflow.apigw.views.get_task_node_detail import get_task_node_detail from bkflow.apigw.views.get_task_states import get_task_states + from bkflow.apigw.views.get_tasks_states import get_tasks_states from bkflow.apigw.views.get_template_detail import get_template_detail from bkflow.apigw.views.get_template_list import get_template_list + from bkflow.apigw.views.get_template_mock_data import get_template_mock_data from bkflow.apigw.views.grant_apigw_permissions_to_app import ( grant_apigw_permissions_to_app, ) @@ -51,6 +53,7 @@ from bkflow.apigw.views.renew_space_config import renew_space_config from bkflow.apigw.views.revoke_token import revoke_token from bkflow.apigw.views.update_template import update_template + from bkflow.apigw.views.validate_pipeline_tree import validate_pipeline_tree urlpatterns += [ url(r"^create_space/$", create_space), @@ -60,6 +63,7 @@ url(r"^space/(?P\d+)/create_template/$", create_template), url(r"^space/(?P\d+)/get_template_list/$", get_template_list), url(r"^space/(?P\d+)/template/(?P\d+)/get_template_detail/$", get_template_detail), + url(r"^space/(?P\d+)/template/(?P\d+)/get_template_mock_data/$", get_template_mock_data), url(r"^space/(?P\d+)/renew_space_config/$", renew_space_config), url(r"^space/(?P\d+)/get_space_configs/$", get_space_configs), url(r"^space/(?P\d+)/update_template/(?P\d+)/$", update_template), @@ -67,10 +71,12 @@ url(r"^space/(?P\d+)/create_task/$", create_task), url(r"^space/(?P\d+)/create_mock_task/$", create_mock_task), url(r"^space/(?P\d+)/create_task_without_template/$", create_task_without_template), + url(r"^space/(?P\d+)/validate_pipeline_tree/$", validate_pipeline_tree), url(r"^space/(?P\d+)/create_credential/$", create_credential), url(r"^space/(?P\d+)/get_task_list/$", get_task_list), url(r"^space/(?P\d+)/task/(?P\d+)/get_task_detail/$", get_task_detail), url(r"^space/(?P\d+)/task/(?P\d+)/get_task_states/$", get_task_states), + url(r"^space/(?P\d+)/get_tasks_states/$", get_tasks_states), url( r"^space/(?P\d+)/task/(?P\d+)/node/(?P\w+)/get_task_node_detail/$", get_task_node_detail, diff --git a/bkflow/apigw/views/create_space.py b/bkflow/apigw/views/create_space.py index 6f85605..467426d 100644 --- a/bkflow/apigw/views/create_space.py +++ b/bkflow/apigw/views/create_space.py @@ -21,6 +21,7 @@ from apigw_manager.apigw.decorators import apigw_require from blueapps.account.decorators import login_exempt +from django.conf import settings from django.db import transaction from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST @@ -46,7 +47,7 @@ def create_space(request): } """ data = json.loads(request.body) - if hasattr(request, "app"): + if hasattr(request, "app") and request.app.bk_app_code not in settings.APP_WHITE_LIST: data["app_code"] = request.app.bk_app_code ser = CreateSpaceSerializer(data=data) diff --git a/bkflow/apigw/views/create_task_without_template.py b/bkflow/apigw/views/create_task_without_template.py index bc70792..bff72fe 100644 --- a/bkflow/apigw/views/create_task_without_template.py +++ b/bkflow/apigw/views/create_task_without_template.py @@ -46,7 +46,8 @@ def create_task_without_template(request, space_id): "notify_type": {"fail": [], "success": []}, "notify_receivers": {"more_receiver": "", "receiver_group": []}, } - create_task_data.setdefault("extra_info", {}).update({"notify_config": DEFAULT_NOTIFY_CONFIG}) + notify_config = create_task_data.pop("notify_config", {}) or DEFAULT_NOTIFY_CONFIG + create_task_data.setdefault("extra_info", {}).update({"notify_config": notify_config}) client = TaskComponentClient(space_id=space_id) result = client.create_task(create_task_data) diff --git a/bkflow/apigw/views/get_task_list.py b/bkflow/apigw/views/get_task_list.py index 3e9d872..0316e44 100644 --- a/bkflow/apigw/views/get_task_list.py +++ b/bkflow/apigw/views/get_task_list.py @@ -44,6 +44,8 @@ def get_task_list(request, space_id): "create_at_start": "create_time__gte", "create_at_end": "create_time__lte", "name": "name__icontains", + "is_started": "is_started", + "is_finished": "is_finished", } for k, v in filter_map.items(): if k in data: diff --git a/bkflow/apigw/views/get_tasks_states.py b/bkflow/apigw/views/get_tasks_states.py new file mode 100644 index 0000000..320e74d --- /dev/null +++ b/bkflow/apigw/views/get_tasks_states.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 THL A29 Limited, +a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +either express or implied. See the License for the +specific language governing permissions and limitations under the License. + +We undertake not to change the open source license (MIT license) applicable + +to the current version of the project delivered to anyone in the future. +""" +import json + +from apigw_manager.apigw.decorators import apigw_require +from blueapps.account.decorators import login_exempt +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST + +from bkflow.apigw.decorators import check_jwt_and_space, return_json_response +from bkflow.apigw.serializers.task import GetTasksStatesSerializer +from bkflow.contrib.api.collections.task import TaskComponentClient + + +@login_exempt +@csrf_exempt +@require_POST +@apigw_require +@check_jwt_and_space +@return_json_response +def get_tasks_states(request, space_id): + data = json.loads(request.body) + ser = GetTasksStatesSerializer(data=data) + ser.is_valid(raise_exception=True) + + client = TaskComponentClient(space_id=space_id) + data = {"space_id": space_id, **ser.validated_data} + result = client.get_tasks_states(data=data) + return result diff --git a/bkflow/apigw/views/get_template_mock_data.py b/bkflow/apigw/views/get_template_mock_data.py new file mode 100644 index 0000000..44ec6d4 --- /dev/null +++ b/bkflow/apigw/views/get_template_mock_data.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 THL A29 Limited, +a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +either express or implied. See the License for the +specific language governing permissions and limitations under the License. + +We undertake not to change the open source license (MIT license) applicable + +to the current version of the project delivered to anyone in the future. +""" +from apigw_manager.apigw.decorators import apigw_require +from blueapps.account.decorators import login_exempt +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_GET + +from bkflow.apigw.decorators import check_jwt_and_space, return_json_response +from bkflow.template.models import TemplateMockData +from bkflow.template.serializers.template import TemplateMockDataSerializer +from bkflow.utils import err_code + + +@login_exempt +@csrf_exempt +@require_GET +@apigw_require +@check_jwt_and_space +@return_json_response +def get_template_mock_data(request, space_id, template_id): + mock_data_qs = TemplateMockData.objects.filter(space_id=space_id, template_id=template_id) + if "node_id" in request.GET: + mock_data_qs = mock_data_qs.filter(node_id=request.GET.get("node_id")) + response_ser = TemplateMockDataSerializer(mock_data_qs, many=True) + response = { + "result": True, + "data": response_ser.data, + "code": err_code.SUCCESS.code, + "message": "", + } + return response diff --git a/bkflow/apigw/views/validate_pipeline_tree.py b/bkflow/apigw/views/validate_pipeline_tree.py new file mode 100644 index 0000000..dabb8f5 --- /dev/null +++ b/bkflow/apigw/views/validate_pipeline_tree.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 THL A29 Limited, +a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +either express or implied. See the License for the +specific language governing permissions and limitations under the License. + +We undertake not to change the open source license (MIT license) applicable + +to the current version of the project delivered to anyone in the future. +""" +import json + +from apigw_manager.apigw.decorators import apigw_require +from blueapps.account.decorators import login_exempt +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST + +from bkflow.apigw.decorators import check_jwt_and_space, return_json_response +from bkflow.apigw.serializers.task import PipelineTreeSerializer +from bkflow.utils import err_code + + +@login_exempt +@csrf_exempt +@require_POST +@apigw_require +@check_jwt_and_space +@return_json_response +def validate_pipeline_tree(request, space_id): + data = json.loads(request.body) + ser = PipelineTreeSerializer(data=data) + ser.is_valid(raise_exception=True) + return {"result": True, "data": {}, "message": "", "code": err_code.SUCCESS.code} diff --git a/bkflow/pipeline_plugins/components/collections/http/v1_0.py b/bkflow/pipeline_plugins/components/collections/http/v1_0.py index be15c56..c36aa98 100644 --- a/bkflow/pipeline_plugins/components/collections/http/v1_0.py +++ b/bkflow/pipeline_plugins/components/collections/http/v1_0.py @@ -21,6 +21,7 @@ from __future__ import absolute_import +import json import traceback from copy import deepcopy @@ -133,6 +134,8 @@ def plugin_schedule(self, data, parent_data, callback_data=None): other = {"headers": {}, "timeout": timeout} if method.upper() not in ["GET", "HEAD"]: + if not isinstance(body, str): + body = json.dumps(body) other["data"] = body.encode("utf-8") other["headers"] = {"Content-type": "application/json"} diff --git a/bkflow/space/configs.py b/bkflow/space/configs.py index c325a97..c55973a 100644 --- a/bkflow/space/configs.py +++ b/bkflow/space/configs.py @@ -271,14 +271,14 @@ class GatewayExpressionConfig(BaseSpaceConfig): name = "gateway_expression" desc = _("网关表达式") default_value = "boolrule" - choices = ["boolrule", "FEEL"] + choices = ["boolrule", "FEEL", "MAKO"] @classmethod def validate(cls, value: str): if value not in cls.choices: raise ValidationError( f"[validate gateway expression error]: gateway expression only support " - f"'boolrule' or 'FEEL', value: {value}" + f"'boolrule' or 'FEEL' or 'MAKO', value: {value}" ) return True diff --git a/bkflow/task/admin.py b/bkflow/task/admin.py index 07bff58..db32a3f 100644 --- a/bkflow/task/admin.py +++ b/bkflow/task/admin.py @@ -51,8 +51,8 @@ class AutoRetryNodeStrategyAdmin(admin.ModelAdmin): @admin.register(models.TaskMockData) class TaskMockDataAdmin(admin.ModelAdmin): - list_display = ["id", "taskflow_id", "data"] - search_fields = ["taskflow_id"] + list_display = ["id", "taskflow_id", "mock_data_ids", "data"] + search_fields = ["taskflow_id", "mock_data_ids"] admin.site.register(TaskOperationRecord, BaseOperateRecordAdmin) diff --git a/bkflow/task/migrations/0009_taskmockdata_mock_data_ids.py b/bkflow/task/migrations/0009_taskmockdata_mock_data_ids.py new file mode 100644 index 0000000..1a70e25 --- /dev/null +++ b/bkflow/task/migrations/0009_taskmockdata_mock_data_ids.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.15 on 2024-09-14 08:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('task', '0008_alter_taskoperationrecord_instance_id'), + ] + + operations = [ + migrations.AddField( + model_name='taskmockdata', + name='mock_data_ids', + field=models.JSONField(default=dict, verbose_name='task mock data ids'), + ), + ] diff --git a/bkflow/task/models.py b/bkflow/task/models.py index cc8b7ea..6ae501e 100644 --- a/bkflow/task/models.py +++ b/bkflow/task/models.py @@ -108,7 +108,9 @@ def create_instance(self, *args, **kwargs): new_mock_data["outputs"] = { act_mappings[node_id]: outputs for node_id, outputs in mock_data.get("outputs", {}).items() } - TaskMockData.objects.create(taskflow_id=instance.id, data=new_mock_data) + TaskMockData.objects.create( + taskflow_id=instance.id, data=new_mock_data, mock_data_ids=mock_data.get("mock_data_ids", {}) + ) # create auto retry strategy arn_creator = AutoRetryNodeStrategyCreator(taskflow_id=instance.id, root_pipeline_id=instance.instance_id) arn_creator.batch_create_strategy(pipeline_tree) @@ -149,12 +151,8 @@ class TaskInstance(models.Model): is_deleted = models.BooleanField("是否已经删除", default=False, help_text="表示当前实例是否删除") is_expired = models.BooleanField("是否已经过期", default=False, help_text="运行时被定期清理即为过期") snapshot_id = models.BigIntegerField(verbose_name="实例结构数据ID", null=True, blank=True, db_index=True) - execution_snapshot_id = models.BigIntegerField( - verbose_name="用于实例执行的结构数据ID", null=True, blank=True, db_index=True - ) - tree_info_id = models.BigIntegerField( - verbose_name="提前计算好的一些流程结构数据ID", null=True, blank=True, db_index=True - ) + execution_snapshot_id = models.BigIntegerField(verbose_name="用于实例执行的结构数据ID", null=True, blank=True, db_index=True) + tree_info_id = models.BigIntegerField(verbose_name="提前计算好的一些流程结构数据ID", null=True, blank=True, db_index=True) extra_info = models.JSONField(verbose_name="额外信息", default=dict) objects = TaskInstanceManager() @@ -375,6 +373,7 @@ class TaskMockData(models.Model): id = models.BigAutoField(verbose_name="ID", primary_key=True) taskflow_id = models.BigIntegerField(verbose_name="taskflow id", db_index=True) data = models.JSONField(verbose_name="task mock data") + mock_data_ids = models.JSONField(verbose_name="task mock data ids", default=dict) create_time = models.DateTimeField("创建时间", auto_now_add=True, db_index=True) class Meta: @@ -386,5 +385,6 @@ def to_json(self): "id": self.id, "taskflow_id": self.taskflow_id, "data": self.data, + "mock_data_ids": self.mock_data_ids, "create_time": self.create_time, } diff --git a/bkflow/task/views.py b/bkflow/task/views.py index 8fc07e9..3ecbf0c 100644 --- a/bkflow/task/views.py +++ b/bkflow/task/views.py @@ -73,6 +73,8 @@ class Meta: "start_time": ["gte", "lte"], "finish_time": ["gte", "lte"], "create_method": ["exact"], + "is_started": ["exact"], + "is_finished": ["exact"], } diff --git a/bkflow/utils/mako.py b/bkflow/utils/mako.py new file mode 100644 index 0000000..c2169d8 --- /dev/null +++ b/bkflow/utils/mako.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 THL A29 Limited, +a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +either express or implied. See the License for the +specific language governing permissions and limitations under the License. + +We undertake not to change the open source license (MIT license) applicable + +to the current version of the project delivered to anyone in the future. +""" + +from bamboo_engine.template.template import Template + + +def parse_mako_expression(expression, context): + parsed_result = Template(expression).render(context) + if parsed_result not in ["True", "False"]: + raise ValueError(f"The result of the expression must be a boolean, now is {parsed_result}") + return parsed_result == "True" diff --git a/bkflow/utils/pipeline.py b/bkflow/utils/pipeline.py index a14f985..382315a 100644 --- a/bkflow/utils/pipeline.py +++ b/bkflow/utils/pipeline.py @@ -23,6 +23,8 @@ from bkflow_feel.api import parse_expression from pipeline.parser.utils import recursive_replace_id +from bkflow.utils.mako import parse_mako_expression + DEFAULT_HORIZONTAL_PIPELINE_TREE = { "activities": { "node89f4f55f853d71c6a15e83d0d0ca": { @@ -208,4 +210,6 @@ def build_default_pipeline_tree(horizontal_canvas=True): def pipeline_gateway_expr_func(expr: str, context: dict, extra_info: dict, *args, **kwargs) -> bool: if extra_info.get("parse_lang") == "FEEL": return parse_expression(expression=expr) + if extra_info.get("parse_lang") == "MAKO": + return parse_mako_expression(expression=expr, context=context) return BoolRule(expr).test() diff --git a/config/default.py b/config/default.py index 47e4dcc..6788a60 100644 --- a/config/default.py +++ b/config/default.py @@ -22,6 +22,7 @@ import datetime import json +from bamboo_engine.config import Settings as BambooSettings from blueapps.conf.default_settings import * # noqa from blueapps.conf.log import get_logging_config_dict from blueapps.opentelemetry.utils import inject_logging_trace_info @@ -84,6 +85,8 @@ APP_INTERNAL_FROM_SUPERUSER_HEADER_KEY = "Bkflow-Internal-From-SuperUser" APP_INTERNAL_TOKEN_REQUEST_META_KEY = "HTTP_BKFLOW_INTERNAL_TOKEN" +APP_WHITE_LIST = env.APP_WHITE_LIST_STR.split(",") if env.APP_WHITE_LIST_STR else [] + # PAAS SERVICE DETECTION BKPAAS_SERVICE_ADDRESSES_BKSAAS = os.getenv("BKPAAS_SERVICE_ADDRESSES_BKSAAS") BKSAAS_DEFAULT_MODULE_NAME = "default" @@ -128,6 +131,62 @@ UUID_DIGIT_STARTS_SENSITIVE = True PIPELINE_EXCLUSIVE_GATEWAY_EXPR_FUNC = pipeline_gateway_expr_func +# pipeline mako render settings +MAKO_SANDBOX_SHIELD_WORDS = [ + "ascii", + "bytearray", + "bytes", + "callable", + "chr", + "classmethod", + "compile", + "delattr", + "dir", + "divmod", + "exec", + "eval", + "filter", + "frozenset", + "getattr", + "globals", + "hasattr", + "hash", + "help", + "id", + "input", + "isinstance", + "issubclass", + "iter", + "locals", + "map", + "memoryview", + "next", + "object", + "open", + "print", + "property", + "repr", + "setattr", + "staticmethod", + "super", + "type", + "vars", + "__import__", +] +BambooSettings.MAKO_SANDBOX_SHIELD_WORDS = MAKO_SANDBOX_SHIELD_WORDS +MAKO_SANDBOX_IMPORT_MODULES = { + "datetime": "datetime", + "re": "re", + "hashlib": "hashlib", + "random": "random", + "time": "time", + "os.path": "os.path", + "json": "json", +} +BambooSettings.MAKO_SANDBOX_IMPORT_MODULES = MAKO_SANDBOX_IMPORT_MODULES +# 支持 mako 表达式在 dict/list/tuple 情况下嵌套索引 +BambooSettings.ENABLE_RENDER_OBJ_BY_MAKO_STRING = True + # 所有环境的日志级别可以在这里配置 # LOG_LEVEL = 'INFO' diff --git a/env.py b/env.py index eac6a7e..11f0d90 100644 --- a/env.py +++ b/env.py @@ -131,3 +131,6 @@ # 文档中心链接 BK_DOC_CENTER_HOST = os.getenv("BK_DOC_CENTER_HOST", os.getenv("BK_DOCS_URL_PREFIX", "")).rstrip("/") + +# APP 白名单 +APP_WHITE_LIST_STR = os.getenv("BKAPP_APP_WHITE_LIST", "") # 逗号分隔的字符串 diff --git a/frontend/package.json b/frontend/package.json index e403d78..64e3b2a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -48,6 +48,7 @@ "vuedraggable": "^2.24.3", "vuex": "^2.4.0", "xlsx": "^0.18.5", + "xlsx-js-style": "^1.2.0", "xss": "^1.0.15" }, "devDependencies": { diff --git a/frontend/src/components/DecisionTable/ImportExport/ExportBtn.vue b/frontend/src/components/DecisionTable/ImportExport/ExportBtn.vue new file mode 100644 index 0000000..a971fd8 --- /dev/null +++ b/frontend/src/components/DecisionTable/ImportExport/ExportBtn.vue @@ -0,0 +1,185 @@ + + + diff --git a/frontend/src/components/DecisionTable/ImportExport/ImportBtn.vue b/frontend/src/components/DecisionTable/ImportExport/ImportBtn.vue new file mode 100644 index 0000000..c0a60d2 --- /dev/null +++ b/frontend/src/components/DecisionTable/ImportExport/ImportBtn.vue @@ -0,0 +1,199 @@ + + + diff --git a/frontend/src/components/DecisionTable/ImportExport/dataTransfer.js b/frontend/src/components/DecisionTable/ImportExport/dataTransfer.js new file mode 100644 index 0000000..d623461 --- /dev/null +++ b/frontend/src/components/DecisionTable/ImportExport/dataTransfer.js @@ -0,0 +1,181 @@ +const operateMap = { + equals: '', + 'not-equals': '!=', + contains: 'contains', + 'not-contains': 'not contains', + 'greater-than': '>', + 'less-than': '<', + 'greater-than-or-equal': '>=', + 'less-than-or-equal': '<=', + // 范围条件特殊处理 +}; + +const intRegex = /^\d+$/; + +export const getCellText = (data = {}) => { + const { compare, right = {} } = data; + if (!compare) return '--'; + const { type, value } = right.obj; + // 非范围条件 + if (compare in operateMap) { + return compare === 'equals' ? value : `${operateMap[compare]} ${value}`; + } + // 范围条件 + const isIntRange = type === 'int[Range]'; + let text = Array.isArray(value) ? value.join(',') : value; + text = isIntRange ? (`${value.start},${value.end}`) : text; + return `${compare === 'in-range' ? '' : '!'}[${text}]`; +}; + +export const validateFiled = (data = []) => { + const keys = ['desc', 'from', 'id', 'name', 'tips', 'type']; + const requiredKeys = ['from', 'id', 'name', 'type']; + let message = ''; + const ids = data.map(field => field.id); + + data.every((field) => { + // 不存在类型 + if (!['string', 'int', 'select'].includes(field.type)) { + message = `【${field.name}】列注释配置中type类型不存在`; + return false; + } + if (!['inputs', 'outputs'].includes(field.from)) { + message = `【${field.name}】列注释配置中from类型不存在`; + return false; + } + + // 下拉框类型 + if (field.type === 'select') { + keys.push('options'); + } + + // 缺少字段 + const missingKeys = keys.filter(key => !(key in field)); + if (missingKeys.length) { + message = `【${field.name}】列注释配置中缺少${missingKeys.join(',')}字段`; + return false; + } + + // id校验 + const idPattern = /^[a-zA-Z][a-zA-Z0-9]*$/.test(field.id); + if (!idPattern) { + message = `【${field.name}】列注释配置中key值只能由数字和字母组成且不能以数字开头`; + return false; + } + const idCount = ids.filter(id => id === field.id).length; + if (idCount > 1) { + message = `【${field.name}】列注释配置中字段标识已经存在`; + return false; + } + + // 必填字段没值 + const emptyRequiredKeys = requiredKeys.filter(key => !field[key]); + if (emptyRequiredKeys.length) { + message = `【${field.name}】列注释配置中缺少${emptyRequiredKeys.join(',')}字段为必填项`; + return false; + } + + // 下拉框类型 + if (field.type === 'select') { + // 校验下拉框类型格式 + const { options = {} } = field; + let isOptionsValid = Object.prototype.toString.call(options) === '[object Object]'; + isOptionsValid = isOptionsValid && (Array.isArray(options.items) && options.type === 'custom'); + isOptionsValid = isOptionsValid && options.items.every(item => (item.id && item.name)); + if (isOptionsValid) { + const names = new Set(); + const ids = new Set(); + const isUnique = options.items.every((item) => { + if (names.has(item.name) || ids.has(item.id)) { + return false; + } + names.add(item.name); + ids.add(item.id); + return true; + }); + message = isUnique ? '' : `【${field.name}】列注释配置中option的id和name不唯一`; + return isUnique; + } + message = `【${field.name}】列注释配置中options结构不对`; + return false; + } + + return true; + }); + + return message; +}; + +export const parseValue = (data = '', config) => { + // 解析出表格实际值,类型 + let value = String(data).trim(); + let type = 'equals'; + let message = ''; + + // 检查是否有操作符匹配 + for (const [key, text] of Object.entries(operateMap)) { + if (text && new RegExp(`^${text}`).test(value)) { + [, value] = value.split(text); + value = value.trim(); + type = key; + break; + } + } + + // 定义一个函数来验证整数 + const validateInt = (val) => { + if (!intRegex.test(val)) { + message = `【${config.name}】列存在非数字类型`; + return ''; + } + return Number(val); + }; + + // 处理范围条件(只有数字和下拉框类型有范围条件) + if (!operateMap[type] && value && /^(!)?\[.*\]$/.test(value)) { + const [v1, v2] = value.match(/^(!)?\[.*\]$/); + value = v1.slice(v2 ? 2 : 1, -1).split(',') + .map((item) => { + if (config.type === 'int') { + return validateInt(item); + } + const option = config.options.items.find(option => option.name === item.trim()); + if (!option) { + message = `【${config.name}】列存在所填选项不存在`; + return ''; + } + return option.id; + }); + type = `${v2 ? 'not-' : ''}in-range`; + return { value, type, message }; + } + + // 处理等于和其他条件 + if (config.type === 'int') { + value = validateInt(value); + } else if (config.type === 'select') { + const option = config.options.items.find(option => option.name === value); + if (!option) { + message = `【${config.name}】列存在所填选项不存在`; + value = ''; + } else { + value = option.id; + } + } + + return { value, type, message }; +}; + +export const getValueRight = (value, type, config) => { + let objType = config.type === 'select' ? 'select[Range]' : 'int[Range]'; + objType = type !== 'in-range' ? type : objType; + const objValue = type === 'in-range' && config.type === 'int' ? { start: value[0], end: value[1] } : value; + const obj = { + type: objType, + value: objValue, + }; + return { + obj, + type: 'value', + }; +}; diff --git a/frontend/src/components/DecisionTable/SideSlider/components/FieldSlider/index.vue b/frontend/src/components/DecisionTable/SideSlider/components/FieldSlider/index.vue index e9775b5..c0e010a 100644 --- a/frontend/src/components/DecisionTable/SideSlider/components/FieldSlider/index.vue +++ b/frontend/src/components/DecisionTable/SideSlider/components/FieldSlider/index.vue @@ -170,6 +170,11 @@ message: this.$t('必填项不能为空'), trigger: 'blur', }, + { + validator: this.checkOption, + message: this.$t('id或name不唯一'), + trigger: 'blur', + }, ], }, }; @@ -190,6 +195,20 @@ }, []); return !keys.includes(val); }, + // 校验option是否存在相同id和name + checkOption(val) { + if (!val) return false; + const names = new Set(); + const ids = new Set(); + return val.items.every((item) => { + if (names.has(item.name) || ids.has(item.id)) { + return false; + } + names.add(item.name); + ids.add(item.id); + return true; + }); + }, updateOptions(options) { this.formData.options = options; // 单独校验 diff --git a/frontend/src/components/DecisionTable/components/TableHeader.vue b/frontend/src/components/DecisionTable/components/TableHeader.vue index 7f88501..6cdb1bd 100644 --- a/frontend/src/components/DecisionTable/components/TableHeader.vue +++ b/frontend/src/components/DecisionTable/components/TableHeader.vue @@ -6,7 +6,24 @@ :title="item.name" :style="{ width: `${widthMap[item.type]}px` }" :class="['header-cell', { 'index-header': item.type === 'index' }]"> -