/** * 标签管理器 * 负责管理标签的显示和操作 */ import { logger } from '../utils/logger.js'; import { CacheService, TagCacheService } from "../services/cache.js"; import { UIToolkit } from "../utils/UIToolkit.js"; import { PopupManager } from "../utils/popupManager.js"; import { ResourceManager } from "../utils/resourceManager.js"; import { EventManager } from "../utils/eventManager.js"; import { PromptFormatter } from "../utils/promptFormatter.js"; import { createSettingsDialog, showContextMenu, createConfirmPopup } from "./uiComponents.js"; /** * 标签管理器类 * 管理标签弹窗和标签选择 */ class TagManager { // ---UI状态持久化配置--- static LAST_TAB_KEY = 'PromptAssistant_TagPopup_LastTab'; // 上次激活的标签页 static ACCORDION_STATE_KEY = 'PromptAssistant_TagPopup_AccordionState'; // 手风琴展开状态 static POPUP_SIZE_KEY = 'PromptAssistant_TagPopup_Size'; // 弹窗尺寸 /** * 获取上次激活的标签页(分类名) */ static getLastActiveTab() { try { return CacheService.get(this.LAST_TAB_KEY) || null; } catch (e) { return null; } } /** * 记录本次激活的标签页(分类名) */ static setLastActiveTab(category) { try { if (category && typeof category === 'string') { CacheService.set(this.LAST_TAB_KEY, category); } } catch (e) { } } /** * 获取手风琴展开状态 * @returns {Object} { tabName: { accordionPath: isExpanded } } */ static getAccordionState() { try { const state = CacheService.get(this.ACCORDION_STATE_KEY); return state ? JSON.parse(state) : {}; } catch (e) { return {}; } } /** * 保存手风琴展开状态 * @param {string} tabName 标签页名称 * @param {string} accordionPath 手风琴路径(用分类名表示) * @param {boolean} isExpanded 是否展开 */ static setAccordionState(tabName, accordionPath, isExpanded) { try { const state = this.getAccordionState(); if (!state[tabName]) { state[tabName] = {}; } state[tabName][accordionPath] = isExpanded; CacheService.set(this.ACCORDION_STATE_KEY, JSON.stringify(state)); } catch (e) { logger.error(`保存手风琴状态失败: ${e.message}`); } } /** * 获取保存的弹窗尺寸 * @returns {Object|null} { width: number, height: number } */ static getPopupSize() { try { const size = CacheService.get(this.POPUP_SIZE_KEY); return size ? JSON.parse(size) : null; } catch (e) { return null; } } /** * 保存弹窗尺寸 * @param {number} width 宽度 * @param {number} height 高度 */ static setPopupSize(width, height) { try { const size = { width, height }; CacheService.set(this.POPUP_SIZE_KEY, JSON.stringify(size)); } catch (e) { logger.error(`保存窗口大小失败: ${e.message}`); } } /** * 递归查找分类对象 * @param {Object} obj 数据对象 * @param {string} catName 分类名称 * @returns {Object|null} 找到的分类对象或 null * @note 对于虚拟分类"标签"(仅在没有实际分类时使用),返回根对象本身 */ static _findCategoryRecursively(obj, catName) { if (!obj || typeof obj !== 'object') return null; // 虚拟分类"标签"代表根级别,返回根对象本身 // 这个分类仅在 CSV 中没有任何实际分类,只有根标签时使用 if (catName === "" || catName === "标签") return obj; for (const [key, value] of Object.entries(obj)) { if (key === catName && typeof value === 'object' && value !== null) { return value; } if (typeof value === 'object' && value !== null) { const result = this._findCategoryRecursively(value, catName); if (result) return result; } } return null; } /** * 递归查找标签及其父对象 * @param {Object} obj 数据对象 * @param {string} tagName 标签名称 * @param {string} tagValue 标签值 * @returns {Object|null} 包含 {parent, key} 的对象或 null */ static _findTagRecursively(obj, tagName, tagValue) { if (!obj || typeof obj !== 'object') return null; for (const [key, value] of Object.entries(obj)) { if (key === tagName && value === tagValue) { return { parent: obj, key: key }; } if (typeof value === 'object' && value !== null) { const result = this._findTagRecursively(value, tagName, tagValue); if (result) return result; } } return null; } static popupInstance = null; static onCloseCallback = null; // 添加关闭回调存储 static eventCleanups = []; // 事件清理函数数组 static searchTimeout = null; // 搜索延迟定时器 static currentNodeId = null; static currentInputId = null; static currentWidgetKey = null; static activeTooltip = null; static usedTags = new Map(); // 存储已使用标签的Map: key为标签值,value为对应的DOM元素 static currentCsvFile = null; // 当前选中的CSV文件 static favorites = {}; // 收藏列表缓存 {name: value} static tagLookup = new Map(); // 标签值到名称的映射表 static Sortable = null; // Sortable 库引用 static sortables = []; // 存储 sortable 实例以供清理 static tagData = null; // 当前CSV文件的标签数据 /** * 初始化 Sortable */ static async _initSortable() { if (this.Sortable) return; try { this.Sortable = await ResourceManager.getSortable(); } catch (error) { logger.warn('Sortable library not loaded', error); } } /** * 检查标签是否已插入到输入框中 */ static isTagUsed(tagValue, nodeId, inputId) { const mapping = UIToolkit._findMapping(nodeId, inputId, this.currentWidgetKey); if (!mapping || !mapping.inputEl) return false; // 检查输入框内容是否包含标签的任一格式 const inputValue = mapping.inputEl.value; return TagCacheService.isTagInInput(nodeId, inputId, tagValue, inputValue); } /** * 更新标签状态 */ static updateTagState(tagElement, isUsed) { if (isUsed) { tagElement.classList.add('used'); } else { tagElement.classList.remove('used'); } } /** * 处理标签点击 */ static handleTagClick(tagElement, tagName, tagValue, e) { // 阻止事件冒泡,确保弹窗不会关闭 e.stopPropagation(); // 获取输入框信息 const mapping = UIToolkit._findMapping(this.currentNodeId, this.currentInputId, this.currentWidgetKey); if (!mapping || !mapping.inputEl) return; const inputEl = mapping.inputEl; const inputValue = inputEl.value; // 判断标签是否已使用 const isUsed = this.isTagUsed(tagValue, this.currentNodeId, this.currentInputId); try { if (isUsed) { // 标签已使用,移除 // 确保移除tooltip this._hideTooltip(); this.removeTag(tagValue, this.currentNodeId, this.currentInputId, true); this.updateTagState(tagElement, false); this.usedTags.delete(tagValue); // 立即更新所有标签页中的标签状态 this.updateAllTagsState(this.currentNodeId, this.currentInputId); // 如果当前在搜索状态,也要更新搜索结果中的标签状态 const searchResultList = document.querySelector('.tag_search_result_list'); if (searchResultList) { this.refreshSearchResultsState(); } // logger.debug(`标签操作 | 动作:移除 | 标签:"${tagName}" | 原始值:"${tagValue}"`); } else { // 标签未使用,插入 // 获取光标位置前后的文本 const cursorPos = inputEl.selectionStart; const beforeText = inputValue.substring(0, cursorPos); const afterText = inputValue.substring(cursorPos); // 确定使用哪种格式 const formatType = PromptFormatter.determineFormatType(beforeText, afterText); // 获取或创建标签格式 let formats; const existingFormats = TagCacheService.getTagFormats(this.currentNodeId, this.currentInputId, tagValue); if (existingFormats) { // 如果缓存中已有该标签的格式,直接使用缓存的格式 formats = existingFormats; } else { // 如果缓存中没有,创建新的格式 formats = PromptFormatter.formatTag(tagValue); } // 根据formatType选择要插入的格式 let insertFormat; switch (formatType) { case 1: insertFormat = formats.format1; break; case 2: insertFormat = formats.format2; break; case 3: insertFormat = formats.format3; break; case 4: insertFormat = formats.format4; break; default: insertFormat = formats.format2; // 默认使用格式2 } // 如果是新创建的格式,添加到缓存 if (!existingFormats) { formats.insertedFormat = insertFormat; TagCacheService.addTag(this.currentNodeId, this.currentInputId, tagValue, formats); } else { // 如果是已存在的格式,更新insertedFormat TagCacheService.updateInsertedFormat(this.currentNodeId, this.currentInputId, tagValue, insertFormat); } // 插入到光标位置 UIToolkit.insertAtCursor(insertFormat, this.currentNodeId, this.currentInputId, { highlight: true, keepFocus: true, widgetKey: this.currentWidgetKey }); // 更新光标位置到插入内容之后 setTimeout(() => { if (inputEl === document.activeElement) { const newPos = cursorPos + insertFormat.length; inputEl.setSelectionRange(newPos, newPos); inputEl.focus(); } }, 0); // 更新标签状态 this.updateTagState(tagElement, true); this.usedTags.set(tagValue, tagElement); // 立即更新所有标签页中的标签状态 this.updateAllTagsState(this.currentNodeId, this.currentInputId); // 如果当前在搜索状态,也要更新搜索结果中的标签状态 const searchResultList = document.querySelector('.tag_search_result_list'); if (searchResultList) { this.refreshSearchResultsState(); } // logger.debug(`标签操作 | 动作:插入 | 标签:"${tagName}" | 原始值:"${tagValue}" | 格式类型:${formatType} | 插入格式:"${insertFormat}"`); } } catch (error) { logger.error(`标签操作失败 | 标签:"${tagName}" | 错误:${error.message}`); } } /** * 从输入框中移除标签 */ static removeTag(tagValue, nodeId, inputId, keepFocus = true) { const mapping = UIToolkit._findMapping(nodeId, inputId, this.currentWidgetKey); if (mapping && mapping.inputEl) { const inputEl = mapping.inputEl; const currentValue = inputEl.value; // 获取标签的所有格式 const formatInfo = TagCacheService.getTagFormats(nodeId, inputId, tagValue); if (!formatInfo) return false; // 优先使用insertedFormat进行精确匹配 if (formatInfo.insertedFormat) { const tagIndex = currentValue.indexOf(formatInfo.insertedFormat); if (tagIndex !== -1) { // 直接使用精确替换,不进行额外的清理 const newValue = currentValue.substring(0, tagIndex) + currentValue.substring(tagIndex + formatInfo.insertedFormat.length); // 更新输入框值 inputEl.value = newValue; // 触发事件 inputEl.dispatchEvent(new Event('input', { bubbles: true })); inputEl.dispatchEvent(new Event('change', { bubbles: true })); if (keepFocus) { inputEl.focus(); } logger.debug(`标签移除 | 方式:精确匹配 | 标签:${tagValue} | 格式:"${formatInfo.insertedFormat}"`); return true; } } // 如果insertedFormat不存在或未找到,按优先级尝试其他格式 const removeOrder = ['format4', 'format3', 'format2', 'format1']; for (const formatKey of removeOrder) { const format = formatInfo[formatKey]; if (!format) continue; const tagIndex = currentValue.indexOf(format); if (tagIndex !== -1) { // 检查是否是独立的标签(前后是空格或标点) const isValidRemoval = this._isValidTagRemoval(currentValue, tagIndex, format); if (isValidRemoval) { // 直接使用精确替换,不进行额外的清理 const newValue = currentValue.substring(0, tagIndex) + currentValue.substring(tagIndex + format.length); // 更新输入框值 inputEl.value = newValue; // 触发事件 inputEl.dispatchEvent(new Event('input', { bubbles: true })); inputEl.dispatchEvent(new Event('change', { bubbles: true })); if (keepFocus) { inputEl.focus(); } logger.debug(`标签移除 | 方式:格式匹配 | 标签:${tagValue} | 格式:${formatKey}`); return true; } } } } return false; } /** * 检查标签移除是否有效 */ static _isValidTagRemoval(value, index, format) { // 获取标签前后的字符 const beforeChar = index > 0 ? value[index - 1] : ''; const afterChar = index + format.length < value.length ? value[index + format.length] : ''; // 检查前后字符是否是空格或标点 const isValidChar = char => !char || char === ' ' || char === ',' || char === '.' || char === ';'; return isValidChar(beforeChar) && isValidChar(afterChar); } /** * 清理标签移除后的文本 */ static _cleanupAfterRemoval(text, removePosition, removeLength) { // 获取移除位置前后的一小段文本进行清理 const cleanRange = 10; // 清理范围(前后各10个字符) const startClean = Math.max(0, removePosition - cleanRange); const endClean = Math.min(text.length, removePosition + cleanRange); // 分割文本为三部分:前段、清理段、后段 const beforeText = text.substring(0, startClean); let cleanText = text.substring(startClean, endClean); const afterText = text.substring(endClean); // 只清理中间部分 cleanText = cleanText // 移除连续的逗号 .replace(/,\s*,/g, ',') // 确保逗号后有一个空格 .replace(/,(\S)/g, ', $1') // 移除多余的空格 .replace(/\s+/g, ' ') .trim(); // 重新组合文本 let result = beforeText + cleanText + afterText; // 处理首尾 if (removePosition === 0) { result = result.replace(/^\s*,\s*/, ''); // 如果标签在开头,移除开头的逗号和空格 } if (removePosition + removeLength >= text.length) { result = result.replace(/\s*,\s*$/, ''); // 如果标签在结尾,移除结尾的逗号和空格 } return result; } /** * 创建并显示tooltip */ static _showTooltip(target, text) { // 移除已存在的tooltip this._hideTooltip(); // 创建tooltip元素 const tooltip = document.createElement('div'); tooltip.className = 'tag_tooltip'; tooltip.innerHTML = text; // 使用 innerHTML 以支持 HTML 内容 document.body.appendChild(tooltip); // 获取目标元素的位置和尺寸 const rect = target.getBoundingClientRect(); // 计算tooltip位置 const tooltipRect = tooltip.getBoundingClientRect(); const left = rect.left + (rect.width - tooltipRect.width) / 2; const top = rect.top - tooltipRect.height - 8; // 8px的间距 // 设置tooltip位置 tooltip.style.left = `${left}px`; tooltip.style.top = `${top}px`; // 保存当前tooltip引用 this.activeTooltip = tooltip; } /** * 隐藏tooltip */ static _hideTooltip() { if (this.activeTooltip) { this.activeTooltip.remove(); this.activeTooltip = null; } } /** * 优化的手风琴切换方法 - 使用动态高度计算,解决跳帧问题 */ static _toggleAccordion(header, content, headerIcon) { const isExpanding = !header.classList.contains('active'); // 防止重复触发动画 if (content.dataset.animating === 'true') { return; } // 标记动画状态 content.dataset.animating = 'true'; if (isExpanding) { // 展开手风琴 header.classList.add('active'); content.classList.add('active'); // 临时移除过渡效果来测量高度 content.style.transition = 'none'; content.style.maxHeight = 'none'; content.style.overflow = 'visible'; content.style.padding = '2px 0'; // 确保padding正确 // 强制回流并获取准确高度 void content.offsetHeight; const contentHeight = content.scrollHeight; // 设置动画起始状态 content.style.maxHeight = '0px'; content.style.padding = '0'; content.style.overflow = 'hidden'; // 再次强制回流 void content.offsetHeight; // 启用过渡效果并开始动画 content.style.transition = 'max-height 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94), padding 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)'; // 使用requestAnimationFrame确保动画平滑 requestAnimationFrame(() => { content.style.maxHeight = contentHeight + 'px'; content.style.padding = '2px 0'; }); // 监听动画结束事件 const handleTransitionEnd = (e) => { if (e.target === content && e.propertyName === 'max-height') { content.removeEventListener('transitionend', handleTransitionEnd); // 动画完成后的清理工作 if (content.classList.contains('active')) { content.style.maxHeight = 'none'; content.style.overflow = 'visible'; content.style.transition = ''; } // 清除动画状态标记 content.dataset.animating = 'false'; } }; content.addEventListener('transitionend', handleTransitionEnd); // 备用清理机制(防止事件未触发) setTimeout(() => { if (content.dataset.animating === 'true') { content.dataset.animating = 'false'; if (content.classList.contains('active')) { content.style.maxHeight = 'none'; content.style.overflow = 'visible'; content.style.transition = ''; } } }, 250); // 调整为0.2s + 50ms缓冲 } else { // 收起手风琴 // 立即移除header的active类以更新视觉状态 header.classList.remove('active'); // 获取当前高度作为动画起点 const currentHeight = content.scrollHeight; // 设置起始状态 content.style.transition = 'none'; content.style.maxHeight = currentHeight + 'px'; content.style.overflow = 'hidden'; // 强制回流 void content.offsetHeight; // 启用过渡效果 content.style.transition = 'max-height 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94), padding 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)'; // 使用requestAnimationFrame确保动画平滑 requestAnimationFrame(() => { content.style.maxHeight = '0px'; content.style.padding = '0'; }); // 监听动画结束事件 const handleTransitionEnd = (e) => { if (e.target === content && e.propertyName === 'max-height') { content.removeEventListener('transitionend', handleTransitionEnd); // 动画完成后移除active类和清理样式 content.classList.remove('active'); content.style.transition = ''; content.style.maxHeight = ''; content.style.padding = ''; content.style.overflow = ''; // 清除动画状态标记 content.dataset.animating = 'false'; } }; content.addEventListener('transitionend', handleTransitionEnd); // 备用清理机制(防止事件未触发) setTimeout(() => { if (content.dataset.animating === 'true') { content.dataset.animating = 'false'; content.classList.remove('active'); content.style.transition = ''; content.style.maxHeight = ''; content.style.padding = ''; content.style.overflow = ''; } }, 250); // 调整为0.2s + 50ms缓冲 } // 切换图标旋转 const arrowIcon = headerIcon.querySelector('.pi.pi-chevron-down, .accordion_arrow_icon'); if (arrowIcon) { arrowIcon.classList.toggle('rotate-180'); } } /** * 递归创建标签结构 * @param {Object} data 数据对象 * @param {string} level 层级 * @param {string} tabName 标签页名称(用于恢复状态) */ static _createAccordionContent(data, level = '0', tabName = null, categoryName = null) { // 如果是顶级(一级分类),则创建标签页结构 if (level === '0') { // 创建外层容器 const container = document.createElement('div'); container.style.display = 'flex'; container.style.flexDirection = 'column'; container.style.height = '100%'; container.style.overflow = 'hidden'; // 创建标签页容器 const tabsContainer = document.createElement('div'); tabsContainer.className = 'popup_tabs_container'; // 创建标签滚动区域 const tabsScroll = document.createElement('div'); tabsScroll.className = 'popup_tabs_scroll'; // 创建标签栏 const tabs = document.createElement('div'); tabs.className = 'popup_tabs'; // 创建内容区域 const tabContents = document.createElement('div'); tabContents.className = 'tag_category_container'; tabContents.style.overflow = 'hidden'; tabContents.style.display = 'flex'; tabContents.style.flexDirection = 'column'; tabContents.style.flex = '1'; // 确保内容区域占满剩余空间 tabContents.style.minHeight = '0'; // 允许flex收缩 tabContents.style.flex = '1'; // 确保内容区域占满剩余空间 tabContents.style.minHeight = '0'; // 允许flex收缩 // 获取所有一级分类和标签 const categories = Object.keys(data); // 分离根级别标签(字符串值)和实际分类(对象值) // 注意:我们只在渲染时分离,不修改原始 data 对象 const rootTags = {}; const actualCategories = []; categories.forEach(key => { if (typeof data[key] === 'string') { rootTags[key] = data[key]; } else { actualCategories.push(key); } }); // 如果没有分类且没有根标签,返回空容器 if (actualCategories.length === 0 && Object.keys(rootTags).length === 0) { const emptyContainer = document.createElement('div'); emptyContainer.className = 'tag_category_container'; return emptyContainer; } // 创建左右滚动指示器 const leftIndicator = document.createElement('div'); leftIndicator.className = 'tabs_scroll_indicator left'; // 添加图标 const leftIconSpan = document.createElement('span'); leftIconSpan.className = 'pi pi-angle-left scroll_indicator_icon'; leftIndicator.appendChild(leftIconSpan); leftIndicator.style.display = 'none'; // 初始隐藏 const rightIndicator = document.createElement('div'); rightIndicator.className = 'tabs_scroll_indicator right'; // 添加图标 const rightIconSpan = document.createElement('span'); rightIconSpan.className = 'pi pi-angle-right scroll_indicator_icon'; rightIndicator.appendChild(rightIconSpan); rightIndicator.style.display = 'none'; // 初始隐藏 // 更新指示器状态的函数 const updateIndicators = () => { const canScroll = tabsScroll.scrollWidth > tabsScroll.clientWidth; if (!canScroll) { leftIndicator.style.display = 'none'; rightIndicator.style.display = 'none'; return; } // 使用更大的阈值(5像素)确保边界情况下能正确隐藏 const scrollLeft = tabsScroll.scrollLeft; const maxScroll = tabsScroll.scrollWidth - tabsScroll.clientWidth; // 移除高频滚动调试日志 leftIndicator.style.display = scrollLeft > 5 ? 'flex' : 'none'; rightIndicator.style.display = scrollLeft < (maxScroll - 5) ? 'flex' : 'none'; }; // 添加指示器点击事件 - 每次滚动一个标签 const leftClickCleanup = EventManager.addDOMListener(leftIndicator, 'click', () => { // 获取所有标签 const allTabs = tabs.querySelectorAll('.popup_tab'); if (allTabs.length === 0) return; // 找到当前第一个可见的标签 const scrollRect = tabsScroll.getBoundingClientRect(); let firstVisibleTab = null; for (const tab of allTabs) { const tabRect = tab.getBoundingClientRect(); // 如果标签的右边缘在可视区域内,说明它至少部分可见 if (tabRect.right > scrollRect.left + 10) { firstVisibleTab = tab; break; } } // 找到前一个标签 if (firstVisibleTab) { const currentIndex = Array.from(allTabs).indexOf(firstVisibleTab); if (currentIndex > 0) { const prevTab = allTabs[currentIndex - 1]; prevTab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' }); // 滚动完成后更新指示器状态(使用更长的延迟确保动画完成) setTimeout(updateIndicators, 600); } } }); const rightClickCleanup = EventManager.addDOMListener(rightIndicator, 'click', () => { // 获取所有标签 const allTabs = tabs.querySelectorAll('.popup_tab'); if (allTabs.length === 0) return; // 找到当前最后一个可见的标签 const scrollRect = tabsScroll.getBoundingClientRect(); let lastVisibleTab = null; for (let i = allTabs.length - 1; i >= 0; i--) { const tab = allTabs[i]; const tabRect = tab.getBoundingClientRect(); // 如果标签的左边缘在可视区域内,说明它至少部分可见 if (tabRect.left < scrollRect.right - 10) { lastVisibleTab = tab; break; } } // 找到下一个标签 if (lastVisibleTab) { const currentIndex = Array.from(allTabs).indexOf(lastVisibleTab); if (currentIndex < allTabs.length - 1) { const nextTab = allTabs[currentIndex + 1]; nextTab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'end' }); // 滚动完成后更新指示器状态(使用更长的延迟确保动画完成) setTimeout(updateIndicators, 600); } } }); // 监听滚动事件,显示/隐藏滚动指示器 const scrollCleanup = EventManager.addDOMListener(tabsScroll, 'scroll', updateIndicators); // 监听窗口大小调整事件 const resizeObserver = new ResizeObserver(() => { updateIndicators(); }); resizeObserver.observe(popup); // 添加清理函数 const resizeCleanup = () => { resizeObserver.disconnect(); }; this.eventCleanups.push(leftClickCleanup, rightClickCleanup, scrollCleanup, resizeCleanup); // 初始检测是否需要滚动指示器,并自动定位到激活的标签页 setTimeout(() => { // 检查是否需要滚动 const canScroll = tabsScroll.scrollWidth > tabsScroll.clientWidth; if (canScroll) { // 找到激活的标签页 const activeTab = tabs.querySelector('.popup_tab.active'); if (activeTab) { // 将激活的标签页滚动到可见区域 activeTab.scrollIntoView({ behavior: 'auto', block: 'nearest', inline: 'center' }); } // 等待滚动完成后,更新滚动指示器显示状态 setTimeout(updateIndicators, 50); } }, 100); // 如果没有实际分类但有根标签,创建一个默认 Tab 来显示它们 const finalCategories = actualCategories.length > 0 ? actualCategories : (Object.keys(rootTags).length > 0 ? ["标签"] : []); // 为每个分类创建标签和内容 finalCategories.forEach((category, index) => { // 创建标签 const tab = document.createElement('div'); tab.className = 'popup_tab'; tab.textContent = category; tab.setAttribute('data-category', category); // 第一个标签默认激活 if (index === 0) { tab.classList.add('active'); } // 添加标签点击事件 const tabClickCleanup = EventManager.addDOMListener(tab, 'click', (e) => { // 获取当前激活的标签 const currentActiveTab = tabs.querySelector('.popup_tab.active'); // 如果点击的是当前激活的标签,不做任何处理 if (currentActiveTab === tab) return; // 为当前激活的标签添加退出动画 if (currentActiveTab) { currentActiveTab.classList.add('exiting'); // 监听动画结束 const animationEndHandler = () => { currentActiveTab.classList.remove('active', 'exiting'); currentActiveTab.removeEventListener('transitionend', animationEndHandler); }; currentActiveTab.addEventListener('transitionend', animationEndHandler); } // 移除所有内容的active类 tabContents.querySelectorAll('.popup_tab_content').forEach(c => { c.classList.remove('active'); c.style.display = 'none'; }); // 添加当前标签的active类 tab.classList.add('active'); // 添加对应内容的active类 const contentId = tab.getAttribute('data-category'); const content = tabContents.querySelector(`.popup_tab_content[data-category="${contentId}"]`); if (content) { content.classList.add('active'); content.style.display = 'flex'; content.style.flexDirection = 'column'; } // 改进滚动逻辑:确保选中的标签完全可见 const tabRect = tab.getBoundingClientRect(); const scrollRect = tabsScroll.getBoundingClientRect(); // 检查标签是否完全在可视区域内 const isFullyVisible = tabRect.left >= scrollRect.left && tabRect.right <= scrollRect.right; if (!isFullyVisible) { // 如果标签在左侧不完全可见 if (tabRect.left < scrollRect.left) { tab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' }); } // 如果标签在右侧不完全可见 else if (tabRect.right > scrollRect.right) { tab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'end' }); } } }); this.eventCleanups.push(tabClickCleanup); tabs.appendChild(tab); // 创建对应的内容区域 const content = document.createElement('div'); content.className = 'popup_tab_content'; content.setAttribute('data-category', category); content.style.flex = '1'; content.style.display = 'none'; content.style.minHeight = '0'; // 允许flex收缩 content.style.overflow = 'auto'; // 确保内容溢出时显示滚动条 // 第一个内容默认显示 if (index === 0) { content.classList.add('active'); content.style.display = 'flex'; content.style.flexDirection = 'column'; } // 获取该分类的数据 // 如果是"标签"虚拟分类(仅在没有实际分类时使用),使用根标签作为数据 const categoryData = category === "标签" ? rootTags : data[category]; if (typeof categoryData === 'object' && categoryData !== null) { // 使用 _createInnerAccordion,它已经支持混合内容(标签+子分类) const innerContent = this._createInnerAccordion(categoryData, '1', category, category); content.appendChild(innerContent); } tabContents.appendChild(content); }); // ---在 Tab 栏末尾添加"新建分类"按钮--- const addTabButton = document.createElement('div'); addTabButton.className = 'popup_tab add_category_tab'; addTabButton.title = '新建分类'; const addTabIcon = document.createElement('span'); addTabIcon.className = 'pi pi-plus'; addTabButton.appendChild(addTabIcon); const addTabClickCleanup = EventManager.addDOMListener(addTabButton, 'click', (e) => { e.stopPropagation(); this._handleAddCategory(addTabButton, null, tabs, tabContents, data); }); this.eventCleanups.push(addTabClickCleanup); tabs.appendChild(addTabButton); // 组装标签页结构 tabsScroll.appendChild(tabs); tabsContainer.appendChild(leftIndicator); tabsContainer.appendChild(tabsScroll); tabsContainer.appendChild(rightIndicator); // 组装容器 container.appendChild(tabsContainer); container.appendChild(tabContents); return container; } else { // 非顶级分类使用普通容器 const container = document.createElement('div'); container.className = 'tag_category_container'; container.style.overflow = 'visible'; // 移除滚动条,让父容器处理 // 跟踪当前层级的第一个手风琴 let isFirstAccordionInLevel = true; for (const [key, value] of Object.entries(data)) { // 如果值是字符串,说明是标签 if (typeof value === 'string') { const tagItem = this._createTagElement(key, value, categoryName); container.appendChild(tagItem); } // 递归处理下一级 else if (typeof value === 'object' && value !== null) { const accordion = document.createElement('div'); accordion.className = 'tag_accordion'; accordion.setAttribute('data-category', key); accordion.setAttribute('data-level', level); const header = document.createElement('div'); header.className = 'tag_accordion_header'; const headerTitle = document.createElement('div'); headerTitle.className = 'tag_accordion_title'; headerTitle.textContent = key; const headerIcon = document.createElement('div'); headerIcon.className = 'tag_accordion_icon'; // 添加加号图标(创建新标签) const addIconSpan = document.createElement('span'); addIconSpan.className = 'pi pi-plus accordion_add_icon'; addIconSpan.title = '在此分类下创建新标签'; headerIcon.appendChild(addIconSpan); // 添加箭头图标 const arrowIconSpan = document.createElement('span'); arrowIconSpan.className = 'pi pi-chevron-down accordion_arrow_icon'; headerIcon.appendChild(arrowIconSpan); // 添加加号图标点击事件 const addIconCleanup = EventManager.addDOMListener(addIconSpan, 'click', (e) => { e.stopPropagation(); // 阻止事件冒泡,避免触发手风琴展开/收起 this._handleAddTag(key, categoryName || key); }); this.eventCleanups.push(addIconCleanup); header.appendChild(headerTitle); header.appendChild(headerIcon); // 添加拖拽时自动展开功能 let hoverTimer = null; const dragOverCleanup = EventManager.addDOMListener(header, 'dragover', (e) => { e.preventDefault(); // 必须阻止默认行为才能响应 drop/dragover // 检查是否有正在拖拽的标签 const draggingTag = document.querySelector('.tag_item.tag-dragging'); if (draggingTag && !header.classList.contains('active')) { if (!hoverTimer) { hoverTimer = setTimeout(() => { logger.debug(`[AutoExpand] 拖拽悬停自动展开: ${key}`); header.click(); hoverTimer = null; }, 500); // 500ms 延迟 } } }); const dragLeaveCleanup = EventManager.addDOMListener(header, 'dragleave', () => { if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; } }); // 还要处理 drop 事件,防止计时器残留 const dropCleanup = EventManager.addDOMListener(header, 'drop', () => { if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; } }); this.eventCleanups.push(dragOverCleanup, dragLeaveCleanup, dropCleanup); const content = document.createElement('div'); content.className = 'tag_accordion_content'; // 递归创建子内容,传递 tabName const childContent = this._createAccordionContent(value, (parseInt(level) + 1).toString(), tabName, key); content.appendChild(childContent); // 根据保存的状态或默认行为确定是否展开 const accordionState = this.getAccordionState(); const shouldExpand = tabName && accordionState[tabName]?.[key] !== undefined ? accordionState[tabName][key] // 使用保存的状态 : isFirstAccordionInLevel; // 默认展开第一个 if (shouldExpand) { header.classList.add('active'); content.classList.add('active'); const arrowIconSpan = headerIcon.querySelector('.accordion_arrow_icon'); if (arrowIconSpan) { arrowIconSpan.classList.add('rotate-180'); } if (!tabName || !accordionState[tabName]?.[key]) { // 只有在使用默认行为时才更新标志 isFirstAccordionInLevel = false; } } // 添加手风琴切换事件 const accordionCleanup = EventManager.addDOMListener(header, 'click', (e) => { e.stopPropagation(); // 获取当前手风琴的层级 const currentLevel = accordion.getAttribute('data-level'); // 获取当前标签页下的所有同级手风琴 const parentTab = header.closest('.popup_tab_content'); if (parentTab) { // 只关闭同级别的其他手风琴 const siblingAccordions = parentTab.querySelectorAll(`.tag_accordion[data-level="${currentLevel}"] .tag_accordion_header.active`); if (!header.classList.contains('active')) { siblingAccordions.forEach(otherHeader => { if (otherHeader !== header) { // 获取父级手风琴 const parentAccordion = otherHeader.closest('.tag_accordion'); // 确保是同级的手风琴 if (parentAccordion && parentAccordion.getAttribute('data-level') === currentLevel) { const otherContent = otherHeader.nextElementSibling; const otherHeaderIcon = otherHeader.querySelector('.tag_accordion_icon'); // 使用优化的切换方法关闭其他手风琴 if (otherHeader.classList.contains('active')) { this._toggleAccordion(otherHeader, otherContent, otherHeaderIcon); // 保存关闭状态 const otherAccordionCategory = parentAccordion.getAttribute('data-category'); const otherTabName = parentTab.getAttribute('data-category'); if (otherTabName && otherAccordionCategory) { this.setAccordionState(otherTabName, otherAccordionCategory, false); } } } } }); } } // 使用优化的切换方法切换当前手风琴 this._toggleAccordion(header, content, headerIcon); // 保存当前手风琴状态 const accordionCategory = accordion.getAttribute('data-category'); const tabName = parentTab?.getAttribute('data-category'); if (tabName && accordionCategory) { const isExpanded = header.classList.contains('active'); this.setAccordionState(tabName, accordionCategory, isExpanded); } }); this.eventCleanups.push(accordionCleanup); accordion.appendChild(header); accordion.appendChild(content); container.appendChild(accordion); } } // 初始化拖拽排序(如果有 Sortable) // 分别处理手风琴排序和标签排序 if (this.Sortable) { // 检测容器内容类型 const hasAccordions = container.querySelector(':scope > .tag_accordion') !== null; const hasTags = container.querySelector(':scope > .tag_item') !== null; // 手风琴排序 if (hasAccordions) { const accordionSortable = new this.Sortable(container, { group: { name: 'accordions', pull: false, put: false }, animation: 150, ghostClass: 'tag-ghost', handle: '.tag_accordion_header', draggable: '.tag_accordion', delay: 50, onEnd: async (evt) => { const { oldIndex, newIndex } = evt; if (oldIndex === newIndex) return; const newOrderKeys = []; Array.from(container.children).forEach(el => { if (el.classList.contains('tag_accordion')) { const cat = el.getAttribute('data-category'); if (cat) newOrderKeys.push(cat); } }); const tempObj = { ...data }; for (const key in data) delete data[key]; newOrderKeys.forEach(key => { if (Object.prototype.hasOwnProperty.call(tempObj, key)) { data[key] = tempObj[key]; } }); for (const key in tempObj) { if (!Object.prototype.hasOwnProperty.call(data, key)) { data[key] = tempObj[key]; } } await this._saveTagOrder(data, tabName, categoryName); } }); this.sortables.push(accordionSortable); } // 标签排序(支持跨分类拖拽) // 始终初始化标签 Sortable,确保空分类或只有子分类的容器也能接收标签 if (true) { // 为容器添加分类标识,用于跨分类拖拽时识别 container.setAttribute('data-sortable-category', categoryName || tabName); // 确保容器有最小高度,以便空容器也能因 drop-zone 样式而被看到 container.style.minHeight = '10px'; const tagSortable = new this.Sortable(container, { group: { name: 'tags', pull: true, put: function (to) { // 如果目标容器包含手风琴,则不允许放入标签 return to.el.querySelector(':scope > .tag_accordion') === null; } }, // 允许跨分类拖拽 animation: 150, ghostClass: 'tag-ghost', draggable: '.tag_item', delay: 50, onStart: function (evt) { evt.item.classList.add('tag-dragging'); // 高亮所有可放置的分类容器(仅限底层容器) document.querySelectorAll('[data-sortable-category]').forEach(el => { if (el !== evt.from && !el.querySelector(':scope > .tag_accordion')) { el.classList.add('tag-drop-zone'); } }); }, onEnd: async (evt) => { evt.item.classList.remove('tag-dragging'); // 移除高亮 document.querySelectorAll('.tag-drop-zone').forEach(el => { el.classList.remove('tag-drop-zone'); }); // 如果是跨分类移动,由 onAdd 处理 if (evt.from !== evt.to) return; const { oldIndex, newIndex } = evt; if (oldIndex === newIndex) return; const newOrderKeys = []; Array.from(container.children).forEach(el => { if (el.classList.contains('tag_item')) { const name = el.getAttribute('data-name'); if (name) newOrderKeys.push(name); } }); const tempObj = { ...data }; for (const key in data) delete data[key]; newOrderKeys.forEach(key => { if (Object.prototype.hasOwnProperty.call(tempObj, key)) { data[key] = tempObj[key]; } }); for (const key in tempObj) { if (!Object.prototype.hasOwnProperty.call(data, key)) { data[key] = tempObj[key]; } } await this._saveTagOrder(data, tabName, categoryName); }, onAdd: async (evt) => { // 跨分类移动标签 const tagItem = evt.item; const tagName = tagItem.getAttribute('data-name'); const tagValue = tagItem.getAttribute('data-value'); const fromCategory = evt.from.getAttribute('data-sortable-category'); const toCategory = evt.to.getAttribute('data-sortable-category'); logger.debug(`[onAdd(root)] 开始移动标签: ${tagName}, 从: ${fromCategory}, 到: ${toCategory}`); // 更新标签元素的分类属性 tagItem.setAttribute('data-category', toCategory); // 调用移动函数 (不立即保存,等待排序) const success = await this._moveTagToCategory(tagName, tagValue, fromCategory, toCategory, tabName, false); logger.debug(`[onAdd(root)] 移动结果: ${success}`); if (!success) { // 如果移动失败,将标签移回原位置 logger.warn(`[onAdd(root)] 移动失败,回滚 DOM`); evt.from.appendChild(tagItem); } else { // 移动成功,现在根据 DOM 顺序重新排序并保存 // 获取新的 DOM 顺序 const newOrderKeys = []; Array.from(evt.to.children).forEach(el => { if (el.classList.contains('tag_item')) { const name = el.getAttribute('data-name'); if (name) newOrderKeys.push(name); } }); // 重构 data 对象 const tempObj = { ...data }; for (const key in data) delete data[key]; newOrderKeys.forEach(key => { if (Object.prototype.hasOwnProperty.call(tempObj, key)) { data[key] = tempObj[key]; } }); // 确保没有遗漏 for (const key in tempObj) { if (!Object.prototype.hasOwnProperty.call(data, key)) { data[key] = tempObj[key]; } } // 保存排序后的数据 await this._saveTagOrder(data, tabName, toCategory); // 显示成功提示 logger.debug(`[标签移动(root)] 成功并排序 | 标签: ${tagName} | 到: ${toCategory}`); app.extensionManager.toast.add({ severity: "success", summary: "移动成功", detail: `标签 "${tagName}" 已移动到 "${toCategory}"`, life: 2000 }); } } }); this.sortables.push(tagSortable); } } return container; } } /** * 为二级分类创建手风琴元素 */ static _createAccordionElement(key, value, level) { const accordion = document.createElement('div'); accordion.className = 'tag_accordion'; accordion.setAttribute('data-category', key); const header = document.createElement('div'); header.className = 'tag_accordion_header'; const headerTitle = document.createElement('div'); headerTitle.className = 'tag_accordion_title'; headerTitle.textContent = key; const headerIcon = document.createElement('div'); headerIcon.className = 'tag_accordion_icon'; // 创建图标元素 const arrowIconSpan = document.createElement('span'); arrowIconSpan.className = 'pi pi-chevron-down accordion_arrow_icon'; headerIcon.appendChild(arrowIconSpan); header.appendChild(headerTitle); header.appendChild(headerIcon); const content = document.createElement('div'); content.className = 'tag_accordion_content'; // 递归创建子内容 const childContent = this._createInnerAccordion(data, (parseInt(level) + 1).toString(), tabName, categoryName); accordion.appendChild(childContent); // 添加手风琴切换事件,包含关闭其他手风琴的逻辑 const accordionCleanup = EventManager.addDOMListener(header, 'click', (e) => { e.stopPropagation(); // 获取当前标签页下的所有手风琴 const parentTab = header.closest('.popup_tab_content'); if (parentTab) { if (!header.classList.contains('active')) { const otherAccordions = parentTab.querySelectorAll('.tag_accordion_header.active'); otherAccordions.forEach(otherHeader => { if (otherHeader !== header) { otherHeader.classList.remove('active'); const otherContent = otherHeader.nextElementSibling; if (otherContent && otherContent.classList.contains('active')) { otherContent.classList.remove('active'); } const otherIcon = otherHeader.querySelector('.accordion_arrow_icon'); if (otherIcon) { otherIcon.classList.add('rotate-180'); } } }); } } // 切换当前手风琴状态 header.classList.toggle('active'); content.classList.toggle('active'); // 图标旋转 const toggleArrowIcon = headerIcon.querySelector('.accordion_arrow_icon'); if (toggleArrowIcon) { if (header.classList.contains('active')) { toggleArrowIcon.classList.add('rotate-180'); } else { toggleArrowIcon.classList.remove('rotate-180'); } } }); this.eventCleanups.push(accordionCleanup); accordion.appendChild(header); accordion.appendChild(content); // 默认展开第一个手风琴(根据父元素的位置) if (accordion.parentElement && accordion.parentElement.firstChild === accordion) { header.classList.add('active'); content.classList.add('active'); } return accordion; } /** * 为二级分类创建内容 * @param {Object} data 数据对象 * @param {string} level 层级 * @param {string} tabName 标签页名称(用于恢复状态) */ static _createInnerAccordion(data, level, tabName = null, categoryName = null) { const container = document.createElement('div'); container.className = 'tag_category_container'; container.style.flex = '1'; container.style.overflow = 'visible'; // 移除滚动条,让父容器处理 container.style.minHeight = '0'; // 允许flex收缩 // 跟踪是否为第一个手风琴 let isFirstAccordion = true; for (const [key, value] of Object.entries(data)) { // 如果值是对象,创建新的手风琴 if (typeof value === 'object' && value !== null) { const accordion = document.createElement('div'); accordion.className = 'tag_accordion'; accordion.setAttribute('data-category', key); const header = document.createElement('div'); header.className = 'tag_accordion_header'; const headerTitle = document.createElement('div'); headerTitle.className = 'tag_accordion_title'; headerTitle.textContent = key; const headerIcon = document.createElement('div'); headerIcon.className = 'tag_accordion_icon'; // 添加加号图标(创建新标签)- 收藏页面不显示 if (tabName !== 'favorites') { const addIconSpan = document.createElement('span'); addIconSpan.className = 'pi pi-plus accordion_add_icon'; addIconSpan.title = '在此分类下创建新标签'; headerIcon.appendChild(addIconSpan); // 添加加号图标点击事件 const addIconCleanup = EventManager.addDOMListener(addIconSpan, 'click', (e) => { e.stopPropagation(); // 阻止事件冒泡,避免触发手风琴展开/收起 this._handleAddTag(key, categoryName || key, addIconSpan); }); this.eventCleanups.push(addIconCleanup); } // 添加箭头图标 const arrowIconSpan = document.createElement('span'); arrowIconSpan.className = 'pi pi-chevron-down accordion_arrow_icon'; headerIcon.appendChild(arrowIconSpan); header.appendChild(headerTitle); header.appendChild(headerIcon); // 添加右键菜单事件(收藏页面不显示) if (tabName !== 'favorites') { const headerContextMenuCleanup = EventManager.addDOMListener(header, 'contextmenu', (e) => { e.preventDefault(); e.stopPropagation(); this._showCategoryContextMenu(e, key, false, tabName); }); this.eventCleanups.push(headerContextMenuCleanup); } // 添加拖拽时自动展开功能 let hoverTimer = null; const dragOverCleanup = EventManager.addDOMListener(header, 'dragover', (e) => { e.preventDefault(); // 必须阻止默认行为才能响应 drop/dragover // 检查是否有正在拖拽的标签 const draggingTag = document.querySelector('.tag_item.tag-dragging'); if (draggingTag && !header.classList.contains('active')) { if (!hoverTimer) { hoverTimer = setTimeout(() => { logger.debug(`[AutoExpand] 拖拽悬停自动展开: ${key}`); header.click(); hoverTimer = null; }, 500); // 500ms 延迟 } } }); const dragLeaveCleanup = EventManager.addDOMListener(header, 'dragleave', () => { if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; } }); // 还要处理 drop 事件,防止计时器残留 const dropCleanup = EventManager.addDOMListener(header, 'drop', () => { if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; } }); this.eventCleanups.push(dragOverCleanup, dragLeaveCleanup, dropCleanup); const content = document.createElement('div'); content.className = 'tag_accordion_content'; // 递归创建子内容 // 传递当前手风琴的分类名称(key)作为 categoryName,而不是父分类名称 // 这样每个容器的 data-sortable-category 才能正确标识其所属分类 const childContent = this._createInnerAccordion(value, (parseInt(level) + 1).toString(), tabName, key); childContent.style.flex = '1'; // 让子内容占满可用空间 childContent.style.minHeight = '0'; // 允许flex收缩 content.appendChild(childContent); // 根据保存的状态或默认行为确定是否展开 let shouldExpand; const accordionState = this.getAccordionState(); if (tabName === 'favorites') { // 收藏页特殊处理:默认展开当前CSV对应的分类 // 获取当前CSV文件名(无扩展名) let currentCsvName = this.currentCsvFile || ""; currentCsvName = currentCsvName.replace(/\.(csv|json|yaml|yml)$/i, ''); // 检查当前key是否匹配当前CSV (key就是分类名) const isCurrentCsv = key === currentCsvName; // 检查是否存在匹配的分类 const hasCurrentCsv = Object.keys(data).includes(currentCsvName); // 如果当前Key匹配当前CSV,展开 // 或者如果没有对应的CSV分类,且这是第一个,展开 shouldExpand = isCurrentCsv || (!hasCurrentCsv && isFirstAccordion); // console.log(`[AutoExpand] Key: ${key}, Current: ${currentCsvName}, Match: ${isCurrentCsv}, HasCurrent: ${hasCurrentCsv}, Should: ${shouldExpand}`); } else { // 普通页逻辑:优先使用保存状态,否则默认展开第一个 shouldExpand = tabName && accordionState[tabName]?.[key] !== undefined ? accordionState[tabName][key] : isFirstAccordion; } if (shouldExpand) { header.classList.add('active'); content.classList.add('active'); const firstArrowIcon = headerIcon.querySelector('.pi.pi-chevron-down'); if (firstArrowIcon) { firstArrowIcon.classList.add('rotate-180'); } if (!tabName || !accordionState[tabName]?.[key]) { // 只有在使用默认行为时才更新标志 isFirstAccordion = false; } } // 添加手风琴切换事件,包含关闭同级其他手风琴的逻辑 const accordionCleanup = EventManager.addDOMListener(header, 'click', (e) => { e.stopPropagation(); // 获取当前手风琴的父容器(而不是整个标签页) const parentContainer = accordion.parentElement; const parentTab = header.closest('.popup_tab_content'); if (parentContainer) { if (!header.classList.contains('active')) { // 只查找同一父容器内的直接子手风琴(同级手风琴),而不是所有层级 const siblingAccordions = parentContainer.querySelectorAll(':scope > .tag_accordion > .tag_accordion_header.active'); siblingAccordions.forEach(otherHeader => { if (otherHeader !== header) { const otherContent = otherHeader.nextElementSibling; const otherHeaderIcon = otherHeader.querySelector('.tag_accordion_icon'); // 使用优化的切换方法关闭同级其他手风琴 if (otherHeader.classList.contains('active')) { this._toggleAccordion(otherHeader, otherContent, otherHeaderIcon); // 保存关闭状态 const otherAccordion = otherHeader.closest('.tag_accordion'); const otherAccordionCategory = otherAccordion?.getAttribute('data-category'); const tabName = parentTab?.getAttribute('data-category'); if (tabName && otherAccordionCategory) { this.setAccordionState(tabName, otherAccordionCategory, false); } } } }); } } // 使用优化的切换方法切换当前手风琴 this._toggleAccordion(header, content, headerIcon); // 保存当前手风琴状态 const accordionCategory = accordion.getAttribute('data-category'); const tabName = parentTab?.getAttribute('data-category'); if (tabName && accordionCategory) { const isExpanded = header.classList.contains('active'); this.setAccordionState(tabName, accordionCategory, isExpanded); } }); this.eventCleanups.push(accordionCleanup); accordion.appendChild(header); accordion.appendChild(content); container.appendChild(accordion); } else if (typeof value === 'string') { // 如果值是字符串,创建标签项 const tagItem = this._createTagElement(key, value, categoryName); container.appendChild(tagItem); } } // ---在手风琴列表末尾添加"新建子分类"按钮(仅限 level='1',即第一级手风琴)--- if (level === '1' && tabName !== 'favorites') { const addSubCategoryBtn = document.createElement('div'); addSubCategoryBtn.className = 'add_subcategory_button'; addSubCategoryBtn.title = '新建子分类'; const addSubCategoryIcon = document.createElement('span'); addSubCategoryIcon.className = 'pi pi-plus'; addSubCategoryBtn.appendChild(addSubCategoryIcon); const addSubCategoryClickCleanup = EventManager.addDOMListener(addSubCategoryBtn, 'click', (e) => { e.stopPropagation(); this._handleAddCategory(addSubCategoryBtn, tabName, null, null, data, categoryName); }); this.eventCleanups.push(addSubCategoryClickCleanup); container.appendChild(addSubCategoryBtn); } // 初始化拖拽排序(如果有 Sortable) // 分别处理手风琴排序和标签排序,避免拖拽标签时误触发手风琴排序 if (this.Sortable) { // 检测容器内容类型(只检测直接子元素) const hasAccordions = container.querySelector(':scope > .tag_accordion') !== null; const hasTags = container.querySelector(':scope > .tag_item') !== null; // 手风琴排序:只有手风琴头部可以触发,且只排序手风琴元素 if (hasAccordions) { const accordionSortable = new this.Sortable(container, { group: { name: 'accordions', pull: false, put: false }, // 独立分组,不与标签混排 animation: 150, ghostClass: 'tag-ghost', handle: '.tag_accordion_header', // 只有手风琴头部可以拖拽 draggable: '.tag_accordion', // 只排序手风琴元素 delay: 50, onEnd: async (evt) => { const { oldIndex, newIndex } = evt; if (oldIndex === newIndex) return; // 获取新顺序(只处理手风琴) const newOrderKeys = []; Array.from(container.children).forEach(el => { if (el.classList.contains('tag_accordion')) { const cat = el.getAttribute('data-category'); if (cat) newOrderKeys.push(cat); } }); // 重构 data 对象 const tempObj = { ...data }; for (const key in data) delete data[key]; newOrderKeys.forEach(key => { if (Object.prototype.hasOwnProperty.call(tempObj, key)) { data[key] = tempObj[key]; } }); // 保留未在DOM中的数据 for (const key in tempObj) { if (!Object.prototype.hasOwnProperty.call(data, key)) { data[key] = tempObj[key]; } } // 保存排序 await this._saveTagOrder(data, tabName, categoryName); } }); this.sortables.push(accordionSortable); } // 标签排序(支持跨分类拖拽) // 始终初始化标签 Sortable,确保空分类或只有子分类的容器也能接收标签 if (true) { // 为容器添加分类标识,用于跨分类拖拽时识别 container.setAttribute('data-sortable-category', categoryName || tabName); // 确保容器有最小高度,以便空容器也能因 drop-zone 样式而被看到 container.style.minHeight = '10px'; const tagSortable = new this.Sortable(container, { group: { name: 'tags', pull: true, put: function (to) { // 如果目标容器包含手风琴(由 .tag_accordion 子元素判断),则不允许放入标签 // 标签只能放入最底层的分类容器(即手风琴的内容区域) return to.el.querySelector(':scope > .tag_accordion') === null; } }, // 允许跨分类拖拽 animation: 150, ghostClass: 'tag-ghost', draggable: '.tag_item', delay: 50, onStart: function (evt) { evt.item.classList.add('tag-dragging'); // 高亮所有可放置的分类容器(仅限底层容器) document.querySelectorAll('[data-sortable-category]').forEach(el => { if (el !== evt.from && !el.querySelector(':scope > .tag_accordion')) { el.classList.add('tag-drop-zone'); } }); }, onEnd: async (evt) => { evt.item.classList.remove('tag-dragging'); // 移除高亮 document.querySelectorAll('.tag-drop-zone').forEach(el => { el.classList.remove('tag-drop-zone'); }); // 如果是跨分类移动,由 onAdd 处理 if (evt.from !== evt.to) return; const { oldIndex, newIndex } = evt; if (oldIndex === newIndex) return; const newOrderKeys = []; Array.from(container.children).forEach(el => { if (el.classList.contains('tag_item')) { const name = el.getAttribute('data-name'); if (name) newOrderKeys.push(name); } }); const tempObj = { ...data }; for (const key in data) delete data[key]; // 先添加排序后的标签 newOrderKeys.forEach(key => { if (Object.prototype.hasOwnProperty.call(tempObj, key)) { data[key] = tempObj[key]; } }); // 补回其他键(即该层级下的子分类) for (const key in tempObj) { if (!Object.prototype.hasOwnProperty.call(data, key)) { data[key] = tempObj[key]; } } await this._saveTagOrder(data, tabName, categoryName); }, onAdd: async (evt) => { // 跨分类移动标签 const tagItem = evt.item; const tagName = tagItem.getAttribute('data-name'); const tagValue = tagItem.getAttribute('data-value'); const fromCategory = evt.from.getAttribute('data-sortable-category'); const toCategory = evt.to.getAttribute('data-sortable-category'); logger.debug(`[onAdd] 开始移动标签: ${tagName}, 从: ${fromCategory}, 到: ${toCategory}`); // 更新标签元素的分类属性 tagItem.setAttribute('data-category', toCategory); // 调用移动函数 (不立即保存,等待排序) const success = await this._moveTagToCategory(tagName, tagValue, fromCategory, toCategory, tabName, false); logger.debug(`[onAdd] 移动结果: ${success}`); if (!success) { // 如果移动失败,将标签移回原位置 logger.warn(`[onAdd] 移动失败,回滚 DOM`); evt.from.appendChild(tagItem); } else { // 移动成功,现在根据 DOM 顺序重新排序并保存 // 获取新的 DOM 顺序 const newOrderKeys = []; Array.from(evt.to.children).forEach(el => { if (el.classList.contains('tag_item')) { const name = el.getAttribute('data-name'); if (name) newOrderKeys.push(name); } }); // 重构 data 对象 const tempObj = { ...data }; for (const key in data) delete data[key]; // 先添加排序后的标签 newOrderKeys.forEach(key => { if (Object.prototype.hasOwnProperty.call(tempObj, key)) { data[key] = tempObj[key]; } }); // 补回其他键(即该层级下的子分类/原本就在此处的子分类) for (const key in tempObj) { if (!Object.prototype.hasOwnProperty.call(data, key)) { data[key] = tempObj[key]; } } // 保存排序后的数据 await this._saveTagOrder(data, tabName, toCategory); // 显示成功提示 logger.debug(`[标签移动] 成功并排序 | 标签: ${tagName} | 到: ${toCategory}`); app.extensionManager.toast.add({ severity: "success", summary: "移动成功", detail: `标签 "${tagName}" 已移动到 "${toCategory}"`, life: 2000 }); } } }); this.sortables.push(tagSortable); } } return container; } /** * 保存标签排序 * @param {Object} data 数据对象 * @param {string} tabName 标签页名称 * @param {string} categoryName 分类名称 */ static async _saveTagOrder(data, tabName, categoryName) { try { let success = false; const isFavorites = tabName === 'favorites' || categoryName === 'favorites'; if (isFavorites) { // 保存到 tags_user.json const userTagData = await ResourceManager.getUserTagData(); if (!userTagData.favorites) { userTagData.favorites = {}; } userTagData.favorites = TagManager.favorites; success = await ResourceManager.saveUserTags(userTagData); if (success) { logger.debug(`[标签排序] 收藏排序已保存`); app.extensionManager.toast.add({ severity: "success", summary: "排序保存成功", detail: "收藏标签顺序已更新", life: 2000 }); } } else { // 保存到 CSV if (TagManager.currentCsvFile && TagManager.tagData) { success = await ResourceManager.saveTagsCsv(TagManager.currentCsvFile, TagManager.tagData); if (success) { logger.debug(`[标签排序] CSV排序已保存 | 文件: ${TagManager.currentCsvFile}`); app.extensionManager.toast.add({ severity: "success", summary: "排序保存成功", detail: "标签文件顺序已更新", life: 2000 }); } } } if (!success) { app.extensionManager.toast.add({ severity: "error", summary: "保存失败", detail: "排序未能保存到服务器", life: 3000 }); } } catch (err) { logger.error(`[标签排序] 保存出错: ${err.message}`); } } /** * 移动标签到新分类 * @param {string} tagName 标签名称 * @param {string} tagValue 标签值 * @param {string} fromCategory 原分类名 * @param {string} toCategory 目标分类名 * @param {string} tabName 标签页名称 */ static async _moveTagToCategory(tagName, tagValue, fromCategory, toCategory, tabName, shouldSave = true) { try { // 收藏页不支持跨分类移动 if (tabName === 'favorites' || fromCategory === 'favorites' || toCategory === 'favorites') { logger.debug('[标签移动] 收藏页标签不支持跨分类移动'); return false; } // 如果源分类和目标分类相同,不处理 if (fromCategory === toCategory) { return false; } const filename = this.currentCsvFile; if (!filename || !TagManager.tagData) { logger.error('[标签移动] 无法获取当前CSV文件或标签数据'); return false; } // 获取源分类和目标分类 const sourceCategory = TagManager._findCategoryRecursively(TagManager.tagData, fromCategory); const targetCategory = TagManager._findCategoryRecursively(TagManager.tagData, toCategory); if (!sourceCategory) { logger.error(`[标签移动] 找不到源分类: ${fromCategory}`); return false; } if (!targetCategory) { logger.error(`[标签移动] 找不到目标分类: ${toCategory}`); return false; } // 检查目标分类是否已存在同名标签 if (targetCategory.hasOwnProperty(tagName)) { app.extensionManager.toast.add({ severity: "warn", summary: "移动失败", detail: `目标分类中已存在同名标签 "${tagName}"`, life: 3000 }); return false; } // 从源分类删除标签 delete sourceCategory[tagName]; // 添加到目标分类 targetCategory[tagName] = tagValue; if (!shouldSave) { return true; } // 保存到 CSV const success = await ResourceManager.saveTagsCsv(filename, TagManager.tagData); if (success) { logger.debug(`[标签移动] 成功 | 标签: ${tagName} | 从: ${fromCategory} | 到: ${toCategory}`); app.extensionManager.toast.add({ severity: "success", summary: "移动成功", detail: `标签 "${tagName}" 已移动到 "${toCategory}"`, life: 2000 }); return true; } else { // 回滚操作 targetCategory[tagName] && delete targetCategory[tagName]; sourceCategory[tagName] = tagValue; app.extensionManager.toast.add({ severity: "error", summary: "移动失败", detail: "保存到服务器失败", life: 3000 }); return false; } } catch (err) { logger.error(`[标签移动] 出错: ${err.message}`); app.extensionManager.toast.add({ severity: "error", summary: "移动失败", detail: err.message, life: 3000 }); return false; } } /** * 检查标签是否已收藏 */ static _isTagFavorited(tagValue, category = null) { if (!this.favorites) return false; // 如果提供了分类,优先检查该分类 if (category) { // 标准化分类名称(去除扩展名) const normalize = (name) => name.replace(/\.(csv|json|yaml|yml)$/i, ''); const targetCat = normalize(category); // 检查直接匹配 if (this.favorites[targetCat]) { return Object.values(this.favorites[targetCat]).includes(tagValue); } // 如果没找到直接匹配的Key,可能需要遍历Keys进行模糊匹配? // 目前假设 Keys 都是已标准化。 // 但如果 favorites 还是旧结构(平铺),直接检查 values const isOldStructure = Object.values(this.favorites).some(v => typeof v !== 'object'); if (isOldStructure) { // 旧结构忽略 category return Object.values(this.favorites).includes(tagValue); } return false; } // 如果没有提供分类(比如全局搜索),则递归检查 // 递归查找值 const checkRecursive = (obj) => { if (typeof obj !== 'object' || obj === null) return false; for (const value of Object.values(obj)) { if (typeof value === 'object' && value !== null) { if (checkRecursive(value)) return true; } else if (value === tagValue) { return true; } } return false; }; return checkRecursive(this.favorites); } /** * 创建标签元素 */ static _createTagElement(tagName, tagValue, categoryName = null) { const tagItem = document.createElement('div'); tagItem.className = 'tag_item'; tagItem.setAttribute('data-name', tagName); tagItem.setAttribute('data-value', tagValue); if (categoryName) { tagItem.setAttribute('data-category', categoryName); } // 检查标签是否已使用 if (this.isTagUsed(tagValue, this.currentNodeId, this.currentInputId)) { tagItem.classList.add('used'); this.usedTags.set(tagValue, tagItem); } const tagText = document.createElement('span'); tagText.className = 'tag_item_text'; // 检查是否已收藏 (考虑分类) // 如果是收藏页,TagElement的 categoryName 本身就是 SourceCategory // 如果是常规页,categoryName 就是 SourceCategory // 注意:categoryName 在这里通常是文件名,比如 "foo.csv" 或 "foo" // 我们后端/缓存的 key 通常是去掉了扩展名的。 // _isTagFavorited 内部会尝试处理,但最好我们传进去的是原始 categoryName,在内部处理。 const isFavorited = this._isTagFavorited(tagValue, categoryName); tagText.textContent = isFavorited ? `⭐️ ${tagName}` : tagName; tagItem.appendChild(tagText); // 添加鼠标事件监听 const mouseEnterCleanup = EventManager.addDOMListener(tagItem, 'mouseenter', () => { this._showTooltip(tagItem, tagValue); }); const mouseLeaveCleanup = EventManager.addDOMListener(tagItem, 'mouseleave', () => { this._hideTooltip(); }); // 添加点击事件 const tagClickCleanup = EventManager.addDOMListener(tagItem, 'click', (e) => { // 先隐藏tooltip this._hideTooltip(); this.handleTagClick(tagItem, tagName, tagValue, e); // 移除后,重新加载已插入标签 setTimeout(() => { // 重新加载已插入标签页 const insertedTabContent = document.querySelector('.popup_tab_content[data-category="已插入"]'); if (insertedTabContent && insertedTabContent.classList.contains('active')) { this._loadInsertedTagsContent(insertedTabContent); } // 更新所有标签页中的标签状态 this.updateAllTagsState(this.currentNodeId, this.currentInputId); // 如果当前在搜索状态,也要更新搜索结果中的标签状态 const searchResultList = document.querySelector('.tag_search_result_list'); if (searchResultList) { this.refreshSearchResultsState(); } }, 0); }); // 添加右键菜单 const contextMenuCleanup = EventManager.addDOMListener(tagItem, 'contextmenu', (e) => { e.preventDefault(); this._showContextMenu(e, tagValue, tagName, categoryName); }); this.eventCleanups.push(mouseEnterCleanup, mouseLeaveCleanup, tagClickCleanup, contextMenuCleanup); return tagItem; } /** * 显示右键菜单 */ static _showContextMenu(e, tagValue, tagName, category = null) { // 检查是否已收藏 (使用传入的 category) const isFavorited = this._isTagFavorited(tagValue, category); // 查找触发右键的标签元素 const tagItem = e.target.closest('.tag_item') || e.currentTarget; // ---为收藏标签提供简化菜单--- // 收藏标签(⭐️)是引用类型,不应该被编辑或删除,只能取消收藏 const menuItems = isFavorited ? [ // 收藏标签:仅显示取消收藏选项 { label: '取消收藏', icon: 'pi-minus-circle', onClick: async () => { // 使用CSV文件名作为分类名 let targetCategory = this.currentCsvFile || "默认"; targetCategory = targetCategory.replace(/\.(csv|json|yaml|yml)$/i, ''); const success = await ResourceManager.removeFavorite(tagValue, targetCategory); if (success) { // 移除本地缓存 (favorites 是嵌套对象 {category: {name: value}}) if (this.favorites && this.favorites[targetCategory]) { const catData = this.favorites[targetCategory]; for (const key in catData) { if (catData[key] === tagValue) { delete catData[key]; break; } } } // 更新UI状态 this.updateTagFavoriteState(tagValue, false); // 如果当前在收藏页,刷新收藏页 const activeTab = document.querySelector('.popup_tab.active'); if (activeTab && activeTab.getAttribute('data-category') === 'favorites') { const content = document.querySelector('.popup_tab_content.active'); if (content) { content.removeAttribute('data-loaded'); this._loadCategoryContent(content, this.favorites, 'favorites'); } } } } } ] : [ // 常规标签:显示完整菜单选项 { label: '收藏标签', icon: 'pi-star', onClick: async () => { // 使用CSV文件名作为分类名 let targetCategory = this.currentCsvFile || "默认"; targetCategory = targetCategory.replace(/\.(csv|json|yaml|yml)$/i, ''); const success = await ResourceManager.addFavorite(tagValue, tagName, targetCategory); if (success) { if (!this.favorites) this.favorites = {}; // 添加到本地缓存 if (!this.favorites[targetCategory]) { this.favorites[targetCategory] = {}; } this.favorites[targetCategory][tagName] = tagValue; // 更新UI状态 this.updateTagFavoriteState(tagValue, true); // 立即刷新收藏页(如果在) const activeTab = document.querySelector('.popup_tab.active'); if (activeTab && activeTab.getAttribute('data-category') === 'favorites') { const content = document.querySelector('.popup_tab_content.active'); if (content) { // 强制移除 data-loaded 属性 content.removeAttribute('data-loaded'); this._loadCategoryContent(content, this.favorites, 'favorites'); } } } } }, { label: '编辑标签', icon: 'pi-pencil', onClick: () => { // 使用识别出的 tagItem 执行编辑 this._handleEditTag(tagItem, tagName, tagValue, category); } }, { separator: true }, { label: '删除标签', icon: 'pi-trash', danger: true, onClick: () => { // 使用识别出的 tagItem 执行删除 this._handleDeleteTag(tagItem, tagName, tagValue); } } ]; showContextMenu({ x: e.clientX, y: e.clientY, items: menuItems }); } /** * 处理添加新分类 * @param {HTMLElement} triggerElement 触发元素(按钮) * @param {string|null} parentTabName 父 Tab 名称(如果是添加子分类) * @param {HTMLElement|null} tabsContainer Tab 容器(如果是添加顶级分类) * @param {HTMLElement|null} tabContentsContainer Tab 内容容器(如果是添加顶级分类) * @param {Object} data 当前数据对象 * @param {string|null} parentCategory 父分类名称(如果是添加子分类) */ static _handleAddCategory(triggerElement, parentTabName, tabsContainer, tabContentsContainer, data, parentCategory = null) { const isTopLevel = parentTabName === null; // 是否创建顶级分类(Tab) // 创建表单内容渲染函数 const renderForm = (container) => { container.style.display = 'flex'; container.style.flexDirection = 'column'; container.style.gap = '16px'; container.style.minWidth = '250px'; // 分类名输入组 - 使用浮动标签样式 const nameGroup = document.createElement('div'); nameGroup.className = 'settings-form-group'; const nameFloatContainer = document.createElement('div'); nameFloatContainer.className = 'float-label-container'; const nameInput = document.createElement('input'); nameInput.type = 'text'; nameInput.className = 'p-inputtext p-component'; nameInput.placeholder = ' '; const nameLabel = document.createElement('label'); nameLabel.textContent = '分类名称'; nameFloatContainer.appendChild(nameInput); nameFloatContainer.appendChild(nameLabel); nameGroup.appendChild(nameFloatContainer); container.appendChild(nameGroup); // 返回引用以便获取值 return { nameInput }; }; let inputs = {}; createConfirmPopup({ target: triggerElement, message: isTopLevel ? '创建新的标签页分类' : `在 "${parentTabName}" 下创建子分类`, icon: 'pi-plus', position: 'top', confirmLabel: '创建', renderFormContent: (container) => { inputs = renderForm(container); }, onConfirm: async () => { const categoryName = inputs.nameInput.value.trim(); if (!categoryName) { app.extensionManager.toast.add({ severity: "warn", summary: "输入无效", detail: "分类名称不能为空", life: 3000 }); throw new Error("Validation failed"); } // 检查分类名是否已存在 const targetData = isTopLevel ? TagManager.tagData : data; if (targetData && targetData.hasOwnProperty(categoryName)) { app.extensionManager.toast.add({ severity: "warn", summary: "创建失败", detail: `分类 "${categoryName}" 已存在`, life: 3000 }); throw new Error("Category already exists"); } // 保存新分类 await this._saveNewCategory(categoryName, isTopLevel, parentTabName, parentCategory); } }); } /** * 保存新分类到 CSV * @param {string} categoryName 分类名称 * @param {boolean} isTopLevel 是否为顶级分类 * @param {string|null} parentTabName 父 Tab 名称 * @param {string|null} parentCategory 父分类名称 */ static async _saveNewCategory(categoryName, isTopLevel, parentTabName, parentCategory) { try { // 使用 loadTagsCsv 加载当前 CSV 文件的最新数据 const filename = this.currentCsvFile || await ResourceManager.getSelectedTagFile(); if (!filename) { throw new Error("无法确定当前 CSV 文件"); } const data = await ResourceManager.loadTagsCsv(filename); if (!data) { throw new Error("加载标签数据失败"); } // 确定目标位置并添加新分类 if (isTopLevel) { // 检查顶级分类是否已存在 if (data.hasOwnProperty(categoryName)) { app.extensionManager.toast.add({ severity: "warn", summary: "分类已存在", detail: `分类 "${categoryName}" 已存在`, life: 3000 }); throw new Error("Category already exists"); } // 添加顶级分类(空分类需要有一个占位标签才能保存) data[categoryName] = { "__placeholder__": "__empty__" }; } else { // 查找父分类 const parentData = this._findCategoryRecursively(data, parentTabName); if (!parentData) { throw new Error(`找不到父分类: ${parentTabName}`); } // 检查子分类是否已存在 if (parentData.hasOwnProperty(categoryName)) { app.extensionManager.toast.add({ severity: "warn", summary: "分类已存在", detail: `分类 "${categoryName}" 已存在于 "${parentTabName}" 下`, life: 3000 }); throw new Error("Category already exists"); } // 添加子分类(空分类需要有一个占位标签才能保存) parentData[categoryName] = { "__placeholder__": "__empty__" }; } // 保存到 CSV 文件 await ResourceManager.saveTagsCsv(filename, data); // 重新加载 CSV 数据以刷新缓存 const newData = await ResourceManager.loadTagsCsv(filename); // 更新静态数据引用 TagManager.tagData = newData; // 实时刷新 UI if (isTopLevel) { // 顶级分类:在 Tab 栏中添加新 Tab(在"添加分类"按钮之前) const tabs = document.querySelector('.popup_tabs'); const tabContents = document.querySelector('.tag_category_container'); const addCategoryBtn = tabs?.querySelector('.add_category_tab'); if (tabs && tabContents && addCategoryBtn) { // 创建新 Tab const tab = document.createElement('div'); tab.className = 'popup_tab'; tab.textContent = categoryName; tab.setAttribute('data-category', categoryName); // 创建内容区域 const content = document.createElement('div'); content.className = 'popup_tab_content'; content.setAttribute('data-category', categoryName); content.style.flex = '1'; content.style.display = 'none'; // 添加 Tab 点击事件 const tabClickCleanup = EventManager.addDOMListener(tab, 'click', () => { // 取消所有 Tab 激活状态 tabs.querySelectorAll('.popup_tab').forEach(t => t.classList.remove('active')); tabContents.querySelectorAll('.popup_tab_content').forEach(c => { c.classList.remove('active'); c.style.display = 'none'; }); // 激活当前 Tab tab.classList.add('active'); content.classList.add('active'); content.style.display = 'block'; // 记忆当前选择的标签页 TagManager.setLastActiveTab(categoryName); // 懒加载内容 if (content.getAttribute('data-loaded') !== 'true') { this._loadCategoryContent(content, newData[categoryName], categoryName); content.setAttribute('data-loaded', 'true'); } }); this.eventCleanups.push(tabClickCleanup); // 在"添加分类"按钮之前插入新 Tab tabs.insertBefore(tab, addCategoryBtn); tabContents.appendChild(content); // 自动激活新创建的分类 tab.click(); } } else { // 子分类:刷新当前 Tab 的内容 const activeTab = document.querySelector('.popup_tab.active'); const content = document.querySelector('.popup_tab_content.active'); if (activeTab && content) { const cat = activeTab.getAttribute('data-category'); content.innerHTML = ''; content.removeAttribute('data-loaded'); this._loadCategoryContent(content, newData[cat], cat); } } app.extensionManager.toast.add({ severity: "success", summary: "创建成功", detail: `分类 "${categoryName}" 已创建`, life: 3000 }); } catch (error) { if (error.message !== "Validation failed" && error.message !== "Category already exists") { logger.error(`创建分类失败: ${error.message}`); app.extensionManager.toast.add({ severity: "error", summary: "创建失败", detail: error.message, life: 3000 }); } } } /** * 显示分类右键菜单 * @param {Event} e 鼠标事件 * @param {string} categoryName 分类名称 * @param {boolean} isTopLevel 是否为顶级分类 * @param {string|null} parentCategory 父分类名称(对于手风琴) */ static _showCategoryContextMenu(e, categoryName, isTopLevel, parentCategory = null) { // 特殊分类不允许修改或删除 const specialCategories = ['⭐️', 'favorites', '已插入', '标签']; if (specialCategories.includes(categoryName)) { return; } // 保存触发元素以便后续使用 const targetElement = e.target.closest('.popup_tab') || e.target.closest('.tag_accordion_header') || e.target; const menuItems = [ { label: '修改名称', icon: 'pi-pencil', onClick: () => { // 从 DOM 获取最新的分类名称 const currentName = isTopLevel ? targetElement.getAttribute('data-category') || targetElement.textContent.trim() : targetElement.closest('.tag_accordion')?.getAttribute('data-category') || categoryName; this._handleEditCategory(targetElement, currentName, isTopLevel, parentCategory); } }, { separator: true }, { label: '删除分类', icon: 'pi-trash', danger: true, onClick: () => { // 从 DOM 获取最新的分类名称 const currentName = isTopLevel ? targetElement.getAttribute('data-category') || targetElement.textContent.trim() : targetElement.closest('.tag_accordion')?.getAttribute('data-category') || categoryName; this._handleDeleteCategory(targetElement, currentName, isTopLevel, parentCategory); } } ]; showContextMenu({ x: e.clientX, y: e.clientY, items: menuItems }); } /** * 处理编辑分类名称 * @param {HTMLElement} target 触发元素 * @param {string} oldName 原分类名称 * @param {boolean} isTopLevel 是否为顶级分类 * @param {string|null} parentCategory 父分类名称 */ static _handleEditCategory(target, oldName, isTopLevel, parentCategory) { // 创建表单内容渲染函数 const renderForm = (container) => { container.style.display = 'flex'; container.style.flexDirection = 'column'; container.style.gap = '16px'; container.style.minWidth = '250px'; const nameGroup = document.createElement('div'); nameGroup.className = 'settings-form-group'; const nameFloatContainer = document.createElement('div'); nameFloatContainer.className = 'float-label-container'; const nameInput = document.createElement('input'); nameInput.type = 'text'; nameInput.className = 'p-inputtext p-component'; nameInput.placeholder = ' '; nameInput.value = oldName; const nameLabel = document.createElement('label'); nameLabel.textContent = '分类名称'; nameFloatContainer.appendChild(nameInput); nameFloatContainer.appendChild(nameLabel); nameGroup.appendChild(nameFloatContainer); container.appendChild(nameGroup); // 自动聚焦并选中文本 setTimeout(() => { nameInput.focus(); nameInput.select(); }, 100); return { nameInput }; }; let inputs = {}; createConfirmPopup({ target: target, message: `修改分类名称`, icon: 'pi-pencil', position: 'bottom', confirmLabel: '保存', renderFormContent: (container) => { inputs = renderForm(container); }, onConfirm: async () => { const newName = inputs.nameInput.value.trim(); if (!newName) { app.extensionManager.toast.add({ severity: "warn", summary: "输入无效", detail: "分类名称不能为空", life: 3000 }); throw new Error("Validation failed"); } if (newName === oldName) { return; // 名称未改变,无需保存 } await this._saveEditedCategory(oldName, newName, isTopLevel, parentCategory); } }); } /** * 保存编辑后的分类名称 */ static async _saveEditedCategory(oldName, newName, isTopLevel, parentCategory) { try { const filename = this.currentCsvFile || await ResourceManager.getSelectedTagFile(); if (!filename) { throw new Error("无法确定当前 CSV 文件"); } const data = await ResourceManager.loadTagsCsv(filename); if (!data) { throw new Error("加载标签数据失败"); } if (isTopLevel) { // 顶级分类重命名 if (data.hasOwnProperty(newName)) { app.extensionManager.toast.add({ severity: "warn", summary: "名称已存在", detail: `分类 "${newName}" 已存在`, life: 3000 }); throw new Error("Category already exists"); } // 复制数据到新名称,删除旧名称 data[newName] = data[oldName]; delete data[oldName]; } else { // 子分类重命名 const parentData = this._findCategoryRecursively(data, parentCategory); if (!parentData) { throw new Error(`找不到父分类: ${parentCategory}`); } if (parentData.hasOwnProperty(newName)) { app.extensionManager.toast.add({ severity: "warn", summary: "名称已存在", detail: `分类 "${newName}" 已存在于 "${parentCategory}" 下`, life: 3000 }); throw new Error("Category already exists"); } parentData[newName] = parentData[oldName]; delete parentData[oldName]; } await ResourceManager.saveTagsCsv(filename, data); const newData = await ResourceManager.loadTagsCsv(filename); TagManager.tagData = newData; // 刷新 UI if (isTopLevel) { // 更新 Tab 名称 const tab = document.querySelector(`.popup_tab[data-category="${oldName}"]`); const tabContent = document.querySelector(`.popup_tab_content[data-category="${oldName}"]`); if (tab) { tab.textContent = newName; tab.setAttribute('data-category', newName); } if (tabContent) { tabContent.setAttribute('data-category', newName); } } else { // 刷新当前 Tab 内容 const activeTab = document.querySelector('.popup_tab.active'); const content = document.querySelector('.popup_tab_content.active'); if (activeTab && content) { const cat = activeTab.getAttribute('data-category'); content.innerHTML = ''; content.removeAttribute('data-loaded'); this._loadCategoryContent(content, newData[cat], cat); } } app.extensionManager.toast.add({ severity: "success", summary: "修改成功", detail: `分类已重命名为 "${newName}"`, life: 3000 }); } catch (error) { if (error.message !== "Validation failed" && error.message !== "Category already exists") { logger.error(`修改分类失败: ${error.message}`); app.extensionManager.toast.add({ severity: "error", summary: "修改失败", detail: error.message, life: 3000 }); } } } /** * 处理删除分类 */ static async _handleDeleteCategory(targetElement, categoryName, isTopLevel, parentCategory) { // 使用确认弹窗 - 定位到触发元素 createConfirmPopup({ target: targetElement, message: `确定要删除分类 "${categoryName}" 和该分类下的所有标签吗?`, icon: 'pi-exclamation-triangle', iconColor: 'var(--p-orange-500)', position: 'bottom', confirmLabel: '删除', cancelLabel: '取消', confirmDanger: true, onConfirm: async () => { try { const filename = this.currentCsvFile || await ResourceManager.getSelectedTagFile(); if (!filename) { throw new Error("无法确定当前 CSV 文件"); } const data = await ResourceManager.loadTagsCsv(filename); if (!data) { throw new Error("加载标签数据失败"); } if (isTopLevel) { delete data[categoryName]; } else { const parentData = this._findCategoryRecursively(data, parentCategory); if (parentData) { delete parentData[categoryName]; } } await ResourceManager.saveTagsCsv(filename, data); const newData = await ResourceManager.loadTagsCsv(filename); TagManager.tagData = newData; // 刷新 UI if (isTopLevel) { // 删除 Tab 和内容 const tab = document.querySelector(`.popup_tab[data-category="${categoryName}"]`); const tabContent = document.querySelector(`.popup_tab_content[data-category="${categoryName}"]`); const wasActive = tab?.classList.contains('active'); tab?.remove(); tabContent?.remove(); // 如果删除的是当前激活的 Tab,激活第一个普通 Tab if (wasActive) { const firstTab = document.querySelector('.popup_tab:not(.add_category_tab)'); if (firstTab) { firstTab.click(); } } } else { // 刷新当前 Tab 内容 const activeTab = document.querySelector('.popup_tab.active'); const content = document.querySelector('.popup_tab_content.active'); if (activeTab && content) { const cat = activeTab.getAttribute('data-category'); content.innerHTML = ''; content.removeAttribute('data-loaded'); this._loadCategoryContent(content, newData[cat], cat); } } app.extensionManager.toast.add({ severity: "success", summary: "删除成功", detail: `分类 "${categoryName}" 已删除`, life: 3000 }); } catch (error) { logger.error(`删除分类失败: ${error.message}`); app.extensionManager.toast.add({ severity: "error", summary: "删除失败", detail: error.message, life: 3000 }); } } }); } /** * 处理添加新标签 * @param {string} categoryKey 分类键 * @param {string} categoryName 分类名 * @param {HTMLElement} triggerElement 触发元素(加号按钮) */ static _handleAddTag(categoryKey, categoryName, triggerElement) { // 创建表单内容渲染函数 const renderForm = (container) => { container.style.display = 'flex'; container.style.flexDirection = 'column'; container.style.gap = '16px'; container.style.minWidth = '300px'; // 标签名输入组 - 使用浮动标签样式 const nameGroup = document.createElement('div'); nameGroup.className = 'settings-form-group'; const nameFloatContainer = document.createElement('div'); nameFloatContainer.className = 'float-label-container'; const nameInput = document.createElement('input'); nameInput.type = 'text'; nameInput.value = ''; nameInput.className = 'p-inputtext p-component'; nameInput.placeholder = ' '; // 使用空格触发 :not(:placeholder-shown) const nameLabel = document.createElement('label'); nameLabel.textContent = '标签名称'; nameFloatContainer.appendChild(nameInput); nameFloatContainer.appendChild(nameLabel); nameGroup.appendChild(nameFloatContainer); container.appendChild(nameGroup); // 标签内容输入组 - 使用浮动标签样式 const valueGroup = document.createElement('div'); valueGroup.className = 'settings-form-group'; const valueFloatContainer = document.createElement('div'); valueFloatContainer.className = 'float-label-container'; const valueInput = document.createElement('textarea'); valueInput.value = ''; valueInput.className = 'p-inputtext p-component settings-form-textarea'; valueInput.placeholder = ' '; valueInput.rows = 3; valueInput.style.resize = 'vertical'; valueInput.style.minHeight = '80px'; const valueLabel = document.createElement('label'); valueLabel.textContent = '标签内容'; valueFloatContainer.appendChild(valueInput); valueFloatContainer.appendChild(valueLabel); valueGroup.appendChild(valueFloatContainer); container.appendChild(valueGroup); // 返回引用以便获取值 return { nameInput, valueInput }; }; // 使用传入的触发元素作为定位目标,如果没有则降级到手风琴头部 const anchor = triggerElement || document.querySelector(`.tag_accordion[data-category="${CSS.escape(categoryKey)}"] .tag_accordion_header`) || document.body; let inputs = {}; createConfirmPopup({ target: anchor, message: `在 "${categoryKey}" 分类下创建新标签`, icon: 'pi-plus', position: 'left', // 创建按钮在右侧,气泡向左显示 confirmLabel: '保存', renderFormContent: (container) => { inputs = renderForm(container); }, onConfirm: async () => { const newName = inputs.nameInput.value.trim(); const newValue = inputs.valueInput.value.trim(); if (!newName || !newValue) { app.extensionManager.toast.add({ severity: "warn", summary: "输入无效", detail: "标签名称和内容不能为空", life: 3000 }); throw new Error("Validation failed"); } // 执行保存逻辑 await this._saveNewTag(categoryKey, categoryName, newName, newValue); } }); } /** * 保存新标签 */ static async _saveNewTag(categoryKey, categoryName, tagName, tagValue) { try { // 使用 loadTagsCsv 加载当前 CSV 文件的最新数据 const filename = this.currentCsvFile || await ResourceManager.getSelectedTagFile(); if (!filename) { throw new Error("无法确定当前 CSV 文件"); } const data = await ResourceManager.loadTagsCsv(filename); if (!data || Object.keys(data).length === 0) { throw new Error("加载标签数据失败"); } // 查找目标分类 let targetCategory = TagManager._findCategoryRecursively(data, categoryKey); if (!targetCategory) { // 如果分类不存在,创建新分类 data[categoryKey] = {}; targetCategory = data[categoryKey]; } // 检查标签名是否已存在 if (targetCategory.hasOwnProperty(tagName)) { app.extensionManager.toast.add({ severity: "warn", summary: "标签已存在", detail: `标签 "${tagName}" 已存在于该分类中`, life: 3000 }); throw new Error("Tag already exists"); } // 添加新标签 targetCategory[tagName] = tagValue; // 保存到 CSV 文件 await ResourceManager.saveTagsCsv(filename, data); // 重新加载 CSV 数据以刷新缓存 const newData = await ResourceManager.loadTagsCsv(filename); // 更新静态数据引用,确保后续拖拽排序使用最新数据 TagManager.tagData = newData; // 刷新当前视图 const activeTab = document.querySelector('.popup_tab.active'); const content = document.querySelector('.popup_tab_content.active'); if (activeTab && content) { const cat = activeTab.getAttribute('data-category'); content.innerHTML = ''; content.removeAttribute('data-loaded'); if (cat === 'favorites') { this._loadCategoryContent(content, this.favorites, 'favorites'); } else { this._loadCategoryContent(content, newData[cat], cat); } } app.extensionManager.toast.add({ severity: "success", summary: "创建成功", detail: `标签 "${tagName}" 已添加到 "${categoryKey}" 分类`, life: 3000 }); } catch (error) { // 如果是验证错误,不显示错误提示(已经在验证时显示) if (error.message !== "Validation failed" && error.message !== "Tag already exists") { logger.error(`创建标签失败: ${error.message}`); app.extensionManager.toast.add({ severity: "error", summary: "创建失败", detail: error.message, life: 3000 }); } } } /** * 处理编辑标签 */ static _handleEditTag(target, tagName, tagValue, category) { // 创建表单内容渲染函数 const renderForm = (container) => { container.style.display = 'flex'; container.style.flexDirection = 'column'; container.style.gap = '16px'; container.style.minWidth = '300px'; // 标签名输入组 - 使用浮动标签样式 const nameGroup = document.createElement('div'); nameGroup.className = 'settings-form-group'; const nameFloatContainer = document.createElement('div'); nameFloatContainer.className = 'float-label-container'; const nameInput = document.createElement('input'); nameInput.type = 'text'; nameInput.value = tagName; nameInput.className = 'p-inputtext p-component'; nameInput.placeholder = ' '; // 使用空格触发 :not(:placeholder-shown) const nameLabel = document.createElement('label'); nameLabel.textContent = '标签名称'; nameFloatContainer.appendChild(nameInput); nameFloatContainer.appendChild(nameLabel); nameGroup.appendChild(nameFloatContainer); container.appendChild(nameGroup); // 标签内容输入组 - 使用浮动标签样式 const valueGroup = document.createElement('div'); valueGroup.className = 'settings-form-group'; const valueFloatContainer = document.createElement('div'); valueFloatContainer.className = 'float-label-container'; const valueInput = document.createElement('textarea'); valueInput.value = tagValue; valueInput.className = 'p-inputtext p-component settings-form-textarea'; valueInput.placeholder = ' '; valueInput.rows = 3; valueInput.style.resize = 'vertical'; valueInput.style.minHeight = '80px'; const valueLabel = document.createElement('label'); valueLabel.textContent = '标签内容'; valueFloatContainer.appendChild(valueInput); valueFloatContainer.appendChild(valueLabel); valueGroup.appendChild(valueFloatContainer); container.appendChild(valueGroup); // 返回引用以便获取值 return { nameInput, valueInput, categoryInput: null }; }; // 查找合适的定位目标 (tag_item) const tagItem = document.querySelector(`.tag_item[data-value="${CSS.escape(tagValue)}"]`); const anchor = tagItem || document.body; // 降级处理 let inputs = {}; createConfirmPopup({ target: anchor, message: '编辑标签信息', icon: 'pi-pencil', confirmLabel: '保存', renderFormContent: (container) => { inputs = renderForm(container); }, onConfirm: async () => { const newName = inputs.nameInput.value.trim(); const newValue = inputs.valueInput.value.trim(); const newCategory = inputs.categoryInput ? inputs.categoryInput.value.trim() : category; if (!newName || !newValue) { app.extensionManager.toast.add({ severity: "warn", summary: "输入无效", detail: "标签名称和内容不能为空", life: 3000 }); throw new Error("Validation failed"); } // 执行保存逻辑 await this._saveTagEdit(tagName, tagValue, category, newName, newValue, newCategory); } }); } /** * 保存标签编辑 */ static async _saveTagEdit(oldName, oldValue, oldCategory, newName, newValue, newCategory) { try { // 使用 loadTagsCsv 加载当前 CSV 文件的最新数据 const filename = this.currentCsvFile || await ResourceManager.getSelectedTagFile(); if (!filename) { throw new Error("无法确定当前 CSV 文件"); } const data = await ResourceManager.loadTagsCsv(filename); if (!data || Object.keys(data).length === 0) { throw new Error("加载标签数据失败"); } // 查找原始标签 const found = TagManager._findTagRecursively(data, oldName, oldValue); if (!found) { throw new Error("无法定位原始标签数据,请刷新后重试"); } const { parent } = found; // 删除旧标签 delete parent[oldName]; // 确定目标分类对象 let targetCategoryObj; if (oldCategory !== newCategory && newCategory) { // 如果分类改变,移动到新分类 targetCategoryObj = TagManager._findCategoryRecursively(data, newCategory); if (!targetCategoryObj) { // 如果目标分类不存在,创建它 data[newCategory] = {}; targetCategoryObj = data[newCategory]; } } else { // 分类未变,仍在原父对象中 targetCategoryObj = parent; } // 添加新标签(或更新后的标签) targetCategoryObj[newName] = newValue; // 保存到 CSV 文件 const saveSuccess = await ResourceManager.saveTagsCsv(filename, data); if (!saveSuccess) { throw new Error("保存文件失败"); } // 重新加载并更新状态 const newData = await ResourceManager.loadTagsCsv(filename); TagManager.tagData = newData; // 刷新当前视图 const activeTab = document.querySelector('.popup_tab.active'); const content = document.querySelector('.popup_tab_content.active'); if (activeTab && content) { const cat = activeTab.getAttribute('data-category'); content.innerHTML = ''; content.removeAttribute('data-loaded'); if (cat === 'favorites') { this._loadCategoryContent(content, this.favorites, 'favorites'); } else { // 常规分类,如果分类名变了可能需要动态加载新分类 const displayCategory = newData[cat] ? cat : newCategory; this._loadCategoryContent(content, newData[displayCategory] || newData, displayCategory); } } app.extensionManager.toast.add({ severity: "success", summary: "保存成功", detail: "标签已更新", life: 3000 }); } catch (error) { logger.error(`保存编辑失败: ${error.message}`); app.extensionManager.toast.add({ severity: "error", summary: "保存失败", detail: error.message, life: 3000 }); } } /** * 处理删除标签 */ static async _handleDeleteTag(targetElement, tagName, tagValue) { createConfirmPopup({ target: targetElement || document.body, message: `确定要删除标签 "${tagName}" 吗?`, icon: 'pi-exclamation-triangle', iconColor: 'var(--p-orange-500)', confirmLabel: '删除', cancelLabel: '取消', confirmDanger: true, onConfirm: async () => { try { // 1. 获取当前 CSV 文件名 const filename = this.currentCsvFile || await ResourceManager.getSelectedTagFile(); if (!filename) { throw new Error("无法确定当前 CSV 文件"); } // 2. 加载 CSV 数据 const data = await ResourceManager.loadTagsCsv(filename); if (!data) { throw new Error("加载标签数据失败"); } // 3. 在数据中查找并删除标签 let deleted = false; // 递归删除函数 const deleteRecursively = (obj) => { for (const key in obj) { if (key === tagName && obj[key] === tagValue) { delete obj[key]; deleted = true; return true; } if (typeof obj[key] === 'object' && obj[key] !== null) { if (deleteRecursively(obj[key])) { return true; } } } return false; }; deleteRecursively(data); if (!deleted) { throw new Error("未找到指定的标签"); } // 4. 保存更改 const saveSuccess = await ResourceManager.saveTagsCsv(filename, data); if (!saveSuccess) { throw new Error("保存文件失败"); } // 5. 刷新 UI TagManager.tagData = data; // 重新加载当前视图 const activeTab = document.querySelector('.popup_tab.active'); if (activeTab) { const category = activeTab.getAttribute('data-category'); const content = document.querySelector(`.popup_tab_content[data-category="${category}"]`); if (content) { if (category === 'favorites') { this._loadCategoryContent(content, this.favorites, 'favorites'); } else if (category === 'inserted') { this._loadInsertedTagsContent(content); } else { this._loadCategoryContent(content, data[category], category); } } } app.extensionManager.toast.add({ severity: "success", summary: "删除成功", detail: `标签 "${tagName}" 已删除`, life: 3000 }); } catch (error) { logger.error(`删除标签失败: ${error.message}`); app.extensionManager.toast.add({ severity: "error", summary: "删除失败", detail: error.message, life: 3000 }); } } }); } /** * 更新标签的收藏状态显示 */ static updateTagFavoriteState(tagValue, isFavorited) { // 查找所有具有该值的标签元素 // 注意:需要转义引号以防止选择器错误 const safeValue = tagValue.replace(/"/g, '\\"'); const tagItems = document.querySelectorAll(`.tag_item[data-value="${safeValue}"]`); tagItems.forEach(item => { const textSpan = item.querySelector('.tag_item_text'); if (textSpan) { const currentText = textSpan.textContent; // 移除现有的星星前缀(如果有) const cleanText = currentText.replace(/^⭐️\s*/, ''); // 根据状态添加或不添加前缀 textSpan.textContent = isFavorited ? `⭐️ ${cleanText}` : cleanText; } }); } /** * 比对输入框内容与标签缓存,更新标签状态 */ static compareInputWithCache(nodeId, inputId, updateUI = true) { const startTime = performance.now(); // 获取输入框值 const mapping = UIToolkit._findMapping(nodeId, inputId, (nodeId === this.currentNodeId && inputId === this.currentInputId) ? this.currentWidgetKey : null); if (!mapping || !mapping.inputEl) return { cacheCount: 0, matchedCount: 0 }; const inputValue = mapping.inputEl.value; // 获取缓存 const cache = TagCacheService.getTagCache(nodeId, inputId); const cacheCount = cache ? cache.size : 0; let matchedCount = 0; if (updateUI) { // 清空已使用标签映射 this.usedTags.clear(); // 获取所有标签元素 const tagItems = document.querySelectorAll('.tag_item'); // 遍历所有标签元素,更新状态 tagItems.forEach(tagItem => { const tagValue = tagItem.getAttribute('data-value'); if (!tagValue) return; // 检查是否在输入框中 const isUsed = TagCacheService.isTagInInput(nodeId, inputId, tagValue, inputValue); if (isUsed) { matchedCount++; this.usedTags.set(tagValue, tagItem); } // 更新标签显示状态 this.updateTagState(tagItem, isUsed); }); } else { // 只统计匹配数量,不更新UI cache?.forEach((_, rawTag) => { if (TagCacheService.isTagInInput(nodeId, inputId, rawTag, inputValue)) { matchedCount++; } }); } const endTime = performance.now(); // logger.debug(`标签状态 | 动作:比对 | 节点:${nodeId} | 输入框:${inputId} | 缓存数量:${cacheCount} | 匹配数量:${matchedCount} | 耗时:${(endTime - startTime).toFixed(2)}ms`); return { cacheCount, matchedCount }; } /** * 创建特殊标签页结构(包含正常标签、收藏标签和已插入标签) */ static _createTabsWithSpecialCategories(tagData, favorites, container, nodeId, inputId) { // 创建标签页框架 const tabsContainer = document.createElement('div'); tabsContainer.className = 'popup_tabs_container'; // 创建标签滚动区域 const tabsScroll = document.createElement('div'); tabsScroll.className = 'popup_tabs_scroll'; // 创建标签栏 const tabs = document.createElement('div'); tabs.className = 'popup_tabs'; // 创建内容区域 const content = document.createElement('div'); content.className = 'popup_content'; // 准备所有分类 const categories = []; // 1. 添加收藏夹分类 categories.push({ name: 'favorites', displayName: '⭐️ 收藏', data: favorites, type: 'favorites' }); // 2. 添加CSV中的分类 if (tagData) { // 构建查找表 this.tagLookup.clear(); const buildLookup = (obj) => { for (const [key, value] of Object.entries(obj)) { if (typeof value === 'object' && value !== null) { buildLookup(value); } else if (typeof value === 'string') { // Value -> Name 映射 // 注意:如果不同标签有相同的值,后面的会覆盖前面的名称。这通常是可以接受的。 this.tagLookup.set(value, key); } } }; buildLookup(tagData); Object.keys(tagData).forEach(key => { categories.push({ name: key, displayName: key, data: tagData[key], type: 'normal' }); }); } // 3. 添加已插入分类 categories.push({ name: '已插入', displayName: '📝 已插入', data: null, type: 'inserted' }); // 获取上次激活的标签页 let activeTabName = this.getLastActiveTab(); // 如果上次激活的是普通分类,但当前CSV中没有这个分类,则重置为第一个分类 if (activeTabName && activeTabName !== 'favorites' && activeTabName !== '已插入' && tagData && !tagData[activeTabName]) { activeTabName = null; } if (!activeTabName && categories.length > 0) { activeTabName = categories[0].name; } // 创建标签页和内容 categories.forEach((category, index) => { // 创建标签 const tab = document.createElement('div'); tab.className = 'popup_tab'; tab.textContent = category.displayName; tab.setAttribute('data-category', category.name); // 创建内容容器 const tabContent = document.createElement('div'); tabContent.className = 'popup_tab_content'; tabContent.setAttribute('data-category', category.name); // 激活状态处理 if (category.name === activeTabName) { tab.classList.add('active'); tabContent.classList.add('active'); // 立即加载当前激活的标签页内容 if (category.type === 'favorites') { // 使用标准的 _loadCategoryContent 加载,因为数据结构已统一 this._loadCategoryContent(tabContent, category.data, 'favorites'); } else if (category.type === 'inserted') { this._loadInsertedTagsContent(tabContent); } else { this._loadCategoryContent(tabContent, category.data, category.name); } } // 标签点击事件 const tabClickCleanup = EventManager.addDOMListener(tab, 'click', () => { // 切换标签激活状态 tabs.querySelectorAll('.popup_tab').forEach(t => t.classList.remove('active')); tab.classList.add('active'); // 切换内容激活状态 content.querySelectorAll('.popup_tab_content').forEach(c => c.classList.remove('active')); tabContent.classList.add('active'); // 记录激活的标签页 this.setLastActiveTab(category.name); // 懒加载内容或需要实时刷新的内容 if (!tabContent.hasAttribute('data-loaded') || category.type === 'favorites') { if (category.type === 'favorites') { // 收藏页每次点击都重新获取最新数据 // 先清空现有内容 tabContent.innerHTML = ''; // 重新获取收藏数据 ResourceManager.getFavorites().then(favorites => { this.favorites = favorites; // 更新本地缓存 this._loadCategoryContent(tabContent, favorites, 'favorites'); tabContent.setAttribute('data-loaded', 'true'); }).catch(err => { console.error("刷新收藏失败", err); }); } else if (category.type === 'inserted') { this._loadInsertedTagsContent(tabContent); tabContent.setAttribute('data-loaded', 'true'); } else { // 常规分类只加载一次 if (!tabContent.hasAttribute('data-loaded')) { this._loadCategoryContent(tabContent, category.data, category.name); tabContent.setAttribute('data-loaded', 'true'); } } } else if (category.type === 'inserted') { // 已插入标签页每次点击都刷新 this._loadInsertedTagsContent(tabContent); } // 确保标签完全可见 tab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); }); this.eventCleanups.push(tabClickCleanup); tabs.appendChild(tab); content.appendChild(tabContent); }); // ---在 Tab 栏末尾添加"新建分类"按钮--- const addTabButton = document.createElement('div'); addTabButton.className = 'popup_tab add_category_tab'; addTabButton.title = '新建分类'; const addTabIcon = document.createElement('span'); addTabIcon.className = 'pi pi-plus'; addTabButton.appendChild(addTabIcon); const addTabClickCleanup = EventManager.addDOMListener(addTabButton, 'click', (e) => { e.stopPropagation(); this._handleAddCategory(addTabButton, null, tabs, content, tagData); }); this.eventCleanups.push(addTabClickCleanup); tabs.appendChild(addTabButton); // 添加滚动指示器 const leftIndicator = document.createElement('div'); leftIndicator.className = 'popup_tabs_indicator left'; const leftIcon = document.createElement('i'); leftIcon.className = 'pi pi-chevron-left'; leftIndicator.appendChild(leftIcon); const rightIndicator = document.createElement('div'); rightIndicator.className = 'popup_tabs_indicator right'; const rightIcon = document.createElement('i'); rightIcon.className = 'pi pi-chevron-right'; rightIndicator.appendChild(rightIcon); // 更新指示器状态 const updateIndicators = () => { const { scrollLeft, scrollWidth, clientWidth } = tabsScroll; leftIndicator.style.display = scrollLeft > 0 ? 'flex' : 'none'; rightIndicator.style.display = scrollLeft < scrollWidth - clientWidth - 1 ? 'flex' : 'none'; }; // 监听滚动事件 const scrollCleanup = EventManager.addDOMListener(tabsScroll, 'scroll', updateIndicators); this.eventCleanups.push(scrollCleanup); // 监听窗口大小变化 const resizeObserver = new ResizeObserver(updateIndicators); resizeObserver.observe(tabsScroll); // 保存清理函数 this.eventCleanups.push(() => resizeObserver.disconnect()); // 添加指示器点击事件 const leftClickCleanup = EventManager.addDOMListener(leftIndicator, 'click', () => { tabsScroll.scrollBy({ left: -200, behavior: 'smooth' }); }); this.eventCleanups.push(leftClickCleanup); const rightClickCleanup = EventManager.addDOMListener(rightIndicator, 'click', () => { tabsScroll.scrollBy({ left: 200, behavior: 'smooth' }); }); this.eventCleanups.push(rightClickCleanup); // 组装DOM tabsScroll.appendChild(tabs); tabsContainer.appendChild(leftIndicator); tabsContainer.appendChild(tabsScroll); tabsContainer.appendChild(rightIndicator); container.appendChild(tabsContainer); container.appendChild(content); // 初始化指示器 setTimeout(updateIndicators, 0); } /** * 按照CSV文件的分类结构重新组织收藏标签 * @param {Object} csvData CSV文件的分类结构 * @param {Object} favorites 收藏标签数据 * @param {string} csvFileName 当前CSV文件名 * @returns {Object} 重新组织后的收藏标签数据 */ static _reorganizeFavoritesByCSVStructure(csvData, favorites, csvFileName) { // 标准化CSV文件名(去除扩展名) const normalizedFileName = csvFileName.replace(/\.(csv|json|yaml|yml)$/i, ''); // 获取当前CSV文件的收藏标签 const csvFavorites = favorites[normalizedFileName] || {}; // 如果没有收藏标签,返回空对象 if (Object.keys(csvFavorites).length === 0) { return {}; } // 递归函数:按照CSV结构组织收藏标签 const reorganize = (structure) => { const result = {}; for (const [key, value] of Object.entries(structure)) { if (typeof value === 'string') { // 这是一个标签,检查是否在收藏中 if (Object.values(csvFavorites).includes(value)) { // 找到对应的标签名 const tagName = Object.keys(csvFavorites).find(k => csvFavorites[k] === value); if (tagName) { result[tagName] = value; } } } else if (typeof value === 'object' && value !== null) { // 这是一个分类,递归处理 const subResult = reorganize(value); if (Object.keys(subResult).length > 0) { result[key] = subResult; } } } return result; }; return reorganize(csvData); } /** * 加载单个分类的内容 */ static _loadCategoryContent(contentElement, categoryData, categoryName) { if (!contentElement) return; // 加载前先清空容器,避免重复添加或残留Empty Tip contentElement.innerHTML = ''; // 特殊处理收藏夹 if (categoryName === 'favorites') { // 直接使用favorites的原始结构(CSV文件名作为顶层分类) // categoryData 的结构应该是 {CSV文件名: {分类: {标签名: 标签值}}} // 如果没有收藏标签,显示空提示 if (!categoryData || Object.keys(categoryData).length === 0) { const emptyTip = document.createElement('div'); emptyTip.className = 'empty_tip'; emptyTip.textContent = '暂无收藏标签,在标签上右键点击可添加到收藏'; emptyTip.style.padding = '20px'; emptyTip.style.textAlign = 'center'; emptyTip.style.color = 'var(--text-color-secondary)'; contentElement.appendChild(emptyTip); contentElement.setAttribute('data-loaded', 'true'); return; } } if (!categoryData) return; // 创建一个内容容器,用于应用动画效果 const contentWrapper = document.createElement('div'); contentWrapper.className = 'tag_content_wrapper'; // 异步加载分类内容 setTimeout(() => { try { // 创建分类内容 if (typeof categoryData === 'object' && categoryData !== null) { // 对于收藏标签页,不传递rootCategory,因为顶层就是CSV文件名 // 对于普通标签页,传递当前CSV文件名作为根分类 const rootCategory = categoryName === 'favorites' ? null : this.currentCsvFile; const innerContent = this._createInnerAccordion(categoryData, '1', categoryName, rootCategory); contentWrapper.appendChild(innerContent); // 添加到父容器 contentElement.appendChild(contentWrapper); // 标记为已加载 contentElement.setAttribute('data-loaded', 'true'); // 触发渐显动画 requestAnimationFrame(() => { // 确保DOM已经渲染 setTimeout(() => { contentWrapper.classList.add('visible'); // 对于收藏标签页,自动展开当前CSV文件对应的手风琴 if (categoryName === 'favorites' && this.currentCsvFile) { // 标准化CSV文件名(去除扩展名) const normalizedFileName = this.currentCsvFile.replace(/\.(csv|json|yaml|yml)$/i, ''); // 查找对应的手风琴 const targetAccordion = contentElement.querySelector(`.tag_accordion[data-category="${normalizedFileName}"]`); if (targetAccordion) { const header = targetAccordion.querySelector('.tag_accordion_header'); if (header && !header.classList.contains('active')) { // 自动展开 header.click(); } } } }, 10); }); } } catch (error) { logger.error(`加载标签分类失败: ${categoryName} | ${error.message}`); // 创建错误提示 const errorMessage = document.createElement('div'); errorMessage.textContent = `加载${categoryName}分类失败`; errorMessage.style.textAlign = 'center'; errorMessage.style.padding = '20px'; errorMessage.style.color = '#ff6b6b'; contentWrapper.appendChild(errorMessage); contentElement.appendChild(contentWrapper); // 显示错误信息 requestAnimationFrame(() => { contentWrapper.classList.add('visible'); }); } }, 0); // 使用0延迟,让UI线程有机会更新 } /** * 显示标签弹窗 */ static showTagPopup(options) { const { anchorButton, nodeId, inputId, onClose, buttonInfo, onTagSelect, refresh = false } = options; // 保存当前节点和输入框ID this.currentNodeId = nodeId; this.currentInputId = inputId; this.currentWidgetKey = options.widgetKey || null; this.onTagSelectCallback = onTagSelect; // 清理现有事件监听 this._cleanupEvents(); // 初始化 Sortable this._initSortable(); try { // 创建弹窗容器 const popup = document.createElement('div'); popup.className = 'popup_container tag_popup'; // 添加tag_popup类用于特定样式 popup.style.display = 'flex'; popup.style.flexDirection = 'column'; popup.style.minHeight = '400px'; // 设置最小高度,确保 PopupManager 可以正确计算位置 popup.style.maxHeight = '80vh'; // 设置最大高度,防止弹窗过大 popup.style.height = 'auto'; // 允许高度自适应内容 // 使用事件委托处理右键菜单 - 解决某些情况下事件无法绑定的问题 popup.addEventListener('contextmenu', (e) => { const tagItem = e.target.closest('.tag_item'); if (tagItem) { const tagName = tagItem.getAttribute('data-name'); const tagValue = tagItem.getAttribute('data-value'); const category = tagItem.getAttribute('data-category'); if (tagValue && tagName) { e.preventDefault(); e.stopPropagation(); // 记录日志确保触发 logger.debug(`Delegated context menu triggered on tag: ${tagName} | category: ${category}`); try { this._showContextMenu(e, tagValue, tagName, category); } catch (error) { logger.error(`Error showing context menu: ${error.message}`); console.error(error); } } } }, true); // 使用捕获阶段 // 创建标题栏 const titleBar = document.createElement('div'); titleBar.className = 'popup_title_bar'; const title = document.createElement('div'); title.className = 'popup_title'; // 加载SVG图标(getIcon返回已包含SVG的容器) const iconContainer = ResourceManager.getIcon('icon-tag.svg'); if (iconContainer) { iconContainer.style.width = '18px'; iconContainer.style.height = '18px'; iconContainer.style.color = 'var(--p-dialog-color)'; // 设置颜色供SVG继承 title.appendChild(iconContainer); } // 创建CSV选择器容器 const csvSelectorContainer = document.createElement('div'); csvSelectorContainer.className = 'popup_csv_selector_container'; csvSelectorContainer.style.marginRight = '10px'; csvSelectorContainer.style.position = 'relative'; // 创建自定义下拉按钮 const csvSelector = document.createElement('button'); csvSelector.className = 'popup_csv_selector'; csvSelector.style.padding = '4px 12px 4px 8px'; csvSelector.style.borderRadius = '10px'; csvSelector.style.border = '0px solid var(--p-dialog-border-color)'; csvSelector.style.backgroundColor = 'color-mix(in srgb, var(--comfy-menu-secondary-bg), transparent 60%)'; csvSelector.style.color = 'var(--p-text-color)'; csvSelector.style.outline = 'none'; csvSelector.style.cursor = 'pointer'; csvSelector.style.display = 'flex'; csvSelector.style.alignItems = 'center'; csvSelector.style.gap = '8px'; csvSelector.style.minWidth = '120px'; csvSelector.style.transition = 'border-color 0.2s, background-color 0.2s'; // 添加文本标签 const csvSelectorLabel = document.createElement('span'); csvSelectorLabel.style.flex = '1'; csvSelectorLabel.style.textAlign = 'left'; csvSelectorLabel.style.fontSize = '14px'; csvSelector.appendChild(csvSelectorLabel); // 添加下拉箭头图标 const csvSelectorIcon = document.createElement('i'); csvSelectorIcon.className = 'pi pi-chevron-down'; csvSelectorIcon.style.fontSize = '12px'; csvSelectorIcon.style.transition = 'transform 0.2s'; csvSelector.appendChild(csvSelectorIcon); // 创建下拉菜单 const csvDropdownMenu = document.createElement('div'); csvDropdownMenu.className = 'pa-context-menu pa-dropdown-menu'; // 添加pa-dropdown-menu类用于区分 csvDropdownMenu.style.display = 'none'; csvDropdownMenu.style.position = 'absolute'; csvDropdownMenu.style.top = 'calc(100% + 4px)'; csvDropdownMenu.style.left = '0'; csvDropdownMenu.style.minWidth = '100%'; // z-index 由 CSS 的 --settings-context-menu-z-index 变量管理 const csvMenuList = document.createElement('ul'); csvMenuList.className = 'pa-context-menu-list'; csvDropdownMenu.appendChild(csvMenuList); csvSelectorContainer.appendChild(csvSelector); csvSelectorContainer.appendChild(csvDropdownMenu); // 切换下拉菜单显示/隐藏 let csvMenuOpen = false; const toggleCsvMenu = (e) => { e.stopPropagation(); csvMenuOpen = !csvMenuOpen; if (csvMenuOpen) { csvDropdownMenu.style.display = 'block'; requestAnimationFrame(() => { csvDropdownMenu.classList.add('pa-context-menu-show'); csvSelectorIcon.style.transform = 'rotate(180deg)'; }); csvSelector.style.borderColor = 'var(--p-primary-color)'; } else { csvDropdownMenu.classList.remove('pa-context-menu-show'); csvDropdownMenu.classList.add('pa-context-menu-hide'); csvSelectorIcon.style.transform = 'rotate(0deg)'; csvSelector.style.borderColor = 'var(--p-dialog-border-color)'; setTimeout(() => { csvDropdownMenu.style.display = 'none'; csvDropdownMenu.classList.remove('pa-context-menu-hide'); }, 150); } }; // 关闭下拉菜单 const closeCsvMenu = () => { if (csvMenuOpen) { csvMenuOpen = false; csvDropdownMenu.classList.remove('pa-context-menu-show'); csvDropdownMenu.classList.add('pa-context-menu-hide'); csvSelectorIcon.style.transform = 'rotate(0deg)'; csvSelector.style.borderColor = 'var(--p-dialog-border-color)'; setTimeout(() => { csvDropdownMenu.style.display = 'none'; csvDropdownMenu.classList.remove('pa-context-menu-hide'); }, 150); } }; // 点击按钮切换菜单 const csvSelectorClickCleanup = EventManager.addDOMListener(csvSelector, 'click', toggleCsvMenu); this.eventCleanups.push(csvSelectorClickCleanup); // 点击外部关闭菜单 const handleCsvMenuOutsideClick = (e) => { if (csvMenuOpen && !csvSelectorContainer.contains(e.target)) { closeCsvMenu(); } }; const csvOutsideClickCleanup = EventManager.addDOMListener(document, 'click', handleCsvMenuOutsideClick, true); this.eventCleanups.push(csvOutsideClickCleanup); // 监听右键菜单打开事件,自动关闭CSV下拉菜单 const handleContextMenuOpen = (e) => { // 如果右键菜单不是在CSV选择器容器内打开的,则关闭CSV下拉菜单 if (csvMenuOpen && !csvSelectorContainer.contains(e.target)) { closeCsvMenu(); } }; const contextMenuCleanup = EventManager.addDOMListener(document, 'contextmenu', handleContextMenuOpen, true); this.eventCleanups.push(contextMenuCleanup); // CSV切换处理函数 const handleCsvChange = async (filename) => { if (filename && filename !== this.currentCsvFile) { this.currentCsvFile = filename; await ResourceManager.setSelectedTagFile(filename); // 重新加载数据 loadingIndicator.style.display = 'block'; contentContainer.innerHTML = ''; contentContainer.appendChild(loadingIndicator); try { const [tagData, favorites] = await Promise.all([ ResourceManager.loadTagsCsv(filename), ResourceManager.getFavorites() ]); this.favorites = favorites; // 移除加载指示器 if (loadingIndicator.parentNode) { loadingIndicator.parentNode.removeChild(loadingIndicator); } // 重新创建标签页 this._createTabsWithSpecialCategories(tagData, favorites, contentContainer, nodeId, inputId); // 如果当前在收藏标签页,重新加载收藏标签内容 // _loadCategoryContent会自动展开当前CSV对应的手风琴 setTimeout(() => { const activeTab = contentContainer.querySelector('.popup_tab.active'); if (activeTab && activeTab.getAttribute('data-category') === '⭐️') { const favoritesContent = contentContainer.querySelector('.popup_tab_content[data-category="⭐️"]'); if (favoritesContent) { // 重新加载收藏标签内容(会自动展开当前CSV) this._loadCategoryContent(favoritesContent, favorites, 'favorites'); } } }, 50); } catch (error) { logger.error(`切换CSV文件失败: ${error.message}`); loadingIndicator.textContent = '加载失败,请重试'; } } }; // 创建搜索框容器 const searchContainer = document.createElement('div'); searchContainer.className = 'popup_search_container'; const searchInput = document.createElement('input'); searchInput.className = 'popup_search_input'; searchInput.type = 'text'; searchInput.placeholder = '搜索标签...'; // 创建清除按钮 const clearBtn = document.createElement('button'); clearBtn.className = 'popup_btn'; clearBtn.title = '清除搜索'; clearBtn.style.display = 'none'; UIToolkit.addIconToButton(clearBtn, 'pi-times', '清除搜索'); // 添加清除按钮点击事件 const clearBtnCleanup = EventManager.addDOMListener(clearBtn, 'click', (e) => { e.stopPropagation(); searchInput.value = ''; clearBtn.style.display = 'none'; searchInput.blur(); // 让搜索框失去焦点 this._handleSearch(''); }); this.eventCleanups.push(clearBtnCleanup); searchContainer.appendChild(searchInput); searchContainer.appendChild(clearBtn); // 创建操作按钮容器 const actions = document.createElement('div'); actions.className = 'popup_actions'; // 移除了管理标签按钮 // 添加刷新按钮 const refreshBtn = document.createElement('button'); refreshBtn.className = 'popup_btn'; refreshBtn.title = '刷新标签状态'; const refreshIcon = document.createElement('span'); refreshIcon.className = 'pi pi-refresh'; refreshBtn.appendChild(refreshIcon); refreshBtn.title = '刷新标签状态'; // 添加关闭按钮 const closeBtn = document.createElement('button'); closeBtn.className = 'popup_btn'; UIToolkit.addIconToButton(closeBtn, 'pi-times', '关闭'); // 添加刷新事件 const refreshCleanup = EventManager.addDOMListener(refreshBtn, 'click', async () => { try { refreshBtn.style.transform = 'rotate(360deg)'; refreshBtn.style.transition = 'transform 0.5s'; // 显示加载指示器 contentContainer.innerHTML = ''; const refreshLoadingIndicator = document.createElement('div'); refreshLoadingIndicator.className = 'loading_indicator'; refreshLoadingIndicator.textContent = '重新加载标签数据...'; refreshLoadingIndicator.style.textAlign = 'center'; refreshLoadingIndicator.style.padding = '20px'; refreshLoadingIndicator.style.color = 'var(--text-color-secondary)'; contentContainer.appendChild(refreshLoadingIndicator); // 重新加载当前CSV和收藏 if (this.currentCsvFile) { const [tagData, favorites] = await Promise.all([ ResourceManager.loadTagsCsv(this.currentCsvFile, true), // 强制重新加载 ResourceManager.getFavorites() ]); this.favorites = favorites; TagManager.tagData = tagData; // 移除加载指示器 if (refreshLoadingIndicator.parentNode) { refreshLoadingIndicator.parentNode.removeChild(refreshLoadingIndicator); } // 完全重新创建标签页结构 contentContainer.innerHTML = ''; this._createTabsWithSpecialCategories(tagData, favorites, contentContainer, nodeId, inputId); logger.debug(`[助手-标签] 刷新完成 | CSV文件:${this.currentCsvFile}`); } } catch (error) { logger.error(`标签刷新失败: ${error.message}`); // 显示错误信息 contentContainer.innerHTML = ''; const errorMsg = document.createElement('div'); errorMsg.className = 'loading_indicator'; errorMsg.textContent = '刷新失败,请重试'; errorMsg.style.textAlign = 'center'; errorMsg.style.padding = '20px'; errorMsg.style.color = 'var(--error-color)'; contentContainer.appendChild(errorMsg); } finally { setTimeout(() => { refreshBtn.style.transform = ''; refreshBtn.style.transition = ''; }, 500); } }); this.eventCleanups.push(refreshCleanup); // 添加关闭事件 const closeCleanup = EventManager.addDOMListener(closeBtn, 'click', () => { PopupManager.closeAllPopups(); }); this.eventCleanups.push(closeCleanup); // 添加搜索事件 const searchCleanup = EventManager.addDOMListener(searchInput, 'input', (e) => { const value = e.target.value; clearBtn.style.display = value.length > 0 ? 'flex' : 'none'; if (this.searchTimeout) { clearTimeout(this.searchTimeout); } this.searchTimeout = setTimeout(() => { this._handleSearch(value.toLowerCase()); }, 300); }); this.eventCleanups.push(searchCleanup); // 添加搜索框聚焦和点击事件,防止弹窗关闭 const searchFocusCleanup = EventManager.addDOMListener(searchInput, 'focus', (e) => { e.stopPropagation(); }); this.eventCleanups.push(searchFocusCleanup); const searchClickCleanup = EventManager.addDOMListener(searchInput, 'click', (e) => { e.stopPropagation(); }); this.eventCleanups.push(searchClickCleanup); // 组装标题栏 - 修改组装顺序 titleBar.appendChild(title); titleBar.appendChild(csvSelectorContainer); // 添加CSV选择器 titleBar.appendChild(searchContainer); titleBar.appendChild(actions); // 移除了旧的管理按钮 actions.appendChild(refreshBtn); actions.appendChild(closeBtn); // 创建内容容器 const contentContainer = document.createElement('div'); contentContainer.style.display = 'flex'; contentContainer.style.flexDirection = 'column'; contentContainer.style.flex = '1'; contentContainer.style.overflow = 'hidden'; contentContainer.style.minHeight = '350px'; // 设置最小高度,确保有足够空间显示加载状态 // 组装弹窗 popup.appendChild(titleBar); popup.appendChild(contentContainer); // 添加标签弹窗特有的类名,用于识别 popup.classList.add('tag_popup'); // 恢复保存的窗口大小或使用默认尺寸 const savedSize = this.getPopupSize(); if (savedSize) { popup.style.width = `${savedSize.width}px`; popup.style.height = `${savedSize.height}px`; // logger.debug(`[助手-标签] 恢复窗口大小 | 宽:${savedSize.width}px | 高:${savedSize.height}px`); } else { popup.style.width = '600px'; popup.style.height = '400px'; } // 显示弹窗 PopupManager.showPopup({ popup: popup, anchorButton: anchorButton, buttonInfo: buttonInfo, preventCloseOnElementTypes: ['tag_item', 'tag_item_text', 'tag_search_input'], // 阻止标签和搜索框关闭弹窗 enableResize: true, // 启用窗口大小调节功能 onClose: () => { try { // 关闭时记住当前激活的标签页 const activeTab = popup.querySelector('.popup_tab.active'); if (activeTab) { const category = activeTab.getAttribute('data-category'); TagManager.setLastActiveTab(category); // 保存当前标签页中所有手风琴的展开状态 const activeTabContent = popup.querySelector('.popup_tab_content.active'); if (activeTabContent) { const accordions = activeTabContent.querySelectorAll('.tag_accordion'); accordions.forEach(accordion => { const accordionCategory = accordion.getAttribute('data-category'); const header = accordion.querySelector('.tag_accordion_header'); if (accordionCategory && header) { const isExpanded = header.classList.contains('active'); TagManager.setAccordionState(category, accordionCategory, isExpanded); } }); } } } catch (e) { logger.error(`保存弹窗状态失败: ${e.message}`); } this._cleanupEvents(); if (typeof onClose === 'function') { onClose(); } } }); // 显示加载指示器 const loadingIndicator = document.createElement('div'); loadingIndicator.className = 'loading_indicator'; loadingIndicator.textContent = '加载标签数据...'; loadingIndicator.style.textAlign = 'center'; loadingIndicator.style.padding = '20px'; loadingIndicator.style.color = 'var(--text-color-secondary)'; contentContainer.appendChild(loadingIndicator); // 加载数据并创建标签页 (async () => { try { // 1. 获取CSV文件列表 const files = await ResourceManager.getTagFileList(); // 2. 获取上次选择的文件 let selectedFile = await ResourceManager.getSelectedTagFile(); // 确保选择的文件在列表中 if (!files.includes(selectedFile) && files.length > 0) { selectedFile = files[0]; } this.currentCsvFile = selectedFile; // 3. 填充下拉菜单 csvMenuList.innerHTML = ''; // 设置当前选中文件的显示文本 csvSelectorLabel.textContent = selectedFile.replace('.csv', ''); files.forEach(file => { const menuItem = document.createElement('li'); menuItem.className = 'pa-context-menu-item'; if (file === selectedFile) { menuItem.classList.add('active'); } const menuItemContent = document.createElement('div'); menuItemContent.className = 'pa-context-menu-item-content'; const label = document.createElement('span'); label.className = 'pa-context-menu-item-label'; label.textContent = file.replace('.csv', ''); menuItemContent.appendChild(label); menuItem.appendChild(menuItemContent); // 点击菜单项切换CSV文件 menuItem.addEventListener('click', async (e) => { e.stopPropagation(); // 更新选中状态 csvMenuList.querySelectorAll('.pa-context-menu-item').forEach(item => { item.classList.remove('active'); }); menuItem.classList.add('active'); // 更新显示文本 csvSelectorLabel.textContent = file.replace('.csv', ''); // 关闭菜单 closeCsvMenu(); // 切换CSV文件 await handleCsvChange(file); }); csvMenuList.appendChild(menuItem); }); // 4. 加载标签数据和收藏 const [tagData, favorites] = await Promise.all([ ResourceManager.loadTagsCsv(selectedFile), ResourceManager.getFavorites() ]); this.favorites = favorites; // 移除加载指示器 if (loadingIndicator.parentNode) { loadingIndicator.parentNode.removeChild(loadingIndicator); } // 5. 创建标签页 // 更新静态数据引用 TagManager.tagData = tagData; this._createTabsWithSpecialCategories(tagData, favorites, contentContainer, nodeId, inputId); } catch (error) { logger.error(`初始化标签弹窗失败: ${error.message}`); loadingIndicator.textContent = '加载失败,请重试'; } })(); } catch (error) { logger.error(`标签弹窗创建失败: ${error.message}`); this._cleanupAll(); } } /** * 创建特殊标签页结构(包含正常标签、自定义标签和已插入标签) */ static _createTabsWithSpecialCategories(tagData, userTagData, container, nodeId, inputId) { // 创建标签页框架 const tabsContainer = document.createElement('div'); tabsContainer.className = 'popup_tabs_container'; // 创建标签滚动区域 const tabsScroll = document.createElement('div'); tabsScroll.className = 'popup_tabs_scroll'; // 创建标签栏 const tabs = document.createElement('div'); tabs.className = 'popup_tabs'; // 创建内容区域 const tabContents = document.createElement('div'); tabContents.className = 'tag_category_container'; tabContents.style.overflow = 'hidden'; tabContents.style.display = 'flex'; tabContents.style.flexDirection = 'column'; // 创建左右滚动指示器 const leftIndicator = document.createElement('div'); leftIndicator.className = 'tabs_scroll_indicator left'; const leftIconSpan = document.createElement('span'); leftIconSpan.className = 'pi pi-angle-left scroll_indicator_icon'; leftIndicator.appendChild(leftIconSpan); leftIndicator.style.display = 'none'; const rightIndicator = document.createElement('div'); rightIndicator.className = 'tabs_scroll_indicator right'; const rightIconSpan = document.createElement('span'); rightIconSpan.className = 'pi pi-angle-right scroll_indicator_icon'; rightIndicator.appendChild(rightIconSpan); rightIndicator.style.display = 'none'; // 更新指示器状态的函数 const updateIndicators = () => { const canScroll = tabsScroll.scrollWidth > tabsScroll.clientWidth; if (!canScroll) { leftIndicator.style.display = 'none'; rightIndicator.style.display = 'none'; return; } // 使用更大的阈值(8像素)确保边界情况下能正确隐藏 const scrollLeft = tabsScroll.scrollLeft; const maxScroll = tabsScroll.scrollWidth - tabsScroll.clientWidth; leftIndicator.style.display = scrollLeft > 8 ? 'flex' : 'none'; rightIndicator.style.display = scrollLeft < (maxScroll - 8) ? 'flex' : 'none'; }; // 添加滚动指示器点击事件 - 每次滚动一个标签 const leftClickCleanup = EventManager.addDOMListener(leftIndicator, 'click', () => { // 获取所有标签 const allTabs = tabs.querySelectorAll('.popup_tab'); if (allTabs.length === 0) return; // 找到当前第一个可见的标签 const scrollRect = tabsScroll.getBoundingClientRect(); let firstVisibleTab = null; for (const tab of allTabs) { const tabRect = tab.getBoundingClientRect(); // 如果标签的右边缘在可视区域内,说明它至少部分可见 if (tabRect.right > scrollRect.left + 10) { firstVisibleTab = tab; break; } } // 找到前一个标签 if (firstVisibleTab) { const currentIndex = Array.from(allTabs).indexOf(firstVisibleTab); if (currentIndex > 0) { const prevTab = allTabs[currentIndex - 1]; prevTab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' }); // 滚动完成后更新指示器状态(使用更长的延迟确保动画完成) setTimeout(updateIndicators, 600); } } }); const rightClickCleanup = EventManager.addDOMListener(rightIndicator, 'click', () => { // 获取所有标签 const allTabs = tabs.querySelectorAll('.popup_tab'); if (allTabs.length === 0) return; // 找到当前最后一个可见的标签 const scrollRect = tabsScroll.getBoundingClientRect(); let lastVisibleTab = null; for (let i = allTabs.length - 1; i >= 0; i--) { const tab = allTabs[i]; const tabRect = tab.getBoundingClientRect(); // 如果标签的左边缘在可视区域内,说明它至少部分可见 if (tabRect.left < scrollRect.right - 10) { lastVisibleTab = tab; break; } } // 找到下一个标签 if (lastVisibleTab) { const currentIndex = Array.from(allTabs).indexOf(lastVisibleTab); if (currentIndex < allTabs.length - 1) { const nextTab = allTabs[currentIndex + 1]; nextTab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'end' }); // 滚动完成后更新指示器状态(使用更长的延迟确保动画完成) setTimeout(updateIndicators, 600); } } }); // 监听滚动事件,控制滚动指示器的显示/隐藏 const scrollCleanup = EventManager.addDOMListener(tabsScroll, 'scroll', updateIndicators); // 监听窗口大小调整事件 const resizeObserver = new ResizeObserver(() => { updateIndicators(); }); resizeObserver.observe(container); // 添加清理函数 const resizeCleanup = () => { resizeObserver.disconnect(); }; this.eventCleanups.push(leftClickCleanup, rightClickCleanup, scrollCleanup, resizeCleanup); // 添加滚动指示器 tabsContainer.appendChild(leftIndicator); tabsContainer.appendChild(tabsScroll); tabsContainer.appendChild(rightIndicator); // 将标签栏添加到滚动区域 tabsScroll.appendChild(tabs); // 将标签页容器和内容区域添加到主容器 container.appendChild(tabsContainer); container.appendChild(tabContents); // 获取所有标签页,重新排序将"⭐️"放在最前面 const normalTabs = Object.keys(tagData); const allTabs = ['⭐️', ...normalTabs, '已插入']; // 记忆上次激活的标签页 let activeTabIndex = 1; // 默认第二个标签(即tags.json的第一个类别) const lastCategory = this.getLastActiveTab(); if (lastCategory && allTabs.includes(lastCategory)) { activeTabIndex = allTabs.indexOf(lastCategory); } // 创建所有标签页但不立即加载内容 allTabs.forEach((category, index) => { // 创建标签 const tab = document.createElement('div'); tab.className = 'popup_tab'; tab.textContent = category; tab.setAttribute('data-category', category); // 第一个标签默认激活 if (index === activeTabIndex) { tab.classList.add('active'); } // 添加标签点击事件 const tabClickCleanup = EventManager.addDOMListener(tab, 'click', () => { const currentActiveTab = tabs.querySelector('.popup_tab.active'); if (currentActiveTab === tab) return; if (currentActiveTab) { currentActiveTab.classList.add('exiting'); const animationEndHandler = () => { currentActiveTab.classList.remove('active', 'exiting'); currentActiveTab.removeEventListener('transitionend', animationEndHandler); }; currentActiveTab.addEventListener('transitionend', animationEndHandler); } tabContents.querySelectorAll('.popup_tab_content').forEach(c => { c.classList.remove('active'); c.style.display = 'none'; }); tab.classList.add('active'); const contentId = tab.getAttribute('data-category'); const content = tabContents.querySelector(`.popup_tab_content[data-category="${contentId}"]`); if (content) { content.classList.add('active'); content.style.display = 'block'; // 记忆当前选择的标签页 TagManager.setLastActiveTab(contentId); // 对于特殊标签页,每次点击都重新加载 if (contentId === '⭐️') { this._loadCategoryContent(content, userTagData, 'favorites'); } else if (contentId === '已插入') { this._loadInsertedTagsContent(content); } // 对于普通标签页,仅在首次加载 else if (content.getAttribute('data-loaded') !== 'true') { this._loadCategoryContent(content, tagData[contentId], contentId); } } // 确保选中的标签完全可见 const tabRect = tab.getBoundingClientRect(); const scrollRect = tabsScroll.getBoundingClientRect(); const isFullyVisible = tabRect.left >= scrollRect.left && tabRect.right <= scrollRect.right; if (!isFullyVisible) { if (tabRect.left < scrollRect.left) { tab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' }); } else if (tabRect.right > scrollRect.right) { tab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'end' }); } } }); this.eventCleanups.push(tabClickCleanup); // 添加右键菜单事件(特殊分类除外) const specialCategories = ['⭐️', 'favorites', '已插入']; if (!specialCategories.includes(category)) { const tabContextMenuCleanup = EventManager.addDOMListener(tab, 'contextmenu', (e) => { e.preventDefault(); e.stopPropagation(); this._showCategoryContextMenu(e, category, true, null); }); this.eventCleanups.push(tabContextMenuCleanup); } tabs.appendChild(tab); // 创建内容区域 const content = document.createElement('div'); content.className = 'popup_tab_content'; content.setAttribute('data-category', category); content.setAttribute('data-loaded', 'false'); // 标记为未加载 content.style.flex = '1'; content.style.display = 'none'; // 第一个内容默认显示 if (index === activeTabIndex) { content.classList.add('active'); content.style.display = 'block'; } tabContents.appendChild(content); }); // ---在 Tab 栏末尾添加"新建分类"按钮--- const addTabButton = document.createElement('div'); addTabButton.className = 'popup_tab add_category_tab'; addTabButton.title = '新建分类'; const addTabIcon = document.createElement('span'); addTabIcon.className = 'pi pi-plus'; addTabButton.appendChild(addTabIcon); const addTabBtnClickCleanup = EventManager.addDOMListener(addTabButton, 'click', (e) => { e.stopPropagation(); this._handleAddCategory(addTabButton, null, tabs, tabContents, tagData); }); this.eventCleanups.push(addTabBtnClickCleanup); tabs.appendChild(addTabButton); // ---初始化 Tab 栏拖拽排序--- if (this.Sortable) { const tabsSortable = new this.Sortable(tabs, { animation: 150, ghostClass: 'tag-ghost', draggable: '.popup_tab:not(.add_category_tab)', // 排除添加按钮 delay: 50, onEnd: async (evt) => { const { oldIndex, newIndex } = evt; if (oldIndex === newIndex) return; // 获取当前所有 Tab 的新顺序 const newOrder = Array.from(tabs.querySelectorAll('.popup_tab:not(.add_category_tab)')) .map(tab => tab.getAttribute('data-category')); // 重新排列 tagData const newTagData = {}; // 按照新顺序重建数据对象 newOrder.forEach(category => { // 特殊分类 (⭐️, 已插入) 不在 tagData 中,跳过 if (category !== '⭐️' && category !== '已插入' && tagData[category]) { newTagData[category] = tagData[category]; } }); // 确保没有遗漏的数据(以防万一) Object.keys(tagData).forEach(category => { if (!newTagData[category]) { newTagData[category] = tagData[category]; } }); // 更新内存中的数据 TagManager.tagData = newTagData; // 持久化到 CSV const filename = this.currentCsvFile || await ResourceManager.getSelectedTagFile(); if (filename) { const success = await ResourceManager.saveTagsCsv(filename, newTagData); if (success) { app.extensionManager.toast.add({ severity: "success", summary: "排序已保存", detail: "分类显示顺序已更新", life: 2000 }); } } } }); this.sortables.push(tabsSortable); } // 初始化滚动指示器,并自动定位到激活的标签页 setTimeout(() => { const canScroll = tabsScroll.scrollWidth > tabsScroll.clientWidth; if (canScroll) { // 找到激活的标签页 const activeTab = tabs.querySelector('.popup_tab.active'); if (activeTab) { // 将激活的标签页滚动到可见区域 activeTab.scrollIntoView({ behavior: 'auto', block: 'nearest', inline: 'center' }); } // 等待滚动完成后,更新滚动指示器显示状态 setTimeout(updateIndicators, 50); } }, 50); // 加载第一个激活的标签页内容 const firstContent = tabContents.querySelector('.popup_tab_content.active'); if (firstContent) { const firstCategory = firstContent.getAttribute('data-category'); // 初始激活的标签页也进行记忆 TagManager.setLastActiveTab(firstCategory); if (firstCategory === '⭐️') { this._loadCategoryContent(firstContent, userTagData, 'favorites'); } else if (firstCategory === '已插入') { this._loadInsertedTagsContent(firstContent); } else { this._loadCategoryContent(firstContent, tagData[firstCategory], firstCategory); } } // 更新标签状态 setTimeout(() => { this.compareInputWithCache(nodeId, inputId, true); }, 100); } /** * 处理搜索 */ static _handleSearch(searchText) { const tagItems = document.querySelectorAll('.tag_item'); const accordions = document.querySelectorAll('.tag_accordion'); const tabs = document.querySelectorAll('.popup_tab'); const tabContents = document.querySelectorAll('.popup_tab_content'); const popup = document.querySelector('.popup_container'); const tabsContainer = document.querySelector('.popup_tabs_container'); const tagCategoryContainers = document.querySelectorAll('.tag_category_container'); // 搜索结果容器class const SEARCH_RESULT_CLASS = 'tag_search_result_list'; let searchResultList = document.querySelector('.' + SEARCH_RESULT_CLASS); if (!searchText) { // 恢复原有内容 if (searchResultList) searchResultList.remove(); // 恢复标签页容器 if (tabsContainer) { tabsContainer.style.display = ''; tabsContainer.style.visibility = ''; } // 恢复所有相关容器 tagCategoryContainers.forEach(container => { container.style.display = ''; container.style.visibility = ''; }); // 恢复标签项 tagItems.forEach(item => { item.style.display = ''; item.style.pointerEvents = ''; }); // 恢复手风琴 accordions.forEach(accordion => { accordion.style.display = ''; accordion.style.pointerEvents = ''; }); // 恢复标签页 tabs.forEach(tab => { tab.style.display = ''; tab.style.pointerEvents = ''; }); // 恢复标签页内容 tabContents.forEach(content => { if (content.classList.contains('active')) { content.style.display = 'block'; } else { content.style.display = 'none'; } content.style.pointerEvents = ''; }); // 重置滚动条 const tabsScroll = document.querySelector('.popup_tabs_scroll'); if (tabsScroll) { const activeTab = document.querySelector('.popup_tab.active'); if (activeTab) { activeTab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); } else { tabsScroll.scrollLeft = 0; } setTimeout(() => { tabsScroll.dispatchEvent(new Event('scroll')); }, 100); } // 重新更新标签状态,确保点击功能正常 setTimeout(() => { this.compareInputWithCache(this.currentNodeId, this.currentInputId, true); }, 50); return; } // 有搜索内容时,彻底隐藏原有内容和tabs栏 if (tabsContainer) { tabsContainer.style.display = 'none'; tabsContainer.style.visibility = 'hidden'; } // 隐藏所有分类容器 tagCategoryContainers.forEach(container => { container.style.display = 'none'; container.style.visibility = 'hidden'; }); // 隐藏标签页内容 tabContents.forEach(content => { content.style.display = 'none'; content.style.pointerEvents = 'none'; }); // 隐藏标签页 tabs.forEach(tab => { tab.style.display = 'none'; tab.style.pointerEvents = 'none'; }); // 隐藏手风琴 accordions.forEach(accordion => { accordion.style.display = 'none'; accordion.style.pointerEvents = 'none'; }); // 移除旧的搜索结果容器 if (searchResultList) searchResultList.remove(); // 创建新的搜索结果容器 searchResultList = document.createElement('div'); searchResultList.className = SEARCH_RESULT_CLASS; // 从原始数据中搜索标签,而不是从DOM中搜索 this._searchFromData(searchText, searchResultList).then(() => { // 搜索完成后刷新标签状态 setTimeout(() => { this.refreshSearchResultsState(); }, 10); }).catch(error => { logger.error(`搜索处理失败: ${error.message}`); }); // 插入到内容区(搜索框下方) // 先尝试找到弹窗的主内容容器(标题栏的下一个兄弟元素) const titleBar = popup.querySelector('.popup_title_bar'); let contentContainer = null; if (titleBar && titleBar.nextElementSibling) { contentContainer = titleBar.nextElementSibling; } else { // 备用方案:查找包含标签页的容器 contentContainer = popup.querySelector('.tag_category_container')?.parentElement || popup.querySelector('[class*="tab_content"]')?.parentElement; } if (contentContainer) { contentContainer.appendChild(searchResultList); } else { logger.error('无法找到合适的容器来插入搜索结果'); } } /** * 从原始数据中搜索标签 * @param {string} searchText 搜索文本 * @param {HTMLElement} searchResultList 搜索结果容器 */ static async _searchFromData(searchText, searchResultList) { try { // 获取原始标签数据 const [tagData, userTagData] = await Promise.all([ ResourceManager.getTagData(), ResourceManager.getUserTagData() ]); let matchCount = 0; // 搜索系统标签 matchCount += this._searchInDataObject(tagData, searchText, searchResultList, []); // 搜索用户自定义标签(⭐️标签页) matchCount += this._searchInDataObject(userTagData, searchText, searchResultList, ['⭐️']); // 搜索已插入标签 matchCount += this._searchInsertedTags(searchText, searchResultList); // 无结果提示 if (matchCount === 0) { const empty = document.createElement('div'); empty.textContent = '无匹配标签'; empty.className = 'search_empty_message'; searchResultList.appendChild(empty); } } catch (error) { logger.error(`搜索标签数据失败: ${error.message}`); const errorMessage = document.createElement('div'); errorMessage.textContent = '搜索失败,请重试'; errorMessage.className = 'search_empty_message'; searchResultList.appendChild(errorMessage); } } /** * 在数据对象中递归搜索标签 * @param {Object} dataObj 数据对象 * @param {string} searchText 搜索文本 * @param {HTMLElement} container 结果容器 * @param {Array} categoryPath 分类路径 * @returns {number} 匹配的标签数量 */ static _searchInDataObject(dataObj, searchText, container, categoryPath = [], onMatch) { let localMatchCount = 0; for (const [key, value] of Object.entries(dataObj)) { if (typeof value === 'string') { // 这是一个标签 const tagName = key; const tagValue = value; const matches = tagName.toLowerCase().includes(searchText) || tagValue.toLowerCase().includes(searchText); if (matches) { // 创建标签元素 const tagElement = this._createSearchResultTag(tagName, tagValue, [...categoryPath]); container.appendChild(tagElement); localMatchCount++; } } else if (typeof value === 'object' && value !== null) { // 这是一个分类,递归搜索 const childMatchCount = this._searchInDataObject(value, searchText, container, [...categoryPath, key]); localMatchCount += childMatchCount; } } if (onMatch) { onMatch(localMatchCount); } return localMatchCount; } /** * 搜索已插入标签 * @param {string} searchText 搜索文本 * @param {HTMLElement} container 结果容器 * @returns {number} 匹配的标签数量 */ static _searchInsertedTags(searchText, container) { let matchCount = 0; const cache = TagCacheService.getTagCache(this.currentNodeId, this.currentInputId); if (cache && cache.size > 0) { cache.forEach((formatInfo, tagValue) => { // 尝试从已加载的DOM中找到对应的标签名 let tagName = tagValue; // 默认使用值作为名称 // 查找已加载的标签元素来获取显示名称 const existingTag = document.querySelector(`.tag_item[data-value="${tagValue}"]`); if (existingTag) { tagName = existingTag.getAttribute('data-name') || tagValue; } const matches = tagName.toLowerCase().includes(searchText) || tagValue.toLowerCase().includes(searchText); if (matches) { const tagElement = this._createSearchResultTag(tagName, tagValue, ['已插入']); container.appendChild(tagElement); matchCount++; } }); } return matchCount; } /** * 创建搜索结果标签元素 * @param {string} tagName 标签名称 * @param {string} tagValue 标签值 * @param {Array} categoryPath 分类路径 * @returns {HTMLElement} 标签元素 */ static _createSearchResultTag(tagName, tagValue, categoryPath) { const tagItem = document.createElement('div'); tagItem.className = 'tag_item search_result_tag_item'; tagItem.setAttribute('data-name', tagName); tagItem.setAttribute('data-value', tagValue); // 确保搜索结果也包含当前的分类上下文(当前文件) if (this.currentCsvFile) { tagItem.setAttribute('data-category', this.currentCsvFile); } const tagText = document.createElement('span'); tagText.className = 'tag_item_text'; tagText.textContent = tagName; tagItem.appendChild(tagText); // 检查标签是否已使用 if (this.isTagUsed(tagValue, this.currentNodeId, this.currentInputId)) { tagItem.classList.add('used'); } // 添加鼠标事件监听 const mouseEnterCleanup = EventManager.addDOMListener(tagItem, 'mouseenter', () => { const tooltipContent = `${tagValue}\n类别: ${categoryPath.join(' > ')}`; this._showTooltip(tagItem, tooltipContent); }); const mouseLeaveCleanup = EventManager.addDOMListener(tagItem, 'mouseleave', () => { this._hideTooltip(); }); // 添加点击事件 const tagClickCleanup = EventManager.addDOMListener(tagItem, 'click', (e) => { this.handleTagClick(tagItem, tagName, tagValue, e); }); this.eventCleanups.push(mouseEnterCleanup, mouseLeaveCleanup, tagClickCleanup); return tagItem; } /** * 隐藏标签弹窗 */ static hideTagPopup() { // 清理事件监听 this._cleanupEvents(); // 使用PopupManager关闭所有弹窗 PopupManager.closeAllPopups(); } /** * 清理所有相关资源 */ static _cleanupAll() { // 清理事件监听 this._cleanupEvents(); // 使用PopupManager关闭所有弹窗 PopupManager.closeAllPopups(); } /** * 清理所有事件监听 */ static _cleanupEvents() { // 执行并清空所有事件清理函数 if (this.eventCleanups.length > 0) { this.eventCleanups.forEach(cleanup => { if (typeof cleanup === 'function') { cleanup(); } }); this.eventCleanups = []; } } /** * 更新所有标签的状态 */ static updateAllTagsState(nodeId, inputId) { const mapping = UIToolkit._findMapping(nodeId, inputId, (nodeId === this.currentNodeId && inputId === this.currentInputId) ? this.currentWidgetKey : null); if (!mapping || !mapping.inputEl) return; const inputValue = mapping.inputEl.value; const startTime = performance.now(); // 清空已使用标签映射 this.usedTags.clear(); // 获取所有标签元素,包括所有标签页中的标签 const tagItems = document.querySelectorAll('.tag_item'); let checkedCount = 0; let matchedCount = 0; // 检查每个标签是否在输入框中 tagItems.forEach(tagItem => { const tagValue = tagItem.getAttribute('data-value'); if (!tagValue) return; checkedCount++; // 检查是否在输入框中 const isUsed = TagCacheService.isTagInInput(nodeId, inputId, tagValue, inputValue); if (isUsed) { matchedCount++; } // 更新状态 this.updateTagState(tagItem, isUsed); // 如果已使用,添加到映射 if (isUsed) { this.usedTags.set(tagValue, tagItem); } }); const endTime = performance.now(); // logger.debug(`标签状态 | 动作:更新所有 | 节点:${nodeId} | 输入框:${inputId} | 检查数量:${checkedCount} | 匹配数量:${matchedCount} | 耗时:${(endTime - startTime).toFixed(2)}ms`); } /** * 添加辅助方法:统计标签总数 */ static _countTags(tagData) { let count = 0; const countTagsInObject = (obj) => { for (const key in obj) { if (typeof obj[key] === 'string') { count++; } else if (typeof obj[key] === 'object' && obj[key] !== null) { countTagsInObject(obj[key]); } } }; countTagsInObject(tagData); return count; } /** * 刷新搜索结果中的标签状态 * 注意:不在此处绑定点击事件,避免与创建时的事件重复导致触发两次(插入后又被移除)。 */ static refreshSearchResultsState() { // 获取搜索结果中的所有标签 const searchResultTags = document.querySelectorAll('.search_result_tag_item'); // 更新每个搜索结果标签的状态 searchResultTags.forEach(tagItem => { const tagValue = tagItem.getAttribute('data-value'); if (!tagValue) return; // 检查是否已使用 const isUsed = this.isTagUsed(tagValue, this.currentNodeId, this.currentInputId); // 更新状态 this.updateTagState(tagItem, isUsed); }); } /** * 创建收藏夹内容 */ /** * 加载已插入标签内容 */ static _loadInsertedTagsContent(container) { container.innerHTML = ''; // 清空内容 container.setAttribute('data-loaded', 'true'); // 创建内容包装器 const contentWrapper = document.createElement('div'); contentWrapper.className = 'tag_content_wrapper visible'; // 创建标签容器 const tagsContainer = document.createElement('div'); tagsContainer.className = 'inserted_tags_container'; tagsContainer.style.padding = '10px'; tagsContainer.style.display = 'flex'; tagsContainer.style.flexWrap = 'wrap'; tagsContainer.style.gap = '8px'; // 获取已使用的标签 const usedTags = new Map(); // 从输入框中获取标签 const mapping = UIToolkit._findMapping(this.currentNodeId, this.currentInputId, this.currentWidgetKey); if (mapping && mapping.inputEl) { // 从标签缓存中获取所有标签 const tagCache = TagCacheService.getTagCache(this.currentNodeId, this.currentInputId); if (tagCache && tagCache.size > 0) { // 获取输入框内容 const inputValue = mapping.inputEl.value; // 遍历缓存,检查哪些标签在输入框中 tagCache.forEach((formats, rawTag) => { // 检查标签是否在输入框中 if (TagCacheService.isTagInInput(this.currentNodeId, this.currentInputId, rawTag, inputValue)) { // 标签在输入框中,添加到已使用标签列表 usedTags.set(rawTag, formats); } }); } } if (usedTags.size === 0) { // 如果没有已插入标签 const emptyMessage = document.createElement('div'); emptyMessage.className = 'empty_tags_message'; emptyMessage.textContent = '没有已插入标签'; emptyMessage.style.textAlign = 'center'; emptyMessage.style.padding = '20px'; emptyMessage.style.color = 'var(--text-color-secondary)'; contentWrapper.appendChild(emptyMessage); } else { // 加载标签数据,用于查找中文名称 Promise.all([ ResourceManager.getTagData(), ResourceManager.getUserTagData() ]).then(([tagData, userTagData]) => { // 创建标签名称映射表 (英文值 -> 中文名) const tagNameMap = new Map(); // 递归函数,用于从标签数据中提取标签名称映射 const extractTagNames = (data) => { for (const key in data) { const value = data[key]; if (typeof value === 'string') { // 找到一个标签,添加到映射表 (值 -> 名称) tagNameMap.set(value, key); } else if (typeof value === 'object' && value !== null) { // 递归处理子分类 extractTagNames(value); } } }; // 从标准标签和用户标签中提取名称映射 extractTagNames(tagData); extractTagNames(userTagData); // 如果有已插入标签,创建标签元素 usedTags.forEach((formats, rawTag) => { const tagItem = document.createElement('div'); tagItem.className = 'tag_item used'; tagItem.setAttribute('data-value', rawTag); // 查找标签的中文名称,如果找不到则使用原始值 const tagName = tagNameMap.get(rawTag) || rawTag; tagItem.setAttribute('data-name', tagName); const tagText = document.createElement('span'); tagText.className = 'tag_item_text'; tagText.textContent = tagName; // 使用中文名称 tagItem.appendChild(tagText); // 添加鼠标事件监听 const mouseEnterCleanup = EventManager.addDOMListener(tagItem, 'mouseenter', () => { this._showTooltip(tagItem, rawTag); // 显示原始值作为提示 }); const mouseLeaveCleanup = EventManager.addDOMListener(tagItem, 'mouseleave', () => { this._hideTooltip(); }); // 添加点击事件 - 移除标签 const tagClickCleanup = EventManager.addDOMListener(tagItem, 'click', (e) => { // 先隐藏tooltip this._hideTooltip(); this.handleTagClick(tagItem, tagName, rawTag, e); // 移除后,重新加载已插入标签 setTimeout(() => { // 重新加载已插入标签页 this._loadInsertedTagsContent(container); // 更新所有标签页中的标签状态 this.updateAllTagsState(this.currentNodeId, this.currentInputId); // 如果当前在搜索状态,也要更新搜索结果中的标签状态 const searchResultList = document.querySelector('.tag_search_result_list'); if (searchResultList) { this.refreshSearchResultsState(); } }, 100); }); this.eventCleanups.push(mouseEnterCleanup, mouseLeaveCleanup, tagClickCleanup); tagsContainer.appendChild(tagItem); }); contentWrapper.appendChild(tagsContainer); container.appendChild(contentWrapper); }).catch(error => { // 处理错误情况 logger.error(`加载已插入标签失败: ${error.message}`); const errorMessage = document.createElement('div'); errorMessage.className = 'error_message'; errorMessage.textContent = '加载已插入标签失败'; errorMessage.style.textAlign = 'center'; errorMessage.style.padding = '20px'; errorMessage.style.color = '#ff6b6b'; contentWrapper.appendChild(errorMessage); container.appendChild(contentWrapper); }); } if (usedTags.size === 0) { container.appendChild(contentWrapper); } } } export { TagManager };