]> acesimba.cloud Git - StationBuilder.git/commitdiff
领域方案库重构为 7 步向导(仿 Station Builder)
authorwang.yu <wang.yu@advantech.com>
Wed, 19 Aug 2026 09:53:30 +0000 (17:53 +0800)
committerwang.yu <wang.yu@advantech.com>
Wed, 19 Aug 2026 09:53:30 +0000 (17:53 +0800)
- 方案基本属性:补充描述输入 + 右侧填写建议 + AI 智能补全
- 资产设置:设备模板库下拉(禁止自由输入/+新增)、4 行可编辑资产表、
  资产层级预览、AI 补全(自动建缺失设备模板)、新增设备模板弹窗
- 功能与菜单:MU(默认8项勾选)/Cube(上传Json+AI生成+实时预览+修改意见
  精修)/菜单 三 Tab
- 运维流程/领域知识/专家确认:待开发占位(待开发徽标)
- 生成保存:资产+菜单+页面总览预览 + 专家确认方案
- 后端 db-sidecar:domain-templates 补全/资产/Cube/页面生成与精修接口,
  handleDomainTemplateDetail/Update 持久化补充描述+资产+MU

app/globals.css
app/page.tsx
db-sidecar.mjs

index 6c91e257c1de1d78aa1d8f72d494793eb9db1875..d8f2158e149f287a5c09a8cd75f0c766f97f70a8 100644 (file)
 .domain-card .badge{display:inline-block;align-self:flex-start;background:var(--pale);color:var(--blue2);border:1px solid #cfeaf6;border-radius:10px;padding:1px 10px;font-size:10px}
 .domain-card .meta{display:flex;gap:14px;color:#888;font-size:11px}
 .domain-card .meta b{color:#333;font-size:13px;margin-right:3px}
+
+/* 领域方案 7 步向导 */
+.placeholder-block{display:flex;flex-direction:column;gap:12px;padding:6px 2px}
+.badge.todo{display:inline-block;align-self:flex-start;background:#fff4d8;color:#b37400;border:1px solid #f6bc44;border-radius:10px;padding:2px 10px;font-size:10px}
+.domain-preview-canvas{background:#1e2630;min-height:430px;border-radius:6px;overflow:hidden}
+.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-page h4{margin:0;font-size:13px}
+
 .domain-card .card-actions{display:flex;gap:14px;margin-top:2px;padding-top:8px;border-top:1px solid #f0f2f3}
 /* ===== 设备模版 ===== */
 .device-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:12px;padding:4px 0}
index a1ef3a6beb4c5fe6e08f57a36825f4020663a81e..c6f7948b251b07426c46b671a723aca93f2ce571 100644 (file)
@@ -48,6 +48,9 @@ type SiteDetail = {
 type MuTab = string | { label:string; disabled?:boolean; note?:string };
 type MuNode = { key:string; children?:MuNode[]; tabs?:MuTab[] };
 type PageStyle = { id:number; name:string; created_at?:string };
+type DomainAsset = { id?:number; device_template_id:number; device_name:string; count:number; rule:string; sort_order:number };
+type DomainPage = { id:number; name:string; description:string; json_text:string; status:string; gen_filename?:string|null; prompt?:string|null; sort_order:number };
+type DomainStepKey = "basic" | "assets" | "functions" | "opsflow" | "knowledge" | "generate" | "expert";
 
 // ---- helpers ----
 function Status({children}:{children:React.ReactNode}){const v=String(children);const tone=/完成|正常|成功|通过|推荐|generated/.test(v)?"ok":/生成中|校验中|告警|running/.test(v)?"warn":/失败|异常|failed/.test(v)?"bad":"muted";return <span className={"status "+tone}>{children}</span>}
@@ -231,6 +234,30 @@ export default function Home(){
       setModal("completePrompt");
     }catch(e:any){ notify("读取提示词失败: "+e.message); }
   };
+  const viewDtCompletePrompt=async(id:number,name:string,domain:string,description:string)=>{
+    try{
+      const qs=new URLSearchParams({name,domain,description}).toString();
+      const data:{prompt:string}=await fetchDb(`/domain-templates/${id}/complete-prompt?${qs}`);
+      setPromptData({name:"AI 智能补全", text:data.prompt});
+      setModal("completePrompt");
+    }catch(e:any){ notify("读取提示词失败: "+e.message); }
+  };
+  const viewDtCompleteAssetsPrompt=async(id:number,name:string,domain:string,description:string,supplement:string)=>{
+    try{
+      const qs=new URLSearchParams({name,domain,description,supplement}).toString();
+      const data:{prompt:string}=await fetchDb(`/domain-templates/${id}/complete-assets-prompt?${qs}`);
+      setPromptData({name:"AI 资产补全", text:data.prompt});
+      setModal("completePrompt");
+    }catch(e:any){ notify("读取提示词失败: "+e.message); }
+  };
+  const viewDtCompleteCubesPrompt=async(id:number,name:string,domain:string,description:string,supplement:string,assets:any[])=>{
+    try{
+      const qs=new URLSearchParams({name,domain,description,supplement}).toString();
+      const data:{prompt:string}=await fetchDb(`/domain-templates/${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);
@@ -460,7 +487,7 @@ export default function Home(){
     <main className="workspace"><div className="crumb">iStation StationBuilder / <b>{["domains","domainDetail","styleLibrary","deviceTemplates"].includes(page)?meta[page].label:page==="projects"?"站点列表":`站点详情 / ${meta[page].label}`}</b></div><div className="page-heading"><div><h1>{meta[page].label}</h1><p>{meta[page].subtitle}</p></div><div>{!["projects","domains","domainDetail","styleLibrary","deviceTemplates"].includes(page)&&<Button secondary onClick={()=>notify("草稿已保存")}>保存草稿</Button>}{page==="projects"&&<Button onClick={()=>go("basic","new")}>+ 新建站点</Button>}{page==="domains"&&<Button onClick={()=>setOpenDomainTemplateId("create")}>+ 新增领域方案</Button>}{page==="domains"&&openDomainTemplateId!=null&&<Button secondary onClick={()=>setOpenDomainTemplateId(null)}>← 返回方案列表</Button>}{domainFilter!=null&&page==="domains"&&<Button secondary onClick={()=>{setDomainFilter(null);}}>清除领域筛选:{domainOptions.find(d=>d.id===domainFilter)?.name||""}</Button>}</div></div>{(["basic","assets","functions","generate"] as PageKey[]).includes(page)&&<Steps page={page} go={go}/>}<div className="content">
       {page==="projects"&&<Projects view={view} setView={setView} sites={sites} loading={loading} go={go} loadSite={loadSite} notify={notify} onDelete={deleteSites}/>}
       {(page==="domains"||page==="domainDetail")&&(openDomainTemplateId!=null
-        ? <DomainDetail id={openDomainTemplateId==="create"?null:openDomainTemplateId} go={go} notify={notify} onBack={()=>setOpenDomainTemplateId(null)} onCreated={setOpenDomainTemplateId}/>
+        ? <DomainDetail id={openDomainTemplateId==="create"?null:openDomainTemplateId} go={go} notify={notify} onBack={()=>setOpenDomainTemplateId(null)} onCreated={setOpenDomainTemplateId} viewCompletePrompt={viewDtCompletePrompt} viewCompleteAssetsPrompt={viewDtCompleteAssetsPrompt} viewCompleteCubesPrompt={viewDtCompleteCubesPrompt}/>
         : <DomainLibrary go={go} notify={notify} onOpen={(id:number)=>setOpenDomainTemplateId(id)} domainFilter={domainFilter} domainOptions={domainOptions}/>)}
       {page==="styleLibrary"&&<PageStyleLibrary pageStyles={pageStyles} loadPageStyles={loadPageStyles} notify={notify}/>}
       {page==="deviceTemplates"&&<DeviceTemplates notify={notify}/>}
@@ -1282,57 +1309,386 @@ function PageRow({p,notify,onChange}:{p:any;notify:(m:string)=>void;onChange:()=
 
 function OpsFlowsViewer(){ const [flows,setFlows]=useState<any[]>([]); useEffect(()=>{ fetchDb("/ops-flows").then((d:any)=>setFlows(d||[])).catch(()=>{}); },[]); return <Panel title="运维流程(本期为占位,后续实现)"><table className="data-table"><thead><tr><th>流程名称</th><th>内容</th></tr></thead><tbody>{flows.length===0? <tr><td colSpan={2} className="muted">暂无</td></tr> : flows.map((f:any)=><tr key={f.id}><td>{f.name}</td><td style={{maxWidth:480,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{f.content}</td></tr>)}</tbody></table><p className="muted">运维流程功能尚未实现,当前仅保留占位记录。</p></Panel>; }
 
-function DomainDetail({id,go,notify,onBack,onCreated}:{id:number|null;go:(p:PageKey)=>void;notify:(m:string)=>void;onBack:()=>void;onCreated:(nid:number)=>void}){
+function DomainDetail({id,go,notify,onBack,onCreated,viewCompletePrompt,viewCompleteAssetsPrompt,viewCompleteCubesPrompt}:{id:number|null;go:(p:PageKey)=>void;notify:(m:string)=>void;onBack:()=>void;onCreated:(nid:number)=>void;viewCompletePrompt:(id:number,name:string,domain:string,description:string)=>void;viewCompleteAssetsPrompt:(id:number,name:string,domain:string,description:string,supplement:string)=>void;viewCompleteCubesPrompt:(id:number,name:string,domain:string,description:string,supplement:string,assets:any[])=>void}){
   const [doms,setDoms]=useState<any[]>([]);
-  const [t,setT]=useState<any>(null);
   const [loading,setLoading]=useState(false);
-  const [m,setM]=useState<any>(null);
-  const {confirm, el:confirmEl}=useConfirm();
-  const {prompt, el:promptEl}=usePrompt();
   const loadDoms=async()=>{ try{ setDoms(await fetchDb("/domains") as any); }catch{} };
-  const load=async(silent=false)=>{ if(id==null){ setT(null); return; } if(!silent) setLoading(true); try{ setT(await fetchDb(`/domain-templates/${id}`) as any); }catch(e:any){ notify("加载失败:"+e.message); } finally{ if(!silent) setLoading(false); } };
-  useEffect(()=>{ loadDoms(); load(); },[id]);
-  useEffect(()=>{ if(t) setM({domain_id:t.domain_id,name:t.name,description:t.description||"",status:t.status,version:t.version,creator:t.creator||""}); },[t?.id]);
+  useEffect(()=>{ loadDoms(); },[]);
   if(id==null) return <CreateTemplate doms={doms} notify={notify} onCreated={onCreated}/>;
-  if(loading||!t) return <p>加载中…</p>;
-  if(!m) return <p>加载中…</p>;
-  const saveMeta=async()=>{ await fetchDb(`/domain-templates/${id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(m)}); notify("已保存模板信息"); load(); };
-  const addPage=async()=>{ await fetchDb(`/domain-templates/${id}/pages`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:"新页面",description:"",json_text:"{}"})}); load(); };
-  const addMenu=async()=>{ await fetchDb(`/domain-templates/${id}/menu`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({label:"新菜单项"})}); load(); };
-  const addDoc=async()=>{ const nm=await prompt("文档名称"); if(!nm) return; const link=await confirm("是否为超链?(确定=超链,取消=上传文件)"); if(link){ const u=await prompt("文档超链 URL"); if(!u) return; await fetchDb(`/domain-templates/${id}/docs`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:nm,doc_type:"link",url:u})}); } else { const f:any=await new Promise(res=>{ const input=document.createElement("input"); input.type="file"; input.onchange=()=>res(input.files?.[0]); input.click(); }); if(!f) return; const b=await fileToBase64(f); await fetchDb(`/domain-templates/${id}/docs`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:nm,doc_type:"file",file:{filename:b.filename,mime:b.mime,data:b.data}})}); } load(); };
-  const delDoc=async(did:number)=>{ await fetchDb(`/knowledge-docs/${did}`,{method:"DELETE"}); load(); };
-  const dlDoc=async(did:number)=>{ const r=await fetch(`/api/db/knowledge-docs/${did}/file`); const blob=await r.blob(); const d=t.docs.find((x:any)=>x.id===did); downloadBlob(blob, d?.name||"doc"); };
+  return <DomainWizard id={id} doms={doms} go={go} notify={notify} onBack={onBack} viewCompletePrompt={viewCompletePrompt} viewCompleteAssetsPrompt={viewCompleteAssetsPrompt} viewCompleteCubesPrompt={viewCompleteCubesPrompt}/>;
+}
+
+const DOMAIN_STEPS:{key:DomainStepKey;label:string}[]=[
+  {key:"basic",label:"方案基本属性"},
+  {key:"assets",label:"资产设置"},
+  {key:"functions",label:"功能与菜单"},
+  {key:"opsflow",label:"运维流程"},
+  {key:"knowledge",label:"领域知识"},
+  {key:"generate",label:"生成保存"},
+  {key:"expert",label:"专家确认"},
+];
+
+function DomainSteps({step,go}:{step:DomainStepKey;go:(s:DomainStepKey)=>void}){
+  const current=DOMAIN_STEPS.findIndex(x=>x.key===step);
+  return <div className="steps">{DOMAIN_STEPS.map((item,index)=><button key={item.key} onClick={()=>index<=current&&go(item.key)} className={index===current?"active":index<current?"done":""}><i>{index<current?"✓":index+1}</i><span><small>STEP {index+1}</small>{item.label}</span>{index<6&&<b>›</b>}</button>)}</div>;
+}
+
+function useStreamComplete(){
+  const [completing,setCompleting]=useState(false);
+  const [log,setLog]=useState("");
+  const ref=useRef<HTMLPreElement>(null);
+  useEffect(()=>{ if(ref.current) ref.current.scrollTop=ref.current.scrollHeight; },[log]);
+  return {completing,setCompleting,log,setLog,ref};
+}
+
+async function streamInto(res:Response, setLog:(f:(p:string)=>string)=>void, onEv:(ev:any)=>void){
+  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) setLog(prev=>prev+ev.log); onEv(ev); }catch{} } }
+}
+
+function DomainPageThumb({jsonText,emptyLabel="未生成"}:{jsonText:string; emptyLabel?:string}){
+  const outer=useRef<HTMLDivElement>(null);
+  const inner=useRef<HTMLDivElement>(null);
+  useEffect(()=>{
+    let cancelled=false;
+    if(!jsonText || jsonText.trim()==="{}" || jsonText.trim()===""){ if(inner.current) inner.current.innerHTML='<div class="thumb-empty">'+emptyLabel+'</div>'; if(outer.current) outer.current.style.height="auto"; return; }
+    (async()=>{
+      try{
+        const json=JSON.parse(jsonText);
+        if(cancelled||!inner.current) return;
+        const renderDashboard:any=await loadRenderer();
+        renderDashboard(json, inner.current, {});
+        const w=outer.current?.clientWidth||300;
+        const pw=json.grid?.pageWidth||1920;
+        const rootEl=inner.current.firstElementChild as HTMLElement|null;
+        const rh=rootEl? rootEl.offsetHeight : (json.grid?.pageHeight||1080);
+        const s=Math.max(0.02, w/pw);
+        inner.current.style.height=`${rh}px`;
+        inner.current.style.transform=`scale(${s})`;
+        inner.current.style.transformOrigin="top left";
+        if(outer.current) outer.current.style.height=`${rh*s}px`;
+      }catch(e){ if(inner.current) inner.current.innerHTML='<div class="thumb-empty">预览不可用</div>'; }
+    })();
+    return ()=>{cancelled=true;};
+  },[jsonText, emptyLabel]);
+  return <div ref={outer} className="dashboard-thumb-live" style={{overflow:"hidden"}}><div ref={inner} style={{width:1920, height:1080, position:"relative"}}/></div>;
+}
+
+function DomainPagePreviewModal({page,tmplId,notify,onClose}:{page:DomainPage;tmplId:number;notify:(m:string)=>void;onClose:(reload:boolean)=>void}){
+  const [feedback,setFeedback]=useState("");
+  const [refining,setRefining]=useState(false);
+  const [refineLog,setRefineLog]=useState("");
+  const consoleRef=useRef<HTMLPreElement>(null);
+  useEffect(()=>{ if(consoleRef.current) consoleRef.current.scrollTop=consoleRef.current.scrollHeight; },[refineLog]);
+  const hasJson = !!page.json_text && page.json_text.trim()!=="{}" && page.json_text.trim()!=="";
+  const regenerate=async()=>{
+    if(!feedback.trim()){ notify("请填写修改意见"); return; }
+    setRefining(true); setRefineLog("");
+    const watchdog=setTimeout(()=>{ setRefining(false); notify("重新生成超时(>10 分钟),请重试"); },600000);
+    try{
+      const res=await fetch(`/api/db/domain-templates/${tmplId}/pages/${page.id}/refine`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({feedback})});
+      if(!res.ok||!res.body) throw new Error("HTTP "+res.status);
+      await streamInto(res, setRefineLog, ()=>{});
+      notify(`已根据修改意见重新生成「${page.name}」`);
+      onClose(true);
+    }catch(e:any){ notify("重新生成失败:"+(e?.message||e)); }
+    finally{ clearTimeout(watchdog); setRefining(false); }
+  };
+  return <div className="modal-backdrop" onMouseDown={()=>onClose(false)}><div className="modal" style={{width:"min(1100px,94vw)",maxWidth:"94vw"}} onMouseDown={e=>e.stopPropagation()}>
+    <header><h2>{page.name||"页面预览"}</h2><button onClick={()=>onClose(false)}>×</button></header>
+    <div className="modal-body">
+      <div className="domain-preview-canvas">{hasJson? <DomainPageThumb jsonText={page.json_text}/> : <div className="thumb-empty">尚未生成,请点击「生成」后再预览</div>}</div>
+      <div className="domain-preview-feedback">
+        <h4>修改意见</h4>
+        <p className="hint">输入对当前页面的修改意见,AI 将据此重新生成该页面。</p>
+        <textarea value={feedback} onChange={e=>setFeedback(e.target.value)} placeholder="例如:把能耗图表改为按周维度对比,并增加告警列表" rows={4}/>
+        {refining&&<pre className="modal-console-body" ref={consoleRef}>{refineLog||"(重新生成中…)"}</pre>}
+        <Button disabled={refining} onClick={regenerate}>{refining?"重新生成中…":"根据意见重新生成"}</Button>
+      </div>
+    </div>
+  </div></div>;
+}
+
+function DomainWizard({id,doms,go,notify,onBack,viewCompletePrompt,viewCompleteAssetsPrompt,viewCompleteCubesPrompt}:{id:number;doms:any[];go:(p:PageKey)=>void;notify:(m:string)=>void;onBack:()=>void;viewCompletePrompt:(id:number,name:string,domain:string,description:string)=>void;viewCompleteAssetsPrompt:(id:number,name:string,domain:string,description:string,supplement:string)=>void;viewCompleteCubesPrompt:(id:number,name:string,domain:string,description:string,supplement:string,assets:any[])=>void}){
+  const [t,setT]=useState<any>(null);
+  const [m,setM]=useState<any>(null);
+  const [assets,setAssets]=useState<DomainAsset[]>([]);
+  const [mu,setMu]=useState<string[]>([]);
+  const [pages,setPages]=useState<DomainPage[]>([]);
+  const [allDevices,setAllDevices]=useState<any[]>([]);
+  const [muTreeLocal,setMuTreeLocal]=useState<MuNode[]>([]);
+  const [step,setStep]=useState<DomainStepKey>("basic");
+  const [loading,setLoading]=useState(true);
+  const [saving,setSaving]=useState(false);
+  const [devCreateOpen,setDevCreateOpen]=useState(false);
+  const [pendingDevRow,setPendingDevRow]=useState<number|null>(null);
+  const [previewPage,setPreviewPage]=useState<DomainPage|null>(null);
+  const basicAi=useStreamComplete();
+  const assetsAi=useStreamComplete();
+  const cubesAi=useStreamComplete();
+  const {confirm, el:confirmEl}=useConfirm();
+
+  const domainName = doms.find((d:any)=>d.id===m?.domain_id)?.name || "";
+  const allMuKeys = useMemo(()=>buildMuLeaves(muTreeLocal).map(l=>l.key),[muTreeLocal]);
+  const effSelected = mu.length ? mu : allMuKeys;
+  const grouped = useMemo(()=>{ const leaves=buildMuLeaves(muTreeLocal); const map=new Map<string,{parentMenu:string,items:typeof leaves}>(); for(const l of leaves){ if(!map.has(l.parentMenu)) map.set(l.parentMenu,{parentMenu:l.parentMenu,items:[]}); map.get(l.parentMenu)!.items.push(l); } return Array.from(map.values()); },[muTreeLocal]);
+
+  const loadAllDevices=async()=>{ try{ setAllDevices(await fetchDb("/device-templates") as any); }catch{} };
+  const load=async(silent=false)=>{
+    if(!silent) setLoading(true);
+    try{
+      const [d, mt]=await Promise.all([ fetchDb(`/domain-templates/${id}`) as any, fetchDb(`/mu-tree`) as Promise<MuNode[]> ]);
+      setMuTreeLocal(mt);
+      setT(d);
+      setM({domain_id:d.domain_id,name:d.name,description:d.description||"",status:d.status,version:d.version,creator:d.creator||"",supplement:d.supplement||""});
+      setAssets((d.assets||[]).map((a:any)=>({id:a.id,device_template_id:a.device_template_id,device_name:a.device_name||"",count:Number(a.count)||0,rule:a.rule||"",sort_order:a.sort_order||0})));
+      setPages((d.pages||[]).map((p:any)=>({id:p.id,name:p.name,description:p.description||"",json_text:p.json_text||"",status:p.status||"pending",gen_filename:p.gen_filename||null,prompt:p.prompt||null,sort_order:p.sort_order||0})));
+      setMu(d.mu && d.mu.length ? d.mu : []);
+    }catch(e:any){ notify("加载失败:"+e.message); }
+    finally{ if(!silent) setLoading(false); }
+  };
+  useEffect(()=>{ loadAllDevices(); load(); },[id]);
+
+  const patch=async(patchBody:any, msg="已保存")=>{
+    setSaving(true);
+    try{ await fetchDb(`/domain-templates/${id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(patchBody)}); notify(msg); }
+    catch(e:any){ notify("保存失败:"+e.message); }
+    finally{ setSaving(false); }
+  };
+  const saveMeta=()=>patch(m);
+  const saveAssets=async(next?:DomainAsset[])=>{ const a=next??assets; setAssets(a); await patch({assets:a.map(x=>({device_template_id:x.device_template_id,count:x.count,rule:x.rule,sort_order:x.sort_order}))}, "资产已保存"); };
+  const saveMu=async(next?:string[])=>{ const sel=next??mu; setMu(sel); await patch({mu:sel}, "MU 选择已保存"); };
+  const toggleMu=(key:string)=> saveMu(effSelected.includes(key)? effSelected.filter(k=>k!==key) : [...effSelected, key]);
+
+  // ===== Step1 AI 智能补全(写入补充描述)=====
+  const runBasicComplete=async()=>{
+    if(basicAi.completing) return;
+    basicAi.setCompleting(true); basicAi.setLog("");
+    let newSup:string = m.supplement||"";
+    const wd=setTimeout(()=>{ basicAi.setCompleting(false); notify("AI 智能补全超时(>6 分钟),请重试"); },360000);
+    try{
+      const res=await fetch(`/api/db/domain-templates/${id}/complete`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:m.name,domain:domainName,description:m.description})});
+      if(!res.ok||!res.body) throw new Error("HTTP "+res.status);
+      await streamInto(res, basicAi.setLog, (ev)=>{ if(ev.result!==undefined){ newSup=ev.result; setM((prev:any)=>({...prev,supplement:newSup})); } });
+      await patch({supplement:newSup}, "AI 已写入补充描述");
+    }catch(e:any){ notify("AI 智能补全失败:"+(e?.message||e)); }
+    finally{ clearTimeout(wd); basicAi.setCompleting(false); }
+  };
+
+  // ===== Step2 AI 资产补全(追加并自动建设备模板)=====
+  const runAssetsComplete=async()=>{
+    if(assetsAi.completing) return;
+    assetsAi.setCompleting(true); assetsAi.setLog("");
+    const wd=setTimeout(()=>{ assetsAi.setCompleting(false); notify("AI 资产补全超时(>6 分钟),请重试"); },360000);
+    try{
+      const res=await fetch(`/api/db/domain-templates/${id}/complete-assets`,{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);
+      let created:string[]=[];
+      await streamInto(res, assetsAi.setLog, (ev)=>{ if(Array.isArray(ev.result)){ setAssets((prev)=>[...prev, ...ev.result.map((it:any,i:number)=>({device_template_id:it.device_template_id,device_name:allDevices.find((d:any)=>d.id===it.device_template_id)?.name||"",count:Number(it.count)||0,rule:it.rule||"",sort_order:prev.length+i}))]); } if(Array.isArray(ev.created)) created=ev.created; });
+      await loadAllDevices(); await load(true);
+      notify("AI 资产补全已完成"+(created.length?`,并自动创建设备模板:${created.join("、")}`:""));
+    }catch(e:any){ notify("AI 资产补全失败:"+(e?.message||e)); }
+    finally{ clearTimeout(wd); assetsAi.setCompleting(false); }
+  };
+
+  // ===== Step3 AI Cube 补全(创建新页面)=====
+  const runCubesComplete=async()=>{
+    if(cubesAi.completing) return;
+    cubesAi.setCompleting(true); cubesAi.setLog("");
+    const wd=setTimeout(()=>{ cubesAi.setCompleting(false); notify("AI Cube 补全超时(>6 分钟),请重试"); },360000);
+    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 load(true);
+      notify("AI Cube 页面补全已完成");
+    }catch(e:any){ notify("AI Cube 补全失败:"+(e?.message||e)); }
+    finally{ clearTimeout(wd); cubesAi.setCompleting(false); }
+  };
+
+  // ===== 资产表格操作 =====
+  const baseAssets=()=> assets.length ? assets : Array.from({length:4},(_:number,k:number)=>({device_template_id:0,device_name:"",count:0,rule:"",sort_order:k}));
+  const updateAsset=(i:number,patchRow:Partial<DomainAsset>)=>{ const next=baseAssets().map((a,k)=>k===i?{...a,...patchRow}:a); saveAssets(next); };
+  const addAssetRow=()=>{ const next=[...baseAssets(),{device_template_id:0,device_name:"",count:0,rule:"",sort_order:baseAssets().length}]; saveAssets(next); };
+  const removeAssetRow=(i:number)=>{ const next=baseAssets().filter((_,k)=>k!==i); saveAssets(next); };
+  const onAssetDeviceChange=(i:number,val:string)=>{
+    if(val==="__new__"){ setPendingDevRow(i); setDevCreateOpen(true); return; }
+    const did=Number(val); const dev=allDevices.find(d=>d.id===did);
+    updateAsset(i,{device_template_id:did,device_name:dev?.name||""});
+  };
+  const onDevCreated=async(nid:number)=>{ await loadAllDevices(); setDevCreateOpen(false); if(pendingDevRow!=null){ const dev=allDevices.concat([{id:nid}]).find(d=>d.id===nid); updateAsset(pendingDevRow,{device_template_id:nid,device_name:dev?.name||""}); setPendingDevRow(null); } };
+
+  // ===== 页面操作 =====
+  const addPage=async()=>{ await fetchDb(`/domain-templates/${id}/pages`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:"新页面",description:"",json_text:"{}"})}); await load(true); notify("已新增页面"); };
+  const uploadPageJson=async(file:File, replaceId?:number)=>{
+    const text=await file.text();
+    try{ JSON.parse(text); }catch{ notify("不是合法 JSON 文件"); return; }
+    const name=file.name.replace(/\.json$/i,"");
+    try{
+      if(replaceId){ await fetchDb(`/domain-pages/${replaceId}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({json_text:text})}); notify("已更新页面 JSON"); }
+      else { await fetchDb(`/domain-templates/${id}/pages`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name,description:"",json_text:text})}); notify("已上传页面 JSON"); }
+      await load(true);
+    }catch(e:any){ notify("上传失败:"+e.message); }
+  };
+  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 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>;
+
+  const curIdx=DOMAIN_STEPS.findIndex(s=>s.key===step);
+  const goStep=(s:DomainStepKey)=> setStep(s);
+  const assetRows = baseAssets();
+
   return <>
-    {confirmEl}{promptEl}
+    {confirmEl}
+    {devCreateOpen && <div className="modal-backdrop" onMouseDown={()=>{setDevCreateOpen(false);setPendingDevRow(null);}}><div className="modal" style={{width:"640px"}} onMouseDown={e=>e.stopPropagation()}><header><h2>新增设备模版</h2><button onClick={()=>{setDevCreateOpen(false);setPendingDevRow(null);}}>×</button></header><div className="modal-body"><DeviceTemplateCreate notify={notify} onDone={onDevCreated} onCancel={()=>{setDevCreateOpen(false);setPendingDevRow(null);}}/></div></div></div>}
+    {previewPage && <DomainPagePreviewModal page={previewPage} tmplId={id} notify={notify} onClose={(reload:boolean)=>{ setPreviewPage(null); if(reload) load(true); }}/>}
     <div className="domain-detail-head"><button className="text-button" onClick={onBack}>← 返回列表</button></div>
-    <Panel title="模板信息"><div className="form-grid">
-      <label>所属领域<select value={m.domain_id} onChange={e=>setM({...m,domain_id:Number(e.target.value)})}>{doms.map(d=><option key={d.id} value={d.id}>{d.name}</option>)}</select></label>
-      <label>模板名称<input value={m.name} onChange={e=>setM({...m,name:e.target.value})}/></label>
-      <label>版本<input value={m.version} onChange={e=>setM({...m,version:e.target.value})}/></label>
-      <label>状态<select value={m.status} onChange={e=>setM({...m,status:e.target.value})}><option value="available">可用</option><option value="unavailable">不可用</option></select></label>
-      <label>创建人<input value={m.creator} onChange={e=>setM({...m,creator:e.target.value})}/></label>
-      <label style={{gridColumn:"1 / -1"}}>领域描述<textarea value={m.description} onChange={e=>setM({...m,description:e.target.value})} rows={3}/></label>
-      <div style={{gridColumn:"1 / -1"}}><Button onClick={saveMeta}>保存模板信息</Button></div>
-    </div></Panel>
-    <Panel title={`选用设备模版(${t.devices.length})`}>
-      <DeviceTemplatePicker tmplId={id} go={go} selectedIds={t.devices.map((d:any)=>d.id)} notify={notify} onChange={()=>load(true)}/>
-    </Panel>
-    <Panel title={`领域页面(${t.pages.length})`}><div className="page-list">
-      {t.pages.map((p:any)=><PageRow key={p.id} p={p} notify={notify} onChange={load}/>)}
-      <div style={{marginTop:8}}><Button onClick={addPage}>+ 新增页面</Button></div>
+    <DomainSteps step={step} go={goStep}/>
+    <div className="content">
+      {step==="basic" && <div className="two-column basic-layout"><Panel title="方案信息"><div className="form-grid">
+        <Field label="所属领域" required><select value={m.domain_id} onChange={e=>setM({...m,domain_id:Number(e.target.value)})}>{doms.map((d:any)=><option key={d.id} value={d.id}>{d.name}</option>)}</select></Field>
+        <Field label="方案名称" required><input value={m.name} maxLength={20} onChange={e=>setM({...m,name:e.target.value})}/></Field>
+        <Field label="版本"><input value={m.version} onChange={e=>setM({...m,version:e.target.value})}/></Field>
+        <Field label="状态"><select value={m.status} onChange={e=>setM({...m,status:e.target.value})}><option value="available">可用</option><option value="unavailable">不可用</option></select></Field>
+        <Field label="创建人"><input value={m.creator} onChange={e=>setM({...m,creator:e.target.value})}/></Field>
+        <Field label="方案描述" required hint="建议包含方案用途、重点设备、管理目标及关注指标"><textarea value={m.description} onChange={e=>setM({...m,description:e.target.value})} rows={3}/></Field>
+        <Field label="补充描述" hint="可由右侧 AI 智能补全自动生成"><textarea className="small" value={m.supplement||""} onChange={e=>setM({...m,supplement:e.target.value})}/></Field>
+        <div style={{gridColumn:"1 / -1"}}><Button onClick={saveMeta}>保存方案信息</Button></div>
+      </div></Panel>
+      <div className="stack">
+        <Panel title="填写建议"><ul className="tips"><li>这个方案服务于哪个业务场景?</li><li>涉及哪些核心设备与子系统?</li><li>管理人员最关注哪些运行指标?</li><li>希望方案落地后首先解决什么问题?</li></ul></Panel>
+        <Panel title="AI 智能补全">
+          <p className="hint">根据「方案名称 / 所属领域 / 方案描述」,由 AI 搜索同类型站点的物联网建设经验,自动生成补充描述到左侧「补充描述」。</p>
+          <div style={{display:"flex",gap:"8px",alignItems:"center"}}>
+            <button className="button" disabled={basicAi.completing} onClick={runBasicComplete}>{basicAi.completing?"补全中…":"开始补全"}</button>
+            <button className="text-button" onClick={()=>viewCompletePrompt(id,m.name,domainName,m.description)}>查看提示词</button>
+          </div>
+          {(basicAi.completing||basicAi.log)&&<div className="modal-console"><div className="modal-console-head">AI 智能补全</div><pre className="modal-console-body" ref={basicAi.ref}>{basicAi.log||"(等待 AI 分析…)"}</pre></div>}
+        </Panel>
+        {saving&&<Panel><div className="empty-state">正在保存…</div></Panel>}
+      </div></div>}
+
+      {step==="assets" && <div className="two-column">
+        <div>
+          <div className="kpi-row">{[[assets.filter(a=>a.device_template_id).length,"设备类型","类"],[assets.reduce((n,a)=>n+Number(a.count||0),0),"设备实例","个"],[Math.max(2, assets.filter(a=>a.device_template_id).length+1),"资产层级","层"]].map(x=><div key={x[1]}><span>{x[1]}</span><b>{x[0]}</b><small>{x[2]}</small></div>)}{saving&&<div><span>保存</span><Status>保存中…</Status></div>}</div>
+          <Panel title="设备资产配置" tools={<Button onClick={addAssetRow}>+ 添加设备类型</Button>}>
+            <table className="data-table editable">
+              <thead><tr><th>设备类型 *</th><th>数量 *</th><th>命名规则</th><th>操作</th></tr></thead>
+              <tbody>{assetRows.map((row,i)=><tr key={i} className={!row.device_template_id?"empty-row":""}>
+                <td><select value={row.device_template_id||""} onChange={e=>onAssetDeviceChange(i,e.target.value)}><option value="">选择设备模板…</option>{allDevices.map((d:any)=><option key={d.id} value={d.id}>{d.name}</option>)}<option value="__new__">+ 新增设备模板…</option></select></td>
+                <td><input className="number" type="number" min={0} value={row.count||""} placeholder="0" onChange={e=>updateAsset(i,{count:Number(e.target.value)})}/></td>
+                <td><input value={row.rule} placeholder="例如:CH-##" onChange={e=>updateAsset(i,{rule:e.target.value})}/></td>
+                <td><button className="trash" onClick={()=>removeAssetRow(i)}>⌫</button></td>
+              </tr>)}</tbody>
+            </table>
+            <div style={{marginTop:8}}><Button secondary onClick={()=>go("deviceTemplates")}>查看全部设备模版</Button></div>
+          </Panel>
+          <Panel title="资产层级预览"><div className="asset-tree-preview">
+            <div className="tree-root">⌄ <b>{m.name || "未命名方案"}</b></div>
+            {assets.filter(r=>r.device_template_id).map((row,i)=><div key={i} className="tree-branch">
+              <div className="tree-node">⌄ {row.device_name||"(未选设备)"} <span className="tree-count">{row.count}</span></div>
+              {row.count>0&&<div className="tree-leaves">{formatInstances(row.rule, row.count)}</div>}
+            </div>)}
+          </div></Panel>
+        </div>
+        <div className="stack">
+          <Panel title="填写建议"><ul className="tips"><li>设备类型必须从设备模板库中选择,库中没有时点击下拉框「+ 新增设备模板…」现场创建。</li><li>为每种设备估算典型数量与命名规则。</li><li>命名规则用 <code>#</code> 表示序号,如 <code>CH-##</code> 生成 CH-01/CH-02。</li><li>点击「保存资产」才会写入;AI 补全会把建议设备追加在您已填项下方,库内缺失的模板会自动创建。</li></ul></Panel>
+          <Panel title="AI 智能补全">
+            <p className="hint">基于方案名称 / 领域 / 描述 / 补充描述,由 AI 分析并补充设备类型、数量与命名规则(位于您已填项下方)。</p>
+            <div style={{display:"flex",gap:"8px",alignItems:"center"}}>
+              <button className="button" disabled={assetsAi.completing} onClick={runAssetsComplete}>{assetsAi.completing?"补全中…":"开始补全"}</button>
+              <button className="text-button" onClick={()=>viewCompleteAssetsPrompt(id,m.name,domainName,m.description,m.supplement||"")}>查看提示词</button>
+            </div>
+            {(assetsAi.completing||assetsAi.log)&&<div className="modal-console"><div className="modal-console-head">AI 智能补全</div><pre className="modal-console-body" ref={assetsAi.ref}>{assetsAi.log||"(等待 AI 分析…)"}</pre></div>}
+          </Panel>
+          {saving&&<Panel><div className="empty-state">正在保存…</div></Panel>}
+        </div>
+      </div>}
+
+      {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}
+        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} />}
+
+      {step==="opsflow" && <PlaceholderStep title="运维流程" note="运维流程编辑功能待开发,后续版本将支持在方案中配置标准运维 SOP 与告警处理流程。"/>}
+      {step==="knowledge" && <PlaceholderStep title="领域知识" note="领域知识(知识文档、标准规范、参考资料)编辑功能待开发,后续版本将支持在方案中沉淀领域知识库。"/>}
+      {step==="generate" && <DomainGenerateReview tName={m.name} assets={assets} effSelected={effSelected} grouped={grouped} pages={pages} onBack={()=>goStep("functions")} onConfirm={()=>notify("方案已生成并保存,可返回上一步继续调整")}/>}
+      {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>
+  </>;
+}
+
+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}){
+  const [tab,setTab]=useState<"mu"|"cube"|"menu">("mu");
+  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">
+      <div>
+        <div className="cube-editor"><table className="data-table editable cube-editor-table"><thead><tr><th>页面名称 <small>最多6字</small></th><th>页面描述</th><th>生成文件</th><th>操作</th></tr></thead><tbody>{pages.map((p,i)=><tr key={p.id}>
+          <td><input value={p.name} maxLength={6} placeholder="例如:运行总览" onChange={e=>updatePageField(i,{name:e.target.value})}/></td>
+          <td><input value={p.description} placeholder="描述该页面要展示的内容与指标" onChange={e=>updatePageField(i,{description:e.target.value})}/></td>
+          <td>{p.status==="generated"? <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>
+        </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>
+      </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>
+        <Panel title="AI 智能补全">
+          <p className="hint">基于方案名称 / 领域 / 描述 / 补充描述及资产,由 AI 推断并补充 Cube 扩展页面(追加在已有页面下方)。</p>
+          <div style={{display:"flex",gap:"8px",alignItems:"center"}}>
+            <button className="button" disabled={cubesAi.completing} onClick={onRunCubesComplete}>{cubesAi.completing?"补全中…":"开始补全"}</button>
+            <button className="text-button" onClick={onViewCompleteCubesPrompt}>查看提示词</button>
+          </div>
+          {(cubesAi.completing||cubesAi.log)&&<div className="modal-console"><div className="modal-console-head">AI 智能补全</div><pre className="modal-console-body" ref={cubesAi.ref}>{cubesAi.log||"(等待 AI 分析…)"}</pre></div>}
+        </Panel>
+        {saving&&<Panel><div className="empty-state">正在保存…</div></Panel>}
+      </div>
+    </div>}
+    {tab==="menu" && <div className="menu-preview">
+      <p className="hint">默认菜单结构(展示用,无需编辑):MU 标准页面在上,所有 Cube 扩展页面归入「综合监控」一级菜单下。</p>
+      <div className="menu-tree">
+        <div className="menu-root">⌄ {tName || "未命名方案"}</div>
+        <div className="menu-section"><div className="menu-section-title">MU 标准页面</div>
+          {effSelected.length===0? <div className="menu-leaf muted">(未选择 MU 页面)</div> : buildMuLeaves(muTree).filter(l=>effSelected.includes(l.key)).map(l=><div key={l.key} className="menu-leaf">· {l.parentMenu}/{l.label}</div>)}
+        </div>
+        <div className="menu-section"><div className="menu-section-title">综合监控</div>
+          {pages.filter(p=>p.name&&p.name.trim()).length===0? <div className="menu-leaf muted">(暂无 Cube 页面)</div> : pages.filter(p=>p.name&&p.name.trim()).map(p=><div key={p.id} className="menu-leaf cube">· {p.name}</div>)}
+        </div>
+      </div>
+    </div>}
+  </Panel>;
+}
+
+function DomainGenerateReview({tName,assets,effSelected,grouped,pages,onBack,onConfirm}:{tName:string;assets:DomainAsset[];effSelected:string[];grouped:any[];pages:DomainPage[];onBack:()=>void;onConfirm:()=>void}){
+  return <div className="generate-layout"><div>
+    <Panel title="生成范围确认"><div className="confirm-grid">
+      {[["方案",tName,"","返回修改"],["资产",`${assets.filter(a=>a.device_template_id).length} 类 / ${assets.reduce((n,a)=>n+Number(a.count||0),0)} 个设备`,"3 层资产结构",""],["MU 页面",`${effSelected.length} 个标准页面`,"复用并配置菜单",""],["Cube 页面",`${pages.length} 个扩展页面`,`已生成 ${pages.filter(p=>p.status==="generated").length} 个`,""],["菜单结构","MU + 综合监控",`共 ${effSelected.length+pages.length} 项`,""],["领域知识","沉淀标准规范","(待开发)",""],["运维流程","标准 SOP","(待开发)",""]].map(x=><div key={x[0]}><span>{x[0]}</span><b>{x[1]}</b><small>{x[2]}</small>{x[3]&&<button onClick={onBack}>{x[3]}</button>}</div>)}
     </div></Panel>
-    <Panel title={`模板菜单(${t.menu.length})`}><table className="data-table"><thead><tr><th>菜单项</th><th>类型</th><th>父级</th><th>关联</th><th></th></tr></thead>
-      <tbody>{t.menu.map((mi:any)=>(<tr key={mi.id}><td>{mi.label}</td><td>{mi.page_type||"—"}</td><td>{mi.parent_id??"—"}</td><td>{mi.ref_id??"—"}</td><td><button className="text-button" onClick={async()=>{ await fetchDb(`/template-menu-items/${mi.id}`,{method:"DELETE"}); load(); }}>删</button></td></tr>))}</tbody></table>
-      <div style={{marginTop:8}}><Button onClick={addMenu}>+ 新增菜单项</Button></div>
-    </Panel>
-    <Panel title={`知识文档(${t.docs.length})`}><table className="data-table"><thead><tr><th>名称</th><th>类型</th><th>链接/大小</th><th></th></tr></thead>
-      <tbody>{t.docs.map((d:any)=>(<tr key={d.id}><td>{d.name}</td><td>{d.doc_type==="link"?"超链":"文件"}</td><td>{d.doc_type==="link"?d.url:(d.size?Math.round(d.size/1024)+"KB":"")}</td><td><button className="text-button" onClick={()=>dlDoc(d.id)}>下载</button> <button className="text-button" onClick={()=>delDoc(d.id)}>删</button></td></tr>))}</tbody></table>
-      <div style={{marginTop:8}}><Button onClick={addDoc}>+ 新增文档</Button></div>
+    <Panel title="资产层级预览"><div className="asset-tree-preview"><div className="tree-root">⌄ <b>{tName||"未命名方案"}</b></div>{assets.filter(a=>a.device_template_id).map((row,i)=><div key={i} className="tree-branch"><div className="tree-node">⌄ {row.device_name}<span className="tree-count">{row.count}</span></div>{row.count>0&&<div className="tree-leaves">{formatInstances(row.rule,row.count)}</div>}</div>)}</div></Panel>
+    <Panel title="菜单结构预览"><div className="menu-tree"><div className="menu-root">⌄ {tName||"未命名方案"}</div><div className="menu-section"><div className="menu-section-title">MU 标准页面</div>{effSelected.length===0?<div className="menu-leaf muted">(未选择)</div>:grouped.flatMap((g:any)=>g.items).filter((l:any)=>effSelected.includes(l.key)).map((l:any)=><div key={l.key} className="menu-leaf">· {l.parentMenu}/{l.label}</div>)}</div><div className="menu-section"><div className="menu-section-title">综合监控</div>{pages.filter(p=>p.name).map(p=><div key={p.id} className="menu-leaf cube">· {p.name}</div>)}</div></div></Panel>
+  </div>
+  <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"?"已生成":"未生成"}</Status><h4>{p.name}</h4></div></article>)}</div>
+      <div style={{display:"flex",gap:8,marginTop:12}}><Button secondary onClick={onBack}>返回上一步</Button><Button onClick={onConfirm}>确认方案</Button></div>
     </Panel>
-    <OpsFlowsViewer/>
-  </>;
+  </div></div>;
 }
 
+
+
 function PageStyleLibrary({pageStyles, loadPageStyles, notify}:{pageStyles:PageStyle[]; loadPageStyles:()=>Promise<void>; notify:(m:string)=>void}){
   const [editor,setEditor]=useState<{open:boolean; id?:number}>({open:false});
   const [preview,setPreview]=useState<PageStyle|null>(null);
index 7ff5303f20516c9027f5be7bfe097ae8c250716c..6d4a05c3f80d36991dad4a9584e4c118bedbdc14 100644 (file)
@@ -304,6 +304,23 @@ function initSchema() {
       content TEXT,
       FOREIGN KEY(domain_template_id) REFERENCES domain_templates(id) ON DELETE CASCADE
     );
+    CREATE TABLE IF NOT EXISTS domain_template_assets (
+      id INTEGER PRIMARY KEY AUTOINCREMENT,
+      domain_template_id INTEGER NOT NULL,
+      device_template_id INTEGER NOT NULL,
+      count INTEGER DEFAULT 0,
+      rule TEXT,
+      sort_order INTEGER DEFAULT 0,
+      FOREIGN KEY(domain_template_id) REFERENCES domain_templates(id) ON DELETE CASCADE,
+      FOREIGN KEY(device_template_id) REFERENCES device_templates(id) ON DELETE CASCADE
+    );
+    CREATE TABLE IF NOT EXISTS domain_template_mu (
+      id INTEGER PRIMARY KEY AUTOINCREMENT,
+      domain_template_id INTEGER NOT NULL,
+      page_key TEXT NOT NULL,
+      FOREIGN KEY(domain_template_id) REFERENCES domain_templates(id) ON DELETE CASCADE,
+      UNIQUE(domain_template_id, page_key)
+    );
     CREATE INDEX IF NOT EXISTS idx_sites_updated ON sites(updated_at);
     CREATE INDEX IF NOT EXISTS idx_sites_status ON sites(status);
   `);
@@ -321,6 +338,10 @@ function ensureColumn(table, column, def) {
 }
 ensureColumn("cubes", "prompt", "TEXT");
 ensureColumn("sites", "style_id", "INTEGER");
+ensureColumn("domain_templates", "supplement", "TEXT");
+ensureColumn("domain_pages", "status", "TEXT DEFAULT 'pending'");
+ensureColumn("domain_pages", "gen_filename", "TEXT");
+ensureColumn("domain_pages", "prompt", "TEXT");
 
 // GeoMap 中国地图底图:若 data/db/china-geo.json 存在(中国 34 省 + 九段线 FeatureCollection),
 // 则在 fixDashboard 中自动注入 GeoMap 面板的 custom.dataConfig.geoJson,避免地图气泡不渲染。
@@ -1644,7 +1665,9 @@ async function handleDomainTemplateDetail(res, id) {
   const menu = db.prepare("SELECT * FROM template_menu_items WHERE domain_template_id=? ORDER BY sort_order,id").all(id);
   const docs = db.prepare("SELECT id,name,doc_type,url,mime,size,created_at FROM knowledge_docs WHERE domain_template_id=? ORDER BY id").all(id);
   const flows = db.prepare("SELECT * FROM ops_flows WHERE domain_template_id=? ORDER BY id").all(id);
-  respondJson(res, 200, { ...dt, devices, pages, menu, docs, flows });
+  const assets = db.prepare("SELECT a.id, a.device_template_id, dt.name AS device_name, a.count, a.rule, a.sort_order FROM domain_template_assets a JOIN device_templates dt ON dt.id=a.device_template_id WHERE a.domain_template_id=? ORDER BY a.sort_order, a.id").all(id);
+  const mu = db.prepare("SELECT page_key FROM domain_template_mu WHERE domain_template_id=?").all(id).map(r => r.page_key);
+  respondJson(res, 200, { ...dt, devices, pages, menu, docs, flows, assets, mu });
 }
 
 async function handleDomainTemplateUpdate(req, res, id) {
@@ -1657,8 +1680,9 @@ async function handleDomainTemplateUpdate(req, res, id) {
   const status = body.status != null ? (body.status === "unavailable" ? "unavailable" : "available") : row.status;
   const version = body.version != null ? body.version.toString() : row.version;
   const creator = body.creator != null ? body.creator.toString() : row.creator;
+  const supplement = body.supplement != null ? body.supplement : row.supplement;
   if (!name) return respondError(res, 400, "模板名称不能为空");
-  db.prepare("UPDATE domain_templates SET domain_id=?,name=?,description=?,status=?,version=?,creator=? WHERE id=?").run(domain_id, name, description, status, version, creator, id);
+  db.prepare("UPDATE domain_templates SET domain_id=?,name=?,description=?,status=?,version=?,creator=?,supplement=? WHERE id=?").run(domain_id, name, description, status, version, creator, supplement, id);
   if (body.device_template_ids !== undefined) {
     db.prepare("DELETE FROM device_template_domain_templates WHERE domain_template_id=?").run(id);
     for (const devId of Array.isArray(body.device_template_ids) ? body.device_template_ids : []) {
@@ -1666,6 +1690,19 @@ async function handleDomainTemplateUpdate(req, res, id) {
         db.prepare("INSERT OR IGNORE INTO device_template_domain_templates (device_template_id, domain_template_id) VALUES (?,?)").run(Number(devId), id);
     }
   }
+  if (body.assets !== undefined) {
+    db.prepare("DELETE FROM domain_template_assets WHERE domain_template_id=?").run(id);
+    const ins = db.prepare("INSERT INTO domain_template_assets (domain_template_id, device_template_id, count, rule, sort_order) VALUES (?,?,?,?,?)");
+    for (let i = 0; i < (body.assets || []).length; i++) {
+      const a = body.assets[i];
+      if (a && a.device_template_id) ins.run(id, Number(a.device_template_id), Number(a.count) || 0, a.rule || "", i);
+    }
+  }
+  if (body.mu !== undefined) {
+    db.prepare("DELETE FROM domain_template_mu WHERE domain_template_id=?").run(id);
+    const ins = db.prepare("INSERT OR IGNORE INTO domain_template_mu (domain_template_id, page_key) VALUES (?,?)");
+    for (const k of (body.mu || [])) ins.run(id, k);
+  }
   respondJson(res, 200, { ok: true, id });
 }
 
@@ -1677,6 +1714,206 @@ async function handleDomainTemplateDelete(res, id) {
   respondJson(res, 200, { ok: true });
 }
 
+// ===== 领域方案 AI 补全 / Cube 生成(复用站点侧提示词构造与生成逻辑)=====
+async function hydrateDomainTemplateCtx(tmplId) {
+  const dt = db.prepare("SELECT dt.*, d.name AS domain_name FROM domain_templates dt JOIN domains d ON d.id=dt.domain_id WHERE dt.id=?").get(tmplId);
+  if (!dt) return null;
+  const assets = db.prepare("SELECT a.device_template_id, dt.name AS type, a.count, a.rule FROM domain_template_assets a JOIN device_templates dt ON dt.id=a.device_template_id WHERE a.domain_template_id=?").all(tmplId);
+  return { id: dt.id, name: dt.name, domain: dt.domain_name, description: dt.description || "", supplement: dt.supplement || "", assets, style_id: null, cubes: [] };
+}
+
+async function resolveDeviceTemplate(name, domainId, tmplId) {
+  const existing = db.prepare("SELECT id FROM device_templates WHERE name=?").get(name);
+  if (existing) return { id: existing.id, created: false };
+  const info = db.prepare("INSERT INTO device_templates (name, description) VALUES (?,?)").run(name, `由领域方案 AI 资产补全自动创建设备模板`);
+  const devId = info.lastInsertRowid;
+  if (domainId) db.prepare("INSERT OR IGNORE INTO device_template_domains (device_template_id, domain_id) VALUES (?,?)").run(devId, domainId);
+  if (tmplId) db.prepare("INSERT OR IGNORE INTO device_template_domain_templates (device_template_id, domain_template_id) VALUES (?,?)").run(devId, tmplId);
+  return { id: devId, created: true };
+}
+
+async function handleDomainComplete(req, res, tmplId) {
+  res.writeHead(200, { "Content-Type": "application/x-ndjson; charset=utf-8", "Cache-Control": "no-cache" });
+  const emit = (o) => streamEvent(res, o);
+  const fail = (m) => { emit({ task: "系统", status: "failed", message: m }); res.end(); };
+  if (genLocks.has(`dt-complete-${tmplId}`)) return fail("AI 智能补全正在进行中");
+  genLocks.add(`dt-complete-${tmplId}`);
+  try {
+    const dt = db.prepare("SELECT dt.*, d.name AS domain_name FROM domain_templates dt JOIN domains d ON d.id=dt.domain_id WHERE dt.id=?").get(tmplId);
+    if (!dt) return fail("领域方案不存在");
+    const body = await parseBody(req).catch(() => ({}));
+    const ctx = { name: (body.name ?? dt.name) || "", domain: (body.domain ?? dt.domain_name) || "", description: (body.description ?? dt.description) || "" };
+    const prompt = buildCompletePrompt(ctx);
+    emit({ prompt });
+    emit({ log: `\n▶ 开始 AI 智能补全:基于方案名称 / 所属领域 / 描述,生成「补充描述」…\n` });
+    emit({ task: "AI 智能补全", status: "running" });
+    const onEvent = (o) => { if (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-dt-complete-${tmplId}`, onEvent);
+    const supplement = extractSupplement(text);
+    emit({ log: `\n✓ 已生成补充描述(${supplement.length} 字)\n` });
+    emit({ result: supplement });
+    emit({ task: "AI 智能补全", status: "done" });
+    res.end();
+  } catch (e) {
+    emit({ log: `✗ AI 智能补全失败:${e.message}\n` });
+    emit({ task: "系统", status: "failed", message: e.message });
+    res.end();
+  } finally { genLocks.delete(`dt-complete-${tmplId}`); }
+}
+
+async function handleDomainCompletePrompt(req, res, tmplId) {
+  const dt = db.prepare("SELECT dt.*, d.name AS domain_name FROM domain_templates dt JOIN domains d ON d.id=dt.domain_id WHERE dt.id=?").get(tmplId);
+  if (!dt) return respondError(res, 404, "领域方案不存在");
+  const u = new URL(req.url, "http://localhost");
+  const ctx = { name: u.searchParams.get("name") ?? dt.name ?? "", domain: u.searchParams.get("domain") ?? dt.domain_name ?? "", description: u.searchParams.get("description") ?? dt.description ?? "" };
+  return respondJson(res, 200, { prompt: buildCompletePrompt(ctx) });
+}
+
+async function handleDomainCompleteAssets(req, res, tmplId) {
+  res.writeHead(200, { "Content-Type": "application/x-ndjson; charset=utf-8", "Cache-Control": "no-cache" });
+  const emit = (o) => streamEvent(res, o);
+  const fail = (m) => { emit({ task: "系统", status: "failed", message: m }); res.end(); };
+  if (genLocks.has(`dt-complete-assets-${tmplId}`)) return fail("AI 资产补全正在进行中");
+  genLocks.add(`dt-complete-assets-${tmplId}`);
+  try {
+    const dt = db.prepare("SELECT dt.*, d.name AS domain_name FROM domain_templates dt JOIN domains d ON d.id=dt.domain_id WHERE dt.id=?").get(tmplId);
+    if (!dt) return fail("领域方案不存在");
+    const body = await parseBody(req).catch(() => ({}));
+    const ctx = { name: (body.name ?? dt.name) || "", domain: (body.domain ?? dt.domain_name) || "", description: (body.description ?? dt.description) || "", supplement: (body.supplement ?? dt.supplement) || "", assets: body.assets || [] };
+    const prompt = buildCompleteAssetsPrompt(ctx, ctx.assets);
+    emit({ prompt });
+    emit({ log: `\n▶ 开始 AI 资产补全:分析并补充设备类型(库内无合适模板时自动创建)…\n` });
+    emit({ task: "AI 资产补全", status: "running" });
+    const onEvent = (o) => { if (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-dt-complete-assets-${tmplId}`, onEvent);
+    const items = extractAssets(text);
+    const createdNames = [];
+    const assets = [];
+    for (const it of items) {
+      const r = await resolveDeviceTemplate(it.type, dt.domain_id, tmplId);
+      if (r.created) createdNames.push(it.type);
+      assets.push({ device_template_id: r.id, count: it.count, rule: it.rule });
+    }
+    emit({ log: `\n✓ 已生成 ${assets.length} 类设备配置${createdNames.length ? `,并自动创建设备模板:${createdNames.join("、")}` : ""}\n` });
+    emit({ result: assets, created: createdNames });
+    emit({ task: "AI 资产补全", status: "done" });
+    res.end();
+  } catch (e) {
+    emit({ log: `✗ AI 资产补全失败:${e.message}\n` });
+    emit({ task: "系统", status: "failed", message: e.message });
+    res.end();
+  } finally { genLocks.delete(`dt-complete-assets-${tmplId}`); }
+}
+
+async function handleDomainCompleteAssetsPrompt(req, res, tmplId) {
+  const dt = db.prepare("SELECT dt.*, d.name AS domain_name FROM domain_templates dt JOIN domains d ON d.id=dt.domain_id WHERE dt.id=?").get(tmplId);
+  if (!dt) return respondError(res, 404, "领域方案不存在");
+  const u = new URL(req.url, "http://localhost");
+  const ctx = { name: u.searchParams.get("name") ?? dt.name ?? "", domain: u.searchParams.get("domain") ?? dt.domain_name ?? "", description: u.searchParams.get("description") ?? dt.description ?? "", supplement: u.searchParams.get("supplement") ?? dt.supplement ?? "", assets: [] };
+  return respondJson(res, 200, { prompt: buildCompleteAssetsPrompt(ctx, ctx.assets) });
+}
+
+async function handleDomainCompleteCubes(req, res, tmplId) {
+  res.writeHead(200, { "Content-Type": "application/x-ndjson; charset=utf-8", "Cache-Control": "no-cache" });
+  const emit = (o) => streamEvent(res, o);
+  const fail = (m) => { emit({ task: "系统", status: "failed", message: m }); res.end(); };
+  if (genLocks.has(`dt-complete-cubes-${tmplId}`)) return fail("AI Cube 补全正在进行中");
+  genLocks.add(`dt-complete-cubes-${tmplId}`);
+  try {
+    const dt = db.prepare("SELECT dt.*, d.name AS domain_name FROM domain_templates dt JOIN domains d ON d.id=dt.domain_id WHERE dt.id=?").get(tmplId);
+    if (!dt) return fail("领域方案不存在");
+    const body = await parseBody(req).catch(() => ({}));
+    const ctx = { name: (body.name ?? dt.name) || "", domain: (body.domain ?? dt.domain_name) || "", description: (body.description ?? dt.description) || "", supplement: (body.supplement ?? dt.supplement) || "", assets: body.assets || [], cubes: body.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?.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-dt-complete-cubes-${tmplId}`, 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(`dt-complete-cubes-${tmplId}`); }
+}
+
+async function handleDomainCompleteCubesPrompt(req, res, tmplId) {
+  const dt = db.prepare("SELECT dt.*, d.name AS domain_name FROM domain_templates dt JOIN domains d ON d.id=dt.domain_id WHERE dt.id=?").get(tmplId);
+  if (!dt) return respondError(res, 404, "领域方案不存在");
+  const u = new URL(req.url, "http://localhost");
+  const ctx = { name: u.searchParams.get("name") ?? dt.name ?? "", domain: u.searchParams.get("domain") ?? dt.domain_name ?? "", description: u.searchParams.get("description") ?? dt.description ?? "", supplement: u.searchParams.get("supplement") ?? dt.supplement ?? "", assets: [], cubes: [] };
+  return respondJson(res, 200, { prompt: buildCompleteCubesPrompt(ctx) });
+}
+
+async function handleDomainPageGenerate(req, res, tmplId, pageId) {
+  res.writeHead(200, { "Content-Type": "application/x-ndjson; charset=utf-8", "Cache-Control": "no-cache" });
+  const emit = (o) => streamEvent(res, o);
+  const fail = (m) => { emit({ task: "系统", status: "failed", message: m }); res.end(); };
+  if (genLocks.has(`dt-page-${pageId}`)) return fail("该页面正在生成中");
+  genLocks.add(`dt-page-${pageId}`);
+  try {
+    const ctx = await hydrateDomainTemplateCtx(tmplId);
+    if (!ctx) return fail("领域方案不存在");
+    const page = db.prepare("SELECT * FROM domain_pages WHERE id=? AND domain_template_id=?").get(Number(pageId), tmplId);
+    if (!page) return fail("领域页面不存在");
+    const cube = { id: page.id, name: page.name, content: page.description || "" };
+    emit({ log: `\n▶ 开始生成「${page.name}」:调用 Wordbuddy AI(Hy3)与 dashboard-json-generator Skill…\n` });
+    emit({ task: page.name, status: "running" });
+    const onEvent = (o) => { if (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 { json, prompt } = await generateCubeJson(ctx, cube, onEvent, (t) => emit({ log: t }));
+    const filename = `${safeFilename(page.name)}.json`;
+    const jsonText = JSON.stringify(json, null, 2);
+    db.prepare("UPDATE domain_pages SET json_text=?, status='generated', gen_filename=?, prompt=? WHERE id=?").run(jsonText, filename, prompt, page.id);
+    emit({ log: `✓ 「${page.name}」生成完成\n` });
+    emit({ task: page.name, status: "done" });
+    res.end();
+  } catch (e) {
+    db.prepare("UPDATE domain_pages SET status='failed' WHERE id=?").run(pageId);
+    emit({ log: `✗ 生成失败:${e.message}\n` });
+    emit({ task: "系统", status: "failed", message: e.message });
+    res.end();
+  } finally { genLocks.delete(`dt-page-${pageId}`); }
+}
+
+async function handleDomainPageRefine(req, res, tmplId, pageId) {
+  res.writeHead(200, { "Content-Type": "application/x-ndjson; charset=utf-8", "Cache-Control": "no-cache" });
+  const emit = (o) => streamEvent(res, o);
+  const fail = (m) => { emit({ task: "系统", status: "failed", message: m }); res.end(); };
+  if (genLocks.has(`dt-page-${pageId}`)) return fail("该页面正在生成中");
+  genLocks.add(`dt-page-${pageId}`);
+  try {
+    const ctx = await hydrateDomainTemplateCtx(tmplId);
+    if (!ctx) return fail("领域方案不存在");
+    const page = db.prepare("SELECT * FROM domain_pages WHERE id=? AND domain_template_id=?").get(Number(pageId), tmplId);
+    if (!page) return fail("领域页面不存在");
+    const body = await parseBody(req).catch(() => ({}));
+    const feedback = (body.feedback || "").toString().trim();
+    if (!feedback) return fail("修改意见不能为空");
+    let currentJson;
+    try { currentJson = JSON.parse(page.json_text); } catch { return fail("当前 JSON 解析失败"); }
+    const cube = { id: page.id, name: page.name, content: page.description || "" };
+    const onEvent = (o) => { if (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 }); };
+    emit({ log: `\n▶ 开始按修改意见重新生成「${page.name}」…\n` });
+    emit({ task: `修改「${page.name}」`, status: "running" });
+    const { json } = await refineCubeJson(ctx, cube, currentJson, feedback, onEvent, (t) => emit({ log: t }));
+    const jsonText = JSON.stringify(json, null, 2);
+    db.prepare("UPDATE domain_pages SET json_text=?, status='generated' WHERE id=?").run(jsonText, page.id);
+    emit({ log: `✓ 「${page.name}」修改完成\n` });
+    emit({ task: `修改「${page.name}」`, status: "done" });
+    res.end();
+  } catch (e) {
+    db.prepare("UPDATE domain_pages SET status='failed' WHERE id=?").run(pageId);
+    emit({ log: `✗ 重新生成失败:${e.message}\n` });
+    emit({ task: "系统", status: "failed", message: e.message });
+    res.end();
+  } finally { genLocks.delete(`dt-page-${pageId}`); }
+}
+
 async function handleDeviceTemplatesList(res) {
   const rows = db.prepare(`
     SELECT dt.*, (SELECT json_group_array(d.name) FROM device_template_domains dtd JOIN domains d ON d.id=dtd.domain_id WHERE dtd.device_template_id=dt.id) AS domain_names
@@ -1929,6 +2166,14 @@ async function route(req, res) {
   const mDtMenu = path.match(/^\/api\/domain-templates\/(\d+)\/menu$/);
   const mDtDocs = path.match(/^\/api\/domain-templates\/(\d+)\/docs$/);
   const mDt = path.match(/^\/api\/domain-templates\/(\d+)$/);
+  const mDtComplete = path.match(/^\/api\/domain-templates\/(\d+)\/complete$/);
+  const mDtCompletePrompt = path.match(/^\/api\/domain-templates\/(\d+)\/complete-prompt$/);
+  const mDtCompleteAssets = path.match(/^\/api\/domain-templates\/(\d+)\/complete-assets$/);
+  const mDtCompleteAssetsPrompt = path.match(/^\/api\/domain-templates\/(\d+)\/complete-assets-prompt$/);
+  const mDtCompleteCubes = path.match(/^\/api\/domain-templates\/(\d+)\/complete-cubes$/);
+  const mDtCompleteCubesPrompt = path.match(/^\/api\/domain-templates\/(\d+)\/complete-cubes-prompt$/);
+  const mDtPageGen = path.match(/^\/api\/domain-templates\/(\d+)\/pages\/(\d+)\/generate$/);
+  const mDtPageRefine = path.match(/^\/api\/domain-templates\/(\d+)\/pages\/(\d+)\/refine$/);
   const mDevList = path === "/api/device-templates";
   const mDevCp = path.match(/^\/api\/device-templates\/(\d+)\/collection-points$/);
   const mDev = path.match(/^\/api\/device-templates\/(\d+)$/);
@@ -2081,6 +2326,14 @@ async function route(req, res) {
     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 (mDtComplete) return handleDomainComplete(req, res, Number(mDtComplete[1]));
+    if (mDtCompletePrompt) return handleDomainCompletePrompt(req, res, Number(mDtCompletePrompt[1]));
+    if (mDtCompleteAssets) return handleDomainCompleteAssets(req, res, Number(mDtCompleteAssets[1]));
+    if (mDtCompleteAssetsPrompt) return handleDomainCompleteAssetsPrompt(req, res, Number(mDtCompleteAssetsPrompt[1]));
+    if (mDtCompleteCubes) return handleDomainCompleteCubes(req, res, Number(mDtCompleteCubes[1]));
+    if (mDtCompleteCubesPrompt) return handleDomainCompleteCubesPrompt(req, res, Number(mDtCompleteCubesPrompt[1]));
+    if (mDtPageGen) return handleDomainPageGenerate(req, res, Number(mDtPageGen[1]), Number(mDtPageGen[2]));
+    if (mDtPageRefine) return handleDomainPageRefine(req, res, Number(mDtPageRefine[1]), Number(mDtPageRefine[2]));
     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));