Browse Source

update webapp

tags/v0.3.0
之江实验室 3 years ago
parent
commit
fd6b7ae04c
100 changed files with 8385 additions and 2274 deletions
  1. +2
    -2
      webapp/.env.development
  2. +13
    -0
      webapp/.env.test
  3. +14
    -10
      webapp/.eslintrc.js
  4. +26
    -0
      webapp/CHANGELOG.md
  5. +211
    -0
      webapp/LICENSE
  6. +69
    -3
      webapp/README.md
  7. +6
    -1
      webapp/package.json
  8. +3
    -1
      webapp/src/App.vue
  9. +9
    -2
      webapp/src/api/preparation/annotation.js
  10. +8
    -0
      webapp/src/api/preparation/datalabel.js
  11. +27
    -2
      webapp/src/api/preparation/dataset.js
  12. +89
    -0
      webapp/src/api/preparation/labelGroup.js
  13. +1
    -1
      webapp/src/api/system/harbor.js
  14. +50
    -0
      webapp/src/api/system/pod.js
  15. +19
    -3
      webapp/src/api/trainingImage/index.js
  16. +13
    -6
      webapp/src/api/trainingJob/job.js
  17. +4
    -0
      webapp/src/assets/styles/atomic.scss
  18. +6
    -13
      webapp/src/assets/styles/common.scss
  19. +32
    -0
      webapp/src/assets/styles/element-ui.scss
  20. +21
    -1
      webapp/src/assets/styles/index.scss
  21. +1
    -0
      webapp/src/assets/styles/variables.scss
  22. +2
    -2
      webapp/src/components/BaseModal/index.js
  23. +2
    -0
      webapp/src/components/Crud/CD.operation.vue
  24. +2
    -0
      webapp/src/components/Crud/RR.operation.vue
  25. +133
    -0
      webapp/src/components/Drag/drag.vue
  26. +19
    -0
      webapp/src/components/Drag/index.js
  27. +4
    -5
      webapp/src/components/Exception/index.vue
  28. +1
    -1
      webapp/src/components/IconFont/index.js
  29. +9
    -5
      webapp/src/components/ImageGallery/index.vue
  30. +17
    -4
      webapp/src/components/InfoSelect/info-select.vue
  31. +0
    -217
      webapp/src/components/LabelPopover/index.vue
  32. +125
    -0
      webapp/src/components/LogContainer/index.vue
  33. +243
    -0
      webapp/src/components/Training/dataSourceSelector.vue
  34. +479
    -257
      webapp/src/components/Training/jobForm.vue
  35. +135
    -0
      webapp/src/components/Training/paramPair.vue
  36. +117
    -144
      webapp/src/components/Training/runParamForm.vue
  37. +7
    -1
      webapp/src/components/Training/saveModelDialog.vue
  38. +4
    -7
      webapp/src/components/UploadForm/form.js
  39. +1
    -1
      webapp/src/components/UploadForm/inline.vue
  40. +93
    -0
      webapp/src/components/UploadProgress/index.vue
  41. +217
    -0
      webapp/src/components/svg/brush/Brush.js
  42. +227
    -0
      webapp/src/components/svg/brush/BrushCorner.js
  43. +193
    -0
      webapp/src/components/svg/brush/BrushHandle.js
  44. +55
    -0
      webapp/src/components/svg/brush/BrushSelection.js
  45. +19
    -0
      webapp/src/components/svg/brush/index.js
  46. +42
    -0
      webapp/src/components/svg/group/index.js
  47. +18
    -0
      webapp/src/components/svg/index.js
  48. +3
    -3
      webapp/src/config/index.js
  49. +38
    -0
      webapp/src/hooks/brush/useBrush.js
  50. +5
    -0
      webapp/src/hooks/zoom/useZoom.js
  51. +2
    -1
      webapp/src/layout/BaseLayout.vue
  52. +1
    -0
      webapp/src/layout/components/AppMain/index.vue
  53. +3
    -3
      webapp/src/layout/components/Feedback/index.vue
  54. +13
    -0
      webapp/src/store/modules/dataset.js
  55. +25
    -1
      webapp/src/utils/base.js
  56. +47
    -2
      webapp/src/utils/event.js
  57. +4
    -0
      webapp/src/utils/request.js
  58. +89
    -0
      webapp/src/utils/utils.js
  59. +27
    -0
      webapp/src/utils/validate.js
  60. +89
    -45
      webapp/src/views/algorithm/index.vue
  61. +28
    -28
      webapp/src/views/dashboard/dashboard.vue
  62. +98
    -58
      webapp/src/views/dataset/annotate/index.vue
  63. +1
    -1
      webapp/src/views/dataset/annotate/settingContainer/annotations.vue
  64. +1
    -1
      webapp/src/views/dataset/annotate/settingContainer/enhance.vue
  65. +59
    -18
      webapp/src/views/dataset/annotate/settingContainer/index.vue
  66. +135
    -0
      webapp/src/views/dataset/annotate/settingContainer/labelList/edit.vue
  67. +70
    -9
      webapp/src/views/dataset/annotate/settingContainer/labelList/index.vue
  68. +14
    -2
      webapp/src/views/dataset/annotate/settingContainer/selectLabel.vue
  69. +15
    -5
      webapp/src/views/dataset/annotate/thumbContainer/index.vue
  70. +10
    -2
      webapp/src/views/dataset/annotate/thumbContainer/list.vue
  71. +24
    -25
      webapp/src/views/dataset/annotate/workSpaceContainer/annotationId.js
  72. +34
    -32
      webapp/src/views/dataset/annotate/workSpaceContainer/bbox.js
  73. +430
    -0
      webapp/src/views/dataset/annotate/workSpaceContainer/bboxWrapper.js
  74. +89
    -0
      webapp/src/views/dataset/annotate/workSpaceContainer/brushTip.js
  75. +361
    -67
      webapp/src/views/dataset/annotate/workSpaceContainer/index.vue
  76. +19
    -26
      webapp/src/views/dataset/annotate/workSpaceContainer/score.js
  77. +21
    -26
      webapp/src/views/dataset/annotate/workSpaceContainer/tag.js
  78. +55
    -28
      webapp/src/views/dataset/classify.vue
  79. +1
    -1
      webapp/src/views/dataset/components/picInfoModal/index.vue
  80. +99
    -33
      webapp/src/views/dataset/list/action.js
  81. +622
    -0
      webapp/src/views/dataset/list/create-dataset.vue
  82. +345
    -0
      webapp/src/views/dataset/list/edit-dataset.vue
  83. +79
    -58
      webapp/src/views/dataset/list/import-dataset.vue
  84. +386
    -922
      webapp/src/views/dataset/list/index.vue
  85. +2
    -9
      webapp/src/views/dataset/list/status.js
  86. +259
    -0
      webapp/src/views/dataset/list/upload-datafile.vue
  87. +262
    -0
      webapp/src/views/dataset/style/list.scss
  88. +80
    -13
      webapp/src/views/dataset/util.js
  89. +4
    -4
      webapp/src/views/development/components/CreateDialog.vue
  90. +64
    -18
      webapp/src/views/development/notebook.vue
  91. +117
    -0
      webapp/src/views/labelGroup/dynamicField.vue
  92. +359
    -0
      webapp/src/views/labelGroup/index.vue
  93. +89
    -0
      webapp/src/views/labelGroup/labelGroupAction.js
  94. +654
    -0
      webapp/src/views/labelGroup/labelGroupForm.vue
  95. +48
    -13
      webapp/src/views/model/components/addModelDialog.vue
  96. +69
    -32
      webapp/src/views/model/index.vue
  97. +64
    -20
      webapp/src/views/model/version.vue
  98. +1
    -14
      webapp/src/views/system/dict/dictDetail.vue
  99. +38
    -33
      webapp/src/views/system/user/index.vue
  100. +145
    -32
      webapp/src/views/trainingImage/index.vue

+ 2
- 2
webapp/.env.development View File

@@ -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 = ''

+ 13
- 0
webapp/.env.test View File

@@ -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 = ''

+ 14
- 10
webapp/.eslintrc.js View File

@@ -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": []
}]
}
};

+ 26
- 0
webapp/CHANGELOG.md View File

@@ -0,0 +1,26 @@
## 1.1.0 (2020-10-26)

### Breaking Change

- [数据管理] 导入数据集功能重构。系统提供标准数据集模板,用户按照规范导入数据集文件,实现数据集全功能兼容
- [训练管理] 支持OneFlow、TensorFlow、Pytorch等主流框架的多机多卡模式分布式训练
- [训练管理] 训练时支持将已有模型作为训练入参
- [训练管理] 训练时支持区分训练数据集与验证数据集
- [训练管理] 训练支持延时启动、定时停止功能
- [训练管理] 训练日志、运行日志下载功能优化,避免大文件导致的浏览器卡死

### Features

- [数据管理] 将标签和数据集拆分,引入「标签组」统一管理标签
- [数据管理] 超大数据集操作流程优化。实现超大数据集(40w+文件)的全流程平滑操作
- [数据管理] 数据集图片手动标注优化。支持标注像素级位置、大小调整,支持常见缩放、拖拽、平移等操作
- [数据管理] 数据集状态逻辑优化,代码性能优化等
- [训练管理] 断点续训功能、模型下载功能、模型保存功能支持通过目录树选择模型文件/文件夹
- [训练管理] 文件上传增加进度条展示
- [训练管理] 训练创建页,增加运行命令预览功能;训练详情页,增加算法在线编辑跳转功能
- [训练管理] 镜像管理功能,镜像名称支持自定义;支持镜像的删除、修改等操作
- [训练管理] 增加训练失败异常信息反馈

### Bug Fixs

- [数据管理] 标注详情里面不同分辨率图片标注位置偏移 bug

+ 211
- 0
webapp/LICENSE View File

@@ -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

+ 69
- 3
webapp/README.md View File

@@ -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


+ 6
- 1
webapp/package.json View File

@@ -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"
},


+ 3
- 1
webapp/src/App.vue View File

@@ -16,7 +16,9 @@

<template>
<div id="app">
<router-view />
<keep-alive include="DataSet">
<router-view/>
</keep-alive>
</div>
</template>



+ 9
- 2
webapp/src/api/preparation/annotation.js View File

@@ -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({


+ 8
- 0
webapp/src/api/preparation/datalabel.js View File

@@ -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',


+ 27
- 2
webapp/src/api/preparation/dataset.js View File

@@ -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 };

+ 89
- 0
webapp/src/api/preparation/labelGroup.js View File

@@ -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 };


+ 1
- 1
webapp/src/api/system/harbor.js View File

@@ -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',
});
}


+ 50
- 0
webapp/src/api/system/pod.js View File

@@ -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 },
});
}

+ 19
- 3
webapp/src/api/trainingImage/index.js View File

@@ -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 };

+ 13
- 6
webapp/src/api/trainingJob/job.js View File

@@ -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 };

+ 4
- 0
webapp/src/assets/styles/atomic.scss View File

@@ -214,6 +214,10 @@ img.responsive {
color: red;
}

.success {
color: $successColor;
}

.g3 {
color: #333;
}


+ 6
- 13
webapp/src/assets/styles/common.scss View File

@@ -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;
}

+ 32
- 0
webapp/src/assets/styles/element-ui.scss View File

@@ -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;
}
}
}

+ 21
- 1
webapp/src/assets/styles/index.scss View File

@@ -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;
}
}
}

+ 1
- 0
webapp/src/assets/styles/variables.scss View File

@@ -33,6 +33,7 @@ $imageBg: #f8f8f8;
$borderColorBase: #ebeef5;
$borderColorDark: #c0c4cc;
$black: #001529;
$dark: #323232;

// sidebar
$menuBg: #f3f7ff;


+ 2
- 2
webapp/src/components/BaseModal/index.js View File

@@ -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>
);
};


+ 2
- 0
webapp/src/components/Crud/CD.operation.vue View File

@@ -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"


+ 2
- 0
webapp/src/components/Crud/RR.operation.vue View File

@@ -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` : ''"


+ 133
- 0
webapp/src/components/Drag/drag.vue View File

@@ -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>

+ 19
- 0
webapp/src/components/Drag/index.js View File

@@ -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;

+ 4
- 5
webapp/src/components/Exception/index.vue View File

@@ -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>

+ 1
- 1
webapp/src/components/IconFont/index.js View File

@@ -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' },
});



+ 9
- 5
webapp/src/components/ImageGallery/index.vue View File

@@ -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
- 4
webapp/src/components/InfoSelect/info-select.vue View File

@@ -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,
};
},


+ 0
- 217
webapp/src/components/LabelPopover/index.vue View File

@@ -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">&nbsp;+ {{ 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>

+ 125
- 0
webapp/src/components/LogContainer/index.vue View File

@@ -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>

+ 243
- 0
webapp/src/components/Training/dataSourceSelector.vue View File

@@ -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>

+ 479
- 257
webapp/src/components/Training/jobForm.vue
File diff suppressed because it is too large
View File


+ 135
- 0
webapp/src/components/Training/paramPair.vue View File

@@ -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>

+ 117
- 144
webapp/src/components/Training/runParamForm.vue View File

@@ -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>

+ 7
- 1
webapp/src/components/Training/saveModelDialog.vue View File

@@ -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 => {


+ 4
- 7
webapp/src/components/UploadForm/form.js View File

@@ -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>


+ 1
- 1
webapp/src/components/UploadForm/inline.vue View File

@@ -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)


+ 93
- 0
webapp/src/components/UploadProgress/index.vue View File

@@ -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>

+ 217
- 0
webapp/src/components/svg/brush/Brush.js View File

@@ -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>
);
},
};

+ 227
- 0
webapp/src/components/svg/brush/BrushCorner.js View File

@@ -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>
);
},
};

+ 193
- 0
webapp/src/components/svg/brush/BrushHandle.js View File

@@ -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>
);
},
};

+ 55
- 0
webapp/src/components/svg/brush/BrushSelection.js View File

@@ -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',
}}
/>
);
},
};

+ 19
- 0
webapp/src/components/svg/brush/index.js View File

@@ -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';

+ 42
- 0
webapp/src/components/svg/group/index.js View File

@@ -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>
);
},
};

+ 18
- 0
webapp/src/components/svg/index.js View File

@@ -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';

+ 3
- 3
webapp/src/config/index.js View File

@@ -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,
},


+ 38
- 0
webapp/src/hooks/brush/useBrush.js View File

@@ -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,
});
}


+ 5
- 0
webapp/src/hooks/zoom/useZoom.js View File

@@ -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,


+ 2
- 1
webapp/src/layout/BaseLayout.vue View File

@@ -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;
}


+ 1
- 0
webapp/src/layout/components/AppMain/index.vue View File

@@ -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;
}


+ 3
- 3
webapp/src/layout/components/Feedback/index.vue View File

@@ -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>


+ 13
- 0
webapp/src/store/modules/dataset.js View File

@@ -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 {


+ 25
- 1
webapp/src/utils/base.js View File

@@ -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 };

+ 47
- 2
webapp/src/utils/event.js View File

@@ -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]]);
};


+ 4
- 0
webapp/src/utils/request.js View File

@@ -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)) {


+ 89
- 0
webapp/src/utils/utils.js View File

@@ -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);
}

+ 27
- 0
webapp/src/utils/validate.js View File

@@ -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();
}

+ 89
- 45
webapp/src/views/algorithm/index.vue View File

@@ -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,
}});
},
},
};


+ 28
- 28
webapp/src/views/dashboard/dashboard.vue View File

@@ -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>

+ 98
- 58
webapp/src/views/dataset/annotate/index.vue View File

@@ -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,


+ 1
- 1
webapp/src/views/dataset/annotate/settingContainer/annotations.vue View File

@@ -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}_` : '';


+ 1
- 1
webapp/src/views/dataset/annotate/settingContainer/enhance.vue View File

@@ -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,


+ 59
- 18
webapp/src/views/dataset/annotate/settingContainer/index.vue View File

@@ -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 }} &nbsp;</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,
};


+ 135
- 0
webapp/src/views/dataset/annotate/settingContainer/labelList/edit.vue View File

@@ -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>

webapp/src/views/dataset/annotate/settingContainer/labelList.vue → webapp/src/views/dataset/annotate/settingContainer/labelList/index.vue View File

@@ -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>

+ 14
- 2
webapp/src/views/dataset/annotate/settingContainer/selectLabel.vue View File

@@ -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,
};
},
};


+ 15
- 5
webapp/src/views/dataset/annotate/thumbContainer/index.vue View File

@@ -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,


+ 10
- 2
webapp/src/views/dataset/annotate/thumbContainer/list.vue View File

@@ -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,
};
},
};


+ 24
- 25
webapp/src/views/dataset/annotate/workSpaceContainer/annotationId.js View File

@@ -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>
);
},


+ 34
- 32
webapp/src/views/dataset/annotate/workSpaceContainer/bbox.js View File

@@ -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>


+ 430
- 0
webapp/src/views/dataset/annotate/workSpaceContainer/bboxWrapper.js View File

@@ -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>
);
},
};

+ 89
- 0
webapp/src/views/dataset/annotate/workSpaceContainer/brushTip.js View File

@@ -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>
);
},
};

+ 361
- 67
webapp/src/views/dataset/annotate/workSpaceContainer/index.vue View File

@@ -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>

+ 19
- 26
webapp/src/views/dataset/annotate/workSpaceContainer/score.js View File

@@ -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 (


+ 21
- 26
webapp/src/views/dataset/annotate/workSpaceContainer/tag.js View File

@@ -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>
);
},


+ 55
- 28
webapp/src/views/dataset/classify.vue View File

@@ -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 }} &nbsp;</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),
};
},
},


+ 1
- 1
webapp/src/views/dataset/components/picInfoModal/index.vue View File

@@ -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


+ 99
- 33
webapp/src/views/dataset/list/action.js View File

@@ -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">
导入本地数据集&nbsp;
<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>


+ 622
- 0
webapp/src/views/dataset/list/create-dataset.vue View File

@@ -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">
&nbsp;
<span v-if="labelGroupId === null">&nbsp;&nbsp;标签组</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>

+ 345
- 0
webapp/src/views/dataset/list/edit-dataset.vue View File

@@ -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">
&nbsp;
<span v-if="state.model.labelGroupId === null">&nbsp;&nbsp;标签组</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;">
&nbsp;&nbsp;&nbsp;&nbsp;{{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>

+ 79
- 58
webapp/src/views/dataset/list/import-dataset.vue View File

@@ -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: "",
};
},
},
};

+ 386
- 922
webapp/src/views/dataset/list/index.vue
File diff suppressed because it is too large
View File


+ 2
- 9
webapp/src/views/dataset/list/status.js View File

@@ -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: {


+ 259
- 0
webapp/src/views/dataset/list/upload-datafile.vue View File

@@ -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>

+ 262
- 0
webapp/src/views/dataset/style/list.scss View File

@@ -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);
}
}
}

+ 80
- 13
webapp/src/views/dataset/util.js View File

@@ -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));
};

+ 4
- 4
webapp/src/views/development/components/CreateDialog.vue View File

@@ -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>


+ 64
- 18
webapp/src/views/development/notebook.vue View File

@@ -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();
},


+ 117
- 0
webapp/src/views/labelGroup/dynamicField.vue View File

@@ -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>

+ 359
- 0
webapp/src/views/labelGroup/index.vue View File

@@ -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>

+ 89
- 0
webapp/src/views/labelGroup/labelGroupAction.js View File

@@ -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);
},
};

+ 654
- 0
webapp/src/views/labelGroup/labelGroupForm.vue View File

@@ -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>

+ 48
- 13
webapp/src/views/model/components/addModelDialog.vue View File

@@ -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>

+ 69
- 32
webapp/src/views/model/index.vue View File

@@ -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: '请查看下载文件',


+ 64
- 20
webapp/src/views/model/version.vue View File

@@ -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',
});
}, () => {});
},
() => {},
);
},
},
};


+ 1
- 14
webapp/src/views/system/dict/dictDetail.vue View File

@@ -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' },


+ 38
- 33
webapp/src/views/system/user/index.vue View File

@@ -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>

+ 145
- 32
webapp/src/views/trainingImage/index.vue View File

@@ -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();
},
);
},
},
};


Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save