@@ -7,7 +7,7 @@ VUE_APP_BASE_API = '' | |||
VUE_APP_DATA_API = '' | |||
# minio | |||
VUE_APP_MINIO_API = '' | |||
VUE_APP_MINIO_API = '' // 建议使用与当前 web 服务域名的域名 | |||
# atlas | |||
# atlas 模型炼知 | |||
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/comment-directive": "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模型高效训练。多维度产品形态满足从开发者到大型企业的不同需求,将提升人工智能技术的研发效率、扩大算法模型的应用范围,进一步构建人工智能生态“朋友圈”。 | |||
## 特性 | |||
* 一站式开发 | |||
* 集成先进算法 | |||
* 灵活易用 | |||
* 性能优越 | |||
## 预览 | |||
![概览](/public/dubhe_dashboard.png "概览") | |||
## 源码部署 | |||
### 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 | |||
# 进入前端项目根目录 | |||
cd webapp | |||
# 下载源码 | |||
git clone https://codeup.teambition.com/zhejianglab/dubhe-web.git | |||
# 进入项目根目录 | |||
cd dubhe-web | |||
# 安装依赖 | |||
npm install | |||
@@ -1,6 +1,6 @@ | |||
{ | |||
"name": "dubhe-web", | |||
"version": "1.0.0", | |||
"version": "1.1.0", | |||
"description": "之江天枢人工智能开源平台", | |||
"author": "zhejianglab", | |||
"keywords": [ | |||
@@ -13,6 +13,8 @@ | |||
"scripts": { | |||
"dev": "vue-cli-service serve --open", | |||
"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", | |||
"fix": "eslint --fix --ext .js,.vue src", | |||
"lint:style": "stylelint src/**/*.{html,vue,css,sass,scss}", | |||
@@ -54,6 +56,7 @@ | |||
"filereader-stream": "^2.0.0", | |||
"jquery": "^3.5.1", | |||
"jquery-contextmenu": "^2.9.1", | |||
"js-beautify": "^1.13.0", | |||
"js-cookie": "2.2.0", | |||
"jsencrypt": "^3.0.0-rc.1", | |||
"json2csv": "^5.0.1", | |||
@@ -72,7 +75,9 @@ | |||
"v-hotkey": "^0.8.0", | |||
"vee-validate": "^3.3.0", | |||
"vue": "2.6.10", | |||
"vue-copy-to-clipboard": "^1.0.3", | |||
"vue-prism-component": "^1.2.0", | |||
"vue-prism-editor": "^1.2.2", | |||
"vue-router": "^3.0.2", | |||
"vuex": "3.1.0" | |||
}, | |||
@@ -16,7 +16,9 @@ | |||
<template> | |||
<div id="app"> | |||
<router-view /> | |||
<keep-alive include="DataSet"> | |||
<router-view/> | |||
</keep-alive> | |||
</div> | |||
</template> | |||
@@ -16,9 +16,9 @@ | |||
import request from '@/utils/request'; | |||
export function batchFinishAnnotation(data) { | |||
export function batchFinishAnnotation(data, datasetId) { | |||
return request({ | |||
url: 'api/data/datasets/files/annotations', | |||
url: `api/data/datasets/files/${datasetId}/annotations`, | |||
method: 'post', | |||
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) { | |||
const data = { datasetIds: ids }; | |||
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() { | |||
return request({ | |||
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) { | |||
return request({ | |||
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) { | |||
return request({ | |||
@@ -153,9 +169,9 @@ export function postDataEnhance(datasetId, types = []) { | |||
} | |||
// 指定原始文件,获取增强文件列表 | |||
export function getEnhanceFileList(fileId) { | |||
export function getEnhanceFileList(datasetId, fileId) { | |||
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 }; |
@@ -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() { | |||
return request({ | |||
url: `api/v1/ptImage/project`, | |||
url: `api/v1/ptImage/imageNameList`, | |||
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({ | |||
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', | |||
}); | |||
} | |||
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({ | |||
url: `api/v1/trainLog`, | |||
url: `api/v1/trainJob/jobDetail`, | |||
method: 'get', | |||
params, | |||
params: { id: jobId }, | |||
}); | |||
} | |||
export function downloadTrainLog(params) { | |||
export function getTrainLog(params) { | |||
return request({ | |||
url: `api/v1/trainLog/download`, | |||
url: `api/v1/trainLog`, | |||
method: 'get', | |||
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; | |||
} | |||
.success { | |||
color: $successColor; | |||
} | |||
.g3 { | |||
color: #333; | |||
} | |||
@@ -126,7 +126,6 @@ | |||
} | |||
.text { | |||
height: 19px; | |||
font-size: 14px; | |||
line-height: 19px; | |||
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 { | |||
@@ -242,3 +229,9 @@ | |||
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 | |||
.el-radio-button__inner { | |||
padding: 8px 25px; | |||
@@ -298,3 +306,27 @@ | |||
.el-tooltip__popper { | |||
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:focus, | |||
a:hover { | |||
color: inherit; | |||
text-decoration: none; | |||
cursor: pointer; | |||
} | |||
@@ -87,6 +86,10 @@ ol li { | |||
color: $infoColor; | |||
} | |||
.fontBold { | |||
font-weight: bold; | |||
} | |||
.primary-bg { | |||
background-color: $primaryColor; | |||
@@ -100,3 +103,20 @@ p.error-message { | |||
font-size: 12px; | |||
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; | |||
$borderColorDark: #c0c4cc; | |||
$black: #001529; | |||
$dark: #323232; | |||
// sidebar | |||
$menuBg: #f3f7ff; | |||
@@ -97,10 +97,10 @@ const BaseModal = { | |||
return ( | |||
<div class='modal-footer'> | |||
{ 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> | |||
); | |||
}; | |||
@@ -19,6 +19,7 @@ | |||
<span class="cd-opts-left"> | |||
<el-button | |||
v-if="crud.optShow.add" | |||
id="toAdd" | |||
v-bind="addProps" | |||
class="filter-item" | |||
type="primary" | |||
@@ -31,6 +32,7 @@ | |||
<slot name="left" /> | |||
<el-button | |||
v-if="crud.optShow.del" | |||
id="toDelete" | |||
slot="reference" | |||
class="filter-item" | |||
type="danger" | |||
@@ -18,6 +18,7 @@ | |||
<span> | |||
<el-button | |||
v-if="crud.optShow.reset" | |||
id="toReset" | |||
class="filter-item" | |||
:icon="crud.props.resetIconShow ? `el-icon-refresh-left` : ''" | |||
@click="resetQuery" | |||
@@ -25,6 +26,7 @@ | |||
{{ crud.props.optText.reset }} | |||
</el-button> | |||
<el-button | |||
id="toQuery" | |||
class="filter-item" | |||
type="primary" | |||
: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> | |||
<style lang="scss"> | |||
@import '@/assets/styles/variables.scss'; | |||
.exception { | |||
display: flex; | |||
align-items: center; | |||
justify-content: center; | |||
height: 100%; | |||
margin: 0 auto; | |||
color: $infoColor; | |||
text-align: center; | |||
.imgBlock { | |||
font-size: 48px; | |||
} | |||
.content { | |||
margin-top: 10px; | |||
} | |||
} | |||
</style> |
@@ -26,7 +26,7 @@ | |||
import create from './iconfont'; | |||
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' }, | |||
}); | |||
@@ -53,7 +53,7 @@ | |||
:class="rootClass + '__img'" | |||
@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)" /> | |||
<div v-show="showOption(dataImage.id)" :title="dataImage.name" class="img-name-row"> | |||
<div class="img-name">{{ basename(dataImage.url) }}</div> | |||
@@ -74,6 +74,7 @@ | |||
<script> | |||
import Vue from 'vue'; | |||
import { bucketHost } from '@/utils/minIO'; | |||
import { fileCodeMap, findKey, statusCodeMap } from '@/views/dataset/util'; | |||
// eslint-disable-next-line import/no-extraneous-dependencies | |||
const path = require('path'); | |||
@@ -118,10 +119,13 @@ export default { | |||
multipleSelected: [], | |||
imageTagVisible: true, | |||
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, | |||
statusCodeMap, | |||
}; | |||
}, | |||
computed: { | |||
@@ -139,9 +143,9 @@ export default { | |||
imageLabelTag() { | |||
const labelTag = {}; | |||
this.dataImages.forEach((item) => { | |||
const statusInfo = this.imgStatusMap[item.status]; | |||
const statusInfo = this.imgStatusMap[findKey(item.status, fileCodeMap)]; | |||
const annotation = JSON.parse(item.annotation); | |||
let categoryName = '无标注'; | |||
let categoryName = '未识别'; | |||
let tagColor = '#db2a2a'; | |||
if (statusInfo && (annotation instanceof Array) && annotation.length > 0) { | |||
const categoryId = annotation[0].category_id; | |||
@@ -17,11 +17,12 @@ | |||
<template> | |||
<div class="info-data-select"> | |||
<el-select | |||
:style="{ width: '100%' }" | |||
ref="selectRef" | |||
:style="{ width: selectEleWidth }" | |||
clearable | |||
v-bind="attrs" | |||
:value="state.sValue" | |||
@change="handleChange" | |||
v-on="listeners" | |||
> | |||
<el-option | |||
v-for="item in state.list" | |||
@@ -36,7 +37,7 @@ | |||
</template> | |||
<script> | |||
import { isNil } from 'lodash'; | |||
import { reactive, watch, computed } from '@vue/composition-api'; | |||
import { reactive, watch, computed, ref } from '@vue/composition-api'; | |||
export default { | |||
name: 'InfoSelect', | |||
@@ -47,6 +48,7 @@ export default { | |||
}, | |||
props: { | |||
request: Function, | |||
width: String, | |||
value: { | |||
type: [String, Number, Array], | |||
}, | |||
@@ -62,9 +64,12 @@ export default { | |||
type: Array, | |||
default: () => ([]), | |||
}, | |||
innerRef: Function, | |||
}, | |||
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 => ({ | |||
...d, | |||
@@ -98,10 +103,18 @@ export default { | |||
}); | |||
const attrs = computed(() => ctx.attrs); | |||
const selectEleWidth =computed(() => props.width || '100%'); | |||
const listeners = computed(() => ({ | |||
...ctx.listeners, | |||
change: handleChange, | |||
})); | |||
return { | |||
state, | |||
selectEleWidth, | |||
attrs, | |||
selectRef, | |||
listeners, | |||
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> | |||
<el-form-item label="运行参数模式"> | |||
<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-form-item> | |||
<el-form-item | |||
@@ -29,38 +29,21 @@ | |||
:prop="prop" | |||
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 v-show="paramsMode === 2" label="运行参数" :error="argErrorMsg"> | |||
<el-input | |||
@@ -76,30 +59,20 @@ | |||
<script> | |||
import { stringIsValidPythonVariable } from '@/utils'; | |||
import ParamPair from './paramPair'; | |||
export default { | |||
name: 'RunParamForm', | |||
components: { ParamPair }, | |||
props: { | |||
id: { | |||
type: [Number, String], | |||
default: null, | |||
}, | |||
runParamObj: { | |||
type: Object, | |||
default: () => {}, | |||
default: () => ({}), | |||
}, | |||
prop: { | |||
type: String, | |||
default: null, | |||
}, | |||
input1Width: { | |||
type: Number, | |||
default: 150, | |||
}, | |||
input2Width: { | |||
type: Number, | |||
default: 150, | |||
}, | |||
paramLabelWidth: { | |||
type: String, | |||
default: '100px', | |||
@@ -110,73 +83,34 @@ export default { | |||
}, | |||
}, | |||
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 { | |||
runParamsList: [], | |||
errMsg: [], | |||
paramsMode: 1, | |||
paramsArguments: '', | |||
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: { | |||
id(newValue) { | |||
if (newValue === null || isNaN(newValue)) { | |||
/** | |||
* newValue为null时的一种情况是与el-form组合使用的 | |||
* crud组件触发了cancelCU方法,此时不需更新 | |||
*/ | |||
return; | |||
} | |||
this.syncListData(); | |||
}, | |||
runParamObj() { | |||
this.syncListData(); | |||
}, | |||
@@ -185,19 +119,12 @@ export default { | |||
this.syncListData(); | |||
}, | |||
methods: { | |||
isInputEmpty(value) { | |||
return value === '' || value === null; | |||
}, | |||
itemKeyId(index) { | |||
return `runParamsList.${ index }.key`; | |||
}, | |||
itemValueId(index) { | |||
return `runParamsList.${ index }.value`; | |||
}, | |||
addP() { | |||
this.runParamsList.push({ | |||
key: '', | |||
value: '', | |||
// eslint-disable-next-line no-plusplus | |||
id: this.paramId++, | |||
}); | |||
}, | |||
removeP(i) { | |||
@@ -205,12 +132,17 @@ export default { | |||
this.updateRunParamObj(); | |||
}, | |||
syncListData() { | |||
const rpObj = { ...this.runParamObj}; | |||
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; | |||
@@ -221,33 +153,59 @@ export default { | |||
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(); | |||
}, | |||
// 提供修改参数的入口, 如果参数存在则可修改 | |||
updateParam(key, value) { | |||
const param = this.runParamsList.find(p => p.key === key); | |||
if (param) { | |||
param.value = value; | |||
this.updateRunParamObj(); | |||
} | |||
}, | |||
updateRunParamObj() { | |||
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); | |||
}, | |||
goValid() { | |||
validate() { | |||
// 单独校验 | |||
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; | |||
}, | |||
onParamsModeChange(value) { | |||
@@ -265,33 +223,41 @@ export default { | |||
const paramsList = this.paramsArguments.split(' '); | |||
const pairList = []; | |||
const re = /^--(.+)=(.*)$/; | |||
this.hasError = false; | |||
// 先使用正则进行匹配 | |||
paramsList.forEach(arg => { | |||
const group = re.exec(arg); | |||
if (group) { | |||
pairList.push({ | |||
key: group[1], | |||
value: group[2], | |||
// eslint-disable-next-line no-plusplus | |||
id: this.paramId++, | |||
}); | |||
} else if (arg) { | |||
this.$nextTick(() => { | |||
this.argErrorMsg = `参数'${arg}'不合法,请检查运行参数`; | |||
}); | |||
this.paramsMode = 2; | |||
this.hasError = true; | |||
} | |||
}); | |||
if (this.hasError) return; | |||
// 其次做参数名验证 | |||
pairList.forEach(pair => { | |||
if (!stringIsValidPythonVariable(pair.key)) { | |||
this.$nextTick(() => { | |||
this.argErrorMsg = `参数名'${pair.key}'不是合法参数,请检查运行参数`; | |||
}); | |||
this.paramsMode = 2; | |||
this.hasError = true; | |||
} | |||
}); | |||
if (this.hasError) return; | |||
// 参数为空时增加一个空参数 | |||
if (!pairList.length) { | |||
pairList.push({ key: '', value: '' }); | |||
// eslint-disable-next-line no-plusplus | |||
pairList.push({ key: '', value: '', id: this.paramId++ }); | |||
} | |||
this.runParamsList = pairList; | |||
this.updateRunParamObj(); | |||
@@ -308,13 +274,20 @@ export default { | |||
this.paramsArguments = args; | |||
}, | |||
reset() { | |||
this.errMsg = []; | |||
this.argErrorMsg = null; | |||
this.paramsMode = 1; | |||
this.paramsArguments = ''; | |||
this.runParamsList = [{ key: '', value: '' }]; | |||
this.$refs.runParamForm.clearValidate(); | |||
// eslint-disable-next-line no-plusplus | |||
this.runParamsList = [{ key: '', value: '', id: this.paramId++ }]; | |||
}, | |||
}, | |||
}; | |||
</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-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-tooltip class="item" effect="dark" content="如果没有对应的模型,请点击新建" placement="right-start"> | |||
<el-button @click="goModel">新建模型</el-button> | |||
@@ -210,6 +210,12 @@ export default { | |||
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 | |||
doSaveModel() { | |||
this.$refs.modelForm.validate(valid => { | |||
@@ -43,16 +43,12 @@ export default { | |||
}, | |||
limit: { | |||
type: Number, | |||
default: 1000, | |||
default: 5000, | |||
}, | |||
showFileCount: { | |||
type: Boolean, | |||
default: true, | |||
}, | |||
wordShow: { | |||
type: Boolean, | |||
default: true, | |||
}, | |||
}, | |||
data() { | |||
return { | |||
@@ -130,6 +126,7 @@ export default { | |||
}, | |||
onRemove(file, fileList) { | |||
this.lenOfFileList = fileList.length; | |||
this.$attrs['on-remove'] && this.$attrs['on-remove'](file, fileList); | |||
}, | |||
cancelUpload() { | |||
if (this.source) { | |||
@@ -163,7 +160,7 @@ export default { | |||
class='upload-field' | |||
limit={this.limit} | |||
multiple | |||
list-type='picture' | |||
list-type={this.lenOfFileList>100? 'text' : 'picture'} | |||
auto-upload={false} | |||
disabled={this.uploading} | |||
{...uploadProps} | |||
@@ -180,7 +177,7 @@ export default { | |||
</div> | |||
{ | |||
this.showFileCount && ( | |||
this.wordShow ? <span class='upload-chosen-tip'>已选择{ this.lenOfFileList }张</span> : null | |||
<span class='upload-chosen-tip'>已选择{ this.lenOfFileList }张</span> | |||
) | |||
} | |||
</div> | |||
@@ -69,7 +69,7 @@ export default { | |||
} | |||
state.uploading = true; | |||
ctx.emit('uploadStart'); | |||
ctx.emit('uploadStart', files); | |||
const uploadReqeust = request || minIOUpload; | |||
// 开始调用上传接口 | |||
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: { | |||
development: { | |||
config: { | |||
endPoint: '10.5.26.234', | |||
endPoint: '', // MinIO 服务地址 | |||
port: 9000, | |||
useSSL: false, | |||
}, | |||
@@ -26,7 +26,7 @@ module.exports = { | |||
}, | |||
test: { | |||
config: { | |||
endPoint: '10.5.26.234', | |||
endPoint: '', | |||
port: 9000, | |||
useSSL: false, | |||
}, | |||
@@ -34,7 +34,7 @@ module.exports = { | |||
}, | |||
production: { | |||
config: { | |||
endPoint: '121.41.72.89', | |||
endPoint: '', | |||
port: 9000, | |||
useSSL: false, | |||
}, | |||
@@ -21,26 +21,53 @@ function useBrush() { | |||
const state = reactive({ | |||
start: undefined, | |||
end: undefined, | |||
extent: undefined, | |||
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 }) { | |||
Object.assign(state, { | |||
start: { x, y }, | |||
isBrushing: true, | |||
end: undefined, | |||
extent: undefined, | |||
}); | |||
} | |||
function onBrushMove({ x, y }) { | |||
const extent = getExtent(state.start, {x, y}); | |||
Object.assign(state, { | |||
end: { x, y }, | |||
extent, | |||
}); | |||
} | |||
function onBrushEnd() { | |||
const { extent } = state; | |||
Object.assign(state, { | |||
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, { | |||
start: undefined, | |||
end: undefined, | |||
extent: undefined, | |||
isBrushing: false, | |||
}); | |||
} | |||
function updateBrush(updater, callback) { | |||
const newState = updater(state); | |||
Object.assign(state, newState); | |||
if(typeof callback === 'function') { | |||
callback(state); | |||
} | |||
} | |||
return ({ | |||
brush: state, | |||
getExtent, | |||
onBrushStart, | |||
onBrushMove, | |||
onBrushEnd, | |||
updateBrush, | |||
onBrushReset, | |||
}); | |||
} | |||
@@ -66,8 +66,13 @@ function useZoom(initialZoom, wrapperRef, options = { | |||
updateZoom({ newZoom: 1, zoom: 1, zoomX: 0, zoomY: 0 }); | |||
} | |||
function getZoom(){ | |||
return state; | |||
} | |||
return ({ | |||
zoom: state, | |||
getZoom, | |||
setZoom, | |||
zoomIn, | |||
zoomOut, | |||
@@ -91,7 +91,8 @@ export default { | |||
} else { | |||
// 不存在历史记录 | |||
// 或者新开 Tab | |||
if (!window.history.length || window.history.length === 1) { | |||
// chrome 新开tab页面历史记录为 2 | |||
if (!window.history.length || window.history.length <= 2) { | |||
this.$router.push('/'); | |||
return; | |||
} | |||
@@ -59,6 +59,7 @@ export default { | |||
font-size: 0.7rem !important; | |||
color: #7a8b9a; | |||
letter-spacing: 0.8px; | |||
pointer-events: none; | |||
background: none repeat scroll 0 0 white; | |||
border-top: 1px solid #e7eaec; | |||
} | |||
@@ -33,15 +33,15 @@ | |||
<el-col :span="12"> | |||
<el-popover | |||
placement="bottom" | |||
trigger="click" | |||
placement="bottom" | |||
trigger="click" | |||
> | |||
<img src="../../../assets/images/dingtalk.jpg" width="200" alt=""> | |||
<div slot="reference" class="feed-action"> | |||
<i class="el-icon-chat-dot-square" /> | |||
<div>钉钉交流群</div> | |||
</div> | |||
</el-popover> | |||
</el-popover> | |||
</el-col> | |||
</el-row> | |||
@@ -20,6 +20,7 @@ | |||
const state = { | |||
activePanel: 0, | |||
activePanelLabelGroup: 0, | |||
}; | |||
const mutations = { | |||
@@ -29,6 +30,12 @@ const mutations = { | |||
RESET_PANEL: (state) => { | |||
state.activePanel = 0; | |||
}, | |||
TOGGLE_PANEL_LABEL_GROUP: (state, panel) => { | |||
state.activePanelLabelGroup = panel; | |||
}, | |||
RESET_PANEL_LABEL_GROUP: (state) => { | |||
state.activePanelLabelGroup = 0; | |||
}, | |||
}; | |||
const actions = { | |||
@@ -38,6 +45,12 @@ const actions = { | |||
resetPanel({ commit }) { | |||
commit('RESET_PANEL'); | |||
}, | |||
togglePanelLabelGroup({ commit }, panel) { | |||
commit('TOGGLE_PANEL_LABEL_GROUP', panel); | |||
}, | |||
resetPanelLabelGroup({ commit }) { | |||
commit('RESET_PANEL_LABEL_GROUP'); | |||
}, | |||
}; | |||
export default { | |||
@@ -15,9 +15,17 @@ | |||
*/ | |||
import { format, parseISO, isDate } from 'date-fns'; | |||
import { isEqual, isPlainObject } from 'lodash'; | |||
import { isEqual, isPlainObject, isNil, findIndex, findLastIndex } from 'lodash'; | |||
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) { | |||
const props = {}; | |||
@@ -144,3 +152,19 @@ export const identity = d => d; | |||
export const isEqualByProp = (arr1, arr2, 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 获取缩放后的相对位置 | |||
export const getZoomPosition = (el, originPosition = []) => { | |||
export const getZoomPosition = (el, {x, y}) => { | |||
const transform = zoomTransform(el); | |||
// 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 | |||
}; | |||
@@ -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 | |||
export const parseBbox = (bbox = []) => { | |||
if (!bbox.length) return null; | |||
@@ -97,3 +113,32 @@ export function getStyle(el, property) { | |||
.getPropertyValue(property) | |||
.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( | |||
response => { | |||
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 (res.code !== 200) { | |||
if (isWhiteList(response.config.url)) { | |||
@@ -18,6 +18,8 @@ | |||
* utils, 通用方法 | |||
*/ | |||
import { nanoid } from 'nanoid'; | |||
/** | |||
* Parse the time to string | |||
* @param {(Object|string|number)} time | |||
@@ -252,5 +254,92 @@ export function stringIsValidPythonVariable(str) { | |||
} | |||
const pattern = /^[_a-zA-Z][_a-zA-Z0-9]*$/; | |||
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,校验函数 | |||
*/ | |||
import { isPlainObject } from 'lodash'; | |||
import { ValidationProvider, ValidationObserver, extend } from 'vee-validate'; | |||
import { required } from 'vee-validate/dist/rules'; | |||
@@ -268,3 +269,29 @@ export function validateRunCommand(rule, value, callback) { | |||
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"> | |||
<span slot="right"> | |||
<el-input | |||
id="algorithmName" | |||
v-model="localQuery.algorithmName" | |||
clearable | |||
placeholder="请输入算法名称或 ID" | |||
@@ -30,6 +31,7 @@ | |||
@clear="crud.toQuery" | |||
/> | |||
<el-input | |||
id="algorithmUsage" | |||
v-model="localQuery.algorithmUsage" | |||
clearable | |||
placeholder="请输入算法用途" | |||
@@ -43,8 +45,8 @@ | |||
</cdOperation> | |||
<div> | |||
<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> | |||
</div> | |||
</div> | |||
@@ -82,19 +84,19 @@ | |||
</el-table-column> | |||
<el-table-column label="操作" width="370px" fixed="right"> | |||
<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-button type="text" style="margin-left: 10px;" @click.stop> | |||
更多<i class="el-icon-arrow-down el-icon--right" /> | |||
</el-button> | |||
<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-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-dropdown-item> | |||
</el-dropdown-menu></el-dropdown> | |||
@@ -124,6 +126,7 @@ | |||
> | |||
<el-form-item label="名称" prop="algorithmName"> | |||
<el-input | |||
id="algorithmName" | |||
v-model.trim="form.algorithmName" | |||
placeholder | |||
maxlength="32" | |||
@@ -133,6 +136,7 @@ | |||
</el-form-item> | |||
<el-form-item label="描述" prop="description"> | |||
<el-input | |||
id="description" | |||
v-model="form.description" | |||
type="textarea" | |||
:rows="3" | |||
@@ -144,6 +148,7 @@ | |||
</el-form-item> | |||
<el-form-item label="算法用途" prop="algorithmUsage"> | |||
<el-select | |||
id="algorithmUsage" | |||
v-model="form.algorithmUsage" | |||
placeholder="请选择或输入算法用途" | |||
filterable | |||
@@ -169,24 +174,34 @@ | |||
</el-form-item> | |||
<el-form-item v-show="formType !== 'fork'" ref="codeDir" label="上传代码包" prop="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> | |||
<upload-inline | |||
v-if="crud.status.cu > 0" | |||
ref="upload" | |||
action="fakeApi" | |||
accept=".zip" | |||
:acceptSize="100" | |||
:acceptSize="1024" | |||
:acceptSizeFormat="(size) => `${size/1024} GB`" | |||
list-type="text" | |||
:show-file-count="false" | |||
:params="uploadParams" | |||
:auto-upload="true" | |||
:hash="false" | |||
:limit="1" | |||
:on-remove="onFileRemove" | |||
@uploadStart="uploadStart" | |||
@uploadSuccess="uploadSuccess" | |||
@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 label="训练输出" prop="isTrainOut" class="is-required"> | |||
<el-tooltip | |||
@@ -198,8 +213,18 @@ | |||
<i class="el-icon-warning-outline primary f18 vm" /> | |||
</el-tooltip> | |||
</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-checkbox v-model="form.isTrainLog" /> | |||
<el-checkbox id="isTrainLog" v-model="form.isTrainLog" /> | |||
<el-tooltip | |||
v-show="form.isTrainLog" | |||
class="item" | |||
@@ -211,7 +236,7 @@ | |||
</el-tooltip> | |||
</el-form-item> | |||
<el-form-item label="可视化日志" prop="isVisualizedLog"> | |||
<el-checkbox v-model="form.isVisualizedLog" /> | |||
<el-checkbox id="isVisualizedLog" v-model="form.isVisualizedLog" /> | |||
<el-tooltip | |||
v-show="form.isVisualizedLog" | |||
class="item" | |||
@@ -238,9 +263,7 @@ | |||
</template> | |||
<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 cdOperation from '@crud/CD.operation'; | |||
import rrOperation from '@crud/RR.operation'; | |||
@@ -251,6 +274,7 @@ import { createNotebook, getNotebookAddress } from '@/api/development/notebook'; | |||
import BaseModal from '@/components/BaseModal'; | |||
import AlgorithmDetail from '@/components/Training/algorithmDetail'; | |||
import UploadInline from '@/components/UploadForm/inline'; | |||
import UploadProgress from '@/components/UploadProgress'; | |||
const defaultForm = { | |||
id: null, | |||
@@ -275,6 +299,7 @@ export default { | |||
AlgorithmDetail, | |||
UploadInline, | |||
rrOperation, | |||
UploadProgress, | |||
}, | |||
cruds() { | |||
return CRUD({ | |||
@@ -338,8 +363,14 @@ export default { | |||
objectPath: null, // 对象存储路径 | |||
}, | |||
disableEdit: false, | |||
keepAskAddress: false, | |||
uploading: false, | |||
progress: 0, | |||
size: 0, | |||
customColors: [ | |||
{color: '#909399', percentage: 40}, | |||
{color: '#e6a23c', percentage: 80}, | |||
{color: '#67c23a', percentage: 100}, | |||
], | |||
}; | |||
}, | |||
computed: { | |||
@@ -362,6 +393,9 @@ export default { | |||
user() { | |||
return this.$store.getters.user; | |||
}, | |||
status() { | |||
return this.progress === 100 ? 'success' : null; | |||
}, | |||
}, | |||
mounted() { | |||
this.getAlgorithmUsages(); | |||
@@ -370,7 +404,7 @@ export default { | |||
this.updateObjectPath(); | |||
}, | |||
beforeDestroy() { | |||
this.keepAskAddress = false; | |||
this.disableEdit = false; | |||
}, | |||
methods: { | |||
// handle | |||
@@ -386,6 +420,7 @@ export default { | |||
}, | |||
onDialogClose() { | |||
this.$refs.upload.formRef.reset(); | |||
this.uploading = false; | |||
}, | |||
onAlgorithmUsageChange(value) { | |||
const usageRes = this.algorithmUsageList.find(usage => usage.auxInfo === value); | |||
@@ -393,14 +428,28 @@ export default { | |||
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.$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() { | |||
this.$message({ | |||
message: '上传文件失败', | |||
@@ -415,13 +464,13 @@ export default { | |||
}, | |||
goTraining(item) { | |||
this.$router.push({ | |||
path: '/training/jobAdd', | |||
path: '/training/jobadd', | |||
name: 'jobAdd', | |||
params: { | |||
from: 'algorithm', | |||
params: { | |||
algorithmId: item.id, | |||
algorithmSource: this.active, | |||
algorithmSource: Number(this.active), | |||
algorithmUsage: item.algorithmUsage, | |||
runParams: item.runParams, | |||
imageNameProject: item.imageNameProject, | |||
@@ -432,7 +481,7 @@ export default { | |||
}); | |||
}, | |||
goDownload(algorithm) { | |||
downloadZipFromObjectPath(algorithm.codeDir, `${algorithm.algorithmName }.zip`, { flat: true }); | |||
downloadZipFromObjectPath(algorithm.codeDir, `${algorithm.algorithmName}.zip`, { flat: true }); | |||
this.$message({ | |||
message: '请查看下载文件', | |||
type: 'success', | |||
@@ -450,14 +499,10 @@ export default { | |||
this.disableEdit = false; | |||
}); | |||
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 { | |||
this.keepAskAddress = true; | |||
this.getNotebookAddress(notebookInfo.id, notebookInfo.name); | |||
this.disableEdit = true; | |||
this.getNotebookAddress(notebookInfo.id, notebookInfo.noteBookName); | |||
} | |||
}, | |||
// op | |||
@@ -479,26 +524,18 @@ export default { | |||
// hook | |||
[CRUD.HOOK.beforeToAdd]() { | |||
this.formType = 'add'; | |||
this.updateObjectPath(); | |||
}, | |||
[CRUD.HOOK.beforeRefresh]() { | |||
this.crud.query = { ...this.localQuery}; | |||
this.crud.query.algorithmSource = Number(this.active); | |||
}, | |||
getNotebookAddress(id, noteBookName) { | |||
if (!this.keepAskAddress) { | |||
if (!this.disableEdit) { | |||
return; | |||
} | |||
this.disableEdit = true; | |||
getNotebookAddress(id).then(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 { | |||
setTimeout(() => { | |||
this.getNotebookAddress(id, noteBookName); | |||
@@ -506,7 +543,6 @@ export default { | |||
} | |||
}).catch(err => { | |||
this.disableEdit = false; | |||
this.keepAskAddress = false; | |||
throw new Error(err); | |||
}); | |||
}, | |||
@@ -531,7 +567,15 @@ export default { | |||
this.getAlgorithmUsages(); | |||
}, | |||
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> | |||
<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> |
@@ -30,7 +30,7 @@ | |||
:isTrack="isTrack" | |||
:state="state" | |||
:currentImg="currentImg" | |||
:handleBrushEnd="handleBrushEnd" | |||
:drawBboxEnd="drawBboxEnd" | |||
:createLabel="createLabel" | |||
:queryLabels="queryLabels" | |||
:updateState="updateState" | |||
@@ -65,8 +65,8 @@ import { isEmpty, isFunction, omit, isNil } from 'lodash'; | |||
import { detail, detectFileList, queryFileOffset, queryDataEnhanceList, getEnhanceFileList } from '@/api/preparation/dataset'; | |||
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 WorkSpaceContainer from './workSpaceContainer'; | |||
@@ -89,6 +89,9 @@ export default { | |||
const { params = {}} = $route; | |||
const workspaceRef = ref(null); | |||
// 加载下一页,避免重复加载 | |||
const loadNextPageFlag = ref(false); | |||
// 标注类型 | |||
const isTrack = $route.name.startsWith('TrackDataset'); | |||
// const isAnnotation = meta.type === 'annotate' | |||
@@ -102,6 +105,7 @@ export default { | |||
hasMore: true, // 是否有更多列表 | |||
datasetId: Number(params.datasetId), | |||
currentImgId: Number(params.fileId) || undefined, // 当前图片 id | |||
rawAnnotations: [], // 原始标注集合 | |||
annotations: [], // 标注集合 | |||
fileInfo: null, // 文件信息 | |||
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 获取标签颜色 | |||
@@ -236,6 +247,7 @@ export default { | |||
const clearHistory = () => { | |||
updateState({ | |||
history: [], | |||
rawAnnotations: [], | |||
annotations: [], | |||
fileInfo: null, // 当前文件信息 | |||
lastSelectedLabel: undefined, | |||
@@ -259,7 +271,13 @@ export default { | |||
// 当到下边界只有 2 张图片时,请求下一页数据 | |||
// 仍然有下页 | |||
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 file = await request(`api/data/datasets/files/${id}/info`) || {}; | |||
const file = await request(`api/data/datasets/files/${params.datasetId}/${id}/info`) || {}; | |||
return file; | |||
}; | |||
@@ -319,7 +337,7 @@ export default { | |||
// 保存标注 | |||
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: [] }); | |||
Message.success({ message: '保存成功', duration: 800 }); | |||
@@ -328,7 +346,7 @@ export default { | |||
// 人工确认标注 | |||
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: [] }); | |||
// 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); | |||
// 记录上一次选中的 selectLabel | |||
const otherProps = state.lastSelectedLabel ? { | |||
categoryId: state.lastSelectedLabel, | |||
color: getColorLabel(state.lastSelectedLabel), | |||
} : {}; | |||
const annotation = { | |||
const rawAnnotation = { | |||
id: generateUuid(), | |||
__type: 0, // 标识为新创建的标注 | |||
data: { | |||
@@ -369,6 +443,9 @@ export default { | |||
...otherProps, | |||
}, | |||
}; | |||
// todo: 转换成标准地址(extent/bbox) | |||
const annotation = mapBrushToBbox(rawAnnotation); | |||
// 更新框选位置坐标 | |||
const newAnnotation = (state.annotations || []).concat(annotation); | |||
Object.assign(state, { | |||
@@ -386,48 +463,6 @@ export default { | |||
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 isValid = state.annotations.every(checkAnnotationValid); | |||
@@ -580,6 +615,8 @@ export default { | |||
let { result: files } = rawFile.value; | |||
const { __offset__, page = {}} = rawFile.value; | |||
// 同步当前文件的偏移 | |||
state.offset = __offset__; | |||
// 自定义分页 | |||
// 当前条数小于每页可返回的总条数,向上补齐 | |||
const availableSize = Math.min(page.size, page.total); | |||
@@ -610,15 +647,16 @@ export default { | |||
updateState(nextState); | |||
// 根据第一个文件是否携带数据增强结果来决定是否展示 | |||
const firstEnhanceList = await getEnhanceFileList(firstFile.id); | |||
const firstEnhanceList = await getEnhanceFileList(params.datasetId, firstFile.id); | |||
// 更新当前图片 | |||
const { file, annotations } = await updateImageInfo(activeFileId, labels); | |||
updateState({ | |||
currentImgId: file.id, | |||
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() => { | |||
const imgId = state.currentImgId; | |||
updateState({ | |||
rawAnnotations: [], | |||
annotations: [], | |||
fileInfo: null, | |||
}); | |||
@@ -646,7 +685,8 @@ export default { | |||
gotoFileDetail(imgId); | |||
// 清理数据 | |||
updateState({ | |||
annotations, | |||
rawAnnotations: annotations, | |||
annotations: withExtent(annotations), | |||
fileInfo: file, | |||
}); | |||
} | |||
@@ -663,7 +703,7 @@ export default { | |||
currentImg, | |||
handleSelection, | |||
handleBrushStart, | |||
handleBrushEnd, | |||
drawBboxEnd, | |||
handleSave, | |||
handleConfirm, | |||
gotoFileDetail, | |||
@@ -102,7 +102,7 @@ export default { | |||
}; | |||
const withEdit = (item, isEdit = false) => { | |||
const { categoryId, track_id } = item.data; | |||
const { categoryId, track_id } = item.data || {}; | |||
// 获取到分类标签名 | |||
const labelName = rLabels.value[categoryId]; | |||
const labelNameTxt = labelName ? `${labelName}_` : ''; | |||
@@ -151,7 +151,7 @@ export default { | |||
watch(() => props.fileId, async(next) => { | |||
if (next) { | |||
const enhanceFileList = await getEnhanceFileList(next); | |||
const enhanceFileList = await getEnhanceFileList(props.datasetId,next); | |||
const isOrigin = !!enhanceFileList.length; // 被增强 | |||
Object.assign(state, { | |||
isOrigin, | |||
@@ -17,13 +17,35 @@ | |||
<template> | |||
<div class="workspace-settings"> | |||
<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 | |||
v-if="!isPresetLabel" | |||
:dataSource="api.systemLabels" | |||
:handleLabelChange="handleLabelChange" | |||
@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="state.annotations.value" | |||
:currentAnnotationId="state.currentAnnotationId.value" | |||
@@ -55,10 +77,10 @@ | |||
<script> | |||
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 { getAutoLabels } from '@/api/preparation/datalabel'; | |||
import { getAutoLabels, editLabel } from '@/api/preparation/datalabel'; | |||
import { labelsSymbol } from '@/views/dataset/util'; | |||
import SelectLabel from './selectLabel'; | |||
@@ -90,6 +112,7 @@ export default { | |||
const { createLabel, updateState, queryLabels } = props; | |||
const api = reactive({ | |||
newLabel: undefined, | |||
currentAnnotationId: undefined, | |||
}); | |||
// 当前所有标签信息 | |||
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 = () => { | |||
@@ -131,6 +146,24 @@ export default { | |||
api.newLabel = undefined; | |||
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); | |||
}; | |||
// 使用的是预置标签时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(() => { | |||
getSystemLabel(); | |||
@@ -190,6 +229,8 @@ export default { | |||
toggleShowId, | |||
labels, | |||
postLabel, | |||
addLabel, | |||
edit, | |||
handleLabelChange, | |||
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;"> | |||
<el-row :gutter="5" style="clear: both;"> | |||
<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-row> | |||
</div> | |||
@@ -32,34 +46,44 @@ | |||
<script> | |||
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 { | |||
name: 'LabelList', | |||
components: { | |||
SearchLabel, | |||
Edit, | |||
}, | |||
props: { | |||
labels: { | |||
type: Array, | |||
default: () => ([]), | |||
}, | |||
currentAnnotationId: { | |||
type: String, | |||
default: undefined, | |||
}, | |||
editLabel: Function, | |||
annotations: Array, | |||
updateState: Function, | |||
getColorLabel: Function, | |||
findRowIndex: Function, | |||
}, | |||
setup(props) { | |||
const { annotations: rawAnnotations ,updateState, getColorLabel, findRowIndex, editLabel } = props; | |||
const state = reactive({ | |||
annotations: rawAnnotations, | |||
labelData: props.labels, | |||
currentAnnotationId: props.currentAnnotationId, | |||
}); | |||
// 根据亮度来决定颜色 | |||
const getStyle = (item) => { | |||
if (item.color && chroma(item.color).luminance() < 0.5) { | |||
return { | |||
color: '#fff', | |||
}; | |||
} | |||
const color = colorByLuminance(item.color); | |||
return { | |||
color: '#000', | |||
color, | |||
}; | |||
}; | |||
// 查询分类标签 | |||
@@ -75,16 +99,53 @@ export default { | |||
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) => { | |||
state.labelData = next; | |||
}); | |||
watch(() => props.currentAnnotationId, (next) => { | |||
state.currentAnnotationId = next; | |||
}); | |||
return { | |||
state, | |||
labelsTitle, | |||
handleEditAnnotation, | |||
handleEditLabel, | |||
getStyle, | |||
handleSearch, | |||
}; | |||
}, | |||
}; | |||
</script> | |||
<style lang="scss" scoped> | |||
.el-icon-edit { | |||
padding: 0 4px; | |||
margin-left: 4px; | |||
} | |||
</style> |
@@ -22,6 +22,7 @@ | |||
<div class="flex flex-between"> | |||
<InfoSelect | |||
v-model="state.label" | |||
:innerRef="innerRef" | |||
style="width: 68%;" | |||
placeholder="选择已有标签或新建标签" | |||
:dataSource="dataSource" | |||
@@ -29,7 +30,7 @@ | |||
default-first-option | |||
filterable | |||
allow-create | |||
@change="handleLabelChange" | |||
@change="handleChange" | |||
/> | |||
<el-button size="mini" type="primary" @click="postLabel">确定</el-button> | |||
</div> | |||
@@ -37,7 +38,7 @@ | |||
</template> | |||
<script> | |||
import { reactive } from '@vue/composition-api'; | |||
import { reactive, ref } from '@vue/composition-api'; | |||
import InfoSelect from '@/components/InfoSelect'; | |||
import LabelTip from './labelTip'; | |||
@@ -56,6 +57,9 @@ export default { | |||
handleLabelChange: Function, | |||
}, | |||
setup(props, ctx) { | |||
const { handleLabelChange } = props; | |||
const selectRef = ref(null); | |||
const state = reactive({ | |||
label: undefined, | |||
}); | |||
@@ -65,9 +69,17 @@ export default { | |||
state.label = undefined; | |||
}; | |||
const handleChange = (params) => { | |||
handleLabelChange(params, () => { | |||
state.label = undefined; | |||
}); | |||
}; | |||
return { | |||
state, | |||
postLabel, | |||
handleChange, | |||
innerRef: () => selectRef, | |||
}; | |||
}, | |||
}; | |||
@@ -48,6 +48,7 @@ | |||
</div> | |||
</div> | |||
<List | |||
ref="listRef" | |||
v-bind="$attrs" | |||
:updateState="updateState" | |||
:list="state.files.value" | |||
@@ -55,6 +56,7 @@ | |||
:hasMore="state.hasMore.value" | |||
:total="state.total.value" | |||
:offset="state.offset.value" | |||
:type="thumbState.type" | |||
:history="state.history.value" | |||
v-on="$listeners" | |||
/> | |||
@@ -93,7 +95,7 @@ import { Message } from 'element-ui'; | |||
import { pick } from 'lodash'; | |||
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 { detectFileList, queryFileOffset } from '@/api/preparation/dataset'; | |||
import List from './list'; | |||
@@ -115,6 +117,8 @@ export default { | |||
const { $route } = ctx.root; | |||
const uploaderRef = ref(null); | |||
const listRef = ref(null); | |||
const { updateList, state, updateState, isTrack } = props; | |||
const { datasetId } = state; | |||
const thumbState = reactive({ | |||
@@ -132,11 +136,11 @@ export default { | |||
const dropdownList = computed(() => { | |||
let filter = []; | |||
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 { | |||
// 目标检测:全部(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 => ({ | |||
command: k, | |||
@@ -153,6 +157,11 @@ export default { | |||
updateState({ annotations: [], fileFilterType: command }); | |||
// 重新请求文件 | |||
updateList({ type: command, offset: 0 }); | |||
// 获取滚动列表容器 | |||
const listWrapper = listRef.value.$refs?.listWrapper; | |||
listWrapper.scrollTo({ | |||
top: 0, | |||
}); | |||
}; | |||
const handleClose = () => { | |||
@@ -223,6 +232,7 @@ export default { | |||
}); | |||
return { | |||
listRef, | |||
thumbState, | |||
withDimensionFile, | |||
uploadParams, | |||
@@ -15,7 +15,7 @@ | |||
*/ | |||
<template> | |||
<div class="infinite-list-wrapper" style="overflow: auto;"> | |||
<div ref="listWrapper" class="infinite-list-wrapper" style="overflow: auto;"> | |||
<ul | |||
v-infinite-scroll="loadMore" | |||
infinite-scroll-distance="100" | |||
@@ -35,7 +35,7 @@ | |||
</div> | |||
</template> | |||
<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 ListItem from './listItem'; | |||
@@ -50,6 +50,9 @@ export default { | |||
type: Array, | |||
default: () => [], | |||
}, | |||
type: { | |||
type: [String, Number], | |||
}, | |||
addList: { | |||
type: Array, | |||
default: () => [], | |||
@@ -70,6 +73,9 @@ export default { | |||
}, | |||
setup(props, ctx) { | |||
const { updateState, queryNextPage } = props; | |||
const listWrapper = ref(null); | |||
const state = reactive({ | |||
loading: false, | |||
}); | |||
@@ -99,6 +105,7 @@ export default { | |||
}); | |||
queryNextPage({ | |||
offset: props.offset, | |||
type: Number(props.type), | |||
}).then(() => { | |||
Object.assign(state, { | |||
loading: false, | |||
@@ -111,6 +118,7 @@ export default { | |||
disabled, | |||
loadMore, | |||
handleClick, | |||
listWrapper, | |||
}; | |||
}, | |||
}; | |||
@@ -15,12 +15,10 @@ | |||
*/ | |||
import { isNil } from 'lodash'; | |||
import { addSuffix } from '@/utils'; | |||
import { addSuffix, chroma, colorByLuminance } from '@/utils'; | |||
import { defaultColor } from './bbox'; | |||
const chroma = require('chroma-js'); | |||
const validTrackId = (trackId) => { | |||
if (isNil(trackId) || trackId === -1) return false; | |||
return trackId; | |||
@@ -31,47 +29,48 @@ export default { | |||
functional: true, | |||
props: { | |||
annotate: Object, | |||
offset: Function, | |||
currentAnnotationId: String, | |||
brush: Object, | |||
transformer: Object, | |||
scale: { | |||
type: Number, | |||
}, | |||
imgBoundingLeft: Number, | |||
imgBounding: { | |||
type: Array, | |||
}, | |||
getLabelName: Function, | |||
}, | |||
render(h, context) { | |||
const { props } = context; | |||
const { | |||
annotate = {}, | |||
imgBoundingLeft, | |||
offset, | |||
brush, | |||
transformer, | |||
} = props; | |||
const { data = {}, __type } = annotate; | |||
const { data = {}, id } = annotate; | |||
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 = { | |||
width: addSuffix(pos.width), | |||
left: addSuffix(pos.x), | |||
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 trackId = (() => { | |||
@@ -85,7 +84,7 @@ export default { | |||
if (!trackId) return null; | |||
return ( | |||
<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> | |||
); | |||
}, | |||
@@ -16,8 +16,7 @@ | |||
import cx from 'classnames'; | |||
import { isNil } from 'lodash'; | |||
const chroma = require('chroma-js'); | |||
import { chroma } from '@/utils'; | |||
export const defaultColor = 'rgba(102, 181, 245, 1)'; | |||
const defaultFill = 'rgba(102, 181, 245, 0.1)'; | |||
@@ -27,68 +26,71 @@ export default { | |||
functional: true, | |||
props: { | |||
annotate: Object, | |||
brush: Object, | |||
scale: { | |||
type: Number, | |||
default: 1, | |||
}, | |||
currentAnnotationId: Object, | |||
imgBoundingLeft: Number, | |||
handleClick: Function, | |||
pos: { | |||
type: Object, | |||
default: () => ({}), | |||
}, | |||
dragStart: Function, | |||
dragMove: Function, | |||
dragEnd: Function, | |||
currentAnnotationId: String, | |||
transformer: Object, | |||
imgRef: HTMLImageElement, | |||
}, | |||
render(h, context) { | |||
const { props } = context; | |||
const { style } = context.data; | |||
const { | |||
annotate = {}, | |||
imgBoundingLeft, | |||
currentAnnotationId, | |||
handleClick, | |||
dragStart, | |||
dragMove, | |||
dragEnd, | |||
brush, | |||
transformer, | |||
...rest // does this work? | |||
} = props; | |||
const { data = {}, __type } = annotate; | |||
const { data = {} } = annotate; | |||
const { bbox, color } = data; | |||
if (isNil(bbox)) return null; | |||
const bgColor = color || defaultFill; | |||
const isActive = currentAnnotationId.value === annotate.id; | |||
const isActive = currentAnnotationId === annotate.id; | |||
const colorAlpha = isActive ? 0.4 : 0.1; | |||
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 ( | |||
<g class={cx('bbox-group', { | |||
active: isActive, | |||
})} onClick={handleClick(annotate)}> | |||
})}> | |||
<rect | |||
fill={fill} | |||
stroke={color || defaultColor} | |||
strokeWidth={4} | |||
// {...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} | |||
/> | |||
</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-bound rel" :style="dimension.marginStyle"> | |||
<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> | |||
<!-- svg 宽高要根据图片自适应 --> | |||
<div class="annotation-element-group abs" :style="dimension.annotationGroupStyle"> | |||
<svg | |||
ref="svgRef" | |||
class="canvas" | |||
:class="api.active === 'selection' ? 'crosshair' : ''" | |||
: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"> | |||
<Bbox | |||
<BboxWrapper | |||
v-for="annotate in api.annotations" | |||
:key="annotate.id" | |||
:annotate="annotate" | |||
:brush="brush" | |||
:offset="offset" | |||
:transformer="transformer" | |||
:svg="dimension.svg" | |||
: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> | |||
<BasicBrush :brush="brush" /> | |||
</svg> | |||
<div v-if="state.showScore.value" class="annotation-score-group"> | |||
<Score | |||
v-for="annotate in api.annotations" | |||
:key="annotate.id" | |||
:annotate="annotate" | |||
:scale="dimension.scale" | |||
:imgBoundingLeft="api.imgBoundingLeft" | |||
:currentAnnotationId="state.currentAnnotationId.value" | |||
:brush="brush" | |||
:offset="offset" | |||
:transformer="transformer" | |||
/> | |||
</div> | |||
<div v-if="state.showTag.value" class="annotation-tag-group"> | |||
@@ -85,9 +100,11 @@ | |||
v-for="annotate in api.annotations" | |||
:key="annotate.id" | |||
:annotate="annotate" | |||
:scale="dimension.scale" | |||
:currentAnnotationId="state.currentAnnotationId.value" | |||
:brush="brush" | |||
:offset="offset" | |||
:transformer="transformer" | |||
:getLabelName="getLabelName" | |||
:imgBoundingLeft="api.imgBoundingLeft" | |||
/> | |||
</div> | |||
<div v-if="state.showId.value && isTrack" class="annotation-tag-group"> | |||
@@ -95,11 +112,21 @@ | |||
v-for="annotate in api.annotations" | |||
:key="annotate.id" | |||
:annotate="annotate" | |||
:currentAnnotationId="state.currentAnnotationId.value" | |||
:brush="brush" | |||
:offset="offset" | |||
:transformer="transformer" | |||
:scale="dimension.scale" | |||
:getLabelName="getLabelName" | |||
:imgBoundingLeft="api.imgBoundingLeft" | |||
:imgBounding="api.imgBounding" | |||
/> | |||
</div> | |||
<!-- 新建标注展示尺寸信息 --> | |||
<BrushTip | |||
v-if="brush.isBrushing && brush.extent" | |||
:brush="brush" | |||
:dimension="dimension" | |||
/> | |||
</div> | |||
</div> | |||
</div> | |||
@@ -129,16 +156,18 @@ import { event as d3Event } from 'd3-selection'; | |||
import { Message } from 'element-ui'; | |||
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 Exception from '@/components/Exception'; | |||
import ToolBar from './toolbar'; | |||
import Bbox from './bbox'; | |||
import BboxWrapper from './bboxWrapper'; | |||
import Score from './score'; | |||
import Tag from './tag'; | |||
import AnnotationId from './annotationId'; | |||
import DropDownLabel from './dropdownLabel'; | |||
import BrushTip from './brushTip'; | |||
const addEventListener = require('add-dom-event-listener'); | |||
@@ -155,13 +184,14 @@ export default { | |||
components: { | |||
ZoomContainer, | |||
Exception, | |||
BasicBrush, | |||
Brush, | |||
ToolBar, | |||
Bbox, | |||
DropDownLabel, | |||
Score, | |||
Tag, | |||
AnnotationId, | |||
BboxWrapper, | |||
BrushTip, | |||
}, | |||
props: { | |||
state: Object, | |||
@@ -169,7 +199,7 @@ export default { | |||
type: Object, | |||
default: () => null, | |||
}, | |||
handleBrushEnd: Function, | |||
drawBboxEnd: Function, | |||
createLabel: Function, | |||
queryLabels: Function, | |||
getLabelName: Function, | |||
@@ -193,17 +223,28 @@ export default { | |||
label: {}, // 一个页面当前只能存在一个标签 | |||
bounding: null, // 容器位置信息 | |||
isCenter: false, // 图片是否已居中 | |||
imgBoundingLeft: null, // 图片的位置,给 bbox 位置定位使用 | |||
imgBounding: null, // 图片的位置,给 bbox 位置定位使用 | |||
active: '', // 当前选中 | |||
}); | |||
// 标注偏移 | |||
const transformer = reactive({ | |||
id: undefined, | |||
dx: 0, | |||
dy: 0, | |||
x: undefined, | |||
y: undefined, | |||
}); | |||
const { listeners } = ctx; | |||
const { handleBrushEnd, state, createLabel, queryLabels, updateState, deleteAnnotation, handleConfirm } = props; | |||
const { drawBboxEnd, state, createLabel, queryLabels, updateState, deleteAnnotation, handleConfirm } = props; | |||
const { | |||
brush, | |||
onBrushStart, | |||
onBrushMove, | |||
onBrushEnd, | |||
updateBrush, | |||
getExtent, | |||
// onBrushEnd, | |||
onBrushReset, | |||
} = 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 | |||
const { tooltipData, showTooltip, hideTooltip } = useTooltip(imgWrapperRef); | |||
@@ -233,6 +274,16 @@ export default { | |||
Object.assign(api, params); | |||
}; | |||
// 更新标注偏移 | |||
const setTransformer = params => { | |||
Object.assign(transformer, params); | |||
}; | |||
// 转换 zoom 位置 | |||
const transformZoom = (point) => { | |||
return getZoomPosition(ctx.refs.zoomRef.wrapperRef, point); | |||
}; | |||
// 监听 currentImage 变化 | |||
watch(() => props.currentImg, (nextImg) => { | |||
// 每次切换图片重置 zoom | |||
@@ -241,7 +292,7 @@ export default { | |||
Object.assign(api, { | |||
label: {}, | |||
isCenter: false, | |||
imgBoundingLeft: null, | |||
imgBounding: null, | |||
}); | |||
if (nextImg?.url) { | |||
setImg(nextImg.url); | |||
@@ -286,13 +337,15 @@ export default { | |||
// 如果图片有缩放,直接取容器尺寸即可 | |||
const svgDimension = { | |||
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 = { | |||
left: imgScale === 1 ? `${(cw - iw) / 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 做过宽度处理 | |||
@@ -368,7 +421,7 @@ export default { | |||
callback(); | |||
} else if (!msgInstance) { | |||
msgInstance = Message.warning({ | |||
message: '当前图片不存在或图片已经到顶了', | |||
message: '当前图片不存在或图片已经到底了', | |||
onClose: onMessageClose, | |||
}); | |||
} | |||
@@ -393,13 +446,13 @@ export default { | |||
watch(() => api.isCenter, (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: 缩放图片取容器尺寸,否则取图片尺寸 | |||
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, { | |||
imgBoundingLeft: (mw - imgWidth) / 2, | |||
imgBounding: [(mw - imgWidth) / 2, (mh - imgHeight) / 2], | |||
}); | |||
} | |||
}, { | |||
@@ -419,41 +472,38 @@ export default { | |||
n: selection, | |||
})); | |||
const handleMouseDown = (event) => { | |||
if (brush.start && brush.end) { | |||
// 首先清理已有的 brush 状态 | |||
onBrushReset(); | |||
} | |||
// 选中标注 | |||
const setCurAnnotation = (annotation = {}) => { | |||
updateState({ | |||
currentAnnotationId: annotation.id || '', | |||
}); | |||
}; | |||
// 开始绘制 | |||
const handleBrushStart = (start) => { | |||
// 关闭已有的 dropdown | |||
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 | |||
showTooltip({}, event); | |||
// 回调 | |||
handleBrushEnd && handleBrushEnd(brush, event); | |||
drawBboxEnd && drawBboxEnd(state, event); | |||
onBrushReset(); | |||
return; | |||
} | |||
@@ -468,10 +518,85 @@ export default { | |||
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) || {}; | |||
// 触发标注对应标签变更事件 | |||
ctx.emit('selectLabel', { selectedLabel, curAnnotation }); | |||
// 选择标签完成关闭选择器 | |||
hideTooltip(); | |||
}; | |||
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(() => { | |||
addEventListener(document.body, 'click', (e) => { | |||
// 如果不在画布内,直接清空 | |||
@@ -547,11 +807,7 @@ export default { | |||
imgWrapperRef, | |||
// labels | |||
labels, | |||
// brush | |||
brush, | |||
handleMouseDown, | |||
handleMouseMove, | |||
handleMouseUp, | |||
clearSelection, | |||
filter, | |||
// zoom | |||
@@ -574,13 +830,31 @@ export default { | |||
// event | |||
handleSelectChange, | |||
confirm, | |||
handleBboxClick, | |||
onDragStart, | |||
onDragMove, | |||
onDragEnd, | |||
keymap, | |||
// brush 事件 | |||
handleBrushStart, | |||
handleBrushMove, | |||
handleBrushEnd, | |||
// 标注偏移 | |||
offset, | |||
transformer, | |||
setTransformer, | |||
onBrushHandleChange, | |||
onBrushHandleEnd, | |||
// 缩放情况下将绝对位置转换为相对路径 | |||
transformZoom, | |||
getZoom, | |||
setCurAnnotation, | |||
}; | |||
}, | |||
}; | |||
</script> | |||
<style lang='scss'> | |||
@import "~@/assets/styles/variables.scss"; | |||
#stage { | |||
max-height: 100%; | |||
} | |||
@@ -597,6 +871,7 @@ export default { | |||
display: inline-block; | |||
width: 100%; | |||
height: 100%; | |||
user-select: none; | |||
} | |||
} | |||
} | |||
@@ -610,6 +885,8 @@ export default { | |||
} | |||
.annotation-score-group { | |||
pointer-events: none; | |||
.annotation-score-row { | |||
position: absolute; | |||
color: #fff; | |||
@@ -630,16 +907,33 @@ export default { | |||
} | |||
.annotation-tag-group { | |||
pointer-events: none; | |||
.annotation-label { | |||
position: absolute; | |||
color: #fff; | |||
pointer-events: none; | |||
} | |||
} | |||
.bbox-group { | |||
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> |
@@ -15,12 +15,10 @@ | |||
*/ | |||
import { isNil } from 'lodash'; | |||
import { addSuffix } from '@/utils'; | |||
import { addSuffix, colorByLuminance, chroma } from '@/utils'; | |||
import { defaultColor } from './bbox'; | |||
const chroma = require('chroma-js'); | |||
// 分数最小宽度 | |||
const MinWidth = 48; | |||
@@ -29,39 +27,28 @@ export default { | |||
functional: true, | |||
props: { | |||
annotate: Object, | |||
scale: { | |||
type: Number, | |||
}, | |||
imgBoundingLeft: Number, | |||
offset: Function, | |||
transformer: Object, | |||
brush: Object, | |||
currentAnnotationId: String, | |||
}, | |||
render(h, context) { | |||
const { props } = context; | |||
const { | |||
annotate = {}, | |||
imgBoundingLeft, | |||
offset, | |||
transformer, | |||
brush, | |||
} = props; | |||
const { data = {}, __type } = annotate; | |||
const { data = {}, id } = annotate; | |||
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 = { | |||
width: addSuffix(pos.width), | |||
@@ -70,8 +57,14 @@ export default { | |||
minWidth: addSuffix(MinWidth), | |||
}; | |||
// 匹配当前标注 | |||
if(annotate.id === transformer.id) { | |||
style.transform = `translate(${transformer.dx}px, ${transformer.dy}px)`; | |||
} | |||
const boxStyle = { | |||
backgroundColor: chroma(color).alpha(0.8), | |||
color: colorByLuminance(color), | |||
}; | |||
return ( | |||
@@ -15,66 +15,61 @@ | |||
*/ | |||
import { isNil } from 'lodash'; | |||
import { addSuffix } from '@/utils'; | |||
import { addSuffix, chroma, colorByLuminance } from '@/utils'; | |||
import { defaultColor } from './bbox'; | |||
const chroma = require('chroma-js'); | |||
export default { | |||
name: 'Tag', | |||
functional: true, | |||
props: { | |||
annotate: Object, | |||
scale: { | |||
type: Number, | |||
}, | |||
imgBoundingLeft: Number, | |||
offset: Function, | |||
transformer: Object, | |||
getLabelName: Function, | |||
brush: Object, | |||
currentAnnotationId: String, | |||
}, | |||
render(h, context) { | |||
const { props } = context; | |||
const { | |||
annotate = {}, | |||
imgBoundingLeft, | |||
getLabelName, | |||
offset, | |||
transformer, | |||
brush, | |||
} = props; | |||
const { data = {}, __type } = annotate; | |||
const { data = {}, id } = annotate; | |||
const { bbox, color = defaultColor } = data; | |||
// 当前在拖拽中不展示 | |||
if(props.currentAnnotationId === id && brush.isBrushing) 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 = { | |||
width: addSuffix(pos.width), | |||
left: addSuffix(pos.x), | |||
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 tagName = getLabelName(data.categoryId); | |||
if (!tagName) return null; | |||
return ( | |||
<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> | |||
); | |||
}, | |||
@@ -29,12 +29,12 @@ | |||
<div class="classify-container flex"> | |||
<!--文件列表展示--> | |||
<div class="file-list-container"> | |||
<div class="app-container"> | |||
<div v-loading="crud.loading" class="app-container"> | |||
<!--tabs页和工具栏--> | |||
<div class="classify-tab"> | |||
<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> | |||
<div class="classify-button flex flex-between flex-vertical-align"> | |||
<div class="row-left"> | |||
@@ -111,7 +111,16 @@ | |||
<!--Label列表展示--> | |||
<div class="label-list-container"> | |||
<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 /> | |||
<div class="flex flex-between"> | |||
<InfoSelect | |||
@@ -136,7 +145,15 @@ | |||
<div style="max-height: 200px; padding: 0 2.5px; overflow: auto;"> | |||
<el-row :gutter="5" style="clear: both;"> | |||
<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-row> | |||
</div> | |||
@@ -163,11 +180,13 @@ | |||
<script> | |||
import { without, isNil } from 'lodash'; | |||
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 { getAutoLabels, getLabels, createLabel } from '@/api/preparation/datalabel'; | |||
import { getAutoLabels, getLabels, createLabel, editLabel } from '@/api/preparation/datalabel'; | |||
import { batchFinishAnnotation } from '@/api/preparation/annotation'; | |||
import CRUD, { presenter, header, crud } from '@crud/crud'; | |||
import ImageGallery from '@/components/ImageGallery'; | |||
@@ -178,18 +197,18 @@ import SortingMenu from '@/components/SortingMenu'; | |||
import SearchLabel from './components/searchLabel'; | |||
import LabelTip from './annotate/settingContainer/labelTip'; | |||
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 | |||
const path = require('path'); | |||
export default { | |||
name: 'Classify', | |||
components: { ImageGallery, UploadForm, InfoCard, InfoSelect, SearchLabel, LabelTip, SortingMenu, PicInfoModal }, | |||
components: { ImageGallery, UploadForm, InfoCard, InfoSelect, SearchLabel, LabelTip, SortingMenu, PicInfoModal, EditLabel }, | |||
cruds() { | |||
const id = this.parent.$route.params.datasetId; | |||
const crudObj = CRUD({ title: '数据分类', crudMethod: { ...crudDataFile }}); | |||
crudObj.params = { 'datasetId': id, 'status': [0] }; | |||
crudObj.params = { 'datasetId': id, 'status': fileCodeMap.UNCOMPLETED }; | |||
crudObj.page.size = 30; | |||
return crudObj; | |||
}, | |||
@@ -203,18 +222,18 @@ export default { | |||
data() { | |||
return { | |||
datasetId: 0, | |||
datasetInfo: {}, | |||
uploadDialogVisible: false, | |||
lastTabName: 'unannotate', | |||
crudStatusMap: { | |||
'unannotate': [0], | |||
'annotate': [2, 3], | |||
'unannotate': [fileCodeMap.UNCOMPLETED], | |||
'annotate': [fileCodeMap.COMPLETED], | |||
}, | |||
newLabel: undefined, | |||
checkAll: false, | |||
isIndeterminate: false, | |||
typeSwitch: true, | |||
rawLabelData: [], | |||
showCreateLabel: true, | |||
labelData: [], | |||
name2CategoryId: {}, | |||
// 选中列表 | |||
@@ -251,8 +270,8 @@ export default { | |||
this.datasetId = parseInt(this.$route.params.datasetId, 10); | |||
this.refreshLabel(); | |||
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]) => { | |||
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(); | |||
}, | |||
@@ -277,6 +299,9 @@ export default { | |||
})(); | |||
}, | |||
methods: { | |||
handleEditLabel(field, item){ | |||
editLabel(item.id, field).then(this.refreshLabel); | |||
}, | |||
handleSort(command) { | |||
this.resetQuery(); | |||
this.crud.params.order = command === 1 ? 'name' : ''; | |||
@@ -340,6 +365,10 @@ export default { | |||
}; | |||
if (ids.length) { | |||
del(params).then(() => { | |||
this.$message({ | |||
message: '删除文件成功', | |||
type: 'success', | |||
}); | |||
this.crud.toQuery(); | |||
}).finally(() => { | |||
this.crud.delAllLoading = false; | |||
@@ -354,6 +383,7 @@ export default { | |||
}, | |||
handleCheckAllChange(val) { | |||
const {imgGallery} = this.$refs; | |||
if(!imgGallery) return false; | |||
if (val) { | |||
imgGallery.selectAll(); | |||
} else { | |||
@@ -442,10 +472,6 @@ export default { | |||
}, | |||
refreshLabel() { | |||
getLabels(this.datasetId).then((res) => { | |||
// 图像分类使用的是预置标签时,不显示新建标签功能,目前自定义标签type为0,自动标注标签为1 | |||
if (res[0] && res[0].type > 1) { | |||
this.showCreateLabel = false; | |||
} | |||
this.rawLabelData = res; | |||
this.rawLabelData.forEach((item) => { | |||
if (item.color === '#000000') { | |||
@@ -460,7 +486,9 @@ export default { | |||
this.labelData = this.rawLabelData; | |||
}); | |||
}, | |||
chooseLabel(row) { | |||
chooseLabel(row, event) { | |||
// 过滤编辑入口 | |||
if (event.target.classList.contains('el-icon-edit')) return; | |||
if (this.selectImgsId.length > 0) { | |||
const annotations = []; | |||
this.selectImgsId.forEach((item) => { | |||
@@ -472,7 +500,7 @@ export default { | |||
id: item, | |||
}); | |||
}); | |||
batchFinishAnnotation({ annotations }).then(() => { | |||
batchFinishAnnotation({ annotations }, this.datasetId).then(() => { | |||
this.crud.refresh(); | |||
this.handleCheckAllChange(0); | |||
}); | |||
@@ -489,6 +517,8 @@ export default { | |||
// 如果不是系统标签,才会选择新建 | |||
if (this.systemLabels.findIndex(d => d.value === value) === -1) { | |||
this.addLabel(value); | |||
// 新建标签 | |||
this.postLabel(); | |||
} else { | |||
const systemLabel = this.systemLabels.find(d => d.value === value) || {}; | |||
systemLabel.label && this.addLabel(systemLabel.label); | |||
@@ -512,6 +542,8 @@ export default { | |||
this.newLabel = undefined; | |||
this.refreshLabel(); | |||
}); | |||
} else { | |||
Message.warning('请选择标签'); | |||
} | |||
}, | |||
switchLabelTag(newSwitch) { | |||
@@ -519,13 +551,8 @@ export default { | |||
}, | |||
getStyle(item) { | |||
// 根据亮度来决定颜色 | |||
if (item.color && chroma(item.color).luminance() < 0.5) { | |||
return { | |||
color: '#fff', | |||
}; | |||
} | |||
return { | |||
color: '#000', | |||
color: colorByLuminance(item.color), | |||
}; | |||
}, | |||
}, | |||
@@ -36,7 +36,7 @@ | |||
> | |||
<el-carousel-item v-for="item in fileList" :key="item.id"> | |||
<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 class="figure-wrapper carousel-figure-item"> | |||
<div | |||
@@ -13,22 +13,28 @@ | |||
* limitations under the License. | |||
* ============================================================= | |||
*/ | |||
import { statusCodeMap } from '../util'; | |||
export default { | |||
name: 'DatasetAction', | |||
functional: true, | |||
props: { | |||
showPublish: Function, | |||
openUploadDialog: Function, | |||
uploadDataFile: Function, | |||
goDetail: Function, | |||
getAutoAnnotateStatus: Function, | |||
autoAnnotate: Function, | |||
gotoVersion: Function, | |||
reAnnotation: Function, | |||
track: Function, | |||
dataEnhance: Function, | |||
topDataset: Function, | |||
editDataset: Function, | |||
checkImport: Function, // 查询外部数据集导入状态 | |||
}, | |||
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 = { | |||
...data, | |||
scopedSlots: { | |||
@@ -36,10 +42,6 @@ export default { | |||
return ( | |||
<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> | |||
); | |||
}, | |||
@@ -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; | |||
} | |||
// 查看标注按钮 | |||
@@ -67,8 +69,8 @@ export default { | |||
</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 = ( | |||
<el-button {...btnProps} onClick={() => autoAnnotate(row)}> | |||
@@ -109,17 +111,17 @@ export default { | |||
</el-button> | |||
); | |||
// 当类型为视频时,状态为标注完成(4)目标跟踪完成(6)显示发布按钮,其余状态不显示发布按钮 | |||
// 当类型为图片时,状态为自动标注完成(3)显示有弹窗确认的发布按钮,为标注完成(4)显示发布按钮,其余状态不显示发布按钮 | |||
// 当类型为视频时,状态为标注完成、目标跟踪完成时显示发布按钮,其余状态不显示发布按钮 | |||
// 当类型为图片时,状态为自动标注完成时显示有弹窗确认的发布按钮,为标注完成时显示发布按钮,其余状态不显示发布按钮 | |||
if (row.dataType === 1) { | |||
if ([4, 6].includes(row.status)) { | |||
if (['ANNOTATED', 'TRACK_SUCCEED'].includes(statusCodeMap[row.status])) { | |||
showPublishButton = true; | |||
publishButton = publishDialogButton; | |||
} | |||
} else if (row.status === 3) { | |||
} else if (statusCodeMap[row.status] === 'AUTO_ANNOTATED') { | |||
showPublishButton = true; | |||
publishButton = publishConfirmButton; | |||
} else if (row.status === 4) { | |||
} else if (statusCodeMap[row.status] === 'ANNOTATED') { | |||
showPublishButton = true; | |||
publishButton = publishDialogButton; | |||
} | |||
@@ -127,22 +129,22 @@ export default { | |||
let showUploadButton = false; | |||
// 导入按钮 | |||
const uploadButton = ( | |||
<el-button {...btnProps} onClick={() => openUploadDialog(row)}> | |||
<el-button {...btnProps} onClick={() => uploadDataFile(row)}> | |||
导入 | |||
</el-button> | |||
); | |||
// 类型为视频时,当状态为未采样(5)时才可导入,其余状态不可导入 | |||
// 类型为图片时,自动标注中(2) 数据增强中(8)不可导入,其余状态均可导入 | |||
// 类型为视频时,当状态为未采样时才可导入,其余状态不可导入 | |||
// 类型为图片时,自动标注中、数据增强中 目标跟踪失败 不可导入,其余状态均可导入 | |||
if (row.dataType === 1) { | |||
if (row.status === 5) { | |||
if (statusCodeMap[row.status] === 'UNSAMPLED') { | |||
showUploadButton = true; | |||
} | |||
} else if (![2, 8].includes(row.status)) { | |||
} else if (!['AUTO_ANNOTATING', 'ENHANCING', 'TRACK_FAILED'].includes(statusCodeMap[row.status])) { | |||
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 = ( | |||
<el-popconfirm | |||
@@ -157,10 +159,28 @@ export default { | |||
</el-button> | |||
</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 = ( | |||
<el-button {...btnProps} onClick={() => dataEnhance(row)}> | |||
@@ -168,14 +188,41 @@ export default { | |||
</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 = ( | |||
<el-button {...btnProps} onClick={() => gotoVersion(row)}> | |||
历史版本 | |||
</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) { | |||
@@ -184,18 +231,23 @@ export default { | |||
showCheckButton = true; | |||
showAutoButton = false; | |||
showReAutoButton = false; | |||
showTrackButton = false; | |||
showReTrackButton = false; | |||
showVersionButton = true; | |||
showAugmentButton = false; | |||
showTopButton = false; | |||
showEditButton = false; | |||
}; | |||
// 导入的自定义数据集只允许删除操作 | |||
// 导入的自定义数据集只允许删除 置顶 修改操作 | |||
if (row.import) { | |||
showPublishButton = false; | |||
showUploadButton = false; | |||
showCheckButton = false; | |||
showAutoButton = false; | |||
showReAutoButton = false; | |||
showVersionButton = false; | |||
showTrackButton = false; | |||
showReTrackButton = false; | |||
showAugmentButton = false; | |||
// 导入完成才可以查看标注 | |||
showCheckButton = (statusCodeMap[row.status] === 'ANNOTATED'); | |||
}; | |||
// 统计需要显示的按钮个数 | |||
const buttonCount = (arr) => { | |||
@@ -204,8 +256,8 @@ export default { | |||
(item) => { if (item) count+=1; }); | |||
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 rightButtonCount = buttonCount(rightButtonArr); | |||
@@ -216,8 +268,11 @@ export default { | |||
if (leftButtonCount < 3) { | |||
moreButton = ( | |||
<span> | |||
{showReTrackButton && reTrackButton} | |||
{showVersionButton && versionButton} | |||
{showAugmentButton && augmentButton} | |||
{showTopButton && topButton} | |||
{showEditButton && editButton} | |||
</span> | |||
); | |||
} else { | |||
@@ -227,11 +282,20 @@ export default { | |||
更多<i class='el-icon-arrow-down el-icon--right'></i> | |||
</el-button> | |||
<el-dropdown-menu slot='dropdown'> | |||
<el-dropdown-item> | |||
{showReTrackButton && reTrackButton} | |||
</el-dropdown-item> | |||
<el-dropdown-item> | |||
{showVersionButton && versionButton} | |||
</el-dropdown-item> | |||
<el-dropdown-item key='dataEnhance'> | |||
{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-menu> | |||
</el-dropdown> | |||
@@ -241,10 +305,12 @@ export default { | |||
return ( | |||
<span> | |||
{ importDatasetButton } | |||
{showPublishButton && publishButton} | |||
{showUploadButton && uploadButton} | |||
{showCheckButton && checkButton} | |||
{showAutoButton && autoButton} | |||
{showTrackButton && trackButton} | |||
{showReAutoButton && reAutoButton} | |||
{moreButton} | |||
</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> | |||
<BaseModal | |||
:key="formKey" | |||
title="导入自定义数据集" | |||
:title="importStep===0 ? '导入数据集' : '创建数据集'" | |||
width="600px" | |||
center | |||
:visible="visible" | |||
:disabled="uploading" | |||
@change="handleCancelUploadDataset" | |||
@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-input v-model="form.name" placeholder="数据集名称不能超过50字" maxlength="50" /> | |||
</el-form-item> | |||
<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 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 label="数据集描述"> | |||
<el-input | |||
@@ -60,44 +73,48 @@ | |||
/> | |||
</el-form-item> | |||
</el-form> | |||
<el-button v-if="importStep===0" slot="footer" class="tc" type="primary" @click="nextImportStep">已阅读,确定创建</el-button> | |||
</BaseModal> | |||
</template> | |||
<script> | |||
import { bucketName } from '@/utils/minIO'; | |||
import UploadInline from '@/components/UploadForm/inline'; | |||
import BaseModal from '@/components/BaseModal'; | |||
import { addCustomDataset } from '@/api/preparation/dataset'; | |||
import InfoSelect from '@/components/InfoSelect'; | |||
import { validateName } from "@/utils/validate"; | |||
import { annotationMap } from '@/views/dataset/util'; | |||
import { add } from '@/api/preparation/dataset'; | |||
export default { | |||
name: "UploadDatasetForm", | |||
name: "ImportDataset", | |||
components: { | |||
UploadInline, | |||
BaseModal, | |||
InfoSelect, | |||
}, | |||
props: { | |||
visible: { | |||
type: Boolean, | |||
default: false, | |||
}, | |||
closeUploadDatasetForm: { | |||
toggleImportDataset: { | |||
type: Function, | |||
}, | |||
onResetFresh: { | |||
type: Function, | |||
}, | |||
}, | |||
data() { | |||
return { | |||
importStep: 0, | |||
formKey: 1, | |||
form: { | |||
name: "", | |||
dataType: 0, | |||
annotateType: 2, | |||
status: 4, | |||
datasetFile: undefined, | |||
remark: "", | |||
}, | |||
uploading: false, | |||
rules: { | |||
name: [ | |||
{ | |||
@@ -107,67 +124,71 @@ export default { | |||
}, | |||
{ validator: validateName, trigger: ["change", "blur"] }, | |||
], | |||
datasetFile: [ | |||
annotateType: [ | |||
{ | |||
required: true, | |||
message: "请选择上传数据集", | |||
trigger: ["blur", "manual"], | |||
message: "请选择标注类型", | |||
trigger: ["change", "blur"], | |||
}, | |||
], | |||
}, | |||
}; | |||
}, | |||
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: { | |||
nextImportStep() { | |||
this.importStep += 1; | |||
}, | |||
handleCancelUploadDataset() { | |||
this.formKey += 1; | |||
this.closeUploadDatasetForm(); | |||
this.importStep = 0; | |||
this.toggleImportDataset(); | |||
this.onResetFresh(); | |||
}, | |||
handleUploadDataset(formName) { | |||
this.$refs[formName].validate(valid => { | |||
if (!valid) { | |||
return; | |||
} | |||
const customForm = { | |||
const customForm = { | |||
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({ | |||
message: '导入数据集成功', | |||
message: '创建数据集成功', | |||
type: 'success', | |||
}); | |||
}).finally(() => { | |||
this.resetFormFields(); | |||
this.closeUploadDatasetForm(); | |||
this.toggleImportDataset(); | |||
this.onResetFresh(); | |||
}); | |||
}); | |||
}, | |||
resetFormFields() { | |||
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', | |||
functional: true, | |||
render(h, { data, props }) { | |||
const { withAllDatasetStatusList, filterByDatasetStatus, datasetStatusFilter } = props; | |||
const { statusList, filterByDatasetStatus, datasetStatusFilter } = props; | |||
const iconClass = ['el-icon-arrow-down', 'el-icon--right']; | |||
const textClass = datasetStatusFilter === 'all' ? null : 'primary'; | |||
const columnProps = { | |||
@@ -34,7 +34,7 @@ export default { | |||
<i {... { class: iconClass } } /> | |||
</span> | |||
<el-dropdown-menu slot='dropdown'> | |||
{withAllDatasetStatusList.map(item => { | |||
{statusList.map(item => { | |||
return ( | |||
<el-dropdown-item | |||
key={item.value} | |||
@@ -45,17 +45,10 @@ export default { | |||
); | |||
})} | |||
</el-dropdown-menu> | |||
<el-tooltip effect='dark' content='数据集状态可能会延迟更新,请耐心等待' placement='top' style={{ marginLeft: '10px' }}> | |||
<i class='el-icon-question'/> | |||
</el-tooltip> | |||
</el-dropdown> | |||
); | |||
}, | |||
default: ({ row }) => { | |||
// 导入自定义数据集 状态保持为标注完成(4) | |||
if (row.import) { | |||
row.status = 4; | |||
} | |||
const status = datasetStatusMap[row.status] || {}; | |||
const colorProps = (!status.type && status.bgColor) && { | |||
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; | |||
}; | |||
// 将 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 生成字符串 | |||
export const stringifyAnnotations = (annotations) => { | |||
const resultList = annotations.map(d => { | |||
@@ -157,6 +173,16 @@ export const withDimensionFiles = async(files) => { | |||
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 配置 | |||
export const labelsSymbol = Symbol('labels'); | |||
export const enhanceSymbol = Symbol('enhance'); | |||
@@ -170,10 +196,25 @@ export const dataTypeMap = { | |||
// 文件状态 | |||
export const fileTypeEnum = { | |||
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 = { | |||
@@ -186,15 +227,34 @@ export const annotationMap = { | |||
// 数据集状态 | |||
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: '未完成', | |||
autoFinished: '自动标注完成', | |||
finishAutoTrack: '目标跟踪完成', | |||
annotationNotDistinguishFile: '未识别', | |||
}; | |||
export const decompressProgressMap = { | |||
@@ -219,3 +280,9 @@ export const dataEnhanceMap = { | |||
3: 'info', | |||
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-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 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 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-select> | |||
</el-form-item> | |||
<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-group> | |||
</el-form-item> | |||
@@ -19,17 +19,26 @@ | |||
<!--工具栏--> | |||
<div class="head-container"> | |||
<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"> | |||
<!-- 搜索 --> | |||
<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 /> | |||
</span> | |||
</cdOperation> | |||
@@ -53,6 +62,14 @@ | |||
</el-table-column> | |||
<el-table-column prop="description" label="描述" /> | |||
<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"> | |||
<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> | |||
@@ -65,13 +82,13 @@ | |||
</el-table-column> | |||
<el-table-column label="操作" width="200" fixed="right"> | |||
<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" /> | |||
</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" /> | |||
</template> | |||
</el-table-column> | |||
@@ -87,6 +104,7 @@ import { debounce } from 'throttle-debounce'; | |||
import notebookApi, {detail, getStatus, start, stop, open} from '@/api/development/notebook'; | |||
import { add as addAlgorithm } from '@/api/algorithm/algorithm'; | |||
import DropdownHeader from '@/components/DropdownHeader'; | |||
import CRUD, { presenter, header, crud } from '@crud/crud'; | |||
import rrOperation from '@crud/RR.operation'; | |||
import cdOperation from '@crud/CD.operation'; | |||
@@ -96,7 +114,7 @@ import NotebookDetail from './components/NotebookDetail'; | |||
export default { | |||
name: 'Notebook', | |||
components: { pagination, rrOperation, cdOperation, CreateDialog, NotebookDetail }, | |||
components: { pagination, rrOperation, cdOperation, DropdownHeader, CreateDialog, NotebookDetail }, | |||
cruds() { | |||
return CRUD({ | |||
title: 'Notebook', | |||
@@ -121,9 +139,24 @@ export default { | |||
drawer: false, | |||
selectedItemObj: {}, | |||
pollingCount: 0, | |||
keepPoll: true, | |||
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() { | |||
this.crud.msg.del = '正在删除'; | |||
this.pollingCount = 0; | |||
@@ -131,10 +164,12 @@ export default { | |||
this.query.noteBookName = this.$route.params.noteBookName; | |||
} | |||
this.refetch = debounce(1000, this.crud.refresh); | |||
this.detailRefetch = debounce(2000, this.polling); | |||
this.getNotebookStatus(); | |||
}, | |||
beforeDestroy() { | |||
this.ct && clearTimeout(this.ct); | |||
this.keepPoll = false; | |||
}, | |||
methods: { | |||
[CRUD.HOOK.afterRefresh]() { | |||
@@ -153,18 +188,22 @@ export default { | |||
this.crud.refresh(); | |||
}); | |||
}, | |||
filterByStatus(status) { | |||
this.localQuery.status = status; | |||
this.crud.toQuery(); | |||
}, | |||
checkStatus() { | |||
// 删除操作5s内 或 有进行中的状态需要刷新列表 | |||
if (this.deleteCount > 0) { | |||
this.deleteCount -= 1; | |||
this.refetch(); | |||
} else if (this.crud.data.some(item => [3, 4, 5].includes(item.status) || (item.status === 0 && !item.url))) { | |||
this.polling(); | |||
this.detailRefetch(); | |||
} | |||
}, | |||
async polling() { | |||
const idList = this.checkPollingIds(); | |||
if (!idList.length) { | |||
if (!this.keepPoll || !idList.length) { | |||
return; | |||
} | |||
const res = await detail(idList); | |||
@@ -177,12 +216,16 @@ export default { | |||
ele.status = item.status; | |||
ele.updateTime = item.updateTime; | |||
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))) { | |||
this.ct = setTimeout(() => { | |||
if (this.pollingCount < 200) { // 400s超时,超时不作提示 | |||
this.polling(); | |||
this.detailRefetch(); | |||
} | |||
}, 2000); | |||
} | |||
@@ -209,6 +252,9 @@ export default { | |||
default: return ''; | |||
} | |||
}, | |||
[CRUD.HOOK.beforeRefresh]() { | |||
this.crud.query = { ...this.localQuery}; | |||
}, | |||
toAdd() { | |||
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> | |||
<!--step==2--> | |||
<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="模型名称"> | |||
<div>{{ form.name }}</div> | |||
</el-form-item> | |||
<el-form-item label="模型上传" prop="modelAddress"> | |||
<el-form-item ref="modelAddress" label="模型上传" prop="modelAddress"> | |||
<upload-inline | |||
v-if="refreshFlag" | |||
action="fakeApi" | |||
accept=".zip,.pb,.h5,.ckpt,.pkl,.pth,.weight,.caffemodel,.pt" | |||
:acceptSize="5120" | |||
:acceptSize="0" | |||
list-type="text" | |||
:limit="1" | |||
:multiple="false" | |||
@@ -103,12 +103,19 @@ | |||
@uploadSuccess="uploadSuccess" | |||
@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> | |||
<div slot="footer" class="dialog-footer"> | |||
<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> | |||
</template> | |||
</el-dialog> | |||
@@ -119,8 +126,8 @@ import { add as addVersion } from '@/api/model/modelVersion'; | |||
import { add as addModel } from '@/api/model/model'; | |||
import { list as getAlgorithmUsages, add as addAlgorithmUsage } from '@/api/algorithm/algorithmUsage'; | |||
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 = { | |||
name: null, | |||
@@ -139,7 +146,7 @@ const defaultForm2 = { | |||
export default { | |||
name: 'AddModelDialog', | |||
dicts: ['model_type', 'frame_type'], | |||
components: { UploadInline }, | |||
components: { UploadInline, UploadProgress }, | |||
data() { | |||
return { | |||
visible: false, | |||
@@ -171,18 +178,33 @@ export default { | |||
{ max: 255, message: '长度在255个字符以内', trigger: 'blur' }, | |||
], | |||
modelAddress: [ | |||
{ required: true, message: '请上传有效的模型', trigger: 'blur' }, | |||
{ required: true, message: '请上传有效的模型', trigger: ['blur', 'manual'] }, | |||
], | |||
}, | |||
step: 0, | |||
uploadParams: { | |||
objectPath: `model/${this.$store.state.user.user.id}/${parseTime(new Date(), '{y}{m}{d}{h}{i}{s}{S}') + nanoid(4)}`, // 对象存储路径 | |||
objectPath: null, // 对象存储路径 | |||
}, | |||
algorithmUsageList: [], | |||
refreshFlag: true, | |||
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: { | |||
show() { | |||
this.refreshFlag = false; | |||
@@ -196,6 +218,7 @@ export default { | |||
}, | |||
onDialogClose() { | |||
this.reset(); | |||
this.loading = false; | |||
this.$emit('addDone', true); | |||
}, | |||
reset() { | |||
@@ -217,13 +240,22 @@ export default { | |||
handleRemove() { | |||
this.loading = false; | |||
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) { | |||
this.loading = false; | |||
this.progress = 100; | |||
setTimeout(() => { | |||
this.loading = false; | |||
}, 1000); | |||
this.form2.modelAddress = res[0].data.objectName; | |||
this.$refs.modelAddress.validate('manual'); | |||
}, | |||
uploadError() { | |||
this.loading = false; | |||
@@ -274,6 +306,9 @@ export default { | |||
await addAlgorithmUsage({ auxInfo }); | |||
this.getAlgorithmUsages(); | |||
}, | |||
updateImagePath() { | |||
this.uploadParams.objectPath = `upload-temp/${this.user.id}/${getUniqueId()}`; | |||
}, | |||
}, | |||
}; | |||
</script> |
@@ -21,17 +21,17 @@ | |||
<div class="cd-opts"> | |||
<span class="cd-opts-left"> | |||
<el-button | |||
id="toAdd" | |||
class="filter-item" | |||
type="primary" | |||
icon="el-icon-plus" | |||
round | |||
@click="toAdd" | |||
> | |||
创建模型 | |||
</el-button> | |||
>创建模型</el-button> | |||
</span> | |||
<span class="cd-opts-right"> | |||
<el-input | |||
id="queryName" | |||
v-model="query.name" | |||
clearable | |||
placeholder="请输入模型名称或ID" | |||
@@ -44,8 +44,8 @@ | |||
</div> | |||
<div> | |||
<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> | |||
</div> | |||
</div> | |||
@@ -61,15 +61,20 @@ | |||
<el-table-column prop="id" label="ID" width="80" sortable="custom" /> | |||
<el-table-column prop="name" label="模型名称" min-width="180px" /> | |||
<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 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 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 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"> | |||
<template slot-scope="scope"> | |||
<a | |||
@@ -88,6 +93,7 @@ | |||
<template slot-scope="scope"> | |||
<el-button | |||
v-if="isCustom" | |||
:id="`goVersion_`+scope.$index" | |||
type="text" | |||
@click="goVersion(scope.row.id, scope.row.name)" | |||
>历史版本</el-button> | |||
@@ -99,6 +105,7 @@ | |||
> | |||
<span :class="{'ml-10 mr-10': isCustom}"> | |||
<el-button | |||
:id="`doDownload_`+scope.$index" | |||
:disabled="!scope.row.modelAddress" | |||
type="text" | |||
@click="doDownload(scope.row)" | |||
@@ -107,11 +114,13 @@ | |||
</el-tooltip> | |||
<el-button | |||
v-if="isCustom" | |||
:id="`doEdit_`+scope.$index" | |||
type="text" | |||
@click="doEdit(scope.row)" | |||
>编辑</el-button> | |||
<el-button | |||
v-if="isCustom" | |||
:id="`doDelete_`+scope.$index" | |||
type="text" | |||
@click="doDelete(scope.row.id)" | |||
>删除</el-button> | |||
@@ -134,6 +143,7 @@ | |||
<el-form ref="form" :model="form" :rules="rules" label-width="100px"> | |||
<el-form-item label="模型名称" prop="name"> | |||
<el-input | |||
id="name" | |||
v-model.trim="form.name" | |||
style="width: 300px;" | |||
maxlength="15" | |||
@@ -142,7 +152,12 @@ | |||
/> | |||
</el-form-item> | |||
<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 | |||
v-for="item in dict.frame_type" | |||
:key="item.value" | |||
@@ -152,7 +167,12 @@ | |||
</el-select> | |||
</el-form-item> | |||
<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 | |||
v-for="item in dict.model_type" | |||
:key="item.value" | |||
@@ -163,6 +183,7 @@ | |||
</el-form-item> | |||
<el-form-item label="模型类别" prop="modelClassName"> | |||
<el-select | |||
id="modelClassName" | |||
v-model="form.modelClassName" | |||
placeholder="请选择或输入模型类别" | |||
filterable | |||
@@ -179,27 +200,35 @@ | |||
</el-select> | |||
</el-form-item> | |||
<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> | |||
</BaseModal> | |||
<!--多步骤新增dialog--> | |||
<add-model-dialog | |||
ref="addModel" | |||
@addDone="addDone" | |||
/> | |||
<add-model-dialog ref="addModel" @addDone="addDone" /> | |||
</div> | |||
</template> | |||
<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 = { | |||
name: null, | |||
@@ -235,7 +264,7 @@ export default { | |||
{ max: 20, message: '长度在 20 个字符以内', trigger: 'blur' }, | |||
{ | |||
validator: validateNameWithHyphen, | |||
trigger: ['blur', 'change'], | |||
trigger: ["blur", "change"], | |||
}, | |||
], | |||
frameType: [ | |||
@@ -245,7 +274,11 @@ export default { | |||
{ required: true, message: '请选择模型格式', trigger: 'blur' }, | |||
], | |||
modelClassName: [ | |||
{ required: true, message: '请输入模型类别', trigger: ['blur', 'change'] }, | |||
{ | |||
required: true, | |||
message: '请输入模型类别', | |||
trigger: ["blur", "change"], | |||
}, | |||
], | |||
modelDescription: [ | |||
{ required: true, message: '请输入模型描述', trigger: 'blur' }, | |||
@@ -253,7 +286,7 @@ export default { | |||
], | |||
}, | |||
algorithmUsageList: [], | |||
active: '0', | |||
active: "0", | |||
}; | |||
}, | |||
computed: { | |||
@@ -291,7 +324,9 @@ export default { | |||
this.getAlgorithmUsages(); | |||
}, | |||
onAlgorithmUsageChange(value) { | |||
const usageRes = this.algorithmUsageList.find(usage => usage.auxInfo === value); | |||
const usageRes = this.algorithmUsageList.find( | |||
usage => usage.auxInfo === value, | |||
); | |||
if (!usageRes) { | |||
this.createAlgorithmUsage(value); | |||
} | |||
@@ -304,7 +339,7 @@ export default { | |||
}, | |||
// link | |||
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 | |||
async doEdit(item) { | |||
@@ -315,7 +350,7 @@ export default { | |||
}, | |||
doDelete(id) { | |||
this.$confirm('此操作将永久删除该模型, 是否继续?', '请确认').then( | |||
async() => { | |||
async () => { | |||
const params = { | |||
ids: [id], | |||
}; | |||
@@ -333,9 +368,11 @@ export default { | |||
const msg = this.isCustom | |||
? `此操作将下载 ${name} 模型的 ${versionNum} 版本, 是否继续?` | |||
: `此操作将下载预训练模型 ${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'); | |||
this.$message({ | |||
message: '请查看下载文件', | |||
@@ -42,10 +42,11 @@ | |||
<el-table-column label="操作" width="150px" fixed="right"> | |||
<template slot-scope="scope"> | |||
<el-button | |||
:id="`doDownload_`+scope.$index" | |||
type="text" | |||
@click="doDownload(scope.row.parentId, scope.row.versionNum, scope.row.modelAddress)" | |||
>下载</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> | |||
</el-table-column> | |||
</el-table> | |||
@@ -57,7 +58,9 @@ | |||
:visible="crud.status.cu > 0" | |||
:title="crud.status.title" | |||
:loading="crud.status.cu === 2" | |||
:disabled="loading" | |||
width="800px" | |||
@close="onDialogClose" | |||
@cancel="crud.cancelCU" | |||
@ok="onSubmit" | |||
> | |||
@@ -68,9 +71,10 @@ | |||
<el-form-item ref="modelAddress" label="模型上传" prop="modelAddress"> | |||
<upload-inline | |||
v-if="refreshFlag" | |||
ref="upload" | |||
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" | |||
:limit="1" | |||
:multiple="false" | |||
@@ -82,7 +86,14 @@ | |||
@uploadSuccess="uploadSuccess" | |||
@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> | |||
</BaseModal> | |||
@@ -90,14 +101,14 @@ | |||
</template> | |||
<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 BaseModal from '@/components/BaseModal'; | |||
import cdOperation from '@crud/CD.operation'; | |||
import pagination from '@crud/Pagination'; | |||
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 = { | |||
parentId: null, | |||
@@ -107,7 +118,7 @@ const defaultForm = { | |||
export default { | |||
name: 'ModelVersion', | |||
dicts: ['model_source'], | |||
components: { BaseModal, pagination, cdOperation, UploadInline }, | |||
components: { BaseModal, pagination, cdOperation, UploadInline, UploadProgress }, | |||
cruds() { | |||
return CRUD({ | |||
title: '模型版本管理', | |||
@@ -135,12 +146,27 @@ export default { | |||
], | |||
}, | |||
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, | |||
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() { | |||
this.modelId = this.$route.query.id; | |||
this.modelName = this.$route.query.name; | |||
@@ -156,12 +182,20 @@ export default { | |||
handleRemove() { | |||
this.loading = false; | |||
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) { | |||
this.loading = false; | |||
this.progress = 100; | |||
setTimeout(() => { | |||
this.loading = false; | |||
}, 1000); | |||
this.form.modelAddress = res[0].data.objectName; | |||
this.$refs.modelAddress.validate('manual'); | |||
}, | |||
@@ -172,6 +206,10 @@ export default { | |||
type: 'error', | |||
}); | |||
}, | |||
onDialogClose() { | |||
this.$refs.upload.formRef.reset(); | |||
this.loading = false; | |||
}, | |||
onSubmit() { | |||
this.form.parentId = this.modelId; | |||
this.crud.submitCU(); | |||
@@ -183,10 +221,13 @@ export default { | |||
this.refreshFlag = true; | |||
}); | |||
}, | |||
updateImagePath() { | |||
this.uploadParams.objectPath = `upload-temp/${this.user.id}/${getUniqueId()}`; | |||
}, | |||
// op | |||
doDelete(id) { | |||
this.$confirm('此操作将永久删除该模型, 是否继续?', '请确认') | |||
.then(async() => { | |||
this.$confirm('此操作将永久删除该模型, 是否继续?', '请确认').then( | |||
async () => { | |||
const params = { | |||
ids: [id], | |||
}; | |||
@@ -196,19 +237,22 @@ export default { | |||
type: 'success', | |||
}); | |||
this.crud.refresh(); | |||
}); | |||
}, | |||
); | |||
}, | |||
doDownload(parentId, versionNum, filepath) { | |||
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({ | |||
message: '请查看下载文件', | |||
type: 'success', | |||
}); | |||
}, () => {}); | |||
}, | |||
() => {}, | |||
); | |||
}, | |||
}, | |||
}; | |||
@@ -31,7 +31,7 @@ | |||
<el-input v-model="form.label" style="width: 370px;" maxlength="50" show-word-limit /> | |||
</el-form-item> | |||
<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 label="排序" prop="sort"> | |||
<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}; | |||
})], | |||
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 { | |||
dictId: null, | |||
dictName: '', | |||
rules: { | |||
label: [ | |||
{ required: true, message: '请输入字典标签', trigger: 'blur' }, | |||
{ validator: validateAccount, trigger: 'change' }, | |||
], | |||
value: [ | |||
{ required: true, message: '请输入字典值', trigger: 'blur' }, | |||
{ validator: validateAccount, trigger: 'change' }, | |||
], | |||
sort: [ | |||
{ required: true, message: '请输入序号', trigger: 'blur', type: 'number' }, | |||
@@ -41,36 +41,6 @@ | |||
:picker-options="pickerOptions" | |||
@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 /> | |||
</span> | |||
</cdOperation> | |||
@@ -142,12 +112,28 @@ | |||
<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="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"> | |||
<span>{{ getUserRoles(scope.row) }}</span> | |||
</template> | |||
</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"> | |||
<el-tag :type="scope.row.enabled ? '' : 'info'" effect="plain">{{ dict.label.user_status[scope.row.enabled.toString()] }} </el-tag> | |||
</template> | |||
@@ -181,6 +167,7 @@ import { validateName, validateAccount } from '@/utils/validate'; | |||
import crudUser from '@/api/system/user'; | |||
import { getAll } from '@/api/system/role'; | |||
import BaseModal from '@/components/BaseModal'; | |||
import DropdownHeader from '@/components/DropdownHeader'; | |||
import datePickerMixin from '@/mixins/datePickerMixin'; | |||
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: '' }; | |||
export default { | |||
name: 'User', | |||
components: { BaseModal, cdOperation, rrOperation, udOperation, pagination }, | |||
components: { BaseModal, cdOperation, rrOperation, udOperation, pagination, DropdownHeader }, | |||
cruds() { | |||
return CRUD({ title: '用户', crudMethod: { ...crudUser }}); | |||
}, | |||
@@ -233,6 +220,16 @@ export default { | |||
...mapGetters([ | |||
'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() { | |||
this.$nextTick(() => { | |||
@@ -288,6 +285,14 @@ export default { | |||
const names = roles.map(role => role.name); | |||
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> |
@@ -18,14 +18,28 @@ | |||
<div class="app-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> | |||
<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-table | |||
v-if="prefabricate" | |||
ref="table" | |||
v-loading="crud.loading || disableEdit" | |||
:data="crud.data" | |||
@@ -33,10 +47,10 @@ | |||
@selection-change="crud.selectionChangeHandler" | |||
@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="imageTag" label="镜像版本号" sortable="custom" /> | |||
<el-table-column prop="imageStatus" width="160px"> | |||
<el-table-column prop="imageStatus" label="状态" width="160px"> | |||
<template #header> | |||
<dropdown-header | |||
title="状态" | |||
@@ -57,6 +71,16 @@ | |||
<span>{{ parseTime(scope.row.createTime) }}</span> | |||
</template> | |||
</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> | |||
<!--分页组件--> | |||
<pagination /> | |||
@@ -78,48 +102,63 @@ | |||
:rules="rules" | |||
label-width="120px" | |||
> | |||
<el-form-item label="镜像名称" prop="imageName"> | |||
<el-form-item v-if="isEdit" label="镜像名称" prop="imageName"> | |||
<el-select | |||
id="imageName" | |||
v-model="form.imageName" | |||
placeholder="请选择镜像名称" | |||
placeholder="请选择或输入镜像名称" | |||
style="width: 400px;" | |||
clearable | |||
filterable | |||
allow-create | |||
default-first-option | |||
@focus="getHarborProjects" | |||
> | |||
<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-form-item> | |||
<el-form-item label="镜像文件路径" prop="imagePath"> | |||
<el-form-item v-if="isEdit" ref="imagePath" label="镜像文件路径" prop="imagePath"> | |||
<upload-inline | |||
v-if="crud.status.cu > 0" | |||
ref="upload" | |||
action="fakeApi" | |||
accept=".zip,.tar,.rar,.gz" | |||
list-type="text" | |||
:acceptSize="5120" | |||
:acceptSize="0" | |||
:params="uploadParams" | |||
:show-file-count="false" | |||
:auto-upload="true" | |||
:hash="false" | |||
:limit="1" | |||
:on-remove="onFileRemove" | |||
@uploadStart="uploadStart" | |||
@uploadSuccess="uploadSuccess" | |||
@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 label="镜像版本号" prop="imageTag"> | |||
<el-form-item v-if="isEdit" label="镜像版本号" prop="imageTag"> | |||
<el-input | |||
id="imageTag" | |||
v-model="form.imageTag" | |||
style="width: 400px;" | |||
/> | |||
</el-form-item> | |||
<el-form-item label="描述" prop="remark"> | |||
<el-input | |||
id="remark" | |||
v-model="form.remark" | |||
type="textarea" | |||
:rows="4" | |||
@@ -135,18 +174,19 @@ | |||
</template> | |||
<script> | |||
import { nanoid } from 'nanoid'; | |||
// eslint-disable-next-line import/no-extraneous-dependencies | |||
import { debounce } from 'throttle-debounce'; | |||
import cdOperation from '@crud/CD.operation'; | |||
import rrOperation from '@crud/RR.operation'; | |||
import pagination from '@crud/Pagination'; | |||
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 UploadInline from '@/components/UploadForm/inline'; | |||
import DropdownHeader from '@/components/DropdownHeader'; | |||
import UploadProgress from '@/components/UploadProgress'; | |||
const defaultForm = { | |||
imageName: null, | |||
@@ -160,8 +200,10 @@ export default { | |||
BaseModal, | |||
pagination, | |||
cdOperation, | |||
rrOperation, | |||
UploadInline, | |||
DropdownHeader, | |||
UploadProgress, | |||
}, | |||
cruds() { | |||
return CRUD({ | |||
@@ -194,10 +236,22 @@ export default { | |||
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 { | |||
active: '0', | |||
localQuery: { | |||
imageStatus: null, | |||
imageNameOrId: null, | |||
}, | |||
map: { | |||
0: 'info', | |||
@@ -212,9 +266,10 @@ export default { | |||
rules: { | |||
imageName: [ | |||
{ required: true, message: '请选择项目名称', trigger: 'change' }, | |||
{ validator: validateImageName, trigger: ['blur', 'change'] }, | |||
], | |||
imagePath: [ | |||
{ required: true, message: '请输入镜像路径', trigger: 'blur' }, | |||
{ required: true, message: '请输入镜像路径', trigger: ['blur', 'manual'] }, | |||
], | |||
imageTag: [ | |||
{ required: true, message: '请输入镜像版本号', trigger: 'blur' }, | |||
@@ -226,11 +281,23 @@ export default { | |||
uploadParams: { | |||
objectPath: null, // 对象存储路径 | |||
}, | |||
progress: 0, | |||
size: 0, | |||
customColors: [ | |||
{color: '#909399', percentage: 40}, | |||
{color: '#e6a23c', percentage: 80}, | |||
{color: '#67c23a', percentage: 100}, | |||
], | |||
disableEdit: false, | |||
loading: false, | |||
isEdit: false, | |||
prefabricate: true, | |||
}; | |||
}, | |||
computed: { | |||
isShow() { | |||
return this.active === '0'; | |||
}, | |||
operationProps() { | |||
return { | |||
disabled: Number(this.active) === 1, | |||
@@ -243,9 +310,12 @@ export default { | |||
} | |||
return arr; | |||
}, | |||
getUser() { | |||
user() { | |||
return this.$store.getters.user; | |||
}, | |||
status() { | |||
return this.progress === 100 ? 'success' : null; | |||
}, | |||
}, | |||
mounted() { | |||
this.crud.query.imageResource = Number(this.active); | |||
@@ -258,19 +328,31 @@ export default { | |||
handleClick() { | |||
this.crud.query.imageResource = Number(this.active); | |||
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) { | |||
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() { | |||
this.$message({ | |||
@@ -284,18 +366,24 @@ export default { | |||
this.checkStatus(); | |||
}, | |||
[CRUD.HOOK.beforeToAdd]() { | |||
this.isEdit = true; | |||
this.formType = 'add'; | |||
this.updateImagePath(); | |||
}, | |||
[CRUD.HOOK.beforeRefresh]() { | |||
this.crud.query = { ...this.localQuery}; | |||
this.crud.query.imageResource = Number(this.active); | |||
}, | |||
[CRUD.HOOK.beforeToEdit]() { | |||
this.isEdit = false; | |||
}, | |||
async getHarborProjects() { | |||
this.harborProjectList = await project(); | |||
this.harborProjectList = await imageNameList(); | |||
}, | |||
onDialogClose() { | |||
this.$refs.upload.formRef.reset(); | |||
if (this.isEdit) { | |||
this.$refs.upload.formRef.reset(); | |||
} | |||
this.loading = false; | |||
}, | |||
checkStatus() { | |||
if (this.crud.data.some(item => [0].includes(item.imageStatus))) { | |||
@@ -306,8 +394,33 @@ export default { | |||
this.localQuery.imageStatus = status; | |||
this.crud.toQuery(); | |||
}, | |||
resetQuery() { | |||
this.localQuery = { | |||
imageStatus: null, | |||
imageNameOrId: null, | |||
}; | |||
}, | |||
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(); | |||
}, | |||
); | |||
}, | |||
}, | |||
}; | |||