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

shuai pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/answer.git

commit 9c0844f0ed29ff8caaad6491262a48fdfb5764ab
Author: hgaol <dhan...@hotmail.com>
AuthorDate: Sat Apr 19 19:24:25 2025 +0800

    feat: implement MergeTag functionality with UI and backend support
---
 i18n/en_US.yaml                                |  10 +
 internal/controller/tag_controller.go          |   1 +
 internal/schema/tag_schema.go                  |   2 +
 internal/service/permission/permission_name.go |   3 +-
 internal/service/permission/tag_permission.go  |  11 +-
 internal/service/tag/tag_service.go            |   2 +-
 internal/service/tag_common/tag_common.go      |   2 +
 ui/src/components/Modal/MergeTagModal.tsx      | 233 +++++++++++++++++++++
 ui/src/components/Modal/index.tsx              |   3 +-
 ui/src/pages/Tags/Info/index.tsx               | 276 ++++++++++++++-----------
 ui/src/services/client/tag.ts                  |   6 +
 11 files changed, 421 insertions(+), 128 deletions(-)

diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml
index f220b454..444eb826 100644
--- a/i18n/en_US.yaml
+++ b/i18n/en_US.yaml
@@ -58,6 +58,8 @@ backend:
       other: Edit
     undelete:
       other: Undelete
+    merge:
+      other: Merge
   role:
     name:
       user:
@@ -1041,6 +1043,14 @@ ui:
         <p>Please remove the synonyms from this tag first.</p>
       tip: Are you sure you wish to delete?
       close: Close
+    merge:
+      title: Merge tag
+      source_tag_title: Source tag
+      source_tag_description: The source tag and its associated data will be 
remapped to the target tag.
+      target_tag_title: Target tag
+      target_tag_description: A synonym between these two tags will be created 
after merging.
+      btn_submit: Submit
+      btn_close: Close
   edit_tag:
     title: Edit Tag
     default_reason: Edit tag
diff --git a/internal/controller/tag_controller.go 
b/internal/controller/tag_controller.go
index 765497bc..56555cd8 100644
--- a/internal/controller/tag_controller.go
+++ b/internal/controller/tag_controller.go
@@ -249,6 +249,7 @@ func (tc *TagController) GetTagInfo(ctx *gin.Context) {
        req.CanEdit = canList[0]
        req.CanDelete = canList[1]
        req.CanRecover = canList[2]
+       req.CanMerge = middleware.GetUserIsAdminModerator(ctx)
 
        resp, err := tc.tagService.GetTagInfo(ctx, req)
        handler.HandleResponse(ctx, err, resp)
diff --git a/internal/schema/tag_schema.go b/internal/schema/tag_schema.go
index b5a5835b..a51b2d2d 100644
--- a/internal/schema/tag_schema.go
+++ b/internal/schema/tag_schema.go
@@ -48,6 +48,7 @@ type GetTagInfoReq struct {
        UserID     string `json:"-"`
        CanEdit    bool   `json:"-"`
        CanDelete  bool   `json:"-"`
+       CanMerge   bool   `json:"-"`
        CanRecover bool   `json:"-"`
 }
 
@@ -300,6 +301,7 @@ type GetFollowingTagsResp struct {
 
 // GetTagBasicResp get tag basic response
 type GetTagBasicResp struct {
+       TagID       string `json:"tag_id"`
        SlugName    string `json:"slug_name"`
        DisplayName string `json:"display_name"`
        Recommend   bool   `json:"recommend"`
diff --git a/internal/service/permission/permission_name.go 
b/internal/service/permission/permission_name.go
index 5c93e89e..fb9fbb21 100644
--- a/internal/service/permission/permission_name.go
+++ b/internal/service/permission/permission_name.go
@@ -52,8 +52,8 @@ const (
        TagEditSlugName             = "tag.edit_slug_name"
        TagEditWithoutReview        = "tag.edit_without_review"
        TagDelete                   = "tag.delete"
-       TagSynonym                  = "tag.synonym"
        TagMerge                    = "tag.merge"
+       TagSynonym                  = "tag.synonym"
        LinkUrlLimit                = "link.url_limit"
        VoteDetail                  = "vote.detail"
        AnswerAudit                 = "answer.audit"
@@ -69,6 +69,7 @@ const (
        reportActionName                = "action.report"
        editActionName                  = "action.edit"
        deleteActionName                = "action.delete"
+       mergeActionName                 = "action.merge"
        undeleteActionName              = "action.undelete"
        closeActionName                 = "action.close"
        reopenActionName                = "action.reopen"
diff --git a/internal/service/permission/tag_permission.go 
b/internal/service/permission/tag_permission.go
index 53161033..67ac2fa0 100644
--- a/internal/service/permission/tag_permission.go
+++ b/internal/service/permission/tag_permission.go
@@ -21,6 +21,7 @@ package permission
 
 import (
        "context"
+
        "github.com/apache/answer/internal/entity"
 
        "github.com/apache/answer/internal/base/handler"
@@ -29,7 +30,7 @@ import (
 )
 
 // GetTagPermission get tag permission
-func GetTagPermission(ctx context.Context, status int, canEdit, canDelete, 
canRecover bool) (
+func GetTagPermission(ctx context.Context, status int, canEdit, canDelete, 
canMerge, canRecover bool) (
        actions []*schema.PermissionMemberAction) {
        lang := handler.GetLangByCtx(ctx)
        actions = make([]*schema.PermissionMemberAction, 0)
@@ -49,6 +50,14 @@ func GetTagPermission(ctx context.Context, status int, 
canEdit, canDelete, canRe
                })
        }
 
+       if canMerge && status != entity.TagStatusDeleted {
+               actions = append(actions, &schema.PermissionMemberAction{
+                       Action: "merge",
+                       Name:   translator.Tr(lang, mergeActionName),
+                       Type:   "edit",
+               })
+       }
+
        if canRecover && status == entity.QuestionStatusDeleted {
                actions = append(actions, &schema.PermissionMemberAction{
                        Action: "undelete",
diff --git a/internal/service/tag/tag_service.go 
b/internal/service/tag/tag_service.go
index 8b614a7a..a5df5fe8 100644
--- a/internal/service/tag/tag_service.go
+++ b/internal/service/tag/tag_service.go
@@ -186,7 +186,7 @@ func (ts *TagService) GetTagInfo(ctx context.Context, req 
*schema.GetTagInfoReq)
        resp.Reserved = tagInfo.Reserved
        resp.IsFollower = ts.checkTagIsFollow(ctx, req.UserID, tagInfo.ID)
        resp.Status = entity.TagStatusDisplayMapping[tagInfo.Status]
-       resp.MemberActions = permission.GetTagPermission(ctx, tagInfo.Status, 
req.CanEdit, req.CanDelete, req.CanRecover)
+       resp.MemberActions = permission.GetTagPermission(ctx, tagInfo.Status, 
req.CanEdit, req.CanDelete, req.CanMerge, req.CanRecover)
        resp.GetExcerpt()
        return resp, nil
 }
diff --git a/internal/service/tag_common/tag_common.go 
b/internal/service/tag_common/tag_common.go
index 5f59e374..df7c7ef2 100644
--- a/internal/service/tag_common/tag_common.go
+++ b/internal/service/tag_common/tag_common.go
@@ -137,6 +137,7 @@ func (ts *TagCommonService) SearchTagLike(ctx 
context.Context, req *schema.Searc
                }
                mainTagID := converter.IntToString(tag.MainTagID)
                if _, ok := mainTagMap[mainTagID]; ok {
+                       tag.ID = mainTagMap[mainTagID].ID
                        tag.SlugName = mainTagMap[mainTagID].SlugName
                        tag.DisplayName = mainTagMap[mainTagID].DisplayName
                        tag.Reserved = mainTagMap[mainTagID].Reserved
@@ -148,6 +149,7 @@ func (ts *TagCommonService) SearchTagLike(ctx 
context.Context, req *schema.Searc
        for _, tag := range tags {
                if _, ok := repetitiveTag[tag.SlugName]; !ok {
                        item := schema.GetTagBasicResp{}
+                       item.TagID = tag.ID
                        item.SlugName = tag.SlugName
                        item.DisplayName = tag.DisplayName
                        item.Recommend = tag.Recommend
diff --git a/ui/src/components/Modal/MergeTagModal.tsx 
b/ui/src/components/Modal/MergeTagModal.tsx
new file mode 100644
index 00000000..6441331e
--- /dev/null
+++ b/ui/src/components/Modal/MergeTagModal.tsx
@@ -0,0 +1,233 @@
+/*
+ * 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 { FC, useState, useEffect, useCallback, useRef } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Form, Dropdown } from 'react-bootstrap';
+
+import { TagInfo } from '@/common/interface';
+import request from '@/utils/request';
+
+import Modal from './Modal';
+
+interface Props {
+  visible: boolean;
+  sourceTag: TagInfo;
+  onClose: () => void;
+  onConfirm: (sourceTagID: string, targetTagID: string) => void;
+}
+
+interface SearchTagResp {
+  tag_id: string;
+  slug_name: string;
+  display_name: string;
+  recommend: boolean;
+  reserved: boolean;
+}
+
+const MergeTagModal: FC<Props> = ({
+  visible,
+  sourceTag,
+  onClose,
+  onConfirm,
+}) => {
+  const { t } = useTranslation('translation', {
+    keyPrefix: 'tag_info.merge',
+  });
+  const [targetTag, setTargetTag] = useState<SearchTagResp | null>(null);
+  const [searchValue, setSearchValue] = useState('');
+  const [tags, setTags] = useState<SearchTagResp[]>([]);
+  const [currentIndex, setCurrentIndex] = useState(0);
+  const [dropdownVisible, setDropdownVisible] = useState(false);
+  const [isFocused, setIsFocused] = useState(false);
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  const searchTags = async (search: string) => {
+    try {
+      const res = await request.get<SearchTagResp[]>(
+        '/answer/api/v1/question/tags',
+        {
+          params: { tag: search },
+        },
+      );
+      // Filter out the source tag from results
+      const filteredTags = res.filter(
+        (tag) => tag.slug_name !== sourceTag.slug_name,
+      );
+      setTags(filteredTags);
+      if (filteredTags.length > 0 && isFocused) {
+        setDropdownVisible(true);
+      }
+    } catch (error) {
+      console.error('Failed to search tags:', error);
+      setTags([]);
+    }
+  };
+
+  // Debounced search function
+  const debouncedSearch = useCallback(
+    (() => {
+      let timeout: number | undefined;
+      return (search: string) => {
+        if (timeout) {
+          clearTimeout(timeout);
+        }
+        timeout = window.setTimeout(() => {
+          searchTags(search);
+        }, 1000);
+      };
+    })(),
+    [isFocused],
+  );
+
+  useEffect(() => {
+    if (visible) {
+      searchTags('');
+      setSearchValue('');
+      setTargetTag(null);
+      setCurrentIndex(0);
+      setDropdownVisible(false);
+      setIsFocused(false);
+    }
+  }, [visible]);
+
+  const handleConfirm = () => {
+    if (!targetTag) return;
+    onConfirm(sourceTag.tag_id, targetTag.tag_id);
+  };
+
+  const handleSelect = (tag: SearchTagResp) => {
+    setTargetTag(tag);
+    setDropdownVisible(false);
+    setSearchValue(tag.display_name);
+    setIsFocused(false);
+    inputRef.current?.blur();
+  };
+
+  const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
+    const { value } = e.target;
+    setSearchValue(value);
+    if (value) {
+      debouncedSearch(value);
+    } else {
+      searchTags('');
+    }
+  };
+
+  const handleFocus = () => {
+    setIsFocused(true);
+    setDropdownVisible(true);
+  };
+
+  const handleBlur = () => {
+    // Use setTimeout to allow click events on dropdown items to fire before 
closing
+    setTimeout(() => {
+      setIsFocused(false);
+      if (!targetTag) {
+        setDropdownVisible(false);
+      }
+    }, 200);
+  };
+
+  const handleKeyDown = (e: React.KeyboardEvent) => {
+    if (!tags.length) return;
+
+    switch (e.key) {
+      case 'ArrowDown':
+        e.preventDefault();
+        setCurrentIndex((prev) => (prev < tags.length - 1 ? prev + 1 : prev));
+        break;
+      case 'ArrowUp':
+        e.preventDefault();
+        setCurrentIndex((prev) => (prev > 0 ? prev - 1 : prev));
+        break;
+      case 'Enter':
+        e.preventDefault();
+        if (currentIndex >= 0 && currentIndex < tags.length) {
+          handleSelect(tags[currentIndex]);
+        }
+        break;
+      case 'Escape':
+        e.preventDefault();
+        inputRef.current?.blur();
+        setDropdownVisible(false);
+        break;
+      default:
+        break;
+    }
+  };
+
+  return (
+    <Modal
+      title={t('title')}
+      visible={visible}
+      onCancel={onClose}
+      onConfirm={handleConfirm}
+      confirmText={t('btn_submit')}
+      confirmBtnVariant="primary"
+      cancelText={t('btn_close')}
+      cancelBtnVariant="link"
+      confirmBtnDisabled={!targetTag}>
+      <Form>
+        <Form.Group className="mb-3">
+          <Form.Label>{t('source_tag_title')}</Form.Label>
+          <Form.Control value={sourceTag.display_name} disabled />
+          <Form.Text className="text-muted">
+            {t('source_tag_description')}
+          </Form.Text>
+        </Form.Group>
+        <Form.Group>
+          <Form.Label>{t('target_tag_title')}</Form.Label>
+          <Dropdown
+            show={dropdownVisible && (tags.length > 0 || Boolean(searchValue))}
+            onToggle={setDropdownVisible}>
+            <Form.Control
+              ref={inputRef}
+              type="text"
+              value={searchValue}
+              onChange={handleSearch}
+              onKeyDown={handleKeyDown}
+              onFocus={handleFocus}
+              onBlur={handleBlur}
+              autoComplete="off"
+            />
+            <Dropdown.Menu className="w-100">
+              {tags.map((tag, index) => (
+                <Dropdown.Item
+                  key={tag.slug_name}
+                  active={index === currentIndex}
+                  onClick={() => handleSelect(tag)}>
+                  {tag.display_name}
+                </Dropdown.Item>
+              ))}
+              {tags.length === 0 && searchValue && (
+                <Dropdown.Item disabled>{t('no_results')}</Dropdown.Item>
+              )}
+            </Dropdown.Menu>
+          </Dropdown>
+          <Form.Text className="text-muted">
+            {t('target_tag_description')}
+          </Form.Text>
+        </Form.Group>
+      </Form>
+    </Modal>
+  );
+};
+
+export default MergeTagModal;
diff --git a/ui/src/components/Modal/index.tsx 
b/ui/src/components/Modal/index.tsx
index 75d96842..6d95c312 100644
--- a/ui/src/components/Modal/index.tsx
+++ b/ui/src/components/Modal/index.tsx
@@ -21,6 +21,7 @@ import DefaultModal from './Modal';
 import confirm, { Config } from './Confirm';
 import LoginToContinueModal from './LoginToContinueModal';
 import BadgeModal from './BadgeModal';
+import MergeTagModal from './MergeTagModal';
 
 type ModalType = typeof DefaultModal & {
   confirm: (config: Config) => void;
@@ -33,4 +34,4 @@ Modal.confirm = function (props: Config) {
 
 export default Modal;
 
-export { LoginToContinueModal, BadgeModal };
+export { LoginToContinueModal, BadgeModal, MergeTagModal };
diff --git a/ui/src/pages/Tags/Info/index.tsx b/ui/src/pages/Tags/Info/index.tsx
index 1c32e249..3a9d21ee 100644
--- a/ui/src/pages/Tags/Info/index.tsx
+++ b/ui/src/pages/Tags/Info/index.tsx
@@ -33,9 +33,11 @@ import {
   deleteTag,
   editCheck,
   unDeleteTag,
+  mergeTag,
 } from '@/services';
 import { pathFactory } from '@/router/pathFactory';
 import { loggedUserInfoStore, toastStore } from '@/stores';
+import { MergeTagModal } from '@/components/Modal';
 
 const TagIntroduction = () => {
   const userInfo = loggedUserInfoStore((state) => state.user);
@@ -52,6 +54,8 @@ const TagIntroduction = () => {
     tagInfo?.tag_id,
     tagInfo?.status,
   );
+  const [showMergeModal, setShowMergeModal] = useState(false);
+
   let pageTitle = '';
   if (tagInfo) {
     pageTitle = `'${tagInfo.display_name}' ${t('tag_wiki', {
@@ -156,6 +160,19 @@ const TagIntroduction = () => {
       },
     });
   };
+  const handleMergeTag = () => {
+    setShowMergeModal(true);
+  };
+
+  const handleMergeConfirm = (sourceTagID: string, targetTagID: string) => {
+    mergeTag({ source_tag_id: sourceTagID, target_tag_id: targetTagID }).then(
+      () => {
+        setShowMergeModal(false);
+        navigate('/tags', { replace: true });
+      },
+    );
+  };
+
   const onAction = (params) => {
     if (params.action === 'edit') {
       handleEditTag();
@@ -163,6 +180,9 @@ const TagIntroduction = () => {
     if (params.action === 'delete') {
       handleDeleteTag();
     }
+    if (params.action === 'merge') {
+      handleMergeTag();
+    }
     if (params.action === 'undelete') {
       Modal.confirm({
         title: t('undelete_title', { keyPrefix: 'delete' }),
@@ -181,136 +201,144 @@ const TagIntroduction = () => {
   };
 
   return (
-    <Row className="pt-4 mb-5">
-      <Col className="page-main flex-auto">
-        {tagInfo?.status === 'deleted' && (
-          <Alert variant="danger" className="mb-4">
-            {t('post_deleted', { keyPrefix: 'messages' })}
-          </Alert>
-        )}
-        <h3 className="mb-3">
-          <Link
-            to={pathFactory.tagLanding(tagInfo.slug_name)}
-            replace
-            className="link-dark">
-            {tagInfo.display_name}
-          </Link>
-        </h3>
+    <>
+      <Row className="pt-4 mb-5">
+        <Col className="page-main flex-auto">
+          {tagInfo?.status === 'deleted' && (
+            <Alert variant="danger" className="mb-4">
+              {t('post_deleted', { keyPrefix: 'messages' })}
+            </Alert>
+          )}
+          <h3 className="mb-3">
+            <Link
+              to={pathFactory.tagLanding(tagInfo.slug_name)}
+              replace
+              className="link-dark">
+              {tagInfo.display_name}
+            </Link>
+          </h3>
 
-        <div className="text-secondary mb-4 small">
-          <FormatTime preFix={t('created_at')} time={tagInfo.created_at} />
-          <FormatTime
-            preFix={t('edited_at')}
-            className="ms-3"
-            time={tagInfo.updated_at}
-          />
-        </div>
+          <div className="text-secondary mb-4 small">
+            <FormatTime preFix={t('created_at')} time={tagInfo.created_at} />
+            <FormatTime
+              preFix={t('edited_at')}
+              className="ms-3"
+              time={tagInfo.updated_at}
+            />
+          </div>
 
-        <div
-          className="content text-break fmt"
-          dangerouslySetInnerHTML={{ __html: tagInfo?.parsed_text }}
-        />
-        <div className="mt-4">
-          {tagInfo?.member_actions.map((action, index) => {
-            return (
-              <Button
-                key={action.name}
-                variant="link"
-                size="sm"
+          <div
+            className="content text-break fmt"
+            dangerouslySetInnerHTML={{ __html: tagInfo?.parsed_text }}
+          />
+          <div className="mt-4">
+            {tagInfo?.member_actions.map((action, index) => {
+              return (
+                <Button
+                  key={action.name}
+                  variant="link"
+                  size="sm"
+                  className={classNames(
+                    'link-secondary btn-no-border p-0',
+                    index > 0 && 'ms-3',
+                  )}
+                  onClick={() => onAction(action)}>
+                  {action.name}
+                </Button>
+              );
+            })}
+            {isLogged && (
+              <Link
+                to={`/tags/${tagInfo?.tag_id}/timeline`}
                 className={classNames(
-                  'link-secondary btn-no-border p-0',
-                  index > 0 && 'ms-3',
-                )}
-                onClick={() => onAction(action)}>
-                {action.name}
-              </Button>
-            );
-          })}
-          {isLogged && (
-            <Link
-              to={`/tags/${tagInfo?.tag_id}/timeline`}
-              className={classNames(
-                'link-secondary btn-no-border p-0 small',
-                tagInfo?.member_actions?.length > 0 && 'ms-3',
-              )}>
-              {t('history')}
-            </Link>
-          )}
-        </div>
-      </Col>
-      <Col className="page-right-side mt-4 mt-xl-0">
-        <Card>
-          <Card.Header className="d-flex justify-content-between">
-            <span>{t('synonyms.title')}</span>
-            {isEdit ? (
-              <Button
-                variant="link"
-                className="p-0 btn-no-border"
-                onClick={handleSave}>
-                {t('synonyms.btn_save')}
-              </Button>
-            ) : synonymsData?.member_actions?.find(
-                (v) => v.action === 'edit',
-              ) ? (
-              <Button
-                variant="link"
-                className="p-0 btn-no-border"
-                onClick={handleEdit}>
-                {t('synonyms.btn_edit')}
-              </Button>
-            ) : null}
-          </Card.Header>
-          <Card.Body>
-            {isEdit && (
-              <>
-                <div className="mb-3">
-                  {t('synonyms.text')}{' '}
-                  <Tag
-                    data={{
-                      slug_name: tagName || '',
-                      main_tag_slug_name: '',
-                      display_name:
-                        tagInfo?.display_name || tagInfo?.slug_name || '',
-                      recommend: false,
-                      reserved: false,
-                    }}
-                  />
-                </div>
-                <TagSelector
-                  value={synonymsData?.synonyms}
-                  onChange={handleTagsChange}
-                  hiddenDescription
-                />
-              </>
+                  'link-secondary btn-no-border p-0 small',
+                  tagInfo?.member_actions?.length > 0 && 'ms-3',
+                )}>
+                {t('history')}
+              </Link>
             )}
-            {!isEdit &&
-              (synonymsData?.synonyms && synonymsData.synonyms.length > 0 ? (
-                <div className="m-n1">
-                  {synonymsData.synonyms.map((item) => {
-                    return (
-                      <Tag key={item.tag_id} className="m-1" data={item} />
-                    );
-                  })}
-                </div>
-              ) : (
+          </div>
+        </Col>
+        <Col className="page-right-side mt-4 mt-xl-0">
+          <Card>
+            <Card.Header className="d-flex justify-content-between">
+              <span>{t('synonyms.title')}</span>
+              {isEdit ? (
+                <Button
+                  variant="link"
+                  className="p-0 btn-no-border"
+                  onClick={handleSave}>
+                  {t('synonyms.btn_save')}
+                </Button>
+              ) : synonymsData?.member_actions?.find(
+                  (v) => v.action === 'edit',
+                ) ? (
+                <Button
+                  variant="link"
+                  className="p-0 btn-no-border"
+                  onClick={handleEdit}>
+                  {t('synonyms.btn_edit')}
+                </Button>
+              ) : null}
+            </Card.Header>
+            <Card.Body>
+              {isEdit && (
                 <>
-                  <div className="text-muted mb-3">{t('synonyms.empty')}</div>
-                  {synonymsData?.member_actions?.find(
-                    (v) => v.action === 'edit',
-                  ) && (
-                    <Button
-                      variant="outline-primary"
-                      size="sm"
-                      onClick={handleEdit}>
-                      {t('synonyms.btn_add')}
-                    </Button>
-                  )}
+                  <div className="mb-3">
+                    {t('synonyms.text')}{' '}
+                    <Tag
+                      data={{
+                        slug_name: tagName || '',
+                        main_tag_slug_name: '',
+                        display_name:
+                          tagInfo?.display_name || tagInfo?.slug_name || '',
+                        recommend: false,
+                        reserved: false,
+                      }}
+                    />
+                  </div>
+                  <TagSelector
+                    value={synonymsData?.synonyms}
+                    onChange={handleTagsChange}
+                    hiddenDescription
+                  />
                 </>
-              ))}
-          </Card.Body>
-        </Card>
-      </Col>
-    </Row>
+              )}
+              {!isEdit &&
+                (synonymsData?.synonyms && synonymsData.synonyms.length > 0 ? (
+                  <div className="m-n1">
+                    {synonymsData.synonyms.map((item) => {
+                      return (
+                        <Tag key={item.tag_id} className="m-1" data={item} />
+                      );
+                    })}
+                  </div>
+                ) : (
+                  <>
+                    <div className="text-muted 
mb-3">{t('synonyms.empty')}</div>
+                    {synonymsData?.member_actions?.find(
+                      (v) => v.action === 'edit',
+                    ) && (
+                      <Button
+                        variant="outline-primary"
+                        size="sm"
+                        onClick={handleEdit}>
+                        {t('synonyms.btn_add')}
+                      </Button>
+                    )}
+                  </>
+                ))}
+            </Card.Body>
+          </Card>
+        </Col>
+      </Row>
+      <MergeTagModal
+        visible={showMergeModal}
+        sourceTag={tagInfo}
+        onClose={() => setShowMergeModal(false)}
+        onConfirm={handleMergeConfirm}
+      />
+    </>
   );
 };
 
diff --git a/ui/src/services/client/tag.ts b/ui/src/services/client/tag.ts
index 25741315..b94e7876 100644
--- a/ui/src/services/client/tag.ts
+++ b/ui/src/services/client/tag.ts
@@ -31,6 +31,12 @@ export const deleteTag = (id) => {
 export const modifyTag = (params) => {
   return request.put('/answer/api/v1/tag', params);
 };
+export const mergeTag = (params: {
+  source_tag_id: string;
+  target_tag_id: string;
+}) => {
+  return request.post('/answer/api/v1/tag/merge', params);
+};
 
 export const useQuerySynonymsTags = (tagId, status) => {
   const apiUrl =

Reply via email to