InfoLens / client /src /ts /ui /demoManager.ts
dqy08's picture
initial beta release
494c9e4
import * as d3 from 'd3';
import { AnalysisData, TextAnalysisAPI } from '../api/GLTR_API';
import { createPathNavigator, PathNavigator } from './pathNavigator';
import { createMenuButton } from './itemMenu';
import { showMoveDialog, showRenameDialog, showDeleteConfirm, showCreateFolderDialog } from './folderOperations';
import { showAlertDialog } from './dialog';
import { createToast } from './toast';
// 国际化
import { tr } from '../lang/i18n-lite';
import URLHandler from '../utils/URLHandler';
import { createMultiSelect, type MultiSelect } from './demoMultiSelect';
import { normalizeFullPath } from '../utils/pathUtils';
import { isValidDemoFormat } from '../utils/localFileUtils';
import { ServerStorage } from '../storage/demoStorage';
import { DemoStorageController } from '../controllers/demoStorageController';
export type DemoManagerOptions = {
api: TextAnalysisAPI;
enableDemo: boolean;
containerSelector: string;
loaderSelector: string;
refreshSelector: string;
onDemoLoaded: (data: AnalysisData, disableAnimation: boolean, isNewDemo?: boolean, path?: string) => void;
onTextPrefill?: (text: string) => void;
onDemoLoading?: (loading: boolean) => void;
onRefreshStart?: () => void;
onRefreshEnd?: () => void;
forceMultiSelect?: boolean; // 强制启用多选模式(只读选择场景),默认为 false
disableFolderOperations?: boolean; // 禁用文件夹操作(新建、移动、重命名、删除),默认为 false
disableClickLoad?: boolean; // 禁用单击加载(多选模式下,文件按钮点击只切换复选框),默认为 false
onSelectionChange?: (selectedCount: number) => void; // 选择数量变化时的回调
};
export type DemoManager = {
refresh: () => Promise<void>;
highlightDemo: (fullPath: string | null) => void;
navigateToDemoAndHighlight: (fullPath: string) => Promise<void>; // 导航到demo所在文件夹并高亮(不加载数据)
loadDemoByPath: (fullPath: string) => Promise<boolean>;
getSelectedPaths: () => string[]; // 获取选中的demo路径
};
type DemoItem = {
type: 'folder' | 'file';
name: string;
path: string;
};
/**
* @deprecated 已迁移到 isValidDemoFormat,请使用 utils/localFileUtils 中的函数
* 保留此导出以保持向后兼容,但新代码应使用 isValidDemoFormat
*/
export { isValidDemoFormat as isValidAnalyzeResponse } from '../utils/localFileUtils';
export function initDemoManager(options: DemoManagerOptions): DemoManager {
const {
api,
enableDemo,
containerSelector,
loaderSelector,
refreshSelector,
onDemoLoaded,
onTextPrefill,
onDemoLoading,
onRefreshStart,
onRefreshEnd,
forceMultiSelect = false,
disableFolderOperations = false,
disableClickLoad = false,
onSelectionChange,
} = options;
if (!enableDemo) {
d3.selectAll('.demo').remove();
return {
refresh: () => Promise.resolve(),
highlightDemo: () => {},
navigateToDemoAndHighlight: () => Promise.resolve(),
loadDemoByPath: () => Promise.resolve(false),
getSelectedPaths: () => [],
};
}
const container = d3.select(containerSelector);
const loader = d3.select(loaderSelector);
const refreshBtn = d3.select(refreshSelector);
// 当前路径状态(统一使用 "/" 开头的格式,"/" 表示根目录)
let currentPath: string = '/';
let pathNavigator: PathNavigator | null = null;
// 创建路径导航容器(在demo列表上方)
let pathNavContainer: d3.Selection<HTMLDivElement, any, any, any> | null = null;
const containerNode = container.node() as HTMLElement | null;
if (containerNode && containerNode.parentElement) {
pathNavContainer = d3.select(containerNode.parentElement)
.insert('div', () => containerNode)
.attr('class', 'demo-path-nav-container');
}
// 初始化路径导航器
if (pathNavContainer) {
pathNavigator = createPathNavigator(
pathNavContainer,
currentPath,
(newPath: string) => {
currentPath = newPath;
pathNavigator?.update(newPath);
fetchDemoList().catch(err => {
console.error('刷新demo列表失败:', err);
});
},
disableFolderOperations ? undefined : () => {
// 新建文件夹(如果未禁用)
showCreateFolderDialog(async (folderName: string) => {
try {
setListLoading(true);
const result = await api.create_folder(currentPath, folderName);
if (result.success) {
await fetchDemoList();
} else {
showAlertDialog(tr('Error'), tr(result.message || 'Failed to create folder'));
}
} catch (err) {
console.error('创建文件夹失败:', err);
showAlertDialog(tr('Error'), tr('Failed to create folder, please check console for details.'));
} finally {
setListLoading(false);
}
});
}
);
}
// 使用统一的路径规范化函数
let activeDemoFullPath: string | null = null;
let lastLoadedDemoPath: string | null = null; // 记录上次加载的demo路径
const applyActiveState = () => {
const buttons = container.selectAll<HTMLDivElement, DemoItem>('.demoBtn, .demo-folder-btn');
buttons.classed('demo-selected', d => {
return d.type === 'file' && normalizeFullPath(d.path) === activeDemoFullPath;
});
// 滚动到选中的demo项(使用原生 scrollIntoView)
if (activeDemoFullPath) {
const selectedButton = buttons.filter(d => {
return d.type === 'file' && normalizeFullPath(d.path) === activeDemoFullPath;
});
if (!selectedButton.empty()) {
const buttonNode = selectedButton.node() as HTMLElement | null;
if (buttonNode) {
// 使用 requestAnimationFrame 确保DOM已更新(包括选中状态的样式)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
// 使用原生 scrollIntoView 方法,浏览器会自动处理滚动
// block: 'nearest' - 只在必要时垂直滚动,如果已在可视区域则不滚动
// inline: 'nearest' - 只在必要时水平滚动,如果已在可视区域则不滚动
buttonNode.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'nearest'
});
});
});
}
}
}
};
const getItemFullPath = (item: DemoItem): string | null => {
return normalizeFullPath(item.path);
};
// 创建多选模块
let multiSelect: MultiSelect | null = null;
const toastController = createToast('#toast');
const showToast = toastController.show;
// 延迟初始化多选模块,因为需要先创建 pathNavigator
const initMultiSelect = () => {
if (multiSelect) return; // 已经初始化
if (!pathNavContainer) return;
multiSelect = createMultiSelect({
container,
api,
getItemFullPath,
getCurrentPath: () => currentPath,
setLoading: setListLoading,
fetchDemoList,
showToast,
showAlertDialog,
onModeChange: () => {
// 当模式切换时,重新渲染列表
const items = container.selectAll<HTMLDivElement, DemoItem>('.demo-item').data();
renderItems(items);
},
initialMultiSelectMode: forceMultiSelect, // 传递强制多选配置
disableModeToggle: forceMultiSelect, // 如果强制多选,则禁用模式切换
onSelectionChange, // 传递选择变化回调(当用户选择/取消选择demo时触发)
});
// 初始化 UI(控制栏和切换按钮)
// 在 forceMultiSelect 模式下也需要控制栏(只显示全选和清空按钮)
const navWrapper = pathNavContainer.select('.demo-path-nav-wrapper');
if (!navWrapper.empty()) {
multiSelect.initUI(navWrapper);
}
};
const highlightDemo = (fullPath: string | null) => {
activeDemoFullPath = normalizeFullPath(fullPath);
applyActiveState();
};
// ============ 辅助函数:路径提取和导航 ============
// 从完整路径中提取文件夹路径
const extractFolderPath = (fullPath: string): string => {
const pathParts = fullPath.split('/').filter(p => p);
if (pathParts.length <= 1) {
return '/';
}
return '/' + pathParts.slice(0, -1).join('/');
};
// 导航到指定文件夹并刷新列表(如果当前不在该文件夹)
const navigateToFolder = async (targetFolderPath: string): Promise<void> => {
if (currentPath !== targetFolderPath) {
currentPath = targetFolderPath;
if (pathNavigator) {
pathNavigator.update(targetFolderPath);
}
await fetchDemoList();
}
};
// 导航到demo所在文件夹并高亮(不加载数据,用于URL恢复等场景)
const navigateToDemoAndHighlight = async (fullPath: string): Promise<void> => {
const normalizedPath = normalizeFullPath(fullPath);
if (!normalizedPath) {
return;
}
try {
const targetFolderPath = extractFolderPath(normalizedPath);
await navigateToFolder(targetFolderPath);
highlightDemo(normalizedPath);
} catch (error) {
console.error('导航到demo失败:', error);
}
};
const setActiveDemo = (fullPath: string | null) => {
highlightDemo(fullPath);
// 然后同步更新URL参数
if (activeDemoFullPath) {
URLHandler.updateURLParam('demo', activeDemoFullPath, false);
} else {
// 清除demo参数
const currentParams = URLHandler.parameters;
delete currentParams['demo'];
URLHandler.updateUrl(currentParams, false);
}
};
// setListLoading 只用于demo列表区域的loading指示器(显示/隐藏"正在刷新..."文本)
// 不触发 onDemoLoading,因为刷新列表和文件夹操作不应该影响统计信息
const setListLoading = (loading: boolean) => {
loader.style('display', loading ? null : 'none');
};
const disableDemoButtons = (disabled: boolean) => {
container.selectAll('.demoBtn, .demo-folder-btn')
.style('opacity', disabled ? '0.5' : '1')
.style('pointer-events', disabled ? 'none' : null)
.style('cursor', disabled ? 'not-allowed' : 'pointer');
};
// 创建服务器存储控制器(统一加载逻辑)
const serverStorageController = new DemoStorageController(
new ServerStorage(api),
{
setLoading: (loading: boolean) => {
onDemoLoading?.(loading);
disableDemoButtons(loading);
},
showToast: (message, type) => {
// demoManager 使用 alert 显示错误
if (type === 'error') {
showAlertDialog(tr('Error'), message);
}
},
showSuccessToast: false // 由调用者自行处理成功提示
}
);
const fetchDemoList = async () => {
disableDemoButtons(true);
setListLoading(true);
onRefreshStart?.();
try {
const result = await api.list_demos(currentPath);
// 多选模式下,复选状态不受路径切换影响,保留选择状态
renderItems(result.items || []);
if (pathNavigator) {
pathNavigator.update(result.path || currentPath);
currentPath = result.path || currentPath;
}
} finally {
setListLoading(false);
disableDemoButtons(false);
onRefreshEnd?.();
}
};
const handleMoveItem = async (item: DemoItem) => {
try {
setListLoading(true);
const foldersResult = await api.list_all_folders();
const folders = foldersResult.folders || [];
// 排除当前项所在的路径及其子路径
const excludePath = item.path;
const filteredFolders = folders.filter(f => {
if (f === excludePath) return false;
if (excludePath && f.startsWith(excludePath + '/')) return false;
return true;
});
showMoveDialog(filteredFolders, currentPath, async (targetPath: string) => {
try {
setListLoading(true);
let result;
if (item.type === 'file') {
result = await api.move_demo(item.path, targetPath);
} else {
result = await api.move_folder(item.path, targetPath);
}
if (result.success) {
await fetchDemoList();
} else {
showAlertDialog(tr('Error'), tr(result.message || 'Move failed'));
}
} catch (err) {
console.error('移动失败:', err);
showAlertDialog(tr('Error'), tr('Move failed, please check console for details.'));
} finally {
setListLoading(false);
}
});
} catch (err) {
console.error('获取文件夹列表失败:', err);
showAlertDialog(tr('Error'), tr('Failed to get folder list, please check console for details.'));
} finally {
setListLoading(false);
}
};
const handleRenameItem = async (item: DemoItem) => {
showRenameDialog(item.name, async (newName: string) => {
try {
setListLoading(true);
let result;
if (item.type === 'file') {
result = await api.rename_demo(item.path, newName);
} else {
result = await api.rename_folder(item.path, newName);
}
if (result.success) {
await fetchDemoList();
} else {
showAlertDialog(tr('Error'), tr(result.message || 'Rename failed'));
}
} catch (err) {
console.error('重命名失败:', err);
showAlertDialog(tr('Error'), tr('Rename failed, please check console for details.'));
} finally {
setListLoading(false);
}
});
};
const handleDeleteItem = async (item: DemoItem) => {
showDeleteConfirm(item.name, item.type, async () => {
try {
setListLoading(true);
let result;
if (item.type === 'file') {
result = await api.delete_demo(item.path);
} else {
result = await api.delete_folder(item.path);
}
if (result.success) {
await fetchDemoList();
} else {
showAlertDialog(tr('Error'), tr(result.message || 'Delete failed'));
}
} catch (err) {
console.error('删除失败:', err);
showAlertDialog(tr('Error'), tr('Delete failed, please check console for details.'));
} finally {
setListLoading(false);
}
});
};
const handleFolderClick = (folderPath: string) => {
currentPath = folderPath;
if (pathNavigator) {
pathNavigator.update(folderPath);
}
fetchDemoList().catch(err => {
console.error('刷新demo列表失败:', err);
});
};
const renderItems = (items: DemoItem[]) => {
// 创建demo项容器,保持原有布局
const demoItems = container.selectAll<HTMLDivElement, DemoItem>('.demo-item')
.data(items, (d: DemoItem) => d.path)
.join('div')
.attr('class', 'demo-item');
// 多选模式下,在demo-item最左边添加复选框
if (multiSelect && multiSelect.shouldShowCheckbox()) {
const checkboxes = demoItems.selectAll<HTMLInputElement, DemoItem>('.demo-checkbox-inline')
.data(d => [d])
.join('input')
.attr('type', 'checkbox')
.attr('class', 'demo-checkbox-inline')
.property('checked', d => multiSelect.isItemSelected(d))
.property('disabled', d => d.type === 'folder'); // 文件夹的复选框设为不可选
} else {
// 非多选模式下,移除复选框
demoItems.selectAll('.demo-checkbox-inline').remove();
}
// Demo按钮(文件夹或文件),保持原有样式
const buttons = demoItems.selectAll<HTMLDivElement, DemoItem>('.demoBtn, .demo-folder-btn')
.data(d => [d])
.join('div')
.attr('class', d => d.type === 'folder' ? 'demo-folder-btn' : 'demoBtn')
.style('opacity', '1')
.style('pointer-events', null)
.style('cursor', 'pointer')
.html(d => {
// 文件夹添加简约风格的SVG图标
if (d.type === 'folder') {
const folderIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="display: inline-block; vertical-align: middle; margin-right: 6px; opacity: 0.7;"><path d="M3 7v10a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-6l-2-2H5a2 2 0 0 0-2 2z"/></svg>';
return folderIcon + d.name;
}
return d.name;
});
// 菜单按钮(替换原来的删除按钮)
// 在 forceMultiSelect 模式下不显示菜单按钮;
// 在禁用文件夹操作(非管理员模式)时也不显示菜单按钮
if (!forceMultiSelect && !disableFolderOperations) {
const menuContainers = demoItems.selectAll<HTMLDivElement, DemoItem>('.demo-menu-container')
.data(d => [d])
.join('div')
.attr('class', 'demo-menu-container')
.style('flex-shrink', '0')
.html(''); // 先清空容器内容,避免重复添加
// 存储菜单对象,用于悬浮显示/隐藏
const menuMap = new Map<string, ReturnType<typeof createMenuButton>['menu']>();
menuContainers.each(function(item) {
const menuContainer = d3.select(this);
const { button, menu } = createMenuButton(
item,
disableFolderOperations ? () => {} : () => handleMoveItem(item),
disableFolderOperations ? () => {} : () => handleRenameItem(item),
disableFolderOperations ? () => {} : () => handleDeleteItem(item)
);
const containerNode = menuContainer.node() as HTMLElement | null;
const buttonNode = button.node() as HTMLElement | null;
if (containerNode && buttonNode) {
containerNode.appendChild(buttonNode);
}
// 存储菜单对象
const key = item.path || '';
menuMap.set(key, menu);
});
// 在demo项上添加鼠标悬浮事件,显示/隐藏菜单按钮
demoItems
.on('mouseenter', function(event, item) {
const key = item.path || '';
const menu = menuMap.get(key);
if (menu) {
menu.showButton();
}
})
.on('mouseleave', function(event, item) {
const key = item.path || '';
const menu = menuMap.get(key);
if (menu) {
menu.hideButton();
}
});
} else {
// forceMultiSelect 或禁用文件夹操作模式下,移除菜单容器
demoItems.selectAll('.demo-menu-container').remove();
}
buttons.on('click', function(event, item) {
if (item.type === 'folder') {
handleFolderClick(item.path);
} else if (item.type === 'file') {
// 如果禁用了单击加载且启用了多选模式,则切换复选框状态
if (disableClickLoad && multiSelect && multiSelect.shouldShowCheckbox()) {
const demoItem = d3.select(this.parentElement);
const checkbox = demoItem.select<HTMLInputElement>('.demo-checkbox-inline');
const checkboxNode = checkbox.node();
if (checkboxNode && !checkboxNode.disabled) {
checkboxNode.checked = !checkboxNode.checked;
// 触发 change 事件以同步到 selectedDemos
checkbox.dispatch('change');
}
} else {
loadDemoFile(item);
}
}
});
// 为复选框绑定 change 事件,从复选框同步状态到 selectedDemos
if (multiSelect && multiSelect.shouldShowCheckbox()) {
demoItems.selectAll<HTMLInputElement, DemoItem>('.demo-checkbox-inline')
.on('change', function(event, d) {
// d 是绑定到复选框的数据(DemoItem),event.target 是复选框元素
if (d && event.target instanceof HTMLInputElement && multiSelect) {
multiSelect.syncSelectionFromCheckbox(d, event.target);
}
});
}
// 已改为竖向布局,不再需要设置column-width
// 移除原有的列宽计算逻辑(基于按钮宽度)
applyActiveState();
// 切换路径后,确保复选框状态与 selectedDemos 同步
if (multiSelect && multiSelect.shouldShowCheckbox()) {
multiSelect.syncCheckboxFromSelection();
// 列表渲染完成后,更新控制栏按钮状态(修复初始状态下全选按钮不可用的问题)
multiSelect.updateBar();
}
};
const loadDemoFile = async (item: DemoItem) => {
if (!item.path || !item.path.trim()) {
showAlertDialog(tr('Error'), tr('Cannot find corresponding demo file path, unable to load.'));
return;
}
// 使用统一的存储控制器加载(与本地加载保持一致)
const data = await serverStorageController.load(item.path);
if (!data) {
// 错误已在控制器中处理
return;
}
// 判断是否是新demo(与上次加载的不同)
// 使用与setActiveDemo相同的路径规范化逻辑
const demoPath = item.path;
const normalizedPath = normalizeFullPath(demoPath);
const isNewDemo = normalizedPath !== lastLoadedDemoPath;
if (isNewDemo) {
lastLoadedDemoPath = normalizedPath;
}
onTextPrefill?.(data.request.text);
onDemoLoaded(data, true, isNewDemo, demoPath);
setActiveDemo(demoPath);
};
// 根据完整路径加载demo(用于保存后重新加载等场景)
const loadDemoByPath = async (fullPath: string): Promise<boolean> => {
const normalizedPath = normalizeFullPath(fullPath);
if (!normalizedPath) {
return false;
}
try {
// 使用辅助函数:导航到文件夹
const targetFolderPath = extractFolderPath(normalizedPath);
await navigateToFolder(targetFolderPath);
// 在当前列表中查找匹配的文件
const result = await api.list_demos(currentPath);
const items = result.items || [];
const targetItem = items.find((item: DemoItem) => {
if (item.type !== 'file') {
return false;
}
// 检查item的完整路径是否匹配
const normalizedItemPath = normalizeFullPath(item.path);
return normalizedItemPath === normalizedPath;
});
if (targetItem && targetItem.type === 'file') {
await loadDemoFile(targetItem);
return true;
}
return false;
} catch (err) {
console.error('根据路径加载demo失败:', err);
return false;
}
};
refreshBtn.on('click', () => {
fetchDemoList().catch(err => {
console.error('刷新demo列表失败:', err);
showAlertDialog(tr('Error'), tr('Failed to refresh demo list, please check console for details.'));
});
});
// 初始化多选模块(在 pathNavigator 创建之后)
initMultiSelect();
// 初次加载
fetchDemoList().catch(err => {
console.error('加载demo列表失败:', err);
showAlertDialog(tr('Error'), tr('Failed to refresh demo list, please check console for details.'));
});
return {
refresh: fetchDemoList,
highlightDemo: highlightDemo,
navigateToDemoAndHighlight: navigateToDemoAndHighlight,
loadDemoByPath: loadDemoByPath,
getSelectedPaths: () => multiSelect ? multiSelect.getSelectedPaths() : [],
};
}