InfoLens / client /src /ts /utils /settingsMenuManager.ts
dqy08's picture
UI改进
0c4dc0a
/**
* 设置菜单管理器
*/
import * as d3 from 'd3';
import { AdminManager } from './adminManager';
import { showDialog, showAlertDialog } from '../ui/dialog';
import { TextAnalysisAPI } from '../api/GLTR_API';
import { tr } from '../lang/i18n-lite';
import type { ThemeManager } from '../ui/theme';
import type { LanguageManager } from '../ui/language';
import { createSettingsDropdown } from '../ui/settingsDropdown';
import { getTokenRenderStyle, setTokenRenderStyle, type TokenRenderStyle } from './tokenRenderStyle';
import { getSemanticAnalysisEnabled, setSemanticAnalysisEnabled } from './semanticAnalysisManager';
import { getDigitsMergeEnabled, setDigitsMergeEnabled } from './digitsMergeManager';
import { getForceNarrowScreen, setForceNarrowScreen, FORCE_NARROW_CHANGE_EVENT } from './responsive';
import { getSemanticMatchThreshold } from './semanticThresholdManager';
import { getInfoDensityRenderDisabled, setInfoDensityRenderDisabled } from './infoDensityRenderManager';
import { showVisitStatsDialog } from './visitStatsDialog';
import { showModelManageDialog } from './modelManageDialog';
export type SettingsMenuCallbacks = {
onMinimapToggle?: (enabled: boolean) => void;
onThemeChange?: () => void;
onLanguageToggle?: () => void;
onSemanticAnalysisToggle?: (enabled: boolean) => void;
};
export type SettingsMenuContext = 'common' | 'analysis';
export class SettingsMenuManager {
private settingsBtn: d3.Selection<Element, unknown, HTMLElement, any>;
private settingsMenu: d3.Selection<Element, unknown, HTMLElement, any>;
private adminModeBtn: d3.Selection<Element, unknown, HTMLElement, any>;
private modelManageBtn: d3.Selection<Element, unknown, HTMLElement, any>;
private visitStatsBtn: d3.Selection<Element, unknown, HTMLElement, any>;
private tokenRenderStyleDropdown: { updateCurrent: (v: TokenRenderStyle) => void } | null = null;
private minimapToggle: d3.Selection<HTMLInputElement, unknown, HTMLElement, any>;
private semanticAnalysisToggle: d3.Selection<HTMLInputElement, unknown, HTMLElement, any>;
private digitsMergeToggle: d3.Selection<HTMLInputElement, unknown, HTMLElement, any>;
private forceNarrowToggle: d3.Selection<HTMLInputElement, unknown, HTMLElement, any>;
private semanticThresholdInput: d3.Selection<HTMLInputElement, unknown, HTMLElement, any>;
private semanticThresholdItem: d3.Selection<HTMLElement, unknown, HTMLElement, any>;
private semanticSubmodeRow: d3.Selection<HTMLElement, unknown, HTMLElement, any>;
private disableInfoDensityToggle: d3.Selection<HTMLInputElement, unknown, HTMLElement, any>;
private themeDropdownContainer: d3.Selection<Element, unknown, HTMLElement, any>;
private adminManager: AdminManager;
private api: TextAnalysisAPI;
private onAdminStateChange?: () => void;
private callbacks: SettingsMenuCallbacks;
private themeManager?: ThemeManager;
private languageManager?: LanguageManager;
private readonly menuContext: SettingsMenuContext;
constructor(
settingsBtnSelector: string,
settingsMenuSelector: string,
adminModeBtnSelector: string,
adminManager: AdminManager,
api: TextAnalysisAPI,
onAdminStateChange?: () => void,
callbacks?: SettingsMenuCallbacks,
themeManager?: ThemeManager,
languageManager?: LanguageManager,
menuContext: SettingsMenuContext = 'analysis'
) {
this.menuContext = menuContext;
this.settingsBtn = d3.select(settingsBtnSelector);
this.settingsMenu = d3.select(settingsMenuSelector);
this.adminModeBtn = d3.select(adminModeBtnSelector);
this.modelManageBtn = d3.select('#model_manage_btn');
this.visitStatsBtn = d3.select('#visit_stats_btn');
this.tokenRenderStyleDropdown =
menuContext === 'analysis' ? this.initTokenRenderStyleDropdown() : null;
this.minimapToggle = d3.select<HTMLInputElement, any>('#enable_minimap_toggle');
this.semanticAnalysisToggle = d3.select<HTMLInputElement, any>('#semantic_analysis_toggle');
this.digitsMergeToggle = d3.select<HTMLInputElement, any>('#enable_digits_merge_toggle');
this.forceNarrowToggle = d3.select<HTMLInputElement, any>('#force_narrow_toggle');
this.semanticThresholdInput = d3.select<HTMLInputElement, any>('#semantic_threshold_input');
this.semanticThresholdItem = d3.select<HTMLElement, any>('#semantic_threshold_item');
this.semanticSubmodeRow = d3.select<HTMLElement, any>('#semantic_submode_row');
this.disableInfoDensityToggle = d3.select<HTMLInputElement, any>('#disable_info_density_toggle');
this.themeDropdownContainer = d3.select('#theme_dropdown');
this.adminManager = adminManager;
this.api = api;
this.onAdminStateChange = onAdminStateChange;
this.callbacks = callbacks || {};
this.themeManager = themeManager;
this.languageManager = languageManager;
this.initialize();
}
private initialize(): void {
// 点击齿轮按钮切换菜单
this.settingsBtn.on('click', (event: MouseEvent) => {
event.stopPropagation();
this.toggleMenu();
});
// 点击页面其他区域关闭菜单
d3.select('body').on('click.settings-menu', () => {
this.closeMenu();
});
// 阻止菜单内部点击关闭菜单
this.settingsMenu.on('click', (event: MouseEvent) => {
event.stopPropagation();
});
if (this.minimapToggle.node()) {
this.minimapToggle.on('change', () => {
const enabled = (this.minimapToggle.node() as HTMLInputElement)?.checked || false;
if (this.callbacks.onMinimapToggle) {
this.callbacks.onMinimapToggle(enabled);
}
});
}
if (this.digitsMergeToggle.node()) {
this.digitsMergeToggle.on('change', () => {
const enabled = (this.digitsMergeToggle.node() as HTMLInputElement)?.checked ?? false;
setDigitsMergeEnabled(enabled);
});
}
if (this.forceNarrowToggle.node()) {
this.forceNarrowToggle.on('change', () => {
const enabled = (this.forceNarrowToggle.node() as HTMLInputElement)?.checked ?? false;
setForceNarrowScreen(enabled);
});
// 跨标签:其他标签更改时同步 checkbox 视觉状态
window.addEventListener(FORCE_NARROW_CHANGE_EVENT, () => {
this.setCheckboxChecked(this.forceNarrowToggle, getForceNarrowScreen());
});
}
if (this.menuContext === 'analysis' && this.semanticAnalysisToggle.node()) {
this.semanticAnalysisToggle.on('change', () => {
const enabled = (this.semanticAnalysisToggle.node() as HTMLInputElement)?.checked || false;
setSemanticAnalysisEnabled(enabled);
this.updateSemanticThresholdVisibility();
this.updateSemanticSubmodeRowVisibility();
setInfoDensityRenderDisabled(enabled);
this.setDisableInfoDensity(enabled);
window.dispatchEvent(new CustomEvent('info-density-render-change'));
if (this.callbacks.onSemanticAnalysisToggle) {
this.callbacks.onSemanticAnalysisToggle(enabled);
}
});
}
if (this.menuContext === 'analysis' && this.disableInfoDensityToggle.node()) {
this.disableInfoDensityToggle.on('change', () => {
const disabled = (this.disableInfoDensityToggle.node() as HTMLInputElement)?.checked || false;
setInfoDensityRenderDisabled(disabled);
window.dispatchEvent(new CustomEvent('info-density-render-change'));
});
}
// Language dropdown - 由 languageManager 初始化,这里只需要确保容器存在
// 语言切换逻辑在 language.ts 中处理
// Theme dropdown - 由 themeManager 初始化,这里只需要确保容器存在
// 主题切换逻辑在 theme.ts 中处理
// 管理员模式入口(录入 token / 退出)
if (this.adminModeBtn.node()) {
this.adminModeBtn.on('click', () => {
this.closeMenu();
this.handleAdminModeClick();
});
}
if (this.modelManageBtn.node()) {
this.modelManageBtn.on('click', () => {
this.closeMenu();
void showModelManageDialog(this.api);
});
}
if (this.visitStatsBtn.node()) {
this.visitStatsBtn.on('click', () => {
this.closeMenu();
void showVisitStatsDialog(this.api);
});
}
if (this.menuContext === 'analysis' && this.semanticAnalysisToggle.node()) {
this.setSemanticAnalysisEnabled(getSemanticAnalysisEnabled());
}
this.setDigitsMergeCheckbox(getDigitsMergeEnabled());
this.setCheckboxChecked(this.forceNarrowToggle, getForceNarrowScreen());
if (this.menuContext === 'analysis' && this.semanticThresholdInput.node()) {
this.setSemanticThresholdValue(getSemanticMatchThreshold());
}
if (this.menuContext === 'analysis' && this.disableInfoDensityToggle.node()) {
this.setDisableInfoDensity(getInfoDensityRenderDisabled());
}
this.applyAdminUiState();
}
private initTokenRenderStyleDropdown(): { updateCurrent: (v: TokenRenderStyle) => void } {
const container = d3.select('#token_render_style_dropdown');
if (!container.node()) {
throw new Error('initTokenRenderStyleDropdown: #token_render_style_dropdown missing on analysis page');
}
const options: Array<{ value: TokenRenderStyle; label: string }> = [
{ value: 'classic', label: 'Classic' },
{ value: 'density', label: 'Density' },
];
const dropdown = createSettingsDropdown<TokenRenderStyle>({
container,
classPrefix: 'token-render-style',
options: options.map((o) => ({ value: o.value, html: `<span>${o.label}</span>` })),
dataAttr: 'data-style',
bodyClickNamespace: 'token-render-style-dropdown',
onSelect: (v) => {
setTokenRenderStyle(v);
dropdown.updateCurrent(v);
window.dispatchEvent(new CustomEvent('token-render-style-change'));
},
});
dropdown.updateCurrent(getTokenRenderStyle());
return dropdown;
}
private closeMenu(): void {
this.settingsMenu.style('display', 'none');
}
private toggleMenu(): void {
const cur = this.settingsMenu.style('display');
this.settingsMenu.style('display', cur === 'none' || cur === '' ? 'block' : 'none');
}
/**
* 根据管理员模式更新菜单项文案
*/
public applyAdminUiState(): void {
const isAdmin = this.adminManager.isInAdminMode();
this.adminModeBtn.text(isAdmin ? 'Exit' : 'Enter');
this.adminModeBtn.classed('active', isAdmin);
// 显示/隐藏所有带 data-admin-only 的菜单项
this.settingsMenu.selectAll<HTMLElement, unknown>('.settings-menu-item[data-admin-only]')
.style('display', isAdmin ? null : 'none');
this.tokenRenderStyleDropdown?.updateCurrent(getTokenRenderStyle());
if (this.menuContext === 'analysis' && this.semanticAnalysisToggle.node()) {
this.setSemanticAnalysisEnabled(getSemanticAnalysisEnabled());
}
this.setDigitsMergeCheckbox(getDigitsMergeEnabled());
this.setCheckboxChecked(this.forceNarrowToggle, getForceNarrowScreen());
if (this.menuContext === 'analysis' && this.semanticThresholdInput.node()) {
this.setSemanticThresholdValue(getSemanticMatchThreshold());
this.updateSemanticThresholdVisibility();
}
if (this.menuContext === 'analysis' && this.disableInfoDensityToggle.node()) {
this.setDisableInfoDensity(getInfoDensityRenderDisabled());
}
// 通知外部更新 UI
if (this.onAdminStateChange) {
this.onAdminStateChange();
}
}
/**
* 设置 minimap 的初始状态
*/
public setMinimapEnabled(enabled: boolean): void {
const checkbox = this.minimapToggle.node() as HTMLInputElement | null;
if (checkbox) {
checkbox.checked = enabled;
}
}
/**
* 设置 semantic analysis 的初始状态
*/
public setSemanticAnalysisEnabled(enabled: boolean): void {
const checkbox = this.semanticAnalysisToggle.node() as HTMLInputElement | null;
if (checkbox) {
checkbox.checked = enabled;
}
}
public setDigitsMergeCheckbox(checked: boolean): void {
this.setCheckboxChecked(this.digitsMergeToggle, checked);
}
private setCheckboxChecked(
sel: d3.Selection<HTMLInputElement, unknown, HTMLElement, any>,
checked: boolean
): void {
const el = sel.node() as HTMLInputElement | null;
if (el) el.checked = checked;
}
private updateSemanticThresholdVisibility(): void {
if (!this.semanticThresholdItem.node()) return;
const isAdmin = this.adminManager.isInAdminMode();
const semanticOn = getSemanticAnalysisEnabled();
this.semanticThresholdItem.style('display', isAdmin && semanticOn ? null : 'none');
}
private updateSemanticSubmodeRowVisibility(): void {
if (!this.semanticSubmodeRow.node()) return;
const isAdmin = this.adminManager.isInAdminMode();
this.semanticSubmodeRow.style('display', isAdmin ? null : 'none');
}
private setSemanticThresholdValue(value: number): void {
const input = this.semanticThresholdInput.node() as HTMLInputElement | null;
if (input) {
input.value = String(value);
}
}
/**
* 设置 disable info density 的初始状态
*/
public setDisableInfoDensity(disabled: boolean): void {
const checkbox = this.disableInfoDensityToggle.node() as HTMLInputElement | null;
if (checkbox) {
checkbox.checked = disabled;
}
}
private handleAdminModeClick(): void {
if (this.adminManager.isInAdminMode()) {
this.adminManager.clearAdminTokenAndNotify();
// 刷新页面以让 demoManager 等基于配置的模块重新初始化
window.location.reload();
return;
}
showDialog({
title: 'Admin Mode',
content: (dialog) => {
const container = dialog.append('div').attr('class', 'dialog-form-container');
container.append('label')
.attr('class', 'dialog-label')
.text('Please enter admin token:');
const input = container.append('input')
.attr('type', 'password')
.attr('class', 'dialog-input')
.attr('placeholder', 'INFORADAR_ADMIN_TOKEN');
return {
getValue: () => (input.node() as HTMLInputElement | null)?.value?.trim() || '',
validate: () => ((input.node() as HTMLInputElement | null)?.value?.trim() || '').length > 0,
focus: () => {
const n = input.node() as HTMLInputElement | null;
if (n) n.focus();
}
};
},
onConfirm: async (token: string) => {
const { success, message } = await this.adminManager.setAdminTokenAndNotify(token);
if (!success) {
showAlertDialog(tr('Error'), message || 'Admin token verification failed.');
return;
}
// 注入到 API,随后刷新页面以启用文件夹操作等(初始化期配置)
this.api.setAdminToken(this.adminManager.getAdminToken());
window.location.reload();
},
onCancel: () => {},
confirmText: 'Enter',
cancelText: tr('Cancel'),
width: 'clamp(300px, 90vw, 420px)'
});
}
}