_audit.js 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
  1. (async()=>{
  2. const R={score:{},issues:[],facts:{}};
  3. const px=v=>parseFloat(v)||0;
  4. const all=[...document.querySelectorAll('body *')].filter(e=>e.offsetParent!==null||e.tagName==='TH');
  5. // —— 1 排版: 字号阶梯收敛
  6. const sizes={}; all.forEach(e=>{const s=getComputedStyle(e).fontSize; sizes[s]=(sizes[s]||0)+1});
  7. const sizeList=Object.entries(sizes).sort((a,b)=>b[1]-a[1]);
  8. R.facts.fontSizes=sizeList.map(([k,v])=>k+'×'+v);
  9. const nSizes=sizeList.filter(([k,v])=>v>=3).length;
  10. R.score.type_scale = nSizes<=7?100:nSizes<=9?80:50;
  11. if(nSizes>7) R.issues.push(`字号阶梯 ${nSizes} 档 (>7): ${sizeList.filter(x=>x[1]>=3).map(x=>x[0]).join(',')}`);
  12. // 字族一致
  13. const fams=new Set(all.map(e=>getComputedStyle(e).fontFamily.split(',')[0].replace(/"/g,'')));
  14. R.facts.families=[...fams];
  15. R.score.type_family = fams.size<=3?100:70;
  16. if(fams.size>3) R.issues.push('字族 '+fams.size+' 种: '+[...fams].join('|'));
  17. // 数字等宽覆盖 (.num/.kpi b 必须 tabular)
  18. const numEls=[...document.querySelectorAll('.num,.kpi b,.cnt')];
  19. const badNum=numEls.filter(e=>!/tabular-nums/.test(getComputedStyle(e).fontVariantNumeric));
  20. R.score.type_tabular = numEls.length? Math.round((1-badNum.length/numEls.length)*100):100;
  21. if(badNum.length) R.issues.push(`数字未等宽 ${badNum.length}/${numEls.length}`);
  22. // 行长 (正文 ch)
  23. // 行长: CJK 按 1em/字, 拉丁按 0.5em/字 → 目标 ≤ 46 CJK 字 (中文正文舒适区 35~46)
  24. const proses=[...document.querySelectorAll('p.small,.fig-note,.det p,.empty')].slice(0,40);
  25. const longs=proses.filter(e=>{const fs=px(getComputedStyle(e).fontSize); const t=e.textContent;
  26. const cjk=(t.match(/[\u4e00-\u9fff]/g)||[]).length, frac=t.length?cjk/t.length:0;
  27. const em=fs*(0.5+0.5*frac); // 中文占比越高, 每字越宽
  28. return e.getBoundingClientRect().width/em>46+ (1-frac)*44});
  29. R.score.line_length = proses.length? Math.round((1-longs.length/proses.length)*100):100;
  30. if(longs.length) R.issues.push(`行长超舒适区的段落 ${longs.length}/${proses.length}`);
  31. // —— 2 间距: 4px 栅格
  32. const spaced=all.slice(0,400);
  33. const offGrid=[];
  34. spaced.forEach(e=>{const s=getComputedStyle(e);
  35. ['marginTop','marginBottom','paddingTop','paddingBottom','paddingLeft','gap'].forEach(k=>{
  36. const v=px(s[k]); if(v>0 && Math.abs(v-Math.round(v))<0.01 && v%1===0 && (v%2!==0)) offGrid.push(e.className+'.'+k+'='+v)})});
  37. R.facts.offGrid=offGrid.slice(0,8);
  38. R.score.spacing_grid = offGrid.length<=3?100:offGrid.length<=10?80:60;
  39. if(offGrid.length>3) R.issues.push(`非偶数间距 ${offGrid.length} 处 (示例 ${offGrid.slice(0,3).join(', ')})`);
  40. // —— 3 版面: 无横向溢出 + 点击目标
  41. R.facts.bodyScroll=[document.documentElement.scrollWidth, window.innerWidth];
  42. R.score.layout_nohscroll = document.documentElement.scrollWidth<=window.innerWidth+1?100:0;
  43. if(document.documentElement.scrollWidth>window.innerWidth+1) R.issues.push('页面横向溢出 '+document.documentElement.scrollWidth+'>'+window.innerWidth);
  44. const targets=[...document.querySelectorAll('button,select,a.tl')];
  45. const small=targets.filter(e=>{const r=e.getBoundingClientRect(); return r.height>0&&r.height<24});
  46. R.score.layout_targets = targets.length? Math.round((1-small.length/targets.length)*100):100;
  47. if(small.length) R.issues.push(`点击目标<24px高 ${small.length}/${targets.length}`);
  48. // —— 4 对比度 (WCAG AA 4.5:1 正文 / 3:1 大字)
  49. // 颜色解析: 支持 rgb()/rgba()/color(srgb r g b / a), 0..1 与 0..255 自动判别; 半透明按父底合成
  50. function parse(c){
  51. if(!c) return null;
  52. const nums=(c.match(/-?[\d.]+(e-?\d+)?/g)||[]).map(Number);
  53. if(nums.length<3) return null;
  54. let [r,g,b]=nums; const a=nums.length>3?nums[3]:1;
  55. const isUnit=/color\(/.test(c)||(r<=1&&g<=1&&b<=1&&/\./.test(c));
  56. if(isUnit){r*=255;g*=255;b*=255}
  57. return {r,g,b,a};
  58. }
  59. function over(fg,bg){ // alpha 合成
  60. const a=fg.a; return {r:fg.r*a+bg.r*(1-a), g:fg.g*a+bg.g*(1-a), b:fg.b*a+bg.b*(1-a), a:1};
  61. }
  62. function lumc(o){const f=v=>{v/=255; return v<=.03928?v/12.92:Math.pow((v+.055)/1.055,2.4)};
  63. return .2126*f(o.r)+.7152*f(o.g)+.0722*f(o.b)}
  64. function bg(e){ // 从元素向上合成到不透明底
  65. let n=e, stack=[];
  66. while(n&&n!==document.documentElement){
  67. const c=parse(getComputedStyle(n).backgroundColor);
  68. if(c&&c.a>0){ stack.push(c); if(c.a>=1) break }
  69. n=n.parentElement;
  70. }
  71. let base=parse(getComputedStyle(document.body).backgroundColor)||{r:255,g:255,b:255,a:1};
  72. if(base.a<1) base={r:255,g:255,b:255,a:1};
  73. for(let i=stack.length-1;i>=0;i--) base=over(stack[i], base);
  74. return base;
  75. }
  76. const texts=[...document.querySelectorAll('p,td,th,span,b,h1,h3,button,a')].filter(e=>e.textContent.trim()&&e.children.length===0).slice(0,220);
  77. const lowC=[];
  78. texts.forEach(e=>{const s=getComputedStyle(e); const fg=parse(s.color); if(!fg) return;
  79. const b=bg(e); const fgc=fg.a<1?over(fg,b):fg;
  80. const L1=lumc(fgc)+.05, L2=lumc(b)+.05;
  81. const ratio=Math.max(L1,L2)/Math.min(L1,L2); const fs=px(s.fontSize); const need=(fs>=18||(fs>=14&&+s.fontWeight>=600))?3:4.5;
  82. if(ratio<need) lowC.push(`${e.className||e.tagName} ${fs}px ${ratio.toFixed(2)}:1<${need}`)});
  83. R.facts.lowContrast=lowC.slice(0,10);
  84. R.score.contrast = texts.length? Math.round((1-lowC.length/texts.length)*100):100;
  85. if(lowC.length) R.issues.push(`对比度不足 ${lowC.length}/${texts.length}: ${lowC.slice(0,3).join(' | ')}`);
  86. // —— 5 图表
  87. const svgs=[...document.querySelectorAll('svg')];
  88. let chartIssues=[];
  89. svgs.forEach((sv,i)=>{
  90. const card=sv.closest('.card')||sv.closest('.fig'); const title=(card?.querySelector('h3')||card?.querySelector('h4'))?.textContent||'?';
  91. const texts=[...sv.querySelectorAll('text')];
  92. const tiny=texts.filter(t=>px(getComputedStyle(t).fontSize)<9);
  93. if(tiny.length) chartIssues.push(`${title.slice(0,16)}: ${tiny.length}个刻度字号<9px`);
  94. // 图例重叠
  95. const legs=texts.filter(t=>/^—/.test(t.textContent));
  96. for(let a=0;a<legs.length;a++)for(let b=a+1;b<legs.length;b++){
  97. const r1=legs[a].getBoundingClientRect(),r2=legs[b].getBoundingClientRect();
  98. if(r1.right>r2.left+1&&r2.right>r1.left+1&&Math.abs(r1.top-r2.top)<4) chartIssues.push(`${title.slice(0,16)}: 图例重叠`)}
  99. // 单位
  100. const hasAxisLabel=[...sv.querySelectorAll('text')].some(x=>/[a-zA-Z%℃°]|m\/s|kW|bar|rpm|条|次|日|月/.test(x.textContent)&&x.textContent.length>1);
  101. if(!/\(|(/.test(title) && !card?.querySelector('.fig-note') && !hasAxisLabel) chartIssues.push(`${title.slice(0,16)}: 无单位无图注无轴标`);
  102. });
  103. R.facts.svgCount=svgs.length; R.facts.chartIssues=chartIssues.slice(0,10);
  104. R.score.charts = svgs.length? Math.max(0,100-chartIssues.length*12):100;
  105. if(chartIssues.length) R.issues.push(`图表问题 ${chartIssues.length}: ${chartIssues.slice(0,3).join(' | ')}`);
  106. // —— 总分
  107. const vals=Object.values(R.score); R.total=Math.round(vals.reduce((a,b)=>a+b,0)/vals.length);
  108. return JSON.stringify(R);
  109. })()