]> acesimba.cloud Git - StationBuilder.git/commitdiff
v0.30: 修复领域方案页面预览空白(dashboard-renderer 按容器隔离 echarts 实例)+ 预览卡片点击放大 + 设计文档同步
authorroot <root@localhost.localdomain>
Fri, 21 Aug 2026 05:04:41 +0000 (13:04 +0800)
committerroot <root@localhost.localdomain>
Fri, 21 Aug 2026 05:04:41 +0000 (13:04 +0800)
app/globals.css
app/page.tsx
data/db/StationBuilder.sqlite
public/dashboard-renderer.js
public/design-doc.html

index 0818ed2aed9ff7fc4da452032654256b717317a0..b3d4a657eb8751bab413996f3ce1268c808ed368 100644 (file)
 .domain-preview-canvas .dashboard-thumb-live{min-height:430px}
 .domain-preview-feedback{margin-top:14px}
 .domain-preview-feedback textarea{width:100%;border:1px solid #cfd3d7;border-radius:3px;padding:9px;resize:vertical;line-height:1.6;margin-top:6px}
-.domain-review-pages{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}
-.domain-review-page{border:1px solid #e0e3e5;background:#fff;overflow:hidden}
-.domain-review-page .dashboard-thumb-live{border-bottom:0}
-.domain-review-page>div{padding:9px 11px;display:flex;align-items:center;justify-content:space-between}
+.domain-review-pages{display:grid;grid-template-columns:1fr;gap:12px}
+.domain-review-page{border:1px solid #e0e3e5;background:#fff;overflow:hidden;transition:border-color .15s,box-shadow .15s}
+.domain-review-page:hover{border-color:var(--blue);box-shadow:0 2px 8px #008fd21f}
+.domain-review-page .dashboard-thumb-live{border-bottom:0;min-height:240px}
+.domain-review-page>div{padding:9px 11px;display:flex;align-items:center;justify-content:space-between;gap:8px}
 .domain-review-page h4{margin:0;font-size:13px}
+.domain-review-page .review-zoom{font-size:10px;color:#008ed2;white-space:nowrap}
 
 .domain-card .card-actions{display:flex;gap:14px;margin-top:2px;padding-top:8px;border-top:1px solid #f0f2f3}
 /* ===== 设备模版 ===== */
index c0c411d7af21285687762e52121d25412299e544..4c8d99808e166b74542e1503879e8204dc12aafd 100644 (file)
@@ -1742,7 +1742,7 @@ function DomainWizard({id,doms,go,notify,onBack,viewCompletePrompt,viewCompleteA
         pageStyles={pageStyles} styleId={m.style_id??null} onSelectStyle={selectStyle} />}
 
       {step==="opsflow" && <PlaceholderStep title="运维流程" note="运维流程编辑功能待开发,后续版本将支持在方案中配置标准运维 SOP 与告警处理流程。"/>}
-      {step==="generate" && <DomainGenerateReview tName={m.name} assets={assets} effSelected={effSelected} grouped={grouped} pages={pages} docs={m.docs||[]} onBack={()=>goStep("functions")} onKnowledge={()=>goStep("knowledge")} onConfirm={()=>notify("方案已生成并保存,可返回上一步继续调整")}/>}
+      {step==="generate" && <DomainGenerateReview tName={m.name} assets={assets} effSelected={effSelected} grouped={grouped} pages={pages} docs={m.docs||[]} onBack={()=>goStep("functions")} onKnowledge={()=>goStep("knowledge")} onConfirm={()=>notify("方案已生成并保存,可返回上一步继续调整")} onPreviewPage={setPreviewPage}/>}
       {step==="expert" && <PlaceholderStep title="专家确认" note="专家确认流程待开发,后续版本将支持领域专家在线评审并签署方案。"/>}
     </div>
     <div className="sticky-actions"><Button secondary onClick={()=>curIdx>0?goStep(DOMAIN_STEPS[curIdx-1].key):onBack()}>{curIdx>0?"上一步":"返回列表"}</Button><div><Button secondary onClick={()=>notify("草稿已保存")}>保存草稿</Button>{curIdx<DOMAIN_STEPS.length-1 && <Button onClick={()=>goStep(DOMAIN_STEPS[curIdx+1].key)}>下一步</Button>}{curIdx===DOMAIN_STEPS.length-1 && <Button onClick={()=>notify("方案已提交专家确认")}>提交确认</Button>}</div></div>
@@ -1821,7 +1821,7 @@ function FunctionsStep({muTree,grouped,effSelected,onToggleMu,pages,onAddPage,on
   </Panel>;
 }
 
-function DomainGenerateReview({tName,assets,effSelected,grouped,pages,docs,onBack,onKnowledge,onConfirm}:{tName:string;assets:DomainAsset[];effSelected:string[];grouped:any[];pages:DomainPage[];docs:any[];onBack:()=>void;onKnowledge?:()=>void;onConfirm:()=>void}){
+function DomainGenerateReview({tName,assets,effSelected,grouped,pages,docs,onBack,onKnowledge,onConfirm,onPreviewPage}:{tName:string;assets:DomainAsset[];effSelected:string[];grouped:any[];pages:DomainPage[];docs:any[];onBack:()=>void;onKnowledge?:()=>void;onConfirm:()=>void;onPreviewPage?:(p:DomainPage)=>void}){
   const reviewRows:[string,string,string,string?,(()=>void)?][]=[
     ["方案",tName,"","返回修改",onBack],
     ["资产",`${assets.filter(a=>a.device_template_id).length} 类 / ${assets.reduce((n,a)=>n+Number(a.count||0),0)} 个设备`,"3 层资产结构",""],
@@ -1841,7 +1841,7 @@ function DomainGenerateReview({tName,assets,effSelected,grouped,pages,docs,onBac
   <div className="gen-right">
     <Panel title="页面总览" className="task-panel">
       <p className="hint">以下为方案中已配置的 Cube 扩展页面预览(点击可放大查看)。如需调整,请返回「功能与菜单」。</p>
-      <div className="domain-review-pages">{pages.length===0? <div className="muted">暂无 Cube 页面</div> : pages.map(p=><article key={p.id} className="domain-review-page"><DomainPageThumb jsonText={p.json_text} emptyLabel="未生成"/><div><Status>{p.status==="generated"?"AI已生成":p.status==="uploaded"?"已上传":"未生成"}{p.source?` · ${p.source==="ai"?"AI":p.source==="upload"?"上传":"手动"}`:""}</Status><h4>{p.name}</h4></div></article>)}</div>
+      <div className="domain-review-pages">{pages.length===0? <div className="muted">暂无 Cube 页面</div> : pages.map(p=><article key={p.id} className="domain-review-page" style={{cursor:"pointer"}} onClick={()=>onPreviewPage&&onPreviewPage(p)}><DomainPageThumb jsonText={p.json_text} emptyLabel="未生成"/><div><Status>{p.status==="generated"?"AI已生成":p.status==="uploaded"?"已上传":"未生成"}{p.source?` · ${p.source==="ai"?"AI":p.source==="upload"?"上传":"手动"}`:""}</Status><h4>{p.name}</h4><span className="review-zoom">⛶ 点击放大</span></div></article>)}</div>
       <div style={{display:"flex",gap:8,marginTop:12}}><Button secondary onClick={onBack}>返回上一步</Button><Button onClick={onConfirm}>确认方案</Button></div>
     </Panel>
   </div></div>;
index bd413b68431f65a5365877a8254424856f988c7b..d6e9dcc9f612187ae3045a5a7dbe55a8ce8191dc 100644 (file)
Binary files a/data/db/StationBuilder.sqlite and b/data/db/StationBuilder.sqlite differ
index 4e3ec6b0b3d624b73cd69905bc90f8b6404ddf9f..8c88fa3112eb43b7eb84be6fc378f7a31a329bf4 100644 (file)
@@ -4,6 +4,7 @@
 (function () {
   var COLS = 24;
   var ROWS = 25; // 24 列网格,整页 25 行贴合 1920×1080
+  var _curInst = []; // 当前这一次渲染产生的 echarts 实例(按容器维度管理,避免误杀其它缩略图)
 
   function el(tag, style, text) {
     var e = document.createElement(tag);
     content.appendChild(div);
     var inst = echarts.init(div, undefined, { renderer: "canvas" });
     inst.setOption(option);
-    if (!renderDashboard._instances) renderDashboard._instances = [];
-    renderDashboard._instances.push(inst);
+    _curInst.push(inst);
   }
 
   function renderPanel(cell, p, ctx) {
 
   function renderDashboard(json, container, options) {
     options = options || {};
-    if (renderDashboard._instances) renderDashboard._instances.forEach(function (i) { try { i.dispose(); } catch (e) {} });
-    renderDashboard._instances = [];
+    // 仅销毁「本容器」上一次渲染产生的 echarts 实例;禁止全局销毁,否则同一页面内的多张缩略图会互相误杀导致图表空白。
+    if (container.__dashInst) { container.__dashInst.forEach(function (i) { try { i.dispose(); } catch (e) {} }); container.__dashInst = null; }
+    _curInst = [];
     container.innerHTML = "";
     if (!json || !json.panels || !json.panels.length) {
       container.innerHTML = '<div style="padding:16px;color:#999">(看板无内容,可能尚未生成)</div>';
       root.appendChild(card);
     });
     container.appendChild(root);
+    container.__dashInst = _curInst;
 
     if (options.fit) {
       // 按宽度铺满(与真实 Cube 一致:填满屏宽、垂直滚动),避免整体被等比缩小显得“压缩”。
index b6b3647053c71e583f85f8223a4acf2b8ab136bf..75e6da7f30b6391bdc549abe4231305bc811814d 100644 (file)
   <nav class="doc-nav"><div class="doc-nav-inner" id="toc"></div></nav>
 
 <main id="doc">
-  <p class="muted">文档版本:v0.29 最后更新:2026-08-21 修改人:wang.yu 状态:开发中(演示数据已标注)</p>
+  <p class="muted">文档版本:v0.30 最后更新:2026-08-21 修改人:wang.yu 状态:开发中(演示数据已标注)</p>
   <h1>StationBuilder 产品与交互设计方案</h1>
 
   <h2>1. 文档说明</h2>
   <h3>10.1 版本变更记录(Changelog)</h3>
   <table class="changelog">
     <tr><th>版本</th><th>日期</th><th>修改人</th><th>说明</th></tr>
+    <tr><td>v0.30</td><td>2026-08-21</td><td>wang.yu</td><td>修复领域方案「生成保存」页面预览全部空白:根因为 <code>public/dashboard-renderer.js</code> 的 <code>renderDashboard</code> 每次渲染开头执行 <code>renderDashboard._instances.forEach(i=&gt;i.dispose())</code> 把<b>全部全局 echarts 实例销毁</b>——同一方案 11 张页面缩略图顺序渲染时,后一张的渲染把前一张的图表实例 dispose 掉,仅最后一张卡片保留图表、其余均为空白(DOM 有内容但 0 个 <code>&lt;canvas&gt;</code>)。修复:废弃全局实例数组,改为<b>按容器隔离</b>——每个容器记录自身 <code>container.__dashInst</code>,渲染前仅销毁本容器旧实例并清空 <code>innerHTML</code>,渲染后回挂本容器实例;新增模块级 <code>_curInst</code> 收集本次渲染的实例。现已验证污水厂(橙色风格)方案 11 张卡片全部渲染出图表(共 43 个 canvas,每卡 ≥0)。附带增强:① 生成确认页「领域方案页面」列表卡片改为整宽单列大图(<code>.domain-review-pages</code> 由两列改 1 列,<code>.dashboard-thumb-live</code> 最小高度 115px→240px),并整体可点击——点击卡片经新增 <code>onPreviewPage</code> 入参打开 <code>DomainPagePreviewModal</code> 全屏预览(含图表),卡片右下角加「⛶ 点击放大」提示。② globals.css 增补 <code>.domain-review-page</code> hover 描边与 <code>.review-zoom</code> 提示样式。Playwright E2E:11 卡全部有 canvas、点开预览 modal 含 2 个 canvas + 361 字正文,0 控制台报错。</td></tr>
     <tr><td>v0.29</td><td>2026-08-21</td><td>wang.yu</td><td>领域方案库 / 站点「功能与菜单 → Cube 扩展页面」遗留问题修复与体验提升:① <b>页签改名与排序</b>:站点与领域两处「Cube 扩展页面」页签统一更名为「领域扩展页面」,并调整顺序置于「MU 标准页面」之前(默认进入该页签);文档同步。② <b>生成按钮醒目化</b>:两处扩展页面表格的「生成」按钮文案升级为「AI生成」、生成中显示「AI生成中」,并采用醒目渐变蓝紫样式(<code>.btn-ai</code>,覆盖 <code>.cube-actions button</code> 白底默认态)。③ <b>统一生成</b>:领域方案「领域扩展页面」表格上方新增「⚡ AI 统一生成全部(N)」按钮(N = 未生成页面数),一键对所有「未生成」且未在生成中的页面并发触发 <code>generatePage</code>;后端按每页独立锁 + <code>AI_CONCURRENCY=2</code> 信号量排队,各自页面按钮同步显示「AI生成中」,免去逐个点击(仅保留单页生成)。④ <b>图片加载提速</b>:后端 db-sidecar 的 <code>handlePageStyleImage</code> / <code>handleDeviceTemplateImage</code> 新增 <code>?w=</code> 宽度参数,经 sharp 生成 WebP 缩略图并落盘缓存(mtime 校验失效重生成),原图约 1.4MB → 缩略图约 9KB(提速约 150 倍);前端样式选择器(?<w=360)、页面样式库列表(?<w=560)、编辑预览(?<w=640)均改用缩略图,大图灯箱仍取原图。需 <code>systemctl restart stationbuilder-db</code> 生效(新增 sharp 依赖,Node 原生模块已就绪)。Playwright E2E:领域方案进入「领域扩展页面」页签确认置首、10 个页面按钮显示「AI生成」、统一按钮计数正确;新增待生成页面后点「AI 统一生成全部」其按钮即时翻为「AI生成中」、其余已生成页保持「AI生成」,0 控制台报错;页面样式库 5 张缩略图均带 <code>?w=</code>、缩略图接口实测 1355KB→8.9KB。</td></tr>
     <tr><td>v0.28</td><td>2026-08-21</td><td>wang.yu</td><td>设备模板「采集点」AI 智能补全(两处入口):① 领域方案「AI 资产补全」自动创建设备模板后,后端立即以设备名称为其生成 6~12 个合适采集点并写库(resolveDeviceTemplate 返回 created:true 时触发;追加模式,单个失败不影响整体,推导过程回流到资产补全控制台;已存在模板不重复补全)。② 设备模板详情(DeviceTemplateCard)新增「AI 智能补全 · 采集点」面板:「开始补全」调用新增接口 POST /api/db/device-templates/:id/complete-collection-points(流式 NDJSON,codebuddy 生成 + 写库,前端补全后重新拉取详情刷新表格),「查看提示词」GET /api/db/device-templates/:id/complete-collection-points-prompt 弹窗展示完整组合提示词。后端新增 buildCompleteCollectionPointsPrompt / extractCollectionPoints / generateDeviceTemplatePoints(按 name 去重追加,不覆盖用户已录入采集点;数值范围优先取预设集合);前端新增 DeviceTemplateCard 的 cpAi=useStreamComplete() 流式面板,并透传 onViewCpPrompt 复用 App 级提示词弹窗;资产补全前端超时守卫由 6 分钟放宽到 10 分钟(因额外补全采集点)。Playwright E2E:临时设备点「开始补全」→ 实际运行 codebuddy 生成 12 个采集点并持久化(GET 详情 collection_points.length=12),控制台显示「✓ 已补全 12 个采集点」,0 控制台报错,临时设备已清理。设计文档同步更新 4.12 / 新增 4.12.2、版本 v0.28。</td></tr>
     <tr><td>v0.27</td><td>2026-08-21</td><td>wang.yu</td><td>领域编辑迁移至左侧导航栏:① 移除「领域方案库」首页的「管理领域」面板,首页方案列表相应上移(直接进入列表,不再被管理面板挤占首屏)。② 领域增删改入口迁至左侧「领域方案库模块 → 领域分类」分组,UI 与现有领域分类导航项一致:每个领域行尾加 × 删除按钮(仅非「其他」领域,点击经 window.confirm 二次确认后 DELETE /api/domains/:id,其下方案归入「其他」,随后 reloadDomains 刷新导航);行末新增「+ 新增领域」按钮,点击展开行内输入框(最多 6 字,Enter 确认 / Esc 取消),POST /api/domains 后刷新导航。③ 修复 addDomain 原只更新前端 domains 字符串数组、未刷新导航用 domainOptions 导致新增后导航不出现的问题——现改由 reloadDomains 统一刷新两个 state。④ globals.css 新增 .domain-nav-row / .domain-nav-del / .domain-nav-ok / .domain-nav-add / .domain-nav-input 等样式,保持与导航按钮一致的 41px 行高与激活态。Playwright 验证:面板已移除、导航显示 6 领域行(5 个 × + 1 个 +)、新增/删除临时领域均生效、「其他」无删除按钮、0 控制台报错。后续微调:领域名按钮与 ×/+ 按钮原共用 `.domain-nav-row>button` 的 `flex:1` 导致 × 也等分给到约 95px、过滤按钮被压窄至 95px、4 字以内领域名在用户浏览器中换行;改为 `flex:1` 仅作用于过滤按钮(`:not(.domain-nav-del):not(.domain-nav-ok)`),×/✓ 固定 30px,领域名加 `white-space:nowrap` 并包入 `.domain-nav-name` 超出省略号,4 字以内领域名稳定单行显示。</td></tr>