// ==UserScript== // @name ZenTao // @namespace https://iin.ink // @version 2.25 // @description ZenTao style and function enhancement // @author happy share forever core team // @include /^https:\/\/zentao.*$/ // @grant GM_addStyle // @grant GM_setClipboard // ==/UserScript== (function () { 'use strict'; // 宽屏适配 GM_addStyle('#main .container { max-width: unset !important; }'); GM_addStyle('.m-execution-task td.c-name, td.c-title {white-space: normal;}'); GM_addStyle('.m-execution-task .main-table tbody>tr:nth-child(odd) { background: #fff !important; }'); GM_addStyle('.m-execution-task .main-table tbody>tr.table-children { background: #90939442 !important; }'); GM_addStyle('.m-execution-task .main-table .c-actions { width: 300px !important; text-align: left; }'); GM_addStyle('.m-execution-task .datatable-head-span.datatable-span.fixed-right,.datatable-rows-span.datatable-span.fixed-right { width: 300px !important; text-align: left; }'); GM_addStyle('.m-execution-task .main-table .c-actions-5 { width: 300px !important; text-align: left; }'); GM_addStyle('.m-execution-task .chosen-container .chosen-drop { right: 0; }'); GM_addStyle('.m-execution-task .table-datatable { min-width: unset !important; }'); // 看板 GM_addStyle('.m-execution-kanban .board-item > .title { max-height: unset !important; -webkit-line-clamp: unset !important; -webkit-box-orient: unset !important; font-size: 15px !important; }'); GM_addStyle('.m-execution-kanban #kanban .group-title { line-height: 20px !important; font-size: 15px !important; }'); // 弹出层,只看备注按钮 GM_addStyle('.histories-custom-filter-btn { margin-right: 8px }'); const _window = window; let cachedPrefix = _window.localStorage.getItem('_customFilter_projectPrefix'); if (!cachedPrefix) { cachedPrefix = _window.prompt('请补全项目代号,之后可以通过 localStorage _customFilter_projectPrefix 来修改。', 'XXX'); _window.localStorage.setItem('_customFilter_projectPrefix', cachedPrefix || 'XXX'); } const projectPrefix = cachedPrefix || 'XXX'; class Context { executionIframe tW projectPrefix kanbanRefreshTag constructor ({ executionIframe }) { this.executionIframe = executionIframe; this.tW = _window; this.projectPrefix = projectPrefix; this.kanbanRefreshTag = false; } get window () { return this.executionIframe.contentWindow } get urlDomain () { return this.tW.location.origin } get _window () { return this.tW } get document () { return this.executionIframe.contentWindow.document } get kanbanRefreshed () { return this.kanbanRefreshTag === true } setKanbanRefreshTag () { this.kanbanRefreshTag = true; } resetKanbanRefreshTag () { this.kanbanRefreshTag = false; } static of (executionIframe) { return new Context({ executionIframe }) } } /** * 历史记录只展示备注 * @param {Context} ctx */ function enhanceHistoryList (ctx) { const doc = ctx.document; if (doc.querySelectorAll('.histories-custom-filter-btn').length) return const fn = function (type) { $(doc.querySelectorAll('.histories-list li')).each(function () { const $this = $(this); if (type === 'hide' && $this.text().indexOf('备注') === -1) { $this.hide(); } else { $this.show(); } }); }; const $titleBox = $(doc.querySelector('.histories .detail-title')); const $hideBtn = $(doc.createElement('a')); $hideBtn.addClass('btn btn-link pull-right histories-custom-filter-btn'); $hideBtn.html('查看全部'); $hideBtn.on('click', function () { if ($hideBtn.html() === '只看备注') { fn('hide'); $hideBtn.html('查看全部'); } else { fn('show'); $hideBtn.html('只看备注'); } }); $hideBtn.appendTo($titleBox); // 默认查看 fn('hide'); } function enhanceTask (ctx) { const document = ctx.document; const target = $(document.querySelectorAll('.main-table td.c-actions')); if (target.find('span:contains("copy:")').length > 0) return target.each(function () { const $el = $(this).parent(); const taskId = $el.attr('data-id') || $el.find('.cell-id').find('a').text(); const $text = $('copy:'); $text.appendTo($el.find('.c-actions')); const $copyId = $(document.createElement('a')); $copyId.html(' 分支'); $copyId.on('click', function () { GM_setClipboard(`feature/${ctx.projectPrefix}-${taskId}`, { type: 'text', mimetype: 'text/plain' }); }); $copyId.appendTo($el.find('.c-actions')); // 复制标题 const $copyTitle = $(document.createElement('a')); $copyTitle.html(' 标题'); $copyTitle.on('click', function () { let title = window.location.search.includes('f=bug') ? $($el.children()[3]).attr('title') : $(document).find(`tr[data-id=${taskId}]`).find('.c-name').attr('title'); GM_setClipboard(`${ctx.projectPrefix}-${taskId} ${title}`, { type: 'text', mimetype: 'text/plain' }); }); $copyTitle.appendTo($el.find('.c-actions')); // 复制链接 const $copyLink = $(document.createElement('a')); $copyLink.html(' 链接'); $copyLink.on('click', function () { GM_setClipboard(`${ctx.urlDomain}/index.php?m=task&f=view&taskID=${taskId}`, { type: 'text', mimetype: 'text/plain' }); }); $copyLink.appendTo($el.find('.c-actions')); }); } const ALL_TEXT = '全部'; const NOT_CLOSED = '未关闭'; const CN_REG = /[^\x00-\xff]+/gm; // 过滤中文字符的正则 function debounce (fn, delay) { let timerID = null; return function () { const context = this; const args = arguments; if (timerID) { clearTimeout(timerID); } timerID = setTimeout(function () { fn.apply(context, args); }, delay); } } function isAllText (btnArr) { return btnArr.some(b => { const trim = $(b).text().trim(); return !trim || trim === ALL_TEXT }) } function delay (fn, delay = 200) { setTimeout(fn, delay); } class Button { constructor (name, exclusiveList) { this.name = name; this.exclusiveList = exclusiveList; } } function enhanceKanBanStory (target, ctx) { const projectPrefix = ctx.projectPrefix; const document = ctx.document; target.each(function () { const $el = $(this); const $ul = $el.find('ul'); const storyId = $el.attr('data-id'); const $copyIdLi = $(document.createElement('li')); $copyIdLi.html('复制分支'); $copyIdLi.on('click', function () { GM_setClipboard(`feature/${projectPrefix}-${storyId}`, { type: 'text', mimetype: 'text/plain' }); }); $copyIdLi.appendTo($ul); const $copyTitle = $(document.createElement('li')); $copyTitle.html('复制标题'); $copyTitle.on('click', function () { const title = $el.find('.group-title').attr('title'); GM_setClipboard(`${projectPrefix}-${storyId} ${title}`, { type: 'text', mimetype: 'text/plain' }); }); $copyTitle.appendTo($ul); const $copyLink = $(document.createElement('li')); $copyLink.html('复制链接'); $copyLink.on('click', function () { const link = `${ctx.urlDomain}/index.php?m=story&f=view&storyID=${storyId}`; GM_setClipboard(link, { type: 'text', mimetype: 'text/plain' }); }); $copyLink.appendTo($ul); // hover 增强 const $dropdown = $el.find('li.dropdown'); $dropdown.on('mouseover', function () { $dropdown.addClass('open'); $ul.css('margin-top', '-30px'); }).on('mouseleave', function () { $dropdown.removeClass('open'); }); }); } function enhanceKanBanTask (ctx) { const taskInfos = [...ctx.document.querySelectorAll('.info')]; for (const taskInfo of taskInfos) { const $taskInfo = $(taskInfo); const id = $taskInfo.parent().attr('data-id'); const $no = $(ctx.document.createElement('a')); $no.text('#' + id); $no.addClass('small'); $taskInfo.prepend($no); } } function getKanbanTasksMap (kanbanData) { const kanbanTasks = Object.values(kanbanData.stories).map(a => a.tasks).filter(a => a).flatMap(a => Object.values(a)).flatMap(a => a) .concat(Object.values(kanbanData.stories).map(a => a.bugs).filter(a => a).flatMap(a => Object.values(a)).flatMap(a => a)) .concat(Object.values(kanbanData.kanbanGroup).map(a => a.tasks).filter(a => a).flatMap(a => Object.values(a)).flatMap(a => a)) .concat(Object.values(kanbanData.kanbanGroup).map(a => a.bugs).filter(a => a).flatMap(a => Object.values(a)).flatMap(a => a)); const kanbanTasksMap = {}; kanbanTasks.forEach(task => kanbanTasksMap[task.id] = task); return kanbanTasksMap } function getKanbanClosedTaskMap (kanbanTasksMap) { const closedTasks = Object.values(kanbanTasksMap).filter(a => a.status && a.status === 'closed'); const closedTasksMap = {}; closedTasks.forEach(task => closedTasksMap[task.id] = task); return closedTasksMap } function hiddenBoardItemWithPrimaryBtn (doc) { const roleFilterBtnArr = $.makeArray($(doc).find('.btn.custom-filter-btn.btn-primary')); const allBoardList = $(doc.querySelectorAll('.board-item')); allBoardList.each(function () { const $item = $(this); if (isAllText(roleFilterBtnArr)) { $item.css('display', 'block'); } else { const assignedTo = $($item.find('.task-assignedTo,.bug-assignedTo').children()[1]).text().trim(); const isNotClosed = roleFilterBtnArr.map(e => $(e).text().trim()).includes(NOT_CLOSED); const roles = isNotClosed ? roleFilterBtnArr.filter(e => $(e).text().trim() !== NOT_CLOSED) : roleFilterBtnArr; let isDisplay = roles.every(b => { return assignedTo.includes($(b).text().trim()) }); if (isDisplay && isNotClosed) { isDisplay = !assignedTo.includes('Closed'); } if (isDisplay) { $item.css('display', 'block'); } else { $item.css('display', 'none'); } } }); // 隐藏空行 $(doc.querySelectorAll('tr[data-id]')).each(function () { const $tr = $(this); let hasTask = false; $tr.children().find('.board-item').each(function () { if ($(this).css('display') === 'block') { hasTask = true; } }); if (!hasTask && roleFilterBtnArr.length && !isAllText(roleFilterBtnArr)) { $tr.css('display', 'none'); } else { $tr.css('display', 'table-row'); } }); } function enhanceRoleFilter (ctx) { const doc = ctx.document; if (!ctx._window.location.search.includes('kanban')) return if (doc.querySelectorAll('.custom-filter-btn').length) { hiddenBoardItemWithPrimaryBtn(doc); return } const btnList = []; $(doc.querySelectorAll('.task-assignedTo,.bug-assignedTo')).each(function () { const ssignedTo = $(this).text().trim(); const matches = ssignedTo.match(CN_REG); if (!matches) return const name = matches[0]; if (!btnList.map(b => b.name).includes(name)) btnList.push(new Button(name, [0])); }); btnList.sort(); btnList.unshift(new Button(NOT_CLOSED, [1])); btnList.unshift(new Button(ALL_TEXT, [0, 1])); const $mainMenu = $(doc.querySelector('#mainMenu')); btnList.forEach(i => { const $btn = $(doc.createElement('a')); $btn.addClass('btn custom-filter-btn'); if (i.name.includes(ALL_TEXT)) { $btn.addClass('all-button'); } $btn.css('margin-right', '10px'); $btn.html(i.name); $btn.on('click', function () { const isChecked = $btn.hasClass('btn-primary'); if (isChecked) { $btn.removeClass('btn-primary'); } else { $.makeArray($btn.addClass('btn-primary').siblings('a')) .filter(e => btnList.find(b => b.name === $(e).text()).exclusiveList.filter(v => i.exclusiveList.includes(v)).length > 0) .forEach(e => $(e).removeClass('btn-primary')); } const checkedNames = $.makeArray($(doc).find('.btn-primary.custom-filter-btn')).map(e => e.text); if (!checkedNames || !checkedNames.length) { // 如果没选中任何条件,则默认选中“全部” $(doc).find('.all-button').click(); } ctx._window.localStorage.setItem('_customerFilter_name', JSON.stringify(checkedNames)); hiddenBoardItemWithPrimaryBtn(doc); }); $btn.appendTo($mainMenu); }); const checkedNames = JSON.parse(ctx._window.localStorage.getItem('_customerFilter_name') || '""'); if (checkedNames && checkedNames.length > 0) { if (!checkedNames.every(c => btnList.map(b => b.name).includes(c))) { ctx._window.localStorage.setItem('_customerFilter_name', JSON.stringify('')); return } $(doc).find('.btn.custom-filter-btn').each((index, item) => { const $item = $(item); checkedNames.forEach(c => { if ($item.text().trim() === c) { $item.click(); } }); }); } } const kanbanDataCache = {}; function enhanceKanBanClosedTaskWithCache (ctx) { const executionID = new URL(ctx.tW.location.href).searchParams.get('executionID'); if (kanbanDataCache[executionID]) { enhanceKanBanClosedTask(kanbanDataCache[executionID], ctx); } else { debouncedEnhanceKanBanClosedTask(ctx); } } const debouncedEnhanceKanBanClosedTask = debounce(queryKanbanAndEnhanceKanBanClosedTask, 100); function queryKanbanAndEnhanceKanBanClosedTask (ctx) { const executionID = new URL(ctx.tW.location.href).searchParams.get('executionID'); $.get(`${ctx.urlDomain}/index.php?m=execution&f=kanban&t=json&executionID=${executionID}`, function (res) { const kanbanData = JSON.parse(JSON.parse(res).data); kanbanDataCache[executionID] = kanbanData; enhanceKanBanClosedTask(kanbanData, ctx); }); } function enhanceKanBanClosedTask (kanbanData, ctx) { const kanbanTasksMap = getKanbanTasksMap(kanbanData); const closedTasksMap = getKanbanClosedTaskMap(kanbanTasksMap); const tasksDom = [...ctx.document.querySelectorAll('.task-assignedTo,.bug-assignedTo')]; for (const taskDom of tasksDom) { const u = new URL(taskDom.parentElement.previousElementSibling.href); const taskID = u.searchParams.get('bugID') ? u.searchParams.get('bugID') : u.searchParams.get('taskID'); if (!closedTasksMap[taskID]) continue const kanbanTask = kanbanTasksMap[taskID]; const $span = $(taskDom).find('span'); const closerName = kanbanData.realnames[kanbanTask.closedBy]; $span.text(`Closed(${closerName})`); $span.css('max-width', '100px'); } // 增强看板:增加角色过滤器 enhanceRoleFilter(ctx); } function enhanceKanBan (ctx) { const document = ctx.document; const $container = $(document.querySelector('#kanban > table')); if ($container.hasClass('enhanceKanBan') && ctx.kanbanRefreshed) return $container.addClass('enhanceKanBan'); ctx.setKanbanRefreshTag(); const target = $(document.querySelectorAll('.board-story')); // 已经添加过了 if (target.find('a:contains("复制分支")').length > 0) return enhanceKanBanStory(target, ctx); enhanceKanBanTask(ctx); enhanceKanBanClosedTaskWithCache(ctx); } function enhanceDialog (mutations, ctx) { const document = ctx.document; mutations.forEach(item => { if (item.addedNodes.length > 0) { const firstChild = $(item.addedNodes[0]); if (firstChild.attr('id') === 'iframe-triggerModal') { // 任务详情弹窗 firstChild.off('load').on('load', function () { const doc = firstChild[0].contentWindow.document; enhanceHistoryList(Context.of(firstChild[0])); const toolbar = $(doc.querySelector('.main-actions > .btn-toolbar')); // 复制分支 const $copyId = $(document.createElement('a')); $copyId.addClass('btn btn-link showinonlybody'); $copyId.html(' 复制分支'); const taskId = $(doc.querySelector('.page-title > span.label-id')).text(); $copyId.on('click', function () { GM_setClipboard(`feature/${ctx.projectPrefix}-${taskId}`, { type: 'text', mimetype: 'text/plain' }); }); $copyId.appendTo(toolbar); // 复制标题 const $copyTitle = $(document.createElement('a')); $copyTitle.addClass('btn btn-link showinonlybody'); $copyTitle.html(' 复制标题'); $copyTitle.on('click', function () { const title = $(doc.querySelector('.page-title > span.text')).attr('title'); GM_setClipboard(`${ctx.projectPrefix}-${taskId} ${title}`, { type: 'text', mimetype: 'text/plain' }); }); $copyTitle.appendTo(toolbar); // 复制链接 const $copyLink = $(document.createElement('a')); $copyLink.addClass('btn btn-link showinonlybody'); $copyLink.html(' 复制链接'); $copyLink.on('click', function () { const textContent = doc.querySelector('.tabs').textContent; console.log('textContent', textContent); if (textContent.indexOf('任务的一生') !== -1) { GM_setClipboard(`${ctx.urlDomain}/index.php?m=task&f=view&taskID=${taskId}`, { type: 'text', mimetype: 'text/plain' }); } else if (textContent.indexOf('Bug类型') !== -1) { GM_setClipboard(`${ctx.urlDomain}/index.php?m=bug&f=view&bugID=${taskId}`, { type: 'text', mimetype: 'text/plain' }); } else { GM_setClipboard(`${ctx.urlDomain}/index.php?m=story&f=view&storyID=${taskId}`, { type: 'text', mimetype: 'text/plain' }); } }); $copyLink.appendTo(toolbar); }); } } }); } const fixBackBtn = function (ctx) { delay(() => { const $aDom = $(ctx.window.document).find('a[href*="json"]'); $aDom.each((idx, item) => { item.href="javascript:window.history.back()"; }); }); }; const debouncedFixBackBtn = debounce(fixBackBtn, 1000); function enhanceExecution () { const executionIframe = document.querySelector('#appIframe-execution'); if (executionIframe) { const ctx = Context.of(executionIframe); executionIframe.onload = function () { setTimeout(() => ctx.window.dispatchEvent(new Event('resize')), 500); const doc = ctx.document; enhanceTask(ctx); enhanceKanBan(ctx); enhanceHistoryList(ctx); debouncedFixBackBtn(ctx); const observer = new MutationObserver((mutations) => { enhanceTask(ctx); enhanceKanBan(ctx); enhanceDialog(mutations, ctx); enhanceHistoryList(ctx); debouncedFixBackBtn(ctx); }); observer.observe(doc.body, { childList: true, subtree: true }); }; } } enhanceExecution(); const observer = new MutationObserver(() => { enhanceExecution(); }); const target = document.querySelector('#apps'); if (target) { observer.observe(target, { childList: true, subtree: true }); } })();