From 7ddd378b9a9878a46ee5c123d7685331377fcc5f Mon Sep 17 00:00:00 2001 From: xiaoshi <115949669+xiaoshi930@users.noreply.github.com> Date: Tue, 25 Nov 2025 19:22:06 +0800 Subject: [PATCH] Create xiaoshi-device-consumables-card.js --- xiaoshi-device-consumables-card.js | 1115 ++++++++++++++++++++++++++++ 1 file changed, 1115 insertions(+) create mode 100644 xiaoshi-device-consumables-card.js diff --git a/xiaoshi-device-consumables-card.js b/xiaoshi-device-consumables-card.js new file mode 100644 index 0000000..11643d7 --- /dev/null +++ b/xiaoshi-device-consumables-card.js @@ -0,0 +1,1115 @@ +import { LitElement, html, css } from "https://unpkg.com/lit-element@2.4.0/lit-element.js?module"; + +class XiaoshiConsumablesCardEditor extends LitElement { + static get properties() { + return { + hass: { type: Object }, + config: { type: Object }, + _searchTerm: { type: String }, + _filteredEntities: { type: Array }, + _showEntityList: { type: Boolean } + }; + } + + static get styles() { + return css` + .form { + display: flex; + flex-direction: column; + gap: 10px; + min-height: 500px; + } + .form-group { + display: flex; + flex-direction: column; + gap: 5px; + } + label { + font-weight: bold; + } + select, input, textarea { + padding: 8px; + border: 1px solid #ddd; + border-radius: 4px; + } + textarea { + min-height: 80px; + resize: vertical; + } + .help-text { + font-size: 0.85em; + color: #666; + margin-top: 4px; + } + + .entity-selector { + position: relative; + } + + .entity-search-input { + width: 100%; + padding: 8px; + border: 1px solid #ddd; + border-radius: 4px; + box-sizing: border-box; + } + + .entity-dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + height: 300px; + overflow-y: auto; + background: white; + border: 1px solid #ddd; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + z-index: 1000; + margin-top: 2px; + } + + .entity-option { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + cursor: pointer; + border-bottom: 1px solid #eee; + } + + .entity-option:hover { + background: #f5f5f5; + } + + .entity-option.selected { + background: #e3f2fd; + } + + .entity-info { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + justify-content: space-between; + } + + .entity-details { + flex: 1; + } + + .entity-name { + font-weight: 500; + font-size: 14px; + color: #000; + } + + .entity-id { + font-size: 12px; + color: #000; + font-family: monospace; + } + + .check-icon { + color: #4CAF50; + } + + .no-results { + padding: 12px; + text-align: center; + color: #666; + font-style: italic; + } + + .selected-entities { + margin-top: 8px; + } + + .selected-label { + font-size: 12px; + font-weight: bold; + margin-bottom: 4px; + color: #333; + } + + .selected-entity-config { + margin-bottom: 8px; + border: 1px solid #ddd; + border-radius: 4px; + padding: 8px; + background: #f9f9f9; + } + + .selected-entity { + display: flex; + align-items: center; + gap: 4px; + margin-bottom: 8px; + font-size: 12px; + color: #000; + justify-content: space-between; + } + + .attribute-config { + margin-top: 4px; + display: flex; + flex-direction: column; + gap: 4px; + } + + .attribute-input { + width: 100%; + padding: 4px 8px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 12px; + box-sizing: border-box; + } + + .override-config { + display: flex; + align-items: center; + gap: 4px; + margin-top: 2px; + } + + .override-checkbox { + margin-right: 4px; + } + + .override-input { + flex: 1; + padding: 2px 6px; + border: 1px solid #ddd; + border-radius: 3px; + font-size: 11px; + box-sizing: border-box; + } + + .override-label { + font-size: 11px; + color: #666; + white-space: nowrap; + } + + .remove-btn { + background: none; + border: none; + cursor: pointer; + padding: 0; + display: flex; + align-items: center; + color: #666; + margin-left: auto; + } + + .remove-btn:hover { + color: #f44336; + } + `; + } + + render() { + if (!this.hass) return html``; + + return html` +
+
+ + +
+ +
+ + +
+ +
+ + +
+ 全局预警条件:当任一实体满足此条件时,该实体显示为红色预警状态
+ 优先级:明细预警 > 全局预警 > 无预警 +
+
+ +
+ + +
+ +
+ +
+ + ${this._showEntityList ? html` +
+ ${this._filteredEntities.map(entity => html` +
this._toggleEntity(entity.entity_id)} + > +
+
+
${entity.attributes.friendly_name || entity.entity_id}
+
${entity.entity_id}
+
+ +
+ ${this.config.entities && this.config.entities.some(e => e.entity_id === entity.entity_id) ? + html`` : ''} +
+ `)} + ${this._filteredEntities.length === 0 ? html` +
未找到匹配的实体
+ ` : ''} +
+ ` : ''} +
+
+ ${this.config.entities && this.config.entities.length > 0 ? html` +
已选择的实体:
+ ${this.config.entities.map((entityConfig, index) => { + const entity = this.hass.states[entityConfig.entity_id]; + return html` +
+
+ ${entity?.attributes.friendly_name || entityConfig.entity_id} + + +
+
+ this._updateEntityAttribute(index, e.target.value)} + .value=${entityConfig.attribute || ''} + placeholder="留空使用实体状态,或输入属性名" + class="attribute-input" + /> + +
+ this._updateEntityOverride(index, 'icon', e.target.checked)} + .checked=${entityConfig.overrides?.icon !== undefined} + /> + 图标: + this._updateEntityOverrideValue(index, 'icon', e.target.value)} + .value=${entityConfig.overrides?.icon || ''} + placeholder="mdi:icon-name" + ?disabled=${entityConfig.overrides?.icon === undefined} + /> +
+ +
+ this._updateEntityOverride(index, 'name', e.target.checked)} + .checked=${entityConfig.overrides?.name !== undefined} + /> + 名称: + this._updateEntityOverrideValue(index, 'name', e.target.value)} + .value=${entityConfig.overrides?.name || ''} + placeholder="自定义名称" + ?disabled=${entityConfig.overrides?.name === undefined} + /> +
+ +
+ this._updateEntityOverride(index, 'unit_of_measurement', e.target.checked)} + .checked=${entityConfig.overrides?.unit_of_measurement !== undefined} + /> + 单位: + this._updateEntityOverrideValue(index, 'unit_of_measurement', e.target.value)} + .value=${entityConfig.overrides?.unit_of_measurement || ''} + placeholder="自定义单位" + ?disabled=${entityConfig.overrides?.unit_of_measurement === undefined} + /> +
+ +
+ this._updateEntityOverride(index, 'warning', e.target.checked)} + .checked=${entityConfig.overrides?.warning !== undefined} + /> + 预警: + this._updateEntityOverrideValue(index, 'warning', e.target.value)} + .value=${entityConfig.overrides?.warning || ''} + placeholder="如: >10, <=5, ==on, ==off, =='hello world'" + ?disabled=${entityConfig.overrides?.warning === undefined} + /> +
+
+
+ `; + })} + ` : ''} +
+
+ 搜索并选择要显示的设备耗材实体,支持多选。每个实体可以配置:
+ • 属性名:留空使用实体状态,或输入属性名
+ • 名称重定义:勾选后可自定义显示名称
+ • 图标重定义:勾选后可自定义图标(如 mdi:phone)
+ • 单位重定义:勾选后可自定义单位(如 元、$、kWh 等)
+ • 预警条件:勾选后设置预警条件,支持 >10, >=10, <10, <=10, ==10, ==on, ==off, =="hello world" 等
+ • 未勾选重定义时,将使用实体的原始属性值 +
+
+
+ + `; + } + + _entityChanged(e) { + const { name, value } = e.target; + if (!value && name !== 'theme' && name !== 'width' ) return; + + let finalValue = value; + + // 处理不同字段的默认值 + if (name === 'width') { + finalValue = value || '100%'; + } + + this.config = { + ...this.config, + [name]: finalValue + }; + + this.dispatchEvent(new CustomEvent('config-changed', { + detail: { config: this.config }, + bubbles: true, + composed: true + })); + } + + _onEntitySearch(e) { + const searchTerm = e.target.value.toLowerCase(); + this._searchTerm = searchTerm; + this._showEntityList = true; + + if (!this.hass) return; + + // 获取所有实体 + const allEntities = Object.values(this.hass.states); + + // 过滤实体 + this._filteredEntities = allEntities.filter(entity => { + const entityId = entity.entity_id.toLowerCase(); + const friendlyName = (entity.attributes.friendly_name || '').toLowerCase(); + + return entityId.includes(searchTerm) || friendlyName.includes(searchTerm); + }).slice(0, 50); // 限制显示数量 + + this.requestUpdate(); + } + + _toggleEntity(entityId) { + const currentEntities = this.config.entities || []; + let newEntities; + + if (currentEntities.some(e => e.entity_id === entityId)) { + // 移除实体 + newEntities = currentEntities.filter(e => e.entity_id !== entityId); + } else { + // 添加实体 + newEntities = [...currentEntities, { + entity_id: entityId, + attribute: null, + overrides: undefined + }]; + } + + this.config = { + ...this.config, + entities: newEntities + }; + + this.dispatchEvent(new CustomEvent('config-changed', { + detail: { config: this.config }, + bubbles: true, + composed: true + })); + + this.requestUpdate(); + } + + _removeEntity(index) { + const currentEntities = this.config.entities || []; + const newEntities = currentEntities.filter((_, i) => i !== index); + + this.config = { + ...this.config, + entities: newEntities + }; + + this.dispatchEvent(new CustomEvent('config-changed', { + detail: { config: this.config }, + bubbles: true, + composed: true + })); + + this.requestUpdate(); + } + + _updateEntityAttribute(index, attributeValue) { + const currentEntities = this.config.entities || []; + const newEntities = [...currentEntities]; + + if (newEntities[index]) { + newEntities[index] = { + ...newEntities[index], + attribute: attributeValue.trim() || null + }; + } + + this.config = { + ...this.config, + entities: newEntities + }; + + this.dispatchEvent(new CustomEvent('config-changed', { + detail: { config: this.config }, + bubbles: true, + composed: true + })); + + this.requestUpdate(); + } + + _updateEntityOverride(index, overrideType, enabled) { + const currentEntities = this.config.entities || []; + const newEntities = [...currentEntities]; + + if (newEntities[index]) { + const overrides = { ...newEntities[index].overrides }; + + if (enabled) { + // 启用覆盖,设置默认值 + overrides[overrideType] = ''; + } else { + // 禁用覆盖,删除该属性 + delete overrides[overrideType]; + } + + newEntities[index] = { + ...newEntities[index], + overrides: Object.keys(overrides).length > 0 ? overrides : undefined + }; + } + + this.config = { + ...this.config, + entities: newEntities + }; + + this.dispatchEvent(new CustomEvent('config-changed', { + detail: { config: this.config }, + bubbles: true, + composed: true + })); + + this.requestUpdate(); + } + + _updateEntityOverrideValue(index, overrideType, value) { + const currentEntities = this.config.entities || []; + const newEntities = [...currentEntities]; + + if (newEntities[index] && newEntities[index].overrides && newEntities[index].overrides[overrideType] !== undefined) { + const overrides = { ...newEntities[index].overrides }; + overrides[overrideType] = value.trim(); + + newEntities[index] = { + ...newEntities[index], + overrides: overrides + }; + } + + this.config = { + ...this.config, + entities: newEntities + }; + + this.dispatchEvent(new CustomEvent('config-changed', { + detail: { config: this.config }, + bubbles: true, + composed: true + })); + + this.requestUpdate(); + } + + // 点击外部关闭下拉列表 + firstUpdated() { + document.addEventListener('click', (e) => { + if (!e.target.closest('.entity-selector')) { + this._showEntityList = false; + this.requestUpdate(); + } + }); + } + + constructor() { + super(); + this._searchTerm = ''; + this._filteredEntities = []; + this._showEntityList = false; + } + + setConfig(config) { + this.config = config; + } +} +customElements.define('xiaoshi-consumables-card-editor', XiaoshiConsumablesCardEditor); + +class XiaoshiConsumablesCard extends LitElement { + static get properties() { + return { + hass: Object, + config: Object, + _oilPriceData: Array, + _loading: Boolean, + _refreshInterval: Number, + theme: { type: String } + }; + } + + static get styles() { + return css` + :host { + display: block; + width: var(--card-width, 100%); + } + + ha-card { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + background: var(--bg-color, #fff); + border-radius: 12px; + } + + /*标题容器*/ + .card-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + background: var(--bg-color, #fff); + + border-radius: 12px; + } + + /*标题红色圆点*/ + .offline-indicator { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 8px; + } + + /*标题红色圆点动画*/ + @keyframes pulse { + 0% { opacity: 1; } + 50% { opacity: 0.5; } + 100% { opacity: 1; } + } + + /*标题*/ + .card-title { + font-size: 20px; + font-weight: 500; + color: var(--fg-color, #000); + height: 30px; + line-height: 30px; + display: flex; + align-items: center; + justify-content: center; + + + } + + /*标题统计数字*/ + .device-count { + color: var(--fg-color, #000); + border-radius: 8px; + font-size: 13px; + width: 30px; + height: 30px; + text-align: center; + line-height: 30px; + font-weight: bold; + padding: 0px; + } + + .device-count.non-zero { + background: rgb(2, 250, 250, 0.5); + } + + .device-count.zero { + background: rgb(0, 205, 0); + } + + /*标题刷新按钮*/ + .refresh-btn { + color: var(--fg-color, #fff); + border: none; + border-radius: 8px; + padding: 5px; + cursor: pointer; + font-size: 13px; + width: 50px; + height: 30px; + line-height: 30px; + text-align: center; + font-weight: bold; + padding: 0px; + } + + /*2级标题*/ + .section-divider { + margin: 0 0 8px 0; + padding: 8px 8px; + background: var(--bg-color, #fff); + font-weight: 500; + color: var(--fg-color, #000); + border-top: 1px solid rgb(150,150,150,0.5); + border-bottom: 1px solid rgb(150,150,150,0.5); + margin: 0 16px 0 16px; + + } + + /*2级标题字体*/ + .section-title { + display: flex; + align-items: center; + justify-content: space-between; + color: var(--fg-color, #000); + font-size: 13px; + } + + /*2级标题,统计数量字体*/ + .section-count { + background: rgb(255,0,0,0.5); + color: var(--fg-color, #000); + border-radius: 12px; + width: 15px; + height: 15px; + text-align: center; + line-height: 15px; + padding: 3px; + font-size: 12px; + font-weight: bold; + } + + /*设备、实体明细*/ + .device-item { + display: flex; + align-items: center; + justify-content: space-between; + margin: 0px 16px; + padding: 8px 0; + border-bottom: 1px solid rgb(150,150,150,0.5); + cursor: pointer; + transition: background-color 0.2s; + } + + .device-item:first-child { + border-top: 1px solid rgb(150,150,150,0.5); + } + + .device-item:hover { + background-color: rgba(150,150,150,0.1); + } + + /*设备、实体明细背景*/ + .devices-list { + flex: 1; + overflow-y: auto; + min-height: 0; + padding: 0 0 8px 0; + } + + .device-left { + display: flex; + align-items: center; + flex: 1; + min-width: 0; + } + + .device-icon { + margin-right: 12px; + color: var(--fg-color, #000); + flex-shrink: 0; + } + + .device-name { + color: var(--fg-color, #000); + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .device-value { + color: var(--fg-color, #000); + font-size: 12px; + margin-left: auto; + flex-shrink: 0; + font-weight: bold; + } + + .device-value.warning { + color: #F44336; + } + + .device-unit { + font-size: 12px; + color: var(--fg-color, #000); + margin-left: 4px; + font-weight: bold; + } + + .device-unit.warning { + color: #F44336; + } + + .no-devices { + text-align: center; + padding: 10px 0; + color: var(--fg-color, #000); + } + + .loading { + text-align: center; + padding: 10px 0; + color: var(--fg-color, #000); + } + `; + } + + constructor() { + super(); + this._oilPriceData = []; + this._loading = false; + this._refreshInterval = null; + this.theme = 'on'; + } + + static getConfigElement() { + return document.createElement("xiaoshi-consumables-card-editor"); + } + + connectedCallback() { + super.connectedCallback(); + this._loadOilPriceData(); + + // 设置主题属性 + this.setAttribute('theme', this._evaluateTheme()); + + // 每300秒刷新一次数据,减少频繁刷新 + this._refreshInterval = setInterval(() => { + this._loadOilPriceData(); + }, 300000); + } + + _evaluateTheme() { + try { + if (!this.config || !this.config.theme) return 'on'; + if (typeof this.config.theme === 'function') { + return this.config.theme(); + } + if (typeof this.config.theme === 'string' && + (this.config.theme.includes('return') || this.config.theme.includes('=>'))) { + return (new Function(`return ${this.config.theme}`))(); + } + return this.config.theme; + } catch(e) { + console.error('计算主题时出错:', e); + return 'on'; + } + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this._refreshInterval) { + clearInterval(this._refreshInterval); + } + } + + async _loadOilPriceData() { + if (!this.hass) return; + + this._loading = true; + this.requestUpdate(); + + try { + const entities = this.config.entities || []; + const consumablesData = []; + + for (const entityConfig of entities) { + const entityId = entityConfig.entity_id; + const attributeName = entityConfig.attribute; + const entity = this.hass.states[entityId]; + if (!entity) continue; + + const attributes = entity.attributes; + let value = entity.state; + let unit = '元'; + + // 如果指定了属性,则使用属性值 + if (attributeName && attributes[attributeName] !== undefined) { + value = attributes[attributeName]; + } + + // 尝试从属性中获取单位 + if (attributes.unit_of_measurement) { + unit = attributes.unit_of_measurement; + } else { + // 如果实体没有单位,则不显示单位 + unit = ''; + } + + // 应用属性重定义 + let friendlyName = attributes.friendly_name || entityId; + let icon = attributes.icon || 'mdi:help-circle'; + let warningThreshold = undefined; + + // 应用用户自定义的重定义 + if (entityConfig.overrides) { + if (entityConfig.overrides.name !== undefined && entityConfig.overrides.name !== '') { + friendlyName = entityConfig.overrides.name; + } + if (entityConfig.overrides.icon !== undefined && entityConfig.overrides.icon !== '') { + icon = entityConfig.overrides.icon; + } + if (entityConfig.overrides.unit_of_measurement !== undefined && entityConfig.overrides.unit_of_measurement !== '') { + unit = entityConfig.overrides.unit_of_measurement; + } + if (entityConfig.overrides.warning !== undefined && entityConfig.overrides.warning !== '') { + warningThreshold = entityConfig.overrides.warning; // 保持原始字符串 + } + } + + consumablesData.push({ + entity_id: entityId, + friendly_name: friendlyName, + value: value, + unit: unit, + icon: icon, + warning_threshold: warningThreshold + }); + } + + this._oilPriceData = consumablesData; + } catch (error) { + console.error('加载设备耗材数据失败:', error); + this._oilPriceData = []; + } + + this._loading = false; + } + + _handleRefresh() { + this._loadOilPriceData(); + navigator.vibrate(50); + } + + _handleEntityClick(entity) { + navigator.vibrate(50); + // 点击实体时打开实体详情页 + if (entity.entity_id) { + const evt = new Event('hass-more-info', { composed: true }); + evt.detail = { entityId: entity.entity_id }; + this.dispatchEvent(evt); + } + } + + _evaluateWarningCondition(value, condition) { + if (!condition) return false; + + // 解析条件字符串,支持操作符后可能有空格 + const match = condition.match(/^(>=|<=|>|<|==|!=)\s*(.+)$/); + if (!match) return false; + + const operator = match[1]; + let compareValue = match[2].trim(); + + // 移除比较值两端的引号(如果有的话) + if ((compareValue.startsWith('"') && compareValue.endsWith('"')) || + (compareValue.startsWith("'") && compareValue.endsWith("'"))) { + compareValue = compareValue.slice(1, -1); + } + + // 尝试将值转换为数字 + const numericValue = parseFloat(value); + const numericCompare = parseFloat(compareValue); + + // 如果两个值都是数字,进行数值比较 + if (!isNaN(numericValue) && !isNaN(numericCompare)) { + switch (operator) { + case '>': return numericValue > numericCompare; + case '>=': return numericValue >= numericCompare; + case '<': return numericValue < numericCompare; + case '<=': return numericValue <= numericCompare; + case '==': return numericValue === numericCompare; + case '!=': return numericValue !== numericCompare; + } + } + + // 字符串比较(用于 ==on, ==off, ==66 66 等) + const stringValue = String(value); + const stringCompare = compareValue; + + switch (operator) { + case '==': return stringValue === stringCompare; + case '!=': return stringValue !== stringCompare; + case '>': return stringValue > stringCompare; + case '>=': return stringValue >= stringCompare; + case '<': return stringValue < stringCompare; + case '<=': return stringValue <= stringCompare; + } + + return false; + } + + + render() { + if (!this.hass) { + return html`
等待Home Assistant连接...
`; + } + // 获取主题和颜色 + const theme = this._evaluateTheme(); + const fgColor = theme === 'on' ? 'rgb(0, 0, 0)' : 'rgb(255, 255, 255)'; + const bgColor = theme === 'on' ? 'rgb(255, 255, 255)' : 'rgb(50, 50, 50)'; + + return html` + +
+
+ + ${this.config.name || '耗材信息统计'} +
+
+ +
+ ${this._loading ? + html`
加载中...
` : + + this._oilPriceData.length === 0 ? + html`
请配置耗材实体
` : + html` + ${this._oilPriceData.map(consumablesData => { + // 明细预警优先级最高 + let isWarning = false; + + // 首先检查明细预警,如果存在且满足条件,直接设为预警状态 + if (consumablesData.warning_threshold && consumablesData.warning_threshold.trim() !== '') { + isWarning = this._evaluateWarningCondition(consumablesData.value, consumablesData.warning_threshold); + console.log(`明细预警 - 实体: ${consumablesData.friendly_name}, 值: "${consumablesData.value}", 条件: "${consumablesData.warning_threshold}", 预警: ${isWarning}`); + } else { + // 只有在没有明细预警时才检查全局预警 + if (this.config.global_warning && this.config.global_warning.trim() !== '') { + isWarning = this._evaluateWarningCondition(consumablesData.value, this.config.global_warning); + console.log(`全局预警 - 实体: ${consumablesData.friendly_name}, 值: "${consumablesData.value}", 条件: "${this.config.global_warning}", 预警: ${isWarning}`); + } + } + + return html` +
this._handleEntityClick(consumablesData)}> +
+ +
${consumablesData.friendly_name}
+
+
+ ${consumablesData.value} + ${consumablesData.unit} +
+
+ `; + })} + ` + } +
+
+ `; + } + + setConfig(config) { + this.config = config; + + // 设置CSS变量来控制卡片的宽度和高度 + if (config.width) { + this.style.setProperty('--card-width', config.width); + } + + // 设置主题 + if (config.theme) { + this.setAttribute('theme', config.theme); + } + } + + getCardSize() { + // 根据设备耗材实体数量动态计算卡片大小 + const baseSize = 3; + const entitySize = Math.max(0, Math.min(this._oilPriceData.length * 2, 10)); + return baseSize + entitySize; + } +} +customElements.define('xiaoshi-consumables-card', XiaoshiConsumablesCard);