// ==UserScript==
// @name 米家中枢极客版助手
// @namespace http://tampermonkey.net/
// @version v0.8.10
// @description 登录(不可用)极客版页面后,自动开启助手插件,显示设备与规则的关系,方便查看规则与设备的对应关系,支持快捷键折叠/展开,关闭,适应画布布局,设备高亮,日志高亮,自动适应画布、设置规则列表布局样式等功能。
// @author 王丰,sk163
// @license MIT
// @match http://*/*
// @icon data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// ==/UserScript==
/**
* v0.8.8更新:
* 1. 更改了插件名称
* 2、增加了快捷键,Ctrl/Command+E折叠/展开,Ctrl/Command+Q关闭,Ctrl/Command+B适应画布布局
* 3、自动画布布局修改为仅在初次进入规则编排页面或激活编排页面时触发
* 4、极客版登录(不可用)后自动启动插件,无需再点击设备列表激活
* 5、修正了一些已知问题
*
* v0.8.9更新:
* 1、修改了日志高亮逻辑,优化了执行效率,提升了性能,减少无用的循环
* 2、参考了米家自动化极客版样式优化(感谢原作者:lqs1848,https://gf.qytechs.cn/zh-CN/scripts/495833),增加了规则列表样式设置选项,可选择每行显示1-5条规则
* 3、增加了自动折叠窗口的选项
* 4、修正了一些已知问题
*
* v0.8.10更新:
* 1、修复了窗口过小的问题
* 2、取消了规则列表最小宽度限制
*
*/
(async () => {
const callAPI = (api, params) => {
return new Promise(res => editor.gateway.callAPI(api, params, res));
};
let scriptTitle=GM_info.script.name;
let scriptVersion=GM_info.script.version;
let isInit = false;
let selectCardIds = '';
let defaultColor='#43ad7f7f'
let defaultWindowWidth=1120;
let defaultWindowHeight=600;
let defaultRuleStyle='4';
let minWindowWidth=400;
let minWindowHeight=100;
let enableEnhancedDisplayLog=GM_getValue("enableEnhancedDisplayLog");
let enableAutoFitContent=GM_getValue("enableAutoFitContent");
let enableAutoCollapseCheck=GM_getValue("enableAutoCollapseCheck");
let backgroundColor = GM_getValue("backgroundColor") ;
let windowWidth = GM_getValue("windowWidth");
let windowHeight = GM_getValue("windowHeight");
let ruleStyle = GM_getValue("ruleStyle");
if (enableEnhancedDisplayLog === undefined || enableEnhancedDisplayLog === null || enableEnhancedDisplayLog === "") {
enableEnhancedDisplayLog = true;
}
if (enableAutoFitContent === undefined || enableAutoFitContent === null || enableAutoFitContent === "") {
enableAutoFitContent = true;
}
if (enableAutoCollapseCheck === undefined || enableAutoCollapseCheck === null || enableAutoCollapseCheck === "") {
enableAutoCollapseCheck = false;
}
if (backgroundColor === undefined || backgroundColor === null || backgroundColor === "") {
backgroundColor = defaultColor;
}
if (windowWidth === undefined || windowWidth === null || windowWidth === "" || isNaN(windowWidth)) {
windowWidth = defaultWindowWidth;
} else {
windowWidth = parseInt(windowWidth, 10) < minWindowWidth ? minWindowWidth : parseInt(windowWidth, 10);
}
if (windowHeight === undefined || windowHeight === null || windowHeight === "" || isNaN(windowHeight)) {
windowHeight = defaultWindowHeight;
} else {
windowHeight = parseInt(windowHeight, 10) < minWindowHeight ? minWindowHeight : parseInt(windowHeight, 10)
}
if (ruleStyle === undefined || ruleStyle === null || ruleStyle === "") {
ruleStyle = defaultRuleStyle;
}
const executeScript = async () => {
if (document.getElementById('device-rule-map')||isInit===true) {
return;
}
if (typeof editor === 'undefined' || typeof editor.gateway === 'undefined' || typeof editor.gateway.callAPI === 'undefined') {
console.error('editor.gateway.callAPI 方法未定义。请确保在正确的环境中运行此脚本。');
return;
}
try {
isInit=true;
const devListResponse = await callAPI('getDevList');
const devList = devListResponse.devList;
const roomNames = Array.from(new Set(Object.values(devList).map(device => device.roomName)));
const ruleList = await callAPI('getGraphList');
let devRuleMap = {};
for (const rule of ruleList) {
const content = await callAPI('getGraph', {id: rule.id});
const dids = new Set(content.nodes.map(n => n.props?.did).filter(did => did !== undefined));
const cards = new Set(content.nodes.map(n => {
return (n.props && n.cfg) ? {did: n.props.did, oriId: n.cfg.oriId} : undefined;
}).filter(card => card !== undefined));
dids.forEach(did => {
devRuleMap[did] = devRuleMap[did] ?? [];
const cardIds = Array.from(cards)
.filter(card => card.did === did)
.map(card => card.oriId).join(',');
const tempDevRule = {
ruleId: rule.id,
cardIds: cardIds,
totalCardNum: cards.size
};
devRuleMap[did].push(tempDevRule);
});
}
const result = Object.fromEntries(
Object.entries(devRuleMap).map(([k, v]) => [
k,
{
device: {
name: devList[k]?.name ?? `did: ${k}`,
roomName: devList[k]?.roomName ?? `未知`
},
rules: v.map(r => {
const rule = ruleList.find(rr => rr.id === r.ruleId);
return {
id: r.ruleId,
cardIds: r.cardIds,
totalCardNum: r.totalCardNum,
name: rule ? rule.userData.name : 'Unknown'
};
})
}
])
);
const container = document.createElement('div');
container.id = 'device-rule-map';
container.style.position = 'fixed';
container.style.top = '10px';
container.style.right = '10px';
container.style.width = windowWidth+'px';
container.style.height = windowHeight+'px';
container.style.overflowY = 'scroll';
container.style.backgroundColor = 'white';
container.style.border = '1px solid #ccc';
container.style.paddingTop = '45px';
container.style.zIndex = 10000;
container.style.boxShadow = '0 4px 8px rgba(0, 0, 0, 0.1)';
const topBar = document.createElement('div');
topBar.id = 'topBar';
topBar.style.position = 'fixed';
topBar.style.top = '0';
topBar.style.right = '10px';
topBar.style.width = windowWidth+'px';
topBar.style.height = '38px';
topBar.style.backgroundColor = 'white';
topBar.style.zIndex = 10001;
topBar.style.boxShadow = '0 4px 8px rgba(0, 0, 0, 0.1)';
topBar.style.display = 'flex';
topBar.style.justifyContent = 'space-between';
topBar.style.alignItems = 'center';
topBar.style.padding = '0 10px';
const titleDiv = document.createElement('div');
titleDiv.style.display = 'flex';
const title = document.createElement('h2');
title.style.margin = '0';
title.textContent = scriptTitle;
titleDiv.appendChild(title);
const version = document.createElement('span');
version.textContent = scriptVersion;
version.style.marginLeft = '2px';
version.style.paddingTop = '16px';
version.style.fontSize = '9px';
titleDiv.appendChild(version);
const buttonContainer = document.createElement('div');
buttonContainer.style.display = 'flex';
buttonContainer.style.gap = '10px';
const collapseButton = document.createElement('button');
collapseButton.id="collapseButton";
collapseButton.textContent = '折叠';
collapseButton.title = '快捷键为Ctrl+E';
collapseButton.onclick = () => {
handleCollapseBtnClick();
};
const closeButton = document.createElement('button');
closeButton.id="closeButton";
closeButton.title = '快捷键为Ctrl+Q';
closeButton.textContent = '关闭';
closeButton.onclick = () => {
document.body.removeChild(container);
isInit=false;
}
buttonContainer.appendChild(collapseButton);
buttonContainer.appendChild(closeButton);
topBar.appendChild(titleDiv);
topBar.appendChild(buttonContainer);
const table = document.createElement('table');
table.border = '1';
table.cellSpacing = '0';
table.cellPadding = '5';
table.style.width = '100%';
const thead = document.createElement('thead');
const headerRow = document.createElement('tr');
const roomHeader = document.createElement('th');
const deviceHeader = document.createElement('th');
const ruleHeader = document.createElement('th');
let roomSortOrder = 'asc';
let deviceSortOrder = 'asc';
let ruleSortOrder = 'asc';
const updateSortMarkers = () => {
roomHeader.innerHTML = `房间 ${roomSortOrder === 'asc' ? '⬆️' : '⬇️'}`;
deviceHeader.innerHTML = `设备 ${deviceSortOrder === 'asc' ? '⬆️' : '⬇️'}`;
ruleHeader.innerHTML = `规则 ${ruleSortOrder === 'asc' ? '⬆️' : '⬇️'}`;
};
roomHeader.textContent = '房间';
roomHeader.style.textWrap= 'nowrap';
deviceHeader.textContent = '设备';
deviceHeader.style.textWrap = 'nowrap';
ruleHeader.textContent = '规则';
roomHeader.onclick = () => {
roomSortOrder = roomSortOrder === 'asc' ? 'desc' : 'asc';
sortTable(0, roomSortOrder);
updateSortMarkers();
};
deviceHeader.onclick = () => {
deviceSortOrder = deviceSortOrder === 'asc' ? 'desc' : 'asc';
sortTable(1, deviceSortOrder);
updateSortMarkers();
};
ruleHeader.onclick = () => {
ruleSortOrder = ruleSortOrder === 'asc' ? 'desc' : 'asc';
sortTable(2, ruleSortOrder);
updateSortMarkers();
};
headerRow.appendChild(roomHeader);
headerRow.appendChild(deviceHeader);
headerRow.appendChild(ruleHeader);
thead.appendChild(headerRow);
table.appendChild(thead);
const roomFilterSelect = document.createElement('select');
roomFilterSelect.style.marginBottom = '10px';
roomFilterSelect.style.height = '28px';
roomFilterSelect.style.borderStyle = 'solid';
roomFilterSelect.style.borderWidth = '1px';
roomFilterSelect.innerHTML = `<option value="">所有房间</option>` + roomNames.map(room => `<option value="${room}">${room}</option>`).join('');
roomFilterSelect.onchange = () => {
filterTable(roomFilterSelect.value,deviceFilterInput.value, ruleFilterInput.value);
};
container.appendChild(roomFilterSelect);
const deviceFilterInput = document.createElement('input');
deviceFilterInput.type = 'text';
deviceFilterInput.placeholder = '设备筛选';
deviceFilterInput.style.width = '100px';
deviceFilterInput.style.marginBottom = '10px';
deviceFilterInput.style.marginLeft = '10px';
deviceFilterInput.style.height = '28px';
deviceFilterInput.style.borderStyle = 'solid';
deviceFilterInput.style.borderWidth = '1px';
deviceFilterInput.oninput = () => {
filterTable(roomFilterSelect.value,deviceFilterInput.value, ruleFilterInput.value);
};
container.appendChild(deviceFilterInput);
const ruleFilterInput = document.createElement('input');
ruleFilterInput.type = 'text';
ruleFilterInput.placeholder = '规则筛选';
ruleFilterInput.style.width = '100px';
ruleFilterInput.style.marginBottom = '10px';
ruleFilterInput.style.marginLeft = '10px';
ruleFilterInput.style.height = '28px';
ruleFilterInput.style.borderStyle = 'solid';
ruleFilterInput.style.borderWidth = '1px';
ruleFilterInput.oninput = () => {
filterTable(roomFilterSelect.value,deviceFilterInput.value, ruleFilterInput.value);
};
container.appendChild(ruleFilterInput);
const widthInput = document.createElement('input');
widthInput.type = 'text';
widthInput.placeholder = windowWidth+'px';
widthInput.style.width = '60px';
widthInput.style.marginBottom = '10px';
widthInput.style.marginLeft = '10px';
widthInput.style.height = '28px';
widthInput.style.borderStyle = 'solid';
widthInput.style.borderWidth = '1px';
widthInput.onchange = () => {
windowWidth = parseInt(widthInput.value, 10) < minWindowWidth ? minWindowWidth : parseInt(widthInput.value, 10);
GM_setValue("windowWidth", windowWidth);
container.style.width = windowWidth + 'px';
topBar.style.width = windowWidth + 'px';
};
const spanW = document.createElement('span');
spanW.textContent = '宽度:';
spanW.style.marginLeft = '10px';
container.appendChild(spanW);
container.appendChild(widthInput);
const heightInput = document.createElement('input');
heightInput.type = 'text';
heightInput.placeholder = windowHeight+'px';
heightInput.style.width = '60px';
heightInput.style.marginBottom = '10px';
heightInput.style.marginLeft = '10px';
heightInput.style.height = '28px';
heightInput.style.borderStyle = 'solid';
heightInput.style.borderWidth = '1px';
heightInput.onchange = () => {
windowHeight = parseInt(heightInput.value, 10) < minWindowHeight ? minWindowHeight : parseInt(heightInput.value, 10);
GM_setValue("windowHeight", windowHeight);
container.style.height = windowHeight + 'px';
};
const spanH = document.createElement('span');
spanH.textContent = '高度:';
spanH.style.marginLeft = '10px';
container.appendChild(spanH);
container.appendChild(heightInput);
const ruleStyleSelect = document.createElement('select');
ruleStyleSelect.style.marginBottom = '10px';
ruleStyleSelect.style.height = '28px';
ruleStyleSelect.style.marginLeft = '10px';
ruleStyleSelect.style.borderStyle = 'solid';
ruleStyleSelect.style.borderWidth = '1px';
ruleStyleSelect.innerHTML = '<option value="1">每行1列</option>' +
'<option value="2">每行2列</option>' +
'<option value="3">每行3列</option>' +
'<option value="4">每行4列</option>' +
'<option value="5">每行5列</option>' ;
ruleStyleSelect.onchange = () => {
GM_setValue("ruleStyle", ruleStyleSelect.value);
changeRuleListStyle(ruleStyleSelect.value);
};
const spanS = document.createElement('span');
spanS.textContent = '规则列表:';
spanS.style.marginLeft = '10px';
container.appendChild(spanS);
container.appendChild(ruleStyleSelect);
ruleStyleSelect.value=ruleStyle;
changeRuleListStyle(ruleStyle);
const colorInput = document.createElement('input');
colorInput.type = 'text';
colorInput.placeholder=defaultColor;
colorInput.style.width = '80px';
colorInput.style.marginBottom = '10px';
colorInput.style.marginLeft = '10px';
colorInput.style.height = '28px';
colorInput.style.borderStyle = 'solid';
colorInput.style.borderWidth = '1px';
colorInput.oninput = () => {
backgroundColor = colorInput.value;
GM_setValue("backgroundColor", backgroundColor);
};
const spanC = document.createElement('span');
spanC.textContent = '卡片颜色:';
spanC.style.marginLeft = '10px';
container.appendChild(spanC);
container.appendChild(colorInput);
const logLabel = document.createElement('label');
logLabel.htmlFor = 'highlightLogCheck';
logLabel.appendChild(document.createTextNode('日志高亮'));
logLabel.style.marginBottom = '10px';
logLabel.style.marginLeft = '10px';
container.appendChild(logLabel);
const highlightLogCheck = document.createElement('input');
highlightLogCheck.type = 'checkbox';
highlightLogCheck.id = 'highlightLogCheck';
highlightLogCheck.checked=enableEnhancedDisplayLog;
highlightLogCheck.style.marginLeft = '2px';
highlightLogCheck.onchange=function() {
enableEnhancedDisplayLog = highlightLogCheck.checked;
GM_setValue("enableEnhancedDisplayLog", enableEnhancedDisplayLog);
};
container.appendChild(highlightLogCheck);
const fitLabel = document.createElement('label');
fitLabel.htmlFor = 'autoFitCheck';
fitLabel.appendChild(document.createTextNode('自动画布'));
fitLabel.style.marginBottom = '10px';
fitLabel.style.marginLeft = '10px';
container.appendChild(fitLabel);
const autoFitCheck = document.createElement('input');
autoFitCheck.type = 'checkbox';
autoFitCheck.id = 'autoFitCheck';
autoFitCheck.checked=enableAutoFitContent;
autoFitCheck.style.marginLeft = '2px';
autoFitCheck.onchange=function() {
enableAutoFitContent = autoFitCheck.checked;
GM_setValue("enableAutoFitContent", enableAutoFitContent);
};
container.appendChild(autoFitCheck);
const autoCollapseLabel = document.createElement('label');
autoCollapseLabel.htmlFor = 'autoCollapseCheck';
autoCollapseLabel.appendChild(document.createTextNode('自动折叠'));
autoCollapseLabel.style.marginBottom = '10px';
autoCollapseLabel.style.marginLeft = '10px';
container.appendChild(autoCollapseLabel);
const autoCollapseCheck = document.createElement('input');
autoCollapseCheck.type = 'checkbox';
autoCollapseCheck.id = 'autoCollapseCheck';
autoCollapseCheck.checked=enableAutoCollapseCheck;
autoCollapseCheck.style.marginLeft = '2px';
autoCollapseCheck.onchange=function() {
enableAutoCollapseCheck = autoCollapseCheck.checked;
GM_setValue("enableAutoCollapseCheck", enableAutoCollapseCheck);
autoCollapse();
};
container.appendChild(autoCollapseCheck);
const tbody = document.createElement('tbody');
Object.entries(result).forEach(([did, data]) => {
const device=data.device;
const rules=data.rules;
const row = document.createElement('tr');
const roomCell = document.createElement('td');
roomCell.textContent = device.roomName;
roomCell.style.textWrap= 'nowrap';
const deviceCell = document.createElement('td');
deviceCell.textContent = device.name;
deviceCell.style.textWrap= 'nowrap';
const ruleCell = document.createElement('td');
const host = window.location.host;
rules.forEach(rule => {
const link = document.createElement('a');
link.href = `http://${host}/#/graph/${rule.id}`;
link.target = '_self';
link.textContent = rule.name + "[" + rule.cardIds.split(',').length + "/" + rule.totalCardNum + "]";
link.onclick = () => {
window.location.hash = '#/';
selectCardIds = rule.cardIds;
};
ruleCell.appendChild(link);
ruleCell.appendChild(document.createTextNode(', '));
});
ruleCell.removeChild(ruleCell.lastChild);
row.appendChild(roomCell);
row.appendChild(deviceCell);
row.appendChild(ruleCell);
tbody.appendChild(row);
});
table.appendChild(tbody);
container.appendChild(topBar);
container.appendChild(table);
document.body.appendChild(container);
function sortTable(columnIndex, sortOrder) {
const rows = Array.from(tbody.rows);
const sortedRows = rows.sort((a, b) => {
const aText = a.cells[columnIndex].textContent;
const bText = b.cells[columnIndex].textContent;
if (sortOrder === 'asc') {
return aText.localeCompare(bText);
} else {
return bText.localeCompare(aText);
}
});
tbody.innerHTML = '';
sortedRows.forEach(row => tbody.appendChild(row));
}
updateSortMarkers();
function filterTable(roomName,deviceKeyword, ruleKeyword) {
const rows = Array.from(tbody.rows);
rows.forEach(row => {
const roomText = row.cells[0].textContent;
const deviceText = row.cells[1].textContent.toLowerCase();
const ruleText = row.cells[2].textContent.toLowerCase();
if ((roomName === '' || roomText === roomName) && deviceText.includes(deviceKeyword.toLowerCase()) && ruleText.includes(ruleKeyword.toLowerCase())) {
row.style.display = '';
} else {
row.style.display = 'none';
}
});
}
autoCollapse();
} catch (error) {
isInit=false;
console.error('调用 API 时出错:', error);
}
};
const selectDevices = async () => {
await sleep(1000);
const cardIds = selectCardIds.split(',');
for (const cardId of cardIds) {
if (cardId.trim() !== '') {
let targetElement = document.querySelector("#" + cardId.trim() + " > div > div");
if (targetElement) {
targetElement.style.backgroundColor = backgroundColor === '' ? defaultColor : backgroundColor;
}
}
}
selectCardIds = '';
};
function handleCollapseBtnClick() {
const container = document.getElementById('device-rule-map');
if (container) {
if (container.style.height === windowHeight + 'px') {
collapse();
}else{
expand();
}
}
}
function collapse() {
const container = document.getElementById('device-rule-map');
if (container) {
const collapseButton = document.getElementById('collapseButton');
const topBar = document.getElementById('topBar');
topBar.style.width = '320px';
container.style.height = '0px';
container.style.width = '0px';
collapseButton.textContent = '展开';
}
}
function expand() {
const container = document.getElementById('device-rule-map');
if (container) {
const collapseButton = document.getElementById('collapseButton');
const topBar = document.getElementById('topBar');
topBar.style.width = windowWidth + 'px';
container.style.width = windowWidth + 'px';
container.style.height = windowHeight + 'px';
collapseButton.textContent = '折叠';
}
}
function autoFitContent() {
if(enableAutoFitContent && editor && editor.transformTool){
editor.transformTool.fitToBestPos();
}
}
function autoCollapse() {
if(enableAutoCollapseCheck){
collapse();
}
}
function enhancedDisplayLog() {
//监听画布变化
const canvas = document.getElementById('canvas-root');
if (canvas) {
const config = {attributes: false, childList: true, subtree: true};
const callback = function (mutationsList, observer) {
if (enableEnhancedDisplayLog) {
let element = document.querySelector('.panel-log-card-blink');
if (element && element.style.outline !== "red solid 20px") {
element.style.outline = "red solid 10px";
}
let animateElement = document.querySelector('animate');
if (animateElement && animateElement.getAttribute('stroke-width') != '10') {
let pathElement = animateElement.parentElement;
pathElement.setAttribute('stroke-width', '10');
if (pathElement) {
let gElement = pathElement.parentElement;
gElement.setAttribute('stroke', 'red');
}
}
}
};
const observer = new MutationObserver(callback);
observer.observe(canvas, config);
}
}
function changeRuleListStyle(count) {
let percentage= 100 / count -0.2;
GM_addStyle('.rule-list-item {width: calc('+percentage+'% - 10px);margin: 5px;} .rule-list-head {width: 100%;} .rule-list {display: flex;flex-wrap: wrap;}')
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function isMiJiaJiKePage() {
return document.title === "米家自动化极客版" && !document.querySelector('.pin-form') && editor;
}
function handleUrlChange() {
if (isMiJiaJiKePage()) {
executeScript();
if (window.location.hash.match(/^#\/graph\/.*/g)) {
selectDevices();
setTimeout(function () {
autoFitContent();
enhancedDisplayLog();
autoCollapse();
}, 500);
}
}
}
//页面变化
window.addEventListener('popstate', handleUrlChange);
window.addEventListener('hashchange', handleUrlChange);
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
history.pushState = function() {
originalPushState.apply(this, arguments);
handleUrlChange();
};
history.replaceState = function() {
originalReplaceState.apply(this, arguments);
handleUrlChange();
};
//快捷键
document.addEventListener('keydown', function(event) {
if ((event.metaKey || event.ctrlKey) && event.key === 'e') {
event.preventDefault();
if(document.getElementById('collapseButton')){
document.getElementById('collapseButton').click();
}
}
if ((event.metaKey || event.ctrlKey) && event.key === 'q') {
event.preventDefault();
if(document.getElementById('closeButton')){
document.getElementById('closeButton').click();
}
}
if ((event.metaKey || event.ctrlKey) && event.key === 'b') {
event.preventDefault();
if(editor && editor.transformTool){
editor.transformTool.fitToBestPos();
}
}
});
window.onload = function () {
//监控登录(不可用)页面
const loginForm = document.querySelector('.account-content');
if (loginForm) {
const config = {attributes: true, childList: true, subtree: true};
const callback = function (mutationsList, observer) {
setTimeout(function () {
handleUrlChange();
}, 500);
};
const observer = new MutationObserver(callback);
observer.observe(loginForm, config);
}
};
})();