// ==UserScript==
// @name Wiki 树形菜单
// @namespace http://tampermonkey.net/
// @version 2025-08-08
// @description 增加 wiki js 左侧树形菜单
// @author You
// @match http://192.168.1.111:7001/*
// @exclude http://192.168.1.111:7001/e/*
// @icon data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @grant none
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// 查找 nav 下 button,获取第二个
const getTargetButton = () => {
const buttons = document.querySelectorAll('nav button');
return buttons.length >= 2 ? buttons[1] : null;
};
// 获取 button 下 span -> div 的文字
const getDivText = (button) => {
const span = button?.querySelector('span');
const div = span?.querySelector('div');
return div?.textContent.trim();
};
// 获取当前 URL 中的 path
const getCurrentPath = () => {
const match = window.location.pathname.match(/^\/zh\/(.+)/);
return match ? match[1] : '';
};
// 初始检查
const init = () => {
const button = getTargetButton();
if (button) {
const text = getDivText(button);
if (text) {
handleMenuDisplay(text);
return text === '浏览';
}
}
return false;
};
// 渲染菜单项
const renderMenuItems = (items, level = 0, parentNode = null, currentPath = '') => {
const listDiv = document.createElement('div');
listDiv.className = 'v-list py-2 v-sheet theme--dark v-list--dense primary';
const pathParts = currentPath ? currentPath.split('/') : [];
items.forEach(item => {
const itemDiv = document.createElement('div');
itemDiv.tabIndex = 0;
itemDiv.className = `v-list-item v-list-item--link theme--dark`;
itemDiv.style.minHeight = '30px';
// 检查是否为当前路径的菜单项
//if (!item.isFolder && item.path === currentPath) {
if (!item.isFolder && item.path === decodeURI(currentPath)) {
itemDiv.classList.add('v-list-item--active');
}
const avatarDiv = document.createElement('div');
avatarDiv.className = 'v-avatar v-list-item__avatar';
avatarDiv.style.height = '18px';
avatarDiv.style.minWidth = '18px';
avatarDiv.style.width = 'auto';
avatarDiv.style.paddingLeft = `${level * 8}px`;
avatarDiv.style.margin = '0px 5px 0px 0px';
const icon = document.createElement('i');
icon.setAttribute('aria-hidden', 'true');
icon.className = `v-icon notranslate mdi mdi-${
item.isFolder ? 'folder' : 'text-box'
} theme--dark`;
icon.style.fontSize = '16px';
avatarDiv.appendChild(icon);
const titleDiv = document.createElement('div');
titleDiv.className = 'v-list-item__title';
titleDiv.textContent = item.title;
itemDiv.appendChild(avatarDiv);
itemDiv.appendChild(titleDiv);
// 为文件夹添加点击事件
if (item.isFolder) {
let isExpanded = false;
itemDiv.addEventListener('click', () => {
const existingSubMenu = itemDiv.nextElementSibling?.classList.contains('v-list') ? itemDiv.nextElementSibling : null;
if (isExpanded && existingSubMenu) {
existingSubMenu.remove();
icon.className = 'v-icon notranslate mdi mdi-folder theme--dark';
icon.style.fontSize = '16px';
isExpanded = false;
} else {
getMenuByParent(item.id).then(response => {
const subMenu = renderMenuItems(response, level + 1, null, currentPath);
if (existingSubMenu) {
existingSubMenu.remove();
}
itemDiv.parentElement.insertBefore(subMenu, itemDiv.nextSibling);
icon.className = 'v-icon notranslate mdi mdi-folder-open theme--dark';
icon.style.fontSize = '16px';
isExpanded = true;
});
}
});
// 自动展开包含当前路径的文件夹
// if (pathParts.length > 0 && item.path === pathParts.slice(0, level + 1).join('/')) {
if (pathParts.length > 0 && item.path === decodeURI(pathParts.slice(0, level + 1).join('/'))) {
getMenuByParent(item.id).then(response => {
const subMenu = renderMenuItems(response, level + 1, null, currentPath);
itemDiv.parentElement.insertBefore(subMenu, itemDiv.nextSibling);
icon.className = 'v-icon notranslate mdi mdi-folder-open theme--dark';
icon.style.fontSize = '16px';
isExpanded = true;
});
}
} else {
// 为非文件夹添加跳转事件
itemDiv.addEventListener('click', () => {
window.location.href = `${window.location.origin}/zh/${item.path}`;
});
}
listDiv.appendChild(itemDiv);
});
return listDiv;
};
// 处理 custom_tree_menu 的显示/隐藏逻辑
const handleMenuDisplay = (text) => {
const existingDiv = document.querySelector('div#custom_tree_menu');
const button = getTargetButton();
const parent = button?.parentElement?.parentElement;
const currentPath = getCurrentPath();
if (text === '浏览') {
if (!existingDiv && parent) {
const newDiv = document.createElement('div');
newDiv.id = 'custom_tree_menu';
parent.appendChild(newDiv);
getHomeMenu().then(response => {
newDiv.appendChild(renderMenuItems(response, 0, null, currentPath));
});
} else if (existingDiv) {
existingDiv.style.display = 'block';
getHomeMenu().then(response => {
existingDiv.innerHTML = '';
existingDiv.appendChild(renderMenuItems(response, 0, null, currentPath));
});
}
} else if (text === '主菜单' && existingDiv) {
existingDiv.style.display = 'none';
}
};
// 使用 MutationObserver 监听 div 内容变化
const observeDivChanges = () => {
const button = getTargetButton();
const targetDiv = button?.querySelector('span div');
if (!targetDiv) return;
const observer = new MutationObserver((mutations) => {
mutations.forEach(() => {
const text = getDivText(button);
if (text) handleMenuDisplay(text);
});
});
observer.observe(targetDiv, {
characterData: true,
subtree: true,
childList: true
});
};
// 获取最外层 menu
async function getHomeMenu() {
const currentUrl = window.location.origin;
const uploadUrl = `${currentUrl}/graphql`;
const jwt = document.cookie.split('; ')
.find(row => row.startsWith('jwt='))
?.split('=')[1];
const auth = `Bearer ${jwt}`;
const response = await fetch(uploadUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': auth
},
body: JSON.stringify([{
operationName: null,
variables: { parent: "home", locale: "zh" },
extensions: {},
query: "query ($path: String, $locale: String!) {\n pages {\n tree(path: $path, mode: ALL, locale: $locale, includeAncestors: true) {\n id\n path\n title\n isFolder\n pageId\n parent\n locale\n __typename\n }\n __typename\n }\n}\n"
}])
});
const result = await response.json();
const tree = result[0]?.data?.pages?.tree || [];
return tree.map(item => ({
id: item.id,
path: item.path,
title: item.title,
isFolder: item.isFolder,
pageId: item.pageId,
parent: item.parent
}));
}
// 点击 folder 获取新的 menu
async function getMenuByParent(parentId) {
const currentUrl = window.location.origin;
const uploadUrl = `${currentUrl}/graphql`;
const jwt = document.cookie.split('; ')
.find(row => row.startsWith('jwt='))
?.split('=')[1];
const auth = `Bearer ${jwt}`;
const response = await fetch(uploadUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': auth
},
body: JSON.stringify([{
operationName: null,
variables: { parent: parentId, locale: "zh" },
extensions: {},
query: "query ($parent: Int, $locale: String!) {\n pages {\n tree(parent: $parent, mode: ALL, locale: $locale) {\n id\n path\n title\n isFolder\n pageId\n parent\n locale\n __typename\n }\n __typename\n }\n}"
}])
});
const result = await response.json();
const tree = result[0]?.data?.pages?.tree || [];
return tree.map(item => ({
id: item.id,
path: item.path,
title: item.title,
isFolder: item.isFolder,
pageId: item.pageId,
parent: item.parent
}));
}
let intervalId = window.setInterval(function() {
if (init()) {
observeDivChanges();
window.clearInterval(intervalId);
}
}, 500);
})();