const [domains,setDomains]=useState<string[]>([]);
const [muTree,setMuTree]=useState<MuNode[]>([]);
const [pageStyles,setPageStyles]=useState<PageStyle[]>([]);
+ const [openDomainTemplateId,setOpenDomainTemplateId]=useState<number|"create"|null>(null);
const [view,setView]=useState<"table"|"card">("table");
const [functionTab,setFunctionTab]=useState<"mu"|"cube"|"menu">("mu");
</>
)}
</div></aside>
- <main className="workspace"><div className="crumb">iStation StationBuilder / <b>{["domains","domainDetail","styleLibrary"].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"].includes(page)&&<Button secondary onClick={()=>notify("草稿已保存")}>保存草稿</Button>}{page==="projects"&&<Button onClick={()=>go("basic","new")}>+ 新建站点</Button>}{page==="domains"&&<Button onClick={()=>notify("已打开新增领域方案向导")}>+ 新增领域方案</Button>}{page==="domainDetail"&&<Button onClick={()=>{notify("已应用水务方案模板");go("basic","new")}}>使用此方案创建站点</Button>}</div></div>{(["basic","assets","functions","generate"] as PageKey[]).includes(page)&&<Steps page={page} go={go}/>}<div className="content">
+ <main className="workspace"><div className="crumb">iStation StationBuilder / <b>{["domains","domainDetail","styleLibrary"].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"].includes(page)&&<Button secondary onClick={()=>notify("草稿已保存")}>保存草稿</Button>}{page==="projects"&&<Button onClick={()=>go("basic","new")}>+ 新建站点</Button>}{(page==="domains"||page==="domainDetail")&&<Button onClick={()=>setOpenDomainTemplateId("create")}>+ 新增领域方案</Button>}{(page==="domains"||page==="domainDetail")&&openDomainTemplateId!=null&&<Button secondary onClick={()=>setOpenDomainTemplateId(null)}>← 返回方案列表</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"&&<DomainLibrary go={go} notify={notify}/>}
- {page==="domainDetail"&&<DomainDetail go={go} notify={notify}/>}
+ {(page==="domains"||page==="domainDetail")&&(openDomainTemplateId!=null
+ ? <DomainDetail id={openDomainTemplateId==="create"?null:openDomainTemplateId} go={go} notify={notify} onBack={()=>setOpenDomainTemplateId(null)} onCreated={setOpenDomainTemplateId}/>
+ : <DomainLibrary go={go} notify={notify} onOpen={(id:number)=>setOpenDomainTemplateId(id)}/>)}
{page==="styleLibrary"&&<PageStyleLibrary pageStyles={pageStyles} loadPageStyles={loadPageStyles} notify={notify}/>}
{page==="basic"&&<Basic site={site} domains={domains} onChange={saveSiteBasic} onAddDomain={addDomain} onUpload={uploadFile} saving={saving} notify={notify} onViewCompletePrompt={viewCompletePrompt}/>}
{page==="assets"&&<Assets site={site} onChange={saveAssets} saving={saving} notify={notify} onViewCompletePrompt={viewCompleteAssetsPrompt}/>}
return <div className="preview-shell"><div className="preview-toolbar"><div><select value={cubeIdx} onChange={e=>setCubeIdx(Number(e.target.value))}>{(site?.cubes||[]).map((c,i)=><option key={i} value={i}>{c.name}</option>)}</select><Status>{cube?.status==="generated"?"校验通过":"未生成"}</Status></div><div><button>1440 × 900⌄</button><button>☼ 浅色</button><button onClick={()=>notify("模拟数据已刷新")}>↻ 刷新数据</button><button>⛶ 全屏</button></div></div><div className="preview-main"><div className="cube-canvas"><div className="cube-top"><b>⬡ iStation</b><span>{site?.name || "未命名站点"}</span><i>⌕ ♧ ? ▦ W</i></div><aside className="cube-side"><strong>站点监控</strong><button className="active">▦ {cube?.name || "运行总览"}</button><button>▧ 设备管理</button><button>▥ 能源管理</button><button>△ 告警中心</button><button>⚙ 系统设置</button></aside><div className="cube-content"><header><div><small>站点监控 / {cube?.name}</small><h2>{site?.name || "未命名站点"}</h2></div><span>最后更新:刚刚 ↻</span></header><div className="cube-kpis">{[["设备在线率","92%","23 / 25"],["今日总能耗","12,480","kWh"],["未处理告警","7","高危 2"],["平均负载率","68%","较昨日 +3%"]].map((x,i)=><div key={x[0]}><span>{x[0]}</span><b>{x[1]}</b><small>{x[2]}</small><i className={"spark s"+i}/></div>)}</div><div className="cube-grid"><section className="line-panel"><h3>近 7 日能源消耗 <span>本周⌄</span></h3><div className="line-chart"><div/><svg viewBox="0 0 500 150" preserveAspectRatio="none"><polyline points="0,112 65,92 130,103 200,56 270,75 335,45 400,62 500,24" fill="none" stroke="#009fe3" strokeWidth="3"/><polyline points="0,132 65,118 130,121 200,98 270,102 335,82 400,91 500,68" fill="none" stroke="#8ecfee" strokeWidth="2"/></svg><footer>{["08/08","08/09","08/10","08/11","08/12","08/13","今天"].map(x=><span key={x}>{x}</span>)}</footer></div></section><section className="donut-panel"><h3>设备状态分布</h3><div className="donut"><svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="40" fill="none" stroke="#E8E8E8" strokeWidth="14"/><circle cx="50" cy="50" r="40" fill="none" stroke="#009fe3" strokeWidth="14" strokeDasharray="180 251"/></svg><b>72%</b><span>在线</span></div></section></div></div></div></div></div>
}
-function DomainLibrary({go,notify}:{go:(p:PageKey)=>void;notify:(m:string)=>void}){
- const domains=[
- {icon:"♨",name:"燃气",desc:"城市燃气门站、调压站及管网监控标准方案",assets:18,flows:6,pages:7,docs:12,color:"gas"},
- {icon:"≋",name:"水务 · 自来水厂",desc:"覆盖取水、制水、送水及水质保障的完整方案",assets:24,flows:8,pages:9,docs:16,color:"water"},
- {icon:"☀",name:"新能源",desc:"光伏、储能与充电设施的一体化运营方案",assets:15,flows:5,pages:8,docs:10,color:"energy"},
- {icon:"▥",name:"数据中心",desc:"供配电、制冷、能耗和基础设施健康度方案",assets:28,flows:9,pages:10,docs:18,color:"dc"},
- ];
- return <><div className="domain-hero"><div><span className="eyebrow">DOMAIN SOLUTION LIBRARY</span><h2>从成熟领域方案开始,而不是从零开始</h2><p>复用已验证的资产模板、运维流程、Cube 领域页面、菜单组合和领域知识,快速交付客户站点。</p></div><div className="domain-hero-stats"><div><b>4</b><span>重点领域</span></div><div><b>34</b><span>领域页面</span></div><div><b>56</b><span>知识文档</span></div><div><b>72%</b><span>预计提速</span></div></div></div>
- <Panel><div className="filterbar domain-filter"><label>⌕ <input placeholder="搜索领域、设备、页面或知识"/></label><select><option>全部领域</option></select><select><option>全部状态</option></select><Button>Search</Button><span/><button className="text-button" onClick={()=>notify("方案库已刷新")}>↻ 同步方案库</button></div><div className="domain-grid">{domains.map((d,i)=><article key={d.name} className={d.color}><header><span>{d.icon}</span><div><Status>{i===1?"已验证":"可使用"}</Status><small>V{[2.1,3.4,1.8,4.2][i]}</small></div></header><h3>{d.name}</h3><p>{d.desc}</p><div className="domain-metrics"><span><b>{d.assets}</b>资产模板</span><span><b>{d.flows}</b>运维流程</span><span><b>{d.pages}</b>领域页面</span><span><b>{d.docs}</b>知识文档</span></div><footer><button onClick={()=>i===1?go("domainDetail"):notify("已打开 "+d.name+" 方案概览")}>查看方案 →</button><button onClick={()=>{notify("已应用 "+d.name+" 方案");go("basic")}}>直接使用</button></footer></article>)}</div></Panel>
- <Panel title="一个领域方案包含什么"><div className="solution-parts">{[["▧","资产属性","设备类型、采集点位、数据范围、单位、报警规则与资产组层级"],["↻","运维流程表单","保养模板与流程、维修模板与流程,后续扩展巡检"],["▣","领域页面","一组可直接使用或微调的 Cube 扩展页面及动态 Panel"],["☷","解决方案菜单","MU 标准页面、Cube 页面、菜单层级及默认参数组合"],["▤","领域知识","PDF 文档、报警规则、维修规则和保养规则"]].map(x=><div key={x[1]}><i>{x[0]}</i><span><b>{x[1]}</b><small>{x[2]}</small></span></div>)}</div></Panel>
- <Panel title="最近更新"><table className="data-table"><thead><tr><th>领域方案</th><th>更新内容</th><th>版本</th><th>负责人</th><th>更新时间</th><th>状态</th></tr></thead><tbody>{[["水务 · 自来水厂","新增水质分析与药剂消耗页面","V3.4","王宇","今天 10:42","已发布"],["数据中心","更新冷源群控资产组模板","V4.2","陈晨","昨天 16:20","已发布"],["新能源","新增储能收益分析页面","V1.8","李静","08/12 14:08","评审中"]].map(x=><tr key={x[0]}>{x.slice(0,5).map(v=><td key={v}>{v}</td>)}<td><Status>{x[5]}</Status></td></tr>)}</tbody></table></Panel></>
+function fileToBase64(file:File){return new Promise<{filename:string;mime:string;data:string}>((resolve,reject)=>{const r=new FileReader();r.onload=()=>{const buf=new Uint8Array(r.result as ArrayBuffer);let bin="";for(const b of buf)bin+=String.fromCharCode(b);resolve({filename:file.name,mime:file.type||"application/octet-stream",data:btoa(bin)});};r.onerror=reject;r.readAsArrayBuffer(file);});}
+
+function DomainLibrary({go,notify,onOpen}:{go:(p:PageKey)=>void;notify:(m:string)=>void;onOpen:(id:number)=>void}){
+ const [list,setList]=useState<any[]>([]);
+ const [loading,setLoading]=useState(true);
+ const load=async()=>{ try{ setList(await fetchDb("/domain-templates") as any); }catch(e:any){ notify("加载失败:"+e.message); } finally{ setLoading(false); } };
+ useEffect(()=>{ load(); },[]);
+ const del=async(id:number)=>{ if(!confirm("确定删除该领域方案?此操作不可恢复")) return; try{ await fetchDb(`/domain-templates/${id}`,{method:"DELETE"}); notify("已删除"); load(); }catch(e:any){ notify("删除失败:"+e.message); } };
+ return <>
+ <div className="domain-hero"><div><span className="eyebrow">DOMAIN SOLUTION LIBRARY</span><h2>领域方案库</h2><p>沉淀各领域标准方案,供客户站点直接使用或快速微调。</p></div></div>
+ <Panel title="领域方案列表">
+ {loading? <p>加载中…</p> :
+ <table className="data-table"><thead><tr><th>模板名称</th><th>所属领域</th><th>版本</th><th>状态</th><th>创建人</th><th>设备/页面/文档</th><th>操作</th></tr></thead>
+ <tbody>
+ {list.length===0 && <tr><td colSpan={7} className="muted">暂无方案,点击右上角「+ 新增领域方案」创建</td></tr>}
+ {list.map((t:any)=>(<tr key={t.id}>
+ <td>{t.name}</td><td>{t.domain_name}</td><td>{t.version}</td>
+ <td><Status>{t.status==="available"?"可用":"不可用"}</Status></td>
+ <td>{t.creator||"—"}</td>
+ <td>{t.device_count}/{t.page_count}/{t.doc_count}</td>
+ <td><button className="text-button" onClick={()=>onOpen(t.id)}>编辑</button> <button className="text-button" onClick={()=>del(t.id)}>删除</button></td>
+ </tr>))}
+ </tbody></table>}
+ </Panel>
+ </>;
+}
+
+function CreateTemplate({doms,notify,onCreated}:{doms:any[];notify:(m:string)=>void;onCreated:(id:number)=>void}){
+ const [domain_id,setDomainId]=useState<number>(doms[0]?.id||0);
+ const [name,setName]=useState(""); const [description,setDesc]=useState("");
+ const [status,setStatus]=useState("available"); const [version,setVersion]=useState("V1.0"); const [creator,setCreator]=useState("");
+ const save=async()=>{ try{ const r:any=await fetchDb("/domain-templates",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain_id,name,description,status,version,creator})}); notify("已创建"); onCreated(r.id); }catch(e:any){ notify("创建失败:"+e.message); } };
+ return <Panel title="新增领域方案"><div className="form-grid">
+ <label>所属领域<select value={domain_id} onChange={e=>setDomainId(Number(e.target.value))}>{doms.map(d=><option key={d.id} value={d.id}>{d.name}</option>)}</select></label>
+ <label>模板名称<input value={name} onChange={e=>setName(e.target.value)} placeholder="如:自来水厂标准方案"/></label>
+ <label>模板版本<input value={version} onChange={e=>setVersion(e.target.value)}/></label>
+ <label>状态<select value={status} onChange={e=>setStatus(e.target.value)}><option value="available">可用</option><option value="unavailable">不可用</option></select></label>
+ <label>创建人<input value={creator} onChange={e=>setCreator(e.target.value)}/></label>
+ <label style={{gridColumn:"1 / -1"}}>领域描述<textarea value={description} onChange={e=>setDesc(e.target.value)} rows={3}/></label>
+ <div style={{gridColumn:"1 / -1"}}><Button onClick={save}>创建</Button></div>
+ </div></Panel>;
}
-function DomainDetail({go,notify}:{go:(p:PageKey)=>void;notify:(m:string)=>void}){
- const pages=[["水厂运行总览","总览大屏","全厂关键指标、工艺流程和实时告警"],["制水工艺监控","工艺监控","取水、混凝、沉淀、过滤和消毒全过程"],["水质分析","分析页面","浊度、余氯、pH 及关键水质趋势"],["泵组效率分析","设备分析","泵组能效、运行区间和优化建议"],["药剂消耗分析","成本分析","药剂单耗、趋势和异常消耗"],["供水压力总览","管网监控","送水压力、流量和区域分布"]];
- return <><div className="solution-banner water"><div className="solution-logo">≋</div><div><span>水务领域 / 标准方案</span><h2>自来水厂数字化运营方案</h2><p>覆盖资产、制水运维、领域页面、菜单与知识的一体化模板,可直接用于新站点或按项目微调。</p><div><Status>已验证</Status><small>V3.4 · 更新于今天 10:42 · 适用中小型自来水厂</small></div></div><aside><Button secondary onClick={()=>go("preview")}>预览领域页面</Button><Button secondary onClick={()=>go("functions")}>微调方案</Button></aside></div>
- <div className="solution-summary">{[["24","资产模板"],["42","采集点位"],["18","报警规则"],["8","运维流程"],["9","Cube 页面"],["16","知识文档"]].map(x=><div key={x[1]}><b>{x[0]}</b><span>{x[1]}</span></div>)}</div>
- <div className="detail-grid"><div><Panel title="资产属性与资产组模板" tools={<button className="text-button" onClick={()=>notify("资产模板预览已打开")}>查看完整模板 →</button>}><div className="asset-template"><div className="asset-tree"><b>⌄ 自来水厂</b><span>├─ 取水区域 <em>取水泵 × 3</em></span><span>├─ 制水车间 <em>沉淀池 × 2 · 滤池 × 4</em></span><span>├─ 清水区域 <em>清水池 × 2</em></span><span>└─ 送水区域 <em>送水泵 × 4</em></span></div><div className="template-specs"><div><b>设备类型</b><p>泵、池体、阀门、流量计、压力表、水质分析仪等 24 类</p></div><div><b>关键采集点位</b><p>点位名称、数据范围、单位及模拟数据规则,共 42 项</p></div><div><b>关键报警规则</b><p>18 条判断规则与处理规则,覆盖水质、压力及设备异常</p></div></div></div></Panel>
- <Panel title="领域页面 · Cube 扩展页面" tools={<Button onClick={()=>go("preview")}>预览页面组</Button>}><div className="domain-page-list">{pages.map((x,i)=><article key={x[0]}><div className={"domain-page-thumb p"+i}><span>▦</span><i/><i/><i/></div><section><div><Status>{i<3?"推荐":"可选"}</Status><small>Cube JSON</small></div><h3>{x[0]}</h3><b>{x[1]}</b><p>{x[2]}</p><footer><button onClick={()=>go("preview")}>预览</button><button onClick={()=>notify(x[0]+" 已加入微调清单")}>加入站点</button></footer></section></article>)}</div></Panel></div>
- <div><Panel title="运维流程表单"><div className="flow-list">{[["保养模板","6","滤池反冲洗、泵组润滑、仪表校准"],["保养流程","3","计划、执行、验收闭环"],["维修模板","5","泵组、阀门、仪表典型维修"],["维修流程","2","故障报修与紧急抢修"]].map(x=><div key={x[0]}><i>↻</i><span><b>{x[0]}</b><small>{x[2]}</small></span><em>{x[1]} 项</em></div>)}<div className="future-item"><i>+</i><span><b>巡检模板</b><small>建议第二阶段加入</small></span><em>规划中</em></div></div></Panel>
- <Panel title="领域解决方案菜单"><div className="solution-menu"><b>⌄ 水厂总览</b><span><i>C</i>水厂运行总览</span><b>⌄ 生产运行</b><span><i>MU</i>设备总览</span><span><i>C</i>制水工艺监控</span><span><i>C</i>水质分析</span><b>⌄ 设备管理</b><span><i>MU</i>实时监控</span><span><i>C</i>泵组效率分析</span><b>⌄ 运维管理</b><span><i>MU</i>告警中心 / 维修任务</span></div><button className="text-button" onClick={()=>notify("菜单默认参数已展开")}>查看默认区域与站点参数 →</button></Panel>
- <Panel title="领域知识"><div className="knowledge-list">{[["▤","自来水厂工艺说明.pdf","PDF · 3.2 MB"],["△","水质报警规则集","18 条规则"],["⚒","水泵维修规则","12 条规则"],["↻","设备保养规则","16 条规则"]].map(x=><div key={x[1]}><i>{x[0]}</i><span><b>{x[1]}</b><small>{x[2]}</small></span><button onClick={()=>notify("已打开 "+x[1])}>查看</button></div>)}</div></Panel></div></div></>
+function DeviceCard({dev,doms,tmplId,notify,onChange}:{dev:any;doms:any[];tmplId:number;notify:(m:string)=>void;onChange:()=>void}){
+ const [name,setName]=useState(dev.name); const [description,setDesc]=useState(dev.description||"");
+ const [domainIds,setDomainIds]=useState<number[]>(dev.domain_ids||[]);
+ const [cps,setCps]=useState<any[]>(dev.collection_points||[]);
+ const imgChanged=async(e:any)=>{ const f=e.target.files?.[0]; if(!f) return; const b=await fileToBase64(f); await fetchDb(`/device-templates/${dev.id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({name,description,domain_ids:domainIds,domain_template_ids:[tmplId],image:{filename:b.filename,mime:b.mime,data:b.data}})}); notify("已更新图片"); onChange(); };
+ const save=async()=>{ await fetchDb(`/device-templates/${dev.id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({name,description,domain_ids:domainIds,domain_template_ids:[tmplId]})}); notify("设备已保存"); onChange(); };
+ const delDev=async()=>{ if(!confirm("删除该设备模板?")) return; await fetchDb(`/device-templates/${dev.id}`,{method:"DELETE"}); onChange(); };
+ const addCp=async()=>{ const nm=prompt("采集点名称"); if(!nm) return; await fetchDb(`/device-templates/${dev.id}/collection-points`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:nm})}); onChange(); };
+ const delCp=async(id:number)=>{ await fetchDb(`/collection-points/${id}`,{method:"DELETE"}); onChange(); };
+ return <div className="device-card">
+ <div className="device-card-head"><b>{name||"(未命名)"}</b><button className="text-button" onClick={delDev}>删除设备</button></div>
+ <div className="form-grid">
+ <label>设备名称<input value={name} onChange={e=>setName(e.target.value)}/></label>
+ <label>设备图片<input type="file" accept="image/*" onChange={imgChanged}/></label>
+ <label style={{gridColumn:"1 / -1"}}>说明<textarea value={description} onChange={e=>setDesc(e.target.value)} rows={2}/></label>
+ <label style={{gridColumn:"1 / -1"}}>所属领域(多选)
+ <div className="chk">{doms.map(d=><label key={d.id} className="chk-item"><input type="checkbox" checked={domainIds.includes(d.id)} onChange={e=>setDomainIds(e.target.checked?[...domainIds,d.id]:domainIds.filter(x=>x!==d.id))}/>{d.name}</label>)}</div>
+ </label>
+ </div>
+ <div className="cp-list"><b>采集点</b> <button className="text-button" onClick={addCp}>+ 采集点</button>
+ <table className="data-table"><thead><tr><th>名称</th><th>说明</th><th>单位</th><th>数值范围</th><th>描述</th><th></th></tr></thead>
+ <tbody>{cps.map((c:any)=>(<tr key={c.id}><td>{c.name}</td><td>{c.collect_note||""}</td><td>{c.unit||""}</td><td>{c.value_range||""}</td><td>{c.description||""}</td><td><button className="text-button" onClick={()=>delCp(c.id)}>删</button></td></tr>))}</tbody></table>
+ </div>
+ <div style={{marginTop:8}}><Button onClick={save}>保存设备</Button></div>
+ </div>;
+}
+
+function AddDevice({tmplId,doms,notify,onChange}:{tmplId:number;doms:any[];notify:(m:string)=>void;onChange:()=>void}){
+ const [name,setName]=useState(""); const [domainId,setDomainId]=useState<number>(doms[0]?.id||0);
+ const add=async()=>{ if(!name.trim()){ notify("请填写设备名称"); return; } await fetchDb(`/device-templates`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name,domain_ids:[domainId],domain_template_ids:[tmplId]})}); notify("已添加设备"); onChange(); };
+ return <div className="add-device"><input placeholder="新设备模板名称" value={name} onChange={e=>setName(e.target.value)}/><select value={domainId} onChange={e=>setDomainId(Number(e.target.value))}>{doms.map(d=><option key={d.id} value={d.id}>{d.name}</option>)}</select><Button onClick={add}>+ 添加设备</Button></div>;
+}
+
+function PageRow({p,notify,onChange}:{p:any;notify:(m:string)=>void;onChange:()=>void}){
+ const [name,setName]=useState(p.name); const [description,setDesc]=useState(p.description||""); const [json_text,setJson]=useState(p.json_text);
+ const [editing,setEditing]=useState(false);
+ const save=async()=>{ try{ JSON.parse(json_text); }catch{ notify("Cube JSON 不是合法 JSON"); return; } await fetchDb(`/domain-pages/${p.id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({name,description,json_text})}); notify("页面已保存"); setEditing(false); onChange(); };
+ const del=async()=>{ if(!confirm("删除该页面?")) return; await fetchDb(`/domain-pages/${p.id}`,{method:"DELETE"}); onChange(); };
+ return <div className="page-row"><div className="page-row-head"><b>{name}</b><span>{editing? <><Button onClick={save}>保存</Button> <button className="text-button" onClick={()=>setEditing(false)}>取消</button></> : <><button className="text-button" onClick={()=>setEditing(true)}>编辑</button> <button className="text-button" onClick={del}>删除</button></>}</span></div>
+ {editing && <div className="form-grid">
+ <label>页面名称<input value={name} onChange={e=>setName(e.target.value)}/></label>
+ <label>说明<input value={description} onChange={e=>setDesc(e.target.value)}/></label>
+ <label style={{gridColumn:"1 / -1"}}>Cube JSON<textarea value={json_text} onChange={e=>setJson(e.target.value)} rows={10} style={{fontFamily:"monospace"}}/></label>
+ </div>}
+ </div>;
+}
+
+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}){
+ const [doms,setDoms]=useState<any[]>([]);
+ const [t,setT]=useState<any>(null);
+ const [loading,setLoading]=useState(false);
+ const [m,setM]=useState<any>(null);
+ const loadDoms=async()=>{ try{ setDoms(await fetchDb("/domains") as any); }catch{} };
+ const load=async()=>{ if(id==null){ setT(null); return; } setLoading(true); try{ setT(await fetchDb(`/domain-templates/${id}`) as any); }catch(e:any){ notify("加载失败:"+e.message); } finally{ 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]);
+ if(id==null) return <CreateTemplate doms={doms} notify={notify} onCreated={onCreated}/>;
+ if(loading||!t) 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=prompt("文档名称"); if(!nm) return; const link=confirm("是否为超链?(确定=超链,取消=上传文件)"); if(link){ const u=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 <>
+ <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})`}><div className="device-list">
+ {t.devices.map((d:any)=><DeviceCard key={d.id} dev={d} doms={doms} tmplId={id} notify={notify} onChange={load}/>)}
+ <AddDevice tmplId={id} doms={doms} notify={notify} onChange={load}/>
+ </div></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>
+ </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>
+ <OpsFlowsViewer/>
+ </>;
}
function PageStyleLibrary({pageStyles, loadPageStyles, notify}:{pageStyles:PageStyle[]; loadPageStyles:()=>Promise<void>; notify:(m:string)=>void}){
image_path TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
+ CREATE TABLE IF NOT EXISTS domain_templates (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ domain_id INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ description TEXT,
+ status TEXT NOT NULL DEFAULT 'available',
+ version TEXT NOT NULL DEFAULT 'V1.0',
+ creator TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY(domain_id) REFERENCES domains(id) ON DELETE CASCADE
+ );
+ CREATE TABLE IF NOT EXISTS device_templates (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ description TEXT,
+ image_path TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+ CREATE TABLE IF NOT EXISTS device_collection_points (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ device_template_id INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ collect_note TEXT,
+ unit TEXT,
+ value_range TEXT,
+ description TEXT,
+ FOREIGN KEY(device_template_id) REFERENCES device_templates(id) ON DELETE CASCADE
+ );
+ CREATE TABLE IF NOT EXISTS device_template_domains (
+ device_template_id INTEGER NOT NULL,
+ domain_id INTEGER NOT NULL,
+ PRIMARY KEY(device_template_id, domain_id),
+ FOREIGN KEY(device_template_id) REFERENCES device_templates(id) ON DELETE CASCADE,
+ FOREIGN KEY(domain_id) REFERENCES domains(id) ON DELETE CASCADE
+ );
+ CREATE TABLE IF NOT EXISTS device_template_domain_templates (
+ device_template_id INTEGER NOT NULL,
+ domain_template_id INTEGER NOT NULL,
+ PRIMARY KEY(device_template_id, domain_template_id),
+ FOREIGN KEY(device_template_id) REFERENCES device_templates(id) ON DELETE CASCADE,
+ FOREIGN KEY(domain_template_id) REFERENCES domain_templates(id) ON DELETE CASCADE
+ );
+ CREATE TABLE IF NOT EXISTS domain_pages (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ domain_template_id INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ description TEXT,
+ json_text TEXT NOT NULL,
+ FOREIGN KEY(domain_template_id) REFERENCES domain_templates(id) ON DELETE CASCADE
+ );
+ CREATE TABLE IF NOT EXISTS template_menu_items (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ domain_template_id INTEGER NOT NULL,
+ parent_id INTEGER,
+ label TEXT NOT NULL,
+ page_type TEXT,
+ ref_id INTEGER,
+ sort_order INTEGER DEFAULT 0,
+ FOREIGN KEY(domain_template_id) REFERENCES domain_templates(id) ON DELETE CASCADE
+ );
+ CREATE TABLE IF NOT EXISTS knowledge_docs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ domain_template_id INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ doc_type TEXT NOT NULL DEFAULT 'file',
+ url TEXT,
+ file_path TEXT,
+ mime TEXT,
+ size INTEGER,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY(domain_template_id) REFERENCES domain_templates(id) ON DELETE CASCADE
+ );
+ CREATE TABLE IF NOT EXISTS ops_flows (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ domain_template_id INTEGER,
+ name TEXT NOT NULL,
+ content TEXT,
+ FOREIGN KEY(domain_template_id) REFERENCES domain_templates(id) ON DELETE CASCADE
+ );
CREATE INDEX IF NOT EXISTS idx_sites_updated ON sites(updated_at);
CREATE INDEX IF NOT EXISTS idx_sites_status ON sites(status);
`);
const insertDomain = db.prepare("INSERT OR IGNORE INTO domains (name) VALUES (?)");
for (const d of DEFAULT_DOMAINS) insertDomain.run(d);
seedPageStyles();
+ // 运维流程本期不实现,仅保留一条占位记录
+ db.prepare("INSERT INTO ops_flows (name, content) SELECT '示例运维流程(占位)', '{}' WHERE NOT EXISTS (SELECT 1 FROM ops_flows)").run();
}
initSchema();
res.end(buf);
}
+// ============ 领域方案库 API ============
+
+async function saveBase64File(subdir, filename, mime, data) {
+ const dir = join(UPLOAD_DIR, subdir);
+ ensureDir(dir);
+ const safe = safeFilename(filename || "file");
+ const p = join(dir, safe);
+ const buf = Buffer.from(data, "base64");
+ await writeFile(p, buf);
+ return { path: p, mime: mime || "application/octet-stream", size: buf.length };
+}
+
+function linkDevice(body, devId) {
+ db.prepare("DELETE FROM device_template_domains WHERE device_template_id=?").run(devId);
+ db.prepare("DELETE FROM device_template_domain_templates WHERE device_template_id=?").run(devId);
+ for (const did of Array.isArray(body.domain_ids) ? body.domain_ids : []) {
+ if (db.prepare("SELECT 1 FROM domains WHERE id=?").get(Number(did)))
+ db.prepare("INSERT OR IGNORE INTO device_template_domains (device_template_id, domain_id) VALUES (?,?)").run(devId, Number(did));
+ }
+ for (const tid of Array.isArray(body.domain_template_ids) ? body.domain_template_ids : []) {
+ if (db.prepare("SELECT 1 FROM domain_templates WHERE id=?").get(Number(tid)))
+ db.prepare("INSERT OR IGNORE INTO device_template_domain_templates (device_template_id, domain_template_id) VALUES (?,?)").run(devId, Number(tid));
+ }
+}
+
+async function handleDomainTemplatesList(res) {
+ const rows = db.prepare(`
+ SELECT dt.id, dt.name, dt.description, dt.status, dt.version, dt.creator, dt.created_at,
+ d.name AS domain_name, d.id AS domain_id,
+ (SELECT COUNT(*) FROM device_template_domain_templates td WHERE td.domain_template_id=dt.id) AS device_count,
+ (SELECT COUNT(*) FROM domain_pages dp WHERE dp.domain_template_id=dt.id) AS page_count,
+ (SELECT COUNT(*) FROM knowledge_docs kd WHERE kd.domain_template_id=dt.id) AS doc_count
+ FROM domain_templates dt JOIN domains d ON d.id=dt.domain_id
+ ORDER BY dt.id DESC`).all();
+ respondJson(res, 200, rows);
+}
+
+async function handleDomainTemplateCreate(req, res) {
+ const body = await parseBody(req);
+ const domain_id = Number(body.domain_id);
+ const name = (body.name || "").toString().trim();
+ if (!domain_id) return respondError(res, 400, "请选择所属领域");
+ if (!name) return respondError(res, 400, "模板名称不能为空");
+ if (!db.prepare("SELECT 1 FROM domains WHERE id=?").get(domain_id)) return respondError(res, 404, "领域不存在");
+ const status = body.status === "unavailable" ? "unavailable" : "available";
+ const info = db.prepare("INSERT INTO domain_templates (domain_id,name,description,status,version,creator) VALUES (?,?,?,?,?,?)").run(domain_id, name, body.description || "", status, (body.version || "V1.0").toString(), (body.creator || "").toString());
+ respondJson(res, 200, { ok: true, id: info.lastInsertRowid });
+}
+
+async function handleDomainTemplateDetail(res, id) {
+ 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(id);
+ if (!dt) return respondError(res, 404, "领域模板不存在");
+ const devices = db.prepare(`
+ SELECT dt2.*,
+ (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=dt2.id) AS domain_names,
+ (SELECT json_group_array(dtd.domain_id) FROM device_template_domains dtd WHERE dtd.device_template_id=dt2.id) AS domain_ids
+ FROM device_templates dt2
+ WHERE dt2.id IN (SELECT device_template_id FROM device_template_domain_templates WHERE domain_template_id=?)
+ `).all(id);
+ for (const dev of devices) { dev.collection_points = db.prepare("SELECT * FROM device_collection_points WHERE device_template_id=? ORDER BY id").all(dev.id); try { dev.domain_ids = JSON.parse(dev.domain_ids || "[]"); } catch { dev.domain_ids = []; } try { dev.domain_names = JSON.parse(dev.domain_names || "[]"); } catch { dev.domain_names = []; } }
+ const pages = db.prepare("SELECT * FROM domain_pages WHERE domain_template_id=? ORDER BY id").all(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 });
+}
+
+async function handleDomainTemplateUpdate(req, res, id) {
+ const row = db.prepare("SELECT * FROM domain_templates WHERE id=?").get(id);
+ if (!row) return respondError(res, 404, "领域模板不存在");
+ const body = await parseBody(req);
+ const name = body.name != null ? body.name.toString().trim() : row.name;
+ const domain_id = body.domain_id != null ? Number(body.domain_id) : row.domain_id;
+ const description = body.description != null ? body.description : row.description;
+ 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;
+ 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);
+ respondJson(res, 200, { ok: true, id });
+}
+
+async function handleDomainTemplateDelete(res, id) {
+ const row = db.prepare("SELECT * FROM domain_templates WHERE id=?").get(id);
+ if (!row) return respondError(res, 404, "领域模板不存在");
+ db.prepare("DELETE FROM device_template_domain_templates WHERE domain_template_id=?").run(id);
+ db.prepare("DELETE FROM domain_templates WHERE id=?").run(id);
+ respondJson(res, 200, { ok: true });
+}
+
+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
+ FROM device_templates dt ORDER BY dt.id DESC`).all();
+ respondJson(res, 200, rows);
+}
+
+async function handleDeviceTemplateCreate(req, res) {
+ const body = await parseBody(req);
+ const name = (body.name || "").toString().trim();
+ if (!name) return respondError(res, 400, "设备模板名称不能为空");
+ let image_path = null;
+ if (body.image && body.image.data) image_path = (await saveBase64File("devices", body.image.filename, body.image.mime, body.image.data)).path;
+ const info = db.prepare("INSERT INTO device_templates (name,description,image_path) VALUES (?,?,?)").run(name, body.description || "", image_path);
+ linkDevice(body, info.lastInsertRowid);
+ respondJson(res, 200, { ok: true, id: info.lastInsertRowid });
+}
+
+async function handleDeviceTemplateDetail(res, id) {
+ const dev = db.prepare("SELECT * FROM device_templates WHERE id=?").get(id);
+ if (!dev) return respondError(res, 404, "设备模板不存在");
+ dev.collection_points = db.prepare("SELECT * FROM device_collection_points WHERE device_template_id=? ORDER BY id").all(id);
+ dev.domain_ids = db.prepare("SELECT domain_id FROM device_template_domains WHERE device_template_id=?").all(id).map(r => r.domain_id);
+ dev.domain_template_ids = db.prepare("SELECT domain_template_id FROM device_template_domain_templates WHERE device_template_id=?").all(id).map(r => r.domain_template_id);
+ respondJson(res, 200, dev);
+}
+
+async function handleDeviceTemplateUpdate(req, res, id) {
+ const row = db.prepare("SELECT * FROM device_templates WHERE id=?").get(id);
+ if (!row) return respondError(res, 404, "设备模板不存在");
+ const body = await parseBody(req);
+ const name = body.name != null ? body.name.toString().trim() : row.name;
+ const description = body.description != null ? body.description : row.description;
+ let image_path = row.image_path;
+ if (body.image && body.image.data) image_path = (await saveBase64File("devices", body.image.filename, body.image.mime, body.image.data)).path;
+ else if (body.image === null || body.image === "") image_path = null;
+ if (!name) return respondError(res, 400, "设备模板名称不能为空");
+ db.prepare("UPDATE device_templates SET name=?,description=?,image_path=? WHERE id=?").run(name, description, image_path, id);
+ if (body.domain_ids !== undefined || body.domain_template_ids !== undefined) linkDevice(body, id);
+ respondJson(res, 200, { ok: true, id });
+}
+
+async function handleDeviceTemplateDelete(res, id) {
+ const row = db.prepare("SELECT * FROM device_templates WHERE id=?").get(id);
+ if (!row) return respondError(res, 404, "设备模板不存在");
+ if (row.image_path && existsSync(row.image_path)) { try { await rm(row.image_path); } catch {} }
+ db.prepare("DELETE FROM device_templates WHERE id=?").run(id);
+ respondJson(res, 200, { ok: true });
+}
+
+async function handleCollectionPointCreate(req, res, devId) {
+ const body = await parseBody(req);
+ const name = (body.name || "").toString().trim();
+ if (!name) return respondError(res, 400, "采集点名称不能为空");
+ const info = db.prepare("INSERT INTO device_collection_points (device_template_id,name,collect_note,unit,value_range,description) VALUES (?,?,?,?,?,?)").run(devId, name, body.collect_note || "", body.unit || "", body.value_range || "", body.description || "");
+ respondJson(res, 200, { ok: true, id: info.lastInsertRowid });
+}
+
+async function handleCollectionPointUpdate(req, res, id) {
+ const row = db.prepare("SELECT * FROM device_collection_points WHERE id=?").get(id);
+ if (!row) return respondError(res, 404, "采集点不存在");
+ const body = await parseBody(req);
+ const name = body.name != null ? body.name.toString().trim() : row.name;
+ const collect_note = body.collect_note != null ? body.collect_note : row.collect_note;
+ const unit = body.unit != null ? body.unit : row.unit;
+ const value_range = body.value_range != null ? body.value_range : row.value_range;
+ const description = body.description != null ? body.description : row.description;
+ if (!name) return respondError(res, 400, "采集点名称不能为空");
+ db.prepare("UPDATE device_collection_points SET name=?,collect_note=?,unit=?,value_range=?,description=? WHERE id=?").run(name, collect_note, unit, value_range, description, id);
+ respondJson(res, 200, { ok: true, id });
+}
+
+async function handleCollectionPointDelete(res, id) {
+ db.prepare("DELETE FROM device_collection_points WHERE id=?").run(id);
+ respondJson(res, 200, { ok: true });
+}
+
+async function handleDomainPageCreate(req, res, tmplId) {
+ const body = await parseBody(req);
+ const name = (body.name || "").toString().trim();
+ if (!name) return respondError(res, 400, "页面名称不能为空");
+ if (!body.json_text) return respondError(res, 400, "Cube JSON 不能为空");
+ try { JSON.parse(body.json_text); } catch { return respondError(res, 400, "Cube JSON 不是合法 JSON"); }
+ const info = db.prepare("INSERT INTO domain_pages (domain_template_id,name,description,json_text) VALUES (?,?,?,?)").run(tmplId, name, body.description || "", body.json_text);
+ respondJson(res, 200, { ok: true, id: info.lastInsertRowid });
+}
+
+async function handleDomainPageUpdate(req, res, id) {
+ const row = db.prepare("SELECT * FROM domain_pages WHERE id=?").get(id);
+ if (!row) return respondError(res, 404, "领域页面不存在");
+ const body = await parseBody(req);
+ const name = body.name != null ? body.name.toString().trim() : row.name;
+ const description = body.description != null ? body.description : row.description;
+ let json_text = body.json_text != null ? body.json_text : row.json_text;
+ if (body.json_text != null) { try { JSON.parse(json_text); } catch { return respondError(res, 400, "Cube JSON 不是合法 JSON"); } }
+ if (!name) return respondError(res, 400, "页面名称不能为空");
+ db.prepare("UPDATE domain_pages SET name=?,description=?,json_text=? WHERE id=?").run(name, description, json_text, id);
+ respondJson(res, 200, { ok: true, id });
+}
+
+async function handleDomainPageDelete(res, id) {
+ db.prepare("DELETE FROM domain_pages WHERE id=?").run(id);
+ respondJson(res, 200, { ok: true });
+}
+
+async function handleMenuCreate(req, res, tmplId) {
+ const body = await parseBody(req);
+ const label = (body.label || "").toString().trim();
+ if (!label) return respondError(res, 400, "菜单项名称不能为空");
+ const maxSort = db.prepare("SELECT MAX(sort_order) m FROM template_menu_items WHERE domain_template_id=?").get(tmplId).m || 0;
+ const info = db.prepare("INSERT INTO template_menu_items (domain_template_id,parent_id,label,page_type,ref_id,sort_order) VALUES (?,?,?,?,?,?)").run(tmplId, body.parent_id ?? null, label, body.page_type || null, body.ref_id ?? null, body.sort_order ?? maxSort + 1);
+ respondJson(res, 200, { ok: true, id: info.lastInsertRowid });
+}
+
+async function handleMenuUpdate(req, res, id) {
+ const row = db.prepare("SELECT * FROM template_menu_items WHERE id=?").get(id);
+ if (!row) return respondError(res, 404, "菜单项不存在");
+ const body = await parseBody(req);
+ const label = body.label != null ? body.label.toString().trim() : row.label;
+ const parent_id = body.parent_id !== undefined ? (body.parent_id ?? null) : row.parent_id;
+ const page_type = body.page_type !== undefined ? body.page_type : row.page_type;
+ const ref_id = body.ref_id !== undefined ? body.ref_id : row.ref_id;
+ const sort_order = body.sort_order !== undefined ? body.sort_order : row.sort_order;
+ if (!label) return respondError(res, 400, "菜单项名称不能为空");
+ db.prepare("UPDATE template_menu_items SET label=?,parent_id=?,page_type=?,ref_id=?,sort_order=? WHERE id=?").run(label, parent_id, page_type, ref_id, sort_order, id);
+ respondJson(res, 200, { ok: true, id });
+}
+
+async function handleMenuDelete(res, id) {
+ const row = db.prepare("SELECT * FROM template_menu_items WHERE id=?").get(id);
+ if (!row) return respondError(res, 404, "菜单项不存在");
+ db.prepare("UPDATE template_menu_items SET parent_id=? WHERE parent_id=?").run(row.parent_id, id);
+ db.prepare("DELETE FROM template_menu_items WHERE id=?").run(id);
+ respondJson(res, 200, { ok: true });
+}
+
+async function handleKnowledgeDocCreate(req, res, tmplId) {
+ const body = await parseBody(req);
+ const name = (body.name || "").toString().trim();
+ if (!name) return respondError(res, 400, "文档名称不能为空");
+ const doc_type = body.doc_type === "link" ? "link" : "file";
+ let url = null, file_path = null, mime = null, size = null;
+ if (doc_type === "link") {
+ url = (body.url || "").toString().trim();
+ if (!url) return respondError(res, 400, "请填写文档超链");
+ } else {
+ if (!body.file || !body.file.data) return respondError(res, 400, "请上传文档文件");
+ const f = await saveBase64File("docs", body.file.filename, body.file.mime, body.file.data);
+ file_path = f.path; mime = f.mime; size = f.size;
+ }
+ const info = db.prepare("INSERT INTO knowledge_docs (domain_template_id,name,doc_type,url,file_path,mime,size) VALUES (?,?,?,?,?,?,?)").run(tmplId, name, doc_type, url, file_path, mime, size);
+ respondJson(res, 200, { ok: true, id: info.lastInsertRowid });
+}
+
+async function handleKnowledgeDocUpdate(req, res, id) {
+ const row = db.prepare("SELECT * FROM knowledge_docs WHERE id=?").get(id);
+ if (!row) return respondError(res, 404, "文档不存在");
+ const body = await parseBody(req);
+ const name = body.name != null ? body.name.toString().trim() : row.name;
+ const doc_type = body.doc_type != null ? (body.doc_type === "link" ? "link" : "file") : row.doc_type;
+ let url = row.url, file_path = row.file_path, mime = row.mime, size = row.size;
+ if (doc_type === "link") { if (body.url !== undefined) url = (body.url || "").toString().trim(); }
+ else if (body.file && body.file.data) {
+ const f = await saveBase64File("docs", body.file.filename, body.file.mime, body.file.data);
+ if (row.file_path && existsSync(row.file_path)) { try { await rm(row.file_path); } catch {} }
+ file_path = f.path; mime = f.mime; size = f.size;
+ }
+ if (!name) return respondError(res, 400, "文档名称不能为空");
+ db.prepare("UPDATE knowledge_docs SET name=?,doc_type=?,url=?,file_path=?,mime=?,size=? WHERE id=?").run(name, doc_type, url, file_path, mime, size, id);
+ respondJson(res, 200, { ok: true, id });
+}
+
+async function handleKnowledgeDocDelete(res, id) {
+ const row = db.prepare("SELECT * FROM knowledge_docs WHERE id=?").get(id);
+ if (!row) return respondError(res, 404, "文档不存在");
+ if (row.file_path && existsSync(row.file_path)) { try { await rm(row.file_path); } catch {} }
+ db.prepare("DELETE FROM knowledge_docs WHERE id=?").run(id);
+ respondJson(res, 200, { ok: true });
+}
+
+async function handleKnowledgeDocFile(res, id) {
+ const row = db.prepare("SELECT * FROM knowledge_docs WHERE id=?").get(id);
+ if (!row || !row.file_path || !existsSync(row.file_path)) return respondError(res, 404, "文件不存在");
+ const buf = await readFile(row.file_path);
+ res.writeHead(200, { "Content-Type": row.mime || "application/octet-stream", "Content-Disposition": `attachment; filename="${encodeURIComponent(row.name)}"`, "Content-Length": buf.length });
+ res.end(buf);
+}
+
+async function handleOpsFlowsList(res) {
+ respondJson(res, 200, db.prepare("SELECT * FROM ops_flows ORDER BY id").all());
+}
+
+async function handleOpsFlowCreate(req, res) {
+ const body = await parseBody(req);
+ const name = (body.name || "").toString().trim();
+ if (!name) return respondError(res, 400, "流程名称不能为空");
+ const info = db.prepare("INSERT INTO ops_flows (domain_template_id,name,content) VALUES (?,?,?)").run(body.domain_template_id != null ? Number(body.domain_template_id) : null, name, body.content || "{}");
+ respondJson(res, 200, { ok: true, id: info.lastInsertRowid });
+}
+
+async function handleOpsFlowUpdate(req, res, id) {
+ const row = db.prepare("SELECT * FROM ops_flows WHERE id=?").get(id);
+ if (!row) return respondError(res, 404, "运维流程不存在");
+ const body = await parseBody(req);
+ const name = body.name != null ? body.name.toString().trim() : row.name;
+ const content = body.content != null ? body.content : row.content;
+ const domain_template_id = body.domain_template_id !== undefined ? (body.domain_template_id != null ? Number(body.domain_template_id) : null) : row.domain_template_id;
+ if (!name) return respondError(res, 400, "流程名称不能为空");
+ db.prepare("UPDATE ops_flows SET domain_template_id=?,name=?,content=? WHERE id=?").run(domain_template_id, name, content, id);
+ respondJson(res, 200, { ok: true, id });
+}
+
+async function handleOpsFlowDelete(res, id) {
+ db.prepare("DELETE FROM ops_flows WHERE id=?").run(id);
+ respondJson(res, 200, { ok: true });
+}
+
async function route(req, res) {
const parsed = new URL(req.url, `http://${req.headers.host}`);
const path = parsed.pathname;
const mPageStyles = path.match(/^\/api\/page-styles$/);
const mPageStyle = path.match(/^\/api\/page-styles\/(\d+)$/);
const mPageStylesImage = path.match(/^\/api\/page-styles\/(\d+)\/image$/);
+ const mDtList = path === "/api/domain-templates";
+ const mDtPages = path.match(/^\/api\/domain-templates\/(\d+)\/pages$/);
+ 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 mDevList = path === "/api/device-templates";
+ const mDevCp = path.match(/^\/api\/device-templates\/(\d+)\/collection-points$/);
+ const mDev = path.match(/^\/api\/device-templates\/(\d+)$/);
+ const mCp = path.match(/^\/api\/collection-points\/(\d+)$/);
+ const mDp = path.match(/^\/api\/domain-pages\/(\d+)$/);
+ const mMenu = path.match(/^\/api\/template-menu-items\/(\d+)$/);
+ const mDoc = path.match(/^\/api\/knowledge-docs\/(\d+)$/);
+ const mDocFile = path.match(/^\/api\/knowledge-docs\/(\d+)\/file$/);
+ const mOpsList = path === "/api/ops-flows";
+ const mOps = path.match(/^\/api\/ops-flows\/(\d+)$/);
const mSiteGenerate = path.match(/^\/api\/sites\/(\d+)\/generate-all$/);
const mSitePackage = path.match(/^\/api\/sites\/(\d+)\/package$/);
if (mCubeFile) return handleCubeFileDownload(res, Number(mCubeFile[2]));
if (mCubeRefine) return handleRefineCube(req, res, Number(mCubeRefine[1]), Number(mCubeRefine[2]));
if (mSiteGenerate) return handleGenerateAll(req, res, Number(mSiteGenerate[1]));
+ // 领域方案库
+ if (mDtList) {
+ if (req.method === "GET") return handleDomainTemplatesList(res);
+ if (req.method === "POST") return handleDomainTemplateCreate(req, res);
+ }
+ if (mDtPages) { if (req.method === "POST") return handleDomainPageCreate(req, res, Number(mDtPages[1])); }
+ if (mDtMenu) { if (req.method === "POST") return handleMenuCreate(req, res, Number(mDtMenu[1])); }
+ if (mDtDocs) { if (req.method === "POST") return handleKnowledgeDocCreate(req, res, Number(mDtDocs[1])); }
+ if (mDt) {
+ if (req.method === "GET") return handleDomainTemplateDetail(res, Number(mDt[1]));
+ if (req.method === "PUT") return handleDomainTemplateUpdate(req, res, Number(mDt[1]));
+ if (req.method === "DELETE") return handleDomainTemplateDelete(res, Number(mDt[1]));
+ }
+ if (mDevList) {
+ if (req.method === "GET") return handleDeviceTemplatesList(res);
+ if (req.method === "POST") return handleDeviceTemplateCreate(req, res);
+ }
+ if (mDevCp) { if (req.method === "POST") return handleCollectionPointCreate(req, res, Number(mDevCp[1])); }
+ if (mDev) {
+ if (req.method === "GET") return handleDeviceTemplateDetail(res, Number(mDev[1]));
+ if (req.method === "PUT") return handleDeviceTemplateUpdate(req, res, Number(mDev[1]));
+ if (req.method === "DELETE") return handleDeviceTemplateDelete(res, Number(mDev[1]));
+ }
+ if (mCp) {
+ if (req.method === "PUT") return handleCollectionPointUpdate(req, res, Number(mCp[1]));
+ if (req.method === "DELETE") return handleCollectionPointDelete(res, Number(mCp[1]));
+ }
+ if (mDp) {
+ if (req.method === "PUT") return handleDomainPageUpdate(req, res, Number(mDp[1]));
+ if (req.method === "DELETE") return handleDomainPageDelete(res, Number(mDp[1]));
+ }
+ if (mMenu) {
+ if (req.method === "PUT") return handleMenuUpdate(req, res, Number(mMenu[1]));
+ if (req.method === "DELETE") return handleMenuDelete(res, Number(mMenu[1]));
+ }
+ if (mDoc) {
+ if (req.method === "PUT") return handleKnowledgeDocUpdate(req, res, Number(mDoc[1]));
+ if (req.method === "DELETE") return handleKnowledgeDocDelete(res, Number(mDoc[1]));
+ }
+ if (mDocFile) { if (req.method === "GET") return handleKnowledgeDocFile(res, Number(mDocFile[1])); }
+ if (mOpsList) {
+ if (req.method === "GET") return handleOpsFlowsList(res);
+ if (req.method === "POST") return handleOpsFlowCreate(req, res);
+ }
+ if (mOps) {
+ if (req.method === "PUT") return handleOpsFlowUpdate(req, res, Number(mOps[1]));
+ if (req.method === "DELETE") return handleOpsFlowDelete(res, Number(mOps[1]));
+ }
if (mSitePackage) return handlePackageDownload(res, Number(mSitePackage[1]));
respondError(res, 404, "not found");