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);