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>}
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);
<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}/>}
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);
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);
`);
}
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,避免地图气泡不渲染。
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) {
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 : []) {
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 });
}
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
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+)$/);
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));