Greasy Fork is available in English.

zentao tool

仅针对 OS-EASY 适配,标记 bug 留存时间、解决方案填写人提示、计算每日工时、一键复制解决的 bug、解决指派 bug 强制填写工时、Bug 点击在新标签页打开

  1. // ==UserScript==
  2. // @name zentao tool
  3. // @namespace Violentmonkey Scripts
  4. // @match http*://172.16.203.14/*
  5. // @require https://unpkg.com/jquery@3.3.1/dist/jquery.min.js
  6. // @require https://unpkg.com/workday-cn/lib/workday-cn.umd.js
  7. // @grant GM_addStyle
  8. // @grant GM_setClipboard
  9. // @version 1.4.1
  10. // @author snowman
  11. // @license GPLv3
  12. // @description 仅针对 OS-EASY 适配,标记 bug 留存时间、解决方案填写人提示、计算每日工时、一键复制解决的 bug、解决指派 bug 强制填写工时、Bug 点击在新标签页打开
  13. // ==/UserScript==
  14.  
  15. (() => {
  16. $.noConflict(true)(document).ready(async ($) => {
  17. // 初始化
  18. await initialize();
  19.  
  20. // 定义颜色常量
  21. const colors = {
  22. green: '#82E0AA',
  23. yellow: '#F7DC6F',
  24. brown: '#FE9900',
  25. red: '#E74C3C'
  26. };
  27.  
  28. // 设置通用的点击事件监听器
  29. setBodyClickListener();
  30. // 根据当前路径进行不同的处理
  31. const path = document.location.pathname;
  32. switch (path) {
  33. case '/effort-calendar.html':
  34. handleEffortCalendar(colors);
  35. break;
  36. case '/my-work-bug.html':
  37. handleMyWorkBug(colors);
  38. break;
  39. default:
  40. handleDefaultPath(path);
  41. break;
  42. }
  43.  
  44. // 初始化函数
  45. async function initialize() {
  46. const userName = localStorage.getItem('zm-username');
  47. if (!userName) {
  48. const name = prompt("看上去你是第一次使用,请输入禅道中的姓名:");
  49. if (name) localStorage.setItem('zm-username', name);
  50. else return;
  51. }
  52. $("td.text-left a").attr('target', '_blank');
  53. }
  54.  
  55. // 设置通用的点击事件监听器
  56. function setBodyClickListener() {
  57. document.body.onclick = async function (e) {
  58. if (e instanceof PointerEvent) {
  59. const aTag = getATag(e.target);
  60. if (!aTag) return;
  61. const aHref = $(aTag).attr('href');
  62. if (aHref?.includes('bug-resolve')) {
  63. await generatorResolveType();
  64. }
  65. }
  66. };
  67. }
  68.  
  69. // 获取点击的A标签
  70. function getATag(target) {
  71. if (target.tagName === 'A') return target;
  72. if (target.parentElement.tagName === 'A') return target.parentElement;
  73. return null;
  74. }
  75.  
  76. // 处理 effort-calendar 页面
  77. function handleEffortCalendar(colors) {
  78. GM_addStyle(`
  79. span.zm-day { font-weight: bold; margin: 0 8px; }
  80. .warn { color: ${colors.brown}; }
  81. .fine { color: ${colors.green}; }
  82. `);
  83. waitForContentInContainer('#main', 'table').then(element => {
  84. const observer = new MutationObserver(() => markEffortCalendar(element, observer));
  85. observer.observe(element, { subtree: true, childList: true });
  86. markEffortCalendar(element, observer);
  87. });
  88. }
  89.  
  90. // 标记 effort-calendar 页面的数据
  91. function markEffortCalendar(element, observer) {
  92. observer.disconnect();
  93. const days = element.querySelectorAll(".cell-day");
  94. days.forEach(dayElement => {
  95. const total = calculateTotalTime(dayElement);
  96. updateDayElement(dayElement, total);
  97. });
  98. observer.observe(element, { subtree: true, childList: true });
  99. }
  100.  
  101. // 计算时间总和
  102. function calculateTotalTime(dayElement) {
  103. const timeEles = dayElement.querySelectorAll('.has-time .time');
  104. return Array.from(timeEles).reduce((total, time) => total + parseFloat(time.textContent), 0);
  105. }
  106.  
  107. // 更新天数元素的显示
  108. function updateDayElement(dayElement, total) {
  109. $(dayElement).find('.zm-day').remove();
  110. if (total != 0) {
  111. const colorClass = total > 10 || total < 8 ? 'warn' : 'fine';
  112. $(dayElement) .find('.heading') .prepend( `<div class="copy-time btn-toolbar pull-left" style="margin-left:25px;display:flex;align-items:center;">复制</div>` )
  113. $(dayElement).find('.heading').prepend(`<span class="zm-day ${colorClass}">【${total.toFixed(1)}小时】</span>`);
  114. $(dayElement) .find('.heading') .find('.copy-time') .on('click', async function (e) { copyTaskTime(e) })
  115. }
  116. }
  117.  
  118. // 复制任务时间
  119. async function copyTaskTime(e) {
  120. e.stopPropagation()
  121. const targetEle = e.target
  122. const content = $(targetEle).parent('.heading').next('.content')
  123. function calculateTaskTimes(startTime, tasks) {
  124. let currentHour = parseInt(startTime.split(':')[0])
  125. let currentMinute = parseInt(startTime.split(':')[1])
  126. const results = []
  127. let startDate = new Date()
  128. startDate.setHours(currentHour)
  129. startDate.setMinutes(currentMinute)
  130.  
  131. const middleStartDate = new Date()
  132. middleStartDate.setHours(12)
  133. middleStartDate.setMinutes(0)
  134. const middleEndDate = new Date()
  135. middleEndDate.setHours(14)
  136. middleEndDate.setMinutes(0)
  137.  
  138. let endDate = null
  139.  
  140. tasks.forEach((task) => {
  141. const hourStamp = 60 * 60 * 1000
  142. const timeParts = task.time.split('h')
  143. let hours = timeParts[0] * 1
  144. let startStamp = startDate.getTime()
  145. const middleStamp = middleStartDate.getTime()
  146. const middleEndStamp = middleEndDate.getTime()
  147. let endStamp = startStamp + hours * hourStamp
  148.  
  149. if (startStamp <= middleStamp && endStamp > middleStamp) {
  150. endStamp = endStamp + 2 * hourStamp
  151. }
  152. const start = new Date(startStamp)
  153. const end = new Date(endStamp)
  154. const startTimeStr = `${String(start.getHours()).padStart(2, '0')}:${String(start.getMinutes()).padStart(2, '0')}`
  155. const endTimeStr = `${String(end.getHours()).padStart(2, '0')}:${String(end.getMinutes()).padStart(2, '0')}`
  156. startDate = new Date(endStamp)
  157. results.push({
  158. ...task,
  159. start: startTimeStr,
  160. end: endTimeStr
  161. })
  162. })
  163.  
  164. return results
  165. }
  166.  
  167. // 示例用法
  168. const start = '08:30'
  169. let tempTasks = Array.from(
  170. content
  171. .find('.events')
  172. .find('.event')
  173. .map(function () {
  174. const title = $(this).find('.title').text().trim()
  175. const time = $(this).find('.time').text().trim()
  176. const id = $(this).data('id')
  177. return {
  178. id,
  179. time,
  180. title
  181. }
  182. })
  183. )
  184. tempTasks = calculateTaskTimes(start, tempTasks)
  185. const parseTaskDoc = function (doc) {
  186. const objReg = new RegExp(`对象\n`)
  187. const id = $(doc).find('.main-header span.label').text()
  188. let item = {}
  189. $(doc)
  190. .find('table tbody tr')
  191. .each(function () {
  192. // console.log($(this).text())
  193. const text = $(this).text()
  194. if (objReg.test(text)) {
  195. item.obj = text.replace(objReg, '').replace('\n', '').trim()
  196. item.href = $(this).find('a').attr('href')
  197. }
  198. })
  199. return { ...item, id }
  200. }
  201. const fetchTaskData = async function () {
  202. const docs = await Promise.all(
  203. tempTasks.map(async function (t) {
  204. return fetchDocument(
  205. `/effort-view-${t.id}.html?onlybody=yes&tid=i2sh4q46`
  206. )
  207. })
  208. )
  209. return docs.map((d) => parseTaskDoc(d))
  210. }
  211. const taskObjData = await fetchTaskData()
  212. let tasks = tempTasks.map((t) => {
  213. const findOne = taskObjData.find(
  214. (task) => task.id * 1 === t.id * 1
  215. )
  216. return { ...t, ...findOne }
  217. })
  218. tasks = tasks
  219. .map((t) => {
  220. return `- [ ] ${t.start} - ${t.end} #工时 ${t.time}\t${t.title}\t ${t.obj && t.href ? `[${t.obj}](${location.origin + t.href})\t` : ''}\n`
  221. })
  222. .join('')
  223. GM_setClipboard(tasks)
  224. }
  225. // 设置 执行-版本-6.0.5-future-我解决的bug 页面功能
  226. function setupResolvedByMeBuildPage() {
  227. $(
  228. '<div class="btn btn-success" style="margin-right:10px;">复制勾选</div>'
  229. )
  230. .on('click', function () {
  231. const bugs = $('tr.checked')
  232. .map(function () {
  233. const tds = $(this).find('td')
  234. const id = $(tds[0]).text().trim()
  235. const raw = $(tds[1]).text().trim()
  236. let range = raw.match(/【([^【】]+?\/.+?)】/)
  237. range = !range ? '' : range[1].replace(/(\d\.?|-){3}/, '') // 移除版本号
  238. const title = raw.slice(raw.lastIndexOf('】') + 1)
  239. return `${localStorage.getItem('zm-username')}\t\t${id} ${title}\t${range}\n`
  240. })
  241. .get()
  242. .join('')
  243. GM_setClipboard(bugs)
  244. })
  245. .insertBefore('#bugs .actions a')
  246. }
  247. // 处理 my-work-bug 页面
  248. function handleMyWorkBug(colors) {
  249. GM_addStyle(`
  250. td.text-left.nobr { white-space: normal; }
  251. span.zm-mark { padding: 2px; border-radius: 4px; border: 1px solid; font-size: .9em; }
  252. `);
  253. addBugFetchButton(colors);
  254. }
  255.  
  256. // 添加获取bug时间按钮
  257. function addBugFetchButton(colors) {
  258. const btn = $(`<div class="btn-toolbar pull-right" style="display:flex;align-items:center;"><div class="btn btn-warning">获取bug时间</div><span style="color:${colors.red};">一页超过8个Bug时需要手动获取</span></div>`)
  259. .on('click', async function () {
  260. let bugData = await fetchBugData();
  261. bugData = bugData.map(({ start, hasReactive }) => ({ ...timeRangeStr(start), processed: hasReactive }))
  262. updateBugTimeCells(bugData, colors);
  263. }).appendTo('#mainMenu');
  264.  
  265. // 自动点击按钮以加载数据
  266. if ($('tr').length < 9) btn.click();
  267. }
  268.  
  269. // 获取Bug数据
  270. async function fetchBugData() {
  271. const bugUrls = $("tr td:nth-child(5) a").map((_, ele) => ele.href).get();
  272. const bugPages = await Promise.all(bugUrls.map(fetchDocument));
  273. return bugPages.map(parseBugPage);
  274. }
  275.  
  276. // 更新Bug时间单元格
  277. function updateBugTimeCells(bugData, colors) {
  278. $("tr th:nth-child(9)").text('Bug 留存').removeClass('text-center');
  279. $("tr td:nth-child(9)").each((idx, ele) => {
  280. const cell = $(ele).empty().html(`<span class="zm-mark">${bugData[idx].str}</span>`);
  281. const { h, processed } = bugData[idx];
  282. updateCellColor(cell, h, processed, colors);
  283. });
  284. }
  285.  
  286. // 更新单元格颜色
  287. function updateCellColor(cell, h, processed, colors) {
  288. if (h < 12) cell.css({ color: colors.green });
  289. else if (h < 24) cell.css({ color: !processed ? colors.yellow : colors.green });
  290. else if (h < 34) cell.css({ color: !processed ? colors.brown : colors.yellow });
  291. else if (h < 70) cell.css({ color: !processed ? colors.red : colors.brown });
  292. else cell.css({ color: colors.red });
  293. }
  294.  
  295. // 处理默认路径
  296. function handleDefaultPath(path) {
  297. if (/bug-view-\d+\.html/.test(path)) {
  298. setupBugDetailPage();
  299. } else if (/resolvedbyme/.test(path)) {
  300. setupResolvedByMePage();
  301. } else if (/build-view-\d+.*\.html/.test(path)) {
  302. setupVersionBugPage()
  303. } else if (/effort-createForObject-bug-\d+.html/.test(path)) {
  304. setupBugEffortPage()
  305. } else if (/build-view/.test(path)) {
  306. setupResolvedByMeBuildPage()
  307. }
  308. setupLeftMenu()
  309. }
  310.  
  311. async function setupLeftMenu() {
  312. const element = await waitForContentInContainer('body', '#menuMainNav')
  313. const myBug = $('<li><a href="/my-work-bug.html" class="show-in-app"><i class="icon icon-bug"></i><span class="text num">我的Bug</span></a></li>');
  314. const myTask = $('<li><a href="/my-work-task.html" class="show-in-app"><i class="icon icon-list-alt"></i><span class="text num">我的任务</span></a></li>');
  315. const zenGuard = $('<li><a class="show-in-app"><i class="icon icon-magic"></i><span class="text num">禅道卫士</span></a></li>');
  316.  
  317. myBug.click(function () {
  318. window.location.href = '/my-work-bug.html';
  319. });
  320. myTask.click(function () {
  321. window.location.href = '/my-work-task.html';
  322. });
  323. zenGuard.click(function () {
  324. window.open('http://172.21.15.106:8090/')
  325. })
  326.  
  327. $('#menuMainNav .divider').before(myBug, myTask, zenGuard);
  328. }
  329.  
  330. // 设置Bug详情页功能
  331. function setupBugDetailPage() {
  332. $('.label.label-id').on('click', function () {
  333. GM_setClipboard(`🔨bug(${$(this).text().trim()}): ${$(this).next().text().trim().replace(/【.+】(【.+】)*(-)*/, '')}`);
  334. }).attr('title', '点击复制 Bug').css({ cursor: 'pointer' });
  335. enforceEffortLogging();
  336. }
  337.  
  338. // 强制填写工时
  339. function enforceEffortLogging() {
  340. $('a').has('.icon-bug-resolve, .icon-bug-assignTo').each((_, e) => {
  341. e.addEventListener('click', async function (e) {
  342. const targetEle = e.target;
  343. const { needEffort } = parseBugPage();
  344. if (needEffort) {
  345. e.stopPropagation();
  346. e.preventDefault();
  347. $('a.effort').get(0).click();
  348. }
  349. }, true);
  350. });
  351. }
  352.  
  353. // 设置 "我解决的Bug" 页面功能
  354. function setupResolvedByMePage() {
  355. $('<div class="btn btn-success">复制勾选</div>').on('click', function () {
  356. const bugs = $('tr.checked').map(function () {
  357. const tds = $(this).find("td");
  358. const id = $(tds[0]).text().trim();
  359. const raw = $(tds[4]).text().trim();
  360. let range = raw.match(/【([^【】]+?\/.+?)】/);
  361. range = !range ? '' : range[1].replace(/(\d\.?|-){3}/, ''); // 移除版本号
  362. const title = raw.slice(raw.lastIndexOf('】') + 1);
  363. return `${localStorage.getItem('zm-username')}\t\t${id} ${title}\t${range}\n`;
  364. }).get().join('');
  365. GM_setClipboard(bugs);
  366. }).insertBefore('.btn-group.dropdown');
  367. }
  368.  
  369. // 迭代版本页面中,添加一键复制已勾选BUG的按钮
  370. function addCopyBtnOnVersionBugPage() {
  371. $('<div class="btn btn-success table-actions btn-toolbar">复制勾选</div>').on('click', function () {
  372. const bugs = $('tr.checked').map( function () {
  373. const tds = $(this).find("td")
  374. const id = $(tds[0]).text().trim()
  375. const title = $(tds[1]).text().trim()
  376. const resolver = $(tds[5]).text().trim()
  377. return `${id} ${title}\t${resolver}\n`
  378. })
  379. GM_setClipboard(bugs.get().join(''))
  380. }).insertAfter('.table-actions.btn-toolbar')
  381. }
  382.  
  383. /**
  384. * 配置迭代版本BUG页面
  385. * 1. 添加一键复制已勾选BUG的按钮
  386. */
  387. function setupVersionBugPage() {
  388. addCopyBtnOnVersionBugPage()
  389. }
  390.  
  391. /**
  392. * Bug填写工时窗口默认填充1h处理BUG
  393. */
  394. function setupBugEffortPage() {
  395. // 自动填BUG工时、内容
  396. let bug_id=$("#mainContent > div > h2 > span.label.label-id")[0].innerHTML
  397. $(".form-control")[1].value = 1
  398. $(".form-control")[2].value = "处理BUG: " + bug_id
  399. }
  400.  
  401. // 根据时间范围生成字符串
  402. function timeRangeStr(start, end = Date.now()) {
  403. start = new Date(start);
  404. end = new Date(end);
  405. const msPerDay = 3.6e6 * 24;
  406. let ms = 0;
  407.  
  408. while (start.getTime() < end) {
  409. if (workdayCn.isWorkday(start)) {
  410. ms += msPerDay;
  411. }
  412. start.setDate(start.getDate() + 1);
  413. }
  414.  
  415. ms += end - start;
  416. ms = Math.max(ms, 0);
  417.  
  418. const rawh = ms / 3.6e6;
  419. const h = Math.trunc(rawh);
  420. const m = Math.trunc((rawh - h) * 60);
  421. return { str: `${h} 小时 ${m} 分钟`, h, m };
  422. }
  423.  
  424. // 解析Bug页面
  425. function parseBugPage(document = window.document) {
  426. const userName = localStorage.getItem('zm-username');
  427. const processedRe = new RegExp(`由.${userName}.(指派|解决|确认|添加)`);
  428. const effortRe = new RegExp(`由.${userName}.记录工时`);
  429. const assignRe = new RegExp(`由.${userName}.指派`);
  430. const assignedRe = new RegExp(`指派给.${userName}`);
  431. const dateRe = /(\d{4}-.+:\d{2})/;
  432.  
  433. let start, hasReactive = false, needEffort = false;
  434. const assignmens = [], reactives = [];
  435.  
  436. const current = $('#legendBasicInfo th:contains(当前指派) ~ td').text().trim();
  437. needEffort = current.includes(userName);
  438.  
  439. $(document).find('#actionbox li').each(function () {
  440. const text = $(this).text().trim();
  441. if (processedRe.test(text)) {
  442. hasReactive = true;
  443. reactives.push({ time: new Date(text.match(dateRe)[1]), action: text });
  444. }
  445. if (effortRe.test(text)) {
  446. needEffort = false;
  447. }
  448. if (/由.+创建/.test(text)) {
  449. start = new Date(text.match(dateRe)[1]);
  450. }
  451. if (assignRe.test(text)) {
  452. assignmens.push({ toMe: false, time: new Date(text.match(dateRe)[1]) });
  453. }
  454. if (assignedRe.test(text)) {
  455. assignmens.push({ toMe: true, time: new Date(text.match(dateRe)[1]) });
  456. if (assignmens.length && assignmens[0].toMe) {
  457. start = assignmens[0].time;
  458. }
  459. needEffort = current.includes(userName);
  460. }
  461. });
  462.  
  463. console.log('(zm)DEBUG: ', { start: new Date(start).toLocaleString(), reactives, assignmens, hasReactive, needEffort });
  464. return { start, reactives, assignmens, hasReactive, needEffort };
  465. }
  466.  
  467. // 获取Owner信息
  468. function getOwner(type) {
  469. const data = {
  470. "已解决": "研发、产品经理",
  471. "设计如此": "产品经理",
  472. "设计缺陷": "项目经理",
  473. "不予解决": "产品经理",
  474. "外部原因": "研发",
  475. "提交错误": "研发",
  476. "重复Bug": "研发",
  477. "无法重现": "项目经理",
  478. "下个版本解决": "产品经理",
  479. "延期处理": "产品经理"
  480. };
  481. return data[type] ? `${type}<span style="color: #8e8e8e;">(填写人:${data[type]})</span>` : type;
  482. }
  483.  
  484. // 生成处理类型选择器
  485. async function generatorResolveType() {
  486. const element = await waitForContentInContainer('body', '.modal-trigger.modal-scroll-inside .modal-dialog');
  487. const oIframe = element.querySelector('iframe');
  488. oIframe.addEventListener('load', () => {
  489. const content = oIframe.contentDocument;
  490. const body = content.querySelector('.m-bug-resolve');
  491. const oResolveType = body.querySelector('.chosen-container');
  492. oResolveType.addEventListener('click', () => {
  493. const lis = oResolveType.querySelectorAll('li');
  494. lis.forEach(node => {
  495. const text = getOwner(node.textContent.trim());
  496. node.innerHTML = text;
  497. node.title = text.replace(/<span style="color: .*;">|<\/span>/g, '');
  498. });
  499. });
  500. });
  501. }
  502.  
  503. // 等待容器内内容加载
  504. async function waitForContentInContainer(containerSelector, targetSelector, timeout = 10000) {
  505. return new Promise((resolve, reject) => {
  506. let timer;
  507. const container = document.querySelector(containerSelector);
  508.  
  509. if (!container) {
  510. return reject(new Error(`Container ${containerSelector} not found`));
  511. }
  512.  
  513. function checkElement() {
  514. const element = container.querySelector(targetSelector);
  515. if (element) {
  516. if (timer) clearTimeout(timer);
  517. observer.disconnect();
  518. resolve(element);
  519. }
  520. }
  521.  
  522. const observer = new MutationObserver(checkElement);
  523. observer.observe(container, { childList: true, subtree: true });
  524.  
  525. const iframes = container.querySelectorAll('iframe');
  526. let iframeLoadPromises = Array.from(iframes).map(iframe => new Promise(resolve => {
  527. iframe.addEventListener('load', resolve);
  528. if (iframe.contentDocument && iframe.contentDocument.readyState === 'complete') {
  529. resolve();
  530. }
  531. }));
  532.  
  533. timer = setTimeout(() => {
  534. observer.disconnect();
  535. reject(new Error(`Timeout: Element ${targetSelector} not found within ${timeout}ms`));
  536. }, timeout);
  537.  
  538. Promise.all(iframeLoadPromises).then(() => checkElement());
  539. });
  540. }
  541.  
  542. // 获取网页文档
  543. async function fetchDocument(url) {
  544. const response = await fetch(url);
  545. const arrayBuffer = await response.arrayBuffer();
  546. const decoder = new TextDecoder(document.characterSet);
  547. return new DOMParser().parseFromString(decoder.decode(arrayBuffer), 'text/html');
  548. }
  549. });
  550. })();