// ==UserScript==
// @name 安徽OA · 亳州经营报表 XLSX
// @namespace local.ahoa.bozhou.report
// @version 1.3.2
// @description OA首页选择报表及日期,按500条串行读取亳州全部分页,本地生成XLSX。不调用系统导出。
// @match http://portal.mss.anhuitelecom.com:12104/idealprotalview/*
// @match https://nlkf-eda.anhuitelecom.com:11050/dtPortal/*
// @match http://tsfx-eda.anhuitelecom.com:12110/dashboard/*
// @grant unsafeWindow
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_getTab
// @grant GM_saveTab
// @grant GM_addValueChangeListener
// @grant GM_registerMenuCommand
// @grant GM_openInTab
// @sandbox raw
// @run-at document-idle
// @noframes
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const W = typeof unsafeWindow === 'undefined' ? window : unsafeWindow;
const KEY = 'ahoa.export.job.v1';
const APP = 'http://portal.mss.anhuitelecom.com:12104/idealprotalview/application?subscript=2';
const REPORTS = {
promo:{name:'大促发展日报(五级)',id:'vbap878e0411e1dc07938c4aa5a0',system:'每日重点经营关注',cols:73,headers:3,latn:'本地网',dates:['开始时间','结束时间'],filters:['客户群','分团队','发展渠道','四级单元类型','是否星客']},
million4:{name:'百天百万路攻坚(四级)',id:'vbapfe346f0637978fa3da47fd50',system:'企业数据仓库',cols:25,headers:2,latn:'地市',range:'日期',filters:['三级单元名称','四级单元名称','渠道','条线'],summary:'bottom',dimensions:5},
value3:{name:'价值积分(三级)',id:'vbapda46e735e0b7d78add62ee5f',system:'每日重点经营关注',cols:52,headers:3,latn:'本地网',dates:['开始日期','结束日期'],filters:['客户群','渠道','是否存量'],summary:'top',dimensions:3},
value4:{name:'价值积分(四级)',id:'vbap2f5cb8d9c5be7d9bc4a377fd',system:'每日重点经营关注',cols:54,headers:3,latn:'本地网',dates:['开始日期','结束日期'],filters:['客户群','渠道','是否存量','四级类型'],summary:'top',dimensions:5}
};
const reportOf = job => REPORTS[job?.reportKey || 'promo'];
const dashboardId = () => new URL(W.location.href).searchParams.get('dashboardId');
const PAGE_SIZE = 500;
const TTL = 30 * 60 * 1000;
const D = W.document;
if (D.getElementById('ahoa-export-root')) return;
const host = D.createElement('div');
host.id = 'ahoa-export-root';
D.documentElement.append(host);
const root = host.attachShadow({mode: 'open'});
const icon=(name,size=20)=>``;
root.innerHTML = `
${icon('chevron',18)}
${['一','二','三','四','五','六','日'].map(s=>`${s}`).join('')}
`;
const $ = s => root.querySelector(s);
$('#reportType').value=GM_getValue('ahoa.export.report','promo');
if(!REPORTS[$('#reportType').value]) $('#reportType').value='promo';
let busy = false, cancelled = false, lastFile = null, currentJob = null, autoNavTab=false;
const fallbackTabs=[];
const readJob = () => GM_getValue(KEY, null);
const isLive = j => j && Date.now() - j.created < TTL && !['done','error','cancelled'].includes(j.stage);
const saveJob = j => { GM_setValue(KEY, j); currentJob = j; return j; };
const updateJob = (j, patch) => saveJob({...j, ...patch, updated:Date.now()});
const tabState = () => new Promise(resolve => GM_getTab(resolve));
const isoDate=d=>`${String(d.getFullYear()).padStart(4,'0')}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
let calendarTarget=null, calendarMonth=null, calendarFocus=null;
function closeCalendar(focus=false){
const target=calendarTarget;calendarTarget=null;$('#calendar').hidden=true;
$('#pickStart').setAttribute('aria-expanded','false');$('#pickEnd').setAttribute('aria-expanded','false');
if(focus&&target) $('#'+target).focus({preventScroll:true});
}
function placeCalendar(){
if(!calendarTarget)return;
const rect=$('#'+calendarTarget).getBoundingClientRect(),cal=$('#calendar'),gap=8,pad=12;
const width=cal.offsetWidth,height=cal.offsetHeight;
const below=rect.bottom+gap,above=rect.top-gap-height;
cal.style.left=`${Math.max(pad,Math.min(rect.right-width,W.innerWidth-width-pad))}px`;
cal.style.top=`${Math.max(pad,Math.min(below+height<=W.innerHeight-pad?below:above,W.innerHeight-height-pad))}px`;
}
function renderCalendar(focus=false){
const year=calendarMonth.getFullYear(),month=calendarMonth.getMonth(),first=new Date(year,month,1),today=isoDate(new Date());
$('#calMonth').textContent=`${year}年 ${month+1}月`;
const begin=1-(first.getDay()+6)%7,selected=$('#'+calendarTarget).value;
$('#calDays').innerHTML=Array.from({length:42},(_,i)=>{
const day=new Date(year,month,begin+i),date=isoDate(day);
const classes=[day.getMonth()!==month?'outside':'',date===today?'today':'',date===selected?'selected':''].filter(Boolean).join(' ');
return ``;
}).join('');
placeCalendar();
if(focus) $('#calDays').querySelector(`[data-date="${calendarFocus}"]`)?.focus({preventScroll:true});
}
function openCalendar(target){
if(busy)return;
if(calendarTarget===target){closeCalendar();return;}
closeCalendar();calendarTarget=target;
const date=validDate($('#'+target).value)?new Date($('#'+target).value+'T12:00:00'):new Date();
calendarMonth=new Date(date.getFullYear(),date.getMonth(),1);calendarFocus=isoDate(date);
$('#calendar').hidden=false;$('#'+(target==='start'?'pickStart':'pickEnd')).setAttribute('aria-expanded','true');
renderCalendar(true);
}
function chooseDate(date){
if(!calendarTarget)return;
$('#'+calendarTarget).value=date;closeCalendar(true);
}
function shiftMonth(delta){calendarMonth=new Date(calendarMonth.getFullYear(),calendarMonth.getMonth()+delta,1);calendarFocus=isoDate(calendarMonth);renderCalendar();}
$('#pickStart').onclick=()=>openCalendar('start');$('#pickEnd').onclick=()=>openCalendar('end');
$('#start').onclick=()=>openCalendar('start');$('#end').onclick=()=>openCalendar('end');
$('#calPrev').onclick=()=>shiftMonth(-1);$('#calNext').onclick=()=>shiftMonth(1);
$('#calToday').onclick=()=>chooseDate(isoDate(new Date()));$('#calDone').onclick=()=>closeCalendar(true);
$('#calDays').onclick=e=>{const day=e.target.closest('button[data-date]');if(day)chooseDate(day.dataset.date);};
$('#calDays').onkeydown=e=>{
const key=e.key,delta={ArrowLeft:-1,ArrowRight:1,ArrowUp:-7,ArrowDown:7}[key];
if(delta===undefined && !['PageUp','PageDown','Home','End'].includes(key))return;
e.preventDefault();const date=new Date(calendarFocus+'T12:00:00');
if(delta!==undefined)date.setDate(date.getDate()+delta);
else if(key==='Home'||key==='End')date.setDate(date.getDate()-(date.getDay()+6)%7+(key==='End'?6:0));
else {date.setDate(1);date.setMonth(date.getMonth()+(key==='PageUp'?-1:1));}
calendarMonth=new Date(date.getFullYear(),date.getMonth(),1);calendarFocus=isoDate(date);renderCalendar(true);
};
root.onpointerdown=e=>{
if(calendarTarget && !e.composedPath().includes($('#calendar')) && !e.composedPath().includes($('#'+calendarTarget).parentElement))closeCalendar();
};
root.onkeydown=e=>{
if(e.key==='Escape'){
e.preventDefault();e.stopPropagation();if(calendarTarget)closeCalendar(true);else hide();
} else if(e.key==='Tab' && $('#shade').style.display==='flex'){
const items=[...root.querySelectorAll('#box button,#box input,#box select,#calendar button')].filter(e=>!e.disabled&&e.tabIndex!==-1&&e.getClientRects().length);
const first=items[0],last=items[items.length-1];
if(e.shiftKey&&root.activeElement===first){e.preventDefault();last?.focus();}
else if(!e.shiftKey&&root.activeElement===last){e.preventDefault();first?.focus();}
}
};
$('#shade').onclick=e=>{if(e.target===$('#shade'))hide();};
function hide(){closeCalendar();$('#shade').style.display='none';$('#entry').focus({preventScroll:true});if(currentJob?.message)$('#jobbar').style.display='block';}
// Pointer capture keeps dragging local to this one button; no idle polling.
const entry=$('#entry'),savedPosition=GM_getValue('ahoa.export.buttonPosition',null);
let position=savedPosition&&Number.isFinite(savedPosition.x)&&Number.isFinite(savedPosition.y)?savedPosition:null;
let drag=null,dragFrame=0,suppressClick=false;
const clamp=(v,min,max)=>Math.max(min,Math.min(v,Math.max(min,max)));
function placeEntry(){
if(!position)return;
const x=clamp(position.x*(W.innerWidth-entry.offsetWidth),12,W.innerWidth-entry.offsetWidth-12);
const y=clamp(position.y*(W.innerHeight-entry.offsetHeight),12,W.innerHeight-entry.offsetHeight-12);
Object.assign(entry.style,{left:x+'px',top:y+'px',right:'auto',bottom:'auto'});
}
function paintDrag(){dragFrame=0;if(!drag)return;Object.assign(entry.style,{left:drag.x+'px',top:drag.y+'px',right:'auto',bottom:'auto'});}
entry.onpointerdown=e=>{
if(e.button!==0)return;
const rect=entry.getBoundingClientRect();drag={id:e.pointerId,startX:e.clientX,startY:e.clientY,left:rect.left,top:rect.top,x:rect.left,y:rect.top,moved:false};
entry.setPointerCapture(e.pointerId);
};
entry.onpointermove=e=>{
if(!drag||e.pointerId!==drag.id)return;
const dx=e.clientX-drag.startX,dy=e.clientY-drag.startY;
if(!drag.moved&&Math.hypot(dx,dy)<5)return;
drag.moved=true;entry.classList.add('dragging');
drag.x=clamp(drag.left+dx,12,W.innerWidth-entry.offsetWidth-12);drag.y=clamp(drag.top+dy,12,W.innerHeight-entry.offsetHeight-12);
if(!dragFrame)dragFrame=W.requestAnimationFrame(paintDrag);
};
function finishDrag(e){
if(!drag||e.pointerId!==drag.id)return;
if(dragFrame){W.cancelAnimationFrame(dragFrame);dragFrame=0;}if(drag.moved)paintDrag();
if(drag.moved){position={x:drag.x/Math.max(1,W.innerWidth-entry.offsetWidth),y:drag.y/Math.max(1,W.innerHeight-entry.offsetHeight)};GM_setValue('ahoa.export.buttonPosition',position);suppressClick=e.type!=='pointercancel';setTimeout(()=>{suppressClick=false;},0);}
drag=null;entry.classList.remove('dragging');
if(entry.hasPointerCapture(e.pointerId))entry.releasePointerCapture(e.pointerId);
}
entry.onpointerup=finishDrag;entry.onpointercancel=finishDrag;
entry.onclick=()=>{if(!suppressClick)show();};
W.addEventListener('resize',()=>{placeEntry();closeCalendar();},{passive:true});
placeEntry();
const text = e => (e?.textContent || '').replace(/[\ue000-\uf8ff]/g,'').replace(/(/g,'(').replace(/)/g,')').replace(/\s+/g,'').trim();
const visible = e => e && e.getClientRects().length && W.getComputedStyle(e).visibility !== 'hidden';
const exact = (s, selectors='a,li,h1,span,div') => [...D.querySelectorAll(selectors)].filter(e => visible(e) && text(e)===s.replace(/\s+/g,'')).sort((a,b)=>a.children.length-b.children.length)[0];
function paintStatus(message,error=false) {
const display=message;
$('#status').textContent=display;
$('#status').className=display==='导出成功'?'success':error?'error':'';
$('#jobbar').textContent=display;
$('#jobbar').style.display=display&&$('#shade').style.display!=='flex'?'block':'none';
}
function status(message, error=false) {
if (currentJob && readJob()?.id===currentJob.id) currentJob=updateJob(currentJob,{message});
paintStatus(message,error);
}
function show() {
$('#shade').style.display='flex';
$('#jobbar').style.display='none';
$('#cancelTask').style.display=isLive(readJob())?'inline-flex':'none';
const last=GM_getValue('ahoa.export.dates',{}), now=new Date();
const today=`${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;
if (!$('#start').value) $('#start').value=last.start || `${now.getFullYear()}-01-01`;
if (!$('#end').value) $('#end').value=last.end || today;
if(!busy)$('#reportType').focus({preventScroll:true});
}
function setBusy(value) {
busy=value; $('#startExport').disabled=value;
$('#start').disabled=value; $('#end').disabled=value;
$('#reportType').disabled=value;
$('#pickStart').disabled=value;$('#pickEnd').disabled=value;
$('#startExport').textContent=value?'正在导出…':lastFile?'再次导出':'开始导出';
$('#cancelTask').style.display=value||isLive(readJob())?'inline-flex':'none';
$('#cancelTask').disabled=false;
if(value){closeCalendar();$('#close').focus({preventScroll:true});}
}
function check() {
if (cancelled || (currentJob && readJob()?.id!==currentJob.id) || readJob()?.stage==='cancelled') throw new Error('已停止导出,未生成不完整文件。');
}
async function waitFor(fn, label, ms=60000) {
const end=Date.now()+ms;
while(Date.now()setTimeout(r,350)); }
throw new Error(`等待${label}超时。请检查登录状态或页面是否改版。`);
}
async function timed(promise, label) {
let timer;
try { return await Promise.race([promise,new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error(`${label}超过120秒,已停止。请稍后重新导出。`)),120000);})]); }
finally { clearTimeout(timer); }
}
function download(file) {
const a=D.createElement('a'), url=URL.createObjectURL(file.blob);
a.href=url; a.download=file.name; D.body.append(a); a.click(); a.remove();
setTimeout(()=>URL.revokeObjectURL(url),60000);
}
function fail(e) {
const detail=e?.message || String(e),message=cancelled?'已停止':'导出失败';
if(currentJob && readJob()?.id===currentJob.id) currentJob=updateJob(currentJob,{stage:cancelled?'cancelled':'error',message,errorDetail:detail});
W.console?.warn('[经营报表导出]',detail);
setBusy(false); show(); status(message,!cancelled);
}
// Keep the portal's two-step SSO intact: the source page must stay alive for
// its delayed second window.open. Reuse one named target for each SSO stage.
function trackPopups(job,stage) {
const original=W.open;
const replacement=function(url,_target,features){
if(url && url!=='about:blank') {
const u=new URL(String(url),W.location.href);
if(!/^https?:$/.test(u.protocol) || !/(^|\.)anhuitelecom\.com$/.test(u.hostname)) throw new Error('页面跳转到了未知站点,已停止自动导航。');
}
const child=original.call(W,url,`ahoa-${stage}-${job.id}`,features);
if(child) return child;
const fallback=GM_openInTab(String(url),{active:true,insert:true,setParent:true});
fallbackTabs.push(fallback);
status('正在通过篡改猴打开认证页面…');
return fallback;
};
W.open=replacement;
const restore=()=>{if(W.open===replacement)W.open=original;};
setTimeout(restore,30000);
return restore;
}
function monitorNavigation(job,child) {
let attempts=0;
const inspect=()=>{
const latest=readJob();
if(latest?.id!==job.id || !isLive(latest) || latest.stage==='query') return;
if(child.closed) {currentJob=latest;fail(new Error('自动导出标签页已关闭。'));return;}
const idle=Date.now()-(latest.updated||latest.created);
if(idle<18000){setTimeout(inspect,20000-idle);return;}
if(attempts>=2){currentJob=latest;fail(new Error('OA跳转连续失败,自动重试2次后仍未进入报表。请稍后重新点击导出。'));return;}
attempts++;
currentJob=updateJob(latest,{stage:'oa',navigationRetries:attempts,message:`OA跳转未完成,正在第${attempts}/2次重新点击入口…`});
$('#jobbar').textContent=currentJob.message;
try {child.location.href=`${APP}#ahoa-export=${encodeURIComponent(job.id)}`;}
catch(e){fail(new Error('无法重新打开导航标签页,请重新点击导出。'));return;}
setTimeout(inspect,20000);
};
setTimeout(inspect,20000);
}
async function navigateOA(job) {
currentJob=job; $('#jobbar').style.display='block';
status('正在进入:应用导航 → 每日经营关注…');
const restore=trackPopups(job,'dt');
try {
const daily=await waitFor(()=>exact('每日经营关注','a'),'每日经营关注入口');
currentJob=updateJob(currentJob,{stage:'dt'});
daily.click();
} finally { setTimeout(restore,30000); }
}
async function navigateDT(job) {
currentJob=job; $('#jobbar').style.display='block';
const report=reportOf(job);
if(!report) throw new Error('未知报表类型,请重新选择。');
status(`正在进入:${report.system} → ${report.name}…`);
const restore=trackPopups(job,'report');
try {
// Follow the live menu tree, using menu IDs to disambiguate repeated names.
const findPath=(nodes,path=[])=>{
for(const node of nodes||[]){const next=[...path,node];if((node.url||'').includes(`dashboardId=${report.id}`))return next;const hit=findPath(node.children,next);if(hit)return hit;}
return null;
};
const portal=await waitFor(()=>D.querySelector('.container')?.__vue__,'门户初始化');
// Left-menu buttons appear before the portal's initial menu request has
// finished. Wait for that request's result before deciding to switch.
await waitFor(()=>portal.menuData?.length && portal.headerArr?.[0]?.text,'初始菜单');
await portal.$nextTick();
// Clicking the already selected system starts another asynchronous reload
// which can overwrite the following submenu click.
if(portal.headerArr?.[0]?.text!==report.system || !findPath(portal.menuData)){
const system=await waitFor(()=>exact(report.system,'li,a,span'),report.system);
system.click();
}
const path=await waitFor(()=>portal.headerArr?.[0]?.text===report.system && findPath(portal.menuData),'报表菜单');
await portal.$nextTick();
const menuElement=node=>[...D.querySelectorAll('[data-menu-id]')].find(e=>e.getAttribute('data-menu-id')===String(node.id)&&visible(e));
for(let step=0;stepn.id===ancestor.id)+1];
menuElement(ancestor).click();
await waitFor(()=>menuElement(next),'下级菜单',15000);
}
throw new Error('菜单层级发生变化,请重新导出。');
} finally { setTimeout(restore,20000); }
}
function getTable() {
const tables=[...D.querySelectorAll('.table-box')].map(e=>e.__vue__).filter(v=>v?.$options?.name==='TableSummaryCom');
if(tables.length!==1) return null;
const t=tables[0];
return typeof t.requestParams==='function' && typeof t.__BI__?.queryDataUseDict==='function' && t.dataInitialized && t.dataMatrix?.length>=3 ? t : null;
}
function queryVM(label) {return [...D.querySelectorAll('.query-com-wrapper')].find(e=>(e.innerText||'').replace(/\s+/g,'').startsWith(label))?.__vue__;}
async function applyFilters(job) {
const report=reportOf(job);
for(const label of [...(report.dates||[report.range]),...report.filters,report.latn]) {
const v=queryVM(label);
if(!v?.newQuery || !v.inputValue) throw new Error(`找不到${label}筛选组件,已停止。`);
let value;
if(label===report.range){
v.$set(v.inputValue,'value0start',job.start);v.$set(v.inputValue,'value0end',job.end);value=[job.start,job.end];
} else {
value=label===report.dates?.[0]?job.start:label===report.dates?.[1]?job.end:label===report.latn?'亳州':'checkedAllOption';
if(!report.dates?.includes(label)&&Array.isArray(v.inputValue.value0)) value=[value];
if(!report.dates?.includes(label)&&!v.inputOption?.option0?.some(o=>o.value===(label===report.latn?'亳州':'checkedAllOption'))) throw new Error(`${label}缺少预期选项,已停止。`);
v.$set(v.inputValue,'value0',value);
}
v.newQuery(0,value);
await v.$nextTick();
}
}
async function exportReport(job) {
currentJob=job; cancelled=false;
$('#reportType').value=job.reportKey||'promo';$('#start').value=job.start;$('#end').value=job.end;
show(); setBusy(true); lastFile=null; $('#again').style.display='none';
try {
const report=reportOf(job);
if(!report || dashboardId()!==report.id) throw new Error('当前页面与所选报表不匹配。');
currentJob=updateJob(currentJob,{stage:'query'});
status('等待报表初始化…');
const t=await waitFor(getTable,'报表数据和筛选组件');
await waitFor(()=>queryVM(report.latn)?.inputOption?.option0?.length && queryVM(report.range||report.dates[1])?.inputValue,'筛选项初始化');
await applyFilters(job);
const headerCount=Number(t.settings.fixedRowsTop);
if(headerCount!==report.headers || t.tableHeaderCodes?.length!==report.cols) throw new Error('报表的表头或列数已变化,需更新脚本以避免错列。');
const headers=t.dataMatrix.slice(0,headerCount).map(r=>r.map(c=>c?.value??''));
const merges=t.cellsMergeConfig.filter(m=>!m.removed && m.row({...m}));
const formats=Array.from({length:report.cols},(_,col)=>{
const c=t.dataMatrix.slice(headerCount).map(r=>r[col]).find(c=>typeof c?.innerContent==='string' && /^-?\d+\.\d+$/.test(c.innerContent));
return c ? Math.min(10,c.innerContent.split('.')[1].length) : 0;
});
const base=JSON.parse(JSON.stringify(t.requestParams({type:'pageChange',flag:true})));
t.handleDealSort(base);
const condition=base.conditions?.find(c=>c.queryField?.code==='latn_name');
if(condition?.oprand!=='亳州' && JSON.stringify(condition?.oprand)!=='["亳州"]') throw new Error('查询条件中没有正确设置亳州,已停止。');
if(report.summary && !t.getCalcType?.some(c=>c.type==='rowTotal'&&c.show&&c.location===report.summary&&c.isQueryAll&&c.displayName==='合计')) throw new Error('报表合计配置变化,已停止。');
const filterStamp=JSON.stringify([...D.querySelectorAll('.query-com-wrapper')].map(e=>e.__vue__?.inputValue));
const parts=[]; let total=null, count=0, written=0, pages=0, requestConfig=null, preview=null, summary=null; const signatures=new Set();
for(let offset=0; total===null || offsete.__vue__?.inputValue))) throw new Error('筛选条件发生变化,已停止本次导出。');
status(`正在读取第 ${pages+1} 页(500条/页)${total===null?'':`,已读取 ${count} / ${total} 条`}…`);
const req=JSON.parse(JSON.stringify(base)); req.pageInfo={limit:PAGE_SIZE,offset};
// Same query client as the visible table. This never calls any export endpoint.
const response=requestConfig
? await readNextPage(requestConfig,offset)
: await timed(t.__BI__.queryDataUseDict(req,t.comId,true),`第${pages+1}页查询`);
if(!requestConfig) requestConfig=response?.config;
check();
const data=response?.data;
if(!Array.isArray(data?.data)) throw new Error('查询没有返回表格数据,登录可能已过期。');
const found=Number(data.totalCount);
if(!Number.isSafeInteger(found)||found<0) throw new Error('返回的总条数无效。');
if(total===null) total=found;
if(found!==total) throw new Error('分页过程中总条数改变,请重新查询。');
if(total+headerCount+(report.summary?1:0)>1048576) throw new Error('记录数超过Excel单个工作表上限。');
let rows=data.data;
if(report.summary){
const index=report.summary==='top'?0:rows.length-1, candidate=rows[index];
if(!Array.isArray(candidate)||candidate.length!==report.cols||candidate[0]!=='合计'||candidate.slice(1,report.dimensions).some(v=>v!==null)) throw new Error('返回的合计行结构异常,已停止。');
if(summary && JSON.stringify(summary)!==JSON.stringify(candidate)) throw new Error('分页过程中合计发生变化,请重新查询。');
summary=summary||candidate; rows=rows.filter((_,i)=>i!==index);
if(pages===0 && report.summary==='top'){parts.push(rowsXML([summary],headerCount,0,formats));written++;}
}
if(preview===null) preview={...data,data:report.summary==='top'?[summary,...rows.slice(0,20)]:report.summary==='bottom'?[...rows.slice(0,20),summary]:rows.slice(0,20)};
const expected=Math.min(PAGE_SIZE,total-offset);
if(rows.length!==expected) throw new Error(`第${pages+1}页应有${expected}条,实际${rows.length}条。未生成不完整文件。`);
if(rows.some(r=>!Array.isArray(r)||r.length!==report.cols||r[0]!=='亳州')) throw new Error('返回记录的列数或本地网不一致,已停止。');
const signature=JSON.stringify(rows);
if(rows.length && signatures.has(hashString(signature))) throw new Error('服务器重复返回同一页,已停止以防重复数据。');
// Keep only a compact hash, never all page serializations.
if(rows.length) signatures.add(hashString(signature));
parts.push(rowsXML(rows,headerCount+written,0,formats));
count+=rows.length; written+=rows.length; pages++;
await new Promise(r=>setTimeout(r,0));
}
if(count!==total) throw new Error(`行数校验失败:${count}/${total}。`);
if(report.summary==='bottom'){parts.push(rowsXML([summary],headerCount+written,0,formats));written++;}
// Refresh the visible preview once (20 rows), without another query or a
// 500-row DOM render. The export itself still contains every record.
if(typeof t.getData==='function') {
t.$set(t.tablePageInfo,'pageSize',20);t.$set(t.tablePageInfo,'currentPage',1);
// The portal appends codes in updateNormalHeader instead of replacing
// them. Keep the verified list so another export on this page still works.
const codes=t.tableHeaderCodes.slice();
try {await t.getData({type:'pageChange',resData:preview});}
finally {t.tableHeaderCodes=codes;}
}
status(`已读取 ${count} 条,正在生成 XLSX…`);
check();
const blob=await buildXlsx(headers,parts,merges,written,formats,`亳州${report.name}`);
check();
lastFile={name:`亳州_${report.name}_${job.start}_${job.end}_${count}条.xlsx`,blob};
download(lastFile);
currentJob=updateJob(currentJob,{stage:'done',count,pages,message:'导出成功'});
status(currentJob.message); $('#again').style.display='inline-flex';setBusy(false);
} catch(e){fail(e);}
}
async function readNextPage(config,offset) {
if(!config?.url || !config?.data) throw new Error('未取得分页查询参数,已停止。');
const url=new URL(config.url,config.baseURL.replace(/\/$/,'')+'/');
if(url.origin!==W.location.origin || !url.pathname.endsWith('/query/data/')) throw new Error('分页查询地址不匹配,已停止。');
const body=typeof config.data==='string'?JSON.parse(config.data):JSON.parse(JSON.stringify(config.data));
body.pageInfo={limit:PAGE_SIZE,offset};
const controller=new AbortController(), timer=setTimeout(()=>controller.abort(),120000);
try {
// Reuse this page's in-memory query headers. Never persist session credentials.
const r=await W.fetch(url.href,{method:'POST',credentials:'same-origin',headers:config.headers,body:JSON.stringify(body),signal:controller.signal});
if(!r.ok) throw new Error(`分页查询HTTP ${r.status},已停止。`);
return {data:await r.json()};
} finally {clearTimeout(timer);}
}
function hashString(s) {let h=2166136261;for(let i=0;i>>0}`;}
function validDate(s) {if(!/^\d{4}-\d{2}-\d{2}$/.test(s)||s.slice(0,4)==='0000')return false;const d=new Date(`${s}T12:00:00`);return !Number.isNaN(d.getTime())&&isoDate(d)===s;}
function start() {
closeCalendar();
const start=$('#start').value.trim(), end=$('#end').value.trim(), reportKey=$('#reportType').value;
if(!REPORTS[reportKey]) return status('请选择报表类型。',true);
if(!validDate(start)||!validDate(end)||start>end) return status('请选择有效日期,开始日期不能晚于结束日期。',true);
if(isLive(readJob())) {$('#cancelTask').style.display='inline-flex';return status('已有任务正在导出,请稍候。',true);}
const sameReport=dashboardId()===REPORTS[reportKey].id;
const job=saveJob({id:`${Date.now()}-${Math.random().toString(36).slice(2,9)}`,reportKey,start,end,created:Date.now(),stage:sameReport?'report':'oa',message:`准备进入${REPORTS[reportKey].name}…`});
GM_setValue('ahoa.export.dates',{start,end});
GM_setValue('ahoa.export.report',reportKey);
if(sameReport) {exportReport(job);return;}
const url=`${APP}#ahoa-export=${encodeURIComponent(job.id)}`;
const child=W.open(url,'_blank');
if(!child) W.location.assign(url);
else {$('#shade').style.display='none';$('#jobbar').style.display='block';$('#jobbar').textContent='已打开报表自动导出标签页。';monitorNavigation(job,child);}
}
$('#startExport').onclick=start;
$('#again').onclick=()=>{if(lastFile)download(lastFile);};
$('#cancelTask').onclick=()=>{const j=readJob();if(isLive(j)){if(currentJob?.id===j.id)cancelled=true;currentJob=updateJob(j,{stage:'cancelled',message:'已停止'});status('已停止');if(busy)$('#startExport').textContent='正在停止…';}$('#cancelTask').disabled=true;$('#cancelTask').style.display='none';};
$('#close').onclick=hide;
GM_registerMenuCommand('亳州经营报表:选择类型和日期导出',show);
GM_addValueChangeListener(KEY,(_key,_old,j)=>{
if(!currentJob || j?.id!==currentJob.id || busy) return;
currentJob=j;
if(['query','done'].includes(j.stage)) for(const tab of fallbackTabs.slice(0,-1)) {try{tab.close();}catch(_){}}
paintStatus(j.stage==='done'?'导出成功':j.stage==='error'?'导出失败':j.stage==='cancelled'?'已停止':j.message||'正在处理…',j.stage==='error');
$('#cancelTask').style.display=isLive(j)?'inline-flex':'none';
if(autoNavTab && ['query','done'].includes(j.stage)) W.close();
});
async function boot() {
const tab=await tabState();
const marker=new URLSearchParams(W.location.hash.slice(1)).get('ahoa-export');
if(marker){tab.ahoaExport=marker;GM_saveTab(tab);}
const job=readJob();
if(!isLive(job)) return;
if(tab.ahoaExport!==job.id && (
(W.location.hostname.startsWith('nlkf-eda.') && job.stage==='dt') ||
(W.location.hostname.startsWith('tsfx-eda.') && job.stage==='report' && dashboardId()===reportOf(job)?.id)
)) {tab.ahoaExport=job.id;GM_saveTab(tab);}
if(tab.ahoaExport!==job.id) return;
currentJob=job;
if(W.location.hostname.startsWith('portal.') && job.stage==='oa') {autoNavTab=true;await navigateOA(job);}
else if(W.location.hostname.startsWith('nlkf-eda.') && job.stage==='dt') {autoNavTab=true;await navigateDT(job);}
else if(W.location.hostname.startsWith('tsfx-eda.') && ['report','dt'].includes(job.stage) && dashboardId()===reportOf(job)?.id) await exportReport(job);
}
// Minimal OOXML + ZIP writer: no CDN, no clipboard, no third-party libraries.
const esc=s=>String(s).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\ufffe\uffff]/g,'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
function colName(i){let s='';for(i++;i;i=Math.floor((i-1)/26))s=String.fromCharCode(65+(i-1)%26)+s;return s;}
function rowsXML(rows,start,headerRows,formats=[]) {
return rows.map((row,i)=>``+row.map((v,c)=>{
const ref=`${colName(c)}${start+i+1}`;
const style=i`;
if(typeof v==='number' && Number.isFinite(v)) return `${v}`;
return `${esc(v??'--')}`;
}).join('')+'
').join('');
}
async function buildXlsx(headers,bodyParts,merges,count,formats,sheetName='亳州经营报表') {
const ns='http://schemas.openxmlformats.org/spreadsheetml/2006/main';
const xml=s=>''+s;
const n=headers[0].length, h=headers.length;
const columns=Array.from({length:n},(_,i)=>``).join('');
const mergeXML=merges.length?``+merges.map(m=>``).join('')+'':'';
const sheet=xml(`${columns}${rowsXML(headers,0,h,formats)}${bodyParts.join('')}${mergeXML}`);
const xf=(font,fill,border,fmt,alignment)=>``;
const styles=xml(`${Array.from({length:10},(_,i)=>``).join('')}${xf(0,0,0,0,'')}${xf(1,2,1,0,'horizontal="center" wrapText="1"')}${xf(0,0,1,0,'horizontal="right"')}${xf(0,0,1,49,'horizontal="left"')}${Array.from({length:10},(_,i)=>xf(0,0,1,164+i,'horizontal="right"')).join('')}`);
const files=[
['[Content_Types].xml',xml('')],
['_rels/.rels',xml('')],
['xl/workbook.xml',xml(``)],
['xl/_rels/workbook.xml.rels',xml('')],
['xl/styles.xml',styles], ['xl/worksheets/sheet1.xml',sheet]
];
return zip(files);
}
async function zip(files) {
const enc=new TextEncoder(), locals=[], central=[]; let offset=0;
const crcTable=Uint32Array.from({length:256},(_,i)=>{let c=i;for(let k=0;k<8;k++)c=c&1?0xedb88320^(c>>>1):c>>>1;return c>>>0;});
const crc32=b=>{let c=0xffffffff;for(const x of b)c=crcTable[(c^x)&255]^(c>>>8);return(c^0xffffffff)>>>0;};
const write=(bytes,fields)=>{const d=new DataView(bytes.buffer);for(const [pos,size,value] of fields)size===2?d.setUint16(pos,value,true):d.setUint32(pos,value,true);};
for(const [name,content] of files) {
const filename=enc.encode(name), raw=enc.encode(content), crc=crc32(raw);let payload=raw, method=0;
// Native compression when supported; stored ZIP is a standards-compliant fallback.
try {const compressed=new Uint8Array(await new Response(new Blob([raw]).stream().pipeThrough(new CompressionStream('deflate-raw'))).arrayBuffer());if(compressed.lengths+b.length,0), end=new Uint8Array(22);
write(end,[[0,4,0x06054b50],[8,2,files.length],[10,2,files.length],[12,4,size],[16,4,offset]]);
return new Blob([...locals,...central,end],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
}
boot().catch(fail);
})();