Files

5188 lines
219 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 标签管理器
* 负责管理标签的显示和操作
*/
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<span class="tooltip_path">类别: ${categoryPath.join(' > ')}</span>`;
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 };