Files
xiaoshi-pad-card/xiaoshi-device-todo-button.js
2025-12-20 01:48:48 +08:00

2047 lines
64 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { LitElement, html, css } from "https://unpkg.com/lit-element@2.4.0/lit-element.js?module";
class XiaoshiTodoButtonEditor 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;
}
/*button新元素 开始*/
.checkbox-group {
display: flex;
align-items: center;
gap: 0;
margin: 0;
padding: 0;
}
.checkbox-input {
margin: 0;
}
.checkbox-label {
font-weight: normal;
margin: 0;
}
/*button新元素 结束*/
`;
}
render() {
if (!this.hass) return html``;
return html`
<div class="form">
<!-- button新元素 开始-->
<div class="checkbox-group">
<input
type="checkbox"
class="checkbox-input"
@change=${this._entityChanged}
.checked=${this.config.badge_mode === true}
name="badge_mode"
id="badge_mode"
/>
<label for="badge_mode" class="checkbox-label" style="color: orange; font-weight: bold;">
🏷️ 角标模式(勾选后只显示图标,数量>0时显示红色角标
</label>
</div>
<div class="checkbox-group">
<input
type="checkbox"
class="checkbox-input"
@change=${this._entityChanged}
.checked=${this.config.auto_hide === true}
name="auto_hide"
id="auto_hide"
/>
<label for="auto_hide" class="checkbox-label" style="color: orange; font-weight: bold;">
🚫 自动隐藏勾选后数量为0时完全不显示
</label>
</div>
<div class="form-group">
<label>按钮显示文本
<input
type="text"
@change=${this._entityChanged}
.value=${this.config.button_text !== undefined ? this.config.button_text : '待办'}
name="button_text"
placeholder="待办"
/></label>
</div>
<div class="form-group">
<label>按钮显示图标
<input
type="text"
@change=${this._entityChanged}
.value=${this.config.button_icon !== undefined ? this.config.button_icon : 'mdi:clipboard-list'}
name="button_icon"
placeholder="mdi:clipboard-list"
/></label>
</div>
<div class="checkbox-group">
<input
type="checkbox"
class="checkbox-input"
@change=${this._entityChanged}
.checked=${this.config.transparent_bg === true}
name="transparent_bg"
id="transparent_bg"
/>
<label for="transparent_bg" class="checkbox-label">
(平板端特性)透明背景(勾选后按钮背景透明)
</label>
</div>
<div class="checkbox-group">
<input
type="checkbox"
class="checkbox-input"
@change=${this._entityChanged}
.checked=${this.config.lock_white_fg === true}
name="lock_white_fg"
id="lock_white_fg"
/>
<label for="lock_white_fg" class="checkbox-label">
(平板端特性)白色图标文字(勾选后锁定显示白色)
</label>
</div>
<div class="checkbox-group">
<input
type="checkbox"
class="checkbox-input"
@change=${this._entityChanged}
.checked=${this.config.hide_icon === true}
name="hide_icon"
id="hide_icon"
/>
<label for="hide_icon" class="checkbox-label">
平板端特性)隐藏图标(勾选后隐藏图标)
</label>
</div>
<div class="checkbox-group">
<input
type="checkbox"
class="checkbox-input"
@change=${this._entityChanged}
.checked=${this.config.hide_colon === true}
name="hide_colon"
id="hide_colon"
/>
<label for="hide_colon" class="checkbox-label">
(平板端特性)隐藏冒号(勾选后不显示冒号,改为空格)
</label>
</div>
<div class="checkbox-group">
<input
type="checkbox"
class="checkbox-input"
@change=${this._entityChanged}
.checked=${this.config.hide_zero === true}
name="hide_zero"
id="hide_zero"
/>
<label for="hide_zero" class="checkbox-label">
平板端特性隐藏0值勾选后数量为0时不显示数量
</label>
</div>
<div class="form-group">
<label>按钮宽度默认65px, 支持像素(px)和百分比(%)</label>
<input
type="text"
@change=${this._entityChanged}
.value=${this.config.button_width !== undefined ? this.config.button_width : '65px'}
name="button_width"
placeholder="默认65px"
/>
</div>
<div class="form-group">
<label>按钮高度:支持像素(px)、百分比(%)和视窗高度(vh)默认24px</label>
<input
type="text"
@change=${this._entityChanged}
.value=${this.config.button_height !== undefined ? this.config.button_height : '24px'}
name="button_height"
placeholder="默认24px"
/>
</div>
<div class="form-group">
<label>按钮文字大小:支持像素(px)默认11px</label>
<input
type="text"
@change=${this._entityChanged}
.value=${this.config.button_font_size !== undefined ? this.config.button_font_size : '11px'}
name="button_font_size"
placeholder="默认11px"
/>
</div>
<div class="form-group">
<label>按钮图标大小:支持像素(px)默认13px</label>
<input
type="text"
@change=${this._entityChanged}
.value=${this.config.button_icon_size !== undefined ? this.config.button_icon_size : '13px'}
name="button_icon_size"
placeholder="默认13px"
/>
</div>
<div class="form-group">
<label>点击动作:点击按钮时触发的动作</label>
<select
@change=${this._entityChanged}
.value=${this.config.tap_action !== 'none' ? 'tap_action' : 'none'}
name="tap_action"
>
<option value="tap_action">弹出待办信息卡片(默认)</option>
<option value="none">无动作</option>
</select>
</div>
<div class="form-group">
<label>👇👇👇下方弹出的卡片可增加的其他卡片👇👇👇</label>
<textarea
@change=${this._entityChanged}
.value=${this.config.other_cards || ''}
name="other_cards"
placeholder='# 示例配置添加button卡片
- type: custom:button-card
template: 测试模板(最好引用模板,否则大概率会报错)
- type: custom:button-card
template: 测试模板(最好引用模板,否则大概率会报错)'>
</textarea>
</div>
<div class="checkbox-group">
<input
type="checkbox"
class="checkbox-input"
@change=${this._entityChanged}
.checked=${this.config.no_preview === true}
name="no_preview"
id="no_preview"
/>
<label for="no_preview" class="checkbox-label" style="color: red;">
📻显示预览📻( 请先勾选测试显示效果
</label>
</div>
<div class="form-group">
<label> </label>
<label>👇👇👇下方是弹出的主卡配置项👇👇👇</label>
<label> </label>
</div>
<!-- button新元素 结束-->
<div class="form-group">
<label>卡片宽度:支持像素(px)和百分比(%)默认100%</label>
<input
type="text"
@change=${this._entityChanged}
.value=${this.config.width !== undefined ? this.config.width : '100%'}
name="width"
placeholder="默认100%"
/>
</div>
<div class="form-group">
<label>主题</label>
<select
@change=${this._entityChanged}
.value=${this.config.theme !== undefined ? this.config.theme : 'on'}
name="theme"
>
<option value="on">浅色主题(白底黑字)</option>
<option value="off">深色主题(深灰底白字)</option>
</select>
</div>
<div class="form-group">
<label>待办事项实体:搜索并选择实体</label>
<div class="entity-selector">
<input
type="text"
@input=${this._onEntitySearch}
@focus=${this._onEntitySearch}
.value=${this._searchTerm || ''}
placeholder="搜索实体..."
class="entity-search-input"
/>
${this._showEntityList ? html`
<div class="entity-dropdown">
${this._filteredEntities.map(entity => html`
<div
class="entity-option ${this.config.entities && this.config.entities.includes(entity.entity_id) ? 'selected' : ''}"
@click=${() => this._toggleEntity(entity.entity_id)}
>
<div class="entity-info">
<ha-icon icon="${entity.attributes.icon || 'mdi:help-circle'}"></ha-icon>
<div class="entity-details">
<div class="entity-name">${entity.attributes.friendly_name || entity.entity_id}</div>
<div class="entity-id">${entity.entity_id}</div>
</div>
</div>
${this.config.entities && this.config.entities.includes(entity.entity_id) ?
html`<ha-icon icon="mdi:check" class="check-icon"></ha-icon>` : ''}
</div>
`)}
${this._filteredEntities.length === 0 ? html`
<div class="no-results">未找到匹配的实体</div>
` : ''}
</div>
` : ''}
</div>
<div class="selected-entities">
${this.config.entities && this.config.entities.length > 0 ? html`
<div class="selected-label">已选择的实体:</div>
${this.config.entities.map(entityId => {
const entity = this.hass.states[entityId];
return html`
<div class="selected-entity">
<ha-icon icon="${entity?.attributes.icon || 'mdi:help-circle'}"></ha-icon>
<span>${entity?.attributes.friendly_name || entityId}</span>
<button class="remove-btn" @click=${() => this._removeEntity(entityId)}>
<ha-icon icon="mdi:close"></ha-icon>
</button>
</div>
`;
})}
` : ''}
</div>
<div class="help-text">
搜索并选择要显示的待办事项实体,支持多选
</div>
</div>
</div>
`;
}
_entityChanged(e) {
/*button新按钮方法 开始*/
const { name, value, type, checked } = e.target;
let finalValue;
// 处理复选框
if (type === 'checkbox') {
finalValue = checked;
} else {
if (!value && name !== 'theme' && name !== 'button_width' && name !== 'button_height' && name !== 'button_font_size' && name !== 'button_icon_size' && name !== 'width' && name !== 'tap_action') return;
finalValue = value
}
// 处理不同字段的默认值
if (name === 'button_width') {
finalValue = value || '100%';
} else if (name === 'button_height') {
finalValue = value || '24px';
} else if (name === 'button_font_size') {
finalValue = value || '11px';
} else if (name === 'button_icon_size') {
finalValue = value || '13px';
} else if (name === 'width') {
finalValue = value || '100%';
} else if (name === 'tap_action') {
// 处理 tap_action 的特殊逻辑
if (value === 'tap_action') {
// 如果是弹出卡片,则不设置 tap_action让组件使用默认逻辑
finalValue = undefined;
} else {
finalValue = value;
}
}
/*button新按钮方法 结束*/
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-button-editor', XiaoshiTodoButtonEditor);
class XiaoshiTodoButton extends LitElement {
static get properties() {
return {
hass: Object,
config: Object,
_todoData: Array,
_loading: Boolean,
_refreshInterval: Number,
_dataLoaded: Boolean, //button新元素
theme: { type: String },
_editingItem: { type: Object },
_expandedAddForm: { type: Object }
};
}
static get styles() {
return css`
:host {
display: block;
width: var(--card-width, 100%);
}
/*button新元素 开始*/
.todo-status {
width: var(--button-width, 65px);
height: var(--button-height, 24px);
padding: 0;
margin: 0;
background: var(--bg-color, #fff);
color: var(--fg-color, #000);
border-radius: 10px;
font-size: var(--button-font-size, 11px);
font-weight: 500;
text-align: center;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
gap: 0;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
position: relative;
}
.status-icon {
--mdc-icon-size: var(--button-icon-size, 13px);
color: var(--fg-color, #000);
margin-right: 3px;
}
/* 角标模式样式 */
.todo-status.badge-mode {
width: var(--button-width, 65px);
height: var(--button-height, 24px);
border-radius: 10px;
padding: 0;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
}
.todo-status.badge-mode .status-icon {
color: rgb(128, 128, 128);
transition: color 0.2s;
}
.todo-status.badge-mode.has-warning .status-icon {
color: rgb(255, 0, 0);
}
.badge-number {
position: absolute;
top: -6px;
right: -6px;
min-width: 12px;
height: 12px;
background: rgb(255, 0, 0);
color: rgb(255, 255, 255);
border-radius: 50%;
font-size: 8px;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
box-sizing: border-box;
line-height: 1;
}
/*button新元素 结束*/
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-top: 8px;
margin-bottom: 2px;
}
.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._dataLoaded = false; //button新元素
this._refreshInterval = null;
this.theme = 'on';
this._editingItem = null;
this._expandedAddForm = {};
}
static getConfigElement() {
return document.createElement("xiaoshi-todo-button-editor");
}
connectedCallback() {
super.connectedCallback();
this._loadTodoData();
// 设置主题属性
this.setAttribute('theme', this._evaluateTheme());
// 每300秒刷新一次数据减少频繁刷新
this._refreshInterval = setInterval(() => {
this._loadTodoData();
}, 3000);
}
_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;
// button新元素 开始 删除下面
// this._loading = true;
// this.requestUpdate();
// button新元素 介素 删除下面
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;
this._dataLoaded = true; //button新元素
} catch (error) {
console.error('加载待办事项数据失败:', error);
this._todoData = [];
this._dataLoaded = true; //button新元素
}
this._loading = false;
}
_handleRefresh() {
this._handleClick();
this._loadTodoData();
}
_handleEntityClick(entity) {
this._handleClick();
// 点击实体时打开实体详情页
if (entity.entity_id) {
const evt = new Event('hass-more-info', { composed: true });
evt.detail = { entityId: entity.entity_id };
this.dispatchEvent(evt);
}
}
_handleClick(){
if (navigator.vibrate) {
navigator.vibrate(50);
}
else if (navigator.webkitVibrate) {
navigator.webkitVibrate(50);
}
else {
}
}
/*button新元素 开始*/
_handleButtonClick() {
const tapAction = this.config.tap_action;
if (!tapAction || tapAction !== 'none') {
// 默认 tap_action 行为:弹出垂直堆叠卡片
const excludedParams = ['type', 'button_height', 'button_width', 'button_font_size', 'button_icon_size', 'show_preview', 'tap_action'];
// 构建垂直堆叠卡片的内容
const cards = [];
// 1. 添加待办信息卡片
const todoCardConfig = {};
Object.keys(this.config).forEach(key => {
if (!excludedParams.includes(key) && key !== 'other_cards' && key !== 'no_preview') {
todoCardConfig[key] = this.config[key];
}
});
cards.push({
type: 'custom:xiaoshi-todo-card',
...todoCardConfig
});
// 2. 添加附加卡片
if (this.config.other_cards && this.config.other_cards.trim()) {
try {
const additionalCardsConfig = this._parseYamlCards(this.config.other_cards);
// 为每个附加卡片传递 theme 值
const cardsWithTheme = additionalCardsConfig.map(card => {
// 如果卡片没有 theme 配置,则从当前卡片配置中传递
if (!card.theme && this.config.theme) {
return {
...card,
theme: this.config.theme
};
}
return card;
});
cards.push(...cardsWithTheme);
} catch (error) {
console.error('解析附加卡片配置失败:', error);
}
}
// 创建垂直堆叠卡片
const popupContent = {
type: 'vertical-stack',
cards: cards
};
const popupStyle = this.config.popup_style || `
--mdc-theme-surface: rgb(0,0,0,0);
--dialog-backdrop-filter: blur(10px) brightness(1);
`;
if (window.browser_mod) {
window.browser_mod.service('popup', {
style: popupStyle,
content: popupContent
});
} else {
console.warn('browser_mod not available, cannot show popup');
}
}
this._handleClick();
}
_parseYamlCards(yamlString) {
try {
const lines = yamlString.split('\n');
const cards = [];
let currentCard = null;
let indentStack = [];
let contextStack = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const indentLevel = line.length - line.trimStart().length;
if (trimmed.startsWith('- type')) {
if (currentCard) {
cards.push(currentCard);
currentCard = null;
indentStack = [];
contextStack = [];
}
const content = trimmed.substring(1).trim();
if (content.includes(':')) {
const [key, ...valueParts] = content.split(':');
const value = valueParts.join(':').trim();
currentCard = {};
this._setNestedValue(currentCard, key.trim(), this._parseValue(value));
} else {
currentCard = { type: content };
}
indentStack = [indentLevel];
contextStack = [currentCard];
} else if (currentCard && trimmed.startsWith('-')) {
while (indentStack.length > 1 && indentLevel <= indentStack[indentStack.length - 1]) {
indentStack.pop();
contextStack.pop();
}
let currentContext = contextStack[contextStack.length - 1];
const itemValue = trimmed.substring(1).trim();
if (!Array.isArray(currentContext)) {
if (contextStack.length > 1) {
const parentContext = contextStack[contextStack.length - 2];
for (let key in parentContext) {
if (parentContext[key] === currentContext) {
parentContext[key] = [];
contextStack[contextStack.length - 1] = parentContext[key];
currentContext = parentContext[key];
break;
}
}
}
}
if (Array.isArray(currentContext)) {
if (itemValue.includes(':')) {
const [key, ...valueParts] = itemValue.split(':');
const value = valueParts.join(':').trim();
const obj = {};
obj[key.trim()] = this._parseValue(value);
currentContext.push(obj);
} else {
currentContext.push(this._parseValue(itemValue));
}
}
} else if (currentCard && trimmed.includes(':')) {
const [key, ...valueParts] = trimmed.split(':');
const value = valueParts.join(':').trim();
const keyName = key.trim();
while (indentStack.length > 1 && indentLevel <= indentStack[indentStack.length - 1]) {
indentStack.pop();
contextStack.pop();
}
const currentContext = contextStack[contextStack.length - 1];
if (value) {
this._setNestedValue(currentContext, keyName, this._parseValue(value));
} else {
let nextLine = null, nextIndent = null;
for (let j = i + 1; j < lines.length; j++) {
const nextTrimmed = lines[j].trim();
if (nextTrimmed && !nextTrimmed.startsWith('#')) {
nextLine = nextTrimmed;
nextIndent = lines[j].length - lines[j].trimStart().length;
break;
}
}
currentContext[keyName] = (nextLine && nextLine.startsWith('-') && nextIndent > indentLevel)
? [] : (currentContext[keyName] || {});
indentStack.push(indentLevel);
contextStack.push(currentContext[keyName]);
}
}
}
if (currentCard) cards.push(currentCard);
return cards;
} catch (error) {
console.error('YAML解析错误:', error);
return [];
}
}
_parseValue(value) {
if (!value) return '';
// 移除引号
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
return value.slice(1, -1);
}
// 尝试解析为数字
if (!isNaN(value) && value.trim() !== '') {
return Number(value);
}
// 尝试解析为布尔值
if (value === 'true') return true;
if (value === 'false') return false;
if (value === 'null') return null;
// 返回字符串
return value;
}
_setNestedValue(obj, path, value) {
// 支持嵌套路径,如 "styles.card"
const keys = path.split('.');
let current = obj;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (!current[key] || typeof current[key] !== 'object') {
current[key] = {};
}
current = current[key];
}
current[keys[keys.length - 1]] = value;
}
/*button新元素 结束*/
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`<div class="loading">等待Home Assistant连接...</div>`;
}
// 获取主题和颜色
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);
/*button新元素 前9行和最后1行开始*/
const showPreview = this.config.no_preview === true;
// 获取新参数
const badgeMode = this.config.badge_mode === true;
const transparentBg = this.config.transparent_bg === true;
const hideIcon = this.config.hide_icon === true;
const hideColon = this.config.hide_colon === true;
const hideZero = this.config.hide_zero === true;
const autoHide = this.config.auto_hide === true;
const lockWhiteFg = this.config.lock_white_fg === true;
const buttonText = this.config.button_text || '待办';
const buttonIcon = this.config.button_icon || 'mdi:clipboard-list';
// 设置背景颜色
const buttonBgColor = transparentBg ? 'transparent' : bgColor;
// 检查是否需要自动隐藏只有数据加载完成且数量为0时才考虑隐藏
const shouldAutoHide = this._dataLoaded && autoHide && totalIncompleteCount === 0;
// 如果需要自动隐藏返回空div
if (shouldAutoHide) {
return html`<div></div>`;
}
// 渲染按钮
let buttonHtml;
if (!this._dataLoaded) {
if (badgeMode) {
// 角标模式只显示图标数量为0时不显示角标
buttonHtml = html`
<div class="todo-status badge-mode" style="--bg-color: ${buttonBgColor};" @click=${this._handleButtonClick}>
<ha-icon class="status-icon" icon="${buttonIcon}"></ha-icon>
</div>
`;
} else {
// 普通模式
// 应用锁定白色功能
const iconColor = lockWhiteFg ? 'rgb(255, 255, 255)' : fgColor;
const textColor = lockWhiteFg ? 'rgb(255, 255, 255)' : fgColor;
// 构建显示文本
let displayText = buttonText;
// 根据hide_colon参数决定是否显示冒号
if (!hideColon) {
displayText += ':';
} else {
displayText += ' ';
}
// 根据hide_zero参数决定是否显示0值
if (!hideZero) {
displayText += ' 0';
} else {
// 隐藏0值时使用CSS空格占位符保持布局稳定
displayText += '\u2002'; // 两个en空格大约等于数字"0"的宽度
}
buttonHtml = html`
<div class="todo-status" style="--fg-color: ${textColor}; --bg-color: ${buttonBgColor};" @click=${this._handleButtonClick}>
${!hideIcon ? html`<ha-icon class="status-icon" style="color: ${iconColor};" icon="${buttonIcon}"></ha-icon>` : ''}
${displayText}
</div>
`;
}
} else {
// 数据加载完成后
if (badgeMode) {
// 角标模式:只显示图标,根据数量显示角标
const hasWarning = totalIncompleteCount > 0;
buttonHtml = html`
<div class="todo-status badge-mode ${hasWarning ? 'has-warning' : ''}" style="--bg-color: ${buttonBgColor};" @click=${this._handleButtonClick}>
<ha-icon class="status-icon" icon="${buttonIcon}"></ha-icon>
${hasWarning ? html`<div class="badge-number">${totalIncompleteCount}</div>` : ''}
</div>
`;
} else {
// 普通模式:显示文本和数量
// 应用锁定白色功能,但预警颜色(红色)不受影响
let textColor, iconColor;
if (totalIncompleteCount === 0) {
// 非预警状态:根据锁定白色设置决定颜色
textColor = lockWhiteFg ? 'rgb(255, 255, 255)' : fgColor;
iconColor = lockWhiteFg ? 'rgb(255, 255, 255)' : fgColor;
} else {
// 预警状态:始终使用红色,不受锁定白色影响
textColor = 'rgb(255, 0, 0)';
iconColor = lockWhiteFg ? 'rgb(255, 255, 255)' : fgColor;
}
// 构建显示文本
let displayText = buttonText;
// 根据hide_colon参数决定是否显示冒号
if (!hideColon) {
displayText += ':';
} else {
displayText += ' ';
}
// 根据hide_zero参数和实际数量决定是否显示数量
if (hideZero && totalIncompleteCount === 0) {
// 隐藏0值时使用CSS空格占位符保持布局稳定
displayText += '\u2002'; // 两个en空格大约等于数字"0"的宽度
} else {
displayText += ` ${totalIncompleteCount}`;
}
buttonHtml = html`
<div class="todo-status" style="--fg-color: ${textColor}; --bg-color: ${buttonBgColor};" @click=${this._handleButtonClick}>
${!hideIcon ? html`<ha-icon class="status-icon" style="color: ${iconColor};" icon="${buttonIcon}"></ha-icon>` : ''}
${displayText}
</div>
`;
}
}
// 返回最终的渲染结果(包括按钮和预览卡片)
return html`
${buttonHtml}
${showPreview ? html`
<div class="form-group">
<label>👇👇👇下面是弹出卡片内容👇👇👇</label>
</div>
<ha-card style="--fg-color: ${fgColor}; --bg-color: ${bgColor};">
<div class="card-header">
<div class="card-title">
<span class="offline-indicator"></span>
待办事项
</div>
<div style="display: flex; align-items: center; gap: 8px; ">
<span class="device-count">
${totalIncompleteCount}
</span>
<button class="refresh-btn" @click=${this._handleRefresh}>
刷新
</button>
</div>
</div>
<div class="devices-list">
${this._loading ?
html`<div class="loading">加载中...</div>` :
this._todoData.length === 0 ?
html`<div class="no-devices">请配置待办事项实体</div>` :
html`
${this._todoData.map(todoData => html`
<div class="section-divider">
<div class="section-title">
<span>${todoData.friendly_name}</span>
<span class="section-count">${todoData.incomplete_count}</span>
</div>
</div>
<div class="device-item">
<div class="device-info">
${todoData.items.length === 0 ?
html`<div class="no-devices">暂无待办事项</div>` :
html`
${(() => {
// 将待办事项分为有时间和无时间两组
const itemsWithoutTime = todoData.items.filter(item => !item.due);
const itemsWithTime = todoData.items.filter(item => item.due);
// 没有时间的按名称排序
itemsWithoutTime.sort((a, b) => (a.summary || '').localeCompare(b.summary || ''));
// 有时间的按时间排序
itemsWithTime.sort((a, b) => {
const dateA = new Date(a.due);
const dateB = new Date(b.due);
return dateA - dateB;
});
// 合并结果:无时间的在前,有时间的在后
const sortedItems = [...itemsWithoutTime, ...itemsWithTime];
return sortedItems.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`
<div class="todo-item ${!item.description ? 'no-description' : ''}" style="display: flex; padding: 4px 0; border-bottom: 1px solid rgba(150,150,150,0.1);">
<input
type="checkbox"
.checked=${item.status === 'completed'}
@change=${(e) => {
this._updateTodoItem(todoData.entity_id, item.summary || item.uid, e.target.checked ? 'completed' : 'needs_action');
this._handleClick();
}}
style="margin-right: 8px; margin-top: 2px;"
/>
${isEditing ? html`
<div style="flex: 1; display: flex; flex-direction: column; gap: 4px;">
<input
class="edit-input"
type="text"
.value=${this._editingItem.summary}
@input=${(e) => {
this._editingItem.summary = e.target.value;
this.requestUpdate();
}}
/>
<textarea
class="edit-input"
style="min-height: 40px; resize: vertical;"
placeholder="描述(可选)..."
.value=${this._editingItem.description || ''}
@input=${(e) => {
this._editingItem.description = e.target.value;
this.requestUpdate();
}}
></textarea>
<div style="display: flex; gap: 8px; align-items: center;">
<input
type="date"
class="edit-input"
style="width: auto; flex: none;"
.value=${this._editingItem.due || ''}
@input=${(e) => {
this._editingItem.due = e.target.value;
this.requestUpdate();
this._handleClick();
}}
/>
<button
style="padding: 4px 8px; background: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer;"
@click=${() => {
this._editTodoItem(todoData.entity_id, item.summary || item.uid, this._editingItem.summary, this._editingItem.description, this._editingItem.due);
this._editingItem = null;
this.requestUpdate();
this._handleClick();
}}
>
保存
</button>
<button
style="padding: 4px 8px; background: #f44336; color: white; border: none; border-radius: 4px; cursor: pointer;"
@click=${() => {
this._editingItem = null;
this.requestUpdate();
this._handleClick();
}}
>
取消
</button>
</div>
</div>
` : html`
<div class="todo-content">
<div class="todo-main" style="text-decoration: ${item.status === 'completed' ? 'line-through' : 'none'}; color: ${item.status === 'completed' ? '#999' : 'var(--fg-color, #000)'};">
<span>${item.summary}</span>
${dueText ? html`<span class="todo-due">(${dueText})</span>` : ''}
</div>
${item.description ? html`<div class="todo-description">${item.description}</div>` : ''}
</div>
`}
${!isEditing ? html`
<button
class="edit-btn"
@click=${() => {
this._editingItem = {
entityId: todoData.entity_id,
uid: item.uid,
summary: item.summary,
description: item.description || '',
due: this._formatDateForInput(item.due) || ''
};
this.requestUpdate();
this._handleClick();
}}
style="margin-left: 8px; margin-top: 2px;"
title="修改"
>
<ha-icon icon="mdi:pencil"></ha-icon>
</button>
` : ''}
<button
class="remove-btn"
@click=${() => {
this._removeTodoItem(todoData.entity_id, item.summary || item.uid);
this._handleClick();
}}
style="margin-left: 4px; margin-top: 2px;"
title="删除"
>
<ha-icon icon="mdi:delete"></ha-icon>
</button>
</div>
`;
});
})()}
`
}
<div>
<button
class="add-todo-toggle"
@click=${() => {
this._expandedAddForm = {
...this._expandedAddForm,
[todoData.entity_id]: !this._expandedAddForm[todoData.entity_id]
};
this.requestUpdate();
this._handleClick();
}}
>
${this._expandedAddForm[todoData.entity_id] ? '收起' : '添加新待办事项'}
</button>
${this._expandedAddForm[todoData.entity_id] ? html`
<div class="add-todo-expanded">
<input
type="text"
class="add-todo-description"
placeholder="待办事项名称..."
@keypress=${(e) => {
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 = '';
}
}
}}
/>
<input
type="text"
class="add-todo-description"
placeholder="描述(可选)..."
/>
<div class="add-todo-row">
<input
type="date"
class="add-todo-date"
placeholder="截止日期(可选)"
/>
<button
@click=${(e) => {
const nameInput = e.target.closest('.add-todo-expanded').querySelector('.add-todo-description:first-of-type');
const descInput = e.target.closest('.add-todo-expanded').querySelector('.add-todo-description:nth-of-type(2)');
const dateInput = e.target.closest('.add-todo-expanded').querySelector('.add-todo-date');
if (nameInput.value.trim()) {
this._addTodoItem(todoData.entity_id, nameInput.value.trim(), descInput.value, dateInput.value);
nameInput.value = '';
descInput.value = '';
dateInput.value = '';
}
this._handleClick();
}}
>
添加
</button>
</div>
</div>
` : ''}
</div>
</div>
</div>
`)}
`
}
</div>
</ha-card>
` : html``}
`;
/*button新元素 结束*/
}
setConfig(config) {
/*button新元素 开始*/
// 不设置默认值,只有明确配置时才添加 no_preview
this.config = {
...config
};
if (config.button_width) {
this.style.setProperty('--button-width', config.button_width);
} else {
this.style.setProperty('--button-width', '65px');
}
// 设置按钮高度(只控制 todo-status
if (config.button_height) {
this.style.setProperty('--button-height', config.button_height);
} else {
this.style.setProperty('--button-height', '24px');
}
// 设置按钮文字大小(只控制 todo-status
if (config.button_font_size) {
this.style.setProperty('--button-font-size', config.button_font_size);
} else {
this.style.setProperty('--button-font-size', '11px');
}
// 设置按钮图标大小(只控制 todo-status
if (config.button_icon_size) {
this.style.setProperty('--button-icon-size', config.button_icon_size);
} else {
this.style.setProperty('--button-icon-size', '13px');
}
// 设置卡片宽度(控制原来的 UI
if (config.width) {
this.style.setProperty('--card-width', config.width);
} else {
this.style.setProperty('--card-width', '100%');
}
/*button新元素 结束*/
// 设置主题
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-button', XiaoshiTodoButton);