import './index.css'; import type { ThemeConfig } from '../../themes/types'; import { IBimComponent } from '../../types/component'; import { localeManager, t } from '../../services/locale'; import { themeManager } from '../../services/theme'; import type { MeasureMode, MeasurePanelOptions, MeasureResult } from './types'; /** * 测量面板组件(只做 UI,不实现真实测量) * * 组件职责: * - 展示 8 种测量方式按钮(默认 4 个,可展开/收起) * - 维护当前选中的测量方式(current mode) * - 展示测量结果(由外部 setResult 注入) * - 提供 “删除全部 / 设置” 的 UI 与对外方法(暂不实现真实逻辑,仅回调/占位) * * 注意: * - 所有用户可见文本必须通过 t(key) 获取(国际化强制要求) * - 组件需要订阅主题/语言变更,并在 destroy 时清理订阅 */ export class MeasurePanel implements IBimComponent { public element: HTMLElement; private options: MeasurePanelOptions; private activeMode: MeasureMode; private isExpanded: boolean; private result: MeasureResult | null = null; // DOM 引用(便于局部更新,减少频繁 querySelector) private toolButtons: Map = new Map(); private toggleBtn!: HTMLButtonElement; private toggleTextEl!: HTMLElement; private currentModeValueEl!: HTMLElement; private mainValueValueEl!: HTMLElement; private mainValueLabelEl!: HTMLElement; private xyzXEl!: HTMLElement; private xyzYEl!: HTMLElement; private xyzZEl!: HTMLElement; private clearBtn!: HTMLButtonElement; private settingsBtn!: HTMLButtonElement; // 订阅清理 private unsubscribeLocale: (() => void) | null = null; private unsubscribeTheme: (() => void) | null = null; /** * 构造函数 * @param options 组件配置 */ constructor(options: MeasurePanelOptions = {}) { this.options = options; this.activeMode = options.defaultMode ?? 'distance'; this.isExpanded = options.defaultExpanded ?? false; this.element = this.createDom(); } /** * 初始化组件(实现 IBimComponent) */ public init(): void { // 订阅语言变更:更新所有文本/提示 this.unsubscribeLocale = localeManager.subscribe(() => { this.setLocales(); }); // 订阅主题变更:更新 CSS 变量(如需要) this.unsubscribeTheme = themeManager.subscribe((theme) => { this.setTheme(theme); }); // 初始应用 this.setLocales(); this.setTheme(themeManager.getTheme()); // 初始渲染状态(按钮显隐、选中态、结果区) this.applyExpandedState(); this.applyActiveModeState(); this.renderResult(); } /** * 设置主题(实现 IBimComponent) * @param theme 主题配置 */ public setTheme(theme: ThemeConfig): void { // 为了可读性:这里显式写出映射,不做过度抽象 const style = this.element.style; // 这些变量不会强制覆盖外部(Dialog)已有变量,只做兜底 style.setProperty('--bim-measure-border', theme.border ?? 'rgba(255, 255, 255, 0.12)'); style.setProperty('--bim-measure-divider', theme.border ?? 'rgba(255, 255, 255, 0.10)'); style.setProperty('--bim-measure-icon-color', theme.icon ?? '#ddd'); style.setProperty('--bim-measure-label-color', theme.textSecondary ?? 'rgba(255, 255, 255, 0.70)'); style.setProperty('--bim-measure-value-color', theme.textPrimary ?? 'rgba(255, 255, 255, 0.90)'); // “删除全部”颜色:截图中偏绿色,这里用 primary 做一个合理映射 style.setProperty('--bim-measure-danger', theme.primary ?? '#46d369'); style.setProperty('--bim-measure-btn-bg', theme.componentBackground ?? 'rgba(255, 255, 255, 0.06)'); style.setProperty('--bim-measure-btn-hover-bg', theme.componentHover ?? 'rgba(255, 255, 255, 0.10)'); style.setProperty('--bim-measure-btn-active-bg', theme.componentActive ?? 'rgba(255, 255, 255, 0.14)'); } /** * 设置语言(实现 IBimComponent) */ public setLocales(): void { // 1) 更新按钮 tooltip(图标占位时,tooltip 是主要的可读文本) for (const [mode, btn] of this.toolButtons.entries()) { btn.title = t(this.getModeI18nKey(mode)); btn.setAttribute('aria-label', btn.title); } // 2) 更新展开/收起按钮 tooltip this.toggleBtn.title = this.isExpanded ? t('measure.actions.collapse') : t('measure.actions.expand'); this.toggleBtn.setAttribute('aria-label', this.toggleBtn.title); // 2.1) 更新展开/收起按钮可见文本(你要求的“文字提示”) if (this.toggleTextEl) { this.toggleTextEl.textContent = this.toggleBtn.title; } // 3) 更新底部按钮文本/tooltip this.clearBtn.textContent = t('measure.actions.clearAll'); this.settingsBtn.title = t('measure.actions.settings'); this.settingsBtn.setAttribute('aria-label', this.settingsBtn.title); // 4) 更新“当前方式”显示(value) this.currentModeValueEl.textContent = t(this.getModeI18nKey(this.activeMode)); // 5) 主值 label(随模式变化) this.mainValueLabelEl.textContent = t(this.getModeValueLabelI18nKey(this.activeMode)); // 6) XYZ label(使用 key) // 这里 label 在 createDom 已经是固定文本节点,直接用 setText 更新更直观 // 但为了减少 DOM 结构复杂度,我们把 label 写在 createDom 里,通过 data-key 更新 const labelNodes = this.element.querySelectorAll('[data-i18n-key]'); labelNodes.forEach((node) => { const key = node.dataset.i18nKey; if (key) node.textContent = t(key); }); } /** * 销毁组件(实现 IBimComponent) */ public destroy(): void { // 清理订阅 if (this.unsubscribeLocale) { this.unsubscribeLocale(); this.unsubscribeLocale = null; } if (this.unsubscribeTheme) { this.unsubscribeTheme(); this.unsubscribeTheme = null; } // 清理事件监听:由于本组件的监听都绑定在创建时的具体按钮上, // 且按钮会随 element 一起被 GC,这里不做逐个 removeEventListener(可读性优先) // 移除 DOM this.element.remove(); } // ========================== // 对外 API(给 Manager / 外部业务调用) // ========================== /** * 获取当前测量方式 */ public getActiveMode(): MeasureMode { return this.activeMode; } /** * 切换测量方式(你要求的“切换类型的方法”) * @param mode 目标测量方式 */ public switchMode(mode: MeasureMode): void { this.setActiveMode(mode); } /** * 设置当前测量方式 * @param mode 目标测量方式 */ public setActiveMode(mode: MeasureMode): void { if (this.activeMode === mode) return; this.activeMode = mode; this.applyActiveModeState(); // 切换方式后,主值 label 也需要更新 this.mainValueLabelEl.textContent = t(this.getModeValueLabelI18nKey(this.activeMode)); this.currentModeValueEl.textContent = t(this.getModeI18nKey(this.activeMode)); // 通知外部(如果需要) if (this.options.onModeChange) { this.options.onModeChange(mode); } // 模式切换后,结果展示也应刷新(例如某些字段显示为 --) this.renderResult(); } /** * 设置测量结果(由外部注入) * @param result 测量结果;传 null 表示清空 */ public setResult(result: MeasureResult | null): void { this.result = result; this.renderResult(); } /** * 删除全部(只做 UI 状态清空 + 回调) */ public clearAll(): void { // 先清空结果显示 this.result = null; this.renderResult(); // 通知外部 if (this.options.onClearAll) { this.options.onClearAll(); } } /** * 打开设置(本次只预留方法/回调) */ public openSettings(): void { if (this.options.onSettings) { this.options.onSettings(); return; } // 兜底:避免无声失败,打印中文日志(符合项目规范) console.warn('[MeasurePanel] 未提供设置回调 onSettings,当前仅预留接口。'); } /** * 展开 / 收起(可选对外调用) * @param expanded 是否展开 */ public setExpanded(expanded: boolean): void { if (this.isExpanded === expanded) return; this.isExpanded = expanded; this.applyExpandedState(); this.setLocales(); // 更新 tooltip(展开/收起) // 通知外部:用于重新计算 Dialog 高度 if (this.options.onExpandedChange) { this.options.onExpandedChange(this.isExpanded); } } /** * 获取是否展开 */ public getExpanded(): boolean { return this.isExpanded; } // ========================== // 内部实现 // ========================== private createDom(): HTMLElement { const root = document.createElement('div'); root.className = 'bim-measure-panel'; // 顶部:工具按钮区 const toolsBox = document.createElement('div'); toolsBox.className = 'bim-measure-tools'; const grid = document.createElement('div'); grid.className = 'bim-measure-tool-grid'; // 8 种测量方式(顺序严格按你给的) const modes: MeasureMode[] = [ 'distance', 'minDistance', 'angle', 'elevation', 'volume', 'laserDistance', 'slope', 'spaceVolume' ]; // 图标占位:统一用圆形(你要求的“圆形占位”) const circleIconSvg = ` `; // 逐个创建按钮 for (let i = 0; i < modes.length; i++) { const mode = modes[i]; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'bim-measure-tool-btn'; btn.dataset.mode = mode; // icon const icon = document.createElement('span'); icon.className = 'bim-measure-tool-icon'; icon.innerHTML = circleIconSvg; btn.appendChild(icon); // 点击切换模式 btn.addEventListener('click', () => { this.setActiveMode(mode); }); // 先不在这里设置 title/text(统一交给 setLocales) this.toolButtons.set(mode, btn); grid.appendChild(btn); } toolsBox.appendChild(grid); // 展开/收起按钮(箭头) const toggleBox = document.createElement('div'); toggleBox.className = 'bim-measure-toggle'; this.toggleBtn = document.createElement('button'); this.toggleBtn.type = 'button'; this.toggleBtn.className = 'bim-measure-toggle-btn'; // 展开/收起按钮:更小,并带文字提示(展开/收起) // 注意:文本内容由 setLocales() 统一更新,这里先放一个占位容器 this.toggleTextEl = document.createElement('span'); this.toggleTextEl.className = 'bim-measure-toggle-text'; const toggleIconEl = document.createElement('span'); toggleIconEl.className = 'bim-measure-toggle-icon'; toggleIconEl.innerHTML = ` `; this.toggleBtn.appendChild(this.toggleTextEl); this.toggleBtn.appendChild(toggleIconEl); this.toggleBtn.addEventListener('click', () => { this.isExpanded = !this.isExpanded; this.applyExpandedState(); this.setLocales(); // 更新 tooltip(展开/收起) // 通知外部:用于重新计算 Dialog 高度 if (this.options.onExpandedChange) { this.options.onExpandedChange(this.isExpanded); } }); toggleBox.appendChild(this.toggleBtn); toolsBox.appendChild(toggleBox); root.appendChild(toolsBox); // 中部:结果区 const resultBox = document.createElement('div'); resultBox.className = 'bim-measure-result'; // 当前方式 const currentModeRow = document.createElement('div'); currentModeRow.className = 'bim-measure-row'; const currentModeLabel = document.createElement('span'); currentModeLabel.className = 'label'; currentModeLabel.dataset.i18nKey = 'measure.labels.currentMode'; const currentModeValue = document.createElement('span'); currentModeValue.className = 'value'; this.currentModeValueEl = currentModeValue; currentModeRow.appendChild(currentModeLabel); currentModeRow.appendChild(currentModeValue); resultBox.appendChild(currentModeRow); // 主结果值(随模式变化) const mainValueRow = document.createElement('div'); mainValueRow.className = 'bim-measure-row'; const mainValueLabel = document.createElement('span'); mainValueLabel.className = 'label'; this.mainValueLabelEl = mainValueLabel; const mainValueValue = document.createElement('span'); mainValueValue.className = 'value'; this.mainValueValueEl = mainValueValue; mainValueRow.appendChild(mainValueLabel); mainValueRow.appendChild(mainValueValue); resultBox.appendChild(mainValueRow); // XYZ const xyzBox = document.createElement('div'); xyzBox.className = 'bim-measure-xyz'; const makeXyzRow = (labelKey: string, valueElSetter: (el: HTMLElement) => void) => { const row = document.createElement('div'); row.className = 'bim-measure-row'; const label = document.createElement('span'); label.className = 'label'; label.dataset.i18nKey = labelKey; const value = document.createElement('span'); value.className = 'value'; valueElSetter(value); row.appendChild(label); row.appendChild(value); return row; }; xyzBox.appendChild(makeXyzRow('measure.labels.x', (el) => (this.xyzXEl = el))); xyzBox.appendChild(makeXyzRow('measure.labels.y', (el) => (this.xyzYEl = el))); xyzBox.appendChild(makeXyzRow('measure.labels.z', (el) => (this.xyzZEl = el))); resultBox.appendChild(xyzBox); root.appendChild(resultBox); // 底部:删除全部 + 设置 const footer = document.createElement('div'); footer.className = 'bim-measure-footer'; this.clearBtn = document.createElement('button'); this.clearBtn.type = 'button'; this.clearBtn.className = 'bim-measure-clear-btn'; this.clearBtn.addEventListener('click', () => { this.clearAll(); }); this.settingsBtn = document.createElement('button'); this.settingsBtn.type = 'button'; this.settingsBtn.className = 'bim-measure-settings-btn'; this.settingsBtn.innerHTML = ` `; this.settingsBtn.addEventListener('click', () => { this.openSettings(); }); footer.appendChild(this.clearBtn); footer.appendChild(this.settingsBtn); root.appendChild(footer); return root; } /** * 应用“展开/收起”状态:默认只显示前 4 个按钮 */ private applyExpandedState(): void { let index = 0; for (const btn of this.toolButtons.values()) { // 默认展示前四个,其余根据展开状态显示/隐藏 if (index >= 4) { btn.style.display = this.isExpanded ? '' : 'none'; } else { btn.style.display = ''; } index++; } // toggle 样式(旋转箭头) if (this.isExpanded) { this.toggleBtn.classList.add('is-expanded'); } else { this.toggleBtn.classList.remove('is-expanded'); } } /** * 应用“当前选中按钮”样式 */ private applyActiveModeState(): void { for (const [mode, btn] of this.toolButtons.entries()) { if (mode === this.activeMode) { btn.classList.add('is-active'); } else { btn.classList.remove('is-active'); } } } /** * 渲染结果区(根据 activeMode 从 result 里取对应字段) */ private renderResult(): void { // 1) 主值 const mainText = this.formatMainValue(this.activeMode, this.result); this.mainValueValueEl.textContent = mainText; // 2) XYZ const xyz = this.result?.xyz; if (!xyz) { this.xyzXEl.textContent = '--'; this.xyzYEl.textContent = '--'; this.xyzZEl.textContent = '--'; return; } // 为了可读性:这里不做 fancy formatter,只做基础展示 this.xyzXEl.textContent = this.formatNumber(xyz.x); this.xyzYEl.textContent = this.formatNumber(xyz.y); this.xyzZEl.textContent = this.formatNumber(xyz.z); } /** * 获取模式名称的国际化 key */ private getModeI18nKey(mode: MeasureMode): string { return `measure.modes.${mode}`; } /** * 获取“主值 label”的国际化 key(随模式变化) */ private getModeValueLabelI18nKey(mode: MeasureMode): string { return `measure.labels.value.${mode}`; } /** * 将“当前模式”的主值格式化为文本 * @param mode 当前模式 * @param result 当前结果 */ private formatMainValue(mode: MeasureMode, result: MeasureResult | null): string { if (!result) return '--'; // 根据不同 mode 读取对应字段并格式化单位 // 单位文本也走国际化(可替换为英文/中文) switch (mode) { case 'distance': return this.formatWithUnit(result.distanceMm, 'measure.units.mm'); case 'minDistance': return this.formatWithUnit(result.minDistanceMm, 'measure.units.mm'); case 'angle': return this.formatWithUnit(result.angleDeg, 'measure.units.deg'); case 'elevation': return this.formatWithUnit(result.elevationMm, 'measure.units.mm'); case 'volume': return this.formatWithUnit(result.volumeM3, 'measure.units.m3'); case 'laserDistance': return this.formatWithUnit(result.laserDistanceMm, 'measure.units.mm'); case 'slope': return this.formatWithUnit(result.slopePercent, 'measure.units.percent'); case 'spaceVolume': return this.formatWithUnit(result.spaceVolumeM3, 'measure.units.m3'); default: return '--'; } } /** * 格式化数值 + 单位(单位走国际化) */ private formatWithUnit(value: number | undefined, unitKey: string): string { if (value === null || value === undefined || Number.isNaN(value)) return '--'; return `${this.formatNumber(value)} ${t(unitKey)}`; } /** * 基础数字格式化(可读性优先) */ private formatNumber(value: number): string { // 保留 3 位小数以内(简单策略:整数不带小数,非整数保留到 3 位) if (Number.isInteger(value)) return String(value); return value.toFixed(3).replace(/0+$/g, '').replace(/\.$/g, ''); } }