InfoLens / client /src /ts /ui /dialog.ts
dqy08's picture
访问统计持久化;修复LMF高度回退问题
c4753aa
/**
* 通用弹框组件
* 提供统一的弹框样式和行为
*/
import * as d3 from 'd3';
import { countTokenCharacters } from '../utils/Util';
import { tr } from '../lang/i18n-lite';
export type DialogContentBuilder = (
dialog: d3.Selection<HTMLDivElement, unknown, any, any>,
setConfirmButtonState?: (enabled: boolean) => void
) => {
getValue?: () => any;
validate?: () => boolean;
focus?: () => void;
};
export interface DialogOptions {
title: string;
content: DialogContentBuilder;
/** 确定按钮存在时点击会调用;省略时等价于未定义返回值(仍会在用户点击确定后关闭弹窗) */
onConfirm?: (value: any) => boolean | void | Promise<boolean | void>;
onCancel?: () => void;
/** null 表示不显示确定按钮 */
confirmText?: string | null;
cancelText?: string | null; // undefined/null 表示不显示取消按钮
/** 确定按钮处于加载/排队状态时显示的文案,默认「排队中...」 */
loadingConfirmText?: string;
width?: string;
height?: string;
}
export type ConfirmButtonState = 'normal' | 'disabled' | 'queuing';
/**
* 显示通用弹框
* @returns 返回包含确定按钮状态更新函数的对象
*/
export function showDialog(options: DialogOptions): {
setConfirmButtonState: (enabled: boolean, queuing?: boolean) => void;
} {
const {
title,
content,
onConfirm: onConfirmUser,
onCancel,
confirmText = tr('Confirm'),
cancelText, // 不设置默认值,undefined/null 表示不显示取消按钮
loadingConfirmText = tr('Queuing...'),
width = 'clamp(300px, 90vw, 500px)',
height
} = options;
const onConfirm = onConfirmUser ?? (() => undefined);
// 创建遮罩层
// 简单直接:上下各多500px余量,确保完全覆盖屏幕
const overlay = d3.select('body').append('div')
.attr('class', 'dialog-overlay');
// 无需监听视口变化,简单直接
const cleanup = () => {
// 无需清理
};
// 创建对话框
const dialog = overlay.append('div')
.attr('class', 'dialog')
.style('width', width);
// 如果提供了高度设置,应用它
if (height) {
dialog.style('height', height);
}
// 标题
dialog.append('div')
.attr('class', 'dialog-title')
.text(title);
// 内容区域
const contentArea = dialog.append('div')
.attr('class', 'dialog-content');
// 按钮容器(先创建,以便在 content 回调中可以使用 setConfirmButtonState)
const buttonContainer = dialog.append('div')
.attr('class', 'dialog-buttons');
// 取消按钮(如果提供了 cancelText)
if (cancelText !== undefined && cancelText !== null) {
const cancelBtn = buttonContainer.append('button')
.attr('class', 'dialog-button cancel')
.text(cancelText)
.on('click', () => {
cleanup();
overlay.remove();
if (onCancel) {
onCancel();
}
});
}
const confirmBtn = confirmText != null
? buttonContainer.append('button')
.attr('class', 'dialog-button confirm')
.text(confirmText)
: null;
// 保存原始按钮文本,确保不依赖按钮内容
const originalButtonText = confirmText ?? '';
// 创建确定按钮状态更新函数(在 content 回调之前创建,避免闭包问题)
// 使用 data 属性来存储状态,不依赖文本内容
const setConfirmButtonState = (enabled: boolean, queuing: boolean = false) => {
const btnNode = confirmBtn?.node() as HTMLButtonElement | null;
if (btnNode) {
btnNode.disabled = !enabled || queuing;
if (queuing) {
btnNode.setAttribute('data-state', 'queuing');
confirmBtn!.classed('queuing', true);
btnNode.innerHTML = `
<span class="queuing-text">${loadingConfirmText}</span>
<span class="queuing-spinner"></span>
`;
} else {
btnNode.setAttribute('data-state', enabled ? 'enabled' : 'disabled');
confirmBtn!.classed('queuing', false);
btnNode.textContent = originalButtonText;
}
}
};
// 构建内容,传递 setConfirmButtonState 函数
const contentControls = content(contentArea, setConfirmButtonState);
// 设置确定按钮的点击事件(需要在 contentControls 创建之后)
confirmBtn?.on('click', async () => {
// 验证
if (contentControls.validate && !contentControls.validate()) {
return;
}
// 检查是否处于排队状态
const btnNode = confirmBtn?.node() as HTMLButtonElement | null;
if (btnNode && btnNode.getAttribute('data-state') === 'queuing') {
return; // 排队中,不处理点击
}
// 获取值
const value = contentControls.getValue ? contentControls.getValue() : undefined;
// 注意:如果 onConfirm 中需要保持弹窗打开(如排队场景),则不应在这里关闭
// 由 onConfirm 回调决定是否关闭弹窗
const shouldClose = await onConfirm(value);
// 如果 onConfirm 返回 false,表示需要保持弹窗打开(排队场景)
if (shouldClose !== false) {
cleanup();
overlay.remove();
}
});
// 阻止点击遮罩层关闭,只能通过按钮关闭
// 阻止对话框内的点击事件冒泡到遮罩层
dialog.on('click', function(event) {
event.stopPropagation();
});
// ESC 键关闭(排除中文输入法组合状态)
const escHandler = (e: KeyboardEvent) => {
// 如果正在使用输入法组合,则忽略
if (e.isComposing) {
return;
}
if (e.key === 'Escape') {
cleanup();
document.removeEventListener('keydown', escHandler);
overlay.remove();
if (onCancel) {
onCancel();
}
}
};
document.addEventListener('keydown', escHandler);
// 自动聚焦
if (contentControls.focus) {
contentControls.focus();
}
// 返回弹窗控制对象,提供确定按钮状态更新功能
return {
setConfirmButtonState
};
}
/**
* 创建输入框内容构建器
*/
export function createInputContent(
label: string,
defaultValue: string = '',
placeholder?: string
): DialogContentBuilder {
return (dialog: d3.Selection<HTMLDivElement, unknown, null, undefined>, setConfirmButtonState?) => {
const container = dialog.append('div')
.attr('class', 'dialog-form-container');
container.append('label')
.attr('class', 'dialog-label')
.text(label);
const input = container.append('input')
.attr('type', 'text')
.attr('class', 'dialog-input')
.attr('value', defaultValue)
.attr('placeholder', placeholder || '');
// 回车键确认(排除中文输入法组合状态)
input.on('keydown', function(event) {
const keyboardEvent = event as KeyboardEvent;
// 如果正在使用输入法组合,则忽略
if (keyboardEvent.isComposing) {
return;
}
if (keyboardEvent.key === 'Enter') {
const inputNode = input.node() as HTMLInputElement;
const value = inputNode?.value?.trim() || '';
if (value) {
const dialogElement = dialog.node()?.closest('.dialog');
if (dialogElement) {
const confirmBtn = dialogElement.querySelector('.dialog-button.confirm') as HTMLButtonElement;
if (confirmBtn && !confirmBtn.disabled) {
confirmBtn.click();
}
}
}
}
});
return {
getValue: () => {
const inputNode = input.node() as HTMLInputElement;
return inputNode?.value?.trim() || '';
},
validate: () => {
const inputNode = input.node() as HTMLInputElement;
return (inputNode?.value?.trim() || '').length > 0;
},
focus: () => {
const inputNode = input.node() as HTMLInputElement;
if (inputNode) {
inputNode.focus();
inputNode.select();
}
}
};
};
}
/**
* 创建下拉选择框内容构建器
*/
export function createSelectContent(
label: string,
options: Array<{ value: string; text: string }>,
defaultValue?: string
): DialogContentBuilder {
return (dialog: d3.Selection<HTMLDivElement, unknown, null, undefined>, setConfirmButtonState?) => {
const container = dialog.append('div')
.attr('class', 'dialog-form-container');
container.append('label')
.attr('class', 'dialog-label')
.text(label);
const select = container.append('select')
.attr('class', 'dialog-select folder-select');
// 添加选项
select.selectAll('option')
.data(options)
.join('option')
.attr('value', d => d.value)
.text(d => d.text);
// 设置默认值
if (defaultValue !== undefined) {
select.property('value', defaultValue);
} else if (options.length > 0) {
select.property('value', options[0].value);
}
return {
getValue: () => {
return select.property('value') || '';
},
validate: () => {
return select.property('value') !== '';
}
};
};
}
/**
* 创建确认弹框内容构建器(只显示文本,不需要输入)
*/
export function createConfirmContent(message: string): DialogContentBuilder {
return (dialog: d3.Selection<HTMLDivElement, unknown, any, any>, setConfirmButtonState?) => {
dialog.append('div')
.attr('class', 'dialog-message')
.text(message);
return {
getValue: () => true,
validate: () => true
};
};
}
/**
* 显示确认弹框(用于删除等危险操作)
*/
export function showConfirmDialog(
title: string,
message: string,
onConfirm: () => void,
onCancel?: () => void,
confirmText: string = tr('Confirm'),
cancelText: string = tr('Cancel')
): void {
showDialog({
title,
content: createConfirmContent(message),
onConfirm: () => {
onConfirm();
},
onCancel,
confirmText,
cancelText
});
}
/**
* 显示提示弹框(用于信息提示,只有一个确定按钮)
*/
export function showAlertDialog(
title: string,
message: string,
onClose?: () => void
): void {
showDialog({
title,
content: createConfirmContent(message),
onConfirm: () => {
if (onClose) {
onClose();
}
},
confirmText: tr('OK'),
cancelText: undefined // 不显示取消按钮
});
}
/**
* 创建组合内容构建器(输入框 + 下拉框)
*/
export function createCombinedContent(
inputLabel: string,
inputDefaultValue: string,
selectLabel: string,
selectOptions: Array<{ value: string; text: string }>,
selectDefaultValue?: string
): DialogContentBuilder {
return (dialog: d3.Selection<HTMLDivElement, unknown, null, undefined>, setConfirmButtonState?) => {
// 输入框
const inputContainer = dialog.append('div')
.attr('class', 'dialog-form-container');
inputContainer.append('label')
.attr('class', 'dialog-label')
.text(inputLabel);
const input = inputContainer.append('input')
.attr('type', 'text')
.attr('class', 'dialog-input')
.attr('value', inputDefaultValue);
// 下拉框
const selectContainer = dialog.append('div')
.attr('class', 'dialog-form-container');
selectContainer.append('label')
.attr('class', 'dialog-label')
.text(selectLabel);
const select = selectContainer.append('select')
.attr('class', 'dialog-select folder-select');
// 添加选项
select.selectAll('option')
.data(selectOptions)
.join('option')
.attr('value', d => d.value)
.text(d => d.text);
// 设置默认值
if (selectDefaultValue !== undefined) {
select.property('value', selectDefaultValue);
} else if (selectOptions.length > 0) {
select.property('value', selectOptions[0].value);
}
// 回车键确认(排除中文输入法组合状态)
input.on('keydown', function(event) {
const keyboardEvent = event as KeyboardEvent;
// 如果正在使用输入法组合,则忽略
if (keyboardEvent.isComposing) {
return;
}
if (keyboardEvent.key === 'Enter') {
const inputNode = input.node() as HTMLInputElement;
const value = inputNode?.value?.trim() || '';
if (value) {
const dialogElement = dialog.node()?.closest('.dialog');
if (dialogElement) {
const confirmBtn = dialogElement.querySelector('.dialog-button.confirm') as HTMLButtonElement;
if (confirmBtn && !confirmBtn.disabled) {
confirmBtn.click();
}
}
}
}
});
return {
getValue: () => {
const inputNode = input.node() as HTMLInputElement;
return {
input: inputNode?.value?.trim() || '',
select: select.property('value') || ''
};
},
validate: () => {
const inputNode = input.node() as HTMLInputElement;
return (inputNode?.value?.trim() || '').length > 0;
},
focus: () => {
const inputNode = input.node() as HTMLInputElement;
if (inputNode) {
inputNode.focus();
inputNode.select();
}
}
};
};
}
/**
* 创建“名称 + 目录 + 文本”复合内容(用于 Analyze&Save)
*/
export function createNamePathTextContent(
inputLabel: string,
inputDefaultValue: string,
selectLabel: string,
selectOptions: Array<{ value: string; text: string }>,
selectDefaultValue: string,
textLabel: string,
textDefaultValue: string
): DialogContentBuilder {
return (dialog: d3.Selection<HTMLDivElement, unknown, null, undefined>, setConfirmButtonState?) => {
// 名称输入
const inputContainer = dialog.append('div')
.attr('class', 'dialog-form-container');
inputContainer.append('label')
.attr('class', 'dialog-label')
.text(inputLabel);
const input = inputContainer.append('input')
.attr('type', 'text')
.attr('class', 'dialog-input')
.attr('value', inputDefaultValue);
// 目录选择
const selectContainer = dialog.append('div')
.attr('class', 'dialog-form-container');
selectContainer.append('label')
.attr('class', 'dialog-label')
.text(selectLabel);
const select = selectContainer.append('select')
.attr('class', 'dialog-select folder-select');
select.selectAll('option')
.data(selectOptions)
.join('option')
.attr('value', d => d.value)
.text(d => d.text);
select.property('value', selectDefaultValue || (selectOptions[0]?.value ?? '/'));
// 文本预览/编辑
const textContainer = dialog.append('div')
.attr('class', 'dialog-form-container');
// 标签和字数显示容器(同一行,左侧标签,右侧字数)
const labelContainer = textContainer.append('div')
.attr('class', 'dialog-label-container');
labelContainer.append('label')
.attr('class', 'dialog-label')
.text(textLabel);
// 字数显示(参照原有文本输入框的实现,放在标签右侧)
const textCountDisplay = labelContainer.append('div')
.attr('class', 'dialog-textarea-counter');
const textarea = textContainer.append('textarea')
.attr('class', 'dialog-textarea')
.attr('rows', 6)
.text(textDefaultValue || '');
// 更新字数显示的函数
const updateTextCount = () => {
const textNode = textarea.node() as HTMLTextAreaElement;
const textValue = textNode?.value || '';
const charCount = countTokenCharacters(textValue);
textCountDisplay.text(`${charCount} 字`);
};
// 监听textarea的input事件,实时更新字数
textarea.on('input', updateTextCount);
// 初始化时显示字数
updateTextCount();
// 回车键确认(排除中文输入法组合状态)
input.on('keydown', function(event) {
const keyboardEvent = event as KeyboardEvent;
// 如果正在使用输入法组合,则忽略
if (keyboardEvent.isComposing) {
return;
}
if (keyboardEvent.key === 'Enter') {
const inputNode = input.node() as HTMLInputElement;
const value = inputNode?.value?.trim() || '';
if (value) {
const dialogElement = dialog.node()?.closest('.dialog');
if (dialogElement) {
const confirmBtn = dialogElement.querySelector('.dialog-button.confirm') as HTMLButtonElement;
if (confirmBtn && !confirmBtn.disabled) {
confirmBtn.click();
}
}
}
}
});
return {
getValue: () => {
const inputNode = input.node() as HTMLInputElement;
const textNode = textarea.node() as HTMLTextAreaElement;
return {
input: inputNode?.value?.trim() || '',
select: select.property('value') || '',
text: textNode?.value ?? ''
};
},
validate: () => {
const inputNode = input.node() as HTMLInputElement;
return (inputNode?.value?.trim() || '').length > 0;
},
focus: () => {
const inputNode = input.node() as HTMLInputElement;
if (inputNode) {
inputNode.focus();
inputNode.select();
}
}
};
};
}
/**
* 创建 URL 输入弹窗内容(URL 输入框)
*/
export function createUrlInputContent(
label: string,
defaultValue: string = '',
placeholder?: string
): DialogContentBuilder {
return (dialog: d3.Selection<HTMLDivElement, unknown, null, undefined>, setConfirmButtonState?) => {
const container = dialog.append('div')
.attr('class', 'dialog-form-container');
container.append('label')
.attr('class', 'dialog-label')
.text(label);
const input = container.append('input')
.attr('type', 'url')
.attr('class', 'dialog-input')
.attr('value', defaultValue)
.attr('placeholder', placeholder || 'https://example.com');
// 回车键确认(排除中文输入法组合状态)
input.on('keydown', function(event) {
const keyboardEvent = event as KeyboardEvent;
// 如果正在使用输入法组合,则忽略
if (keyboardEvent.isComposing) {
return;
}
if (keyboardEvent.key === 'Enter') {
const inputNode = input.node() as HTMLInputElement;
const value = inputNode?.value?.trim() || '';
if (value) {
const dialogElement = dialog.node()?.closest('.dialog');
if (dialogElement) {
const confirmBtn = dialogElement.querySelector('.dialog-button.confirm') as HTMLButtonElement;
if (confirmBtn && !confirmBtn.disabled) {
confirmBtn.click();
}
}
}
}
});
return {
getValue: () => {
const inputNode = input.node() as HTMLInputElement;
return inputNode?.value?.trim() || '';
},
validate: () => {
const inputNode = input.node() as HTMLInputElement;
const value = inputNode?.value?.trim() || '';
// 基本的 URL 验证
if (value.length === 0) {
return false;
}
try {
new URL(value);
return true;
} catch {
return false;
}
},
focus: () => {
const inputNode = input.node() as HTMLInputElement;
if (inputNode) {
inputNode.focus();
inputNode.select();
}
}
};
};
}