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 <[email protected]>
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);