您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
计算pingcode特性列表页面中的故事点总数,监听特定API返回的故事点
// ==UserScript== // @name pingcode 特性故事点统计 // @namespace http://tampermonkey.net/ // @version 0.2 // @description 计算pingcode特性列表页面中的故事点总数,监听特定API返回的故事点 // @author LW // @match *://*.pingcode.com/* // @license MIT // @grant none // @note 0.2 增加图钉功能 // ==/UserScript== (function () { 'use strict'; let container = null; // === Storage操作函数 === function setStorage(key, value) { try { localStorage.setItem(key, value); } catch (e) { console.warn('无法写入localStorage:', e); } } function getStorage(key) { try { return localStorage.getItem(key); } catch (e) { console.warn('无法读取localStorage:', e); return null; } } function removeStorage(key) { try { localStorage.removeItem(key); } catch (e) { console.warn('无法删除localStorage:', e); } } // === 图钉状态管理 === const PIN_STORAGE_KEY = 'story_points_calculator_pinned'; function isPinned() { return getStorage(PIN_STORAGE_KEY) === 'true'; } function setPinned(pinned) { if (pinned) { setStorage(PIN_STORAGE_KEY, 'true'); } else { removeStorage(PIN_STORAGE_KEY); } } // === 自动销毁相关 === let autoRemoveTimer = null; function startAutoRemoveTimer() { // 如果已固定,则不启动自动销毁定时器 if (isPinned()) { return; } clearAutoRemoveTimer(); autoRemoveTimer = setTimeout(() => { if (container && container.parentNode) { container.parentNode.removeChild(container); container = null; } removeAutoRemoveListeners(); }, 10000); } function clearAutoRemoveTimer() { if (autoRemoveTimer) { clearTimeout(autoRemoveTimer); autoRemoveTimer = null; } } function onMouseEnter() { clearAutoRemoveTimer(); } function onMouseLeave() { startAutoRemoveTimer(); } function removeAutoRemoveListeners() { container && container.removeEventListener('mouseenter', onMouseEnter); container && container.removeEventListener('mouseleave', onMouseLeave); } // === 自动销毁相关 END === function getContainer() { startAutoRemoveTimer(); if (container) { return container; } let originalStyles = {}; // 保存原始样式 if (container) { return container; } else { container = document.createElement('div'); container.id = 'story-points-container'; document.body.appendChild(container); } container.addEventListener('mouseenter', onMouseEnter); container.addEventListener('mouseleave', onMouseLeave); // 保存原始样式以便展开时恢复 originalStyles = { position: 'fixed', top: '80px', left: '60px', backgroundColor: 'rgba(0, 0, 0, 0.7)', color: 'white', borderRadius: '5px', zIndex: '9999', fontWeight: 'bold', padding: '10px', width: container.style.width || 'auto', // 初始宽度 height: container.style.height || 'auto', // 初始高度 overflow: 'visible', // 初始 overflow cursor: 'grab', }; // 应用初始/展开样式 Object.assign(container.style, originalStyles); // 拖动功能 let offsetX, offsetY, isDragging = false; container.addEventListener('mousedown', (e) => { // 只允许在未收缩时,或收缩时点击非内容区域(这里整个球体都可拖动)开始拖动 // 如果有特定的拖动把手,判断逻辑会不同 isDragging = true; offsetX = e.clientX - container.getBoundingClientRect().left; offsetY = e.clientY - container.getBoundingClientRect().top; container.style.cursor = 'grabbing'; container.style.userSelect = 'none'; // 防止拖动时选中文本 // 确保在document上监听move和up,这样鼠标移出元素也能继续拖动 document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); e.preventDefault(); // 防止默认的拖动行为,如图片或链接 }); function onMouseMove(e) { if (!isDragging) return; let newX = e.clientX - offsetX; let newY = e.clientY - offsetY; // 边界检测 (可选) const containerWidth = container.offsetWidth; const containerHeight = container.offsetHeight; const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; newX = Math.max(0, Math.min(newX, viewportWidth - containerWidth)); newY = Math.max(0, Math.min(newY, viewportHeight - containerHeight)); container.style.left = `${newX}px`; container.style.top = `${newY}px`; } function onMouseUp() { if (!isDragging) return; isDragging = false; container.style.cursor = originalStyles.cursor || 'grab'; // 恢复光标 container.style.userSelect = ''; document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); } if ( document.querySelector('#story-points-container') && document.querySelector('#story-points-container')._alreadyEnhanced ) { // 如果已经存在并且已经增强过了,直接返回 return document.querySelector('#story-points-container'); } // 标记为已增强 container._alreadyEnhanced = true; // 添加图钉按钮 const pinBtn = document.createElement('span'); pinBtn.innerText = '📌'; pinBtn.title = isPinned() ? '取消固定' : '固定显示'; Object.assign(pinBtn.style, { position: 'absolute', top: '-8px', right: '16px', fontSize: '16px', color: '#fff', borderRadius: '50%', width: '24px', height: '24px', lineHeight: '24px', textAlign: 'center', cursor: 'pointer', zIndex: '10000', transition: 'all 0.2s', userSelect: 'none', fontSize: '14px', lineHeight: '24px', background: isPinned() ? 'rgba(0,123,255,0.7)' : 'rgba(0,0,0,0.7)', }); pinBtn.addEventListener('click', (e) => { e.stopPropagation(); const currentPinned = isPinned(); const newPinned = !currentPinned; // 更新图钉状态 setPinned(newPinned); // 更新按钮外观 pinBtn.style.background = newPinned ? 'rgba(0,123,255,0.7)' : 'rgba(0,0,0,0.7)'; pinBtn.title = newPinned ? '取消固定' : '固定显示'; if (newPinned) { // 固定时清除自动销毁定时器 clearAutoRemoveTimer(); } else { // 取消固定时重新启动自动销毁定时器 startAutoRemoveTimer(); } }); container.appendChild(pinBtn); // 添加关闭按钮 const closeBtn = document.createElement('span'); closeBtn.innerText = '×'; closeBtn.title = '关闭'; Object.assign(closeBtn.style, { position: 'absolute', top: '-8px', right: '-10px', fontSize: '20px', color: '#fff', background: 'rgba(0,0,0,0.7)', borderRadius: '50%', width: '24px', height: '24px', lineHeight: 1, textAlign: 'center', cursor: 'pointer', zIndex: '10000', transition: 'background 0.2s', userSelect: 'none', }); closeBtn.addEventListener('mouseenter', () => { closeBtn.style.background = 'rgba(255,0,0,0.7)'; }); closeBtn.addEventListener('mouseleave', () => { closeBtn.style.background = 'rgba(0,0,0,0.7)'; }); closeBtn.addEventListener('click', (e) => { e.stopPropagation(); if (container && container.parentNode) { container.parentNode.removeChild(container); container = null; } removeAutoRemoveListeners(); }); container.appendChild(closeBtn); return container; } function createTabItem(omsId, item, parentDom) { const activeKey = parentDom.dataset.activeKey || 'member'; const fullOmsId = `OMS-${omsId}`; const dom = document.createElement('div'); dom.setAttribute('data-oms-id', fullOmsId); dom.style.marginBottom = '10px'; dom.style.fontSize = '14px'; dom.style.color = '#fff'; const createTabItemDom = (text, key) => { const tabItemDom = document.createElement('div'); tabItemDom.style.padding = '0 16px'; tabItemDom.style.height = '36px'; tabItemDom.style.display = 'flex'; tabItemDom.style.alignItems = 'center'; tabItemDom.style.cursor = 'pointer'; tabItemDom.style.fontSize = '13px'; tabItemDom.style.background = 'transparent'; tabItemDom.style.border = 'none'; tabItemDom.style.position = 'relative'; tabItemDom.style.transition = 'color 0.2s'; tabItemDom.style.fontWeight = activeKey === key ? 'bold' : 'normal'; tabItemDom.style.color = activeKey === key ? '#1677ff' : '#ccc'; tabItemDom.setAttribute('data-key', key); tabItemDom.setAttribute('data-active', activeKey === key); tabItemDom.className = 'spc-tab-item'; tabItemDom.appendChild(document.createTextNode(text)); // 指示条 const indicator = document.createElement('div'); indicator.style.position = 'absolute'; indicator.style.left = '8px'; indicator.style.right = '8px'; indicator.style.bottom = '0'; indicator.style.height = activeKey === key ? '3px' : '0'; indicator.style.background = activeKey === key ? '#1677ff' : 'transparent'; indicator.style.borderRadius = '2px'; indicator.style.transition = 'all 0.2s'; tabItemDom.appendChild(indicator); tabItemDom.addEventListener('mouseenter', () => { if (tabItemDom.dataset.active !== 'true') { tabItemDom.style.color = '#1677ff'; } }); tabItemDom.addEventListener('mouseleave', () => { if (tabItemDom.dataset.active !== 'true') { tabItemDom.style.color = '#ccc'; } }); tabItemDom.addEventListener('click', () => { parentDom.dataset.activeKey = key; parentDom.querySelectorAll('.tab-content-dom').forEach(item => { item.style.display = 'none'; }) parentDom.querySelectorAll('.spc-tab-item').forEach(tab => { tab.style.color = '#ccc'; tab.style.fontWeight = 'normal'; tab.dataset.active = 'false'; if (tab.lastChild) { tab.lastChild.style.height = '0'; tab.lastChild.style.background = 'transparent'; } }); tabItemDom.style.color = '#1677ff'; tabItemDom.style.fontWeight = 'bold'; indicator.style.height = '2px'; indicator.style.background = '#1677ff'; tabItemDom.dataset.active = 'true'; parentDom.querySelector(`[data-content-key="${key}"]`).style.display = 'block'; }) return tabItemDom; } const createContentDom = (key, map) => { const contentDom = document.createElement('div'); contentDom.setAttribute('data-content-key', key); contentDom.classList.add('tab-content-dom'); contentDom.style.display = key === activeKey ? 'block' : 'none'; contentDom.style.padding = '5px 5px 5px 10px'; contentDom.style.marginTop = '-2px'; contentDom.style.minHeight = '36px'; let isFirst = true; map.forEach((value, key) => { const itemDiv = document.createElement('div'); itemDiv.style.padding = '6px 0'; itemDiv.style.display = 'flex'; itemDiv.style.justifyContent = 'space-between'; itemDiv.style.alignItems = 'center'; itemDiv.style.borderTop = isFirst ? 'none' : '1px solid rgba(255,255,255,0.08)'; itemDiv.textContent = `${key} : ${value.total}`; contentDom.appendChild(itemDiv); isFirst = false; }) return contentDom; } const tabItems = [ { text: '负责人', key: 'member', content: item.calcForMember, tabDom: createTabItemDom('负责人', 'member'), contentDom: createContentDom('member', item.calcForMember), }, { text: '故事类型', key: 'storyType', content: item.calcForStoryType, tabDom: createTabItemDom('故事类型', 'storyType'), contentDom: createContentDom('storyType', item.calcForStoryType), }, { text: '状态', key: 'state', content: item.calcForState, tabDom: createTabItemDom('状态', 'state'), contentDom: createContentDom('state', item.calcForState), }, ] const tabsDom = document.createElement('div'); tabsDom.style.display = 'flex'; tabsDom.style.alignItems = 'center'; tabsDom.style.borderBottom = '1px solid rgba(255,255,255,0.08)'; tabsDom.style.marginBottom = '0px'; tabsDom.style.paddingBottom = '0px'; tabsDom.style.gap = '0px'; tabsDom.style.background = 'transparent'; tabItems.forEach(item => { tabsDom.appendChild(item.tabDom); }) dom.appendChild(tabsDom); const contentDom = document.createElement('div'); tabItems.forEach(item => { contentDom.appendChild(item.contentDom); }) dom.appendChild(contentDom); const activeContentDom = parentDom.querySelector(`[data-content-key="${activeKey}"]`); if (activeContentDom) { activeContentDom.style.display = 'block'; } return dom; } function calcStoryPointsArray(data) { // 按人计算故事点 // 按故事类型计算故事点 // 按状态 const getItemMemberName = (item) => { const member = data?.references?.members?.find(m => m.uid === item.assignee); return member?.display_name || '-'; } const getItemStateName = (item) => { const state = data?.references?.states?.find(s => s._id === item.state_id); return state?.name || '-'; } const getItemStoryTypeName = (item) => { const gushileixingType = data?.references?.properties?.find(t => t.key === 'gushileixing'); const storyType = gushileixingType?.options?.find(s => s._id === item.properties.gushileixing); return storyType?.text || '-'; } const getValues = data.value.map(item => { return { source: item.source, __story_points: item.properties.story_points, __memberName: getItemMemberName(item), __stateName: getItemStateName(item), __storyTypeName: getItemStoryTypeName(item), } }) // return getValues; const calcForKey = (key) => { const map = new Map(); getValues.forEach(item => { const value = item[key]; const next = map.get(value); map.set(value, { total: (next?.total || 0) + item.__story_points, source: [...(next?.source || []), item.source], }); }); return map; } const calcForMember = calcForKey('__memberName'); const calcForStoryType = calcForKey('__storyTypeName'); const calcForState = calcForKey('__stateName'); return { total: getValues.reduce((acc, item) => acc + (item.__story_points || 0), 0), calcForMember, calcForStoryType, calcForState, } } // 公共方法:为指定父节点和内容节点添加折叠/展开按钮,返回按钮节点 function addCollapseToggle(headerDom, contentDom) { const btn = document.createElement('span'); btn.style.cursor = 'pointer'; btn.style.padding = '2px 4px 2px 8px'; btn.style.userSelect = 'none'; btn.style.fontSize = '12px'; btn.style.display = 'inline-block'; btn.style.transition = 'transform 0.2s'; btn.style.alignSelf = 'flex-start'; // 可选:居左/居中 btn.innerText = '\u25B6'; // ▶ ▼ btn.dataset.collapsed = false; const innerHeaderDom = document.createElement('div'); innerHeaderDom.appendChild(headerDom); innerHeaderDom.appendChild(btn); innerHeaderDom.style.display = 'flex'; innerHeaderDom.style.alignItems = 'center'; innerHeaderDom.style.justifyContent = 'space-between'; const innerContentDom = document.createElement('div'); innerContentDom.appendChild(contentDom); innerContentDom.style.overflow = 'hidden'; innerContentDom.style.height = '0'; btn.addEventListener('click', () => { let collapsed = btn.dataset.collapsed === 'true'; collapsed = !collapsed; if (!collapsed) { innerContentDom.style.height = '0'; btn.innerText = '\u25B6'; } else { innerContentDom.style.height = 'auto'; btn.innerText = '\u25BC'; } btn.dataset.collapsed = collapsed; }); const dom = document.createElement('div'); dom.appendChild(innerHeaderDom); dom.appendChild(innerContentDom); return dom; } function createLinkDom(shortId, name, title) { const linkDom = document.createElement('a'); linkDom.href = `${window.location.origin}/pjm/workitems/${shortId}`; linkDom.target = '_blank'; linkDom.textContent = name; linkDom.style.color = '#1677ff'; linkDom.title = title; return linkDom; } const matchUrls = [ { url: '/sprint/views/work-item/content', callback: (response) => { // const total = calculateStoryPoints(response.data); // console.log('故事点总和:', total); // const info = { // total, // count: response.data.count, // sprint: response.data.references?.sprints?.[0]?.name, // }; // updateStoryPointsDisplay(info); }, }, { url: '/api/agile/work-items/.+/children', callback: (response) => { const info = calcStoryPointsArray(response.data); const total = info.total; const parentShortId = response.data.references?.parents?.[0]?.short_id; const parentTitle = response.data.references?.parents?.[0]?.title; const omsId = response.data.references?.parents?.[0]?.identifier; const omsType = response.data.references?.parents?.[0]?.type; // 只支持特性 if (omsType !== 2) { return; } const fullOmsId = `OMS-${omsId}`; const container = getContainer(); // 先尝试移除已有的相同ID的子项,避免重复 const existingChildDom = container.querySelector( `div[data-oms-id="${fullOmsId}"]`, ); const dom = document.createElement('div'); dom.setAttribute('data-oms-id', fullOmsId); dom.style.display = 'flex'; dom.style.flexDirection = 'column'; dom.style.alignItems = 'stretch'; // 创建标题和内容分离结构 const header = document.createElement('div'); const linkDom = createLinkDom(parentShortId, fullOmsId, parentTitle); header.appendChild(linkDom); header.appendChild(document.createTextNode(` 故事点总和: ${total}`)); header.style.flex = '1'; dom.appendChild(header); const tabContent = createTabItem(omsId, info, dom); // 创建折叠按钮,插入到 header 下方 const collapseBtn = addCollapseToggle(header, tabContent); dom.appendChild(collapseBtn); if (existingChildDom) { existingChildDom.replaceWith(dom); } else { container.insertBefore(dom, container.firstChild); } }, }, ]; // 拦截XHR请求 const originalXHROpen = XMLHttpRequest.prototype.open; const originalXHRSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function () { this._url = arguments[1]; return originalXHROpen.apply(this, arguments); }; XMLHttpRequest.prototype.send = function () { const matchUrl = matchUrls.find((item) => new RegExp(item.url).test(this._url), ); if (matchUrl) { const originalOnReadyStateChange = this.onreadystatechange; this.onreadystatechange = function () { if (this.readyState === 4 && this.status === 200) { try { const response = JSON.parse(this.responseText); setTimeout(() => { matchUrl.callback(response); }, 500); } catch (e) { console.error('解析故事点数据时出错:', e); } } if (originalOnReadyStateChange) { originalOnReadyStateChange.apply(this, arguments); } }; } return originalXHRSend.apply(this, arguments); }; })();