// ==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);
};
})();