From 9a59c2bb900b292926e07d8da242d586d0029837 Mon Sep 17 00:00:00 2001 From: xiaoshi <115949669+xiaoshi930@users.noreply.github.com> Date: Tue, 25 Nov 2025 13:34:43 +0800 Subject: [PATCH] Create xiaoshi-device-todo-card.js --- xiaoshi-device-todo-card.js | 1283 +++++++++++++++++++++++++++++++++++ 1 file changed, 1283 insertions(+) create mode 100644 xiaoshi-device-todo-card.js diff --git a/xiaoshi-device-todo-card.js b/xiaoshi-device-todo-card.js new file mode 100644 index 0000000..dfa6d63 --- /dev/null +++ b/xiaoshi-device-todo-card.js @@ -0,0 +1,1283 @@ +class XiaoshiTodoCardEditor 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; + } + + .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 { + display: inline-flex; + align-items: center; + gap: 4px; + background: #f0f0f0; + padding: 4px 8px; + border-radius: 16px; + margin: 2px 4px 2px 0; + font-size: 12px; + color: #000; + } + + .remove-btn { + background: none; + border: none; + cursor: pointer; + padding: 0; + display: flex; + align-items: center; + color: #f44336; + } + + .remove-btn:hover { + color: #d32f2f; + } + `; + } + + 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.includes(entity.entity_id) ? + html`` : ''} +
+ `)} + ${this._filteredEntities.length === 0 ? html` +
未找到匹配的实体
+ ` : ''} +
+ ` : ''} +
+
+ ${this.config.entities && this.config.entities.length > 0 ? html` +
已选择的实体:
+ ${this.config.entities.map(entityId => { + const entity = this.hass.states[entityId]; + return html` +
+ + ${entity?.attributes.friendly_name || entityId} + +
+ `; + })} + ` : ''} +
+
+ 搜索并选择要显示的待办事项实体,支持多选 +
+
+
+ + `; + } + + _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); + + // 过滤实体,默认只显示todo.开头的实体 + this._filteredEntities = allEntities.filter(entity => { + const entityId = entity.entity_id.toLowerCase(); + const friendlyName = (entity.attributes.friendly_name || '').toLowerCase(); + + // 默认只显示todo.开头的实体,或者搜索时匹配搜索词 + const isTodoEntity = entityId.startsWith('todo.'); + const matchesSearch = entityId.includes(searchTerm) || friendlyName.includes(searchTerm); + + return isTodoEntity && matchesSearch; + }).slice(0, 50); // 限制显示数量 + + this.requestUpdate(); + } + + _toggleEntity(entityId) { + const currentEntities = this.config.entities || []; + let newEntities; + + if (currentEntities.includes(entityId)) { + // 移除实体 + newEntities = currentEntities.filter(id => id !== entityId); + } else { + // 添加实体 + newEntities = [...currentEntities, entityId]; + } + + this.config = { + ...this.config, + entities: newEntities + }; + + this.dispatchEvent(new CustomEvent('config-changed', { + detail: { config: this.config }, + bubbles: true, + composed: true + })); + + this.requestUpdate(); + } + + _removeEntity(entityId) { + const currentEntities = this.config.entities || []; + const newEntities = currentEntities.filter(id => id !== entityId); + + 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-todo-card-editor', XiaoshiTodoCardEditor); + +class XiaoshiTodoCard extends LitElement { + static get properties() { + return { + hass: Object, + config: Object, + _todoData: Array, + _loading: Boolean, + _refreshInterval: Number, + theme: { type: String }, + _editingItem: { type: Object }, + _expandedAddForm: { type: Object } + }; + } + + 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; + background: rgb(255, 165, 0); + } + + /*标题红色圆点动画*/ + @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; + background: rgb(255, 165, 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; + background: rgb(255, 165, 0); + } + + /*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,165,0); + 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; + padding: 0px; + border-bottom: 1px solid rgb(150,150,150,0.2); + margin: 0 32px 0px 32px; + } + + /*设备、实体明细背景*/ + .devices-list { + flex: 1; + overflow-y: auto; + min-height: 0; + padding: 0 0 8px 0; + } + + .device-icon { + margin-right: 12px; + color: var(--error-color); + } + + .device-info { + flex-grow: 1; + padding: 6px 0; + } + + .device-name { + font-weight: 500; + color: var(--fg-color, #000); + padding: 6px 0 0 0; + } + + .device-entity { + font-size: 10px; + color: var(--fg-color, #000); + font-family: monospace; + } + + .device-details { + font-size: 10px; + color: var(--fg-color, #000); + } + + .device-last-seen { + font-size: 10px; + color: var(--fg-color, #000); + margin-left: auto; + } + + .no-devices { + text-align: center; + padding: 8px 0 0 0; + color: var(--fg-color, #000); + } + + .loading { + text-align: center; + padding: 0px; + color: var(--fg-color, #000); + } + + /*加油图标样式*/ + .device-details ha-icon { + --mdc-icon-size: 12px; + color: var(--fg-color, #000); + } + + /*待办事项样式*/ + .todo-item { + transition: background-color 0.2s ease; + } + + .todo-item:hover { + background-color: rgba(150,150,150,0.1); + border-radius: 4px; + } + + .todo-item input[type="checkbox"] { + cursor: pointer; + } + + .todo-item button { + background: none; + border: none; + cursor: pointer; + padding: 4px; + border-radius: 4px; + transition: background-color 0.2s ease; + } + + .todo-item button { + color: #f44336; + } + + .todo-item button:hover { + background-color: rgba(244, 67, 54, 0.1); + color: #d32f2f; + } + + .add-todo { + display: flex; + gap: 4px; + margin-top: 8px; + } + + .add-todo input { + flex: 1; + padding: 4px; + border-radius: 4px; + background: var(--bg-color, #fff); + border: 1px solid var(--fg-color, #000); + color: var(--fg-color, #000); + } + + .add-todo button { + padding: 4px 8px; + border-radius: 4px; + border: 1px solid var(--fg-color, #000); + background: var(--bg-color, #fff); + color: var(--fg-color, #000); + cursor: pointer; + } + + .add-todo input:focus { + outline: none; + border-color: #2196F3; + box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2); + } + + .add-todo-expanded { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 8px; + padding: 8px; + border: 1px solid var(--fg-color, #000); + border-radius: 4px; + background: var(--bg-color, #fff); + } + + .add-todo-row { + display: flex; + gap: 8px; + align-items: center; + } + + .add-todo-description { + flex: 1; + padding: 4px; + border: 1px solid var(--fg-color, #000); + border-radius: 4px; + background: var(--bg-color, #fff); + color: var(--fg-color, #000); + font-size: 13px; + } + + .add-todo-description:focus { + outline: none; + border-color: #2196F3; + box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2); + } + + .add-todo-date { + padding: 4px; + border: 1px solid var(--fg-color, #000); + border-radius: 4px; + background: var(--bg-color, #fff); + color: var(--fg-color, #000); + font-size: 12px; + width: 120px; + } + + /* 确保日期输入框显示正确的格式 */ + input[type="date"] { + color-scheme: light dark; + } + + input[type="date"]::-webkit-calendar-picker-indicator { + cursor: pointer; + filter: invert(0.5); + } + + /* 深色主题下的日期选择器 */ + [theme="off"] input[type="date"]::-webkit-calendar-picker-indicator { + filter: invert(1); + } + + .add-todo-toggle { + background: none; + border: 1px solid var(--fg-color, #000); + color: var(--fg-color, #000); + padding: 4px 8px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + margin-bottom: 8px; + } + + .add-todo-toggle:hover { + background-color: rgba(33, 150, 243, 0.1); + border-color: #2196F3; + } + + .todo-content { + flex: 1; + display: flex; + flex-direction: column; + } + + .todo-main { + display: flex; + align-items: center; + } + + .todo-due { + color: #ff9800; + font-size: 12px; + margin-left: 4px; + font-weight: 500; + } + + .todo-description { + color: #999; + font-size: 11px; + margin-top: 2px; + line-height: 1.3; + } + + .todo-item.no-description { + align-items: center; + } + + .todo-item.no-description input[type="checkbox"] { + margin-top: 0; + } + + .todo-item .edit-btn { + background: none; + border: none; + cursor: pointer; + padding: 4px; + border-radius: 4px; + transition: background-color 0.2s ease; + color: #2196F3 !important; + margin-right: 4px; + } + + .todo-item .edit-btn:hover { + background-color: rgba(33, 150, 243, 0.1); + color: #1976D2 !important; + } + + .edit-input { + flex: 1; + padding: 4px; + border: 1px solid var(--fg-color, #000); + border-radius: 4px; + background: var(--bg-color, #fff); + color: var(--fg-color, #000); + font-size: 13px; + margin-right: 8px; + } + + .edit-input:focus { + outline: none; + border-color: #2196F3; + box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2); + } + `; + } + + constructor() { + super(); + this._todoData = []; + this._loading = false; + this._refreshInterval = null; + this.theme = 'on'; + this._editingItem = null; + this._expandedAddForm = {}; + } + + static getConfigElement() { + return document.createElement("xiaoshi-todo-card-editor"); + } + + connectedCallback() { + super.connectedCallback(); + this._loadTodoData(); + + // 设置主题属性 + this.setAttribute('theme', this._evaluateTheme()); + + // 每300秒刷新一次数据,减少频繁刷新 + this._refreshInterval = setInterval(() => { + this._loadTodoData(); + }, 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'; + } + } + + _formatDate(dateString) { + if (!dateString) return ''; + + const date = new Date(dateString); + + // 检查日期是否有效 + if (isNaN(date.getTime())) { + return dateString; // 如果无法解析,返回原字符串 + } + + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + + return `${year}-${month}-${day}`; + } + + _formatDateForInput(dateString) { + if (!dateString) return ''; + + const date = new Date(dateString); + + // 检查日期是否有效 + if (isNaN(date.getTime())) { + return ''; + } + + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + + return `${year}-${month}-${day}`; + } + + _calculateDueDate(dueDate) { + if (!dueDate) return ''; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const due = new Date(dueDate); + + // 检查日期是否有效 + if (isNaN(due.getTime())) { + return dueDate; // 如果无法解析,返回原字符串 + } + + due.setHours(0, 0, 0, 0); + + const diffTime = due - today; + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) { + return '今天'; + } else if (diffDays === 1) { + return '明天'; + } else if (diffDays === -1) { + return '昨天'; + } else if (diffDays > 0 && diffDays <= 7) { + return `${diffDays}天后`; + } else if (diffDays > 7) { + return this._formatDate(dueDate); + } else { + return `${Math.abs(diffDays)}天前`; + } + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this._refreshInterval) { + clearInterval(this._refreshInterval); + } + } + + async _loadTodoData() { + if (!this.hass) return; + + this._loading = true; + this.requestUpdate(); + + try { + const entities = this.config.entities || []; + const todoData = []; + + for (const entityId of entities) { + const entity = this.hass.states[entityId]; + if (!entity) continue; + + // 获取待办事项项目 + let todoItems = []; + try { + // 获取所有待办事项 - 使用 WebSocket API + const result = await this.hass.callWS({ + type: 'todo/item/list', + entity_id: entityId + }); + + todoItems = result.items || []; + } catch (error) { + console.error(`获取待办事项失败 ${entityId}:`, error); + } + + const attributes = entity.attributes; + todoData.push({ + entity_id: entityId, + friendly_name: attributes.friendly_name || entityId, + icon: attributes.icon || 'mdi:format-list-checks', + state: entity.state || '0', + items: todoItems, + incomplete_count: todoItems.filter(item => item.status === 'needs_action').length, + completed_count: todoItems.filter(item => item.status === 'completed').length + }); + } + + this._todoData = todoData; + } catch (error) { + console.error('加载待办事项数据失败:', error); + this._todoData = []; + } + + this._loading = false; + } + + _handleRefresh() { + this._loadTodoData(); + 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); + } + } + + async _addTodoItem(entityId, item, description = '', due = '') { + try { + const params = { + entity_id: entityId, + item: item + }; + + // 只有当描述不为空时才添加 + if (description && description.trim()) { + params.description = description.trim(); + } + + // 只有当日期不为空时才添加 + if (due && due.trim()) { + params.due_date = due.trim(); + } + + await this.hass.callService('todo', 'add_item', params); + this._loadTodoData(); // 重新加载数据 + } catch (error) { + console.error('添加待办事项失败:', error); + } + } + + async _removeTodoItem(entityId, item) { + try { + await this.hass.callService('todo', 'remove_item', { + entity_id: entityId, + item: item + }); + this._loadTodoData(); // 重新加载数据 + } catch (error) { + console.error('删除待办事项失败:', error); + } + } + + async _updateTodoItem(entityId, item, status) { + try { + await this.hass.callService('todo', 'update_item', { + entity_id: entityId, + item: item, + status: status + }); + this._loadTodoData(); // 重新加载数据 + } catch (error) { + console.error('更新待办事项失败:', error); + } + } + + async _editTodoItem(entityId, oldItem, newItem, description = '', due = '') { + try { + // 先删除旧的待办事项,然后添加新的 + await this.hass.callService('todo', 'remove_item', { + entity_id: entityId, + item: oldItem + }); + + const params = { + entity_id: entityId, + item: newItem + }; + + // 只有当描述不为空时才添加 + if (description && description.trim()) { + params.description = description.trim(); + } + + // 只有当日期不为空时才添加 + if (due && due.trim()) { + params.due_date = due.trim(); + } + + await this.hass.callService('todo', 'add_item', params); + this._loadTodoData(); // 重新加载数据 + } catch (error) { + console.error('修改待办事项失败:', error); + } + } + + 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)'; + + // 计算总待办数量 + const totalIncompleteCount = this._todoData.reduce((sum, todo) => sum + todo.incomplete_count, 0); + + return html` + +
+
+ + 待办事项 +
+
+ + ${totalIncompleteCount} + + +
+
+ +
+ ${this._loading ? + html`
加载中...
` : + + this._todoData.length === 0 ? + html`
请配置待办事项实体
` : + html` + ${this._todoData.map(todoData => html` +
+
+ ${todoData.friendly_name} + ${todoData.incomplete_count} +
+
+
+
+ ${todoData.items.length === 0 ? + html`
暂无待办事项
` : + html` + ${todoData.items.map(item => { + const dueText = this._calculateDueDate(item.due); + const isEditing = this._editingItem && this._editingItem.entityId === todoData.entity_id && this._editingItem.uid === item.uid; + + return html` +
+ this._updateTodoItem(todoData.entity_id, item.summary || item.uid, e.target.checked ? 'completed' : 'needs_action')} + style="margin-right: 8px; margin-top: 2px;" + /> + ${isEditing ? html` +
+ { + this._editingItem.summary = e.target.value; + this.requestUpdate(); + }} + /> + +
+ { + this._editingItem.due = e.target.value; + this.requestUpdate(); + }} + /> + + +
+
+ ` : html` +
+
+ ${item.summary} + ${dueText ? html`(${dueText})` : ''} +
+ ${item.description ? html`
${item.description}
` : ''} +
+ `} + ${!isEditing ? html` + + ` : ''} + +
+ `; + })} + ` + } +
+ + + ${this._expandedAddForm[todoData.entity_id] ? html` +
+ { + if (e.key === 'Enter') { + e.preventDefault(); + const descInput = e.target.parentElement.querySelector('.add-todo-description:nth-of-type(2)'); + const dateInput = e.target.parentElement.querySelector('.add-todo-date'); + if (e.target.value.trim()) { + this._addTodoItem(todoData.entity_id, e.target.value.trim(), descInput.value, dateInput.value); + e.target.value = ''; + descInput.value = ''; + dateInput.value = ''; + } + } + }} + /> + +
+ + +
+
+ ` : ''} +
+
+
+ `)} + ` + } +
+
+ `; + } + + 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._todoData.length * 2, 10)); + return baseSize + entitySize; + } +} +customElements.define('xiaoshi-todo-card', XiaoshiTodoCard);