]> acesimba.cloud Git - StationBuilder.git/commitdiff
feat: 领域方案库升级为真实数据库驱动的 CRUD
authorwang.yu <wang.yu@advantech.com>
Tue, 18 Aug 2026 09:52:08 +0000 (17:52 +0800)
committerwang.yu <wang.yu@advantech.com>
Tue, 18 Aug 2026 09:52:08 +0000 (17:52 +0800)
- Phase 0:移除领域方案库页 Search 行(含「同步方案库」占位按钮)
- db-sidecar 新增 schema:domain_templates / device_templates + device_collection_points /
  device_template_domains / device_template_domain_templates(两处多对多)/ domain_pages /
  template_menu_items / knowledge_docs(doc_type 区分超链与文件,文件落盘 data/db/uploads/)/
  ops_flows(仅占位)
- 全套 CRUD 接口 /api/domain-templates(详情聚合设备·采集点·页面·菜单·文档·流程)、
  /api/device-templates(图片 base64 上传)、/api/collection-points、/api/domain-pages、
  /api/template-menu-items、/api/knowledge-docs(含 /file 下载)、/api/ops-flows
- 前端 DomainLibrary + DomainDetail 内嵌编辑器(模板信息 / 设备与采集点 / 领域页面 Cube JSON /
  模板菜单 / 知识文档 / 运维流程占位),openDomainTemplateId 控制 列表·新增·详情 切换
- 修复:sidecar 路由前缀由 /api/db 改为 /api(匹配代理层改写);设备 domain_ids 由 JSON 字符串
  解析为数组;DomainDetail 修正 hooks 顺序
- 更新设计文档 5.9/5.10、尚未实现汇总、更新记录
- 提交变更 schema 的 DB 文件

app/page.tsx
data/db/StationBuilder.sqlite
db-sidecar.mjs
public/design-doc.html

index 1ac549775ac5abdc1e0549afbbc8ecbae9f49c3c..c8479390d7c6bacccd540ac6cf9a1668d2612fc9 100644 (file)
@@ -170,6 +170,7 @@ export default function Home(){
   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");
@@ -452,10 +453,11 @@ export default function Home(){
       </>
     )}
   </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}/>}
@@ -976,28 +978,144 @@ function Preview({site,tab,setTab,setModal,notify}:{site:SiteDetail|null;tab:"in
   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}){
index 29fc0321f3897324821820b098c70c2c3462e04d..22631c06cf47f6e5819e37119297256f4ce8ae9a 100644 (file)
Binary files a/data/db/StationBuilder.sqlite and b/data/db/StationBuilder.sqlite differ
index c32b60cd58642a2147df105c6559fdc26a724127..feeb85a972420f3d9b26fb24e7de24917d4836fa 100644 (file)
@@ -225,12 +225,93 @@ function initSchema() {
       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();
 
@@ -1491,6 +1572,313 @@ async function handlePackageDownload(res, siteId) {
   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;
@@ -1520,6 +1908,21 @@ async function route(req, res) {
   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$/);
 
@@ -1706,6 +2109,54 @@ async function route(req, res) {
     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");
index a7ee37deeb8d6a32661e7f7ada18ceba13fd3231..8a2e6ab4986e8e007a949d524c99f964f35bacc5 100644 (file)
 │
 ├─ 【领域方案库模块时】(domains / domainDetail)
 │   ├─ 方案库导航
-│   │   ├─ 全部领域方案        → domains
-│   │   └─ 方案详情            → domainDetail
-│   └─ 领域分类(当前以「水务 · 自来水厂」为示例激活项)
+│   │   ├─ 全部领域方案        → domains(方案列表)
+│   │   └─ 方案详情            → domainDetail(同列表;在列表中点击某方案「编辑」打开详情编辑器)
+â\94\82   â\94\94â\94\80 é¢\86å\9f\9få\88\86ç±»ï¼\88å½\93å\89\8d以ã\80\8cæ°´å\8a¡ Â· è\87ªæ\9d¥æ°´å\8e\82ã\80\8d为示ä¾\8bæ¿\80活项ï¼\8cä»\85ä½\9cå\88\86ç»\84å±\95示ï¼\89
 │       ├─ 燃气                → 提示(占位)
 │       ├─ 水务 · 自来水厂     → domainDetail
 │       ├─ 新能源              → 提示(占位)
     <tr><td>站点切换器 &lt;select&gt;</td><td>StationBuilder / 站点详情</td><td>在已建站点间快速切换</td><td>GET /api/db/sites</td><td>选择后加载该站点并跳转(在列表页则进基本属性,在详情页则停留在当前子页)</td></tr>
     <tr><td>1 基本属性 / 2 资产 / 3 功能与菜单 / 4 生成确认</td><td>StationBuilder / 生成向导</td><td>生成向导分步导航</td><td>前端 state</td><td>点击 go 对应页;当前步骤高亮;已完成步骤可回跳</td></tr>
     <tr><td>站点结果 / 页面预览</td><td>StationBuilder / 方案交付</td><td>查看生成结果与预览</td><td>—</td><td>点击 go("result" / "preview");当前页高亮</td></tr>
-    <tr><td>全部领域方案 / 方案详情</td><td>领域方案库 / 方案库导航</td><td>方案库列表与详情</td><td>前端硬编码演示</td><td>点击 go("domains" / "domainDetail");当前页高亮 <span class="todo">真实方案库尚未接入</span></td></tr>
-    <tr><td>领域分类四类</td><td>领域方案库 / 领域分类</td><td>按领域筛选方案</td><td>前端硬编码(燃气/水务/新能源/数据中心)</td><td>水务进详情、其余提示占位;当前示例高亮「水务 · 自来水厂」</td></tr>
+    <tr><td>全部领域方案 / 方案详情</td><td>领域方案库 / 方案库导航</td><td>方案库列表与详情</td><td>GET /api/db/domain-templates、GET /api/domains</td><td>点击 go("domains" / "domainDetail");当前页高亮。列表页点击某方案「编辑」以同页内嵌编辑器打开详情(<code>openDomainTemplateId</code> state 控制),「+ 新增领域方案」切到新增表单</td></tr>
+    <tr><td>领域分类四类</td><td>领域方案库 / 领域分类</td><td>按领域分组展示(非筛选)</td><td>前端硬编码(燃气/水务/新能源/数据中心)</td><td>水务进详情、其余提示占位;当前示例高亮「水务 · 自来水厂」;按领域筛选 <span class="todo">尚未实现</span></td></tr>
     <tr><td>全部页面样式</td><td>页面样式库 / 样式库导航</td><td>样式库列表</td><td>GET /api/db/page-styles</td><td>点击 go("styleLibrary");当前页高亮</td></tr>
   </table>
 
   </table>
 
   <h3>5.9 领域方案库(domains)</h3>
+  <p>领域方案库已从硬编码演示改为<strong>真实数据库驱动</strong>。列表与详情编辑器同一页面内通过 <code>openDomainTemplateId</code> state 切换:<code>null</code>=列表,<code>"create"</code>=新增表单,<code>number</code>=该方案详情编辑器。</p>
   <table>
     <tr><th>元素</th><th>功能</th><th>数据来源</th><th>交互效果</th></tr>
-    <tr><td>领域卡片(燃气/水务/新能源/数据中心)</td><td>领域方案入口</td><td><b>前端硬编码演示数据</b></td><td>「查看方案」仅水务进详情,其余提示占位 <span class="todo">真实方案库尚未接入</span></td></tr>
-    <tr><td>搜索 / 筛选 / 同步方案库</td><td>方案检索</td><td>—</td><td>提示占位 <span class="todo">尚未实现</span></td></tr>
-    <tr><td>「直接使用」</td><td>基于方案建站</td><td>—</td><td>提示并进入基本属性(不真正套用方案模板) <span class="todo">尚未实现</span></td></tr>
+    <tr><td>领域方案列表表格</td><td>展示全部领域方案(模板名称 / 所属领域 / 版本 / 状态 / 创建人 / 设备·页面·文档数 / 操作)</td><td>GET /api/db/domain-templates(含 domain_name、device_count、page_count、doc_count)</td><td>每行「编辑」→ 打开该方案详情编辑器;「删除」→ 确认后 DELETE /api/db/domain-templates/:id(级联删除 device_template_domain_templates)并刷新;空列表提示「点击右上角新增」</td></tr>
+    <tr><td>「+ 新增领域方案」</td><td>新建领域方案,进入新增表单</td><td>—</td><td>同页切到 CreateTemplate 表单:所属领域(select 来自 GET /api/domains)、模板名称、版本、状态(可用/不可用)、创建人、领域描述;POST /api/db/domain-templates 成功后自动打开该方案详情编辑器</td></tr>
+    <tr><td>搜索 / 筛选 / 同步方案库</td><td>(历史功能)</td><td>—</td><td>已按计划 Phase 0 移除整行(含「同步方案库」占位按钮) <span class="todo">已移除</span></td></tr>
   </table>
 
-  <h3>5.10 方案详情(domainDetail)</h3>
-  <p>以「水务 · 自来水厂」为例的详情展示:资产模板、领域页面、运维流程、菜单、知识等,均为<strong>演示内容</strong>。</p>
+  <h3>5.10 方案详情(domainDetail / 内嵌编辑器)</h3>
+  <p>详情为同一页面内嵌编辑器,所有内容均可编辑,数据持久化到领域方案库相关表。各面板如下:</p>
   <table>
-    <tr><th>元素</th><th>功能</th><th>数据来源</th><th>交互效果</th></tr>
-    <tr><td>领域页面列表 / 加入站点</td><td>查看并选用领域页面</td><td>硬编码</td><td>「加入站点」仅提示 <span class="todo">尚未实现</span>;「预览」进 preview 占位</td></tr>
-    <tr><td>「使用此方案创建站点」</td><td>从方案建站</td><td>—</td><td>创建空白站点并进入基本属性</td></tr>
-    <tr><td>巡检模板(规划中)</td><td>路线图占位</td><td>硬编码</td><td>标注「规划中」</td></tr>
+    <tr><th>面板 / 元素</th><th>功能</th><th>数据来源 / 接口</th><th>交互效果</th></tr>
+    <tr><td>模板信息</td><td>编辑所属领域 / 名称 / 版本 / 状态 / 创建人 / 描述</td><td>GET /api/db/domain-templates/:id 详情;PUT /api/db/domain-templates/:id 保存</td><td>「保存模板信息」写回并重新加载</td></tr>
+    <tr><td>设备模板(DeviceCard + AddDevice)</td><td>管理设备模板:名称 / 说明 / 图片 / 所属领域(多选) / 采集点</td><td>详情 devices[](含 collection_points、domain_ids、domain_names);PUT /api/db/device-templates/:id(可带 image base64 或 domain_ids+domain_template_ids);DELETE /api/db/device-templates/:id;新增 POST /api/db/device-templates</td><td>图片以 base64 上传,由服务端落盘 <code>data/db/uploads/devices/</code>;所属领域为复选(与 domains、与 domain_templates 均为多对多);采集点增删见下</td></tr>
+    <tr><td>采集点</td><td>设备下的采集点(名称 / 说明 / 单位 / 数值范围 / 描述)</td><td>POST /api/db/device-templates/:id/collection-points(名称);DELETE /api/db/collection-points/:id</td><td>「+ 采集点」填名称新增;行内「删」移除</td></tr>
+    <tr><td>领域页面(PageRow)</td><td>编辑领域页面(=StationBuilder 的 Cube 扩展页面)</td><td>详情 pages[];POST /api/db/domain-templates/:id/pages;PUT / DELETE /api/db/domain-pages/:id</td><td>编辑态展示名称 / 说明 / Cube JSON(textarea);保存前校验 JSON 合法性;「+ 新增页面」建默认空页</td></tr>
+    <tr><td>模板菜单</td><td>编辑模板菜单(=StationBuilder 的 MU + Cube 菜单保存结构)</td><td>详情 menu[];POST /api/db/domain-templates/:id/menu;DELETE /api/db/template-menu-items/:id</td><td>表格展示菜单项 / 类型 / 父级 / 关联;「+ 新增菜单项」建占位项</td></tr>
+    <tr><td>知识文档</td><td>管理知识文档(名称 + 本体)</td><td>详情 docs[];POST /api/db/domain-templates/:id/docs(doc_type=link 传 url,或 doc_type=file 传 base64 文件);GET /api/db/knowledge-docs/:id/file 下载;DELETE /api/db/knowledge-docs/:id</td><td>新增时选择「超链」(填 URL) 或「文件」(上传,落盘 <code>data/db/uploads/docs/</code>);列表可下载 / 删除</td></tr>
+    <tr><td>运维流程(OpsFlowsViewer)</td><td>查看运维流程(流程名称 + 内容 JSON 串)</td><td>GET /api/ops-flows</td><td>仅展示占位记录,本功能 <span class="todo">尚未实现</span></td></tr>
   </table>
 
   <h3>5.11 页面样式库(styleLibrary)</h3>
   <h2>10. 尚未实现功能汇总</h2>
   <ul>
     <li>真实登录 / 账号体系(当前为演示登录) <span class="todo">尚未实现</span></li>
-    <li>领域方案库真实数据与「直接使用 / 加入站点」落地(当前为硬编码演示) <span class="todo">尚未实现</span></li>
+    <li>领域方案库真实数据 CRUD(领域模板 / 设备模板 / 采集点 / 领域页面 / 模板菜单 / 知识文档)已落地;「直接使用 / 加入站点」将从方案一键建站 <span class="todo">尚未实现</span></li>
+    <li>运维流程内容编辑(当前仅占位记录) <span class="todo">尚未实现</span></li>
+    <li>领域方案按领域分类筛选(当前分类仅作分组展示) <span class="todo">尚未实现</span></li>
     <li>Asset Excel 生成与下载 <span class="todo">尚未实现</span></li>
     <li>资产三级层级建模(当前为展示占位) <span class="todo">尚未实现</span></li>
     <li>页面预览中「站点监控」真实数据接入(当前为静态演示) <span class="todo">尚未实现</span></li>
 
   <h3>11.1 更新记录</h3>
   <ul>
+    <li>2026-08-18 领域方案库(Domain Solution Library)从硬编码演示升级为真实数据库驱动的 CRUD:(1) Phase 0 移除领域方案库页的 Search 行(含「同步方案库」占位按钮);(2) db-sidecar 新增 schema——domain_templates(所属领域 FK、描述、状态、版本、创建人、创建时间)、device_templates + device_collection_points(采集点)、device_template_domains / device_template_domain_templates(设备↔领域、设备↔领域模板 两个多对多)、domain_pages(领域页面=Cube 扩展页 JSON)、template_menu_items(模板菜单=MU+Cube 菜单结构)、knowledge_docs(doc_type 区分超链/文件,文件落盘 data/db/uploads/docs/)、ops_flows(仅占位);(3) 全套 CRUD 接口:/api/domain-templates(列表/详情/增删改,详情聚合设备·采集点·页面·菜单·文档·流程)、/api/device-templates(含图片 base64 上传)、/api/collection-points、/api/domain-pages、/api/template-menu-items、/api/knowledge-docs(含 /file 下载)、/api/ops-flows;(4) 前端 DomainLibrary + DomainDetail 内嵌编辑器(模板信息 / 设备模板与采集点 / 领域页面 Cube JSON / 模板菜单 / 知识文档文件·超链 / 运维流程占位),通过 openDomainTemplateId state 在列表·新增·详情间切换。验证:端到端 CRUD 全绿(含设备 domain_ids 数组解析、级联删除、文档下载)。注意:代理层 app/api/db/[[...slug]] 会把浏览器 /api/db/x 改写为 /api/x 再转发 sidecar,故 sidecar 路由以 /api/ 为前缀(不带 /db)。</li>
     <li>2026-08-18 修复「北方污水厂」等项目整站生成大量失败(多页 300s 超时判失败)。根因:上一轮为修「导入空白」而加入的「逐项读取模板文件」提示词指令使单页提示词过胖并引入文件工具调用,复杂页面(综合总览/工艺监控等)生成耗时偶发超过 300s 硬上限被 SIGKILL。修复:(1) 撤销文件读取指令,改为在 DASHBOARD_SPEC 内联各面板类型的紧凑 custom 骨架(占位 COLOR_PALETTE),消除文件工具开销、把单页耗时拉回 300s 内;(2) extractJson 增强容错——先原生解析,失败再去除注释/尾逗号后重试;(3) runCodebuddy 超时阈值由 300s 提高到 480s,且 generateCubeJson 将「调用超时 / JSON 解析失败 / 结构校验失败」统一纳入最多 2 次重试,把确切错误(含问题位置附近原文)回传模型自我修正,避免一次瑕疵即整页失败;(4) 提示词加约束:不得添加 query/linkage/triggerByTimer/minW 等规范外字段;(5) fixDashboard/normalizePanelCustom 兜底保持不变。验证:北方污水厂 7 个 Cube 全部重新生成成功,面板 custom 结构正确(LineChart/Bar 扁平、PieChart 等 dataConfig+chartConfig),导入无空白。</li>
     <li>2026-08-18 修复右下角 AI 悬浮按钮拖动时常失效的问题:图标改为不可原生拖拽、事件直接落在按钮上,避免浏览器图片拖拽劫持 Pointer 拖拽手势;按下拖动时不再有 1px 移位。</li>
     <li>2026-08-18 修复「下载 Markdown」导入 Azure DevOps Wiki 后仅显示到 §4.2 的问题:代码块改以 4 空格缩进式代码块导出(不再用 ``` 围栏),避免围栏闭合异常吞掉后续章节;表格单元格内将 <code>&lt;</code> 与 <code>&gt;</code> 转义为 HTML 实体,避免 Azure 把 <code>&lt;select&gt;</code> 当成未闭合标签吞掉后续内容。</li>