| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701 |
- <!doctype html>
- <html lang="zh">
- <head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width,initial-scale=1">
- <title>观澜 · 风电数据分析与决策支持</title>
- <link rel="icon" href="/guanlan/favicon.svg?v=20260904" type="image/svg+xml">
- <style>/* 观澜 新前端样式表 —— 设计基准 = docs/design/guanlan_v2.html
- * (2026-09-03 用户: "你帮我整体设计一下吧"; 纸底/印章/长卷那版已否, 留档 docs/design/_explored/)
- *
- * 六条决定:
- * 01 问题先于全貌 打开先看该管的几台; 全场状态压成 38 格一条带子放第二屏
- * 02 两条轴两枚chip 设备状态 (彩色) × 证据级 (单色) 并排 —— 两者不可互替, 这是产品的真正特色
- * 03 限定语不可分离 每个结论下的限定行在同一张卡, 带自己的标签
- * 04 一种强调色 海蓝只用于「可操作」; 红黄灰绿是语义不是装饰
- * 05 System font stack only; no external font request.
- * 06 不动 无入场动画, 只有悬停与选中
- *
- * 单主题 (显式底色)。深色版留作后续 —— token 已分层, 届时只改 :root。
- */
- :root{
- --ground:#F5F7F8; --panel:#FFFFFF; --ink:#1B2430; --muted:#5C6B78; --line:#DDE3E8; --hair:#C9D1D8;
- --accent:#1F6F8B; --accent-deep:#175A71; --accent-soft:#BFDCE6; --wash:#E8F2F6;
- --critical:#C8372D; --warning:#D48A1E; --unknown:#8A96A0; --good:#2E8B6A; --excellent:#8FCBB5;
- /* 迁移期: 旧 JS 里的变量名指到新语义 */
- --bad:var(--critical); --warn:var(--warning); --ok:var(--good); --info:var(--accent);
- --sans:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",Arial,sans-serif;
- --mono:"SF Mono",Menlo,Consolas,monospace;
- --r:10px;
- }
- *{box-sizing:border-box}
- html{background:var(--ground)}
- body{margin:0;background:var(--ground);color:var(--ink);font-family:var(--sans);font-size:14px;line-height:1.55;-webkit-font-smoothing:antialiased}
- body[data-lang=zh]{font-family:"PingFang SC","Hiragino Sans GB","Microsoft YaHei",Inter,ui-sans-serif,system-ui,sans-serif}
- a{color:var(--accent-deep);text-decoration:none}
- a:hover{text-decoration:underline}
- .wrap{max-width:1200px;margin:0 auto;padding:0 28px 72px}
- :focus-visible{outline:2px solid var(--accent);outline-offset:2px}
- .num{font-variant-numeric:tabular-nums}
- .small{font-size:12.5px} .muted{color:var(--muted)}
- h1,h2,h3{margin:0;font-weight:600;letter-spacing:-.005em;text-wrap:balance}
- h2{font-size:15px} h3{font-size:14px}
- @media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
- /* ── 顶 ── */
- header.top{display:flex;align-items:center;gap:14px;padding:18px 0 12px}
- .mark{display:inline-flex;align-items:center;gap:9px}
- .mark svg{width:26px;height:26px;color:var(--accent)}
- .brand{font-weight:600;font-size:18px;letter-spacing:-.01em}
- .brand small{font-weight:400;color:var(--muted);margin-left:8px;font-size:13px}
- .tag{font-family:var(--mono);font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);border:1px solid var(--hair);border-radius:4px;padding:2px 7px}
- header.top .right{margin-left:auto;display:flex;gap:8px;align-items:center}
- select,input,button{font:inherit;font-size:13px;color:var(--ink)}
- select,input{background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:6px 10px}
- input::placeholder{color:var(--muted)}
- .toggle{display:inline-flex;border:1px solid var(--line);border-radius:7px;overflow:hidden;background:var(--panel)}
- .toggle button{border:0;background:transparent;padding:6px 12px;cursor:pointer;color:var(--muted)}
- .toggle button.on{background:var(--ink);color:#fff}
- /* ── 页签: 平铺 (用户 2026-08-28 裁定), 当前项一条下划线 ── */
- nav.tabs{display:flex;gap:2px;border-bottom:1px solid var(--line);flex-wrap:wrap}
- nav.tabs button{font-size:13.5px;border:0;background:transparent;color:var(--muted);padding:9px 12px 10px;cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px}
- nav.tabs button.on{color:var(--accent-deep);border-bottom-color:var(--accent);font-weight:500}
- /* ── 节 ── */
- section{margin-top:26px}
- .sechead{display:flex;align-items:baseline;gap:12px;margin-bottom:10px}
- .sechead .meta{color:var(--muted);font-size:12.5px}
- .sechead .legend{margin-left:auto}
- .legend{display:flex;gap:14px;font-size:12px;color:var(--muted)}
- .legend i{display:inline-block;width:9px;height:9px;border-radius:2px;vertical-align:-1px;margin-right:5px}
- .eyebrow{font-family:var(--mono);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin:22px 0 8px}
- /* ── 按钮 ── */
- .btn{padding:7px 16px;border-radius:7px;border:1px solid var(--accent);background:var(--accent);color:#fff;font-weight:500;cursor:pointer}
- .btn:hover{background:var(--accent-deep);border-color:var(--accent-deep)}
- .btn.quiet{background:transparent;color:var(--accent-deep)}
- .btn:disabled{opacity:.5;cursor:not-allowed}
- /* ── 面板 / 表 ── */
- .panel{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:12px 14px}
- .mb{margin-bottom:12px}
- .tblwrap{overflow-x:auto}
- table{width:100%;border-collapse:collapse;font-size:13px}
- th{font-weight:500;color:var(--muted);text-align:left;padding:7px 10px;border-bottom:1px solid var(--line);font-size:12px;letter-spacing:.02em;white-space:nowrap}
- td{padding:7px 10px;border-bottom:1px solid var(--line);font-variant-numeric:tabular-nums;vertical-align:top}
- tr:last-child td{border-bottom:0}
- td.r,th.r{text-align:right}
- /* ── KPI: 左边条 = 语义 ── */
- .kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px}
- .kpi{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:12px 14px 11px;position:relative}
- .kpi::before{content:"";position:absolute;left:0;top:10px;bottom:10px;width:3px;border-radius:0 2px 2px 0;background:var(--accent)}
- .kpi.warn::before{background:var(--warning)} .kpi.bad::before{background:var(--critical)} .kpi.ok::before{background:var(--good)}
- .kpi b{display:block;font-size:24px;font-weight:600;letter-spacing:-.02em;line-height:1.15;font-variant-numeric:tabular-nums}
- .kpi b small{font-size:13px;font-weight:500;color:var(--muted);margin-left:2px}
- .kpi span{display:block;font-size:12px;color:var(--muted);margin-top:4px}
- /* ── chip: 设备状态 (彩色) ── */
- .chip{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;font-weight:500;padding:2px 8px;border-radius:5px;white-space:nowrap;line-height:1.5}
- .chip.bad{background:rgba(200,55,45,.10);color:var(--critical)}
- .chip.warn{background:rgba(212,138,30,.14);color:#9C6410}
- .chip.unk{background:rgba(138,150,160,.16);color:#4F5B66}
- .chip.ok{background:rgba(46,139,106,.12);color:#22705A}
- .chip.ex{background:rgba(143,203,181,.22);color:#2E7A62}
- /* ── tier: 证据级 (单色) · rv: 审级 ── */
- .evid{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
- .tier{font-family:var(--mono);font-size:10.5px;letter-spacing:.05em;text-transform:uppercase;padding:2px 7px;border-radius:4px;border:1px solid var(--hair);color:var(--muted);white-space:nowrap}
- .tier.confirmed{background:var(--ink);border-color:var(--ink);color:#fff}
- .tier.near{border-color:var(--ink);color:var(--ink)}
- .tier.candidate{border-color:var(--muted);color:var(--muted)}
- .tier.reference{}
- .tier.insufficient{border-style:dashed}
- .tier.withdrawn{text-decoration:line-through}
- .rv{font-family:var(--mono);font-size:10.5px;letter-spacing:.05em;text-transform:uppercase;color:var(--muted)}
- .rv::before{content:"·";margin-right:3px;color:var(--hair)}
- /* ── 关注卡: 左边条 = 设备状态 ── */
- .cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:12px}
- .card{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:12px 14px 12px 18px;position:relative;transition:box-shadow .15s}
- .card::before{content:"";position:absolute;left:0;top:12px;bottom:12px;width:3px;border-radius:0 2px 2px 0;background:var(--sev,var(--hair))}
- .card.lit{box-shadow:0 0 0 2px var(--accent-soft)}
- .card .head{display:flex;align-items:baseline;gap:10px}
- .card .unit{font-weight:600;font-size:19px;letter-spacing:-.02em;font-variant-numeric:tabular-nums}
- .card .sys{color:var(--muted);font-size:13px}
- .card .head .chip{margin-left:auto}
- .card .why{margin:8px 0 9px;font-size:13.5px}
- .caveat{font-size:12px;color:var(--muted);border-top:1px solid var(--line);padding-top:7px;margin-top:2px;line-height:1.5}
- .caveat b{font-family:var(--mono);font-weight:500;font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin-right:7px}
- /* ── 迷你趋势: 越早越淡 (JS 按序设 opacity), 末点实色 ── */
- .spark{height:28px;display:flex;align-items:flex-end;gap:2px;margin:9px 0 8px}
- .spark i{flex:1;display:block;background:var(--accent);border-radius:1px 1px 0 0}
- /* ── 全场状态条: 一格一台 ── */
- .strip{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:12px 14px 8px}
- .cells{display:grid;grid-template-columns:repeat(var(--n,38),1fr);gap:4px}
- .cell{aspect-ratio:1;border-radius:3px;background:var(--excellent);transition:transform .1s}
- .cell.hot{cursor:pointer} .cell.hot:hover{transform:translateY(-2px)}
- .cell.bad{background:var(--critical)} .cell.warn{background:var(--warning)} .cell.unk{background:var(--unknown)} .cell.ok{background:var(--good)}
- .cell.lit{outline:2px solid var(--ink);outline-offset:1px}
- .nums{display:grid;grid-template-columns:repeat(var(--n,38),1fr);gap:4px;margin-top:5px;font-family:var(--mono);font-size:9.5px;color:var(--muted);text-align:center}
- .nums span.hot{color:var(--ink);font-weight:500}
- .quiet{color:var(--muted);font-size:13px;margin-top:10px}
- .quiet b{color:var(--ink);font-weight:500}
- /* ── 系统入口 ── */
- .systems{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:10px}
- .sysc{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:11px 13px;display:grid;gap:7px}
- .sysc .t{display:flex;justify-content:space-between;align-items:baseline}
- .sysc .t b{font-weight:500}
- .sysc .t a{font-size:12px}
- .bar{display:flex;height:6px;border-radius:3px;overflow:hidden;background:var(--line)}
- .bar i{display:block;height:100%}
- .sysc .cnt{display:flex;gap:10px;font-size:11.5px;color:var(--muted);font-variant-numeric:tabular-nums}
- .sysc .cnt i{display:inline-block;width:7px;height:7px;border-radius:2px;margin-right:4px}
- /* ── 骨架 (载入中) ── */
- .skel{height:12px;border-radius:6px;background:linear-gradient(90deg,var(--line),var(--panel),var(--line));background-size:200% 100%;animation:sk 1.2s linear infinite}
- @keyframes sk{to{background-position:-200% 0}}
- /* 原文数据 (英文模式下按原文标注显示) */
- .verbatim{font-family:"PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif}
- code{font-family:var(--mono);font-size:11.5px}
- /* ── 问答: 类别 pill / 常用问题行 / 核验徽章 ── */
- .pill{font-size:13px;padding:6px 14px;border-radius:999px;border:1px solid var(--line);background:var(--panel);color:var(--ink);cursor:pointer}
- .pill.on{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:500}
- .qitem{font-size:13px;cursor:pointer;border:1px solid var(--line);border-radius:8px;padding:8px 12px;margin-top:8px;background:var(--panel)}
- .qitem:hover{border-color:var(--accent-soft);background:var(--wash)}
- .badge{display:inline-block;font-size:11.5px;padding:1px 8px;border-radius:5px;border:1px solid var(--hair);color:var(--muted)}
- .badge.ok{border-color:var(--good);color:var(--good)} .badge.warn{border-color:var(--warning);color:#9C6410}
- /* ── 汇报定制: 一页纸预览 ── */
- .paper{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:26px 30px;margin-top:14px}
- .paper-hd{display:flex;justify-content:space-between;align-items:flex-start;padding-bottom:12px;margin-bottom:16px;border-bottom:2px solid var(--ink)}
- .paper-ti{font-weight:600;font-size:17px;letter-spacing:-.01em}
- .paper-ft{display:flex;justify-content:space-between;gap:16px;font-size:11.5px;color:var(--muted);padding-top:10px;margin-top:16px;border-top:1px solid var(--line);flex-wrap:wrap}
- .paper .panel{border-radius:8px}
- svg.dt text{font-family:var(--sans)}
- @media print{.noprint,nav.tabs,header.top,footer{display:none!important}.wrap{padding:0;max-width:none}.paper{border:0;padding:0}body{background:#fff}}
- /* ── 双列网格 (故障/电量页): 窄屏单列, 宽屏两列; span2 占满 ── */
- .grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(480px,1fr));gap:12px}
- .grid2 .span2{grid-column:1/-1}
- i.bar{display:inline-block;height:10px;border-radius:2px;vertical-align:middle;margin-right:6px;opacity:.85}
- /* ── 振动融合: 状态|证据 双联 chip, 传动链小格, 矩阵表 ── */
- .duo{display:inline-flex;align-items:stretch;border:1px solid var(--c,var(--hair));border-radius:5px;overflow:hidden;font-size:11.5px;line-height:1.5;max-width:100%}
- .duo .st{padding:1px 7px;background:var(--c,var(--hair));color:#fff;font-weight:500;white-space:nowrap}
- .duo .ev{padding:1px 7px;color:var(--ink);background:var(--panel);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:220px}
- .chain{display:inline-flex;gap:2px;vertical-align:middle}
- .chain i{display:inline-block;width:11px;height:11px;border-radius:2px;border:1px solid var(--hair);background:var(--panel)}
- .chain i.on{border-color:var(--accent)}
- table.fmx td{vertical-align:top;padding:6px 8px}
- table.fmx th div{line-height:1.3}
- /* ── 逐台/单问题浮层 (静态站上 /turbine /problem 是 404, 页内兜住) ── */
- .popm{position:fixed;inset:0;z-index:9999;background:rgba(27,36,48,.55);overflow:auto;padding:24px 16px}
- .popc{max-width:1120px;margin:0 auto;background:var(--ground);border:1px solid var(--line);border-radius:var(--r);padding:16px 18px}
- .poph{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:12px;padding-bottom:10px;border-bottom:1px solid var(--line)}
- .poph h3{font-size:16px}
- /* 2026-09-03: reading hierarchy, work-order details and responsive layout. */
- :root{--r:12px;--shadow:0 3px 16px rgba(23,51,68,.035)} /* 2026-09-06 用户裁: 去中段 --ground/--line 覆盖, 令牌与门户一致 */
- .wrap{max-width:1360px;padding:0 32px 64px}
- header.top{padding:24px 0 20px;gap:24px}
- .brand{font-size:21px;letter-spacing:-.035em}
- .brand small{letter-spacing:0;font-size:12px}
- .mark{gap:12px}
- nav.tabs{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:4px;padding:6px;background:#EAF0F3;border:1px solid var(--line);border-radius:12px}
- nav.tabs button{margin:0;padding:10px 6px;min-height:40px;border:0;border-radius:7px;font-size:12px;line-height:1.3}
- nav.tabs button:hover{color:var(--accent-deep);background:#F5F9FA}
- nav.tabs button.on{color:var(--accent-deep);background:var(--panel);box-shadow:0 1px 5px #17334412;font-weight:600}
- .page-head{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:28px 0 16px}
- .page-head h1{font-size:28px;line-height:1.2;letter-spacing:-.035em}
- .window-badge{font-size:11px;font-weight:500;white-space:nowrap;color:var(--accent-deep);border:1px solid #CDDEE6;background:#ECF4F7;padding:5px 10px;border-radius:6px}
- .page-head+section{margin-top:0}
- .sechead{flex-wrap:wrap;row-gap:6px;margin-bottom:14px}
- .sechead h2{font-size:18px;letter-spacing:-.025em}
- .legend{flex-wrap:wrap;row-gap:5px}
- .panel,.card,.kpi,.strip,.sysc{box-shadow:var(--shadow)}
- .panel{padding:18px}
- .panel>h3{font-size:15px;line-height:1.45;margin-bottom:8px}
- .panel,.grid2>*{min-width:0}
- .kpis{grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}
- .kpi{padding:18px 20px;min-height:108px}
- .kpi::before{top:18px;bottom:18px}
- .kpi b{font-size:29px;line-height:1.1}
- .kpi b small{margin-left:5px;font-size:12px}
- .kpi span{font-size:12px;margin-top:9px;line-height:1.45;max-width:36ch}
- .cards{grid-template-columns:repeat(2,minmax(0,1fr));align-items:start;gap:16px}
- .card{padding:20px 22px 18px}
- .card .head{align-items:start;flex-wrap:wrap;gap:8px}
- .card .unit{font-size:23px;line-height:1.15;white-space:nowrap}
- .card .sys{flex:1;min-width:90px;line-height:1.45}
- .card .head .chip{margin-left:auto}
- .card .evid{margin:12px 0 8px}
- .card .caveat{font-size:12px;line-height:1.65;padding-top:12px}
- .evidence-detail{margin-top:14px;background:#F5F8FA;border:1px solid #E5ECF0;border-radius:7px;padding:9px 12px}
- .evidence-detail summary{cursor:pointer;color:var(--accent-deep);font-size:12px;font-weight:500}
- .card .why{line-height:1.7;margin:10px 0 3px}
- .sysc{padding:16px;gap:12px}
- .sysc[role=button]{cursor:pointer}
- .sysc[role=button]:hover{border-color:#9DBECA;background:#FBFDFE}
- .grid2{grid-template-columns:repeat(auto-fit,minmax(min(100%,440px),1fr));gap:16px}
- .tblwrap{max-width:100%;border-radius:7px;overscroll-behavior-x:contain}
- th{background:#F4F7F9;font-size:11px;font-weight:600;letter-spacing:.025em;padding:10px 12px}
- td{padding:11px 12px;line-height:1.6}
- tr:hover>td{background:#F7FAFC}
- td.r,th.r{white-space:nowrap}
- .strip{overflow-x:auto;padding:18px}
- .strip .cells,.strip .nums{min-width:850px}
- footer{border-top:1px solid var(--line);padding-top:18px;line-height:1.7;font-size:11px!important}
- .popm{padding:24px;background:rgba(17,35,47,.58);backdrop-filter:blur(3px)}
- .popc{max-width:1240px;padding:0 0 8px;border-radius:16px;box-shadow:0 24px 80px #0E263640}
- .poph{position:sticky;top:-24px;z-index:2;align-items:center;padding:20px 24px;margin:0 0 20px;background:var(--panel);border-radius:16px 16px 0 0;box-shadow:0 2px 8px #17334405}
- .poph h3{font-size:23px;line-height:1.3;letter-spacing:-.025em}
- .poph .btn{flex-shrink:0;min-height:36px;border-color:var(--line);background:var(--ground)}
- .popc>.panel,.popc>p{margin:16px 20px}
- .popc>.panel{padding:20px}
- .popc>.panel>.head{align-items:center!important;margin-bottom:10px;font-size:17px}
- .popc>.panel>.small{max-width:105ch;line-height:1.7}
- .popc .grid2>.panel{padding:16px;box-shadow:none;background:#FBFCFD}
- .order-block{margin-top:24px;border-top:1px solid var(--line);padding-top:18px}
- .order-head{display:flex;align-items:center;gap:18px;flex-wrap:wrap;margin-bottom:12px}
- .order-head>b{font-size:14px}
- .action-counts{display:flex;align-items:center;gap:7px;flex-wrap:wrap}
- .action-count{display:inline-flex;gap:7px;align-items:center;background:var(--wash);color:var(--accent-deep);border:1px solid #D5E7EF;border-radius:5px;padding:3px 8px;font-size:11px}
- .action-count b{font-variant-numeric:tabular-nums;font-weight:600}
- .order-table{table-layout:fixed;min-width:780px}
- .order-table .order-date{width:112px;white-space:nowrap}
- .order-table td{overflow-wrap:anywhere;padding-top:14px;padding-bottom:14px}
- .order-table td.order-date{overflow-wrap:normal;font-size:12px;color:var(--muted)}
- .popup-verbatim-note{color:var(--muted);font-size:11px;line-height:1.7;padding:0 4px}
- .verbatim{border-bottom:1px dotted #B9C6CF}
- @media(min-width:1440px){.kpis{grid-template-columns:repeat(6,minmax(0,1fr))}}
- @media(max-width:900px){.wrap{padding:0 20px 48px}.brand small{display:block;margin:3px 0 0}.cards{grid-template-columns:1fr}.sechead .legend{margin-left:0;width:100%}.popm{padding:12px}.poph{top:-12px}.systems{grid-template-columns:repeat(2,minmax(0,1fr))}}
- @media(max-width:600px){
- .wrap{padding:0 14px 40px}header.top{flex-wrap:wrap;gap:14px;padding:18px 0}.brand{font-size:20px}header.top .right{margin-left:0;width:100%;gap:8px}header.top select{flex:1;min-width:0}
- nav.tabs{grid-template-columns:repeat(2,minmax(0,1fr));gap:3px;padding:5px}nav.tabs button{font-size:11.5px;min-height:36px;text-align:left;padding:8px 10px}
- .page-head{margin:22px 0 14px;align-items:flex-start}.page-head h1{font-size:23px}.window-badge{font-size:10px;padding:4px 7px}
- .kpis{grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.kpi{padding:14px;min-height:116px}.kpi b{font-size:24px}.kpi span{font-size:11px}
- .systems{grid-template-columns:1fr}.panel{padding:14px}.card{padding:16px}.card .head .sys{font-size:12px}.card .unit{font-size:21px}
- .popm{padding:8px}.popc{border-radius:12px}.poph{top:-8px;padding:15px 14px;border-radius:12px 12px 0 0;margin-bottom:12px}.poph h3{font-size:19px}.popc>.panel,.popc>p{margin:12px 10px}.popc>.panel{padding:14px}.popc .grid2>.panel{padding:12px}
- .order-head{gap:8px}.paper{padding:18px 14px}.paper-hd{gap:12px;flex-wrap:wrap}
- }
- @media print{.evidence-detail{display:block}.poph{position:static}nav.tabs,header.top,.page-head .window-badge{display:none!important}.card,.panel,.kpi{box-shadow:none}}
- /* Singapore bay theme: a restrained Marina Bay silhouette, garden canopies, turbines and wind-flow traces. */
- /* 2026-09-06 用户裁: 删末块令牌覆盖, 与门户单文件浅色令牌 (#F5F7F8/#1B2430/#1F6F8B/#DDE3E8) 一致; 基线 detail_style_baseline */
- body{background:radial-gradient(ellipse at 95% 0,rgba(41,201,187,.075),transparent 40%),var(--ground)}
- header.top{position:relative;isolation:isolate;overflow:hidden;min-height:128px;margin:22px 0 12px;padding:28px 30px;border:1px solid #1F526A;border-radius:18px;background:radial-gradient(ellipse at 68% 120%,#08788788,transparent 60%),linear-gradient(115deg,#092438,#103E55);box-shadow:0 10px 32px #0A344416}
- header.top::before{content:"";position:absolute;z-index:-1;inset:0;opacity:.16;background-image:linear-gradient(#73B4C425 1px,transparent 1px),linear-gradient(90deg,#73B4C425 1px,transparent 1px);background-size:28px 28px;pointer-events:none}
- .wind-field{position:absolute;width:420px;height:130px;left:35%;bottom:0;color:#61DBCE;opacity:.4;pointer-events:none;z-index:-1}
- header.top .mark{gap:15px}header.top .mark>svg{width:36px;height:36px;color:#65E1D0;padding:5px;border:1px solid #66CCBE55;border-radius:10px;background:#73E4CE0D}
- header.top .brand{font-size:27px;color:#F4FFFF;letter-spacing:-.035em}
- header.top .brand small{display:block;margin:3px 0 0;font-size:11px;letter-spacing:.035em;color:#B3D6DF}
- header.top .right{position:relative;padding:9px 11px;border:1px solid #759EAD44;border-radius:10px;background:#092A3EBF}
- header.top .right label{color:#B9D7E1}header.top select{background:#153E52;color:#E5FAFF;border-color:#3A6576}
- header.top .toggle{background:#E9F5F7;border-color:#527887}
- nav.tabs{background:#E4EFF2;border-color:#D1E3E8;gap:5px}
- nav.tabs button.on{color:#fff;background:linear-gradient(110deg,#087788,#006975);box-shadow:0 3px 8px #08778820}
- nav.tabs button:hover:not(.on){background:#F7FFFF;color:#00636B}
- .page-head h1{position:relative;padding-left:14px}
- .page-head h1::before{content:"";position:absolute;left:0;top:4px;bottom:2px;width:4px;border-radius:3px;background:linear-gradient(#07949C,#46C7AB)}
- .window-badge{background:#E5F5F3;border-color:#BDDCD8;color:#00636B}
- .kpi{background:linear-gradient(140deg,#fff 65%,#EEF9F8)}
- .evidence-detail{background:#F0F8F9;border-color:#DAECEE}
- .panel,.card,.sysc{border-color:#D7E5EB}.sysc[role=button]:hover{border-color:#69AAA9}
- a:hover{color:#008591}
- :focus-visible{outline-color:#05A2AD;outline-width:3px}
- @media(max-width:900px){header.top{padding:24px;gap:16px}.wind-field{left:34%;width:280px;opacity:.25}}
- @media(max-width:600px){header.top{margin-top:14px;padding:20px 17px;min-height:156px;border-radius:14px;gap:18px}header.top .brand{font-size:25px}header.top .brand small{font-size:10px}header.top .right{padding:8px;width:100%}.wind-field{left:auto;right:-50px;bottom:46px;width:230px;opacity:.22}header.top .mark{gap:12px}}
- @media print{body{background:#fff}.page-head h1::before{display:none}.kpi{background:#fff}a{color:inherit}}
- .source-record{margin-top:5px;font-size:10px;color:var(--muted);font-weight:400;line-height:1.6}
- .source-record summary{cursor:pointer;display:list-item;color:var(--accent-deep)}
- .source-record[open]{background:var(--ground);border:1px solid var(--line);border-radius:6px;padding:7px}
- .source-record>div{margin-top:6px;font-size:12px;overflow-wrap:anywhere}
- /* 返回门户 / 重新选择机组·测点 (2026-09-06 用户裁: 补上再冻结) */
- header.top .right .back-portal,header.top .right .reselect{display:inline-block;margin-right:8px;padding:6px 11px;border:1px solid #759EAD66;border-radius:8px;background:#0E3446;color:#E5FAFF;font-size:12.5px;text-decoration:none;cursor:pointer;line-height:1.3}
- header.top .right .back-portal:hover,header.top .right .reselect:hover{background:#155069;border-color:#9BC6D3}
- </style>
- </head>
- <body data-lang="zh">
- <div class="wrap">
- <header class="top" id="top"></header>
- <nav class="tabs" id="tabs"></nav>
- <main id="root"><div class="panel"><div class="skel" style="width:38%"></div><div class="skel" style="width:72%;margin-top:9px"></div><div class="skel" style="width:56%;margin-top:9px"></div></div></main>
- <footer id="foot" class="small muted" style="margin-top:36px"></footer>
- </div>
- <script>window.__L={"app.title": "观澜 · 风电数据分析与决策支持", "app.brand": "观澜", "app.subtitle": "海上风电场", "app.build": "最新完成构建", "app.lang_zh": "中文", "app.lang_en": "英文", "tab.overview": "总览", "tab.energy": "电量算账", "tab.component": "部件问题", "tab.vibration": "振动融合分析", "tab.generation": "发电性能", "tab.fault": "故障统计", "tab.decision": "检修决策", "tab.assistant": "检修助手", "tab.report": "汇报定制", "tab.system": "系统维护", "tab.ontology": "本体", "ui.query": "查询", "ui.loading": "载入中…", "ui.empty": "无数据", "ui.error": "出错了", "ui.retry": "重试", "ui.expand": "展开", "ui.collapse": "收起", "ui.all": "全部", "ui.unit": "机组", "ui.units": "机组", "ui.system": "系统", "ui.date": "日期", "ui.count": "条数", "ui.not_recorded": "台账未填", "asst.head": "检修助手 — 确定性本体查询", "asst.head_note": "结果逐字取自手册与工单, 不经模型", "asst.corrective": "抢修", "asst.corrective_flow": "报警码 → 失效模式 → 手册分步处置 → 历史工单", "asst.code_placeholder": "报警码, 如 3225 / 3130 / 13902", "asst.common": "常见", "asst.preventive": "预防性维护", "asst.preventive_flow": "系统 → 失效模式 → 年度任务 → 作业指导步骤", "asst.faulttree": "故障树", "asst.faulttree_flow": "按证据量排序的系统失效模式 + 部件级成因", "sys.blade": "叶片与叶根", "sys.pitch": "变桨", "sys.yaw": "偏航", "sys.gearbox": "齿轮箱", "sys.generator": "发电机", "sys.main_bearing": "主轴承", "sys.converter": "变流器", "sys.tower": "塔架与基础", "sys.control": "主控与传感网", "st.bad": "危险", "st.warn": "报警", "st.unk": "不可判", "st.ok": "良好", "st.excellent": "优秀", "st.ok_excellent": "良好 / 优秀", "tier.confirmed": "定论", "tier.near": "准定论·预警", "tier.candidate": "候选", "tier.reference": "参考", "tier.insufficient": "不足", "tier.withdrawn": "撤回", "tier.confirmed.s": "定论", "tier.near.s": "准定论", "tier.candidate.s": "候选", "tier.reference.s": "参考", "tier.insufficient.s": "不足", "tier.withdrawn.s": "撤回", "rv.dual": "双模型审", "rv.advci": "AdvCI", "rv.lite": "lite", "rv.none": "未审", "ov.attention": "需要关注", "ov.fus_gap_hint": "这一面(需要关注 / 全场状态)取数来自融合面;缺件时如实为空,不从交付包补齐。在本机放好原始件后跑一次重算即会由观澜自己的生成端补上(scripts/rudong_fusion_handoff.py,见 docs 的「源代码化清单」)。", "ov.attention_n": "{n} 台", "ov.ranked_by": "按 设备状态 × 证据量 排序", "ov.strip": "全场状态", "ov.strip_hint": "一格一台 · 点格定位", "ov.quiet": "其余 {n} 台未进入优先关注清单;设备状态请查看全场状态评估。", "ov.quiet_all": "当前优先关注清单为空;{n} 台机组的设备状态请查看全场状态评估。", "ov.caveat": "限定", "ov.systems": "九系统", "ov.systems_hint": "点系统看逐台依据", "ov.illustrative": "示意数据", "ui.open": "查看 →", "ui.back_to": "← 返回{tab}", "ui.back_portal": "← 返回门户", "rpt.facts.title": "可交付级结论 · 契约生成", "rpt.facts.sub": "{n} 条, 契约 {sha}; 数据/时间窗/结论只经契约更新", "rpt.facts.verdict": "裁决", "rpt.facts.text": "结论", "rpt.facts.sys": "系统", "rpt.facts.agg": "聚合层", "ui.reselect": "重新选择机组 / 测点", "kpi.avail": "时间可用率 (不含调度令) · {win}", "kpi.downtime": "停机时长 · {n} 起", "kpi.mtbo": "平均停机间隔(MTBO;非行业 MTBF)", "kpi.loss": "停机电量损失 · {win}", "kpi.loss_dispatch": "另调度令 {n}", "kpi.curtail": "限电·绑定 电量损失", "kpi.alarm_units": "九系统有报警的台数 / 全场", "kpi.alarm_units.hint": "统计方法: 九系统 (叶片与叶根/变桨/偏航/齿轮箱/发电机/主轴承/变流器/塔架与基础/主控与传感网) 中任一被判「报警」即计一台。与下方「全场状态」格的统计方法不同 —— 那里按每台 振动/温度/润滑/油液/报警/工单 六列取最严一级, 所以两处数字可以不同, 不是矛盾。", "tier.candidate_new": "候选·新发", "tier.candidate_baseline": "候选·记基线", "tier.normal": "正常", "tier.candidate_new.s": "候选·新发", "tier.candidate_baseline.s": "候选·记基线", "tier.normal.s": "正常", "step.evidence": "证据", "step.site_records": "现场记录", "step.mechanism": "机制", "step.rating": "判级", "step.schedule": "排期", "step.action": "动作", "step.acceptance": "验收", "step.stuck_at": "卡在「{step}」", "win.label": "时间窗", "win.presets": "预设时间窗", "win.months": "按月", "win.custom": "自定义", "win.custom_hint": "自定义起止日期(含两端)。日粒度件(判级/停机台账/曲线)按日精确;月度聚合件按所跨月份取整,图上会写明。", "win.from": "开始日期(含)", "win.to": "结束日期(含)", "win.apply": "应用", "win.need_both": "请把开始与结束日期都选上", "win.bad_order": "开始日期不能晚于结束日期", "win.custom_sel": "自定义 {a} ~ {b}", "cmp.grading_pending": "正在按所选时间窗重算判级…", "cmp.grading_pending_why": "时间窗 {span}:变桨/偏航/蓄能/温度四个判级面正按该时间窗重算(首次约 25 秒),下面的系统卡暂时还是上一份统计方法 —— 算完会自动刷新,不会静默混用。", "win.30d": "近30日", "win.90d": "近90日", "win.2026": "2026年", "win.2026H1": "2026H1", "win.2025H2": "2025H2", "win.all": "全程", "ui.not_ported": "本页尚未迁入新版。", "ui.open_classic": "在经典版打开 →", "ui.data_hidden": "({n} 处数据文本尚无英文,需复核)", "ui.verbatim": "原始记录 (中文)", "ui.verbatim_note": "{n} 处为手册/工单原文, 无英文译本, 按原文转录。", "asst.querying": "查询中…", "asst.query_failed": "查询失败: {e}", "asst.identity": "系统 {sys} · 本场 {n} 条", "asst.alarm_type": "告警类型 {t}", "asst.unassigned": "未归属", "asst.no_name": "(码表未收录)", "asst.failmodes": "指向的失效模式", "asst.fm_counts": "该类本场报警 {a} 条 / 工单 {w} 条", "asst.steps": "现场处置 {n} 步", "asst.steps_note": "节点部件 = 手册原文元件位号 (确定); 判定规范 = 实机参数表按物理量匹配的候选判据, 非厂商绑定 — 现场以铭牌与图纸复核。", "asst.step_n": "第 {n} 步", "asst.qty": "量: {q}", "asst.tags": "节点部件", "asst.nodes": "应检节点 · {c}", "asst.nodes_note": "手册此步未给位号 → 按回路构成展开, 逐点查", "asst.node": "节点", "asst.check": "查什么", "asst.known_tags": "该系统手册出现过的位号 (对图纸时参考):", "asst.specs": "判定规范 (实机参数表)", "asst.spec_no": "参数号", "asst.spec_name": "名称", "asst.spec_val": "整定值", "asst.spec_range": "允许范围", "asst.drawings": "对应图纸", "asst.no_steps": "本码无手册处置条目 (手册覆盖本场 161/322 码) — 需查图纸或联系厂商支持。", "asst.wo_head": "现场实际怎么修的 ({sys} 历史工单 {n} 条, 台账 2020~2024)", "asst.wo_fault": "故障", "asst.wo_action": "处置", "asst.wo_note": "工单时间窗止 2024-11-21; 2025–26 现场未提供 ⇒ \"0 条\" 是记录缺失非未修过。", "asst.tree_head": "{sys} 故障树 · {n} 个失效模式", "asst.tree_note": "一层 = 系统 × 失效类 (归纳自本场码表与工单) · 二层 = 手册排查方向归一 + 工单原因计数; 条长按实证量 (报警条数 + 工单 × 10), 因果来自手册与工单原文非推断, 树深两层不外推。", "asst.tree_counts": "本场报警 {a} 条 · 工单 {w} 条", "asst.tree_codes": "征兆码 {c}", "asst.causes": "← 可能原因 (手册排查方向 + 工单实证)", "asst.cause_counts": "手册 {r} 次 · 工单 {w} 次 · 涉码 {c}", "asst.no_causes": "(手册无该类排查条目 — 原因层待补)", "asst.tree_empty": "该系统无失效模式记录", "asst.prev_head": "{sys} 预防链 · 失效模式 {f} 类 · 预防性任务 {n} 项", "asst.prev_fm": "失效模式 (按本场报警量)", "asst.prev_tasks": "年度任务", "asst.interval": "周期 {i}", "asst.wi_steps": "WI {n} 步", "asst.prev_empty": "(该系统无匹配的年度维护条目 — 维护作业指导书按位置代号编排, 未必逐系统对齐)", "dec.head": "检修决策 · 本体问答 (推理无界, 结论须过校验)", "dec.classic": "维护标准 / 维护框架 / 数据质量三块尚未迁入新版 —", "ask.common_q": "{cat} · 常用问题 (点即问, 或下方自由输入)", "ask.grounding": "答案句句带 [对象id] 溯源 + 数据回抓校验核验; 判级只转述本体原文带审级, 模型零判级权。", "ask.placeholder": "自由提问 ({cat})", "ask.model": "模型", "ask.no_models": "无可用模型", "ask.refs.title": "契约引用 ({n} 条, 契约 {sha}) — 按问题/回答匹配的可交付结论, 引文来自 qa_refs", "ask.refs.none": "无匹配契约条目 (契约 {sha}): 本回答未被结构化事实契约背书, 按模型输出对待", "ask.local_down": "本机模型未启动 (Ollama 127.0.0.1:11434 未响应); 离线版不转云", "ask.partial": "初答 · 27B 复核中", "ask.refined": "已复核 (默认档 {m} 未过成文校验, 由更强模型重答)", "ask.xrev": "交叉审核", "ask.xrev_note": "(DeepSeek 14B 互审, +约 1 分钟)", "ask.xrev_title": "由另一模型基于同一份证据复核; 同一模型自审无法构成独立校验", "ask.go": "提问", "ask.idle": "空闲 — 点常用问题或输入后提问。串行一次一问。", "ask.empty": "问题为空", "ask.running": "推理中…", "ask.elapsed": "{s} 秒", "ask.question": "问题: {q}", "ask.verify_ok": "溯源核验 {n}/{n} 全真", "ask.verify_bad": "溯源核验未全过 (引用 {n})", "ask.done_meta": "模型 {m} · 用时 {s} 秒 · {n} 步", "ask.review": "交叉审核 · {m}", "ask.review_note": "由另一模型基于同一份证据复核上述答案。溯源核验仅校验数字与机组号是否越界, 本项校验结论是否有证据支撑。", "ask.review_err": "审核未完成: {e}", "ask.error": "出错: {e}", "ask.offline": "离线版: 模型问答需连接系统。", "ask.en_qa_failed": "英文质检未通过: 已拦截这次模型回答, 请重新提问。", "mdl.local": "本地", "mdl.cloud": "云端", "mdl.zero_outbound": "全程零外发", "mdl.outbound_warn": "内容会外发到模型厂商, 场名与台号已去标识化", "mdl.unknown_warn": "未知档位, 按内容会外发处理", "mdl.qwen3:8b.nm": "轻量 8B · 默认", "mdl.qwen3:8b.t": "约 30 到 60 秒", "mdl.qwen3.8:27b.nm": "标准 27B · 复杂题", "mdl.qwen3.8:27b.t": "约 2 到 4 分钟", "mdl.qwen3:30b-a3b.nm": "快 30B (a3b)", "mdl.qwen3:30b-a3b.t": "约 20 到 60 秒", "mdl.deepseek-r1:14b.nm": "本机 DeepSeek 14B · 审核档", "mdl.deepseek-r1:14b.t": "约 1 到 3 分钟", "mdl.deepseek-r1:32b.nm": "本机 DeepSeek 32B", "mdl.deepseek-r1:32b.t": "约 3 到 8 分钟", "mdl.deepseek-chat.nm": "DeepSeek", "mdl.deepseek-chat.t": "约 3 到 10 秒", "mdl.kimi-fast.nm": "Kimi", "mdl.kimi-fast.t": "约 5 到 20 秒", "mdl.qwen-plus.nm": "通义千问", "mdl.qwen-plus.t": "约 5 到 20 秒", "mdl.deepseek-v4-pro.nm": "云端 DS", "mdl.deepseek-v4-pro.t": "约 2 到 6 分钟", "cat.equip": "设备类", "cat.equip.hint": "设备状态 / 机制 / 判级 — 从机组档案、对象查询与机制检索取证", "cat.equip.q1": "29号机组现在什么状态、为什么、该怎么办?", "cat.equip.q2": "全场哪些机组有报警级或定论级判级?逐台列出并注明审级", "cat.equip.q3": "失效机制库里有哪些机制族?各涉及哪些机组、状态如何?", "cat.sched": "检修排程决策", "cat.sched.hint": "排期沙盘 / 天气窗 / 验收树 — ont_scenario / ont_get 取证", "cat.sched.q1": "29号修泵什么时候做最合适?对比各情景的损失与风险", "cat.sched.q2": "未来几个月哪些月份适合安排CTV检修和吊装作业?依据历史可作业窗", "cat.sched.q3": "29号修好之后按什么判据验收?三分支分别是什么条件?", "cat.loss": "损失分析", "cat.loss.hint": "当前 3 个月损失 / 未来损失 — ont_loss / ont_scenario 取证, 金额带电价区间统计方法", "cat.loss.q1": "近3个月全场损失结构如何?分状态和分机组说, 电量与金额都给", "cat.loss.q2": "29号如果拖到主轴承失效, 未来损失量级是多少?与现在修对比", "cat.loss.q3": "限电常态化对检修机会成本有什么影响?", "cat.risk": "安全风险预测", "cat.risk.hint": "风险敞口 / 劣化趋势 / 盲区 — ont_risk / ont_mechanism_search 取证, 未列 ≠ 正常", "cat.risk.q1": "当前哪些机组风险在上升需要重点盯?联动监视读数如何?", "cat.risk.q2": "监测有哪些盲区?哪些\"没报警\"不等于正常?", "cat.risk.q3": "29号轴承正在承受金属接触, 风险敞口怎么评估?", "ch.quad.few": "时间窗内码数不足, 不作四象限", "ch.quad.q1": "低频长时 · 硬故障", "ch.quad.q1s": "备件与检修窗", "ch.quad.q2": "高频长时 · 重点治理", "ch.quad.q2s": "优先派工", "ch.quad.q3": "低频短时 · 本底", "ch.quad.q3s": "不单独处置", "ch.quad.q4": "高频短时 · 重复触发", "ch.quad.q4s": "调整参数或屏蔽, 非硬件故障", "ch.quad.xlab": "发生条数 (对数)", "ch.quad.ylab": "单次中位时长 (对数)", "ch.quad.tip": "{n} 条 · 单次中位 {med}s · 累计 {h}h · {nt} 台", "ch.rate.tip": "{m}: {v} 条/日 (总 {n} 条, 数据覆盖 {cov}%)", "ch.rate.month": "{m}月", "ch.rate.n": "{n}条", "ch.rate.days": "仅{d}天", "ch.rate.ylab": "条/日", "ch.wf.theo": "理论可发", "ch.wf.act": "实际上网", "ch.wf.tip": "{nm}: 损失 {loss} MWh, 占理论 {pct}%, 时长 {h}h", "loss.curtail_bound": "限电 (命令与实际功率对得上)", "loss.curtail_unbound": "限电命令期 · 功率未被压制", "loss.dispatch": "调度令停机", "loss.stop": "故障与检修停机", "loss.normal": "正常发电时的欠发", "loss.lowwind": "低风待机", "ch.ram.few": "部件数不足, 不作象限", "ch.ram.q1": "常坏且难修", "ch.ram.q1s": "最高优先级", "ch.ram.q2": "罕发难修", "ch.ram.q2s": "备件与作业窗", "ch.ram.q3": "频发易修", "ch.ram.q3s": "查根因, 换件无用", "ch.ram.q4": "少发好修", "ch.ram.q4s": "维持", "ch.ram.xlab": "平均无故障间隔(MTBF,h,对数)→ 越右越可靠", "ch.ram.ylab": "单次停机时长(MDT,h)→ 越上越难修", "ch.ram.equip": "设备类", "ch.ram.ext": "外部类", "ch.ram.area": "圆面积 = 累计停机时长", "ch.ram.median": "虚线 = 本场中位分界", "ch.ram.tip": "{p}: 平均无故障间隔(MTBF){mtbf}h · 单次停机时长(MDT){mdt}h · 停机 {h}h · {n}起 · {u}台", "ch.ram.tip_ext": " · 外部类不计设备可靠性", "ch.attrib.ok": "已归因到部件 {h}h ({p}%)", "ch.attrib.none": "无记录 {h}h ({p}%)", "ch.attrib.uncls": "未归类 {h}h", "ch.pareto.none": "时间窗内无数据", "ch.pareto.cum80": "累计 80%", "ch.pareto.cumtip": "{k} · 累计 {p}%", "ch.pareto.xlab": "报警码 (悬停看全名)", "ch.pareto.key": "关键少数: 前 {k} 项占 80%", "ch.pareto.total": "共 {n} 项, 合计 {tot}{unit}", "ch.pareto.top3": "前三: ", "ch.blame.n1": "交易与调度侧", "ch.blame.d1": "限电与调度令, 不进设备责任", "ch.blame.n2": "设备侧", "ch.blame.d2": "故障与检修停机, 唯一进设备考核", "ch.blame.n3": "运行侧", "ch.blame.d3": "正常发电欠发与低风待机", "ch.blame.title": "损失 {tot} MWh 按责任归属", "unit.h": "h", "unit.n": " 条", "unit.units": "台", "rpt.eyebrow": "汇报定制 · 画布", "rpt.presets": "预设", "rpt.preset.month": "月度运行分析", "rpt.preset.week": "周报", "rpt.preset.topic": "设备专题", "rpt.preset.year": "年度总结", "rpt.preset.blank": "空白画布", "rpt.aud_in": "场站内部 (全量)", "rpt.aud_up": "上报集团 (脱敏)", "rpt.print": "打印 / 存 PDF", "rpt.export_docx": "导出 Word", "rpt.export_xlsx": "导出 Excel", "rpt.up_note": "上报版 — 判据门槛与特征频率已脱敏", "rpt.library": "组件库", "rpt.library_note": "点击加入报告, 再次点击移出 · 画布里已有 {n} 块", "rpt.hint_idle": "悬停查看组件说明 · 已选组件再次点击移出 · 右侧 ↑↓ 调整顺序", "rpt.need_head": "输入报告要求", "rpt.need_hint": "模型依据需求选取组件, 并基于实测数据生成运行总结", "rpt.local_note": "; 数据不出本地", "rpt.cloud_note": "; 内容会外发到模型厂商", "rpt.need_ph": "例如: 上报集团月报, 侧重设备可靠性与重点机组", "rpt.compose": "生成", "rpt.no_models": "本部署未配置任何模型密钥, 编排不可用", "rpt.no_compose": "本部署未启用编排接口, 画布仍可手动拼装", "rpt.need_empty": "先输入报告要求", "rpt.composing": "模型编排中… 挑组件 + 写总结", "rpt.compose_fail": "生成失败: {e}", "rpt.composed": "已按需求编排 {n} 块{narr} · {m} 用时 {s}s", "rpt.narr_plus": " + 一段运行总结", "rpt.ungrounded_n": "{n} 个数字待核", "rpt.grounded": "数字全部接地", "rpt.empty_canvas": "画布是空的 — 先从组件库选内容", "rpt.preview_empty": "文档预览窗 — 从上面的组件库点选内容, 此处显示报告预览", "rpt.paper_title": "运行分析报告", "rpt.paper_sub": "统计期 {win} | {n} 台", "rpt.up_tag": "上报版", "rpt.chapters": "{n} 个章节", "rpt.foot_src": "数据源 SCADA 10min · 报警事件 · 工单台账 · CMS 振动 · 油液化验", "rpt.foot_asof": "出件 {d}", "rpt.foot_trace": "结论可回溯至本体对象", "rpt.foot_deid": "判据门槛与特征频率已脱敏", "rpt.caliber_lbl": "统计方法: ", "rpt.g.gen": "发电", "rpt.g.rel": "可靠性", "rpt.g.flt": "故障", "rpt.g.unit": "机组", "rpt.g.end": "收尾", "rpt.b.kpi.nm": "一级指标", "rpt.b.kpi.d": "六个核心数: 上网电量 / 等效满发 / 可用率 / 损失率 / 停机间隔 / 需行动台", "rpt.b.water.nm": "能量瀑布", "rpt.b.water.d": "理论可发 → 逐段扣损 → 实际上网, 用于定位电量流向", "rpt.b.emon.nm": "逐月发电表", "rpt.b.emon.d": "逐月上网 / 损失 / 损失率 / 等效满发, 含数据覆盖率", "rpt.b.blame.nm": "损失责任分账", "rpt.b.blame.d": "交易调度侧 / 设备侧 / 运行侧 三段占比 — 回答\"谁的责任\"", "rpt.b.ram.nm": "可靠性象限", "rpt.b.ram.d": "平均无故障间隔(MTBF)× 单次停机时长(MDT)部件象限, 分出\"频发易修\"与\"罕发难修\", 处置方向相反", "rpt.b.parts.nm": "部件停机表", "rpt.b.parts.d": "逐部件停机时长 / 事件数 / 平均无故障间隔(MTBF)/ 单次停机时长(MDT)/ 影响机组数", "rpt.b.attrib.nm": "归因覆盖", "rpt.b.attrib.d": "已归因 vs 无记录停机的占比 — 无记录不等于没有故障", "rpt.b.pareto.nm": "故障帕累托", "rpt.b.pareto.d": "按条数降序 + 累计 80% 线, 找关键少数", "rpt.b.quad.nm": "故障四象限", "rpt.b.quad.d": "条数 × 单次时长, 区分重复触发与实际停机 — 按条数排序会误定优先级", "rpt.b.frate.nm": "月度故障率", "rpt.b.frate.d": "按天归一的日均条数, 数据不满月单独标注", "rpt.b.units.nm": "重点机组", "rpt.b.units.d": "需跟踪台的部件 / 证据源 / 六步进度 / 卡在哪一步", "rpt.b.chain.nm": "决策链进度", "rpt.b.chain.d": "证据→机制→判级→排期→动作→验收 六步的瓶颈分布", "rpt.b.loop.nm": "闭环实证", "rpt.b.loop.d": "检出 → 换件 → 复测回落的历史例证, 证明这条链是通的", "rpt.b.plan.nm": "下期建议", "rpt.b.plan.d": "由数据推出的行动项, 不是套话", "rpt.b.caliber.nm": "统计方法与适用边界", "rpt.b.caliber.d": "统计期 / 残月 / 工单时间窗 / 判级性质 — 建议常驻, 没有边界的数字堆不能作汇报依据", "rpt.b.narr.nm": "运行总结", "rpt.b.narr.d": "模型基于实测数据生成的运行总结 — 为图表补充文字结论, 供汇报使用", "rpt.kpi.act": "上网电量", "rpt.kpi.theo": "理论可发 {v}", "rpt.kpi.mom": "环比 {v}%", "rpt.kpi.mom_na": "环比不可比(末月不满)", "rpt.kpi.eflh": "等效满发小时", "rpt.kpi.annual": "年化约 {v} h", "rpt.kpi.avail": "时间可用率", "rpt.kpi.avail_n": "不含调度令与低风待机", "rpt.kpi.loss": "损失率", "rpt.kpi.mtbo": "平均停机间隔", "rpt.kpi.mtbo_n": "停机 {n} 起 · 单次均 {h}h", "rpt.kpi.action": "需行动机组", "rpt.kpi.action_n": "定论 {a} · 预警 {b} · 候选 {c}", "rpt.water.title": "发电完成情况", "rpt.water.note": "理论可发 {theo} = 上网 {act} + 损失 {loss} MWh ({pct}%)", "rpt.water.cap_ok": "量级自检: 理论低于全额定上限 {cap} MWh ✔", "rpt.water.cap_bad": "量级自检: 理论 ≥ 全额定上限 {cap} MWh ✗ 数字不可信", "rpt.water.cal": "SCADA 10min 工况态分解; 损失=同工况理论出力与实测之差。量级自检为防虚高的下限校验, 不是风资源判据", "rpt.emon.title": "逐月发电", "rpt.emon.m": "月", "rpt.emon.act": "上网 MWh", "rpt.emon.loss": "损失 MWh", "rpt.emon.pct": "损失率", "rpt.emon.eflh": "等效满发 h", "rpt.emon.cov": "覆盖", "rpt.emon.partial": " 不满月", "rpt.emon.cal": "覆盖率\u003c50% 的月不参与环比", "rpt.blame.title": "损失责任分账", "rpt.blame.note": "进设备考核的只有\"故障与检修停机\"; 限电属交易与调度侧。", "rpt.blame.note2": "限电不是\"低风时段收到的空令\": 限电·绑定时段平均出力 1.435 MW, 正常发电时段 1.412 MW, 比值 1.02 — 风况相同而出力被压, 说明限电是真压制; 但\"限电命令期·功率未被压制\"那一档平均出力仅 0.724 MW (18% 额定), 确属低风时段收令。", "rpt.blame.cal": "工况态分解 + 限电命令面绑定判别; 平均出力=实发÷台时, 用作风资源代理量", "rpt.ram.title": "可靠性 × 可维修性", "rpt.ram.note": "设备类 {a}h / 外部类 {b}h (电网·风况·运维操作不计设备可靠性)", "rpt.ram.cal": "SCADA ≥1h 停机段, 不含调度令; 海上 MDT 含交通窗口", "rpt.parts.title": "部件停机", "rpt.parts.part": "部件", "rpt.parts.h": "停机h", "rpt.parts.ev": "事件", "rpt.parts.mtbf": "平均无故障间隔(MTBF,h)", "rpt.parts.mdt": "单次停机时长(MDT,h)", "rpt.parts.units": "影响机组", "rpt.parts.ext": " (外部)", "rpt.parts.cal": "归因=段起点±30min 首发码", "rpt.attrib.title": "归因覆盖", "rpt.attrib.note": "\"无记录停机\"不是没有故障, 是没归上因。", "rpt.pareto.title": "故障帕累托", "rpt.pareto.cal": "报警事件表", "rpt.quad.title": "故障模式四象限", "rpt.quad.note": "右下角报警码属重复触发, 不应按条数排定优先级。", "rpt.quad.cal": "纵界=10min(一个SCADA采样格)", "rpt.frate.title": "月度故障率", "rpt.frate.note": "柱高=日均条数, 不是月总数。", "rpt.frate.cal": "不满月按天归一", "rpt.units.title": "重点机组与处置", "rpt.units.head": "需跟踪 {n} 台 / {tot} | 瓶颈 ", "rpt.units.stuck": "{n} 台卡在「{s}」", "rpt.units.state": "状态", "rpt.units.ev": "部件与证据", "rpt.units.prog": "进度", "rpt.units.at": "卡在", "rpt.units.rising": "在升 {r}×", "rpt.units.clear": "全通", "rpt.units.cal": "四源融合判级", "rpt.chain.title": "决策链进度", "rpt.chain.note": "六步: 证据 → 机制 → 判级 → 排期 → 动作 → 验收。当前 {s}。", "rpt.chain.cal": "「动作」一列不可判 — {b}", "rpt.loop.title": "端到端闭环实证", "rpt.loop.comp": "部件", "rpt.loop.date": "换件日期", "rpt.loop.pre": "换前", "rpt.loop.post": "换后", "rpt.loop.ratio": "比值", "rpt.loop.note": "这几台正是算法当初的检出台 ⇒ 故障前能看见、换件后能看见恢复。", "rpt.loop.cal": "振动标量前后对比", "rpt.plan.title": "下期计划与建议", "rpt.plan.1": "补机制定性 — {n} 台停在\"判了级但根因未定\", 是当前最大单一瓶颈", "rpt.plan.2": "回收 25~26 工单 — 台账止 2024-11, 本期检修执行与闭环无法自动判定", "rpt.plan.3": "油液采样节奏 — 四源里油液长期 stale, 只能作回测锚", "rpt.plan.4": "限电侧 — 损失六成在交易与调度侧, 设备侧改善空间有限", "rpt.plan.cal": "建议级 — 排期与动作须经审批", "rpt.narr.title": "运行总结", "rpt.narr.empty": "还没有生成 — 在上面的交互窗输入报告要求。", "rpt.narr.ungr": "⚠ 以下数值由模型自行推算, 不在原始数据摘要中, 引用前须核对: {v}", "rpt.narr.cal": "模型 {m} 生成 · 数字取自系统实测摘要 · 数据回抓校验{g}", "rpt.narr.g_ok": "全通过", "rpt.narr.g_n": "报 {n} 处待核", "rpt.cal.title": "统计方法与数据边界", "rpt.cal.period": "统计期", "rpt.cal.partial": " — {m} 数据不满月(覆盖 {c}%), 月度已按天归一", "rpt.cal.denom": "可用率与损失率分母不同", "rpt.cal.denom_d": "可用率分母剔除低风待机与调度令(问\"该发时能不能发\"), 损失率分母含全部时段(问\"理论能发多少\"); 同一低风时段在可用率里不计时间、在损失率里计入基数 — 这是两个指标的设计差异不是错, 但两者不可直接相乘或互推。低风待机对损失的贡献仅 118 MWh(0.5%)", "rpt.cal.stop": "停机", "rpt.cal.stop_d": "可靠性统计方法 ≥1h 段, 电量账为逐条工况态 — 两个数都对, 不可相减", "rpt.cal.wo": "工单", "rpt.cal.wo_d": "台账 2020~2024-11, 本报告不对本期检修执行下结论", "rpt.cal.rating": "判级", "rpt.cal.rating_d": "由振动/温度/润滑/油液四源融合得出; 行动等级由综合报告出具", "rpt.cal.deid": "脱敏", "rpt.cal.deid_d": "上报版隐去判据门槛与特征频率", "win.banner": "时间窗 {win} · 本页全部数字随上方时间窗切换", "win.partial": "时间窗内 {m} 数据不满月 (覆盖 {c}%), 月度图已按天归一", "flt.eyebrow": "故障统计与可靠性", "flt.alarm_face": "报警面", "flt.alarm_meta": "{n} 条 · {c} 个码 · 时间窗 {win}", "flt.rate": "月度故障率", "flt.rate_note": "柱高 = 日均条数, 不是月总数", "flt.rate_partial": "本时间窗 {m} 数据不满月 (灰虚线柱), 直接比月总数会读反。", "flt.rate_full": "各月覆盖完整。", "flt.rate_example": "实测: {m} 总数最低但日均 {v} 条/日 = 全时间窗前列。", "flt.pareto_n": "帕累托 · 按发生条数", "flt.pareto_n_note": "条数 = 发生频次, 不反映影响程度 — 高频短时报警码 (信号抖动或重复触发) 排序靠前", "flt.pareto_d": "帕累托 · 按持续时长", "flt.pareto_d_note": "时长 = 闩锁统计方法 (复位 − 激活), 只作排序参考不作停机时长", "flt.quad": "故障模式四象限 · 条数 × 单次时长", "flt.quad_note": "时间窗内共 {n} 个码, 图上为条数前 {m} 个。", "flt.quad_partial": "窗含不满月 {m} (覆盖 {c}%), 条数为原始累计未按天归一。", "flt.quad_note2": "点面积表示影响机组数, 大点为批次共性、小点为单台; 竖分界取条数中位, 横分界取 10min (一个 SCADA 采样格)。右下角的码不要按条数排优先级 — 属重复触发, 非硬件故障。上面两张帕累托对照看即此图的两条投影。", "flt.per_t": "台级分布 · 报警条数 top{n}", "flt.per_t_share": "占全场", "flt.per_t_note": "全场合计 {n} 条 · 单台占比高须先核查是否为单一报警码重复触发", "flt.seasonal": "故障季节性 · top 报警族 × 月", "flt.seasonal_unit": "条/月", "flt.seasonal_x": "月", "flt.seasonal_note": "全程 19 个月 (跨 2 冬 1 夏); 未做 χ² 显著性检验前为【参考级】, 只看形态不作季节归因", "flt.stop_face": "停机面", "flt.stop_meta": "{h}h · {n} 起 · 统计方法 ≥1h 段, 不含调度令", "flt.ram": "可靠性 × 可维修性 · 部件象限", "flt.ram_note": "按停机时长排序的表看不出这层结构: 同样 500h 的停机, \"频发易修\"要查根因、\"罕发难修\"要备件, 处置相反。设备类停机 {a}h / 外部类 {b}h (外部 = 电网·风况·运维操作, 不计设备可靠性)。海上机组 MDT 普遍偏高是交通窗口决定的, 不等于维修本身慢 — 跨场比 MDT 须同为海上。", "flt.attrib_note": "归因覆盖: \"无记录停机\" {h}h 是全场最大单项, 因不给 MTBF 而在上图中不出现 — 它不是\"没有故障\", 是\"没归上因\"。统计方法为段起点 ±30min 首发码。缺口成因待定: 此前写的\"98% 的段在前 72h 内有报警 ⇒ 长停远窗\"不成立 — 前 72h 有报警不等于该报警与本段停机有因果 (双模审 2026-08-28 逮)。要定成因须改判\"段起止是否落在某条报警的持续区间内\", 未做之前只报缺口不报原因。", "flt.stop_pareto": "停机归因帕累托", "flt.stop_pareto_note": "按停机时长; 事件次数见悬停", "flt.stop_units": "台级停机 top{n}", "flt.stop_h": "停机时长", "flt.stop_n": "次数", "flt.stop_units_note": "停机时长高 ≠ 设备差 — 须先看归因里外部类占比 (调度令已剔除)", "flt.block_err": "本块未算出 ({k}): {e}", "ch.ml.legend": "— {s}", "en.kpi_theo": "理论可发 (实际+损失)", "en.kpi_act": "实际上网 · 等效满发 {h} h", "en.kpi_loss": "损失率 · {v} MWh", "en.cap_pass": "通过", "en.cap_fail": "不通过", "en.cap_note": "能量守恒核 · 理论 {op} 全额定上限 {cap}", "en.balance": "电量平衡", "en.win": "窗 {win}", "en.blame": "损失责任分账", "en.blame_note": "最大一笔是限电不是故障。进设备考核的只有\"故障与检修停机\" {n} MWh; 限电属交易与调度侧, 2026 起交易策略主导, 少发未必是弃风。", "en.states": "工况态明细", "en.state": "工况态", "en.share": "时长占比", "en.loss_mwh": "损失 MWh", "en.states_note": "控制参数一致性与曲线欠发筛查在固定干净窗判, 已迁入「发电性能」页 — 本页只出当期电量账。", "cmp.by_system": "按系统查看", "cmp.cards_note": "点系统卡 → 该系统全场机组与依据; 九系统 = 交付九分册一一对应; 叶片与叶根 / 塔架与基础无 SCADA 轴 (分册蓝本取 CMS / 巡检)", "cmp.alarm_n": "报警 {a}", "cmp.unk_n": " · 不可判 {u}", "cmp.fleet_n": " · 全场 {n} 台", "cmp.detail_head": "{sys} · 全场 {n} 台", "cmp.counts": "报警 {a} · 不可判 {u} · 良好 {g} · 优秀 {e}", "cmp.alarm_units": "报警机组", "cmp.unk_units": "不可判 (监测/数据不足, ≠ 正常)", "cmp.good_units": "良好 {n} 台 (轻微偏离, 周期观察 — 点开看逐台依据)", "cmp.n_units": "({n} 台)", "cmp.basis": "依据", "cmp.detail": "详情", "cmp.problem_page": "该问题页 →", "cmp.none": "本系统全场无非常态。", "cmp.rel_eyebrow": "部件可靠性 · 时间窗 {win}", "cmp.rel_head": "部件停机贡献与可靠性", "cmp.rel_meta": "总停机 {t}h = 设备类 {a} + 外部类 {b}", "cmp.rel_note": "统计方法 ≥1h 停机段, 不含调度令 — 与\"电量算账\"里的停机时长{h} 不同, 差额是短于 1h 的停机段 (本页按段做可靠性指标须成段, 能量账按逐条工况态算电量): 两个数都对, 不可互相相减。", "cmp.rel_part": "部件", "cmp.rel_h": "停机时长", "cmp.rel_ev": "事件", "cmp.rel_mtbo": "平均停机间隔(MTBO,h)", "cmp.rel_mdt": "单次停机时长(MDT,h)", "cmp.rel_units": "影响机组", "cmp.rel_fm": "典型失效模式 (按时长)", "cmp.external": "外部", "cmp.fusion_moved": "振动线结论与四源融合已移至独立板块 →", "cmp.fusion_n": "({n} 台次收录)", "cmp.cross_eyebrow": "多系统交叉机组", "cmp.watch_head": "关注机组 (≥2 系统交叉, {n} 台; 点机组号 → 该台独立页)", "cmp.n_sys": "系统数", "cmp.hit_sys": "命中系统", "mt.eyebrow": "系统维护 · 数据输入与更新端口", "mt.surveying": "盘点中…", "mt.fail": "盘点失败: {e}", "mt.head": "数据维护面 · {n} 个源", "mt.note": "每个源给出: 当前覆盖 / 末次更新 / 约定位置 / 摄入命令 / 更新频率 / 责任方。要补数据就把新文件放到\"约定位置\"再跑\"摄入命令\"。", "mt.layer_data": "数据层", "mt.layer_mech": "机理层", "mt.layer_data_note": "随运营滚动 — 新数据到位后跑摄入, 本体与判级自动刷新", "mt.layer_mech_note": "相对稳定 — 换厂商资料或现场反馈修正时才动; 它是判据的地基, 变更须过审", "mt.src": "源", "mt.cov": "当前覆盖", "mt.last": "末次更新", "mt.freq": "频率", "mt.resp": "责任", "mt.how": "补数据怎么做", "mt.put": "放到:", "mt.run": "跑:", "mt.rows_n": " ({n} 条)", "gen.eyebrow": "特性曲线与控制参数", "gen.fixed_win": "随上方时间窗 · 按时间窗重算", "gen.win_range": "时间窗 {span}", "gen.win_why": "七镜头与月度时序都按所选时间窗重算(含自定义起止日期)。提示:2025-07 ~ 2025-12 是\"限电前干净判别窗\"——2026 起交易策略主导,曲线含策略性少发,作设备判据时须注意;选到含 2026 的窗时该影响存在但不再遮掩。", "gen.loading": "正在计算七个镜头 (剥限电 · 逐档中位 · 同型机群残差)…", "gen.recomputing": "正在按所选时间窗重算七镜头…", "gen.recomputing_short": "正在按窗重算…", "gen.ctrl": "控制参数一致性", "gen.ctrl_win": "窗 {win}", "gen.cap": "封顶参数落位", "gen.cap_note": "台数落在几个离散位置上, 不是连成一片 — 这是配置/版本差异的形状; 磨损或工况引起的会是连续散布。两轴各自成组且分组不同台 ⇒ 参数级差异, 成因须配置台账。", "gen.beta": "桨距调度同档差", "gen.beta_note": "同一功率档下的桨角与全场中位之差。{units} 偏离达中位的 {k} 倍 → {v}", "gen.ruler": "尺子巡检 · 极差 ÷ σ", "gen.ruler_note": "判据自身的分辨力。38 台正态抽样的极差期望约 3.7σ, 明显超出即分布非单峰 — 与左图的分组互为印证; 落在期望附近的轴 (额定桨角) 则是连续分布。", "gen.cands": "曲线欠发筛查", "gen.cands_meta": "{note} · σ = {s}%", "gen.no_cands": "无候选", "gen.figs": "七个镜头 · 逐档中位与同型机群残差", "ch.cap.p": "满发封顶", "ch.cap.w": "转速封顶", "ch.cap.median": "中位 {v}", "ch.cap.legend": "圆内数字 = 台数, 圆面积 ∝ 台数", "ch.beta.median": "全场中位 {v}°", "ch.ruler.norm": "正态期望 3.7σ", "ch.ruler.row": "{r}σ σ = {s}", "fus.eyebrow": "振动与 SCADA 证据视图 · 展示振动结论、SCADA 指标、报警与工单线索", "fus.unavailable": "融合面数据不可用: {e}", "fus.sub.matrix": "融合矩阵", "fus.sub.loop": "端到端闭环", "fus.sub.units": "逐台情况", "fus.sub.events": "报警工单", "fus.sub.cms": "振动分析", "fus.rules_head": "两条判读纪律 (随板块常显)", "fus.rule1": "① 同一失效机制的多个观测面 ≠ 多源印证 — 融合禁按源数加权 (29# 柱塞/温度/振动稳态/振动瞬态 = 四个观测面一条机制链)。", "fus.rule2": "② \"没报警\" ≠ \"正常\" — 在已知盲区上, 缺报警只说明监测面无效, 不说明设备无恙。", "fus.matrix_head": "全场融合矩阵 (38 台 × 6 个证据面)", "fus.matrix_note1": "各列取数窗口不同 (见列头) — 不同窗的两格同时发红不等于\"同时发生\", 不可当共时印证。", "fus.matrix_note2": "每格右侧曲线 = 逐月记忆轨迹 ({n} 个月, {m0} 起; 实心点 = 现窗) — 现窗快照会丢形态, 轨迹才看得见\"塌→回升→翻转\"类机制签名。", "fus.legend_state": "格子颜色 = 设备状态 (业主统计方法, 与 windcms 报告同一套):", "fus.legend_tier": "徽章 = 证据状态 (可信度, windcms 九枚举):", "fus.legend_note": "两轴不可互相替代: \"候选\"说的是可信度不是设备好坏; \"良好\"说的是设备状态不是证据强度。设备状态只取自振动线的判级栏这一个来源。", "fus.col.vib": "振动", "fus.col.temp": "温度", "fus.col.lub": "润滑柱塞", "fus.col.oil": "油样", "fus.col.alarm": "报警", "fus.col.wo": "工单", "fus.col.fixed": " · 不随本时间窗", "fus.col.display_only": " · 只展示", "fus.short.nodata": "数据不足", "fus.short.uneval": "未评估", "fus.short.nosample": "未取样", "fus.short.stale": "样龄过期", "fus.short.normal": "正常", "fus.short.unlisted": "未收录", "fus.tip_state": " — 设备状态 {s}", "fus.tip_tier": " / 证据状态 {e}", "fus.alarm_n": "{n} 条", "fus.alarm_rank": " · 第 {r}/{t}", "fus.burst": "⚠ {p}% 是码 {c} 抖动", "fus.burst_tip": "⚠ 单码 {c} 占 {p}%, 中位 {m} 秒/次 — 条数被抖动灌满", "fus.no_alarm": "时间窗内无报警", "fus.fam_tip": "{n} 条 · 全场最多 {m} 条", "fus.wo_n": "{n} 单", "fus.wo_none": "时间窗内无传动链工单", "fus.matrix_foot": "润滑柱塞各台设定本就不同 (全场单次循环 22~961 秒), 所以此列只展示不判级, 只有已定案的机制链才标色; 油液多数台末次取样距今超 12 个月, 此列看的是\"样够不够新\", 不代表当前状态。", "fus.loop_none": "暂无端到端闭环证据:现场提交的振动数据包里没有「报警→检修→复测」的成对记录", "fus.loop_head": "维修后验证案例 (检出 → 换件 → 恢复)", "fus.loop_note": "有换件记录且同一统计方法指标回落, 可作为检测链已闭合的案例; 指标仍在上升但缺少可匹配工单的机组, 需现场或台账确认后再关闭。", "fus.loop_closed": "已闭环 · 换件后恢复 ({n} 例)", "fus.loop_item": "事项", "fus.loop_date": "换件日", "fus.loop_ba": "换前 → 换后 (同标尺)", "fus.loop_ratio": "恢复比", "fus.loop_verdict": "判", "fus.loop_neg": "负面对照: 工单后持平 {n} 例 (换件 ≠ 必然回落, 说明回落非巧合)。", "fus.loop_rising": "未闭环 · 指标上升待现场/工单确认 ({n} 台)", "fus.loop_el": "早期 → 近期 (同标尺)", "fus.loop_x": "倍比", "fus.loop_state": "状态", "fus.loop_bound": "数据边界: {b}", "fus.units_head": "逐台情况 (全场 {u} 台 / {n} 台次{f}; 深入谱图/波形 → \"振动分析\")", "fus.units_filter": " · 筛选: {l}", "fus.clear": "× 清除", "fus.lv.bad": "定论/报警级", "fus.lv.warn": "预警级", "fus.lv.note": "候选/监视/观察级", "fus.lv.ok": "销案/无新证", "fus.unlisted_head": "未列机组 {n} 台 (振动线未收录 — 未列 ≠ 正常)", "fus.unlisted_link": "未列 {n} 台", "fus.temp_now": "温度轴现状", "fus.lub_now": "润滑柱塞现状", "fus.eval_head": "评价体系 (规范化定义 — 两轴并存, 不可互相替代)", "fus.eval_state": "设备状态 (业主统计方法)", "fus.eval_tier": "证据状态 (可信度)", "fus.eval_meaning": "含义", "fus.m.bad": "异常严重, 建议尽快停机检查", "fus.m.bad_e": "证据: 已定案", "fus.m.warn": "存在需现场核实或处置的异常", "fus.m.warn_e": "证据: 基本确认 / 新出现待核实", "fus.m.ok": "轻微偏离, 记录观察", "fus.m.ok_e": "证据: 待现场核实 / 已记为基线下窗观察", "fus.m.ex": "未见异常, 保持常规监视", "fus.m.ex_e": "证据: 仅记录 / 已取消", "fus.m.unk": "数据不足或监测中断 — 不等于正常", "fus.m.unk_e": "证据: 数据不足", "fus.eval_note": "设备状态答\"这台设备现在怎么样\" (危险 > 报警 > 良好 > 优秀 > 不可判); 证据状态答\"这个判断有多可信\" (定论 → INSUFFICIENT)。设备状态一律取自振动线的判级栏这一个来源。本页判级为转录, 不产新判级; 标注为参照同型机组/频谱信号级 (未定位到具体零件)/测量链/未对应到具体部件的行不作部件级结论消费; claim_window = 该结论的证据时间窗, 窗外状态该结论不背书。", "fus.col.part": "部件", "fus.col.state_concl": "设备状态 / 振动结论", "fus.col.claim": "证据时间窗", "fus.col.claim_axis": "26-01 → 09", "fus.col.signal": "振动信号", "fus.col.tempaxis": "温度轴", "fus.col.tempaxis_note": "±6 K", "fus.win_none": "未标窗", "fus.win_n": "{n} 个窗段", "fus.oil_fresh": "近共时", "fus.oil_stale": "stale > 12 个月", "fus.oil_none": "无样", "fus.ev_win": "时间窗 {win} · 顶部选择器可换", "fus.ev_head": "{c} — 报警 {an} 条 / {au} 台 · 工单 {wn} 条 / {wu} 台 (时间窗内)", "fus.ev_code": "码", "fus.ev_text": "报警内容", "fus.ev_units": "台数", "fus.ev_dur": "累计时长 h", "fus.ev_top": "台分布 top: ", "fus.ev_recent": "最近 {n} 条明细", "fus.ev_time": "时间", "fus.ev_min": "时长 min", "fus.ev_no_alarm": "时间窗内无该部件报警", "fus.ev_wo_name": "故障名称", "fus.ev_wo_act": "处理", "fus.ev_wo_cause": "原因", "fus.ev_no_wo": "时间窗内无该部件工单 (工单数据止 2024-11, 2026 类窗必空 = 数据边界)", "fus.cms_loading": "载入振动评估结果…", "fus.cms_head": "振动状态评估 (windcms 报告 {d} 结果转录)", "fus.cms_note": "状态管理判级, 不代表确诊数量; 判据/谱线/计算过程不在本页 — 深潜走底部独立系统。", "fus.cms_window": "数据窗 {r} —— 本时间窗**实算**覆盖区间 (窗名只是起始日代号); 页面上的图谱与\"末窗\"状态取自时间窗内最新记录, 故多显示末期日期。", "fus.cms_action": "需行动机组 (报警 / 不可判)", "fus.cms_focus": "状态焦点", "fus.cms_focus_note": "(传动链: 主承前 · 主承后 · 齿轮箱 · 发电机两端)", "fus.cms_evidence": "证据强度", "fus.cms_act": "行动", "fus.cms_grades": "38 台部件等级一览", "fus.cms_chain": "部件状态链", "fus.cms_chain_note": "(主承前 · 主承后 · 齿轮箱 · 发电机; 悬停看逐件等级)", "fus.cms_overall": "综合", "fus.cms_basis": "统计方法说明", "fus.cms_basis_note": "本表 = windcms 状态管理判级 (含标量趋势与数据可判性); 融合矩阵 / 逐台结论 = 振动线确诊链转录 (候选以上)。两统计方法差异台: 仅本表列 {a} (多为标量发作 / 数据不可判, 未到确诊链); 仅确诊链列 {b} (预警级在察, 状态管理未升)。", "fus.none_word": "无", "fus.cms_link": "谱图 · 波形 · 判据 · 工作台等分析功能在独立 windcms 系统 →", "fus.cms_open": "打开 windcms ↗", "fus.cms_static": "(未启动时用静态版)", "chain.mbf": "主轴承前", "chain.mba": "主轴承后", "chain.ps": "行星", "chain.ims": "中间", "chain.hss": "高速", "chain.gde": "发电机DE", "chain.gnde": "发电机NDE", "chain.gb": "齿轮箱", "chain.gen": "发电机", "attr.无记录停机": "无记录停机", "attr.未归类": "未归类", "attr.运维操作": "运维操作", "attr.变桨与液压": "变桨与液压", "attr.塔架/结构": "塔架/结构", "attr.轮毂/叶片": "轮毂/叶片", "attr.电网/供电(外部)": "电网/供电(外部)", "attr.风况(外部)": "风况(外部)", "vp.(振动线未收录)": "(振动线未收录)", "vp.齿轮箱高速轴": "齿轮箱高速轴", "vp.齿轮箱中间轴": "齿轮箱中间轴", "vp.齿轮箱二级行星内齿圈": "齿轮箱二级行星内齿圈", "vp.齿轮箱(未映射)": "齿轮箱(未对应到具体部件)", "vp.齿轮箱(谱线级, 未映射到部件)": "齿轮箱(频谱信号级 (未定位到具体零件), 未对应到具体部件)", "vp.齿轮箱(测量链问题, 非部件)": "齿轮箱(测量链问题, 非部件)", "vp.发电机DE": "发电机DE", "vp.发电机NDE": "发电机NDE", "vp.发电机DE+NDE(两端)": "发电机DE+NDE(两端)", "vp.发电机(未映射)": "发电机(未对应到具体部件)", "vp.发电机(引用同族, 见族主台)": "发电机(无本台独立证据, 参照同型机组)", "vp.主轴承(后)+集中润滑泵": "主轴承(后)+集中润滑泵", "pop.unit": "{u} · 全部问题", "pop.problem": "{u} · {sys}", "pop.close": "关闭", "pop.none": "本时间窗无记录", "pop.loading": "载入中…", "pop.orders": "工单 {n} 单", "pop.orders_none": "时间窗内无工单", "pop.o_date": "日期", "pop.o_fault": "故障", "pop.o_check": "排查", "pop.o_cause": "原因", "pop.o_act": "处置", "pop.o_kind": "类别", "pop.acts": "动作统计", "pop.orders_one": "工单 1 单", "ov.evidence": "查看完整依据", "ov.basis_fusion": "本台依据来自融合面(九系统判级无「报警」行):部件 {part} · 链路走到第 {done}/{n} 步 · 卡在「{stuck}」 · 证据源 {srcs}", "ov.all_steps_done": "已走完(待验收)", "ov.no_srcs": "无(该台无外部证据源)", "sys.other": "其他", "win.through": "至{date}", "fig.vibbar_note": "条为本台与全场中位之比; 色按 z (稳健离群度): 绿 \u003c2 · 橙 2~3 · 红 ≥3", "fig.point": "测点", "fig.x_median": "×中位", "fig.this_unit": "本台", "fig.fleet_median": "全场中位", "fig.quartile": "四分位", "fig.trend": "趋势", "tier.detail.优": "优", "tier.detail.良": "良", "tier.detail.报": "报", "tier.detail.预警": "预警", "tier.detail.监视": "监视", "tier.detail.观察": "观察", "tier.detail.危险": "危险", "tier.detail.候选": "候选", "tier.detail.优秀": "优秀", "tier.detail.正常": "正常", "tier.detail.不可判": "不可判", "tier.detail.良好": "良好", "tier.detail.报警": "报警", "tier.detail.定论": "定论", "tier.detail.蓄能失效候选(定向查)": "蓄能失效候选(定向查)", "tier.detail.预警·处置规划": "预警·处置规划", "tier.detail.预警·处置升急": "预警·处置升急", "tier.detail.仲裁输入": "仲裁输入", "tier.detail.弱同向新证": "弱同向新证", "tier.detail.候选·跟踪": "候选·跟踪", "tier.detail.候选·监视(复发)": "候选·监视(复发)", "tier.detail.复发候选(两轴合并)": "复发候选(两轴合并)", "tier.detail.销案(定论)": "销案(定论)", "tier.detail.本体无新证": "本体无新证", "tier.detail.温度单轴保留": "温度单轴保留", "tier.detail.监视(不开箱)": "监视(不开箱)", "tier.detail.观察(发电机侧)": "观察(发电机侧)", "tier.detail.传感器项": "传感器项", "tier.detail.正样本": "正样本", "tier.detail.可降": "可降", "tier.detail.降观察": "降观察", "tier.detail.记基线观察": "记基线观察", "__display_zh": [["引用同族, 见族主台", "无本台独立证据, 参照同型机组"], ["引用同族,见族主台", "无本台独立证据, 参照同型机组"], ["未映射到部件", "未对应到具体部件"], ["口径与边界", "统计方法与适用边界"], ["引用同族", "参照同型机组"], ["见族主台", "参照同型机组结论"], ["结论受闸", "结论须过校验"], ["口径不同", "统计方法不同"], ["定谳链", "确诊链"], ["接地闸", "数据回抓校验"], ["写入闸", "写入校验"], ["下限闸", "下限校验"], ["六枚举", "结论等级"], ["同口径", "同一统计方法"], ["按口径", "按统计方法"], ["谱线级", "频谱信号级 (未定位到具体零件)"], ["未映射", "未对应到具体部件"], ["语法闸", "语法校验"], ["渲染闸", "渲染校验"], ["体积闸", "体积检查"], ["未落盘", "没有记录"], ["已落盘", "已保存"], ["定谳", "确诊"], ["校闸", "成文校验"], ["硬闸", "硬性校验"], ["口径", "统计方法"], ["归宿", "处置措施"], ["落盘", "已保存"]]};</script>
- <script>/* 观澜 图元 —— 从经典页 CHART_JS 搬出的 7 个 SVG 图元, 文案全部走语言包。
- * 用法: const CH = window.__charts({ t, fmt, esc, dv, EN }); (app.js 里调用, 同一份 ctx)
- * 输入形状与经典 /api/fleet 一致 (faults.quad / faults.monthly / control.energy / rel.表 / faults.pareto_n)。
- * 设计: 海蓝 = 主, 红 = 需注意, 灰 = 外部/本底; 无动画。
- */
- window.__charts = function (ctx) {
- 'use strict';
- const { t, fmt, esc, dv, EN } = ctx;
- // ★SVG 里不能用 dv(): 它返回 HTML <span>, SVG 不认这个元素, 原文标注会静默失效 (英文点检实逮)。
- // 图元里的数据字符串走**纯文本**通道, 由 SVG 元素自身带 class="verbatim" 承担标注与计数。
- const CJK = /[㐀-鿿]/;
- const isV = (x) => EN && CJK.test(String(x == null ? '' : x));
- const plain = (x) => { x = ctx.displayText ? ctx.displayText(x) : (x == null ? '' : String(x)); if (isV(x) && ctx.verb) ctx.verb(); return x; };
- const ttl = (x) => { const label = plain(x); return `<title${isV(label) ? ' class="verbatim"' : ''}>${esc(label)}</title>`; };
- const svg = (W, H, body) => `<svg class="dt" viewBox="0 0 ${W} ${H}" style="width:${W}px;max-width:100%;height:auto" role="img">${body}</svg>`;
- const txt = (x, y, s, o = {}) => `<text x="${(+x).toFixed(1)}" y="${(+y).toFixed(1)}" font-size="${o.fs || 9}" fill="${o.fill || 'var(--muted)'}"${o.anchor ? ` text-anchor="${o.anchor}"` : ''}${o.op ? ` opacity="${o.op}"` : ''}${o.w ? ` font-weight="${o.w}"` : ''}${o.tr ? ` transform="${o.tr}"` : ''}${o.cls ? ` class="${o.cls}"` : ''}>${s}</text>`;
- // 数据字符串进 SVG 文本: 转义 + (英文模式下含中文则) 标注
- const dtxt = (x, y, raw, o = {}) => { const p = plain(raw); return txt(x, y, esc(p), isV(p) ? Object.assign({}, o, { cls: 'verbatim' }) : o); };
- const LOSS = { '限电·绑定': 'loss.curtail_bound', '限电命令·未绑定': 'loss.curtail_unbound', '停机(调度令)': 'loss.dispatch', '停机': 'loss.stop', '正常发电': 'loss.normal', '低风待机': 'loss.lowwind' };
- const lossName = (k) => LOSS[k] ? t(LOSS[k]) : plain(k);
- // 故障四象限: 条数 × 单次中位时长 (对数); 分界 = 条数中位 × 600s (一个 SCADA 采样格, 物理刻度)
- function quadChart(items) {
- const it = (items || []).filter(x => x && x.n > 0 && x.med > 0);
- if (it.length < 3) return `<p class="small muted">${t('ch.quad.few')}</p>`;
- const W = 720, H = 330, ML = 58, MR = 16, MB = 44, MT = 16;
- const lx = x => Math.log10(x), xs = it.map(d => lx(d.n)), ys = it.map(d => lx(d.med));
- const x0 = Math.min(...xs) - .15, x1 = Math.max(...xs) + .15, y0 = Math.min(...ys) - .15, y1 = Math.max(...ys) + .15;
- const PX = v => ML + (lx(v) - x0) / (x1 - x0) * (W - ML - MR), PY = v => H - MB - (lx(v) - y0) / (y1 - y0) * (H - MT - MB);
- const srt = it.map(d => d.n).sort((a, b) => a - b), nmid = srt[Math.floor(srt.length / 2)];
- const cx = PX(nmid), cy = PY(600), mt = Math.max(...it.map(d => d.nt));
- const pts = it.map(d => { const r = 3 + Math.sqrt(d.nt / mt) * 7, hot = d.n >= nmid && d.med >= 600, c = hot ? 'var(--critical)' : 'var(--accent)';
- return `<circle cx="${PX(d.n).toFixed(1)}" cy="${PY(d.med).toFixed(1)}" r="${r.toFixed(1)}" fill="${c}" fill-opacity=".5" stroke="${c}" stroke-width="1">${ttl(`${d.code} ${plain(d.k)}\n` + t('ch.quad.tip', { n: d.n, med: d.med, h: d.h, nt: d.nt }))}</circle>`; }).join('');
- const lab = it.filter(d => d.n >= nmid || d.med >= 600).slice(0, 8).map(d => txt(PX(d.n) + 9, PY(d.med) + 3, esc(d.code))).join('');
- const qn = (x, y, a, b) => txt(x, y, a, { fs: 10, op: .85 }) + txt(x, y + 12, b, { fs: 8.5, op: .6 });
- const tick = v => v >= 3600 ? (v / 3600) + 'h' : (v >= 60 ? (v / 60) + 'min' : v + 's');
- const yt = [10, 60, 600, 3600, 36000].filter(v => lx(v) >= y0 && lx(v) <= y1).map(v => `<line x1="${ML}" y1="${PY(v).toFixed(1)}" x2="${W - MR}" y2="${PY(v).toFixed(1)}" stroke="var(--line)" stroke-opacity=".4"/>` + txt(ML - 6, PY(v) + 3, tick(v), { anchor: 'end' })).join('');
- const xt = [1, 10, 100, 1000, 10000].filter(v => lx(v) >= x0 && lx(v) <= x1).map(v => txt(PX(v), H - MB + 14, v, { anchor: 'middle' })).join('');
- const mid = MT + (H - MB - MT) / 2;
- return svg(W, H, `${yt}${xt}
- <line x1="${cx.toFixed(1)}" y1="${MT}" x2="${cx.toFixed(1)}" y2="${H - MB}" stroke="var(--muted)" stroke-dasharray="3 3" stroke-opacity=".5"/>
- <line x1="${ML}" y1="${cy.toFixed(1)}" x2="${W - MR}" y2="${cy.toFixed(1)}" stroke="var(--muted)" stroke-dasharray="3 3" stroke-opacity=".5"/>
- ${qn(ML + 6, MT + 12, t('ch.quad.q1'), t('ch.quad.q1s'))}${qn(W - MR - 130, MT + 12, t('ch.quad.q2'), t('ch.quad.q2s'))}
- ${qn(ML + 6, H - MB - 18, t('ch.quad.q3'), t('ch.quad.q3s'))}${qn(W - MR - 130, H - MB - 18, t('ch.quad.q4'), t('ch.quad.q4s'))}
- ${pts}${lab}
- ${txt(ML + (W - MR - ML) / 2, H - 6, t('ch.quad.xlab'), { fs: 9.5, anchor: 'middle' })}
- ${txt(14, mid, t('ch.quad.ylab'), { fs: 9.5, anchor: 'middle', tr: `rotate(-90 14 ${mid.toFixed(1)})` })}`);
- }
- // 月度故障率: 柱 = 日均 (可比); 残月灰虚线柱; 标总条数 (原始事实)
- function rateChart(mo) {
- const ms = mo.months || [], vs = mo.vals || [], cov = mo.cov || ms.map(() => 1), pd = mo.per_day || [];
- const W = 720, H = 250, ML = 48, MR = 14, MB = 52, MT = 18, n = ms.length || 1;
- const mx = Math.max(...pd.filter(x => x != null), 1), bw = (W - ML - MR) / n;
- let bars = '', lab = '';
- ms.forEach((m, i) => { const v = pd[i], part = cov[i] < .5; if (v == null) return;
- const hh = v / mx * (H - MT - MB), bx = ML + i * bw + bw * .16, by = H - MB - hh, cxm = bx + bw * .34;
- bars += `<rect x="${bx.toFixed(1)}" y="${by.toFixed(1)}" width="${(bw * .68).toFixed(1)}" height="${hh.toFixed(1)}" rx="2" fill="${part ? 'var(--muted)' : 'var(--accent)'}" fill-opacity="${part ? .32 : .8}" stroke="${part ? 'var(--warning)' : 'none'}" stroke-dasharray="${part ? '3 2' : ''}"><title>${t('ch.rate.tip', { m, v, n: vs[i], cov: (cov[i] * 100).toFixed(0) })}</title></rect>`;
- lab += txt(cxm, by - 4, v, { anchor: 'middle' }) + txt(cxm, H - MB + 13, t('ch.rate.month', { m: m.slice(5) }), { anchor: 'middle' })
- + txt(cxm, H - MB + 25, t('ch.rate.n', { n: vs[i] }), { fs: 8, op: .65, anchor: 'middle' })
- + (part ? txt(cxm, H - MB + 37, t('ch.rate.days', { d: Math.round(cov[i] * 30) }), { fs: 8, fill: 'var(--warning)', anchor: 'middle' }) : ''); });
- const yt = [0, .5, 1].map(f => txt(ML - 6, H - MB - f * (H - MT - MB) + 3, Math.round(mx * f), { anchor: 'end' })).join('');
- const mid = MT + (H - MB - MT) / 2;
- return svg(W, H, `<line x1="${ML}" y1="${H - MB}" x2="${W - MR}" y2="${H - MB}" stroke="var(--line)"/><line x1="${ML}" y1="${MT}" x2="${ML}" y2="${H - MB}" stroke="var(--line)"/>
- ${bars}${lab}${yt}${txt(14, mid, t('ch.rate.ylab'), { fs: 9.5, anchor: 'middle', tr: `rotate(-90 14 ${mid.toFixed(1)})` })}`);
- }
- // 能量瀑布: 理论可发 → 逐段扣损 (按损失降序) → 实际上网
- function waterfall(e) {
- if (!e || !e.theo) return '';
- const W = 720, H = 300, ML = 8, MR = 8, MT = 30, MB = 64, items = (e.items || []).filter(x => x.loss > 0);
- const cols = items.length + 2, cw = (W - ML - MR) / cols, sc = (H - MT - MB) / e.theo;
- let run = e.theo, h = '', i = 0;
- const bar = (idx, y, hh, fill, op) => `<rect x="${(ML + idx * cw + cw * .14).toFixed(1)}" y="${y.toFixed(1)}" width="${(cw * .72).toFixed(1)}" height="${Math.max(hh, 1.2).toFixed(1)}" rx="2" fill="${fill}" fill-opacity="${op}"/>`;
- const lab = (idx, y, nm, v, c) => txt(ML + idx * cw + cw * .5, y - 6, v, { fs: 10, fill: c || 'var(--ink)', anchor: 'middle', w: 600 }) + txt(ML + idx * cw + cw * .5, H - MB + 14, nm, { fs: 8.5, anchor: 'middle' });
- h += bar(0, H - MB - e.theo * sc, e.theo * sc, 'var(--accent)', .45) + lab(0, H - MB - e.theo * sc, t('ch.wf.theo'), fmt(e.theo));
- items.forEach(x => { i++; const top = H - MB - run * sc, hh = x.loss * sc; run -= x.loss; const nm = lossName(x.k);
- h += bar(i, top, hh, 'var(--critical)', .55) + `<line x1="${(ML + (i - 1) * cw + cw * .86).toFixed(1)}" y1="${top.toFixed(1)}" x2="${(ML + i * cw + cw * .14).toFixed(1)}" y2="${top.toFixed(1)}" stroke="var(--muted)" stroke-dasharray="2 2" stroke-opacity=".6"/>`
- + lab(i, top, esc(nm.length > 12 ? nm.slice(0, 11) + '…' : nm), '−' + fmt(x.loss), 'var(--critical)')
- + ttl(t('ch.wf.tip', { nm, loss: fmt(x.loss), pct: (x.loss / e.theo * 100).toFixed(1), h: fmt(x.h) })); });
- h += bar(cols - 1, H - MB - e.act * sc, e.act * sc, 'var(--good)', .6) + lab(cols - 1, H - MB - e.act * sc, t('ch.wf.act'), fmt(e.act), 'var(--good)');
- return svg(W, H, `<line x1="${ML}" y1="${H - MB}" x2="${W - MR}" y2="${H - MB}" stroke="var(--line)"/>${txt(ML, 14, 'MWh', { fs: 9.5 })}${h}`);
- }
- // 可靠性 × 可维修性象限: x = MTBF (对数), y = MDT, 面积 = 累计停机
- function ramChart(tb, partName) {
- const it = (tb || []).filter(x => x.MTBF_h && x.MDT_h);
- if (it.length < 3) return `<p class="small muted">${t('ch.ram.few')}</p>`;
- const W = 720, H = 340, ML = 56, MR = 112, MB = 48, MT = 16;
- const lg = v => Math.log10(v), xs = it.map(d => lg(d.MTBF_h)), ys = it.map(d => d.MDT_h);
- const x0 = Math.min(...xs) - .08, x1 = Math.max(...xs) + .08, y0 = 0, y1 = Math.max(...ys) * 1.12;
- const PX = v => ML + (lg(v) - x0) / (x1 - x0) * (W - ML - MR), PY = v => H - MB - (v - y0) / (y1 - y0) * (H - MT - MB);
- const md = a => { const z = a.slice().sort((p, q) => p - q); return z[Math.floor(z.length / 2)]; };
- const mMT = md(it.map(d => d.MTBF_h)), mMD = md(ys), cx = PX(mMT), cy = PY(mMD), mh = Math.max(...it.map(d => d.停机时长h));
- const nm = d => plain(partName ? partName(d.部件) : d.部件);
- const pts = it.map(d => { const r = 4 + Math.sqrt(d.停机时长h / mh) * 13, ext = d.外部, hot = d.MTBF_h < mMT && d.MDT_h >= mMD, c = ext ? 'var(--muted)' : (hot ? 'var(--critical)' : 'var(--accent)');
- return `<circle cx="${PX(d.MTBF_h).toFixed(1)}" cy="${PY(d.MDT_h).toFixed(1)}" r="${r.toFixed(1)}" fill="${c}" fill-opacity="${ext ? .3 : .42}" stroke="${c}" stroke-width="1.2" stroke-dasharray="${ext ? '3 2' : ''}">${ttl(t('ch.ram.tip', { p: nm(d), mtbf: d.MTBF_h, mdt: d.MDT_h, h: d.停机时长h, n: d.事件数, u: d.影响台数 }) + (ext ? t('ch.ram.tip_ext') : ''))}</circle>`
- + dtxt(PX(d.MTBF_h) + r + 4, PY(d.MDT_h) + 3.5, plain(nm(d)).slice(0, EN ? 14 : 7), { fs: 9.5, fill: ext ? 'var(--muted)' : 'currentColor' }); }).join('');
- const qn = (x, y, a, s1, s2) => txt(x, y, s1, { fs: 10, anchor: a, op: .9 }) + txt(x, y + 12, s2, { fs: 8.5, anchor: a, op: .62 });
- const xt = [1000, 2000, 5000, 10000, 20000].filter(v => lg(v) >= x0 && lg(v) <= x1).map(v => `<line x1="${PX(v).toFixed(1)}" y1="${MT}" x2="${PX(v).toFixed(1)}" y2="${H - MB}" stroke="var(--line)" stroke-opacity=".3"/>` + txt(PX(v), H - MB + 14, v >= 1000 ? (v / 1000) + 'k' : v, { anchor: 'middle' })).join('');
- const yt = [0, 5, 10, 15, 20].filter(v => v <= y1).map(v => txt(ML - 6, PY(v) + 3, v, { anchor: 'end' })).join('');
- const mid = MT + (H - MB - MT) / 2;
- return svg(W, H, `${xt}${yt}<line x1="${ML}" y1="${H - MB}" x2="${W - MR}" y2="${H - MB}" stroke="var(--line)"/><line x1="${ML}" y1="${MT}" x2="${ML}" y2="${H - MB}" stroke="var(--line)"/>
- <line x1="${cx.toFixed(1)}" y1="${MT}" x2="${cx.toFixed(1)}" y2="${H - MB}" stroke="var(--muted)" stroke-dasharray="3 3" stroke-opacity=".45"/>
- <line x1="${ML}" y1="${cy.toFixed(1)}" x2="${W - MR}" y2="${cy.toFixed(1)}" stroke="var(--muted)" stroke-dasharray="3 3" stroke-opacity=".45"/>
- ${qn(ML + 6, MT + 12, 'start', t('ch.ram.q1'), t('ch.ram.q1s'))}${qn(W - MR - 6, MT + 12, 'end', t('ch.ram.q2'), t('ch.ram.q2s'))}
- ${qn(ML + 6, H - MB - 16, 'start', t('ch.ram.q3'), t('ch.ram.q3s'))}${qn(W - MR - 6, H - MB - 16, 'end', t('ch.ram.q4'), t('ch.ram.q4s'))}
- ${pts}${txt(ML + (W - MR - ML) / 2, H - 8, t('ch.ram.xlab'), { fs: 9.5, anchor: 'middle' })}
- ${txt(13, mid, t('ch.ram.ylab'), { fs: 9.5, anchor: 'middle', tr: `rotate(-90 13 ${mid.toFixed(1)})` })}
- <circle cx="${W - MR + 16}" cy="${MT + 40}" r="5" fill="var(--accent)" fill-opacity=".42" stroke="var(--accent)"/>${txt(W - MR + 26, MT + 44, t('ch.ram.equip'))}
- <circle cx="${W - MR + 16}" cy="${MT + 58}" r="5" fill="var(--muted)" fill-opacity=".3" stroke="var(--muted)" stroke-dasharray="3 2"/>${txt(W - MR + 26, MT + 62, t('ch.ram.ext'))}
- ${txt(W - MR + 6, MT + 84, t('ch.ram.area'), { fs: 8.5 })}${txt(W - MR + 6, MT + 98, t('ch.ram.median'), { fs: 8.5 })}`);
- }
- // 归因覆盖条: 已归因 / 未归类 / 无记录
- function attribBar(tb, tot) {
- const nore = (tb || []).find(x => x.部件 === '无记录停机'), unk = nore ? nore.停机时长h : 0;
- const un2 = (tb || []).filter(x => x.部件 === '未归类').reduce((a, b) => a + b.停机时长h, 0), ok = tot - unk - un2, W = 720, H = 54;
- 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}"/>`;
- const w1 = ok / tot * W, w2 = un2 / tot * W, w3 = unk / tot * W;
- return svg(W, H, `${seg(0, w1, 'var(--accent)', .55)}${seg(w1, w2, 'var(--warning)', .5)}${seg(w1 + w2, w3, 'var(--critical)', .5)}
- ${txt(4, 10, t('ch.attrib.ok', { h: Math.round(ok), p: (ok / tot * 100).toFixed(0) }))}
- ${txt(w1 + w2 + w3 - 2, 10, t('ch.attrib.none', { h: Math.round(unk), p: (unk / tot * 100).toFixed(0) }), { fill: 'var(--critical)', anchor: 'end' })}
- ${txt(w1 + w2 / 2, 50, t('ch.attrib.uncls', { h: Math.round(un2) }), { fill: 'var(--warning)', anchor: 'middle' })}`);
- }
- // 帕累托: 降序条 + 累计折线 + 80% 线
- function paretoChart(items, unit, note) {
- const it = (items || []).filter(x => x && x.v > 0);
- if (!it.length) return `<p class="small muted">${t('ch.pareto.none')}</p>`;
- const W = 520, H = 236, ML = 46, MR = 40, MB = 40, MT = 14, n = it.length, tot = it.reduce((a, x) => a + x.v, 0), mx = Math.max(...it.map(x => x.v)), bw = (W - ML - MR) / n;
- const short = k => { k = plain(k); const m = String(k).match(/^([0-9]+)/); return m ? m[1] : String(k).slice(0, 4); };
- let cum = 0, bars = '', line = [], dots = '', k80 = null;
- it.forEach((x, i) => { const hh = x.v / mx * (H - MT - MB), bx = ML + i * bw + bw * .14, by = H - MB - hh;
- bars += `<rect x="${bx.toFixed(1)}" y="${by.toFixed(1)}" width="${(bw * .72).toFixed(1)}" height="${hh.toFixed(1)}" rx="2" fill="var(--accent)" fill-opacity="${i < 3 ? .85 : .45}">${ttl(`${plain(x.k)}: ${fmt(x.v)}${unit}`)}</rect>`;
- cum += x.v; const pct = cum / tot; if (k80 === null && pct >= .8) k80 = i + 1;
- const px = ML + i * bw + bw / 2, py = MT + (1 - pct) * (H - MT - MB); line.push(`${px.toFixed(1)},${py.toFixed(1)}`);
- dots += `<circle cx="${px.toFixed(1)}" cy="${py.toFixed(1)}" r="2.4" fill="var(--warning)">${ttl(t('ch.pareto.cumtip', { k: plain(x.k), p: (pct * 100).toFixed(0) }))}</circle>`; });
- const y80 = MT + .2 * (H - MT - MB);
- const xt = it.map((x, i) => dtxt(ML + i * bw + bw / 2, H - MB + 13, short(x.k), { anchor: 'middle' })).join('');
- const yt = [0, .5, 1].map(f => txt(ML - 5, H - MB - f * (H - MT - MB) + 3, fmt(Math.round(mx * f)), { anchor: 'end' })).join('') + [0, .5, 1].map(f => txt(W - MR + 5, MT + (1 - f) * (H - MT - MB) + 3, Math.round(f * 100) + '%', { fill: 'var(--warning)' })).join('');
- const top3 = it.slice(0, 3).map(x => `<b>${isV(short(x.k)) ? `<span class="verbatim">${esc(plain(short(x.k)))}</span>` : esc(short(x.k))}</b> ${dv(plain(x.k).replace(/^[0-9]+ */, '').slice(0, 18))}`).join(' · ');
- return svg(W, H, `<line x1="${ML}" y1="${H - MB}" x2="${W - MR}" y2="${H - MB}" stroke="var(--line)"/><line x1="${ML}" y1="${MT}" x2="${ML}" y2="${H - MB}" stroke="var(--line)"/>${bars}
- <line x1="${ML}" y1="${y80.toFixed(1)}" x2="${W - MR}" y2="${y80.toFixed(1)}" stroke="var(--warning)" stroke-dasharray="4 3" stroke-opacity=".7"/>
- ${txt(W - MR - 3, y80 - 4, t('ch.pareto.cum80'), { fill: 'var(--warning)', anchor: 'end' })}
- <polyline points="${line.join(' ')}" fill="none" stroke="var(--warning)" stroke-width="1.5"/>${dots}${xt}${yt}
- ${txt(ML + (W - MR - ML) / 2, H - 3, t('ch.pareto.xlab'), { fs: 8.5, anchor: 'middle' })}`)
- + `<p class="small muted" style="margin-top:5px;line-height:1.6">${k80 ? `<b>${t('ch.pareto.key', { k: k80 })}</b> (${t('ch.pareto.total', { n, tot: fmt(Math.round(tot)), unit })})` : t('ch.pareto.total', { n, tot: fmt(Math.round(tot)), unit })}<br>${t('ch.pareto.top3')}${top3}${note ? '<br>' + note : ''}</p>`;
- }
- // 损失责任分账: 交易与调度侧 / 设备侧 / 运行侧
- function blameBar(e) {
- const cat = k => (k.indexOf('限电') >= 0 || k.indexOf('调度') >= 0) ? 0 : (k === '停机' ? 1 : 2);
- const NM = [t('ch.blame.n1'), t('ch.blame.n2'), t('ch.blame.n3')], NOTE = [t('ch.blame.d1'), t('ch.blame.d2'), t('ch.blame.d3')], CL = ['var(--accent)', 'var(--critical)', 'var(--muted)'];
- const g = [0, 0, 0]; (e.items || []).forEach(x => { g[cat(x.k)] += x.loss; });
- const tot = g.reduce((a, b) => a + b, 0) || 1, W = 500, H = 44; let x = 0, seg = '';
- g.forEach((v, i) => { const w = v / tot * W;
- seg += `<rect x="${x.toFixed(1)}" y="12" width="${Math.max(w, 1).toFixed(1)}" height="26" rx="3" fill="${CL[i]}" fill-opacity=".55"><title>${NM[i]}: ${fmt(Math.round(v))} MWh</title></rect>` + (w > 46 ? txt(x + w / 2, 29, (v / tot * 100).toFixed(0) + '%', { fs: 10.5, fill: 'currentColor', anchor: 'middle', w: 600 }) : ''); x += w; });
- const leg = g.map((v, i) => `<div style="display:flex;align-items:baseline;gap:7px;margin-top:5px"><i style="flex:none;width:9px;height:9px;border-radius:2px;background:${CL[i]};opacity:.55"></i><span style="font-weight:600">${NM[i]}</span><span class="num">${fmt(Math.round(v))} MWh</span><span class="small muted">${NOTE[i]}</span></div>`).join('');
- return svg(W, H, `${txt(0, 8, t('ch.blame.title', { tot: fmt(Math.round(tot)) }))}${seg}`) + leg;
- }
- // 多线月度图 (季节性): 至多 4 条序列, 同一 y 轴; 断点不连线
- function multiline(f) {
- const xs = f.months || f.x || [], n = xs.length; if (!n) return '';
- const W = 620, H = 250, ML = 52, MB = 34, cols = ['var(--accent)', '#D48A1E', '#2E8B6A', '#7C5295'];
- const all = []; (f.series || []).forEach(s => s.vals.forEach(v => { if (v != null && v === v) all.push(v); })); (f.thresholds || []).forEach(th => { if (th && th.v != null) all.push(th.v); });
- if (!all.length) return '';
- let lo = f.zero === false ? Math.min(...all) : Math.min(0, ...all), hi = Math.max(...all); if (lo === hi) { lo -= 1; hi += 1; } const pad = (hi - lo) * .08; hi += pad; if (f.zero === false) lo -= pad;
- const X = i => ML + i / Math.max(n - 1, 1) * (W - ML - 12), Y = v => (H - MB) - (v - lo) / (hi - lo) * (H - MB - 14);
- const line = (vals, c) => { let segs = [], cur = []; vals.forEach((v, i) => { if (v == null || v !== v) { if (cur.length) segs.push(cur); cur = []; } else cur.push(`${X(i).toFixed(1)},${Y(v).toFixed(1)}`); }); if (cur.length) segs.push(cur);
- return segs.map(sg => `<polyline points="${sg.join(' ')}" fill="none" stroke="${c}" stroke-width="1.8"/>`).join(''); };
- let body = (f.series || []).map((s, k) => line(s.vals, cols[k % 4])).join('');
- const step = Math.max(1, Math.ceil(n / 8)); let xt = '';
- for (let i = 0; i < n; i += step) xt += txt(X(i), H - 10, String(xs[i]).includes('-') ? String(xs[i]).slice(2, 7) : String(xs[i]), { fs: 10, anchor: 'middle' });
- let yt = ''; for (let k = 0; k < 5; k++) { const v = lo + (hi - lo) * k / 4; yt += txt(ML - 6, Y(v) + 3, v.toFixed(Math.abs(hi) < 10 ? 1 : 0), { fs: 10, anchor: 'end' }) + `<line x1="${ML}" x2="${W - 12}" y1="${Y(v).toFixed(1)}" y2="${Y(v).toFixed(1)}" stroke="var(--line)"/>`; }
- (f.thresholds || []).forEach(th => { if (!th || th.v == null) return; body += `<line x1="${ML}" x2="${W - 12}" y1="${Y(th.v).toFixed(1)}" y2="${Y(th.v).toFixed(1)}" stroke="var(--muted)" stroke-dasharray="5 4"/>` + (th.label ? dtxt(ML + 6, Y(th.v) - 4, th.label, { fs: 10 }) : ''); });
- if (f.xlab) body += dtxt((ML + W) / 2, H - 1, f.xlab, { fs: 10, anchor: 'middle' });
- const leg = `<div class="legend" style="margin-top:6px;flex-wrap:wrap">` + (f.series || []).map((s, k) => `<span><i style="background:${cols[k % 4]}"></i>${isV(s.name) ? `<span class="verbatim">${esc(plain(s.name))}</span>` : esc(s.name)}</span>`).join('') + `</div>`;
- return svg(W, H, `${yt}${body}${xt}`) + leg;
- }
- // 封顶参数落位: 两轴 (满发 kW / 转速 rpm) 上各自的离散分组, 圆面积 ∝ 台数
- function capAxis(P, W9) {
- const W = 500, H = 158, ML = 54, MR = 34;
- const row = (y, spec, label) => { const gs = Object.entries(spec.分组 || {}).map(([k, v]) => ({ v: parseFloat(k), n: v, raw: k })); if (!gs.length) return '';
- const lo = Math.min(...gs.map(g => g.v)), hi = Math.max(...gs.map(g => g.v)), pad = (hi - lo) * .22 || 1, x0 = lo - pad, x1 = hi + pad, PX = v => ML + (v - x0) / (x1 - x0) * (W - ML - MR), mn = Math.max(...gs.map(g => g.n)), med = spec['中位'];
- return txt(ML - 6, y + 3, label, { fs: 9.5, anchor: 'end' }) + `<line x1="${ML}" y1="${y}" x2="${W - MR}" y2="${y}" stroke="var(--line)"/><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=".55"/>`
- + txt(PX(med), y + 28, t('ch.cap.median', { v: med }), { fs: 8.5, anchor: 'middle' })
- + gs.map(g => { const r = 5 + Math.sqrt(g.n / mn) * 9; return `<circle cx="${PX(g.v).toFixed(1)}" cy="${y}" r="${r.toFixed(1)}" fill="var(--accent)" fill-opacity=".42" stroke="var(--accent)"/>` + txt(PX(g.v), y - r - 5, esc(g.raw), { anchor: 'middle', fill: 'currentColor' }) + txt(PX(g.v), y + 3.5, g.n, { anchor: 'middle', fill: 'var(--panel)', w: 600 }); }).join(''); };
- return svg(W, H, `${row(44, P || {}, t('ch.cap.p'))}${row(120, W9 || {}, t('ch.cap.w'))}${txt(W - MR, 12, t('ch.cap.legend'), { fs: 8.5, anchor: 'end' })}`);
- }
- // 桨距同档差: 离群台的平均偏 vs 全场同档差中位
- function betaBar(b, unitNo) {
- const outs = b.离群 || [], med = b['全场同档差中位'] || 0, W = 500, H = 34 + outs.length * 26, ML = 52, MR = 58;
- const mx = Math.max(med, ...outs.map(o => o.平均偏)) * 1.18 || 1, PX = v => ML + v / mx * (W - ML - MR);
- const rows = outs.map((o, i) => { const y = 30 + i * 26; return txt(ML - 6, y + 10, esc(unitNo(o.t)), { fs: 9.5, anchor: 'end', fill: 'currentColor' }) + `<rect x="${ML}" y="${y + 1}" width="${(PX(o.平均偏) - ML).toFixed(1)}" height="13" rx="2" fill="var(--warning)" fill-opacity=".62"/>` + txt(PX(o.平均偏) + 5, y + 11, o.平均偏 + '°', { fill: 'var(--warning)' }); }).join('');
- return svg(W, H, `<line x1="${PX(med).toFixed(1)}" y1="18" x2="${PX(med).toFixed(1)}" y2="${H - 4}" stroke="var(--good)" stroke-dasharray="3 3"/>${txt(PX(med), 12, t('ch.beta.median', { v: med }), { fs: 8.5, fill: 'var(--good)' })}<line x1="${ML}" y1="18" x2="${ML}" y2="${H - 4}" stroke="var(--line)"/>${rows}`);
- }
- // 尺子巡检: 各轴 极差/σ 对 38 台正态抽样期望 3.7σ
- function rulerBar(rl) {
- const ks = Object.entries(rl || {}), W = 500, H = 26 + ks.length * 30, ML = 96, MR = 76, NORM = 3.7;
- const mxr = Math.max(NORM * 1.35, ...ks.map(([, v]) => v.极差 / v.σ)), PX = v => ML + v / mxr * (W - ML - MR);
- const rows = ks.map(([k, v], i) => { const y = 22 + i * 30, ratio = v.极差 / v.σ, wide = ratio > NORM;
- return dtxt(ML - 6, y + 11, k, { fs: 9.5, anchor: 'end', fill: 'currentColor' }) + `<rect x="${ML}" y="${y + 2}" width="${(PX(ratio) - ML).toFixed(1)}" height="12" rx="2" fill="${wide ? 'var(--warning)' : 'var(--accent)'}" fill-opacity=".55"/>` + txt(PX(ratio) + 5, y + 11, t('ch.ruler.row', { r: ratio.toFixed(1), s: v.σ })); }).join('');
- return svg(W, H, `<line x1="${PX(NORM).toFixed(1)}" y1="14" x2="${PX(NORM).toFixed(1)}" y2="${H - 4}" stroke="var(--good)" stroke-dasharray="3 3"/>${txt(PX(NORM), 10, t('ch.ruler.norm'), { fs: 8.5, fill: 'var(--good)', anchor: 'middle' })}${rows}`);
- }
- // ── 通用图元 fig(f): 覆盖 /api/turbine · /api/problem 的 5 种 kind ──
- // band 本台 vs 全场中位 + 四分位带 · bars 月度条 · xy 两线 · multiline 多线
- // vib/vibbar 是经典页的传动链 SVG 与倍中位条, 这里退化为文本 + 表格 (语义不丢)
- function fig(f) {
- const k = f.kind;
- if (k === 'vib') return `<p class="small">${isV(f.text || f.comp) ? `<span class="verbatim">${esc(plain(f.text || f.comp))}</span>` : esc(f.text || f.comp || '')}</p>`;
- if (k === 'vibbar') {
- const rows = f.rows || []; if (!rows.length) return '';
- 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 >= .01 ? v.toFixed(3) : Number(v).toExponential(1); };
- const byM = {}; rows.forEach(r => { (byM[r.指标] = byM[r.指标] || []).push(r); });
- let h = `<p class="small muted">${t('fig.vibbar_note')}</p>`;
- for (const [mk, rs] of Object.entries(byM)) {
- h += `<div class="tblwrap" style="margin-top:8px"><b class="small muted">${dv(mk)}</b><table style="margin-top:2px"><tr><th>${t('fig.point')}</th><th class="r">${t('fig.x_median')}</th><th class="r">${t('fig.this_unit')}</th><th class="r">${t('fig.fleet_median')}</th><th class="r">z</th><th class="r">${t('fig.trend')}</th></tr>`
- + rs.map(r => { const col = Math.abs(r.z) >= 3 ? 'var(--critical)' : Math.abs(r.z) >= 2 ? 'var(--warning)' : 'var(--good)';
- return `<tr><td class="small">${dv(r.测点)}</td><td class="r" style="color:${col};font-weight:600">${r.倍中位 != null ? r.倍中位 + '×' : '—'}</td><td class="r small">${sig(r.本台)}</td><td class="r small muted">${sig(r.全场中位)}</td><td class="r small" style="color:${Math.abs(r.z) >= 3 ? 'var(--critical)' : 'var(--muted)'}">${r.z}</td><td class="r small" style="color:${r.六窗比 > 1.15 ? 'var(--warning)' : 'var(--muted)'}">${r.六窗比 != null ? r.六窗比 + '×' : '—'}</td></tr>`; }).join('') + `</table></div>`;
- }
- return h;
- }
- const xs = f.months || f.x || [], n = xs.length; if (!n) return '';
- const W = 620, H = 250, ML = 52, MB = 34, MT = 14;
- const all = [];
- if (k === 'band') [f.self, f.med, f.q1, f.q3].forEach(a => (a || []).forEach(v => { if (v != null && v === v) all.push(v); }));
- else if (k === 'bars') { all.push(0); (f.vals || []).forEach(v => { if (v != null && v === v) all.push(v); }); }
- else if (k === 'xy') [f.self, f.med].forEach(a => (a || []).forEach(v => { if (v != null && v === v) all.push(v); }));
- else (f.series || []).forEach(s => (s.vals || []).forEach(v => { if (v != null && v === v) all.push(v); }));
- (f.thresholds || []).forEach(th => { if (th && th.v != null) all.push(th.v); });
- if (!all.length) return '';
- let lo = Math.min(...all), hi = Math.max(...all); if (lo === hi) { lo -= 1; hi += 1; }
- const pad = (hi - lo) * .08; lo -= pad; hi += pad;
- const X = i => ML + i / Math.max(n - 1, 1) * (W - ML - 12), Y = v => (H - MB) - (v - lo) / (hi - lo) * (H - MB - MT);
- const line = (vals, c, w) => { let segs = [], cur = []; (vals || []).forEach((v, i) => { if (v == null || v !== v) { if (cur.length) segs.push(cur); cur = []; } else cur.push(`${X(i).toFixed(1)},${Y(v).toFixed(1)}`); }); if (cur.length) segs.push(cur);
- return segs.map(sg => `<polyline points="${sg.join(' ')}" fill="none" stroke="${c}" stroke-width="${w || 1.8}"/>`).join(''); };
- let body = '';
- if (k === 'band' && f.q1 && f.q3) { const up = [], dn = [];
- f.q3.forEach((v, i) => { if (v != null) up.push(`${X(i).toFixed(1)},${Y(v).toFixed(1)}`); });
- for (let i = f.q1.length - 1; i >= 0; i--) if (f.q1[i] != null) dn.push(`${X(i).toFixed(1)},${Y(f.q1[i]).toFixed(1)}`);
- if (up.length && dn.length) body += `<polygon points="${up.join(' ')} ${dn.join(' ')}" fill="var(--unknown)" fill-opacity=".18"/>`; }
- if (k === 'bars') { const bw = Math.max(3, (W - ML - 20) / n * .6), y0 = Y(Math.max(lo, 0));
- body += (f.vals || []).map((v, i) => (v == null || v !== v) ? '' : `<rect x="${(X(i) - bw / 2).toFixed(1)}" y="${Y(v).toFixed(1)}" width="${bw.toFixed(1)}" height="${Math.max(y0 - Y(v), .8).toFixed(1)}" rx="2" fill="var(--accent)" fill-opacity=".75"/>`).join(''); }
- if (f.med) body += line(f.med, 'var(--muted)', 1.7);
- if (f.self) body += line(f.self, 'var(--critical)', 2.1) + (f.self || []).map((v, i) => (v == null || v !== v) ? '' : `<circle cx="${X(i).toFixed(1)}" cy="${Y(v).toFixed(1)}" r="2.4" fill="var(--critical)"/>`).join('');
- const cols = ['var(--accent)', '#D48A1E', '#2E8B6A', '#7C5295'];
- (f.series || []).forEach((s, i) => { body += line(s.vals, cols[i % 4]); });
- (f.thresholds || []).forEach(th => { if (th == null || th.v == null) return;
- body += `<line x1="${ML}" x2="${W - 12}" y1="${Y(th.v).toFixed(1)}" y2="${Y(th.v).toFixed(1)}" stroke="var(--muted)" stroke-dasharray="5 4"/>` + (th.label ? dtxt(ML + 6, Y(th.v) - 4, th.label, { fs: 10 }) : ''); });
- const step = Math.max(1, Math.ceil(n / 8)); let xt = '';
- for (let i = 0; i < n; i += step) xt += txt(X(i), H - 10, String(xs[i]).includes('-') ? String(xs[i]).slice(2, 7) : String(xs[i]), { fs: 10, anchor: 'middle' });
- let yt = ''; for (let i = 0; i < 5; i++) { const v = lo + (hi - lo) * i / 4;
- yt += txt(ML - 6, Y(v) + 3, v.toFixed(Math.abs(hi) < 10 ? 1 : 0), { fs: 10, anchor: 'end' }) + `<line x1="${ML}" x2="${W - 12}" y1="${Y(v).toFixed(1)}" y2="${Y(v).toFixed(1)}" stroke="var(--line)"/>`; }
- const leg = k === 'band' ? txt(W - 190, 12, t('fig.this_unit'), { fill: 'var(--critical)', fs: 10 }) + txt(W - 110, 12, t('fig.fleet_median'), { fs: 10 }) + txt(W - 40, 12, t('fig.quartile'), { fs: 10 })
- : ((f.series || []).length && (f.series || []).length <= 2 ? f.series.map((s, i) => txt(W - 12 - (f.series.length - 1 - i) * 118, 12, '— ' + esc(plain(s.name)), { fill: cols[i % 4], fs: 10, anchor: 'end' })).join('') : '');
- const legHtml = ((f.series || []).length > 2) ? `<div class="legend" style="margin-top:6px;flex-wrap:wrap">` + f.series.map((s, i) => `<span><i style="background:${cols[i % 4]}"></i>${isV(s.name) ? `<span class="verbatim">${esc(plain(s.name))}</span>` : esc(s.name)}</span>`).join('') + `</div>` : '';
- return svg(W, H, `${yt}${body}${xt}${leg}`) + legHtml;
- }
- return { quadChart, rateChart, waterfall, ramChart, attribBar, paretoChart, blameBar, lossName, multiline, capAxis, betaBar, rulerBar, fig };
- };
- </script>
- <script>/* 观澜 新前端 —— 真正的 .js 文件, 不再是 Python 三引号串 (反斜杠那类坑到此为止)。
- *
- * 语言: 构建期注入 window.__L (已过 lang.check 硬闸)。这里没有"翻译"这一步 ——
- * 文案按 key 取, 缺 key 就显示 ⟦key⟧ 并 console.error, 响亮不静默。
- * 数据: 同一套 /api/*。英文模式带 lang=en 让 API 用 name_en 等权威译名投影;
- * 正文使用标准词库与逐条译文;未覆盖正文保持可见,来源原文可主动展开。
- * 设计: docs/design/guanlan_v2.html 六条决定。
- */
- (function () {
- 'use strict';
- const L = window.__L || {};
- const LANG = document.documentElement.lang || 'zh';
- const EN = LANG === 'en';
- const RECORD_EN = L.__record_en || {};
- const REVIEW_REQUIRED = 'Review required';
- // 中文显示层术语映射: 数据里的内部词 (如「引用同族, 见族主台」就在振动线 handoff 与本体里,
- // 且 populate.py 等 3 处拿它做匹配) 只在**显示时**换成现场说法, 数据与匹配逻辑一概不动。
- // 表与后端同源 = configs/terms/display_map.yaml, 经语言包 __display_zh 注入 (已按长度降序)。
- const DISPLAY_ZH = L.__display_zh || [];
- const displayText = value => {
- const raw = String(value == null ? '' : value);
- let s = EN ? (RECORD_EN[raw] || raw) : raw;
- if (EN && /\[translation pending\]/i.test(s)) s = s.replace(/\[translation pending\]/ig, REVIEW_REQUIRED);
- if (!EN) for (const [a, b] of DISPLAY_ZH) if (s.indexOf(a) >= 0) s = s.split(a).join(b);
- return s;
- };
- const CJK = /[㐀-鿿 -〿-]/;
- function t(k, kw) {
- let s = L[k];
- if (s == null) { console.error('lang miss:', k); return '⟦' + k + '⟧'; }
- if (kw) for (const [a, b] of Object.entries(kw)) s = s.split('{' + a + '}').join(String(b));
- return s;
- }
- const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
- // 数据文本按标准词库精确翻译;未覆盖文本保持可见并计数,不能靠隐藏通过英文检查。
- let HIDDEN = 0;
- function dt(s) {
- s = displayText(s);
- if (!EN) return s;
- if (CJK.test(s) || s.includes('[translation pending]')) HIDDEN++; // Keep unresolved text visible so the English audit cannot pass by hiding it.
- return s;
- }
- // ── API 枚举 → 语言包 key (API 的枚举值是中文, 有限集合; 这里是唯一的映射点) ──
- const ST = { '危险': 'bad', '报警': 'warn', '不可判': 'unk', '良好': 'ok', '优秀': 'ex' };
- const stateKey = value => ST[value] || ({critical:'bad', alarm:'warn', undeterminable:'unk', indeterminate:'unk', 'not evaluable':'unk', good:'ok', excellent:'ex'}[String(value || '').toLowerCase()]);
- const ST_RANK = { bad: 0, warn: 1, unk: 2, ok: 3, ex: 4 };
- const TIER = { '定论': 'confirmed', '准定论·预警': 'near', '候选·新发': 'candidate_new', '候选': 'candidate',
- '候选·记基线': 'candidate_baseline', '参考': 'reference', 'INSUFFICIENT': 'insufficient',
- '正常': 'normal', '撤回': 'withdrawn' };
- const TIER_CLS = { candidate_new: 'candidate', candidate_baseline: 'candidate', normal: 'reference' };
- const STEP = { '证据': 'evidence', '机制': 'mechanism', '判级': 'rating', '排期': 'schedule', '动作': 'action', '验收': 'acceptance' };
- const SYS9 = ['叶片与叶根', '变桨', '偏航', '齿轮箱', '发电机', '主轴承', '变流器', '塔架与基础', '主控与传感网'];
- const SYS_KEY = { '叶片与叶根': 'blade', '变桨': 'pitch', '偏航': 'yaw', '齿轮箱': 'gearbox', '发电机': 'generator',
- '主轴承': 'main_bearing', '变流器': 'converter', '塔架与基础': 'tower', '主控与传感网': 'control',
- '主轴': 'main_bearing', '其他': 'other' }; // fus.事件 用「主轴」作键 (英文点检逮: 标题空白)
- // 系统名查不到时按**原文标注**而非隐藏 —— 隐藏会让标题整个空掉
- // 反查表: **正名优先, 别名只补空位**。用 Object.fromEntries 直接反转会让后写的别名覆盖正名
- // (「主轴」在「主轴承」之后 ⇒ main_bearing 反查成「主轴」, 而数据键是「主轴承」⇒ 详情页取不到数据,
- // 显示"全场 1 台 / 四项全 0"; 2026-09-08 xzy 测试报告逮)。
- const SYS_ZH = Object.fromEntries(SYS9.map(zh => [SYS_KEY[zh], zh]));
- Object.entries(SYS_KEY).forEach(([zh, key]) => { if (!(key in SYS_ZH)) SYS_ZH[key] = zh; });
- const sysSlug = value => SYS_KEY[value] || value;
- const sysFromSlug = value => SYS_ZH[value] || value;
- const sysName = (value) => { const key = SYS_KEY[value] || value; return SYS_ZH[key] ? t('sys.' + key) : dv(value); };
- const WIN_PRESETS = [['近30日', 'win.30d'], ['近90日', 'win.90d'], ['2026年', 'win.2026'], ['2026H1', 'win.2026H1'], ['2025H2', 'win.2025H2'], ['全程', 'win.all']];
- const WIN_SLUG = { '近30日': '30d', '近90日': '90d', '2026年': '2026', '2026H1': '2026H1', '2025H2': '2025H2', '全程': 'all' };
- const WIN_ZH = Object.fromEntries(Object.entries(WIN_SLUG).map(([zh, slug]) => [slug, zh]));
- const winSlug = value => WIN_SLUG[value] || value;
- const winFromSlug = value => WIN_ZH[value] || value;
- const winName = (w) => { const p = WIN_PRESETS.find(x => x[0] === w); return p ? t(p[1]) : w; };
- const TABS = ['overview', 'energy', 'component', 'vibration', 'generation', 'fault', 'decision', 'assistant', 'report', 'system'];
- // ── 状态 ──
- const Q = new URLSearchParams(location.search);
- // 快照模式 (window.__WINS 由 snapshot.py 注入): 只列已烤的窗, 默认取第一个已烤窗 —— 给一个必然空白的选项比少给几个更糟
- const SNAP = typeof window.__SNAP__ !== 'undefined', BAKED = SNAP ? (window.__WINS || []) : null;
- let WIN = winFromSlug(Q.get('win') || (BAKED ? (BAKED[0] || '2026年') : '2026年'));
- if (BAKED && !BAKED.includes(WIN)) WIN = BAKED[0] || WIN;
- let TAB = (location.hash.match(/tab=([a-z]+)/) || [])[1] || 'overview';
- let D = null;
- function stateChip(zh, cls) { const k = stateKey(zh); return k ? `<span class="chip ${k} ${cls || ''}">${t(stKey(k))}</span>` : (zh ? `<span class="chip">${esc(dt(zh))}</span>` : ''); }
- function tierChip(zh) { const k = TIER[zh]; if (!k) return ''; return `<span class="tier ${TIER_CLS[k] || k}" title="${esc(t('tier.' + k))}">${t('tier.' + k + '.s')}</span>`; }
- // 枚举短名 ex ↔ 语言包 key st.excellent (CSS class 用短名, 语言包用全名; 首次浏览器点检即逮 ⟦st.ex⟧)
- const stKey = (k) => 'st.' + (k === 'ex' ? 'excellent' : k);
- const unitNo = (wtg) => String(wtg).replace('WTG', '').replace(/^0/, '') + '#';
- const fmt = (n, d = 0) => (n == null || isNaN(n)) ? '—' : Number(n).toLocaleString(EN ? 'en-US' : 'zh-CN', { maximumFractionDigits: d, minimumFractionDigits: d });
- // ── 顶 + 页签 ──
- function renderTop(d) {
- const months = BAKED ? [] : ((d && d.all_months) || []);
- const isCustom = /^\d{4}-\d{2}-\d{2}~\d{4}-\d{2}-\d{2}$/.test(WIN);
- const custOpt = isCustom ? `<option value="${esc(WIN)}" selected>${t('win.custom_sel', { a: WIN.split('~')[0], b: WIN.split('~')[1] })}</option>` : '';
- const opts = `<optgroup label="${t('win.presets')}">` + WIN_PRESETS.filter(([w]) => !BAKED || BAKED.includes(w)).map(([w]) => `<option value="${winSlug(w)}" ${w === WIN ? 'selected' : ''}>${winName(w)}</option>`).join('') + `</optgroup>`
- + (custOpt ? `<optgroup label="${t('win.custom')}">${custOpt}</optgroup>` : '')
- + (months.length ? `<optgroup label="${t('win.months')}">` + months.slice().reverse().map(m => `<option value="${m}" ${m === WIN ? 'selected' : ''}>${m}</option>`).join('') + `</optgroup>` : '');
- // 静态快照下 ?lang= 不起作用 (点了只重载同一文件) —— 用 __PEER 指向对方语言的真实文件。
- // 没有 peer 就**不给**对方按钮: 一个点了没反应的切换比没有切换更糟。
- const PEER = (typeof window.__PEER === 'string' && window.__PEER) ? window.__PEER : null;
- // 当前语言的按钮指向**自身** (只带 hash), 对方语言才指向 peer —— 两个都指对方的话
- // 点哪个都跳走, 高亮那个也点不住 (云上实逮)。
- const href = (l) => { const self = (l === 'en') === EN;
- return SNAP ? (self ? location.hash || '#' : PEER + location.hash)
- : `?lang=${l}&win=${encodeURIComponent(winSlug(WIN))}${location.hash}`; };
- // 静态快照且没有对方语言 ⇒ 整个切换器收起。只剩一个按钮的"切换器"没有意义, 还让人以为能切。
- const showToggle = !SNAP || !!PEER;
- const fleetCount = d ? (d.kpi && d.kpi.全场) : null;
- const fleetUnitLabel = EN && Number(fleetCount) !== 1 ? t('ui.units') : t('ui.unit');
- document.getElementById('top').innerHTML = `
- <svg class="wind-field" viewBox="0 0 420 130" fill="none" aria-hidden="true">
- <g stroke="currentColor" stroke-width="1.2"><path d="M0 108Q100 84 200 108T420 106M0 118Q100 96 200 118T420 116" opacity=".45"/>
- <path d="M115 48v62m0-62 4-39-4-5-4 44m4 0-33 23 2 5 31-28m0 0 35 18 5-2-40-16M230 34v74m0-74 4-27-4-5-4 32m4 0-35 23 2 5 33-28m0 0 37 19 5-2-42-17M332 56v54m0-54 3-27-3-5-3 32m3 0-25 19 1 4 24-23m0 0 28 15 4-2-32-13"/>
- <path d="M38 35h24m-12-8h28M165 82h30m-15-9h28M355 32h31m-15-8h30" opacity=".5"/>
- <path d="M44 100 115 88 230 77 332 88 392 71" stroke-dasharray="3 5" opacity=".5"/></g>
- <g stroke="currentColor" stroke-width="1" opacity=".8">
- <path d="M20 96V58h9v38m9 0V57h9v39m9 0V56h9v40M12 55 72 51 68 58 19 61Z"/>
- <path d="M378 100V71m0 0-14-11m14 11 15-11m-29 0q14 8 29 0m-25-5q10 5 21 0M403 105V82m0 0-10-8m10 8 11-8m-21 0q10 5 21 0"/>
- </g>
- <g fill="currentColor"><circle cx="115" cy="88" r="3"/><circle cx="230" cy="77" r="3"/><circle cx="332" cy="88" r="3"/><circle cx="392" cy="71" r="3"/></g>
- </svg>
- <span class="mark"><svg viewBox="0 0 26 26" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true">
- <path d="M3 8.5c3-3 6-3 9 0s6 3 9 0"/><path d="M3 14.5c3-3 6-3 9 0s6 3 9 0"/><path d="M3 20.5c3-3 6-3 9 0s6 3 9 0"/></svg>
- <span class="brand">${t('app.brand')}<small>${t('app.subtitle')} · ${d ? fmt(fleetCount) : '—'} ${fleetUnitLabel}</small></span></span>
- <div class="right">
- <a class="back-portal" id="backPortal" href="${portalHref()}" title="${t('ui.back_portal')}">${t('ui.back_portal')}</a>
- <button type="button" class="reselect" id="reselect" title="${t('ui.reselect')}">${t('ui.reselect')}</button>
- <label class="small muted" for="win">${t('win.label')}</label>
- <select id="win" aria-label="${t('win.label')}">${opts}</select>
- ${SNAP ? '' : `<span class="winrng" title="${t('win.custom_hint')}">
- <input type="date" id="wina" aria-label="${t('win.from')}" value="${(D && D.sysmx_span ? D.sysmx_span[0] : ((Q.get('wina') || '')))}">
- <span class="muted">~</span>
- <input type="date" id="winb" aria-label="${t('win.to')}" value="${(D && D.sysmx_span ? D.sysmx_span[1] : ((Q.get('winb') || '')))}">
- <button type="button" id="winapply">${t('win.apply')}</button></span>`}
- ${showToggle ? `<div class="toggle" role="group" aria-label="language">
- <a class="${EN ? '' : 'on'}" href="${href('zh')}" style="padding:6px 12px;color:inherit">${EN ? 'ZH' : '中文'}</a><a class="${EN ? 'on' : ''}" href="${href('en')}" style="padding:6px 12px;color:inherit">EN</a></div>` : ''}
- </div>`;
- document.getElementById('reselect').addEventListener('click', () => { SYSV = null; TAB = 'overview'; location.hash = 'tab=overview'; renderTabs(); render(); const w = document.getElementById('win'); if (w) { w.focus(); } window.scrollTo({ top: 0, behavior: 'smooth' }); });
- document.getElementById('win').addEventListener('change', e => { WIN = winFromSlug(e.target.value); const u = new URL(location.href); u.searchParams.set('win', winSlug(WIN)); history.replaceState(null, '', u); load(); });
- // ★自定义起止日期窗(含两端;用户令 2026-09-21):校验 a ≤ b、且不超出数据月范围,然后按同一路径重载
- const _ap = document.getElementById('winapply');
- if (_ap) _ap.addEventListener('click', () => {
- const a = (document.getElementById('wina') || {}).value, b = (document.getElementById('winb') || {}).value;
- if (!a || !b) { alert(t('win.need_both')); return; }
- if (a > b) { alert(t('win.bad_order')); return; }
- WIN = `${a}~${b}`;
- const u = new URL(location.href); u.searchParams.set('win', WIN); u.searchParams.delete('wina'); u.searchParams.delete('winb');
- history.replaceState(null, '', u); GEN.win = null; load();
- });
- document.querySelectorAll('.toggle a.on').forEach(a => { a.style.background = 'var(--ink)'; a.style.color = '#fff'; });
- }
- function portalHref() { try { const q = new URLSearchParams(location.search).get('portal'); if (q) { localStorage.setItem('guanlan.portal', q); return q; } const s = localStorage.getItem('guanlan.portal'); if (s) return s; const r = document.referrer; if (r && !/\/v2(\?|#|$)/.test(r) && /门户|portal|_demo\/site|18084/.test(decodeURIComponent(r))) { localStorage.setItem('guanlan.portal', r); return r; } return '/'; } catch (e) { return '/'; } } // 2026-09-06 用户批: 门户链接改指 /v2; 来路是门户时记住它作返回目标
- function renderTabs() {
- document.getElementById('tabs').innerHTML = TABS.map(id => `<button class="${id === TAB ? 'on' : ''}" data-tab="${id}">${t('tab.' + id)}</button>`).join('');
- document.querySelectorAll('#tabs button').forEach(b => b.addEventListener('click', () => { TAB = b.dataset.tab; location.hash = 'tab=' + TAB; renderTabs(); render(); }));
- }
- // ── 总览 ──
- function unitState(row) {
- // 台级状态 = 六列里最严重的设备状态。不可判只在没有任何良好/优秀评估时才成为台级状态 ——
- // 它说的是"这一列看不见", 不是"这台有问题"; 有别的列看得见且正常, 台级就按看得见的算。
- const ks = ['振动', '温度', '润滑', '油液', '报警', '工单'].map(c => stateKey((row[c] || {}).ostate)).filter(Boolean);
- if (!ks.length) return 'unk';
- if (ks.includes('bad')) return 'bad';
- if (ks.includes('warn')) return 'warn';
- const seen = ks.filter(k => k !== 'unk');
- if (!seen.length) return 'unk';
- return seen.includes('ok') ? 'ok' : 'ex';
- }
- function overview(d) {
- const F = d.fus || {}, M = (d.m8 && d.m8.mtbf) || {}, C = d.control || {};
- // 损失按 control.states 分项取 (与经典页同源): 停机 / 停机(调度令) / 限电·绑定。
- // 首次点检逮: 对 energy.monthly.loss 求和是"理论−上网"总损失 (24,819), 拿它标"停机损失"= 标错标签
- // (memory headline-number-mislabels-own-decomposition) —— 数字标错比缺数字更坏。
- const LS = {}; (C.states || []).forEach(x => { LS[x.k] = x.loss; });
- const kpi = [
- [fmt(C.avail, 1), '%', t('kpi.avail', { win: winName(d.win) }), C.avail >= 95 ? 'ok' : 'warn'],
- [fmt(M.停机总时长h), 'h', t('kpi.downtime', { n: fmt(M.停机事件数) }), 'warn'],
- [fmt(M.MTBF_h), 'h', t('kpi.mtbo'), ''],
- [fmt(LS['停机']), 'MWh', t('kpi.loss', { win: winName(d.win) }) + (LS['停机(调度令)'] != null ? ` · ${t('kpi.loss_dispatch', { n: fmt(LS['停机(调度令)']) })}` : ''), ''],
- [fmt(LS['限电·绑定']), 'MWh', t('kpi.curtail'), ''],
- [`${fmt(d.kpi && d.kpi.报警台)} <small>/ ${fmt(d.kpi && d.kpi.全场)}</small>`, '', t('kpi.alarm_units'), (d.kpi && d.kpi.报警台) ? 'warn' : 'ok', t('kpi.alarm_units.hint')],
- ];
- // 第 5 元素 = 口径提示 (title): 同名不同轴的数字必须自带口径, 否则读者会与"全场状态"格对不上
- // (2026-09-08 xzy 测试报告: "报警台数信息不相符" —— 33 是九系统轴, 格子是六列设备状态轴)。
- let h = `<section><div class="kpis">` + kpi.map(([v, u, l, c, ti]) => `<div class="kpi ${c}"${ti ? ` title="${esc(ti)}"` : ''}><b>${v}${u ? `<small>${u}</small>` : ''}</b><span>${l}</span></div>`).join('') + `</div></section>`;
- // 需要关注: 链盘 rows (已按 bad>warn>note 排), 取 bad/warn; 依据取 watch.why
- const why = {}; (d.watch || []).forEach(w => { why[w.t] = w.why; });
- const rows = (((F.链盘 || {}).rows) || []).filter(r => r.lvl === 'bad' || r.lvl === 'warn');
- h += `<section><div class="sechead"><h2>${t('ov.attention')}</h2><span class="meta">${t('ov.attention_n', { n: rows.length })}</span><span class="meta">${t('ov.ranked_by')}</span></div>`;
- // 融合面缺件时**如实说明**(用 /api/fleet 的 fus.gap 原文),否则读者看到两片空白,
- // 分不清"没有需要关注的台"与"这一面的数据没到位"(2026-09-19 用户报"下面没有计算输出")。
- if (F.gap) h += `<div class="card mb" style="margin-bottom:10px"><p class="small" style="color:var(--warning)">${dv(F.gap)}</p><p class="small muted">${t('ov.fus_gap_hint')}</p></div>`;
- h += `<div class="cards">`;
- rows.forEach(r => {
- const st = ST[r.ostate] || (r.lvl === 'bad' ? 'bad' : 'warn');
- const stuckKey = STEP[r.stuck] || String(r.stuck || '').toLowerCase();
- const stuckStep = stuckKey === 'evidence' ? t('step.site_records') : t('step.' + (stuckKey || 'evidence'));
- const stuck = r.stuck ? t('step.stuck_at', { step: stuckStep }) : '';
- const nxt = dt(r.next);
- // ★2026-09-22 实逮(用户测试 2): 「需要关注」的行来自**融合面链盘**(d.fus.链盘.rows),
- // 而依据过去只从 SCADA 的 d.watch 取 —— 两边机组集合不一致时 why[r.t] 就是空 ⇒
- // 「查看完整依据」展开是**空白**(实测 WTG09 齿轮箱 bad 台:watch 里没有它)。
- // 修法: 缺依据时按**本行自己的字段**组句(部件/链路进度/卡在哪一步/证据源),永不空。
- const evTxt = (why[r.t] || '').trim() || t('ov.basis_fusion', {
- part: dv(r.part || '—'),
- done: (r.done == null ? '—' : r.done),
- n: ((r.steps || []).length || '—'),
- stuck: r.stuck ? stuckStep : t('ov.all_steps_done'),
- srcs: (r.srcs || []).join(' / ') || t('ov.no_srcs'),
- });
- h += `<div class="card" id="u-${r.t}" style="--sev:var(--${{ bad: 'critical', warn: 'warning', unk: 'unknown', ok: 'good', ex: 'excellent' }[st]})">
- <div class="head"><a class="unit" href="/turbine/${r.t}">${unitNo(r.t)}</a><span class="sys">${dv(r.part || '')}</span>${stateChip(r.ostate || (r.lvl === 'bad' ? '危险' : '报警'))}</div>
- <details class="evidence-detail"><summary>${t('ov.evidence')}</summary><div class="why">${dv(evTxt)}</div></details>
- <div class="evid">${tierChip(r.estate)}${r.srcs && r.srcs.length ? `<span class="rv">${r.srcs.length} sources</span>` : ''}</div>
- ${stuck || nxt ? `<div class="caveat"><b>${t('ov.caveat')}</b>${esc(stuck)}${stuck && nxt ? ' — ' : ''}${esc(nxt)}</div>` : ''}</div>`;
- });
- h += `</div></section>`;
- // 全场状态条
- const mat = F.矩阵 || [];
- const hot = new Set(rows.map(r => r.t));
- h += `<section><div class="sechead"><h2>${t('ov.strip')}</h2><span class="meta">${t('ov.strip_hint')}</span>
- <div class="legend">${['bad', 'warn', 'unk', 'ok', 'ex'].map(k => `<span><i style="background:var(--${{ bad: 'critical', warn: 'warning', unk: 'unknown', ok: 'good', ex: 'excellent' }[k]})"></i>${t(stKey(k))}</span>`).join('')}</div></div>
- <div class="strip" style="--n:${mat.length || 38}"><div class="cells">`
- + (mat.length ? mat.map(r => { const k = unitState(r); return `<div class="cell ${k} ${hot.has(r.turbine) ? 'hot' : ''}" data-u="${r.turbine}" title="${unitNo(r.turbine)} ${t(stKey(k))}"></div>`; }).join('')
- : `<div class="quiet" style="padding:10px">${esc(F.gap || t('ov.fus_gap_hint'))}</div>`)
- + `</div><div class="nums">` + mat.map(r => `<span class="${hot.has(r.turbine) ? 'hot' : ''}">${unitNo(r.turbine).replace('#', '')}</span>`).join('') + `</div>`
- + `<div class="quiet">${rows.length ? t('ov.quiet', { n: Math.max(0, (mat.length || 38) - rows.length) }) : t('ov.quiet_all', { n: mat.length || 38 })}</div></div></section>`;
- // 九系统
- const dist = d.sysdist || {};
- h += `<section><div class="sechead"><h2>${t('ov.systems')}</h2><span class="meta">${t('ov.systems_hint')}</span></div><div class="systems">`
- + SYS9.map(s => { const c = dist[s] || {}; const n = Object.values(c).reduce((a, b) => a + b, 0) || 1;
- const seg = [['危险', 'critical'], ['报警', 'warning'], ['不可判', 'unknown'], ['良好', 'good'], ['优秀', 'excellent']].filter(([k]) => c[k]);
- return `<div class="sysc"><div class="t"><b>${sysName(s)}</b><a href="#tab=component&sys=${encodeURIComponent(sysSlug(s))}">${t('ui.open')}</a></div>
- <div class="bar">${seg.map(([k, v]) => `<i style="width:${(c[k] / n * 100).toFixed(1)}%;background:var(--${v})"></i>`).join('')}</div>
- <div class="cnt">${seg.map(([k, v]) => `<span><i style="background:var(--${v})"></i>${c[k]}</span>`).join('')}</div></div>`; }).join('')
- + `</div></section>`;
- return h;
- }
- // ── 原文数据: 公网英文不展示中文原文, 未覆盖项用可审核占位符标明。 ──
- let VERB = 0;
- function dv(s) {
- s = displayText(s);
- s = s == null ? '' : String(s);
- if (!EN || !CJK.test(s)) return esc(s);
- VERB++; return `<span class="review-required" title="${esc(t('ui.verbatim'))}">${REVIEW_REQUIRED}</span>`;
- }
- function askStepText(x) {
- let s = '';
- if (x && typeof x === 'object') s = [x.k, x.v].filter(v => v != null && String(v)).join(' · ');
- else s = String(x == null ? '' : x);
- if (!EN) return dv(s);
- s = s
- .replace(/启动/g, 'Starting')
- .replace(/预取/g, 'Prefetch')
- .replace(/推理/g, 'Reasoning')
- .replace(/取证/g, 'Evidence retrieval')
- .replace(/成文/g, 'Drafting')
- .replace(/校闸/g, 'Grounding gate')
- .replace(/交叉审核/g, 'Cross-review')
- .replace(/完成/g, 'Done')
- .replace(/失败/g, 'Failed')
- .replace(/第\s*(\d+\s*\/\s*\d+)\s*轮/g, (_, n) => 'round ' + n.replace(/\s+/g, ''))
- .replace(/\d{1,2}\s*一站式证据包/g, 'focus-unit evidence pack')
- .replace(/闭环与在升证据\s*(\d*)\s*条/g, (_, n) => 'closure and trend evidence' + (n ? `: ${n} records` : ''))
- .replace(/全场判级\s*(\d*)\s*台/g, (_, n) => 'fleet ratings' + (n ? `: ${n} units` : ''))
- .replace(/换模型复核中/g, 'reviewing with another model');
- if (CJK.test(s)) s = 'Working';
- return esc(s);
- }
- function recordValue(obj, key) {
- const value = obj[key], original = obj._original && obj._original[key] || (RECORD_EN[value] ? value : null);
- return dv(value);
- }
- const verbNote = () => (EN && VERB) ? `<p class="small muted" style="margin-top:8px">${t('ui.verbatim_note', { n: VERB })}</p>` : '';
- // ── 检修助手: 三窗并列 (抢修 / 预防性维护 / 故障树), 各自输入与结果 ──
- const SYSOPT = ['变桨', '偏航', '主轴承', '齿轮箱', '发电机', '变流器', '塔架与基础', '主控与传感网', '叶片与叶根'];
- const HOT = ['3225', '3130', '13902', '10105', '3224'];
- const ASST = { fault: '', prev: 'pitch', tree: 'pitch', out: { fault: '', prev: '', tree: '' } };
- function assistant() {
- const sel = (id) => `<select id="asstIn_${id}" style="flex:1 1 200px">${SYSOPT.map(s => `<option value="${sysSlug(s)}" ${ASST[id] === sysSlug(s) ? 'selected' : ''}>${sysName(s)}</option>`).join('')}</select>`;
- const btn = (id) => `<button class="btn" data-asst="${id}">${t('ui.query')}</button>`;
- const row = 'display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:8px';
- return `<section>
- <div class="eyebrow">${t('asst.head')} — ${t('asst.head_note')}</div>
- <div class="panel mb"><h3>${t('asst.corrective')} <span class="small muted" style="font-weight:400">${t('asst.corrective_flow')}</span></h3>
- <div style="${row}"><input id="asstIn_fault" placeholder="${esc(t('asst.code_placeholder'))}" value="${esc(ASST.fault)}" style="flex:1 1 220px">${btn('fault')}
- <span class="small muted">${t('asst.common')} ${HOT.map(c => `<a href="#" data-code="${c}">${c}</a>`).join(' · ')}</span></div>
- <div id="asstOut_fault" style="margin-top:10px">${ASST.out.fault}</div></div>
- <div class="panel mb"><h3>${t('asst.preventive')} <span class="small muted" style="font-weight:400">${t('asst.preventive_flow')}</span></h3>
- <div style="${row}">${sel('prev')}${btn('prev')}</div>
- <div id="asstOut_prev" style="margin-top:10px">${ASST.out.prev}</div></div>
- <div class="panel mb"><h3>${t('asst.faulttree')} <span class="small muted" style="font-weight:400">${t('asst.faulttree_flow')}</span></h3>
- <div style="${row}">${sel('tree')}${btn('tree')}</div>
- <div id="asstOut_tree" style="margin-top:10px">${ASST.out.tree}</div></div></section>`;
- }
- function bindAssistant() {
- document.querySelectorAll('[data-asst]').forEach(b => b.addEventListener('click', () => asstGo(b.dataset.asst)));
- document.querySelectorAll('[data-code]').forEach(a => a.addEventListener('click', e => { e.preventDefault(); document.getElementById('asstIn_fault').value = a.dataset.code; asstGo('fault'); }));
- const inp = document.getElementById('asstIn_fault'); if (inp) inp.addEventListener('keydown', e => { if (e.key === 'Enter') asstGo('fault'); });
- }
- async function asstGo(id) {
- const out = document.getElementById('asstOut_' + id), el = document.getElementById('asstIn_' + id);
- if (!out || !el) return;
- ASST[id] = el.value.trim();
- const v = ASST[id] || (id === 'fault' ? '' : 'pitch');
- const apiValue = id === 'fault' ? v : sysFromSlug(v);
- const qs = id === 'fault' ? `kind=fault&code=${encodeURIComponent(apiValue)}` : `kind=${id}&system=${encodeURIComponent(apiValue)}`;
- out.innerHTML = `<p class="small muted">${t('asst.querying')}</p>`;
- let d;
- try { d = await (await fetch(`/api/ont_chain?${qs}${EN ? '&lang=en&keep=1' : ''}`)).json(); }
- catch (e) { out.innerHTML = `<p class="small" style="color:var(--critical)">${esc(t('asst.query_failed', { e: e.message }))}</p>`; return; }
- VERB = 0;
- const x = d.data;
- out.innerHTML = (id === 'fault' && x) ? faultCards(x) : (id === 'tree' && x) ? treeCards(x) : (id === 'prev' && x) ? prevCards(x)
- : `<pre class="small" style="white-space:pre-wrap;margin:0">${dv(d.text || d.err || '')}</pre>`;
- out.innerHTML += verbNote();
- ASST.out[id] = out.innerHTML;
- }
- const box = 'border:1px solid var(--line);border-left:3px solid var(--accent);border-radius:8px;padding:11px 13px';
- function faultCards(x) {
- const sev = x.n_site >= 1000 ? 'var(--critical)' : x.n_site >= 100 ? 'var(--warning)' : 'var(--muted)';
- const nm = (x.name == null || /^(nan|none|null)$/i.test(String(x.name).trim()) || !String(x.name).trim()) ? t('asst.no_name') : dv(x.name);
- let h = `<div class="panel mb"><h3 style="margin:0">${esc(x.code)} · ${nm}</h3>
- <p class="small muted" style="margin-top:5px">${t('asst.identity', { sys: x.system ? sysName(x.system) : t('asst.unassigned'), n: `<b style="color:${sev}">${fmt(x.n_site)}</b>` })}${x.alarm_type ? ` · ${t('asst.alarm_type', { t: esc(x.alarm_type) })}` : ''}</p>`;
- if (x.failmodes && x.failmodes.length) h += `<div style="margin-top:10px"><b class="small muted">${t('asst.failmodes')}</b>`
- + x.failmodes.map(f => `<div class="small" style="margin-top:5px">· ${dv(f.name)} <span class="muted">(${t('asst.fm_counts', { a: fmt(f.n_alarm), w: fmt(f.n_wo) })})</span></div>`).join('') + `</div>`;
- h += `</div>`;
- if (x.steps && x.steps.length) {
- h += `<div class="panel mb"><h3>${t('asst.steps', { n: x.steps.length })} <span class="small muted" style="font-weight:400">${dv(x.wi_source || '')}</span></h3>
- <p class="small muted">${t('asst.steps_note')}</p>
- <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:10px;margin-top:10px">`
- + x.steps.map(st => `<div style="${box}">
- <div class="small" style="color:var(--accent-deep);font-weight:600">${t('asst.step_n', { n: st.序 })}${st.类别 ? ` · ${dv(st.类别)}` : ''}${st.qty ? ` · ${t('asst.qty', { q: dv(st.qty) })}` : ''}</div>
- <div style="margin-top:5px;line-height:1.65">${recordValue(st, '步骤')}</div>
- ${(st.tags && st.tags.length) ? `<div class="small" style="margin-top:7px"><b class="muted">${t('asst.tags')}</b> ${st.tags.map(g => `<code style="border:1px solid var(--line);border-radius:4px;padding:1px 6px;margin-right:5px">${esc(g)}</code>`).join('')}</div>` : ''}
- ${(st.nodes && st.nodes.length) ? `<div class="small" style="margin-top:7px"><b class="muted">${t('asst.nodes', { c: dv(st.circuit || '') })}</b> <span class="muted">(${t('asst.nodes_note')})</span>
- <div class="tblwrap"><table style="margin-top:3px"><tr><th style="width:38%">${t('asst.node')}</th><th>${t('asst.check')}</th></tr>
- ${st.nodes.map(n => `<tr><td class="small">${dv(n.name)}</td><td class="small">${recordValue(n, 'check')}</td></tr>`).join('')}</table></div>
- ${(st.known_tags && st.known_tags.length) ? `<div class="muted" style="margin-top:4px">${t('asst.known_tags')} ${st.known_tags.map(k => `<code style="margin-right:5px">${esc(k.tag)}</code>`).join('')}</div>` : ''}</div>` : ''}
- ${(st.specs && st.specs.length) ? `<div class="small" style="margin-top:7px"><b class="muted">${t('asst.specs')}</b>
- <div class="tblwrap"><table style="margin-top:3px"><tr><th style="width:64px">${t('asst.spec_no')}</th><th>${t('asst.spec_name')}</th><th class="r" style="width:74px">${t('asst.spec_val')}</th><th class="r" style="width:96px">${t('asst.spec_range')}</th></tr>
- ${st.specs.map(sp => `<tr><td class="small">${esc(sp.no)}</td><td class="small">${dv(sp.text)}</td><td class="small r">${sp.val != null ? esc(sp.val) + esc(sp.unit || '') : '—'}</td><td class="small r">${sp.lo != null ? `${esc(sp.lo)} ~ ${esc(sp.hi)}` : '—'}</td></tr>`).join('')}</table></div></div>` : ''}
- ${(st.drawings && st.drawings.length) ? `<div class="small" style="margin-top:7px"><b class="muted">${t('asst.drawings')}</b>${st.drawings.map(dw => `<div class="muted" style="margin-top:2px">· ${recordValue(dw, 'name')}</div>`).join('')}</div>` : ''}
- </div>`).join('') + `</div></div>`;
- } else h += `<div class="panel mb"><p class="small">${t('asst.no_steps')}</p></div>`;
- if (x.workorders && x.workorders.length) h += `<div class="panel mb"><h3>${t('asst.wo_head', { sys: x.system ? sysName(x.system) : t('asst.unassigned'), n: fmt(x.wo_total) })}</h3>
- <div class="tblwrap"><table style="margin-top:8px"><tr><th style="width:92px">${t('ui.date')}</th><th style="width:64px">${t('ui.unit')}</th><th>${t('asst.wo_fault')}</th><th style="width:140px">${t('asst.wo_action')}</th></tr>
- ${x.workorders.map(w => `<tr><td class="small">${esc(w.date)}</td><td class="small">${esc(unitNo(w.turbine))}</td><td class="small">${recordValue(w, 'name')}</td><td class="small">${dv(w.action)} ${dv(w.part)}</td></tr>`).join('')}</table></div>
- <p class="small muted" style="margin-top:6px">${t('asst.wo_note')}</p></div>`;
- return h;
- }
- function treeCards(x) {
- const nodes = x.nodes || [];
- if (!nodes.length) return `<p class="small muted">${t('asst.tree_empty')}</p>`;
- const maxE = Math.max(1, ...nodes.map(n => n.n_alarm + n.n_wo * 10));
- let h = `<div class="panel mb"><h3>${t('asst.tree_head', { sys: sysName(x.system), n: nodes.length })}</h3><p class="small muted">${t('asst.tree_note')}</p></div>`;
- nodes.forEach(n => {
- const w = Math.max(3, (n.n_alarm + n.n_wo * 10) / maxE * 100);
- const col = n.n_wo >= 5 ? 'var(--critical)' : n.n_alarm >= 100 ? 'var(--warning)' : 'var(--accent)';
- h += `<div class="panel mb"><div style="flex:1;min-width:200px"><b>${dv(n.name)}</b>
- <div style="height:7px;border-radius:3px;background:${col};width:${w.toFixed(0)}%;margin-top:5px;max-width:100%"></div>
- <div class="small muted" style="margin-top:4px">${t('asst.tree_counts', { a: fmt(n.n_alarm), w: fmt(n.n_wo) })}${n.codes && n.codes.length ? ` · ${t('asst.tree_codes', { c: n.codes.map(esc).join(', ') })}` : ''}</div></div>`;
- if (n.causes && n.causes.length) h += `<div style="margin-top:10px;padding-left:14px;border-left:2px dashed var(--line)"><div class="small muted" style="margin-bottom:6px">${t('asst.causes')}</div>
- <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:10px">`
- + n.causes.map(c => `<div style="border:1px solid var(--line);border-radius:8px;padding:10px 12px"><div><b class="small">${dv(c.name)}</b> <span class="small muted">${t('asst.cause_counts', { r: fmt(c.n_ref), w: fmt(c.n_wo), c: fmt(c.n_codes) })}</span></div>
- ${(c.steps || []).map(s => `<div class="small muted" style="margin-top:5px;line-height:1.6">· ${dv(s)}</div>`).join('')}</div>`).join('') + `</div></div>`;
- else h += `<p class="small muted" style="margin-top:8px">${t('asst.no_causes')}</p>`;
- h += `</div>`;
- });
- return h;
- }
- function prevCards(x) {
- let h = `<div class="panel mb"><h3>${t('asst.prev_head', { sys: sysName(x.system), f: x.n_fm, n: x.n_task })}</h3></div>`;
- if (x.failmodes && x.failmodes.length) h += `<div class="panel mb"><b class="small muted">${t('asst.prev_fm')}</b>`
- + x.failmodes.slice(0, 8).map(f => `<div class="small" style="margin-top:5px">· ${dv(f.name)} <span class="muted">— ${t('asst.tree_counts', { a: fmt(f.n_alarm), w: fmt(f.n_wo) })}</span></div>`).join('') + `</div>`;
- if (x.tasks && x.tasks.length) h += `<div class="panel mb"><b class="small muted">${t('asst.prev_tasks')}</b>
- <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:10px;margin-top:8px">`
- + x.tasks.map(k => `<div style="${box}"><div>${dv(k.name)}</div><div class="small muted" style="margin-top:4px">${k.interval ? t('asst.interval', { i: dv(k.interval) }) : ''}${k.wi ? ` · ${t('asst.wi_steps', { n: k.wi.n_step })}` : ''}</div>
- ${k.wi && k.wi.steps.length ? `<div class="small" style="margin-top:6px">${k.wi.steps.map(s => `<div style="margin-top:3px;line-height:1.55">${esc(s.序)}. ${recordValue(s, '步骤')}</div>`).join('')}</div>` : ''}</div>`).join('') + `</div></div>`;
- else h += `<p class="small muted">${t('asst.prev_empty')}</p>`;
- return h;
- }
- // ── 检修决策 · 本体问答 (模型参与的板块) ──
- // API: POST /api/ask {q, model, cat(中文类名), xrev} → GET /api/ask_status 轮询 (running/done/error)
- // 模型清单唯一来源 = /api/ask_models (本地与云端同形); 档位说明按 id 查语言包, 查不到按"会外发"处理。
- const CATS = [['equip', '设备类'], ['sched', '检修排程决策'], ['loss', '损失分析'], ['risk', '安全风险预测']];
- const MDL_LOC = { 'qwen3:8b': 'local', 'qwen3.8:27b': 'local', 'qwen3:30b-a3b': 'local', 'deepseek-r1:14b': 'local', 'deepseek-r1:32b': 'local', 'deepseek-chat': 'cloud', 'kimi-fast': 'cloud', 'qwen-plus': 'cloud', 'deepseek-v4-pro': 'cloud' };
- const ASK = { cat: 'equip', models: null, model: '', q: '', out: '', timer: null };
- const mdlKey = (id, f) => (L['mdl.' + id + '.' + f] != null) ? t('mdl.' + id + '.' + f) : null;
- const mdlLabel = (id) => (mdlKey(id, 'nm') || id) + (MDL_LOC[id] ? ' · ' + t('mdl.' + MDL_LOC[id]) : '') + (mdlKey(id, 't') ? ' · ' + mdlKey(id, 't') : '');
- const mdlNote = (id) => !MDL_LOC[id] ? `<span style="color:var(--warning)">${t('mdl.unknown_warn')}</span>`
- : MDL_LOC[id] === 'local' ? t('mdl.zero_outbound') : `<span style="color:var(--warning)">${t('mdl.outbound_warn')}</span>`;
- function decision() {
- const cat = ASK.cat, catName = t('cat.' + cat);
- const pills = CATS.map(([id]) => `<button class="pill ${id === cat ? 'on' : ''}" data-cat="${id}">${t('cat.' + id)}</button>`).join('');
- const qs = [1, 2, 3].map(i => `<div class="qitem" data-q="${esc(t('cat.' + cat + '.q' + i))}">${t('cat.' + cat + '.q' + i)}</div>`).join('');
- const models = ASK.models;
- const opts = models === null ? `<option value="">…</option>`
- : Object.keys(models).length ? Object.keys(models).map(k => `<option value="${esc(k)}" ${k === ASK.model ? 'selected' : ''}>${esc(mdlLabel(k))}</option>`).join('')
- : `<option value="">${window.LOCAL_AI_DOWN ? t('ask.local_down') : t('ask.no_models')}</option>`;
- return `<section>
- <div class="eyebrow">${t('dec.head')}</div>
- ${SNAP ? '' : `<p class="small muted">${t('dec.classic')} <a href="/?lang=${LANG}&win=${encodeURIComponent(WIN)}">${t('ui.open_classic')}</a></p>`}
- <div style="display:flex;gap:8px;margin:10px 0 12px;flex-wrap:wrap">${pills}</div>
- <div class="panel mb"><h3>${t('ask.common_q', { cat: catName })}</h3>
- <p class="small muted">${t('cat.' + cat + '.hint')}${EN ? '. ' : '。'}${t('ask.grounding')}</p>
- ${qs}
- <div style="display:flex;gap:8px;flex-wrap:wrap;margin:10px 0;align-items:center">
- <input id="askq" type="text" placeholder="${esc(t('ask.placeholder', { cat: catName }))}" value="${esc(ASK.q)}" style="flex:1;min-width:280px">
- <label class="small muted" for="askm">${t('ask.model')}</label>
- <select id="askm">${opts}</select>
- <span id="mdlnote" class="small">${ASK.model ? mdlNote(ASK.model) : ''}</span>
- <label class="small" style="cursor:pointer;white-space:nowrap" title="${esc(t('ask.xrev_title'))}"><input type="checkbox" id="askxrev"> ${t('ask.xrev')} <span class="muted">${t('ask.xrev_note')}</span></label>
- <button class="btn" id="askgo">${t('ask.go')}</button></div>
- <div id="askout" class="small">${ASK.out || t('ask.idle')}</div></div></section>`;
- }
- async function bindDecision() {
- document.querySelectorAll('[data-cat]').forEach(b => b.addEventListener('click', () => { ASK.cat = b.dataset.cat; ASK.q = (document.getElementById('askq') || {}).value || ''; render(); }));
- document.querySelectorAll('.qitem').forEach(q => q.addEventListener('click', () => { document.getElementById('askq').value = q.dataset.q; askGo(); }));
- document.getElementById('askgo').addEventListener('click', askGo);
- document.getElementById('askq').addEventListener('keydown', e => { if (e.key === 'Enter') askGo(); });
- document.getElementById('askm').addEventListener('change', e => { ASK.model = e.target.value; document.getElementById('mdlnote').innerHTML = mdlNote(ASK.model); });
- if (ASK.models === null) {
- try { const j = await (await fetch('/api/ask_models')).json(); ASK.models = j.models || {}; window.LOCAL_AI_DOWN = !!(j.offline && j.offline.local_ai_running === false); } catch (e) { ASK.models = {}; }
- const ks = Object.keys(ASK.models);
- if (!ASK.model || !ks.includes(ASK.model)) ASK.model = ks[0] || '';
- if (TAB === 'decision') render();
- return;
- }
- askPoll(true); // 页面打开时如已有推理在跑, 接上进度; 空闲则只刷一次
- }
- const askStatusUrl = () => '/api/ask_status' + (EN ? '?lang=en' : '?lang=zh');
- const answerRefCount = (text) => new Set([...String(text || '').matchAll(/\[\s*(?:object id|source|id|对象id|对象|来源|出处)?\s*[::]?\s*([a-z]+\/[A-Za-z0-9_/.\-]+)\s*\]/gi)].map(m => m[1])).size;
- // 契约引用匹配 (确定性规则, 不调模型): (a) 文本点名 claim_id; (b) 问题含条目 systems 名; (c) 问题含机组号 (WTG/tid/#号) 且条目标题点名同号. 定论/准定论 优先, 最多 5 条.
- async function askRefs(q, answer) {
- try {
- if (FACTS === null) { try { const r = await fetch('/api/facts'); FACTS = r.ok ? await r.json() : false; } catch (e) { FACTS = false; } }
- if (!FACTS || !FACTS.cards) return '';
- const text = q + '\n' + answer, tids = new Set([...q.matchAll(/(?:WTG|tid|#)\s*0*(\d{1,2})(?!\d)|(\d{1,2})\s*[号#]/g)].map(m => String(Number(m[1] || m[2]))));
- const rank = { '定论': 0, '准定论·预警': 1, '候选': 2, '参考': 3, 'INSUFFICIENT': 4, '撤回': 5 };
- const hits = FACTS.cards.map(c => { const why = [];
- if (text.includes(c.claim_id)) why.push('id');
- (c.systems || []).forEach(sname => { if (sname && q.includes(sname)) why.push(sname); });
- const ct = [...(c.title || '').matchAll(/(?:WTG|tid)\s*0*(\d{1,2})(?!\d)/g)].map(m => String(Number(m[1])));
- if (ct.some(n => tids.has(n))) why.push('tid');
- return why.length ? { c, why } : null; }).filter(Boolean).sort((a, b) => (rank[a.c.verdict] ?? 9) - (rank[b.c.verdict] ?? 9) || b.why.length - a.why.length).slice(0, 5);
- const sha = (FACTS.contract_sha256 || '').slice(0, 16);
- if (!hits.length) return `<div class="small muted" style="margin-top:8px" data-contract="${esc(sha)}">${t('ask.refs.none', { sha })}</div>`;
- const cites = await Promise.all(hits.map(async h => { try { const r = await fetch('/api/facts/claim?id=' + encodeURIComponent(h.c.claim_id)); const j = r.ok ? await r.json() : null; return (j && j.claim && j.claim.cite) || ''; } catch (e) { return ''; } }));
- return `<div class="small" style="margin-top:8px" data-contract="${esc(sha)}"><b>${t('ask.refs.title', { n: hits.length, sha })}</b><ul style="margin:4px 0 0 18px;line-height:1.7">${hits.map((h, i) => `<li><code>${esc(h.c.claim_id)}</code> · ${esc(h.c.verdict)} · ${esc((h.c.title || '').slice(0, 90))}${cites[i] ? `<div class="muted">${esc(cites[i])}</div>` : ''}</li>`).join('')}</ul></div>`;
- } catch (e) { return ''; }
- }
- async function askGo() {
- const q = document.getElementById('askq').value, out = document.getElementById('askout');
- ASK.q = q;
- if (!q.trim()) { out.textContent = t('ask.empty'); return; }
- const body = { q, display_q: q, lang: EN ? 'en' : 'zh', model: ASK.model, cat: (CATS.find(([id]) => id === ASK.cat) || [])[1] || '', xrev: !!(document.getElementById('askxrev') || {}).checked };
- let d;
- try { const r = await fetch('/api/ask', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!r.ok || !/json/.test(r.headers.get('content-type') || '')) { out.innerHTML = t('ask.offline'); return; } d = await r.json(); }
- catch (e) { out.innerHTML = t('ask.offline'); return; }
- if (d.err) { out.innerHTML = dv(d.err); return; }
- askPoll();
- }
- async function askPoll(once) {
- const el = document.getElementById('askout'); if (!el) return;
- // 离线快照下 /api/ask_status 回的是静态站的 404 页 (非 JSON) —— 判成"需连接系统", 不报解析错
- let s; try { const r = await fetch(askStatusUrl()); if (!r.ok || !/json/.test(r.headers.get('content-type') || '')) { el.innerHTML = t('ask.offline'); ASK.out = el.innerHTML; return; } s = await r.json(); } catch (e) { el.innerHTML = t('ask.offline'); ASK.out = el.innerHTML; return; }
- // 页面打开 (once) 时只接"正在跑"的进度: 服务端 ASK 是全局态, done/error 是上一位提问者的结果 ——
- // 云上实逮: 英文页一打开回放了 19 小时前别人的中文答案。本会话没提问就不显示任何答案。
- if (once && s.state !== 'running' && s.state !== 'partial') { el.innerHTML = t('ask.idle'); ASK.out = ''; return; }
- if (s.state === 'partial') {
- // 先快后准 (用户裁 2026-09-07): 初答 (契约直出) 先显示, 继续轮询, 27B 复核完成后整段替换
- el.innerHTML = `<p><span class="badge warn">${t('ask.partial')}</span> ${t('ask.elapsed', { s: s.elapsed })} · <span style="color:var(--accent-deep)">${askStepText(s.step || '')}</span></p>
- <pre style="white-space:pre-wrap;background:var(--ground);padding:12px;border-radius:8px;border:1px dashed var(--line);font-family:inherit;margin:0">${esc(String(s.answer || ''))}</pre>`;
- clearTimeout(ASK.timer); ASK.timer = setTimeout(() => askPoll(), 2000);
- } else if (s.state === 'running') {
- const steps = s.steps || [];
- el.innerHTML = `<b>${t('ask.running')}</b> ${t('ask.elapsed', { s: s.elapsed })} · <span style="color:var(--accent-deep)">${askStepText(s.step || '')}</span>
- <div class="small muted" style="margin-top:5px">${esc(s.model || '')} · ${mdlNote(s.model)}</div>
- ${steps.length ? `<div class="small muted" style="margin-top:4px">${steps.slice(-6).map(askStepText).join(' → ')}</div>` : ''}
- <div class="small muted" style="margin-top:4px">${t('ask.question', { q: dv(String((EN ? ASK.q : s.q) || '').slice(0, 90)) })}</div>`;
- clearTimeout(ASK.timer); ASK.timer = setTimeout(() => askPoll(), 2000);
- } else if (s.state === 'done') {
- const answer = String(s.answer || '');
- if (EN && CJK.test(answer)) {
- el.innerHTML = `<p><span class="badge warn">${t('ask.en_qa_failed')}</span></p>`;
- ASK.out = el.innerHTML;
- return;
- }
- const v = s.verify || {}, n = Math.max(v.引用id数 || 0, answerRefCount(answer));
- const badge = (v.全通过 ? `<span class="badge ok">${t('ask.verify_ok', { n })}</span>` : `<span class="badge warn">${t('ask.verify_bad', { n })}</span>`) + (s.refined_from ? ` <span class="badge ok">${t('ask.refined', { m: esc(s.refined_from) })}</span>` : '');
- const R = s.review;
- const rv = R ? `<div class="panel mb" style="border-left:3px solid ${(R.issues || []).length ? 'var(--warning)' : 'var(--good)'};margin-top:10px">
- <h3>${t('ask.review', { m: esc(R.model || '') })} <span class="small" style="font-weight:400;color:${(R.issues || []).length ? 'var(--warning)' : 'var(--good)'}">${dv(R.verdict || '')}</span></h3>
- <p class="small muted">${t('ask.review_note')}</p>
- ${(R.issues || []).map(i => `<div class="small" style="padding:5px 0;border-top:1px solid var(--line)"><span class="badge warn">${dv(i.type)}</span> <b>「${dv(i.quote)}」</b> <span class="muted">${dv(i.why)}</span></div>`).join('')}
- ${R.err ? `<p class="small" style="color:var(--critical)">${t('ask.review_err', { e: dv(R.err) })}</p>` : ''}</div>` : '';
- el.innerHTML = `<p>${badge} · ${t('ask.done_meta', { m: esc(s.model || ''), s: s.elapsed, n: (s.steps || []).length })}</p>
- <pre style="white-space:pre-wrap;background:var(--ground);padding:12px;border-radius:8px;border:1px solid var(--line);font-family:inherit;margin:0">${esc(answer)}</pre>${rv}`;
- // 契约引用 (用户批 2026-09-06 "问答接 qa_refs"): 回答落地后按 问题/回答 匹配契约条目, 附 qa_refs 引文; 无匹配明说"未被契约背书", 不伪造
- askRefs(String((EN ? ASK.q : s.q) || ASK.q || ''), answer).then(h => { if (!h || el.dataset.refsFor === answer.slice(0, 40)) return; const d = document.createElement('div'); d.innerHTML = h; el.appendChild(d); el.dataset.refsFor = answer.slice(0, 40); ASK.out = el.innerHTML; });
- } else if (s.state === 'error') {
- el.innerHTML = t('ask.error', { e: dv(s.err || '') });
- } else if (!once) {
- el.innerHTML = t('ask.idle');
- }
- ASK.out = el.innerHTML;
- }
- // ── 汇报定制: 组件库 → 画布 → 预览 (一页纸) → 模型编排 / 导出 ──
- // API: GET /api/rpt_compose?need&canvas&model → {blocks, narrative, model, secs, ungrounded, err}
- // GET /api/rpt_export?fmt&blocks&aud&win&narr → .docx/.xlsx
- const CH = window.__charts({ t, fmt, esc, dv, EN, displayText, verb: () => { VERB++; } });
- const RPT_GROUPS = ['gen', 'rel', 'flt', 'unit', 'end'];
- const RPT_BLOCKS = { kpi: 'gen', water: 'gen', emon: 'gen', blame: 'gen', ram: 'rel', parts: 'rel', attrib: 'rel', pareto: 'flt', quad: 'flt', frate: 'flt', units: 'unit', chain: 'unit', loop: 'unit', plan: 'end', caliber: 'end', narr: 'end' };
- const RPT_ICON = { kpi: '▦', water: '▤', emon: '▥', blame: '◧', ram: '◈', parts: '▤', attrib: '▬', pareto: '▮', quad: '◇', frate: '▪', units: '☰', chain: '⛓', loop: '✓', plan: '✎', caliber: '⚑', narr: '✍' };
- const RPT_PRESETS = { month: ['kpi', 'water', 'emon', 'blame', 'ram', 'pareto', 'units', 'plan', 'caliber'], week: ['kpi', 'frate', 'units', 'plan', 'caliber'], topic: ['units', 'chain', 'ram', 'loop', 'caliber'], year: ['kpi', 'water', 'emon', 'ram', 'loop', 'caliber'], blank: [] };
- const RPT = { canvas: [], aud: 'in', need: '', model: '', models: null, compose: null, narr: null, out: '' };
- // 契约生成 (用户批 2026-09-06): 结论/时间窗只经 /api/facts (derived/detail_cards.json) 进纸, 不手写. FACTS = null 未取 / false 无 / {…}.
- let FACTS = null;
- function rptFacts() { if (!FACTS || !FACTS.cards) return ''; const cards = FACTS.cards.filter(c => c.verdict === '定论' || c.verdict === '准定论·预警'); const rows = cards.map(c => `<tr><td><code>${esc(c.claim_id)}</code></td><td>${esc(c.verdict)}</td><td>${esc(c.title)}</td><td>${esc((c.systems || []).join(', ') || '—')}</td><td>${esc(c.aggregation || '—')}</td></tr>`).join('');
- return `<div class="rpt-blk" data-contract="${esc(FACTS.contract_sha256 || '')}"><div class="blk-ti">${t('rpt.facts.title')} <span class="small muted">${t('rpt.facts.sub', { n: cards.length, sha: (FACTS.contract_sha256 || '').slice(0, 16) })}</span></div><div style="overflow:auto"><table class="tbl"><tr><th>claim</th><th>${t('rpt.facts.verdict')}</th><th>${t('rpt.facts.text')}</th><th>${t('rpt.facts.sys')}</th><th>${t('rpt.facts.agg')}</th></tr>${rows}</table></div></div>`; }
- // 判别值 (API 按 _KEEP_ZH 原样保留中文, 前端拿来做判别) —— 显示时查语言包。
- // 查不到才回落原文标注: 振动线的技术速记 (「2R倒谱自基线红警」) 不可枚举也不该猜译。
- const enumWord = (p) => { const k = String(p || ''); for (const pre of ['attr.', 'vp.']) if (L[pre + k] != null) return t(pre + k); return null; };
- const partName = (p) => SYS_KEY[p] ? sysName(p) : (enumWord(p) ? esc(enumWord(p)) : dv(p));
- const partPlain = (p) => SYS_KEY[p] ? sysName(p) : (enumWord(p) || displayText(p));
- function rptK(v, u, lab, note, cls) { return `<div class="kpi ${cls || ''}"><b>${v}<small>${u}</small></b><span>${lab}<br><span style="opacity:.8">${note || ''}</span></span></div>`; }
- function rptBlock(k, d) {
- const E = (d.control && d.control.energy) || {}, em = E.monthly || [], R = (d.rel && !d.rel.err) ? d.rel : null, M = (d.m8 && !d.m8.err) ? d.m8.mtbf : null, F = d.faults || {}, FU = d.fus || {}, BD = FU.链盘 || {};
- const wrap = (ti, b, src) => `<div class="panel mb"><h3>${ti}</h3>${b}${src ? `<p class="small muted" style="margin-top:8px;padding-top:7px;border-top:1px solid var(--line)">${t('rpt.caliber_lbl')}${src}</p>` : ''}</div>`;
- if (k === 'kpi') { const last = em[em.length - 1] || {}, prev = em[em.length - 2] || {}, ok = (last.cov || 0) >= .5 && (prev.cov || 0) >= .5, dl = (a, b) => b ? ((a - b) / b * 100) : 0, mom = dl(last.act, prev.act);
- return `<div class="kpis" style="margin-bottom:14px">${rptK(fmt(E.act), 'MWh', t('rpt.kpi.act'), t('rpt.kpi.theo', { v: fmt(E.theo) }) + ' | ' + (ok ? t('rpt.kpi.mom', { v: (mom >= 0 ? '+' : '') + mom.toFixed(0) }) : t('rpt.kpi.mom_na')))}
- ${rptK(fmt(E.eflh), 'h', t('rpt.kpi.eflh'), t('rpt.kpi.annual', { v: fmt(Math.round((E.eflh || 0) / Math.max(em.filter(x => x.cov >= .5).length, 1) * 12)) }))}
- ${rptK(fmt(d.control && d.control.avail, 1), '%', t('rpt.kpi.avail'), t('rpt.kpi.avail_n'), (d.control && d.control.avail >= 95) ? 'ok' : 'warn')}
- ${rptK(fmt(E.loss_pct, 1), '%', t('rpt.kpi.loss'), fmt(E.loss) + ' MWh', E.loss_pct >= 15 ? 'warn' : '')}
- ${rptK(M ? fmt(M.MTBF_h) : '—', 'h', t('rpt.kpi.mtbo'), t('rpt.kpi.mtbo_n', { n: M ? fmt(M.停机事件数) : '—', h: M ? M.MDT_h : '—' }))}
- ${rptK((BD.rows || []).length, t('unit.units'), t('rpt.kpi.action'), t('rpt.kpi.action_n', { a: (FU.kpi || {}).定论 || 0, b: (FU.kpi || {}).预警 || 0, c: (FU.kpi || {}).候选观察 || 0 }), (BD.rows || []).length > 20 ? 'warn' : '')}</div>`; }
- if (k === 'water') return wrap(t('rpt.water.title'), CH.waterfall(E) + `<p class="small muted" style="margin-top:6px">${t('rpt.water.note', { theo: fmt(E.theo), act: fmt(E.act), loss: fmt(E.loss), pct: E.loss_pct })} | ${E.cap_ok ? t('rpt.water.cap_ok', { cap: fmt(E.cap) }) : t('rpt.water.cap_bad', { cap: fmt(E.cap) })}</p>`, t('rpt.water.cal'));
- if (k === 'emon') return wrap(t('rpt.emon.title'), em.length ? `<div class="tblwrap"><table><tr><th>${t('rpt.emon.m')}</th><th class="r">${t('rpt.emon.act')}</th><th class="r">${t('rpt.emon.loss')}</th><th class="r">${t('rpt.emon.pct')}</th><th class="r">${t('rpt.emon.eflh')}</th><th>${t('rpt.emon.cov')}</th></tr>${em.map(x => `<tr><td>${x.m}</td><td class="r">${fmt(x.act)}</td><td class="r">${fmt(x.loss)}</td><td class="r">${x.loss_pct}%</td><td class="r">${x.eflh}</td><td class="small" style="color:${x.cov < .5 ? 'var(--warning)' : 'var(--muted)'}">${(x.cov * 100).toFixed(0)}%${x.cov < .5 ? t('rpt.emon.partial') : ''}</td></tr>`).join('')}</table></div>` : '—', t('rpt.emon.cal'));
- if (k === 'blame') return wrap(t('rpt.blame.title'), CH.blameBar(E) + `<p class="small muted" style="margin-top:8px">${t('rpt.blame.note')}<br><b>${t('rpt.blame.note2')}</b></p>`, t('rpt.blame.cal'));
- if (k === 'ram') return R ? wrap(t('rpt.ram.title'), CH.ramChart(R.表, partPlain) + `<p class="small muted" style="margin-top:6px">${t('rpt.ram.note', { a: R.设备类停机h, b: R.外部类停机h })}</p>`, t('rpt.ram.cal')) : '';
- if (k === 'parts') return R ? wrap(t('rpt.parts.title'), `<div class="tblwrap"><table><tr><th>${t('rpt.parts.part')}</th><th class="r">${t('rpt.parts.h')}</th><th class="r">${t('rpt.parts.ev')}</th><th class="r">${t('rpt.parts.mtbf')}</th><th class="r">${t('rpt.parts.mdt')}</th><th class="r">${t('rpt.parts.units')}</th></tr>${R.表.slice(0, 9).map(x => `<tr><td class="small">${partName(x.部件)}${x.外部 ? t('rpt.parts.ext') : ''}</td><td class="r">${x.停机时长h}</td><td class="r">${x.事件数}</td><td class="r">${x.MTBF_h || '—'}</td><td class="r">${x.MDT_h}</td><td class="r">${x.影响台数}</td></tr>`).join('')}</table></div>`, t('rpt.parts.cal')) : '';
- if (k === 'attrib') return R ? wrap(t('rpt.attrib.title'), CH.attribBar(R.表, R.总停机h) + `<p class="small muted" style="margin-top:6px">${t('rpt.attrib.note')}</p>`, '') : '';
- if (k === 'pareto') return wrap(t('rpt.pareto.title'), CH.paretoChart(F.pareto_n || [], t('unit.n'), ''), t('rpt.pareto.cal'));
- if (k === 'quad') return wrap(t('rpt.quad.title'), CH.quadChart(F.quad || []) + `<p class="small muted" style="margin-top:6px">${t('rpt.quad.note')}</p>`, t('rpt.quad.cal'));
- if (k === 'frate') return wrap(t('rpt.frate.title'), CH.rateChart(F.monthly || { months: [], vals: [] }) + `<p class="small muted" style="margin-top:6px">${t('rpt.frate.note')}</p>`, t('rpt.frate.cal'));
- if (k === 'units') { const rows = (BD.rows || []).slice(0, 14), stuck = Object.entries(BD.stuck || {}).map(([a, b]) => t('rpt.units.stuck', { n: b, s: t('step.' + (STEP[a] || 'evidence')) })).join(' · ');
- return wrap(t('rpt.units.title'), `<p class="small muted" style="margin-bottom:8px">${t('rpt.units.head', { n: (BD.rows || []).length, tot: (D.kpi || {}).全场 || 38 })}${stuck}</p><div class="tblwrap"><table><tr><th style="width:52px">${t('ui.unit')}</th><th style="width:80px">${t('rpt.units.state')}</th><th>${t('rpt.units.ev')}</th><th style="width:56px">${t('rpt.units.prog')}</th><th style="width:90px">${t('rpt.units.at')}</th></tr>
- ${rows.map(r => `<tr><td><b>${unitNo(r.t)}</b></td><td>${stateChip(r.ostate)}</td><td class="small muted">${r.part && r.part.indexOf('未收录') < 0 ? `<b style="color:var(--ink)">${dv(r.part)}</b> · ` : ''}${(r.srcs || []).map(dv).join('/') || '—'}${r.rising ? ` · <span style="color:var(--critical)">${t('rpt.units.rising', { r: r.rising.ratio })}</span>` : ''}</td><td class="r small">${r.done}/6</td><td class="small" style="color:var(--warning)">${r.stuck ? t('step.' + (STEP[r.stuck] || 'evidence')) : t('rpt.units.clear')}</td></tr>`).join('')}</table></div>`, t('rpt.units.cal')); }
- if (k === 'chain') return wrap(t('rpt.chain.title'), `<p class="small muted">${t('rpt.chain.note', { s: Object.entries(BD.stuck || {}).map(([a, b]) => t('rpt.units.stuck', { n: b, s: t('step.' + (STEP[a] || 'evidence')) })).join(', ') })}</p>`, t('rpt.chain.cal', { b: dv(BD.wo_bound || '') }));
- if (k === 'loop') { const cl = BD.closed || [];
- return wrap(t('rpt.loop.title'), `<div class="tblwrap"><table><tr><th>${t('ui.unit')}</th><th>${t('rpt.loop.comp')}</th><th>${t('rpt.loop.date')}</th><th class="r">${t('rpt.loop.pre')}</th><th class="r">${t('rpt.loop.post')}</th><th class="r">${t('rpt.loop.ratio')}</th></tr>${cl.map(x => `<tr><td><b>${unitNo(x.turbine)}</b></td><td class="small">${dv(x.component)}</td><td class="small">${esc(x.replace_date)}</td><td class="r">${x.pre}</td><td class="r">${x.post}</td><td class="r" style="color:var(--good)"><b>${x.ratio}×</b></td></tr>`).join('')}</table></div><p class="small muted" style="margin-top:6px">${t('rpt.loop.note')}</p>`, t('rpt.loop.cal')); }
- if (k === 'plan') return wrap(t('rpt.plan.title'), `<ol style="margin:0 0 0 20px;line-height:1.85"><li>${t('rpt.plan.1', { n: (BD.stuck || {}).机制 || 0 })}</li><li>${t('rpt.plan.2')}</li><li>${t('rpt.plan.3')}</li><li>${t('rpt.plan.4')}</li></ol>`, t('rpt.plan.cal'));
- if (k === 'narr') { const N = RPT.narr; if (!N || !N.text) return wrap(t('rpt.narr.title'), `<p class="small muted">${t('rpt.narr.empty')}</p>`, '');
- return wrap(t('rpt.narr.title'), `<p style="line-height:1.9;margin:0">${esc(N.text).split('\n').join('<br>')}</p>${(N.ungrounded || []).length ? `<p class="small" style="color:var(--warning);margin-top:8px">${t('rpt.narr.ungr', { v: N.ungrounded.map(esc).join(' / ') })}</p>` : ''}`, t('rpt.narr.cal', { m: esc(N.model), g: (N.ungrounded || []).length ? t('rpt.narr.g_n', { n: N.ungrounded.length }) : t('rpt.narr.g_ok') })); }
- if (k === 'caliber') { const part = F.partial || [];
- return wrap(t('rpt.cal.title'), `<ul style="margin:0 0 0 20px;line-height:1.85"><li><b>${t('rpt.cal.period')}</b> ${winName(d.win)}${part.length ? t('rpt.cal.partial', { m: part.join('/'), c: Math.round((F.cov_min || 0) * 100) }) : ''}</li><li><b>${t('rpt.cal.denom')}</b> ${t('rpt.cal.denom_d')}</li><li><b>${t('rpt.cal.stop')}</b> ${t('rpt.cal.stop_d')}</li><li><b>${t('rpt.cal.wo')}</b> ${t('rpt.cal.wo_d')}</li><li><b>${t('rpt.cal.rating')}</b> ${t('rpt.cal.rating_d')}</li>${RPT.aud === 'up' ? `<li><b>${t('rpt.cal.deid')}</b> ${t('rpt.cal.deid_d')}</li>` : ''}</ul>`, ''); }
- return '';
- }
- function report() {
- const d = D, FU = d.fus || {}, on = k => RPT.canvas.includes(k);
- const models = RPT.models, opts = models === null ? '<option value="">…</option>' : Object.keys(models).length ? Object.keys(models).map(k => `<option value="${esc(k)}" ${k === RPT.model ? 'selected' : ''}>${esc(mdlLabel(k))}</option>`).join('') : `<option value="">${window.LOCAL_AI_DOWN ? t('ask.local_down') : t('ask.no_models')}</option>`;
- const canCompose = RPT.compose === true && models && Object.keys(models).length;
- let h = `<section><div class="eyebrow">${t('rpt.eyebrow')}</div><div class="panel mb noprint">
- <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:10px"><span class="small" style="font-weight:600">${t('rpt.presets')}</span>
- ${Object.keys(RPT_PRESETS).map(k => `<button class="pill" data-preset="${k}">${t('rpt.preset.' + k)}</button>`).join('')}<span style="flex:1"></span>
- <select id="rptaud"><option value="in" ${RPT.aud === 'in' ? 'selected' : ''}>${t('rpt.aud_in')}</option><option value="up" ${RPT.aud === 'up' ? 'selected' : ''}>${t('rpt.aud_up')}</option></select>
- <button class="btn quiet" id="rptprint">${t('rpt.print')}</button><button class="btn quiet" data-export="docx">${t('rpt.export_docx')}</button><button class="btn quiet" data-export="xlsx">${t('rpt.export_xlsx')}</button>
- ${RPT.aud === 'up' ? `<span class="small" style="color:var(--warning);flex-basis:100%">${t('rpt.up_note')}</span>` : ''}</div>
- <div class="small" style="font-weight:600;margin-bottom:7px">${t('rpt.library')} <span style="font-weight:400;color:var(--muted)">${t('rpt.library_note', { n: RPT.canvas.length })}</span></div>
- <div style="display:grid;grid-template-columns:auto 1fr;gap:6px 12px;align-items:center">${RPT_GROUPS.map(g => `<div class="small muted" style="text-align:right;white-space:nowrap">${t('rpt.g.' + g)}</div><div style="display:flex;gap:6px;flex-wrap:wrap">${Object.keys(RPT_BLOCKS).filter(k => RPT_BLOCKS[k] === g).map(k => `<button class="pill ${on(k) ? 'on' : ''}" data-block="${k}" style="border-radius:7px;padding:5px 11px;font-size:12.5px;opacity:${on(k) ? 1 : .8}">${RPT_ICON[k]} ${t('rpt.b.' + k + '.nm')}${on(k) ? ' ✓' : ''}</button>`).join('')}</div>`).join('')}</div>
- <div id="rpthint" class="small muted" style="margin-top:9px;padding-top:8px;border-top:1px solid var(--line);min-height:19px">${t('rpt.hint_idle')}</div>
- <div style="margin-top:11px;padding-top:10px;border-top:1px solid var(--line)"><div class="small" style="font-weight:600;margin-bottom:6px">${t('rpt.need_head')} <span id="rptmdlnote" style="font-weight:400;color:var(--muted)">${t('rpt.need_hint')}${RPT.model ? (MDL_LOC[RPT.model] === 'local' ? t('rpt.local_note') : `<span style="color:var(--warning)">${t('rpt.cloud_note')}</span>`) : ''}</span></div>
- <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap"><input id="rptneed" placeholder="${esc(t('rpt.need_ph'))}" value="${esc(RPT.need)}" style="flex:1 1 300px;min-width:220px"><select id="rptmdl">${opts}</select><button class="btn" id="rptgo" ${canCompose ? '' : 'disabled'}>${t('rpt.compose')}</button></div>
- <div id="rptout" class="small muted" style="margin-top:7px">${RPT.out || (RPT.compose === false ? t('rpt.no_compose') : (models && !Object.keys(models).length ? (window.LOCAL_AI_DOWN ? t('ask.local_down') : t('rpt.no_models')) : ''))}</div></div></div>`;
- if (!RPT.canvas.length) return h + `<div class="paper" style="text-align:center;padding:56px 40px"><div class="small muted">${t('rpt.preview_empty')}</div></div></section>`;
- // 数据时点只有形如 YYYY-MM-DD 才当日期用 —— 实测该字段可能是一句说明 ("振动证据包…"), 截 10 字就成乱码
- const asof = (String(FU.数据时点 || '').match(/\d{4}-\d{2}-\d{2}/) || [''])[0]; // 实测该字段是一句说明 ("振动证据包 · 出具 2026-08-26 · …"), 取其中第一个日期
- h += `<div class="paper"><div class="paper-hd"><div><div class="paper-ti">${t('app.brand')} · ${t('rpt.paper_title')}</div><div class="small muted" style="margin-top:3px">${t('rpt.paper_sub', { win: winName(d.win), n: (d.kpi || {}).全场 || 38 })}${RPT.aud === 'up' ? ' | ' + t('rpt.up_tag') : ''}</div></div><div class="small muted" style="text-align:right">${t('rpt.chapters', { n: RPT.canvas.length })}</div></div>`;
- RPT.canvas.forEach((k, i) => { if (!RPT_BLOCKS[k]) return;
- const bb = (s, act, dis) => `<button class="pill" data-mv="${act}" data-k="${k}" ${dis ? 'disabled' : ''} style="padding:1px 8px;font-size:11px;opacity:${dis ? .3 : .7}">${s}</button>`;
- h += `<div class="noprint" style="display:flex;gap:3px;justify-content:flex-end;margin-bottom:-6px;position:relative;z-index:2">${bb('↑', 'up', i === 0)}${bb('↓', 'down', i === RPT.canvas.length - 1)}${bb('×', 'del', false)}</div>${rptBlock(k, d)}`; });
- h += rptFacts();
- h += `<div class="paper-ft"><span>${t('rpt.foot_src')}${asof ? ' | ' + t('rpt.foot_asof', { d: asof }) : ''}</span><span>${t('rpt.foot_trace')}${RPT.aud === 'up' ? ' | ' + t('rpt.foot_deid') : ''}</span></div></div></section>`;
- return h;
- }
- async function bindReport() {
- const R = document.getElementById('root');
- R.querySelectorAll('[data-preset]').forEach(b => b.addEventListener('click', () => { RPT.canvas = RPT_PRESETS[b.dataset.preset].slice(); render(); }));
- R.querySelectorAll('[data-block]').forEach(b => { const k = b.dataset.block;
- b.addEventListener('click', () => { const i = RPT.canvas.indexOf(k); if (i >= 0) RPT.canvas.splice(i, 1); else RPT.canvas.push(k); render(); });
- const hint = document.getElementById('rpthint');
- b.addEventListener('mouseenter', () => { hint.innerHTML = `<b style="color:var(--ink)">${RPT_ICON[k]} ${t('rpt.b.' + k + '.nm')}</b> — ${t('rpt.b.' + k + '.d')}`; });
- b.addEventListener('mouseleave', () => { hint.textContent = t('rpt.hint_idle'); }); });
- R.querySelectorAll('[data-mv]').forEach(b => b.addEventListener('click', () => { const k = b.dataset.k, i = RPT.canvas.indexOf(k); if (i < 0) return;
- if (b.dataset.mv === 'del') RPT.canvas.splice(i, 1); else { const j = i + (b.dataset.mv === 'up' ? -1 : 1); if (j < 0 || j >= RPT.canvas.length) return; [RPT.canvas[i], RPT.canvas[j]] = [RPT.canvas[j], RPT.canvas[i]]; } render(); }));
- document.getElementById('rptaud').addEventListener('change', e => { RPT.aud = e.target.value; render(); });
- document.getElementById('rptprint').addEventListener('click', () => window.print());
- R.querySelectorAll('[data-export]').forEach(b => b.addEventListener('click', () => rptExport(b.dataset.export)));
- document.getElementById('rptneed').addEventListener('keydown', e => { if (e.key === 'Enter') rptCompose(); });
- document.getElementById('rptmdl').addEventListener('change', e => { RPT.model = e.target.value; RPT.need = document.getElementById('rptneed').value; render(); });
- document.getElementById('rptgo').addEventListener('click', rptCompose);
- if (FACTS === null) { try { const r = await fetch('/api/facts'); FACTS = r.ok ? await r.json() : false; } catch (e) { FACTS = false; } if (FACTS) render(); }
- if (RPT.models === null) {
- try { const j = await (await fetch('/api/ask_models')).json(); RPT.models = j.models || {}; window.LOCAL_AI_DOWN = !!(j.offline && j.offline.local_ai_running === false); } catch (e) { RPT.models = {}; }
- const ks = Object.keys(RPT.models); if (!RPT.model || !ks.includes(RPT.model)) RPT.model = ks[0] || '';
- try { const r = await fetch('/api/rpt_compose?probe=1'); RPT.compose = !!(r && r.ok); } catch (e) { RPT.compose = false; }
- if (TAB === 'report') render();
- }
- }
- async function rptCompose() {
- const need = document.getElementById('rptneed').value, out = document.getElementById('rptout');
- RPT.need = need;
- if (!need.trim()) { out.innerHTML = t('rpt.need_empty'); return; }
- out.innerHTML = `<span style="color:var(--accent-deep)">${t('rpt.composing')}</span>${MDL_LOC[RPT.model] === 'local' ? t('rpt.local_note') : `<span style="color:var(--warning)">${t('rpt.cloud_note')}</span>`}`;
- let d;
- try { d = await (await fetch(`/api/rpt_compose?need=${encodeURIComponent(need)}&canvas=${encodeURIComponent(RPT.canvas.join(','))}&model=${encodeURIComponent(RPT.model)}`)).json(); }
- catch (e) { out.innerHTML = `<span style="color:var(--critical)">${esc(t('rpt.compose_fail', { e: e.message }))}</span>`; RPT.out = out.innerHTML; return; }
- if (d.err) { out.innerHTML = `<span style="color:var(--critical)">${dv(d.err)}</span>`; RPT.out = out.innerHTML; return; }
- RPT.narr = { text: d.narrative, model: d.model, ungrounded: d.ungrounded || [] };
- const bl = (d.blocks || []).filter(b => RPT_BLOCKS[b]);
- if (bl.length) RPT.canvas = (d.narrative ? ['narr'] : []).concat(bl); else if (d.narrative && !RPT.canvas.includes('narr')) RPT.canvas.unshift('narr');
- RPT.out = t('rpt.composed', { n: bl.length, narr: d.narrative ? t('rpt.narr_plus') : '', m: esc(d.model || ''), s: d.secs }) + ' · ' + ((d.ungrounded || []).length ? `<span style="color:var(--warning)">${t('rpt.ungrounded_n', { n: d.ungrounded.length })}</span>` : t('rpt.grounded'));
- render();
- }
- function rptExport(fmtx) {
- if (!RPT.canvas.length) { alert(t('rpt.empty_canvas')); return; }
- const n = (RPT.narr && RPT.narr.text) ? RPT.narr.text : '';
- const url = `/api/rpt_export?fmt=${fmtx}&blocks=${encodeURIComponent(RPT.canvas.join(','))}&aud=${RPT.aud}&win=${encodeURIComponent(WIN)}&narr=${encodeURIComponent(n.slice(0, 4000))}`;
- const a = document.createElement('a'); a.href = url; a.download = ''; document.body.appendChild(a); a.click(); a.remove();
- }
- // ── 故障统计: 报警面 (率/帕累托/四象限/台级/季节) + 停机面 (RAM/归因/停机帕累托/台级) ──
- const winBanner = (d, extra) => `<div class="panel mb small" style="border-left:3px solid var(--accent)"><b>${t('win.banner', { win: winName(d.win) })}</b>${extra ? ' | ' + extra : ''}</div>`;
- const barCell = (v, mx, hot) => `<i class="bar" style="width:${Math.round(v / mx * 90)}px;background:${hot ? 'var(--critical)' : 'var(--accent)'}"></i><span class="num small">${fmt(v)}</span>`;
- function fault() {
- const d = D, F2 = d.faults || {}, pm = F2.partial || [], m = (d.m8 && !d.m8.err) ? d.m8.mtbf : null;
- const sec = (ti, meta) => `<div class="eyebrow" style="grid-column:1/-1;margin-top:4px">${ti} <span style="text-transform:none;letter-spacing:0;color:var(--muted)">${meta}</span></div>`;
- const partialWarn = pm.length ? `<span style="color:var(--warning)">${t('win.partial', { m: pm.join('/'), c: Math.round((F2.cov_min || 0) * 100) })}</span>` : '';
- let h = `<section><div class="eyebrow">${t('flt.eyebrow')}</div>${winBanner(d, partialWarn)}<div class="grid2">`;
- h += sec(t('flt.alarm_face'), t('flt.alarm_meta', { n: fmt(F2.total), c: F2.n_codes, win: winName(d.win) }));
- const mo = F2.monthly || { months: [], vals: [] }, i0 = pm.length ? mo.months.indexOf(pm[0]) : -1;
- h += `<div class="panel span2"><h3>${t('flt.rate')}</h3>${CH.rateChart(mo)}<p class="small muted" style="margin-top:6px"><b>${t('flt.rate_note')}</b> — ${pm.length ? t('flt.rate_partial', { m: pm.join('/') }) : t('flt.rate_full')}${(i0 >= 0 && mo.per_day) ? ' ' + t('flt.rate_example', { m: pm[0], v: mo.per_day[i0] }) : ''}</p></div>`;
- h += `<div class="panel"><h3>${t('flt.pareto_n')}</h3>${CH.paretoChart(F2.pareto_n, t('unit.n'), t('flt.pareto_n_note'))}</div>`;
- h += `<div class="panel"><h3>${t('flt.pareto_d')}</h3>${CH.paretoChart(F2.pareto_d, ' h', t('flt.pareto_d_note'))}</div>`;
- h += `<div class="panel span2"><h3>${t('flt.quad')}</h3>${CH.quadChart(F2.quad)}<p class="small muted" style="margin-top:6px">${t('flt.quad_note', { n: F2.n_codes, m: (F2.quad || []).length })} ${pm.length ? `<span style="color:var(--warning)">${t('flt.quad_partial', { m: pm.join('/'), c: Math.round((F2.cov_min || 0) * 100) })}</span> ` : ''}${t('flt.quad_note2')}</p></div>`;
- const pt = F2.per_t || [], maxT = Math.max(1, ...pt.map(x => x.v));
- h += `<div class="panel"><h3>${t('flt.per_t', { n: pt.length })}</h3><div class="tblwrap"><table><tr><th style="width:70px">${t('ui.unit')}</th><th>${t('ui.count')}</th><th class="r" style="width:80px">${t('flt.per_t_share')}</th></tr>${pt.map(x => `<tr><td><b>${unitNo(x.k)}</b></td><td>${barCell(x.v, maxT, x.v / maxT > .6)}</td><td class="r small">${(x.v / F2.total * 100).toFixed(1)}%</td></tr>`).join('')}</table></div><p class="small muted" style="margin-top:6px">${t('flt.per_t_note', { n: fmt(F2.total) })}</p></div>`;
- if (d.m8 && d.m8.err) h += `<div class="panel" style="border-left:3px solid var(--critical)"><p class="small">${t('flt.block_err', { k: 'm8', e: dv(d.m8.err) })}</p></div>`;
- if (m && d.m8.seasonal && d.m8.seasonal.length) h += `<div class="panel"><h3>${t('flt.seasonal')} <span class="small muted" style="font-weight:400">(${t('flt.seasonal_unit')})</span></h3>${CH.multiline({ months: d.m8.seasonal[0].months, series: d.m8.seasonal.slice(0, 4).map(x => ({ name: displayText(x.name).slice(0, 16), vals: x.vals })), xlab: t('flt.seasonal_x') })}<p class="small muted" style="margin-top:6px">${t('flt.seasonal_note')}</p></div>`;
- if (m) {
- const RT = (d.rel && !d.rel.err) ? d.rel : null;
- h += sec(t('flt.stop_face'), t('flt.stop_meta', { h: fmt(m.停机总时长h), n: fmt(m.停机事件数) }));
- if (RT) { const nore = (RT.表 || []).find(x => x.部件 === '无记录停机') || {};
- h += `<div class="panel span2"><h3>${t('flt.ram')}</h3>${CH.ramChart(RT.表, partPlain)}<p class="small muted" style="margin-top:6px">${t('flt.ram_note', { a: RT.设备类停机h, b: RT.外部类停机h })}</p><div style="margin-top:12px">${CH.attribBar(RT.表, RT.总停机h)}</div><p class="small muted" style="margin-top:2px">${t('flt.attrib_note', { h: nore.停机时长h || 0 })}</p></div>`; }
- h += `<div class="panel"><h3>${t('flt.stop_pareto')}</h3>${CH.paretoChart((d.m8.stop_pareto || []).map(x => ({ k: displayText(x.k).split(';')[0].slice(0, 26), v: x.h })), ' h', t('flt.stop_pareto_note'))}<p class="small muted" style="margin-top:4px">${dv(m.口径)}</p></div>`;
- const top = m.台级top || [], mx = Math.max(1, ...top.map(x => x.时长h));
- h += `<div class="panel"><h3>${t('flt.stop_units', { n: top.length })}</h3><div class="tblwrap"><table><tr><th style="width:64px">${t('ui.unit')}</th><th>${t('flt.stop_h')}</th><th class="r" style="width:56px">${t('flt.stop_n')}</th></tr>${top.map(x => `<tr><td><b>${unitNo(x.t)}</b></td><td>${barCell(x.时长h, mx, x.时长h / mx > .6)}h</td><td class="r small">${x.次}</td></tr>`).join('')}</table></div><p class="small muted" style="margin-top:6px">${t('flt.stop_units_note')}</p></div>`;
- }
- return h + `</div></section>`;
- }
- // ── 电量算账: 四 KPI / 电量平衡瀑布 / 责任分账 / 工况态明细 (只出当期账; 控制参数件在发电性能页) ──
- function energy() {
- const d = D, E = (d.control || {}).energy, S = (d.control || {}).states || [];
- if (!E) return `<section><div class="panel"><p class="small muted">${t('ui.empty')}</p></div></section>`;
- let h = `<section><div class="kpis" style="margin-bottom:12px">
- <div class="kpi"><b>${fmt(E.theo)}<small>MWh</small></b><span>${t('en.kpi_theo')}</span></div>
- <div class="kpi ok"><b>${fmt(E.act)}<small>MWh</small></b><span>${t('en.kpi_act', { h: fmt(E.eflh) })}</span></div>
- <div class="kpi ${E.loss_pct >= 10 ? 'warn' : ''}"><b>${fmt(E.loss_pct, 1)}<small>%</small></b><span>${t('en.kpi_loss', { v: fmt(E.loss) })}</span></div>
- <div class="kpi ${E.cap_ok ? 'ok' : 'bad'}"><b>${E.cap_ok ? t('en.cap_pass') : t('en.cap_fail')}</b><span>${t('en.cap_note', { op: E.cap_ok ? '<' : '≥', cap: fmt(E.cap) })}</span></div></div>
- <div class="grid2">
- <div class="panel span2"><h3>${t('en.balance')} <span class="small muted" style="font-weight:400">${t('en.win', { win: winName(d.win) })}</span></h3>${CH.waterfall(E)}</div>
- <div class="panel"><h3>${t('en.blame')}</h3>${CH.blameBar(E)}<p class="small muted" style="margin-top:6px">${t('en.blame_note', { n: fmt(Math.round(((E.items || []).find(x => x.k === '停机') || {}).loss || 0)) })}</p></div>`;
- const mxp = Math.max(1, ...S.map(x => x.pct));
- h += `<div class="panel"><h3>${t('en.states')} <span class="small muted" style="font-weight:400">${t('en.win', { win: winName(d.win) })}</span></h3><div class="tblwrap"><table><tr><th>${t('en.state')}</th><th>${t('en.share')}</th><th class="r" style="width:90px">${t('en.loss_mwh')}</th></tr>
- ${S.map(x => `<tr><td class="small">${esc(CH.lossName(x.k))}</td><td><i class="bar" style="width:${Math.round(x.pct / mxp * 84)}px;background:${x.k.indexOf('停机') >= 0 && x.k.indexOf('调度') < 0 ? 'var(--critical)' : 'var(--accent)'}"></i><span class="num small">${x.pct}% · ${fmt(Math.round(x.h))}h</span></td><td class="r small">${fmt(Math.round(x.loss))}</td></tr>`).join('')}</table></div>
- <p class="small muted" style="margin-top:6px">${dv(d.control.anchors || '')}<br>${t('en.states_note')}</p></div></div></section>`;
- return h;
- }
- // ── 部件问题: 九系统卡 → 系统详情 (逐台依据) / 部件可靠性表 / 跨系统关注台 ──
- let SYSV = (location.hash.match(/sys=([^&]+)/) || [])[1]; SYSV = SYSV ? sysFromSlug(decodeURIComponent(SYSV)) : null;
- const STC = { bad: 'var(--critical)', warn: 'var(--warning)', unk: 'var(--unknown)', ok: 'var(--good)', ex: 'var(--excellent)' };
- function sysDetail(d, s) {
- const rows = (d.systems || {})[s] || [], dist = (d.sysdist || {})[s] || {}, gd = (d.systems || {})[s + '·良好'] || [];
- const cnt = k => dist[k] || 0, N = Object.values(dist).reduce((a, b) => a + b, 0) || 1;
- const al = rows.filter(r => r.st === '报警'), un = rows.filter(r => r.st === '不可判');
- const tbl = (title, list) => list.length ? `<div class="panel mb"><h3>${title} ${t('cmp.n_units', { n: list.length })}</h3><div class="tblwrap"><table><tr><th style="width:74px">${t('ui.unit')}</th><th>${t('cmp.basis')}</th><th style="width:110px">${t('cmp.detail')}</th></tr>
- ${list.map(r => `<tr><td><a href="/turbine/${r.t}"><b>${unitNo(r.t)}</b></a></td><td class="small">${dv(r.why)}</td><td><a class="small" href="/problem/${r.t}/${encodeURIComponent(s)}">${t('cmp.problem_page')}</a></td></tr>`).join('')}</table></div></div>` : '';
- let h = `<section><p class="small"><a href="#tab=component" data-back="1">${t('ui.back_to', { tab: t('tab.component') })}</a></p>
- <div class="panel mb"><h3>${t('cmp.detail_head', { sys: sysName(s), n: N })}</h3>
- <div style="display:flex;height:12px;border-radius:4px;overflow:hidden;margin:8px 0 4px">${[['报警', 'critical'], ['不可判', 'unknown'], ['良好', 'good'], ['优秀', 'excellent']].map(([k, c]) => cnt(k) ? `<i style="width:${(cnt(k) / N * 100).toFixed(1)}%;background:var(--${c})"></i>` : '').join('')}</div>
- <div class="small muted">${t('cmp.counts', { a: cnt('报警'), u: cnt('不可判'), g: cnt('良好'), e: cnt('优秀') })}</div></div>`;
- h += tbl(t('cmp.alarm_units'), al) + tbl(t('cmp.unk_units'), un);
- if (gd.length) h += `<details class="panel mb"><summary style="cursor:pointer;font-weight:600">${t('cmp.good_units', { n: gd.length })}</summary><div class="tblwrap"><table style="margin-top:10px"><tr><th style="width:74px">${t('ui.unit')}</th><th>${t('cmp.basis')}</th><th style="width:110px">${t('cmp.detail')}</th></tr>${gd.map(r => `<tr><td><a href="/turbine/${r.t}"><b>${unitNo(r.t)}</b></a></td><td class="small">${dv(r.why)}</td><td><a class="small" href="/problem/${r.t}/${encodeURIComponent(s)}">${t('cmp.problem_page')}</a></td></tr>`).join('')}</table></div></details>`;
- if (!al.length && !un.length && !gd.length) h += `<div class="panel"><p class="small">${t('cmp.none')}</p></div>`;
- return h + `</section>`;
- }
- function component() {
- const d = D;
- if (SYSV) return sysDetail(d, SYSV);
- const SD = d.sysdist || {};
- // ★判级按所选时间窗重算是后台算(首次约 25 s)⇒ 先出上一口径并**在脸上写清**,轮询到就自动换掉
- let h = (d.sysmx_pending ? `<section><div class="panel mb small" style="border-left:3px solid var(--accent)"><b>${t('cmp.grading_pending')}</b> ${t('cmp.grading_pending_why', { span: (d.sysmx_span || []).join(' ~ ') })}</div></section>` : '')
- + `<section><div class="eyebrow">${t('cmp.by_system')}</div><div class="panel mb"><div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px">`
- + SYS9.filter(s => SD[s]).map(s => { const dist = SD[s], a = dist['报警'] || 0, u = dist['不可判'] || 0, N = Object.values(dist).reduce((x, y) => x + y, 0) || 1;
- const col = a >= 8 ? 'var(--critical)' : a >= 3 ? 'var(--warning)' : a ? 'var(--accent)' : 'var(--good)';
- return `<div class="sysc" role="button" tabindex="0" data-sys="${esc(sysSlug(s))}" style="cursor:pointer;border-left:3px solid ${col}"><div class="t"><b>${sysName(s)}</b></div>
- <div class="bar">${[['报警', 'critical'], ['不可判', 'unknown'], ['良好', 'good'], ['优秀', 'excellent']].map(([k, c]) => dist[k] ? `<i style="width:${(dist[k] / N * 100).toFixed(1)}%;background:var(--${c})"></i>` : '').join('')}</div>
- <div class="small muted">${t('cmp.alarm_n', { a: `<b style="color:${col}">${a}</b>` })}${u ? t('cmp.unk_n', { u }) : ''}${t('cmp.fleet_n', { n: N })}</div></div>`; }).join('')
- + `</div><p class="small muted" style="margin-top:8px">${t('cmp.cards_note')}</p></div>`;
- if (d.rel && d.rel.err) h += `<div class="panel mb" style="border-left:3px solid var(--critical)"><p class="small">${t('flt.block_err', { k: 'rel', e: dv(d.rel.err) })}</p></div>`;
- if (d.rel && !d.rel.err) { const R = d.rel, tb = R.表 || [], maxH = Math.max(1, ...tb.map(r => r.停机时长h));
- const eh = (((d.control || {}).energy || {}).items || []).filter(x => x.k === '停机').map(x => x.h)[0];
- h += `<div class="eyebrow">${t('cmp.rel_eyebrow', { win: dv(R.窗) })}</div><div class="panel mb"><h3>${t('cmp.rel_head')} <span class="small muted" style="font-weight:400">${t('cmp.rel_meta', { t: R.总停机h, a: R.设备类停机h, b: R.外部类停机h })}</span></h3>
- <p class="small muted" style="margin:-2px 0 8px">${t('cmp.rel_note', { h: eh == null ? '' : ' ' + fmt(eh) + 'h' })}</p>
- <div class="tblwrap"><table><tr><th>${t('cmp.rel_part')}</th><th>${t('cmp.rel_h')}</th><th class="r">${t('cmp.rel_ev')}</th><th class="r">${t('cmp.rel_mtbo')}</th><th class="r">${t('cmp.rel_mdt')}</th><th class="r">${t('cmp.rel_units')}</th><th>${t('cmp.rel_fm')}</th></tr>
- ${tb.map(r => { const sev = r.部件 === '无记录停机' ? 'var(--unknown)' : r.外部 ? 'var(--accent)' : (r.占停机 >= .06 ? 'var(--critical)' : r.占停机 >= .03 ? 'var(--warning)' : 'var(--good)');
- return `<tr><td><b>${partName(r.部件)}</b>${r.外部 ? ` <span class="badge">${t('cmp.external')}</span>` : ''}</td><td><i class="bar" style="width:${Math.round(r.停机时长h / maxH * 72)}px;background:${sev}"></i><span class="num small">${r.停机时长h}h · ${(r.占停机 * 100).toFixed(1)}%</span></td><td class="r">${r.事件数}</td><td class="r">${r.外部 ? '—' : (r.MTBF_h || '—')}</td><td class="r">${r.MDT_h}</td><td class="r">${r.影响台数}</td><td class="small">${dv(r.典型失效模式 || '—')}</td></tr>`; }).join('')}</table></div><p class="small muted">${dv(R.口径)}</p></div>`; }
- if (d.fus && !d.fus.err) h += `<div class="panel mb"><p class="small">${t('cmp.fusion_moved')} <a href="#tab=vibration"><b>${t('tab.vibration')}</b></a> ${t('cmp.fusion_n', { n: (d.fus.表 || []).length })}</p></div>`;
- h += `<div class="eyebrow">${t('cmp.cross_eyebrow')}</div><div class="panel mb"><h3>${t('cmp.watch_head', { n: (d.watch || []).length })}</h3><div class="tblwrap"><table><tr><th>${t('ui.unit')}</th><th class="r">${t('cmp.n_sys')}</th><th>${t('cmp.hit_sys')}</th><th>${t('cmp.basis')}</th></tr>
- ${(d.watch || []).map(w => `<tr><td><a href="/turbine/${w.t}"><b>${unitNo(w.t)}</b></a></td><td class="r">${w.n}</td><td class="small">${(w.syss || []).map(x => `<a href="/problem/${w.t}/${encodeURIComponent(sysFromSlug(x) || x)}">${sysName(x)}</a>`).join(' · ')}</td><td class="small">${dv(w.why)}</td></tr>`).join('')}</table></div></div></section>`;
- return h;
- }
- function bindComponent() {
- document.querySelectorAll('[data-sys]').forEach(el => { const go = () => { SYSV = sysFromSlug(el.dataset.sys); location.hash = 'tab=component&sys=' + encodeURIComponent(el.dataset.sys); render(); };
- el.addEventListener('click', go); el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } }); });
- document.querySelectorAll('[data-back]').forEach(a => a.addEventListener('click', e => { e.preventDefault(); SYSV = null; location.hash = 'tab=component'; render(); }));
- }
- // ── 系统维护: 数据维护面 (两层: 数据 / 机理), 取 /api/maint_survey ──
- const MT = { data: null };
- function system() {
- if (!MT.data) return `<section><div class="eyebrow">${t('mt.eyebrow')}</div><div class="panel"><p class="small muted">${t('mt.surveying')}</p></div></section>`;
- const md = MT.data, rows = md.rows || [];
- if (md.error) return `<section><div class="eyebrow">${t('mt.eyebrow')}</div><div class="panel"><p class="small" style="color:var(--critical)">${dv(md.error)}</p></div></section>`;
- let h = `<section><div class="eyebrow">${t('mt.eyebrow')}</div><div class="panel mb"><h3>${t('mt.head', { n: rows.length })}</h3><p class="small muted">${t('mt.note')}</p></div>`;
- // 层字段在 lang=en 下被经典枚举表投影成 'Data' / 'Domain model' (英文点检实逮: 两层表全空) —— 中英值都认
- const LAYERS = [[['数据', 'Data'], 'mt.layer_data', 'mt.layer_data_note', 'var(--accent)'], [['机理', 'Domain model', 'Mechanism'], 'mt.layer_mech', 'mt.layer_mech_note', 'var(--warning)']];
- LAYERS.forEach(([lvs, kn, kd, col]) => {
- const rs = rows.filter(r => lvs.includes(String(r.层))); if (!rs.length) return;
- h += `<div class="panel mb" style="border-left:3px solid ${col}"><h3>${t(kn)} (${rs.length})</h3><p class="small muted">${t(kd)}</p>
- <div class="tblwrap"><table style="margin-top:10px"><tr><th style="width:18%">${t('mt.src')}</th><th style="width:20%">${t('mt.cov')}</th><th style="width:13%">${t('mt.last')}</th><th style="width:10%">${t('mt.freq')}</th><th style="width:11%">${t('mt.resp')}</th><th>${t('mt.how')}</th></tr>
- ${rs.map(r => `<tr><td class="small"><b>${r.存在 ? '' : '✗ '}${publicMeta(r.名, 'name')}</b></td><td class="small num">${publicMeta(r.覆盖, 'coverage')}${r.条数 ? `<span class="muted">${t('mt.rows_n', { n: fmt(r.条数) })}</span>` : ''}</td><td class="small muted">${esc(r.末次更新 || '—')}</td><td class="small">${publicMeta(r.频率, 'freq')}</td><td class="small">${publicMeta(r.责任, 'responsible')}</td>
- <td class="small"><div class="muted">${t('mt.put')} <code>${publicMeta(r.位置, 'path')}</code></div><div style="margin-top:3px">${t('mt.run')} <code>${publicMeta(r.摄入, 'run')}</code></div><div class="muted" style="margin-top:3px">${publicMeta(r.说明, 'note')}</div></td></tr>`).join('')}</table></div></div>`; });
- return h + `</section>`;
- }
- async function bindSystem() {
- if (MT.data) return;
- try { MT.data = await (await fetch(`/api/maint_survey${EN ? '?lang=en&keep=1' : ''}`)).json(); } catch (e) { MT.data = { error: t('mt.fail', { e: e.message }) }; }
- if (TAB === 'system') render();
- }
- // ── 发电性能: 按所选时间窗重算 (2026-09-21 用户令"发电性能随时间窗变化");
- // 控制参数一致性 (m9) + 曲线欠发候选 + 七镜头 19 图 (/api/curves?win=, 懒取; 首次后台算 → building 轮询) ──
- const GEN = { win: null, curves: null, err: null, building: false, tries: 0 };
- function generation() {
- const d = D, M9 = (d.m9 && !d.m9.err) ? d.m9 : null, F = d.control || null;
- const sp = (d.sysmx_span || []).join(' ~ ') || '';
- let h = `<section><div class="eyebrow">${t('gen.eyebrow')}</div><div class="panel mb small" style="border-left:3px solid var(--accent)"><b>${t('gen.fixed_win')}</b> ${sp ? t('gen.win_range', { span: sp }) : ''}<div class="muted" style="margin-top:3px">${t('gen.win_why')}</div></div>`;
- if (d.m9 && d.m9.err) h += `<div class="panel mb" style="border-left:3px solid var(--critical)"><p class="small">${t('flt.block_err', { k: 'm9', e: dv(d.m9.err) })}</p></div>`;
- if (M9) { const b9 = M9.β_schedule || {}, outs = b9.离群 || [];
- h += `<div class="panel mb"${d.m9_pending ? ' style="border-left:3px solid var(--accent)"' : ''}><h3>${t('gen.ctrl')} <span class="small muted" style="font-weight:400">${t('gen.ctrl_win', { win: dv(M9.窗) })}</span>${d.m9_pending ? ` <span class="chip">${t('gen.recomputing_short')}</span>` : ''}</h3><div style="display:flex;flex-wrap:wrap;gap:18px">
- <div style="flex:1 1 300px;min-width:280px"><div class="small" style="font-weight:600;margin-bottom:4px">${t('gen.cap')}</div>${CH.capAxis(M9.P封顶, M9.ω封顶)}<p class="small muted" style="margin-top:4px">${t('gen.cap_note')}</p></div>
- <div style="flex:1 1 300px;min-width:280px"><div class="small" style="font-weight:600;margin-bottom:4px">${t('gen.beta')}</div>${CH.betaBar(b9, unitNo)}<p class="small muted" style="margin-top:4px">${t('gen.beta_note', { units: outs.map(x => `<a href="/turbine/${x.t}">${unitNo(x.t)}</a>`).join(' / '), k: outs.length ? Math.round(Math.max(...outs.map(x => x.平均偏)) / Math.max(b9.全场同档差中位 || 0, .01)) : '—', v: dv(b9.判) })}</p>
- <div class="small" style="font-weight:600;margin:12px 0 4px">${t('gen.ruler')}</div>${CH.rulerBar(M9.尺子)}<p class="small muted" style="margin-top:4px">${t('gen.ruler_note')}</p></div></div></div>`; }
- if (F && F.curve) h += `<div class="panel mb"><h3>${t('gen.cands')} <span class="small muted" style="font-weight:400">${t('gen.cands_meta', { note: dv(F.curve.note || ''), s: F.sigma || F.curve.sigma })}</span></h3><p class="small">${(F.curve.cands || []).map(c => `<a href="/turbine/${c.t}">${unitNo(c.t)}</a> ${c.dev}% ${dv(c.判)}`).join(' | ') || t('gen.no_cands')}</p></div>`;
- h += `<div class="eyebrow">${t('gen.figs')}</div>`;
- if (GEN.err) h += `<div class="panel" style="border-left:3px solid var(--critical)"><p class="small">${esc(GEN.err)}</p></div>`;
- else if (GEN.building) h += `<div class="panel" style="border-left:3px solid var(--accent)"><p class="small"><b>${t('gen.recomputing')}</b> ${dv((GEN.curves && GEN.curves.note) || '')}</p><div class="skel" style="width:60%;margin-top:10px"></div><div class="skel" style="width:80%;margin-top:8px"></div></div>`;
- else if (!GEN.curves) h += `<div class="panel"><p class="small muted">${t('gen.loading')}</p><div class="skel" style="width:60%;margin-top:10px"></div><div class="skel" style="width:80%;margin-top:8px"></div></div>`;
- else h += `<div class="grid2">` + (GEN.curves.figs || []).map(f => `<div class="panel"><h3>${dv(f.title || '')}${f.unit ? ` <span class="small muted" style="font-weight:400">(${dv(f.unit)})</span>` : ''}</h3>${CH.multiline(Object.assign({ zero: false }, f))}${f.note ? `<p class="small muted" style="margin-top:6px">${dv(f.note)}</p>` : ''}</div>`).join('') + `</div>`;
- return h + `</section>`;
- }
- async function bindGeneration() {
- // 按窗缓存: 换窗就重取; 服务端首次是后台算(building=true)→ 定时轮询直到出图
- if (GEN.win !== WIN) { GEN.win = WIN; GEN.curves = null; GEN.err = null; GEN.building = false; GEN.tries = 0; }
- if (GEN.curves && !GEN.building) return;
- try {
- const qs = `?win=${encodeURIComponent(WIN)}${EN ? '&lang=en&keep=1' : ''}`;
- const d = await (await fetch(`/api/curves${qs}`)).json();
- GEN.curves = d;
- GEN.building = !!d.building;
- if (GEN.building && (GEN.tries = (GEN.tries || 0) + 1) <= 40) setTimeout(() => { if (TAB === 'generation' && GEN.win === WIN) { GEN.curves = null; render(); bindGeneration(); } }, 6000);
- } catch (e) { GEN.err = t('ask.error', { e: e.message }); }
- if (TAB === 'generation') render();
- }
- // ── 振动融合分析: 五个子视图, 展示已复核的振动结论、SCADA 指标、报警与工单线索 ──
- const FUS = { sub: 'matrix', filter: null, cms: null, cmsErr: null };
- const CST = { '优秀': 'var(--excellent)', '良好': 'var(--good)', '报警': 'var(--warning)', '危险': 'var(--critical)', '不可判': 'var(--unknown)', '监测不可用': 'var(--unknown)', '无监测': 'var(--unknown)' };
- const CLV = { bad: 'var(--critical)', warn: 'var(--warning)', note: 'var(--muted)', ok: 'var(--good)', unlisted: 'var(--muted)', stale: 'var(--muted)', meta: 'var(--muted)' };
- // 振动技术速记 → 人话 (用户 2026-08-28 令); 只在中文数据上做, 英文走服务端译表
- const VIBJARGON = [[/DE[/]NDE/g, '驱动端与非驱动端'], [/GenDE/g, '发电机驱动端'], [/GenNDE/g, '发电机非驱动端'], [/HS_gen2?/g, '高速轴发电机侧'], [/HS_rot/g, '高速轴转子侧'], [/IMS/g, '中间轴'],
- [/(?<![A-Za-z])NDE(?![A-Za-z])/g, '非驱动端'], [/(?<![A-Za-z])DE(?![A-Za-z])/g, '驱动端'], [/内圈BPFI/g, '内圈特征频率'], [/外圈BPFO/g, '外圈特征频率'], [/BPFI/g, '内圈特征频率'], [/BPFO/g, '外圈特征频率'], [/FTF/g, '保持架特征频率'], [/BSF/g, '滚动体特征频率'],
- [/TD_1R/g, '时域单转指标'], [/Peak/g, '冲击峰值'], [/Rms/gi, '有效值'], [/([0-9]+(?:[.][0-9]+)?)[ ]*阶/g, '$1 倍轴频'], [/[(]o([0-9]+(?:[.][0-9]+)?)[)]/g, '(轴频 $1 倍)'], [/×p90/g, '×全场90分位'], [/×fleet/g, '×全场中位'],
- [/六窗/g, '六个观测窗'], [/([0-9])窗/g, '$1 个观测窗'], [/宽带/g, '宽频带总量'], [/台阶/g, '阶跃上升'], [/缓升/g, '缓慢上升'], [/回落/g, '回落至'], [/全净|六窗全净/g, '各窗均干净'], [/清洁/g, '干净'], [/回归/g, '回到常态'],
- [/同族/g, '同批次共性 (见该批主台)'], [/族第([^ ]+)台/g, '同批次第$1台'], [/族/g, '批次共性组'], [/gain异常/g, '测点增益异常'], [/gain/g, '测点增益'], [/[(]销[)]/g, '(已销案)'], [/梯度/g, '沿链梯度最高点'],
- [/周期主导/g, '周期成分主导'], [/急性事件/g, '突发事件'], [/慢性/g, '长期'], [/主异常在/g, '主要异常位于']];
- const vibPlain = (x) => { let s = String(x == null ? '' : x); if (!EN) VIBJARGON.forEach(([a, b]) => { s = s.replace(a, b); }); return s; };
- const attrPlain = (x) => { let s = vibPlain(displayText(x)); return (EN && CJK.test(s)) ? REVIEW_REQUIRED : s; };
- function publicMeta(x, kind) {
- let s = String(x == null ? '' : displayText(x));
- if (!EN) return dv(s);
- s = s
- .replace(new RegExp('hand' + 'off[_ -]?vibration[_ -]?v2\\.json', 'ig'), 'Vibration evidence package')
- .replace(new RegExp('hand' + 'off[_ -]?vibration[_ -]?v2', 'ig'), 'vibration evidence package')
- .replace(/component_history\.json/ig, 'component trend history package')
- .replace(/objects\.json/ig, 'knowledge graph object store')
- .replace(/turbine_params\.parquet/ig, 'commissioning parameter registry')
- .replace(/outputs\/rudong/ig, 'outputs/<site>')
- .replace(/Rudong/ig, 'offshore site')
- .replace(/Huanneng/ig, 'site operator')
- .replace(/BPFI|BPFO|FTF|BSF/g, 'bearing marker')
- .replace(/characteristic frequenc(?:y|ies)/ig, 'bearing signature marker')
- .replace(/spectra/ig, 'condition traces')
- .replace(/spectrum/ig, 'condition trace')
- .replace(/waveforms?/ig, 'condition traces')
- .replace(new RegExp('\\bAdd' + 'ress\\b', 'g'), 'Resolve')
- .replace(new RegExp('\\badd' + 'ress\\b', 'g'), 'resolve')
- .replace(/coordinate(?:d)?/ig, 'aligned')
- .replace(/outputs\/[<]site[>]\/CMS dataset\/?/ig, 'vibration evidence landing zone')
- .replace(/outputs\/site\/CMS module\/?/ig, 'CMS assessment landing zone')
- .replace(/windscada decision chain \(computed at run time, read through \/api\/fleet\)/ig, 'fleet decision-chain service')
- .replace(/\[technical document\]/ig, 'OEM technical document set')
- .replace(/\bpython(?:3)?\s+-c\s+"[^"]+"/ig, 'managed knowledge-graph refresh')
- .replace(/\bpython(?:3)?\s+-m\s+src\.ontology\.[A-Za-z0-9_.-]+[^)]*/ig, 'managed knowledge-graph refresh')
- .replace(/python scripts\/[A-Za-z0-9_.-]+(?:\.py)?[^)]*/ig, 'managed data refresh')
- .replace(/scripts\/[A-Za-z0-9_.-]+(?:\.py)?/ig, 'managed data refresh')
- .replace(/\/api\/fleet/ig, 'fleet API');
- if (kind === 'run' && /managed knowledge-graph refresh/.test(s)) s = 'managed knowledge-graph refresh';
- else if (kind === 'run' && /managed data refresh/.test(s)) s = 'managed data refresh';
- if (CJK.test(s)) return `<span class="review-required" title="source label withheld">${REVIEW_REQUIRED}</span>`;
- return esc(s);
- }
- const stWord = (zh) => stateKey(zh) ? t(stKey(stateKey(zh))) : dv(zh);
- const tierWord = (zh) => TIER[zh] ? t('tier.' + TIER[zh]) : (L['tier.detail.' + zh] ? esc(t('tier.detail.' + zh)) : (zh === '—' ? '—' : dv(zh)));
- const duo = (st, ev, col) => `<span class="duo" style="--c:${col}"><span class="st">${st}</span><span class="ev">${ev}</span></span>`;
- const spark = (hist, color) => { if (!hist || hist.filter(x => x != null).length < 3) return ''; const vs = hist.filter(x => x != null), mn = Math.min(...vs), mx = Math.max(...vs), sp = (mx - mn) || 1, W = 84, H = 22, n = hist.length; let d = '', last = null;
- hist.forEach((v, i) => { if (v == null) return; const x = i / (n - 1) * W, y = H - 2 - (v - mn) / sp * (H - 4); d += (d ? 'L' : 'M') + x.toFixed(1) + ' ' + y.toFixed(1); last = [x, y]; });
- return `<svg width="${W}" height="${H}" style="vertical-align:middle;margin-left:6px"><path d="${d}" fill="none" stroke="${color}" stroke-width="1.2" opacity=".75"/><circle cx="${last[0]}" cy="${last[1]}" r="2.4" fill="${color}"/></svg>`; };
- const FAMCO = { '齿轮箱': '#8A6D3B', '发电机': '#4A6FA5', '主轴承': '#2E7D6B', '变桨叶片': '#4A6FA5', '偏航': '#2E7D6B', '测风': '#7C5295', '其他': '#5A6470' };
- const famBar = (fam, n, maxN) => { if (!n) return `<span class="small muted">${t('fus.no_alarm')}</span>`; const W = 118, w = Math.max(6, Math.round(n / Math.max(maxN || n, 1) * W));
- return `<div title="${esc(t('fus.fam_tip', { n, m: maxN || n }))}" style="display:flex;height:9px;border-radius:3px;overflow:hidden;width:${w}px">${(fam || []).map(f => `<i title="${esc(sysName(f.k))} ${f.n}" style="width:${(f.n / n * 100).toFixed(1)}%;background:${FAMCO[f.k] || '#5A6470'}"></i>`).join('')}</div><div class="small muted" style="margin-top:3px">${(fam || []).slice(0, 2).map(f => sysName(f.k) + ' ' + f.n).join(' · ')}</div>`; };
- const baBar = (pre, post, col) => { const M = Math.max(pre, post, 7), W = 120, sc = v => Math.max(2, v / M * (W - 4)); return `<svg class="dt" viewBox="0 0 ${W} 26" style="width:${W}px;height:26px"><rect x="2" y="2" width="${sc(pre).toFixed(1)}" height="9" rx="2" fill="var(--muted)" fill-opacity=".55"/><rect x="2" y="15" width="${sc(post).toFixed(1)}" height="9" rx="2" fill="${col}" fill-opacity=".9"/></svg>`; };
- const winBar = (spans, raw) => { if (!spans || !spans.length) return `<span class="small" title="${esc(raw || '')}">${t('fus.win_none')}</span>`; const T0 = Date.parse('2026-01-01'), T1 = Date.parse('2026-09-01'), W = 118, px = d => 3 + Math.max(0, Math.min(1, (Date.parse(d) - T0) / (T1 - T0))) * (W - 6);
- return `<div title="${esc(raw || '')}"><svg class="dt" viewBox="0 0 ${W} 18" style="width:${W}px;height:18px"><rect x="3" y="4" width="${W - 6}" height="10" rx="2" fill="var(--line)" fill-opacity=".5"/>${spans.map(([a, b]) => { const x1 = px(a), x2 = px(b); 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=".8"/>`; }).join('')}</svg><div class="small muted">${t('fus.win_n', { n: spans.length })}</div></div>`; };
- const devBar = (ch, dev, vd, raw) => { if (dev == null) return `<span class="small">${dv(displayText(raw).slice(0, 30))}</span>`; const M = 6, W = 104, mid = W / 2, k = Math.max(-1, Math.min(1, dev / M)), bw = Math.abs(k) * (mid - 8), strong = Math.abs(dev) >= 3;
- const col = !strong ? 'var(--muted)' : (/候选|andidate/.test(vd || '') ? 'var(--warning)' : /记基线|aseline/.test(vd || '') ? 'var(--accent)' : /不可判|返服|ndeterm/.test(vd || '') ? 'var(--unknown)' : 'var(--muted)'), over = Math.abs(dev) > M;
- return `<div title="${esc(raw || '')}" style="display:flex;align-items:center;gap:7px"><svg class="dt" viewBox="0 0 ${W} 18" style="width:${W}px;height:18px;flex:none"><line x1="4" y1="9" x2="${W - 4}" y2="9" stroke="var(--line)" stroke-opacity=".7"/><line x1="${mid}" y1="1" x2="${mid}" y2="17" stroke="var(--muted)" stroke-opacity=".6"/><rect x="${dev >= 0 ? mid : mid - bw}" y="4" width="${Math.max(bw, 1.5)}" height="10" rx="2" fill="${col}" fill-opacity=".9"/>${over ? `<path d="M${dev > 0 ? W - 5 : 5},9 l${dev > 0 ? -5 : 5},-4 v8 z" fill="${col}"/>` : ''}</svg><b class="small num" style="color:${col}">${dev > 0 ? '+' : ''}${dev}K</b></div><div class="small muted" style="margin-top:1px">${esc(ch || '')}</div>`; };
- const oilPill = (last, fresh, raw) => `<div title="${esc(raw || '')}" style="display:flex;align-items:center;gap:6px"><i style="width:9px;height:9px;border-radius:50%;flex:none;background:${fresh ? 'var(--good)' : 'var(--unknown)'}"></i><span class="small num">${esc(last || t('fus.oil_none'))}</span></div><div class="small muted">${last ? (fresh ? t('fus.oil_fresh') : t('fus.oil_stale')) : '—'}</div>`;
- const evLadder = (x) => { const T = String(x || ''), n = /准定论|near[- ](?:conf|establish)/i.test(T) ? 3 : /确诊|onfirmed/.test(T) ? 4 : /定论|ettled|stablished/.test(T) ? 3 : /候选|andidate/.test(T) ? 2 : /参考|eference/.test(T) ? 1 : 0, col = n >= 4 ? 'var(--critical)' : n === 3 ? 'var(--warning)' : n === 2 ? 'var(--accent)' : n === 1 ? 'var(--muted)' : 'var(--unknown)';
- return `<div style="display:flex;align-items:center;gap:7px"><svg class="dt" viewBox="0 0 62 20" style="width:62px;height:20px;flex:none">${[0, 1, 2, 3].map(i => `<rect x="${2 + i * 15}" y="${12 - i * 3}" width="11" height="${6 + i * 3}" rx="1.5" fill="${i < n ? col : 'none'}" fill-opacity=".9" stroke="${col}" stroke-opacity="${i < n ? 1 : .4}" ${i >= n ? 'stroke-dasharray="2 1.5"' : ''}/>`).join('')}</svg><span class="small" style="color:${col}">${dv(T)}</span></div>`; };
- // 传动链状态链: 用一排小格代替经典的 SVG 齿轮/轴承剖面 (语义相同: 哪一环、什么状态)
- const CHAIN7 = [['mbf', /主轴承[((]前|主轴承前|主轴承[++]|主轴承(?![((前后])/], ['mba', /主轴承[((]后|主轴承后/], ['ps', /行星|二级行星/], ['ims', /中间轴|中速/], ['hss', /高速轴|高速(?!.*发电)/], ['gde', /发电机DE|发电机[((]|发电机(?!NDE)/], ['gnde', /发电机NDE|NDE/]];
- const chainMini = (comp, col) => { const c = String(comp || '').replace(/main bearing[, ]*[(]?(?:rear|back)[)]?/ig, '主轴承后').replace(/main bearing[, ]*[(]?(?:front|forward)[)]?/ig, '主轴承前').replace(/main bearing/ig, '主轴承').replace(/generator[, ]*(?:NDE|non-drive[- ]end)/ig, '发电机NDE').replace(/generator[, ]*(?:DE|drive[- ]end)/ig, '发电机DE').replace(/generator/ig, '发电机').replace(/gearbox/ig, '齿轮箱').replace(/planetary/ig, '行星').replace(/intermediate[- ](?:speed[- ])?shaft/ig, '中间轴').replace(/high[- ]speed shaft/ig, '高速轴'), gball = /齿轮箱/.test(c) && !/行星|中间轴|中速|高速轴/.test(c);
- return `<span class="chain" title="${esc(displayText(comp))}">${CHAIN7.map(([k, re]) => { const on = re.test(c) || (gball && ['ps', 'ims', 'hss'].includes(k)); return `<i class="${on ? 'on' : ''}" style="${on ? `background:${col};border-color:${col}` : ''}" title="${t('chain.' + k)}"></i>`; }).join('')}</span>`; };
- const chainStates = (map) => `<span class="chain">${Object.entries(map).map(([k, col]) => `<i class="${col ? 'on' : ''}" style="${col ? `background:${col};border-color:${col}` : ''}" title="${t('chain.' + k)}"></i>`).join('')}</span>`;
- const gc = st => STC[stateKey(st)] || null;
- function vibration() {
- const d = D, F = d.fus;
- if (!F || F.err) return `<section><div class="panel"><p>${t('fus.unavailable', { e: dv((F && F.err) || '') })}</p></div></section>`;
- const SUBS = ['matrix', 'loop', 'units', 'events', 'cms'];
- let h = `<section><div class="eyebrow">${t('fus.eyebrow')}</div><div style="display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap">${SUBS.map(s => `<button class="pill ${s === FUS.sub ? 'on' : ''}" data-fsub="${s}">${t('fus.sub.' + s)}</button>`).join('')}</div>`;
- if (FUS.sub === 'matrix') h += fusMatrix(F);
- else if (FUS.sub === 'loop') h += fusLoop(F);
- else if (FUS.sub === 'units') h += fusUnits(F);
- else if (FUS.sub === 'events') h += fusEvents(F, d);
- else h += fusCms();
- return h + `</section>`;
- }
- function fusMatrix(F) {
- const months = (F.列窗 || {}).months || [];
- let h = `<div class="panel mb" style="border-left:3px solid var(--warning)"><b>${t('fus.rules_head')}</b><p class="small" style="margin-top:4px">${t('fus.rule1')}<br>${t('fus.rule2')}</p></div>`;
- h += `<div class="panel mb"><h3>${t('fus.matrix_head')}</h3><p class="small muted">${t('fus.matrix_note1')}</p><p class="small">${t('fus.matrix_note2', { n: months.length, m0: months[0] || '?' })}</p>
- <div class="small muted" style="margin:6px 0 12px;line-height:2"><div><b>${t('fus.legend_state')}</b> ${['bad', 'warn', 'ok', 'ex', 'unk'].map(k => `<span style="display:inline-flex;align-items:center;gap:4px;margin-right:12px"><i style="width:10px;height:10px;border-radius:2px;background:${STC[k]}"></i>${t(stKey(k))}</span>`).join('')}</div>
- <div><b>${t('fus.legend_tier')}</b> ${['定论', '准定论·预警', '候选·新发', '候选', '候选·记基线', '参考', 'INSUFFICIENT'].map(k => `<span style="display:inline-flex;align-items:center;gap:4px;margin-right:12px"><i style="width:10px;height:10px;border-radius:2px;background:${(F.色标 || {})[k] || '#ccc'}"></i>${tierWord(k)}</span>`).join('')}</div><div>${t('fus.legend_note')}</div></div>`;
- const shortWin = w => { const s = String(w || ''); const m = s.match(/20[0-9][0-9]-[0-9][0-9](?:~20[0-9][0-9]-[0-9][0-9])?|至[ ]*20[0-9][0-9]-[0-9][0-9]|20[0-9][0-9]~20[0-9][0-9]/); return m ? (m[0].startsWith('至') ? t('win.through', { date: m[0].replace(/^至\s*/, '') }) : m[0]) : s.slice(0, 14); };
- const th = (name, win, extra) => `<th title="${esc(win || '')}${extra || ''}"><div>${name}</div><div class="small muted" style="font-weight:400">${dv(shortWin(win))}${extra || ''}</div></th>`;
- const LW = F.列窗 || {};
- h += `<div class="tblwrap"><table class="fmx" style="table-layout:fixed;width:100%;min-width:900px"><colgroup><col style="width:7%">${'<col style="width:15.5%">'.repeat(6)}</colgroup><tr><th>${t('ui.unit')}</th>${th(t('fus.col.vib'), LW.振动, t('fus.col.fixed'))}${th(t('fus.col.temp'), LW.温度)}${th(t('fus.col.lub'), LW.润滑, t('fus.col.display_only'))}${th(t('fus.col.oil'), LW.油液)}${th(t('fus.col.alarm'), LW.报警)}${th(t('fus.col.wo'), LW.工单)}</tr>`;
- const mat = F.矩阵 || [], MAXAL = Math.max(1, ...mat.map(r => (r.报警 || {}).n || 0)), MAXWO = Math.max(1, ...mat.map(r => (r.工单 || {}).n || 0)), WC = F.色标 || {};
- const shortOf = c => { if (c.ostate && c.ostate !== '不可判') return stWord(c.ostate); const n = String(c.note || ''); if (/数据不足|不可判|nsufficient/.test(n)) return t('fus.short.nodata'); if (/未评估|ot assessed/.test(n)) return t('fus.short.uneval'); if (/无样|未取样|o sample/.test(n)) return t('fus.short.nosample'); if (/样龄|stale/.test(n)) return t('fus.short.stale'); return { ok: t('fus.short.normal'), unlisted: t('fus.short.unlisted'), stale: t('fus.short.stale') }[c.level] || '—'; };
- const cell = (c) => { c = c || {}; const col = CST[c.ostate] || WC[c.wlevel] || CLV[c.level] || 'var(--muted)', full = vibPlain(c.note), isProb = ['bad', 'warn', 'note'].includes(c.level);
- const tip = esc(full) + t('fus.tip_state', { s: stWord(c.ostate || '?') });
- if (!isProb) return `<td class="small" title="${tip}" style="border-left:3px solid ${col};color:var(--muted)">${shortOf(c)}</td>`;
- const parts = full.split(/[::]/), brief = (parts.length > 1 ? parts.slice(1).join(':') : full).trim(), cleanBrief = (/^[ ]*(评估报告|两口径差异|⚠|ssessment)/.test(brief) || brief.length > (EN ? 28 : 16)) ? '' : brief;
- const shortCut = cleanBrief ? dv(cleanBrief) : (c.ostate && c.ostate !== '不可判' ? '' : shortOf(c));
- const ev = (c.wlevel && c.wlevel !== '—' && c.wlevel !== '正常' && c.ostate) ? duo(stWord(c.ostate), tierWord(c.wlevel), col) : '';
- return `<td class="small" title="${tip}${t('fus.tip_tier', { e: tierWord(c.wlevel || '?') })}" style="border-left:3px solid ${col};overflow:hidden"><div><div style="color:${col};font-weight:${['bad', 'warn'].includes(c.level) ? 600 : 400}">${shortCut}</div>${ev}</div>${spark(c.hist, c.level === 'bad' ? 'var(--critical)' : c.level === 'warn' ? 'var(--warning)' : 'var(--muted)')}</td>`; };
- mat.forEach(r => { const al = r.报警 || { n: 0, fam: [] }, wo = r.工单 || { n: 0, acts: [] }, alc = al.rank && al.rank <= 5 ? 'var(--warning)' : 'var(--line)', bu = al.burst;
- const altip = esc((al.items || []).map(x => `${x.code} ${vibPlain(x.text)} — ${x.n} (${x.first}~${x.last})`).join('\n')) + (bu ? '\n' + esc(t('fus.burst_tip', { c: bu.code, p: (bu.share * 100).toFixed(0), m: bu.med_s })) : '');
- h += `<tr><td><a href="/turbine/${r.turbine}"><b>${unitNo(r.turbine)}</b></a></td>${cell(r.振动)}${cell(r.温度)}${cell(r.润滑)}${cell(r.油液)}
- <td title="${altip}" style="border-left:3px solid ${bu ? 'var(--unknown)' : alc}"><b class="small num" style="color:${al.rank && al.rank <= 10 ? 'var(--ink)' : 'var(--muted)'}">${t('fus.alarm_n', { n: al.n })}</b>${al.rank ? `<span class="small muted">${t('fus.alarm_rank', { r: al.rank, t: al.tot })}</span>` : ''}${bu ? `<div class="small" style="color:var(--unknown)">${t('fus.burst', { p: (bu.share * 100).toFixed(0), c: bu.code })}</div>` : ''}${(al.rank && al.rank <= 10) || bu ? famBar(al.fam, al.n, MAXAL) : ''}</td>
- <td title="${esc((wo.items || []).map(x => `${x.date} ${attrPlain(x.name)} → ${attrPlain(x.act)}${x.part ? ' ' + attrPlain(x.part) : ''}`).join('\n') || t('fus.wo_none'))}" style="min-width:110px">${wo.n ? `<b class="small">${t('fus.wo_n', { n: wo.n })}</b>${wo.last ? `<span class="small muted"> · ${esc(wo.last)}</span>` : ''}<div style="height:7px;border-radius:3px;background:var(--muted);opacity:.5;margin-top:4px;width:${Math.max(6, Math.round(wo.n / MAXWO * 90))}px"></div><div class="small muted" style="margin-top:3px">${(wo.acts || []).slice(0, 2).map(a => dv(a.k) + '×' + a.n).join(' · ')}</div>` : `<span class="small muted">—</span>`}</td></tr>`; });
- return h + `</table></div><p class="small muted" style="margin-top:8px">${t('fus.matrix_foot')}</p></div>`;
- }
- function fusLoop(F) {
- const LP = (F.能力 && F.能力.loop && F.能力.loop['换件闭环 (检出→换件→恢复)']) ? F.能力.loop : null;
- if (!LP) return `<div class="panel mb"><p class="small">${t('fus.loop_none')}</p></div>`;
- const cl = LP['换件闭环 (检出→换件→恢复)'] || [], up = LP['当前在升 (无工单可对)'] || [], neg = LP['负面对照 (工单后持平)'];
- return `<div class="panel mb"><h3>${t('fus.loop_head')}</h3><p class="small">${t('fus.loop_note')}</p>
- <h3 style="margin-top:8px">${t('fus.loop_closed', { n: cl.length })}</h3><div class="tblwrap"><table><tr><th>${t('ui.unit')}</th><th>${t('fus.col.part')}</th><th>${t('fus.loop_item')}</th><th>${t('fus.loop_date')}</th><th>${t('fus.loop_ba')}</th><th class="r">${t('fus.loop_ratio')}</th><th>${t('fus.loop_verdict')}</th></tr>
- ${cl.map(r => `<tr><td><b>${unitNo(r.turbine)}</b></td><td>${chainMini(r.component + (r.title || ''), 'var(--good)')} <span class="small">${partName(r.component)}</span></td><td class="small">${dv(r.title)}</td><td>${esc(r.replace_date)}</td><td>${baBar(r.pre, r.post, 'var(--good)')}<span class="small muted">${r.pre} → ${r.post}</span></td><td class="r"><b style="color:var(--good)">${r.ratio}×</b></td><td><span class="chip ok">${dv(r.verdict)}</span></td></tr>`).join('')}</table></div>
- <p class="small" style="margin-top:6px">${t('fus.loop_neg', { n: neg })}</p>
- <h3 style="margin-top:14px">${t('fus.loop_rising', { n: up.length })}</h3><div class="tblwrap"><table><tr><th>${t('ui.unit')}</th><th>${t('fus.col.part')}</th><th>${t('fus.loop_el')}</th><th class="r">${t('fus.loop_x')}</th><th>${t('fus.loop_state')}</th></tr>
- ${up.map(r => `<tr><td><b>${unitNo(r.turbine)}</b></td><td>${chainMini(r.component, 'var(--warning)')} <span class="small">${partName(r.component)}</span></td><td>${baBar(r.early, r.late, 'var(--warning)')}<span class="small muted">${r.early} → ${r.late}</span></td><td class="r"><b style="color:var(--warning)">${r.ratio}×</b></td><td><span class="chip warn">${t('rpt.units.rising', { r: r.ratio })}</span></td></tr>`).join('')}</table></div>
- <p class="small muted" style="margin-top:8px">${t('fus.loop_bound', { b: dv(String(LP['★数据边界'] || '').split('**').join('')) })}</p></div>`;
- }
- function fusUnits(F) {
- const lv = v => /定论|报警|onfirmed|larm/.test(v) ? 'bad' : /预警|arning/.test(v) ? 'warn' : /销案|无新证|保留|losed|o new/.test(v) ? 'ok' : 'note';
- const tb = F.表 || [], mat = F.矩阵 || [];
- if (FUS.filter === 'unlisted') { const un = mat.filter(r => (r.振动 || {}).level === 'unlisted');
- return `<div class="panel mb"><h3>${t('fus.unlisted_head', { n: un.length })} <a href="#" data-ffilter="">${t('fus.clear')}</a></h3><div class="tblwrap"><table><tr><th>${t('ui.unit')}</th><th>${t('fus.temp_now')}</th><th>${t('fus.lub_now')}</th></tr>${un.map(r => `<tr><td><a href="/turbine/${r.turbine}"><b>${unitNo(r.turbine)}</b></a></td><td class="small">${dv(vibPlain((r.温度 || {}).note))}</td><td class="small">${dv(vibPlain((r.润滑 || {}).note))}</td></tr>`).join('')}</table></div></div>`; }
- let rows = tb; if (FUS.filter) rows = tb.filter(r => lv(r.振动结论) === FUS.filter);
- const filt = FUS.filter ? t('fus.units_filter', { l: t('fus.lv.' + FUS.filter) }) + ` <a href="#" data-ffilter="">${t('fus.clear')}</a>` : '';
- let h = `<div class="panel mb"><h3>${t('fus.units_head', { u: new Set(tb.map(x => x.turbine)).size, n: rows.length + (FUS.filter ? '/' + tb.length : ''), f: filt })}</h3>
- <div style="display:flex;gap:8px;margin:8px 0 12px;flex-wrap:wrap">${['bad', 'warn', 'note', 'ok'].map(k => `<button class="pill ${FUS.filter === k ? 'on' : ''}" data-ffilter="${k}">${t('fus.lv.' + k)}</button>`).join('')}<button class="pill ${FUS.filter === 'unlisted' ? 'on' : ''}" data-ffilter="unlisted">${t('fus.unlisted_link', { n: mat.filter(r => (r.振动 || {}).level === 'unlisted').length })}</button></div>
- <div class="panel" style="border-left:3px solid var(--accent);margin-bottom:12px"><b class="small">${t('fus.eval_head')}</b><div class="tblwrap"><table style="margin-top:8px"><tr><th style="width:30%">${t('fus.eval_state')}</th><th style="width:30%">${t('fus.eval_tier')}</th><th>${t('fus.eval_meaning')}</th></tr>
- <tr><td>${stateChip('危险')}</td><td><span class="tier confirmed">${t('tier.confirmed.s')}</span></td><td class="small">${t('fus.m.bad')} <span class="muted">(${t('fus.m.bad_e')})</span></td></tr>
- <tr><td>${stateChip('报警')}</td><td><span class="tier near">${t('tier.near')}</span> / <span class="tier candidate">${t('tier.candidate_new')}</span></td><td class="small">${t('fus.m.warn')} <span class="muted">(${t('fus.m.warn_e')})</span></td></tr>
- <tr><td>${stateChip('良好')}</td><td><span class="tier candidate">${t('tier.candidate')}</span> / <span class="tier candidate">${t('tier.candidate_baseline')}</span></td><td class="small">${t('fus.m.ok')} <span class="muted">(${t('fus.m.ok_e')})</span></td></tr>
- <tr><td>${stateChip('优秀')}</td><td><span class="tier reference">${t('tier.reference')}</span> / <span class="tier reference">${t('tier.normal')}</span> / <span class="tier withdrawn">${t('tier.withdrawn')}</span></td><td class="small">${t('fus.m.ex')} <span class="muted">(${t('fus.m.ex_e')})</span></td></tr>
- <tr><td>${stateChip('不可判')}</td><td><span class="tier insufficient">${t('tier.insufficient')}</span></td><td class="small">${t('fus.m.unk')} <span class="muted">(${t('fus.m.unk_e')})</span></td></tr></table></div><p class="small muted" style="margin-top:7px">${t('fus.eval_note')}</p></div>
- <div class="tblwrap"><table><tr><th>${t('ui.unit')}</th><th>${t('fus.col.part')}</th><th>${t('fus.col.state_concl')}</th><th>${t('fus.col.claim')} <span class="small muted" style="font-weight:400">${t('fus.col.claim_axis')}</span></th><th>${t('fus.col.signal')}</th><th>${t('fus.col.tempaxis')} <span class="small muted" style="font-weight:400">${t('fus.col.tempaxis_note')}</span></th><th>${t('fus.col.oil')}</th></tr>`;
- rows.forEach(r => { const sev = CLV[r.级] || 'var(--accent)', col = CST[r.设备状态] || 'var(--muted)';
- h += `<tr><td><a href="/turbine/${r.turbine}"><b>${unitNo(r.turbine)}</b></a></td><td style="min-width:200px">${chainMini(r.部件, sev)} <span class="small">${partName(r.部件)}</span></td>
- <td title="${esc(vibPlain(r.振动结论))}">${duo(stWord(r.设备状态 || '—'), dv(r.振动结论), col)}</td><td style="min-width:130px">${winBar(r.窗区间, r.claim_window)}</td>
- <td class="small" title="${esc(r.振动信号 || '')}">${dv(vibPlain(r.振动信号))}</td><td style="min-width:158px">${devBar(r.温度通道, r.温度dev, r.温度判, r.温度轴)}</td><td style="min-width:112px">${oilPill(r.油末样, r.油新鲜, r.油样轴)}</td></tr>`; });
- return h + `</table></div></div>`;
- }
- function fusEvents(F, d) {
- if (!F.事件) return `<div class="panel mb"><p class="small muted">${t('ui.empty')}</p></div>`;
- let h = `<div class="panel mb"><p class="small muted"><b>${t('fus.ev_win', { win: winName(d.win) })}</b></p></div>`;
- for (const [comp, E] of Object.entries(F.事件)) { const A = E.报警 || {}, W = E.工单 || {};
- h += `<div class="panel mb"><h3>${t('fus.ev_head', { c: sysName(comp), an: A.总条数 || 0, au: A.台数 || 0, wn: W.总条数 || 0, wu: W.台数 || 0 })}</h3>`;
- if (A.总条数 > 0) { h += `<div class="tblwrap"><table><tr><th>${t('fus.ev_code')}</th><th>${t('fus.ev_text')}</th><th class="r">${t('ui.count')}</th><th class="r">${t('fus.ev_units')}</th><th class="r">${t('fus.ev_dur')}</th></tr>${(A.码统计 || []).map(c => `<tr><td>${esc(c.code)}</td><td class="small">${dv(vibPlain(c.text))}</td><td class="r">${c.条数}</td><td class="r">${c.台数}</td><td class="r">${c.时长h}</td></tr>`).join('')}</table></div>
- <p class="small">${t('fus.ev_top')}${Object.entries(A.top台 || {}).map(([u, n]) => `<a href="/turbine/${u}">${unitNo(u)}</a>×${n}`).join(' · ')}</p>
- <details><summary class="small">${t('fus.ev_recent', { n: (A.近况 || []).length })}</summary><div class="tblwrap"><table><tr><th>${t('ui.unit')}</th><th>${t('fus.ev_time')}</th><th>${t('fus.ev_code')}</th><th>${t('fus.ev_text')}</th><th class="r">${t('fus.ev_min')}</th></tr>${(A.近况 || []).map(x => `<tr><td>${unitNo(x.t)}</td><td class="small">${esc(x.on)}</td><td>${esc(x.code)}</td><td class="small">${dv(vibPlain(x.text))}</td><td class="r">${x.dur_min ?? '—'}</td></tr>`).join('')}</table></div></details>`; }
- else h += `<p class="small">${t('fus.ev_no_alarm')}</p>`;
- if (W.总条数 > 0) h += `<div class="tblwrap"><table style="margin-top:8px"><tr><th>${t('ui.unit')}</th><th>${t('ui.date')}</th><th>${t('fus.ev_wo_name')}</th><th>${t('fus.ev_wo_act')}</th><th>${t('fus.ev_wo_cause')}</th></tr>${(W.明细 || []).map(x => `<tr><td>${unitNo(x.t)}</td><td class="small">${esc(x.date)}</td><td class="small">${dv(x.name)}</td><td class="small">${dv(x.act)}</td><td class="small">${dv(x.cause)}</td></tr>`).join('')}</table></div>`;
- else h += `<p class="small">${t('fus.ev_no_wo')}</p>`;
- h += `</div>`; }
- return h;
- }
- function fusCms() {
- if (FUS.cmsErr) return `<div class="panel mb"><p class="small" style="color:var(--critical)">${esc(FUS.cmsErr)}</p></div>`;
- if (!FUS.cms) return `<div class="panel mb"><p class="small muted">${t('fus.cms_loading')}</p></div>`;
- const V = FUS.cms; if (V.error) return `<div class="panel mb"><p class="small" style="color:var(--critical)">${dv(V.error)}</p></div>`;
- const cp = st => stateChip(st);
- const CK = { '主轴承前': 'mbf', '主轴承后': 'mba', '齿轮箱': 'gb', '发电机': 'gen', '发电机DE': 'gde', '发电机NDE': 'gnde', 'Main bearing, front': 'mbf', 'Main bearing, rear': 'mba', 'Gearbox': 'gb', 'Generator': 'gen', 'Generator DE': 'gde', 'Generator NDE': 'gnde' };
- return `<div class="panel mb"><h3>${t('fus.cms_head', { d: esc(V.date || '') })}</h3>
- ${V['时间范围'] ? `<p class="small" style="margin:2px 0 6px">${t('fus.cms_window', { r: esc(V['时间范围']) })}</p>` : ''}
- <p class="small">${dv(V.overall || '')} ${t('fus.cms_note')}</p>
- <h3 style="margin-top:10px">${t('fus.cms_action')}</h3><div class="tblwrap"><table><tr><th>${t('ui.unit')}</th><th>${t('fus.loop_state')}</th><th>${t('fus.cms_focus')} <span class="small muted" style="font-weight:400">${t('fus.cms_focus_note')}</span></th><th>${t('fus.cms_evidence')}</th><th>${t('fus.cms_act')}</th></tr>
- ${(V.action || []).map(r => { const cm = {}; String(r[2]).split(/[;;]/).forEach(seg => { const m = seg.split(/[::]/); if (m.length > 1) { const k = CK[m[0].trim()] || null; if (k) cm[k] = gc(m[1].trim()); } });
- return `<tr><td><b>${unitNo(r[0])}</b></td><td>${cp(r[1])}</td><td title="${esc(r[2])}">${chainStates(cm)} <span class="small muted">${dv(r[2])}</span></td><td style="min-width:150px">${evLadder(r[3])}</td><td>${/P0/.test(r[4]) ? `<b style="color:var(--critical)">${esc(r[4])}</b>` : esc(r[4])}</td></tr>`; }).join('')}</table></div>
- <h3 style="margin-top:14px">${t('fus.cms_grades')}</h3><div class="tblwrap"><table><tr><th>${t('ui.unit')}</th><th>${t('fus.cms_chain')} <span class="small muted" style="font-weight:400">${t('fus.cms_chain_note')}</span></th><th>${t('fus.cms_overall')}</th><th>${t('fus.cms_act')}</th></tr>
- ${(V.grades || []).map(g => `<tr><td><b>${unitNo(g.t)}</b></td><td title="${t('chain.mbf')} ${stWord(g.主轴承前)} · ${t('chain.mba')} ${stWord(g.主轴承后)} · ${t('chain.gb')} ${stWord(g.齿轮箱)} · ${t('chain.gen')} ${stWord(g.发电机)}">${chainStates({ mbf: gc(g.主轴承前), mba: gc(g.主轴承后), gb: gc(g.齿轮箱), gen: gc(g.发电机) })}</td><td>${cp(g.综合)}</td><td class="small">${esc(g.行动)}</td></tr>`).join('')}</table></div>
- <div class="small" style="margin-top:12px;padding:10px;border:1px solid var(--line);border-radius:8px"><b>${t('fus.cms_basis')}</b> ${t('fus.cms_basis_note', { a: ((V.diff || {}).only_cms || []).map(unitNo).join(', ') || t('fus.none_word'), b: (((V.diff || {}).only_vibration_package || (V.diff || {})['only_' + 'hand' + 'off']) || []).map(unitNo).join(', ') || t('fus.none_word') })}</div>
- <p class="small muted" style="margin-top:10px">${t('fus.cms_link')} <a href="http://localhost:8030/index.html" target="_blank" rel="noopener">${t('fus.cms_open')}</a> <a href="/windcms/index.html" target="_blank" rel="noopener">${t('fus.cms_static')}</a></p></div>`;
- }
- async function bindVibration() {
- document.querySelectorAll('[data-fsub]').forEach(b => b.addEventListener('click', () => { FUS.sub = b.dataset.fsub; if (FUS.sub !== 'units') FUS.filter = null; render(); }));
- document.querySelectorAll('[data-ffilter]').forEach(b => b.addEventListener('click', e => { e.preventDefault(); FUS.filter = b.dataset.ffilter || null; render(); }));
- if (FUS.sub === 'cms' && !FUS.cms && !FUS.cmsErr) { try { FUS.cms = await (await fetch(`/api/vibcms${EN ? '?lang=en&keep=1' : ''}`)).json(); } catch (e) { FUS.cmsErr = t('ask.error', { e: e.message }); } if (TAB === 'vibration') render(); }
- }
- // ── 逐台 / 单问题浮层 ──
- // /turbine/<t> 与 /problem/<t>/<sys> 是**服务端独立页**, 静态站上是 404 (实逮)。
- // 经典页当年用页内浮层兜住, v2 同此: 数据走同一批接口 (快照已预烤), 图表复用 charts.fig。
- // 浮层而不是新页: 点机组号是想看详情, 不是想离开当前上下文。
- const POP = { el: null, trigger: null, request: 0 };
- function popBox() {
- if (POP.el) return POP.el;
- const m = document.createElement('div');
- m.className = 'popm'; m.hidden = true;
- m.setAttribute('role', 'dialog'); m.setAttribute('aria-modal', 'true'); m.setAttribute('aria-labelledby', 'pop-title');
- m.addEventListener('click', e => { if (e.target === m) popClose(); });
- document.body.appendChild(m); POP.el = m; return m;
- }
- function popClose() {
- POP.request++;
- if (POP.el) { POP.el.hidden = true; document.body.style.overflow = ''; document.querySelector('.wrap').inert = false; }
- if (POP.trigger && POP.trigger.isConnected) POP.trigger.focus();
- }
- document.addEventListener('keydown', e => {
- if (!POP.el || POP.el.hidden) return;
- if (e.key === 'Escape') { e.preventDefault(); popClose(); }
- if (e.key === 'Tab') {
- const items = [...POP.el.querySelectorAll('button, a[href], input, select, textarea, summary, [tabindex="0"]')].filter(x => !x.disabled && x.getClientRects().length);
- const first = items[0], last = items[items.length - 1];
- if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
- else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
- }
- });
- function popRender(title, d) {
- const ps = (d && d.problems) || [];
- const verbStart = VERB;
- let h = `<div class="popc"><div class="poph"><h3 id="pop-title">${title}</h3><button class="btn quiet" data-popclose>${t('pop.close')}</button></div>`;
- if (!ps.length) h += `<p class="small muted">${t('pop.none')}</p>`;
- ps.forEach(p => {
- h += `<div class="panel mb"><div class="head" style="display:flex;align-items:baseline;gap:10px"><b>${sysName(p.sys)}</b>${stateChip(p['状态'])}</div>
- <p class="small" style="margin:6px 0 8px">${dv(p['依据'])}</p>`;
- const figs = p.figs || [];
- if (figs.length) h += `<div class="grid2">` + figs.map(f => `<div class="panel"><h3>${dv(f.title || '')}${f.unit ? ` <span class="small muted" style="font-weight:400">(${dv(f.unit)})</span>` : ''}</h3>${CH.fig(f)}${f.note ? `<p class="small muted" style="margin-top:6px">${dv(f.note)}</p>` : ''}</div>`).join('') + `</div>`;
- const o = p.orders || {};
- if (o.n) { h += `<div class="order-block"><div class="order-head"><b>${o.n === 1 ? t('pop.orders_one') : t('pop.orders', { n: o.n })}</b>`
- + (o['动作统计'] ? ` <div class="action-counts"><span class="small muted">${t('pop.acts')}</span>${Object.entries(o['动作统计']).map(([k, v]) => `<span class="action-count">${dv(k)} <b>×${v}</b></span>`).join('')}</div>` : '')
- + `</div><div class="tblwrap"><table class="order-table"><colgroup><col class="order-date"><col><col><col><col></colgroup><tr><th>${t('pop.o_date')}</th><th>${t('pop.o_fault')}</th><th>${t('pop.o_check')}</th><th>${t('pop.o_cause')}</th><th>${t('pop.o_act')}</th></tr>`
- + (o.rows || []).map(r => `<tr><td class="small order-date">${esc(r['日期'] || '')}</td><td class="small">${recordValue(r, '故障')}</td><td class="small">${recordValue(r, '排查')}</td><td class="small">${recordValue(r, '原因')}</td><td class="small">${recordValue(r, '处置')}</td></tr>`).join('') + `</table></div></div>`; }
- else h += `<p class="small muted" style="margin-top:8px">${t('pop.orders_none')}</p>`;
- h += `</div>`;
- });
- const b = popBox(); b.innerHTML = h + (VERB > verbStart ? `<p class="popup-verbatim-note">${t('ui.verbatim_note', { n: VERB - verbStart })}</p>` : '') + `</div>`;
- b.hidden = false; document.body.style.overflow = 'hidden'; b.scrollTop = 0;
- b.querySelectorAll('[data-popclose]').forEach(x => x.addEventListener('click', popClose));
- b.querySelector('[data-popclose]').focus({ preventScroll: true });
- }
- async function popOpen(turb, sysn) {
- sysn = sysn ? sysFromSlug(sysn) : sysn;
- const request = ++POP.request;
- POP.trigger = document.activeElement;
- document.querySelector('.wrap').inert = true;
- const title = sysn ? t('pop.problem', { u: unitNo(turb), sys: sysName(sysn) }) : t('pop.unit', { u: unitNo(turb) });
- const b = popBox(); b.innerHTML = `<div class="popc"><div class="poph"><h3 id="pop-title">${title}</h3><button class="btn quiet" data-popclose>${t('pop.close')}</button></div><p class="small muted">${t('pop.loading')}</p></div>`;
- b.hidden = false; document.body.style.overflow = 'hidden';
- b.querySelectorAll('[data-popclose]').forEach(x => x.addEventListener('click', popClose));
- b.querySelector('[data-popclose]').focus({ preventScroll: true });
- const q = sysn ? `/api/problem?t=${encodeURIComponent(turb)}&sys=${encodeURIComponent(sysn)}&win=${encodeURIComponent(WIN)}${EN ? '&lang=en&keep=1' : ''}`
- : `/api/turbine?t=${encodeURIComponent(turb)}&win=${encodeURIComponent(WIN)}${EN ? '&lang=en&keep=1' : ''}`;
- let d = {};
- try { const r = await fetch(q); if (r.ok) d = await r.json(); } catch (e) { d = {}; }
- if (request === POP.request) popRender(title, d);
- }
- // 全局拦截: 这两类链接在静态站上是死链, 一律转成浮层 (在线版也走浮层, 行为一致)
- document.addEventListener('click', e => {
- const a = e.target.closest && e.target.closest('a[href]'); if (!a) return;
- const h = a.getAttribute('href') || '';
- let m = h.match(/^\/problem\/([^/]+)\/(.+)$/);
- if (m) { e.preventDefault(); popOpen(decodeURIComponent(m[1]), decodeURIComponent(m[2])); return; }
- m = h.match(/^\/turbine\/([^/?#]+)/);
- if (m) { e.preventDefault(); popOpen(decodeURIComponent(m[1]), ''); }
- });
- const PAGES = { overview: () => overview(D), assistant: assistant, decision: decision, report: report, fault: fault, energy: energy, component: component, system: system, generation: generation, vibration: vibration };
- const BIND = { assistant: bindAssistant, decision: bindDecision, report: bindReport, component: bindComponent, system: bindSystem, generation: bindGeneration, vibration: bindVibration };
- function render() {
- if (!D) return;
- HIDDEN = 0;
- const root = document.getElementById('root');
- root.innerHTML = PAGES[TAB] ? `<div class="page-head"><h1>${t('tab.' + TAB)}</h1><span class="window-badge">${t('win.label')} · ${winName(WIN)}</span></div>` + PAGES[TAB]()
- : `<section><div class="panel"><p>${t('ui.not_ported')}${SNAP ? '' : ` <a href="/?lang=${LANG}&win=${encodeURIComponent(WIN)}">${t('ui.open_classic')}</a>`}</p></div></section>`;
- if (BIND[TAB]) BIND[TAB]();
- document.querySelectorAll('.cell.hot').forEach(el => el.addEventListener('click', () => {
- document.querySelectorAll('.card.lit,.cell.lit').forEach(x => x.classList.remove('lit'));
- el.classList.add('lit'); const c = document.getElementById('u-' + el.dataset.u); if (c) { c.classList.add('lit'); c.scrollIntoView({ behavior: 'smooth', block: 'center' }); }
- }));
- const note = dt(D.note);
- document.getElementById('foot').innerHTML = esc(note) + (EN && HIDDEN ? ` <span class="muted">${t('ui.data_hidden', { n: HIDDEN })}</span>` : '');
- window.__HIDDEN = HIDDEN; // 点检闸读这个: 英文页隐藏了多少数据文本
- }
- async function load() {
- const r = await fetch(`/api/fleet?win=${encodeURIComponent(WIN)}${EN ? '&lang=en&keep=1' : ''}`);
- D = await r.json();
- renderTop(D); renderTabs(); render();
- // ★判级矩阵/控制参数按窗重算是后台算的(首次约 25 s / 1 s;用户令 2026-09-21)⇒ 页面先出上一口径并
- // **如实标注**,然后轮询直到按窗结果就位(同一窗只算一次,服务端常驻缓存)。
- if ((D.win_pending || D.sysmx_pending || D.m9_pending)
- && (window.__PENDING_TRIES = (window.__PENDING_TRIES || 0) + 1) <= 40) {
- setTimeout(() => { if (D && (D.win_pending || D.sysmx_pending || D.m9_pending)) load().catch(() => {}); }, 5000);
- } else if (!D.win_pending && !D.sysmx_pending && !D.m9_pending) { window.__PENDING_TRIES = 0; }
- }
- window.addEventListener('hashchange', () => { const m = location.hash.match(/tab=([a-z]+)/); const sv = (location.hash.match(/sys=([^&]+)/) || [])[1]; SYSV = sv ? sysFromSlug(decodeURIComponent(sv)) : null; if (m && TABS.includes(m[1])) { TAB = m[1]; renderTabs(); render(); } });
- renderTop(null); renderTabs();
- load().catch(e => { document.getElementById('root').innerHTML = `<div class="panel"><b>${t('ui.error')}</b> <span class="muted">${esc(e.message)}</span></div>`; console.error(e); });
- })();
- </script>
- </body>
- </html>
|