= new Map();
+ private rootNodes: BimTreeNode[] = [];
+
+ // 订阅清理函数
+ private unsubscribeLocale: (() => void) | null = null;
+ private unsubscribeTheme: (() => void) | null = null;
+
+ // 事���回调 (由 Manager 注入)
+ public onNodeCheck?: (node: BimTreeNode) => void;
+ public onNodeSelect?: (node: BimTreeNode) => void;
+ public onNodeExpand?: (node: BimTreeNode) => void;
+
+ constructor(options: TreeOptions) {
+ this.options = {
+ checkable: true,
+ checkStrictly: true,
+ indent: 24,
+ defaultExpandAll: false,
+ ...options
+ };
+ this.element = document.createElement('div');
+ this.element.className = 'bim-tree';
+ }
+
+ public init(): void {
+ this.render();
+
+ // 订阅系统事件
+ this.unsubscribeLocale = localeManager.subscribe(() => this.setLocales());
+ this.unsubscribeTheme = themeManager.subscribe((theme) => this.setTheme(theme));
+ }
+
+ /**
+ * 设置主题
+ */
+ public setTheme(theme: ThemeConfig): void {
+ const style = this.element.style;
+ style.setProperty('--bim-ui_bg_color', theme.panelBackground);
+ style.setProperty('--bim-ui_text_primary', theme.textPrimary);
+ style.setProperty('--bim-ui_text_secondary', theme.textSecondary || '#999');
+ style.setProperty('--bim-ui_border_color', theme.border);
+ style.setProperty('--bim-ui_bg_hover', theme.componentHover);
+ style.setProperty('--bim-primary_color', theme.primary);
+ // style.setProperty('--bim-ui_text_disabled', theme.textDisabled); // 如果 ThemeConfig 有这个字段
+ }
+
+ /**
+ * 响应语言变更
+ */
+ public setLocales(): void {
+ this.nodeMap.forEach(node => node.updateLabel());
+ }
+
+ public destroy(): void {
+ if (this.unsubscribeLocale) {
+ this.unsubscribeLocale();
+ this.unsubscribeLocale = null;
+ }
+ if (this.unsubscribeTheme) {
+ this.unsubscribeTheme();
+ this.unsubscribeTheme = null;
+ }
+ this.rootNodes.forEach(node => node.destroy());
+ this.rootNodes = [];
+ this.nodeMap.clear();
+ this.element.remove();
+ }
+
+ /**
+ * 核心渲染逻辑
+ */
+ private render(): void {
+ this.element.innerHTML = '';
+ this.nodeMap.clear();
+ this.rootNodes = [];
+
+ this.options.data.forEach(config => {
+ this.createNodeRecursively(config, null);
+ });
+
+ // 如果设置了 defaultExpandAll,展开所有
+ if (this.options.defaultExpandAll) {
+ this.expandAll(true);
+ }
+ }
+
+ /**
+ * 递归创建节点
+ */
+ private createNodeRecursively(config: TreeNodeConfig, parent: BimTreeNode | null) {
+ const node = new BimTreeNode(config, this.options, {
+ onExpand: (n) => { if (this.onNodeExpand) this.onNodeExpand(n); },
+ onCheck: (n) => this.handleNodeCheck(n),
+ onClick: (n) => { if (this.onNodeSelect) this.onNodeSelect(n); }
+ });
+
+ this.nodeMap.set(config.id, node);
+
+ if (parent) {
+ parent.appendChild(node);
+ } else {
+ this.rootNodes.push(node);
+ this.element.appendChild(node.element);
+ }
+
+ if (config.children && config.children.length > 0) {
+ config.children.forEach(childConfig => {
+ this.createNodeRecursively(childConfig, node);
+ });
+ }
+
+ // 如果是初始化渲染,需要处理 checkStrictly 的向上联动(因为数据里可能只给了子节点 checked,父节点没给)
+ // 这里做一个简单的后处理:如果 checkStrictly 开启,且当前节点 checked,则触发一次联动
+ // 注意:这可能会导致性能问题,优化做法是在所有节点创建完后统一计算一次状态
+ }
+
+ /**
+ * 处理节点勾选逻辑 (核心算法)
+ */
+ private handleNodeCheck(node: BimTreeNode) {
+ const isChecked = node.checkState === TreeNodeCheckState.Checked;
+
+ // 1. 触发外部回调 (Event)
+ if (this.onNodeCheck) this.onNodeCheck(node);
+
+ // 2. 如果不联动,直接返回
+ if (this.options.checkStrictly === false) return;
+
+ // 3. 联动逻辑
+ // 3.1 向下级联 (Cascade Down): 父变子全变
+ const updateChildren = (n: BimTreeNode, state: TreeNodeCheckState) => {
+ n.children.forEach(child => {
+ if (child.config.disabled) return; // 跳过禁用节点
+ child.setChecked(state, false); // 不再触发事件,只更新状态 UI
+ updateChildren(child, state);
+ });
+ };
+
+ // 当前节点是 Checked 或 Unchecked,子节点跟随
+ if (isChecked) {
+ updateChildren(node, TreeNodeCheckState.Checked);
+ } else {
+ updateChildren(node, TreeNodeCheckState.Unchecked);
+ }
+
+ // 3.2 向上冒泡 (Bubble Up): 子变父更新
+ let current = node.parent;
+ while (current) {
+ if (current.config.disabled) {
+ current = current.parent;
+ continue;
+ }
+
+ const children = current.children;
+ const allChecked = children.every(c => c.checkState === TreeNodeCheckState.Checked);
+ const allUnchecked = children.every(c => c.checkState === TreeNodeCheckState.Unchecked);
+
+ if (allChecked) {
+ current.setChecked(TreeNodeCheckState.Checked, false);
+ } else if (allUnchecked) {
+ current.setChecked(TreeNodeCheckState.Unchecked, false);
+ } else {
+ current.setChecked(TreeNodeCheckState.Indeterminate, false);
+ }
+
+ current = current.parent;
+ }
+ }
+
+ // ================== Public APIs ==================
+
+ public getNode(id: string): BimTreeNode | undefined {
+ return this.nodeMap.get(id);
+ }
+
+ public checkNode(id: string, checked: boolean) {
+ const node = this.nodeMap.get(id);
+ if (node) {
+ node.setChecked(checked ? TreeNodeCheckState.Checked : TreeNodeCheckState.Unchecked, true);
+ // 手动调用联动处理,因为 setChecked 的 fireEvent 只触发回调,不包含内部逻辑调用?
+ // 不,我们在 createNodeRecursively 里绑定的 onCheck 就是 handleNodeCheck
+ // 所以只要 fireEvent=true,就会触发 handleNodeCheck,进而触发联动。
+ }
+ }
+
+ public expandAll(expanded: boolean) {
+ this.nodeMap.forEach(node => node.toggleExpand(expanded));
+ }
+
+ public getCheckedNodes(includeHalfChecked: boolean = false): TreeNodeConfig[] {
+ const result: TreeNodeConfig[] = [];
+ this.nodeMap.forEach(node => {
+ if (node.checkState === TreeNodeCheckState.Checked) {
+ result.push(node.config);
+ } else if (includeHalfChecked && node.checkState === TreeNodeCheckState.Indeterminate) {
+ result.push(node.config);
+ }
+ });
+ return result;
+ }
+}
diff --git a/src/components/tree/tree-node.ts b/src/components/tree/tree-node.ts
new file mode 100644
index 0000000..e9733f0
--- /dev/null
+++ b/src/components/tree/tree-node.ts
@@ -0,0 +1,239 @@
+import { TreeNodeConfig, TreeNodeCheckState, TreeOptions } from './types';
+import { t } from '../../services/locale';
+
+/**
+ * 树节点类
+ * 负责渲染单个节点、处理交互和递归
+ */
+export class BimTreeNode {
+ public config: TreeNodeConfig;
+ public element: HTMLElement;
+ public children: BimTreeNode[] = [];
+ public parent: BimTreeNode | null = null;
+ public checkState: TreeNodeCheckState = TreeNodeCheckState.Unchecked;
+
+ // UI Elements
+ private contentEl!: HTMLElement;
+ private switcherEl!: HTMLElement;
+ private checkboxEl: HTMLElement | null = null;
+ private titleEl!: HTMLElement;
+ private childrenContainer!: HTMLElement;
+
+ // 外部回调
+ private onExpandChange: (node: BimTreeNode) => void;
+ private onCheckChange: (node: BimTreeNode) => void;
+ private onNodeClick: (node: BimTreeNode) => void;
+
+ constructor(
+ config: TreeNodeConfig,
+ options: TreeOptions,
+ callbacks: {
+ onExpand: (n: BimTreeNode) => void,
+ onCheck: (n: BimTreeNode) => void,
+ onClick: (n: BimTreeNode) => void
+ }
+ ) {
+ this.config = config;
+ this.onExpandChange = callbacks.onExpand;
+ this.onCheckChange = callbacks.onCheck;
+ this.onNodeClick = callbacks.onClick;
+
+ // 初始化状态
+ this.checkState = config.checked ? TreeNodeCheckState.Checked : TreeNodeCheckState.Unchecked;
+
+ this.element = this.createDom(options);
+ }
+
+ /**
+ * 创建节点 DOM
+ */
+ private createDom(options: TreeOptions): HTMLElement {
+ const nodeEl = document.createElement('div');
+ nodeEl.className = 'bim-tree-node';
+ if (this.config.disabled) nodeEl.classList.add('is-disabled');
+
+ // 1. 内容行
+ this.contentEl = document.createElement('div');
+ this.contentEl.className = 'bim-tree-node-content';
+
+ // 计算缩进 (由父级传入或在 Tree 中统一处理样式,这里使用 padding-left 动态计算较复杂,
+ // 我们改用 Flex + 占位符 或者直接在 Tree 递归时处理。
+ // 实际上,递归渲染时只需把 children 放在一个有 padding-left 的容器里即可,CSS 中已经处理吗?
+ // 检查 CSS: .bim-tree-children 没有 padding-left。
+ // 我们采用在 CSS 中给 children 加 padding-left 的方式最简单,或者动态设置 style.
+ // 为了灵活性,这里采用动态设置 contentEl 的 paddingLeft
+ // 但这需要知道层级 depth。为了简单,我们在 renderChildren 时设置容器样式。
+
+ // 1.1 展开/折叠箭头
+ this.switcherEl = document.createElement('span');
+ this.switcherEl.className = 'bim-tree-switcher';
+ // 默认右箭头 SVG
+ this.switcherEl.innerHTML = ``;
+
+ const hasChildren = this.config.children && this.config.children.length > 0;
+ if (!hasChildren) {
+ this.switcherEl.classList.add('is-hidden');
+ } else if (this.config.expanded) {
+ this.switcherEl.classList.add('is-expanded');
+ }
+
+ this.switcherEl.addEventListener('click', (e) => {
+ e.stopPropagation();
+ this.toggleExpand();
+ });
+
+ this.contentEl.appendChild(this.switcherEl);
+
+ // 1.2 复选框 (可选)
+ if (options.checkable !== false) {
+ this.checkboxEl = document.createElement('span');
+ this.checkboxEl.className = 'bim-tree-checkbox';
+ this.updateCheckboxUI();
+
+ this.checkboxEl.addEventListener('click', (e) => {
+ e.stopPropagation();
+ if (this.config.disabled) return;
+ this.toggleCheck();
+ });
+ this.contentEl.appendChild(this.checkboxEl);
+ }
+
+ // 1.3 图标 (可选)
+ if (this.config.icon) {
+ const iconEl = document.createElement('span');
+ iconEl.className = 'bim-tree-icon';
+ iconEl.innerHTML = this.config.icon.includes('`;
+ this.contentEl.appendChild(iconEl);
+ }
+
+ // 1.4 文本
+ this.titleEl = document.createElement('span');
+ this.titleEl.className = 'bim-tree-title';
+ this.updateLabel(); // 设置文本
+ this.contentEl.appendChild(this.titleEl);
+
+ // 绑定整行点击
+ this.contentEl.addEventListener('click', (e) => {
+ e.stopPropagation();
+ if (this.config.disabled) return;
+ this.onNodeClick(this);
+ });
+
+ nodeEl.appendChild(this.contentEl);
+
+ // 2. 子节点容器
+ this.childrenContainer = document.createElement('div');
+ this.childrenContainer.className = 'bim-tree-children';
+ // 设置缩进
+ const indent = options.indent || 24;
+ this.childrenContainer.style.paddingLeft = `${indent}px`; // 每一级子���器左移
+
+ if (this.config.expanded && hasChildren) {
+ this.childrenContainer.classList.add('is-visible');
+ }
+ nodeEl.appendChild(this.childrenContainer);
+
+ return nodeEl;
+ }
+
+ /**
+ * 更新显示文本 (国际化支持)
+ */
+ public updateLabel() {
+ if (this.titleEl) {
+ this.titleEl.textContent = t(this.config.label);
+ }
+ }
+
+ /**
+ * 切换展开状态
+ */
+ public toggleExpand(force?: boolean) {
+ if (this.config.children?.length === 0) return;
+
+ const newState = force !== undefined ? force : !this.config.expanded;
+ this.config.expanded = newState;
+
+ if (newState) {
+ this.switcherEl.classList.add('is-expanded');
+ this.childrenContainer.classList.add('is-visible');
+ } else {
+ this.switcherEl.classList.remove('is-expanded');
+ this.childrenContainer.classList.remove('is-visible');
+ }
+
+ // 触发回调
+ if (force === undefined) { // 只有用户交互才触发回调,防止初始化时无限循环
+ this.onExpandChange(this);
+ }
+ }
+
+ /**
+ * 切换选中状态 (用户点击)
+ */
+ public toggleCheck() {
+ // 如果当前是半选,点击变全选;如果全选,点击��未选;如果未选,点击变全选
+ // 简化逻辑:只要不是 Checked,点击都变 Checked;如果是 Checked,变 Unchecked
+ const newChecked = this.checkState !== TreeNodeCheckState.Checked;
+ this.setChecked(newChecked ? TreeNodeCheckState.Checked : TreeNodeCheckState.Unchecked, true);
+ }
+
+ /**
+ * 设置选中状态 (API调用或联动)
+ * @param state 新状态
+ * @param fireEvent 是否触发事件
+ */
+ public setChecked(state: TreeNodeCheckState, fireEvent: boolean = false) {
+ if (this.checkState === state) return;
+
+ this.checkState = state;
+ this.config.checked = (state === TreeNodeCheckState.Checked);
+
+ this.updateCheckboxUI();
+
+ if (fireEvent) {
+ this.onCheckChange(this);
+ }
+ }
+
+ /**
+ * 更新复选框 UI 样式
+ */
+ public updateCheckboxUI() {
+ if (!this.checkboxEl) return;
+
+ this.checkboxEl.classList.remove('is-checked', 'is-indeterminate');
+
+ if (this.checkState === TreeNodeCheckState.Checked) {
+ this.checkboxEl.classList.add('is-checked');
+ } else if (this.checkState === TreeNodeCheckState.Indeterminate) {
+ this.checkboxEl.classList.add('is-indeterminate');
+ }
+ }
+
+ /**
+ * 添加子节点实例
+ */
+ public appendChild(childNode: BimTreeNode) {
+ childNode.parent = this;
+ this.children.push(childNode);
+ this.childrenContainer.appendChild(childNode.element);
+
+ // 如果之前是隐藏的箭头,现在有了子节点,需要显示出来
+ if (this.children.length === 1) {
+ this.switcherEl.classList.remove('is-hidden');
+ }
+ }
+
+ /**
+ * 销毁
+ */
+ public destroy() {
+ this.children.forEach(c => c.destroy());
+ this.children = [];
+ this.element.remove();
+ this.parent = null;
+ }
+}
diff --git a/src/components/tree/types.ts b/src/components/tree/types.ts
new file mode 100644
index 0000000..f28f941
--- /dev/null
+++ b/src/components/tree/types.ts
@@ -0,0 +1,64 @@
+/**
+ * 节点勾选状态枚举
+ */
+export enum TreeNodeCheckState {
+ Unchecked = 0,
+ Checked = 1,
+ Indeterminate = 2 // 半选
+}
+
+/**
+ * 树节点配置接口
+ */
+export interface TreeNodeConfig {
+ /** 唯一标识符 */
+ id: string;
+
+ /** 显示文本的翻译键 */
+ label: string;
+
+ /** 节点图标 (SVG string 或 URL) */
+ icon?: string;
+
+ /** 子节点列表 */
+ children?: TreeNodeConfig[];
+
+ /** 初始展开状态 (默认 false) */
+ expanded?: boolean;
+
+ /** 初始选中状态 (默认 false) */
+ checked?: boolean;
+
+ /** 是否禁用 (默认 false) */
+ disabled?: boolean;
+
+ /** 自定义业务数据 */
+ data?: any;
+
+ /** 是否是叶子节点 (用于异步加载场景,暂���接口) */
+ isLeaf?: boolean;
+}
+
+/**
+ * 树组件配置选项
+ */
+export interface TreeOptions {
+ /** 树的数据源 */
+ data: TreeNodeConfig[];
+
+ /** 是否显示复选框 (默认 true) */
+ checkable?: boolean;
+
+ /**
+ * 父子节点选中状态是否关联 (默认 true)
+ * true: 选中父选子,子全选自动选父
+ * false: 独立选中
+ */
+ checkStrictly?: boolean;
+
+ /** 默认展开所有节点 (默认 false) */
+ defaultExpandAll?: boolean;
+
+ /** 缩进宽度 (像素,默认 24) */
+ indent?: number;
+}
diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts
index 6f1ac8b..130c090 100644
--- a/src/locales/en-US.ts
+++ b/src/locales/en-US.ts
@@ -22,7 +22,16 @@ export const enUS: TranslationDictionary = {
testContent: 'This is a draggable and resizable dialog.
Try dragging the title bar or resizing from the bottom-right corner.
',
},
menu: {
- info: "info",
- home: "home",
+ info: 'Info',
+ home: 'Home',
+ },
+ tree: {
+ modelStruct: 'Model Structure',
+ floor1: 'Level 1',
+ floor2: 'Level 2',
+ wall: 'Walls',
+ column: 'Columns',
+ window: 'Windows',
+ door: 'Doors',
}
};
diff --git a/src/locales/types.ts b/src/locales/types.ts
index f442c24..f96ce24 100644
--- a/src/locales/types.ts
+++ b/src/locales/types.ts
@@ -26,6 +26,15 @@ export interface TranslationDictionary {
menu: {
info: string;
home: string;
+ };
+ tree: {
+ modelStruct: string;
+ floor1: string;
+ floor2: string;
+ wall: string;
+ column: string;
+ window: string;
+ door: string;
}
}
diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts
index e858a42..891c3a7 100644
--- a/src/locales/zh-CN.ts
+++ b/src/locales/zh-CN.ts
@@ -22,7 +22,16 @@ export const zhCN: TranslationDictionary = {
testContent: '这是一个 可拖拽 且 可缩放 的弹窗。
你可以尝试拖动标题栏,或者拖动右下角改变大小。
',
},
menu: {
- info: "信息",
- home: "首页"
+ info: '信息',
+ home: '首页',
+ },
+ tree: {
+ modelStruct: '模型结构',
+ floor1: '一楼',
+ floor2: '二楼',
+ wall: '墙体',
+ column: '柱子',
+ window: '窗户',
+ door: '门',
}
};
diff --git a/src/managers/model-tree-manager.ts b/src/managers/model-tree-manager.ts
new file mode 100644
index 0000000..3a65bdc
--- /dev/null
+++ b/src/managers/model-tree-manager.ts
@@ -0,0 +1,76 @@
+import { BimComponent } from '../core/component';
+import { BimTree } from '../components/tree/index';
+import { BimDialog } from '../components/dialog/index';
+import { TreeNodeConfig } from '../components/tree/types';
+
+/**
+ * 模型树业务管理器
+ * 负责组装 Tree 和 Dialog 组件,提供开箱即用的业务弹窗
+ */
+export class ModelTreeManager extends BimComponent {
+
+ /**
+ * 显示带复选框的模型结构树弹窗
+ * @param data 树节点数据
+ * @param title 弹窗标题 (翻译键)
+ */
+ public showStructTree(data: TreeNodeConfig[], title: string = 'tree.modelStruct'): { tree: BimTree, dialog: BimDialog } {
+ // 1. 创建 Tree 实例
+ const tree = this.engine.tree!.create({
+ data: data,
+ checkable: true,
+ checkStrictly: true,
+ defaultExpandAll: true
+ });
+
+ // 2. 创建 Dialog 实例
+ const dialog = this.engine.dialog!.create({
+ title: title,
+ width: 300,
+ height: 400,
+ content: tree.element,
+ position: 'left-center',
+ resizable: true,
+ // 关键:绑定生命周期
+ onClose: () => {
+ tree.destroy();
+ }
+ });
+
+ return { tree, dialog };
+ }
+
+ /**
+ * 显示简单的树形弹窗 (无复选框)
+ * @param data 树节点数据
+ * @param title 弹窗标题 (翻译键)
+ */
+ public showSimpleTree(data: TreeNodeConfig[], title: string = 'menu.home'): { tree: BimTree, dialog: BimDialog } {
+ // 1. 创建 Tree 实例
+ const tree = this.engine.tree!.create({
+ data: data,
+ checkable: false,
+ defaultExpandAll: true
+ });
+
+ // 2. 创建 Dialog 实例
+ const dialog = this.engine.dialog!.create({
+ title: title,
+ width: 250,
+ height: 300,
+ content: tree.element,
+ position: 'center',
+ resizable: true,
+ onClose: () => {
+ tree.destroy();
+ }
+ });
+
+ return { tree, dialog };
+ }
+
+ public destroy(): void {
+ // 具体的 Tree 和 Dialog 实例由它们各自的生命周期管理
+ // 这里不需要销毁它们,除非我们持有了全局引用
+ }
+}
diff --git a/src/managers/tree-manager.ts b/src/managers/tree-manager.ts
new file mode 100644
index 0000000..eef8c2d
--- /dev/null
+++ b/src/managers/tree-manager.ts
@@ -0,0 +1,57 @@
+import { BimComponent } from '../core/component';
+import { BimTree } from '../components/tree/index';
+import { TreeOptions } from '../components/tree/types';
+import type { BimEngine } from '../bim-engine';
+
+/**
+ * 树组件管理器
+ * 负责创建和管理 BimTree 实例
+ */
+export class TreeManager extends BimComponent {
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ constructor(engine: BimEngine, _container: HTMLElement) {
+ super(engine);
+ }
+
+ /**
+ * 创建一个新的树组件实例
+ * @param options 配置选项
+ */
+ public create(options: TreeOptions): BimTree {
+ const tree = new BimTree(options);
+
+ // 绑定事件桥接
+ tree.onNodeCheck = (node) => {
+ this.emit('ui:tree-node-check', {
+ id: node.config.id,
+ checked: node.config.checked || false,
+ node: node.config
+ });
+ };
+
+ tree.onNodeSelect = (node) => {
+ this.emit('ui:tree-node-select', {
+ id: node.config.id,
+ selected: true,
+ node: node.config
+ });
+ };
+
+ tree.onNodeExpand = (node) => {
+ this.emit('ui:tree-node-expand', {
+ id: node.config.id,
+ expanded: node.config.expanded || false
+ });
+ };
+
+ tree.init();
+ return tree;
+ }
+
+ public destroy(): void {
+ // TreeManager 本身不持有 Tree 实例的强引用列表
+ // 实例通常由调用者(如 Dialog)持有并销毁
+ // 这里可以做一些全局清理工作
+ }
+}
diff --git a/src/types/events.ts b/src/types/events.ts
index 6d610e8..6c40c66 100644
--- a/src/types/events.ts
+++ b/src/types/events.ts
@@ -5,9 +5,14 @@ export interface EngineEvents {
// Engine Events
'engine:model-loaded': { url: string };
- 'engine:object-clicked': { objectId: string; position: { x: number, y: number, z: number } };
-
- // System Events
+ 'engine:object-clicked': { objectId: string; position: { x: number, y: number, z: number } };
+
+ // 树组件事件
+ 'ui:tree-node-check': { id: string; checked: boolean; node: any };
+ 'ui:tree-node-select': { id: string; selected: boolean; node: any };
+ 'ui:tree-node-expand': { id: string; expanded: boolean };
+
+ // 系统事件
'sys:theme-changed': { theme: string };
'sys:locale-changed': { locale: string };
}