You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@shenyu.apache.org by mi...@apache.org on 2023/01/14 08:34:40 UTC

[shenyu-dashboard] branch master updated: feat: add tag operator (#267)

This is an automated email from the ASF dual-hosted git repository.

midnight2104 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu-dashboard.git


The following commit(s) were added to refs/heads/master by this push:
     new 0b0d2768 feat: add tag operator (#267)
0b0d2768 is described below

commit 0b0d27682c1f2dae88c50c8d0a9e2015679ad093
Author: dayu <we...@qq.com>
AuthorDate: Sat Jan 14 16:34:35 2023 +0800

    feat: add tag operator (#267)
    
    Co-authored-by: mahaitao617 <ma...@mahaitao617deMacBook-Pro.local>
---
 src/index.less                                     |  11 +-
 src/locales/en-US.json                             |   4 +
 src/locales/zh-CN.json                             |   4 +
 src/routes/Document/ApiDoc.js                      | 167 +++++++------
 .../Document/components/AddAndUpdateApiDoc.js      | 118 ++++++---
 src/routes/Document/components/AddAndUpdateTag.js  | 125 ++++++++++
 src/routes/Document/components/ApiContext.js       |   5 +-
 src/routes/Document/components/ApiInfo.js          |  28 ++-
 src/routes/Document/components/SearchApi.js        | 274 ++++++++++++++-------
 src/routes/Document/components/TagInfo.js          |  55 +++++
 src/services/api.js                                |  36 +++
 11 files changed, 602 insertions(+), 225 deletions(-)

diff --git a/src/index.less b/src/index.less
index 8a6eb80c..dcb224d8 100644
--- a/src/index.less
+++ b/src/index.less
@@ -23,7 +23,7 @@ body,
 
 :global {
   .plug-content-wrap {
-    padding: 24px
+    padding: 24px;
   }
 
   .open {
@@ -33,11 +33,6 @@ body,
   .close {
     color: #ff586d;
   }
-
-  .ant-btn-danger {
-    background: #f5222d !important;
-    color: #fff !important;
-  }
 }
 
 :global(.ant-layout) {
@@ -79,7 +74,7 @@ ol {
 }
 
 :global {
-  .ant-table-small>.ant-table-content>.ant-table-body {
+  .ant-table-small > .ant-table-content > .ant-table-body {
     margin: 0 !important;
   }
 }
@@ -146,4 +141,4 @@ body {
   .ant-modal {
     max-width: calc(100vw - 32px);
   }
-}
\ No newline at end of file
+}
diff --git a/src/locales/en-US.json b/src/locales/en-US.json
index 75b52a2b..a18b6eb1 100644
--- a/src/locales/en-US.json
+++ b/src/locales/en-US.json
@@ -324,6 +324,10 @@
   "SHENYU.DOCUMENT.APIDOC.APIDESC": "apiDesc",
   "SHENYU.DOCUMENT.APIDOC.APISOURCE": "apiSource",
   "SHENYU.DOCUMENT.APIDOC.DOCUMENT": "document",
+  "SHENYU.DOCUMENT.TAG.NAME": "name",
+  "SHENYU.DOCUMENT.TAG.DESC": "tagDesc",
+  "SHENYU.DOCUMENT.TAG.PARENT.ID": "parentTagId",
+  "SHENYU.DOCUMENT.TAG.ext": "ext",
   "SHENYU.COMMON.REQUIRED": "Required",
   "SHENYU.COMMON.MAX.LENGTH": "Max Length",
   "SHENYU.COMMON.MAX.EXAMPLE": "Example",
diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json
index a26509a4..84c2da21 100644
--- a/src/locales/zh-CN.json
+++ b/src/locales/zh-CN.json
@@ -312,6 +312,10 @@
   "SHENYU.DOCUMENT.APIDOC.APIDESC": "api描述",
   "SHENYU.DOCUMENT.APIDOC.APISOURCE": "api来源",
   "SHENYU.DOCUMENT.APIDOC.DOCUMENT": "文档说明",
+  "SHENYU.DOCUMENT.TAG.NAME": "标签名称",
+  "SHENYU.DOCUMENT.TAG.DESC": "标签描述",
+  "SHENYU.DOCUMENT.TAG.EXT": "标签扩展信息",
+  "SHENYU.DOCUMENT.TAG.PARENT.ID": "父节点id",
   "SHENYU.COMMON.REQUIRED": "必填",
   "SHENYU.COMMON.MAX.LENGTH": "最大长度",
   "SHENYU.COMMON.MAX.EXAMPLE": "示例值",
diff --git a/src/routes/Document/ApiDoc.js b/src/routes/Document/ApiDoc.js
index b06b6486..12b417cb 100644
--- a/src/routes/Document/ApiDoc.js
+++ b/src/routes/Document/ApiDoc.js
@@ -15,38 +15,30 @@
  * limitations under the License.
  */
 
+/* eslint-disable no-unused-expressions */
+
 import { Col, Row, Card, BackTop, Empty, message } from "antd";
 import React, { useEffect, useState } from "react";
 import SearchApi from "./components/SearchApi";
-import AddAndUpdateApiDoc from "./components/AddAndUpdateApiDoc";
 import ApiInfo from "./components/ApiInfo";
-import { getDocMenus, getApiDetail, addApi, updateApi, deleteApi, getApiMockRequest} from "../../services/api";
+import TagInfo from "./components/TagInfo";
+import {
+  getDocMenus,
+  getApiDetail,
+  deleteApi,
+  getTagDetail,
+  deleteTag,
+  getApiMockRequest
+} from "../../services/api";
 import ApiContext from "./components/ApiContext";
 
 function ApiDoc() {
+  const [tagDetail, setTagDetail] = useState({});
   const [apiDetail, setApiDetail] = useState({});
   const [apiData, setApiData] = useState({});
   const [apiMock, setApiMock] = useState({});
-  const [open, setOpen] = useState(false);
-  const [flag, setflag] = useState('add');
 
-  const [initialValue, setInitialValue] = useState({
-    id: '',
-    contextPath: '',
-    apiPath: '',
-    httpMethod: '',
-    consume: '',
-    produce: '',
-    version: '',
-    rpcType: '',
-    state: '',
-    ext: '',
-    apiOwner: '',
-    apiDesc: '',
-    apiSource: '',
-    document: '',
-    tagIds: []
-  })
+  const searchApiRef = React.createRef();
 
   const initData = async () => {
     const { code, data = {} } = await getDocMenus();
@@ -69,69 +61,74 @@ function ApiDoc() {
       setApiData(data);
     }
   };
+
   const handleSelectNode = async (_, e) => {
     const {
       node: {
-        props: {
-          dataRef: { id, isLeaf }
-        }
+        props: { id, isLeaf }
       }
     } = e;
-    if (!isLeaf) {
-      return;
+    if (isLeaf) {
+      const { code, message: msg, data } = await getApiDetail(id);
+      if (code !== 200) {
+        message.error(msg);
+        return;
+      }
+      setApiDetail(data);
+      setTagDetail({});
+
+      const {
+        code: mockCode,
+        message: mockMsg,
+        data: mockData
+      } = await getApiMockRequest(id);
+      if (mockCode !== 200) {
+        message.error(mockMsg);
+        return;
+      }
+      setApiMock(mockData);
+    } else {
+      const { code, message: msg, data } = await getTagDetail(id);
+      if (code !== 200) {
+        message.error(msg);
+        return;
+      }
+      setTagDetail(data);
+      setApiDetail({});
+    }
+  };
+
+  const handleDelete = async () => {
+    let res = {};
+    if (tagDetail.id) {
+      res = await deleteTag([tagDetail.id]);
     }
-    if (!id) {
-      const targetId = _
-      handleAddApi(targetId)
-      return;
+    if (apiDetail.id) {
+      res = await deleteApi([apiDetail.id]);
     }
-    const { code, message: msg, data } = await getApiDetail(id);
+    const { code, message: msg } = res;
     if (code !== 200) {
       message.error(msg);
-      return;
+    } else {
+      message.success(msg);
+      searchApiRef.current?.updateTree();
     }
-    setInitialValue({
-      id
-    });
-    setApiDetail(data);
+  };
 
-    const { code: mockCode, message: mockMsg, data: mockData} = await getApiMockRequest(id);
-    if (mockCode !== 200) {
-      message.error(mockMsg);
-      return;
+  const handleUpdate = () => {
+    if (tagDetail.id) {
+      searchApiRef.current?.addOrUpdateTag(tagDetail);
     }
-    setApiMock(mockData);
-  };
-  const handleAddApi = (targetId) => {
-    setflag('add')
-    setInitialValue({
-      tagIds: [targetId]
-    });
-    setOpen(true)
-  };
-  const callSaveOrUpdateApi = async (params) => {
-    let rs = (flag === 'add' ? await addApi({ ...params, tagIds: initialValue.tagIds[0] }) : await updateApi({ ...params, id: initialValue.id, tagIds: initialValue.tagIds }));
-    if (rs.code !== 200) {
-      message.error(rs.msg);
-    } else {
-      setOpen(false)
-      location.reload()
+    if (apiDetail.id) {
+      searchApiRef.current?.addOrUpdateApi(apiDetail);
     }
   };
-  const handleDeleteApi = async () => {
-    const { code, message: msg } = await deleteApi([initialValue.id]);
-    if (code !== 200) {
-      message.error(msg);
-    } else {
-      location.reload()
-    }
+
+  // eslint-disable-next-line no-unused-vars
+  const handleAfterUpdate = data => {
+    setApiDetail({});
+    setTagDetail({});
   };
-  const handleUpdateApi = async () => {
-    let queryData = await getApiDetail(initialValue.id)
-    setInitialValue(queryData.data);
-    setOpen(true)
-    setflag('update')
-  }
 
   useEffect(() => {
     initData();
@@ -142,24 +139,36 @@ function ApiDoc() {
       value={{
         apiDetail,
         apiData,
-        apiMock
+        apiMock,
+        tagDetail
       }}
     >
       <Card style={{ margin: 24 }}>
-        {open && <AddAndUpdateApiDoc onCancel={() => setOpen(false)} handleOk={callSaveOrUpdateApi} {...initialValue} />
-        }
         <Row gutter={24}>
           <Col span={6}>
-            <SearchApi onSelect={handleSelectNode} />
+            <SearchApi
+              onSelect={handleSelectNode}
+              ref={searchApiRef}
+              afterUpdate={handleAfterUpdate}
+            />
           </Col>
           <Col span={18}>
+            {tagDetail.id ? (
+              <TagInfo
+                handleUpdate={handleUpdate}
+                handleDelete={handleDelete}
+              />
+            ) : null}
             {apiDetail.id ? (
-              <>
-                <ApiInfo handleUpdateApi={handleUpdateApi} handleDeleteApi={handleDeleteApi} />
-              </>
-            ) : (
-              <Empty description={false} style={{ padding: "160px 0" }} />
-            )}
+              <ApiInfo
+                handleUpdate={handleUpdate}
+                handleDelete={handleDelete}
+              />
+            ) : null}
+            {!tagDetail.id &&
+              !apiDetail.id && (
+                <Empty description={false} style={{ padding: "160px 0" }} />
+              )}
           </Col>
         </Row>
         <BackTop />
diff --git a/src/routes/Document/components/AddAndUpdateApiDoc.js b/src/routes/Document/components/AddAndUpdateApiDoc.js
index b8b5485e..91382322 100644
--- a/src/routes/Document/components/AddAndUpdateApiDoc.js
+++ b/src/routes/Document/components/AddAndUpdateApiDoc.js
@@ -15,30 +15,78 @@
 	* limitations under the License.
 	*/
 
-import { Modal, Form, Input, Select } from "antd";
+/* eslint-disable no-unused-expressions */
+/* eslint-disable radix */
+import { Modal, Form, Input, Select, message } from "antd";
 import React, { Component } from "react";
+import PropTypes from "prop-types";
 import { Method } from "./globalData";
 import { getIntlContent } from "../../../utils/IntlUtils";
+import { addApi, updateApi } from "../../../services/api";
+
+const RPCTYPE = [
+  "http",
+  "dubbo",
+  "sofa",
+  "tars",
+  "websocket",
+  "springCloud",
+  "motan",
+  "grpc"
+];
+
+const API_SOURCE_TYPE = [
+  "swagger",
+  "annotation generation",
+  "create manuallym",
+  "import swagger",
+  "import yapi"
+];
 
 class AddAndUpdateApiDoc extends Component {
+  static defaultProps = {
+    form: PropTypes.object,
+    visible: PropTypes.bool,
+    formLoaded: PropTypes.func,
+    onOk: PropTypes.func,
+    onCancel: PropTypes.func
+  };
+
+  componentDidMount() {
+    const { form, formLoaded } = this.props;
+    formLoaded?.(form);
+  }
+
+  handleSubmit = () => {
+    const { form, onOk } = this.props;
+    form.validateFieldsAndScroll(async (err, values) => {
+      if (!err) {
+        const { id } = values;
+        let res = {};
+        values.state = parseInt(values.state);
+        values.apiSource = parseInt(values.apiSource);
+        values.httpMethod = parseInt(values.httpMethod);
+        if (!id) {
+          res = await addApi({
+            ...values
+          });
+        } else {
+          res = await updateApi({
+            ...values
+          });
+        }
+
+        if (res.code !== 200) {
+          message.error(res.message);
+        } else {
+          message.success(res.message);
+          onOk?.(values);
+        }
+      }
+    });
+  };
+
   render() {
-    const RPCTYPE = [
-      "http",
-      "dubbo",
-      "sofa",
-      "tars",
-      "websocket",
-      "springCloud",
-      "motan",
-      "grpc"
-    ];
-    const API_SOURCE_TYPE = [
-      "swagger",
-      "annotation generation",
-      "create manuallym",
-      "import swagger",
-      "import yapi"
-    ];
     const {
       onCancel,
       form,
@@ -54,7 +102,8 @@ class AddAndUpdateApiDoc extends Component {
       apiOwner = "",
       apiDesc = "",
       apiSource = "",
-      document = ""
+      document = "",
+      visible = false
     } = this.props;
     const { getFieldDecorator } = form;
     const formItemLayout = {
@@ -65,25 +114,16 @@ class AddAndUpdateApiDoc extends Component {
         sm: { span: 19 }
       }
     };
-    const handleSubmit = () => {
-      const { handleOk } = this.props;
-      let newValues = "";
-      form.validateFieldsAndScroll((err, values) => {
-        if (!err) {
-          // eslint-disable-next-line radix
-          values.state = parseInt(values.state);
-          // eslint-disable-next-line radix
-          values.apiSource = parseInt(values.apiSource);
-          // eslint-disable-next-line radix
-          values.httpMethod = parseInt(values.httpMethod);
-          newValues = values;
-        }
-      });
-      handleOk(newValues);
-    };
+
     return (
-      <Modal visible onCancel={onCancel} onOk={handleSubmit}>
-        <Form onSubmit={handleSubmit} className="login-form">
+      <Modal
+        visible={visible}
+        onCancel={onCancel}
+        onOk={this.handleSubmit}
+        closable={false}
+        forceRender
+      >
+        <Form className="login-form">
           <Form.Item
             label={`${getIntlContent("SHENYU.DOCUMENT.APIDOC.CONTEXTPATH")}`}
             {...formItemLayout}
@@ -338,6 +378,10 @@ class AddAndUpdateApiDoc extends Component {
               />
             )}
           </Form.Item>
+
+          <Form.Item hidden>{getFieldDecorator("tagIds")(<Input />)}</Form.Item>
+
+          <Form.Item hidden>{getFieldDecorator("id")(<Input />)}</Form.Item>
         </Form>
       </Modal>
     );
diff --git a/src/routes/Document/components/AddAndUpdateTag.js b/src/routes/Document/components/AddAndUpdateTag.js
new file mode 100644
index 00000000..687fed97
--- /dev/null
+++ b/src/routes/Document/components/AddAndUpdateTag.js
@@ -0,0 +1,125 @@
+/*
+	* Licensed to the Apache Software Foundation (ASF) under one or more
+	* contributor license agreements. See the NOTICE file distributed with
+	* this work for additional information regarding copyright ownership.
+	* The ASF licenses this file to You 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.
+	*/
+
+/* eslint-disable no-unused-expressions */
+import { Modal, Form, Input, message } from "antd";
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import { getIntlContent } from "../../../utils/IntlUtils";
+import { addTag, updateTag } from "../../../services/api";
+
+class AddAndUpdateTag extends Component {
+  static defaultProps = {
+    form: PropTypes.object,
+    visible: PropTypes.bool,
+    formLoaded: PropTypes.func,
+    onOk: PropTypes.func,
+    onCancel: PropTypes.func
+  };
+
+  componentDidMount() {
+    const { form, formLoaded } = this.props;
+    formLoaded?.(form);
+  }
+
+  handleSubmit = () => {
+    const {
+      onOk,
+      form: { validateFieldsAndScroll }
+    } = this.props;
+    validateFieldsAndScroll(async (err, values) => {
+      if (!err) {
+        const { id } = values;
+        let res = {};
+        if (!id) {
+          // add
+          res = await addTag(values);
+        } else {
+          // update
+          res = await updateTag(values);
+        }
+        if (res.code !== 200) {
+          message.error(res.message);
+        } else {
+          message.success(res.message);
+          onOk?.(values);
+        }
+      }
+    });
+  };
+
+  render() {
+    const { onCancel, form, name = "", tagDesc = "", visible } = this.props;
+    const { getFieldDecorator } = form;
+    const formItemLayout = {
+      labelCol: {
+        sm: { span: 5 }
+      },
+      wrapperCol: {
+        sm: { span: 19 }
+      }
+    };
+
+    return (
+      <Modal
+        visible={visible}
+        onCancel={onCancel}
+        onOk={this.handleSubmit}
+        closable={false}
+        forceRender
+      >
+        <Form className="login-form" {...formItemLayout}>
+          <Form.Item label={`${getIntlContent("SHENYU.DOCUMENT.TAG.NAME")}`}>
+            {getFieldDecorator("name", {
+              rules: [
+                {
+                  required: true,
+                  message: getIntlContent("SHENYU.DOCUMENT.TAG.NAME")
+                }
+              ],
+              initialValue: name
+            })(
+              <Input placeholder={getIntlContent("SHENYU.DOCUMENT.TAG.NAME")} />
+            )}
+          </Form.Item>
+
+          <Form.Item label={`${getIntlContent("SHENYU.DOCUMENT.TAG.DESC")}`}>
+            {getFieldDecorator("tagDesc", {
+              rules: [
+                {
+                  required: true,
+                  message: getIntlContent("SHENYU.DOCUMENT.TAG.DESC")
+                }
+              ],
+              initialValue: tagDesc
+            })(
+              <Input placeholder={getIntlContent("SHENYU.DOCUMENT.TAG.DESC")} />
+            )}
+          </Form.Item>
+
+          <Form.Item hidden>
+            {getFieldDecorator("parentTagId")(<Input />)}
+          </Form.Item>
+
+          <Form.Item hidden>{getFieldDecorator("id")(<Input />)}</Form.Item>
+        </Form>
+      </Modal>
+    );
+  }
+}
+
+export default Form.create()(AddAndUpdateTag);
diff --git a/src/routes/Document/components/ApiContext.js b/src/routes/Document/components/ApiContext.js
index 58d7d19f..eb6e52ab 100644
--- a/src/routes/Document/components/ApiContext.js
+++ b/src/routes/Document/components/ApiContext.js
@@ -15,10 +15,13 @@
  * limitations under the License.
  */
 
+/* eslint-disable no-unused-vars */
+
 import { createContext } from "react";
 
 export default createContext({
   apiDetail: {},
   apiData: {},
-  apiMock: {}
+  apiMock: {},
+  tagDetail: {}
 });
diff --git a/src/routes/Document/components/ApiInfo.js b/src/routes/Document/components/ApiInfo.js
index 8ca7e49e..44011d2d 100644
--- a/src/routes/Document/components/ApiInfo.js
+++ b/src/routes/Document/components/ApiInfo.js
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import { Typography, Table, Tabs, Icon, Row, Col , Button } from "antd";
+import { Typography, Table, Tabs, Icon, Row, Col, Button } from "antd";
 import React, { useContext } from "react";
 import ApiDebug from "./ApiDebug";
 import ApiContext from "./ApiContext";
@@ -30,8 +30,9 @@ function ApiInfo(props) {
     apiDetail,
     apiDetail: { document, responseParameters, requestHeaders }
   } = useContext(ApiContext);
+  const { handleUpdate, handleDelete } = props;
+
   let documentJSON = {};
-  let {handleUpdateApi,handleDeleteApi} = props
   try {
     documentJSON = JSON.parse(document);
     documentJSON.errorCode = [];
@@ -135,6 +136,7 @@ function ApiInfo(props) {
       dataIndex: "envDesc"
     }
   ];
+
   return (
     <>
       <Tabs>
@@ -147,11 +149,23 @@ function ApiInfo(props) {
           }
           key="1"
         >
-          <Title level={2}>
-            {apiDetail.tags[apiDetail.tags.length - 1].name}
-            <Button style={{float:'right'}} onClick={handleDeleteApi}>delete</Button>
-            <Button style={{float:'right'}} onClick={handleUpdateApi}>edit</Button>
-          </Title>
+          <Row gutter={24}>
+            <Col span={12}>
+              <Title level={2}>
+                {apiDetail.tags[apiDetail.tags.length - 1].name}
+              </Title>
+            </Col>
+            <Col span={12} style={{ textAlign: "right" }}>
+              <Button onClick={handleUpdate}>
+                {getIntlContent("SHENYU.BUTTON.SYSTEM.EDIT")}
+              </Button>
+              &nbsp;&nbsp;
+              <Button ghost type="danger" onClick={handleDelete}>
+                {getIntlContent("SHENYU.BUTTON.SYSTEM.DELETE")}
+              </Button>
+            </Col>
+          </Row>
+
           <Paragraph>
             <Title level={4}>
               {getIntlContent("SHENYU.DOCUMENT.APIDOC.INFO.INTERFACE.ADDRESS")}
diff --git a/src/routes/Document/components/SearchApi.js b/src/routes/Document/components/SearchApi.js
index 132e0334..9fef08b4 100644
--- a/src/routes/Document/components/SearchApi.js
+++ b/src/routes/Document/components/SearchApi.js
@@ -15,143 +15,231 @@
 	* limitations under the License.
 	*/
 
-import { Tree, Empty, message, Typography } from "antd";
-import React, { useEffect, useState } from "react";
-// import ApiContext from "./ApiContext";
-// import { getIntlContent } from "../../../utils/IntlUtils";
+/* eslint-disable no-unused-expressions */
+
+import { Tree, Empty, message, Typography, Button, Row, Col, Spin } from "antd";
+import React, { useEffect, useImperativeHandle, useState } from "react";
 import { getRootTag, getParentTagId, getApi } from "../../../services/api";
 import { Method } from "./globalData";
+import AddAndUpdateTag from "./AddAndUpdateTag";
+import AddAndUpdateApiDoc from "./AddAndUpdateApiDoc";
 
 const { Text } = Typography;
-const { TreeNode } = Tree;
-// const { Search } = Input;
-
-function SearchApi(props) {
-  const { onSelect } = props;
-  // const [searchValue, setSearchValue] = useState("");
-
-  // const handleSearchChange = e => {
-  //   const { value } = e.target;
-  //   const keys = [];
-  //   const findSearchKeys = data =>
-  //     data.forEach(item => {
-  //       if (item.label.indexOf(value) > -1 || item.name?.indexOf(value) > -1) {
-  //         keys.push(item.key);
-  //       }
-  //       if (Array.isArray(item.children)) {
-  //         findSearchKeys(item.children);
-  //       }
-  //     });
-  //   setSearchValue(value);
-  // };
-
-  const [apiTree, setApiTree] = useState([]);
-
-  const renderTreeNodes = data => {
-    return data.map(item => {
-      if (item.children) {
-        return (
-          <TreeNode
-            title={item.title}
-            key={item.key}
-            dataRef={item}
-            selectable={item.isLeaf}
-            isLeaf={item.isLeaf}
-          >
-            {renderTreeNodes(item.children)}
-          </TreeNode>
-        );
-      }
-      return <TreeNode key={item.key} {...item} dataRef={item} />;
-    });
-  };
+
+const SearchApi = React.forwardRef((props, ref) => {
+  const { onSelect, afterUpdate } = props;
+  const [loading, setLoading] = useState(false);
+  const [treeData, setTreeData] = useState({});
+  const [expandedKeys, setExpandedKeys] = useState([]);
 
   const queryRootTag = async () => {
+    setLoading(true);
     const { code, data = [], message: msg } = await getRootTag();
+    setLoading(false);
     if (code !== 200) {
       message.error(msg);
       return;
     }
-    setApiTree(
+    const arr =
       data?.map((item, index) => ({
         ...item,
         title: item.name,
         key: index.toString(),
-        isLeaf: !item.hasChildren
-      })) || []
-    );
+        isLeaf: false
+      })) || [];
+    setTreeData(arr);
   };
 
-  const onLoadData = async treeNode => {
-    if (treeNode.props.children) {
-      return Promise.resolve();
+  const onExpand = async (keys, { expanded, node }) => {
+    setExpandedKeys(keys);
+    if (expanded === false) {
+      return;
     }
-    const { id, hasChildren } = treeNode.props.dataRef;
+    setLoading(true);
+    const { id, hasChildren, eventKey } = node.props;
+    const newTreeData = [...treeData];
+    let showAddTag = true;
+    let resData = [];
+    const eventKeys = eventKey
+      .split("-")
+      .map((v, i, arr) => arr.slice(0, i + 1).join("-"));
+
     if (hasChildren) {
       const { code, message: msg, data } = await getParentTagId(id);
+      setLoading(false);
       if (code !== 200) {
         message.error(msg);
         return Promise.reject();
       }
-      treeNode.props.dataRef.children = data?.map((item, index) => ({
-        ...item,
-        title: item.name,
-        key: `${treeNode.props.eventKey}-${index}`
-      }));
+      resData = data;
     } else {
       const { code, message: msg, data } = await getApi(id);
+      setLoading(false);
       if (code !== 200) {
         message.error(msg);
         return Promise.reject();
       }
       const { dataList } = data;
-      treeNode.props.dataRef.children = dataList?.map((item, index) => ({
-        ...item,
-        title: (
-          <>
-            <Text code>{Method[item.httpMethod]}</Text> {item.apiPath}
-          </>
-        ),
-        key: `${treeNode.props.eventKey}-${index}`,
-        isLeaf: true
-      }))
-      treeNode.props.dataRef.children.push({
-        title: (
-          <>
-            <Text code>&nbsp;+&nbsp;</Text>
-          </>
-        ),
-        key: treeNode.props.dataRef.id,
-        isLeaf: true
-      })
-      ;
+      if (dataList.length) {
+        showAddTag = false;
+      }
+      resData = dataList;
     }
-    setApiTree([...apiTree]);
-    return Promise.resolve();
+    const curNode = eventKeys.reduce((pre, cur, curIndex, curArray) => {
+      const el = pre.find(item => item.key === cur);
+      if (curIndex === curArray.length - 1) {
+        return el;
+      } else {
+        return el.children || [];
+      }
+    }, newTreeData);
+
+    curNode.children = resData?.map((item, index) => ({
+      ...item,
+      title: hasChildren ? (
+        item.name
+      ) : (
+        <>
+          <Text code>{Method[item.httpMethod]}</Text> {item.apiPath}
+        </>
+      ),
+      key: `${eventKey}-${index}`,
+      isLeaf: !hasChildren
+    }));
+    curNode.children.push({
+      selectable: false,
+      title: (
+        <Row gutter={8}>
+          {showAddTag && (
+            <Col span={12}>
+              <Button
+                type="primary"
+                ghost
+                size="small"
+                onClick={() =>
+                  addOrUpdateTag({
+                    parentTagId: id
+                  })
+                }
+              >
+                + Tag
+              </Button>
+            </Col>
+          )}
+
+          <Col span={12}>
+            <Button
+              type="primary"
+              ghost
+              size="small"
+              onClick={() =>
+                addOrUpdateApi({
+                  tagIds: [id]
+                })
+              }
+            >
+              + Api
+            </Button>
+          </Col>
+        </Row>
+      ),
+      key: `${eventKey}-operator`,
+      isLeaf: true
+    });
+    setTreeData(newTreeData);
+  };
+
+  const [openTag, setOpenTag] = useState(false);
+  const [tagForm, setTagForm] = useState({});
+
+  const handleTagCancel = () => {
+    setOpenTag(false);
+    tagForm.resetFields();
+  };
+
+  const handleTagOk = data => {
+    handleTagCancel();
+    updateTree(data);
+  };
+
+  const [openApi, setOpenApi] = useState(false);
+  const [apiForm, setApiForm] = useState({});
+
+  const handleApiCancel = () => {
+    setOpenApi(false);
+    tagForm.resetFields();
+  };
+
+  const handleApiOk = data => {
+    handleApiCancel();
+    updateTree(data);
+  };
+
+  const addOrUpdateApi = data => {
+    apiForm.setFieldsValue({
+      ...data
+    });
+    setOpenApi(true);
+  };
+
+  const addOrUpdateTag = data => {
+    tagForm.setFieldsValue({
+      ...data
+    });
+    setOpenTag(true);
   };
 
+  const updateTree = data => {
+    setExpandedKeys([]);
+    queryRootTag();
+    afterUpdate(data);
+  };
+
+  useImperativeHandle(ref, () => ({
+    addOrUpdateApi,
+    addOrUpdateTag,
+    updateTree
+  }));
+
   useEffect(() => {
     queryRootTag();
   }, []);
 
   return (
     <div style={{ overflow: "auto" }}>
-      {/* <Search
-        allowClear
-        onChange={handleSearchChange}
-        placeholder={getIntlContent(
-          "SHENYU.DOCUMENT.APIDOC.SEARCH.PLACEHOLDER"
-        )}
-      /> */}
-      {apiTree?.length ? (
-        <Tree loadData={onLoadData} onSelect={onSelect}>
-          {renderTreeNodes(apiTree)}
-        </Tree>
+      {treeData?.length ? (
+        <Spin spinning={loading}>
+          <Tree
+            onSelect={onSelect}
+            treeData={treeData}
+            onExpand={onExpand}
+            expandedKeys={expandedKeys}
+          />
+        </Spin>
       ) : (
         <Empty style={{ padding: "80px 0" }} description={false} />
       )}
+      <Button
+        block
+        type="dashed"
+        onClick={() => addOrUpdateTag({ parentTagId: "0" })}
+      >
+        Add Root Tag
+      </Button>
+      <AddAndUpdateTag
+        visible={openTag}
+        formLoaded={setTagForm}
+        onOk={handleTagOk}
+        onCancel={handleTagCancel}
+      />
+      <AddAndUpdateApiDoc
+        visible={openApi}
+        formLoaded={setApiForm}
+        onOk={handleApiOk}
+        onCancel={handleApiCancel}
+      />
     </div>
   );
-}
+});
 
 export default SearchApi;
diff --git a/src/routes/Document/components/TagInfo.js b/src/routes/Document/components/TagInfo.js
new file mode 100644
index 00000000..a79d9fa4
--- /dev/null
+++ b/src/routes/Document/components/TagInfo.js
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Typography, Button, Row, Col } from "antd";
+import React, { useContext } from "react";
+import ApiContext from "./ApiContext";
+import { getIntlContent } from "../../../utils/IntlUtils";
+
+const { Title, Text, Paragraph } = Typography;
+
+function TagInfo(props) {
+  const { handleDelete, handleUpdate } = props;
+  const { tagDetail } = useContext(ApiContext);
+
+  return (
+    <Row gutter={24}>
+      <Col span={12}>
+        <Title level={2}>{tagDetail.name}</Title>
+      </Col>
+      <Col span={12} style={{ textAlign: "right" }}>
+        <Button onClick={handleUpdate}>
+          {getIntlContent("SHENYU.BUTTON.SYSTEM.EDIT")}
+        </Button>
+        &nbsp;&nbsp;
+        <Button ghost type="danger" onClick={handleDelete}>
+          {getIntlContent("SHENYU.BUTTON.SYSTEM.DELETE")}
+        </Button>
+      </Col>
+      <Col span={24}>
+        <Paragraph>
+          <Title level={4}>{getIntlContent("SHENYU.DOCUMENT.TAG.DESC")}</Title>
+          <Text>{tagDetail.tagDesc}</Text>
+          <Title level={4}>{getIntlContent("SHENYU.DOCUMENT.TAG.EXT")}</Title>
+          <Text code>{tagDetail.ext}</Text>
+        </Paragraph>
+      </Col>
+    </Row>
+  );
+}
+
+export default TagInfo;
diff --git a/src/services/api.js b/src/services/api.js
index 22234ff4..726a4148 100644
--- a/src/services/api.js
+++ b/src/services/api.js
@@ -809,6 +809,42 @@ export function getParentTagId(id) {
   });
 }
 
+/* getTagDetail */
+export function getTagDetail(id) {
+  return request(
+    `${baseUrl}/tag/id/${id}`, {
+      method: `GET`
+    });
+}
+
+/** add tag */
+export function addTag(params) {
+  return request(`${baseUrl}/tag`, {
+    method: `POST`,
+    body: {
+      ...params
+    }
+  });
+}
+
+/** delete tag */
+export function deleteTag(params) {
+  return request(`${baseUrl}/tag/batchDelete`, {
+    method: `DELETE`,
+    body: params
+  });
+}
+
+/** updateTag */
+export function updateTag(params) {
+  return request(`${baseUrl}/tag/id/${params.id}`, {
+    method: `PUT`,
+    body: {
+      ...params
+    }
+  });
+}
+
 /* queryApi */
 export function getApi(tagId) {
   return request(`${baseUrl}/api?tagId=${tagId}&currentPage=0&pageSize=100`, {