Files
yawiii-ComfyUI-Prompt-Assis…/js/modules/uiComponents.js
T
2025-12-21 22:52:38 +08:00

2473 lines
87 KiB
JavaScript
Raw 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.
/**
* 通用UI组件库
* 提供项目中可复用的UI组件
*/
import { app } from "../../../../scripts/app.js";
import { logger } from '../utils/logger.js';
/**
* 创建通用的设置弹窗
* @param {Object} options 弹窗配置选项
* @param {string} options.title 弹窗标题
* @param {Function} options.renderContent 渲染弹窗内容的函数
* @param {Function} options.renderNotice 渲染通知区域的函数(可选,显示在标题和内容之间)
* @param {Function} options.onSave 保存按钮点击回调
* @param {Function} options.onCancel 取消按钮点击回调(可选)
* @param {boolean} options.isConfirmDialog 是否是确认对话框(可选)
* @param {string} options.saveButtonText 保存按钮文本(可选)
* @param {string} options.cancelButtonText 取消按钮文本(可选)
* @param {string} options.saveButtonIcon 保存按钮图标(可选)
* @param {boolean} options.isDangerButton 保存按钮是否为危险按钮样式(可选,红色背景)
* @param {boolean} options.disableBackdropAndCloseOnClickOutside 禁用遮罩层和点击外部关闭(可选)
*/
export function createSettingsDialog(options) {
try {
const {
title,
renderContent,
renderNotice = null,
onSave,
onCancel = null,
onClose = null, // 关闭回调(无论保存还是取消都会调用)
isConfirmDialog = false,
saveButtonText = '保存',
cancelButtonText = '取消',
saveButtonIcon = 'pi-check',
isDangerButton = false, // 是否为危险按钮
dialogClassName = null,
disableBackdropAndCloseOnClickOutside = false,
hideFooter = false, // 是否隐藏底部按钮
} = options;
let overlay = null;
if (!disableBackdropAndCloseOnClickOutside) {
// 创建遮罩层
overlay = document.createElement('div');
overlay.className = 'settings-modal-overlay';
document.body.appendChild(overlay);
}
// 创建弹窗
const modal = document.createElement('div');
modal.className = 'settings-modal';
// 如果提供了额外的对话框类名,添加到modal
if (dialogClassName) {
modal.classList.add(dialogClassName);
}
// 如果是确认对话框,设置特殊样式
if (isConfirmDialog) {
// 只有在没有提供自定义类名的情况下才设置默认宽度
if (!dialogClassName) {
modal.style.width = 'min(90vw, 400px)';
}
modal.style.minHeight = 'auto';
// z-index 由 CSS 的 .settings-modal 和 .settings-modal-overlay 统一管理,不需要在 JS 中设置
}
// 表单修改状态
let isFormModified = false;
// 内部关闭函数:关闭弹窗并调用 onClose 回调
const closeDialog = () => {
closeModalWithAnimation(modal, overlay);
// 在动画完成后调用 onClose 回调
if (onClose) {
setTimeout(() => {
onClose();
}, 300); // 与动画时间匹配
}
};
// 处理关闭弹窗的逻辑
const handleCloseModal = async (saveAction) => {
// 如果是保存操作,直接保存并关闭,不弹出确认对话框
if (saveAction) {
try {
await onSave(content);
closeDialog();
} catch (error) {
app.extensionManager.toast.add({
severity: "error",
summary: "保存失败",
detail: error.message,
life: 3000
});
}
return;
}
// 只有在表单被修改、不是确认对话框、且未提供 onCancel 时才显示确认对话框
// 如果提供了 onCancel,表示调用者选择跳过二次确认
if (isFormModified && !isConfirmDialog && !onCancel) {
// 创建确认对话框
createSettingsDialog({
title: '确认操作',
isConfirmDialog: true,
saveButtonText: '返回',
saveButtonIcon: 'pi-undo',
cancelButtonText: '关闭',
renderContent: (content) => {
content.style.textAlign = 'center';
content.style.padding = '1rem';
const confirmMessage = document.createElement('p');
confirmMessage.textContent = '配置已修改,是否保存?';
confirmMessage.style.margin = '0';
confirmMessage.style.fontSize = '1rem';
content.appendChild(confirmMessage);
},
onSave: () => {
// 返回按钮只关闭确认对话框,不执行保存操作
// 这里不需要做任何操作,因为默认的对话框关闭逻辑会在点击按钮后执行
},
onCancel: () => {
// 关闭主对话框
closeDialog();
}
});
} else {
// 没有修改或是确认对话框或有 onCancel,直接关闭
if (onCancel && !isConfirmDialog) {
onCancel();
}
closeDialog();
}
};
if (!disableBackdropAndCloseOnClickOutside) {
// 点击遮罩层关闭弹窗
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
handleCloseModal(false);
}
});
}
// 创建弹窗头部
const header = document.createElement('div');
header.className = 'p-dialog-header';
const titleSpan = document.createElement('span');
titleSpan.className = 'p-dialog-title';
titleSpan.innerHTML = title;
const closeButton = document.createElement('button');
closeButton.className = 'p-dialog-header-icon p-dialog-header-close p-link';
closeButton.setAttribute('aria-label', '关闭');
closeButton.innerHTML = '<span class="pi pi-times"></span>';
closeButton.onclick = () => {
handleCloseModal(false);
};
const headerIcons = document.createElement('div');
headerIcons.className = 'p-dialog-header-icons';
headerIcons.appendChild(closeButton);
header.appendChild(titleSpan);
header.appendChild(headerIcons);
// 创建通知区域(在标题和内容之间)
let noticeArea = null;
if (renderNotice) {
noticeArea = document.createElement('div');
noticeArea.className = 'p-dialog-notice';
renderNotice(noticeArea);
}
// 创建弹窗内容
const content = document.createElement('div');
content.className = 'p-dialog-content';
// 监听表单变化
const trackFormChanges = (formElement) => {
// 为所有输入元素添加变更监听
const inputs = formElement.querySelectorAll('input, textarea, select');
inputs.forEach(input => {
const originalValue = input.type === 'checkbox' ? input.checked : input.value;
input.addEventListener('change', () => {
const currentValue = input.type === 'checkbox' ? input.checked : input.value;
// 只有当值真正改变时才标记为已修改
if (currentValue !== originalValue) {
isFormModified = true;
}
});
if (input.tagName.toLowerCase() === 'textarea' || input.type === 'text') {
input.addEventListener('input', () => {
const currentValue = input.value;
// 只有当值真正改变时才标记为已修改
if (currentValue !== originalValue) {
isFormModified = true;
}
});
}
});
// 监听自定义下拉框变化
const dropdowns = formElement.querySelectorAll('.p-dropdown');
dropdowns.forEach(dropdown => {
// 存储原始选中值
const hiddenSelect = dropdown.querySelector('select');
const originalValue = hiddenSelect ? hiddenSelect.value : '';
const observer = new MutationObserver(() => {
const currentValue = hiddenSelect ? hiddenSelect.value : '';
// 只有当值真正改变时才标记为已修改
if (currentValue !== originalValue) {
isFormModified = true;
}
});
observer.observe(dropdown, { attributes: true, childList: true, subtree: true });
});
};
// 渲染内容并跟踪变化
renderContent(content, header);
// 如果不是确认对话框且内容中有表单,添加变更跟踪
if (!isConfirmDialog) {
const forms = content.querySelectorAll('form');
forms.forEach(trackFormChanges);
// 如果没有找到表单,则监视整个内容区域
if (forms.length === 0) {
trackFormChanges(content);
}
}
// 创建弹窗底部
const footer = document.createElement('div');
footer.className = 'p-dialog-footer';
const cancelButton = document.createElement('button');
cancelButton.className = 'p-button p-component p-button-secondary';
cancelButton.innerHTML = `<span class="p-button-icon-left pi pi-times"></span><span class="p-button-label">${cancelButtonText}</span>`;
// 为不同类型的对话框设置不同的关闭行为
if (isConfirmDialog) {
cancelButton.onclick = () => {
// 对于确认对话框,"关闭"按钮应执行onCancel回调(该回调负责关闭主窗口),然后关闭自己
if (onCancel) {
onCancel();
}
closeModalWithAnimation(modal, overlay);
};
} else {
cancelButton.onclick = () => {
// 对于普通对话框,使用标准的关闭处理逻辑
handleCloseModal(false);
};
}
const saveButton = document.createElement('button');
// 根据isDangerButton参数设置按钮样式
saveButton.className = isDangerButton
? 'p-button p-component p-button-danger'
: 'p-button p-component';
saveButton.innerHTML = `<span class="p-button-icon-left pi ${saveButtonIcon}"></span><span class="p-button-label">${saveButtonText}</span>`;
saveButton.onclick = () => {
// 如果是确认对话框中的返回按钮,直接关闭确认对话框
if (isConfirmDialog) {
try {
// 对于确认对话框,先执行onSave回调,然后关闭弹窗
const result = onSave && onSave(content);
// 如果onSave返回Promise,等待其完成
if (result instanceof Promise) {
result.then(() => {
closeModalWithAnimation(modal, overlay);
}).catch(error => {
logger.error(`确认对话框处理失败: ${error.message}`);
closeModalWithAnimation(modal, overlay);
});
} else {
// 普通返回值,直接关闭
closeModalWithAnimation(modal, overlay);
}
} catch (error) {
logger.error(`确认对话框处理失败: ${error.message}`);
closeModalWithAnimation(modal, overlay);
}
} else {
handleCloseModal(true);
}
};
footer.appendChild(cancelButton);
footer.appendChild(saveButton);
// 加入拖动功能
let isDragging = false;
let startX = 0;
let startY = 0;
let offsetX = 0;
let offsetY = 0;
const startDragging = (e) => {
if (e.target === closeButton || closeButton.contains(e.target)) return;
e.preventDefault();
isDragging = true;
// 获取鼠标相对于弹窗的偏移量
const rect = modal.getBoundingClientRect();
offsetX = e.clientX - rect.left;
offsetY = e.clientY - rect.top;
// 移除居中定位
modal.style.transform = 'none';
modal.style.left = rect.left + 'px';
modal.style.top = rect.top + 'px';
// 添加拖动状态
modal.classList.add('dragging');
document.addEventListener('mousemove', onDrag);
document.addEventListener('mouseup', stopDragging);
};
const onDrag = (e) => {
if (!isDragging) return;
e.preventDefault();
// 计算新位置(考虑鼠标在弹窗内的偏移量)
let newLeft = e.clientX - offsetX;
let newTop = e.clientY - offsetY;
// 获取视口和弹窗尺寸
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const modalRect = modal.getBoundingClientRect();
const modalWidth = modalRect.width;
const modalHeight = modalRect.height;
// 边界检查(保持10px边距)
const margin = 10;
newLeft = Math.max(margin, Math.min(newLeft, viewportWidth - modalWidth - margin));
newTop = Math.max(margin, Math.min(newTop, viewportHeight - modalHeight - margin));
// 更新位置
modal.style.left = `${newLeft}px`;
modal.style.top = `${newTop}px`;
};
const stopDragging = () => {
isDragging = false;
modal.classList.remove('dragging');
document.removeEventListener('mousemove', onDrag);
document.removeEventListener('mouseup', stopDragging);
};
header.addEventListener('mousedown', startDragging);
// 组装弹窗
modal.appendChild(header);
if (noticeArea) {
modal.appendChild(noticeArea);
}
modal.appendChild(content);
// 只有在 hideFooter 为 false 时才添加底部按钮
if (!hideFooter) {
modal.appendChild(footer);
}
// 显示弹窗和遮罩层
document.body.appendChild(modal);
// 添加显示动画
requestAnimationFrame(() => {
modal.classList.add('modal-show');
if (overlay) {
overlay.classList.add('overlay-show');
}
});
return modal;
} catch (error) {
logger.error(`创建设置弹窗失败: ${error.message}`);
app.extensionManager.toast.add({
severity: "error",
summary: "创建弹窗失败",
detail: error.message || "创建设置弹窗过程中发生错误",
life: 3000
});
}
}
/**
* 关闭弹窗时添加动画效果
* @param {HTMLElement} modal 弹窗元素
* @param {HTMLElement} overlay 遮罩层元素
*/
export function closeModalWithAnimation(modal, overlay) {
// 添加关闭动画类
modal.classList.remove('modal-show');
modal.classList.add('modal-hide');
if (overlay) {
overlay.classList.remove('overlay-show');
overlay.classList.add('overlay-hide');
}
// 等待动画完成后移除弹窗和遮罩层
setTimeout(() => {
if (modal.parentNode) {
modal.parentNode.removeChild(modal);
}
if (overlay && overlay.parentNode) {
overlay.parentNode.removeChild(overlay);
}
}, 300); // 与 CSS transition 时间相匹配
}
/**
* 创建表单组
* @param {string} title 表单组标题
* @param {Array<{text: string, url: string}>} links 标题右侧的链接数组
* @param {Object} [options] 额外可选项
* @param {string} [options.prefixText] 链接前的前缀纯文本(不作为链接,不包在标签内)
* @returns {HTMLElement} 表单组容器
*/
export function createFormGroup(title, links = [], options = {}) {
const { prefixText = null } = options;
const group = document.createElement('div');
group.className = 'settings-form-section';
const titleContainer = document.createElement('div');
titleContainer.className = 'settings-form-section-header';
titleContainer.style.display = 'flex';
titleContainer.style.alignItems = 'center';
titleContainer.style.justifyContent = 'space-between';
titleContainer.style.marginBottom = '10px';
const titleElem = document.createElement('h3');
titleElem.className = 'settings-form-section-title';
titleElem.textContent = title;
titleElem.style.margin = '0';
const linksContainer = document.createElement('div');
linksContainer.style.display = 'flex';
linksContainer.style.gap = '8px';
linksContainer.style.alignItems = 'center';
if (links.length > 0) {
const serviceLinksContainer = document.createElement('div');
serviceLinksContainer.className = 'settings-service-links';
// 可选的前缀文本,保持与链接字号一致(不包在<a>标签内)
if (prefixText) {
const prefix = document.createElement('span');
prefix.className = 'settings-service-prefix';
prefix.textContent = prefixText;
serviceLinksContainer.appendChild(prefix);
}
links.forEach((linkInfo, index) => {
if (index > 0) {
// 添加分隔符
const separator = document.createElement('span');
separator.textContent = '|';
separator.className = 'settings-service-separator';
serviceLinksContainer.appendChild(separator);
}
const link = document.createElement('a');
link.href = linkInfo.url;
link.target = '_blank';
link.textContent = linkInfo.text;
link.className = 'settings-service-link';
serviceLinksContainer.appendChild(link);
});
linksContainer.appendChild(serviceLinksContainer);
}
titleContainer.appendChild(titleElem);
titleContainer.appendChild(linksContainer);
group.appendChild(titleContainer);
return group;
}
/**
* 创建浮动标签输入框组 (PrimeVue FloatLabel variant="on" 风格)
* @param {string} label 标签文本
* @param {string} placeholder 占位符文本
* @param {string} type 输入框类型
* @returns {Object} 包含 group 和 input 的对象
*/
export function createInputGroup(label, placeholder, type = 'text') {
const group = document.createElement('div');
group.className = 'settings-form-group';
// 创建浮动标签容器
const floatContainer = document.createElement('div');
floatContainer.className = 'float-label-container';
// 创建输入框
const input = document.createElement('input');
input.className = 'p-inputtext p-component';
input.type = type;
// 使用空格作为 placeholder 以触发 :not(:placeholder-shown) 选择器
input.placeholder = ' ';
// 创建浮动标签
const floatLabel = document.createElement('label');
floatLabel.textContent = label;
// 组装结构: input 在前, label 在后 (使用 ~ 选择器)
floatContainer.appendChild(input);
floatContainer.appendChild(floatLabel);
// 如果是数字输入框,添加自定义上下调整按钮
if (type === 'number') {
const buttonsContainer = document.createElement('div');
buttonsContainer.className = 'number-input-buttons';
// 增加按钮
const increaseBtn = document.createElement('button');
increaseBtn.className = 'number-input-button';
increaseBtn.type = 'button';
increaseBtn.innerHTML = '<i class="pi pi-chevron-up"></i>';
increaseBtn.addEventListener('click', () => {
const step = parseFloat(input.step) || 1;
const max = input.max ? parseFloat(input.max) : Infinity;
const currentValue = parseFloat(input.value) || 0;
const newValue = Math.min(currentValue + step, max);
input.value = newValue;
// 触发change事件以保存数据
input.dispatchEvent(new Event('change', { bubbles: true }));
});
// 减少按钮
const decreaseBtn = document.createElement('button');
decreaseBtn.className = 'number-input-button';
decreaseBtn.type = 'button';
decreaseBtn.innerHTML = '<i class="pi pi-chevron-down"></i>';
decreaseBtn.addEventListener('click', () => {
const step = parseFloat(input.step) || 1;
const min = input.min ? parseFloat(input.min) : -Infinity;
const currentValue = parseFloat(input.value) || 0;
const newValue = Math.max(currentValue - step, min);
input.value = newValue;
// 触发change事件以保存数据
input.dispatchEvent(new Event('change', { bubbles: true }));
});
buttonsContainer.appendChild(increaseBtn);
buttonsContainer.appendChild(decreaseBtn);
floatContainer.appendChild(buttonsContainer);
}
group.appendChild(floatContainer);
return { group, input };
}
/**
* 创建下拉选择框组 (PrimeVue FloatLabel variant="on" 风格)
* @param {string} label 标签文本
* @param {Array<{value: string, text: string}>} options 选项列表
* @param {string} [initialValue=null] 初始选中的值
* @param {Object} [config={}] 配置选项
* @param {boolean} [config.showLabel=true] 是否显示浮动标签
* @returns {Object} 包含 group 和 select 的对象
*/
export function createSelectGroup(label, options, initialValue = null, config = {}) {
const { showLabel = true } = config;
const group = document.createElement('div');
group.className = 'settings-form-group';
// 创建浮动标签容器
const floatContainer = document.createElement('div');
floatContainer.className = 'float-label-container';
// Main Container
const dropdownContainer = document.createElement('div');
// 增加自定义样式类 pa-dropdown,保留 p-dropdown 以兼容现有逻辑(如脏检查与布局选择器)
dropdownContainer.className = 'pa-dropdown p-dropdown p-component w-full';
dropdownContainer.style.position = 'relative'; // Needed for panel positioning
// Hidden select for form data and accessibility
const select = document.createElement('select');
const hiddenContainer = document.createElement('div');
hiddenContainer.className = 'p-hidden-accessible';
hiddenContainer.appendChild(select);
// Visible Part: Label and Trigger
const dropdownLabel = document.createElement('span');
dropdownLabel.className = 'pa-dropdown-label p-dropdown-label p-inputtext';
const dropdownTrigger = document.createElement('div');
dropdownTrigger.className = 'pa-dropdown-trigger p-dropdown-trigger';
dropdownTrigger.innerHTML = '<span class="p-dropdown-trigger-icon pi pi-chevron-down"></span>';
// Dropdown Panel (the menu)
const dropdownPanel = document.createElement('div');
dropdownPanel.className = 'pa-dropdown-panel p-dropdown-panel p-component settings-modal-dropdown-panel';
dropdownPanel.style.display = 'none'; // Initially hidden
// z-index 由 CSS 的 --settings-dropdown-z-index 变量管理
const dropdownItemsWrapper = document.createElement('div');
dropdownItemsWrapper.className = 'p-dropdown-items-wrapper';
const dropdownList = document.createElement('ul');
dropdownList.className = 'p-dropdown-items';
dropdownList.setAttribute('role', 'listbox');
// Populate options
options.forEach(opt => {
const optionEl = document.createElement('option');
optionEl.value = opt.value;
optionEl.textContent = opt.text;
select.appendChild(optionEl);
const itemEl = document.createElement('li');
itemEl.className = 'p-dropdown-item';
itemEl.textContent = opt.text;
itemEl.dataset.value = opt.value;
itemEl.setAttribute('role', 'option');
itemEl.addEventListener('click', (e) => {
e.stopPropagation();
// Update highlight
dropdownList.querySelectorAll('.p-dropdown-item').forEach(el => el.classList.remove('p-highlight'));
itemEl.classList.add('p-highlight');
select.value = opt.value;
dropdownLabel.textContent = opt.text;
closePanel();
select.dispatchEvent(new Event('change', { bubbles: true }));
});
dropdownList.appendChild(itemEl);
});
// Set initial display value
const valueToSet = initialValue !== null && options.some(o => o.value === initialValue)
? initialValue
: (options.length > 0 ? options[0].value : null);
if (valueToSet !== null) {
const selectedOption = options.find(o => o.value === valueToSet);
if (selectedOption) {
dropdownLabel.textContent = selectedOption.text;
select.value = selectedOption.value;
// Set initial highlight
const initialItem = dropdownList.querySelector(`.p-dropdown-item[data-value="${select.value}"]`);
if (initialItem) {
initialItem.classList.add('p-highlight');
}
}
} else {
dropdownLabel.textContent = ' '; // Placeholder
}
// Assemble dropdown
dropdownItemsWrapper.appendChild(dropdownList);
dropdownPanel.appendChild(dropdownItemsWrapper);
dropdownContainer.appendChild(hiddenContainer);
dropdownContainer.appendChild(dropdownLabel);
dropdownContainer.appendChild(dropdownTrigger);
// 根据 showLabel 决定是否创建浮动标签
if (showLabel) {
// 创建浮动标签
const floatLabel = document.createElement('label');
floatLabel.textContent = label;
// 组装浮动标签结构: dropdown 在前, label 在后
floatContainer.appendChild(dropdownContainer);
floatContainer.appendChild(floatLabel);
} else {
// 不显示浮动标签,直接添加下拉框
floatContainer.appendChild(dropdownContainer);
}
group.appendChild(floatContainer);
// --- Event Handling ---
let isOpen = false;
// 更新面板位置的函数
const updatePanelPosition = () => {
if (!isOpen) return;
const rect = dropdownContainer.getBoundingClientRect();
dropdownPanel.style.top = rect.bottom + 'px';
dropdownPanel.style.left = rect.left + 'px';
dropdownPanel.style.width = rect.width + 'px';
};
const closePanel = () => {
if (!isOpen) return;
isOpen = false;
dropdownPanel.classList.add('p-hidden');
dropdownPanel.classList.remove('p-enter-active');
dropdownContainer.classList.remove('p-dropdown-open', 'p-focus');
// 移除事件监听器
window.removeEventListener('resize', updatePanelPosition);
window.removeEventListener('scroll', updatePanelPosition, true);
// 等待动画完成后再移除面板和事件监听
setTimeout(() => {
if (dropdownPanel.parentNode === document.body) {
document.body.removeChild(dropdownPanel);
}
document.removeEventListener('click', handleOutsideClick, true);
}, 120); // 与CSS中的transition时间相匹配
};
const openPanel = () => {
isOpen = true;
// 将面板临时附加到 body 上以避免裁切
document.body.appendChild(dropdownPanel);
// 计算下拉框相对于视口的位置
const rect = dropdownContainer.getBoundingClientRect();
// 设置面板样式
dropdownPanel.style.position = 'fixed';
dropdownPanel.style.display = 'block';
dropdownPanel.style.top = rect.bottom + 'px';
dropdownPanel.style.left = rect.left + 'px';
dropdownPanel.style.width = rect.width + 'px';
// 样式由 CSS 中的 .pa-dropdown-panel 接管,避免依赖 PrimeVue 变量
dropdownPanel.classList.remove('p-hidden');
// 强制重排,确保动画生效
dropdownPanel.offsetHeight;
dropdownPanel.classList.add('p-enter-active');
dropdownContainer.classList.add('p-dropdown-open', 'p-focus');
document.addEventListener('click', handleOutsideClick, true);
// 添加窗口大小变化和滚动事件监听器,以便重新定位面板
window.addEventListener('resize', updatePanelPosition);
window.addEventListener('scroll', updatePanelPosition, true);
};
const handleOutsideClick = (e) => {
// 如果点击的是下拉框本身,切换状态
if (dropdownContainer.contains(e.target)) {
e.stopPropagation();
if (isOpen) {
closePanel();
} else {
openPanel();
}
return;
}
// 如果点击的是其他区域,关闭面板
closePanel();
};
// 初始化面板状态
dropdownPanel.classList.add('p-hidden');
dropdownContainer.addEventListener('click', handleOutsideClick);
select.addEventListener('change', () => {
const selectedOption = options.find(o => o.value === select.value);
if (selectedOption) {
dropdownLabel.textContent = selectedOption.text;
// Update highlight
dropdownList.querySelectorAll('.p-dropdown-item').forEach(el => {
if (el.dataset.value === select.value) {
el.classList.add('p-highlight');
} else {
el.classList.remove('p-highlight');
}
});
}
});
return { group, select };
}
/**
* 创建可输入下拉框组(Combo Box)
* 支持从下拉列表选择或自定义输入
* @param {string} label 标签文本
* @param {Array<{value: string, text: string}>} options 选项列表(动态传入)
* @param {string} [initialValue=''] 初始值
* @param {Object} [config={}] 配置选项
* @param {string} [config.placeholder=''] 输入框占位符
* @param {string} [config.emptyText='暂无选项'] 无选项时的提示文本
* @param {boolean} [config.showLabel=true] 是否显示浮动标签
* @returns {Object} 包含 group, input, setValue, getValue, updateOptions 的对象
*/
export function createComboBoxGroup(label, options = [], initialValue = '', config = {}) {
const {
placeholder = '',
emptyText = '暂无选项',
showLabel = true
} = config;
const group = document.createElement('div');
group.className = 'settings-form-group';
// 创建浮动标签容器
const floatContainer = document.createElement('div');
floatContainer.className = 'float-label-container';
// ---主容器---
const comboContainer = document.createElement('div');
comboContainer.className = 'pa-combobox pa-dropdown p-dropdown p-component w-full';
comboContainer.style.position = 'relative';
// ---输入框(可见且可编辑)---
const input = document.createElement('input');
input.type = 'text';
input.className = 'pa-combobox-input pa-dropdown-label p-dropdown-label p-inputtext';
input.placeholder = placeholder || ' ';
input.value = initialValue;
// ---下拉触发器(箭头图标)---
const dropdownTrigger = document.createElement('div');
dropdownTrigger.className = 'pa-dropdown-trigger p-dropdown-trigger';
dropdownTrigger.innerHTML = '<span class="p-dropdown-trigger-icon pi pi-chevron-down"></span>';
// ---下拉面板---
const dropdownPanel = document.createElement('div');
dropdownPanel.className = 'pa-dropdown-panel pa-combobox-panel p-dropdown-panel p-component settings-modal-dropdown-panel';
dropdownPanel.style.display = 'none';
const dropdownItemsWrapper = document.createElement('div');
dropdownItemsWrapper.className = 'p-dropdown-items-wrapper';
const dropdownList = document.createElement('ul');
dropdownList.className = 'p-dropdown-items';
dropdownList.setAttribute('role', 'listbox');
// ---状态变量---
let isOpen = false;
let currentOptions = [...options];
// ---渲染选项列表---
const renderOptions = (optionsList) => {
dropdownList.innerHTML = '';
if (optionsList.length === 0) {
// 显示空状态提示
const emptyItem = document.createElement('li');
emptyItem.className = 'p-dropdown-item pa-combobox-empty';
emptyItem.textContent = emptyText;
emptyItem.style.color = 'var(--p-text-muted-color)';
emptyItem.style.fontStyle = 'italic';
emptyItem.style.pointerEvents = 'none';
dropdownList.appendChild(emptyItem);
return;
}
optionsList.forEach(opt => {
const itemEl = document.createElement('li');
itemEl.className = 'p-dropdown-item';
itemEl.textContent = opt.text;
itemEl.dataset.value = opt.value;
itemEl.setAttribute('role', 'option');
// 高亮当前选中项
if (input.value === opt.value) {
itemEl.classList.add('p-highlight');
}
itemEl.addEventListener('click', (e) => {
e.stopPropagation();
// 更新输入框值
input.value = opt.value;
// 更新高亮
dropdownList.querySelectorAll('.p-dropdown-item').forEach(el => el.classList.remove('p-highlight'));
itemEl.classList.add('p-highlight');
// 关闭面板
closePanel();
// 触发 change 事件
input.dispatchEvent(new Event('change', { bubbles: true }));
input.dispatchEvent(new Event('input', { bubbles: true }));
});
dropdownList.appendChild(itemEl);
});
};
// 初始渲染
renderOptions(currentOptions);
// ---组装下拉面板---
dropdownItemsWrapper.appendChild(dropdownList);
dropdownPanel.appendChild(dropdownItemsWrapper);
// ---组装主容器---
comboContainer.appendChild(input);
comboContainer.appendChild(dropdownTrigger);
// ---根据 showLabel 决定是否创建浮动标签---
if (showLabel) {
const floatLabel = document.createElement('label');
floatLabel.textContent = label;
floatContainer.appendChild(comboContainer);
floatContainer.appendChild(floatLabel);
} else {
floatContainer.appendChild(comboContainer);
}
group.appendChild(floatContainer);
// ---更新面板位置---
const updatePanelPosition = () => {
if (!isOpen) return;
const rect = comboContainer.getBoundingClientRect();
dropdownPanel.style.top = rect.bottom + 'px';
dropdownPanel.style.left = rect.left + 'px';
dropdownPanel.style.width = rect.width + 'px';
};
// ---关闭面板---
const closePanel = () => {
if (!isOpen) return;
isOpen = false;
dropdownPanel.classList.add('p-hidden');
dropdownPanel.classList.remove('p-enter-active');
comboContainer.classList.remove('p-dropdown-open', 'p-focus');
window.removeEventListener('resize', updatePanelPosition);
window.removeEventListener('scroll', updatePanelPosition, true);
setTimeout(() => {
if (dropdownPanel.parentNode === document.body) {
document.body.removeChild(dropdownPanel);
}
document.removeEventListener('click', handleOutsideClick, true);
}, 120);
};
// ---打开面板---
const openPanel = () => {
if (isOpen) return;
isOpen = true;
document.body.appendChild(dropdownPanel);
const rect = comboContainer.getBoundingClientRect();
dropdownPanel.style.position = 'fixed';
dropdownPanel.style.display = 'block';
dropdownPanel.style.top = rect.bottom + 'px';
dropdownPanel.style.left = rect.left + 'px';
dropdownPanel.style.width = rect.width + 'px';
dropdownPanel.classList.remove('p-hidden');
dropdownPanel.offsetHeight; // 强制重排
dropdownPanel.classList.add('p-enter-active');
comboContainer.classList.add('p-dropdown-open', 'p-focus');
document.addEventListener('click', handleOutsideClick, true);
window.addEventListener('resize', updatePanelPosition);
window.addEventListener('scroll', updatePanelPosition, true);
// 更新高亮状态
dropdownList.querySelectorAll('.p-dropdown-item').forEach(el => {
if (el.dataset.value === input.value) {
el.classList.add('p-highlight');
} else {
el.classList.remove('p-highlight');
}
});
};
// ---处理外部点击---
const handleOutsideClick = (e) => {
// 点击面板内部不关闭
if (dropdownPanel.contains(e.target)) {
return;
}
// 点击输入框或触发器时切换状态
if (comboContainer.contains(e.target)) {
return; // 由输入框或触发器的事件处理
}
closePanel();
};
// ---输入框事件---
input.addEventListener('focus', () => {
openPanel();
comboContainer.classList.add('p-focus');
});
input.addEventListener('blur', () => {
// 延迟关闭,允许点击选项
setTimeout(() => {
if (!dropdownPanel.contains(document.activeElement)) {
comboContainer.classList.remove('p-focus');
}
}, 150);
});
// ---触发器点击事件---
dropdownTrigger.addEventListener('click', (e) => {
e.stopPropagation();
if (isOpen) {
closePanel();
} else {
openPanel();
input.focus();
}
});
// ---初始化面板状态---
dropdownPanel.classList.add('p-hidden');
// ---公开方法---
const setValue = (value) => {
input.value = value;
// 更新高亮
dropdownList.querySelectorAll('.p-dropdown-item').forEach(el => {
if (el.dataset.value === value) {
el.classList.add('p-highlight');
} else {
el.classList.remove('p-highlight');
}
});
};
const getValue = () => {
return input.value;
};
const updateOptions = (newOptions) => {
currentOptions = [...newOptions];
renderOptions(currentOptions);
};
return { group, input, setValue, getValue, updateOptions };
}
/**
* 创建水平布局的表单组
* @param {Array<{label: string, element: HTMLElement}>} items 表单项数组
* @returns {HTMLElement} 水平布局的表单组
*/
export function createHorizontalFormGroup(items) {
const group = document.createElement('div');
group.className = 'settings-form-group horizontal';
items.forEach(item => {
const container = document.createElement('div');
const label = document.createElement('label');
label.className = 'settings-form-label';
label.textContent = item.label;
container.appendChild(label);
container.appendChild(item.element);
group.appendChild(container);
});
return group;
}
/**
* 创建多行文本输入框组 (PrimeVue FloatLabel variant="on" 风格)
* @param {string} label 标签文本
* @param {string} placeholder 占位符文本(未使用,保留参数以兼容旧代码)
* @param {number} rows 默认行数
* @returns {Object} 包含 group 和 textarea 的对象
*/
export function createTextareaGroup(label, placeholder, rows = 8) {
const group = document.createElement('div');
group.className = 'settings-form-group';
// 创建浮动标签容器
const floatContainer = document.createElement('div');
floatContainer.className = 'float-label-container';
// 创建文本域
const textarea = document.createElement('textarea');
textarea.className = 'p-inputtext p-component settings-form-textarea';
// 使用空格作为 placeholder 以触发 :not(:placeholder-shown) 选择器
textarea.placeholder = ' ';
textarea.rows = rows;
textarea.style.resize = 'vertical';
textarea.style.minHeight = '150px';
// 创建浮动标签
const floatLabel = document.createElement('label');
floatLabel.textContent = label;
// 组装结构: textarea 在前, label 在后 (使用 ~ 选择器)
floatContainer.appendChild(textarea);
floatContainer.appendChild(floatLabel);
group.appendChild(floatContainer);
return { group, textarea };
}
/**
* 创建开关控制组件
* @param {string} label 标签文本
* @param {string} description 描述文本
* @param {boolean} defaultChecked 默认选中状态
* @param {Function} onChange 变化回调函数
* @returns {HTMLElement} 开关容器元素
*/
export function createSwitchControl(label, description, defaultChecked, onChange) {
const container = document.createElement('div');
container.className = 'switch-control-container';
const textContainer = document.createElement('div');
textContainer.className = 'switch-control-text';
const labelEl = document.createElement('div');
labelEl.className = 'switch-control-label';
labelEl.textContent = label;
textContainer.appendChild(labelEl);
if (description) {
const descEl = document.createElement('div');
descEl.className = 'switch-control-desc';
descEl.textContent = description;
textContainer.appendChild(descEl);
}
container.appendChild(textContainer);
// 创建开关
const switchWrapper = document.createElement('label');
switchWrapper.className = 'switch-wrapper';
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = defaultChecked;
const slider = document.createElement('span');
slider.className = `switch-slider${defaultChecked ? ' checked' : ''}`;
const sliderButton = document.createElement('span');
sliderButton.className = `switch-button${defaultChecked ? ' checked' : ''}`;
slider.appendChild(sliderButton);
input.addEventListener('change', async (e) => {
const isChecked = e.target.checked;
if (isChecked) {
slider.classList.add('checked');
sliderButton.classList.add('checked');
} else {
slider.classList.remove('checked');
sliderButton.classList.remove('checked');
}
if (onChange) {
await onChange(isChecked);
}
});
switchWrapper.appendChild(input);
switchWrapper.appendChild(slider);
container.appendChild(switchWrapper);
return container;
}
/**
* 创建加载按钮
* @param {string} text 按钮文本
* @param {Function} onClick 点击回调函数
* @param {boolean} showSuccessToast 是否显示成功提示
* @returns {HTMLElement} 按钮元素
*/
export function createLoadingButton(text, onClick, showSuccessToast = true) {
const button = document.createElement('button');
button.className = 'p-button p-component p-button-primary';
button.style.width = '208px'; // 相当于w-52
button.innerHTML = `<span class="p-button-label">${text}</span>`;
button.addEventListener('click', async () => {
if (button.disabled) return;
// 开始加载状态
button.disabled = true;
button.classList.add('p-disabled');
try {
await onClick();
// 只有在 showSuccessToast 为 true 时才显示成功提示
if (showSuccessToast) {
app.extensionManager.toast.add({
severity: "success",
summary: "清理已清理完成",
life: 3000
});
}
} catch (error) {
// 显示错误提示
app.extensionManager.toast.add({
severity: "error",
summary: "操作失败",
detail: error.message || "操作过程中发生错误",
life: 3000
});
logger.error(`按钮操作失败: ${error.message}`);
} finally {
// 恢复按钮状态
button.disabled = false;
button.classList.remove('p-disabled');
}
});
return button;
}
/**
* 创建确认气泡框 (参考 PrimeVue ConfirmPopup)
* @param {Object} options 配置选项
* @param {HTMLElement} options.target 触发元素,气泡框的指针会指向它
* @param {string} options.message 确认消息文本
* @param {string} [options.icon] 消息图标类名(可选,默认为 'pi-info-circle')
* @param {Function} options.renderFormContent 渲染表单内容的函数(可选)
* @param {Function} options.onConfirm 确认回调函数
* @param {Function} [options.onCancel] 取消回调函数(可选)
* @param {string} [options.confirmLabel='确认'] 确认按钮文本
* @param {string} [options.cancelLabel='取消'] 取消按钮文本
* @param {string} [options.position='bottom'] 气泡相对于触发元素的位置 ('top', 'bottom', 'left', 'right')
* @param {boolean} [options.autoPosition=true] 是否自动计算最佳弹出位置(基于元素在窗口中的位置)
* @param {boolean} [options.singleButton=false] 是否只显示单个确认按钮(适用于信息提示场景)
* @returns {Object} 包含 popup 元素和 close 方法的对象
*/
export function createConfirmPopup(options) {
const {
target,
message,
icon = 'pi-info-circle',
renderFormContent = null,
onConfirm,
onCancel = null,
confirmLabel = '确认',
cancelLabel = '取消',
position = 'bottom',
autoPosition = true,
singleButton = false
} = options;
// 创建气泡框容器
const popup = document.createElement('div');
popup.className = 'pa-confirm-popup';
// 创建气泡框内容
const content = document.createElement('div');
content.className = 'pa-confirm-popup-content';
// 创建消息区域
const messageContainer = document.createElement('div');
messageContainer.className = 'pa-confirm-popup-message';
const iconElement = document.createElement('i');
iconElement.className = `pi ${icon} pa-confirm-popup-icon`;
messageContainer.appendChild(iconElement);
const messageText = document.createElement('span');
messageText.textContent = message;
messageContainer.appendChild(messageText);
content.appendChild(messageContainer);
// 如果提供了表单渲染函数,创建表单区域
let formContainer = null;
if (renderFormContent) {
// 调整消息区域的下边距
messageContainer.style.marginBottom = '12px';
// 添加分割线
const divider = document.createElement('div');
divider.className = 'pa-confirm-popup-divider';
content.appendChild(divider);
formContainer = document.createElement('div');
formContainer.className = 'pa-confirm-popup-form';
renderFormContent(formContainer);
content.appendChild(formContainer);
}
// 创建按钮组
const footer = document.createElement('div');
footer.className = 'pa-confirm-popup-footer';
// 单按钮模式:只显示确认按钮
if (singleButton) {
const confirmButton = document.createElement('button');
confirmButton.className = 'p-button p-component p-button-sm';
confirmButton.innerHTML = `<span class="p-button-icon-left pi pi-check"></span><span class="p-button-label">${confirmLabel}</span>`;
confirmButton.onclick = async () => {
try {
if (onConfirm) {
await onConfirm(formContainer);
}
closePopup();
} catch (error) {
logger.error('确认操作失败', error);
// 如果确认操作失败,不关闭气泡框
}
};
footer.appendChild(confirmButton);
} else {
// 双按钮模式:显示取消和确认按钮
const cancelButton = document.createElement('button');
cancelButton.className = 'p-button p-component p-button-secondary p-button-sm';
cancelButton.innerHTML = `<span class="p-button-icon-left pi pi-times"></span><span class="p-button-label">${cancelLabel}</span>`;
cancelButton.onclick = () => {
if (onCancel) {
onCancel();
}
closePopup();
};
const confirmButton = document.createElement('button');
confirmButton.className = 'p-button p-component p-button-sm';
confirmButton.innerHTML = `<span class="p-button-icon-left pi pi-check"></span><span class="p-button-label">${confirmLabel}</span>`;
confirmButton.onclick = async () => {
try {
// 将表单容器传递给 onConfirm 回调
await onConfirm(formContainer);
closePopup();
} catch (error) {
logger.error('确认操作失败', error);
// 如果确认操作失败,不关闭气泡框
}
};
footer.appendChild(cancelButton);
footer.appendChild(confirmButton);
}
content.appendChild(footer);
// 创建指针(箭头)
const arrow = document.createElement('div');
arrow.className = 'pa-confirm-popup-arrow';
popup.appendChild(arrow);
popup.appendChild(content);
// 关闭气泡框的函数
const closePopup = () => {
popup.classList.add('pa-confirm-popup-hide');
setTimeout(() => {
if (popup.parentNode) {
popup.parentNode.removeChild(popup);
}
// 移除点击外部关闭的事件监听
document.removeEventListener('click', handleOutsideClick, true);
}, 200);
};
// 点击外部关闭
const handleOutsideClick = (e) => {
if (!popup.contains(e.target) && !target.contains(e.target)) {
if (onCancel) {
onCancel();
}
closePopup();
}
};
// ---定位气泡框---
const positionPopup = () => {
const targetRect = target.getBoundingClientRect();
const popupRect = popup.getBoundingClientRect();
const margin = 10;
const gap = 12; // 气泡与目标之间的间隙
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let top, left;
let arrowPosition = '';
let finalPosition = position;
// 如果开启自动定位,根据目标元素位置计算最佳方向
if (autoPosition) {
// 计算各方向可用空间
const spaceTop = targetRect.top - margin;
const spaceBottom = viewportHeight - targetRect.bottom - margin;
const spaceLeft = targetRect.left - margin;
const spaceRight = viewportWidth - targetRect.right - margin;
// 判断各方向是否能容纳气泡
const canFitTop = spaceTop >= popupRect.height + gap;
const canFitBottom = spaceBottom >= popupRect.height + gap;
const canFitLeft = spaceLeft >= popupRect.width + gap;
const canFitRight = spaceRight >= popupRect.width + gap;
// 根据目标元素位置选择最佳方向
// 优先级:用户指定的方向 > 相反方向 > 垂直/水平替代方向
const preferHorizontal = (position === 'left' || position === 'right');
if (preferHorizontal) {
// 用户偏好水平方向
if (position === 'left' && canFitLeft) {
finalPosition = 'left';
} else if (position === 'right' && canFitRight) {
finalPosition = 'right';
} else if (canFitRight) {
finalPosition = 'right';
} else if (canFitLeft) {
finalPosition = 'left';
} else if (canFitBottom) {
finalPosition = 'bottom';
} else if (canFitTop) {
finalPosition = 'top';
} else {
// 空间都不够,选择垂直空间最大的方向
finalPosition = spaceBottom >= spaceTop ? 'bottom' : 'top';
}
} else {
// 用户偏好垂直方向(默认)
if (position === 'bottom' && canFitBottom) {
finalPosition = 'bottom';
} else if (position === 'top' && canFitTop) {
finalPosition = 'top';
} else if (canFitBottom) {
finalPosition = 'bottom';
} else if (canFitTop) {
finalPosition = 'top';
} else if (canFitRight) {
finalPosition = 'right';
} else if (canFitLeft) {
finalPosition = 'left';
} else {
// 空间都不够,选择垂直空间最大的方向
finalPosition = spaceBottom >= spaceTop ? 'bottom' : 'top';
}
}
}
// 根据最终方向计算位置
switch (finalPosition) {
case 'top':
top = targetRect.top - popupRect.height - gap;
left = targetRect.left + (targetRect.width / 2) - (popupRect.width / 2);
arrowPosition = 'bottom';
break;
case 'left':
top = targetRect.top + (targetRect.height / 2) - (popupRect.height / 2);
left = targetRect.left - popupRect.width - gap;
arrowPosition = 'right';
break;
case 'right':
top = targetRect.top + (targetRect.height / 2) - (popupRect.height / 2);
left = targetRect.right + gap;
arrowPosition = 'left';
break;
case 'bottom':
default:
top = targetRect.bottom + gap;
left = targetRect.left + (targetRect.width / 2) - (popupRect.width / 2);
arrowPosition = 'top';
break;
}
// 水平边界检查(确保气泡不超出视口)
if (left < margin) {
left = margin;
} else if (left + popupRect.width > viewportWidth - margin) {
left = viewportWidth - popupRect.width - margin;
}
// 垂直边界检查
if (top < margin) {
top = margin;
} else if (top + popupRect.height > viewportHeight - margin) {
top = viewportHeight - popupRect.height - margin;
}
popup.style.top = `${top}px`;
popup.style.left = `${left}px`;
// 设置箭头位置
arrow.className = `pa-confirm-popup-arrow ${arrowPosition}`;
// 计算箭头的偏移,使其指向触发元素的中心
if (arrowPosition === 'top' || arrowPosition === 'bottom') {
let arrowLeft = targetRect.left + (targetRect.width / 2) - left;
// 确保箭头不超出气泡边界
arrowLeft = Math.max(16, Math.min(arrowLeft, popupRect.width - 16));
arrow.style.left = `${arrowLeft}px`;
arrow.style.top = '';
} else if (arrowPosition === 'left' || arrowPosition === 'right') {
let arrowTop = targetRect.top + (targetRect.height / 2) - top;
// 确保箭头不超出气泡边界
arrowTop = Math.max(16, Math.min(arrowTop, popupRect.height - 16));
arrow.style.top = `${arrowTop}px`;
arrow.style.left = '';
}
};
// 将气泡框添加到 body
document.body.appendChild(popup);
// 定位气泡框
requestAnimationFrame(() => {
positionPopup();
popup.classList.add('pa-confirm-popup-show');
// 延迟添加外部点击监听,防止立即触发
setTimeout(() => {
document.addEventListener('click', handleOutsideClick, true);
}, 100);
});
return {
popup,
close: closePopup
};
}
/**
* 创建右键菜单组件 (参考 PrimeVue Menu)
* @param {Object} options 配置选项
* @param {HTMLElement} options.target 触发元素,右键菜单会在此元素上触发
* @param {Array<Object>} options.items 菜单项列表
* @param {string} options.items[].label 菜单项文本
* @param {string} [options.items[].icon] 菜单项图标类名(可选)
* @param {Function} options.items[].onClick 菜单项点击回调
* @param {boolean} [options.items[].separator] 是否为分隔符(可选)
* @param {boolean} [options.items[].disabled] 是否禁用(可选)
* @returns {Object} 包含 destroy 方法的对象
*/
/**
* 显示右键菜单 (动态显示,不需要绑定特定元素)
* @param {Object} options 配置选项
* @param {number} options.x 显示位置 X 坐标
* @param {number} options.y 显示位置 Y 坐标
* @param {Array<Object>} options.items 菜单项列表
* @param {Function} [options.onClose] 菜单关闭回调
*/
export function showContextMenu(options) {
const {
x,
y,
items = [],
onClose
} = options;
// 移除现有的右键菜单(但不移除 Split Button 的菜单和下拉菜单)
const existingMenu = document.querySelector('.pa-context-menu:not(.pa-split-button-menu):not(.pa-dropdown-menu)');
if (existingMenu) {
existingMenu.remove();
}
const menu = document.createElement('div');
menu.className = 'pa-context-menu pa-context-menu-show'; // 直接显示
// 我们需要手动设置样式以确保它立即可见但可以被定位
menu.style.display = 'block';
const menuList = document.createElement('ul');
menuList.className = 'pa-context-menu-list';
const closeMenu = () => {
menu.classList.remove('pa-context-menu-show');
menu.classList.add('pa-context-menu-hide');
setTimeout(() => {
if (menu && menu.parentNode) {
menu.parentNode.removeChild(menu);
}
}, 150);
document.removeEventListener('click', handleOutsideClick, true);
document.removeEventListener('contextmenu', handleOutsideClick, true);
if (onClose) {
onClose();
}
};
items.forEach(item => {
if (item.separator) {
const separator = document.createElement('li');
separator.className = 'pa-context-menu-separator';
menuList.appendChild(separator);
} else {
const menuItem = document.createElement('li');
menuItem.className = 'pa-context-menu-item';
if (item.disabled) {
menuItem.classList.add('disabled');
}
if (item.danger) {
menuItem.classList.add('danger');
}
const menuItemContent = document.createElement('div');
menuItemContent.className = 'pa-context-menu-item-content';
if (item.icon) {
const icon = document.createElement('i');
icon.className = `pi ${item.icon} pa-context-menu-item-icon`;
menuItemContent.appendChild(icon);
}
const label = document.createElement('span');
label.className = 'pa-context-menu-item-label';
label.textContent = item.label;
menuItemContent.appendChild(label);
menuItem.appendChild(menuItemContent);
if (!item.disabled && item.onClick) {
menuItem.addEventListener('click', (e) => {
e.stopPropagation();
item.onClick(e);
closeMenu();
});
}
menuList.appendChild(menuItem);
}
});
menu.appendChild(menuList);
document.body.appendChild(menu);
// 定位菜单
const menuRect = menu.getBoundingClientRect();
let left = x;
let top = y;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
if (left + menuRect.width > viewportWidth) {
left = viewportWidth - menuRect.width - 10;
}
if (top + menuRect.height > viewportHeight) {
top = viewportHeight - menuRect.height - 10;
}
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
// 点击外部关闭
const handleOutsideClick = (e) => {
if (menu && !menu.contains(e.target)) {
closeMenu();
}
};
// 延迟添加外部点击监听
setTimeout(() => {
document.addEventListener('click', handleOutsideClick, true);
document.addEventListener('contextmenu', handleOutsideClick, true);
}, 100);
return {
close: closeMenu
};
}
/**
* 创建右键菜单组件 (参考 PrimeVue Menu)
* @param {Object} options 配置选项
* @param {HTMLElement} options.target 触发元素,右键菜单会在此元素上触发
* @param {Array<Object>|Function} options.items 菜单项列表或返回菜单项列表的函数
* @returns {Object} 包含 destroy 方法的对象
*/
export function createContextMenu(options) {
const {
target,
items = []
} = options;
const handleContextMenu = (e) => {
e.preventDefault();
e.stopPropagation();
// 如果 items 是函数,则调用它获取最新的菜单项列表
const menuItems = typeof items === 'function' ? items() : items;
showContextMenu({
x: e.clientX,
y: e.clientY,
items: menuItems
});
};
target.addEventListener('contextmenu', handleContextMenu);
const destroy = () => {
target.removeEventListener('contextmenu', handleContextMenu);
};
return {
destroy
};
}
/**
* 创建 Tooltip 提示框 (参考 PrimeVue Tooltip)
* @param {Object} options 配置选项
* @param {HTMLElement} options.target 触发元素
* @param {string} options.content 提示内容
* @param {string} [options.position='top'] 提示框位置 ('top', 'bottom', 'left', 'right')
* @returns {Object} 包含 destroy 方法的对象
*/
export function createTooltip(options) {
const {
target,
content,
position = 'top'
} = options;
let tooltip = null;
let showTimeout = null;
let hideTimeout = null;
// 创建 tooltip 元素
const createTooltipElement = () => {
tooltip = document.createElement('div');
tooltip.className = 'pa-tooltip';
tooltip.textContent = content;
// 创建箭头
const arrow = document.createElement('div');
arrow.className = 'pa-tooltip-arrow';
tooltip.appendChild(arrow);
document.body.appendChild(tooltip);
};
// 定位 tooltip
const positionTooltip = () => {
if (!tooltip) return;
const targetRect = target.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
let top, left;
let arrowClass = '';
switch (position) {
case 'top':
top = targetRect.top - tooltipRect.height - 8;
left = targetRect.left + (targetRect.width / 2) - (tooltipRect.width / 2);
arrowClass = 'bottom';
break;
case 'bottom':
top = targetRect.bottom + 8;
left = targetRect.left + (targetRect.width / 2) - (tooltipRect.width / 2);
arrowClass = 'top';
break;
case 'left':
top = targetRect.top + (targetRect.height / 2) - (tooltipRect.height / 2);
left = targetRect.left - tooltipRect.width - 8;
arrowClass = 'right';
break;
case 'right':
top = targetRect.top + (targetRect.height / 2) - (tooltipRect.height / 2);
left = targetRect.right + 8;
arrowClass = 'left';
break;
}
// 边界检查
const margin = 10;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
if (left < margin) {
left = margin;
} else if (left + tooltipRect.width > viewportWidth - margin) {
left = viewportWidth - tooltipRect.width - margin;
}
if (top < margin) {
top = margin;
} else if (top + tooltipRect.height > viewportHeight - margin) {
top = viewportHeight - tooltipRect.height - margin;
}
tooltip.style.top = `${top}px`;
tooltip.style.left = `${left}px`;
const arrow = tooltip.querySelector('.pa-tooltip-arrow');
if (arrow) {
arrow.className = `pa-tooltip-arrow ${arrowClass}`;
}
};
// 显示 tooltip
const showTooltip = () => {
clearTimeout(hideTimeout);
showTimeout = setTimeout(() => {
if (!tooltip) {
createTooltipElement();
}
positionTooltip();
requestAnimationFrame(() => {
tooltip.classList.add('pa-tooltip-show');
});
}, 200); // 延迟 200ms 显示
};
// 隐藏 tooltip
const hideTooltip = () => {
clearTimeout(showTimeout);
if (tooltip) {
tooltip.classList.remove('pa-tooltip-show');
hideTimeout = setTimeout(() => {
if (tooltip && tooltip.parentNode) {
tooltip.parentNode.removeChild(tooltip);
tooltip = null;
}
}, 150);
}
};
// 绑定事件
target.addEventListener('mouseenter', showTooltip);
target.addEventListener('mouseleave', hideTooltip);
// 销毁方法
const destroy = () => {
clearTimeout(showTimeout);
clearTimeout(hideTimeout);
target.removeEventListener('mouseenter', showTooltip);
target.removeEventListener('mouseleave', hideTooltip);
if (tooltip && tooltip.parentNode) {
tooltip.parentNode.removeChild(tooltip);
}
tooltip = null;
};
return {
destroy
};
}
/**
* 创建多选 Listbox 组件
* @param {Object} options 配置选项
* @param {HTMLElement} options.triggerElement 触发元素(按钮),用于定位
* @param {string} options.placeholder 搜索框占位符
* @param {Function} options.fetchItems 异步获取数据的函数,返回 Promise<Array>
* @param {Function} options.onConfirm 确认选择回调,参数为选中项数组
* @param {Function} options.onCancel 取消回调(可选)
* @returns {void}
*/
export function createMultiSelectListbox(options) {
const {
triggerElement,
placeholder = '搜索...',
fetchItems,
onConfirm,
onCancel = null
} = options;
// 获取触发按钮的位置
const btnRect = triggerElement.getBoundingClientRect();
// 创建listbox容器
const listbox = document.createElement('div');
listbox.className = 'pa-multi-listbox';
// 创建搜索框容器
const searchContainer = document.createElement('div');
searchContainer.className = 'pa-multi-listbox-search';
const searchWrapper = document.createElement('div');
searchWrapper.className = 'pa-multi-listbox-search-wrapper';
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.placeholder = placeholder;
searchInput.className = 'pa-multi-listbox-search-input';
const searchIcon = document.createElement('i');
searchIcon.className = 'pi pi-search pa-multi-listbox-search-icon';
searchWrapper.appendChild(searchInput);
searchWrapper.appendChild(searchIcon);
searchContainer.appendChild(searchWrapper);
listbox.appendChild(searchContainer);
// 创建列表容器
const listContainer = document.createElement('div');
listContainer.className = 'pa-multi-listbox-content';
listbox.appendChild(listContainer);
// 创建底部操作栏
const footer = document.createElement('div');
footer.className = 'pa-multi-listbox-footer';
const countLabel = document.createElement('span');
countLabel.className = 'pa-multi-listbox-count';
countLabel.textContent = '已选 0 项';
const actions = document.createElement('div');
actions.className = 'pa-multi-listbox-actions';
const cancelBtn = document.createElement('button');
cancelBtn.className = 'p-button p-component p-button-secondary p-button-sm';
cancelBtn.innerHTML = '<span class="p-button-icon-left pi pi-times"></span><span class="p-button-label">取消</span>';
const confirmBtn = document.createElement('button');
confirmBtn.className = 'p-button p-component p-button-sm';
confirmBtn.innerHTML = '<span class="p-button-icon-left pi pi-check"></span><span class="p-button-label">确定</span>';
confirmBtn.disabled = true;
actions.appendChild(cancelBtn);
actions.appendChild(confirmBtn);
footer.appendChild(countLabel);
footer.appendChild(actions);
listbox.appendChild(footer);
// 数据存储
let allItems = [];
let selectedItems = new Set();
// 显示loading状态
const showLoading = () => {
listContainer.innerHTML = `
<div class="loading-container">
<div class="loading-spinner"></div>
<div class="loading-text">加载中...</div>
</div>
`;
};
// 显示错误信息
const showError = (message) => {
listContainer.innerHTML = `
<div style="text-align: center; padding: 40px 20px;">
<div style="font-size: 48px; margin-bottom: 16px;">⚠️</div>
<div style="font-size: 14px; color: var(--p-text-color); margin-bottom: 8px;">加载失败</div>
<div style="font-size: 13px; color: var(--p-red-500);">${message}</div>
<div style="font-size: 12px; color: var(--p-text-muted-color); margin-top: 12px;">你仍然可以在搜索框中手动输入模型名称</div>
</div>
`;
// 不禁用搜索框,允许用户手动输入模型名称
};
// 更新选中计数
const updateCount = () => {
countLabel.textContent = `已选 ${selectedItems.size} 项`;
// 如果有选中项或搜索框有内容,则启用确认按钮
const hasInput = searchInput.value.trim().length > 0;
confirmBtn.disabled = selectedItems.size === 0 && !hasInput;
};
// 渲染列表
const renderList = (searchTerm = '') => {
listContainer.innerHTML = '';
const filteredItems = searchTerm
? allItems.filter(item => item.toLowerCase().includes(searchTerm.toLowerCase()))
: allItems;
if (filteredItems.length === 0) {
const emptyHint = document.createElement('div');
emptyHint.className = 'pa-multi-listbox-empty';
emptyHint.textContent = searchTerm ? '未找到匹配项' : '暂无可用项';
listContainer.appendChild(emptyHint);
return;
}
filteredItems.forEach(itemName => {
const item = document.createElement('div');
item.className = 'pa-multi-listbox-item';
if (selectedItems.has(itemName)) {
item.classList.add('selected');
}
const checkbox = document.createElement('div');
checkbox.className = 'pa-multi-listbox-checkbox';
const label = document.createElement('span');
label.textContent = itemName;
item.appendChild(checkbox);
item.appendChild(label);
// 点击切换选中状态
item.addEventListener('click', () => {
if (selectedItems.has(itemName)) {
selectedItems.delete(itemName);
item.classList.remove('selected');
} else {
selectedItems.add(itemName);
item.classList.add('selected');
}
updateCount();
});
listContainer.appendChild(item);
});
};
// 初始化数据
const initialize = async () => {
showLoading();
try {
const items = await fetchItems();
if (!items || !Array.isArray(items)) {
throw new Error('数据格式错误');
}
allItems = items;
renderList();
} catch (error) {
showError(error.message || '未知错误');
}
};
// 搜索框事件
searchInput.addEventListener('input', (e) => {
renderList(e.target.value);
updateCount(); // 输入时更新按钮状态
});
// 取消按钮
cancelBtn.addEventListener('click', () => {
if (onCancel) {
onCancel();
}
close();
});
// 确定按钮
confirmBtn.addEventListener('click', () => {
const selected = Array.from(selectedItems);
const searchValue = searchInput.value; // 获取搜索框的值
onConfirm(selected, searchValue); // 将搜索框的值作为第二个参数传递
close();
});
// 创建透明背景层
const overlay = document.createElement('div');
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: var(--settings-multi-listbox-z-index);
`;
overlay.appendChild(listbox);
document.body.appendChild(overlay);
// 计算并设置位置(右对齐按钮,自适应上下)
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const listboxHeight = 400; // max-height
const spaceBelow = viewportHeight - btnRect.bottom;
const spaceAbove = btnRect.top;
if (spaceBelow >= listboxHeight || spaceBelow > spaceAbove) {
// 显示在按钮下方
listbox.style.top = `${btnRect.bottom + 4}px`;
} else {
// 显示在按钮上方
listbox.style.bottom = `${viewportHeight - btnRect.top + 4}px`;
}
// 右对齐按钮
listbox.style.right = `${viewportWidth - btnRect.right}px`;
// 关闭函数
const close = () => {
if (overlay.parentNode) {
document.body.removeChild(overlay);
}
};
// 点击背景关闭
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
if (onCancel) {
onCancel();
}
close();
}
});
// 初始化数据
initialize();
// 聚焦搜索框
setTimeout(() => searchInput.focus(), 100);
}
/**
* 创建 SplitButton 组件 (参考 PrimeVue SplitButton)
* @param {Object} options 配置选项
* @param {string} options.label 按钮文本
* @param {string} options.icon 按钮图标
* @param {string} options.className 自定义类名
* @param {Function} options.onClick 主按钮点击回调
* @param {Array} options.items 下拉菜单项 [{label, icon, command, separator, checked, disabled}]
* @returns {Object} { container, updateLabel, updateIcon, updateMenu, setMainButtonDisabled, setMenuButtonDisabled }
*/
export function createSplitButton(options) {
const { label = '', icon = '', className = '', onClick = () => { }, items = [] } = options;
const container = document.createElement('div');
container.className = `p-splitbutton p-component${className ? ' ' + className : ''}`;
container.style.display = 'inline-flex';
container.style.position = 'relative';
// 1. 主按钮
const mainKey = document.createElement('button');
mainKey.className = 'p-button p-component p-button-text p-button-sm';
mainKey.type = 'button';
mainKey.style.borderTopRightRadius = '0';
mainKey.style.borderBottomRightRadius = '0';
mainKey.style.borderRight = '0 none';
// 内容容器
const mainContent = document.createElement('span');
mainContent.className = 'p-button-label p-c';
mainContent.style.display = 'flex';
mainContent.style.alignItems = 'center';
mainContent.style.gap = '0.5rem';
const renderMainButton = (text, iconClass, spinning = false) => {
mainContent.innerHTML = '';
if (iconClass) {
const i = document.createElement('span');
i.className = `p-button-icon p-button-icon-left ${iconClass} ${spinning ? 'spinning' : ''}`;
mainContent.appendChild(i);
}
const span = document.createElement('span');
span.className = 'p-button-label';
span.textContent = text;
mainContent.appendChild(span);
// 更新 stored data for subsequent updates via updateIcon
mainKey.dataset.label = text;
mainKey.dataset.icon = iconClass || '';
};
renderMainButton(label, icon);
mainKey.appendChild(mainContent);
mainKey.addEventListener('click', (e) => {
e.stopPropagation();
onClick(e);
});
// 2. 下拉触发按钮
const menuBtn = document.createElement('button');
menuBtn.className = 'p-button p-component p-button-icon-only p-button-text p-button-sm p-splitbutton-menubutton';
menuBtn.type = 'button';
menuBtn.style.borderTopLeftRadius = '0';
menuBtn.style.borderBottomLeftRadius = '0';
menuBtn.innerHTML = '<span class="p-button-icon pi pi-chevron-down"></span>';
container.appendChild(mainKey);
container.appendChild(menuBtn);
// 3. 下拉菜单
const menuPanel = document.createElement('div');
menuPanel.className = 'pa-context-menu pa-split-button-menu';
menuPanel.style.display = 'none';
menuPanel.style.position = 'fixed';
menuPanel.style.minWidth = '12rem';
// z-index 由 CSS 的 --settings-context-menu-z-index 变量管理
const menuList = document.createElement('ul');
menuList.className = 'pa-context-menu-list';
menuList.setAttribute('role', 'menu');
menuPanel.appendChild(menuList);
document.body.appendChild(menuPanel);
let currentItems = items;
let isOpen = false;
const renderMenu = () => {
menuList.innerHTML = '';
currentItems.forEach(item => {
if (item.separator) {
const sep = document.createElement('li');
sep.className = 'pa-context-menu-separator';
sep.setAttribute('role', 'separator');
menuList.appendChild(sep);
return;
}
const li = document.createElement('li');
li.className = 'pa-context-menu-item';
li.setAttribute('role', 'menuitem');
if (item.disabled) li.classList.add('disabled');
const itemContent = document.createElement('div');
itemContent.className = 'pa-context-menu-item-content';
// Icon logic
let iconHtml = '';
if (typeof item.checked === 'boolean') {
const checkIcon = item.checked ? 'pi pi-check' : '';
iconHtml = `<span class="pa-context-menu-item-icon ${checkIcon}"></span>`;
} else if (item.icon) {
iconHtml = `<span class="pa-context-menu-item-icon ${item.icon}"></span>`;
} else {
iconHtml = '<span class="pa-context-menu-item-icon"></span>';
}
itemContent.innerHTML = `
${iconHtml}
<span class="pa-context-menu-item-label">${item.label}</span>
`;
li.appendChild(itemContent);
if (!item.disabled) {
li.addEventListener('click', (e) => {
e.stopPropagation();
if (item.command) {
item.command({ originalEvent: e, item });
}
closeMenu();
});
}
menuList.appendChild(li);
});
};
const updatePosition = () => {
if (!isOpen) return;
const rect = menuBtn.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
let left = containerRect.left;
let top = containerRect.bottom + 2;
// Edge detection could be added here similar to createTooltip
menuPanel.style.top = `${top}px`;
menuPanel.style.left = `${left}px`;
// menuPanel.style.minWidth = `${containerRect.width}px`;
};
const closeMenu = () => {
if (!isOpen) return;
isOpen = false;
menuPanel.classList.remove('pa-context-menu-show');
menuPanel.classList.add('pa-context-menu-hide');
setTimeout(() => {
menuPanel.style.display = 'none';
menuPanel.classList.remove('pa-context-menu-hide');
}, 150);
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
document.removeEventListener('click', outsideClickListener);
};
const openMenu = () => {
if (isOpen) {
closeMenu();
return;
}
isOpen = true;
renderMenu(); // Render fresh on open to catch state changes
menuPanel.style.display = 'block';
menuPanel.classList.add('pa-context-menu-show');
updatePosition();
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
setTimeout(() => document.addEventListener('click', outsideClickListener), 0);
};
const outsideClickListener = (e) => {
if (!menuPanel.contains(e.target) && !menuBtn.contains(e.target) && !container.contains(e.target)) {
closeMenu();
}
};
menuBtn.addEventListener('click', (e) => {
e.stopPropagation();
openMenu();
});
return {
container,
updateLabel: (text) => renderMainButton(text, mainKey.dataset.icon),
updateIcon: (newIcon, spinning) => renderMainButton(mainKey.dataset.label, newIcon, spinning),
updateMainButton: (text, newIcon, spinning) => renderMainButton(text, newIcon, spinning),
updateMenu: (newItems) => {
currentItems = newItems;
if (isOpen) renderMenu();
},
setMainButtonDisabled: (disabled) => { mainKey.disabled = disabled; },
setMenuButtonDisabled: (disabled) => { menuBtn.disabled = disabled; },
destroy: () => {
// Cleanup if needed
if (menuPanel && menuPanel.parentNode) menuPanel.parentNode.removeChild(menuPanel);
// Remove listeners... (handled by closure mostly, but good practice if needed)
}
};
}
/**
* 创建 SelectButton 组件 (参考 PrimeVue SelectButton)
* 支持单选、多选、切换三种模式
* @param {string} label 标签文本
* @param {Array<Object>} options 选项数组,每个选项包含 { value: string, label: string, icon?: string, disabled?: boolean }
* @param {string|Array<string>} initialValue 初始值,单选为字符串,多选为数组
* @param {Object} config 配置选项
* @param {string} config.mode 模式:'single'(单选), 'multiple'(多选), 'toggle'(切换,相当于开关)
* @param {string} config.size 尺寸:'small', 'normal', 'large'
* @param {boolean} config.allowEmpty 是否允许全部不选(仅多选模式有效)
* @param {Function} config.onChange 值变化回调函数
* @returns {Object} { group: HTMLElement, getValue: Function, setValue: Function }
*/
export function createSelectButtonGroup(label, options, initialValue, config = {}) {
const {
mode = 'multiple',
size = 'normal',
allowEmpty = true,
onChange = null
} = config;
// ---创建容器---
const group = document.createElement('div');
group.className = 'pa-form-group pa-select-button-group';
// 标签
if (label) {
const labelEl = document.createElement('label');
labelEl.className = 'pa-form-label';
labelEl.textContent = label;
group.appendChild(labelEl);
}
// 按钮容器
const buttonContainer = document.createElement('div');
buttonContainer.className = `pa-select-button pa-select-button-${size}`;
// 当前选中值
let currentValue = mode === 'multiple'
? (Array.isArray(initialValue) ? [...initialValue] : [])
: initialValue;
// 渲染选项按钮
const renderButtons = () => {
buttonContainer.innerHTML = '';
options.forEach((option, index) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'pa-select-button-item';
// 判断是否选中
const isSelected = mode === 'multiple'
? currentValue.includes(option.value)
: currentValue === option.value;
if (isSelected) {
button.classList.add('pa-select-button-item-selected');
}
if (option.disabled) {
button.classList.add('pa-select-button-item-disabled');
button.disabled = true;
}
// 图标
if (option.icon) {
const icon = document.createElement('span');
icon.className = `pa-select-button-icon pi ${option.icon}`;
button.appendChild(icon);
}
// 标签
if (option.label) {
const labelSpan = document.createElement('span');
labelSpan.className = 'pa-select-button-label';
labelSpan.textContent = option.label;
button.appendChild(labelSpan);
}
// 点击事件
if (!option.disabled) {
button.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
handleClick(option.value);
});
}
buttonContainer.appendChild(button);
});
};
// 处理点击事件
const handleClick = (value) => {
const prevValue = mode === 'multiple' ? [...currentValue] : currentValue;
if (mode === 'multiple') {
// 多选模式:切换选中状态
const index = currentValue.indexOf(value);
if (index === -1) {
currentValue.push(value);
} else {
// 检查是否允许取消选择
if (allowEmpty || currentValue.length > 1) {
currentValue.splice(index, 1);
}
}
} else if (mode === 'toggle') {
// 切换模式:点击切换开关状态
currentValue = currentValue === value ? null : value;
} else {
// 单选模式:直接选中
currentValue = value;
}
// 重新渲染按钮
renderButtons();
// 触发回调
if (onChange) {
onChange(currentValue, prevValue);
}
};
// 初始渲染
renderButtons();
group.appendChild(buttonContainer);
// ---返回组件API---
return {
group,
getValue: () => mode === 'multiple' ? [...currentValue] : currentValue,
setValue: (newValue) => {
currentValue = mode === 'multiple'
? (Array.isArray(newValue) ? [...newValue] : [])
: newValue;
renderButtons();
},
setOptions: (newOptions) => {
options = newOptions;
renderButtons();
}
};
}