classic_chart.js 177 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162
  1. // 设备状态色 = 观澜语义色单源 (2026-08-28 用户裁: 观澜色块体系更好, 保留; windcms 那边改过来)
  2. // 对照 windcms 现值见 design.py 的 --st-* (迁移期保留, 供振动线对齐用)
  3. const C={'优秀':'var(--ok)','良好':'var(--info)','报警':'var(--warn)','危险':'var(--bad)','不可判':'var(--unknown)','监测不可用':'var(--unknown)','无监测':'var(--unknown)'};
  4. // 传动链运动简图 (2026-08-28 用户令"纯图形": 弃方块标签, 用工程简图语言 —
  5. // 轴=横线 · 齿轮=竖矩形(啮合处相切) · 轴承=剖面符号 · 行星级=齿圈+行星架 · 发电机=定子/转子;
  6. // 只有命中部件着色并标名, 其余全灰 = 一眼看出"链条哪一环出问题")
  7. const baBar=(pre,post,col)=>{
  8. const M=Math.max(pre,post,7), W=120, sc2=v=>Math.max(2,v/M*(W-4));
  9. return `<svg class="dt" viewBox="0 0 ${W} 26" style="width:${W}px;height:26px">
  10. <rect x="2" y="2" width="${sc2(pre).toFixed(1)}" height="9" rx="2" fill="var(--muted)" fill-opacity="0.55"/>
  11. <rect x="2" y="15" width="${sc2(post).toFixed(1)}" height="9" rx="2" fill="${col}" fill-opacity="0.9"/>
  12. </svg>`};
  13. const esc=t=>String(t==null?'':t).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;');
  14. // 温度偏差条: 0 为中线的双向条 (方向即符号, 长度即幅度, 色即判级) + 数字并存
  15. const devBar=(ch,dev,vd,raw)=>{
  16. if(dev==null||dev===undefined) return `<span class="small">${esc(raw).slice(0,30)}</span>`;
  17. const M=6, W=104, mid=W/2, k=Math.max(-1,Math.min(1,dev/M)), bw=Math.abs(k)*(mid-8);
  18. // 只有真越档(|dev|>=3K)才上语义色, 其余淡灰 — 避免整列彩条抢注意力
  19. const strong = Math.abs(dev) >= 3;
  20. const col = !strong ? 'var(--muted)' : (/候选/.test(vd||'')?'var(--warn)':/记基线/.test(vd||'')?'var(--info)':/不可判|返服/.test(vd||'')?'var(--unknown)':'var(--muted)');
  21. const over=Math.abs(dev)>M;
  22. return `<div title="${esc(raw)}" style="display:flex;align-items:center;gap:7px">
  23. <svg class="dt" viewBox="0 0 ${W} 18" style="width:${W}px;height:18px;flex:none">
  24. <line x1="4" y1="9" x2="${W-4}" y2="9" stroke="var(--line)" stroke-width="1" stroke-opacity="0.7"/>
  25. <line x1="${mid}" y1="1" x2="${mid}" y2="17" stroke="var(--muted)" stroke-width="1" stroke-opacity="0.6"/>
  26. <rect x="${dev>=0?mid:mid-bw}" y="4" width="${Math.max(bw,1.5)}" height="10" rx="2" fill="${col}" fill-opacity="0.9"/>
  27. ${over?`<path d="M${dev>0?W-5:5},9 l${dev>0?-5:5},-4 v8 z" fill="${col}"/>`:''}
  28. </svg>
  29. <b class="small" style="font-variant-numeric:tabular-nums;color:${col}">${dev>0?'+':''}${dev}K</b></div>
  30. <div class="small" style="color:var(--muted);margin-top:1px">${esc(ch||'')}</div>`};
  31. // 证据窗时间条: 固定 2026-01→09 轴, 块=claim 窗覆盖段 (跨行可比, 一眼看窗对不对得上)
  32. const winBar=(spans,raw)=>{
  33. if(!spans||!spans.length) return `<span class="small" title="${esc(raw)}">未标时间窗</span>`;
  34. const T0=Date.parse('2026-01-01'), T1=Date.parse('2026-09-01'), W=118;
  35. const px=d=>{const v=Date.parse(d); return 3+Math.max(0,Math.min(1,(v-T0)/(T1-T0)))*(W-6)};
  36. const blocks=spans.map(([a,b])=>{const x1=px(a), x2=px(b);
  37. return `<rect x="${x1.toFixed(1)}" y="4" width="${Math.max(x2-x1,2.5).toFixed(1)}" height="10" rx="2" fill="var(--accent)" fill-opacity="0.8"/>`}).join('');
  38. return `<div title="${esc(raw)}"><svg class="dt" viewBox="0 0 ${W} 18" style="width:${W}px;height:18px">
  39. <rect x="3" y="4" width="${W-6}" height="10" rx="2" fill="var(--line)" fill-opacity="0.5"/>${blocks}</svg>
  40. <div class="small" style="color:var(--muted)">${spans.length} 个窗段</div></div>`};
  41. // 油液时效胶囊: 绿=近共时可作现时判据 / 灰=stale 只作回测锚
  42. const oilPill=(last,fresh,raw)=>`<div title="${esc(raw)}" style="display:flex;align-items:center;gap:6px">
  43. <i style="width:9px;height:9px;border-radius:50%;flex:none;background:${fresh?'var(--ok)':'var(--unknown)'}"></i>
  44. <span class="small" style="font-variant-numeric:tabular-nums">${last||'无样'}</span></div>
  45. <div class="small" style="color:var(--muted)">${last?(fresh?'近共时':'stale &gt;12 个月'):'—'}</div>`;
  46. // 术语去黑话 (2026-08-28 用户令"翻译·审核黑话"): 内部判级词/口径缩写 → 现场话, 括注保留原词以便对账
  47. const JARGON=[[/正样本/g,'健康对照台'],[/可降(?![级])/g,'可降级观察'],[/记基线观察/g,'记基线·下轮复核'],
  48. [/CLAIM窗|claim窗/g,'证据窗'],[/位次([0-9]+)[/]([0-9]+)/g,'全场第$1名(共$2台)'],[/未列 [((]≠正常[^))]*[))]/g,'未收录 (≠已排除异常)'],
  49. [/温度候选[(]定向查[)]/g,'温度偏高·待定向排查'],[/最大偏/g,'偏差最大通道'],[/单次([0-9]+)s/g,'单次循环 $1 秒'],
  50. [/时间窗内无样/g,'本时间窗未取样'],[/仲裁输入/g,'待仲裁输入'],[/无新证/g,'本轮无新证据'],[/降观察/g,'降为观察'],
  51. [/弱同向新证/g,'弱同向新证据'],[/传感器项/g,'测量链(仪器)问题'],[/份样/g,' 份油样'],[/NBM/g,'基线模型(NBM)']];
  52. // 表述规范单源 = windcms agent.py 的业主口径注解 (2026-08-28 用户令: 等级评价和表述一定要参照 windcms)。
  53. // 规范原文: 状态等级打头(优秀=未见异常保持常规监视 / 良好=轻微偏离记录观察 / 报警=存在需现场核实或处置的异常 /
  54. // 危险=异常严重建议尽快停机检查 / 不可判=数据不足或监测中断); 证据状态放括号里说可信度
  55. // (候选=待现场核实 / 准定论·预警=基本确认 / 候选·新发=新出现待核实 / 候选·记基线=已记为基线下窗观察 /
  56. // 参考=仅记录 / INSUFFICIENT=数据不足 / 撤回=已取消)。
  57. // ★不自造措辞: 之前我译的"待现场核实/观察即可"虽通顺但偏离了 windcms 词表, 已撤。
  58. const ST_NOTE = {'优秀':'未见异常, 保持常规监视', '良好':'轻微偏离, 记录观察',
  59. '报警':'存在需现场核实或处置的异常', '危险':'异常严重, 建议尽快停机检查', '不可判':'数据不足或监测中断'};
  60. const EV_NOTE = {'候选':'待现场核实', '准定论·预警':'基本确认', '候选·新发':'新出现待核实',
  61. '候选·记基线':'已记为基线, 下窗观察', '参考':'仅记录', 'INSUFFICIENT':'数据不足',
  62. '定论':'已定案', '正常':'未见异常', '撤回':'已取消'};
  63. const plain = t => String(t || ''); // 判级词一律用 windcms 原词, 注解走 ST_NOTE/EV_NOTE
  64. // 振动技术速记 → 人话 (2026-08-28 用户令: 黑话改掉)。只翻术语不动数字, 原文进悬停。
  65. // 顺序敏感: 长词先于短词 (DE/NDE 要先于 DE), 否则会被短词切碎。
  66. const VIBJARGON = [
  67. [/DE[/]NDE/g, '驱动端与非驱动端'], [/GenDE/g, '发电机驱动端'], [/GenNDE/g, '发电机非驱动端'],
  68. [/HS_gen2?/g, '高速轴发电机侧'], [/HS_rot/g, '高速轴转子侧'], [/IMS/g, '中间轴'],
  69. [/(?<![A-Za-z])NDE(?![A-Za-z])/g, '非驱动端'], [/(?<![A-Za-z])DE(?![A-Za-z])/g, '驱动端'],
  70. // 复合词先于单词: 原文"内圈BPFI"若先翻 BPFI 会变成"内圈内圈特征频率"
  71. [/内圈BPFI/g, '内圈特征频率'], [/外圈BPFO/g, '外圈特征频率'],
  72. [/BPFI/g, '内圈特征频率'], [/BPFO/g, '外圈特征频率'], [/FTF/g, '保持架特征频率'], [/BSF/g, '滚动体特征频率'],
  73. [/TD_1R/g, '时域单转指标'], [/Peak/g, '冲击峰值'], [/Rms/gi, '有效值'],
  74. [/([0-9]+(?:[.][0-9]+)?)[ ]*阶/g, '$1 倍轴频'], [/[(]o([0-9]+(?:[.][0-9]+)?)[)]/g, '(轴频 $1 倍)'],
  75. [/×p90/g, '×全场90分位'], [/×fleet/g, '×全场中位'],
  76. [/六窗/g, '六个观测窗'], [/([0-9])窗/g, '$1 个观测窗'],
  77. [/宽带/g, '宽频带总量'], [/台阶/g, '阶跃上升'], [/缓升/g, '缓慢上升'], [/回落/g, '回落至'],
  78. [/全净|六窗全净/g, '各窗均干净'], [/清洁/g, '干净'], [/回归/g, '回到常态'],
  79. // 替换文本本身不得含后续规则会再匹配的词 (原写'见族主台'会被下一条的 /族/ 二次替换)
  80. [/同族/g, '同批次共性 (见该批主台)'], [/族第([^ ]+)台/g, '同批次第$1台'], [/族/g, '批次共性组'],
  81. [/gain异常/g, '测点增益异常'], [/gain/g, '测点增益'],
  82. [/[(]销[)]/g, '(已销案)'], [/梯度/g, '沿链梯度最高点'],
  83. [/周期主导/g, '周期成分主导'], [/急性事件/g, '突发事件'], [/慢性/g, '长期'],
  84. [/主异常在/g, '主要异常位于'],
  85. ];
  86. const vibPlain = t => { let x = String(t || ''); VIBJARGON.forEach(([a, b]) => x = x.replace(a, b)); return x; };
  87. const zh=t=>{let x=String(t==null?'':t); JARGON.forEach(([a,b])=>x=x.replace(a,b)); return x};
  88. // 族分布堆叠条 (报警列)
  89. // 族分布条 (2026-08-28 用户逮"故障多少长度都一样"):
  90. // 条总长 = 该台条数/全场最大 → 跨行可比 (原来段宽按台内占比算, 总和恒 100% 所以每行一样长, 量的信息全丢);
  91. // 段内比例 = 族构成。总长封顶 118px, 最短 6px 保证可见。
  92. const famBar=(fam,n,maxN)=>{
  93. if(!n) return `<span class="small" style="color:var(--muted)">时间窗内无报警</span>`;
  94. const CO={'齿轮箱':'#8A6D3B','发电机':'#4A6FA5','主轴承':'#2E7D6B','变桨叶片':'#4A6FA5','偏航':'#2E7D6B','测风':'#7C5295','其他':'#5A6470'};
  95. const W=118, w=Math.max(6, Math.round(n/Math.max(maxN||n,1)*W));
  96. return `<div title="${n} 条 · 全场最多 ${maxN||n} 条" style="display:flex;height:9px;border-radius:3px;overflow:hidden;width:${w}px">`
  97. +fam.map(f=>`<i title="${f.k} ${f.n}" style="width:${(f.n/n*100).toFixed(1)}%;background:${CO[f.k]||'#5A6470'}"></i>`).join('')
  98. +`</div><div class="small" style="color:var(--muted);margin-top:3px">${fam.slice(0,2).map(f=>f.k+' '+f.n).join(' · ')}</div>`};
  99. const PART2SYS=p=>/主轴承|主轴/.test(p)?'主轴承':/齿轮|行星|中间|中速|高速/.test(p)?'齿轮箱':/发电机/.test(p)?'发电机':p;
  100. // ────────────────────────────────────────────────────────────────────────
  101. // 报表汇报端口 (2026-08-28 用户令: 按客户汇报管理要求做定制化报表端口, 一级显示, ontology 支持)
  102. //
  103. // 范式不是自创的 — 国内风电场上报集团的月度运行分析报告结构是固定的:
  104. // 发电完成 → 设备运行 → 故障分析 → 限电情况 → 检修执行 → 重点机组 → 下期计划。
  105. // 端口做三件事: ①选范式与周期 ②勾章节(定制化) ③选受众(决定脱敏级别)。
  106. // "一级显示" = 顶部六个核心指标用大字直出, 明细下沉到章节; 领导只看第一屏也拿得到结论。
  107. // 每个数字带口径与来源, 结论可回溯到本体对象 — 这是它区别于"手搓 PPT"的地方。
  108. // ────────────────────────────────────────────────────────────────────────
  109. // 报表画布 (2026-08-28 用户令: "像画布一样让客户选, 不是放太多信息")
  110. //
  111. // 改法: 从"满配 + 关掉不要的"翻成"空画布 + 挑要的"。前者默认给一大堆再让人做减法,
  112. // 客户看到的是负担; 后者每加一块都是一次主动选择, 报告长度天然由客户控制。
  113. // 组件粒度也从"章"降到"块" — 一张图/一张表就是一块, 挑的时候看得见自己在放什么。
  114. // 范式(月报/周报/专题/年度) 退化为**预设组合**: 点一下把一组块填进画布, 之后照样能加减调序。
  115. // 组件按主题分组 (2026-08-28 用户"交互界面设计一下"): 15 块平铺成两行时,
  116. // 客户要在一堆同质按钮里找, 且原生 title 弹出会遮住旁边两个按钮 (截图逮)。
  117. // 改为按报告的叙事顺序分五组, 说明走固定说明区不浮动。
  118. const RPT_GROUPS = [['发电', 'gen'], ['可靠性', 'rel'], ['故障', 'flt'], ['机组', 'unit'], ['收尾', 'end']];
  119. const RPT_BLOCKS = {
  120. kpi: {g:'gen', nm:'一级指标', ic:'▦', d:'六个核心数: 上网电量 / 等效满发 / 可用率 / 损失率 / 停机间隔 / 需行动台'},
  121. water: {g:'gen', nm:'能量瀑布', ic:'▤', d:'理论可发 → 逐段扣损 → 实际上网, 用于定位电量流向'},
  122. emon: {g:'gen', nm:'逐月发电表', ic:'▥', d:'逐月上网 / 损失 / 损失率 / 等效满发, 含数据覆盖率'},
  123. blame: {g:'gen', nm:'损失责任分账', ic:'◧', d:'交易调度侧 / 设备侧 / 运行侧 三段占比 — 回答"谁的责任"'},
  124. ram: {g:'rel', nm:'可靠性象限', ic:'◈', d:'平均无故障间隔(MTBF)× 单次停机时长(MDT)部件象限, 分出"频发易修"与"罕发难修", 处置方向相反'},
  125. parts: {g:'rel', nm:'部件停机表', ic:'▤', d:'逐部件停机时长 / 事件数 / 平均无故障间隔(MTBF)/ 单次停机时长(MDT)/ 影响机组数'},
  126. attrib: {g:'rel', nm:'归因覆盖', ic:'▬', d:'已归因 vs 无记录停机的占比 — 无记录不等于没有故障'},
  127. pareto: {g:'flt', nm:'故障帕累托', ic:'▮', d:'按条数降序 + 累计 80% 线, 找关键少数'},
  128. quad: {g:'flt', nm:'故障四象限', ic:'◇', d:'条数 × 单次时长, 区分重复触发与实际停机 — 按条数排序会误定优先级'},
  129. frate: {g:'flt', nm:'月度故障率', ic:'▪', d:'按天归一的日均条数, 数据不满月单独标注'},
  130. units: {g:'unit', nm:'重点机组', ic:'☰', d:'需跟踪台的部件 / 证据源 / 六步进度 / 卡在哪一步'},
  131. chain: {g:'unit', nm:'决策链进度', ic:'⛓', d:'证据→机制→判级→排期→动作→验收 六步的瓶颈分布'},
  132. loop: {g:'unit', nm:'闭环实证', ic:'✓', d:'检出 → 换件 → 复测回落的历史例证, 证明这条链是通的'},
  133. plan: {g:'end', nm:'下期建议', ic:'✎', d:'由数据推出的行动项, 不是套话'},
  134. caliber:{g:'end', nm:'口径与边界', ic:'⚑', d:'统计期 / 残月 / 工单窗 / 判级性质 — 建议常驻, 没有边界的数字堆不能作汇报依据'},
  135. narr: {g:'end', nm:'运行总结', ic:'✍', d:'本地模型基于实测数据生成的运行总结 — 为图表补充文字结论, 供汇报使用'},
  136. };
  137. const RPT_PRESETS = {
  138. month: {nm:'月度运行分析', blocks:['kpi','water','emon','blame','ram','pareto','units','plan','caliber']},
  139. week: {nm:'周报', blocks:['kpi','frate','units','plan','caliber']},
  140. topic: {nm:'设备专题', blocks:['units','chain','ram','loop','caliber']},
  141. year: {nm:'年度总结', blocks:['kpi','water','emon','ram','loop','caliber']},
  142. blank: {nm:'空白画布', blocks:[]},
  143. };
  144. function rptInit(){
  145. if(!window.RPTAUD) window.RPTAUD='in';
  146. // 默认空画布 (2026-08-28 用户令"都去掉, 只剩选项栏"): 打开只有选项栏, 一块都不预填。
  147. // 起手给几块仍是替客户做决定 — 画布的意思就是空着等人放东西。
  148. if(!window.RPTCANVAS) window.RPTCANVAS=[];
  149. }
  150. // 报告定制交互 (2026-08-28 用户令: 留一个互动交互窗, 报告按定制, 离线模型支持)。
  151. // 模型做两件事: 按需求挑组件 + 据实测摘要写总结。数字全部来自后端摘要, 生成后过接地闸;
  152. // 模型自己算的派生值(如占比求和)会被标出来让人核 — 算得对也要标, 因为核不了的数字不能进报告。
  153. // ★下拉原来写死两个**本地**模型 (qwen3:8b / qwen3.8:27b), 而云端部署上一个都没配;
  154. // /api/rpt_compose 在云端的问答微服务里也不存在 (只有 ask / ask_status / ask_models)。
  155. // 于是这个按钮在生产环境是死的: 点了拿回 404 的 {"err":"not found"}, 显示成 "not found"。
  156. // 改为按**实际可用**填充: 有编排接口才启用, 模型清单与问答助手同源 (/api/ask_models)。
  157. // 交付件不该摆一个点了就报错的按钮。
  158. // ★不能叫 rptInit —— 上面 L1528 已经有一个同名函数 (初始化 RPTAUD / RPTCANVAS)。
  159. // 函数声明后者覆盖前者, 于是 RPTAUD 永远初始化不了, 渲染直接 ReferenceError,
  160. // 整个汇报定制面板消失。命名撞车只在运行时才炸, 语法闸看不见。
  161. // ★模型下拉必须按**实际配了密钥的**填, 不能把 MODEL_SPEC 全列出来 ——
  162. // MODEL_SPEC 是"档位说明表"(含本地档), 不是"可用清单"。全列出来并默认选第一个
  163. // (qwen3:8b, 本地档) 的后果: 用户一提问就报「模型 qwen3:8b 未配置密钥」。
  164. // 云主机 2 核 1GB, 本地模型本来就跑不起来。可用清单唯一来源 = /api/ask_models。
  165. async function fillModels(selId){
  166. const sel=document.getElementById(selId); if(!sel) return {};
  167. let models={};
  168. try{ models=(await (await fetch('/api/ask_models')).json()).models||{} }catch(e){}
  169. const ks=Object.keys(models);
  170. sel.innerHTML = ks.length
  171. ? ks.map(k=>{const v=MODEL_SPEC[k]||{}; return `<option value="${k}">${v.nm||k}${v.loc?' · '+v.loc:''}${v.t?' · '+v.t:''}</option>`}).join('')
  172. : '<option value="">无可用模型</option>';
  173. return models;
  174. }
  175. async function askModelInit(){
  176. await fillModels('askm');
  177. if(typeof mdlNote==='function') mdlNote();
  178. }
  179. async function rptModelInit(){
  180. const sel=document.getElementById('rptmdl'), btn=document.getElementById('rptgo'),
  181. out=document.getElementById('rptout');
  182. if(!sel) return;
  183. let hasCompose=false;
  184. const models=await fillModels('rptmdl');
  185. // 编排已移植上云 (2026-09-01), 故改回**真探测** —— 拦截器已把 rpt_compose 放行到
  186. // 真网络, 探得到 200 就启用, 离线打开探不到就禁用并说明。
  187. try{ const r=await fetch('/api/rpt_compose?probe=1'); hasCompose = !!(r&&r.ok) }catch(e){ hasCompose=false }
  188. const ks=Object.keys(models);
  189. // ★这句原文是「本地模型…数据不出本地」—— 选 DeepSeek 时数据当然出本地。
  190. // 与徽章同一判据: 按选中档位的 loc 给话, 不写死。
  191. const hint=document.getElementById('rpthint');
  192. const syncHint=()=>{ if(!hint) return;
  193. const v=MODEL_SPEC[sel.value]||{};
  194. hint.innerHTML='模型依据需求选取组件, 并基于实测数据生成运行总结'
  195. + (v.loc==='本地' ? '; 数据不出本地'
  196. : '; <span style="color:var(--warn)">内容会外发到模型厂商</span>'); };
  197. sel.addEventListener('change', syncHint); syncHint();
  198. if(!hasCompose||!ks.length){
  199. if(btn){ btn.disabled=true; btn.style.opacity='.5'; btn.style.cursor='not-allowed' }
  200. if(out) out.innerHTML = !ks.length ? '本部署未配置任何模型密钥, 编排不可用'
  201. : '本部署未启用编排接口, 画布仍可手动拼装';
  202. }
  203. }
  204. async function rptCompose(){
  205. const need=(document.getElementById('rptneed')||{}).value||'';
  206. const mdl=(document.getElementById('rptmdl')||{}).value||'qwen3:8b';
  207. const out=document.getElementById('rptout');
  208. if(!need.trim()){ out.innerHTML='先输入报告要求'; return }
  209. const _sp=MODEL_SPEC[mdl]||{}, _loc=(_sp.loc==='本地');
  210. out.innerHTML='<span style="color:var(--accent)">模型编排中…</span> 挑组件 + 写总结'
  211. + (_loc ? ', 全程零外发' : ', <span style="color:var(--warn)">内容会外发到模型厂商</span>');
  212. let d;
  213. try{ d=await (await fetch(`/api/rpt_compose?need=${encodeURIComponent(need)}&canvas=${encodeURIComponent(RPTCANVAS.join(','))}&model=${encodeURIComponent(mdl)}`)).json(); }
  214. catch(e){ out.innerHTML=`<span style="color:var(--bad)">生成失败: ${e.message}</span>`; return }
  215. if(d.err){ out.innerHTML=`<span style="color:var(--bad)">${d.err}</span>`; return }
  216. window.RPTNARR={text:d.narrative, model:d.model, ungrounded:d.ungrounded||[]};
  217. const bl=(d.blocks||[]).filter(b=>RPT_BLOCKS[b]);
  218. if(bl.length) window.RPTCANVAS = (d.narrative? ['narr'] : []).concat(bl);
  219. else if(d.narrative && !RPTCANVAS.includes('narr')) RPTCANVAS.unshift('narr');
  220. out.innerHTML=`已按需求编排 <b>${bl.length}</b> 块${d.narrative?' + 一段运行总结':''} ·
  221. ${d.model} 用时 ${d.secs}s${(d.ungrounded||[]).length?` · <span style="color:var(--warn)">${d.ungrounded.length} 个数字待核</span>`:' · 数字全部接地'}`;
  222. render();
  223. setTimeout(()=>{const el=document.getElementById('rptout'); if(el&&!el.innerHTML) el.innerHTML='';},50);
  224. }
  225. function rptExport(fmt){
  226. // 导出走后端生成真 .docx/.xlsx: 图表块转成对应数据表, 信息不丢且可核对。
  227. // 上报版沿用界面同一套脱敏规则, 不需要导出后再手工处理。
  228. if(!RPTCANVAS.length){ alert('画布是空的 — 先从组件库选内容'); return }
  229. const n=(window.RPTNARR&&RPTNARR.text)?RPTNARR.text:'';
  230. const win=(window.D&&D.win)||'2026年';
  231. const url=`/api/rpt_export?fmt=${fmt}&blocks=${encodeURIComponent(RPTCANVAS.join(','))}`
  232. +`&aud=${RPTAUD}&win=${encodeURIComponent(win)}&narr=${encodeURIComponent(n.slice(0,4000))}`;
  233. const a=document.createElement('a'); a.href=url; a.download=''; document.body.appendChild(a); a.click(); a.remove();
  234. }
  235. function rptHint(k){
  236. // 固定说明区而非浮动 tooltip: 原生 title 弹出会盖住相邻按钮 (用户截图逮),
  237. // 且鼠标一移就消失, 读不完。放在组件库下方定位不动。
  238. const el=document.getElementById('rpthint'); if(!el) return;
  239. const v=k?RPT_BLOCKS[k]:null;
  240. el.innerHTML = v ? `<b style="color:var(--ink)">${v.ic} ${v.nm}</b> — ${v.d}`
  241. : '悬停查看组件说明 · 已选组件再次点击移出 · 右侧 ↑↓ 调整顺序';
  242. }
  243. function rptPreset(k){ window.RPTCANVAS=RPT_PRESETS[k].blocks.slice(); render(); }
  244. function rptAdd(k){ if(!RPTCANVAS.includes(k)){ RPTCANVAS.push(k); render(); } }
  245. function rptDel(k){ const i=RPTCANVAS.indexOf(k); if(i>=0){ RPTCANVAS.splice(i,1); render(); } }
  246. function rptMove(k,dir){
  247. const i=RPTCANVAS.indexOf(k), j=i+dir;
  248. if(i<0||j<0||j>=RPTCANVAS.length) return;
  249. [RPTCANVAS[i],RPTCANVAS[j]]=[RPTCANVAS[j],RPTCANVAS[i]]; render();
  250. }
  251. function rptK(v,u,lab,note,cls){
  252. return `<div class="kpi ${cls||''}"><b>${v}<span> ${u}</span></b>
  253. <span>${lab}<br><span style="opacity:.8">${note||''}</span></span></div>`;
  254. }
  255. function rptBlock(k, d){
  256. const E=(d.control&&d.control.energy)||{}, em=E.monthly||[], R=(d.rel&&!d.rel.err)?d.rel:null,
  257. M=(d.m8&&!d.m8.err)?d.m8.mtbf:null, F=d.faults||{}, FU=d.fus||{}, BD=(FU.链盘)||{};
  258. const wrap=(t,b,src)=>`<div class="card mb"><h3>${t}</h3>${b}
  259. ${src?`<p class="small" style="color:var(--muted);margin-top:8px;padding-top:7px;border-top:1px solid var(--line-2)">口径: ${src}</p>`:''}</div>`;
  260. if(k==='kpi'){
  261. const last=em[em.length-1]||{}, prev=em[em.length-2]||{};
  262. const cmpOK=(last.cov||0)>=0.5 && (prev.cov||0)>=0.5;
  263. const dl=(a,b)=>b?((a-b)/b*100):0;
  264. return `<div class="kpis" style="margin-bottom:14px">
  265. ${rptK((E.act||0).toLocaleString(),'MWh','上网电量',`理论可发 ${(E.theo||0).toLocaleString()} | ${cmpOK?`环比 ${dl(last.act,prev.act)>=0?'+':''}${dl(last.act,prev.act).toFixed(0)}%`:'环比不可比(末月不满)'}`)}
  266. ${rptK((E.eflh||0).toLocaleString(),'h','等效满发小时',`年化约 ${Math.round((E.eflh||0)/Math.max(em.filter(x=>x.cov>=0.5).length,1)*12).toLocaleString()} h`)}
  267. ${rptK(d.control&&d.control.avail,'%','时间可用率','不含调度令与低风待机',(d.control&&d.control.avail>=95)?'ok':'warn')}
  268. ${rptK(E.loss_pct,'%','损失率',`${(E.loss||0).toLocaleString()} MWh`,(E.loss_pct>=15)?'warn':'')}
  269. ${rptK(M?M.MTBF_h:'—','h','平均停机间隔',`停机 ${M?M.停机事件数:'—'} 起 · 单次均 ${M?M.MDT_h:'—'}h`)}
  270. ${rptK((BD.rows||[]).length,'台','需行动机组',`定论 ${(FU.kpi||{}).定论||0} · 预警 ${(FU.kpi||{}).预警||0} · 候选 ${(FU.kpi||{}).候选观察||0}`,(BD.rows||[]).length>20?'warn':'')}</div>`;
  271. }
  272. if(k==='water') return wrap('发电完成情况', waterfall(E)+
  273. `<p class="small" style="color:var(--muted);margin-top:6px">理论可发 ${(E.theo||0).toLocaleString()} = 上网 ${(E.act||0).toLocaleString()} + 损失 ${(E.loss||0).toLocaleString()} MWh (${E.loss_pct}%)
  274. | <span title="不是物理判据: 正常数据下必然通过。它的作用是当损失被高估到不可能时(台账数字虚高曾把损失写成超过全停上限)立刻拦下">量级自检</span>: 理论 ${E.cap_ok?'低于':'≥'} 全额定上限 ${(E.cap||0).toLocaleString()} MWh ${E.cap_ok?'✔':'✗ 数字不可信'}</p>`,
  275. 'SCADA 10min 工况态分解; 损失=同工况理论出力与实测之差。量级自检为防虚高的下限闸, 不是风资源判据');
  276. if(k==='emon') return wrap('逐月发电', em.length?`<table><tr><th>月</th><th class="num">上网 MWh</th><th class="num">损失 MWh</th><th class="num">损失率</th><th class="num">等效满发 h</th><th>覆盖</th></tr>
  277. ${em.map(x=>`<tr><td>${x.m}</td><td class="num">${x.act.toLocaleString()}</td><td class="num">${x.loss.toLocaleString()}</td><td class="num">${x.loss_pct}%</td><td class="num">${x.eflh}</td>
  278. <td class="small" style="color:${x.cov<0.5?'var(--warn)':'var(--muted)'}">${(x.cov*100).toFixed(0)}%${x.cov<0.5?' 不满月':''}</td></tr>`).join('')}</table>`:'—',
  279. '覆盖率<50% 的月不参与环比');
  280. if(k==='blame') return wrap('损失责任分账', blameBar(E)+
  281. `<p class="small" style="color:var(--muted);margin-top:8px">进设备考核的只有"故障与检修停机"; 限电属交易与调度侧。
  282. <br><b>限电不是"低风时段收到的空令"</b>: 限电·绑定时段平均出力 1.435 MW, 正常发电时段 1.412 MW, 比值 1.02 —
  283. 风况相同而出力被压, 说明限电是真压制; 但"限电命令期·功率未被压制"那一档平均出力仅 0.724 MW(18% 额定), 确属低风时段收令。</p>`,
  284. '工况态分解 + 限电命令面绑定判别; 平均出力=实发÷台时, 用作风资源代理量');
  285. if(k==='ram') return R?wrap('可靠性 × 可维修性', ramChart(R.表)+
  286. `<p class="small" style="color:var(--muted);margin-top:6px">设备类 ${R.设备类停机h}h / 外部类 ${R.外部类停机h}h (电网·风况·运维操作不计设备可靠性)</p>`,
  287. 'SCADA ≥1h 停机段, 不含调度令; 海上单次停机时长(MDT)含交通窗口'):'';
  288. if(k==='parts') return R?wrap('部件停机', `<table><tr><th>部件</th><th class="num">停机h</th><th class="num">事件</th><th class="num">平均无故障间隔(MTBF,h)</th><th class="num">单次停机时长(MDT,h)</th><th class="num">影响机组</th></tr>
  289. ${R.表.slice(0,9).map(x=>`<tr><td class="small">${x.部件}${x.外部?' (外部)':''}</td><td class="num">${x.停机时长h}</td><td class="num">${x.事件数}</td>
  290. <td class="num">${x.MTBF_h||'—'}</td><td class="num">${x.MDT_h}</td><td class="num">${x.影响台数}</td></tr>`).join('')}</table>`,
  291. '归因=段起点±30min 首发码'):'';
  292. if(k==='attrib') return R?wrap('归因覆盖', attribBar(R.表,R.总停机h)+
  293. `<p class="small" style="color:var(--muted);margin-top:6px">"无记录停机"不是没有故障, 是没归上因。</p>`,''):'';
  294. if(k==='pareto') return wrap('故障帕累托', paretoChart(F.pareto_n||[],' 条',''), '报警事件表');
  295. if(k==='quad') return wrap('故障模式四象限', quadChart(F.quad||[])+
  296. `<p class="small" style="color:var(--muted);margin-top:6px">右下角报警码属重复触发, 不应按条数排定优先级。</p>`,'纵界=10min(一个SCADA采样格)');
  297. if(k==='frate') return wrap('月度故障率', rateChart(F.monthly||{months:[],vals:[]})+
  298. `<p class="small" style="color:var(--muted);margin-top:6px">柱高=日均条数, 不是月总数。</p>`,'不满月按天归一');
  299. if(k==='units'){
  300. const rows=(BD.rows||[]).slice(0,14);
  301. return wrap('重点机组与处置', `<p class="small" style="color:var(--muted);margin-bottom:8px">需跟踪 ${(BD.rows||[]).length} 台 / 38 | 瓶颈 ${Object.entries(BD.stuck||{}).map(([a,b])=>`<b>${b} 台</b>卡在「${a}」`).join(' · ')}</p>
  302. <table><tr><th style="width:52px">机组</th><th style="width:64px">状态</th><th>部件与证据</th><th style="width:56px">进度</th><th style="width:62px">卡在</th></tr>
  303. ${rows.map(r=>`<tr><td><b>${r.t.replace('WTG','')}#</b></td><td>${chip(r.ostate||'—')}</td>
  304. <td class="small" style="color:var(--muted)">${r.part&&r.part.indexOf('未收录')<0?`<b style="color:var(--ink)">${r.part}</b> · `:''}${(r.srcs||[]).join('/')||'—'}${r.rising?` · <span style="color:var(--bad)">在升 ${r.rising.ratio}×</span>`:''}</td>
  305. <td class="num small">${r.done}/6</td><td class="small" style="color:var(--warn)">${r.stuck||'全通'}</td></tr>`).join('')}</table>`,
  306. '四源融合判级');
  307. }
  308. if(k==='chain') return wrap('决策链进度', `<p class="small" style="color:var(--muted)">六步: 证据 → 机制 → 判级 → 排期 → 动作 → 验收。
  309. 当前 ${Object.entries(BD.stuck||{}).map(([a,b])=>`${b} 台卡在「${a}」`).join(', ')}。</p>`,
  310. '「动作」一列不可判 — ' + (BD.wo_bound||''));
  311. if(k==='loop'){
  312. const cl=BD.closed||[];
  313. return wrap('端到端闭环实证', `<table><tr><th>机组</th><th>部件</th><th>换件日期</th><th class="num">换前</th><th class="num">换后</th><th class="num">比值</th></tr>
  314. ${cl.map(x=>`<tr><td><b>${x.turbine.replace('WTG','')}#</b></td><td class="small">${x.component}</td><td class="small">${x.replace_date}</td>
  315. <td class="num">${x.pre}</td><td class="num">${x.post}</td><td class="num" style="color:var(--ok)"><b>${x.ratio}×</b></td></tr>`).join('')}</table>
  316. <p class="small" style="color:var(--muted);margin-top:6px">这几台正是算法当初的检出台 ⇒ 故障前能看见、换件后能看见恢复。</p>`,
  317. '振动标量前后对比');
  318. }
  319. if(k==='plan') return wrap('下期计划与建议', `<ol style="margin:0 0 0 20px;line-height:1.85">
  320. <li><b>补机制定性</b> — ${(BD.stuck||{}).机制||0} 台停在"判了级但根因未定", 是当前最大单一瓶颈</li>
  321. <li><b>回收 25~26 工单</b> — 台账止 2024-11, 本期检修执行与闭环无法自动判定</li>
  322. <li><b>油液采样节奏</b> — 四源里油液长期 stale, 只能作回测锚</li>
  323. <li><b>限电侧</b> — 损失六成在交易与调度侧, 设备侧改善空间有限</li></ol>`,
  324. '建议级 — 排期与动作须经审批');
  325. if(k==='narr'){
  326. const N=window.RPTNARR;
  327. if(!N||!N.text) return wrap('运行总结','<p class="small" style="color:var(--muted)">还没有生成 — 在上面的交互窗输入报告要求。</p>','');
  328. return wrap('运行总结', `<p style="line-height:1.9;margin:0">${N.text.replace(/</g,'&lt;').split(String.fromCharCode(10)).join('<br>')}</p>
  329. ${(N.ungrounded||[]).length?`<p class="small" style="color:var(--warn);margin-top:8px">
  330. ⚠ 以下数值由模型自行推算, 不在原始数据摘要中, 引用前须核对: ${N.ungrounded.join(' / ')}</p>`:''}`,
  331. `本地模型 ${N.model} 生成 · 数字取自系统实测摘要 · 接地闸${(N.ungrounded||[]).length?'报 '+N.ungrounded.length+' 处待核':'全通过'}`);
  332. }
  333. if(k==='caliber'){
  334. const part=(F.partial||[]);
  335. return wrap('口径与数据边界', `<ul style="margin:0 0 0 20px;line-height:1.85">
  336. <li><b>统计期</b> ${d.win}${part.length?` — ${part.join('/')} 数据不满月(覆盖 ${Math.round((F.cov_min||0)*100)}%), 月度已按天归一`:''}</li>
  337. <li><b>可用率与损失率分母不同</b> 可用率分母剔除低风待机与调度令(问"该发时能不能发"),
  338. 损失率分母含全部时段(问"理论能发多少"); 同一低风时段在可用率里不计时间、在损失率里计入基数 —
  339. 这是两个指标的设计差异不是错, 但两者不可直接相乘或互推。低风待机对损失的贡献仅 118 MWh(0.5%)</li>
  340. <li><b>停机</b> 可靠性口径 ≥1h 段, 电量账为逐条工况态 — 两个数都对, 不可相减</li>
  341. <li><b>工单</b> 台账 2020~2024-11, 本报告不对本期检修执行下结论</li>
  342. <li><b>判级</b> 由振动/温度/润滑/油液四源融合得出; 行动等级由综合报告出具</li>
  343. ${RPTAUD==='up'?'<li><b>脱敏</b> 上报版隐去判据门槛与特征频率</li>':''}</ul>`,'');
  344. }
  345. return '';
  346. }
  347. function renderReport(d){
  348. rptInit();
  349. const FU=d.fus||{};
  350. let h=`<div class="eyebrow">汇报定制 · 画布</div>`;
  351. // ── 画布控制条: 预设 / 组件库 / 受众 / 打印 ──
  352. h+=`<div class="card mb noprint">
  353. <div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:10px">
  354. <span class="small" style="font-weight:600">预设</span>
  355. ${Object.entries(RPT_PRESETS).map(([k,v])=>`<button onclick="rptPreset('${k}')" style="cursor:pointer;padding:5px 13px;border-radius:15px;
  356. border:1px solid var(--line);background:var(--panel);color:var(--ink);font-size:12.5px">${v.nm}</button>`).join('')}
  357. <span style="flex:1"></span>
  358. <select onchange="window.RPTAUD=this.value;render()" style="padding:5px 9px;border:1px solid var(--line);border-radius:7px;background:var(--panel);color:var(--ink);font-size:12.5px">
  359. <option value="in"${RPTAUD==='in'?' selected':''}>场站内部 (全量)</option>
  360. <option value="up"${RPTAUD==='up'?' selected':''}>上报集团 (脱敏)</option></select>
  361. <button onclick="window.print()" style="cursor:pointer;padding:5px 13px;border-radius:7px;border:1px solid var(--accent);background:var(--panel);color:var(--accent);font-weight:600;font-size:12.5px">打印 / 存 PDF</button>
  362. <button onclick="rptExport('docx')" style="cursor:pointer;padding:5px 13px;border-radius:7px;border:1px solid var(--line);background:var(--panel);color:var(--ink);font-size:12.5px">导出 Word</button>
  363. <button onclick="rptExport('xlsx')" style="cursor:pointer;padding:5px 13px;border-radius:7px;border:1px solid var(--line);background:var(--panel);color:var(--ink);font-size:12.5px">导出 Excel</button>
  364. ${RPTAUD==='up'?'<span class="small" style="color:var(--warn);flex-basis:100%">上报版 — 判据门槛与特征频率已脱敏</span>':''}
  365. </div>
  366. <div class="small" style="font-weight:600;margin-bottom:7px">组件库
  367. <span style="font-weight:400;color:var(--muted)">点击加入报告, 再次点击移出 · 画布里已有 <b>${RPTCANVAS.length}</b> 块</span></div>
  368. <div style="display:grid;grid-template-columns:auto 1fr;gap:6px 12px;align-items:center">
  369. ${RPT_GROUPS.map(([gn,gk])=>`
  370. <div class="small" style="color:var(--muted);text-align:right;white-space:nowrap">${gn}</div>
  371. <div style="display:flex;gap:6px;flex-wrap:wrap">
  372. ${Object.entries(RPT_BLOCKS).filter(([,v])=>v.g===gk).map(([k,v])=>{const on=RPTCANVAS.includes(k);
  373. return `<button onmouseenter="rptHint('${k}')" onmouseleave="rptHint()" onfocus="rptHint('${k}')"
  374. onclick="rpt${on?'Del':'Add'}('${k}')" style="cursor:pointer;padding:5px 11px;border-radius:7px;
  375. border:1px solid ${on?'var(--accent)':'var(--line)'};background:${on?'var(--accent)':'var(--panel)'};
  376. color:${on?'#fff':'var(--ink)'};font-size:12.5px;opacity:${on?1:0.78}">${v.ic} ${v.nm}${on?' ✓':''}</button>`}).join('')}
  377. </div>`).join('')}
  378. </div>
  379. <div id="rpthint" class="small" style="color:var(--muted);margin-top:9px;padding-top:8px;border-top:1px solid var(--line-2);min-height:19px">
  380. 悬停查看组件说明 · 已选组件再次点击移出 · 右侧 ↑↓ 调整顺序</div>
  381. <div style="margin-top:11px;padding-top:10px;border-top:1px solid var(--line-2)">
  382. <div class="small" style="font-weight:600;margin-bottom:6px">输入报告要求
  383. <span id="rpthint" style="font-weight:400;color:var(--muted)">模型依据需求选取组件, 并基于实测数据生成运行总结</span></div>
  384. <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
  385. <input id="rptneed" placeholder="例如: 上报集团月报, 侧重设备可靠性与重点机组"
  386. onkeydown="if(event.key==='Enter')rptCompose()"
  387. style="flex:1 1 300px;min-width:220px;padding:7px 12px;border:1px solid var(--line);border-radius:7px;background:var(--panel);color:var(--ink);font-size:13px">
  388. <select id="rptmdl" style="padding:7px 9px;border:1px solid var(--line);border-radius:7px;background:var(--panel);color:var(--ink);font-size:12.5px"></select>
  389. <button id="rptgo" onclick="rptCompose()" style="cursor:pointer;padding:7px 18px;border-radius:7px;border:1px solid var(--accent);background:var(--accent);color:#fff;font-weight:600;font-size:13px">生成</button>
  390. </div>
  391. <div id="rptout" class="small" style="color:var(--muted);margin-top:7px"></div></div></div>`;
  392. setTimeout(rptModelInit, 60);
  393. if(!RPTCANVAS.length)
  394. return h+`<div class="paper" style="text-align:center;padding:56px 40px">
  395. <div class="small" style="color:var(--muted)">文档预览窗 — 从上面的组件库点选内容, 此处显示报告预览</div></div>`;
  396. // 报表头卡撤 (2026-08-28 用户令): 场名与统计期页眉已有, 数据源列表与"结论可回溯"声明
  397. // 属口径块的内容 — 摆在报告最上面是重复且抢戏。打印时的抬头由下面这行轻量署名承担。
  398. // ── 文档预览窗 (2026-08-28 用户令: 生成的文档能预览才打印) ──
  399. // 画布内容包进"一页纸": 有抬头、有边界、有页脚, 所见即将打印出来的样子。
  400. const _asof=(FU.数据时点||'').slice(0,10);
  401. h+=`<div class="paper"><div class="paper-hd">
  402. <div><div class="paper-ti">如东海上风电场 · 运行分析报告</div>
  403. <div class="small" style="color:var(--muted);margin-top:3px">统计期 ${d.win} | 38 × SWT-4.0-130${RPTAUD==='up'?' | 上报版':''}</div></div>
  404. <div class="small" style="color:var(--muted);text-align:right">${RPTCANVAS.length} 个章节</div></div>`;
  405. RPTCANVAS.forEach((k,i)=>{
  406. const v=RPT_BLOCKS[k]; if(!v) return;
  407. // 控制条移到块**上方**独立一行 — 原来绝对定位压在卡右上角, 挡住 KPI 数字与图例 (用户截图逮)
  408. const bb=(t,fn,dis)=>`<button title="${t}" onclick="${fn}" ${dis?'disabled':''} style="cursor:pointer;padding:1px 8px;border-radius:5px;
  409. border:1px solid var(--line);background:transparent;color:var(--muted);font-size:11px;opacity:${dis?0.3:0.7}">${t}</button>`;
  410. h+=`<div class="noprint" style="display:flex;gap:3px;justify-content:flex-end;margin-bottom:-6px;position:relative;z-index:2">
  411. ${bb('↑',`rptMove('${k}',-1)`,i===0)}${bb('↓',`rptMove('${k}',1)`,i===RPTCANVAS.length-1)}${bb('×',`rptDel('${k}')`,false)}
  412. </div>${rptBlock(k,d)}`;
  413. });
  414. h+=`<div class="paper-ft">
  415. <span>数据源 SCADA 10min · 报警事件 · 工单台账 · CMS 振动 · 油液化验${_asof?` | 出件 ${_asof}`:''}</span>
  416. <span>结论可回溯至本体对象${RPTAUD==='up'?' | 判据门槛与特征频率已脱敏':''}</span></div></div>`;
  417. return h;
  418. }
  419. // 决策链浮窗 (2026-08-28 用户令: 29号点开应该是决策链的具体信息的浮窗)。
  420. // 盘上一行只给"卡在哪", 点开给这一台的六步全貌: 每步达没达、依据是什么、为什么卡住。
  421. // 29# 另有本体链(证据清单/机制/四情景/验收树), 其余台给四源证据明细。
  422. function popChain(t){
  423. const BD=(D&&D.fus&&D.fus.链盘)||{}; const r=(BD.rows||[]).find(x=>x.t===t); if(!r) return;
  424. const STC={done:'var(--ok)',open:'var(--warn)',unknown:'var(--muted)'};
  425. const STT={done:'已达',open:'未达',unknown:'不可判'};
  426. const num='①②③④⑤⑥';
  427. // ★列宽按语言取。原来写死 44/52/96px, 是照**两个汉字**量的; 英文 "Verification" /
  428. // "Not evaluable" 塞不下 ⇒ 标签被截成 "Mechanis"/"Scheduli"/"Verificati", chip 压到
  429. // 下一列上 (用户截图实逮)。中文页取原值, 一个像素不动。
  430. const EN=(typeof window!=='undefined'&&window.__EN), W=EN?[92,104,132]:[44,52,96];
  431. const steps=r.steps.map((x,i)=>{
  432. const cur=(x.k===r.stuck);
  433. return `<div style="display:flex;gap:10px;padding:9px 11px;border-radius:8px;margin-bottom:5px;align-items:flex-start;
  434. border:1px solid ${cur?STC[x.st]:'var(--line)'};background:${cur?'var(--bg)':'transparent'}">
  435. <div style="flex:none;width:20px;color:var(--muted);font-size:13px">${num[i]}</div>
  436. <div style="flex:none;width:${W[0]}px;font-weight:600;font-size:12.5px">${x.k}</div>
  437. <div style="flex:none;width:${W[1]}px"><span class="badge" style="border-color:${STC[x.st]};color:${STC[x.st]};white-space:nowrap">${STT[x.st]}</span></div>
  438. <div style="flex:none;width:${W[2]}px;font-size:12px">${x.v}</div>
  439. <div class="small" style="color:var(--muted);flex:1;line-height:1.5">${x.d}${cur?` <b style="color:${STC[x.st]}">← 卡在这里</b>`:''}</div></div>`;}).join('');
  440. // 与后端 _tbi 同口径: 多行台取最严重那行, 否则浮窗四源卡会指向另一个部件
  441. const SEV={bad:0,warn:1,note:2,stale:3,ok:4,unlisted:5};
  442. const F=(D&&D.fus)||{};
  443. const frow=(F.表||[]).filter(x=>x.turbine===t).sort((a,b)=>(SEV[a.级]??9)-(SEV[b.级]??9))[0];
  444. let mini='';
  445. if(frow){
  446. mini=`<div class="pop-mini">
  447. <div><b>振动结论</b><span class="badge" style="border-color:${LVC2(frow.级)};color:${LVC2(frow.级)}">${(frow.振动结论||'').slice(0,26)}</span>
  448. <div class="small" style="color:var(--muted);margin-top:4px">${frow.部件||''}</div></div>
  449. <div><b>证据窗</b>${winBar(frow.时间窗区间,frow.claim_window)}</div>
  450. <div><b>温度轴 (fleet相对)</b>${devBar(frow.温度通道,frow.温度dev,frow.温度判,frow.温度轴)}</div>
  451. <div><b>油液时效</b>${oilPill(frow.油末样,frow.油新鲜,frow.油样轴)}</div></div>`;
  452. }
  453. let ext='';
  454. const C=(typeof ONT!=='undefined'&&ONT)?ONT.chain29:null;
  455. if(t==='WTG29'&&C){
  456. const ev=(C.evidence||[]).map(e=>`<div class="small" style="color:var(--muted);padding:4px 0;border-bottom:1px solid var(--line-2)">
  457. <b style="color:var(--ink)">${e.facet||e.id||''}</b>
  458. <span style="color:var(--muted)"> 窗 ${e.claim_window||'—'}</span>
  459. <div style="line-height:1.55;margin-top:2px">${(e.summary||'').toString().slice(0,150)}</div></div>`).join('');
  460. const sc=(C.scenario&&C.scenario.scenarios)||[];
  461. ext=`<div style="margin-top:12px">
  462. <div class="small" style="font-weight:600;margin-bottom:5px">① 证据清单 · ${(C.evidence||[]).length} 条 / ${C.mechanism.independent_source_count} 个观测面</div>
  463. ${ev}
  464. <div class="small" style="color:var(--muted);margin-top:5px">四个观测面是<b>同一条机制链的四个面</b>, 不算四个独立源 — 加权时不可当多源印证。</div>
  465. <div class="small" style="font-weight:600;margin:12px 0 5px">② 机制 · ${C.mechanism.name||''}</div>
  466. <div class="small" style="color:var(--muted)">${C.mechanism.note||''}</div>
  467. <div class="small" style="font-weight:600;margin:12px 0 5px">④ 排期沙盘 · ${sc.length} 个情景</div>
  468. <table style="margin-top:2px"><tr><th>情景</th><th>窗</th><th>作业级</th><th>停机损失</th></tr>
  469. ${sc.map(x=>`<tr><td class="small"><b>${x.id}</b></td><td class="small">${x['修理窗']||'—'}</td>
  470. <td class="small" style="color:var(--muted)">${(x['作业级']||'').slice(0,20)}</td>
  471. <td class="small" style="color:var(--muted)">${((x['停机损失']||{})['电费口径']||'—').slice(0,22)}</td></tr>`).join('')}</table>
  472. <div class="small" style="font-weight:600;margin:12px 0 5px">⑥ 验收 · ${C.acceptance.state||'待执行'}</div>
  473. <div class="small" style="color:var(--muted);line-height:1.55">${(C.acceptance.note||'').slice(0,200)}</div></div>`;
  474. }
  475. document.body.insertAdjacentHTML('beforeend',
  476. `<div class="pop-mask" id="popm" onclick="if(event.target.id==='popm')closePop()"><div class="pop-box">
  477. <div class="pop-hd"><b>${t.replace('WTG','')}# 决策链</b>
  478. ${chip(r.ostate||'—')}<span class="small" style="color:var(--muted)">走到 ${r.done}/6 · 卡在「${r.stuck||'全通'}」</span>
  479. <span class="pop-x" onclick="closePop()">×</span></div>
  480. <div class="pop-bd">
  481. <p class="small" style="color:var(--muted);margin-bottom:10px">从发现异常到修完验收的六步。
  482. <b>下一步:</b> ${r.next||'—'}</p>
  483. ${steps}${mini}${ext}
  484. <div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap">
  485. <a class="badge" href="/turbine/${t}">该台完整问题页 →</a></div>
  486. </div></div></div>`);
  487. }
  488. async function popPart(t,part){
  489. const sys=PART2SYS(part), win=(D&&D.win)||'2026年';
  490. document.body.insertAdjacentHTML('beforeend',
  491. `<div class="pop-mask" id="popm" onclick="if(event.target.id==='popm')closePop()"><div class="pop-box">
  492. <div class="pop-hd"><b>${t} · ${part}</b><span class="small" style="color:var(--muted)">载入中…</span>
  493. <span class="pop-x" onclick="closePop()">×</span></div><div class="pop-bd" id="popbd"></div></div></div>`);
  494. let figs=[], p0=null;
  495. try{ const r=await fetch(`/api/problem?t=${t}&sys=${encodeURIComponent(sys)}&win=${encodeURIComponent(win)}`);
  496. const d=await r.json(); p0=(d.problems||[])[0]; figs=(p0&&p0.figs)||[]; }catch(e){}
  497. const F=(D&&D.fus)||{}, frow=(F.表||[]).filter(x=>x.turbine===t&&PART2SYS(x.部件)===sys)[0];
  498. const curves=figs.filter(f=>f.kind!=='text'&&f.kind!=='vib');
  499. const mini=[];
  500. if(frow){
  501. mini.push(`<div><b>振动结论</b><span class="badge" style="border-color:${LVC2(frow.级)};color:${LVC2(frow.级)}">${frow.振动结论}</span>
  502. <div class="small" style="color:var(--muted);margin-top:4px">${frow.部件}</div></div>`);
  503. mini.push(`<div><b>证据窗</b>${winBar(frow.时间窗区间,frow.claim_window)}</div>`);
  504. mini.push(`<div><b>温度轴 (fleet相对)</b>${devBar(frow.温度通道,frow.温度dev,frow.温度判,frow.温度轴)}</div>`);
  505. mini.push(`<div><b>油液时效</b>${oilPill(frow.油末样,frow.油新鲜,frow.油样轴)}</div>`);
  506. }
  507. if(p0) mini.unshift(`<div><b>系统判级</b>${chip(p0['状态']||'—')}<div class="small" style="color:var(--muted);margin-top:4px">${(p0['依据']||'').slice(0,70)}</div></div>`);
  508. const hd=document.querySelector('#popm .pop-hd span.small');
  509. if(hd) hd.outerHTML=`<span class="small" style="color:var(--muted)">${sys} · 窗 ${win}</span>`;
  510. document.getElementById('popbd').innerHTML=
  511. `<div style="margin-bottom:10px">${dtChainStates({[part.replace(/[((].*/,'')]:LVC2(p0&&p0['状态']==='报警'?'bad':'warn')},260,null)}</div>
  512. <div class="pop-mini">${mini.join('')}</div>`
  513. +(curves.length?curves.slice(0,2).map(f=>`<div style="margin-bottom:10px">${drawFig(f)}</div>`).join('')
  514. :`<p class="small" style="color:var(--muted)">该部件在本时间窗无 SCADA 曲线 (振动谱级证据走 windcms)</p>`)
  515. +`<p class="small" style="margin-top:8px"><a class="tl" href="/problem/${t}/${encodeURIComponent(sys)}">完整问题页 (全部曲线与工单史) →</a>
  516. &nbsp;·&nbsp;<a class="tl" href="/turbine/${t}">本台总览 →</a></p>`;
  517. }
  518. // 三个场景各自独立提交与展示 (2026-08-28 用户令"都单独放在一个输入窗")。
  519. // 原来共用一个输入框 + 一个结果区, 切场景就把上一个结果冲掉 —
  520. // 而实际用法是"先查报警码怎么修, 顺手看这个系统的预防任务和故障树", 三者要并存对照。
  521. if(!window.ASSTCACHE3) window.ASSTCACHE3={};
  522. async function asstGo(mode){
  523. const m=mode||ASSTMODE;
  524. const out=document.getElementById('asstOut_'+m); if(!out) return;
  525. const el=document.getElementById('asstIn_'+m); const v=el?el.value.trim():'';
  526. out.innerHTML=`<div class="card"><p class="small">查询中…</p></div>`;
  527. const qs={fault:`kind=fault&code=${encodeURIComponent(v)}`,
  528. prev:`kind=prev&system=${encodeURIComponent(v||'齿轮箱')}`,
  529. tree:`kind=tree&system=${encodeURIComponent(v||'齿轮箱')}`}[m];
  530. let d;
  531. try{ d=await (await fetch('/api/ont_chain?'+qs)).json(); }
  532. catch(e){ out.innerHTML=`<div class="card"><p class="small" style="color:var(--bad)">查询失败: ${e.message}</p></div>`; return }
  533. out.innerHTML = (m==='fault' && d.data) ? faultCards(d.data)
  534. : (m==='tree' && d.data) ? treeCards(d.data)
  535. : `<div class="card"><pre class="small" style="white-space:pre-wrap;margin:0;line-height:1.75">${(d.text||'').replace(/</g,'&lt;')}</pre></div>`;
  536. window.ASSTCACHE3[m]=out.innerHTML;
  537. }
  538. function treeCards(x){
  539. // 故障树两层: 失效模式(果) ← causedBy ← 部件级原因(因); 条长=实证量, 步骤=手册原文
  540. const nodes = x.nodes || [];
  541. if(!nodes.length) return `<div class="card"><p class="small">该系统无失效模式记录</p></div>`;
  542. const maxE = Math.max(1, ...nodes.map(n => n.n_alarm + n.n_wo * 10));
  543. let h = `<div class="card mb"><h3>${x.system} 故障树 · ${nodes.length} 个失效模式</h3>
  544. <p class="small" style="color:var(--muted)">一层=系统×失效类(归纳自本场码表与工单) · 二层=手册排查方向归一+工单原因计数;
  545. 条长按实证量(报警条数 + 工单×10), 因果来自手册与工单原文非推断, 树深两层不外推。</p></div>`;
  546. nodes.forEach(n => {
  547. const w = Math.max(3, (n.n_alarm + n.n_wo * 10) / maxE * 100);
  548. const col = n.n_wo >= 5 ? 'var(--bad)' : n.n_alarm >= 100 ? 'var(--warn)' : 'var(--info)';
  549. h += `<div class="card mb"><div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
  550. <div style="flex:1;min-width:200px"><b>${n.name}</b>
  551. <div style="height:7px;border-radius:3px;background:${col};width:${w.toFixed(0)}%;margin-top:5px;max-width:100%"></div>
  552. <div class="small" style="color:var(--muted);margin-top:4px">本场报警 ${n.n_alarm} 条 · 工单 ${n.n_wo} 条${n.codes.length?` · 征兆码 ${n.codes.join(', ')}`:''}</div>
  553. </div></div>`;
  554. if(n.causes && n.causes.length){
  555. h += `<div style="margin-top:10px;padding-left:14px;border-left:2px dashed var(--line)">
  556. <div class="small" style="color:var(--muted);margin-bottom:6px">← 可能原因 (手册排查方向 + 工单实证)</div>
  557. <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:10px">`
  558. + n.causes.map(c => `<div style="border:1px solid var(--line);border-radius:8px;padding:10px 12px">
  559. <div><b class="small">${c.name}</b>
  560. <span class="small" style="color:var(--muted)"> 手册 ${c.n_ref} 次 · 工单 ${c.n_wo} 次 · 涉码 ${c.n_codes}</span></div>
  561. ${(c.steps||[]).map(st => `<div class="small" style="color:var(--muted);margin-top:5px;line-height:1.6">· ${st}</div>`).join('')}
  562. </div>`).join('') + `</div></div>`;
  563. } else {
  564. h += `<p class="small" style="color:var(--muted);margin-top:8px">(手册无该类排查条目 — 原因层待补)</p>`;
  565. }
  566. h += `</div>`;
  567. });
  568. return h;
  569. }
  570. function maintCards(md){
  571. // 数据维护面: 两层分开 (混在一起会让"机理被数据覆盖" — 更新频率与责任方完全不同)
  572. const rows = md.rows || [];
  573. if(md.error) return `<div class="card"><p class="small" style="color:var(--bad)">${md.error}</p></div>`;
  574. const LAYER = [['数据','随运营滚动 — 新数据到位后跑摄入, 本体与判级自动刷新','var(--accent)'],
  575. ['机理','相对稳定 — 换厂商资料或现场反馈修正时才动; 它是判据的地基, 变更须过审','var(--warn)']];
  576. let h = `<div class="card mb"><h3>数据维护面 · ${rows.length} 个源</h3>
  577. <p class="small" style="color:var(--muted)">每个源给出: 当前覆盖 / 末次更新 / 约定位置 / 摄入命令 / 更新频率 / 责任方。
  578. 要补数据就把新文件放到"约定位置"再跑"摄入命令"。</p></div>`;
  579. LAYER.forEach(([lv, note, col]) => {
  580. const rs = rows.filter(r => r.层 === lv);
  581. if(!rs.length) return;
  582. h += `<div class="card mb" style="border-left:3px solid ${col}"><h3>${lv}${LAYER_SFX} (${rs.length})</h3>
  583. <p class="small" style="color:var(--muted)">${note}</p>
  584. <table style="margin-top:10px"><tr><th style="width:20%">源</th><th style="width:22%">当前覆盖</th>
  585. <th style="width:14%">末次更新</th><th style="width:10%">频率</th><th style="width:10%">责任</th><th>补数据怎么做</th></tr>
  586. ${rs.map(r => `<tr>
  587. <td class="small"><b>${r.存在 ? '' : '✗ '}${r.名}</b></td>
  588. <td class="small" style="font-variant-numeric:tabular-nums">${r.覆盖}${r.条数 ? ` <span style="color:var(--muted)">(${r.条数.toLocaleString()} 条)</span>` : ''}</td>
  589. <td class="small" style="color:var(--muted)">${r.末次更新 || '—'}</td>
  590. <td class="small">${r.频率}</td><td class="small">${r.责任}</td>
  591. <td class="small"><div style="color:var(--muted)">放到: <code>${r.位置}</code></div>
  592. <div style="margin-top:3px">跑: <code>${r.摄入}</code></div>
  593. <div style="margin-top:3px;color:var(--muted)">${r.说明}</div></td></tr>`).join('')}</table></div>`;
  594. });
  595. return h;
  596. }
  597. function faultCards(x){
  598. // 抢修链结构化渲染: 码身份 → 失效模式 → 分步处置(卡片) → 历史工单 → 技术依据
  599. const sev = x.n_site>=1000?'var(--bad)':x.n_site>=100?'var(--warn)':'var(--muted)';
  600. let h=`<div class="card mb"><div style="display:flex;align-items:flex-start;gap:14px;flex-wrap:wrap">
  601. ${x.system?partGlyph(x.system,'var(--accent)'):''}
  602. <div style="flex:1;min-width:220px"><h3 style="margin:0">${x.code} · ${x.name}</h3>
  603. <p class="small" style="color:var(--muted);margin-top:5px">
  604. 系统 ${x.system||'未归属'} · 本场 <b style="color:${sev}">${x.n_site}</b> 条${x.alarm_type?` · 告警类型 ${x.alarm_type}`:''}</p></div></div>`;
  605. if(x.failmodes&&x.failmodes.length)
  606. h+=`<div style="margin-top:10px"><b class="small" style="color:var(--muted)">指向的失效模式</b>
  607. ${x.failmodes.map(f=>`<div class="small" style="margin-top:5px">· ${f.name} <span style="color:var(--muted)">(该类本场报警 ${f.n_alarm} 条 / 工单 ${f.n_wo} 条)</span></div>`).join('')}</div>`;
  608. h+=`</div>`;
  609. if(x.steps&&x.steps.length){
  610. h+=`<div class="card mb"><h3>现场处置 ${x.steps.length} 步 <span class="small" style="font-weight:400;color:var(--muted)">(${x.wi_source||''})</span></h3>
  611. <p class="small" style="color:var(--muted)">节点部件=手册原文元件位号(确定); 判定规范=实机参数表(2024-10-21 导出)按物理量匹配的<b>候选判据</b>,
  612. 非厂商绑定 — 现场以铭牌与图纸复核; 参数 ID 与告警码是不同命名空间, 未按同号关联。</p>
  613. <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:10px;margin-top:10px">`
  614. +x.steps.map(st=>`<div style="border:1px solid var(--line);border-left:3px solid var(--accent);border-radius:8px;padding:11px 13px">
  615. <div class="small" style="color:var(--accent);font-weight:600">第 ${st.序} 步${st.类别?` · ${st.类别}`:''}${st.qty?` · 量: ${st.qty}`:''}</div>
  616. <div style="margin-top:5px;line-height:1.65">${st.步骤}</div>
  617. ${(st.tags&&st.tags.length)?`<div class="small" style="margin-top:7px"><b style="color:var(--muted)">节点部件</b>
  618. ${st.tags.map(t=>`<code style="background:var(--panel-2);border:1px solid var(--line);border-radius:4px;padding:1px 6px;margin-right:5px">${t}</code>`).join('')}</div>`:''}
  619. ${(st.nodes&&st.nodes.length)?`<div class="small" style="margin-top:7px">
  620. <b style="color:var(--muted)">应检节点 · ${st.circuit||''} (手册此步未给位号 → 按回路构成展开, 逐点查)</b>
  621. <table style="margin-top:3px"><tr><th style="width:38%">节点</th><th>查什么</th></tr>
  622. ${st.nodes.map(n=>`<tr><td class="small">${n.name}</td><td class="small">${n.check}</td></tr>`).join('')}</table>
  623. ${(st.known_tags&&st.known_tags.length)?`<div style="color:var(--muted);margin-top:4px">该系统手册出现过的位号 (对图纸时参考):
  624. ${st.known_tags.map(k=>`<code style="margin-right:5px">${k.tag}</code>`).join('')}</div>`:''}
  625. </div>`:''}
  626. ${(st.specs&&st.specs.length)?`<div class="small" style="margin-top:7px"><b style="color:var(--muted)">判定规范 (实机参数表)</b>
  627. <table style="margin-top:3px"><tr><th style="width:64px">参数号</th><th>名称</th><th style="width:74px" class="num">整定值</th><th style="width:96px" class="num">允许范围</th></tr>
  628. ${st.specs.map(sp=>`<tr><td class="small">${sp.no}</td><td class="small">${sp.text}</td>
  629. <td class="num small">${sp.val!=null?sp.val+(sp.unit||''):'—'}</td>
  630. <td class="num small">${sp.lo!=null?`${sp.lo} ~ ${sp.hi}`:'—'}</td></tr>`).join('')}</table></div>`:''}
  631. ${(st.drawings&&st.drawings.length)?`<div class="small" style="margin-top:7px"><b style="color:var(--muted)">对应图纸</b>
  632. ${st.drawings.map(dw=>`<div style="color:var(--muted);margin-top:2px">· ${dw.name}</div>`).join('')}</div>`:''}
  633. </div>`).join('')
  634. +`</div></div>`;
  635. } else {
  636. h+=`<div class="card mb"><p class="small">本码无手册处置条目 (手册覆盖本场 161/322 码) — 需查图纸或联系厂商支持。</p></div>`;
  637. }
  638. if(x.workorders&&x.workorders.length)
  639. h+=`<div class="card mb"><h3>现场实际怎么修的 (${x.system} 历史工单 ${x.wo_total} 条, 台账 2020~2024)</h3>
  640. <table style="margin-top:8px"><tr><th style="width:92px">日期</th><th style="width:64px">机组</th><th>故障</th><th style="width:110px">处置</th></tr>
  641. ${x.workorders.map(w=>`<tr><td class="small">${w.date}</td><td class="small">${w.turbine.replace('WTG','')}#</td>
  642. <td class="small">${w.name}</td><td class="small">${w.action} ${w.part}</td></tr>`).join('')}</table>
  643. <p class="small" style="color:var(--muted);margin-top:6px">工单窗止 2024-11-21; 2025-26 现场未提供 ⇒ "0 条"是记录缺失非未修过。</p></div>`;
  644. // 技术依据卡撤 (2026-08-28 用户令): 按文件名关键词匹配出来的文档与本次故障常不相关
  645. // (液压压力偏高 → 推出"单叶片吊具培训资料"/"轮毂起吊图纸"), 列出来是噪音不是依据。
  646. // ont_doc_search API 保留, 需要时按码精确查, 不再在抢修链里自动铺开。
  647. return h;
  648. }
  649. async function ontChain(kind){
  650. const out=document.getElementById('ocOut'); if(!out) return;
  651. out.style.display='block'; out.textContent='查询中…';
  652. const qs={fault:()=>`kind=fault&code=${encodeURIComponent((document.getElementById('ocCode')||{}).value||'')}`,
  653. prev:()=>`kind=prev&system=${encodeURIComponent((document.getElementById('ocSys')||{}).value||'')}`,
  654. tree:()=>`kind=tree&system=${encodeURIComponent((document.getElementById('ocSys')||{}).value||'')}`,
  655. plan:()=>'kind=plan&top=12',
  656. doc:()=>`kind=doc&keyword=${encodeURIComponent((document.getElementById('ocDoc')||{}).value||'')}`}[kind];
  657. try{ const r=await fetch('/api/ont_chain?'+qs()); const d=await r.json();
  658. out.textContent=d.text||JSON.stringify(d); }
  659. catch(e){ out.textContent='查询失败: '+e.message }
  660. }
  661. function closePop(){const m=document.getElementById('popm'); if(m)m.remove()}
  662. const LVC2=lv=>({bad:'var(--bad)',warn:'var(--warn)',ok:'var(--ok)',meta:'var(--muted)',note:'var(--info)'}[lv]||'var(--warn)');
  663. document.addEventListener('keydown',e=>{if(e.key==='Escape')closePop()});
  664. // 状态链条 (per 节点各自着色版; windcms 结果层用: 四部件等级一次性画在传动链上, 替代四列 chips)
  665. const dtChainStates=(cmap,W,T2)=>{
  666. const w=W||208, Y=20, MU='var(--muted)', PN='var(--panel)', LN='var(--line)';
  667. const zone=(x,w2,dash)=>`<rect x="${x}" y="5" width="${w2}" height="30" rx="6" fill="var(--muted)" fill-opacity="0.13"
  668. stroke="${LN}" stroke-opacity="0.5" ${dash?'stroke-dasharray="3 2"':''}/>`;
  669. const node=(x,key,kind)=>{
  670. const col=cmap[key]||null, k=!!col, r=k?6.6:5;
  671. const teeth=kind==='g'?[0,90,180,270].map(a=>{const t=a*Math.PI/180;
  672. return `<line x1="${(x+Math.cos(t)*r).toFixed(1)}" y1="${(Y+Math.sin(t)*r).toFixed(1)}" x2="${(x+Math.cos(t)*(r+2.6)).toFixed(1)}" y2="${(Y+Math.sin(t)*(r+2.6)).toFixed(1)}" stroke="${col||MU}" stroke-width="${k?1.7:1.2}"/>`}).join(''):'';
  673. const core=`<circle cx="${x}" cy="${Y}" r="${r}" fill="${col||PN}" fill-opacity="${k?0.9:1}" stroke="${col||MU}" stroke-opacity="${k?1:0.8}" stroke-width="${k?2:1.3}"/>`
  674. +(kind==='b'?`<circle cx="${x}" cy="${Y}" r="${(r*0.4).toFixed(1)}" fill="${k?'#fff':MU}" fill-opacity="${k?0.85:0.7}"/>`:teeth);
  675. return (k&&T2)?`<g class="dt-hot" onclick="event.stopPropagation();popPart('${T2}','${key}')"><title>${key} — 点看曲线与四源微视图</title>
  676. <circle cx="${x}" cy="${Y}" r="${r+7}" fill="transparent"/>${core}</g>`:core};
  677. return `<svg class="dt" viewBox="0 0 320 40" style="display:block;width:${w}px;max-width:100%;height:auto">
  678. ${zone(28,74)}${zone(112,90,1)}${zone(212,96)}
  679. <line x1="20" y1="${Y}" x2="304" y2="${Y}" stroke="${LN}" stroke-width="3" stroke-linecap="round"/>
  680. <path d="M20,13 L11,4 M20,27 L11,36 M20,${Y} L9,${Y}" stroke="${MU}" stroke-width="1.6" fill="none"/>
  681. ${node(46,'主轴承前','b')}${node(88,'主轴承后','b')}
  682. ${node(133,'齿轮箱','g')}${node(157,'齿轮箱','g')}${node(181,'齿轮箱','g')}
  683. ${node(236,'发电机','b')}${node(282,'发电机','b')}
  684. </svg>`};
  685. // 证据强度阶梯 (确诊4格/准定论3/候选2/参考1/INSUFFICIENT 0虚框) — 文字分级换成可比的阶梯
  686. const evLadder=t=>{
  687. const T=String(t||''), n=/确诊/.test(T)?4:/准定论|定论/.test(T)?3:/候选/.test(T)?2:/参考/.test(T)?1:0;
  688. const col=n>=4?'var(--bad)':n===3?'var(--warn)':n===2?'var(--info)':n===1?'var(--muted)':'var(--unknown)';
  689. const cells=[0,1,2,3].map(i=>`<rect x="${2+i*15}" y="${12-i*3}" width="11" height="${6+i*3}" rx="1.5"
  690. fill="${i<n?col:'none'}" fill-opacity="0.9" stroke="${col}" stroke-opacity="${i<n?1:0.4}" stroke-width="1"
  691. ${i>=n?'stroke-dasharray="2 1.5"':''}/>`).join('');
  692. return `<div title="${esc(T)}" style="display:flex;align-items:center;gap:7px">
  693. <svg class="dt" viewBox="0 0 62 20" style="width:62px;height:20px;flex:none">${cells}</svg>
  694. <span class="small" style="color:${col}">${esc(T)}</span></div>`};
  695. // 部件微缩符号 (矩阵/列表行内用): 与解剖图同一套图形语言的 24px 版 —
  696. // 齿轮箱=行星轮系 · 发电机=定子转子 · 主轴承=滚子剖面 · 变桨=桨叶轴承 · 偏航=齿圈驱动
  697. function partIcon(name, col){
  698. const c = col || 'currentColor', n = String(name || '');
  699. const W = 30, H = 26;
  700. // 尺寸走 inline style (全局 svg{width:100%} 会吃掉 width 属性 — 同一个坑第三次: dtSVG/台页/此处)
  701. const wrap = body => `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${n}"
  702. style="width:${W}px;height:${H}px;min-width:${W}px;vertical-align:-6px;margin-right:6px;flex:none;display:inline-block"
  703. ><g fill="none" stroke="${c}" stroke-width="1.3">${body}</g></svg>`;
  704. if(/齿轮箱|行星|中间轴|中速|高速/.test(n))
  705. return wrap(`<circle cx="15" cy="13" r="10.5"/><circle cx="15" cy="13" r="3.4"/>
  706. <circle cx="15" cy="5.5" r="3"/><circle cx="8.5" cy="17" r="3"/><circle cx="21.5" cy="17" r="3"/>
  707. <path d="M15,13 L15,5.5 M15,13 L8.5,17 M15,13 L21.5,17" stroke-width="0.9" stroke-opacity="0.75"/>`);
  708. if(/发电机|定子|转子|DE|NDE/.test(n))
  709. return wrap(`<rect x="4" y="6" width="22" height="14" rx="2.5"/>
  710. <path d="M4,10 h22 M4,16 h22" stroke-opacity="0.45"/>
  711. <rect x="9" y="10" width="12" height="6" rx="1" fill="${c}" fill-opacity="0.18" stroke="none"/>
  712. <path d="M1,13 h3 M26,13 h3" stroke-width="1.6"/>`);
  713. if(/主轴承|主轴/.test(n)){
  714. // 前后两个轴承都画出来 (2026-08-28 用户令), 命中的那个着色加粗, 另一个留灰 —
  715. // 左=前(轮毂侧) 右=后(齿轮箱侧), 与传动链图同一朝向; 未指明前后则两个都着色
  716. const hasF = /前/.test(n), hasR = /后/.test(n), both = !hasF && !hasR;
  717. const brg = (x, on) => {
  718. const st = on ? c : 'var(--muted)', w = on ? 1.7 : 1.05, op = on ? 1 : 0.55;
  719. return `<g stroke="${st}" stroke-width="${w}" stroke-opacity="${op}">
  720. <rect x="${x}" y="5.5" width="16" height="4" rx="1"/><rect x="${x}" y="16.5" width="16" height="4" rx="1"/>
  721. <circle cx="${x + 4.5}" cy="13" r="2.3"/><circle cx="${x + 11.5}" cy="13" r="2.3"/></g>`;
  722. };
  723. return `<svg viewBox="0 0 46 26" role="img" aria-label="${n}"
  724. style="width:46px;height:26px;min-width:46px;vertical-align:-6px;margin-right:6px;flex:none;display:inline-block">
  725. <g fill="none"><path d="M2,13 h42" stroke="var(--muted)" stroke-width="2.2" stroke-opacity="0.35"/>
  726. ${brg(4, hasF || both)}${brg(26, hasR || both)}</g></svg>`;
  727. }
  728. if(/变桨|桨|叶片|叶根|轮毂/.test(n))
  729. return wrap(`<circle cx="15" cy="13" r="7.5"/><circle cx="15" cy="13" r="4"/>
  730. <path d="M15,5.5 q7,-3 11,1 M15,20.5 q7,3 11,-1 M7.5,13 q-6,0 -6.5,-4" stroke-width="1.1" stroke-opacity="0.8"/>`);
  731. if(/偏航|解缆|扭缆|对风/.test(n))
  732. return wrap(`<circle cx="15" cy="15" r="9"/><circle cx="15" cy="15" r="5.5" stroke-dasharray="2 2"/>
  733. ${[0,72,144,216,288].map(a=>{const r=(a-90)*Math.PI/180;
  734. return `<circle cx="${(15+Math.cos(r)*9).toFixed(1)}" cy="${(15+Math.sin(r)*9).toFixed(1)}" r="1.7"/>`}).join('')}`);
  735. if(/变流|变频|逆变/.test(n))
  736. return wrap(`<rect x="5" y="6" width="20" height="14" rx="2"/><path d="M11,10 l-3,4 h5 l-3,4" stroke-width="1.2"/>
  737. <path d="M18,10 v8 M21.5,10 v8" stroke-opacity="0.6"/>`);
  738. if(/塔架|塔筒|基础|法兰/.test(n))
  739. return wrap(`<path d="M11,4 h8 l3,17 h-14 z"/><path d="M9,14 h12" stroke-opacity="0.5"/>
  740. <path d="M5,21 h20" stroke-width="1.8"/>`);
  741. return wrap(`<circle cx="15" cy="13" r="8" stroke-dasharray="3 2.5"/>`);
  742. }
  743. // 部件小图 (按部件截取: 只画命中的那一段, 不画整条链; 比八节点链条形象 — 带座体/齿形/散热筋等外形特征)
  744. function partGlyph(name, col, solid){
  745. // solid=true 仅用于报警/危险级 — 良好/优秀级用描边不填充, 否则密集表格里满屏深色块
  746. // (2026-08-28 用户逮'满屏黑化'; 对应 skill 原则: boldness 只花在一处, 其余安静)
  747. const c = col || 'currentColor', n = String(name || ''), MU = 'var(--muted)';
  748. const svg = (w, body) => `<svg viewBox="0 0 ${w} 38" role="img" aria-label="${n}"
  749. style="width:${w}px;height:38px;min-width:${w}px;vertical-align:-11px;flex:none;display:block">
  750. <g fill="none" stroke-linejoin="round">${body}</g></svg>`;
  751. if(/主轴承|主轴/.test(n)){
  752. const F = /前/.test(n), R = /后/.test(n), both = !F && !R;
  753. // 带座体的轴承: 座体梯形 + 外圈 + 滚子 + 内圈, 轴穿过
  754. const unit = (x, on) => {
  755. const st = on ? c : MU, w = on ? 1.7 : 1, op = on ? 1 : 0.5;
  756. return `<g stroke="${st}" stroke-width="${w}" stroke-opacity="${op}">
  757. <path d="M${x-2},32 h26 l-3,-8 h-20 z" fill="${on&&solid?c:'none'}" fill-opacity="${on&&solid?0.14:0}"/>
  758. <rect x="${x+1}" y="7" width="18" height="4" rx="1"/>
  759. <rect x="${x+1}" y="25" width="18" height="4" rx="1"/>
  760. <rect x="${x+1}" y="12.5" width="18" height="2.6" rx="0.8" stroke-opacity="${op*0.7}"/>
  761. <rect x="${x+1}" y="21" width="18" height="2.6" rx="0.8" stroke-opacity="${op*0.7}"/>
  762. <circle cx="${x+5}" cy="18" r="2.6"/><circle cx="${x+10}" cy="18" r="2.6"/><circle cx="${x+15}" cy="18" r="2.6"/></g>`;
  763. };
  764. return svg(96, `<path d="M2,18 h92" stroke="${MU}" stroke-width="3" stroke-opacity="0.3" stroke-linecap="round"/>
  765. ${unit(8, F||both)}${unit(56, R||both)}
  766. <text x="19" y="6" text-anchor="middle" font-size="8" fill="${F||both?c:MU}" stroke="none" opacity="${F||both?1:0.6}">前</text>
  767. <text x="67" y="6" text-anchor="middle" font-size="8" fill="${R||both?c:MU}" stroke="none" opacity="${R||both?1:0.6}">后</text>`);
  768. }
  769. if(/齿轮箱|行星|中间轴|中速|高速/.test(n)){
  770. const P = /行星/.test(n), M = /中间|中速/.test(n), H = /高速/.test(n), all = !P && !M && !H;
  771. const gear = (cx, cy, r, teeth, on) => {
  772. const st = on ? c : MU, sw = on ? 1.6 : 1, op = on ? 1 : 0.45;
  773. let t = '';
  774. for(let i=0;i<teeth;i++){const a=i*2*Math.PI/teeth;
  775. t += `<line x1="${(cx+Math.cos(a)*r).toFixed(1)}" y1="${(cy+Math.sin(a)*r).toFixed(1)}"
  776. x2="${(cx+Math.cos(a)*(r+2.4)).toFixed(1)}" y2="${(cy+Math.sin(a)*(r+2.4)).toFixed(1)}"/>`}
  777. return `<g stroke="${st}" stroke-width="${sw}" stroke-opacity="${op}"><circle cx="${cx}" cy="${cy}" r="${r}"/>${t}
  778. <circle cx="${cx}" cy="${cy}" r="${(r*0.3).toFixed(1)}"/></g>`;
  779. };
  780. return svg(104, `<path d="M3,10 h98 v22 q0,4 -4,4 h-90 q-4,0 -4,-4 z" stroke="${MU}" stroke-opacity="0.45" stroke-dasharray="3 2"/>
  781. ${gear(22, 21, 9, 12, P||all)}${gear(22, 21, 3.4, 0, P||all)}
  782. ${gear(55, 18, 7.5, 10, M||all)}${gear(80, 25, 5.5, 8, H||all)}
  783. <path d="M31,21 h16 M62,20 h11" stroke="${MU}" stroke-width="1.6" stroke-opacity="0.45"/>`);
  784. }
  785. if(/发电机|定子|转子|DE|NDE/.test(n)){
  786. const D = /(?<!N)DE/.test(n) && !/NDE/.test(n), ND = /NDE/.test(n), all = !D && !ND;
  787. const end = (x, on) => `<g stroke="${on?c:MU}" stroke-width="${on?1.7:1}" stroke-opacity="${on?1:0.5}">
  788. <rect x="${x}" y="12" width="9" height="14" rx="2"/><circle cx="${x+4.5}" cy="19" r="2.6"/></g>`;
  789. return svg(96, `<g stroke="${c}" stroke-width="1.3">
  790. <rect x="18" y="8" width="60" height="22" rx="3"/></g>
  791. <g stroke="${MU}" stroke-opacity="0.5" stroke-width="0.9">
  792. ${[26,34,42,50,58,66].map(x=>`<line x1="${x}" y1="8" x2="${x}" y2="30"/>`).join('')}</g>
  793. <rect x="30" y="14" width="36" height="10" rx="2" stroke="${c}" stroke-width="1.2" fill="${c}" fill-opacity="${solid?0.14:0.04}"/>
  794. <path d="M4,19 h14 M78,19 h14" stroke="${MU}" stroke-width="2.4" stroke-opacity="0.35" stroke-linecap="round"/>
  795. ${end(8, D||all)}${end(79, ND||all)}
  796. <text x="12" y="6" text-anchor="middle" font-size="8" fill="${D||all?c:MU}" stroke="none" opacity="${D||all?1:0.6}">DE</text>
  797. <text x="84" y="6" text-anchor="middle" font-size="8" fill="${ND||all?c:MU}" stroke="none" opacity="${ND||all?1:0.6}">NDE</text>`);
  798. }
  799. if(/变桨|桨|叶片|叶根|轮毂/.test(n))
  800. return svg(80, `<g stroke="${c}" stroke-width="1.5">
  801. <circle cx="26" cy="19" r="12"/><circle cx="26" cy="19" r="7"/>
  802. ${[0,120,240].map(a=>{const r=(a-90)*Math.PI/180;
  803. return `<circle cx="${(26+Math.cos(r)*9.6).toFixed(1)}" cy="${(19+Math.sin(r)*9.6).toFixed(1)}" r="2.2"/>`}).join('')}</g>
  804. <g stroke="${MU}" stroke-width="4" stroke-opacity="0.32" stroke-linecap="round">
  805. <path d="M38,19 q22,-3 36,-11"/><path d="M38,19 q22,3 36,11"/></g>`);
  806. if(/偏航|解缆|扭缆|对风/.test(n))
  807. return svg(80, `<g stroke="${c}" stroke-width="1.5"><circle cx="34" cy="21" r="13"/>
  808. ${Array.from({length:14},(_,i)=>{const a=i*2*Math.PI/14;
  809. return `<line x1="${(34+Math.cos(a)*13).toFixed(1)}" y1="${(21+Math.sin(a)*13).toFixed(1)}"
  810. x2="${(34+Math.cos(a)*15.6).toFixed(1)}" y2="${(21+Math.sin(a)*15.6).toFixed(1)}"/>`}).join('')}</g>
  811. <g stroke="${MU}" stroke-width="1.2" stroke-opacity="0.6">
  812. <circle cx="60" cy="14" r="5"/><circle cx="60" cy="28" r="5"/></g>
  813. <path d="M18,6 h32" stroke="${MU}" stroke-width="2" stroke-opacity="0.4"/>`);
  814. return svg(60, `<circle cx="30" cy="19" r="11" stroke="${MU}" stroke-dasharray="3 2.5" stroke-opacity="0.6"/>`);
  815. }
  816. // ══ 部件解剖图 (2026-08-28 用户令"部件UI图像化认真对待") ══
  817. // 原则: 画机制不画名字 — 每张图展示该系统的介质/动力如何流经部件, 箭头带口径标注,
  818. // 命中部件按状态着色; 一图一论点。与传动链简图分工: 传动链=链上哪一环, 解剖图=环内怎么坏的。
  819. const ANAT_C = st => ({'报警':'var(--bad)','危险':'var(--bad)','预警':'var(--warn)','良好':'var(--info)',
  820. '优秀':'var(--ok)','不可判':'var(--unknown)'}[st] || 'var(--muted)');
  821. function anatMainBearing(hit, states){
  822. // 主轴承集中润滑链: 泵 →(管路)→ 分配器 →(柱塞往复=出脂计量)→ 前/后轴承
  823. // 画的是"脂怎么到轴承", 29# 的失效点在泵本体 (柱塞单次时长拖长=打不满)
  824. const c = k => ANAT_C((states||{})[k]);
  825. const on = k => (hit||'').includes(k);
  826. const hl = k => on(k) ? 'var(--bad)' : 'currentColor';
  827. return `<figure style="margin:0"><svg viewBox="0 0 660 210" role="img" width="660"
  828. aria-label="主轴承集中润滑链: 润滑泵经管路到分配器, 分配器柱塞往复把脂定量送到前后主轴承"
  829. style="max-width:100%;height:auto;display:block;margin:0 auto">
  830. <g fill="none" stroke="currentColor" stroke-width="1.4" font-family="inherit">
  831. <!-- 脂桶+泵 -->
  832. <rect x="18" y="60" width="54" height="66" rx="4" stroke="${hl('泵')}" stroke-width="${on('泵')?2.4:1.4}"/>
  833. <path d="M28,70 h34 M28,80 h34 M28,90 h34" stroke-opacity="0.35"/>
  834. <text x="45" y="142" text-anchor="middle" font-size="11" fill="currentColor" stroke="none">脂桶</text>
  835. <circle cx="104" cy="93" r="17" stroke="${hl('泵')}" stroke-width="${on('泵')?2.4:1.4}"/>
  836. <path d="M97,86 l14,7 -14,7 z" fill="${hl('泵')}" stroke="none"/>
  837. <text x="104" y="128" text-anchor="middle" font-size="11" fill="${hl('泵')}" stroke="none">润滑泵</text>
  838. ${on('泵') ? '<text x="104" y="62" text-anchor="middle" font-size="10" fill="var(--bad)" stroke="none">本体失效</text>' : ''}
  839. <!-- 管路 -->
  840. <path d="M121,93 h58" marker-end="url(#aH)"/>
  841. <text x="150" y="86" text-anchor="middle" font-size="10" fill="currentColor" stroke="none" stroke-opacity="0">主管路</text>
  842. <!-- 分配器 -->
  843. <rect x="182" y="66" width="66" height="54" rx="4" stroke="${hl('分配器')}"/>
  844. <path d="M196,80 v26 M215,80 v26 M234,80 v26" stroke-opacity="0.55"/>
  845. <text x="215" y="134" text-anchor="middle" font-size="11" fill="currentColor" stroke="none">分配器</text>
  846. <text x="215" y="60" text-anchor="middle" font-size="9.5" fill="currentColor" stroke="none" opacity="0.7">柱塞往复=出脂计量</text>
  847. <!-- 支路到前后轴承 -->
  848. <path d="M248,80 h48 q12,0 12,-12 v-14" marker-end="url(#aH)"/>
  849. <path d="M248,106 h48 q12,0 12,12 v14" marker-end="url(#aH)"/>
  850. <text x="286" y="70" text-anchor="middle" font-size="9.5" fill="currentColor" stroke="none" opacity="0.7">前支路</text>
  851. <text x="286" y="140" text-anchor="middle" font-size="9.5" fill="currentColor" stroke="none" opacity="0.7">后支路</text>
  852. <!-- 前后轴承 (剖面) -->
  853. ${[['主轴承前', 34], ['主轴承后', 132]].map(([k, y]) => `
  854. <g stroke="${c(k)}" stroke-width="${(states||{})[k]==='报警'?2.4:1.4}">
  855. <rect x="330" y="${y}" width="86" height="16" rx="2"/>
  856. <rect x="330" y="${y+28}" width="86" height="16" rx="2"/>
  857. <circle cx="352" cy="${y+22}" r="5"/><circle cx="373" cy="${y+22}" r="5"/><circle cx="394" cy="${y+22}" r="5"/>
  858. </g>
  859. <text x="373" y="${y+58}" text-anchor="middle" font-size="11" fill="${c(k)}" stroke="none">${k}</text>`).join('')}
  860. <!-- 主轴穿过 -->
  861. <path d="M300,105 h180" stroke-width="7" stroke-opacity="0.25" stroke-linecap="round"/>
  862. <!-- 观测面标注 -->
  863. <path d="M215,120 v42" stroke-dasharray="3 3" stroke-opacity="0.6"/>
  864. <text x="215" y="176" text-anchor="middle" font-size="10" fill="currentColor" stroke="none">因侧: 柱塞计数/单次时长</text>
  865. <path d="M373,${(states||{})['主轴承后']?'190':'190'} v-14" stroke-dasharray="3 3" stroke-opacity="0.6"/>
  866. <text x="470" y="176" text-anchor="middle" font-size="10" fill="currentColor" stroke="none">果侧: 轴承温度 · 振动冲击</text>
  867. <defs><marker id="aH" markerWidth="7" markerHeight="7" refX="6" refY="3.5" orient="auto">
  868. <polygon points="0,0 7,3.5 0,7" fill="currentColor"/></marker></defs>
  869. </g></svg>
  870. <figcaption class="small" style="color:var(--muted);margin-top:6px">
  871. 脂路: 泵 → 主管路 → 分配器(柱塞定量) → 前/后支路 → 轴承。因侧看柱塞行为, 果侧看轴承温度与冲击 —
  872. 两侧不是两个独立源, 是同一条机制链的两端。</figcaption></figure>`;
  873. }
  874. function anatGearbox(hit, states){
  875. // 齿轮箱: 一级行星 + 两级平行轴 + 润滑/冷却回路 (油从箱底经泵/滤/冷却器回喷淋)
  876. const c = k => ANAT_C((states||{})[k]);
  877. const on = k => (hit||'').includes(k);
  878. const hl = k => on(k) ? 'var(--bad)' : 'currentColor';
  879. return `<figure style="margin:0"><svg viewBox="0 0 660 250" role="img" width="660"
  880. aria-label="齿轮箱: 一级行星加两级平行轴传动, 下方为润滑油路(泵-滤芯-冷却器-喷淋)"
  881. style="max-width:100%;height:auto;display:block;margin:0 auto">
  882. <g fill="none" stroke="currentColor" stroke-width="1.4">
  883. <rect x="24" y="18" width="500" height="128" rx="8" stroke-dasharray="5 4" stroke-opacity="0.5"/>
  884. <text x="274" y="14" text-anchor="middle" font-size="10.5" fill="currentColor" stroke="none" opacity="0.7">齿轮箱壳体 (i=119.752)</text>
  885. <!-- 行星级 -->
  886. <g stroke="${hl('行星')}" stroke-width="${on('行星')?2.2:1.4}">
  887. <circle cx="98" cy="82" r="46"/><circle cx="98" cy="82" r="15"/>
  888. <circle cx="98" cy="46" r="14"/><circle cx="67" cy="100" r="14"/><circle cx="129" cy="100" r="14"/>
  889. <path d="M98,82 L98,46 M98,82 L67,100 M98,82 L129,100" stroke-width="1"/>
  890. </g>
  891. <text x="98" y="146" text-anchor="middle" font-size="11" fill="${hl('行星')}" stroke="none">一级行星</text>
  892. <path d="M144,82 h40" marker-end="url(#gH)"/>
  893. <text x="164" y="74" text-anchor="middle" font-size="9" fill="currentColor" stroke="none" opacity="0.7">中速</text>
  894. <!-- 平行轴两级 -->
  895. <g stroke="${hl('中间')}" stroke-width="${on('中间')?2.2:1.4}">
  896. <rect x="188" y="52" width="16" height="60" rx="3"/><rect x="188" y="26" width="16" height="26" rx="3"/></g>
  897. <text x="196" y="146" text-anchor="middle" font-size="11" fill="${hl('中间')}" stroke="none">中间轴</text>
  898. <g stroke="${hl('高速')}" stroke-width="${on('高速')?2.2:1.4}">
  899. <rect x="244" y="26" width="16" height="52" rx="3"/><rect x="244" y="78" width="16" height="30" rx="3"/></g>
  900. <text x="252" y="146" text-anchor="middle" font-size="11" fill="${hl('高速')}" stroke="none">高速轴</text>
  901. <path d="M268,93 h56" marker-end="url(#gH)"/>
  902. <text x="296" y="86" text-anchor="middle" font-size="9" fill="currentColor" stroke="none" opacity="0.7">→ 发电机</text>
  903. <!-- 油路 -->
  904. <path d="M60,146 v34 h420 v-34" stroke-opacity="0.8"/>
  905. <circle cx="180" cy="180" r="13" stroke="${hl('油泵')}"/><path d="M174,174 l12,6 -12,6 z" fill="${hl('油泵')}" stroke="none"/>
  906. <text x="180" y="208" text-anchor="middle" font-size="10" fill="currentColor" stroke="none">油泵</text>
  907. <rect x="242" y="168" width="26" height="24" rx="2" stroke="${hl('滤芯')}"/>
  908. <path d="M246,172 l18,16 M264,172 l-18,16" stroke-width="0.9" stroke-opacity="0.6"/>
  909. <text x="255" y="208" text-anchor="middle" font-size="10" fill="currentColor" stroke="none">滤芯</text>
  910. <rect x="322" y="166" width="40" height="28" rx="3" stroke="${hl('冷却')}"/>
  911. <path d="M330,170 v20 M340,170 v20 M350,170 v20" stroke-width="0.9" stroke-opacity="0.6"/>
  912. <text x="342" y="208" text-anchor="middle" font-size="10" fill="${hl('冷却')}" stroke="none">冷却器</text>
  913. <path d="M410,180 v-20" marker-end="url(#gH)"/>
  914. <text x="446" y="176" text-anchor="middle" font-size="10" fill="currentColor" stroke="none">喷淋回箱</text>
  915. <!-- 观测面 -->
  916. <text x="560" y="60" font-size="10" fill="currentColor" stroke="none">观测面:</text>
  917. <text x="560" y="78" font-size="10" fill="currentColor" stroke="none" opacity="0.8">· 振动 (各级测点)</text>
  918. <text x="560" y="94" font-size="10" fill="currentColor" stroke="none" opacity="0.8">· 轴承/油温 ΔT</text>
  919. <text x="560" y="110" font-size="10" fill="currentColor" stroke="none" opacity="0.8">· 滤芯压差码</text>
  920. <text x="560" y="126" font-size="10" fill="currentColor" stroke="none" opacity="0.8">· 油样铁谱</text>
  921. <defs><marker id="gH" markerWidth="7" markerHeight="7" refX="6" refY="3.5" orient="auto">
  922. <polygon points="0,0 7,3.5 0,7" fill="currentColor"/></marker></defs>
  923. </g></svg>
  924. <figcaption class="small" style="color:var(--muted);margin-top:6px">
  925. 传动: 行星级(输入) → 中间轴 → 高速轴(输出)。油路: 箱底 → 泵 → 滤芯 → 冷却器 → 喷淋回箱 —
  926. 过热类失效沿油路查(冷却/滤芯), 磨损类沿齿轮线查(振动/铁谱)。</figcaption></figure>`;
  927. }
  928. function anatPitch(hit, states){
  929. // 变桨: 蓄能器储压 → 比例阀 → 油缸推动变桨轴承 → 桨叶; 滑环供电与信号
  930. const c = k => ANAT_C((states||{})[k]);
  931. const on = k => (hit||'').includes(k);
  932. const hl = k => on(k) ? 'var(--bad)' : 'currentColor';
  933. return `<figure style="margin:0"><svg viewBox="0 0 660 230" role="img" width="660"
  934. aria-label="变桨系统: 蓄能器储压经比例阀推动油缸, 油缸驱动变桨轴承转动桨叶; 滑环传电与信号"
  935. style="max-width:100%;height:auto;display:block;margin:0 auto">
  936. <g fill="none" stroke="currentColor" stroke-width="1.4">
  937. <!-- 滑环 -->
  938. <g stroke="${hl('滑环')}"><rect x="20" y="86" width="30" height="50" rx="4"/>
  939. <path d="M20,96 h30 M20,110 h30 M20,124 h30" stroke-opacity="0.5"/></g>
  940. <text x="35" y="152" text-anchor="middle" font-size="10.5" fill="currentColor" stroke="none">滑环</text>
  941. <path d="M50,111 h34" marker-end="url(#pH)"/>
  942. <text x="67" y="104" text-anchor="middle" font-size="9" fill="currentColor" stroke="none" opacity="0.7">电/信号</text>
  943. <!-- 蓄能器 -->
  944. <g stroke="${hl('蓄能')}" stroke-width="${on('蓄能')?2.2:1.4}">
  945. <rect x="88" y="70" width="40" height="82" rx="20"/>
  946. <path d="M88,104 h40" stroke-dasharray="3 2"/></g>
  947. <text x="108" y="166" text-anchor="middle" font-size="10.5" fill="${hl('蓄能')}" stroke="none">蓄能器</text>
  948. <text x="108" y="62" text-anchor="middle" font-size="9" fill="currentColor" stroke="none" opacity="0.7">氮气预充</text>
  949. <path d="M128,111 h36" marker-end="url(#pH)"/>
  950. <!-- 比例阀 -->
  951. <g stroke="${hl('阀')}"><rect x="166" y="92" width="34" height="38" rx="3"/>
  952. <path d="M166,111 h34 M183,92 v38" stroke-opacity="0.5"/></g>
  953. <text x="183" y="146" text-anchor="middle" font-size="10.5" fill="currentColor" stroke="none">比例阀</text>
  954. <path d="M200,111 h34" marker-end="url(#pH)"/>
  955. <text x="217" y="104" text-anchor="middle" font-size="9" fill="currentColor" stroke="none" opacity="0.7">压力</text>
  956. <!-- 油缸 -->
  957. <g stroke="${hl('油缸')}" stroke-width="${on('油缸')?2.2:1.4}">
  958. <rect x="236" y="94" width="86" height="34" rx="3"/>
  959. <rect x="246" y="100" width="30" height="22" rx="2" fill="currentColor" fill-opacity="0.14"/>
  960. <path d="M276,111 h66"/></g>
  961. <text x="279" y="146" text-anchor="middle" font-size="10.5" fill="${hl('油缸')}" stroke="none">变桨油缸</text>
  962. <!-- 变桨轴承 + 桨叶 -->
  963. <g stroke="${hl('轴承')}" stroke-width="${on('轴承')?2.2:1.4}">
  964. <circle cx="392" cy="111" r="34"/><circle cx="392" cy="111" r="24"/>
  965. ${[0,120,240].map(a=>{const r=(a-90)*Math.PI/180;
  966. return `<circle cx="${(392+Math.cos(r)*29).toFixed(1)}" cy="${(111+Math.sin(r)*29).toFixed(1)}" r="3.4"/>`}).join('')}</g>
  967. <text x="392" y="164" text-anchor="middle" font-size="10.5" fill="${hl('轴承')}" stroke="none">变桨轴承</text>
  968. <path d="M426,111 q46,-4 74,-40" stroke-width="5" stroke-opacity="0.3" stroke-linecap="round"/>
  969. <path d="M426,111 q46,4 74,40" stroke-width="5" stroke-opacity="0.3" stroke-linecap="round"/>
  970. <text x="520" y="76" font-size="10.5" fill="currentColor" stroke="none">桨叶 A/B/C</text>
  971. <!-- 观测面 -->
  972. <path d="M108,152 v30 h284 v-18" stroke-dasharray="3 3" stroke-opacity="0.55"/>
  973. <text x="250" y="196" text-anchor="middle" font-size="10" fill="currentColor" stroke="none">
  974. 观测: 蓄能带宽/停机泄压 · 桨距跟踪偏差 · 三叶零位一致性 · 轴承润滑脂</text>
  975. <defs><marker id="pH" markerWidth="7" markerHeight="7" refX="6" refY="3.5" orient="auto">
  976. <polygon points="0,0 7,3.5 0,7" fill="currentColor"/></marker></defs>
  977. </g></svg>
  978. <figcaption class="small" style="color:var(--muted);margin-top:6px">
  979. 动力: 蓄能器储压 → 比例阀 → 油缸 → 变桨轴承 → 桨叶。压力侧失效看蓄能带宽与泄压率,
  980. 执行侧看桨距跟踪偏差, 三叶不一致指向单支路。</figcaption></figure>`;
  981. }
  982. const ANAT = {'主轴承': anatMainBearing, '齿轮箱': anatGearbox, '变桨': anatPitch};
  983. // 链条缩略版 (表格用: 列宽有限, 全剖面被挤成49px不可读) — 同一图形语言的 sparkline。
  984. // 2026-08-28 用户"更清楚": 八个一样的圆点分不出段落 → 节点形状语义化 (轴承=双环 · 齿轮=带齿圆)
  985. // + 三段分区底纹 (主轴 / 齿轮箱 / 发电机), 命中环放大填色+外发光, 仍零文字。
  986. const dtMini=(comp,sev,W,T2)=>{
  987. const c=String(comp||''), w=W||208;
  988. const hit=n=>({'主轴承前':/主轴承[((]前|主轴承前|主轴承[++]|主轴承(?![((前后])/,'主轴承后':/主轴承[((]后|主轴承后/,
  989. '行星':/行星|二级行星/,'中间':/中间轴|中速/,'高速':/高速轴|高速(?!.*发电)/,
  990. '发电机DE':/发电机DE|发电机[((]|发电机(?!NDE)/,'发电机NDE':/发电机NDE|NDE/}[n].test(c));
  991. const gball=/齿轮箱/.test(c)&&!/行星|中间轴|中速|高速轴/.test(c);
  992. const on=n=>hit(n)||(gball&&['行星','中间','高速'].includes(n));
  993. const Y=20, MU='var(--muted)', PN='var(--panel)', LN='var(--line)';
  994. const zone=(x,w2,dash)=>`<rect x="${x}" y="5" width="${w2}" height="30" rx="6" fill="var(--muted)" fill-opacity="0.13"
  995. stroke="${dash&&gball?sev:LN}" stroke-opacity="${dash&&gball?0.9:0.5}" ${dash?'stroke-dasharray="3 2"':''}/>`;
  996. // 轴承节点 = 双环 (外圈+滚道); 齿轮节点 = 圆+四齿
  997. const node=(x,n,kind)=>{
  998. const k=on(n), col=k?sev:MU, r=k?7:5.4;
  999. const teeth=kind==='g'?[0,90,180,270].map(a=>{const t=a*Math.PI/180;
  1000. return `<line x1="${(x+Math.cos(t)*r).toFixed(1)}" y1="${(Y+Math.sin(t)*r).toFixed(1)}" x2="${(x+Math.cos(t)*(r+2.6)).toFixed(1)}" y2="${(Y+Math.sin(t)*(r+2.6)).toFixed(1)}" stroke="${col}" stroke-width="${k?1.8:1.2}"/>`}).join(''):'';
  1001. const core=(k?`<circle class="dt-halo" cx="${x}" cy="${Y}" r="${r+6}" fill="${sev}"/>`:'')
  1002. +`<circle cx="${x}" cy="${Y}" r="${r}" fill="${k?sev:PN}" fill-opacity="${k?0.9:1}" stroke="${col}" stroke-opacity="${k?1:0.85}" stroke-width="${k?2.2:1.4}"/>`
  1003. +(kind==='b'?`<circle cx="${x}" cy="${Y}" r="${(r*0.42).toFixed(1)}" fill="${k?'#fff':MU}" fill-opacity="${k?0.85:0.7}" stroke="none"/>`:teeth);
  1004. return (k&&T2)?`<g class="dt-hot" onclick="event.stopPropagation();popPart('${T2}','${n}')"><title>${n} — 点看曲线与四源微视图</title>
  1005. <circle cx="${x}" cy="${Y}" r="${r+7}" fill="transparent"/>${core}</g>`:core;
  1006. };
  1007. return `<svg class="dt" viewBox="0 0 320 40" style="display:block;width:${w}px;max-width:100%;height:auto">
  1008. ${zone(28,74)}${zone(112,90,1)}${zone(212,96)}
  1009. <line x1="20" y1="${Y}" x2="304" y2="${Y}" stroke="${LN}" stroke-width="3" stroke-linecap="round"/>
  1010. <path d="M20,13 L11,4 M20,27 L11,36 M20,${Y} L9,${Y}" stroke="${MU}" stroke-width="1.6" fill="none"/>
  1011. ${node(46,'主轴承前','b')}${node(88,'主轴承后','b')}
  1012. ${node(133,'行星','g')}${node(157,'中间','g')}${node(181,'高速','g')}
  1013. ${node(236,'发电机DE','b')}${node(282,'发电机NDE','b')}
  1014. </svg>`};
  1015. const dtSVG=(comp,sev,W)=>{
  1016. const c=String(comp||''), w=W||660;
  1017. const hit=n=>({'主轴承前':/主轴承[((]前|主轴承前|主轴承[++]|主轴承(?![((前后])/,'主轴承后':/主轴承[((]后|主轴承后/,
  1018. '行星':/行星|二级行星/,'中间':/中间轴|中速/,'高速':/高速轴|高速(?!.*发电)/,
  1019. '发电机DE':/发电机DE|发电机[((]|发电机(?!NDE)/,'发电机NDE':/发电机NDE|NDE/}[n].test(c));
  1020. const gball=/齿轮箱/.test(c)&&!/行星|中间轴|中速|高速轴/.test(c);
  1021. const on=n=>hit(n)||(gball&&['行星','中间','高速'].includes(n));
  1022. const LN='var(--line)', MU='var(--muted)', PN='var(--panel)';
  1023. const K=660/w, FS=(11*K).toFixed(1), SW=(1.5*K).toFixed(2); // 缩放补偿: 显示字号/线宽与图宽无关
  1024. // 部件绘制器: 命中→光晕+sev描边加粗+标名; 未命中→灰描边无字 (纯图形: 只有出问题的那一环有字)
  1025. const part=(n,label,lx,ly,body,halo)=>{
  1026. const k=on(n), col=k?sev:MU;
  1027. return (k&&halo?`<rect class="dt-halo" x="${halo[0]}" y="${halo[1]}" width="${halo[2]}" height="${halo[3]}" rx="8" fill="${sev}"/>`:'')
  1028. +`<g stroke="${col}" stroke-width="${k?SW:0.9*K}" fill="${PN}" fill-opacity="1">${body}</g>`
  1029. +(k?`<text x="${lx}" y="${ly}" text-anchor="middle" font-size="${FS}" font-weight="600" fill="${sev}">${label}</text>`:'');
  1030. };
  1031. // 轴承剖面符号 (上下外圈+滚子+内圈)
  1032. const brg=(x,n,label,ly)=>part(n,label,x,ly,
  1033. `<rect x="${x-17}" y="47" width="34" height="7" rx="1.5"/><rect x="${x-17}" y="96" width="34" height="7" rx="1.5"/>`+
  1034. [-11,0,11].map(d=>`<circle cx="${x+d}" cy="60" r="4.2"/><circle cx="${x+d}" cy="90" r="4.2"/>`).join('')+
  1035. `<rect x="${x-17}" y="66" width="34" height="4" rx="1"/><rect x="${x-17}" y="80" width="34" height="4" rx="1"/>`,[x-24,40,48,70]);
  1036. // 齿轮 = 竖矩形 + 齿纹
  1037. const gear=(x,cy,D,n,label,ly)=>part(n,label,x+6,ly,
  1038. `<rect x="${x}" y="${cy-D/2}" width="13" height="${D}" rx="2"/>`+
  1039. Array.from({length:Math.max(3,Math.round(D/9))},(_,i)=>{const yy=cy-D/2+4+i*(D-8)/Math.max(2,Math.round(D/9)-1);
  1040. return `<line x1="${x}" y1="${yy}" x2="${x+13}" y2="${yy}" stroke-width="${0.7*K}" stroke-opacity="0.55"/>`}).join(''),[x-7,cy-D/2-7,27,D+14]);
  1041. const shaft=(x1,x2,y,th)=>`<line x1="${x1}" y1="${y}" x2="${x2}" y2="${y}" stroke="${LN}" stroke-width="${th||9}" stroke-linecap="round"/>`;
  1042. return `<svg class="dt" viewBox="0 0 660 150" style="display:block;width:${w}px;max-width:100%;height:auto;margin:6px auto">
  1043. <!-- 轮毂+叶根 -->
  1044. <g stroke="${MU}" fill="${PN}"><path d="M22,52 L48,62 L48,88 L22,98 Z" stroke-width="1"/>
  1045. <line x1="22" y1="75" x2="6" y2="75" stroke-width="3"/><line x1="24" y1="60" x2="10" y2="34" stroke-width="3"/>
  1046. <line x1="24" y1="90" x2="10" y2="116" stroke-width="3"/></g>
  1047. ${shaft(46,206,75,12)}
  1048. ${brg(88,'主轴承前','主轴承 前',126)}${brg(154,'主轴承后','主轴承 后',126)}
  1049. <!-- 齿轮箱壳 -->
  1050. <rect x="196" y="16" width="242" height="118" rx="7" fill="none"
  1051. stroke="${gball?sev:LN}" stroke-width="${gball?1.6:1}" stroke-dasharray="4 3" stroke-opacity="${gball?0.9:0.6}"/>
  1052. <!-- 一级行星: 齿圈(上下)+行星轮+太阳轮+行星架 -->
  1053. ${part('行星','行星级',236,146,
  1054. `<rect x="210" y="24" width="52" height="8" rx="2"/><rect x="210" y="118" width="52" height="8" rx="2"/>
  1055. <rect x="228" y="38" width="14" height="26" rx="2"/><rect x="228" y="86" width="14" height="26" rx="2"/>
  1056. <rect x="228" y="62" width="14" height="26" rx="2"/>
  1057. <path d="M210,75 L228,51 M210,75 L228,99" stroke-width="${1.4*K}" fill="none"/>`,[203,17,66,116])}
  1058. ${shaft(256,306,75,7)}
  1059. <!-- 二级 中速→中间轴 (节点相切) -->
  1060. ${gear(300,75,46,'中间','中间轴',146)}
  1061. ${gear(300,37,30,'中间','',0)}
  1062. ${shaft(306,352,37,6)}
  1063. <!-- 三级 中间轴→高速轴 -->
  1064. ${gear(348,37,50,'高速','高速轴',146)}
  1065. ${gear(348,75,26,'高速','',0)}
  1066. ${shaft(360,438,75,8)}
  1067. <!-- 联轴器 -->
  1068. <g stroke="${MU}" fill="${PN}"><rect x="438" y="62" width="6" height="26" rx="1"/><rect x="452" y="62" width="6" height="26" rx="1"/>
  1069. <line x1="444" y1="75" x2="452" y2="75" stroke-width="2"/></g>
  1070. <!-- 发电机: 壳+定子+转子+两端轴承 -->
  1071. <rect x="462" y="34" width="184" height="82" rx="7" fill="none" stroke="${LN}" stroke-dasharray="4 3" stroke-opacity="0.6"/>
  1072. <g stroke="${MU}" fill="${PN}"><rect x="486" y="42" width="136" height="12" rx="2"/><rect x="486" y="96" width="136" height="12" rx="2"/>
  1073. <rect x="492" y="60" width="124" height="30" rx="3" fill-opacity="0.35"/></g>
  1074. ${shaft(430,652,75,8)}
  1075. ${part('发电机DE','发电机 DE',478,146,`<rect x="470" y="56" width="16" height="12" rx="2"/><rect x="470" y="82" width="16" height="12" rx="2"/>
  1076. <circle cx="478" cy="64" r="3.4"/><circle cx="478" cy="86" r="3.4"/>`,[464,48,28,54])}
  1077. ${part('发电机NDE','发电机 NDE',628,146,`<rect x="620" y="56" width="16" height="12" rx="2"/><rect x="620" y="82" width="16" height="12" rx="2"/>
  1078. <circle cx="628" cy="64" r="3.4"/><circle cx="628" cy="86" r="3.4"/>`,[614,48,28,54])}
  1079. </svg>`};
  1080. function chip(s){return `<span class="chip" style="--c:${C[s]||'#9aa4b0'}">${s}</span>`}
  1081. function sc(v,lo,hi,a,b){return hi>lo? a+(b-a)*(v-lo)/(hi-lo) : (a+b)/2}
  1082. // 图幅分级 (2026-08-28 用户令"根据系统和分类要合理大小"):
  1083. // L=整行 (传动链/振动标量/功率曲线 — 需要横向空间, 但图身封顶 880/760 不随容器无限拉伸)
  1084. // M=常规格 (月度趋势/计数条 — 两张并排可比)
  1085. const FIGW = {vib: 880, vibbar: 900, xy: 760, band: 620, bar: 620, line: 620};
  1086. const isL = f => f.size === 'L' || ['vib', 'vibbar', 'xy'].includes(f.kind);
  1087. const figWrap = (f, inner, note) => `<div class="card"${isL(f) ? ' style="grid-column:1/-1"' : ''}>
  1088. <h3>${f.title || ''}</h3><div style="max-width:${FIGW[f.kind] || 620}px;margin:0 auto">${inner}</div>
  1089. ${note ? `<p class="small" style="margin-top:6px;color:var(--muted)">${note}</p>` : ''}</div>`;
  1090. // 故障模式四象限 (经典可靠性分析)。x=发生条数(对数) y=单次中位时长(对数);
  1091. // 右下=高频短时(抖动/刷屏, 调参或屏蔽) 左上=低频长时(硬故障, 备件/检修)
  1092. // 右上=高频长时(重点治理) 左下=低频短时(本底)。点面积=影响台数, 分离单台刷屏与批次共性。
  1093. function quadChart(items){
  1094. const it=(items||[]).filter(x=>x&&x.n>0&&x.med>0);
  1095. if(it.length<3) return '<p class="small">时间窗内码数不足, 不作四象限</p>';
  1096. const W=720,H=330,ML=58,MR=16,MB=44,MT=16;
  1097. const lx=x=>Math.log10(x), xs=it.map(d=>lx(d.n)), ys=it.map(d=>lx(d.med));
  1098. const x0=Math.min(...xs)-0.15,x1=Math.max(...xs)+0.15,y0=Math.min(...ys)-0.15,y1=Math.max(...ys)+0.15;
  1099. const PX=v=>ML+(lx(v)-x0)/(x1-x0)*(W-ML-MR), PY=v=>H-MB-(lx(v)-y0)/(y1-y0)*(H-MT-MB);
  1100. // 分界: 条数中位 与 单次时长 600s(10min, 一个SCADA采样格) — 时长界是物理刻度不是分位数
  1101. const srt=[...it].map(d=>d.n).sort((a,b)=>a-b), nmid=srt[Math.floor(srt.length/2)];
  1102. const cx=PX(nmid), cy=PY(600);
  1103. const mt=Math.max(...it.map(d=>d.nt));
  1104. const pts=it.map(d=>{
  1105. const r=3+Math.sqrt(d.nt/mt)*7, hot=(d.n>=nmid&&d.med>=600);
  1106. return `<circle cx="${PX(d.n).toFixed(1)}" cy="${PY(d.med).toFixed(1)}" r="${r.toFixed(1)}"
  1107. fill="${hot?'var(--bad)':'var(--accent)'}" fill-opacity="0.5" stroke="${hot?'var(--bad)':'var(--accent)'}" stroke-width="1">
  1108. <title>${d.code} ${d.k}
  1109. ${d.n} 条 · 单次中位 ${d.med}s · 累计 ${d.h}h · ${d.nt} 台</title></circle>`;}).join('');
  1110. const lab=it.filter(d=>d.n>=nmid||d.med>=600).slice(0,8).map(d=>
  1111. `<text x="${(PX(d.n)+9).toFixed(1)}" y="${(PY(d.med)+3).toFixed(1)}" font-size="9" fill="var(--muted)">${d.code}</text>`).join('');
  1112. const qn=(x,y,t,sub)=>`<text x="${x}" y="${y}" font-size="10" fill="var(--muted)" opacity="0.85">${t}</text>
  1113. <text x="${x}" y="${y+12}" font-size="8.5" fill="var(--muted)" opacity="0.6">${sub}</text>`;
  1114. const tick=v=>v>=3600?(v/3600)+'h':(v>=60?(v/60)+'min':v+'s');
  1115. const yt=[10,60,600,3600,36000].filter(v=>lx(v)>=y0&&lx(v)<=y1).map(v=>
  1116. `<line x1="${ML}" y1="${PY(v).toFixed(1)}" x2="${W-MR}" y2="${PY(v).toFixed(1)}" stroke="var(--line)" stroke-opacity="0.4"/>
  1117. <text x="${ML-6}" y="${(PY(v)+3).toFixed(1)}" font-size="9" fill="var(--muted)" text-anchor="end">${tick(v)}</text>`).join('');
  1118. const xt=[1,10,100,1000,10000].filter(v=>lx(v)>=x0&&lx(v)<=x1).map(v=>
  1119. `<text x="${PX(v).toFixed(1)}" y="${H-MB+14}" font-size="9" fill="var(--muted)" text-anchor="middle">${v}</text>`).join('');
  1120. return `<svg class="dt" viewBox="0 0 ${W} ${H}" style="width:${W}px;max-width:100%;height:auto">
  1121. ${yt}${xt}
  1122. <line x1="${cx.toFixed(1)}" y1="${MT}" x2="${cx.toFixed(1)}" y2="${H-MB}" stroke="var(--muted)" stroke-dasharray="3 3" stroke-opacity="0.5"/>
  1123. <line x1="${ML}" y1="${cy.toFixed(1)}" x2="${W-MR}" y2="${cy.toFixed(1)}" stroke="var(--muted)" stroke-dasharray="3 3" stroke-opacity="0.5"/>
  1124. ${qn(ML+6,MT+12,'低频长时 · 硬故障','备件与检修窗')}
  1125. ${qn(W-MR-96,MT+12,'高频长时 · 重点治理','优先派工')}
  1126. ${qn(ML+6,H-MB-18,'低频短时 · 本底','不单独处置')}
  1127. ${qn(W-MR-96,H-MB-18,'高频短时 · 重复触发','调整参数或屏蔽, 非硬件故障')}
  1128. ${pts}${lab}
  1129. <text x="${(ML+(W-MR-ML)/2).toFixed(1)}" y="${H-6}" font-size="9.5" fill="var(--muted)" text-anchor="middle">发生条数 (对数)</text>
  1130. <text x="14" y="${(MT+(H-MB-MT)/2).toFixed(1)}" font-size="9.5" fill="var(--muted)" text-anchor="middle" transform="rotate(-90 14 ${(MT+(H-MB-MT)/2).toFixed(1)})">单次中位时长 (对数)</text>
  1131. </svg>`;
  1132. }
  1133. // 月度故障率图 (2026-08-28 审核逮的反向误读)。
  1134. // 原图画"月度条数", 末月 1371 条看着是全时间窗最低=在改善; 但 2026-07 只有 6 天数据(覆盖 19.4%),
  1135. // 按日均归一后是 228 条/日 = 全时间窗第二高, 是 6 月(75)的 3 倍 — 方向完全相反。
  1136. // ⇒ 残月绝不能与整月并列比较。柱=日均(可比), 灰斜纹=数据不满月, 数字标总条数(原始事实)。
  1137. function rateChart(mo){
  1138. const ms=mo.months, vs=mo.vals, cov=mo.cov||ms.map(()=>1), pd=mo.per_day||[];
  1139. const W=720,H=250,ML=48,MR=14,MB=52,MT=18, n=ms.length;
  1140. const mx=Math.max(...pd.filter(x=>x!=null),1), bw=(W-ML-MR)/n;
  1141. let bars='', lab='';
  1142. ms.forEach((m,i)=>{
  1143. const v=pd[i], part=cov[i]<0.5;
  1144. if(v==null) return;
  1145. const hh=v/mx*(H-MT-MB), bx=ML+i*bw+bw*0.16, by=H-MB-hh;
  1146. bars+=`<rect x="${bx.toFixed(1)}" y="${by.toFixed(1)}" width="${(bw*0.68).toFixed(1)}" height="${hh.toFixed(1)}" rx="2"
  1147. fill="${part?'var(--muted)':'var(--accent)'}" fill-opacity="${part?0.32:0.8}"
  1148. stroke="${part?'var(--warn)':'none'}" stroke-dasharray="${part?'3 2':''}">
  1149. <title>${m}: ${v} 条/日 (总 ${vs[i]} 条, 数据覆盖 ${(cov[i]*100).toFixed(0)}%)</title></rect>`;
  1150. lab+=`<text x="${(bx+bw*0.34).toFixed(1)}" y="${(by-4).toFixed(1)}" font-size="9" fill="var(--muted)" text-anchor="middle">${v}</text>`
  1151. +`<text x="${(bx+bw*0.34).toFixed(1)}" y="${H-MB+13}" font-size="9" fill="var(--muted)" text-anchor="middle">${m.slice(5)}月</text>`
  1152. +`<text x="${(bx+bw*0.34).toFixed(1)}" y="${H-MB+25}" font-size="8" fill="var(--muted)" opacity="0.65" text-anchor="middle">${vs[i]}条</text>`
  1153. +(part?`<text x="${(bx+bw*0.34).toFixed(1)}" y="${H-MB+37}" font-size="8" fill="var(--warn)" text-anchor="middle">仅${Math.round(cov[i]*30)}天</text>`:'');
  1154. });
  1155. const yt=[0,.5,1].map(f=>`<text x="${ML-6}" y="${(H-MB-f*(H-MT-MB)+3).toFixed(1)}" font-size="9" fill="var(--muted)" text-anchor="end">${Math.round(mx*f)}</text>`).join('');
  1156. return `<svg class="dt" viewBox="0 0 ${W} ${H}" style="width:${W}px;max-width:100%;height:auto">
  1157. <line x1="${ML}" y1="${H-MB}" x2="${W-MR}" y2="${H-MB}" stroke="var(--line)"/>
  1158. <line x1="${ML}" y1="${MT}" x2="${ML}" y2="${H-MB}" stroke="var(--line)"/>
  1159. ${bars}${lab}${yt}
  1160. <text x="14" y="${(MT+(H-MB-MT)/2).toFixed(1)}" font-size="9.5" fill="var(--muted)" text-anchor="middle"
  1161. transform="rotate(-90 14 ${(MT+(H-MB-MT)/2).toFixed(1)})">条/日</text></svg>`;
  1162. }
  1163. // 能量账瀑布 (经典发电量分析)。四行工况表回答不了业主唯一关心的问题: 电去哪了。
  1164. // 左=理论可发, 中间逐段扣损(按损失降序), 右=实际上网; 段高即电量, 一眼看出谁是大头。
  1165. // 物理闸: 理论可发不得超 台数×额定×窗时长 (能量守恒, memory ledger-loss-numbers-need-physical-cap-check)。
  1166. const LOSSPLAIN = {
  1167. '限电·绑定':'限电 (命令与实际功率对得上)',
  1168. '限电命令·未绑定':'限电命令期 · 功率未被压制',
  1169. '停机(调度令)':'调度令停机',
  1170. '停机':'故障与检修停机',
  1171. '正常发电':'正常发电时的欠发',
  1172. '低风待机':'低风待机'};
  1173. function waterfall(e){
  1174. if(!e||!e.theo) return '';
  1175. const W=720,H=300,ML=8,MR=8,MT=30,MB=64, items=(e.items||[]).filter(x=>x.loss>0);
  1176. const cols=items.length+2, cw=(W-ML-MR)/cols, sc=(H-MT-MB)/e.theo;
  1177. let run=e.theo, h='', i=0;
  1178. const bar=(idx,y,hh,fill,op)=>`<rect x="${(ML+idx*cw+cw*0.14).toFixed(1)}" y="${y.toFixed(1)}"
  1179. width="${(cw*0.72).toFixed(1)}" height="${Math.max(hh,1.2).toFixed(1)}" rx="2" fill="${fill}" fill-opacity="${op}"/>`;
  1180. const lab=(idx,y,t,v,c)=>`<text x="${(ML+idx*cw+cw*0.5).toFixed(1)}" y="${(y-6).toFixed(1)}" font-size="10"
  1181. fill="${c||'var(--fg)'}" text-anchor="middle" font-weight="600">${v}</text>`
  1182. +`<text x="${(ML+idx*cw+cw*0.5).toFixed(1)}" y="${H-MB+14}" font-size="8.5" fill="var(--muted)" text-anchor="middle">${t}</text>`;
  1183. h+=bar(0,H-MB-e.theo*sc,e.theo*sc,'var(--info)',0.45)
  1184. +lab(0,H-MB-e.theo*sc,'理论可发',Math.round(e.theo).toLocaleString());
  1185. items.forEach(x=>{ i++;
  1186. const top=H-MB-run*sc, hh=x.loss*sc; run-=x.loss;
  1187. const nm=LOSSPLAIN[x.k]||x.k;
  1188. h+=bar(i,top,hh,'var(--bad)',0.55)
  1189. +`<line x1="${(ML+(i-1)*cw+cw*0.86).toFixed(1)}" y1="${top.toFixed(1)}" x2="${(ML+i*cw+cw*0.14).toFixed(1)}" y2="${top.toFixed(1)}"
  1190. stroke="var(--muted)" stroke-dasharray="2 2" stroke-opacity="0.6"/>`
  1191. +lab(i,top,nm.length>10?nm.slice(0,9)+'…':nm,'−'+Math.round(x.loss).toLocaleString(),'var(--bad)')
  1192. +`<title>${nm}: 损失 ${x.loss.toLocaleString()} MWh, 占理论 ${(x.loss/e.theo*100).toFixed(1)}%, 时长 ${x.h.toLocaleString()}h</title>`;
  1193. });
  1194. h+=bar(cols-1,H-MB-e.act*sc,e.act*sc,'var(--ok)',0.6)
  1195. +lab(cols-1,H-MB-e.act*sc,'实际上网',Math.round(e.act).toLocaleString(),'var(--ok)');
  1196. return `<svg class="dt" viewBox="0 0 ${W} ${H}" style="width:${W}px;max-width:100%;height:auto">
  1197. <line x1="${ML}" y1="${H-MB}" x2="${W-MR}" y2="${H-MB}" stroke="var(--line)"/>
  1198. <text x="${ML}" y="14" font-size="9.5" fill="var(--muted)">MWh</text>${h}</svg>`;
  1199. }
  1200. // 可靠性-可维修性象限 (经典 RAM 分析, 2026-08-28 用户令: 停机事件化与可靠性可视化)。
  1201. // x=MTBF 平均无故障间隔(可靠性), y=MDT 平均单次停机时长(可维修性), 点面积=累计停机时长。
  1202. // 四象限的处置方向完全不同 — 表格按"停机时长"排序把这层结构整个抹掉了:
  1203. // 左上 常坏且难修 → 最高优先级 左下 频发易修 → 查根因(治本, 换件无用)
  1204. // 右上 罕发难修 → 备件与作业窗 右下 少发好修 → 维持
  1205. function ramChart(tb){
  1206. const it=(tb||[]).filter(x=>x.MTBF_h&&x.MDT_h);
  1207. if(it.length<3) return '<p class="small">部件数不足, 不作象限</p>';
  1208. const W=720,H=340,ML=56,MR=112,MB=48,MT=16;
  1209. const lg=v=>Math.log10(v), xs=it.map(d=>lg(d.MTBF_h)), ys=it.map(d=>d.MDT_h);
  1210. const x0=Math.min(...xs)-0.08,x1=Math.max(...xs)+0.08,y0=0,y1=Math.max(...ys)*1.12;
  1211. const PX=v=>ML+(lg(v)-x0)/(x1-x0)*(W-ML-MR), PY=v=>H-MB-(v-y0)/(y1-y0)*(H-MT-MB);
  1212. const md=a=>{const z=[...a].sort((p,q)=>p-q);return z[Math.floor(z.length/2)]};
  1213. const cx=PX(md(it.map(d=>d.MTBF_h))), cy=PY(md(ys));
  1214. const mh=Math.max(...it.map(d=>d.停机时长h));
  1215. const pts=it.map(d=>{
  1216. const r=4+Math.sqrt(d.停机时长h/mh)*13;
  1217. const ext=d.外部, hot=(d.MTBF_h<md(it.map(v=>v.MTBF_h))&&d.MDT_h>=md(ys));
  1218. const c=ext?'var(--muted)':(hot?'var(--bad)':'var(--accent)');
  1219. return `<circle cx="${PX(d.MTBF_h).toFixed(1)}" cy="${PY(d.MDT_h).toFixed(1)}" r="${r.toFixed(1)}"
  1220. fill="${c}" fill-opacity="${ext?0.3:0.42}" stroke="${c}" stroke-width="1.2"
  1221. stroke-dasharray="${ext?'3 2':''}"><title>${d.部件}: 平均无故障间隔(MTBF)${d.MTBF_h}h · 单次停机时长(MDT)${d.MDT_h}h · 停机 ${d.停机时长h}h · ${d.事件数}起 · ${d.影响台数} 台机组${ext?' · 外部类不计设备可靠性':''}</title></circle>
  1222. <text x="${(PX(d.MTBF_h)+r+4).toFixed(1)}" y="${(PY(d.MDT_h)+3.5).toFixed(1)}" font-size="9.5"
  1223. fill="${ext?'var(--muted)':'currentColor'}">${d.部件.slice(0,7)}</text>`;}).join('');
  1224. const qn=(x,y,a,t,sub)=>`<text x="${x}" y="${y}" font-size="10" fill="var(--muted)" text-anchor="${a}" opacity="0.9">${t}</text>
  1225. <text x="${x}" y="${y+12}" font-size="8.5" fill="var(--muted)" text-anchor="${a}" opacity="0.62">${sub}</text>`;
  1226. const xt=[1000,2000,5000,10000,20000].filter(v=>lg(v)>=x0&&lg(v)<=x1).map(v=>
  1227. `<line x1="${PX(v).toFixed(1)}" y1="${MT}" x2="${PX(v).toFixed(1)}" y2="${H-MB}" stroke="var(--line)" stroke-opacity="0.3"/>
  1228. <text x="${PX(v).toFixed(1)}" y="${H-MB+14}" font-size="9" fill="var(--muted)" text-anchor="middle">${v>=1000?(v/1000)+'k':v}</text>`).join('');
  1229. const yt=[0,5,10,15,20].filter(v=>v<=y1).map(v=>
  1230. `<text x="${ML-6}" y="${(PY(v)+3).toFixed(1)}" font-size="9" fill="var(--muted)" text-anchor="end">${v}</text>`).join('');
  1231. return `<svg class="dt" viewBox="0 0 ${W} ${H}" style="width:${W}px;max-width:100%;height:auto">
  1232. ${xt}${yt}
  1233. <line x1="${ML}" y1="${H-MB}" x2="${W-MR}" y2="${H-MB}" stroke="var(--line)"/>
  1234. <line x1="${ML}" y1="${MT}" x2="${ML}" y2="${H-MB}" stroke="var(--line)"/>
  1235. <line x1="${cx.toFixed(1)}" y1="${MT}" x2="${cx.toFixed(1)}" y2="${H-MB}" stroke="var(--muted)" stroke-dasharray="3 3" stroke-opacity="0.45"/>
  1236. <line x1="${ML}" y1="${cy.toFixed(1)}" x2="${W-MR}" y2="${cy.toFixed(1)}" stroke="var(--muted)" stroke-dasharray="3 3" stroke-opacity="0.45"/>
  1237. ${qn(ML+6,MT+12,'start','常坏且难修','最高优先级')}
  1238. ${qn(W-MR-6,MT+12,'end','罕发难修','备件与作业窗')}
  1239. ${qn(ML+6,H-MB-16,'start','频发易修','查根因, 换件无用')}
  1240. ${qn(W-MR-6,H-MB-16,'end','少发好修','维持')}
  1241. ${pts}
  1242. <text x="${(ML+(W-MR-ML)/2).toFixed(1)}" y="${H-8}" font-size="9.5" fill="var(--muted)" text-anchor="middle">平均无故障间隔 MTBF (h, 对数) → 越右越可靠</text>
  1243. <text x="13" y="${(MT+(H-MB-MT)/2).toFixed(1)}" font-size="9.5" fill="var(--muted)" text-anchor="middle"
  1244. transform="rotate(-90 13 ${(MT+(H-MB-MT)/2).toFixed(1)})">单次停机时长(MDT,h)→ 越上越难修</text>
  1245. <circle cx="${W-MR+16}" cy="${MT+40}" r="5" fill="var(--accent)" fill-opacity="0.42" stroke="var(--accent)"/>
  1246. <text x="${W-MR+26}" y="${MT+44}" font-size="9" fill="var(--muted)">设备类</text>
  1247. <circle cx="${W-MR+16}" cy="${MT+58}" r="5" fill="var(--muted)" fill-opacity="0.3" stroke="var(--muted)" stroke-dasharray="3 2"/>
  1248. <text x="${W-MR+26}" y="${MT+62}" font-size="9" fill="var(--muted)">外部类</text>
  1249. <text x="${W-MR+6}" y="${MT+84}" font-size="8.5" fill="var(--muted)">圆面积 = 累计停机时长</text>
  1250. <text x="${W-MR+6}" y="${MT+98}" font-size="8.5" fill="var(--muted)">虚线 = 本场中位分界</text>
  1251. </svg>`;
  1252. }
  1253. // 归因覆盖率条: "无记录停机" 是最大单项却因不给 MTBF 而在可靠性排序里隐身 (规划 P1-2)
  1254. function attribBar(tb, tot){
  1255. const nore=(tb||[]).find(x=>x.部件==='无记录停机');
  1256. const unk=nore?nore.停机时长h:0, un2=(tb||[]).filter(x=>x.部件==='未归类').reduce((a,b)=>a+b.停机时长h,0);
  1257. const ok=tot-unk-un2, W=720,H=54;
  1258. const seg=(x,w,c,op)=>`<rect x="${x.toFixed(1)}" y="14" width="${Math.max(w,1).toFixed(1)}" height="22" rx="3" fill="${c}" fill-opacity="${op}"/>`;
  1259. const w1=ok/tot*W, w2=un2/tot*W, w3=unk/tot*W;
  1260. return `<svg class="dt" viewBox="0 0 ${W} ${H}" style="width:${W}px;max-width:100%;height:auto">
  1261. ${seg(0,w1,'var(--accent)',0.55)}${seg(w1,w2,'var(--warn)',0.5)}${seg(w1+w2,w3,'var(--bad)',0.5)}
  1262. <text x="4" y="10" font-size="9" fill="var(--muted)">已归因到部件 ${Math.round(ok)}h (${(ok/tot*100).toFixed(0)}%)</text>
  1263. <text x="${(w1+w2+w3-2).toFixed(1)}" y="10" font-size="9" fill="var(--bad)" text-anchor="end">无记录 ${Math.round(unk)}h (${(unk/tot*100).toFixed(0)}%)</text>
  1264. <text x="${(w1+w2/2).toFixed(1)}" y="50" font-size="9" fill="var(--warn)" text-anchor="middle">未归类 ${Math.round(un2)}h</text>
  1265. </svg>`;
  1266. }
  1267. // 失败必须响亮 (2026-08-28 全系统盘点逮): 后端 m8/m9/rel/fus 四块都是
  1268. // `except: X = {err: ...}`, 前端一律 `&&!d.X.err` 直接跳过渲染 —— 界面看不出"这块没算出来",
  1269. // 看起来只是"这一页没这个内容"。reliability 的 bool 位取反 bug 就是这样活下来的。
  1270. // ⇒ 出错时改为渲染一张醒目失败卡: 说清哪块没了、意味着什么缺失、错在哪。
  1271. const BLOCKDESC = {
  1272. rel: ['部件停机贡献与可靠性', '本页看不到部件级平均无故障间隔(MTBF)/单次停机时长(MDT)与停机归因'],
  1273. m8: ['停机事件化与故障季节性', '故障统计页缺停机归因与季节形态'],
  1274. m9: ['控制参数一致性', '发电性能页缺封顶分组与桨距调度差'],
  1275. fus: ['振动四源融合', '振动融合页缺逐台判级矩阵'],
  1276. };
  1277. function blockErr(key, err){
  1278. const d = BLOCKDESC[key] || [key, ''];
  1279. return `<div class="card mb" style="border-left:3px solid var(--bad)">
  1280. <h3 style="color:var(--bad)">⚠ ${d[0]} — 未算出</h3>
  1281. <p class="small" style="margin-bottom:6px">${d[1]}。<b>非"本时间窗无数据", 属计算失败</b> — 下方数字不完整, 不要据此下结论。</p>
  1282. <pre class="small" style="white-space:pre-wrap;color:var(--muted);background:var(--bg);padding:8px 10px;border-radius:6px;margin:0">${String(err).slice(0,300)}</pre></div>`;
  1283. }
  1284. // 逐页窗声明 (2026-08-28 审核逮的交互欺骗)。
  1285. // 顶部窗选择器对"发电性能"完全无效 — 那 19 张图是预计算产物, 窗在生成时钉死为 2025-07~2026-01;
  1286. // 用户切到"2026年"图纹丝不动却毫无提示。"控制策略"曾更糟(工况态跟随+参数件固定时间窗并列),
  1287. // 两个窗并列在同一页。窗对不齐 = claim窗≠证据窗, 是判据失效的头号来源, 必须写在脸上。
  1288. const WINSPEC = {
  1289. '发电性能': {mode:'fixed', win:'2025-07 ~ 2025-12',
  1290. why:'限电前干净窗 — 2026 起交易策略主导, 曲线与控制参数含策略性少发不可作设备判据。本页含特性曲线与控制参数一致性两件, 同窗'},
  1291. // 2026-08-28 用户裁: 控制参数件(固定时间窗)迁入发电性能, "电量算账"只留跟随时间窗内容
  1292. // → 每页只有一个窗制, 混合窗从根上消掉 (原来是本系统唯一的双窗页)
  1293. };
  1294. function winBanner(tab, curWin, extra){
  1295. const w = WINSPEC[tab];
  1296. if(!w) return `<div class="winb"><b>时间窗</b> ${curWin} · 本页全部数字随上方窗切换${extra?' | '+extra:''}</div>`;
  1297. const c = w.mode==='fixed' ? 'var(--warn)' : 'var(--info)';
  1298. const tag = w.mode==='fixed' ? '固定时间窗 · 不随上方切换' : '本页含两个窗';
  1299. return `<div class="winb" style="border-left-color:${c}">
  1300. <b style="color:${c}">${tag}</b> ${w.win}
  1301. <div class="small" style="color:var(--muted);margin-top:3px">${w.why}${extra?' | '+extra:''}</div></div>`;
  1302. }
  1303. // 控制参数三图 (2026-08-28 用户令: 控制参数一致性要图形, 在发电性能展示)。
  1304. // 原来是三段密文字, 关键结论"离散成组≠连续分布"完全看不出来 — 而这正是判"参数级差异"的依据。
  1305. // A 封顶参数数轴: 台数落在几个离散位置上 = 配置/版本差异; 若是磨损或工况会是连续散布。
  1306. // B 桨距同档差: 零中线偏差条, 长度即幅度, 与全场中位同尺比。
  1307. // C 尺子巡检: 极差/σ 比 — 38 台正态抽样的极差期望约 3.7σ, 明显超出即分布非单峰(佐证 A 的分组)。
  1308. function capAxis(P, W9){
  1309. const W=500, H=158, ML=54, MR=34;
  1310. const row=(y, spec, unit, label)=>{
  1311. const gs=Object.entries(spec.分组).map(([k,v])=>({v:parseFloat(k), n:v, raw:k}));
  1312. if(!gs.length) return '';
  1313. const lo=Math.min(...gs.map(g=>g.v)), hi=Math.max(...gs.map(g=>g.v));
  1314. const pad=(hi-lo)*0.22||1, x0=lo-pad, x1=hi+pad;
  1315. const PX=v=>ML+(v-x0)/(x1-x0)*(W-ML-MR);
  1316. const mn=Math.max(...gs.map(g=>g.n));
  1317. const dots=gs.map(g=>{const r=5+Math.sqrt(g.n/mn)*9;
  1318. return `<circle cx="${PX(g.v).toFixed(1)}" cy="${y}" r="${r.toFixed(1)}" fill="var(--accent)" fill-opacity="0.42" stroke="var(--accent)"/>
  1319. <text x="${PX(g.v).toFixed(1)}" y="${y-r-5}" font-size="9" text-anchor="middle" fill="currentColor">${g.raw}</text>
  1320. <text x="${PX(g.v).toFixed(1)}" y="${y+3.5}" font-size="9" text-anchor="middle" fill="var(--panel)" font-weight="600">${g.n}</text>`}).join('');
  1321. const med=spec['中位'];
  1322. return `<text x="${ML-6}" y="${y+3}" font-size="9.5" text-anchor="end" fill="var(--muted)">${label}</text>
  1323. <line x1="${ML}" y1="${y}" x2="${W-MR}" y2="${y}" stroke="var(--line)"/>
  1324. <line x1="${PX(med).toFixed(1)}" y1="${y-24}" x2="${PX(med).toFixed(1)}" y2="${y+18}" stroke="var(--muted)" stroke-dasharray="3 3" stroke-opacity="0.55"/>
  1325. <text x="${PX(med).toFixed(1)}" y="${y+28}" font-size="8.5" text-anchor="middle" fill="var(--muted)">中位 ${med}</text>
  1326. ${dots}`;
  1327. };
  1328. return `<svg class="dt" viewBox="0 0 ${W} ${H}" style="width:${W}px;max-width:100%;height:auto">
  1329. ${row(44, P, 'kW', '满发封顶')}
  1330. ${row(120, W9, 'rpm', '转速封顶')}
  1331. <text x="${W-MR}" y="12" font-size="8.5" text-anchor="end" fill="var(--muted)">圆内数字=台数, 圆面积∝台数</text></svg>`;
  1332. }
  1333. function betaBar(b){
  1334. const outs=(b.离群||[]), med=b['全场同档差中位'];
  1335. const W=500, H=34+outs.length*26, ML=52, MR=58;
  1336. const mx=Math.max(med, ...outs.map(o=>o.平均偏))*1.18||1;
  1337. const PX=v=>ML+v/mx*(W-ML-MR);
  1338. const rows=outs.map((o,i)=>{const y=30+i*26;
  1339. return `<text x="${ML-6}" y="${y+10}" font-size="9.5" text-anchor="end" fill="currentColor">${o.t.replace('WTG','')}#</text>
  1340. <rect x="${ML}" y="${y+1}" width="${(PX(o.平均偏)-ML).toFixed(1)}" height="13" rx="2" fill="var(--warn)" fill-opacity="0.62"/>
  1341. <text x="${(PX(o.平均偏)+5).toFixed(1)}" y="${y+11}" font-size="9" fill="var(--warn)">${o.平均偏}°</text>`}).join('');
  1342. return `<svg class="dt" viewBox="0 0 ${W} ${H}" style="width:${W}px;max-width:100%;height:auto">
  1343. <line x1="${PX(med).toFixed(1)}" y1="18" x2="${PX(med).toFixed(1)}" y2="${H-4}" stroke="var(--ok)" stroke-dasharray="3 3"/>
  1344. <text x="${PX(med).toFixed(1)}" y="12" font-size="8.5" fill="var(--ok)">全场中位 ${med}°</text>
  1345. <line x1="${ML}" y1="18" x2="${ML}" y2="${H-4}" stroke="var(--line)"/>
  1346. ${rows}</svg>`;
  1347. }
  1348. function rulerBar(rl){
  1349. const ks=Object.entries(rl); const W=500, H=26+ks.length*30, ML=88, MR=76;
  1350. const NORM=3.7; // 38 台正态抽样极差期望 ≈ 3.7σ
  1351. const mxr=Math.max(NORM*1.35, ...ks.map(([,v])=>v.极差/v.σ));
  1352. const PX=v=>ML+v/mxr*(W-ML-MR);
  1353. const rows=ks.map(([k,v],i)=>{const y=22+i*30, ratio=v.极差/v.σ, wide=ratio>NORM;
  1354. return `<text x="${ML-6}" y="${y+11}" font-size="9.5" text-anchor="end" fill="currentColor">${k}</text>
  1355. <rect x="${ML}" y="${y+2}" width="${(PX(ratio)-ML).toFixed(1)}" height="12" rx="2"
  1356. fill="${wide?'var(--warn)':'var(--accent)'}" fill-opacity="0.55"/>
  1357. <text x="${(PX(ratio)+5).toFixed(1)}" y="${y+11}" font-size="9" fill="var(--muted)">${ratio.toFixed(1)}σ σ=${v.σ}</text>`}).join('');
  1358. return `<svg class="dt" viewBox="0 0 ${W} ${H}" style="width:${W}px;max-width:100%;height:auto">
  1359. <line x1="${PX(NORM).toFixed(1)}" y1="14" x2="${PX(NORM).toFixed(1)}" y2="${H-4}" stroke="var(--ok)" stroke-dasharray="3 3"/>
  1360. <text x="${PX(NORM).toFixed(1)}" y="10" font-size="8.5" fill="var(--ok)" text-anchor="middle">正态期望 3.7σ</text>
  1361. ${rows}</svg>`;
  1362. }
  1363. // 责任分账 (2026-08-28 用户令: 电量算账优化显示)。
  1364. // 瀑布回答"电量流向", 但业主真正要问的下一句是"谁的责任" — 原来只在一段小字里写了个百分比。
  1365. // 三分账: 交易与调度侧(限电两项+调度令停机) / 设备侧(故障与检修停机) / 运行侧(正常发电欠发+低风)。
  1366. function blameBar(e){
  1367. const cat = k => (k.indexOf('限电')>=0 || k.indexOf('调度')>=0) ? 0 : (k==='停机' ? 1 : 2);
  1368. const NM = ['交易与调度侧','设备侧','运行侧'];
  1369. const NOTE = ['限电与调度令, 不进设备责任','故障与检修停机, 唯一进设备考核','正常发电欠发与低风待机'];
  1370. const CL = ['var(--info)','var(--bad)','var(--muted)'];
  1371. const g = [0,0,0];
  1372. (e.items||[]).forEach(x => { g[cat(x.k)] += x.loss; });
  1373. const tot = g.reduce((a,b)=>a+b,0) || 1;
  1374. const W=500, H=44;
  1375. let x=0, seg='';
  1376. g.forEach((v,i)=>{ const w=v/tot*W;
  1377. seg += `<rect x="${x.toFixed(1)}" y="12" width="${Math.max(w,1).toFixed(1)}" height="26" rx="3" fill="${CL[i]}" fill-opacity="0.55"><title>${NM[i]}: ${Math.round(v).toLocaleString()} MWh</title></rect>`
  1378. + (w>46 ? `<text x="${(x+w/2).toFixed(1)}" y="29" font-size="10.5" fill="currentColor" text-anchor="middle" font-weight="600">${(v/tot*100).toFixed(0)}%</text>` : '');
  1379. x += w; });
  1380. // 图例走 HTML 而非 SVG text: SVG 里估算中文字宽不准, 三段说明会互相压字 (实测重叠)
  1381. const leg = g.map((v,i)=>`<div style="display:flex;align-items:baseline;gap:7px;margin-top:5px">
  1382. <i style="flex:none;width:9px;height:9px;border-radius:2px;background:${CL[i]};opacity:.55"></i>
  1383. <span style="font-weight:600">${NM[i]}</span>
  1384. <span class="cnt">${Math.round(v).toLocaleString()} MWh</span>
  1385. <span class="small" style="color:var(--muted)">${NOTE[i]}</span></div>`).join('');
  1386. return `<svg class="dt" viewBox="0 0 ${W} ${H}" style="width:${W}px;max-width:100%;height:auto">
  1387. <text x="0" y="8" font-size="9" fill="var(--muted)">损失 ${Math.round(tot).toLocaleString()} MWh 按责任归属</text>
  1388. ${seg}</svg>${leg}`;
  1389. }
  1390. // 帕累托图 (2026-08-28 用户令: 故障统计按经典分析展示)。
  1391. // 经典做法 = 条形按降序 + 右轴累计百分比折线 + 80% 参考线 → 一眼看出"关键少数";
  1392. // 原来只有两列排序表, 看不出前几项就占了大头。
  1393. function paretoChart(items, unit, note){
  1394. const it = (items || []).filter(x => x && x.v > 0);
  1395. if(!it.length) return '<p class="small">时间窗内无数据</p>';
  1396. // 尺寸 (2026-08-28 排布优化): 原 720x260 + 旋转长标签, 两列并排时被缩到 0.60 倍 = 字高 6.5px 不可读。
  1397. // 改紧凑版 520x236: x 轴只标码号(水平, 不旋转 → 底部留白 86→40), 全名进悬停与图注前三行。
  1398. const W = 520, H = 236, ML = 46, MR = 40, MB = 40, MT = 14;
  1399. const n = it.length, tot = it.reduce((a, x) => a + x.v, 0);
  1400. const mx = Math.max(...it.map(x => x.v));
  1401. const bw = (W - ML - MR) / n;
  1402. const short = k => { const m = String(k).match(/^([0-9]+)/); return m ? m[1] : String(k).slice(0, 4); };
  1403. let cum = 0, bars = '', line = [], dots = '', k80 = null;
  1404. it.forEach((x, i) => {
  1405. const hh = (x.v / mx) * (H - MT - MB);
  1406. const bx = ML + i * bw + bw * 0.14, by = H - MB - hh;
  1407. bars += `<rect x="${bx.toFixed(1)}" y="${by.toFixed(1)}" width="${(bw * 0.72).toFixed(1)}" height="${hh.toFixed(1)}"
  1408. rx="2" fill="var(--accent)" fill-opacity="${i < 3 ? 0.85 : 0.45}"><title>${x.k}: ${x.v}${unit}</title></rect>`;
  1409. cum += x.v;
  1410. const pct = cum / tot;
  1411. if(k80 === null && pct >= 0.8) k80 = i + 1;
  1412. const px = ML + i * bw + bw / 2, py = MT + (1 - pct) * (H - MT - MB);
  1413. line.push(`${px.toFixed(1)},${py.toFixed(1)}`);
  1414. dots += `<circle cx="${px.toFixed(1)}" cy="${py.toFixed(1)}" r="2.4" fill="var(--warn)"><title>${x.k} · 累计 ${(pct*100).toFixed(0)}%</title></circle>`;
  1415. });
  1416. const y80 = MT + 0.2 * (H - MT - MB);
  1417. const xt = it.map((x, i) => `<text x="${(ML + i*bw + bw/2).toFixed(1)}" y="${H - MB + 13}" font-size="9"
  1418. fill="var(--muted)" text-anchor="middle">${short(x.k)}</text>`).join('');
  1419. const yt = [0, .5, 1].map(f => `<text x="${ML-5}" y="${(H-MB-(f*(H-MT-MB))+3).toFixed(1)}" font-size="9"
  1420. fill="var(--muted)" text-anchor="end">${Math.round(mx*f).toLocaleString()}</text>`).join('')
  1421. + [0, .5, 1].map(f => `<text x="${W-MR+5}" y="${(MT+(1-f)*(H-MT-MB)+3).toFixed(1)}" font-size="9"
  1422. fill="var(--warn)">${Math.round(f*100)}%</text>`).join('');
  1423. const top3 = it.slice(0, 3).map(x => `<b>${short(x.k)}</b> ${String(x.k).replace(/^[0-9]+ */, '').slice(0, 13)}`).join(' · ');
  1424. return `<svg class="dt" viewBox="0 0 ${W} ${H}" style="width:${W}px;max-width:100%;height:auto">
  1425. <line x1="${ML}" y1="${H-MB}" x2="${W-MR}" y2="${H-MB}" stroke="var(--line)"/>
  1426. <line x1="${ML}" y1="${MT}" x2="${ML}" y2="${H-MB}" stroke="var(--line)"/>
  1427. ${bars}
  1428. <line x1="${ML}" y1="${y80.toFixed(1)}" x2="${W-MR}" y2="${y80.toFixed(1)}" stroke="var(--warn)"
  1429. stroke-dasharray="4 3" stroke-opacity="0.7"/>
  1430. <text x="${W-MR-3}" y="${(y80-4).toFixed(1)}" font-size="9" fill="var(--warn)" text-anchor="end">累计 80%</text>
  1431. <polyline points="${line.join(' ')}" fill="none" stroke="var(--warn)" stroke-width="1.5"/>${dots}
  1432. ${xt}${yt}
  1433. <text x="${(ML+(W-MR-ML)/2).toFixed(1)}" y="${H-3}" font-size="8.5" fill="var(--muted)" text-anchor="middle">报警码 (悬停看全名)</text></svg>
  1434. <p class="small" style="color:var(--muted);margin-top:5px;line-height:1.6">
  1435. ${k80 ? `<b>关键少数: 前 ${k80} 项占 80%</b> (共 ${n} 项, 合计 ${Math.round(tot).toLocaleString()}${unit})` : `共 ${n} 项, 合计 ${Math.round(tot).toLocaleString()}${unit}`}
  1436. <br>前三: ${top3}${note ? '<br>' + note : ''}</p>`;
  1437. }
  1438. // ── 风场阵列 (2026-08-31 用户令: 风机与环境形象要用视觉体现) ──────────────
  1439. // 视觉语言与推介页 hero 同源: 细线几何 + stroke-opacity 造景深, 不写实。
  1440. // 它同时是**信息图**不只是插画 —— 每台按其最严重系统状态着色, 全场健康分布一眼可见。
  1441. // 快照版总览原本只有一句"内嵌页不可用", 这里正好补上。
  1442. // ── 运维成熟度与四判据 (2026-08-31) ───────────────────────────────
  1443. // 为什么放在检修决策页顶端: 这四条是"系统会主动拦下来"的判据, 不是又一张统计图。
  1444. // 那三个问题(复发/共性/记录断层)在本场存在五年没被发现, 不是没人懂,
  1445. // 是没有任何环节会主动报出来。
  1446. // 本体里的文本用 **x** 标重点 (那是给人读源文件时用的)。直接插进 HTML 会把星号
  1447. // 原样印在客户交付页上。这里只转粗体, 不引入 markdown 解析器 —— 需求就这一条。
  1448. // 用字符类 [*] 而非反斜杠转义 —— CHART_JS 是**非 raw** 的 Python 三引号串,
  1449. // 反斜杠在这里会被 Python 当转义处理: 反斜杠加星号触发 SyntaxWarning, 反斜杠加 n
  1450. // 直接变成真换行, 把单行注释劈成两行 —— 第二行不再是注释, 当场语法错、页面白屏。
  1451. // (写这条注释时我为了举例又写了一次那个转义, 于是当场复现了它描述的 bug,
  1452. // 被成品页语法闸逮到。所以: 这个字符串里能不写反斜杠就不写, 举例也别写。)
  1453. // 层级后缀单独成常量 —— 原来写成 `${lv}层` 时那个 `层` 是模板里的**字面量孤字**,
  1454. // 数据侧的 `机理` 译成了 Domain model 而它没译, 页面上就是 'Domain model层'。
  1455. // 提成常量后它是一个可查表的完整词。
  1456. const LAYER_SFX = '层';
  1457. const TIMES_SFX = '倍';
  1458. const mdB = s => String(s == null ? '' : s).replace(/[*][*]([^*]+)[*][*]/g, '<b>$1</b>');
  1459. // 快照版总览面板: 用已固化的数据汇总, 而不是道歉。
  1460. // 逐系统两个键: 裸名 = 有问题的机组, `·良好` = 判良好的机组 —— 汇总时要分开数。
  1461. function snapOverview(d){
  1462. const SY = d.systems || {};
  1463. const rows = [];
  1464. Object.keys(SY).forEach(function(k){
  1465. if (k.indexOf('·') >= 0) return; // 只遍历裸名, 良好那半单独取
  1466. const bad = (SY[k] || []).length;
  1467. const good = (SY[k + '·良好'] || []).length;
  1468. if (bad + good) rows.push({sys: k, bad: bad, good: good});
  1469. });
  1470. rows.sort(function(a, b){ return b.bad - a.bad; });
  1471. const K = d.kpi || {};
  1472. const M = (d.m8 && d.m8.mtbf) || {};
  1473. let h = '<div class="card mb"><div class="eyebrow">Where to look</div>';
  1474. h += '<div style="font-size:12px;color:var(--muted);margin:2px 0 12px;line-height:1.6">'
  1475. + '本页为固化快照: 上方每个标签页的数据都已随页面打包, 断网也能看。'
  1476. + '下表按系统汇总当前状态, 点系统名进该系统的机组清单。</div>';
  1477. if (rows.length){
  1478. h += '<table class="tbl"><thead><tr><th>系统</th><th>需跟踪</th><th>判良好</th>'
  1479. + '<th>占全场</th></tr></thead><tbody>';
  1480. rows.forEach(function(r){
  1481. const tot = r.bad + r.good;
  1482. // 用 data 属性 + 委托事件, **不写嵌套引号** —— CHART_JS 是非 raw 的 Python
  1483. // 三引号串, 里面的反斜杠转义会被 Python 吃掉, 反引号/单引号一嵌套就断串。
  1484. // (今天第三次栽在这条上, 每次都是成品页语法闸拦下来的。)
  1485. h += '<tr><td><a class="tl" href="#" data-gotab="部件问题">'
  1486. + r.sys + '</a></td>'
  1487. + '<td class="num"><b style="color:' + (r.bad ? 'var(--warn)' : 'var(--muted)') + '">'
  1488. + r.bad + '</b></td><td class="num">' + r.good + '</td>'
  1489. + '<td class="num small">' + tot + '</td></tr>';
  1490. });
  1491. h += '</tbody></table>';
  1492. }
  1493. h += '<div class="kpis" style="margin-top:12px">'
  1494. + '<div class="kpi"><b>' + (K['报警台'] != null ? K['报警台'] : '—') + '</b>'
  1495. + '<span>机组带报警级判定</span></div>'
  1496. + '<div class="kpi"><b>' + (K['关注台'] != null ? K['关注台'] : '—') + '</b>'
  1497. + '<span>机组在跟踪清单上</span></div>'
  1498. + '<div class="kpi"><b>' + (M['停机事件数'] != null ? M['停机事件数'] : '—') + '</b>'
  1499. + '<span>停机事件 (本时间窗)</span></div></div>';
  1500. h += '<div style="font-size:12px;color:var(--muted);margin-top:14px;line-height:1.75">'
  1501. + '<b>各屏回答什么</b><br>'
  1502. + '电量算账 — 电去哪了, 每一度归到哪个原因<br>'
  1503. + '部件问题 — 哪台哪系统该管, 依据是什么<br>'
  1504. + '振动融合分析 — 振动与 SCADA 对上没有<br>'
  1505. + '发电性能 — 曲线偏了没有, 偏在哪一段<br>'
  1506. + '故障统计 — 报警的关键少数与可靠性象限<br>'
  1507. + '检修决策 — 数据本身可不可信, 运维体系差在哪</div>';
  1508. h += '</div>';
  1509. return h;
  1510. }
  1511. // 总览表里的系统名跳到对应标签页。委托绑定, 只挂一次。
  1512. document.addEventListener('click', function(e){
  1513. var a = e.target && e.target.closest ? e.target.closest('a[data-gotab]') : null;
  1514. if (!a) return;
  1515. e.preventDefault();
  1516. TAB = a.getAttribute('data-gotab');
  1517. render();
  1518. });
  1519. function dqFindings(d){
  1520. // 数据可信度 —— 系统自查出的数据质量与口径问题。
  1521. // 放在判据面板之前: 先说"这些数字可不可信", 再说"数字说明什么"。
  1522. // 一个只报结论不报数据缺陷的系统, 是在把风险留给读者。
  1523. if(!d) return '<div class="card">数据可信度加载中…</div>';
  1524. if(d.err) return '<div class="card">数据可信度: '+d.err+'</div>';
  1525. const KIND={data_quality:['数据质量','var(--bad)'], caliber:['口径','var(--warn)'],
  1526. system_coverage:['覆盖','var(--accent)'], tier_trend:['结构趋势','var(--ok)']};
  1527. const GR={established:['定论','var(--bad)'], candidate:['候选','var(--warn)'],
  1528. reference:['参考','var(--muted)']};
  1529. let h='<div class="card"><div class="eyebrow">数据可信度 · 系统自查发现</div>';
  1530. h+='<div style="font-size:12px;color:var(--muted);margin:2px 0 12px;line-height:1.6">'
  1531. +'先回答"这些数字可不可信", 再谈数字说明什么。以下每条都是在做分析的过程中被判据拦下来的, '
  1532. +'不是事后补的说明。</div>';
  1533. (d.rows||[]).forEach(r=>{
  1534. const k=KIND[r.kind]||['其他','var(--muted)'];
  1535. const g=GR[r.grade]||[r.grade,'var(--muted)'];
  1536. h+=`<div style="border:1px solid var(--line);border-left:3px solid ${k[1]};border-radius:var(--r);
  1537. padding:12px 14px;margin-bottom:10px;background:var(--panel-2)">`;
  1538. h+=`<div style="display:flex;gap:8px;align-items:baseline;flex-wrap:wrap;margin-bottom:6px">
  1539. <span style="font-size:11px;padding:1px 8px;border-radius:10px;background:${k[1]};color:#fff">${k[0]}</span>
  1540. <span style="font-size:11px;color:${g[1]};border:1px solid ${g[1]};padding:0 6px;border-radius:10px">${g[0]}</span>
  1541. <span style="font-size:11px;color:var(--muted);font-family:var(--num)">${r.claim_window||''}</span>
  1542. </div>`;
  1543. h+=`<div style="font-size:13px;font-weight:600;line-height:1.5;margin-bottom:6px">${mdB(r.name)}</div>`;
  1544. if((r.evidence||[]).length){
  1545. h+='<ul style="margin:4px 0 6px 16px;padding:0;font-size:12px;color:var(--ink);line-height:1.65">';
  1546. (r.evidence||[]).slice(0,4).forEach(e=>{h+=`<li>${mdB(e)}</li>`});
  1547. h+='</ul>';
  1548. }
  1549. if(r.impact) h+=`<div style="font-size:12px;color:var(--ok);line-height:1.6;margin-top:4px">
  1550. <b>判别 / 处置</b> ${mdB(r.impact)}</div>`;
  1551. if(r.got_wrong) h+=`<div style="font-size:12px;color:var(--warn);line-height:1.6;margin-top:6px;
  1552. padding:6px 10px;background:var(--panel);border-radius:6px;border:1px dashed var(--line)">
  1553. <b>首版判错</b> ${mdB(r.got_wrong)}</div>`;
  1554. // 数据质量标志 + 稳健性复算: 削弱结论的证据, 与证据同级显示
  1555. if(r.quality_flag) h+=`<div style="font-size:12px;color:var(--ink);line-height:1.6;margin-top:6px;
  1556. padding:6px 10px;background:var(--panel);border-radius:6px;border:1px dashed var(--warn)">
  1557. <b style="color:var(--warn)">记录质量</b> ${mdB(r.quality_flag)}</div>`;
  1558. if(r.robust) h+=`<div style="font-size:12px;color:var(--muted);line-height:1.6;margin-top:4px">
  1559. <b>稳健性复算</b> ${mdB(r.robust)}</div>`;
  1560. if(r.basis) h+=`<div style="font-size:12px;color:var(--muted);line-height:1.6;margin-top:4px">
  1561. <b>分类依据</b> ${mdB(r.basis)}</div>`;
  1562. if(r.sensitivity) h+=`<div style="font-size:12px;color:var(--ink);line-height:1.6;margin-top:6px;
  1563. padding:6px 10px;background:var(--panel);border-radius:6px;border:1px dashed var(--accent)">
  1564. <b style="color:var(--accent)">敏感性检验</b> ${mdB(r.sensitivity)}</div>`;
  1565. if(r.age_basis) h+=`<div style="font-size:12px;color:var(--muted);line-height:1.6;margin-top:4px">
  1566. <b>场龄口径</b> ${mdB(r.age_basis)}</div>`;
  1567. // ★边界从 11px/muted 的脚注提到 12px/正文色 (2026-08-31 独立审逮):
  1568. // 它**限定结论成立的范围**, 不是补充说明。原样式让它成为卡片里最小最淡的元素,
  1569. // 排在一串证据数字之后 —— 读者读完数字就走了, 限定语等于没写。
  1570. if(r.caveat) h+=`<div style="font-size:12px;color:var(--ink);line-height:1.6;margin-top:6px;
  1571. padding:6px 10px;background:var(--panel);border-radius:6px;border-left:3px solid var(--muted)">
  1572. <b>结论边界</b> ${mdB(r.caveat)}</div>`;
  1573. h+='</div>';
  1574. });
  1575. h+='</div>';
  1576. return h;
  1577. }
  1578. function maintFw(d){
  1579. // 运维管理体系三维骨架 — 架构取自一套跨厂商通用的培训编号体系, 实证取自本场五年工单。
  1580. // 放在四判据之后: 判据回答"哪台该管", 本面板回答"这个场的运维能力结构长什么样"。
  1581. // 配色一律走 CSS 变量 —— 页面有明暗两套 :root, 硬编码色在另一套下不可读。
  1582. if(!d) return '<div class="card">运维体系加载中…</div>';
  1583. if(d.err) return '<div class="card">运维体系: '+d.err+'</div>';
  1584. const c=d.coverage||{}, g=d.gaps||{};
  1585. const tierName={common:'通用 · 换机型仍有效', platform:'机型专属 · 绑定当前机型', basic:'技术基础', eng:'工程改造'};
  1586. const tierCol={common:'var(--accent)', platform:'var(--warn)', basic:'var(--muted)', eng:'var(--unknown)'};
  1587. let h='<div class="card"><div class="eyebrow">运维管理体系 · 系统 × 阶段 × 记录</div>';
  1588. // ★跨厂商借用必须在**用它的地方**声明 (2026-08-31 双票独立命中)。
  1589. // ★这套码是**借来的跨厂商分类法**, 不点任何厂商名 (点了等于用否定把机型漏回去)。
  1590. // 声明写在别的页面等于没声明: 读者是在这一屏做判断的。
  1591. h+='<div style="font-size:12px;color:var(--muted);margin:2px 0 12px;line-height:1.6">'
  1592. +'三位系统码分两层 (3xx 跨机型通用 / 4xx 机型专属), 判据与缺口取自本场五年工单实证。'
  1593. +'<b style="color:var(--warn)">口径声明</b>: '
  1594. // ★整句不嵌 <b> —— 中间插标签会把这段切成多个 PH 片段, 逐片查表再拼回去,
  1595. // 结果是"arenot"这种缺空格拼接 + 某一片查不到就留中文 (实测两种都发生了)。
  1596. // 要强调就整句强调, 别在句中加标记。整段一个 key, 查得到就整段换, 查不到就整段留。
  1597. // ★整句放在**一个**字符串字面量里。apply() 跑在**源码文本**上, 拆成多个字面量拼接时,
  1598. // 源码里它们被换行加 + 号隔开 ⇒ PH 切出的段与渲染后看到的整句**不是同一个东西**,
  1599. // (注意: CHART_JS 是非 raw 的 Python 三引号串, 注释里写反斜杠-n 会变成**真换行**,
  1600. // 把 // 注释劈成两行 —— 第二行不再是注释, 直接语法错。本行原来就踩了这个坑。)
  1601. // 我按渲染后的整句注册 key, 页面上却按源码分段查表 → 全部 miss。
  1602. // (实测踩到: 助手 --segments 报"命中", 页面上仍是中文。验证跑在渲染后, 翻译跑在源码上。)
  1603. +'三位码仅用于能力分级与培训投资参照。部件到系统码的映射由本项目建立、未经确认, 因此结构趋势比绝对占比可靠。</div>';
  1604. // ① 覆盖率 — 本轮最实的一条发现
  1605. h+='<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:16px">';
  1606. h+=`<div style="flex:1;min-width:180px;padding:12px 14px;border-radius:var(--r);background:var(--panel-2);border:1px solid var(--line);border-left:3px solid var(--bad)">
  1607. <div style="font-size:28px;font-weight:600;color:var(--bad);font-family:var(--num)">${c.system_pct}%</div>
  1608. <div style="font-size:12px;color:var(--muted);margin-top:2px">工单可归属到系统<br><span style="font-family:var(--num)">${c.mapped} / ${c.n_orders}</span></div></div>`;
  1609. h+=`<div style="flex:1;min-width:180px;padding:12px 14px;border-radius:var(--r);background:var(--panel-2);border:1px solid var(--line);border-left:3px solid var(--warn)">
  1610. <div style="font-size:28px;font-weight:600;color:var(--warn);font-family:var(--num)">${c.part_field_pct}%</div>
  1611. <div style="font-size:12px;color:var(--muted);margin-top:2px">工单填了部件字段<br><span style="font-family:var(--num)">${c.with_part} / ${c.n_orders}</span></div></div>`;
  1612. h+=`<div style="flex:2.2;min-width:280px;padding:12px 14px;border-radius:var(--r);background:var(--accent-soft);border:1px solid var(--line);border-left:3px solid var(--ok)">
  1613. <div style="font-size:12px;color:var(--ok);font-weight:600;margin-bottom:4px">怎么补</div>
  1614. <div style="font-size:12px;color:var(--ink);line-height:1.65">工单模板增加系统码必填项 (31 个系统码下拉), 此后每条工单按构造自动归属。
  1615. <div style="color:var(--muted);margin-top:4px">归属率低是模板缺字段, 不是维修没做 — 后者要贵得多。</div></div></div>`;
  1616. h+='</div>';
  1617. // ② 通用 vs 机型专属 — 决定培训投资与合同边界
  1618. h+='<div style="font-size:13px;font-weight:600;margin:16px 0 4px;color:var(--ink)">作业量结构 · 通用能力 vs 机型专属</div>';
  1619. h+='<div style="font-size:12px;color:var(--muted);margin-bottom:8px">通用能力换机型仍成立, 属场站资产; 机型专属绑定当前机队, 更适合合同覆盖</div>';
  1620. const byT=g.by_tier||{}, tks=Object.keys(byT).sort((a,b)=>byT[b].jobs-byT[a].jobs);
  1621. h+='<div style="display:flex;height:24px;border-radius:4px;overflow:hidden;margin-bottom:6px;border:1px solid var(--line)">';
  1622. tks.forEach(t=>{h+=`<div style="width:${byT[t].share}%;background:${tierCol[t]||'var(--muted)'};display:flex;align-items:center;justify-content:center;font-size:11px;color:#fff;font-family:var(--num)">${byT[t].share}%</div>`;});
  1623. h+='</div><div style="font-size:11px;color:var(--muted);margin-bottom:14px">';
  1624. tks.forEach(t=>{h+=`<span style="margin-right:16px"><span style="display:inline-block;width:9px;height:9px;background:${tierCol[t]||'var(--muted)'};border-radius:2px;margin-right:5px;vertical-align:middle"></span>${tierName[t]||t} · <span style="font-family:var(--num)">${byT[t].jobs}</span> 次</span>`;});
  1625. h+='</div>';
  1626. // ③ 系统作业量排行
  1627. h+='<div style="font-size:13px;font-weight:600;margin:16px 0 6px;color:var(--ink)">按系统码的作业量</div>';
  1628. h+='<table class="tbl"><tr><th>码</th><th>系统</th><th>类别</th><th>作业次数</th><th>台数</th><th>次/台</th></tr>';
  1629. (d.systems||[]).slice(0,10).forEach(r=>{
  1630. h+=`<tr><td style="font-family:var(--num)">${r.code}</td><td>${r.zh}<div style="font-size:11px;color:var(--muted)">${r.en}</div></td>
  1631. <td style="color:${tierCol[r.tier]||'var(--muted)'};font-size:12px">${tierName[r.tier]||r.tier}</td>
  1632. <td style="font-family:var(--num)">${r.jobs}</td><td style="font-family:var(--num)">${r.n_units}</td><td style="font-family:var(--num)">${r.per_unit}</td></tr>`;});
  1633. h+='</table>';
  1634. // ④ 阶段 × 出口闸 × 记录
  1635. h+='<div style="font-size:13px;font-weight:600;margin:18px 0 4px;color:var(--ink)">六个运维阶段与各自的放行条件</div>';
  1636. h+='<div style="font-size:12px;color:var(--muted);margin-bottom:8px">放行条件 = 不满足就不该进下一阶段; 记录 = 该阶段必须留下的凭据</div>';
  1637. h+='<table class="tbl"><tr><th style="width:15%">阶段</th><th style="width:38%">放行条件</th><th>必备记录</th></tr>';
  1638. (d.stages||[]).forEach(st=>{
  1639. const rn=(st.records||[]).map(r=>((d.records||{})[r]||{}).zh||r)
  1640. .map(x=>`<span style="display:inline-block;padding:1px 7px;margin:1px 3px 1px 0;border-radius:10px;background:var(--panel-2);border:1px solid var(--line);font-size:11px">${x}</span>`).join('');
  1641. // ★英文侧 zh 已被 _proj 投影成英文 (裸 zh/en 成对的处理), 两行会显示成一样的字 ——
  1642. // 相同就只出一行。出口闸那列同理: 页面原读 st.gate_zh, 而投影后字段名是 gate,
  1643. // 于是**整列 undefined** —— 而"出口闸"正是这张表最要紧的一列 (无闸的阶段划分只是标签)。
  1644. h+=`<tr><td><span style="font-family:var(--num);color:var(--muted)">${st.seq}</span> ${st.zh||st.en||''}
  1645. ${(st.en&&st.en!==st.zh)?`<div style="font-size:11px;color:var(--muted)">${st.en}</div>`:''}</td>
  1646. <td style="font-size:12px;line-height:1.5">${st.gate||st.gate_zh||''}</td><td>${rn}</td></tr>`;});
  1647. h+='</table>';
  1648. // ⑤ 未映射 — 不猜归属
  1649. const um=(g.unmapped_top||[]);
  1650. if(um.length){
  1651. h+='<div style="font-size:11px;color:var(--muted);margin-top:12px;line-height:1.6">未映射到系统码的部件 (不猜归属, 留待补映射表): '
  1652. + um.slice(0,10).map(x=>`${x[0]}<span style="font-family:var(--num)">×${x[1]}</span>`).join('、')+'</div>';}
  1653. h+='</div>';
  1654. return h;
  1655. }
  1656. function maintStd(d){
  1657. if(!d) return '<div class="card"><p class="small">载入中…</p></div>';
  1658. if(d.err) return `<div class="card"><p class="small" style="color:var(--bad)">${d.err}</p></div>`;
  1659. const S=d.score||{}; let h='';
  1660. const dims=(S.dims||[]).map(x=>{
  1661. // ★"不可评估"要在**出现的地方**就自明 —— 用户读到这四个字问"什么意思",
  1662. // 说明把解释放在上一行的口径说明里不够。它不是"做得差", 是"没法判":
  1663. // 工单模板里没有记录该证据的位置, 所以既不能记功也不能记过。
  1664. if(!x.evaluable) return `<div class="kpi"><b style="color:var(--muted)">—</b>
  1665. <span>${x.dim} · <b style="color:var(--warn)">不可评估</b>
  1666. <span style="color:var(--muted)">(工单里没有记录这项证据的字段, 所以既不能算做到、也不能算没做到 &mdash; 不是 0 分, 是无法判断)</span><br>
  1667. <span style="color:var(--muted)">${mdB(x.detail)}</span></span></div>`;
  1668. const pct=Math.round(x.score/x.full*100);
  1669. const cls=pct>=70?'ok':pct>=40?'':'bad';
  1670. return `<div class="kpi ${cls}"><b>${x.score}<span style="font-size:14px;color:var(--muted)">/${x.full}</span></b>
  1671. <span>${x.dim}<br><span style="color:var(--muted)">${mdB(x.detail)}</span></span></div>`;
  1672. }).join('');
  1673. // 两个分并排、各自带标签; 口径与理由见 src/windscada/maint/standards.py。
  1674. // (页面源码随交付件一起发出去, 内部审核讨论不留在这里。)
  1675. const _en=(typeof window!=='undefined'&&window.__EN);
  1676. const twoScore=(lab,val,ev,n,tone)=>`<div style="flex:1;min-width:190px;padding:12px 14px;
  1677. border-radius:var(--r);background:var(--panel-2);border:1px solid var(--line);border-left:3px solid ${tone}">
  1678. <div style="font-size:12px;color:var(--muted)">${lab||''}</div>
  1679. <div style="font-size:30px;font-weight:600;font-family:var(--num);color:${tone};line-height:1.15">
  1680. ${val!=null?val:'—'}<span style="font-size:14px;color:var(--muted);font-weight:400">/100</span></div>
  1681. <div style="font-size:11.5px;color:var(--muted);margin-top:2px">${ev} / ${n} ${_en?'dimensions measurable':'维可测'}</div></div>`;
  1682. h+=`<div class="card mb"><h3>${_en?'Maintenance scorecard':'运维评分卡'}
  1683. <span class="badge" style="margin-left:10px">${S.stage_label||('第 '+S.stage+' 阶')} · ${S.stage_name}</span></h3>
  1684. <div style="display:flex;gap:12px;flex-wrap:wrap;margin:4px 0 12px">
  1685. ${twoScore(S.record_label,S.record_score,S.record_evaluable,S.record_dims,'var(--bad)')}
  1686. ${twoScore(S.exec_label,S.exec_score,S.exec_evaluable,S.exec_dims,'var(--ok)')}
  1687. </div>
  1688. ${S.score_basis?`<p class="small" style="color:var(--muted);margin:0 2px 10px;line-height:1.6">${mdB(S.score_basis)}</p>`:''}
  1689. <div class="kpis">${dims}</div>
  1690. <p class="small" style="color:var(--warn);margin:8px 2px 0">${mdB(S.caveat)}</p>
  1691. ${S.model_source?`<p class="small" style="color:var(--muted);margin:6px 2px 0;line-height:1.6">
  1692. <b>评分方法来源</b> ${mdB(S.model_source)}</p>`:''}
  1693. ${S.bottleneck?`<p class="small" style="margin:4px 2px 0">当前瓶颈: <b>${S.bottleneck}</b> — 先补这一项, 其余维度的提升都受它限制。</p>`:''}</div>`;
  1694. // R1 复发
  1695. const R=(d.recurrence||[]).slice(0,10);
  1696. if(R.length) h+=`<div class="card mb"><h3>复发 · ${d.recurrence.length} 组</h3>
  1697. <p class="small" style="color:var(--muted)">同一机组同一部件多次更换 — 换件未解决产生它的原因。按作业日去重计数, 非工单条数。</p>
  1698. <table class="tbl"><thead><tr><th>机组</th><th>部件</th><th>作业次数</th><th>首次</th><th>末次</th><th>最短间隔</th></tr></thead><tbody>
  1699. ${R.map(x=>`<tr><td>${x.turbine}</td><td>${x.part}</td><td><b>${x.n_jobs}</b></td>
  1700. <td>${x.first}</td><td>${x.last}</td><td>${x.min_gap_months} 月</td></tr>`).join('')}
  1701. </tbody></table></div>`;
  1702. // R2 批次候选
  1703. const B=(d.batch||[]).slice(0,8);
  1704. if(B.length) h+=`<div class="card mb"><h3>共性候选 · ${d.batch.length} 项</h3>
  1705. <p class="small" style="color:var(--warn)">仅候选。分布形态无法区分 批次 / 共模应力 / 耗损期 — 三者形态相似而处置方向完全不同(追索赔 / 改运行条件 / 排寿命更换)。定性需物料证据。</p>
  1706. <table class="tbl"><thead><tr><th>部件</th><th>涉及台数</th><th>作业次数</th><th>每台</th><th>缺什么才能定性</th></tr></thead><tbody>
  1707. ${B.map(x=>`<tr><td>${x.part}</td><td>${x.n_units}</td><td>${x.n_jobs}</td>
  1708. <td>${x.per_unit}</td><td class="small" style="color:var(--muted)">${x.needs}</td></tr>`).join('')}
  1709. </tbody></table></div>`;
  1710. // R4 断层
  1711. const D=d.discontinuity||{};
  1712. if((D.breaks||[]).length) h+=`<div class="card mb"><h3>记录断层</h3>
  1713. <p class="small">${D.note}</p>
  1714. ${D.breaks.map(b=>`<p class="small"><b>${b.before} → ${b.after}</b>: ${b.n_before} → ${b.n_after} 条 (<b style="color:var(--bad)">${b.ratio}×</b>) — 机组不会一年之间变坏这么多, 变的是记录制度。</p>`).join('')}
  1715. </div>`;
  1716. return h;
  1717. }
  1718. function farmScene(d){
  1719. const RANK={'危险':4,'报警':3,'不可判':2,'良好':1,'优秀':0};
  1720. const COL={'危险':'var(--bad)','报警':'var(--bad)','不可判':'var(--unknown)',
  1721. '良好':'var(--ok)','优秀':'var(--st-exc)'};
  1722. const S={};
  1723. Object.entries((d&&d.systems)||{}).forEach(([sys,rows])=>(rows||[]).forEach(r=>{
  1724. if(!r||!r.t) return;
  1725. const st=String(r.st||'').split('·')[0].trim();
  1726. if(!(st in RANK)) return;
  1727. if(!(r.t in S) || RANK[st] > RANK[S[r.t]]) S[r.t]=st;
  1728. }));
  1729. // 补全: 预烤 key 里有全场台号; 在线模式下退回已知台集合
  1730. let all=Object.keys(S);
  1731. if(typeof window.__SNAP__!=='undefined'){
  1732. const ks=new Set();
  1733. Object.keys(window.__SNAP__).forEach(k=>{const m=/[?&]t=([A-Za-z0-9]+)/.exec(k); if(m)ks.add(m[1])});
  1734. if(ks.size>all.length) all=[...ks];
  1735. }
  1736. all=all.sort();
  1737. if(!all.length) return '';
  1738. const n=all.length, W=980, H=190, m=30;
  1739. const per=Math.ceil(n/2), gap=(W-m*2)/(per-1||1);
  1740. let g='';
  1741. all.forEach((t,i)=>{
  1742. const row=i<per?0:1, idx=i<per?i:i-per;
  1743. const x=m+idx*gap+(row?gap/2:0);
  1744. const base=row?150:126, hh=row?30:38, op=row?.55:.92, bw=row?12:15;
  1745. const st=S[t]||'优秀', c=COL[st]||'var(--unknown)';
  1746. const hub=base-hh;
  1747. g+=`<g opacity="${op}"><title>${t} · ${st}</title>`
  1748. +`<path d="M${x} ${base} L${x} ${hub}" stroke="${c}" stroke-width="${row?1:1.3}" fill="none" stroke-linecap="round"/>`
  1749. +`<path d="M${x} ${hub} L${x} ${hub-bw}" stroke="${c}" stroke-width="${row?.9:1.1}" fill="none" stroke-linecap="round"/>`
  1750. +`<path d="M${x} ${hub} L${(x+bw*0.87).toFixed(1)} ${(hub+bw*0.5).toFixed(1)}" stroke="${c}" stroke-width="${row?.9:1.1}" fill="none" stroke-linecap="round"/>`
  1751. +`<path d="M${x} ${hub} L${(x-bw*0.87).toFixed(1)} ${(hub+bw*0.5).toFixed(1)}" stroke="${c}" stroke-width="${row?.9:1.1}" fill="none" stroke-linecap="round"/>`
  1752. +`<circle cx="${x}" cy="${hub}" r="${row?1.5:1.9}" fill="${c}"/></g>`;
  1753. });
  1754. const cnt={};
  1755. Object.values(S).forEach(v=>cnt[v]=(cnt[v]||0)+1);
  1756. const legend=['危险','报警','不可判','良好','优秀'].filter(k=>cnt[k])
  1757. .map(k=>`<span style="display:inline-flex;align-items:center;gap:5px">
  1758. <i style="width:7px;height:7px;border-radius:50%;background:${COL[k]};display:inline-block"></i>${k} ${cnt[k]}</span>`).join('');
  1759. return `<div class="card mb"><svg viewBox="0 0 ${W} ${H}" style="width:100%;height:auto;display:block"
  1760. role="img" aria-label="Fleet layout: each turbine coloured by its most severe system state">
  1761. <defs><linearGradient id="fsea" x1="0" y1="0" x2="0" y2="1">
  1762. <stop offset="0" stop-color="var(--accent)" stop-opacity=".14"/>
  1763. <stop offset="1" stop-color="var(--accent)" stop-opacity="0"/></linearGradient></defs>
  1764. <path d="M0 150 H${W}" stroke="var(--line)" stroke-width="1" fill="none"/>
  1765. <path d="M0 126 H${W}" stroke="var(--line)" stroke-width="1" fill="none" opacity=".5"/>
  1766. <rect x="0" y="150" width="${W}" height="${H-150}" fill="url(#fsea)"/>
  1767. ${g}</svg>
  1768. <p class="small" style="color:var(--muted);display:flex;gap:16px;flex-wrap:wrap;margin:10px 2px 0">
  1769. <b style="color:var(--ink)">全场态势</b>${legend}
  1770. <span style="margin-left:auto">按各台最严重系统状态着色 · 悬停看台号</span></p></div>`;
  1771. }
  1772. function drawFig(f){
  1773. if(f.kind==='vib'){return figWrap(f, dtSVG(f.comp||f.text||'',f.sev||'var(--warn)',880), (f.text||'').replace(/</g,'&lt;'))}
  1774. if(f.kind==='text'){return `<div class="card"><h3>${f.title||''}</h3><p class="small">${(f.text||'').replace(/</g,'&lt;')}</p></div>`}
  1775. if(f.kind==='vibbar'){
  1776. // 倍中位条 (1× 为基准竖线; 绝对值量级差 3 个数量级时按绝对值画条会全部顶满 — 速度有效值 0.001 实例)
  1777. const rows=f.rows||[]; if(!rows.length) return '';
  1778. const sig=v=>{const a=Math.abs(v); return a>=100?v.toFixed(0):a>=10?v.toFixed(1):a>=1?v.toFixed(2):a>=0.01?v.toFixed(3):v.toExponential(1)};
  1779. const byM={}; rows.forEach(r=>{(byM[r.指标]=byM[r.指标]||[]).push(r)});
  1780. const LO=0.5, HI=2.5, W2=190, xr=v=>((Math.max(LO,Math.min(HI,v))-LO)/(HI-LO))*W2;
  1781. let inner=`<div class="small" style="color:var(--muted);margin-bottom:8px">条为本台与全场中位之比, 竖线取 1× 基准, 右端封顶 2.5×; 色按 z (稳健离群度): 绿&lt;2 · 橙2~3 · 红≥3</div>`;
  1782. for(const [mk,rs] of Object.entries(byM)){
  1783. inner+=`<div style="margin-bottom:9px;overflow-x:auto"><b class="small" style="color:var(--muted)">${mk}</b><table style="width:100%;margin-top:2px">`;
  1784. rs.forEach(r=>{
  1785. const v=r.倍中位||1, col=Math.abs(r.z)>=3?'var(--bad)':Math.abs(r.z)>=2?'var(--warn)':'var(--ok)';
  1786. const x1=xr(1), xv=xr(v), left=Math.min(x1,xv), wid=Math.max(Math.abs(xv-x1),1.5);
  1787. inner+=`<tr><td class="small" style="width:104px">${r.测点}</td>
  1788. <td style="width:${W2+10}px"><svg class="dt" viewBox="0 0 ${W2} 16" style="width:${W2}px;height:16px">
  1789. <line x1="0" y1="8" x2="${W2}" y2="8" stroke="var(--line)" stroke-width="1"/>
  1790. <line x1="${x1}" y1="1" x2="${x1}" y2="15" stroke="var(--muted)" stroke-width="1"/>
  1791. <rect x="${left}" y="4" width="${wid}" height="8" rx="2" fill="${col}" fill-opacity="0.9"/>
  1792. ${v>HI?`<path d="M${W2-1},8 l-6,-4 v8 z" fill="${col}"/>`:''}</svg></td>
  1793. <td class="num small" style="width:60px;color:${col};font-weight:600">${r.倍中位!=null?r.倍中位+'×':'—'}</td>
  1794. <td class="num small" style="width:80px">${sig(r.本台)}</td>
  1795. <td class="num small" style="width:78px;color:var(--muted)">中位 ${sig(r.全场中位)}</td>
  1796. <td class="num small" style="width:56px;color:${Math.abs(r.z)>=3?'var(--bad)':'var(--muted)'}">z ${r.z}</td>
  1797. <td class="num small" style="width:64px;color:${r.六窗比>1.15?'var(--warn)':'var(--muted)'}">${r.六窗比!=null?'趋势'+r.六窗比+'×':'趋势—'}</td></tr>`;
  1798. });
  1799. inner+='</table></div>';
  1800. }
  1801. return figWrap(f, inner, f.note);
  1802. }
  1803. const W=620,H=250,ML=52,MB=34;
  1804. let xs=f.months||f.x, n=xs.length; if(!n) return '';
  1805. const X=i=>sc(i,0,Math.max(n-1,1),ML,W-12);
  1806. let all=[];
  1807. if(f.kind==='band'){all=[...(f.self||[]),...(f.med||[]),...(f.q1||[]),...(f.q3||[])]}
  1808. else if(f.kind==='bars'){all=[0,...(f.vals||[])]}
  1809. else if(f.kind==='xy'){all=[...(f.self||[]),...(f.med||[])]}
  1810. else {(f.series||[]).forEach(s=>all.push(...s.vals))}
  1811. (f.thresholds||[]).forEach(t=>all.push(t.v));
  1812. all=all.filter(v=>v!==null&&v===v); if(!all.length) return '';
  1813. let lo=Math.min(...all),hi=Math.max(...all); if(lo===hi){lo-=1;hi+=1} const pad=(hi-lo)*0.08; lo-=pad; hi+=pad;
  1814. const Y=v=>sc(v,lo,hi,H-MB,14);
  1815. const line=(vals,st)=>{let p=vals.map((v,i)=>v===null||v!==v?null:`${X(i).toFixed(1)},${Y(v).toFixed(1)}`);
  1816. let segs=[],cur=[]; p.forEach(q=>{if(q)cur.push(q);else{if(cur.length)segs.push(cur);cur=[]}}); if(cur.length)segs.push(cur);
  1817. return segs.map(sg=>`<polyline points="${sg.join(' ')}" style="fill:none;${st}"/>`).join('')}
  1818. let body='';
  1819. if(f.kind==='band'&&f.q1){
  1820. let up=f.q3.map((v,i)=>v===null?null:`${X(i).toFixed(1)},${Y(v).toFixed(1)}`).filter(Boolean);
  1821. let dn=f.q1.map((v,i)=>v===null?null:`${X(i).toFixed(1)},${Y(v).toFixed(1)}`).filter(Boolean).reverse();
  1822. if(up.length&&dn.length) body+=`<polygon points="${up.join(' ')} ${dn.join(' ')}" fill="#cbd5e1" fill-opacity="0.45"/>`;
  1823. }
  1824. if(f.kind==='bars'){const bw=Math.max(3,(W-ML-20)/n*0.6);
  1825. body+=f.vals.map((v,i)=>`<rect x="${(X(i)-bw/2).toFixed(1)}" y="${Y(v).toFixed(1)}" width="${bw.toFixed(1)}" height="${(Y(Math.max(lo,0))-Y(v)).toFixed(1)}" fill="#0f4c75" fill-opacity="0.75"/>`).join('');}
  1826. if(f.med) body+=line(f.med,'stroke:#64748b;stroke-width:1.7');
  1827. if(f.self) body+=line(f.self,'stroke:#c0392b;stroke-width:2.1')+
  1828. f.self.map((v,i)=>v===null||v!==v?'':`<circle cx="${X(i)}" cy="${Y(v)}" r="2.4" fill="var(--bad)"/>`).join('');
  1829. const cols=['#0f4c75','#ef6c00','#0e6e66','#7c5295'];
  1830. (f.series||[]).forEach((s,k)=>{body+=line(s.vals,`stroke:${cols[k%4]};stroke-width:1.8`)});
  1831. (f.thresholds||[]).forEach(t=>{body+=`<line x1="${ML}" x2="${W-12}" y1="${Y(t.v)}" y2="${Y(t.v)}" stroke="var(--muted)" stroke-dasharray="5 4"/>`+
  1832. (t.label?`<text x="${ML+6}" y="${Y(t.v)-4}" font-size="10" fill="var(--muted)">${t.label}</text>`:'')});
  1833. const step=Math.max(1,Math.ceil(n/8));
  1834. let xt=''; for(let i=0;i<n;i+=step) xt+=`<text x="${X(i)}" y="${H-10}" font-size="10" fill="var(--muted)" text-anchor="middle">${String(xs[i]).includes('-')?String(xs[i]).slice(2,7):String(xs[i])}</text>`;
  1835. let yt=''; for(let k=0;k<5;k++){const v=lo+(hi-lo)*k/4; yt+=`<text x="${ML-6}" y="${Y(v)+3}" font-size="10" fill="var(--muted)" text-anchor="end">${v.toFixed(Math.abs(hi)<10?1:0)}</text>`+
  1836. `<line x1="${ML}" x2="${W-12}" y1="${Y(v)}" y2="${Y(v)}" stroke="var(--line-2)"/>`}
  1837. if(f.band&&f.band.q1){const q1=f.band.q1,q3=f.band.q3;
  1838. let up='',dn='';
  1839. for(let i=0;i<n;i++){if(q3[i]!=null)up+=`${X(i)},${Y(q3[i])} `}
  1840. for(let i=n-1;i>=0;i--){if(q1[i]!=null)dn+=`${X(i)},${Y(q1[i])} `}
  1841. body=`<polygon points="${up}${dn}" fill="var(--unknown)" fill-opacity="0.16"/>`+body}
  1842. if(f.xlab) body+=`<text x="${(ML+W)/2}" y="${H-1}" font-size="10" fill="var(--unknown)" text-anchor="middle">${f.xlab}</text>`;
  1843. let leg = f.kind==='band' ? `<text x="${W-190}" y="12" font-size="10" fill="var(--bad)">— 本台</text><text x="${W-130}" y="12" font-size="10" fill="var(--muted)">— 全场中位</text><text x="${W-60}" y="12" font-size="10" fill="var(--unknown)">▧ 四分位</text>` :
  1844. ((f.series&&f.series.length<=2)?(()=>{const n2=f.series.length,gap=Math.min(118,Math.floor((W-ML-20)/n2));
  1845. return f.series.map((s,k)=>`<text x="${W-12-(n2-1-k)*gap}" y="12" font-size="10" fill="${cols[k%4]}" text-anchor="end">— ${s.name}</text>`).join('')})():'');
  1846. const legendHtml=(f.series&&f.series.length>2)?`<div class="legend">`+f.series.map((s,k)=>
  1847. `<span class="lgi"><i style="background:${cols[k%4]}"></i>${s.name}</span>`).join('')+`</div>`:'';
  1848. // 统一走 figWrap: 图身按类别封顶宽度 (曲线760/月度620), 不再随容器拉伸成巨图
  1849. return figWrap(f, `<svg viewBox="0 0 ${W} ${H}">${yt}${body}${xt}${leg}</svg>`+legendHtml,
  1850. f.note||'').replace('<h3>'+(f.title||''), '<h3>'+(f.title||'')+(f.unit?' ('+f.unit+')':''));
  1851. }
  1852. function ordersHtml(o){
  1853. if(!o) return '';
  1854. let h=`<div class="card mb"><h3>工单处理史 · 该系统 (处理 ${o.n} 次${o.窗?'; 窗 '+o.窗:''})</h3>`;
  1855. if(o.n===0){h+=`<p class="small">该时间窗内无工单记录。${o.note||''}</p></div>`; return h}
  1856. const acts=Object.entries(o.动作统计||{}).map(([k,v])=>k+'×'+v).join(' / ');
  1857. if(acts) h+=`<p class="small" style="margin-bottom:6px">处置动作统计: <b>${acts}</b></p>`;
  1858. h+=`<table><tr><th style="width:92px">日期</th><th>故障/工作</th><th>排查发现</th><th>处置</th><th>类别</th></tr>`;
  1859. o.rows.forEach(r=>{h+=`<tr><td class="num" style="text-align:left">${r.日期}</td><td class="small">${r.故障}</td><td class="small">${r.排查&&r.排查!=='nan'?r.排查:'—'}</td><td class="small">${r.处置||'—'}</td><td class="small">${r.类别||''}</td></tr>`});
  1860. h+=`</table><p class="small">${o.note||''}</p></div>`;
  1861. return h;
  1862. }
  1863. let ONT=null;
  1864. async function loadDbase(){
  1865. const el=document.getElementById('dbase'); if(!el) return;
  1866. if(!ONT) ONT=await (await fetch('/api/ontology')).json();
  1867. const C=ONT.chain29; let h='';
  1868. // ① 机制链横图 (决策的证据底座, 图形化)
  1869. const node=(label,val,color)=>`<div style="min-width:120px;flex:1;text-align:center">
  1870. <div style="border:1.5px solid ${color};border-radius:10px;padding:8px;background:var(--panel)">
  1871. <div class="small" style="color:var(--muted);letter-spacing:.05em">${label}</div>
  1872. <div style="font-weight:600;font-size:13.5px;margin-top:4px">${val}</div></div></div>`;
  1873. const arrow=`<div style="align-self:center;color:var(--muted);font-size:16px;padding:0 4px">→</div>`;
  1874. // 每格加一行"这一格回答什么" (2026-08-28 用户问"什么意思" — 六个词现场看不出是干嘛的)
  1875. const node2=(t,v,c,q)=>`<div style="flex:none;min-width:132px;border:1px solid ${c};border-radius:10px;padding:9px 12px;text-align:center">
  1876. <div class="small" style="color:var(--muted)">${t}</div>
  1877. <div style="font-weight:600;margin:4px 0 3px">${v}</div>
  1878. <div class="small" style="color:var(--muted);line-height:1.45">${q}</div></div>`;
  1879. // 决策链进度盘 (2026-08-28 用户问"这个到底能怎么用, 我没看太懂")。
  1880. // 原来是 29# 一台的静态六格 — 打开它做不了任何决定, 是展示牌不是工作面。
  1881. // 改为: 全部需跟踪台各走到第几步 / 卡在哪一步 / 下一步具体做什么 / 点台号进该台。
  1882. // 这样它回答的是"今天该管谁、卡在哪、谁去做什么", 而不是"29# 的故事是怎样的"。
  1883. const BD=(window.D&&D.fus&&D.fus.链盘)?D.fus.链盘:(D.fus&&D.fus.链盘);
  1884. if(BD&&BD.rows&&BD.rows.length){
  1885. const SK=Object.entries(BD.stuck||{}).sort((a,b)=>b[1]-a[1]);
  1886. const tot=BD.rows.length;
  1887. const cell=st=>st==='done'?`<i style="display:inline-block;width:13px;height:13px;border-radius:3px;background:var(--accent);opacity:.8"></i>`
  1888. :st==='unknown'?`<i style="display:inline-block;width:13px;height:13px;border-radius:3px;border:1.5px dashed var(--muted);opacity:.75"></i>`
  1889. :`<i style="display:inline-block;width:13px;height:13px;border-radius:3px;border:1.5px solid var(--line)"></i>`;
  1890. h+=`<div class="card mb"><h3>决策链进度盘 · ${tot} 台需跟踪</h3>
  1891. <p class="small" style="color:var(--muted);margin-bottom:10px">
  1892. 一台机组从发现异常到修完验收要走六步。这张盘不讲某一台的故事, 用于确认<b>当前需处置机组、各自所处环节、后续动作</b>。
  1893. <span style="color:var(--accent)">■</span> 已达 ·
  1894. <span style="color:var(--muted)">▢ 虚线</span> 数据不足不可判 ·
  1895. <span style="color:var(--muted)">▢ 实线</span> 未达</p>
  1896. <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:12px;
  1897. padding:9px 12px;border-radius:8px;background:var(--bg);border:1px solid var(--line)">
  1898. <b class="small">瓶颈</b>${SK.map(([k,n])=>`<span class="small">
  1899. <b style="color:${n/tot>0.5?'var(--bad)':'var(--warn)'}">${n} 台</b> 卡在「${k}」</span>`).join('<span class="small" style="color:var(--muted)">·</span>')}
  1900. ${SK.length&&SK[0][1]/tot>0.5?`<span class="small" style="color:var(--muted)">— 瓶颈集中在一步, 不是"再多看点数据"能解的</span>`:''}
  1901. <div style="flex-basis:100%;height:0"></div>
  1902. ${SK.map(([k,n])=>{const r=BD.rows.find(x=>x.stuck===k);
  1903. /* 值为空就整块不出 —— 原来无条件渲染, 没 next 的两个环节在页面上留下
  1904. "「机制」24 台的下一步:" 后面空着的悬空标签 (中英文版同样露)。 */
  1905. return (r&&r.next)?`<div class="small" style="color:var(--muted);margin-top:3px">
  1906. <b>「${k}」${n} 台的下一步:</b> ${r.next}</div>`:''}).join('')}</div>
  1907. <table><tr><th style="width:52px">机组</th><th style="width:64px">状态</th>
  1908. <th style="width:126px">六步进度</th><th style="width:70px">卡在</th><th>本台情况</th></tr>`;
  1909. BD.rows.forEach(r=>{
  1910. const ris=r.rising;
  1911. h+=`<tr><td><a class="tl" href="javascript:void(0)" onclick="popChain('${r.t}')" title="点开看这一台的六步决策链"><b>${r.t.replace('WTG','')}#</b></a>
  1912. ${ris?`<span class="small" style="color:var(--bad)" title="${ris.component} 末三窗/早三窗 ${ris.ratio}×">↑${ris.ratio}×</span>`:''}</td>
  1913. <td>${chip(r.ostate||'—')}</td>
  1914. <td style="white-space:nowrap">${r.steps.map((x,i)=>`<span title="${i+1} ${x.k}: ${x.v} — ${x.d}" style="margin-right:3px">${cell(x.st)}</span>`).join('')}
  1915. <span class="cnt">${r.done}/6</span></td>
  1916. <td class="small" style="color:${r.stuck_kind==='unknown'?'var(--muted)':'var(--warn)'}">${r.stuck||'全通'}${r.stuck_kind==='unknown'?' <span title="非未执行, 系统无法判定">?</span>':''}</td>
  1917. <td class="small" style="color:var(--muted)">${r.part&&r.part.indexOf('未收录')<0?`<b style="color:var(--ink)">${r.part}</b> · `:''}证据 ${(r.srcs||[]).join('/')||'—'}
  1918. ${ris?` · <span style="color:var(--bad)">${ris.component}末三窗/早三窗 ${ris.ratio}×${ris.工单可对===false?', 待现场/工单确认':''}</span>`:''}
  1919. ${r.hist_loop?` · <span style="color:var(--ok)">${r.hist_loop.replace_date} 有过闭环 ${r.hist_loop.ratio}×</span>`:''}</td></tr>`;});
  1920. h+=`</table>
  1921. <p class="small" style="color:var(--muted);margin-top:8px">
  1922. <b>「动作」一列全场都是不可判</b>, 不是偷懒: ${BD.wo_bound}, 而判级时间窗在 2026 —
  1923. 台账里的历史工单不可能是针对本次问题的, 用"有没有工单"判已派工会造出假的完成态。要填这一列须向现场要 25~26 工单。</p>`;
  1924. if(BD.closed&&BD.closed.length){
  1925. h+=`<div style="margin-top:12px;padding:10px 12px;border-radius:8px;background:var(--bg);border:1px solid var(--line)">
  1926. <div class="small" style="font-weight:600;margin-bottom:5px">已走完全程并退出盘面 · ${BD.closed.length} 台 (端到端闭环实证)</div>
  1927. ${BD.closed.map(x=>`<div class="small" style="color:var(--muted)">
  1928. <a class="tl" href="/turbine/${x.turbine}"><b>${x.turbine.replace('WTG','')}#</b></a>
  1929. ${x.component}${x.title?' · '+x.title:''} ${x.replace_date} 换件 →
  1930. 振动 ${x.pre} 降至 ${x.post} = <b style="color:var(--ok)">${x.ratio}×</b></div>`).join('')}
  1931. <div class="small" style="color:var(--muted);margin-top:5px">
  1932. 这三台正是算法当初的检出台 ⇒ <b>故障前能看见、换件后能看见恢复</b>, 这条链是通的; 闭环成功的台会从盘面消失。</div></div>`;
  1933. }
  1934. h+=`</div>`;
  1935. }
  1936. // ② 四情景损失对比 (log 尺度条形; 数值解析电费口径区间取中值)
  1937. const sc=C.scenario.scenarios||[];
  1938. const parseWan=t=>{const m=/([0-9.]+)~([0-9.]+) 万元/.exec(t||'');return m?(+m[1]+ +m[2])/2:null};
  1939. const rows=sc.map(x=>{
  1940. let v=parseWan(x.停机损失&&x.停机损失.电费口径), extra=0, lab=x.id;
  1941. if(x.停机损失&&x.停机损失.大部件成本){extra=350; lab+=' (+大部件成本200-500万)'; // 用 - 不用 ~ : ~ 不在 PH 续接字符类里, 会把整串切断
  1942. }
  1943. return {lab, v:(v||0)+extra, base:v||0, extra};});
  1944. if(rows.length){
  1945. const mx=Math.max(...rows.map(r=>r.v));
  1946. h+=`<div class="card mb"><h3>排期沙盘 · 四情景损失对比 (万元, 电价区间中值; 建议级待人裁)</h3>`;
  1947. rows.forEach(r=>{
  1948. const w=Math.max(3,Math.round(Math.log10(1+r.v)/Math.log10(1+mx)*320));
  1949. const col=r.extra?'var(--bad)':(r.lab.startsWith('A')?'var(--ok)':'var(--warn)');
  1950. h+=`<div style="display:flex;align-items:center;gap:10px;margin:8px 0">
  1951. <div class="small" style="width:170px;text-align:right">${r.lab}</div>
  1952. <div style="height:16px;width:${w}px;background:${col};border-radius:4px;opacity:.85"></div>
  1953. <div class="small" style="font-variant-numeric:tabular-nums"><b>${r.v>=100?r.v.toFixed(0):r.v.toFixed(1)}</b> 万元</div></div>`});
  1954. h+=`<p class="small">条长=log 尺度 (跨两个数量级); D 含大部件成本中值 350 万 (grade D 弱源)。早修成本被限电稀释, 晚修成本被窗口与船期放大。</p></div>`;
  1955. }
  1956. // ③ 天气窗热力条 (近12月, CTV/吊装 双行)
  1957. const W=ONT.weather||{}; const ms=Object.keys(W).sort().slice(-12);
  1958. if(ms.length){
  1959. const cell=(v,d)=>{const r=v/d; const bg=r>0.9?'var(--ok)':r>0.7?'var(--warn)':'var(--bad)';
  1960. return `<td style="text-align:center;padding:4px"><div style="background:${bg};opacity:${0.25+r*0.6};border-radius:4px;padding:4px 0;font-size:11.5px;font-variant-numeric:tabular-nums">${v}</div></td>`};
  1961. h+=`<div class="card mb"><h3>海上作业天气窗 · 近12月可作业日 (公开再分析统计, 非派工依据)</h3>
  1962. <div style="overflow-x:auto"><table><tr><th style="width:90px"></th>${ms.map(m=>`<th class="small" style="text-align:center">${m.slice(2)}</th>`).join('')}</tr>`;
  1963. h+=`<tr><td class="small">CTV人员转运</td>${ms.map(m=>cell(W[m]['CTV人员转运']||0,W[m]['天数']||30)).join('')}</tr>`;
  1964. h+=`<tr><td class="small">吊装作业</td>${ms.map(m=>cell(W[m]['吊装作业']||0,W[m]['天数']||30)).join('')}</tr>`;
  1965. h+=`</table></div><p class="small">CTV 级小修全年窗宽; 吊装级受台风季约束, 色深表示可作业占比。限值取行业常规 (CTV Hs≤1.5m∧风速≤12m/s, 吊装 1.2/8), 非本场合同值。</p></div>`;
  1966. }
  1967. el.innerHTML=h;
  1968. }
  1969. async function renderOntology(){
  1970. const root=document.getElementById('root');
  1971. if(!ONT){
  1972. root.innerHTML='<div class="card"><div class="skel" style="width:40%"></div><div class="skel" style="width:70%;margin-top:9px"></div></div>';
  1973. ONT=await (await fetch('/api/ontology')).json();
  1974. }
  1975. const O=ONT; let h='';
  1976. h+=`<div class="eyebrow">语义本体 · 对象/机制/决策/约束 (Palantir 思路轻量自建; 判读纪律=写入闸, 违反写不进库)</div>`;
  1977. h+=`<div class="card mb" style="border-left:3px solid var(--accent)"><b>六条写入约束 (库级保证)</b><p class="small">${O.constraints}</p></div>`;
  1978. h+=`<div class="kpis">`+Object.entries(O.stats).map(([k,v])=>`<div class="kpi"><b>${v}</b><span>${k}</span></div>`).join('')+`</div>`;
  1979. const C=O.chain29;
  1980. h+=`<div class="card mb"><h3>机制链视图 · 29# 范例 (证据 → 机制 → 判级 → 沙盘 → 动作 → 验收)</h3>
  1981. <div style="display:flex;gap:10px;overflow-x:auto;padding:6px 0">`;
  1982. h+=`<div style="min-width:240px;flex:1"><p class="small"><b>① 证据 ×${C.evidence.length} (${C.mechanism.independent_source_count||4} 观测面)</b></p>`;
  1983. C.evidence.forEach(e=>{h+=`<div class="small" style="border:1px solid var(--line);border-left:3px solid var(--info);border-radius:6px;padding:6px 8px;margin-bottom:6px"><b>${e.facet}</b> · 窗 ${e.claim_window}<br>${(e.summary||'').slice(0,90)}…<br><span style="color:var(--muted)">${e.id}</span></div>`});
  1984. h+=`</div>`;
  1985. const box=(t,color,inner)=>`<div style="min-width:210px;flex:1"><p class="small"><b>${t}</b></p><div class="small" style="border:1px solid var(--line);border-left:3px solid ${color};border-radius:6px;padding:8px">${inner}</div></div>`;
  1986. h+=box('② 机制','var(--warn)',`<b>${C.mechanism.name}</b><br>源数=观测面去重 ${C.mechanism.independent_source_count} (证据${C.evidence.length}条)<br><span style="color:var(--muted)">${C.mechanism.id}</span>`);
  1987. h+=box('③ 判级','var(--bad)',`<b>${C.verdict.level}</b> · 审级 ${C.verdict.review_tier}<br>${(C.verdict.title||'').slice(0,110)}…<br><span style="color:var(--muted)">${C.verdict.id}</span>`);
  1988. h+=box('④ 沙盘','var(--accent)',`${(C.scenario.recommendation||'').slice(0,130)}…<br><span style="color:var(--muted)">${C.scenario.id}</span>`);
  1989. h+=box('⑤ 动作','var(--warn)',`${(C.action.name||'').slice(0,90)}<br>闸: ${C.action.gate?C.action.gate.reversibility+' · '+C.action.gate.review_tier:''} · ${C.action.status||''}<br><span style="color:var(--muted)">${C.action.id}</span>`);
  1990. h+=box('⑥ 验收状态机','var(--ok)',`状态 <b>${C.acceptance.state||'—'}</b> 分支 ${C.acceptance.branch||'—'}<br>${(C.acceptance.note||'').slice(0,110)}<br><span style="color:var(--muted)">acceptance/WTG29/state</span>`);
  1991. h+=`</div><p class="small">判读纪律: 六个观测量=一条机制链 (C3 源数按观测面), 禁写多源印证; 判级只在 L1/审级产生, 本体转录带审级。</p></div>`;
  1992. h+=`<div class="card mb"><h3>失效机制库 (${O.mechanisms.length} 族)</h3><div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:10px">`;
  1993. O.mechanisms.forEach(m=>{h+=`<div class="small" style="border:1px solid var(--line);border-radius:8px;padding:9px"><b>${m.name}</b><br>状态: ${m.status||''}<br>观测面: ${(m.facets||[]).join('/')}<br>涉及: ${(m.affects||[]).map(a=>a.split('/').pop()).join(', ')||'fleet'}<br><span style="color:var(--muted)">${m.id}</span></div>`});
  1994. h+=`</div></div>`;
  1995. h+=`<div class="card mb"><h3>决策台 (决策捕获 + 月度滚动)</h3><table><tr><th>决策</th><th>裁决人</th><th>时间</th><th>状态</th></tr>`;
  1996. O.decisions.forEach(x=>{const on=x.status==='pending_user';
  1997. h+=`<tr><td class="small">${(x.decision||'').slice(0,130)}</td><td>${x.decided_by}</td><td class="small">${x.when}</td>
  1998. <td><span class="badge" style="border-color:${on?'var(--warn)':'var(--ok)'};color:${on?'var(--warn)':'var(--ok)'}">${x.status}</span></td></tr>`});
  1999. h+=`</table><p class="small">月度滚动: 数据月 ${O.roll.data_month||'—'} · 上次动作 ${(O.roll.last_roll_actions||[]).join('; ')||'—'} · 变化 ${(O.roll.last_changes||[]).join('; ')||'—'}</p></div>`;
  2000. h+=`<div class="card mb"><h3>对象浏览器 (${Object.values(O.stats).reduce((a,b)=>a+b,0)} 对象)</h3>
  2001. <div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px">
  2002. <select id="obType" style="padding:8px;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--ink)"><option value="">全部类型</option>${Object.keys(O.stats).map(k=>`<option>${k}</option>`).join('')}</select>
  2003. <input id="obT" placeholder="台号 如 29" style="padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--ink);width:110px">
  2004. <button onclick="obGo()" style="cursor:pointer;padding:8px 16px;border-radius:8px;border:1px solid var(--accent);background:var(--accent);color:#fff">查询</button>
  2005. </div><div id="obOut" class="small">选类型/台号后查询; 点结果行看对象全文与链接。</div></div>`;
  2006. root.innerHTML=h;
  2007. document.getElementById('foot').innerHTML='本体=只读语义层; 判级权在 L1+审级, 模型与网页均零判级权';
  2008. }
  2009. async function obGo(){
  2010. const t=document.getElementById('obType').value, tb=document.getElementById('obT').value;
  2011. const d=await (await fetch(`/api/ont_list?type=${encodeURIComponent(t)}&t=${encodeURIComponent(tb)}&limit=60`)).json();
  2012. const el=document.getElementById('obOut');
  2013. if(!d.rows||!d.rows.length){el.textContent='无结果';return}
  2014. el.innerHTML=`<table><tr><th>id</th><th>类型</th><th>要点</th></tr>`+d.rows.map(r=>
  2015. `<tr style="cursor:pointer" onclick="obOpen('${r.id}')"><td class="small">${r.id}</td><td>${r.type}</td><td class="small">${r.name||r.level||r.status||''} ${r.review_tier?'· '+r.review_tier:''}</td></tr>`).join('')+`</table><div id="obDetail"></div>`;
  2016. }
  2017. async function obOpen(id){
  2018. const d=await (await fetch('/api/ont_obj?id='+encodeURIComponent(id))).json();
  2019. const el=document.getElementById('obDetail'); if(!el) return;
  2020. el.innerHTML=`<pre style="white-space:pre-wrap;background:var(--ground);border:1px solid var(--line);border-radius:8px;padding:10px;margin-top:8px">${JSON.stringify(d,null,1).replace(/</g,'&lt;')}</pre>`;
  2021. }
  2022. // 模型档位 (2026-08-28 用户令: 模型切换的应用你设计好; 去 30B 用 27B)。
  2023. // 按"这个问题值不值得等"选, 而非凭型号大小猜 — 每档写清 在哪跑 / 要多久 / 适合什么。
  2024. // 云端档单独标"内容会外发", 因为那是一个需要用户知情的选择而不是性能选项。
  2025. // ★页面用的是**这一份** (JS), 上面 L1158 那份是 Python 的 —— 两份同名不同源。
  2026. // 我第一次只改了 Python 那份, 页面一点没变 (deepseek-chat 在成品件里 0 命中)。
  2027. // 同名双实现: 改一处不报错、不生效, 只能靠回查产物发现。
  2028. const MODEL_SPEC = {
  2029. 'qwen3:8b': {nm:'轻量 8B', loc:'本地', t:'约 30 到 60 秒', use:'默认档: 快速简答问答', warn:''},
  2030. 'qwen3.8:27b': {nm:'标准 27B', loc:'本地', t:'约 2 到 4 分钟', use:'复杂问题档: 多台横向比对与机制推断', warn:''},
  2031. 'qwen3:30b-a3b': {nm:'快 30B (a3b)', loc:'本地', t:'约 20 到 60 秒', use:'8B 成文校验未过时自动升档目标', warn:''},
  2032. 'deepseek-r1:14b': {nm:'本机 DeepSeek 14B', loc:'本地', t:'约 1 到 3 分钟', use:'交叉审核审核档 (作答请优先 27B)', warn:''},
  2033. 'deepseek-r1:32b': {nm:'本机 DeepSeek 32B', loc:'本地', t:'约 3 到 8 分钟', use:'备用: 更强更慢, 引用格式不稳', warn:''},
  2034. 'deepseek-chat': {nm:'DeepSeek', loc:'云端', t:'约 3 到 10 秒', use:'默认档, 状态查询与机制推断', warn:'内容会外发到模型厂商, 场名与台号已去标识化'},
  2035. 'kimi-fast': {nm:'Kimi', loc:'云端', t:'约 5 到 20 秒', use:'长文归纳', warn:'内容会外发到模型厂商, 场名与台号已去标识化'},
  2036. 'qwen-plus': {nm:'通义千问', loc:'云端', t:'约 5 到 20 秒', use:'备用云端档', warn:'内容会外发到模型厂商, 场名与台号已去标识化'},
  2037. 'deepseek-v4-pro': {nm:'云端 DS', loc:'云端', t:'约 2 到 6 分钟', use:'复杂推理与交叉审核', warn:'内容会外发到模型厂商, 场名与台号已去标识化'},
  2038. };
  2039. function mdlNote(){
  2040. const el=document.getElementById('mdlnote'), sel=document.getElementById('askm');
  2041. if(!el||!sel) return;
  2042. const v=MODEL_SPEC[sel.value]||{};
  2043. // ★查不到档位时**不能**默认"零外发" —— 未知档只可能是新加的云端模型。宁可说重不说轻。
  2044. const note = v.warn ? `<span style="color:var(--warn)">${v.warn}</span>`
  2045. : (v.loc==='本地' ? '全程零外发' : '<span style="color:var(--warn)">未知档位, 按内容会外发处理</span>');
  2046. el.innerHTML=`${v.use||''} · ${note}`;
  2047. }
  2048. async function askGo(){
  2049. const q=document.getElementById('askq').value, m=document.getElementById('askm').value;
  2050. if(!q.trim()){document.getElementById('askout').textContent='问题为空';return}
  2051. const r=await fetch('/api/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({q,model:m,cat:window.ASKCAT||'',xrev:(document.getElementById('askxrev')||{}).checked||false})});
  2052. const d=await r.json();
  2053. if(d.err){document.getElementById('askout').textContent=d.err;return}
  2054. askPoll();
  2055. }
  2056. // ★这句提示原文是「本地直连约 45~250 秒 (复合问题更久; 全程零外发)」—— 那是本地模型
  2057. // 时期的文案, 两处都已失真 (2026-09-01 用户说"太慢", 实测才发现):
  2058. // ① 时间: 云端档实测 3~7 秒, 而提示写 45~250 秒 —— **是提示语在告诉用户它很慢**
  2059. // ② 「全程零外发」现在是**假的**: 云端档的问题会发到厂商 API。
  2060. // 向用户保证一件不成立的事, 比写慢了严重得多。
  2061. // 故改为按档给话, 本地档才说零外发。
  2062. function MDLNOTE(m){
  2063. // ★不用正则: CHART_JS 是**非 raw** 的 Python 三引号串, 反斜杠转义序列会被 Python
  2064. // 吃掉并报 SyntaxWarning/SyntaxError。本地档判别用朴素前缀/子串即可。
  2065. // (本会话第 4 次踩这个坑 —— 而这一次是在**写这条警告的注释里**踩的。)
  2066. const s0=String(m||'').toLowerCase();
  2067. const local = s0.indexOf(':')>=0 || ['qwen3','llama','gemma','deepseek-r1'].some(x=>s0.indexOf(x)===0);
  2068. return local ? '本地直连, 约 45 到 250 秒 (复合问题更久), 全程零外发'
  2069. : '云端模型, 通常 3 到 10 秒 (复合问题更久), 问题与检索到的证据会发往模型厂商';
  2070. }
  2071. async function askPoll(){
  2072. const el=document.getElementById('askout'); if(!el) return;
  2073. const r=await fetch('/api/ask_status'); const s=await r.json();
  2074. if((s.state==='running'||s.state==='partial')){
  2075. // 进度上屏 (2026-08-28 用户"刚发现给不出结果"): 原来只有一个秒数在跳, 本地模型一问 45~250s,
  2076. // 看不见它在干什么就会被读成卡死。现在报当前阶段(预取/推理第n轮/取证工具名/成文/校闸)与轨迹。
  2077. const st=(s.steps||[]);
  2078. el.innerHTML=`<b>推理中…</b> ${s.elapsed}s · <span style="color:var(--accent)">${s.step||'启动'}</span>
  2079. <div class="small" style="color:var(--muted);margin-top:5px">${s.model} · ${MDLNOTE(s.model)}</div>
  2080. ${st.length?`<div class="small" style="color:var(--muted);margin-top:4px">${st.slice(-6).join(' → ')}</div>`:''}
  2081. <div class="small" style="color:var(--muted);margin-top:4px">问题: ${(s.q||'').slice(0,90)}</div>`;
  2082. setTimeout(askPoll, 2000);
  2083. }else if(s.state==='done'){
  2084. const v=s.verify||{};
  2085. const badge=v.全通过?`<span class="badge" style="border-color:var(--ok);color:var(--ok)">溯源核验 ${v.引用id数}/${v.引用id数} 全真</span>`
  2086. :`<span class="badge" style="border-color:var(--warn);color:var(--warn)">溯源核验未全过 (引用${v.引用id数||0})</span>`;
  2087. const R=s.review;
  2088. const rv=R?`<div class="card mb" style="border-left:3px solid ${R.issues&&R.issues.length?'var(--warn)':'var(--ok)'};margin-top:10px">
  2089. <h3>交叉审核 · ${R.model} <span class="small" style="font-weight:400;color:${R.issues&&R.issues.length?'var(--warn)':'var(--ok)'}">${R.verdict}</span></h3>
  2090. <p class="small" style="color:var(--muted);margin-bottom:${R.issues&&R.issues.length?'8px':'0'}">
  2091. 由另一本地模型基于<b>同一份证据</b>复核上述答案。溯源核验仅校验数字与机组号是否越界, 本项校验结论是否有证据支撑。</p>
  2092. ${(R.issues||[]).map(i=>`<div class="small" style="padding:5px 0;border-top:1px solid var(--line-2)">
  2093. <span class="badge" style="border-color:var(--warn);color:var(--warn)">${i.type}</span>
  2094. <b>「${i.quote}」</b> <span style="color:var(--muted)">${i.why}</span></div>`).join('')}
  2095. ${R.err?`<p class="small" style="color:var(--bad)">审核未完成: ${R.err}</p>`:''}</div>`:'';
  2096. el.innerHTML=`<p>${badge} · 模型 ${s.model} · 用时 ${s.elapsed}s · ${(s.steps||[]).length} 步</p><pre style="white-space:pre-wrap;background:var(--ground);padding:12px;border-radius:8px;border:1px solid var(--line)">${(s.answer||'').replace(/</g,'&lt;')}</pre>${rv}`;
  2097. }else if(s.state==='error'){
  2098. el.textContent='出错: '+s.err;
  2099. }
  2100. }
  2101. function winSel(cur){
  2102. const base=['近30日','近90日','2026年','2026H1','2025H2','全程'];
  2103. const ms=(window.ALLMONTHS&&ALLMONTHS.length)?ALLMONTHS:[]; // 按月份过滤和选择 (用户令): 预设窗+逐月
  2104. let h=`<select id="win" onchange="reload(this.value)"><optgroup label="预设窗">`+
  2105. base.map(w=>`<option ${w===cur?'selected':''}>${w}</option>`).join('')+`</optgroup>`;
  2106. if(ms.length) h+=`<optgroup label="按月">`+ms.slice().reverse().map(m=>`<option ${m===cur?'selected':''}>${m}</option>`).join('')+`</optgroup>`;
  2107. else if(!base.includes(cur)) h+=`<option selected>${cur}</option>`; // 台页/问题页无月轴时保显当前月窗
  2108. return h+`</select>`}