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


The following commit(s) were added to refs/heads/dev by this push:
     new 83145487 fix: Optimize the internal logic of merge tags and ui #1110
83145487 is described below

commit 83145487118ec28fb746cbf7b878f09c591e35e0
Author: shuai <lishuail...@sifou.com>
AuthorDate: Sun Apr 27 11:58:29 2025 +0800

    fix: Optimize the internal logic of merge tags and ui #1110
---
 i18n/zh_CN.yaml                                    |   9 +
 ui/src/components/Modal/MergeTagModal.tsx          | 248 ---------------------
 ui/src/components/Modal/index.tsx                  |   3 +-
 .../Tags/Info/components/MergeTagModal/index.scss  |   6 +
 .../Tags/Info/components/MergeTagModal/index.tsx   | 206 +++++++++++++++++
 ui/src/pages/Tags/Info/index.tsx                   |   3 +-
 6 files changed, 224 insertions(+), 251 deletions(-)

diff --git a/i18n/zh_CN.yaml b/i18n/zh_CN.yaml
index fa43da24..a1ba1314 100644
--- a/i18n/zh_CN.yaml
+++ b/i18n/zh_CN.yaml
@@ -57,6 +57,8 @@ backend:
       other: 编辑
     undelete:
       other: 撤消删除
+    merge:
+      other: 合并
   role:
     name:
       user:
@@ -1027,6 +1029,13 @@ ui:
         <p>我们不允许 <strong>删除带有同义词的标签</strong>。</p> <p>请先从此标签中删除同义词。</p>
       tip: 确定要删除吗?
       close: 关闭
+    merge:
+      title: 合并标签
+      source_tag_title: 源标签
+      source_tag_description: 源标签及其相关数据将重新映射到目标标签。
+      target_tag_title: 目标标签
+      target_tag_description: 合并后将在这两个标签之间将创建一个同义词。
+      no_results: 没有匹配的标签
   edit_tag:
     title: 编辑标签
     default_reason: 编辑标签
diff --git a/ui/src/components/Modal/MergeTagModal.tsx 
b/ui/src/components/Modal/MergeTagModal.tsx
deleted file mode 100644
index 68e32699..00000000
--- a/ui/src/components/Modal/MergeTagModal.tsx
+++ /dev/null
@@ -1,248 +0,0 @@
-/*
- * 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, useMemo } from 'react';
-import { useTranslation } from 'react-i18next';
-import { Form, Dropdown } from 'react-bootstrap';
-
-import debounce from 'lodash/debounce';
-
-import { TagInfo } from '@/common/interface';
-import { queryTags } from '@/services';
-
-import Modal from './Modal';
-
-const DEBOUNCE_DELAY = 300;
-
-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 [hasSearched, setHasSearched] = useState(false);
-  const inputRef = useRef<HTMLInputElement>(null);
-
-  const filteredTags = useMemo(() => {
-    return tags.filter((tag) => tag.slug_name !== sourceTag.slug_name);
-  }, [tags, sourceTag.slug_name]);
-
-  const searchTags = useCallback(async (search: string) => {
-    try {
-      const res = await queryTags(search);
-      setTags(res || []);
-      setHasSearched(true);
-    } catch (error) {
-      console.error('Failed to search tags:', error);
-      setTags([]);
-      setHasSearched(true);
-    }
-  }, []);
-
-  const debouncedSearch = useMemo(
-    () => debounce(searchTags, DEBOUNCE_DELAY),
-    [searchTags],
-  );
-
-  const handleConfirm = useCallback(() => {
-    if (!targetTag) return;
-    onConfirm(sourceTag.tag_id, targetTag.tag_id);
-  }, [targetTag, sourceTag.tag_id, onConfirm]);
-
-  const handleSelect = useCallback((tag: SearchTagResp) => {
-    setTargetTag(tag);
-    setDropdownVisible(false);
-    setSearchValue(tag.display_name);
-    setIsFocused(false);
-    inputRef.current?.blur();
-  }, []);
-
-  const handleSearch = useCallback(
-    (e: React.ChangeEvent<HTMLInputElement>) => {
-      const { value } = e.target;
-      setSearchValue(value);
-      setHasSearched(false);
-      if (value) {
-        debouncedSearch(value);
-      } else {
-        searchTags('');
-      }
-    },
-    [debouncedSearch, searchTags],
-  );
-
-  const handleFocus = useCallback(() => {
-    setIsFocused(true);
-    setDropdownVisible(true);
-  }, []);
-
-  const handleBlur = useCallback(() => {
-    setTimeout(() => {
-      setIsFocused(false);
-      if (!targetTag) {
-        setDropdownVisible(false);
-      }
-    }, 200);
-  }, [targetTag]);
-
-  const handleKeyDown = useCallback(
-    (e: React.KeyboardEvent) => {
-      if (!filteredTags.length) return;
-
-      switch (e.key) {
-        case 'ArrowDown':
-          e.preventDefault();
-          setCurrentIndex((prev) =>
-            prev < filteredTags.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 < filteredTags.length) {
-            handleSelect(filteredTags[currentIndex]);
-          }
-          break;
-        case 'Escape':
-          e.preventDefault();
-          inputRef.current?.blur();
-          setDropdownVisible(false);
-          break;
-        default:
-          break;
-      }
-    },
-    [filteredTags, currentIndex, handleSelect],
-  );
-
-  useEffect(() => {
-    if (visible) {
-      searchTags('');
-      setSearchValue('');
-      setTargetTag(null);
-      setCurrentIndex(0);
-      setDropdownVisible(false);
-      setIsFocused(false);
-      setHasSearched(false);
-    }
-    return () => {
-      debouncedSearch.cancel();
-    };
-  }, [visible, searchTags, debouncedSearch]);
-
-  useEffect(() => {
-    if (filteredTags.length > 0 && isFocused) {
-      setDropdownVisible(true);
-    }
-  }, [filteredTags, isFocused]);
-
-  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>
-          <div className="position-relative">
-            <Dropdown
-              show={
-                dropdownVisible &&
-                (filteredTags.length > 0 || Boolean(searchValue))
-              }
-              onToggle={setDropdownVisible}>
-              <Form.Control
-                ref={inputRef}
-                type="text"
-                value={searchValue}
-                onChange={handleSearch}
-                onKeyDown={handleKeyDown}
-                onFocus={handleFocus}
-                onBlur={handleBlur}
-                autoComplete="off"
-              />
-              {filteredTags.length !== 0 && (
-                <Dropdown.Menu className="w-100">
-                  {filteredTags.map((tag, index) => (
-                    <Dropdown.Item
-                      key={tag.slug_name}
-                      active={index === currentIndex}
-                      onClick={() => handleSelect(tag)}>
-                      {tag.display_name}
-                    </Dropdown.Item>
-                  ))}
-                </Dropdown.Menu>
-              )}
-              {filteredTags.length === 0 && searchValue && hasSearched && (
-                <Dropdown.Menu className="w-100">
-                  <Dropdown.Item disabled>{t('no_results')}</Dropdown.Item>
-                </Dropdown.Menu>
-              )}
-            </Dropdown>
-          </div>
-          <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 6d95c312..75d96842 100644
--- a/ui/src/components/Modal/index.tsx
+++ b/ui/src/components/Modal/index.tsx
@@ -21,7 +21,6 @@ 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;
@@ -34,4 +33,4 @@ Modal.confirm = function (props: Config) {
 
 export default Modal;
 
-export { LoginToContinueModal, BadgeModal, MergeTagModal };
+export { LoginToContinueModal, BadgeModal };
diff --git a/ui/src/pages/Tags/Info/components/MergeTagModal/index.scss 
b/ui/src/pages/Tags/Info/components/MergeTagModal/index.scss
new file mode 100644
index 00000000..d798d896
--- /dev/null
+++ b/ui/src/pages/Tags/Info/components/MergeTagModal/index.scss
@@ -0,0 +1,6 @@
+.mergeTagModal {
+  .dropdown-item.active {
+    color: var(--bs-body-color);
+    background-color: var(--an-invite-answer-item-active);
+  }
+}
diff --git a/ui/src/pages/Tags/Info/components/MergeTagModal/index.tsx 
b/ui/src/pages/Tags/Info/components/MergeTagModal/index.tsx
new file mode 100644
index 00000000..88bd6599
--- /dev/null
+++ b/ui/src/pages/Tags/Info/components/MergeTagModal/index.tsx
@@ -0,0 +1,206 @@
+/*
+ * 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, Modal, Button } from 'react-bootstrap';
+
+import debounce from 'lodash/debounce';
+
+import { TagInfo } from '@/common/interface';
+import { queryTags } from '@/services';
+
+import './index.scss';
+
+const DEBOUNCE_DELAY = 400;
+
+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 inputRef = useRef<HTMLInputElement>(null);
+
+  const searchTags = useCallback(
+    debounce((search) => {
+      if (!search) {
+        setTags([]);
+        return;
+      }
+      queryTags(search).then((res) => {
+        const filteredTags =
+          res.filter((tag) => tag.slug_name !== sourceTag.slug_name) || [];
+        setTags(filteredTags || []);
+        setDropdownVisible(true);
+      });
+    }, DEBOUNCE_DELAY),
+    [],
+  );
+
+  const handleConfirm = () => {
+    if (!targetTag) return;
+    onConfirm(sourceTag.tag_id, targetTag.tag_id);
+  };
+
+  const handleSelect = (tag: SearchTagResp) => {
+    setTargetTag(tag);
+    setDropdownVisible(false);
+    setSearchValue(tag.display_name);
+    inputRef.current?.blur();
+  };
+
+  const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
+    const searchStr = e.currentTarget.value.trim();
+    setSearchValue(searchStr);
+    searchTags(searchStr);
+  };
+
+  const handleBlur = () => {
+    setTimeout(() => {
+      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;
+    }
+  };
+
+  useEffect(() => {
+    if (visible) {
+      searchTags('');
+      setSearchValue('');
+      setTargetTag(null);
+      setCurrentIndex(0);
+      setDropdownVisible(false);
+    }
+  }, [visible]);
+
+  return (
+    <Modal show={visible} onCancel={onClose} className="mergeTagModal">
+      <Modal.Header>
+        <Modal.Title>{t('title')}</Modal.Title>
+      </Modal.Header>
+      <Modal.Body>
+        <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>
+            <div className="position-relative">
+              <Dropdown
+                show={Boolean(dropdownVisible && searchValue)}
+                onToggle={setDropdownVisible}>
+                <Form.Control
+                  ref={inputRef}
+                  type="text"
+                  value={searchValue}
+                  onChange={handleSearch}
+                  onKeyDown={handleKeyDown}
+                  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 && searchValue && (
+                    <Dropdown.Item disabled>{t('no_results')}</Dropdown.Item>
+                  )}
+                </Dropdown.Menu>
+              </Dropdown>
+            </div>
+            <Form.Text className="text-muted">
+              {t('target_tag_description')}
+            </Form.Text>
+          </Form.Group>
+        </Form>
+      </Modal.Body>
+      <Modal.Footer>
+        <Button variant="link" onClick={onClose}>
+          {t('close', { keyPrefix: 'btns' })}
+        </Button>
+        <Button onClick={handleConfirm} disabled={!targetTag}>
+          {t('submit', { keyPrefix: 'btns' })}
+        </Button>
+      </Modal.Footer>
+    </Modal>
+  );
+};
+
+export default MergeTagModal;
diff --git a/ui/src/pages/Tags/Info/index.tsx b/ui/src/pages/Tags/Info/index.tsx
index 3a9d21ee..2008e187 100644
--- a/ui/src/pages/Tags/Info/index.tsx
+++ b/ui/src/pages/Tags/Info/index.tsx
@@ -37,7 +37,8 @@ import {
 } from '@/services';
 import { pathFactory } from '@/router/pathFactory';
 import { loggedUserInfoStore, toastStore } from '@/stores';
-import { MergeTagModal } from '@/components/Modal';
+
+import MergeTagModal from './components/MergeTagModal';
 
 const TagIntroduction = () => {
   const userInfo = loggedUserInfoStore((state) => state.user);

Reply via email to