NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @name 印迹异常需求 V1.0 // @namespace http://tampermonkey.net/ // @version 1.0.0 // @description 查看当前页面上是否有今天到期、已过期、已提交但没写体验目标的需求 // @license MIT // @copyright 2025, littlebig (https://openuserjs.org/users/littlebig) // @author littlebig // @match https://ingee.meituan.com/* // @grant GM_setValue // @grant GM_getValue // @grant GM_openInTab // @grant unsafeWindow // ==/UserScript== // ==OpenUserJS== // @author littlebig // ==/OpenUserJS== (function() { 'use_strict'; // --- START: Configuration & Global Variables --- const SCRIPT_B_NAME = "印迹异常需求 V1.0 (v4.3.3)"; let extractedData = {}; const resultContainerId = 'batch-project-data-extractor-result'; let isFetchingGlobal = false; let refreshButton; let popupContentElement; const SCRIPT_STATE_KEY = 'meituanExtractor_v4_3_3_scriptState'; const LAST_REFRESH_TIMESTAMP_KEY = 'meituanExtractor_v4_3_3_lastRefreshTime'; const THROTTLE_DURATION_MS = 30 * 1000; const INVALID_DATE_STRING = "0000-00-00"; const DESIGN_AIM_TARGET_STATUS = "设计中"; // This constant's value is "设计中" let g_passivelyCapturedSchemes = []; const TARGET_API_GET_SCHEMES = '/api/search/get-schemes'; const trueOriginalFetch = unsafeWindow.fetch; const trueOriginalXhrOpen = unsafeWindow.XMLHttpRequest.prototype.open; const trueOriginalXhrSend = unsafeWindow.XMLHttpRequest.prototype.send; let expandDataPanelButton = null; let resultContainerElement = null; const MASK_SELECTOR = 'div.mtd-drawer-mask'; // --- END: Configuration & Global Variables --- // --- START: Consolidated Network Interception --- unsafeWindow.fetch = async function(input, init) { const url = (typeof input === 'string' || input instanceof URL) ? String(input) : (input ? input.url : ''); if (url && url.includes(TARGET_API_GET_SCHEMES)) { // console.log(`[${SCRIPT_B_NAME}] Passively Intercepting fetch for:`, url); // Kept for basic logging try { const response = await trueOriginalFetch.apply(this, arguments); const clonedResponse = response.clone(); clonedResponse.json().then(data => { if (data && data.data && Array.isArray(data.data.schemes)) { g_passivelyCapturedSchemes = data.data.schemes.map(item => ({ id: item.id, name: item.name || `[未命名-${item.id}]` })).filter(s => s.id !== undefined && s.id !== null); // console.log(`[${SCRIPT_B_NAME}] Passively captured scheme data (Fetch): ${g_passivelyCapturedSchemes.length} items.`); } else { console.warn(`[${SCRIPT_B_NAME}] Fetch ${TARGET_API_GET_SCHEMES}: response structure not as expected or no schemes found.`, data); } }).catch(e => console.error(`[${SCRIPT_B_NAME}] Error parsing JSON from fetched ${TARGET_API_GET_SCHEMES}:`, e)); return response; } catch (error) { console.error(`[${SCRIPT_B_NAME}] Fetch interception error for ${TARGET_API_GET_SCHEMES}:`, error); throw error; } } return trueOriginalFetch.apply(this, arguments); }; unsafeWindow.XMLHttpRequest.prototype.open = function(method, url) { this._tampermonkey_merged_url = url; return trueOriginalXhrOpen.apply(this, arguments); }; unsafeWindow.XMLHttpRequest.prototype.send = function() { this.addEventListener('load', function() { if (this._tampermonkey_merged_url && this._tampermonkey_merged_url.includes(TARGET_API_GET_SCHEMES)) { // console.log(`[${SCRIPT_B_NAME}] Passively Intercepting XHR for:`, this._tampermonkey_merged_url); try { const responseData = JSON.parse(this.responseText); if (responseData && responseData.data && Array.isArray(responseData.data.schemes)) { g_passivelyCapturedSchemes = responseData.data.schemes.map(item => ({ id: item.id, name: item.name || `[未命名-${item.id}]` })).filter(s => s.id !== undefined && s.id !== null); // console.log(`[${SCRIPT_B_NAME}] Passively captured scheme data (XHR): ${g_passivelyCapturedSchemes.length} items.`); } else { console.warn(`[${SCRIPT_B_NAME}] XHR ${TARGET_API_GET_SCHEMES}: response structure not as expected or no schemes found.`, responseData); } } catch (e) { console.error(`[${SCRIPT_B_NAME}] Error parsing JSON from XHR ${TARGET_API_GET_SCHEMES}:`, e); } } }); return trueOriginalXhrSend.apply(this, arguments); }; // --- END: Consolidated Network Interception --- // --- START: Helper Functions --- function showToast(message, duration = 3000) { let toast = document.createElement('div'); toast.textContent = message; Object.assign(toast.style, { position: 'fixed', bottom: '20px', left: '50%', transform: 'translateX(-50%)', backgroundColor: 'rgba(0,0,0,0.7)', color: 'white', padding: '12px 25px', borderRadius: '6px', zIndex: '10001', fontSize: '12px', textAlign: 'center' }); document.body.appendChild(toast); setTimeout(() => { if (toast.parentNode) document.body.removeChild(toast); }, duration); } function updateButtonState(isLoading, message = '') { if (refreshButton) { refreshButton.disabled = isLoading; refreshButton.textContent = isLoading ? (message || '处理中...') : '查看异常'; } } function resetScriptStateToIdle() { updateButtonState(false); isFetchingGlobal = false; // console.log(`[${SCRIPT_B_NAME}] Script state reset to idle. isFetchingGlobal:`, isFetchingGlobal); } function togglePanelVisibility(show) { if (!resultContainerElement || !expandDataPanelButton) { return; } if (show) { resultContainerElement.style.display = 'flex'; expandDataPanelButton.style.display = 'none'; } else { resultContainerElement.style.display = 'none'; expandDataPanelButton.style.display = 'flex'; } } function waitForMaskToHide(maskElement, timeoutMs) { return new Promise((resolve, reject) => { if (!maskElement || !maskElement.isConnected || window.getComputedStyle(maskElement).display === 'none') { resolve(); return; } const observer = new MutationObserver((mutationsList, obs) => { if (!maskElement.isConnected || window.getComputedStyle(maskElement).display === 'none') { obs.disconnect(); clearTimeout(timeout); resolve(); } }); const timeout = setTimeout(() => { observer.disconnect(); if (!maskElement.isConnected || window.getComputedStyle(maskElement).display === 'none') { resolve(); } else { reject(new Error('Timeout waiting for mask to hide.')); } }, timeoutMs); observer.observe(maskElement, { attributes: true, attributeFilter: ['style'] }); if (maskElement.parentNode) { observer.observe(maskElement.parentNode, { childList: true }); } }); } // --- END: Helper Functions --- // --- START: Core Logic Functions --- async function handlePopupItemClick(event) { const clickedLink = event.target.closest('.scheme-link-js'); if (!clickedLink) return; const schemeId = clickedLink.dataset.schemeId; const schemeName = clickedLink.dataset.schemeName; if (!schemeName) { showToast("无法获取项目名称以进行定位。", 3000); return; } const proceedWithNavigation = () => findAndClickMainPageRowByName(schemeName, schemeId); const drawerMask = document.querySelector(MASK_SELECTOR); if (drawerMask && window.getComputedStyle(drawerMask).display !== 'none') { console.log(`[${SCRIPT_B_NAME}] Drawer mask is visible. Attempting to click it.`); drawerMask.click(); try { await waitForMaskToHide(drawerMask, 5000); console.log(`[${SCRIPT_B_NAME}] Drawer mask successfully hidden or removed.`); proceedWithNavigation(); } catch (error) { console.warn(`[${SCRIPT_B_NAME}] Drawer mask did not hide in time or error waiting:`, error.message); } } else { proceedWithNavigation(); } } function findAndClickMainPageRowByName(schemeNameToFind, schemeIdForLog) { const tableRows = document.querySelectorAll('tr.mtd-ingee-table-row'); let foundRow = null; for (const row of tableRows) { const nameElement = row.querySelector('td:first-child div.name > span'); if (nameElement && nameElement.textContent.trim() === schemeNameToFind.trim()) { foundRow = row; break; } } if (foundRow) { foundRow.click(); } else { showToast(`无法在主页面找到项目 "${schemeNameToFind}"。请确保其在当前表格页可见。`, 4000); } } async function fetchSchemeDetails() { const schemeIds = Object.keys(extractedData); updatePanelHeaderAndContent(`<i>正在加载 ${schemeIds.length} 个项目的详细信息...</i>`, false); const today = new Date().toISOString().slice(0, 10); let detailFetchErrors = 0; for (const schemeId of schemeIds) { const apiUrl = `https://ingee.meituan.com/api/schemes/${schemeId}`; try { const response = await unsafeWindow.fetch(apiUrl); if (!response.ok) { detailFetchErrors++; if (extractedData[schemeId]) extractedData[schemeId].hasSpecialMarking = false; console.warn(`[${SCRIPT_B_NAME}] Error fetching details for ${schemeId}: ${response.status}`); continue; } const schemeDetails = await response.json(); if (schemeDetails && schemeDetails.data) { const schemeInfo = extractedData[schemeId]; if (schemeDetails.data.name && (!schemeInfo.name || schemeInfo.name.startsWith('[未命名-'))) schemeInfo.name = schemeDetails.data.name; if (!schemeInfo.name || schemeInfo.name.startsWith('[未命名-')) schemeInfo.name = `[需求名称未获取] ${schemeId}`; Object.assign(schemeInfo, { designAim: schemeDetails.data.design_aim, statusName: schemeDetails.data.status_name, endTimes: [], planEndTimes: [] }); if (Array.isArray(schemeDetails.data.segments)) { schemeDetails.data.segments.forEach(segment => { schemeInfo.endTimes.push(segment.end_time ? segment.end_time.slice(0, 10) : null); schemeInfo.planEndTimes.push(segment.plan_end_time ? segment.plan_end_time.slice(0, 10) : null); }); schemeInfo.endTimes = schemeInfo.endTimes.slice(0, 2); schemeInfo.planEndTimes = schemeInfo.planEndTimes.slice(0, 2); } let itemHasMarking = false; // MODIFIED: Check statusName against DESIGN_AIM_TARGET_STATUS for due/overdue if ((schemeInfo.statusName || "").trim() === DESIGN_AIM_TARGET_STATUS) { for (let i = 0; i < Math.min(schemeInfo.planEndTimes.length, 2) ; i++) { const planTime = schemeInfo.planEndTimes[i]; const actualTime = schemeInfo.endTimes[i] || null; if (!actualTime && planTime && planTime !== INVALID_DATE_STRING) { if (planTime === today || planTime < today) { itemHasMarking = true; break; } } } } // Condition for missing design aim (remains unchanged) if (!itemHasMarking && (['待评价', '待验收', '待追踪'].includes((schemeInfo.statusName || "").trim())) && !(schemeInfo.designAim || "").trim()) { itemHasMarking = true; } schemeInfo.hasSpecialMarking = itemHasMarking; } else { detailFetchErrors++; if (extractedData[schemeId]) extractedData[schemeId].hasSpecialMarking = false; } } catch (error) { detailFetchErrors++; if (extractedData[schemeId]) extractedData[schemeId].hasSpecialMarking = false; console.error(`[${SCRIPT_B_NAME}] Exception fetching details for ${schemeId}:`, error); } } if (detailFetchErrors > 0) showToast(`部分项目详情获取失败 (${detailFetchErrors}个)`, 4000); displayExtractedData(false); } function updatePanelHeaderAndContent(contentHtml, isInitialDisplay) { if (!resultContainerElement) return; const summaryBoxHtml = isInitialDisplay ? '' : generateSummaryBoxHtml(); resultContainerElement.innerHTML = ` <div id="popupHeader" style="position:relative; flex-shrink:0; background-color:white; z-index:1; padding:12px 12px ${isInitialDisplay ? '12px' : '0px'} 12px; border-bottom:1px solid #eee; border-radius:12px 12px 0 0;"> <span id="collapse-panel-icon" title="收起" style="position:absolute; top:12px; right:12px; cursor:pointer; font-size:24px; line-height:1; color:#777; z-index:2;">×</span> <h3 style="cursor:move; margin-top:0; margin-bottom:0px; font-size:16px; font-weight: 500; padding-bottom:5px; padding-right: 25px;">当前页面异常需求</h3> ${summaryBoxHtml} </div> <div id="popupContent" style="flex-grow:1; overflow-y:auto; padding:10px 15px 15px 15px;"> ${contentHtml} </div>`; popupContentElement = resultContainerElement.querySelector('#popupContent'); if (popupContentElement) { popupContentElement.removeEventListener('click', handlePopupItemClick); popupContentElement.addEventListener('click', handlePopupItemClick); } const dragHandle = resultContainerElement.querySelector('#popupHeader > h3'); if (dragHandle) makeDraggable(resultContainerElement, dragHandle); const collapseIcon = resultContainerElement.querySelector('#collapse-panel-icon'); if (collapseIcon) collapseIcon.addEventListener('click', () => togglePanelVisibility(false)); } function generateSummaryBoxHtml() { const today = new Date().toISOString().slice(0, 10); let countTodayExpired = 0, countOverdue = 0, countMissingDesignAim = 0; for (const schemeId in extractedData) { const schemeInfo = extractedData[schemeId]; let isToday = false, isOver = false; // MODIFIED: Check statusName against DESIGN_AIM_TARGET_STATUS for counting due/overdue if ((schemeInfo.statusName || "").trim() === DESIGN_AIM_TARGET_STATUS) { for (let i = 0; i < Math.min(schemeInfo.planEndTimes.length, 2); i++) { const planTime = schemeInfo.planEndTimes[i]; const actualTime = schemeInfo.endTimes[i] || null; if (!actualTime && planTime && planTime !== INVALID_DATE_STRING) { if (planTime === today) isToday = true; else if (planTime < today) isOver = true; } } } if(isToday) countTodayExpired++; if(isOver) countOverdue++; if ((['待评价', '待验收', '待追踪'].includes((schemeInfo.statusName || "").trim())) && !(schemeInfo.designAim || "").trim()) { countMissingDesignAim++; } } return ` <div style="margin-bottom:10px; padding:10px; border:0px solid #eee; background-color:#f9f9f9; border-radius:8px; font-size:12px; line-height:1.8;"> <span style="color:${countTodayExpired > 0 ? '#FF1F1F':'black'}; font-weight:bold;">${countTodayExpired}</span> 个需求今天到期 <br> <span style="color:${countOverdue > 0 ? '#FF1F1F':'black'}; font-weight:bold;">${countOverdue}</span> 个需求已过期 <br> <span style="color:${countMissingDesignAim > 0 ? '#FF7700':'black'}; font-weight:bold;">${countMissingDesignAim}</span> 个需求已提交但没写体验目标 </div>`; } function displayExtractedData(isInitialOrNoDataMessage = false) { const today = new Date().toISOString().slice(0, 10); let displayedItemCount = 0; let itemsDisplayHtml = ''; let contentHtmlForPanel; if (isInitialOrNoDataMessage) { if (isFetchingGlobal && (!g_passivelyCapturedSchemes || g_passivelyCapturedSchemes.length === 0)) { contentHtmlForPanel = '<i>尚未捕获到项目列表数据。<br>请确保页面表格已加载或刷新后再试。</i>'; } else { contentHtmlForPanel = '<i>脚本已激活,被动捕获数据中。<br>请点击“查看异常”按钮处理当前已捕获的数据。</i>'; } } else { for (const schemeId in extractedData) { const schemeInfo = extractedData[schemeId]; if (!schemeInfo.hasSpecialMarking) continue; displayedItemCount++; let schemeNameForDisplay = schemeInfo.name || `[需求ID: ${schemeInfo.schemeId}]`; const schemeNameForDataAttr = String(schemeNameForDisplay).replace(/'/g, "'").replace(/"/g, """); itemsDisplayHtml += `<div style="margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px dotted #eee;"> <strong class="scheme-link-js" data-scheme-id="${schemeInfo.schemeId}" data-scheme-name="${schemeNameForDataAttr}" style="color: #333; cursor:pointer;" title="定位并点击主页表格中的: ${schemeNameForDataAttr}">${schemeNameForDisplay}</strong><br>`; const planEndTimeTexts = []; for (let i = 0; i < Math.max(schemeInfo.planEndTimes.length, schemeInfo.endTimes.length, 0); i++) { if (i >= 2) break; let planTime = schemeInfo.planEndTimes[i], actualTime = schemeInfo.endTimes[i]; let timeText; if (planTime === INVALID_DATE_STRING) timeText = 'N/A (0000)'; else if (!planTime) timeText = 'N/A'; else { timeText = planTime; // MODIFIED: Check statusName against DESIGN_AIM_TARGET_STATUS for displaying due/overdue text if (!actualTime && (schemeInfo.statusName || "").trim() === DESIGN_AIM_TARGET_STATUS) { if (planTime === today) timeText += `<span style="color: #FF1F1F; font-weight: 500;"> (今天到期)</span>`; else if (planTime < today) timeText += `<span style="color: #FF1F1F; font-weight: 500;"> (已过期)</span>`; } } planEndTimeTexts.push(timeText); } while(planEndTimeTexts.length < 2) planEndTimeTexts.push('N/A'); itemsDisplayHtml += `截止日期: ${planEndTimeTexts.slice(0,2).join(', ') || 'N/A, N/A'}<br> 状态: ${schemeInfo.statusName || '-'}<br> 体验目标: ${schemeInfo.designAim || '-'}`; // Display designAim here, even if not used for due/overdue logic if ((['待评价', '待验收', '待追踪'].includes((schemeInfo.statusName || "").trim())) && !(schemeInfo.designAim || "").trim()) { itemsDisplayHtml += `<span style="color: #FF7700; font-weight: 500;"> (没写体验目标)</span>`; } itemsDisplayHtml += `</div>`; } if (Object.keys(extractedData).length > 0 && displayedItemCount === 0) { contentHtmlForPanel = '🎉 当前页面没有异常需求'; } else if (displayedItemCount > 0) { contentHtmlForPanel = itemsDisplayHtml; } else { contentHtmlForPanel = '<i>数据处理完毕,但未找到可显示的项目</i>'; } } updatePanelHeaderAndContent(contentHtmlForPanel, isInitialOrNoDataMessage && !isFetchingGlobal); } function makeDraggable(containerToMove, handleToDragBy) { let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0; if (!handleToDragBy) { console.warn(`[${SCRIPT_B_NAME}] makeDraggable: Drag handle missing.`); return; } handleToDragBy.onmousedown = function(e) { e = e || window.event; let targetElement = e.target; if (targetElement && targetElement.id === 'collapse-panel-icon') return; while (targetElement && targetElement !== this) { if (targetElement.hasAttribute('onclick') || ['A', 'BUTTON'].includes(targetElement.tagName) ) return; targetElement = targetElement.parentNode; } e.preventDefault(); pos3 = e.clientX; pos4 = e.clientY; document.onmouseup = closeDragElement; document.onmousemove = elementDrag; }; function elementDrag(e) { e = e || window.event; e.preventDefault(); pos1 = pos3 - e.clientX; pos2 = pos4 - e.clientY; pos3 = e.clientX; pos4 = e.clientY; containerToMove.style.top = (containerToMove.offsetTop - pos2) + "px"; containerToMove.style.left = (containerToMove.offsetLeft - pos1) + "px"; } function closeDragElement() { document.onmouseup = null; document.onmousemove = null; } } async function processCapturedSchemesAndFetchDetails(schemesList) { extractedData = {}; console.log(`[${SCRIPT_B_NAME}] Processing ${schemesList.length} captured schemes for details.`); schemesList.forEach(scheme => { if (scheme.id) extractedData[scheme.id] = { schemeId: scheme.id, name: scheme.name, planEndTimes: [], designAim: null, statusName: null, endTimes: [], hasSpecialMarking: false }; }); // console.log(`[${SCRIPT_B_NAME}] Prepared extractedData: ${Object.keys(extractedData).length} items`); await fetchSchemeDetails(); } async function handleRefreshButtonClick() { const now = Date.now(); if (isFetchingGlobal) { showToast('数据仍在加载中...', 2000); return; } const lastRefresh = GM_getValue(LAST_REFRESH_TIMESTAMP_KEY, 0); if (now - lastRefresh < THROTTLE_DURATION_MS) { showToast(`🤔 操作过于频繁,请 ${Math.ceil((THROTTLE_DURATION_MS - (now - lastRefresh)) / 1000)} 秒后再试`, 3000); return; } togglePanelVisibility(true); updateButtonState(true, '处理中...'); GM_setValue(LAST_REFRESH_TIMESTAMP_KEY, now); isFetchingGlobal = true; try { if (g_passivelyCapturedSchemes && g_passivelyCapturedSchemes.length > 0) { // console.log(`[${SCRIPT_B_NAME}] Using ${g_passivelyCapturedSchemes.length} passively captured schemes for processing.`); await processCapturedSchemesAndFetchDetails(g_passivelyCapturedSchemes); } else { console.log(`[${SCRIPT_B_NAME}] No schemes captured to process.`); showToast('尚未捕获到项目列表数据。请确保页面表格已加载或刷新后再试。', 4000); displayExtractedData(true); } } catch (error) { console.error(`[${SCRIPT_B_NAME}] Error during handleRefreshButtonClick:`, error); showToast("处理时发生错误", 3000); displayExtractedData(true); } finally { resetScriptStateToIdle(); } } // --- END: Core Logic Functions --- // --- START: Initialization --- function initializeScript() { console.log(`[${SCRIPT_B_NAME}] Initializing...`); refreshButton = document.createElement('button'); Object.assign(refreshButton.style, { position: 'fixed', bottom: '0px', left: '0px', zIndex: '10001', padding: '8px 18px', backgroundColor: '#FFFFFF', color: '#111925', border: '1px solid #eee', borderRadius: '0 12px 0 0', cursor: 'pointer', fontSize: '14px', boxShadow: '0 2px 5px rgba(0,0,0,0.2)' }); document.body.appendChild(refreshButton); refreshButton.addEventListener('click', handleRefreshButtonClick); resultContainerElement = document.getElementById(resultContainerId); if (!resultContainerElement) { resultContainerElement = document.createElement('div'); resultContainerElement.id = resultContainerId; Object.assign(resultContainerElement.style, { position: 'fixed', top: '70px', left: '20px', backgroundColor: 'white', color: 'black', border: '1px solid #eee', borderRadius: '12px', width: '320px', boxShadow: '0 4px 8px rgba(0,0,0,0.1)', fontSize: '12px', lineHeight: '1.8', display: 'flex', flexDirection: 'column', maxHeight: 'calc(100vh - 110px)', zIndex: '10000' }); document.body.appendChild(resultContainerElement); } expandDataPanelButton = document.createElement('button'); expandDataPanelButton.id = 'tampermonkey-expand-panel-btn'; expandDataPanelButton.innerHTML = '▴'; expandDataPanelButton.title = '展开面板'; Object.assign(expandDataPanelButton.style, { display: 'none', position: 'fixed', bottom: '5px', left: (refreshButton.offsetLeft + refreshButton.offsetWidth + 62) + 'px', zIndex: '10001', width: '30px', height: '30px', backgroundColor: 'white', border: '0px solid #ccc', borderRadius: '50%', cursor: 'pointer', fontSize: '24px', lineHeight: '28px', textAlign: 'top', padding: '0 0 6px 0', boxShadow: '0 1px 3px rgba(0,0,0,0.2)', alignItems: 'center', justifyContent: 'center' }); expandDataPanelButton.addEventListener('click', () => togglePanelVisibility(true)); document.body.appendChild(expandDataPanelButton); displayExtractedData(true); if (resultContainerElement) resultContainerElement.style.display = 'none'; if (expandDataPanelButton) expandDataPanelButton.style.display = 'none'; isFetchingGlobal = false; updateButtonState(false); console.log(`[${SCRIPT_B_NAME}] Initialization complete. Panel initially hidden. Passively listening.`); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initializeScript); else initializeScript(); // --- END: Initialization --- })();