@@ -7,7 +7,7 @@ VUE_APP_BASE_API = '' | |||||
VUE_APP_DATA_API = '' | VUE_APP_DATA_API = '' | ||||
# minio | # minio | ||||
VUE_APP_MINIO_API = '' | |||||
VUE_APP_MINIO_API = '' // 建议使用与当前 web 服务域名的域名 | |||||
# atlas | |||||
# atlas 模型炼知 | |||||
VUE_APP_ATLAS_HOST = '' | VUE_APP_ATLAS_HOST = '' |
@@ -0,0 +1,13 @@ | |||||
ENV = 'test' | |||||
# 默认BASE URL | |||||
VUE_APP_BASE_API = '' | |||||
# 数据管理 | |||||
VUE_APP_DATA_API = '' | |||||
# minio | |||||
VUE_APP_MINIO_API = '' | |||||
# atlas | |||||
VUE_APP_ATLAS_HOST = '' |
@@ -53,15 +53,19 @@ module.exports = { | |||||
"vue/attribute-hyphenation": "off", | "vue/attribute-hyphenation": "off", | ||||
"vue/comment-directive": "off", | "vue/comment-directive": "off", | ||||
"vue/prop-name-casing": "off", | "vue/prop-name-casing": "off", | ||||
"vue/max-attributes-per-line": [ | |||||
2, | |||||
{ | |||||
singleline: 20, | |||||
multiline: { | |||||
max: 1, | |||||
allowFirstLine: false | |||||
} | |||||
} | |||||
] | |||||
"vue/max-attributes-per-line": [2, { | |||||
singleline: 20, | |||||
multiline: { | |||||
max: 1, | |||||
allowFirstLine: false | |||||
}} | |||||
], | |||||
"vue/html-indent": ["error", 2, { | |||||
"attribute": 1, | |||||
"baseIndent": 1, | |||||
"closeBracket": 0, | |||||
"alignAttributesVertically": true, | |||||
"ignores": [] | |||||
}] | |||||
} | } | ||||
}; | }; |
@@ -0,0 +1,26 @@ | |||||
## 1.1.0 (2020-10-26) | |||||
### Breaking Change | |||||
- [数据管理] 导入数据集功能重构。系统提供标准数据集模板,用户按照规范导入数据集文件,实现数据集全功能兼容 | |||||
- [训练管理] 支持OneFlow、TensorFlow、Pytorch等主流框架的多机多卡模式分布式训练 | |||||
- [训练管理] 训练时支持将已有模型作为训练入参 | |||||
- [训练管理] 训练时支持区分训练数据集与验证数据集 | |||||
- [训练管理] 训练支持延时启动、定时停止功能 | |||||
- [训练管理] 训练日志、运行日志下载功能优化,避免大文件导致的浏览器卡死 | |||||
### Features | |||||
- [数据管理] 将标签和数据集拆分,引入「标签组」统一管理标签 | |||||
- [数据管理] 超大数据集操作流程优化。实现超大数据集(40w+文件)的全流程平滑操作 | |||||
- [数据管理] 数据集图片手动标注优化。支持标注像素级位置、大小调整,支持常见缩放、拖拽、平移等操作 | |||||
- [数据管理] 数据集状态逻辑优化,代码性能优化等 | |||||
- [训练管理] 断点续训功能、模型下载功能、模型保存功能支持通过目录树选择模型文件/文件夹 | |||||
- [训练管理] 文件上传增加进度条展示 | |||||
- [训练管理] 训练创建页,增加运行命令预览功能;训练详情页,增加算法在线编辑跳转功能 | |||||
- [训练管理] 镜像管理功能,镜像名称支持自定义;支持镜像的删除、修改等操作 | |||||
- [训练管理] 增加训练失败异常信息反馈 | |||||
### Bug Fixs | |||||
- [数据管理] 标注详情里面不同分辨率图片标注位置偏移 bug |
@@ -0,0 +1,211 @@ | |||||
Apache License | |||||
Version 2.0, January 2004 | |||||
http://www.apache.org/licenses/ | |||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION | |||||
1. Definitions. | |||||
"License" shall mean the terms and conditions for use, reproduction, | |||||
and distribution as defined by Sections 1 through 9 of this document. | |||||
"Licensor" shall mean the copyright owner or entity authorized by | |||||
the copyright owner that is granting the License. | |||||
"Legal Entity" shall mean the union of the acting entity and all | |||||
other entities that control, are controlled by, or are under common | |||||
control with that entity. For the purposes of this definition, | |||||
"control" means (i) the power, direct or indirect, to cause the | |||||
direction or management of such entity, whether by contract or | |||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the | |||||
outstanding shares, or (iii) beneficial ownership of such entity. | |||||
"You" (or "Your") shall mean an individual or Legal Entity | |||||
exercising permissions granted by this License. | |||||
"Source" form shall mean the preferred form for making modifications, | |||||
including but not limited to software source code, documentation | |||||
source, and configuration files. | |||||
"Object" form shall mean any form resulting from mechanical | |||||
transformation or translation of a Source form, including but | |||||
not limited to compiled object code, generated documentation, | |||||
and conversions to other media types. | |||||
"Work" shall mean the work of authorship, whether in Source or | |||||
Object form, made available under the License, as indicated by a | |||||
copyright notice that is included in or attached to the work | |||||
(an example is provided in the Appendix below). | |||||
"Derivative Works" shall mean any work, whether in Source or Object | |||||
form, that is based on (or derived from) the Work and for which the | |||||
editorial revisions, annotations, elaborations, or other modifications | |||||
represent, as a whole, an original work of authorship. For the purposes | |||||
of this License, Derivative Works shall not include works that remain | |||||
separable from, or merely link (or bind by name) to the interfaces of, | |||||
the Work and Derivative Works thereof. | |||||
"Contribution" shall mean any work of authorship, including | |||||
the original version of the Work and any modifications or additions | |||||
to that Work or Derivative Works thereof, that is intentionally | |||||
submitted to Licensor for inclusion in the Work by the copyright owner | |||||
or by an individual or Legal Entity authorized to submit on behalf of | |||||
the copyright owner. For the purposes of this definition, "submitted" | |||||
means any form of electronic, verbal, or written communication sent | |||||
to the Licensor or its representatives, including but not limited to | |||||
communication on electronic mailing lists, source code control systems, | |||||
and issue tracking systems that are managed by, or on behalf of, the | |||||
Licensor for the purpose of discussing and improving the Work, but | |||||
excluding communication that is conspicuously marked or otherwise | |||||
designated in writing by the copyright owner as "Not a Contribution." | |||||
"Contributor" shall mean Licensor and any individual or Legal Entity | |||||
on behalf of whom a Contribution has been received by Licensor and | |||||
subsequently incorporated within the Work. | |||||
2. Grant of Copyright License. Subject to the terms and conditions of | |||||
this License, each Contributor hereby grants to You a perpetual, | |||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable | |||||
copyright license to reproduce, prepare Derivative Works of, | |||||
publicly display, publicly perform, sublicense, and distribute the | |||||
Work and such Derivative Works in Source or Object form. | |||||
3. Grant of Patent License. Subject to the terms and conditions of | |||||
this License, each Contributor hereby grants to You a perpetual, | |||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable | |||||
(except as stated in this section) patent license to make, have made, | |||||
use, offer to sell, sell, import, and otherwise transfer the Work, | |||||
where such license applies only to those patent claims licensable | |||||
by such Contributor that are necessarily infringed by their | |||||
Contribution(s) alone or by combination of their Contribution(s) | |||||
with the Work to which such Contribution(s) was submitted. If You | |||||
institute patent litigation against any entity (including a | |||||
cross-claim or counterclaim in a lawsuit) alleging that the Work | |||||
or a Contribution incorporated within the Work constitutes direct | |||||
or contributory patent infringement, then any patent licenses | |||||
granted to You under this License for that Work shall terminate | |||||
as of the date such litigation is filed. | |||||
4. Redistribution. You may reproduce and distribute copies of the | |||||
Work or Derivative Works thereof in any medium, with or without | |||||
modifications, and in Source or Object form, provided that You | |||||
meet the following conditions: | |||||
(a) You must give any other recipients of the Work or | |||||
Derivative Works a copy of this License; and | |||||
(b) You must cause any modified files to carry prominent notices | |||||
stating that You changed the files; and | |||||
(c) You must retain, in the Source form of any Derivative Works | |||||
that You distribute, all copyright, patent, trademark, and | |||||
attribution notices from the Source form of the Work, | |||||
excluding those notices that do not pertain to any part of | |||||
the Derivative Works; and | |||||
(d) If the Work includes a "NOTICE" text file as part of its | |||||
distribution, then any Derivative Works that You distribute must | |||||
include a readable copy of the attribution notices contained | |||||
within such NOTICE file, excluding those notices that do not | |||||
pertain to any part of the Derivative Works, in at least one | |||||
of the following places: within a NOTICE text file distributed | |||||
as part of the Derivative Works; within the Source form or | |||||
documentation, if provided along with the Derivative Works; or, | |||||
within a display generated by the Derivative Works, if and | |||||
wherever such third-party notices normally appear. The contents | |||||
of the NOTICE file are for informational purposes only and | |||||
do not modify the License. You may add Your own attribution | |||||
notices within Derivative Works that You distribute, alongside | |||||
or as an addendum to the NOTICE text from the Work, provided | |||||
that such additional attribution notices cannot be construed | |||||
as modifying the License. | |||||
You may add Your own copyright statement to Your modifications and | |||||
may provide additional or different license terms and conditions | |||||
for use, reproduction, or distribution of Your modifications, or | |||||
for any such Derivative Works as a whole, provided Your use, | |||||
reproduction, and distribution of the Work otherwise complies with | |||||
the conditions stated in this License. | |||||
5. Submission of Contributions. Unless You explicitly state otherwise, | |||||
any Contribution intentionally submitted for inclusion in the Work | |||||
by You to the Licensor shall be under the terms and conditions of | |||||
this License, without any additional terms or conditions. | |||||
Notwithstanding the above, nothing herein shall supersede or modify | |||||
the terms of any separate license agreement you may have executed | |||||
with Licensor regarding such Contributions. | |||||
6. Trademarks. This License does not grant permission to use the trade | |||||
names, trademarks, service marks, or product names of the Licensor, | |||||
except as required for reasonable and customary use in describing the | |||||
origin of the Work and reproducing the content of the NOTICE file. | |||||
7. Disclaimer of Warranty. Unless required by applicable law or | |||||
agreed to in writing, Licensor provides the Work (and each | |||||
Contributor provides its Contributions) on an "AS IS" BASIS, | |||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or | |||||
implied, including, without limitation, any warranties or conditions | |||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A | |||||
PARTICULAR PURPOSE. You are solely responsible for determining the | |||||
appropriateness of using or redistributing the Work and assume any | |||||
risks associated with Your exercise of permissions under this License. | |||||
8. Limitation of Liability. In no event and under no legal theory, | |||||
whether in tort (including negligence), contract, or otherwise, | |||||
unless required by applicable law (such as deliberate and grossly | |||||
negligent acts) or agreed to in writing, shall any Contributor be | |||||
liable to You for damages, including any direct, indirect, special, | |||||
incidental, or consequential damages of any character arising as a | |||||
result of this License or out of the use or inability to use the | |||||
Work (including but not limited to damages for loss of goodwill, | |||||
work stoppage, computer failure or malfunction, or any and all | |||||
other commercial damages or losses), even if such Contributor | |||||
has been advised of the possibility of such damages. | |||||
9. Accepting Warranty or Additional Liability. While redistributing | |||||
the Work or Derivative Works thereof, You may choose to offer, | |||||
and charge a fee for, acceptance of support, warranty, indemnity, | |||||
or other liability obligations and/or rights consistent with this | |||||
License. However, in accepting such obligations, You may act only | |||||
on Your own behalf and on Your sole responsibility, not on behalf | |||||
of any other Contributor, and only if You agree to indemnify, | |||||
defend, and hold each Contributor harmless for any liability | |||||
incurred by, or claims asserted against, such Contributor by reason | |||||
of your accepting any such warranty or additional liability. | |||||
END OF TERMS AND CONDITIONS | |||||
APPENDIX: How to apply the Apache License to your work. | |||||
To apply the Apache License to your work, attach the following | |||||
boilerplate notice, with the fields enclosed by brackets "[]" | |||||
replaced with your own identifying information. (Don't include | |||||
the brackets!) The text should be enclosed in the appropriate | |||||
comment syntax for the file format. We also recommend that a | |||||
file or class name and description of purpose be included on the | |||||
same "printed page" as the copyright notice for easier | |||||
identification within third-party archives. | |||||
Copyright [yyyy] [name of copyright owner] | |||||
Licensed under the Apache License, Version 2.0 (the "License"); | |||||
you may not use this file except in compliance with the License. | |||||
You may obtain a copy of the License at | |||||
http://www.apache.org/licenses/LICENSE-2.0 | |||||
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. | |||||
Other dependencies and licenses: | |||||
---------------------------------------------------------------------------------------- | |||||
Open Source Software Licensed Under the Apache License, Version 2.0: | |||||
The below software in this distribution may have been modified. | |||||
---------------------------------------------------------------------------------------- | |||||
1. EL-ADMIN | |||||
Copyright 2019-2020 Zheng Jie |
@@ -1,10 +1,76 @@ | |||||
# 一站式开发平台-前端 | |||||
# 之江天枢-前端 | |||||
**之江天枢一站式人工智能开源平台**(简称:**之江天枢**),包括海量数据处理、交互式模型构建(包含Notebook和模型可视化)、AI模型高效训练。多维度产品形态满足从开发者到大型企业的不同需求,将提升人工智能技术的研发效率、扩大算法模型的应用范围,进一步构建人工智能生态“朋友圈”。 | |||||
## 特性 | |||||
* 一站式开发 | |||||
* 集成先进算法 | |||||
* 灵活易用 | |||||
* 性能优越 | |||||
## 预览 | |||||
 | |||||
## 源码部署 | |||||
### 1. 下载源码 | |||||
``` bash | |||||
git clone https://codeup.teambition.com/zhejianglab/dubhe-web.git | |||||
# 进入根目录 | |||||
cd dubhe-web | |||||
``` | |||||
### 2. 配置 | |||||
根据需要修改如下配置文件 | |||||
``` | |||||
config/index.js | |||||
settings.js | |||||
.env.production | |||||
``` | |||||
### 3. 构建 | |||||
``` bash | |||||
# 安装项目依赖 | |||||
npm install | |||||
# 构建生产环境 | |||||
npm run build:prod | |||||
``` | |||||
### 4. 部署 | |||||
- 构建完成后会在根目录生成 dist 文件夹,并将该文件夹上传至服务器; | |||||
- 在服务器 nginx.conf 文件中添加如下配置; | |||||
``` nginx | |||||
server { | |||||
listen 80; # 端口 | |||||
server_name localhost; # 域名/外网IP | |||||
location / { | |||||
root /home/wwwroot/dubhe-web/dist; # dist 文件夹根目录 | |||||
index index.html; | |||||
try_files $uri $uri/ /index.html; | |||||
} | |||||
} | |||||
``` | |||||
- 保存 `nginx.conf` 并重启 Nginx 使之生效。 | |||||
## 本地开发 | ## 本地开发 | ||||
``` bash | ``` bash | ||||
# 进入前端项目根目录 | |||||
cd webapp | |||||
# 下载源码 | |||||
git clone https://codeup.teambition.com/zhejianglab/dubhe-web.git | |||||
# 进入项目根目录 | |||||
cd dubhe-web | |||||
# 安装依赖 | # 安装依赖 | ||||
npm install | npm install | ||||
@@ -1,6 +1,6 @@ | |||||
{ | { | ||||
"name": "dubhe-web", | "name": "dubhe-web", | ||||
"version": "1.0.0", | |||||
"version": "1.1.0", | |||||
"description": "之江天枢人工智能开源平台", | "description": "之江天枢人工智能开源平台", | ||||
"author": "zhejianglab", | "author": "zhejianglab", | ||||
"keywords": [ | "keywords": [ | ||||
@@ -13,6 +13,8 @@ | |||||
"scripts": { | "scripts": { | ||||
"dev": "vue-cli-service serve --open", | "dev": "vue-cli-service serve --open", | ||||
"build:prod": "vue-cli-service build", | "build:prod": "vue-cli-service build", | ||||
"build:test": "vue-cli-service build --mode test", | |||||
"build:dev": "vue-cli-service build --mode development", | |||||
"lint": "eslint --ext .js,.vue src", | "lint": "eslint --ext .js,.vue src", | ||||
"fix": "eslint --fix --ext .js,.vue src", | "fix": "eslint --fix --ext .js,.vue src", | ||||
"lint:style": "stylelint src/**/*.{html,vue,css,sass,scss}", | "lint:style": "stylelint src/**/*.{html,vue,css,sass,scss}", | ||||
@@ -54,6 +56,7 @@ | |||||
"filereader-stream": "^2.0.0", | "filereader-stream": "^2.0.0", | ||||
"jquery": "^3.5.1", | "jquery": "^3.5.1", | ||||
"jquery-contextmenu": "^2.9.1", | "jquery-contextmenu": "^2.9.1", | ||||
"js-beautify": "^1.13.0", | |||||
"js-cookie": "2.2.0", | "js-cookie": "2.2.0", | ||||
"jsencrypt": "^3.0.0-rc.1", | "jsencrypt": "^3.0.0-rc.1", | ||||
"json2csv": "^5.0.1", | "json2csv": "^5.0.1", | ||||
@@ -72,7 +75,9 @@ | |||||
"v-hotkey": "^0.8.0", | "v-hotkey": "^0.8.0", | ||||
"vee-validate": "^3.3.0", | "vee-validate": "^3.3.0", | ||||
"vue": "2.6.10", | "vue": "2.6.10", | ||||
"vue-copy-to-clipboard": "^1.0.3", | |||||
"vue-prism-component": "^1.2.0", | "vue-prism-component": "^1.2.0", | ||||
"vue-prism-editor": "^1.2.2", | |||||
"vue-router": "^3.0.2", | "vue-router": "^3.0.2", | ||||
"vuex": "3.1.0" | "vuex": "3.1.0" | ||||
}, | }, | ||||
@@ -16,7 +16,9 @@ | |||||
<template> | <template> | ||||
<div id="app"> | <div id="app"> | ||||
<router-view /> | |||||
<keep-alive include="DataSet"> | |||||
<router-view/> | |||||
</keep-alive> | |||||
</div> | </div> | ||||
</template> | </template> | ||||
@@ -16,9 +16,9 @@ | |||||
import request from '@/utils/request'; | import request from '@/utils/request'; | ||||
export function batchFinishAnnotation(data) { | |||||
export function batchFinishAnnotation(data, datasetId) { | |||||
return request({ | return request({ | ||||
url: 'api/data/datasets/files/annotations', | |||||
url: `api/data/datasets/files/${datasetId}/annotations`, | |||||
method: 'post', | method: 'post', | ||||
data, | data, | ||||
}); | }); | ||||
@@ -33,6 +33,13 @@ export function delAnnotation(id) { | |||||
}); | }); | ||||
} | } | ||||
export function track(id) { | |||||
return request({ | |||||
url: `api/data/datasets/files/annotations/auto/track/${id}`, | |||||
method: 'get', | |||||
}); | |||||
} | |||||
export function autoAnnotate(ids) { | export function autoAnnotate(ids) { | ||||
const data = { datasetIds: ids }; | const data = { datasetIds: ids }; | ||||
return request({ | return request({ | ||||
@@ -31,6 +31,14 @@ export function createLabel(id, label) { | |||||
}); | }); | ||||
} | } | ||||
export function editLabel(id, label) { | |||||
return request({ | |||||
url: `api/data/datasets/labels/${id}`, | |||||
method: 'put', | |||||
data: label, | |||||
}); | |||||
} | |||||
export function getAutoLabels() { | export function getAutoLabels() { | ||||
return request({ | return request({ | ||||
url: 'api/data/datasets/labels/auto', | url: 'api/data/datasets/labels/auto', | ||||
@@ -32,6 +32,15 @@ export function detail(id) { | |||||
}); | }); | ||||
} | } | ||||
// 数据集状态(导入数据集轮询使用) | |||||
export function queryDatasetStatus(ids) { | |||||
return request({ | |||||
url: `/api/data/datasets/status`, | |||||
method: 'get', | |||||
params: { datasetIds: ids }, | |||||
}); | |||||
} | |||||
export function add(data) { | export function add(data) { | ||||
return request({ | return request({ | ||||
url: 'api/data/datasets', | url: 'api/data/datasets', | ||||
@@ -57,6 +66,13 @@ export function editDataset(data) { | |||||
}); | }); | ||||
} | } | ||||
export function topDataset(data) { | |||||
return request({ | |||||
url: `api/data/datasets/${data.id}/top`, | |||||
method: 'get', | |||||
}); | |||||
} | |||||
// 导入自定义数据集 | // 导入自定义数据集 | ||||
export function addCustomDataset(data) { | export function addCustomDataset(data) { | ||||
return request({ | return request({ | ||||
@@ -153,9 +169,9 @@ export function postDataEnhance(datasetId, types = []) { | |||||
} | } | ||||
// 指定原始文件,获取增强文件列表 | // 指定原始文件,获取增强文件列表 | ||||
export function getEnhanceFileList(fileId) { | |||||
export function getEnhanceFileList(datasetId, fileId) { | |||||
return request({ | return request({ | ||||
url: `api/data/datasets/${fileId}/enhanceFileList`, | |||||
url: `api/data/datasets/${datasetId}/${fileId}/enhanceFileList`, | |||||
}); | }); | ||||
} | } | ||||
@@ -173,4 +189,13 @@ export function queryDatasetsCount() { | |||||
}); | }); | ||||
} | } | ||||
// 查询数据集状态 | |||||
export function queryDatasetsProgress(params) { | |||||
return request({ | |||||
url: `/api/data/datasets/progress`, | |||||
method: 'get', | |||||
params, | |||||
}); | |||||
} | |||||
export default { list, add, del }; | export default { list, add, del }; |
@@ -0,0 +1,89 @@ | |||||
/** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||||
* | |||||
* Licensed under the Apache License, Version 2.0 (the "License"); | |||||
* you may not use this file except in compliance with the License. | |||||
* You may obtain a copy of the License at | |||||
* | |||||
* http://www.apache.org/licenses/LICENSE-2.0 | |||||
* | |||||
* 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. | |||||
* ============================================================= | |||||
*/ | |||||
import request from '@/utils/request'; | |||||
// 创建标签组 | |||||
export function add(data) { | |||||
return request({ | |||||
url: `/api/data/labelGroup`, | |||||
method: 'post', | |||||
data, | |||||
}); | |||||
} | |||||
// 编辑标签组 | |||||
export function edit(data) { | |||||
return request({ | |||||
url: `/api/data/labelGroup/${data.id}`, | |||||
method: 'put', | |||||
data, | |||||
}); | |||||
} | |||||
// 删除标签组 | |||||
export function del(ids) { | |||||
return request({ | |||||
url: `/api/data/labelGroup`, | |||||
method: 'delete', | |||||
data: {ids}, | |||||
}); | |||||
} | |||||
// 复制标签组 | |||||
export function copy(data) { | |||||
return request({ | |||||
url: `/api/data/labelGroup/copy`, | |||||
method: 'post', | |||||
data, | |||||
}); | |||||
} | |||||
// 标签组列表分页查询 | |||||
export function list(params) { | |||||
return request({ | |||||
url: `/api/data/labelGroup/query`, | |||||
method: 'get', | |||||
params, | |||||
}); | |||||
} | |||||
// 标签组列表的简况查询 用于详情页选择标签组列举 | |||||
export function getLabelGroupList(params) { | |||||
return request({ | |||||
url: `/api/data/labelGroup/getList/${params}`, | |||||
method: 'get', | |||||
}); | |||||
} | |||||
// 获取标签组详情 | |||||
export function getLabelGroupDetail(id) { | |||||
return request({ | |||||
url: `/api/data/labelGroup/${id}`, | |||||
method: 'get', | |||||
}); | |||||
} | |||||
export function importLabelGroup(form) { | |||||
return request.post(`api/data/labelGroup/import`, form, { | |||||
headers: { | |||||
'Content-Type': 'multipart/form-data', | |||||
}, | |||||
}); | |||||
} | |||||
export default { list, add, del, edit }; | |||||
@@ -18,7 +18,7 @@ import request from '@/utils/request'; | |||||
export function harborProjectNames() { | export function harborProjectNames() { | ||||
return request({ | return request({ | ||||
url: `api/v1/ptImage/project`, | |||||
url: `api/v1/ptImage/imageNameList`, | |||||
method: 'get', | method: 'get', | ||||
}); | }); | ||||
} | } | ||||
@@ -0,0 +1,50 @@ | |||||
/** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||||
* | |||||
* Licensed under the Apache License, Version 2.0 (the "License"); | |||||
* you may not use this file except in compliance with the License. | |||||
* You may obtain a copy of the License at | |||||
* | |||||
* http://www.apache.org/licenses/LICENSE-2.0 | |||||
* | |||||
* 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. | |||||
* ============================================================= | |||||
*/ | |||||
import request from '@/utils/request'; | |||||
export function getPodLog(params) { | |||||
return request({ | |||||
url: 'api/v1/pod/log', | |||||
method: 'get', | |||||
params, | |||||
}); | |||||
} | |||||
export function downloadPodLog(params) { | |||||
return request({ | |||||
url: 'api/v1/pod/log/download', | |||||
method: 'get', | |||||
params, | |||||
}); | |||||
} | |||||
export function batchDownloadPodLog(data) { | |||||
return request({ | |||||
url: 'api/v1/pod/log/download', | |||||
method: 'post', | |||||
responseType: 'blob', | |||||
data, | |||||
}); | |||||
} | |||||
export function countPodLogs(podVOList) { | |||||
return request({ | |||||
url: 'api/v1/pod/log/count', | |||||
method: 'post', | |||||
data: { podVOList }, | |||||
}); | |||||
} |
@@ -32,11 +32,27 @@ export function add(data) { | |||||
}); | }); | ||||
} | } | ||||
export function project() { | |||||
export function edit(data) { | |||||
return request({ | return request({ | ||||
url: 'api/v1/ptImage/project', | |||||
url: 'api/v1/ptImage', | |||||
method: 'put', | |||||
data, | |||||
}); | |||||
} | |||||
export function del(ids) { | |||||
return request({ | |||||
url: 'api/v1/ptImage', | |||||
method: 'delete', | |||||
data: ids, | |||||
}); | |||||
} | |||||
export function imageNameList() { | |||||
return request({ | |||||
url: 'api/v1/ptImage/imageNameList', | |||||
method: 'get', | method: 'get', | ||||
}); | }); | ||||
} | } | ||||
export default { list, add }; | |||||
export default { list, add, edit }; |
@@ -72,17 +72,17 @@ export function getJobList(params) { | |||||
}); | }); | ||||
} | } | ||||
export function getTrainLog(params) { | |||||
export function getJobDetail(jobId) { | |||||
return request({ | return request({ | ||||
url: `api/v1/trainLog`, | |||||
url: `api/v1/trainJob/jobDetail`, | |||||
method: 'get', | method: 'get', | ||||
params, | |||||
params: { id: jobId }, | |||||
}); | }); | ||||
} | } | ||||
export function downloadTrainLog(params) { | |||||
export function getTrainLog(params) { | |||||
return request({ | return request({ | ||||
url: `api/v1/trainLog/download`, | |||||
url: `api/v1/trainLog`, | |||||
method: 'get', | method: 'get', | ||||
params, | params, | ||||
}); | }); | ||||
@@ -109,4 +109,11 @@ export function getGarafanaInfo(jobId) { | |||||
}); | }); | ||||
} | } | ||||
export default { list, add, edit, del, getGarafanaInfo }; | |||||
export function getPods(jobId) { | |||||
return request({ | |||||
url: `api/v1/trainLog/pod/${jobId}`, | |||||
method: 'get', | |||||
}); | |||||
} | |||||
export default { list, add, edit, del }; |
@@ -214,6 +214,10 @@ img.responsive { | |||||
color: red; | color: red; | ||||
} | } | ||||
.success { | |||||
color: $successColor; | |||||
} | |||||
.g3 { | .g3 { | ||||
color: #333; | color: #333; | ||||
} | } | ||||
@@ -126,7 +126,6 @@ | |||||
} | } | ||||
.text { | .text { | ||||
height: 19px; | |||||
font-size: 14px; | font-size: 14px; | ||||
line-height: 19px; | line-height: 19px; | ||||
color: rgba(68, 68, 68, 1); | color: rgba(68, 68, 68, 1); | ||||
@@ -143,18 +142,6 @@ | |||||
} | } | ||||
} | } | ||||
} | } | ||||
.fr { | |||||
margin-right: 5%; | |||||
} | |||||
.iframe { | |||||
width: 90%; | |||||
height: 500px; | |||||
margin: 40px 5%; | |||||
overflow: auto; | |||||
border: #ccc solid 1px; | |||||
} | |||||
} | } | ||||
.eltabs-inlineblock.el-tabs { | .eltabs-inlineblock.el-tabs { | ||||
@@ -242,3 +229,9 @@ | |||||
color: $primaryHoverColor; | color: $primaryHoverColor; | ||||
} | } | ||||
} | } | ||||
.tree-container { | |||||
height: 500px; | |||||
margin-top: 20px; | |||||
overflow-y: scroll; | |||||
} |
@@ -44,6 +44,14 @@ | |||||
} | } | ||||
} | } | ||||
// el-radio border | |||||
.el-radio.is-bordered { | |||||
&.is-checked, | |||||
&:hover { | |||||
border-color: $primaryBorderColor; | |||||
} | |||||
} | |||||
// radio-button | // radio-button | ||||
.el-radio-button__inner { | .el-radio-button__inner { | ||||
padding: 8px 25px; | padding: 8px 25px; | ||||
@@ -298,3 +306,27 @@ | |||||
.el-tooltip__popper { | .el-tooltip__popper { | ||||
max-width: 50%; | max-width: 50%; | ||||
} | } | ||||
.info-alert.is-light { | |||||
margin-bottom: 20px; | |||||
background-color: $primaryBg; | |||||
.el-alert__content { | |||||
width: 100%; | |||||
} | |||||
.el-alert__icon { | |||||
color: $primaryColor; | |||||
} | |||||
.slot-content { | |||||
display: flex; | |||||
justify-content: space-between; | |||||
width: 100%; | |||||
color: #000; | |||||
a { | |||||
color: $primaryColor; | |||||
} | |||||
} | |||||
} |
@@ -69,7 +69,6 @@ ol { | |||||
a, | a, | ||||
a:focus, | a:focus, | ||||
a:hover { | a:hover { | ||||
color: inherit; | |||||
text-decoration: none; | text-decoration: none; | ||||
cursor: pointer; | cursor: pointer; | ||||
} | } | ||||
@@ -87,6 +86,10 @@ ol li { | |||||
color: $infoColor; | color: $infoColor; | ||||
} | } | ||||
.fontBold { | |||||
font-weight: bold; | |||||
} | |||||
.primary-bg { | .primary-bg { | ||||
background-color: $primaryColor; | background-color: $primaryColor; | ||||
@@ -100,3 +103,20 @@ p.error-message { | |||||
font-size: 12px; | font-size: 12px; | ||||
color: $red; | color: $red; | ||||
} | } | ||||
// 新手导引 | |||||
.v-tour { | |||||
div.v-step { | |||||
color: #2e4fde; | |||||
background: #d8dfff; | |||||
} | |||||
button.v-step__button { | |||||
color: #2e4fde; | |||||
border-color: #2e4fde; | |||||
&:hover { | |||||
background: #f3f7ff; | |||||
} | |||||
} | |||||
} |
@@ -33,6 +33,7 @@ $imageBg: #f8f8f8; | |||||
$borderColorBase: #ebeef5; | $borderColorBase: #ebeef5; | ||||
$borderColorDark: #c0c4cc; | $borderColorDark: #c0c4cc; | ||||
$black: #001529; | $black: #001529; | ||||
$dark: #323232; | |||||
// sidebar | // sidebar | ||||
$menuBg: #f3f7ff; | $menuBg: #f3f7ff; | ||||
@@ -97,10 +97,10 @@ const BaseModal = { | |||||
return ( | return ( | ||||
<div class='modal-footer'> | <div class='modal-footer'> | ||||
{ this.showCancel && ( | { this.showCancel && ( | ||||
<el-button onClick={this.handleCancel}>{this.cancelText}</el-button> | |||||
<el-button id="cancel" onClick={this.handleCancel}>{this.cancelText}</el-button> | |||||
) | ) | ||||
} | } | ||||
<el-button type='primary' disabled={this.disabled} onClick={this.handleOk} loading={this.loading}>{this.okText}</el-button> | |||||
<el-button id="ok" type='primary' disabled={this.disabled} onClick={this.handleOk} loading={this.loading}>{this.okText}</el-button> | |||||
</div> | </div> | ||||
); | ); | ||||
}; | }; | ||||
@@ -19,6 +19,7 @@ | |||||
<span class="cd-opts-left"> | <span class="cd-opts-left"> | ||||
<el-button | <el-button | ||||
v-if="crud.optShow.add" | v-if="crud.optShow.add" | ||||
id="toAdd" | |||||
v-bind="addProps" | v-bind="addProps" | ||||
class="filter-item" | class="filter-item" | ||||
type="primary" | type="primary" | ||||
@@ -31,6 +32,7 @@ | |||||
<slot name="left" /> | <slot name="left" /> | ||||
<el-button | <el-button | ||||
v-if="crud.optShow.del" | v-if="crud.optShow.del" | ||||
id="toDelete" | |||||
slot="reference" | slot="reference" | ||||
class="filter-item" | class="filter-item" | ||||
type="danger" | type="danger" | ||||
@@ -18,6 +18,7 @@ | |||||
<span> | <span> | ||||
<el-button | <el-button | ||||
v-if="crud.optShow.reset" | v-if="crud.optShow.reset" | ||||
id="toReset" | |||||
class="filter-item" | class="filter-item" | ||||
:icon="crud.props.resetIconShow ? `el-icon-refresh-left` : ''" | :icon="crud.props.resetIconShow ? `el-icon-refresh-left` : ''" | ||||
@click="resetQuery" | @click="resetQuery" | ||||
@@ -25,6 +26,7 @@ | |||||
{{ crud.props.optText.reset }} | {{ crud.props.optText.reset }} | ||||
</el-button> | </el-button> | ||||
<el-button | <el-button | ||||
id="toQuery" | |||||
class="filter-item" | class="filter-item" | ||||
type="primary" | type="primary" | ||||
:icon="crud.props.searchIconShow ? `el-icon-search` : ''" | :icon="crud.props.searchIconShow ? `el-icon-search` : ''" | ||||
@@ -0,0 +1,133 @@ | |||||
/** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||||
* | |||||
* Licensed under the Apache License, Version 2.0 (the "License"); | |||||
* you may not use this file except in compliance with the License. | |||||
* You may obtain a copy of the License at | |||||
* | |||||
* http://www.apache.org/licenses/LICENSE-2.0 | |||||
* | |||||
* 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. | |||||
* ============================================================= | |||||
*/ | |||||
<script> | |||||
import { reactive } from '@vue/composition-api'; | |||||
import { findAncestorSvg } from '@/utils'; | |||||
export default { | |||||
name: 'Drag', | |||||
props: { | |||||
width: Number, | |||||
height: Number, | |||||
resetOnStart: { | |||||
type: Boolean, | |||||
default: false, | |||||
}, | |||||
onDragStart: Function, | |||||
onDragMove: Function, | |||||
onDragEnd: Function, | |||||
}, | |||||
setup(props) { | |||||
const { resetOnStart, onDragStart, onDragMove, onDragEnd } = props; | |||||
const state = reactive({ | |||||
x: undefined, | |||||
y: undefined, | |||||
dx: 0, | |||||
dy: 0, | |||||
isDragging: false, // 鼠标按下 | |||||
isMoving: true, // 鼠标移动 | |||||
}); | |||||
function getPoint(event) { | |||||
// 容器尺寸 | |||||
const bound = findAncestorSvg(event).getBoundingClientRect(); | |||||
const { clientX, clientY } = event; | |||||
return { | |||||
x: clientX - bound.left, | |||||
y: clientY - bound.top, | |||||
}; | |||||
} | |||||
function dragStart(event) { | |||||
const point = getPoint(event); | |||||
const nextState = { | |||||
isDragging: true, | |||||
isMoving: false, | |||||
dx: resetOnStart ? 0 : state.dx, | |||||
dy: resetOnStart ? 0 : state.dy, | |||||
x: resetOnStart ? point.x : -state.dx + point.x, | |||||
y: resetOnStart ? point.y : -state.dy + point.y, | |||||
}; | |||||
Object.assign(state, nextState); | |||||
if (typeof onDragStart === 'function') onDragStart(nextState, event); | |||||
} | |||||
function dragMove(event) { | |||||
if (!state.isDragging) return; | |||||
const point = getPoint(event); | |||||
// 避免无效移动 | |||||
if(Math.abs(point.x - state.x) < 2 && Math.abs(point.y - state.y) < 2) return; | |||||
const nextState = { | |||||
isDragging: true, | |||||
isMoving: true, | |||||
dx: point.x - state.x, | |||||
dy: point.y - state.y, | |||||
}; | |||||
Object.assign(state, nextState); | |||||
if (typeof onDragMove === 'function') onDragMove(state, event); | |||||
} | |||||
function dragEnd(event) { | |||||
const nextState = { | |||||
isDragging: false, | |||||
isMoving: false, | |||||
}; | |||||
const prevState = { ...state }; | |||||
Object.assign(state, nextState); | |||||
// 传递 prevState | |||||
if (typeof onDragEnd === 'function') onDragEnd(state, event, { | |||||
prevState, | |||||
}); | |||||
} | |||||
return { | |||||
state, | |||||
dragStart, | |||||
dragMove, | |||||
dragEnd, | |||||
}; | |||||
}, | |||||
render() { | |||||
const children = this.$scopedSlots.default; | |||||
return ( | |||||
<g> | |||||
{this.state.isDragging && | |||||
( | |||||
<rect | |||||
width={this.width} | |||||
height={this.height} | |||||
onMousemove={this.dragMove} | |||||
onMouseup={this.dragEnd} | |||||
fill='transparent' | |||||
/> | |||||
)} | |||||
{ typeof children === 'function' && ( | |||||
children({ | |||||
state: this.state, | |||||
dragStart: this.dragStart, | |||||
dragMove: this.dragMove, | |||||
dragEnd: this.dragEnd, | |||||
}) | |||||
) } | |||||
</g> | |||||
); | |||||
}, | |||||
}; | |||||
</script> |
@@ -0,0 +1,19 @@ | |||||
/** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||||
* | |||||
* Licensed under the Apache License, Version 2.0 (the "License"); | |||||
* you may not use this file except in compliance with the License. | |||||
* You may obtain a copy of the License at | |||||
* | |||||
* http://www.apache.org/licenses/LICENSE-2.0 | |||||
* | |||||
* 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. | |||||
* ============================================================= | |||||
*/ | |||||
import Drag from './drag'; | |||||
export default Drag; |
@@ -38,19 +38,18 @@ export default { | |||||
}; | }; | ||||
</script> | </script> | ||||
<style lang="scss"> | <style lang="scss"> | ||||
@import '@/assets/styles/variables.scss'; | |||||
.exception { | .exception { | ||||
display: flex; | display: flex; | ||||
align-items: center; | align-items: center; | ||||
justify-content: center; | |||||
height: 100%; | height: 100%; | ||||
margin: 0 auto; | |||||
color: $infoColor; | |||||
text-align: center; | text-align: center; | ||||
.imgBlock { | .imgBlock { | ||||
font-size: 48px; | font-size: 48px; | ||||
} | } | ||||
.content { | |||||
margin-top: 10px; | |||||
} | |||||
} | } | ||||
</style> | </style> |
@@ -26,7 +26,7 @@ | |||||
import create from './iconfont'; | import create from './iconfont'; | ||||
const IconFont = create({ | const IconFont = create({ | ||||
scriptUrl: '//at.alicdn.com/t/font_1756495_bycxbb6pz6s.js', | |||||
scriptUrl: '//at.alicdn.com/t/font_1756495_k4j524i5vng.js', | |||||
extraIconProps: { class: 'svg-icon' }, | extraIconProps: { class: 'svg-icon' }, | ||||
}); | }); | ||||
@@ -53,7 +53,7 @@ | |||||
:class="rootClass + '__img'" | :class="rootClass + '__img'" | ||||
@click="onClickImg(dataImage)" | @click="onClickImg(dataImage)" | ||||
> | > | ||||
<el-tag v-if="imageTagVisible && dataImage.status > 1" :hit="false" class="image-tag" :color="imageLabelTag[dataImage.id]['color']">{{ imageLabelTag[dataImage.id]['text'] }}</el-tag> | |||||
<el-tag v-if="imageTagVisible && statusCodeMap[dataImage.status] !== 'UNANNOTATED'" :hit="false" class="image-tag" :color="imageLabelTag[dataImage.id]['color']">{{ imageLabelTag[dataImage.id]['text'] }}</el-tag> | |||||
<el-checkbox v-show="showOption(dataImage.id)" :value="selectedMap[dataImage.id]" class="image-checkbox" @change="checked => handleCheck(dataImage, checked)" /> | <el-checkbox v-show="showOption(dataImage.id)" :value="selectedMap[dataImage.id]" class="image-checkbox" @change="checked => handleCheck(dataImage, checked)" /> | ||||
<div v-show="showOption(dataImage.id)" :title="dataImage.name" class="img-name-row"> | <div v-show="showOption(dataImage.id)" :title="dataImage.name" class="img-name-row"> | ||||
<div class="img-name">{{ basename(dataImage.url) }}</div> | <div class="img-name">{{ basename(dataImage.url) }}</div> | ||||
@@ -74,6 +74,7 @@ | |||||
<script> | <script> | ||||
import Vue from 'vue'; | import Vue from 'vue'; | ||||
import { bucketHost } from '@/utils/minIO'; | import { bucketHost } from '@/utils/minIO'; | ||||
import { fileCodeMap, findKey, statusCodeMap } from '@/views/dataset/util'; | |||||
// eslint-disable-next-line import/no-extraneous-dependencies | // eslint-disable-next-line import/no-extraneous-dependencies | ||||
const path = require('path'); | const path = require('path'); | ||||
@@ -118,10 +119,13 @@ export default { | |||||
multipleSelected: [], | multipleSelected: [], | ||||
imageTagVisible: true, | imageTagVisible: true, | ||||
imgStatusMap: { | imgStatusMap: { | ||||
2: { 'text': '自动', 'color': '#468CFF' }, | |||||
3: { 'text': '人工', 'color': '#FF9943' }, | |||||
'UNRECOGNIZED': {'text': '未识别', 'color': '#FFFFFF'}, | |||||
'UNANNOTATED': {'text': '未标注', 'color': '#FFFFFF'}, | |||||
'AUTO_ANNOTATED': { 'text': '自动', 'color': '#468CFF' }, | |||||
'MANUAL_ANNOTATED': { 'text': '人工', 'color': '#FF9943' }, | |||||
}, | }, | ||||
hoverImg: null, | hoverImg: null, | ||||
statusCodeMap, | |||||
}; | }; | ||||
}, | }, | ||||
computed: { | computed: { | ||||
@@ -139,9 +143,9 @@ export default { | |||||
imageLabelTag() { | imageLabelTag() { | ||||
const labelTag = {}; | const labelTag = {}; | ||||
this.dataImages.forEach((item) => { | this.dataImages.forEach((item) => { | ||||
const statusInfo = this.imgStatusMap[item.status]; | |||||
const statusInfo = this.imgStatusMap[findKey(item.status, fileCodeMap)]; | |||||
const annotation = JSON.parse(item.annotation); | const annotation = JSON.parse(item.annotation); | ||||
let categoryName = '无标注'; | |||||
let categoryName = '未识别'; | |||||
let tagColor = '#db2a2a'; | let tagColor = '#db2a2a'; | ||||
if (statusInfo && (annotation instanceof Array) && annotation.length > 0) { | if (statusInfo && (annotation instanceof Array) && annotation.length > 0) { | ||||
const categoryId = annotation[0].category_id; | const categoryId = annotation[0].category_id; | ||||
@@ -17,11 +17,12 @@ | |||||
<template> | <template> | ||||
<div class="info-data-select"> | <div class="info-data-select"> | ||||
<el-select | <el-select | ||||
:style="{ width: '100%' }" | |||||
ref="selectRef" | |||||
:style="{ width: selectEleWidth }" | |||||
clearable | clearable | ||||
v-bind="attrs" | v-bind="attrs" | ||||
:value="state.sValue" | :value="state.sValue" | ||||
@change="handleChange" | |||||
v-on="listeners" | |||||
> | > | ||||
<el-option | <el-option | ||||
v-for="item in state.list" | v-for="item in state.list" | ||||
@@ -36,7 +37,7 @@ | |||||
</template> | </template> | ||||
<script> | <script> | ||||
import { isNil } from 'lodash'; | import { isNil } from 'lodash'; | ||||
import { reactive, watch, computed } from '@vue/composition-api'; | |||||
import { reactive, watch, computed, ref } from '@vue/composition-api'; | |||||
export default { | export default { | ||||
name: 'InfoSelect', | name: 'InfoSelect', | ||||
@@ -47,6 +48,7 @@ export default { | |||||
}, | }, | ||||
props: { | props: { | ||||
request: Function, | request: Function, | ||||
width: String, | |||||
value: { | value: { | ||||
type: [String, Number, Array], | type: [String, Number, Array], | ||||
}, | }, | ||||
@@ -62,9 +64,12 @@ export default { | |||||
type: Array, | type: Array, | ||||
default: () => ([]), | default: () => ([]), | ||||
}, | }, | ||||
innerRef: Function, | |||||
}, | }, | ||||
setup(props, ctx) { | setup(props, ctx) { | ||||
const { labelKey, valueKey } = props; | |||||
const { labelKey, valueKey, innerRef } = props; | |||||
const selectRef = !isNil(innerRef) ? innerRef() : ref(null); | |||||
const buildOptions = (list) => list.map(d => ({ | const buildOptions = (list) => list.map(d => ({ | ||||
...d, | ...d, | ||||
@@ -98,10 +103,18 @@ export default { | |||||
}); | }); | ||||
const attrs = computed(() => ctx.attrs); | const attrs = computed(() => ctx.attrs); | ||||
const selectEleWidth =computed(() => props.width || '100%'); | |||||
const listeners = computed(() => ({ | |||||
...ctx.listeners, | |||||
change: handleChange, | |||||
})); | |||||
return { | return { | ||||
state, | state, | ||||
selectEleWidth, | |||||
attrs, | attrs, | ||||
selectRef, | |||||
listeners, | |||||
handleChange, | handleChange, | ||||
}; | }; | ||||
}, | }, | ||||
@@ -1,217 +0,0 @@ | |||||
/** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||||
* | |||||
* Licensed under the Apache License, Version 2.0 (the "License"); | |||||
* you may not use this file except in compliance with the License. | |||||
* You may obtain a copy of the License at | |||||
* | |||||
* http://www.apache.org/licenses/LICENSE-2.0 | |||||
* | |||||
* 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. | |||||
* ============================================================= | |||||
*/ | |||||
<template> | |||||
<el-popover | |||||
v-model="addLabelTagVisible" | |||||
placement="bottom" | |||||
trigger="click" | |||||
:visible-arrow="false" | |||||
width="370" | |||||
height="370" | |||||
@hide="handleHide" | |||||
> | |||||
<div slot="default" class="add-label-tag"> | |||||
<el-tabs v-model="activeLabel" tab-position="left" @tab-click="handleTabClick"> | |||||
<el-tab-pane label="自动标注标签" name="systemLabel"> | |||||
<el-table :data="systemLabel" :show-header="false" height="290" row-class-name="tag-table-row"> | |||||
<el-table-column prop="chosen" class-name="no-ellipsis" width="30"> | |||||
<template slot-scope="scope"> | |||||
<el-checkbox v-model="scope.row.chosen" /> | |||||
</template> | |||||
</el-table-column> | |||||
<el-table-column prop="name" width="80" class-name="pl-0" /> | |||||
<el-table-column prop="color" align="right"> | |||||
<template slot-scope="scope"> | |||||
<el-color-picker v-model="scope.row.color" /> | |||||
</template> | |||||
</el-table-column> | |||||
</el-table> | |||||
</el-tab-pane> | |||||
<el-tab-pane label="自定义标签" name="customLabel"> | |||||
<div style="height: 290px;"> | |||||
<el-input v-model="newCustomLabel" placeholder="字符长度不能超过30" maxlength="30" @keyup.enter.native="addCustomLabel"> | |||||
<el-button slot="append" style="padding: 12px;" type="text" class="el-icon-check" @click="addCustomLabel" /> | |||||
</el-input> | |||||
<el-table :data="customLabel" :show-header="false" height="260" row-class-name="tag-table-row"> | |||||
<div slot="empty">暂无标签</div> | |||||
<el-table-column prop="chosen" class-name="no-ellipsis" width="30"> | |||||
<template slot-scope="scope"> | |||||
<el-checkbox v-model="scope.row.chosen" /> | |||||
</template> | |||||
</el-table-column> | |||||
<el-table-column prop="name" width="120" class-name="pl-0 ellipsis"> | |||||
<template slot-scope="scope"> | |||||
<span :title="scope.row.name">{{ scope.row.name }}</span> | |||||
</template> | |||||
</el-table-column> | |||||
<el-table-column prop="color" align="right" width="60"> | |||||
<template slot-scope="scope"> | |||||
<el-color-picker v-model="scope.row.color" /> | |||||
</template> | |||||
</el-table-column> | |||||
</el-table> | |||||
</div> | |||||
</el-tab-pane> | |||||
<el-tab-pane :disabled="![1, 2].includes(annotateType)" label="预置标签" name="presetLabel"> | |||||
<div style="height: 290px; padding: 40px 0 0 16px;"> | |||||
<el-radio-group v-model="chosenRadioId" class="block-label-group"> | |||||
<el-radio v-for="(value, key) in presetLabelList" :key="key" :label="key" :disabled="key==2 && annotateType==1"> | |||||
{{ value }} | |||||
</el-radio> | |||||
</el-radio-group> | |||||
</div> | |||||
</el-tab-pane> | |||||
</el-tabs> | |||||
<div class="add-label-foot" style=" padding-top: 10px; margin-bottom: 0; text-align: center;"> | |||||
<el-button type="text" @click="addLabelTagVisible = false">取消</el-button> | |||||
<el-button type="primary" @click="addLabelTag">确定</el-button> | |||||
</div> | |||||
</div> | |||||
<el-button slot="reference" type="text"> + {{ labelButtonText }}</el-button> | |||||
</el-popover> | |||||
</template> | |||||
<script> | |||||
import { find } from 'lodash'; | |||||
export default { | |||||
name: 'LabelPopover', | |||||
props: { | |||||
customLabel: { | |||||
type: Array, | |||||
default: () => [], | |||||
}, | |||||
systemLabel: { | |||||
type: Array, | |||||
default: () => [], | |||||
}, | |||||
presetLabelList: { | |||||
type: Object, | |||||
default: () => {}, | |||||
}, | |||||
chosenPresetLabelId: { | |||||
type: String, | |||||
}, | |||||
annotateType: { | |||||
type: Number, | |||||
default: 2, | |||||
}, | |||||
setPresetLabel: { | |||||
type: Function, | |||||
}, | |||||
setNoPresetLabel: { | |||||
type: Function, | |||||
}, | |||||
}, | |||||
data() { | |||||
return { | |||||
addLabelTagVisible: false, | |||||
activeLabel: 'systemLabel', // 默认为自动标注标签 | |||||
newCustomLabel: '', | |||||
chosenRadioId: undefined, | |||||
defaultLabelColor: '#6973FF', | |||||
}; | |||||
}, | |||||
computed: { | |||||
labelButtonText() { | |||||
return this.chosenPresetLabelId ? '修改标签' : '添加标签'; | |||||
}, | |||||
}, | |||||
watch: { | |||||
// 因为外部修改标注类型,本组件key不变,需监听外部变化来改变popover的标签页 | |||||
// eslint-disable-next-line func-names | |||||
'chosenPresetLabelId': function(next) { | |||||
if (next) { | |||||
this.activeLabel = 'presetLabel'; | |||||
this.chosenRadioId = this.chosenPresetLabelId; | |||||
} else { | |||||
this.activeLabel = 'systemLabel'; | |||||
} | |||||
}, | |||||
// eslint-disable-next-line func-names | |||||
'annotateType': function(next) { | |||||
if ([1, 5].includes(next)) { | |||||
this.activeLabel = 'systemLabel'; | |||||
} | |||||
}, | |||||
}, | |||||
created() { | |||||
// 修改预置标签时弹出popover为预置标签tab | |||||
if (this.chosenPresetLabelId) { | |||||
this.activeLabel = 'presetLabel'; | |||||
this.chosenRadioId = this.chosenPresetLabelId; | |||||
} | |||||
}, | |||||
methods: { | |||||
handleTabClick() { | |||||
// 切换tab清除了选中的预置标签 | |||||
this.chosenRadioId = undefined; | |||||
}, | |||||
findItem(list, name) { | |||||
return find(list, d => d.name === name); | |||||
}, | |||||
addCustomLabel() { | |||||
if (this.newCustomLabel.trim() !== '' && !this.findItem(this.customLabel, this.newCustomLabel)) { | |||||
this.customLabel.push({ | |||||
name: this.newCustomLabel, | |||||
color: this.defaultLabelColor, | |||||
chosen: true, | |||||
}); | |||||
this.newCustomLabel = ''; | |||||
} | |||||
}, | |||||
addLabelTag() { | |||||
if (this.activeLabel === 'presetLabel') { | |||||
if (!this.chosenRadioId === undefined) { | |||||
this.setNoPresetLabel(); // 若未选择预置标签,则不添加标签 | |||||
} else { | |||||
this.setPresetLabel(this.chosenRadioId); | |||||
} | |||||
} else { | |||||
this.setNoPresetLabel(); | |||||
} | |||||
this.addLabelTagVisible = false; | |||||
}, | |||||
handleHide() { | |||||
this.$emit('hide'); | |||||
}, | |||||
}, | |||||
}; | |||||
</script> | |||||
<style lang='scss'> | |||||
.tag-table-row { | |||||
td { | |||||
padding: 6px 0 0 0; | |||||
} | |||||
.cell { | |||||
white-space: nowrap; | |||||
} | |||||
} | |||||
.el-table { | |||||
.no-ellipsis .cell { | |||||
padding-right: 0; | |||||
text-overflow: unset; | |||||
} | |||||
.pl-0 .cell { | |||||
padding-left: 0; | |||||
} | |||||
} | |||||
</style> |
@@ -0,0 +1,125 @@ | |||||
/** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||||
* | |||||
* Licensed under the Apache License, Version 2.0 (the "License"); | |||||
* you may not use this file except in compliance with the License. | |||||
* You may obtain a copy of the License at | |||||
* | |||||
* http://www.apache.org/licenses/LICENSE-2.0 | |||||
* | |||||
* 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. | |||||
* ============================================================= | |||||
*/ | |||||
<template> | |||||
<div | |||||
v-mouse-wheel="getLog" | |||||
> | |||||
<prism-render :code="logTxt" /> | |||||
</div> | |||||
</template> | |||||
<script> | |||||
import PrismRender from '@/components/Prism'; | |||||
export default { | |||||
name: 'LogContainer', | |||||
components: { | |||||
PrismRender, | |||||
}, | |||||
props: { | |||||
// 日志请求的接口方法 | |||||
logGetter: { | |||||
type: Function, | |||||
required: true, | |||||
}, | |||||
// 查询日志需要用到的其他参数 | |||||
options: { | |||||
type: Object, | |||||
default: () => ({}), | |||||
}, | |||||
// 日志请求行数 | |||||
logLines: { | |||||
type: Number, | |||||
default: 50, | |||||
}, | |||||
showMsg: { | |||||
type: Boolean, | |||||
default: false, | |||||
}, | |||||
msg: { | |||||
type: String, | |||||
default: '', | |||||
}, | |||||
}, | |||||
data() { | |||||
return { | |||||
logList: [], | |||||
noMoreLog: false, | |||||
currentLogLine: 1, | |||||
logLoading: false, | |||||
logMsgInstance: null, | |||||
}; | |||||
}, | |||||
computed: { | |||||
getLogDisabled() { | |||||
return this.logLoading || this.noMoreLog; | |||||
}, | |||||
logTxt() { | |||||
return `${this.showMsg ? `${this.msg}\n` : ''}${this.logList.join('\n')}`; | |||||
}, | |||||
}, | |||||
methods: { | |||||
getLog(noWarning = false) { | |||||
if (this.getLogDisabled) { | |||||
return; | |||||
} | |||||
this.logLoading = true; | |||||
this.logGetter({ | |||||
...this.options, | |||||
startLine: this.currentLogLine, | |||||
lines: this.logLines, | |||||
}).then(res => { | |||||
this.logList = this.logList.concat(res.content); | |||||
this.currentLogLine = res.endLine + 1; | |||||
// 当请求到的行数小于请求行数时,冻结请求一秒 | |||||
if (res.lines < this.logLines) { | |||||
this.pauseRequest(); | |||||
// 当返回行数小于三行时提示日志已到达底部 | |||||
// TODO: logMsgInstance 到达底部提示是否应该设为,当有新的提示出现,关闭旧的提示,而不是等三秒后自动消失? | |||||
if (!noWarning && res.lines < 3 && !this.logMsgInstance) { | |||||
this.logMsgInstance = this.$message.warning({ | |||||
message: '已经到达日志底部了。', | |||||
onClose: this.onLogMsgClose, | |||||
}); | |||||
} | |||||
} | |||||
}).catch(err => { | |||||
this.pauseRequest(); | |||||
throw err; | |||||
}).finally(() => { | |||||
this.logLoading = false; | |||||
}); | |||||
}, | |||||
reset(getLog = false) { | |||||
this.logList = []; | |||||
this.noMoreLog = false; | |||||
this.currentLogLine = 1; | |||||
getLog && this.getLog(true); | |||||
}, | |||||
onLogMsgClose() { | |||||
this.logMsgInstance = null; | |||||
}, | |||||
pauseRequest() { | |||||
this.noMoreLog = true; | |||||
setTimeout(() => { | |||||
this.noMoreLog = false; | |||||
}, 1000); | |||||
}, | |||||
}, | |||||
}; | |||||
</script> |
@@ -0,0 +1,243 @@ | |||||
/** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||||
* | |||||
* Licensed under the Apache License, Version 2.0 (the "License"); | |||||
* you may not use this file except in compliance with the License. | |||||
* You may obtain a copy of the License at | |||||
* | |||||
* http://www.apache.org/licenses/LICENSE-2.0 | |||||
* | |||||
* 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. | |||||
* ============================================================= | |||||
*/ | |||||
<template> | |||||
<div> | |||||
<el-select | |||||
v-model="algoUsage" | |||||
placeholder="请选择数据集用途" | |||||
@change="onAlgorithmUsageChange" | |||||
> | |||||
<el-option :value="null" label="全部" /> | |||||
<el-option | |||||
v-for="item in algorithmUsageList" | |||||
:key="item.id" | |||||
:value="item.auxInfo" | |||||
:label="item.auxInfo" | |||||
/> | |||||
</el-select> | |||||
<el-select | |||||
v-model="dataSource" | |||||
placeholder="请选择您挂载的数据集" | |||||
filterable | |||||
value-key="id" | |||||
@change="onDataSourceChange" | |||||
> | |||||
<el-option | |||||
v-for="item in datasetIdList" | |||||
:key="item.id" | |||||
:value="item" | |||||
:label="item.name" | |||||
/> | |||||
</el-select> | |||||
<el-select | |||||
v-model="dataSourceVersion" | |||||
placeholder="请选择您挂载的数据集版本" | |||||
value-key="versionUrl" | |||||
@change="onDataSourceVersionChange" | |||||
> | |||||
<el-option | |||||
v-for="(item, index) in datasetVersionList" | |||||
:key="index" | |||||
:value="item" | |||||
:label="item.versionName" | |||||
/> | |||||
</el-select> | |||||
<el-tooltip effect="dark" :content="urlTooltip" placement="top"> | |||||
<i class="el-icon-warning-outline primary f18 v-text-top" /> | |||||
</el-tooltip> | |||||
<el-tooltip effect="dark" :disabled="!dataSourceVersion" :content="ofRecordTooltip" placement="top"> | |||||
<el-checkbox | |||||
v-model="useOfRecord" | |||||
:disabled="!ofRecordDisabled" | |||||
@change="onUseOfRecordChange" | |||||
>使用 OfRecord</el-checkbox> | |||||
</el-tooltip> | |||||
</div> | |||||
</template> | |||||
<script> | |||||
import { list as getAlgorithmUsages } from '@/api/algorithm/algorithmUsage'; | |||||
import { getPublishedDatasets, getDatasetVersions } from '@/api/preparation/dataset'; | |||||
export default { | |||||
name: 'DataSourceSelector', | |||||
props: { | |||||
type: { | |||||
type: String, | |||||
default: 'train', | |||||
}, | |||||
algorithmUsage: { | |||||
type: String, | |||||
default: null, | |||||
}, | |||||
dataSourceName: { | |||||
type: String, | |||||
default: null, | |||||
}, | |||||
dataSourcePath: { | |||||
type: String, | |||||
default: null, | |||||
}, | |||||
}, | |||||
data() { | |||||
return { | |||||
algorithmUsageList: [], | |||||
datasetIdList: [], | |||||
datasetVersionList: [], | |||||
algoUsage: null, | |||||
dataSource: null, | |||||
dataSourceVersion: null, | |||||
useOfRecord: false, | |||||
result: { | |||||
dataSourceType: null, | |||||
dataSourceName: null, | |||||
dataSourcePath: null, | |||||
imageCounts: null, | |||||
}, | |||||
}; | |||||
}, | |||||
computed: { | |||||
ofRecordTooltip() { | |||||
const content = this.dataSourceVersion?.versionOfRecordUrl | |||||
? '选中 OfRecord 将使用二进制数据集文件' | |||||
: '二进制数据集文件不可用或正在生成中'; | |||||
return content; | |||||
}, | |||||
ofRecordDisabled() { | |||||
return this.dataSourceVersion && this.dataSourceVersion.versionOfRecordUrl; | |||||
}, | |||||
urlTooltip() { | |||||
return this.type === 'verify' | |||||
? '请确保代码中包含“val_data_url”参数用于传输数据集路径' | |||||
: '请确保代码中包含“data_url”参数用于传输数据集路径'; | |||||
}, | |||||
}, | |||||
watch: { | |||||
result: { | |||||
deep: true, | |||||
handler(result) { | |||||
this.$emit('change', result); | |||||
}, | |||||
}, | |||||
}, | |||||
mounted() { | |||||
this.algoUsage = this.algoUsage || null; | |||||
this.getAlgorithmUsages(); | |||||
}, | |||||
methods: { | |||||
// handlers | |||||
onAlgorithmUsageChange(annotateType, datasetInit = false) { | |||||
// 算法用途修改之后,重新获取数据集列表,清空数据集结果 | |||||
this.getDataSetList(annotateType, datasetInit); | |||||
}, | |||||
async onDataSourceChange(dataSource) { | |||||
// 数据集选项发生变化时,获取版本列表,同时清空数据集版本、路径、OfRecord 相关信息 | |||||
this.datasetVersionList = await getDatasetVersions(dataSource.id); | |||||
this.result.dataSourceName = null; | |||||
this.result.dataSourcePath = null; | |||||
this.dataSourceVersion = null; | |||||
this.useOfRecord = false; | |||||
}, | |||||
onDataSourceVersionChange(version) { | |||||
// 选择数据集版本后,如果存在 OfRecordUrl,则默认勾选使用,否则禁用选择 | |||||
this.result.dataSourceName = `${this.dataSource.name}:${version.versionName}`; | |||||
this.result.imageCounts = version.imageCounts; | |||||
if (version.versionOfRecordUrl) { | |||||
this.useOfRecord = true; | |||||
this.result.dataSourcePath = version.versionOfRecordUrl; | |||||
} else { | |||||
this.useOfRecord = false; | |||||
this.result.dataSourcePath = version.versionUrl; | |||||
} | |||||
}, | |||||
onUseOfRecordChange(useOfRecord) { | |||||
this.result.dataSourcePath = useOfRecord | |||||
? this.dataSourceVersion.versionOfRecordUrl | |||||
: this.dataSourceVersion.versionUrl; | |||||
}, | |||||
// getters | |||||
getAlgorithmUsages() { | |||||
const params = { | |||||
isContainDefault: true, | |||||
current: 1, | |||||
size: 1000, | |||||
}; | |||||
getAlgorithmUsages(params).then(res => { | |||||
this.algorithmUsageList = res.result; | |||||
}); | |||||
}, | |||||
/** | |||||
* 用于获取数据集列表 | |||||
* @param {String} annotateType | |||||
* @param {Boolean} init 表示是否根据传入的数据集信息进行初始化 | |||||
*/ | |||||
async getDataSetList(annotateType, init) { | |||||
const params = { | |||||
size: 1000, | |||||
annotateType: annotateType || undefined, | |||||
}; | |||||
const data = await getPublishedDatasets(params); | |||||
this.datasetIdList = data.result; | |||||
this.datasetVersionList = []; | |||||
if (!init || !this.dataSourceName) { | |||||
this.dataSource = this.dataSourceVersion = this.result.dataSourceName = this.result.dataSourcePath = null; | |||||
} else { | |||||
// 根据传入的数据集信息进行初始化 | |||||
this.dataSource = this.datasetIdList.find(dataset => dataset.name === this.dataSourceName.split(':')[0]); | |||||
if (!this.dataSource) { | |||||
// 无法在数据集列表中找到同名的数据集 | |||||
this.$message.warning('原有数据集不存在,请重新选择'); | |||||
this.result.dataSourceName = this.result.dataSourcePath = null; | |||||
return; | |||||
} | |||||
this.datasetVersionList = await getDatasetVersions(this.dataSource.id); | |||||
// 首先尝试使用 versionUrl 进行数据集路径匹配 | |||||
this.dataSourceVersion = this.datasetVersionList.find(dataset => dataset.versionUrl === this.dataSourcePath); | |||||
if (!this.dataSourceVersion) { | |||||
// 无法匹配上时使用 versionOfRecordUrl 进行数据集路径匹配 | |||||
this.dataSourceVersion = this.datasetVersionList.find(dataset => dataset.versionOfRecordUrl === this.dataSourcePath); | |||||
this.dataSourceVersion && (this.useOfRecord = true); | |||||
} | |||||
// 如果二者都不能匹配上,说明原有的数据集版本目前不存在 | |||||
if (!this.dataSourceVersion) { | |||||
this.$message.warning('原有数据集版本不存在,请重新选择'); | |||||
this.result.dataSourcePath = null; | |||||
} | |||||
} | |||||
}, | |||||
// 外部调用接口方法 | |||||
updateAlgorithmUsage(usage, init = false) { | |||||
this.algoUsage = usage || null; | |||||
this.onAlgorithmUsageChange(usage, init); | |||||
}, | |||||
reset() { | |||||
Object.assign(this.result, { | |||||
dataSourceType: null, | |||||
dataSourceName: null, | |||||
dataSourcePath: null, | |||||
}); | |||||
this.algoUsage = null; | |||||
this.dataSource = null; | |||||
this.dataSourceVersion = null; | |||||
this.useOfRecord = false; | |||||
this.datasetVersionList = []; | |||||
}, | |||||
}, | |||||
}; | |||||
</script> |
@@ -0,0 +1,135 @@ | |||||
/** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||||
* | |||||
* Licensed under the Apache License, Version 2.0 (the "License"); | |||||
* you may not use this file except in compliance with the License. | |||||
* You may obtain a copy of the License at | |||||
* | |||||
* http://www.apache.org/licenses/LICENSE-2.0 | |||||
* | |||||
* 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. | |||||
* ============================================================= | |||||
*/ | |||||
<template> | |||||
<el-form | |||||
ref="form" | |||||
:label-width="labelWidth" | |||||
class="mb-20" | |||||
:model="item" | |||||
> | |||||
<el-form-item | |||||
:label="'运行参数' + (index + 1)" | |||||
class="param-pair-item" | |||||
prop="key" | |||||
:rules="keyRule" | |||||
> | |||||
<el-input | |||||
ref="keyInput" | |||||
v-model="item.key" | |||||
clearable | |||||
class="key-input" | |||||
:disabled="disabled" | |||||
@change="$emit('change', item)" | |||||
/> | |||||
</el-form-item> | |||||
<el-form-item | |||||
label="=" | |||||
label-width="30px" | |||||
class="param-pair-item" | |||||
prop="value" | |||||
:rules="valueRule" | |||||
> | |||||
<el-input | |||||
ref="valueInput" | |||||
v-model="item.value" | |||||
type="text" | |||||
class="value-input" | |||||
:disabled="disabled" | |||||
@change="$emit('change', item)" | |||||
/> | |||||
</el-form-item> | |||||
<el-button | |||||
v-if="!disabled && showAdd" | |||||
type="primary" | |||||
size="mini" | |||||
icon="el-icon-plus" | |||||
circle | |||||
@click="onAdd" | |||||
/> | |||||
<el-button | |||||
v-if="!disabled && showRemove" | |||||
type="danger" | |||||
size="mini" | |||||
icon="el-icon-minus" | |||||
circle | |||||
@click="onRemove" | |||||
/> | |||||
</el-form> | |||||
</template> | |||||
<script> | |||||
export default { | |||||
name: 'ParamPair', | |||||
props: { | |||||
index: { | |||||
type: Number, | |||||
required: true, | |||||
}, | |||||
item: { | |||||
type: Object, | |||||
default: () => ({}), | |||||
}, | |||||
labelWidth: { | |||||
type: String, | |||||
default: '100px', | |||||
}, | |||||
disabled: { | |||||
type: Boolean, | |||||
default: false, | |||||
}, | |||||
showAdd: { | |||||
type: Boolean, | |||||
default: false, | |||||
}, | |||||
showRemove: { | |||||
type: Boolean, | |||||
default: false, | |||||
}, | |||||
keyRule: { | |||||
type: Array, | |||||
default: () => ([]), | |||||
}, | |||||
valueRule: { | |||||
type: Array, | |||||
default: () => ([]), | |||||
}, | |||||
}, | |||||
methods: { | |||||
onAdd() { | |||||
this.$emit('add'); | |||||
}, | |||||
onRemove() { | |||||
this.$emit('remove', this.index); | |||||
}, | |||||
validate(callback) { | |||||
return this.$refs.form.validate(callback || undefined); | |||||
}, | |||||
}, | |||||
}; | |||||
</script> | |||||
<style lang="scss" scoped> | |||||
.key-input, | |||||
.value-input { | |||||
width: 150px; | |||||
} | |||||
.param-pair-item { | |||||
display: inline-block; | |||||
} | |||||
</style> |
@@ -18,8 +18,8 @@ | |||||
<div> | <div> | ||||
<el-form-item label="运行参数模式"> | <el-form-item label="运行参数模式"> | ||||
<el-radio-group v-model="paramsMode" @change="onParamsModeChange"> | <el-radio-group v-model="paramsMode" @change="onParamsModeChange"> | ||||
<el-radio-button :label="1">key-value</el-radio-button> | |||||
<el-radio-button :label="2">arguments</el-radio-button> | |||||
<el-radio :label="1" border class="mr-0">key-value</el-radio> | |||||
<el-radio :label="2" border>arguments</el-radio> | |||||
</el-radio-group> | </el-radio-group> | ||||
</el-form-item> | </el-form-item> | ||||
<el-form-item | <el-form-item | ||||
@@ -29,38 +29,21 @@ | |||||
:prop="prop" | :prop="prop" | ||||
style="margin-bottom: 0;" | style="margin-bottom: 0;" | ||||
> | > | ||||
<el-form ref="runParamForm" :label-width="paramLabelWidth"> | |||||
<div v-for="(item, index) in runParamsList" :key="index"> | |||||
<el-form-item | |||||
:ref="itemKeyId(index)" | |||||
style="display: inline-block; margin-bottom: 18px;" | |||||
:label="'运行参数' + (index+1)" | |||||
:prop="itemKeyId(index)" | |||||
:rules="{ | |||||
validator: (rule, value, callback) => {validateKey(callback, item, index)}, trigger: 'blur' | |||||
}" | |||||
:error="errMsg[index]" | |||||
> | |||||
<el-input v-model="item.key" :style="`width:${input1Width}px;`" clearable :disabled="disabled" @change="handleChange" /> | |||||
</el-form-item> | |||||
<el-form-item | |||||
:ref="itemValueId(index)" | |||||
style="display: inline-block;" | |||||
label="=" | |||||
label-width="30px" | |||||
:prop="itemValueId(index)" | |||||
:rules="{ | |||||
validator: (rule, value, callback) => {validateValue(callback, item, index)}, trigger: 'blur' | |||||
}" | |||||
> | |||||
<el-input v-model="item.value" type="text" :style="`width:${input2Width}px;`" :disabled="disabled" @change="handleChange" /> | |||||
</el-form-item> | |||||
<template v-if="!disabled"> | |||||
<el-button v-if="index==runParamsList.length-1" type="primary" size="mini" icon="el-icon-plus" circle @click="() => { addP(index) }" /> | |||||
<el-button v-if="runParamsList.length>1" type="danger" size="mini" icon="el-icon-minus" circle @click="() => { removeP(index) }" /> | |||||
</template> | |||||
</div> | |||||
</el-form> | |||||
<param-pair | |||||
v-for="(item, index) in runParamsList" | |||||
:key="item.id" | |||||
ref="paramPairs" | |||||
:item="runParamsList[index]" | |||||
:index="index" | |||||
:label-width="paramLabelWidth" | |||||
:disabled="disabled" | |||||
:show-add="index==runParamsList.length-1" | |||||
:show-remove="runParamsList.length>1" | |||||
:key-rule="keyRule" | |||||
@add="addP" | |||||
@remove="removeP" | |||||
@change="handleChange" | |||||
/> | |||||
</el-form-item> | </el-form-item> | ||||
<el-form-item v-show="paramsMode === 2" label="运行参数" :error="argErrorMsg"> | <el-form-item v-show="paramsMode === 2" label="运行参数" :error="argErrorMsg"> | ||||
<el-input | <el-input | ||||
@@ -76,30 +59,20 @@ | |||||
<script> | <script> | ||||
import { stringIsValidPythonVariable } from '@/utils'; | import { stringIsValidPythonVariable } from '@/utils'; | ||||
import ParamPair from './paramPair'; | |||||
export default { | export default { | ||||
name: 'RunParamForm', | name: 'RunParamForm', | ||||
components: { ParamPair }, | |||||
props: { | props: { | ||||
id: { | |||||
type: [Number, String], | |||||
default: null, | |||||
}, | |||||
runParamObj: { | runParamObj: { | ||||
type: Object, | type: Object, | ||||
default: () => {}, | |||||
default: () => ({}), | |||||
}, | }, | ||||
prop: { | prop: { | ||||
type: String, | type: String, | ||||
default: null, | default: null, | ||||
}, | }, | ||||
input1Width: { | |||||
type: Number, | |||||
default: 150, | |||||
}, | |||||
input2Width: { | |||||
type: Number, | |||||
default: 150, | |||||
}, | |||||
paramLabelWidth: { | paramLabelWidth: { | ||||
type: String, | type: String, | ||||
default: '100px', | default: '100px', | ||||
@@ -110,73 +83,34 @@ export default { | |||||
}, | }, | ||||
}, | }, | ||||
data() { | data() { | ||||
const isInputEmpty = value => { | |||||
return value === '' || value === null; | |||||
}; | |||||
const keyValidator = (rule, value, callback) => { | |||||
if (!isInputEmpty(value) && !stringIsValidPythonVariable(value)) { | |||||
callback(new Error('参数key必须是合法变量名')); | |||||
} else { | |||||
callback(); | |||||
} | |||||
}; | |||||
return { | return { | ||||
runParamsList: [], | runParamsList: [], | ||||
errMsg: [], | |||||
paramsMode: 1, | paramsMode: 1, | ||||
paramsArguments: '', | paramsArguments: '', | ||||
argErrorMsg: null, | argErrorMsg: null, | ||||
validateKey: (callback, item, index) => { | |||||
// 先校验是不是都为空,若都为空则通过 | |||||
const isEmptyKey = this.isInputEmpty(item.key); | |||||
const isEmptyValue = this.isInputEmpty(item.value); | |||||
if (isEmptyKey && isEmptyValue) { | |||||
// 可能之前value有校验错误信息 | |||||
this.$refs[this.itemValueId(index)][0].form.clearValidate(this.itemValueId(index)); | |||||
callback(); | |||||
return; | |||||
} | |||||
// 再校验自己是不是合法的变量名 | |||||
if (!stringIsValidPythonVariable(item.key)) { | |||||
callback(new Error('参数key必须是合法变量名')); | |||||
return; | |||||
} | |||||
// 然后和value联合校验 | |||||
if (isEmptyKey) { | |||||
callback(new Error('请输入参数key')); | |||||
return; | |||||
} if (isEmptyValue) { | |||||
this.$refs.runParamForm.validateField(this.itemValueId(index)); | |||||
} else { | |||||
callback(); | |||||
} | |||||
}, | |||||
validateValue: (callback, item, index) => { | |||||
// 先校验是不是都为空,若都为空则通过 | |||||
const isEmptyKey = this.isInputEmpty(item.key); | |||||
const isEmptyValue = this.isInputEmpty(item.value); | |||||
if (isEmptyKey && isEmptyValue) { | |||||
// 可能之前key有校验错误信息 | |||||
this.$refs[this.itemKeyId(index)][0].form.clearValidate(this.itemKeyId(index)); | |||||
callback(); | |||||
return; | |||||
} | |||||
// 输入框格式保证了其类似一定是字符串,只需不传空即可 | |||||
// 然后和key联合校验 | |||||
if (isEmptyValue) { | |||||
callback(); | |||||
return; | |||||
} if (isEmptyKey) { | |||||
this.$refs.runParamForm.validateField(this.itemKeyId(index)); | |||||
} else { | |||||
callback(); | |||||
} | |||||
}, | |||||
// 整体校验规则:对 key 做 python 变量名有效性校验,对 value 不做任何校验 | |||||
keyRule: [{ | |||||
validator: keyValidator, | |||||
trigger: 'blur', | |||||
}], | |||||
paramId: 0, | |||||
paramRepeatWarning: null, | |||||
hasError: false, | |||||
}; | }; | ||||
}, | }, | ||||
watch: { | watch: { | ||||
id(newValue) { | |||||
if (newValue === null || isNaN(newValue)) { | |||||
/** | |||||
* newValue为null时的一种情况是与el-form组合使用的 | |||||
* crud组件触发了cancelCU方法,此时不需更新 | |||||
*/ | |||||
return; | |||||
} | |||||
this.syncListData(); | |||||
}, | |||||
runParamObj() { | runParamObj() { | ||||
this.syncListData(); | this.syncListData(); | ||||
}, | }, | ||||
@@ -185,19 +119,12 @@ export default { | |||||
this.syncListData(); | this.syncListData(); | ||||
}, | }, | ||||
methods: { | methods: { | ||||
isInputEmpty(value) { | |||||
return value === '' || value === null; | |||||
}, | |||||
itemKeyId(index) { | |||||
return `runParamsList.${ index }.key`; | |||||
}, | |||||
itemValueId(index) { | |||||
return `runParamsList.${ index }.value`; | |||||
}, | |||||
addP() { | addP() { | ||||
this.runParamsList.push({ | this.runParamsList.push({ | ||||
key: '', | key: '', | ||||
value: '', | value: '', | ||||
// eslint-disable-next-line no-plusplus | |||||
id: this.paramId++, | |||||
}); | }); | ||||
}, | }, | ||||
removeP(i) { | removeP(i) { | ||||
@@ -205,12 +132,17 @@ export default { | |||||
this.updateRunParamObj(); | this.updateRunParamObj(); | ||||
}, | }, | ||||
syncListData() { | syncListData() { | ||||
const rpObj = { ...this.runParamObj}; | |||||
const list = []; | const list = []; | ||||
for (const formKey in rpObj) { | |||||
list.push({ | |||||
key: formKey, | |||||
value: typeof (rpObj[formKey]) === 'object' ? JSON.stringify(rpObj[formKey]) : rpObj[formKey], | |||||
for (const key in this.runParamObj) { | |||||
const objItem = this.runParamsList.find(p => p.key === key); | |||||
if (objItem) { | |||||
objItem.value = this.runParamObj[key]; | |||||
} | |||||
list.push(objItem || { | |||||
key, | |||||
value: this.runParamObj[key], | |||||
// eslint-disable-next-line no-plusplus | |||||
id: this.paramId++, | |||||
}); | }); | ||||
} | } | ||||
this.runParamsList = list; | this.runParamsList = list; | ||||
@@ -221,33 +153,59 @@ export default { | |||||
this.convertPairsToArgs(); | this.convertPairsToArgs(); | ||||
} | } | ||||
}, | }, | ||||
handleChange() { | |||||
handleChange(paramPair) { | |||||
// 当参数对的值改变时 key 为空,则把对于的 param 删除 | |||||
if (!paramPair.key) { | |||||
const paramIndex = this.runParamsList.findIndex(p => p.id === paramPair.id); | |||||
this.runParamsList.splice(paramIndex, 1); | |||||
} | |||||
if (!this.runParamsList.length) { | |||||
this.addP(); | |||||
} | |||||
this.updateRunParamObj(); | this.updateRunParamObj(); | ||||
}, | }, | ||||
// 提供修改参数的入口, 如果参数存在则可修改 | |||||
updateParam(key, value) { | |||||
const param = this.runParamsList.find(p => p.key === key); | |||||
if (param) { | |||||
param.value = value; | |||||
this.updateRunParamObj(); | |||||
} | |||||
}, | |||||
updateRunParamObj() { | updateRunParamObj() { | ||||
const obj = {}; | const obj = {}; | ||||
this.runParamsList.forEach(d => { | |||||
if (d.key === '') return; | |||||
obj[d.key] = d.value; | |||||
const repeatedParams = new Set(); | |||||
this.runParamsList.forEach(param => { | |||||
// 当 key 为空或者已存在相同 key 时,不加入数值 | |||||
if (!param.key) { | |||||
return; | |||||
} | |||||
if (obj[param.key] !== undefined) { | |||||
repeatedParams.add(param.key); | |||||
return; | |||||
} | |||||
obj[param.key] = param.value; | |||||
}); | }); | ||||
if (repeatedParams.size) { | |||||
this.paramRepeatWarning && this.paramRepeatWarning.close(); | |||||
this.paramRepeatWarning = this.$message.warning(`参数 ${[...repeatedParams].join(', ')} 有重复, 将取用第一个值。`); | |||||
} | |||||
this.$emit('updateRunParams', obj); | this.$emit('updateRunParams', obj); | ||||
}, | }, | ||||
goValid() { | |||||
validate() { | |||||
// 单独校验 | // 单独校验 | ||||
let valid = true; | let valid = true; | ||||
this.errMsg = []; | |||||
this.runParamsList.forEach((item, index) => { | |||||
if (this.isInputEmpty(item.key)) { | |||||
if (!this.isInputEmpty(item.value)) { | |||||
valid = false; | |||||
} | |||||
} else if (!stringIsValidPythonVariable(item.key)) { | |||||
valid = false; | |||||
this.$nextTick(() => { | |||||
this.errMsg[index] = '参数key必须是合法变量名'; | |||||
}); | |||||
} | |||||
}); | |||||
const validCallback = pairValid => { | |||||
valid = valid && pairValid; | |||||
}; | |||||
// eslint-disable-next-line no-plusplus | |||||
for (let i = 0; i < this.runParamsList.length; i++) { | |||||
this.paramsMode === 1 && this.$refs.paramPairs[i].validate(validCallback); | |||||
}; | |||||
valid = valid && !this.hasError; | |||||
return valid; | return valid; | ||||
}, | }, | ||||
onParamsModeChange(value) { | onParamsModeChange(value) { | ||||
@@ -265,33 +223,41 @@ export default { | |||||
const paramsList = this.paramsArguments.split(' '); | const paramsList = this.paramsArguments.split(' '); | ||||
const pairList = []; | const pairList = []; | ||||
const re = /^--(.+)=(.*)$/; | const re = /^--(.+)=(.*)$/; | ||||
this.hasError = false; | |||||
// 先使用正则进行匹配 | |||||
paramsList.forEach(arg => { | paramsList.forEach(arg => { | ||||
const group = re.exec(arg); | const group = re.exec(arg); | ||||
if (group) { | if (group) { | ||||
pairList.push({ | pairList.push({ | ||||
key: group[1], | key: group[1], | ||||
value: group[2], | value: group[2], | ||||
// eslint-disable-next-line no-plusplus | |||||
id: this.paramId++, | |||||
}); | }); | ||||
} else if (arg) { | } else if (arg) { | ||||
this.$nextTick(() => { | this.$nextTick(() => { | ||||
this.argErrorMsg = `参数'${arg}'不合法,请检查运行参数`; | this.argErrorMsg = `参数'${arg}'不合法,请检查运行参数`; | ||||
}); | }); | ||||
this.paramsMode = 2; | this.paramsMode = 2; | ||||
this.hasError = true; | |||||
} | } | ||||
}); | }); | ||||
if (this.hasError) return; | |||||
// 其次做参数名验证 | |||||
pairList.forEach(pair => { | pairList.forEach(pair => { | ||||
if (!stringIsValidPythonVariable(pair.key)) { | if (!stringIsValidPythonVariable(pair.key)) { | ||||
this.$nextTick(() => { | this.$nextTick(() => { | ||||
this.argErrorMsg = `参数名'${pair.key}'不是合法参数,请检查运行参数`; | this.argErrorMsg = `参数名'${pair.key}'不是合法参数,请检查运行参数`; | ||||
}); | }); | ||||
this.paramsMode = 2; | this.paramsMode = 2; | ||||
this.hasError = true; | |||||
} | } | ||||
}); | }); | ||||
if (this.hasError) return; | |||||
// 参数为空时增加一个空参数 | // 参数为空时增加一个空参数 | ||||
if (!pairList.length) { | if (!pairList.length) { | ||||
pairList.push({ key: '', value: '' }); | |||||
// eslint-disable-next-line no-plusplus | |||||
pairList.push({ key: '', value: '', id: this.paramId++ }); | |||||
} | } | ||||
this.runParamsList = pairList; | this.runParamsList = pairList; | ||||
this.updateRunParamObj(); | this.updateRunParamObj(); | ||||
@@ -308,13 +274,20 @@ export default { | |||||
this.paramsArguments = args; | this.paramsArguments = args; | ||||
}, | }, | ||||
reset() { | reset() { | ||||
this.errMsg = []; | |||||
this.argErrorMsg = null; | this.argErrorMsg = null; | ||||
this.paramsMode = 1; | this.paramsMode = 1; | ||||
this.paramsArguments = ''; | this.paramsArguments = ''; | ||||
this.runParamsList = [{ key: '', value: '' }]; | |||||
this.$refs.runParamForm.clearValidate(); | |||||
// eslint-disable-next-line no-plusplus | |||||
this.runParamsList = [{ key: '', value: '', id: this.paramId++ }]; | |||||
}, | }, | ||||
}, | }, | ||||
}; | }; | ||||
</script> | </script> | ||||
<style lang="scss" scoped> | |||||
.el-radio.is-bordered { | |||||
width: 130px; | |||||
height: 35px; | |||||
padding: 10px 0; | |||||
text-align: center; | |||||
} | |||||
</style> |
@@ -33,7 +33,7 @@ | |||||
<!--已有模型--> | <!--已有模型--> | ||||
<el-form-item v-if="!createModelFlag" label="归属模型" prop="parentId"> | <el-form-item v-if="!createModelFlag" label="归属模型" prop="parentId"> | ||||
<el-select v-model="modelForm.parentId" filterable placeholder="请选择模型" style="width: 300px;"> | <el-select v-model="modelForm.parentId" filterable placeholder="请选择模型" style="width: 300px;"> | ||||
<el-option v-for="item in modelList" :key="item.id" :label="item.name" :value="item.id" /> | |||||
<el-option v-for="item in modelList" :key="item.id" :label="formatVersion(item)" :value="item.id" /> | |||||
</el-select> | </el-select> | ||||
<el-tooltip class="item" effect="dark" content="如果没有对应的模型,请点击新建" placement="right-start"> | <el-tooltip class="item" effect="dark" content="如果没有对应的模型,请点击新建" placement="right-start"> | ||||
<el-button @click="goModel">新建模型</el-button> | <el-button @click="goModel">新建模型</el-button> | ||||
@@ -210,6 +210,12 @@ export default { | |||||
this.createAlgorithmUsage(value); | this.createAlgorithmUsage(value); | ||||
} | } | ||||
}, | }, | ||||
formatVersion(item) { | |||||
if (item.versionNum) { | |||||
return `${item.name} (V${(Number(item.versionNum.substr(1)) + 1).toString().padStart(4, '0')})`; | |||||
} | |||||
return `${item.name} (V0001)`; | |||||
}, | |||||
// op | // op | ||||
doSaveModel() { | doSaveModel() { | ||||
this.$refs.modelForm.validate(valid => { | this.$refs.modelForm.validate(valid => { | ||||
@@ -43,16 +43,12 @@ export default { | |||||
}, | }, | ||||
limit: { | limit: { | ||||
type: Number, | type: Number, | ||||
default: 1000, | |||||
default: 5000, | |||||
}, | }, | ||||
showFileCount: { | showFileCount: { | ||||
type: Boolean, | type: Boolean, | ||||
default: true, | default: true, | ||||
}, | }, | ||||
wordShow: { | |||||
type: Boolean, | |||||
default: true, | |||||
}, | |||||
}, | }, | ||||
data() { | data() { | ||||
return { | return { | ||||
@@ -130,6 +126,7 @@ export default { | |||||
}, | }, | ||||
onRemove(file, fileList) { | onRemove(file, fileList) { | ||||
this.lenOfFileList = fileList.length; | this.lenOfFileList = fileList.length; | ||||
this.$attrs['on-remove'] && this.$attrs['on-remove'](file, fileList); | |||||
}, | }, | ||||
cancelUpload() { | cancelUpload() { | ||||
if (this.source) { | if (this.source) { | ||||
@@ -163,7 +160,7 @@ export default { | |||||
class='upload-field' | class='upload-field' | ||||
limit={this.limit} | limit={this.limit} | ||||
multiple | multiple | ||||
list-type='picture' | |||||
list-type={this.lenOfFileList>100? 'text' : 'picture'} | |||||
auto-upload={false} | auto-upload={false} | ||||
disabled={this.uploading} | disabled={this.uploading} | ||||
{...uploadProps} | {...uploadProps} | ||||
@@ -180,7 +177,7 @@ export default { | |||||
</div> | </div> | ||||
{ | { | ||||
this.showFileCount && ( | this.showFileCount && ( | ||||
this.wordShow ? <span class='upload-chosen-tip'>已选择{ this.lenOfFileList }张</span> : null | |||||
<span class='upload-chosen-tip'>已选择{ this.lenOfFileList }张</span> | |||||
) | ) | ||||
} | } | ||||
</div> | </div> | ||||
@@ -69,7 +69,7 @@ export default { | |||||
} | } | ||||
state.uploading = true; | state.uploading = true; | ||||
ctx.emit('uploadStart'); | |||||
ctx.emit('uploadStart', files); | |||||
const uploadReqeust = request || minIOUpload; | const uploadReqeust = request || minIOUpload; | ||||
// 开始调用上传接口 | // 开始调用上传接口 | ||||
return uploadReqeust({ ...props.params, fileList: renameFileList, transformFile }, callback) | return uploadReqeust({ ...props.params, fileList: renameFileList, transformFile }, callback) | ||||
@@ -0,0 +1,93 @@ | |||||
/** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||||
* | |||||
* Licensed under the Apache License, Version 2.0 (the "License"); | |||||
* you may not use this file except in compliance with the License. | |||||
* You may obtain a copy of the License at | |||||
* | |||||
* http://www.apache.org/licenses/LICENSE-2.0 | |||||
* | |||||
* 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. | |||||
* ============================================================= | |||||
*/ | |||||
// 仅支持line-upload上传文件,线性进度条 | |||||
<template> | |||||
<div class="progress"> | |||||
<el-progress :percentage="Math.floor(progress)" :color="color" :status="status"></el-progress> | |||||
</div> | |||||
</template> | |||||
<script> | |||||
export default { | |||||
name: 'UploadProgress', | |||||
props: { | |||||
color: { // 进度条颜色 | |||||
type: [String, Array, Function], | |||||
default: '#67c23a', | |||||
}, | |||||
status: { | |||||
type: String, | |||||
default: null, | |||||
}, | |||||
size: { // 文件大小 | |||||
type: Number, | |||||
required: true, | |||||
}, | |||||
progress: { // 进度 | |||||
type: Number, | |||||
required: true, | |||||
}, | |||||
}, | |||||
mounted() { | |||||
const fileSize = this.size / 1024 / 1024; // 获取文件大小(以MB为单位) | |||||
const uploadTime = fileSize / 10; // 通过10s每兆上传速度 | |||||
const step = 90 / uploadTime * 2; // 每秒刷新的进度上限 | |||||
this.interval = setInterval(() => { | |||||
if (this.progress >= 100 - step) { | |||||
clearInterval(this.interval); | |||||
return; | |||||
} | |||||
this.$emit('onSetProgress', Math.random() * step); | |||||
}, 1000); | |||||
}, | |||||
}; | |||||
</script> | |||||
<style lang="scss"> | |||||
.progress { | |||||
.el-progress-bar__inner::before { | |||||
position: absolute; | |||||
top: 0; | |||||
right: 0; | |||||
bottom: 0; | |||||
left: 0; | |||||
content: ''; | |||||
background: #fff; | |||||
border-radius: 10px; | |||||
opacity: 0; | |||||
animation: active 2.4s cubic-bezier(0.23, 1, 0.32, 1) infinite; | |||||
} | |||||
} | |||||
// 进度条加载时的动画 | |||||
@keyframes active { | |||||
0% { | |||||
width: 0; | |||||
opacity: 0.1; | |||||
} | |||||
20% { | |||||
width: 0; | |||||
opacity: 0.5; | |||||
} | |||||
100% { | |||||
width: 100%; | |||||
opacity: 0; | |||||
} | |||||
} | |||||
</style> |
@@ -0,0 +1,217 @@ | |||||
/** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||||
* | |||||
* Licensed under the Apache License, Version 2.0 (the "License"); | |||||
* you may not use this file except in compliance with the License. | |||||
* You may obtain a copy of the License at | |||||
* | |||||
* http://www.apache.org/licenses/LICENSE-2.0 | |||||
* | |||||
* 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. | |||||
* ============================================================= | |||||
*/ | |||||
import cx from 'classnames'; | |||||
import Vue from 'vue'; | |||||
import { reactive } from '@vue/composition-api'; | |||||
import Drag from '@/components/Drag'; | |||||
import Group from '../group'; | |||||
import BrushSelection from './BrushSelection'; | |||||
export default { | |||||
name: 'Brush', | |||||
components: { | |||||
Group, | |||||
}, | |||||
props: { | |||||
stageWidth: Number, | |||||
stageHeight: Number, | |||||
className: String, | |||||
onBrushStart: Function, | |||||
onBrushMove: Function, | |||||
onBrushEnd: Function, | |||||
transformZoom: Function, | |||||
left: { | |||||
type: Number, | |||||
default: 0, | |||||
}, | |||||
top: { | |||||
type: Number, | |||||
default: 0, | |||||
}, | |||||
brushSelectionStyle: { | |||||
type: Object, | |||||
default: () => ({ | |||||
fill: 'rgba(102, 181, 245, 0.1)', | |||||
stroke: 'rgba(102, 181, 245, 1)', | |||||
strokeWidth: 1, | |||||
}), | |||||
}, | |||||
}, | |||||
setup(props){ | |||||
const { onBrushStart, onBrushMove, left, top, onChange, onBrushEnd, transformZoom } = props; | |||||
const state = reactive({ | |||||
start: { x: 0, y: 0 }, | |||||
end: { x: 0, y: 0 }, | |||||
extent: { x0: 0, x1: 0, y0: 0, y1: 0 }, | |||||
isBrushing: false, | |||||
}); | |||||
const getWidth = () => { | |||||
return Math.abs(state.extent.x1 - state.extent.x0); | |||||
}; | |||||
const getHeight = () => { | |||||
return Math.abs(state.extent.y1 - state.extent.y0); | |||||
}; | |||||
const getExtent = (start, end) => { | |||||
const x0 = Math.min(start.x, end.x); | |||||
const x1 = Math.max(start.x, end.x); | |||||
const y0 = Math.min(start.y, end.y); | |||||
const y1 = Math.max(start.y, end.y); | |||||
return { | |||||
x0, | |||||
x1, | |||||
y0, | |||||
y1, | |||||
}; | |||||
}; | |||||
const update = (updater, callback) => { | |||||
Object.assign(state, updater(state)); | |||||
Vue.nextTick(() => { | |||||
if(callback) { | |||||
callback(state); | |||||
} | |||||
if(onChange) { | |||||
onChange(state); | |||||
} | |||||
}); | |||||
}; | |||||
const handleDragStart = (draw, event) => { | |||||
const start = transformZoom({ | |||||
x: draw.x + draw.dx - left, | |||||
y: draw.y + draw.dy - top, | |||||
}); | |||||
if (onBrushStart) { | |||||
onBrushStart(start, event); | |||||
} | |||||
update(prevBrush => ({ | |||||
...prevBrush, | |||||
start, | |||||
end: undefined, | |||||
extent: { | |||||
x0: -1, | |||||
x1: -1, | |||||
y0: -1, | |||||
y1: -1, | |||||
}, | |||||
isBrushing: true, | |||||
})); | |||||
}; | |||||
const handleDragMove = (draw, event) => { | |||||
if (!draw.isDragging) return; | |||||
const end = transformZoom({ | |||||
x: draw.x + draw.dx - left, | |||||
y: draw.y + draw.dy - top, | |||||
}); | |||||
update(prevBrush => { | |||||
const { start } = prevBrush; | |||||
const extent = getExtent(start, end); | |||||
return { | |||||
...prevBrush, | |||||
end, | |||||
extent, | |||||
}; | |||||
}, (nextState) => { | |||||
// 回调 | |||||
typeof onBrushMove === 'function' && onBrushMove(nextState, event); | |||||
}); | |||||
}; | |||||
const handleDragEnd = (draw, event, options = {}) => { | |||||
update(prevBrush => ({ | |||||
...prevBrush, | |||||
isBrushing: false, | |||||
}), state => onBrushEnd(state, event, options)); | |||||
}; | |||||
return { | |||||
state, | |||||
getWidth, | |||||
getHeight, | |||||
update, | |||||
handleDragStart, | |||||
handleDragMove, | |||||
handleDragEnd, | |||||
getExtent, | |||||
}; | |||||
}, | |||||
render(h) { | |||||
const { stageWidth, stageHeight, className, left, top, brushSelectionStyle } = this; | |||||
const { start, end, isBrushing } = this.state; | |||||
const width = this.getWidth(); | |||||
const height = this.getHeight(); | |||||
const dragProps = { | |||||
props: { | |||||
width: stageWidth, | |||||
height: stageHeight, | |||||
resetOnStart: true, | |||||
onDragStart: this.handleDragStart, | |||||
onDragMove: this.handleDragMove, | |||||
onDragEnd: this.handleDragEnd, | |||||
}, | |||||
}; | |||||
return ( | |||||
<Group className={cx('db-brush', className)} left={left} top={top}> | |||||
{/* overlay */} | |||||
<Drag {...dragProps}> | |||||
{ | |||||
(drag) => ( | |||||
<rect | |||||
className='brush-overlay' | |||||
fill='transparent' | |||||
x={0} | |||||
y={0} | |||||
width={stageWidth} | |||||
height={stageHeight} | |||||
style={{ cursor: 'crosshair' }} | |||||
onMousedown={drag.dragStart} | |||||
onMousemove={drag.dragMove} | |||||
onMouseup={drag.dragEnd} | |||||
/> | |||||
) | |||||
} | |||||
</Drag> | |||||
{start && end && !!isBrushing && ( | |||||
<g> | |||||
<BrushSelection | |||||
updateBrush={this.update} | |||||
width={width} | |||||
height={height} | |||||
stageWidth={stageWidth} | |||||
stageHeight={stageHeight} | |||||
brush={{ ...this.state }} | |||||
selectionStyle={brushSelectionStyle} | |||||
/> | |||||
</g> | |||||
)} | |||||
</Group> | |||||
); | |||||
}, | |||||
}; |
@@ -0,0 +1,227 @@ | |||||
/** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||||
* | |||||
* Licensed under the Apache License, Version 2.0 (the "License"); | |||||
* you may not use this file except in compliance with the License. | |||||
* You may obtain a copy of the License at | |||||
* | |||||
* http://www.apache.org/licenses/LICENSE-2.0 | |||||
* | |||||
* 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. | |||||
* ============================================================= | |||||
*/ | |||||
import Drag from '@/components/Drag'; | |||||
import { chroma } from '@/utils'; | |||||
export default { | |||||
name: 'BrushCorner', | |||||
props: { | |||||
annotate: Object, | |||||
transformer: Object, | |||||
currentAnnotationId: String, | |||||
stageWidth: Number, | |||||
stageHeight: Number, | |||||
type: String, | |||||
scale: { | |||||
type: Number, | |||||
default: 1, | |||||
}, | |||||
x: Number, | |||||
y: Number, | |||||
width: Number, | |||||
height: Number, | |||||
handleBrushStart: Function, | |||||
updateBrush: Function, | |||||
updateBrushEnd: Function, | |||||
getZoom: Function, | |||||
}, | |||||
setup(props) { | |||||
const { updateBrush, updateBrushEnd, type, scale, handleBrushStart, getZoom } = props; | |||||
const handleDragStart = (drag, event) => { | |||||
// 开始拖拽是选中当前标注 | |||||
if(handleBrushStart) { | |||||
handleBrushStart(drag, event); | |||||
} | |||||
}; | |||||
const handleDragMove = (drag) => { | |||||
if (!drag.isDragging) return; | |||||
const { zoom } = getZoom(); | |||||
updateBrush(prevBrush => { | |||||
const { start, end } = prevBrush; | |||||
let nextState = {}; | |||||
let moveX = 0; | |||||
let moveY = 0; | |||||
const _scale = scale * zoom; | |||||
const xMax = Math.max(start.x, end.x); | |||||
const xMin = Math.min(start.x, end.x); | |||||
const yMax = Math.max(start.y, end.y); | |||||
const yMin = Math.min(start.y, end.y); | |||||
switch (type) { | |||||
case 'topRight': | |||||
moveX = xMax + drag.dx / _scale; | |||||
moveY = yMin + drag.dy / _scale; | |||||
nextState = { | |||||
...prevBrush, | |||||
activeHandle: type, | |||||
extent: { | |||||
...prevBrush.extent, | |||||
x0: Math.max(Math.min(moveX, start.x), prevBrush.bounds.x0), | |||||
x1: Math.min(Math.max(moveX, start.x), prevBrush.bounds.x1), | |||||
y0: Math.max(Math.min(moveY, end.y), prevBrush.bounds.y0), | |||||
y1: Math.min(Math.max(moveY, end.y), prevBrush.bounds.y1), | |||||
}, | |||||
}; | |||||
break; | |||||
case 'topLeft': | |||||
moveX = xMin + drag.dx / _scale; | |||||
moveY = yMin + drag.dy / _scale; | |||||
nextState = { | |||||
...prevBrush, | |||||
activeHandle: type, | |||||
extent: { | |||||
...prevBrush.extent, | |||||
x0: Math.max(Math.min(moveX, end.x), prevBrush.bounds.x0), | |||||
x1: Math.min(Math.max(moveX, end.x), prevBrush.bounds.x1), | |||||
y0: Math.max(Math.min(moveY, end.y), prevBrush.bounds.y0), | |||||
y1: Math.min(Math.max(moveY, end.y), prevBrush.bounds.y1), | |||||
}, | |||||
}; | |||||
break; | |||||
case 'bottomLeft': | |||||
moveX = xMin + drag.dx / _scale; | |||||
moveY = yMax + drag.dy / _scale; | |||||
nextState = { | |||||
...prevBrush, | |||||
activeHandle: type, | |||||
extent: { | |||||
...prevBrush.extent, | |||||
x0: Math.max(Math.min(moveX, end.x), prevBrush.bounds.x0), | |||||
x1: Math.min(Math.max(moveX, end.x), prevBrush.bounds.x1), | |||||
y0: Math.max(Math.min(moveY, start.y), prevBrush.bounds.y0), | |||||
y1: Math.min(Math.max(moveY, start.y), prevBrush.bounds.y1), | |||||
}, | |||||
}; | |||||
break; | |||||
case 'bottomRight': | |||||
moveX = xMax + drag.dx / _scale; | |||||
moveY = yMax + drag.dy / _scale; | |||||
nextState = { | |||||
...prevBrush, | |||||
activeHandle: type, | |||||
extent: { | |||||
...prevBrush.extent, | |||||
x0: Math.max(Math.min(moveX, start.x), prevBrush.bounds.x0), | |||||
x1: Math.min(Math.max(moveX, start.x), prevBrush.bounds.x1), | |||||
y0: Math.max(Math.min(moveY, start.y), prevBrush.bounds.y0), | |||||
y1: Math.min(Math.max(moveY, start.y), prevBrush.bounds.y1), | |||||
}, | |||||
}; | |||||
break; | |||||
default: | |||||
break; | |||||
} | |||||
return nextState; | |||||
}); | |||||
}; | |||||
const handleDragEnd = () => { | |||||
updateBrushEnd(prevBrush => { | |||||
const { start, end, extent } = { ...prevBrush }; | |||||
start.x = Math.min(extent.x0, extent.x1); | |||||
start.y = Math.min(extent.y0, extent.y0); | |||||
end.x = Math.max(extent.x0, extent.x1); | |||||
end.y = Math.max(extent.y0, extent.y1); | |||||
const nextBrush = { | |||||
...prevBrush, | |||||
start, | |||||
end, | |||||
activeHandle: undefined, | |||||
isBrushing: false, | |||||
domain: { | |||||
x0: Math.min(start.x, end.x), | |||||
x1: Math.max(start.x, end.x), | |||||
y0: Math.min(start.y, end.y), | |||||
y1: Math.max(start.y, end.y), | |||||
}, | |||||
}; | |||||
return nextBrush; | |||||
}); | |||||
}; | |||||
return { | |||||
handleDragStart, | |||||
handleDragMove, | |||||
handleDragEnd, | |||||
}; | |||||
}, | |||||
render(h) { | |||||
const { annotate, transformer, currentAnnotationId, stageWidth, stageHeight, type, x, y, width, height } = this; | |||||
const cursor = type === 'topLeft' || type === 'bottomRight' ? 'nwse-resize' : 'nesw-resize'; | |||||
let transform = null; | |||||
if(annotate.id === transformer.id) { | |||||
transform = `translate(${transformer.dx}, ${transformer.dy})`; | |||||
} | |||||
const { data = {} } = annotate; | |||||
const { color } = data; | |||||
const defaultFill = 'rgba(102, 181, 245, 0.1)'; | |||||
const bgColor = color || defaultFill; | |||||
const isActive = currentAnnotationId === annotate.id; | |||||
const colorAlpha = isActive ? 1 : 0; | |||||
const fillColor = chroma(bgColor).alpha(colorAlpha); | |||||
const dragProps = { | |||||
props: { | |||||
width: stageWidth, | |||||
height: stageHeight, | |||||
resetOnStart: true, | |||||
onDragStart: this.handleDragStart, | |||||
onDragMove: this.handleDragMove, | |||||
onDragEnd: this.handleDragEnd, | |||||
}, | |||||
}; | |||||
const style = { | |||||
cursor, | |||||
}; | |||||
return ( | |||||
<Drag {...dragProps}> | |||||
{ | |||||
(drag) => ( | |||||
<rect | |||||
x={x} | |||||
y={y} | |||||
width={width} | |||||
height={height} | |||||
transform={transform} | |||||
fill={fillColor} | |||||
class={`brush-corner-${type}`} | |||||
onMousedown={drag.dragStart} | |||||
onMousemove={drag.dragMove} | |||||
onMouseup={drag.dragEnd} | |||||
style={style} | |||||
/> | |||||
) | |||||
} | |||||
</Drag> | |||||
); | |||||
}, | |||||
}; |
@@ -0,0 +1,193 @@ | |||||
/** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||||
* | |||||
* Licensed under the Apache License, Version 2.0 (the "License"); | |||||
* you may not use this file except in compliance with the License. | |||||
* You may obtain a copy of the License at | |||||
* | |||||
* http://www.apache.org/licenses/LICENSE-2.0 | |||||
* | |||||
* 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. | |||||
* ============================================================= | |||||
*/ | |||||
import Drag from '@/components/Drag'; | |||||
export default { | |||||
name: 'BrushHandle', | |||||
props: { | |||||
stageWidth: Number, | |||||
stageHeight: Number, | |||||
type: String, | |||||
scale: { | |||||
type: Number, | |||||
default: 1, | |||||
}, | |||||
handle: { | |||||
type: Object, | |||||
default: () => ({ x: 0, y: 0, width: 0, height: 0 }), | |||||
}, | |||||
handleBrushStart: Function, | |||||
updateBrush: Function, | |||||
updateBrushEnd: Function, | |||||
getZoom: Function, | |||||
}, | |||||
// todo: 鼠标离开画布没有释放 | |||||
setup(props) { | |||||
const { updateBrush, updateBrushEnd, type, scale, handleBrushStart, getZoom } = props; | |||||
const handleDragStart = (drag, event) => { | |||||
// 开始拖拽是选中当前标注 | |||||
if(handleBrushStart) { | |||||
handleBrushStart(drag, event); | |||||
} | |||||
}; | |||||
const handleDragMove = (drag) => { | |||||
if (!drag.isDragging) return; | |||||
const { zoom } = getZoom(); | |||||
updateBrush(prevBrush => { | |||||
const { start, end } = prevBrush; | |||||
let nextState = {}; | |||||
let move = 0; | |||||
const _scale = scale * zoom; | |||||
const xMax = Math.max(start.x, end.x); | |||||
const xMin = Math.min(start.x, end.x); | |||||
const yMax = Math.max(start.y, end.y); | |||||
const yMin = Math.min(start.y, end.y); | |||||
switch (type) { | |||||
case 'right': | |||||
move = xMax + drag.dx / _scale; | |||||
nextState = { | |||||
...prevBrush, | |||||
activeHandle: type, | |||||
extent: { | |||||
...prevBrush.extent, | |||||
x0: Math.max(Math.min(move, start.x), prevBrush.bounds.x0), | |||||
x1: Math.min(Math.max(move, start.x), prevBrush.bounds.x1), | |||||
}, | |||||
}; | |||||
break; | |||||
case 'left': | |||||
move = xMin + drag.dx / _scale; | |||||
nextState = { | |||||
...prevBrush, | |||||
activeHandle: type, | |||||
extent: { | |||||
...prevBrush.extent, | |||||
x0: Math.min(move, end.x), | |||||
x1: Math.max(move, end.x), | |||||
}, | |||||
}; | |||||
break; | |||||
case 'top': | |||||
move = yMin + drag.dy / _scale; | |||||
nextState = { | |||||
...prevBrush, | |||||
activeHandle: type, | |||||
extent: { | |||||
...prevBrush.extent, | |||||
y0: Math.min(move, end.y), | |||||
y1: Math.max(move, end.y), | |||||
}, | |||||
}; | |||||
break; | |||||
case 'bottom': | |||||
move = yMax + drag.dy / _scale; | |||||
nextState = { | |||||
...prevBrush, | |||||
activeHandle: type, | |||||
extent: { | |||||
...prevBrush.extent, | |||||
y0: Math.min(move, start.y), | |||||
y1: Math.max(move, start.y), | |||||
}, | |||||
}; | |||||
break; | |||||
default: | |||||
break; | |||||
} | |||||
return nextState; | |||||
}); | |||||
}; | |||||
const handleDragEnd = () => { | |||||
updateBrushEnd(prevBrush => { | |||||
const { start, end, extent } = { ...prevBrush }; | |||||
start.x = Math.min(extent.x0, extent.x1); | |||||
start.y = Math.min(extent.y0, extent.y0); | |||||
end.x = Math.max(extent.x0, extent.x1); | |||||
end.y = Math.max(extent.y0, extent.y1); | |||||
const nextBrush = { | |||||
...prevBrush, | |||||
start, | |||||
end, | |||||
activeHandle: undefined, | |||||
isBrushing: false, | |||||
domain: { | |||||
x0: Math.min(start.x, end.x), | |||||
x1: Math.max(start.x, end.x), | |||||
y0: Math.min(start.y, end.y), | |||||
y1: Math.max(start.y, end.y), | |||||
}, | |||||
}; | |||||
return nextBrush; | |||||
}); | |||||
}; | |||||
return { | |||||
handleDragStart, | |||||
handleDragMove, | |||||
handleDragEnd, | |||||
}; | |||||
}, | |||||
render(h) { | |||||
const { stageWidth, stageHeight, handle, type } = this; | |||||
const { x, y, width, height } = handle; | |||||
const cursor = type === 'right' || type === 'left' ? 'ew-resize' : 'ns-resize'; | |||||
const dragProps = { | |||||
props: { | |||||
width: stageWidth, | |||||
height: stageHeight, | |||||
resetOnStart: true, | |||||
onDragStart: this.handleDragStart, | |||||
onDragMove: this.handleDragMove, | |||||
onDragEnd: this.handleDragEnd, | |||||
}, | |||||
}; | |||||
const style = { | |||||
cursor, | |||||
}; | |||||
return ( | |||||
<Drag {...dragProps}> | |||||
{ | |||||
(drag) => ( | |||||
<rect | |||||
x={x} | |||||
y={y} | |||||
width={width} | |||||
height={height} | |||||
fill='transparent' | |||||
class={`brush-handle-${type}`} | |||||
onMousedown={drag.dragStart} | |||||
onMousemove={drag.dragMove} | |||||
onMouseup={drag.dragEnd} | |||||
style={style} | |||||
/> | |||||
) | |||||
} | |||||
</Drag> | |||||
); | |||||
}, | |||||
}; |
@@ -0,0 +1,55 @@ | |||||
/** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||||
* | |||||
* Licensed under the Apache License, Version 2.0 (the "License"); | |||||
* you may not use this file except in compliance with the License. | |||||
* You may obtain a copy of the License at | |||||
* | |||||
* http://www.apache.org/licenses/LICENSE-2.0 | |||||
* | |||||
* 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. | |||||
* ============================================================= | |||||
*/ | |||||
export default { | |||||
name: 'BrushSelection', | |||||
props: { | |||||
stageWidth: Number, | |||||
stageHeight: Number, | |||||
width: Number, | |||||
height: Number, | |||||
updateBrush: Function, | |||||
brush: Object, | |||||
onBrushStart: Function, | |||||
onBrushEnd: Function, | |||||
disableDraggingSelection: { | |||||
type: Boolean, | |||||
default: false, | |||||
}, | |||||
selectionStyle: { | |||||
type: Object, | |||||
}, | |||||
}, | |||||
render(h) { | |||||
const { width, height, brush, disableDraggingSelection, selectionStyle } = this; | |||||
return ( | |||||
<rect | |||||
x={Math.min(brush.extent.x0, brush.extent.x1)} | |||||
y={Math.min(brush.extent.y0, brush.extent.y1)} | |||||
width={width} | |||||
height={height} | |||||
className='db-brush-selection' | |||||
style={{ | |||||
...selectionStyle, | |||||
pointerEvents: brush.isBrushing || brush.activeHandle ? 'none' : 'all', | |||||
cursor: disableDraggingSelection ? null : 'move', | |||||
}} | |||||
/> | |||||
); | |||||
}, | |||||
}; |
@@ -0,0 +1,19 @@ | |||||
/** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||||
* | |||||
* Licensed under the Apache License, Version 2.0 (the "License"); | |||||
* you may not use this file except in compliance with the License. | |||||
* You may obtain a copy of the License at | |||||
* | |||||
* http://www.apache.org/licenses/LICENSE-2.0 | |||||
* | |||||
* 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. | |||||
* ============================================================= | |||||
*/ | |||||
export { default as Brush } from './Brush'; | |||||
export { default as BrushHandle } from './BrushHandle'; | |||||
export { default as BrushCorner } from './BrushCorner'; |
@@ -0,0 +1,42 @@ | |||||
/** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||||
* | |||||
* Licensed under the Apache License, Version 2.0 (the "License"); | |||||
* you may not use this file except in compliance with the License. | |||||
* You may obtain a copy of the License at | |||||
* | |||||
* http://www.apache.org/licenses/LICENSE-2.0 | |||||
* | |||||
* 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. | |||||
* ============================================================= | |||||
*/ | |||||
import cx from 'classnames'; | |||||
export default { | |||||
name: 'Group', | |||||
functional: true, | |||||
render(h, context) { | |||||
const { props, children } = context; | |||||
const { | |||||
top = 0, | |||||
left = 0, | |||||
transform, | |||||
className, | |||||
...otherProps | |||||
} = props; | |||||
return ( | |||||
<g | |||||
class={cx('db-group', className)} | |||||
transform={transform || `translate(${left}, ${top})`} | |||||
{...otherProps} | |||||
> | |||||
{children} | |||||
</g> | |||||
); | |||||
}, | |||||
}; |
@@ -0,0 +1,18 @@ | |||||
/** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||||
* | |||||
* Licensed under the Apache License, Version 2.0 (the "License"); | |||||
* you may not use this file except in compliance with the License. | |||||
* You may obtain a copy of the License at | |||||
* | |||||
* http://www.apache.org/licenses/LICENSE-2.0 | |||||
* | |||||
* 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. | |||||
* ============================================================= | |||||
*/ | |||||
export { default as Group } from './group'; | |||||
export * from './brush'; |
@@ -18,7 +18,7 @@ module.exports = { | |||||
minIO: { | minIO: { | ||||
development: { | development: { | ||||
config: { | config: { | ||||
endPoint: '10.5.26.234', | |||||
endPoint: '', // MinIO 服务地址 | |||||
port: 9000, | port: 9000, | ||||
useSSL: false, | useSSL: false, | ||||
}, | }, | ||||
@@ -26,7 +26,7 @@ module.exports = { | |||||
}, | }, | ||||
test: { | test: { | ||||
config: { | config: { | ||||
endPoint: '10.5.26.234', | |||||
endPoint: '', | |||||
port: 9000, | port: 9000, | ||||
useSSL: false, | useSSL: false, | ||||
}, | }, | ||||
@@ -34,7 +34,7 @@ module.exports = { | |||||
}, | }, | ||||
production: { | production: { | ||||
config: { | config: { | ||||
endPoint: '121.41.72.89', | |||||
endPoint: '', | |||||
port: 9000, | port: 9000, | ||||
useSSL: false, | useSSL: false, | ||||
}, | }, | ||||
@@ -21,26 +21,53 @@ function useBrush() { | |||||
const state = reactive({ | const state = reactive({ | ||||
start: undefined, | start: undefined, | ||||
end: undefined, | end: undefined, | ||||
extent: undefined, | |||||
isBrushing: false, | isBrushing: false, | ||||
}); | }); | ||||
function getExtent(start, end) { | |||||
const x0 = Math.min(start.x, end.x); | |||||
const x1 = Math.max(start.x, end.x); | |||||
const y0 = Math.min(start.y, end.y); | |||||
const y1 = Math.max(start.y, end.y); | |||||
return { | |||||
x0, | |||||
x1, | |||||
y0, | |||||
y1, | |||||
}; | |||||
} | |||||
function onBrushStart({ x, y }) { | function onBrushStart({ x, y }) { | ||||
Object.assign(state, { | Object.assign(state, { | ||||
start: { x, y }, | start: { x, y }, | ||||
isBrushing: true, | isBrushing: true, | ||||
end: undefined, | end: undefined, | ||||
extent: undefined, | |||||
}); | }); | ||||
} | } | ||||
function onBrushMove({ x, y }) { | function onBrushMove({ x, y }) { | ||||
const extent = getExtent(state.start, {x, y}); | |||||
Object.assign(state, { | Object.assign(state, { | ||||
end: { x, y }, | end: { x, y }, | ||||
extent, | |||||
}); | }); | ||||
} | } | ||||
function onBrushEnd() { | function onBrushEnd() { | ||||
const { extent } = state; | |||||
Object.assign(state, { | Object.assign(state, { | ||||
isBrushing: false, | isBrushing: false, | ||||
start: { | |||||
x: extent.x0, | |||||
y: extent.y0, | |||||
}, | |||||
end: { | |||||
x: extent.x1, | |||||
y: extent.y1, | |||||
}, | |||||
}); | }); | ||||
} | } | ||||
@@ -48,15 +75,26 @@ function useBrush() { | |||||
Object.assign(state, { | Object.assign(state, { | ||||
start: undefined, | start: undefined, | ||||
end: undefined, | end: undefined, | ||||
extent: undefined, | |||||
isBrushing: false, | isBrushing: false, | ||||
}); | }); | ||||
} | } | ||||
function updateBrush(updater, callback) { | |||||
const newState = updater(state); | |||||
Object.assign(state, newState); | |||||
if(typeof callback === 'function') { | |||||
callback(state); | |||||
} | |||||
} | |||||
return ({ | return ({ | ||||
brush: state, | brush: state, | ||||
getExtent, | |||||
onBrushStart, | onBrushStart, | ||||
onBrushMove, | onBrushMove, | ||||
onBrushEnd, | onBrushEnd, | ||||
updateBrush, | |||||
onBrushReset, | onBrushReset, | ||||
}); | }); | ||||
} | } | ||||
@@ -66,8 +66,13 @@ function useZoom(initialZoom, wrapperRef, options = { | |||||
updateZoom({ newZoom: 1, zoom: 1, zoomX: 0, zoomY: 0 }); | updateZoom({ newZoom: 1, zoom: 1, zoomX: 0, zoomY: 0 }); | ||||
} | } | ||||
function getZoom(){ | |||||
return state; | |||||
} | |||||
return ({ | return ({ | ||||
zoom: state, | zoom: state, | ||||
getZoom, | |||||
setZoom, | setZoom, | ||||
zoomIn, | zoomIn, | ||||
zoomOut, | zoomOut, | ||||
@@ -91,7 +91,8 @@ export default { | |||||
} else { | } else { | ||||
// 不存在历史记录 | // 不存在历史记录 | ||||
// 或者新开 Tab | // 或者新开 Tab | ||||
if (!window.history.length || window.history.length === 1) { | |||||
// chrome 新开tab页面历史记录为 2 | |||||
if (!window.history.length || window.history.length <= 2) { | |||||
this.$router.push('/'); | this.$router.push('/'); | ||||
return; | return; | ||||
} | } | ||||
@@ -59,6 +59,7 @@ export default { | |||||
font-size: 0.7rem !important; | font-size: 0.7rem !important; | ||||
color: #7a8b9a; | color: #7a8b9a; | ||||
letter-spacing: 0.8px; | letter-spacing: 0.8px; | ||||
pointer-events: none; | |||||
background: none repeat scroll 0 0 white; | background: none repeat scroll 0 0 white; | ||||
border-top: 1px solid #e7eaec; | border-top: 1px solid #e7eaec; | ||||
} | } | ||||
@@ -33,15 +33,15 @@ | |||||
<el-col :span="12"> | <el-col :span="12"> | ||||
<el-popover | <el-popover | ||||
placement="bottom" | |||||
trigger="click" | |||||
placement="bottom" | |||||
trigger="click" | |||||
> | > | ||||
<img src="../../../assets/images/dingtalk.jpg" width="200" alt=""> | <img src="../../../assets/images/dingtalk.jpg" width="200" alt=""> | ||||
<div slot="reference" class="feed-action"> | <div slot="reference" class="feed-action"> | ||||
<i class="el-icon-chat-dot-square" /> | <i class="el-icon-chat-dot-square" /> | ||||
<div>钉钉交流群</div> | <div>钉钉交流群</div> | ||||
</div> | </div> | ||||
</el-popover> | |||||
</el-popover> | |||||
</el-col> | </el-col> | ||||
</el-row> | </el-row> | ||||
@@ -20,6 +20,7 @@ | |||||
const state = { | const state = { | ||||
activePanel: 0, | activePanel: 0, | ||||
activePanelLabelGroup: 0, | |||||
}; | }; | ||||
const mutations = { | const mutations = { | ||||
@@ -29,6 +30,12 @@ const mutations = { | |||||
RESET_PANEL: (state) => { | RESET_PANEL: (state) => { | ||||
state.activePanel = 0; | state.activePanel = 0; | ||||
}, | }, | ||||
TOGGLE_PANEL_LABEL_GROUP: (state, panel) => { | |||||
state.activePanelLabelGroup = panel; | |||||
}, | |||||
RESET_PANEL_LABEL_GROUP: (state) => { | |||||
state.activePanelLabelGroup = 0; | |||||
}, | |||||
}; | }; | ||||
const actions = { | const actions = { | ||||
@@ -38,6 +45,12 @@ const actions = { | |||||
resetPanel({ commit }) { | resetPanel({ commit }) { | ||||
commit('RESET_PANEL'); | commit('RESET_PANEL'); | ||||
}, | }, | ||||
togglePanelLabelGroup({ commit }, panel) { | |||||
commit('TOGGLE_PANEL_LABEL_GROUP', panel); | |||||
}, | |||||
resetPanelLabelGroup({ commit }) { | |||||
commit('RESET_PANEL_LABEL_GROUP'); | |||||
}, | |||||
}; | }; | ||||
export default { | export default { | ||||
@@ -15,9 +15,17 @@ | |||||
*/ | */ | ||||
import { format, parseISO, isDate } from 'date-fns'; | import { format, parseISO, isDate } from 'date-fns'; | ||||
import { isEqual, isPlainObject } from 'lodash'; | |||||
import { isEqual, isPlainObject, isNil, findIndex, findLastIndex } from 'lodash'; | |||||
import { nanoid } from 'nanoid'; | import { nanoid } from 'nanoid'; | ||||
const chroma = require('chroma-js'); | |||||
export const duplicate = (arr, callback) => { | |||||
const index = findIndex(arr, callback); | |||||
const lastIndex = findLastIndex(arr, callback); | |||||
return index !== lastIndex; | |||||
}; | |||||
// 合并多个属性 | // 合并多个属性 | ||||
export function mergeProps(...args) { | export function mergeProps(...args) { | ||||
const props = {}; | const props = {}; | ||||
@@ -144,3 +152,19 @@ export const identity = d => d; | |||||
export const isEqualByProp = (arr1, arr2, prop) => { | export const isEqualByProp = (arr1, arr2, prop) => { | ||||
return isEqual(arr1.map(d => d[prop]), arr2.map(d => d[prop])); | return isEqual(arr1.map(d => d[prop]), arr2.map(d => d[prop])); | ||||
}; | }; | ||||
// 根据背景色深浅来设置颜色 | |||||
export const colorByLuminance = (color) => { | |||||
if(isNil(color) || color === '') { | |||||
return '#333'; | |||||
} | |||||
const colorMap = { | |||||
dark: '#333', | |||||
light: '#fff', | |||||
}; | |||||
const luminance = chroma(color).luminance(); | |||||
const theme = luminance < 0.5 ? 'light' : 'dark'; | |||||
return colorMap[theme]; | |||||
}; | |||||
export { chroma }; |
@@ -25,10 +25,10 @@ export const getCursorPosition = (el, event, options = {}) => { | |||||
}; | }; | ||||
// 根据 d3-zoom 获取缩放后的相对位置 | // 根据 d3-zoom 获取缩放后的相对位置 | ||||
export const getZoomPosition = (el, originPosition = []) => { | |||||
export const getZoomPosition = (el, {x, y}) => { | |||||
const transform = zoomTransform(el); | const transform = zoomTransform(el); | ||||
// const invertPosition = transform.invert(originPosition) | // const invertPosition = transform.invert(originPosition) | ||||
return [originPosition[0] / transform.k, originPosition[1] / transform.k]; | |||||
return { x: x / transform.k, y: y / transform.k }; | |||||
// return invertPosition | // return invertPosition | ||||
}; | }; | ||||
@@ -60,6 +60,22 @@ export const generateBbox = (brush) => { | |||||
}; | }; | ||||
}; | }; | ||||
// Bbox 转为 extent | |||||
export const bbox2Extent = bbox => ({ | |||||
x0: bbox.x, | |||||
y0: bbox.y, | |||||
x1: bbox.x + bbox.width, | |||||
y1: bbox.y + bbox.height, | |||||
}); | |||||
// 将 extent 转为 bbox | |||||
export const extent2Bbox = extent => ({ | |||||
x: extent.x0, | |||||
y: extent.y0, | |||||
width: extent.x1 - extent.x0, | |||||
height: extent.y1 - extent.y0, | |||||
}); | |||||
// 解析bbox | // 解析bbox | ||||
export const parseBbox = (bbox = []) => { | export const parseBbox = (bbox = []) => { | ||||
if (!bbox.length) return null; | if (!bbox.length) return null; | ||||
@@ -97,3 +113,32 @@ export function getStyle(el, property) { | |||||
.getPropertyValue(property) | .getPropertyValue(property) | ||||
.replace('px', ''); | .replace('px', ''); | ||||
} | } | ||||
/** | |||||
* 向上找到原始 svg 元素 | |||||
* @param {[type]} node [节点] | |||||
* @param {[type]} event [事件对象] | |||||
*/ | |||||
// eslint-disable-next-line | |||||
export const findAncestorSvg = (node, event) => { | |||||
// 检测是否有参数传入 | |||||
if (!node) return null; | |||||
// 如果只有一个参数 | |||||
if (node.target) { | |||||
event = null; | |||||
// 当前元素的 svg 包裹元素 | |||||
node = node.target.ownerSVGElement; | |||||
} | |||||
// 向上一直遍历,直到找到 svg 元素 | |||||
while (node.ownerSVGElement) { | |||||
node = node.ownerSVGElement; | |||||
} | |||||
return node; | |||||
}; | |||||
export const raise = (arr, raiseIndex) => { | |||||
return ([...arr.slice(0, raiseIndex), ...arr.slice(raiseIndex + 1), arr[raiseIndex]]); | |||||
}; | |||||
@@ -71,6 +71,10 @@ service.interceptors.request.use( | |||||
service.interceptors.response.use( | service.interceptors.response.use( | ||||
response => { | response => { | ||||
const res = response.data; | const res = response.data; | ||||
// 如果请求的返回类型是流,则直接返回 data | |||||
if (response.config.responseType === 'blob') { | |||||
return res; | |||||
} | |||||
// if the custom code is not 200, it is judged as an error. | // if the custom code is not 200, it is judged as an error. | ||||
if (res.code !== 200) { | if (res.code !== 200) { | ||||
if (isWhiteList(response.config.url)) { | if (isWhiteList(response.config.url)) { | ||||
@@ -18,6 +18,8 @@ | |||||
* utils, 通用方法 | * utils, 通用方法 | ||||
*/ | */ | ||||
import { nanoid } from 'nanoid'; | |||||
/** | /** | ||||
* Parse the time to string | * Parse the time to string | ||||
* @param {(Object|string|number)} time | * @param {(Object|string|number)} time | ||||
@@ -252,5 +254,92 @@ export function stringIsValidPythonVariable(str) { | |||||
} | } | ||||
const pattern = /^[_a-zA-Z][_a-zA-Z0-9]*$/; | const pattern = /^[_a-zA-Z][_a-zA-Z0-9]*$/; | ||||
return pattern.test(str); | return pattern.test(str); | ||||
} | |||||
const _toTree = (data) => { | |||||
const result = []; | |||||
if (!Array.isArray(data)) { | |||||
return result; | |||||
} | |||||
data.forEach(item => { | |||||
delete item.children; | |||||
}); | |||||
const map = {}; | |||||
data.forEach(item => { | |||||
map[item.id] = item; | |||||
}); | |||||
data.forEach(item => { | |||||
const parent = map[item.pid]; | |||||
if (parent) { | |||||
(parent.children || (parent.children = [])).push(item); | |||||
} else { | |||||
result.push(item); | |||||
} | |||||
}); | |||||
return result; | |||||
}; | |||||
/** | |||||
* minio数据转成树形结构 | |||||
* @param {string} filepath minio的路径 | |||||
* @returns {list} [treeList, expandedKeys] 树形结构,和默认展开的元素 | |||||
*/ | |||||
export const getTreeListFromFilepath = async (filepath) => { | |||||
// 1,获取minio的数据 | |||||
const tmp = await window.minioClient.listObjects(filepath); | |||||
if(!tmp || !tmp.length){ | |||||
return [[], []]; | |||||
} | |||||
const minioList = []; | |||||
for (const item of tmp) { | |||||
minioList.push(item.name.replace(filepath, "")); | |||||
} | |||||
// 2 转成平级数据 | |||||
const dataList = []; | |||||
const keyList = []; // 去重用 | |||||
for (const filename of minioList) { | |||||
const list = filename.split("/"); | |||||
list.forEach((item, index) => { | |||||
const p = { | |||||
pid: index === 0 ? 9999 : `${index - 1}_${list[index - 1]}`, | |||||
id: `${index}_${item}`, | |||||
name: item, | |||||
originPath: filepath + list.slice(0,index+1).join('/'), | |||||
isFile: index === list.length-1, | |||||
}; | |||||
const key = `${p.pid}_${p.id}`; | |||||
if (keyList.indexOf(key) === -1) { | |||||
keyList.push(key); | |||||
dataList.push(p); | |||||
} | |||||
}); | |||||
} | |||||
// 2.1 最外层单独封装一层 | |||||
const tmp2 = filepath.split('/'); | |||||
const wrapperNodeName = tmp2[tmp2.length-2]; | |||||
const wrapperNode = { | |||||
pid: 0, | |||||
id: 9999, | |||||
name: wrapperNodeName, | |||||
originPath: filepath, | |||||
isFile: false, | |||||
}; | |||||
dataList.push(wrapperNode); | |||||
// 3 转成树形结构 | |||||
const treeList = _toTree([].concat(dataList)); | |||||
// 4 显示默认展开的层级,默认二级 | |||||
const expandedKeys = []; | |||||
for (const item of treeList) { | |||||
expandedKeys.push(item.id); | |||||
for(const item2 of item.children){ | |||||
expandedKeys.push(item2.id); | |||||
} | |||||
} | |||||
// 返回数据 | |||||
return [treeList, expandedKeys]; | |||||
}; | |||||
export function getUniqueId() { | |||||
return parseTime(new Date(), '{y}{m}{d}{h}{i}{s}{S}') + nanoid(4); | |||||
} | } |
@@ -18,6 +18,7 @@ | |||||
* validate,校验函数 | * validate,校验函数 | ||||
*/ | */ | ||||
import { isPlainObject } from 'lodash'; | |||||
import { ValidationProvider, ValidationObserver, extend } from 'vee-validate'; | import { ValidationProvider, ValidationObserver, extend } from 'vee-validate'; | ||||
import { required } from 'vee-validate/dist/rules'; | import { required } from 'vee-validate/dist/rules'; | ||||
@@ -268,3 +269,29 @@ export function validateRunCommand(rule, value, callback) { | |||||
callback(new Error('请输入正确的启动命令')); | callback(new Error('请输入正确的启动命令')); | ||||
} | } | ||||
} | } | ||||
// 校验标签组基本方法 | |||||
export const validateLabelsUtil = (value) => { | |||||
if(!isPlainObject(value)) { | |||||
return '标签不能为空'; | |||||
} | |||||
if(!value.name) { | |||||
return '标签名称不能为空'; | |||||
} | |||||
if(!value.color) { | |||||
return '标签颜色不能为空'; | |||||
} | |||||
if(!/^#[0-9A-F]{6}$/i.test(value.color)) { | |||||
return '标签颜色格式不对'; | |||||
} | |||||
return ''; | |||||
}; | |||||
export function validateLabel(rule, value, callback) { | |||||
const validateResult = validateLabelsUtil(value); | |||||
if(validateResult !== '') { | |||||
callback(new Error(validateResult)); | |||||
return; | |||||
} | |||||
callback(); | |||||
} |
@@ -21,6 +21,7 @@ | |||||
<cdOperation :addProps="operationProps"> | <cdOperation :addProps="operationProps"> | ||||
<span slot="right"> | <span slot="right"> | ||||
<el-input | <el-input | ||||
id="algorithmName" | |||||
v-model="localQuery.algorithmName" | v-model="localQuery.algorithmName" | ||||
clearable | clearable | ||||
placeholder="请输入算法名称或 ID" | placeholder="请输入算法名称或 ID" | ||||
@@ -30,6 +31,7 @@ | |||||
@clear="crud.toQuery" | @clear="crud.toQuery" | ||||
/> | /> | ||||
<el-input | <el-input | ||||
id="algorithmUsage" | |||||
v-model="localQuery.algorithmUsage" | v-model="localQuery.algorithmUsage" | ||||
clearable | clearable | ||||
placeholder="请输入算法用途" | placeholder="请输入算法用途" | ||||
@@ -43,8 +45,8 @@ | |||||
</cdOperation> | </cdOperation> | ||||
<div> | <div> | ||||
<el-tabs v-model="active" class="eltabs-inlineblock" @tab-click="handleClick"> | <el-tabs v-model="active" class="eltabs-inlineblock" @tab-click="handleClick"> | ||||
<el-tab-pane label="我的算法" name="1" /> | |||||
<el-tab-pane label="预置算法" name="2" /> | |||||
<el-tab-pane id="tab_0" label="我的算法" name="1" /> | |||||
<el-tab-pane id="tab_1" label="预置算法" name="2" /> | |||||
</el-tabs> | </el-tabs> | ||||
</div> | </div> | ||||
</div> | </div> | ||||
@@ -82,19 +84,19 @@ | |||||
</el-table-column> | </el-table-column> | ||||
<el-table-column label="操作" width="370px" fixed="right"> | <el-table-column label="操作" width="370px" fixed="right"> | ||||
<template slot-scope="scope"> | <template slot-scope="scope"> | ||||
<el-button v-if="isCustom" type="text" @click.stop="goEdit(scope.row)">在线编辑</el-button> | |||||
<el-button type="text" @click.stop="goTraining(scope.row)">创建训练任务</el-button> | |||||
<el-button type="text" @click.stop="goDownload(scope.row)">下载</el-button> | |||||
<el-button v-if="isPreset" type="text" @click.stop="doFork(scope.row)">fork</el-button> | |||||
<el-button v-if="isCustom" :id="`goEdit_`+scope.$index" type="text" @click.stop="goEdit(scope.row)">在线编辑</el-button> | |||||
<el-button :id="`goTraining_`+scope.$index" type="text" @click.stop="goTraining(scope.row)">创建训练任务</el-button> | |||||
<el-button :id="`goDownload_`+scope.$index" type="text" @click.stop="goDownload(scope.row)">下载</el-button> | |||||
<el-button v-if="isPreset" :id="`doFork_`+scope.$index" type="text" @click.stop="doFork(scope.row)">fork</el-button> | |||||
<el-dropdown v-if="isCustom"> | <el-dropdown v-if="isCustom"> | ||||
<el-button type="text" style="margin-left: 10px;" @click.stop> | <el-button type="text" style="margin-left: 10px;" @click.stop> | ||||
更多<i class="el-icon-arrow-down el-icon--right" /> | 更多<i class="el-icon-arrow-down el-icon--right" /> | ||||
</el-button> | </el-button> | ||||
<el-dropdown-menu slot="dropdown"> | <el-dropdown-menu slot="dropdown"> | ||||
<el-dropdown-item @click.native="doFork(scope.row)"> | |||||
<el-dropdown-item :id="`doFork_`+scope.$index" @click.native="doFork(scope.row)"> | |||||
<el-button type="text">fork</el-button> | <el-button type="text">fork</el-button> | ||||
</el-dropdown-item> | </el-dropdown-item> | ||||
<el-dropdown-item v-if="isCustom" @click.native="doDelete(scope.row.id)"> | |||||
<el-dropdown-item v-if="isCustom" :id="`doDelete_`+scope.$index" @click.native="doDelete(scope.row.id)"> | |||||
<el-button type="text">删除</el-button> | <el-button type="text">删除</el-button> | ||||
</el-dropdown-item> | </el-dropdown-item> | ||||
</el-dropdown-menu></el-dropdown> | </el-dropdown-menu></el-dropdown> | ||||
@@ -124,6 +126,7 @@ | |||||
> | > | ||||
<el-form-item label="名称" prop="algorithmName"> | <el-form-item label="名称" prop="algorithmName"> | ||||
<el-input | <el-input | ||||
id="algorithmName" | |||||
v-model.trim="form.algorithmName" | v-model.trim="form.algorithmName" | ||||
placeholder | placeholder | ||||
maxlength="32" | maxlength="32" | ||||
@@ -133,6 +136,7 @@ | |||||
</el-form-item> | </el-form-item> | ||||
<el-form-item label="描述" prop="description"> | <el-form-item label="描述" prop="description"> | ||||
<el-input | <el-input | ||||
id="description" | |||||
v-model="form.description" | v-model="form.description" | ||||
type="textarea" | type="textarea" | ||||
:rows="3" | :rows="3" | ||||
@@ -144,6 +148,7 @@ | |||||
</el-form-item> | </el-form-item> | ||||
<el-form-item label="算法用途" prop="algorithmUsage"> | <el-form-item label="算法用途" prop="algorithmUsage"> | ||||
<el-select | <el-select | ||||
id="algorithmUsage" | |||||
v-model="form.algorithmUsage" | v-model="form.algorithmUsage" | ||||
placeholder="请选择或输入算法用途" | placeholder="请选择或输入算法用途" | ||||
filterable | filterable | ||||
@@ -169,24 +174,34 @@ | |||||
</el-form-item> | </el-form-item> | ||||
<el-form-item v-show="formType !== 'fork'" ref="codeDir" label="上传代码包" prop="codeDir"> | <el-form-item v-show="formType !== 'fork'" ref="codeDir" label="上传代码包" prop="codeDir"> | ||||
<div v-if="formType === 'fork' && form.codeDir">源代码包: | <div v-if="formType === 'fork' && form.codeDir">源代码包: | ||||
<el-button type="text" @click="goDownload(form)">下载</el-button> | |||||
<el-button id="goDownload" type="text" @click="goDownload(form)">下载</el-button> | |||||
</div> | </div> | ||||
<upload-inline | <upload-inline | ||||
v-if="crud.status.cu > 0" | |||||
ref="upload" | ref="upload" | ||||
action="fakeApi" | action="fakeApi" | ||||
accept=".zip" | accept=".zip" | ||||
:acceptSize="100" | |||||
:acceptSize="1024" | |||||
:acceptSizeFormat="(size) => `${size/1024} GB`" | |||||
list-type="text" | list-type="text" | ||||
:show-file-count="false" | :show-file-count="false" | ||||
:params="uploadParams" | :params="uploadParams" | ||||
:auto-upload="true" | :auto-upload="true" | ||||
:hash="false" | :hash="false" | ||||
:limit="1" | :limit="1" | ||||
:on-remove="onFileRemove" | |||||
@uploadStart="uploadStart" | @uploadStart="uploadStart" | ||||
@uploadSuccess="uploadSuccess" | @uploadSuccess="uploadSuccess" | ||||
@uploadError="uploadError" | @uploadError="uploadError" | ||||
/> | /> | ||||
<div v-if="uploading"><i class="el-icon-loading" />算法上传中...</div> | |||||
<upload-progress | |||||
v-if="uploading" | |||||
:progress="progress" | |||||
:color="customColors" | |||||
:status="status" | |||||
:size="size" | |||||
@onSetProgress="onSetProgress" | |||||
/> | |||||
</el-form-item> | </el-form-item> | ||||
<el-form-item label="训练输出" prop="isTrainOut" class="is-required"> | <el-form-item label="训练输出" prop="isTrainOut" class="is-required"> | ||||
<el-tooltip | <el-tooltip | ||||
@@ -198,8 +213,18 @@ | |||||
<i class="el-icon-warning-outline primary f18 vm" /> | <i class="el-icon-warning-outline primary f18 vm" /> | ||||
</el-tooltip> | </el-tooltip> | ||||
</el-form-item> | </el-form-item> | ||||
<el-form-item label="断点续训"> | |||||
<el-tooltip | |||||
class="item" | |||||
effect="dark" | |||||
content="请确保代码中包含“model_load_dir”参数用于接收训练的断点路径" | |||||
placement="right" | |||||
> | |||||
<i class="el-icon-warning-outline primary f18 vm" /> | |||||
</el-tooltip> | |||||
</el-form-item> | |||||
<el-form-item label="日志输出" prop="isTrainLog"> | <el-form-item label="日志输出" prop="isTrainLog"> | ||||
<el-checkbox v-model="form.isTrainLog" /> | |||||
<el-checkbox id="isTrainLog" v-model="form.isTrainLog" /> | |||||
<el-tooltip | <el-tooltip | ||||
v-show="form.isTrainLog" | v-show="form.isTrainLog" | ||||
class="item" | class="item" | ||||
@@ -211,7 +236,7 @@ | |||||
</el-tooltip> | </el-tooltip> | ||||
</el-form-item> | </el-form-item> | ||||
<el-form-item label="可视化日志" prop="isVisualizedLog"> | <el-form-item label="可视化日志" prop="isVisualizedLog"> | ||||
<el-checkbox v-model="form.isVisualizedLog" /> | |||||
<el-checkbox id="isVisualizedLog" v-model="form.isVisualizedLog" /> | |||||
<el-tooltip | <el-tooltip | ||||
v-show="form.isVisualizedLog" | v-show="form.isVisualizedLog" | ||||
class="item" | class="item" | ||||
@@ -238,9 +263,7 @@ | |||||
</template> | </template> | ||||
<script> | <script> | ||||
import { nanoid } from 'nanoid'; | |||||
import { downloadZipFromObjectPath, parseTime, validateNameWithHyphen } from '@/utils'; | |||||
import { downloadZipFromObjectPath, validateNameWithHyphen, getUniqueId } from '@/utils'; | |||||
import CRUD, { presenter, header, form, crud } from '@crud/crud'; | import CRUD, { presenter, header, form, crud } from '@crud/crud'; | ||||
import cdOperation from '@crud/CD.operation'; | import cdOperation from '@crud/CD.operation'; | ||||
import rrOperation from '@crud/RR.operation'; | import rrOperation from '@crud/RR.operation'; | ||||
@@ -251,6 +274,7 @@ import { createNotebook, getNotebookAddress } from '@/api/development/notebook'; | |||||
import BaseModal from '@/components/BaseModal'; | import BaseModal from '@/components/BaseModal'; | ||||
import AlgorithmDetail from '@/components/Training/algorithmDetail'; | import AlgorithmDetail from '@/components/Training/algorithmDetail'; | ||||
import UploadInline from '@/components/UploadForm/inline'; | import UploadInline from '@/components/UploadForm/inline'; | ||||
import UploadProgress from '@/components/UploadProgress'; | |||||
const defaultForm = { | const defaultForm = { | ||||
id: null, | id: null, | ||||
@@ -275,6 +299,7 @@ export default { | |||||
AlgorithmDetail, | AlgorithmDetail, | ||||
UploadInline, | UploadInline, | ||||
rrOperation, | rrOperation, | ||||
UploadProgress, | |||||
}, | }, | ||||
cruds() { | cruds() { | ||||
return CRUD({ | return CRUD({ | ||||
@@ -338,8 +363,14 @@ export default { | |||||
objectPath: null, // 对象存储路径 | objectPath: null, // 对象存储路径 | ||||
}, | }, | ||||
disableEdit: false, | disableEdit: false, | ||||
keepAskAddress: false, | |||||
uploading: false, | uploading: false, | ||||
progress: 0, | |||||
size: 0, | |||||
customColors: [ | |||||
{color: '#909399', percentage: 40}, | |||||
{color: '#e6a23c', percentage: 80}, | |||||
{color: '#67c23a', percentage: 100}, | |||||
], | |||||
}; | }; | ||||
}, | }, | ||||
computed: { | computed: { | ||||
@@ -362,6 +393,9 @@ export default { | |||||
user() { | user() { | ||||
return this.$store.getters.user; | return this.$store.getters.user; | ||||
}, | }, | ||||
status() { | |||||
return this.progress === 100 ? 'success' : null; | |||||
}, | |||||
}, | }, | ||||
mounted() { | mounted() { | ||||
this.getAlgorithmUsages(); | this.getAlgorithmUsages(); | ||||
@@ -370,7 +404,7 @@ export default { | |||||
this.updateObjectPath(); | this.updateObjectPath(); | ||||
}, | }, | ||||
beforeDestroy() { | beforeDestroy() { | ||||
this.keepAskAddress = false; | |||||
this.disableEdit = false; | |||||
}, | }, | ||||
methods: { | methods: { | ||||
// handle | // handle | ||||
@@ -386,6 +420,7 @@ export default { | |||||
}, | }, | ||||
onDialogClose() { | onDialogClose() { | ||||
this.$refs.upload.formRef.reset(); | this.$refs.upload.formRef.reset(); | ||||
this.uploading = false; | |||||
}, | }, | ||||
onAlgorithmUsageChange(value) { | onAlgorithmUsageChange(value) { | ||||
const usageRes = this.algorithmUsageList.find(usage => usage.auxInfo === value); | const usageRes = this.algorithmUsageList.find(usage => usage.auxInfo === value); | ||||
@@ -393,14 +428,28 @@ export default { | |||||
this.createAlgorithmUsage(value); | this.createAlgorithmUsage(value); | ||||
} | } | ||||
}, | }, | ||||
uploadStart() { | |||||
this.uploading = true; | |||||
}, | |||||
uploadSuccess(res) { | |||||
this.form.codeDir = res[0].data.objectName; | |||||
onFileRemove() { | |||||
this.form.codeDir = null; | |||||
this.uploading = false; | this.uploading = false; | ||||
this.$refs.codeDir.validate('manual'); | this.$refs.codeDir.validate('manual'); | ||||
}, | }, | ||||
uploadStart(files) { | |||||
this.updateObjectPath(); | |||||
[ this.uploading, this.size, this.progress ] = [ true, files.size, 0 ]; | |||||
}, | |||||
onSetProgress(val) { | |||||
this.progress += val; | |||||
}, | |||||
uploadSuccess(res) { | |||||
this.progress = 100; | |||||
setTimeout(() => { | |||||
this.uploading = false; | |||||
}, 1000); | |||||
if (this.uploading) { | |||||
this.form.codeDir = res[0].data.objectName; | |||||
this.$refs.codeDir.validate('manual'); | |||||
} | |||||
}, | |||||
uploadError() { | uploadError() { | ||||
this.$message({ | this.$message({ | ||||
message: '上传文件失败', | message: '上传文件失败', | ||||
@@ -415,13 +464,13 @@ export default { | |||||
}, | }, | ||||
goTraining(item) { | goTraining(item) { | ||||
this.$router.push({ | this.$router.push({ | ||||
path: '/training/jobAdd', | |||||
path: '/training/jobadd', | |||||
name: 'jobAdd', | name: 'jobAdd', | ||||
params: { | params: { | ||||
from: 'algorithm', | from: 'algorithm', | ||||
params: { | params: { | ||||
algorithmId: item.id, | algorithmId: item.id, | ||||
algorithmSource: this.active, | |||||
algorithmSource: Number(this.active), | |||||
algorithmUsage: item.algorithmUsage, | algorithmUsage: item.algorithmUsage, | ||||
runParams: item.runParams, | runParams: item.runParams, | ||||
imageNameProject: item.imageNameProject, | imageNameProject: item.imageNameProject, | ||||
@@ -432,7 +481,7 @@ export default { | |||||
}); | }); | ||||
}, | }, | ||||
goDownload(algorithm) { | goDownload(algorithm) { | ||||
downloadZipFromObjectPath(algorithm.codeDir, `${algorithm.algorithmName }.zip`, { flat: true }); | |||||
downloadZipFromObjectPath(algorithm.codeDir, `${algorithm.algorithmName}.zip`, { flat: true }); | |||||
this.$message({ | this.$message({ | ||||
message: '请查看下载文件', | message: '请查看下载文件', | ||||
type: 'success', | type: 'success', | ||||
@@ -450,14 +499,10 @@ export default { | |||||
this.disableEdit = false; | this.disableEdit = false; | ||||
}); | }); | ||||
if (notebookInfo.status === 0 && notebookInfo.url) { | if (notebookInfo.status === 0 && notebookInfo.url) { | ||||
window.open(notebookInfo.url); | |||||
this.$message.success('Notebook已启动.'); | |||||
this.$router.push({ name: 'Notebook', params: { | |||||
noteBookName: notebookInfo.name, | |||||
}}); | |||||
this.openNoteBook(notebookInfo.url, notebookInfo.noteBookName); | |||||
} else { | } else { | ||||
this.keepAskAddress = true; | |||||
this.getNotebookAddress(notebookInfo.id, notebookInfo.name); | |||||
this.disableEdit = true; | |||||
this.getNotebookAddress(notebookInfo.id, notebookInfo.noteBookName); | |||||
} | } | ||||
}, | }, | ||||
// op | // op | ||||
@@ -479,26 +524,18 @@ export default { | |||||
// hook | // hook | ||||
[CRUD.HOOK.beforeToAdd]() { | [CRUD.HOOK.beforeToAdd]() { | ||||
this.formType = 'add'; | this.formType = 'add'; | ||||
this.updateObjectPath(); | |||||
}, | }, | ||||
[CRUD.HOOK.beforeRefresh]() { | [CRUD.HOOK.beforeRefresh]() { | ||||
this.crud.query = { ...this.localQuery}; | this.crud.query = { ...this.localQuery}; | ||||
this.crud.query.algorithmSource = Number(this.active); | this.crud.query.algorithmSource = Number(this.active); | ||||
}, | }, | ||||
getNotebookAddress(id, noteBookName) { | getNotebookAddress(id, noteBookName) { | ||||
if (!this.keepAskAddress) { | |||||
if (!this.disableEdit) { | |||||
return; | return; | ||||
} | } | ||||
this.disableEdit = true; | |||||
getNotebookAddress(id).then(url => { | getNotebookAddress(id).then(url => { | ||||
if (url) { | if (url) { | ||||
window.open(url); | |||||
this.$message.success('Notebook已启动.'); | |||||
this.disableEdit = false; | |||||
this.keepAskAddress = false; | |||||
this.$router.push({ name: 'Notebook', params: { | |||||
noteBookName, | |||||
}}); | |||||
this.openNoteBook(url, noteBookName); | |||||
} else { | } else { | ||||
setTimeout(() => { | setTimeout(() => { | ||||
this.getNotebookAddress(id, noteBookName); | this.getNotebookAddress(id, noteBookName); | ||||
@@ -506,7 +543,6 @@ export default { | |||||
} | } | ||||
}).catch(err => { | }).catch(err => { | ||||
this.disableEdit = false; | this.disableEdit = false; | ||||
this.keepAskAddress = false; | |||||
throw new Error(err); | throw new Error(err); | ||||
}); | }); | ||||
}, | }, | ||||
@@ -531,7 +567,15 @@ export default { | |||||
this.getAlgorithmUsages(); | this.getAlgorithmUsages(); | ||||
}, | }, | ||||
updateObjectPath() { | updateObjectPath() { | ||||
this.uploadParams.objectPath = `algorithm-manage/${this.user.id}/${parseTime(new Date(), '{y}{m}{d}{h}{i}{s}{S}') + nanoid(4)}`; | |||||
this.uploadParams.objectPath = `upload-temp/${this.user.id}/${getUniqueId()}`; | |||||
}, | |||||
openNoteBook(url, noteBookName) { | |||||
window.open(url); | |||||
this.$message.success('Notebook已启动.'); | |||||
this.disableEdit = false; | |||||
this.$router.push({ name: 'Notebook', params: { | |||||
noteBookName, | |||||
}}); | |||||
}, | }, | ||||
}, | }, | ||||
}; | }; | ||||
@@ -157,40 +157,40 @@ export default { | |||||
</script> | </script> | ||||
<style rel="stylesheet/scss" lang="scss" scoped> | <style rel="stylesheet/scss" lang="scss" scoped> | ||||
.dashboard-container { | |||||
padding: 24px; | |||||
color: #666; | |||||
.dashboard-container { | |||||
padding: 24px; | |||||
color: #666; | |||||
.section-title { | |||||
height: 24px; | |||||
margin: 26px 0 24px; | |||||
font-size: 18px; | |||||
font-weight: bold; | |||||
line-height: 24px; | |||||
letter-spacing: 2px; | |||||
} | |||||
.section-title { | |||||
height: 24px; | |||||
margin: 26px 0 24px; | |||||
font-size: 18px; | |||||
font-weight: bold; | |||||
line-height: 24px; | |||||
letter-spacing: 2px; | |||||
} | |||||
.section-card { | |||||
padding: 4px; | |||||
.section-card { | |||||
padding: 4px; | |||||
&:last-child { | |||||
margin-bottom: 34px; | |||||
} | |||||
&:last-child { | |||||
margin-bottom: 34px; | |||||
} | } | ||||
} | |||||
.card-head { | |||||
display: flex; | |||||
align-items: center; | |||||
justify-content: space-between; | |||||
height: 32px; | |||||
margin-bottom: 8px; | |||||
.card-head { | |||||
display: flex; | |||||
align-items: center; | |||||
justify-content: space-between; | |||||
height: 32px; | |||||
margin-bottom: 8px; | |||||
&-title { | |||||
height: 20px; | |||||
font-size: 14px; | |||||
font-weight: bold; | |||||
line-height: 20px; | |||||
} | |||||
&-title { | |||||
height: 20px; | |||||
font-size: 14px; | |||||
font-weight: bold; | |||||
line-height: 20px; | |||||
} | } | ||||
} | } | ||||
} | |||||
</style> | </style> |
@@ -30,7 +30,7 @@ | |||||
:isTrack="isTrack" | :isTrack="isTrack" | ||||
:state="state" | :state="state" | ||||
:currentImg="currentImg" | :currentImg="currentImg" | ||||
:handleBrushEnd="handleBrushEnd" | |||||
:drawBboxEnd="drawBboxEnd" | |||||
:createLabel="createLabel" | :createLabel="createLabel" | ||||
:queryLabels="queryLabels" | :queryLabels="queryLabels" | ||||
:updateState="updateState" | :updateState="updateState" | ||||
@@ -65,8 +65,8 @@ import { isEmpty, isFunction, omit, isNil } from 'lodash'; | |||||
import { detail, detectFileList, queryFileOffset, queryDataEnhanceList, getEnhanceFileList } from '@/api/preparation/dataset'; | import { detail, detectFileList, queryFileOffset, queryDataEnhanceList, getEnhanceFileList } from '@/api/preparation/dataset'; | ||||
import request from '@/utils/request'; | import request from '@/utils/request'; | ||||
import { generateUuid, generateBbox, endsWith, replace, remove, AssertError } from '@/utils'; | |||||
import { parseAnnotation, labelsSymbol, enhanceSymbol, stringifyAnnotations, annotationMap, transformFiles } from '../util'; | |||||
import { generateUuid, generateBbox, bbox2Extent, extent2Bbox, endsWith, replace, remove, AssertError } from '@/utils'; | |||||
import { parseAnnotation, labelsSymbol, enhanceSymbol, stringifyAnnotations, annotationMap, transformFiles, withExtent } from '../util'; | |||||
import ThumbContainer from './thumbContainer'; | import ThumbContainer from './thumbContainer'; | ||||
import WorkSpaceContainer from './workSpaceContainer'; | import WorkSpaceContainer from './workSpaceContainer'; | ||||
@@ -89,6 +89,9 @@ export default { | |||||
const { params = {}} = $route; | const { params = {}} = $route; | ||||
const workspaceRef = ref(null); | const workspaceRef = ref(null); | ||||
// 加载下一页,避免重复加载 | |||||
const loadNextPageFlag = ref(false); | |||||
// 标注类型 | // 标注类型 | ||||
const isTrack = $route.name.startsWith('TrackDataset'); | const isTrack = $route.name.startsWith('TrackDataset'); | ||||
// const isAnnotation = meta.type === 'annotate' | // const isAnnotation = meta.type === 'annotate' | ||||
@@ -102,6 +105,7 @@ export default { | |||||
hasMore: true, // 是否有更多列表 | hasMore: true, // 是否有更多列表 | ||||
datasetId: Number(params.datasetId), | datasetId: Number(params.datasetId), | ||||
currentImgId: Number(params.fileId) || undefined, // 当前图片 id | currentImgId: Number(params.fileId) || undefined, // 当前图片 id | ||||
rawAnnotations: [], // 原始标注集合 | |||||
annotations: [], // 标注集合 | annotations: [], // 标注集合 | ||||
fileInfo: null, // 文件信息 | fileInfo: null, // 文件信息 | ||||
fileId: Number($route.params.fileId), | fileId: Number($route.params.fileId), | ||||
@@ -177,8 +181,15 @@ export default { | |||||
}; | }; | ||||
// 根据异步结果更新状态 | // 根据异步结果更新状态 | ||||
const updateState = (nextState) => { | |||||
Object.assign(state, nextState); | |||||
const updateState = (params) => { | |||||
// 区分函数式更新和对象更新 | |||||
if(typeof params === 'function') { | |||||
const next = params(state); | |||||
Object.assign(state, next); | |||||
return; | |||||
} | |||||
// 普通更新 | |||||
Object.assign(state, params); | |||||
}; | }; | ||||
// 根据 labelId 获取标签颜色 | // 根据 labelId 获取标签颜色 | ||||
@@ -236,6 +247,7 @@ export default { | |||||
const clearHistory = () => { | const clearHistory = () => { | ||||
updateState({ | updateState({ | ||||
history: [], | history: [], | ||||
rawAnnotations: [], | |||||
annotations: [], | annotations: [], | ||||
fileInfo: null, // 当前文件信息 | fileInfo: null, // 当前文件信息 | ||||
lastSelectedLabel: undefined, | lastSelectedLabel: undefined, | ||||
@@ -259,7 +271,13 @@ export default { | |||||
// 当到下边界只有 2 张图片时,请求下一页数据 | // 当到下边界只有 2 张图片时,请求下一页数据 | ||||
// 仍然有下页 | // 仍然有下页 | ||||
if (index + 2 >= fileList.value.length && state.hasMore) { | if (index + 2 >= fileList.value.length && state.hasMore) { | ||||
queryNextPage({ offset: state.offset, type: state.fileFilterType }); | |||||
// 避免重复加载 | |||||
if(loadNextPageFlag.value === false) { | |||||
loadNextPageFlag.value = true; | |||||
queryNextPage({ offset: state.offset, type: state.fileFilterType }).then(() => { | |||||
loadNextPageFlag.value = false; | |||||
}); | |||||
} | |||||
} | } | ||||
}; | }; | ||||
@@ -286,7 +304,7 @@ export default { | |||||
// 请求指定图片信息 | // 请求指定图片信息 | ||||
const queryFile = async(id) => { | const queryFile = async(id) => { | ||||
const file = await request(`api/data/datasets/files/${id}/info`) || {}; | |||||
const file = await request(`api/data/datasets/files/${params.datasetId}/${id}/info`) || {}; | |||||
return file; | return file; | ||||
}; | }; | ||||
@@ -319,7 +337,7 @@ export default { | |||||
// 保存标注 | // 保存标注 | ||||
const saveAnnotation = async(data) => { | const saveAnnotation = async(data) => { | ||||
await request.post(`api/data/datasets/files/${state.currentImgId}/annotations`, data).then(() => { | |||||
await request.post(`api/data/datasets/files/${params.datasetId}/${state.currentImgId}/annotations`, data).then(() => { | |||||
// 清空历史记录 | // 清空历史记录 | ||||
Object.assign(state, { history: [] }); | Object.assign(state, { history: [] }); | ||||
Message.success({ message: '保存成功', duration: 800 }); | Message.success({ message: '保存成功', duration: 800 }); | ||||
@@ -328,7 +346,7 @@ export default { | |||||
// 人工确认标注 | // 人工确认标注 | ||||
const confirmAnnotation = async(data) => { | const confirmAnnotation = async(data) => { | ||||
await request.post(`api/data/datasets/files/${state.currentImgId}/annotations/finish`, data).then(() => { | |||||
await request.post(`api/data/datasets/files/${params.datasetId}/${state.currentImgId}/annotations/finish`, data).then(() => { | |||||
// 清空历史记录 | // 清空历史记录 | ||||
Object.assign(state, { history: [] }); | Object.assign(state, { history: [] }); | ||||
// todo: 更新列表 | // todo: 更新列表 | ||||
@@ -352,15 +370,71 @@ export default { | |||||
}); | }); | ||||
}; | }; | ||||
// 将绝对路径映射为相对图片路径 | |||||
const mapBrushToBbox = annotation => { | |||||
const { bbox } = annotation.data; | |||||
const { dimension } = workspaceRef.value; | |||||
// 临时变量 | |||||
let temp_bbox = {}; | |||||
// 解析 bbox 值 | |||||
const _bbox = {}; | |||||
// 当图片缩放比例小于1,当前画布尺寸会超过图片,需要截取空白尺寸 | |||||
if (dimension.scale < 1) { | |||||
const padding = { | |||||
width: dimension.svg.width - dimension.img.width * dimension.scale, | |||||
height: dimension.svg.height - dimension.img.height * dimension.scale, | |||||
}; | |||||
Object.assign(temp_bbox, { | |||||
...bbox, | |||||
x: bbox.x - padding.width / 2, | |||||
// 垂直反向偏移 | |||||
y: bbox.y - padding.height / 2, | |||||
}); | |||||
} else { | |||||
temp_bbox = bbox; | |||||
} | |||||
for (const k in temp_bbox) { | |||||
// 根据图片缩放比例进行调整 | |||||
_bbox[k] = temp_bbox[k] / (dimension.scale || 1); | |||||
} | |||||
const updatedAnnotation = { | |||||
...annotation, | |||||
data: { | |||||
...annotation.data, | |||||
bbox: _bbox, | |||||
extent: bbox2Extent(_bbox), | |||||
}, | |||||
}; | |||||
return updatedAnnotation; | |||||
}; | |||||
// 保存的时候生成新的位置信息 | |||||
const rescale = (annotation) => { | |||||
const { extent } = annotation.data; | |||||
const updatedAnnotation = { | |||||
...annotation, | |||||
data: { | |||||
...annotation.data, | |||||
bbox: extent2Bbox(extent), | |||||
}, | |||||
}; | |||||
// _type 仅供绘画使用 | |||||
return omit(updatedAnnotation, ['__type']); | |||||
}; | |||||
// 手动画框结束 | // 手动画框结束 | ||||
const handleBrushEnd = (brush) => { | |||||
const drawBboxEnd = (brush) => { | |||||
const bbox = generateBbox(brush); | const bbox = generateBbox(brush); | ||||
// 记录上一次选中的 selectLabel | // 记录上一次选中的 selectLabel | ||||
const otherProps = state.lastSelectedLabel ? { | const otherProps = state.lastSelectedLabel ? { | ||||
categoryId: state.lastSelectedLabel, | categoryId: state.lastSelectedLabel, | ||||
color: getColorLabel(state.lastSelectedLabel), | color: getColorLabel(state.lastSelectedLabel), | ||||
} : {}; | } : {}; | ||||
const annotation = { | |||||
const rawAnnotation = { | |||||
id: generateUuid(), | id: generateUuid(), | ||||
__type: 0, // 标识为新创建的标注 | __type: 0, // 标识为新创建的标注 | ||||
data: { | data: { | ||||
@@ -369,6 +443,9 @@ export default { | |||||
...otherProps, | ...otherProps, | ||||
}, | }, | ||||
}; | }; | ||||
// todo: 转换成标准地址(extent/bbox) | |||||
const annotation = mapBrushToBbox(rawAnnotation); | |||||
// 更新框选位置坐标 | // 更新框选位置坐标 | ||||
const newAnnotation = (state.annotations || []).concat(annotation); | const newAnnotation = (state.annotations || []).concat(annotation); | ||||
Object.assign(state, { | Object.assign(state, { | ||||
@@ -386,48 +463,6 @@ export default { | |||||
return true; | return true; | ||||
}; | }; | ||||
// 保存的时候生成新的位置信息 | |||||
const rescale = (annotation) => { | |||||
const { __type } = annotation; | |||||
const { bbox } = annotation.data; | |||||
const { dimension } = workspaceRef.value; | |||||
// 临时变量 | |||||
let temp_bbox = {}; | |||||
// 解析 bbox 值 | |||||
const _bbox = {}; | |||||
if (__type === 0) { | |||||
// 当图片缩放比例小于1,当前画布尺寸会超过图片,需要截取空白尺寸 | |||||
if (dimension.scale < 1) { | |||||
const padding = { | |||||
width: dimension.svg.width - dimension.img.width * dimension.scale, | |||||
height: dimension.svg.height - dimension.img.height * dimension.scale, | |||||
}; | |||||
Object.assign(temp_bbox, { | |||||
...bbox, | |||||
x: bbox.x - padding.width / 2, | |||||
// 垂直反向偏移 | |||||
// y: bbox.y - padding.height / 2 | |||||
}); | |||||
} else { | |||||
temp_bbox = bbox; | |||||
} | |||||
for (const k in temp_bbox) { | |||||
// 根据图片缩放比例进行调整 | |||||
_bbox[k] = temp_bbox[k] / (dimension.scale || 1); | |||||
} | |||||
} | |||||
const updatedAnnotation = { | |||||
...annotation, | |||||
data: { | |||||
...annotation.data, | |||||
bbox: __type === 0 ? _bbox : bbox, | |||||
}, | |||||
}; | |||||
// _type 仅供绘画使用 | |||||
return omit(updatedAnnotation, ['__type']); | |||||
}; | |||||
// 保存标注 | // 保存标注 | ||||
const handleSave = () => { | const handleSave = () => { | ||||
const isValid = state.annotations.every(checkAnnotationValid); | const isValid = state.annotations.every(checkAnnotationValid); | ||||
@@ -580,6 +615,8 @@ export default { | |||||
let { result: files } = rawFile.value; | let { result: files } = rawFile.value; | ||||
const { __offset__, page = {}} = rawFile.value; | const { __offset__, page = {}} = rawFile.value; | ||||
// 同步当前文件的偏移 | |||||
state.offset = __offset__; | |||||
// 自定义分页 | // 自定义分页 | ||||
// 当前条数小于每页可返回的总条数,向上补齐 | // 当前条数小于每页可返回的总条数,向上补齐 | ||||
const availableSize = Math.min(page.size, page.total); | const availableSize = Math.min(page.size, page.total); | ||||
@@ -610,15 +647,16 @@ export default { | |||||
updateState(nextState); | updateState(nextState); | ||||
// 根据第一个文件是否携带数据增强结果来决定是否展示 | // 根据第一个文件是否携带数据增强结果来决定是否展示 | ||||
const firstEnhanceList = await getEnhanceFileList(firstFile.id); | |||||
const firstEnhanceList = await getEnhanceFileList(params.datasetId, firstFile.id); | |||||
// 更新当前图片 | // 更新当前图片 | ||||
const { file, annotations } = await updateImageInfo(activeFileId, labels); | const { file, annotations } = await updateImageInfo(activeFileId, labels); | ||||
updateState({ | updateState({ | ||||
currentImgId: file.id, | currentImgId: file.id, | ||||
fileInfo: file, | fileInfo: file, | ||||
annotations, | |||||
hasEnhanceRecord: firstEnhanceList.length > 0, | |||||
rawAnnotations: annotations, | |||||
annotations: withExtent(annotations), | |||||
hasEnhanceRecord: !isNil(firstEnhanceList), | |||||
}); | }); | ||||
}); | }); | ||||
@@ -632,6 +670,7 @@ export default { | |||||
watch(() => [state.currentImgId, state.timestamp], async() => { | watch(() => [state.currentImgId, state.timestamp], async() => { | ||||
const imgId = state.currentImgId; | const imgId = state.currentImgId; | ||||
updateState({ | updateState({ | ||||
rawAnnotations: [], | |||||
annotations: [], | annotations: [], | ||||
fileInfo: null, | fileInfo: null, | ||||
}); | }); | ||||
@@ -646,7 +685,8 @@ export default { | |||||
gotoFileDetail(imgId); | gotoFileDetail(imgId); | ||||
// 清理数据 | // 清理数据 | ||||
updateState({ | updateState({ | ||||
annotations, | |||||
rawAnnotations: annotations, | |||||
annotations: withExtent(annotations), | |||||
fileInfo: file, | fileInfo: file, | ||||
}); | }); | ||||
} | } | ||||
@@ -663,7 +703,7 @@ export default { | |||||
currentImg, | currentImg, | ||||
handleSelection, | handleSelection, | ||||
handleBrushStart, | handleBrushStart, | ||||
handleBrushEnd, | |||||
drawBboxEnd, | |||||
handleSave, | handleSave, | ||||
handleConfirm, | handleConfirm, | ||||
gotoFileDetail, | gotoFileDetail, | ||||
@@ -102,7 +102,7 @@ export default { | |||||
}; | }; | ||||
const withEdit = (item, isEdit = false) => { | const withEdit = (item, isEdit = false) => { | ||||
const { categoryId, track_id } = item.data; | |||||
const { categoryId, track_id } = item.data || {}; | |||||
// 获取到分类标签名 | // 获取到分类标签名 | ||||
const labelName = rLabels.value[categoryId]; | const labelName = rLabels.value[categoryId]; | ||||
const labelNameTxt = labelName ? `${labelName}_` : ''; | const labelNameTxt = labelName ? `${labelName}_` : ''; | ||||
@@ -151,7 +151,7 @@ export default { | |||||
watch(() => props.fileId, async(next) => { | watch(() => props.fileId, async(next) => { | ||||
if (next) { | if (next) { | ||||
const enhanceFileList = await getEnhanceFileList(next); | |||||
const enhanceFileList = await getEnhanceFileList(props.datasetId,next); | |||||
const isOrigin = !!enhanceFileList.length; // 被增强 | const isOrigin = !!enhanceFileList.length; // 被增强 | ||||
Object.assign(state, { | Object.assign(state, { | ||||
isOrigin, | isOrigin, | ||||
@@ -17,13 +17,35 @@ | |||||
<template> | <template> | ||||
<div class="workspace-settings"> | <div class="workspace-settings"> | ||||
<el-form label-position="top" @submit.native.prevent> | <el-form label-position="top" @submit.native.prevent> | ||||
<el-form-item v-if="state.datasetInfo.value.labelGroupId" label="标签组" style="margin-bottom: 0;"> | |||||
<div style="margin-top: -10px;"> | |||||
<span class="vm">{{ state.datasetInfo.value.labelGroupName }} </span> | |||||
<el-link | |||||
target="_blank" | |||||
type="primary" | |||||
:underline="false" | |||||
class="vm" | |||||
:href="`/data/labelgroup/detail?id=${state.datasetInfo.value.labelGroupId}`" | |||||
> | |||||
查看详情 | |||||
</el-link> | |||||
</div> | |||||
</el-form-item> | |||||
<SelectLabel | <SelectLabel | ||||
v-if="!isPresetLabel" | v-if="!isPresetLabel" | ||||
:dataSource="api.systemLabels" | :dataSource="api.systemLabels" | ||||
:handleLabelChange="handleLabelChange" | :handleLabelChange="handleLabelChange" | ||||
@postLabel="postLabel" | @postLabel="postLabel" | ||||
/> | /> | ||||
<LabelList :labels="labels" /> | |||||
<LabelList | |||||
:labels="labels" | |||||
:editLabel="edit" | |||||
:annotations="state.annotations.value" | |||||
:currentAnnotationId="api.currentAnnotationId" | |||||
:updateState="updateState" | |||||
:getColorLabel="getColorLabel" | |||||
:findRowIndex="findRowIndex" | |||||
/> | |||||
<Annotations | <Annotations | ||||
:annotations="state.annotations.value" | :annotations="state.annotations.value" | ||||
:currentAnnotationId="state.currentAnnotationId.value" | :currentAnnotationId="state.currentAnnotationId.value" | ||||
@@ -55,10 +77,10 @@ | |||||
<script> | <script> | ||||
import { Message } from 'element-ui'; | import { Message } from 'element-ui'; | ||||
import { inject, reactive, onMounted, computed } from '@vue/composition-api'; | |||||
import { inject, watch, reactive, onMounted, computed } from '@vue/composition-api'; | |||||
import { isNil } from 'lodash'; | import { isNil } from 'lodash'; | ||||
import { getAutoLabels } from '@/api/preparation/datalabel'; | |||||
import { getAutoLabels, editLabel } from '@/api/preparation/datalabel'; | |||||
import { labelsSymbol } from '@/views/dataset/util'; | import { labelsSymbol } from '@/views/dataset/util'; | ||||
import SelectLabel from './selectLabel'; | import SelectLabel from './selectLabel'; | ||||
@@ -90,6 +112,7 @@ export default { | |||||
const { createLabel, updateState, queryLabels } = props; | const { createLabel, updateState, queryLabels } = props; | ||||
const api = reactive({ | const api = reactive({ | ||||
newLabel: undefined, | newLabel: undefined, | ||||
currentAnnotationId: undefined, | |||||
}); | }); | ||||
// 当前所有标签信息 | // 当前所有标签信息 | ||||
const labels = inject(labelsSymbol); | const labels = inject(labelsSymbol); | ||||
@@ -103,21 +126,13 @@ export default { | |||||
}); | }); | ||||
}; | }; | ||||
const addLabel = (label) => { | |||||
api.newLabel = label; | |||||
// 编辑标签 | |||||
const edit = (labelId, data) => { | |||||
return editLabel(labelId, data).then(refreshLabel); | |||||
}; | }; | ||||
const handleLabelChange = value => { | |||||
// 新建标签 | |||||
if (!isNil(value)) { | |||||
// 如果不是系统标签,才会选择新建 | |||||
if (api.systemLabels.findIndex(d => d.value === value) === -1) { | |||||
addLabel(value); | |||||
} else { | |||||
const systemLabel = api.systemLabels.find(d => d.value === value) || {}; | |||||
systemLabel.label && addLabel(systemLabel.label); | |||||
} | |||||
} | |||||
const addLabel = (label) => { | |||||
api.newLabel = label; | |||||
}; | }; | ||||
const postLabel = () => { | const postLabel = () => { | ||||
@@ -131,6 +146,24 @@ export default { | |||||
api.newLabel = undefined; | api.newLabel = undefined; | ||||
refreshLabel(); | refreshLabel(); | ||||
}); | }); | ||||
} else { | |||||
Message.warning('请选择标签'); | |||||
} | |||||
}; | |||||
const handleLabelChange = (value, callback) => { | |||||
// 新建标签 | |||||
if (!isNil(value)) { | |||||
// 如果不是系统标签,才会选择新建 | |||||
if (api.systemLabels.findIndex(d => d.value === value) === -1) { | |||||
addLabel(value); | |||||
// 新建标签直接触发创建 | |||||
postLabel(); | |||||
typeof callback === 'function' && callback(); | |||||
} else { | |||||
const systemLabel = api.systemLabels.find(d => d.value === value) || {}; | |||||
systemLabel.label && addLabel(systemLabel.label); | |||||
} | |||||
} | } | ||||
}; | }; | ||||
@@ -176,8 +209,14 @@ export default { | |||||
updateState(newState); | updateState(newState); | ||||
}; | }; | ||||
// 使用的是预置标签时type大于1,目前自定义标签type为0,自动标注标签为1 | |||||
const isPresetLabel = computed(() => labels.value && labels.value[0] && labels.value[0].type > 1); | |||||
// labelGroupType 标签组类型:0: private 私有标签组, 1:public 公开标签组 | |||||
const isPresetLabel = computed(() => props.state.labelGroupType === 1); | |||||
watch(() => props.state, (next) => { | |||||
if ('currentAnnotationId' in next) { | |||||
api.currentAnnotationId = next.currentAnnotationId || []; | |||||
} | |||||
}); | |||||
onMounted(() => { | onMounted(() => { | ||||
getSystemLabel(); | getSystemLabel(); | ||||
@@ -190,6 +229,8 @@ export default { | |||||
toggleShowId, | toggleShowId, | ||||
labels, | labels, | ||||
postLabel, | postLabel, | ||||
addLabel, | |||||
edit, | |||||
handleLabelChange, | handleLabelChange, | ||||
isPresetLabel, | isPresetLabel, | ||||
}; | }; | ||||
@@ -0,0 +1,135 @@ | |||||
/** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||||
* | |||||
* Licensed under the Apache License, Version 2.0 (the "License"); | |||||
* you may not use this file except in compliance with the License. | |||||
* You may obtain a copy of the License at | |||||
* | |||||
* http://www.apache.org/licenses/LICENSE-2.0 | |||||
* | |||||
* 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. | |||||
* ============================================================= | |||||
*/ | |||||
<template> | |||||
<el-popover | |||||
v-model="state.visible" | |||||
placement="top" | |||||
width="240" | |||||
trigger="click" | |||||
title="编辑标签" | |||||
@show="onShow" | |||||
> | |||||
<el-form ref="formRef" :model="state.form" :rules="rules" label-width="60px" style="margin-top: 20px;"> | |||||
<el-form-item label="名称" prop="name"> | |||||
<el-input | |||||
ref="inputRef" | |||||
v-model="state.form.name" | |||||
placeholder="修改标签名称" | |||||
/> | |||||
</el-form-item> | |||||
<el-form-item label="颜色" prop="color"> | |||||
<el-color-picker v-model="state.form.color" /> | |||||
</el-form-item> | |||||
<div class="tc"> | |||||
<el-button type="text" @click="handleCancel">取消</el-button> | |||||
<el-button type="primary" @click="handleOk">确定</el-button> | |||||
</div> | |||||
</el-form> | |||||
<i | |||||
slot="reference" | |||||
class="el-icon-edit" | |||||
style="margin-left: 4px;" | |||||
:style="getStyle(item)" | |||||
/> | |||||
</el-popover> | |||||
</template> | |||||
<script> | |||||
import Vue from 'vue'; | |||||
import { reactive, ref, watch } from '@vue/composition-api'; | |||||
import { validateName } from '@/utils/validate'; | |||||
export default { | |||||
name: 'EditLabel', | |||||
props: { | |||||
item: { | |||||
type: Object, | |||||
default: () => ({}), | |||||
}, | |||||
getStyle: Function, | |||||
title: String, | |||||
}, | |||||
setup(props, ctx) { | |||||
const inputRef = ref(null); | |||||
const formRef = ref(null); | |||||
const state = reactive({ | |||||
visible: false, | |||||
form: { | |||||
name: props.item.name || '', | |||||
color: props.item.color || '#2e4fde', | |||||
}, | |||||
}); | |||||
// 表单规则 | |||||
const rules = { | |||||
name: [ | |||||
{ required: true, message: '请输入数据集名称', trigger: ['change', 'blur'] }, | |||||
{ validator: validateName, trigger: ['change', 'blur'] }, | |||||
], | |||||
}; | |||||
const handleCancel = () => { | |||||
Object.assign(state, { | |||||
visible: false, | |||||
form: { | |||||
name: props.item.name || '', | |||||
color: props.item.color || '#2e4fde', | |||||
}, | |||||
}); | |||||
}; | |||||
// 编辑标注名称 | |||||
const handleOk = () => { | |||||
formRef.value.validate().then(valid => { | |||||
if (!valid) { | |||||
return; | |||||
} | |||||
ctx.emit('handleOk', state.form, props.item); | |||||
handleCancel(); | |||||
}); | |||||
}; | |||||
const onShow = () => { | |||||
// onShow 的时候重置 | |||||
Vue.nextTick(() => { | |||||
const input = inputRef && inputRef.value.$refs.input; | |||||
input && input.focus(); | |||||
}); | |||||
}; | |||||
watch(() => props.item, (next) => { | |||||
if (next) { | |||||
state.form = { | |||||
name: next.name || '', | |||||
color: next.color || '#2e4fde', | |||||
}; | |||||
} | |||||
}); | |||||
return { | |||||
props, | |||||
state, | |||||
rules, | |||||
inputRef, | |||||
formRef, | |||||
handleOk, | |||||
handleCancel, | |||||
onShow, | |||||
}; | |||||
}, | |||||
}; | |||||
</script> |
@@ -23,7 +23,21 @@ | |||||
<div style="max-height: 200px; padding: 0 2.5px; overflow: auto;"> | <div style="max-height: 200px; padding: 0 2.5px; overflow: auto;"> | ||||
<el-row :gutter="5" style="clear: both;"> | <el-row :gutter="5" style="clear: both;"> | ||||
<el-col v-for="item in state.labelData" :key="item.id" :span="8"> | <el-col v-for="item in state.labelData" :key="item.id" :span="8"> | ||||
<el-tag class="tag-item" :title="item.name" :color="item.color" :style="getStyle(item)">{{ item.name }}</el-tag> | |||||
<el-tag | |||||
class="tag-item" | |||||
:title="item.name" | |||||
:color="item.color" | |||||
:style="getStyle(item)" | |||||
@click="event => handleEditAnnotation(item, event)" | |||||
> | |||||
{{ item.name }} | |||||
<Edit | |||||
v-if="!item.labelGroupId" | |||||
:getStyle="getStyle" | |||||
:item="item" | |||||
@handleOk="handleEditLabel" | |||||
/> | |||||
</el-tag> | |||||
</el-col> | </el-col> | ||||
</el-row> | </el-row> | ||||
</div> | </div> | ||||
@@ -32,34 +46,44 @@ | |||||
<script> | <script> | ||||
import { reactive, watch, computed } from '@vue/composition-api'; | import { reactive, watch, computed } from '@vue/composition-api'; | ||||
import SearchLabel from '@/views/dataset/components/searchLabel'; | |||||
const chroma = require('chroma-js'); | |||||
import { colorByLuminance, replace } from '@/utils'; | |||||
import SearchLabel from '@/views/dataset/components/searchLabel'; | |||||
import Edit from './edit'; | |||||
export default { | export default { | ||||
name: 'LabelList', | name: 'LabelList', | ||||
components: { | components: { | ||||
SearchLabel, | SearchLabel, | ||||
Edit, | |||||
}, | }, | ||||
props: { | props: { | ||||
labels: { | labels: { | ||||
type: Array, | type: Array, | ||||
default: () => ([]), | default: () => ([]), | ||||
}, | }, | ||||
currentAnnotationId: { | |||||
type: String, | |||||
default: undefined, | |||||
}, | |||||
editLabel: Function, | |||||
annotations: Array, | |||||
updateState: Function, | |||||
getColorLabel: Function, | |||||
findRowIndex: Function, | |||||
}, | }, | ||||
setup(props) { | setup(props) { | ||||
const { annotations: rawAnnotations ,updateState, getColorLabel, findRowIndex, editLabel } = props; | |||||
const state = reactive({ | const state = reactive({ | ||||
annotations: rawAnnotations, | |||||
labelData: props.labels, | labelData: props.labels, | ||||
currentAnnotationId: props.currentAnnotationId, | |||||
}); | }); | ||||
// 根据亮度来决定颜色 | // 根据亮度来决定颜色 | ||||
const getStyle = (item) => { | const getStyle = (item) => { | ||||
if (item.color && chroma(item.color).luminance() < 0.5) { | |||||
return { | |||||
color: '#fff', | |||||
}; | |||||
} | |||||
const color = colorByLuminance(item.color); | |||||
return { | return { | ||||
color: '#000', | |||||
color, | |||||
}; | }; | ||||
}; | }; | ||||
// 查询分类标签 | // 查询分类标签 | ||||
@@ -75,16 +99,53 @@ export default { | |||||
return `全部标签(${props.labels.length})`; | return `全部标签(${props.labels.length})`; | ||||
}); | }); | ||||
const handleEditAnnotation = (item, event) => { | |||||
// 过滤编辑入口 | |||||
if (event.target.classList.contains('el-icon-edit')) return; | |||||
const updateIndex = findRowIndex(state.currentAnnotationId); | |||||
if (updateIndex > -1) { | |||||
const curItem = props.annotations[updateIndex]; | |||||
const nextItem = { | |||||
...curItem, | |||||
data: { | |||||
...curItem.data, | |||||
categoryId: item.id, | |||||
color: getColorLabel(item.id), | |||||
}, | |||||
}; | |||||
const updateList = replace(props.annotations, updateIndex, nextItem); | |||||
updateState({ | |||||
annotations: updateList, | |||||
}); | |||||
} | |||||
}; | |||||
const handleEditLabel = (field, item) => { | |||||
editLabel(item.id, field); | |||||
}; | |||||
watch(() => props.labels, (next) => { | watch(() => props.labels, (next) => { | ||||
state.labelData = next; | state.labelData = next; | ||||
}); | }); | ||||
watch(() => props.currentAnnotationId, (next) => { | |||||
state.currentAnnotationId = next; | |||||
}); | |||||
return { | return { | ||||
state, | state, | ||||
labelsTitle, | labelsTitle, | ||||
handleEditAnnotation, | |||||
handleEditLabel, | |||||
getStyle, | getStyle, | ||||
handleSearch, | handleSearch, | ||||
}; | }; | ||||
}, | }, | ||||
}; | }; | ||||
</script> | </script> | ||||
<style lang="scss" scoped> | |||||
.el-icon-edit { | |||||
padding: 0 4px; | |||||
margin-left: 4px; | |||||
} | |||||
</style> |
@@ -22,6 +22,7 @@ | |||||
<div class="flex flex-between"> | <div class="flex flex-between"> | ||||
<InfoSelect | <InfoSelect | ||||
v-model="state.label" | v-model="state.label" | ||||
:innerRef="innerRef" | |||||
style="width: 68%;" | style="width: 68%;" | ||||
placeholder="选择已有标签或新建标签" | placeholder="选择已有标签或新建标签" | ||||
:dataSource="dataSource" | :dataSource="dataSource" | ||||
@@ -29,7 +30,7 @@ | |||||
default-first-option | default-first-option | ||||
filterable | filterable | ||||
allow-create | allow-create | ||||
@change="handleLabelChange" | |||||
@change="handleChange" | |||||
/> | /> | ||||
<el-button size="mini" type="primary" @click="postLabel">确定</el-button> | <el-button size="mini" type="primary" @click="postLabel">确定</el-button> | ||||
</div> | </div> | ||||
@@ -37,7 +38,7 @@ | |||||
</template> | </template> | ||||
<script> | <script> | ||||
import { reactive } from '@vue/composition-api'; | |||||
import { reactive, ref } from '@vue/composition-api'; | |||||
import InfoSelect from '@/components/InfoSelect'; | import InfoSelect from '@/components/InfoSelect'; | ||||
import LabelTip from './labelTip'; | import LabelTip from './labelTip'; | ||||
@@ -56,6 +57,9 @@ export default { | |||||
handleLabelChange: Function, | handleLabelChange: Function, | ||||
}, | }, | ||||
setup(props, ctx) { | setup(props, ctx) { | ||||
const { handleLabelChange } = props; | |||||
const selectRef = ref(null); | |||||
const state = reactive({ | const state = reactive({ | ||||
label: undefined, | label: undefined, | ||||
}); | }); | ||||
@@ -65,9 +69,17 @@ export default { | |||||
state.label = undefined; | state.label = undefined; | ||||
}; | }; | ||||
const handleChange = (params) => { | |||||
handleLabelChange(params, () => { | |||||
state.label = undefined; | |||||
}); | |||||
}; | |||||
return { | return { | ||||
state, | state, | ||||
postLabel, | postLabel, | ||||
handleChange, | |||||
innerRef: () => selectRef, | |||||
}; | }; | ||||
}, | }, | ||||
}; | }; | ||||
@@ -48,6 +48,7 @@ | |||||
</div> | </div> | ||||
</div> | </div> | ||||
<List | <List | ||||
ref="listRef" | |||||
v-bind="$attrs" | v-bind="$attrs" | ||||
:updateState="updateState" | :updateState="updateState" | ||||
:list="state.files.value" | :list="state.files.value" | ||||
@@ -55,6 +56,7 @@ | |||||
:hasMore="state.hasMore.value" | :hasMore="state.hasMore.value" | ||||
:total="state.total.value" | :total="state.total.value" | ||||
:offset="state.offset.value" | :offset="state.offset.value" | ||||
:type="thumbState.type" | |||||
:history="state.history.value" | :history="state.history.value" | ||||
v-on="$listeners" | v-on="$listeners" | ||||
/> | /> | ||||
@@ -93,7 +95,7 @@ import { Message } from 'element-ui'; | |||||
import { pick } from 'lodash'; | import { pick } from 'lodash'; | ||||
import UploadForm from '@/components/UploadForm'; | import UploadForm from '@/components/UploadForm'; | ||||
import { fileTypeEnum, getImgFromMinIO, withDimensionFile } from '@/views/dataset/util'; | |||||
import { fileTypeEnum, fileCodeMap, getImgFromMinIO, withDimensionFile } from '@/views/dataset/util'; | |||||
import { submit } from '@/api/preparation/datafile'; | import { submit } from '@/api/preparation/datafile'; | ||||
import { detectFileList, queryFileOffset } from '@/api/preparation/dataset'; | import { detectFileList, queryFileOffset } from '@/api/preparation/dataset'; | ||||
import List from './list'; | import List from './list'; | ||||
@@ -115,6 +117,8 @@ export default { | |||||
const { $route } = ctx.root; | const { $route } = ctx.root; | ||||
const uploaderRef = ref(null); | const uploaderRef = ref(null); | ||||
const listRef = ref(null); | |||||
const { updateList, state, updateState, isTrack } = props; | const { updateList, state, updateState, isTrack } = props; | ||||
const { datasetId } = state; | const { datasetId } = state; | ||||
const thumbState = reactive({ | const thumbState = reactive({ | ||||
@@ -132,11 +136,11 @@ export default { | |||||
const dropdownList = computed(() => { | const dropdownList = computed(() => { | ||||
let filter = []; | let filter = []; | ||||
if (isTrack) { | if (isTrack) { | ||||
// 目标跟踪:全部(0)、未标注-手动标注、手动标注中(1)、手动标注中(1)、自动目标跟踪完成(4)、手动标注完成(3) | |||||
filter = pick(fileTypeEnum, [0, 1, 3, 4]); | |||||
// 目标跟踪:全部 未标注 未识别 手动标注中 手动标注完成 自动标注完成 目标跟踪完成 | |||||
filter = pick(fileTypeEnum, [fileCodeMap.ALL, fileCodeMap.UNANNOTATED, fileCodeMap.UNRECOGNIZED, fileCodeMap.MANUAL_ANNOTATING, fileCodeMap.MANUAL_ANNOTATED, fileCodeMap.AUTO_ANNOTATED, fileCodeMap.TRACK_SUCCEED]); | |||||
} else { | } else { | ||||
// 目标检测:全部(0)、未标注-手动标注、手动标注中(1)、自动标注完成(2)、手动标注完成(3) | |||||
filter = pick(fileTypeEnum, [0, 1, 2, 3]); | |||||
// 目标检测:全部 未标注 未识别 手动标注中 自动标注完成 手动标注完成 | |||||
filter = pick(fileTypeEnum, [fileCodeMap.ALL, fileCodeMap.UNANNOTATED, fileCodeMap.UNRECOGNIZED, fileCodeMap.MANUAL_ANNOTATING, fileCodeMap.AUTO_ANNOTATED, fileCodeMap.MANUAL_ANNOTATED]); | |||||
} | } | ||||
const statusList = Object.keys(filter).map(k => ({ | const statusList = Object.keys(filter).map(k => ({ | ||||
command: k, | command: k, | ||||
@@ -153,6 +157,11 @@ export default { | |||||
updateState({ annotations: [], fileFilterType: command }); | updateState({ annotations: [], fileFilterType: command }); | ||||
// 重新请求文件 | // 重新请求文件 | ||||
updateList({ type: command, offset: 0 }); | updateList({ type: command, offset: 0 }); | ||||
// 获取滚动列表容器 | |||||
const listWrapper = listRef.value.$refs?.listWrapper; | |||||
listWrapper.scrollTo({ | |||||
top: 0, | |||||
}); | |||||
}; | }; | ||||
const handleClose = () => { | const handleClose = () => { | ||||
@@ -223,6 +232,7 @@ export default { | |||||
}); | }); | ||||
return { | return { | ||||
listRef, | |||||
thumbState, | thumbState, | ||||
withDimensionFile, | withDimensionFile, | ||||
uploadParams, | uploadParams, | ||||
@@ -15,7 +15,7 @@ | |||||
*/ | */ | ||||
<template> | <template> | ||||
<div class="infinite-list-wrapper" style="overflow: auto;"> | |||||
<div ref="listWrapper" class="infinite-list-wrapper" style="overflow: auto;"> | |||||
<ul | <ul | ||||
v-infinite-scroll="loadMore" | v-infinite-scroll="loadMore" | ||||
infinite-scroll-distance="100" | infinite-scroll-distance="100" | ||||
@@ -35,7 +35,7 @@ | |||||
</div> | </div> | ||||
</template> | </template> | ||||
<script> | <script> | ||||
import { reactive, watch, computed } from '@vue/composition-api'; | |||||
import { reactive, watch, computed, ref } from '@vue/composition-api'; | |||||
import { limit } from '@/views/dataset/annotate'; | import { limit } from '@/views/dataset/annotate'; | ||||
import ListItem from './listItem'; | import ListItem from './listItem'; | ||||
@@ -50,6 +50,9 @@ export default { | |||||
type: Array, | type: Array, | ||||
default: () => [], | default: () => [], | ||||
}, | }, | ||||
type: { | |||||
type: [String, Number], | |||||
}, | |||||
addList: { | addList: { | ||||
type: Array, | type: Array, | ||||
default: () => [], | default: () => [], | ||||
@@ -70,6 +73,9 @@ export default { | |||||
}, | }, | ||||
setup(props, ctx) { | setup(props, ctx) { | ||||
const { updateState, queryNextPage } = props; | const { updateState, queryNextPage } = props; | ||||
const listWrapper = ref(null); | |||||
const state = reactive({ | const state = reactive({ | ||||
loading: false, | loading: false, | ||||
}); | }); | ||||
@@ -99,6 +105,7 @@ export default { | |||||
}); | }); | ||||
queryNextPage({ | queryNextPage({ | ||||
offset: props.offset, | offset: props.offset, | ||||
type: Number(props.type), | |||||
}).then(() => { | }).then(() => { | ||||
Object.assign(state, { | Object.assign(state, { | ||||
loading: false, | loading: false, | ||||
@@ -111,6 +118,7 @@ export default { | |||||
disabled, | disabled, | ||||
loadMore, | loadMore, | ||||
handleClick, | handleClick, | ||||
listWrapper, | |||||
}; | }; | ||||
}, | }, | ||||
}; | }; | ||||
@@ -15,12 +15,10 @@ | |||||
*/ | */ | ||||
import { isNil } from 'lodash'; | import { isNil } from 'lodash'; | ||||
import { addSuffix } from '@/utils'; | |||||
import { addSuffix, chroma, colorByLuminance } from '@/utils'; | |||||
import { defaultColor } from './bbox'; | import { defaultColor } from './bbox'; | ||||
const chroma = require('chroma-js'); | |||||
const validTrackId = (trackId) => { | const validTrackId = (trackId) => { | ||||
if (isNil(trackId) || trackId === -1) return false; | if (isNil(trackId) || trackId === -1) return false; | ||||
return trackId; | return trackId; | ||||
@@ -31,47 +29,48 @@ export default { | |||||
functional: true, | functional: true, | ||||
props: { | props: { | ||||
annotate: Object, | annotate: Object, | ||||
offset: Function, | |||||
currentAnnotationId: String, | |||||
brush: Object, | |||||
transformer: Object, | |||||
scale: { | scale: { | ||||
type: Number, | type: Number, | ||||
}, | }, | ||||
imgBoundingLeft: Number, | |||||
imgBounding: { | |||||
type: Array, | |||||
}, | |||||
getLabelName: Function, | getLabelName: Function, | ||||
}, | }, | ||||
render(h, context) { | render(h, context) { | ||||
const { props } = context; | const { props } = context; | ||||
const { | const { | ||||
annotate = {}, | annotate = {}, | ||||
imgBoundingLeft, | |||||
offset, | |||||
brush, | |||||
transformer, | |||||
} = props; | } = props; | ||||
const { data = {}, __type } = annotate; | |||||
const { data = {}, id } = annotate; | |||||
const { bbox, color = defaultColor } = data; | const { bbox, color = defaultColor } = data; | ||||
if (isNil(bbox)) return null; | |||||
// 是否为草稿模式 | |||||
const isDraft = __type === 0; | |||||
// todo: top | |||||
const paddingLeft = (props.scale < 1 && !isNil(imgBoundingLeft)) | |||||
? imgBoundingLeft | |||||
: 0; | |||||
const pos = isDraft ? { | |||||
x: bbox.x, | |||||
y: bbox.y, | |||||
width: bbox.width, | |||||
height: bbox.height, | |||||
} : { | |||||
x: bbox.x * props.scale + paddingLeft, | |||||
y: bbox.y * props.scale, | |||||
width: bbox.width * props.scale, | |||||
height: bbox.height * props.scale, | |||||
}; | |||||
// 当前在拖拽中不展示 | |||||
if(props.currentAnnotationId === id && brush.isBrushing) return null; | |||||
if (isNil(bbox)) return null; | |||||
const pos = offset(props.annotate); | |||||
const style = { | const style = { | ||||
width: addSuffix(pos.width), | width: addSuffix(pos.width), | ||||
left: addSuffix(pos.x), | left: addSuffix(pos.x), | ||||
top: addSuffix(pos.y), | top: addSuffix(pos.y), | ||||
color: colorByLuminance(color), | |||||
}; | }; | ||||
// 匹配当前标注 | |||||
if(annotate.id === transformer.id) { | |||||
style.transform = `translate(${transformer.dx}px, ${transformer.dy}px)`; | |||||
} | |||||
const tagColor = chroma(color).alpha(0.8).toString(); | const tagColor = chroma(color).alpha(0.8).toString(); | ||||
const trackId = (() => { | const trackId = (() => { | ||||
@@ -85,7 +84,7 @@ export default { | |||||
if (!trackId) return null; | if (!trackId) return null; | ||||
return ( | return ( | ||||
<div class='annotation-label image-tag' style={style}> | <div class='annotation-label image-tag' style={style}> | ||||
<el-tag color={tagColor} style={{ color: '#fff', border: 'none' }}>{trackId}</el-tag> | |||||
<el-tag color={tagColor} style={{ color: 'inherit', border: 'none' }}>{trackId}</el-tag> | |||||
</div> | </div> | ||||
); | ); | ||||
}, | }, | ||||
@@ -16,8 +16,7 @@ | |||||
import cx from 'classnames'; | import cx from 'classnames'; | ||||
import { isNil } from 'lodash'; | import { isNil } from 'lodash'; | ||||
const chroma = require('chroma-js'); | |||||
import { chroma } from '@/utils'; | |||||
export const defaultColor = 'rgba(102, 181, 245, 1)'; | export const defaultColor = 'rgba(102, 181, 245, 1)'; | ||||
const defaultFill = 'rgba(102, 181, 245, 0.1)'; | const defaultFill = 'rgba(102, 181, 245, 0.1)'; | ||||
@@ -27,68 +26,71 @@ export default { | |||||
functional: true, | functional: true, | ||||
props: { | props: { | ||||
annotate: Object, | annotate: Object, | ||||
brush: Object, | |||||
scale: { | scale: { | ||||
type: Number, | type: Number, | ||||
default: 1, | default: 1, | ||||
}, | }, | ||||
currentAnnotationId: Object, | |||||
imgBoundingLeft: Number, | |||||
handleClick: Function, | |||||
pos: { | |||||
type: Object, | |||||
default: () => ({}), | |||||
}, | |||||
dragStart: Function, | |||||
dragMove: Function, | |||||
dragEnd: Function, | |||||
currentAnnotationId: String, | |||||
transformer: Object, | |||||
imgRef: HTMLImageElement, | imgRef: HTMLImageElement, | ||||
}, | }, | ||||
render(h, context) { | render(h, context) { | ||||
const { props } = context; | const { props } = context; | ||||
const { style } = context.data; | |||||
const { | const { | ||||
annotate = {}, | annotate = {}, | ||||
imgBoundingLeft, | |||||
currentAnnotationId, | currentAnnotationId, | ||||
handleClick, | |||||
dragStart, | |||||
dragMove, | |||||
dragEnd, | |||||
brush, | |||||
transformer, | |||||
...rest // does this work? | ...rest // does this work? | ||||
} = props; | } = props; | ||||
const { data = {}, __type } = annotate; | |||||
const { data = {} } = annotate; | |||||
const { bbox, color } = data; | const { bbox, color } = data; | ||||
if (isNil(bbox)) return null; | if (isNil(bbox)) return null; | ||||
const bgColor = color || defaultFill; | const bgColor = color || defaultFill; | ||||
const isActive = currentAnnotationId.value === annotate.id; | |||||
const isActive = currentAnnotationId === annotate.id; | |||||
const colorAlpha = isActive ? 0.4 : 0.1; | const colorAlpha = isActive ? 0.4 : 0.1; | ||||
const fill = chroma(bgColor).alpha(colorAlpha); | const fill = chroma(bgColor).alpha(colorAlpha); | ||||
// 是否为草稿模式 | |||||
const isDraft = __type === 0; | |||||
const paddingLeft = (props.scale < 1 && !isNil(imgBoundingLeft)) | |||||
? imgBoundingLeft | |||||
: 0; | |||||
const pos = isDraft ? { | |||||
x: bbox.x, | |||||
y: bbox.y, | |||||
width: bbox.width, | |||||
height: bbox.height, | |||||
} : { | |||||
x: bbox.x * props.scale + paddingLeft, | |||||
y: bbox.y * props.scale, | |||||
width: bbox.width * props.scale, | |||||
height: bbox.height * props.scale, | |||||
}; | |||||
let transform = null; | |||||
// 匹配当前标注 | |||||
if(annotate.id === transformer.id) { | |||||
transform = `translate(${transformer.dx}, ${transformer.dy})`; | |||||
} | |||||
return ( | return ( | ||||
<g class={cx('bbox-group', { | <g class={cx('bbox-group', { | ||||
active: isActive, | active: isActive, | ||||
})} onClick={handleClick(annotate)}> | |||||
})}> | |||||
<rect | <rect | ||||
fill={fill} | fill={fill} | ||||
stroke={color || defaultColor} | stroke={color || defaultColor} | ||||
strokeWidth={4} | strokeWidth={4} | ||||
// {...bounding} spread operator sucks... | // {...bounding} spread operator sucks... | ||||
x={pos.x} | |||||
y={pos.y} | |||||
width={pos.width} | |||||
height={pos.height} | |||||
x={props.pos.x} | |||||
y={props.pos.y} | |||||
width={props.pos.width} | |||||
height={props.pos.height} | |||||
transform={transform} | |||||
onMousemove={dragMove} | |||||
onMouseup={dragEnd} | |||||
onMousedown={dragStart} | |||||
style={style} | |||||
{...rest} | {...rest} | ||||
/> | /> | ||||
</g> | </g> | ||||
@@ -0,0 +1,430 @@ | |||||
/** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||||
* | |||||
* Licensed under the Apache License, Version 2.0 (the "License"); | |||||
* you may not use this file except in compliance with the License. | |||||
* You may obtain a copy of the License at | |||||
* | |||||
* http://www.apache.org/licenses/LICENSE-2.0 | |||||
* | |||||
* 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. | |||||
* ============================================================= | |||||
*/ | |||||
import Vue from 'vue'; | |||||
import { isEmpty } from 'lodash'; | |||||
import { createElement, reactive, watch } from '@vue/composition-api'; | |||||
import { mergeProps } from '@/utils'; | |||||
import Drag from '@/components/Drag'; | |||||
import { BrushHandle, BrushCorner } from '@/components/svg'; | |||||
import Bbox from './bbox'; | |||||
export default { | |||||
name: 'BboxWrapper', | |||||
inheritAttrs: false, | |||||
props: { | |||||
annotate: Object, | |||||
brush: { | |||||
type: Object, | |||||
default: () => ({}), | |||||
}, | |||||
onDragStart: Function, | |||||
onDragMove: Function, | |||||
onDragEnd: Function, | |||||
onBrushHandleChange: Function, | |||||
onBrushHandleEnd: Function, | |||||
transformer: Object, | |||||
currentAnnotationId: String, | |||||
setCurAnnotation: Function, | |||||
getZoom: Function, | |||||
handleSize: { | |||||
type: Number, | |||||
default: 6, | |||||
}, | |||||
offset: Function, | |||||
scale: { | |||||
type: Number, | |||||
default: 1, | |||||
}, | |||||
bounds: { | |||||
type: Object, | |||||
}, | |||||
svg: { | |||||
type: Object, | |||||
default: () => ({}), | |||||
}, | |||||
}, | |||||
components: { | |||||
Drag, | |||||
Bbox, | |||||
}, | |||||
setup(props) { | |||||
const { | |||||
offset, | |||||
scale, | |||||
onDragStart, | |||||
onDragMove, | |||||
onDragEnd, | |||||
onBrushHandleChange, | |||||
bounds = {}, | |||||
onBrushHandleEnd, | |||||
setCurAnnotation, | |||||
getZoom, | |||||
} = props; | |||||
function getExtent() { | |||||
const { data = {} } = props.annotate; | |||||
const { extent } = data; | |||||
return { | |||||
extent, | |||||
start: { | |||||
x: extent.x0, | |||||
y: extent.y0, | |||||
}, | |||||
end: { | |||||
x: extent.x1, | |||||
y: extent.y1, | |||||
}, | |||||
}; | |||||
} | |||||
const state = reactive({ | |||||
activeHandle: undefined, | |||||
drag: undefined, | |||||
bounds: { x0: 0, x1: bounds.width, y0: 0, y1: bounds.height }, | |||||
...getExtent(), | |||||
}); | |||||
const updateBrush = (updater, callback) => { | |||||
const newState = updater(state); | |||||
Vue.nextTick(() => { | |||||
Object.assign(state, newState); | |||||
if(typeof callback === 'function') { | |||||
callback(state); | |||||
} | |||||
}); | |||||
}; | |||||
// handler 拖拽事件 | |||||
const updateBrushHandler = (updater) => { | |||||
updateBrush(updater, state => { | |||||
if(typeof onBrushHandleChange === 'function') { | |||||
onBrushHandleChange(state, props.annotate); | |||||
} | |||||
}); | |||||
}; | |||||
// handler 拖拽结束 | |||||
const updateBrushHandlerEnd = (updater) => { | |||||
updateBrush(updater, state => { | |||||
if(typeof onBrushHandleEnd === 'function') { | |||||
onBrushHandleEnd(state, props.annotate); | |||||
} | |||||
}); | |||||
}; | |||||
const handles = () => { | |||||
const { handleSize } = props; | |||||
const {x, y, width, height} = offset(props.annotate); | |||||
const handleOffset = handleSize / 2; | |||||
return { | |||||
top: { | |||||
x: x - handleOffset, | |||||
y: y - handleOffset, | |||||
height: handleSize, | |||||
width: width + handleSize, | |||||
}, | |||||
bottom: { | |||||
x: x - handleOffset, | |||||
y: y + height - handleOffset, | |||||
height: handleSize, | |||||
width: width + handleSize, | |||||
}, | |||||
right: { | |||||
x: x + width - handleOffset, | |||||
y: y - handleOffset, | |||||
height: height + handleSize, | |||||
width: handleSize, | |||||
}, | |||||
left: { | |||||
x: x - handleOffset, | |||||
y: y - handleOffset, | |||||
height: height + handleSize, | |||||
width: handleSize, | |||||
}, | |||||
}; | |||||
}; | |||||
const corners = () => { | |||||
const { handleSize } = props; | |||||
const {x, y, width, height} = offset(props.annotate); | |||||
const handleOffset = handleSize / 2; | |||||
return { | |||||
topLeft: { | |||||
x: x - handleOffset, | |||||
y: y - handleOffset, | |||||
}, | |||||
bottomLeft: { | |||||
x: x - handleOffset, | |||||
y: y + height - handleOffset, | |||||
}, | |||||
topRight: { | |||||
x: x + width - handleOffset, | |||||
y: y - handleOffset, | |||||
}, | |||||
bottomRight: { | |||||
x: x + width - handleOffset, | |||||
y: y + height - handleOffset, | |||||
}, | |||||
}; | |||||
}; | |||||
const brushHandlerStart = () => { | |||||
setCurAnnotation(props.annotate); | |||||
}; | |||||
const selectionDragStart = drag => { | |||||
const start = { | |||||
x: drag.x + drag.dx, | |||||
y: drag.y + drag.dy, | |||||
}; | |||||
const end = { ...start }; | |||||
const transformState = { | |||||
start, | |||||
end, | |||||
}; | |||||
// 回调 | |||||
if (typeof onDragStart === 'function') { | |||||
onDragStart(transformState, props.annotate); | |||||
} | |||||
}; | |||||
const selectionDragMove = (drag) => { | |||||
const { zoom } = getZoom(); | |||||
updateBrush(prevBrush => { | |||||
const { x: x0, y: y0 } = prevBrush.start; | |||||
const { x: x1, y: y1 } = prevBrush.end; | |||||
// 位置比较计算 | |||||
const _scale = zoom * scale; | |||||
const validDx = | |||||
drag.dx > 0 | |||||
? Math.min(drag.dx / _scale, prevBrush.bounds.x1 - x1) | |||||
: Math.max(drag.dx / _scale, prevBrush.bounds.x0 - x0); | |||||
const validDy = | |||||
drag.dy > 0 | |||||
? Math.min(drag.dy / _scale, prevBrush.bounds.y1 - y1) | |||||
: Math.max(drag.dy / _scale, prevBrush.bounds.y0 - y0); | |||||
return { | |||||
...prevBrush, | |||||
isBrushing: true, | |||||
extent: { | |||||
...prevBrush.extent, | |||||
x0: x0 + validDx, | |||||
x1: x1 + validDx, | |||||
y0: y0 + validDy, | |||||
y1: y1 + validDy, | |||||
}, | |||||
drag: { | |||||
...drag, | |||||
validDx, | |||||
validDy, | |||||
}, | |||||
}; | |||||
}, (nextState) => { | |||||
if (typeof onDragMove === 'function') { | |||||
onDragMove(nextState, props.annotate); | |||||
} | |||||
}); | |||||
}; | |||||
const selectionDragEnd = (state, event, options = {}) => { | |||||
const { prevState } = options; | |||||
// fix 双击触发移动选框 | |||||
if(!prevState.isMoving) return; | |||||
updateBrush(prevBrush => { | |||||
const nextBrush = { | |||||
...prevBrush, | |||||
isBrushing: false, | |||||
start: { | |||||
...prevBrush.start, | |||||
x: Math.min(prevBrush.extent.x0, prevBrush.extent.x1), | |||||
y: Math.min(prevBrush.extent.y0, prevBrush.extent.y1), | |||||
}, | |||||
end: { | |||||
...prevBrush.end, | |||||
x: Math.max(prevBrush.extent.x0, prevBrush.extent.x1), | |||||
y: Math.max(prevBrush.extent.y0, prevBrush.extent.y1), | |||||
}, | |||||
}; | |||||
return nextBrush; | |||||
}, (nextState) => { | |||||
// 回调 | |||||
if (typeof onDragEnd === 'function') { | |||||
onDragEnd(nextState, props.annotate); | |||||
} | |||||
}); | |||||
}; | |||||
watch(() => props.bounds, (next) => { | |||||
if(!isEmpty(next)) { | |||||
Object.assign(state, { | |||||
bounds: { x0: 0, x1: bounds.width, y0: 0, y1: bounds.height }, | |||||
}); | |||||
} | |||||
}, { | |||||
lazy: true, | |||||
}); | |||||
return { | |||||
state, | |||||
updateBrush, | |||||
updateBrushHandler, | |||||
updateBrushHandlerEnd, | |||||
brushHandlerStart, | |||||
handles, | |||||
corners, | |||||
getExtent, | |||||
selectionDragStart, | |||||
selectionDragMove, | |||||
selectionDragEnd, | |||||
}; | |||||
}, | |||||
render(h) { | |||||
const { | |||||
annotate = {}, | |||||
scale, | |||||
brush, | |||||
handleSize, | |||||
transformer, | |||||
currentAnnotationId, | |||||
} = this; | |||||
const handles = this.handles(); | |||||
const corners = this.corners(); | |||||
const pos = this.offset(annotate); | |||||
const bboxProps = { | |||||
props: { | |||||
...this.$attrs, | |||||
annotate, | |||||
pos, | |||||
transformer, | |||||
currentAnnotationId, | |||||
}, | |||||
}; | |||||
const dragProps = { | |||||
props: { | |||||
onDragStart: this.selectionDragStart, | |||||
onDragMove: this.selectionDragMove, | |||||
onDragEnd: this.selectionDragEnd, | |||||
resetOnStart: true, | |||||
width: this.svg.width, | |||||
height: this.svg.height, | |||||
}, | |||||
}; | |||||
return ( | |||||
<Drag {...dragProps} key={annotate.id}> | |||||
{ | |||||
(draw) => { | |||||
const style = { | |||||
pointerEvents: brush.isBrushing || this.state.activeHandle ? 'none' : 'all', | |||||
}; | |||||
const _props = mergeProps(bboxProps, { | |||||
props: { ...draw, brush: this.state }, | |||||
style, | |||||
}); | |||||
const Handles = Object.keys(handles).map((handleKey) => { | |||||
const handle = handles[handleKey]; | |||||
return ( | |||||
<BrushHandle | |||||
key={`handle-${handleKey}`} | |||||
type={handleKey} | |||||
handle={handle} | |||||
scale={scale} | |||||
stageWidth={this.svg.width} | |||||
stageHeight={this.svg.height} | |||||
handleBrushStart={this.brushHandlerStart} | |||||
updateBrush={this.updateBrushHandler} | |||||
updateBrushEnd={this.updateBrushHandlerEnd} | |||||
getZoom={this.getZoom} | |||||
/> | |||||
); | |||||
}); | |||||
const Corners = Object.keys(corners).map((cornerKey) => { | |||||
const corner = corners[cornerKey]; | |||||
return ( | |||||
<BrushCorner | |||||
annotate={annotate} | |||||
transformer={transformer} | |||||
currentAnnotationId={currentAnnotationId} | |||||
key={`corner-${cornerKey}`} | |||||
type={cornerKey} | |||||
x={corner.x} | |||||
y={corner.y} | |||||
width={handleSize} | |||||
height={handleSize} | |||||
scale={scale} | |||||
stageWidth={this.svg.width} | |||||
stageHeight={this.svg.height} | |||||
handleBrushStart={this.brushHandlerStart} | |||||
updateBrush={this.updateBrushHandler} | |||||
updateBrushEnd={this.updateBrushHandlerEnd} | |||||
getZoom={this.getZoom} | |||||
/> | |||||
); | |||||
}); | |||||
return ( | |||||
<g> | |||||
{draw.state.isDragging && ( | |||||
<rect | |||||
width={this.svg.width} | |||||
height={this.svg.height} | |||||
fill="transparent" | |||||
onMouseup={draw.dragEnd} | |||||
onMousemove={draw.dragMove} | |||||
onMouseleave={(event) => { | |||||
// hack: 获取画布背景的位置 | |||||
const rect = event.target.getBoundingClientRect(); | |||||
// 超出边界判断 | |||||
if(event.clientX <= rect.x || event.clientX >= rect.right || event.clientY <= rect.y || event.clientY >= rect.bottom) { | |||||
draw.dragEnd(); | |||||
} | |||||
}} | |||||
style={{ | |||||
cursor: 'move', | |||||
}} | |||||
/> | |||||
)} | |||||
{createElement(Bbox, _props)} | |||||
<g | |||||
class='bbox-handles-group' | |||||
>{Handles}</g> | |||||
<g | |||||
class='bbox-corners-group' | |||||
>{Corners}</g> | |||||
</g> | |||||
); | |||||
} | |||||
} | |||||
</Drag> | |||||
); | |||||
}, | |||||
}; |
@@ -0,0 +1,89 @@ | |||||
/** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||||
* | |||||
* Licensed under the Apache License, Version 2.0 (the "License"); | |||||
* you may not use this file except in compliance with the License. | |||||
* You may obtain a copy of the License at | |||||
* | |||||
* http://www.apache.org/licenses/LICENSE-2.0 | |||||
* | |||||
* 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. | |||||
* ============================================================= | |||||
*/ | |||||
import { isNil } from 'lodash'; | |||||
import { toFixed, addSuffix } from '@/utils'; | |||||
export default { | |||||
name: 'BrushTip', | |||||
props: { | |||||
annotate: Object, | |||||
dimension: Object, | |||||
brush: Object, | |||||
}, | |||||
setup(props) { | |||||
const getWidth = () => { | |||||
const { extent } = props.brush; | |||||
if(isNil(extent)) return 0; | |||||
return extent.x1 - extent.x0; | |||||
}; | |||||
const getHeight = () => { | |||||
const { extent } = props.brush; | |||||
if(isNil(extent)) return 0; | |||||
return extent.y1 - extent.y0; | |||||
}; | |||||
const getEndPoint = () => { | |||||
const { extent = {} } = props.brush; | |||||
return {x: extent.x1, y: extent.y1}; | |||||
}; | |||||
return { | |||||
getWidth, | |||||
getHeight, | |||||
getEndPoint, | |||||
}; | |||||
}, | |||||
render(h) { | |||||
const width = this.getWidth(); | |||||
const height = this.getHeight(); | |||||
const endPoint = this.getEndPoint(); | |||||
const { svg } = this.dimension; | |||||
const sizeTipStyle = { | |||||
left: addSuffix(this.brush.extent?.x0), | |||||
top: addSuffix(this.brush.extent?.y0 - 30), | |||||
}; | |||||
const dimensionTipStyle = { | |||||
right: addSuffix(svg.width - this.brush.extent?.x1), | |||||
top: addSuffix(this.brush.extent?.y1 + 6), | |||||
}; | |||||
// 到上边缘 | |||||
if (this.brush.extent?.y0 < 30) { | |||||
sizeTipStyle.top = addSuffix(this.brush.extent?.y0 + 6); | |||||
}; | |||||
return ( | |||||
<div class='usn'> | |||||
<div class='brush-tooltip size-tipper' style={sizeTipStyle}>{ | |||||
width > 0 && height > 0 && ( | |||||
<div class='tooltip-item-row'>{toFixed(width, 0, 0)} * {toFixed(height, 0, 0)}</div> | |||||
) | |||||
}</div> | |||||
<div class='brush-tooltip dimension-tipper' style={dimensionTipStyle}>{ | |||||
endPoint && ( | |||||
<div class='tooltip-item-row'> | |||||
({toFixed(endPoint.x, 0, 0)}, {toFixed(endPoint.y, 0, 0)}) | |||||
</div> | |||||
) | |||||
}</div> | |||||
</div> | |||||
); | |||||
}, | |||||
}; |
@@ -45,39 +45,54 @@ | |||||
<div class="zoom-content"> | <div class="zoom-content"> | ||||
<div class="zoom-content-bound rel" :style="dimension.marginStyle"> | <div class="zoom-content-bound rel" :style="dimension.marginStyle"> | ||||
<div class="imgWrapper" :style="dimension.imgScaleStyle" :class="dimension.scale < 1 ? 'imgScale' : ''"> | <div class="imgWrapper" :style="dimension.imgScaleStyle" :class="dimension.scale < 1 ? 'imgScale' : ''"> | ||||
<img ref="imgRef" :src="currentImg.url"> | |||||
<img ref="imgRef" :src="currentImg.url" class='usn'> | |||||
</div> | </div> | ||||
<!-- svg 宽高要根据图片自适应 --> | <!-- svg 宽高要根据图片自适应 --> | ||||
<div class="annotation-element-group abs" :style="dimension.annotationGroupStyle"> | <div class="annotation-element-group abs" :style="dimension.annotationGroupStyle"> | ||||
<svg | <svg | ||||
ref="svgRef" | ref="svgRef" | ||||
class="canvas" | class="canvas" | ||||
:class="api.active === 'selection' ? 'crosshair' : ''" | |||||
:style="dimension.svg" | :style="dimension.svg" | ||||
@mousedown="handleMouseDown" | |||||
@mousemove="handleMouseMove" | |||||
@mouseup="handleMouseUp" | |||||
> | > | ||||
<Brush | |||||
:stageWidth="dimension.svg.width" | |||||
:stageHeight="dimension.svg.height" | |||||
:onBrushStart="handleBrushStart" | |||||
:onBrushMove="handleBrushMove" | |||||
:onBrushEnd="handleBrushEnd" | |||||
:transformZoom="transformZoom" | |||||
/> | |||||
<g class="annotation-group"> | <g class="annotation-group"> | ||||
<Bbox | |||||
<BboxWrapper | |||||
v-for="annotate in api.annotations" | v-for="annotate in api.annotations" | ||||
:key="annotate.id" | :key="annotate.id" | ||||
:annotate="annotate" | :annotate="annotate" | ||||
:brush="brush" | |||||
:offset="offset" | |||||
:transformer="transformer" | |||||
:svg="dimension.svg" | |||||
:scale="dimension.scale" | :scale="dimension.scale" | ||||
:imgBoundingLeft="api.imgBoundingLeft" | |||||
:handleClick="handleBboxClick" | |||||
:currentAnnotationId="state.currentAnnotationId" | |||||
:bounds="dimension.img" | |||||
:onDragStart="onDragStart" | |||||
:onDragMove="onDragMove" | |||||
:onDragEnd="onDragEnd" | |||||
:onBrushHandleChange="onBrushHandleChange" | |||||
:onBrushHandleEnd="onBrushHandleEnd" | |||||
:currentAnnotationId="state.currentAnnotationId.value" | |||||
:setCurAnnotation="setCurAnnotation" | |||||
:getZoom="getZoom" | |||||
/> | /> | ||||
</g> | </g> | ||||
<BasicBrush :brush="brush" /> | |||||
</svg> | </svg> | ||||
<div v-if="state.showScore.value" class="annotation-score-group"> | <div v-if="state.showScore.value" class="annotation-score-group"> | ||||
<Score | <Score | ||||
v-for="annotate in api.annotations" | v-for="annotate in api.annotations" | ||||
:key="annotate.id" | :key="annotate.id" | ||||
:annotate="annotate" | :annotate="annotate" | ||||
:scale="dimension.scale" | |||||
:imgBoundingLeft="api.imgBoundingLeft" | |||||
:currentAnnotationId="state.currentAnnotationId.value" | |||||
:brush="brush" | |||||
:offset="offset" | |||||
:transformer="transformer" | |||||
/> | /> | ||||
</div> | </div> | ||||
<div v-if="state.showTag.value" class="annotation-tag-group"> | <div v-if="state.showTag.value" class="annotation-tag-group"> | ||||
@@ -85,9 +100,11 @@ | |||||
v-for="annotate in api.annotations" | v-for="annotate in api.annotations" | ||||
:key="annotate.id" | :key="annotate.id" | ||||
:annotate="annotate" | :annotate="annotate" | ||||
:scale="dimension.scale" | |||||
:currentAnnotationId="state.currentAnnotationId.value" | |||||
:brush="brush" | |||||
:offset="offset" | |||||
:transformer="transformer" | |||||
:getLabelName="getLabelName" | :getLabelName="getLabelName" | ||||
:imgBoundingLeft="api.imgBoundingLeft" | |||||
/> | /> | ||||
</div> | </div> | ||||
<div v-if="state.showId.value && isTrack" class="annotation-tag-group"> | <div v-if="state.showId.value && isTrack" class="annotation-tag-group"> | ||||
@@ -95,11 +112,21 @@ | |||||
v-for="annotate in api.annotations" | v-for="annotate in api.annotations" | ||||
:key="annotate.id" | :key="annotate.id" | ||||
:annotate="annotate" | :annotate="annotate" | ||||
:currentAnnotationId="state.currentAnnotationId.value" | |||||
:brush="brush" | |||||
:offset="offset" | |||||
:transformer="transformer" | |||||
:scale="dimension.scale" | :scale="dimension.scale" | ||||
:getLabelName="getLabelName" | :getLabelName="getLabelName" | ||||
:imgBoundingLeft="api.imgBoundingLeft" | |||||
:imgBounding="api.imgBounding" | |||||
/> | /> | ||||
</div> | </div> | ||||
<!-- 新建标注展示尺寸信息 --> | |||||
<BrushTip | |||||
v-if="brush.isBrushing && brush.extent" | |||||
:brush="brush" | |||||
:dimension="dimension" | |||||
/> | |||||
</div> | </div> | ||||
</div> | </div> | ||||
</div> | </div> | ||||
@@ -129,16 +156,18 @@ import { event as d3Event } from 'd3-selection'; | |||||
import { Message } from 'element-ui'; | import { Message } from 'element-ui'; | ||||
import { labelsSymbol } from '@/views/dataset/util'; | import { labelsSymbol } from '@/views/dataset/util'; | ||||
import { useBrush, BasicBrush, useZoom, unref, useTooltip, useImage } from '@/hooks'; | |||||
import { getCursorPosition, getBounding, getZoomPosition, noop } from '@/utils'; | |||||
import { useBrush, useZoom, unref, useTooltip, useImage } from '@/hooks'; | |||||
import { getBounding, raise, noop, replace, extent2Bbox, getZoomPosition } from '@/utils'; | |||||
import { Brush } from '@/components/svg'; | |||||
import ZoomContainer from '@/components/ZoomContainer'; | import ZoomContainer from '@/components/ZoomContainer'; | ||||
import Exception from '@/components/Exception'; | import Exception from '@/components/Exception'; | ||||
import ToolBar from './toolbar'; | import ToolBar from './toolbar'; | ||||
import Bbox from './bbox'; | |||||
import BboxWrapper from './bboxWrapper'; | |||||
import Score from './score'; | import Score from './score'; | ||||
import Tag from './tag'; | import Tag from './tag'; | ||||
import AnnotationId from './annotationId'; | import AnnotationId from './annotationId'; | ||||
import DropDownLabel from './dropdownLabel'; | import DropDownLabel from './dropdownLabel'; | ||||
import BrushTip from './brushTip'; | |||||
const addEventListener = require('add-dom-event-listener'); | const addEventListener = require('add-dom-event-listener'); | ||||
@@ -155,13 +184,14 @@ export default { | |||||
components: { | components: { | ||||
ZoomContainer, | ZoomContainer, | ||||
Exception, | Exception, | ||||
BasicBrush, | |||||
Brush, | |||||
ToolBar, | ToolBar, | ||||
Bbox, | |||||
DropDownLabel, | DropDownLabel, | ||||
Score, | Score, | ||||
Tag, | Tag, | ||||
AnnotationId, | AnnotationId, | ||||
BboxWrapper, | |||||
BrushTip, | |||||
}, | }, | ||||
props: { | props: { | ||||
state: Object, | state: Object, | ||||
@@ -169,7 +199,7 @@ export default { | |||||
type: Object, | type: Object, | ||||
default: () => null, | default: () => null, | ||||
}, | }, | ||||
handleBrushEnd: Function, | |||||
drawBboxEnd: Function, | |||||
createLabel: Function, | createLabel: Function, | ||||
queryLabels: Function, | queryLabels: Function, | ||||
getLabelName: Function, | getLabelName: Function, | ||||
@@ -193,17 +223,28 @@ export default { | |||||
label: {}, // 一个页面当前只能存在一个标签 | label: {}, // 一个页面当前只能存在一个标签 | ||||
bounding: null, // 容器位置信息 | bounding: null, // 容器位置信息 | ||||
isCenter: false, // 图片是否已居中 | isCenter: false, // 图片是否已居中 | ||||
imgBoundingLeft: null, // 图片的位置,给 bbox 位置定位使用 | |||||
imgBounding: null, // 图片的位置,给 bbox 位置定位使用 | |||||
active: '', // 当前选中 | active: '', // 当前选中 | ||||
}); | }); | ||||
// 标注偏移 | |||||
const transformer = reactive({ | |||||
id: undefined, | |||||
dx: 0, | |||||
dy: 0, | |||||
x: undefined, | |||||
y: undefined, | |||||
}); | |||||
const { listeners } = ctx; | const { listeners } = ctx; | ||||
const { handleBrushEnd, state, createLabel, queryLabels, updateState, deleteAnnotation, handleConfirm } = props; | |||||
const { drawBboxEnd, state, createLabel, queryLabels, updateState, deleteAnnotation, handleConfirm } = props; | |||||
const { | const { | ||||
brush, | brush, | ||||
onBrushStart, | onBrushStart, | ||||
onBrushMove, | onBrushMove, | ||||
onBrushEnd, | |||||
updateBrush, | |||||
getExtent, | |||||
// onBrushEnd, | |||||
onBrushReset, | onBrushReset, | ||||
} = useBrush(); | } = useBrush(); | ||||
@@ -214,7 +255,7 @@ export default { | |||||
}; | }; | ||||
// 初始放大和缩小函数 | // 初始放大和缩小函数 | ||||
const { zoomIn, zoomOut, setZoom, reset: resetZoom, zoom } = useZoom(initialZoom, imgWrapperRef); | |||||
const { zoomIn, zoomOut, setZoom, reset: resetZoom, zoom, getZoom } = useZoom(initialZoom, imgWrapperRef); | |||||
// tooltip | // tooltip | ||||
const { tooltipData, showTooltip, hideTooltip } = useTooltip(imgWrapperRef); | const { tooltipData, showTooltip, hideTooltip } = useTooltip(imgWrapperRef); | ||||
@@ -233,6 +274,16 @@ export default { | |||||
Object.assign(api, params); | Object.assign(api, params); | ||||
}; | }; | ||||
// 更新标注偏移 | |||||
const setTransformer = params => { | |||||
Object.assign(transformer, params); | |||||
}; | |||||
// 转换 zoom 位置 | |||||
const transformZoom = (point) => { | |||||
return getZoomPosition(ctx.refs.zoomRef.wrapperRef, point); | |||||
}; | |||||
// 监听 currentImage 变化 | // 监听 currentImage 变化 | ||||
watch(() => props.currentImg, (nextImg) => { | watch(() => props.currentImg, (nextImg) => { | ||||
// 每次切换图片重置 zoom | // 每次切换图片重置 zoom | ||||
@@ -241,7 +292,7 @@ export default { | |||||
Object.assign(api, { | Object.assign(api, { | ||||
label: {}, | label: {}, | ||||
isCenter: false, | isCenter: false, | ||||
imgBoundingLeft: null, | |||||
imgBounding: null, | |||||
}); | }); | ||||
if (nextImg?.url) { | if (nextImg?.url) { | ||||
setImg(nextImg.url); | setImg(nextImg.url); | ||||
@@ -286,13 +337,15 @@ export default { | |||||
// 如果图片有缩放,直接取容器尺寸即可 | // 如果图片有缩放,直接取容器尺寸即可 | ||||
const svgDimension = { | const svgDimension = { | ||||
width: imgScale < 1 ? cw : Math.min(iw, cw), | width: imgScale < 1 ? cw : Math.min(iw, cw), | ||||
height: imgScale < 1 ? ch : Math.min(ih, ch), | |||||
height: imgScale < 1 ? ch - FooterHeight : Math.min(ih, ch), | |||||
}; | }; | ||||
// 标注相关元素的容器 | // 标注相关元素的容器 | ||||
const annotationGroupStyle = { | const annotationGroupStyle = { | ||||
left: imgScale === 1 ? `${(cw - iw) / 2}px` : 0, | left: imgScale === 1 ? `${(cw - iw) / 2}px` : 0, | ||||
top: imgScale === 1 ? `${(ch - FooterHeight - ih) / 2}px` : 0, | top: imgScale === 1 ? `${(ch - FooterHeight - ih) / 2}px` : 0, | ||||
width: imgScale === 1 ? `${iw}px` : `${cw}px`, | |||||
height: imgScale === 1 ? `${ih}px` : `${ch-FooterHeight}px`, | |||||
}; | }; | ||||
// 上面已经通过margin: 0 auto 做过宽度处理 | // 上面已经通过margin: 0 auto 做过宽度处理 | ||||
@@ -368,7 +421,7 @@ export default { | |||||
callback(); | callback(); | ||||
} else if (!msgInstance) { | } else if (!msgInstance) { | ||||
msgInstance = Message.warning({ | msgInstance = Message.warning({ | ||||
message: '当前图片不存在或图片已经到顶了', | |||||
message: '当前图片不存在或图片已经到底了', | |||||
onClose: onMessageClose, | onClose: onMessageClose, | ||||
}); | }); | ||||
} | } | ||||
@@ -393,13 +446,13 @@ export default { | |||||
watch(() => api.isCenter, (isCenter) => { | watch(() => api.isCenter, (isCenter) => { | ||||
if (isCenter) { | if (isCenter) { | ||||
const { width: boundingWidth } = api.bounding; | |||||
const { width: imgWidth } = getBounding(imgRef.value); | |||||
const { width: boundingWidth, height: boundingHeight } = api.bounding; | |||||
const { width: imgWidth, height: imgHeight } = getBounding(imgRef.value); | |||||
// todo: 缩放图片取容器尺寸,否则取图片尺寸 | // todo: 缩放图片取容器尺寸,否则取图片尺寸 | ||||
const mw = dimension.value.scale < 1 ? boundingWidth : dimension.value.img.width; | const mw = dimension.value.scale < 1 ? boundingWidth : dimension.value.img.width; | ||||
const mh = dimension.value.scale < 1 ? boundingHeight - FooterHeight : dimension.value.img.height; | |||||
Object.assign(api, { | Object.assign(api, { | ||||
imgBoundingLeft: (mw - imgWidth) / 2, | |||||
imgBounding: [(mw - imgWidth) / 2, (mh - imgHeight) / 2], | |||||
}); | }); | ||||
} | } | ||||
}, { | }, { | ||||
@@ -419,41 +472,38 @@ export default { | |||||
n: selection, | n: selection, | ||||
})); | })); | ||||
const handleMouseDown = (event) => { | |||||
if (brush.start && brush.end) { | |||||
// 首先清理已有的 brush 状态 | |||||
onBrushReset(); | |||||
} | |||||
// 选中标注 | |||||
const setCurAnnotation = (annotation = {}) => { | |||||
updateState({ | |||||
currentAnnotationId: annotation.id || '', | |||||
}); | |||||
}; | |||||
// 开始绘制 | |||||
const handleBrushStart = (start) => { | |||||
// 关闭已有的 dropdown | // 关闭已有的 dropdown | ||||
hideTooltip(); | hideTooltip(); | ||||
// 判断是否开启选框 | // 判断是否开启选框 | ||||
if (!state.selection.value) return; | |||||
const [x, y] = getCursorPosition(svgRef.value, event); | |||||
// 根据绝对路径生成相对于 zoom 之后的位置 | |||||
const zoomePos = getZoomPosition(ctx.refs.zoomRef.wrapperRef, [x, y]); | |||||
onBrushStart({ x: zoomePos[0], y: zoomePos[1] }); | |||||
// if (!state.selection.value) return; | |||||
const {x, y} = start; | |||||
onBrushStart({ x, y }); | |||||
// 重置当前选中的标注 | |||||
setCurAnnotation(undefined); | |||||
}; | }; | ||||
const handleMouseMove = (event) => { | |||||
if (!brush.isBrushing) return; | |||||
const [x, y] = getCursorPosition(svgRef.value, event); | |||||
// 根据绝对路径生成相对于 zoom 之后的位置 | |||||
const zoomePos = getZoomPosition(ctx.refs.zoomRef.wrapperRef, [x, y]); | |||||
onBrushMove({ x: zoomePos[0], y: zoomePos[1] }); | |||||
const handleBrushMove = (state) => { | |||||
const {x, y} = state.end || {}; | |||||
onBrushMove({ x, y }); | |||||
}; | }; | ||||
const handleMouseUp = (event) => { | |||||
if (brush.end) { | |||||
const [x, y] = getCursorPosition(svgRef.value, event); | |||||
// 根据绝对路径生成相对于 zoom 之后的位置 | |||||
const zoomePos = getZoomPosition(ctx.refs.zoomRef.wrapperRef, [x, y]); | |||||
onBrushEnd(({ x: zoomePos[0], y: zoomePos[1] })); | |||||
const handleBrushEnd = (state, event, options = {}) => { | |||||
const { prevState = {} } = options; | |||||
// 确认是move 之后触发 | |||||
if(state.end && !!prevState.isDragging) { | |||||
// 展示tooltip | // 展示tooltip | ||||
showTooltip({}, event); | showTooltip({}, event); | ||||
// 回调 | // 回调 | ||||
handleBrushEnd && handleBrushEnd(brush, event); | |||||
drawBboxEnd && drawBboxEnd(state, event); | |||||
onBrushReset(); | onBrushReset(); | ||||
return; | return; | ||||
} | } | ||||
@@ -468,10 +518,85 @@ export default { | |||||
return !!(labels.value || []).find(label => label.id === Number(value)); | return !!(labels.value || []).find(label => label.id === Number(value)); | ||||
}; | }; | ||||
// 选中注释 | |||||
const handleBboxClick = (annotation) => () => { | |||||
updateState({ | |||||
currentAnnotationId: annotation.id, | |||||
// 标注偏移 | |||||
const offset = (annotate) => { | |||||
const { data = {} } = annotate; | |||||
const { extent } = data; | |||||
const _bbox = extent2Bbox(extent); | |||||
const paddingLeft = (dimension.value.scale < 1 && !isNil(api.imgBounding)) | |||||
? api.imgBounding[0] | |||||
: 0; | |||||
const paddingTop = (dimension.value.scale < 1 && !isNil(api.imgBounding)) | |||||
? api.imgBounding[1] | |||||
: 0; | |||||
const pos = { | |||||
x: _bbox.x * dimension.value.scale + paddingLeft, | |||||
y: _bbox.y * dimension.value.scale + paddingTop, | |||||
width: _bbox.width * dimension.value.scale, | |||||
height: _bbox.height * dimension.value.scale, | |||||
}; | |||||
return pos; | |||||
}; | |||||
// handle 变更 | |||||
const onBrushHandleChange = (brush, annotation) => { | |||||
// 同步 brush | |||||
const pos = offset(annotation); | |||||
updateState(prev => { | |||||
const index = prev.annotations.findIndex(d => d.id === annotation.id); | |||||
if (index > -1) { | |||||
const selectedItem = prev.annotations[index]; | |||||
const _nextItem = { | |||||
...selectedItem, | |||||
data: { | |||||
...selectedItem.data, | |||||
extent: brush.extent, | |||||
}, | |||||
}; | |||||
const nextAnnotations = replace(prev.annotations, index, _nextItem); | |||||
return { | |||||
...prev, | |||||
annotations: nextAnnotations, | |||||
}; | |||||
} | |||||
}); | |||||
// 更新brush | |||||
updateBrush(prevBrush => { | |||||
return { | |||||
...prevBrush, | |||||
isBrushing: true, | |||||
extent: { | |||||
x0: pos.x, | |||||
x1: pos.x + pos.width, | |||||
y0: pos.y, | |||||
y1: pos.y + pos.height, | |||||
}, | |||||
}; | |||||
}); | |||||
}; | |||||
// handle 拖拽完成 | |||||
const onBrushHandleEnd = (brush, annotation) => { | |||||
// 同步 brush | |||||
const pos = offset(annotation); | |||||
// 更新brush | |||||
updateBrush(prevBrush => { | |||||
return { | |||||
...prevBrush, | |||||
isBrushing: false, | |||||
extent: { | |||||
x0: pos.x, | |||||
x1: pos.x + pos.width, | |||||
y0: pos.y, | |||||
y1: pos.y + pos.height, | |||||
}, | |||||
}; | |||||
}); | }); | ||||
}; | }; | ||||
@@ -507,6 +632,8 @@ export default { | |||||
const curAnnotation = annotations.value.find(d => d.id === currentAnnotationId.value) || {}; | const curAnnotation = annotations.value.find(d => d.id === currentAnnotationId.value) || {}; | ||||
// 触发标注对应标签变更事件 | // 触发标注对应标签变更事件 | ||||
ctx.emit('selectLabel', { selectedLabel, curAnnotation }); | ctx.emit('selectLabel', { selectedLabel, curAnnotation }); | ||||
// 选择标签完成关闭选择器 | |||||
hideTooltip(); | |||||
}; | }; | ||||
const handleZoom = (nextZoomTransform) => { | const handleZoom = (nextZoomTransform) => { | ||||
@@ -518,6 +645,139 @@ export default { | |||||
}); | }); | ||||
}; | }; | ||||
// 每次拖拽的优先级提升 | |||||
const onDragStart = (draw, annotation) => { | |||||
const index = api.annotations.findIndex(d => d.id === annotation.id); | |||||
if (index > -1) { | |||||
const raised = raise(api.annotations, index); | |||||
Object.assign(api, { | |||||
annotations: raised, | |||||
}); | |||||
} | |||||
// 同步当前标注 | |||||
setCurAnnotation(annotation); | |||||
// 同步 brush | |||||
const pos = offset(annotation); | |||||
updateBrush(prevBrush => { | |||||
const start = { | |||||
x: pos.x, | |||||
y: pos.y, | |||||
}; | |||||
const end = { | |||||
x: pos.x + pos.width, | |||||
y: pos.y + pos.height, | |||||
}; | |||||
return { | |||||
...prevBrush, | |||||
start, | |||||
end, | |||||
extent: getExtent(start, end), | |||||
}; | |||||
}); | |||||
}; | |||||
// 拖拽 boxing 更新位置 | |||||
const onDragMove = (draw, annotation) => { | |||||
const pos = offset(annotation); | |||||
const { drag = {} } = draw; | |||||
const { zoom } = getZoom(); | |||||
const validDx = | |||||
drag.dx > 0 | |||||
? Math.min(drag.dx / zoom, dimension.value.svg.width - pos.x - pos.width) | |||||
: Math.max(drag.dx / zoom, -pos.x); | |||||
const validDy = | |||||
drag.dy > 0 | |||||
? Math.min(drag.dy / zoom, dimension.value.svg.height - pos.y - pos.height) | |||||
: Math.max(drag.dy / zoom, -pos.y); | |||||
// 更新 brush 位置 | |||||
updateBrush(prevBrush => { | |||||
const { x: x0, y: y0 } = prevBrush.start; | |||||
const { x: x1, y: y1 } = prevBrush.end; | |||||
return { | |||||
...prevBrush, | |||||
isBrushing: true, | |||||
extent: { | |||||
...prevBrush.extent, | |||||
x0: x0 + validDx, | |||||
x1: x1 + validDx, | |||||
y0: y0 + validDy, | |||||
y1: y1 + validDy, | |||||
}, | |||||
}; | |||||
}); | |||||
setTransformer({ | |||||
isDragging: true, | |||||
id: annotation.id, | |||||
x: drag.x, | |||||
y: drag.y, | |||||
dx: validDx, | |||||
dy: validDy, | |||||
}); | |||||
}; | |||||
// 拖拽 boxing 结束,更新位置 | |||||
const onDragEnd = (draw, annotation) => { | |||||
const { drag = {} } = draw; | |||||
// 重置标注 transform | |||||
setTransformer({ | |||||
isDragging: false, | |||||
id: annotation.id, | |||||
x: drag.x, | |||||
y: drag.y, | |||||
dx: 0, | |||||
dy: 0, | |||||
}); | |||||
updateState(prev => { | |||||
const index = prev.annotations.findIndex(d => d.id === annotation.id); | |||||
if (index > -1) { | |||||
const selectedItem = prev.annotations[index]; | |||||
const _nextItem = { | |||||
...selectedItem, | |||||
data: { | |||||
...selectedItem.data, | |||||
extent: { | |||||
// todo: 如果到达边界就不需要zoom | |||||
x0: selectedItem.data.extent.x0 + (drag.validDx || 0), | |||||
y0: selectedItem.data.extent.y0 + (drag.validDy || 0), | |||||
x1: selectedItem.data.extent.x1 + (drag.validDx || 0), | |||||
y1: selectedItem.data.extent.y1 + (drag.validDy || 0), | |||||
}, | |||||
}, | |||||
}; | |||||
const nextAnnotations = replace(prev.annotations, index, _nextItem); | |||||
return { | |||||
...prev, | |||||
annotations: nextAnnotations, | |||||
}; | |||||
} | |||||
}); | |||||
// 更新 brush 位置 | |||||
updateBrush(prevBrush => { | |||||
return { | |||||
...prevBrush, | |||||
isBrushing: false, | |||||
start: { | |||||
...prevBrush.start, | |||||
x: Math.min(prevBrush.extent.x0, prevBrush.extent.x1), | |||||
y: Math.min(prevBrush.extent.y0, prevBrush.extent.y1), | |||||
}, | |||||
end: { | |||||
...prevBrush.end, | |||||
x: Math.max(prevBrush.extent.x0, prevBrush.extent.x1), | |||||
y: Math.max(prevBrush.extent.y0, prevBrush.extent.y1), | |||||
}, | |||||
}; | |||||
}); | |||||
}; | |||||
onMounted(() => { | onMounted(() => { | ||||
addEventListener(document.body, 'click', (e) => { | addEventListener(document.body, 'click', (e) => { | ||||
// 如果不在画布内,直接清空 | // 如果不在画布内,直接清空 | ||||
@@ -547,11 +807,7 @@ export default { | |||||
imgWrapperRef, | imgWrapperRef, | ||||
// labels | // labels | ||||
labels, | labels, | ||||
// brush | |||||
brush, | brush, | ||||
handleMouseDown, | |||||
handleMouseMove, | |||||
handleMouseUp, | |||||
clearSelection, | clearSelection, | ||||
filter, | filter, | ||||
// zoom | // zoom | ||||
@@ -574,13 +830,31 @@ export default { | |||||
// event | // event | ||||
handleSelectChange, | handleSelectChange, | ||||
confirm, | confirm, | ||||
handleBboxClick, | |||||
onDragStart, | |||||
onDragMove, | |||||
onDragEnd, | |||||
keymap, | keymap, | ||||
// brush 事件 | |||||
handleBrushStart, | |||||
handleBrushMove, | |||||
handleBrushEnd, | |||||
// 标注偏移 | |||||
offset, | |||||
transformer, | |||||
setTransformer, | |||||
onBrushHandleChange, | |||||
onBrushHandleEnd, | |||||
// 缩放情况下将绝对位置转换为相对路径 | |||||
transformZoom, | |||||
getZoom, | |||||
setCurAnnotation, | |||||
}; | }; | ||||
}, | }, | ||||
}; | }; | ||||
</script> | </script> | ||||
<style lang='scss'> | <style lang='scss'> | ||||
@import "~@/assets/styles/variables.scss"; | |||||
#stage { | #stage { | ||||
max-height: 100%; | max-height: 100%; | ||||
} | } | ||||
@@ -597,6 +871,7 @@ export default { | |||||
display: inline-block; | display: inline-block; | ||||
width: 100%; | width: 100%; | ||||
height: 100%; | height: 100%; | ||||
user-select: none; | |||||
} | } | ||||
} | } | ||||
} | } | ||||
@@ -610,6 +885,8 @@ export default { | |||||
} | } | ||||
.annotation-score-group { | .annotation-score-group { | ||||
pointer-events: none; | |||||
.annotation-score-row { | .annotation-score-row { | ||||
position: absolute; | position: absolute; | ||||
color: #fff; | color: #fff; | ||||
@@ -630,16 +907,33 @@ export default { | |||||
} | } | ||||
.annotation-tag-group { | .annotation-tag-group { | ||||
pointer-events: none; | |||||
.annotation-label { | .annotation-label { | ||||
position: absolute; | position: absolute; | ||||
color: #fff; | color: #fff; | ||||
pointer-events: none; | |||||
} | } | ||||
} | } | ||||
.bbox-group { | .bbox-group { | ||||
cursor: pointer; | cursor: pointer; | ||||
} | } | ||||
.brush-tooltip { | |||||
position: absolute; | |||||
padding: 7px 12px; | |||||
font-size: 12px; | |||||
line-height: 1em; | |||||
color: #fff; | |||||
pointer-events: none; | |||||
background-color: $dark; | |||||
border-radius: 4px; | |||||
.tooltip-item-row { | |||||
display: flex; | |||||
white-space: nowrap; | |||||
} | |||||
} | |||||
} | } | ||||
</style> | </style> |
@@ -15,12 +15,10 @@ | |||||
*/ | */ | ||||
import { isNil } from 'lodash'; | import { isNil } from 'lodash'; | ||||
import { addSuffix } from '@/utils'; | |||||
import { addSuffix, colorByLuminance, chroma } from '@/utils'; | |||||
import { defaultColor } from './bbox'; | import { defaultColor } from './bbox'; | ||||
const chroma = require('chroma-js'); | |||||
// 分数最小宽度 | // 分数最小宽度 | ||||
const MinWidth = 48; | const MinWidth = 48; | ||||
@@ -29,39 +27,28 @@ export default { | |||||
functional: true, | functional: true, | ||||
props: { | props: { | ||||
annotate: Object, | annotate: Object, | ||||
scale: { | |||||
type: Number, | |||||
}, | |||||
imgBoundingLeft: Number, | |||||
offset: Function, | |||||
transformer: Object, | |||||
brush: Object, | |||||
currentAnnotationId: String, | |||||
}, | }, | ||||
render(h, context) { | render(h, context) { | ||||
const { props } = context; | const { props } = context; | ||||
const { | const { | ||||
annotate = {}, | annotate = {}, | ||||
imgBoundingLeft, | |||||
offset, | |||||
transformer, | |||||
brush, | |||||
} = props; | } = props; | ||||
const { data = {}, __type } = annotate; | |||||
const { data = {}, id } = annotate; | |||||
const { bbox, color = defaultColor, score = 1 } = data; | const { bbox, color = defaultColor, score = 1 } = data; | ||||
if (isNil(bbox)) return null; | |||||
// 是否为草稿模式 | |||||
const isDraft = __type === 0; | |||||
const paddingLeft = (props.scale < 1 && !isNil(imgBoundingLeft)) | |||||
? imgBoundingLeft | |||||
: 0; | |||||
// 当前在拖拽中不展示 | |||||
if(props.currentAnnotationId === id && brush.isBrushing) return null; | |||||
const pos = isDraft ? { | |||||
x: bbox.x, | |||||
y: bbox.y, | |||||
width: bbox.width, | |||||
height: bbox.height, | |||||
} : { | |||||
x: bbox.x * props.scale + paddingLeft, | |||||
y: bbox.y * props.scale, | |||||
width: bbox.width * props.scale, | |||||
height: bbox.height * props.scale, | |||||
}; | |||||
if (isNil(bbox)) return null; | |||||
const pos = offset(props.annotate); | |||||
const style = { | const style = { | ||||
width: addSuffix(pos.width), | width: addSuffix(pos.width), | ||||
@@ -70,8 +57,14 @@ export default { | |||||
minWidth: addSuffix(MinWidth), | minWidth: addSuffix(MinWidth), | ||||
}; | }; | ||||
// 匹配当前标注 | |||||
if(annotate.id === transformer.id) { | |||||
style.transform = `translate(${transformer.dx}px, ${transformer.dy}px)`; | |||||
} | |||||
const boxStyle = { | const boxStyle = { | ||||
backgroundColor: chroma(color).alpha(0.8), | backgroundColor: chroma(color).alpha(0.8), | ||||
color: colorByLuminance(color), | |||||
}; | }; | ||||
return ( | return ( | ||||
@@ -15,66 +15,61 @@ | |||||
*/ | */ | ||||
import { isNil } from 'lodash'; | import { isNil } from 'lodash'; | ||||
import { addSuffix } from '@/utils'; | |||||
import { addSuffix, chroma, colorByLuminance } from '@/utils'; | |||||
import { defaultColor } from './bbox'; | import { defaultColor } from './bbox'; | ||||
const chroma = require('chroma-js'); | |||||
export default { | export default { | ||||
name: 'Tag', | name: 'Tag', | ||||
functional: true, | functional: true, | ||||
props: { | props: { | ||||
annotate: Object, | annotate: Object, | ||||
scale: { | |||||
type: Number, | |||||
}, | |||||
imgBoundingLeft: Number, | |||||
offset: Function, | |||||
transformer: Object, | |||||
getLabelName: Function, | getLabelName: Function, | ||||
brush: Object, | |||||
currentAnnotationId: String, | |||||
}, | }, | ||||
render(h, context) { | render(h, context) { | ||||
const { props } = context; | const { props } = context; | ||||
const { | const { | ||||
annotate = {}, | annotate = {}, | ||||
imgBoundingLeft, | |||||
getLabelName, | getLabelName, | ||||
offset, | |||||
transformer, | |||||
brush, | |||||
} = props; | } = props; | ||||
const { data = {}, __type } = annotate; | |||||
const { data = {}, id } = annotate; | |||||
const { bbox, color = defaultColor } = data; | const { bbox, color = defaultColor } = data; | ||||
// 当前在拖拽中不展示 | |||||
if(props.currentAnnotationId === id && brush.isBrushing) return null; | |||||
if (isNil(bbox)) return null; | if (isNil(bbox)) return null; | ||||
// 是否为草稿模式 | // 是否为草稿模式 | ||||
const isDraft = __type === 0; | |||||
const paddingLeft = (props.scale < 1 && !isNil(imgBoundingLeft)) | |||||
? imgBoundingLeft | |||||
: 0; | |||||
const pos = isDraft ? { | |||||
x: bbox.x, | |||||
y: bbox.y, | |||||
width: bbox.width, | |||||
height: bbox.height, | |||||
} : { | |||||
x: bbox.x * props.scale + paddingLeft, | |||||
y: bbox.y * props.scale, | |||||
width: bbox.width * props.scale, | |||||
height: bbox.height * props.scale, | |||||
}; | |||||
const pos = offset(props.annotate); | |||||
const style = { | const style = { | ||||
width: addSuffix(pos.width), | width: addSuffix(pos.width), | ||||
left: addSuffix(pos.x), | left: addSuffix(pos.x), | ||||
top: addSuffix(pos.y), | top: addSuffix(pos.y), | ||||
color: colorByLuminance(color), | |||||
}; | }; | ||||
// 匹配当前标注 | |||||
if(annotate.id === transformer.id) { | |||||
style.transform = `translate(${transformer.dx}px, ${transformer.dy}px)`; | |||||
} | |||||
const tagColor = chroma(color).alpha(0.8).toString(); | const tagColor = chroma(color).alpha(0.8).toString(); | ||||
const tagName = getLabelName(data.categoryId); | const tagName = getLabelName(data.categoryId); | ||||
if (!tagName) return null; | if (!tagName) return null; | ||||
return ( | return ( | ||||
<div class='annotation-label image-tag' style={style}> | <div class='annotation-label image-tag' style={style}> | ||||
<el-tag color={tagColor} style={{ color: '#fff', border: 'none' }}>{tagName}</el-tag> | |||||
<el-tag color={tagColor} disable-transitions style={{ color: 'inherit', border: 'none' }}>{tagName}</el-tag> | |||||
</div> | </div> | ||||
); | ); | ||||
}, | }, | ||||
@@ -29,12 +29,12 @@ | |||||
<div class="classify-container flex"> | <div class="classify-container flex"> | ||||
<!--文件列表展示--> | <!--文件列表展示--> | ||||
<div class="file-list-container"> | <div class="file-list-container"> | ||||
<div class="app-container"> | |||||
<div v-loading="crud.loading" class="app-container"> | |||||
<!--tabs页和工具栏--> | <!--tabs页和工具栏--> | ||||
<div class="classify-tab"> | <div class="classify-tab"> | ||||
<el-tabs :value="lastTabName" @tab-click="handleTabClick"> | <el-tabs :value="lastTabName" @tab-click="handleTabClick"> | ||||
<el-tab-pane label="未标注" name="unannotate" /> | |||||
<el-tab-pane label="已标注" name="annotate" /> | |||||
<el-tab-pane label="未完成" name="unannotate" /> | |||||
<el-tab-pane label="已完成" name="annotate" /> | |||||
</el-tabs> | </el-tabs> | ||||
<div class="classify-button flex flex-between flex-vertical-align"> | <div class="classify-button flex flex-between flex-vertical-align"> | ||||
<div class="row-left"> | <div class="row-left"> | ||||
@@ -111,7 +111,16 @@ | |||||
<!--Label列表展示--> | <!--Label列表展示--> | ||||
<div class="label-list-container"> | <div class="label-list-container"> | ||||
<div class="fixed-label-list"> | <div class="fixed-label-list"> | ||||
<div v-if="showCreateLabel" class="mb-22"> | |||||
<div v-if="datasetInfo.labelGroupId" class='mb-10'> | |||||
<label class="el-form-item__label no-float tl">标签组</label> | |||||
<div class="f14"> | |||||
<span class="vm">{{ datasetInfo.labelGroupName }} </span> | |||||
<el-link target="_blank" type="primary" :underline="false" class="vm" :href="`/data/labelgroup/detail?id=${datasetInfo.labelGroupId}`"> | |||||
查看详情 | |||||
</el-link> | |||||
</div> | |||||
</div> | |||||
<div v-if="datasetInfo.labelGroupType !== 1" class="mb-22"> | |||||
<LabelTip /> | <LabelTip /> | ||||
<div class="flex flex-between"> | <div class="flex flex-between"> | ||||
<InfoSelect | <InfoSelect | ||||
@@ -136,7 +145,15 @@ | |||||
<div style="max-height: 200px; padding: 0 2.5px; overflow: auto;"> | <div style="max-height: 200px; padding: 0 2.5px; overflow: auto;"> | ||||
<el-row :gutter="5" style="clear: both;"> | <el-row :gutter="5" style="clear: both;"> | ||||
<el-col v-for="data in labelData" :key="data.id" :span="8"> | <el-col v-for="data in labelData" :key="data.id" :span="8"> | ||||
<el-tag class="tag-item" :title="data.name" :color="data.color" :style="getStyle(data)" @click="chooseLabel(data)">{{ data.name }}</el-tag> | |||||
<el-tag class="tag-item" :title="data.name" :color="data.color" :style="getStyle(data)" @click="event => chooseLabel(data, event)"> | |||||
<span :title="data.name">{{ data.name }}</span> | |||||
<EditLabel | |||||
v-if="!data.labelGroupId" | |||||
:getStyle="getStyle" | |||||
:item="data" | |||||
@handleOk="handleEditLabel" | |||||
/> | |||||
</el-tag> | |||||
</el-col> | </el-col> | ||||
</el-row> | </el-row> | ||||
</div> | </div> | ||||
@@ -163,11 +180,13 @@ | |||||
<script> | <script> | ||||
import { without, isNil } from 'lodash'; | import { without, isNil } from 'lodash'; | ||||
import { Message } from 'element-ui'; | import { Message } from 'element-ui'; | ||||
import { queryDataEnhanceList } from '@/api/preparation/dataset'; | |||||
import { transformFile, transformFiles, getImgFromMinIO, dataEnhanceMap, withDimensionFile } from '@/views/dataset/util'; | |||||
import { colorByLuminance } from '@/utils'; | |||||
import { queryDataEnhanceList, detail } from '@/api/preparation/dataset'; | |||||
import { transformFile, transformFiles, getImgFromMinIO, dataEnhanceMap, withDimensionFile, fileCodeMap } from '@/views/dataset/util'; | |||||
import crudDataFile, { list, del , submit } from '@/api/preparation/datafile'; | import crudDataFile, { list, del , submit } from '@/api/preparation/datafile'; | ||||
import { getAutoLabels, getLabels, createLabel } from '@/api/preparation/datalabel'; | |||||
import { getAutoLabels, getLabels, createLabel, editLabel } from '@/api/preparation/datalabel'; | |||||
import { batchFinishAnnotation } from '@/api/preparation/annotation'; | import { batchFinishAnnotation } from '@/api/preparation/annotation'; | ||||
import CRUD, { presenter, header, crud } from '@crud/crud'; | import CRUD, { presenter, header, crud } from '@crud/crud'; | ||||
import ImageGallery from '@/components/ImageGallery'; | import ImageGallery from '@/components/ImageGallery'; | ||||
@@ -178,18 +197,18 @@ import SortingMenu from '@/components/SortingMenu'; | |||||
import SearchLabel from './components/searchLabel'; | import SearchLabel from './components/searchLabel'; | ||||
import LabelTip from './annotate/settingContainer/labelTip'; | import LabelTip from './annotate/settingContainer/labelTip'; | ||||
import PicInfoModal from './components/picInfoModal'; | import PicInfoModal from './components/picInfoModal'; | ||||
import EditLabel from './annotate/settingContainer/labelList/edit'; | |||||
const chroma = require('chroma-js'); | |||||
// eslint-disable-next-line import/no-extraneous-dependencies | // eslint-disable-next-line import/no-extraneous-dependencies | ||||
const path = require('path'); | const path = require('path'); | ||||
export default { | export default { | ||||
name: 'Classify', | name: 'Classify', | ||||
components: { ImageGallery, UploadForm, InfoCard, InfoSelect, SearchLabel, LabelTip, SortingMenu, PicInfoModal }, | |||||
components: { ImageGallery, UploadForm, InfoCard, InfoSelect, SearchLabel, LabelTip, SortingMenu, PicInfoModal, EditLabel }, | |||||
cruds() { | cruds() { | ||||
const id = this.parent.$route.params.datasetId; | const id = this.parent.$route.params.datasetId; | ||||
const crudObj = CRUD({ title: '数据分类', crudMethod: { ...crudDataFile }}); | const crudObj = CRUD({ title: '数据分类', crudMethod: { ...crudDataFile }}); | ||||
crudObj.params = { 'datasetId': id, 'status': [0] }; | |||||
crudObj.params = { 'datasetId': id, 'status': fileCodeMap.UNCOMPLETED }; | |||||
crudObj.page.size = 30; | crudObj.page.size = 30; | ||||
return crudObj; | return crudObj; | ||||
}, | }, | ||||
@@ -203,18 +222,18 @@ export default { | |||||
data() { | data() { | ||||
return { | return { | ||||
datasetId: 0, | datasetId: 0, | ||||
datasetInfo: {}, | |||||
uploadDialogVisible: false, | uploadDialogVisible: false, | ||||
lastTabName: 'unannotate', | lastTabName: 'unannotate', | ||||
crudStatusMap: { | crudStatusMap: { | ||||
'unannotate': [0], | |||||
'annotate': [2, 3], | |||||
'unannotate': [fileCodeMap.UNCOMPLETED], | |||||
'annotate': [fileCodeMap.COMPLETED], | |||||
}, | }, | ||||
newLabel: undefined, | newLabel: undefined, | ||||
checkAll: false, | checkAll: false, | ||||
isIndeterminate: false, | isIndeterminate: false, | ||||
typeSwitch: true, | typeSwitch: true, | ||||
rawLabelData: [], | rawLabelData: [], | ||||
showCreateLabel: true, | |||||
labelData: [], | labelData: [], | ||||
name2CategoryId: {}, | name2CategoryId: {}, | ||||
// 选中列表 | // 选中列表 | ||||
@@ -251,8 +270,8 @@ export default { | |||||
this.datasetId = parseInt(this.$route.params.datasetId, 10); | this.datasetId = parseInt(this.$route.params.datasetId, 10); | ||||
this.refreshLabel(); | this.refreshLabel(); | ||||
Promise.all([ | Promise.all([ | ||||
list({ 'datasetId': this.datasetId, 'status': [0] }), | |||||
list({ 'datasetId': this.datasetId, 'status': [2, 3] }), | |||||
list({ 'datasetId': this.datasetId, 'status': [fileCodeMap.UNCOMPLETED] }), | |||||
list({ 'datasetId': this.datasetId, 'status': [fileCodeMap.COMPLETED] }), | |||||
]) | ]) | ||||
.then(([unannotate, annotate]) => { | .then(([unannotate, annotate]) => { | ||||
if (unannotate.result.length === 0 && annotate.result.length !== 0) { | if (unannotate.result.length === 0 && annotate.result.length !== 0) { | ||||
@@ -262,6 +281,9 @@ export default { | |||||
} | } | ||||
}); | }); | ||||
detail(this.datasetId).then(res => { | |||||
this.datasetInfo = res || {}; | |||||
}); | |||||
// 系统标签 | // 系统标签 | ||||
this.getSystemLabel(); | this.getSystemLabel(); | ||||
}, | }, | ||||
@@ -277,6 +299,9 @@ export default { | |||||
})(); | })(); | ||||
}, | }, | ||||
methods: { | methods: { | ||||
handleEditLabel(field, item){ | |||||
editLabel(item.id, field).then(this.refreshLabel); | |||||
}, | |||||
handleSort(command) { | handleSort(command) { | ||||
this.resetQuery(); | this.resetQuery(); | ||||
this.crud.params.order = command === 1 ? 'name' : ''; | this.crud.params.order = command === 1 ? 'name' : ''; | ||||
@@ -340,6 +365,10 @@ export default { | |||||
}; | }; | ||||
if (ids.length) { | if (ids.length) { | ||||
del(params).then(() => { | del(params).then(() => { | ||||
this.$message({ | |||||
message: '删除文件成功', | |||||
type: 'success', | |||||
}); | |||||
this.crud.toQuery(); | this.crud.toQuery(); | ||||
}).finally(() => { | }).finally(() => { | ||||
this.crud.delAllLoading = false; | this.crud.delAllLoading = false; | ||||
@@ -354,6 +383,7 @@ export default { | |||||
}, | }, | ||||
handleCheckAllChange(val) { | handleCheckAllChange(val) { | ||||
const {imgGallery} = this.$refs; | const {imgGallery} = this.$refs; | ||||
if(!imgGallery) return false; | |||||
if (val) { | if (val) { | ||||
imgGallery.selectAll(); | imgGallery.selectAll(); | ||||
} else { | } else { | ||||
@@ -442,10 +472,6 @@ export default { | |||||
}, | }, | ||||
refreshLabel() { | refreshLabel() { | ||||
getLabels(this.datasetId).then((res) => { | getLabels(this.datasetId).then((res) => { | ||||
// 图像分类使用的是预置标签时,不显示新建标签功能,目前自定义标签type为0,自动标注标签为1 | |||||
if (res[0] && res[0].type > 1) { | |||||
this.showCreateLabel = false; | |||||
} | |||||
this.rawLabelData = res; | this.rawLabelData = res; | ||||
this.rawLabelData.forEach((item) => { | this.rawLabelData.forEach((item) => { | ||||
if (item.color === '#000000') { | if (item.color === '#000000') { | ||||
@@ -460,7 +486,9 @@ export default { | |||||
this.labelData = this.rawLabelData; | this.labelData = this.rawLabelData; | ||||
}); | }); | ||||
}, | }, | ||||
chooseLabel(row) { | |||||
chooseLabel(row, event) { | |||||
// 过滤编辑入口 | |||||
if (event.target.classList.contains('el-icon-edit')) return; | |||||
if (this.selectImgsId.length > 0) { | if (this.selectImgsId.length > 0) { | ||||
const annotations = []; | const annotations = []; | ||||
this.selectImgsId.forEach((item) => { | this.selectImgsId.forEach((item) => { | ||||
@@ -472,7 +500,7 @@ export default { | |||||
id: item, | id: item, | ||||
}); | }); | ||||
}); | }); | ||||
batchFinishAnnotation({ annotations }).then(() => { | |||||
batchFinishAnnotation({ annotations }, this.datasetId).then(() => { | |||||
this.crud.refresh(); | this.crud.refresh(); | ||||
this.handleCheckAllChange(0); | this.handleCheckAllChange(0); | ||||
}); | }); | ||||
@@ -489,6 +517,8 @@ export default { | |||||
// 如果不是系统标签,才会选择新建 | // 如果不是系统标签,才会选择新建 | ||||
if (this.systemLabels.findIndex(d => d.value === value) === -1) { | if (this.systemLabels.findIndex(d => d.value === value) === -1) { | ||||
this.addLabel(value); | this.addLabel(value); | ||||
// 新建标签 | |||||
this.postLabel(); | |||||
} else { | } else { | ||||
const systemLabel = this.systemLabels.find(d => d.value === value) || {}; | const systemLabel = this.systemLabels.find(d => d.value === value) || {}; | ||||
systemLabel.label && this.addLabel(systemLabel.label); | systemLabel.label && this.addLabel(systemLabel.label); | ||||
@@ -512,6 +542,8 @@ export default { | |||||
this.newLabel = undefined; | this.newLabel = undefined; | ||||
this.refreshLabel(); | this.refreshLabel(); | ||||
}); | }); | ||||
} else { | |||||
Message.warning('请选择标签'); | |||||
} | } | ||||
}, | }, | ||||
switchLabelTag(newSwitch) { | switchLabelTag(newSwitch) { | ||||
@@ -519,13 +551,8 @@ export default { | |||||
}, | }, | ||||
getStyle(item) { | getStyle(item) { | ||||
// 根据亮度来决定颜色 | // 根据亮度来决定颜色 | ||||
if (item.color && chroma(item.color).luminance() < 0.5) { | |||||
return { | |||||
color: '#fff', | |||||
}; | |||||
} | |||||
return { | return { | ||||
color: '#000', | |||||
color: colorByLuminance(item.color), | |||||
}; | }; | ||||
}, | }, | ||||
}, | }, | ||||
@@ -36,7 +36,7 @@ | |||||
> | > | ||||
<el-carousel-item v-for="item in fileList" :key="item.id"> | <el-carousel-item v-for="item in fileList" :key="item.id"> | ||||
<div class="figure-action-row rel" :style="buildActionRow(item)"> | <div class="figure-action-row rel" :style="buildActionRow(item)"> | ||||
<div v-if="item.enhanceTag" class="action-tag tc">{{ item.enhanceTag.label }}</div> | |||||
<div v-if="item.enhanceTag" class="action-tag tc">增强类型:{{ item.enhanceTag.label }}</div> | |||||
</div> | </div> | ||||
<div class="figure-wrapper carousel-figure-item"> | <div class="figure-wrapper carousel-figure-item"> | ||||
<div | <div | ||||
@@ -13,22 +13,28 @@ | |||||
* limitations under the License. | * limitations under the License. | ||||
* ============================================================= | * ============================================================= | ||||
*/ | */ | ||||
import { statusCodeMap } from '../util'; | |||||
export default { | export default { | ||||
name: 'DatasetAction', | name: 'DatasetAction', | ||||
functional: true, | functional: true, | ||||
props: { | props: { | ||||
showPublish: Function, | showPublish: Function, | ||||
openUploadDialog: Function, | |||||
uploadDataFile: Function, | |||||
goDetail: Function, | goDetail: Function, | ||||
getAutoAnnotateStatus: Function, | getAutoAnnotateStatus: Function, | ||||
autoAnnotate: Function, | autoAnnotate: Function, | ||||
gotoVersion: Function, | gotoVersion: Function, | ||||
reAnnotation: Function, | reAnnotation: Function, | ||||
track: Function, | |||||
dataEnhance: Function, | dataEnhance: Function, | ||||
topDataset: Function, | |||||
editDataset: Function, | |||||
checkImport: Function, // 查询外部数据集导入状态 | |||||
}, | }, | ||||
render(h, { data, props }) { | render(h, { data, props }) { | ||||
const { showPublish, openUploadDialog, goDetail, autoAnnotate, gotoVersion, reAnnotation, dataEnhance } = props; | |||||
const { showPublish, uploadDataFile, goDetail, autoAnnotate, gotoVersion, reAnnotation, track, dataEnhance, topDataset, editDataset, checkImport } = props; | |||||
const columnProps = { | const columnProps = { | ||||
...data, | ...data, | ||||
scopedSlots: { | scopedSlots: { | ||||
@@ -36,10 +42,6 @@ export default { | |||||
return ( | return ( | ||||
<span> | <span> | ||||
<span>操作</span> | <span>操作</span> | ||||
<el-tooltip effect='dark' placement='top' style={{ marginLeft: '10px' }}> | |||||
<div slot='content'>如果数据集操作没有更新,<br/>可能是后台算法在执行其他任务,<br/>请耐心等待或稍后重试</div> | |||||
<i class='el-icon-question'/> | |||||
</el-tooltip> | |||||
</span> | </span> | ||||
); | ); | ||||
}, | }, | ||||
@@ -55,9 +57,9 @@ export default { | |||||
}, | }, | ||||
}; | }; | ||||
// 查看标注按钮在 自动标注中(2) 未采样(5) 采样中(7) 数据增强中(8)时不显示, 此外,类型为视频时,自动标注完成(3)也不可查看(此时下游会进行目标跟踪) | |||||
let showCheckButton = ![2, 5, 7, 8].includes(row.status); | |||||
if (row.dataType === 1 && row.status === 3) { | |||||
// 查看标注按钮在 自动标注中 未采样 采样中 采样失败 目标跟踪中 数据增强中 目标跟踪失败 时不显示, 此外,类型为视频时,自动标注完成也不可查看(此时下游会进行目标跟踪) | |||||
let showCheckButton = !['AUTO_ANNOTATING', 'UNSAMPLED', 'SAMPLING', 'SAMPLE_FAILED', 'TRACKING', 'ENHANCING', 'TRACK_FAILED'].includes(statusCodeMap[row.status]); | |||||
if (row.dataType === 1 && statusCodeMap[row.status] === 'AUTO_ANNOTATED') { | |||||
showCheckButton = false; | showCheckButton = false; | ||||
} | } | ||||
// 查看标注按钮 | // 查看标注按钮 | ||||
@@ -67,8 +69,8 @@ export default { | |||||
</el-button> | </el-button> | ||||
); | ); | ||||
// 自动标注按钮在 自动标注中(2) 自动标注完成(3) 标注完成(4) 未采样(5) 目标跟踪完成(6) 采样中(7) 数据增强中(8)时不显示 | |||||
let showAutoButton = ![2, 3, 4, 5, 6, 7, 8].includes(row.status); | |||||
// 自动标注按钮只在 未标注 标注中 时显示 | |||||
let showAutoButton = ['UNANNOTATED', 'ANNOTATING'].includes(statusCodeMap[row.status]); | |||||
// 自动标注按钮 | // 自动标注按钮 | ||||
const autoButton = ( | const autoButton = ( | ||||
<el-button {...btnProps} onClick={() => autoAnnotate(row)}> | <el-button {...btnProps} onClick={() => autoAnnotate(row)}> | ||||
@@ -109,17 +111,17 @@ export default { | |||||
</el-button> | </el-button> | ||||
); | ); | ||||
// 当类型为视频时,状态为标注完成(4)目标跟踪完成(6)显示发布按钮,其余状态不显示发布按钮 | |||||
// 当类型为图片时,状态为自动标注完成(3)显示有弹窗确认的发布按钮,为标注完成(4)显示发布按钮,其余状态不显示发布按钮 | |||||
// 当类型为视频时,状态为标注完成、目标跟踪完成时显示发布按钮,其余状态不显示发布按钮 | |||||
// 当类型为图片时,状态为自动标注完成时显示有弹窗确认的发布按钮,为标注完成时显示发布按钮,其余状态不显示发布按钮 | |||||
if (row.dataType === 1) { | if (row.dataType === 1) { | ||||
if ([4, 6].includes(row.status)) { | |||||
if (['ANNOTATED', 'TRACK_SUCCEED'].includes(statusCodeMap[row.status])) { | |||||
showPublishButton = true; | showPublishButton = true; | ||||
publishButton = publishDialogButton; | publishButton = publishDialogButton; | ||||
} | } | ||||
} else if (row.status === 3) { | |||||
} else if (statusCodeMap[row.status] === 'AUTO_ANNOTATED') { | |||||
showPublishButton = true; | showPublishButton = true; | ||||
publishButton = publishConfirmButton; | publishButton = publishConfirmButton; | ||||
} else if (row.status === 4) { | |||||
} else if (statusCodeMap[row.status] === 'ANNOTATED') { | |||||
showPublishButton = true; | showPublishButton = true; | ||||
publishButton = publishDialogButton; | publishButton = publishDialogButton; | ||||
} | } | ||||
@@ -127,22 +129,22 @@ export default { | |||||
let showUploadButton = false; | let showUploadButton = false; | ||||
// 导入按钮 | // 导入按钮 | ||||
const uploadButton = ( | const uploadButton = ( | ||||
<el-button {...btnProps} onClick={() => openUploadDialog(row)}> | |||||
<el-button {...btnProps} onClick={() => uploadDataFile(row)}> | |||||
导入 | 导入 | ||||
</el-button> | </el-button> | ||||
); | ); | ||||
// 类型为视频时,当状态为未采样(5)时才可导入,其余状态不可导入 | |||||
// 类型为图片时,自动标注中(2) 数据增强中(8)不可导入,其余状态均可导入 | |||||
// 类型为视频时,当状态为未采样时才可导入,其余状态不可导入 | |||||
// 类型为图片时,自动标注中、数据增强中 目标跟踪失败 不可导入,其余状态均可导入 | |||||
if (row.dataType === 1) { | if (row.dataType === 1) { | ||||
if (row.status === 5) { | |||||
if (statusCodeMap[row.status] === 'UNSAMPLED') { | |||||
showUploadButton = true; | showUploadButton = true; | ||||
} | } | ||||
} else if (![2, 8].includes(row.status)) { | |||||
} else if (!['AUTO_ANNOTATING', 'ENHANCING', 'TRACK_FAILED'].includes(statusCodeMap[row.status])) { | |||||
showUploadButton = true; | showUploadButton = true; | ||||
} | } | ||||
// 当标注完成(4)目标跟踪完成(6),以及非视频的自动标注完成(3)时显示重新自动标注按钮 (若为视频此时下游会进行目标跟踪) | |||||
let showReAutoButton = [4, 6].includes(row.status) || (row.status === 3 && row.dataType === 0); | |||||
// 当标注完成、目标跟踪完成,以及非视频的自动标注完成时显示重新自动标注按钮 (若为视频此时下游会进行目标跟踪) | |||||
let showReAutoButton = ['ANNOTATED', 'TRACK_SUCCEED'].includes(statusCodeMap[row.status]) || (statusCodeMap[row.status] === 'AUTO_ANNOTATED' && row.dataType === 0); | |||||
// 重新自动标注按钮 | // 重新自动标注按钮 | ||||
const reAutoButton = ( | const reAutoButton = ( | ||||
<el-popconfirm | <el-popconfirm | ||||
@@ -157,10 +159,28 @@ export default { | |||||
</el-button> | </el-button> | ||||
</el-popconfirm> | </el-popconfirm> | ||||
); | ); | ||||
// 当目标跟踪标注类型的数据集状态为自动标注完成 标注完成时,显示目标跟踪按钮 | |||||
let showTrackButton = row.annotateType === 5 && ['AUTO_ANNOTATED','ANNOTATED'].includes(statusCodeMap[row.status]); | |||||
// 目标跟踪按钮 | |||||
const trackButton = ( | |||||
<el-button {...btnProps} onClick={() => track(row, false)}> | |||||
目标跟踪 | |||||
</el-button> | |||||
); | |||||
// 当目标跟踪失败时,显示重新目标跟踪按钮 | |||||
let showReTrackButton = ['TRACK_FAILED', 'TRACK_SUCCEED'].includes(statusCodeMap[row.status]); | |||||
// 重新目标跟踪按钮 | |||||
const reTrackButton = ( | |||||
<el-button {...btnProps} onClick={() => track(row, true)}> | |||||
重新目标跟踪 | |||||
</el-button> | |||||
); | |||||
// 展示数据增强入口 | // 展示数据增强入口 | ||||
// 当数据类型为图片,并且状态为自动标注完成(3) 标注完成(4)展示数据增强入口 | |||||
let showAugmentButton = row.dataType === 0 && [3, 4].includes(row.status); | |||||
// 当数据类型为图片,并且状态为自动标注完成、标注完成展示数据增强入口 | |||||
let showAugmentButton = row.dataType === 0 && ['AUTO_ANNOTATED', 'ANNOTATED'].includes(statusCodeMap[row.status]); | |||||
// 数据增强按钮 | // 数据增强按钮 | ||||
const augmentButton = ( | const augmentButton = ( | ||||
<el-button {...btnProps} onClick={() => dataEnhance(row)}> | <el-button {...btnProps} onClick={() => dataEnhance(row)}> | ||||
@@ -168,14 +188,41 @@ export default { | |||||
</el-button> | </el-button> | ||||
); | ); | ||||
// 有当前版本且状态不为自动标注中(2) 数据增强中(8) | |||||
let showVersionButton = (row.currentVersionName && ![2, 8].includes(row.status)); | |||||
// 有当前版本且状态不为自动标注中、数据增强中、目标跟踪中,导入中 | |||||
let showVersionButton = (row.currentVersionName && !['AUTO_ANNOTATING', 'ENHANCING', 'TRACKING', 'IMPORTING'].includes(statusCodeMap[row.status])); | |||||
// 历史版本按钮 | // 历史版本按钮 | ||||
const versionButton = ( | const versionButton = ( | ||||
<el-button {...btnProps} onClick={() => gotoVersion(row)}> | <el-button {...btnProps} onClick={() => gotoVersion(row)}> | ||||
历史版本 | 历史版本 | ||||
</el-button> | </el-button> | ||||
); | ); | ||||
let showTopButton = true; | |||||
// 置顶按钮总会显示 | |||||
const topButton = ( | |||||
<el-button {...btnProps} onClick={() => topDataset(row)}> | |||||
{row.top ? '取消置顶' : '置顶'} | |||||
</el-button> | |||||
); | |||||
let showEditButton = true; | |||||
// 修改按钮总会显示 | |||||
const editButton = ( | |||||
<el-button {...btnProps} onClick={() => editDataset(row)}> | |||||
修改 | |||||
</el-button> | |||||
); | |||||
// 导入外部数据集 | |||||
const showImportButton = row.import === true && ['UNANNOTATED'].includes(statusCodeMap[row.status]); | |||||
// 外部导入数据集 | |||||
const importDatasetButton = showImportButton ? ( | |||||
<a {...btnProps} onClick={() => checkImport(row)} href="http://docs.dubhe.ai/docs/module/dataset/import-dataset" target="_blank" class="primary"> | |||||
导入本地数据集 | |||||
<IconFont type="externallink" /> | |||||
</a> | |||||
) : null; | |||||
// 预置数据集只具备查看标注,历史版本功能。 | // 预置数据集只具备查看标注,历史版本功能。 | ||||
if (row.type === 2) { | if (row.type === 2) { | ||||
@@ -184,18 +231,23 @@ export default { | |||||
showCheckButton = true; | showCheckButton = true; | ||||
showAutoButton = false; | showAutoButton = false; | ||||
showReAutoButton = false; | showReAutoButton = false; | ||||
showTrackButton = false; | |||||
showReTrackButton = false; | |||||
showVersionButton = true; | showVersionButton = true; | ||||
showAugmentButton = false; | showAugmentButton = false; | ||||
showTopButton = false; | |||||
showEditButton = false; | |||||
}; | }; | ||||
// 导入的自定义数据集只允许删除操作 | |||||
// 导入的自定义数据集只允许删除 置顶 修改操作 | |||||
if (row.import) { | if (row.import) { | ||||
showPublishButton = false; | |||||
showUploadButton = false; | showUploadButton = false; | ||||
showCheckButton = false; | |||||
showAutoButton = false; | showAutoButton = false; | ||||
showReAutoButton = false; | showReAutoButton = false; | ||||
showVersionButton = false; | |||||
showTrackButton = false; | |||||
showReTrackButton = false; | |||||
showAugmentButton = false; | showAugmentButton = false; | ||||
// 导入完成才可以查看标注 | |||||
showCheckButton = (statusCodeMap[row.status] === 'ANNOTATED'); | |||||
}; | }; | ||||
// 统计需要显示的按钮个数 | // 统计需要显示的按钮个数 | ||||
const buttonCount = (arr) => { | const buttonCount = (arr) => { | ||||
@@ -204,8 +256,8 @@ export default { | |||||
(item) => { if (item) count+=1; }); | (item) => { if (item) count+=1; }); | ||||
return count; | return count; | ||||
}; | }; | ||||
const leftButtonArr = [showPublishButton, showUploadButton, showCheckButton, showAutoButton, showReAutoButton]; | |||||
const rightButtonArr = [showVersionButton, showAugmentButton]; | |||||
const leftButtonArr = [showPublishButton, showUploadButton, showCheckButton, showAutoButton, showReAutoButton, showTrackButton]; | |||||
const rightButtonArr = [showVersionButton, showAugmentButton, showTopButton, showEditButton, showReTrackButton]; | |||||
const leftButtonCount = buttonCount(leftButtonArr); | const leftButtonCount = buttonCount(leftButtonArr); | ||||
const rightButtonCount = buttonCount(rightButtonArr); | const rightButtonCount = buttonCount(rightButtonArr); | ||||
@@ -216,8 +268,11 @@ export default { | |||||
if (leftButtonCount < 3) { | if (leftButtonCount < 3) { | ||||
moreButton = ( | moreButton = ( | ||||
<span> | <span> | ||||
{showReTrackButton && reTrackButton} | |||||
{showVersionButton && versionButton} | {showVersionButton && versionButton} | ||||
{showAugmentButton && augmentButton} | {showAugmentButton && augmentButton} | ||||
{showTopButton && topButton} | |||||
{showEditButton && editButton} | |||||
</span> | </span> | ||||
); | ); | ||||
} else { | } else { | ||||
@@ -227,11 +282,20 @@ export default { | |||||
更多<i class='el-icon-arrow-down el-icon--right'></i> | 更多<i class='el-icon-arrow-down el-icon--right'></i> | ||||
</el-button> | </el-button> | ||||
<el-dropdown-menu slot='dropdown'> | <el-dropdown-menu slot='dropdown'> | ||||
<el-dropdown-item> | |||||
{showReTrackButton && reTrackButton} | |||||
</el-dropdown-item> | |||||
<el-dropdown-item> | <el-dropdown-item> | ||||
{showVersionButton && versionButton} | {showVersionButton && versionButton} | ||||
</el-dropdown-item> | </el-dropdown-item> | ||||
<el-dropdown-item key='dataEnhance'> | <el-dropdown-item key='dataEnhance'> | ||||
{showAugmentButton && augmentButton} | {showAugmentButton && augmentButton} | ||||
</el-dropdown-item> | |||||
<el-dropdown-item key='top'> | |||||
{showTopButton && topButton} | |||||
</el-dropdown-item> | |||||
<el-dropdown-item key='edit'> | |||||
{showEditButton && editButton} | |||||
</el-dropdown-item> | </el-dropdown-item> | ||||
</el-dropdown-menu> | </el-dropdown-menu> | ||||
</el-dropdown> | </el-dropdown> | ||||
@@ -241,10 +305,12 @@ export default { | |||||
return ( | return ( | ||||
<span> | <span> | ||||
{ importDatasetButton } | |||||
{showPublishButton && publishButton} | {showPublishButton && publishButton} | ||||
{showUploadButton && uploadButton} | {showUploadButton && uploadButton} | ||||
{showCheckButton && checkButton} | {showCheckButton && checkButton} | ||||
{showAutoButton && autoButton} | {showAutoButton && autoButton} | ||||
{showTrackButton && trackButton} | |||||
{showReAutoButton && reAutoButton} | {showReAutoButton && reAutoButton} | ||||
{moreButton} | {moreButton} | ||||
</span> | </span> | ||||
@@ -0,0 +1,622 @@ | |||||
/** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||||
* | |||||
* Licensed under the Apache License, Version 2.0 (the "License"); | |||||
* you may not use this file except in compliance with the License. | |||||
* You may obtain a copy of the License at | |||||
* | |||||
* http://www.apache.org/licenses/LICENSE-2.0 | |||||
* | |||||
* 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. | |||||
* ============================================================= | |||||
*/ | |||||
<template> | |||||
<el-dialog | |||||
append-to-body | |||||
custom-class="create-dataset" | |||||
center | |||||
:close-on-click-modal="false" | |||||
:visible="visible" | |||||
title="创建数据集" | |||||
width="610px" | |||||
@close="closeDialog" | |||||
> | |||||
<!--步骤条--> | |||||
<el-steps :active="activeStep" finish-status="success"> | |||||
<el-step title="新建数据集" /> | |||||
<el-step title="导入数据" /> | |||||
<el-step title="完成" /> | |||||
</el-steps> | |||||
<!--step0新建数据集--> | |||||
<div v-if="activeStep === 0"> | |||||
<el-form ref="form" :model="form" :rules="rules" label-width="100px"> | |||||
<el-form-item label="数据集名称" prop="name"> | |||||
<el-input v-model="form.name" placeholder="数据集名称不能超过50字" maxlength="50" /> | |||||
</el-form-item> | |||||
<el-form-item label="数据类型" prop="dataType"> | |||||
<InfoSelect | |||||
v-model="form.dataType" | |||||
placeholder="数据类型" | |||||
:dataSource="dataTypeList" | |||||
@change="handleDataTypeChange" | |||||
/> | |||||
</el-form-item> | |||||
<el-form-item label="标注类型" prop="annotateType"> | |||||
<InfoSelect | |||||
v-model="form.annotateType" | |||||
placeholder="标注类型" | |||||
:dataSource="annotationList" | |||||
:disabled="form.dataType === 1" | |||||
@change="handleAnnotateTypeChange" | |||||
/> | |||||
</el-form-item> | |||||
<el-form-item label="标签组" prop="labelGroupId"> | |||||
<div class="label-input"> | |||||
<el-popover | |||||
ref="popover" | |||||
v-model="popoverVisible" | |||||
placement="top" | |||||
trigger="click" | |||||
popper-class="label-group-popover" | |||||
> | |||||
<div class="add-label-tag"> | |||||
<el-tabs v-model="labelGroupTab" type="border-card"> | |||||
<el-tab-pane label="自定义标签组" name="custom"> | |||||
<el-select | |||||
v-model="customLabelGroupId" | |||||
filterable | |||||
placeholder="请选择" | |||||
popper-class="label-group-select" | |||||
@change="handleCustomId" | |||||
> | |||||
<el-option | |||||
v-for="item in customLabelGroups" | |||||
:key="item.labelGroupId" | |||||
:label="item.name" | |||||
:value="item.labelGroupId" | |||||
> | |||||
</el-option> | |||||
</el-select> | |||||
</el-tab-pane> | |||||
<el-tab-pane label="预置标签组" name="system" :disabled="!systemLabelEnabled"> | |||||
<el-select | |||||
v-model="systemLabelGroupId" | |||||
filterable | |||||
placeholder="请选择" | |||||
@change="handleSystemId" | |||||
> | |||||
<el-option | |||||
v-for="item in systemLabelGroups" | |||||
:key="item.labelGroupId" | |||||
:label="item.name" | |||||
:value="item.labelGroupId" | |||||
:disabled="!optionEnabled(item.labelGroupId, form.annotateType)" | |||||
> | |||||
</el-option> | |||||
</el-select> | |||||
</el-tab-pane> | |||||
</el-tabs> | |||||
</div> | |||||
<el-button slot="reference" type="text"> | |||||
| |||||
<span v-if="labelGroupId === null"> 标签组</span> | |||||
<el-tag v-else closable @close="handleRemoveLabelGroup()"> | |||||
{{labelGroupName}} | |||||
</el-tag> | |||||
</el-button> | |||||
</el-popover> | |||||
<el-link | |||||
v-if="labelGroupId !== null" | |||||
target="_blank" | |||||
type="primary" | |||||
:underline="false" | |||||
class="vm" | |||||
:href="`/data/labelgroup/detail?id=${labelGroupId}`" | |||||
style="float: right; margin-right: 8px;" | |||||
> | |||||
查看详情 | |||||
</el-link> | |||||
</div> | |||||
</el-form-item> | |||||
<div v-if="labelGroupId === null" style=" position: relative; top: -12px; left: 118px;"> | |||||
<span>标签组需要在</span> | |||||
<a | |||||
target="_blank" | |||||
type="primary" | |||||
:underline="false" | |||||
class="primary" | |||||
:href="`/data/labelgroup/create`" | |||||
> | |||||
新建标签组 | |||||
</a> | |||||
<span>页面创建</span> | |||||
</div> | |||||
<el-form-item label="数据集描述" prop="remark"> | |||||
<el-input | |||||
v-model="form.remark" | |||||
type="textarea" | |||||
placeholder="数据集描述长度不能超过100字" | |||||
maxlength="100" | |||||
rows="3" | |||||
show-word-limit | |||||
/> | |||||
</el-form-item> | |||||
</el-form> | |||||
<div style=" margin-top: 25px; text-align: center;"> | |||||
<el-button | |||||
:loading="crud.status.cu === 2" | |||||
type="primary" | |||||
@click="createDataset" | |||||
> | |||||
下一步 | |||||
</el-button> | |||||
</div> | |||||
</div> | |||||
<!--step1上传文件--> | |||||
<div v-show="activeStep === 1"> | |||||
<upload-inline | |||||
ref="initFileUploadForm" | |||||
action="fakeApi" | |||||
:params="uploadParams" | |||||
:transformFile="withDimensionFile" | |||||
v-bind="optionCreateProps" | |||||
@uploadSuccess="uploadSuccess" | |||||
@uploadError="uploadError" | |||||
/> | |||||
<!--上传视频时显示帧间隔设置--> | |||||
<el-form | |||||
v-if="form.dataType === 1" | |||||
ref="formStep1" | |||||
:model="step1Form" | |||||
label-width="100px" | |||||
style="margin-top: 10px;" | |||||
> | |||||
<el-form-item | |||||
label="视频帧间隔" | |||||
prop="frameInterval" | |||||
:rules="[{required: true, message: '请输入有效的帧间隔', trigger: 'blur'}]" | |||||
> | |||||
<el-input-number v-model="step1Form.frameInterval" :min="1" /> | |||||
</el-form-item> | |||||
</el-form> | |||||
<div style=" margin-top: 25px; text-align: center;"> | |||||
<el-button @click="skip">跳过</el-button> | |||||
<el-button type="primary" @click="uploadSubmit('initFileUploadForm')">确定上传</el-button> | |||||
</div> | |||||
</div> | |||||
<!--step2上传中--> | |||||
<div v-if="activeStep === 2 && skipUpload !== true"> | |||||
<!--上传图片进度条--> | |||||
<el-progress | |||||
v-if="form.dataType !== 1" | |||||
type="circle" | |||||
:percentage="uploadPercent" | |||||
:status="uploadStatus" | |||||
:format="formatProgress" | |||||
/> | |||||
<!--上传视频进度条--> | |||||
<div v-else class="circleProgressWrapper"> | |||||
<div class="circleText">正在上传</div> | |||||
<div class="wrapper right"> | |||||
<div class="circleProgress rightCircle"></div> | |||||
</div> | |||||
<div class="wrapper left"> | |||||
<div class="circleProgress leftCircle"></div> | |||||
</div> | |||||
</div> | |||||
<div style=" margin-top: 25px; text-align: center;"> | |||||
<el-button type="primary" :loading="true">确定</el-button> | |||||
</div> | |||||
</div> | |||||
<!--step3上传完成--> | |||||
<div v-if="activeStep === 3"> | |||||
<el-progress v-if="skipUpload !== true" type="circle" :percentage="100" :status="uploadStatus"/> | |||||
<div style=" margin-top: 25px; text-align: center;"> | |||||
<el-button type="primary" :loading="!uploadFinished" @click="completeCreate">确定</el-button> | |||||
</div> | |||||
</div> | |||||
</el-dialog> | |||||
</template> | |||||
<script> | |||||
import CRUD, { presenter, header, form, crud } from '@crud/crud'; | |||||
import crudDataset, { detail } from '@/api/preparation/dataset'; | |||||
import { submit, submitVideo } from '@/api/preparation/datafile'; | |||||
import { getLabelGroupList } from '@/api/preparation/labelGroup'; | |||||
import { | |||||
getImgFromMinIO, | |||||
annotationMap, | |||||
dataTypeMap, | |||||
withDimensionFile, | |||||
trackUploadProps, | |||||
} from '@/views/dataset/util'; | |||||
import { validateName } from '@/utils/validate'; | |||||
import UploadInline from '@/components/UploadForm/inline'; | |||||
import InfoSelect from '@/components/InfoSelect'; | |||||
import { toFixed } from '@/utils'; | |||||
// 默认帧间隔 | |||||
const defaultFrameInterval = 5; | |||||
// 默认表单 | |||||
const defaultForm = { | |||||
id: null, | |||||
name: null, | |||||
dataType: null, | |||||
annotateType: null, | |||||
labelGroupId: null, | |||||
presetLabelType: '', | |||||
remark: '', | |||||
type: 0, | |||||
}; | |||||
export default { | |||||
name: "CreateDataset", | |||||
components: { | |||||
UploadInline, | |||||
InfoSelect, | |||||
}, | |||||
cruds() { | |||||
return CRUD({ | |||||
title: '数据集管理', | |||||
crudMethod: { ...crudDataset }, | |||||
props: { optText: { add: '创建数据集' }}, | |||||
queryOnPresenterCreated: false, | |||||
}); | |||||
}, | |||||
mixins: [presenter(), header(), form(defaultForm), crud()], | |||||
props: { | |||||
visible: { | |||||
type: Boolean, | |||||
default: false, | |||||
}, | |||||
closeCreateDatasetForm: { | |||||
type: Function, | |||||
}, | |||||
onResetFresh: { | |||||
type: Function, | |||||
}, | |||||
}, | |||||
data() { | |||||
return { | |||||
chosenDatasetId: 0, // 当前数据集id | |||||
activeStep: 0, // 当前的step | |||||
actionKey: 1, | |||||
// customLabelEnabled: true, // 自定义标签组可用性 | |||||
systemLabelEnabled: true, // 预置标签组可用性 | |||||
uploadPercent: 0, | |||||
uploadStatus: undefined, | |||||
skipUpload: false, // 跳过上传 | |||||
popoverVisible: false, | |||||
rules: { | |||||
name: [ | |||||
{ required: true, message: '请输入数据集名称', trigger: ['change', 'blur'] }, | |||||
{ validator: validateName, trigger: ['change', 'blur'] }, | |||||
], | |||||
dataType: [ | |||||
{ required: true, message: '请选择数据类型', trigger: 'change' }, | |||||
], | |||||
annotateType: [ | |||||
{ required: true, message: '请选择标注类型', trigger: 'change' }, | |||||
], | |||||
remark: [ | |||||
{ required: false, message: '请输入数据集描述信息', trigger: 'blur' }, | |||||
], | |||||
}, | |||||
step1Form: { | |||||
frameInterval: defaultFrameInterval, // 默认值 | |||||
}, | |||||
labelGroupTab: "custom", | |||||
labelGroupName: null, | |||||
labelGroupId: null, | |||||
customLabelGroupId: null, | |||||
systemLabelGroupId: null, | |||||
customLabelGroups: [], | |||||
systemLabelGroups: [], | |||||
}; | |||||
}, | |||||
computed: { | |||||
// 文件上传前携带尺寸信息 | |||||
withDimensionFile() { | |||||
return withDimensionFile; | |||||
}, | |||||
uploadParams() { | |||||
// 是否为视频数据类类型 | |||||
const isVideo = | |||||
this.importRow?.dataType === 1 || this.form.dataType === 1; | |||||
const dir = isVideo ? `video` : `origin`; | |||||
return { | |||||
datasetId: this.chosenDatasetId, | |||||
objectPath: `dataset/${this.chosenDatasetId}/${dir}`, // 对象存储路径 | |||||
}; | |||||
}, | |||||
// 新建数据集(视频)上传组件参数 | |||||
optionCreateProps() { | |||||
const props = this.form.dataType === 1 ? trackUploadProps : {}; | |||||
return props; | |||||
}, | |||||
annotationList() { | |||||
// 原始标注列表 | |||||
const rawAnnotationList = Object.keys(annotationMap).map(d => ({ | |||||
label: annotationMap[d].name, | |||||
value: Number(d), | |||||
})); | |||||
// 如果是图片,目标跟踪不可用 | |||||
// 如果是视频,只能用目标跟踪 | |||||
return rawAnnotationList.map(d => { | |||||
let disabled = false; | |||||
if (this.form.dataType === 0) { | |||||
disabled = d.value === 5; | |||||
} else if (this.form.dataType === 1) { | |||||
disabled = d.value !== 5; | |||||
} | |||||
return { | |||||
...d, | |||||
disabled, | |||||
}; | |||||
}); | |||||
}, | |||||
dataTypeList: () => | |||||
Object.keys(dataTypeMap).map(d => ({ | |||||
label: dataTypeMap[d], | |||||
value: Number(d), | |||||
})), | |||||
uploadFinished() { | |||||
return this.uploadStatus && ['success', 'exception'].includes(this.uploadStatus); | |||||
}, | |||||
}, | |||||
created() { | |||||
this.crud.toQuery(); | |||||
getLabelGroupList(1).then(res => { | |||||
res.forEach((item) => { | |||||
this.systemLabelGroups.push({ | |||||
labelGroupId: item.id, | |||||
name: item.name, | |||||
}); | |||||
}); | |||||
}); | |||||
getLabelGroupList(0).then(res => { | |||||
res.forEach((item) => { | |||||
this.customLabelGroups.push({ | |||||
labelGroupId: item.id, | |||||
name: item.name, | |||||
}); | |||||
}); | |||||
}); | |||||
}, | |||||
methods: { | |||||
handleCustomId() { | |||||
this.popoverVisible = false; | |||||
this.labelGroupId = this.customLabelGroupId; | |||||
this.systemLabelGroupId = null; | |||||
this.labelGroupName = this.customLabelGroups.find(d => d.labelGroupId === this.labelGroupId).name; | |||||
}, | |||||
handleSystemId() { | |||||
this.popoverVisible = false; | |||||
this.labelGroupId = this.systemLabelGroupId; | |||||
this.customLabelGroupId = null; | |||||
this.labelGroupName = this.systemLabelGroups.find(d => d.labelGroupId === this.labelGroupId).name; | |||||
}, | |||||
handleRemoveLabelGroup() { | |||||
this.labelGroupId = null; | |||||
this.customLabelGroupId = null; | |||||
this.systemLabelGroupId = null; | |||||
this.$refs.popover.doClose(); | |||||
}, | |||||
optionEnabled(labelGroupId, annotateType) { | |||||
// 目标检测(1)目标跟踪(5)可以使用预置标签组COCO | |||||
if([1, 5].includes(annotateType)) { | |||||
return labelGroupId === 1; | |||||
} | |||||
return true; | |||||
}, | |||||
// 重置创建数据集表单 | |||||
resetCreateDatasetForm() { | |||||
// 清理第一步表单 | |||||
this.$refs.form?.resetFields(); | |||||
// 清除标签组 | |||||
this.labelGroupId = null; | |||||
this.systemLabelEnabled = true; | |||||
this.systemLabelGroupId = null; | |||||
this.customLabelGroupId = null; | |||||
// 清理上传表单 | |||||
this.$refs.initFileUploadForm?.$refs?.formRef.reset(); | |||||
this.crud.cancelCU(); | |||||
this.crud.status.add = CRUD.STATUS.NORMAL; | |||||
this.chosenDatasetId = 0; | |||||
this.activeStep = 0; | |||||
// 重置帧数 | |||||
this.step1Form.frameInterval = defaultFrameInterval; | |||||
this.skipUpload = false; | |||||
this.uploadStatus = undefined; | |||||
this.uploadPercent = 0; | |||||
this.videoUploadProgress = 0; | |||||
}, | |||||
// step0 标签选择框刷新 | |||||
handleLabelHide() { | |||||
this.actionKey += 1; | |||||
}, | |||||
// step0 改变数据类型 | |||||
handleDataTypeChange(dataType) { | |||||
// 数据类型选中为视频时,标注类型自动切换为目标跟踪,同时清除不符合类型的标签组 | |||||
if (dataType === 1) { | |||||
this.form.annotateType = 5; | |||||
this.handleAnnotateTypeChange(5); | |||||
} else { | |||||
this.form.annotateType = undefined; | |||||
this.systemLabelEnabled = true; | |||||
} | |||||
}, | |||||
// step0 改变标注类型 | |||||
handleAnnotateTypeChange(annotateType) { | |||||
// 更改标注类型会清除不符合条件的标签组 | |||||
// 目标检测(1) 目标跟踪(5) 可以选中预置标签组中的Coco(id=1) | |||||
if ([1, 5].includes(annotateType)) { | |||||
if(this.labelGroupId !== 1 && this.labelGroupId === this.systemLabelGroupId) { | |||||
this.systemLabelEnabled = true; | |||||
this.labelGroupId = null; | |||||
this.systemLabelGroupId = null; | |||||
} | |||||
} | |||||
// 图像分类(2)可以选中预置标签组Coco(id=1)和ImageNet(id=2) | |||||
if (annotateType === 2) { | |||||
if(![1, 2].includes(this.labelGroupId) && this.labelGroupId === this.systemLabelGroupId) { | |||||
this.systemLabelEnabled = true; | |||||
this.labelGroupId = null; | |||||
this.systemLabelGroupId = null; | |||||
} | |||||
} | |||||
// 其余不可以使用预置标签组 | |||||
if (![1, 2, 5].includes(annotateType)) { | |||||
if( this.labelGroupId === this.systemLabelGroupId) { | |||||
this.systemLabelGroupId = null; | |||||
this.labelGroupId = null; | |||||
this.labelGroupName = null; | |||||
this.systemLabelEnabled = false; | |||||
this.labelGroupTab = "custom"; | |||||
} | |||||
} | |||||
}, | |||||
// step0 创建数据集调用 | |||||
createDataset() { | |||||
if (this.activeStep === 0) { | |||||
this.crud.findVM('form').$refs.form.validate(valid => { | |||||
if (!valid) { | |||||
return; | |||||
} | |||||
this.crud.status.add = CRUD.STATUS.PROCESSING; | |||||
this.crud.form.labelGroupId = this.labelGroupId; | |||||
this.crud.crudMethod | |||||
.add(this.crud.form) | |||||
.then(res => { | |||||
this.chosenDatasetId = res; | |||||
this.activeStep = 1; | |||||
}) | |||||
.catch(err => { | |||||
this.$message({ | |||||
message: err.message || '数据集创建失败', | |||||
type: 'exception', | |||||
}); | |||||
this.crud.status.add = CRUD.STATUS.PREPARED; | |||||
}); | |||||
}); | |||||
} | |||||
}, | |||||
// step1 上传前需要查询数据集详情 | |||||
async queryDatasetDetail(datasetId) { | |||||
const res = await detail(datasetId); | |||||
return res; | |||||
}, | |||||
// step1 上传包括图片和视频 | |||||
async uploader(datasetId, files) { | |||||
const datasetInfo = await this.queryDatasetDetail(datasetId); | |||||
// 点击导入操作 | |||||
const { dataType } = datasetInfo || {}; | |||||
// 文件上传 | |||||
if (dataType === 0) { | |||||
return submit(datasetId, files); | |||||
} | |||||
if (dataType === 1) { | |||||
return submitVideo(datasetId, { | |||||
frameInterval: this.step1Form.frameInterval, | |||||
url: files[0].url, | |||||
}); | |||||
} | |||||
return Promise.reject(); | |||||
}, | |||||
// step1 上传成功 | |||||
uploadSuccess(res) { | |||||
if (this.crud.status.cu > 0) { | |||||
this.activeStep+=1; | |||||
} | |||||
// 视频上传完毕 | |||||
if (this.form.dataType === 1) { | |||||
this.videoUploadProgress = 100; | |||||
} | |||||
const files = getImgFromMinIO(res); | |||||
// 自动标注完成时 导入 提示信息不同 | |||||
const successMessage = '上传文件成功'; | |||||
if (files.length > 0) { | |||||
this.uploader(this.chosenDatasetId, files).then(() => { | |||||
this.$message({ | |||||
message: successMessage, | |||||
duration: 5000, | |||||
type: 'success', | |||||
}); | |||||
this.uploadStatus = 'success'; | |||||
}); | |||||
} | |||||
}, | |||||
// step1 上传失败 | |||||
uploadError() { | |||||
this.uploadStatus = 'exception'; | |||||
this.$message({ | |||||
message: '上传文件失败', | |||||
type: 'error', | |||||
}); | |||||
}, | |||||
// step1 跳过上传 | |||||
skip() { | |||||
this.skipUpload = true; | |||||
this.activeStep += 2; | |||||
}, | |||||
// step1 确定上传 | |||||
uploadSubmit(formName) { | |||||
this.$refs[formName].uploadSubmit((resolved, total) => { | |||||
// eslint-disable-next-line func-names | |||||
this.$nextTick(function() { | |||||
this.uploadPercent = | |||||
this.uploadPercent > 100 ? 100 : toFixed(resolved / total); | |||||
}); | |||||
}); | |||||
if (this.crud.status.cu > 0) { | |||||
this.activeStep = 2; | |||||
} | |||||
}, | |||||
// step2 进度格式化 | |||||
formatProgress(percentage) { | |||||
let formatTxt = `${percentage}%`; | |||||
if (this.form.dataType === 1) { | |||||
formatTxt = this.videoUploadProgress === 100 ? `100%` : `上传中...`; | |||||
} | |||||
return formatTxt; | |||||
}, | |||||
// step2 完成时点击确定 | |||||
completeCreate() { | |||||
// 发送创建成功消息 | |||||
this.$message({ | |||||
message: '数据集创建成功', | |||||
type: 'success', | |||||
}); | |||||
// 关闭创建数据集对话框 | |||||
this.closeCreateDatasetForm(); | |||||
this.onResetFresh(); | |||||
// 重置创建数据集各个步骤的表单 | |||||
this.resetCreateDatasetForm(); | |||||
}, | |||||
// 关闭显示的创建数据集对话框 | |||||
closeDialog() { | |||||
if(this.activeStep === 0){ | |||||
// step=0还未创建数据集时不需要刷新列表 | |||||
this.closeCreateDatasetForm(); | |||||
this.resetCreateDatasetForm(); | |||||
} else{ | |||||
// step>0数据集创建成功 | |||||
this.completeCreate(); | |||||
} | |||||
}, | |||||
}, | |||||
}; | |||||
</script> |
@@ -0,0 +1,345 @@ | |||||
/** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||||
* | |||||
* Licensed under the Apache License, Version 2.0 (the "License"); | |||||
* you may not use this file except in compliance with the License. | |||||
* You may obtain a copy of the License at | |||||
* | |||||
* http://www.apache.org/licenses/LICENSE-2.0 | |||||
* | |||||
* 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. | |||||
* ============================================================= | |||||
*/ | |||||
<template> | |||||
<BaseModal | |||||
:visible="visible" | |||||
:loading="loading" | |||||
title="修改数据集" | |||||
@change="handleCancel" | |||||
@ok="handleEditDataset" | |||||
> | |||||
<el-form ref="form" :model="state.model" :rules="rules" label-width="100px"> | |||||
<el-form-item label="数据集名称" prop="name"> | |||||
<el-input v-model="state.model.name" placeholder="数据集名称不能超过50字" maxlength="50" /> | |||||
</el-form-item> | |||||
<el-form-item label="数据类型" prop="dataType"> | |||||
<InfoSelect | |||||
v-model="state.model.dataType" | |||||
placeholder="数据类型" | |||||
:dataSource="dataTypeList" | |||||
disabled | |||||
/> | |||||
</el-form-item> | |||||
<el-form-item v-if="!state.model.import" label="标注类型" prop="annotateType"> | |||||
<InfoSelect | |||||
v-model="state.model.annotateType" | |||||
placeholder="标注类型" | |||||
:dataSource="annotationList" | |||||
disabled | |||||
/> | |||||
</el-form-item> | |||||
<el-form-item v-if="!state.model.import" label="标签组" prop="labelGroupId"> | |||||
<div v-if="editable" class="label-input"> | |||||
<el-popover | |||||
ref="popoverRef" | |||||
v-model="state.popoverVisible" | |||||
placement="top" | |||||
trigger="click" | |||||
popper-class="label-group-popover" | |||||
> | |||||
<div class="add-label-tag"> | |||||
<el-tabs v-model="state.labelGroupTab" type="border-card"> | |||||
<el-tab-pane label="自定义标签组" name="custom"> | |||||
<el-select | |||||
v-model="state.customLabelGroupId" | |||||
filterable | |||||
placeholder="请选择" | |||||
popper-class="label-group-select" | |||||
@change="handleCustomId" | |||||
> | |||||
<el-option | |||||
v-for="item in customLabelGroups" | |||||
:key="item.labelGroupId" | |||||
:label="item.name" | |||||
:value="item.labelGroupId" | |||||
> | |||||
</el-option> | |||||
</el-select> | |||||
</el-tab-pane> | |||||
<el-tab-pane label="预置标签组" name="system" :disabled="!systemLabelEnabled"> | |||||
<el-select | |||||
v-model="state.systemLabelGroupId" | |||||
filterable | |||||
placeholder="请选择" | |||||
@change="handleSystemId" | |||||
> | |||||
<el-option | |||||
v-for="item in systemLabelGroups" | |||||
:key="item.labelGroupId" | |||||
:label="item.name" | |||||
:value="item.labelGroupId" | |||||
:disabled="!optionEnabled(item.labelGroupId, state.model.annotateType)" | |||||
> | |||||
</el-option> | |||||
</el-select> | |||||
</el-tab-pane> | |||||
</el-tabs> | |||||
</div> | |||||
<el-button slot="reference" type="text"> | |||||
| |||||
<span v-if="state.model.labelGroupId === null"> 标签组</span> | |||||
<el-tag v-else :closable="deletable" @close="handleRemoveLabelGroup()"> | |||||
{{state.model.labelGroupName}} | |||||
</el-tag> | |||||
</el-button> | |||||
</el-popover> | |||||
<el-link | |||||
v-if="state.model.labelGroupId !== null" | |||||
target="_blank" | |||||
type="primary" | |||||
:underline="false" | |||||
class="vm" | |||||
:href="`/data/labelgroup/detail?id=${state.model.labelGroupId}`" | |||||
style="float: right; margin-right: 8px;" | |||||
> | |||||
查看详情 | |||||
</el-link> | |||||
</div> | |||||
<div v-else class="label-input" style="color: #c0c4cc; background-color: #f5f7fa;"> | |||||
{{state.model.labelGroupName}} | |||||
<el-link | |||||
v-if="state.model.labelGroupId !== null" | |||||
target="_blank" | |||||
type="primary" | |||||
:underline="false" | |||||
class="vm" | |||||
:href="`/data/labelgroup/detail?id=${state.model.labelGroupId}`" | |||||
style="float: right; margin-right: 8px;" | |||||
> | |||||
查看详情 | |||||
</el-link> | |||||
</div> | |||||
</el-form-item> | |||||
<el-form-item label="数据集描述" prop="remark"> | |||||
<el-input | |||||
v-model="state.model.remark" | |||||
type="textarea" | |||||
placeholder="数据集描述长度不能超过100字" | |||||
maxlength="100" | |||||
rows="3" | |||||
show-word-limit | |||||
/> | |||||
</el-form-item> | |||||
</el-form> | |||||
</BaseModal> | |||||
</template> | |||||
<script> | |||||
import {isNil} from 'lodash'; | |||||
import { watch, reactive, computed, ref, onMounted } from '@vue/composition-api'; | |||||
import BaseModal from '@/components/BaseModal'; | |||||
import InfoSelect from '@/components/InfoSelect'; | |||||
import { validateName } from '@/utils/validate'; | |||||
import { annotationMap, dataTypeMap, statusCodeMap } from '@/views/dataset/util'; | |||||
import { getLabelGroupList } from '@/api/preparation/labelGroup'; | |||||
export default { | |||||
name: 'EditDataset', | |||||
components: { | |||||
BaseModal, | |||||
InfoSelect, | |||||
}, | |||||
props: { | |||||
visible: { | |||||
type: Boolean, | |||||
default: false, | |||||
}, | |||||
loading: { | |||||
type: Boolean, | |||||
default: false, | |||||
}, | |||||
handleCancel: Function, | |||||
handleOk: Function, | |||||
goLabelGroupDetail: Function, | |||||
row: { | |||||
type: Object, | |||||
default: () => {}, | |||||
}, | |||||
}, | |||||
setup(props, { refs }) { | |||||
const { handleOk } = props; | |||||
const popoverRef = ref(null); | |||||
const systemLabelGroups = []; | |||||
const customLabelGroups = []; | |||||
const rules= { | |||||
name: [ | |||||
{ required: true, message: '请输入数据集名称', trigger: ['change', 'blur'] }, | |||||
{ validator: validateName, trigger: ['change', 'blur'] }, | |||||
], | |||||
dataType: [ | |||||
{ required: true, message: '请选择数据类型', trigger: 'change' }, | |||||
], | |||||
annotateType: [ | |||||
{ required: true, message: '请选择标注类型', trigger: 'change' }, | |||||
], | |||||
remark: [ | |||||
{ required: false, message: '请输入数据集描述信息', trigger: 'blur' }, | |||||
], | |||||
}; | |||||
const buildModel = (record, options) => { | |||||
return { ...record, ...options}; | |||||
}; | |||||
const state = reactive({ | |||||
model: buildModel(props.row), | |||||
popoverVisible: false, | |||||
labelGroupTab: "custom", | |||||
customLabelGroupId: null, | |||||
systeomLabelGroupId: null, | |||||
}); | |||||
const systemLabelEnabled = computed(() => { | |||||
return props.row.annotateType !== 5; | |||||
}); | |||||
const deletable = computed(() => { | |||||
return isNil(props.row.labelGroupId); | |||||
}); | |||||
const dataTypeList = computed(() => { | |||||
return Object.keys(dataTypeMap).map(d => ({ | |||||
label: dataTypeMap[d], | |||||
value: Number(d), | |||||
})); | |||||
}); | |||||
const annotationList = computed(() => { | |||||
// 原始标注列表 | |||||
const rawAnnotationList = Object.keys(annotationMap).map(d => ({ | |||||
label: annotationMap[d].name, | |||||
value: Number(d), | |||||
})); | |||||
// 如果是图片,目标跟踪不可用 | |||||
// 如果是视频,只能用目标跟踪 | |||||
return rawAnnotationList.map(d => { | |||||
let disabled = false; | |||||
if (state.model.dataType === 0) { | |||||
disabled = d.value === 5; | |||||
} else if (state.model.dataType === 1) { | |||||
disabled = d.value !== 5; | |||||
} | |||||
return { | |||||
...d, | |||||
disabled, | |||||
}; | |||||
}); | |||||
}); | |||||
const editable = computed(() => { | |||||
return ['UNANNOTATED', 'UNSAMPLED'].includes(statusCodeMap[state.model.status]); | |||||
}); | |||||
const handleEditDataset = () => { | |||||
refs.form.validate(valid => { | |||||
if (!valid) { | |||||
return false; | |||||
} | |||||
handleOk(state.model, props.row); | |||||
return null; | |||||
}); | |||||
}; | |||||
const handleCustomId = () => { | |||||
Object.assign(state, { | |||||
popoverVisible: false, | |||||
systemLabelGroupId: null, | |||||
model: { | |||||
...state.model, | |||||
labelGroupId: state.customLabelGroupId, | |||||
labelGroupName: customLabelGroups.find(d => d.labelGroupId === state.customLabelGroupId).name, | |||||
}, | |||||
}); | |||||
}; | |||||
const handleSystemId = () => { | |||||
Object.assign(state, { | |||||
popoverVisible: false, | |||||
customLabelGroupId: null, | |||||
model: { | |||||
...state.model, | |||||
labelGroupId: state.systemLabelGroupId, | |||||
labelGroupName: systemLabelGroups.find(d => d.labelGroupId === state.systemLabelGroupId).name, | |||||
}, | |||||
}); | |||||
}; | |||||
const handleRemoveLabelGroup = () => { | |||||
Object.assign(state, { | |||||
customLabelGroupId: null, | |||||
systemLabelGroupId: null, | |||||
model: { | |||||
...state.model, | |||||
labelGroupId: null, | |||||
}, | |||||
}); | |||||
popoverRef.value.doClose(); | |||||
}; | |||||
const optionEnabled = (labelGroupId, annotateType) => { | |||||
if(annotateType === 1) { | |||||
return labelGroupId === 1; | |||||
} | |||||
if(annotateType === 5) { | |||||
return false; | |||||
} | |||||
return true; | |||||
}; | |||||
onMounted(() => { | |||||
getLabelGroupList(1).then(res => res.forEach((item) => { | |||||
systemLabelGroups.push({ | |||||
labelGroupId: item.id, | |||||
name: item.name, | |||||
}); | |||||
})); | |||||
getLabelGroupList(0).then(res => res.forEach((item) => { | |||||
customLabelGroups.push({ | |||||
labelGroupId: item.id, | |||||
name: item.name, | |||||
}); | |||||
})); | |||||
}); | |||||
watch(() => props.row, (next) => { | |||||
Object.assign(state, { | |||||
model: { ...state.model, ...next }, | |||||
}); | |||||
}); | |||||
return { | |||||
rules, | |||||
state, | |||||
deletable, | |||||
editable, | |||||
systemLabelEnabled, | |||||
optionEnabled, | |||||
systemLabelGroups, | |||||
customLabelGroups, | |||||
handleCustomId, | |||||
handleSystemId, | |||||
handleRemoveLabelGroup, | |||||
handleEditDataset, | |||||
dataTypeList, | |||||
annotationList, | |||||
popoverRef, | |||||
}; | |||||
}, | |||||
}; | |||||
</script> |
@@ -17,37 +17,50 @@ | |||||
<template> | <template> | ||||
<BaseModal | <BaseModal | ||||
:key="formKey" | :key="formKey" | ||||
title="导入自定义数据集" | |||||
:title="importStep===0 ? '导入数据集' : '创建数据集'" | |||||
width="600px" | width="600px" | ||||
center | |||||
:visible="visible" | :visible="visible" | ||||
:disabled="uploading" | |||||
@change="handleCancelUploadDataset" | @change="handleCancelUploadDataset" | ||||
@ok="handleUploadDataset('formRef')" | @ok="handleUploadDataset('formRef')" | ||||
> | > | ||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px"> | |||||
<div v-if="importStep===0" class="placeholder"> | |||||
<div class="has-tip"> | |||||
<div class="tip"> | |||||
请认真阅读下方说明,创建数据集完毕后,按照系统格式要求上传数据集文件,否则标注文件可能无法正确解析。 | |||||
<a class="db" href="http://tianshu.org.cn/static/upload/file/dubhe-dataset-template.zip" target="_blank">下载示例数据集模板</a> | |||||
</div> | |||||
<div class="requirement"> | |||||
<p>1. 系统提供了一站式脚本服务用以快速导入本地已有数据集(<a href="http://docs.dubhe.ai/docs/module/dataset/import-dataset" target="_blank">使用文档</a>),推荐使用 | |||||
<p>2. 本地数据集需要包括图片(origin 目录)、标注文件(annotation 目录)和标签文件三部分</p> | |||||
<p>3. 注意区分「图像分类」和「目标检测」类型数据集</p> | |||||
<p>4. 图片格式支持 jpg/png/bmp/jpeg,不大于 5M,位于 origin 目录下,不支持目录嵌套</p> | |||||
<p>5. 标注文件为 json 格式,位于 annotation 目录下,必须和文件同名(如果不存在标注,可不上传),不支持目录嵌套</p> | |||||
<p>6. 标签文件为 json 格式,命名要求为 label_{name}.json,其中 name 为标签组名称,不能与系统已有标签组重名</p> | |||||
<p>7. 更多参考示例数据集模板</p> | |||||
</div> | |||||
</div> | |||||
</div> | |||||
<el-form v-else ref="formRef" :model="form" :rules="rules" label-width="100px"> | |||||
<el-alert class="info-alert" type="warning" show-icon :closable="false"> | |||||
<div slot='title' class='slot-content'> | |||||
<div>数据集创建完毕后,需要使用脚本工具上传本地已有数据集</div> | |||||
<a href="http://docs.dubhe.ai/docs/module/dataset/import-dataset" target="_blank">使用文档</a> | |||||
</div> | |||||
</el-alert> | |||||
<el-form-item label="数据集名称" prop="name"> | <el-form-item label="数据集名称" prop="name"> | ||||
<el-input v-model="form.name" placeholder="数据集名称不能超过50字" maxlength="50" /> | <el-input v-model="form.name" placeholder="数据集名称不能超过50字" maxlength="50" /> | ||||
</el-form-item> | </el-form-item> | ||||
<el-form-item label="数据类型" prop="dataType"> | <el-form-item label="数据类型" prop="dataType"> | ||||
<el-select disabled value="图片" width="100px" /> | |||||
<el-select disabled value="图片" style="width: 200px;" /> | |||||
</el-form-item> | </el-form-item> | ||||
<el-form-item ref="datasetFile" v-model="form.datasetFile" label="上传数据集" prop="datasetFile"> | |||||
<upload-inline | |||||
ref="uploadForm" | |||||
action="fakeApi" | |||||
accept=".zip" | |||||
list-type="text" | |||||
:acceptSize="0" | |||||
:show-file-count="false" | |||||
:params="uploadParams" | |||||
:auto-upload="true" | |||||
:hash="false" | |||||
:limit="1" | |||||
@uploadStart="uploadStart" | |||||
@uploadSuccess="uploadSuccess" | |||||
@uploadError="uploadError" | |||||
<el-form-item label="标注类型" prop="annotateType"> | |||||
<InfoSelect | |||||
v-model="form.annotateType" | |||||
placeholder="标注类型" | |||||
:dataSource="annotationList" | |||||
width="200px" | |||||
/> | /> | ||||
<div v-if="uploading"><i class="el-icon-loading" />数据集上传中...</div> | |||||
</el-form-item> | </el-form-item> | ||||
<el-form-item label="数据集描述"> | <el-form-item label="数据集描述"> | ||||
<el-input | <el-input | ||||
@@ -60,44 +73,48 @@ | |||||
/> | /> | ||||
</el-form-item> | </el-form-item> | ||||
</el-form> | </el-form> | ||||
<el-button v-if="importStep===0" slot="footer" class="tc" type="primary" @click="nextImportStep">已阅读,确定创建</el-button> | |||||
</BaseModal> | </BaseModal> | ||||
</template> | </template> | ||||
<script> | <script> | ||||
import { bucketName } from '@/utils/minIO'; | |||||
import UploadInline from '@/components/UploadForm/inline'; | |||||
import BaseModal from '@/components/BaseModal'; | import BaseModal from '@/components/BaseModal'; | ||||
import { addCustomDataset } from '@/api/preparation/dataset'; | |||||
import InfoSelect from '@/components/InfoSelect'; | |||||
import { validateName } from "@/utils/validate"; | import { validateName } from "@/utils/validate"; | ||||
import { annotationMap } from '@/views/dataset/util'; | |||||
import { add } from '@/api/preparation/dataset'; | |||||
export default { | export default { | ||||
name: "UploadDatasetForm", | |||||
name: "ImportDataset", | |||||
components: { | components: { | ||||
UploadInline, | |||||
BaseModal, | BaseModal, | ||||
InfoSelect, | |||||
}, | }, | ||||
props: { | props: { | ||||
visible: { | visible: { | ||||
type: Boolean, | type: Boolean, | ||||
default: false, | default: false, | ||||
}, | }, | ||||
closeUploadDatasetForm: { | |||||
toggleImportDataset: { | |||||
type: Function, | |||||
}, | |||||
onResetFresh: { | |||||
type: Function, | type: Function, | ||||
}, | }, | ||||
}, | }, | ||||
data() { | data() { | ||||
return { | return { | ||||
importStep: 0, | |||||
formKey: 1, | formKey: 1, | ||||
form: { | form: { | ||||
name: "", | name: "", | ||||
dataType: 0, | dataType: 0, | ||||
annotateType: 2, | annotateType: 2, | ||||
status: 4, | status: 4, | ||||
datasetFile: undefined, | |||||
remark: "", | remark: "", | ||||
}, | }, | ||||
uploading: false, | |||||
rules: { | rules: { | ||||
name: [ | name: [ | ||||
{ | { | ||||
@@ -107,67 +124,71 @@ export default { | |||||
}, | }, | ||||
{ validator: validateName, trigger: ["change", "blur"] }, | { validator: validateName, trigger: ["change", "blur"] }, | ||||
], | ], | ||||
datasetFile: [ | |||||
annotateType: [ | |||||
{ | { | ||||
required: true, | required: true, | ||||
message: "请选择上传数据集", | |||||
trigger: ["blur", "manual"], | |||||
message: "请选择标注类型", | |||||
trigger: ["change", "blur"], | |||||
}, | }, | ||||
], | ], | ||||
}, | }, | ||||
}; | }; | ||||
}, | }, | ||||
computed: { | computed: { | ||||
uploadParams() { | |||||
return { | |||||
objectPath: `dataset/importdataset`, // 导入自定义数据集存储路径 | |||||
}; | |||||
annotationList() { | |||||
const activeList = Object.keys(annotationMap).filter(type => ["1", "2"].includes(type)).map(d => ({ | |||||
label: annotationMap[d].name, | |||||
value: Number(d), | |||||
})); | |||||
return activeList; | |||||
}, | }, | ||||
}, | }, | ||||
methods: { | methods: { | ||||
nextImportStep() { | |||||
this.importStep += 1; | |||||
}, | |||||
handleCancelUploadDataset() { | handleCancelUploadDataset() { | ||||
this.formKey += 1; | this.formKey += 1; | ||||
this.closeUploadDatasetForm(); | |||||
this.importStep = 0; | |||||
this.toggleImportDataset(); | |||||
this.onResetFresh(); | |||||
}, | }, | ||||
handleUploadDataset(formName) { | handleUploadDataset(formName) { | ||||
this.$refs[formName].validate(valid => { | this.$refs[formName].validate(valid => { | ||||
if (!valid) { | if (!valid) { | ||||
return; | return; | ||||
} | } | ||||
const customForm = { | |||||
const customForm = { | |||||
name: this.form.name, | name: this.form.name, | ||||
desc: this.form.remark, | |||||
archiveUrl: `${bucketName}/${this.form.datasetFile}`, | |||||
remark: this.form.remark, | |||||
annotateType: this.form.annotateType, | |||||
dataType: this.form.dataType, | |||||
type: 0, | |||||
import: true, | |||||
}; | }; | ||||
return addCustomDataset(customForm).then(() => { | |||||
return add(customForm).then(() => { | |||||
this.$message({ | this.$message({ | ||||
message: '导入数据集成功', | |||||
message: '创建数据集成功', | |||||
type: 'success', | type: 'success', | ||||
}); | }); | ||||
}).finally(() => { | }).finally(() => { | ||||
this.resetFormFields(); | this.resetFormFields(); | ||||
this.closeUploadDatasetForm(); | |||||
this.toggleImportDataset(); | |||||
this.onResetFresh(); | |||||
}); | }); | ||||
}); | }); | ||||
}, | }, | ||||
resetFormFields() { | resetFormFields() { | ||||
this.formKey += 1; | this.formKey += 1; | ||||
this.form = {}; | |||||
}, | |||||
uploadStart() { | |||||
this.uploading = true; | |||||
}, | |||||
uploadSuccess(res) { | |||||
this.form.datasetFile = res[0].data.objectName; | |||||
this.uploading = false; | |||||
this.$refs.datasetFile.validate('manual'); | |||||
}, | |||||
uploadError() { | |||||
this.$message({ | |||||
message: '上传文件失败', | |||||
type: 'error', | |||||
}); | |||||
this.uploading = false; | |||||
this.importStep = 0; | |||||
this.form = { | |||||
name: "", | |||||
dataType: 0, | |||||
annotateType: 2, | |||||
status: 4, | |||||
remark: "", | |||||
}; | |||||
}, | }, | ||||
}, | }, | ||||
}; | }; |
@@ -20,7 +20,7 @@ export default { | |||||
name: 'DatasetStatus', | name: 'DatasetStatus', | ||||
functional: true, | functional: true, | ||||
render(h, { data, props }) { | render(h, { data, props }) { | ||||
const { withAllDatasetStatusList, filterByDatasetStatus, datasetStatusFilter } = props; | |||||
const { statusList, filterByDatasetStatus, datasetStatusFilter } = props; | |||||
const iconClass = ['el-icon-arrow-down', 'el-icon--right']; | const iconClass = ['el-icon-arrow-down', 'el-icon--right']; | ||||
const textClass = datasetStatusFilter === 'all' ? null : 'primary'; | const textClass = datasetStatusFilter === 'all' ? null : 'primary'; | ||||
const columnProps = { | const columnProps = { | ||||
@@ -34,7 +34,7 @@ export default { | |||||
<i {... { class: iconClass } } /> | <i {... { class: iconClass } } /> | ||||
</span> | </span> | ||||
<el-dropdown-menu slot='dropdown'> | <el-dropdown-menu slot='dropdown'> | ||||
{withAllDatasetStatusList.map(item => { | |||||
{statusList.map(item => { | |||||
return ( | return ( | ||||
<el-dropdown-item | <el-dropdown-item | ||||
key={item.value} | key={item.value} | ||||
@@ -45,17 +45,10 @@ export default { | |||||
); | ); | ||||
})} | })} | ||||
</el-dropdown-menu> | </el-dropdown-menu> | ||||
<el-tooltip effect='dark' content='数据集状态可能会延迟更新,请耐心等待' placement='top' style={{ marginLeft: '10px' }}> | |||||
<i class='el-icon-question'/> | |||||
</el-tooltip> | |||||
</el-dropdown> | </el-dropdown> | ||||
); | ); | ||||
}, | }, | ||||
default: ({ row }) => { | default: ({ row }) => { | ||||
// 导入自定义数据集 状态保持为标注完成(4) | |||||
if (row.import) { | |||||
row.status = 4; | |||||
} | |||||
const status = datasetStatusMap[row.status] || {}; | const status = datasetStatusMap[row.status] || {}; | ||||
const colorProps = (!status.type && status.bgColor) && { | const colorProps = (!status.type && status.bgColor) && { | ||||
props: { | props: { | ||||
@@ -0,0 +1,259 @@ | |||||
/** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||||
* | |||||
* Licensed under the Apache License, Version 2.0 (the "License"); | |||||
* you may not use this file except in compliance with the License. | |||||
* You may obtain a copy of the License at | |||||
* | |||||
* http://www.apache.org/licenses/LICENSE-2.0 | |||||
* | |||||
* 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. | |||||
* ============================================================= | |||||
*/ | |||||
<template> | |||||
<el-dialog | |||||
:key="state.uploadKey" | |||||
:closeOnClickModal="false" | |||||
append-to-body | |||||
width="610px" | |||||
:visible="visible" | |||||
:title="state.title" | |||||
@close="handleClose" | |||||
> | |||||
<!--选择上传的文件--> | |||||
<div v-show="state.uploadStep === 0"> | |||||
<upload-inline | |||||
ref="fileUploadForm" | |||||
action="fakeApi" | |||||
:accept="state.accept" | |||||
:params="state.uploadParams" | |||||
:transformFile="withDimensionFile" | |||||
v-bind="state.optionUploadProps" | |||||
@uploadSuccess="uploadSuccess" | |||||
@uploadError="uploadError" | |||||
/> | |||||
<!--上传视频时显示帧间隔设置--> | |||||
<el-form | |||||
v-if="!state.isImage" | |||||
ref="formStep" | |||||
:model="state.form" | |||||
label-width="100px" | |||||
style="margin-top: 10px;" | |||||
> | |||||
<el-form-item | |||||
label="视频帧间隔" | |||||
prop="frameInterval" | |||||
:rules="[{required: true, message: '请输入有效的帧间隔', trigger: 'blur'}]" | |||||
> | |||||
<el-input-number v-model="state.form.frameInterval" :min="1" /> | |||||
</el-form-item> | |||||
</el-form> | |||||
</div> | |||||
<!--上传文件进度展示--> | |||||
<div v-show="state.uploadStep === 1"> | |||||
<el-progress | |||||
v-if="state.isImage" | |||||
type="circle" | |||||
:percentage="state.percentage" | |||||
:status="state.uploadStatus" | |||||
:format="format" | |||||
/> | |||||
<div v-else class="circleProgressWrapper"> | |||||
<div class="circleText">正在上传</div> | |||||
<div class="wrapper right"> | |||||
<div class="circleProgress rightCircle"></div> | |||||
</div> | |||||
<div class="wrapper left"> | |||||
<div class="circleProgress leftCircle"></div> | |||||
</div> | |||||
</div> | |||||
</div> | |||||
<!--上传成功--> | |||||
<div v-show="state.uploadStep === 2"> | |||||
<el-progress type="circle" :percentage="100" status="success" /> | |||||
</div> | |||||
<div slot="footer"> | |||||
<div v-show="state.uploadStep === 0"> | |||||
<el-button @click="handleClose">取消</el-button> | |||||
<el-button type="primary" @click="uploadSubmit('fileUploadForm')">开始上传</el-button> | |||||
</div> | |||||
<div v-show="state.uploadStep === 1"> | |||||
<el-button @click="handleClose">取消</el-button> | |||||
</div> | |||||
<div v-show="state.uploadStep === 2"> | |||||
<el-button type="primary" @click="handleClose">完成</el-button> | |||||
</div> | |||||
</div> | |||||
</el-dialog> | |||||
</template> | |||||
<script> | |||||
import Vue from 'vue'; | |||||
import { reactive, watch } from '@vue/composition-api'; | |||||
import { toFixed } from '@/utils'; | |||||
import UploadInline from '@/components/UploadForm/inline'; | |||||
import { getImgFromMinIO, withDimensionFile, trackUploadProps } from '@/views/dataset/util'; | |||||
import { submit, submitVideo } from '@/api/preparation/datafile'; | |||||
import { Message } from 'element-ui'; | |||||
export default { | |||||
name: 'UploadDataFile', | |||||
components: { | |||||
UploadInline, | |||||
}, | |||||
props: { | |||||
row: { | |||||
type: Object, | |||||
default: () => {}, | |||||
}, | |||||
visible: { | |||||
type: Boolean, | |||||
default: false, | |||||
}, | |||||
loading: { | |||||
type: Boolean, | |||||
default: false, | |||||
}, | |||||
closeUploadDataFile: { | |||||
type: Function, | |||||
}, | |||||
}, | |||||
setup(props, context) { | |||||
const defaultFrameInterval = 5; | |||||
const { closeUploadDataFile } = props; | |||||
const state = reactive({ | |||||
uploadKey: 1, | |||||
row: {}, | |||||
uploadStep: 0, | |||||
isImage: undefined, | |||||
accept: "", | |||||
title: "", | |||||
uploadParams: {}, | |||||
optionUploadProps: {}, | |||||
percentage: 0, | |||||
uploadStatus: undefined, | |||||
form: { | |||||
frameInterval: defaultFrameInterval, | |||||
}, | |||||
}); | |||||
// 监测选中导入的列数据变化 | |||||
watch(() => props.row, (next) => { | |||||
Object.assign(state, { | |||||
row: { ...state.row, ...next }, | |||||
}); | |||||
const { id } = state.row; | |||||
if (state.row.dataType === 0) { | |||||
Object.assign(state, { | |||||
isImage: true, | |||||
title: "导入图片", | |||||
accept: ".jpg,.png,.bmp,.jpeg", | |||||
uploadParams: { | |||||
datasetId: id, | |||||
objectPath: `dataset/${id}/origin`, // 图片对象存储路径 | |||||
}, | |||||
optionUploadProps: {}, | |||||
}); | |||||
} else { | |||||
Object.assign(state, { | |||||
isImage: false, | |||||
title: "导入视频", | |||||
uploadParams: { | |||||
datasetId: id, | |||||
objectPath: `dataset/${id}/video`, // 图片对象存储路径 | |||||
}, | |||||
accept: ".mp4,.avi,.mkv,.mov,.webm,.wmv", | |||||
optionUploadProps: trackUploadProps, | |||||
}); | |||||
} | |||||
}); | |||||
// 上传包括图片和视频 | |||||
const uploader = async (datasetId, files) => { | |||||
// 文件上传 | |||||
if (state.isImage) { | |||||
return submit(datasetId, files); | |||||
} | |||||
return submitVideo(datasetId, { | |||||
frameInterval: state.form.frameInterval, | |||||
url: files[0].url, | |||||
}); | |||||
}; | |||||
// 上传视频时不显示实时进度 | |||||
const format = (percentage) => { | |||||
return percentage < 100 ? `${percentage}%` : ``; | |||||
}; | |||||
// 上传成功 | |||||
const uploadSuccess = (res) => { | |||||
// 视频上传完毕 | |||||
if (!state.isImage) { | |||||
state.percentage = 100; | |||||
} | |||||
const files = getImgFromMinIO(res); | |||||
// 自动标注完成时 导入 提示信息不同 | |||||
const successMessage = "上传文件成功"; | |||||
if (files.length > 0) { | |||||
uploader(state.row.id, files).then(() => { | |||||
Message.success({ message: successMessage, duration: 1000 }); | |||||
}); | |||||
} | |||||
Object.assign(state, { | |||||
loading: false, | |||||
uploadStatus: "success", | |||||
uploadStep: 2, | |||||
title: "上传成功", | |||||
}); | |||||
}; | |||||
// 上传失败 | |||||
const uploadError = () => { | |||||
state.loading = false; | |||||
state.uploadStatus = "exception"; | |||||
Message.error({ message: "上传失败", duration: 1000 }); | |||||
}; | |||||
// 确定上传 | |||||
const uploadSubmit = formName => { | |||||
context.refs[formName].uploadSubmit((resolved, total) => { | |||||
// eslint-disable-next-line func-names | |||||
Vue.nextTick(function() { | |||||
state.percentage = | |||||
state.percentage > 100 ? 100 : toFixed(resolved / total); | |||||
}); | |||||
}); | |||||
Object.assign(state, { | |||||
loading: true, | |||||
uploadStep: 1, | |||||
title: "上传中", | |||||
}); | |||||
}; | |||||
const handleClose = () => { | |||||
closeUploadDataFile(); | |||||
Object.assign(state, { | |||||
uploadStep: 0, | |||||
uploadKey: state.uploadKey + 1, | |||||
percentage: 0, | |||||
uploadStatus: undefined, | |||||
}); | |||||
}; | |||||
return { | |||||
state, | |||||
uploadSubmit, | |||||
format, | |||||
handleClose, | |||||
withDimensionFile, | |||||
uploadSuccess, | |||||
uploadError, | |||||
}; | |||||
}, | |||||
}; | |||||
</script> |
@@ -0,0 +1,262 @@ | |||||
/** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||||
* | |||||
* Licensed under the Apache License, Version 2.0 (the "License"); | |||||
* you may not use this file except in compliance with the License. | |||||
* You may obtain a copy of the License at | |||||
* | |||||
* http://www.apache.org/licenses/LICENSE-2.0 | |||||
* | |||||
* 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. | |||||
* ============================================================= | |||||
*/ | |||||
@import "~@/assets/styles/variables.scss"; | |||||
.table-top-row { | |||||
background-color: $menuBg !important; | |||||
} | |||||
.link-primary { | |||||
color: $primaryColor; | |||||
cursor: pointer; | |||||
} | |||||
.label-input { | |||||
max-height: 200px; | |||||
overflow-y: auto; | |||||
border-color: #b4bccc; | |||||
border-style: solid; | |||||
border-width: 1px; | |||||
border-radius: 5px; | |||||
.el-button { | |||||
padding: 0; | |||||
} | |||||
} | |||||
.label-group-select { | |||||
width: 240px; | |||||
} | |||||
.label-group-popover { | |||||
padding: 0; | |||||
} | |||||
.label-input .el-tag { | |||||
margin-left: 4px; | |||||
} | |||||
.tt-wrapper.progress-tip { | |||||
.tooltip-item-label { | |||||
min-width: 100px; | |||||
} | |||||
} | |||||
.dataset-name-col { | |||||
.cell { | |||||
text-overflow: unset; | |||||
} | |||||
.name-col { | |||||
max-width: 90%; | |||||
span { | |||||
overflow: hidden; | |||||
text-overflow: ellipsis; | |||||
} | |||||
} | |||||
} | |||||
.placeholder { | |||||
p { | |||||
margin: 0; | |||||
} | |||||
.has-tip { | |||||
line-height: 1.5; | |||||
.tip { | |||||
padding: 10px; | |||||
margin-bottom: 20px; | |||||
color: #f38900; | |||||
background: #ffe9cc; | |||||
} | |||||
.requirement { | |||||
font-size: 14px; | |||||
color: $infoColor; | |||||
} | |||||
} | |||||
a { | |||||
color: $primaryColor; | |||||
} | |||||
} | |||||
.el-progress { | |||||
display: block; | |||||
} | |||||
.el-progress-bar { | |||||
padding-right: 70px; | |||||
margin-right: -70px; | |||||
} | |||||
.el-progress--circle { | |||||
.el-progress__text { | |||||
i { | |||||
font-size: 30px !important; | |||||
} | |||||
} | |||||
} | |||||
.el-progress-circle { | |||||
margin: 0 auto; | |||||
} | |||||
.progress-wrap { | |||||
.el-icon-loading + span { | |||||
display: block; | |||||
flex: 1; | |||||
margin-left: 4px; | |||||
} | |||||
} | |||||
.decompress-progress { | |||||
flex: 1; | |||||
margin: 0 auto; | |||||
.el-progress-bar__inner { | |||||
background: | |||||
-webkit-repeating-linear-gradient( | |||||
-30deg, | |||||
#83a7cf 0, | |||||
#83a7cf 10px, | |||||
#93b3d6 10px, | |||||
#93b3d6 20px | |||||
); | |||||
animation: process 5s linear infinite; | |||||
} | |||||
.el-progress__text { | |||||
display: inline; | |||||
} | |||||
@keyframes process { | |||||
0% { | |||||
background-position: 0 0; | |||||
} | |||||
100% { | |||||
background-position: 180px 0; | |||||
} | |||||
} | |||||
} | |||||
.reannotate-popconfirm { | |||||
.el-popconfirm__main { | |||||
align-items: baseline; | |||||
} | |||||
} | |||||
// 模拟的圆形进度条 | |||||
.circleProgressWrapper { | |||||
position: relative; | |||||
width: 126px; | |||||
height: 126px; | |||||
margin: 0 auto; | |||||
.circleText { | |||||
line-height: 126px; | |||||
text-align: center; | |||||
} | |||||
.wrapper { | |||||
position: absolute; | |||||
top: 0; | |||||
width: 63px; | |||||
height: 126px; | |||||
overflow: hidden; | |||||
} | |||||
.right { | |||||
right: 0; | |||||
} | |||||
.left { | |||||
left: 0; | |||||
} | |||||
.circleProgress { | |||||
position: absolute; | |||||
top: 0; | |||||
width: 126px; | |||||
height: 126px; | |||||
border: 8px solid #87d068; | |||||
border-radius: 50%; | |||||
transform: rotate(45deg); | |||||
} | |||||
.rightCircle { | |||||
right: 0; | |||||
border-top: 8px solid #87d068; | |||||
border-right: 8px solid #87d068; | |||||
animation: rightCircleProgressLoad 5s linear infinite; | |||||
} | |||||
.leftCircle { | |||||
left: 0; | |||||
border-bottom: 8px solid #87d068; | |||||
border-left: 8px solid #87d068; | |||||
animation: leftCircleProgressLoad 5s linear infinite; | |||||
} | |||||
@keyframes rightCircleProgressLoad { | |||||
0% { | |||||
border-top: 8px solid #87d068; | |||||
border-right: 8px solid #87d068; | |||||
transform: rotate(45deg); | |||||
} | |||||
50% { | |||||
border-top: 8px solid #108ee9; | |||||
border-right: 8px solid #108ee9; | |||||
border-bottom: 8px solid rgb(81, 197, 81); | |||||
border-left: 8px solid rgb(81, 197, 81); | |||||
transform: rotate(225deg); | |||||
} | |||||
100% { | |||||
border-bottom: 8px solid #87d068; | |||||
border-left: 8px solid #87d068; | |||||
transform: rotate(225deg); | |||||
} | |||||
} | |||||
@keyframes leftCircleProgressLoad { | |||||
0% { | |||||
border-bottom: 8px solid #87d068; | |||||
border-left: 8px solid #87d068; | |||||
transform: rotate(45deg); | |||||
} | |||||
50% { | |||||
border-top: 8px solid rgb(81, 197, 81); | |||||
border-right: 8px solid rgb(81, 197, 81); | |||||
border-bottom: 8px solid #108ee9; | |||||
border-left: 8px solid #108ee9; | |||||
transform: rotate(45deg); | |||||
} | |||||
100% { | |||||
border-top: 8px solid #87d068; | |||||
border-right: 8px solid #87d068; | |||||
border-bottom: 8px solid #87d068; | |||||
border-left: 8px solid #87d068; | |||||
transform: rotate(225deg); | |||||
} | |||||
} | |||||
} |
@@ -42,6 +42,22 @@ export const parseAnnotation = (annotationStr, labels) => { | |||||
return result; | return result; | ||||
}; | }; | ||||
// 将 annotation 生成可拖拽的形式 | |||||
export const withExtent = annotations => { | |||||
return annotations.map(d => ({ | |||||
...d, | |||||
data: { | |||||
...d.data, | |||||
extent: { | |||||
x0: d.data.bbox.x, | |||||
y0: d.data.bbox.y, | |||||
x1: d.data.bbox.x + d.data.bbox.width, | |||||
y1: d.data.bbox.y + d.data.bbox.height, | |||||
}, | |||||
}, | |||||
})); | |||||
}; | |||||
// 将annotations 生成字符串 | // 将annotations 生成字符串 | ||||
export const stringifyAnnotations = (annotations) => { | export const stringifyAnnotations = (annotations) => { | ||||
const resultList = annotations.map(d => { | const resultList = annotations.map(d => { | ||||
@@ -157,6 +173,16 @@ export const withDimensionFiles = async(files) => { | |||||
return Promise.all(files.map(file => checkImg(file))); | return Promise.all(files.map(file => checkImg(file))); | ||||
}; | }; | ||||
// 目标跟踪视频上传参数 | |||||
export const trackUploadProps = { | |||||
acceptSize: 1024, | |||||
accept: '.mp4,.avi,.mkv,.mov,.webm,.wmv', | |||||
listType: 'text', | |||||
limit: 1, | |||||
multiple: false, | |||||
showFileCount: false, | |||||
}; | |||||
// context 配置 | // context 配置 | ||||
export const labelsSymbol = Symbol('labels'); | export const labelsSymbol = Symbol('labels'); | ||||
export const enhanceSymbol = Symbol('enhance'); | export const enhanceSymbol = Symbol('enhance'); | ||||
@@ -170,10 +196,25 @@ export const dataTypeMap = { | |||||
// 文件状态 | // 文件状态 | ||||
export const fileTypeEnum = { | export const fileTypeEnum = { | ||||
0: { label: '全部', abbr: '全部' }, | 0: { label: '全部', abbr: '全部' }, | ||||
1: { label: '未标注', abbr: '未标注' }, | |||||
2: { label: '自动标注完成', abbr: '自动完成' }, | |||||
3: { label: '手动标注完成', abbr: '手动完成' }, | |||||
4: { label: '自动目标跟踪完成', abbr: '跟踪完成' }, | |||||
101: { label: '未标注', abbr: '未标注' }, | |||||
102: { label: '手动标注中', abbr: '手动标注中' }, | |||||
103: { label: '自动标注完成', abbr: '自动完成' }, | |||||
104: { label: '手动标注完成', abbr: '手动完成' }, | |||||
105: { label: '未识别', abbr: '未识别'}, | |||||
201: { label: '目标跟踪完成', abbr: '跟踪完成' }, | |||||
301: { label: '未完成', abbr: '未完成'}, | |||||
302: { label: '已完成', abbr: '已完成'}, | |||||
}; | |||||
export const fileCodeMap = { | |||||
'ALL': 0, | |||||
'UNANNOTATED': 101, | |||||
'MANUAL_ANNOTATING': 102, | |||||
'AUTO_ANNOTATED': 103, | |||||
'MANUAL_ANNOTATED': 104, | |||||
'UNRECOGNIZED': 105, | |||||
'TRACK_SUCCEED': 201, | |||||
'UNCOMPLETED': 301, | |||||
'COMPLETED': 302, | |||||
}; | }; | ||||
export const annotationMap = { | export const annotationMap = { | ||||
@@ -186,15 +227,34 @@ export const annotationMap = { | |||||
// 数据集状态 | // 数据集状态 | ||||
export const datasetStatusMap = { | export const datasetStatusMap = { | ||||
0: { name: '未标注', type: 'info' }, | |||||
1: { name: '标注中', type: 'warning' }, | |||||
2: { name: '自动标注中', type: 'danger' }, | |||||
3: { name: '自动标注完成', type: '' }, | |||||
4: { name: '标注完成', type: 'success' }, | |||||
5: { name: '未采样', bgColor: '#a7a7a7', color: '#fff' }, | |||||
6: { name: '目标跟踪完成', bgColor: '#409EFF', color: '#fff' }, | |||||
7: { name: '采样中', bgColor: '#606266', color: '#fff' }, | |||||
8: { name: '数据增强中', bgColor: '#1890ff', color: '#fff' }, | |||||
101: { name: '未标注', type: 'info' }, | |||||
102: { name: '标注中', type: 'warning' }, | |||||
103: { name: '自动标注中', type: 'danger' }, | |||||
104: { name: '自动标注完成', type: '' }, | |||||
105: { name: '标注完成', type: 'success' }, | |||||
201: { name: '目标跟踪中', bgColor: '#409EFF', color: '#fff' }, | |||||
202: { name: '目标跟踪完成', bgColor: '#409EFF', color: '#fff' }, | |||||
203: { name: '目标跟踪失败', bgColor: '#409EFF', color: '#fff' }, | |||||
301: { name: '未采样', bgColor: '#a7a7a7', color: '#fff' }, | |||||
302: { name: '采样中', bgColor: '#606266', color: '#fff' }, | |||||
303: { name: '采样失败', bgColor: '#606266', color: '#fff' }, | |||||
401: { name: '数据增强中', bgColor: '#1890ff', color: '#fff' }, | |||||
402: { name: '导入中', bgColor: '#606266', color: '#fff' }, | |||||
}; | |||||
export const statusCodeMap = { | |||||
101: 'UNANNOTATED', // 未标注 | |||||
102: 'ANNOTATING', | |||||
103: 'AUTO_ANNOTATING', | |||||
104: 'AUTO_ANNOTATED', | |||||
105: 'ANNOTATED', | |||||
201: 'TRACKING', | |||||
202: 'TRACK_SUCCEED', | |||||
203: 'TRACK_FAILED', | |||||
301: 'UNSAMPLED', | |||||
302: 'SAMPLING', | |||||
303: 'SAMPLE_FAILED', | |||||
401: 'ENHANCING', | |||||
402: 'IMPORTING', | |||||
}; | }; | ||||
// 标注精度 | // 标注精度 | ||||
@@ -203,6 +263,7 @@ export const annotationProgressMap = { | |||||
unfinished: '未完成', | unfinished: '未完成', | ||||
autoFinished: '自动标注完成', | autoFinished: '自动标注完成', | ||||
finishAutoTrack: '目标跟踪完成', | finishAutoTrack: '目标跟踪完成', | ||||
annotationNotDistinguishFile: '未识别', | |||||
}; | }; | ||||
export const decompressProgressMap = { | export const decompressProgressMap = { | ||||
@@ -219,3 +280,9 @@ export const dataEnhanceMap = { | |||||
3: 'info', | 3: 'info', | ||||
4: 'warning', | 4: 'warning', | ||||
}; | }; | ||||
// 根据value取key | |||||
export const findKey = (value, data, compare = (a, b) => a === b) => | |||||
{ | |||||
return Object.keys(data).find(k => compare(data[k], value)); | |||||
}; |
@@ -27,18 +27,18 @@ | |||||
> | > | ||||
<el-form ref="form" :model="form" :rules="rules" label-width="100px"> | <el-form ref="form" :model="form" :rules="rules" label-width="100px"> | ||||
<el-form-item label="名称" prop="noteBookName"> | <el-form-item label="名称" prop="noteBookName"> | ||||
<el-input v-model="form.noteBookName" class="input" maxlength="30" style="width: 600px;" show-word-limit placeholder="请输入notebook名称" /> | |||||
<el-input id="noteBookName" v-model="form.noteBookName" class="input" maxlength="30" style="width: 600px;" show-word-limit placeholder="请输入notebook名称" /> | |||||
</el-form-item> | </el-form-item> | ||||
<el-form-item label="描述" prop="description"> | <el-form-item label="描述" prop="description"> | ||||
<el-input v-model="form.description" type="textarea" maxlength="255" show-word-limit style="width: 600px;" /> | |||||
<el-input id="description" v-model="form.description" type="textarea" maxlength="255" show-word-limit style="width: 600px;" /> | |||||
</el-form-item> | </el-form-item> | ||||
<el-form-item label="开发环境" prop="k8sImageName"> | <el-form-item label="开发环境" prop="k8sImageName"> | ||||
<el-select v-model="form.k8sImageName" placeholder="请选择开发环境" no-data-text="请先选择项目" style="width: 600px;" @change="validateField('k8sImageName')"> | |||||
<el-select id="k8sImageName" v-model="form.k8sImageName" placeholder="请选择开发环境" no-data-text="请先选择项目" style="width: 600px;" @change="validateField('k8sImageName')"> | |||||
<el-option v-for="(item, index) in imageOptions" :key="index" :label="item.label" :value="item.value" /> | <el-option v-for="(item, index) in imageOptions" :key="index" :label="item.label" :value="item.value" /> | ||||
</el-select> | </el-select> | ||||
</el-form-item> | </el-form-item> | ||||
<el-form-item label="类型" prop="deviceType"> | <el-form-item label="类型" prop="deviceType"> | ||||
<el-radio-group v-model="form.deviceType" @change="onDeviceChange"> | |||||
<el-radio-group id="deviceType" v-model="form.deviceType" @change="onDeviceChange"> | |||||
<el-radio-button v-for="(item,index) in deviceOptions" :key="index" :label="item">{{ item==='GPU'?'CPU + GPU':item }}</el-radio-button> | <el-radio-button v-for="(item,index) in deviceOptions" :key="index" :label="item">{{ item==='GPU'?'CPU + GPU':item }}</el-radio-button> | ||||
</el-radio-group> | </el-radio-group> | ||||
</el-form-item> | </el-form-item> | ||||
@@ -19,17 +19,26 @@ | |||||
<!--工具栏--> | <!--工具栏--> | ||||
<div class="head-container"> | <div class="head-container"> | ||||
<cdOperation linkType="custom" @to-add="toAdd"> | <cdOperation linkType="custom" @to-add="toAdd"> | ||||
<span slot="left"> | |||||
<el-tooltip | |||||
content="Notebook 将会在开启后四小时自动关闭,请及时保存您的代码" | |||||
placement="top" | |||||
> | |||||
<i class="el-icon-warning-outline primary f18" /> | |||||
</el-tooltip> | |||||
</span> | |||||
<span slot="right"> | <span slot="right"> | ||||
<!-- 搜索 --> | <!-- 搜索 --> | ||||
<el-select v-model="query.status" class="filter-item" placeholder="状态" clearable @change="crud.toQuery"> | |||||
<el-option | |||||
v-for="item in statusOptions" | |||||
:key="item.statusCode" | |||||
:value="item.statusCode" | |||||
:label="item.statusName" | |||||
/> | |||||
</el-select> | |||||
<el-input v-model="query.noteBookName" clearable placeholder="请输入名称" class="filter-item" style="width: 200px;" @keyup.enter.native="crud.toQuery" /> | |||||
<el-input | |||||
id="queryName" | |||||
v-model="localQuery.noteBookName" | |||||
clearable | |||||
placeholder="请输入名称" | |||||
class="filter-item" | |||||
style="width: 200px;" | |||||
@clear="crud.toQuery" | |||||
@keyup.enter.native="crud.toQuery" | |||||
/> | |||||
<rrOperation /> | <rrOperation /> | ||||
</span> | </span> | ||||
</cdOperation> | </cdOperation> | ||||
@@ -53,6 +62,14 @@ | |||||
</el-table-column> | </el-table-column> | ||||
<el-table-column prop="description" label="描述" /> | <el-table-column prop="description" label="描述" /> | ||||
<el-table-column prop="status" label="状态" width="100"> | <el-table-column prop="status" label="状态" width="100"> | ||||
<template #header> | |||||
<dropdown-header | |||||
title="状态" | |||||
:list="notebookStatusList" | |||||
:filtered="Boolean(localQuery.status) || localQuery.status === 0" | |||||
@command="filterByStatus" | |||||
/> | |||||
</template> | |||||
<template slot-scope="scope"> | <template slot-scope="scope"> | ||||
<el-tag v-if="!(scope.row.status==0 && !scope.row.url)" :type="getTagType(scope.row.status)" effect="plain">{{ notebookStatus[scope.row.status] }} </el-tag> | <el-tag v-if="!(scope.row.status==0 && !scope.row.url)" :type="getTagType(scope.row.status)" effect="plain">{{ notebookStatus[scope.row.status] }} </el-tag> | ||||
<el-tag v-if="(scope.row.status==0 && !scope.row.url)" :type="getTagType(3)" effect="plain">{{ notebookStatus[3] }} </el-tag> | <el-tag v-if="(scope.row.status==0 && !scope.row.url)" :type="getTagType(3)" effect="plain">{{ notebookStatus[3] }} </el-tag> | ||||
@@ -65,13 +82,13 @@ | |||||
</el-table-column> | </el-table-column> | ||||
<el-table-column label="操作" width="200" fixed="right"> | <el-table-column label="操作" width="200" fixed="right"> | ||||
<template slot-scope="scope"> | <template slot-scope="scope"> | ||||
<el-button v-if="scope.row.status === 1" type="text" @click.stop="doStart(scope.row)">启动</el-button> | |||||
<el-button v-if="scope.row.status === 1" type="text" @click.stop="doDelete(scope.row)">删除</el-button> | |||||
<el-button v-if="scope.row.status === 0 && scope.row.url" type="text" @click.stop="doOpen(scope.row)"> | |||||
<el-button v-if="scope.row.status === 1" :id="`start_`+scope.$index" type="text" @click.stop="doStart(scope.row)">启动</el-button> | |||||
<el-button v-if="scope.row.status === 1" :id="`delete_`+scope.$index" type="text" @click.stop="doDelete(scope.row)">删除</el-button> | |||||
<el-button v-if="scope.row.status === 0 && scope.row.url" :id="`open_`+scope.$index" type="text" @click.stop="doOpen(scope.row)"> | |||||
打开<IconFont type="externallink" /> | 打开<IconFont type="externallink" /> | ||||
</el-button> | </el-button> | ||||
<el-button v-if="scope.row.status === 0 && scope.row.url" type="text" @click.stop="doStop(scope.row)">停止</el-button> | |||||
<el-button v-if="((scope.row.status === 0 && scope.row.url) || scope.row.status === 1) && !scope.row.algorithmId" type="text" @click.stop="doSave(scope.row)">保存算法</el-button> | |||||
<el-button v-if="scope.row.status === 0 && scope.row.url" :id="`stop_`+scope.$index" type="text" @click.stop="doStop(scope.row)">停止</el-button> | |||||
<el-button v-if="((scope.row.status === 0 && scope.row.url) || scope.row.status === 1) && !scope.row.algorithmId" :id="`save_`+scope.$index" type="text" @click.stop="doSave(scope.row)">保存算法</el-button> | |||||
<i v-if="[3, 4, 5].includes(scope.row.status) || (scope.row.status === 0 && !scope.row.url)" class="el-icon-loading" /> | <i v-if="[3, 4, 5].includes(scope.row.status) || (scope.row.status === 0 && !scope.row.url)" class="el-icon-loading" /> | ||||
</template> | </template> | ||||
</el-table-column> | </el-table-column> | ||||
@@ -87,6 +104,7 @@ import { debounce } from 'throttle-debounce'; | |||||
import notebookApi, {detail, getStatus, start, stop, open} from '@/api/development/notebook'; | import notebookApi, {detail, getStatus, start, stop, open} from '@/api/development/notebook'; | ||||
import { add as addAlgorithm } from '@/api/algorithm/algorithm'; | import { add as addAlgorithm } from '@/api/algorithm/algorithm'; | ||||
import DropdownHeader from '@/components/DropdownHeader'; | |||||
import CRUD, { presenter, header, crud } from '@crud/crud'; | import CRUD, { presenter, header, crud } from '@crud/crud'; | ||||
import rrOperation from '@crud/RR.operation'; | import rrOperation from '@crud/RR.operation'; | ||||
import cdOperation from '@crud/CD.operation'; | import cdOperation from '@crud/CD.operation'; | ||||
@@ -96,7 +114,7 @@ import NotebookDetail from './components/NotebookDetail'; | |||||
export default { | export default { | ||||
name: 'Notebook', | name: 'Notebook', | ||||
components: { pagination, rrOperation, cdOperation, CreateDialog, NotebookDetail }, | |||||
components: { pagination, rrOperation, cdOperation, DropdownHeader, CreateDialog, NotebookDetail }, | |||||
cruds() { | cruds() { | ||||
return CRUD({ | return CRUD({ | ||||
title: 'Notebook', | title: 'Notebook', | ||||
@@ -121,9 +139,24 @@ export default { | |||||
drawer: false, | drawer: false, | ||||
selectedItemObj: {}, | selectedItemObj: {}, | ||||
pollingCount: 0, | pollingCount: 0, | ||||
keepPoll: true, | |||||
ct: null, | ct: null, | ||||
localQuery: { | |||||
noteBookName: null, | |||||
status: null, | |||||
}, | |||||
}; | }; | ||||
}, | }, | ||||
computed: { | |||||
notebookStatusList() { | |||||
return [{ label: '全部', value: null }].concat(this.statusOptions.map(status => { | |||||
return { | |||||
label: status.statusName, | |||||
value: status.statusCode, | |||||
}; | |||||
})); | |||||
}, | |||||
}, | |||||
mounted() { | mounted() { | ||||
this.crud.msg.del = '正在删除'; | this.crud.msg.del = '正在删除'; | ||||
this.pollingCount = 0; | this.pollingCount = 0; | ||||
@@ -131,10 +164,12 @@ export default { | |||||
this.query.noteBookName = this.$route.params.noteBookName; | this.query.noteBookName = this.$route.params.noteBookName; | ||||
} | } | ||||
this.refetch = debounce(1000, this.crud.refresh); | this.refetch = debounce(1000, this.crud.refresh); | ||||
this.detailRefetch = debounce(2000, this.polling); | |||||
this.getNotebookStatus(); | this.getNotebookStatus(); | ||||
}, | }, | ||||
beforeDestroy() { | beforeDestroy() { | ||||
this.ct && clearTimeout(this.ct); | this.ct && clearTimeout(this.ct); | ||||
this.keepPoll = false; | |||||
}, | }, | ||||
methods: { | methods: { | ||||
[CRUD.HOOK.afterRefresh]() { | [CRUD.HOOK.afterRefresh]() { | ||||
@@ -153,18 +188,22 @@ export default { | |||||
this.crud.refresh(); | this.crud.refresh(); | ||||
}); | }); | ||||
}, | }, | ||||
filterByStatus(status) { | |||||
this.localQuery.status = status; | |||||
this.crud.toQuery(); | |||||
}, | |||||
checkStatus() { | checkStatus() { | ||||
// 删除操作5s内 或 有进行中的状态需要刷新列表 | // 删除操作5s内 或 有进行中的状态需要刷新列表 | ||||
if (this.deleteCount > 0) { | if (this.deleteCount > 0) { | ||||
this.deleteCount -= 1; | this.deleteCount -= 1; | ||||
this.refetch(); | this.refetch(); | ||||
} else if (this.crud.data.some(item => [3, 4, 5].includes(item.status) || (item.status === 0 && !item.url))) { | } else if (this.crud.data.some(item => [3, 4, 5].includes(item.status) || (item.status === 0 && !item.url))) { | ||||
this.polling(); | |||||
this.detailRefetch(); | |||||
} | } | ||||
}, | }, | ||||
async polling() { | async polling() { | ||||
const idList = this.checkPollingIds(); | const idList = this.checkPollingIds(); | ||||
if (!idList.length) { | |||||
if (!this.keepPoll || !idList.length) { | |||||
return; | return; | ||||
} | } | ||||
const res = await detail(idList); | const res = await detail(idList); | ||||
@@ -177,12 +216,16 @@ export default { | |||||
ele.status = item.status; | ele.status = item.status; | ||||
ele.updateTime = item.updateTime; | ele.updateTime = item.updateTime; | ||||
ele.url = item.url; | ele.url = item.url; | ||||
// 当变成云心中且有url时,自动打开url | |||||
if(item.status === 0 && item.url){ | |||||
window.open(item.url, '_blank'); | |||||
} | |||||
} | } | ||||
} | } | ||||
if (this.crud.data.some(item => [3, 4, 5].includes(item.status)) || this.crud.data.some(item => (item.status === 0 && !item.url))) { | if (this.crud.data.some(item => [3, 4, 5].includes(item.status)) || this.crud.data.some(item => (item.status === 0 && !item.url))) { | ||||
this.ct = setTimeout(() => { | this.ct = setTimeout(() => { | ||||
if (this.pollingCount < 200) { // 400s超时,超时不作提示 | if (this.pollingCount < 200) { // 400s超时,超时不作提示 | ||||
this.polling(); | |||||
this.detailRefetch(); | |||||
} | } | ||||
}, 2000); | }, 2000); | ||||
} | } | ||||
@@ -209,6 +252,9 @@ export default { | |||||
default: return ''; | default: return ''; | ||||
} | } | ||||
}, | }, | ||||
[CRUD.HOOK.beforeRefresh]() { | |||||
this.crud.query = { ...this.localQuery}; | |||||
}, | |||||
toAdd() { | toAdd() { | ||||
this.$refs.create.showThis(); | this.$refs.create.showThis(); | ||||
}, | }, | ||||
@@ -0,0 +1,117 @@ | |||||
/** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||||
* | |||||
* Licensed under the Apache License, Version 2.0 (the "License"); | |||||
* you may not use this file except in compliance with the License. | |||||
* You may obtain a copy of the License at | |||||
* | |||||
* http://www.apache.org/licenses/LICENSE-2.0 | |||||
* | |||||
* 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. | |||||
* ============================================================= | |||||
*/ | |||||
<template> | |||||
<div> | |||||
<el-form-item | |||||
v-for="(key, index) in keys" | |||||
:key="key" | |||||
class="mb-10" | |||||
:label="'自定义标签' + (key + 1)" | |||||
:prop="'labels.' + index" | |||||
:rules="rules" | |||||
> | |||||
<div class="flex"> | |||||
<InfoSelect | |||||
:value="list[index].id || list[index].name" | |||||
style="width: 200px; margin-right: 10px;" | |||||
placeholder="选择或新建标签" | |||||
:dataSource="activeLabels" | |||||
valueKey="id" | |||||
labelKey='name' | |||||
default-first-option | |||||
filterable | |||||
allow-create | |||||
:disabled="!editAble && isOriginList(list[index])" | |||||
@change="params => handleChange(key, params)" | |||||
/> | |||||
<el-input v-model="list[index].name" :disabled="!editAble && isOriginList(list[index])" class='dn'></el-input> | |||||
<el-color-picker v-model="list[index].color" :disabled="!editAble && isOriginList(list[index])" size="small" /> | |||||
<span style="width: 50px; margin-left: 10px; line-height: 32px;"> | |||||
<i | |||||
v-if="keys.length > 1 && addAble" | |||||
class="el-icon-remove-outline vm cp" | |||||
:class="!editAble && isOriginList(list[index]) ? 'disabled' : ''" | |||||
style="font-size: 20px;" | |||||
@click.prevent="remove(key)" | |||||
/> | |||||
<i | |||||
v-if="index === (keys.length - 1) && addAble" | |||||
class="el-icon-circle-plus-outline vm cp" | |||||
:class="!addAble ? 'disabled' : ''" | |||||
style="font-size: 20px;" | |||||
@click="add" | |||||
/> | |||||
</span> | |||||
</div> | |||||
</el-form-item> | |||||
</div> | |||||
</template> | |||||
<script> | |||||
import InfoSelect from '@/components/InfoSelect'; | |||||
import { validateLabel } from '@/utils/validate'; | |||||
export default { | |||||
name: 'DynamicField', | |||||
components: { | |||||
InfoSelect, | |||||
}, | |||||
props: { | |||||
actionType: String, | |||||
list: { | |||||
type: Array, | |||||
deafault: () => ([]), | |||||
}, | |||||
activeLabels: { | |||||
type: Array, | |||||
deafault: () => ([]), | |||||
}, | |||||
originList: { | |||||
type: Array, | |||||
deafault: () => ([]), | |||||
}, | |||||
keys: { | |||||
type: Array, | |||||
deafault: () => ([]), | |||||
}, | |||||
remove: Function, | |||||
add: Function, | |||||
handleChange: Function, | |||||
validateDuplicate: Function, | |||||
}, | |||||
setup(props) { | |||||
const rules = [ | |||||
{ validator: validateLabel, trigger: ['change', 'blur'] }, | |||||
{ validator: props.validateDuplicate, trigger: ['change', 'blur'] }, | |||||
]; | |||||
// 可以添加 | |||||
const addAble = ['create', 'edit'].includes(props.actionType); | |||||
const editAble = props.actionType === 'create'; | |||||
const isOriginList = item => { | |||||
const isOrigin = props.originList.findIndex(d => d.id === item.id) > -1; | |||||
return isOrigin; | |||||
}; | |||||
return { | |||||
rules, | |||||
editAble, | |||||
addAble, | |||||
isOriginList, | |||||
}; | |||||
}, | |||||
}; | |||||
</script> |
@@ -0,0 +1,359 @@ | |||||
/** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||||
* | |||||
* Licensed under the Apache License, Version 2.0 (the "License"); | |||||
* you may not use this file except in compliance with the License. | |||||
* You may obtain a copy of the License at | |||||
* | |||||
* http://www.apache.org/licenses/LICENSE-2.0 | |||||
* | |||||
* 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. | |||||
* ============================================================= | |||||
*/ | |||||
<template> | |||||
<div class="app-container"> | |||||
<div class="head-container"> | |||||
<cdOperation :addProps="operationProps" :delProps="operationProps"> | |||||
<el-button | |||||
slot="left" | |||||
class="filter-item" | |||||
type="primary" | |||||
icon="el-icon-plus" | |||||
round | |||||
@click="doCreate" | |||||
> | |||||
创建标签组 | |||||
</el-button> | |||||
<span slot="right"> | |||||
<el-input | |||||
v-model="query.name" | |||||
placeholder="输入名称或ID查询标签组" | |||||
style="width: 200px;" | |||||
class="filter-item" | |||||
@keyup.enter.native="crud.toQuery" | |||||
/> | |||||
<rrOperation @resetQuery="onResetQuery" /> | |||||
</span> | |||||
</cdOperation> | |||||
</div> | |||||
<div class="mb-10 flex"> | |||||
<el-tabs :value="activePanelLabelGroup" class="eltabs-inlineblock" @tab-click="handlePanelClick"> | |||||
<el-tab-pane label="我的标签组" name="0" /> | |||||
<el-tab-pane label="预置标签组" name="1" /> | |||||
</el-tabs> | |||||
<el-button class="filter-item" style="margin-left: auto;" icon="el-icon-refresh" circle @click="onResetFresh"/> | |||||
</div> | |||||
<!--表格渲染--> | |||||
<el-table | |||||
ref="table" | |||||
v-loading="crud.loading" | |||||
:data="crud.data" | |||||
highlight-current-row | |||||
@selection-change="crud.selectionChangeHandler" | |||||
@sort-change="crud.sortChange" | |||||
> | |||||
<el-table-column fixed type="selection" min-width="40" /> | |||||
<el-table-column fixed prop="id" width="70" label="ID" sortable="custom" align="left" /> | |||||
<el-table-column | |||||
fixed | |||||
show-overflow-tooltip | |||||
prop="name" | |||||
label="名称" | |||||
min-width="160" | |||||
align="left" | |||||
class-name="dataset-name-col" | |||||
> | |||||
<template slot-scope="scope"> | |||||
<el-link class="mr-10 name-col" @click="goDetail(scope.row)">{{ scope.row.name }}</el-link> | |||||
</template> | |||||
</el-table-column> | |||||
<el-table-column | |||||
prop="count" | |||||
min-width="80" | |||||
label="标签数量" | |||||
align="left" | |||||
/> | |||||
<el-table-column | |||||
prop="updateTime" | |||||
min-width="160" | |||||
label="更新时间" | |||||
:formatter="formatDate" | |||||
sortable="custom" | |||||
align="left" | |||||
/> | |||||
<el-table-column | |||||
prop="createTime" | |||||
min-width="160" | |||||
label="创建时间" | |||||
:formatter="formatDate" | |||||
sortable="custom" | |||||
align="left" | |||||
/> | |||||
<el-table-column | |||||
prop="remark" | |||||
min-width="220" | |||||
label="标签组描述" | |||||
align="left" | |||||
show-overflow-tooltip | |||||
/> | |||||
<LabelGroupAction | |||||
fixed="right" | |||||
min-width="220" | |||||
align="left" | |||||
:goDetail="goDetail" | |||||
:doEdit="doEdit" | |||||
:doFork="showFork" | |||||
/> | |||||
</el-table> | |||||
<!--分页组件--> | |||||
<el-pagination | |||||
:page-size.sync="crud.page.size" | |||||
:page-sizes="[10, 20, 50]" | |||||
:total="crud.page.total" | |||||
:current-page.sync="crud.page.current" | |||||
:style="`text-align:${crud.props.paginationAlign};`" | |||||
style="margin-top: 8px;" | |||||
layout="total, prev, pager, next, sizes" | |||||
@size-change="crud.sizeChangeHandler($event)" | |||||
@current-change="crud.pageChangeHandler" | |||||
/> | |||||
<BaseModal | |||||
:visible="actionModal.show && actionModal.type === 'fork'" | |||||
:loading="actionModal.showOkLoading" | |||||
title="复制标签组" | |||||
@change="handleCancel" | |||||
@ok="handleFork" | |||||
> | |||||
<el-form ref="form" :model="forkForm" :rules="rules" label-width="100px"> | |||||
<el-form-item label="名称" prop="name"> | |||||
<el-input v-model="forkForm.name" placeholder="标签组名称不能超过50字" maxlength="50" /> | |||||
</el-form-item> | |||||
<el-form-item label="描述" prop="remark"> | |||||
<el-input | |||||
v-model="forkForm.remark" | |||||
type="textarea" | |||||
placeholder="标签组描述长度不能超过100字" | |||||
maxlength="100" | |||||
rows="3" | |||||
show-word-limit | |||||
/> | |||||
</el-form-item> | |||||
<el-form-item label="标签" prop="labels"> | |||||
<el-input | |||||
v-model="forkForm.labels" | |||||
:disabled="true" | |||||
type="textarea" | |||||
placeholder="JSON5格式" | |||||
rows="6" | |||||
/> | |||||
</el-form-item> | |||||
</el-form> | |||||
</BaseModal> | |||||
</div> | |||||
</template> | |||||
<script> | |||||
import { isNil } from 'lodash'; | |||||
import { mapState } from 'vuex'; | |||||
import crudLabelGroup, { copy as LabelGroupFork, getLabelGroupDetail } from '@/api/preparation/labelGroup'; | |||||
import CRUD, { presenter, header, form, crud } from '@crud/crud'; | |||||
import rrOperation from '@crud/RR.operation'; | |||||
import cdOperation from '@crud/CD.operation'; | |||||
import { formatDateTime } from '@/utils'; | |||||
import { validateName } from '@/utils/validate'; | |||||
import store from '@/store'; | |||||
import BaseModal from '@/components/BaseModal'; | |||||
import LabelGroupAction from './labelGroupAction'; | |||||
import "@/views/dataset/style/list.scss"; | |||||
const defaultForm = { | |||||
id: null, | |||||
name: null, | |||||
labels: null, | |||||
remark: '', | |||||
type: 0, | |||||
}; | |||||
export default { | |||||
name: 'LabelGroup', | |||||
components: { | |||||
cdOperation, | |||||
rrOperation, | |||||
BaseModal, | |||||
LabelGroupAction, | |||||
}, | |||||
cruds() { | |||||
return CRUD({ | |||||
title: '标签组管理', | |||||
crudMethod: { ...crudLabelGroup }, | |||||
optShow: { | |||||
add: false, | |||||
}, | |||||
queryOnPresenterCreated: false, | |||||
}); | |||||
}, | |||||
mixins: [presenter(), header(), form(defaultForm), crud()], | |||||
data() { | |||||
return { | |||||
forkVisible: false, // fork对话框 | |||||
actionModal: { | |||||
show: false, | |||||
row: undefined, | |||||
showOkLoading: false, | |||||
type: null, | |||||
}, | |||||
forkForm : { | |||||
id: null, | |||||
name: null, | |||||
labels: null, | |||||
remark: null, | |||||
type: 0, | |||||
}, | |||||
rules: { | |||||
name: [ | |||||
{ required: true, message: '请输入标签组名称', trigger: ['change', 'blur'] }, | |||||
{ validator: validateName, trigger: ['change', 'blur'] }, | |||||
], | |||||
remark: [ | |||||
{ required: false, message: '请输入标签组描述信息', trigger: 'blur' }, | |||||
], | |||||
}, | |||||
}; | |||||
}, | |||||
computed: { | |||||
...mapState({ | |||||
activePanelLabelGroup: state => { | |||||
return String(state.dataset.activePanelLabelGroup); | |||||
}, | |||||
}), | |||||
isNil() { | |||||
return isNil; | |||||
}, | |||||
localQuery() { | |||||
return { | |||||
type: this.activePanelLabelGroup || 0, | |||||
}; | |||||
}, | |||||
// 区分预置标签组和普通便签组操作权限 | |||||
operationProps() { | |||||
return Number(this.activePanelLabelGroup) === 1 ? { disabled: true } : undefined; | |||||
}, | |||||
}, | |||||
created() { | |||||
this.crud.toQuery(); | |||||
}, | |||||
mounted() { | |||||
if (this.$route.params.type === 'add') { | |||||
setTimeout(() => { | |||||
this.crud.toAdd(); | |||||
}, 500); | |||||
} | |||||
}, | |||||
methods: { | |||||
[CRUD.HOOK.beforeRefresh]() { | |||||
this.crud.query = { ...this.query, ...this.localQuery}; | |||||
}, | |||||
onResetQuery() { | |||||
// 重置查询条件 | |||||
this.query = {}; | |||||
this.crud.order = null; | |||||
this.crud.sort = null; | |||||
this.crud.params = {}; | |||||
this.crud.page.current = 1; | |||||
// 重置表格的排序和筛选条件 | |||||
this.$refs.table.clearSort(); | |||||
}, | |||||
onResetFresh() { | |||||
this.onResetQuery(); | |||||
this.crud.refresh(); | |||||
}, | |||||
handlePanelClick(tab) { | |||||
this.onResetQuery(); | |||||
store.dispatch('dataset/togglePanelLabelGroup', Number(tab.name)); | |||||
Object.assign(this.localQuery, { | |||||
type: Number(tab.name), | |||||
}); | |||||
this.crud.refresh(); | |||||
}, | |||||
formatDate(row, column, cellValue) { | |||||
if(isNil(cellValue)){ | |||||
return cellValue; | |||||
} | |||||
return formatDateTime(cellValue); | |||||
}, | |||||
doCreate() { | |||||
this.$router.push({ | |||||
path: `/data/labelgroup/create`, | |||||
}); | |||||
}, | |||||
// 查看标签组详情 | |||||
goDetail(row) { | |||||
this.$router.push({ | |||||
path: `/data/labelgroup/detail`, | |||||
query: { | |||||
id: row.id, | |||||
}, | |||||
}); | |||||
}, | |||||
// 编辑标签组 | |||||
doEdit(row) { | |||||
this.$router.push({ | |||||
path: `/data/labelgroup/edit`, | |||||
query: { | |||||
id: row.id, | |||||
}, | |||||
}); | |||||
}, | |||||
// 显示fork对话框 | |||||
showFork(row) { | |||||
this.showActionModal(row, 'fork'); | |||||
getLabelGroupDetail(row.id).then(res => { | |||||
Object.assign(this.forkForm, { | |||||
name: res.name, | |||||
remark: res.remark, | |||||
type: res.type, | |||||
labels: JSON.stringify(res.labels), | |||||
id: row.id, | |||||
}); | |||||
}); | |||||
}, | |||||
handleCancel() { | |||||
this.resetActionModal(); | |||||
}, | |||||
handleFork() { | |||||
LabelGroupFork(this.forkForm); | |||||
this.resetActionModal(); | |||||
setTimeout(() => { | |||||
this.onResetFresh(); | |||||
}, 500); | |||||
}, | |||||
showActionModal(row, type) { | |||||
this.actionModal = { | |||||
show: true, | |||||
row, | |||||
showOkLoading: false, | |||||
type, | |||||
}; | |||||
}, | |||||
resetActionModal() { | |||||
this.actionModal = { | |||||
show: false, | |||||
row: undefined, | |||||
showOkLoading: false, | |||||
type: null, | |||||
}; | |||||
}, | |||||
}, | |||||
}; | |||||
</script> |
@@ -0,0 +1,89 @@ | |||||
/** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||||
* | |||||
* Licensed under the Apache License, Version 2.0 (the "License"); | |||||
* you may not use this file except in compliance with the License. | |||||
* You may obtain a copy of the License at | |||||
* | |||||
* http://www.apache.org/licenses/LICENSE-2.0 | |||||
* | |||||
* 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. | |||||
* ============================================================= | |||||
*/ | |||||
export default { | |||||
name: 'LabelGroupAction', | |||||
functional: true, | |||||
props: { | |||||
goDetail: Function, | |||||
doEdit: Function, | |||||
doFork: Function, | |||||
}, | |||||
render(h, { data, props }) { | |||||
const { doFork, goDetail, doEdit } = props; | |||||
const columnProps = { | |||||
...data, | |||||
scopedSlots: { | |||||
header: () => { | |||||
return ( | |||||
<span>操作</span> | |||||
); | |||||
}, | |||||
default: ({ row } ) => { | |||||
const btnProps = { | |||||
props: { | |||||
type: 'text', | |||||
disabled: row.disabledAction, | |||||
}, | |||||
style: { | |||||
marginLeft: '0px', | |||||
marginRight: '10px', | |||||
}, | |||||
}; | |||||
// 查看详情按钮 | |||||
const checkButton = ( | |||||
<el-button {...btnProps} onClick={() => goDetail(row)}> | |||||
查看详情 | |||||
</el-button> | |||||
); | |||||
// 编辑按钮 | |||||
let showEditButton = true; | |||||
const editButton = ( | |||||
<el-button {...btnProps} onClick={() => doEdit(row)}> | |||||
编辑 | |||||
</el-button> | |||||
); | |||||
// 复制按钮 | |||||
let showForkButton = true; | |||||
const forkButton = ( | |||||
<el-button {...btnProps} onClick={() => doFork(row)}> | |||||
复制 | |||||
</el-button> | |||||
); | |||||
// 预置标签组只具备查看标签功能 | |||||
if (row.type === 1) { | |||||
showEditButton = false; | |||||
showForkButton = false; | |||||
}; | |||||
return ( | |||||
<span> | |||||
{checkButton} | |||||
{showEditButton && editButton} | |||||
{showForkButton && forkButton} | |||||
</span> | |||||
); | |||||
}, | |||||
}, | |||||
}; | |||||
return h('el-table-column', columnProps); | |||||
}, | |||||
}; |
@@ -0,0 +1,654 @@ | |||||
/** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||||
* | |||||
* Licensed under the Apache License, Version 2.0 (the "License"); | |||||
* you may not use this file except in compliance with the License. | |||||
* You may obtain a copy of the License at | |||||
* | |||||
* http://www.apache.org/licenses/LICENSE-2.0 | |||||
* | |||||
* 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. | |||||
* ============================================================= | |||||
*/ | |||||
<template> | |||||
<div v-loading="state.loading" class="app-container" style="width: 600px; margin-top: 28px;"> | |||||
<el-form ref="formRef" :model="state.createForm" :rules="rules" label-width="100px"> | |||||
<el-form-item label="名称" prop="name"> | |||||
<el-input | |||||
v-model="state.createForm.name" | |||||
placeholder="标签组名称不能超过50字" | |||||
maxlength="50" | |||||
show-word-limit | |||||
:disabled="state.actionType === 'detail'" | |||||
/> | |||||
</el-form-item> | |||||
<el-form-item v-if="labelGroupType" label="类型" prop="type"> | |||||
<el-input | |||||
v-model="labelGroupType" | |||||
:disabled="true" | |||||
/> | |||||
</el-form-item> | |||||
<el-form-item label="描述" prop="remark"> | |||||
<el-input | |||||
v-model="state.createForm.remark" | |||||
type="textarea" | |||||
placeholder="标签组描述长度不能超过100字" | |||||
maxlength="100" | |||||
rows="3" | |||||
show-word-limit | |||||
:disabled="state.actionType === 'detail'" | |||||
/> | |||||
</el-form-item> | |||||
<el-form-item label="创建方式"> | |||||
<el-tabs :value="state.addWay" class='labels-edit-wrapper' type="border-card" :before-leave="beforeLeave" @tab-click="handleClick"> | |||||
<el-tab-pane label="自定义标签组" name="custom" class="dynamic-field"> | |||||
<Exception v-if="state.createForm.labels.length === 0" /> | |||||
<div v-else> | |||||
<div v-if="state.groupType === 1"> | |||||
<el-tag v-for="label in state.originList" :key="label.id" class="mr-10">{{ label.name }}</el-tag> | |||||
</div> | |||||
<el-form | |||||
v-else | |||||
ref="customFormRef" | |||||
:model="state.createForm" | |||||
label-width="100px" | |||||
> | |||||
<DynamicField | |||||
:list="state.createForm.labels" | |||||
:originList="state.originList" | |||||
:keys="state.keys" | |||||
:activeLabels="state.activeLabels" | |||||
:add="addRow" | |||||
:remove="removeLabel" | |||||
:handleChange="handleLabelChange" | |||||
:actionType="state.actionType" | |||||
:validateDuplicate="validateDuplicate" | |||||
/> | |||||
</el-form> | |||||
</div> | |||||
</el-tab-pane> | |||||
<el-tab-pane label="编辑标签组" name="edit" class='labelgroup-editor'> | |||||
<prism-editor | |||||
ref="editorRef" | |||||
v-model="state.codeContent" | |||||
:readonly="state.actionType === 'detail'" | |||||
class="min-height-100 max-height-400" | |||||
:highlight="highlighter" | |||||
/> | |||||
<span class='icon-wrapper' @click="beautify"> | |||||
<IconFont type="beauty" class="format" /> | |||||
</span> | |||||
</el-tab-pane> | |||||
<el-tab-pane label="导入标签组" name="upload" :disabled="state.actionType !== 'create'"> | |||||
<div class="min-height-100 flex flex-center upload-tab"> | |||||
<UploadInline | |||||
ref="uploadFormRef" | |||||
action="fakeApi" | |||||
accept=".json" | |||||
listType="text" | |||||
:limit="1" | |||||
:acceptSize="0" | |||||
:multiple="false" | |||||
:showFileCount="false" | |||||
:hash="false" | |||||
@uploadError="uploadError" | |||||
/> | |||||
</div> | |||||
</el-tab-pane> | |||||
</el-tabs> | |||||
<div class="field-extra mt-10"> | |||||
<div v-if="state.addWay === 'custom'"> | |||||
<div>「自定义标签组」由用户自己创建,标签名长度不能超过 30</div> | |||||
</div> | |||||
<div v-else-if="state.addWay === 'edit'"> | |||||
<div>1.「编辑标签组」提供用户自由编写标签方式</div> | |||||
<div>2. 请不要随意删除已有标签</div> | |||||
<div>3. 请不要随意修改已有标签 id</div> | |||||
<div>4. 请按照标准格式提供颜色色值</div> | |||||
</div> | |||||
<div v-else-if="state.addWay === 'upload'"> | |||||
<div>1. 请按照格式要求提交 json 格式标签文件</div> | |||||
</div> | |||||
</div> | |||||
</el-form-item> | |||||
</el-form> | |||||
<div style="margin-left: 100px;"> | |||||
<el-button type="primary" @click="handleSubmit">{{ submitTxt }}</el-button> | |||||
<!-- <el-button @click="goBack">{{state.cancelText}}</el-button> --> | |||||
</div> | |||||
</div> | |||||
</template> | |||||
<script> | |||||
import { reactive, ref, onMounted, computed } from '@vue/composition-api'; | |||||
import { Message, MessageBox } from 'element-ui'; | |||||
import { pick, uniqBy } from 'lodash'; | |||||
import Beautify from 'js-beautify'; | |||||
import { PrismEditor } from 'vue-prism-editor'; | |||||
import 'vue-prism-editor/dist/prismeditor.min.css'; | |||||
import { highlight, languages } from 'prismjs/components/prism-core'; | |||||
import 'prismjs/components/prism-clike'; | |||||
import 'prismjs/components/prism-javascript'; | |||||
import Exception from '@/components/Exception'; | |||||
import UploadInline from "@/components/UploadForm/inline"; | |||||
import { remove, replace, duplicate } from '@/utils'; | |||||
import { validateName, validateLabelsUtil } from '@/utils/validate'; | |||||
import { getAutoLabels } from '@/api/preparation/datalabel'; | |||||
import { add, edit, getLabelGroupDetail, importLabelGroup } from "@/api/preparation/labelGroup"; | |||||
import DynamicField from './dynamicField'; | |||||
import 'prismjs/themes/prism-tomorrow.css'; | |||||
const defaultColor = '#FFFFFF'; | |||||
const initialLabels = [{"name":"","color": defaultColor}, {"name":"","color":"#000000"}]; | |||||
export default { | |||||
name: 'LabelGroupForm', | |||||
components: { | |||||
PrismEditor, | |||||
DynamicField, | |||||
UploadInline, | |||||
Exception, | |||||
}, | |||||
setup(props, ctx) { | |||||
const editorRef = ref(null); | |||||
const formRef = ref(null); | |||||
const uploadFormRef = ref(null); | |||||
const customFormRef = ref(null); | |||||
const { $route, $router } = ctx.root; | |||||
const routeMap = { | |||||
LabelGroupCreate: 'create', | |||||
LabelGroupDetail: 'detail', | |||||
LabelGroupEdit: 'edit', | |||||
}; | |||||
const txtMap = { | |||||
create: "确认创建", | |||||
edit: "确认编辑", | |||||
detail: "返回", | |||||
}; | |||||
const operateTypeMap = { | |||||
1: 'custom', | |||||
2: 'edit', | |||||
3: 'upload', | |||||
}; | |||||
const labelGroupTypeMap = { | |||||
0: '自定义标签组', | |||||
1: '预置标签组', | |||||
}; | |||||
// 表单规则 | |||||
const rules = { | |||||
name: [ | |||||
{ required: true, message: '请输入标签组名称', trigger: ['change', 'blur'] }, | |||||
{ validator: validateName, trigger: ['change', 'blur'] }, | |||||
], | |||||
}; | |||||
const buildModel = (record, options) => { | |||||
return { ...record, ...options}; | |||||
}; | |||||
// 生成 keys | |||||
const setKeys = labels => labels.map((label, index) => index); | |||||
// 页面类型 | |||||
const actionType = routeMap[$route.name] || 'create'; | |||||
const state = reactive({ | |||||
id: actionType !== 'create' ? $route.query.id : null, | |||||
actionType, | |||||
groupType: null, // 查询标签组详情类型 | |||||
model: buildModel(props.row), | |||||
systemLabels: [], // 系统自动标注标签列表 | |||||
originList: [], // 记录原始返回列表 | |||||
activeLabels: [], // 当前可用标签列表 | |||||
fileCount: undefined, | |||||
// counter: 动态表单项数量,keys: 每次生成唯一的表单项 | |||||
counter: initialLabels.length - 1, | |||||
keys: setKeys(initialLabels), | |||||
createForm: { | |||||
labels: initialLabels, | |||||
name: '', | |||||
remark: "", | |||||
type: 0, | |||||
}, | |||||
codeContent: JSON.stringify(initialLabels), | |||||
customForm: { | |||||
labels: [{ | |||||
name: '', | |||||
color: defaultColor, | |||||
}], | |||||
}, | |||||
addWay: "custom", // 默认创建类型为自定义 | |||||
cancelText: "取消", | |||||
errmsg: '', | |||||
loading: false, // 加载详情 | |||||
}); | |||||
const submitTxt = txtMap[state.actionType]; | |||||
// 获取 key 值索引 | |||||
const getIndex = (index) => state.keys.findIndex(key => key === index); | |||||
const setCode = (code) => { | |||||
Object.assign(state, { | |||||
codeContent: code, | |||||
}); | |||||
}; | |||||
const beautify = () => { | |||||
// 编辑器内容 | |||||
const code = editorRef.value.value; | |||||
const formated = Beautify(code); | |||||
setCode(formated); | |||||
}; | |||||
const uploadError = () => { | |||||
}; | |||||
const goBack = () => { | |||||
$router.push({path: "/data/labelgroup"}); | |||||
}; | |||||
// 更新 | |||||
const updateCreateForm = (next) => { | |||||
Object.assign(state, { | |||||
createForm: { | |||||
...state.createForm, | |||||
...next, | |||||
}, | |||||
}); | |||||
}; | |||||
const handleLabelGroupRequest = (params) => { | |||||
const nextParams = { | |||||
...params, | |||||
labels: JSON.stringify(params.labels), | |||||
}; | |||||
const requestResource = params.id ? edit : add; | |||||
const message = params.id ? '标签组编辑成功' : '标签组创建成功'; | |||||
requestResource(nextParams).then(() => { | |||||
Message.success({ | |||||
message, | |||||
duration: 1500, | |||||
onClose: goBack, | |||||
}); | |||||
}); | |||||
}; | |||||
const handleSubmit = () => { | |||||
if(actionType === 'detail') { | |||||
goBack(); | |||||
return; | |||||
} | |||||
formRef.value.validate(validWrapper => { | |||||
if (validWrapper) { | |||||
switch(state.addWay) { | |||||
// 自定标签组 | |||||
case 'custom': | |||||
customFormRef.value.validate(isValid => { | |||||
if (isValid) { | |||||
const params = { | |||||
...state.createForm, | |||||
operateType: 1, | |||||
}; | |||||
handleLabelGroupRequest(params); | |||||
} | |||||
}); | |||||
break; | |||||
// 编辑标签组 | |||||
case 'edit': | |||||
try { | |||||
let errMsg = ''; | |||||
const code = JSON.parse(editorRef.value.value); | |||||
if(Array.isArray(code) && code.length) { | |||||
for(const d of code) { | |||||
if(validateLabelsUtil(d) !== '') { | |||||
errMsg = validateLabelsUtil(d); | |||||
break; | |||||
} | |||||
} | |||||
} | |||||
if(errMsg) { | |||||
Message.error(errMsg); | |||||
return; | |||||
} | |||||
const editParams = { | |||||
...state.createForm, | |||||
labels: code, | |||||
operateType: 2, | |||||
}; | |||||
handleLabelGroupRequest(editParams); | |||||
} catch(err) { | |||||
console.error(err); | |||||
throw err; | |||||
} | |||||
break; | |||||
case 'upload': { | |||||
const { uploadFiles } = uploadFormRef.value.formRef?.$refs.uploader || {}; | |||||
const { name, remark } = state.createForm; | |||||
const formData = new FormData(); | |||||
formData.append('name', name); | |||||
formData.append('remark', remark); | |||||
formData.append('file', uploadFiles[0].raw); | |||||
formData.append('operateType', 3); | |||||
importLabelGroup(formData).then(() => { | |||||
Message.success({ | |||||
message: '标签组导入成功', | |||||
duration: 1500, | |||||
onClose: goBack, | |||||
}); | |||||
}); | |||||
break; | |||||
} | |||||
default: | |||||
break; | |||||
} | |||||
} | |||||
}); | |||||
}; | |||||
const beforeLeave = (activeName, oldActiveName) => { | |||||
if(activeName === oldActiveName) return false; | |||||
if(oldActiveName === 'upload') { | |||||
const { uploadFiles } = uploadFormRef.value.formRef?.$refs.uploader || {}; | |||||
if(uploadFiles.length) { | |||||
return MessageBox.confirm('标注文件已提交,确认切换?') | |||||
.catch(() => { | |||||
state.addWay = 'upload'; | |||||
return Promise.reject(); | |||||
}); | |||||
} | |||||
return true; | |||||
} | |||||
return true; | |||||
}; | |||||
// | |||||
const handleClick = (tab) => { | |||||
if(state.addWay === tab.name) return; | |||||
// 切换到编辑模式 | |||||
if (tab.name === 'edit') { | |||||
// 从自定义编辑切换过去 | |||||
if(state.addWay === 'custom') { | |||||
state.codeContent = JSON.stringify(state.createForm.labels); | |||||
} | |||||
} else if (tab.name === 'custom'){ | |||||
if(state.addWay === 'edit') { | |||||
try { | |||||
const nextLabels = JSON.parse(editorRef.value.value); | |||||
Object.assign(state, { | |||||
createForm: { | |||||
...state.createForm, | |||||
labels: nextLabels, | |||||
}, | |||||
keys: setKeys(nextLabels), | |||||
counter: Math.max(state.counter, nextLabels.length - 1), | |||||
}); | |||||
} catch(err) { | |||||
Message.error('编辑格式不合法'); | |||||
return; | |||||
} | |||||
} | |||||
} | |||||
state.addWay = tab.name; | |||||
}; | |||||
const highlighter = (code) => { | |||||
return highlight(code, languages.js); | |||||
}; | |||||
const addLabel = (row) => { | |||||
state.createForm.labels.push(row); | |||||
const nextKeys = state.keys.concat(state.counter + 1); | |||||
Object.assign(state, { | |||||
keys: nextKeys, | |||||
counter: state.counter + 1, | |||||
}); | |||||
}; | |||||
// 添加一行标签 | |||||
const addRow = () => { | |||||
addLabel({ | |||||
name: '', | |||||
color: defaultColor, | |||||
}); | |||||
}; | |||||
// 用户自定义创建标签 | |||||
const createCustomLabel = (name, index) => { | |||||
const updateLabel = {name, color: defaultColor}; | |||||
updateCreateForm({ | |||||
labels: replace(state.createForm.labels, index, updateLabel), | |||||
}); | |||||
}; | |||||
const validateDuplicate = (rule, value, callback) => { | |||||
const isDuplicate = duplicate(state.createForm.labels, d => { | |||||
if(!value.id) return false; | |||||
return d.id === value.id; | |||||
}); | |||||
if (isDuplicate) { | |||||
callback(new Error('标签不能重复')); | |||||
return; | |||||
} | |||||
callback(); | |||||
}; | |||||
const handleLabelChange = (key, value) => { | |||||
const index = getIndex(key); | |||||
// 每次触发错误表单项验证 | |||||
const errorFields = customFormRef.value.fields.filter(d => d.validateState === 'error').map(d => d.prop); | |||||
customFormRef.value.validateField(errorFields); | |||||
// 判断是新建还是选择标签 | |||||
const editLabel = state.systemLabels.find(d => d.id === value); | |||||
// 选择已有标签 | |||||
if(editLabel) { | |||||
const updateLabel = pick(editLabel, ['name', 'id', 'color']); | |||||
Object.assign(state, { | |||||
createForm: { | |||||
...state.createForm, | |||||
labels: replace(state.createForm.labels, index, updateLabel), | |||||
}, | |||||
}); | |||||
} else { | |||||
// 创建用户自定义标签 | |||||
createCustomLabel(value, index); | |||||
} | |||||
}; | |||||
// 移除标签 | |||||
const removeLabel = (k) => { | |||||
// 至少保留一条记录 | |||||
if (state.keys.length === 1) return; | |||||
const index = getIndex(k); | |||||
Object.assign(state, { | |||||
keys: state.keys.filter(key => key !== k), | |||||
createForm: { | |||||
...state.createForm, | |||||
labels: remove(state.createForm.labels, index), | |||||
}, | |||||
}); | |||||
}; | |||||
const setLoading = (loading) => { | |||||
Object.assign(state, { | |||||
loading, | |||||
}); | |||||
}; | |||||
// 自定义标签组,预置标签组 | |||||
const labelGroupType = computed(() => labelGroupTypeMap[state.groupType]) || undefined; | |||||
onMounted(async () => { | |||||
const autoLabels = await getAutoLabels(); | |||||
Object.assign(state, { | |||||
activeLabels: autoLabels, | |||||
systemLabels: autoLabels, | |||||
}); | |||||
// 异常判断 | |||||
if(actionType !== 'create') { | |||||
if(!state.id) { | |||||
$router.push({ path: '/data/labelgroup' }); | |||||
throw new Error('当前标签组 id 不存在'); | |||||
} | |||||
setLoading(true); | |||||
// 查询数据集详情 | |||||
getLabelGroupDetail(state.id).then(res => { | |||||
// 当编辑模式,且数据为空时需要提供默认数据 | |||||
const labels = res.labels.length === 0 && actionType === 'edit' ? initialLabels : res.labels; | |||||
const restProps = state.actionType === 'detail' ? { | |||||
groupType: res.type || 0, | |||||
} : {}; | |||||
Object.assign(state, { | |||||
createForm: { | |||||
...state.createForm, | |||||
...res, | |||||
labels, | |||||
}, | |||||
addWay: operateTypeMap[res.operateType] || 'custom', | |||||
activeLabels: uniqBy(state.activeLabels.concat(res.labels), 'id'), | |||||
originList: res.labels.slice(), | |||||
keys: setKeys(labels), | |||||
counter: Math.max(state.counter, labels.length - 1), | |||||
codeContent: JSON.stringify(res.labels), | |||||
...restProps, | |||||
}); | |||||
}).finally(() => { | |||||
setLoading(false); | |||||
}); | |||||
} | |||||
}); | |||||
return { | |||||
rules, | |||||
state, | |||||
submitTxt, | |||||
beautify, | |||||
editorRef, | |||||
formRef, | |||||
customFormRef, | |||||
validateDuplicate, | |||||
goBack, | |||||
handleClick, | |||||
handleSubmit, | |||||
highlighter, | |||||
removeLabel, | |||||
addRow, | |||||
handleLabelChange, | |||||
uploadError, | |||||
uploadFormRef, | |||||
beforeLeave, | |||||
labelGroupType, | |||||
}; | |||||
}, | |||||
}; | |||||
</script> | |||||
<style lang="scss"> | |||||
@import '@/assets/styles/variables.scss'; | |||||
.min-height-100 { | |||||
min-height: 100px; | |||||
} | |||||
.height-400 { | |||||
height: 400px; | |||||
} | |||||
.max-height-400 { | |||||
max-height: 400px; | |||||
} | |||||
.field-extra { | |||||
font-size: 14px; | |||||
line-height: 1.5; | |||||
color: $infoColor; | |||||
} | |||||
.labelgroup-editor { | |||||
position: relative; | |||||
padding: 5px; | |||||
font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace; | |||||
font-size: 18px; | |||||
line-height: 1.5; | |||||
color: black; | |||||
background: white; | |||||
} | |||||
.prism-editor__textarea:focus { | |||||
outline: none; | |||||
} | |||||
.labels-edit-wrapper { | |||||
.icon-wrapper { | |||||
position: absolute; | |||||
top: -10px; | |||||
right: 10px; | |||||
width: 32px; | |||||
height: 32px; | |||||
line-height: 32px; | |||||
color: $commonTextColor; | |||||
text-align: center; | |||||
cursor: pointer; | |||||
border: 1px solid $borderColor; | |||||
border-radius: 50%; | |||||
transition: 200ms ease; | |||||
&:hover { | |||||
color: #333; | |||||
} | |||||
} | |||||
.format { | |||||
font-size: 20px; | |||||
} | |||||
.disabled { | |||||
color: $infoColor; | |||||
pointer-events: none; | |||||
cursor: not-allowed; | |||||
} | |||||
.el-tabs__content { | |||||
padding-right: 0; | |||||
} | |||||
.dynamic-field { | |||||
min-height: 100px; | |||||
max-height: 400px; | |||||
overflow: auto; | |||||
.exception { | |||||
min-height: 100px; | |||||
} | |||||
.el-form-item { | |||||
margin-bottom: 20px; | |||||
} | |||||
} | |||||
.upload-tab { | |||||
max-width: 80%; | |||||
} | |||||
} | |||||
</style> |
@@ -82,16 +82,16 @@ | |||||
</template> | </template> | ||||
<!--step==2--> | <!--step==2--> | ||||
<template v-if="step==1"> | <template v-if="step==1"> | ||||
<el-form ref="form2" :model="form2" :rules="rules" label-width="100px"> | |||||
<el-form v-if="visible" ref="form2" :model="form2" :rules="rules" label-width="100px"> | |||||
<el-form-item label="模型名称"> | <el-form-item label="模型名称"> | ||||
<div>{{ form.name }}</div> | <div>{{ form.name }}</div> | ||||
</el-form-item> | </el-form-item> | ||||
<el-form-item label="模型上传" prop="modelAddress"> | |||||
<el-form-item ref="modelAddress" label="模型上传" prop="modelAddress"> | |||||
<upload-inline | <upload-inline | ||||
v-if="refreshFlag" | v-if="refreshFlag" | ||||
action="fakeApi" | action="fakeApi" | ||||
accept=".zip,.pb,.h5,.ckpt,.pkl,.pth,.weight,.caffemodel,.pt" | accept=".zip,.pb,.h5,.ckpt,.pkl,.pth,.weight,.caffemodel,.pt" | ||||
:acceptSize="5120" | |||||
:acceptSize="0" | |||||
list-type="text" | list-type="text" | ||||
:limit="1" | :limit="1" | ||||
:multiple="false" | :multiple="false" | ||||
@@ -103,12 +103,19 @@ | |||||
@uploadSuccess="uploadSuccess" | @uploadSuccess="uploadSuccess" | ||||
@uploadError="uploadError" | @uploadError="uploadError" | ||||
/> | /> | ||||
<div v-if="loading"><i class="el-icon-loading" />模型上传中...</div> | |||||
<upload-progress | |||||
v-if="loading" | |||||
:progress="progress" | |||||
:color="customColors" | |||||
:status="status" | |||||
:size="size" | |||||
@onSetProgress="onSetProgress" | |||||
/> | |||||
</el-form-item> | </el-form-item> | ||||
</el-form> | </el-form> | ||||
<div slot="footer" class="dialog-footer"> | <div slot="footer" class="dialog-footer"> | ||||
<el-button @click="visible = false;step=0;">下次再传</el-button> | <el-button @click="visible = false;step=0;">下次再传</el-button> | ||||
<el-button type="primary" @click="doAddVersion">确定上传</el-button> | |||||
<el-button type="primary" :disabled="loading" @click="doAddVersion">确定上传</el-button> | |||||
</div> | </div> | ||||
</template> | </template> | ||||
</el-dialog> | </el-dialog> | ||||
@@ -119,8 +126,8 @@ import { add as addVersion } from '@/api/model/modelVersion'; | |||||
import { add as addModel } from '@/api/model/model'; | import { add as addModel } from '@/api/model/model'; | ||||
import { list as getAlgorithmUsages, add as addAlgorithmUsage } from '@/api/algorithm/algorithmUsage'; | import { list as getAlgorithmUsages, add as addAlgorithmUsage } from '@/api/algorithm/algorithmUsage'; | ||||
import UploadInline from '@/components/UploadForm/inline'; | import UploadInline from '@/components/UploadForm/inline'; | ||||
import { parseTime, validateNameWithHyphen } from '@/utils'; | |||||
import { nanoid } from 'nanoid'; | |||||
import UploadProgress from '@/components/UploadProgress'; | |||||
import { getUniqueId, validateNameWithHyphen } from '@/utils'; | |||||
const defaultForm = { | const defaultForm = { | ||||
name: null, | name: null, | ||||
@@ -139,7 +146,7 @@ const defaultForm2 = { | |||||
export default { | export default { | ||||
name: 'AddModelDialog', | name: 'AddModelDialog', | ||||
dicts: ['model_type', 'frame_type'], | dicts: ['model_type', 'frame_type'], | ||||
components: { UploadInline }, | |||||
components: { UploadInline, UploadProgress }, | |||||
data() { | data() { | ||||
return { | return { | ||||
visible: false, | visible: false, | ||||
@@ -171,18 +178,33 @@ export default { | |||||
{ max: 255, message: '长度在255个字符以内', trigger: 'blur' }, | { max: 255, message: '长度在255个字符以内', trigger: 'blur' }, | ||||
], | ], | ||||
modelAddress: [ | modelAddress: [ | ||||
{ required: true, message: '请上传有效的模型', trigger: 'blur' }, | |||||
{ required: true, message: '请上传有效的模型', trigger: ['blur', 'manual'] }, | |||||
], | ], | ||||
}, | }, | ||||
step: 0, | step: 0, | ||||
uploadParams: { | uploadParams: { | ||||
objectPath: `model/${this.$store.state.user.user.id}/${parseTime(new Date(), '{y}{m}{d}{h}{i}{s}{S}') + nanoid(4)}`, // 对象存储路径 | |||||
objectPath: null, // 对象存储路径 | |||||
}, | }, | ||||
algorithmUsageList: [], | algorithmUsageList: [], | ||||
refreshFlag: true, | refreshFlag: true, | ||||
loading: false, | loading: false, | ||||
size: 0, | |||||
progress: 0, | |||||
customColors: [ | |||||
{color: '#909399', percentage: 40}, | |||||
{color: '#e6a23c', percentage: 80}, | |||||
{color: '#67c23a', percentage: 100}, | |||||
], | |||||
}; | }; | ||||
}, | }, | ||||
computed: { | |||||
status() { | |||||
return this.progress === 100 ? 'success' : null; | |||||
}, | |||||
user() { | |||||
return this.$store.getters.user; | |||||
}, | |||||
}, | |||||
methods: { | methods: { | ||||
show() { | show() { | ||||
this.refreshFlag = false; | this.refreshFlag = false; | ||||
@@ -196,6 +218,7 @@ export default { | |||||
}, | }, | ||||
onDialogClose() { | onDialogClose() { | ||||
this.reset(); | this.reset(); | ||||
this.loading = false; | |||||
this.$emit('addDone', true); | this.$emit('addDone', true); | ||||
}, | }, | ||||
reset() { | reset() { | ||||
@@ -217,13 +240,22 @@ export default { | |||||
handleRemove() { | handleRemove() { | ||||
this.loading = false; | this.loading = false; | ||||
this.form2.modelAddress = null; | this.form2.modelAddress = null; | ||||
this.$refs.modelAddress.validate('manual'); | |||||
}, | }, | ||||
uploadStart() { | |||||
this.loading = true; | |||||
uploadStart(files) { | |||||
this.updateImagePath(); | |||||
[ this.loading, this.size, this.progress ] = [ true, files.size, 0 ]; | |||||
}, | |||||
onSetProgress(val) { | |||||
this.progress += val; | |||||
}, | }, | ||||
uploadSuccess(res) { | uploadSuccess(res) { | ||||
this.loading = false; | |||||
this.progress = 100; | |||||
setTimeout(() => { | |||||
this.loading = false; | |||||
}, 1000); | |||||
this.form2.modelAddress = res[0].data.objectName; | this.form2.modelAddress = res[0].data.objectName; | ||||
this.$refs.modelAddress.validate('manual'); | |||||
}, | }, | ||||
uploadError() { | uploadError() { | ||||
this.loading = false; | this.loading = false; | ||||
@@ -274,6 +306,9 @@ export default { | |||||
await addAlgorithmUsage({ auxInfo }); | await addAlgorithmUsage({ auxInfo }); | ||||
this.getAlgorithmUsages(); | this.getAlgorithmUsages(); | ||||
}, | }, | ||||
updateImagePath() { | |||||
this.uploadParams.objectPath = `upload-temp/${this.user.id}/${getUniqueId()}`; | |||||
}, | |||||
}, | }, | ||||
}; | }; | ||||
</script> | </script> |
@@ -21,17 +21,17 @@ | |||||
<div class="cd-opts"> | <div class="cd-opts"> | ||||
<span class="cd-opts-left"> | <span class="cd-opts-left"> | ||||
<el-button | <el-button | ||||
id="toAdd" | |||||
class="filter-item" | class="filter-item" | ||||
type="primary" | type="primary" | ||||
icon="el-icon-plus" | icon="el-icon-plus" | ||||
round | round | ||||
@click="toAdd" | @click="toAdd" | ||||
> | |||||
创建模型 | |||||
</el-button> | |||||
>创建模型</el-button> | |||||
</span> | </span> | ||||
<span class="cd-opts-right"> | <span class="cd-opts-right"> | ||||
<el-input | <el-input | ||||
id="queryName" | |||||
v-model="query.name" | v-model="query.name" | ||||
clearable | clearable | ||||
placeholder="请输入模型名称或ID" | placeholder="请输入模型名称或ID" | ||||
@@ -44,8 +44,8 @@ | |||||
</div> | </div> | ||||
<div> | <div> | ||||
<el-tabs v-model="active" class="eltabs-inlineblock" @tab-click="crud.toQuery"> | <el-tabs v-model="active" class="eltabs-inlineblock" @tab-click="crud.toQuery"> | ||||
<el-tab-pane label="我的模型" name="0" /> | |||||
<el-tab-pane label="预训练模型" name="1" /> | |||||
<el-tab-pane id="tab_0" label="我的模型" name="0" /> | |||||
<el-tab-pane id="tab_1" label="预训练模型" name="1" /> | |||||
</el-tabs> | </el-tabs> | ||||
</div> | </div> | ||||
</div> | </div> | ||||
@@ -61,15 +61,20 @@ | |||||
<el-table-column prop="id" label="ID" width="80" sortable="custom" /> | <el-table-column prop="id" label="ID" width="80" sortable="custom" /> | ||||
<el-table-column prop="name" label="模型名称" min-width="180px" /> | <el-table-column prop="name" label="模型名称" min-width="180px" /> | ||||
<el-table-column prop="frameType" label="框架名称" min-width="150px"> | <el-table-column prop="frameType" label="框架名称" min-width="150px"> | ||||
<template slot-scope="scope">{{ dict.label.frame_type[scope.row.frameType]||'--' }}</template> | |||||
<template slot-scope="scope">{{ dict.label.frame_type[scope.row.frameType]|| "--" }}</template> | |||||
</el-table-column> | </el-table-column> | ||||
<el-table-column prop="modelType" label="模型格式" min-width="150px"> | <el-table-column prop="modelType" label="模型格式" min-width="150px"> | ||||
<template slot-scope="scope">{{ dict.label.model_type[scope.row.modelType]||'--' }}</template> | |||||
<template slot-scope="scope">{{ dict.label.model_type[scope.row.modelType]|| "--" }}</template> | |||||
</el-table-column> | </el-table-column> | ||||
<el-table-column prop="modelClassName" label="模型类别" min-width="150px"> | <el-table-column prop="modelClassName" label="模型类别" min-width="150px"> | ||||
<template slot-scope="scope">{{ scope.row.modelClassName ||'--' }}</template> | |||||
<template slot-scope="scope">{{ scope.row.modelClassName || "--" }}</template> | |||||
</el-table-column> | </el-table-column> | ||||
<el-table-column prop="modelDescription" label="模型描述" min-width="300px" show-overflow-tooltip /> | |||||
<el-table-column | |||||
prop="modelDescription" | |||||
label="模型描述" | |||||
min-width="300px" | |||||
show-overflow-tooltip | |||||
/> | |||||
<el-table-column v-if="isCustom" prop="versionNum" label="版本" width="80"> | <el-table-column v-if="isCustom" prop="versionNum" label="版本" width="80"> | ||||
<template slot-scope="scope"> | <template slot-scope="scope"> | ||||
<a | <a | ||||
@@ -88,6 +93,7 @@ | |||||
<template slot-scope="scope"> | <template slot-scope="scope"> | ||||
<el-button | <el-button | ||||
v-if="isCustom" | v-if="isCustom" | ||||
:id="`goVersion_`+scope.$index" | |||||
type="text" | type="text" | ||||
@click="goVersion(scope.row.id, scope.row.name)" | @click="goVersion(scope.row.id, scope.row.name)" | ||||
>历史版本</el-button> | >历史版本</el-button> | ||||
@@ -99,6 +105,7 @@ | |||||
> | > | ||||
<span :class="{'ml-10 mr-10': isCustom}"> | <span :class="{'ml-10 mr-10': isCustom}"> | ||||
<el-button | <el-button | ||||
:id="`doDownload_`+scope.$index" | |||||
:disabled="!scope.row.modelAddress" | :disabled="!scope.row.modelAddress" | ||||
type="text" | type="text" | ||||
@click="doDownload(scope.row)" | @click="doDownload(scope.row)" | ||||
@@ -107,11 +114,13 @@ | |||||
</el-tooltip> | </el-tooltip> | ||||
<el-button | <el-button | ||||
v-if="isCustom" | v-if="isCustom" | ||||
:id="`doEdit_`+scope.$index" | |||||
type="text" | type="text" | ||||
@click="doEdit(scope.row)" | @click="doEdit(scope.row)" | ||||
>编辑</el-button> | >编辑</el-button> | ||||
<el-button | <el-button | ||||
v-if="isCustom" | v-if="isCustom" | ||||
:id="`doDelete_`+scope.$index" | |||||
type="text" | type="text" | ||||
@click="doDelete(scope.row.id)" | @click="doDelete(scope.row.id)" | ||||
>删除</el-button> | >删除</el-button> | ||||
@@ -134,6 +143,7 @@ | |||||
<el-form ref="form" :model="form" :rules="rules" label-width="100px"> | <el-form ref="form" :model="form" :rules="rules" label-width="100px"> | ||||
<el-form-item label="模型名称" prop="name"> | <el-form-item label="模型名称" prop="name"> | ||||
<el-input | <el-input | ||||
id="name" | |||||
v-model.trim="form.name" | v-model.trim="form.name" | ||||
style="width: 300px;" | style="width: 300px;" | ||||
maxlength="15" | maxlength="15" | ||||
@@ -142,7 +152,12 @@ | |||||
/> | /> | ||||
</el-form-item> | </el-form-item> | ||||
<el-form-item label="框架" prop="frameType"> | <el-form-item label="框架" prop="frameType"> | ||||
<el-select v-model="form.frameType" placeholder="请选择框架" style="width: 300px;"> | |||||
<el-select | |||||
id="frameType" | |||||
v-model="form.frameType" | |||||
placeholder="请选择框架" | |||||
style="width: 300px;" | |||||
> | |||||
<el-option | <el-option | ||||
v-for="item in dict.frame_type" | v-for="item in dict.frame_type" | ||||
:key="item.value" | :key="item.value" | ||||
@@ -152,7 +167,12 @@ | |||||
</el-select> | </el-select> | ||||
</el-form-item> | </el-form-item> | ||||
<el-form-item label="模型格式" prop="modelType"> | <el-form-item label="模型格式" prop="modelType"> | ||||
<el-select v-model="form.modelType" placeholder="请选择模型格式" style="width: 300px;"> | |||||
<el-select | |||||
id="modelType" | |||||
v-model="form.modelType" | |||||
placeholder="请选择模型格式" | |||||
style="width: 300px;" | |||||
> | |||||
<el-option | <el-option | ||||
v-for="item in dict.model_type" | v-for="item in dict.model_type" | ||||
:key="item.value" | :key="item.value" | ||||
@@ -163,6 +183,7 @@ | |||||
</el-form-item> | </el-form-item> | ||||
<el-form-item label="模型类别" prop="modelClassName"> | <el-form-item label="模型类别" prop="modelClassName"> | ||||
<el-select | <el-select | ||||
id="modelClassName" | |||||
v-model="form.modelClassName" | v-model="form.modelClassName" | ||||
placeholder="请选择或输入模型类别" | placeholder="请选择或输入模型类别" | ||||
filterable | filterable | ||||
@@ -179,27 +200,35 @@ | |||||
</el-select> | </el-select> | ||||
</el-form-item> | </el-form-item> | ||||
<el-form-item label="模型描述" prop="modelDescription"> | <el-form-item label="模型描述" prop="modelDescription"> | ||||
<el-input v-model="form.modelDescription" type="textarea" placeholder="请输入模型描述" maxlength="255" show-word-limit style="width: 400px;" /> | |||||
<el-input | |||||
id="modelDescription" | |||||
v-model="form.modelDescription" | |||||
type="textarea" | |||||
placeholder="请输入模型描述" | |||||
maxlength="255" | |||||
show-word-limit | |||||
style="width: 400px;" | |||||
/> | |||||
</el-form-item> | </el-form-item> | ||||
</el-form> | </el-form> | ||||
</BaseModal> | </BaseModal> | ||||
<!--多步骤新增dialog--> | <!--多步骤新增dialog--> | ||||
<add-model-dialog | |||||
ref="addModel" | |||||
@addDone="addDone" | |||||
/> | |||||
<add-model-dialog ref="addModel" @addDone="addDone" /> | |||||
</div> | </div> | ||||
</template> | </template> | ||||
<script> | <script> | ||||
import crudModel, { del } from '@/api/model/model'; | |||||
import { list as getAlgorithmUsages, add as addAlgorithmUsage } from '@/api/algorithm/algorithmUsage'; | |||||
import CRUD, { presenter, header, form, crud } from '@crud/crud'; | |||||
import BaseModal from '@/components/BaseModal'; | |||||
import rrOperation from '@crud/RR.operation'; | |||||
import pagination from '@crud/Pagination'; | |||||
import { downloadZipFromObjectPath, validateNameWithHyphen } from '@/utils'; | |||||
import AddModelDialog from './components/addModelDialog'; | |||||
import crudModel, { del } from "@/api/model/model"; | |||||
import { | |||||
list as getAlgorithmUsages, | |||||
add as addAlgorithmUsage, | |||||
} from "@/api/algorithm/algorithmUsage"; | |||||
import CRUD, { presenter, header, form, crud } from "@crud/crud"; | |||||
import BaseModal from "@/components/BaseModal"; | |||||
import rrOperation from "@crud/RR.operation"; | |||||
import pagination from "@crud/Pagination"; | |||||
import { downloadZipFromObjectPath, validateNameWithHyphen } from "@/utils"; | |||||
import AddModelDialog from "./components/addModelDialog"; | |||||
const defaultForm = { | const defaultForm = { | ||||
name: null, | name: null, | ||||
@@ -235,7 +264,7 @@ export default { | |||||
{ max: 20, message: '长度在 20 个字符以内', trigger: 'blur' }, | { max: 20, message: '长度在 20 个字符以内', trigger: 'blur' }, | ||||
{ | { | ||||
validator: validateNameWithHyphen, | validator: validateNameWithHyphen, | ||||
trigger: ['blur', 'change'], | |||||
trigger: ["blur", "change"], | |||||
}, | }, | ||||
], | ], | ||||
frameType: [ | frameType: [ | ||||
@@ -245,7 +274,11 @@ export default { | |||||
{ required: true, message: '请选择模型格式', trigger: 'blur' }, | { required: true, message: '请选择模型格式', trigger: 'blur' }, | ||||
], | ], | ||||
modelClassName: [ | modelClassName: [ | ||||
{ required: true, message: '请输入模型类别', trigger: ['blur', 'change'] }, | |||||
{ | |||||
required: true, | |||||
message: '请输入模型类别', | |||||
trigger: ["blur", "change"], | |||||
}, | |||||
], | ], | ||||
modelDescription: [ | modelDescription: [ | ||||
{ required: true, message: '请输入模型描述', trigger: 'blur' }, | { required: true, message: '请输入模型描述', trigger: 'blur' }, | ||||
@@ -253,7 +286,7 @@ export default { | |||||
], | ], | ||||
}, | }, | ||||
algorithmUsageList: [], | algorithmUsageList: [], | ||||
active: '0', | |||||
active: "0", | |||||
}; | }; | ||||
}, | }, | ||||
computed: { | computed: { | ||||
@@ -291,7 +324,9 @@ export default { | |||||
this.getAlgorithmUsages(); | this.getAlgorithmUsages(); | ||||
}, | }, | ||||
onAlgorithmUsageChange(value) { | onAlgorithmUsageChange(value) { | ||||
const usageRes = this.algorithmUsageList.find(usage => usage.auxInfo === value); | |||||
const usageRes = this.algorithmUsageList.find( | |||||
usage => usage.auxInfo === value, | |||||
); | |||||
if (!usageRes) { | if (!usageRes) { | ||||
this.createAlgorithmUsage(value); | this.createAlgorithmUsage(value); | ||||
} | } | ||||
@@ -304,7 +339,7 @@ export default { | |||||
}, | }, | ||||
// link | // link | ||||
goVersion(id, name, type = 'detail') { | goVersion(id, name, type = 'detail') { | ||||
this.$router.push({ path: '/model/version', query: { id, name, type }}); | |||||
this.$router.push({ path: '/model/version', query: { id, name, type } }); | |||||
}, | }, | ||||
// op | // op | ||||
async doEdit(item) { | async doEdit(item) { | ||||
@@ -315,7 +350,7 @@ export default { | |||||
}, | }, | ||||
doDelete(id) { | doDelete(id) { | ||||
this.$confirm('此操作将永久删除该模型, 是否继续?', '请确认').then( | this.$confirm('此操作将永久删除该模型, 是否继续?', '请确认').then( | ||||
async() => { | |||||
async () => { | |||||
const params = { | const params = { | ||||
ids: [id], | ids: [id], | ||||
}; | }; | ||||
@@ -333,9 +368,11 @@ export default { | |||||
const msg = this.isCustom | const msg = this.isCustom | ||||
? `此操作将下载 ${name} 模型的 ${versionNum} 版本, 是否继续?` | ? `此操作将下载 ${name} 模型的 ${versionNum} 版本, 是否继续?` | ||||
: `此操作将下载预训练模型 ${name}, 是否继续?`; | : `此操作将下载预训练模型 ${name}, 是否继续?`; | ||||
this.$confirm(msg, '请确认').then( | |||||
this.$confirm(msg, "请确认").then( | |||||
() => { | () => { | ||||
const url = /^\//.test(modelAddress) ? modelAddress : `/${ modelAddress}`; | |||||
const url = /^\//.test(modelAddress) | |||||
? modelAddress | |||||
: `/${modelAddress}`; | |||||
downloadZipFromObjectPath(url, 'model.zip'); | downloadZipFromObjectPath(url, 'model.zip'); | ||||
this.$message({ | this.$message({ | ||||
message: '请查看下载文件', | message: '请查看下载文件', | ||||
@@ -42,10 +42,11 @@ | |||||
<el-table-column label="操作" width="150px" fixed="right"> | <el-table-column label="操作" width="150px" fixed="right"> | ||||
<template slot-scope="scope"> | <template slot-scope="scope"> | ||||
<el-button | <el-button | ||||
:id="`doDownload_`+scope.$index" | |||||
type="text" | type="text" | ||||
@click="doDownload(scope.row.parentId, scope.row.versionNum, scope.row.modelAddress)" | @click="doDownload(scope.row.parentId, scope.row.versionNum, scope.row.modelAddress)" | ||||
>下载</el-button> | >下载</el-button> | ||||
<el-button type="text" @click="doDelete(scope.row.id)">删除</el-button> | |||||
<el-button :id="`doDelete`+scope.$index" type="text" @click="doDelete(scope.row.id)">删除</el-button> | |||||
</template> | </template> | ||||
</el-table-column> | </el-table-column> | ||||
</el-table> | </el-table> | ||||
@@ -57,7 +58,9 @@ | |||||
:visible="crud.status.cu > 0" | :visible="crud.status.cu > 0" | ||||
:title="crud.status.title" | :title="crud.status.title" | ||||
:loading="crud.status.cu === 2" | :loading="crud.status.cu === 2" | ||||
:disabled="loading" | |||||
width="800px" | width="800px" | ||||
@close="onDialogClose" | |||||
@cancel="crud.cancelCU" | @cancel="crud.cancelCU" | ||||
@ok="onSubmit" | @ok="onSubmit" | ||||
> | > | ||||
@@ -68,9 +71,10 @@ | |||||
<el-form-item ref="modelAddress" label="模型上传" prop="modelAddress"> | <el-form-item ref="modelAddress" label="模型上传" prop="modelAddress"> | ||||
<upload-inline | <upload-inline | ||||
v-if="refreshFlag" | v-if="refreshFlag" | ||||
ref="upload" | |||||
action="fakeApi" | action="fakeApi" | ||||
accept=".zip,.pb,.h5,.ckpt,.pkl,.pth,.weight,.caffemodel,.pt" | |||||
:acceptSize="5120" | |||||
accept=".zip, .pb, .h5, .ckpt, .pkl, .pth, .weight, .caffemodel, .pt" | |||||
:acceptSize="0" | |||||
list-type="text" | list-type="text" | ||||
:limit="1" | :limit="1" | ||||
:multiple="false" | :multiple="false" | ||||
@@ -82,7 +86,14 @@ | |||||
@uploadSuccess="uploadSuccess" | @uploadSuccess="uploadSuccess" | ||||
@uploadError="uploadError" | @uploadError="uploadError" | ||||
/> | /> | ||||
<div v-if="loading"><i class="el-icon-loading" />模型上传中...</div> | |||||
<upload-progress | |||||
v-if="loading" | |||||
:progress="progress" | |||||
:color="customColors" | |||||
:status="status" | |||||
:size="size" | |||||
@onSetProgress="onSetProgress" | |||||
/> | |||||
</el-form-item> | </el-form-item> | ||||
</el-form> | </el-form> | ||||
</BaseModal> | </BaseModal> | ||||
@@ -90,14 +101,14 @@ | |||||
</template> | </template> | ||||
<script> | <script> | ||||
import crudModelVersion, {del} from '@/api/model/modelVersion'; | |||||
import crudModelVersion, { del } from '@/api/model/modelVersion'; | |||||
import CRUD, { presenter, header, form, crud } from '@crud/crud'; | import CRUD, { presenter, header, form, crud } from '@crud/crud'; | ||||
import BaseModal from '@/components/BaseModal'; | import BaseModal from '@/components/BaseModal'; | ||||
import cdOperation from '@crud/CD.operation'; | import cdOperation from '@crud/CD.operation'; | ||||
import pagination from '@crud/Pagination'; | import pagination from '@crud/Pagination'; | ||||
import UploadInline from '@/components/UploadForm/inline'; | import UploadInline from '@/components/UploadForm/inline'; | ||||
import { parseTime, downloadZipFromObjectPath } from '@/utils'; | |||||
import { nanoid } from 'nanoid'; | |||||
import UploadProgress from '@/components/UploadProgress'; | |||||
import { getUniqueId, downloadZipFromObjectPath } from '@/utils'; | |||||
const defaultForm = { | const defaultForm = { | ||||
parentId: null, | parentId: null, | ||||
@@ -107,7 +118,7 @@ const defaultForm = { | |||||
export default { | export default { | ||||
name: 'ModelVersion', | name: 'ModelVersion', | ||||
dicts: ['model_source'], | dicts: ['model_source'], | ||||
components: { BaseModal, pagination, cdOperation, UploadInline }, | |||||
components: { BaseModal, pagination, cdOperation, UploadInline, UploadProgress }, | |||||
cruds() { | cruds() { | ||||
return CRUD({ | return CRUD({ | ||||
title: '模型版本管理', | title: '模型版本管理', | ||||
@@ -135,12 +146,27 @@ export default { | |||||
], | ], | ||||
}, | }, | ||||
uploadParams: { | uploadParams: { | ||||
objectPath: `model/${this.$store.state.user.user.id}/${parseTime(new Date(), '{y}{m}{d}{h}{i}{s}{S}') + nanoid(4)}`, // 对象存储路径 | |||||
objectPath: null, // 对象存储路径 | |||||
}, | }, | ||||
refreshFlag: true, | refreshFlag: true, | ||||
loading: false, | loading: false, | ||||
progress: 0, | |||||
size: 0, | |||||
customColors: [ | |||||
{color: '#909399', percentage: 40}, | |||||
{color: '#e6a23c', percentage: 80}, | |||||
{color: '#67c23a', percentage: 100}, | |||||
], | |||||
}; | }; | ||||
}, | }, | ||||
computed: { | |||||
status() { | |||||
return this.progress === 100 ? 'success' : null; | |||||
}, | |||||
user() { | |||||
return this.$store.getters.user; | |||||
}, | |||||
}, | |||||
mounted() { | mounted() { | ||||
this.modelId = this.$route.query.id; | this.modelId = this.$route.query.id; | ||||
this.modelName = this.$route.query.name; | this.modelName = this.$route.query.name; | ||||
@@ -156,12 +182,20 @@ export default { | |||||
handleRemove() { | handleRemove() { | ||||
this.loading = false; | this.loading = false; | ||||
this.form.modelAddress = null; | this.form.modelAddress = null; | ||||
this.$refs.modelAddress.validate('manual'); | |||||
}, | |||||
uploadStart(files) { | |||||
this.updateImagePath(); | |||||
[ this.loading, this.size, this.progress ] = [ true, files.size, 0 ]; | |||||
}, | }, | ||||
uploadStart() { | |||||
this.loading = true; | |||||
onSetProgress(val) { | |||||
this.progress += val; | |||||
}, | }, | ||||
uploadSuccess(res) { | uploadSuccess(res) { | ||||
this.loading = false; | |||||
this.progress = 100; | |||||
setTimeout(() => { | |||||
this.loading = false; | |||||
}, 1000); | |||||
this.form.modelAddress = res[0].data.objectName; | this.form.modelAddress = res[0].data.objectName; | ||||
this.$refs.modelAddress.validate('manual'); | this.$refs.modelAddress.validate('manual'); | ||||
}, | }, | ||||
@@ -172,6 +206,10 @@ export default { | |||||
type: 'error', | type: 'error', | ||||
}); | }); | ||||
}, | }, | ||||
onDialogClose() { | |||||
this.$refs.upload.formRef.reset(); | |||||
this.loading = false; | |||||
}, | |||||
onSubmit() { | onSubmit() { | ||||
this.form.parentId = this.modelId; | this.form.parentId = this.modelId; | ||||
this.crud.submitCU(); | this.crud.submitCU(); | ||||
@@ -183,10 +221,13 @@ export default { | |||||
this.refreshFlag = true; | this.refreshFlag = true; | ||||
}); | }); | ||||
}, | }, | ||||
updateImagePath() { | |||||
this.uploadParams.objectPath = `upload-temp/${this.user.id}/${getUniqueId()}`; | |||||
}, | |||||
// op | // op | ||||
doDelete(id) { | doDelete(id) { | ||||
this.$confirm('此操作将永久删除该模型, 是否继续?', '请确认') | |||||
.then(async() => { | |||||
this.$confirm('此操作将永久删除该模型, 是否继续?', '请确认').then( | |||||
async () => { | |||||
const params = { | const params = { | ||||
ids: [id], | ids: [id], | ||||
}; | }; | ||||
@@ -196,19 +237,22 @@ export default { | |||||
type: 'success', | type: 'success', | ||||
}); | }); | ||||
this.crud.refresh(); | this.crud.refresh(); | ||||
}); | |||||
}, | |||||
); | |||||
}, | }, | ||||
doDownload(parentId, versionNum, filepath) { | doDownload(parentId, versionNum, filepath) { | ||||
const msg = `此操作将下载${this.modelName}模型的${versionNum}版本, 是否继续?`; | const msg = `此操作将下载${this.modelName}模型的${versionNum}版本, 是否继续?`; | ||||
this.$confirm(msg, '请确认') | |||||
.then(() => { | |||||
const url = /^\//.test(filepath) ? filepath : `/${ filepath}`; | |||||
downloadZipFromObjectPath(url, 'model.zip'); | |||||
this.$confirm(msg, "请确认").then( | |||||
() => { | |||||
const url = /^\//.test(filepath) ? filepath : `/${filepath}`; | |||||
downloadZipFromObjectPath(url, "model.zip"); | |||||
this.$message({ | this.$message({ | ||||
message: '请查看下载文件', | message: '请查看下载文件', | ||||
type: 'success', | type: 'success', | ||||
}); | }); | ||||
}, () => {}); | |||||
}, | |||||
() => {}, | |||||
); | |||||
}, | }, | ||||
}, | }, | ||||
}; | }; | ||||
@@ -31,7 +31,7 @@ | |||||
<el-input v-model="form.label" style="width: 370px;" maxlength="50" show-word-limit /> | <el-input v-model="form.label" style="width: 370px;" maxlength="50" show-word-limit /> | ||||
</el-form-item> | </el-form-item> | ||||
<el-form-item label="字典值" prop="value"> | <el-form-item label="字典值" prop="value"> | ||||
<el-input v-model="form.value" style="width: 370px;" maxlength="50" show-word-limit /> | |||||
<el-input v-model="form.value" style="width: 370px;" maxlength="255" show-word-limit /> | |||||
</el-form-item> | </el-form-item> | ||||
<el-form-item label="排序" prop="sort"> | <el-form-item label="排序" prop="sort"> | ||||
<el-input-number v-model.number="form.sort" :min="0" :max="999" style="width: 370px;" /> | <el-input-number v-model.number="form.sort" :min="0" :max="999" style="width: 370px;" /> | ||||
@@ -87,28 +87,15 @@ export default { | |||||
sort: this.crud.data.length + 1, ...defaultForm}; | sort: this.crud.data.length + 1, ...defaultForm}; | ||||
})], | })], | ||||
data() { | data() { | ||||
const validateAccount = (rule, value, callback) => { | |||||
if (value === '' || value == null) { | |||||
callback(); | |||||
} else if (value.length > 50) { | |||||
callback(new Error('长度不超过 50 个字符')); | |||||
} else if (!/^[\u4E00-\u9FA5A-Za-z0-9:_-]+$/.test(value)) { | |||||
callback(new Error('只支持中英文、数字、下划线、横杠和英文冒号')); | |||||
} else { | |||||
callback(); | |||||
} | |||||
}; | |||||
return { | return { | ||||
dictId: null, | dictId: null, | ||||
dictName: '', | dictName: '', | ||||
rules: { | rules: { | ||||
label: [ | label: [ | ||||
{ required: true, message: '请输入字典标签', trigger: 'blur' }, | { required: true, message: '请输入字典标签', trigger: 'blur' }, | ||||
{ validator: validateAccount, trigger: 'change' }, | |||||
], | ], | ||||
value: [ | value: [ | ||||
{ required: true, message: '请输入字典值', trigger: 'blur' }, | { required: true, message: '请输入字典值', trigger: 'blur' }, | ||||
{ validator: validateAccount, trigger: 'change' }, | |||||
], | ], | ||||
sort: [ | sort: [ | ||||
{ required: true, message: '请输入序号', trigger: 'blur', type: 'number' }, | { required: true, message: '请输入序号', trigger: 'blur', type: 'number' }, | ||||
@@ -41,36 +41,6 @@ | |||||
:picker-options="pickerOptions" | :picker-options="pickerOptions" | ||||
@change="crud.toQuery" | @change="crud.toQuery" | ||||
/> | /> | ||||
<el-select | |||||
v-model="query.roleId" | |||||
clearable | |||||
placeholder="请选择角色" | |||||
class="filter-item" | |||||
style="width: 120px;" | |||||
@change="crud.toQuery" | |||||
> | |||||
<el-option | |||||
v-for="item in roleOptions" | |||||
:key="item.id" | |||||
:label="item.name" | |||||
:value="item.id" | |||||
/> | |||||
</el-select> | |||||
<el-select | |||||
v-model="query.enabled" | |||||
clearable | |||||
placeholder="状态" | |||||
class="filter-item" | |||||
style="width: 80px;" | |||||
@change="crud.toQuery" | |||||
> | |||||
<el-option | |||||
v-for="item in dict.user_status" | |||||
:key="item.value" | |||||
:label="item.label" | |||||
:value="item.value" | |||||
/> | |||||
</el-select> | |||||
<rrOperation /> | <rrOperation /> | ||||
</span> | </span> | ||||
</cdOperation> | </cdOperation> | ||||
@@ -142,12 +112,28 @@ | |||||
<el-table-column prop="sex" width="60" label="性别" /> | <el-table-column prop="sex" width="60" label="性别" /> | ||||
<el-table-column show-overflow-tooltip prop="phone" width="120" label="手机号" /> | <el-table-column show-overflow-tooltip prop="phone" width="120" label="手机号" /> | ||||
<el-table-column show-overflow-tooltip prop="email" label="邮箱" /> | <el-table-column show-overflow-tooltip prop="email" label="邮箱" /> | ||||
<el-table-column show-overflow-tooltip prop="rodes" label="角色"> | |||||
<el-table-column show-overflow-tooltip prop="roles"> | |||||
<template #header> | |||||
<dropdown-header | |||||
title="角色" | |||||
:list="userRoleList" | |||||
:filtered="Boolean(crud.query.roleId)" | |||||
@command="filterByRoles" | |||||
/> | |||||
</template> | |||||
<template slot-scope="scope"> | <template slot-scope="scope"> | ||||
<span>{{ getUserRoles(scope.row) }}</span> | <span>{{ getUserRoles(scope.row) }}</span> | ||||
</template> | </template> | ||||
</el-table-column> | </el-table-column> | ||||
<el-table-column label="状态" prop="enabled" width="80"> | |||||
<el-table-column prop="enabled" width="80"> | |||||
<template #header> | |||||
<dropdown-header | |||||
title="状态" | |||||
:list="userStatusList" | |||||
:filtered="Boolean(crud.query.enabled)" | |||||
@command="filterByStatus" | |||||
/> | |||||
</template> | |||||
<template slot-scope="scope"> | <template slot-scope="scope"> | ||||
<el-tag :type="scope.row.enabled ? '' : 'info'" effect="plain">{{ dict.label.user_status[scope.row.enabled.toString()] }} </el-tag> | <el-tag :type="scope.row.enabled ? '' : 'info'" effect="plain">{{ dict.label.user_status[scope.row.enabled.toString()] }} </el-tag> | ||||
</template> | </template> | ||||
@@ -181,6 +167,7 @@ import { validateName, validateAccount } from '@/utils/validate'; | |||||
import crudUser from '@/api/system/user'; | import crudUser from '@/api/system/user'; | ||||
import { getAll } from '@/api/system/role'; | import { getAll } from '@/api/system/role'; | ||||
import BaseModal from '@/components/BaseModal'; | import BaseModal from '@/components/BaseModal'; | ||||
import DropdownHeader from '@/components/DropdownHeader'; | |||||
import datePickerMixin from '@/mixins/datePickerMixin'; | import datePickerMixin from '@/mixins/datePickerMixin'; | ||||
const ADMIN_USER_ID = 1; // 系统管理员ID | const ADMIN_USER_ID = 1; // 系统管理员ID | ||||
@@ -188,7 +175,7 @@ const ADMIN_USER_ID = 1; // 系统管理员ID | |||||
const defaultForm = { id: null, username: null, nickName: null, sex: null, email: null, remark: null, enabled: null, phone: null, roles: [], roleId: '' }; | const defaultForm = { id: null, username: null, nickName: null, sex: null, email: null, remark: null, enabled: null, phone: null, roles: [], roleId: '' }; | ||||
export default { | export default { | ||||
name: 'User', | name: 'User', | ||||
components: { BaseModal, cdOperation, rrOperation, udOperation, pagination }, | |||||
components: { BaseModal, cdOperation, rrOperation, udOperation, pagination, DropdownHeader }, | |||||
cruds() { | cruds() { | ||||
return CRUD({ title: '用户', crudMethod: { ...crudUser }}); | return CRUD({ title: '用户', crudMethod: { ...crudUser }}); | ||||
}, | }, | ||||
@@ -233,6 +220,16 @@ export default { | |||||
...mapGetters([ | ...mapGetters([ | ||||
'user', | 'user', | ||||
]), | ]), | ||||
userStatusList() { | |||||
return [{ label: '全部', value: null }].concat(this.dict.user_status); | |||||
}, | |||||
userRoleList() { | |||||
const arr = [{ label: '全部', value: null }]; | |||||
this.roleOptions.forEach(item => { | |||||
arr.push({ label: item.name, value: item.id }); | |||||
}); | |||||
return arr; | |||||
}, | |||||
}, | }, | ||||
created() { | created() { | ||||
this.$nextTick(() => { | this.$nextTick(() => { | ||||
@@ -288,6 +285,14 @@ export default { | |||||
const names = roles.map(role => role.name); | const names = roles.map(role => role.name); | ||||
return names.join('<br/>') || '-'; | return names.join('<br/>') || '-'; | ||||
}, | }, | ||||
filterByStatus(status) { | |||||
this.crud.query.enabled = status; | |||||
this.crud.refresh(); | |||||
}, | |||||
filterByRoles(id) { | |||||
this.crud.query.roleId = id; | |||||
this.crud.refresh(); | |||||
}, | |||||
}, | }, | ||||
}; | }; | ||||
</script> | </script> |
@@ -18,14 +18,28 @@ | |||||
<div class="app-container"> | <div class="app-container"> | ||||
<!--工具栏--> | <!--工具栏--> | ||||
<div class="head-container"> | <div class="head-container"> | ||||
<cdOperation :addProps="operationProps" /> | |||||
<cdOperation :addProps="operationProps"> | |||||
<span slot="right"> | |||||
<el-input | |||||
v-model="localQuery.imageNameOrId" | |||||
clearable | |||||
placeholder="请输入镜像名称或ID" | |||||
class="filter-item" | |||||
style="width: 200px;" | |||||
@keyup.enter.native="crud.toQuery" | |||||
@clear="crud.toQuery" | |||||
/> | |||||
<rrOperation @resetQuery="resetQuery" /> | |||||
</span> | |||||
</cdOperation> | |||||
</div> | </div> | ||||
<el-tabs v-model="active" class="eltabs-inlineblock" @tab-click="handleClick"> | <el-tabs v-model="active" class="eltabs-inlineblock" @tab-click="handleClick"> | ||||
<el-tab-pane label="我的镜像" name="0" /> | |||||
<el-tab-pane label="预置镜像" name="1" /> | |||||
<el-tab-pane id="tab_0" label="我的镜像" name="0" /> | |||||
<el-tab-pane id="tab_1" label="预置镜像" name="1" /> | |||||
</el-tabs> | </el-tabs> | ||||
<!--表格渲染--> | <!--表格渲染--> | ||||
<el-table | <el-table | ||||
v-if="prefabricate" | |||||
ref="table" | ref="table" | ||||
v-loading="crud.loading || disableEdit" | v-loading="crud.loading || disableEdit" | ||||
:data="crud.data" | :data="crud.data" | ||||
@@ -33,10 +47,10 @@ | |||||
@selection-change="crud.selectionChangeHandler" | @selection-change="crud.selectionChangeHandler" | ||||
@sort-change="crud.sortChange" | @sort-change="crud.sortChange" | ||||
> | > | ||||
<el-table-column v-if="active == 0" prop="id" label="ID" sortable="custom" width="80px" /> | |||||
<el-table-column v-if="isShow" prop="id" label="ID" sortable="custom" width="80px" /> | |||||
<el-table-column prop="imageName" label="镜像名称" sortable="custom" /> | <el-table-column prop="imageName" label="镜像名称" sortable="custom" /> | ||||
<el-table-column prop="imageTag" label="镜像版本号" sortable="custom" /> | <el-table-column prop="imageTag" label="镜像版本号" sortable="custom" /> | ||||
<el-table-column prop="imageStatus" width="160px"> | |||||
<el-table-column prop="imageStatus" label="状态" width="160px"> | |||||
<template #header> | <template #header> | ||||
<dropdown-header | <dropdown-header | ||||
title="状态" | title="状态" | ||||
@@ -57,6 +71,16 @@ | |||||
<span>{{ parseTime(scope.row.createTime) }}</span> | <span>{{ parseTime(scope.row.createTime) }}</span> | ||||
</template> | </template> | ||||
</el-table-column> | </el-table-column> | ||||
<el-table-column v-if="isShow" label="操作" width="200px" fixed="right"> | |||||
<template slot-scope="scope"> | |||||
<el-button :id="`doEdit_`+scope.$index" type="text" @click.stop="doEdit(scope.row)"> | |||||
修改 | |||||
</el-button> | |||||
<el-button :id="`doDelete_`+scope.$index" type="text" @click.stop="doDelete(scope.row.id)"> | |||||
删除 | |||||
</el-button> | |||||
</template> | |||||
</el-table-column> | |||||
</el-table> | </el-table> | ||||
<!--分页组件--> | <!--分页组件--> | ||||
<pagination /> | <pagination /> | ||||
@@ -78,48 +102,63 @@ | |||||
:rules="rules" | :rules="rules" | ||||
label-width="120px" | label-width="120px" | ||||
> | > | ||||
<el-form-item label="镜像名称" prop="imageName"> | |||||
<el-form-item v-if="isEdit" label="镜像名称" prop="imageName"> | |||||
<el-select | <el-select | ||||
id="imageName" | |||||
v-model="form.imageName" | v-model="form.imageName" | ||||
placeholder="请选择镜像名称" | |||||
placeholder="请选择或输入镜像名称" | |||||
style="width: 400px;" | style="width: 400px;" | ||||
clearable | clearable | ||||
filterable | |||||
allow-create | |||||
default-first-option | |||||
@focus="getHarborProjects" | @focus="getHarborProjects" | ||||
> | > | ||||
<el-option | <el-option | ||||
v-for="(item, index) in harborProjectList" | |||||
:key="index" | |||||
:label="item.imageName" | |||||
:value="item.imageName" | |||||
v-for="item in harborProjectList" | |||||
:key="item" | |||||
:label="item" | |||||
:value="item" | |||||
/> | /> | ||||
</el-select> | </el-select> | ||||
</el-form-item> | </el-form-item> | ||||
<el-form-item label="镜像文件路径" prop="imagePath"> | |||||
<el-form-item v-if="isEdit" ref="imagePath" label="镜像文件路径" prop="imagePath"> | |||||
<upload-inline | <upload-inline | ||||
v-if="crud.status.cu > 0" | |||||
ref="upload" | ref="upload" | ||||
action="fakeApi" | action="fakeApi" | ||||
accept=".zip,.tar,.rar,.gz" | accept=".zip,.tar,.rar,.gz" | ||||
list-type="text" | list-type="text" | ||||
:acceptSize="5120" | |||||
:acceptSize="0" | |||||
:params="uploadParams" | :params="uploadParams" | ||||
:show-file-count="false" | :show-file-count="false" | ||||
:auto-upload="true" | :auto-upload="true" | ||||
:hash="false" | :hash="false" | ||||
:limit="1" | :limit="1" | ||||
:on-remove="onFileRemove" | |||||
@uploadStart="uploadStart" | @uploadStart="uploadStart" | ||||
@uploadSuccess="uploadSuccess" | @uploadSuccess="uploadSuccess" | ||||
@uploadError="uploadError" | @uploadError="uploadError" | ||||
/> | /> | ||||
<div v-if="loading"><i class="el-icon-loading" />镜像上传中...</div> | |||||
<upload-progress | |||||
v-if="loading" | |||||
:progress="progress" | |||||
:color="customColors" | |||||
:status="status" | |||||
:size="size" | |||||
@onSetProgress="onSetProgress" | |||||
/> | |||||
</el-form-item> | </el-form-item> | ||||
<el-form-item label="镜像版本号" prop="imageTag"> | |||||
<el-form-item v-if="isEdit" label="镜像版本号" prop="imageTag"> | |||||
<el-input | <el-input | ||||
id="imageTag" | |||||
v-model="form.imageTag" | v-model="form.imageTag" | ||||
style="width: 400px;" | style="width: 400px;" | ||||
/> | /> | ||||
</el-form-item> | </el-form-item> | ||||
<el-form-item label="描述" prop="remark"> | <el-form-item label="描述" prop="remark"> | ||||
<el-input | <el-input | ||||
id="remark" | |||||
v-model="form.remark" | v-model="form.remark" | ||||
type="textarea" | type="textarea" | ||||
:rows="4" | :rows="4" | ||||
@@ -135,18 +174,19 @@ | |||||
</template> | </template> | ||||
<script> | <script> | ||||
import { nanoid } from 'nanoid'; | |||||
// eslint-disable-next-line import/no-extraneous-dependencies | // eslint-disable-next-line import/no-extraneous-dependencies | ||||
import { debounce } from 'throttle-debounce'; | import { debounce } from 'throttle-debounce'; | ||||
import cdOperation from '@crud/CD.operation'; | import cdOperation from '@crud/CD.operation'; | ||||
import rrOperation from '@crud/RR.operation'; | |||||
import pagination from '@crud/Pagination'; | import pagination from '@crud/Pagination'; | ||||
import CRUD, { presenter, header, form, crud } from '@crud/crud'; | import CRUD, { presenter, header, form, crud } from '@crud/crud'; | ||||
import { parseTime } from '@/utils'; | |||||
import trainingImageApi, {project} from '@/api/trainingImage/index'; | |||||
import trainingImageApi, { imageNameList, del } from '@/api/trainingImage/index'; | |||||
import { getUniqueId } from '@/utils'; | |||||
import BaseModal from '@/components/BaseModal'; | import BaseModal from '@/components/BaseModal'; | ||||
import UploadInline from '@/components/UploadForm/inline'; | import UploadInline from '@/components/UploadForm/inline'; | ||||
import DropdownHeader from '@/components/DropdownHeader'; | import DropdownHeader from '@/components/DropdownHeader'; | ||||
import UploadProgress from '@/components/UploadProgress'; | |||||
const defaultForm = { | const defaultForm = { | ||||
imageName: null, | imageName: null, | ||||
@@ -160,8 +200,10 @@ export default { | |||||
BaseModal, | BaseModal, | ||||
pagination, | pagination, | ||||
cdOperation, | cdOperation, | ||||
rrOperation, | |||||
UploadInline, | UploadInline, | ||||
DropdownHeader, | DropdownHeader, | ||||
UploadProgress, | |||||
}, | }, | ||||
cruds() { | cruds() { | ||||
return CRUD({ | return CRUD({ | ||||
@@ -194,10 +236,22 @@ export default { | |||||
callback(); | callback(); | ||||
} | } | ||||
}; | }; | ||||
const validateImageName = (rule, value, callback) => { | |||||
if (value === '' || value == null) { | |||||
callback(); | |||||
} else if (value.length > 64) { | |||||
callback(new Error('长度不超过 64 个字符')); | |||||
} else if (!/^[a-z0-9_-]+$/.test(value)) { | |||||
callback(new Error('只支持小写英文、数字、下划线和横杠')); | |||||
} else { | |||||
callback(); | |||||
} | |||||
}; | |||||
return { | return { | ||||
active: '0', | active: '0', | ||||
localQuery: { | localQuery: { | ||||
imageStatus: null, | imageStatus: null, | ||||
imageNameOrId: null, | |||||
}, | }, | ||||
map: { | map: { | ||||
0: 'info', | 0: 'info', | ||||
@@ -212,9 +266,10 @@ export default { | |||||
rules: { | rules: { | ||||
imageName: [ | imageName: [ | ||||
{ required: true, message: '请选择项目名称', trigger: 'change' }, | { required: true, message: '请选择项目名称', trigger: 'change' }, | ||||
{ validator: validateImageName, trigger: ['blur', 'change'] }, | |||||
], | ], | ||||
imagePath: [ | imagePath: [ | ||||
{ required: true, message: '请输入镜像路径', trigger: 'blur' }, | |||||
{ required: true, message: '请输入镜像路径', trigger: ['blur', 'manual'] }, | |||||
], | ], | ||||
imageTag: [ | imageTag: [ | ||||
{ required: true, message: '请输入镜像版本号', trigger: 'blur' }, | { required: true, message: '请输入镜像版本号', trigger: 'blur' }, | ||||
@@ -226,11 +281,23 @@ export default { | |||||
uploadParams: { | uploadParams: { | ||||
objectPath: null, // 对象存储路径 | objectPath: null, // 对象存储路径 | ||||
}, | }, | ||||
progress: 0, | |||||
size: 0, | |||||
customColors: [ | |||||
{color: '#909399', percentage: 40}, | |||||
{color: '#e6a23c', percentage: 80}, | |||||
{color: '#67c23a', percentage: 100}, | |||||
], | |||||
disableEdit: false, | disableEdit: false, | ||||
loading: false, | loading: false, | ||||
isEdit: false, | |||||
prefabricate: true, | |||||
}; | }; | ||||
}, | }, | ||||
computed: { | computed: { | ||||
isShow() { | |||||
return this.active === '0'; | |||||
}, | |||||
operationProps() { | operationProps() { | ||||
return { | return { | ||||
disabled: Number(this.active) === 1, | disabled: Number(this.active) === 1, | ||||
@@ -243,9 +310,12 @@ export default { | |||||
} | } | ||||
return arr; | return arr; | ||||
}, | }, | ||||
getUser() { | |||||
user() { | |||||
return this.$store.getters.user; | return this.$store.getters.user; | ||||
}, | }, | ||||
status() { | |||||
return this.progress === 100 ? 'success' : null; | |||||
}, | |||||
}, | }, | ||||
mounted() { | mounted() { | ||||
this.crud.query.imageResource = Number(this.active); | this.crud.query.imageResource = Number(this.active); | ||||
@@ -258,19 +328,31 @@ export default { | |||||
handleClick() { | handleClick() { | ||||
this.crud.query.imageResource = Number(this.active); | this.crud.query.imageResource = Number(this.active); | ||||
this.crud.refresh(); | this.crud.refresh(); | ||||
// 切换tab键时让表格重渲 | |||||
this.prefabricate = false; | |||||
this.$nextTick(() => { this.prefabricate = true; }); | |||||
}, | }, | ||||
handleClose(done) { | |||||
done(); | |||||
onFileRemove() { | |||||
this.form.imagePath = null; | |||||
this.loading = false; | |||||
this.$refs.imagePath.validate('manual'); | |||||
}, | }, | ||||
uploadStart() { | |||||
this.loading = true; | |||||
uploadStart(files) { | |||||
this.updateImagePath(); | |||||
[ this.loading, this.size, this.progress ] = [ true, files.size, 0 ]; | |||||
}, | }, | ||||
updateRunParams(p) { | |||||
this.form.runParams = p; | |||||
onSetProgress(val) { | |||||
this.progress += val; | |||||
}, | }, | ||||
uploadSuccess(res) { | uploadSuccess(res) { | ||||
this.loading = false; | |||||
this.form.imagePath = res[0].data.objectName; | |||||
this.progress = 100; | |||||
setTimeout(() => { | |||||
this.loading = false; | |||||
}, 1000); | |||||
if (this.loading) { | |||||
this.form.imagePath = res[0].data.objectName; | |||||
this.$refs.imagePath.validate('manual'); | |||||
} | |||||
}, | }, | ||||
uploadError() { | uploadError() { | ||||
this.$message({ | this.$message({ | ||||
@@ -284,18 +366,24 @@ export default { | |||||
this.checkStatus(); | this.checkStatus(); | ||||
}, | }, | ||||
[CRUD.HOOK.beforeToAdd]() { | [CRUD.HOOK.beforeToAdd]() { | ||||
this.isEdit = true; | |||||
this.formType = 'add'; | this.formType = 'add'; | ||||
this.updateImagePath(); | |||||
}, | }, | ||||
[CRUD.HOOK.beforeRefresh]() { | [CRUD.HOOK.beforeRefresh]() { | ||||
this.crud.query = { ...this.localQuery}; | this.crud.query = { ...this.localQuery}; | ||||
this.crud.query.imageResource = Number(this.active); | this.crud.query.imageResource = Number(this.active); | ||||
}, | }, | ||||
[CRUD.HOOK.beforeToEdit]() { | |||||
this.isEdit = false; | |||||
}, | |||||
async getHarborProjects() { | async getHarborProjects() { | ||||
this.harborProjectList = await project(); | |||||
this.harborProjectList = await imageNameList(); | |||||
}, | }, | ||||
onDialogClose() { | onDialogClose() { | ||||
this.$refs.upload.formRef.reset(); | |||||
if (this.isEdit) { | |||||
this.$refs.upload.formRef.reset(); | |||||
} | |||||
this.loading = false; | |||||
}, | }, | ||||
checkStatus() { | checkStatus() { | ||||
if (this.crud.data.some(item => [0].includes(item.imageStatus))) { | if (this.crud.data.some(item => [0].includes(item.imageStatus))) { | ||||
@@ -306,8 +394,33 @@ export default { | |||||
this.localQuery.imageStatus = status; | this.localQuery.imageStatus = status; | ||||
this.crud.toQuery(); | this.crud.toQuery(); | ||||
}, | }, | ||||
resetQuery() { | |||||
this.localQuery = { | |||||
imageStatus: null, | |||||
imageNameOrId: null, | |||||
}; | |||||
}, | |||||
updateImagePath() { | updateImagePath() { | ||||
this.uploadParams.objectPath = `upload-image/${this.getUser.id}/${parseTime(new Date(), '{y}{m}{d}{h}{i}{s}{S}') + nanoid(4)}`; | |||||
this.uploadParams.objectPath = `upload-temp/${this.user.id}/${getUniqueId()}`; | |||||
}, | |||||
async doEdit(imageObj) { | |||||
const dataObj = { | |||||
ids: [imageObj.id], | |||||
...imageObj, | |||||
}; | |||||
await this.crud.toEdit(dataObj); | |||||
}, | |||||
doDelete(id) { | |||||
this.$confirm('此操作将永久删除该镜像, 是否继续?', '请确认').then( | |||||
async() => { | |||||
await del({ ids: [id] }); | |||||
this.$message({ | |||||
message: '删除成功', | |||||
type: 'success', | |||||
}); | |||||
this.crud.refresh(); | |||||
}, | |||||
); | |||||
}, | }, | ||||
}, | }, | ||||
}; | }; | ||||