fd44b15d81
**死锁根因修复** - periodicThinkLoop:1015 orphaned lock → 删除(字段已原子化) - RecordUserMessage 隔离为 recordMu - atomic.Int64 替换 lastUserMessage/lastThinkTime 等 **MD3 / Android 17 主题** - 毛玻璃卡片 (backdrop-filter) - MD3 色彩令牌 (pink primary #f472b6) - icons.js 独立矢量图标库 + 运行时 emoji 替换 - 无边框卡片、圆角按钮、阴影层次 **上下文持久化** - AddMessage → saveToDB 异步写 PostgreSQL - LoadFromDB 恢复 (admin-session-main + 懒加载) - LLMMessage.Timestamp 字段 **群聊与适配器** - group_ambient 模式: 非@消息让 LLM 自己判断是否插话 - 戳一戳动作消息总是回复 - NapCat 打字状态 (set_input_status, 最小3秒显示) - HTTP API 配置 (http_url/http_token) **知识库 & 防编造** - knowledge.CanHandle 对 chat 意图也触发 - 关键词预筛选避免无关 embedding 调用 - persona + synthesizer 三重诚实规则 - 工具结果持久化到会话历史 **平台桥接器** - detached:true Go进程独立存活 - ethend 重启自动接管已运行服务 - stop() 接管模式 taskkill/F/ PID - Windows netstat 替代 fuser 获取 PID - 重复适配器种子逻辑修复 - 失败转发日志 Direction: error **崩溃诊断** - crashlog 包 (Recover + WrapHTTP + LLMCall) - /api/v1/debug/goroutines 端点 - thinker 操作日志 + 30s stats - 日志写入 logs/ 目录持久化 Co-Authored-By: Claude <noreply@anthropic.com>
375 lines
16 KiB
JavaScript
375 lines
16 KiB
JavaScript
// ========== IoT 设备控制面板 ==========
|
|
function startIoTRefresh() {
|
|
stopIoTRefresh();
|
|
STATE.iotInterval = setInterval(function() {
|
|
if (STATE.activePanel === 'iot') renderIoTPanel();
|
|
}, 3000);
|
|
}
|
|
|
|
function stopIoTRefresh() {
|
|
if (STATE.iotInterval) { clearInterval(STATE.iotInterval); STATE.iotInterval = null; }
|
|
}
|
|
|
|
async function fetchIoTDevices() {
|
|
var data = await api('/api/iot/devices');
|
|
if (data.error) return { error: data };
|
|
var devices = [];
|
|
if (Array.isArray(data)) devices = data;
|
|
else if (data.devices) devices = data.devices;
|
|
return { devices: devices };
|
|
}
|
|
|
|
var IOT_DEVICE_TYPES = {
|
|
'ac': 'ac_unit', 'light': 'lightbulb', 'curtain': 'blinds', 'door_lock': 'lock',
|
|
'camera': 'camera', 'sensor': 'sensors', 'speaker': 'volume_up', 'thermostat': 'thermostat',
|
|
};
|
|
|
|
var IOT_MODE_OPTIONS = ['cool', 'heat', 'auto', 'fan', 'dry'];
|
|
var IOT_COLOR_OPTIONS = [
|
|
{ name: '暖白', value: 'warm_white', bg: '#f5d0a9' },
|
|
{ name: '冷白', value: 'cool_white', bg: '#d4e6fc' },
|
|
{ name: '暖黄', value: 'warm_yellow', bg: '#fce4a6' },
|
|
{ name: '蓝色', value: 'blue', bg: '#3b82f6' },
|
|
{ name: '紫色', value: 'purple', bg: '#a855f7' },
|
|
{ name: '绿色', value: 'green', bg: '#22c55e' },
|
|
];
|
|
|
|
async function renderIoTPanel() {
|
|
var result = await fetchIoTDevices();
|
|
var panel = document.getElementById('panel-iot');
|
|
|
|
// 更新操作栏 (只更新时间戳文本)
|
|
document.getElementById('panel-actions').innerHTML =
|
|
'<button class="btn btn-sm" onclick="renderIoTPanel()" id="iot-refresh-btn">'+icon('refresh',14)+' 刷新</button>' +
|
|
'<span class="iot-last-update">'+icon('timer',12)+' 每3秒自动刷新 · 最后更新: ' + new Date().toLocaleTimeString('zh-CN', {hour12: false}) + '</span>';
|
|
|
|
if (result.error) {
|
|
var hint = '';
|
|
if (result.error.errorType === 'iot_not_running') {
|
|
hint = '<br><span style="font-size:11px">'+icon('lightbulb',12)+' 提示: 请先在「服务管理」面板中启动 IoT Debug 服务</span>';
|
|
}
|
|
panel.innerHTML = '<div class="empty-state"><div class="icon">'+icon('warning_amber',24)+'</div>' + escHtml(result.error.error) + hint + '</div>';
|
|
STATE.iotInitialized = false;
|
|
return;
|
|
}
|
|
|
|
// 数据未变则跳过 (设备列表不变)
|
|
var devices = result.devices;
|
|
var iotHash = simpleHash(JSON.stringify(devices));
|
|
if (STATE.renderHashes && STATE.renderHashes['iot'] === iotHash) return;
|
|
if (STATE.renderHashes) STATE.renderHashes['iot'] = iotHash;
|
|
|
|
// 固定设备排列顺序: 先按类型,同类型再按 device_id
|
|
var typeOrder = { 'sensor': 1, 'ac': 2, 'light': 3, 'curtain': 4, 'lock': 5, 'camera': 6, 'speaker': 7, 'thermostat': 8 };
|
|
devices.sort(function(a, b) {
|
|
var oa = typeOrder[a.type] || 99;
|
|
var ob = typeOrder[b.type] || 99;
|
|
if (oa !== ob) return oa - ob;
|
|
return (a.id || a.entity_id || '').localeCompare(b.id || b.entity_id || '');
|
|
});
|
|
|
|
var badge = document.getElementById('iot-badge');
|
|
if (badge) {
|
|
badge.textContent = devices.length;
|
|
badge.style.display = devices.length > 0 ? 'inline-block' : 'none';
|
|
}
|
|
|
|
// Bug 7: 增量更新 — 首次渲染完整 DOM,后续只更新设备属性值
|
|
var grid = document.getElementById('iot-device-grid');
|
|
var firstRender = !STATE.iotInitialized;
|
|
|
|
if (firstRender || !grid) {
|
|
// 首次渲染: 创建完整结构
|
|
var html = '<div class="iot-refresh-bar">' +
|
|
'<span style="font-weight:600;font-size:14px" id="iot-device-count">'+icon('sensors',16)+' 模拟 IoT 设备 (' + devices.length + ')</span>' +
|
|
'<span style="font-size:11px;color:var(--text2)">通过 IoT 调试服务 (端口 8083) 管理</span>' +
|
|
'</div><div class="iot-device-grid" id="iot-device-grid">' +
|
|
devices.map(function(d) { return renderIoTDeviceCard(d); }).join('') +
|
|
'</div>';
|
|
panel.innerHTML = html;
|
|
STATE.iotInitialized = true;
|
|
} else {
|
|
// 增量更新: 只更新设备数量、最后更新时间
|
|
var countEl = document.getElementById('iot-device-count');
|
|
if (countEl) countEl.innerHTML = icon('sensors',16)+' 模拟 IoT 设备 (' + devices.length + ')';
|
|
|
|
// 对每个已有设备卡片做增量更新
|
|
devices.forEach(function(device) {
|
|
updateIoTDeviceCardInPlace(device);
|
|
});
|
|
}
|
|
}
|
|
|
|
// Bug 7 helper: 增量更新单个设备卡片的属性值,不重建 DOM
|
|
function updateIoTDeviceCardInPlace(device) {
|
|
var card = document.getElementById('iot-card-' + device.id);
|
|
if (!card) return;
|
|
|
|
var isOn = device.status === 'on';
|
|
|
|
// 更新卡片 class (on/off 边框)
|
|
card.className = 'iot-device-card ' + (isOn ? 'on' : 'off');
|
|
|
|
// 更新状态圆点和文字
|
|
var statusDot = card.querySelector('.iot-status-dot');
|
|
if (statusDot) statusDot.className = 'iot-status-dot ' + (isOn ? 'on' : 'off');
|
|
var statusText = card.querySelector('.iot-device-status span:last-child');
|
|
if (statusText) {
|
|
statusText.textContent = isOn ? '开启' : '关闭';
|
|
statusText.style.color = isOn ? 'var(--green)' : 'var(--text3)';
|
|
}
|
|
|
|
// 更新开关按钮
|
|
var toggleBtn = card.querySelector('.iot-toggle-btn');
|
|
if (toggleBtn) {
|
|
toggleBtn.className = 'iot-toggle-btn ' + (isOn ? 'on' : 'off');
|
|
toggleBtn.innerHTML = isOn ? icon('power_settings_new',14)+' 关闭' : icon('power_settings_new',14)+' 开启';
|
|
}
|
|
|
|
// 更新设备属性值 (温度、亮度、位置等)
|
|
var propValues = card.querySelectorAll('.iot-prop-value');
|
|
var type = device.type;
|
|
|
|
if (type === 'ac') {
|
|
// AC: 温度 slider + 模式按钮
|
|
var tempSlider = card.querySelector('.iot-prop-control input[type="range"]');
|
|
if (tempSlider) {
|
|
tempSlider.value = device.temperature || 26;
|
|
var tempVal = tempSlider.nextElementSibling;
|
|
if (tempVal) tempVal.textContent = (device.temperature || 26) + '°C';
|
|
}
|
|
// 更新模式按钮
|
|
var modeBtns = card.querySelectorAll('.iot-mode-btn');
|
|
modeBtns.forEach(function(btn) {
|
|
var btnMode = btn.textContent.trim();
|
|
if (btnMode === (device.mode || 'cool')) {
|
|
btn.classList.add('active');
|
|
} else {
|
|
btn.classList.remove('active');
|
|
}
|
|
});
|
|
} else if (type === 'light') {
|
|
var brightSlider = card.querySelector('.iot-prop-control input[type="range"]');
|
|
if (brightSlider) {
|
|
brightSlider.value = device.brightness || 80;
|
|
var brightVal = brightSlider.nextElementSibling;
|
|
if (brightVal) brightVal.textContent = (device.brightness || 80) + '%';
|
|
}
|
|
// 更新颜色按钮
|
|
var colorBtns = card.querySelectorAll('.iot-color-btn');
|
|
colorBtns.forEach(function(btn) {
|
|
// 颜色值从 onclick 属性解析
|
|
var onclick = btn.getAttribute('onclick') || '';
|
|
var match = onclick.match(/iotSetProperty\('[^']+',\s*'color',\s*'([^']+)'\)/);
|
|
if (match && match[1] === (device.color || 'warm_white')) {
|
|
btn.classList.add('active');
|
|
} else if (!match || match[1] !== (device.color || 'warm_white')) {
|
|
btn.classList.remove('active');
|
|
}
|
|
});
|
|
} else if (type === 'curtain') {
|
|
var posSlider = card.querySelector('.iot-prop-control input[type="range"]');
|
|
if (posSlider) {
|
|
posSlider.value = device.position != null ? device.position : 100;
|
|
var posVal = posSlider.nextElementSibling;
|
|
if (posVal) posVal.textContent = (device.position != null ? device.position : 100) + '%';
|
|
}
|
|
} else if (device.temperature != null) {
|
|
// sensor/thermostat: 只读温度
|
|
if (propValues.length > 0) propValues[0].textContent = device.temperature + (device.unit || '°C');
|
|
}
|
|
|
|
// 更新电量
|
|
var batteryEls = card.querySelectorAll('.iot-prop-value');
|
|
batteryEls.forEach(function(el) {
|
|
if (el.parentElement && el.parentElement.querySelector('.iot-prop-label[data-prop="battery"]')) {
|
|
if (device.battery != null) el.textContent = device.battery + '%';
|
|
}
|
|
});
|
|
|
|
// 更新 AC 温度按钮的 onclick 引用
|
|
if (type === 'ac') {
|
|
var acBtns = card.querySelectorAll('.iot-device-actions .btn-xs');
|
|
acBtns.forEach(function(btn) {
|
|
var text = btn.textContent.trim();
|
|
var currentTemp = device.temperature || 26;
|
|
if (text === ''+icon('arrow_downward',12)+' -2°C') {
|
|
btn.setAttribute('onclick', "iotSetProperty('" + device.id + "', 'temperature', " + (currentTemp - 2) + ");refreshIoTDeviceCard('" + device.id + "')");
|
|
} else if (text === ''+icon('arrow_upward',12)+' +2°C') {
|
|
btn.setAttribute('onclick', "iotSetProperty('" + device.id + "', 'temperature', " + (currentTemp + 2) + ");refreshIoTDeviceCard('" + device.id + "')");
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function renderIoTDeviceCard(device) {
|
|
var isOn = device.status === 'on';
|
|
var iconName = IOT_DEVICE_TYPES[device.type] || 'inventory_2';
|
|
var propsHtml = '';
|
|
|
|
if (device.type === 'ac') {
|
|
propsHtml =
|
|
'<div class="iot-prop-row">' +
|
|
'<span class="iot-prop-label">'+icon('thermostat',16)+' 温度</span>' +
|
|
'<div class="iot-prop-control">' +
|
|
'<input type="range" min="16" max="30" value="' + (device.temperature || 26) + '"' +
|
|
' onchange="iotSetProperty(\'' + device.id + '\', \'temperature\', parseInt(this.value)); this.nextElementSibling.textContent=this.value+\'°C\'">' +
|
|
'<span class="iot-prop-value">' + (device.temperature || 26) + '°C</span>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'<div class="iot-prop-row">' +
|
|
'<span class="iot-prop-label">'+icon('refresh',14)+' 模式</span>' +
|
|
'<div class="iot-prop-control" style="gap:4px">' +
|
|
IOT_MODE_OPTIONS.map(function(m) {
|
|
var active = (device.mode || 'cool') === m ? ' active' : '';
|
|
return '<button class="iot-mode-btn' + active + '" onclick="iotSetProperty(\'' + device.id + '\', \'mode\', \'' + m + '\');refreshIoTDeviceCard(\'' + device.id + '\')">' + m + '</button>';
|
|
}).join('') +
|
|
'</div>' +
|
|
'</div>';
|
|
} else if (device.type === 'light') {
|
|
propsHtml =
|
|
'<div class="iot-prop-row">' +
|
|
'<span class="iot-prop-label">'+icon('lightbulb',16)+' 亮度</span>' +
|
|
'<div class="iot-prop-control">' +
|
|
'<input type="range" min="1" max="100" value="' + (device.brightness || 80) + '"' +
|
|
' onchange="iotSetProperty(\'' + device.id + '\', \'brightness\', parseInt(this.value)); this.nextElementSibling.textContent=this.value+\'%\'">' +
|
|
'<span class="iot-prop-value">' + (device.brightness || 80) + '%</span>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'<div class="iot-prop-row">' +
|
|
'<span class="iot-prop-label">'+icon('palette',16)+' 颜色</span>' +
|
|
'<div class="iot-prop-control" style="gap:4px">' +
|
|
IOT_COLOR_OPTIONS.map(function(c) {
|
|
var active = (device.color || 'warm_white') === c.value ? ' active' : '';
|
|
return '<button class="iot-color-btn' + active + '" style="background:' + c.bg + '" title="' + c.name + '"' +
|
|
' onclick="iotSetProperty(\'' + device.id + '\', \'color\', \'' + c.value + '\');refreshIoTDeviceCard(\'' + device.id + '\')"></button>';
|
|
}).join('') +
|
|
'</div>' +
|
|
'</div>';
|
|
} else if (device.type === 'curtain') {
|
|
propsHtml =
|
|
'<div class="iot-prop-row">' +
|
|
'<span class="iot-prop-label">'+icon('blinds',16)+' 位置</span>' +
|
|
'<div class="iot-prop-control">' +
|
|
'<input type="range" min="0" max="100" value="' + (device.position != null ? device.position : 100) + '"' +
|
|
' onchange="iotSetProperty(\'' + device.id + '\', \'position\', parseInt(this.value)); this.nextElementSibling.textContent=this.value+\'%\'">' +
|
|
'<span class="iot-prop-value">' + (device.position != null ? device.position : 100) + '%</span>' +
|
|
'</div>' +
|
|
'</div>';
|
|
} else if (device.temperature != null) {
|
|
propsHtml =
|
|
'<div class="iot-prop-row">' +
|
|
'<span class="iot-prop-label">'+icon('thermostat',16)+' 温度</span>' +
|
|
'<span class="iot-prop-value">' + device.temperature + (device.unit || '°C') + '</span>' +
|
|
'</div>';
|
|
}
|
|
|
|
if (device.battery != null) {
|
|
propsHtml +=
|
|
'<div class="iot-prop-row">' +
|
|
'<span class="iot-prop-label" data-prop="battery">'+icon('battery_full',16)+' 电量</span>' +
|
|
'<span class="iot-prop-value">' + device.battery + '%</span>' +
|
|
'</div>';
|
|
}
|
|
|
|
var actionsHtml = '<div class="iot-device-actions">' +
|
|
'<button class="iot-toggle-btn ' + (isOn ? 'on' : 'off') + '" onclick="iotToggle(\'' + device.id + '\')">' +
|
|
(isOn ? ''+icon('power_settings_new',14)+' 关闭' : ''+icon('power_settings_new',14)+' 开启') +
|
|
'</button>';
|
|
|
|
if (device.type === 'ac') {
|
|
var currentTemp = device.temperature || 26;
|
|
actionsHtml +=
|
|
'<button class="btn btn-xs" onclick="iotSetProperty(\'' + device.id + '\', \'temperature\', ' + (currentTemp - 2) + ');refreshIoTDeviceCard(\'' + device.id + '\')">'+icon('arrow_downward',12)+' -2°C</button>' +
|
|
'<button class="btn btn-xs" onclick="iotSetProperty(\'' + device.id + '\', \'temperature\', ' + (currentTemp + 2) + ');refreshIoTDeviceCard(\'' + device.id + '\')">'+icon('arrow_upward',12)+' +2°C</button>';
|
|
}
|
|
|
|
actionsHtml +=
|
|
'<button class="btn btn-xs" onclick="iotShowHistory(\'' + device.id + '\')" style="margin-left:auto">'+icon('list_alt',13)+' 历史</button>' +
|
|
'</div>' +
|
|
'<div id="iot-history-' + device.id + '" class="iot-history-panel" style="display:none"></div>';
|
|
|
|
return '<div class="iot-device-card ' + (isOn ? 'on' : 'off') + '" id="iot-card-' + device.id + '">' +
|
|
'<div class="iot-device-header">' +
|
|
'<div class="iot-device-name">' +
|
|
'<span style="font-size:24px">' + icon(iconName, 24) + '</span>' +
|
|
'<div>' +
|
|
'<div>' + escHtml(device.name) + '</div>' +
|
|
'<div class="iot-device-type">' + escHtml(device.type) + ' · ' + escHtml(device.id) + '</div>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'<div class="iot-device-status">' +
|
|
'<span class="iot-status-dot ' + (isOn ? 'on' : 'off') + '"></span>' +
|
|
'<span style="font-size:12px;font-weight:600;color:' + (isOn ? 'var(--green)' : 'var(--text3)') + '">' + (isOn ? '开启' : '关闭') + '</span>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
(propsHtml ? '<div class="iot-device-props">' + propsHtml + '</div>' : '') +
|
|
actionsHtml +
|
|
'</div>';
|
|
}
|
|
|
|
async function iotToggle(deviceId) {
|
|
console.log('[IoT] 切换设备开关: ' + deviceId);
|
|
var data = await api('/api/iot/devices/' + deviceId + '/toggle', { method: 'POST' });
|
|
if (data.error) {
|
|
showToast('切换失败: ' + data.error, 'error');
|
|
} else {
|
|
var device = data.device || {};
|
|
showToast((device.name || deviceId) + ': ' + (device.status === 'on' ? '已开启' : '已关闭'), 'success');
|
|
renderIoTPanel();
|
|
}
|
|
}
|
|
|
|
async function iotSetProperty(deviceId, field, value) {
|
|
console.log('[IoT] 设置设备属性: ' + deviceId + ' -> ' + field + ' = ' + value);
|
|
var data = await api('/api/iot/devices/' + deviceId + '/set', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ field: field, value: value }),
|
|
});
|
|
if (data.error) {
|
|
showToast('设置失败: ' + data.error, 'error');
|
|
} else {
|
|
var device = data.device || {};
|
|
showToast((device.name || deviceId) + ': ' + field + ' = ' + value, 'success');
|
|
}
|
|
}
|
|
|
|
async function refreshIoTDeviceCard(deviceId) {
|
|
var data = await api('/api/iot/devices/' + deviceId);
|
|
if (data.error) return;
|
|
var device = data.device;
|
|
if (!device) return;
|
|
var card = document.getElementById('iot-card-' + deviceId);
|
|
if (!card) return;
|
|
card.outerHTML = renderIoTDeviceCard(device);
|
|
}
|
|
|
|
async function iotShowHistory(deviceId) {
|
|
var panel = document.getElementById('iot-history-' + deviceId);
|
|
if (!panel) return;
|
|
if (panel.style.display !== 'none') {
|
|
panel.style.display = 'none';
|
|
return;
|
|
}
|
|
console.log('[IoT] 获取设备历史: ' + deviceId);
|
|
var data = await api('/api/iot/devices/' + deviceId + '/history');
|
|
panel.style.display = '';
|
|
if (data.error) {
|
|
panel.innerHTML = '<div style="color:var(--red);font-size:11px;padding:4px">' + escHtml(data.error) + '</div>';
|
|
return;
|
|
}
|
|
var history = data.history || [];
|
|
if (history.length === 0) {
|
|
panel.innerHTML = '<div style="color:var(--text3);font-size:11px;padding:4px">暂无操作历史</div>';
|
|
return;
|
|
}
|
|
panel.innerHTML = history.slice(-20).reverse().map(function(h) {
|
|
var timeStr = h.timestamp ? new Date(h.timestamp).toLocaleTimeString('zh-CN', {hour12: false}) : '—';
|
|
return '<div class="iot-history-item">' +
|
|
'<span class="iot-hist-time">' + timeStr + '</span>' +
|
|
'<span class="iot-hist-action">' + escHtml(h.action || '操作') + '</span>' +
|
|
'<span class="iot-hist-detail">' + escHtml(h.detail || h.value || '') + '</span>' +
|
|
'</div>';
|
|
}).join('');
|
|
}
|