]> acesimba.cloud Git - StationBuilder.git/commitdiff
功能与菜单页改造:MU树状全选、Cube AI补全、菜单只读预览,并精简MU标准页为9项
authorwang.yu <wang.yu@advantech.com>
Tue, 18 Aug 2026 03:29:55 +0000 (11:29 +0800)
committerwang.yu <wang.yu@advantech.com>
Tue, 18 Aug 2026 03:29:55 +0000 (11:29 +0800)
app/globals.css
app/page.tsx
db-sidecar.mjs
public/design-doc.html

index 49e8d15819d6d4dafb471d8601d3179d549f8df9..beee6448f17b0e6013d59b61b80b7951fd442e2e 100644 (file)
@@ -98,7 +98,7 @@
 /* ===== Functions 功能与菜单 ===== */
 .mu-tree{display:flex;flex-direction:column;gap:14px}
 .mu-group>b{display:block;color:var(--blue2);font-size:13px;margin-bottom:6px}
-.mu-items{display:grid;grid-template-columns:repeat(3,1fr);gap:7px;padding-left:4px}
+.mu-items{display:flex;flex-direction:column;gap:6px;padding:6px 0 4px 18px;border-left:1px dashed var(--line);margin-left:6px}
 .mu-items label{display:flex;align-items:flex-start;gap:7px;border:1px solid var(--line);border-radius:5px;padding:7px 9px;background:#fff;cursor:pointer;font-size:12px}
 .mu-items label:hover{border-color:#9ed3ec;background:#f4fafe}
 .mu-items label.disabled{opacity:.5;cursor:not-allowed;background:#f6f7f8}
 .menu-row input,.menu-row select{border:1px solid var(--line);border-radius:3px;height:30px;padding:0 8px;background:#fff;width:100%}
 .menu-row .trash{border:1px solid #f0c4bd;background:#fff;color:var(--red);border-radius:4px;height:30px;width:34px;font-size:15px}
 .menu-row .trash:hover{background:#fdeae6;border-color:var(--red)}
+.menu-preview .menu-tree{border:1px solid var(--line);background:#fff}
+.menu-preview .menu-root{padding:10px 12px;font-weight:bold;border-bottom:1px solid var(--line);background:#fafbfc}
+.menu-preview .menu-section{padding:10px 12px;border-bottom:1px solid #eee}
+.menu-preview .menu-section:last-child{border-bottom:0}
+.menu-preview .menu-section-title{font-size:12px;color:var(--blue2);font-weight:bold;margin-bottom:7px}
+.menu-preview .menu-leaf{padding:5px 0 5px 16px;font-size:12px;color:#555;border-left:2px solid #e1e7ea;margin:3px 0 3px 4px}
+.menu-preview .menu-leaf.cube{border-left-color:#c9b8f0}
+.menu-preview .menu-leaf.muted{color:#aaa;border-left-color:#eee}
 
 /* ===== Generate 预览 line 面板 ===== */
 .line-panel{background:#fff;border:1px solid var(--line);border-radius:6px;padding:11px 13px;display:flex;flex-direction:column;gap:8px}
index d7e1627dca5679a0ac6dd012452f12ac8bbbdb00..1ac549775ac5abdc1e0549afbbc8ecbae9f49c3c 100644 (file)
@@ -218,6 +218,15 @@ export default function Home(){
       setModal("completePrompt");
     }catch(e:any){ notify("读取提示词失败: "+e.message); }
   };
+  const viewCompleteCubesPrompt=async()=>{
+    if(!site?.id) { notify("请先创建或打开一个站点"); return; }
+    try{
+      const qs=new URLSearchParams({name:site.name||"",domain:site.domain||"",description:site.description||"",supplement:site.supplement||""}).toString();
+      const data:{prompt:string}=await fetchDb(`/sites/${site.id}/complete-cubes-prompt?${qs}`);
+      setPromptData({name:"AI Cube 页面补全", text:data.prompt});
+      setModal("completePrompt");
+    }catch(e:any){ notify("读取提示词失败: "+e.message); }
+  };
   const openFeedback=(cube:Cube)=>{
     if(!cube.id){ notify("请先生成该页面再提出修改意见"); return; }
     setFeedbackCube(cube);
@@ -450,7 +459,7 @@ export default function Home(){
       {page==="styleLibrary"&&<PageStyleLibrary pageStyles={pageStyles} loadPageStyles={loadPageStyles} notify={notify}/>}
       {page==="basic"&&<Basic site={site} domains={domains} onChange={saveSiteBasic} onAddDomain={addDomain} onUpload={uploadFile} saving={saving} notify={notify} onViewCompletePrompt={viewCompletePrompt}/>}
       {page==="assets"&&<Assets site={site} onChange={saveAssets} saving={saving} notify={notify} onViewCompletePrompt={viewCompleteAssetsPrompt}/>}
-      {page==="functions"&&<Functions site={site} muTree={muTree} onMuChange={saveMu} onSiteChange={setSite} pageStyles={pageStyles} notify={notify}/>}
+      {page==="functions"&&<Functions site={site} muTree={muTree} onMuChange={saveMu} onSiteChange={setSite} pageStyles={pageStyles} notify={notify} onViewCompletePrompt={viewCompleteCubesPrompt}/>}
       {page==="generate"&&<Generate site={site} onSiteChange={setSite} notify={notify} viewPrompt={viewPrompt} viewSitePrompt={viewSitePrompt}/>}
       {page==="result"&&<Result site={site} go={go} setModal={setModal} viewPrompt={viewPrompt} notify={notify} openFeedback={openFeedback} viewSitePrompt={viewSitePrompt}/>}
       {page==="preview"&&<Preview site={site} tab={previewTab} setTab={setPreviewTab} setModal={setModal} notify={notify}/>}
@@ -653,9 +662,8 @@ function Assets({site,onChange,saving,notify,onViewCompletePrompt}:{site:SiteDet
   </div>;
 }
 
-function Functions({site,muTree,onMuChange,onSiteChange,pageStyles,notify}:{site:SiteDetail|null;muTree:MuNode[];onMuChange:(s:string[])=>void;onSiteChange:(s:SiteDetail)=>void;pageStyles:PageStyle[];notify:(m:string)=>void}){
+function Functions({site,muTree,onMuChange,onSiteChange,pageStyles,notify,onViewCompletePrompt}:{site:SiteDetail|null;muTree:MuNode[];onMuChange:(s:string[])=>void;onSiteChange:(s:SiteDetail)=>void;pageStyles:PageStyle[];notify:(m:string)=>void;onViewCompletePrompt:()=>void}){
   const [tab,setTab]=useState<"mu"|"cube"|"menu">("mu");
-  const selectedMu = site?.mu || [];
   const leaves = useMemo(()=>buildMuLeaves(muTree),[muTree]);
   const grouped = useMemo(()=>{
     const map = new Map<string,{parentMenu:string, items:typeof leaves}>();
@@ -665,9 +673,14 @@ function Functions({site,muTree,onMuChange,onSiteChange,pageStyles,notify}:{site
     }
     return Array.from(map.values());
   },[leaves]);
+  const allMuKeys = useMemo(()=>leaves.map(l=>l.key),[leaves]);
+  const selectedMu = site?.mu || [];
+  // 默认全选中:site.mu 为空时按全部叶子键显示,首次勾选才落地保存
+  const effSelected = selectedMu.length ? selectedMu : allMuKeys;
   if(!site) return <Panel><div className="empty-state">加载中…</div></Panel>;
   const toggleMu=(key:string)=>{
-    onMuChange(selectedMu.includes(key)?selectedMu.filter(k=>k!==key):[...selectedMu,key]);
+    const base = selectedMu.length ? selectedMu : allMuKeys;
+    onMuChange(base.includes(key)? base.filter(k=>k!==key) : [...base, key]);
   };
   const updateCube=(idx:number, patch:Partial<Cube>)=>{
     const next = site.cubes.map((c,i)=>i===idx?{...c,...patch}:c);
@@ -719,44 +732,98 @@ function Functions({site,muTree,onMuChange,onSiteChange,pageStyles,notify}:{site
     notify(`预览 ${cube.name}`);
   };
 
-  // menu editor state
-  const [menu, setMenu] = useState<MenuItem[]>([]);
-  useEffect(()=>{ setMenu(site.menu.length?site.menu:[{parent_id:null,label:"站点监控",page_type:"",ref_id:"",sort_order:0}]); },[site?.menu]);
-  const saveMenu=async()=>{
+  // ===== AI Cube 页面补全 =====
+  const [completing,setCompleting]=useState(false);
+  const [completeLog,setCompleteLog]=useState("");
+  const completeConsoleRef=useRef<HTMLPreElement>(null);
+  useEffect(()=>{ if(completeConsoleRef.current) completeConsoleRef.current.scrollTop=completeConsoleRef.current.scrollHeight; },[completeLog]);
+  const appendCubes=(items:Array<{name:string;content:string}>)=>{
+    const base = site?.cubes || [];
+    const next = [...base, ...items.map((it,i)=>({name:it.name, content:it.content||"", status:"pending", sort_order: base.length+i}))];
+    onSiteChange({...site, cubes:next});
+    notify(`AI 已补充 ${items.length} 个 Cube 页面(位于您已填项下方)`);
+  };
+  const runCompleteCubes=async()=>{
+    if(!site?.id || completing) return;
+    setCompleting(true); setCompleteLog("");
+    const watchdog=setTimeout(()=>{ setCompleting(false); notify("AI Cube 补全超时(>6 分钟),请重试"); }, 360000);
     try{
-      await fetchDb(`/sites/${site.id}/menu`, {method:"PUT", headers:{"Content-Type":"application/json"}, body:JSON.stringify({menu})});
-      onSiteChange({...site, menu});
-      notify("菜单结构已保存");
-    }catch(e:any){ notify("菜单保存失败: "+e.message); }
+      const res=await fetch(`/api/db/sites/${site.id}/complete-cubes`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:site.name,domain:site.domain,description:site.description,supplement:site.supplement,assets:site.assets})});
+      if(!res.ok||!res.body) throw new Error("HTTP "+res.status);
+      const reader=res.body.getReader(); const dec=new TextDecoder(); let buf="";
+      for(;;){
+        const {done,value}=await reader.read(); if(done) break;
+        buf+=dec.decode(value,{stream:true});
+        const lines=buf.split("\n"); buf=lines.pop()||"";
+        for(const line of lines){
+          if(!line.trim()) continue;
+          try{
+            const ev=JSON.parse(line);
+            if(ev.log) setCompleteLog(prev=>prev+ev.log);
+            if(Array.isArray(ev.result)) appendCubes(ev.result);
+          }catch{}
+        }
+      }
+      notify("AI Cube 页面补全已完成");
+    }catch(e:any){ notify("AI Cube 页面补全失败:"+(e?.message||e)); }
+    finally{ clearTimeout(watchdog); setCompleting(false); }
   };
 
-  return <Panel><div className="tabs"><button className={tab==="mu"?"active":""} onClick={()=>setTab("mu")}>MU 标准页面 <i>{selectedMu.length}</i></button><button className={tab==="cube"?"active":""} onClick={()=>setTab("cube")}>Cube 扩展页面 <i>{site.cubes.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=><label key={item.key} className={item.disabled?"disabled":""} title={item.note}><input type="checkbox" disabled={item.disabled} checked={selectedMu.includes(item.key)} onChange={()=>toggleMu(item.key)}/><span>{item.label}{item.note&&<small>{item.note}</small>}</span></label>)}</div></div>)}</div>}
-    {tab==="cube"&&<div className="cube-editor"><table className="data-table editable"><thead><tr><th>页面名称 <small>最多6字</small></th><th>页面内容</th><th>生成文件</th><th>操作</th></tr></thead><tbody>{site.cubes.map((cube,i)=><tr key={i}>
-      <td><input value={cube.name} maxLength={6} placeholder="例如:运行总览" onChange={e=>updateCube(i,{name:e.target.value})}/></td>
-      <td><input value={cube.content} placeholder="描述该页面要展示的内容和指标" onChange={e=>updateCube(i,{content:e.target.value})}/></td>
-      <td>{cube.status==="generated"? <button className="link" onClick={()=>downloadCube(cube)}>下载 {cube.gen_filename}</button> : <span className="muted">未生成</span>}</td>
-      <td className="cube-actions"><button onClick={()=>previewCube(cube)}>预览</button><button onClick={()=>generateCube(i)}>生成</button><button onClick={()=>saveCube(i)}>保存</button><button className="trash" onClick={()=>removeCube(i)}>删除</button></td>
-    </tr>)}</tbody></table><Button onClick={addCube}>+ 添加 Cube 页面</Button></div>}
-    {tab==="cube"&&<div className="style-picker">
-      <h4>站点页面样式 <small>站点级 · 全部 Cube 页面共用</small></h4>
-      <p className="hint">选择一个样式库方案作为本站点所有 Cube 页面的视觉风格(生成时参考其 JSON 主题参数)。</p>
-      <div className="style-pick-list">
-        <button className={"style-pick"+(!site.style_id?" active":"")} onClick={()=>selectStyle(null)}>
-          <div className="style-pick-thumb none">无</div><span>不使用样式</span>
-        </button>
-        {pageStyles.map(s=><button key={s.id} className={"style-pick"+(site.style_id===s.id?" active":"")} onClick={()=>selectStyle(s.id)}>
-          <div className="style-pick-thumb"><img src={`/api/db/page-styles/${s.id}/image`} alt={s.name} loading="lazy"/></div>
-          <span>{s.name}</span>
-        </button>)}
+  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>{site.cubes.length}</i></button><button className={tab==="menu"?"active":""} onClick={()=>setTab("menu")}>菜单结构</button></div>
+    {/* MU 标准页面:树状上下结构,仅保留勾选框,默认全选中 */}
+    {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=><label key={item.key} className={item.disabled?"disabled":""}><input type="checkbox" disabled={item.disabled} checked={effSelected.includes(item.key)} onChange={()=>toggleMu(item.key)}/><span>{item.label}</span></label>)}</div></div>)}</div>}
+    {/* Cube 扩展页面:左右两栏,右栏为填写建议 + AI 智能补全 */}
+    {tab==="cube"&&<div className="two-column">
+      <div>
+        <div className="cube-editor"><table className="data-table editable"><thead><tr><th>页面名称 <small>最多6字</small></th><th>页面内容</th><th>生成文件</th><th>操作</th></tr></thead><tbody>{site.cubes.map((cube,i)=><tr key={i}>
+          <td><input value={cube.name} maxLength={6} placeholder="例如:运行总览" onChange={e=>updateCube(i,{name:e.target.value})}/></td>
+          <td><input value={cube.content} placeholder="描述该页面要展示的内容和指标" onChange={e=>updateCube(i,{content:e.target.value})}/></td>
+          <td>{cube.status==="generated"? <button className="link" onClick={()=>downloadCube(cube)}>下载 {cube.gen_filename}</button> : <span className="muted">未生成</span>}</td>
+          <td className="cube-actions"><button onClick={()=>previewCube(cube)}>预览</button><button onClick={()=>generateCube(i)}>生成</button><button onClick={()=>saveCube(i)}>保存</button><button className="trash" onClick={()=>removeCube(i)}>删除</button></td>
+        </tr>)}</tbody></table><Button onClick={addCube}>+ 添加 Cube 页面</Button></div>
+        <div className="style-picker">
+          <h4>站点页面样式 <small>站点级 · 全部 Cube 页面共用</small></h4>
+          <p className="hint">选择一个样式库方案作为本站点所有 Cube 页面的视觉风格(生成时参考其 JSON 主题参数)。</p>
+          <div className="style-pick-list">
+            <button className={"style-pick"+(!site.style_id?" active":"")} onClick={()=>selectStyle(null)}>
+              <div className="style-pick-thumb none">无</div><span>不使用样式</span>
+            </button>
+            {pageStyles.map(s=><button key={s.id} className={"style-pick"+(site.style_id===s.id?" active":"")} onClick={()=>selectStyle(s.id)}>
+              <div className="style-pick-thumb"><img src={`/api/db/page-styles/${s.id}/image`} alt={s.name} loading="lazy"/></div>
+              <span>{s.name}</span>
+            </button>)}
+          </div>
+        </div>
+      </div>
+      <div className="stack">
+        <Panel title="填写建议"><ul className="tips"><li>从站点业务与设备资产出发,规划需要的监控维度(综合总览、能耗能效、设备健康、报警事件、环境监测、生产产量等)。</li><li>每个 Cube 页面写明:页面目标、关键指标/图表、数据来源与模拟数据构造思路。</li><li>页面名称简洁,不超过 6 个汉字。</li><li>AI 补充的页面会追加在您已创建页面下方,不会改动您原有的内容。</li></ul></Panel>
+        <Panel title="AI 智能补全">
+          <p className="hint">基于站点名称 / 领域 / 描述 / 补充要求,以及设备资产层级,由 AI 分析并补充站点所需的 Cube 扩展页面(页面名称 + 详细内容),追加在您已建页面下方。</p>
+          <div style={{display:"flex",gap:"8px",alignItems:"center"}}>
+            <button className="button" disabled={completing} onClick={runCompleteCubes}>{completing?"补全中…":"开始补全"}</button>
+            <button className="text-button" onClick={onViewCompletePrompt}>查看提示词</button>
+          </div>
+          {(completing||completeLog)&&<div className="modal-console"><div className="modal-console-head">AI 智能补全</div><pre className="modal-console-body" ref={completeConsoleRef}>{completeLog||"(等待 AI 分析…)"}</pre></div>}
+        </Panel>
+      </div>
+    </div>}
+    {/* 菜单结构:只读默认预览(MU 在上,Cube 全部归入「综合监控」一级菜单) */}
+    {tab==="menu"&&<div className="menu-preview">
+      <p className="hint">默认菜单结构(展示用,无需编辑):MU 标准页面在上,所有 Cube 扩展页面归入「综合监控」一级菜单下。</p>
+      <div className="menu-tree">
+        <div className="menu-root">⌄ {site.name || "未命名站点"}</div>
+        <div className="menu-section">
+          <div className="menu-section-title">MU 标准页面</div>
+          {leaves.filter(l=>effSelected.includes(l.key)).map(l=><div key={l.key} className="menu-leaf">· {l.parentMenu}/{l.label}</div>)}
+          {leaves.filter(l=>effSelected.includes(l.key)).length===0&&<div className="menu-leaf muted">(未选择 MU 页面)</div>}
+        </div>
+        <div className="menu-section">
+          <div className="menu-section-title">综合监控</div>
+          {site.cubes.filter(c=>c.name&&c.name.trim()).map(c=><div key={c.id||c.name} className="menu-leaf cube">· {c.name}</div>)}
+          {site.cubes.filter(c=>c.name&&c.name.trim()).length===0&&<div className="menu-leaf muted">(暂无 Cube 页面)</div>}
+        </div>
       </div>
     </div>}
-    {tab==="menu"&&<div className="menu-editor"><p className="hint">配置站点菜单结构,可选择引用 MU 标准页或 Cube 扩展页。</p>{menu.map((m,i)=><div key={i} className="menu-row"><input value={m.label} placeholder="菜单名称" onChange={e=>{const next=[...menu]; next[i]={...next[i],label:e.target.value}; setMenu(next);}}/>
-      <select value={m.page_type||""} onChange={e=>{const next=[...menu]; next[i]={...next[i],page_type:e.target.value as any}; setMenu(next);}}><option value="">分组</option><option value="mu">MU 页面</option><option value="cube">Cube 页面</option></select>
-      {m.page_type==="mu"&&<select value={String(m.ref_id)} onChange={e=>{const next=[...menu]; next[i]={...next[i],ref_id:e.target.value}; setMenu(next);}}><option value="">选择 MU 页面</option>{leaves.filter(l=>!l.disabled).map(l=><option key={l.key} value={l.key}>{l.parentMenu}/{l.label}</option>)}</select>}
-      {m.page_type==="cube"&&<select value={String(m.ref_id)} onChange={e=>{const next=[...menu]; next[i]={...next[i],ref_id:Number(e.target.value)}; setMenu(next);}}><option value="">选择 Cube 页面</option>{site.cubes.map(c=><option key={c.id||c.name} value={c.id||""}>{c.name}</option>)}</select>}
-      <button className="trash" onClick={()=>setMenu(menu.filter((_,idx)=>idx!==i))}>删除</button>
-    </div>)}<Button secondary onClick={()=>setMenu([...menu,{parent_id:null,label:"",page_type:"",ref_id:"",sort_order:menu.length}])}>+ 添加菜单项</Button><Button onClick={saveMenu}>保存菜单</Button></div>}
   </Panel>;
 }
 
index 1da3dc290c39b9ce786b91601e58f10858e4b5e8..d18f8de332fffa0a48fac2fab83cae988a41793e 100644 (file)
@@ -38,37 +38,15 @@ const DEFAULT_DOMAINS = [
 const MU_TREE = [
   {
     key: "设备监控",
-    children: [
-      { key: "设备总览", tabs: ["列表式", "卡片式"] },
-      {
-        key: "设备详情",
-        tabs: [
-          "设备工况", "告警记录",
-          { label: "维修记录", disabled: true, note: "第一阶段暂不开发" },
-          { label: "保养记录", disabled: true, note: "第一阶段暂不开发" },
-          { label: "巡检记录", disabled: true, note: "第一阶段暂不开发" },
-          "上下线记录", "设备文档", "区域监控",
-          { label: "设备视频", disabled: true, note: "第一阶段暂不开发" },
-          "远程快速控制", "远程控制计划", "设备轨迹",
-        ],
-      },
-    ],
+    tabs: ["设备总览", "设备详情"],
   },
   {
     key: "数据分析",
-    children: [
-      { key: "数据分析" },
-      { key: "报表分析" },
-      { key: "用量分析" },
-    ],
+    tabs: ["数据分析", "报表分析", "用量分析"],
   },
   {
     key: "智能控制",
-    children: [
-      { key: "远程控制计划" },
-      { key: "执行记录" },
-      { key: "控制模板" },
-    ],
+    tabs: ["远程控制计划", "执行记录", "控制模板"],
   },
 ];
 
@@ -1011,6 +989,108 @@ async function handleRefineCube(req, res, siteId, cubeId) {
     return respondJson(res, 200, { prompt: buildCompleteAssetsPrompt(ctx, ctx.assets) });
   }
 
+  // ===== AI Cube 页面补全:基于站点信息 + 资产层级,推断并补充 Cube 扩展页面 =====
+  function buildCompleteCubesPrompt(site) {
+    const assets = (site.assets || []).filter(a => a && a.type && a.type.trim());
+    const assetText = assets.length
+      ? assets.map(a => `- ${a.type.trim()} 实例数量:${Number(a.count) || 0}${a.rule ? `(命名规则:${a.rule.trim()})` : ""}`).join("\n")
+      : "(无,用户尚未配置设备资产)";
+    const existingCubes = (site.cubes || []).filter(c => c && c.name && c.name.trim()).map(c => c.name.trim());
+    const existingText = existingCubes.length ? existingCubes.join("、") : "(无,用户尚未创建)";
+    return `你是一名资深的工业站点数字化大屏方案架构师,擅长根据站点业务背景与设备资产,规划所需的可视化监控页面(Cube 扩展页)。
+请根据以下信息,推断该站点监控大屏所需的 Cube 扩展页面,并为每个页面撰写详尽的页面内容说明,用于补充「Cube 扩展页面」列表(位于用户已建页面下方)。
+
+站点信息:
+- 站点名称:${site.name || "未命名站点"}
+- 所属领域:${site.domain || "未指定"}
+- 站点基本情况描述:${site.description || "无"}
+- 补充要求:${site.supplement || "无"}
+- 设备资产层级(站点 → 设备类型 → 实例):
+${assetText}
+- 用户已手工创建的 Cube 页面(请勿重复、不要修改,仅补充新增):${existingText}
+
+要求:
+1. 先简要说明分析思路(思考过程):从站点业务与设备资产出发,推断需要哪些监控维度(如综合总览、能耗能效、设备健康、报警事件、环境监测、生产产量等),以及每个页面应覆盖的核心内容。
+2. 思考过程结束后,另起一段,以「【Cube页面】」开头,给出 JSON 数组(仅数组本身,不要多余文字与代码围栏),每个元素为 {"name":"页面名称(最多6个汉字)","content":"页面内容详述"}。
+3. content 需详实,至少覆盖:① 页面目标;② 关键展示要素(图表/指标卡/模块);③ 数据范围(来自哪些设备资产);④ 模拟数据构造思路(指标量级、单位、典型值)。
+4. 仅补充用户尚未创建的页面;页面名称简洁且不超过 6 个汉字。
+
+请直接输出你的分析与【Cube页面】JSON。`;
+  }
+
+  function extractCubes(text) {
+    const marker = "【Cube页面】";
+    const idx = text.lastIndexOf(marker);
+    let body = idx >= 0 ? text.slice(idx + marker.length) : text;
+    body = body.replace(/```(?:json)?/gi, "").trim();
+    const s = body.indexOf("[");
+    const e = body.lastIndexOf("]");
+    if (s < 0 || e <= s) throw new Error("未在输出中找到 Cube 页面 JSON");
+    const arr = JSON.parse(body.slice(s, e + 1));
+    if (!Array.isArray(arr)) throw new Error("Cube 页面不是数组");
+    return arr.filter(x => x && x.name).map(x => ({
+      name: String(x.name).trim().slice(0, 6),
+      content: x.content != null ? String(x.content).trim() : "",
+    }));
+  }
+
+  async function handleCompleteCubes(req, res, siteId) {
+    res.writeHead(200, { "Content-Type": "application/x-ndjson; charset=utf-8", "Cache-Control": "no-cache" });
+    const emit = (obj) => streamEvent(res, obj);
+    const fail = (message) => { emit({ task: "系统", status: "failed", message }); res.end(); };
+    if (genLocks.has(`complete-cubes-${siteId}`)) return fail("AI Cube 补全正在进行中");
+    genLocks.add(`complete-cubes-${siteId}`);
+    try {
+      const site = hydrateSite(siteId);
+      if (!site) return fail("站点不存在");
+      const body = await parseBody(req).catch(() => ({}));
+      const ctx = {
+        name: (body.name ?? site.name) || "",
+        domain: (body.domain ?? site.domain) || "",
+        description: (body.description ?? site.description) || "",
+        supplement: (body.supplement ?? site.supplement) || "",
+        assets: body.assets || site.assets || [],
+        cubes: site.cubes || [],
+      };
+      const prompt = buildCompleteCubesPrompt(ctx);
+      emit({ prompt });
+      emit({ log: `\n▶ 开始 AI Cube 补全:基于站点信息与设备资产,推断并补充 Cube 扩展页面…\n` });
+      emit({ task: "AI Cube 补全", status: "running" });
+      const onEvent = (o) => {
+        if (o && o.type === "stream_event" && o.event?.type === "content_block_delta" && o.event.delta?.type === "text_delta" && o.event.delta.text) {
+          emit({ log: o.event.delta.text });
+        }
+      };
+      const text = await runCodebuddy(prompt, `stationbuilder-complete-cubes-${siteId}`, onEvent);
+      const items = extractCubes(text);
+      emit({ log: `\n✓ 已生成 ${items.length} 个 Cube 页面\n` });
+      emit({ result: items });
+      emit({ task: "AI Cube 补全", status: "done" });
+      res.end();
+    } catch (e) {
+      emit({ log: `✗ AI Cube 补全失败:${e.message}\n` });
+      emit({ task: "系统", status: "failed", message: e.message });
+      res.end();
+    } finally {
+      genLocks.delete(`complete-cubes-${siteId}`);
+    }
+  }
+
+  async function handleCompleteCubesPrompt(req, res, siteId) {
+    const site = hydrateSite(siteId);
+    if (!site) return respondError(res, 404, "站点不存在");
+    const u = new URL(req.url, "http://localhost");
+    const ctx = {
+      name: u.searchParams.get("name") ?? site.name ?? "",
+      domain: u.searchParams.get("domain") ?? site.domain ?? "",
+      description: u.searchParams.get("description") ?? site.description ?? "",
+      supplement: u.searchParams.get("supplement") ?? site.supplement ?? "",
+      assets: site.assets || [],
+      cubes: site.cubes || [],
+    };
+    return respondJson(res, 200, { prompt: buildCompleteCubesPrompt(ctx) });
+  }
+
 async function handleGenerateAll(req, res, siteId) {
   res.writeHead(200, { "Content-Type": "application/x-ndjson; charset=utf-8", "Cache-Control": "no-cache" });
   if (genLocks.has(`site-${siteId}`)) {
@@ -1230,6 +1310,8 @@ async function route(req, res) {
   const mSiteCompletePrompt = path.match(/^\/api\/sites\/(\d+)\/complete-prompt$/);
   const mSiteCompleteAssets = path.match(/^\/api\/sites\/(\d+)\/complete-assets$/);
   const mSiteCompleteAssetsPrompt = path.match(/^\/api\/sites\/(\d+)\/complete-assets-prompt$/);
+  const mSiteCompleteCubes = path.match(/^\/api\/sites\/(\d+)\/complete-cubes$/);
+  const mSiteCompleteCubesPrompt = path.match(/^\/api\/sites\/(\d+)\/complete-cubes-prompt$/);
   const mCubePromptNew = path.match(/^\/api\/sites\/(\d+)\/cubes\/prompt$/);
   const mSiteMenu = path.match(/^\/api\/sites\/(\d+)\/menu$/);
   const mSiteFiles = path.match(/^\/api\/sites\/(\d+)\/files$/);
@@ -1375,6 +1457,8 @@ async function route(req, res) {
     if (mSiteCompletePrompt) return handleCompletePrompt(req, res, Number(mSiteCompletePrompt[1]));
     if (mSiteCompleteAssets) return handleCompleteAssets(req, res, Number(mSiteCompleteAssets[1]));
     if (mSiteCompleteAssetsPrompt) return handleCompleteAssetsPrompt(req, res, Number(mSiteCompleteAssetsPrompt[1]));
+    if (mSiteCompleteCubes) return handleCompleteCubes(req, res, Number(mSiteCompleteCubes[1]));
+    if (mSiteCompleteCubesPrompt) return handleCompleteCubesPrompt(req, res, Number(mSiteCompleteCubesPrompt[1]));
     if (mSiteCubes) {
       const siteId = Number(mSiteCubes[1]);
       if (req.method === "GET") return respondJson(res, 200, db.prepare("SELECT c.*, c.prompt, gf.filename as gen_filename FROM cubes c LEFT JOIN gen_files gf ON gf.id=c.gen_file_id WHERE c.site_id=? ORDER BY c.sort_order,c.id").all(siteId));
index a5234979ee023e5b30f8d1993882ba1879cc98ff..893c0d909b0fa47958b4560b728eb255c9c9d60f 100644 (file)
   <p>三个子页签:</p>
   <table>
     <tr><th>元素</th><th>功能</th><th>数据来源</th><th>交互效果</th></tr>
-    <tr><td>MU 标准页面(复选)</td><td>选择标准页面</td><td>GET /api/db/mu-tree</td><td>勾选即保存(PUT)</td></tr>
+    <tr><td>MU 标准页面(树状复选)</td><td>树状上下结构列出标准页面,仅保留勾选框</td><td>GET /api/db/mu-tree</td><td>默认全选中(site.mu 为空时按全部叶子键显示,首次取消才落地保存);勾选即保存(PUT)</td></tr>
     <tr><td>Cube 扩展页面表格(名称/内容/生成文件/操作)</td><td>定义并管理扩展页面</td><td>GET/POST/PUT/DELETE /api/db/sites/:id/cubes</td><td>行内编辑、生成、保存、删除、下载;可添加页面</td></tr>
     <tr><td>「生成」单页按钮</td><td>单独生成某个 Cube</td><td>POST /api/db/sites/:id/cubes/:cid/generate</td><td>触发生成(该路径为非流式,无实时日志) <span class="tag">与生成确认页不同</span></td></tr>
     <tr><td>站点页面样式选择器</td><td>为全站 Cube 套用视觉样式</td><td>GET /api/db/page-styles;PUT /api/db/sites</td><td>选择样书后应用到站点(写入 style_id),生成时参考其主题</td></tr>
-    <tr><td>菜单结构编辑器</td><td>配置站点菜单(引用 MU / Cube)</td><td>PUT /api/db/sites/:id/menu</td><td>增删菜单项、选择页面类型与引用;保存菜单</td></tr>
+    <tr><td>Cube「AI 智能补全」面板</td><td>基于站点名称 / 领域 / 描述 / 补充要求 + 设备资产层级,由 AI 推断并补充 Cube 扩展页面(页面名称 + 详细内容)</td><td>POST /api/db/sites/:id/complete-cubes(流式 NDJSON);GET .../complete-cubes-prompt 取提示词</td><td>「开始补全」后控制台流式输出思考过程,结束将 AI 页面追加到用户已建页面下方(不改动用户内容)并自动保存;「查看提示词」可查看完整组合提示词</td></tr>
+    <tr><td>菜单结构(只读默认预览)</td><td>展示默认菜单结构</td><td>本地计算(MU + Cube)</td><td>MU 标准页面在上;所有 Cube 页面归入「综合监控」一级菜单,二级为各 Cube 页面名称;本页仅为展示,无编辑/保存 <span class="tag">默认结构</span></td></tr>
   </table>
 
   <h3>5.6 生成确认(generate)</h3>
     <li>2026-08-18 修复「下载 Markdown」导入 Azure DevOps Wiki 后仅显示到 §4.2 的问题:代码块改以 4 空格缩进式代码块导出(不再用 ``` 围栏),避免围栏闭合异常吞掉后续章节;表格单元格内将 <code>&lt;</code> 与 <code>&gt;</code> 转义为 HTML 实体,避免 Azure 把 <code>&lt;select&gt;</code> 当成未闭合标签吞掉后续内容。</li>
     <li>2026-08-18 新增「AI 智能补全」功能:站点基本属性页「填写建议」下方新增面板,点击「开始补全」后基于站点名称 / 所属领域 / 站点基本情况描述,由 AI 流式分析并自动写入「补充要求」字段;「查看提示词」可查看完整组合提示词(后端 <code>POST /api/db/sites/:id/complete</code> 流式、<code>GET .../complete-prompt</code> 取提示词)。</li>
     <li>2026-08-18 扩展「AI 智能补全」至 Asset 资产设置页:右侧新增与基本属性同款的「填写建议 + AI 智能补全」栏;点击「开始补全」后基于站点名称 / 领域 / 描述 / 补充要求分析并补充设备类型、数量预设与命名规则,追加到用户已填项下方(不改动已有项),资产层级预览随之重算(后端 <code>POST /api/db/sites/:id/complete-assets</code> 流式、<code>GET .../complete-assets-prompt</code> 取提示词)。</li>
+    <li>2026-08-18 重构「功能与菜单」页:① MU 标准页面改为树状上下结构、仅保留勾选框、默认全选中;② Cube 扩展页面右侧新增「填写建议 + AI 智能补全」栏,点击「开始补全」后基于站点属性与设备资产层级由 AI 推断并补充 Cube 扩展页面(名称 + 详尽内容),追加到用户已建页面下方(不改动用户内容)(后端 <code>POST /api/db/sites/:id/complete-cubes</code> 流式、<code>GET .../complete-cubes-prompt</code> 取提示词);③ 菜单结构改为只读默认预览:MU 页面在上,所有 Cube 页面归入「综合监控」一级菜单、二级为各 Cube 名称,仅展示无编辑。</li>
   </ul>
 
   <ul>