Repository: vicanso/cyberapi Branch: main Commit: 9e0bf4499039 Files: 123 Total size: 426.1 KB Directory structure: gitextract_n8v88nit/ ├── .eslintignore ├── .eslintrc.js ├── .gitattributes ├── .github/ │ └── workflows/ │ ├── publish.yml │ └── test.yml ├── .gitignore ├── .vscode/ │ └── extensions.json ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── README_uk.md ├── README_zh.md ├── cliff.toml ├── dev.md ├── index.html ├── package.json ├── splashscreen.html ├── src/ │ ├── App.tsx │ ├── commands/ │ │ ├── api_collection.ts │ │ ├── api_folder.ts │ │ ├── api_setting.ts │ │ ├── cookies.ts │ │ ├── database.ts │ │ ├── fake.ts │ │ ├── fn.ts │ │ ├── http_request.ts │ │ ├── http_response.ts │ │ ├── import_api.ts │ │ ├── interface.ts │ │ ├── invoke.ts │ │ ├── variable.ts │ │ └── window.ts │ ├── components/ │ │ ├── APIResponse/ │ │ │ ├── index.tsx │ │ │ ├── list.tsx │ │ │ └── status_text.tsx │ │ ├── APISettingParams/ │ │ │ ├── index.tsx │ │ │ ├── req_params.tsx │ │ │ └── uri.tsx │ │ ├── APISettingTree/ │ │ │ ├── header.tsx │ │ │ ├── index.tsx │ │ │ ├── item_dropdown.tsx │ │ │ └── items.tsx │ │ ├── ExColumn.tsx │ │ ├── ExCookieEditor.tsx │ │ ├── ExDeleteCheck.tsx │ │ ├── ExDialog.tsx │ │ ├── ExForm.tsx │ │ ├── ExKeyValue.tsx │ │ ├── ExLoading.tsx │ │ ├── ExPreview.tsx │ │ └── ExTimer.tsx │ ├── constants/ │ │ ├── common.ts │ │ ├── handle_key.ts │ │ ├── provide.ts │ │ └── style.ts │ ├── env.d.ts │ ├── event.ts │ ├── helpers/ │ │ ├── curl.js │ │ ├── editor.ts │ │ ├── hot_key.ts │ │ ├── html.ts │ │ ├── pinyin.ts │ │ └── util.ts │ ├── i18n/ │ │ ├── en.ts │ │ ├── index.ts │ │ ├── uk.ts │ │ └── zh.ts │ ├── icons.ts │ ├── main.css │ ├── main.ts │ ├── root.tsx │ ├── router/ │ │ ├── index.ts │ │ └── routes.ts │ ├── stores/ │ │ ├── api_collection.ts │ │ ├── api_folder.ts │ │ ├── api_setting.ts │ │ ├── app.ts │ │ ├── cookie.ts │ │ ├── dialog.ts │ │ ├── environment.ts │ │ ├── global_req_header.ts │ │ ├── header.ts │ │ ├── local.ts │ │ ├── pin_request.ts │ │ ├── setting.ts │ │ └── variable.ts │ ├── userWorker.ts │ └── views/ │ ├── AppHeader.tsx │ ├── AppSetting.tsx │ ├── Collection.tsx │ ├── CookieSetting.tsx │ ├── Dashboard.tsx │ ├── StoreSetting.tsx │ └── VariableSetting.tsx ├── src-tauri/ │ ├── .gitignore │ ├── Cargo.toml │ ├── build.rs │ ├── icons/ │ │ └── icon.icns │ ├── src/ │ │ ├── commands.rs │ │ ├── cookies.rs │ │ ├── entities/ │ │ │ ├── api_collections.rs │ │ │ ├── api_folders.rs │ │ │ ├── api_settings.rs │ │ │ ├── mod.rs │ │ │ ├── prelude.rs │ │ │ ├── variables.rs │ │ │ └── versions.rs │ │ ├── error.rs │ │ ├── http_request.rs │ │ ├── main.rs │ │ ├── schemas/ │ │ │ ├── api_collection.rs │ │ │ ├── api_folder.rs │ │ │ ├── api_setting.rs │ │ │ ├── database.rs │ │ │ ├── mod.rs │ │ │ ├── variable.rs │ │ │ └── version.rs │ │ └── util.rs │ └── tauri.conf.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintignore ================================================ # don't ever lint node_modules node_modules # don't lint build output (make sure it's set to your correct build folder name) dist # don't lint nyc coverage output coverage .eslintrc.js src-tauri ================================================ FILE: .eslintrc.js ================================================ module.exports = { root: true, parser: 'vue-eslint-parser', plugins: [ '@typescript-eslint', ], parserOptions: { parser: '@typescript-eslint/parser', }, extends: [ 'eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:vue/vue3-recommended', ] }; ================================================ FILE: .gitattributes ================================================ *.ts linguist-language=rust ================================================ FILE: .github/workflows/publish.yml ================================================ name: "publish" on: push: branches: - release jobs: publish-tauri: strategy: fail-fast: false matrix: platform: [macos-latest, ubuntu-20.04, windows-latest] runs-on: ${{ matrix.platform }} steps: - uses: actions/checkout@v3 - name: setup node uses: actions/setup-node@v3 - name: install Rust stable uses: actions-rs/toolchain@v1 with: toolchain: stable - name: install dependencies (ubuntu only) if: matrix.platform == 'ubuntu-20.04' run: | sudo apt-get update sudo apt-get install -y libgtk-3-dev webkit2gtk-4.0 libappindicator3-dev librsvg2-dev patchelf - name: install app dependencies and build it run: yarn && yarn build - uses: tauri-apps/tauri-action@v0.4.3 env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} with: tagName: v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version releaseName: "CyberAPI v__VERSION__" releaseBody: "See the assets to download this version and install." prerelease: false ================================================ FILE: .github/workflows/test.yml ================================================ name: "test" on: push: branches: [ main ] jobs: publish-tauri: strategy: fail-fast: false matrix: platform: [macos-latest] runs-on: ${{ matrix.platform }} steps: - uses: actions/checkout@v3 - name: setup node uses: actions/setup-node@v3 - name: install Rust stable uses: actions-rs/toolchain@v1 with: toolchain: stable - name: install app dependencies and build it run: | yarn yarn build cargo install tauri-cli make build ================================================ FILE: .gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules dist dist-ssr *.local # Editor directories and files .vscode/* !.vscode/extensions.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? stats.html ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": ["Vue.volar"] } ================================================ FILE: CHANGELOG.md ================================================ # Changelog All notable changes to this project will be documented in this file. ## [0.1.21] - 2024-05-30 ### Bug Fixes - Fix url input symbols line break - Fix read text from clipboard no permissions ### Miscellaneous Tasks - Update dependencies - Version 0.1.20 - Version 0.1.21 ### Refactor - Set overflow hidden for editor ## [0.1.20] - 2024-04-21 ### Bug Fixes - Fix url input symbols line break ### Miscellaneous Tasks - Update dependencies ### Refactor - Set overflow hidden for editor ## [0.1.19] - 2024-02-19 ### Bug Fixes - Fix export settings ### Refactor - Adjust import api setting ## [0.1.18] - 2023-12-09 ### Bug Fixes - Fix select api item ### Miscellaneous Tasks - Update dependencies - Update dependencies ### Refactor - Adjust menu of windows ## [0.1.17] - 2023-10-10 ### Bug Fixes - Fix expired cookie for cookie store ### Miscellaneous Tasks - Update dependencies ## [0.1.16] - 2023-09-18 ### Documentation - Update document ### Refactor - Update dependencies ## [0.1.15] - 2023-08-12 ### Bug Fixes - Fix dashboard scroll - Fix content type of multipart form ### Miscellaneous Tasks - Update dependencies - Update dependencies ## [0.1.14] - 2023-07-10 ### Bug Fixes - Fix media query list add event listener, #27 ### Features - Support import api from swagger, #26 ### Refactor - Enhance api select handle - Adjust zip feature for small size ## [0.1.13] - 2023-07-02 ### Bug Fixes - Fix parse file of header ### Features - Support import from curl command ### Miscellaneous Tasks - Update install cli script ### Refactor - Enhance query string format ## [0.1.12] - 2023-06-29 ### Bug Fixes - Fix base64 encode, #23 ### Refactor - Improve the performance of select file - Improve the performance of select file ## [0.1.11] - 2023-06-26 ### Bug Fixes - Fix set variable for req header ### Documentation - Update readme ### Features - Support expand view editor ## [0.1.10] - 2023-06-15 ### Bug Fixes - Fix column resize function ### Documentation - Update readme ### Features - Support automatic layout for editor ### Miscellaneous Tasks - Update tauri version to 1.4.0 ## [0.1.9] - 2023-06-13 ### Features - Add accept-encoding and support text content preview ### Refactor - Adjust json format ## [0.1.8] - 2023-06-10 ### Bug Fixes - Fix image preview for response ### Refactor - Ignore short interval of send request - Use monaco instead of codemirror - Adjust editor functions ## [0.1.7] - 2023-05-23 ### Miscellaneous Tasks - Update tauri action ### Refactor - Adjust fs scope ## [0.1.6] - 2023-05-09 ### Bug Fixes - Fix value of cookie includes "=" - Fix get set cookie value of response header ### Features - Support url keyword filter - Show user agent of cyberapi ### Miscellaneous Tasks - Update dependencies - Update dependencies - Update modules ### Refactor - Add space before header value - Adjust http timeout option - Update modules ## [0.1.5] - 2023-01-13 ### Bug Fixes - Fix double click - Update tauri fix repeat keydown event ### Features - Support toggle comment for json editor ## [0.1.4] - 2022-12-06 ### Bug Fixes - Fix short cut doesn't work - Fix generate curl function - Fix keydown event trigger twice ### Miscellaneous Tasks - Version 0.1.3 - Update package ## [0.1.3] - 2022-11-26 ### Features - Support select latest response - Support hide application for mac - Support clear history of response - Support hide application of macos ### Refactor - Show response of http request ## [0.1.2] - 2022-11-08 ### Bug Fixes - Fix import cancel handle ### Documentation - Update readme ### Features - Use tracing to get http stats - Support request timeout setting - Support tls tracing - Support clear cookie function ### Miscellaneous Tasks - Adjust publish workflow - Update modules ### Refactor - Remove unused todo - Show send time of request - Adjust splash screen - Adjust cookie setting view - Adjust cookie view - Enhance api item drag function - Try request error and convert to response - Set auto focus mirror editor ================================================ FILE: LICENSE ================================================ 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. ================================================ FILE: Makefile ================================================ .PHONY: default lint: cd src-tauri && cargo clippy fmt: cd src-tauri && cargo fmt --all -- dev: cargo tauri dev icon: cargo tauri icon ./cyberapi.png build: cargo tauri build clean: cd src-tauri && cargo clean install-cli: cargo install tauri-cli --version 1.4.0 install-orm-cli: cargo install sea-orm-cli orm: cd src-tauri && sea-orm-cli generate entity --with-serde=both \ -u "sqlite:///~/Library/Application Support/com.bigtree.cyberapi/my_db.db" \ -o src/entities version: git cliff --unreleased --tag 0.1.21 --prepend CHANGELOG.md ================================================ FILE: README.md ================================================ # Cyber API [![test library](https://img.shields.io/github/workflow/status/vicanso/cyberapi/test?label=test)](https://github.com/vicanso/cyberapi/actions?query=workflow%3A%22test%22) [![License](https://img.shields.io/badge/License-Apache%202-green.svg)](https://github.com/vicanso/cyberapi) [![donwnload](https://img.shields.io/github/downloads/vicanso/cyberapi/total?label=Downloads&logoColor=fff&logo=GitHub)](https://github.com/vicanso/cyberapi/releases)

cyberapi

CyberAPI is API tool based on tauri.

English|[简体中文](./README_zh.md)|[Українська](./README_uk.md) ## Features - Support macos, windows and linux platforms, the installation package is below 10MB - Thousands of interfaces for a single project are opened in seconds, with low memory usage - Support dark/light theme and chinese/english/ukrainian - Operation and configuration is simple - Support importing configuration from postman, insonmia or swagger. - The configuration can be exported by interface, function and project, which is convenient for sharing - Support many custom functions

cyberapi

## Installation The installer can be downloaded through [release](https://github.com/vicanso/cyberapi/releases), including windows, macos and linux versions. Windows may need to install webview2. ## macos > If you can't open it after installation, exec the following command then reopen:
`sudo xattr -rd com.apple.quarantine /Applications/CyberAPI.app` ## Development The project depends on rust and Nodejs. If you want to compile or participate in the development yourself, you can first install the dependencies of tauri by referring to the relevant documents [here](https://tauri.app/v1/guides/getting-started/prerequisites), and then : ```shell yarn ``` Install tauri-cli: ```shell cargo install tauri-cli ``` Running in browser mode, the data is mock: ```shell yarn dev ``` Running in app mode: ```shell make dev ``` Build the release package: ```shell make build ``` ================================================ FILE: README_uk.md ================================================ # Cyber API [![test library](https://img.shields.io/github/workflow/status/vicanso/cyberapi/test?label=test)](https://github.com/vicanso/cyberapi/actions?query=workflow%3A%22test%22) [![License](https://img.shields.io/badge/License-Apache%202-green.svg)](https://github.com/vicanso/cyberapi) [![donwnload](https://img.shields.io/github/downloads/vicanso/cyberapi/total?label=Downloads&logoColor=fff&logo=GitHub)](https://github.com/vicanso/cyberapi/releases)

cyberapi

CyberAPI це інструмент для запитів до API оснований на tauri.

[English](./README.md)|[简体中文](./README_zh.md)|Українська ## Features - Підтримка платформ macos, windows і linux, інсталяційний пакет менше 10 МБ - Тисячі інтерфейсів для одного проекту відкриваються за лічені секунди, з низьким споживанням пам'яті - Підтримка темної / світлої теми та китайської / англійської / української мови - Проста експлуатація та конфігурація - Підтримка імпорту конфігурації з postman, insonmia або swagger. - Конфігурацію можна експортувати за інтерфейсом, функцією та проектом, що зручно для спільного використання - Підтримка багатьох користувацьких функцій

cyberapi

## Installation Інсталятор можна завантажити з [release](https://github.com/vicanso/cyberapi/releases), включаючи версії для Windows, macos і linux. У Windows може знадобитися встановити webview2. ## Development Проект залежить від rust та Nodejs. Якщо ви хочете самостійно компілювати або брати участь у розробці, ви можете спочатку встановити залежності tauri, звернувшись до відповідних документів [тут](https://tauri.app/v1/guides/getting-started/prerequisites), а потім : ```shell yarn ``` Встановити tauri-cli: ```shell cargo install tauri-cli ``` При запуску в режимі браузера дані є імітацією: ```shell yarn dev ``` Запустити в режимі додатку: ```shell make dev ``` Зібрати релізний пакет: ```shell make build ``` ================================================ FILE: README_zh.md ================================================ # Cyber API [![test library](https://img.shields.io/github/workflow/status/vicanso/cyberapi/test?label=test)](https://github.com/vicanso/cyberapi/actions?query=workflow%3A%22test%22) [![License](https://img.shields.io/badge/License-Apache%202-green.svg)](https://github.com/vicanso/cyberapi)

cyberapi

CyberAPI是基于tauri开发的跨平台API客户端

[English](./README.md)|简体中文|[Українська](./README_uk.md) ## 功能点 - 支持macos、windows以及linux平台,安装包均在10MB以下 - 单个项目上千个接口秒级打开,内存占用较低 - 支持Dark/Light主题以及中英语言 - 简单易用的操作及配置方式 - 可快速导入postman,insomnia或者swagger的配置 - 关键字筛选支持中文拼音或者首字母 - 可按接口、按功能、按项目导出配置,方便团队内共用 - 各类自定义的函数,方便各请求间关联数据

cyberapi

CyberAPI暂时仅是开发版本,业余时间的个人项目,如果有BUG或期望新增功能可以issue,对于BUG请附上系统版本信息,本人尽可能抽时间处理。 ## 安装 安装程序可以通过[release](https://github.com/vicanso/cyberapi/releases)下载,包括windows、macos以及linux版本。 需要注意如果是win7或者未安装Edge的windows,在安装时会提示需要执行MicrosoftEdgeUpdateSetup的程序,如果杀毒软件提示允许执行即可。 如果是macos,由于系统的安全调整,打开应用时会提示"无法打开“CyberAPI”,因为Apple无法检查其是否包含恶意软件。",在“系统设置” -> “安全性与隐私” -> “通用”面板选择继续打开即可。或者执行以下命令:`sudo xattr -rd com.apple.quarantine /Applications/CyberAPI.app` ## 开发者 项目依赖于rust与Nodejs,如果想自行编译或参与开发,可以先参考[这里](https://tauri.app/v1/guides/getting-started/prerequisites)的相关文档安装tauri的依赖,之后执行: ```shell yarn ``` 安装tauri-cli: ```shell cargo install tauri-cli ``` 仅调整前端界面时可直接使用浏览器的方式来测试(增加了各类mock的接口),执行: ```shell yarn dev ``` 如果以APP的形式运行,则执行: ```shell make dev ``` 如果想编译安装包,则执行: ```shell make build ``` ## 创建项目 首次启动后,需要先创建项目,建议按不同的项目来创建,同一项目可共用环境变量的配置。

home

## 创建项目的环境变量 环境变量主要用于定义HTTP请求的host等场景,用于方便快捷的切换请求对接的环境。

env-select

tiny配置了两个环境的ENV设置,其中`http://tiny.npmtrend.com`未生效(复选框未勾选),如果需要切换不同的环境时,选择勾选不同的配置生效即可,需要注意不要同时选择相同的环境变量生效。

env-editor

## 创建项目的变量 项目中使用的变量可用于各请求中的参数设置,通过`{{value(key)}}`函数引用。

variable

## 创建目录与请求 创建请求之前,建议按功能来创建不同的分组,例如创建用户相关的一个分组:

create-folder

在创建分组之后,则可以在该分组下创建对应的请求:

create-request

在创建请求之后,则可以选择请求使用的env(自动添加至请求url中),对应的HTTP Method以及输入URL。对于POST类请求的body部分,则可以选择对应的数据类型,如选择了json数据,填写对应的参数,图中的`{{md5(123123)}}`为函数形式,会在请求时执行此函数,填充对应的数据,后续会专门介绍此类函数:

create-request-detail

配置完成后,点击发送则发送该请求,获取到响应后则展示如下图。第一个图标点击时会展示该请求的ID(后续可用于其它请求指定获取该请求的响应时使用),第二个图标点击会展示此请求对应的`curl`。

request-result

### 不同的数据类型 数据提交较为常用的是JSON以及Form类型,下图示例为选择Form类型的数据填写(需要注意切换数据类型时会清除原有数据)。如果临时想不使用某个参数,则可取消勾选即可,不需要的则可以删除,参数也可使用函数的形式:

request-post-form

`Query`与`Header`的设置与`Form`类似,不再细说。 ## Pin 对于经常使用的请求,可以通过`Pin`操作将其置顶,便于后续使用,操作如下:

pin-request

## 内置函数 CyberAPI内置支持了部分函数,便于在请求中动态生成参数值,具体函数如下: - `readTextFile`:读取文本文件,也可使用简写`rtf` - `readFile`:读取文件,也可使用简写`rf` - `base64`:转换为base64,也可使用简写`b64` - `openFile`:打开文件,弹窗让用户选择要打开的文件,也可使用简写`of` - `get`:获取请求响应数据的值,也可使用简写`g` - `timestamp`:时间戳(秒),也可使用简写`ts` - `md5`:md5并以hex形式输出 - `sha256`:sha256并以hex形式输出 ### readTextFile 读取文件并以字符的形式返回,其参数为`(file, dir)`,其中`dir`为可选参数,默认为`Download`目录,基于安全考虑,仅允许使用三个目录:`document`,`desktop`以及`download`。例如读取`download`目录下的`test/data.json`,其使用方式:`{{readTextFile(test/data.json, download)}}` ### readFile 读取文件并以字节的形式返回,其参数及使用方法与`readTextFile`一致,仅返回数据的类型不一样 ### base64 将字节转换为base64的形式,一般用于函数串联时使用,如读取图片并将其转换为base64,使用方式:`{{base64.readFile(test-files/data.jpg, document)}}`,表示读取图片后,将图片的数据转换为bas64。函数串联可以以多个函数串联使用,处理方式为由右往左,后一个函数参数为前一个函数的返回。 ### openFile 弹出文件打开选择框,由客户选择要打开的文件,其返回客户选择的文件路径。 ### get 获取指定请求的响应,例如A请求的响应为`{"data": {"name": "CyberAPI"}}`,B请求需要使用A请求的响应的`data.name`,则先获取请求A的ID,在请求A的响应中点图标获取,如下:

request-id

在得到请求A的ID之后(该ID不会变化),则使用方式:`{{get(01GCE5X1X5FXM3D13NQQKYEWF7, data.name)}}`,此方式主要用于接口依赖于其它接口的响应的场景中使用。 注:如果响应数据data为数组,从数组中获取第一个元素的值,则参数如下:`data.0.name` ### timestamp 时间戳(秒),用于返回当前的时间,使用方式:`{{timestamp()}}` ### md5/sha256 计算md5并以hex形式输出,使用方式:`{{md5(123123)`,sha256的使用方式一致。 ### value 获取全局配置的值,使用方式`{{value(key)}}`,方便获取项目中配置的变量数据。 ## Cookie设置 Cookie的数据为应用共享,在HTTP响应头中有`Set-Cookie`则会自动保存,需要注意对于`Session`有效期的Cookie,在程序关闭之后会自动清除。用户可直接修改Cookie的有效期、值等信息或删除Cookie。

cookies

## 配置导入 配置导入支持四种方式,`JSON`与`File`仅支持`CyberAPI`的配置形式导入,`PostMan`用于导入postman的配置,`Insonmia`则用于导入insonmia的配置。

import-configurations

import-editor

================================================ FILE: cliff.toml ================================================ # configuration file for git-cliff (0.1.0) [changelog] # changelog header header = """ # Changelog\n All notable changes to this project will be documented in this file.\n """ # template for the changelog body # https://tera.netlify.app/docs/#introduction body = """ {% if version %}\ ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} {% else %}\ ## [unreleased] {% endif %}\ {% for group, commits in commits | group_by(attribute="group") %} ### {{ group | upper_first }} {% for commit in commits %} - {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }}\ {% endfor %} {% endfor %}\n """ # remove the leading and trailing whitespace from the template trim = true # changelog footer footer = """ """ [git] # parse the commits based on https://www.conventionalcommits.org conventional_commits = true # filter out the commits that are not conventional filter_unconventional = true # process each line of a commit as an individual commit split_commits = false # regex for preprocessing the commit messages commit_preprocessors = [ { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/orhun/git-cliff/issues/${2}))"}, ] # regex for parsing and grouping commits commit_parsers = [ { message = "^feat", group = "Features"}, { message = "^fix", group = "Bug Fixes"}, { message = "^doc", group = "Documentation"}, { message = "^perf", group = "Performance"}, { message = "^refactor", group = "Refactor"}, { message = "^style", group = "Styling"}, { message = "^test", group = "Testing"}, { message = "^chore\\(release\\): prepare for", skip = true}, { message = "^chore", group = "Miscellaneous Tasks"}, { body = ".*security", group = "Security"}, ] # filter out the commits that are not matched by commit parsers filter_commits = false # glob pattern for matching git tags tag_pattern = "v[0-9]*" # regex for skipping tags skip_tags = "v0.1.0-beta.1" # regex for ignoring tags ignore_tags = "" # sort the tags chronologically date_order = false # sort the commits inside sections by oldest/newest order sort_commits = "oldest" ================================================ FILE: dev.md ================================================ # 查询证件列表 ``` security find-identity -v -p codesigning ``` # 校验IMG 校验IMG是否使用证件签名: ``` spctl -a -v src-tauri/target/release/bundle/dmg/cyberapi_0.1.0_aarch64.dmg ``` # 版本发布 修改版本号后执行`make version`生成修改记录,提交代码后合并至release ================================================ FILE: index.html ================================================ CyberAPI
================================================ FILE: package.json ================================================ { "name": "cyberapi", "private": true, "version": "", "scripts": { "format": "prettier --write src/*.ts src/*.tsx src/**/*.ts src/**/*.tsx src/**/**/*.tsx", "lint": "eslint . --ext .js,.tsx,.ts --fix && vue-tsc --noEmit", "dev": "vite", "build": "vue-tsc --noEmit && vite build", "preview": "vite preview", "tauri": "tauri" }, "dependencies": { "@linaria/core": "3.0.0-beta.22", "@tauri-apps/api": "^1.5.3", "@tauri-apps/cli": "^1.5.11", "@vicons/ionicons5": "^0.12.0", "bluebird": "^3.7.2", "crypto-js": "^4.2.0", "dayjs": "^1.11.10", "debug": "^4.3.4", "form-data-encoder": "^4.0.2", "js-base64": "^3.7.7", "localforage": "^1.10.0", "lodash-es": "^4.17.21", "mime": "^4.0.1", "mitt": "^3.0.1", "monaco-editor": "^0.47.0", "naive-ui": "^2.38.1", "pinia": "^2.1.7", "pretty-bytes": "^6.1.1", "shellwords": "^1.0.1", "ulid": "^2.3.0", "vue": "^3.4.21", "vue-i18n": "^9.11.1", "vue-router": "^4.3.0" }, "devDependencies": { "@types/bluebird": "^3.5.42", "@types/crypto-js": "^4.2.2", "@types/debug": "^4.1.12", "@types/mime": "^3.0.4", "@types/shortid": "^0.0.32", "@types/uuid": "^9.0.8", "@typescript-eslint/eslint-plugin": "^7.6.0", "@typescript-eslint/parser": "^7.6.0", "@vitejs/plugin-vue": "^5.0.4", "@vitejs/plugin-vue-jsx": "^3.1.0", "@vue/compiler-sfc": "^3.4.21", "eslint": "^9.0.0", "eslint-plugin-vue": "^9.24.1", "glob-parent": ">=6.0.2", "prettier": "^3.2.5", "rollup-plugin-visualizer": "^5.12.0", "typescript": "^5.4.4", "vite": "^5.2.8", "vite-plugin-linaria": "^1.0.0", "vue-tsc": "^2.0.11" } } ================================================ FILE: splashscreen.html ================================================ CyberAPI

Cyber API

================================================ FILE: src/App.tsx ================================================ import { defineComponent, onMounted } from "vue"; import { NLayout, NLayoutHeader, useLoadingBar, NModal } from "naive-ui"; import { storeToRefs } from "pinia"; import "./main.css"; import AppHeader from "./views/AppHeader"; import { useDialogStore } from "./stores/dialog"; import AppSetting from "./views/AppSetting"; import CookieSetting from "./views/CookieSetting"; import VariableSetting from "./views/VariableSetting"; import StoreSetting from "./views/StoreSetting"; import { VariableCategory } from "./commands/variable"; import { i18nEnvironment, i18nCustomizeVariable, i18nGlobalReqHeader, } from "./i18n"; export default defineComponent({ name: "App", setup() { const loadingBar = useLoadingBar(); const dialogStore = useDialogStore(); const { showSetting, showCookie, showEnvironment, showStore, showCustomizeVariableStore, showReqHeader, } = storeToRefs(dialogStore); const closeDialog = () => { dialogStore.$reset(); }; onMounted(() => { loadingBar.finish(); }); return { closeDialog, showSetting, showCookie, showEnvironment, showStore, showCustomizeVariableStore, showReqHeader, }; }, render() { const { showSetting, showCookie, showEnvironment, showStore, showCustomizeVariableStore, showReqHeader, closeDialog, } = this; const settingModal = ( { closeDialog(); }} onMaskClick={() => { closeDialog(); }} > ); const cookieModal = ( { closeDialog(); }} onMaskClick={() => { closeDialog(); }} > ); const environmentModal = ( { closeDialog(); }} onMaskClick={() => { closeDialog(); }} > ); const customizeVariableModal = ( { closeDialog(); }} onMaskClick={() => { closeDialog(); }} > ); const reqHeaderModal = ( { closeDialog(); }} onMaskClick={() => { closeDialog(); }} > ); const storeModal = ( { closeDialog(); }} onMaskClick={() => { closeDialog(); }} > ); return ( {settingModal} {cookieModal} {environmentModal} {storeModal} {customizeVariableModal} {reqHeaderModal}
); }, }); ================================================ FILE: src/commands/api_collection.ts ================================================ import dayjs from "dayjs"; import { ulid } from "ulid"; import { isWebMode } from "../helpers/util"; import { fakeAdd, fakeDeleteAPICollection, fakeList, fakeUpdate } from "./fake"; import { run, cmdAddAPICollection, cmdListAPICollection, cmdUpdateAPICollection, cmdDeleteAPICollection, } from "./invoke"; const store = "apiCollections"; export interface APICollection { [key: string]: unknown; id: string; // 名称 name: string; // 描述 description: string; // 创建时间 createdAt: string; // 更新时间 updatedAt: string; } export function newDefaultAPICollection(): APICollection { const id = ulid(); return { id, name: "", description: "", createdAt: dayjs().format(), updatedAt: dayjs().format(), }; } export async function createAPICollection( collection: APICollection, ): Promise { if (isWebMode()) { await fakeAdd(store, collection); return; } await run(cmdAddAPICollection, { collection, }); } export async function listAPICollection(): Promise { if (isWebMode()) { return await fakeList(store); } return await run(cmdListAPICollection); } export async function updateAPICollection(collection: APICollection) { if (isWebMode()) { await fakeUpdate(store, collection); return; } await run(cmdUpdateAPICollection, { collection, }); } export async function deleteAPICollection(id: string) { if (isWebMode()) { await fakeDeleteAPICollection(store, id); } await run(cmdDeleteAPICollection, { id, }); } ================================================ FILE: src/commands/api_folder.ts ================================================ import dayjs from "dayjs"; import { ulid } from "ulid"; import { isWebMode } from "../helpers/util"; import { run, cmdAddAPIFolder, cmdListAPIFolder, cmdUpdateAPIFolder, cmdDeleteAPIFolder, } from "./invoke"; import { fakeList, fakeAdd, fakeUpdate, fakeDeleteItems } from "./fake"; const store = "apiFolders"; export interface APIFolder { [key: string]: unknown; id: string; collection: string; children: string; // 名称 name: string; // 创建时间 createdAt: string; // 更新时间 updatedAt: string; } export function newDefaultAPIFolder(): APIFolder { const id = ulid(); return { id, collection: "", children: "", name: "", createdAt: dayjs().format(), updatedAt: dayjs().format(), }; } export async function createAPIFolder(folder: APIFolder): Promise { if (isWebMode()) { await fakeAdd(store, folder); return; } await run(cmdAddAPIFolder, { folder, }); } export async function listAPIFolder(collection: string): Promise { if (isWebMode()) { const folders = await fakeList(store); return folders.filter((item) => item.collection === collection); } return await run(cmdListAPIFolder, { collection, }); } export async function updateAPIFolder(folder: APIFolder) { if (isWebMode()) { return await fakeUpdate(store, folder); } await run(cmdUpdateAPIFolder, { folder, }); } export async function deleteAPIFolder(id: string): Promise<{ folders: string[]; settings: string[]; }> { const result = { folders: [] as string[], settings: [] as string[], }; if (isWebMode()) { // 查询folders const folders = await listAPIFolder(""); const folderDict: Map = new Map(); folders.forEach((item) => { folderDict.set(item.id, item); }); if (!folderDict.has(id)) { return Promise.resolve(result); } const folderIds = [id]; const settingIds: string[] = []; let children = folderDict.get(id)?.children; while (children) { const subChildren: string[] = []; const arr = children.split(","); arr.forEach((id) => { if (!id) { return; } if (settingIds.includes(id) || folderIds.includes(id)) { return; } const folder = folderDict.get(id); if (folder) { folderIds.push(id); if (folder.children) { subChildren.push(folder.children); } } else { settingIds.push(id); } }); children = subChildren.join(","); } await fakeDeleteItems("apiSettings", settingIds); await fakeDeleteItems(store, folderIds); result.folders = folderIds; result.settings = settingIds; return result; } return await run(cmdDeleteAPIFolder, { id, }); } ================================================ FILE: src/commands/api_setting.ts ================================================ import dayjs from "dayjs"; import { ulid } from "ulid"; import { isWebMode } from "../helpers/util"; import { run, cmdAddAPISetting, cmdListAPISetting, cmdUpdateAPISetting, cmdDeleteAPISettings, } from "./invoke"; import { fakeList, fakeAdd, fakeUpdate, fakeUpdateStore } from "./fake"; const store = "apiSettings"; export interface APISetting { [key: string]: unknown; id: string; collection: string; // 名称 name: string; // 类型(http, graphQL) category: string; // 配置信息 setting: string; // 创建时间 createdAt: string; // 更新时间 updatedAt: string; } export function newDefaultAPISetting(): APISetting { const id = ulid(); return { id, collection: "", name: "", category: "", setting: "", createdAt: dayjs().format(), updatedAt: dayjs().format(), }; } export async function createAPISetting(setting: APISetting): Promise { if (isWebMode()) { await fakeAdd(store, setting); return; } if (!setting.id) { setting.id = ulid(); } try { await run(cmdAddAPISetting, { setting, }); } catch (err) { let catchError = false; if (err instanceof Error) { const message = err.message; if (message.includes("seaOrm") && message.includes("UNIQUE constraint failed")) { catchError = true; setting.id = ulid(); await run(cmdAddAPISetting, { setting, }); } } if (!catchError) { throw err; } } } export async function listAPISetting( collection: string, ): Promise { if (isWebMode()) { const settings = await fakeList(store); return settings.filter((item) => item.collection === collection); } return await run(cmdListAPISetting, { collection, }); } export async function updateAPISetting(setting: APISetting) { if (isWebMode()) { await fakeUpdate(store, setting); return; } await run(cmdUpdateAPISetting, { setting, }); } export async function deleteAPISettings(ids: string[]) { if (isWebMode()) { const arr = await fakeList(store); const result = arr.filter((item) => { return !ids.includes(item.id); }); await fakeUpdateStore(store, result); } await run(cmdDeleteAPISettings, { ids, }); } ================================================ FILE: src/commands/cookies.ts ================================================ import { get, values } from "lodash-es"; import { isWebMode } from "../helpers/util"; import { cmdAddCookie, cmdClearCookie, cmdDeleteCookie, cmdListCookie, run, } from "./invoke"; export interface Cookie { [key: string]: unknown; name: string; value: string; path: string; domain: string; expires: string; } export async function listCookie(): Promise { if (isWebMode()) { return Promise.resolve([ { name: "cybertect", value: "CBBVJIUT8Q9EEFDKF9H0", path: "/", domain: "cybertect.npmtrend.com", expires: "Wed, 31 Jan 2024 16:00:00 GMT", }, { name: "cybertect.sig", value: "iIoKqqpgXc-Ao-ilTf4XdaNyblsdKauy0fVqISbikoU", path: "/", domain: "", expires: "Wed, 31 Jan 2024 16:00:00 GMT", }, ]); } const arr = await run(cmdListCookie, {}); if (!arr || !arr.length) { return []; } const cookies: Cookie[] = []; arr.forEach((data) => { const item = JSON.parse(data); const cookie = get(item, "raw_cookie"); if (!cookie) { return; } const cookieValues = (cookie as string).split(";"); const [name, ...value] = cookieValues[0].split("="); const path = (get(item, "path.0") || "/") as string; const domainValues = values(get(item, "domain")); let domain = ""; if (domainValues && domainValues.length) { domain = domainValues[0]; } const expires = get(item, "expires.AtUtc") as string; cookies.push({ name, value: value.join("="), path: path || "", domain: domain || "", expires: expires || "", }); }); return cookies; } export async function deleteCookie(c: Cookie) { if (isWebMode()) { return; } await run(cmdDeleteCookie, { c, }); } export async function clearCookie() { if (isWebMode()) { return; } await run(cmdClearCookie, {}); } export async function addOrUpdate(c: Cookie) { if (isWebMode()) { return; } await run(cmdAddCookie, { c, }); } ================================================ FILE: src/commands/database.ts ================================================ import { getVersion } from "@tauri-apps/api/app"; import { message } from "@tauri-apps/api/dialog"; import dayjs from "dayjs"; import { ulid } from "ulid"; import { isWebMode } from "../helpers/util"; import { cmdAddVersion, cmdExportTables, cmdGetLatestVersion, cmdImportTables, cmdInitTables, run, } from "./invoke"; export interface Version { [key: string]: unknown; id: string; version: string; createdAt: string; updatedAt: string; } async function getDatabaseLatestVersion() { if (isWebMode()) { return {} as Version; } return await run(cmdGetLatestVersion, {}); } // handleDatabaseCompatible 处理数据库兼容 export async function handleDatabaseCompatible() { if (isWebMode()) { return; } try { await run(cmdInitTables); const version = await getVersion(); const latestVersion = await getDatabaseLatestVersion(); if (!latestVersion || latestVersion.version !== version) { await run(cmdAddVersion, { version: { id: ulid(), version, createdAt: dayjs().format(), updatedAt: dayjs().format(), }, }); } // TODO 后续针对数据库做更新 } catch (err) { if (err instanceof Error) { message(err.message); } console.error(err); } } export async function exportTables(): Promise { return await run(cmdExportTables); } export async function importTables(file: string) { return await run(cmdImportTables, { file, }); } ================================================ FILE: src/commands/fake.ts ================================================ import localforage from "localforage"; const stores = new Map(); interface WithID { id: string; } function getStore(name: string): LocalForage { let store = stores.get(name); if (store) { return store; } store = localforage.createInstance({ name, }); stores.set(name, store); return store; } export async function fakeList(storeName: string): Promise { const store = getStore(storeName); const result = await store.getItem("fake"); if (result != null) { return result; } return []; } export async function fakeAdd(storeName: string, data: T) { const result = await fakeList(storeName); result.push(Object.assign({}, data)); await fakeUpdateStore(storeName, result); } export async function fakeUpdate(storeName: string, data: T) { const result = await fakeList(storeName); let found = -1; result.forEach((item, index) => { if (item.id == data.id) { found = index; } }); if (found !== -1) { result[found] = Object.assign({}, data); } await fakeUpdateStore(storeName, result); } export async function fakeDeleteAPICollection( storeName: string, id: string, ) { // 暂时简单删除collection const result = await fakeList(storeName); let found = -1; result.forEach((item, index) => { if (item.id == id) { found = index; } }); if (found !== -1) { result.splice(found, 1); } await fakeUpdateStore(storeName, result); } export async function fakeDeleteItems( storeName: string, ids: string[], ) { const result = await fakeList(storeName); const arr = [] as unknown[]; result.forEach((item) => { if (!ids.includes(item.id)) { arr.push(item); } }); await fakeUpdateStore(storeName, arr); } export async function fakeUpdateStore(storeName: string, data: unknown) { const store = getStore(storeName); await store.setItem("fake", data); } ================================================ FILE: src/commands/fn.ts ================================================ import { BaseDirectory, FsOptions, readBinaryFile, readTextFile, } from "@tauri-apps/api/fs"; import { open } from "@tauri-apps/api/dialog"; import { toString, get, trim } from "lodash-es"; import { fromUint8Array } from "js-base64"; import sha256 from "crypto-js/sha256"; import md5 from "crypto-js/md5"; import { i18nCommon } from "../i18n"; import { getLatestResponse, getResponseBody } from "./http_response"; import { listVariable, VariableCategory, VariableStatus } from "./variable"; interface FnHandler { collection: string; // 原始字符 text: string; // 函数列表 fnList: string[]; // 初始参数 param: string | string[]; } enum Fn { readTextFile = "readTextFile", rtf = "rtf", readFile = "readFile", rf = "rf", base64 = "base64", b64 = "b64", openFile = "openFile", of = "of", get = "get", g = "g", timestamp = "timestamp", ts = "ts", md5 = "md5", sha256 = "sha256", value = "value", } function trimParam(param: string): string | string[] { const arr = param.split(",").map((item) => { item = item.trim(); item = trim(item, "'"); item = trim(item, '"'); return item; }); // 单引号替换为双引号 // const str = `[${param.replaceAll("'", '"')}]`; // const arr = JSON.parse(str); if (arr.length < 2) { return arr[0]; } return arr; } export function parseFunctions(collection: string, value: string): FnHandler[] { const reg = /\{\{([\s\S]+?)\}\}/g; const parmaReg = /\(([\s\S]*?)\)/; let result: RegExpExecArray | null; const handlers: FnHandler[] = []; while ((result = reg.exec(value)) !== null) { if (result.length !== 2) { break; } const paramResult = parmaReg.exec(result[1]); if (paramResult?.length !== 2) { break; } const fnList = result[1].replace(paramResult[0], "").split("."); handlers.push({ collection, text: result[0], fnList: fnList, param: trimParam(paramResult[1]), }); } return handlers; } interface FsParams { file: string; option: FsOptions; } function getDir(dir: string): BaseDirectory { switch (dir.toLowerCase()) { case "document": return BaseDirectory.Document; break; case "desktop": return BaseDirectory.Desktop; break; default: return BaseDirectory.Download; break; } } function convertToFsParams(p: unknown): FsParams { const option = { dir: BaseDirectory.Download, }; let file = toString(p); if (Array.isArray(p)) { file = toString(p[0]); if (p[1]) { option.dir = getDir(p[1]); } } return { file, option, }; } export async function doFnHandler(handler: FnHandler): Promise { const { param, fnList, collection } = handler; let p: unknown = param; const size = fnList.length; // 函数处理从后往前 for (let index = size - 1; index >= 0; index--) { const fn = fnList[index]; switch (fn) { case Fn.readTextFile: case Fn.rtf: { const params = convertToFsParams(p); p = await readTextFile(params.file, params.option); } break; case Fn.md5: p = md5(toString(p)).toString(); break; case Fn.sha256: p = sha256(toString(p)).toString(); break; case Fn.readFile: case Fn.rf: { const params = convertToFsParams(p); p = await readBinaryFile(params.file, params.option); } break; case Fn.base64: case Fn.b64: { p = fromUint8Array(p as Uint8Array); } break; case Fn.openFile: case Fn.of: { const selected = await open({ title: i18nCommon("selectFile"), }); if (selected) { p = selected as string; } } break; case Fn.timestamp: case Fn.ts: { p = `${Math.round(Date.now() / 1000)}`; } break; case Fn.get: case Fn.g: { const arr = toString(p).split(","); if (arr.length !== 2) { throw new Error("params of get from response is invalid"); } const resp = await getLatestResponse(arr[0].trim()); if (resp) { const result = getResponseBody(resp); p = get(result.json, arr[1].trim()); } } break; case Fn.value: { const name = toString(p); const arr = await listVariable( collection, VariableCategory.Customize, ); const found = arr.find( (item) => item.enabled === VariableStatus.Enabled && item.name === name, ); if (found) { p = found.value; } } break; default: break; } } return toString(p); } ================================================ FILE: src/commands/http_request.ts ================================================ import { forEach, isArray } from "lodash-es"; import { encode } from "js-base64"; import { ulid } from "ulid"; import { getVersion, getTauriVersion } from "@tauri-apps/api/app"; import { arch, type, version } from "@tauri-apps/api/os"; import { FormDataEncoder } from "form-data-encoder"; import { fromUint8Array } from "js-base64"; import { readBinaryFile } from "@tauri-apps/api/fs"; import { run, cmdDoHTTPRequest } from "./invoke"; import { KVParam } from "./interface"; import { isWebMode, delay, formatError } from "../helpers/util"; import { doFnHandler, parseFunctions } from "./fn"; import { HTTPResponse, addLatestResponse } from "./http_response"; import { Cookie } from "./cookies"; import mime from "mime"; export enum HTTPMethod { GET = "GET", POST = "POST", PUT = "PUT", PATCH = "PATCH", DELETE = "DELETE", OPTIONS = "OPTIONS", HEAD = "HEAD", } export enum ContentType { JSON = "application/json", Form = "application/x-www-form-urlencoded", Multipart = "multipart/form-data", XML = "application/xml", Plain = "text/plain", } export interface RequestTimeout { [key: string]: unknown; connect: number; write: number; read: number; } export interface HTTPRequest { [key: string]: unknown; method: string; uri: string; body: string; contentType: string; headers: KVParam[]; query: KVParam[]; auth: KVParam[]; } function convertKVListToURLValues(kvList: KVParam[]) { if (!kvList || kvList.length === 0) { return []; } const arr: string[] = []; kvList.forEach((kv) => { if (!kv.enabled) { return; } arr.push(`${kv.key}=${encodeURIComponent(kv.value)}`); }); return arr; } export async function convertRequestToCURL( collection: string, req: HTTPRequest, cookies: Cookie[], ) { await convertKVParams(collection, req.query); await convertKVParams(collection, req.headers); const queryList = convertKVListToURLValues(req.query); let uri = req.uri; if (queryList.length !== 0) { if (uri.includes("?")) { uri += `&${queryList.join("&")}`; } else { uri += `?${queryList.join("&")}`; } } const headerList: string[] = []; const host = new URL(uri).host; const cookieValues: string[] = []; cookies.forEach((item) => { if (host.includes(item.domain)) { cookieValues.push(`${item.name}=${item.value}`); } }); if (cookieValues.length) { headerList.push(`-H 'Cookie: ${cookieValues.join("; ")}'`); } let includeContentType = false; req.headers?.forEach((kv) => { if (!kv.enabled) { return; } if (kv.key.toLowerCase() === "content-type") { includeContentType = true; } headerList.push(`-H '${kv.key}: ${kv.value}'`); }); if (!includeContentType && req.contentType) { headerList.push(`-H 'Content-Type: ${req.contentType}'`); } let body = " "; if (req.body) { body = await convertBody(collection, req.body); switch (req.contentType) { case ContentType.JSON: body = JSON.stringify(JSON.parse(body)); break; case ContentType.Form: { const arr: KVParam[] = JSON.parse(body); body = convertKVListToURLValues(arr).join("&"); } break; default: break; } body = ` -d '${body}' `; } const method = req.method || "GET"; return `curl -v -X${method.toUpperCase()}${body}${headerList.join( " ", )} '${uri}'`; } function is_json(str: string) { const value = str.trim(); if (value.length < 2) { return false; } const key = value[0] + value[value.length - 1]; return key === "{}" || key === "[]"; } async function convertBody(collection: string, data: string) { let body = data; // 注释的处理 if (is_json(body)) { const arr = body.split("\n").filter((item) => { if (item.trim().startsWith("//")) { return false; } return true; }); body = arr.join("\n"); } const handlers = parseFunctions(collection, body); if (handlers.length === 0) { return body; } for (let i = 0; i < handlers.length; i++) { const handler = handlers[i]; const result = await doFnHandler(handler); // 替换result的内容 body = body.replace(handler.text, result); } return body; } export async function convertKVParams(collection: string, params: KVParam[]) { if (!params || params.length === 0) { return; } for (let i = 0; i < params.length; i++) { const param = params[i]; const handlers = parseFunctions(collection, param.value); if (handlers.length === 0) { continue; } let { value } = param; for (let j = 0; j < handlers.length; j++) { const handler = handlers[j]; const result = await doFnHandler(handler); // 替换result的内容 value = value.replace(handler.text, result); } param.value = value; } } export const abortRequestID = ulid(); interface MultipartFormData { headers: { "Content-Type": string; "Content-Length"?: string; }; body: string; } // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore async function convertMultipartForm(body: string): Promise { const arr = JSON.parse(body) as KVParam[]; const form = new FormData(); for (let i = 0; i < arr.length; i++) { const item = arr[i]; if (!item.enabled || !item.key) { continue; } const fileProtocol = "file://"; if (item.value.startsWith(fileProtocol)) { const file = item.value.substring(fileProtocol.length); const fileData = await readBinaryFile(file); form.append( item.key, new Blob([fileData], { type: mime.getType(file) || "", }), file, ); continue; } form.append(item.key, item.value); } // eslint-disable-next-line // @ts-ignore const encoder = new FormDataEncoder(form); // eslint-disable-next-line // @ts-ignore const b = new Blob(encoder, { type: encoder.contentType, }); const buf = await b.arrayBuffer(); return { headers: encoder.headers, body: fromUint8Array(new Uint8Array(buf)), }; } export async function getUserAgent() { const appVersion = await getVersion(); const appOS = await type(); const appOSVersion = await version(); const appArch = await arch(); const tauriVersion = await getTauriVersion(); return `CyberAPI/${appVersion} (${appOS}; tauri/${tauriVersion}; ${appOSVersion}; ${appArch})`; } // Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) let userAgent = ""; export async function doHTTPRequest(options: { id: string; collection: string; req: HTTPRequest; originalReq: HTTPRequest; timeout: RequestTimeout; }): Promise { const { id, collection, req, originalReq, timeout } = options; if (!req.headers) { req.headers = []; } if (!req.query) { req.query = []; } if (!req.auth) { req.auth = []; } const method = req.method || HTTPMethod.GET; let body = req.body || ""; let contentType = req.contentType || ""; // 非此类请求,将body设置为空 if ( ![HTTPMethod.POST, HTTPMethod.PATCH, HTTPMethod.PUT].includes( method as HTTPMethod, ) ) { body = ""; contentType = ""; } body = await convertBody(collection, body); // 如果是form if (body && contentType === ContentType.Form) { const arr = JSON.parse(body) as KVParam[]; const result: string[] = []; arr.forEach((item) => { if (!item.enabled) { return; } result.push( `${window.encodeURIComponent(item.key)}=${window.encodeURIComponent( item.value, )}`, ); }); body = result.join("&"); } if (body && contentType === ContentType.Multipart) { const data = await convertMultipartForm(body); contentType = data.headers["Content-Type"]; body = data.body; } const params = { method: method, uri: req.uri, body, contentType, headers: req.headers, query: req.query, }; await convertKVParams(collection, params.query); await convertKVParams(collection, params.headers); if (isWebMode()) { const ms = Math.random() * 2000; await delay(ms); const headers = new Map(); headers.set("content-type", ["application/json"]); headers.set("set-cookie", ["uid=ZHGG9VYP; path=/; httponly"]); const resp = { api: id, req: req, latency: Math.ceil(ms), status: 200, bodySize: 0, headers, body: encode(JSON.stringify(params)), stats: { isHttps: false, cipher: "", remoteAddr: "127.0.0.1:80", dnsLookup: 1, tcp: 2, tls: 3, send: 0, serverProcessing: 4, contentTransfer: 5, total: 20, }, }; addLatestResponse(resp); return Promise.resolve(resp); } if (!userAgent) { userAgent = await getUserAgent(); } params.headers.push({ key: "User-Agent", value: userAgent, enabled: true, }); const auth = req.auth.filter((item) => item.enabled); if (auth.length) { const value = encode(`${auth[0].key}:${auth[0].value}`); params.headers.push({ key: "Authorization", value: `Basic ${value}`, enabled: true, }); } const requestTimeout = { connect: 10, write: 120, read: 300, }; if (timeout.connect && timeout.connect > 0) { requestTimeout.connect = timeout.connect; } if (timeout.write && timeout.write > 0) { requestTimeout.write = timeout.write; } if (timeout.read && timeout.read > 0) { requestTimeout.read = timeout.read; } // eslint-disable-next-line // @ts-ignore let resp: HTTPResponse = {}; const startedAt = Date.now(); try { resp = await run(cmdDoHTTPRequest, { req: params, api: id, timeout: requestTimeout, }); } catch (err) { resp.body = formatError(err); resp.latency = Date.now() - startedAt; } if (resp.latency <= 0) { resp.latency = 1; } // 转换为Map const headers = new Map(); forEach(resp.headers, (value, key) => { const k = key.toString(); if (isArray(value)) { headers.set(k, value); } else { headers.set(k, [value as string]); } }); resp.req = originalReq; resp.headers = headers; addLatestResponse(resp); return resp; } ================================================ FILE: src/commands/http_response.ts ================================================ import { decode } from "js-base64"; import dayjs from "dayjs"; import { forEach } from "lodash-es"; import mitt, { Emitter } from "mitt"; import { getLatestResponseStore } from "../stores/local"; import { HTTPRequest } from "./http_request"; import { ulid } from "ulid"; const applicationJSON = "application/json"; export interface HTTPStats { remoteAddr: string; isHttps: boolean; cipher: string; dnsLookup: number; tcp: number; tls: number; send: number; serverProcessing: number; contentTransfer: number; total: number; } export interface HTTPResponse { [key: string]: unknown; // response id id?: string; // api id api: string; req: HTTPRequest; // 原始body的大小(未解压) bodySize: number; // 耗时(ms) latency: number; status: number; headers: Map; body: string; stats: HTTPStats; } const selectEvent = "select"; type Events = { [selectEvent]: HTTPResponse; }; const emitter: Emitter = mitt(); export enum ResponseBodyCategory { JSON = "json", Binary = "binary", Text = "text", } export interface ResponseBodyResult { category: ResponseBodyCategory; data: string; size: number; json?: Map; } const statusTextMap = new Map(); (() => { const dict = { 100: "Continue", 101: "Switching Protocols", 102: "Processing", 103: "Early Hints", 200: "OK", 201: "Created", 202: "Accepted", 203: "Non-Authoritative Information", 204: "No Content", 205: "Reset Content", 206: "Partial Content", 207: "Multi-Status", 208: "Already Reported", 226: "IM Used", 300: "Multiple Choices", 301: "Moved Permanently", 302: "Found", 303: "See Other", 304: "Not Modified", 305: "Use Proxy", 307: "Temporary Redirect", 308: "Permanent Redirect", 400: "Bad Request", 401: "Unauthorized", 402: "Payment Required", 403: "Forbidden", 404: "Not Found", 405: "Method Not Allowed", 406: "Not Acceptable", 407: "Proxy Authentication Required", 408: "Request Timeout", 409: "Conflict", 410: "Gone", 411: "Length Required", 412: "Precondition Failed", 413: "Request Entity Too Large", 414: "Request URI Too Long", 415: "Unsupported Media Type", 416: "Requested Range Not Satisfiable", 417: "Expectation Failed", 418: "I'm a teapot", 421: "Misdirected Request", 422: "Unprocessable Entity", 423: "Locked", 424: "Failed Dependency", 425: "Too Early", 426: "Upgrade Required", 428: "Precondition Required", 429: "Too Many Requests", 431: "Request Header Fields Too Large", 451: "Unavailable For Legal Reasons", 500: "Internal Server Error", 501: "Not Implemented", 502: "Bad Gateway", 503: "Service Unavailable", 504: "Gateway Timeout", 505: "HTTP Version Not Supported", 506: "Variant Also Negotiates", 507: "Insufficient Storage", 508: "Loop Detected", 510: "Not Extended", 511: "Network Authentication Required", }; forEach(dict, (value, key) => { statusTextMap.set(key.toString(), value); }); })(); export function getStatusText(code: number) { return statusTextMap.get(code.toString()) || ""; } export function getResponseBody(resp: HTTPResponse): ResponseBodyResult { const { headers, body } = resp; let category = ResponseBodyCategory.Binary; let data = body; let size = -1; let json: Map = new Map(); let isJSON = false; headers.forEach((values, key) => { const k = key.toLowerCase(); switch (k) { case "content-type": { const mimeTextReg = /text|javascript/gi; const value = values.join(" "); if (value.includes(applicationJSON)) { category = ResponseBodyCategory.JSON; data = decode(data); json = JSON.parse(data); isJSON = true; // format data = JSON.stringify(json, null, 4); } else if (mimeTextReg.test(value)) { category = ResponseBodyCategory.Text; data = decode(data); } } break; case "content-length": { const v = Number.parseInt(values[0]); if (!Number.isNaN(v)) { size = v; } } break; } }); if (size < 0) { size = Math.ceil((body.length / 4) * 3); } const result: ResponseBodyResult = { category, data, size, }; if (isJSON) { result.json = json; } return result; } // 缓存的response数据 export interface Response { resp: HTTPResponse; createdAt: string; } // 每个响应保存的记录限制数 const limit = 10; export async function addLatestResponse(resp: HTTPResponse) { resp.id = ulid(); const id = resp.api; const store = getLatestResponseStore(); if (!id || !store) { return; } const arr = (await store.getItem(id)) || []; if (arr.length >= limit) { arr.pop(); } // 添加至顶部 arr.unshift({ resp, createdAt: dayjs().format(), }); await store.setItem(id, arr); } export async function getLatestResponseList(id: string) { const arr = (await getLatestResponseStore().getItem(id)) || []; return arr; } export async function clearLatestResponseList(id: string) { const store = getLatestResponseStore(); if (!id || !store) { return; } await store.setItem(id, []); } export async function getLatestResponse(id: string) { const arr = await getLatestResponseList(id); if (arr && arr.length) { return arr[0].resp; } } export function onSelectResponse(ln: (resp: HTTPResponse) => void) { const fn = (resp: HTTPResponse) => { ln(resp); }; emitter.on(selectEvent, fn); return () => { emitter.off(selectEvent, fn); }; } export function selectResponse(resp: HTTPResponse) { emitter.emit(selectEvent, resp); } ================================================ FILE: src/commands/import_api.ts ================================================ import { Promise } from "bluebird"; import { get, uniq, forEach, has } from "lodash-es"; import dayjs from "dayjs"; import { ulid } from "ulid"; import { SettingType } from "../stores/api_setting"; import { APIFolder, createAPIFolder, newDefaultAPIFolder } from "./api_folder"; import { APISetting, createAPISetting, newDefaultAPISetting, } from "./api_setting"; import { ContentType, HTTPRequest } from "./http_request"; import { KVParam } from "./interface"; import { createVariable, newDefaultVariable, Variable, VariableCategory, } from "./variable"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore import parseCurl from "../helpers/curl"; import { i18nCommon } from "../i18n"; interface PostManSetting { name: string; item?: PostManSetting[]; request?: { method: string; url: { raw: string; }; query: { key: string; value: string; }[]; body: { mode: string; raw: string; }; }; } interface InsomniaSetting { parentId: string; url: string; name: string; method: string; sort: number; data: Map; body: { mimeType: string; text: string; }; headers: { name: string; value: string; }[]; parameters: { name: string; value: string; }[]; _id: string; _type: string; } interface ImportData { settings: APISetting[]; folders: APIFolder[]; } export enum ImportCategory { PostMan = "postMan", Insomnia = "insomnia", Swagger = "swagger", File = "file", Text = "text", } function convertPostManAPISetting(item: PostManSetting, collection: string) { if (!item.request) { return; } const setting = newDefaultAPISetting(); setting.category = SettingType.HTTP; setting.collection = collection; setting.name = item.name; let contentType = ""; const body = item.request.body?.raw; if (body && body.startsWith("{") && body.endsWith("}")) { contentType = ContentType.JSON; } const query: KVParam[] = []; item.request.query?.forEach((q) => { query.push({ key: q.key, value: q.value, enabled: true, }); }); // TODO headers的处理 let uri = item.request.url?.raw || ""; if (uri && uri.includes("?")) { const arr = uri.split("?"); uri = arr[0]; // 去除前面host+path部分 arr.shift(); const url = new URL(`http://localhost/?${arr.join("?")}`); url.searchParams.forEach((value, key) => { query.push({ key, value, enabled: true, }); }); } const req: HTTPRequest = { headers: [], method: item.request.method, uri, contentType, query, body: body, auth: [], }; setting.setting = JSON.stringify(req); return setting; } function convertSwaggerSetting(params: { result: ImportData; // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types json: any; collection: string; environments: Variable[]; }) { const { result, json, collection, environments } = params; const name = get(json, "info.title") || "basePath"; const basePathENV = newDefaultVariable(); basePathENV.category = VariableCategory.Environment; basePathENV.name = name.replace(/ /g, ""); basePathENV.value = `${get(json, "schemes.0")}://${get(json, "host")}${get( json, "basePath", )}`; environments.push(basePathENV); const folderDict = new Map(); forEach(json.paths, (value, uri) => { forEach(value, (data, method) => { const setting = newDefaultAPISetting(); setting.collection = collection; setting.category = SettingType.HTTP; setting.name = get(data, "summary") || get(data, "operationId"); const query: KVParam[] = []; const headers: KVParam[] = []; let contentType = ""; let body = ""; forEach(get(data, "parameters"), (param) => { if (param.in === "query") { query.push({ key: param.name, value: get(param, "example") || "", enabled: true, }); } else if (param.in === "body") { contentType = ContentType.JSON; const defineKey: string = param.schema?.$ref?.substring(2); const bodyData: Record = {}; forEach( get(json, defineKey.replace(/\//g, ".") + ".properties"), (value, key) => { const v = value.example; if (value.type === "boolean") { bodyData[key] = v === "true"; } else if (value.type !== "string") { bodyData[key] = Number(v || 0); } else { bodyData[key] = v || ""; } }, ); body = JSON.stringify(bodyData, null, 4); } }); const req: HTTPRequest = { headers, method: method.toUpperCase(), uri: `{{${basePathENV.name}}}${uri}`, contentType, query, body, auth: [], }; setting.setting = JSON.stringify(req); result.settings.push(setting); const tag = get(data, "tags.0"); if (tag) { let folder = folderDict.get(tag); if (!folder) { folder = newDefaultAPIFolder(); folder.collection = collection; folder.name = tag; folderDict.set(tag, folder); result.folders.push(folder); } const children = folder.children.split(","); children.push(setting.id); folder.children = children.join(","); } }); }); } function convertPostManSetting(params: { result: ImportData; items: PostManSetting[]; collection: string; parentChildren: string[]; }) { const { result, items, collection, parentChildren } = params; if (!items || items.length === 0) { return; } items.forEach((item) => { // api 接口 if (item.request) { const setting = convertPostManAPISetting(item, collection); if (setting) { result.settings.push(setting); parentChildren.push(setting.id); } } else { // folder const folder = newDefaultAPIFolder(); result.folders.push(folder); parentChildren.push(folder.id); folder.collection = collection; folder.name = item.name; const subChildren: string[] = []; convertPostManSetting({ result, items: item.item || [], collection, parentChildren: subChildren, }); folder.children = subChildren.join(","); } }); } function convertInsomniaSetting(params: { result: ImportData; items: InsomniaSetting[]; collection: string; }) { const { result, collection, items } = params; const subChildrenMap: Map = new Map(); const parentIDMap: Map = new Map(); const addToParent = (id: string, parentID: string) => { const pid = parentIDMap.get(parentID); if (pid) { const arr = subChildrenMap.get(pid) || []; arr.push(id); subChildrenMap.set(pid, arr); } }; items.forEach((item) => { if (item._type === "request_group") { // folder const folder = newDefaultAPIFolder(); result.folders.push(folder); folder.collection = collection; folder.name = item.name; subChildrenMap.set(folder.id, []); parentIDMap.set(item._id, folder.id); addToParent(folder.id, item.parentId); return; } const setting = newDefaultAPISetting(); setting.category = SettingType.HTTP; setting.collection = collection; setting.name = item.name; const body = item.body.text; let contentType = ""; if (body && body.startsWith("{") && body.endsWith("}")) { contentType = ContentType.JSON; } const query: KVParam[] = []; item.parameters?.forEach((q) => { if (!q.value && !q.name) { return; } query.push({ key: q.name, value: q.value, enabled: true, }); }); const headers: KVParam[] = []; item.headers?.forEach((q) => { if (!q.value && !q.name) { return; } headers.push({ key: q.name, value: q.value, enabled: true, }); }); let uri = item.url || ""; if (uri && uri.includes("?")) { const arr = uri.split("?"); uri = arr[0]; // 去除前面host+path部分 arr.shift(); const url = new URL(`http://localhost/?${arr.join("?")}`); url.searchParams.forEach((value, key) => { query.push({ key, value, enabled: true, }); }); } const req: HTTPRequest = { headers: headers, method: item.method, uri, contentType, query, body: body, auth: [], }; setting.setting = JSON.stringify(req); addToParent(setting.id, item.parentId); result.settings.push(setting); }); result.folders.forEach((folder) => { const arr = subChildrenMap.get(folder.id); if (arr) { folder.children = arr.join(","); } }); } export async function importAPI(params: { category: ImportCategory; collection: string; fileData: string; }): Promise { let category = params.category; const { collection } = params; const result: ImportData = { settings: [], folders: [], }; if (params.fileData.startsWith("curl")) { const req = parseCurl(params.fileData); const id = ulid(); await createAPISetting({ category: SettingType.HTTP, collection: params.collection, name: i18nCommon("untitled"), id, setting: JSON.stringify(req), createdAt: dayjs().format(), updatedAt: dayjs().format(), }); return [id]; } const json = JSON.parse(params.fileData); const environments: Variable[] = []; if (has(json, "swagger")) { category = ImportCategory.Swagger; } else if (has(json, "item")) { category = ImportCategory.PostMan; } else if (has(json, "resources")) { category = ImportCategory.Insomnia; } switch (category) { case ImportCategory.Swagger: { convertSwaggerSetting({ result, json, collection, environments, }); break; } case ImportCategory.PostMan: { if (!Array.isArray(json.item)) { return []; } const arr = json.item as PostManSetting[]; convertPostManSetting({ result, items: arr, collection, parentChildren: [], }); forEach(json.variable as [], (item: { key: string; value: string }) => { const env = newDefaultVariable(); env.category = VariableCategory.Environment; env.name = item.key; env.value = item.value; environments.push(env); }); } break; case ImportCategory.Insomnia: { const items = get(json, "resources"); if (!Array.isArray(items)) { return []; } let arr = items as InsomniaSetting[]; arr.forEach((item) => { if (item._type === "environment") { forEach(item.data, (value, key) => { const env = newDefaultVariable(); env.category = VariableCategory.Environment; env.name = key; env.value = value as string; environments.push(env); }); return; } if (item._type === "request_group") { if (item.parentId.startsWith("wrk_")) { item.sort = 0; } else { item.sort = 1; } } else { item.sort = 2; } }); arr = arr.filter((item) => ["request", "request_group"].includes(item._type), ); arr.sort((item1, item2) => { return item1.sort - item2.sort; }); convertInsomniaSetting({ result, items: arr, collection, }); } break; case ImportCategory.Text: case ImportCategory.File: { const arr = Array.isArray(json) ? json : [json]; arr.forEach((item) => { item.collection = collection; if (item.category === SettingType.HTTP) { result.settings.push(item); } else { result.folders.push(item); } }); } break; default: throw new Error(`${category} is not supported`); break; } const topIDList: string[] = []; const childrenIDMap: Map = new Map(); result.folders.forEach((item) => { const children = item.children?.split(",") || []; children.forEach((id) => { childrenIDMap.set(id, true); }); }); await Promise.each(result.folders, async (item) => { if (!childrenIDMap.has(item.id)) { topIDList.push(item.id); } await createAPIFolder(item); }); await Promise.each(result.settings, async (item) => { if (!childrenIDMap.has(item.id)) { topIDList.push(item.id); } await createAPISetting(item); }); await Promise.each(environments, async (item) => { if (!item.name && !item.value) { return; } item.collection = collection; await createVariable(item); }); return uniq(topIDList); } ================================================ FILE: src/commands/interface.ts ================================================ export interface KVParam { [key: string]: unknown; key: string; value: string; enabled: boolean; } ================================================ FILE: src/commands/invoke.ts ================================================ import { invoke, InvokeArgs } from "@tauri-apps/api/tauri"; import Debug from "debug"; import { isWebMode } from "../helpers/util"; export const cmdInitTables = "init_tables"; export const cmdExportTables = "export_tables"; export const cmdImportTables = "import_tables"; export const cmdAddAPISetting = "add_api_setting"; export const cmdUpdateAPISetting = "update_api_setting"; export const cmdListAPISetting = "list_api_setting"; export const cmdDeleteAPISettings = "delete_api_settings"; export const cmdAddAPIFolder = "add_api_folder"; export const cmdListAPIFolder = "list_api_folder"; export const cmdUpdateAPIFolder = "update_api_folder"; export const cmdDeleteAPIFolder = "delete_api_folder"; export const cmdAddAPICollection = "add_api_collection"; export const cmdUpdateAPICollection = "update_api_collection"; export const cmdListAPICollection = "list_api_collection"; export const cmdDeleteAPICollection = "delete_api_collection"; export const cmdDoHTTPRequest = "do_http_request"; export const cmdListCookie = "list_cookie"; export const cmdDeleteCookie = "delete_cookie"; export const cmdAddCookie = "add_cookie"; export const cmdClearCookie = "clear_cookie"; export const cmdAddVariable = "add_variable"; export const cmdUpdateVariable = "update_variable"; export const cmdDeleteVariable = "delete_variable"; export const cmdListVariable = "list_variable"; export const cmdGetLatestVersion = "get_latest_version"; export const cmdAddVersion = "add_version"; const debug = Debug("invoke"); export async function run(cmd: string, args?: InvokeArgs): Promise { if (isWebMode()) { debug("invoke, cmd:%s, args:%o", cmd, args); // eslint-disable-next-line // @ts-ignore: mock return Promise.resolve(null); } try { const result = await invoke(cmd, args); debug("invoke, result:%o", result); return result; } catch (err) { // eslint-disable-next-line // @ts-ignore: mock const message = `[${err.category}]${err.message}`; throw new Error(message); } } ================================================ FILE: src/commands/variable.ts ================================================ import { ulid } from "ulid"; import dayjs from "dayjs"; import { isWebMode } from "../helpers/util"; import { cmdAddVariable, cmdDeleteVariable, cmdListVariable, cmdUpdateVariable, run, } from "./invoke"; import { fakeList, fakeAdd, fakeUpdate, fakeUpdateStore } from "./fake"; const store = "variables"; export enum VariableStatus { Enabled = "1", Disabled = "0", } export enum VariableCategory { // 环境变量 Environment = "env", // 自定义 Customize = "customize", // 全局请求头 GlobalReqHeaders = "globalReqHeaders", } export interface Variable { [key: string]: unknown; id: string; category: string; collection: string; // 名称 name: string; // 值 value: string; // 是否启用(0:禁用 1:启用) enabled: string; // 创建时间 createdAt: string; // 更新时间 updatedAt: string; } export function newDefaultVariable(): Variable { const id = ulid(); return { id, category: "", collection: "", name: "", value: "", enabled: VariableStatus.Enabled, createdAt: dayjs().format(), updatedAt: dayjs().format(), }; } export async function createVariable(value: Variable) { if (isWebMode()) { await fakeAdd(store, value); } await run(cmdAddVariable, { value, }); } export async function listVariable( collection: string, category: string, ): Promise { if (isWebMode()) { return await fakeList(store); } return await run(cmdListVariable, { collection, category, }); } export async function updateVariable(value: Variable) { if (isWebMode()) { return await fakeUpdate(store, value); return; } await run(cmdUpdateVariable, { value, }); } export async function deleteVariable(ids: string[]) { if (isWebMode()) { const arr = await fakeList(store); const result = arr.filter((item) => { return !ids.includes(item.id); }); await fakeUpdateStore(store, result); } await run(cmdDeleteVariable, { ids, }); } ================================================ FILE: src/commands/window.ts ================================================ import { run } from "./invoke"; import { appWindow, LogicalSize, getAll } from "@tauri-apps/api/window"; import { isWebMode } from "../helpers/util"; export function closeSplashscreen() { run("close_splashscreen"); } export function showSplashscreen() { if (isWebMode()) { return; } getAll().forEach((item) => { if (item.label === "splashscreen") { item.show(); } }); } export async function setWindowSize(width: number, height: number) { if (isWebMode()) { return; } // 如果有设置小于0,则最大化 if (width < 0 || height < 0) { await appWindow.maximize(); } else if (width > 0 && height > 0) { await appWindow.setSize(new LogicalSize(width, height)); } } ================================================ FILE: src/components/APIResponse/index.tsx ================================================ import { defineComponent, onBeforeUnmount, onMounted, PropType, ref, StyleValue, watch, } from "vue"; import { css } from "@linaria/core"; import prettyBytes from "pretty-bytes"; import { BowlingBallOutline, InformationCircleOutline, LinkOutline, } from "@vicons/ionicons5"; import { editor } from "monaco-editor/esm/vs/editor/editor.api"; import { HTTPResponse, ResponseBodyResult, getLatestResponse, getResponseBody, HTTPStats, } from "../../commands/http_response"; import { useSettingStore } from "../../stores/setting"; import { NDescriptions, NDescriptionsItem, NDivider, NIcon, NPopover, NSpace, useMessage, } from "naive-ui"; import { useRoute } from "vue-router"; import { padding } from "../../constants/style"; import { replaceContent, createEditor } from "../../helpers/editor"; import { i18nCollection, i18nCommon } from "../../i18n"; import { convertRequestToCURL, HTTPRequest } from "../../commands/http_request"; import { convertHTTPHeaderName, showError, writeFileToDownload, writeTextToClipboard, formatLatency, } from "../../helpers/util"; import ExPreview, { isSupportPreview } from "../ExPreview"; import ExTimer from "../ExTimer"; import APIResponseList from "./list"; import APIResponseStatusText from "./status_text"; import { toUint8Array } from "js-base64"; import { useCookieStore } from "../../stores/cookie"; import { useAPISettingStore } from "../../stores/api_setting"; const responseClass = css` margin-left: 5px; margin-right: 2px; .infos { height: 48px; line-height: 48px; padding: 0 ${padding}px; } .codeEditor, .previewWrapper { position: absolute; top: 50px; left: 5px; right: 2px; bottom: 0; overflow: auto; } .previewWrapper { z-index: 99; background-color: rgb(255, 255, 255); } .previewWrapper.isDark { background-color: rgb(30, 30, 30); } .n-divider { margin: 0; } .info { cursor: pointer; float: left; margin-top: 15px; padding: 0 2px; font-size: 20px; font-weight: 600; } .cookie { margin-top: 16px; } .header { padding: 0 5px; cursor: pointer; font-size: 16px; } .responseList { float: right; } `; const showCurlLimitSize = 2 * 1024; export default defineComponent({ name: "APIResponse", props: { response: { type: Object as PropType, default: () => { return { status: -1, headers: new Map(), body: "", }; }, }, }, setup(props) { const route = useRoute(); const message = useMessage(); const settingStore = useSettingStore(); const cookieStore = useCookieStore(); const apiSettingStore = useAPISettingStore(); let editorIns: editor.IStandaloneCodeEditor | null; const destroy = () => { if (editorIns) { editorIns = null; } }; const statusCode = ref(0); const size = ref(-1); const originalSize = ref(-1); const latency = ref(0); const apiID = ref(""); const stats = ref({} as HTTPStats); const headers = ref(new Map()); const collection = route.query.collection as string; const previewMode = ref(false); const previewData = ref({ contentType: "", data: "", }); let req: HTTPRequest; const reqExists = ref(false); const curl = ref(""); const fillValues = async (resp: HTTPResponse) => { // 初始加载时,读取最近的响应 let isFromCache = false; if (!resp.status) { const tmp = await getLatestResponse(resp.api); isFromCache = true; if (tmp) { resp = tmp; } } statusCode.value = resp.status; let body = { size: -1, } as ResponseBodyResult; if (resp.body) { body = getResponseBody(resp); } let contentType = ""; let filename = ""; resp.headers?.forEach((values, key) => { const k = key.toLowerCase(); switch (k) { case "content-type": contentType = values[0]; break; case "content-disposition": { const reg = /filename="([\s\S]*?)"/; const result = reg.exec(values[0]); if (result?.length === 2) { filename = result[1]; } } break; } }); if (isSupportPreview(contentType)) { previewMode.value = true; previewData.value = { contentType, data: body.data, }; editorIns?.setValue(""); } else { previewMode.value = false; } originalSize.value = resp.bodySize; size.value = body.size; latency.value = resp.latency; apiID.value = resp.api; headers.value = resp.headers; req = resp.req; if (!req) { reqExists.value = false; } else { reqExists.value = true; } curl.value = ""; stats.value = resp.stats; if (filename) { if (!isFromCache) { writeFileToDownload(filename, toUint8Array(body.data)) .then(() => { message.info(i18nCommon("saveToDownloadSuccess")); }) .catch((err) => { showError(message, err); }); } } else if (!previewMode.value) { replaceContent(editorIns, body.data); } }; const handleToCURL = async () => { if (!req || curl.value) { return; } try { // 由于cookie会一直更新,因此此时再拉取 await cookieStore.fetch(); // req 对象为未调用uri部分,需要先调整 apiSettingStore.fillValues(req); const value = await convertRequestToCURL( collection, req, cookieStore.cookies, ); if (value.length > showCurlLimitSize) { await writeTextToClipboard(value); } curl.value = value; } catch (err) { showError(message, err); } }; const stop = watch( () => props.response, (resp) => { fillValues(resp); editorIns?.setScrollTop(0); }, ); const codeEditor = ref(); const initEditor = () => { if (codeEditor.value) { editorIns = createEditor({ readonly: true, dom: codeEditor.value, isDark: settingStore.isDark, }); } }; onMounted(() => { initEditor(); if (props.response.api) { fillValues(props.response); } }); onBeforeUnmount(() => { destroy(); stop(); }); return { curl, reqExists, headers, size, originalSize, stats, latency, statusCode, apiID, previewMode, previewData, codeEditor, handleToCURL, isDark: settingStore.isDark, }; }, render() { const { statusCode, size, originalSize, latency, apiID, curl, headers, reqExists, stats, previewMode, previewData, } = this; let statusCodeInfo = ; if (statusCode === -1) { statusCodeInfo = ( {i18nCollection("requesting")} ); } else if (statusCode) { statusCodeInfo = ; } const apiIDSlots = { trigger: () => ( ), }; const headerSlots = { trigger: () => H, }; const curlSlots = { trigger: () => ( ), }; const cookieSlots = { trigger: () => ( ), }; const isTooLarge = curl.length > showCurlLimitSize; let curlText = i18nCollection("curlTooLargeTips"); if (!isTooLarge) { curlText = curl; } if (curl.length === 0) { curlText = i18nCollection("curlGenerateFail"); } const curlStyle: StyleValue = isTooLarge ? {} : { width: "400px", "word-break": "break-all", "word-wrap": "break-word", }; const descriptionItemOptions = [ { label: i18nCollection("apiID"), key: "apiID", value: apiID, }, ]; if (stats?.remoteAddr) { descriptionItemOptions.push({ label: i18nCollection("remoteAddr"), key: "remoteAddr", value: stats.remoteAddr, }); } if (stats?.cipher) { descriptionItemOptions.push({ label: i18nCollection("cipher"), key: "cipher", value: `${stats.cipher}`, }); } if (stats?.dnsLookup) { descriptionItemOptions.push({ label: i18nCollection("dns"), key: "dns", value: formatLatency(stats.dnsLookup), }); } if (stats) { descriptionItemOptions.push({ label: i18nCollection("tcp"), key: "tcp", value: formatLatency(stats.tcp), }); if (stats.isHttps) { descriptionItemOptions.push({ label: i18nCollection("tls"), key: "tls", value: formatLatency(stats.tls), }); } descriptionItemOptions.push( { label: i18nCollection("send"), key: "send", value: formatLatency(stats.send), }, { label: i18nCollection("serverProcessing"), key: "serverProcessing", value: formatLatency(stats.serverProcessing), }, { label: i18nCollection("contentTransfer"), key: "contentTransfer", value: formatLatency(stats.contentTransfer), }, ); } const headerDescriptionItems: JSX.Element[] = []; const cookieDescriptionItems: JSX.Element[] = []; const setCookieKey = "set-cookie"; if (headers && headers.size !== 0) { headers.forEach((values, key) => { values.forEach((value, index) => { if (key === setCookieKey) { const cookie = value.split(";")[0]; if (!cookie) { return; } const arr = cookie.split("="); cookieDescriptionItems.push( {arr.slice(1).join("=")} , ); return; } headerDescriptionItems.push( {value} , ); }); }); } const descriptionItems = descriptionItemOptions.map((item) => { return ( {item.value} ); }); const codeEditorCls = { hidden: false, }; if (previewMode) { codeEditorCls.hidden = true; } const previewWrapperCls = { previewWrapper: true, isDark: false, }; if (this.isDark) { previewWrapperCls.isDark = true; } const popupContentStyle: StyleValue = { maxWidth: "600px", wordBreak: "break-all", wordWrap: "break-word", }; const sizeDesc: string[] = []; if (size > 0) { sizeDesc.push(prettyBytes(size)); if (originalSize > 0 && Math.abs(size - originalSize) > 100) { const percent = `${Math.ceil((originalSize * 100) / size)}%`; sizeDesc.push(`${prettyBytes(originalSize)}(${percent})`); } } return (
{apiID && (
{" "} {" "}
)} {apiID && ( {descriptionItems} )} {cookieDescriptionItems.length !== 0 && ( {cookieDescriptionItems} )} {headerDescriptionItems.length !== 0 && ( {headerDescriptionItems} )} {reqExists && ( { if (value) { this.handleToCURL(); } }} >
{curlText}
)} {statusCodeInfo} {/* 占位 */} {sizeDesc.length !== 0 && sizeDesc.join(" / ")} {/* 占位 */} {latency > 0 && formatLatency(latency)}
{codeEditorCls.hidden && (
)}
); }, }); ================================================ FILE: src/components/APIResponse/list.tsx ================================================ import { NButton, NDivider, NDropdown, NIcon, NP, NSpace, useMessage, } from "naive-ui"; import { css } from "@linaria/core"; import prettyBytes from "pretty-bytes"; import { isNumber } from "lodash-es"; import { defineComponent, ref } from "vue"; import { clearLatestResponseList, getLatestResponseList, Response, selectResponse, } from "../../commands/http_response"; import { formatSimpleDate, showError } from "../../helpers/util"; import { ListOutline, TrashOutline } from "@vicons/ionicons5"; import { DropdownMixedOption } from "naive-ui/es/dropdown/src/interface"; import { i18nStore } from "../../i18n"; const showMoreClass = css` cursor: pointer; margin: 15px 10px 0 0; padding: 0 5px; `; export default defineComponent({ name: "APIResponseList", props: { id: { type: String, required: true, }, }, setup(props) { const message = useMessage(); const responseList = ref([] as Response[]); const handleFetch = async () => { responseList.value.length = 0; try { const arr = await getLatestResponseList(props.id); responseList.value = arr; } catch (err) { showError(message, err); } }; const handleSelect = (index: number) => { const resp = responseList.value[index]; if (resp) { selectResponse(resp.resp); } }; const handleClearHistory = async () => { try { await clearLatestResponseList(props.id); message.info(i18nStore("clearHistorySuccess")); responseList.value.length = 0; } catch (err) { showError(message, err); } }; return { responseList, handleFetch, handleSelect, handleClearHistory, }; }, render() { const { responseList } = this; const options: DropdownMixedOption[] = responseList.map((item, index) => { let bodySize = "--"; if (item.resp && isNumber(item.resp.bodySize)) { bodySize = prettyBytes(item.resp.bodySize); } return { label: () => ( {item.resp.status} {bodySize} {formatSimpleDate(item.createdAt)} ), key: index, }; }); const clearHistorySlots = { icon: () => ( ), }; const clearBtn = responseList?.length !== 0 && ( { this.handleClearHistory(); }} > {i18nStore("clearHistory")} ); const tips = responseList?.length === 0 && ( {i18nStore("noHistory")} ); options.unshift({ key: "header", type: "render", render: () => (
{clearBtn} {i18nStore("responseList")} {tips}
), }); return ( { this.handleSelect(value); }} onUpdateShow={(value) => { if (value) { this.handleFetch(); } }} options={options} showArrow={true} > ); }, }); ================================================ FILE: src/components/APIResponse/status_text.tsx ================================================ import { NGradientText } from "naive-ui"; import { defineComponent } from "vue"; import { getStatusText } from "../../commands/http_response"; function getStatusType(statusCode: number) { if (statusCode >= 500) { return "error"; } if (statusCode >= 400) { return "warning"; } return "success"; } export default defineComponent({ name: "APIResponseStatusText", props: { statusCode: { type: Number, required: true, }, }, render() { const { statusCode } = this.$props; return ( {statusCode} {getStatusText(statusCode)} ); }, }); ================================================ FILE: src/components/APISettingParams/index.tsx ================================================ import { defineComponent, watch, ref, onBeforeUnmount, PropType, VNode, } from "vue"; import { css } from "@linaria/core"; import { NDivider, useMessage } from "naive-ui"; import { storeToRefs } from "pinia"; import { cloneDeep, debounce } from "lodash-es"; import { useAPISettingStore } from "../../stores/api_setting"; import { abortRequestID, HTTPRequest } from "../../commands/http_request"; import { showError } from "../../helpers/util"; import { i18nCollection } from "../../i18n"; import APISettingParamsURI, { RequestURI } from "./uri"; import APISettingParamsReqParams from "./req_params"; import { KVParam } from "../../commands/interface"; import { onSelectResponse } from "../../commands/http_response"; const wrapperClass = css` height: 100%; position: relative; margin-left: 5px; .n-divider { margin: 0; } `; export default defineComponent({ name: "APISettingParams", props: { onSend: { type: Function as PropType<(id: string) => Promise>, required: true, }, }, setup() { const message = useMessage(); const apiSettingStore = useAPISettingStore(); const { selectedID } = storeToRefs(apiSettingStore); const componentKey = ref(selectedID.value); const reqParams = ref({} as HTTPRequest); const reqParamsStyle = ref({ height: "0px", }); const wrapper = ref(); let uriNodeHeight = 0; const caclWrapperHeight = () => { const height = wrapper.value?.clientHeight || 0; if (!height) { return; } reqParamsStyle.value.height = `${height - uriNodeHeight}px`; }; const updateURINodeHeight = (node: VNode) => { uriNodeHeight = node.el?.clientHeight || 0; caclWrapperHeight(); }; const updateReqParams = (id: string) => { try { if (id) { reqParams.value = apiSettingStore.getHTTPRequest(id); } else { reqParams.value = {} as HTTPRequest; } } catch (err) { console.error(err); } finally { caclWrapperHeight(); } }; const stop = watch(selectedID, (id) => { componentKey.value = id; updateReqParams(id); }); if (selectedID.value) { updateReqParams(selectedID.value); } const offListen = onSelectResponse((resp) => { reqParams.value = cloneDeep(resp.req); caclWrapperHeight(); const id = resp.id || `${Date.now()}`; componentKey.value = `${selectedID.value}-${id}`; }); onBeforeUnmount(() => { offListen(); stop(); }); const update = async () => { const id = selectedID.value; if (!id) { message.warning(i18nCollection("shouldSelectAPISettingFirst")); return; } const data = apiSettingStore.findByID(id); if (!data) { return; } try { let value = ""; if (reqParams.value) { value = JSON.stringify(reqParams.value); } data.setting = value; await apiSettingStore.updateByID(id, data); } catch (err) { showError(message, err); } }; const handleUpdateURI = async (data: RequestURI) => { Object.assign(reqParams.value, data); await update(); }; const handleUpdateBody = async ( id: string, params: { body: string; contentType: string; }, ) => { // 因为是延时执行,如果已经切换,则不更新 // 避免更新了其它接口的数据 if (id !== selectedID.value) { return; } reqParams.value.contentType = params.contentType; reqParams.value.body = params.body; await update(); }; const newHandleUpdate = (key: string) => { return async (id: string, data: KVParam[]) => { // 因为是延时执行,如果已经切换,则不更新 // 避免更新了其它接口的数据 if (id !== selectedID.value) { return; } reqParams.value[key] = data; await update(); }; }; const handleUpdateAuth = debounce(newHandleUpdate("auth"), 300); const handleUpdateQuery = debounce(newHandleUpdate("query"), 300); const handleUpdateHeaders = debounce(newHandleUpdate("headers"), 300); return { componentKey, reqParamsStyle, updateURINodeHeight, wrapper, selectedID, reqParams, // 避免频繁重复触发,不能设置过长 // 如果设置过长容易导致更新了还没生效 handleUpdateAuth, handleUpdateBody: debounce(handleUpdateBody, 300), handleUpdateURI, handleUpdateQuery, handleUpdateHeaders, }; }, render() { const { reqParams, selectedID, componentKey } = this; return (
{ this.updateURINodeHeight(node); }} onVnodeUpdated={(node) => { this.updateURINodeHeight(node); }} params={reqParams} onUpdateURI={(data) => { this.handleUpdateURI(data); }} onSubmit={(isAborted: boolean) => { if (isAborted) { return this.$props.onSend(abortRequestID); } return this.$props.onSend(selectedID); }} /> { this.handleUpdateBody(selectedID, value); }} onUpdateAuth={(value) => { this.handleUpdateAuth(selectedID, value); }} onUpdateQuery={(value) => { this.handleUpdateQuery(selectedID, value); }} onUpdateHeaders={(value) => { this.handleUpdateHeaders(selectedID, value); }} />
); }, }); ================================================ FILE: src/components/APISettingParams/req_params.tsx ================================================ import { NBadge, NButton, NButtonGroup, NDropdown, NIcon, NTab, NTabs, useDialog, useMessage, } from "naive-ui"; import { css } from "@linaria/core"; import { defineComponent, onBeforeUnmount, onMounted, PropType, ref, watch, } from "vue"; import { editor } from "monaco-editor/esm/vs/editor/editor.api"; import { HTTPMethod, HTTPRequest, ContentType, } from "../../commands/http_request"; import { useSettingStore } from "../../stores/setting"; import { i18nCollection, i18nCommon } from "../../i18n"; import { CaretDownOutline, CodeSlashOutline } from "@vicons/ionicons5"; import { showError, tryToParseArray } from "../../helpers/util"; import ExKeyValue, { HandleOption } from "../ExKeyValue"; import { KVParam } from "../../commands/interface"; import { padding } from "../../constants/style"; import { useAPICollectionStore } from "../../stores/api_collection"; import { replaceContent, createEditor } from "../../helpers/editor"; enum TabItem { Body = "Body", Query = "Query", Auth = "Auth", Header = "Header", } const tabClass = css` position: relative; .expandSelect { visibility: hidden; } .n-tabs:hover .expandSelect { visibility: visible; } .n-tabs-tab__label { .n-icon { margin-left: 5px; } .contentType { width: 60px; text-align: center; } } .badgeTab { position: relative; .badge { position: absolute; right: -15px; top: 8px; .n-badge-sup { padding: 0 3px !important; border-radius: 3px !important; } } } .hidden { display: none; } .format { position: fixed; bottom: 2px; .n-icon { font-size: 16px; font-weight: 900; margin-right: 5px; } } .content { position: absolute; top: 40px; left: 0; right: 0; bottom: 0; overflow: hidden; } .keyValue { margin: ${padding}px; } `; function shouldHaveBody(method: string) { return [HTTPMethod.POST, HTTPMethod.PUT, HTTPMethod.PATCH].includes( method as HTTPMethod, ); } function shouldShowEditor(contentType: string) { return [ContentType.JSON, ContentType.XML, ContentType.Plain].includes( contentType as ContentType, ); } function createBadgeTab(params: { tab: string; value: number; activeTab: string; }) { const { value, tab, activeTab } = params; const badge = value && tab !== activeTab ? ( ) : null; return ( {tab} {badge} ); } function createBodyBadge(params: { contentType: string; body: string }) { const { contentType, body } = params; if ( ![ContentType.Multipart, ContentType.Form].includes( contentType as ContentType, ) ) { return; } const arr = tryToParseArray(body); if (arr.length === 0) { return; } return ; } export default defineComponent({ name: "APISettingParamsReqParams", props: { id: { type: String, default: () => "", }, params: { type: Object as PropType, required: true, }, onUpdateBody: { type: Function as PropType< (params: { body: string; contentType: string }) => void >, required: true, }, onUpdateQuery: { type: Function as PropType<(query: KVParam[]) => void>, required: true, }, onUpdateHeaders: { type: Function as PropType<(headers: KVParam[]) => void>, required: true, }, onUpdateAuth: { type: Function as PropType<(auth: KVParam[]) => void>, required: true, }, }, setup(props) { const settingStore = useSettingStore(); const message = useMessage(); const dialog = useDialog(); const collectionStore = useAPICollectionStore(); const codeEditor = ref(); const contentType = ref(props.params.contentType || ContentType.JSON); let tab = collectionStore.getActiveTab(props.id); if (!tab) { if (shouldHaveBody(props.params.method)) { tab = TabItem.Body; } else { tab = TabItem.Query; } } const activeTab = ref(tab as TabItem); let editorIns: editor.IStandaloneCodeEditor | null; const destroy = () => { if (editorIns) { editorIns = null; } }; const handleEditorUpdate = () => { if (props.onUpdateBody && editorIns) { props.onUpdateBody({ body: editorIns.getValue().trim(), contentType: contentType.value, }); } }; const initEditor = () => { if (editorIns) { editorIns.setValue(props.params.body); return; } if (codeEditor.value) { editorIns = createEditor({ dom: codeEditor.value, isDark: settingStore.isDark, }); editorIns.setValue(props.params.body || ""); editorIns.onDidChangeModelContent(handleEditorUpdate); } }; const handleChangeContentType = (newContentType: string) => { // 如果无数据,直接切换 const changeContentType = () => { // 清空 replaceContent(editorIns, ""); if (props.onUpdateBody) { props.onUpdateBody({ body: "", contentType: newContentType, }); } contentType.value = newContentType; }; if (!props.params.body) { changeContentType(); return; } dialog.warning({ title: i18nCollection("changeContentType"), content: i18nCollection("changeContentTypeContent"), positiveText: i18nCommon("confirm"), onPositiveClick: async () => { changeContentType(); }, }); }; const getParamsFromHandleOption = (opt: HandleOption) => { const arr = [] as KVParam[]; opt.params.forEach((item) => { const { key, value } = item; if (!key && !value) { return; } arr.push({ key, value, enabled: item.enabled, }); }); return arr; }; const handleBodyParams = (opt: HandleOption) => { const arr = getParamsFromHandleOption(opt); if (props.onUpdateBody) { props.onUpdateBody({ body: JSON.stringify(arr), contentType: contentType.value, }); } }; const handleQueryParams = (opt: HandleOption) => { const arr = getParamsFromHandleOption(opt); if (props.onUpdateQuery) { props.onUpdateQuery(arr); } }; const handleHeaders = (opt: HandleOption) => { const arr = getParamsFromHandleOption(opt); if (props.onUpdateHeaders) { props.onUpdateHeaders(arr); } }; const handleAuth = (opt: HandleOption) => { const arr = getParamsFromHandleOption(opt); if (props.onUpdateAuth) { props.onUpdateAuth(arr); } }; const updateParamsColumnWidth = (width: number) => { settingStore.updateParamsColumnWidth(width); }; // method变化时要选定对应的tab const stop = watch( () => props.params.method, (method) => { if (shouldHaveBody(method)) { activeTab.value = TabItem.Body; } else { activeTab.value = TabItem.Query; } }, ); const handleUpdateActiveTab = async (activeTab: string) => { try { await collectionStore.updateActiveTab({ id: props.id, activeTab, }); } catch (err) { showError(message, err); } }; const handleFormat = () => { if (editorIns) { editorIns.getAction("editor.action.formatDocument")?.run(); } }; onMounted(() => { initEditor(); }); onBeforeUnmount(() => { stop(); destroy(); }); return { contentType, handleBodyParams, handleQueryParams, handleHeaders, handleAuth, handleChangeContentType, handleUpdateActiveTab, handleFormat, activeTab, codeEditor, updateParamsColumnWidth, }; }, render() { const { params } = this.$props; const { method } = params; const { activeTab, contentType } = this; const tabs = [TabItem.Query, TabItem.Header, TabItem.Auth]; if (shouldHaveBody(method)) { tabs.unshift(TabItem.Body); } let activeIndex = tabs.indexOf(activeTab); if (activeIndex < 0) { activeIndex = 0; } const contentTypeOptions = [ { label: "JSON", key: ContentType.JSON, }, { label: "Form", key: ContentType.Form, }, { label: "Multipart", key: ContentType.Multipart, }, { label: "XML", key: ContentType.XML, }, { label: "Plain", key: ContentType.Plain, }, ]; const list = tabs.map((item) => { switch (item) { case TabItem.Body: { const label = contentTypeOptions.find( (opt) => opt.key === contentType, ); if (activeTab !== TabItem.Body) { const badge = createBodyBadge({ contentType, body: params.body, }); return (
{label?.label}
{badge}
); } return ( { this.handleChangeContentType(value); }} >
{label?.label}
); } break; case TabItem.Query: return createBadgeTab({ activeTab, tab: item, value: params.query?.length, }); break; case TabItem.Header: { return createBadgeTab({ activeTab, tab: item, value: params.headers?.length, }); } break; case TabItem.Auth: { return createBadgeTab({ activeTab, tab: item, value: params.auth?.length, }); } break; default: return {item}; break; } }); let codeEditorClass = ""; if (activeTab !== TabItem.Body || !shouldShowEditor(contentType)) { codeEditorClass = "hidden"; } let showBodyKeyValue = false; let keyValues = []; switch (activeTab) { case TabItem.Body: { if (!shouldShowEditor(contentType)) { showBodyKeyValue = true; try { keyValues = tryToParseArray(this.params.body); } catch (err) { // 忽略parse出错 console.error(err); } } } break; case TabItem.Query: { keyValues = this.params.query || []; } break; case TabItem.Header: { keyValues = this.params.headers || []; } break; case TabItem.Auth: { keyValues = this.params.auth || []; } break; } const keyValueSpans = [8, 16]; const tabSlots = { suffix: () => ( { this.updateParamsColumnWidth(0.3); }} > 30% { this.updateParamsColumnWidth(0.5); }} > 50% { this.updateParamsColumnWidth(0.7); }} > 70% ), }; return (
{ let activeTab = value as string; if (shouldHaveBody(method)) { if (value === TabItem.Body) { activeTab = ""; } } else { if (value === TabItem.Query) { activeTab = ""; } } this.handleUpdateActiveTab(activeTab); this.activeTab = value; }} > {list}
{/* json, xml, text */}
{activeTab === TabItem.Body && contentType === ContentType.JSON && ( { this.handleFormat(); }} > {i18nCollection("format")} )} {/* body form/multipart */} {showBodyKeyValue && ( { this.handleBodyParams(opt); }} /> )} {activeTab === TabItem.Query && ( { this.handleQueryParams(opt); }} /> )} {activeTab === TabItem.Header && ( { this.handleHeaders(opt); }} /> )} {activeTab === TabItem.Auth && ( { this.handleAuth(opt); }} /> )}
); }, }); ================================================ FILE: src/components/APISettingParams/uri.tsx ================================================ import { defineComponent, PropType, ref } from "vue"; import { css } from "@linaria/core"; import { NButton, NInput, NInputGroup, NSelect, NIcon, NDropdown, NGradientText, } from "naive-ui"; import { ulid } from "ulid"; import { storeToRefs } from "pinia"; import { CodeSlashOutline } from "@vicons/ionicons5"; import { i18nCollection, i18nEnvironment } from "../../i18n"; import { HTTPRequest, HTTPMethod } from "../../commands/http_request"; import { useEnvironmentStore, ENVRegexp } from "../../stores/environment"; import { useDialogStore } from "../../stores/dialog"; import { VariableStatus } from "../../commands/variable"; const environmentSelectWidth = 50; const wrapperClass = css` padding: 7px 4px 5px 0; overflow: hidden; .environmentSelect { width: ${environmentSelectWidth}px; float: left; .n-icon { font-size: 16px; font-weight: 900; } } .url { margin-left: ${environmentSelectWidth}px; .method { width: 120px; .n-base-selection { height: 36px; } } .submit { width: 80px; height: 36px; } } .n-input, .n-base-selection-label { background-color: transparent !important; line-break: anywhere; } `; const envLabelClass = css` padding: 0 5px; span { margin-left: 10px; } .n-icon { font-weight: 900; font-size: 16px; } `; interface CuttingURIResult { env: string; uri: string; } function cuttingURI(uri: string): CuttingURIResult { const result = { env: "", uri, }; const arr = ENVRegexp.exec(uri); if (arr?.length === 2) { result.env = arr[1].trim(); result.uri = uri.substring(arr[0].length); } return result; } export interface RequestURI { method: string; uri: string; } const addNewENVKey = ulid(); const clearENVKey = ulid(); export default defineComponent({ name: "APISettingParamsURI", props: { params: { type: Object as PropType, required: true, }, onUpdateURI: { type: Function as PropType<(value: RequestURI) => void>, required: true, }, onSubmit: { type: Function as PropType<(isAborted: boolean) => Promise>, required: true, }, }, setup(props) { const dialogStore = useDialogStore(); const environmentStore = useEnvironmentStore(); const environments = storeToRefs(environmentStore).variables; const uriResult = cuttingURI(props.params.uri); const currentURI = ref(uriResult.uri); const env = ref(uriResult.env); const method = ref(props.params.method); const sending = ref(false); const showEnvironment = () => { dialogStore.toggleEnvironmentDialog(true); }; const handleUpdate = () => { let uri = currentURI.value || ""; if (env.value) { uri = `{{${env.value}}}${uri}`; } const changed = uri !== props.params.uri || method.value !== props.params.metod; if (changed && props.onUpdateURI) { props.onUpdateURI({ method: method.value, uri, }); } }; let currentID = ""; const isCurrent = (id: string) => { return id === currentID; }; let lastHandleSendAt = 0; const handleSend = async () => { if (!props.onSubmit) { return; } const now = Date.now(); // 如果快速点击 // 直接忽略第二次点击 if (now - lastHandleSendAt < 200) { return; } lastHandleSendAt = now; // 如果发送中,则中止请求 if (sending.value) { sending.value = false; currentID = ""; await props.onSubmit(true); return; } const id = ulid(); currentID = id; sending.value = true; try { await props.onSubmit(false); } finally { // 只有当前id才重置状态 if (isCurrent(id)) { sending.value = false; } } }; return { sending, handleSend, showEnvironment, handleUpdate, environments, method, env, currentURI, }; }, render() { const { environments, currentURI, env, method } = this; const options = [ HTTPMethod.GET, HTTPMethod.POST, HTTPMethod.PUT, HTTPMethod.PATCH, HTTPMethod.DELETE, HTTPMethod.OPTIONS, HTTPMethod.HEAD, ].map((item) => { return { label: item, value: item, }; }); // 只过滤启用的 const envOptions = environments .filter((item) => item.enabled === VariableStatus.Enabled) .map((item) => { return { label: `${item.name} | ${item.value}`, key: item.name, }; }); let envPrefix = ""; if (env) { envPrefix = env.substring(0, 2).toUpperCase(); } envOptions.push({ label: i18nEnvironment("addNew"), key: addNewENVKey, }); if (this.env) { envOptions.push({ label: i18nEnvironment("clearCurrent"), key: clearENVKey, }); } const autoSizeOption = { minRows: 1, maxRows: 3 }; return (
{ const label = (option.label as string) || ""; const arr = label.split(" | "); return ( {arr[0]} {arr[1] && {arr[1]}} ); }} value={env} onSelect={(value) => { if (value === addNewENVKey) { this.showEnvironment(); return; } if (value === clearENVKey) { this.env = ""; } else { this.env = value; } this.handleUpdate(); }} > {!envPrefix && ( )} {envPrefix && {envPrefix}}
{ this.method = value; this.handleUpdate(); }} /> { this.handleUpdate(); }} onUpdateValue={(value) => { this.currentURI = value?.trim(); }} onKeydown={(e) => { if (e.key.toLowerCase() === "enter" && this.currentURI) { this.handleSend(); e.preventDefault(); } }} /> { this.handleSend(); }} > {this.sending ? i18nCollection("abort") : i18nCollection("send")}
); }, }); ================================================ FILE: src/components/APISettingTree/header.tsx ================================================ // API栏目的顶部功能栏 import { defineComponent, inject, onBeforeUnmount, PropType } from "vue"; import { css } from "@linaria/core"; import { NDropdown, NButton, NGi, NGrid, NInput, NIcon, useMessage, useDialog, } from "naive-ui"; import { DropdownMixedOption } from "naive-ui/es/dropdown/src/interface"; import { i18nCollection, i18nCommon } from "../../i18n"; import { SettingType, useAPISettingStore } from "../../stores/api_setting"; import { AnalyticsOutline, DownloadOutline, FolderOpenOutline, } from "@vicons/ionicons5"; import { hotKeyCreateFolder, hotKeyCreateHTTPSetting, hotKeyMatchCreateFolder, hotKeyMatchCreateHTTPSetting, } from "../../helpers/hot_key"; import { addFolderDefaultValue, addFolderKey, addHTTPSettingDefaultValue, addHTTPSettingKey, } from "../../constants/provide"; import { readTextFromClipboard, showError, writeSettingToDownload, } from "../../helpers/util"; import { useRoute } from "vue-router"; import { useAPIFolderStore } from "../../stores/api_folder"; import { useAPICollectionStore } from "../../stores/api_collection"; import { HandleKey } from "../../constants/handle_key"; import { newImportDialog } from "../ExDialog"; const collapseWidth = 50; const headerClass = css` margin-right: ${collapseWidth}px; position: relative; .collapse { position: absolute; top: 0; right: ${-collapseWidth}px; bottom: 0; width: ${collapseWidth}px; .n-button { margin-left: 10px; } } `; const addDropdownClass = css` .label { min-width: 180px; } .hotKey { float: right; } `; export default defineComponent({ name: "APISettingTreeHeader", props: { onFilter: { type: Function as PropType<(value: string) => void>, required: true, }, }, setup() { const route = useRoute(); const message = useMessage(); const dialog = useDialog(); const apiFolderStore = useAPIFolderStore(); const apiSettingStore = useAPISettingStore(); const collectionStore = useAPICollectionStore(); const collection = route.query.collection as string; const addHTTPSetting = inject( addHTTPSettingKey, addHTTPSettingDefaultValue, ); const addFolder = inject(addFolderKey, addFolderDefaultValue); const handleKeydown = (e: KeyboardEvent) => { if (hotKeyMatchCreateFolder(e)) { addFolder(""); return; } if (hotKeyMatchCreateHTTPSetting(e)) { addHTTPSetting(""); return; } }; document.addEventListener("keydown", handleKeydown); onBeforeUnmount(() => { document.removeEventListener("keydown", handleKeydown); }); const handleCloseAllFolders = async () => { try { await collectionStore.closeAllFolders(collection); } catch (err) { showError(message, err); } }; const handleImport = async () => { let data = ""; try { data = (await readTextFromClipboard()) || ""; } catch (err) { showError(message, err); } finally { newImportDialog({ dialog, collection, data, }); } }; const handleExport = async () => { const arr: unknown[] = []; apiFolderStore.apiFolders.forEach((folder) => arr.push(folder)); apiSettingStore.apiSettings.forEach((apiSetting) => arr.push(apiSetting)); try { let name = "unknown"; const result = collectionStore.findByID(collection); if (result) { name = result.name; } await writeSettingToDownload(arr, name); message.info(i18nCollection("exportSettingsSuccess")); } catch (err) { showError(message, err); } }; return { handleImport, handleExport, addHTTPSetting, addFolder, handleCloseAllFolders, text: { add: i18nCommon("add"), placeholder: i18nCollection("filterPlaceholder"), }, }; }, render() { const options: DropdownMixedOption[] = [ { label: `${i18nCollection( "newHTTPRequest", )} | ${hotKeyCreateHTTPSetting()}`, key: SettingType.HTTP, icon: () => ( ), }, { label: `${i18nCollection("newFolder")} | ${hotKeyCreateFolder()}`, key: SettingType.Folder, icon: () => ( ), }, { type: "divider", key: "divider", }, { label: i18nCollection("exportSettings"), key: HandleKey.ExportSettings, icon: () => ( ), }, { label: i18nCollection("importSettings"), key: HandleKey.ImportSettings, icon: () => ( ), }, ]; const { text } = this; const inputProps = { spellcheck: false, }; return (
{ this.$props.onFilter(value.toLowerCase()); }} /> { const arr = (option.label as string).split(" | "); const hotkey = arr.length === 2 ? ( {arr[1]} ) : undefined; return (
{arr[0]} {hotkey}
); }} onSelect={(key: string) => { switch (key) { case SettingType.HTTP: this.addHTTPSetting(""); break; case SettingType.Folder: this.addFolder(""); break; case HandleKey.ImportSettings: this.handleImport(); break; case HandleKey.ExportSettings: this.handleExport(); break; } }} > {text.add}
{ this.handleCloseAllFolders(); }} >
); }, }); ================================================ FILE: src/components/APISettingTree/index.tsx ================================================ // API应用配置列表 import { css } from "@linaria/core"; import { defineComponent, provide, ref } from "vue"; import { useDialog, useMessage } from "naive-ui"; import { SettingType, useAPISettingStore } from "../../stores/api_setting"; import APISettingTreeHeader from "./header"; import APISettingTreeItems from "./items"; import ExDialog from "../ExDialog"; import { i18nCollection, i18nCommon } from "../../i18n"; import { ExFormItem } from "../ExForm"; import { newDefaultAPISetting } from "../../commands/api_setting"; import { useRoute } from "vue-router"; import { showError } from "../../helpers/util"; import { useAPIFolderStore } from "../../stores/api_folder"; import { newDefaultAPIFolder } from "../../commands/api_folder"; import { addFolderKey, addHTTPSettingKey } from "../../constants/provide"; const treesClass = css` padding: 10px; `; const getSettingFormItems = (): ExFormItem[] => { return [ { key: "name", label: i18nCommon("name"), placeholer: i18nCommon("namePlaceholder"), rule: { required: true, message: i18nCommon("nameRequireError"), trigger: "blur", }, }, ]; }; const getFolderFormItems = (): ExFormItem[] => { return [ { key: "name", label: i18nCommon("name"), placeholer: i18nCommon("namePlaceholder"), rule: { required: true, message: i18nCommon("nameRequireError"), trigger: "blur", }, }, ]; }; export default defineComponent({ name: "APISettingTree", setup() { const keyword = ref(""); const apiSettingStore = useAPISettingStore(); const apiFolderStore = useAPIFolderStore(); const dialog = useDialog(); const route = useRoute(); const message = useMessage(); const collection = route.query.collection as string; provide(addHTTPSettingKey, (folder: string) => { ExDialog({ dialog, title: i18nCollection("newHTTPRequest"), formItems: getSettingFormItems(), onConfirm: async (data) => { const setting = newDefaultAPISetting(); setting.category = SettingType.HTTP; setting.collection = collection; setting.name = data.name as string; try { await apiSettingStore.add(setting); if (folder) { await apiFolderStore.addChild({ id: folder, children: [setting.id], }); } apiSettingStore.select(setting.id); } catch (err) { showError(message, err); } }, }); }); provide(addFolderKey, (parentFolder: string) => { ExDialog({ dialog, title: i18nCollection("newFolder"), formItems: getFolderFormItems(), onConfirm: async (data) => { const folder = newDefaultAPIFolder(); folder.collection = collection; folder.name = data.name as string; try { await apiFolderStore.add(folder); if (folder) { await apiFolderStore.addChild({ id: parentFolder, children: [folder.id], }); } } catch (err) { showError(message, err); } }, }); }); return { keyword, }; }, render() { return (
{ this.keyword = value; }} />
); }, }); ================================================ FILE: src/components/APISettingTree/item_dropdown.tsx ================================================ // API功能下拉选项框 import { AddOutline, ChevronDownOutline, CopyOutline, CreateOutline, DownloadOutline, FolderOpenOutline, LinkOutline, PinOutline, TrashOutline, } from "@vicons/ionicons5"; import { NDropdown, NIcon, useDialog, useMessage } from "naive-ui"; import { defineComponent, inject } from "vue"; import { useRoute } from "vue-router"; import { css } from "@linaria/core"; import { DropdownMixedOption } from "naive-ui/es/dropdown/src/interface"; import { readTextFromClipboard, showError, writeSettingToDownload, writeTextToClipboard, } from "../../helpers/util"; import { i18nCollection, i18nCommon } from "../../i18n"; import { useAPIFolderStore } from "../../stores/api_folder"; import { SettingType, useAPISettingStore } from "../../stores/api_setting"; import { HandleKey } from "../../constants/handle_key"; import ExDialog, { newImportDialog } from "../ExDialog"; import { addFolderDefaultValue, addFolderKey, addHTTPSettingDefaultValue, addHTTPSettingKey, } from "../../constants/provide"; import { usePinRequestStore } from "../../stores/pin_request"; import { convertRequestToCURL } from "../../commands/http_request"; import { APISetting } from "../../commands/api_setting"; import { APIFolder } from "../../commands/api_folder"; import { useCookieStore } from "../../stores/cookie"; const dropdownClass = css` .n-dropdown-option { margin: 2px 0; } `; export default defineComponent({ name: "APISettingTreeItemDropdown", props: { id: { type: String, required: true, }, apiSettingType: { type: String, required: true, }, }, setup(props) { const dialog = useDialog(); const message = useMessage(); const cookieStore = useCookieStore(); const apiFolderStore = useAPIFolderStore(); const pinRequestStore = usePinRequestStore(); const apiSettingStore = useAPISettingStore(); const route = useRoute(); const collection = route.query.collection as string; const addHTTPSetting = inject( addHTTPSettingKey, addHTTPSettingDefaultValue, ); const addFolder = inject(addFolderKey, addFolderDefaultValue); const hanldeImport = async (id: string) => { try { const data = (await readTextFromClipboard()) || ""; newImportDialog({ dialog, collection, folder: id, data, }); } catch (err) { showError(message, err); } }; const handleExport = async (id: string) => { try { const apiSettings: APISetting[] = []; const folders: APIFolder[] = []; const name = apiFolderStore.findByID(id).name; const folderIdList:string[] = []; const appendChildren = (folderId: string) => { if (folderIdList.includes(folderId)) { return; } folderIdList.push(folderId); const folder = apiFolderStore.findByID(folderId); if (!folder) { return; } folder.children.split(",").forEach((child) => { const apiSetting = apiSettingStore.findByID(child); if (apiSetting) { apiSettings.push(apiSetting); } else { // folder appendChildren(child); } }); }; appendChildren(id); const arr: unknown[] = []; folders.forEach((folder) => arr.push(folder)); apiSettings.forEach((apiSetting) => arr.push(apiSetting)); await writeSettingToDownload(arr, name); message.info(i18nCollection("exportSettingsSuccess")); } catch (err) { showError(message, err); } }; const handleSelect = (key: string) => { const { id, apiSettingType } = props; let name = ""; let isFolder = false; if (apiSettingType === SettingType.Folder) { isFolder = true; name = apiFolderStore.findByID(id).name; } else { name = apiSettingStore.findByID(id).name; } switch (key) { case HandleKey.Delete: { const content = i18nCollection("deleteSettingContent").replace( "%s", name, ); const d = dialog.warning({ title: i18nCollection("deleteSetting"), content: content, positiveText: i18nCommon("confirm"), onPositiveClick: async () => { d.loading = true; try { if (isFolder) { await apiFolderStore.remove(id); } else { await apiSettingStore.remove(id); } } catch (err) { showError(message, err); } }, }); } break; case HandleKey.Modify: { ExDialog({ dialog, title: i18nCollection("modifySetting"), formItems: [ { key: "name", label: i18nCommon("name"), defaultValue: name, placeholer: i18nCommon("namePlaceholder"), rule: { required: true, message: i18nCommon("nameRequireError"), trigger: "blur", }, }, ], onConfirm: async (data) => { try { if (isFolder) { await apiFolderStore.updateByID(id, data); } else { await apiSettingStore.updateByID(id, data); } } catch (err) { showError(message, err); } }, }); } break; case HandleKey.Create: { addHTTPSetting(id); } break; case HandleKey.CreateFolder: { addFolder(id); } break; case HandleKey.CopyAsCURL: { const { req } = apiSettingStore.getHTTPRequestFillValues(id); cookieStore .fetch() .then(() => { return convertRequestToCURL( collection, req, cookieStore.cookies, ); }) .then(writeTextToClipboard) .then(() => { message.success(i18nCollection("copyAsCURLSuccess")); }) .catch((err) => { showError(message, err); }); } break; case HandleKey.Pin: { pinRequestStore.add(collection, { id, }); } break; case HandleKey.ImportSettings: { hanldeImport(id); } break; case HandleKey.ExportSettings: { handleExport(id); } break; case HandleKey.Copy: { const setting = apiSettingStore.findByID(id); writeTextToClipboard(JSON.stringify(setting, null, 2)) .then(() => { message.success(i18nCollection("copySettingSuccess")); }) .catch((err) => { showError(message, err); }); } break; default: break; } }; return { handleSelect, }; }, render() { const { apiSettingType } = this.$props; const options: DropdownMixedOption[] = [ { label: i18nCollection("modifySetting"), key: HandleKey.Modify, icon: () => ( ), }, ]; if (apiSettingType === SettingType.Folder) { options.unshift( { label: i18nCollection("newHTTPRequest"), key: HandleKey.Create, icon: () => ( ), }, { label: i18nCollection("newFolder"), key: HandleKey.CreateFolder, icon: () => ( ), }, ); options.push( { label: i18nCollection("exportSettings"), key: HandleKey.ExportSettings, icon: () => ( ), }, { label: i18nCollection("importSettings"), key: HandleKey.ImportSettings, icon: () => ( ), }, ); } else { options.push( { label: i18nCollection("copyAsCURL"), key: HandleKey.CopyAsCURL, icon: () => ( ), }, { label: i18nCollection("pinRequest"), key: HandleKey.Pin, icon: () => ( ), }, { label: i18nCollection("copySetting"), key: HandleKey.Copy, icon: () => ( ), }, ); } options.push( { type: "divider", key: HandleKey.Divider, }, { label: i18nCollection("deleteSetting"), key: HandleKey.Delete, icon: () => ( ), }, ); return ( { return {option.label}; }} > { e.stopPropagation(); }} > ); }, }); ================================================ FILE: src/components/APISettingTree/items.tsx ================================================ // API列表,实现拖动 import { defineComponent, ref, onBeforeUnmount } from "vue"; import { storeToRefs } from "pinia"; import { css } from "@linaria/core"; import { NGradientText, NInput, useMessage } from "naive-ui"; import { sortBy, uniq } from "lodash-es"; import { useRoute } from "vue-router"; import { useAPIFolderStore } from "../../stores/api_folder"; import { isMatchTextOrPinYin, showError } from "../../helpers/util"; import { useAPISettingStore, SettingType } from "../../stores/api_setting"; import { APIFolder } from "../../commands/api_folder"; import { APISetting } from "../../commands/api_setting"; import { useSettingStore } from "../../stores/setting"; import { useAPICollectionStore } from "../../stores/api_collection"; import { nodeAddClass, nodeGetDataValue, nodeGetOffset, nodeGetOffsetHeightWidth, nodGetScrollTop, nodeInsertAt, nodeRemove, nodeRemoveClass, nodeSetStyle, nodeHasClass, } from "../../helpers/html"; import APISettingTreeItemDropdown from "./item_dropdown"; import { HTTPMethod } from "../../commands/http_request"; import { openFolderIcon, closeFolderIcon } from "../../icons"; const itemsWrapperClass = css` user-select: none; position: absolute; top: 50px; left: 5px; right: 5px; bottom: 5px; overflow-y: auto; overflow-x: hidden; &.dragging { li:hover { background-color: rgba(255, 255, 255, 0) !important; } .dragItem { &:hover { background-color: rgba(255, 255, 255, 0.3) !important; } &.light:hover { background-color: rgba(0, 0, 0, 0.3) !important; } } } ul { margin: 0; padding: 0; } li { list-style: none; padding: 3px 10px; line-height: 34px; height: 34px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; .method { margin: 0 8px 0 5px; font-size: 12px; } &.renameItem { // 避免出现... text-overflow: initial; &:hover .itemDropitem { display: none; } } .renameInput { width: 86% !important; } &.insertBefore { padding-top: 2px; border-top: 1px dashed; } &:hover { cursor: pointer; background-color: rgba(255, 255, 255, 0.1); .itemDropitem { display: inline; } } &.selected { background-color: rgba(255, 255, 255, 0.1); color: #fff; } &.light:hover { background-color: rgba(30, 30, 30, 0.1); } &.light.selected { background-color: rgba(30, 30, 30, 0.1); color: #000; } .itemDropitem { float: right; display: none; } .folder { display: block; float: left; width: 30px; height: 100%; background-size: 20px; background-repeat: no-repeat; background-position: center; &.close { background-image: url(${closeFolderIcon}); } &.open { background-image: url(${openFolderIcon}); } } } .n-icon { float: left; font-size: 16px; line-height: 29px; margin: 5px 5px 0 10px; font-weight: 900; } `; enum OverType { Over = 0, Top = 1, Bottom = 2, } interface TreeItem { id: string; name: string; settingType: string; method: string; uri: string; children: TreeItem[]; expanded: boolean; parent: string; childIndex: number; hidden: boolean; isLastChild: boolean; } function getMethodColorType(method: string) { switch (method) { case HTTPMethod.DELETE: return "error"; break; case HTTPMethod.PATCH: case HTTPMethod.PUT: case HTTPMethod.POST: return "success"; break; default: return "info"; break; } } function convertToTreeItems(params: { apiFolders: APIFolder[]; apiSettings: APISetting[]; expandedFolders: string[]; topTreeItems: string[]; keyword: string; }): TreeItem[] { const { apiFolders, apiSettings, expandedFolders, topTreeItems } = params; const map = new Map(); const keyword = params.keyword.toLowerCase(); const methodReg = /"method":"(\S+?)"/; const uriReg = /"uri":"(\S+?)"/; apiSettings.forEach((item) => { let method = ""; let uri = ""; if (item.setting) { let result = methodReg.exec(item.setting); if (result?.length === 2) { method = result[1]; } result = uriReg.exec(item.setting); if (result?.length === 2) { uri = result[1].replace(/{{\S+}}/, ""); } } map.set(item.id, { method, uri, id: item.id, name: item.name, settingType: SettingType.HTTP, children: [], expanded: false, parent: "", childIndex: -1, hidden: false, isLastChild: false, }); }); apiFolders.forEach((item) => { map.set(item.id, { id: item.id, uri: "", name: item.name, settingType: SettingType.Folder, children: [], expanded: expandedFolders.includes(item.id), parent: "", childIndex: -1, hidden: false, method: "", isLastChild: false, }); }); // 记录已经设置为子元素的id const children = [] as string[]; apiFolders.forEach((item) => { if (!item.children) { return; } const treeItem = map.get(item.id); if (!treeItem) { return; } const arr: string[] = []; item.children.split(",").forEach((child) => { if (!child || child === treeItem.id) { return; } arr.push(child); }); const childCount = arr.length; arr.forEach((child, index) => { const subItem = map.get(child); if (!subItem) { return; } subItem.parent = treeItem.id; subItem.childIndex = treeItem.children.length; subItem.isLastChild = index === childCount - 1; treeItem.children.push(subItem); children.push(child); }); }); let result = [] as TreeItem[]; map.forEach((item, key) => { if (children.includes(key)) { return; } result.push(item); }); if (keyword) { const methodKeyword = keyword.toUpperCase(); const shouldBeHide = (item: TreeItem) => { // 匹配method // 匹配url // 如果当前元素匹配,则其子元素展示 if ( item.method === methodKeyword || item.uri.toLowerCase().includes(keyword) || isMatchTextOrPinYin(item.name, keyword) ) { return; } let hidden = true; item.children.forEach((item) => { shouldBeHide(item); // 子元素有一个非hidden,则父元素非hidden if (!item.hidden) { hidden = false; } }); item.hidden = hidden; }; result.forEach(shouldBeHide); } const filterVisible = (item: TreeItem) => { if (item.hidden) { return false; } item.children = item.children.filter(filterVisible); return true; }; result = result.filter(filterVisible); return sortBy(result, (item) => { return topTreeItems.indexOf(item.id); }); } export default defineComponent({ name: "APISettingTreeItems", props: { keyword: { type: String, default: () => "", }, }, setup() { const message = useMessage(); const route = useRoute(); const wrapper = ref(null); const collection = route.query.collection as string; const collectionStore = useAPICollectionStore(); const apiFolderStore = useAPIFolderStore(); const apiSettingStore = useAPISettingStore(); const { apiFolders } = storeToRefs(apiFolderStore); const { expandedFolders, topTreeItems } = storeToRefs(collectionStore); const { isDark } = storeToRefs(useSettingStore()); const { apiSettings, selectedID } = storeToRefs(apiSettingStore); let currentTreeItems = [] as TreeItem[]; let topTreeItemIDList = [] as string[]; const renameItem = ref({ name: "", id: "", }); const renameValue = ref(""); const setTreeItems = (items: TreeItem[], topItems: string[]) => { currentTreeItems = items; topTreeItemIDList = topItems; }; const handleClick = async (item: TreeItem) => { try { // folder的处理 if (item.settingType === SettingType.Folder) { let fn = collectionStore.openFolder; if (item.expanded) { fn = collectionStore.closeFolder; } await fn(collection, item.id); } else { apiSettingStore.select(item.id); } } catch (err) { showError(message, err); } }; const handleMove = async ( moveIndex: number, targetIndex: number, overType: OverType, ) => { // TODO 如果是最后一个元素的处理 // isOver move 与 target 是否重叠 const moveItem = currentTreeItems[moveIndex]; const targetItem = currentTreeItems[targetIndex]; // 如果元素不存在,则忽略不处理 if (!moveItem || !targetItem) { return; } // 同一个元素不处理 if (moveItem.id === targetItem.id) { return; } let parentID = targetItem.parent; let insertBefore = targetItem.id; // 如果是最后一个元素,而且bottom if ( targetIndex === currentTreeItems.length - 1 && overType === OverType.Bottom ) { insertBefore = ""; } if (targetItem.settingType === SettingType.Folder) { // 拖动至文件上面,则add child if (overType === OverType.Over) { parentID = targetItem.id; insertBefore = ""; } else { // 如果folder前面是元素,而且有parent // 且是该folder的最后元素 // 则添加至该元素所有在folder const newTarget = currentTreeItems[targetIndex - 1]; if (newTarget && newTarget.parent && newTarget.isLastChild) { parentID = newTarget.parent; insertBefore = ""; } } } try { await apiFolderStore.addChild({ id: parentID, children: [moveItem.id], before: insertBefore, }); if (!parentID) { // 设置至top items const moveItemIndex = topTreeItemIDList.indexOf(moveItem.id); if (moveItemIndex !== -1) { topTreeItemIDList.splice(moveItemIndex, 1); } const index = topTreeItemIDList.indexOf(insertBefore); if (index === -1) { topTreeItemIDList.push(moveItem.id); } else { topTreeItemIDList.splice(index, 0, moveItem.id); } await collectionStore.updateTopTreeItems( collection, uniq(topTreeItemIDList), ); } } catch (err) { showError(message, err); } }; let target: EventTarget; let moveTarget: EventTarget; let originClientY = 0; let originOffset = 0; let targetHeight = 0; let currentInsertIndex = -1; let isDragging = false; const draggingClass = "dragging"; let listItems = [] as HTMLCollection[]; let mousedownFiredAt = 0; let maxMoveOffsetX = 0; const handleMousemove = (e: MouseEvent) => { // 每移动两个点再处理 if (isDragging && e.clientY % 2 !== 0) { e.preventDefault(); return; } const offset = e.clientY - originClientY; if ( !isDragging && Math.abs(offset) > 5 && Date.now() - mousedownFiredAt < 500 ) { isDragging = true; nodeAddClass(wrapper.value, draggingClass); nodeAddClass(document.body, "disableUserSelect"); // 提交公共方法至html无法复制(TODO确认原因) // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore moveTarget = target.cloneNode(true); nodeSetStyle(moveTarget, { position: "absolute", left: "0px", right: "0px", }); nodeAddClass(moveTarget, "dragItem"); nodeInsertAt(wrapper.value, moveTarget, 0); } if (isDragging) { const top = offset + originOffset + nodGetScrollTop(wrapper.value); const index = Math.round(top / targetHeight); if (currentInsertIndex !== index) { if (currentInsertIndex !== -1) { nodeRemoveClass(listItems[currentInsertIndex], "insertBefore"); } if (listItems.length > index) { nodeAddClass(listItems[index], "insertBefore"); currentInsertIndex = index; } } nodeSetStyle(moveTarget, { top: `${top}px`, }); e.preventDefault(); } }; const handleMouseup = (e: MouseEvent) => { document.removeEventListener("mousemove", handleMousemove); document.removeEventListener("mouseup", handleMouseup); nodeRemoveClass(document.body, "disableUserSelect"); if (!isDragging) { return; } let overType = OverType.Bottom; const overOffset = 5; const offset = Math.abs(e.clientY - originClientY) % targetHeight; // 覆盖 if (offset <= overOffset || targetHeight - offset <= overOffset) { overType = OverType.Over; } else if (offset < targetHeight * 0.4) { overType = OverType.Top; } isDragging = false; const moveItemIndex = Number.parseInt( nodeGetDataValue(moveTarget, "index"), ); const targetItemIndex = Number.parseInt( nodeGetDataValue(listItems[currentInsertIndex], "index"), ); nodeRemove(moveTarget); nodeRemoveClass(listItems[currentInsertIndex], "insertBefore"); nodeRemoveClass(wrapper.value, draggingClass); if (maxMoveOffsetX && e.clientX > maxMoveOffsetX) { return; } handleMove(moveItemIndex, targetItemIndex, overType); }; const handleMousedown = (e: MouseEvent) => { isDragging = false; // 无target或者点击非左键 if (!e.currentTarget || e.button > 1) { return; } mousedownFiredAt = Date.now(); // TODO 此处导致无法复制,后续研究 // e.preventDefault(); currentInsertIndex = -1; target = e.currentTarget; originOffset = nodeGetOffset(target).top - nodeGetOffset(wrapper.value).top; targetHeight = nodeGetOffsetHeightWidth(target).height; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore listItems = wrapper.value.children[0].children; originClientY = e.clientY; document.addEventListener("mousemove", handleMousemove); document.addEventListener("mouseup", handleMouseup); if (wrapper.value) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore maxMoveOffsetX = wrapper.value.clientWidth as number; } }; const resetRename = () => { renameValue.value = ""; renameItem.value = { name: "", id: "", }; }; const handelRename = async () => { // 无变化,无需修改 const name = renameValue.value; const id = renameItem.value.id; if (!name || !id) { resetRename(); return; } try { const folder = apiFolderStore.findByID(id); if (folder) { folder.name = name; await apiFolderStore.updateByID(id, folder); } else { const apiSetting = apiSettingStore.findByID(id); apiSetting.name = name; await apiSettingStore.updateByID(id, apiSetting); } } catch (err) { showError(message, err); } finally { resetRename(); } }; const handleKeydown = (e: KeyboardEvent) => { const key = e.key.toLowerCase(); switch (key) { case "escape": { resetRename(); } break; case "enter": { handelRename(); } break; default: break; } }; document.addEventListener("keydown", handleKeydown); onBeforeUnmount(() => { document.removeEventListener("keydown", handleKeydown); }); return { renameValue, renameItem, selectedID, topTreeItems, expandedFolders, isDark, apiFolders, apiSettings, handleClick, handleMousedown, handelRename, setTreeItems, wrapper, }; }, render() { const { keyword } = this.$props; const { apiFolders, apiSettings, isDark, expandedFolders, topTreeItems, setTreeItems, selectedID, renameItem, } = this; const treeItems = convertToTreeItems({ apiFolders, apiSettings, expandedFolders, topTreeItems, keyword, }); const showAllChildren = keyword.trim().length !== 0; const itemList = [] as JSX.Element[]; // 当前展示的tree item const currentTreeItems = [] as TreeItem[]; // 顶层元素 const topTreeItemIDList = [] as string[]; let treeItemIndex = 0; const appendToList = (items: TreeItem[], level: number) => { if (!items || items.length === 0) { return; } items.forEach((item) => { if (level === 0) { topTreeItemIDList.push(item.id); } let folderClass = "folder"; if (item.expanded) { folderClass += " open"; } else { folderClass += " close"; } let icon = ; const isFolder = item.settingType === SettingType.Folder; if (!isFolder) { icon = ( {item.method || HTTPMethod.GET} ); } const style = { "padding-left": `${level * 20}px`, }; let cls = isDark ? "" : "light"; if (item.id === selectedID) { cls += " selected"; } if (item.id === renameItem.id) { cls += " renameItem"; } const onClick = item.id !== selectedID ? () => { if (this.renameItem.id) { this.renameItem = { id: "", name: "", }; } this.handleClick(item); } : undefined; const onDblclick = !isFolder ? (e: MouseEvent) => { if (!nodeHasClass(e.target, "name")) { return; } this.renameItem = { id: item.id, name: item.name, }; } : undefined; currentTreeItems.push(item); itemList.push(
  • {icon} {item.id === renameItem.id && ( { node.el?.getElementsByTagName("input")[0]?.focus(); }} onUpdateValue={(value) => { this.renameValue = value; }} onInputBlur={() => { this.handelRename(); }} /> )} {item.id !== renameItem.id && {item.name}}
  • , ); treeItemIndex++; // 未展开的则不需要展示子元素 // 而且非展示所有子元素 if (!item.expanded && !showAllChildren) { return; } appendToList(item.children, level + 1); }); }; appendToList(treeItems, 0); setTreeItems(currentTreeItems, topTreeItemIDList); return (
      {itemList}
    ); }, }); ================================================ FILE: src/components/ExColumn.tsx ================================================ import { defineComponent, PropType, StyleValue } from "vue"; import { css } from "@linaria/core"; import { NDivider } from "naive-ui"; import { nodeInsertAt, nodeSetStyle, nodeRemove } from "../helpers/html"; const dividerClass = css` margin: 0 !important; height: 100% !important; position: absolute; `; export default defineComponent({ name: "ExColumn", props: { width: { type: Number, default: 0, }, left: { type: Number, default: 0, }, showDivider: { type: Boolean, default: false, }, onResize: { type: Function as PropType<(value: number) => void>, default: () => { return (value: number) => { console.info(value); }; }, }, }, setup(props) { let isDragging = false; let originClientX = 0; let target: EventTarget; let moveLeft = 0; const onMousemove = (e: MouseEvent) => { if (!isDragging) { return; } e.preventDefault(); moveLeft = e.clientX - originClientX; nodeSetStyle(target, { left: `${e.clientX}px`, }); }; const onMouseup = () => { if (props.onResize) { props.onResize(moveLeft); } moveLeft = 0; isDragging = false; document.removeEventListener("mousemove", onMousemove); document.removeEventListener("mouseup", onMouseup); if (target) { nodeRemove(target); } }; const onMousedown = (e: MouseEvent) => { isDragging = false; if (!e.currentTarget) { return; } originClientX = e.clientX; isDragging = true; document.addEventListener("mousemove", onMousemove); document.addEventListener("mouseup", onMouseup); // 提交公共方法至html无法复制(TODO确认原因) // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore target = e.currentTarget.cloneNode(true); nodeSetStyle(target, { left: `${originClientX}px`, width: "2px", zIndex: "9", }); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore nodeInsertAt(e.currentTarget.parentNode.parentNode, target, 0); }; return { onMousedown, }; }, render() { const { left, width, showDivider } = this.$props; const { $slots } = this; const style: StyleValue = { position: "absolute", top: "0px", bottom: "0px", left: `${left}px`, width: `${width}px`, overflow: "hidden", }; if (!width) { delete style.width; style.right = "0px"; } const divider = showDivider && (
    ); return (
    {divider} {$slots.default && $slots.default()}
    ); }, }); ================================================ FILE: src/components/ExCookieEditor.tsx ================================================ import { defineComponent, PropType, ref } from "vue"; import { NForm, NFormItem, NInput, NP, NGrid, NGi, NDatePicker, useMessage, NButton, FormInst, } from "naive-ui"; import dayjs from "dayjs"; import { i18nCookie, i18nCommon } from "../i18n"; import { Cookie } from "../commands/cookies"; import { useCookieStore } from "../stores/cookie"; import { newRequireRules, showError } from "../helpers/util"; export default defineComponent({ name: "ExCookieEditor", props: { cookie: { type: Object as PropType, required: true, }, onBack: { type: Function as PropType<() => void>, default: () => { console.info("back"); }, }, }, setup(props) { const message = useMessage(); const cookieStore = useCookieStore(); const formRef = ref(null); const updateValues = ref({ name: props.cookie.name, path: props.cookie.path, domain: props.cookie.domain, value: props.cookie.value, expires: props.cookie.expires, }); const cookie = Object.assign({}, props.cookie); const update = async () => { const { value, expires, name, path, domain } = updateValues.value; try { await new Promise((resolve, reject) => { if (!formRef.value) { return reject(new Error("form ref is null")); } formRef.value.validate((errors) => { if (!errors) { resolve(null); return; } reject(errors[0][0]); }); }); cookie.value = value; cookie.expires = expires; // 新增 if (!cookie.name) { cookie.name = name; cookie.path = path || "/"; cookie.domain = domain || ""; } await cookieStore.addOrUpdate(cookie); if (props.onBack) { props.onBack(); } } catch (err) { showError(message, err); } }; return { updateValues, update, formRef, }; }, render() { const { cookie } = this.$props; let defaultExpires = null; if (cookie.expires) { defaultExpires = dayjs(cookie.expires).unix() * 1000; } const shortcuts = { [i18nCookie("neverExpired")]: 32503651200000, }; const isAdd = !cookie.name; const rules = newRequireRules([ "name", "value", "path", "domain", "expires", ]); return ( {!isAdd && {cookie.name}} {isAdd && ( { this.updateValues.name = value; }} /> )} {!isAdd && {cookie.path}} {isAdd && ( { this.updateValues.path = value; }} /> )} {!isAdd && {cookie.domain}} {isAdd && ( { this.updateValues.domain = value; }} /> )} { this.updateValues.value = value; }} /> { this.updateValues.expires = dayjs(value).toString(); }} clearable /> { this.update(); }} > {i18nCommon("confirm")} { this.$props.onBack(); }} > {i18nCommon("back")} ); }, }); ================================================ FILE: src/components/ExDeleteCheck.tsx ================================================ import { defineComponent, ref, PropType } from "vue"; import { NButton, NIcon } from "naive-ui"; import { AlertCircleOutline, TrashOutline } from "@vicons/ionicons5"; import { css } from "@linaria/core"; const checkClass = css` color: #f2c97d; font-weight: 900; `; // 两次短时间点击则清除 export default defineComponent({ name: "ExDeleteCheck", props: { onConfirm: { type: Function as PropType<() => void>, required: true, }, }, setup(props) { const deleting = ref(false); const handleClick = () => { if (!deleting.value) { deleting.value = true; } else if (props.onConfirm) { props.onConfirm(); } }; return { handleClick, deleting, }; }, render() { const { deleting } = this; return ( { this.handleClick(); }} > {!deleting && } {deleting && } ); }, }); ================================================ FILE: src/components/ExDialog.tsx ================================================ import { editor } from "monaco-editor/esm/vs/editor/editor.api"; import { DialogApiInjection } from "naive-ui/es/dialog/src/DialogProvider"; import { defineComponent, onBeforeUnmount, ref, PropType } from "vue"; import { i18nCollection, i18nCommon } from "../i18n"; import { useSettingStore } from "../stores/setting"; import { useEnvironmentStore } from "../stores/environment"; import ExForm, { ExFormItem, ExUpdateData } from "./ExForm"; import { createEditor } from "../helpers/editor"; import { css } from "@linaria/core"; import { NButton, NIcon, NTabPane, NTabs, NText, NUpload, NUploadDragger, UploadFileInfo, useMessage, } from "naive-ui"; import { useAPISettingStore } from "../stores/api_setting"; import { useAPIFolderStore } from "../stores/api_folder"; import { getNormalDialogStyle, isJSON, jsonFormat, showError, delay, } from "../helpers/util"; import { CloudUploadOutline } from "@vicons/ionicons5"; import { importAPI, ImportCategory } from "../commands/import_api"; interface OnConfirm { (data: ExUpdateData): Promise; } interface DialogOption { dialog: DialogApiInjection; title: string; enterTriggerSubmit?: boolean; formItems: ExFormItem[]; onConfirm: OnConfirm; } interface ImportDialogOption { dialog: DialogApiInjection; collection: string; folder?: string; data: string; } export default function newDialog(option: DialogOption) { const { dialog, formItems } = option; const d = dialog.info({ title: option.title, autoFocus: true, closable: false, content: () => ( { await option.onConfirm(data); d.destroy(); // 增加延时,否则快速点击时会触发两次 await delay(100); }} /> ), }); } const importWrapperHeight = 300; const codeEditorClass = css` .codeEditor { height: ${importWrapperHeight}px; overflow-y: auto; } `; const ImportEditor = defineComponent({ name: "ImportEditor", props: { data: { type: String, default: () => "", }, collection: { type: String, required: true, }, folder: { type: String, default: () => "", }, onConfirm: { type: Function as PropType<() => void>, required: true, }, }, setup(props) { const settingStore = useSettingStore(); const apiSettingStore = useAPISettingStore(); const apiFolderStore = useAPIFolderStore(); const message = useMessage(); const currentTab = ref(ImportCategory.Text); const fileData = ref(""); let editorIns: editor.IStandaloneCodeEditor | null; const destroyEditor = () => { if (editorIns) { editorIns = null; } }; const codeEditor = ref(); const initEditor = () => { if (editorIns) { return; } if (codeEditor.value) { editorIns = createEditor({ dom: codeEditor.value, isDark: settingStore.isDark, }); } if (!editorIns) { return; } const data = isJSON(props.data) ? jsonFormat(props.data) : ""; editorIns.setValue(data); const setFocus = () => { editorIns?.focus(); }; setTimeout(setFocus, 50); }; const processing = ref(false); const handleImport = async () => { if (processing.value) { return; } processing.value = true; try { if (currentTab.value === ImportCategory.Text) { if (editorIns) { fileData.value = editorIns.getValue(); } else { fileData.value = ""; } } const topIDList = await importAPI({ category: currentTab.value, collection: props.collection, fileData: fileData.value.trim(), }); // 如果指定了目录 if (props.folder && topIDList.length) { await apiFolderStore.addChild({ id: props.folder, children: topIDList, }); } // 重新加载数据,触发页面刷新 await apiFolderStore.fetch(props.collection); await apiSettingStore.fetch(props.collection); await useEnvironmentStore().fetch(props.collection); message.info(i18nCollection("importSuccess")); if (props.onConfirm) { props.onConfirm(); } } catch (err) { showError(message, err); } finally { processing.value = false; } }; const handleReadFile = (blob: Blob) => { const r = new FileReader(); r.onload = () => { fileData.value = r.result as string; }; r.onerror = () => { fileData.value = ""; showError(message, new Error("read file fail")); }; r.readAsText(blob); }; onBeforeUnmount(() => { destroyEditor(); }); return { currentTab, fileData, destroyEditor, initEditor, handleReadFile, handleImport, processing, codeEditor, }; }, render() { const { currentTab } = this; const uploadWrapper = ( { this.handleReadFile(data.fileList[0].file as Blob); }} >
    {i18nCollection("dragUploadTips")}
    ); return (
    { this.fileData = ""; this.currentTab = value; }} > { this.initEditor(); }} onVnodeUnmounted={() => { this.destroyEditor(); }} >
    {uploadWrapper}
    { this.handleImport(); }} > {i18nCommon("confirm")}
    ); }, }); export function newImportDialog(option: ImportDialogOption) { const { dialog, data, collection, folder } = option; const d = dialog.info({ title: i18nCollection("importSettings"), closable: false, style: getNormalDialogStyle(), content: () => ( { d.destroy(); }} /> ), }); } ================================================ FILE: src/components/ExForm.tsx ================================================ import { defineComponent, PropType, ref } from "vue"; import { FormInst, FormRules, FormItemRule, NForm, NFormItem, NInput, NButton, useMessage, } from "naive-ui"; import { get, isArray } from "lodash-es"; import { i18nCommon } from "../i18n"; import { showError } from "../helpers/util"; export interface ExUpdateData { [key: string]: unknown; } export interface ExOnSubmit { (data: ExUpdateData): Promise; } export interface ExFormItem { inputType?: "textarea" | "text" | "password"; key: string; label: string; placeholer: string; defaultValue?: string; rule?: FormItemRule; } export default defineComponent({ name: "ExForm", props: { formItems: { type: Array as PropType, required: true, }, onSubmit: { type: Function as PropType, required: true, }, enterTriggerSubmit: { type: Boolean, default: () => false, }, }, setup(props) { const message = useMessage(); const formRef = ref(null); const data: ExUpdateData = {}; const rules: FormRules = {}; props.formItems.forEach((item) => { if (item.defaultValue) { data[item.key] = item.defaultValue; } if (item.rule) { rules[item.key] = item.rule; } }); const formValue = ref(data); const submitting = ref(false); const submit = async () => { if (submitting.value) { return; } submitting.value = true; try { await formRef.value?.validate(); if (props.onSubmit) { await props.onSubmit(data); } } catch (errors) { let err = errors; if (isArray(errors)) { err = get(errors, "[0][0].message"); } showError(message, err); } finally { submitting.value = false; } }; return { submitting, formValue, formRef, rules, submit, }; }, render() { const { formValue, rules, submit, submitting } = this; const { formItems } = this.$props; const lastIndex = formItems.length - 1; const items = formItems.map((item, index) => { const isAddEvent = index === lastIndex || this.$props.enterTriggerSubmit; const onKeyup = isAddEvent ? (e: KeyboardEvent) => { if (e.key.toLocaleLowerCase() === "enter") { submit(); } } : undefined; return ( { formValue[item.key] = value; }} /> ); }); return ( {items}
    submit()}> {i18nCommon("confirm")}
    ); }, }); ================================================ FILE: src/components/ExKeyValue.tsx ================================================ import { ulid } from "ulid"; import { NButton, NGi, NGrid, NIcon, NInput, useMessage } from "naive-ui"; import { defineComponent, PropType, ref } from "vue"; import { css } from "@linaria/core"; import { CheckboxOutline, DocumentOutline, SquareOutline, } from "@vicons/ionicons5"; import { debounce } from "lodash-es"; import { open } from "@tauri-apps/api/dialog"; import { downloadDir } from "@tauri-apps/api/path"; import ExDeleteCheck from "./ExDeleteCheck"; import { KVParam } from "../commands/interface"; import { i18nCollection, i18nCommon } from "../i18n"; import { padding } from "../constants/style"; import { showError } from "../helpers/util"; const kvClass = css` .item { margin-bottom: ${padding}px; } .n-input--autosize { width: 100%; } .btns { float: right; margin-left: 5px; .n-button { padding: 0 8px !important; margin-left: 2px; } i { font-size: 16px; } } .kv { margin-right: 75px; &.withFile { margin-right: 110px; } } `; export enum HandleOptionCategory { Update = "update", Add = "add", Delete = "delete", } export interface HandleOption { category: string; index: number; param?: KVParam; params: KVParam[]; } type KVItem = { id: string; isNew: boolean; } & KVParam; export default defineComponent({ name: "ExKeyValue", props: { params: { type: Array as PropType, required: true, }, spans: { type: Array as PropType, default: () => [12, 12], }, onHandleParam: { type: Function as PropType<(opt: HandleOption) => void>, required: true, }, typeList: { type: Array as PropType<("textarea" | "text" | "password")[]>, default: () => ["textarea", "textarea"], }, supportFileSelect: { type: Boolean, default: () => false, }, }, setup(props) { const message = useMessage(); const arr = props.params.map((item) => { return Object.assign( { id: ulid(), isNew: false, }, item, ); }); const kvList = ref(arr as KVItem[]); const addParams = (item: KVItem) => { kvList.value.push(item); }; const handle = (opt: HandleOption) => { if (props.onHandleParam) { props.onHandleParam(opt); } }; const toggleEnabled = (index: number) => { if (index >= kvList.value.length) { return; } const item = kvList.value[index]; item.enabled = !item.enabled; if (item.key && item.value) { handle({ category: HandleOptionCategory.Update, param: item, index, params: kvList.value, }); } }; const handleUpdate = (index: number) => { if (index >= kvList.value.length) { return; } const item = kvList.value[index]; let category = HandleOptionCategory.Update; if (item.isNew) { category = HandleOptionCategory.Add; item.isNew = false; } handle({ category, param: item, index, params: kvList.value, }); }; const deleteParams = (index: number) => { const items = kvList.value.splice(index, 1); // 如果是新元素未添加至数据库的,则忽略 if (items.length && items[0].isNew) { return; } handle({ category: HandleOptionCategory.Delete, index, params: kvList.value, }); }; const selectFile = async (index: number) => { if (index >= kvList.value.length) { return; } try { const selected = await open({ title: i18nCommon("selectFile"), multiple: false, defaultPath: await downloadDir(), }); if (selected) { const item = kvList.value[index]; item.value = ("file://" + selected) as string; item.id = ulid(); kvList.value[index] = item; } handleUpdate(index); } catch (err) { showError(message, err); } }; return { kvList, handleUpdate, selectFile, toggleEnabled, deleteParams, addParams, }; }, render() { const { spans, typeList, supportFileSelect } = this.$props; const { kvList } = this; const arr = kvList.slice(0); const lastItem: KVItem = { id: ulid(), key: "", value: "", enabled: true, isNew: true, }; arr.push(lastItem); const namePlaceholder = i18nCollection("namePlaceholder"); const valuePlaceholder = i18nCollection("valuePlaceholder"); const size = arr.length; const inputDebounce = 200; const kvCls = ["kv"]; if (supportFileSelect) { kvCls.push("withFile"); } const list = arr.map((item, index) => { const isLast = index === size - 1; const handleFocus = () => { // 点击最后一个元素,则添加 if (isLast) { this.addParams(lastItem); } }; return (
    {!isLast && (
    {supportFileSelect && ( { this.selectFile(index); }} > )} { this.toggleEnabled(index); }} > {item.enabled && } {!item.enabled && } { this.deleteParams(index); }} />
    )}
    { arr[index].key = value; this.handleUpdate(index); }, inputDebounce)} > { arr[index].value = value; this.handleUpdate(index); }, inputDebounce)} >
    ); }); return
    {list}
    ; }, }); ================================================ FILE: src/components/ExLoading.tsx ================================================ import { defineComponent, PropType, StyleValue } from "vue"; import { css } from "@linaria/core"; import { i18nCommon } from "../i18n"; import { useSettingStore } from "../stores/setting"; import { NText } from "naive-ui"; const loadingClass = css` text-align: center; height: 30px; line-height: 30px; `; const loadingTextClass = css` margin-left: 5px; `; export default defineComponent({ name: "ExLoading", props: { style: { type: Object as PropType, default: () => { return { padding: "30px 0", }; }, }, }, setup() { const settingStore = useSettingStore(); let color = "#000"; if (settingStore.isDark) { color = "#fff"; } return { color, }; }, render() { const { color } = this; const { style } = this.$props; return (
    {i18nCommon("loading")}
    ); }, }); ================================================ FILE: src/components/ExPreview.tsx ================================================ import { defineComponent } from "vue"; import { css } from "@linaria/core"; const wrapperClass = css` padding: 15px; img, span, iframe { max-width: 100%; display: block; margin: auto; } `; export function isSupportPreview(contentType: string) { const reg = /image|pdf/i; return reg.test(contentType); } export default defineComponent({ name: "ExPreview", props: { contentType: { type: String, required: true, }, data: { type: String, required: true, }, }, render() { const { contentType, data } = this.$props; let dom =

    Not Support

    ; const src = `data:${contentType};base64,${data}`; if (contentType.includes("image")) { dom = ; } else { const height = window.innerHeight || 700; dom =