]> acesimba.cloud Git - StationBuilder.git/commitdiff
fix(domain): Cube 生成按钮流式化 + AI补全写入列表防静默丢失
authorwang.yu <wang.yu@advantech.com>
Thu, 20 Aug 2026 04:13:20 +0000 (12:13 +0800)
committerwang.yu <wang.yu@advantech.com>
Thu, 20 Aug 2026 04:13:20 +0000 (12:13 +0800)
- generatePage 改为流式消费 NDJSON 响应体(保持连接),生成期间按钮显示「生成中…」并禁用、底部新增「AI 生成页面」控制台实时输出进度,并据服务端 {status:failed} 弹失败提示;修复此前普通 fetch 不读响应体导致连接提前关闭、服务端 AI 生成中断(status=failed)、Json 未写入的问题。
- runCubesComplete 由逐条静默 .catch 插入改为收集结果后逐个 await 插入、失败显式 notify,并捕获服务端 {status:failed} 事件弹失败提示,避免补全内容静默丢失。
- design-doc 同步 v0.19 变更记录。

app/page.tsx
public/design-doc.html

index 901ac7edd1b22bb9e8e6cd8d12ec15ce587d5637..55917962542200c4dc611c073693ce889da4d984 100644 (file)
@@ -1426,6 +1426,8 @@ function DomainWizard({id,doms,go,notify,onBack,viewCompletePrompt,viewCompleteA
   const basicAi=useStreamComplete();
   const assetsAi=useStreamComplete();
   const cubesAi=useStreamComplete();
+  const [genIds,setGenIds]=useState<Set<number>>(new Set());
+  const [genLog,setGenLog]=useState("");
   const {confirm, el:confirmEl}=useConfirm();
 
   const domainName = doms.find((d:any)=>d.id===m?.domain_id)?.name || "";
@@ -1496,12 +1498,16 @@ function DomainWizard({id,doms,go,notify,onBack,viewCompletePrompt,viewCompleteA
     if(cubesAi.completing) return;
     cubesAi.setCompleting(true); cubesAi.setLog("");
     const wd=setTimeout(()=>{ cubesAi.setCompleting(false); notify("AI Cube 补全超时(>6 分钟),请重试"); },360000);
+    let failed=""; let items:any[]=[];
     try{
       const res=await fetch(`/api/db/domain-templates/${id}/complete-cubes`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:m.name,domain:domainName,description:m.description,supplement:m.supplement,assets:assets.map(a=>({type:a.device_name,count:a.count,rule:a.rule}))})});
       if(!res.ok||!res.body) throw new Error("HTTP "+res.status);
-      await streamInto(res, cubesAi.setLog, (ev)=>{ if(Array.isArray(ev.result)){ for(const it of ev.result) fetchDb(`/domain-templates/${id}/pages`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:it.name,description:it.content||"",json_text:"{}"})}).catch(()=>{}); } });
+      await streamInto(res, cubesAi.setLog, (ev)=>{ if(Array.isArray(ev.result)) items=ev.result; if(ev?.status==="failed") failed=ev?.message||"AI 分析失败"; });
+      if(failed){ notify("AI Cube 补全失败:"+failed); return; }
+      let added=0;
+      for(const it of items){ try{ await fetchDb(`/domain-templates/${id}/pages`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:it.name,description:it.content||"",json_text:"{}"})}); added++; }catch(e:any){ notify(`新增页面「${it.name||""}」失败:${e.message}`); } }
       await load(true);
-      notify("AI Cube 页面补全已完成");
+      notify(items.length? `AI Cube 页面补全已完成,已新增 ${added} 个页面` : "AI 未返回可用页面");
     }catch(e:any){ notify("AI Cube 补全失败:"+(e?.message||e)); }
     finally{ clearTimeout(wd); cubesAi.setCompleting(false); }
   };
@@ -1532,7 +1538,20 @@ function DomainWizard({id,doms,go,notify,onBack,viewCompletePrompt,viewCompleteA
   };
   const savePageMeta=async(p:DomainPage)=>{ await fetchDb(`/domain-pages/${p.id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:p.name,description:p.description,json_text:p.json_text})}); notify("页面已保存"); };
   const deletePage=async(p:DomainPage)=>{ if(!(await confirm(`删除页面「${p.name}」?`))) return; await fetchDb(`/domain-pages/${p.id}`,{method:"DELETE"}); await load(true); };
-  const generatePage=async(p:DomainPage)=>{ notify(`开始生成「${p.name}」…`); try{ await fetch(`/api/db/domain-templates/${id}/pages/${p.id}/generate`,{method:"POST"}); await load(true); notify(`「${p.name}」生成完成`); }catch(e:any){ notify("生成失败:"+e.message); await load(true); } };
+  const generatePage=async(p:DomainPage)=>{
+    if(genIds.has(p.id)) return;
+    setGenIds(prev=>new Set(prev).add(p.id)); setGenLog("");
+    notify(`开始生成「${p.name}」…`);
+    let failed="";
+    try{
+      const res=await fetch(`/api/db/domain-templates/${id}/pages/${p.id}/generate`,{method:"POST"});
+      if(!res.ok||!res.body) throw new Error("HTTP "+res.status);
+      await streamInto(res, setGenLog, (ev)=>{ if(ev?.status==="failed") failed=ev?.message||"生成失败"; });
+      if(failed) notify(`「${p.name}」生成失败:${failed}`);
+      else notify(`「${p.name}」生成完成`);
+    }catch(e:any){ notify("生成失败:"+(e?.message||e)); }
+    finally{ setGenIds(prev=>{ const n=new Set(prev); n.delete(p.id); return n; }); await load(true); }
+  };
   const downloadPage=(p:DomainPage)=>{ const blob=new Blob([p.json_text||"{}"],{type:"application/json"}); downloadBlob(blob, p.gen_filename||`${p.name||"page"}.json`); };
 
   if(loading||!t||!m) return <p>加载中…</p>;
@@ -1611,6 +1630,7 @@ function DomainWizard({id,doms,go,notify,onBack,viewCompletePrompt,viewCompleteA
       {step==="functions" && <FunctionsStep
         muTree={muTreeLocal} grouped={grouped} effSelected={effSelected} onToggleMu={toggleMu}
         pages={pages} onAddPage={addPage} onUploadPageJson={uploadPageJson} onSavePageMeta={savePageMeta} onDeletePage={deletePage} onGeneratePage={generatePage} onDownloadPage={downloadPage} onPreviewPage={setPreviewPage}
+        genIds={genIds} genLog={genLog}
         cubesAi={cubesAi} onRunCubesComplete={runCubesComplete} onViewCompleteCubesPrompt={()=>viewCompleteCubesPrompt(id,m.name,domainName,m.description,m.supplement||"",assets.map(a=>({type:a.device_name,count:a.count,rule:a.rule})))}
         updatePageField={(i,patchRow)=>setPages(prev=>prev.map((p,idx)=>idx===i?{...p,...patchRow}:p))}
         tName={m.name} saving={saving} notify={notify} />}
@@ -1628,13 +1648,15 @@ function PlaceholderStep({title,note}:{title:string;note:string}){
   return <Panel title={title}><div className="placeholder-block"><span className="badge todo">待开发</span><p>{note}</p></div></Panel>;
 }
 
-function FunctionsStep({muTree,grouped,effSelected,onToggleMu,pages,onAddPage,onUploadPageJson,onSavePageMeta,onDeletePage,onGeneratePage,onDownloadPage,onPreviewPage,cubesAi,onRunCubesComplete,onViewCompleteCubesPrompt,updatePageField,tName,saving,notify}:{muTree:MuNode[];grouped:any[];effSelected:string[];onToggleMu:(k:string)=>void;pages:DomainPage[];onAddPage:()=>Promise<void>;onUploadPageJson:(f:File)=>Promise<void>;onSavePageMeta:(p:DomainPage)=>Promise<void>;onDeletePage:(p:DomainPage)=>Promise<void>;onGeneratePage:(p:DomainPage)=>Promise<void>;onDownloadPage:(p:DomainPage)=>void;onPreviewPage:(p:DomainPage)=>void;cubesAi:ReturnType<typeof useStreamComplete>;onRunCubesComplete:()=>Promise<void>;onViewCompleteCubesPrompt:()=>void;updatePageField:(i:number,patch:Partial<DomainPage>)=>void;tName:string;saving:boolean;notify:(m:string)=>void}){
+function FunctionsStep({muTree,grouped,effSelected,onToggleMu,pages,onAddPage,onUploadPageJson,onSavePageMeta,onDeletePage,onGeneratePage,onDownloadPage,onPreviewPage,genIds,genLog,cubesAi,onRunCubesComplete,onViewCompleteCubesPrompt,updatePageField,tName,saving,notify}:{muTree:MuNode[];grouped:any[];effSelected:string[];onToggleMu:(k:string)=>void;pages:DomainPage[];onAddPage:()=>Promise<void>;onUploadPageJson:(f:File)=>Promise<void>;onSavePageMeta:(p:DomainPage)=>Promise<void>;onDeletePage:(p:DomainPage)=>Promise<void>;onGeneratePage:(p:DomainPage)=>Promise<void>;onDownloadPage:(p:DomainPage)=>void;onPreviewPage:(p:DomainPage)=>void;genIds:Set<number>;genLog:string;cubesAi:ReturnType<typeof useStreamComplete>;onRunCubesComplete:()=>Promise<void>;onViewCompleteCubesPrompt:()=>void;updatePageField:(i:number,patch:Partial<DomainPage>)=>void;tName:string;saving:boolean;notify:(m:string)=>void}){
   const sourceBadge=(src?:string|null)=>{
     if(src==="upload") return <span className="badge src-upload">↑ 上传</span>;
     if(src==="ai") return <span className="badge src-ai">✦ AI生成</span>;
     return <span className="badge src-manual">手动</span>;
   };
   const [tab,setTab]=useState<"mu"|"cube"|"menu">("mu");
+  const genLogRef=useRef<HTMLPreElement>(null);
+  useEffect(()=>{ if(genLogRef.current) genLogRef.current.scrollTop=genLogRef.current.scrollHeight; },[genLog]);
   return <Panel><div className="tabs"><button className={tab==="mu"?"active":""} onClick={()=>setTab("mu")}>MU 标准页面 <i>{effSelected.length}</i></button><button className={tab==="cube"?"active":""} onClick={()=>setTab("cube")}>Cube 扩展页面 <i>{pages.length}</i></button><button className={tab==="menu"?"active":""} onClick={()=>setTab("menu")}>菜单结构</button></div>
     {tab==="mu" && <div className="mu-tree">{grouped.map(g=><div key={g.parentMenu} className="mu-group"><b>{g.parentMenu}</b><div className="mu-items">{g.items.map((item:any)=><label key={item.key} className={item.disabled?"disabled":""}><input type="checkbox" disabled={item.disabled} checked={effSelected.includes(item.key)} onChange={()=>onToggleMu(item.key)}/><span>{item.label}</span></label>)}</div></div>)}</div>}
     {tab==="cube" && <div className="two-column">
@@ -1644,9 +1666,10 @@ function FunctionsStep({muTree,grouped,effSelected,onToggleMu,pages,onAddPage,on
           <td><input value={p.description} placeholder="描述该页面要展示的内容与指标" onChange={e=>updatePageField(i,{description:e.target.value})}/></td>
           <td>{sourceBadge(p.source)}</td>
           <td>{p.gen_filename? <button className="link" onClick={()=>onDownloadPage(p)}>下载 {p.gen_filename}</button> : <span className="muted">未生成</span>}</td>
-          <td className="cube-actions"><button onClick={()=>onPreviewPage(p)}>预览</button><button onClick={()=>onGeneratePage(p)}>生成</button><button onClick={()=>onSavePageMeta(p)}>保存</button><button className="trash" onClick={()=>onDeletePage(p)}>删除</button></td>
+          <td className="cube-actions"><button onClick={()=>onPreviewPage(p)}>预览</button><button disabled={genIds.has(p.id)} onClick={()=>onGeneratePage(p)}>{genIds.has(p.id)?"生成中…":"生成"}</button><button onClick={()=>onSavePageMeta(p)}>保存</button><button className="trash" onClick={()=>onDeletePage(p)}>删除</button></td>
         </tr>)}</tbody></table></div>
         <div style={{display:"flex",gap:8,marginTop:8}}><Button onClick={onAddPage}>+ 添加 Cube 页面</Button><label className="button secondary" style={{cursor:"pointer"}}>↑ 上传页面 Json<input type="file" accept=".json,application/json" hidden onChange={e=>{const f=e.target.files?.[0]; if(f) onUploadPageJson(f); e.target.value="";}}/></label></div>
+        {(genIds.size>0||genLog)&&<div className="modal-console"><div className="modal-console-head">AI 生成页面</div><pre className="modal-console-body" ref={genLogRef}>{genLog||"(等待生成…)"}</pre></div>}
       </div>
       <div className="stack">
         <Panel title="填写建议"><ul className="tips"><li>从方案业务与设备资产出发,规划监控维度(综合总览、能耗能效、设备健康、报警事件、环境监测等)。</li><li>页面名称简洁,不超过 6 个汉字;描述写清页面目标与关键指标。</li><li>每个 Cube 页面可「生成」(AI 创建 JSON)或「上传页面 Json」(使用已有 JSON)。</li><li>已生成页面可点击「预览」查看效果,并在预览中提交修改意见重新生成。</li></ul></Panel>
index 3687f02b23c9dc7b516bd01be3e5c3a19740a271..5c4ac3118f4300c396ffa879a8c77664f2af9e06 100644 (file)
   <nav class="doc-nav"><div class="doc-nav-inner" id="toc"></div></nav>
 
 <main id="doc">
-  <p class="muted">文档版本:v0.17 最后更新:2026-08-19 修改人:wang.yu 状态:开发中(演示数据已标注)</p>
+  <p class="muted">文档版本:v0.19 最后更新:2026-08-20 修改人:wang.yu 状态:开发中(演示数据已标注)</p>
   <h1>StationBuilder 产品与交互设计方案</h1>
 
   <h2>1. 文档说明</h2>
   <h3>11.1 版本变更记录(Changelog)</h3>
   <table class="changelog">
     <tr><th>版本</th><th>日期</th><th>修改人</th><th>说明</th></tr>
+    <tr><td>v0.19</td><td>2026-08-20</td><td>wang.yu</td><td>修复领域方案「功能与菜单 → Cube 扩展页面」两处问题:①「生成」按钮改为流式请求(消费 NDJSON 响应体以保持连接),生成期间按钮显示「生成中…」并禁用、底部「AI 生成页面」控制台实时输出进度并据服务端 <code>{status:failed}</code> 事件弹失败提示;此前前端用普通 <code>fetch</code> 不读取响应体,连接被提前关闭导致服务端 AI 生成中断(<code>status='failed'</code>)、Json 未写入。② AI 智能补全(complete-cubes)由「逐条静默 <code>.catch(()=>{})</code> 插入」改为「收集结果后逐个 <code>await</code> 插入、失败显式 <code>notify</code>」,并捕获服务端 <code>{status:failed}</code> 事件弹失败提示,避免补全内容静默丢失、列表无变化。</td></tr>
     <tr><td>v0.17</td><td>2026-08-19</td><td>wang.yu</td><td>修复左侧目录(TOC)点击章节后消失:根因为顶部版本说明段落含全角空格不换行,在 flex 布局下把 <code>main#doc</code> 撑到约 2238px 宽(flex item 默认 <code>min-width:auto</code> 不收缩),点击锚点时浏览器水平滚动将 sticky 目录推出左边界。给 <code>main#doc</code> 增加 <code>min-width:0</code> 使其可收缩、内容正常折行,消除水平滚动,目录任意宽度常驻可见。</td></tr>
     <tr><td>v0.18</td><td>2026-08-20</td><td>wang.yu</td><td>领域方案「功能与菜单 → Cube 扩展页面」表格增强:① 新增「来源」列,以徽标区分 上传 / AI生成 / 手动;② 原「生成文件」列更名为「Json文件」,对上传页面提供原始 .json 文件名下载、对 AI 生成页面提供生成的 Cube .json 下载(未生成显示"未生成");③ domain_pages 新增 source 字段,上传/生成时分别写入 source=upload/ai 与 gen_filename;④ 生成确认页状态徽标区分「AI已生成 / 已上传 / 未生成 · 来源」。同步更新 5.10 章节。</td></tr>
     <tr><td>v0.16</td><td>2026-08-19</td><td>wang.yu</td><td>设计方案文档修订:① 左侧目录改为任意宽度常驻可见(移除窄屏 <code>display:none</code>,窄屏下列表转为顶部可滚动常驻栏),点击章节后目录不再消失;② 移除「内部登录」章节(5.1)及 5.13 登录截图,可达性说明去掉登录;③ 新增 AI 智能补全子章(5.3.2 / 5.4.2 / 5.5.2),附 AI 流式输出终端截图(ai-complete-basic/assets/functions.png);④ 修复 PDF 导出图片被分页截断(<code>@media print</code> 增加 <code>page-break-inside:avoid</code> 与图片适配);⑤ 修复 Word 导出无图片(<code>downloadWord</code> 将图片转为 base64 data URI 内嵌)。</td></tr>