]> acesimba.cloud Git - StationBuilder.git/commitdiff
领域方案库重构为多菜单:列表/卡片切换、设备模版独立页、选用设备模版关联
authorwang.yu <wang.yu@advantech.com>
Tue, 18 Aug 2026 13:05:57 +0000 (21:05 +0800)
committerwang.yu <wang.yu@advantech.com>
Tue, 18 Aug 2026 13:05:57 +0000 (21:05 +0800)
- 导航拆分:领域方案库模块下新增「领域方案」「设备模版」;领域分类改为 GET /api/domains 动态二级菜单,点击按 domainFilter 筛选方案
- 领域方案列表支持列表/卡片视图切换
- 新增独立「设备模版」页(DeviceTemplates),独立维护设备模版与采集点、所属领域多选
- 方案详情设备面板改为「选用设备模版」勾选器,保存即重写 device_template_domain_templates 关联
- 后端 domain-templates 更新支持 device_template_ids 重关联
- 设计文档升级至 v0.6,更新 4.2/5.9/5.10 与 changelog

app/globals.css
app/page.tsx
db-sidecar.mjs
public/design-doc-assets/placeholder.svg [new file with mode: 0644]
public/design-doc.html

index 4094a87b96d7cdd4411a7336d022bae0c7bbe959..e81791d577c2877124de2e74d64782e5b3412d9e 100644 (file)
 .modal-console-head{background:#0f1722;color:#9fd0e6;font-size:12px;padding:6px 10px;border-bottom:1px solid #1c2a38}
 .modal-console-body{background:#0f1722;color:#cfe3f0;font:12px/1.5 "SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace;margin:0;padding:10px 12px;max-height:240px;overflow:auto;white-space:pre-wrap;word-break:break-word}
 
+/* ===== 视图切换(列表/卡片) ===== */
+.view-toggle{display:inline-flex;border:1px solid var(--line);border-radius:3px;overflow:hidden}
+.view-toggle button{border:0;background:#fff;color:#666;height:28px;padding:0 12px;font-size:11px}
+.view-toggle button+button{border-left:1px solid var(--line)}
+.view-toggle button.active{background:var(--blue);color:#fff}
+/* ===== 领域方案卡片视图 ===== */
+.domain-card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(245px,1fr));gap:12px;padding:6px 0}
+.domain-card{border:1px solid var(--line);border-top:3px solid var(--blue);background:#fff;padding:14px;display:flex;flex-direction:column;gap:9px;cursor:pointer;transition:.15s}
+.domain-card:hover{box-shadow:0 6px 18px #0001;border-top-color:var(--blue2)}
+.domain-card>header{display:flex;align-items:center;justify-content:space-between;gap:8px}
+.domain-card h3{font-size:15px;margin:0}
+.domain-card .badge{display:inline-block;align-self:flex-start;background:var(--pale);color:var(--blue2);border:1px solid #cfeaf6;border-radius:10px;padding:1px 10px;font-size:10px}
+.domain-card .meta{display:flex;gap:14px;color:#888;font-size:11px}
+.domain-card .meta b{color:#333;font-size:13px;margin-right:3px}
+.domain-card .card-actions{display:flex;gap:14px;margin-top:2px;padding-top:8px;border-top:1px solid #f0f2f3}
+/* ===== 设备模版 ===== */
+.device-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:12px;padding:4px 0}
+.device-card{border:1px solid var(--line);background:#fff;padding:14px}
+.device-card-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}
+.device-card-head b{font-size:14px}
+.cp-list{margin-top:10px}
+.cp-list>b{display:flex;align-items:center;gap:8px;font-size:12px}
+.cp-list .data-table{margin-top:6px}
+.chk{display:flex;flex-wrap:wrap;gap:8px}
+.chk-item{display:inline-flex;align-items:center;gap:5px;font-size:12px;background:#f6f8f9;border:1px solid #e6e9eb;border-radius:3px;padding:4px 9px;cursor:pointer}
+.chk-item input{margin:0;accent-color:var(--blue)}
+/* ===== 方案详情:选用设备模版 ===== */
+.device-picker{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:8px}
+.picker-item{display:flex;align-items:center;gap:9px;border:1px solid var(--line);background:#fff;border-radius:4px;padding:9px 11px;cursor:pointer}
+.picker-item.on{border-color:var(--blue);background:#f4fbff}
+.picker-item input{margin:0;accent-color:var(--blue)}
+.picker-name{font-weight:bold;font-size:12px}
+.picker-doms{margin-left:auto;color:#888;font-size:10px;max-width:42%;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+
index c5a52e615053e443c5202337ba0c04e27a08d3b3..4397c888fbf42e99cbcd9992537b97ef79b47523 100644 (file)
@@ -2,7 +2,7 @@
 
 import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
 
-type PageKey = "login" | "projects" | "basic" | "assets" | "functions" | "generate" | "result" | "preview" | "domains" | "domainDetail" | "styleLibrary";
+type PageKey = "login" | "projects" | "basic" | "assets" | "functions" | "generate" | "result" | "preview" | "domains" | "domainDetail" | "styleLibrary" | "deviceTemplates";
 type ModalKey = "" | "feedback" | "download" | "history" | "prompt" | "completePrompt";
 
 const meta: Record<PageKey, { label: string; icon: string; subtitle: string }> = {
@@ -15,7 +15,8 @@ const meta: Record<PageKey, { label: string; icon: string; subtitle: string }> =
   result:{label:"站点结果",icon:"◫",subtitle:"集中查看、预览、校验并下载站点交付物"},
   preview:{label:"Cube 页面预览",icon:"▣",subtitle:"检查单个 Cube 页面效果,并通过反馈快速重新生成"},
   domains:{label:"领域方案库",icon:"◆",subtitle:"沉淀各领域标准方案,供客户站点直接使用或快速微调"},
-  domainDetail:{label:"水务 · 自来水厂方案",icon:"◇",subtitle:"查看领域资产、运维流程、Cube 页面、菜单及知识内容"},
+  domainDetail:{label:"方案详情",icon:"◇",subtitle:"查看领域资产、运维流程、Cube 页面、菜单及知识内容"},
+  deviceTemplates:{label:"设备模版",icon:"⬡",subtitle:"独立管理设备模版与采集点,供领域方案选用"},
   styleLibrary:{label:"页面样式库",icon:"❖",subtitle:"沉淀可复用的 Cube 仪表盘页面样式(配色与视觉主题),供站点页面快速套用"},
 };
 
@@ -171,6 +172,8 @@ export default function Home(){
   const [muTree,setMuTree]=useState<MuNode[]>([]);
   const [pageStyles,setPageStyles]=useState<PageStyle[]>([]);
   const [openDomainTemplateId,setOpenDomainTemplateId]=useState<number|"create"|null>(null);
+  const [domainFilter,setDomainFilter]=useState<number|null>(null);
+  const [domainOptions,setDomainOptions]=useState<{id:number;name:string}[]>([]);
 
   const [view,setView]=useState<"table"|"card">("table");
   const [functionTab,setFunctionTab]=useState<"mu"|"cube"|"menu">("mu");
@@ -292,12 +295,13 @@ export default function Home(){
     setLoading(true);
     Promise.all([
       fetchDb("/sites") as Promise<SiteSummary[]>,
-      fetchDb("/domains") as Promise<{name:string}[]>,
+      fetchDb("/domains") as Promise<{id:number;name:string}[]>,
       fetchDb("/mu-tree") as Promise<MuNode[]>,
       fetchDb("/page-styles") as Promise<PageStyle[]>,
     ]).then(([s,d,m,ps])=>{
       setSites(s||[]);
       setDomains((d||[]).map(x=>x.name));
+      setDomainOptions((d||[]).map(x=>({id:x.id,name:x.name})));
       setMuTree(m||[]);
       setPageStyles(ps||[]);
     }).catch(e=>notify("加载失败: "+e.message)).finally(()=>setLoading(false));
@@ -431,7 +435,7 @@ export default function Home(){
 
   return <div className="app-shell">
     <header className="topbar"><button className="hamburger">☰</button><div className="brand-mark">⬡</div><strong>iStation</strong><span className="divider"/><span>iStation Team</span><span className="divider"/><span>StationBuilder⌄</span><div className="top-actions"><a className="design-link" href="/design-doc.html" target="_blank" rel="noopener">设计方案</a><button title="查看内部登录页" className="avatar avatar-button" onClick={()=>go("login")}>W</button><small>wang.yu@advantech.com<br/>iStation Team</small></div></header>
-    <aside className="side-nav"><div className="module-switch"><button className={(["projects","basic","assets","functions","generate","result","preview"] as PageKey[]).includes(page)?"active":""} onClick={()=>go("projects")}><b>▦</b><span>StationBuilder<small>站点生成工具</small></span></button><button className={(["domains","domainDetail"] as PageKey[]).includes(page)?"active":""} onClick={()=>go("domains")}><b>◆</b><span>领域方案库<small>行业标准方案</small></span></button><button className={page==="styleLibrary"?"active":""} onClick={()=>go("styleLibrary")}><b>❖</b><span>页面样式库<small>页面视觉样式</small></span></button></div>{page==="styleLibrary"?<><small>样式库导航</small><button className={page==="styleLibrary"?"active":""} onClick={()=>go("styleLibrary")}><b>❖</b>全部页面样式</button></>:(["domains","domainDetail"] as PageKey[]).includes(page)?<><small>方案库导航</small><button className={page==="domains"?"active":""} onClick={()=>go("domains")}><b>▦</b>全部领域方案</button><button className={page==="domainDetail"?"active":""} onClick={()=>go("domainDetail")}><b>◇</b>方案详情</button><small>领域分类</small>{["燃气","水务 · 自来水厂","新能源","数据中心"].map((x,i)=><button key={x} className={i===1&&page==="domainDetail"?"active":""} onClick={()=>i===1?go("domainDetail"):notify(x+"方案预览已切换")}><b>{["♨","≋","☀","▥"][i]}</b>{x}</button>)}</>:<><small>站点列表</small><button className={page==="projects"?"active":""} onClick={()=>go("projects")}><b>▦</b>站点项目</button><small>站点详情</small><select className="site-switcher" value={siteId ?? ""} onChange={e=>{const v=e.target.value; if(v){ const t=(page==="projects"||["domains","domainDetail","styleLibrary"].includes(page))?"basic":page; go(t, Number(v)); }}}><option value="">选择站点…</option>{sites.map(s=><option key={s.id} value={s.id}>{s.name||"未命名站点"}</option>)}</select><small>生成向导</small>{(["basic","assets","functions","generate"] as PageKey[]).map((item,index)=><button key={item} className={page===item?"active":""} onClick={()=>go(item)}><em>{index+1}</em>{meta[item].label}</button>)}<small>方案交付</small><button className={page==="result"?"active":""} onClick={()=>go("result")}><b>◫</b>站点结果</button><button className={page==="preview"?"active":""} onClick={()=>go("preview")}><b>▣</b>页面预览</button></>}<div className="side-help">
+    <aside className="side-nav"><div className="module-switch"><button className={(["projects","basic","assets","functions","generate","result","preview"] as PageKey[]).includes(page)?"active":""} onClick={()=>go("projects")}><b>▦</b><span>StationBuilder<small>站点生成工具</small></span></button><button className={(["domains","domainDetail","deviceTemplates"] as PageKey[]).includes(page)?"active":""} onClick={()=>{setDomainFilter(null); go("domains");}}><b>◆</b><span>领域方案库<small>行业标准方案</small></span></button><button className={page==="styleLibrary"?"active":""} onClick={()=>go("styleLibrary")}><b>❖</b><span>页面样式库<small>页面视觉样式</small></span></button></div>{page==="styleLibrary"?<><small>样式库导航</small><button className={page==="styleLibrary"?"active":""} onClick={()=>go("styleLibrary")}><b>❖</b>全部页面样式</button></>:(["domains","domainDetail","deviceTemplates"] as PageKey[]).includes(page)?<><small>方案库导航</small><button className={page==="domains"?"active":""} onClick={()=>{setDomainFilter(null); go("domains");}}><b>▦</b>领域方案</button><button className={page==="deviceTemplates"?"active":""} onClick={()=>go("deviceTemplates")}><b>⬡</b>设备模版</button><small>领域分类</small>{domainOptions.map((d:any)=><button key={d.id} className={(page==="domains"&&domainFilter===d.id)?"active":""} onClick={()=>{setDomainFilter(d.id); go("domains");}}><b>◆</b>{d.name}</button>)}</>:<><small>站点列表</small><button className={page==="projects"?"active":""} onClick={()=>go("projects")}><b>▦</b>站点项目</button><small>站点详情</small><select className="site-switcher" value={siteId ?? ""} onChange={e=>{const v=e.target.value; if(v){ const t=(page==="projects"||["domains","domainDetail","styleLibrary","deviceTemplates"].includes(page))?"basic":page; go(t, Number(v)); }}}><option value="">选择站点…</option>{sites.map(s=><option key={s.id} value={s.id}>{s.name||"未命名站点"}</option>)}</select><small>生成向导</small>{(["basic","assets","functions","generate"] as PageKey[]).map((item,index)=><button key={item} className={page===item?"active":""} onClick={()=>go(item)}><em>{index+1}</em>{meta[item].label}</button>)}<small>方案交付</small><button className={page==="result"?"active":""} onClick={()=>go("result")}><b>◫</b>站点结果</button><button className={page==="preview"?"active":""} onClick={()=>go("preview")}><b>▣</b>页面预览</button></>}<div className="side-help">
     <b>内部工具 · v0.3</b>
     <span className="model-trigger" onClick={() => setModelOpen(o => !o)}>
       Wordbuddy AI · {model}<span className="caret">▾</span>
@@ -453,12 +457,13 @@ 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"||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">
+    <main className="workspace"><div className="crumb">iStation StationBuilder / <b>{["domains","domainDetail","styleLibrary","deviceTemplates"].includes(page)?meta[page].label:page==="projects"?"站点列表":`站点详情 / ${meta[page].label}`}</b></div><div className="page-heading"><div><h1>{meta[page].label}</h1><p>{meta[page].subtitle}</p></div><div>{!["projects","domains","domainDetail","styleLibrary","deviceTemplates"].includes(page)&&<Button secondary onClick={()=>notify("草稿已保存")}>保存草稿</Button>}{page==="projects"&&<Button onClick={()=>go("basic","new")}>+ 新建站点</Button>}{page==="domains"&&<Button onClick={()=>setOpenDomainTemplateId("create")}>+ 新增领域方案</Button>}{page==="domains"&&openDomainTemplateId!=null&&<Button secondary onClick={()=>setOpenDomainTemplateId(null)}>← 返回方案列表</Button>}{domainFilter!=null&&page==="domains"&&<Button secondary onClick={()=>{setDomainFilter(null);}}>清除领域筛选:{domainOptions.find(d=>d.id===domainFilter)?.name||""}</Button>}</div></div>{(["basic","assets","functions","generate"] as PageKey[]).includes(page)&&<Steps page={page} go={go}/>}<div className="content">
       {page==="projects"&&<Projects view={view} setView={setView} sites={sites} loading={loading} go={go} loadSite={loadSite} notify={notify} onDelete={deleteSites}/>}
       {(page==="domains"||page==="domainDetail")&&(openDomainTemplateId!=null
         ? <DomainDetail id={openDomainTemplateId==="create"?null:openDomainTemplateId} go={go} notify={notify} onBack={()=>setOpenDomainTemplateId(null)} onCreated={setOpenDomainTemplateId}/>
-        : <DomainLibrary go={go} notify={notify} onOpen={(id:number)=>setOpenDomainTemplateId(id)}/>)}
+        : <DomainLibrary go={go} notify={notify} onOpen={(id:number)=>setOpenDomainTemplateId(id)} domainFilter={domainFilter} domainOptions={domainOptions}/>)}
       {page==="styleLibrary"&&<PageStyleLibrary pageStyles={pageStyles} loadPageStyles={loadPageStyles} notify={notify}/>}
+      {page==="deviceTemplates"&&<DeviceTemplates notify={notify} domainOptions={domainOptions}/>}
       {page==="basic"&&<Basic site={site} domains={domains} onChange={saveSiteBasic} onAddDomain={addDomain} onUpload={uploadFile} saving={saving} notify={notify} onViewCompletePrompt={viewCompletePrompt}/>}
       {page==="assets"&&<Assets site={site} onChange={saveAssets} saving={saving} notify={notify} onViewCompletePrompt={viewCompleteAssetsPrompt}/>}
       {page==="functions"&&<Functions site={site} muTree={muTree} onMuChange={saveMu} onSiteChange={setSite} pageStyles={pageStyles} notify={notify} onViewCompletePrompt={viewCompleteCubesPrompt}/>}
@@ -980,27 +985,46 @@ function Preview({site,tab,setTab,setModal,notify}:{site:SiteDetail|null;tab:"in
 
 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}){
+function DomainLibrary({go,notify,onOpen,domainFilter,domainOptions}:{go:(p:PageKey)=>void;notify:(m:string)=>void;onOpen:(id:number)=>void;domainFilter:number|null;domainOptions:{id:number;name:string}[]}){
   const [list,setList]=useState<any[]>([]);
   const [loading,setLoading]=useState(true);
+  const [view,setView]=useState<"list"|"card">("list");
   const load=async()=>{ try{ setList(await fetchDb("/domain-templates") as any); }catch(e:any){ notify("加载失败:"+e.message); } finally{ setLoading(false); } };
   useEffect(()=>{ load(); },[]);
+  const filtered=list.filter(t=>!domainFilter||t.domain_id===domainFilter);
   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="领域方案列表">
+    <div className="domain-hero"><div><span className="eyebrow">DOMAIN SOLUTION LIBRARY</span><h2>领域方案库</h2><p>沉淀各领域标准方案,供客户站点直接使用或快速微调。</p></div>
+      <div className="domain-hero-stats">{domainOptions.map(d=><div key={d.id}><b>{list.filter(t=>t.domain_id===d.id).length}</b><span>{d.name}</span></div>)}</div>
+    </div>
+    <Panel title={domainFilter!=null?`领域方案列表 · ${domainOptions.find(d=>d.id===domainFilter)?.name||""}`:"领域方案列表"} tools={<div className="view-toggle">
+        <button className={view==="list"?"active":""} onClick={()=>setView("list")}>▤ 列表</button>
+        <button className={view==="card"?"active":""} onClick={()=>setView("card")}>▦ 卡片</button>
+      </div>}>
       {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>}
+       filtered.length===0? <p className="muted">暂无方案,点击右上角「+ 新增领域方案」创建</p> :
+       view==="list"? (
+        <table className="data-table"><thead><tr><th>方案名称</th><th>所属领域</th><th>版本</th><th>状态</th><th>创建人</th><th>设备/页面/文档</th><th>操作</th></tr></thead>
+        <tbody>
+          {filtered.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>
+       ) : (
+        <div className="domain-card-grid">{filtered.map((t:any)=>(
+          <article key={t.id} className="domain-card" onClick={()=>onOpen(t.id)}>
+            <header><h3>{t.name}</h3><Status>{t.status==="available"?"可用":"不可用"}</Status></header>
+            <span className="badge">{t.domain_name}</span>
+            <div className="meta"><span>版本 <b>{t.version}</b></span><span>创建人 <b>{t.creator||"—"}</b></span></div>
+            <div className="meta"><span>设备 <b>{t.device_count}</b></span><span>页面 <b>{t.page_count}</b></span><span>文档 <b>{t.doc_count}</b></span></div>
+            <footer className="card-actions"><button className="text-button" onClick={e=>{e.stopPropagation();onOpen(t.id);}}>编辑</button><button className="text-button" onClick={e=>{e.stopPropagation();del(t.id);}}>删除</button></footer>
+          </article>
+        ))}</div>
+       )}
     </Panel>
   </>;
 }
@@ -1023,12 +1047,12 @@ function CreateTemplate({doms,notify,onCreated}:{doms:any[];notify:(m:string)=>v
   </div></Panel>;
 }
 
-function DeviceCard({dev,doms,tmplId,notify,onChange}:{dev:any;doms:any[];tmplId:number;notify:(m:string)=>void;onChange:()=>void}){
+function DeviceTemplateCard({dev,doms,notify,onChange}:{dev:any;doms:any[];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 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,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})}); 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(); };
@@ -1050,10 +1074,61 @@ function DeviceCard({dev,doms,tmplId,notify,onChange}:{dev:any;doms:any[];tmplId
   </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 DeviceTemplateCreate({doms,notify,onDone,onCancel}:{doms:any[];notify:(m:string)=>void;onDone:(id:number)=>void;onCancel:()=>void}){
+  const [name,setName]=useState(""); const [description,setDesc]=useState("");
+  const [domainIds,setDomainIds]=useState<number[]>(doms[0]?[doms[0].id]:[]);
+  const add=async()=>{ if(!name.trim()){ notify("请填写设备名称"); return; } try{ const r:any=await fetchDb(`/device-templates`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name,description,domain_ids:domainIds})}); notify("已创建设备模版"); onDone(r.id); }catch(e:any){ notify("创建失败:"+e.message); } };
+  return <Panel title="新增设备模版" tools={<button className="text-button" onClick={onCancel}>取消</button>}><div className="form-grid">
+    <label>设备名称<input value={name} onChange={e=>setName(e.target.value)} placeholder="如:PLC 控制器"/></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 style={{gridColumn:"1 / -1"}}><Button onClick={add}>创建</Button></div>
+  </div></Panel>;
+}
+
+function DeviceTemplates({notify,domainOptions}:{notify:(m:string)=>void;domainOptions:{id:number;name:string}[]}){
+  const [list,setList]=useState<any[]>([]);
+  const [loading,setLoading]=useState(true);
+  const [creating,setCreating]=useState(false);
+  const load=async()=>{ try{ setList(await fetchDb("/device-templates") as any); }catch(e:any){ notify("加载失败:"+e.message); } finally{ setLoading(false); } };
+  useEffect(()=>{ load(); },[]);
+  return <>
+    <div className="domain-hero"><div><span className="eyebrow">DEVICE TEMPLATE LIBRARY</span><h2>设备模版</h2><p>独立维护设备模版及其采集点,编辑后可在领域方案中按需选用。</p></div></div>
+    <Panel title="设备模版" tools={creating?null:<Button onClick={()=>setCreating(true)}>+ 新增设备模版</Button>}>
+      {creating && <DeviceTemplateCreate doms={domainOptions} notify={notify} onDone={()=>{setCreating(false);load();}} onCancel={()=>setCreating(false)}/>}
+      {loading? <p>加载中…</p> :
+       list.length===0 && !creating? <p className="muted">暂无设备模版,点击「+ 新增设备模版」创建。</p> :
+       <div className="device-list">{list.map((d:any)=><DeviceTemplateCard key={d.id} dev={d} doms={domainOptions} notify={notify} onChange={load}/>)}</div>}
+    </Panel>
+  </>;
+}
+
+function DeviceTemplatePicker({tmplId,go,selectedIds,notify,onChange}:{tmplId:number;go:(p:PageKey)=>void;selectedIds:number[];notify:(m:string)=>void;onChange:()=>void}){
+  const [all,setAll]=useState<any[]>([]);
+  const [sel,setSel]=useState<Set<number>>(new Set(selectedIds));
+  useEffect(()=>{ setSel(new Set(selectedIds)); },[selectedIds.join(",")]);
+  useEffect(()=>{ fetchDb("/device-templates").then((d:any)=>setAll(d||[])).catch(()=>{}); },[]);
+  const toggle=async(id:number,checked:boolean)=>{
+    const next=new Set(sel); checked?next.add(id):next.delete(id); setSel(next);
+    try{ await fetchDb(`/domain-templates/${tmplId}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({device_template_ids:[...next]})}); notify(checked?"已选用设备模版":"已移除设备模版"); onChange(); }
+    catch(e:any){ notify("保存失败:"+e.message); const back=new Set(sel); checked?back.delete(id):back.add(id); setSel(back); }
+  };
+  return <>
+    <div className="device-picker">
+      {all.length===0? <p className="muted">暂无设备模版,点击下方「+ 新增设备模版」创建后选用。</p> :
+        all.map((d:any)=>{ let doms:any[]=[]; try{ doms=JSON.parse(d.domain_names||"[]"); }catch{} return (
+          <label key={d.id} className={"picker-item"+(sel.has(d.id)?" on":"")}>
+            <input type="checkbox" checked={sel.has(d.id)} onChange={e=>toggle(d.id,e.target.checked)}/>
+            <span className="picker-name">{d.name}</span>
+            <span className="picker-doms">{(doms.length?doms.join("、"):"未归类")}</span>
+          </label>
+        )})}
+
+    </div>
+    <div style={{marginTop:10}}><a className="text-button" href="#" onClick={e=>{e.preventDefault();go("deviceTemplates");}}>+ 新增设备模版</a></div>
+  </>;
 }
 
 function PageRow({p,notify,onChange}:{p:any;notify:(m:string)=>void;onChange:()=>void}){
@@ -1101,10 +1176,9 @@ function DomainDetail({id,go,notify,onBack,onCreated}:{id:number|null;go:(p:Page
       <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.devices.length})`}>
+      <DeviceTemplatePicker tmplId={id} go={go} selectedIds={t.devices.map((d:any)=>d.id)} notify={notify} onChange={load}/>
+    </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>
index feeb85a972420f3d9b26fb24e7de24917d4836fa..f46b3c52e648b2067d0c43e45924a76a04c27b48 100644 (file)
@@ -1651,6 +1651,13 @@ async function handleDomainTemplateUpdate(req, res, id) {
   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);
+  if (body.device_template_ids !== undefined) {
+    db.prepare("DELETE FROM device_template_domain_templates WHERE domain_template_id=?").run(id);
+    for (const devId of Array.isArray(body.device_template_ids) ? body.device_template_ids : []) {
+      if (db.prepare("SELECT 1 FROM device_templates WHERE id=?").get(Number(devId)))
+        db.prepare("INSERT OR IGNORE INTO device_template_domain_templates (device_template_id, domain_template_id) VALUES (?,?)").run(Number(devId), id);
+    }
+  }
   respondJson(res, 200, { ok: true, id });
 }
 
diff --git a/public/design-doc-assets/placeholder.svg b/public/design-doc-assets/placeholder.svg
new file mode 100644 (file)
index 0000000..301ccaa
--- /dev/null
@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
+  <rect width="960" height="540" fill="#f4f5f6"/>
+  <rect x="0.5" y="0.5" width="959" height="539" fill="none" stroke="#cfd4d9"/>
+  <g font-family="Arial,Microsoft YaHei,sans-serif" fill="#9aa0a6" text-anchor="middle">
+    <text x="480" y="262" font-size="28">页面截图占位</text>
+    <text x="480" y="300" font-size="14">将实际截图保存为 PNG 放入 public/design-doc-assets/ 后替换此处</text>
+  </g>
+</svg>
index 8a2e6ab4986e8e007a949d524c99f964f35bacc5..46af0ffc3ca28f2ca91a856e6a98bd7ff2c75c17 100644 (file)
   h2,h3,h4{ scroll-margin-top:64px; }
   @media (max-width:760px){ .doc-nav{ display:none; } }
   @media print{ .doc-toolbar, .doc-nav{ display:none; } main#doc{ max-width:none; padding:0; } }
+  /* 页面截图(参考 MachineCube 手册撰写方式:小节标题 + 说明 + 下图 + 配图 + 图注)*/
+  body{ counter-reset: fig; }
+  .figure{ margin:14px 0; border:1px solid var(--line); background:#fbfcfd; }
+  .figure img{ display:block; width:100%; height:auto; border-bottom:1px solid var(--line); }
+  .figure figcaption{ counter-increment: fig; padding:9px 12px; font-size:11px; color:#555; line-height:1.6; }
+  .figure figcaption::before{ content:"图 " counter(fig) " "; font-weight:bold; color:#333; }
+  .figure.placeholder img{ min-height:180px; object-fit:cover; background:repeating-linear-gradient(45deg,#f1f3f5,#f1f3f5 12px,#e7eaed 12px,#e7eaed 24px); }
+  .shot-note{ font-size:10px; color:#999; }
+  .changelog{ width:100%; border-collapse:collapse; margin:6px 0 14px; }
+  .changelog th{ background:#f6f7f8; color:#4b4f53; text-align:left; font-size:11px; font-weight:bold; border-bottom:1px solid var(--line); padding:8px 10px; }
+  .changelog td{ border-bottom:1px solid #e3e5e7; padding:9px 10px; font-size:11px; vertical-align:top; }
+  .changelog td:first-child{ white-space:nowrap; font-weight:bold; color:#008ed2; }
 </style>
 </head>
 <body>
@@ -73,7 +85,7 @@
   <nav class="doc-nav"><div class="doc-nav-inner" id="toc"></div></nav>
 
 <main id="doc">
-  <p class="muted">文档版本:v0.3 最后更新:2026-08-18 状态:开发中(演示数据已标注)</p>
+  <p class="muted">文档版本:v0.6 最后更新:2026-08-18 修改人:wang.yu 状态:开发中(演示数据已标注)</p>
   <h1>StationBuilder 产品与交互设计方案</h1>
 
   <h2>1. 文档说明</h2>
@@ -81,6 +93,7 @@
   <ul>
     <li><b>约定</b>:标注 <span class="todo">尚未实现</span> 的项表示已在界面或方案中设计、但当前版本尚未打通能力(多为演示 / 占位)。</li>
     <li><b>维护</b>:本文件为单一 HTML 源(<code>public/design-doc.html</code>),后续功能改动需同步更新对应章节,未实现项持续标注,直到能力落地后移除标记。</li>
+    <li><b>截图</b>:可按 MachineCube 手册的撰写方式,在相关章节插入页面截图——小节标题 + 一句话说明 +「下图」引用 + 配图 + 图注。图片统一放在 <code>public/design-doc-assets/</code> 目录(PNG),在文中以 <code>&lt;figure&gt;&lt;img src="design-doc-assets/xxx.png"&gt;&lt;figcaption&gt;…&lt;/figcaption&gt;&lt;/figure&gt;</code> 插入;图注会自动编号(图 1 / 图 2 …)。每次内容修改需同步升级文档小版本号并登记「版本变更记录」。</li>
     <li><b>导出</b>:顶部按钮分别导出 PDF(调用浏览器打印)、Word(<code>.doc</code> 兼容格式)、Markdown(由本页内容实时转换)。</li>
     <li><b>目录</b>:页面左侧为章节目录(自动生成),点击可跳转至对应章节;滚动阅读时自动高亮当前所在章节。</li>
   </ul>
 │           ├─ 站点结果        → result
 │           └─ 页面预览        → preview
 │
-├─ 【领域方案库模块时】(domains / domainDetail)
+├─ 【领域方案库模块时】(domains / domainDetail / deviceTemplates)
 │   ├─ 方案库导航
-│   │   ├─ 全部领域方案        → domains(方案列表)
-│   │   └─ 方案详情            → domainDetail(同列表;在列表中点击某方案「编辑」打开详情编辑器)
-│   └─ 领域分类(当前以「水务 · 自来水厂」为示例激活项,仅作分组展示)
-│       ├─ 燃气                → 提示(占位)
-│       ├─ 水务 · 自来水厂     → domainDetail
-│       ├─ 新能源              → 提示(占位)
-│       └─ 数据中心            → 提示(占位)
+│   │   ├─ 领域方案            → domains(方案列表,支持列表/卡片切换;点击具体方案打开 domainDetail)
+│   │   └─ 设备模版            → deviceTemplates(独立页面,编辑设备模版与采集点,供方案选用)
+│   └─ 领域分类(动态二级菜单:来自 GET /api/domains;点击即按该领域筛选方案列表)
+│       ├─ (领域 A)          → domains + domainFilter=领域A(自动列出该领域全部方案)
+│       ├─ (领域 B)          → domains + domainFilter=领域B
+│       └─ …(按数据库 domains 表动态渲染)
 │
 └─ 【页面样式库模块时】(styleLibrary)
     └─ 样式库导航
     <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>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/domain-templates、GET /api/domains</td><td>点击 go("domains");列表页右上角可在「列表 / 卡片」间切换;点击某方案(列表「编辑」或卡片本身)以同页内嵌编辑器打开详情(<code>openDomainTemplateId</code> state 控制)。「+ 新增领域方案」切到新增表单;「清除领域筛选」返回全部</td></tr>
+    <tr><td>设备模版</td><td>领域方案库 / 方案库导航</td><td>独立页面,编辑设备模版与采集点</td><td>GET /api/db/device-templates</td><td>点击 go("deviceTemplates");列表展示全部设备模版,可新增 / 编辑 / 删除;每个设备模版维护名称 / 说明 / 图片 / 所属领域(多选) / 采集点。设备模版在此独立维护,方案详情中通过「选用设备模版」勾选引用</td></tr>
+    <tr><td>领域分类(动态)</td><td>领域方案库 / 领域分类</td><td>按领域筛选方案列表(二级菜单)</td><td>GET /api/domains(动态渲染)</td><td>点击某领域 → go("domains") 且 <code>domainFilter</code>=该领域 id,列表自动只显示该领域方案;当前领域高亮;「清除领域筛选」返回全部</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>
+  <p>领域方案库已从硬编码演示改为<strong>真实数据库驱动</strong>。列表与详情编辑器同一页面内通过 <code>openDomainTemplateId</code> state 切换:<code>null</code>=列表,<code>"create"</code>=新增表单,<code>number</code>=该方案详情编辑器。列表支持<strong>列表 / 卡片两种视图</strong>切换(右上角视图切换按钮,<code>view</code> state 控制);点击左侧「领域分类」下的某个领域会按 <code>domainFilter</code> 自动筛选该领域方案。</p>
+  <p>下图展示了领域方案库列表与「+ 新增领域方案」入口:</p>
+  <figure class="placeholder"><img src="design-doc-assets/placeholder.svg" alt="领域方案库列表"/><figcaption>领域方案库:方案列表与新增入口(示意图,待替换为实际截图)。</figcaption></figure>
   <table>
     <tr><th>元素</th><th>功能</th><th>数据来源</th><th>交互效果</th></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>
 
   <h3>5.10 方案详情(domainDetail / 内嵌编辑器)</h3>
   <p>详情为同一页面内嵌编辑器,所有内容均可编辑,数据持久化到领域方案库相关表。各面板如下:</p>
+  <p>下图展示了一个领域方案的详情编辑器(模板信息 + 设备模板 + 领域页面等面板):</p>
+  <figure class="placeholder"><img src="design-doc-assets/placeholder.svg" alt="方案详情编辑器"/><figcaption>领域方案详情编辑器(示意图,待替换为实际截图)。</figcaption></figure>
   <table>
     <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>选用设备模版(DeviceTemplatePicker)</td><td>从「设备模版」页中勾选本方案要用的设备模版</td><td>详情 devices[](已选用的设备模版 id 列表);GET /api/db/device-templates(全部可选);PUT /api/db/domain-templates/:id(传 <code>device_template_ids</code>,服务端重写 device_template_domain_templates 关联)</td><td>勾选即保存:PUT 携带当前全部选中 id,服务端先 DELETE 旧关联再按 id 重建;未找到合适模版可点「+ 新增设备模版」跳到 deviceTemplates 页新建。设备模版的编辑(名称/说明/图片/所属领域/采集点)已移至独立的「设备模版」页,不再在本面板内联编辑</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>
     <li>真实登录 / 账号体系(当前为演示登录) <span class="todo">尚未实现</span></li>
     <li>领域方案库真实数据 CRUD(领域模板 / 设备模板 / 采集点 / 领域页面 / 模板菜单 / 知识文档)已落地;「直接使用 / 加入站点」将从方案一键建站 <span class="todo">尚未实现</span></li>
     <li>运维流程内容编辑(当前仅占位记录) <span class="todo">尚未实现</span></li>
-    <li>é¢\86å\9f\9fæ\96¹æ¡\88æ\8c\89é¢\86å\9f\9få\88\86ç±»ç­\9bé\80\89ï¼\88å½\93å\89\8då\88\86ç±»ä»\85ä½\9cå\88\86ç»\84å±\95示ï¼\89 <span class="todo">å°\9aæ\9cªå®\9eç\8e°</span></li>
+    <li>é¢\86å\9f\9fæ\96¹æ¡\88æ\8c\89é¢\86å\9f\9få\88\86ç±»ç­\9bé\80\89ï¼\88左侧ã\80\8cé¢\86å\9f\9få\88\86ç±»ã\80\8då\8a¨æ\80\81äº\8c级è\8f\9cå\8d\95ï¼\8cç\82¹å\87»å\8d³æ\8c\89 domainFilter ç­\9bé\80\89æ\96¹æ¡\88å\88\97表ï¼\89</li>
     <li>Asset Excel 生成与下载 <span class="todo">尚未实现</span></li>
     <li>资产三级层级建模(当前为展示占位) <span class="todo">尚未实现</span></li>
     <li>页面预览中「站点监控」真实数据接入(当前为静态演示) <span class="todo">尚未实现</span></li>
 
   <h2>11. 维护与导出说明</h2>
 
-  <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>
-    <li>2026-08-18 新增「AI 智能补全」功能:站点基本属性页「填写建议」下方新增面板,点击「开始补全」后基于站点名称 / 所属领域 / 站点基本情况描述,由 AI 流式分析并自动写入「补充要求」字段;「查看提示词」可查看完整组合提示词(后端 <code>POST /api/db/sites/:id/complete</code> 流式、<code>GET .../complete-prompt</code> 取提示词)。</li>
-    <li>2026-08-18 扩展「AI 智能补全」至 Asset 资产设置页:右侧新增与基本属性同款的「填写建议 + AI 智能补全」栏;点击「开始补全」后基于站点名称 / 领域 / 描述 / 补充要求分析并补充设备类型、数量预设与命名规则,追加到用户已填项下方(不改动已有项),资产层级预览随之重算(后端 <code>POST /api/db/sites/:id/complete-assets</code> 流式、<code>GET .../complete-assets-prompt</code> 取提示词)。</li>
-    <li>2026-08-18 重构「功能与菜单」页:① MU 标准页面改为树状上下结构、仅保留勾选框、默认全选中;② Cube 扩展页面右侧新增「填写建议 + AI 智能补全」栏,点击「开始补全」后基于站点属性与设备资产层级由 AI 推断并补充 Cube 扩展页面(名称 + 详尽内容),追加到用户已建页面下方(不改动用户内容)(后端 <code>POST /api/db/sites/:id/complete-cubes</code> 流式、<code>GET .../complete-cubes-prompt</code> 取提示词);③ 菜单结构改为只读默认预览:MU 页面在上,所有 Cube 页面归入「综合监控」一级菜单、二级为各 Cube 名称,仅展示无编辑。</li>
-    <li>2026-08-18 修复整站生成超时:将「生成确认」页全站生成由无上限 <code>Promise.all</code> 并行改为最多 3 个并发(<code>mapWithConcurrency</code>),避免 9 个 Cube 同时抢占 CPU/模型 API 资源导致部分页面 300s 超时失败;超时阈值维持 300s 不变,单页失败不再自动重试,仅提示用户到「功能与菜单」对该页面单独点「生成」重试。</li>
-    <li>2026-08-18 修复生成看板导入 Cube 后大面积空白(含「分辨率不是 1920×1080」的错觉,实为空白导致观感异常;JSON 内 pageWidth/pageHeight 始终为 1920/1080 正确)。根因:生成提示词 <code>DASHBOARD_SPEC</code> 仅含糊要求「include proper chartConfig」,模型据此把图表配置整体套一层 <code>custom.chartConfig</code> 外壳,且自造 StatusLight / ParameterCard / Table / GeoMap 的 custom 结构,而 Cube 工具要求的是 dashboard-json-generator 各面板模板的精确结构(Bar/LineChart/HeatMap 为扁平 <code>custom:{config,series,xAxis,yAxis,legend,grid,dataZoom}</code>;PieChart/RadarChart/ProgressRing/GeoMap 为 <code>custom:{dataConfig,chartConfig}</code> 等)。修复:(1) <code>DASHBOARD_SPEC</code> 改为强制要求每个面板逐项读取模板文件 <code>/root/.codebuddy/skills/dashboard-json-generator/assets/templates/&lt;type&gt;.json</code> 并原样照抄其 <code>custom</code> 结构,明确扁平 vs 包裹规则及 StatusLight/ParameterCard/Table/GeoMap 的字段;(2) <code>fixDashboard</code> 增加 <code>normalizePanelCustom</code> 兜底:把 <code>custom.chartConfig</code> 拍平为扁平 custom、StatusLight 的 statusMap→configs、ParameterCard 的 items→tableCnt/rowCnt/style、Table 的 columns 补成可渲染结构、GeoMap 补全 dataConfig 绑定并在存在 <code>data/db/china-geo.json</code> 时注入中国地图 geoJson。存量 gen_files 已用该兜底重写。</li>
-  </ul>
+  <h3>11.1 版本变更记录(Changelog)</h3>
+  <table class="changelog">
+    <tr><th>版本</th><th>日期</th><th>修改人</th><th>说明</th></tr>
+    <tr><td>v0.6</td><td>2026-08-18</td><td>wang.yu</td><td>领域方案库重构为多菜单:列表页支持「列表 / 卡片」视图切换与按领域分类筛选;新增独立的「设备模版」页(前端 DeviceTemplates,独立维护设备模版与采集点);方案详情的设备面板改为「选用设备模版」(DeviceTemplatePicker,勾选即重写 device_template_domain_templates 关联,未找到可跳转新增);领域分类改为由 GET /api/domains 动态渲染的二级菜单。后端 domain-templates 更新支持 device_template_ids 重关联。同步更新 4.2 / 5.9 / 5.10 章节。</td></tr>
+    <tr><td>v0.5</td><td>2026-08-18</td><td>wang.yu</td><td>设计方案支持页面截图:新增 figure / 图注 样式(自动编号),撰写方式参考 MachineCube 手册(小节标题 + 说明 +「下图」引用 + 配图 + 图注);图片置于 public/design-doc-assets/。新增「版本变更记录」,每次修改升级小版本号并登记修改人 / 日期。</td></tr>
+    <tr><td>v0.4</td><td>2026-08-18</td><td>wang.yu</td><td>领域方案库升级为真实数据库驱动 CRUD:新增 schema(领域模板 / 设备模板 / 采集点 / 领域页面 / 模板菜单 / 知识文档 / 运维流程占位);全套接口(/api/domain-templates、/api/device-templates、/api/collection-points、/api/domain-pages、/api/template-menu-items、/api/knowledge-docs、/api/ops-flows);前端列表 + 内嵌编辑器,openDomainTemplateId 控制 列表·新增·详情 切换。</td></tr>
+    <tr><td>v0.3</td><td>2026-08-18</td><td>wang.yu</td><td>基线版本:整站生成超时修复(提示词精简 + 最多 2 次重试 + 超时 300s→480s)、AI 智能补全(基本属性 / 资产 / 功能与菜单三页)、功能与菜单重构、导入 Cube 后空白修复(flatten custom + normalizePanelCustom)、下载 Markdown 兼容 Azure DevOps Wiki。运维流程 / 领域分类筛选 / 从方案一键建站等仍标注「尚未实现」。</td></tr>
+  </table>
 
   <ul>
     <li>功能变更后请同步更新本文档对应章节,未实现项持续标注 <span class="todo">尚未实现</span>,落地后移除。</li>