// ==UserScript== // @name 绿证台账数据合并导出(4合1)- v3.15 20260617更新 // @namespace http://tampermonkey.net/ // @version 3.15 // @description 一键导出绿证台账总览/核发明细/核销明细/建档立卡明细到单一xlsx。v3.15: 表单5简化为纯API拉取,去掉详情列和点击逻辑;悬浮按钮自动对齐。 // @match https://gec.renewable.org.cn/* // @match https://gec.nea.gov.cn/* // @require https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js // @require https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js // @require https://unpkg.com/xlsx@0.18.5/dist/xlsx.full.min.js // @require https://cdn.bootcdn.net/ajax/libs/xlsx/0.18.5/xlsx.full.min.js // @require https://cdn.staticfile.org/xlsx/0.18.5/xlsx.full.min.js // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @grant GM_xmlhttpRequest // @connect cdn.sheetjs.com // @connect cdn.jsdelivr.net // @connect unpkg.com // @connect cdn.bootcdn.net // @connect cdn.staticfile.org // @run-at document-start // ==/UserScript== //类别 位置 改动 //限频 新增 globalApiQueue / acquireApiSlot() 所有 apiFetchJSON 调用先排队获取"令牌",保证 ≤2 次/秒 + 50–200ms 随机抖动 //缓存 新增 DETAIL_RESPONSE_CACHE 只对幂等的 overDue/detail、gec/detail 做结果缓存(10 分钟 TTL),拦截到的详情响应也会自动回灌 //并发 DETAIL_CONCURRENCY 5 → 2 //total 校验 collectAllPagesViaApiF2 末尾 抓取行数 < total 则抛错,触发上层重试 //total 校验 fetchListAllByGecSource 末尾 同上,按 gecSource 分别校验 //分页节奏 clickNextAndWaitF2 加入 100–300ms 随机抖动 //拖动 addGroupDrag 替换 addDrag 整组快照+整组平移+整组边界约束+整组保存位置 //视口 clampAllToViewport 窗口缩放时整组平移回视口内,不破坏相对位置 (function () { 'use strict'; // ===================================================================== // 全局配置 // ===================================================================== const SCRIPT_VERSION = '3.15'; const OVERVIEW_HASH = '#/ledgerManage/ledgerOverview'; const OVERDUE_HASH = '#/ledgerManage/overdueGecInfo'; const FILECARD_HASH = '#/ledgerManage/fileCardManage'; // API 路径 const API_PATH_ACCOUNT = '/api/ordinary/standing/account'; const API_PATH_CALENDAR = '/api/ordinary/standing/greenCalendar'; const API_PATH_PERSONNEL = '/api/ordinary/personnel/list'; const API_PATH_OVERDUE_LIST = '/api/ordinary/cancel/cancelled-expired-green-certs'; const API_PATH_OVERDUE_DETAIL = '/api/admin/overDue/detail'; const API_PATH_GEC_LIST = '/api/ordinary/gec/list'; const API_PATH_GEC_DETAIL = '/api/ordinary/gec/detail'; const API_PATH_IMMINENT_WARNING = '/api/ordinary/standing/imminentWarning'; const API_PATH_COMING_OF_AGE = '/api/ordinary/standing/comingOfAge'; // 通用参数 const TARGET_PAGE_SIZE = 40; const POLL_INTERVAL_MS = 300; const MAX_WAIT_MS = 60000; const MIN_WAIT_AFTER_CLICK_MS = 400; const MAX_PAGES = 500; const DETAIL_CONCURRENCY = 2; // ← 由 5 降为 2,避免并发过高 const FETCH_TIMEOUT_MS = 30000; const MAX_RETRY_COUNT = 3; const RETRY_WAIT_MS = 3000; // ★ 限频参数:≤2 次/秒 + 随机抖动 const RATE_LIMIT_MIN_INTERVAL_MS = 500; // 1000/2 = 500ms const RATE_LIMIT_JITTER_MIN_MS = 50; const RATE_LIMIT_JITTER_MAX_MS = 200; // ★ 详情缓存 TTL(会话级足矣) const DETAIL_CACHE_TTL_MS = 10 * 60 * 1000; // 表单名称 const SHEET1_NAME = '表单1-绿证台账总览'; const SHEET2_NAME = '表单2-绿证核发明细'; const SHEET3_NAME = '表单3-超期核销明细'; const SHEET4_NAME = '表单4-建档立卡项目管理'; const SHEET5_NAME = '表单5-临期3月预警'; // function2 模块定位 const MODULE2_TITLE = '绿证核发上架统计'; const MODULE5_TITLE = '临期绿证预警'; const sleep = (ms) => new Promise(r => setTimeout(r, ms)); // ===================================================================== // 表头与字段映射(func1) // ===================================================================== const SHEET1_HEADERS_OUT = [ '项目业主', '企业代码', '所在地区', '建档立卡项目数(个)', '绿证核发数量(个)', '已售出绿证总量(个)', '现持有绿证总量(个)', '未售出绿证数量(个)', '已购买绿证数量(个)', '超期核销绿证量(个)', '联系人', '联系方式', '工作负责人', '工作负责人联系方式', ]; const SHEET1_OVERVIEW_FIELD_MAP = { '项目业主': 'ownerName', '企业代码': 'ownerCode', '所在地区': 'address', '建档立卡项目数(个)': 'projectNum', '绿证核发数量(个)': 'issueTotalNum', '已售出绿证总量(个)': 'soldTotalNum', '现持有绿证总量(个)': 'holdingTotalNum', '未售出绿证数量(个)': 'unsoldTotalNum', '已购买绿证数量(个)': 'buyTotalNum', '超期核销绿证量(个)': 'expiredTotalNum', '联系人': 'userName', '联系方式': 'userTel', }; const SHEET1_NUMERIC_FIELDS = new Set([ 'projectNum', 'issueTotalNum', 'soldTotalNum', 'holdingTotalNum', 'unsoldTotalNum', 'buyTotalNum', 'expiredTotalNum', ]); const LEADER_RESP = '工作负责人'; const PERSONNEL_FETCH_TIMEOUT_MS = 10000; // ===================================================================== // 表头与字段映射(func2) // ===================================================================== const SHEET2_HEADERS = [ '项目业主', '项目名称', '建档立卡编码', '项目所在省', '项目所在省市', '项目所在省县', '电量生产年月', '核发量(个)', '可交易绿证量(个)', '普通绿证量(个)', '绿电绿证量(个)', '不可交易绿证量(个)', '机制电量对应绿证量(个)', '已上架量(个)', '已售出量(个)', '未售出量(个)', '未上架量(个)', '电量生产年', '电量生产月', '绿证核发批次' ]; const SHEET2_FIELD_MAP = { '项目业主': null, '项目名称': 'projectName', '建档立卡编码': 'projectCode', '项目所在省': 'province', '项目所在省市': 'city', '项目所在省县': 'county', '电量生产年月': 'productionYearMonth', '核发量(个)': 'releaseQuantity', '可交易绿证量(个)': 'traQuantity', '普通绿证量(个)': 'ordinaryQuantity', '绿电绿证量(个)': 'greenQuantity', '不可交易绿证量(个)': 'unTraQuantity', '机制电量对应绿证量(个)': 'mechanismNum', '已上架量(个)': 'shelfLoad', '已售出量(个)': 'soldQuantity', '未售出量(个)': 'unsoldQuantity', '未上架量(个)': 'unshelfLoad', '电量生产年': 'year', '电量生产月': 'month', '绿证核发批次': 'batchInfo', }; const SHEET2_TEXT_COLS = [ '项目业主', '项目名称', '建档立卡编码', '项目所在省', '项目所在省市', '项目所在省县', '电量生产年月', '绿证核发批次' ]; // ★ v3.13: 网站导出文件列 → 内部字段键映射 const SHEET2_EXPORT_FIELD_MAP = { '项目名称': 'projectName', '电量生产年月': 'productionYearMonth', '省份': 'province', '市': 'city', '县(区)': 'county', '核发量(个)': 'releaseQuantity', '普通绿证量(个)': 'ordinaryQuantity', '绿电绿证量(个)': 'greenQuantity', '可交易绿证量(个)': 'traQuantity', '不可交易绿证量(个)': 'unTraQuantity', '机制电量对应绿证(个)': 'mechanismNum', '已上架量(个)': 'shelfLoad', '已售出量(个)': 'soldQuantity', '未售出量(个)': 'unsoldQuantity', '未上架量(个)': 'unshelfLoad', }; // ===================================================================== // 表头与字段映射(func3) // ===================================================================== const SHEET3_HEADERS = [ '项目业主', '项目名称', '建档立卡编码', '电量生产年', '电量生产月', '绿证类型', '核销绿证数量', '核销时间', '核销原因', '绿证来源', '发电类型', '技术类型', '环境权益归属地' ]; const SHEET3_TEXT_COLS = new Set([ '项目业主', '项目名称', '建档立卡编码', '绿证类型', '核销时间', '核销原因', '绿证来源', '发电类型', '技术类型', '环境权益归属地' ]); // ===================================================================== // 表头与字段映射(func4) // ===================================================================== const SHEET4_HEADERS = [ '项目业主', '信用代码/身份证号', '绿证账户编码', '项目名称', '建档立卡编码', '项目接网类型', '项目并网容量', '项目并网时间', '项目状态', '是否补贴', '发电户号', '项目联系人', '联系方式' ]; const SHEET4_TEXT_HEADERS = new Set([ '项目业主', '信用代码/身份证号', '绿证账户编码', '项目名称', '建档立卡编码', '项目接网类型', '项目并网时间', '项目状态', '是否补贴', '发电户号', '项目联系人', '联系方式' ]); // ===================================================================== // 表头与字段映射(func5) // ===================================================================== const SHEET5_HEADERS = [ '项目业主', '项目名称', '建档立卡编码', '电量生产年月', '电量生产年', '电量生产月', '临期绿证数量', '剩余时间' ]; const SHEET5_FIELD_MAP = { '项目业主': { source: 'account', field: 'ownerName' }, '项目名称': { source: 'warning', field: 'projectName' }, '建档立卡编码': { source: 'warning', field: 'projectCode' }, '电量生产年月': { source: 'warning', field: 'productionYearMonth' }, '电量生产年': { source: 'warning', field: 'year' }, '电量生产月': { source: 'warning', field: 'month' }, '临期绿证数量': { source: 'warning', field: 'imminentWarningNum' }, '剩余时间': { source: 'warning', field: 'remainingTime' }, }; const SHEET5_NUMERIC_FIELDS = new Set([ 'year', 'month', 'imminentWarningNum' ]); const SHEET5_TEXT_COLS = [ '项目业主', '项目名称', '建档立卡编码', '电量生产年月', '剩余时间' ]; // ===================================================================== // 共享缓存与拦截 // ===================================================================== // ★ v3.13: 表单2 网站导出辅助函数 ★ const SHEET2_EXPORT_THRESHOLD = 10000; // 超过此行数使用网站导出功能 /** 快速查询 greenCalendar 接口的 total(仅请求 1 行) */ async function getCalendarTotal() { const accountCode = getAccountCodeForGec(); if (!accountCode) { // 尝试从缓存中获取 for (const [, entry] of SHARED_CACHE.calendarPages) { if (entry && typeof entry.total === 'number') return entry.total; } return -1; // 未知 } try { await acquireApiSlot(); const url = `${location.origin}${API_PATH_CALENDAR}?accountCode=${encodeURIComponent(accountCode)}&pageNum=1&pageSize=1`; const json = await apiFetchJSONInternal(url); if (json && typeof json.total === 'number') { console.log(`[表单2] getCalendarTotal: ${json.total}`); return json.total; } } catch (e) { console.warn('[表单2] getCalendarTotal 失败:', e?.message || e); } // 兜底:使用已有缓存 for (const [, entry] of SHARED_CACHE.calendarPages) { if (entry && typeof entry.total === 'number') return entry.total; } return -1; } /** * 挂载临时 Blob 拦截器(createObjectURL / fetch / XHR), * 用于捕获网站导出按钮触发的文件下载数据。 * 返回 { promise: Promise, cleanup: Function }。 * 注意:必须在点击导出按钮之前调用,否则可能错过下载。 */ function setupTemporaryBlobCapture() { let resolve, reject; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); let captured = false; function capture(buffer, source) { if (captured) return; captured = true; console.log(`[表单2-Blob] 捕获成功 via ${source}: ${buffer.byteLength} bytes`); resolve(buffer); } // ---- 1. Hook URL.createObjectURL ---- const origCreateObjectURL = URL.createObjectURL; URL.createObjectURL = function (blob) { try { if (!captured && blob instanceof Blob && blob.size > 1000) { const t = blob.type || ''; if (t.includes('spreadsheet') || t.includes('excel') || t.includes('octet-stream') || t === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' || t === '' ) { console.log(`[表单2-Blob] createObjectURL: type=${t}, size=${blob.size}`); blob.arrayBuffer().then(buf => capture(buf, 'createObjectURL')).catch(() => {}); } } } catch (_) {} return origCreateObjectURL.apply(this, arguments); }; // ---- 2. Hook fetch(仅二进制响应) ---- const origFetch = window.fetch ? window.fetch.bind(window) : null; if (origFetch) { window.fetch = function (input, init) { const p = origFetch(input, init); p.then(res => { if (!captured && res.ok) { const ct = res.headers.get('content-type') || ''; if (ct.includes('spreadsheet') || ct.includes('excel') || ct.includes('octet-stream')) { res.clone().arrayBuffer().then(buf => { if (buf.byteLength > 1000) capture(buf, 'fetch'); }).catch(() => {}); } } }).catch(() => {}); return p; }; } // ---- 3. Hook XHR(仅 blob/arraybuffer 响应) ---- const origXhrSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.send = function (...args) { this.addEventListener('load', () => { try { if (!captured && (this.responseType === 'blob' || this.responseType === 'arraybuffer') && this.status >= 200 && this.status < 300) { const resp = this.response; if (resp instanceof Blob) { resp.arrayBuffer().then(buf => { if (buf.byteLength > 1000) capture(buf, 'XHR-blob'); }).catch(() => {}); } else if (resp instanceof ArrayBuffer && resp.byteLength > 1000) { capture(resp, 'XHR-arraybuffer'); } } } catch (_) {} }); return origXhrSend.apply(this, args); }; // ---- 超时 & 清理 ---- const timer = setTimeout(() => { if (!captured) reject(new Error('等待导出文件下载超时(180秒)')); }, 180000); function cleanup() { URL.createObjectURL = origCreateObjectURL; if (origFetch) window.fetch = origFetch; XMLHttpRequest.prototype.send = origXhrSend; clearTimeout(timer); } promise.then(cleanup, cleanup); return { promise, cleanup }; } /** 弹出文件选择框(兜底方案),让用户选择刚下载的 xlsx 文件 */ function pickDownloadedXlsxFile() { return new Promise((resolve, reject) => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.xlsx,.xls'; input.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0.01;z-index:99999999;'; document.body.appendChild(input); let settled = false; input.addEventListener('change', () => { if (settled) return; settled = true; const file = input.files && input.files[0]; input.remove(); if (file) resolve(file); else reject(new Error('未选择文件')); }); // 监听取消(focus 回到页面且无文件选择时) window.addEventListener('focus', function onFocus() { setTimeout(() => { if (!settled && (!input.files || input.files.length === 0)) { settled = true; window.removeEventListener('focus', onFocus); input.remove(); reject(new Error('文件选择已取消')); } }, 1000); }, { once: true }); // 确保元素可见后再触发点击 requestAnimationFrame(() => { input.click(); }); }); } /** 读取 File 对象为 ArrayBuffer */ function readFileAsArrayBuffer(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = () => reject(reader.error); reader.readAsArrayBuffer(file); }); } /** 在「绿证核发上架统计」模块内寻找导出按钮 */ function findExportButtonInModule() { // 策略1:通过 .early_title 找到标题,向上遍历祖先找到包含 .export_btn 的容器 const titles = [...document.querySelectorAll('.early_title')]; const targetTitle = titles.find(t => (t.innerText || '').includes(MODULE2_TITLE)); if (targetTitle) { let node = targetTitle; for (let i = 0; i < 8 && node; i++) { node = node.parentElement; if (!node) break; const exportBtn = node.querySelector('.export_btn'); if (exportBtn) return exportBtn; // 也检查文本匹配 const btns = node.querySelectorAll('button, a.ant-btn, [role="button"]'); for (const btn of btns) { const txt = (btn.innerText || btn.textContent || '').trim(); if (/导\s*出|下载|export/i.test(txt)) return btn; } } } // 策略2:直接全局搜索 .export_btn(兜底) const allExportBtns = document.querySelectorAll('.export_btn'); if (allExportBtns.length > 0) { // 如果有多个 export_btn,选择离"绿证核发上架统计"标题最近的那个 if (allExportBtns.length === 1) return allExportBtns[0]; if (targetTitle) { const titleRect = targetTitle.getBoundingClientRect(); let closest = null, minDist = Infinity; allExportBtns.forEach(btn => { const dist = Math.abs(btn.getBoundingClientRect().top - titleRect.top); if (dist < minDist) { minDist = dist; closest = btn; } }); return closest; } return allExportBtns[0]; } // 策略3:在 .early_warning 内部搜索(原始逻辑兜底) const ews = [...document.querySelectorAll('.early_warning')]; const ew = ews.find(b => { const t = b.querySelector('.early_title'); return t && t.innerText && t.innerText.includes(MODULE2_TITLE); }); if (ew) { const candidates = [...ew.querySelectorAll('button'), ...ew.querySelectorAll('.ant-btn')]; for (const btn of candidates) { const txt = (btn.innerText || btn.textContent || '').trim(); if (/导\s*出|下载|export/i.test(txt)) return btn; } } return null; } /** 等待网站确认弹窗出现并点击"确定" */ function waitForConfirmDialogAndConfirm(timeoutMs = 8000) { return new Promise((resolve) => { const start = Date.now(); function check() { const modals = document.querySelectorAll('.ant-modal, .ant-modal-confirm, .ant-modal-wrap'); for (const modal of modals) { const style = getComputedStyle(modal); if (style.display === 'none' || style.visibility === 'hidden') continue; const text = modal.innerText || ''; if (text.includes('导出') || text.includes('临期') || text.includes('预警')) { const confirmBtn = modal.querySelector('.ant-modal-confirm-btns .ant-btn-primary') || modal.querySelector('.ant-btn-primary') || [...modal.querySelectorAll('button')].find(b => { const t = (b.innerText || '').trim(); return t === '确定' || t === '确 定'; }); if (confirmBtn) { resolve(confirmBtn); return; } } } if (Date.now() - start < timeoutMs) { requestAnimationFrame(check); } else { resolve(null); } } check(); }); } const SHARED_CACHE = { account: null, accountByCode: new Map(), personnelByAccountCode: new Map(), latestPersonnel: null, capturedHeaders: null, calendarPages: new Map(), gecListResponse: null, gecListUrl: null, gecListVersion: 0, imminentWarningPages: new Map(), comingOfAgeData: null, }; // ★ 详情响应缓存(只缓存幂等的详情接口,按完整 URL 作 key) const DETAIL_RESPONSE_CACHE = new Map(); // url -> { data, ts } function isDetailApi(url) { return url.indexOf(API_PATH_OVERDUE_DETAIL) !== -1 || url.indexOf(API_PATH_GEC_DETAIL) !== -1; } function detailCacheGet(url) { const e = DETAIL_RESPONSE_CACHE.get(url); if (!e) return null; if (Date.now() - e.ts > DETAIL_CACHE_TTL_MS) { DETAIL_RESPONSE_CACHE.delete(url); return null; } return e.data; } function detailCacheSet(url, data) { DETAIL_RESPONSE_CACHE.set(url, { data, ts: Date.now() }); } // ===================================================================== // ★ 账号切换自动清缓存(防止跨账号数据污染)★ // 触发方式: // 1. 拦截 account 接口响应时,与上次 accountCode / ownerCode / ownerName // 作 fingerprint 比对,发现不一致即清空所有跟账号挂钩的缓存。 // 2. 拦截到 logout / sign-out 等退出登录类 URL 时主动清空。 // 3. 导出按钮入口处,比对 DOM 上显示的项目业主与缓存业主,不一致清空。 // 清缓存后,extractSheetX 走到等待逻辑会自动重新等接口 / 触发刷新。 // ===================================================================== const ACCOUNT_STATE = { currentAccountCode: null, // 当前已识别账号的 accountCode currentOwnerCode: null, // 当前业主代码(兜底 fingerprint) currentOwnerName: null, // 当前业主名(DOM 比对用) lastSwitchReason: null, switchCount: 0, }; function clearAccountSensitiveCache(reason) { console.warn(`[账号切换] 清空缓存,原因: ${reason}`); SHARED_CACHE.account = null; SHARED_CACHE.accountByCode.clear(); SHARED_CACHE.personnelByAccountCode.clear(); SHARED_CACHE.latestPersonnel = null; SHARED_CACHE.calendarPages.clear(); SHARED_CACHE.gecListResponse = null; SHARED_CACHE.gecListUrl = null; SHARED_CACHE.gecListVersion = 0; SHARED_CACHE.capturedHeaders = null; DETAIL_RESPONSE_CACHE.clear(); ACCOUNT_STATE.lastSwitchReason = reason; ACCOUNT_STATE.switchCount++; } // 退出登录 URL 的宽松匹配(不同后端命名差异较大) const LOGOUT_PATH_PATTERNS = [ /\/logout(\b|\/|\?|$)/i, /\/sign[-_]?out(\b|\/|\?|$)/i, /\/auth\/logout/i, /\/sso\/logout/i, /\/user\/logout/i, ]; function isLogoutUrl(url) { if (typeof url !== 'string') return false; return LOGOUT_PATH_PATTERNS.some((re) => re.test(url)); } // 给 account 响应做 fingerprint 比对,若发生切换则清缓存 function detectAndHandleAccountSwitch({ accountCode, ownerCode, ownerName }) { const prevAcc = ACCOUNT_STATE.currentAccountCode; const prevOwnerCode = ACCOUNT_STATE.currentOwnerCode; const prevOwnerName = ACCOUNT_STATE.currentOwnerName; // 只有"上次有值"且"本次有值"且"两者不同"才算切换;首次捕获不算 const accChanged = !!(prevAcc && accountCode && prevAcc !== accountCode); const ownerCodeChanged = !!(prevOwnerCode && ownerCode && prevOwnerCode !== ownerCode); const ownerNameChanged = !!(prevOwnerName && ownerName && prevOwnerName !== ownerName); if (accChanged || ownerCodeChanged || ownerNameChanged) { const from = prevAcc || prevOwnerCode || prevOwnerName || '?'; const to = accountCode || ownerCode || ownerName || '?'; clearAccountSensitiveCache(`account fingerprint 变化 (${from} → ${to})`); } if (accountCode) ACCOUNT_STATE.currentAccountCode = accountCode; if (ownerCode) ACCOUNT_STATE.currentOwnerCode = ownerCode; if (ownerName) ACCOUNT_STATE.currentOwnerName = ownerName; } // 导出入口处做一次 DOM 比对(兜底检测) function checkDomOwnerMismatch() { try { const cacheOwner = SHARED_CACHE.account?.data?.ownerName || ACCOUNT_STATE.currentOwnerName || ''; if (!cacheOwner) return false; const domOwnerEl = document.querySelector('.user_avatar .companyTitle, .companyTitle'); const domOwner = domOwnerEl ? (domOwnerEl.textContent || '').replace(/\s+/g, ' ').trim() : ''; if (!domOwner) return false; const cacheNorm = String(cacheOwner).replace(/\s+/g, ' ').trim(); if (domOwner !== cacheNorm) { clearAccountSensitiveCache(`DOM 业主与缓存不一致 ("${cacheNorm}" → "${domOwner}")`); return true; } } catch (_) {} return false; } // ===================================================================== // ★ 全局 API 请求限流 / 节流队列 ★ // - 严格串行化获取"发射令牌":保证触发速率 ≤ 1000 / RATE_LIMIT_MIN_INTERVAL_MS // - 每次发射前附加 50~200ms 随机抖动,降低对后端的爆发压力 // - 发射令牌后允许真实请求并发返回(不阻塞其它请求的发射) // ===================================================================== const globalApiQueue = { chain: Promise.resolve(), lastFireTime: 0, }; async function acquireApiSlot() { // 串行化"获取令牌" const prev = globalApiQueue.chain; let releaseMine; const mine = new Promise(r => { releaseMine = r; }); globalApiQueue.chain = mine; try { await prev; const now = Date.now(); const elapsed = now - globalApiQueue.lastFireTime; const jitter = RATE_LIMIT_JITTER_MIN_MS + Math.random() * (RATE_LIMIT_JITTER_MAX_MS - RATE_LIMIT_JITTER_MIN_MS); const wait = Math.max(0, RATE_LIMIT_MIN_INTERVAL_MS - elapsed) + jitter; if (wait > 0) await sleep(wait); globalApiQueue.lastFireTime = Date.now(); } finally { releaseMine(); } } function parseQueryParams(url) { const out = {}; try { const q = (url.split('?')[1] || '').split('#')[0]; q.split('&').filter(Boolean).forEach(kv => { const [k, v = ''] = kv.split('='); out[decodeURIComponent(k)] = decodeURIComponent(v); }); } catch (_) {} return out; } function isApiUrl(url) { return typeof url === 'string' && url.indexOf('/api/') !== -1; } function mergeHeaders(target, src) { if (!src) return target; const out = target ? { ...target } : {}; if (src instanceof Headers) { src.forEach((v, k) => { out[k] = v; }); } else if (Array.isArray(src)) { src.forEach(([k, v]) => { out[k] = v; }); } else if (typeof src === 'object') { Object.keys(src).forEach((k) => { out[k] = src[k]; }); } return out; } function recordCapturedHeaders(headersObj) { if (!headersObj) return; const FORBIDDEN = new Set([ 'host','connection','content-length','origin','referer', 'user-agent','cookie','accept-encoding' ]); const filtered = {}; Object.keys(headersObj).forEach((k) => { const lk = k.toLowerCase(); if (FORBIDDEN.has(lk)) return; filtered[k] = headersObj[k]; }); if (Object.keys(filtered).length) { SHARED_CACHE.capturedHeaders = filtered; } } function calKey(pageNum, pageSize) { return `${pageNum}_${pageSize}`; } function handleInterceptedResponse(url, respText) { try { if (!url || !respText) return; if (url.indexOf(API_PATH_ACCOUNT) !== -1) { let json; try { json = JSON.parse(respText); } catch (_) { return; } if (!json || !json.data) return; const params = parseQueryParams(url); const accountCode = params.accountCode || json.data.ownerCode || ''; const userCode = params.userCode || ''; const entry = { data: json.data, url, timestamp: Date.now(), accountCode, userCode }; if (accountCode) SHARED_CACHE.accountByCode.set(accountCode, entry); SHARED_CACHE.account = entry; console.log(`[合并脚本] 已捕获 account: accountCode=${accountCode}`); return; } if (url.indexOf(API_PATH_PERSONNEL) !== -1) { let json; try { json = JSON.parse(respText); } catch (_) { return; } if (!json || !Array.isArray(json.data)) return; const params = parseQueryParams(url); const accountCode = params.accountCode || ''; const entry = { data: json.data, url, timestamp: Date.now(), accountCode }; if (accountCode) SHARED_CACHE.personnelByAccountCode.set(accountCode, entry); SHARED_CACHE.latestPersonnel = entry; console.log(`[合并脚本] 已捕获 personnel: accountCode=${accountCode}, count=${json.data.length}`); return; } if (url.indexOf(API_PATH_CALENDAR) !== -1) { try { const json = JSON.parse(respText); if (!json || !Array.isArray(json.rows)) return; const params = parseQueryParams(url); const pageNum = Number(params.pageNum) || 1; const pageSize = Number(params.pageSize) || 10; SHARED_CACHE.calendarPages.set(calKey(pageNum, pageSize), { rows: json.rows, total: Number(json.total) || json.rows.length, pageNum, pageSize, ts: Date.now(), }); console.log(`[合并脚本] 已捕获 greenCalendar pageNum=${pageNum} rows=${json.rows.length}`); } catch (_) {} return; } // ----- gec/list (func4) ----- if (url.indexOf(API_PATH_GEC_LIST) !== -1) { let json; try { json = JSON.parse(respText); } catch (_) { return; } if (!json) return; if (json.code === 200) { SHARED_CACHE.gecListResponse = json; SHARED_CACHE.gecListUrl = url; // ★ 新增:把网站请求的完整 URL 存下来 SHARED_CACHE.gecListVersion++; console.log(`[合并脚本] 捕获 gec/list code=200 v${SHARED_CACHE.gecListVersion}, url=${url}`); } else { console.warn(`[合并脚本] 忽略 gec/list 非200响应: code=${json.code}`); } return; } // ----- imminentWarning (func5) ----- if (url.indexOf(API_PATH_IMMINENT_WARNING) !== -1) { try { const json = JSON.parse(respText); if (json && Array.isArray(json.rows)) { const params = parseQueryParams(url); const pageNum = Number(params.pageNum) || 1; const pageSize = Number(params.pageSize) || 10; SHARED_CACHE.imminentWarningPages.set(calKey(pageNum, pageSize), { rows: json.rows, total: Number(json.total) || json.rows.length, pageNum, pageSize, ts: Date.now(), }); console.log(`[合并脚本] 已捕获 imminentWarning pageNum=${pageNum} rows=${json.rows.length}`); } } catch (_) {} return; } // ----- comingOfAge (func5 详情) ----- if (url.indexOf(API_PATH_COMING_OF_AGE) !== -1) { try { const json = JSON.parse(respText); if (json && json.code === 200 && Array.isArray(json.data)) { SHARED_CACHE.comingOfAgeData = json.data; console.log(`[合并脚本] 已捕获 comingOfAge: ${json.data.length} 项`); } } catch (_) {} return; } // 顺带把详情响应也回灌进缓存(DOM 驱动产生的详情请求也会被复用) if (isDetailApi(url)) { try { const json = JSON.parse(respText); if (json && json.code === 200) { detailCacheSet(url, json); } } catch (_) {} } } catch (e) { console.warn('[合并脚本] handleInterceptedResponse 异常:', e); } } // ---- Hook Fetch ---- (function hookFetch() { if (!window.fetch) return; if (window.__gec_merged_fetch_hooked__) return; window.__gec_merged_fetch_hooked__ = true; const origFetch = window.fetch.bind(window); window.fetch = function (input, init) { const reqUrl = (typeof input === 'string') ? input : (input && input.url) || ''; if (isApiUrl(reqUrl)) { let h = {}; if (typeof input !== 'string' && input?.headers) h = mergeHeaders(h, input.headers); if (init?.headers) h = mergeHeaders(h, init.headers); recordCapturedHeaders(h); } const p = origFetch(input, init); if (isApiUrl(reqUrl)) { p.then((res) => { try { const cloned = res.clone(); cloned.text().then((txt) => handleInterceptedResponse(reqUrl, txt)).catch(() => {}); } catch (_) {} }).catch(() => {}); } return p; }; })(); // ---- Hook XHR ---- (function hookXHR() { if (window.__gec_merged_xhr_hooked__) return; window.__gec_merged_xhr_hooked__ = true; const OrigOpen = XMLHttpRequest.prototype.open; const OrigSend = XMLHttpRequest.prototype.send; const OrigSetHeader = XMLHttpRequest.prototype.setRequestHeader; XMLHttpRequest.prototype.open = function (method, url, ...rest) { this.__gec_url__ = url; this.__gec_headers__ = {}; return OrigOpen.call(this, method, url, ...rest); }; XMLHttpRequest.prototype.setRequestHeader = function (name, value) { try { if (isApiUrl(this.__gec_url__)) { this.__gec_headers__ = this.__gec_headers__ || {}; this.__gec_headers__[name] = value; } } catch (_) {} return OrigSetHeader.call(this, name, value); }; XMLHttpRequest.prototype.send = function (...args) { try { if (isApiUrl(this.__gec_url__)) { recordCapturedHeaders(this.__gec_headers__); } this.addEventListener('load', () => { try { const url = this.__gec_url__ || ''; if (!isApiUrl(url)) return; const txt = (this.responseType === '' || this.responseType === 'text') ? this.responseText : (typeof this.response === 'string' ? this.response : JSON.stringify(this.response)); handleInterceptedResponse(url, txt); } catch (_) {} }); } catch (_) {} return OrigSend.apply(this, args); }; })(); // ===================================================================== // 通用工具函数 // ===================================================================== function normText(s) { return String(s || '').replace(/\s+/g, ' ').trim(); } function stripUnitParen(title) { return normText(String(title || '').replace(/([^)]*)/g, '').replace(/\([^)]*\)/g, '')); } function formatTimestampYYYYMMDD_HHMMSS(d = new Date()) { const pad = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } function formatTimestampYYYYMMDD_HHMM_V(d = new Date(), version = SCRIPT_VERSION) { const pad = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}_${pad(d.getMinutes())}_V${version}`; } function safeFilenamePart(s) { return String(s || '').trim().replace(/[\\\/:*?"<>|]/g, '_').replace(/\s+/g, ' ').slice(0, 80); } async function waitUntil(fn, timeoutMs, stepMs = 500) { const start = Date.now(); while (Date.now() - start < timeoutMs) { try { const r = fn(); if (r) return r; } catch (_) {} await sleep(stepMs); } return null; } async function ensureHash(hash) { if (!location.hash.includes(hash)) { location.hash = hash; await sleep(1200); } } function robustClick(el) { if (!el) return; el.scrollIntoView?.({ block: 'center' }); const opts = { bubbles: true, cancelable: true }; try { el.dispatchEvent(new MouseEvent('pointerdown', opts)); el.dispatchEvent(new MouseEvent('pointerup', opts)); } catch (_) {} el.dispatchEvent(new MouseEvent('mousedown', opts)); el.dispatchEvent(new MouseEvent('mouseup', opts)); el.dispatchEvent(new MouseEvent('click', opts)); } async function waitForAccount(timeoutMs) { const start = Date.now(); while (Date.now() - start < timeoutMs) { if (SHARED_CACHE.account && SHARED_CACHE.account.data) return SHARED_CACHE.account; await sleep(POLL_INTERVAL_MS); } return null; } async function waitForCapturedHeaders(timeoutMs) { const start = Date.now(); while (Date.now() - start < timeoutMs) { if (SHARED_CACHE.capturedHeaders) return SHARED_CACHE.capturedHeaders; await sleep(POLL_INTERVAL_MS); } return null; } async function waitForCalendarPage(pageNum, pageSize, timeoutMs) { const key = calKey(pageNum, pageSize); const start = Date.now(); while (Date.now() - start < timeoutMs) { const entry = SHARED_CACHE.calendarPages.get(key); if (entry) return entry; await sleep(POLL_INTERVAL_MS); } return null; } async function waitForNewGecListResponse(prevVersion, timeoutMs = MAX_WAIT_MS) { const start = Date.now(); while (Date.now() - start < timeoutMs) { if (SHARED_CACHE.gecListVersion > prevVersion) return SHARED_CACHE.gecListResponse; await sleep(POLL_INTERVAL_MS); } return null; } // ===================================================================== // ★ XLSX 库加载兜底(应对 cdn.sheetjs.com 不可达)★ // - 若 @require 全部失败导致 XLSX 未定义,运行时使用 GM_xmlhttpRequest // 按顺序尝试多个备用 CDN 拉取库代码并通过 new Function 注入到当前沙箱 // - 仅在用户点击导出按钮时触发一次;加载成功后会被后续调用复用 // ===================================================================== const XLSX_FALLBACK_CDNS = [ 'https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js', 'https://unpkg.com/xlsx@0.18.5/dist/xlsx.full.min.js', 'https://cdn.bootcdn.net/ajax/libs/xlsx/0.18.5/xlsx.full.min.js', 'https://cdn.staticfile.org/xlsx/0.18.5/xlsx.full.min.js', ]; let _xlsxLoadingPromise = null; function isXLSXReady() { return typeof XLSX !== 'undefined' && XLSX && XLSX.utils && typeof XLSX.utils.aoa_to_sheet === 'function'; } function fetchTextByGM(url, timeoutMs = 15000) { return new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest === 'undefined') { reject(new Error('GM_xmlhttpRequest 不可用,请在脚本头加 @grant GM_xmlhttpRequest 并重新安装')); return; } try { GM_xmlhttpRequest({ method: 'GET', url, timeout: timeoutMs, onload: (resp) => { if (resp && resp.status >= 200 && resp.status < 300 && resp.responseText && resp.responseText.length > 1000) { resolve(resp.responseText); } else { reject(new Error(`HTTP ${resp && resp.status}`)); } }, onerror: () => reject(new Error('network error')), ontimeout: () => reject(new Error('timeout')), }); } catch (e) { reject(e); } }); } async function ensureXLSXLoaded(onProgress) { if (isXLSXReady()) return true; if (_xlsxLoadingPromise) return _xlsxLoadingPromise; _xlsxLoadingPromise = (async () => { for (let i = 0; i < XLSX_FALLBACK_CDNS.length; i++) { const url = XLSX_FALLBACK_CDNS[i]; try { if (typeof onProgress === 'function') { onProgress(`正在从备用 CDN 加载 XLSX 库 (${i + 1}/${XLSX_FALLBACK_CDNS.length})…`); } const code = await fetchTextByGM(url); // SheetJS 是 UMD,使用 new Function 注入到当前沙箱 globalThis try { (new Function(code)).call(globalThis); } catch (execErr) { console.warn('[XLSX] 执行库代码失败:', execErr); } // 部分 UMD 实现会把 XLSX 挂到 self 上,这里兜底拷贝一次 if (typeof XLSX === 'undefined' && typeof globalThis !== 'undefined' && globalThis.XLSX) { /* 已挂到 globalThis,直接可用 */ } if (isXLSXReady()) { console.log('[XLSX] 从备用 CDN 加载成功:', url); return true; } } catch (e) { console.warn('[XLSX] 从', url, '加载失败:', e && e.message || e); } } return false; })(); try { return await _xlsxLoadingPromise; } finally { // 不论结果如何都清空 promise;失败后下次点击允许重试 if (!isXLSXReady()) _xlsxLoadingPromise = null; } } // ===================================================================== // Excel 工具函数 // ===================================================================== function toNumberMaybe(v) { if (v == null) return null; if (typeof v === 'number' && Number.isFinite(v)) return v; const s = String(v).trim(); if (!s) return null; const cleaned = s.replace(/,/g, ''); if (!/^-?\d+(\.\d+)?$/.test(cleaned)) return null; const n = Number(cleaned); return Number.isFinite(n) ? n : null; } function aoaToSheetTyped(aoa, { forceTextCols = [] } = {}) { const ws = XLSX.utils.aoa_to_sheet(aoa); const textCols = new Set(forceTextCols); const ref = ws['!ref']; if (!ref) return ws; const range = XLSX.utils.decode_range(ref); for (let r = range.s.r; r <= range.e.r; r++) { for (let c = range.s.c; c <= range.e.c; c++) { const addr = XLSX.utils.encode_cell({ r, c }); const cell = ws[addr]; if (!cell) continue; if (r === 0) { cell.t = 's'; continue; } if (textCols.has(c)) { cell.t = 's'; cell.v = String(cell.v ?? ''); continue; } const numVal = toNumberMaybe(cell.v); if (numVal !== null) { cell.t = 'n'; cell.v = numVal; cell.z = Number.isInteger(numVal) ? '0' : '0.00'; } } } return ws; } function forceConvertNumberCells(ws, forceTextCols = []) { const textColSet = new Set(forceTextCols); const ref = ws['!ref']; if (!ref) return; const range = XLSX.utils.decode_range(ref); for (let r = range.s.r + 1; r <= range.e.r; r++) { for (let c = range.s.c; c <= range.e.c; c++) { if (textColSet.has(c)) continue; const addr = XLSX.utils.encode_cell({ r, c }); const cell = ws[addr]; if (!cell) continue; if (cell.t === 's' || cell.t === undefined) { const numVal = toNumberMaybe(cell.v); if (numVal !== null) { cell.t = 'n'; cell.v = numVal; cell.z = Number.isInteger(numVal) ? '0' : '0.00'; } } } } } function displayWidth(str) { str = String(str ?? ''); let w = 0; for (const ch of str) w += /[\u0000-\u00ff]/.test(ch) ? 1 : 2; return w; } function autoCols(headers, rows) { const colCount = Math.max(headers.length, ...rows.map(r => r.length), 1); const widths = new Array(colCount).fill(10); const consider = (r) => { for (let i = 0; i < colCount; i++) { const v = String(r[i] ?? ''); const lines = v.split('\n'); const w = Math.max(...lines.map(displayWidth), 0); widths[i] = Math.max(widths[i], Math.min(60, w + 2)); } }; if (headers?.length) consider(headers); rows.forEach(consider); return widths.map(wch => ({ wch })); } function applyWrap(ws) { const ref = ws['!ref']; if (!ref) return; const range = XLSX.utils.decode_range(ref); for (let r = range.s.r; r <= range.e.r; r++) { for (let c = range.s.c; c <= range.e.c; c++) { const addr = XLSX.utils.encode_cell({ r, c }); if (!ws[addr]) continue; ws[addr].s = ws[addr].s || {}; ws[addr].s.alignment = ws[addr].s.alignment || {}; ws[addr].s.alignment.wrapText = true; ws[addr].s.alignment.vertical = 'top'; } } } function safeSheetName(name) { name = String(name || 'Sheet1').trim().replace(/[\[\]\*\/\\\?\:]/g, ' '); if (name.length > 31) name = name.slice(0, 31); return name || 'Sheet1'; } function setFormula(ws, cellAddr, formula) { ws[cellAddr] = ws[cellAddr] || {}; ws[cellAddr].f = formula; } function enableFullCalcOnLoad(wb) { wb.Workbook = wb.Workbook || {}; wb.Workbook.CalcPr = Object.assign({}, wb.Workbook.CalcPr, { fullCalcOnLoad: true, calcMode: 'auto' }); } // ===================================================================== // DOM 读取 // ===================================================================== function getTextKeepNewline(node) { if (!node) return ''; let t = node.innerText ?? node.textContent ?? ''; t = t.replace(/\r/g, ''); t = t.split('\n').map(line => line.trim()).join('\n').trim(); t = t.replace(/[ \t]+/g, ' '); return t; } function readDescriptionsMap() { const map = Object.create(null); const table = document.querySelector('.user_info .info_table .ant-descriptions-view table'); if (!table) return map; table.querySelectorAll('tr').forEach(tr => { const cells = Array.from(tr.children); for (let i = 0; i < cells.length - 1; i += 2) { const th = cells[i]; const td = cells[i + 1]; if (!th || !td) continue; const label = stripUnitParen(getTextKeepNewline(th)); const value = getTextKeepNewline(td); if (label) map[label] = value; } }); return map; } function getOwnerInfo() { const acc = SHARED_CACHE.account?.data; if (acc) { return { owner: acc.ownerName || '', region: acc.address || '', code: acc.ownerCode || '', }; } const owner = getTextKeepNewline(document.querySelector('.user_avatar .companyTitle')); const desc = readDescriptionsMap(); return { owner, region: desc['所在地区'] || '', code: desc['企业代码'] || desc['统一社会信用代码'] || '', }; } // ===================================================================== // 通用直接调接口工具(接入限流 + 缓存) // ===================================================================== function buildHeadersForApi() { const base = SHARED_CACHE.capturedHeaders ? { ...SHARED_CACHE.capturedHeaders } : {}; if (!base['Accept'] && !base['accept']) base['Accept'] = 'application/json, text/plain, */*'; return base; } async function apiFetchJSONInternal(url) { const ctrl = new AbortController(); const tid = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS); try { const res = await fetch(url, { method: 'GET', credentials: 'include', headers: buildHeadersForApi(), signal: ctrl.signal, }); const txt = await res.text(); let json; try { json = JSON.parse(txt); } catch (_) { throw new Error(`接口返回非 JSON: ${url}`); } return json; } finally { clearTimeout(tid); } } /** * 获取建档立卡接口需要的 accountCode: * 1. 优先从已捕获的 gec/list URL 中解析(与网站请求完全一致) * 2. 否则从 account 接口的缓存中取(accountCode 或 data.ownerCode) */ function getAccountCodeForGec() { try { if (SHARED_CACHE.gecListUrl) { const params = parseQueryParams(SHARED_CACHE.gecListUrl); if (params.accountCode) return params.accountCode; } } catch (_) {} if (SHARED_CACHE.account?.accountCode) return SHARED_CACHE.account.accountCode; if (SHARED_CACHE.account?.data?.ownerCode) return SHARED_CACHE.account.data.ownerCode; return ''; } /** * ★ 统一对外的接口调用:接入全局限流队列 + 详情接口缓存 */ async function apiFetchJSON(url) { // 详情接口:先查缓存 if (isDetailApi(url)) { const cached = detailCacheGet(url); if (cached !== null) return cached; } // 等待限流令牌(≤4 次/秒 + 50-200ms 随机抖动) await acquireApiSlot(); const json = await apiFetchJSONInternal(url); // 回写详情缓存(仅当响应正常时) if (isDetailApi(url) && json && json.code === 200) { detailCacheSet(url, json); } return json; } // 主动请求人员列表(同样走限流,不过由于带超时单独处理) async function fetchPersonnelListByAccountCode(accountCode) { if (!accountCode) return null; const url = `${location.origin}${API_PATH_PERSONNEL}?accountCode=${encodeURIComponent(accountCode)}`; await acquireApiSlot(); const ctrl = new AbortController(); const tid = setTimeout(() => ctrl.abort(), PERSONNEL_FETCH_TIMEOUT_MS); try { const res = await fetch(url, { method: 'GET', credentials: 'include', headers: buildHeadersForApi(), signal: ctrl.signal, }); if (!res.ok) { console.warn(`[合并脚本] 人员列表兜底请求失败 HTTP ${res.status}`); return null; } const txt = await res.text(); try { handleInterceptedResponse(url, txt); } catch (_) {} try { const json = JSON.parse(txt); if (json?.code === 401) { console.warn('[合并脚本] 人员列表兜底请求 401,token 可能已过期'); return null; } if (Array.isArray(json?.data)) return json.data; } catch (_) {} return null; } catch (e) { console.warn('[合并脚本] 人员列表兜底请求异常:', e?.message || e); return null; } finally { clearTimeout(tid); } } function pickLeader(personnelList) { if (!Array.isArray(personnelList)) return { name: '', phone: '' }; const found = personnelList.find(p => String(p?.responsibility || '').trim() === LEADER_RESP); if (!found) return { name: '', phone: '' }; return { name: String(found.contactName ?? '').trim(), phone: String(found.contactPhone ?? '').trim(), }; } // ===================================================================== // ★ 表单1(总览) - 抽取与构建 ★ // ===================================================================== function buildSheet1DataFromApi(apiData, leader) { const outRow = Object.create(null); for (const header of SHEET1_HEADERS_OUT) { if (header === '工作负责人' || header === '工作负责人联系方式') continue; const key = SHEET1_OVERVIEW_FIELD_MAP[header]; let val = (apiData && key in apiData) ? apiData[key] : ''; if (val === null || val === undefined) { outRow[header] = ''; continue; } if (SHEET1_NUMERIC_FIELDS.has(key)) { const n = toNumberMaybe(val); outRow[header] = (n === null) ? '' : String(Math.round(n)); } else { outRow[header] = String(val); } } outRow['工作负责人'] = leader?.name || ''; outRow['工作负责人联系方式'] = leader?.phone || ''; return outRow; } async function extractSheet1(opts = {}) { const { onProgress = () => {}, noReload = false } = opts; try { onProgress('检查页面…'); const alreadyOnTarget = location.hash.includes(OVERVIEW_HASH); if (!alreadyOnTarget) { onProgress('跳转到总览页…'); await ensureHash(OVERVIEW_HASH); } let entry = SHARED_CACHE.account; let retryCount = 0; while (!entry || !entry.data) { onProgress(retryCount === 0 ? '等待 account 响应…' : `重试获取数据(${retryCount}/${MAX_RETRY_COUNT})…`); entry = await waitForAccount(MAX_WAIT_MS); if (entry && entry.data) break; retryCount++; if (retryCount > MAX_RETRY_COUNT) throw new Error('等待 account 接口响应超时'); if (!noReload) { console.warn(`[表单1] 重试 ${retryCount}:刷新页面`); location.reload(); await sleep(RETRY_WAIT_MS); await ensureHash(OVERVIEW_HASH); } else { await sleep(RETRY_WAIT_MS); } } onProgress('组装表单1数据…'); const apiData = entry.data; const accountCode = entry.accountCode || apiData.ownerCode || ''; onProgress('获取工作负责人…'); let personnelList = null; if (accountCode && SHARED_CACHE.personnelByAccountCode.has(accountCode)) { personnelList = SHARED_CACHE.personnelByAccountCode.get(accountCode).data; console.log('[表单1] 使用缓存的人员列表'); } else if (SHARED_CACHE.latestPersonnel && SHARED_CACHE.latestPersonnel.data) { const latestAcc = SHARED_CACHE.latestPersonnel.accountCode || ''; if (!accountCode || !latestAcc || latestAcc === accountCode) { personnelList = SHARED_CACHE.latestPersonnel.data; console.log('[表单1] 使用最近一次人员列表缓存'); } } if (!personnelList && accountCode) { console.log('[表单1] 缓存无命中,发起人员列表兜底请求...'); personnelList = await fetchPersonnelListByAccountCode(accountCode); } const leader = pickLeader(personnelList); if (!leader.name) { console.warn('[表单1] 未找到"工作负责人",对应字段将为空'); } const row1 = buildSheet1DataFromApi(apiData, leader); if (!row1['项目业主'] && !row1['企业代码']) { throw new Error('接口数据异常(项目业主与企业代码均为空)'); } const ownerInfo = { owner: apiData.ownerName || '', region: apiData.address || '', code: apiData.ownerCode || '', }; return { ok: true, row1, ownerInfo, leader }; } catch (e) { console.error('[表单1] 抽取失败:', e); return { ok: false, error: e?.message || String(e), ownerInfo: getOwnerInfo() }; } } function buildSheet1Worksheet(extractResult) { const row1 = extractResult.row1; const s1Headers = [...SHEET1_HEADERS_OUT, '核发-已售出-未售出', '现持有+超期核销-未售出']; const s1Values = SHEET1_HEADERS_OUT.map(h => row1[h] ?? ''); s1Values.push('', ''); const aoa1 = [s1Headers, s1Values]; const forceTextCols1 = [ s1Headers.indexOf('项目业主'), s1Headers.indexOf('企业代码'), s1Headers.indexOf('所在地区'), s1Headers.indexOf('联系人'), s1Headers.indexOf('联系方式'), s1Headers.indexOf('工作负责人'), s1Headers.indexOf('工作负责人联系方式'), ].filter(i => i >= 0); const ws1 = aoaToSheetTyped(aoa1, { forceTextCols: forceTextCols1 }); const colIssued = s1Headers.indexOf('绿证核发数量(个)'); const colSold = s1Headers.indexOf('已售出绿证总量(个)'); const colUnsold = s1Headers.indexOf('未售出绿证数量(个)'); const colHolding = s1Headers.indexOf('现持有绿证总量(个)'); const colOverdue = s1Headers.indexOf('超期核销绿证量(个)'); const colCheck1 = s1Headers.indexOf('核发-已售出-未售出'); const colCheck2 = s1Headers.indexOf('现持有+超期核销-未售出'); if (colIssued >= 0 && colSold >= 0 && colCheck1 >= 0) { const addrIssued = XLSX.utils.encode_cell({ r: 1, c: colIssued }); const addrSold = XLSX.utils.encode_cell({ r: 1, c: colSold }); const addrUnsold = XLSX.utils.encode_cell({ r: 1, c: colUnsold }); const addrCheck1 = XLSX.utils.encode_cell({ r: 1, c: colCheck1 }); setFormula(ws1, addrCheck1, `${addrIssued}-${addrSold}-${addrUnsold}`); } if (colHolding >= 0 && colOverdue >= 0 && colCheck2 >= 0) { const addrHolding = XLSX.utils.encode_cell({ r: 1, c: colHolding }); const addrOverdue = XLSX.utils.encode_cell({ r: 1, c: colOverdue }); const addrUnsold = XLSX.utils.encode_cell({ r: 1, c: colUnsold }); const addrCheck2 = XLSX.utils.encode_cell({ r: 1, c: colCheck2 }); setFormula(ws1, addrCheck2, `${addrHolding}+${addrOverdue}-${addrUnsold}`); } forceConvertNumberCells(ws1, forceTextCols1); ws1['!cols'] = autoCols(s1Headers, [s1Values]); applyWrap(ws1); return { ws: ws1, sheetName: safeSheetName(SHEET1_NAME), rowCount: 1 }; } // ===================================================================== // ★ 表单2(核发明细) - 抽取与构建 ★ // ===================================================================== function findEW2() { const ews = [...document.querySelectorAll('.early_warning')]; return ews.find(b => b.querySelector('.early_title')?.innerText?.includes(MODULE2_TITLE)) || null; } function findPagination(ew) { return ew?.querySelector('.pagination_box .ant-pagination') || ew?.querySelector('.ant-pagination') || null; } function activePage(pagination) { return pagination?.querySelector('.ant-pagination-item-active')?.innerText?.trim() || ''; } function nextLi(pagination) { return pagination?.querySelector('.ant-pagination-next') || null; } function isDisabledNext(li) { if (!li) return true; if (li.classList.contains('ant-pagination-disabled')) return true; if (li.getAttribute('aria-disabled') === 'true') return true; const btn = li.querySelector('button'); if (btn?.disabled) return true; return false; } function findPageSizeComboInEW(ew) { const sizeChanger = ew.querySelector('.ant-pagination-options-size-changer'); if (sizeChanger) { const combo = sizeChanger.querySelector('div[role="combobox"]') || sizeChanger.querySelector('.ant-select-selection'); return combo || sizeChanger; } return null; } function currentPageSizeFromCombo(combo) { const t = combo?.innerText || ''; const m = t.match(/(\d+)\s*条/); return m ? Number(m[1]) : 0; } function findOpenDropdownByAriaControls(comboEl) { const id = comboEl?.getAttribute('aria-controls'); if (!id) return null; const root = document.getElementById(id); if (!root) return null; const dropdown = root.closest('.ant-select-dropdown'); if (dropdown && getComputedStyle(dropdown).display !== 'none') return dropdown; return root.closest('div') || null; } async function ensurePageSize40AndFirstResponseF2(ew) { const combo = findPageSizeComboInEW(ew); if (!combo) throw new Error('未找到分页大小选择器'); const cur = currentPageSizeFromCombo(combo); SHARED_CACHE.calendarPages.delete(calKey(1, 100)); if (cur !== 100) { robustClick(combo); await sleep(300); const dropdown = findOpenDropdownByAriaControls(combo); const opt40 = (() => { if (dropdown) { return [...dropdown.querySelectorAll('.ant-select-dropdown-menu-item')] .find(li => (li.innerText || '').includes(`${100} 条/页`)) || null; } const openDropdowns = [...document.querySelectorAll('.ant-select-dropdown')] .filter(d => getComputedStyle(d).display !== 'none'); for (const d of openDropdowns) { const opt = [...d.querySelectorAll('.ant-select-dropdown-menu-item')] .find(li => (li.innerText || '').includes(`${100} 条/页`)); if (opt) return opt; } return null; })(); if (!opt40) throw new Error('未找到"40 条/页"选项'); robustClick(opt40); await sleep(MIN_WAIT_AFTER_CLICK_MS); } const entry = await waitForCalendarPage(1, 100, MAX_WAIT_MS); if (!entry) throw new Error('切换到"40 条/页"后未捕获第 1 页响应'); return entry; } async function ensureOnFirstPageF2(pagination) { if (!pagination) return; const cur = activePage(pagination); if (cur && cur !== '1') { const first = pagination.querySelector('.ant-pagination-item-1 a') || pagination.querySelector('.ant-pagination-item-1'); if (first) { robustClick(first); await sleep(MIN_WAIT_AFTER_CLICK_MS); } } } async function clickNextAndWaitF2(pagination, expectPageNum) { SHARED_CACHE.calendarPages.delete(calKey(expectPageNum, 100)); const next = nextLi(pagination); if (!next || isDisabledNext(next)) return null; const clickTarget = next.querySelector('a.ant-pagination-item-link') || next.querySelector('a') || next; robustClick(clickTarget); // 随机抖动,对后端更温柔 const jitter = 100 + Math.random() * 200; await sleep(MIN_WAIT_AFTER_CLICK_MS + jitter); const entry = await waitForCalendarPage(expectPageNum, 100, MAX_WAIT_MS); if (!entry) throw new Error(`翻到第 ${expectPageNum} 页未捕获响应`); return entry; } async function collectAllPagesViaApiF2(ew) { const pagination = findPagination(ew); if (!pagination) { // ★ 改动:分页控件可能因为本模块完全没有数据而不渲染(例如新项目无核发记录)。 // 不再抛错,而是检查模块内是否有"暂无数据"占位;若是则返回 0 行让上层生成空表。 const noData = ew.querySelector('.ant-empty, .ant-table-placeholder, .no-data, [class*="empty"]'); const tableRows = ew.querySelectorAll('.ant-table-row, .ant-table-tbody > tr:not(.ant-table-placeholder)'); if (noData || tableRows.length === 0) { console.warn('[表单2] 未找到分页器且页面显示无数据,按"0 行"处理'); return { total: 0, rows: [] }; } throw new Error('未找到模块分页控件'); } await ensureOnFirstPageF2(pagination); const firstEntry = await ensurePageSize40AndFirstResponseF2(ew); const total = Number(firstEntry.total) || firstEntry.rows.length; const totalPages = Math.max(1, Math.ceil(total / 100)); console.log(`[表单2] 总计 ${total} 行,共 ${totalPages} 页`); const allRows = [...firstEntry.rows]; for (let p = 2; p <= totalPages && p <= MAX_PAGES; p++) { const entry = await clickNextAndWaitF2(pagination, p); if (!entry) break; allRows.push(...entry.rows); } // ★ total 完整性校验 if (total > 0) { if (allRows.length < total) { throw new Error(`表单2抽取不完整:实抓 ${allRows.length} 行 < total ${total}`); } else if (allRows.length > total) { console.warn(`[表单2] 警告:抽取 ${allRows.length} 行 > total ${total},可能存在重复`); } else { console.log(`[表单2] ✓ 完整抽取 ${allRows.length} 行(total=${total})`); } } return { total, rows: allRows }; } async function extractSheet2(opts = {}) { const { onProgress = () => {}, noReload = false } = opts; let ownerInfo = getOwnerInfo(); try { onProgress('检查页面…'); const alreadyOnTarget = location.hash.includes(OVERVIEW_HASH); if (!alreadyOnTarget) { onProgress('跳转到总览页…'); await ensureHash(OVERVIEW_HASH); } onProgress('等待 account 响应…'); await waitForAccount(MAX_WAIT_MS); ownerInfo = getOwnerInfo(); // ★ v3.13: 先查询数据总量,决定走翻页还是导出 onProgress('查询数据总量…'); const total = await getCalendarTotal(); console.log(`[表单2] 数据总量: ${total}`); if (total >= 0 && total <= SHEET2_EXPORT_THRESHOLD) { // ========================================================= // 快速路径(total ≤ 10000):翻页拦截 + API 响应拦截 // ========================================================= onProgress(`数据量 ${total},使用翻页模式…`); onProgress('定位核发统计模块…'); const ew2 = await waitUntil(() => findEW2(), MAX_WAIT_MS, 500); if (!ew2) throw new Error(`未找到模块"${MODULE2_TITLE}"`); ew2.scrollIntoView?.({ block: 'center', behavior: 'instant' }); await sleep(800); let apiRows = []; let retryCount = 0; while (retryCount <= MAX_RETRY_COUNT) { try { onProgress('切换"40 条/页"并拉取数据…'); const ew = findEW2(); if (!ew) throw new Error('模块消失'); ew.scrollIntoView?.({ block: 'center' }); await sleep(400); const result = await collectAllPagesViaApiF2(ew); apiRows = result.rows; if (total > 0 && !apiRows.length) throw new Error('抓取到 0 行'); break; } catch (e) { retryCount++; const transient = e.message.includes('超时') || e.message.includes('0 行') || e.message.includes('未捕获') || e.message.includes('抽取不完整'); if (retryCount <= MAX_RETRY_COUNT && transient) { console.warn(`[表单2] 第 ${retryCount} 次重试...`, e.message); if (!noReload) { location.reload(); await sleep(RETRY_WAIT_MS); await ensureHash(OVERVIEW_HASH); await sleep(2000); await waitForAccount(MAX_WAIT_MS); ownerInfo = getOwnerInfo(); } else { await sleep(RETRY_WAIT_MS); } } else { throw e; } } } return { ok: true, rows: apiRows, total: apiRows.length, ownerInfo, empty: apiRows.length === 0 }; } else { // ========================================================= // 慢速路径(total > 10000 或 unknown):网站导出 + Blob 捕获 // ========================================================= onProgress(`数据量 ${total > 0 ? total : '未知'},使用网站导出模式…`); onProgress('定位核发统计模块…'); const ew2 = await waitUntil(() => findEW2(), MAX_WAIT_MS, 500); if (!ew2) throw new Error(`未找到模块"${MODULE2_TITLE}"`); ew2.scrollIntoView?.({ block: 'center', behavior: 'instant' }); await sleep(800); // 先挂载临时拦截器(在点击导出按钮之前!) const { promise: blobPromise, cleanup } = setupTemporaryBlobCapture(); onProgress('查找导出按钮…'); const exportBtn = findExportButtonInModule(); if (!exportBtn) { cleanup(); throw new Error('未找到导出按钮'); } onProgress('点击导出按钮…'); robustClick(exportBtn); await sleep(500); onProgress('等待确认弹窗…'); const confirmBtn = await waitForConfirmDialogAndConfirm(8000); if (!confirmBtn) { cleanup(); throw new Error('未找到确认弹窗或确认按钮'); } onProgress('点击确认,等待下载…'); robustClick(confirmBtn); // 等待 Blob 拦截器捕获到数据 let arrayBuffer = null; try { onProgress('等待文件下载完成…'); arrayBuffer = await blobPromise; console.log(`[表单2] Blob 捕获成功: ${arrayBuffer.byteLength} bytes`); } catch (e) { console.warn('[表单2] Blob 自动捕获失败:', e?.message || e); // 兜底:让用户手动选择文件 onProgress('自动捕获失败,请手动选择下载的文件…'); try { const file = await pickDownloadedXlsxFile(); arrayBuffer = await readFileAsArrayBuffer(file); } catch (e2) { throw new Error('未能获取导出文件:' + (e2?.message || String(e2))); } } onProgress('读取文件…'); const workbook = XLSX.read(arrayBuffer, { type: 'array' }); const sheetName = workbook.SheetNames[0]; const worksheet = workbook.Sheets[sheetName]; const aoa = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: '' }); if (!aoa || aoa.length < 2) throw new Error('导出文件为空或只有表头'); const headers = aoa[0]; const rows = aoa.slice(1); console.log(`[表单2] 成功读取导出文件: ${rows.length} 行, ${headers.length} 列`); console.log('[表单2] 导出文件表头:', headers); return { ok: true, exportData: { headers, rows }, total: rows.length, ownerInfo, empty: rows.length === 0 }; } } catch (e) { console.error('[表单2] 抽取失败:', e); return { ok: false, error: e?.message || String(e), ownerInfo }; } } function buildSheet2Worksheet(extractResult) { const ownerName = extractResult.ownerInfo?.owner || ''; const numericKeys = new Set([ 'releaseQuantity', 'ordinaryQuantity', 'traQuantity', 'greenQuantity', 'unTraQuantity', 'mechanismNum', 'shelfLoad', 'soldQuantity', 'unsoldQuantity', 'unshelfLoad', 'year', 'month' ]); const aoa = [SHEET2_HEADERS.slice()]; if (extractResult.exportData) { // ===== 导出文件路径:从 2D 数组映射 ===== const exportData = extractResult.exportData; const exportHeaderToIndex = new Map(); exportData.headers.forEach((h, i) => exportHeaderToIndex.set(h.trim(), i)); for (const exportRow of exportData.rows) { const line = SHEET2_HEADERS.map(h => { if (h === '项目业主') return ownerName || ''; const field = SHEET2_FIELD_MAP[h]; if (!field) return ''; let exportColName = null; for (const [expCol, expField] of Object.entries(SHEET2_EXPORT_FIELD_MAP)) { if (expField === field) { exportColName = expCol; break; } } if (!exportColName) return ''; const colIdx = exportHeaderToIndex.get(exportColName); if (colIdx === undefined) return ''; const v = exportRow[colIdx]; if (v === null || v === undefined || v === '') return ''; if (numericKeys.has(field)) { const n = toNumberMaybe(v); return n === null ? '' : n; } return String(v); }); aoa.push(line); } } else { // ===== 翻页拦截路径:从 API 对象数组映射 ===== const apiRows = extractResult.rows || []; for (const row of apiRows) { const line = SHEET2_HEADERS.map(h => { if (h === '项目业主') return ownerName || ''; const field = SHEET2_FIELD_MAP[h]; if (!field) return ''; const v = row[field]; if (v === null || v === undefined) return ''; if (numericKeys.has(field)) { const n = toNumberMaybe(v); return n === null ? '' : n; } return String(v); }); aoa.push(line); } } const forceTextCols = SHEET2_TEXT_COLS.map(h => SHEET2_HEADERS.indexOf(h)).filter(i => i >= 0); const ws = aoaToSheetTyped(aoa, { forceTextCols }); forceConvertNumberCells(ws, forceTextCols); ws['!cols'] = autoCols(SHEET2_HEADERS, aoa.slice(1)); applyWrap(ws); const dataRowCount = extractResult.exportData ? extractResult.exportData.rows.length : (extractResult.rows || []).length; return { ws, sheetName: safeSheetName(SHEET2_NAME), rowCount: dataRowCount }; } // ===================================================================== // ★ 表单3(核销明细) - 抽取与构建 ★ // ===================================================================== async function fetchListAllByGecSource(gecSource) { const all = []; let pageNum = 1; let total = Infinity; let firstTotal = -1; while (all.length < total) { const url = `${location.origin}${API_PATH_OVERDUE_LIST}?gecSource=${gecSource}&pageNum=${pageNum}&pageSize=${TARGET_PAGE_SIZE}`; const json = await apiFetchJSON(url); if (!json || json.code !== 200) { throw new Error(`列表接口异常 (gecSource=${gecSource}, page=${pageNum}): code=${json?.code}, msg=${json?.msg}`); } const data = json.data || {}; const rows = Array.isArray(data.rows) ? data.rows : []; total = Number(data.total) || 0; if (firstTotal < 0) firstTotal = total; all.push(...rows); if (rows.length === 0) break; if (all.length >= total) break; pageNum++; if (pageNum > 1000) break; } // ★ total 完整性校验 if (firstTotal > 0) { if (all.length < firstTotal) { throw new Error(`表单3列表不完整 (gecSource=${gecSource}):实抓 ${all.length} < total ${firstTotal}`); } else if (all.length > firstTotal) { console.warn(`[表单3] 警告 (gecSource=${gecSource}):抽取 ${all.length} > total ${firstTotal}`); } else { console.log(`[表单3] ✓ 完整抽取 (gecSource=${gecSource}) ${all.length} 行(total=${firstTotal})`); } } return all; } async function fetchOverdueDetailById(id) { const url = `${location.origin}${API_PATH_OVERDUE_DETAIL}?id=${encodeURIComponent(id)}`; const json = await apiFetchJSON(url); if (!json || json.code !== 200) { throw new Error(`详情接口异常 (id=${id}): code=${json?.code}, msg=${json?.msg}`); } return json.data || {}; } async function fetchAllOverdueDetails(ids, onProgress) { const result = new Map(); let idx = 0; let done = 0; async function worker() { while (true) { const myIdx = idx++; if (myIdx >= ids.length) return; const id = ids[myIdx]; if (!id) { done++; continue; } try { const detail = await fetchOverdueDetailById(id); result.set(id, detail); } catch (e) { console.warn(`[表单3] 详情失败 id=${id}:`, e?.message || e); result.set(id, null); } done++; if (onProgress) onProgress(done, ids.length); } } const workers = []; const n = Math.min(DETAIL_CONCURRENCY, Math.max(1, ids.length)); for (let i = 0; i < n; i++) workers.push(worker()); await Promise.all(workers); return result; } function formatCancelTime(s) { if (!s) return ''; const str = String(s); const tIdx = str.indexOf('T'); if (tIdx >= 0) return str.slice(0, tIdx); return str.slice(0, 10); } function formatCancelReason(code) { if (code === null || code === undefined || code === '') return ''; const s = String(code).trim(); if (s === '3') return '超期自动核销'; return s; } function formatRegionOverdue(detail) { if (!detail) return ''; const parts = [detail.tradeProvince, detail.tradeCity, detail.tradeCounty] .map(x => (x === null || x === undefined) ? '' : String(x).trim()) .filter(Boolean); return parts.join('-'); } async function extractSheet3(opts = {}) { const { onProgress = () => {} } = opts; let ownerInfo = getOwnerInfo(); try { onProgress('获取项目信息…'); if (!SHARED_CACHE.account) { await ensureHash(OVERVIEW_HASH); await sleep(800); await waitForAccount(MAX_WAIT_MS); } ownerInfo = getOwnerInfo(); onProgress('跳转到核销明细页…'); await ensureHash(OVERDUE_HASH); await sleep(1200); onProgress('等待鉴权头捕获…'); const captured = await waitForCapturedHeaders(MAX_WAIT_MS); if (!captured) { console.warn('[表单3] 未捕获鉴权头,仍尝试 credentials:include 发起请求'); } let listSrc2 = []; let listSrc1 = []; let retry = 0; while (true) { try { onProgress('拉取「未交易绿证」列表…'); listSrc2 = await fetchListAllByGecSource(2); onProgress('拉取「购买绿证」列表…'); listSrc1 = await fetchListAllByGecSource(1); break; } catch (e) { retry++; console.warn(`[表单3] 列表拉取失败,第 ${retry} 次重试...`, e?.message || e); if (retry > MAX_RETRY_COUNT) throw e; await sleep(RETRY_WAIT_MS); } } const allRows = [...listSrc2, ...listSrc1]; if (!allRows.length) { console.log('[表单3] 列表接口返回 0 行,本项目暂无超期核销数据,将生成只有表头的空表'); return { ok: true, allRows: [], detailMap: new Map(), detailFailCount: 0, ownerInfo, empty: true }; } const ids = allRows.map(r => r.id).filter(Boolean); onProgress(`获取详情 0/${ids.length}…`); const detailMap = await fetchAllOverdueDetails(ids, (done, total) => { onProgress(`获取详情 ${done}/${total}…`); }); const detailFailCount = ids.filter(id => !detailMap.get(id)).length; return { ok: true, allRows, detailMap, detailFailCount, ownerInfo, empty: false }; } catch (e) { console.error('[表单3] 抽取失败:', e); return { ok: false, error: e?.message || String(e), ownerInfo }; } } function buildSheet3Worksheet(extractResult) { const allRows = extractResult.allRows; const detailMap = extractResult.detailMap; const ownerName = extractResult.ownerInfo?.owner || ''; const aoa = [SHEET3_HEADERS.slice()]; for (const r of allRows) { const detail = detailMap.get(r.id) || null; const line = SHEET3_HEADERS.map(h => { switch (h) { case '项目业主': return ownerName || ''; case '项目名称': return r.projectName || ''; case '建档立卡编码': return r.projectCode || ''; case '电量生产年': return toNumberMaybe(r.year) ?? ''; case '电量生产月': return toNumberMaybe(r.month) ?? ''; case '绿证类型': return r.cancelGecType || ''; case '核销绿证数量': return toNumberMaybe(r.cancelGecNum) ?? ''; case '核销时间': return formatCancelTime(r.cancelTime); case '核销原因': return formatCancelReason(r.cancelReason); case '绿证来源': return detail?.gecSource || ''; case '发电类型': return detail?.projectType || ''; case '技术类型': return detail?.projectSubType || ''; case '环境权益归属地': return formatRegionOverdue(detail); default: return ''; } }); aoa.push(line); } const forceTextCols = SHEET3_HEADERS .map((h, i) => SHEET3_TEXT_COLS.has(h) ? i : -1) .filter(i => i >= 0); const ws = aoaToSheetTyped(aoa, { forceTextCols }); forceConvertNumberCells(ws, forceTextCols); ws['!cols'] = autoCols(SHEET3_HEADERS, aoa.slice(1)); applyWrap(ws); return { ws, sheetName: safeSheetName(SHEET3_NAME), rowCount: allRows.length }; } // ===================================================================== // ★ 表单4(建档立卡) - 抽取与构建 ★ // ===================================================================== function findPageSizeComboGlobal() { const sizeChanger = document.querySelector('.ant-pagination-options-size-changer'); if (sizeChanger) { const combo = sizeChanger.querySelector('div[role="combobox"]') || sizeChanger.querySelector('.ant-select-selection'); return combo || sizeChanger; } const combos = [...document.querySelectorAll('div[role="combobox"].ant-select-selection--single')]; const visible = combos.find(el => el.offsetParent !== null && /条\/页/.test(el.innerText || '')); return visible || combos.find(el => /条\/页/.test(el.innerText || '')) || null; } function getListRows(json) { if (!json) return []; if (Array.isArray(json.rows)) return json.rows; if (json.data && Array.isArray(json.data.rows)) return json.data.rows; return []; } function getListTotal(json) { if (!json) return 0; if (typeof json.total === 'number') return json.total; if (json.data && typeof json.data.total === 'number') return json.data.total; return 0; } async function ensurePageSize40GlobalF4() { console.log('[表单4] 切换每页 40 条…'); const combo = findPageSizeComboGlobal(); if (!combo) { console.warn('[表单4] 未找到条/页下拉框'); return false; } const cur = currentPageSizeFromCombo(combo); if (cur === 100) { console.log('[表单4] 已是 40 条/页'); return true; } const prevVer = SHARED_CACHE.gecListVersion; robustClick(combo); await sleep(300); let dropdown = findOpenDropdownByAriaControls(combo); const findOpt40 = () => { if (dropdown) { return [...dropdown.querySelectorAll('.ant-select-dropdown-menu-item')] .find(li => (li.innerText || '').includes(`${100} 条/页`)) || null; } const openDropdowns = [...document.querySelectorAll('.ant-select-dropdown')] .filter(d => getComputedStyle(d).display !== 'none'); for (const d of openDropdowns) { const opt = [...d.querySelectorAll('.ant-select-dropdown-menu-item')] .find(li => (li.innerText || '').includes(`${100} 条/页`)); if (opt) return opt; } return null; }; const opt40 = findOpt40(); if (!opt40) { console.warn('[表单4] 未找到 40 条/页选项'); document.body.click(); return false; } robustClick(opt40); await sleep(MIN_WAIT_AFTER_CLICK_MS); await waitForNewGecListResponse(prevVer, MAX_WAIT_MS); return true; } async function fetchGecDetailByProjectCode(projectCode) { const url = `${location.origin}${API_PATH_GEC_DETAIL}?projectCode=${encodeURIComponent(projectCode)}`; const json = await apiFetchJSON(url); if (!json || json.code !== 200) { throw new Error(`详情异常 (projectCode=${projectCode}): code=${json?.code}, msg=${json?.msg}`); } return json.data || {}; } async function fetchGecDetailsForRows(listRows, onProgress) { const result = new Array(listRows.length); let idx = 0; let done = 0; async function worker() { while (true) { const myIdx = idx++; if (myIdx >= listRows.length) return; const row = listRows[myIdx]; const pc = row?.projectCode; if (!pc) { result[myIdx] = { _list: row, _detail: null }; done++; if (onProgress) onProgress(done, listRows.length); continue; } try { const detail = await fetchGecDetailByProjectCode(pc); result[myIdx] = { _list: row, _detail: detail }; } catch (e) { console.warn(`[表单4] 详情失败 ${pc}:`, e?.message || e); result[myIdx] = { _list: row, _detail: null }; } done++; if (onProgress) onProgress(done, listRows.length); } } const workers = []; const n = Math.min(DETAIL_CONCURRENCY, Math.max(1, listRows.length)); for (let i = 0; i < n; i++) workers.push(worker()); await Promise.all(workers); return result; } async function collectAllPagesF4(onProgress) { const pageSize = 100; let firstResp = SHARED_CACHE.gecListResponse; let firstRows = firstResp ? getListRows(firstResp) : []; const totalInCache = firstResp ? getListTotal(firstResp) : 0; const cacheIsValid40 = firstResp && (firstRows.length >= pageSize || (totalInCache > 0 && firstRows.length === totalInCache)); const accountCode = getAccountCodeForGec(); if (!accountCode) { // 没有 accountCode 就别硬发请求了,直接报错触发上层重试 throw new Error('未获取到 accountCode(建档立卡接口必须参数),请确认已访问过总览页'); } const acParam = `&accountCode=${encodeURIComponent(accountCode)}`; if (!cacheIsValid40) { console.log(`[表单4] 缓存响应非 40 条/页 (rows=${firstRows.length}, total=${totalInCache}),主动调接口拉第 1 页`); const url = `${location.origin}${API_PATH_GEC_LIST}?pageNum=1&pageSize=${pageSize}${acParam}`; const json = await apiFetchJSON(url); // ...其它不变 if (!json || json.code !== 200) { throw new Error(`第 1 页接口异常: code=${json?.code}, msg=${json?.msg}`); } firstResp = json; firstRows = getListRows(firstResp); SHARED_CACHE.gecListResponse = firstResp; SHARED_CACHE.gecListVersion++; } const total = getListTotal(firstResp); const totalPages = Math.max(1, Math.ceil((total || firstRows.length) / pageSize)); console.log(`[表单4] total=${total}, pageSize=${pageSize}, totalPages=${totalPages}, 第 1 页 ${firstRows.length} 行`); const allItems = []; let collectedRows = 0; if (firstRows.length) { collectedRows += firstRows.length; if (onProgress) onProgress(`第 1/${totalPages} 页: 获取详情 0/${firstRows.length}…`); const pageDetails = await fetchGecDetailsForRows(firstRows, (done, tot) => { if (onProgress) onProgress(`第 1/${totalPages} 页: 获取详情 ${done}/${tot}…`); }); allItems.push(...pageDetails); } for (let pn = 2; pn <= totalPages && pn <= MAX_PAGES; pn++) { if (onProgress) onProgress(`拉取第 ${pn}/${totalPages} 页…`); const url = `${location.origin}${API_PATH_GEC_LIST}?pageNum=${pn}&pageSize=${pageSize}${acParam}`; let json; try { json = await apiFetchJSON(url); } catch (e) { throw new Error(`第 ${pn} 页请求失败:${e?.message || e}`); } if (!json || json.code !== 200) { throw new Error(`第 ${pn} 页接口异常: code=${json?.code}, msg=${json?.msg}`); } const rows = getListRows(json); console.log(`[表单4] 第 ${pn}/${totalPages} 页: ${rows.length} 行,累计 ${collectedRows + rows.length}/${total}`); if (!rows.length) { console.warn(`[表单4] 第 ${pn} 页响应为空,提前结束`); break; } collectedRows += rows.length; if (onProgress) onProgress(`第 ${pn}/${totalPages} 页: 获取详情 0/${rows.length}…`); const pageDetails = await fetchGecDetailsForRows(rows, (done, tot) => { if (onProgress) onProgress(`第 ${pn}/${totalPages} 页: 获取详情 ${done}/${tot}…`); }); allItems.push(...pageDetails); } if (total > 0) { if (collectedRows < total) { throw new Error(`抽取不完整:实抓 ${collectedRows} 行 < total ${total}`); } else if (collectedRows > total) { console.warn(`[表单4] 警告:抽取 ${collectedRows} 行 > total ${total},可能存在重复`); } else { console.log(`[表单4] ✓ 完整抽取 ${collectedRows} 行(total=${total})`); } } else { console.log(`[表单4] 抽取 ${collectedRows} 行(响应未提供 total,跳过校验)`); } return { items: allItems, total }; } async function extractSheet4(opts = {}) { const { onProgress = () => {}, noReload = false } = opts; let allItems = []; let ownerInfo = getOwnerInfo(); try { onProgress('获取项目信息…'); if (!SHARED_CACHE.account) { await ensureHash(OVERVIEW_HASH); await sleep(1200); await waitForAccount(MAX_WAIT_MS); } ownerInfo = getOwnerInfo(); let retryCount = 0; let f4Total = 0; while (retryCount <= MAX_RETRY_COUNT) { try { onProgress('跳转到建档立卡页…'); SHARED_CACHE.gecListResponse = null; SHARED_CACHE.gecListVersion = 0; await ensureHash(FILECARD_HASH); onProgress('等待表格加载…'); const tbl = await waitUntil(() => document.querySelector('.ant-table'), 20000, 500); if (!tbl) throw new Error('跳转后未找到表格容器'); await waitForCapturedHeaders(8000); onProgress('切换 40 条/页…'); await ensurePageSize40GlobalF4(); const result = await collectAllPagesF4((msg) => onProgress(msg)); allItems = result.items; f4Total = result.total; // ★ 改动:仅在"接口声明 total > 0 但实际抓取为 0"时视为异常重试; // total === 0 是合法的"本项目暂无建档立卡项目",不抛错,让上层照常生成只有表头的空表单。 if (f4Total > 0 && !allItems.length) throw new Error('抓取到 0 行'); break; } catch (e) { retryCount++; const transient = e.message.includes('超时') || e.message.includes('0 行') || e.message.includes('未找到') || e.message.includes('未收到') || e.message.includes('主动获取') || e.message.includes('请求失败') || e.message.includes('接口异常') || e.message.includes('抽取不完整'); if (retryCount <= MAX_RETRY_COUNT && transient) { console.warn(`[表单4] 第 ${retryCount} 次重试...`, e.message); SHARED_CACHE.gecListResponse = null; SHARED_CACHE.gecListVersion = 0; if (!noReload) { location.reload(); await sleep(RETRY_WAIT_MS); await ensureHash(FILECARD_HASH); await sleep(2000); } else { await sleep(RETRY_WAIT_MS); } } else { throw e; } } } if (f4Total === 0) { console.log('[表单4] total=0,本项目无建档立卡数据,将生成只有表头的空表'); } return { ok: true, allItems, ownerInfo, total: f4Total, empty: f4Total === 0 }; } catch (e) { console.error('[表单4] 抽取失败:', e); return { ok: false, error: e?.message || String(e), ownerInfo }; } } function formatGridDate(s) { if (!s) return ''; const str = String(s); const tIdx = str.indexOf('T'); if (tIdx >= 0) return str.slice(0, tIdx); return str.slice(0, 10); } function buildSheet4Worksheet(extractResult) { const allItems = extractResult.allItems; const dataRows = allItems.map(item => { const detail = item?._detail || null; const list = item?._list || null; const pick = (field) => { if (detail && detail[field] !== undefined && detail[field] !== null && detail[field] !== '') return detail[field]; if (list && list[field] !== undefined && list[field] !== null && list[field] !== '') return list[field]; return ''; }; return SHEET4_HEADERS.map(h => { switch (h) { case '项目业主': return detail?.ownerName || ''; case '信用代码/身份证号': return detail?.ownerCode || ''; case '绿证账户编码': return detail?.gecUniqueCode || ''; case '项目名称': return pick('projectName'); case '建档立卡编码': return pick('projectCode'); case '项目接网类型': return detail?.projectRunStatus || ''; case '项目并网容量': { const n = toNumberMaybe(pick('projectSize')); return n !== null ? n : (pick('projectSize') || ''); } case '项目并网时间': return formatGridDate(detail?.gridDate); case '项目状态': return detail?.projectStatus || ''; case '是否补贴': return detail?.subsidyProject || ''; case '发电户号': return detail?.accountNum || ''; case '项目联系人': return detail?.contactPerson || ''; case '联系方式': return detail?.contactTel || ''; default: return ''; } }); }); const aoa = [SHEET4_HEADERS, ...dataRows]; const forceTextCols = SHEET4_HEADERS .map((h, i) => SHEET4_TEXT_HEADERS.has(h) ? i : -1) .filter(i => i >= 0); const ws = aoaToSheetTyped(aoa, { forceTextCols }); forceConvertNumberCells(ws, forceTextCols); ws['!cols'] = autoCols(SHEET4_HEADERS, dataRows); applyWrap(ws); return { ws, sheetName: safeSheetName(SHEET4_NAME), rowCount: dataRows.length }; } // ===================================================================== // ★ 表单5(临期绿证预警) - 抽取与构建 ★ // ===================================================================== /** * 表单5 数据抽取:通过 API 直接分页拉取 imminentWarning 列表 */ async function extractSheet5(opts = {}) { const { onProgress = () => {}, noReload = false } = opts; let ownerInfo = getOwnerInfo(); try { onProgress('检查页面…'); if (!location.hash.includes(OVERVIEW_HASH)) { onProgress('跳转到总览页…'); await ensureHash(OVERVIEW_HASH); } onProgress('等待 account 响应…'); await waitForAccount(MAX_WAIT_MS); ownerInfo = getOwnerInfo(); const ownerName = SHARED_CACHE.account?.data?.ownerName || ownerInfo.owner || ''; // ── 通过 API 直接获取所有 imminentWarning 列表数据 ── const accountCode = getAccountCodeForGec(); if (!accountCode) throw new Error('无法获取 accountCode'); const pageSize = 100; const allWarningRows = []; let pageNum = 1; let total = 0; onProgress('通过 API 拉取临期预警列表…'); while (pageNum <= MAX_PAGES) { const url = `${location.origin}${API_PATH_IMMINENT_WARNING}?accountCode=${encodeURIComponent(accountCode)}&pageNum=${pageNum}&pageSize=${pageSize}`; const json = await apiFetchJSON(url); // 兼容两种响应格式:{ rows: [...] } 和 { data: [...] } const rows = Array.isArray(json?.rows) ? json.rows : Array.isArray(json?.data) ? json.data : null; if (!rows) { if (pageNum === 1) throw new Error(`imminentWarning 接口返回格式异常: ${JSON.stringify(json).slice(0, 200)}`); break; } allWarningRows.push(...rows); total = Number(json.total) || rows.length; onProgress(`拉取列表 ${allWarningRows.length}/${total}…`); if (allWarningRows.length >= total || rows.length === 0) break; pageNum++; } if (!allWarningRows.length) { return { ok: true, sheet5Rows: [], total: 0, ownerInfo, empty: true }; } console.log(`[表单5] API 共拉取 ${allWarningRows.length}/${total} 条预警数据`); // ── 组装数据 ── const sheet5Rows = allWarningRows.map(row => ({ ...row, _ownerName: ownerName, })); return { ok: true, sheet5Rows, total, ownerInfo, empty: false }; } catch (e) { console.error('[表单5] 抽取失败:', e); return { ok: false, error: e?.message || String(e), ownerInfo }; } } function buildSheet5Worksheet(extractResult) { const sheet5Rows = extractResult.sheet5Rows || []; const numericSet = SHEET5_NUMERIC_FIELDS; const aoa = [SHEET5_HEADERS.slice()]; for (const row of sheet5Rows) { const line = SHEET5_HEADERS.map(h => { const mapping = SHEET5_FIELD_MAP[h]; if (!mapping) return ''; let v; if (mapping.source === 'account') { v = row._ownerName || ''; } else if (mapping.source === 'warning') { v = row[mapping.field]; } if (v === null || v === undefined || v === '') return ''; if (numericSet.has(mapping.field)) { const n = toNumberMaybe(v); return n === null ? '' : n; } return String(v); }); aoa.push(line); } const forceTextCols = SHEET5_TEXT_COLS.map(h => SHEET5_HEADERS.indexOf(h)).filter(i => i >= 0); const ws = aoaToSheetTyped(aoa, { forceTextCols }); forceConvertNumberCells(ws, forceTextCols); ws['!cols'] = autoCols(SHEET5_HEADERS, aoa.slice(1)); applyWrap(ws); return { ws, sheetName: safeSheetName(SHEET5_NAME), rowCount: sheet5Rows.length }; } // ===================================================================== // ★ 单表导出(分项按钮使用) ★ // ===================================================================== async function exportSingle(sheetIndex, btn) { const setBusy = (text) => setBtnBusy(btn, true, text); try { // ★ 先确保 XLSX 库已就绪(应对 cdn.sheetjs.com 在部分区域不可达) if (!isXLSXReady()) { const ok = await ensureXLSXLoaded(setBusy); if (!ok) { throw new Error('XLSX 库加载失败:所有 CDN 均不可达,请检查网络或更换网络后重试'); } } let extractResult, buildResult; let label = ''; let emptyNote = ''; switch (sheetIndex) { case 1: extractResult = await extractSheet1({ onProgress: setBusy, noReload: false }); if (!extractResult.ok) throw new Error(extractResult.error); buildResult = buildSheet1Worksheet(extractResult); label = '总览'; break; case 2: extractResult = await extractSheet2({ onProgress: setBusy, noReload: false }); if (!extractResult.ok) throw new Error(extractResult.error); buildResult = buildSheet2Worksheet(extractResult); label = '核发明细'; if (extractResult.empty) emptyNote = '本项目无核发明细数据(已生成只含表头的空表)'; break; case 3: extractResult = await extractSheet3({ onProgress: setBusy }); if (!extractResult.ok) throw new Error(extractResult.error); // ★ 改动:即使无数据也照常生成只含表头的空表,避免下游使用 xlsx 时缺少工作表/表头 buildResult = buildSheet3Worksheet(extractResult); label = '核销明细'; if (extractResult.empty) emptyNote = '本项目无超期核销数据(已生成只含表头的空表)'; break; case 4: extractResult = await extractSheet4({ onProgress: setBusy, noReload: false }); if (!extractResult.ok) throw new Error(extractResult.error); buildResult = buildSheet4Worksheet(extractResult); label = '建档立卡明细'; if (extractResult.empty) emptyNote = '本项目无建档立卡数据(已生成只含表头的空表)'; break; case 5: extractResult = await extractSheet5({ onProgress: setBusy, noReload: false }); if (!extractResult.ok) throw new Error(extractResult.error); buildResult = buildSheet5Worksheet(extractResult); label = '临期预警'; if (extractResult.empty) emptyNote = '本项目无临期预警数据(已生成只含表头的空表)'; break; default: throw new Error('未知表单索引'); } setBusy('生成 Excel…'); const wb = XLSX.utils.book_new(); if (sheetIndex === 1) enableFullCalcOnLoad(wb); XLSX.utils.book_append_sheet(wb, buildResult.ws, buildResult.sheetName); const owner = safeFilenamePart(extractResult.ownerInfo?.owner || '项目业主未知'); const region = safeFilenamePart(extractResult.ownerInfo?.region || '所在地区未知'); const ts = formatTimestampYYYYMMDD_HHMM_V(new Date()); const filename = `${owner}+${region}+${label}+${ts}.xlsx`; XLSX.writeFile(wb, filename, { bookType: 'xlsx', cellStyles: true, compression: true }); showSummaryModal({ title: '导出完成', filename, results: [{ name: buildResult.sheetName, ok: true, rowCount: buildResult.rowCount, note: emptyNote || undefined, }], }); } catch (e) { console.error(`[分项导出 ${sheetIndex}] 失败:`, e); showSummaryModal({ title: '导出失败', filename: '', results: [{ name: `表单${sheetIndex}`, ok: false, error: e?.message || String(e) }], }); } finally { setBtnBusy(btn, false); } } // ===================================================================== // ★ 一键全量导出 ★ // ===================================================================== async function exportAll(btn) { const setBusy = (text) => setBtnBusy(btn, true, text); const results = []; let ownerInfoFinal = { owner: '', region: '', code: '' }; let filename = ''; let appended = 0; try { // ★ 先确保 XLSX 库已就绪(应对 cdn.sheetjs.com 在部分区域不可达) if (!isXLSXReady()) { const ok = await ensureXLSXLoaded(setBusy); if (!ok) { showSummaryModal({ title: '导出失败', filename: '', results: [{ name: '【XLSX 库加载】', ok: false, error: '所有 CDN 均不可达,无法加载 XLSX 库。请检查网络或更换网络后重试。', }], }); setBtnBusy(btn, false); return; } } setBusy('[1/5] 表单1 总览:开始…'); const r1 = await extractSheet1({ onProgress: (t) => setBusy(`[1/5] ${t}`), noReload: true }); if (r1.ok) { try { const b1 = buildSheet1Worksheet(r1); results.push({ name: SHEET1_NAME, ok: true, rowCount: b1.rowCount, ws: b1.ws, sheetName: b1.sheetName }); ownerInfoFinal = r1.ownerInfo || ownerInfoFinal; } catch (e) { results.push({ name: SHEET1_NAME, ok: false, error: '构建工作表失败:' + (e?.message || String(e)) }); } } else { results.push({ name: SHEET1_NAME, ok: false, error: r1.error }); if (r1.ownerInfo) ownerInfoFinal = r1.ownerInfo; } setBusy('[2/5] 表单2 核发明细:开始…'); const r2 = await extractSheet2({ onProgress: (t) => setBusy(`[2/5] ${t}`), noReload: true }); if (r2.ok) { try { const b2 = buildSheet2Worksheet(r2); results.push({ name: SHEET2_NAME, ok: true, rowCount: b2.rowCount, ws: b2.ws, sheetName: b2.sheetName, note: r2.empty ? '本项目无核发明细数据(已生成只含表头的空表)' : '', }); if (r2.ownerInfo?.owner) ownerInfoFinal = r2.ownerInfo; } catch (e) { results.push({ name: SHEET2_NAME, ok: false, error: '构建工作表失败:' + (e?.message || String(e)) }); } } else { results.push({ name: SHEET2_NAME, ok: false, error: r2.error }); } setBusy('[3/5] 表单3 核销明细:开始…'); const r3 = await extractSheet3({ onProgress: (t) => setBusy(`[3/5] ${t}`) }); if (r3.ok) { // ★ 改动:无论是否有数据,都生成只含表头的工作表并 append 到合并 xlsx try { const b3 = buildSheet3Worksheet(r3); const notes = []; if (r3.empty) notes.push('本项目无超期核销数据(已生成只含表头的空表)'); if (r3.detailFailCount) notes.push(`有 ${r3.detailFailCount} 条详情获取失败`); results.push({ name: SHEET3_NAME, ok: true, rowCount: b3.rowCount, ws: b3.ws, sheetName: b3.sheetName, note: notes.join(';'), }); if (r3.ownerInfo?.owner) ownerInfoFinal = r3.ownerInfo; } catch (e) { results.push({ name: SHEET3_NAME, ok: false, error: '构建工作表失败:' + (e?.message || String(e)) }); } } else { results.push({ name: SHEET3_NAME, ok: false, error: r3.error }); } setBusy('[4/5] 表单4 建档立卡:开始…'); const r4 = await extractSheet4({ onProgress: (t) => setBusy(`[4/5] ${t}`), noReload: true }); if (r4.ok) { try { const b4 = buildSheet4Worksheet(r4); results.push({ name: SHEET4_NAME, ok: true, rowCount: b4.rowCount, ws: b4.ws, sheetName: b4.sheetName, note: r4.empty ? '本项目无建档立卡数据(已生成只含表头的空表)' : '', }); if (r4.ownerInfo?.owner) ownerInfoFinal = r4.ownerInfo; } catch (e) { results.push({ name: SHEET4_NAME, ok: false, error: '构建工作表失败:' + (e?.message || String(e)) }); } } else { results.push({ name: SHEET4_NAME, ok: false, error: r4.error }); } setBusy('[5/5] 表单5 临期预警:开始…'); const r5 = await extractSheet5({ onProgress: (t) => setBusy(`[5/5] ${t}`), noReload: true }); if (r5.ok) { try { const b5 = buildSheet5Worksheet(r5); results.push({ name: SHEET5_NAME, ok: true, rowCount: b5.rowCount, ws: b5.ws, sheetName: b5.sheetName, note: r5.empty ? '本项目无临期预警数据(已生成只含表头的空表)' : '', }); if (r5.ownerInfo?.owner) ownerInfoFinal = r5.ownerInfo; } catch (e) { results.push({ name: SHEET5_NAME, ok: false, error: '构建工作表失败:' + (e?.message || String(e)) }); } } else { results.push({ name: SHEET5_NAME, ok: false, error: r5.error }); } setBusy('合并生成 Excel…'); const wb = XLSX.utils.book_new(); enableFullCalcOnLoad(wb); for (const r of results) { if (r.ok && r.ws) { XLSX.utils.book_append_sheet(wb, r.ws, r.sheetName); appended++; } } if (appended > 0) { const owner = safeFilenamePart(ownerInfoFinal.owner || '项目业主未知'); const region = safeFilenamePart(ownerInfoFinal.region || '所在地区未知'); const ts = formatTimestampYYYYMMDD_HHMM_V(new Date()); filename = `${owner}+${region}+${ts}.xlsx`; try { XLSX.writeFile(wb, filename, { bookType: 'xlsx', cellStyles: true, compression: true }); } catch (e) { console.error('[一键导出] 写文件失败:', e); results.push({ name: '【文件写入】', ok: false, error: '生成 xlsx 文件失败:' + (e?.message || String(e)) }); filename = ''; } } } catch (e) { console.error('[一键导出] 未预期异常:', e); results.push({ name: '【一键导出流程】', ok: false, error: e?.message || String(e) }); } finally { setBtnBusy(btn, false); } // 汇总文案:基于"表单数"统计;空表(empty)仍计为成功 const totalSheets = results.filter(r => r.name && !r.name.startsWith('【')).length; showSummaryModal({ title: appended > 0 ? `导出完成(${appended} / ${totalSheets} 个表单成功)` : '导出失败', filename, results, }); } // ===================================================================== // UI 样式 // ===================================================================== GM_addStyle(` .gec-merged-btn{ position:fixed; color:#fff; border:none; padding:10px 16px; border-radius:24px; cursor:grab; font-size:13px; font-weight:bold; box-shadow:0 4px 12px rgba(0,0,0,.18); z-index:999999; transition:transform .2s, box-shadow .2s, opacity .2s; display:flex; align-items:center; user-select:none; white-space:nowrap; box-sizing:border-box; max-width:calc(100vw - 32px); } .gec-merged-btn:hover{ transform:translateY(-1px); box-shadow:0 6px 15px rgba(0,0,0,.22); } .gec-merged-btn:active{ transform:translateY(1px); cursor:grabbing; } .gec-merged-btn.busy{ opacity:.78; cursor:progress; transform:none; } .gec-merged-btn::before{ content:attr(data-icon); margin-right:6px; font-size:16px; } .gec-merged-btn.btn-all { background:#1677ff; } .gec-merged-btn.btn-sheet1 { background:#D97757; } .gec-merged-btn.btn-sheet2 { background:#52c41a; } .gec-merged-btn.btn-sheet3 { background:#fa541c; } .gec-merged-btn.btn-sheet4 { background:#722ed1; } .gec-merged-btn.btn-sheet5 { background:#13c2c2; } @media (max-width: 600px){ .gec-merged-btn{ padding:7px 11px; font-size:12px; border-radius:18px; } .gec-merged-btn::before{ margin-right:4px; font-size:13px; } } @media (max-width: 380px){ .gec-merged-btn{ padding:6px 9px; font-size:11px; } } .gec-summary-mask{ position:fixed; top:0;left:0;right:0;bottom:0; background:rgba(0,0,0,.45); z-index:9999999; display:flex; align-items:center; justify-content:center; animation:gec-fade-in .15s ease-out; } @keyframes gec-fade-in{ from{opacity:0} to{opacity:1} } .gec-summary-dialog{ background:#fff; border-radius:10px; width:min(520px, calc(100vw - 32px)); max-height:calc(100vh - 64px); overflow:hidden; display:flex; flex-direction:column; box-shadow:0 18px 60px rgba(0,0,0,.3); font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif; color:#333; } .gec-summary-header{ padding:14px 18px; border-bottom:1px solid #f0f0f0; display:flex; align-items:center; justify-content:space-between; } .gec-summary-title{ font-size:15px; font-weight:bold; color:#262626; } .gec-summary-close{ cursor:pointer; background:transparent; border:0; font-size:18px; color:#999; padding:4px 8px; line-height:1; border-radius:4px; } .gec-summary-close:hover{ background:#f5f5f5; color:#333; } .gec-summary-body{ padding:8px 0; overflow:auto; flex:1 1 auto; } .gec-summary-row{ padding:10px 18px; display:flex; align-items:flex-start; gap:10px; border-bottom:1px solid #fafafa; font-size:13px; line-height:1.5; } .gec-summary-row:last-child{ border-bottom:none; } .gec-summary-icon{ flex:0 0 20px; font-size:16px; line-height:20px; text-align:center; } .gec-summary-content{ flex:1 1 auto; min-width:0; word-break:break-all; } .gec-summary-name{ font-weight:600; color:#262626; } .gec-summary-meta{ font-size:12px; color:#666; margin-top:2px; } .gec-summary-error{ font-size:12px; color:#cf1322; margin-top:2px; } .gec-summary-note{ font-size:12px; color:#d48806; margin-top:2px; } .gec-summary-footer{ padding:10px 18px 14px; border-top:1px solid #f0f0f0; display:flex; align-items:center; justify-content:space-between; gap:12px; } .gec-summary-filename{ font-size:12px; color:#666; word-break:break-all; flex:1 1 auto; min-width:0; } .gec-summary-ok-btn{ flex:0 0 auto; padding:6px 16px; border-radius:6px; border:0; background:#1677ff; color:#fff; font-size:13px; cursor:pointer; } .gec-summary-ok-btn:hover{ background:#4096ff; } @media (max-width: 600px){ .gec-summary-dialog{ width:calc(100vw - 24px); } .gec-summary-row{ padding:10px 14px; } .gec-summary-header{ padding:12px 14px; } .gec-summary-footer{ padding:10px 14px 12px; flex-direction:column; align-items:stretch; } .gec-summary-ok-btn{ width:100%; padding:8px; } } `); // ===================================================================== // 摘要弹窗 // ===================================================================== function showSummaryModal({ title, filename, results }) { const old = document.querySelector('.gec-summary-mask'); if (old) old.remove(); const mask = document.createElement('div'); mask.className = 'gec-summary-mask'; const dlg = document.createElement('div'); dlg.className = 'gec-summary-dialog'; mask.appendChild(dlg); const header = document.createElement('div'); header.className = 'gec-summary-header'; const titleEl = document.createElement('div'); titleEl.className = 'gec-summary-title'; titleEl.textContent = title || '导出摘要'; const closeBtn = document.createElement('button'); closeBtn.className = 'gec-summary-close'; closeBtn.textContent = '×'; closeBtn.title = '关闭'; header.appendChild(titleEl); header.appendChild(closeBtn); dlg.appendChild(header); const body = document.createElement('div'); body.className = 'gec-summary-body'; dlg.appendChild(body); const failedNames = []; for (const r of results) { const row = document.createElement('div'); row.className = 'gec-summary-row'; const icon = document.createElement('div'); icon.className = 'gec-summary-icon'; if (r.ok) { if (r.skip) { icon.textContent = '⚠'; icon.style.color = '#d48806'; } else { icon.textContent = '✔'; icon.style.color = '#52c41a'; } } else { icon.textContent = '✖'; icon.style.color = '#cf1322'; failedNames.push(r.name); } const content = document.createElement('div'); content.className = 'gec-summary-content'; const nameEl = document.createElement('div'); nameEl.className = 'gec-summary-name'; nameEl.textContent = r.name; content.appendChild(nameEl); if (r.ok) { const meta = document.createElement('div'); meta.className = 'gec-summary-meta'; if (r.skip) { meta.textContent = r.note || '已跳过'; } else { meta.textContent = (typeof r.rowCount === 'number') ? `数据行数:${r.rowCount}` : '已导出'; } content.appendChild(meta); if (r.note && !r.skip) { const note = document.createElement('div'); note.className = 'gec-summary-note'; note.textContent = '提示:' + r.note; content.appendChild(note); } } else { const err = document.createElement('div'); err.className = 'gec-summary-error'; err.textContent = '失败原因:' + (r.error || '未知'); content.appendChild(err); } row.appendChild(icon); row.appendChild(content); body.appendChild(row); } if (failedNames.length) { const warn = document.createElement('div'); warn.className = 'gec-summary-row'; warn.style.background = '#fff2f0'; const icon = document.createElement('div'); icon.className = 'gec-summary-icon'; icon.textContent = '!'; icon.style.color = '#cf1322'; const content = document.createElement('div'); content.className = 'gec-summary-content'; const nameEl = document.createElement('div'); nameEl.className = 'gec-summary-name'; nameEl.style.color = '#cf1322'; nameEl.textContent = `以下表单未成功导出:${failedNames.join('、')}`; content.appendChild(nameEl); warn.appendChild(icon); warn.appendChild(content); body.insertBefore(warn, body.firstChild); } const footer = document.createElement('div'); footer.className = 'gec-summary-footer'; const fname = document.createElement('div'); fname.className = 'gec-summary-filename'; fname.textContent = filename ? `文件:${filename}` : '(未生成文件)'; fname.title = filename || ''; const okBtn = document.createElement('button'); okBtn.className = 'gec-summary-ok-btn'; okBtn.textContent = '确定'; footer.appendChild(fname); footer.appendChild(okBtn); dlg.appendChild(footer); const close = () => { mask.remove(); }; closeBtn.addEventListener('click', close); okBtn.addEventListener('click', close); mask.addEventListener('click', (e) => { if (e.target === mask) close(); }); document.addEventListener('keydown', function escClose(ev) { if (ev.key === 'Escape') { close(); document.removeEventListener('keydown', escClose); } }); (document.body || document.documentElement).appendChild(mask); } // ===================================================================== // 按钮辅助函数 // ===================================================================== let GLOBAL_BUSY = false; function setGlobalBusy(on) { GLOBAL_BUSY = !!on; BUTTON_CONFIGS.forEach(cfg => { const el = document.getElementById(cfg.id); if (!el) return; if (on) { if (!el.classList.contains('busy')) { el.dataset.disabledByOther = '1'; el.style.opacity = '.4'; el.style.pointerEvents = 'none'; } } else { if (el.dataset.disabledByOther === '1') { delete el.dataset.disabledByOther; el.style.opacity = ''; el.style.pointerEvents = ''; } } }); } function setBtnBusy(btn, on, text) { if (!btn) return; if (on) { btn.classList.add('busy'); if (!btn.dataset.oldText) btn.dataset.oldText = btn.textContent; btn.textContent = text || '执行中…'; setGlobalBusy(true); } else { btn.classList.remove('busy'); if (btn.dataset.oldText) { btn.textContent = btn.dataset.oldText; delete btn.dataset.oldText; } setGlobalBusy(false); } } function clampAllToViewport() { // 把所有按钮约束到视口内:按组整体平移(保持相对位置) const nodes = BUTTON_CONFIGS .map(c => document.getElementById(c.id)) .filter(Boolean); if (!nodes.length) return; // 当前组的包围盒 let minL = Infinity, minT = Infinity, maxR = -Infinity, maxB = -Infinity; const rects = nodes.map(el => { const r = el.getBoundingClientRect(); minL = Math.min(minL, r.left); minT = Math.min(minT, r.top); maxR = Math.max(maxR, r.right); maxB = Math.max(maxB, r.bottom); return { el, left: r.left, top: r.top }; }); let dx = 0, dy = 0; if (minL < 0) dx = -minL; else if (maxR > window.innerWidth) dx = window.innerWidth - maxR; if (minT < 0) dy = -minT; else if (maxB > window.innerHeight) dy = window.innerHeight - maxB; if (dx === 0 && dy === 0) return; rects.forEach(p => { p.el.style.left = `${p.left + dx}px`; p.el.style.top = `${p.top + dy}px`; p.el.style.right = 'auto'; p.el.style.bottom = 'auto'; }); } /** * ★ 整组拖动:按下任一按钮时,整组同步位移、保持相对位置不变 */ function addGroupDrag(el, onClick, storeKey) { let dragging = false; let startX = 0, startY = 0, moved = false; let startSnapshots = []; // [{el, left, top, w, h}] let boundMinDx = 0, boundMaxDx = 0, boundMinDy = 0, boundMaxDy = 0; const DRAG_THRESHOLD = 5; el.addEventListener('mousedown', (e) => { if (el.classList.contains('busy')) return; // 其它按钮执行中时,当前按钮处于 disabledByOther 状态,pointerEvents 已为 none, // 保险起见这里也拦截一次 if (GLOBAL_BUSY) return; dragging = true; moved = false; startX = e.clientX; startY = e.clientY; // 快照所有按钮当前位置 startSnapshots = BUTTON_CONFIGS.map(c => { const b = document.getElementById(c.id); if (!b) return null; const r = b.getBoundingClientRect(); return { el: b, left: r.left, top: r.top, w: b.offsetWidth || r.width, h: b.offsetHeight || r.height, }; }).filter(Boolean); // 计算组的可移动边界:所有按钮共同满足 0 <= left+dx && left+w+dx <= innerWidth // => dx ∈ [ max(-left), min(innerWidth - w - left) ] let minDx = -Infinity, maxDx = Infinity; let minDy = -Infinity, maxDy = Infinity; for (const s of startSnapshots) { minDx = Math.max(minDx, -s.left); maxDx = Math.min(maxDx, window.innerWidth - s.w - s.left); minDy = Math.max(minDy, -s.top); maxDy = Math.min(maxDy, window.innerHeight - s.h - s.top); } boundMinDx = minDx; boundMaxDx = maxDx; boundMinDy = minDy; boundMaxDy = maxDy; startSnapshots.forEach(s => { s.el.style.transition = 'none'; }); el.style.cursor = 'grabbing'; e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!dragging) return; const dx = e.clientX - startX; const dy = e.clientY - startY; if (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD) moved = true; const clampedDx = Math.min(Math.max(dx, boundMinDx), boundMaxDx); const clampedDy = Math.min(Math.max(dy, boundMinDy), boundMaxDy); startSnapshots.forEach(s => { s.el.style.left = `${s.left + clampedDx}px`; s.el.style.top = `${s.top + clampedDy}px`; s.el.style.right = 'auto'; s.el.style.bottom = 'auto'; }); }); document.addEventListener('mouseup', () => { if (!dragging) return; dragging = false; el.style.cursor = 'grab'; startSnapshots.forEach(s => { s.el.style.transition = ''; }); if (moved) { // 保存所有按钮的新位置 BUTTON_CONFIGS.forEach(c => { const b = document.getElementById(c.id); if (!b) return; const r = b.getBoundingClientRect(); GM_setValue(c.storeKey, { left: r.left, top: r.top }); }); } startSnapshots = []; }); el.addEventListener('click', (e) => { if (moved) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); moved = false; return; } if (GLOBAL_BUSY && !el.classList.contains('busy')) return; if (el.classList.contains('busy')) return; onClick(); }, true); } function restoreOrApplyDefaultPos(el, storeKey, defaultLeft, defaultTop) { const pos = GM_getValue(storeKey, null); if (pos && typeof pos.left === 'number' && typeof pos.top === 'number') { el.style.left = `${pos.left}px`; el.style.top = `${pos.top}px`; } else { el.style.left = `${defaultLeft}px`; el.style.top = `${defaultTop}px`; } el.style.right = 'auto'; el.style.bottom = 'auto'; } // ===================================================================== // 5 个悬浮按钮配置 // ===================================================================== const BUTTON_CONFIGS = [ { id: 'gec-merged-btn-all', cls: 'btn-all', icon: '🚀', text: '一键导出全部', title: '一键导出全部5个表单到同一个xlsx文件', storeKey: 'gec_merged_btn_pos_all', defaultLeft: 16, defaultTop: 70, onClick: (btn) => exportAll(btn), }, { id: 'gec-merged-btn-1', cls: 'btn-sheet1', icon: '📊', text: '导出总览', title: '导出表单1-绿证台账总览', storeKey: 'gec_merged_btn_pos_1', defaultLeft: 16, defaultTop: 120, onClick: (btn) => exportSingle(1, btn), }, { id: 'gec-merged-btn-2', cls: 'btn-sheet2', icon: '📋', text: '导出核发明细', title: '导出表单2-绿证核发明细', storeKey: 'gec_merged_btn_pos_2', defaultLeft: 16, defaultTop: 170, onClick: (btn) => exportSingle(2, btn), }, { id: 'gec-merged-btn-3', cls: 'btn-sheet3', icon: '📛', text: '导出核销明细', title: '导出表单3-超期核销明细', storeKey: 'gec_merged_btn_pos_3', defaultLeft: 16, defaultTop: 220, onClick: (btn) => exportSingle(3, btn), }, { id: 'gec-merged-btn-4', cls: 'btn-sheet4', icon: '📁', text: '导出建档立卡明细', title: '导出表单4-建档立卡项目管理', storeKey: 'gec_merged_btn_pos_4', defaultLeft: 16, defaultTop: 270, onClick: (btn) => exportSingle(4, btn), }, { id: 'gec-merged-btn-5', cls: 'btn-sheet5', icon: '⏰', text: '导出临期预警', title: '导出表单5-临期绿证预警', storeKey: 'gec_merged_btn_pos_5', defaultLeft: 16, defaultTop: 320, onClick: (btn) => exportSingle(5, btn), }, ]; function createOneFloatingButton(cfg) { if (document.getElementById(cfg.id)) return; if (!document.body) return; const btn = document.createElement('button'); btn.id = cfg.id; btn.className = `gec-merged-btn ${cfg.cls}`; btn.textContent = cfg.text; btn.title = cfg.title; btn.dataset.icon = cfg.icon; document.body.appendChild(btn); addGroupDrag(btn, () => cfg.onClick(btn), cfg.storeKey); // ★ 新按钮若无存储位置,根据组内最后一个已存在按钮的位置自动排列 const storedPos = GM_getValue(cfg.storeKey, null); if (storedPos && typeof storedPos.left === 'number' && typeof storedPos.top === 'number') { btn.style.left = `${storedPos.left}px`; btn.style.top = `${storedPos.top}px`; } else { const prevCfg = BUTTON_CONFIGS[BUTTON_CONFIGS.indexOf(cfg) - 1]; const prevEl = prevCfg ? document.getElementById(prevCfg.id) : null; if (prevEl) { const prevRect = prevEl.getBoundingClientRect(); btn.style.left = `${prevRect.left}px`; btn.style.top = `${prevRect.top + prevEl.offsetHeight + 8}px`; } else { btn.style.left = `${cfg.defaultLeft}px`; btn.style.top = `${cfg.defaultTop}px`; } } btn.style.right = 'auto'; btn.style.bottom = 'auto'; } function createAllFloatingButtons() { BUTTON_CONFIGS.forEach(cfg => createOneFloatingButton(cfg)); requestAnimationFrame(() => clampAllToViewport()); } window.addEventListener('resize', () => { clampAllToViewport(); }); // ===================================================================== // 启动 // ===================================================================== function boot() { if (!document.body) return; createAllFloatingButtons(); } const earlyTimer = setInterval(boot, 300); document.addEventListener('DOMContentLoaded', () => boot(), { once: true }); window.addEventListener('load', () => boot(), { once: true }); const mo = new MutationObserver(() => boot()); mo.observe(document.documentElement, { childList: true, subtree: true }); setTimeout(() => clearInterval(earlyTimer), 30000); })();