| @@ -7,7 +7,7 @@ VUE_APP_BASE_API = '' | |||
| VUE_APP_DATA_API = '' | |||
| # minio | |||
| VUE_APP_MINIO_API = '' | |||
| VUE_APP_MINIO_API = '' // 建议使用与当前 web 服务域名的域名 | |||
| # atlas | |||
| # atlas 模型炼知 | |||
| VUE_APP_ATLAS_HOST = '' | |||
| @@ -0,0 +1,13 @@ | |||
| ENV = 'test' | |||
| # 默认BASE URL | |||
| VUE_APP_BASE_API = '' | |||
| # 数据管理 | |||
| VUE_APP_DATA_API = '' | |||
| # minio | |||
| VUE_APP_MINIO_API = '' | |||
| # atlas | |||
| VUE_APP_ATLAS_HOST = '' | |||
| @@ -53,15 +53,19 @@ module.exports = { | |||
| "vue/attribute-hyphenation": "off", | |||
| "vue/comment-directive": "off", | |||
| "vue/prop-name-casing": "off", | |||
| "vue/max-attributes-per-line": [ | |||
| 2, | |||
| { | |||
| singleline: 20, | |||
| multiline: { | |||
| max: 1, | |||
| allowFirstLine: false | |||
| } | |||
| } | |||
| ] | |||
| "vue/max-attributes-per-line": [2, { | |||
| singleline: 20, | |||
| multiline: { | |||
| max: 1, | |||
| allowFirstLine: false | |||
| }} | |||
| ], | |||
| "vue/html-indent": ["error", 2, { | |||
| "attribute": 1, | |||
| "baseIndent": 1, | |||
| "closeBracket": 0, | |||
| "alignAttributesVertically": true, | |||
| "ignores": [] | |||
| }] | |||
| } | |||
| }; | |||
| @@ -0,0 +1,26 @@ | |||
| ## 1.1.0 (2020-10-26) | |||
| ### Breaking Change | |||
| - [数据管理] 导入数据集功能重构。系统提供标准数据集模板,用户按照规范导入数据集文件,实现数据集全功能兼容 | |||
| - [训练管理] 支持OneFlow、TensorFlow、Pytorch等主流框架的多机多卡模式分布式训练 | |||
| - [训练管理] 训练时支持将已有模型作为训练入参 | |||
| - [训练管理] 训练时支持区分训练数据集与验证数据集 | |||
| - [训练管理] 训练支持延时启动、定时停止功能 | |||
| - [训练管理] 训练日志、运行日志下载功能优化,避免大文件导致的浏览器卡死 | |||
| ### Features | |||
| - [数据管理] 将标签和数据集拆分,引入「标签组」统一管理标签 | |||
| - [数据管理] 超大数据集操作流程优化。实现超大数据集(40w+文件)的全流程平滑操作 | |||
| - [数据管理] 数据集图片手动标注优化。支持标注像素级位置、大小调整,支持常见缩放、拖拽、平移等操作 | |||
| - [数据管理] 数据集状态逻辑优化,代码性能优化等 | |||
| - [训练管理] 断点续训功能、模型下载功能、模型保存功能支持通过目录树选择模型文件/文件夹 | |||
| - [训练管理] 文件上传增加进度条展示 | |||
| - [训练管理] 训练创建页,增加运行命令预览功能;训练详情页,增加算法在线编辑跳转功能 | |||
| - [训练管理] 镜像管理功能,镜像名称支持自定义;支持镜像的删除、修改等操作 | |||
| - [训练管理] 增加训练失败异常信息反馈 | |||
| ### Bug Fixs | |||
| - [数据管理] 标注详情里面不同分辨率图片标注位置偏移 bug | |||
| @@ -0,0 +1,211 @@ | |||
| Apache License | |||
| Version 2.0, January 2004 | |||
| http://www.apache.org/licenses/ | |||
| TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION | |||
| 1. Definitions. | |||
| "License" shall mean the terms and conditions for use, reproduction, | |||
| and distribution as defined by Sections 1 through 9 of this document. | |||
| "Licensor" shall mean the copyright owner or entity authorized by | |||
| the copyright owner that is granting the License. | |||
| "Legal Entity" shall mean the union of the acting entity and all | |||
| other entities that control, are controlled by, or are under common | |||
| control with that entity. For the purposes of this definition, | |||
| "control" means (i) the power, direct or indirect, to cause the | |||
| direction or management of such entity, whether by contract or | |||
| otherwise, or (ii) ownership of fifty percent (50%) or more of the | |||
| outstanding shares, or (iii) beneficial ownership of such entity. | |||
| "You" (or "Your") shall mean an individual or Legal Entity | |||
| exercising permissions granted by this License. | |||
| "Source" form shall mean the preferred form for making modifications, | |||
| including but not limited to software source code, documentation | |||
| source, and configuration files. | |||
| "Object" form shall mean any form resulting from mechanical | |||
| transformation or translation of a Source form, including but | |||
| not limited to compiled object code, generated documentation, | |||
| and conversions to other media types. | |||
| "Work" shall mean the work of authorship, whether in Source or | |||
| Object form, made available under the License, as indicated by a | |||
| copyright notice that is included in or attached to the work | |||
| (an example is provided in the Appendix below). | |||
| "Derivative Works" shall mean any work, whether in Source or Object | |||
| form, that is based on (or derived from) the Work and for which the | |||
| editorial revisions, annotations, elaborations, or other modifications | |||
| represent, as a whole, an original work of authorship. For the purposes | |||
| of this License, Derivative Works shall not include works that remain | |||
| separable from, or merely link (or bind by name) to the interfaces of, | |||
| the Work and Derivative Works thereof. | |||
| "Contribution" shall mean any work of authorship, including | |||
| the original version of the Work and any modifications or additions | |||
| to that Work or Derivative Works thereof, that is intentionally | |||
| submitted to Licensor for inclusion in the Work by the copyright owner | |||
| or by an individual or Legal Entity authorized to submit on behalf of | |||
| the copyright owner. For the purposes of this definition, "submitted" | |||
| means any form of electronic, verbal, or written communication sent | |||
| to the Licensor or its representatives, including but not limited to | |||
| communication on electronic mailing lists, source code control systems, | |||
| and issue tracking systems that are managed by, or on behalf of, the | |||
| Licensor for the purpose of discussing and improving the Work, but | |||
| excluding communication that is conspicuously marked or otherwise | |||
| designated in writing by the copyright owner as "Not a Contribution." | |||
| "Contributor" shall mean Licensor and any individual or Legal Entity | |||
| on behalf of whom a Contribution has been received by Licensor and | |||
| subsequently incorporated within the Work. | |||
| 2. Grant of Copyright License. Subject to the terms and conditions of | |||
| this License, each Contributor hereby grants to You a perpetual, | |||
| worldwide, non-exclusive, no-charge, royalty-free, irrevocable | |||
| copyright license to reproduce, prepare Derivative Works of, | |||
| publicly display, publicly perform, sublicense, and distribute the | |||
| Work and such Derivative Works in Source or Object form. | |||
| 3. Grant of Patent License. Subject to the terms and conditions of | |||
| this License, each Contributor hereby grants to You a perpetual, | |||
| worldwide, non-exclusive, no-charge, royalty-free, irrevocable | |||
| (except as stated in this section) patent license to make, have made, | |||
| use, offer to sell, sell, import, and otherwise transfer the Work, | |||
| where such license applies only to those patent claims licensable | |||
| by such Contributor that are necessarily infringed by their | |||
| Contribution(s) alone or by combination of their Contribution(s) | |||
| with the Work to which such Contribution(s) was submitted. If You | |||
| institute patent litigation against any entity (including a | |||
| cross-claim or counterclaim in a lawsuit) alleging that the Work | |||
| or a Contribution incorporated within the Work constitutes direct | |||
| or contributory patent infringement, then any patent licenses | |||
| granted to You under this License for that Work shall terminate | |||
| as of the date such litigation is filed. | |||
| 4. Redistribution. You may reproduce and distribute copies of the | |||
| Work or Derivative Works thereof in any medium, with or without | |||
| modifications, and in Source or Object form, provided that You | |||
| meet the following conditions: | |||
| (a) You must give any other recipients of the Work or | |||
| Derivative Works a copy of this License; and | |||
| (b) You must cause any modified files to carry prominent notices | |||
| stating that You changed the files; and | |||
| (c) You must retain, in the Source form of any Derivative Works | |||
| that You distribute, all copyright, patent, trademark, and | |||
| attribution notices from the Source form of the Work, | |||
| excluding those notices that do not pertain to any part of | |||
| the Derivative Works; and | |||
| (d) If the Work includes a "NOTICE" text file as part of its | |||
| distribution, then any Derivative Works that You distribute must | |||
| include a readable copy of the attribution notices contained | |||
| within such NOTICE file, excluding those notices that do not | |||
| pertain to any part of the Derivative Works, in at least one | |||
| of the following places: within a NOTICE text file distributed | |||
| as part of the Derivative Works; within the Source form or | |||
| documentation, if provided along with the Derivative Works; or, | |||
| within a display generated by the Derivative Works, if and | |||
| wherever such third-party notices normally appear. The contents | |||
| of the NOTICE file are for informational purposes only and | |||
| do not modify the License. You may add Your own attribution | |||
| notices within Derivative Works that You distribute, alongside | |||
| or as an addendum to the NOTICE text from the Work, provided | |||
| that such additional attribution notices cannot be construed | |||
| as modifying the License. | |||
| You may add Your own copyright statement to Your modifications and | |||
| may provide additional or different license terms and conditions | |||
| for use, reproduction, or distribution of Your modifications, or | |||
| for any such Derivative Works as a whole, provided Your use, | |||
| reproduction, and distribution of the Work otherwise complies with | |||
| the conditions stated in this License. | |||
| 5. Submission of Contributions. Unless You explicitly state otherwise, | |||
| any Contribution intentionally submitted for inclusion in the Work | |||
| by You to the Licensor shall be under the terms and conditions of | |||
| this License, without any additional terms or conditions. | |||
| Notwithstanding the above, nothing herein shall supersede or modify | |||
| the terms of any separate license agreement you may have executed | |||
| with Licensor regarding such Contributions. | |||
| 6. Trademarks. This License does not grant permission to use the trade | |||
| names, trademarks, service marks, or product names of the Licensor, | |||
| except as required for reasonable and customary use in describing the | |||
| origin of the Work and reproducing the content of the NOTICE file. | |||
| 7. Disclaimer of Warranty. Unless required by applicable law or | |||
| agreed to in writing, Licensor provides the Work (and each | |||
| Contributor provides its Contributions) on an "AS IS" BASIS, | |||
| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or | |||
| implied, including, without limitation, any warranties or conditions | |||
| of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A | |||
| PARTICULAR PURPOSE. You are solely responsible for determining the | |||
| appropriateness of using or redistributing the Work and assume any | |||
| risks associated with Your exercise of permissions under this License. | |||
| 8. Limitation of Liability. In no event and under no legal theory, | |||
| whether in tort (including negligence), contract, or otherwise, | |||
| unless required by applicable law (such as deliberate and grossly | |||
| negligent acts) or agreed to in writing, shall any Contributor be | |||
| liable to You for damages, including any direct, indirect, special, | |||
| incidental, or consequential damages of any character arising as a | |||
| result of this License or out of the use or inability to use the | |||
| Work (including but not limited to damages for loss of goodwill, | |||
| work stoppage, computer failure or malfunction, or any and all | |||
| other commercial damages or losses), even if such Contributor | |||
| has been advised of the possibility of such damages. | |||
| 9. Accepting Warranty or Additional Liability. While redistributing | |||
| the Work or Derivative Works thereof, You may choose to offer, | |||
| and charge a fee for, acceptance of support, warranty, indemnity, | |||
| or other liability obligations and/or rights consistent with this | |||
| License. However, in accepting such obligations, You may act only | |||
| on Your own behalf and on Your sole responsibility, not on behalf | |||
| of any other Contributor, and only if You agree to indemnify, | |||
| defend, and hold each Contributor harmless for any liability | |||
| incurred by, or claims asserted against, such Contributor by reason | |||
| of your accepting any such warranty or additional liability. | |||
| END OF TERMS AND CONDITIONS | |||
| APPENDIX: How to apply the Apache License to your work. | |||
| To apply the Apache License to your work, attach the following | |||
| boilerplate notice, with the fields enclosed by brackets "[]" | |||
| replaced with your own identifying information. (Don't include | |||
| the brackets!) The text should be enclosed in the appropriate | |||
| comment syntax for the file format. We also recommend that a | |||
| file or class name and description of purpose be included on the | |||
| same "printed page" as the copyright notice for easier | |||
| identification within third-party archives. | |||
| Copyright [yyyy] [name of copyright owner] | |||
| Licensed under the Apache License, Version 2.0 (the "License"); | |||
| you may not use this file except in compliance with the License. | |||
| You may obtain a copy of the License at | |||
| http://www.apache.org/licenses/LICENSE-2.0 | |||
| Unless required by applicable law or agreed to in writing, software | |||
| distributed under the License is distributed on an "AS IS" BASIS, | |||
| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| See the License for the specific language governing permissions and | |||
| limitations under the License. | |||
| Other dependencies and licenses: | |||
| ---------------------------------------------------------------------------------------- | |||
| Open Source Software Licensed Under the Apache License, Version 2.0: | |||
| The below software in this distribution may have been modified. | |||
| ---------------------------------------------------------------------------------------- | |||
| 1. EL-ADMIN | |||
| Copyright 2019-2020 Zheng Jie | |||
| @@ -1,10 +1,76 @@ | |||
| # 一站式开发平台-前端 | |||
| # 之江天枢-前端 | |||
| **之江天枢一站式人工智能开源平台**(简称:**之江天枢**),包括海量数据处理、交互式模型构建(包含Notebook和模型可视化)、AI模型高效训练。多维度产品形态满足从开发者到大型企业的不同需求,将提升人工智能技术的研发效率、扩大算法模型的应用范围,进一步构建人工智能生态“朋友圈”。 | |||
| ## 特性 | |||
| * 一站式开发 | |||
| * 集成先进算法 | |||
| * 灵活易用 | |||
| * 性能优越 | |||
| ## 预览 | |||
|  | |||
| ## 源码部署 | |||
| ### 1. 下载源码 | |||
| ``` bash | |||
| git clone https://codeup.teambition.com/zhejianglab/dubhe-web.git | |||
| # 进入根目录 | |||
| cd dubhe-web | |||
| ``` | |||
| ### 2. 配置 | |||
| 根据需要修改如下配置文件 | |||
| ``` | |||
| config/index.js | |||
| settings.js | |||
| .env.production | |||
| ``` | |||
| ### 3. 构建 | |||
| ``` bash | |||
| # 安装项目依赖 | |||
| npm install | |||
| # 构建生产环境 | |||
| npm run build:prod | |||
| ``` | |||
| ### 4. 部署 | |||
| - 构建完成后会在根目录生成 dist 文件夹,并将该文件夹上传至服务器; | |||
| - 在服务器 nginx.conf 文件中添加如下配置; | |||
| ``` nginx | |||
| server { | |||
| listen 80; # 端口 | |||
| server_name localhost; # 域名/外网IP | |||
| location / { | |||
| root /home/wwwroot/dubhe-web/dist; # dist 文件夹根目录 | |||
| index index.html; | |||
| try_files $uri $uri/ /index.html; | |||
| } | |||
| } | |||
| ``` | |||
| - 保存 `nginx.conf` 并重启 Nginx 使之生效。 | |||
| ## 本地开发 | |||
| ``` bash | |||
| # 进入前端项目根目录 | |||
| cd webapp | |||
| # 下载源码 | |||
| git clone https://codeup.teambition.com/zhejianglab/dubhe-web.git | |||
| # 进入项目根目录 | |||
| cd dubhe-web | |||
| # 安装依赖 | |||
| npm install | |||
| @@ -1,6 +1,6 @@ | |||
| { | |||
| "name": "dubhe-web", | |||
| "version": "1.0.0", | |||
| "version": "1.1.0", | |||
| "description": "之江天枢人工智能开源平台", | |||
| "author": "zhejianglab", | |||
| "keywords": [ | |||
| @@ -13,6 +13,8 @@ | |||
| "scripts": { | |||
| "dev": "vue-cli-service serve --open", | |||
| "build:prod": "vue-cli-service build", | |||
| "build:test": "vue-cli-service build --mode test", | |||
| "build:dev": "vue-cli-service build --mode development", | |||
| "lint": "eslint --ext .js,.vue src", | |||
| "fix": "eslint --fix --ext .js,.vue src", | |||
| "lint:style": "stylelint src/**/*.{html,vue,css,sass,scss}", | |||
| @@ -54,6 +56,7 @@ | |||
| "filereader-stream": "^2.0.0", | |||
| "jquery": "^3.5.1", | |||
| "jquery-contextmenu": "^2.9.1", | |||
| "js-beautify": "^1.13.0", | |||
| "js-cookie": "2.2.0", | |||
| "jsencrypt": "^3.0.0-rc.1", | |||
| "json2csv": "^5.0.1", | |||
| @@ -72,7 +75,9 @@ | |||
| "v-hotkey": "^0.8.0", | |||
| "vee-validate": "^3.3.0", | |||
| "vue": "2.6.10", | |||
| "vue-copy-to-clipboard": "^1.0.3", | |||
| "vue-prism-component": "^1.2.0", | |||
| "vue-prism-editor": "^1.2.2", | |||
| "vue-router": "^3.0.2", | |||
| "vuex": "3.1.0" | |||
| }, | |||
| @@ -16,7 +16,9 @@ | |||
| <template> | |||
| <div id="app"> | |||
| <router-view /> | |||
| <keep-alive include="DataSet"> | |||
| <router-view/> | |||
| </keep-alive> | |||
| </div> | |||
| </template> | |||
| @@ -16,9 +16,9 @@ | |||
| import request from '@/utils/request'; | |||
| export function batchFinishAnnotation(data) { | |||
| export function batchFinishAnnotation(data, datasetId) { | |||
| return request({ | |||
| url: 'api/data/datasets/files/annotations', | |||
| url: `api/data/datasets/files/${datasetId}/annotations`, | |||
| method: 'post', | |||
| data, | |||
| }); | |||
| @@ -33,6 +33,13 @@ export function delAnnotation(id) { | |||
| }); | |||
| } | |||
| export function track(id) { | |||
| return request({ | |||
| url: `api/data/datasets/files/annotations/auto/track/${id}`, | |||
| method: 'get', | |||
| }); | |||
| } | |||
| export function autoAnnotate(ids) { | |||
| const data = { datasetIds: ids }; | |||
| return request({ | |||
| @@ -31,6 +31,14 @@ export function createLabel(id, label) { | |||
| }); | |||
| } | |||
| export function editLabel(id, label) { | |||
| return request({ | |||
| url: `api/data/datasets/labels/${id}`, | |||
| method: 'put', | |||
| data: label, | |||
| }); | |||
| } | |||
| export function getAutoLabels() { | |||
| return request({ | |||
| url: 'api/data/datasets/labels/auto', | |||
| @@ -32,6 +32,15 @@ export function detail(id) { | |||
| }); | |||
| } | |||
| // 数据集状态(导入数据集轮询使用) | |||
| export function queryDatasetStatus(ids) { | |||
| return request({ | |||
| url: `/api/data/datasets/status`, | |||
| method: 'get', | |||
| params: { datasetIds: ids }, | |||
| }); | |||
| } | |||
| export function add(data) { | |||
| return request({ | |||
| url: 'api/data/datasets', | |||
| @@ -57,6 +66,13 @@ export function editDataset(data) { | |||
| }); | |||
| } | |||
| export function topDataset(data) { | |||
| return request({ | |||
| url: `api/data/datasets/${data.id}/top`, | |||
| method: 'get', | |||
| }); | |||
| } | |||
| // 导入自定义数据集 | |||
| export function addCustomDataset(data) { | |||
| return request({ | |||
| @@ -153,9 +169,9 @@ export function postDataEnhance(datasetId, types = []) { | |||
| } | |||
| // 指定原始文件,获取增强文件列表 | |||
| export function getEnhanceFileList(fileId) { | |||
| export function getEnhanceFileList(datasetId, fileId) { | |||
| return request({ | |||
| url: `api/data/datasets/${fileId}/enhanceFileList`, | |||
| url: `api/data/datasets/${datasetId}/${fileId}/enhanceFileList`, | |||
| }); | |||
| } | |||
| @@ -173,4 +189,13 @@ export function queryDatasetsCount() { | |||
| }); | |||
| } | |||
| // 查询数据集状态 | |||
| export function queryDatasetsProgress(params) { | |||
| return request({ | |||
| url: `/api/data/datasets/progress`, | |||
| method: 'get', | |||
| params, | |||
| }); | |||
| } | |||
| export default { list, add, del }; | |||
| @@ -0,0 +1,89 @@ | |||
| /** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||
| * | |||
| * Licensed under the Apache License, Version 2.0 (the "License"); | |||
| * you may not use this file except in compliance with the License. | |||
| * You may obtain a copy of the License at | |||
| * | |||
| * http://www.apache.org/licenses/LICENSE-2.0 | |||
| * | |||
| * Unless required by applicable law or agreed to in writing, software | |||
| * distributed under the License is distributed on an "AS IS" BASIS, | |||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| * See the License for the specific language governing permissions and | |||
| * limitations under the License. | |||
| * ============================================================= | |||
| */ | |||
| import request from '@/utils/request'; | |||
| // 创建标签组 | |||
| export function add(data) { | |||
| return request({ | |||
| url: `/api/data/labelGroup`, | |||
| method: 'post', | |||
| data, | |||
| }); | |||
| } | |||
| // 编辑标签组 | |||
| export function edit(data) { | |||
| return request({ | |||
| url: `/api/data/labelGroup/${data.id}`, | |||
| method: 'put', | |||
| data, | |||
| }); | |||
| } | |||
| // 删除标签组 | |||
| export function del(ids) { | |||
| return request({ | |||
| url: `/api/data/labelGroup`, | |||
| method: 'delete', | |||
| data: {ids}, | |||
| }); | |||
| } | |||
| // 复制标签组 | |||
| export function copy(data) { | |||
| return request({ | |||
| url: `/api/data/labelGroup/copy`, | |||
| method: 'post', | |||
| data, | |||
| }); | |||
| } | |||
| // 标签组列表分页查询 | |||
| export function list(params) { | |||
| return request({ | |||
| url: `/api/data/labelGroup/query`, | |||
| method: 'get', | |||
| params, | |||
| }); | |||
| } | |||
| // 标签组列表的简况查询 用于详情页选择标签组列举 | |||
| export function getLabelGroupList(params) { | |||
| return request({ | |||
| url: `/api/data/labelGroup/getList/${params}`, | |||
| method: 'get', | |||
| }); | |||
| } | |||
| // 获取标签组详情 | |||
| export function getLabelGroupDetail(id) { | |||
| return request({ | |||
| url: `/api/data/labelGroup/${id}`, | |||
| method: 'get', | |||
| }); | |||
| } | |||
| export function importLabelGroup(form) { | |||
| return request.post(`api/data/labelGroup/import`, form, { | |||
| headers: { | |||
| 'Content-Type': 'multipart/form-data', | |||
| }, | |||
| }); | |||
| } | |||
| export default { list, add, del, edit }; | |||
| @@ -18,7 +18,7 @@ import request from '@/utils/request'; | |||
| export function harborProjectNames() { | |||
| return request({ | |||
| url: `api/v1/ptImage/project`, | |||
| url: `api/v1/ptImage/imageNameList`, | |||
| method: 'get', | |||
| }); | |||
| } | |||
| @@ -0,0 +1,50 @@ | |||
| /** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||
| * | |||
| * Licensed under the Apache License, Version 2.0 (the "License"); | |||
| * you may not use this file except in compliance with the License. | |||
| * You may obtain a copy of the License at | |||
| * | |||
| * http://www.apache.org/licenses/LICENSE-2.0 | |||
| * | |||
| * Unless required by applicable law or agreed to in writing, software | |||
| * distributed under the License is distributed on an "AS IS" BASIS, | |||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| * See the License for the specific language governing permissions and | |||
| * limitations under the License. | |||
| * ============================================================= | |||
| */ | |||
| import request from '@/utils/request'; | |||
| export function getPodLog(params) { | |||
| return request({ | |||
| url: 'api/v1/pod/log', | |||
| method: 'get', | |||
| params, | |||
| }); | |||
| } | |||
| export function downloadPodLog(params) { | |||
| return request({ | |||
| url: 'api/v1/pod/log/download', | |||
| method: 'get', | |||
| params, | |||
| }); | |||
| } | |||
| export function batchDownloadPodLog(data) { | |||
| return request({ | |||
| url: 'api/v1/pod/log/download', | |||
| method: 'post', | |||
| responseType: 'blob', | |||
| data, | |||
| }); | |||
| } | |||
| export function countPodLogs(podVOList) { | |||
| return request({ | |||
| url: 'api/v1/pod/log/count', | |||
| method: 'post', | |||
| data: { podVOList }, | |||
| }); | |||
| } | |||
| @@ -32,11 +32,27 @@ export function add(data) { | |||
| }); | |||
| } | |||
| export function project() { | |||
| export function edit(data) { | |||
| return request({ | |||
| url: 'api/v1/ptImage/project', | |||
| url: 'api/v1/ptImage', | |||
| method: 'put', | |||
| data, | |||
| }); | |||
| } | |||
| export function del(ids) { | |||
| return request({ | |||
| url: 'api/v1/ptImage', | |||
| method: 'delete', | |||
| data: ids, | |||
| }); | |||
| } | |||
| export function imageNameList() { | |||
| return request({ | |||
| url: 'api/v1/ptImage/imageNameList', | |||
| method: 'get', | |||
| }); | |||
| } | |||
| export default { list, add }; | |||
| export default { list, add, edit }; | |||
| @@ -72,17 +72,17 @@ export function getJobList(params) { | |||
| }); | |||
| } | |||
| export function getTrainLog(params) { | |||
| export function getJobDetail(jobId) { | |||
| return request({ | |||
| url: `api/v1/trainLog`, | |||
| url: `api/v1/trainJob/jobDetail`, | |||
| method: 'get', | |||
| params, | |||
| params: { id: jobId }, | |||
| }); | |||
| } | |||
| export function downloadTrainLog(params) { | |||
| export function getTrainLog(params) { | |||
| return request({ | |||
| url: `api/v1/trainLog/download`, | |||
| url: `api/v1/trainLog`, | |||
| method: 'get', | |||
| params, | |||
| }); | |||
| @@ -109,4 +109,11 @@ export function getGarafanaInfo(jobId) { | |||
| }); | |||
| } | |||
| export default { list, add, edit, del, getGarafanaInfo }; | |||
| export function getPods(jobId) { | |||
| return request({ | |||
| url: `api/v1/trainLog/pod/${jobId}`, | |||
| method: 'get', | |||
| }); | |||
| } | |||
| export default { list, add, edit, del }; | |||
| @@ -214,6 +214,10 @@ img.responsive { | |||
| color: red; | |||
| } | |||
| .success { | |||
| color: $successColor; | |||
| } | |||
| .g3 { | |||
| color: #333; | |||
| } | |||
| @@ -126,7 +126,6 @@ | |||
| } | |||
| .text { | |||
| height: 19px; | |||
| font-size: 14px; | |||
| line-height: 19px; | |||
| color: rgba(68, 68, 68, 1); | |||
| @@ -143,18 +142,6 @@ | |||
| } | |||
| } | |||
| } | |||
| .fr { | |||
| margin-right: 5%; | |||
| } | |||
| .iframe { | |||
| width: 90%; | |||
| height: 500px; | |||
| margin: 40px 5%; | |||
| overflow: auto; | |||
| border: #ccc solid 1px; | |||
| } | |||
| } | |||
| .eltabs-inlineblock.el-tabs { | |||
| @@ -242,3 +229,9 @@ | |||
| color: $primaryHoverColor; | |||
| } | |||
| } | |||
| .tree-container { | |||
| height: 500px; | |||
| margin-top: 20px; | |||
| overflow-y: scroll; | |||
| } | |||
| @@ -44,6 +44,14 @@ | |||
| } | |||
| } | |||
| // el-radio border | |||
| .el-radio.is-bordered { | |||
| &.is-checked, | |||
| &:hover { | |||
| border-color: $primaryBorderColor; | |||
| } | |||
| } | |||
| // radio-button | |||
| .el-radio-button__inner { | |||
| padding: 8px 25px; | |||
| @@ -298,3 +306,27 @@ | |||
| .el-tooltip__popper { | |||
| max-width: 50%; | |||
| } | |||
| .info-alert.is-light { | |||
| margin-bottom: 20px; | |||
| background-color: $primaryBg; | |||
| .el-alert__content { | |||
| width: 100%; | |||
| } | |||
| .el-alert__icon { | |||
| color: $primaryColor; | |||
| } | |||
| .slot-content { | |||
| display: flex; | |||
| justify-content: space-between; | |||
| width: 100%; | |||
| color: #000; | |||
| a { | |||
| color: $primaryColor; | |||
| } | |||
| } | |||
| } | |||
| @@ -69,7 +69,6 @@ ol { | |||
| a, | |||
| a:focus, | |||
| a:hover { | |||
| color: inherit; | |||
| text-decoration: none; | |||
| cursor: pointer; | |||
| } | |||
| @@ -87,6 +86,10 @@ ol li { | |||
| color: $infoColor; | |||
| } | |||
| .fontBold { | |||
| font-weight: bold; | |||
| } | |||
| .primary-bg { | |||
| background-color: $primaryColor; | |||
| @@ -100,3 +103,20 @@ p.error-message { | |||
| font-size: 12px; | |||
| color: $red; | |||
| } | |||
| // 新手导引 | |||
| .v-tour { | |||
| div.v-step { | |||
| color: #2e4fde; | |||
| background: #d8dfff; | |||
| } | |||
| button.v-step__button { | |||
| color: #2e4fde; | |||
| border-color: #2e4fde; | |||
| &:hover { | |||
| background: #f3f7ff; | |||
| } | |||
| } | |||
| } | |||
| @@ -33,6 +33,7 @@ $imageBg: #f8f8f8; | |||
| $borderColorBase: #ebeef5; | |||
| $borderColorDark: #c0c4cc; | |||
| $black: #001529; | |||
| $dark: #323232; | |||
| // sidebar | |||
| $menuBg: #f3f7ff; | |||
| @@ -97,10 +97,10 @@ const BaseModal = { | |||
| return ( | |||
| <div class='modal-footer'> | |||
| { this.showCancel && ( | |||
| <el-button onClick={this.handleCancel}>{this.cancelText}</el-button> | |||
| <el-button id="cancel" onClick={this.handleCancel}>{this.cancelText}</el-button> | |||
| ) | |||
| } | |||
| <el-button type='primary' disabled={this.disabled} onClick={this.handleOk} loading={this.loading}>{this.okText}</el-button> | |||
| <el-button id="ok" type='primary' disabled={this.disabled} onClick={this.handleOk} loading={this.loading}>{this.okText}</el-button> | |||
| </div> | |||
| ); | |||
| }; | |||
| @@ -19,6 +19,7 @@ | |||
| <span class="cd-opts-left"> | |||
| <el-button | |||
| v-if="crud.optShow.add" | |||
| id="toAdd" | |||
| v-bind="addProps" | |||
| class="filter-item" | |||
| type="primary" | |||
| @@ -31,6 +32,7 @@ | |||
| <slot name="left" /> | |||
| <el-button | |||
| v-if="crud.optShow.del" | |||
| id="toDelete" | |||
| slot="reference" | |||
| class="filter-item" | |||
| type="danger" | |||
| @@ -18,6 +18,7 @@ | |||
| <span> | |||
| <el-button | |||
| v-if="crud.optShow.reset" | |||
| id="toReset" | |||
| class="filter-item" | |||
| :icon="crud.props.resetIconShow ? `el-icon-refresh-left` : ''" | |||
| @click="resetQuery" | |||
| @@ -25,6 +26,7 @@ | |||
| {{ crud.props.optText.reset }} | |||
| </el-button> | |||
| <el-button | |||
| id="toQuery" | |||
| class="filter-item" | |||
| type="primary" | |||
| :icon="crud.props.searchIconShow ? `el-icon-search` : ''" | |||
| @@ -0,0 +1,133 @@ | |||
| /** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||
| * | |||
| * Licensed under the Apache License, Version 2.0 (the "License"); | |||
| * you may not use this file except in compliance with the License. | |||
| * You may obtain a copy of the License at | |||
| * | |||
| * http://www.apache.org/licenses/LICENSE-2.0 | |||
| * | |||
| * Unless required by applicable law or agreed to in writing, software | |||
| * distributed under the License is distributed on an "AS IS" BASIS, | |||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| * See the License for the specific language governing permissions and | |||
| * limitations under the License. | |||
| * ============================================================= | |||
| */ | |||
| <script> | |||
| import { reactive } from '@vue/composition-api'; | |||
| import { findAncestorSvg } from '@/utils'; | |||
| export default { | |||
| name: 'Drag', | |||
| props: { | |||
| width: Number, | |||
| height: Number, | |||
| resetOnStart: { | |||
| type: Boolean, | |||
| default: false, | |||
| }, | |||
| onDragStart: Function, | |||
| onDragMove: Function, | |||
| onDragEnd: Function, | |||
| }, | |||
| setup(props) { | |||
| const { resetOnStart, onDragStart, onDragMove, onDragEnd } = props; | |||
| const state = reactive({ | |||
| x: undefined, | |||
| y: undefined, | |||
| dx: 0, | |||
| dy: 0, | |||
| isDragging: false, // 鼠标按下 | |||
| isMoving: true, // 鼠标移动 | |||
| }); | |||
| function getPoint(event) { | |||
| // 容器尺寸 | |||
| const bound = findAncestorSvg(event).getBoundingClientRect(); | |||
| const { clientX, clientY } = event; | |||
| return { | |||
| x: clientX - bound.left, | |||
| y: clientY - bound.top, | |||
| }; | |||
| } | |||
| function dragStart(event) { | |||
| const point = getPoint(event); | |||
| const nextState = { | |||
| isDragging: true, | |||
| isMoving: false, | |||
| dx: resetOnStart ? 0 : state.dx, | |||
| dy: resetOnStart ? 0 : state.dy, | |||
| x: resetOnStart ? point.x : -state.dx + point.x, | |||
| y: resetOnStart ? point.y : -state.dy + point.y, | |||
| }; | |||
| Object.assign(state, nextState); | |||
| if (typeof onDragStart === 'function') onDragStart(nextState, event); | |||
| } | |||
| function dragMove(event) { | |||
| if (!state.isDragging) return; | |||
| const point = getPoint(event); | |||
| // 避免无效移动 | |||
| if(Math.abs(point.x - state.x) < 2 && Math.abs(point.y - state.y) < 2) return; | |||
| const nextState = { | |||
| isDragging: true, | |||
| isMoving: true, | |||
| dx: point.x - state.x, | |||
| dy: point.y - state.y, | |||
| }; | |||
| Object.assign(state, nextState); | |||
| if (typeof onDragMove === 'function') onDragMove(state, event); | |||
| } | |||
| function dragEnd(event) { | |||
| const nextState = { | |||
| isDragging: false, | |||
| isMoving: false, | |||
| }; | |||
| const prevState = { ...state }; | |||
| Object.assign(state, nextState); | |||
| // 传递 prevState | |||
| if (typeof onDragEnd === 'function') onDragEnd(state, event, { | |||
| prevState, | |||
| }); | |||
| } | |||
| return { | |||
| state, | |||
| dragStart, | |||
| dragMove, | |||
| dragEnd, | |||
| }; | |||
| }, | |||
| render() { | |||
| const children = this.$scopedSlots.default; | |||
| return ( | |||
| <g> | |||
| {this.state.isDragging && | |||
| ( | |||
| <rect | |||
| width={this.width} | |||
| height={this.height} | |||
| onMousemove={this.dragMove} | |||
| onMouseup={this.dragEnd} | |||
| fill='transparent' | |||
| /> | |||
| )} | |||
| { typeof children === 'function' && ( | |||
| children({ | |||
| state: this.state, | |||
| dragStart: this.dragStart, | |||
| dragMove: this.dragMove, | |||
| dragEnd: this.dragEnd, | |||
| }) | |||
| ) } | |||
| </g> | |||
| ); | |||
| }, | |||
| }; | |||
| </script> | |||
| @@ -0,0 +1,19 @@ | |||
| /** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||
| * | |||
| * Licensed under the Apache License, Version 2.0 (the "License"); | |||
| * you may not use this file except in compliance with the License. | |||
| * You may obtain a copy of the License at | |||
| * | |||
| * http://www.apache.org/licenses/LICENSE-2.0 | |||
| * | |||
| * Unless required by applicable law or agreed to in writing, software | |||
| * distributed under the License is distributed on an "AS IS" BASIS, | |||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| * See the License for the specific language governing permissions and | |||
| * limitations under the License. | |||
| * ============================================================= | |||
| */ | |||
| import Drag from './drag'; | |||
| export default Drag; | |||
| @@ -38,19 +38,18 @@ export default { | |||
| }; | |||
| </script> | |||
| <style lang="scss"> | |||
| @import '@/assets/styles/variables.scss'; | |||
| .exception { | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| height: 100%; | |||
| margin: 0 auto; | |||
| color: $infoColor; | |||
| text-align: center; | |||
| .imgBlock { | |||
| font-size: 48px; | |||
| } | |||
| .content { | |||
| margin-top: 10px; | |||
| } | |||
| } | |||
| </style> | |||
| @@ -26,7 +26,7 @@ | |||
| import create from './iconfont'; | |||
| const IconFont = create({ | |||
| scriptUrl: '//at.alicdn.com/t/font_1756495_bycxbb6pz6s.js', | |||
| scriptUrl: '//at.alicdn.com/t/font_1756495_k4j524i5vng.js', | |||
| extraIconProps: { class: 'svg-icon' }, | |||
| }); | |||
| @@ -53,7 +53,7 @@ | |||
| :class="rootClass + '__img'" | |||
| @click="onClickImg(dataImage)" | |||
| > | |||
| <el-tag v-if="imageTagVisible && dataImage.status > 1" :hit="false" class="image-tag" :color="imageLabelTag[dataImage.id]['color']">{{ imageLabelTag[dataImage.id]['text'] }}</el-tag> | |||
| <el-tag v-if="imageTagVisible && statusCodeMap[dataImage.status] !== 'UNANNOTATED'" :hit="false" class="image-tag" :color="imageLabelTag[dataImage.id]['color']">{{ imageLabelTag[dataImage.id]['text'] }}</el-tag> | |||
| <el-checkbox v-show="showOption(dataImage.id)" :value="selectedMap[dataImage.id]" class="image-checkbox" @change="checked => handleCheck(dataImage, checked)" /> | |||
| <div v-show="showOption(dataImage.id)" :title="dataImage.name" class="img-name-row"> | |||
| <div class="img-name">{{ basename(dataImage.url) }}</div> | |||
| @@ -74,6 +74,7 @@ | |||
| <script> | |||
| import Vue from 'vue'; | |||
| import { bucketHost } from '@/utils/minIO'; | |||
| import { fileCodeMap, findKey, statusCodeMap } from '@/views/dataset/util'; | |||
| // eslint-disable-next-line import/no-extraneous-dependencies | |||
| const path = require('path'); | |||
| @@ -118,10 +119,13 @@ export default { | |||
| multipleSelected: [], | |||
| imageTagVisible: true, | |||
| imgStatusMap: { | |||
| 2: { 'text': '自动', 'color': '#468CFF' }, | |||
| 3: { 'text': '人工', 'color': '#FF9943' }, | |||
| 'UNRECOGNIZED': {'text': '未识别', 'color': '#FFFFFF'}, | |||
| 'UNANNOTATED': {'text': '未标注', 'color': '#FFFFFF'}, | |||
| 'AUTO_ANNOTATED': { 'text': '自动', 'color': '#468CFF' }, | |||
| 'MANUAL_ANNOTATED': { 'text': '人工', 'color': '#FF9943' }, | |||
| }, | |||
| hoverImg: null, | |||
| statusCodeMap, | |||
| }; | |||
| }, | |||
| computed: { | |||
| @@ -139,9 +143,9 @@ export default { | |||
| imageLabelTag() { | |||
| const labelTag = {}; | |||
| this.dataImages.forEach((item) => { | |||
| const statusInfo = this.imgStatusMap[item.status]; | |||
| const statusInfo = this.imgStatusMap[findKey(item.status, fileCodeMap)]; | |||
| const annotation = JSON.parse(item.annotation); | |||
| let categoryName = '无标注'; | |||
| let categoryName = '未识别'; | |||
| let tagColor = '#db2a2a'; | |||
| if (statusInfo && (annotation instanceof Array) && annotation.length > 0) { | |||
| const categoryId = annotation[0].category_id; | |||
| @@ -17,11 +17,12 @@ | |||
| <template> | |||
| <div class="info-data-select"> | |||
| <el-select | |||
| :style="{ width: '100%' }" | |||
| ref="selectRef" | |||
| :style="{ width: selectEleWidth }" | |||
| clearable | |||
| v-bind="attrs" | |||
| :value="state.sValue" | |||
| @change="handleChange" | |||
| v-on="listeners" | |||
| > | |||
| <el-option | |||
| v-for="item in state.list" | |||
| @@ -36,7 +37,7 @@ | |||
| </template> | |||
| <script> | |||
| import { isNil } from 'lodash'; | |||
| import { reactive, watch, computed } from '@vue/composition-api'; | |||
| import { reactive, watch, computed, ref } from '@vue/composition-api'; | |||
| export default { | |||
| name: 'InfoSelect', | |||
| @@ -47,6 +48,7 @@ export default { | |||
| }, | |||
| props: { | |||
| request: Function, | |||
| width: String, | |||
| value: { | |||
| type: [String, Number, Array], | |||
| }, | |||
| @@ -62,9 +64,12 @@ export default { | |||
| type: Array, | |||
| default: () => ([]), | |||
| }, | |||
| innerRef: Function, | |||
| }, | |||
| setup(props, ctx) { | |||
| const { labelKey, valueKey } = props; | |||
| const { labelKey, valueKey, innerRef } = props; | |||
| const selectRef = !isNil(innerRef) ? innerRef() : ref(null); | |||
| const buildOptions = (list) => list.map(d => ({ | |||
| ...d, | |||
| @@ -98,10 +103,18 @@ export default { | |||
| }); | |||
| const attrs = computed(() => ctx.attrs); | |||
| const selectEleWidth =computed(() => props.width || '100%'); | |||
| const listeners = computed(() => ({ | |||
| ...ctx.listeners, | |||
| change: handleChange, | |||
| })); | |||
| return { | |||
| state, | |||
| selectEleWidth, | |||
| attrs, | |||
| selectRef, | |||
| listeners, | |||
| handleChange, | |||
| }; | |||
| }, | |||
| @@ -1,217 +0,0 @@ | |||
| /** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||
| * | |||
| * Licensed under the Apache License, Version 2.0 (the "License"); | |||
| * you may not use this file except in compliance with the License. | |||
| * You may obtain a copy of the License at | |||
| * | |||
| * http://www.apache.org/licenses/LICENSE-2.0 | |||
| * | |||
| * Unless required by applicable law or agreed to in writing, software | |||
| * distributed under the License is distributed on an "AS IS" BASIS, | |||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| * See the License for the specific language governing permissions and | |||
| * limitations under the License. | |||
| * ============================================================= | |||
| */ | |||
| <template> | |||
| <el-popover | |||
| v-model="addLabelTagVisible" | |||
| placement="bottom" | |||
| trigger="click" | |||
| :visible-arrow="false" | |||
| width="370" | |||
| height="370" | |||
| @hide="handleHide" | |||
| > | |||
| <div slot="default" class="add-label-tag"> | |||
| <el-tabs v-model="activeLabel" tab-position="left" @tab-click="handleTabClick"> | |||
| <el-tab-pane label="自动标注标签" name="systemLabel"> | |||
| <el-table :data="systemLabel" :show-header="false" height="290" row-class-name="tag-table-row"> | |||
| <el-table-column prop="chosen" class-name="no-ellipsis" width="30"> | |||
| <template slot-scope="scope"> | |||
| <el-checkbox v-model="scope.row.chosen" /> | |||
| </template> | |||
| </el-table-column> | |||
| <el-table-column prop="name" width="80" class-name="pl-0" /> | |||
| <el-table-column prop="color" align="right"> | |||
| <template slot-scope="scope"> | |||
| <el-color-picker v-model="scope.row.color" /> | |||
| </template> | |||
| </el-table-column> | |||
| </el-table> | |||
| </el-tab-pane> | |||
| <el-tab-pane label="自定义标签" name="customLabel"> | |||
| <div style="height: 290px;"> | |||
| <el-input v-model="newCustomLabel" placeholder="字符长度不能超过30" maxlength="30" @keyup.enter.native="addCustomLabel"> | |||
| <el-button slot="append" style="padding: 12px;" type="text" class="el-icon-check" @click="addCustomLabel" /> | |||
| </el-input> | |||
| <el-table :data="customLabel" :show-header="false" height="260" row-class-name="tag-table-row"> | |||
| <div slot="empty">暂无标签</div> | |||
| <el-table-column prop="chosen" class-name="no-ellipsis" width="30"> | |||
| <template slot-scope="scope"> | |||
| <el-checkbox v-model="scope.row.chosen" /> | |||
| </template> | |||
| </el-table-column> | |||
| <el-table-column prop="name" width="120" class-name="pl-0 ellipsis"> | |||
| <template slot-scope="scope"> | |||
| <span :title="scope.row.name">{{ scope.row.name }}</span> | |||
| </template> | |||
| </el-table-column> | |||
| <el-table-column prop="color" align="right" width="60"> | |||
| <template slot-scope="scope"> | |||
| <el-color-picker v-model="scope.row.color" /> | |||
| </template> | |||
| </el-table-column> | |||
| </el-table> | |||
| </div> | |||
| </el-tab-pane> | |||
| <el-tab-pane :disabled="![1, 2].includes(annotateType)" label="预置标签" name="presetLabel"> | |||
| <div style="height: 290px; padding: 40px 0 0 16px;"> | |||
| <el-radio-group v-model="chosenRadioId" class="block-label-group"> | |||
| <el-radio v-for="(value, key) in presetLabelList" :key="key" :label="key" :disabled="key==2 && annotateType==1"> | |||
| {{ value }} | |||
| </el-radio> | |||
| </el-radio-group> | |||
| </div> | |||
| </el-tab-pane> | |||
| </el-tabs> | |||
| <div class="add-label-foot" style=" padding-top: 10px; margin-bottom: 0; text-align: center;"> | |||
| <el-button type="text" @click="addLabelTagVisible = false">取消</el-button> | |||
| <el-button type="primary" @click="addLabelTag">确定</el-button> | |||
| </div> | |||
| </div> | |||
| <el-button slot="reference" type="text"> + {{ labelButtonText }}</el-button> | |||
| </el-popover> | |||
| </template> | |||
| <script> | |||
| import { find } from 'lodash'; | |||
| export default { | |||
| name: 'LabelPopover', | |||
| props: { | |||
| customLabel: { | |||
| type: Array, | |||
| default: () => [], | |||
| }, | |||
| systemLabel: { | |||
| type: Array, | |||
| default: () => [], | |||
| }, | |||
| presetLabelList: { | |||
| type: Object, | |||
| default: () => {}, | |||
| }, | |||
| chosenPresetLabelId: { | |||
| type: String, | |||
| }, | |||
| annotateType: { | |||
| type: Number, | |||
| default: 2, | |||
| }, | |||
| setPresetLabel: { | |||
| type: Function, | |||
| }, | |||
| setNoPresetLabel: { | |||
| type: Function, | |||
| }, | |||
| }, | |||
| data() { | |||
| return { | |||
| addLabelTagVisible: false, | |||
| activeLabel: 'systemLabel', // 默认为自动标注标签 | |||
| newCustomLabel: '', | |||
| chosenRadioId: undefined, | |||
| defaultLabelColor: '#6973FF', | |||
| }; | |||
| }, | |||
| computed: { | |||
| labelButtonText() { | |||
| return this.chosenPresetLabelId ? '修改标签' : '添加标签'; | |||
| }, | |||
| }, | |||
| watch: { | |||
| // 因为外部修改标注类型,本组件key不变,需监听外部变化来改变popover的标签页 | |||
| // eslint-disable-next-line func-names | |||
| 'chosenPresetLabelId': function(next) { | |||
| if (next) { | |||
| this.activeLabel = 'presetLabel'; | |||
| this.chosenRadioId = this.chosenPresetLabelId; | |||
| } else { | |||
| this.activeLabel = 'systemLabel'; | |||
| } | |||
| }, | |||
| // eslint-disable-next-line func-names | |||
| 'annotateType': function(next) { | |||
| if ([1, 5].includes(next)) { | |||
| this.activeLabel = 'systemLabel'; | |||
| } | |||
| }, | |||
| }, | |||
| created() { | |||
| // 修改预置标签时弹出popover为预置标签tab | |||
| if (this.chosenPresetLabelId) { | |||
| this.activeLabel = 'presetLabel'; | |||
| this.chosenRadioId = this.chosenPresetLabelId; | |||
| } | |||
| }, | |||
| methods: { | |||
| handleTabClick() { | |||
| // 切换tab清除了选中的预置标签 | |||
| this.chosenRadioId = undefined; | |||
| }, | |||
| findItem(list, name) { | |||
| return find(list, d => d.name === name); | |||
| }, | |||
| addCustomLabel() { | |||
| if (this.newCustomLabel.trim() !== '' && !this.findItem(this.customLabel, this.newCustomLabel)) { | |||
| this.customLabel.push({ | |||
| name: this.newCustomLabel, | |||
| color: this.defaultLabelColor, | |||
| chosen: true, | |||
| }); | |||
| this.newCustomLabel = ''; | |||
| } | |||
| }, | |||
| addLabelTag() { | |||
| if (this.activeLabel === 'presetLabel') { | |||
| if (!this.chosenRadioId === undefined) { | |||
| this.setNoPresetLabel(); // 若未选择预置标签,则不添加标签 | |||
| } else { | |||
| this.setPresetLabel(this.chosenRadioId); | |||
| } | |||
| } else { | |||
| this.setNoPresetLabel(); | |||
| } | |||
| this.addLabelTagVisible = false; | |||
| }, | |||
| handleHide() { | |||
| this.$emit('hide'); | |||
| }, | |||
| }, | |||
| }; | |||
| </script> | |||
| <style lang='scss'> | |||
| .tag-table-row { | |||
| td { | |||
| padding: 6px 0 0 0; | |||
| } | |||
| .cell { | |||
| white-space: nowrap; | |||
| } | |||
| } | |||
| .el-table { | |||
| .no-ellipsis .cell { | |||
| padding-right: 0; | |||
| text-overflow: unset; | |||
| } | |||
| .pl-0 .cell { | |||
| padding-left: 0; | |||
| } | |||
| } | |||
| </style> | |||
| @@ -0,0 +1,125 @@ | |||
| /** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||
| * | |||
| * Licensed under the Apache License, Version 2.0 (the "License"); | |||
| * you may not use this file except in compliance with the License. | |||
| * You may obtain a copy of the License at | |||
| * | |||
| * http://www.apache.org/licenses/LICENSE-2.0 | |||
| * | |||
| * Unless required by applicable law or agreed to in writing, software | |||
| * distributed under the License is distributed on an "AS IS" BASIS, | |||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| * See the License for the specific language governing permissions and | |||
| * limitations under the License. | |||
| * ============================================================= | |||
| */ | |||
| <template> | |||
| <div | |||
| v-mouse-wheel="getLog" | |||
| > | |||
| <prism-render :code="logTxt" /> | |||
| </div> | |||
| </template> | |||
| <script> | |||
| import PrismRender from '@/components/Prism'; | |||
| export default { | |||
| name: 'LogContainer', | |||
| components: { | |||
| PrismRender, | |||
| }, | |||
| props: { | |||
| // 日志请求的接口方法 | |||
| logGetter: { | |||
| type: Function, | |||
| required: true, | |||
| }, | |||
| // 查询日志需要用到的其他参数 | |||
| options: { | |||
| type: Object, | |||
| default: () => ({}), | |||
| }, | |||
| // 日志请求行数 | |||
| logLines: { | |||
| type: Number, | |||
| default: 50, | |||
| }, | |||
| showMsg: { | |||
| type: Boolean, | |||
| default: false, | |||
| }, | |||
| msg: { | |||
| type: String, | |||
| default: '', | |||
| }, | |||
| }, | |||
| data() { | |||
| return { | |||
| logList: [], | |||
| noMoreLog: false, | |||
| currentLogLine: 1, | |||
| logLoading: false, | |||
| logMsgInstance: null, | |||
| }; | |||
| }, | |||
| computed: { | |||
| getLogDisabled() { | |||
| return this.logLoading || this.noMoreLog; | |||
| }, | |||
| logTxt() { | |||
| return `${this.showMsg ? `${this.msg}\n` : ''}${this.logList.join('\n')}`; | |||
| }, | |||
| }, | |||
| methods: { | |||
| getLog(noWarning = false) { | |||
| if (this.getLogDisabled) { | |||
| return; | |||
| } | |||
| this.logLoading = true; | |||
| this.logGetter({ | |||
| ...this.options, | |||
| startLine: this.currentLogLine, | |||
| lines: this.logLines, | |||
| }).then(res => { | |||
| this.logList = this.logList.concat(res.content); | |||
| this.currentLogLine = res.endLine + 1; | |||
| // 当请求到的行数小于请求行数时,冻结请求一秒 | |||
| if (res.lines < this.logLines) { | |||
| this.pauseRequest(); | |||
| // 当返回行数小于三行时提示日志已到达底部 | |||
| // TODO: logMsgInstance 到达底部提示是否应该设为,当有新的提示出现,关闭旧的提示,而不是等三秒后自动消失? | |||
| if (!noWarning && res.lines < 3 && !this.logMsgInstance) { | |||
| this.logMsgInstance = this.$message.warning({ | |||
| message: '已经到达日志底部了。', | |||
| onClose: this.onLogMsgClose, | |||
| }); | |||
| } | |||
| } | |||
| }).catch(err => { | |||
| this.pauseRequest(); | |||
| throw err; | |||
| }).finally(() => { | |||
| this.logLoading = false; | |||
| }); | |||
| }, | |||
| reset(getLog = false) { | |||
| this.logList = []; | |||
| this.noMoreLog = false; | |||
| this.currentLogLine = 1; | |||
| getLog && this.getLog(true); | |||
| }, | |||
| onLogMsgClose() { | |||
| this.logMsgInstance = null; | |||
| }, | |||
| pauseRequest() { | |||
| this.noMoreLog = true; | |||
| setTimeout(() => { | |||
| this.noMoreLog = false; | |||
| }, 1000); | |||
| }, | |||
| }, | |||
| }; | |||
| </script> | |||
| @@ -0,0 +1,243 @@ | |||
| /** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||
| * | |||
| * Licensed under the Apache License, Version 2.0 (the "License"); | |||
| * you may not use this file except in compliance with the License. | |||
| * You may obtain a copy of the License at | |||
| * | |||
| * http://www.apache.org/licenses/LICENSE-2.0 | |||
| * | |||
| * Unless required by applicable law or agreed to in writing, software | |||
| * distributed under the License is distributed on an "AS IS" BASIS, | |||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| * See the License for the specific language governing permissions and | |||
| * limitations under the License. | |||
| * ============================================================= | |||
| */ | |||
| <template> | |||
| <div> | |||
| <el-select | |||
| v-model="algoUsage" | |||
| placeholder="请选择数据集用途" | |||
| @change="onAlgorithmUsageChange" | |||
| > | |||
| <el-option :value="null" label="全部" /> | |||
| <el-option | |||
| v-for="item in algorithmUsageList" | |||
| :key="item.id" | |||
| :value="item.auxInfo" | |||
| :label="item.auxInfo" | |||
| /> | |||
| </el-select> | |||
| <el-select | |||
| v-model="dataSource" | |||
| placeholder="请选择您挂载的数据集" | |||
| filterable | |||
| value-key="id" | |||
| @change="onDataSourceChange" | |||
| > | |||
| <el-option | |||
| v-for="item in datasetIdList" | |||
| :key="item.id" | |||
| :value="item" | |||
| :label="item.name" | |||
| /> | |||
| </el-select> | |||
| <el-select | |||
| v-model="dataSourceVersion" | |||
| placeholder="请选择您挂载的数据集版本" | |||
| value-key="versionUrl" | |||
| @change="onDataSourceVersionChange" | |||
| > | |||
| <el-option | |||
| v-for="(item, index) in datasetVersionList" | |||
| :key="index" | |||
| :value="item" | |||
| :label="item.versionName" | |||
| /> | |||
| </el-select> | |||
| <el-tooltip effect="dark" :content="urlTooltip" placement="top"> | |||
| <i class="el-icon-warning-outline primary f18 v-text-top" /> | |||
| </el-tooltip> | |||
| <el-tooltip effect="dark" :disabled="!dataSourceVersion" :content="ofRecordTooltip" placement="top"> | |||
| <el-checkbox | |||
| v-model="useOfRecord" | |||
| :disabled="!ofRecordDisabled" | |||
| @change="onUseOfRecordChange" | |||
| >使用 OfRecord</el-checkbox> | |||
| </el-tooltip> | |||
| </div> | |||
| </template> | |||
| <script> | |||
| import { list as getAlgorithmUsages } from '@/api/algorithm/algorithmUsage'; | |||
| import { getPublishedDatasets, getDatasetVersions } from '@/api/preparation/dataset'; | |||
| export default { | |||
| name: 'DataSourceSelector', | |||
| props: { | |||
| type: { | |||
| type: String, | |||
| default: 'train', | |||
| }, | |||
| algorithmUsage: { | |||
| type: String, | |||
| default: null, | |||
| }, | |||
| dataSourceName: { | |||
| type: String, | |||
| default: null, | |||
| }, | |||
| dataSourcePath: { | |||
| type: String, | |||
| default: null, | |||
| }, | |||
| }, | |||
| data() { | |||
| return { | |||
| algorithmUsageList: [], | |||
| datasetIdList: [], | |||
| datasetVersionList: [], | |||
| algoUsage: null, | |||
| dataSource: null, | |||
| dataSourceVersion: null, | |||
| useOfRecord: false, | |||
| result: { | |||
| dataSourceType: null, | |||
| dataSourceName: null, | |||
| dataSourcePath: null, | |||
| imageCounts: null, | |||
| }, | |||
| }; | |||
| }, | |||
| computed: { | |||
| ofRecordTooltip() { | |||
| const content = this.dataSourceVersion?.versionOfRecordUrl | |||
| ? '选中 OfRecord 将使用二进制数据集文件' | |||
| : '二进制数据集文件不可用或正在生成中'; | |||
| return content; | |||
| }, | |||
| ofRecordDisabled() { | |||
| return this.dataSourceVersion && this.dataSourceVersion.versionOfRecordUrl; | |||
| }, | |||
| urlTooltip() { | |||
| return this.type === 'verify' | |||
| ? '请确保代码中包含“val_data_url”参数用于传输数据集路径' | |||
| : '请确保代码中包含“data_url”参数用于传输数据集路径'; | |||
| }, | |||
| }, | |||
| watch: { | |||
| result: { | |||
| deep: true, | |||
| handler(result) { | |||
| this.$emit('change', result); | |||
| }, | |||
| }, | |||
| }, | |||
| mounted() { | |||
| this.algoUsage = this.algoUsage || null; | |||
| this.getAlgorithmUsages(); | |||
| }, | |||
| methods: { | |||
| // handlers | |||
| onAlgorithmUsageChange(annotateType, datasetInit = false) { | |||
| // 算法用途修改之后,重新获取数据集列表,清空数据集结果 | |||
| this.getDataSetList(annotateType, datasetInit); | |||
| }, | |||
| async onDataSourceChange(dataSource) { | |||
| // 数据集选项发生变化时,获取版本列表,同时清空数据集版本、路径、OfRecord 相关信息 | |||
| this.datasetVersionList = await getDatasetVersions(dataSource.id); | |||
| this.result.dataSourceName = null; | |||
| this.result.dataSourcePath = null; | |||
| this.dataSourceVersion = null; | |||
| this.useOfRecord = false; | |||
| }, | |||
| onDataSourceVersionChange(version) { | |||
| // 选择数据集版本后,如果存在 OfRecordUrl,则默认勾选使用,否则禁用选择 | |||
| this.result.dataSourceName = `${this.dataSource.name}:${version.versionName}`; | |||
| this.result.imageCounts = version.imageCounts; | |||
| if (version.versionOfRecordUrl) { | |||
| this.useOfRecord = true; | |||
| this.result.dataSourcePath = version.versionOfRecordUrl; | |||
| } else { | |||
| this.useOfRecord = false; | |||
| this.result.dataSourcePath = version.versionUrl; | |||
| } | |||
| }, | |||
| onUseOfRecordChange(useOfRecord) { | |||
| this.result.dataSourcePath = useOfRecord | |||
| ? this.dataSourceVersion.versionOfRecordUrl | |||
| : this.dataSourceVersion.versionUrl; | |||
| }, | |||
| // getters | |||
| getAlgorithmUsages() { | |||
| const params = { | |||
| isContainDefault: true, | |||
| current: 1, | |||
| size: 1000, | |||
| }; | |||
| getAlgorithmUsages(params).then(res => { | |||
| this.algorithmUsageList = res.result; | |||
| }); | |||
| }, | |||
| /** | |||
| * 用于获取数据集列表 | |||
| * @param {String} annotateType | |||
| * @param {Boolean} init 表示是否根据传入的数据集信息进行初始化 | |||
| */ | |||
| async getDataSetList(annotateType, init) { | |||
| const params = { | |||
| size: 1000, | |||
| annotateType: annotateType || undefined, | |||
| }; | |||
| const data = await getPublishedDatasets(params); | |||
| this.datasetIdList = data.result; | |||
| this.datasetVersionList = []; | |||
| if (!init || !this.dataSourceName) { | |||
| this.dataSource = this.dataSourceVersion = this.result.dataSourceName = this.result.dataSourcePath = null; | |||
| } else { | |||
| // 根据传入的数据集信息进行初始化 | |||
| this.dataSource = this.datasetIdList.find(dataset => dataset.name === this.dataSourceName.split(':')[0]); | |||
| if (!this.dataSource) { | |||
| // 无法在数据集列表中找到同名的数据集 | |||
| this.$message.warning('原有数据集不存在,请重新选择'); | |||
| this.result.dataSourceName = this.result.dataSourcePath = null; | |||
| return; | |||
| } | |||
| this.datasetVersionList = await getDatasetVersions(this.dataSource.id); | |||
| // 首先尝试使用 versionUrl 进行数据集路径匹配 | |||
| this.dataSourceVersion = this.datasetVersionList.find(dataset => dataset.versionUrl === this.dataSourcePath); | |||
| if (!this.dataSourceVersion) { | |||
| // 无法匹配上时使用 versionOfRecordUrl 进行数据集路径匹配 | |||
| this.dataSourceVersion = this.datasetVersionList.find(dataset => dataset.versionOfRecordUrl === this.dataSourcePath); | |||
| this.dataSourceVersion && (this.useOfRecord = true); | |||
| } | |||
| // 如果二者都不能匹配上,说明原有的数据集版本目前不存在 | |||
| if (!this.dataSourceVersion) { | |||
| this.$message.warning('原有数据集版本不存在,请重新选择'); | |||
| this.result.dataSourcePath = null; | |||
| } | |||
| } | |||
| }, | |||
| // 外部调用接口方法 | |||
| updateAlgorithmUsage(usage, init = false) { | |||
| this.algoUsage = usage || null; | |||
| this.onAlgorithmUsageChange(usage, init); | |||
| }, | |||
| reset() { | |||
| Object.assign(this.result, { | |||
| dataSourceType: null, | |||
| dataSourceName: null, | |||
| dataSourcePath: null, | |||
| }); | |||
| this.algoUsage = null; | |||
| this.dataSource = null; | |||
| this.dataSourceVersion = null; | |||
| this.useOfRecord = false; | |||
| this.datasetVersionList = []; | |||
| }, | |||
| }, | |||
| }; | |||
| </script> | |||
| @@ -0,0 +1,135 @@ | |||
| /** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||
| * | |||
| * Licensed under the Apache License, Version 2.0 (the "License"); | |||
| * you may not use this file except in compliance with the License. | |||
| * You may obtain a copy of the License at | |||
| * | |||
| * http://www.apache.org/licenses/LICENSE-2.0 | |||
| * | |||
| * Unless required by applicable law or agreed to in writing, software | |||
| * distributed under the License is distributed on an "AS IS" BASIS, | |||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| * See the License for the specific language governing permissions and | |||
| * limitations under the License. | |||
| * ============================================================= | |||
| */ | |||
| <template> | |||
| <el-form | |||
| ref="form" | |||
| :label-width="labelWidth" | |||
| class="mb-20" | |||
| :model="item" | |||
| > | |||
| <el-form-item | |||
| :label="'运行参数' + (index + 1)" | |||
| class="param-pair-item" | |||
| prop="key" | |||
| :rules="keyRule" | |||
| > | |||
| <el-input | |||
| ref="keyInput" | |||
| v-model="item.key" | |||
| clearable | |||
| class="key-input" | |||
| :disabled="disabled" | |||
| @change="$emit('change', item)" | |||
| /> | |||
| </el-form-item> | |||
| <el-form-item | |||
| label="=" | |||
| label-width="30px" | |||
| class="param-pair-item" | |||
| prop="value" | |||
| :rules="valueRule" | |||
| > | |||
| <el-input | |||
| ref="valueInput" | |||
| v-model="item.value" | |||
| type="text" | |||
| class="value-input" | |||
| :disabled="disabled" | |||
| @change="$emit('change', item)" | |||
| /> | |||
| </el-form-item> | |||
| <el-button | |||
| v-if="!disabled && showAdd" | |||
| type="primary" | |||
| size="mini" | |||
| icon="el-icon-plus" | |||
| circle | |||
| @click="onAdd" | |||
| /> | |||
| <el-button | |||
| v-if="!disabled && showRemove" | |||
| type="danger" | |||
| size="mini" | |||
| icon="el-icon-minus" | |||
| circle | |||
| @click="onRemove" | |||
| /> | |||
| </el-form> | |||
| </template> | |||
| <script> | |||
| export default { | |||
| name: 'ParamPair', | |||
| props: { | |||
| index: { | |||
| type: Number, | |||
| required: true, | |||
| }, | |||
| item: { | |||
| type: Object, | |||
| default: () => ({}), | |||
| }, | |||
| labelWidth: { | |||
| type: String, | |||
| default: '100px', | |||
| }, | |||
| disabled: { | |||
| type: Boolean, | |||
| default: false, | |||
| }, | |||
| showAdd: { | |||
| type: Boolean, | |||
| default: false, | |||
| }, | |||
| showRemove: { | |||
| type: Boolean, | |||
| default: false, | |||
| }, | |||
| keyRule: { | |||
| type: Array, | |||
| default: () => ([]), | |||
| }, | |||
| valueRule: { | |||
| type: Array, | |||
| default: () => ([]), | |||
| }, | |||
| }, | |||
| methods: { | |||
| onAdd() { | |||
| this.$emit('add'); | |||
| }, | |||
| onRemove() { | |||
| this.$emit('remove', this.index); | |||
| }, | |||
| validate(callback) { | |||
| return this.$refs.form.validate(callback || undefined); | |||
| }, | |||
| }, | |||
| }; | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .key-input, | |||
| .value-input { | |||
| width: 150px; | |||
| } | |||
| .param-pair-item { | |||
| display: inline-block; | |||
| } | |||
| </style> | |||
| @@ -18,8 +18,8 @@ | |||
| <div> | |||
| <el-form-item label="运行参数模式"> | |||
| <el-radio-group v-model="paramsMode" @change="onParamsModeChange"> | |||
| <el-radio-button :label="1">key-value</el-radio-button> | |||
| <el-radio-button :label="2">arguments</el-radio-button> | |||
| <el-radio :label="1" border class="mr-0">key-value</el-radio> | |||
| <el-radio :label="2" border>arguments</el-radio> | |||
| </el-radio-group> | |||
| </el-form-item> | |||
| <el-form-item | |||
| @@ -29,38 +29,21 @@ | |||
| :prop="prop" | |||
| style="margin-bottom: 0;" | |||
| > | |||
| <el-form ref="runParamForm" :label-width="paramLabelWidth"> | |||
| <div v-for="(item, index) in runParamsList" :key="index"> | |||
| <el-form-item | |||
| :ref="itemKeyId(index)" | |||
| style="display: inline-block; margin-bottom: 18px;" | |||
| :label="'运行参数' + (index+1)" | |||
| :prop="itemKeyId(index)" | |||
| :rules="{ | |||
| validator: (rule, value, callback) => {validateKey(callback, item, index)}, trigger: 'blur' | |||
| }" | |||
| :error="errMsg[index]" | |||
| > | |||
| <el-input v-model="item.key" :style="`width:${input1Width}px;`" clearable :disabled="disabled" @change="handleChange" /> | |||
| </el-form-item> | |||
| <el-form-item | |||
| :ref="itemValueId(index)" | |||
| style="display: inline-block;" | |||
| label="=" | |||
| label-width="30px" | |||
| :prop="itemValueId(index)" | |||
| :rules="{ | |||
| validator: (rule, value, callback) => {validateValue(callback, item, index)}, trigger: 'blur' | |||
| }" | |||
| > | |||
| <el-input v-model="item.value" type="text" :style="`width:${input2Width}px;`" :disabled="disabled" @change="handleChange" /> | |||
| </el-form-item> | |||
| <template v-if="!disabled"> | |||
| <el-button v-if="index==runParamsList.length-1" type="primary" size="mini" icon="el-icon-plus" circle @click="() => { addP(index) }" /> | |||
| <el-button v-if="runParamsList.length>1" type="danger" size="mini" icon="el-icon-minus" circle @click="() => { removeP(index) }" /> | |||
| </template> | |||
| </div> | |||
| </el-form> | |||
| <param-pair | |||
| v-for="(item, index) in runParamsList" | |||
| :key="item.id" | |||
| ref="paramPairs" | |||
| :item="runParamsList[index]" | |||
| :index="index" | |||
| :label-width="paramLabelWidth" | |||
| :disabled="disabled" | |||
| :show-add="index==runParamsList.length-1" | |||
| :show-remove="runParamsList.length>1" | |||
| :key-rule="keyRule" | |||
| @add="addP" | |||
| @remove="removeP" | |||
| @change="handleChange" | |||
| /> | |||
| </el-form-item> | |||
| <el-form-item v-show="paramsMode === 2" label="运行参数" :error="argErrorMsg"> | |||
| <el-input | |||
| @@ -76,30 +59,20 @@ | |||
| <script> | |||
| import { stringIsValidPythonVariable } from '@/utils'; | |||
| import ParamPair from './paramPair'; | |||
| export default { | |||
| name: 'RunParamForm', | |||
| components: { ParamPair }, | |||
| props: { | |||
| id: { | |||
| type: [Number, String], | |||
| default: null, | |||
| }, | |||
| runParamObj: { | |||
| type: Object, | |||
| default: () => {}, | |||
| default: () => ({}), | |||
| }, | |||
| prop: { | |||
| type: String, | |||
| default: null, | |||
| }, | |||
| input1Width: { | |||
| type: Number, | |||
| default: 150, | |||
| }, | |||
| input2Width: { | |||
| type: Number, | |||
| default: 150, | |||
| }, | |||
| paramLabelWidth: { | |||
| type: String, | |||
| default: '100px', | |||
| @@ -110,73 +83,34 @@ export default { | |||
| }, | |||
| }, | |||
| data() { | |||
| const isInputEmpty = value => { | |||
| return value === '' || value === null; | |||
| }; | |||
| const keyValidator = (rule, value, callback) => { | |||
| if (!isInputEmpty(value) && !stringIsValidPythonVariable(value)) { | |||
| callback(new Error('参数key必须是合法变量名')); | |||
| } else { | |||
| callback(); | |||
| } | |||
| }; | |||
| return { | |||
| runParamsList: [], | |||
| errMsg: [], | |||
| paramsMode: 1, | |||
| paramsArguments: '', | |||
| argErrorMsg: null, | |||
| validateKey: (callback, item, index) => { | |||
| // 先校验是不是都为空,若都为空则通过 | |||
| const isEmptyKey = this.isInputEmpty(item.key); | |||
| const isEmptyValue = this.isInputEmpty(item.value); | |||
| if (isEmptyKey && isEmptyValue) { | |||
| // 可能之前value有校验错误信息 | |||
| this.$refs[this.itemValueId(index)][0].form.clearValidate(this.itemValueId(index)); | |||
| callback(); | |||
| return; | |||
| } | |||
| // 再校验自己是不是合法的变量名 | |||
| if (!stringIsValidPythonVariable(item.key)) { | |||
| callback(new Error('参数key必须是合法变量名')); | |||
| return; | |||
| } | |||
| // 然后和value联合校验 | |||
| if (isEmptyKey) { | |||
| callback(new Error('请输入参数key')); | |||
| return; | |||
| } if (isEmptyValue) { | |||
| this.$refs.runParamForm.validateField(this.itemValueId(index)); | |||
| } else { | |||
| callback(); | |||
| } | |||
| }, | |||
| validateValue: (callback, item, index) => { | |||
| // 先校验是不是都为空,若都为空则通过 | |||
| const isEmptyKey = this.isInputEmpty(item.key); | |||
| const isEmptyValue = this.isInputEmpty(item.value); | |||
| if (isEmptyKey && isEmptyValue) { | |||
| // 可能之前key有校验错误信息 | |||
| this.$refs[this.itemKeyId(index)][0].form.clearValidate(this.itemKeyId(index)); | |||
| callback(); | |||
| return; | |||
| } | |||
| // 输入框格式保证了其类似一定是字符串,只需不传空即可 | |||
| // 然后和key联合校验 | |||
| if (isEmptyValue) { | |||
| callback(); | |||
| return; | |||
| } if (isEmptyKey) { | |||
| this.$refs.runParamForm.validateField(this.itemKeyId(index)); | |||
| } else { | |||
| callback(); | |||
| } | |||
| }, | |||
| // 整体校验规则:对 key 做 python 变量名有效性校验,对 value 不做任何校验 | |||
| keyRule: [{ | |||
| validator: keyValidator, | |||
| trigger: 'blur', | |||
| }], | |||
| paramId: 0, | |||
| paramRepeatWarning: null, | |||
| hasError: false, | |||
| }; | |||
| }, | |||
| watch: { | |||
| id(newValue) { | |||
| if (newValue === null || isNaN(newValue)) { | |||
| /** | |||
| * newValue为null时的一种情况是与el-form组合使用的 | |||
| * crud组件触发了cancelCU方法,此时不需更新 | |||
| */ | |||
| return; | |||
| } | |||
| this.syncListData(); | |||
| }, | |||
| runParamObj() { | |||
| this.syncListData(); | |||
| }, | |||
| @@ -185,19 +119,12 @@ export default { | |||
| this.syncListData(); | |||
| }, | |||
| methods: { | |||
| isInputEmpty(value) { | |||
| return value === '' || value === null; | |||
| }, | |||
| itemKeyId(index) { | |||
| return `runParamsList.${ index }.key`; | |||
| }, | |||
| itemValueId(index) { | |||
| return `runParamsList.${ index }.value`; | |||
| }, | |||
| addP() { | |||
| this.runParamsList.push({ | |||
| key: '', | |||
| value: '', | |||
| // eslint-disable-next-line no-plusplus | |||
| id: this.paramId++, | |||
| }); | |||
| }, | |||
| removeP(i) { | |||
| @@ -205,12 +132,17 @@ export default { | |||
| this.updateRunParamObj(); | |||
| }, | |||
| syncListData() { | |||
| const rpObj = { ...this.runParamObj}; | |||
| const list = []; | |||
| for (const formKey in rpObj) { | |||
| list.push({ | |||
| key: formKey, | |||
| value: typeof (rpObj[formKey]) === 'object' ? JSON.stringify(rpObj[formKey]) : rpObj[formKey], | |||
| for (const key in this.runParamObj) { | |||
| const objItem = this.runParamsList.find(p => p.key === key); | |||
| if (objItem) { | |||
| objItem.value = this.runParamObj[key]; | |||
| } | |||
| list.push(objItem || { | |||
| key, | |||
| value: this.runParamObj[key], | |||
| // eslint-disable-next-line no-plusplus | |||
| id: this.paramId++, | |||
| }); | |||
| } | |||
| this.runParamsList = list; | |||
| @@ -221,33 +153,59 @@ export default { | |||
| this.convertPairsToArgs(); | |||
| } | |||
| }, | |||
| handleChange() { | |||
| handleChange(paramPair) { | |||
| // 当参数对的值改变时 key 为空,则把对于的 param 删除 | |||
| if (!paramPair.key) { | |||
| const paramIndex = this.runParamsList.findIndex(p => p.id === paramPair.id); | |||
| this.runParamsList.splice(paramIndex, 1); | |||
| } | |||
| if (!this.runParamsList.length) { | |||
| this.addP(); | |||
| } | |||
| this.updateRunParamObj(); | |||
| }, | |||
| // 提供修改参数的入口, 如果参数存在则可修改 | |||
| updateParam(key, value) { | |||
| const param = this.runParamsList.find(p => p.key === key); | |||
| if (param) { | |||
| param.value = value; | |||
| this.updateRunParamObj(); | |||
| } | |||
| }, | |||
| updateRunParamObj() { | |||
| const obj = {}; | |||
| this.runParamsList.forEach(d => { | |||
| if (d.key === '') return; | |||
| obj[d.key] = d.value; | |||
| const repeatedParams = new Set(); | |||
| this.runParamsList.forEach(param => { | |||
| // 当 key 为空或者已存在相同 key 时,不加入数值 | |||
| if (!param.key) { | |||
| return; | |||
| } | |||
| if (obj[param.key] !== undefined) { | |||
| repeatedParams.add(param.key); | |||
| return; | |||
| } | |||
| obj[param.key] = param.value; | |||
| }); | |||
| if (repeatedParams.size) { | |||
| this.paramRepeatWarning && this.paramRepeatWarning.close(); | |||
| this.paramRepeatWarning = this.$message.warning(`参数 ${[...repeatedParams].join(', ')} 有重复, 将取用第一个值。`); | |||
| } | |||
| this.$emit('updateRunParams', obj); | |||
| }, | |||
| goValid() { | |||
| validate() { | |||
| // 单独校验 | |||
| let valid = true; | |||
| this.errMsg = []; | |||
| this.runParamsList.forEach((item, index) => { | |||
| if (this.isInputEmpty(item.key)) { | |||
| if (!this.isInputEmpty(item.value)) { | |||
| valid = false; | |||
| } | |||
| } else if (!stringIsValidPythonVariable(item.key)) { | |||
| valid = false; | |||
| this.$nextTick(() => { | |||
| this.errMsg[index] = '参数key必须是合法变量名'; | |||
| }); | |||
| } | |||
| }); | |||
| const validCallback = pairValid => { | |||
| valid = valid && pairValid; | |||
| }; | |||
| // eslint-disable-next-line no-plusplus | |||
| for (let i = 0; i < this.runParamsList.length; i++) { | |||
| this.paramsMode === 1 && this.$refs.paramPairs[i].validate(validCallback); | |||
| }; | |||
| valid = valid && !this.hasError; | |||
| return valid; | |||
| }, | |||
| onParamsModeChange(value) { | |||
| @@ -265,33 +223,41 @@ export default { | |||
| const paramsList = this.paramsArguments.split(' '); | |||
| const pairList = []; | |||
| const re = /^--(.+)=(.*)$/; | |||
| this.hasError = false; | |||
| // 先使用正则进行匹配 | |||
| paramsList.forEach(arg => { | |||
| const group = re.exec(arg); | |||
| if (group) { | |||
| pairList.push({ | |||
| key: group[1], | |||
| value: group[2], | |||
| // eslint-disable-next-line no-plusplus | |||
| id: this.paramId++, | |||
| }); | |||
| } else if (arg) { | |||
| this.$nextTick(() => { | |||
| this.argErrorMsg = `参数'${arg}'不合法,请检查运行参数`; | |||
| }); | |||
| this.paramsMode = 2; | |||
| this.hasError = true; | |||
| } | |||
| }); | |||
| if (this.hasError) return; | |||
| // 其次做参数名验证 | |||
| pairList.forEach(pair => { | |||
| if (!stringIsValidPythonVariable(pair.key)) { | |||
| this.$nextTick(() => { | |||
| this.argErrorMsg = `参数名'${pair.key}'不是合法参数,请检查运行参数`; | |||
| }); | |||
| this.paramsMode = 2; | |||
| this.hasError = true; | |||
| } | |||
| }); | |||
| if (this.hasError) return; | |||
| // 参数为空时增加一个空参数 | |||
| if (!pairList.length) { | |||
| pairList.push({ key: '', value: '' }); | |||
| // eslint-disable-next-line no-plusplus | |||
| pairList.push({ key: '', value: '', id: this.paramId++ }); | |||
| } | |||
| this.runParamsList = pairList; | |||
| this.updateRunParamObj(); | |||
| @@ -308,13 +274,20 @@ export default { | |||
| this.paramsArguments = args; | |||
| }, | |||
| reset() { | |||
| this.errMsg = []; | |||
| this.argErrorMsg = null; | |||
| this.paramsMode = 1; | |||
| this.paramsArguments = ''; | |||
| this.runParamsList = [{ key: '', value: '' }]; | |||
| this.$refs.runParamForm.clearValidate(); | |||
| // eslint-disable-next-line no-plusplus | |||
| this.runParamsList = [{ key: '', value: '', id: this.paramId++ }]; | |||
| }, | |||
| }, | |||
| }; | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .el-radio.is-bordered { | |||
| width: 130px; | |||
| height: 35px; | |||
| padding: 10px 0; | |||
| text-align: center; | |||
| } | |||
| </style> | |||
| @@ -33,7 +33,7 @@ | |||
| <!--已有模型--> | |||
| <el-form-item v-if="!createModelFlag" label="归属模型" prop="parentId"> | |||
| <el-select v-model="modelForm.parentId" filterable placeholder="请选择模型" style="width: 300px;"> | |||
| <el-option v-for="item in modelList" :key="item.id" :label="item.name" :value="item.id" /> | |||
| <el-option v-for="item in modelList" :key="item.id" :label="formatVersion(item)" :value="item.id" /> | |||
| </el-select> | |||
| <el-tooltip class="item" effect="dark" content="如果没有对应的模型,请点击新建" placement="right-start"> | |||
| <el-button @click="goModel">新建模型</el-button> | |||
| @@ -210,6 +210,12 @@ export default { | |||
| this.createAlgorithmUsage(value); | |||
| } | |||
| }, | |||
| formatVersion(item) { | |||
| if (item.versionNum) { | |||
| return `${item.name} (V${(Number(item.versionNum.substr(1)) + 1).toString().padStart(4, '0')})`; | |||
| } | |||
| return `${item.name} (V0001)`; | |||
| }, | |||
| // op | |||
| doSaveModel() { | |||
| this.$refs.modelForm.validate(valid => { | |||
| @@ -43,16 +43,12 @@ export default { | |||
| }, | |||
| limit: { | |||
| type: Number, | |||
| default: 1000, | |||
| default: 5000, | |||
| }, | |||
| showFileCount: { | |||
| type: Boolean, | |||
| default: true, | |||
| }, | |||
| wordShow: { | |||
| type: Boolean, | |||
| default: true, | |||
| }, | |||
| }, | |||
| data() { | |||
| return { | |||
| @@ -130,6 +126,7 @@ export default { | |||
| }, | |||
| onRemove(file, fileList) { | |||
| this.lenOfFileList = fileList.length; | |||
| this.$attrs['on-remove'] && this.$attrs['on-remove'](file, fileList); | |||
| }, | |||
| cancelUpload() { | |||
| if (this.source) { | |||
| @@ -163,7 +160,7 @@ export default { | |||
| class='upload-field' | |||
| limit={this.limit} | |||
| multiple | |||
| list-type='picture' | |||
| list-type={this.lenOfFileList>100? 'text' : 'picture'} | |||
| auto-upload={false} | |||
| disabled={this.uploading} | |||
| {...uploadProps} | |||
| @@ -180,7 +177,7 @@ export default { | |||
| </div> | |||
| { | |||
| this.showFileCount && ( | |||
| this.wordShow ? <span class='upload-chosen-tip'>已选择{ this.lenOfFileList }张</span> : null | |||
| <span class='upload-chosen-tip'>已选择{ this.lenOfFileList }张</span> | |||
| ) | |||
| } | |||
| </div> | |||
| @@ -69,7 +69,7 @@ export default { | |||
| } | |||
| state.uploading = true; | |||
| ctx.emit('uploadStart'); | |||
| ctx.emit('uploadStart', files); | |||
| const uploadReqeust = request || minIOUpload; | |||
| // 开始调用上传接口 | |||
| return uploadReqeust({ ...props.params, fileList: renameFileList, transformFile }, callback) | |||
| @@ -0,0 +1,93 @@ | |||
| /** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||
| * | |||
| * Licensed under the Apache License, Version 2.0 (the "License"); | |||
| * you may not use this file except in compliance with the License. | |||
| * You may obtain a copy of the License at | |||
| * | |||
| * http://www.apache.org/licenses/LICENSE-2.0 | |||
| * | |||
| * Unless required by applicable law or agreed to in writing, software | |||
| * distributed under the License is distributed on an "AS IS" BASIS, | |||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| * See the License for the specific language governing permissions and | |||
| * limitations under the License. | |||
| * ============================================================= | |||
| */ | |||
| // 仅支持line-upload上传文件,线性进度条 | |||
| <template> | |||
| <div class="progress"> | |||
| <el-progress :percentage="Math.floor(progress)" :color="color" :status="status"></el-progress> | |||
| </div> | |||
| </template> | |||
| <script> | |||
| export default { | |||
| name: 'UploadProgress', | |||
| props: { | |||
| color: { // 进度条颜色 | |||
| type: [String, Array, Function], | |||
| default: '#67c23a', | |||
| }, | |||
| status: { | |||
| type: String, | |||
| default: null, | |||
| }, | |||
| size: { // 文件大小 | |||
| type: Number, | |||
| required: true, | |||
| }, | |||
| progress: { // 进度 | |||
| type: Number, | |||
| required: true, | |||
| }, | |||
| }, | |||
| mounted() { | |||
| const fileSize = this.size / 1024 / 1024; // 获取文件大小(以MB为单位) | |||
| const uploadTime = fileSize / 10; // 通过10s每兆上传速度 | |||
| const step = 90 / uploadTime * 2; // 每秒刷新的进度上限 | |||
| this.interval = setInterval(() => { | |||
| if (this.progress >= 100 - step) { | |||
| clearInterval(this.interval); | |||
| return; | |||
| } | |||
| this.$emit('onSetProgress', Math.random() * step); | |||
| }, 1000); | |||
| }, | |||
| }; | |||
| </script> | |||
| <style lang="scss"> | |||
| .progress { | |||
| .el-progress-bar__inner::before { | |||
| position: absolute; | |||
| top: 0; | |||
| right: 0; | |||
| bottom: 0; | |||
| left: 0; | |||
| content: ''; | |||
| background: #fff; | |||
| border-radius: 10px; | |||
| opacity: 0; | |||
| animation: active 2.4s cubic-bezier(0.23, 1, 0.32, 1) infinite; | |||
| } | |||
| } | |||
| // 进度条加载时的动画 | |||
| @keyframes active { | |||
| 0% { | |||
| width: 0; | |||
| opacity: 0.1; | |||
| } | |||
| 20% { | |||
| width: 0; | |||
| opacity: 0.5; | |||
| } | |||
| 100% { | |||
| width: 100%; | |||
| opacity: 0; | |||
| } | |||
| } | |||
| </style> | |||
| @@ -0,0 +1,217 @@ | |||
| /** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||
| * | |||
| * Licensed under the Apache License, Version 2.0 (the "License"); | |||
| * you may not use this file except in compliance with the License. | |||
| * You may obtain a copy of the License at | |||
| * | |||
| * http://www.apache.org/licenses/LICENSE-2.0 | |||
| * | |||
| * Unless required by applicable law or agreed to in writing, software | |||
| * distributed under the License is distributed on an "AS IS" BASIS, | |||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| * See the License for the specific language governing permissions and | |||
| * limitations under the License. | |||
| * ============================================================= | |||
| */ | |||
| import cx from 'classnames'; | |||
| import Vue from 'vue'; | |||
| import { reactive } from '@vue/composition-api'; | |||
| import Drag from '@/components/Drag'; | |||
| import Group from '../group'; | |||
| import BrushSelection from './BrushSelection'; | |||
| export default { | |||
| name: 'Brush', | |||
| components: { | |||
| Group, | |||
| }, | |||
| props: { | |||
| stageWidth: Number, | |||
| stageHeight: Number, | |||
| className: String, | |||
| onBrushStart: Function, | |||
| onBrushMove: Function, | |||
| onBrushEnd: Function, | |||
| transformZoom: Function, | |||
| left: { | |||
| type: Number, | |||
| default: 0, | |||
| }, | |||
| top: { | |||
| type: Number, | |||
| default: 0, | |||
| }, | |||
| brushSelectionStyle: { | |||
| type: Object, | |||
| default: () => ({ | |||
| fill: 'rgba(102, 181, 245, 0.1)', | |||
| stroke: 'rgba(102, 181, 245, 1)', | |||
| strokeWidth: 1, | |||
| }), | |||
| }, | |||
| }, | |||
| setup(props){ | |||
| const { onBrushStart, onBrushMove, left, top, onChange, onBrushEnd, transformZoom } = props; | |||
| const state = reactive({ | |||
| start: { x: 0, y: 0 }, | |||
| end: { x: 0, y: 0 }, | |||
| extent: { x0: 0, x1: 0, y0: 0, y1: 0 }, | |||
| isBrushing: false, | |||
| }); | |||
| const getWidth = () => { | |||
| return Math.abs(state.extent.x1 - state.extent.x0); | |||
| }; | |||
| const getHeight = () => { | |||
| return Math.abs(state.extent.y1 - state.extent.y0); | |||
| }; | |||
| const getExtent = (start, end) => { | |||
| const x0 = Math.min(start.x, end.x); | |||
| const x1 = Math.max(start.x, end.x); | |||
| const y0 = Math.min(start.y, end.y); | |||
| const y1 = Math.max(start.y, end.y); | |||
| return { | |||
| x0, | |||
| x1, | |||
| y0, | |||
| y1, | |||
| }; | |||
| }; | |||
| const update = (updater, callback) => { | |||
| Object.assign(state, updater(state)); | |||
| Vue.nextTick(() => { | |||
| if(callback) { | |||
| callback(state); | |||
| } | |||
| if(onChange) { | |||
| onChange(state); | |||
| } | |||
| }); | |||
| }; | |||
| const handleDragStart = (draw, event) => { | |||
| const start = transformZoom({ | |||
| x: draw.x + draw.dx - left, | |||
| y: draw.y + draw.dy - top, | |||
| }); | |||
| if (onBrushStart) { | |||
| onBrushStart(start, event); | |||
| } | |||
| update(prevBrush => ({ | |||
| ...prevBrush, | |||
| start, | |||
| end: undefined, | |||
| extent: { | |||
| x0: -1, | |||
| x1: -1, | |||
| y0: -1, | |||
| y1: -1, | |||
| }, | |||
| isBrushing: true, | |||
| })); | |||
| }; | |||
| const handleDragMove = (draw, event) => { | |||
| if (!draw.isDragging) return; | |||
| const end = transformZoom({ | |||
| x: draw.x + draw.dx - left, | |||
| y: draw.y + draw.dy - top, | |||
| }); | |||
| update(prevBrush => { | |||
| const { start } = prevBrush; | |||
| const extent = getExtent(start, end); | |||
| return { | |||
| ...prevBrush, | |||
| end, | |||
| extent, | |||
| }; | |||
| }, (nextState) => { | |||
| // 回调 | |||
| typeof onBrushMove === 'function' && onBrushMove(nextState, event); | |||
| }); | |||
| }; | |||
| const handleDragEnd = (draw, event, options = {}) => { | |||
| update(prevBrush => ({ | |||
| ...prevBrush, | |||
| isBrushing: false, | |||
| }), state => onBrushEnd(state, event, options)); | |||
| }; | |||
| return { | |||
| state, | |||
| getWidth, | |||
| getHeight, | |||
| update, | |||
| handleDragStart, | |||
| handleDragMove, | |||
| handleDragEnd, | |||
| getExtent, | |||
| }; | |||
| }, | |||
| render(h) { | |||
| const { stageWidth, stageHeight, className, left, top, brushSelectionStyle } = this; | |||
| const { start, end, isBrushing } = this.state; | |||
| const width = this.getWidth(); | |||
| const height = this.getHeight(); | |||
| const dragProps = { | |||
| props: { | |||
| width: stageWidth, | |||
| height: stageHeight, | |||
| resetOnStart: true, | |||
| onDragStart: this.handleDragStart, | |||
| onDragMove: this.handleDragMove, | |||
| onDragEnd: this.handleDragEnd, | |||
| }, | |||
| }; | |||
| return ( | |||
| <Group className={cx('db-brush', className)} left={left} top={top}> | |||
| {/* overlay */} | |||
| <Drag {...dragProps}> | |||
| { | |||
| (drag) => ( | |||
| <rect | |||
| className='brush-overlay' | |||
| fill='transparent' | |||
| x={0} | |||
| y={0} | |||
| width={stageWidth} | |||
| height={stageHeight} | |||
| style={{ cursor: 'crosshair' }} | |||
| onMousedown={drag.dragStart} | |||
| onMousemove={drag.dragMove} | |||
| onMouseup={drag.dragEnd} | |||
| /> | |||
| ) | |||
| } | |||
| </Drag> | |||
| {start && end && !!isBrushing && ( | |||
| <g> | |||
| <BrushSelection | |||
| updateBrush={this.update} | |||
| width={width} | |||
| height={height} | |||
| stageWidth={stageWidth} | |||
| stageHeight={stageHeight} | |||
| brush={{ ...this.state }} | |||
| selectionStyle={brushSelectionStyle} | |||
| /> | |||
| </g> | |||
| )} | |||
| </Group> | |||
| ); | |||
| }, | |||
| }; | |||
| @@ -0,0 +1,227 @@ | |||
| /** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||
| * | |||
| * Licensed under the Apache License, Version 2.0 (the "License"); | |||
| * you may not use this file except in compliance with the License. | |||
| * You may obtain a copy of the License at | |||
| * | |||
| * http://www.apache.org/licenses/LICENSE-2.0 | |||
| * | |||
| * Unless required by applicable law or agreed to in writing, software | |||
| * distributed under the License is distributed on an "AS IS" BASIS, | |||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| * See the License for the specific language governing permissions and | |||
| * limitations under the License. | |||
| * ============================================================= | |||
| */ | |||
| import Drag from '@/components/Drag'; | |||
| import { chroma } from '@/utils'; | |||
| export default { | |||
| name: 'BrushCorner', | |||
| props: { | |||
| annotate: Object, | |||
| transformer: Object, | |||
| currentAnnotationId: String, | |||
| stageWidth: Number, | |||
| stageHeight: Number, | |||
| type: String, | |||
| scale: { | |||
| type: Number, | |||
| default: 1, | |||
| }, | |||
| x: Number, | |||
| y: Number, | |||
| width: Number, | |||
| height: Number, | |||
| handleBrushStart: Function, | |||
| updateBrush: Function, | |||
| updateBrushEnd: Function, | |||
| getZoom: Function, | |||
| }, | |||
| setup(props) { | |||
| const { updateBrush, updateBrushEnd, type, scale, handleBrushStart, getZoom } = props; | |||
| const handleDragStart = (drag, event) => { | |||
| // 开始拖拽是选中当前标注 | |||
| if(handleBrushStart) { | |||
| handleBrushStart(drag, event); | |||
| } | |||
| }; | |||
| const handleDragMove = (drag) => { | |||
| if (!drag.isDragging) return; | |||
| const { zoom } = getZoom(); | |||
| updateBrush(prevBrush => { | |||
| const { start, end } = prevBrush; | |||
| let nextState = {}; | |||
| let moveX = 0; | |||
| let moveY = 0; | |||
| const _scale = scale * zoom; | |||
| const xMax = Math.max(start.x, end.x); | |||
| const xMin = Math.min(start.x, end.x); | |||
| const yMax = Math.max(start.y, end.y); | |||
| const yMin = Math.min(start.y, end.y); | |||
| switch (type) { | |||
| case 'topRight': | |||
| moveX = xMax + drag.dx / _scale; | |||
| moveY = yMin + drag.dy / _scale; | |||
| nextState = { | |||
| ...prevBrush, | |||
| activeHandle: type, | |||
| extent: { | |||
| ...prevBrush.extent, | |||
| x0: Math.max(Math.min(moveX, start.x), prevBrush.bounds.x0), | |||
| x1: Math.min(Math.max(moveX, start.x), prevBrush.bounds.x1), | |||
| y0: Math.max(Math.min(moveY, end.y), prevBrush.bounds.y0), | |||
| y1: Math.min(Math.max(moveY, end.y), prevBrush.bounds.y1), | |||
| }, | |||
| }; | |||
| break; | |||
| case 'topLeft': | |||
| moveX = xMin + drag.dx / _scale; | |||
| moveY = yMin + drag.dy / _scale; | |||
| nextState = { | |||
| ...prevBrush, | |||
| activeHandle: type, | |||
| extent: { | |||
| ...prevBrush.extent, | |||
| x0: Math.max(Math.min(moveX, end.x), prevBrush.bounds.x0), | |||
| x1: Math.min(Math.max(moveX, end.x), prevBrush.bounds.x1), | |||
| y0: Math.max(Math.min(moveY, end.y), prevBrush.bounds.y0), | |||
| y1: Math.min(Math.max(moveY, end.y), prevBrush.bounds.y1), | |||
| }, | |||
| }; | |||
| break; | |||
| case 'bottomLeft': | |||
| moveX = xMin + drag.dx / _scale; | |||
| moveY = yMax + drag.dy / _scale; | |||
| nextState = { | |||
| ...prevBrush, | |||
| activeHandle: type, | |||
| extent: { | |||
| ...prevBrush.extent, | |||
| x0: Math.max(Math.min(moveX, end.x), prevBrush.bounds.x0), | |||
| x1: Math.min(Math.max(moveX, end.x), prevBrush.bounds.x1), | |||
| y0: Math.max(Math.min(moveY, start.y), prevBrush.bounds.y0), | |||
| y1: Math.min(Math.max(moveY, start.y), prevBrush.bounds.y1), | |||
| }, | |||
| }; | |||
| break; | |||
| case 'bottomRight': | |||
| moveX = xMax + drag.dx / _scale; | |||
| moveY = yMax + drag.dy / _scale; | |||
| nextState = { | |||
| ...prevBrush, | |||
| activeHandle: type, | |||
| extent: { | |||
| ...prevBrush.extent, | |||
| x0: Math.max(Math.min(moveX, start.x), prevBrush.bounds.x0), | |||
| x1: Math.min(Math.max(moveX, start.x), prevBrush.bounds.x1), | |||
| y0: Math.max(Math.min(moveY, start.y), prevBrush.bounds.y0), | |||
| y1: Math.min(Math.max(moveY, start.y), prevBrush.bounds.y1), | |||
| }, | |||
| }; | |||
| break; | |||
| default: | |||
| break; | |||
| } | |||
| return nextState; | |||
| }); | |||
| }; | |||
| const handleDragEnd = () => { | |||
| updateBrushEnd(prevBrush => { | |||
| const { start, end, extent } = { ...prevBrush }; | |||
| start.x = Math.min(extent.x0, extent.x1); | |||
| start.y = Math.min(extent.y0, extent.y0); | |||
| end.x = Math.max(extent.x0, extent.x1); | |||
| end.y = Math.max(extent.y0, extent.y1); | |||
| const nextBrush = { | |||
| ...prevBrush, | |||
| start, | |||
| end, | |||
| activeHandle: undefined, | |||
| isBrushing: false, | |||
| domain: { | |||
| x0: Math.min(start.x, end.x), | |||
| x1: Math.max(start.x, end.x), | |||
| y0: Math.min(start.y, end.y), | |||
| y1: Math.max(start.y, end.y), | |||
| }, | |||
| }; | |||
| return nextBrush; | |||
| }); | |||
| }; | |||
| return { | |||
| handleDragStart, | |||
| handleDragMove, | |||
| handleDragEnd, | |||
| }; | |||
| }, | |||
| render(h) { | |||
| const { annotate, transformer, currentAnnotationId, stageWidth, stageHeight, type, x, y, width, height } = this; | |||
| const cursor = type === 'topLeft' || type === 'bottomRight' ? 'nwse-resize' : 'nesw-resize'; | |||
| let transform = null; | |||
| if(annotate.id === transformer.id) { | |||
| transform = `translate(${transformer.dx}, ${transformer.dy})`; | |||
| } | |||
| const { data = {} } = annotate; | |||
| const { color } = data; | |||
| const defaultFill = 'rgba(102, 181, 245, 0.1)'; | |||
| const bgColor = color || defaultFill; | |||
| const isActive = currentAnnotationId === annotate.id; | |||
| const colorAlpha = isActive ? 1 : 0; | |||
| const fillColor = chroma(bgColor).alpha(colorAlpha); | |||
| const dragProps = { | |||
| props: { | |||
| width: stageWidth, | |||
| height: stageHeight, | |||
| resetOnStart: true, | |||
| onDragStart: this.handleDragStart, | |||
| onDragMove: this.handleDragMove, | |||
| onDragEnd: this.handleDragEnd, | |||
| }, | |||
| }; | |||
| const style = { | |||
| cursor, | |||
| }; | |||
| return ( | |||
| <Drag {...dragProps}> | |||
| { | |||
| (drag) => ( | |||
| <rect | |||
| x={x} | |||
| y={y} | |||
| width={width} | |||
| height={height} | |||
| transform={transform} | |||
| fill={fillColor} | |||
| class={`brush-corner-${type}`} | |||
| onMousedown={drag.dragStart} | |||
| onMousemove={drag.dragMove} | |||
| onMouseup={drag.dragEnd} | |||
| style={style} | |||
| /> | |||
| ) | |||
| } | |||
| </Drag> | |||
| ); | |||
| }, | |||
| }; | |||
| @@ -0,0 +1,193 @@ | |||
| /** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||
| * | |||
| * Licensed under the Apache License, Version 2.0 (the "License"); | |||
| * you may not use this file except in compliance with the License. | |||
| * You may obtain a copy of the License at | |||
| * | |||
| * http://www.apache.org/licenses/LICENSE-2.0 | |||
| * | |||
| * Unless required by applicable law or agreed to in writing, software | |||
| * distributed under the License is distributed on an "AS IS" BASIS, | |||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| * See the License for the specific language governing permissions and | |||
| * limitations under the License. | |||
| * ============================================================= | |||
| */ | |||
| import Drag from '@/components/Drag'; | |||
| export default { | |||
| name: 'BrushHandle', | |||
| props: { | |||
| stageWidth: Number, | |||
| stageHeight: Number, | |||
| type: String, | |||
| scale: { | |||
| type: Number, | |||
| default: 1, | |||
| }, | |||
| handle: { | |||
| type: Object, | |||
| default: () => ({ x: 0, y: 0, width: 0, height: 0 }), | |||
| }, | |||
| handleBrushStart: Function, | |||
| updateBrush: Function, | |||
| updateBrushEnd: Function, | |||
| getZoom: Function, | |||
| }, | |||
| // todo: 鼠标离开画布没有释放 | |||
| setup(props) { | |||
| const { updateBrush, updateBrushEnd, type, scale, handleBrushStart, getZoom } = props; | |||
| const handleDragStart = (drag, event) => { | |||
| // 开始拖拽是选中当前标注 | |||
| if(handleBrushStart) { | |||
| handleBrushStart(drag, event); | |||
| } | |||
| }; | |||
| const handleDragMove = (drag) => { | |||
| if (!drag.isDragging) return; | |||
| const { zoom } = getZoom(); | |||
| updateBrush(prevBrush => { | |||
| const { start, end } = prevBrush; | |||
| let nextState = {}; | |||
| let move = 0; | |||
| const _scale = scale * zoom; | |||
| const xMax = Math.max(start.x, end.x); | |||
| const xMin = Math.min(start.x, end.x); | |||
| const yMax = Math.max(start.y, end.y); | |||
| const yMin = Math.min(start.y, end.y); | |||
| switch (type) { | |||
| case 'right': | |||
| move = xMax + drag.dx / _scale; | |||
| nextState = { | |||
| ...prevBrush, | |||
| activeHandle: type, | |||
| extent: { | |||
| ...prevBrush.extent, | |||
| x0: Math.max(Math.min(move, start.x), prevBrush.bounds.x0), | |||
| x1: Math.min(Math.max(move, start.x), prevBrush.bounds.x1), | |||
| }, | |||
| }; | |||
| break; | |||
| case 'left': | |||
| move = xMin + drag.dx / _scale; | |||
| nextState = { | |||
| ...prevBrush, | |||
| activeHandle: type, | |||
| extent: { | |||
| ...prevBrush.extent, | |||
| x0: Math.min(move, end.x), | |||
| x1: Math.max(move, end.x), | |||
| }, | |||
| }; | |||
| break; | |||
| case 'top': | |||
| move = yMin + drag.dy / _scale; | |||
| nextState = { | |||
| ...prevBrush, | |||
| activeHandle: type, | |||
| extent: { | |||
| ...prevBrush.extent, | |||
| y0: Math.min(move, end.y), | |||
| y1: Math.max(move, end.y), | |||
| }, | |||
| }; | |||
| break; | |||
| case 'bottom': | |||
| move = yMax + drag.dy / _scale; | |||
| nextState = { | |||
| ...prevBrush, | |||
| activeHandle: type, | |||
| extent: { | |||
| ...prevBrush.extent, | |||
| y0: Math.min(move, start.y), | |||
| y1: Math.max(move, start.y), | |||
| }, | |||
| }; | |||
| break; | |||
| default: | |||
| break; | |||
| } | |||
| return nextState; | |||
| }); | |||
| }; | |||
| const handleDragEnd = () => { | |||
| updateBrushEnd(prevBrush => { | |||
| const { start, end, extent } = { ...prevBrush }; | |||
| start.x = Math.min(extent.x0, extent.x1); | |||
| start.y = Math.min(extent.y0, extent.y0); | |||
| end.x = Math.max(extent.x0, extent.x1); | |||
| end.y = Math.max(extent.y0, extent.y1); | |||
| const nextBrush = { | |||
| ...prevBrush, | |||
| start, | |||
| end, | |||
| activeHandle: undefined, | |||
| isBrushing: false, | |||
| domain: { | |||
| x0: Math.min(start.x, end.x), | |||
| x1: Math.max(start.x, end.x), | |||
| y0: Math.min(start.y, end.y), | |||
| y1: Math.max(start.y, end.y), | |||
| }, | |||
| }; | |||
| return nextBrush; | |||
| }); | |||
| }; | |||
| return { | |||
| handleDragStart, | |||
| handleDragMove, | |||
| handleDragEnd, | |||
| }; | |||
| }, | |||
| render(h) { | |||
| const { stageWidth, stageHeight, handle, type } = this; | |||
| const { x, y, width, height } = handle; | |||
| const cursor = type === 'right' || type === 'left' ? 'ew-resize' : 'ns-resize'; | |||
| const dragProps = { | |||
| props: { | |||
| width: stageWidth, | |||
| height: stageHeight, | |||
| resetOnStart: true, | |||
| onDragStart: this.handleDragStart, | |||
| onDragMove: this.handleDragMove, | |||
| onDragEnd: this.handleDragEnd, | |||
| }, | |||
| }; | |||
| const style = { | |||
| cursor, | |||
| }; | |||
| return ( | |||
| <Drag {...dragProps}> | |||
| { | |||
| (drag) => ( | |||
| <rect | |||
| x={x} | |||
| y={y} | |||
| width={width} | |||
| height={height} | |||
| fill='transparent' | |||
| class={`brush-handle-${type}`} | |||
| onMousedown={drag.dragStart} | |||
| onMousemove={drag.dragMove} | |||
| onMouseup={drag.dragEnd} | |||
| style={style} | |||
| /> | |||
| ) | |||
| } | |||
| </Drag> | |||
| ); | |||
| }, | |||
| }; | |||
| @@ -0,0 +1,55 @@ | |||
| /** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||
| * | |||
| * Licensed under the Apache License, Version 2.0 (the "License"); | |||
| * you may not use this file except in compliance with the License. | |||
| * You may obtain a copy of the License at | |||
| * | |||
| * http://www.apache.org/licenses/LICENSE-2.0 | |||
| * | |||
| * Unless required by applicable law or agreed to in writing, software | |||
| * distributed under the License is distributed on an "AS IS" BASIS, | |||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| * See the License for the specific language governing permissions and | |||
| * limitations under the License. | |||
| * ============================================================= | |||
| */ | |||
| export default { | |||
| name: 'BrushSelection', | |||
| props: { | |||
| stageWidth: Number, | |||
| stageHeight: Number, | |||
| width: Number, | |||
| height: Number, | |||
| updateBrush: Function, | |||
| brush: Object, | |||
| onBrushStart: Function, | |||
| onBrushEnd: Function, | |||
| disableDraggingSelection: { | |||
| type: Boolean, | |||
| default: false, | |||
| }, | |||
| selectionStyle: { | |||
| type: Object, | |||
| }, | |||
| }, | |||
| render(h) { | |||
| const { width, height, brush, disableDraggingSelection, selectionStyle } = this; | |||
| return ( | |||
| <rect | |||
| x={Math.min(brush.extent.x0, brush.extent.x1)} | |||
| y={Math.min(brush.extent.y0, brush.extent.y1)} | |||
| width={width} | |||
| height={height} | |||
| className='db-brush-selection' | |||
| style={{ | |||
| ...selectionStyle, | |||
| pointerEvents: brush.isBrushing || brush.activeHandle ? 'none' : 'all', | |||
| cursor: disableDraggingSelection ? null : 'move', | |||
| }} | |||
| /> | |||
| ); | |||
| }, | |||
| }; | |||
| @@ -0,0 +1,19 @@ | |||
| /** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||
| * | |||
| * Licensed under the Apache License, Version 2.0 (the "License"); | |||
| * you may not use this file except in compliance with the License. | |||
| * You may obtain a copy of the License at | |||
| * | |||
| * http://www.apache.org/licenses/LICENSE-2.0 | |||
| * | |||
| * Unless required by applicable law or agreed to in writing, software | |||
| * distributed under the License is distributed on an "AS IS" BASIS, | |||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| * See the License for the specific language governing permissions and | |||
| * limitations under the License. | |||
| * ============================================================= | |||
| */ | |||
| export { default as Brush } from './Brush'; | |||
| export { default as BrushHandle } from './BrushHandle'; | |||
| export { default as BrushCorner } from './BrushCorner'; | |||
| @@ -0,0 +1,42 @@ | |||
| /** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||
| * | |||
| * Licensed under the Apache License, Version 2.0 (the "License"); | |||
| * you may not use this file except in compliance with the License. | |||
| * You may obtain a copy of the License at | |||
| * | |||
| * http://www.apache.org/licenses/LICENSE-2.0 | |||
| * | |||
| * Unless required by applicable law or agreed to in writing, software | |||
| * distributed under the License is distributed on an "AS IS" BASIS, | |||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| * See the License for the specific language governing permissions and | |||
| * limitations under the License. | |||
| * ============================================================= | |||
| */ | |||
| import cx from 'classnames'; | |||
| export default { | |||
| name: 'Group', | |||
| functional: true, | |||
| render(h, context) { | |||
| const { props, children } = context; | |||
| const { | |||
| top = 0, | |||
| left = 0, | |||
| transform, | |||
| className, | |||
| ...otherProps | |||
| } = props; | |||
| return ( | |||
| <g | |||
| class={cx('db-group', className)} | |||
| transform={transform || `translate(${left}, ${top})`} | |||
| {...otherProps} | |||
| > | |||
| {children} | |||
| </g> | |||
| ); | |||
| }, | |||
| }; | |||
| @@ -0,0 +1,18 @@ | |||
| /** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||
| * | |||
| * Licensed under the Apache License, Version 2.0 (the "License"); | |||
| * you may not use this file except in compliance with the License. | |||
| * You may obtain a copy of the License at | |||
| * | |||
| * http://www.apache.org/licenses/LICENSE-2.0 | |||
| * | |||
| * Unless required by applicable law or agreed to in writing, software | |||
| * distributed under the License is distributed on an "AS IS" BASIS, | |||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| * See the License for the specific language governing permissions and | |||
| * limitations under the License. | |||
| * ============================================================= | |||
| */ | |||
| export { default as Group } from './group'; | |||
| export * from './brush'; | |||
| @@ -18,7 +18,7 @@ module.exports = { | |||
| minIO: { | |||
| development: { | |||
| config: { | |||
| endPoint: '10.5.26.234', | |||
| endPoint: '', // MinIO 服务地址 | |||
| port: 9000, | |||
| useSSL: false, | |||
| }, | |||
| @@ -26,7 +26,7 @@ module.exports = { | |||
| }, | |||
| test: { | |||
| config: { | |||
| endPoint: '10.5.26.234', | |||
| endPoint: '', | |||
| port: 9000, | |||
| useSSL: false, | |||
| }, | |||
| @@ -34,7 +34,7 @@ module.exports = { | |||
| }, | |||
| production: { | |||
| config: { | |||
| endPoint: '121.41.72.89', | |||
| endPoint: '', | |||
| port: 9000, | |||
| useSSL: false, | |||
| }, | |||
| @@ -21,26 +21,53 @@ function useBrush() { | |||
| const state = reactive({ | |||
| start: undefined, | |||
| end: undefined, | |||
| extent: undefined, | |||
| isBrushing: false, | |||
| }); | |||
| function getExtent(start, end) { | |||
| const x0 = Math.min(start.x, end.x); | |||
| const x1 = Math.max(start.x, end.x); | |||
| const y0 = Math.min(start.y, end.y); | |||
| const y1 = Math.max(start.y, end.y); | |||
| return { | |||
| x0, | |||
| x1, | |||
| y0, | |||
| y1, | |||
| }; | |||
| } | |||
| function onBrushStart({ x, y }) { | |||
| Object.assign(state, { | |||
| start: { x, y }, | |||
| isBrushing: true, | |||
| end: undefined, | |||
| extent: undefined, | |||
| }); | |||
| } | |||
| function onBrushMove({ x, y }) { | |||
| const extent = getExtent(state.start, {x, y}); | |||
| Object.assign(state, { | |||
| end: { x, y }, | |||
| extent, | |||
| }); | |||
| } | |||
| function onBrushEnd() { | |||
| const { extent } = state; | |||
| Object.assign(state, { | |||
| isBrushing: false, | |||
| start: { | |||
| x: extent.x0, | |||
| y: extent.y0, | |||
| }, | |||
| end: { | |||
| x: extent.x1, | |||
| y: extent.y1, | |||
| }, | |||
| }); | |||
| } | |||
| @@ -48,15 +75,26 @@ function useBrush() { | |||
| Object.assign(state, { | |||
| start: undefined, | |||
| end: undefined, | |||
| extent: undefined, | |||
| isBrushing: false, | |||
| }); | |||
| } | |||
| function updateBrush(updater, callback) { | |||
| const newState = updater(state); | |||
| Object.assign(state, newState); | |||
| if(typeof callback === 'function') { | |||
| callback(state); | |||
| } | |||
| } | |||
| return ({ | |||
| brush: state, | |||
| getExtent, | |||
| onBrushStart, | |||
| onBrushMove, | |||
| onBrushEnd, | |||
| updateBrush, | |||
| onBrushReset, | |||
| }); | |||
| } | |||
| @@ -66,8 +66,13 @@ function useZoom(initialZoom, wrapperRef, options = { | |||
| updateZoom({ newZoom: 1, zoom: 1, zoomX: 0, zoomY: 0 }); | |||
| } | |||
| function getZoom(){ | |||
| return state; | |||
| } | |||
| return ({ | |||
| zoom: state, | |||
| getZoom, | |||
| setZoom, | |||
| zoomIn, | |||
| zoomOut, | |||
| @@ -91,7 +91,8 @@ export default { | |||
| } else { | |||
| // 不存在历史记录 | |||
| // 或者新开 Tab | |||
| if (!window.history.length || window.history.length === 1) { | |||
| // chrome 新开tab页面历史记录为 2 | |||
| if (!window.history.length || window.history.length <= 2) { | |||
| this.$router.push('/'); | |||
| return; | |||
| } | |||
| @@ -59,6 +59,7 @@ export default { | |||
| font-size: 0.7rem !important; | |||
| color: #7a8b9a; | |||
| letter-spacing: 0.8px; | |||
| pointer-events: none; | |||
| background: none repeat scroll 0 0 white; | |||
| border-top: 1px solid #e7eaec; | |||
| } | |||
| @@ -33,15 +33,15 @@ | |||
| <el-col :span="12"> | |||
| <el-popover | |||
| placement="bottom" | |||
| trigger="click" | |||
| placement="bottom" | |||
| trigger="click" | |||
| > | |||
| <img src="../../../assets/images/dingtalk.jpg" width="200" alt=""> | |||
| <div slot="reference" class="feed-action"> | |||
| <i class="el-icon-chat-dot-square" /> | |||
| <div>钉钉交流群</div> | |||
| </div> | |||
| </el-popover> | |||
| </el-popover> | |||
| </el-col> | |||
| </el-row> | |||
| @@ -20,6 +20,7 @@ | |||
| const state = { | |||
| activePanel: 0, | |||
| activePanelLabelGroup: 0, | |||
| }; | |||
| const mutations = { | |||
| @@ -29,6 +30,12 @@ const mutations = { | |||
| RESET_PANEL: (state) => { | |||
| state.activePanel = 0; | |||
| }, | |||
| TOGGLE_PANEL_LABEL_GROUP: (state, panel) => { | |||
| state.activePanelLabelGroup = panel; | |||
| }, | |||
| RESET_PANEL_LABEL_GROUP: (state) => { | |||
| state.activePanelLabelGroup = 0; | |||
| }, | |||
| }; | |||
| const actions = { | |||
| @@ -38,6 +45,12 @@ const actions = { | |||
| resetPanel({ commit }) { | |||
| commit('RESET_PANEL'); | |||
| }, | |||
| togglePanelLabelGroup({ commit }, panel) { | |||
| commit('TOGGLE_PANEL_LABEL_GROUP', panel); | |||
| }, | |||
| resetPanelLabelGroup({ commit }) { | |||
| commit('RESET_PANEL_LABEL_GROUP'); | |||
| }, | |||
| }; | |||
| export default { | |||
| @@ -15,9 +15,17 @@ | |||
| */ | |||
| import { format, parseISO, isDate } from 'date-fns'; | |||
| import { isEqual, isPlainObject } from 'lodash'; | |||
| import { isEqual, isPlainObject, isNil, findIndex, findLastIndex } from 'lodash'; | |||
| import { nanoid } from 'nanoid'; | |||
| const chroma = require('chroma-js'); | |||
| export const duplicate = (arr, callback) => { | |||
| const index = findIndex(arr, callback); | |||
| const lastIndex = findLastIndex(arr, callback); | |||
| return index !== lastIndex; | |||
| }; | |||
| // 合并多个属性 | |||
| export function mergeProps(...args) { | |||
| const props = {}; | |||
| @@ -144,3 +152,19 @@ export const identity = d => d; | |||
| export const isEqualByProp = (arr1, arr2, prop) => { | |||
| return isEqual(arr1.map(d => d[prop]), arr2.map(d => d[prop])); | |||
| }; | |||
| // 根据背景色深浅来设置颜色 | |||
| export const colorByLuminance = (color) => { | |||
| if(isNil(color) || color === '') { | |||
| return '#333'; | |||
| } | |||
| const colorMap = { | |||
| dark: '#333', | |||
| light: '#fff', | |||
| }; | |||
| const luminance = chroma(color).luminance(); | |||
| const theme = luminance < 0.5 ? 'light' : 'dark'; | |||
| return colorMap[theme]; | |||
| }; | |||
| export { chroma }; | |||
| @@ -25,10 +25,10 @@ export const getCursorPosition = (el, event, options = {}) => { | |||
| }; | |||
| // 根据 d3-zoom 获取缩放后的相对位置 | |||
| export const getZoomPosition = (el, originPosition = []) => { | |||
| export const getZoomPosition = (el, {x, y}) => { | |||
| const transform = zoomTransform(el); | |||
| // const invertPosition = transform.invert(originPosition) | |||
| return [originPosition[0] / transform.k, originPosition[1] / transform.k]; | |||
| return { x: x / transform.k, y: y / transform.k }; | |||
| // return invertPosition | |||
| }; | |||
| @@ -60,6 +60,22 @@ export const generateBbox = (brush) => { | |||
| }; | |||
| }; | |||
| // Bbox 转为 extent | |||
| export const bbox2Extent = bbox => ({ | |||
| x0: bbox.x, | |||
| y0: bbox.y, | |||
| x1: bbox.x + bbox.width, | |||
| y1: bbox.y + bbox.height, | |||
| }); | |||
| // 将 extent 转为 bbox | |||
| export const extent2Bbox = extent => ({ | |||
| x: extent.x0, | |||
| y: extent.y0, | |||
| width: extent.x1 - extent.x0, | |||
| height: extent.y1 - extent.y0, | |||
| }); | |||
| // 解析bbox | |||
| export const parseBbox = (bbox = []) => { | |||
| if (!bbox.length) return null; | |||
| @@ -97,3 +113,32 @@ export function getStyle(el, property) { | |||
| .getPropertyValue(property) | |||
| .replace('px', ''); | |||
| } | |||
| /** | |||
| * 向上找到原始 svg 元素 | |||
| * @param {[type]} node [节点] | |||
| * @param {[type]} event [事件对象] | |||
| */ | |||
| // eslint-disable-next-line | |||
| export const findAncestorSvg = (node, event) => { | |||
| // 检测是否有参数传入 | |||
| if (!node) return null; | |||
| // 如果只有一个参数 | |||
| if (node.target) { | |||
| event = null; | |||
| // 当前元素的 svg 包裹元素 | |||
| node = node.target.ownerSVGElement; | |||
| } | |||
| // 向上一直遍历,直到找到 svg 元素 | |||
| while (node.ownerSVGElement) { | |||
| node = node.ownerSVGElement; | |||
| } | |||
| return node; | |||
| }; | |||
| export const raise = (arr, raiseIndex) => { | |||
| return ([...arr.slice(0, raiseIndex), ...arr.slice(raiseIndex + 1), arr[raiseIndex]]); | |||
| }; | |||
| @@ -71,6 +71,10 @@ service.interceptors.request.use( | |||
| service.interceptors.response.use( | |||
| response => { | |||
| const res = response.data; | |||
| // 如果请求的返回类型是流,则直接返回 data | |||
| if (response.config.responseType === 'blob') { | |||
| return res; | |||
| } | |||
| // if the custom code is not 200, it is judged as an error. | |||
| if (res.code !== 200) { | |||
| if (isWhiteList(response.config.url)) { | |||
| @@ -18,6 +18,8 @@ | |||
| * utils, 通用方法 | |||
| */ | |||
| import { nanoid } from 'nanoid'; | |||
| /** | |||
| * Parse the time to string | |||
| * @param {(Object|string|number)} time | |||
| @@ -252,5 +254,92 @@ export function stringIsValidPythonVariable(str) { | |||
| } | |||
| const pattern = /^[_a-zA-Z][_a-zA-Z0-9]*$/; | |||
| return pattern.test(str); | |||
| } | |||
| const _toTree = (data) => { | |||
| const result = []; | |||
| if (!Array.isArray(data)) { | |||
| return result; | |||
| } | |||
| data.forEach(item => { | |||
| delete item.children; | |||
| }); | |||
| const map = {}; | |||
| data.forEach(item => { | |||
| map[item.id] = item; | |||
| }); | |||
| data.forEach(item => { | |||
| const parent = map[item.pid]; | |||
| if (parent) { | |||
| (parent.children || (parent.children = [])).push(item); | |||
| } else { | |||
| result.push(item); | |||
| } | |||
| }); | |||
| return result; | |||
| }; | |||
| /** | |||
| * minio数据转成树形结构 | |||
| * @param {string} filepath minio的路径 | |||
| * @returns {list} [treeList, expandedKeys] 树形结构,和默认展开的元素 | |||
| */ | |||
| export const getTreeListFromFilepath = async (filepath) => { | |||
| // 1,获取minio的数据 | |||
| const tmp = await window.minioClient.listObjects(filepath); | |||
| if(!tmp || !tmp.length){ | |||
| return [[], []]; | |||
| } | |||
| const minioList = []; | |||
| for (const item of tmp) { | |||
| minioList.push(item.name.replace(filepath, "")); | |||
| } | |||
| // 2 转成平级数据 | |||
| const dataList = []; | |||
| const keyList = []; // 去重用 | |||
| for (const filename of minioList) { | |||
| const list = filename.split("/"); | |||
| list.forEach((item, index) => { | |||
| const p = { | |||
| pid: index === 0 ? 9999 : `${index - 1}_${list[index - 1]}`, | |||
| id: `${index}_${item}`, | |||
| name: item, | |||
| originPath: filepath + list.slice(0,index+1).join('/'), | |||
| isFile: index === list.length-1, | |||
| }; | |||
| const key = `${p.pid}_${p.id}`; | |||
| if (keyList.indexOf(key) === -1) { | |||
| keyList.push(key); | |||
| dataList.push(p); | |||
| } | |||
| }); | |||
| } | |||
| // 2.1 最外层单独封装一层 | |||
| const tmp2 = filepath.split('/'); | |||
| const wrapperNodeName = tmp2[tmp2.length-2]; | |||
| const wrapperNode = { | |||
| pid: 0, | |||
| id: 9999, | |||
| name: wrapperNodeName, | |||
| originPath: filepath, | |||
| isFile: false, | |||
| }; | |||
| dataList.push(wrapperNode); | |||
| // 3 转成树形结构 | |||
| const treeList = _toTree([].concat(dataList)); | |||
| // 4 显示默认展开的层级,默认二级 | |||
| const expandedKeys = []; | |||
| for (const item of treeList) { | |||
| expandedKeys.push(item.id); | |||
| for(const item2 of item.children){ | |||
| expandedKeys.push(item2.id); | |||
| } | |||
| } | |||
| // 返回数据 | |||
| return [treeList, expandedKeys]; | |||
| }; | |||
| export function getUniqueId() { | |||
| return parseTime(new Date(), '{y}{m}{d}{h}{i}{s}{S}') + nanoid(4); | |||
| } | |||
| @@ -18,6 +18,7 @@ | |||
| * validate,校验函数 | |||
| */ | |||
| import { isPlainObject } from 'lodash'; | |||
| import { ValidationProvider, ValidationObserver, extend } from 'vee-validate'; | |||
| import { required } from 'vee-validate/dist/rules'; | |||
| @@ -268,3 +269,29 @@ export function validateRunCommand(rule, value, callback) { | |||
| callback(new Error('请输入正确的启动命令')); | |||
| } | |||
| } | |||
| // 校验标签组基本方法 | |||
| export const validateLabelsUtil = (value) => { | |||
| if(!isPlainObject(value)) { | |||
| return '标签不能为空'; | |||
| } | |||
| if(!value.name) { | |||
| return '标签名称不能为空'; | |||
| } | |||
| if(!value.color) { | |||
| return '标签颜色不能为空'; | |||
| } | |||
| if(!/^#[0-9A-F]{6}$/i.test(value.color)) { | |||
| return '标签颜色格式不对'; | |||
| } | |||
| return ''; | |||
| }; | |||
| export function validateLabel(rule, value, callback) { | |||
| const validateResult = validateLabelsUtil(value); | |||
| if(validateResult !== '') { | |||
| callback(new Error(validateResult)); | |||
| return; | |||
| } | |||
| callback(); | |||
| } | |||
| @@ -21,6 +21,7 @@ | |||
| <cdOperation :addProps="operationProps"> | |||
| <span slot="right"> | |||
| <el-input | |||
| id="algorithmName" | |||
| v-model="localQuery.algorithmName" | |||
| clearable | |||
| placeholder="请输入算法名称或 ID" | |||
| @@ -30,6 +31,7 @@ | |||
| @clear="crud.toQuery" | |||
| /> | |||
| <el-input | |||
| id="algorithmUsage" | |||
| v-model="localQuery.algorithmUsage" | |||
| clearable | |||
| placeholder="请输入算法用途" | |||
| @@ -43,8 +45,8 @@ | |||
| </cdOperation> | |||
| <div> | |||
| <el-tabs v-model="active" class="eltabs-inlineblock" @tab-click="handleClick"> | |||
| <el-tab-pane label="我的算法" name="1" /> | |||
| <el-tab-pane label="预置算法" name="2" /> | |||
| <el-tab-pane id="tab_0" label="我的算法" name="1" /> | |||
| <el-tab-pane id="tab_1" label="预置算法" name="2" /> | |||
| </el-tabs> | |||
| </div> | |||
| </div> | |||
| @@ -82,19 +84,19 @@ | |||
| </el-table-column> | |||
| <el-table-column label="操作" width="370px" fixed="right"> | |||
| <template slot-scope="scope"> | |||
| <el-button v-if="isCustom" type="text" @click.stop="goEdit(scope.row)">在线编辑</el-button> | |||
| <el-button type="text" @click.stop="goTraining(scope.row)">创建训练任务</el-button> | |||
| <el-button type="text" @click.stop="goDownload(scope.row)">下载</el-button> | |||
| <el-button v-if="isPreset" type="text" @click.stop="doFork(scope.row)">fork</el-button> | |||
| <el-button v-if="isCustom" :id="`goEdit_`+scope.$index" type="text" @click.stop="goEdit(scope.row)">在线编辑</el-button> | |||
| <el-button :id="`goTraining_`+scope.$index" type="text" @click.stop="goTraining(scope.row)">创建训练任务</el-button> | |||
| <el-button :id="`goDownload_`+scope.$index" type="text" @click.stop="goDownload(scope.row)">下载</el-button> | |||
| <el-button v-if="isPreset" :id="`doFork_`+scope.$index" type="text" @click.stop="doFork(scope.row)">fork</el-button> | |||
| <el-dropdown v-if="isCustom"> | |||
| <el-button type="text" style="margin-left: 10px;" @click.stop> | |||
| 更多<i class="el-icon-arrow-down el-icon--right" /> | |||
| </el-button> | |||
| <el-dropdown-menu slot="dropdown"> | |||
| <el-dropdown-item @click.native="doFork(scope.row)"> | |||
| <el-dropdown-item :id="`doFork_`+scope.$index" @click.native="doFork(scope.row)"> | |||
| <el-button type="text">fork</el-button> | |||
| </el-dropdown-item> | |||
| <el-dropdown-item v-if="isCustom" @click.native="doDelete(scope.row.id)"> | |||
| <el-dropdown-item v-if="isCustom" :id="`doDelete_`+scope.$index" @click.native="doDelete(scope.row.id)"> | |||
| <el-button type="text">删除</el-button> | |||
| </el-dropdown-item> | |||
| </el-dropdown-menu></el-dropdown> | |||
| @@ -124,6 +126,7 @@ | |||
| > | |||
| <el-form-item label="名称" prop="algorithmName"> | |||
| <el-input | |||
| id="algorithmName" | |||
| v-model.trim="form.algorithmName" | |||
| placeholder | |||
| maxlength="32" | |||
| @@ -133,6 +136,7 @@ | |||
| </el-form-item> | |||
| <el-form-item label="描述" prop="description"> | |||
| <el-input | |||
| id="description" | |||
| v-model="form.description" | |||
| type="textarea" | |||
| :rows="3" | |||
| @@ -144,6 +148,7 @@ | |||
| </el-form-item> | |||
| <el-form-item label="算法用途" prop="algorithmUsage"> | |||
| <el-select | |||
| id="algorithmUsage" | |||
| v-model="form.algorithmUsage" | |||
| placeholder="请选择或输入算法用途" | |||
| filterable | |||
| @@ -169,24 +174,34 @@ | |||
| </el-form-item> | |||
| <el-form-item v-show="formType !== 'fork'" ref="codeDir" label="上传代码包" prop="codeDir"> | |||
| <div v-if="formType === 'fork' && form.codeDir">源代码包: | |||
| <el-button type="text" @click="goDownload(form)">下载</el-button> | |||
| <el-button id="goDownload" type="text" @click="goDownload(form)">下载</el-button> | |||
| </div> | |||
| <upload-inline | |||
| v-if="crud.status.cu > 0" | |||
| ref="upload" | |||
| action="fakeApi" | |||
| accept=".zip" | |||
| :acceptSize="100" | |||
| :acceptSize="1024" | |||
| :acceptSizeFormat="(size) => `${size/1024} GB`" | |||
| list-type="text" | |||
| :show-file-count="false" | |||
| :params="uploadParams" | |||
| :auto-upload="true" | |||
| :hash="false" | |||
| :limit="1" | |||
| :on-remove="onFileRemove" | |||
| @uploadStart="uploadStart" | |||
| @uploadSuccess="uploadSuccess" | |||
| @uploadError="uploadError" | |||
| /> | |||
| <div v-if="uploading"><i class="el-icon-loading" />算法上传中...</div> | |||
| <upload-progress | |||
| v-if="uploading" | |||
| :progress="progress" | |||
| :color="customColors" | |||
| :status="status" | |||
| :size="size" | |||
| @onSetProgress="onSetProgress" | |||
| /> | |||
| </el-form-item> | |||
| <el-form-item label="训练输出" prop="isTrainOut" class="is-required"> | |||
| <el-tooltip | |||
| @@ -198,8 +213,18 @@ | |||
| <i class="el-icon-warning-outline primary f18 vm" /> | |||
| </el-tooltip> | |||
| </el-form-item> | |||
| <el-form-item label="断点续训"> | |||
| <el-tooltip | |||
| class="item" | |||
| effect="dark" | |||
| content="请确保代码中包含“model_load_dir”参数用于接收训练的断点路径" | |||
| placement="right" | |||
| > | |||
| <i class="el-icon-warning-outline primary f18 vm" /> | |||
| </el-tooltip> | |||
| </el-form-item> | |||
| <el-form-item label="日志输出" prop="isTrainLog"> | |||
| <el-checkbox v-model="form.isTrainLog" /> | |||
| <el-checkbox id="isTrainLog" v-model="form.isTrainLog" /> | |||
| <el-tooltip | |||
| v-show="form.isTrainLog" | |||
| class="item" | |||
| @@ -211,7 +236,7 @@ | |||
| </el-tooltip> | |||
| </el-form-item> | |||
| <el-form-item label="可视化日志" prop="isVisualizedLog"> | |||
| <el-checkbox v-model="form.isVisualizedLog" /> | |||
| <el-checkbox id="isVisualizedLog" v-model="form.isVisualizedLog" /> | |||
| <el-tooltip | |||
| v-show="form.isVisualizedLog" | |||
| class="item" | |||
| @@ -238,9 +263,7 @@ | |||
| </template> | |||
| <script> | |||
| import { nanoid } from 'nanoid'; | |||
| import { downloadZipFromObjectPath, parseTime, validateNameWithHyphen } from '@/utils'; | |||
| import { downloadZipFromObjectPath, validateNameWithHyphen, getUniqueId } from '@/utils'; | |||
| import CRUD, { presenter, header, form, crud } from '@crud/crud'; | |||
| import cdOperation from '@crud/CD.operation'; | |||
| import rrOperation from '@crud/RR.operation'; | |||
| @@ -251,6 +274,7 @@ import { createNotebook, getNotebookAddress } from '@/api/development/notebook'; | |||
| import BaseModal from '@/components/BaseModal'; | |||
| import AlgorithmDetail from '@/components/Training/algorithmDetail'; | |||
| import UploadInline from '@/components/UploadForm/inline'; | |||
| import UploadProgress from '@/components/UploadProgress'; | |||
| const defaultForm = { | |||
| id: null, | |||
| @@ -275,6 +299,7 @@ export default { | |||
| AlgorithmDetail, | |||
| UploadInline, | |||
| rrOperation, | |||
| UploadProgress, | |||
| }, | |||
| cruds() { | |||
| return CRUD({ | |||
| @@ -338,8 +363,14 @@ export default { | |||
| objectPath: null, // 对象存储路径 | |||
| }, | |||
| disableEdit: false, | |||
| keepAskAddress: false, | |||
| uploading: false, | |||
| progress: 0, | |||
| size: 0, | |||
| customColors: [ | |||
| {color: '#909399', percentage: 40}, | |||
| {color: '#e6a23c', percentage: 80}, | |||
| {color: '#67c23a', percentage: 100}, | |||
| ], | |||
| }; | |||
| }, | |||
| computed: { | |||
| @@ -362,6 +393,9 @@ export default { | |||
| user() { | |||
| return this.$store.getters.user; | |||
| }, | |||
| status() { | |||
| return this.progress === 100 ? 'success' : null; | |||
| }, | |||
| }, | |||
| mounted() { | |||
| this.getAlgorithmUsages(); | |||
| @@ -370,7 +404,7 @@ export default { | |||
| this.updateObjectPath(); | |||
| }, | |||
| beforeDestroy() { | |||
| this.keepAskAddress = false; | |||
| this.disableEdit = false; | |||
| }, | |||
| methods: { | |||
| // handle | |||
| @@ -386,6 +420,7 @@ export default { | |||
| }, | |||
| onDialogClose() { | |||
| this.$refs.upload.formRef.reset(); | |||
| this.uploading = false; | |||
| }, | |||
| onAlgorithmUsageChange(value) { | |||
| const usageRes = this.algorithmUsageList.find(usage => usage.auxInfo === value); | |||
| @@ -393,14 +428,28 @@ export default { | |||
| this.createAlgorithmUsage(value); | |||
| } | |||
| }, | |||
| uploadStart() { | |||
| this.uploading = true; | |||
| }, | |||
| uploadSuccess(res) { | |||
| this.form.codeDir = res[0].data.objectName; | |||
| onFileRemove() { | |||
| this.form.codeDir = null; | |||
| this.uploading = false; | |||
| this.$refs.codeDir.validate('manual'); | |||
| }, | |||
| uploadStart(files) { | |||
| this.updateObjectPath(); | |||
| [ this.uploading, this.size, this.progress ] = [ true, files.size, 0 ]; | |||
| }, | |||
| onSetProgress(val) { | |||
| this.progress += val; | |||
| }, | |||
| uploadSuccess(res) { | |||
| this.progress = 100; | |||
| setTimeout(() => { | |||
| this.uploading = false; | |||
| }, 1000); | |||
| if (this.uploading) { | |||
| this.form.codeDir = res[0].data.objectName; | |||
| this.$refs.codeDir.validate('manual'); | |||
| } | |||
| }, | |||
| uploadError() { | |||
| this.$message({ | |||
| message: '上传文件失败', | |||
| @@ -415,13 +464,13 @@ export default { | |||
| }, | |||
| goTraining(item) { | |||
| this.$router.push({ | |||
| path: '/training/jobAdd', | |||
| path: '/training/jobadd', | |||
| name: 'jobAdd', | |||
| params: { | |||
| from: 'algorithm', | |||
| params: { | |||
| algorithmId: item.id, | |||
| algorithmSource: this.active, | |||
| algorithmSource: Number(this.active), | |||
| algorithmUsage: item.algorithmUsage, | |||
| runParams: item.runParams, | |||
| imageNameProject: item.imageNameProject, | |||
| @@ -432,7 +481,7 @@ export default { | |||
| }); | |||
| }, | |||
| goDownload(algorithm) { | |||
| downloadZipFromObjectPath(algorithm.codeDir, `${algorithm.algorithmName }.zip`, { flat: true }); | |||
| downloadZipFromObjectPath(algorithm.codeDir, `${algorithm.algorithmName}.zip`, { flat: true }); | |||
| this.$message({ | |||
| message: '请查看下载文件', | |||
| type: 'success', | |||
| @@ -450,14 +499,10 @@ export default { | |||
| this.disableEdit = false; | |||
| }); | |||
| if (notebookInfo.status === 0 && notebookInfo.url) { | |||
| window.open(notebookInfo.url); | |||
| this.$message.success('Notebook已启动.'); | |||
| this.$router.push({ name: 'Notebook', params: { | |||
| noteBookName: notebookInfo.name, | |||
| }}); | |||
| this.openNoteBook(notebookInfo.url, notebookInfo.noteBookName); | |||
| } else { | |||
| this.keepAskAddress = true; | |||
| this.getNotebookAddress(notebookInfo.id, notebookInfo.name); | |||
| this.disableEdit = true; | |||
| this.getNotebookAddress(notebookInfo.id, notebookInfo.noteBookName); | |||
| } | |||
| }, | |||
| // op | |||
| @@ -479,26 +524,18 @@ export default { | |||
| // hook | |||
| [CRUD.HOOK.beforeToAdd]() { | |||
| this.formType = 'add'; | |||
| this.updateObjectPath(); | |||
| }, | |||
| [CRUD.HOOK.beforeRefresh]() { | |||
| this.crud.query = { ...this.localQuery}; | |||
| this.crud.query.algorithmSource = Number(this.active); | |||
| }, | |||
| getNotebookAddress(id, noteBookName) { | |||
| if (!this.keepAskAddress) { | |||
| if (!this.disableEdit) { | |||
| return; | |||
| } | |||
| this.disableEdit = true; | |||
| getNotebookAddress(id).then(url => { | |||
| if (url) { | |||
| window.open(url); | |||
| this.$message.success('Notebook已启动.'); | |||
| this.disableEdit = false; | |||
| this.keepAskAddress = false; | |||
| this.$router.push({ name: 'Notebook', params: { | |||
| noteBookName, | |||
| }}); | |||
| this.openNoteBook(url, noteBookName); | |||
| } else { | |||
| setTimeout(() => { | |||
| this.getNotebookAddress(id, noteBookName); | |||
| @@ -506,7 +543,6 @@ export default { | |||
| } | |||
| }).catch(err => { | |||
| this.disableEdit = false; | |||
| this.keepAskAddress = false; | |||
| throw new Error(err); | |||
| }); | |||
| }, | |||
| @@ -531,7 +567,15 @@ export default { | |||
| this.getAlgorithmUsages(); | |||
| }, | |||
| updateObjectPath() { | |||
| this.uploadParams.objectPath = `algorithm-manage/${this.user.id}/${parseTime(new Date(), '{y}{m}{d}{h}{i}{s}{S}') + nanoid(4)}`; | |||
| this.uploadParams.objectPath = `upload-temp/${this.user.id}/${getUniqueId()}`; | |||
| }, | |||
| openNoteBook(url, noteBookName) { | |||
| window.open(url); | |||
| this.$message.success('Notebook已启动.'); | |||
| this.disableEdit = false; | |||
| this.$router.push({ name: 'Notebook', params: { | |||
| noteBookName, | |||
| }}); | |||
| }, | |||
| }, | |||
| }; | |||
| @@ -157,40 +157,40 @@ export default { | |||
| </script> | |||
| <style rel="stylesheet/scss" lang="scss" scoped> | |||
| .dashboard-container { | |||
| padding: 24px; | |||
| color: #666; | |||
| .dashboard-container { | |||
| padding: 24px; | |||
| color: #666; | |||
| .section-title { | |||
| height: 24px; | |||
| margin: 26px 0 24px; | |||
| font-size: 18px; | |||
| font-weight: bold; | |||
| line-height: 24px; | |||
| letter-spacing: 2px; | |||
| } | |||
| .section-title { | |||
| height: 24px; | |||
| margin: 26px 0 24px; | |||
| font-size: 18px; | |||
| font-weight: bold; | |||
| line-height: 24px; | |||
| letter-spacing: 2px; | |||
| } | |||
| .section-card { | |||
| padding: 4px; | |||
| .section-card { | |||
| padding: 4px; | |||
| &:last-child { | |||
| margin-bottom: 34px; | |||
| } | |||
| &:last-child { | |||
| margin-bottom: 34px; | |||
| } | |||
| } | |||
| .card-head { | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: space-between; | |||
| height: 32px; | |||
| margin-bottom: 8px; | |||
| .card-head { | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: space-between; | |||
| height: 32px; | |||
| margin-bottom: 8px; | |||
| &-title { | |||
| height: 20px; | |||
| font-size: 14px; | |||
| font-weight: bold; | |||
| line-height: 20px; | |||
| } | |||
| &-title { | |||
| height: 20px; | |||
| font-size: 14px; | |||
| font-weight: bold; | |||
| line-height: 20px; | |||
| } | |||
| } | |||
| } | |||
| </style> | |||
| @@ -30,7 +30,7 @@ | |||
| :isTrack="isTrack" | |||
| :state="state" | |||
| :currentImg="currentImg" | |||
| :handleBrushEnd="handleBrushEnd" | |||
| :drawBboxEnd="drawBboxEnd" | |||
| :createLabel="createLabel" | |||
| :queryLabels="queryLabels" | |||
| :updateState="updateState" | |||
| @@ -65,8 +65,8 @@ import { isEmpty, isFunction, omit, isNil } from 'lodash'; | |||
| import { detail, detectFileList, queryFileOffset, queryDataEnhanceList, getEnhanceFileList } from '@/api/preparation/dataset'; | |||
| import request from '@/utils/request'; | |||
| import { generateUuid, generateBbox, endsWith, replace, remove, AssertError } from '@/utils'; | |||
| import { parseAnnotation, labelsSymbol, enhanceSymbol, stringifyAnnotations, annotationMap, transformFiles } from '../util'; | |||
| import { generateUuid, generateBbox, bbox2Extent, extent2Bbox, endsWith, replace, remove, AssertError } from '@/utils'; | |||
| import { parseAnnotation, labelsSymbol, enhanceSymbol, stringifyAnnotations, annotationMap, transformFiles, withExtent } from '../util'; | |||
| import ThumbContainer from './thumbContainer'; | |||
| import WorkSpaceContainer from './workSpaceContainer'; | |||
| @@ -89,6 +89,9 @@ export default { | |||
| const { params = {}} = $route; | |||
| const workspaceRef = ref(null); | |||
| // 加载下一页,避免重复加载 | |||
| const loadNextPageFlag = ref(false); | |||
| // 标注类型 | |||
| const isTrack = $route.name.startsWith('TrackDataset'); | |||
| // const isAnnotation = meta.type === 'annotate' | |||
| @@ -102,6 +105,7 @@ export default { | |||
| hasMore: true, // 是否有更多列表 | |||
| datasetId: Number(params.datasetId), | |||
| currentImgId: Number(params.fileId) || undefined, // 当前图片 id | |||
| rawAnnotations: [], // 原始标注集合 | |||
| annotations: [], // 标注集合 | |||
| fileInfo: null, // 文件信息 | |||
| fileId: Number($route.params.fileId), | |||
| @@ -177,8 +181,15 @@ export default { | |||
| }; | |||
| // 根据异步结果更新状态 | |||
| const updateState = (nextState) => { | |||
| Object.assign(state, nextState); | |||
| const updateState = (params) => { | |||
| // 区分函数式更新和对象更新 | |||
| if(typeof params === 'function') { | |||
| const next = params(state); | |||
| Object.assign(state, next); | |||
| return; | |||
| } | |||
| // 普通更新 | |||
| Object.assign(state, params); | |||
| }; | |||
| // 根据 labelId 获取标签颜色 | |||
| @@ -236,6 +247,7 @@ export default { | |||
| const clearHistory = () => { | |||
| updateState({ | |||
| history: [], | |||
| rawAnnotations: [], | |||
| annotations: [], | |||
| fileInfo: null, // 当前文件信息 | |||
| lastSelectedLabel: undefined, | |||
| @@ -259,7 +271,13 @@ export default { | |||
| // 当到下边界只有 2 张图片时,请求下一页数据 | |||
| // 仍然有下页 | |||
| if (index + 2 >= fileList.value.length && state.hasMore) { | |||
| queryNextPage({ offset: state.offset, type: state.fileFilterType }); | |||
| // 避免重复加载 | |||
| if(loadNextPageFlag.value === false) { | |||
| loadNextPageFlag.value = true; | |||
| queryNextPage({ offset: state.offset, type: state.fileFilterType }).then(() => { | |||
| loadNextPageFlag.value = false; | |||
| }); | |||
| } | |||
| } | |||
| }; | |||
| @@ -286,7 +304,7 @@ export default { | |||
| // 请求指定图片信息 | |||
| const queryFile = async(id) => { | |||
| const file = await request(`api/data/datasets/files/${id}/info`) || {}; | |||
| const file = await request(`api/data/datasets/files/${params.datasetId}/${id}/info`) || {}; | |||
| return file; | |||
| }; | |||
| @@ -319,7 +337,7 @@ export default { | |||
| // 保存标注 | |||
| const saveAnnotation = async(data) => { | |||
| await request.post(`api/data/datasets/files/${state.currentImgId}/annotations`, data).then(() => { | |||
| await request.post(`api/data/datasets/files/${params.datasetId}/${state.currentImgId}/annotations`, data).then(() => { | |||
| // 清空历史记录 | |||
| Object.assign(state, { history: [] }); | |||
| Message.success({ message: '保存成功', duration: 800 }); | |||
| @@ -328,7 +346,7 @@ export default { | |||
| // 人工确认标注 | |||
| const confirmAnnotation = async(data) => { | |||
| await request.post(`api/data/datasets/files/${state.currentImgId}/annotations/finish`, data).then(() => { | |||
| await request.post(`api/data/datasets/files/${params.datasetId}/${state.currentImgId}/annotations/finish`, data).then(() => { | |||
| // 清空历史记录 | |||
| Object.assign(state, { history: [] }); | |||
| // todo: 更新列表 | |||
| @@ -352,15 +370,71 @@ export default { | |||
| }); | |||
| }; | |||
| // 将绝对路径映射为相对图片路径 | |||
| const mapBrushToBbox = annotation => { | |||
| const { bbox } = annotation.data; | |||
| const { dimension } = workspaceRef.value; | |||
| // 临时变量 | |||
| let temp_bbox = {}; | |||
| // 解析 bbox 值 | |||
| const _bbox = {}; | |||
| // 当图片缩放比例小于1,当前画布尺寸会超过图片,需要截取空白尺寸 | |||
| if (dimension.scale < 1) { | |||
| const padding = { | |||
| width: dimension.svg.width - dimension.img.width * dimension.scale, | |||
| height: dimension.svg.height - dimension.img.height * dimension.scale, | |||
| }; | |||
| Object.assign(temp_bbox, { | |||
| ...bbox, | |||
| x: bbox.x - padding.width / 2, | |||
| // 垂直反向偏移 | |||
| y: bbox.y - padding.height / 2, | |||
| }); | |||
| } else { | |||
| temp_bbox = bbox; | |||
| } | |||
| for (const k in temp_bbox) { | |||
| // 根据图片缩放比例进行调整 | |||
| _bbox[k] = temp_bbox[k] / (dimension.scale || 1); | |||
| } | |||
| const updatedAnnotation = { | |||
| ...annotation, | |||
| data: { | |||
| ...annotation.data, | |||
| bbox: _bbox, | |||
| extent: bbox2Extent(_bbox), | |||
| }, | |||
| }; | |||
| return updatedAnnotation; | |||
| }; | |||
| // 保存的时候生成新的位置信息 | |||
| const rescale = (annotation) => { | |||
| const { extent } = annotation.data; | |||
| const updatedAnnotation = { | |||
| ...annotation, | |||
| data: { | |||
| ...annotation.data, | |||
| bbox: extent2Bbox(extent), | |||
| }, | |||
| }; | |||
| // _type 仅供绘画使用 | |||
| return omit(updatedAnnotation, ['__type']); | |||
| }; | |||
| // 手动画框结束 | |||
| const handleBrushEnd = (brush) => { | |||
| const drawBboxEnd = (brush) => { | |||
| const bbox = generateBbox(brush); | |||
| // 记录上一次选中的 selectLabel | |||
| const otherProps = state.lastSelectedLabel ? { | |||
| categoryId: state.lastSelectedLabel, | |||
| color: getColorLabel(state.lastSelectedLabel), | |||
| } : {}; | |||
| const annotation = { | |||
| const rawAnnotation = { | |||
| id: generateUuid(), | |||
| __type: 0, // 标识为新创建的标注 | |||
| data: { | |||
| @@ -369,6 +443,9 @@ export default { | |||
| ...otherProps, | |||
| }, | |||
| }; | |||
| // todo: 转换成标准地址(extent/bbox) | |||
| const annotation = mapBrushToBbox(rawAnnotation); | |||
| // 更新框选位置坐标 | |||
| const newAnnotation = (state.annotations || []).concat(annotation); | |||
| Object.assign(state, { | |||
| @@ -386,48 +463,6 @@ export default { | |||
| return true; | |||
| }; | |||
| // 保存的时候生成新的位置信息 | |||
| const rescale = (annotation) => { | |||
| const { __type } = annotation; | |||
| const { bbox } = annotation.data; | |||
| const { dimension } = workspaceRef.value; | |||
| // 临时变量 | |||
| let temp_bbox = {}; | |||
| // 解析 bbox 值 | |||
| const _bbox = {}; | |||
| if (__type === 0) { | |||
| // 当图片缩放比例小于1,当前画布尺寸会超过图片,需要截取空白尺寸 | |||
| if (dimension.scale < 1) { | |||
| const padding = { | |||
| width: dimension.svg.width - dimension.img.width * dimension.scale, | |||
| height: dimension.svg.height - dimension.img.height * dimension.scale, | |||
| }; | |||
| Object.assign(temp_bbox, { | |||
| ...bbox, | |||
| x: bbox.x - padding.width / 2, | |||
| // 垂直反向偏移 | |||
| // y: bbox.y - padding.height / 2 | |||
| }); | |||
| } else { | |||
| temp_bbox = bbox; | |||
| } | |||
| for (const k in temp_bbox) { | |||
| // 根据图片缩放比例进行调整 | |||
| _bbox[k] = temp_bbox[k] / (dimension.scale || 1); | |||
| } | |||
| } | |||
| const updatedAnnotation = { | |||
| ...annotation, | |||
| data: { | |||
| ...annotation.data, | |||
| bbox: __type === 0 ? _bbox : bbox, | |||
| }, | |||
| }; | |||
| // _type 仅供绘画使用 | |||
| return omit(updatedAnnotation, ['__type']); | |||
| }; | |||
| // 保存标注 | |||
| const handleSave = () => { | |||
| const isValid = state.annotations.every(checkAnnotationValid); | |||
| @@ -580,6 +615,8 @@ export default { | |||
| let { result: files } = rawFile.value; | |||
| const { __offset__, page = {}} = rawFile.value; | |||
| // 同步当前文件的偏移 | |||
| state.offset = __offset__; | |||
| // 自定义分页 | |||
| // 当前条数小于每页可返回的总条数,向上补齐 | |||
| const availableSize = Math.min(page.size, page.total); | |||
| @@ -610,15 +647,16 @@ export default { | |||
| updateState(nextState); | |||
| // 根据第一个文件是否携带数据增强结果来决定是否展示 | |||
| const firstEnhanceList = await getEnhanceFileList(firstFile.id); | |||
| const firstEnhanceList = await getEnhanceFileList(params.datasetId, firstFile.id); | |||
| // 更新当前图片 | |||
| const { file, annotations } = await updateImageInfo(activeFileId, labels); | |||
| updateState({ | |||
| currentImgId: file.id, | |||
| fileInfo: file, | |||
| annotations, | |||
| hasEnhanceRecord: firstEnhanceList.length > 0, | |||
| rawAnnotations: annotations, | |||
| annotations: withExtent(annotations), | |||
| hasEnhanceRecord: !isNil(firstEnhanceList), | |||
| }); | |||
| }); | |||
| @@ -632,6 +670,7 @@ export default { | |||
| watch(() => [state.currentImgId, state.timestamp], async() => { | |||
| const imgId = state.currentImgId; | |||
| updateState({ | |||
| rawAnnotations: [], | |||
| annotations: [], | |||
| fileInfo: null, | |||
| }); | |||
| @@ -646,7 +685,8 @@ export default { | |||
| gotoFileDetail(imgId); | |||
| // 清理数据 | |||
| updateState({ | |||
| annotations, | |||
| rawAnnotations: annotations, | |||
| annotations: withExtent(annotations), | |||
| fileInfo: file, | |||
| }); | |||
| } | |||
| @@ -663,7 +703,7 @@ export default { | |||
| currentImg, | |||
| handleSelection, | |||
| handleBrushStart, | |||
| handleBrushEnd, | |||
| drawBboxEnd, | |||
| handleSave, | |||
| handleConfirm, | |||
| gotoFileDetail, | |||
| @@ -102,7 +102,7 @@ export default { | |||
| }; | |||
| const withEdit = (item, isEdit = false) => { | |||
| const { categoryId, track_id } = item.data; | |||
| const { categoryId, track_id } = item.data || {}; | |||
| // 获取到分类标签名 | |||
| const labelName = rLabels.value[categoryId]; | |||
| const labelNameTxt = labelName ? `${labelName}_` : ''; | |||
| @@ -151,7 +151,7 @@ export default { | |||
| watch(() => props.fileId, async(next) => { | |||
| if (next) { | |||
| const enhanceFileList = await getEnhanceFileList(next); | |||
| const enhanceFileList = await getEnhanceFileList(props.datasetId,next); | |||
| const isOrigin = !!enhanceFileList.length; // 被增强 | |||
| Object.assign(state, { | |||
| isOrigin, | |||
| @@ -17,13 +17,35 @@ | |||
| <template> | |||
| <div class="workspace-settings"> | |||
| <el-form label-position="top" @submit.native.prevent> | |||
| <el-form-item v-if="state.datasetInfo.value.labelGroupId" label="标签组" style="margin-bottom: 0;"> | |||
| <div style="margin-top: -10px;"> | |||
| <span class="vm">{{ state.datasetInfo.value.labelGroupName }} </span> | |||
| <el-link | |||
| target="_blank" | |||
| type="primary" | |||
| :underline="false" | |||
| class="vm" | |||
| :href="`/data/labelgroup/detail?id=${state.datasetInfo.value.labelGroupId}`" | |||
| > | |||
| 查看详情 | |||
| </el-link> | |||
| </div> | |||
| </el-form-item> | |||
| <SelectLabel | |||
| v-if="!isPresetLabel" | |||
| :dataSource="api.systemLabels" | |||
| :handleLabelChange="handleLabelChange" | |||
| @postLabel="postLabel" | |||
| /> | |||
| <LabelList :labels="labels" /> | |||
| <LabelList | |||
| :labels="labels" | |||
| :editLabel="edit" | |||
| :annotations="state.annotations.value" | |||
| :currentAnnotationId="api.currentAnnotationId" | |||
| :updateState="updateState" | |||
| :getColorLabel="getColorLabel" | |||
| :findRowIndex="findRowIndex" | |||
| /> | |||
| <Annotations | |||
| :annotations="state.annotations.value" | |||
| :currentAnnotationId="state.currentAnnotationId.value" | |||
| @@ -55,10 +77,10 @@ | |||
| <script> | |||
| import { Message } from 'element-ui'; | |||
| import { inject, reactive, onMounted, computed } from '@vue/composition-api'; | |||
| import { inject, watch, reactive, onMounted, computed } from '@vue/composition-api'; | |||
| import { isNil } from 'lodash'; | |||
| import { getAutoLabels } from '@/api/preparation/datalabel'; | |||
| import { getAutoLabels, editLabel } from '@/api/preparation/datalabel'; | |||
| import { labelsSymbol } from '@/views/dataset/util'; | |||
| import SelectLabel from './selectLabel'; | |||
| @@ -90,6 +112,7 @@ export default { | |||
| const { createLabel, updateState, queryLabels } = props; | |||
| const api = reactive({ | |||
| newLabel: undefined, | |||
| currentAnnotationId: undefined, | |||
| }); | |||
| // 当前所有标签信息 | |||
| const labels = inject(labelsSymbol); | |||
| @@ -103,21 +126,13 @@ export default { | |||
| }); | |||
| }; | |||
| const addLabel = (label) => { | |||
| api.newLabel = label; | |||
| // 编辑标签 | |||
| const edit = (labelId, data) => { | |||
| return editLabel(labelId, data).then(refreshLabel); | |||
| }; | |||
| const handleLabelChange = value => { | |||
| // 新建标签 | |||
| if (!isNil(value)) { | |||
| // 如果不是系统标签,才会选择新建 | |||
| if (api.systemLabels.findIndex(d => d.value === value) === -1) { | |||
| addLabel(value); | |||
| } else { | |||
| const systemLabel = api.systemLabels.find(d => d.value === value) || {}; | |||
| systemLabel.label && addLabel(systemLabel.label); | |||
| } | |||
| } | |||
| const addLabel = (label) => { | |||
| api.newLabel = label; | |||
| }; | |||
| const postLabel = () => { | |||
| @@ -131,6 +146,24 @@ export default { | |||
| api.newLabel = undefined; | |||
| refreshLabel(); | |||
| }); | |||
| } else { | |||
| Message.warning('请选择标签'); | |||
| } | |||
| }; | |||
| const handleLabelChange = (value, callback) => { | |||
| // 新建标签 | |||
| if (!isNil(value)) { | |||
| // 如果不是系统标签,才会选择新建 | |||
| if (api.systemLabels.findIndex(d => d.value === value) === -1) { | |||
| addLabel(value); | |||
| // 新建标签直接触发创建 | |||
| postLabel(); | |||
| typeof callback === 'function' && callback(); | |||
| } else { | |||
| const systemLabel = api.systemLabels.find(d => d.value === value) || {}; | |||
| systemLabel.label && addLabel(systemLabel.label); | |||
| } | |||
| } | |||
| }; | |||
| @@ -176,8 +209,14 @@ export default { | |||
| updateState(newState); | |||
| }; | |||
| // 使用的是预置标签时type大于1,目前自定义标签type为0,自动标注标签为1 | |||
| const isPresetLabel = computed(() => labels.value && labels.value[0] && labels.value[0].type > 1); | |||
| // labelGroupType 标签组类型:0: private 私有标签组, 1:public 公开标签组 | |||
| const isPresetLabel = computed(() => props.state.labelGroupType === 1); | |||
| watch(() => props.state, (next) => { | |||
| if ('currentAnnotationId' in next) { | |||
| api.currentAnnotationId = next.currentAnnotationId || []; | |||
| } | |||
| }); | |||
| onMounted(() => { | |||
| getSystemLabel(); | |||
| @@ -190,6 +229,8 @@ export default { | |||
| toggleShowId, | |||
| labels, | |||
| postLabel, | |||
| addLabel, | |||
| edit, | |||
| handleLabelChange, | |||
| isPresetLabel, | |||
| }; | |||
| @@ -0,0 +1,135 @@ | |||
| /** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||
| * | |||
| * Licensed under the Apache License, Version 2.0 (the "License"); | |||
| * you may not use this file except in compliance with the License. | |||
| * You may obtain a copy of the License at | |||
| * | |||
| * http://www.apache.org/licenses/LICENSE-2.0 | |||
| * | |||
| * Unless required by applicable law or agreed to in writing, software | |||
| * distributed under the License is distributed on an "AS IS" BASIS, | |||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| * See the License for the specific language governing permissions and | |||
| * limitations under the License. | |||
| * ============================================================= | |||
| */ | |||
| <template> | |||
| <el-popover | |||
| v-model="state.visible" | |||
| placement="top" | |||
| width="240" | |||
| trigger="click" | |||
| title="编辑标签" | |||
| @show="onShow" | |||
| > | |||
| <el-form ref="formRef" :model="state.form" :rules="rules" label-width="60px" style="margin-top: 20px;"> | |||
| <el-form-item label="名称" prop="name"> | |||
| <el-input | |||
| ref="inputRef" | |||
| v-model="state.form.name" | |||
| placeholder="修改标签名称" | |||
| /> | |||
| </el-form-item> | |||
| <el-form-item label="颜色" prop="color"> | |||
| <el-color-picker v-model="state.form.color" /> | |||
| </el-form-item> | |||
| <div class="tc"> | |||
| <el-button type="text" @click="handleCancel">取消</el-button> | |||
| <el-button type="primary" @click="handleOk">确定</el-button> | |||
| </div> | |||
| </el-form> | |||
| <i | |||
| slot="reference" | |||
| class="el-icon-edit" | |||
| style="margin-left: 4px;" | |||
| :style="getStyle(item)" | |||
| /> | |||
| </el-popover> | |||
| </template> | |||
| <script> | |||
| import Vue from 'vue'; | |||
| import { reactive, ref, watch } from '@vue/composition-api'; | |||
| import { validateName } from '@/utils/validate'; | |||
| export default { | |||
| name: 'EditLabel', | |||
| props: { | |||
| item: { | |||
| type: Object, | |||
| default: () => ({}), | |||
| }, | |||
| getStyle: Function, | |||
| title: String, | |||
| }, | |||
| setup(props, ctx) { | |||
| const inputRef = ref(null); | |||
| const formRef = ref(null); | |||
| const state = reactive({ | |||
| visible: false, | |||
| form: { | |||
| name: props.item.name || '', | |||
| color: props.item.color || '#2e4fde', | |||
| }, | |||
| }); | |||
| // 表单规则 | |||
| const rules = { | |||
| name: [ | |||
| { required: true, message: '请输入数据集名称', trigger: ['change', 'blur'] }, | |||
| { validator: validateName, trigger: ['change', 'blur'] }, | |||
| ], | |||
| }; | |||
| const handleCancel = () => { | |||
| Object.assign(state, { | |||
| visible: false, | |||
| form: { | |||
| name: props.item.name || '', | |||
| color: props.item.color || '#2e4fde', | |||
| }, | |||
| }); | |||
| }; | |||
| // 编辑标注名称 | |||
| const handleOk = () => { | |||
| formRef.value.validate().then(valid => { | |||
| if (!valid) { | |||
| return; | |||
| } | |||
| ctx.emit('handleOk', state.form, props.item); | |||
| handleCancel(); | |||
| }); | |||
| }; | |||
| const onShow = () => { | |||
| // onShow 的时候重置 | |||
| Vue.nextTick(() => { | |||
| const input = inputRef && inputRef.value.$refs.input; | |||
| input && input.focus(); | |||
| }); | |||
| }; | |||
| watch(() => props.item, (next) => { | |||
| if (next) { | |||
| state.form = { | |||
| name: next.name || '', | |||
| color: next.color || '#2e4fde', | |||
| }; | |||
| } | |||
| }); | |||
| return { | |||
| props, | |||
| state, | |||
| rules, | |||
| inputRef, | |||
| formRef, | |||
| handleOk, | |||
| handleCancel, | |||
| onShow, | |||
| }; | |||
| }, | |||
| }; | |||
| </script> | |||
| @@ -23,7 +23,21 @@ | |||
| <div style="max-height: 200px; padding: 0 2.5px; overflow: auto;"> | |||
| <el-row :gutter="5" style="clear: both;"> | |||
| <el-col v-for="item in state.labelData" :key="item.id" :span="8"> | |||
| <el-tag class="tag-item" :title="item.name" :color="item.color" :style="getStyle(item)">{{ item.name }}</el-tag> | |||
| <el-tag | |||
| class="tag-item" | |||
| :title="item.name" | |||
| :color="item.color" | |||
| :style="getStyle(item)" | |||
| @click="event => handleEditAnnotation(item, event)" | |||
| > | |||
| {{ item.name }} | |||
| <Edit | |||
| v-if="!item.labelGroupId" | |||
| :getStyle="getStyle" | |||
| :item="item" | |||
| @handleOk="handleEditLabel" | |||
| /> | |||
| </el-tag> | |||
| </el-col> | |||
| </el-row> | |||
| </div> | |||
| @@ -32,34 +46,44 @@ | |||
| <script> | |||
| import { reactive, watch, computed } from '@vue/composition-api'; | |||
| import SearchLabel from '@/views/dataset/components/searchLabel'; | |||
| const chroma = require('chroma-js'); | |||
| import { colorByLuminance, replace } from '@/utils'; | |||
| import SearchLabel from '@/views/dataset/components/searchLabel'; | |||
| import Edit from './edit'; | |||
| export default { | |||
| name: 'LabelList', | |||
| components: { | |||
| SearchLabel, | |||
| Edit, | |||
| }, | |||
| props: { | |||
| labels: { | |||
| type: Array, | |||
| default: () => ([]), | |||
| }, | |||
| currentAnnotationId: { | |||
| type: String, | |||
| default: undefined, | |||
| }, | |||
| editLabel: Function, | |||
| annotations: Array, | |||
| updateState: Function, | |||
| getColorLabel: Function, | |||
| findRowIndex: Function, | |||
| }, | |||
| setup(props) { | |||
| const { annotations: rawAnnotations ,updateState, getColorLabel, findRowIndex, editLabel } = props; | |||
| const state = reactive({ | |||
| annotations: rawAnnotations, | |||
| labelData: props.labels, | |||
| currentAnnotationId: props.currentAnnotationId, | |||
| }); | |||
| // 根据亮度来决定颜色 | |||
| const getStyle = (item) => { | |||
| if (item.color && chroma(item.color).luminance() < 0.5) { | |||
| return { | |||
| color: '#fff', | |||
| }; | |||
| } | |||
| const color = colorByLuminance(item.color); | |||
| return { | |||
| color: '#000', | |||
| color, | |||
| }; | |||
| }; | |||
| // 查询分类标签 | |||
| @@ -75,16 +99,53 @@ export default { | |||
| return `全部标签(${props.labels.length})`; | |||
| }); | |||
| const handleEditAnnotation = (item, event) => { | |||
| // 过滤编辑入口 | |||
| if (event.target.classList.contains('el-icon-edit')) return; | |||
| const updateIndex = findRowIndex(state.currentAnnotationId); | |||
| if (updateIndex > -1) { | |||
| const curItem = props.annotations[updateIndex]; | |||
| const nextItem = { | |||
| ...curItem, | |||
| data: { | |||
| ...curItem.data, | |||
| categoryId: item.id, | |||
| color: getColorLabel(item.id), | |||
| }, | |||
| }; | |||
| const updateList = replace(props.annotations, updateIndex, nextItem); | |||
| updateState({ | |||
| annotations: updateList, | |||
| }); | |||
| } | |||
| }; | |||
| const handleEditLabel = (field, item) => { | |||
| editLabel(item.id, field); | |||
| }; | |||
| watch(() => props.labels, (next) => { | |||
| state.labelData = next; | |||
| }); | |||
| watch(() => props.currentAnnotationId, (next) => { | |||
| state.currentAnnotationId = next; | |||
| }); | |||
| return { | |||
| state, | |||
| labelsTitle, | |||
| handleEditAnnotation, | |||
| handleEditLabel, | |||
| getStyle, | |||
| handleSearch, | |||
| }; | |||
| }, | |||
| }; | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .el-icon-edit { | |||
| padding: 0 4px; | |||
| margin-left: 4px; | |||
| } | |||
| </style> | |||
| @@ -22,6 +22,7 @@ | |||
| <div class="flex flex-between"> | |||
| <InfoSelect | |||
| v-model="state.label" | |||
| :innerRef="innerRef" | |||
| style="width: 68%;" | |||
| placeholder="选择已有标签或新建标签" | |||
| :dataSource="dataSource" | |||
| @@ -29,7 +30,7 @@ | |||
| default-first-option | |||
| filterable | |||
| allow-create | |||
| @change="handleLabelChange" | |||
| @change="handleChange" | |||
| /> | |||
| <el-button size="mini" type="primary" @click="postLabel">确定</el-button> | |||
| </div> | |||
| @@ -37,7 +38,7 @@ | |||
| </template> | |||
| <script> | |||
| import { reactive } from '@vue/composition-api'; | |||
| import { reactive, ref } from '@vue/composition-api'; | |||
| import InfoSelect from '@/components/InfoSelect'; | |||
| import LabelTip from './labelTip'; | |||
| @@ -56,6 +57,9 @@ export default { | |||
| handleLabelChange: Function, | |||
| }, | |||
| setup(props, ctx) { | |||
| const { handleLabelChange } = props; | |||
| const selectRef = ref(null); | |||
| const state = reactive({ | |||
| label: undefined, | |||
| }); | |||
| @@ -65,9 +69,17 @@ export default { | |||
| state.label = undefined; | |||
| }; | |||
| const handleChange = (params) => { | |||
| handleLabelChange(params, () => { | |||
| state.label = undefined; | |||
| }); | |||
| }; | |||
| return { | |||
| state, | |||
| postLabel, | |||
| handleChange, | |||
| innerRef: () => selectRef, | |||
| }; | |||
| }, | |||
| }; | |||
| @@ -48,6 +48,7 @@ | |||
| </div> | |||
| </div> | |||
| <List | |||
| ref="listRef" | |||
| v-bind="$attrs" | |||
| :updateState="updateState" | |||
| :list="state.files.value" | |||
| @@ -55,6 +56,7 @@ | |||
| :hasMore="state.hasMore.value" | |||
| :total="state.total.value" | |||
| :offset="state.offset.value" | |||
| :type="thumbState.type" | |||
| :history="state.history.value" | |||
| v-on="$listeners" | |||
| /> | |||
| @@ -93,7 +95,7 @@ import { Message } from 'element-ui'; | |||
| import { pick } from 'lodash'; | |||
| import UploadForm from '@/components/UploadForm'; | |||
| import { fileTypeEnum, getImgFromMinIO, withDimensionFile } from '@/views/dataset/util'; | |||
| import { fileTypeEnum, fileCodeMap, getImgFromMinIO, withDimensionFile } from '@/views/dataset/util'; | |||
| import { submit } from '@/api/preparation/datafile'; | |||
| import { detectFileList, queryFileOffset } from '@/api/preparation/dataset'; | |||
| import List from './list'; | |||
| @@ -115,6 +117,8 @@ export default { | |||
| const { $route } = ctx.root; | |||
| const uploaderRef = ref(null); | |||
| const listRef = ref(null); | |||
| const { updateList, state, updateState, isTrack } = props; | |||
| const { datasetId } = state; | |||
| const thumbState = reactive({ | |||
| @@ -132,11 +136,11 @@ export default { | |||
| const dropdownList = computed(() => { | |||
| let filter = []; | |||
| if (isTrack) { | |||
| // 目标跟踪:全部(0)、未标注-手动标注、手动标注中(1)、手动标注中(1)、自动目标跟踪完成(4)、手动标注完成(3) | |||
| filter = pick(fileTypeEnum, [0, 1, 3, 4]); | |||
| // 目标跟踪:全部 未标注 未识别 手动标注中 手动标注完成 自动标注完成 目标跟踪完成 | |||
| filter = pick(fileTypeEnum, [fileCodeMap.ALL, fileCodeMap.UNANNOTATED, fileCodeMap.UNRECOGNIZED, fileCodeMap.MANUAL_ANNOTATING, fileCodeMap.MANUAL_ANNOTATED, fileCodeMap.AUTO_ANNOTATED, fileCodeMap.TRACK_SUCCEED]); | |||
| } else { | |||
| // 目标检测:全部(0)、未标注-手动标注、手动标注中(1)、自动标注完成(2)、手动标注完成(3) | |||
| filter = pick(fileTypeEnum, [0, 1, 2, 3]); | |||
| // 目标检测:全部 未标注 未识别 手动标注中 自动标注完成 手动标注完成 | |||
| filter = pick(fileTypeEnum, [fileCodeMap.ALL, fileCodeMap.UNANNOTATED, fileCodeMap.UNRECOGNIZED, fileCodeMap.MANUAL_ANNOTATING, fileCodeMap.AUTO_ANNOTATED, fileCodeMap.MANUAL_ANNOTATED]); | |||
| } | |||
| const statusList = Object.keys(filter).map(k => ({ | |||
| command: k, | |||
| @@ -153,6 +157,11 @@ export default { | |||
| updateState({ annotations: [], fileFilterType: command }); | |||
| // 重新请求文件 | |||
| updateList({ type: command, offset: 0 }); | |||
| // 获取滚动列表容器 | |||
| const listWrapper = listRef.value.$refs?.listWrapper; | |||
| listWrapper.scrollTo({ | |||
| top: 0, | |||
| }); | |||
| }; | |||
| const handleClose = () => { | |||
| @@ -223,6 +232,7 @@ export default { | |||
| }); | |||
| return { | |||
| listRef, | |||
| thumbState, | |||
| withDimensionFile, | |||
| uploadParams, | |||
| @@ -15,7 +15,7 @@ | |||
| */ | |||
| <template> | |||
| <div class="infinite-list-wrapper" style="overflow: auto;"> | |||
| <div ref="listWrapper" class="infinite-list-wrapper" style="overflow: auto;"> | |||
| <ul | |||
| v-infinite-scroll="loadMore" | |||
| infinite-scroll-distance="100" | |||
| @@ -35,7 +35,7 @@ | |||
| </div> | |||
| </template> | |||
| <script> | |||
| import { reactive, watch, computed } from '@vue/composition-api'; | |||
| import { reactive, watch, computed, ref } from '@vue/composition-api'; | |||
| import { limit } from '@/views/dataset/annotate'; | |||
| import ListItem from './listItem'; | |||
| @@ -50,6 +50,9 @@ export default { | |||
| type: Array, | |||
| default: () => [], | |||
| }, | |||
| type: { | |||
| type: [String, Number], | |||
| }, | |||
| addList: { | |||
| type: Array, | |||
| default: () => [], | |||
| @@ -70,6 +73,9 @@ export default { | |||
| }, | |||
| setup(props, ctx) { | |||
| const { updateState, queryNextPage } = props; | |||
| const listWrapper = ref(null); | |||
| const state = reactive({ | |||
| loading: false, | |||
| }); | |||
| @@ -99,6 +105,7 @@ export default { | |||
| }); | |||
| queryNextPage({ | |||
| offset: props.offset, | |||
| type: Number(props.type), | |||
| }).then(() => { | |||
| Object.assign(state, { | |||
| loading: false, | |||
| @@ -111,6 +118,7 @@ export default { | |||
| disabled, | |||
| loadMore, | |||
| handleClick, | |||
| listWrapper, | |||
| }; | |||
| }, | |||
| }; | |||
| @@ -15,12 +15,10 @@ | |||
| */ | |||
| import { isNil } from 'lodash'; | |||
| import { addSuffix } from '@/utils'; | |||
| import { addSuffix, chroma, colorByLuminance } from '@/utils'; | |||
| import { defaultColor } from './bbox'; | |||
| const chroma = require('chroma-js'); | |||
| const validTrackId = (trackId) => { | |||
| if (isNil(trackId) || trackId === -1) return false; | |||
| return trackId; | |||
| @@ -31,47 +29,48 @@ export default { | |||
| functional: true, | |||
| props: { | |||
| annotate: Object, | |||
| offset: Function, | |||
| currentAnnotationId: String, | |||
| brush: Object, | |||
| transformer: Object, | |||
| scale: { | |||
| type: Number, | |||
| }, | |||
| imgBoundingLeft: Number, | |||
| imgBounding: { | |||
| type: Array, | |||
| }, | |||
| getLabelName: Function, | |||
| }, | |||
| render(h, context) { | |||
| const { props } = context; | |||
| const { | |||
| annotate = {}, | |||
| imgBoundingLeft, | |||
| offset, | |||
| brush, | |||
| transformer, | |||
| } = props; | |||
| const { data = {}, __type } = annotate; | |||
| const { data = {}, id } = annotate; | |||
| const { bbox, color = defaultColor } = data; | |||
| if (isNil(bbox)) return null; | |||
| // 是否为草稿模式 | |||
| const isDraft = __type === 0; | |||
| // todo: top | |||
| const paddingLeft = (props.scale < 1 && !isNil(imgBoundingLeft)) | |||
| ? imgBoundingLeft | |||
| : 0; | |||
| const pos = isDraft ? { | |||
| x: bbox.x, | |||
| y: bbox.y, | |||
| width: bbox.width, | |||
| height: bbox.height, | |||
| } : { | |||
| x: bbox.x * props.scale + paddingLeft, | |||
| y: bbox.y * props.scale, | |||
| width: bbox.width * props.scale, | |||
| height: bbox.height * props.scale, | |||
| }; | |||
| // 当前在拖拽中不展示 | |||
| if(props.currentAnnotationId === id && brush.isBrushing) return null; | |||
| if (isNil(bbox)) return null; | |||
| const pos = offset(props.annotate); | |||
| const style = { | |||
| width: addSuffix(pos.width), | |||
| left: addSuffix(pos.x), | |||
| top: addSuffix(pos.y), | |||
| color: colorByLuminance(color), | |||
| }; | |||
| // 匹配当前标注 | |||
| if(annotate.id === transformer.id) { | |||
| style.transform = `translate(${transformer.dx}px, ${transformer.dy}px)`; | |||
| } | |||
| const tagColor = chroma(color).alpha(0.8).toString(); | |||
| const trackId = (() => { | |||
| @@ -85,7 +84,7 @@ export default { | |||
| if (!trackId) return null; | |||
| return ( | |||
| <div class='annotation-label image-tag' style={style}> | |||
| <el-tag color={tagColor} style={{ color: '#fff', border: 'none' }}>{trackId}</el-tag> | |||
| <el-tag color={tagColor} style={{ color: 'inherit', border: 'none' }}>{trackId}</el-tag> | |||
| </div> | |||
| ); | |||
| }, | |||
| @@ -16,8 +16,7 @@ | |||
| import cx from 'classnames'; | |||
| import { isNil } from 'lodash'; | |||
| const chroma = require('chroma-js'); | |||
| import { chroma } from '@/utils'; | |||
| export const defaultColor = 'rgba(102, 181, 245, 1)'; | |||
| const defaultFill = 'rgba(102, 181, 245, 0.1)'; | |||
| @@ -27,68 +26,71 @@ export default { | |||
| functional: true, | |||
| props: { | |||
| annotate: Object, | |||
| brush: Object, | |||
| scale: { | |||
| type: Number, | |||
| default: 1, | |||
| }, | |||
| currentAnnotationId: Object, | |||
| imgBoundingLeft: Number, | |||
| handleClick: Function, | |||
| pos: { | |||
| type: Object, | |||
| default: () => ({}), | |||
| }, | |||
| dragStart: Function, | |||
| dragMove: Function, | |||
| dragEnd: Function, | |||
| currentAnnotationId: String, | |||
| transformer: Object, | |||
| imgRef: HTMLImageElement, | |||
| }, | |||
| render(h, context) { | |||
| const { props } = context; | |||
| const { style } = context.data; | |||
| const { | |||
| annotate = {}, | |||
| imgBoundingLeft, | |||
| currentAnnotationId, | |||
| handleClick, | |||
| dragStart, | |||
| dragMove, | |||
| dragEnd, | |||
| brush, | |||
| transformer, | |||
| ...rest // does this work? | |||
| } = props; | |||
| const { data = {}, __type } = annotate; | |||
| const { data = {} } = annotate; | |||
| const { bbox, color } = data; | |||
| if (isNil(bbox)) return null; | |||
| const bgColor = color || defaultFill; | |||
| const isActive = currentAnnotationId.value === annotate.id; | |||
| const isActive = currentAnnotationId === annotate.id; | |||
| const colorAlpha = isActive ? 0.4 : 0.1; | |||
| const fill = chroma(bgColor).alpha(colorAlpha); | |||
| // 是否为草稿模式 | |||
| const isDraft = __type === 0; | |||
| const paddingLeft = (props.scale < 1 && !isNil(imgBoundingLeft)) | |||
| ? imgBoundingLeft | |||
| : 0; | |||
| const pos = isDraft ? { | |||
| x: bbox.x, | |||
| y: bbox.y, | |||
| width: bbox.width, | |||
| height: bbox.height, | |||
| } : { | |||
| x: bbox.x * props.scale + paddingLeft, | |||
| y: bbox.y * props.scale, | |||
| width: bbox.width * props.scale, | |||
| height: bbox.height * props.scale, | |||
| }; | |||
| let transform = null; | |||
| // 匹配当前标注 | |||
| if(annotate.id === transformer.id) { | |||
| transform = `translate(${transformer.dx}, ${transformer.dy})`; | |||
| } | |||
| return ( | |||
| <g class={cx('bbox-group', { | |||
| active: isActive, | |||
| })} onClick={handleClick(annotate)}> | |||
| })}> | |||
| <rect | |||
| fill={fill} | |||
| stroke={color || defaultColor} | |||
| strokeWidth={4} | |||
| // {...bounding} spread operator sucks... | |||
| x={pos.x} | |||
| y={pos.y} | |||
| width={pos.width} | |||
| height={pos.height} | |||
| x={props.pos.x} | |||
| y={props.pos.y} | |||
| width={props.pos.width} | |||
| height={props.pos.height} | |||
| transform={transform} | |||
| onMousemove={dragMove} | |||
| onMouseup={dragEnd} | |||
| onMousedown={dragStart} | |||
| style={style} | |||
| {...rest} | |||
| /> | |||
| </g> | |||
| @@ -0,0 +1,430 @@ | |||
| /** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||
| * | |||
| * Licensed under the Apache License, Version 2.0 (the "License"); | |||
| * you may not use this file except in compliance with the License. | |||
| * You may obtain a copy of the License at | |||
| * | |||
| * http://www.apache.org/licenses/LICENSE-2.0 | |||
| * | |||
| * Unless required by applicable law or agreed to in writing, software | |||
| * distributed under the License is distributed on an "AS IS" BASIS, | |||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| * See the License for the specific language governing permissions and | |||
| * limitations under the License. | |||
| * ============================================================= | |||
| */ | |||
| import Vue from 'vue'; | |||
| import { isEmpty } from 'lodash'; | |||
| import { createElement, reactive, watch } from '@vue/composition-api'; | |||
| import { mergeProps } from '@/utils'; | |||
| import Drag from '@/components/Drag'; | |||
| import { BrushHandle, BrushCorner } from '@/components/svg'; | |||
| import Bbox from './bbox'; | |||
| export default { | |||
| name: 'BboxWrapper', | |||
| inheritAttrs: false, | |||
| props: { | |||
| annotate: Object, | |||
| brush: { | |||
| type: Object, | |||
| default: () => ({}), | |||
| }, | |||
| onDragStart: Function, | |||
| onDragMove: Function, | |||
| onDragEnd: Function, | |||
| onBrushHandleChange: Function, | |||
| onBrushHandleEnd: Function, | |||
| transformer: Object, | |||
| currentAnnotationId: String, | |||
| setCurAnnotation: Function, | |||
| getZoom: Function, | |||
| handleSize: { | |||
| type: Number, | |||
| default: 6, | |||
| }, | |||
| offset: Function, | |||
| scale: { | |||
| type: Number, | |||
| default: 1, | |||
| }, | |||
| bounds: { | |||
| type: Object, | |||
| }, | |||
| svg: { | |||
| type: Object, | |||
| default: () => ({}), | |||
| }, | |||
| }, | |||
| components: { | |||
| Drag, | |||
| Bbox, | |||
| }, | |||
| setup(props) { | |||
| const { | |||
| offset, | |||
| scale, | |||
| onDragStart, | |||
| onDragMove, | |||
| onDragEnd, | |||
| onBrushHandleChange, | |||
| bounds = {}, | |||
| onBrushHandleEnd, | |||
| setCurAnnotation, | |||
| getZoom, | |||
| } = props; | |||
| function getExtent() { | |||
| const { data = {} } = props.annotate; | |||
| const { extent } = data; | |||
| return { | |||
| extent, | |||
| start: { | |||
| x: extent.x0, | |||
| y: extent.y0, | |||
| }, | |||
| end: { | |||
| x: extent.x1, | |||
| y: extent.y1, | |||
| }, | |||
| }; | |||
| } | |||
| const state = reactive({ | |||
| activeHandle: undefined, | |||
| drag: undefined, | |||
| bounds: { x0: 0, x1: bounds.width, y0: 0, y1: bounds.height }, | |||
| ...getExtent(), | |||
| }); | |||
| const updateBrush = (updater, callback) => { | |||
| const newState = updater(state); | |||
| Vue.nextTick(() => { | |||
| Object.assign(state, newState); | |||
| if(typeof callback === 'function') { | |||
| callback(state); | |||
| } | |||
| }); | |||
| }; | |||
| // handler 拖拽事件 | |||
| const updateBrushHandler = (updater) => { | |||
| updateBrush(updater, state => { | |||
| if(typeof onBrushHandleChange === 'function') { | |||
| onBrushHandleChange(state, props.annotate); | |||
| } | |||
| }); | |||
| }; | |||
| // handler 拖拽结束 | |||
| const updateBrushHandlerEnd = (updater) => { | |||
| updateBrush(updater, state => { | |||
| if(typeof onBrushHandleEnd === 'function') { | |||
| onBrushHandleEnd(state, props.annotate); | |||
| } | |||
| }); | |||
| }; | |||
| const handles = () => { | |||
| const { handleSize } = props; | |||
| const {x, y, width, height} = offset(props.annotate); | |||
| const handleOffset = handleSize / 2; | |||
| return { | |||
| top: { | |||
| x: x - handleOffset, | |||
| y: y - handleOffset, | |||
| height: handleSize, | |||
| width: width + handleSize, | |||
| }, | |||
| bottom: { | |||
| x: x - handleOffset, | |||
| y: y + height - handleOffset, | |||
| height: handleSize, | |||
| width: width + handleSize, | |||
| }, | |||
| right: { | |||
| x: x + width - handleOffset, | |||
| y: y - handleOffset, | |||
| height: height + handleSize, | |||
| width: handleSize, | |||
| }, | |||
| left: { | |||
| x: x - handleOffset, | |||
| y: y - handleOffset, | |||
| height: height + handleSize, | |||
| width: handleSize, | |||
| }, | |||
| }; | |||
| }; | |||
| const corners = () => { | |||
| const { handleSize } = props; | |||
| const {x, y, width, height} = offset(props.annotate); | |||
| const handleOffset = handleSize / 2; | |||
| return { | |||
| topLeft: { | |||
| x: x - handleOffset, | |||
| y: y - handleOffset, | |||
| }, | |||
| bottomLeft: { | |||
| x: x - handleOffset, | |||
| y: y + height - handleOffset, | |||
| }, | |||
| topRight: { | |||
| x: x + width - handleOffset, | |||
| y: y - handleOffset, | |||
| }, | |||
| bottomRight: { | |||
| x: x + width - handleOffset, | |||
| y: y + height - handleOffset, | |||
| }, | |||
| }; | |||
| }; | |||
| const brushHandlerStart = () => { | |||
| setCurAnnotation(props.annotate); | |||
| }; | |||
| const selectionDragStart = drag => { | |||
| const start = { | |||
| x: drag.x + drag.dx, | |||
| y: drag.y + drag.dy, | |||
| }; | |||
| const end = { ...start }; | |||
| const transformState = { | |||
| start, | |||
| end, | |||
| }; | |||
| // 回调 | |||
| if (typeof onDragStart === 'function') { | |||
| onDragStart(transformState, props.annotate); | |||
| } | |||
| }; | |||
| const selectionDragMove = (drag) => { | |||
| const { zoom } = getZoom(); | |||
| updateBrush(prevBrush => { | |||
| const { x: x0, y: y0 } = prevBrush.start; | |||
| const { x: x1, y: y1 } = prevBrush.end; | |||
| // 位置比较计算 | |||
| const _scale = zoom * scale; | |||
| const validDx = | |||
| drag.dx > 0 | |||
| ? Math.min(drag.dx / _scale, prevBrush.bounds.x1 - x1) | |||
| : Math.max(drag.dx / _scale, prevBrush.bounds.x0 - x0); | |||
| const validDy = | |||
| drag.dy > 0 | |||
| ? Math.min(drag.dy / _scale, prevBrush.bounds.y1 - y1) | |||
| : Math.max(drag.dy / _scale, prevBrush.bounds.y0 - y0); | |||
| return { | |||
| ...prevBrush, | |||
| isBrushing: true, | |||
| extent: { | |||
| ...prevBrush.extent, | |||
| x0: x0 + validDx, | |||
| x1: x1 + validDx, | |||
| y0: y0 + validDy, | |||
| y1: y1 + validDy, | |||
| }, | |||
| drag: { | |||
| ...drag, | |||
| validDx, | |||
| validDy, | |||
| }, | |||
| }; | |||
| }, (nextState) => { | |||
| if (typeof onDragMove === 'function') { | |||
| onDragMove(nextState, props.annotate); | |||
| } | |||
| }); | |||
| }; | |||
| const selectionDragEnd = (state, event, options = {}) => { | |||
| const { prevState } = options; | |||
| // fix 双击触发移动选框 | |||
| if(!prevState.isMoving) return; | |||
| updateBrush(prevBrush => { | |||
| const nextBrush = { | |||
| ...prevBrush, | |||
| isBrushing: false, | |||
| start: { | |||
| ...prevBrush.start, | |||
| x: Math.min(prevBrush.extent.x0, prevBrush.extent.x1), | |||
| y: Math.min(prevBrush.extent.y0, prevBrush.extent.y1), | |||
| }, | |||
| end: { | |||
| ...prevBrush.end, | |||
| x: Math.max(prevBrush.extent.x0, prevBrush.extent.x1), | |||
| y: Math.max(prevBrush.extent.y0, prevBrush.extent.y1), | |||
| }, | |||
| }; | |||
| return nextBrush; | |||
| }, (nextState) => { | |||
| // 回调 | |||
| if (typeof onDragEnd === 'function') { | |||
| onDragEnd(nextState, props.annotate); | |||
| } | |||
| }); | |||
| }; | |||
| watch(() => props.bounds, (next) => { | |||
| if(!isEmpty(next)) { | |||
| Object.assign(state, { | |||
| bounds: { x0: 0, x1: bounds.width, y0: 0, y1: bounds.height }, | |||
| }); | |||
| } | |||
| }, { | |||
| lazy: true, | |||
| }); | |||
| return { | |||
| state, | |||
| updateBrush, | |||
| updateBrushHandler, | |||
| updateBrushHandlerEnd, | |||
| brushHandlerStart, | |||
| handles, | |||
| corners, | |||
| getExtent, | |||
| selectionDragStart, | |||
| selectionDragMove, | |||
| selectionDragEnd, | |||
| }; | |||
| }, | |||
| render(h) { | |||
| const { | |||
| annotate = {}, | |||
| scale, | |||
| brush, | |||
| handleSize, | |||
| transformer, | |||
| currentAnnotationId, | |||
| } = this; | |||
| const handles = this.handles(); | |||
| const corners = this.corners(); | |||
| const pos = this.offset(annotate); | |||
| const bboxProps = { | |||
| props: { | |||
| ...this.$attrs, | |||
| annotate, | |||
| pos, | |||
| transformer, | |||
| currentAnnotationId, | |||
| }, | |||
| }; | |||
| const dragProps = { | |||
| props: { | |||
| onDragStart: this.selectionDragStart, | |||
| onDragMove: this.selectionDragMove, | |||
| onDragEnd: this.selectionDragEnd, | |||
| resetOnStart: true, | |||
| width: this.svg.width, | |||
| height: this.svg.height, | |||
| }, | |||
| }; | |||
| return ( | |||
| <Drag {...dragProps} key={annotate.id}> | |||
| { | |||
| (draw) => { | |||
| const style = { | |||
| pointerEvents: brush.isBrushing || this.state.activeHandle ? 'none' : 'all', | |||
| }; | |||
| const _props = mergeProps(bboxProps, { | |||
| props: { ...draw, brush: this.state }, | |||
| style, | |||
| }); | |||
| const Handles = Object.keys(handles).map((handleKey) => { | |||
| const handle = handles[handleKey]; | |||
| return ( | |||
| <BrushHandle | |||
| key={`handle-${handleKey}`} | |||
| type={handleKey} | |||
| handle={handle} | |||
| scale={scale} | |||
| stageWidth={this.svg.width} | |||
| stageHeight={this.svg.height} | |||
| handleBrushStart={this.brushHandlerStart} | |||
| updateBrush={this.updateBrushHandler} | |||
| updateBrushEnd={this.updateBrushHandlerEnd} | |||
| getZoom={this.getZoom} | |||
| /> | |||
| ); | |||
| }); | |||
| const Corners = Object.keys(corners).map((cornerKey) => { | |||
| const corner = corners[cornerKey]; | |||
| return ( | |||
| <BrushCorner | |||
| annotate={annotate} | |||
| transformer={transformer} | |||
| currentAnnotationId={currentAnnotationId} | |||
| key={`corner-${cornerKey}`} | |||
| type={cornerKey} | |||
| x={corner.x} | |||
| y={corner.y} | |||
| width={handleSize} | |||
| height={handleSize} | |||
| scale={scale} | |||
| stageWidth={this.svg.width} | |||
| stageHeight={this.svg.height} | |||
| handleBrushStart={this.brushHandlerStart} | |||
| updateBrush={this.updateBrushHandler} | |||
| updateBrushEnd={this.updateBrushHandlerEnd} | |||
| getZoom={this.getZoom} | |||
| /> | |||
| ); | |||
| }); | |||
| return ( | |||
| <g> | |||
| {draw.state.isDragging && ( | |||
| <rect | |||
| width={this.svg.width} | |||
| height={this.svg.height} | |||
| fill="transparent" | |||
| onMouseup={draw.dragEnd} | |||
| onMousemove={draw.dragMove} | |||
| onMouseleave={(event) => { | |||
| // hack: 获取画布背景的位置 | |||
| const rect = event.target.getBoundingClientRect(); | |||
| // 超出边界判断 | |||
| if(event.clientX <= rect.x || event.clientX >= rect.right || event.clientY <= rect.y || event.clientY >= rect.bottom) { | |||
| draw.dragEnd(); | |||
| } | |||
| }} | |||
| style={{ | |||
| cursor: 'move', | |||
| }} | |||
| /> | |||
| )} | |||
| {createElement(Bbox, _props)} | |||
| <g | |||
| class='bbox-handles-group' | |||
| >{Handles}</g> | |||
| <g | |||
| class='bbox-corners-group' | |||
| >{Corners}</g> | |||
| </g> | |||
| ); | |||
| } | |||
| } | |||
| </Drag> | |||
| ); | |||
| }, | |||
| }; | |||
| @@ -0,0 +1,89 @@ | |||
| /** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||
| * | |||
| * Licensed under the Apache License, Version 2.0 (the "License"); | |||
| * you may not use this file except in compliance with the License. | |||
| * You may obtain a copy of the License at | |||
| * | |||
| * http://www.apache.org/licenses/LICENSE-2.0 | |||
| * | |||
| * Unless required by applicable law or agreed to in writing, software | |||
| * distributed under the License is distributed on an "AS IS" BASIS, | |||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| * See the License for the specific language governing permissions and | |||
| * limitations under the License. | |||
| * ============================================================= | |||
| */ | |||
| import { isNil } from 'lodash'; | |||
| import { toFixed, addSuffix } from '@/utils'; | |||
| export default { | |||
| name: 'BrushTip', | |||
| props: { | |||
| annotate: Object, | |||
| dimension: Object, | |||
| brush: Object, | |||
| }, | |||
| setup(props) { | |||
| const getWidth = () => { | |||
| const { extent } = props.brush; | |||
| if(isNil(extent)) return 0; | |||
| return extent.x1 - extent.x0; | |||
| }; | |||
| const getHeight = () => { | |||
| const { extent } = props.brush; | |||
| if(isNil(extent)) return 0; | |||
| return extent.y1 - extent.y0; | |||
| }; | |||
| const getEndPoint = () => { | |||
| const { extent = {} } = props.brush; | |||
| return {x: extent.x1, y: extent.y1}; | |||
| }; | |||
| return { | |||
| getWidth, | |||
| getHeight, | |||
| getEndPoint, | |||
| }; | |||
| }, | |||
| render(h) { | |||
| const width = this.getWidth(); | |||
| const height = this.getHeight(); | |||
| const endPoint = this.getEndPoint(); | |||
| const { svg } = this.dimension; | |||
| const sizeTipStyle = { | |||
| left: addSuffix(this.brush.extent?.x0), | |||
| top: addSuffix(this.brush.extent?.y0 - 30), | |||
| }; | |||
| const dimensionTipStyle = { | |||
| right: addSuffix(svg.width - this.brush.extent?.x1), | |||
| top: addSuffix(this.brush.extent?.y1 + 6), | |||
| }; | |||
| // 到上边缘 | |||
| if (this.brush.extent?.y0 < 30) { | |||
| sizeTipStyle.top = addSuffix(this.brush.extent?.y0 + 6); | |||
| }; | |||
| return ( | |||
| <div class='usn'> | |||
| <div class='brush-tooltip size-tipper' style={sizeTipStyle}>{ | |||
| width > 0 && height > 0 && ( | |||
| <div class='tooltip-item-row'>{toFixed(width, 0, 0)} * {toFixed(height, 0, 0)}</div> | |||
| ) | |||
| }</div> | |||
| <div class='brush-tooltip dimension-tipper' style={dimensionTipStyle}>{ | |||
| endPoint && ( | |||
| <div class='tooltip-item-row'> | |||
| ({toFixed(endPoint.x, 0, 0)}, {toFixed(endPoint.y, 0, 0)}) | |||
| </div> | |||
| ) | |||
| }</div> | |||
| </div> | |||
| ); | |||
| }, | |||
| }; | |||
| @@ -45,39 +45,54 @@ | |||
| <div class="zoom-content"> | |||
| <div class="zoom-content-bound rel" :style="dimension.marginStyle"> | |||
| <div class="imgWrapper" :style="dimension.imgScaleStyle" :class="dimension.scale < 1 ? 'imgScale' : ''"> | |||
| <img ref="imgRef" :src="currentImg.url"> | |||
| <img ref="imgRef" :src="currentImg.url" class='usn'> | |||
| </div> | |||
| <!-- svg 宽高要根据图片自适应 --> | |||
| <div class="annotation-element-group abs" :style="dimension.annotationGroupStyle"> | |||
| <svg | |||
| ref="svgRef" | |||
| class="canvas" | |||
| :class="api.active === 'selection' ? 'crosshair' : ''" | |||
| :style="dimension.svg" | |||
| @mousedown="handleMouseDown" | |||
| @mousemove="handleMouseMove" | |||
| @mouseup="handleMouseUp" | |||
| > | |||
| <Brush | |||
| :stageWidth="dimension.svg.width" | |||
| :stageHeight="dimension.svg.height" | |||
| :onBrushStart="handleBrushStart" | |||
| :onBrushMove="handleBrushMove" | |||
| :onBrushEnd="handleBrushEnd" | |||
| :transformZoom="transformZoom" | |||
| /> | |||
| <g class="annotation-group"> | |||
| <Bbox | |||
| <BboxWrapper | |||
| v-for="annotate in api.annotations" | |||
| :key="annotate.id" | |||
| :annotate="annotate" | |||
| :brush="brush" | |||
| :offset="offset" | |||
| :transformer="transformer" | |||
| :svg="dimension.svg" | |||
| :scale="dimension.scale" | |||
| :imgBoundingLeft="api.imgBoundingLeft" | |||
| :handleClick="handleBboxClick" | |||
| :currentAnnotationId="state.currentAnnotationId" | |||
| :bounds="dimension.img" | |||
| :onDragStart="onDragStart" | |||
| :onDragMove="onDragMove" | |||
| :onDragEnd="onDragEnd" | |||
| :onBrushHandleChange="onBrushHandleChange" | |||
| :onBrushHandleEnd="onBrushHandleEnd" | |||
| :currentAnnotationId="state.currentAnnotationId.value" | |||
| :setCurAnnotation="setCurAnnotation" | |||
| :getZoom="getZoom" | |||
| /> | |||
| </g> | |||
| <BasicBrush :brush="brush" /> | |||
| </svg> | |||
| <div v-if="state.showScore.value" class="annotation-score-group"> | |||
| <Score | |||
| v-for="annotate in api.annotations" | |||
| :key="annotate.id" | |||
| :annotate="annotate" | |||
| :scale="dimension.scale" | |||
| :imgBoundingLeft="api.imgBoundingLeft" | |||
| :currentAnnotationId="state.currentAnnotationId.value" | |||
| :brush="brush" | |||
| :offset="offset" | |||
| :transformer="transformer" | |||
| /> | |||
| </div> | |||
| <div v-if="state.showTag.value" class="annotation-tag-group"> | |||
| @@ -85,9 +100,11 @@ | |||
| v-for="annotate in api.annotations" | |||
| :key="annotate.id" | |||
| :annotate="annotate" | |||
| :scale="dimension.scale" | |||
| :currentAnnotationId="state.currentAnnotationId.value" | |||
| :brush="brush" | |||
| :offset="offset" | |||
| :transformer="transformer" | |||
| :getLabelName="getLabelName" | |||
| :imgBoundingLeft="api.imgBoundingLeft" | |||
| /> | |||
| </div> | |||
| <div v-if="state.showId.value && isTrack" class="annotation-tag-group"> | |||
| @@ -95,11 +112,21 @@ | |||
| v-for="annotate in api.annotations" | |||
| :key="annotate.id" | |||
| :annotate="annotate" | |||
| :currentAnnotationId="state.currentAnnotationId.value" | |||
| :brush="brush" | |||
| :offset="offset" | |||
| :transformer="transformer" | |||
| :scale="dimension.scale" | |||
| :getLabelName="getLabelName" | |||
| :imgBoundingLeft="api.imgBoundingLeft" | |||
| :imgBounding="api.imgBounding" | |||
| /> | |||
| </div> | |||
| <!-- 新建标注展示尺寸信息 --> | |||
| <BrushTip | |||
| v-if="brush.isBrushing && brush.extent" | |||
| :brush="brush" | |||
| :dimension="dimension" | |||
| /> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| @@ -129,16 +156,18 @@ import { event as d3Event } from 'd3-selection'; | |||
| import { Message } from 'element-ui'; | |||
| import { labelsSymbol } from '@/views/dataset/util'; | |||
| import { useBrush, BasicBrush, useZoom, unref, useTooltip, useImage } from '@/hooks'; | |||
| import { getCursorPosition, getBounding, getZoomPosition, noop } from '@/utils'; | |||
| import { useBrush, useZoom, unref, useTooltip, useImage } from '@/hooks'; | |||
| import { getBounding, raise, noop, replace, extent2Bbox, getZoomPosition } from '@/utils'; | |||
| import { Brush } from '@/components/svg'; | |||
| import ZoomContainer from '@/components/ZoomContainer'; | |||
| import Exception from '@/components/Exception'; | |||
| import ToolBar from './toolbar'; | |||
| import Bbox from './bbox'; | |||
| import BboxWrapper from './bboxWrapper'; | |||
| import Score from './score'; | |||
| import Tag from './tag'; | |||
| import AnnotationId from './annotationId'; | |||
| import DropDownLabel from './dropdownLabel'; | |||
| import BrushTip from './brushTip'; | |||
| const addEventListener = require('add-dom-event-listener'); | |||
| @@ -155,13 +184,14 @@ export default { | |||
| components: { | |||
| ZoomContainer, | |||
| Exception, | |||
| BasicBrush, | |||
| Brush, | |||
| ToolBar, | |||
| Bbox, | |||
| DropDownLabel, | |||
| Score, | |||
| Tag, | |||
| AnnotationId, | |||
| BboxWrapper, | |||
| BrushTip, | |||
| }, | |||
| props: { | |||
| state: Object, | |||
| @@ -169,7 +199,7 @@ export default { | |||
| type: Object, | |||
| default: () => null, | |||
| }, | |||
| handleBrushEnd: Function, | |||
| drawBboxEnd: Function, | |||
| createLabel: Function, | |||
| queryLabels: Function, | |||
| getLabelName: Function, | |||
| @@ -193,17 +223,28 @@ export default { | |||
| label: {}, // 一个页面当前只能存在一个标签 | |||
| bounding: null, // 容器位置信息 | |||
| isCenter: false, // 图片是否已居中 | |||
| imgBoundingLeft: null, // 图片的位置,给 bbox 位置定位使用 | |||
| imgBounding: null, // 图片的位置,给 bbox 位置定位使用 | |||
| active: '', // 当前选中 | |||
| }); | |||
| // 标注偏移 | |||
| const transformer = reactive({ | |||
| id: undefined, | |||
| dx: 0, | |||
| dy: 0, | |||
| x: undefined, | |||
| y: undefined, | |||
| }); | |||
| const { listeners } = ctx; | |||
| const { handleBrushEnd, state, createLabel, queryLabels, updateState, deleteAnnotation, handleConfirm } = props; | |||
| const { drawBboxEnd, state, createLabel, queryLabels, updateState, deleteAnnotation, handleConfirm } = props; | |||
| const { | |||
| brush, | |||
| onBrushStart, | |||
| onBrushMove, | |||
| onBrushEnd, | |||
| updateBrush, | |||
| getExtent, | |||
| // onBrushEnd, | |||
| onBrushReset, | |||
| } = useBrush(); | |||
| @@ -214,7 +255,7 @@ export default { | |||
| }; | |||
| // 初始放大和缩小函数 | |||
| const { zoomIn, zoomOut, setZoom, reset: resetZoom, zoom } = useZoom(initialZoom, imgWrapperRef); | |||
| const { zoomIn, zoomOut, setZoom, reset: resetZoom, zoom, getZoom } = useZoom(initialZoom, imgWrapperRef); | |||
| // tooltip | |||
| const { tooltipData, showTooltip, hideTooltip } = useTooltip(imgWrapperRef); | |||
| @@ -233,6 +274,16 @@ export default { | |||
| Object.assign(api, params); | |||
| }; | |||
| // 更新标注偏移 | |||
| const setTransformer = params => { | |||
| Object.assign(transformer, params); | |||
| }; | |||
| // 转换 zoom 位置 | |||
| const transformZoom = (point) => { | |||
| return getZoomPosition(ctx.refs.zoomRef.wrapperRef, point); | |||
| }; | |||
| // 监听 currentImage 变化 | |||
| watch(() => props.currentImg, (nextImg) => { | |||
| // 每次切换图片重置 zoom | |||
| @@ -241,7 +292,7 @@ export default { | |||
| Object.assign(api, { | |||
| label: {}, | |||
| isCenter: false, | |||
| imgBoundingLeft: null, | |||
| imgBounding: null, | |||
| }); | |||
| if (nextImg?.url) { | |||
| setImg(nextImg.url); | |||
| @@ -286,13 +337,15 @@ export default { | |||
| // 如果图片有缩放,直接取容器尺寸即可 | |||
| const svgDimension = { | |||
| width: imgScale < 1 ? cw : Math.min(iw, cw), | |||
| height: imgScale < 1 ? ch : Math.min(ih, ch), | |||
| height: imgScale < 1 ? ch - FooterHeight : Math.min(ih, ch), | |||
| }; | |||
| // 标注相关元素的容器 | |||
| const annotationGroupStyle = { | |||
| left: imgScale === 1 ? `${(cw - iw) / 2}px` : 0, | |||
| top: imgScale === 1 ? `${(ch - FooterHeight - ih) / 2}px` : 0, | |||
| width: imgScale === 1 ? `${iw}px` : `${cw}px`, | |||
| height: imgScale === 1 ? `${ih}px` : `${ch-FooterHeight}px`, | |||
| }; | |||
| // 上面已经通过margin: 0 auto 做过宽度处理 | |||
| @@ -368,7 +421,7 @@ export default { | |||
| callback(); | |||
| } else if (!msgInstance) { | |||
| msgInstance = Message.warning({ | |||
| message: '当前图片不存在或图片已经到顶了', | |||
| message: '当前图片不存在或图片已经到底了', | |||
| onClose: onMessageClose, | |||
| }); | |||
| } | |||
| @@ -393,13 +446,13 @@ export default { | |||
| watch(() => api.isCenter, (isCenter) => { | |||
| if (isCenter) { | |||
| const { width: boundingWidth } = api.bounding; | |||
| const { width: imgWidth } = getBounding(imgRef.value); | |||
| const { width: boundingWidth, height: boundingHeight } = api.bounding; | |||
| const { width: imgWidth, height: imgHeight } = getBounding(imgRef.value); | |||
| // todo: 缩放图片取容器尺寸,否则取图片尺寸 | |||
| const mw = dimension.value.scale < 1 ? boundingWidth : dimension.value.img.width; | |||
| const mh = dimension.value.scale < 1 ? boundingHeight - FooterHeight : dimension.value.img.height; | |||
| Object.assign(api, { | |||
| imgBoundingLeft: (mw - imgWidth) / 2, | |||
| imgBounding: [(mw - imgWidth) / 2, (mh - imgHeight) / 2], | |||
| }); | |||
| } | |||
| }, { | |||
| @@ -419,41 +472,38 @@ export default { | |||
| n: selection, | |||
| })); | |||
| const handleMouseDown = (event) => { | |||
| if (brush.start && brush.end) { | |||
| // 首先清理已有的 brush 状态 | |||
| onBrushReset(); | |||
| } | |||
| // 选中标注 | |||
| const setCurAnnotation = (annotation = {}) => { | |||
| updateState({ | |||
| currentAnnotationId: annotation.id || '', | |||
| }); | |||
| }; | |||
| // 开始绘制 | |||
| const handleBrushStart = (start) => { | |||
| // 关闭已有的 dropdown | |||
| hideTooltip(); | |||
| // 判断是否开启选框 | |||
| if (!state.selection.value) return; | |||
| const [x, y] = getCursorPosition(svgRef.value, event); | |||
| // 根据绝对路径生成相对于 zoom 之后的位置 | |||
| const zoomePos = getZoomPosition(ctx.refs.zoomRef.wrapperRef, [x, y]); | |||
| onBrushStart({ x: zoomePos[0], y: zoomePos[1] }); | |||
| // if (!state.selection.value) return; | |||
| const {x, y} = start; | |||
| onBrushStart({ x, y }); | |||
| // 重置当前选中的标注 | |||
| setCurAnnotation(undefined); | |||
| }; | |||
| const handleMouseMove = (event) => { | |||
| if (!brush.isBrushing) return; | |||
| const [x, y] = getCursorPosition(svgRef.value, event); | |||
| // 根据绝对路径生成相对于 zoom 之后的位置 | |||
| const zoomePos = getZoomPosition(ctx.refs.zoomRef.wrapperRef, [x, y]); | |||
| onBrushMove({ x: zoomePos[0], y: zoomePos[1] }); | |||
| const handleBrushMove = (state) => { | |||
| const {x, y} = state.end || {}; | |||
| onBrushMove({ x, y }); | |||
| }; | |||
| const handleMouseUp = (event) => { | |||
| if (brush.end) { | |||
| const [x, y] = getCursorPosition(svgRef.value, event); | |||
| // 根据绝对路径生成相对于 zoom 之后的位置 | |||
| const zoomePos = getZoomPosition(ctx.refs.zoomRef.wrapperRef, [x, y]); | |||
| onBrushEnd(({ x: zoomePos[0], y: zoomePos[1] })); | |||
| const handleBrushEnd = (state, event, options = {}) => { | |||
| const { prevState = {} } = options; | |||
| // 确认是move 之后触发 | |||
| if(state.end && !!prevState.isDragging) { | |||
| // 展示tooltip | |||
| showTooltip({}, event); | |||
| // 回调 | |||
| handleBrushEnd && handleBrushEnd(brush, event); | |||
| drawBboxEnd && drawBboxEnd(state, event); | |||
| onBrushReset(); | |||
| return; | |||
| } | |||
| @@ -468,10 +518,85 @@ export default { | |||
| return !!(labels.value || []).find(label => label.id === Number(value)); | |||
| }; | |||
| // 选中注释 | |||
| const handleBboxClick = (annotation) => () => { | |||
| updateState({ | |||
| currentAnnotationId: annotation.id, | |||
| // 标注偏移 | |||
| const offset = (annotate) => { | |||
| const { data = {} } = annotate; | |||
| const { extent } = data; | |||
| const _bbox = extent2Bbox(extent); | |||
| const paddingLeft = (dimension.value.scale < 1 && !isNil(api.imgBounding)) | |||
| ? api.imgBounding[0] | |||
| : 0; | |||
| const paddingTop = (dimension.value.scale < 1 && !isNil(api.imgBounding)) | |||
| ? api.imgBounding[1] | |||
| : 0; | |||
| const pos = { | |||
| x: _bbox.x * dimension.value.scale + paddingLeft, | |||
| y: _bbox.y * dimension.value.scale + paddingTop, | |||
| width: _bbox.width * dimension.value.scale, | |||
| height: _bbox.height * dimension.value.scale, | |||
| }; | |||
| return pos; | |||
| }; | |||
| // handle 变更 | |||
| const onBrushHandleChange = (brush, annotation) => { | |||
| // 同步 brush | |||
| const pos = offset(annotation); | |||
| updateState(prev => { | |||
| const index = prev.annotations.findIndex(d => d.id === annotation.id); | |||
| if (index > -1) { | |||
| const selectedItem = prev.annotations[index]; | |||
| const _nextItem = { | |||
| ...selectedItem, | |||
| data: { | |||
| ...selectedItem.data, | |||
| extent: brush.extent, | |||
| }, | |||
| }; | |||
| const nextAnnotations = replace(prev.annotations, index, _nextItem); | |||
| return { | |||
| ...prev, | |||
| annotations: nextAnnotations, | |||
| }; | |||
| } | |||
| }); | |||
| // 更新brush | |||
| updateBrush(prevBrush => { | |||
| return { | |||
| ...prevBrush, | |||
| isBrushing: true, | |||
| extent: { | |||
| x0: pos.x, | |||
| x1: pos.x + pos.width, | |||
| y0: pos.y, | |||
| y1: pos.y + pos.height, | |||
| }, | |||
| }; | |||
| }); | |||
| }; | |||
| // handle 拖拽完成 | |||
| const onBrushHandleEnd = (brush, annotation) => { | |||
| // 同步 brush | |||
| const pos = offset(annotation); | |||
| // 更新brush | |||
| updateBrush(prevBrush => { | |||
| return { | |||
| ...prevBrush, | |||
| isBrushing: false, | |||
| extent: { | |||
| x0: pos.x, | |||
| x1: pos.x + pos.width, | |||
| y0: pos.y, | |||
| y1: pos.y + pos.height, | |||
| }, | |||
| }; | |||
| }); | |||
| }; | |||
| @@ -507,6 +632,8 @@ export default { | |||
| const curAnnotation = annotations.value.find(d => d.id === currentAnnotationId.value) || {}; | |||
| // 触发标注对应标签变更事件 | |||
| ctx.emit('selectLabel', { selectedLabel, curAnnotation }); | |||
| // 选择标签完成关闭选择器 | |||
| hideTooltip(); | |||
| }; | |||
| const handleZoom = (nextZoomTransform) => { | |||
| @@ -518,6 +645,139 @@ export default { | |||
| }); | |||
| }; | |||
| // 每次拖拽的优先级提升 | |||
| const onDragStart = (draw, annotation) => { | |||
| const index = api.annotations.findIndex(d => d.id === annotation.id); | |||
| if (index > -1) { | |||
| const raised = raise(api.annotations, index); | |||
| Object.assign(api, { | |||
| annotations: raised, | |||
| }); | |||
| } | |||
| // 同步当前标注 | |||
| setCurAnnotation(annotation); | |||
| // 同步 brush | |||
| const pos = offset(annotation); | |||
| updateBrush(prevBrush => { | |||
| const start = { | |||
| x: pos.x, | |||
| y: pos.y, | |||
| }; | |||
| const end = { | |||
| x: pos.x + pos.width, | |||
| y: pos.y + pos.height, | |||
| }; | |||
| return { | |||
| ...prevBrush, | |||
| start, | |||
| end, | |||
| extent: getExtent(start, end), | |||
| }; | |||
| }); | |||
| }; | |||
| // 拖拽 boxing 更新位置 | |||
| const onDragMove = (draw, annotation) => { | |||
| const pos = offset(annotation); | |||
| const { drag = {} } = draw; | |||
| const { zoom } = getZoom(); | |||
| const validDx = | |||
| drag.dx > 0 | |||
| ? Math.min(drag.dx / zoom, dimension.value.svg.width - pos.x - pos.width) | |||
| : Math.max(drag.dx / zoom, -pos.x); | |||
| const validDy = | |||
| drag.dy > 0 | |||
| ? Math.min(drag.dy / zoom, dimension.value.svg.height - pos.y - pos.height) | |||
| : Math.max(drag.dy / zoom, -pos.y); | |||
| // 更新 brush 位置 | |||
| updateBrush(prevBrush => { | |||
| const { x: x0, y: y0 } = prevBrush.start; | |||
| const { x: x1, y: y1 } = prevBrush.end; | |||
| return { | |||
| ...prevBrush, | |||
| isBrushing: true, | |||
| extent: { | |||
| ...prevBrush.extent, | |||
| x0: x0 + validDx, | |||
| x1: x1 + validDx, | |||
| y0: y0 + validDy, | |||
| y1: y1 + validDy, | |||
| }, | |||
| }; | |||
| }); | |||
| setTransformer({ | |||
| isDragging: true, | |||
| id: annotation.id, | |||
| x: drag.x, | |||
| y: drag.y, | |||
| dx: validDx, | |||
| dy: validDy, | |||
| }); | |||
| }; | |||
| // 拖拽 boxing 结束,更新位置 | |||
| const onDragEnd = (draw, annotation) => { | |||
| const { drag = {} } = draw; | |||
| // 重置标注 transform | |||
| setTransformer({ | |||
| isDragging: false, | |||
| id: annotation.id, | |||
| x: drag.x, | |||
| y: drag.y, | |||
| dx: 0, | |||
| dy: 0, | |||
| }); | |||
| updateState(prev => { | |||
| const index = prev.annotations.findIndex(d => d.id === annotation.id); | |||
| if (index > -1) { | |||
| const selectedItem = prev.annotations[index]; | |||
| const _nextItem = { | |||
| ...selectedItem, | |||
| data: { | |||
| ...selectedItem.data, | |||
| extent: { | |||
| // todo: 如果到达边界就不需要zoom | |||
| x0: selectedItem.data.extent.x0 + (drag.validDx || 0), | |||
| y0: selectedItem.data.extent.y0 + (drag.validDy || 0), | |||
| x1: selectedItem.data.extent.x1 + (drag.validDx || 0), | |||
| y1: selectedItem.data.extent.y1 + (drag.validDy || 0), | |||
| }, | |||
| }, | |||
| }; | |||
| const nextAnnotations = replace(prev.annotations, index, _nextItem); | |||
| return { | |||
| ...prev, | |||
| annotations: nextAnnotations, | |||
| }; | |||
| } | |||
| }); | |||
| // 更新 brush 位置 | |||
| updateBrush(prevBrush => { | |||
| return { | |||
| ...prevBrush, | |||
| isBrushing: false, | |||
| start: { | |||
| ...prevBrush.start, | |||
| x: Math.min(prevBrush.extent.x0, prevBrush.extent.x1), | |||
| y: Math.min(prevBrush.extent.y0, prevBrush.extent.y1), | |||
| }, | |||
| end: { | |||
| ...prevBrush.end, | |||
| x: Math.max(prevBrush.extent.x0, prevBrush.extent.x1), | |||
| y: Math.max(prevBrush.extent.y0, prevBrush.extent.y1), | |||
| }, | |||
| }; | |||
| }); | |||
| }; | |||
| onMounted(() => { | |||
| addEventListener(document.body, 'click', (e) => { | |||
| // 如果不在画布内,直接清空 | |||
| @@ -547,11 +807,7 @@ export default { | |||
| imgWrapperRef, | |||
| // labels | |||
| labels, | |||
| // brush | |||
| brush, | |||
| handleMouseDown, | |||
| handleMouseMove, | |||
| handleMouseUp, | |||
| clearSelection, | |||
| filter, | |||
| // zoom | |||
| @@ -574,13 +830,31 @@ export default { | |||
| // event | |||
| handleSelectChange, | |||
| confirm, | |||
| handleBboxClick, | |||
| onDragStart, | |||
| onDragMove, | |||
| onDragEnd, | |||
| keymap, | |||
| // brush 事件 | |||
| handleBrushStart, | |||
| handleBrushMove, | |||
| handleBrushEnd, | |||
| // 标注偏移 | |||
| offset, | |||
| transformer, | |||
| setTransformer, | |||
| onBrushHandleChange, | |||
| onBrushHandleEnd, | |||
| // 缩放情况下将绝对位置转换为相对路径 | |||
| transformZoom, | |||
| getZoom, | |||
| setCurAnnotation, | |||
| }; | |||
| }, | |||
| }; | |||
| </script> | |||
| <style lang='scss'> | |||
| @import "~@/assets/styles/variables.scss"; | |||
| #stage { | |||
| max-height: 100%; | |||
| } | |||
| @@ -597,6 +871,7 @@ export default { | |||
| display: inline-block; | |||
| width: 100%; | |||
| height: 100%; | |||
| user-select: none; | |||
| } | |||
| } | |||
| } | |||
| @@ -610,6 +885,8 @@ export default { | |||
| } | |||
| .annotation-score-group { | |||
| pointer-events: none; | |||
| .annotation-score-row { | |||
| position: absolute; | |||
| color: #fff; | |||
| @@ -630,16 +907,33 @@ export default { | |||
| } | |||
| .annotation-tag-group { | |||
| pointer-events: none; | |||
| .annotation-label { | |||
| position: absolute; | |||
| color: #fff; | |||
| pointer-events: none; | |||
| } | |||
| } | |||
| .bbox-group { | |||
| cursor: pointer; | |||
| } | |||
| .brush-tooltip { | |||
| position: absolute; | |||
| padding: 7px 12px; | |||
| font-size: 12px; | |||
| line-height: 1em; | |||
| color: #fff; | |||
| pointer-events: none; | |||
| background-color: $dark; | |||
| border-radius: 4px; | |||
| .tooltip-item-row { | |||
| display: flex; | |||
| white-space: nowrap; | |||
| } | |||
| } | |||
| } | |||
| </style> | |||
| @@ -15,12 +15,10 @@ | |||
| */ | |||
| import { isNil } from 'lodash'; | |||
| import { addSuffix } from '@/utils'; | |||
| import { addSuffix, colorByLuminance, chroma } from '@/utils'; | |||
| import { defaultColor } from './bbox'; | |||
| const chroma = require('chroma-js'); | |||
| // 分数最小宽度 | |||
| const MinWidth = 48; | |||
| @@ -29,39 +27,28 @@ export default { | |||
| functional: true, | |||
| props: { | |||
| annotate: Object, | |||
| scale: { | |||
| type: Number, | |||
| }, | |||
| imgBoundingLeft: Number, | |||
| offset: Function, | |||
| transformer: Object, | |||
| brush: Object, | |||
| currentAnnotationId: String, | |||
| }, | |||
| render(h, context) { | |||
| const { props } = context; | |||
| const { | |||
| annotate = {}, | |||
| imgBoundingLeft, | |||
| offset, | |||
| transformer, | |||
| brush, | |||
| } = props; | |||
| const { data = {}, __type } = annotate; | |||
| const { data = {}, id } = annotate; | |||
| const { bbox, color = defaultColor, score = 1 } = data; | |||
| if (isNil(bbox)) return null; | |||
| // 是否为草稿模式 | |||
| const isDraft = __type === 0; | |||
| const paddingLeft = (props.scale < 1 && !isNil(imgBoundingLeft)) | |||
| ? imgBoundingLeft | |||
| : 0; | |||
| // 当前在拖拽中不展示 | |||
| if(props.currentAnnotationId === id && brush.isBrushing) return null; | |||
| const pos = isDraft ? { | |||
| x: bbox.x, | |||
| y: bbox.y, | |||
| width: bbox.width, | |||
| height: bbox.height, | |||
| } : { | |||
| x: bbox.x * props.scale + paddingLeft, | |||
| y: bbox.y * props.scale, | |||
| width: bbox.width * props.scale, | |||
| height: bbox.height * props.scale, | |||
| }; | |||
| if (isNil(bbox)) return null; | |||
| const pos = offset(props.annotate); | |||
| const style = { | |||
| width: addSuffix(pos.width), | |||
| @@ -70,8 +57,14 @@ export default { | |||
| minWidth: addSuffix(MinWidth), | |||
| }; | |||
| // 匹配当前标注 | |||
| if(annotate.id === transformer.id) { | |||
| style.transform = `translate(${transformer.dx}px, ${transformer.dy}px)`; | |||
| } | |||
| const boxStyle = { | |||
| backgroundColor: chroma(color).alpha(0.8), | |||
| color: colorByLuminance(color), | |||
| }; | |||
| return ( | |||
| @@ -15,66 +15,61 @@ | |||
| */ | |||
| import { isNil } from 'lodash'; | |||
| import { addSuffix } from '@/utils'; | |||
| import { addSuffix, chroma, colorByLuminance } from '@/utils'; | |||
| import { defaultColor } from './bbox'; | |||
| const chroma = require('chroma-js'); | |||
| export default { | |||
| name: 'Tag', | |||
| functional: true, | |||
| props: { | |||
| annotate: Object, | |||
| scale: { | |||
| type: Number, | |||
| }, | |||
| imgBoundingLeft: Number, | |||
| offset: Function, | |||
| transformer: Object, | |||
| getLabelName: Function, | |||
| brush: Object, | |||
| currentAnnotationId: String, | |||
| }, | |||
| render(h, context) { | |||
| const { props } = context; | |||
| const { | |||
| annotate = {}, | |||
| imgBoundingLeft, | |||
| getLabelName, | |||
| offset, | |||
| transformer, | |||
| brush, | |||
| } = props; | |||
| const { data = {}, __type } = annotate; | |||
| const { data = {}, id } = annotate; | |||
| const { bbox, color = defaultColor } = data; | |||
| // 当前在拖拽中不展示 | |||
| if(props.currentAnnotationId === id && brush.isBrushing) return null; | |||
| if (isNil(bbox)) return null; | |||
| // 是否为草稿模式 | |||
| const isDraft = __type === 0; | |||
| const paddingLeft = (props.scale < 1 && !isNil(imgBoundingLeft)) | |||
| ? imgBoundingLeft | |||
| : 0; | |||
| const pos = isDraft ? { | |||
| x: bbox.x, | |||
| y: bbox.y, | |||
| width: bbox.width, | |||
| height: bbox.height, | |||
| } : { | |||
| x: bbox.x * props.scale + paddingLeft, | |||
| y: bbox.y * props.scale, | |||
| width: bbox.width * props.scale, | |||
| height: bbox.height * props.scale, | |||
| }; | |||
| const pos = offset(props.annotate); | |||
| const style = { | |||
| width: addSuffix(pos.width), | |||
| left: addSuffix(pos.x), | |||
| top: addSuffix(pos.y), | |||
| color: colorByLuminance(color), | |||
| }; | |||
| // 匹配当前标注 | |||
| if(annotate.id === transformer.id) { | |||
| style.transform = `translate(${transformer.dx}px, ${transformer.dy}px)`; | |||
| } | |||
| const tagColor = chroma(color).alpha(0.8).toString(); | |||
| const tagName = getLabelName(data.categoryId); | |||
| if (!tagName) return null; | |||
| return ( | |||
| <div class='annotation-label image-tag' style={style}> | |||
| <el-tag color={tagColor} style={{ color: '#fff', border: 'none' }}>{tagName}</el-tag> | |||
| <el-tag color={tagColor} disable-transitions style={{ color: 'inherit', border: 'none' }}>{tagName}</el-tag> | |||
| </div> | |||
| ); | |||
| }, | |||
| @@ -29,12 +29,12 @@ | |||
| <div class="classify-container flex"> | |||
| <!--文件列表展示--> | |||
| <div class="file-list-container"> | |||
| <div class="app-container"> | |||
| <div v-loading="crud.loading" class="app-container"> | |||
| <!--tabs页和工具栏--> | |||
| <div class="classify-tab"> | |||
| <el-tabs :value="lastTabName" @tab-click="handleTabClick"> | |||
| <el-tab-pane label="未标注" name="unannotate" /> | |||
| <el-tab-pane label="已标注" name="annotate" /> | |||
| <el-tab-pane label="未完成" name="unannotate" /> | |||
| <el-tab-pane label="已完成" name="annotate" /> | |||
| </el-tabs> | |||
| <div class="classify-button flex flex-between flex-vertical-align"> | |||
| <div class="row-left"> | |||
| @@ -111,7 +111,16 @@ | |||
| <!--Label列表展示--> | |||
| <div class="label-list-container"> | |||
| <div class="fixed-label-list"> | |||
| <div v-if="showCreateLabel" class="mb-22"> | |||
| <div v-if="datasetInfo.labelGroupId" class='mb-10'> | |||
| <label class="el-form-item__label no-float tl">标签组</label> | |||
| <div class="f14"> | |||
| <span class="vm">{{ datasetInfo.labelGroupName }} </span> | |||
| <el-link target="_blank" type="primary" :underline="false" class="vm" :href="`/data/labelgroup/detail?id=${datasetInfo.labelGroupId}`"> | |||
| 查看详情 | |||
| </el-link> | |||
| </div> | |||
| </div> | |||
| <div v-if="datasetInfo.labelGroupType !== 1" class="mb-22"> | |||
| <LabelTip /> | |||
| <div class="flex flex-between"> | |||
| <InfoSelect | |||
| @@ -136,7 +145,15 @@ | |||
| <div style="max-height: 200px; padding: 0 2.5px; overflow: auto;"> | |||
| <el-row :gutter="5" style="clear: both;"> | |||
| <el-col v-for="data in labelData" :key="data.id" :span="8"> | |||
| <el-tag class="tag-item" :title="data.name" :color="data.color" :style="getStyle(data)" @click="chooseLabel(data)">{{ data.name }}</el-tag> | |||
| <el-tag class="tag-item" :title="data.name" :color="data.color" :style="getStyle(data)" @click="event => chooseLabel(data, event)"> | |||
| <span :title="data.name">{{ data.name }}</span> | |||
| <EditLabel | |||
| v-if="!data.labelGroupId" | |||
| :getStyle="getStyle" | |||
| :item="data" | |||
| @handleOk="handleEditLabel" | |||
| /> | |||
| </el-tag> | |||
| </el-col> | |||
| </el-row> | |||
| </div> | |||
| @@ -163,11 +180,13 @@ | |||
| <script> | |||
| import { without, isNil } from 'lodash'; | |||
| import { Message } from 'element-ui'; | |||
| import { queryDataEnhanceList } from '@/api/preparation/dataset'; | |||
| import { transformFile, transformFiles, getImgFromMinIO, dataEnhanceMap, withDimensionFile } from '@/views/dataset/util'; | |||
| import { colorByLuminance } from '@/utils'; | |||
| import { queryDataEnhanceList, detail } from '@/api/preparation/dataset'; | |||
| import { transformFile, transformFiles, getImgFromMinIO, dataEnhanceMap, withDimensionFile, fileCodeMap } from '@/views/dataset/util'; | |||
| import crudDataFile, { list, del , submit } from '@/api/preparation/datafile'; | |||
| import { getAutoLabels, getLabels, createLabel } from '@/api/preparation/datalabel'; | |||
| import { getAutoLabels, getLabels, createLabel, editLabel } from '@/api/preparation/datalabel'; | |||
| import { batchFinishAnnotation } from '@/api/preparation/annotation'; | |||
| import CRUD, { presenter, header, crud } from '@crud/crud'; | |||
| import ImageGallery from '@/components/ImageGallery'; | |||
| @@ -178,18 +197,18 @@ import SortingMenu from '@/components/SortingMenu'; | |||
| import SearchLabel from './components/searchLabel'; | |||
| import LabelTip from './annotate/settingContainer/labelTip'; | |||
| import PicInfoModal from './components/picInfoModal'; | |||
| import EditLabel from './annotate/settingContainer/labelList/edit'; | |||
| const chroma = require('chroma-js'); | |||
| // eslint-disable-next-line import/no-extraneous-dependencies | |||
| const path = require('path'); | |||
| export default { | |||
| name: 'Classify', | |||
| components: { ImageGallery, UploadForm, InfoCard, InfoSelect, SearchLabel, LabelTip, SortingMenu, PicInfoModal }, | |||
| components: { ImageGallery, UploadForm, InfoCard, InfoSelect, SearchLabel, LabelTip, SortingMenu, PicInfoModal, EditLabel }, | |||
| cruds() { | |||
| const id = this.parent.$route.params.datasetId; | |||
| const crudObj = CRUD({ title: '数据分类', crudMethod: { ...crudDataFile }}); | |||
| crudObj.params = { 'datasetId': id, 'status': [0] }; | |||
| crudObj.params = { 'datasetId': id, 'status': fileCodeMap.UNCOMPLETED }; | |||
| crudObj.page.size = 30; | |||
| return crudObj; | |||
| }, | |||
| @@ -203,18 +222,18 @@ export default { | |||
| data() { | |||
| return { | |||
| datasetId: 0, | |||
| datasetInfo: {}, | |||
| uploadDialogVisible: false, | |||
| lastTabName: 'unannotate', | |||
| crudStatusMap: { | |||
| 'unannotate': [0], | |||
| 'annotate': [2, 3], | |||
| 'unannotate': [fileCodeMap.UNCOMPLETED], | |||
| 'annotate': [fileCodeMap.COMPLETED], | |||
| }, | |||
| newLabel: undefined, | |||
| checkAll: false, | |||
| isIndeterminate: false, | |||
| typeSwitch: true, | |||
| rawLabelData: [], | |||
| showCreateLabel: true, | |||
| labelData: [], | |||
| name2CategoryId: {}, | |||
| // 选中列表 | |||
| @@ -251,8 +270,8 @@ export default { | |||
| this.datasetId = parseInt(this.$route.params.datasetId, 10); | |||
| this.refreshLabel(); | |||
| Promise.all([ | |||
| list({ 'datasetId': this.datasetId, 'status': [0] }), | |||
| list({ 'datasetId': this.datasetId, 'status': [2, 3] }), | |||
| list({ 'datasetId': this.datasetId, 'status': [fileCodeMap.UNCOMPLETED] }), | |||
| list({ 'datasetId': this.datasetId, 'status': [fileCodeMap.COMPLETED] }), | |||
| ]) | |||
| .then(([unannotate, annotate]) => { | |||
| if (unannotate.result.length === 0 && annotate.result.length !== 0) { | |||
| @@ -262,6 +281,9 @@ export default { | |||
| } | |||
| }); | |||
| detail(this.datasetId).then(res => { | |||
| this.datasetInfo = res || {}; | |||
| }); | |||
| // 系统标签 | |||
| this.getSystemLabel(); | |||
| }, | |||
| @@ -277,6 +299,9 @@ export default { | |||
| })(); | |||
| }, | |||
| methods: { | |||
| handleEditLabel(field, item){ | |||
| editLabel(item.id, field).then(this.refreshLabel); | |||
| }, | |||
| handleSort(command) { | |||
| this.resetQuery(); | |||
| this.crud.params.order = command === 1 ? 'name' : ''; | |||
| @@ -340,6 +365,10 @@ export default { | |||
| }; | |||
| if (ids.length) { | |||
| del(params).then(() => { | |||
| this.$message({ | |||
| message: '删除文件成功', | |||
| type: 'success', | |||
| }); | |||
| this.crud.toQuery(); | |||
| }).finally(() => { | |||
| this.crud.delAllLoading = false; | |||
| @@ -354,6 +383,7 @@ export default { | |||
| }, | |||
| handleCheckAllChange(val) { | |||
| const {imgGallery} = this.$refs; | |||
| if(!imgGallery) return false; | |||
| if (val) { | |||
| imgGallery.selectAll(); | |||
| } else { | |||
| @@ -442,10 +472,6 @@ export default { | |||
| }, | |||
| refreshLabel() { | |||
| getLabels(this.datasetId).then((res) => { | |||
| // 图像分类使用的是预置标签时,不显示新建标签功能,目前自定义标签type为0,自动标注标签为1 | |||
| if (res[0] && res[0].type > 1) { | |||
| this.showCreateLabel = false; | |||
| } | |||
| this.rawLabelData = res; | |||
| this.rawLabelData.forEach((item) => { | |||
| if (item.color === '#000000') { | |||
| @@ -460,7 +486,9 @@ export default { | |||
| this.labelData = this.rawLabelData; | |||
| }); | |||
| }, | |||
| chooseLabel(row) { | |||
| chooseLabel(row, event) { | |||
| // 过滤编辑入口 | |||
| if (event.target.classList.contains('el-icon-edit')) return; | |||
| if (this.selectImgsId.length > 0) { | |||
| const annotations = []; | |||
| this.selectImgsId.forEach((item) => { | |||
| @@ -472,7 +500,7 @@ export default { | |||
| id: item, | |||
| }); | |||
| }); | |||
| batchFinishAnnotation({ annotations }).then(() => { | |||
| batchFinishAnnotation({ annotations }, this.datasetId).then(() => { | |||
| this.crud.refresh(); | |||
| this.handleCheckAllChange(0); | |||
| }); | |||
| @@ -489,6 +517,8 @@ export default { | |||
| // 如果不是系统标签,才会选择新建 | |||
| if (this.systemLabels.findIndex(d => d.value === value) === -1) { | |||
| this.addLabel(value); | |||
| // 新建标签 | |||
| this.postLabel(); | |||
| } else { | |||
| const systemLabel = this.systemLabels.find(d => d.value === value) || {}; | |||
| systemLabel.label && this.addLabel(systemLabel.label); | |||
| @@ -512,6 +542,8 @@ export default { | |||
| this.newLabel = undefined; | |||
| this.refreshLabel(); | |||
| }); | |||
| } else { | |||
| Message.warning('请选择标签'); | |||
| } | |||
| }, | |||
| switchLabelTag(newSwitch) { | |||
| @@ -519,13 +551,8 @@ export default { | |||
| }, | |||
| getStyle(item) { | |||
| // 根据亮度来决定颜色 | |||
| if (item.color && chroma(item.color).luminance() < 0.5) { | |||
| return { | |||
| color: '#fff', | |||
| }; | |||
| } | |||
| return { | |||
| color: '#000', | |||
| color: colorByLuminance(item.color), | |||
| }; | |||
| }, | |||
| }, | |||
| @@ -36,7 +36,7 @@ | |||
| > | |||
| <el-carousel-item v-for="item in fileList" :key="item.id"> | |||
| <div class="figure-action-row rel" :style="buildActionRow(item)"> | |||
| <div v-if="item.enhanceTag" class="action-tag tc">{{ item.enhanceTag.label }}</div> | |||
| <div v-if="item.enhanceTag" class="action-tag tc">增强类型:{{ item.enhanceTag.label }}</div> | |||
| </div> | |||
| <div class="figure-wrapper carousel-figure-item"> | |||
| <div | |||
| @@ -13,22 +13,28 @@ | |||
| * limitations under the License. | |||
| * ============================================================= | |||
| */ | |||
| import { statusCodeMap } from '../util'; | |||
| export default { | |||
| name: 'DatasetAction', | |||
| functional: true, | |||
| props: { | |||
| showPublish: Function, | |||
| openUploadDialog: Function, | |||
| uploadDataFile: Function, | |||
| goDetail: Function, | |||
| getAutoAnnotateStatus: Function, | |||
| autoAnnotate: Function, | |||
| gotoVersion: Function, | |||
| reAnnotation: Function, | |||
| track: Function, | |||
| dataEnhance: Function, | |||
| topDataset: Function, | |||
| editDataset: Function, | |||
| checkImport: Function, // 查询外部数据集导入状态 | |||
| }, | |||
| render(h, { data, props }) { | |||
| const { showPublish, openUploadDialog, goDetail, autoAnnotate, gotoVersion, reAnnotation, dataEnhance } = props; | |||
| const { showPublish, uploadDataFile, goDetail, autoAnnotate, gotoVersion, reAnnotation, track, dataEnhance, topDataset, editDataset, checkImport } = props; | |||
| const columnProps = { | |||
| ...data, | |||
| scopedSlots: { | |||
| @@ -36,10 +42,6 @@ export default { | |||
| return ( | |||
| <span> | |||
| <span>操作</span> | |||
| <el-tooltip effect='dark' placement='top' style={{ marginLeft: '10px' }}> | |||
| <div slot='content'>如果数据集操作没有更新,<br/>可能是后台算法在执行其他任务,<br/>请耐心等待或稍后重试</div> | |||
| <i class='el-icon-question'/> | |||
| </el-tooltip> | |||
| </span> | |||
| ); | |||
| }, | |||
| @@ -55,9 +57,9 @@ export default { | |||
| }, | |||
| }; | |||
| // 查看标注按钮在 自动标注中(2) 未采样(5) 采样中(7) 数据增强中(8)时不显示, 此外,类型为视频时,自动标注完成(3)也不可查看(此时下游会进行目标跟踪) | |||
| let showCheckButton = ![2, 5, 7, 8].includes(row.status); | |||
| if (row.dataType === 1 && row.status === 3) { | |||
| // 查看标注按钮在 自动标注中 未采样 采样中 采样失败 目标跟踪中 数据增强中 目标跟踪失败 时不显示, 此外,类型为视频时,自动标注完成也不可查看(此时下游会进行目标跟踪) | |||
| let showCheckButton = !['AUTO_ANNOTATING', 'UNSAMPLED', 'SAMPLING', 'SAMPLE_FAILED', 'TRACKING', 'ENHANCING', 'TRACK_FAILED'].includes(statusCodeMap[row.status]); | |||
| if (row.dataType === 1 && statusCodeMap[row.status] === 'AUTO_ANNOTATED') { | |||
| showCheckButton = false; | |||
| } | |||
| // 查看标注按钮 | |||
| @@ -67,8 +69,8 @@ export default { | |||
| </el-button> | |||
| ); | |||
| // 自动标注按钮在 自动标注中(2) 自动标注完成(3) 标注完成(4) 未采样(5) 目标跟踪完成(6) 采样中(7) 数据增强中(8)时不显示 | |||
| let showAutoButton = ![2, 3, 4, 5, 6, 7, 8].includes(row.status); | |||
| // 自动标注按钮只在 未标注 标注中 时显示 | |||
| let showAutoButton = ['UNANNOTATED', 'ANNOTATING'].includes(statusCodeMap[row.status]); | |||
| // 自动标注按钮 | |||
| const autoButton = ( | |||
| <el-button {...btnProps} onClick={() => autoAnnotate(row)}> | |||
| @@ -109,17 +111,17 @@ export default { | |||
| </el-button> | |||
| ); | |||
| // 当类型为视频时,状态为标注完成(4)目标跟踪完成(6)显示发布按钮,其余状态不显示发布按钮 | |||
| // 当类型为图片时,状态为自动标注完成(3)显示有弹窗确认的发布按钮,为标注完成(4)显示发布按钮,其余状态不显示发布按钮 | |||
| // 当类型为视频时,状态为标注完成、目标跟踪完成时显示发布按钮,其余状态不显示发布按钮 | |||
| // 当类型为图片时,状态为自动标注完成时显示有弹窗确认的发布按钮,为标注完成时显示发布按钮,其余状态不显示发布按钮 | |||
| if (row.dataType === 1) { | |||
| if ([4, 6].includes(row.status)) { | |||
| if (['ANNOTATED', 'TRACK_SUCCEED'].includes(statusCodeMap[row.status])) { | |||
| showPublishButton = true; | |||
| publishButton = publishDialogButton; | |||
| } | |||
| } else if (row.status === 3) { | |||
| } else if (statusCodeMap[row.status] === 'AUTO_ANNOTATED') { | |||
| showPublishButton = true; | |||
| publishButton = publishConfirmButton; | |||
| } else if (row.status === 4) { | |||
| } else if (statusCodeMap[row.status] === 'ANNOTATED') { | |||
| showPublishButton = true; | |||
| publishButton = publishDialogButton; | |||
| } | |||
| @@ -127,22 +129,22 @@ export default { | |||
| let showUploadButton = false; | |||
| // 导入按钮 | |||
| const uploadButton = ( | |||
| <el-button {...btnProps} onClick={() => openUploadDialog(row)}> | |||
| <el-button {...btnProps} onClick={() => uploadDataFile(row)}> | |||
| 导入 | |||
| </el-button> | |||
| ); | |||
| // 类型为视频时,当状态为未采样(5)时才可导入,其余状态不可导入 | |||
| // 类型为图片时,自动标注中(2) 数据增强中(8)不可导入,其余状态均可导入 | |||
| // 类型为视频时,当状态为未采样时才可导入,其余状态不可导入 | |||
| // 类型为图片时,自动标注中、数据增强中 目标跟踪失败 不可导入,其余状态均可导入 | |||
| if (row.dataType === 1) { | |||
| if (row.status === 5) { | |||
| if (statusCodeMap[row.status] === 'UNSAMPLED') { | |||
| showUploadButton = true; | |||
| } | |||
| } else if (![2, 8].includes(row.status)) { | |||
| } else if (!['AUTO_ANNOTATING', 'ENHANCING', 'TRACK_FAILED'].includes(statusCodeMap[row.status])) { | |||
| showUploadButton = true; | |||
| } | |||
| // 当标注完成(4)目标跟踪完成(6),以及非视频的自动标注完成(3)时显示重新自动标注按钮 (若为视频此时下游会进行目标跟踪) | |||
| let showReAutoButton = [4, 6].includes(row.status) || (row.status === 3 && row.dataType === 0); | |||
| // 当标注完成、目标跟踪完成,以及非视频的自动标注完成时显示重新自动标注按钮 (若为视频此时下游会进行目标跟踪) | |||
| let showReAutoButton = ['ANNOTATED', 'TRACK_SUCCEED'].includes(statusCodeMap[row.status]) || (statusCodeMap[row.status] === 'AUTO_ANNOTATED' && row.dataType === 0); | |||
| // 重新自动标注按钮 | |||
| const reAutoButton = ( | |||
| <el-popconfirm | |||
| @@ -157,10 +159,28 @@ export default { | |||
| </el-button> | |||
| </el-popconfirm> | |||
| ); | |||
| // 当目标跟踪标注类型的数据集状态为自动标注完成 标注完成时,显示目标跟踪按钮 | |||
| let showTrackButton = row.annotateType === 5 && ['AUTO_ANNOTATED','ANNOTATED'].includes(statusCodeMap[row.status]); | |||
| // 目标跟踪按钮 | |||
| const trackButton = ( | |||
| <el-button {...btnProps} onClick={() => track(row, false)}> | |||
| 目标跟踪 | |||
| </el-button> | |||
| ); | |||
| // 当目标跟踪失败时,显示重新目标跟踪按钮 | |||
| let showReTrackButton = ['TRACK_FAILED', 'TRACK_SUCCEED'].includes(statusCodeMap[row.status]); | |||
| // 重新目标跟踪按钮 | |||
| const reTrackButton = ( | |||
| <el-button {...btnProps} onClick={() => track(row, true)}> | |||
| 重新目标跟踪 | |||
| </el-button> | |||
| ); | |||
| // 展示数据增强入口 | |||
| // 当数据类型为图片,并且状态为自动标注完成(3) 标注完成(4)展示数据增强入口 | |||
| let showAugmentButton = row.dataType === 0 && [3, 4].includes(row.status); | |||
| // 当数据类型为图片,并且状态为自动标注完成、标注完成展示数据增强入口 | |||
| let showAugmentButton = row.dataType === 0 && ['AUTO_ANNOTATED', 'ANNOTATED'].includes(statusCodeMap[row.status]); | |||
| // 数据增强按钮 | |||
| const augmentButton = ( | |||
| <el-button {...btnProps} onClick={() => dataEnhance(row)}> | |||
| @@ -168,14 +188,41 @@ export default { | |||
| </el-button> | |||
| ); | |||
| // 有当前版本且状态不为自动标注中(2) 数据增强中(8) | |||
| let showVersionButton = (row.currentVersionName && ![2, 8].includes(row.status)); | |||
| // 有当前版本且状态不为自动标注中、数据增强中、目标跟踪中,导入中 | |||
| let showVersionButton = (row.currentVersionName && !['AUTO_ANNOTATING', 'ENHANCING', 'TRACKING', 'IMPORTING'].includes(statusCodeMap[row.status])); | |||
| // 历史版本按钮 | |||
| const versionButton = ( | |||
| <el-button {...btnProps} onClick={() => gotoVersion(row)}> | |||
| 历史版本 | |||
| </el-button> | |||
| ); | |||
| let showTopButton = true; | |||
| // 置顶按钮总会显示 | |||
| const topButton = ( | |||
| <el-button {...btnProps} onClick={() => topDataset(row)}> | |||
| {row.top ? '取消置顶' : '置顶'} | |||
| </el-button> | |||
| ); | |||
| let showEditButton = true; | |||
| // 修改按钮总会显示 | |||
| const editButton = ( | |||
| <el-button {...btnProps} onClick={() => editDataset(row)}> | |||
| 修改 | |||
| </el-button> | |||
| ); | |||
| // 导入外部数据集 | |||
| const showImportButton = row.import === true && ['UNANNOTATED'].includes(statusCodeMap[row.status]); | |||
| // 外部导入数据集 | |||
| const importDatasetButton = showImportButton ? ( | |||
| <a {...btnProps} onClick={() => checkImport(row)} href="http://docs.dubhe.ai/docs/module/dataset/import-dataset" target="_blank" class="primary"> | |||
| 导入本地数据集 | |||
| <IconFont type="externallink" /> | |||
| </a> | |||
| ) : null; | |||
| // 预置数据集只具备查看标注,历史版本功能。 | |||
| if (row.type === 2) { | |||
| @@ -184,18 +231,23 @@ export default { | |||
| showCheckButton = true; | |||
| showAutoButton = false; | |||
| showReAutoButton = false; | |||
| showTrackButton = false; | |||
| showReTrackButton = false; | |||
| showVersionButton = true; | |||
| showAugmentButton = false; | |||
| showTopButton = false; | |||
| showEditButton = false; | |||
| }; | |||
| // 导入的自定义数据集只允许删除操作 | |||
| // 导入的自定义数据集只允许删除 置顶 修改操作 | |||
| if (row.import) { | |||
| showPublishButton = false; | |||
| showUploadButton = false; | |||
| showCheckButton = false; | |||
| showAutoButton = false; | |||
| showReAutoButton = false; | |||
| showVersionButton = false; | |||
| showTrackButton = false; | |||
| showReTrackButton = false; | |||
| showAugmentButton = false; | |||
| // 导入完成才可以查看标注 | |||
| showCheckButton = (statusCodeMap[row.status] === 'ANNOTATED'); | |||
| }; | |||
| // 统计需要显示的按钮个数 | |||
| const buttonCount = (arr) => { | |||
| @@ -204,8 +256,8 @@ export default { | |||
| (item) => { if (item) count+=1; }); | |||
| return count; | |||
| }; | |||
| const leftButtonArr = [showPublishButton, showUploadButton, showCheckButton, showAutoButton, showReAutoButton]; | |||
| const rightButtonArr = [showVersionButton, showAugmentButton]; | |||
| const leftButtonArr = [showPublishButton, showUploadButton, showCheckButton, showAutoButton, showReAutoButton, showTrackButton]; | |||
| const rightButtonArr = [showVersionButton, showAugmentButton, showTopButton, showEditButton, showReTrackButton]; | |||
| const leftButtonCount = buttonCount(leftButtonArr); | |||
| const rightButtonCount = buttonCount(rightButtonArr); | |||
| @@ -216,8 +268,11 @@ export default { | |||
| if (leftButtonCount < 3) { | |||
| moreButton = ( | |||
| <span> | |||
| {showReTrackButton && reTrackButton} | |||
| {showVersionButton && versionButton} | |||
| {showAugmentButton && augmentButton} | |||
| {showTopButton && topButton} | |||
| {showEditButton && editButton} | |||
| </span> | |||
| ); | |||
| } else { | |||
| @@ -227,11 +282,20 @@ export default { | |||
| 更多<i class='el-icon-arrow-down el-icon--right'></i> | |||
| </el-button> | |||
| <el-dropdown-menu slot='dropdown'> | |||
| <el-dropdown-item> | |||
| {showReTrackButton && reTrackButton} | |||
| </el-dropdown-item> | |||
| <el-dropdown-item> | |||
| {showVersionButton && versionButton} | |||
| </el-dropdown-item> | |||
| <el-dropdown-item key='dataEnhance'> | |||
| {showAugmentButton && augmentButton} | |||
| </el-dropdown-item> | |||
| <el-dropdown-item key='top'> | |||
| {showTopButton && topButton} | |||
| </el-dropdown-item> | |||
| <el-dropdown-item key='edit'> | |||
| {showEditButton && editButton} | |||
| </el-dropdown-item> | |||
| </el-dropdown-menu> | |||
| </el-dropdown> | |||
| @@ -241,10 +305,12 @@ export default { | |||
| return ( | |||
| <span> | |||
| { importDatasetButton } | |||
| {showPublishButton && publishButton} | |||
| {showUploadButton && uploadButton} | |||
| {showCheckButton && checkButton} | |||
| {showAutoButton && autoButton} | |||
| {showTrackButton && trackButton} | |||
| {showReAutoButton && reAutoButton} | |||
| {moreButton} | |||
| </span> | |||
| @@ -0,0 +1,622 @@ | |||
| /** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||
| * | |||
| * Licensed under the Apache License, Version 2.0 (the "License"); | |||
| * you may not use this file except in compliance with the License. | |||
| * You may obtain a copy of the License at | |||
| * | |||
| * http://www.apache.org/licenses/LICENSE-2.0 | |||
| * | |||
| * Unless required by applicable law or agreed to in writing, software | |||
| * distributed under the License is distributed on an "AS IS" BASIS, | |||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| * See the License for the specific language governing permissions and | |||
| * limitations under the License. | |||
| * ============================================================= | |||
| */ | |||
| <template> | |||
| <el-dialog | |||
| append-to-body | |||
| custom-class="create-dataset" | |||
| center | |||
| :close-on-click-modal="false" | |||
| :visible="visible" | |||
| title="创建数据集" | |||
| width="610px" | |||
| @close="closeDialog" | |||
| > | |||
| <!--步骤条--> | |||
| <el-steps :active="activeStep" finish-status="success"> | |||
| <el-step title="新建数据集" /> | |||
| <el-step title="导入数据" /> | |||
| <el-step title="完成" /> | |||
| </el-steps> | |||
| <!--step0新建数据集--> | |||
| <div v-if="activeStep === 0"> | |||
| <el-form ref="form" :model="form" :rules="rules" label-width="100px"> | |||
| <el-form-item label="数据集名称" prop="name"> | |||
| <el-input v-model="form.name" placeholder="数据集名称不能超过50字" maxlength="50" /> | |||
| </el-form-item> | |||
| <el-form-item label="数据类型" prop="dataType"> | |||
| <InfoSelect | |||
| v-model="form.dataType" | |||
| placeholder="数据类型" | |||
| :dataSource="dataTypeList" | |||
| @change="handleDataTypeChange" | |||
| /> | |||
| </el-form-item> | |||
| <el-form-item label="标注类型" prop="annotateType"> | |||
| <InfoSelect | |||
| v-model="form.annotateType" | |||
| placeholder="标注类型" | |||
| :dataSource="annotationList" | |||
| :disabled="form.dataType === 1" | |||
| @change="handleAnnotateTypeChange" | |||
| /> | |||
| </el-form-item> | |||
| <el-form-item label="标签组" prop="labelGroupId"> | |||
| <div class="label-input"> | |||
| <el-popover | |||
| ref="popover" | |||
| v-model="popoverVisible" | |||
| placement="top" | |||
| trigger="click" | |||
| popper-class="label-group-popover" | |||
| > | |||
| <div class="add-label-tag"> | |||
| <el-tabs v-model="labelGroupTab" type="border-card"> | |||
| <el-tab-pane label="自定义标签组" name="custom"> | |||
| <el-select | |||
| v-model="customLabelGroupId" | |||
| filterable | |||
| placeholder="请选择" | |||
| popper-class="label-group-select" | |||
| @change="handleCustomId" | |||
| > | |||
| <el-option | |||
| v-for="item in customLabelGroups" | |||
| :key="item.labelGroupId" | |||
| :label="item.name" | |||
| :value="item.labelGroupId" | |||
| > | |||
| </el-option> | |||
| </el-select> | |||
| </el-tab-pane> | |||
| <el-tab-pane label="预置标签组" name="system" :disabled="!systemLabelEnabled"> | |||
| <el-select | |||
| v-model="systemLabelGroupId" | |||
| filterable | |||
| placeholder="请选择" | |||
| @change="handleSystemId" | |||
| > | |||
| <el-option | |||
| v-for="item in systemLabelGroups" | |||
| :key="item.labelGroupId" | |||
| :label="item.name" | |||
| :value="item.labelGroupId" | |||
| :disabled="!optionEnabled(item.labelGroupId, form.annotateType)" | |||
| > | |||
| </el-option> | |||
| </el-select> | |||
| </el-tab-pane> | |||
| </el-tabs> | |||
| </div> | |||
| <el-button slot="reference" type="text"> | |||
| | |||
| <span v-if="labelGroupId === null"> 标签组</span> | |||
| <el-tag v-else closable @close="handleRemoveLabelGroup()"> | |||
| {{labelGroupName}} | |||
| </el-tag> | |||
| </el-button> | |||
| </el-popover> | |||
| <el-link | |||
| v-if="labelGroupId !== null" | |||
| target="_blank" | |||
| type="primary" | |||
| :underline="false" | |||
| class="vm" | |||
| :href="`/data/labelgroup/detail?id=${labelGroupId}`" | |||
| style="float: right; margin-right: 8px;" | |||
| > | |||
| 查看详情 | |||
| </el-link> | |||
| </div> | |||
| </el-form-item> | |||
| <div v-if="labelGroupId === null" style=" position: relative; top: -12px; left: 118px;"> | |||
| <span>标签组需要在</span> | |||
| <a | |||
| target="_blank" | |||
| type="primary" | |||
| :underline="false" | |||
| class="primary" | |||
| :href="`/data/labelgroup/create`" | |||
| > | |||
| 新建标签组 | |||
| </a> | |||
| <span>页面创建</span> | |||
| </div> | |||
| <el-form-item label="数据集描述" prop="remark"> | |||
| <el-input | |||
| v-model="form.remark" | |||
| type="textarea" | |||
| placeholder="数据集描述长度不能超过100字" | |||
| maxlength="100" | |||
| rows="3" | |||
| show-word-limit | |||
| /> | |||
| </el-form-item> | |||
| </el-form> | |||
| <div style=" margin-top: 25px; text-align: center;"> | |||
| <el-button | |||
| :loading="crud.status.cu === 2" | |||
| type="primary" | |||
| @click="createDataset" | |||
| > | |||
| 下一步 | |||
| </el-button> | |||
| </div> | |||
| </div> | |||
| <!--step1上传文件--> | |||
| <div v-show="activeStep === 1"> | |||
| <upload-inline | |||
| ref="initFileUploadForm" | |||
| action="fakeApi" | |||
| :params="uploadParams" | |||
| :transformFile="withDimensionFile" | |||
| v-bind="optionCreateProps" | |||
| @uploadSuccess="uploadSuccess" | |||
| @uploadError="uploadError" | |||
| /> | |||
| <!--上传视频时显示帧间隔设置--> | |||
| <el-form | |||
| v-if="form.dataType === 1" | |||
| ref="formStep1" | |||
| :model="step1Form" | |||
| label-width="100px" | |||
| style="margin-top: 10px;" | |||
| > | |||
| <el-form-item | |||
| label="视频帧间隔" | |||
| prop="frameInterval" | |||
| :rules="[{required: true, message: '请输入有效的帧间隔', trigger: 'blur'}]" | |||
| > | |||
| <el-input-number v-model="step1Form.frameInterval" :min="1" /> | |||
| </el-form-item> | |||
| </el-form> | |||
| <div style=" margin-top: 25px; text-align: center;"> | |||
| <el-button @click="skip">跳过</el-button> | |||
| <el-button type="primary" @click="uploadSubmit('initFileUploadForm')">确定上传</el-button> | |||
| </div> | |||
| </div> | |||
| <!--step2上传中--> | |||
| <div v-if="activeStep === 2 && skipUpload !== true"> | |||
| <!--上传图片进度条--> | |||
| <el-progress | |||
| v-if="form.dataType !== 1" | |||
| type="circle" | |||
| :percentage="uploadPercent" | |||
| :status="uploadStatus" | |||
| :format="formatProgress" | |||
| /> | |||
| <!--上传视频进度条--> | |||
| <div v-else class="circleProgressWrapper"> | |||
| <div class="circleText">正在上传</div> | |||
| <div class="wrapper right"> | |||
| <div class="circleProgress rightCircle"></div> | |||
| </div> | |||
| <div class="wrapper left"> | |||
| <div class="circleProgress leftCircle"></div> | |||
| </div> | |||
| </div> | |||
| <div style=" margin-top: 25px; text-align: center;"> | |||
| <el-button type="primary" :loading="true">确定</el-button> | |||
| </div> | |||
| </div> | |||
| <!--step3上传完成--> | |||
| <div v-if="activeStep === 3"> | |||
| <el-progress v-if="skipUpload !== true" type="circle" :percentage="100" :status="uploadStatus"/> | |||
| <div style=" margin-top: 25px; text-align: center;"> | |||
| <el-button type="primary" :loading="!uploadFinished" @click="completeCreate">确定</el-button> | |||
| </div> | |||
| </div> | |||
| </el-dialog> | |||
| </template> | |||
| <script> | |||
| import CRUD, { presenter, header, form, crud } from '@crud/crud'; | |||
| import crudDataset, { detail } from '@/api/preparation/dataset'; | |||
| import { submit, submitVideo } from '@/api/preparation/datafile'; | |||
| import { getLabelGroupList } from '@/api/preparation/labelGroup'; | |||
| import { | |||
| getImgFromMinIO, | |||
| annotationMap, | |||
| dataTypeMap, | |||
| withDimensionFile, | |||
| trackUploadProps, | |||
| } from '@/views/dataset/util'; | |||
| import { validateName } from '@/utils/validate'; | |||
| import UploadInline from '@/components/UploadForm/inline'; | |||
| import InfoSelect from '@/components/InfoSelect'; | |||
| import { toFixed } from '@/utils'; | |||
| // 默认帧间隔 | |||
| const defaultFrameInterval = 5; | |||
| // 默认表单 | |||
| const defaultForm = { | |||
| id: null, | |||
| name: null, | |||
| dataType: null, | |||
| annotateType: null, | |||
| labelGroupId: null, | |||
| presetLabelType: '', | |||
| remark: '', | |||
| type: 0, | |||
| }; | |||
| export default { | |||
| name: "CreateDataset", | |||
| components: { | |||
| UploadInline, | |||
| InfoSelect, | |||
| }, | |||
| cruds() { | |||
| return CRUD({ | |||
| title: '数据集管理', | |||
| crudMethod: { ...crudDataset }, | |||
| props: { optText: { add: '创建数据集' }}, | |||
| queryOnPresenterCreated: false, | |||
| }); | |||
| }, | |||
| mixins: [presenter(), header(), form(defaultForm), crud()], | |||
| props: { | |||
| visible: { | |||
| type: Boolean, | |||
| default: false, | |||
| }, | |||
| closeCreateDatasetForm: { | |||
| type: Function, | |||
| }, | |||
| onResetFresh: { | |||
| type: Function, | |||
| }, | |||
| }, | |||
| data() { | |||
| return { | |||
| chosenDatasetId: 0, // 当前数据集id | |||
| activeStep: 0, // 当前的step | |||
| actionKey: 1, | |||
| // customLabelEnabled: true, // 自定义标签组可用性 | |||
| systemLabelEnabled: true, // 预置标签组可用性 | |||
| uploadPercent: 0, | |||
| uploadStatus: undefined, | |||
| skipUpload: false, // 跳过上传 | |||
| popoverVisible: false, | |||
| rules: { | |||
| name: [ | |||
| { required: true, message: '请输入数据集名称', trigger: ['change', 'blur'] }, | |||
| { validator: validateName, trigger: ['change', 'blur'] }, | |||
| ], | |||
| dataType: [ | |||
| { required: true, message: '请选择数据类型', trigger: 'change' }, | |||
| ], | |||
| annotateType: [ | |||
| { required: true, message: '请选择标注类型', trigger: 'change' }, | |||
| ], | |||
| remark: [ | |||
| { required: false, message: '请输入数据集描述信息', trigger: 'blur' }, | |||
| ], | |||
| }, | |||
| step1Form: { | |||
| frameInterval: defaultFrameInterval, // 默认值 | |||
| }, | |||
| labelGroupTab: "custom", | |||
| labelGroupName: null, | |||
| labelGroupId: null, | |||
| customLabelGroupId: null, | |||
| systemLabelGroupId: null, | |||
| customLabelGroups: [], | |||
| systemLabelGroups: [], | |||
| }; | |||
| }, | |||
| computed: { | |||
| // 文件上传前携带尺寸信息 | |||
| withDimensionFile() { | |||
| return withDimensionFile; | |||
| }, | |||
| uploadParams() { | |||
| // 是否为视频数据类类型 | |||
| const isVideo = | |||
| this.importRow?.dataType === 1 || this.form.dataType === 1; | |||
| const dir = isVideo ? `video` : `origin`; | |||
| return { | |||
| datasetId: this.chosenDatasetId, | |||
| objectPath: `dataset/${this.chosenDatasetId}/${dir}`, // 对象存储路径 | |||
| }; | |||
| }, | |||
| // 新建数据集(视频)上传组件参数 | |||
| optionCreateProps() { | |||
| const props = this.form.dataType === 1 ? trackUploadProps : {}; | |||
| return props; | |||
| }, | |||
| annotationList() { | |||
| // 原始标注列表 | |||
| const rawAnnotationList = Object.keys(annotationMap).map(d => ({ | |||
| label: annotationMap[d].name, | |||
| value: Number(d), | |||
| })); | |||
| // 如果是图片,目标跟踪不可用 | |||
| // 如果是视频,只能用目标跟踪 | |||
| return rawAnnotationList.map(d => { | |||
| let disabled = false; | |||
| if (this.form.dataType === 0) { | |||
| disabled = d.value === 5; | |||
| } else if (this.form.dataType === 1) { | |||
| disabled = d.value !== 5; | |||
| } | |||
| return { | |||
| ...d, | |||
| disabled, | |||
| }; | |||
| }); | |||
| }, | |||
| dataTypeList: () => | |||
| Object.keys(dataTypeMap).map(d => ({ | |||
| label: dataTypeMap[d], | |||
| value: Number(d), | |||
| })), | |||
| uploadFinished() { | |||
| return this.uploadStatus && ['success', 'exception'].includes(this.uploadStatus); | |||
| }, | |||
| }, | |||
| created() { | |||
| this.crud.toQuery(); | |||
| getLabelGroupList(1).then(res => { | |||
| res.forEach((item) => { | |||
| this.systemLabelGroups.push({ | |||
| labelGroupId: item.id, | |||
| name: item.name, | |||
| }); | |||
| }); | |||
| }); | |||
| getLabelGroupList(0).then(res => { | |||
| res.forEach((item) => { | |||
| this.customLabelGroups.push({ | |||
| labelGroupId: item.id, | |||
| name: item.name, | |||
| }); | |||
| }); | |||
| }); | |||
| }, | |||
| methods: { | |||
| handleCustomId() { | |||
| this.popoverVisible = false; | |||
| this.labelGroupId = this.customLabelGroupId; | |||
| this.systemLabelGroupId = null; | |||
| this.labelGroupName = this.customLabelGroups.find(d => d.labelGroupId === this.labelGroupId).name; | |||
| }, | |||
| handleSystemId() { | |||
| this.popoverVisible = false; | |||
| this.labelGroupId = this.systemLabelGroupId; | |||
| this.customLabelGroupId = null; | |||
| this.labelGroupName = this.systemLabelGroups.find(d => d.labelGroupId === this.labelGroupId).name; | |||
| }, | |||
| handleRemoveLabelGroup() { | |||
| this.labelGroupId = null; | |||
| this.customLabelGroupId = null; | |||
| this.systemLabelGroupId = null; | |||
| this.$refs.popover.doClose(); | |||
| }, | |||
| optionEnabled(labelGroupId, annotateType) { | |||
| // 目标检测(1)目标跟踪(5)可以使用预置标签组COCO | |||
| if([1, 5].includes(annotateType)) { | |||
| return labelGroupId === 1; | |||
| } | |||
| return true; | |||
| }, | |||
| // 重置创建数据集表单 | |||
| resetCreateDatasetForm() { | |||
| // 清理第一步表单 | |||
| this.$refs.form?.resetFields(); | |||
| // 清除标签组 | |||
| this.labelGroupId = null; | |||
| this.systemLabelEnabled = true; | |||
| this.systemLabelGroupId = null; | |||
| this.customLabelGroupId = null; | |||
| // 清理上传表单 | |||
| this.$refs.initFileUploadForm?.$refs?.formRef.reset(); | |||
| this.crud.cancelCU(); | |||
| this.crud.status.add = CRUD.STATUS.NORMAL; | |||
| this.chosenDatasetId = 0; | |||
| this.activeStep = 0; | |||
| // 重置帧数 | |||
| this.step1Form.frameInterval = defaultFrameInterval; | |||
| this.skipUpload = false; | |||
| this.uploadStatus = undefined; | |||
| this.uploadPercent = 0; | |||
| this.videoUploadProgress = 0; | |||
| }, | |||
| // step0 标签选择框刷新 | |||
| handleLabelHide() { | |||
| this.actionKey += 1; | |||
| }, | |||
| // step0 改变数据类型 | |||
| handleDataTypeChange(dataType) { | |||
| // 数据类型选中为视频时,标注类型自动切换为目标跟踪,同时清除不符合类型的标签组 | |||
| if (dataType === 1) { | |||
| this.form.annotateType = 5; | |||
| this.handleAnnotateTypeChange(5); | |||
| } else { | |||
| this.form.annotateType = undefined; | |||
| this.systemLabelEnabled = true; | |||
| } | |||
| }, | |||
| // step0 改变标注类型 | |||
| handleAnnotateTypeChange(annotateType) { | |||
| // 更改标注类型会清除不符合条件的标签组 | |||
| // 目标检测(1) 目标跟踪(5) 可以选中预置标签组中的Coco(id=1) | |||
| if ([1, 5].includes(annotateType)) { | |||
| if(this.labelGroupId !== 1 && this.labelGroupId === this.systemLabelGroupId) { | |||
| this.systemLabelEnabled = true; | |||
| this.labelGroupId = null; | |||
| this.systemLabelGroupId = null; | |||
| } | |||
| } | |||
| // 图像分类(2)可以选中预置标签组Coco(id=1)和ImageNet(id=2) | |||
| if (annotateType === 2) { | |||
| if(![1, 2].includes(this.labelGroupId) && this.labelGroupId === this.systemLabelGroupId) { | |||
| this.systemLabelEnabled = true; | |||
| this.labelGroupId = null; | |||
| this.systemLabelGroupId = null; | |||
| } | |||
| } | |||
| // 其余不可以使用预置标签组 | |||
| if (![1, 2, 5].includes(annotateType)) { | |||
| if( this.labelGroupId === this.systemLabelGroupId) { | |||
| this.systemLabelGroupId = null; | |||
| this.labelGroupId = null; | |||
| this.labelGroupName = null; | |||
| this.systemLabelEnabled = false; | |||
| this.labelGroupTab = "custom"; | |||
| } | |||
| } | |||
| }, | |||
| // step0 创建数据集调用 | |||
| createDataset() { | |||
| if (this.activeStep === 0) { | |||
| this.crud.findVM('form').$refs.form.validate(valid => { | |||
| if (!valid) { | |||
| return; | |||
| } | |||
| this.crud.status.add = CRUD.STATUS.PROCESSING; | |||
| this.crud.form.labelGroupId = this.labelGroupId; | |||
| this.crud.crudMethod | |||
| .add(this.crud.form) | |||
| .then(res => { | |||
| this.chosenDatasetId = res; | |||
| this.activeStep = 1; | |||
| }) | |||
| .catch(err => { | |||
| this.$message({ | |||
| message: err.message || '数据集创建失败', | |||
| type: 'exception', | |||
| }); | |||
| this.crud.status.add = CRUD.STATUS.PREPARED; | |||
| }); | |||
| }); | |||
| } | |||
| }, | |||
| // step1 上传前需要查询数据集详情 | |||
| async queryDatasetDetail(datasetId) { | |||
| const res = await detail(datasetId); | |||
| return res; | |||
| }, | |||
| // step1 上传包括图片和视频 | |||
| async uploader(datasetId, files) { | |||
| const datasetInfo = await this.queryDatasetDetail(datasetId); | |||
| // 点击导入操作 | |||
| const { dataType } = datasetInfo || {}; | |||
| // 文件上传 | |||
| if (dataType === 0) { | |||
| return submit(datasetId, files); | |||
| } | |||
| if (dataType === 1) { | |||
| return submitVideo(datasetId, { | |||
| frameInterval: this.step1Form.frameInterval, | |||
| url: files[0].url, | |||
| }); | |||
| } | |||
| return Promise.reject(); | |||
| }, | |||
| // step1 上传成功 | |||
| uploadSuccess(res) { | |||
| if (this.crud.status.cu > 0) { | |||
| this.activeStep+=1; | |||
| } | |||
| // 视频上传完毕 | |||
| if (this.form.dataType === 1) { | |||
| this.videoUploadProgress = 100; | |||
| } | |||
| const files = getImgFromMinIO(res); | |||
| // 自动标注完成时 导入 提示信息不同 | |||
| const successMessage = '上传文件成功'; | |||
| if (files.length > 0) { | |||
| this.uploader(this.chosenDatasetId, files).then(() => { | |||
| this.$message({ | |||
| message: successMessage, | |||
| duration: 5000, | |||
| type: 'success', | |||
| }); | |||
| this.uploadStatus = 'success'; | |||
| }); | |||
| } | |||
| }, | |||
| // step1 上传失败 | |||
| uploadError() { | |||
| this.uploadStatus = 'exception'; | |||
| this.$message({ | |||
| message: '上传文件失败', | |||
| type: 'error', | |||
| }); | |||
| }, | |||
| // step1 跳过上传 | |||
| skip() { | |||
| this.skipUpload = true; | |||
| this.activeStep += 2; | |||
| }, | |||
| // step1 确定上传 | |||
| uploadSubmit(formName) { | |||
| this.$refs[formName].uploadSubmit((resolved, total) => { | |||
| // eslint-disable-next-line func-names | |||
| this.$nextTick(function() { | |||
| this.uploadPercent = | |||
| this.uploadPercent > 100 ? 100 : toFixed(resolved / total); | |||
| }); | |||
| }); | |||
| if (this.crud.status.cu > 0) { | |||
| this.activeStep = 2; | |||
| } | |||
| }, | |||
| // step2 进度格式化 | |||
| formatProgress(percentage) { | |||
| let formatTxt = `${percentage}%`; | |||
| if (this.form.dataType === 1) { | |||
| formatTxt = this.videoUploadProgress === 100 ? `100%` : `上传中...`; | |||
| } | |||
| return formatTxt; | |||
| }, | |||
| // step2 完成时点击确定 | |||
| completeCreate() { | |||
| // 发送创建成功消息 | |||
| this.$message({ | |||
| message: '数据集创建成功', | |||
| type: 'success', | |||
| }); | |||
| // 关闭创建数据集对话框 | |||
| this.closeCreateDatasetForm(); | |||
| this.onResetFresh(); | |||
| // 重置创建数据集各个步骤的表单 | |||
| this.resetCreateDatasetForm(); | |||
| }, | |||
| // 关闭显示的创建数据集对话框 | |||
| closeDialog() { | |||
| if(this.activeStep === 0){ | |||
| // step=0还未创建数据集时不需要刷新列表 | |||
| this.closeCreateDatasetForm(); | |||
| this.resetCreateDatasetForm(); | |||
| } else{ | |||
| // step>0数据集创建成功 | |||
| this.completeCreate(); | |||
| } | |||
| }, | |||
| }, | |||
| }; | |||
| </script> | |||
| @@ -0,0 +1,345 @@ | |||
| /** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||
| * | |||
| * Licensed under the Apache License, Version 2.0 (the "License"); | |||
| * you may not use this file except in compliance with the License. | |||
| * You may obtain a copy of the License at | |||
| * | |||
| * http://www.apache.org/licenses/LICENSE-2.0 | |||
| * | |||
| * Unless required by applicable law or agreed to in writing, software | |||
| * distributed under the License is distributed on an "AS IS" BASIS, | |||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| * See the License for the specific language governing permissions and | |||
| * limitations under the License. | |||
| * ============================================================= | |||
| */ | |||
| <template> | |||
| <BaseModal | |||
| :visible="visible" | |||
| :loading="loading" | |||
| title="修改数据集" | |||
| @change="handleCancel" | |||
| @ok="handleEditDataset" | |||
| > | |||
| <el-form ref="form" :model="state.model" :rules="rules" label-width="100px"> | |||
| <el-form-item label="数据集名称" prop="name"> | |||
| <el-input v-model="state.model.name" placeholder="数据集名称不能超过50字" maxlength="50" /> | |||
| </el-form-item> | |||
| <el-form-item label="数据类型" prop="dataType"> | |||
| <InfoSelect | |||
| v-model="state.model.dataType" | |||
| placeholder="数据类型" | |||
| :dataSource="dataTypeList" | |||
| disabled | |||
| /> | |||
| </el-form-item> | |||
| <el-form-item v-if="!state.model.import" label="标注类型" prop="annotateType"> | |||
| <InfoSelect | |||
| v-model="state.model.annotateType" | |||
| placeholder="标注类型" | |||
| :dataSource="annotationList" | |||
| disabled | |||
| /> | |||
| </el-form-item> | |||
| <el-form-item v-if="!state.model.import" label="标签组" prop="labelGroupId"> | |||
| <div v-if="editable" class="label-input"> | |||
| <el-popover | |||
| ref="popoverRef" | |||
| v-model="state.popoverVisible" | |||
| placement="top" | |||
| trigger="click" | |||
| popper-class="label-group-popover" | |||
| > | |||
| <div class="add-label-tag"> | |||
| <el-tabs v-model="state.labelGroupTab" type="border-card"> | |||
| <el-tab-pane label="自定义标签组" name="custom"> | |||
| <el-select | |||
| v-model="state.customLabelGroupId" | |||
| filterable | |||
| placeholder="请选择" | |||
| popper-class="label-group-select" | |||
| @change="handleCustomId" | |||
| > | |||
| <el-option | |||
| v-for="item in customLabelGroups" | |||
| :key="item.labelGroupId" | |||
| :label="item.name" | |||
| :value="item.labelGroupId" | |||
| > | |||
| </el-option> | |||
| </el-select> | |||
| </el-tab-pane> | |||
| <el-tab-pane label="预置标签组" name="system" :disabled="!systemLabelEnabled"> | |||
| <el-select | |||
| v-model="state.systemLabelGroupId" | |||
| filterable | |||
| placeholder="请选择" | |||
| @change="handleSystemId" | |||
| > | |||
| <el-option | |||
| v-for="item in systemLabelGroups" | |||
| :key="item.labelGroupId" | |||
| :label="item.name" | |||
| :value="item.labelGroupId" | |||
| :disabled="!optionEnabled(item.labelGroupId, state.model.annotateType)" | |||
| > | |||
| </el-option> | |||
| </el-select> | |||
| </el-tab-pane> | |||
| </el-tabs> | |||
| </div> | |||
| <el-button slot="reference" type="text"> | |||
| | |||
| <span v-if="state.model.labelGroupId === null"> 标签组</span> | |||
| <el-tag v-else :closable="deletable" @close="handleRemoveLabelGroup()"> | |||
| {{state.model.labelGroupName}} | |||
| </el-tag> | |||
| </el-button> | |||
| </el-popover> | |||
| <el-link | |||
| v-if="state.model.labelGroupId !== null" | |||
| target="_blank" | |||
| type="primary" | |||
| :underline="false" | |||
| class="vm" | |||
| :href="`/data/labelgroup/detail?id=${state.model.labelGroupId}`" | |||
| style="float: right; margin-right: 8px;" | |||
| > | |||
| 查看详情 | |||
| </el-link> | |||
| </div> | |||
| <div v-else class="label-input" style="color: #c0c4cc; background-color: #f5f7fa;"> | |||
| {{state.model.labelGroupName}} | |||
| <el-link | |||
| v-if="state.model.labelGroupId !== null" | |||
| target="_blank" | |||
| type="primary" | |||
| :underline="false" | |||
| class="vm" | |||
| :href="`/data/labelgroup/detail?id=${state.model.labelGroupId}`" | |||
| style="float: right; margin-right: 8px;" | |||
| > | |||
| 查看详情 | |||
| </el-link> | |||
| </div> | |||
| </el-form-item> | |||
| <el-form-item label="数据集描述" prop="remark"> | |||
| <el-input | |||
| v-model="state.model.remark" | |||
| type="textarea" | |||
| placeholder="数据集描述长度不能超过100字" | |||
| maxlength="100" | |||
| rows="3" | |||
| show-word-limit | |||
| /> | |||
| </el-form-item> | |||
| </el-form> | |||
| </BaseModal> | |||
| </template> | |||
| <script> | |||
| import {isNil} from 'lodash'; | |||
| import { watch, reactive, computed, ref, onMounted } from '@vue/composition-api'; | |||
| import BaseModal from '@/components/BaseModal'; | |||
| import InfoSelect from '@/components/InfoSelect'; | |||
| import { validateName } from '@/utils/validate'; | |||
| import { annotationMap, dataTypeMap, statusCodeMap } from '@/views/dataset/util'; | |||
| import { getLabelGroupList } from '@/api/preparation/labelGroup'; | |||
| export default { | |||
| name: 'EditDataset', | |||
| components: { | |||
| BaseModal, | |||
| InfoSelect, | |||
| }, | |||
| props: { | |||
| visible: { | |||
| type: Boolean, | |||
| default: false, | |||
| }, | |||
| loading: { | |||
| type: Boolean, | |||
| default: false, | |||
| }, | |||
| handleCancel: Function, | |||
| handleOk: Function, | |||
| goLabelGroupDetail: Function, | |||
| row: { | |||
| type: Object, | |||
| default: () => {}, | |||
| }, | |||
| }, | |||
| setup(props, { refs }) { | |||
| const { handleOk } = props; | |||
| const popoverRef = ref(null); | |||
| const systemLabelGroups = []; | |||
| const customLabelGroups = []; | |||
| const rules= { | |||
| name: [ | |||
| { required: true, message: '请输入数据集名称', trigger: ['change', 'blur'] }, | |||
| { validator: validateName, trigger: ['change', 'blur'] }, | |||
| ], | |||
| dataType: [ | |||
| { required: true, message: '请选择数据类型', trigger: 'change' }, | |||
| ], | |||
| annotateType: [ | |||
| { required: true, message: '请选择标注类型', trigger: 'change' }, | |||
| ], | |||
| remark: [ | |||
| { required: false, message: '请输入数据集描述信息', trigger: 'blur' }, | |||
| ], | |||
| }; | |||
| const buildModel = (record, options) => { | |||
| return { ...record, ...options}; | |||
| }; | |||
| const state = reactive({ | |||
| model: buildModel(props.row), | |||
| popoverVisible: false, | |||
| labelGroupTab: "custom", | |||
| customLabelGroupId: null, | |||
| systeomLabelGroupId: null, | |||
| }); | |||
| const systemLabelEnabled = computed(() => { | |||
| return props.row.annotateType !== 5; | |||
| }); | |||
| const deletable = computed(() => { | |||
| return isNil(props.row.labelGroupId); | |||
| }); | |||
| const dataTypeList = computed(() => { | |||
| return Object.keys(dataTypeMap).map(d => ({ | |||
| label: dataTypeMap[d], | |||
| value: Number(d), | |||
| })); | |||
| }); | |||
| const annotationList = computed(() => { | |||
| // 原始标注列表 | |||
| const rawAnnotationList = Object.keys(annotationMap).map(d => ({ | |||
| label: annotationMap[d].name, | |||
| value: Number(d), | |||
| })); | |||
| // 如果是图片,目标跟踪不可用 | |||
| // 如果是视频,只能用目标跟踪 | |||
| return rawAnnotationList.map(d => { | |||
| let disabled = false; | |||
| if (state.model.dataType === 0) { | |||
| disabled = d.value === 5; | |||
| } else if (state.model.dataType === 1) { | |||
| disabled = d.value !== 5; | |||
| } | |||
| return { | |||
| ...d, | |||
| disabled, | |||
| }; | |||
| }); | |||
| }); | |||
| const editable = computed(() => { | |||
| return ['UNANNOTATED', 'UNSAMPLED'].includes(statusCodeMap[state.model.status]); | |||
| }); | |||
| const handleEditDataset = () => { | |||
| refs.form.validate(valid => { | |||
| if (!valid) { | |||
| return false; | |||
| } | |||
| handleOk(state.model, props.row); | |||
| return null; | |||
| }); | |||
| }; | |||
| const handleCustomId = () => { | |||
| Object.assign(state, { | |||
| popoverVisible: false, | |||
| systemLabelGroupId: null, | |||
| model: { | |||
| ...state.model, | |||
| labelGroupId: state.customLabelGroupId, | |||
| labelGroupName: customLabelGroups.find(d => d.labelGroupId === state.customLabelGroupId).name, | |||
| }, | |||
| }); | |||
| }; | |||
| const handleSystemId = () => { | |||
| Object.assign(state, { | |||
| popoverVisible: false, | |||
| customLabelGroupId: null, | |||
| model: { | |||
| ...state.model, | |||
| labelGroupId: state.systemLabelGroupId, | |||
| labelGroupName: systemLabelGroups.find(d => d.labelGroupId === state.systemLabelGroupId).name, | |||
| }, | |||
| }); | |||
| }; | |||
| const handleRemoveLabelGroup = () => { | |||
| Object.assign(state, { | |||
| customLabelGroupId: null, | |||
| systemLabelGroupId: null, | |||
| model: { | |||
| ...state.model, | |||
| labelGroupId: null, | |||
| }, | |||
| }); | |||
| popoverRef.value.doClose(); | |||
| }; | |||
| const optionEnabled = (labelGroupId, annotateType) => { | |||
| if(annotateType === 1) { | |||
| return labelGroupId === 1; | |||
| } | |||
| if(annotateType === 5) { | |||
| return false; | |||
| } | |||
| return true; | |||
| }; | |||
| onMounted(() => { | |||
| getLabelGroupList(1).then(res => res.forEach((item) => { | |||
| systemLabelGroups.push({ | |||
| labelGroupId: item.id, | |||
| name: item.name, | |||
| }); | |||
| })); | |||
| getLabelGroupList(0).then(res => res.forEach((item) => { | |||
| customLabelGroups.push({ | |||
| labelGroupId: item.id, | |||
| name: item.name, | |||
| }); | |||
| })); | |||
| }); | |||
| watch(() => props.row, (next) => { | |||
| Object.assign(state, { | |||
| model: { ...state.model, ...next }, | |||
| }); | |||
| }); | |||
| return { | |||
| rules, | |||
| state, | |||
| deletable, | |||
| editable, | |||
| systemLabelEnabled, | |||
| optionEnabled, | |||
| systemLabelGroups, | |||
| customLabelGroups, | |||
| handleCustomId, | |||
| handleSystemId, | |||
| handleRemoveLabelGroup, | |||
| handleEditDataset, | |||
| dataTypeList, | |||
| annotationList, | |||
| popoverRef, | |||
| }; | |||
| }, | |||
| }; | |||
| </script> | |||
| @@ -17,37 +17,50 @@ | |||
| <template> | |||
| <BaseModal | |||
| :key="formKey" | |||
| title="导入自定义数据集" | |||
| :title="importStep===0 ? '导入数据集' : '创建数据集'" | |||
| width="600px" | |||
| center | |||
| :visible="visible" | |||
| :disabled="uploading" | |||
| @change="handleCancelUploadDataset" | |||
| @ok="handleUploadDataset('formRef')" | |||
| > | |||
| <el-form ref="formRef" :model="form" :rules="rules" label-width="100px"> | |||
| <div v-if="importStep===0" class="placeholder"> | |||
| <div class="has-tip"> | |||
| <div class="tip"> | |||
| 请认真阅读下方说明,创建数据集完毕后,按照系统格式要求上传数据集文件,否则标注文件可能无法正确解析。 | |||
| <a class="db" href="http://tianshu.org.cn/static/upload/file/dubhe-dataset-template.zip" target="_blank">下载示例数据集模板</a> | |||
| </div> | |||
| <div class="requirement"> | |||
| <p>1. 系统提供了一站式脚本服务用以快速导入本地已有数据集(<a href="http://docs.dubhe.ai/docs/module/dataset/import-dataset" target="_blank">使用文档</a>),推荐使用 | |||
| <p>2. 本地数据集需要包括图片(origin 目录)、标注文件(annotation 目录)和标签文件三部分</p> | |||
| <p>3. 注意区分「图像分类」和「目标检测」类型数据集</p> | |||
| <p>4. 图片格式支持 jpg/png/bmp/jpeg,不大于 5M,位于 origin 目录下,不支持目录嵌套</p> | |||
| <p>5. 标注文件为 json 格式,位于 annotation 目录下,必须和文件同名(如果不存在标注,可不上传),不支持目录嵌套</p> | |||
| <p>6. 标签文件为 json 格式,命名要求为 label_{name}.json,其中 name 为标签组名称,不能与系统已有标签组重名</p> | |||
| <p>7. 更多参考示例数据集模板</p> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <el-form v-else ref="formRef" :model="form" :rules="rules" label-width="100px"> | |||
| <el-alert class="info-alert" type="warning" show-icon :closable="false"> | |||
| <div slot='title' class='slot-content'> | |||
| <div>数据集创建完毕后,需要使用脚本工具上传本地已有数据集</div> | |||
| <a href="http://docs.dubhe.ai/docs/module/dataset/import-dataset" target="_blank">使用文档</a> | |||
| </div> | |||
| </el-alert> | |||
| <el-form-item label="数据集名称" prop="name"> | |||
| <el-input v-model="form.name" placeholder="数据集名称不能超过50字" maxlength="50" /> | |||
| </el-form-item> | |||
| <el-form-item label="数据类型" prop="dataType"> | |||
| <el-select disabled value="图片" width="100px" /> | |||
| <el-select disabled value="图片" style="width: 200px;" /> | |||
| </el-form-item> | |||
| <el-form-item ref="datasetFile" v-model="form.datasetFile" label="上传数据集" prop="datasetFile"> | |||
| <upload-inline | |||
| ref="uploadForm" | |||
| action="fakeApi" | |||
| accept=".zip" | |||
| list-type="text" | |||
| :acceptSize="0" | |||
| :show-file-count="false" | |||
| :params="uploadParams" | |||
| :auto-upload="true" | |||
| :hash="false" | |||
| :limit="1" | |||
| @uploadStart="uploadStart" | |||
| @uploadSuccess="uploadSuccess" | |||
| @uploadError="uploadError" | |||
| <el-form-item label="标注类型" prop="annotateType"> | |||
| <InfoSelect | |||
| v-model="form.annotateType" | |||
| placeholder="标注类型" | |||
| :dataSource="annotationList" | |||
| width="200px" | |||
| /> | |||
| <div v-if="uploading"><i class="el-icon-loading" />数据集上传中...</div> | |||
| </el-form-item> | |||
| <el-form-item label="数据集描述"> | |||
| <el-input | |||
| @@ -60,44 +73,48 @@ | |||
| /> | |||
| </el-form-item> | |||
| </el-form> | |||
| <el-button v-if="importStep===0" slot="footer" class="tc" type="primary" @click="nextImportStep">已阅读,确定创建</el-button> | |||
| </BaseModal> | |||
| </template> | |||
| <script> | |||
| import { bucketName } from '@/utils/minIO'; | |||
| import UploadInline from '@/components/UploadForm/inline'; | |||
| import BaseModal from '@/components/BaseModal'; | |||
| import { addCustomDataset } from '@/api/preparation/dataset'; | |||
| import InfoSelect from '@/components/InfoSelect'; | |||
| import { validateName } from "@/utils/validate"; | |||
| import { annotationMap } from '@/views/dataset/util'; | |||
| import { add } from '@/api/preparation/dataset'; | |||
| export default { | |||
| name: "UploadDatasetForm", | |||
| name: "ImportDataset", | |||
| components: { | |||
| UploadInline, | |||
| BaseModal, | |||
| InfoSelect, | |||
| }, | |||
| props: { | |||
| visible: { | |||
| type: Boolean, | |||
| default: false, | |||
| }, | |||
| closeUploadDatasetForm: { | |||
| toggleImportDataset: { | |||
| type: Function, | |||
| }, | |||
| onResetFresh: { | |||
| type: Function, | |||
| }, | |||
| }, | |||
| data() { | |||
| return { | |||
| importStep: 0, | |||
| formKey: 1, | |||
| form: { | |||
| name: "", | |||
| dataType: 0, | |||
| annotateType: 2, | |||
| status: 4, | |||
| datasetFile: undefined, | |||
| remark: "", | |||
| }, | |||
| uploading: false, | |||
| rules: { | |||
| name: [ | |||
| { | |||
| @@ -107,67 +124,71 @@ export default { | |||
| }, | |||
| { validator: validateName, trigger: ["change", "blur"] }, | |||
| ], | |||
| datasetFile: [ | |||
| annotateType: [ | |||
| { | |||
| required: true, | |||
| message: "请选择上传数据集", | |||
| trigger: ["blur", "manual"], | |||
| message: "请选择标注类型", | |||
| trigger: ["change", "blur"], | |||
| }, | |||
| ], | |||
| }, | |||
| }; | |||
| }, | |||
| computed: { | |||
| uploadParams() { | |||
| return { | |||
| objectPath: `dataset/importdataset`, // 导入自定义数据集存储路径 | |||
| }; | |||
| annotationList() { | |||
| const activeList = Object.keys(annotationMap).filter(type => ["1", "2"].includes(type)).map(d => ({ | |||
| label: annotationMap[d].name, | |||
| value: Number(d), | |||
| })); | |||
| return activeList; | |||
| }, | |||
| }, | |||
| methods: { | |||
| nextImportStep() { | |||
| this.importStep += 1; | |||
| }, | |||
| handleCancelUploadDataset() { | |||
| this.formKey += 1; | |||
| this.closeUploadDatasetForm(); | |||
| this.importStep = 0; | |||
| this.toggleImportDataset(); | |||
| this.onResetFresh(); | |||
| }, | |||
| handleUploadDataset(formName) { | |||
| this.$refs[formName].validate(valid => { | |||
| if (!valid) { | |||
| return; | |||
| } | |||
| const customForm = { | |||
| const customForm = { | |||
| name: this.form.name, | |||
| desc: this.form.remark, | |||
| archiveUrl: `${bucketName}/${this.form.datasetFile}`, | |||
| remark: this.form.remark, | |||
| annotateType: this.form.annotateType, | |||
| dataType: this.form.dataType, | |||
| type: 0, | |||
| import: true, | |||
| }; | |||
| return addCustomDataset(customForm).then(() => { | |||
| return add(customForm).then(() => { | |||
| this.$message({ | |||
| message: '导入数据集成功', | |||
| message: '创建数据集成功', | |||
| type: 'success', | |||
| }); | |||
| }).finally(() => { | |||
| this.resetFormFields(); | |||
| this.closeUploadDatasetForm(); | |||
| this.toggleImportDataset(); | |||
| this.onResetFresh(); | |||
| }); | |||
| }); | |||
| }, | |||
| resetFormFields() { | |||
| this.formKey += 1; | |||
| this.form = {}; | |||
| }, | |||
| uploadStart() { | |||
| this.uploading = true; | |||
| }, | |||
| uploadSuccess(res) { | |||
| this.form.datasetFile = res[0].data.objectName; | |||
| this.uploading = false; | |||
| this.$refs.datasetFile.validate('manual'); | |||
| }, | |||
| uploadError() { | |||
| this.$message({ | |||
| message: '上传文件失败', | |||
| type: 'error', | |||
| }); | |||
| this.uploading = false; | |||
| this.importStep = 0; | |||
| this.form = { | |||
| name: "", | |||
| dataType: 0, | |||
| annotateType: 2, | |||
| status: 4, | |||
| remark: "", | |||
| }; | |||
| }, | |||
| }, | |||
| }; | |||
| @@ -20,7 +20,7 @@ export default { | |||
| name: 'DatasetStatus', | |||
| functional: true, | |||
| render(h, { data, props }) { | |||
| const { withAllDatasetStatusList, filterByDatasetStatus, datasetStatusFilter } = props; | |||
| const { statusList, filterByDatasetStatus, datasetStatusFilter } = props; | |||
| const iconClass = ['el-icon-arrow-down', 'el-icon--right']; | |||
| const textClass = datasetStatusFilter === 'all' ? null : 'primary'; | |||
| const columnProps = { | |||
| @@ -34,7 +34,7 @@ export default { | |||
| <i {... { class: iconClass } } /> | |||
| </span> | |||
| <el-dropdown-menu slot='dropdown'> | |||
| {withAllDatasetStatusList.map(item => { | |||
| {statusList.map(item => { | |||
| return ( | |||
| <el-dropdown-item | |||
| key={item.value} | |||
| @@ -45,17 +45,10 @@ export default { | |||
| ); | |||
| })} | |||
| </el-dropdown-menu> | |||
| <el-tooltip effect='dark' content='数据集状态可能会延迟更新,请耐心等待' placement='top' style={{ marginLeft: '10px' }}> | |||
| <i class='el-icon-question'/> | |||
| </el-tooltip> | |||
| </el-dropdown> | |||
| ); | |||
| }, | |||
| default: ({ row }) => { | |||
| // 导入自定义数据集 状态保持为标注完成(4) | |||
| if (row.import) { | |||
| row.status = 4; | |||
| } | |||
| const status = datasetStatusMap[row.status] || {}; | |||
| const colorProps = (!status.type && status.bgColor) && { | |||
| props: { | |||
| @@ -0,0 +1,259 @@ | |||
| /** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||
| * | |||
| * Licensed under the Apache License, Version 2.0 (the "License"); | |||
| * you may not use this file except in compliance with the License. | |||
| * You may obtain a copy of the License at | |||
| * | |||
| * http://www.apache.org/licenses/LICENSE-2.0 | |||
| * | |||
| * Unless required by applicable law or agreed to in writing, software | |||
| * distributed under the License is distributed on an "AS IS" BASIS, | |||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| * See the License for the specific language governing permissions and | |||
| * limitations under the License. | |||
| * ============================================================= | |||
| */ | |||
| <template> | |||
| <el-dialog | |||
| :key="state.uploadKey" | |||
| :closeOnClickModal="false" | |||
| append-to-body | |||
| width="610px" | |||
| :visible="visible" | |||
| :title="state.title" | |||
| @close="handleClose" | |||
| > | |||
| <!--选择上传的文件--> | |||
| <div v-show="state.uploadStep === 0"> | |||
| <upload-inline | |||
| ref="fileUploadForm" | |||
| action="fakeApi" | |||
| :accept="state.accept" | |||
| :params="state.uploadParams" | |||
| :transformFile="withDimensionFile" | |||
| v-bind="state.optionUploadProps" | |||
| @uploadSuccess="uploadSuccess" | |||
| @uploadError="uploadError" | |||
| /> | |||
| <!--上传视频时显示帧间隔设置--> | |||
| <el-form | |||
| v-if="!state.isImage" | |||
| ref="formStep" | |||
| :model="state.form" | |||
| label-width="100px" | |||
| style="margin-top: 10px;" | |||
| > | |||
| <el-form-item | |||
| label="视频帧间隔" | |||
| prop="frameInterval" | |||
| :rules="[{required: true, message: '请输入有效的帧间隔', trigger: 'blur'}]" | |||
| > | |||
| <el-input-number v-model="state.form.frameInterval" :min="1" /> | |||
| </el-form-item> | |||
| </el-form> | |||
| </div> | |||
| <!--上传文件进度展示--> | |||
| <div v-show="state.uploadStep === 1"> | |||
| <el-progress | |||
| v-if="state.isImage" | |||
| type="circle" | |||
| :percentage="state.percentage" | |||
| :status="state.uploadStatus" | |||
| :format="format" | |||
| /> | |||
| <div v-else class="circleProgressWrapper"> | |||
| <div class="circleText">正在上传</div> | |||
| <div class="wrapper right"> | |||
| <div class="circleProgress rightCircle"></div> | |||
| </div> | |||
| <div class="wrapper left"> | |||
| <div class="circleProgress leftCircle"></div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <!--上传成功--> | |||
| <div v-show="state.uploadStep === 2"> | |||
| <el-progress type="circle" :percentage="100" status="success" /> | |||
| </div> | |||
| <div slot="footer"> | |||
| <div v-show="state.uploadStep === 0"> | |||
| <el-button @click="handleClose">取消</el-button> | |||
| <el-button type="primary" @click="uploadSubmit('fileUploadForm')">开始上传</el-button> | |||
| </div> | |||
| <div v-show="state.uploadStep === 1"> | |||
| <el-button @click="handleClose">取消</el-button> | |||
| </div> | |||
| <div v-show="state.uploadStep === 2"> | |||
| <el-button type="primary" @click="handleClose">完成</el-button> | |||
| </div> | |||
| </div> | |||
| </el-dialog> | |||
| </template> | |||
| <script> | |||
| import Vue from 'vue'; | |||
| import { reactive, watch } from '@vue/composition-api'; | |||
| import { toFixed } from '@/utils'; | |||
| import UploadInline from '@/components/UploadForm/inline'; | |||
| import { getImgFromMinIO, withDimensionFile, trackUploadProps } from '@/views/dataset/util'; | |||
| import { submit, submitVideo } from '@/api/preparation/datafile'; | |||
| import { Message } from 'element-ui'; | |||
| export default { | |||
| name: 'UploadDataFile', | |||
| components: { | |||
| UploadInline, | |||
| }, | |||
| props: { | |||
| row: { | |||
| type: Object, | |||
| default: () => {}, | |||
| }, | |||
| visible: { | |||
| type: Boolean, | |||
| default: false, | |||
| }, | |||
| loading: { | |||
| type: Boolean, | |||
| default: false, | |||
| }, | |||
| closeUploadDataFile: { | |||
| type: Function, | |||
| }, | |||
| }, | |||
| setup(props, context) { | |||
| const defaultFrameInterval = 5; | |||
| const { closeUploadDataFile } = props; | |||
| const state = reactive({ | |||
| uploadKey: 1, | |||
| row: {}, | |||
| uploadStep: 0, | |||
| isImage: undefined, | |||
| accept: "", | |||
| title: "", | |||
| uploadParams: {}, | |||
| optionUploadProps: {}, | |||
| percentage: 0, | |||
| uploadStatus: undefined, | |||
| form: { | |||
| frameInterval: defaultFrameInterval, | |||
| }, | |||
| }); | |||
| // 监测选中导入的列数据变化 | |||
| watch(() => props.row, (next) => { | |||
| Object.assign(state, { | |||
| row: { ...state.row, ...next }, | |||
| }); | |||
| const { id } = state.row; | |||
| if (state.row.dataType === 0) { | |||
| Object.assign(state, { | |||
| isImage: true, | |||
| title: "导入图片", | |||
| accept: ".jpg,.png,.bmp,.jpeg", | |||
| uploadParams: { | |||
| datasetId: id, | |||
| objectPath: `dataset/${id}/origin`, // 图片对象存储路径 | |||
| }, | |||
| optionUploadProps: {}, | |||
| }); | |||
| } else { | |||
| Object.assign(state, { | |||
| isImage: false, | |||
| title: "导入视频", | |||
| uploadParams: { | |||
| datasetId: id, | |||
| objectPath: `dataset/${id}/video`, // 图片对象存储路径 | |||
| }, | |||
| accept: ".mp4,.avi,.mkv,.mov,.webm,.wmv", | |||
| optionUploadProps: trackUploadProps, | |||
| }); | |||
| } | |||
| }); | |||
| // 上传包括图片和视频 | |||
| const uploader = async (datasetId, files) => { | |||
| // 文件上传 | |||
| if (state.isImage) { | |||
| return submit(datasetId, files); | |||
| } | |||
| return submitVideo(datasetId, { | |||
| frameInterval: state.form.frameInterval, | |||
| url: files[0].url, | |||
| }); | |||
| }; | |||
| // 上传视频时不显示实时进度 | |||
| const format = (percentage) => { | |||
| return percentage < 100 ? `${percentage}%` : ``; | |||
| }; | |||
| // 上传成功 | |||
| const uploadSuccess = (res) => { | |||
| // 视频上传完毕 | |||
| if (!state.isImage) { | |||
| state.percentage = 100; | |||
| } | |||
| const files = getImgFromMinIO(res); | |||
| // 自动标注完成时 导入 提示信息不同 | |||
| const successMessage = "上传文件成功"; | |||
| if (files.length > 0) { | |||
| uploader(state.row.id, files).then(() => { | |||
| Message.success({ message: successMessage, duration: 1000 }); | |||
| }); | |||
| } | |||
| Object.assign(state, { | |||
| loading: false, | |||
| uploadStatus: "success", | |||
| uploadStep: 2, | |||
| title: "上传成功", | |||
| }); | |||
| }; | |||
| // 上传失败 | |||
| const uploadError = () => { | |||
| state.loading = false; | |||
| state.uploadStatus = "exception"; | |||
| Message.error({ message: "上传失败", duration: 1000 }); | |||
| }; | |||
| // 确定上传 | |||
| const uploadSubmit = formName => { | |||
| context.refs[formName].uploadSubmit((resolved, total) => { | |||
| // eslint-disable-next-line func-names | |||
| Vue.nextTick(function() { | |||
| state.percentage = | |||
| state.percentage > 100 ? 100 : toFixed(resolved / total); | |||
| }); | |||
| }); | |||
| Object.assign(state, { | |||
| loading: true, | |||
| uploadStep: 1, | |||
| title: "上传中", | |||
| }); | |||
| }; | |||
| const handleClose = () => { | |||
| closeUploadDataFile(); | |||
| Object.assign(state, { | |||
| uploadStep: 0, | |||
| uploadKey: state.uploadKey + 1, | |||
| percentage: 0, | |||
| uploadStatus: undefined, | |||
| }); | |||
| }; | |||
| return { | |||
| state, | |||
| uploadSubmit, | |||
| format, | |||
| handleClose, | |||
| withDimensionFile, | |||
| uploadSuccess, | |||
| uploadError, | |||
| }; | |||
| }, | |||
| }; | |||
| </script> | |||
| @@ -0,0 +1,262 @@ | |||
| /** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||
| * | |||
| * Licensed under the Apache License, Version 2.0 (the "License"); | |||
| * you may not use this file except in compliance with the License. | |||
| * You may obtain a copy of the License at | |||
| * | |||
| * http://www.apache.org/licenses/LICENSE-2.0 | |||
| * | |||
| * Unless required by applicable law or agreed to in writing, software | |||
| * distributed under the License is distributed on an "AS IS" BASIS, | |||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| * See the License for the specific language governing permissions and | |||
| * limitations under the License. | |||
| * ============================================================= | |||
| */ | |||
| @import "~@/assets/styles/variables.scss"; | |||
| .table-top-row { | |||
| background-color: $menuBg !important; | |||
| } | |||
| .link-primary { | |||
| color: $primaryColor; | |||
| cursor: pointer; | |||
| } | |||
| .label-input { | |||
| max-height: 200px; | |||
| overflow-y: auto; | |||
| border-color: #b4bccc; | |||
| border-style: solid; | |||
| border-width: 1px; | |||
| border-radius: 5px; | |||
| .el-button { | |||
| padding: 0; | |||
| } | |||
| } | |||
| .label-group-select { | |||
| width: 240px; | |||
| } | |||
| .label-group-popover { | |||
| padding: 0; | |||
| } | |||
| .label-input .el-tag { | |||
| margin-left: 4px; | |||
| } | |||
| .tt-wrapper.progress-tip { | |||
| .tooltip-item-label { | |||
| min-width: 100px; | |||
| } | |||
| } | |||
| .dataset-name-col { | |||
| .cell { | |||
| text-overflow: unset; | |||
| } | |||
| .name-col { | |||
| max-width: 90%; | |||
| span { | |||
| overflow: hidden; | |||
| text-overflow: ellipsis; | |||
| } | |||
| } | |||
| } | |||
| .placeholder { | |||
| p { | |||
| margin: 0; | |||
| } | |||
| .has-tip { | |||
| line-height: 1.5; | |||
| .tip { | |||
| padding: 10px; | |||
| margin-bottom: 20px; | |||
| color: #f38900; | |||
| background: #ffe9cc; | |||
| } | |||
| .requirement { | |||
| font-size: 14px; | |||
| color: $infoColor; | |||
| } | |||
| } | |||
| a { | |||
| color: $primaryColor; | |||
| } | |||
| } | |||
| .el-progress { | |||
| display: block; | |||
| } | |||
| .el-progress-bar { | |||
| padding-right: 70px; | |||
| margin-right: -70px; | |||
| } | |||
| .el-progress--circle { | |||
| .el-progress__text { | |||
| i { | |||
| font-size: 30px !important; | |||
| } | |||
| } | |||
| } | |||
| .el-progress-circle { | |||
| margin: 0 auto; | |||
| } | |||
| .progress-wrap { | |||
| .el-icon-loading + span { | |||
| display: block; | |||
| flex: 1; | |||
| margin-left: 4px; | |||
| } | |||
| } | |||
| .decompress-progress { | |||
| flex: 1; | |||
| margin: 0 auto; | |||
| .el-progress-bar__inner { | |||
| background: | |||
| -webkit-repeating-linear-gradient( | |||
| -30deg, | |||
| #83a7cf 0, | |||
| #83a7cf 10px, | |||
| #93b3d6 10px, | |||
| #93b3d6 20px | |||
| ); | |||
| animation: process 5s linear infinite; | |||
| } | |||
| .el-progress__text { | |||
| display: inline; | |||
| } | |||
| @keyframes process { | |||
| 0% { | |||
| background-position: 0 0; | |||
| } | |||
| 100% { | |||
| background-position: 180px 0; | |||
| } | |||
| } | |||
| } | |||
| .reannotate-popconfirm { | |||
| .el-popconfirm__main { | |||
| align-items: baseline; | |||
| } | |||
| } | |||
| // 模拟的圆形进度条 | |||
| .circleProgressWrapper { | |||
| position: relative; | |||
| width: 126px; | |||
| height: 126px; | |||
| margin: 0 auto; | |||
| .circleText { | |||
| line-height: 126px; | |||
| text-align: center; | |||
| } | |||
| .wrapper { | |||
| position: absolute; | |||
| top: 0; | |||
| width: 63px; | |||
| height: 126px; | |||
| overflow: hidden; | |||
| } | |||
| .right { | |||
| right: 0; | |||
| } | |||
| .left { | |||
| left: 0; | |||
| } | |||
| .circleProgress { | |||
| position: absolute; | |||
| top: 0; | |||
| width: 126px; | |||
| height: 126px; | |||
| border: 8px solid #87d068; | |||
| border-radius: 50%; | |||
| transform: rotate(45deg); | |||
| } | |||
| .rightCircle { | |||
| right: 0; | |||
| border-top: 8px solid #87d068; | |||
| border-right: 8px solid #87d068; | |||
| animation: rightCircleProgressLoad 5s linear infinite; | |||
| } | |||
| .leftCircle { | |||
| left: 0; | |||
| border-bottom: 8px solid #87d068; | |||
| border-left: 8px solid #87d068; | |||
| animation: leftCircleProgressLoad 5s linear infinite; | |||
| } | |||
| @keyframes rightCircleProgressLoad { | |||
| 0% { | |||
| border-top: 8px solid #87d068; | |||
| border-right: 8px solid #87d068; | |||
| transform: rotate(45deg); | |||
| } | |||
| 50% { | |||
| border-top: 8px solid #108ee9; | |||
| border-right: 8px solid #108ee9; | |||
| border-bottom: 8px solid rgb(81, 197, 81); | |||
| border-left: 8px solid rgb(81, 197, 81); | |||
| transform: rotate(225deg); | |||
| } | |||
| 100% { | |||
| border-bottom: 8px solid #87d068; | |||
| border-left: 8px solid #87d068; | |||
| transform: rotate(225deg); | |||
| } | |||
| } | |||
| @keyframes leftCircleProgressLoad { | |||
| 0% { | |||
| border-bottom: 8px solid #87d068; | |||
| border-left: 8px solid #87d068; | |||
| transform: rotate(45deg); | |||
| } | |||
| 50% { | |||
| border-top: 8px solid rgb(81, 197, 81); | |||
| border-right: 8px solid rgb(81, 197, 81); | |||
| border-bottom: 8px solid #108ee9; | |||
| border-left: 8px solid #108ee9; | |||
| transform: rotate(45deg); | |||
| } | |||
| 100% { | |||
| border-top: 8px solid #87d068; | |||
| border-right: 8px solid #87d068; | |||
| border-bottom: 8px solid #87d068; | |||
| border-left: 8px solid #87d068; | |||
| transform: rotate(225deg); | |||
| } | |||
| } | |||
| } | |||
| @@ -42,6 +42,22 @@ export const parseAnnotation = (annotationStr, labels) => { | |||
| return result; | |||
| }; | |||
| // 将 annotation 生成可拖拽的形式 | |||
| export const withExtent = annotations => { | |||
| return annotations.map(d => ({ | |||
| ...d, | |||
| data: { | |||
| ...d.data, | |||
| extent: { | |||
| x0: d.data.bbox.x, | |||
| y0: d.data.bbox.y, | |||
| x1: d.data.bbox.x + d.data.bbox.width, | |||
| y1: d.data.bbox.y + d.data.bbox.height, | |||
| }, | |||
| }, | |||
| })); | |||
| }; | |||
| // 将annotations 生成字符串 | |||
| export const stringifyAnnotations = (annotations) => { | |||
| const resultList = annotations.map(d => { | |||
| @@ -157,6 +173,16 @@ export const withDimensionFiles = async(files) => { | |||
| return Promise.all(files.map(file => checkImg(file))); | |||
| }; | |||
| // 目标跟踪视频上传参数 | |||
| export const trackUploadProps = { | |||
| acceptSize: 1024, | |||
| accept: '.mp4,.avi,.mkv,.mov,.webm,.wmv', | |||
| listType: 'text', | |||
| limit: 1, | |||
| multiple: false, | |||
| showFileCount: false, | |||
| }; | |||
| // context 配置 | |||
| export const labelsSymbol = Symbol('labels'); | |||
| export const enhanceSymbol = Symbol('enhance'); | |||
| @@ -170,10 +196,25 @@ export const dataTypeMap = { | |||
| // 文件状态 | |||
| export const fileTypeEnum = { | |||
| 0: { label: '全部', abbr: '全部' }, | |||
| 1: { label: '未标注', abbr: '未标注' }, | |||
| 2: { label: '自动标注完成', abbr: '自动完成' }, | |||
| 3: { label: '手动标注完成', abbr: '手动完成' }, | |||
| 4: { label: '自动目标跟踪完成', abbr: '跟踪完成' }, | |||
| 101: { label: '未标注', abbr: '未标注' }, | |||
| 102: { label: '手动标注中', abbr: '手动标注中' }, | |||
| 103: { label: '自动标注完成', abbr: '自动完成' }, | |||
| 104: { label: '手动标注完成', abbr: '手动完成' }, | |||
| 105: { label: '未识别', abbr: '未识别'}, | |||
| 201: { label: '目标跟踪完成', abbr: '跟踪完成' }, | |||
| 301: { label: '未完成', abbr: '未完成'}, | |||
| 302: { label: '已完成', abbr: '已完成'}, | |||
| }; | |||
| export const fileCodeMap = { | |||
| 'ALL': 0, | |||
| 'UNANNOTATED': 101, | |||
| 'MANUAL_ANNOTATING': 102, | |||
| 'AUTO_ANNOTATED': 103, | |||
| 'MANUAL_ANNOTATED': 104, | |||
| 'UNRECOGNIZED': 105, | |||
| 'TRACK_SUCCEED': 201, | |||
| 'UNCOMPLETED': 301, | |||
| 'COMPLETED': 302, | |||
| }; | |||
| export const annotationMap = { | |||
| @@ -186,15 +227,34 @@ export const annotationMap = { | |||
| // 数据集状态 | |||
| export const datasetStatusMap = { | |||
| 0: { name: '未标注', type: 'info' }, | |||
| 1: { name: '标注中', type: 'warning' }, | |||
| 2: { name: '自动标注中', type: 'danger' }, | |||
| 3: { name: '自动标注完成', type: '' }, | |||
| 4: { name: '标注完成', type: 'success' }, | |||
| 5: { name: '未采样', bgColor: '#a7a7a7', color: '#fff' }, | |||
| 6: { name: '目标跟踪完成', bgColor: '#409EFF', color: '#fff' }, | |||
| 7: { name: '采样中', bgColor: '#606266', color: '#fff' }, | |||
| 8: { name: '数据增强中', bgColor: '#1890ff', color: '#fff' }, | |||
| 101: { name: '未标注', type: 'info' }, | |||
| 102: { name: '标注中', type: 'warning' }, | |||
| 103: { name: '自动标注中', type: 'danger' }, | |||
| 104: { name: '自动标注完成', type: '' }, | |||
| 105: { name: '标注完成', type: 'success' }, | |||
| 201: { name: '目标跟踪中', bgColor: '#409EFF', color: '#fff' }, | |||
| 202: { name: '目标跟踪完成', bgColor: '#409EFF', color: '#fff' }, | |||
| 203: { name: '目标跟踪失败', bgColor: '#409EFF', color: '#fff' }, | |||
| 301: { name: '未采样', bgColor: '#a7a7a7', color: '#fff' }, | |||
| 302: { name: '采样中', bgColor: '#606266', color: '#fff' }, | |||
| 303: { name: '采样失败', bgColor: '#606266', color: '#fff' }, | |||
| 401: { name: '数据增强中', bgColor: '#1890ff', color: '#fff' }, | |||
| 402: { name: '导入中', bgColor: '#606266', color: '#fff' }, | |||
| }; | |||
| export const statusCodeMap = { | |||
| 101: 'UNANNOTATED', // 未标注 | |||
| 102: 'ANNOTATING', | |||
| 103: 'AUTO_ANNOTATING', | |||
| 104: 'AUTO_ANNOTATED', | |||
| 105: 'ANNOTATED', | |||
| 201: 'TRACKING', | |||
| 202: 'TRACK_SUCCEED', | |||
| 203: 'TRACK_FAILED', | |||
| 301: 'UNSAMPLED', | |||
| 302: 'SAMPLING', | |||
| 303: 'SAMPLE_FAILED', | |||
| 401: 'ENHANCING', | |||
| 402: 'IMPORTING', | |||
| }; | |||
| // 标注精度 | |||
| @@ -203,6 +263,7 @@ export const annotationProgressMap = { | |||
| unfinished: '未完成', | |||
| autoFinished: '自动标注完成', | |||
| finishAutoTrack: '目标跟踪完成', | |||
| annotationNotDistinguishFile: '未识别', | |||
| }; | |||
| export const decompressProgressMap = { | |||
| @@ -219,3 +280,9 @@ export const dataEnhanceMap = { | |||
| 3: 'info', | |||
| 4: 'warning', | |||
| }; | |||
| // 根据value取key | |||
| export const findKey = (value, data, compare = (a, b) => a === b) => | |||
| { | |||
| return Object.keys(data).find(k => compare(data[k], value)); | |||
| }; | |||
| @@ -27,18 +27,18 @@ | |||
| > | |||
| <el-form ref="form" :model="form" :rules="rules" label-width="100px"> | |||
| <el-form-item label="名称" prop="noteBookName"> | |||
| <el-input v-model="form.noteBookName" class="input" maxlength="30" style="width: 600px;" show-word-limit placeholder="请输入notebook名称" /> | |||
| <el-input id="noteBookName" v-model="form.noteBookName" class="input" maxlength="30" style="width: 600px;" show-word-limit placeholder="请输入notebook名称" /> | |||
| </el-form-item> | |||
| <el-form-item label="描述" prop="description"> | |||
| <el-input v-model="form.description" type="textarea" maxlength="255" show-word-limit style="width: 600px;" /> | |||
| <el-input id="description" v-model="form.description" type="textarea" maxlength="255" show-word-limit style="width: 600px;" /> | |||
| </el-form-item> | |||
| <el-form-item label="开发环境" prop="k8sImageName"> | |||
| <el-select v-model="form.k8sImageName" placeholder="请选择开发环境" no-data-text="请先选择项目" style="width: 600px;" @change="validateField('k8sImageName')"> | |||
| <el-select id="k8sImageName" v-model="form.k8sImageName" placeholder="请选择开发环境" no-data-text="请先选择项目" style="width: 600px;" @change="validateField('k8sImageName')"> | |||
| <el-option v-for="(item, index) in imageOptions" :key="index" :label="item.label" :value="item.value" /> | |||
| </el-select> | |||
| </el-form-item> | |||
| <el-form-item label="类型" prop="deviceType"> | |||
| <el-radio-group v-model="form.deviceType" @change="onDeviceChange"> | |||
| <el-radio-group id="deviceType" v-model="form.deviceType" @change="onDeviceChange"> | |||
| <el-radio-button v-for="(item,index) in deviceOptions" :key="index" :label="item">{{ item==='GPU'?'CPU + GPU':item }}</el-radio-button> | |||
| </el-radio-group> | |||
| </el-form-item> | |||
| @@ -19,17 +19,26 @@ | |||
| <!--工具栏--> | |||
| <div class="head-container"> | |||
| <cdOperation linkType="custom" @to-add="toAdd"> | |||
| <span slot="left"> | |||
| <el-tooltip | |||
| content="Notebook 将会在开启后四小时自动关闭,请及时保存您的代码" | |||
| placement="top" | |||
| > | |||
| <i class="el-icon-warning-outline primary f18" /> | |||
| </el-tooltip> | |||
| </span> | |||
| <span slot="right"> | |||
| <!-- 搜索 --> | |||
| <el-select v-model="query.status" class="filter-item" placeholder="状态" clearable @change="crud.toQuery"> | |||
| <el-option | |||
| v-for="item in statusOptions" | |||
| :key="item.statusCode" | |||
| :value="item.statusCode" | |||
| :label="item.statusName" | |||
| /> | |||
| </el-select> | |||
| <el-input v-model="query.noteBookName" clearable placeholder="请输入名称" class="filter-item" style="width: 200px;" @keyup.enter.native="crud.toQuery" /> | |||
| <el-input | |||
| id="queryName" | |||
| v-model="localQuery.noteBookName" | |||
| clearable | |||
| placeholder="请输入名称" | |||
| class="filter-item" | |||
| style="width: 200px;" | |||
| @clear="crud.toQuery" | |||
| @keyup.enter.native="crud.toQuery" | |||
| /> | |||
| <rrOperation /> | |||
| </span> | |||
| </cdOperation> | |||
| @@ -53,6 +62,14 @@ | |||
| </el-table-column> | |||
| <el-table-column prop="description" label="描述" /> | |||
| <el-table-column prop="status" label="状态" width="100"> | |||
| <template #header> | |||
| <dropdown-header | |||
| title="状态" | |||
| :list="notebookStatusList" | |||
| :filtered="Boolean(localQuery.status) || localQuery.status === 0" | |||
| @command="filterByStatus" | |||
| /> | |||
| </template> | |||
| <template slot-scope="scope"> | |||
| <el-tag v-if="!(scope.row.status==0 && !scope.row.url)" :type="getTagType(scope.row.status)" effect="plain">{{ notebookStatus[scope.row.status] }} </el-tag> | |||
| <el-tag v-if="(scope.row.status==0 && !scope.row.url)" :type="getTagType(3)" effect="plain">{{ notebookStatus[3] }} </el-tag> | |||
| @@ -65,13 +82,13 @@ | |||
| </el-table-column> | |||
| <el-table-column label="操作" width="200" fixed="right"> | |||
| <template slot-scope="scope"> | |||
| <el-button v-if="scope.row.status === 1" type="text" @click.stop="doStart(scope.row)">启动</el-button> | |||
| <el-button v-if="scope.row.status === 1" type="text" @click.stop="doDelete(scope.row)">删除</el-button> | |||
| <el-button v-if="scope.row.status === 0 && scope.row.url" type="text" @click.stop="doOpen(scope.row)"> | |||
| <el-button v-if="scope.row.status === 1" :id="`start_`+scope.$index" type="text" @click.stop="doStart(scope.row)">启动</el-button> | |||
| <el-button v-if="scope.row.status === 1" :id="`delete_`+scope.$index" type="text" @click.stop="doDelete(scope.row)">删除</el-button> | |||
| <el-button v-if="scope.row.status === 0 && scope.row.url" :id="`open_`+scope.$index" type="text" @click.stop="doOpen(scope.row)"> | |||
| 打开<IconFont type="externallink" /> | |||
| </el-button> | |||
| <el-button v-if="scope.row.status === 0 && scope.row.url" type="text" @click.stop="doStop(scope.row)">停止</el-button> | |||
| <el-button v-if="((scope.row.status === 0 && scope.row.url) || scope.row.status === 1) && !scope.row.algorithmId" type="text" @click.stop="doSave(scope.row)">保存算法</el-button> | |||
| <el-button v-if="scope.row.status === 0 && scope.row.url" :id="`stop_`+scope.$index" type="text" @click.stop="doStop(scope.row)">停止</el-button> | |||
| <el-button v-if="((scope.row.status === 0 && scope.row.url) || scope.row.status === 1) && !scope.row.algorithmId" :id="`save_`+scope.$index" type="text" @click.stop="doSave(scope.row)">保存算法</el-button> | |||
| <i v-if="[3, 4, 5].includes(scope.row.status) || (scope.row.status === 0 && !scope.row.url)" class="el-icon-loading" /> | |||
| </template> | |||
| </el-table-column> | |||
| @@ -87,6 +104,7 @@ import { debounce } from 'throttle-debounce'; | |||
| import notebookApi, {detail, getStatus, start, stop, open} from '@/api/development/notebook'; | |||
| import { add as addAlgorithm } from '@/api/algorithm/algorithm'; | |||
| import DropdownHeader from '@/components/DropdownHeader'; | |||
| import CRUD, { presenter, header, crud } from '@crud/crud'; | |||
| import rrOperation from '@crud/RR.operation'; | |||
| import cdOperation from '@crud/CD.operation'; | |||
| @@ -96,7 +114,7 @@ import NotebookDetail from './components/NotebookDetail'; | |||
| export default { | |||
| name: 'Notebook', | |||
| components: { pagination, rrOperation, cdOperation, CreateDialog, NotebookDetail }, | |||
| components: { pagination, rrOperation, cdOperation, DropdownHeader, CreateDialog, NotebookDetail }, | |||
| cruds() { | |||
| return CRUD({ | |||
| title: 'Notebook', | |||
| @@ -121,9 +139,24 @@ export default { | |||
| drawer: false, | |||
| selectedItemObj: {}, | |||
| pollingCount: 0, | |||
| keepPoll: true, | |||
| ct: null, | |||
| localQuery: { | |||
| noteBookName: null, | |||
| status: null, | |||
| }, | |||
| }; | |||
| }, | |||
| computed: { | |||
| notebookStatusList() { | |||
| return [{ label: '全部', value: null }].concat(this.statusOptions.map(status => { | |||
| return { | |||
| label: status.statusName, | |||
| value: status.statusCode, | |||
| }; | |||
| })); | |||
| }, | |||
| }, | |||
| mounted() { | |||
| this.crud.msg.del = '正在删除'; | |||
| this.pollingCount = 0; | |||
| @@ -131,10 +164,12 @@ export default { | |||
| this.query.noteBookName = this.$route.params.noteBookName; | |||
| } | |||
| this.refetch = debounce(1000, this.crud.refresh); | |||
| this.detailRefetch = debounce(2000, this.polling); | |||
| this.getNotebookStatus(); | |||
| }, | |||
| beforeDestroy() { | |||
| this.ct && clearTimeout(this.ct); | |||
| this.keepPoll = false; | |||
| }, | |||
| methods: { | |||
| [CRUD.HOOK.afterRefresh]() { | |||
| @@ -153,18 +188,22 @@ export default { | |||
| this.crud.refresh(); | |||
| }); | |||
| }, | |||
| filterByStatus(status) { | |||
| this.localQuery.status = status; | |||
| this.crud.toQuery(); | |||
| }, | |||
| checkStatus() { | |||
| // 删除操作5s内 或 有进行中的状态需要刷新列表 | |||
| if (this.deleteCount > 0) { | |||
| this.deleteCount -= 1; | |||
| this.refetch(); | |||
| } else if (this.crud.data.some(item => [3, 4, 5].includes(item.status) || (item.status === 0 && !item.url))) { | |||
| this.polling(); | |||
| this.detailRefetch(); | |||
| } | |||
| }, | |||
| async polling() { | |||
| const idList = this.checkPollingIds(); | |||
| if (!idList.length) { | |||
| if (!this.keepPoll || !idList.length) { | |||
| return; | |||
| } | |||
| const res = await detail(idList); | |||
| @@ -177,12 +216,16 @@ export default { | |||
| ele.status = item.status; | |||
| ele.updateTime = item.updateTime; | |||
| ele.url = item.url; | |||
| // 当变成云心中且有url时,自动打开url | |||
| if(item.status === 0 && item.url){ | |||
| window.open(item.url, '_blank'); | |||
| } | |||
| } | |||
| } | |||
| if (this.crud.data.some(item => [3, 4, 5].includes(item.status)) || this.crud.data.some(item => (item.status === 0 && !item.url))) { | |||
| this.ct = setTimeout(() => { | |||
| if (this.pollingCount < 200) { // 400s超时,超时不作提示 | |||
| this.polling(); | |||
| this.detailRefetch(); | |||
| } | |||
| }, 2000); | |||
| } | |||
| @@ -209,6 +252,9 @@ export default { | |||
| default: return ''; | |||
| } | |||
| }, | |||
| [CRUD.HOOK.beforeRefresh]() { | |||
| this.crud.query = { ...this.localQuery}; | |||
| }, | |||
| toAdd() { | |||
| this.$refs.create.showThis(); | |||
| }, | |||
| @@ -0,0 +1,117 @@ | |||
| /** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||
| * | |||
| * Licensed under the Apache License, Version 2.0 (the "License"); | |||
| * you may not use this file except in compliance with the License. | |||
| * You may obtain a copy of the License at | |||
| * | |||
| * http://www.apache.org/licenses/LICENSE-2.0 | |||
| * | |||
| * Unless required by applicable law or agreed to in writing, software | |||
| * distributed under the License is distributed on an "AS IS" BASIS, | |||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| * See the License for the specific language governing permissions and | |||
| * limitations under the License. | |||
| * ============================================================= | |||
| */ | |||
| <template> | |||
| <div> | |||
| <el-form-item | |||
| v-for="(key, index) in keys" | |||
| :key="key" | |||
| class="mb-10" | |||
| :label="'自定义标签' + (key + 1)" | |||
| :prop="'labels.' + index" | |||
| :rules="rules" | |||
| > | |||
| <div class="flex"> | |||
| <InfoSelect | |||
| :value="list[index].id || list[index].name" | |||
| style="width: 200px; margin-right: 10px;" | |||
| placeholder="选择或新建标签" | |||
| :dataSource="activeLabels" | |||
| valueKey="id" | |||
| labelKey='name' | |||
| default-first-option | |||
| filterable | |||
| allow-create | |||
| :disabled="!editAble && isOriginList(list[index])" | |||
| @change="params => handleChange(key, params)" | |||
| /> | |||
| <el-input v-model="list[index].name" :disabled="!editAble && isOriginList(list[index])" class='dn'></el-input> | |||
| <el-color-picker v-model="list[index].color" :disabled="!editAble && isOriginList(list[index])" size="small" /> | |||
| <span style="width: 50px; margin-left: 10px; line-height: 32px;"> | |||
| <i | |||
| v-if="keys.length > 1 && addAble" | |||
| class="el-icon-remove-outline vm cp" | |||
| :class="!editAble && isOriginList(list[index]) ? 'disabled' : ''" | |||
| style="font-size: 20px;" | |||
| @click.prevent="remove(key)" | |||
| /> | |||
| <i | |||
| v-if="index === (keys.length - 1) && addAble" | |||
| class="el-icon-circle-plus-outline vm cp" | |||
| :class="!addAble ? 'disabled' : ''" | |||
| style="font-size: 20px;" | |||
| @click="add" | |||
| /> | |||
| </span> | |||
| </div> | |||
| </el-form-item> | |||
| </div> | |||
| </template> | |||
| <script> | |||
| import InfoSelect from '@/components/InfoSelect'; | |||
| import { validateLabel } from '@/utils/validate'; | |||
| export default { | |||
| name: 'DynamicField', | |||
| components: { | |||
| InfoSelect, | |||
| }, | |||
| props: { | |||
| actionType: String, | |||
| list: { | |||
| type: Array, | |||
| deafault: () => ([]), | |||
| }, | |||
| activeLabels: { | |||
| type: Array, | |||
| deafault: () => ([]), | |||
| }, | |||
| originList: { | |||
| type: Array, | |||
| deafault: () => ([]), | |||
| }, | |||
| keys: { | |||
| type: Array, | |||
| deafault: () => ([]), | |||
| }, | |||
| remove: Function, | |||
| add: Function, | |||
| handleChange: Function, | |||
| validateDuplicate: Function, | |||
| }, | |||
| setup(props) { | |||
| const rules = [ | |||
| { validator: validateLabel, trigger: ['change', 'blur'] }, | |||
| { validator: props.validateDuplicate, trigger: ['change', 'blur'] }, | |||
| ]; | |||
| // 可以添加 | |||
| const addAble = ['create', 'edit'].includes(props.actionType); | |||
| const editAble = props.actionType === 'create'; | |||
| const isOriginList = item => { | |||
| const isOrigin = props.originList.findIndex(d => d.id === item.id) > -1; | |||
| return isOrigin; | |||
| }; | |||
| return { | |||
| rules, | |||
| editAble, | |||
| addAble, | |||
| isOriginList, | |||
| }; | |||
| }, | |||
| }; | |||
| </script> | |||
| @@ -0,0 +1,359 @@ | |||
| /** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||
| * | |||
| * Licensed under the Apache License, Version 2.0 (the "License"); | |||
| * you may not use this file except in compliance with the License. | |||
| * You may obtain a copy of the License at | |||
| * | |||
| * http://www.apache.org/licenses/LICENSE-2.0 | |||
| * | |||
| * Unless required by applicable law or agreed to in writing, software | |||
| * distributed under the License is distributed on an "AS IS" BASIS, | |||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| * See the License for the specific language governing permissions and | |||
| * limitations under the License. | |||
| * ============================================================= | |||
| */ | |||
| <template> | |||
| <div class="app-container"> | |||
| <div class="head-container"> | |||
| <cdOperation :addProps="operationProps" :delProps="operationProps"> | |||
| <el-button | |||
| slot="left" | |||
| class="filter-item" | |||
| type="primary" | |||
| icon="el-icon-plus" | |||
| round | |||
| @click="doCreate" | |||
| > | |||
| 创建标签组 | |||
| </el-button> | |||
| <span slot="right"> | |||
| <el-input | |||
| v-model="query.name" | |||
| placeholder="输入名称或ID查询标签组" | |||
| style="width: 200px;" | |||
| class="filter-item" | |||
| @keyup.enter.native="crud.toQuery" | |||
| /> | |||
| <rrOperation @resetQuery="onResetQuery" /> | |||
| </span> | |||
| </cdOperation> | |||
| </div> | |||
| <div class="mb-10 flex"> | |||
| <el-tabs :value="activePanelLabelGroup" class="eltabs-inlineblock" @tab-click="handlePanelClick"> | |||
| <el-tab-pane label="我的标签组" name="0" /> | |||
| <el-tab-pane label="预置标签组" name="1" /> | |||
| </el-tabs> | |||
| <el-button class="filter-item" style="margin-left: auto;" icon="el-icon-refresh" circle @click="onResetFresh"/> | |||
| </div> | |||
| <!--表格渲染--> | |||
| <el-table | |||
| ref="table" | |||
| v-loading="crud.loading" | |||
| :data="crud.data" | |||
| highlight-current-row | |||
| @selection-change="crud.selectionChangeHandler" | |||
| @sort-change="crud.sortChange" | |||
| > | |||
| <el-table-column fixed type="selection" min-width="40" /> | |||
| <el-table-column fixed prop="id" width="70" label="ID" sortable="custom" align="left" /> | |||
| <el-table-column | |||
| fixed | |||
| show-overflow-tooltip | |||
| prop="name" | |||
| label="名称" | |||
| min-width="160" | |||
| align="left" | |||
| class-name="dataset-name-col" | |||
| > | |||
| <template slot-scope="scope"> | |||
| <el-link class="mr-10 name-col" @click="goDetail(scope.row)">{{ scope.row.name }}</el-link> | |||
| </template> | |||
| </el-table-column> | |||
| <el-table-column | |||
| prop="count" | |||
| min-width="80" | |||
| label="标签数量" | |||
| align="left" | |||
| /> | |||
| <el-table-column | |||
| prop="updateTime" | |||
| min-width="160" | |||
| label="更新时间" | |||
| :formatter="formatDate" | |||
| sortable="custom" | |||
| align="left" | |||
| /> | |||
| <el-table-column | |||
| prop="createTime" | |||
| min-width="160" | |||
| label="创建时间" | |||
| :formatter="formatDate" | |||
| sortable="custom" | |||
| align="left" | |||
| /> | |||
| <el-table-column | |||
| prop="remark" | |||
| min-width="220" | |||
| label="标签组描述" | |||
| align="left" | |||
| show-overflow-tooltip | |||
| /> | |||
| <LabelGroupAction | |||
| fixed="right" | |||
| min-width="220" | |||
| align="left" | |||
| :goDetail="goDetail" | |||
| :doEdit="doEdit" | |||
| :doFork="showFork" | |||
| /> | |||
| </el-table> | |||
| <!--分页组件--> | |||
| <el-pagination | |||
| :page-size.sync="crud.page.size" | |||
| :page-sizes="[10, 20, 50]" | |||
| :total="crud.page.total" | |||
| :current-page.sync="crud.page.current" | |||
| :style="`text-align:${crud.props.paginationAlign};`" | |||
| style="margin-top: 8px;" | |||
| layout="total, prev, pager, next, sizes" | |||
| @size-change="crud.sizeChangeHandler($event)" | |||
| @current-change="crud.pageChangeHandler" | |||
| /> | |||
| <BaseModal | |||
| :visible="actionModal.show && actionModal.type === 'fork'" | |||
| :loading="actionModal.showOkLoading" | |||
| title="复制标签组" | |||
| @change="handleCancel" | |||
| @ok="handleFork" | |||
| > | |||
| <el-form ref="form" :model="forkForm" :rules="rules" label-width="100px"> | |||
| <el-form-item label="名称" prop="name"> | |||
| <el-input v-model="forkForm.name" placeholder="标签组名称不能超过50字" maxlength="50" /> | |||
| </el-form-item> | |||
| <el-form-item label="描述" prop="remark"> | |||
| <el-input | |||
| v-model="forkForm.remark" | |||
| type="textarea" | |||
| placeholder="标签组描述长度不能超过100字" | |||
| maxlength="100" | |||
| rows="3" | |||
| show-word-limit | |||
| /> | |||
| </el-form-item> | |||
| <el-form-item label="标签" prop="labels"> | |||
| <el-input | |||
| v-model="forkForm.labels" | |||
| :disabled="true" | |||
| type="textarea" | |||
| placeholder="JSON5格式" | |||
| rows="6" | |||
| /> | |||
| </el-form-item> | |||
| </el-form> | |||
| </BaseModal> | |||
| </div> | |||
| </template> | |||
| <script> | |||
| import { isNil } from 'lodash'; | |||
| import { mapState } from 'vuex'; | |||
| import crudLabelGroup, { copy as LabelGroupFork, getLabelGroupDetail } from '@/api/preparation/labelGroup'; | |||
| import CRUD, { presenter, header, form, crud } from '@crud/crud'; | |||
| import rrOperation from '@crud/RR.operation'; | |||
| import cdOperation from '@crud/CD.operation'; | |||
| import { formatDateTime } from '@/utils'; | |||
| import { validateName } from '@/utils/validate'; | |||
| import store from '@/store'; | |||
| import BaseModal from '@/components/BaseModal'; | |||
| import LabelGroupAction from './labelGroupAction'; | |||
| import "@/views/dataset/style/list.scss"; | |||
| const defaultForm = { | |||
| id: null, | |||
| name: null, | |||
| labels: null, | |||
| remark: '', | |||
| type: 0, | |||
| }; | |||
| export default { | |||
| name: 'LabelGroup', | |||
| components: { | |||
| cdOperation, | |||
| rrOperation, | |||
| BaseModal, | |||
| LabelGroupAction, | |||
| }, | |||
| cruds() { | |||
| return CRUD({ | |||
| title: '标签组管理', | |||
| crudMethod: { ...crudLabelGroup }, | |||
| optShow: { | |||
| add: false, | |||
| }, | |||
| queryOnPresenterCreated: false, | |||
| }); | |||
| }, | |||
| mixins: [presenter(), header(), form(defaultForm), crud()], | |||
| data() { | |||
| return { | |||
| forkVisible: false, // fork对话框 | |||
| actionModal: { | |||
| show: false, | |||
| row: undefined, | |||
| showOkLoading: false, | |||
| type: null, | |||
| }, | |||
| forkForm : { | |||
| id: null, | |||
| name: null, | |||
| labels: null, | |||
| remark: null, | |||
| type: 0, | |||
| }, | |||
| rules: { | |||
| name: [ | |||
| { required: true, message: '请输入标签组名称', trigger: ['change', 'blur'] }, | |||
| { validator: validateName, trigger: ['change', 'blur'] }, | |||
| ], | |||
| remark: [ | |||
| { required: false, message: '请输入标签组描述信息', trigger: 'blur' }, | |||
| ], | |||
| }, | |||
| }; | |||
| }, | |||
| computed: { | |||
| ...mapState({ | |||
| activePanelLabelGroup: state => { | |||
| return String(state.dataset.activePanelLabelGroup); | |||
| }, | |||
| }), | |||
| isNil() { | |||
| return isNil; | |||
| }, | |||
| localQuery() { | |||
| return { | |||
| type: this.activePanelLabelGroup || 0, | |||
| }; | |||
| }, | |||
| // 区分预置标签组和普通便签组操作权限 | |||
| operationProps() { | |||
| return Number(this.activePanelLabelGroup) === 1 ? { disabled: true } : undefined; | |||
| }, | |||
| }, | |||
| created() { | |||
| this.crud.toQuery(); | |||
| }, | |||
| mounted() { | |||
| if (this.$route.params.type === 'add') { | |||
| setTimeout(() => { | |||
| this.crud.toAdd(); | |||
| }, 500); | |||
| } | |||
| }, | |||
| methods: { | |||
| [CRUD.HOOK.beforeRefresh]() { | |||
| this.crud.query = { ...this.query, ...this.localQuery}; | |||
| }, | |||
| onResetQuery() { | |||
| // 重置查询条件 | |||
| this.query = {}; | |||
| this.crud.order = null; | |||
| this.crud.sort = null; | |||
| this.crud.params = {}; | |||
| this.crud.page.current = 1; | |||
| // 重置表格的排序和筛选条件 | |||
| this.$refs.table.clearSort(); | |||
| }, | |||
| onResetFresh() { | |||
| this.onResetQuery(); | |||
| this.crud.refresh(); | |||
| }, | |||
| handlePanelClick(tab) { | |||
| this.onResetQuery(); | |||
| store.dispatch('dataset/togglePanelLabelGroup', Number(tab.name)); | |||
| Object.assign(this.localQuery, { | |||
| type: Number(tab.name), | |||
| }); | |||
| this.crud.refresh(); | |||
| }, | |||
| formatDate(row, column, cellValue) { | |||
| if(isNil(cellValue)){ | |||
| return cellValue; | |||
| } | |||
| return formatDateTime(cellValue); | |||
| }, | |||
| doCreate() { | |||
| this.$router.push({ | |||
| path: `/data/labelgroup/create`, | |||
| }); | |||
| }, | |||
| // 查看标签组详情 | |||
| goDetail(row) { | |||
| this.$router.push({ | |||
| path: `/data/labelgroup/detail`, | |||
| query: { | |||
| id: row.id, | |||
| }, | |||
| }); | |||
| }, | |||
| // 编辑标签组 | |||
| doEdit(row) { | |||
| this.$router.push({ | |||
| path: `/data/labelgroup/edit`, | |||
| query: { | |||
| id: row.id, | |||
| }, | |||
| }); | |||
| }, | |||
| // 显示fork对话框 | |||
| showFork(row) { | |||
| this.showActionModal(row, 'fork'); | |||
| getLabelGroupDetail(row.id).then(res => { | |||
| Object.assign(this.forkForm, { | |||
| name: res.name, | |||
| remark: res.remark, | |||
| type: res.type, | |||
| labels: JSON.stringify(res.labels), | |||
| id: row.id, | |||
| }); | |||
| }); | |||
| }, | |||
| handleCancel() { | |||
| this.resetActionModal(); | |||
| }, | |||
| handleFork() { | |||
| LabelGroupFork(this.forkForm); | |||
| this.resetActionModal(); | |||
| setTimeout(() => { | |||
| this.onResetFresh(); | |||
| }, 500); | |||
| }, | |||
| showActionModal(row, type) { | |||
| this.actionModal = { | |||
| show: true, | |||
| row, | |||
| showOkLoading: false, | |||
| type, | |||
| }; | |||
| }, | |||
| resetActionModal() { | |||
| this.actionModal = { | |||
| show: false, | |||
| row: undefined, | |||
| showOkLoading: false, | |||
| type: null, | |||
| }; | |||
| }, | |||
| }, | |||
| }; | |||
| </script> | |||
| @@ -0,0 +1,89 @@ | |||
| /** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||
| * | |||
| * Licensed under the Apache License, Version 2.0 (the "License"); | |||
| * you may not use this file except in compliance with the License. | |||
| * You may obtain a copy of the License at | |||
| * | |||
| * http://www.apache.org/licenses/LICENSE-2.0 | |||
| * | |||
| * Unless required by applicable law or agreed to in writing, software | |||
| * distributed under the License is distributed on an "AS IS" BASIS, | |||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| * See the License for the specific language governing permissions and | |||
| * limitations under the License. | |||
| * ============================================================= | |||
| */ | |||
| export default { | |||
| name: 'LabelGroupAction', | |||
| functional: true, | |||
| props: { | |||
| goDetail: Function, | |||
| doEdit: Function, | |||
| doFork: Function, | |||
| }, | |||
| render(h, { data, props }) { | |||
| const { doFork, goDetail, doEdit } = props; | |||
| const columnProps = { | |||
| ...data, | |||
| scopedSlots: { | |||
| header: () => { | |||
| return ( | |||
| <span>操作</span> | |||
| ); | |||
| }, | |||
| default: ({ row } ) => { | |||
| const btnProps = { | |||
| props: { | |||
| type: 'text', | |||
| disabled: row.disabledAction, | |||
| }, | |||
| style: { | |||
| marginLeft: '0px', | |||
| marginRight: '10px', | |||
| }, | |||
| }; | |||
| // 查看详情按钮 | |||
| const checkButton = ( | |||
| <el-button {...btnProps} onClick={() => goDetail(row)}> | |||
| 查看详情 | |||
| </el-button> | |||
| ); | |||
| // 编辑按钮 | |||
| let showEditButton = true; | |||
| const editButton = ( | |||
| <el-button {...btnProps} onClick={() => doEdit(row)}> | |||
| 编辑 | |||
| </el-button> | |||
| ); | |||
| // 复制按钮 | |||
| let showForkButton = true; | |||
| const forkButton = ( | |||
| <el-button {...btnProps} onClick={() => doFork(row)}> | |||
| 复制 | |||
| </el-button> | |||
| ); | |||
| // 预置标签组只具备查看标签功能 | |||
| if (row.type === 1) { | |||
| showEditButton = false; | |||
| showForkButton = false; | |||
| }; | |||
| return ( | |||
| <span> | |||
| {checkButton} | |||
| {showEditButton && editButton} | |||
| {showForkButton && forkButton} | |||
| </span> | |||
| ); | |||
| }, | |||
| }, | |||
| }; | |||
| return h('el-table-column', columnProps); | |||
| }, | |||
| }; | |||
| @@ -0,0 +1,654 @@ | |||
| /** Copyright 2020 Zhejiang Lab. All Rights Reserved. | |||
| * | |||
| * Licensed under the Apache License, Version 2.0 (the "License"); | |||
| * you may not use this file except in compliance with the License. | |||
| * You may obtain a copy of the License at | |||
| * | |||
| * http://www.apache.org/licenses/LICENSE-2.0 | |||
| * | |||
| * Unless required by applicable law or agreed to in writing, software | |||
| * distributed under the License is distributed on an "AS IS" BASIS, | |||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
| * See the License for the specific language governing permissions and | |||
| * limitations under the License. | |||
| * ============================================================= | |||
| */ | |||
| <template> | |||
| <div v-loading="state.loading" class="app-container" style="width: 600px; margin-top: 28px;"> | |||
| <el-form ref="formRef" :model="state.createForm" :rules="rules" label-width="100px"> | |||
| <el-form-item label="名称" prop="name"> | |||
| <el-input | |||
| v-model="state.createForm.name" | |||
| placeholder="标签组名称不能超过50字" | |||
| maxlength="50" | |||
| show-word-limit | |||
| :disabled="state.actionType === 'detail'" | |||
| /> | |||
| </el-form-item> | |||
| <el-form-item v-if="labelGroupType" label="类型" prop="type"> | |||
| <el-input | |||
| v-model="labelGroupType" | |||
| :disabled="true" | |||
| /> | |||
| </el-form-item> | |||
| <el-form-item label="描述" prop="remark"> | |||
| <el-input | |||
| v-model="state.createForm.remark" | |||
| type="textarea" | |||
| placeholder="标签组描述长度不能超过100字" | |||
| maxlength="100" | |||
| rows="3" | |||
| show-word-limit | |||
| :disabled="state.actionType === 'detail'" | |||
| /> | |||
| </el-form-item> | |||
| <el-form-item label="创建方式"> | |||
| <el-tabs :value="state.addWay" class='labels-edit-wrapper' type="border-card" :before-leave="beforeLeave" @tab-click="handleClick"> | |||
| <el-tab-pane label="自定义标签组" name="custom" class="dynamic-field"> | |||
| <Exception v-if="state.createForm.labels.length === 0" /> | |||
| <div v-else> | |||
| <div v-if="state.groupType === 1"> | |||
| <el-tag v-for="label in state.originList" :key="label.id" class="mr-10">{{ label.name }}</el-tag> | |||
| </div> | |||
| <el-form | |||
| v-else | |||
| ref="customFormRef" | |||
| :model="state.createForm" | |||
| label-width="100px" | |||
| > | |||
| <DynamicField | |||
| :list="state.createForm.labels" | |||
| :originList="state.originList" | |||
| :keys="state.keys" | |||
| :activeLabels="state.activeLabels" | |||
| :add="addRow" | |||
| :remove="removeLabel" | |||
| :handleChange="handleLabelChange" | |||
| :actionType="state.actionType" | |||
| :validateDuplicate="validateDuplicate" | |||
| /> | |||
| </el-form> | |||
| </div> | |||
| </el-tab-pane> | |||
| <el-tab-pane label="编辑标签组" name="edit" class='labelgroup-editor'> | |||
| <prism-editor | |||
| ref="editorRef" | |||
| v-model="state.codeContent" | |||
| :readonly="state.actionType === 'detail'" | |||
| class="min-height-100 max-height-400" | |||
| :highlight="highlighter" | |||
| /> | |||
| <span class='icon-wrapper' @click="beautify"> | |||
| <IconFont type="beauty" class="format" /> | |||
| </span> | |||
| </el-tab-pane> | |||
| <el-tab-pane label="导入标签组" name="upload" :disabled="state.actionType !== 'create'"> | |||
| <div class="min-height-100 flex flex-center upload-tab"> | |||
| <UploadInline | |||
| ref="uploadFormRef" | |||
| action="fakeApi" | |||
| accept=".json" | |||
| listType="text" | |||
| :limit="1" | |||
| :acceptSize="0" | |||
| :multiple="false" | |||
| :showFileCount="false" | |||
| :hash="false" | |||
| @uploadError="uploadError" | |||
| /> | |||
| </div> | |||
| </el-tab-pane> | |||
| </el-tabs> | |||
| <div class="field-extra mt-10"> | |||
| <div v-if="state.addWay === 'custom'"> | |||
| <div>「自定义标签组」由用户自己创建,标签名长度不能超过 30</div> | |||
| </div> | |||
| <div v-else-if="state.addWay === 'edit'"> | |||
| <div>1.「编辑标签组」提供用户自由编写标签方式</div> | |||
| <div>2. 请不要随意删除已有标签</div> | |||
| <div>3. 请不要随意修改已有标签 id</div> | |||
| <div>4. 请按照标准格式提供颜色色值</div> | |||
| </div> | |||
| <div v-else-if="state.addWay === 'upload'"> | |||
| <div>1. 请按照格式要求提交 json 格式标签文件</div> | |||
| </div> | |||
| </div> | |||
| </el-form-item> | |||
| </el-form> | |||
| <div style="margin-left: 100px;"> | |||
| <el-button type="primary" @click="handleSubmit">{{ submitTxt }}</el-button> | |||
| <!-- <el-button @click="goBack">{{state.cancelText}}</el-button> --> | |||
| </div> | |||
| </div> | |||
| </template> | |||
| <script> | |||
| import { reactive, ref, onMounted, computed } from '@vue/composition-api'; | |||
| import { Message, MessageBox } from 'element-ui'; | |||
| import { pick, uniqBy } from 'lodash'; | |||
| import Beautify from 'js-beautify'; | |||
| import { PrismEditor } from 'vue-prism-editor'; | |||
| import 'vue-prism-editor/dist/prismeditor.min.css'; | |||
| import { highlight, languages } from 'prismjs/components/prism-core'; | |||
| import 'prismjs/components/prism-clike'; | |||
| import 'prismjs/components/prism-javascript'; | |||
| import Exception from '@/components/Exception'; | |||
| import UploadInline from "@/components/UploadForm/inline"; | |||
| import { remove, replace, duplicate } from '@/utils'; | |||
| import { validateName, validateLabelsUtil } from '@/utils/validate'; | |||
| import { getAutoLabels } from '@/api/preparation/datalabel'; | |||
| import { add, edit, getLabelGroupDetail, importLabelGroup } from "@/api/preparation/labelGroup"; | |||
| import DynamicField from './dynamicField'; | |||
| import 'prismjs/themes/prism-tomorrow.css'; | |||
| const defaultColor = '#FFFFFF'; | |||
| const initialLabels = [{"name":"","color": defaultColor}, {"name":"","color":"#000000"}]; | |||
| export default { | |||
| name: 'LabelGroupForm', | |||
| components: { | |||
| PrismEditor, | |||
| DynamicField, | |||
| UploadInline, | |||
| Exception, | |||
| }, | |||
| setup(props, ctx) { | |||
| const editorRef = ref(null); | |||
| const formRef = ref(null); | |||
| const uploadFormRef = ref(null); | |||
| const customFormRef = ref(null); | |||
| const { $route, $router } = ctx.root; | |||
| const routeMap = { | |||
| LabelGroupCreate: 'create', | |||
| LabelGroupDetail: 'detail', | |||
| LabelGroupEdit: 'edit', | |||
| }; | |||
| const txtMap = { | |||
| create: "确认创建", | |||
| edit: "确认编辑", | |||
| detail: "返回", | |||
| }; | |||
| const operateTypeMap = { | |||
| 1: 'custom', | |||
| 2: 'edit', | |||
| 3: 'upload', | |||
| }; | |||
| const labelGroupTypeMap = { | |||
| 0: '自定义标签组', | |||
| 1: '预置标签组', | |||
| }; | |||
| // 表单规则 | |||
| const rules = { | |||
| name: [ | |||
| { required: true, message: '请输入标签组名称', trigger: ['change', 'blur'] }, | |||
| { validator: validateName, trigger: ['change', 'blur'] }, | |||
| ], | |||
| }; | |||
| const buildModel = (record, options) => { | |||
| return { ...record, ...options}; | |||
| }; | |||
| // 生成 keys | |||
| const setKeys = labels => labels.map((label, index) => index); | |||
| // 页面类型 | |||
| const actionType = routeMap[$route.name] || 'create'; | |||
| const state = reactive({ | |||
| id: actionType !== 'create' ? $route.query.id : null, | |||
| actionType, | |||
| groupType: null, // 查询标签组详情类型 | |||
| model: buildModel(props.row), | |||
| systemLabels: [], // 系统自动标注标签列表 | |||
| originList: [], // 记录原始返回列表 | |||
| activeLabels: [], // 当前可用标签列表 | |||
| fileCount: undefined, | |||
| // counter: 动态表单项数量,keys: 每次生成唯一的表单项 | |||
| counter: initialLabels.length - 1, | |||
| keys: setKeys(initialLabels), | |||
| createForm: { | |||
| labels: initialLabels, | |||
| name: '', | |||
| remark: "", | |||
| type: 0, | |||
| }, | |||
| codeContent: JSON.stringify(initialLabels), | |||
| customForm: { | |||
| labels: [{ | |||
| name: '', | |||
| color: defaultColor, | |||
| }], | |||
| }, | |||
| addWay: "custom", // 默认创建类型为自定义 | |||
| cancelText: "取消", | |||
| errmsg: '', | |||
| loading: false, // 加载详情 | |||
| }); | |||
| const submitTxt = txtMap[state.actionType]; | |||
| // 获取 key 值索引 | |||
| const getIndex = (index) => state.keys.findIndex(key => key === index); | |||
| const setCode = (code) => { | |||
| Object.assign(state, { | |||
| codeContent: code, | |||
| }); | |||
| }; | |||
| const beautify = () => { | |||
| // 编辑器内容 | |||
| const code = editorRef.value.value; | |||
| const formated = Beautify(code); | |||
| setCode(formated); | |||
| }; | |||
| const uploadError = () => { | |||
| }; | |||
| const goBack = () => { | |||
| $router.push({path: "/data/labelgroup"}); | |||
| }; | |||
| // 更新 | |||
| const updateCreateForm = (next) => { | |||
| Object.assign(state, { | |||
| createForm: { | |||
| ...state.createForm, | |||
| ...next, | |||
| }, | |||
| }); | |||
| }; | |||
| const handleLabelGroupRequest = (params) => { | |||
| const nextParams = { | |||
| ...params, | |||
| labels: JSON.stringify(params.labels), | |||
| }; | |||
| const requestResource = params.id ? edit : add; | |||
| const message = params.id ? '标签组编辑成功' : '标签组创建成功'; | |||
| requestResource(nextParams).then(() => { | |||
| Message.success({ | |||
| message, | |||
| duration: 1500, | |||
| onClose: goBack, | |||
| }); | |||
| }); | |||
| }; | |||
| const handleSubmit = () => { | |||
| if(actionType === 'detail') { | |||
| goBack(); | |||
| return; | |||
| } | |||
| formRef.value.validate(validWrapper => { | |||
| if (validWrapper) { | |||
| switch(state.addWay) { | |||
| // 自定标签组 | |||
| case 'custom': | |||
| customFormRef.value.validate(isValid => { | |||
| if (isValid) { | |||
| const params = { | |||
| ...state.createForm, | |||
| operateType: 1, | |||
| }; | |||
| handleLabelGroupRequest(params); | |||
| } | |||
| }); | |||
| break; | |||
| // 编辑标签组 | |||
| case 'edit': | |||
| try { | |||
| let errMsg = ''; | |||
| const code = JSON.parse(editorRef.value.value); | |||
| if(Array.isArray(code) && code.length) { | |||
| for(const d of code) { | |||
| if(validateLabelsUtil(d) !== '') { | |||
| errMsg = validateLabelsUtil(d); | |||
| break; | |||
| } | |||
| } | |||
| } | |||
| if(errMsg) { | |||
| Message.error(errMsg); | |||
| return; | |||
| } | |||
| const editParams = { | |||
| ...state.createForm, | |||
| labels: code, | |||
| operateType: 2, | |||
| }; | |||
| handleLabelGroupRequest(editParams); | |||
| } catch(err) { | |||
| console.error(err); | |||
| throw err; | |||
| } | |||
| break; | |||
| case 'upload': { | |||
| const { uploadFiles } = uploadFormRef.value.formRef?.$refs.uploader || {}; | |||
| const { name, remark } = state.createForm; | |||
| const formData = new FormData(); | |||
| formData.append('name', name); | |||
| formData.append('remark', remark); | |||
| formData.append('file', uploadFiles[0].raw); | |||
| formData.append('operateType', 3); | |||
| importLabelGroup(formData).then(() => { | |||
| Message.success({ | |||
| message: '标签组导入成功', | |||
| duration: 1500, | |||
| onClose: goBack, | |||
| }); | |||
| }); | |||
| break; | |||
| } | |||
| default: | |||
| break; | |||
| } | |||
| } | |||
| }); | |||
| }; | |||
| const beforeLeave = (activeName, oldActiveName) => { | |||
| if(activeName === oldActiveName) return false; | |||
| if(oldActiveName === 'upload') { | |||
| const { uploadFiles } = uploadFormRef.value.formRef?.$refs.uploader || {}; | |||
| if(uploadFiles.length) { | |||
| return MessageBox.confirm('标注文件已提交,确认切换?') | |||
| .catch(() => { | |||
| state.addWay = 'upload'; | |||
| return Promise.reject(); | |||
| }); | |||
| } | |||
| return true; | |||
| } | |||
| return true; | |||
| }; | |||
| // | |||
| const handleClick = (tab) => { | |||
| if(state.addWay === tab.name) return; | |||
| // 切换到编辑模式 | |||
| if (tab.name === 'edit') { | |||
| // 从自定义编辑切换过去 | |||
| if(state.addWay === 'custom') { | |||
| state.codeContent = JSON.stringify(state.createForm.labels); | |||
| } | |||
| } else if (tab.name === 'custom'){ | |||
| if(state.addWay === 'edit') { | |||
| try { | |||
| const nextLabels = JSON.parse(editorRef.value.value); | |||
| Object.assign(state, { | |||
| createForm: { | |||
| ...state.createForm, | |||
| labels: nextLabels, | |||
| }, | |||
| keys: setKeys(nextLabels), | |||
| counter: Math.max(state.counter, nextLabels.length - 1), | |||
| }); | |||
| } catch(err) { | |||
| Message.error('编辑格式不合法'); | |||
| return; | |||
| } | |||
| } | |||
| } | |||
| state.addWay = tab.name; | |||
| }; | |||
| const highlighter = (code) => { | |||
| return highlight(code, languages.js); | |||
| }; | |||
| const addLabel = (row) => { | |||
| state.createForm.labels.push(row); | |||
| const nextKeys = state.keys.concat(state.counter + 1); | |||
| Object.assign(state, { | |||
| keys: nextKeys, | |||
| counter: state.counter + 1, | |||
| }); | |||
| }; | |||
| // 添加一行标签 | |||
| const addRow = () => { | |||
| addLabel({ | |||
| name: '', | |||
| color: defaultColor, | |||
| }); | |||
| }; | |||
| // 用户自定义创建标签 | |||
| const createCustomLabel = (name, index) => { | |||
| const updateLabel = {name, color: defaultColor}; | |||
| updateCreateForm({ | |||
| labels: replace(state.createForm.labels, index, updateLabel), | |||
| }); | |||
| }; | |||
| const validateDuplicate = (rule, value, callback) => { | |||
| const isDuplicate = duplicate(state.createForm.labels, d => { | |||
| if(!value.id) return false; | |||
| return d.id === value.id; | |||
| }); | |||
| if (isDuplicate) { | |||
| callback(new Error('标签不能重复')); | |||
| return; | |||
| } | |||
| callback(); | |||
| }; | |||
| const handleLabelChange = (key, value) => { | |||
| const index = getIndex(key); | |||
| // 每次触发错误表单项验证 | |||
| const errorFields = customFormRef.value.fields.filter(d => d.validateState === 'error').map(d => d.prop); | |||
| customFormRef.value.validateField(errorFields); | |||
| // 判断是新建还是选择标签 | |||
| const editLabel = state.systemLabels.find(d => d.id === value); | |||
| // 选择已有标签 | |||
| if(editLabel) { | |||
| const updateLabel = pick(editLabel, ['name', 'id', 'color']); | |||
| Object.assign(state, { | |||
| createForm: { | |||
| ...state.createForm, | |||
| labels: replace(state.createForm.labels, index, updateLabel), | |||
| }, | |||
| }); | |||
| } else { | |||
| // 创建用户自定义标签 | |||
| createCustomLabel(value, index); | |||
| } | |||
| }; | |||
| // 移除标签 | |||
| const removeLabel = (k) => { | |||
| // 至少保留一条记录 | |||
| if (state.keys.length === 1) return; | |||
| const index = getIndex(k); | |||
| Object.assign(state, { | |||
| keys: state.keys.filter(key => key !== k), | |||
| createForm: { | |||
| ...state.createForm, | |||
| labels: remove(state.createForm.labels, index), | |||
| }, | |||
| }); | |||
| }; | |||
| const setLoading = (loading) => { | |||
| Object.assign(state, { | |||
| loading, | |||
| }); | |||
| }; | |||
| // 自定义标签组,预置标签组 | |||
| const labelGroupType = computed(() => labelGroupTypeMap[state.groupType]) || undefined; | |||
| onMounted(async () => { | |||
| const autoLabels = await getAutoLabels(); | |||
| Object.assign(state, { | |||
| activeLabels: autoLabels, | |||
| systemLabels: autoLabels, | |||
| }); | |||
| // 异常判断 | |||
| if(actionType !== 'create') { | |||
| if(!state.id) { | |||
| $router.push({ path: '/data/labelgroup' }); | |||
| throw new Error('当前标签组 id 不存在'); | |||
| } | |||
| setLoading(true); | |||
| // 查询数据集详情 | |||
| getLabelGroupDetail(state.id).then(res => { | |||
| // 当编辑模式,且数据为空时需要提供默认数据 | |||
| const labels = res.labels.length === 0 && actionType === 'edit' ? initialLabels : res.labels; | |||
| const restProps = state.actionType === 'detail' ? { | |||
| groupType: res.type || 0, | |||
| } : {}; | |||
| Object.assign(state, { | |||
| createForm: { | |||
| ...state.createForm, | |||
| ...res, | |||
| labels, | |||
| }, | |||
| addWay: operateTypeMap[res.operateType] || 'custom', | |||
| activeLabels: uniqBy(state.activeLabels.concat(res.labels), 'id'), | |||
| originList: res.labels.slice(), | |||
| keys: setKeys(labels), | |||
| counter: Math.max(state.counter, labels.length - 1), | |||
| codeContent: JSON.stringify(res.labels), | |||
| ...restProps, | |||
| }); | |||
| }).finally(() => { | |||
| setLoading(false); | |||
| }); | |||
| } | |||
| }); | |||
| return { | |||
| rules, | |||
| state, | |||
| submitTxt, | |||
| beautify, | |||
| editorRef, | |||
| formRef, | |||
| customFormRef, | |||
| validateDuplicate, | |||
| goBack, | |||
| handleClick, | |||
| handleSubmit, | |||
| highlighter, | |||
| removeLabel, | |||
| addRow, | |||
| handleLabelChange, | |||
| uploadError, | |||
| uploadFormRef, | |||
| beforeLeave, | |||
| labelGroupType, | |||
| }; | |||
| }, | |||
| }; | |||
| </script> | |||
| <style lang="scss"> | |||
| @import '@/assets/styles/variables.scss'; | |||
| .min-height-100 { | |||
| min-height: 100px; | |||
| } | |||
| .height-400 { | |||
| height: 400px; | |||
| } | |||
| .max-height-400 { | |||
| max-height: 400px; | |||
| } | |||
| .field-extra { | |||
| font-size: 14px; | |||
| line-height: 1.5; | |||
| color: $infoColor; | |||
| } | |||
| .labelgroup-editor { | |||
| position: relative; | |||
| padding: 5px; | |||
| font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace; | |||
| font-size: 18px; | |||
| line-height: 1.5; | |||
| color: black; | |||
| background: white; | |||
| } | |||
| .prism-editor__textarea:focus { | |||
| outline: none; | |||
| } | |||
| .labels-edit-wrapper { | |||
| .icon-wrapper { | |||
| position: absolute; | |||
| top: -10px; | |||
| right: 10px; | |||
| width: 32px; | |||
| height: 32px; | |||
| line-height: 32px; | |||
| color: $commonTextColor; | |||
| text-align: center; | |||
| cursor: pointer; | |||
| border: 1px solid $borderColor; | |||
| border-radius: 50%; | |||
| transition: 200ms ease; | |||
| &:hover { | |||
| color: #333; | |||
| } | |||
| } | |||
| .format { | |||
| font-size: 20px; | |||
| } | |||
| .disabled { | |||
| color: $infoColor; | |||
| pointer-events: none; | |||
| cursor: not-allowed; | |||
| } | |||
| .el-tabs__content { | |||
| padding-right: 0; | |||
| } | |||
| .dynamic-field { | |||
| min-height: 100px; | |||
| max-height: 400px; | |||
| overflow: auto; | |||
| .exception { | |||
| min-height: 100px; | |||
| } | |||
| .el-form-item { | |||
| margin-bottom: 20px; | |||
| } | |||
| } | |||
| .upload-tab { | |||
| max-width: 80%; | |||
| } | |||
| } | |||
| </style> | |||
| @@ -82,16 +82,16 @@ | |||
| </template> | |||
| <!--step==2--> | |||
| <template v-if="step==1"> | |||
| <el-form ref="form2" :model="form2" :rules="rules" label-width="100px"> | |||
| <el-form v-if="visible" ref="form2" :model="form2" :rules="rules" label-width="100px"> | |||
| <el-form-item label="模型名称"> | |||
| <div>{{ form.name }}</div> | |||
| </el-form-item> | |||
| <el-form-item label="模型上传" prop="modelAddress"> | |||
| <el-form-item ref="modelAddress" label="模型上传" prop="modelAddress"> | |||
| <upload-inline | |||
| v-if="refreshFlag" | |||
| action="fakeApi" | |||
| accept=".zip,.pb,.h5,.ckpt,.pkl,.pth,.weight,.caffemodel,.pt" | |||
| :acceptSize="5120" | |||
| :acceptSize="0" | |||
| list-type="text" | |||
| :limit="1" | |||
| :multiple="false" | |||
| @@ -103,12 +103,19 @@ | |||
| @uploadSuccess="uploadSuccess" | |||
| @uploadError="uploadError" | |||
| /> | |||
| <div v-if="loading"><i class="el-icon-loading" />模型上传中...</div> | |||
| <upload-progress | |||
| v-if="loading" | |||
| :progress="progress" | |||
| :color="customColors" | |||
| :status="status" | |||
| :size="size" | |||
| @onSetProgress="onSetProgress" | |||
| /> | |||
| </el-form-item> | |||
| </el-form> | |||
| <div slot="footer" class="dialog-footer"> | |||
| <el-button @click="visible = false;step=0;">下次再传</el-button> | |||
| <el-button type="primary" @click="doAddVersion">确定上传</el-button> | |||
| <el-button type="primary" :disabled="loading" @click="doAddVersion">确定上传</el-button> | |||
| </div> | |||
| </template> | |||
| </el-dialog> | |||
| @@ -119,8 +126,8 @@ import { add as addVersion } from '@/api/model/modelVersion'; | |||
| import { add as addModel } from '@/api/model/model'; | |||
| import { list as getAlgorithmUsages, add as addAlgorithmUsage } from '@/api/algorithm/algorithmUsage'; | |||
| import UploadInline from '@/components/UploadForm/inline'; | |||
| import { parseTime, validateNameWithHyphen } from '@/utils'; | |||
| import { nanoid } from 'nanoid'; | |||
| import UploadProgress from '@/components/UploadProgress'; | |||
| import { getUniqueId, validateNameWithHyphen } from '@/utils'; | |||
| const defaultForm = { | |||
| name: null, | |||
| @@ -139,7 +146,7 @@ const defaultForm2 = { | |||
| export default { | |||
| name: 'AddModelDialog', | |||
| dicts: ['model_type', 'frame_type'], | |||
| components: { UploadInline }, | |||
| components: { UploadInline, UploadProgress }, | |||
| data() { | |||
| return { | |||
| visible: false, | |||
| @@ -171,18 +178,33 @@ export default { | |||
| { max: 255, message: '长度在255个字符以内', trigger: 'blur' }, | |||
| ], | |||
| modelAddress: [ | |||
| { required: true, message: '请上传有效的模型', trigger: 'blur' }, | |||
| { required: true, message: '请上传有效的模型', trigger: ['blur', 'manual'] }, | |||
| ], | |||
| }, | |||
| step: 0, | |||
| uploadParams: { | |||
| objectPath: `model/${this.$store.state.user.user.id}/${parseTime(new Date(), '{y}{m}{d}{h}{i}{s}{S}') + nanoid(4)}`, // 对象存储路径 | |||
| objectPath: null, // 对象存储路径 | |||
| }, | |||
| algorithmUsageList: [], | |||
| refreshFlag: true, | |||
| loading: false, | |||
| size: 0, | |||
| progress: 0, | |||
| customColors: [ | |||
| {color: '#909399', percentage: 40}, | |||
| {color: '#e6a23c', percentage: 80}, | |||
| {color: '#67c23a', percentage: 100}, | |||
| ], | |||
| }; | |||
| }, | |||
| computed: { | |||
| status() { | |||
| return this.progress === 100 ? 'success' : null; | |||
| }, | |||
| user() { | |||
| return this.$store.getters.user; | |||
| }, | |||
| }, | |||
| methods: { | |||
| show() { | |||
| this.refreshFlag = false; | |||
| @@ -196,6 +218,7 @@ export default { | |||
| }, | |||
| onDialogClose() { | |||
| this.reset(); | |||
| this.loading = false; | |||
| this.$emit('addDone', true); | |||
| }, | |||
| reset() { | |||
| @@ -217,13 +240,22 @@ export default { | |||
| handleRemove() { | |||
| this.loading = false; | |||
| this.form2.modelAddress = null; | |||
| this.$refs.modelAddress.validate('manual'); | |||
| }, | |||
| uploadStart() { | |||
| this.loading = true; | |||
| uploadStart(files) { | |||
| this.updateImagePath(); | |||
| [ this.loading, this.size, this.progress ] = [ true, files.size, 0 ]; | |||
| }, | |||
| onSetProgress(val) { | |||
| this.progress += val; | |||
| }, | |||
| uploadSuccess(res) { | |||
| this.loading = false; | |||
| this.progress = 100; | |||
| setTimeout(() => { | |||
| this.loading = false; | |||
| }, 1000); | |||
| this.form2.modelAddress = res[0].data.objectName; | |||
| this.$refs.modelAddress.validate('manual'); | |||
| }, | |||
| uploadError() { | |||
| this.loading = false; | |||
| @@ -274,6 +306,9 @@ export default { | |||
| await addAlgorithmUsage({ auxInfo }); | |||
| this.getAlgorithmUsages(); | |||
| }, | |||
| updateImagePath() { | |||
| this.uploadParams.objectPath = `upload-temp/${this.user.id}/${getUniqueId()}`; | |||
| }, | |||
| }, | |||
| }; | |||
| </script> | |||
| @@ -21,17 +21,17 @@ | |||
| <div class="cd-opts"> | |||
| <span class="cd-opts-left"> | |||
| <el-button | |||
| id="toAdd" | |||
| class="filter-item" | |||
| type="primary" | |||
| icon="el-icon-plus" | |||
| round | |||
| @click="toAdd" | |||
| > | |||
| 创建模型 | |||
| </el-button> | |||
| >创建模型</el-button> | |||
| </span> | |||
| <span class="cd-opts-right"> | |||
| <el-input | |||
| id="queryName" | |||
| v-model="query.name" | |||
| clearable | |||
| placeholder="请输入模型名称或ID" | |||
| @@ -44,8 +44,8 @@ | |||
| </div> | |||
| <div> | |||
| <el-tabs v-model="active" class="eltabs-inlineblock" @tab-click="crud.toQuery"> | |||
| <el-tab-pane label="我的模型" name="0" /> | |||
| <el-tab-pane label="预训练模型" name="1" /> | |||
| <el-tab-pane id="tab_0" label="我的模型" name="0" /> | |||
| <el-tab-pane id="tab_1" label="预训练模型" name="1" /> | |||
| </el-tabs> | |||
| </div> | |||
| </div> | |||
| @@ -61,15 +61,20 @@ | |||
| <el-table-column prop="id" label="ID" width="80" sortable="custom" /> | |||
| <el-table-column prop="name" label="模型名称" min-width="180px" /> | |||
| <el-table-column prop="frameType" label="框架名称" min-width="150px"> | |||
| <template slot-scope="scope">{{ dict.label.frame_type[scope.row.frameType]||'--' }}</template> | |||
| <template slot-scope="scope">{{ dict.label.frame_type[scope.row.frameType]|| "--" }}</template> | |||
| </el-table-column> | |||
| <el-table-column prop="modelType" label="模型格式" min-width="150px"> | |||
| <template slot-scope="scope">{{ dict.label.model_type[scope.row.modelType]||'--' }}</template> | |||
| <template slot-scope="scope">{{ dict.label.model_type[scope.row.modelType]|| "--" }}</template> | |||
| </el-table-column> | |||
| <el-table-column prop="modelClassName" label="模型类别" min-width="150px"> | |||
| <template slot-scope="scope">{{ scope.row.modelClassName ||'--' }}</template> | |||
| <template slot-scope="scope">{{ scope.row.modelClassName || "--" }}</template> | |||
| </el-table-column> | |||
| <el-table-column prop="modelDescription" label="模型描述" min-width="300px" show-overflow-tooltip /> | |||
| <el-table-column | |||
| prop="modelDescription" | |||
| label="模型描述" | |||
| min-width="300px" | |||
| show-overflow-tooltip | |||
| /> | |||
| <el-table-column v-if="isCustom" prop="versionNum" label="版本" width="80"> | |||
| <template slot-scope="scope"> | |||
| <a | |||
| @@ -88,6 +93,7 @@ | |||
| <template slot-scope="scope"> | |||
| <el-button | |||
| v-if="isCustom" | |||
| :id="`goVersion_`+scope.$index" | |||
| type="text" | |||
| @click="goVersion(scope.row.id, scope.row.name)" | |||
| >历史版本</el-button> | |||
| @@ -99,6 +105,7 @@ | |||
| > | |||
| <span :class="{'ml-10 mr-10': isCustom}"> | |||
| <el-button | |||
| :id="`doDownload_`+scope.$index" | |||
| :disabled="!scope.row.modelAddress" | |||
| type="text" | |||
| @click="doDownload(scope.row)" | |||
| @@ -107,11 +114,13 @@ | |||
| </el-tooltip> | |||
| <el-button | |||
| v-if="isCustom" | |||
| :id="`doEdit_`+scope.$index" | |||
| type="text" | |||
| @click="doEdit(scope.row)" | |||
| >编辑</el-button> | |||
| <el-button | |||
| v-if="isCustom" | |||
| :id="`doDelete_`+scope.$index" | |||
| type="text" | |||
| @click="doDelete(scope.row.id)" | |||
| >删除</el-button> | |||
| @@ -134,6 +143,7 @@ | |||
| <el-form ref="form" :model="form" :rules="rules" label-width="100px"> | |||
| <el-form-item label="模型名称" prop="name"> | |||
| <el-input | |||
| id="name" | |||
| v-model.trim="form.name" | |||
| style="width: 300px;" | |||
| maxlength="15" | |||
| @@ -142,7 +152,12 @@ | |||
| /> | |||
| </el-form-item> | |||
| <el-form-item label="框架" prop="frameType"> | |||
| <el-select v-model="form.frameType" placeholder="请选择框架" style="width: 300px;"> | |||
| <el-select | |||
| id="frameType" | |||
| v-model="form.frameType" | |||
| placeholder="请选择框架" | |||
| style="width: 300px;" | |||
| > | |||
| <el-option | |||
| v-for="item in dict.frame_type" | |||
| :key="item.value" | |||
| @@ -152,7 +167,12 @@ | |||
| </el-select> | |||
| </el-form-item> | |||
| <el-form-item label="模型格式" prop="modelType"> | |||
| <el-select v-model="form.modelType" placeholder="请选择模型格式" style="width: 300px;"> | |||
| <el-select | |||
| id="modelType" | |||
| v-model="form.modelType" | |||
| placeholder="请选择模型格式" | |||
| style="width: 300px;" | |||
| > | |||
| <el-option | |||
| v-for="item in dict.model_type" | |||
| :key="item.value" | |||
| @@ -163,6 +183,7 @@ | |||
| </el-form-item> | |||
| <el-form-item label="模型类别" prop="modelClassName"> | |||
| <el-select | |||
| id="modelClassName" | |||
| v-model="form.modelClassName" | |||
| placeholder="请选择或输入模型类别" | |||
| filterable | |||
| @@ -179,27 +200,35 @@ | |||
| </el-select> | |||
| </el-form-item> | |||
| <el-form-item label="模型描述" prop="modelDescription"> | |||
| <el-input v-model="form.modelDescription" type="textarea" placeholder="请输入模型描述" maxlength="255" show-word-limit style="width: 400px;" /> | |||
| <el-input | |||
| id="modelDescription" | |||
| v-model="form.modelDescription" | |||
| type="textarea" | |||
| placeholder="请输入模型描述" | |||
| maxlength="255" | |||
| show-word-limit | |||
| style="width: 400px;" | |||
| /> | |||
| </el-form-item> | |||
| </el-form> | |||
| </BaseModal> | |||
| <!--多步骤新增dialog--> | |||
| <add-model-dialog | |||
| ref="addModel" | |||
| @addDone="addDone" | |||
| /> | |||
| <add-model-dialog ref="addModel" @addDone="addDone" /> | |||
| </div> | |||
| </template> | |||
| <script> | |||
| import crudModel, { del } from '@/api/model/model'; | |||
| import { list as getAlgorithmUsages, add as addAlgorithmUsage } from '@/api/algorithm/algorithmUsage'; | |||
| import CRUD, { presenter, header, form, crud } from '@crud/crud'; | |||
| import BaseModal from '@/components/BaseModal'; | |||
| import rrOperation from '@crud/RR.operation'; | |||
| import pagination from '@crud/Pagination'; | |||
| import { downloadZipFromObjectPath, validateNameWithHyphen } from '@/utils'; | |||
| import AddModelDialog from './components/addModelDialog'; | |||
| import crudModel, { del } from "@/api/model/model"; | |||
| import { | |||
| list as getAlgorithmUsages, | |||
| add as addAlgorithmUsage, | |||
| } from "@/api/algorithm/algorithmUsage"; | |||
| import CRUD, { presenter, header, form, crud } from "@crud/crud"; | |||
| import BaseModal from "@/components/BaseModal"; | |||
| import rrOperation from "@crud/RR.operation"; | |||
| import pagination from "@crud/Pagination"; | |||
| import { downloadZipFromObjectPath, validateNameWithHyphen } from "@/utils"; | |||
| import AddModelDialog from "./components/addModelDialog"; | |||
| const defaultForm = { | |||
| name: null, | |||
| @@ -235,7 +264,7 @@ export default { | |||
| { max: 20, message: '长度在 20 个字符以内', trigger: 'blur' }, | |||
| { | |||
| validator: validateNameWithHyphen, | |||
| trigger: ['blur', 'change'], | |||
| trigger: ["blur", "change"], | |||
| }, | |||
| ], | |||
| frameType: [ | |||
| @@ -245,7 +274,11 @@ export default { | |||
| { required: true, message: '请选择模型格式', trigger: 'blur' }, | |||
| ], | |||
| modelClassName: [ | |||
| { required: true, message: '请输入模型类别', trigger: ['blur', 'change'] }, | |||
| { | |||
| required: true, | |||
| message: '请输入模型类别', | |||
| trigger: ["blur", "change"], | |||
| }, | |||
| ], | |||
| modelDescription: [ | |||
| { required: true, message: '请输入模型描述', trigger: 'blur' }, | |||
| @@ -253,7 +286,7 @@ export default { | |||
| ], | |||
| }, | |||
| algorithmUsageList: [], | |||
| active: '0', | |||
| active: "0", | |||
| }; | |||
| }, | |||
| computed: { | |||
| @@ -291,7 +324,9 @@ export default { | |||
| this.getAlgorithmUsages(); | |||
| }, | |||
| onAlgorithmUsageChange(value) { | |||
| const usageRes = this.algorithmUsageList.find(usage => usage.auxInfo === value); | |||
| const usageRes = this.algorithmUsageList.find( | |||
| usage => usage.auxInfo === value, | |||
| ); | |||
| if (!usageRes) { | |||
| this.createAlgorithmUsage(value); | |||
| } | |||
| @@ -304,7 +339,7 @@ export default { | |||
| }, | |||
| // link | |||
| goVersion(id, name, type = 'detail') { | |||
| this.$router.push({ path: '/model/version', query: { id, name, type }}); | |||
| this.$router.push({ path: '/model/version', query: { id, name, type } }); | |||
| }, | |||
| // op | |||
| async doEdit(item) { | |||
| @@ -315,7 +350,7 @@ export default { | |||
| }, | |||
| doDelete(id) { | |||
| this.$confirm('此操作将永久删除该模型, 是否继续?', '请确认').then( | |||
| async() => { | |||
| async () => { | |||
| const params = { | |||
| ids: [id], | |||
| }; | |||
| @@ -333,9 +368,11 @@ export default { | |||
| const msg = this.isCustom | |||
| ? `此操作将下载 ${name} 模型的 ${versionNum} 版本, 是否继续?` | |||
| : `此操作将下载预训练模型 ${name}, 是否继续?`; | |||
| this.$confirm(msg, '请确认').then( | |||
| this.$confirm(msg, "请确认").then( | |||
| () => { | |||
| const url = /^\//.test(modelAddress) ? modelAddress : `/${ modelAddress}`; | |||
| const url = /^\//.test(modelAddress) | |||
| ? modelAddress | |||
| : `/${modelAddress}`; | |||
| downloadZipFromObjectPath(url, 'model.zip'); | |||
| this.$message({ | |||
| message: '请查看下载文件', | |||
| @@ -42,10 +42,11 @@ | |||
| <el-table-column label="操作" width="150px" fixed="right"> | |||
| <template slot-scope="scope"> | |||
| <el-button | |||
| :id="`doDownload_`+scope.$index" | |||
| type="text" | |||
| @click="doDownload(scope.row.parentId, scope.row.versionNum, scope.row.modelAddress)" | |||
| >下载</el-button> | |||
| <el-button type="text" @click="doDelete(scope.row.id)">删除</el-button> | |||
| <el-button :id="`doDelete`+scope.$index" type="text" @click="doDelete(scope.row.id)">删除</el-button> | |||
| </template> | |||
| </el-table-column> | |||
| </el-table> | |||
| @@ -57,7 +58,9 @@ | |||
| :visible="crud.status.cu > 0" | |||
| :title="crud.status.title" | |||
| :loading="crud.status.cu === 2" | |||
| :disabled="loading" | |||
| width="800px" | |||
| @close="onDialogClose" | |||
| @cancel="crud.cancelCU" | |||
| @ok="onSubmit" | |||
| > | |||
| @@ -68,9 +71,10 @@ | |||
| <el-form-item ref="modelAddress" label="模型上传" prop="modelAddress"> | |||
| <upload-inline | |||
| v-if="refreshFlag" | |||
| ref="upload" | |||
| action="fakeApi" | |||
| accept=".zip,.pb,.h5,.ckpt,.pkl,.pth,.weight,.caffemodel,.pt" | |||
| :acceptSize="5120" | |||
| accept=".zip, .pb, .h5, .ckpt, .pkl, .pth, .weight, .caffemodel, .pt" | |||
| :acceptSize="0" | |||
| list-type="text" | |||
| :limit="1" | |||
| :multiple="false" | |||
| @@ -82,7 +86,14 @@ | |||
| @uploadSuccess="uploadSuccess" | |||
| @uploadError="uploadError" | |||
| /> | |||
| <div v-if="loading"><i class="el-icon-loading" />模型上传中...</div> | |||
| <upload-progress | |||
| v-if="loading" | |||
| :progress="progress" | |||
| :color="customColors" | |||
| :status="status" | |||
| :size="size" | |||
| @onSetProgress="onSetProgress" | |||
| /> | |||
| </el-form-item> | |||
| </el-form> | |||
| </BaseModal> | |||
| @@ -90,14 +101,14 @@ | |||
| </template> | |||
| <script> | |||
| import crudModelVersion, {del} from '@/api/model/modelVersion'; | |||
| import crudModelVersion, { del } from '@/api/model/modelVersion'; | |||
| import CRUD, { presenter, header, form, crud } from '@crud/crud'; | |||
| import BaseModal from '@/components/BaseModal'; | |||
| import cdOperation from '@crud/CD.operation'; | |||
| import pagination from '@crud/Pagination'; | |||
| import UploadInline from '@/components/UploadForm/inline'; | |||
| import { parseTime, downloadZipFromObjectPath } from '@/utils'; | |||
| import { nanoid } from 'nanoid'; | |||
| import UploadProgress from '@/components/UploadProgress'; | |||
| import { getUniqueId, downloadZipFromObjectPath } from '@/utils'; | |||
| const defaultForm = { | |||
| parentId: null, | |||
| @@ -107,7 +118,7 @@ const defaultForm = { | |||
| export default { | |||
| name: 'ModelVersion', | |||
| dicts: ['model_source'], | |||
| components: { BaseModal, pagination, cdOperation, UploadInline }, | |||
| components: { BaseModal, pagination, cdOperation, UploadInline, UploadProgress }, | |||
| cruds() { | |||
| return CRUD({ | |||
| title: '模型版本管理', | |||
| @@ -135,12 +146,27 @@ export default { | |||
| ], | |||
| }, | |||
| uploadParams: { | |||
| objectPath: `model/${this.$store.state.user.user.id}/${parseTime(new Date(), '{y}{m}{d}{h}{i}{s}{S}') + nanoid(4)}`, // 对象存储路径 | |||
| objectPath: null, // 对象存储路径 | |||
| }, | |||
| refreshFlag: true, | |||
| loading: false, | |||
| progress: 0, | |||
| size: 0, | |||
| customColors: [ | |||
| {color: '#909399', percentage: 40}, | |||
| {color: '#e6a23c', percentage: 80}, | |||
| {color: '#67c23a', percentage: 100}, | |||
| ], | |||
| }; | |||
| }, | |||
| computed: { | |||
| status() { | |||
| return this.progress === 100 ? 'success' : null; | |||
| }, | |||
| user() { | |||
| return this.$store.getters.user; | |||
| }, | |||
| }, | |||
| mounted() { | |||
| this.modelId = this.$route.query.id; | |||
| this.modelName = this.$route.query.name; | |||
| @@ -156,12 +182,20 @@ export default { | |||
| handleRemove() { | |||
| this.loading = false; | |||
| this.form.modelAddress = null; | |||
| this.$refs.modelAddress.validate('manual'); | |||
| }, | |||
| uploadStart(files) { | |||
| this.updateImagePath(); | |||
| [ this.loading, this.size, this.progress ] = [ true, files.size, 0 ]; | |||
| }, | |||
| uploadStart() { | |||
| this.loading = true; | |||
| onSetProgress(val) { | |||
| this.progress += val; | |||
| }, | |||
| uploadSuccess(res) { | |||
| this.loading = false; | |||
| this.progress = 100; | |||
| setTimeout(() => { | |||
| this.loading = false; | |||
| }, 1000); | |||
| this.form.modelAddress = res[0].data.objectName; | |||
| this.$refs.modelAddress.validate('manual'); | |||
| }, | |||
| @@ -172,6 +206,10 @@ export default { | |||
| type: 'error', | |||
| }); | |||
| }, | |||
| onDialogClose() { | |||
| this.$refs.upload.formRef.reset(); | |||
| this.loading = false; | |||
| }, | |||
| onSubmit() { | |||
| this.form.parentId = this.modelId; | |||
| this.crud.submitCU(); | |||
| @@ -183,10 +221,13 @@ export default { | |||
| this.refreshFlag = true; | |||
| }); | |||
| }, | |||
| updateImagePath() { | |||
| this.uploadParams.objectPath = `upload-temp/${this.user.id}/${getUniqueId()}`; | |||
| }, | |||
| // op | |||
| doDelete(id) { | |||
| this.$confirm('此操作将永久删除该模型, 是否继续?', '请确认') | |||
| .then(async() => { | |||
| this.$confirm('此操作将永久删除该模型, 是否继续?', '请确认').then( | |||
| async () => { | |||
| const params = { | |||
| ids: [id], | |||
| }; | |||
| @@ -196,19 +237,22 @@ export default { | |||
| type: 'success', | |||
| }); | |||
| this.crud.refresh(); | |||
| }); | |||
| }, | |||
| ); | |||
| }, | |||
| doDownload(parentId, versionNum, filepath) { | |||
| const msg = `此操作将下载${this.modelName}模型的${versionNum}版本, 是否继续?`; | |||
| this.$confirm(msg, '请确认') | |||
| .then(() => { | |||
| const url = /^\//.test(filepath) ? filepath : `/${ filepath}`; | |||
| downloadZipFromObjectPath(url, 'model.zip'); | |||
| this.$confirm(msg, "请确认").then( | |||
| () => { | |||
| const url = /^\//.test(filepath) ? filepath : `/${filepath}`; | |||
| downloadZipFromObjectPath(url, "model.zip"); | |||
| this.$message({ | |||
| message: '请查看下载文件', | |||
| type: 'success', | |||
| }); | |||
| }, () => {}); | |||
| }, | |||
| () => {}, | |||
| ); | |||
| }, | |||
| }, | |||
| }; | |||
| @@ -31,7 +31,7 @@ | |||
| <el-input v-model="form.label" style="width: 370px;" maxlength="50" show-word-limit /> | |||
| </el-form-item> | |||
| <el-form-item label="字典值" prop="value"> | |||
| <el-input v-model="form.value" style="width: 370px;" maxlength="50" show-word-limit /> | |||
| <el-input v-model="form.value" style="width: 370px;" maxlength="255" show-word-limit /> | |||
| </el-form-item> | |||
| <el-form-item label="排序" prop="sort"> | |||
| <el-input-number v-model.number="form.sort" :min="0" :max="999" style="width: 370px;" /> | |||
| @@ -87,28 +87,15 @@ export default { | |||
| sort: this.crud.data.length + 1, ...defaultForm}; | |||
| })], | |||
| data() { | |||
| const validateAccount = (rule, value, callback) => { | |||
| if (value === '' || value == null) { | |||
| callback(); | |||
| } else if (value.length > 50) { | |||
| callback(new Error('长度不超过 50 个字符')); | |||
| } else if (!/^[\u4E00-\u9FA5A-Za-z0-9:_-]+$/.test(value)) { | |||
| callback(new Error('只支持中英文、数字、下划线、横杠和英文冒号')); | |||
| } else { | |||
| callback(); | |||
| } | |||
| }; | |||
| return { | |||
| dictId: null, | |||
| dictName: '', | |||
| rules: { | |||
| label: [ | |||
| { required: true, message: '请输入字典标签', trigger: 'blur' }, | |||
| { validator: validateAccount, trigger: 'change' }, | |||
| ], | |||
| value: [ | |||
| { required: true, message: '请输入字典值', trigger: 'blur' }, | |||
| { validator: validateAccount, trigger: 'change' }, | |||
| ], | |||
| sort: [ | |||
| { required: true, message: '请输入序号', trigger: 'blur', type: 'number' }, | |||
| @@ -41,36 +41,6 @@ | |||
| :picker-options="pickerOptions" | |||
| @change="crud.toQuery" | |||
| /> | |||
| <el-select | |||
| v-model="query.roleId" | |||
| clearable | |||
| placeholder="请选择角色" | |||
| class="filter-item" | |||
| style="width: 120px;" | |||
| @change="crud.toQuery" | |||
| > | |||
| <el-option | |||
| v-for="item in roleOptions" | |||
| :key="item.id" | |||
| :label="item.name" | |||
| :value="item.id" | |||
| /> | |||
| </el-select> | |||
| <el-select | |||
| v-model="query.enabled" | |||
| clearable | |||
| placeholder="状态" | |||
| class="filter-item" | |||
| style="width: 80px;" | |||
| @change="crud.toQuery" | |||
| > | |||
| <el-option | |||
| v-for="item in dict.user_status" | |||
| :key="item.value" | |||
| :label="item.label" | |||
| :value="item.value" | |||
| /> | |||
| </el-select> | |||
| <rrOperation /> | |||
| </span> | |||
| </cdOperation> | |||
| @@ -142,12 +112,28 @@ | |||
| <el-table-column prop="sex" width="60" label="性别" /> | |||
| <el-table-column show-overflow-tooltip prop="phone" width="120" label="手机号" /> | |||
| <el-table-column show-overflow-tooltip prop="email" label="邮箱" /> | |||
| <el-table-column show-overflow-tooltip prop="rodes" label="角色"> | |||
| <el-table-column show-overflow-tooltip prop="roles"> | |||
| <template #header> | |||
| <dropdown-header | |||
| title="角色" | |||
| :list="userRoleList" | |||
| :filtered="Boolean(crud.query.roleId)" | |||
| @command="filterByRoles" | |||
| /> | |||
| </template> | |||
| <template slot-scope="scope"> | |||
| <span>{{ getUserRoles(scope.row) }}</span> | |||
| </template> | |||
| </el-table-column> | |||
| <el-table-column label="状态" prop="enabled" width="80"> | |||
| <el-table-column prop="enabled" width="80"> | |||
| <template #header> | |||
| <dropdown-header | |||
| title="状态" | |||
| :list="userStatusList" | |||
| :filtered="Boolean(crud.query.enabled)" | |||
| @command="filterByStatus" | |||
| /> | |||
| </template> | |||
| <template slot-scope="scope"> | |||
| <el-tag :type="scope.row.enabled ? '' : 'info'" effect="plain">{{ dict.label.user_status[scope.row.enabled.toString()] }} </el-tag> | |||
| </template> | |||
| @@ -181,6 +167,7 @@ import { validateName, validateAccount } from '@/utils/validate'; | |||
| import crudUser from '@/api/system/user'; | |||
| import { getAll } from '@/api/system/role'; | |||
| import BaseModal from '@/components/BaseModal'; | |||
| import DropdownHeader from '@/components/DropdownHeader'; | |||
| import datePickerMixin from '@/mixins/datePickerMixin'; | |||
| const ADMIN_USER_ID = 1; // 系统管理员ID | |||
| @@ -188,7 +175,7 @@ const ADMIN_USER_ID = 1; // 系统管理员ID | |||
| const defaultForm = { id: null, username: null, nickName: null, sex: null, email: null, remark: null, enabled: null, phone: null, roles: [], roleId: '' }; | |||
| export default { | |||
| name: 'User', | |||
| components: { BaseModal, cdOperation, rrOperation, udOperation, pagination }, | |||
| components: { BaseModal, cdOperation, rrOperation, udOperation, pagination, DropdownHeader }, | |||
| cruds() { | |||
| return CRUD({ title: '用户', crudMethod: { ...crudUser }}); | |||
| }, | |||
| @@ -233,6 +220,16 @@ export default { | |||
| ...mapGetters([ | |||
| 'user', | |||
| ]), | |||
| userStatusList() { | |||
| return [{ label: '全部', value: null }].concat(this.dict.user_status); | |||
| }, | |||
| userRoleList() { | |||
| const arr = [{ label: '全部', value: null }]; | |||
| this.roleOptions.forEach(item => { | |||
| arr.push({ label: item.name, value: item.id }); | |||
| }); | |||
| return arr; | |||
| }, | |||
| }, | |||
| created() { | |||
| this.$nextTick(() => { | |||
| @@ -288,6 +285,14 @@ export default { | |||
| const names = roles.map(role => role.name); | |||
| return names.join('<br/>') || '-'; | |||
| }, | |||
| filterByStatus(status) { | |||
| this.crud.query.enabled = status; | |||
| this.crud.refresh(); | |||
| }, | |||
| filterByRoles(id) { | |||
| this.crud.query.roleId = id; | |||
| this.crud.refresh(); | |||
| }, | |||
| }, | |||
| }; | |||
| </script> | |||
| @@ -18,14 +18,28 @@ | |||
| <div class="app-container"> | |||
| <!--工具栏--> | |||
| <div class="head-container"> | |||
| <cdOperation :addProps="operationProps" /> | |||
| <cdOperation :addProps="operationProps"> | |||
| <span slot="right"> | |||
| <el-input | |||
| v-model="localQuery.imageNameOrId" | |||
| clearable | |||
| placeholder="请输入镜像名称或ID" | |||
| class="filter-item" | |||
| style="width: 200px;" | |||
| @keyup.enter.native="crud.toQuery" | |||
| @clear="crud.toQuery" | |||
| /> | |||
| <rrOperation @resetQuery="resetQuery" /> | |||
| </span> | |||
| </cdOperation> | |||
| </div> | |||
| <el-tabs v-model="active" class="eltabs-inlineblock" @tab-click="handleClick"> | |||
| <el-tab-pane label="我的镜像" name="0" /> | |||
| <el-tab-pane label="预置镜像" name="1" /> | |||
| <el-tab-pane id="tab_0" label="我的镜像" name="0" /> | |||
| <el-tab-pane id="tab_1" label="预置镜像" name="1" /> | |||
| </el-tabs> | |||
| <!--表格渲染--> | |||
| <el-table | |||
| v-if="prefabricate" | |||
| ref="table" | |||
| v-loading="crud.loading || disableEdit" | |||
| :data="crud.data" | |||
| @@ -33,10 +47,10 @@ | |||
| @selection-change="crud.selectionChangeHandler" | |||
| @sort-change="crud.sortChange" | |||
| > | |||
| <el-table-column v-if="active == 0" prop="id" label="ID" sortable="custom" width="80px" /> | |||
| <el-table-column v-if="isShow" prop="id" label="ID" sortable="custom" width="80px" /> | |||
| <el-table-column prop="imageName" label="镜像名称" sortable="custom" /> | |||
| <el-table-column prop="imageTag" label="镜像版本号" sortable="custom" /> | |||
| <el-table-column prop="imageStatus" width="160px"> | |||
| <el-table-column prop="imageStatus" label="状态" width="160px"> | |||
| <template #header> | |||
| <dropdown-header | |||
| title="状态" | |||
| @@ -57,6 +71,16 @@ | |||
| <span>{{ parseTime(scope.row.createTime) }}</span> | |||
| </template> | |||
| </el-table-column> | |||
| <el-table-column v-if="isShow" label="操作" width="200px" fixed="right"> | |||
| <template slot-scope="scope"> | |||
| <el-button :id="`doEdit_`+scope.$index" type="text" @click.stop="doEdit(scope.row)"> | |||
| 修改 | |||
| </el-button> | |||
| <el-button :id="`doDelete_`+scope.$index" type="text" @click.stop="doDelete(scope.row.id)"> | |||
| 删除 | |||
| </el-button> | |||
| </template> | |||
| </el-table-column> | |||
| </el-table> | |||
| <!--分页组件--> | |||
| <pagination /> | |||
| @@ -78,48 +102,63 @@ | |||
| :rules="rules" | |||
| label-width="120px" | |||
| > | |||
| <el-form-item label="镜像名称" prop="imageName"> | |||
| <el-form-item v-if="isEdit" label="镜像名称" prop="imageName"> | |||
| <el-select | |||
| id="imageName" | |||
| v-model="form.imageName" | |||
| placeholder="请选择镜像名称" | |||
| placeholder="请选择或输入镜像名称" | |||
| style="width: 400px;" | |||
| clearable | |||
| filterable | |||
| allow-create | |||
| default-first-option | |||
| @focus="getHarborProjects" | |||
| > | |||
| <el-option | |||
| v-for="(item, index) in harborProjectList" | |||
| :key="index" | |||
| :label="item.imageName" | |||
| :value="item.imageName" | |||
| v-for="item in harborProjectList" | |||
| :key="item" | |||
| :label="item" | |||
| :value="item" | |||
| /> | |||
| </el-select> | |||
| </el-form-item> | |||
| <el-form-item label="镜像文件路径" prop="imagePath"> | |||
| <el-form-item v-if="isEdit" ref="imagePath" label="镜像文件路径" prop="imagePath"> | |||
| <upload-inline | |||
| v-if="crud.status.cu > 0" | |||
| ref="upload" | |||
| action="fakeApi" | |||
| accept=".zip,.tar,.rar,.gz" | |||
| list-type="text" | |||
| :acceptSize="5120" | |||
| :acceptSize="0" | |||
| :params="uploadParams" | |||
| :show-file-count="false" | |||
| :auto-upload="true" | |||
| :hash="false" | |||
| :limit="1" | |||
| :on-remove="onFileRemove" | |||
| @uploadStart="uploadStart" | |||
| @uploadSuccess="uploadSuccess" | |||
| @uploadError="uploadError" | |||
| /> | |||
| <div v-if="loading"><i class="el-icon-loading" />镜像上传中...</div> | |||
| <upload-progress | |||
| v-if="loading" | |||
| :progress="progress" | |||
| :color="customColors" | |||
| :status="status" | |||
| :size="size" | |||
| @onSetProgress="onSetProgress" | |||
| /> | |||
| </el-form-item> | |||
| <el-form-item label="镜像版本号" prop="imageTag"> | |||
| <el-form-item v-if="isEdit" label="镜像版本号" prop="imageTag"> | |||
| <el-input | |||
| id="imageTag" | |||
| v-model="form.imageTag" | |||
| style="width: 400px;" | |||
| /> | |||
| </el-form-item> | |||
| <el-form-item label="描述" prop="remark"> | |||
| <el-input | |||
| id="remark" | |||
| v-model="form.remark" | |||
| type="textarea" | |||
| :rows="4" | |||
| @@ -135,18 +174,19 @@ | |||
| </template> | |||
| <script> | |||
| import { nanoid } from 'nanoid'; | |||
| // eslint-disable-next-line import/no-extraneous-dependencies | |||
| import { debounce } from 'throttle-debounce'; | |||
| import cdOperation from '@crud/CD.operation'; | |||
| import rrOperation from '@crud/RR.operation'; | |||
| import pagination from '@crud/Pagination'; | |||
| import CRUD, { presenter, header, form, crud } from '@crud/crud'; | |||
| import { parseTime } from '@/utils'; | |||
| import trainingImageApi, {project} from '@/api/trainingImage/index'; | |||
| import trainingImageApi, { imageNameList, del } from '@/api/trainingImage/index'; | |||
| import { getUniqueId } from '@/utils'; | |||
| import BaseModal from '@/components/BaseModal'; | |||
| import UploadInline from '@/components/UploadForm/inline'; | |||
| import DropdownHeader from '@/components/DropdownHeader'; | |||
| import UploadProgress from '@/components/UploadProgress'; | |||
| const defaultForm = { | |||
| imageName: null, | |||
| @@ -160,8 +200,10 @@ export default { | |||
| BaseModal, | |||
| pagination, | |||
| cdOperation, | |||
| rrOperation, | |||
| UploadInline, | |||
| DropdownHeader, | |||
| UploadProgress, | |||
| }, | |||
| cruds() { | |||
| return CRUD({ | |||
| @@ -194,10 +236,22 @@ export default { | |||
| callback(); | |||
| } | |||
| }; | |||
| const validateImageName = (rule, value, callback) => { | |||
| if (value === '' || value == null) { | |||
| callback(); | |||
| } else if (value.length > 64) { | |||
| callback(new Error('长度不超过 64 个字符')); | |||
| } else if (!/^[a-z0-9_-]+$/.test(value)) { | |||
| callback(new Error('只支持小写英文、数字、下划线和横杠')); | |||
| } else { | |||
| callback(); | |||
| } | |||
| }; | |||
| return { | |||
| active: '0', | |||
| localQuery: { | |||
| imageStatus: null, | |||
| imageNameOrId: null, | |||
| }, | |||
| map: { | |||
| 0: 'info', | |||
| @@ -212,9 +266,10 @@ export default { | |||
| rules: { | |||
| imageName: [ | |||
| { required: true, message: '请选择项目名称', trigger: 'change' }, | |||
| { validator: validateImageName, trigger: ['blur', 'change'] }, | |||
| ], | |||
| imagePath: [ | |||
| { required: true, message: '请输入镜像路径', trigger: 'blur' }, | |||
| { required: true, message: '请输入镜像路径', trigger: ['blur', 'manual'] }, | |||
| ], | |||
| imageTag: [ | |||
| { required: true, message: '请输入镜像版本号', trigger: 'blur' }, | |||
| @@ -226,11 +281,23 @@ export default { | |||
| uploadParams: { | |||
| objectPath: null, // 对象存储路径 | |||
| }, | |||
| progress: 0, | |||
| size: 0, | |||
| customColors: [ | |||
| {color: '#909399', percentage: 40}, | |||
| {color: '#e6a23c', percentage: 80}, | |||
| {color: '#67c23a', percentage: 100}, | |||
| ], | |||
| disableEdit: false, | |||
| loading: false, | |||
| isEdit: false, | |||
| prefabricate: true, | |||
| }; | |||
| }, | |||
| computed: { | |||
| isShow() { | |||
| return this.active === '0'; | |||
| }, | |||
| operationProps() { | |||
| return { | |||
| disabled: Number(this.active) === 1, | |||
| @@ -243,9 +310,12 @@ export default { | |||
| } | |||
| return arr; | |||
| }, | |||
| getUser() { | |||
| user() { | |||
| return this.$store.getters.user; | |||
| }, | |||
| status() { | |||
| return this.progress === 100 ? 'success' : null; | |||
| }, | |||
| }, | |||
| mounted() { | |||
| this.crud.query.imageResource = Number(this.active); | |||
| @@ -258,19 +328,31 @@ export default { | |||
| handleClick() { | |||
| this.crud.query.imageResource = Number(this.active); | |||
| this.crud.refresh(); | |||
| // 切换tab键时让表格重渲 | |||
| this.prefabricate = false; | |||
| this.$nextTick(() => { this.prefabricate = true; }); | |||
| }, | |||
| handleClose(done) { | |||
| done(); | |||
| onFileRemove() { | |||
| this.form.imagePath = null; | |||
| this.loading = false; | |||
| this.$refs.imagePath.validate('manual'); | |||
| }, | |||
| uploadStart() { | |||
| this.loading = true; | |||
| uploadStart(files) { | |||
| this.updateImagePath(); | |||
| [ this.loading, this.size, this.progress ] = [ true, files.size, 0 ]; | |||
| }, | |||
| updateRunParams(p) { | |||
| this.form.runParams = p; | |||
| onSetProgress(val) { | |||
| this.progress += val; | |||
| }, | |||
| uploadSuccess(res) { | |||
| this.loading = false; | |||
| this.form.imagePath = res[0].data.objectName; | |||
| this.progress = 100; | |||
| setTimeout(() => { | |||
| this.loading = false; | |||
| }, 1000); | |||
| if (this.loading) { | |||
| this.form.imagePath = res[0].data.objectName; | |||
| this.$refs.imagePath.validate('manual'); | |||
| } | |||
| }, | |||
| uploadError() { | |||
| this.$message({ | |||
| @@ -284,18 +366,24 @@ export default { | |||
| this.checkStatus(); | |||
| }, | |||
| [CRUD.HOOK.beforeToAdd]() { | |||
| this.isEdit = true; | |||
| this.formType = 'add'; | |||
| this.updateImagePath(); | |||
| }, | |||
| [CRUD.HOOK.beforeRefresh]() { | |||
| this.crud.query = { ...this.localQuery}; | |||
| this.crud.query.imageResource = Number(this.active); | |||
| }, | |||
| [CRUD.HOOK.beforeToEdit]() { | |||
| this.isEdit = false; | |||
| }, | |||
| async getHarborProjects() { | |||
| this.harborProjectList = await project(); | |||
| this.harborProjectList = await imageNameList(); | |||
| }, | |||
| onDialogClose() { | |||
| this.$refs.upload.formRef.reset(); | |||
| if (this.isEdit) { | |||
| this.$refs.upload.formRef.reset(); | |||
| } | |||
| this.loading = false; | |||
| }, | |||
| checkStatus() { | |||
| if (this.crud.data.some(item => [0].includes(item.imageStatus))) { | |||
| @@ -306,8 +394,33 @@ export default { | |||
| this.localQuery.imageStatus = status; | |||
| this.crud.toQuery(); | |||
| }, | |||
| resetQuery() { | |||
| this.localQuery = { | |||
| imageStatus: null, | |||
| imageNameOrId: null, | |||
| }; | |||
| }, | |||
| updateImagePath() { | |||
| this.uploadParams.objectPath = `upload-image/${this.getUser.id}/${parseTime(new Date(), '{y}{m}{d}{h}{i}{s}{S}') + nanoid(4)}`; | |||
| this.uploadParams.objectPath = `upload-temp/${this.user.id}/${getUniqueId()}`; | |||
| }, | |||
| async doEdit(imageObj) { | |||
| const dataObj = { | |||
| ids: [imageObj.id], | |||
| ...imageObj, | |||
| }; | |||
| await this.crud.toEdit(dataObj); | |||
| }, | |||
| doDelete(id) { | |||
| this.$confirm('此操作将永久删除该镜像, 是否继续?', '请确认').then( | |||
| async() => { | |||
| await del({ ids: [id] }); | |||
| this.$message({ | |||
| message: '删除成功', | |||
| type: 'success', | |||
| }); | |||
| this.crud.refresh(); | |||
| }, | |||
| ); | |||
| }, | |||
| }, | |||
| }; | |||