pingcode 特性故事点统计

计算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);
  };
})();